"""Small runtime typing and source-inspection helpers used by encomp."""
import ast
from types import UnionType
from typing import Any, TypeIs, Union, cast, get_args, get_origin
from typeguard import CollectionCheckStrategy, TypeCheckError, check_type
from typing_extensions import TypeForm
__all__ = ["isinstance_types", "name_assignments"]
def _is_quantity_subclass(expected: object) -> bool:
from .units import Quantity
return isinstance(expected, type) and issubclass(expected, Quantity)
[docs]
def isinstance_types[T](obj: Any, expected: TypeForm[T]) -> TypeIs[T]: # noqa: ANN401
"""Return whether ``obj`` conforms to a runtime type form.
This is the project-wide replacement for ``isinstance`` when the expected
type may be parameterized, such as ``list[Quantity[Pressure]]`` or
``Quantity[Mass, float]``. It delegates general type forms to
:func:`typeguard.check_type` and handles ``Quantity`` subclasses directly
so dimensionality and magnitude parameters are both respected.
Every element of a collection is checked, not just the first: a
``list[Quantity[Pressure]]`` holding a ``Quantity[Length]`` at any position
is rejected. (``typeguard.typechecked`` keeps typeguard's own default of
checking only the first element.)
"""
from .units import Quantity
from .utypes import UnknownDimensionality
# typeguard treats a plain string as a forward reference it cannot resolve
# here: it would emit a TypeHintWarning and *pass*, silently answering True
# for any obj. Reject it explicitly instead
if isinstance(expected, str):
raise TypeError(f"expected must be a type or type form, not a string: {expected!r}")
origin = get_origin(expected)
if origin in (UnionType, Union):
# a Quantity must be routed through the detailed per-member logic below: a plain
# isinstance against the union misclassifies a Quantity[UnknownDimensionality, ...]
# member (it matches ANY dimensionality here, but is a *sibling* class at runtime, so
# isinstance says False) -- decompose so single-type and union checks stay consistent.
if not isinstance(obj, Quantity) and origin is UnionType:
# narrowed to a UnionType by the check above, which isinstance accepts
try:
return isinstance(obj, cast(UnionType, expected))
except TypeError:
pass
return any(isinstance_types(obj, n) for n in get_args(expected))
if isinstance(obj, Quantity) and _is_quantity_subclass(expected):
if expected is Quantity:
return True
obj_q = cast("Quantity[Any, Any]", obj) # pyrefly: ignore[redundant-cast] # cast required by pyright
expected_dt: type | None = getattr(expected, "_dimensionality_type", None)
expected_mt: type | None = getattr(expected, "_magnitude_type", None)
obj_m: Any = obj_q.m
if expected_dt == UnknownDimensionality:
if expected_mt is None:
return True
return isinstance_types(obj_m, expected_mt)
obj_dt: type | None = getattr(obj_q, "_dimensionality_type", None)
if expected_dt is not None and obj_dt is not expected_dt:
return False
return not (expected_mt is not None and not isinstance_types(obj_m, expected_mt))
if _is_quantity_subclass(expected):
# obj is not a Quantity instance (that case is handled above), so it can
# never match -- and delegating to check_type could recurse forever
# through a custom typeguard checker that routes Quantity checks back here
return False
try:
# ALL_ITEMS, not typeguard's FIRST_ITEM default: a dimensionality check that only
# inspects element 0 answers True for list[Quantity[Pressure]] holding a Length at
# index 1, which is exactly the error this library exists to catch. The linear cost
# is irrelevant here -- encomp's own hot-path calls pass scalar quantities, never
# collections
# cast(Any) launders the partially-unknown narrowed type for pyright strict;
# ty already sees plain Any here and calls the cast redundant
check_type(cast(Any, obj), expected, collection_check_strategy=CollectionCheckStrategy.ALL_ITEMS) # ty: ignore[redundant-cast]
return True
except TypeCheckError:
# only a genuine type mismatch answers False -- an invalid ``expected``
# (e.g. an unsupported type form) propagates instead of being swallowed
return False
[docs]
def name_assignments(src: str) -> list[tuple[str, str]]:
"""Return simple ``name = ...`` assignments found in Python source text."""
assigned_names: list[tuple[str, str]] = []
tree = ast.parse(src)
for node in ast.walk(tree):
if isinstance(node, ast.Assign) and isinstance(node.targets[0], ast.Name):
source_segment = ast.get_source_segment(src, node) or ast.unparse(node)
assigned_names.append((node.targets[0].id, source_segment))
return assigned_names