Static type checking explained
Since an atom member is a Python descriptor in which the validation step is allowed to perform a type conversion (ex with Coerced), the types may be different when reading and writing the member value. Therefore, the type hint is logically generic over 2 types:
the type that will be returned when accessing the member, which we will refer to as the getter or read type T
the type that the member can be set to, which we will refer to as the setter or write type S
In general, the type hints shipped with Atom are sufficient to narrow down the type of the members without requiring any manual annotation. For example:
class MyAtom(Atom):
i = Int()
i_f = Int(strict=False)
l = List(int)
d = Dict((str, bytes), List(int))
will be typed as something equivalent to:
class MyAtom(Atom):
i: Member[int, int]
i_f: Member[int, float]
l: Member[TList[int], TList[int]]
d: Member[TDict[Union[str, bytes], TList[int]], TDict[Union[str, bytes], TList[int]]]
Note
Since many member names conflict with name found in the typing module we will add a leading T to types coming from typing. However in real code we recommend rather aliasing the Atom members with a leading A as illustrated in the next example. Note that starting with Python 3.9 generic aliases allow to directly use list, dict, set in type annotations which avoids conflicts.
However, in some cases, static typing can be more strict than Atom validation such as for tuples and we may not want to validate at runtime the size of the tuple (even though it may be a good idea to do so).
from typing import Tuple
from Atom.api import Atom, Tuple as ATuple
class MyAtom(Atom):
t: "Member[Tuple[int, int], Tuple[int, int]]" = ATuple[int] # type: ignore
Let’s walk through this case.
from typing import Tuple
from Atom.api import Atom, Tuple as ATuple
First, since Atom and typing share many names, one must be careful to disambiguate the names. Starting with Python 3.9, one can use generic aliases to limit the conflicts by using native types rather than typing classes.
class MyAtom(Atom):
t: "Member[Tuple[int, int], Tuple[int, int]]" = ATuple[int] # type: ignore
Here we want to specify, that our tuple member is expected to store 2-tuple of int. Since Atom does not enforce the length of a tuple, its type hint looks like Member[Tuple[T, …], Tuple[T, …]] and makes the assumption that no fancy type conversion occurs. If we want to go further we need a type hint and this is where things get a bit more complicated.
Member is actually defined in C and does not inherit from Protocol. However, atom members implement __getitem__ allowing the the use of generic aliases.
Note
If the line becomes too long it can be split on multiple lines as follows:
class MyAtom(Atom):
t: "Member[Tuple[int, int], Tuple[int, int]]"
t = ATuple[int] # type: ignore
Similarly if one implements custom member subclasses and desire to make it compatible with type annotations, one can define the type hint as follow:
class MyMember(Member[int, str]): ...
Note
The above is valid outside of a .pyi file only under Python 3.9+.
Note
One can use types from the typing module or generic aliases in any place
where a type or a tuple of type is expected. Note however that when
using typing.List[int] or list[T], etc in such a position the content
of the container will not be validated at runtime by Atom.
Optional, Union and Callable from the typing module can also be
used, however because they are not seen as proper types by type checkers this
will break static type checking. The recommended workaround is to use Typed
or Instance as appropriate for the first two cases and a separate annotation
for the typing.Callable case.
Member typing in term of Member[T, S]
Below we give the typing of most Atom member in term of Member to clarify the behavior of each member with respect to typing. We also indicate their default typing, but please note that the presence/value of some argument at the member creation will influence the inferred type.
Value[T] = Member[T, T] # default: Value[Any]
Constant[T] = Member[T, NoReturn] # default: Constant[Any]
ReadOnly[T] = Member[T, T] # default: ReadOnly[Any]
Callable[T] = Member[T, T] # default: Callable[TCallable]
Bool[S] = Member[bool, S] # default: Bool[bool]
Int[S] = Member[int, S] # default: Int[int]
Float[S] = Member[float, S] # default: Float[float]
Range[S] = Member[int, S] # default: Range[int]
FloatRange[S] = Member[float, S] # default: FloatRange[float]
Bytes[S] = Member[bytes, S] # default: Bytes[Union[bytes, str]]
Str[S] = Member[str, S] # default: Str[Union[bytes, str]]
List[T] = Member[TList[T], TList[T]]
# List() -> List[Any]
# List(int) -> List[int]
# List(List(int)) -> List[TList[int]]
Set[T] = Member[TSet[T], TSet[T]]
# Set() -> Set[Any]
# Set(int) -> Set[int]
Dict[KT, VT] = Member[TDict[KT, VT], TDict[KT, VT]]
# Dict() -> Dict[Any, Any]
# Dict(str, int) -> Dict[str, int]
# Dict(str, List[int]) -> Dict[str, TList[int]]
Typed[T] = Member[T, T]
# Typed(int) -> Typed[Optional[int]]
# Typed(int, optional=False) -> Typed[int]
ForwardTyped[T] = Member[T, T]
Instance[T] = Member[T, T]
ForwardInstance[T] = Member[T, T]
Note
All members that can take a tuple of types as argument (List, Dict, etc) have type hints for up to a tuple of 3 types as argument. Supporting more types would make type checking even slower, so we suggest using manual annotation.
Finally the case of Coerced is a bit special, since we cannot teach type checkers to
see a type both as a type and a callable. As a consequence for type checking to be
correct when the type itself handle the coercion the type should be manually specified
as coercer:
c = Coerced(int, coercer=int)