typeshed patches¶
basedpython vendors typeshed as .byi stubs, regenerated from upstream ruff's
.pyi on every sync (see the upstream-sync workflow). reverse-transpile
recovers most of what basedpython needs, but the stubs still arrive in the
legacy TypeVar(...) + Generic[...] form, and a few carry semantics no
mechanical step can reconstruct. the by_typeshed_patch crate fixes both,
deterministically, so a fresh sync always reproduces the committed tree
it does two things:
- semantic patches — small rust modules under
crates/by_typeshed_patch/src/patches/, registered inall_patches(), each restoring one deliberate basedpython choice (seemapping-key-covariancebelow) - pep 695 conversion —
crates/by_typeshed_patch/src/pep695.rsrewrites every legacy generic class into a pep 695 header with explicit variance keywords (out/in/in out) and nice type-parameter names (_KT_co→Key,_T_co→Element, ...). this is the bulk of the diff
where patches sit in the sync¶
scripts/sync_typeshed_by.sh runs two phases:
- reverse-transpile — every upstream
.pyibecomes a.byi by_typeshed_patch— for each.byi: apply the registered semantic patches, re-parse, then run the pep 695 conversion
basedpython owns the pep 695 migration itself, which is what lets it emit the explicit variance and nice names
the two passes run in order with a re-parse between them, because a semantic
patch may rewrite a typevar reference (e.g. covariance) that the conversion then
renames. semantic patches see the legacy form: typevars declared with
TypeVar(...) and referenced as plain names in class bases and method
signatures, with no pep 695 type-parameter lists and no variance keywords yet.
write them against that form
the pep 695 conversion¶
pep695::convert_module reads every module-level TypeVar/TypeVarTuple/
ParamSpec declaration (recording variance, bound, constraints, default) and
rewrites each generic class header. variance maps covariant → out,
contravariant → in, invariant → in out (basedpython has no bivariant
spelling — in out is explicit invariance). names come from a curated table
for the core containers and a mechanical fallback (strip the leading underscore
and the _co/_contra suffix) for everything else; within one class colliding
names get a numeric suffix. a candidate is also rejected when the module already
binds it — an import, a class/function definition, an assignment — so a type
parameter never shadows a name the stub's own annotations refer to
(xml.etree.ElementPath imports Element, so its _T becomes T). the
curated table is keyed by kind as well as name, so _P is Parameters only
when it really is a ParamSpec
a ParamSpec becomes a type variable bound by the top parameters form —
_P = ParamSpec("_P") gives [Parameters: (*: *, **: *)], not [**Parameters],
which in basedpython declares a keyword-variadic pack instead
it is deliberately conservative. a class is only rewritten when every type
parameter resolves to a known module-level typevar — anything it can't fully
characterise (an imported typevar, an unusual base) is left in legacy form
rather than risking a broken stub. generic functions and type aliases are left
alone too. a typevar declaration is removed only once every reference to it has
been consumed by a conversion, and only when it is private (_-prefixed):
public typevars like AnyStr may be re-exported and imported by other modules,
so they always survive
the Patch trait¶
pub trait Patch {
fn name(&self) -> &'static str;
fn target_symbols(&self) -> &'static [&'static str];
fn rewrite(&self, module_path: &Path, parsed: &Parsed<ModModule>, source: &str) -> Vec<Edit>;
}
name— stable id for logs and drift alertstarget_symbols— qualified symbols the patch depends on, e.g.["typing.Mapping"]. used for drift detection: if an upstream sync changes one of these symbols, the patch is flagged for reviewrewrite— returnEdits (disjoint byte spans + replacement text) for the module atmodule_path, relative to the typeshedstdlib/root. an empty vec is a no-op for this file
prefer driving edits off the parsed AST rather than raw text scanning — locate the node, then emit an edit over its range. that keeps a patch precise (exact identifier matches, correct scoping) and idempotent (re-running over already-patched source produces no edits)
a patch that replays a reverse transform¶
reverse-transpile and the patches divide the work by when they can run, not by
what they know: a reverse transform sees the upstream .pyi, a patch sees the
committed .byi. so a rewrite that belongs in the reverse pass, written after
the tree has already been converted, has nowhere to land — the reverse pass will
never look at those files again, and replaying the whole pass over them would
have the rest of the transforms read basedpython as if it were the python they
invert (Iterable[(Key, Value)], a tuple type, would lose its parentheses to
the transform that strips python's redundant grouping)
optional-marker is that case: T | None → T? is an ordinary reverse
transform, and it is written once, in by_transforms. the patch is a four-line
adapter over
by_transforms::optional_marker_edits,
which runs that single transform and hands back its edits. it ignores the
parsed module it is given and works from source alone, because the transform
does its own parse — and it needs to, since the rule turns on a question the
syntax cannot answer: ? over a bare type variable is the wrapped optional,
so a stub's Value | None has to stay a union
reach for this shape whenever the rule already exists in by_transforms. write a
patch of its own when the rule is about the stub form specifically — the legacy
TypeVar declarations, the explicit variance keywords, an upstream convention —
which is what every other patch here is about
worked example: mapping-key-covariance¶
upstream typeshed declares Mapping with an invariant key typevar:
class Mapping(Collection[_KT], Generic[_KT, _VT_co]):
def __getitem__(self, key: _KT, /) -> _VT_co: ...
basedpython treats mapping keys as covariant. the covariant typevar _KT_co is
already declared in typing, so the patch rewrites every _KT reference inside
the Mapping class to _KT_co:
class Mapping(Collection[_KT_co], Generic[_KT_co, _VT_co]):
def __getitem__(self, key: _KT_co, /) -> _VT_co: ...
variance inference can't recover this on its own — _KT sits in parameter
position in __getitem__, get, and friends, which would force an invariant
(or contravariant) reading. the covariance is a deliberate basedpython choice,
so it has to be applied explicitly
the patch walks the module, enters only the Mapping class (tracking depth so a
future sys.version_info guard wouldn't hide it), and collects the spans of
every _KT name within. MutableMapping, which needs an invariant key for
__setitem__, is a separate class and is left untouched.
collections.abc.Mapping and _collections_abc.Mapping both re-export
typing.Mapping, so the single rewrite covers every surface path
the pep 695 conversion then runs over the patched source and, seeing two covariant key/value typevars, produces the final header:
class Mapping[out Key, out Value](Collection[Key]):
def __getitem__(self, key: Key, /) -> Value: ...
MutableMapping, whose _KT/_VT stayed invariant, becomes
class MutableMapping[in out Key, in out Value](Mapping[Key, Value])
worked example: numeric-promotion¶
python's typing spec reads a float annotation as int | float, and upstream
typeshed is written against that rule. basedpython has no such rule, and the
vendored stubs are .byi, so nothing supplies the missing arm — the stub has to
say it. this patch writes the union out, in every position the special case is
actually about:
def sleep(secs: float) -> None # -> def sleep(secs: int | float) -> None
def mean[T in (float, Decimal)]() # -> def mean[T in (int | float, Decimal)]()
what counts as such a position is the whole design, and it is a question of
direction rather than of which syntactic slot the annotation sits in. a
parameter accepts; a return, a let, a module constant, an attribute, a class
base and a type-variable default all produce. crucially, each callable
parameter list along the way flips which it is, exactly as contravariance does:
def config(xscrollcommand: (float, float) -> object) # tk hands these in: exact
def kde(...) -> (int | float) -> float # you call this one: widened
widening a callback's own parameters does not admit an int — it demands the
caller's function accept one, rejecting the obvious def cb(a: float, b: float)
two more things accept alongside parameters: type-variable constraints and
bounds, which a call solves from its arguments (statistics.mean([1, 2])), and
type aliases used for nothing but parameters — whether an alias qualifies depends
on where it is used, which no single file can see, so numeric_promotion::scan
decides it once over the whole tree, the way private_names::scan does for the
private conversions
nothing is widened where the union could not be spelled anyway: inside
type[...], which names a class rather than a value of it, or in a union arm
whose all-int reading already sits beside it (list[int] | list[float] covers
both, while list[int | float] — list being invariant — would cover neither)
the corrections table¶
direction gets the syntax right; what it cannot know is what CPython does
inside. CORRECTIONS in the patch is one entry per such fact, each carrying
the reason it is true:
colorsys.hls_to_rgbshort-circuits withreturn l, l, l, so it hands an argument straight back andhls_to_rgb(0, 1, 0)is(1, 1, 1)socketserver.BaseServer.timeoutis documented as a knob to set, andsubprocess.TimeoutExpired.timeoutis raised with the caller's own valueFraction + intis aFraction, never afloat, because an earlier overload answers first — so the float overload must not grow anint- the sequence portion of
os.stat_resultis ten integers:os.stat(f)[7]is anintwhileos.stat(f).st_atimeis afloat
every entry was checked against a running interpreter rather than reasoned about,
and corrections_all_apply fails if one stops matching the tree, so an upstream
rename is caught instead of silently turning a correction into a no-op
widening only adds an arm that is not already reachable, so the patch is
idempotent, and both the unions upstream spells out by hand (float | None) and
the constraint lists that already carry an int beside the float
(array[Element in (int, float, str)]) come out unchanged
worked example: collections-abc-home¶
every other patch rewrites one module in place. this one moves code between two:
upstream declares Mapping, Iterator, Sequence and the rest of the
collections.abc ABCs in typing.pyi, and has _collections_abc.pyi import
them straight back, which is the opposite of what python does. the patch takes
the class statements out of typing and puts them in _collections_abc, and
gives typing the re-export import in exchange
a cross-module move fits the per-file Patch trait because the decision is taken
once, in scan, before any file is rewritten: scan reads typing.byi and
records the text to move. rewrite then cuts it when it is handed typing, and
inserts it when it is handed _collections_abc, so neither call needs to know
which file the walk reached first
two consequences are worth knowing before writing another patch like it:
- it is registered first in
all_post_patches(). the moved text is the raw post-conversion form, and the patches that polish it —container-overlapping,property-to-let,final-modifier— must see it in its new home rather than in the copy about to be cut. a patch that names its target module therefore has to name_collections_abctoo scanreturning nothing is what makes it idempotent. on every run after the first,typinghas no class statements left to move, so both halves of the patch are no-ops
Callable is the one name it rewrites instead of moving. typeshed spells it
Callable: _SpecialForm, which defines nothing; the runtime has a real ABC with
an abstract __call__, so that is what the patch writes. ty still reads a
subscripted Callable as its own callable type rather than as an instance of
that class, the same way it does for the class Any typeshed already writes out
adding a new patch¶
- create
crates/by_typeshed_patch/src/patches/<name>.rsimplementingPatch, and declare it insrc/patches/mod.rs - register it in
all_patches()incrates/by_typeshed_patch/src/lib.rs - write unit tests exercising input → expected output on a minimal snippet, plus an idempotency case and a scoping case (what the patch must not touch)
after wiring it up, run the patch binary over the real stub to confirm it reproduces the committed form: