Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
388 changes: 388 additions & 0 deletions docs/developer/architecture-and-design/demand-driven-generics.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/developer/architecture-and-design/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ maxdepth: 2

workflow-design
rewrite
demand-driven-generics
```
2 changes: 2 additions & 0 deletions src/sciline/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from ._provider import Provider, UnboundTypeVar
from .domain import Scope
from .handler import (
AmbiguousProvider,
HandleAsBuildTimeException,
HandleAsComputeTimeException,
UnsatisfiedRequirement,
Expand All @@ -21,6 +22,7 @@
from .task_graph import TaskGraph

__all__ = [
"AmbiguousProvider",
"HandleAsBuildTimeException",
"HandleAsComputeTimeException",
"Pipeline",
Expand Down
174 changes: 174 additions & 0 deletions src/sciline/_unification.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
# SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2025 Scipp contributors (https://github.com/scipp)
"""Unification of generic type patterns with concrete keys.

Generic providers are not expanded eagerly. They are kept as templates and
instantiated on demand by unifying their argument and return-type patterns
with the concrete keys that appear in the pipeline (parameters, requested
targets, mapped keys). Constraints declared on type variables restrict which
concrete types they unify with.
"""

from __future__ import annotations

import itertools
from collections.abc import Generator, Iterable
from types import UnionType
from typing import TYPE_CHECKING, Any, TypeVar, get_args, get_origin

if TYPE_CHECKING:
from ._provider import Provider
from .typing import Key


def find_all_typevars(t: type | TypeVar) -> set[TypeVar]:
"""Returns the set of all TypeVars in a type expression."""
if isinstance(t, TypeVar):
return {t}
if params := getattr(t, '__parameters__', ()):
return set(params)
return set(itertools.chain(*map(find_all_typevars, get_args(t))))


def origin_and_args(t: Any) -> tuple[Any, tuple[Any, ...]]:
"""Return the generic origin and args of ``t``, or ``(None, ())``.

Supports regular typing generics as well as pydantic generic models, whose
metaclass hides type parameters from :py:func:`typing.get_origin`.
"""
if (origin := get_origin(t)) is not None:
return origin, get_args(t)
if (meta := getattr(t, '__pydantic_generic_metadata__', None)) is not None:
if meta['origin'] is not None:
return meta['origin'], meta['args']
return None, ()


def parameterize(key: Key) -> Key:
"""Subscript bare generic classes with their own type parameters, recursively.

E.g., for ``class A(Generic[T])``, turns ``A`` into ``A[T]`` and
``list[A]`` into ``list[A[T]]``, so that patterns have a uniform
subscripted shape for unification.
"""
origin, args = origin_and_args(key)
if origin is not None:
if origin is UnionType:
return key
return origin[tuple(parameterize(arg) for arg in args)] # type: ignore[no-any-return]
if params := getattr(key, '__parameters__', ()):
return key[params] # type: ignore[index, no-any-return]
return key


def _within_bound(key: Any, bound: Any) -> bool:
"""Best-effort check of a TypeVar bound; keys that are not classes never
satisfy a bound."""
try:
return isinstance(key, type) and issubclass(key, bound)
except TypeError:
return False


def key_depth(key: Key) -> int:
"""Nesting depth of a type expression."""
_, args = origin_and_args(key)
return 1 + max((key_depth(arg) for arg in args), default=0)


def pattern_origin_and_args(pattern: Any) -> tuple[Any, tuple[Any, ...]]:
"""Like :py:func:`origin_and_args`, but treats an unparametrized generic
class whose subscription does not produce an inspectable alias (e.g. a
pydantic model) as its own origin with its type parameters as args."""
origin, args = origin_and_args(pattern)
if origin is None and (params := getattr(pattern, '__parameters__', ())):
return pattern, params
return origin, args


def unify(pattern: Key | TypeVar, concrete: Key, bound: dict[TypeVar, Key]) -> bool:
"""Match ``concrete`` against ``pattern``, extending ``bound`` in place.

Returns True on success. ``bound`` may contain partial bindings on failure
and must be discarded by the caller in that case.
"""
if isinstance(pattern, TypeVar):
if pattern.__constraints__ and concrete not in pattern.__constraints__:
return False
if pattern.__bound__ is not None and not _within_bound(
concrete, pattern.__bound__
):
return False
if pattern in bound:
return bound[pattern] == concrete
bound[pattern] = concrete
return True
pattern_origin, pattern_args = pattern_origin_and_args(pattern)
if pattern_origin is None:
return pattern == concrete
concrete_origin, concrete_args = origin_and_args(concrete)
if concrete_origin != pattern_origin:
return False
if len(pattern_args) != len(concrete_args):
return False
return all(
unify(p, c, bound) for p, c in zip(pattern_args, concrete_args, strict=True)
)


def subsumes(general: Key | TypeVar, specific: Key | TypeVar) -> bool:
"""Return whether every key matched by ``specific`` is matched by ``general``.

One-sided unification: type variables of ``general`` may bind to
sub-patterns of ``specific``, whose type variables are treated as opaque.
Mutual subsumption means the patterns are equivalent up to renaming;
one-sided subsumption means ``specific`` is strictly more specific.
"""
return _subsumes(general, specific, {})


def _subsumes(
general: Key | TypeVar, specific: Key | TypeVar, bound: dict[TypeVar, Any]
) -> bool:
if isinstance(general, TypeVar):
if general.__constraints__:
if isinstance(specific, TypeVar):
# ``specific`` matches keys in its own constraint set; all of
# them must be admissible for ``general``.
if not specific.__constraints__ or not set(
specific.__constraints__
) <= set(general.__constraints__):
return False
elif specific not in general.__constraints__:
return False
if general.__bound__ is not None:
if isinstance(specific, TypeVar):
if specific.__bound__ is None or not _within_bound(
specific.__bound__, general.__bound__
):
return False
elif not _within_bound(specific, general.__bound__):
return False
if general in bound:
return bool(bound[general] == specific)
bound[general] = specific
return True
general_origin, general_args = pattern_origin_and_args(general)
if general_origin is None:
return general == specific
specific_origin, specific_args = pattern_origin_and_args(specific)
if specific_origin != general_origin:
return False
if len(general_args) != len(specific_args):
return False
return all(
_subsumes(g, s, bound) for g, s in zip(general_args, specific_args, strict=True)
)


def match_return(template: Provider, key: Key) -> Provider | None:
"""Instantiate a generic provider if its return type unifies with ``key``."""
bound: dict[TypeVar, Key] = {}
if unify(template.deduce_key(), key, bound):
return template.bind_type_vars(bound)
return None
Loading
Loading