Skip to content

@deprecated meets metaclasses, sibling bases, and the class header - #626

Merged
jgfoster merged 2 commits into
mainfrom
fix/deprecated-tests-tail
Aug 25, 2026
Merged

@deprecated meets metaclasses, sibling bases, and the class header#626
jgfoster merged 2 commits into
mainfrom
fix/deprecated-tests-tail

Conversation

@hernanwilkinson

Copy link
Copy Markdown
Collaborator

Stacked on #625. test.test_warnings 25 → 23 bad, taking DeprecatedTests one by one.

The metaclass eats its keywords first — test_do_not_shadow_user_arguments

CPython evaluates class Foo(metaclass=MyMeta, cls='haha') as MyMeta('Foo', bases, ns, cls='haha'): the header keywords are the metaclass's to bind or forward, and __init_subclass__ receives only what its __new__ passes on to type.__new__. Grail had it upside down twice over — the keywords never reached the metaclass (its __new__ got defaults), and the hook chain got all of them (object's terminal raised "takes no keyword arguments"). This broke the undecorated shape too, not just @deprecated's.

___grailDispatchMetaclass___ now passes the stashed header keywords to the metaclass __new__/__init__, and the hook runner subtracts what the __new__'s signature consumes:

  • named and keyword-only parameters, by name;
  • a **kwargs catch-all consumes everything remainingmeasured, not guessed: CPython leaves the hook chain empty-handed there (a hook requiring a keyword raises), and my first reading — "unconsumed keywords flow through" — was plausible and wrong. The fixture records both the rule and the correction.

The signature is read through __wrapped__ first, so a decorated __new__@deprecated's own wrapper, (cls, *args, **kwargs) — reads the wrapped function's parameters, exactly as inspect.signature would.

A sibling base's hook gets the keywords — test_existing_init_subclass_in_sibling_base

Two findings here, one per base order:

class C(A, B, x=42) (A @deprecated, B's hook takes x): the vendored _py_warnings captured A's pre-wrap hook — object's builtin — and forwarded the keywords into it, which raises. Upstream CPython fixed this after 3.14.0: the builtin-case wrapper now delegates cooperatively, super(arg, cls).__init_subclass__(**kw). The vendored copy carries that fix — and the vendored test corpus is newer than 3.14.0 and pins it. I verified the scenario fails on the reference interpreter itself (3.14.0's own suite doesn't even contain this test), so the fixture carries its sibling check as the inverse of the usual XFAIL: the corpus is ahead of CPython 3.14.0 there, and the fixture header says so explicitly.

class D(B, A, x=42): B's hook runs first, and its zero-argument super().__init_subclass__() must find A's assigned wrapper on D's MRO — a Python object in the attribute store, which _lookupMethodAndSideFirstOf:'s walk over compiled method dictionaries slid straight past to object's terminal no-op, dropping the deprecation warning. The walk now probes the assigned store — scoped to the __init_subclass__ family, wary of the 180-test lesson its own comments record — and answers a third pair shape, { hook. #assigned }, which both consumers hand to the same runner class creation uses.

type.__new__ validates its namespace argument

Routing the header keywords exposed a latent mis-forward: a class-body __new__ with a required extra parameter compiles fixed-arity on the class's instance side, and its varargs forwarder's virtual self-send — run against a class receiver — resolves up the metaclass chain to the builtin with the arguments shifted one left (ns receives the extra parameter's value). That arrived as NoneType does not understand #isEmpty, uncatchable, and turned test_super's test___classcell___overwrite from a pass into a crash — caught by the regression gate on the first full run.

type.__new__ now raises the TypeError CPython's own argument checking raises (argument 3 must be dict, not …), which is what that test asserts — all four of its subtests pass. The mis-forward itself is the same instance-side-method-against-class-receiver disease #625 covers, in a costume that finds a wrong method rather than failing to find one; fixing the forwarder's dispatch is its own change, and the guard's comment names it.

Attempted and reverted, honestly

Mapping the ExecBlock family and UnboundMethod to CPython's 'function' in ___pythonBuiltinTypeName___ (for test_only_strings_allowed's error message) fixed that test and broke five name-pinning SUnit tests, the overlay suite, and test_super — the mapping's own comment had warned exactly this. Reverted; it needs its own PR that updates the pinned tests with it.

Deferred with named causes (not attempted)

  • test_property — stacked decorators under @property: the parse-time property fast-path fires on decoratorList includes: #property regardless of what sits beneath, orphaning the inner decorator's wrapper while reads hit the native accessor;
  • test_class — needs C.__new__ is object.__new__ (method identity for inherited dunder reads; Grail's class-side BoundMethods aren't interned);
  • test_inspectinspect.iscoroutinefunction is False even for a plain async def; the foundation is missing, not the wrapping.

Verification

test.test_warnings 25 → 23 bad (t=187 f=18 e=5 s=29)
test.test_super regressed to 1 mid-branch, back to OK/40
curated suite 0 regressions
SUnit (4.0) 5512 + 127 + 22 run, all passed
SUnit (3.7.5) 2754 + 2758 across CI's shard groups, all passed
fixture gate 2435 OK, 34 XFAIL

New fixture tests/python/deprecated_cooperative.py — 5 checks + 1 inverse-XFAIL, metaclass checks validated against CPython 3.14.0, sibling checks against the upstream fix's semantics and Grail. Wired as DeprecatedCooperativeTestCase (3/3).

Two honest notes. An install.gs wiring slip put the new test's input line before PythonTestCase loads, which took one full verification run to STERROR-everything before the ordering was fixed — the "0 regressions" above is from the clean re-run. And single one-off failures appeared in FrameEqualityTestCase and TracebackTestCase across runs — both live-frame/sys._getframe tests, both 3/3 in isolation and clean on re-runs, same family as the one-offs flagged in #594 and #623.

🤖 Generated with Claude Code

Base automatically changed from fix/unbound-fixed-arity-class-receiver to main August 21, 2026 22:42
@jgfoster
jgfoster enabled auto-merge August 21, 2026 22:52
@hernanwilkinson
hernanwilkinson force-pushed the fix/deprecated-tests-tail branch from 4adcda6 to 861fa97 Compare August 24, 2026 23:28
hernanwilkinson and others added 2 commits August 24, 2026 20:45
test.test_warnings 25 -> 23 bad, taking DeprecatedTests one by one.

THE METACLASS EATS ITS KEYWORDS FIRST.  CPython evaluates ``class
Foo(metaclass=MyMeta, cls='haha')'' as MyMeta('Foo', bases, ns, cls='haha'):
the header keywords are the metaclass's to bind or forward, and
__init_subclass__ receives only what its __new__ passes on to type.__new__.
Grail had it upside down twice over -- the keywords never reached the
metaclass (its __new__ got defaults), and the hook chain got all of them
(object's terminal raised "takes no keyword arguments").
___grailDispatchMetaclass___ now passes the stashed header keywords to the
metaclass __new__/__init__, and the hook runner subtracts what the __new__'s
signature consumes: named and keyword-only parameters by name, and a
**kwargs catch-all consumes EVERYTHING REMAINING -- measured against
CPython, where a forwarding-shaped metaclass that delegates with
``super().__new__(mcs, name, bases, attrs)'' leaves the hook chain
empty-handed and a hook requiring a keyword raises; the first reading
("unconsumed keywords flow through") was plausible and wrong.  The signature
is read through __wrapped__ first, so a DECORATED __new__ -- @deprecated's
own wrapper -- reads the wrapped function's parameters, exactly as
inspect.signature would.  Fixes test_do_not_shadow_user_arguments.

A SIBLING BASE'S HOOK GETS THE KEYWORDS.  The vendored _py_warnings now
carries the post-3.14.0 upstream fix: @deprecated's builtin-case wrapper
delegates cooperatively -- super(arg, cls).__init_subclass__(**kw) -- instead
of forwarding into the captured builtin, which raises the moment a sibling
base's hook was meant to receive the keywords.  The vendored TEST corpus is
newer than 3.14.0 and pins exactly this
(test_existing_init_subclass_in_sibling_base); the reference interpreter
itself fails the scenario, so the fixture carries its one sibling check as
the inverse of the usual XFAIL -- the corpus is AHEAD of CPython 3.14.0
there, and the fixture header says so.

The other base order needs Grail's super() to find an ASSIGNED hook: a
Python object in the attribute store, which the MRO walk over compiled
method dictionaries slid straight past to object's terminal no-op.
_lookupMethodAndSideFirstOf: now probes the assigned store -- scoped to the
__init_subclass__ family, wary of the 180-test lesson its own comments
record -- and answers a third pair shape, { hook. #assigned }, which both
consumers hand to the same runner class creation uses.

TYPE.__NEW__ VALIDATES ITS NAMESPACE ARGUMENT, as CPython's does.  Not
decorative: routing the header keywords exposed a latent mis-forward -- a
class-body __new__ with a REQUIRED extra parameter compiles fixed-arity on
the class's INSTANCE side, and its varargs forwarder's virtual self-send,
run against a class receiver, resolves up the metaclass chain to the builtin
with the arguments shifted one left.  That arrived as "NoneType does not
understand #isEmpty", uncatchable, and turned test_super's
test___classcell___overwrite from a pass into a crash; it is now the
TypeError CPython's own argument checking raises, which that test expects.
The mis-forward itself is the same instance-side-method-against-class-
receiver disease the __init_subclass__ DNU repair covers, in a costume that
FINDS a wrong method rather than failing to find one; fixing the forwarder's
dispatch is its own change.

Attempted and REVERTED: mapping the ExecBlock family and UnboundMethod to
CPython's 'function' in ___pythonBuiltinTypeName___ (for
test_only_strings_allowed's error message).  Five SUnit tests pin the
current names, the overlay suite and test_super coupled to them, and the
mapping's own comment had said so all along.  That change needs its own PR
updating the pinned tests with it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… era

CI moved to CPython 3.14.7, which SHIPS the cooperative-delegation fix the
vendored _py_warnings carries -- so the sibling check passed there and the
hardcoded XFAIL scored XPASS, failing the fixture gate, while 3.14.0
locally still needs the XFAIL.  The fixture now detects the fix from the
running interpreter's own source ('super(arg, cls)' in deprecated's body)
and places the check accordingly.  Detected from the SOURCE, not from the
check's outcome: classifying by result would relabel any real regression as
expected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@hernanwilkinson
hernanwilkinson force-pushed the fix/deprecated-tests-tail branch from 861fa97 to 23c9eca Compare August 24, 2026 23:59
@jgfoster
jgfoster added this pull request to the merge queue Aug 25, 2026
@hernanwilkinson

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main and green: all five CI jobs pass, and the PR is mergeable again.

Two things needed fixing beyond the mechanical rebase:

The scripts job failure was version skew, not a broken fixture. CI moved to CPython 3.14.7, which ships the upstream _py_warnings cooperative-delegation fix this PR vendors — so the sibling check passed on the reference interpreter and the hardcoded XFAIL scored XPASS, failing the gate, while CPython 3.14.0 locally still needs the XFAIL. deprecated_cooperative.py now detects the interpreter's state from _py_warnings' own source ('super(arg, cls)' in the decorator's body) and places the check accordingly — detected from the source, not from the check's outcome, since classifying by result would relabel any real regression as expected.

The scoreboard row needed measuring, not merging. main (with #655's test_only_strings_allowed fix) says 24 bad; this PR's pre-rebase row said 23. The union is 22 bad (t=187 f=17 e=5 s=29) — measured, and the row now says that. test_warnings overall: this PR takes main's 24 → 22.

Re-verified on the new base: curated suite 0 regressions, SUnit 5677/5677 (4.0) and 2827 + 2850 across CI's shard groups (3.7.5, clean on first cold runs), fixture gate 2632 OK / 34 XFAIL.

(One procedural note: the first force-push after the conflict resolution never received a pull_request synchronize run — a manually dispatched workflow on the same SHA came back all green, and the subsequent push triggered normally.)

Merged via the queue into main with commit 3f543d0 Aug 25, 2026
5 checks passed
@jgfoster
jgfoster deleted the fix/deprecated-tests-tail branch August 25, 2026 00:17
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.

2 participants