Source code for app.util.helpers.abc_info

# SPDX-License-Identifier: GPLv3-or-later
# Copyright © 2025 pygaindalf Rui Pinheiro

"""Helpers for inspecting abstract base classes (ABCs).

This module centralises metadata needed to reason about runtime ABCs and the standard collection ABC hierarchy. It provides:

* curated lists of supported ABC types and commonly used concrete bases,
* pre-computed lookup tables for quickly mapping between ABCs and compatible base classes, and
* high-level helpers that resolve the concrete key/value types declared by generic collections or inferred from a concrete instance.

String-like sequences such as ``str`` and ``bytes`` are explicitly represented in :const:`SUPPORTED_ABCS` so callers can detect when string literals are being
treated as collections in generic code paths.

The resolved information is exposed via :class:`ABCInfo`, an immutable structure that provides convenient properties for inspecting the collection's
capabilities and declared key/value types.

Examples
--------
*1. Inspect a built-in mapping instance*

    >>> from app.util.helpers import abc_info
    >>> info = abc_info.get_abc_info([1, 2, 3])
    >>> info.sequence
    True
    >>> info.mutable
    True

*2. Resolve collection metadata from class annotations*

    >>> class ContainsMapping:
    ...     data: dict[str, float]
    >>> info = abc_info.get_class_attribute_abc_info(ContainsMapping, "data")
    >>> info.mapping
    True
    >>> info.key_type
    <class 'str'>
    >>> info.value_type
    <class 'float'>

"""

import typing

from collections import abc as abcs

from frozendict import frozendict

from . import generics, type_hints


# MARK: Definitions
#: Union of ABC classes and built-in string-like types understood by the helpers.
type ABCType = abcs.Container | abcs.Iterator | abcs.Iterable | abcs.Reversible | abcs.Generator | abcs.Hashable | abcs.Sized | str | bytes

#: Union of possible concrete key/value type representations.
type KeyValueType = type | generics.GenericAlias | typing.ForwardRef


# MARK: ABC Lookup Info & Mappings
#: Ordered list of ABC types analysed during lookup, including ``str`` and ``bytes``
#: so callers can easily distinguish literal-like sequences from general collections.
SUPPORTED_ABCS: tuple[type[ABCType], ...] = (
    abcs.Hashable, abcs.Sized, abcs.Container, abcs.Reversible,
    abcs.Iterable, abcs.Iterator, abcs.Generator,
    abcs.Mapping, abcs.MutableMapping,
    abcs.Set, abcs.MutableSet,
    abcs.Sequence, abcs.MutableSequence,
    str, bytes,
)  # fmt: skip

#: Concrete builtin bases tested when deriving key/value arguments for ABCs.
SUPPORTED_BASES: tuple[type, ...] = (
    dict, #frozendict,
    list, tuple,
    frozenset, set,
    str, bytes,
)  # fmt: skip


[docs] class ABCLookupInfo(typing.NamedTuple): """Runtime metadata describing an ABC and its related concrete bases.""" #: Primary abstract base class inspected for compatibility. abc: type[ABCType] #: All ABCs satisfied by the primary ABC through inheritance. abcs: tuple[type[ABCType], ...] #: Built-in concrete types that may implement the ABC. possible_bases: tuple[type, ...] @property def has_keys(self) -> bool: """Return ``True`` when the ABC has keys.""" return abcs.Mapping in self.abcs @property def has_values(self) -> bool: """Return ``True`` when the ABC has values.""" return abcs.Container in self.abcs @property def is_recursive(self) -> bool: """Return ``True`` when the ABC describes a recursive collection, i.e. one where the value_type is the same as the ABC.""" return self.abc in (str, bytes)
def _prepare_mappings() -> abcs.Mapping[type, ABCLookupInfo]: """Build the immutable lookup table mapping ABCs to compatible bases.""" mapping = {} for abc in SUPPORTED_ABCS: abcs_list = [] bases = [] for supported in SUPPORTED_ABCS: if issubclass(abc, supported): # pyright: ignore[reportGeneralTypeIssues] abcs_list.append(supported) bases.append(supported) bases.extend(base for base in SUPPORTED_BASES if issubclass(base, abc)) mapping[abc] = ABCLookupInfo( abc=abc, abcs=tuple(abcs_list), possible_bases=tuple(bases), ) return frozendict(mapping) #: Immutable mapping from supported ABCs to their lookup metadata. ABC_MAPPINGS = _prepare_mappings() # MARK: ABC Info
[docs] class ABCInfo(typing.NamedTuple): """Detailed information about a collection ABC or concrete collection type.""" #: Source runtime type or generic alias inspected for ABC details. source: type | generics.GenericAlias #: Lookup metadata describing the ABC hierarchy and possible bases. lookup: ABCLookupInfo #: Concrete or generic alias describing the key type if applicable. key_type: type | generics.GenericAlias | typing.ForwardRef | None #: Concrete or generic alias describing the value type if applicable. value_type: type | generics.GenericAlias | typing.ForwardRef | None @property def source_origin(self) -> type: """Return the non-aliased origin for :attr:`source`.""" return generics.get_origin(self.source, passthrough=True) @property def has_key(self) -> bool: """Return ``True`` when the collection describes explicit key types.""" return self.key_type is not None @property def key_origin(self) -> type | None: """Return the origin type for the declared key if one exists.""" if not self.has_key: return None return generics.get_origin(self.key_type, passthrough=True) @property def key_concrete(self) -> bool: """Return ``True`` when the key type is a concrete type and not a type variable or forward reference.""" if not self.has_key: return False return isinstance(self.key_type, type) @property def has_value(self) -> bool: """Return ``True`` when the collection exposes a derived value type.""" return self.value_type is not None @property def value_origin(self) -> type | None: """Return the origin type for the declared value if one exists.""" if not self.has_value: return None return generics.get_origin(self.value_type, passthrough=True) @property def value_concrete(self) -> bool: """Return ``True`` when the value type is a concrete type and not a type variable or forward reference.""" if not self.has_value: return False return isinstance(self.value_type, type) @property def specialized(self) -> bool: """Return ``True`` when the collection is specialised with value types.""" has_value = self.has_value assert not has_value or self.has_key or not self.mutable, f"Mutable collections must have keys if they have values, got {self}" return has_value
[docs] @classmethod def create(cls, abc: ABCType) -> ABCInfo: """Construct :class:`ABCInfo` directly from an ABC instance.""" return get_abc_info(abc)
@property def abc(self) -> type[ABCType]: """Return the primary ABC associated with this info entry.""" return self.lookup.abc @property def abcs(self) -> tuple[type[ABCType], ...]: """Return all ABCs satisfied by :attr:`source`.""" return self.lookup.abcs
[docs] def matches(self, abc: type[ABCType] | tuple[ABCType, ...]) -> bool: """Return whether the info is compatible with the supplied ABC(s).""" return any(klass in self.abcs for klass in ((abc,) if isinstance(abc, type) else abc))
@property def mutable(self) -> bool: """Return ``True`` when the collection advertises mutable semantics.""" return self.matches((abcs.MutableMapping, abcs.MutableSet, abcs.MutableSequence)) @property def sequence(self) -> bool: """Return ``True`` for sequence-like collections.""" return self.matches(abcs.Sequence) @property def mapping(self) -> bool: """Return ``True`` for mapping-like collections.""" return self.matches(abcs.Mapping) @property def set(self) -> bool: """Return ``True`` for set-like collections.""" return self.matches(abcs.Set) @property def container(self) -> bool: """Return ``True`` if the collection satisfies :class:`collections.abc.Container`.""" return self.matches(abcs.Container) # pyright: ignore[reportArgumentType] @property def iterable(self) -> bool: """Return ``True`` if the collection satisfies :class:`collections.abc.Iterable`.""" return self.matches(abcs.Iterable) # pyright: ignore[reportArgumentType] @property def iterator(self) -> bool: """Return ``True`` if the collection satisfies :class:`collections.abc.Iterator`.""" return self.matches(abcs.Iterator) # pyright: ignore[reportArgumentType] @property def generator(self) -> bool: """Return ``True`` if the collection satisfies :class:`collections.abc.Generator`.""" return self.matches(abcs.Generator) # pyright: ignore[reportArgumentType] @property def hashable(self) -> bool: """Return ``True`` if the collection satisfies :class:`collections.abc.Hashable`.""" return self.matches(abcs.Hashable) # pyright: ignore[reportArgumentType] @property def str_or_bytes(self) -> bool: """Return ``True`` if the collection is either :class:`str` or :class:`bytes`.""" return self.abc in (str, bytes)
# MARK: Internal API def _validate_get_abc_info_params( abc: type[ABCType] | ABCType | None = None, namespace: object | None = None, attr: str | None = None, ) -> None: """Validate parameters for :func:`_get_abc_info`.""" if abc is None: if namespace is None or attr is None: msg = "If abc is not provided, both namespace and attr must be provided." raise ValueError(msg) if attr is not None: if namespace is None: msg = "If attr is provided, namespace must also be provided." raise ValueError(msg) assert isinstance(attr, str), f"Expected attr to be str, got {type(attr).__name__} instead." if namespace is not None: if attr is None: msg = "If namespace is provided, attr must also be provided." raise ValueError(msg) assert isinstance(namespace, object), f"Expected namespace to be object, got {type(namespace).__name__} instead." def _get_lookup_mapping(origin: type[ABCType]) -> ABCLookupInfo | None: if issubclass(origin, abcs.Container): if issubclass(origin, abcs.Mapping): return ABC_MAPPINGS[abcs.MutableMapping] if issubclass(origin, abcs.MutableMapping) else ABC_MAPPINGS[abcs.Mapping] elif issubclass(origin, abcs.Set): return ABC_MAPPINGS[abcs.MutableSet] if issubclass(origin, abcs.MutableSet) else ABC_MAPPINGS[abcs.Set] elif issubclass(origin, abcs.Sequence): if issubclass(origin, abcs.MutableSequence): return ABC_MAPPINGS[abcs.MutableSequence] elif issubclass(origin, (str, bytes)): return ABC_MAPPINGS[str if origin is str else bytes] else: return ABC_MAPPINGS[abcs.Sequence] else: return ABC_MAPPINGS[abcs.Container] elif issubclass(origin, abcs.Iterable): if issubclass(origin, abcs.Iterator): return ABC_MAPPINGS[abcs.Generator] if issubclass(origin, abcs.Generator) else ABC_MAPPINGS[abcs.Iterator] else: return ABC_MAPPINGS[abcs.Iterable] elif issubclass(origin, abcs.Hashable): return ABC_MAPPINGS[abcs.Hashable] else: return None def _get_key_value_types_from_lookup(abc_type: type[ABCInfo], lookup: ABCLookupInfo) -> tuple[KeyValueType | None, KeyValueType | None]: value_type = lookup.abc if lookup.is_recursive else None key_type = None # Instrospect key type if key_type is None and lookup.has_keys: key_arg_info = generics.get_parent_argument_info_or_none(abc_type, lookup.possible_bases, 0) if key_arg_info is not None: key_type = key_arg_info.value_or_bound # Introspect value type if value_type is None and lookup.has_values: value_arg_info = generics.get_parent_argument_info_or_none(abc_type, lookup.possible_bases, 1 if lookup.has_keys else 0) if value_arg_info is not None: value_type = value_arg_info.value_or_bound return (key_type, value_type) @typing.overload def _get_abc_info(*, abc: type[ABCType] | ABCType, namespace: object, attr: str) -> ABCInfo: ... @typing.overload def _get_abc_info(*, abc: type[ABCType] | ABCType) -> ABCInfo: ... @typing.overload def _get_abc_info(*, namespace: object, attr: str) -> ABCInfo | None: ... def _get_abc_info(*, abc: type[ABCType] | ABCType | None = None, namespace: object | None = None, attr: str | None = None) -> ABCInfo | None: """Resolve :class:`ABCInfo` from either a concrete ABC or cached type hints. Args: abc: An optional collection instance whose type is inspected directly. namespace: A namespace (class, module, object, etc) containing the attribute *attr* for type hint lookups. attr: The attribute name to inspect on *cls*. Returns: The resolved :class:`ABCInfo`, or ``None`` when the type cannot be categorised as one of the supported ABCs. """ # Validate parameters _validate_get_abc_info_params(**locals()) # Find the type for this field, if any - either from the actual collection or from type hints if abc is None: typ = None elif isinstance(abc, type): typ = abc else: typ = type(abc) if namespace is not None and attr is not None: if (type_hint := type_hints.get_type_hints(namespace).get(attr, None)) is not None: if typ is not None: typ = type_hints.match_type_hint(typ or object, type_hint) else: if isinstance(type_hint, typing.Union): msg = "Union type hints not supported when abc is None" raise NotImplementedError(msg) typ = type_hint if typ is None: return None # Figure out what type of collection this is, and try to get the value type origin = generics.get_origin(typ, passthrough=True) lookup = _get_lookup_mapping(origin) if lookup is None: return None key_type, value_type = _get_key_value_types_from_lookup(typ, lookup) # pyright: ignore[reportArgumentType] since it is incorrect # TODO: Optionally iterate the collection and collect concrete types if generics are not available return ABCInfo( source=typ, lookup=lookup, key_type=key_type, value_type=value_type, ) # MARK: Public API @typing.overload def get_abc_info(abc: type[ABCType] | ABCType, *, namespace: object, attr: str) -> ABCInfo: ... @typing.overload def get_abc_info(abc: type[ABCType] | ABCType) -> ABCInfo: ...
[docs] def get_abc_info(abc: type[ABCType] | ABCType, *, namespace: object | None = None, attr: str | None = None) -> ABCInfo: """Public wrapper around :func:`_get_abc_info` that always returns an entry.""" return _get_abc_info(abc=abc, namespace=namespace, attr=attr) # pyright: ignore[reportCallIssue, reportArgumentType]
[docs] def get_class_attribute_abc_info(namespace: object, attr: str) -> ABCInfo | None: """Return :class:`ABCInfo` for a cached collection attribute on *cls* if known.""" return _get_abc_info(abc=None, namespace=namespace, attr=attr)