Coverage for pycommons / types.py: 100%
71 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-11 03:04 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-11 03:04 +0000
1"""Some basic type handling routines."""
2from typing import Any, Iterable
5def type_name(tpe: type | None) -> str:
6 """
7 Convert a type to a string which represents its name.
9 :param tpe: the type
10 :returns: the string
12 >>> type_name(None)
13 'None'
14 >>> type_name(type(None))
15 'None'
16 >>> type_name(int)
17 'int'
18 >>> from pycommons.io.path import file_path, Path
19 >>> type_name(Path)
20 'pycommons.io.path.Path'
21 >>> from typing import Callable
22 >>> type_name(Callable)
23 'typing.Callable'
24 >>> from typing import Callable as Ca
25 >>> type_name(Ca)
26 'typing.Callable'
27 >>> from typing import Callable as Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
28 >>> type_name(Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa)
29 'typing.Callable'
30 >>> import typing as ttttttttttttttttttttttttttttttttttttttttttt
31 >>> type_name(ttttttttttttttttttttttttttttttttttttttttttt.Callable)
32 'typing.Callable'
33 """
34 if tpe is None:
35 return "None"
36 c1: str = str(tpe)
37 if c1.startswith("<class '"):
38 c1 = c1[8:-2]
39 if c1 == "NoneType":
40 return "None"
41 if hasattr(tpe, "__qualname__"):
42 c2: str = tpe.__qualname__
43 if hasattr(tpe, "__module__"):
44 module = tpe.__module__
45 if (module is not None) and (module != "builtins"):
46 c2 = f"{module}.{c2}"
47 if len(c2) >= len(c1):
48 return c2
49 return c1 # will probably never happen
52def type_name_of(obj: Any) -> str:
53 """
54 Get the fully-qualified class name of an object.
56 :param obj: the object
57 :returns: the fully-qualified class name of the object
59 >>> from pycommons.io.path import Path, file_path
60 >>> type_name_of(Path)
61 'type'
62 >>> type_name_of(file_path(__file__))
63 'pycommons.io.path.Path'
64 >>> type_name_of(None)
65 'None'
66 >>> type_name_of(int)
67 'type'
68 >>> type_name_of(print)
69 'builtin_function_or_method'
70 >>> from typing import Callable
71 >>> type_name_of(Callable)
72 'typing._CallableType'
73 >>> from math import sin
74 >>> type_name_of(sin)
75 'builtin_function_or_method'
76 >>> import pycommons.io as iox
77 >>> type_name_of(iox)
78 'module'
79 """
80 if obj is None:
81 return "None"
82 if isinstance(obj, type):
83 return "type"
84 return type_name(type(obj))
87def type_error(obj: Any, name: str,
88 expected: type | Iterable[type] | None = None,
89 call: bool = False) -> ValueError | TypeError:
90 """
91 Create an error to raise if a type did not fit.
93 This eror contains information about the object name, the expected type,
94 the actual type, and, in some cases, the actual value of the object. This
95 should help tracing down what went wrong.
97 We *sometimes* include the actual value of the object. This happens if
98 the object is an `int`, `float`, or `bool`. If the object is a `str`, then
99 we include at most the first 32 characters. If the ojbect is a `list`,
100 `tuple`, `set`, `dict`, or `frozenset`, then we include its length.
102 In previous versions of this function, we always included the full
103 representation of the object. However, this might lead to very lengthy
104 output and could even cause an out-of-memory exception. So we now focus on
105 the above classes only.
107 Since one might still try to cause some mischief by overriding the
108 `__str__` or `__len__` methods of these objects, we force that the methods
109 of the base classes are used, which looks a bit odd in the code but should
110 at least somewhat help preventing issues.
112 :param obj: the object that is of the wrong type
113 :param name: the name of the object
114 :param expected: the expected types (or `None`)
115 :param call: the object should have been callable?
116 :returns: a :class:`TypeError` with a descriptive information
118 >>> type_error(1.3, "var", int)
119 TypeError('var should be an instance of int but is float, namely 1.3.')
120 >>> type_error("x", "z", (int, float)).args[0]
121 "z should be an instance of any in {float, int} but is str, namely 'x'."
122 >>> type_error("x", "z", (int, float, None)).args[0]
123 "z should be an instance of any in {None, float, int} but is str, namely \
124'x'."
125 >>> type_error("x", "z", (int, float, type(None))).args[0]
126 "z should be an instance of any in {None, float, int} but is str, namely \
127'x'."
128 >>> type_error("f", "q", call=True).args[0]
129 "q should be a callable but is str, namely 'f'."
130 >>> type_error("1", "2", bool, call=True).args[0]
131 "2 should be an instance of bool or a callable but is str, namely '1'."
132 >>> type_error(None, "x", str)
133 TypeError('x should be an instance of str but is None.')
134 >>> type_error("123456789012345678901234567890123456789", "var", int)
135 TypeError("var should be an instance of int but is str, namely \
136'123456789012345678901234567890...'.")
137 >>> type_error("12345678901234567890123456789012", "var", int)
138 TypeError("var should be an instance of int but is str, namely \
139'12345678901234567890123456789012'.")
140 >>> type_error("123456789012345678901234567890123", "var", int)
141 TypeError("var should be an instance of int but is str, namely \
142'12345678901234567890123456789...'.")
143 >>> type_error([1], "var", int)
144 TypeError('var should be an instance of int but is list of length 1.')
145 >>> type_error({2, 3}, "var", int)
146 TypeError('var should be an instance of int but is set of length 2.')
147 >>> type_error((1, 2, 3), "var", int)
148 TypeError('var should be an instance of int but is tuple of length 3.')
149 >>> type_error({}, "var", int)
150 TypeError('var should be an instance of int but is dict of length 0.')
151 >>> type_error(frozenset((23, )), "var", int)
152 TypeError('var should be an instance of int but is frozenset of \
153length 1.')
154 >>> type_error(1, "var", list)
155 TypeError('var should be an instance of list but is int, namely 1.')
156 >>> type_error(1.3, "var", list)
157 TypeError('var should be an instance of list but is float, namely 1.3.')
158 >>> type_error(True, "var", list)
159 TypeError('var should be an instance of list but is bool, namely True.')
160 >>> type_error(ValueError("x"), "var", list)
161 TypeError('var should be an instance of list but is ValueError.')
162 >>> type_error(None, "var", list)
163 TypeError('var should be an instance of list but is None.')
164 """
165 exp: str = ""
166 if isinstance(expected, Iterable):
167 exp = ", ".join(sorted(map(type_name, expected)))
168 exp = f"an instance of any in {{{exp}}}"
169 elif expected is not None:
170 exp = f"an instance of {type_name(expected)}"
171 if call:
172 exp = f"{exp} or a callable" if exp else "a callable"
174 message: str
175 if obj is None:
176 message = "None"
177 else:
178 message = type_name_of(obj)
179 if isinstance(obj, bool):
180 message = f"{message}, namely {bool.__str__(obj)}"
181 elif isinstance(obj, int):
182 message = f"{message}, namely {int.__str__(obj)}"
183 elif isinstance(obj, float):
184 message = f"{message}, namely {float.__str__(obj)}"
185 elif isinstance(obj, str):
186 strlen: int = str.__len__(obj)
187 if strlen > 32: # take care of strings that are too long
188 obj = str.__getitem__(obj, slice(0, 30, 1)) + "..."
189 message = f"{message}, namely {str.__str__(obj)!r}"
190 elif isinstance(obj, list):
191 message = f"{message} of length {list.__len__(obj)}"
192 elif isinstance(obj, tuple):
193 message = f"{message} of length {tuple.__len__(obj)}"
194 elif isinstance(obj, set):
195 message = f"{message} of length {set.__len__(obj)}"
196 elif isinstance(obj, dict):
197 message = f"{message} of length {dict.__len__(obj)}"
198 elif isinstance(obj, frozenset):
199 message = f"{message} of length {frozenset.__len__(obj)}"
200 message = f"{name} should be {exp} but is {message}."
202 return TypeError(message)
205def check_int_range(val: Any, name: str | None = None,
206 min_value: int | float = 0,
207 max_value: int | float = 1_000_000_000) -> int:
208 """
209 Check whether a value `val` is an integer in a given range.
211 Via type annotation, this method actually accepts a value `val` of any
212 type as input. However, if `val` is not an instance of `int`, it will
213 throw an error. Also, if `val` is not in the prescribed range, it will
214 throw an error, too. By default, the range is `0...1_000_000_000`.
216 I noticed that often, we think that only want to check a lower limit
217 for `val`, e.g., that a number of threads or a population size should be
218 `val > 0`. However, in such cases, there also always a reasonable upper
219 limits. We never actually want an EA to have a population larger than,
220 say, 1_000_000_000. That would make no sense. So indeed, whenever we have
221 a lower limit for a parameter, we also should have an upper limit
222 resulting from physical constraints. 1_000_000_000 is a reasonably sane
223 upper limit in many situations. If we need smaller or larger limits, we
224 can of course specify them.
226 Notice that there is one strange border case: In Python, `bool` is a
227 subtype of `int`, where `True` has value `1` and `False` has value `0`.
228 See <https://docs.python.org/3/library/functions.html#bool>.
229 We therefore treat `bool` values indeed as instances of `int`.
231 :param val: the value to check
232 :param name: the name of the value, or `None`
233 :param min_value: the minimum permitted value
234 :param max_value: the maximum permitted value
235 :returns: `val` if everything is OK
236 :raises TypeError: if `val` is not an `int`
237 :raises ValueError: if `val` is an `int` but outside the prescribed range
239 >>> try:
240 ... print(check_int_range(12, min_value=7, max_value=13))
241 ... except (ValueError, TypeError) as err:
242 ... print(err)
243 12
245 >>> try:
246 ... print(check_int_range(123, min_value=7, max_value=13))
247 ... except (ValueError, TypeError) as err:
248 ... print(err)
249 ... print(err.__class__)
250 Value=123 is invalid, must be in 7..13.
251 <class 'ValueError'>
253 >>> try:
254 ... print(check_int_range(5.0, name="ThisIsFloat"))
255 ... except (ValueError, TypeError) as err:
256 ... print(err)
257 ... print(err.__class__)
258 ThisIsFloat should be an instance of int but is float, namely 5.0.
259 <class 'TypeError'>
261 The behavior in the border case of `bool` instances actually also being
262 instances of `int`:
264 >>> check_int_range(True, "true", 0, 2)
265 True
267 >>> check_int_range(False, "false", 0, 2)
268 False
270 >>> try:
271 ... print(check_int_range(True, min_value=7, max_value=13))
272 ... except (ValueError, TypeError) as err:
273 ... print(err)
274 ... print(err.__class__)
275 Value=True is invalid, must be in 7..13.
276 <class 'ValueError'>
277 """
278 if not isinstance(val, int):
279 raise type_error(val, "value" if name is None else name, int)
280 if min_value <= val <= max_value:
281 return val
282 raise ValueError(f"{'Value' if name is None else name}={val!r} is "
283 f"invalid, must be in {min_value}..{max_value}.")
286def check_to_int_range(val: Any, name: str | None = None,
287 min_value: int | float = 0,
288 max_value: int | float = 1_000_000_000) -> int:
289 """
290 Check whether a value `val` can be converted an integer in a given range.
292 :param val: the value to convert via `int(...)` and then to check
293 :param name: the name of the value, or `None`
294 :param min_value: the minimum permitted value
295 :param max_value: the maximum permitted value
296 :returns: `val` if everything is OK
297 :raises TypeError: if `val` is `None`
298 :raises ValueError: if `val` is not `None` but can either not be converted
299 to an `int` or to an `int` outside the prescribed range
301 >>> try:
302 ... print(check_to_int_range(12))
303 ... except (ValueError, TypeError) as err:
304 ... print(err)
305 12
307 >>> try:
308 ... print(check_to_int_range(12.0))
309 ... except (ValueError, TypeError) as err:
310 ... print(err)
311 12
313 >>> try:
314 ... print(check_to_int_range("12"))
315 ... except (ValueError, TypeError) as err:
316 ... print(err)
317 12
319 >>> try:
320 ... print(check_to_int_range("A"))
321 ... except (ValueError, TypeError) as err:
322 ... print(err)
323 ... print(err.__class__)
324 Cannot convert value='A' to int, let alone in range 0..1000000000.
325 <class 'ValueError'>
327 >>> try:
328 ... print(check_to_int_range(None))
329 ... except (ValueError, TypeError) as err:
330 ... print(err)
331 ... print(err.__class__)
332 Cannot convert value=None to int, let alone in range 0..1000000000.
333 <class 'TypeError'>
334 """
335 try:
336 conv = int(val)
337 except (ValueError, TypeError) as errx:
338 raise (ValueError if isinstance(errx, ValueError) else TypeError)(
339 f"Cannot convert {'value' if name is None else name}={val!r} "
340 f"to int, let alone in range {min_value}..{max_value}.") from errx
341 return check_int_range(conv, name, min_value, max_value)