Repository navigation
fix(python): a base imported from outside the project is not a project class of that name - #3109
Merged
Merged
Conversation
…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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)afterfrom enum import Enumis not your ownEnum, andclass Item(BaseModel)afterfrom pydantic import BaseModelis not aBaseModelin 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,
isBoundToOutOfRepoImportdrops such an edge; for Python it always answered no. Its comment said Python imports don't go throughresolveImportPath, but they do now.Fix
isBoundToOutOfRepoImportnow answers for Python: a base is outside when every import binding its name is absolute andresolveImportPath, 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:
lib/support/helpers/base.pyforfrom helpers.base import Base. Python's search can miss a module on a path the program sets up itself.class Enum(Enum)).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'sBaseModel). 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:
server/polar@ 8dd9956django/@ a6a5d1eAll 67 are
extends, and all were read against their imports. None was right:from enum import Enum(sqlalchemy)sql/sqltypes.pyEnumfrom typing import Sequence, Tuple, MutableSet(sqlalchemy, the 3 from #3101)from pydantic import BaseModel(litestar)BaseModelfrom fastapi.routing import APIRoute(polar)polar/routing.pyAPIRoutefrom enum import IntEnum(polar)kit/extensions/sqlalchemy/types.pyIntEnumfrom opentelemetry.metrics import Meter(polar)meter/schemas.pyMeterfrom ctypes import Union(django)contrib/gis/db/models/aggregates.pyUnionCost: 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.mdwith it, andgit diffof the test and both source files against 2c31682c is empty.Not in this PR
extendsedges. The text reader behind class-name calls still follows the name: withclass Color(Enum)fromenum,Color.name_of()reaches a projectEnum.name_of, as it does on main. That's the import-side twin of fix(python): a base's type argument is no supertype, so Repo.save() is not User.save #3083 and belongs in its own change.### New Featuresin[Unreleased].🤖 Generated with Claude Code