Skip to content

fix(python): a base imported from outside the project is not a project class of that name - #3109

Merged
colbymchenry merged 1 commit into
mainfrom
claude/python-base-outside-import
Oct 30, 2026
Merged

colbymchenry merged 1 commit into
mainfrom
claude/python-base-outside-import

Conversation

@colbymchenry

Copy link
Copy Markdown
Owner

Summary

In Python, a base class a file imports from outside the project no longer links to a project class that only shares its name. class Color(Enum) after from enum import Enum is not your own Enum, and class Item(BaseModel) after from pydantic import BaseModel is not a BaseModel in a docs example. Plain and parameterized bases are treated alike.

Follow-up to #3101, which reported this as an existing bug and added 3 more cases of it.

Root cause

The import names no project file, so the import strategy finds nothing. The name match, or a framework resolver, then takes the first project class of that name. For JS, TS and Rust, isBoundToOutOfRepoImport drops such an edge; for Python it always answered no. Its comment said Python imports don't go through resolveImportPath, but they do now.

Fix

isBoundToOutOfRepoImport now answers for Python: a base is outside when every import binding its name is absolute and resolveImportPath, which searches for a module the way Python does, finds none.

A false yes deletes a real edge, so three cases keep theirs, each with a test:

  • A class in a file the import path names, like lib/support/helpers/base.py for from helpers.base import Base. Python's search can miss a module on a path the program sets up itself.
  • A class of the referring file, which may rebind the name (class Enum(Enum)).
  • A name with a project import beside the outside one, as in try: from pydantic … except ImportError: from .schema ….

Relative imports are never outside, and a name no named import binds, like one from a star import, is left as it was.

The one miss left: a module on such a program-set path that re-exports a class from somewhere else. That edge drops, and the ref stays failed.

Validation

Tests: new __tests__/python-outside-bases.test.ts. The case for outside bases fails on main (plain and parameterized, Enum, Sequence, Generic, Protocol, pydantic's BaseModel). The 6 cases that must keep their edge pass both ways. Green here, 7 passed. Taking out each of the 4 guards fails exactly its own test.

A/B against main, kernel on:

Repo Edges Removed Added Moved
psf/requests @ 3c7baaa 2,976 0 0 0
pallets/flask @ 49943b5 5,292 0 0 0
python/mypy @ d8a43fb 70,811 0 0 0
litestar-org/litestar @ 716f8b0 42,397 21 0 0
sqlalchemy/sqlalchemy @ 2d8e649 229,549 31 0 0
polarsource/polar server/polar @ 8dd9956 62,305 14 0 0
django/django django/ @ a6a5d1e 46,228 1 0 0
expressjs/express @ 1c6f1b0 (control) 2,296 0 0 0
gin-gonic/gin @ 0f09c3a (control) 8,073 0 0 0

All 67 are extends, and all were read against their imports. None was right:

Removed Base, as imported Wrong target
28 from enum import Enum (sqlalchemy) sql/sqltypes.py Enum
3 from typing import Sequence, Tuple, MutableSet (sqlalchemy, the 3 from #3101) Its own classes of those names
21 from pydantic import BaseModel (litestar) A docs example's BaseModel
11 from fastapi.routing import APIRoute (polar) polar/routing.py APIRoute
2 from enum import IntEnum (polar) kit/extensions/sqlalchemy/types.py IntEnum
1 from opentelemetry.metrics import Meter (polar) meter/schemas.py Meter
1 from ctypes import Union (django) contrib/gis/db/models/aggregates.py Union

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

Suite: 837 files (831 passed, 6 skipped), 9,850 tests passed, 0 failed, 87 skipped, on 2c31682c, kernel on, 2 workers. This head is that commit rebased onto main: main's two new commits share only CHANGELOG.md with it, and git diff of the test and both source files against 2c31682c is empty.

Not in this PR

🤖 Generated with Claude Code

…t class of that name

A Python class whose base the file imports from a package, like
class Color(Enum) after from enum import Enum, linked to any project class
named Enum: the import found no project file, and the name match or a framework
resolver then took the first class of that name. sqlalchemy's enums all
extended its own sql/sqltypes.py Enum, and litestar's pydantic models extended
a BaseModel in a docs example. PR #2998 extended this to parameterized bases,
like Sequence[_T] from typing.

isBoundToOutOfRepoImport is the gate that drops such an edge for JS, TS and
Rust. It now answers for Python too: the name is outside when every import
binding it is absolute and resolveImportPath, which searches for a module the
way Python does, finds none. A class in a file the import path names stays,
because that search can miss a module on a path the program sets up itself, and
so does a class of the referring file, which may rebind the name. A name no
named import binds, like one from a star import, is left as it was.

Validation:
- New python-outside-bases.test.ts: the 7 outside bases fail on main, plain and
  parameterized, and every case that must keep its edge passes both ways.
  Green here (7 passed). Taking out each of the 4 guards fails exactly its own
  test.
- A/B on 7 Python repos, 469,927 edges with the controls: 67 extends removed, 0
  added, 0 moved. All 67 read against their imports and all were wrong:
  sqlalchemy 31 (28 from enum, 3 from typing), litestar 21 (pydantic
  BaseModel), polar 14 (11 fastapi APIRoute, 2 enum IntEnum, 1 opentelemetry
  Meter), django 1 (ctypes Union).
- Controls express and gin unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@colbymchenry
colbymchenry merged commit 274b43c into main Oct 30, 2026
@colbymchenry
colbymchenry deleted the claude/python-base-outside-import branch October 30, 2026 21:34
colbymchenry added a commit that referenced this pull request Oct 30, 2026
…tside the project (#3115)

PR #3109 stopped a base imported from a package linking to a project class of
its name, but only for the extends edge. The reader behind class-name calls
follows the base list as written: declaredSupertypes gives the names and
findInheritedClassMethod looks each up by name. So with class Color(Enum) after
from enum import Enum, Color.name_of() still reached a project Enum.name_of, as
did color.name_of() on a receiver named after the class. On sqlalchemy,
Query(..., Generic[_T]) with Generic from typing walked to a test's local
class Generic, then to that test's Base and on to FunctionElement, so
query.select() linked to FunctionElement.select.

The walk now skips a project class whose name the head binds outside the
project, with the check PR #3109 added, moved to name-matcher.ts so both can
call it (the import resolver already imports from there, not the reverse). A
dotted base is bound through its first word: enum.Enum through import enum,
e.Enum through import enum as e, collections.abc.Mapping through import
collections.abc, which the check now counts as binding collections.

Validation:
- python-outside-bases.test.ts gains the calls: named import, receiver, dotted,
  aliased and deep bases reach nothing, and a project base and a class in a file
  the import path names still reach their methods. Without the gate 2 of the 4
  new tests fail, and taking out the dotted word or the import a.b binding fails
  the dotted one. 11 pass here.
- A/B on 11 Python repos plus express and gin, 496,746 edges: 10 calls removed,
  0 added, 0 moved, 0 extends touched. All 10 are in sqlalchemy and all were
  wrong: 6 query.select(), query.alias() and query.compile() and 4
  value.self_group(), each through typing's Generic into a test's own class
  Generic and its Base.
- Controls express and gin unchanged.
- Index cost on sqlalchemy, median of 3 interleaved: 27.8 s on main, 26.4 s here,
  which is noise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
colbymchenry added a commit that referenced this pull request Oct 31, 2026
… again (#3141)

PR #1921 made Python imports resolve from the package roots a project has, and
stopped binding by any file whose path ends in the module's name. A folder that
is neither a root nor the importer's own is skipped on purpose, so this common
script layout lost its links: redis's modules/vector-sets/tests/*.py do
from test import TestCase, and modules/vector-sets/test.py is the runner whose
folder is first on sys.path. No root says so. On redis, main lost 137 true edges
(69 imports, 68 calls) that 1.6.2 had.

As a last step, resolvePythonModule now walks the importer's ancestors for the
module, nearest first and passing over packages. A name like test or utils may
just as well be the stdlib's, so what proves the module is what the importer
takes from it: every name must be one the file defines at module level. A plain
import or a star import takes no name, so it proves nothing and stays
unresolved, as on main. A module a root provides still answers first.

Reading the importer's own from-imports keeps this inside the resolver, so every
caller gets one answer: context.resolveModuleFile finds the module too, and the
outside-the-project check from PR #3109 does not count it as outside.

A sync follows it. Such a module binds only while it defines those names, so an
edit can link or unlink importers though no file appeared or vanished. An edited
module in a folder that is neither a package nor a root, with Python folders
below it, now re-opens the edges into it and the failures naming it. Any other
edit re-opens nothing.

Validation:
- 9 new tests in python-module-resolution.test.ts, 2 of them on sync. Without the
  fallback the 4 that expect a link fail, and the 5 that expect none pass, as
  they should. Here all 9 pass. Taking out each guard (every name, plain imports,
  packages passed over, the nearest module with the names, the re-open on edit)
  fails exactly its own test.
- A/B against main on 14 Python repos plus express and gin, 741,560 edges: 173
  added, 0 removed, 0 moved, 44 extends upgraded from exact-match to import, all
  in redis. All 137 edges 1.6.2 had and main lost are back. The other 36 added
  are the file-level imports of test.py, as a from-import from the same folder
  gets. The controls' full dumps are byte-identical.
- A trace of the fallback found every candidate it could see on those repos and
  on whole django, polar's server, ansible and cpython: redis's test.py from its 36
  test files, all proven, and nothing else, so no wrong edge and no rejected
  candidate. No plain or star import had a candidate, so leaving those
  unresolved costs nothing on these repos.
- Index cost on redis, median of 3 interleaved: 12.0 s on main, 10.7 s here, which
  is noise.

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.

1 participant