Skip to content
Open
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
2d0db17
feat: limited support for generics
nstarman May 19, 2026
a6ac22c
feat: honor __orig_class__ for custom generic dispatch
nstarman May 19, 2026
08ddce6
Add A[Any] explicit fallback for custom Generic dispatch
nstarman May 19, 2026
4b57bb2
Extract generic timing into standalone script + {include}
nstarman May 19, 2026
9a9d284
feat: add @plum.generic decorator for auto-inferring __orig_class__
nstarman May 19, 2026
e22b0d1
docs: remove % skip from bare-class-hint example, add live doctests
nstarman May 19, 2026
d9339b8
fix: type hints
nstarman May 19, 2026
5f4640c
fix: address Copilot PR review comments on generics support
nstarman May 19, 2026
cff0abe
tests: use dispatch fixture, add parametrize; clean up comments
nstarman May 19, 2026
56b9ac5
Use beartype.typing.Protocol instead of typing.Protocol
nstarman May 19, 2026
dff94e1
Fix is_generic_hint to exclude Annotated via __metadata__
nstarman May 19, 2026
7ee254c
Trust __orig_class__ by convention in is_bearable_with_orig and _arg_key
nstarman May 19, 2026
8f8c757
perf: build TypeHintWrapper pairs once in Signature.__le__
nstarman May 19, 2026
cecebad
perf: hoist type-tuple computation before needs_generic check
nstarman May 19, 2026
d11a3e8
perf: short-circuit Signature.__eq__ on cheap scalar checks
nstarman May 19, 2026
c44885f
perf: override is_comparable on Signature to call __le__ at most twice
nstarman May 19, 2026
3ee50f3
perf: eliminate intermediate list allocation in _resolve_from
nstarman May 19, 2026
d0217c3
perf: use weakref.WeakSet for Function._instances to prevent memory l…
nstarman May 19, 2026
6a6d318
fix: resolve_for_type falls back to full resolve on NotFoundLookupError
nstarman May 19, 2026
19d6476
fix: fall back to full resolve when prefiltered bucket misses Any/Uni…
nstarman May 19, 2026
0ebf1aa
test: document AmbiguousLookupError behavior with non-bucket Any fall…
nstarman May 19, 2026
b0eec5e
fix: mark test_resolve_from_does_not_materialise_filter_list as incom…
nstarman May 19, 2026
3c3452f
perf: update Resolver.register metadata incrementally instead of full…
nstarman May 19, 2026
23296dd
perf: replace layer-peeling sort with Kahn's topological sort
nstarman May 19, 2026
0590343
simplify: condense tests and source code
nstarman May 20, 2026
36ff88e
docs: add generics guide
nstarman May 20, 2026
0bed947
fix: @generic now correctly handles subscripted construction on froze…
nstarman May 20, 2026
7c294d1
fix: enforce @classmethod on __infer_type_parameter__ at decoration time
nstarman May 20, 2026
8fd538f
fix: deduplicate generic cache by hint_tuple, not impl identity
nstarman May 20, 2026
ebf98b6
Apply suggestions from code review
nstarman May 20, 2026
f6f0f95
Update generics.md for clarity on type inference
nstarman May 20, 2026
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
5 changes: 5 additions & 0 deletions docs/_generated/generics_timing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
| Call | Scenario | µs / call | vs faithful |
| :--- | :--- | ---: | ---: |
| `f(B(1))` | faithful — bare `B` overload | 4.01 | 1.0× |
| `f(A(1))` | generic — `A[Any]` fallback | 5.81 | 1.4× |
| `f(A[int](1))` | generic — `A[int]` overload | 6.28 | 1.6× |
103 changes: 103 additions & 0 deletions docs/_scripts/time_generics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Generate the generic-dispatch timing table for docs/generics.md.

Run directly::

uv run python docs/_scripts/time_generics.py

or via nox (which also builds the docs)::

uv run nox -s docs

Output is written to docs/_generated/generics_timing.md, which is
included verbatim by docs/generics.md via the MyST {include} directive.
"""

import dataclasses
import pathlib
import timeit
import typing

import plum

T = typing.TypeVar("T")


# ── Faithful baseline ──────────────────────────────────────────────────────
# A bare `B` overload with no parameterized overloads. Plum uses the fast
# faithful-cache path (cache key = type(arg), no __orig_class__ handling).


@dataclasses.dataclass
class B(typing.Generic[T]):
x: T


d_faithful = plum.Dispatcher()


@d_faithful
def f_faithful(b: B) -> str:
return "B"


# ── Generic dispatch ───────────────────────────────────────────────────────
# Three overloads on A: A[Any] (bare-instance fallback), A[int], A[str].
# Plum uses the two-tier generic cache, keyed on __orig_class__ when present.


@dataclasses.dataclass
class A(typing.Generic[T]):
x: T


d_generic = plum.Dispatcher()


@d_generic
def f_generic(a: A[typing.Any]) -> str:
return "A[Any]"


@d_generic
def f_generic(a: A[int]) -> str:
return "A[int]"


@d_generic
def f_generic(a: A[str]) -> str:
return "A[str]"


# ── Measure ────────────────────────────────────────────────────────────────

# Warm up all cache paths before measuring.
f_faithful(B(1))
f_generic(A(1))
f_generic(A[int](1))
f_generic(A[str]("x"))

N = 50_000

t_faithful = timeit.timeit(lambda: f_faithful(B(1)), number=N) / N * 1e6
t_any = timeit.timeit(lambda: f_generic(A(1)), number=N) / N * 1e6
t_int = timeit.timeit(lambda: f_generic(A[int](1)), number=N) / N * 1e6

rows = [
("f(B(1))", "faithful — bare `B` overload", t_faithful, 1.0),
("f(A(1))", "generic — `A[Any]` fallback", t_any, t_any / t_faithful),
("f(A[int](1))", "generic — `A[int]` overload", t_int, t_int / t_faithful),
]

# ── Write markdown table ───────────────────────────────────────────────────

lines = [
"| Call | Scenario | µs / call | vs faithful |",
"| :--- | :--- | ---: | ---: |",
]
for call, scenario, us, rel in rows:
lines.append(f"| `{call}` | {scenario} | {us:.2f} | {rel:.1f}× |")

out = pathlib.Path(__file__).parent.parent / "_generated" / "generics_timing.md"
out.parent.mkdir(exist_ok=True)
out.write_text("\n".join(lines) + "\n")
print(f"Written {out.relative_to(pathlib.Path(__file__).parent.parent.parent)}")
1 change: 1 addition & 0 deletions docs/_toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ chapters:
- file: conversion_promotion
- file: precedence
- file: parametric
- file: generics
- file: union_aliases
- file: autoreload
- file: command_line
Expand Down
231 changes: 231 additions & 0 deletions docs/generics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
# Custom Generic Types

Plum has **partial support** for dispatching on user-defined [generic classes](https://docs.python.org/3/library/typing.html#generics) — that is, classes you define yourself using `typing.Generic[T]`. This page explains exactly what works, what doesn't, and the recommended pattern for writing fallback overloads.

```{note}
Built-in generic containers like `list[int]` and `dict[str, int]` are fully supported and inspected by Beartype directly. This page is about *your own* generic classes such as `class Box(Generic[T])`.
```

## What works

Subscripted instances dispatch correctly:

```python
from typing import Any, Generic, TypeVar
from plum import dispatch

T = TypeVar("T")


class Box(Generic[T]):
def __init__(self, value: T) -> None:
self.value = value


@dispatch
def unwrap(b: Box[int]) -> str:
return "int box"


@dispatch
def unwrap(b: Box[str]) -> str:
return "str box"
```

```python
>>> unwrap(Box[int](1))
'int box'

>>> unwrap(Box[str]("hello"))
'str box'
```

This works because Python sets `instance.__orig_class__ = Box[int]` after `Box[int](1)` finishes constructing the instance. Plum reads that attribute during dispatch and uses Beartype's type-hint subtype ordering (`TypeHint(Box[int]) <= TypeHint(Box[str])`) to choose the most specific overload.

(generics-bare-instance-limitation)=
## The "bare instance" limitation

Python only sets `__orig_class__` when you instantiate through the subscripted form `Box[int](...)`. When you write `Box(1)` directly there is **no** parameterization information attached to the instance — neither Plum nor Beartype can recover `T`.

This means a bare `Box(1)` cannot be told apart from `Box[anything](1)`. To keep dispatch deterministic, Plum treats parameterized custom-generic hints as **non-matching** for instances that lack `__orig_class__`. With only the two overloads above, calling `unwrap(Box(1))` raises `NotFoundLookupError`:

```python
>>> from plum import NotFoundLookupError
>>> try:
... unwrap(Box(1))
... except NotFoundLookupError:
... print("no match")
no match
```

## The `A[Any]` fallback pattern

The recommended way to handle bare instances is to register an explicit **`Any`-parameterized fallback overload**. Extending the same `unwrap` above:

```python
@dispatch
def unwrap(b: Box[Any]) -> str:
return "unknown parameterization"
```

Now all three call shapes resolve cleanly:

```python
>>> unwrap(Box(1)) # no __orig_class__ → falls through to Box[Any]
'unknown parameterization'

>>> unwrap(Box[int](1)) # __orig_class__ = Box[int]; most specific wins
'int box'

>>> unwrap(Box[str]("hello")) # __orig_class__ = Box[str]; most specific wins
'str box'
```

`Box[Any]` is treated as a strict supertype of every other `Box[X]`, so:

- **Subscripted instances** still pick the most specific overload — `Box[int](1)` prefers `Box[int]` over `Box[Any]`.
- **Bare instances** match only `Box[Any]` (the other overloads are excluded because there is no parameterization to verify), so dispatch is unambiguous.

```{tip}
Think of `A[Any]` as the explicit way to say *"this overload is the fallback for any `A` instance whose parameter is unknown at runtime"*. Without it, bare instances will raise `NotFoundLookupError` when only parameterized overloads are registered.
```

## Bare class hints (`Box` without brackets)

You can also write a fallback against the **bare** (unsubscripted) class:
Comment thread
nstarman marked this conversation as resolved.
Outdated

```python
@dispatch
def g(b: Box) -> str:
return "bare Box"


@dispatch
def g(b: Box[int]) -> str:
return "int"
```

A bare hint like `Box` behaves the same way as `Box[Any]` for dispatch purposes — every `Box` instance matches it, and parameterized overloads still win for subscripted instances when their parameter agrees. `Box` and `Box[Any]` are interchangeable as fallback overloads; pick whichever reads more clearly in your codebase.

```python
>>> g(Box(1)) # bare instance — no __orig_class__ → Box fallback
'bare Box'

>>> g(Box[int](1)) # __orig_class__ = Box[int] → specific overload wins
'int'

>>> g(Box[str]("hi")) # __orig_class__ = Box[str] — no str overload → Box fallback
'bare Box'
```

## Auto-inferring types with `@plum.generic`

If your class can always infer `T` from the constructor arguments — for
example, `Box(1)` should behave exactly like `Box[int](1)` — you can opt into
automatic inference with `@plum.generic`:
Comment thread
nstarman marked this conversation as resolved.
Outdated

```python
from typing import Generic, TypeVar
from plum import dispatch, generic

T = TypeVar("T")


@generic
class Box(Generic[T]):
def __init__(self, value) -> None:
self.value = value

@classmethod
def __infer_type_parameter__(cls, instance):
return type(instance.value)


@dispatch
def unwrap(b: Box[int]) -> str:
return "int box"


@dispatch
def unwrap(b: Box[str]) -> str:
return "str box"
```

Bare instances now dispatch correctly **without** an `A[Any]` fallback:

```python
>>> unwrap(Box(1))
'int box'

>>> unwrap(Box("hello"))
'str box'

>>> unwrap(Box[str](1)) # explicit subscription still wins
'str box'
```

The decorator wraps `__init__` so that after construction it sets `instance.__orig_class__ = Box[inferred_T]`. When you use the subscripted form `Box[str](1)`, Python's own machinery overwrites that attribute after `__init__` returns, so explicit parameterisation always takes precedence. For frozen dataclasses (`@dataclass(frozen=True)`), `@generic` installs a custom `__setattr__` that allows `__orig_class__` to be updated despite the frozen restriction, so subscripted construction takes precedence there too.

**Inference rule**: define `__infer_type_parameter__(cls, instance)` as a classmethod that inspects the freshly-constructed instance and returns the type parameter. For multi-parameter generics return a tuple:

```python
from typing import Generic, TypeVar
from plum import generic

T = TypeVar("T")
S = TypeVar("S")


@generic
class Pair(Generic[T, S]):
def __init__(self, x, y) -> None:
self.x, self.y = x, y

@classmethod
def __infer_type_parameter__(cls, instance):
return (type(instance.x), type(instance.y))
```

`@generic` raises `TypeError` at decoration time if `__infer_type_parameter__` is not defined on the class or any of its ancestors.

```{note}
`@plum.generic` is a **lightweight** opt-in that simply sets `__orig_class__`. For richer parametric machinery — covariant subclassing, custom type-parameter validation, and multi-parameter ordering — see [Parametric Classes](parametric.md).
Comment thread
nstarman marked this conversation as resolved.
Outdated
```

## Why this isn't fully automatic

There are two fundamental limits at play:

1. **Python only attaches `__orig_class__` on subscripted construction.** An instance created via `Box(1)` simply does not carry information about `T`, so no runtime introspection can recover it.
1. **Beartype validates type parameters by inspecting elements**, which works for containers (`list`, `dict`, etc.) but not for user-defined generic classes whose type-variable usage is opaque to the runtime.

Rather than guess or silently pick an arbitrary overload, Plum requires you to state your intent explicitly via the `A[Any]` (or bare `A`) fallback overload.

## Summary

| Call | Without `@generic` | With `@generic` |
| ------------------------- | ---------------------------------------------------------------- | --------------------------------------------------- |
| `f(Box[int](1))` | `Box[int]` (or `Box[Any]` / `Box` if `Box[int]` not present) | `Box[int]` (same; explicit subscription always wins)|
| `f(Box[str]("x"))` | `Box[str]` (or `Box[Any]` / `Box` if `Box[str]` not present) | `Box[str]` (same) |
| `f(Box(1))` | `Box[Any]` or bare `Box` only; `NotFoundLookupError` if absent | `Box[int]` (inferred from `type(1)`) |

For more advanced parametric-class machinery (covariance, custom type-parameter inference, etc.), see [Parametric Classes](parametric.md).

(generics-performance)=
## Performance

Generic dispatch carries a small overhead compared to a regular faithful type. The table below is generated each time the documentation is built.

Two scenarios are measured:

- **Faithful** — only a bare `B` overload (no type-parameter overloads). Plum uses the fast faithful-cache path.
- **Generic** — `A[Any]`, `A[int]`, and `A[str]` overloads. Plum uses the two-tier generic cache, keyed on `__orig_class__` when present.

```{include} _generated/generics_timing.md
```

The faithful path is the fastest because Plum caches by `type(arg)` and checks membership with a single `issubclass` call. The generic path must additionally read `__orig_class__` (or detect its absence), build the two-tier cache key, and run the TypeHint subtype check on a cache miss.
Comment thread
nstarman marked this conversation as resolved.
Outdated

```{tip}
If your code only dispatches on the *class* `A` and never needs to distinguish `A[int]` from `A[str]`, declare a bare `A` (or `B` in the example above) overload and omit the parameterized ones. You get the faithful-cache speed with no change to the calling code.
```
6 changes: 6 additions & 0 deletions docs/types.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ supported, they do incur a performance penalty.
For optimal performance, is recommended to use parametric types only where necessary.
`Union` and `Optional` do not incur a performance penalty.

```{note}
Dispatching on your *own* generic classes (subclasses of `typing.Generic[T]`)
is also partially supported via Python's `__orig_class__` mechanism — see
[Custom Generic Types](generics) for the recommended pattern and its limitations.
```

````{important}
Plum's type system is powered by [Beartype](https://github.com/beartype/beartype).
To ensure constant-time performance,
Expand Down
14 changes: 14 additions & 0 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,17 @@ def pytest(s: nox.Session, /) -> None:
def benchmark(s: nox.Session, /) -> None:
"""Run the benchmarks."""
s.run("python", "tests/benchmark.py", *s.posargs)


@session(uv_groups=["docs"], reuse_venv=True)
def docs(s: nox.Session, /) -> None:
"""Build the documentation.

Runs the timing script first to regenerate docs/_generated/generics_timing.md,
then builds the Jupyter Book. The two steps can also be run separately:

uv run python docs/_scripts/time_generics.py
uv run jupyter-book build docs/
"""
s.run("python", "docs/_scripts/time_generics.py")
s.run("jupyter-book", "build", "docs/", *s.posargs)
3 changes: 3 additions & 0 deletions src/plum/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
# Overload
"overload", # TODO: deprecate
"get_overloads", # TODO: deprecate
# Generic decorator
"generic",
# Parametric
"CovariantMeta",
"parametric",
Expand Down Expand Up @@ -69,6 +71,7 @@
from ._bear import is_bearable as _is_bearable
from ._dispatcher import Dispatcher, clear_all_cache, dispatch
from ._function import Function
from ._generic import generic
from ._method import Method, extract_return_type
from ._overload import get_overloads, overload
from ._parametric import (
Expand Down
Loading
Loading