Skip to content

Resolve the __new__ class alias to the defining class when it exists - #590

Closed
dchaudhari7177 wants to merge 1 commit into
agronholm:masterfrom
dchaudhari7177:fix/578-new-class-name-alias
Closed

dchaudhari7177 wants to merge 1 commit into
agronholm:masterfrom
dchaudhari7177:fix/578-new-class-name-alias

Conversation

@dchaudhari7177

Copy link
Copy Markdown
Contributor

Changes

Fixes #578.

Instrumenting __new__() injects <ClassName> = <first arg> at the top of the body so that a forward reference naming the defining class resolves while the class is still being created — the window #398 is about, since __new__() first runs for an Enum before the class name exists.

The first argument is the subclass on a subclass construction, so that alias made the class's own name mean the subclass for the whole method body:

$ python run.py            # cls=Sub  Base=Base  cls is Base -> False
$ python run.py --hook     # cls=Sub  Base=Sub   cls is Base -> True

An abstract-base guard (if cls is Base: raise TypeError(...)) therefore rejected every subclass once the module was instrumented, and a forward-reference argument annotation was checked against the subclass, so a legitimate instance of the base class was rejected with a message naming a class the annotation never mentioned.

Both symptoms are silent and both disappear if you delete the annotation, so the trap is re-armed by whoever adds it back.

The alias now reads:

Base = globals().get('Base', cls)

so it is the real class whenever the class exists, and falls back to the first argument only in the not-yet-defined window. The binding has to stay in the function's scope — the transformer compiles the forward reference to a bare Name, not to a string the memo evaluates — so what changes is its value, not its existence. My first attempt moved the name into the memo's locals instead and reintroduced #398's NameError, which is what confirmed that.

Unchanged on purpose: a class that is not a module global (nested in a class or a function) is not in globals(), so it still falls back to the argument. That is the current behaviour, and narrowing it further would need a different mechanism.

Checklist

  • You've added tests (in tests/) added which would fail without your patch
  • You've updated the documentation (in docs/, in case of behavior changes or new features) — no documented behaviour changes; this restores what the docs already imply
  • You've added a new changelog entry (in docs/versionhistory.rst).

Verification

Two behaviour tests in tests/test_importhook.py over a new SelfNamingNew / SubclassOfSelfNamingNew pair in tests/dummymodule.py: one asserts the class's own name is the base class while the first argument is the subclass, the other passes a genuine base-class instance to the subclass's __new__. Both fail on master with the patch reverted:

FAILED tests/test_importhook.py::test_new_does_not_alias_own_class_to_the_subclass
FAILED tests/test_importhook.py::test_new_forward_ref_argument_checks_against_the_defining_class

test_new_with_self and test_new_with_explicit_class_name in tests/test_transformer.py are updated for the new generated line; #398's own path (an IntEnum with a __new__ annotated -> "E") was checked by hand against both the old and the new code and still works.

Full suite: 533 passed, 9 skipped, 9 xfailed. The 4 failures in tests/test_pytest_plugin.py are pre-existing on an unmodified checkout — they come from pytest-asyncio's unset asyncio_default_fixture_loop_scope warning being raised as an error, and are unrelated to this change.

ruff 0.15.20 (the version .pre-commit-config.yaml pins) check and format clean; mypy clean on the changed module.

Instrumenting __new__() injects '<ClassName> = <first arg>' so a forward
reference naming the defining class resolves while the class is still
being created -- which is when __new__() first runs for an Enum (agronholm#398).

The first argument is the subclass on a subclass construction, so that
alias made the class's own name mean the subclass for the whole method
body. An abstract-base guard like 'if cls is Base: raise' then rejected
every subclass, and a forward-reference argument annotation was checked
against the subclass, rejecting a legitimate instance of the base class.
Both are silent and both go away if the annotation is deleted, so the
trap is re-armed by anyone who adds it back.

The alias now reads globals().get('<ClassName>', <first arg>), so it is
the real class whenever the class exists and falls back to the argument
only in the window agronholm#398 is about. The annotation is compiled as a bare
name reference, so the binding has to stay in the function's scope; what
changes is its value.

Closes agronholm#578
@coveralls

Copy link
Copy Markdown

Coverage Status

coverage: 94.85% (-0.003%) from 94.853% — dchaudhari7177:fix/578-new-class-name-alias into agronholm:master

@dchaudhari7177

Copy link
Copy Markdown
Contributor Author

Closing as a duplicate — I missed #581 and #583, both of which predate this by over a week and cover #578. Sorry for the noise.

#583 is also the better approach. It stops the transformer compiling the self-naming forward reference to a bare `Name` at all, keeping it a `ForwardRef` for the memo to resolve, so the alias goes away instead of being corrected. This PR only changed what the alias was bound to, which leaves the binding shadowing an enclosing-scope class of the same name.

One data point from here that may be worth having on #583: the annotation being compiled to a bare name is exactly why the alias could not simply be moved into the memo's locals — I tried that first and it reintroduced #398's `NameError` for an `IntEnum` whose `new` is annotated `-> "E"`. Since #583 routes through `ForwardRef` resolution instead, that case is worth an explicit test if it does not have one.

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.

Instrumented __new__ binds the class's own name to cls, so it means the subclass

2 participants