RFC 0001: Hint-informed multiple dispatch for bagof.dispatchers
- Status: Implemented.
- Tracking issue: bagofseeds/bagof-dispatchers#1.
This document is the design record for the dispatch engine: the subtype relation between type hints, the rule that decides which registered method a call selects, and the corner cases the hint vocabulary forces a position on. Citations name a source without quoting it verbatim: [PEP 483] and [PEP 484] for the typing specification, [Julia] for Julia's own multiple-dispatch semantics, and [Wiki] for background terminology.
0. Overview
bagof.dispatchers is the lowest-level package in the bagof family. It owns
both halves of the design: the hint-level subtype relation (issubhint,
ishintstance, and the introspection helpers they need) and the
signature-level rule that chooses among registered methods, covering arity,
TypeVar consistency, Exact, MRO refinement, and ambiguity detection. The
top-level bagof.dispatchers namespace exposes only the dispatch machinery:
dispatch, Dispatcher, Function, Method, Signature, Parameter,
Exact, and the error types. The relation and its introspection helpers
live under bagof.dispatchers.core, the namespace the rest of the family
builds on. The package's only dependency is typing_extensions.
Dispatch follows Julia's symmetric model rather than Python's or CLOS's
asymmetric one. Among the methods applicable to a call, the winner is the
one whose argument types are a subtype of every other applicable method's,
under a partial order, never a summed distance and never a left-to-right
precedence. When two applicable methods are incomparable under that order,
an explicit priority= breaks the tie. Failing that, the arguments' own C3
MRO does, the way [functools.singledispatch] resolves a diamond. If the tie
still stands, the call raises AmbiguousMethodError, naming the candidates
in Julia's own format.
Plain type hints cannot express "this exact class and no subclass," so the
package adds one annotation for it: Exact[C], spelled
Annotated[C, EXACT] so that a type checker still sees plain C. The
subtype relation itself understands Exact, so anything built on the
relation, including converters and validators, gets exactness for free
(§4).
One engine serves two query modes. Calling f(*values) dispatches on the
runtime types of the arguments, through ishintstance; calling
f.resolve(*hints) dispatches on hints directly, through issubhint. The
single-key form of the second mode is resolve_hint, the direct successor to
the sibling packages' get_from_registry (§8.1).
Dispatch is name-aware: a call is bound to each candidate method the way
Python itself binds arguments to parameters, following
inspect.Signature.bind semantics, where positionals bind by position,
keywords bind by name, and keyword-only parameters are included. Specificity
then compares the hints that the same argument landed in across methods,
rather than comparing parameter names or positions directly. For a call with no keywords, against methods with no
*args, this reduces exactly to positional dispatch, but it also lets two
methods with differently named or differently ordered parameters both be
considered for one call. This is the model bagof.magic._polymorph already
uses for dispatching on field names, generalised (§8.2). It departs from
Julia and [plum], both of which dispatch on positional arguments only.
The package supports Python 3.8 through the newest and future versions.
Every typing construct is reached through import typing_extensions as tx;
an unrecognised or future hint degrades to Any-like behaviour and warns
once, rather than crashing a program that has not yet been updated for a new
Python release (§11).
Repeated TypeVars, as in def f(x: T, y: T), are supported for
applicability: the call is applicable only when its argument types have a
single, consistent solution for T. They are also supported for a narrow
specificity tie-break, where two otherwise equally specific methods that
differ only in how their repeated TypeVars group the arguments are settled
in favour of the one that ties more arguments to one consistent type (§3).
Julia's full diagonal dispatch, where a repeated TypeVar can additionally
discriminate on which type filled it, is out of scope.
1. Multiple-dispatch theory
Single dispatch is the model behind Python's ordinary methods and
[functools.singledispatch]: it chooses an implementation from the dynamic
type of one argument. Multiple dispatch chooses from the dynamic types of
several arguments at once Wiki]. The dispatched name is a generic
function; each registered implementation is a method; the methods
applicable to a call are those whose parameter types accept the call's
argument types; and the method actually chosen is the most specific
applicable one. Castagna, Ghelli and Longo formalised this in 1995 as
overloaded functions with late binding, governed by a partial order over
signatures.
A type can be understood as the set of values it describes PEP 483]: t1 is
a subtype of t2 when every value of t1 is a value of t2, equivalently
when every function accepting a t2 also accepts a t1. For instance,
bool is a subtype of int, which is a subtype of object. Hints other
than plain classes fit the same order: Literal[1] is below int, int is
below Optional[int], and Union[int, str] is below object. The order is
only partial: int and str, for instance, are incomparable.
A method's parameter list is compared against a call's argument types as a
tuple, position by position, and Tuple types are covariant [PEP 483]:
(bool, str) is a subtype of (int, str). This is why dispatch on
arguments is covariant: a method's signature is a tuple type, and the
method that wins is the one whose argument-tuple type is the smallest
supertype of the call's own [Julia].
For a call f(1, "a"), whose argument types form the tuple (int, str),
every method whose parameter types accept that tuple is applicable.
(int, str), (int, Any), (Any, Any), and (numbers.Real, str) all
qualify, and (int, str) is chosen because it is a subtype of every other
applicable signature [Julia]. Definition order plays no part in the choice.
The order need not have a unique smallest applicable element, and when it
does not, the call is ambiguous. Given g(x: float, y) and
g(x, y: float), a call g(2.0, 3.0) finds both applicable, and neither is
a subtype of the other, so the applicable set has two maximal elements and
no minimum. This is a property of the two registrations, not of the call,
and Julia raises MethodError rather than choosing between them, suggesting
that the caller define the method that would resolve the intersection
[Julia].
Dispatchers differ in how they handle this situation [Wiki]. CLOS resolves it
by argument precedence, comparing arguments left to right, and never reports
an ambiguity. Julia, Dylan, and Cecil instead treat every argument
symmetrically and raise. Among Python's own dispatch libraries,
[multipledispatch] warns and then picks a winner by topological order,
[plum] raises AmbiguousLookupError, and [functools.singledispatch] raises a plain
RuntimeError, but only between two of an argument's virtual base classes.
bagof.dispatchers is symmetric, in Julia's sense, with one addition: a
genuine tie is first offered to the arguments' own MRO before it is reported
as ambiguous (§2.2).
An earlier design for this engine summed a numeric "distance" between each argument's type and each parameter's declared type, and chose the method with the smallest total. That model was rejected, for reasons independent of the choices described later in this document:
- it invents a winner where the partial order has none, favouring whichever
candidate happens to sit shallower in
__mro__, an arbitrary tie-break with none of CLOS's predictability; - it is not monotone:
numbers.Integraldoes not appear inint.__mro__, sinceintreaches it through ABC registration rather than inheritance, so the naive distance falls back to a large sentinel value for that pair. A signature that is strictly more specific at one position can therefore still lose to one that is less specific there but shallower in the class hierarchy; - it has no answer for hints that are not classes at all, such as
Union,Literal,List[int], or aTypeVar; - every dispatcher actually in use, including Julia's
morespecific, [plum]'sSignature.__le__, [multipledispatch]'ssupercedes/ambiguous, and [functools.singledispatch]'s_find_impl, compares signatures with an order, never a metric.
2. The specificity model
2.1 The subtype relation: issubhint
issubhint(hint, superhint) is the primitive every other part of the engine
is built on: it answers whether every value described by hint is also
described by superhint. The table records that answer case by case; a bold
entry marks a result worth double-checking against intuition.
| Query | Result | Why |
|---|---|---|
bool ≤ int; diamond D ≤ B, D ≤ C |
True | nominal subtyping; a diamond yields two incomparable applicable methods |
Any ≤ object / object ≤ Any |
False / True | object is strictly more specific than Any; both may be registered, and object wins |
T ≤ Any / Any ≤ T (T unbound) |
True / True | an unbound TypeVar behaves as Any |
TB ≤ int / int ≤ TB (TB bound by int) |
True / True | a bound TypeVar is equivalent to its bound; it cannot express "exactly this bound" |
int ≤ TC, TC ≤ int, TC ≤ Union[int,str], Union[int,str] ≤ TC (TC constrained to (int, str)) |
True / False / True / True | a constrained TypeVar is equivalent to the union of its constraints |
list ≤ List, List ≤ list, List[int] ≤ list, list ≤ List[int] |
True / True / True / False | list and List are one equivalence class in what is otherwise a preorder; List[int] sits strictly below list ≡ List, since a bare list promises nothing about its contents |
List[bool] ≤ List[int], Dict[str,int] ≤ Dict[str,object] |
False | list and dict are invariant in their type arguments (PEP 484): one argument being a subtype of the other does not make one parametrisation a sub-hint of the other (§2.3) |
Sequence[bool] ≤ Sequence[int], Mapping[str,bool] ≤ Mapping[str,int] |
True | Sequence is covariant; Mapping is invariant in its key and covariant in its value (§2.3) |
Snk[int] ≤ Snk[bool] (Snk's TypeVar declared contravariant=True); Box[bool] ≤ Box[int] (Box's TypeVar carries no variance flag) |
True / False | a user generic reads its own declared TypeVar: contravariant reverses the order, and no flag means invariant (§2.3) |
IntBox ≤ Box[int], Sub[bool] ≤ Box[bool], Flip[int,str] ≤ Pair[str,int], IntBox ≤ Box[str] (class IntBox(Box[int]); class Sub(Box[T]); class Flip(Pair[B, A], Generic[A, B])) |
True / True / True / False | a sub-hint whose origin differs from the super-hint's is re-expressed through the bases its class was written with, then compared slot by slot (§2.3) |
Box[bool] ≤ Box[TB], Box[TB] ≤ Box[int], Box[int] ≤ Box[TC], Snk[bool] ≤ Snk[TB] (TB bound by int; TC constrained to (int, str)) |
True / False / True / False | a TypeVar filling an invariant slot is solved against its bound or constraints on the super side, but stands for its whole family on the sub side, so nothing concrete sits below it; a contravariant slot keeps the plain bound reading (§2.3) |
Child ≤ List[int], Child ≤ List[float], Child ≤ List[object], Child ≤ Sequence[object] (class Child(List[int])) |
True / False / False / True | Child is List[int] by nominal inheritance; invariance governs comparing that with another list parametrisation, while covariance still lets it widen through Sequence (§2.3) |
GL[bool] ≤ GL[int], GL[int] ≤ List[int], GL[int] ≤ list[str], Sub ≤ list[int] (class GL(list[T]), class Sub(GL[int]), Python 3.9+, with no Generic in GL's MRO) |
False / True / False / True | GL's parameters are the TypeVars its PEP 585 bases mention, read at their declared variance, and compared through those bases like any other generic (§2.3) |
Tuple[int] < Tuple[int, ...] < tuple, Tuple[int,...] ≤ Tuple[Any,...] |
True (a chain) | Tuple is covariant |
Tuple[int,str] ≤ Tuple[int,*Ts], Tuple[int] ≤ Tuple[int,*Ts], Tuple[int,*Ts] ≤ Tuple[*Ts], Tuple[int,*Ts,str] ≤ Tuple[int,*Ts] |
True | *Ts is an open run of zero or more Any: a fixed prefix captures whatever it leaves over, and a longer fixed prefix or suffix is stricter (§3) |
Tuple[int,*Ts] vs Tuple[*Ts,int]; Tuple[int,*Ts] vs Tuple[int,...] |
incomparable | a prefix run and a suffix run, or a *Ts run and a ... run, order neither way |
Tuple[*Ts] ≡ Tuple[Any,...] ≡ Tuple[*Us], Tuple[int,...] ≤ Tuple[*Ts], Tuple[*Ts] ≤ tuple |
True | a lone *Ts run is the widest tuple shape short of bare tuple |
Literal[True] < bool, Literal[1] < Literal[1,2] |
True | literals sit at the bottom of the order |
int ≤ Optional[int], None ≤ Optional[int], Optional[int] ≤ Union[int,str,None] |
True | ordinary union rules |
type[bool] < type[int] < type |
True | type[C] is covariant |
List[int] ≤ Sequence[int], List[int] ≤ Iterable |
True | ABC registration is honoured |
Annotated[int,'x'] ≡ int |
True (both directions) | metadata is invisible to the relation except Exact, which is unwrapped and handled before delegating to the inner type |
Callable\[[int],str] ≤ Callable\[[bool],str], and the reverse |
True, False | parameters are compared contravariantly (a narrower parameter list makes a more permissive callable) and the return type covariantly |
Callable\[[int],R] < Callable[Concatenate[int,P],R] < Callable[P,R] ≡ Callable[...,R] |
True (a chain) | ... and a bare ParamSpec sit at the top of the parameter-list order — they accept a callable of any signature — with a Concatenate prefix strictly between the top and a fully fixed list; ordering them this way keeps the relation transitive (§11.1) |
int ≤ P (a Protocol that is not runtime_checkable), and any other query against P |
False | Python's issubclass would raise here; the relation instead answers False, so an overload registered on P is reachable but never fires, and the call raises NoMethodError rather than the program crashing |
list ≤ RP (a runtime protocol) |
True | protocols dispatch structurally |
Named ≤ HasName, Record ≤ HasName, Ann ≤ HasName, CV ≤ HasName, Sub ≤ HasName, Other ≤ HasName (HasName a runtime protocol declaring the data member name: str; Named sets name = … on the class; Record is a dataclass with a name field; Ann only annotates it; CV declares it ClassVar[str] = …; Sub(HasName, Protocol); Other an unrelated protocol with the same member) |
True / True / True / False / True / False | Python's own issubclass refuses to answer here; the relation instead asks whether the class declares the member as the kind the protocol declares it — an instance variable or a class variable — the way mypy and pyright both read it, and the value level counts a declared member on every instance, so the order stays sound (§2.3) |
dict ≤ TD, TD ≤ dict, TD ≤ Mapping |
False / True / True | a TypedDict orders correctly at the hint level |
int ≤ Union (bare) |
False | a bare Union, Literal, or Type, with no arguments, means only "is one of these" in the abstract, and never actually applies to a value |
Annotated[int,'x'] ≤ Annotated (bare), int ≤ Annotated (bare) |
True / False | (0.2.0) a bare Annotated super-hint is now structural: only an Annotated form is below it, where before it was opaque and accepted everything |
type[Exact[C]\] ≤ type[C], type[C] ≤ type[Exact[C]\], type[Any] accepts every class |
True / False / — | Exact inside a type[...] position is the same identity leaf it is on its own, and type[...] now reads its argument through the full relation, so type[Any], type[Union[…]\] and type[T] all behave (§4) |
Hint[bool] ≤ Hint[int], int ≤ Hint[int], Hint[int] ≤ object, Hint[Exact[int]\] ≤ Hint[int] |
True / False / False / True | (0.2.0) Hint[X] describes type hints, not values: only another Hint form is ordered against it, covariantly by its argument, so no ordinary hint sits below it. Above it sit Any, a free TypeVar, a union that has a Hint member, and a wider Hint form (up to Hint ≡ Hint[Any]), but no ordinary class such as object; Exact inside narrows to the exact hint (§4) |
Type[Super[Animal]\] ≤ Type[Super[Dog]\], Type[Super[Dog]\] ≤ Type[Super[Animal]\] (class Dog(Animal)) |
True / False | (0.3.0) Super[C] inside Type accepts C and every class above it; the classes above Animal are among those above Dog, so lower bounds are ordered contravariantly by their bound (§4.2) |
Type[Exact[Dog]\] ≤ Type[Super[Dog]\], Type[Exact[Dog]\] ≤ Type[Dog] |
True / True | (0.3.0) the single class Dog belongs to both, so the exact form sits below the plain form and the lower bound alike (§4.2) |
Type[Dog] ≤ Type[Super[Dog]\], Type[Super[Dog]\] ≤ Type[Dog] |
False / False | (0.3.0) incomparable: each accepts a class the other refuses (a subclass of Dog, and object), although both accept Dog, so a call with Dog matching both is ambiguous and raises, and ambiguities() reports the pair (§5) |
Type[Super[Dog]\] ≤ Type[object], Hint[Super[int]\] ≤ Hint |
True / True | (0.3.0) every class is below object and every hint below Any, so a lower bound sits below the top of its form |
Type[Between[Dog, Animal]\] ≤ Type[Animal], Type[Between[Dog, Animal]\] ≤ Type[Dog] |
True / False | (0.3.0) Between[L, U] inside Type accepts the classes from L up to U; the classes from Dog up to Animal all lie below Animal, but Animal itself is not below Dog (§4.2) |
Type[Exact[Dog]\] ≤ Type[Between[Dog, Animal]\] |
True | (0.3.0) the single class Dog lies between Dog and Animal (§4.2) |
Between[Animal, Dog] |
TypeError |
(0.3.0) an empty interval is refused when it is written, and the message suggests Between[Dog, Animal] (§4.2) |
Dog() in Super[Dog], Animal() in Super[Dog], Puppy() in Super[Dog] |
True / True / False | (0.3.0) on a value, a bound constrains the value's class the way Type[...] of the same bound constrains a class passed in, so v in Super[C] when C ≤ type(v) (§4.2) |
Between[Dog, Animal] ≤ Animal, Dog ≤ Super[Dog], Exact[Dog] ≤ Super[Dog] |
True / False / True | (0.3.0) on a value, each form is an interval of classes read against type(v) and ordered by inclusion; a plain Dog is the interval from Never up to Dog, and its value Puppy() keeps it out of Super[Dog] (§4.2) |
issubhint(Super[Literal[1]\], int), ishintstance(1, Between[Never, List[int]\]) |
TypeError |
(0.3.0) on a value, each bound must be a hint that a class can be compared against, and a Literal or a parametrised generic is matched against the value itself (§4.2) |
List[int] ≤ List[Super[int]\], List[Integral] ≤ List[Super[int]\], List[bool] ≤ List[Super[int]\], List[Super[Integral]\] ≤ List[Super[int]\] |
True / True / False / True | (0.3.0) the whole argument of an invariant slot may be a bound, which names a range of arguments; one parametrisation is below another when its range lies inside the other's (§4.3) |
Sequence[Super[int]\], Sequence[Between[Never, int]\] ≡ Sequence[int] |
TypeError / True |
(0.3.0) a covariant slot already accepts every argument below the one written, so a lower bound there would be ignored and is refused, while an upper bound adds nothing and reads as the plain argument (§4.3) |
Dict[str, Between[Never, int]\] ≤ Mapping[str, int], Dict[str, Super[int]\] ≤ Mapping[str, int] |
True / False | (0.3.0) a range that reaches a covariant slot through the bases is read through its upper end (§4.3) |
W[Any] ≤ Snk[int], W[Any] ≤ Snk[Never], W[T] ≤ Snk[T], W[Any] ≤ Snk[Any] (class W(Snk[T]), Snk contravariant) |
False / True / False / False | (0.3.0) Any at W's invariant slot stands for every argument, and it reaches Snk's contravariant slot as that whole range rather than as the point Any; the family of every W[Y] is therefore below Snk[X] only when every Snk[Y] is, which never holds for Snk[Any] or Snk[T], since both sit at the bottom of the contravariant order (§2.3) |
issubhint(1, int), issubhint(1, 1), issubhint(int, 1) |
TypeError |
(0.2.0) a non-hint on either side is a caller error, reported for the left argument first, the way issubclass rejects a non-class; a non-hint no longer reads as Any |
The value-level check, ishintstance, does not look at the values held
inside a container. [1] in List[str] is True, because the items of the
list are never examined and a plain list carries no type argument to check
them against. A TypedDict is the one exception. Its value-level check
reads the mapping's own shape, comparing the keys the mapping provides
against the keys the TypedDict declares, so a mapping that supplies the
declared keys is in TD even though a plain dict never orders below a
TypedDict at the hint level (§2.3).
When a value's own class does declare a parametrisation, that declaration
is read and used (§2.3). Box[int]() records Box[int] and is not in
Box[str]. GL[int](), for class GL(list[T]), records GL[int] and is
not in list[str], and an instance of class Child(List[int]) is not in
List[float]. Box(), a base class with a free T
(class C(List[T])), and a value that only declares Any, all stay
shallow. print in Callable\[[int],str] is True: a callable's own signature
is never inspected, and a ParamSpec is never solved from a value.
True in Literal[1] is False ([PEP 586]); 1 in T is True; 'x' in TB is
False (for TB bound by int). v in HasName is True exactly when v has
a name, set on the instance or declared by its class, and False
otherwise, so two instances of the same class can genuinely differ.
A Hint[X] is checked at the value level against the hint passed as a
value: int in Hint[int] and bool in Hint[int] are True, str in
Hint[int] is False, and 1 in Hint[int] is False because 1 is not a
hint at all. Union[int, str] in Hint[Union] is True, and Union in
Hint[Exact[Union]\] is True while Union[int, str] in Hint[Exact[Union]\]
is False. Because a Hint matches on the value rather than on its Python
type, the call cache keys such an argument on the hint itself.
(0.3.0) A lower bound turns the value-level match around. A class v is
in Type[Super[C]\] when C ≤ v, so Animal and object are in
Type[Super[Dog]\] while Puppy, int, and the instance Dog() are not.
A hint v is in Hint[Super[X]\] when X ≤ v, so numbers.Integral,
object, Any, a free TypeVar, and Union[int, str] are
in Hint[Super[int]\], while bool, Literal[1], str, and the value 1
are not. An interval asks for both bounds at once. A class v is in
Type[Between[L, U]\] when L ≤ v and v ≤ U, so Dog and Animal are
in Type[Between[Dog, Animal]\] while Puppy and object are not, and a
hint is in Hint[Between[L, U]\] under the same condition, so int,
numbers.Integral, and numbers.Real are in Hint[Between[int, Real]\]
while bool, object, and Any are not.
(0.3.0) On a value parameter, the same bounds are read against the
value's own class. A value v is in Super[C] when C ≤ type(v), and
in Between[L, U] when L ≤ type(v) and type(v) ≤ U, so Dog() and
Animal() are in Super[Dog] while Puppy() and 1 are not, and Dog()
and Animal() are in Between[Dog, Animal] while Puppy() and
object() are not. This is the rule Type[...] applies, with type(v) in
place of the class passed, and it is the rule a plain class already
follows, since v in C when type(v) ≤ C.
2.2 Binding and selection (name-aware, normative)
Throughout, ⊑ denotes issubhint, already extended to understand Exact
(§4). Dispatch binds a call to each candidate method before comparing
them, and specificity compares the hints of the slots each argument landed
in, rather than comparing parameter names or positions directly. Comparing
name to name would be wrong: def f(a, b) and def f(x, y), called as
f(1, 2), clearly compete for the same two arguments, despite using
different parameter names.
A signature S is a triple (params, varargs, varkw). params is an
ordered mapping from parameter name to Parameter(hint, kind, default),
where kind is one of POSITIONAL_ONLY, POSITIONAL_OR_KEYWORD, or
KEYWORD_ONLY; varargs is the optional *args hint, written h_*;
varkw is the optional **kwargs hint, written h_**. Every parameter in
params is dispatched: an unannotated parameter is treated as Any, so it
still participates, just trivially. The return annotation plays no part in
dispatch.
A call C is (v_0, …, v_{n-1}; {k_j: w_j}): n positional values and
a mapping of keyword values. Its shape σ(C) is (n, sorted keyword
names), and its arguments Args(C) are the positions 0..n-1 together
with the keyword names k_j.
Binding, bind(S, C), restates inspect.Signature.bind, precomputed
once per method for speed. Positional values fill the positional slots in
order, with any surplus going to *args or, if there is none, failing the
bind. Each keyword fills the same-named keyword-able parameter if it is not
already filled, otherwise **kwargs, otherwise the call fails to bind. A
required parameter left unfilled fails the bind as well, while a parameter
left unfilled but carrying a default is default-filled. When binding
succeeds, hint_S(a) denotes the hint of the slot argument a landed in; it
is Any for an argument absorbed by an unannotated catch-all.
A method is applicable to a call's values when bind(S, C) succeeds and,
for every argument a in Args(C), ishintstance(value_a, hint_S(a))
holds. An extra positional is checked against h_*, and an extra keyword
against h_**; every bound argument must also satisfy the TypeVar
consistency rule of §3. Default-filled parameters are not arguments, so
their hints are neither checked nor compared for specificity
(Function(dispatch_defaults=True) is the documented exception, §6). A
method is applicable to a call's hints, for the resolve(*hints,
**named_hints) query mode, by the same rule with issub(q_a, hint_S(a)) in
place of the value check.
Specificity is defined per call, not once at registration: for two methods
applicable to the same call C, A ⊑_C B when, for every argument a in
Args(C), hint_A(a) ⊑ hint_B(a), together with the §3 consistency rule.
Which slot an argument lands in depends on the call's shape, so the order
itself is defined per shape and computed and cached per shape (§6) rather
than once at registration. For every fixed shape, though, it remains a
genuine partial order, so real ambiguities still surface rather than being
masked.
For a call with no keywords, against methods that declare no *args,
hint_S(i) is exactly the i-th hint of the classic positional tuple, so
applicability, ⊑_C, the maximal set, and every tie-break below coincide
exactly with a plain positional dispatcher. This reduction is checked
directly: the test suite keeps a positional reference implementation and
asserts that the two agree over generated positional calls.
A keyword that names no declared parameter binds to **kwargs, or makes the
method inapplicable if there is none. When **kwargs is annotated, such a
keyword is checked against its hint and enters specificity through
hint_S(a); unannotated, it is checked against Any.
Every keyword a call spills into one **kwargs: T is grouped into a single
block for the §3 grouping tie-break, so a method with **kwargs: T is more
specific than one with an untyped **kwargs. Applicability solves T
jointly over that whole group, together with every other slot the same T
reaches, exactly as *args: T does over the positionals it absorbs. So
f(a=1, b=True) matches with T = int, while f(a=1, b="x") has no
consistent solution and does not match. Unpack[TD] written on **kwargs
is treated as unannotated (§11).
Selecting one method among those applicable to a call C proceeds in four
steps:
- Compute the maximal set,
Max: the applicable methods with no strictly more specific applicable rival under⊑_C. If exactly one method remains, it is chosen. - Among the methods in
Max, keep only those with the highest explicitpriority(default0). - Among those, apply MRO refinement, argument by argument. Method
Adominates methodBwhen, for every argumenta, eitherhint_A(a) ≡ hint_B(a), or both hints are classes appearing intype(value_a).__mro__, withA's class no further from the query thanB's and strictly nearer for at least one argument. Hints are unwrapped for this comparison, andExact[C]reads asC, so anExact[C]refines only at the argument where it agrees withC. This resolves a diamondclass D(B, C)toB, the same way [functools.singledispatch] resolves it. Protocols and ABCs that do not appear in the MRO, unions, literals, and parametrised generics give no refinement at all. As a result, a diamond over two parametrisations of one generic (class Both(Ints, Strs), whereIntsisBox[int]andStrsisBox[str]) stays ambiguous between overloads onBox[int]andBox[str](§2.3). A refinement can never override a strict specificity win at another argument: if the method MRO would favour disagrees with⊑_Cat some other position, the pair stays ambiguous rather than being decided by MRO alone. - Among those, apply tightness, which restates the classic arity rule:
fewer arguments absorbed by a catch-all (
*argsor**kwargs) wins, then fewer default-filled parameters, then no**kwargsat all, then no*argsat all.
If more than one method survives every step, one further tie-break applies
before the call is declared ambiguous: the repeated-TypeVar grouping
refinement of §3, which only ever settles a tie step 4 left standing, and
never overturns a strict win from an earlier step. resolve_hint (§8.1), the
single-key lookup this design also provides, applies the same step-3 MRO
refinement to break a tie between equally specific class keys, using the
query's own MRO in place of an argument's runtime type. So {Enum, str}
resolves class Color(str, Enum) to str, and {C, B} resolves the
diamond D(B, C) to B.
After every step, exactly one method surviving means that method is chosen.
More than one means AmbiguousMethodError, and no method both bindable and
applicable means NoMethodError. The latter's message distinguishes three
causes: no method accepts a given keyword, with a difflib-based "did you
mean" suggestion drawn from every method's parameter names; a required
argument is missing for every candidate; or the argument types matched
nothing.
Priority is checked before MRO because an explicit choice should always beat an implicit one. Both refinements are necessarily partial, since each only ever compares one argument's hint against the same argument's counterpart elsewhere, so a genuine conflict across two different arguments is left ambiguous by either of them, and neither can override a strict specificity win.
A handful of cases are worth spelling out, because they show what "name-aware" means in practice:
- Two methods with the same parameter names in the same order behave exactly
like plain positional dispatch, for every spelling of a call such as
area(c, 2.0). - Two methods with different parameter names, such as
f(a, b)againstf(x, y), compete on the same two arguments for a positional call,f(1, 2), but a keyword call binds only to the parameter names that actually exist on each side. - A keyword reachable only through one method's
**kwargsstill loses to a method that declares that name as a real parameter, when both are otherwise applicable. - Two methods with the same names in a different order are equivalent for a
positional call, and therefore genuine duplicate registrations to be
avoided, but they are only ambiguous, not duplicate, for a keyword call,
unless a
priorityseparates them. - A positional-only parameter,
def p(x, /), makesp(x=1)fail to bind at all, raisingNoMethodError.
2.3 Where variance enters
PEP 483 defines variance for a generic type G: given t2 ⊑ t1, G is
covariant if G[t2] ⊑ G[t1], contravariant if G[t1] ⊑ G[t2], and
invariant if neither holds. Variance belongs to the generic's parameter
position, as the type's author declares it, never to the particular
argument filling that position at a call site. The relation reads each
position's declared variance and applies it slot by slot, comparing a
sub-hint's argument A against a super-hint's argument B at the same
position. A covariant position requires A ⊑ B, a contravariant one
requires B ⊑ A, and an invariant one requires A ≡ B. The one exception
is Any, or a free TypeVar, on the super side, which remains a top that
even an invariant slot may widen to, following gradual typing's consistency
rule rather than strict equivalence. Nesting composes by recursion, so the
signs of nested positions multiply.
Where a position's variance comes from
A user-defined generic reads its own declared TypeVar live, off
__parameters__. A TypeVar marked covariant=True gives a covariant
position, one marked contravariant=True gives a contravariant one, and one
marked as neither is invariant, following PEP 484. This is the variance
the TypeVar declares, not necessarily the variance of the position it
happens to fill. An unflagged T is invariant wherever it is used, so the
equivalent class written against List[T] gets no relief just because two
of its parametrisations happen to be related. A T_co written into one of
list's own invariant slots is a mistake a type checker reports on the
class itself, yet the relation still reads it at its word, as covariant,
matching what a checker goes on to do with it. Such a class is ill-typed,
and the order over it is only a preorder rather than a genuine partial order
among well-typed classes: it is possible for Cov[bool] ⊑ Cov[int] ⊑
list[int] to hold while Cov[bool] is not a sub-hint of list[int].
A PEP 695 type parameter declared infer_variance asks a type checker to
work out its variance from where it is used inside the class body. The
result is covariant if it only appears in read or return positions,
contravariant if it only appears in write or parameter positions, and
invariant if it appears in both. That inference is a piece of static
analysis a type
checker performs; nothing about it is computed or stored at runtime. Such a
parameter exposes only __infer_variance__ = True at runtime, with
__covariant__ and __contravariant__ both False, and the inferred
result itself is never materialized anywhere (checked directly against
Python 3.13 and 3.14). The library could replicate the inference by walking
the class body the way a checker does, but that would mean duplicating a
type checker for a benefit too small to justify it. Reading such a
parameter as invariant instead is the sound, conservative choice: invariant
is the most restrictive variance in the order, so treating an
infer_variance parameter this way can only make dispatch stricter, never
produce an unsound match. Someone who needs the exact covariant or
contravariant behaviour can declare an explicit TypeVar(..., covariant=True)
or contravariant=True, whose variance the library reads directly.
A class whose only generic bases are PEP 585 aliases, such as
class GL(list[T]) or class GD(dict[str, T]) (available from Python 3.9
onward), lists no __parameters__ of its own. Its parameters are instead
the TypeVars those bases mention, taken in order of first appearance, the
way Generic itself would collect them, and each is read the same way as
above. GL[bool] is therefore not a sub-hint of GL[int], for exactly the
reason a plain class GL(List[T]) would not be: the unflagged T it
inherits from list is invariant, regardless of which spelling of the base
introduced it.
class GL(list[T]): # a PEP 585 base, with no `Generic[T]` in sight
...
issubhint(GL[bool], GL[int])
# False -- `T` is unflagged, so `list`'s invariance still applies
A standard-library generic's variance is looked up in a table vendored from
CPython's own typing module, the reference implementation of the typing
specification. list, set, dict, and MutableSequence are invariant.
Sequence, frozenset, Collection, Iterable, and Type[C] are
covariant, Mapping is invariant in its key and covariant in its value, and
Generator and Coroutine are covariant in what they yield and return and
contravariant in what is sent to them. A CI test on Python 3.8 regenerates
this table from the live typing module and asserts that it still agrees,
so the table stays anchored to the specification rather than drifting from
it. Tuple and Callable are not in this table; each keeps its own
dedicated comparison, tuple shape for Tuple and contravariant parameters
with a covariant return for Callable.
As a consequence, some same-origin comparisons are narrower than a naive
subtype check would suggest. Because list is invariant, List[bool] is
not a sub-hint of List[int], where reading it by argument subtyping alone
would say it was. Only same-origin pairs where one argument is a genuine
subtype of the other, inside an invariant container, are affected this way.
A covariant container such as Sequence or frozenset keeps the order a
naive reading would expect, while a contravariant one reverses it. Two
List[X] overloads whose arguments are subtype-related become incomparable
rather than ordered, and are therefore ambiguous at a call unless a
priority separates them. Registering both is legitimate, not a mistake to
report: a plain list value declares no type argument at all, so both
overloads genuinely apply to it, while a value whose class declares its own
parametrisation, described below, is still dispatched precisely.
ambiguities() does not list this pair, and the ambiguity, when it exists,
only shows up at the call.
Differing origins are handled by expressing the sub-hint through the
super-hint's origin. When a sub-hint's origin differs from a super-hint's,
as when a user subclass is compared against one of its generic bases, it is
re-expressed as a parametrisation of the super-hint's origin, through the
bases the sub-hint's own class was written with. This means its
__orig_bases__, read off the class's own namespace; a class that declares
none of its own is followed through its plain __bases__ instead.
Every base the class was written with is followed this way, and the
sub-hint counts as below the super-hint if any of the parametrisations it
reaches that way is. For example, a diamond class D(A, B), where A is
written as Box[int] and B as Box[str], is genuinely both a Box[int]
and a Box[str], the same way class Two(List[T], Container[U]) makes
Two[int, str] both a Container[int] and a Container[str]. A type
checker would reject a
class shaped like this. Accepting it here is what keeps the relation
transitive through every base a class names, rather than silently picking
the first one, and the consequence is that such a value is genuinely
ambiguous between overloads on two of the parametrisations it reaches.
Both(), for the D(A, B) example above, is ambiguous between a
Box[int] overload and a Box[str] overload. Two[int, str]() is
likewise ambiguous between a Container[int] overload and a
Container[str] overload. Picking only the first listed base would have
resolved either case silently and, from the caller's point of view,
arbitrarily. Registering a third overload on the more specific Ints type
alone does not settle Both() either, since Ints is below Box[int] but
not below Box[str], and MRO refinement gives no index to a parametrised
generic; a priority is needed to choose.
Filling in each base uses typing's own subscription machinery: Box[T][bool]
is Box[bool], and this pairs each base's own type variables with the
sub-hint's arguments by identity rather than by position, so
class Flip(Pair[B, A], Generic[A, B]) correctly makes Flip[int, str] a
Pair[str, int]. A base with no free variable of its own, such as
Box[int], is used exactly as written, which is what makes
class IntBox(Box[int]) a Box[int]. A parametrised standard-library base,
such as List[int], is read positionally against a standard-library origin
it subclasses that takes the same number of arguments, exactly as two
standard-library origins have always been compared.
(0.3.0) An argument that stands for more than one type keeps doing so
when it reaches a base. Any or an unconstrained TypeVar at an invariant
slot of the sub-hint stands, on the sub side, for every argument it admits
(see "Any and gradual typing" below), so W[Any] for class W(Snk[T])
stands for every W[Y], and therefore for every Snk[Y]. When such an
argument lands as the whole argument of the base's slot, it is handed over
as the range it stands for, Between[Never, B] for a TypeVar bounded by B
and Between[Never, Any] otherwise, and read at the base's variance the
way §4.3 reads a bound: through its upper end at a covariant slot, which is
what the plain argument already gave, and through its lower end at a
contravariant one, where the plain argument Any would have been the
bottom of the order instead. A constrained TypeVar at an invariant slot
stands for each of its constraints in turn, so the sub-hint is below when
each of those parametrisations is. Handing over the point Any instead
would put W[Any] below Snk[int] while W[bool], one of the
parametrisations it stands for, is not, and the order would stop being
transitive. An open argument nested inside another hint, as in
class N(Sequence[List[T]]), is handed over as written.
Sometimes nothing can be mapped this way:
- a runtime-only standard-library subclass such as
Counter, which records no parametrised base at all; - a generic class written bare, the way a bare
Boxwould be; - a
ParamSpecorTypeVarTuplegeneric; - a base whose substitution itself raises;
- an arity mismatch, such as comparing
Dict[K, V]againstIterable.
In each of these cases, the arguments fall back to being compared
positionally, the way they always were before this mechanism existed.
Tuple, Callable, and Type keep their own dedicated comparisons
throughout.
Argument positions in a call are themselves covariant, independently of the
variance inside any one hint: a parameter consumes the value passed to it,
so a call is applicable when type(v) ⊑ P, and a smaller P is more
specific. This is exactly Tuple's covariance applied to the whole argument
tuple, since Julia's method signatures are themselves tuple types, and it is
a separate axis from the variance of a position inside one hint, discussed
above.
A value's declared parametrisation, not its contents, governs how deeply it
is checked. Dispatch never inspects a container's contents: type([True])
is plain list, so [1] still satisfies ishintstance against
List[int], regardless of the container's declared variance, because a
plain list promises nothing about what it holds. But when a value's own
class does declare a parametrisation, the value check uses it. This is
read, in order, from two places. The first is the instance's
__orig_class__, which typing sets when a generic alias like Box[int]
is called, and which the runtime alias type sets the same way for a class
such as class GL(list[T]) when GL[int]() is called. This is read only
off an instance of a Generic subclass, or of a class whose MRO carries a
PEP 585 base, and it is compared against any parametrised class hint,
whether a user generic or a standard-library one (so Row[int](), for
class Row(Sequence[T]), is a Sequence[int] and not a Sequence[str]),
and it is used whenever it re-expresses as a parametrisation of the hint's
own origin. The second, failing that, is the class's own written bases,
used when type(v) itself re-expresses as a parametrisation of the hint's
origin, the way class Child(List[int]) does. Either path decides the
check by issubhint(declared, G[args]).
A value's declared parametrisation is used to re-express a hint only when
every base reaching a given origin is fully declared, not merely one of
them. Two[Any, str](), for class Two(List[T], Container[U]), reaches
Container[Any] through its List base and Container[str] through its
own; because these disagree, the check stays shallow rather than picking one
of them. Reading it by Container[str] alone, for instance, would reject
the value as a Container[bytes]. Yet the same value would still pass,
shallowly through List[Any], as a Collection[bytes], which sits below
Container[bytes] in the order — putting the value in a sub-hint without
putting it in that sub-hint's own super-hint.
This is why the hint-level relation and the value-level check use different quantifiers over the same set of bases. The hint-level relation, above, accepts a sub-hint through any one base that reaches the super-hint's origin, because a hint carries no ambiguity about which parametrisation is meant. A value's declared parametrisation, by contrast, is trusted only when every base reaching a given origin agrees, because trusting just one while another disagrees could accept a value that a stricter reading through the other base would reject.
Short of a declared parametrisation, the check stays shallow in a handful of cases:
- a plain built-in instance, including one built from a PEP 585 alias
such as
list[int]([1]), which is simply a plain list; - an instance built from a bare, unparametrised class;
- a
__slots__class with no__dict__to record a parametrisation in; - a frozen dataclass, where
typingitself swallows theFrozenInstanceErrorit would otherwise raise while trying to record one; self, seen from inside its own__init__, since the record is only written after__init__returns;- a declared parametrisation whose arguments include a free TypeVar,
Any, or an unresolved name (class C(List[T]),Box[Any](),Box["int"]()), since none of these say what the value actually holds.
A ParamSpec or TypeVarTuple generic, whose arguments do not pair
one-to-one with parameters, also stays shallow.
Where a value does carry a declared parametrisation, the comparison follows
the specification's variance rules exactly, which is stricter than the
shallow check in an invariant position. Box[int]() no longer matches
Box[object], Box[Union[int, str]\], or Box[Optional[int]\], and an
instance of class Strs(List[str]) no longer matches List[object],
though it still matches List[Any], plain list, and the covariant
Sequence[object].
A TypeVar filling the hint's invariant slot is solved the same way as at
the hint level, below. Box[int]() still matches Box[T] when T is
unbound or bound by object or by numbers.Real, and it matches a TypeVar
constrained to (int, str), but not one bound by float, since there is
no numeric-tower promotion here. Tuple, Callable, Type[C], and
TypedDict each keep their own value-level checks, untouched by this
mechanism.
Any and gradual typing
The typing specification distinguishes
subtype of from consistent with PEP 483: Any is consistent with
everything, but is neither a subtype nor a supertype of anything else, while
object remains the nominal top of the ordinary subtype order. A dispatcher
still has to order (object,) against (Any,) for two overloads to make
sense, and the relation answers that object is strictly below Any. So Any,
and an unannotated parameter, form the widest possible catch-all, and an
object-typed method beats it, matching Julia's own reading. Inside an
invariant slot, the same consistency reading keeps a free TypeVar or Any
above every parametrisation G[X], so a generic fallback overload stays
comparable to more specific ones rather than becoming incomparable with all
of them.
A TypeVar filling an invariant slot is solved against its bound or
constraints, not read as exactly its bound. On the super side, it stands
for some type within its bound or constraints, exactly as a type checker
would solve it. On the sub side, by contrast, it stands for the whole
family of types that could fill it, which no single concrete type can be
said to contain. A free TypeVar, or Any, remains the top of the order as
above. For a TypeVar
bounded by B: G[A] ⊑ G[TB] when A ⊑ B, since the TypeVar is read by its
own bound. So Box[bool] ⊑ Box[TB] when TB is bounded by int, and a
Box[TB] fallback sits above every one of its concrete specialisations; two
bounded TypeVars order by comparing their bounds. For a TypeVar constrained
to (C1, …, Cn): an argument A must be equivalent to one of the Ci, and
a constrained TypeVar on the sub side must have each of its own constraints
match one of the super side's. A TypeVar on the sub side is never below a
concrete super-side type: Box[TB] is not below Box[int], while
Box[int] is strictly below Box[TB]. Each of these rules reduces to ⊑
or ≡ against the super side's bound or constraints, so the resulting order
remains a preorder.
A covariant slot already reads a TypeVar by its bound, which is exactly what
"solving" it gives; nothing changes there. A contravariant slot,
however, keeps the plain bound reading rather than solving, because solving
it would mean asking whether the two sides overlap, that is, whether some
type exists below both bounds, and overlap is not transitive. In a diamond
class D(B, C), both B and C overlap with D without overlapping each
other, and deciding overlap in general would require knowing the whole,
open class hierarchy, which the relation cannot do. So Snk[bool]() does
not match Snk[T] bounded by int, even though a type checker would
accept it. This is a documented limitation, accepted because it keeps the
relation itself computable and transitive, rather than trying to answer a
question that is not decidable over an open hierarchy.
(0.3.0) Bounds on a position. A Super[C] or a Between[L, U] written
as the whole argument of an invariant slot names a range of arguments, and
G[A] ⊑ G[B] holds when the range of A lies inside the range of B
(§4.3). A plain argument is a range of one point, and a TypeVar bounded by
B is the range Between[Never, B], which is exactly the super-side rule
above restated; the sub-side rule is the same range read from the other
side. At a covariant or contravariant slot the declared variance already
widens a plain argument, so a range there means its upper or its lower end.
A bound that says only that is accepted and read as the plain argument, and
one whose other end would be ignored is refused, which is how Kotlin treats
a use-site projection on a parameter that declares its variance.
A generic that mixes variance across its positions can leave two of its
parametrisations incomparable. Generator[Y, S, R] is covariant in what
it yields and returns and contravariant in what is sent to it. Any is the
top of a covariant position but the bottom of a contravariant one, so
Generator[int, None, None] and Generator[int, Any, Any] order neither
way. Two overloads built on them are therefore ambiguous, which is sound,
since an antisymmetric preorder is allowed to leave some pairs
incomparable.
A generic-class TypeVar from bagof.hints.typevars carries its variance
the same way. hints.typevars.co.INT declares a covariant position,
contra.INT a contravariant one, and inv.INT (as well as infer.INT) an
invariant one, when such a variable fills a user generic's declared
parameter. PEP 484 and the type checkers forbid a variance-flagged TypeVar
from being used as a plain function parameter; when one is anyway, it is
read by its bound, and Exact[int] remains the tool for asking for
exactness (§4).
The value-level check is deliberately structural where the hint-level
relation is nominal. ishintstance(v, H) is not defined as
issubhint(type(v), H): for a runtime Protocol, Hashable, or
Callable, the value check asks whether the value itself has the
required capability, while the hint-level relation asks a nominal question
about the type. So ishintstance(object(), Hashable) is true, because a
plain object instance has __hash__, even though
issubhint(object, Hashable) is false. A Mapping subclass that sets
__hash__ = None on itself is exactly why the nominal answer is the
conservative, safe one for the type-level relation to give. The two query
modes are deliberately different for this reason: f(value) asks a
structural question and f.resolve(hint) asks a nominal one, and collapsing
them into one answer would make value dispatch miss a value that
structurally satisfies a protocol. This sits in the same family of
gradual-typing corner as the Callable[...] wildcard, and the affected
comparisons, Hashable reached only through object and the
Callable[...] wildcard, are deliberately excluded from the property-tested
preorder-law corpus described below, rather than special-cased into it.
Protocols with data members
Python's own issubclass refuses to answer for a runtime-checkable
protocol that declares a data member, such as name: str, because whether
an instance has that attribute is a property of the instance rather than
the class. The relation instead reads such a protocol member by member. A
class is below the protocol when it declares every member the protocol
does. A value is in the protocol when it actually holds every member. These
are two related but distinct questions, worked out below, whose answers can
come apart for exactly the classes that never declare what they set.
>>> import typing_extensions as tx
>>> from bagof.dispatchers.core import issubhint, ishintstance
>>> @tx.runtime_checkable
... class HasName(tx.Protocol):
... name: str
...
>>> class Named:
... name: str # declares the member the protocol asks for
... def __init__(self, name):
... self.name = name
...
>>> issubhint(Named, HasName)
True
>>> ishintstance(Named("Ada"), HasName)
True
Reading members at the value level
A value v is in a protocol P when type(v) lists P among its bases,
exactly as isinstance already counts it, or when every one of P's
members is satisfied. Every method P declares must be defined by
type(v), and not simply set to None. Every member P declares as a
class variable, through a ClassVar annotation, must be declared as a
class variable somewhere in type(v)'s own MRO, with or without a value,
read off the class alone the way a type checker reads a class attribute.
Every other, ordinary data member must either be present in the
instance's own __dict__, or declared as an instance variable anywhere in
the class's MRO. Such a declaration is a plain annotation (name: str, so
long as it is not itself marked ClassVar, InitVar, or KW_ONLY), or a
class attribute, a property, or a slot that the class defining it does not
mark ClassVar or InitVar.
Members are found statically. A property is never called, and
__getattr__ is never consulted, matching how isinstance itself reads a
protocol from Python 3.12 onward. As a result, the relation answers the
same way on every supported Python and with either typing module, where
isinstance itself would not be consistent: before 3.12, typing's own
isinstance calls hasattr, which does call properties. Because methods
are read off the class, only the data members actually depend on the
particular instance, so a method assigned to one instance alone is not
counted, even though a native isinstance from 3.12 onward would count it.
This deliberately follows the type checkers' own reading rather than
isinstance's in three further ways:
- an annotated member counts as present even on an instance that never actually set it;
- a member set on the instance that the class instead declares
ClassVar, or a plain class attribute with no annotation at all, does not count as an instance member; - a class attribute declared
ClassVarnever counts toward an instance member either.
Reading members at the hint level
C ⊑ P when C itself lists P among its bases, including through a
sub-protocol, or when C is not itself a protocol and declares every one
of P's members. This is the same way a type checker reads structural
subtyping: each method C defines (again, not as None), and each data
member declared, anywhere in C's MRO, as the kind P declares it. An
instance variable is declared by a plain annotation, including a
dataclass field whether or not it is init=False, or by a class attribute,
a property, or a slot that the class defining it does not mark ClassVar.
A value given in the class body for such an attribute is simply the
instance variable's default. A class variable is declared only by a
ClassVar annotation, with or without a value.
The two kinds are mutually exclusive, matching how the typing
specification
treats a protocol's own members: a member is an instance variable unless it
is explicitly declared ClassVar. This also matches what both mypy and
pyright do. Each rejects a plain class attribute standing in for a
ClassVar member, and each rejects a plain annotation standing in for an
instance-variable member declared without one, with the message "expected
class variable, got instance variable" or its mirror. The relation enforces
the same exclusivity.
When an annotation is still text, as it is under from __future__ import
annotations, it is parsed rather than executed. Its leading dotted name is
resolved purely by dictionary lookup, first in the class's own namespace,
then its defining module's, then the builtins, and, for a dotted name, in
the intermediate module's own namespace too. The ClassVar marker is
recognised by identity rather than by name. An import alias such as from
typing import ClassVar as CV still reads as ClassVar, and a user class
the reader happens to have named InitVar reads as itself rather than
being mistaken for dataclasses.InitVar. A name that cannot be
resolved this way falls back to being read by its last dotted component.
InitVar, KW_ONLY, and a TypedDict's own keys declare no attribute at
all.
A protocol that does not itself list P among its bases is never below it
merely for sharing the same member names. A protocol that only declares
data members is below a method-only protocol Q when it lists Q, or when
its own methods happen to cover Q's members. It is never below Q
through a data member alone — a distinction Python's own issubclass does
not draw, since it would accept a bare annotation that an instance is
nonetheless free to hold without.
Keeping the two levels sound
The order must never place C below P while some instance of C fails
to actually satisfy P. A bare annotation with no value, such as name:
str alone, promises nothing at runtime, since a class that only annotates
name and never sets it in __init__ produces instances with no such
attribute at all.
The relation does not, however, refuse the annotation at the hint level.
Refusing it, which the type checkers themselves do not do, would leave a
class that both annotates a member and sets it in __init__ incomparable
with the protocol, making an overload on each ambiguous for every one of
its instances. Instead, the value level counts a merely-annotated member as
present on every instance of the class, whether or not that particular
instance ever set it. The two levels read one shared per-class record of
what the whole MRO declares, gathered once across every class in the MRO
rather than decided class by class. A subclass therefore automatically
declares everything its bases do, and v ∈ C ⊑ P implies v ∈ P by
construction, transitively through the whole class hierarchy; the test
suite sweeps this property directly. A ClassVar member is read off the
class at both levels, for the same reason.
Documented divergences from type checkers
A few cases are left as documented residue rather than guarded against, because guarding against them is either impossible or not worth the cost:
- a subclass that sets an inherited method to
None; - an attribute or annotation added to a class after that class was first dispatched on, since the per-class record is memoised at first use;
- a subclass that redeclares an inherited instance variable as a
ClassVarstill sits below the protocol, because the union taken over the whole MRO keeps the base class's own declaration. Inclass SubCV(Ann): name: ClassVar[str], whereAnnannotatesname: stras an instance variable,SubCV ⊑ Ann ⊑ HasNameis preserved even though both type checkers reject the redeclaration itself; - a read-only member, such as a property with no setter or
name: Final = "x", counts toward a protocol's instance-variable requirement even though both type checkers reject it with "expected settable variable, got read-only attribute". Dispatch only ever reads a member and never assigns to it, so the value genuinely has what dispatch needs, and the divergence from the type checkers is deliberate rather than an oversight; - an annotated member that a particular instance never set simply reaches the chosen overload as a value lacking that attribute, exactly as it would reach a plain function a type checker had already accepted it for.
The one gap the order cannot close
A class that declares none of a protocol's members at all — no annotation, attribute, property, slot, method, or dataclass field — is incomparable with that protocol at the hint level. Yet a particular instance of it can still acquire the member at runtime, and so belong to the protocol at the value level:
>>> class Quiet:
... def __init__(self, name):
... self.name = name # set on the instance, never declared
...
>>> issubhint(Quiet, HasName)
False
>>> ishintstance(Quiet("Bo"), HasName)
True
An overload on Quiet and an overload on HasName are then ambiguous for
an instance like Quiet("Bo"), and AmbiguousMethodError is the correct
answer, since neither overload is more specific than the other.
ambiguities() reports nothing, because both overloads are genuinely
incomparable at the hint level, which is all a check made without a value
can see. The remedy is the caller's: annotate
the member on the class, or give one of the two overloads a higher
priority. The guide's page on protocols shows this exact case running,
together with the more common one where the class wins outright.
Registration itself treats such a protocol like any other hint, so an
overload on it is reachable the normal way. A protocol that is not
runtime_checkable is unaffected by any of this: it simply answers False
at both levels, so an overload registered on it can never match a call.
3. TypeVars in signatures
| Kind | Applicability at a position | Hint used for specificity |
|---|---|---|
unbound T |
any value (T behaves as Any) |
Any |
bound by B |
an instance of B |
B |
constrained to (C1, C2) |
an instance of one Ci (bool solves as int) |
Union[C1, C2] |
carrying a default= ([PEP 696]) |
as above — the default is a static-checker fallback and plays no role at runtime | as above |
The origin of a TypeVar does not matter to dispatch: a legacy
tx.TypeVar(...), a variance-flagged bagof.hints.typevars.* variable, and
a PEP 695 def f[T] variable are all plain TypeVar instances and are
dispatched on identically. Signature.from_callable reads a function's
hints alone; __type_params__ is only consulted to name a variable in an
error message.
PEP 696 defaults play no part in dispatch, for any of the three kinds above.
The relation reads a TypeVar's upper bound through a private helper that
tries its bound, then the union of its constraints, then falls back to
Any. It reads __default__ through getattr(tv, "__default__",
tx.NoDefault) rather than the .has_default() method a native Python 3.12
PEP 695 TypeVar lacks. Reading it this way also fixes a bug the summed-distance
predecessor had: TypeVar("TB", bound=float, default=int) used to read as
bound to int, its default, rather than float, its actual bound, because
the older lookup helper preferred the default over the bound.
unwrap's own public contract still prefers default, then constraints,
then bound, in that order, because a factory that needs to build a value
legitimately wants the default; only the relation's own reading of a
TypeVar's upper bound ignores it.
Repeated TypeVars
A signature such as def same(x: T, y: T) requires consistency over its
bound arguments: the arguments whose landed hint is that same T,
meaning every declared parameter with that hint together with any surplus
positional absorbed by an *args: T. This holds regardless of whether the
call spells them positionally or by keyword — same(1, 2), same(x=1,
y=2), and same(y=2, x=1) are all checked the same way. A call is
applicable only when those argument types have a greatest element under
⊑. (int, bool) solves T = int, since bool ⊑ int, while (int, str)
has no such solution, because joining them to object would collapse (T,
T) into (object, object), and the relation refuses to invent a join this
way. A constrained T instead requires that every bound argument resolve
to the same one of its constraints. A default-filled parameter annotated
T contributes nothing, since it is not an argument at all.
A **kwargs: T group solves T for applicability jointly, across the
keywords it captures together with every other slot carrying T. This is
the same greatest-element solve that *args: T performs over its
positionals, and those same keywords also form one group for the
specificity tie-break described below. This sits deliberately between two
other models. Julia's strict diagonal dispatch lets a repeated TypeVar
additionally discriminate on which concrete type filled it. Mypy's static
join, on the other hand, would collapse (T, T) to the join of the two
argument types rather than requiring them to already share one.
A constrained TypeVar whose constraint is itself a runtime protocol with
data members is solved from the argument's class, through
issubhint(type(v), Ci), at every position it fills. As a result, a value
that only belongs to the protocol through what its particular instance
holds does not select that constraint. Declaring the member on the class,
or using a plain bound=, which is checked value by value instead, is the
way to reach it. A generic protocol
with data members, such as HasItem[int] declaring item: T, checks only
that the members are present. Since a structural value declares no type
arguments of its own, what it actually holds under item is never compared
against int, the same way a plain list's items are never compared against
its declared element type.
Specificity with TypeVars
Position by position, a TypeVar is replaced
by the hint from the table above, so (int, int) is strictly below
(T, T), which is equivalent to (Any, Any). One further tie-break applies
inside that equivalence. When two methods are equivalent position by
position, the one whose repeated TypeVars group strictly more arguments
into one consistent type is the more specific of the two. This recovers the
outcome Julia's own diagonal dispatch would give for the "same type" case.
This tie-break is the very last selection step, reached only when two
methods are already equally specific after priority, MRO refinement, and
tightness (§2.2) have all failed to separate them. It compares the two
methods' TypeVar groupings over the bound arguments, meaning which argument
positions share one TypeVar's identity, and one grouping wins only when it
strictly refines the other: it ties every pair the other ties, and at least
one pair more besides. Neither grouping refines the other when they are
equal, such as (T, U) against (U, T), or when each ties a pair the
other does not, such as (T, T, U) against (T, U, U); in either case the
two stay incomparable, and the call remains ambiguous. Because it only ever
applies once every earlier step has failed to decide, this tie-break can
never overturn a strict specificity win, and can never make two genuinely
independent signatures comparable; it only ever resolves a tie that would
otherwise be ambiguous. A signature with any repeated group beats one with
none at all, including a fully unannotated (Any, Any); this is the least
surprising reading of "more constraint is more specific" for the cases this
section would otherwise leave silent. A group of exactly one argument constrains nothing on its own, so this
refinement only ever applies when a TypeVar is bound at two or more
positions of the actual call. *args: T against a plain *args is
refined, and therefore resolved, for a call of two or more arguments, but
the two tie for a call of zero or one argument, and are therefore ambiguous
there. **kwargs: T against a plain **kwargs behaves the same way over
the keywords a call spills into the catch-all: refined for two or more
captured keywords, tied for zero or one.
Hint-level resolve queries containing a TypeVar use issubhint unchanged;
there is no separate rule for them.
Only a top-level TypeVar, meaning one that names an argument's own
landed hint directly, is solved jointly across positions. A TypeVar nested
inside a hint, such as the T in Box[T] or List[T], is solved
locally instead, once per position, inside issubhint itself, and never
jointly across positions. (Box[T], Box[T]) still accepts Box[int](),
Box[str]() as two independent arguments, the same way (List[T], List[T])
accepts two lists that happen to declare different element types.
Variadic parameter kinds
*args: *Ts and *args: P.args are both read
as an Any tail for a single element. A bare Ts or P, a bare
Concatenate[...], or a top-level Unpack[Ts], are each invalid as a
parameter annotation and raise TypeError at registration (a bare Ts
written on *args produces an error pointing the caller at
*args: Unpack[Ts] instead). Two open runs inside one tuple or parameter
list, such as Tuple[*Ts, *Us], are refused at registration too, enforcing
PEP 646's single-unpack rule, which typing itself does not enforce at
runtime.
A ParamSpec is solved at the hint level. Named at several top-level
Callable slots, it must capture a consistent parameter list at each one —
the direct analogue of a repeated TypeVar, where consistency again means
the captured lists have a greatest element under the parameter-list order.
A TypeVarTuple is solved at the hint level the same way. Named at several
top-level Tuple slots, it must capture a consistent run at each: the
covariant, tuple-shaped analogue of the same idea, where consistency means
the captured runs have a greatest element under the tuple-shape order.
(int,) and (bool,) solve to (int,). (int,) and (str,), or two runs
of different length, are inconsistent, and a closed run alongside an open
one solves to the open one. A *args: *Ts absorbs its own positionals into
one run of that same Ts, solved jointly with every Tuple[..., *Ts] slot
elsewhere in the signature. So (t: Tuple[*Ts], *args: *Ts) applies to
resolve(Tuple[int, str], int, str) but not to resolve(Tuple[int]),
since the empty *args run there disagrees with (int,). A
Callable\[[int, *Ts], R] parameter list rides the same open-tail machinery
ParamSpec uses, with its own *Ts tail solved by that machinery, keyed
separately from any tuple run of the same Ts elsewhere. Only a top-level,
landed Tuple or Callable joins a group this way. A *Ts nested inside
another hint does not, nor does a *Ts in the middle of a Callable
parameter list followed by a fixed suffix — Callable\[[int, *Ts, str], R]
degrades to an open Concatenate[int, P] shape instead. *Ts never enters the
repeated-TypeVar specificity tie-break above. It is not a TypeVar at all,
deliberately the opposite of how *args: T behaves, so *args: *Ts against
a plain *args stays ambiguous regardless of how many positional arguments
the call carries.
Value-level solving of ParamSpec and TypeVarTuple is out of scope for
now (§10): a callable or tuple value is matched shallowly, without ever
inspecting its actual signature or element shape.
4. "Exact type" vs. "subtype": Exact[C]
Ordinary type hints have no way to say "this class, and none of its
subclasses." Exact[C] fills that gap while still presenting as plain
int to a type checker. It is spelled bagof.dispatchers.Exact[int] and
implemented as tx.Annotated[int, EXACT] behind a private sentinel, the
same pattern bagof.magic's Frozen[int] uses. It is understood directly
inside issubhint and ishintstance, rather than through a wrapper around
them, so anything built on the relation, including converters and
validators, gets exactness for free.
None of the tools already in the hint vocabulary can do this job. A bound
TypeVar is equivalent to its bound, not restricted to it. A TypeVar with a
single constraint is not legal, and one with two or more constraints still
accepts subclasses of each. Variance flags are rejected on a TypeVar used as
a function parameter. And type[C] constrains an argument that is itself a
class object, not the exactness of an ordinary instance. (Julia gets
exactness without any of this machinery, because its concrete types are
final by construction; Python's classes are open, so dispatch needs an
explicit marker.)
Exact[C] behaves as a leaf subtype of C, a genuine bottom sitting
just under C in the order, which is what keeps ⊑ a proper preorder
(reflexive and transitive) once Exact is added to it:
- At the value level, a call is applicable through an
Exact[C]parameter only whentype(v) is Cexactly. ApplyingExactto anything other than a class (aside fromNoneType) raisesTypeErrorat registration. - At the hint level,
issub(Exact[C], C)is true, since a value that is exactlyCis certainly aC, butissub(C, Exact[C])is false, and so isissub(D, Exact[C])for any subclassDofC: nothing ordinary sits belowExact[C]at all. The only ordinary hints that do sit below it are literals every one of whose values happens to have type exactlyC:issub(Literal[v], Exact[C])holds exactly whentype(v) is C, soLiteral[1]is belowExact[int], butLiteral[True], abool, is not.issub(Exact[C1], Exact[C2])holds exactly whenC1andC2are the same class. - In the wider order,
Exact[C]sits strictly belowC, andissub(Exact[C], P), for anyPthat is not itself a union or a TypeVar containingExact, reduces toissub(C, P). APthat does containExact, such as a union member or a TypeVar's bound, is checked member by member instead of reducing this way, since reducing first would lose the exactness.issub(Exact[C], Union[Exact[C], …])andissub(Exact[C], TypeVar(bound=Exact[C]))are both true for exactly this reason. TwoExacthints over different classes are incomparable. MRO refinement (§2.2) treatsExact[C]asC, and it is keyed by class for the cache, so it caches exactly as a plain class hint would. - For resolution, the hint-level
resolve()may additionally reach anExact[C]entry for a query equivalent toC. This is a lookup convenience layered on top of the relation, not a change to⊑itself, which still keeps a plain queryqfrom being a sub-hint ofExact[C].
from bagof.dispatchers import dispatch, Exact
@dispatch
def describe(x: Exact[int]) -> str: # exactly int, so not bool
return "an integer"
@dispatch
def describe(x: object) -> str: # everything else, True included
return "something else"
Exact answers the reverse of the usual dispatch need: an int overload
that should not fire for True, or a base class whose subclasses each need
their own dedicated method. It lives in bagof.dispatchers.core._exact, and
is a candidate for promotion to bagof-hints if a package that does not
otherwise depend on bagof.dispatchers ever needs it.
4.1 Composing Exact inside Type and Hint
(0.2.0) Exact composes with Type[…] and with Hint[…] (§6) under one
rule: placing Exact inside the bracket flips that position's match
from subtype to identity, while Exact around the whole form means the
same thing and is normalised to the inner spelling. Exact[Type[int]\]
normalises to Type[Exact[int]\], Exact[Hint[int]\] to Hint[Exact[int]\],
and a bare Exact[Type] to Exact[type].
The leaf rules then compose automatically, because both type[…] and
Hint[…] compare their argument through issubhint. So type[Exact[C]\] is
a leaf below type[C] exactly as Exact[C] is a leaf below C:
issub(type[Exact[C]\], type[C]) is true, issub(type[C], type[Exact[C]\])
is false, and issub(type[Exact[C1]\], type[Exact[C2]\]) holds only when
C1 and C2 are the same class. Hint[Exact[X]\] behaves the same way
within the Hint order. At the value level, type[Exact[C]\] matches a
class v only when v is C, and Hint[Exact[X]\] matches a hint only when
it is structurally X itself, judged by identity of spelling rather than
by equivalence, so a free TypeVar — equivalent to Any but not the hint
Any — does not match Hint[Exact[Any]\].
Reading type[…]'s argument through the full relation also fixes the forms
that were degenerate before: type[Any] now accepts every class,
type[Union[…]\] accepts a class in the union, and type[T] for a free
TypeVar accepts every class.
4.2 Bounds: Super[C] and Between[L, U]
(0.3.0) A hint on a value parameter constrains the value's own class
exactly as Type[hint] constrains a class passed in. A plain C on a
value accepts v when type(v) ≤ C, which is Type[C] asked of
type(v), and Exact[C] on a value is Type[Exact[C]\] asked of
type(v). Super[C] and Between[L, U] complete that family, both on a
value and inside Type or Hint.
An ordinary hint is an upper bound: Animal accepts an instance of
Animal or of a class below it, and Type[Animal] accepts Animal and
the classes below it. Super[C] supplies the missing lower bound.
Super[Dog] accepts a value whose class is Dog or a class that Dog
derives from, Type[Super[Dog]\] accepts the class Dog and every class
that Dog derives from, and Hint[Super[int]\] accepts the hint int
and every hint that int is a sub-hint of. It is spelled
bagof.dispatchers.Super[C] and implemented, like Exact, as
tx.Annotated[C, SUPER] behind a private sentinel. SuperType[C] and
SuperHint[X] are aliases that expand to exactly Type[Super[C]\] and
Hint[Super[X]\].
Between[L, U] supplies both bounds at once. Between[Dog, Animal]
accepts a value whose class lies between Dog and Animal, both
included, Type[Between[Dog, Animal]\] accepts those classes themselves,
and Hint[Between[int, Real]\] accepts the hints between int and
numbers.Real, both included. It is spelled bagof.dispatchers.Between[L, U] and
implemented as tx.Annotated[U, LOWER(L)], the upper bound annotated with
a private marker that carries the lower one. There are no BetweenType or
BetweenHint aliases, since Type[Between[Dog, Animal]\] already reads
naturally.
The relation reads each of these forms as a closed interval of the
classes, or hints, it accepts. A plain X is the interval from the bottom
Never up to X, Super[C] is the interval from C up to the top, which
is object on a value and inside Type, and Any inside Hint, and
Between[L, U] is the interval from L up to U. Type[X] is therefore
the same as Type[Between[Never, X]\], and Type[Super[C]\] the same as
Type[Between[C, object]\]; on a value, X is the same as
Between[Never, X] and Super[C] the same as Between[C, object].
Exact[C] is the point below Between[C, C]: it names the one hint C
structurally, while Between[C, C] is the interval of every hint
equivalent to C. On a value that difference is between a class
identical to C and a class that register or __subclasshook__ has
made mutually a subclass of C. One form is below another when its
interval lies inside the other's, meaning that its lower bound is higher
and its upper bound is lower. The rules that follow from that reading are
these:
| Query | Result | Why |
|---|---|---|
Type[Super[D]\] ≤ Type[Super[C]\] |
iff C ≤ D |
a higher lower bound leaves fewer classes above it |
Type[Exact[X]\] ≤ Type[Super[C]\] |
iff C ≤ X |
the one class X is in the interval when it is above C |
Type[X] ≤ Type[Super[C]\] |
False | a subclass of X can always be defined that is not above C |
Type[Super[C]\] ≤ Type[X] |
iff object ≤ X |
the interval reaches object, so only a top contains it |
Type[Super[C]\] ≤ Type[Exact[X]\] |
False | conservative: an interval above C is never taken to be a single class |
Type[Between[L1, U1]\] ≤ Type[Between[L2, U2]\] |
iff L2 ≤ L1 and U1 ≤ U2 |
the first interval lies inside the second; every row above is a special case of this one |
Hint follows the same table with Any in place of object. On a value
parameter the same table holds with type(v) in place of the class passed
and object as the top, so Between[Dog, Animal] < Animal, Exact[Dog]
is below every interval that contains Dog, and Dog and Super[Dog]
are incomparable. A union below a bound distributes over its members, a
TypeVar is read as its upper bound, and a Literal is below a bound
when each of its values is accepted, so Literal[1] ≤ Super[int] while
Literal[True] is not. Every rule is an inclusion of intervals, so the
relation stays a preorder, and it stays sound: when A ≤ B, every class,
hint, or value in A is also in B. Values are matched against the same
interval (§2.1), which keeps the two levels in agreement.
On a value, each bound must be a hint that a class can be compared
against: for every class K, K ≤ E must hold exactly when an instance
of K is in E. A class, an ABC, object, type (or a bare Type,
which is the same hint), NoneType, a NewType, a bare alias such as
List, a protocol with methods only, Never, Any, a union of such
hints, and a TypeVar bounded by one all qualify. A Literal, a
TypedDict, a protocol with data members, a parametrised generic,
Tuple[...], Callable[[...], R], Type[C], and Hint in any spelling
do not, because each of them reads the value itself or
names no class, so Between[Never, E] would accept nothing while E
accepts values. Such a bound on a value is refused wherever it is read,
naming the offending bound; Between[Type[A], Type[B]\] is refused with a
message naming Type[Between[A, B]\]. Inside Type or Hint, where the
value passed is itself a class or a hint, any hint can be a bound, so the
same Between[Literal[1], int] that is refused on a value is legal inside
Hint[...]. When a hint is passed as a value to a Hint[X] parameter,
only a bound at the top level of that hint is checked on arrival: a bound
nested inside it, as in Optional[Super[Literal[1]]\], is refused only
when the comparison with X reaches it, and a comparison with Any never
does.
An interval must not be empty. Between[L, U] requires L ≤ U, and an
empty one is refused when it is written, with a message that suggests
Between[U, L] when that spelling is not empty. Between[Any, U] is
refused with a message pointing to Between[Never, U], the interval with
no lower bound, which means the same as plain U. An upper bound written
as a forward reference is checked once it resolves, when the method is
registered or when its signature is first used, and the refusal then
names the parameter. A lower bound cannot itself be a quoted forward
reference, because it is carried as Annotated metadata, which
get_type_hints never resolves; quoting the whole annotation works as
usual. The degenerate intervals are legal: Between[C, C] holds the hints
equivalent to C, and Between[Never, Never], Between[Never, Any], and
Between[Any, Any] mean what their bounds say.
A bound stands in four kinds of position. On a value parameter it may be
the whole hint, a member of the parameter's union, as in
Optional[Super[Dog]\], or the bound of a TypeVar used there. Inside
Type or Hint it must be the whole argument: a union member or a
TypeVar bound inside the argument is refused, because the argument is
compared as a class or a hint, which would read the bound as if it stood
on a value. As the type argument of a generic it must likewise be the
whole argument of a slot, where it names a range of arguments, and the
variance of the slot decides whether that range means anything (§4.3).
Everywhere else it is refused: as a constraint of a TypeVar, which is
solved to the one constraint an argument's class is below and so would
never apply, and as an element of a Tuple or in the signature of a
Callable, whose variance the form already fixes. These positions nest,
so a bound inside a generic inside a Callable parameter, as in
Callable[[List[Super[int]\]], None], stands at a slot of List and is
legal. Registration refuses each misplaced bound with an error naming the
parameter and the spelling to write instead, and the relation refuses the
same hints with the same messages. An unsubscripted Super, SuperType,
SuperHint, or Between is refused too, since an unbounded form names
nothing. Super[Type[C]\] is normalised to Type[Super[C]\] and
Super[Super[C]\] collapses to Super[C]. Exact, Super, and
Between cannot be nested inside one another in any order, even through a
union or a TypeVar, because an exact type leaves nothing above it to
bound and each form already describes the whole hint at its position.
The degenerate bounds need no special case. On a value, Super[Never]
accepts exactly what object accepts, Super[object] accepts only an
instance of object itself, and Super[Any] accepts no value, because no
class is above Any. In the same way Type[Super[Never]\] accepts every
class, like type, Type[Super[object]\] accepts only object, and
Type[Super[Any]\] accepts no class at all, while Hint[Super[Any]\]
accepts the tops: Any, a free TypeVar, and an opaque form.
To a type checker, Super[C] is Union[C, Any]. Exact[C] can present as
plain C, but Type[Super[Dog]\] read as Type[Dog] would reject a call
with Animal that dispatch accepts. Union[C, Any] is the sound generic
spelling: it uses its type variable, which a checker requires of a generic
alias, and Type[Union[C, Any]\] accepts every class object, so a checker
never rejects a call that the runtime accepts. The aliases are declared
with a [PEP 613] TypeAlias annotation, which both mypy and pyright
read. Between[L, U] is Union[L, U, Any] for the same reason. Checked
with mypy 1.19.1 and pyright 1.1.408, neither rejects a call with Dog,
Animal, object, or Puppy on a Type[Between[Dog, Animal]\]
parameter, nor one with Animal, object, Dog, Puppy, or int on a
Type[Super[Dog]\] parameter, nor any call on a value parameter
annotated Super[Dog], Between[Dog, Animal], or
Optional[Super[Dog]\]: both over-accept, and neither rejects a call that
dispatch accepts. Inside the function body the same reading has a cost: a
checker reads x: Super[Dog] as Union[Dog, Any], and so checks
attribute access against Dog although the runtime may pass an
Animal(). Between[Dog, Animal] reads as Union[Dog, Animal, Any],
whose shared attributes are those of Animal, which is right. When the
difference matters, a Super[C] value is best treated as object in the
body.
Two things are deliberately left out. There is no option to flip the
direction in which Type orders its argument; a lower bound is written
where it is meant. And a contravariant TypeVar is read exactly as before
(§2.3): Super bounds a hint, and is not a spelling of a generic's
contravariant parameter.
Bounding a value's class from below has little precedent. Julia can write
f(x::T) where {T>:Dog}, but its concrete types are final, so
typeof(x) >: Dog collapses to typeof(x) == Dog for a concrete Dog,
and to nothing for an abstract one. Java's ? super L and Kotlin's in
exist only on the type arguments of a generic, never on a value, and C++
and Rust have no equivalent. Python's classes are open, which is what
gives an interval of a value's class real content. Exact, Super, and
Between on a value are one family of exact-class-interval tests, with
Exact the point and the other two the half-open and closed intervals,
and each is a deliberate opt-out of substitutability: Exact[C] excludes
the instances of subclasses of C, and Super[C] and Between[L, U]
exclude the instances of classes below L. The type arguments of a
generic are where bounds do have a long history, which §4.3 compares.
4.3 Bounds as type arguments
(0.3.0) An argument at an invariant slot of a generic means exactly that
type: List[int] accepts a list declared to hold int, and neither one
declared to hold bool nor one declared to hold object. A bound written
as the whole argument of such a slot widens it into a range of arguments.
List[Super[int]\] stands for every List[Y] with int ≤ Y, so it
accepts a list declared to hold int, numbers.Integral, or object, and
refuses one declared to hold bool or str.
Box[Between[Never, Integral]\] stands for every Box[Y] with
Y ≤ Integral. A value that declares nothing, such as a plain [1],
matches every parametrisation, as it always has (§2.1), and a value that
declares a bounded parametrisation, such as Box[Super[int]\](), is read
as the range it names.
Each bound is an anonymous range of its own. Two bounds in one signature
are independent of each other, and a bound nested inside an argument does
not widen the slot around it: List[List[Super[int]\]\] has the single
argument List[Super[int]\] at its outer, invariant slot, so it accepts a
list declared to hold List[Super[int]\] and not one declared to hold
List[int]. The range of lists whose items are below List[Super[int]\]
is written List[Between[Never, List[Super[int]\]\]\]. Every end of a
bound at a slot may be any hint, because a slot compares hint with hint,
so the Between[Literal[1], int] that is refused on a value (§4.2) is
legal in List[...].
The order is inclusion of ranges, as in §4.2, with Any as the top. At an
invariant slot a plain X is the single point from X to X,
Super[C] runs from C to Any, Between[L, U] from L to U, Any
and a free TypeVar from Never to Any, and a TypeVar bounded by B from
Never to B, which is the reading §2.3 already gives it. A constrained
TypeVar stands for one of its constraints, each a point. Exact[C] is an
ordinary point there, because the exact hint C is a narrower argument
than C: List[Exact[int]\] is below List[Between[Never, int]\] and
not below List[int] or List[Super[int]\]. The rules that follow are
these, with TB bounded by int:
| Query | Result | Why |
|---|---|---|
List[int] ≤ List[Super[int]\], List[object] ≤ List[Super[int]\] |
True / True | the one argument lies in the range above int |
List[bool] ≤ List[Super[int]\], List[Any] ≤ List[Super[int]\] |
False / False | bool is below int, and Any on the sub side stands for every argument |
List[Super[Integral]\] ≤ List[Super[int]\] |
True | a higher lower end leaves a smaller range |
List[Super[int]\] ≤ List[int], List[Super[int]\] ≤ List[Any] |
False / True | a range is below a point only when it is that point, and below Any always |
List[TB] ≡ List[Between[Never, int]\], List[Between[int, int]\] ≡ List[int], List[Between[Never, Any]\] ≡ List[Any] |
True | the same range, written three ways each |
List[Between[int, Real]\] ≤ List[Super[int]\], List[Between[Never, int]\] ≤ List[Super[int]\] |
True / False | the first range lies above int, the second reaches below it |
A covariant or contravariant slot already widens its argument, so a range
there collapses to one of its ends. At a covariant slot every G[Y] with
Y ≤ U is below G[U], so a range means its upper end, and at a
contravariant slot every G[Y] with Y ≥ L is below G[L], so a range
means its lower end. A bound whose other end is trivial says exactly that
and is accepted as redundant, while a bound whose other end would be
silently dropped is refused as conflicting, with a message naming the plain
spelling:
| Slot | Between[Never, U] |
Super[L], Between[L, Any] |
Between[L, U], both ends non-trivial |
|---|---|---|---|
invariant: list, both dict slots, set, MutableSequence, a Mapping key, an unflagged T, a PEP 585 subclass |
arguments at or below U |
arguments at or above L |
arguments from L up to U |
covariant: Sequence, frozenset, Iterable, a Mapping value, a T_co |
redundant: G[U] |
refused: would accept every G |
refused: write G[U] |
contravariant: a T_contra, the send slot of Generator |
refused: would accept every G |
redundant: G[L] |
refused: write G[L] |
A bound that a user generic's invariant slot accepts can reach a variant
slot of a base, as Row[Super[int]\] does for class Row(Sequence[T]). It
was written where it can be read, so it is not refused there; it stands for
every Row[Y] in its range, and so reaches Sequence through that range's
upper end, which makes Row[Super[int]\] below Sequence[Any] but not
below Sequence[int], and Dict[str, Between[Never, int]\] below
Mapping[str, int]. A class that writes a bound directly into a variant
base, such as class Row(Sequence[Super[int]\]), is refused whenever it is
compared against a parametrisation of that base, with the message the hint
Sequence[Super[int]\] would get, prefixed with the class. A bound that
reaches a base only as part of a union, or at a slot whose variance cannot
be read, is refused as well.
A bound must be the whole argument of its slot. A union member or a TypeVar
bound inside a slot, as in List[Union[Super[int], str]\], is refused with
a message suggesting Union[List[Super[int]\], List[str]\], and a bound is
refused as an argument of a generic whose variance cannot be read, such as
one with a ParamSpec or a TypeVarTuple parameter. Tuple elements and
Callable parameters and returns keep refusing a bound (§4.2), since the
form already fixes their variance and a bound could at best repeat it.
Registration, the relation, and ishintstance refuse the same hints with
the same messages, so ishintstance([], Sequence[Super[int]\]) raises
although a plain list declares nothing.
Specificity stays the sub-hint order, with no separate heuristic and no
option to change it; priority settles what the order leaves tied. Two
ranges that overlap without either lying inside the other, such as
List[Between[Never, Integral]\] and List[Super[int]\], which share the
parametrisations List[int] and List[Integral], make their methods
ambiguous: a call matching both raises, and ambiguities() reports the pair
(§5).
To a type checker the aliases of §4.2 still apply, so List[Super[int]\]
reads as List[Union[int, Any]\]. Checked with mypy 1.19.1 and pyright
1.1.408, with class Dog(Animal) and class Puppy(Dog): pyright reports
no error for any call to a parameter annotated List[Super[Dog]\],
List[Between[Never, Animal]\], List[Between[Puppy, Animal]\], or
Dict[str, Super[Dog]\], so it never rejects a call that dispatch
accepts, and it over-accepts. mypy is exact for a lower bound, accepting
list[Dog], list[Animal], list[object], and list[Any] for
List[Super[Dog]\] and rejecting list[Puppy] and list[str], and it is
exact for Dict[str, Super[Dog]\]. For an upper bound it is not: mypy
accepts list[X] against list[Union[A, B, Any]\] only when A ≤ X and
B ≤ X, reading every member as a lower bound, so it rejects list[Puppy]
and list[Dog] for List[Between[Never, Animal]\] and for
List[Between[Puppy, Animal]\], calls that dispatch accepts. No generic
alias spelling avoids this, and a bounded TypeVar is the spelling of an
upper bound that both checkers check natively: List[A] with
A = TypeVar("A", bound=Animal) is checked exactly by both, and dispatch
reads it as List[Between[Never, Animal]\]. Super and Between serve
for lower and two-sided bounds, and mypy cannot check the upper end of a
two-sided bound at an invariant slot. Where mypy's rejection gets in the
way, the function can be annotated List[Any] and registered with
Function.register((List[Between[Puppy, Animal]\],)), or the call can
carry # type: ignore[arg-type].
Bounds on type arguments are well established elsewhere. Julia bounds a
type parameter from above and below, as in Vector{>:Int} or
T where Int<:T<:Real, and its parameters are all invariant, so every slot
takes a bound; its method specificity, though, comes from a separate
morespecific heuristic with known non-transitive cases, where here it is
the sub-hint order, whose preorder argument is the whole story. Java's
wildcards ? super L and ? extends U exist because all its generics are
invariant, and never combine both ends. Kotlin's use-site projections are
the closest match, because Kotlin, like Python, also has declaration-site
variance: an in projection on an out parameter is an error, as a
conflicting bound is refused here, and an out projection on an out
parameter is a redundant-projection warning, where here a redundant bound
is accepted silently, so that code that builds hints generically does not
trip over it. Nested bounds behave as in Java and Julia, where
List<List<? super Integer>> and Vector{Vector{>:Int}} do not accept a
list of lists of integers either.
5. Ambiguity and no-match errors
Every dispatch error lives in _errors.py. DispatchError is the common
base, and it subclasses TypeError, since calling a dispatched function
with types nothing supports is, after all, a type error, and existing
except TypeError: handlers keep working unchanged. NoMethodError and
AmbiguousMethodError both subclass it, and both carry .function, .call
(the argument classes for a value call, or the queried hints for
resolve), and .candidates. Problems detected at registration time,
rather than at a call, raise a plain TypeError or ValueError instead.
An ambiguity is reported in Julia's own format, printing classes rather than
values, so the message never has to render an arbitrary repr:
AmbiguousMethodError: area(int, int) is ambiguous.
Candidates:
area(x: int, y: Any) @ shapes.py:12
area(x: Any, y: int) @ shapes.py:15
Possible fix, define
area(x: int, y: int)
The suggested fix is the signature built from the call's own classes
(substituting Exact[...] wherever a candidate already used it), so it is
always applicable to the call that triggered the error. A signature built this
way is not always more specific than the candidates, however. When the tied
methods take List[int] and List[str], for example, the class of the
argument is list, which sits above both hints rather than below them, and a
method defined for list would not settle the tie. The message therefore
includes the fix only when the suggested signature is strictly more specific
than every method in the most-specific set, including any method that lost
only on priority, and ends after the list of candidates otherwise. This is a
simpler stand-in for Julia's own type-intersection suggestion, which can
itself be more specific than necessary.
A failed match is reported the same way, marking the offending arguments
with !:
NoMethodError: no method matching area(str).
`area` has 3 methods, but none is defined for this combination of argument types.
Closest candidates:
area(x: !int, y: int = 0) @ shapes.py:12
area(x: !float) @ shapes.py:20
"Closest" ranks candidates by how many positions are applicable and how
well the call's arity matches, showing at most eight. When a function has
no methods registered at all, the message instead reads "area has no
methods yet: the module that defines them has not been imported." Both
errors are
raised from Function.dispatch or Function.resolve, never from inside the
__call__ frame itself, so a traceback does not point into the dispatch
machinery. Function.ambiguities(), the analogue of Julia's
Test.detect_ambiguities, returns the pairs of registered methods that some
call would match with nothing to choose between them, so a test suite can
assert that a registry has none.
(0.3.0) Registration never checks a new method against the others: it
neither raises nor warns about an ambiguity, so the order in which modules
happen to be imported can never break a program that was working before.
Earlier versions issued a RuntimeWarning when a newly registered method was
bound to tie with one already registered. That check weighed the two methods
alone, so it also fired for a pair that a third method, registered before or
after them, settles at every call the two share, and it was removed. An
ambiguity now surfaces only at an actual call, as AmbiguousMethodError, or
on request, from ambiguities().
(0.3.0) ambiguities() reports a pair when some call matches both methods
and the whole selection of §2.2 still leaves both of them standing. The two
methods must bind a common call shape, be incomparable or equivalent, share
the same priority and the same tightness of fit, and carry hints that share
a value at every argument, as described below. At an argument where one hint
lies below the other, the values the two share are those of the narrower
hint, so the calls the pair has in common are described by a signature made
of the narrower hint at every argument. That signature is resolved against
every registered method as resolve_bestcandidates would resolve it, and the
pair is reported when both of its methods survive. A third method, in
whichever order it was registered, therefore clears the pair exactly when it
wins those calls at dispatch, and a priority, a tightness, or a repeated
TypeVar settles the pair the way it settles a call. Two precautions keep
the check from clearing a pair that a call would still find tied. The lookup
convenience of §4, by which a query of C reaches a slot of Exact[C], is
not applied, because a method written for Exact[C] does not win a value
whose class is a subclass of C. When the shared calls cannot be written as
one signature, because two hints share values only through a bound, or
because one method of the pair rejects the signature as a query, the pair is
reported without being resolved at all. What imprecision remains is on the side of reporting: a pair whose
shared calls form a union, won only jointly by several narrower methods, is
reported although every one of those calls dispatches.
A pair separated by an explicit, differing priority is resolved
deterministically at every call, since the higher priority always wins, so
it is not listed by ambiguities(). A same-origin parametrised pair, such
as List[int] and List[str] registered side by side, is likewise not
listed.
Registering both is entirely legitimate, since a plain list value applies
to both, and the incomparability between them only becomes visible at an
actual call. A value that declares its own parametrisation — an instance of
Child(List[int]), or one built as Box[int]() or GL[int]() — picks the
right one outright, and only a value that declares nothing at all is
genuinely ambiguous between them.
(0.3.0) A pair is recognised as sharing a call by checking that, at every
argument, the two methods' hints share a value, since a call built from one
shared value per argument then matches both, and neither method is more
specific. Two hints ordered against each other always share the narrower
one's values. A lower bound adds pairs that share values without being
ordered: Type[Animal] and Type[Super[Dog]\] are incomparable, yet both
accept Dog and Animal, and Type[Dog] and
Type[Between[Dog, Animal]\] both accept Dog. On a value the same
holds for Animal and Super[Dog], which share a Dog() and an
Animal(), and for Dog and Between[Dog, Animal], which share a
Dog(). Such a pair is recognised by reading each hint, or each Type or
Hint argument, as an interval (§4.2) and asking whether some candidate
lies in both intervals. The candidates are the ends of the two intervals
and, on a value or inside Type, every class on the MRO of a lower end
that is a class, because a class shared by both intervals lies above both
lower ends. That is how Type[Between[A, C]\] and Type[Between[B, D]\] are
found to share X, for a class X(C, D) with subclasses A and B,
although no end of either interval lies in the other. There a candidate
must also be a class, since the intervals hold classes. The
test is sufficient, so a reported overlap always has a real call behind it,
and for nominal class hierarchies, in which no class is made a subclass
through register or __subclasshook__, it is also complete when every
bound is a class. It is only ever consulted for a pair that involves a lower
bound, which leaves every other pair as it was. ambiguities() therefore
reports Type[Animal] and Type[Super[Dog]\] registered side by side, and
likewise Animal and Super[Dog]. A method for
Type[Exact[Dog]\] settles the call with Dog, but not the one with
Animal, while a priority on either method settles both; on a value,
Exact[Dog] settles the call with a Dog() in the same way.
(0.3.0) A bound in a type argument (§4.3) adds pairs of the same kind.
List[Between[Never, Integral]\] and List[Super[int]\] are unordered,
yet a list declared to hold int belongs to both. Such a pair is
recognised by looking for a parametrisation below both hints: one is built
on the more derived of the two origins, with each argument replaced in turn
by an end of the range it names or, for a shared origin, by the argument
the other hint gives there, and each candidate is checked against both
hints by the relation itself. The test is therefore sufficient, so a
reported overlap always has a real value behind it, and it is only
consulted when a bound stands among the arguments, which keeps
List[int] and List[str] unreported as before.
The MRO tie-break (§2.2) does not apply to a bound. A bound names a range
of classes rather than one position in the value's MRO, just as
Type[C] does, so mro_index gives no refinement for it, and a call with
a Dog() stays ambiguous between Animal and Super[Dog] exactly as a
call with Dog is between Type[Animal] and Type[Super[Dog]\]. When such
a call raises, the suggested fix spells the argument Exact[Dog], because
the exact class is below every competitor that accepts the value, a bound
included, while the plain class Dog is not below Super[Dog].
6. Public API
from bagof.dispatchers import dispatch
@dispatch
def area(shape: Circle) -> float:
return 3.14159 * shape.r ** 2
@dispatch
def area(shape: Rect) -> float:
return shape.w * shape.h
area(Circle(1))
# 3.14159
Redefining area a second time in the same module adds a method to the
existing generic function rather than rebinding the name to a fresh one.
Registries and Function identity
A registry is a Dispatcher
instance, and what identifies one Function, a named group of methods,
inside a given registry depends on which registry it is. The module-level
bagof.dispatchers.dispatch identifies a Function by
(defining module, __qualname__), so the same name defined in two different
modules produces two entirely independent functions, sharing no methods. A
Dispatcher() constructed directly instead identifies a Function by
__qualname__ alone, independent of which module registers into it, so
every module that registers area into the same constructed instance
extends one shared Function. This is how a shared, cross-module generic
function is built.
A Function is reached through its dispatcher's functions namespace,
either d.functions["area"] by item or d.functions.area by attribute.
Both of these get the existing Function or create it on first access, so
area = d.functions.area written in one module composes correctly with
registrations made from another. @dispatch also returns the Function it
just extended. A method is added either with @dispatch decorating
def area(...) directly (which takes the name from the def), or with
@area.register or @area.dispatch decorating any callable, including one
defined as def _(...), whose own name is then ignored. An explicit
signature can be laid over the wrapped callable: positional hints are given
as a tuple, named hints as a dict, and any keyword arguments passed
alongside them are registration options such as priority, never hints.
For example, area.register((int,), {"scale": float}, priority=0)(callable).
Passing a class as the implementation, as in area.register(SomeClass),
dispatches on that class's __init__ or __new__. The signature-only
form with no wrapped function at all, whether from_hints or
from_mapping given a tuple key, is positional-only by construction. To
declare a /, a *, an *args, or a **kwargs without writing a
function, pass a Signature object directly as the from_mapping key
instead.
d.functions is a protocol-only namespace: it supports the mapping
protocol (d.functions["area"], d.functions.area, iterating over it by
name, len(d.functions), and name in d.functions), and it defines no
named methods of its own. So every function name, including register,
items, or map, is safe to register there, while the Dispatcher itself
keeps its ordinary methods (d.register(...), d.clear_cache()) with no
risk of collision. Attribute access ignores any name starting with an
underscore, so that a tool or REPL probing the object for dunder or private
attributes never accidentally creates an empty Function.
Which key this namespace uses follows the identity rule above. On a
constructed Dispatcher() it is the bare name, so d.functions.area is
unambiguous. On the module-level dispatch, the key also carries the
module, so there the Function is taken from the decorator's own return
value, or from a qualified lookup, rather than a bare-name attribute.
# registry.py -- a shared, cross-module generic function
from bagof.dispatchers import Dispatcher
dispatch = Dispatcher()
area = dispatch.functions.area # the (initially empty) Function
# shapes.py
from registry import dispatch
@dispatch
def area(s: Circle) -> float: ... # extends registry's area
# app.py
from registry import area
area(Circle(1)) # sees shapes.py's method
Surface
The package exposes two namespaces with disjoint object sets.
bagof.dispatchers (its __all__, and the intended public API) holds
dispatch, Dispatcher, Function, Method, Signature, Parameter,
Exact, Hint, Super, SuperType, SuperHint, Between,
DispatchError, NoMethodError, and AmbiguousMethodError. bagof.dispatchers.core (also its own __all__)
holds the relation and introspection helpers that the rest of the family
reuses: issubhint, ishintstance, ishint, resolve_hint,
safe_get_origin, safe_get_args, get_origin_uw, get_args_uw,
unwrap, normalise_hint, is_typeddict, typeddict_required_keys,
safe_issubclass, safe_isinstance, issubclassable, issubscriptable,
get_concrete_type, type2hint, eq_safenan, Unset, UNSET,
NoneType, UnionType, and UNION_TYPES. Exact and Hint are
documented once each, under the top-level API, since they are the objects
both namespaces need. issubhint and ishintstance understand them
directly, but they belong conceptually to the dispatch surface. ishint,
which reports whether an object is a type hint at all, is a .core helper
only, since it is a building block rather than part of the dispatch API.
(0.2.0) Hint and ishint are new in this release. (0.3.0) Super,
SuperType, SuperHint, and Between are new in this release; like
Exact and Hint, they are documented once, under the top-level API, and
understood directly by the relation in .core.
Key objects:
Dispatcher()is a registry, identifying itsFunctions by the rule above. Reach them throughd.functions(d.functions.area,d.functions["area"], iterable by name).Functionsupports__call__(*args, **kwargs);dispatch(*args, **kwargs) -> Method, which binds and selects without calling;resolve(*hints, **named_hints, default=UNSET, ambiguity="raise") -> Method;register(...);from_mapping(mapping);methods;ambiguities();clear_cache(); and__get__, which bindsselforclsas argument zero (unannotated, so it isAnyby default). It carriesfunctools.update_wrappermetadata and is deliberately not adictsubclass.Function(dispatch_defaults=True)treats a default-filled dispatched parameter as if the caller had written its default value out explicitly, the same rulebagof.magic._polymorphalready uses (§8.2), and is off by default.- (0.3.0)
Functionalso reports what selection sees for a call without acting on it.candidates(*args, **kwargs) -> Tuple[Method, ...]returns every method that accepts the call, most specific first, andbestcandidates(*args, **kwargs) -> Tuple[Method, ...]returns the methods that survive every tie-break of §2.2 and §3. That is one method whendispatchwould succeed, the tied methods whendispatchwould raiseAmbiguousMethodError(whosecandidatesattribute holds the same tuple), and no method at all when nothing applies.itercandidatesanditerbestcandidatesreturn the same sequences as iterators, whileresolve_candidates(*hints, **named_hints)andresolve_bestcandidates(*hints, **named_hints)are the hint-level counterparts, reaching exactly the methodsresolvereaches. None of the six raisesNoMethodErrororAmbiguousMethodError. The order ofcandidatesis built in layers: the best candidates come first, then each layer holds the methods that nothing still unplaced is strictly more specific than, ordered by descending priority and then by registration order. A method therefore always follows every method that is strictly more specific than it, but the relative position of two incomparable methods is not a claim about specificity. The same question is answered by CLOS'scompute-applicable-methods, which returns the applicable methods sorted by precedence, by Julia'smethods(f, types), and by [multipledispatch]'sDispatcher.dispatch_iter, which yields the matching implementations in order of specificity. Methodexposessignature,function,priority,__call__, and__repr__.Parameter(name, hint, kind, default)is frozen;kindmirrorsinspect.Parameter.kind, andrequiredisdefault is Parameter.empty.Signatureexposes an orderedparameters: Mapping[str, Parameter], plus.varargs,.varkw, and.dispatched_names.from_callable(fn)reads hints throughtx.get_type_hints(fn, include_extras=True)and names, kinds, and defaults throughinspect.signature(an unannotated parameter reads asAny; keyword-only parameters are dispatched by name like any other).from_hints(*hints, **named_hints)builds one for explicit registration, as in@dispatch((int,), {"scale": float}).bind(args, kwargs) -> Optional[Binding]andle(other, shape), which gives specificity for one shape, round out the interface.bagof.dispatchers.core.resolve_hint(hint, mapping, *, default=UNSET, ambiguity="raise")is the hint-level functional API, the direct successor toget_from_registry(§8.1). It lives under.corebecause that is whatbagof-core-magicitself reuses.
The naive dispatcher's pre_check, post_check, __apply__, and
__error__ hooks are deliberately dropped: ordinary decorators cover the
same need, dispatch() and resolve() are the only dispatch-specific steps
worth exposing, and resolve(default=...) covers what __error__ used to
be for. Subclassing Function or Dispatcher is supported, in
anticipation of bagof.magic's own eventual field-name dispatch needs.
Forward references are handled the way bagof.magic._resolve._Deferred
already does: a raw annotation that raises NameError or TypeError when
first read is kept as-is and retried on the function's first dispatch; if
it is still unresolvable then, the error names the function and the
parameter. On Python 3.14 and its lazy annotations ([PEP 649]/[PEP 749]), the
resolution order is tx.get_type_hints(include_extras=True) first, then
annotationlib.get_annotations(fn, format=Format.FORWARDREF), and finally
the raw __annotations__ with deferral.
Caching and thread safety
Because the specificity order is defined per call shape (§2.2), the cache has two levels.
The first is a shape plan, one per distinct σ(C). For each method, it
precomputes that method's binding outcome for the shape, meaning which slot
each argument lands in, or that the method cannot bind at all, together
with the pairwise ⊑_σ comparison over every pair of methods for that
shape. Alongside these, it records, for each argument position, whether it
is value-dependent, declaration-dependent, or member-dependent.
- A position is value-dependent when any method's hint there is a
Literalor atype[...], aHint[...], or aUnionorTypeVarwhose members or upper bound include one, or a concreteTypedDict. Its value-level check reads the argument value itself rather than its type alone; for aHint[...], that value is the hint passed in, sointandstr, both of typetype, key the cache separately. - A position is declaration-dependent when any method's hint there is a
parametrised class generic, user-defined or standard-library, directly
or through a
Union,TypeVar, orAnnotated(but never throughTuple,Callable,Type[C], or aTypedDict). Its value check reads the__orig_class__recorded on an instance of aGenericsubclass, or of a class written against a PEP 585 alias (§2.3). - A position is member-dependent when any method's hint there is a
runtime protocol with data members, directly or through a
Union,TypeVar, orAnnotated. Its value check reads those members off the instance, recorded as the sorted union of every such protocol's data members landing at that position.
The shape-plan cache itself is a bounded LRU, and every plan in it is discarded whenever a method is registered.
The second level is a call cache, under each shape plan, keyed by
tuple(type(v_i) for positional v_i) + tuple((k, type(w_k)) for k in sorted
keyword names), with two adjustments. A value-dependent argument keys by
(type, value) rather than type alone, and a declaration-dependent one
keys by (type, __orig_class__), comparing the recorded parametrisation by
identity rather than equality. typing itself caches Box[int], so every
Box[int]() shares one cache entry, and comparing by identity avoids
merging entries that == would wrongly treat as equal (Literal[1] ==
Literal[True] on Python 3.8, for instance). A PEP 585 record such as
GL[int] is a fresh object at every subscription, so it is instead keyed
by its origin, its own arguments, each keyed the same recursive way, and
whether it is unpacked; every GL[int]() still lands in the same cache
entry this way.
A position that is both value-dependent and declaration-dependent is safe
to key this way even though the value's own == need not agree with the
record — a dataclass generic, for instance, compares only its fields. The
declared-parametrisation record itself is only ever read off an instance of
a Generic subclass or of a class whose MRO carries a PEP 585 base. It
is gated by one memoised per-class check that both the value check and the
cache key consult, so the two stay in lockstep and no other value is ever
probed for a record it cannot have. Because typing's own subscription
cache is a bounded LRU, a fresh Box[int] object created after eviction
simply misses the call cache once; it can never produce a wrong method. A
value the per-class gate refuses, such as a plain list at a List[int]
argument, is never probed and keys simply as (type, None).
These extra key computations have a measured cost. A cached call at a
gated-but-record-less position (a plain list at a List[int] argument)
costs roughly 0.1 to 0.25 microseconds more than an ordinary type-keyed
call. This is the cost of asking the gate and of building, hashing, and
comparing the richer key. A value that does carry a record costs more again: roughly
0.5 to 0.6 microseconds for a Box[int](), and roughly 0.7 microseconds for
a GL[int](), whose record is keyed by its own parts. At a
member-dependent position, the key instead carries a tuple of booleans, one
per recorded data member, saying whether the value has each one. This is
read by the same function the value check itself uses, so the key always
covers exactly what the check reads, and never the value's own identity:
every instance of a class that declares the same members shares one entry.
A member the class declares as an instance variable, whether by annotation
or by attribute, property, or slot, reads True for every instance of that
class. This comes from a per-class memo that also records how to look up
the class's own instances, so the read costs one set lookup rather than a
walk of the MRO. A ClassVar member, being read off the class itself, is
not part of the key at all. Because this read happens on every call, a
cached call at a
member-dependent position costs roughly one microsecond more than an
ordinary type-keyed one. A position with several of these dependences at
once carries every relevant part of the key together: the value, the
record, and the member booleans, since none of them can stand in for
another. A value's own == reflects neither its declared record nor which
of its attributes happen to be set. An unhashable value at a
value-dependent argument makes the call uncacheable. Positional and keyword
spellings of what looks like "the same" call are different shapes, and
therefore different cache keys, because they genuinely can bind differently. This is not an incidental
cost, but a direct consequence of binding being name-aware.
The cache is invalidated on every register call, when the methods tuple
is rebuilt and published in a single assignment following the same pattern
bagof.magic._polymorph._Registry uses, and whenever
abc.get_cache_token() changes, the same signal [functools.singledispatch]
watches. Registration takes a threading.Lock; reads are lock-free, which
matters on the free-threaded Python 3.13 build.
(0.2.0) Beneath the two dispatch-level caches sits a third, in
core._relation: issubhint memoises its own result by (hint,
superhint). Selection asks the same sub-hint question repeatedly, and the
same pair recurs across a shape plan's pairwise comparisons, so the memo
saves the recursive relation from recomputing it. Because internal
recursion goes back through the public issubhint, a nested comparison is
cached at every level, not only at the top. The cache is a bounded,
insertion-ordered working set (RELATION_CACHE_SIZE, read at store time),
evicting oldest-first exactly as the call cache does; reads are lock-free,
and a lock guards only eviction, insertion, and the clear. It is emptied
whenever abc.get_cache_token() changes, so a late ABC.register never
leaves a stale answer; a result computed while such a change lands is
dropped rather than stored, checked once more under the lock. Only ABC
registration is watched, though, exactly as the standard library's own ABC
caches watch it: mutating a class in a way the token does not track, such
as adding a data member to a Protocol or editing a TypedDict after it
has been compared, is not invalidated, and callers who do that must clear
the cache themselves. An unhashable pair is simply answered without
caching. On an interpreter whose typing.Literal equality merges distinct
literals (Python 3.8 and 3.9.0), the cache is keyed by object identity so
that two hints that compare equal but must dispatch differently are never
collapsed onto one entry. ishintstance is not cached, since it reads a
value whose identity is not a stable key. The relation cache only ever
changes how fast an answer is reached, never the answer itself.
Every rendered error names the full signature involved, such as
area(shape: !Circle, scale: float = 1.0) @ shapes.py:12, with / and *
markers where relevant, and *args: H or **kwargs: H spelled out. The
! marks the argument that actually failed. A binding failure, as opposed
to a type mismatch, is rendered in words after the signature instead, such
as -- no parameter 'scake', -- missing 'y', or -- 'x' is
positional-only.
7. Module layout
The top-level package is dispatch-only. __init__.py re-exports exactly
dispatch, Dispatcher, Function, Method, Signature, Parameter,
Exact, Hint, Super, SuperType, SuperHint, Between, and the
three error types. _lattice.py holds equivalent(), overlaps() (§5),
TypeVar solving, and the value-dependence classifier described in §6. This
is dispatch-internal code that builds on core._relation, while the actual
value check (ishintstance) is always called directly from core, never
wrapped. _signature.py holds Signature, Parameter, Binding, and the
precomputed per-method binder; _method.py holds Method, including its
deferred hint resolution; _function.py holds Function itself: the
methods tuple, the per-shape order, the two-level cache and its abc
token, dispatch/resolve/__call__/__get__, ambiguities(),
register, and from_mapping. _dispatcher.py holds Dispatcher, the
module-level dispatch, and the functions namespace. _errors.py holds
the three error types and their message builders. _constants.py holds the
package's __dispatch_*__ attribute names.
bagof.dispatchers.core is a separate, dependency-light subpackage whose
own __init__.py is a pure facade re-exporting the relation and
introspection helpers the rest of the family reuses. core/_compat.py
holds version-pinned names (NoneType, UnionType, UNION_TYPES), the
special-form detection described in §11, TypedDict markers, and the
spellings(name) helper that reconciles typing against
typing_extensions. core/_sentinels.py holds Unset and UNSET.
core/_exact.py holds Exact, its EXACT sentinel, and the helpers that
read it; these are also re-exported at the top level. (0.3.0)
core/_super.py holds Super, SuperType, SuperHint, their SUPER
sentinel, and the messages that refuse the unbounded forms; the three
public names are re-exported at the top level. (0.3.0)
core/_bounds.py holds Between and its LOWER marker, the interval
reading of a bound on a value or of a Type or Hint argument (§4.2),
the readers of a bound written as a type argument and of its slot's
variance (§4.3), and the position, endpoint, and variance refusals shared
by every bound written where it cannot stand, including the empty-interval
message; Between is re-exported at the top level. core/_introspect.py holds
the general-purpose introspection helpers (safe_get_origin/
safe_get_args, get_origin_uw/get_args_uw, unwrap, normalise_hint,
alias and NewType resolution, issubclassable, issubscriptable, the
TypedDict helpers, safe_issubclass, safe_isinstance,
get_concrete_type, type2hint, eq_safenan, and mro_index, which both
value dispatch and resolve_hint share). core/_relation.py holds
issubhint and ishintstance and every branch they dispatch to:
Exact-aware and Callable-variance-aware, with an opaque fall-through for
any unrecognised form (§11), and the private _typevar_upper helper.
core/_registry.py holds resolve_hint itself (§8.1).
Test modules mirror this layout one-to-one (test_introspect.py,
test_relation_callable.py, test_relation_modern.py, test_exact.py,
test_lattice.py, test_signature.py, test_dispatch_values.py,
test_dispatch_hints.py, test_registry.py, test_typevars.py,
test_cache.py, and the usual test_docstrings.py, test_import.py,
test_module_surface.py).
The package's only runtime dependency is typing_extensions>=4.13; an
optional test extra adds numpy solely to exercise eq_safenan against
numpy's own scalar types. Every private module is _-prefixed, following
the family's usual convention; core/ is the one public subpackage, and
its own __init__ is a facade with no logic of its own. The split into two
namespaces is deliberate for griffe's sake as well as the reader's: the two
namespaces export disjoint object sets, with dispatch objects at the top
level and relation and introspection helpers under .core. As a result,
nothing is ever documented under two different dotted paths, and Exact,
the one object both sides need, is documented exactly once, under the
top-level API. Every
name under .core traces back to original bagof code; none of it derives
from CPython's dataclasses, so no PSF licence notice travels with it (the
dataclasses-derived code in bagof-magic's own builder is unrelated, and
stays there).
8. resolve_hint and the relationship to _polymorph
8.1 resolve_hint's parity with the old summed-distance lookup
resolve_hint (§6) replaced an older lookup, get_from_registry, that
summed a numeric distance the same way the rejected dispatch model of §1
did. Moving to the relation-based lookup settled two behavioural corners
where the two disagreed, and confirmed a third already agreed:
- The MRO tie-break for equally specific class keys (§2.2, step 3)
carries over unchanged. When several keys in the mapping are equally
specific and the query itself is a class, the tie is broken by the
query's own MRO, using the same
mro_indexhelper the value-dispatch path uses.{Enum, str}resolvesclass Color(str, Enum)tostr, and the diamondclass D(B, C)resolves{C, B}toB, in both cases independent of how the mapping happened to be built. A tie the MRO cannot break, such as a non-class query or class keys that sit equally far from the query in its MRO, still falls through to the caller'sambiguitysetting. - A bare
TypedDictmarker is a sub-hint ofdict. EveryTypedDictvalue is, after all, adict, while a plaindictis not aTypedDict. The bare marker is therefore the unique most-specific key overdictfor aTypedDict-subclass query, and this resolves the same way regardless of the mapping's insertion order. (This is a purely nominal, hint-level fact, distinct from any question about what aTypedDictaccepts at the value level.) - A bare
Literalstays genuinely ambiguous against a concrete class. A bareLiteral, meaning "any literal value," is incomparable with a class, since not every literal value is, say, anint, and MRO refinement gives no help here either;{Literal, int}queried againstLiteral[1]is, correctly, left to the caller'sambiguitysetting.
Every consumer that looked entries up by hint, meaning the converters,
validators, and factories packages, calls resolve_hint directly today, with
ambiguity="warn" where a registry might still hold an unresolved tie and
"raise" once it is known to be clean; their registration dictionaries are
unchanged.
Beyond resolve_hint itself, building the relation on issubhint and
ishintstance changed a handful of other behaviours relative to the
package's summed-distance predecessor, each already described in its own
section above:
- a non-
runtime_checkableProtocolanswersFalserather than raising (§2.1); - a constrained TypeVar behaves as the union of its constraints (§2.1, §3);
Callable's parameters are contravariant and its return type covariant (§2.1, §2.3);Exact(§4) is understood by the relation directly.
eq_safenan, which the cache and the relation both rely on for comparing
NaN-bearing values, no longer imports numpy at all, since
numbers.Real already covers a numpy scalar through its own ABC
registration.
8.2 Name-aware dispatch and bagof.magic._polymorph
bagof.magic._polymorph already dispatches on field name, for a
Magic class's polymorphic construction. It computes once, per class,
where each constrained field arrives — a position for a positional field,
its public name for a keyword-able one. It then binds one call to those
names and checks each field's value against a Literal-shaped
specification. Ties are broken by (priority, number of specs, precision,
MRO depth), a lexicographic stand-in for "priority, then specificity over
names, then MRO depth." The way a call is bound to those names and each
candidate judged applicable is this package's own model, applied to the
parameter list of one generated __init__. Selection is where the two
differ. bagof.magic._polymorph breaks ties with the fixed lexicographic
tuple above, whereas this package ranks the applicable methods by the
partial order of §3, taking the single most specific one and reporting
ambiguity when two are incomparable.
A field's on={...} clause corresponds to a Method whose Signature is
the owning class's __init__ signature, with the constrained names' hints
replaced by the field's own value specifications and every other name read
as Any. Reading a default-filled dispatched parameter as if its default
had been written out is the behaviour a Function opts into with
dispatch_defaults=True, so the binding matches, and
AmbiguousPolymorphError and NoPolymorphError correspond to the dispatch
errors of §5. No adapter from positions to names is needed to make the
correspondence work, because both models are name-aware from the start.
9. Corner cases
Each case below is covered by a dedicated test.
Hints and the lattice
objectis strictly more specific thanAny, and an unannotated parameter is read asAny.List[int]is strictly belowlist, which is equivalent toListitself.- Two overloads on
List[int]andList[str]at the same position are ambiguous at the call (resolved bypriority), butambiguities()does not list them, since both are genuinely legitimate registrations (§2.3). OptionalandUnionare ordered byissubhint; aNoneargument is read asNoneType.- A bare
Union,Literal, orType, used with no arguments, is never applicable to a value, and warns. Literalis value-dependent;Trueis notin Literal[1];NaNcomparisons go througheq_safenan; an unhashable value at a value-dependent argument is left uncached.- The
Tuple[X, ...]/Tuple[X, Y]/tuplechain orders as expected; items are never inspected;Tuple[()]is valid. Callableparametrisations order by contravariant parameters and covariant return; the full chainCallable\[[int], R] < Callable[Concatenate[int, P], R] < Callable[P, R] ≡ Callable[..., R]holds (§11.1); a repeatedParamSpecis solved by greatest element at the hint level, and shallowly at the value level.type[X]is value-dependent;type[bool] < type[int] < type. (0.2.0)type[X]reads its argument through the full relation, sotype[Any]accepts every class,type[Union[…]\]accepts a class in the union, andtype[Exact[C]\]is an identity leaf belowtype[C].Annotatedmetadata other thanExactis invisible (Annotated[X, ...] ≡ X); applyingExactto a non-class raisesTypeError. (0.2.0) A bare, unsubscriptedAnnotatedsuper-hint is structural instead of opaque:Annotated[X, …] ≤ Annotatedholds andint ≤ Annotateddoes not.- (0.2.0)
Hint[X]dispatches on a hint passed as a value: aHintform is ordered only against anotherHintform, covariantly by its argument, so no ordinary hint sits below it, and above it sit only the tops (Any, a freeTypeVar, a union with aHintmember) and a widerHintform up toHint ≡ Hint[Any]-- never an ordinary class such asobject.Hint[Exact[X]\]matches the exact hintXstructurally, not merely something equivalent to it, so a freeTypeVardoes not matchHint[Exact[Any]\].Hint[X]is value-dependent, and the outer formExact[Hint[X]\]normalises toHint[Exact[X]\]. - (0.3.0)
Super[C]is a lower bound (§4.2). On a value parameter it accepts a value whose class isCor above it, and insideTypeorHintthe class or hintCand everything above it. On a value it may also be a member of the parameter's union or the bound of aTypeVarused there, and each bound must be a hint a class can be compared against; insideTypeorHint, and as the type argument of a generic (§4.3), it must be the whole argument, and any hint can be its bound.Super[Never]on a value is equivalent toobject,Super[object]accepts only an instance ofobjectitself, andSuper[Any]accepts no value. In the same wayType[Super[Any]\]accepts no class,Type[Super[object]\]accepts onlyobject, andType[Super[Never]\]accepts every class;Hint[Super[Any]\]accepts only the tops.Super[Super[C]\]collapses toSuper[C], andSuper[Type[C]\]normalises toType[Super[C]\].Super[Exact[C]\]andExact[Super[C]\]are refused when they are written. An unsubscriptedSuper,SuperType, orSuperHint, and a bound in a position where it cannot stand, are refused at registration with the parameter named, and by the relation with the same message. - (0.3.0)
Between[L, U]stands in the same positions asSuper[C](§4.2).Between[C, C]is legal and holds the hints equivalent toC, which is wider thanExact[C], soExact[C] ≤ Between[C, C]andType[Exact[C]\] ≤ Type[Between[C, C]\]but not the reverse; on a value,Between[C, C]accepts a value whose class is equivalent toC, not only one whose class isCitself. The degenerateBetween[Never, Never],Between[Never, Any], andBetween[Any, Any]are legal, whileBetween[Any, U]for any otherUis refused as empty, with a message pointing toBetween[Never, U].Exact,Super, andBetweencannot be nested inside one another in any order, even through a union or aTypeVar. There is no outer form:Between[Type[A], Type[B]\]is not rewritten, and on a value it is refused with a message namingType[Between[A, B]\]. - (0.3.0) A bound may be the whole type argument of an invariant slot,
where it names a range of arguments (§4.3):
List[Super[int]\]accepts a list declared to holdintor a type above it. At a covariant slot,Sequence[Between[Never, C]\]is the same asSequence[C]andSequence[Super[C]\]is refused; at a contravariant slot the reverse holds. A bound in a union or as aTypeVarbound inside a slot, a bound in a generic whose variance cannot be read, and a bound in aTupleorCallableare refused, while one nested inside a generic there is legal. A bound does not reach through the slot around it, soList[List[Super[C]\]\]is a single point at its outer slot. A value declaringBox[Super[int]\]is read as that range, andishintstance([], Sequence[Super[int]\])raises, since the hint is refused. - (0.3.0)
Anyor an unconstrained TypeVar at an invariant slot reaches a base's slot as the range it stands for, and a constrained TypeVar as each of its constraints, soW[Any] ≤ Snk[int]is False forclass W(Snk[T])withSnkcontravariant (§2.3). - (0.3.0)
Type[C]andType[Super[D]\]withD ≤ Care incomparable yet share the classes betweenDandC, soambiguities()reports the pair, and a call with one of those classes is ambiguous unlesspriorityor anExactmethod settles it (§5). The same holds on a value forCandSuper[D], and for the values whose class lies betweenDandC. - (0.2.0)
issubhintandishintstancereject a non-hint the wayissubclassandisinstancedo: a non-hint on either side ofissubhint, or as the hint argument ofishintstance, raisesTypeErrorrather than being read asAny;issubhintreports the left argument first when both are at fault.ishintreports whether an object is a hint at all. - A
TypedDictorders correctly at the hint level; its value-level check inspects the mapping's own shape. - A runtime
Protocolis structural; two protocols a value structurally satisfies are ambiguous unless comparable; aProtocolthat is notruntime_checkableregisters but answersFalseat both levels, so it is reachable yet never actually matches, and the call raisesNoMethodErrorrather than crashing. - A runtime protocol with data members is read member by member, with the value-level check keyed by which members the value actually has (§2.3).
- A class that annotates a member sits below the protocol and wins over it, whether the call is positional or keyword, and an instance of it that never set the member is still in the protocol at the value level too.
- A class that declares nothing, whose instance later acquires the member anyway, stays genuinely ambiguous against the protocol.
- An inherited annotation still counts, and a subclass that redeclares an
inherited member as
ClassVarstill sits below the protocol. - The two member kinds, instance variable and class variable, are mutually exclusive, exactly as the type checkers read them.
- A text annotation's marker is looked up by name rather than evaluated, so
an import alias, a module-qualified name, or a user class named
InitVarare all read correctly. - A dataclass field with
init=Falsecounts as a member; anInitVarand aTypedDict's own keys do not. - A read-only member, such as a property with no setter or a
Finalattribute, counts toward a protocol's requirement — a deliberate divergence from what a type checker would accept. - ABC registration is honoured through
issubclass; a lateregister()call invalidates the cache through theabccache token. - A diamond
class D(B, C)resolves toB; a class compared against a satisfied ABC that is not in its MRO is ambiguous rather than resolved. - The preorder's laws (reflexivity, transitivity, and antisymmetry up to equivalence) are checked by property-based tests over a broad corpus of hints.
Modern typing forms (§11)
type X = …resolves to its value; two aliases of one underlying value replace each other with a warning; recursive alias resolution stops at its own origin.L[int], fortype L[T] = list[T], resolves toList[int].NewTyperesolves to its declared supertype.- The
Tuple[int, *Ts]family orders as described in §3: a*Tsrun captures whatever is left over, a longer fixed prefix or suffix is stricter,Tuple[int, *Ts]againstTuple[*Ts, int]and againstTuple[int, ...]are both incomparable, andTuple[*Ts] ≡ Tuple[Any, ...]; a repeated*Tsat several top-levelTupleslots is solved jointly by greatest element at the hint level (value-level solving remains out of scope); two open runs in one parameter list raiseTypeErrorat registration;Tuple[int, *Tuple[str, int]\]flattens. - The
Callable[P, R]family orders as described in §2.1 and §11.1, with.../barePat the top and aConcatenateprefix strictly between; a repeatedPis solved jointly by greatest element at the hint level;Callable\[[int, *Ts], R]rides the open-tail machinery. *args: P.argsand*args: *Tsare both read as anAnytail; a bareTs/P, or a top-levelUnpack[Ts], used as a parameter annotation raisesTypeErrorat registration;Unpack[TD]on**kwargsis ignored.- A
Neverparameter makes its method never applicable. Required,NotRequired,ReadOnly,Final, andClassVarare all transparent, read as their inner type.- An unrecognised or future special form is opaque, behaves like
Any, and emits oneUnknownHintWarning; it never crashes. TypeVar(bound=float, default=int)reads as bound tofloat; anintargument does not match it, since there is no numeric-tower promotion at runtime.- A native PEP 695
TypeVarwith no__default__is read throughgetattr(..., NoDefault)rather than.has_default(). list[int], on Python 3.9 and 3.10, is not mistaken for an ordinary class.- Both spellings of
Unpack, native and fromtyping_extensions, are recognised on Python 3.11.
Calls and parameters
- Default values give a method an arity range, and among otherwise-tied methods the one with the shorter fixed arity wins on tightness.
- A method with
*args: Hloses on tightness to one with a matching fixed arity. - A call with no arguments matches only zero-arity methods.
- A method defined inside a class is bound through
__get__, andselforclsbecomes argument zero, read asAnyunless explicitly annotated. - An unhashable argument leaves the call uncached.
- A lying
__class__is a documented limitation:type(v)is what the cache key and MRO refinement read, whileisinstancesemantics (which a lying__class__can fool) govern applicability. - No error message ever calls
repron an argument's value.
Name-aware binding
- The same argument bound positionally in one call and by keyword in another still selects the same method.
- A keyword that only some methods accept simply makes the others
unbindable, not ambiguous with them; a keyword no method accepts raises
NoMethodError, naming it with a "did you mean" suggestion. - A positional-only parameter (
/) can only ever be filled positionally; a keyword-only one (*) is dispatched by name and never filled positionally. - A default-filled parameter is excluded from both applicability and
specificity, unless the function was built with
dispatch_defaults=True. - Extra keywords bind to
**kwargs: H, checked againstHwhen annotated and againstAnyotherwise;Unpack[TD]there is treated as unannotated. - Two methods with the same parameter names in a different order are not
duplicate registrations, since a positional call still tells them apart,
though a keyword call between them is ambiguous without an explicit
priority. - A TypeVar shared between
*argsand a named parameter requires consistency over every bound argument together (§3). - Passing the same value under two spellings at once, as in
f(1, x=1), fails to bind at all, matching ordinary Python call semantics. f(1, 2)andf(1, y=2)are different shapes, and therefore different cache keys.- The reduce-to-positional guarantee (§2.2) is checked directly, as a property test against a dedicated positional reference implementation.
Registration and lifecycle
- A new registration clears the cache, extends the methods tuple, and publishes the result atomically.
- Concurrent registration and calls are never torn, thanks to the registration lock and the atomic publish.
- The same name registered in two different modules against the
module-level
dispatchproduces two distinctFunctions; re-registering a method after a module reload replaces it and emits aRuntimeWarning. - A malformed registration raises
TypeError. - Two methods tied on equal, explicit priority are still ambiguous: priority only ever breaks a tie when it differs, and never overrides a strict specificity win.
- A pair separated by differing priority is resolved deterministically and
is therefore not listed by
ambiguities(). - (0.3.0) Registering a method never warns about an ambiguity, and
ambiguities()reports a pair only when the whole selection, every other registered method included, still leaves the pair tied on the calls it shares (§5). - A same-origin parametrised pair, whether invariant like
List[int]andList[bool], or otherwise incomparable likeList[int]andList[str], is incomparable and hence ambiguous at a call that supplies no declared parametrisation, but registering both is legitimate, so the pair is not listed (§2.3). - The re-exported names under
.coreretain their identity, andresolve_hint's behaviour matches its predecessor's wherever the two were meant to agree (§8.1).
10. Scope
Some capabilities are explicitly out of scope for the design, either permanently or pending further work.
A handful of capabilities are not planned at all, because they conflict with the symmetric design (§1):
invoke/next-method fall-through to a less specific overload;- dispatch on a function's return type;
- dispatch on a keyword-only parameter that is not itself part of the call's binding;
- static overload synthesis, the kind a type checker performs at analysis time;
- asymmetric, left-to-right argument precedence in the style of CLOS.
Other work is deferred, and is tracked separately:
- Value-level solving of
ParamSpecandTypeVarTuple: a callable or tuple value is matched shallowly, without inspecting its actual signature or element shape, soprintmatches everyCallable[...]slot and(1, "a")matches everyTuple[...]slot; only the hint level solves either of them (§3). - The repeated-TypeVar grouping tie-break (§3) is not extended to a
repeated
ParamSpecorTypeVarTuple: two incomparable open prefixes, or two incomparable runs, stay ambiguous, and*args: *Tsnever beats a plain*args. - The following are all out of scope for
ParamSpec:P.argsandP.kwargsas first-class components, which for now remain anAnytail;ParamSpec(bound=...); PEP 696 defaults on aParamSpec; and the consistency of oneParamSpecacross nested positions within a single hint, since only a top-level, landedCallablejoins a group. - The following are all out of scope for
TypeVarTuple: more than one unpack in a single parameter list;Unpack[Ts]written on**kwargs; PEP 696 defaults on aTypeVarTuple;TypeVarTuplebounds, which do not exist in the typing specification; and a*Tsin the middle of aCallableparameter list followed by a fixed suffix, which degrades to an open shape instead.
11. Modern typing constructs and version support
The design commits, as a first-class goal, to supporting Python 3.8 through
the newest and future versions. Every construct is reached exclusively
through import typing_extensions as tx, and the relation treats every
spelling of a construct uniformly and tolerates whatever it does not yet
recognise. Two facts about the summed-distance baseline this improves on
shaped the approach.
First, nothing in that baseline actually crashed, but a great deal of it
was silently dead. For every modern construct tested, its
issubhint/ishintstance answered False in both directions, so a method
annotated with a type alias, a NewType, Never, TypeIs, or
Required[...], for instance, registered successfully and simply never
fired. For a dispatcher, that is worse than an outright crash, since
nothing signals the mistake. §11.2 turns "silently dead" into "understood,
or explicitly and visibly degraded."
Second, typing.X and the corresponding typing_extensions.X are not the
same object, and which one a given construct actually produces drifts by
Python version, measured to differ across 3.10 through 3.13.
tx.get_origin(tx.Unpack[Ts]), for example, returns
typing_extensions.Unpack on 3.11 but typing.Unpack on 3.13, and a
native type X = int alias is not an instance of tx.TypeAliasType. Every
identity check the relation performs on a special form therefore has to
accept both spellings, generalising the _TYPEDDICT_MARKERS approach into
the _compat.spellings(name) helper.
11.1 Per-construct handling
| Construct | Native since | typing_extensions backport |
Handling and scope |
|---|---|---|---|
PEP 695 def f[T] / class C[T] |
3.12 | — (syntax only) | __type_params__ holds ordinary TypeVars; dispatch treats them identically to legacy ones. A default is read through getattr(tv, "__default__", tx.NoDefault), since a native 3.12 TypeVar has no has_default(). Supported. |
PEP 695 type X = … (TypeAliasType) |
3.12 | 4.6+ | resolve_alias detects one by duck type (__value__ plus __type_params__) or either spelling, returns __value__, and substitutes arguments for a subscripted G[int] through typing's own __getitem__; resolution is recursive, with a cycle guard, and runs at the top of issubhint/ishintstance and from normalise_hint (not from unwrap). Supported. |
PEP 613 TypeAlias |
3.10 | 4.x | The bound value is an ordinary hint; the bare marker itself falls under the unknown-form rule. Supported (trivially). |
| PEP 696 defaults | 3.13 | 4.4+ | Only the bound or constraints are read, through _typevar_upper; the default is ignored. Supported. |
PEP 646 TypeVarTuple/Unpack/*Ts |
3.11 | 4.1+ | Unpack[Ts] inside Tuple[...] is an open run of zero or more Any, split into prefix and suffix by the tuple-shape classifier; a fixed prefix or suffix captures the rest, a longer one is stricter, and Tuple[*Ts] ≡ Tuple[Any, ...]; a repeated *Ts at top-level Tuple slots (and in Callable\[[int, *Ts], R], through the open-tail path) is solved jointly by greatest element at the hint level; *args: *Ts reads as an Any tail sharing that same run; a bare Ts or a top-level Unpack[Ts] used as a parameter, and two open runs in one list, both raise TypeError at registration. Value-level solving is deferred (§10); a *Ts in the middle of a list followed by a fixed suffix degrades to an open shape; Unpack[Ts] on **kwargs, and PEP 696 defaults on a TypeVarTuple, are both out of scope. Supported at the hint level. |
PEP 612 ParamSpec/Concatenate |
3.10 | 4.x | Callable\[[int], R] < Callable[Concatenate[int, P], R] < Callable[P, R] ≡ Callable[..., R], with .../bare P at the top of the parameter-list order (§2.1); Concatenate is a contravariant prefix, and a longer prefix is stricter; a repeated P is solved jointly by greatest element at the hint level; *args: P.args reads as an Any tail; a bare P or Concatenate used as a parameter raises TypeError at registration. Value-level solving is deferred (§10). Supported at the hint level. |
NewType |
3.5 native / 3.10 class-based | typing_extensions class on 3.8/3.9 | resolve_newtype follows __supertype__ recursively, inside normalise_hint. Supported. |
Never/NoReturn |
3.11/3.6 | 4.1+ | The bottom type; a Never parameter makes its method never applicable — an explicit way to forbid a combination. Supported. |
TypeGuard/TypeIs |
3.10/3.13 | 4.x/4.10+ | Treated as bool. Supported. |
LiteralString |
3.11 | 4.1+ | Treated as str. Supported. |
Self |
3.11 | 4.0+ | A method's Self resolves to the owner class through __get__; on a free function, it falls under the unknown-form rule. Supported, or degraded, as appropriate. |
Required/NotRequired/ReadOnly |
3.11/3.11/3.13 | 4.0+/4.9+ | Transparent qualifiers, unwrapped to their inner type. Supported. |
Final/ClassVar |
3.8 | — | Transparent qualifiers, unwrapped to their inner type. Supported. |
Annotated/Doc (PEP 727) |
3.9 / typing_extensions | 4.x/4.9+ | Transparent except for EXACT (§4); Annotated is a class through 3.12 but not on 3.13, which is pinned for. Supported. |
PEP 604 X \| Y |
3.10 | — | Recognised through types.UnionType, tracked in UNION_TYPES; on 3.14, types.UnionType is typing.Union is pinned for. Supported. |
PEP 585 list[int] |
3.9 | — | isinstance(list[int], type) is true on 3.9 and 3.10, so it is guarded with get_origin(x) is None before anything treats it as a plain class; equivalent to List[int]. A class written against one, such as class GL(list[T]), takes the TypeVars its PEP 585 bases mention, each at its declared variance, and its instances' declared parametrisations are read the same way a Generic instance's are (§2.3). Supported. |
User Generic[T] |
3.8 | — | The origin is checked by isinstance; arguments are compared by each position's declared variance, read live off __parameters__ (covariant/contravariant/unflagged→invariant, infer_variance→invariant); a subclass is compared through the bases it was written with, and a value by the parametrisation it declares (§2.3). Supported. |
| Unknown or future form | — | typing_extensions checked first | The opaque rule of §11.2. Degraded, not crashed. |
A note on the numeric tower: issubhint(int, TypeVar(bound=float)) is
False. PEP 484's float/complex promotion is a convention type
checkers apply statically; dispatch instead follows runtime isinstance
semantics, which do not promote. Callers who want int and float to
dispatch together write numbers.Real or Union[int, float] explicitly.
11.2 Forward-compatibility strategy
An unrecognised hint is treated as opaque, behaving like Any. It is
accepted by every other hint as a superhint (issubhint(x, unknown) and
ishintstance(v, unknown) both answer True), while being a subhint only
of itself and of Any. The first time a given unrecognised form is
encountered, it emits one UnknownHintWarning naming the form, so that a
method built on it remains reachable rather than silently dead.
Inside the relation, forms are handled in a fixed order. normalise_hint
first resolves None, NewType, TypeAliasType, and any transparent
qualifier. Known non-class forms are then matched by identity, accepting
either spelling, and an origin that is itself a class takes the ordinary
structural path after that. Anything still left over falls to the opaque
rule above.
There is no bare return False fall-through anywhere for a form the
relation simply does not recognise.
The set of known special forms is kept as an explicit tuple for the common
case, backed by a structural fallback in _compat. A value counts as a
special form if it is a type whose __module__ is typing or
typing_extensions and it is not Generic, Protocol, or a TypedDict
marker. Combined with the try/except TypeError → False guard already
inside safe_issubclass, no future construct, one that does not exist yet
on any currently supported Python, can raise its way out of the relation.
11.3 Version-by-version notes
- On Python 3.8, every modern construct is reached through
typing_extensions. Nativelist[int]andX | Ydo not exist, so a stringified annotation using either raisesTypeErrorinsideget_type_hints, caught and deferred like any other forward reference.NewTypeis atyping_extensionsclass rather than a function. - On Python 3.9, PEP 585 arrives, and
isinstance(list[int], type)is true, soissubclassableandsafe_issubclassguard against it withget_origin(x) is None. - On Python 3.10,
X | Y,ParamSpec,Concatenate,TypeAlias, andTypeGuardall become native;NewTypebecomes a class; both spellings of everything are still required. - On Python 3.11,
Anybecomes a class;Never,Self,LiteralString,TypeVarTuple,Unpack, andRequiredall become native, and the PEP 585GenericAliastrap from 3.9/3.10 is fixed.typing_extensionsstill supplies its ownUnpack,TypeVarTuple, andTypeVar, distinct from the native ones.tx.Unpack is not typing.Unpack, and the star syntaxTuple[int, *Ts]producestyping.Unpack[Ts]whiletx.Unpack[Ts]produces thetyping_extensionsone; the two are not even equal, sospellings("Unpack"), comparing by identity across both, is mandatory rather than a convenience.tx.TypeVarTuple is not typing.TypeVarTupleeither, so aTypeVarTuplecan only be recognised byisinstance, never by class identity. Re-registering a method that switches between*TsandUnpack[Ts]reads as a brand-new method, since structural equality compares the origin by identity. This is an acceptable consequence, not a bug. - On Python 3.12, PEP 695 syntax and a native
TypeAliasTypearrive (duck-typed rather than assumed); native PEP 695 TypeVars still lack__default__;Annotatedremains a class. - On Python 3.13, PEP 696 defaults,
TypeIs, andReadOnlyall become native;Annotatedis no longer a class; the free-threaded build exists, which is why registration takes an explicit lock. - From Python 3.14 onward, lazy annotations ([PEP 649]/[PEP 749]) are read through
annotationlibwithFormat.FORWARDREF;Unionbecomes a class, withtypes.UnionType is typing.Union. Anything newer than this is covered by the opaque rule, the structural special-form fallback, andspellings(), producing a warning rather than a crash.