Skip to content

Parametric type check - #47

Merged
KotlinIsland merged 9 commits into
mainfrom
parametric-type-check
Jul 23, 2026
Merged

Parametric type check#47
KotlinIsland merged 9 commits into
mainfrom
parametric-type-check

Conversation

@KotlinIsland

Copy link
Copy Markdown
Owner

No description provided.

a parametric `is`-test against a protocol target (`x is A[int]`) was always an
error: a protocol's instances never record which specialization they satisfy.
but basedpython reifies class attribute annotations, so a protocol whose members
are all data members can be checked structurally — each member's reified
annotation against its specialized type, read at runtime with get_type_hints and
an mro fallback. only a protocol with a method member (unrecoverable from an
annotation) stays an error.
extend the parametric protocol check from `is` to the checked cast: a
data-member protocol target (`x cast A[int]`) now validates the value's
reified class annotations member by member via `_by_protocol_is`, rather
than emitting a crashing `isinstance` against a subscripted protocol. a
method-bearing protocol has no runtime residue, so it degrades to an
unchecked `typing.cast` and reports `erased-cast-argument`.
extend the structural protocol check (for both `is` and `cast`) to method
members: a method's parameters are validated contravariantly and its return
covariantly against the value method's reified annotations, reading a
parameter's inferred type from `type(default)` when it has no annotation.
so `C() is A[int]` for `protocol A[T]: def f(self, other: T)` inspects
`C.f`'s reified parameter type. only a member whose specialized type has no
runtime spelling (a callable attribute) now leaves a protocol uncheckable.
`cast` had its own target-only plan (`runtime_check_plan`), which meant it
could not see the value's type at all: a union target collapsed to one
`isinstance` against a tuple, and a value typed by a reified type parameter
warned `erased-cast-argument` even though `T == int` decides it exactly.

both forms now build their runtime test with one `build_predicate`, over one
`ParametricIsPlan`, differing only in `TargetPosition` (a cast target is a
type expression, an `is`-rhs a value expression) and `ProbeStrictness` (an
`is` must earn its `True` because it narrows; a cast only holds the value to
arguments the runtime can see, keeping `[1, 2] cast list[int]` legal).

- union arms are decomposed per kind, so a protocol or parameterized arm no
  longer produces a crashing `isinstance`
- a reified type parameter compares its cell (`data cast? list[int]` on
  `data: list[T]` lowers to `T == int`); a parametric cast now reifies `T`
- the four bespoke deep cast helpers collapse into `_checked_cast_pred` /
  `_try_cast_pred`; targets with no parametric claim keep the shallow form
`A[True]` specializes the member to `Literal[True]`, which had no runtime
spelling — so the whole protocol fell back to `erased-type-check` even
though the check is perfectly decidable.

a literal member type now renders as a call to the structural check's own
`_by_lit` helper, which rebuilds `typing.Literal[…]`. spelling it as a call
keeps the member list import-free, and confining it to a protocol-specific
speller keeps `_by_lit` out of the reified-call and constructor injections,
where it is not in scope.

`_by_proto_sub` learns literal subtyping, so variance stays exact: an
invariant data member still rejects a `bool` annotation for `Literal[True]`,
while a contravariant parameter accepts it (`bool` does accept `True`) and a
covariant return follows `Literal[True] <: bool <: int`.
the cast now shares the parametric `is` engine, so a value typed by a
reified type parameter verifies its argument exactly (`T == int`) and no
longer warns. document that alongside the `__orig_class__` and protocol
cases, and regenerate rules.md / ty.schema.json.
@KotlinIsland
KotlinIsland force-pushed the parametric-type-check branch from d4c7e51 to 5106246 Compare July 23, 2026 06:24
the probe assumed a subclass's type arguments line up positionally with its
base's, so `class Odd[T](list[int])` reported `T` as list's argument:
`Odd[str]() is list[str]` answered True even though `Odd` is a `list[int]`.
a reordering base (`class Swap[A, B](dict[B, A])`) was wrong the same way.

arguments are now resolved down the declared base chain, substituting the
type parameters into each base, so a base that fixes, reorders or nests its
arguments is followed faithfully. this subsumes both default patches: a bare
class starts from its own pep 696 defaults and the same walk carries them
through, so the special cases for own-identity and base-recorded parameters
collapse into one mechanism.

the positional rule survives only as a last resort, after resolution fails,
for a builtin registered as a *virtual* subclass of an abc (`list` for
`Sequence`) which has no base to walk. because it now runs on the resolved
base rather than the original subclass, `Odd[str]` reaches it as `list[int]`
and still answers `Sequence[int]` correctly.

fix(parametric-is): resolve own-identity type-param defaults

a type parameter that appears only in the class's own identity
(`class A[T = Never]`) is recorded in no base, so the mro walk had nothing
to resolve and the probe found no arguments at all. read it straight off
`__type_params__` instead.

this is precisely the case constructor reification cannot cover: it fills
defaults by injecting `A[int]()`, but only when the default has a runtime
spelling, so an unspellable one like `Never` leaves the constructor bare.
the two mechanisms are complementary — injection covers own-identity
parameters with spellable defaults, this covers the rest.

restricted to `klass is origin`: a subclass's parameters have no positional
relationship to a base's, so `class Odd[T = str](list[int])` must not report
`str` as list's argument.

fix(parametric-is): resolve pep 696 defaults in the runtime probe

a class records its generic bases *unsubstituted* — `class L[T = Never]
(list[T])` stores `list[T]`, never `list[Never]` — so the probe was
comparing a bare TypeVar against the target and never matching. a
parameter left at its default now resolves to that default, so
`L() is list[Never]` answers True.

the substitution is applied only when the value records no explicit
`__orig_class__`: an `L[int]()` fixes `T` itself, and reading the class
default over the top of that would report an argument the value never had
(`L[str]() is list[int]` must stay False).
@KotlinIsland
KotlinIsland force-pushed the parametric-type-check branch from 5106246 to 1cb7601 Compare July 23, 2026 06:24
the parametric-is runtime test probed with `type X[T] = T` — pep 695, which
3.12 accepts — but the pep 696 default assertions I added need native
`[T = int]` syntax and `TypeVar.has_default()`, both 3.13. on a box whose
`python3` is 3.12 (the linux CI runner) the probe passed, the test ran, and
the program died on a SyntaxError.

split the program by requirement rather than raising the bar for all of it:
the protocol, method, literal, cast and base-chain coverage needs only pep
695 and keeps running on 3.12, while the defaults half is probed for pep 696
and skips when unavailable. `Odd`'s default was incidental to what it tests,
so it drops one and stays in the 3.12 program.

the probe's own SyntaxError is now swallowed — a rejected candidate should
not print a scary traceback inside a passing run.
@KotlinIsland
KotlinIsland merged commit 0418cf0 into main Jul 23, 2026
61 of 62 checks passed
@KotlinIsland
KotlinIsland deleted the parametric-type-check branch July 23, 2026 09:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant