Skip to content

Commit 1cb7601

Browse files
committed
fix(parametric-is): resolve a subclass's arguments down its base chain
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).
1 parent 808e22a commit 1cb7601

4 files changed

Lines changed: 294 additions & 72 deletions

File tree

crates/by_transforms/src/transforms/parametric_is.rs

Lines changed: 89 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -85,29 +85,97 @@ pub(crate) fn variance_tuple(variances: &[u8]) -> String {
8585
}
8686

8787
pub(crate) const PARAMETRIC_IS_RUNTIME: &str = "\
88+
def _by_type_param_defaults(args):
89+
# a class records its generic bases *unsubstituted* — `class L[T = Never]
90+
# (list[T])` stores `list[T]`, never `list[Never]` — so a type parameter
91+
# left at its pep 696 default resolves to that default rather than staying a
92+
# bare TypeVar that matches nothing
93+
resolved = []
94+
substituted = False
95+
for arg in args:
96+
has_default = getattr(arg, \"has_default\", None)
97+
if has_default is not None and has_default():
98+
resolved.append(arg.__default__)
99+
substituted = True
100+
else:
101+
resolved.append(arg)
102+
return tuple(resolved) if substituted else args
103+
104+
def _by_subst(annotation, mapping):
105+
# replace type parameters with the arguments bound to them, rebuilding
106+
# nested aliases (`list[dict[str, T]]` with `T = int` → `list[dict[str, int]]`)
107+
try:
108+
if annotation in mapping:
109+
return mapping[annotation]
110+
except TypeError:
111+
pass
112+
args = getattr(annotation, \"__args__\", ())
113+
if not args:
114+
return annotation
115+
replaced = tuple(_by_subst(arg, mapping) for arg in args)
116+
if replaced == args:
117+
return annotation
118+
origin = getattr(annotation, \"__origin__\", None)
119+
if origin is None:
120+
return annotation
121+
try:
122+
return origin[replaced]
123+
except TypeError:
124+
return annotation
125+
126+
def _by_specialize(alias, origin, depth=0):
127+
# the arguments with which `alias` satisfies `origin`, resolved *down the
128+
# declared base chain* rather than assumed to line up positionally. a base
129+
# that fixes or reorders its arguments is then followed faithfully:
130+
# `class Odd[T](list[int])` is a `list[int]` whatever `T` is, and
131+
# `class Swap[A, B](dict[B, A])` specializes `dict` in the other order
132+
if depth > 16:
133+
return None
134+
klass = getattr(alias, \"__origin__\", alias)
135+
if not isinstance(klass, type):
136+
return None
137+
args = getattr(alias, \"__args__\", ())
138+
params = getattr(klass, \"__type_params__\", ())
139+
if not args:
140+
defaulted = _by_type_param_defaults(params)
141+
if defaulted is not params:
142+
args = defaulted
143+
if klass is origin:
144+
return args or None
145+
mapping = {}
146+
for param, arg in zip(params, args):
147+
try:
148+
mapping[param] = arg
149+
except TypeError:
150+
pass
151+
bases = klass.__dict__.get(\"__orig_bases__\")
152+
if bases is None:
153+
# a class inheriting only plain classes records no `__orig_bases__`
154+
bases = getattr(klass, \"__bases__\", ())
155+
for base in bases:
156+
found = _by_specialize(_by_subst(base, mapping) if mapping else base, origin, depth + 1)
157+
if found is not None:
158+
return found
159+
# the declared bases don't reach `origin`: a builtin registered as a *virtual*
160+
# subclass of an abc (`list` for `Sequence`) has no base to walk. its
161+
# arguments do line up positionally once membership is established. this runs
162+
# only after resolution has failed, so it applies to the already-resolved base
163+
# (`list[int]`), never to a subclass that fixes or reorders arguments
164+
if args and isinstance(origin, type):
165+
try:
166+
if issubclass(klass, origin):
167+
return args
168+
except TypeError:
169+
pass
170+
return None
171+
88172
def _by_generic_args(value, origin):
89-
found = []
90-
def consider(alias):
91-
base_origin = getattr(alias, \"__origin__\", None)
92-
if base_origin is origin:
93-
found.append(getattr(alias, \"__args__\", ()))
94-
elif isinstance(base_origin, type) and isinstance(origin, type):
95-
# a specialization of a sub-origin (`list[int]` for a `Sequence`
96-
# target) carries the arguments through inheritance, matched by
97-
# position once membership is established
98-
try:
99-
is_sub = issubclass(base_origin, origin)
100-
except TypeError:
101-
is_sub = False
102-
if is_sub:
103-
found.append(getattr(alias, \"__args__\", ()))
173+
# an explicit `A[int]()` records its specialization on the instance;
174+
# otherwise the class itself is the starting point and any pep 696 defaults
175+
# stand in for the arguments it was constructed with
104176
reified = getattr(value, \"__orig_class__\", None)
105-
if reified is not None:
106-
consider(reified)
107-
for klass in type(value).__mro__:
108-
for base in klass.__dict__.get(\"__orig_bases__\", ()):
109-
consider(base)
110-
return found
177+
found = _by_specialize(reified if reified is not None else type(value), origin)
178+
return [found] if found is not None else []
111179
112180
def _parametric_is(value, alias, variances):
113181
alias = getattr(alias, \"__value__\", alias)

crates/by_transforms/tests/parametric_is_runtime.rs

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ use by_transforms::{Config, PythonVersion, transpile};
2424
/// annotation, a covariant read-only member, a nested-generic member, a missing
2525
/// member, and an inheriting subclass.
2626
const PROGRAM: &str = r#"
27-
from typing import Literal, Protocol
27+
from typing import Literal, Never, Protocol
2828
2929
class HasA[T](Protocol):
3030
a: T
@@ -159,6 +159,67 @@ assert (GetBool() is Get[bool]) is True, "return matches bool"
159159
assert (GetBool() is Get[int]) is True, "return covariant: bool <: int"
160160
assert (GetBool() is Get[str]) is False, "return bool is not str"
161161
162+
# a class records its generic bases *unsubstituted* (`class L[T = Never]
163+
# (list[T])` stores `list[T]`), so a type parameter left at its pep 696 default
164+
# must resolve to that default — otherwise the probe compares a bare TypeVar and
165+
# never matches
166+
class DefaultNever[T = Never](list[T]): ...
167+
168+
class DefaultInt[T = int](list[T]): ...
169+
170+
assert (DefaultNever() is list[Never]) is True, "a defaulted parameter resolves to its default"
171+
assert (DefaultNever() is list[int]) is False, "the default is not some other argument"
172+
assert (DefaultInt() is list[int]) is True, "a non-Never default resolves too"
173+
174+
# an *explicit* specialization wins over the default: reading the default over
175+
# the top of it would report an argument the value never had
176+
assert (DefaultInt[str]() is list[str]) is True, "the explicit argument matches"
177+
assert (DefaultInt[str]() is list[int]) is False, "the default must not leak in"
178+
179+
# a parameter with no default stays unknown, so it matches nothing
180+
class NoDefault[T](list[T]): ...
181+
182+
assert (NoDefault() is list[int]) is False, "an unknown argument matches nothing"
183+
184+
# a parameter that appears *only* in the class's own identity is recorded in no
185+
# base at all, so it is read straight off `__type_params__`. `Never` has no
186+
# runtime spelling, so the constructor is left bare — this is exactly the case
187+
# type reification cannot cover
188+
class OwnNever[T = Never]:
189+
a: T
190+
191+
assert (OwnNever() is OwnNever[Never]) is True, "an own-identity default resolves"
192+
assert (OwnNever() is OwnNever[int]) is False, "and is not some other argument"
193+
194+
# a subclass's arguments are resolved *down the declared base chain*, never
195+
# assumed to line up positionally with the base's. `Odd` is a `list[int]`
196+
# whatever `T` is, so `T` must never be reported as list's argument
197+
class Odd[T = str](list[int]): ...
198+
199+
assert (Odd() is list[int]) is True, "the base's written argument stands"
200+
assert (Odd() is list[str]) is False, "the subclass's own argument is not the base's"
201+
assert (Odd[str]() is list[int]) is True, "explicit argument, base still fixed"
202+
assert (Odd[str]() is list[str]) is False, "an explicit argument is not the base's either"
203+
204+
# a base may also *reorder* its arguments
205+
class Swap[A, B](dict[B, A]): ...
206+
207+
assert (Swap[int, str]() is dict[str, int]) is True, "the base's order is followed"
208+
assert (Swap[int, str]() is dict[int, str]) is False, "not the subclass's order"
209+
210+
# a base may nest the parameter
211+
class Wrap[T](list[dict[str, T]]): ...
212+
213+
assert (Wrap[int]() is list[dict[str, int]]) is True, "a nested parameter substitutes"
214+
assert (Wrap[int]() is list[dict[str, bool]]) is False, "and stays exact"
215+
216+
# plain (non-generic) inheritance is still followed to the generic base
217+
class Plain(list[int]): ...
218+
219+
class Deeper(Plain): ...
220+
221+
assert (Deeper() is list[int]) is True, "a plain subclass inherits the specialization"
222+
162223
# a *literal* type argument (`A[True]`) specializes the member to
163224
# `Literal[True]`, rebuilt at runtime by `_by_lit`. an invariant data member
164225
# stays exact — a `bool` annotation is not a `Literal[True]`

0 commit comments

Comments
 (0)