agronholm / agronholm/typeguard

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

Aperta
#578 0 commenti 0 reazioni 0 assegnatari Vedi su GitHub
bug
Lingua principale
Python
Stelle
1.8k
Fork
145
Merge medio
8g 12h
PR unite (30g)
1

Descrizione

### Things to check first

- [x] I have searched the existing issues and didn't find my bug already reported there

- [x] I have checked that my bug is still present in the latest release

### Typeguard version

4.6.0

### Python version

3.14.6

### What happened?

I was doing some work with Claude Code, and it came up with this bug report after some issues using typeguard. I think it's legit, I ended up not using typeguard for now.

## Summary

To fix #398, instrumentation injects ` = ` at the top of `__new__`.
That alias is correct while the class is being defined, but it is also live for every later
call — and when a **subclass** is constructed, the first argument is the subclass. So inside
`Base.__new__`, the name `Base` means `Sub`.

The alias shadows the real class for all code in the method body, not just for annotation
resolution, so ordinary user code that refers to the class by name silently gets the wrong
class.

This appears to be a regression introduced by commit `888a8c57` (the fix for
[#398](https://github.com/agronholm/typeguard/issues/398)).

## Why it matters

The failure is silent and the connection is invisible. A standard abstract-base guard:

```python
def __new__(cls, *args, **kwargs) -> "Base":
if cls is Base:
raise TypeError("Base is abstract")
return super().__new__(cls)
```

behaves correctly unhooked and rejects **every subclass** once the module is instrumented.
Deleting the `-> "Base"` annotation fixes it, so the trap is re-armed by anyone who later
adds the annotation back, with no obvious link to the breakage.

## Suggested direction

The alias is only needed to resolve annotations during class definition, but it is
currently visible to the whole method body for the life of the class. Options:

- resolve the self-referencing annotation without introducing a binding user code can see;
- or make the alias refer to the defining class rather than the first argument, once the
class exists — falling back to `cls` only while the class is still being defined.

## Relationship to existing issues

- [#398](https://github.com/agronholm/typeguard/issues/398) — the inverse symptom
(`NameError` because the class did not yet exist). Fixed by `888a8c57`, which introduced
this alias. Not a duplicate: that one is fixed and raises loudly, this one is silent and
gives the wrong class.
- [#402](https://github.com/agronholm/typeguard/issues/402) — `NameError` on Enum with a
class-referencing default argument. Different mechanism (class body, not `__new__`).

Relevant changelog history, in case it helps place this:

- **4.1.3** (2023-08-27) — "Fixed type checking of class instances created in `__new__()` in
cases such as enums where this method is already invoked before the class has finished
initializing (`#398`)" — the release carrying the alias.
- **4.0.0rc5** (2023-05-01) — "Fixed instrumentation using the wrong 'self' type in the
`__new__()` method" — an earlier, separate correction to self-typing in `__new__`.

Claude Code could find no changelog entry or issue covering the alias remaining live after class
definition, and none reverting or narrowing it, which matches it still reproducing on 4.6.0.

### How can we reproduce the bug?

`bugdemo/__init__.py` — empty.

`bugdemo/mod.py`:

```python
class Base:
def __new__(cls, *args, **kwargs) -> "Base":
print(f"cls={cls.__name__} Base={Base.__name__} cls is Base -> {cls is Base}")
return super().__new__(cls)

class Sub(Base):
pass
```

`run.py`:

```python
import sys
if "--hook" in sys.argv:
from typeguard import install_import_hook
install_import_hook("bugdemo")
from bugdemo.mod import Sub
Sub()
```

```console
$ python run.py
cls=Sub Base=Base cls is Base -> False # correct

$ python run.py --hook
cls=Sub Base=Sub cls is Base -> True # Base now means Sub
```

### Expected

`Base` inside `Base.__new__` refers to `Base`, hooked or not. Constructing `Sub()` should
print `Base=Base` and `cls is Base -> False`.

### Actual

Under the import hook, `Base` evaluates to `Sub`.

## Second symptom: spurious TypeCheckError

The same alias makes a forward-ref *argument* annotation check against the wrong class:

```python
class F:
def __new__(cls, other: "F" = None, *a, **k) -> None:
return super().__new__(cls)

class SubF(F): pass

SubF()
# TypeCheckError: argument "other" (None) is not an instance of bugdemo.variants.SubF
```

The annotation says `F`; the error says `SubF`. A legitimate `F` that is not a `SubF` would
be rejected here.

## Scope

Constructing a subclass in each case, under the hook:

| Case | Result |
|---|---|
| `__new__`, return annotation `-> "Own"` | **wrong** — own name means the subclass |
| same, plain class with no `ABC` | **wrong** — not ABC- or Enum-specific |
| `__new__`, forward-ref *argument* annotation | **wrong** — raises `TypeCheckError` |
| `__new__`, no annotations | correct — no alias injected |
| `__init__` | correct |
| Ordinary instance method, `-> "Own"` | correct |

So the trigger is `__new__` plus a forward reference naming the defining class. #398 was
reported through `IntEnum`, but the enum is incidental.

Guida per i contributori

Apri la guida per i contributori

Valutazione

Questa issue non è ancora stata valutata.

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.