feat: mostly support Generic#272
Conversation
Signed-off-by: nstarman <nstarman@users.noreply.github.com>
When a user writes Box[int](1), Python sets __orig_class__ = Box[int] on the instance. Previously plum ignored this and beartype.door.is_bearable would return True for all Box instances vs any Box[X] hint, making Box[int] and Box[str] overloads permanently ambiguous. Changes: - _bear.py: add is_bearable_with_orig() which uses TypeHint subtype ordering when __orig_class__ is present, with an exact-match fast path to avoid TypeHint construction on the warm cache hit. - _signature.py: Signature.match() uses is_bearable_with_orig so dispatch correctly distinguishes f(x: Box[int]) from f(x: Box[str]). - _function.py: _arg_key() returns __orig_class__ when present so Box[int](1) and Box[str](1) get separate cache buckets; resolve_for_type receives the subscripted alias and extracts the bare origin for lookup. - _resolver.py: resolve_for_type() accepts subscripted generics (e.g. Box[int]) and extracts the bare origin via get_origin for _arity1_methods lookup. - tests/test_generic_dispatch.py: 7 new test_orig_class_* tests covering two-way dispatch, three-way with fallback, bare-still-ambiguous, cache keying, nested generics, and mixed args. - tests/benchmark_generics.py: Scenario 6 measures warm/cold overhead for __orig_class__-based dispatch (~5 us warm, ~140 us cold). Limitation: bare Box(1) without __orig_class__ remains ambiguous when only parameterized overloads exist -- users must write Box[int](...) syntax.
When a value lacks __orig_class__ (e.g. bare Box(1)), parameterized custom-Generic hints like Box[int] no longer match. This eliminates the ambiguity that arose when multiple parameterized overloads were registered for the same class. Users opt into a fallback for bare instances by registering an explicit Box[Any] (or bare Box) overload, which beartype's TypeHint ordering treats as a strict supertype of every Box[X]. Subscripted instances still pick the most specific overload via __orig_class__. Also fix _resolver.generic_origins to skip non-class origins (e.g. typing.Literal), which were causing TypeError in issubclass(). Documents the new semantics in docs/generics.md and links it from docs/types.md and docs/_toc.yml.
Replace {code-cell} execution blocks in docs/generics.md with a static
{include} of docs/_generated/generics_timing.md.
The timing data is now produced by docs/_scripts/time_generics.py, which
can be run independently or via `nox -s docs` (which runs the script
then builds the Jupyter Book). A placeholder stub is committed so the
docs build works out of the box without running the timing step first.
Introduces a lightweight @Generic decorator that wraps a Generic[T] class's __init__ to set __orig_class__ at construction time, enabling plum dispatch to route bare instances (e.g. Box(1)) to parameterised overloads without requiring an explicit A[Any] fallback. Key behaviours: - Raises TypeError at decoration time if __infer_type_parameter__ is absent (the class must supply explicit inference logic). - Emits RuntimeWarning at decoration time when __slots__ is declared without '__orig_class__', since no __dict__ fallback is available. - Emits RuntimeWarning at instantiation time if object.__setattr__ fails (e.g. slotted class missing the slot). - Supports frozen dataclasses via object.__setattr__; Python's _GenericAlias.__call__ swallows the resulting FrozenInstanceError so the inferred value persists even for subscripted construction. - Subscripted construction (A[str](x)) still overrides inference for non-frozen classes (Python sets __orig_class__ after __init__). - @overload stubs use type[T] for precise static type-checker support. Changes: - src/plum/_generic.py — generic() decorator + overload stubs - src/plum/__init__.py — export generic - src/plum/_bear.py — route __orig_class__ through beartype check - tests/test_generic_decorator.py — 30 new tests - docs/generics.md — document @plum.generic with examples
There was a problem hiding this comment.
Pull request overview
This PR extends Plum’s dispatch system to better support parameterized generics (stdlib list[int]-style hints and user-defined Generic[T] classes), including improved matching via __orig_class__, a new @plum.generic decorator for inference, and a new two-tier generic dispatch cache.
Changes:
- Add generic-aware matching and caching (including
__orig_class__-aware bearability checks and a two-tier_generic_cache). - Introduce
plum._generichelpers (is_generic_hint,le_generic) and exportplum.generic. - Add extensive tests, docs, and a docs timing generator for generics performance.
Reviewed changes
Copilot reviewed 18 out of 19 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| uv.lock | Adds plum-dispatch to dependency groups (notably docs). |
| pyproject.toml | Adds plum-dispatch to docs dependency group. |
| noxfile.py | Adds a docs session that regenerates generics timing and builds Jupyter Book. |
| src/plum/_type.py | Enhances type-hint resolution and marks parameterized generics unfaithful. |
| src/plum/_signature.py | Switches signature matching to is_bearable_with_orig. |
| src/plum/_resolver.py | Tracks generic origins and adds an arity-1 pre-filtered resolution shortcut. |
| src/plum/_generic.py | Adds generic-hint helpers and the @plum.generic decorator. |
| src/plum/_function.py | Adds two-tier generic cache and __orig_class__-aware cache keying. |
| src/plum/_bear.py | Adds is_bearable_with_orig for __orig_class__-aware matching. |
| src/plum/init.py | Exports generic. |
| tests/test_generic_dispatch.py | New tests for stdlib generics + user-defined generics + caching behavior. |
| tests/test_generic_decorator.py | New tests for @plum.generic inference, slots, dataclasses, and routing. |
| tests/test_cache.py | Updates cache expectations for mixed faithful+generic overloads. |
| tests/benchmark_generics.py | Adds a benchmark script for generic dispatch and __orig_class__ scenarios. |
| docs/types.md | Adds a note pointing to custom generic type dispatch docs. |
| docs/generics.md | New documentation page describing custom generic dispatch patterns and limitations. |
| docs/_toc.yml | Adds the new generics page to the docs TOC. |
| docs/_scripts/time_generics.py | Generates the timing table included in docs/generics.md. |
| docs/_generated/generics_timing.md | Adds generated timing table output. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Signed-off-by: nstarman <nstarman@users.noreply.github.com>
Coverage Report for CI Build 26164590132Warning Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes. Coverage increased (+0.1%) to 99.596%Details
Uncovered Changes
Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
- exclude PEP-604 union type (int | str) from is_generic_hint via UnionType in _EXCLUDED_ORIGINS - add is_faithful_for_non_generic to Resolver; tighten cache condition in Function to avoid caching value-dependent (Annotated/validator) overloads - guard _can_match_arity1_origin against non-type origins (e.g. Literal[1]) - update test_cache_unfaithful expectations; add new generic dispatch tests
Signed-off-by: nstarman <nstarman@users.noreply.github.com>
get_origin(Annotated[X, ...]) returns the inner type X on Python 3.10 (the minimum supported version), not Annotated itself, so the Annotated entry in _EXCLUDED_ORIGINS never matched and Annotated hints were misclassified as generic. Detect them explicitly via __metadata__ (PEP 593) before the origin check. Also remove the now-dead Annotated entry from _EXCLUDED_ORIGINS and the corresponding import. Regression tests added.
__orig_class__ is set by Python after subscripted instantiation and by the @Generic decorator. Document this trust assumption with a comment; no logic change.
Previously __le__ ran two full list-comprehension passes when the equality check failed but a subset relationship held, constructing TypeHintWrapper for every type pair twice (4k constructions for k pairs). Replace the two separate passes with a single pre-built list of (wx, wy) tuples that both the equality check and the subset check reuse, halving TypeHintWrapper constructions to 2k. Switch from all([list_comp]) to all(generator) so the checks themselves also short-circuit. Regression test added: asserts exactly 4 TypeHintWrapper constructions for a two-pair Sig(bool, bool) <= Sig(int, int) comparison. Signed-off-by: nstarman <nstarman@users.noreply.github.com>
In _resolve_method_with_cache, when has_generic_signatures is True, type(a) was called twice per argument on the non-generic hot path: 1. Inside issubclass(type(a), o) in the needs_generic generator. 2. Again in tuple(map(type, args)) for the cache key. Hoist types = tuple(map(type, args)) before the generic check and rewrite the generator to iterate over the pre-computed types tuple (issubclass(t, o)), halving type() calls to once per argument. For generic-enabled functions called with non-generic args this saves n_args extra type() calls on every cache miss and every cache hit path that passes through the generic check. Regression test added: shadows type() in plum._function via patch.dict and asserts exactly 1 call for a single-arg dispatch.
Before building any TypeHintWrapper objects, check three O(1) scalar conditions that prove inequality immediately: 1. len(self.types) != len(other.types) 2. one signature has varargs and the other does not 3. self.precedence != other.precedence Previously the method constructed TypeHintWrapper for every type in both signatures and compared the resulting tuples, paying the full wrapping cost even when a simple length or precedence difference made equality impossible. The precedence check was also redundant inside the tuple; it has been moved to the early-exit guard, leaving only the type and varargs wrappers in the final comparison. Regression test added: asserts 0 TypeHintWrapper calls for length, precedence, and varargs-presence mismatches.
Comparable.is_comparable expands to:
self < other or self == other or self > other
Each branch internally calls Signature.__eq__ (which constructs
TypeHintWrapper objects) in addition to __le__. For equal 1-type
signatures this amounts to 6 TypeHintWrapper constructions:
- self.__le__(other) → 2 constructions (from __lt__ via __le__)
- Signature.__eq__ → 2 constructions (from self != other check)
- Signature.__eq__ again → 2 constructions (from 'or self == other')
The Signature override exploits the fact that two signatures are
comparable iff one is a subtype of the other, so __le__ in each
direction is both necessary and sufficient:
return bool(self.__le__(other)) or bool(other.__le__(self))
This short-circuits after the first direction when it returns True,
reducing the equal-signature case from 6 to 2 TypeHintWrapper
constructions and the incomparable case from 6 to 4.
is_comparable is called on the hot path in both _function.py
(AmbiguousLookupError check) and _resolver.py (candidate filtering).
Regression test added: asserts at most 2 TypeHintWrapper constructions
for is_comparable on equal 1-type signatures.
The old _sort_most_specific_first used a layer-peeling loop:
- Computed 'layer = [m for m in remaining if not any(o < m for o in remaining)]'
which called Comparable.__lt__ = '__le__ and __ne__', incurring a
Signature.__eq__ (TypeHintWrapper construction) for every comparable pair.
- Called remaining.remove(m) for each extracted method (O(N) scan each time).
New Kahn's algorithm:
- Pre-computation: one pass over N*(N-1)/2 unordered pairs, 2 '__le__' calls
each (le_ij and le_ji). Strict ordering derived as 'le_ij and not le_ji',
so Signature.__eq__ is never called — eliminates O(N^2) TypeHintWrapper
constructions for comparable pairs.
- BFS processes in O(N + E) with no list.remove() calls.
- Safety valve: if Kahn's queue empties early (cyclic __le__, invalid partial
order), remaining methods are appended in original order.
Signed-off-by: nstarman <nstarman@users.noreply.github.com>
| (i for i, m in enumerate(self.methods) if m.signature == signature), | ||
| None, | ||
| ) | ||
| if existing_idx is not None: |
There was a problem hiding this comment.
why are we dropping this? I mean registering should be something done rarely so it's not a huge issue... or is it?
|
@wesselb this got to be a doozy, in a push one corner of the rug another corner pops up kind of way. |
…n dataclasses Python's _GenericAlias.__call__ silently swallows FrozenInstanceError when trying to set __orig_class__ via normal attribute assignment, leaving the inferred value (set by the wrapped __init__) in place. As a result, FrozenBox[str](1) dispatched as FrozenBox[int]. Fix: @Generic installs a thin __setattr__ override on frozen dataclasses that allows __orig_class__ to be written via object.__setattr__, so Python's own machinery can overwrite the inferred value with the subscripted alias — the same 'subscripted wins' behaviour as non-frozen classes. Add a canary test (test_cpython_swallows_frozen_instance_error_for_orig_class) that verifies the CPython bug is still present. If CPython is ever fixed the test will fail and the workaround can be removed. Signed-off-by: nstarman <nstarman@users.noreply.github.com>
Previously @Generic only checked hasattr(cls, '__infer_type_parameter__'), so a plain method or staticmethod would pass decoration and then fail silently at instantiation. Now inspect.getattr_static is used to walk the MRO without invoking descriptors, and TypeError is raised immediately if the attribute is absent or is not a classmethod.
| return isinstance(origin, type) and Generic in origin.__mro__ | ||
|
|
||
|
|
||
| def is_bearable_with_orig(value: object, hint: Any, /) -> bool: |
There was a problem hiding this comment.
@leycec is there a more beartype way to do this? I hacked this together to also get the type parameter info.
|
See https://plum-dispatch--272.org.readthedocs.build/en/272/generics.html for the user-facing API. |
|
@wesselb in a followup I'm interested in unleashing mypyc on a lot of the changes made in this PR. I think there's some major speedups to be had in building better compiled wheels. |
When dispatch_multi registers one function for multiple generic signatures (e.g. list[int] and list[str]), the slow-path cache-append guard was comparing by implementation identity (existing_impl is impl). Because both signatures share the same impl object, the second entry was never appended, leaving the bucket with only one hint_tuple. A subsequent call with an empty list would then match the single entry without ever detecting that a second, equally-specific candidate existed — silently returning instead of raising AmbiguousLookupError. Fix: deduplicate by hint_tuple equality instead. The number of distinct hint_tuples is bounded by the number of registered method signatures, so unbounded bucket growth is not a concern. Add a regression test: dispatch_multi registers _impl for both list[int] and list[str]; after priming the cache with [1] and ['a'], calling f([]) must raise AmbiguousLookupError.
Co-authored-by: Nathaniel Starkman <nstarman@users.noreply.github.com>
Clarify section titles and improve explanations regarding type inference with @plum.generic.
| # Find the index of the first existing method with an equal signature, | ||
| # using a generator so we stop at the first match rather than scanning | ||
| # the full list and building a boolean array. | ||
| existing_idx = next( | ||
| (i for i, m in enumerate(self.methods) if m.signature == signature), | ||
| None, | ||
| ) | ||
| if existing_idx is not None: | ||
| # Save the replaced method before overwriting: needed below to swap | ||
| # the reference inside _arity1_methods buckets. | ||
| _replaced_method = self.methods[existing_idx] | ||
| if self.warn_redefinition: | ||
| # Determine the new and previous implementation. Unwrap possible | ||
| # wrapping by Plum from :meth:`Function.invoke`s, which can obscure the | ||
| # location where the implementation was originally defined. | ||
| previous_method = self.methods[existing.index(True)] | ||
| prev_impl = _unwrap_invoked_methods(previous_method.implementation) | ||
| prev_impl = _unwrap_invoked_methods(_replaced_method.implementation) | ||
| impl = _unwrap_invoked_methods(method.implementation) | ||
| warnings.warn( | ||
| f"`{method}` (`{repr_source_path(impl)}`) " | ||
| f"overwrites the earlier definition " | ||
| f"`{previous_method}` " | ||
| f"`{_replaced_method}` " | ||
| f"(`{repr_source_path(prev_impl)}`).", | ||
| category=MethodRedefinitionWarning, | ||
| stacklevel=0, | ||
| ) | ||
|
|
||
| self.methods[existing.index(True)] = method | ||
| self.methods[existing_idx] = method |
| for j in successors[i]: | ||
| in_degree[j] -= 1 | ||
| if in_degree[j] == 0: | ||
| next_queue.append(j) |
|
Ping @wesselb :) |
|
...heh. This is deliciously intense. @beartype does, indeed, provide the public (and admittedly poorly documented)
Deciding the partial order between a type hierarchy of user-defined generics subscriptable by arbitrary type variables was especially... non-trivial. Merely to remember it, I grit my teeth in mental anguish. Still, it all works reasonably reliably to the best of my knowledge. Let us ignore a spate of recent @beartype issues filed against Examples or it didn't happen: from beartype.door import TypeHint
class Pep484GenericST[S, T](): pass
class Pep484GenericSInt[S](Pep484GenericST[S, int]): pass
print(TypeHint(Pep484GenericSInt) <= TypeHint(Pep484GenericST))
print(TypeHint(Pep484GenericSInt) <= TypeHint(Pep484GenericST[int, int]))
print(TypeHint(Pep484GenericSInt[int]) <= TypeHint(Pep484GenericST))...which prints (as thankfully expected): True
False
TrueIf you'd like even deeper introspection, @beartype can probably do that in theory but probably doesn't do that in practice. It's summertime. I cannot be held responsible for cycling instead of coding. 😂 |
|
Whoa, @nstarman, this is very very cool. I wasn't aware how far support for generics has come. This PR introduces a lot, but I'm very much in favour! I'll need to take a moment to sit down and go through all details very carefully, but on a skim everything seems to make sense. Is this ready for an in-depth review? |
|
I think so! GH does have some good comments about the topological algorithm, but treating that as a distinct sub-unit, I think the rest is ready. |
|
Fantastic! Let me dig into this. :) |

Implements support for Generics.
AI Disclosure: most ideas were mine. I used claude opus with R/G TDD to implement and refine the code. Then Copilot reviews to catch bugs and continue iterating.
I've spent some time optimizing dispatching on generics, but it's still slower than normal types, so bleeding into this PR are some various performance optimizations I added as I saw them. This should also speed up the "normal" dispatch routes on non-parametrized types!
Remaning Qs:
__method__that can be fast? @leycec this is for you :).