Skip to content

fix(python): a base's type argument is no supertype, so Repo.save() is not User.save - #3083

Merged
colbymchenry merged 1 commit into
mainfrom
claude/pr-3000-python-type-arg-supertypes
Oct 30, 2026
Merged

colbymchenry merged 1 commit into
mainfrom
claude/pr-3000-python-type-arg-supertypes

Conversation

@colbymchenry

Copy link
Copy Markdown
Owner

Summary

In Python, a type argument in a class's base list no longer counts as a supertype. With class Repo(Base[User]), Repo.save() and repo.save() no longer link to User.save, and in class Mixed(Base[Optional[User]], Mixin), Mixed.save() now reaches Mixin.save.

Lands #3000 by @JosefAschauer.

Root cause

The Python branch of declaredSupertypes read every word of the base list, so class Repo(Base[User]) declared both Base and User. A call on the class, or on a receiver named after it, walks those supertypes for the method (classStrategyMethod → inheritedClassMethod). When Base has no save, it found User.save, at 0.85 for Repo.save() and 0.8 for repo.save().

What came from the contributor, and what we changed

From #3000, unchanged: the fix in name-matcher.ts, which drops bracketed type arguments, nested ones too, before reading the names; the new test file; the CHANGELOG text.

Ours:

  • Two more cases in the test file: class Mixed(Base[Optional[User]], Mixin), where a type argument came ahead of a real base, and a quoted forward reference, class Forward(Base["Pair[str, User]"]), the shape sqlalchemy uses.
  • The CHANGELOG bullet moved next to the other Python bullets, with the credit added.

Validation

Stage A: every changed line read, scanner clean. The only hits are RegExp.exec and the test's ../src import.

Reproduced on main with a fixture of our own: Repo.save(), repo.save(), Store.save() and Keyed.save() reach User::save, and Mixed.save() reaches User::save instead of Mixin::save.

Tests: red on main's name-matcher.ts (2 failed, 1 passed), green here (3 passed).

A/B against main, same build otherwise, kernel on:

Repo Language Edges on main Removed Added Retargeted
psf/requests @ 3c7baaa Python 2,976 0 0 0
pallets/flask @ 49943b5 Python 5,287 0 0 0
litestar-org/litestar @ 716f8b0 Python 42,289 0 0 0
python/mypy @ d8a43fb Python 70,754 0 0 0
polarsource/polar server/ @ 8dd9956 Python 126,354 0 0 0
django/django @ a6a5d1e Python 194,159 0 0 0
sqlalchemy/sqlalchemy @ 2d8e649 Python 228,407 1 0 0
expressjs/express @ 1c6f1b0 JavaScript (control) 2,296 0 0 0
gin-gonic/gin @ 0f09c3a Go (control) 8,073 0 0 0

Controls and the two small Python repos have byte-identical full dumps.

The change is small on real code: 1 edge of 670,226. That edge is a.references(b) in sqlalchemy's relationships.py. Its local a matched a test's class a(TypedColumns), and class TypedColumns(ReadOnlyColumnCollection[str, "Column[Any]"]) then reached Column.references through the type argument. The target happens to be right, because the code narrows a with isinstance, but the path is the bug this fixes. Its twin b.references(a) two lines down has no edge in either arm. The call is now unresolved, which is better than a guess that happened to land.

Kernel parity: 43/43 on flask and requests in both arms. This doesn't touch the kernel.

Cost: sqlalchemy, median of 3 interleaved runs: 29.5 s on main, 25.7 s here. That gap is noise, so nothing measurable.

Suite: 828 files (822 passed, 6 skipped), 9,721 tests passed, 0 failed, 87 skipped, on this exact commit, kernel on, 2 workers.

Not in this PR

🤖 Generated with Claude Code

…s not User.save

The Python branch of declaredSupertypes read every word of a class's base
list, so class Repo(Base[User]) declared both Base and User. A call on the
class or on a receiver named after it, Repo.save() or repo.save(), walks those
supertypes for the method (classStrategyMethod to inheritedClassMethod), and
when Base declares no save it reached User.save at 0.85 or 0.8. In
class Mixed(Base[Optional[User]], Mixin) the type argument also came ahead of
Mixin, so Mixed.save() reached User.save instead of Mixin.save.

Bracketed type arguments, nested ones and quoted forward references too, are
now dropped before the names are read. The base itself is still read and
searched. Other languages are untouched.

Validation:
- The new test file: red on main (the type-argument and later-base cases
  fail, the case that a base's own method is still reached passes), green here.
  We added the Mixed and quoted forward-reference cases to the contributor's test.
- A/B against main on 7 Python repos, 670,226 edges on main: requests, flask,
  litestar, mypy, polar's server, django unchanged, sqlalchemy 1 edge removed,
  0 added, 0 retargeted. That edge is a.references(b) in relationships.py. Its
  local a named a test's class a(TypedColumns), and TypedColumns' type argument
  "Column[Any]" reached Column.references: the right method by chance, by the
  path this fixes. Its twin b.references(a) two lines down has no edge in
  either arm.
- Controls express and gin: full dumps byte-identical.
- Kernel parity on flask and requests: 43/43 in both arms (the kernel is not
  touched). Index cost on sqlalchemy, median of 3 interleaved: 29.5 s on main,
  25.7 s here, so nothing measurable.

Original PR #3000 by @JosefAschauer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@colbymchenry
colbymchenry merged commit cd04b21 into main Oct 30, 2026
@colbymchenry
colbymchenry deleted the claude/pr-3000-python-type-arg-supertypes branch October 30, 2026 17:05
colbymchenry pushed a commit that referenced this pull request Oct 30, 2026
Both of the resolver's readers of a Python base list stopped early. pythonBases,
behind module-global and self. dispatch, read only the class line.
declaredSupertypes, behind class-name calls, read three lines and needed the
closing parenthesis on them. black and ruff wrap a long list one base per line,
so class Wrapped(Mixin, Base) written that way seemed to have no bases:
current = Wrapped() then current.run(), and Wrapped.run(x), reached nothing a
base declares. Extraction already read the whole list.

A new pythonBaseList reads from the class line to the list's closing
parenthesis, counting nested parentheses, within the class's extent, and leaves
out comments, so a ) in one doesn't end the list. Both readers use it.
declaredSupertypes still drops type arguments first, the composition the author
posted on PR #3001, and keeps its metaclass= and factory rewrites.

Validation:
- The test file: 7 of 9 fail without this change (the two one-line controls
  pass), 9 pass here. Putting the type-argument drop out of the merged
  case 'python' fails the new composition case and the two of PR #3083.
- Wrapped.run(None) on a wrapped base list reaches Base::run with the kernel
  and with wasm, as do a list over five lines, a decorated class, and a wrapped
  Base[Optional[User]].
- A/B on top of PR #2998, 7 Python repos, 459,440 edges: 118 added (102 calls,
  16 references), 0 removed, 959 call sites moved, 113 kept their target with
  higher confidence. requests, flask, litestar, mypy and django do not change.
  polar: 942 XRepository.from_session(session) calls move from a sibling
  repository's from_session at 0.65 to RepositoryBase.from_session at 0.85.
  sqlalchemy: wrapped heads like class Table( and class ColumnElement( now
  reach what they inherit: t.select() reaches FromClause.select. Its 17 moved
  sites read right; 15 of them land on the typing-only SQLCoreOperations
  overload of the same ColumnOperators method.
- Controls express and gin: full dumps byte-identical.
- No kernel change. Index cost with PR #2998, sqlalchemy, median of 3
  interleaved: 23.7 s on main, 25.2 s here.

Original PR #3001 by @JosefAschauer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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