Skip to content

feat: mostly support Generic#272

Open
nstarman wants to merge 31 commits into
beartype:masterfrom
nstarman:generics
Open

feat: mostly support Generic#272
nstarman wants to merge 31 commits into
beartype:masterfrom
nstarman:generics

Conversation

@nstarman

@nstarman nstarman commented May 19, 2026

Copy link
Copy Markdown
Collaborator

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:

  1. Is this behaviour sensible, particularly the fallback dispatch? @PhilipVinc you Julia a lot....
  2. is there a beartype mechanism to inspect Generics, preferably a __method__ that can be fast? @leycec this is for you :).

nstarman added 6 commits May 19, 2026 15:16
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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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._generic helpers (is_generic_hint, le_generic) and export plum.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.

Comment thread pyproject.toml Outdated
Comment thread src/plum/_function.py Outdated
Comment thread src/plum/_generic.py
Comment thread src/plum/_resolver.py Outdated
Signed-off-by: nstarman <nstarman@users.noreply.github.com>
@coveralls

coveralls commented May 19, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 26164590132

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Coverage increased (+0.1%) to 99.596%

Details

  • Coverage increased (+0.1%) from the base build.
  • Patch coverage: 1 uncovered change across 1 file (207 of 208 lines covered, 99.52%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
src/plum/_generic.py 48 47 97.92%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1238
Covered Lines: 1233
Line Coverage: 99.6%
Coverage Strength: 6.8 hits per line

💛 - 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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 2 comments.

Comment thread src/plum/_generic.py
Comment thread src/plum/_bear.py
nstarman added 5 commits May 19, 2026 17:42
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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 22 out of 22 changed files in this pull request and generated 2 comments.

Comment thread src/plum/_resolver.py Outdated
Comment thread src/plum/_function.py

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 24 out of 24 changed files in this pull request and generated 3 comments.

Comment thread src/plum/_function.py Outdated
Comment thread src/plum/_function.py Outdated
Comment thread src/plum/_signature.py
nstarman added 2 commits May 19, 2026 20:01
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>
@nstarman

nstarman commented May 20, 2026

Copy link
Copy Markdown
Collaborator Author
CleanShot 2026-05-19 at 20 27 37

It's pretty performant! My naive implementations were ~20x the time. Now it's 1.6
And I sped everything up, so that 1.6 is pretty competitive with the old baseline speed.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 24 out of 24 changed files in this pull request and generated 3 comments.

Comment thread src/plum/_resolver.py
(i for i, m in enumerate(self.methods) if m.signature == signature),
None,
)
if existing_idx is not None:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WDYT @wesselb ?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are we dropping this? I mean registering should be something done rarely so it's not a huge issue... or is it?

Comment thread src/plum/_generic.py Outdated
Comment thread src/plum/_generic.py Outdated
@nstarman

Copy link
Copy Markdown
Collaborator Author

@wesselb this got to be a doozy, in a push one corner of the rug another corner pops up kind of way.

nstarman added 2 commits May 19, 2026 20:55
…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.
Comment thread src/plum/_bear.py
return isinstance(origin, type) and Generic in origin.__mro__


def is_bearable_with_orig(value: object, hint: Any, /) -> bool:

@nstarman nstarman May 20, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@leycec is there a more beartype way to do this? I hacked this together to also get the type parameter info.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 24 out of 24 changed files in this pull request and generated 3 comments.

Comment thread src/plum/_resolver.py
Comment thread src/plum/_function.py Outdated
Comment thread tests/test_type.py
@nstarman

Copy link
Copy Markdown
Collaborator Author

@nstarman

nstarman commented May 20, 2026

Copy link
Copy Markdown
Collaborator Author

@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.
Comment thread docs/generics.md Outdated
Comment thread docs/generics.md Outdated
Comment thread docs/generics.md Outdated
Comment thread docs/generics.md Outdated
nstarman added 2 commits May 20, 2026 08:53
Co-authored-by: Nathaniel Starkman <nstarman@users.noreply.github.com>
Clarify section titles and improve explanations regarding type inference with @plum.generic.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 24 out of 24 changed files in this pull request and generated 2 comments.

Comment thread src/plum/_resolver.py
Comment on lines +401 to +427
# 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
Comment thread src/plum/_resolver.py
for j in successors[i]:
in_degree[j] -= 1
if in_degree[j] == 0:
next_queue.append(j)
@nstarman

Copy link
Copy Markdown
Collaborator Author

Ping @wesselb :)

@leycec

leycec commented May 27, 2026

Copy link
Copy Markdown
Member

...heh. This is deliciously intense. @beartype does, indeed, provide the public (and admittedly poorly documented) beartype.door.TypeHint API. TypeHint defines a partial order over the family of almost all PEP-compliant type hints – including:

  • PEP 484-compliant user-defined generics like class MuhGeneric[T]():.
  • PEP 585-compliant container type hints like list[str].

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 beartype.door.is_subhint() that are surely ignorable. surely!

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
True

If 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. 😂 -> 😭

@wesselb

wesselb commented May 29, 2026

Copy link
Copy Markdown
Member

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?

@nstarman

nstarman commented May 30, 2026

Copy link
Copy Markdown
Collaborator Author

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.

@wesselb

wesselb commented Jun 4, 2026

Copy link
Copy Markdown
Member

Fantastic! Let me dig into this. :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants