"""Some basic type handling routines."""
from typing import Any, Final, Iterable, Iterator, TypeVar
[docs]
def type_name(tpe: type | None) -> str:
"""
Convert a type to a string which represents its name.
:param tpe: the type
:returns: the string
>>> type_name(None)
'None'
>>> type_name(type(None))
'None'
>>> type_name(int)
'int'
>>> from pycommons.io.path import file_path, Path
>>> type_name(Path)
'pycommons.io.path.Path'
>>> from typing import Callable
>>> type_name(Callable)
'typing.Callable'
>>> from typing import Callable as Ca
>>> type_name(Ca)
'typing.Callable'
>>> from typing import Callable as Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
>>> type_name(Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa)
'typing.Callable'
>>> import typing as ttttttttttttttttttttttttttttttttttttttttttt
>>> type_name(ttttttttttttttttttttttttttttttttttttttttttt.Callable)
'typing.Callable'
"""
if tpe is None:
return "None"
c1: str = str(tpe)
if c1.startswith("<class '"):
c1 = c1[8:-2]
if c1 == "NoneType":
return "None"
if hasattr(tpe, "__qualname__"):
c2: str = tpe.__qualname__
if hasattr(tpe, "__module__"):
module = tpe.__module__
if (module is not None) and (module != "builtins"):
c2 = f"{module}.{c2}"
if len(c2) >= len(c1):
return c2
return c1 # will probably never happen
[docs]
def type_name_of(obj: Any) -> str:
"""
Get the fully-qualified class name of an object.
:param obj: the object
:returns: the fully-qualified class name of the object
>>> from pycommons.io.path import Path, file_path
>>> type_name_of(Path)
'type'
>>> type_name_of(file_path(__file__))
'pycommons.io.path.Path'
>>> type_name_of(None)
'None'
>>> type_name_of(int)
'type'
>>> type_name_of(print)
'builtin_function_or_method'
>>> from typing import Callable
>>> type_name_of(Callable)
'typing._CallableType'
>>> from math import sin
>>> type_name_of(sin)
'builtin_function_or_method'
>>> import pycommons.io as iox
>>> type_name_of(iox)
'module'
"""
if obj is None:
return "None"
if isinstance(obj, type):
return "type"
return type_name(type(obj))
[docs]
def type_error(obj: Any, name: str,
expected: None | type | Iterable[type] = None,
call: bool = False) -> ValueError | TypeError:
"""
Create an error to raise if a type did not fit.
:param obj: the object that is of the wrong type
:param name: the name of the object
:param expected: the expected types (or `None`)
:param call: the object should have been callable?
:returns: a :class:`TypeError` with a descriptive information
>>> type_error(1.3, "var", int)
TypeError("var should be an instance of int but is float, namely '1.3'.")
>>> type_error("x", "z", (int, float)).args[0]
"z should be an instance of any in {float, int} but is str, namely 'x'."
>>> type_error("x", "z", (int, float, None)).args[0]
"z should be an instance of any in {None, float, int} but is str, namely \
'x'."
>>> type_error("x", "z", (int, float, type(None))).args[0]
"z should be an instance of any in {None, float, int} but is str, namely \
'x'."
>>> type_error("f", "q", call=True).args[0]
"q should be a callable but is str, namely 'f'."
>>> type_error("1", "2", bool, call=True).args[0]
"2 should be an instance of bool or a callable but is str, namely '1'."
>>> type_error(None, "x", str)
TypeError('x should be an instance of str but is None.')
"""
exp: str = ""
if isinstance(expected, Iterable):
exp = ", ".join(sorted(map(type_name, expected)))
exp = f"an instance of any in {{{exp}}}"
elif expected is not None:
exp = f"an instance of {type_name(expected)}"
if call:
exp = f"{exp} or a callable" if exp else "a callable"
message: Final[str] = f"{name} should be {exp} but is None." \
if obj is None else \
(f"{name} should be {exp} but is {type_name_of(obj)}, "
f"namely {str(obj)!r}.")
return TypeError(message)
[docs]
def check_int_range(val: Any, name: str | None = None,
min_value: int | float = 0,
max_value: int | float = 1_000_000_000) -> int:
"""
Check whether a value `val` is an integer in a given range.
Via type annotation, this method actually accepts a value `val` of any
type as input. However, if `val` is not an instance of `int`, it will
throw an error. Also, if `val` is not in the prescribed range, it will
throw an error, too. By default, the range is `0...1_000_000_000`.
I noticed that often, we think that only want to check a lower limit
for `val`, e.g., that a number of threads or a population size should be
`val > 0`. However, in such cases, there also always a reasonable upper
limits. We never actually want an EA to have a population larger than,
say, 1_000_000_000. That would make no sense. So indeed, whenever we have
a lower limit for a parameter, we also should have an upper limit
resulting from physical constraints. 1_000_000_000 is a reasonably sane
upper limit in many situations. If we need smaller or larger limits, we
can of course specify them.
Notice that there is one strange border case: In Python, `bool` is a
subtype of `int`, where `True` has value `1` and `False` has value `0`.
See <https://docs.python.org/3/library/functions.html#bool>.
We therefore treat `bool` values indeed as instances of `int`.
:param val: the value to check
:param name: the name of the value, or `None`
:param min_value: the minimum permitted value
:param max_value: the maximum permitted value
:returns: `val` if everything is OK
:raises TypeError: if `val` is not an `int`
:raises ValueError: if `val` is an `int` but outside the prescribed range
>>> try:
... print(check_int_range(12, min_value=7, max_value=13))
... except (ValueError, TypeError) as err:
... print(err)
12
>>> try:
... print(check_int_range(123, min_value=7, max_value=13))
... except (ValueError, TypeError) as err:
... print(err)
... print(err.__class__)
Value=123 is invalid, must be in 7..13.
<class 'ValueError'>
>>> try:
... print(check_int_range(5.0, name="ThisIsFloat"))
... except (ValueError, TypeError) as err:
... print(err)
... print(err.__class__)
ThisIsFloat should be an instance of int but is float, namely '5.0'.
<class 'TypeError'>
The behavior in the border case of `bool` instances actually also being
instances of `int`:
>>> check_int_range(True, "true", 0, 2)
True
>>> check_int_range(False, "false", 0, 2)
False
>>> try:
... print(check_int_range(True, min_value=7, max_value=13))
... except (ValueError, TypeError) as err:
... print(err)
... print(err.__class__)
Value=True is invalid, must be in 7..13.
<class 'ValueError'>
"""
if not isinstance(val, int):
raise type_error(val, "value" if name is None else name, int)
if min_value <= val <= max_value:
return val
raise ValueError(f"{'Value' if name is None else name}={val!r} is "
f"invalid, must be in {min_value}..{max_value}.")
[docs]
def check_to_int_range(val: Any, name: str | None = None,
min_value: int | float = 0,
max_value: int | float = 1_000_000_000) -> int:
"""
Check whether a value `val` can be converted an integer in a given range.
:param val: the value to convert via `int(...)` and then to check
:param name: the name of the value, or `None`
:param min_value: the minimum permitted value
:param max_value: the maximum permitted value
:returns: `val` if everything is OK
:raises TypeError: if `val` is `None`
:raises ValueError: if `val` is not `None` but can either not be converted
to an `int` or to an `int` outside the prescribed range
>>> try:
... print(check_to_int_range(12))
... except (ValueError, TypeError) as err:
... print(err)
12
>>> try:
... print(check_to_int_range(12.0))
... except (ValueError, TypeError) as err:
... print(err)
12
>>> try:
... print(check_to_int_range("12"))
... except (ValueError, TypeError) as err:
... print(err)
12
>>> try:
... print(check_to_int_range("A"))
... except (ValueError, TypeError) as err:
... print(err)
... print(err.__class__)
Cannot convert value='A' to int, let alone in range 0..1000000000.
<class 'ValueError'>
>>> try:
... print(check_to_int_range(None))
... except (ValueError, TypeError) as err:
... print(err)
... print(err.__class__)
Cannot convert value=None to int, let alone in range 0..1000000000.
<class 'TypeError'>
"""
try:
conv = int(val)
except (ValueError, TypeError) as errx:
raise (ValueError if isinstance(errx, ValueError) else TypeError)(
f"Cannot convert {'value' if name is None else name}={val!r} "
f"to int, let alone in range {min_value}..{max_value}.") from errx
return check_int_range(conv, name, min_value, max_value)
#: the type variable for the data elements in an iterator
T = TypeVar("T")
[docs]
def reiterable(source: Iterable[T] | Iterator[T]) -> Iterable[T]:
"""
Ensure that an :class:`Iterable` can be iterated over multiple times.
This function will solidify an :class:`Iterator` into an
:class:`Iterable`. In Python, :class:`Iterator` is a sub-class of
:class:`Iterable`. This means that if your function accepts instances of
:class:`Iterable` as input, it may expect to be able to iterate over them
multiple times. However, if an :class:`Iterator` is passed in, which also
is an instance of :class:`Iterable` and thus fulfills the function's type
requirement, this is not the case. A typical example of this would be if
a :class:`Generator` is passed in. A :class:`Generator` is an instance of
:class:`Iterator`, which, in turn, is an instance of :class:`Iterable`.
However, you can iterate over a :class:`Generator` only once.
:param source: the data source
:return: the resulting re-iterable iterator
>>> a = [1, 2, 3]
>>> reiterable(a) is a
True
>>> a = (1, 2, 3)
>>> reiterable(a) is a
True
>>> a = {1, 2, 3}
>>> reiterable(a) is a
True
>>> a = {1: 1, 2: 2, 3: 3}
>>> reiterable(a) is a
True
>>> k = a.keys()
>>> reiterable(k) is k
True
>>> k = a.values()
>>> reiterable(k) is k
True
>>> reiterable((x for x in range(5)))
(0, 1, 2, 3, 4)
>>> try:
... reiterable(None)
... except TypeError as te:
... print(str(te)[:60])
source should be an instance of any in {typing.Iterable, typ
>>> try:
... reiterable(1)
... except TypeError as te:
... print(str(te)[:60])
source should be an instance of any in {typing.Iterable, typ
"""
if isinstance(source, Iterator):
return tuple(source) # solidify iterators into tuples
if not isinstance(source, Iterable):
raise type_error(source, "source", (Iterable, Iterator))
return source # iterables can be returned as-is