Skip to content

fix(python): keep package directories off the sys.path root walk - #701

Open
Yi-111-a wants to merge 1 commit into
Egonex-AI:mainfrom
Yi-111-a:fix/python-package-dir-not-syspath-root
Open

Yi-111-a wants to merge 1 commit into
Egonex-AI:mainfrom
Yi-111-a:fix/python-package-dir-not-syspath-root

Conversation

@Yi-111-a

@Yi-111-a Yi-111-a commented Oct 1, 2026

Copy link
Copy Markdown

Summary

resolvePythonImport() walks up from the importer's directory and tries each ancestor as a candidate sys.path root, first match wins. The walk treated every ancestor as a root candidate, including directories that contain an __init__.py.

A directory with an __init__.py is a package, and Python puts a package's parent on sys.path — never the package itself. Offering one as a root produces two wrong edges:

  1. A package module shadows a stdlib / third-party name. With
    service/app/__init__.py and service/app/logging.py, the bare import logging inside service/app/logging.py resolved to the in-tree file, so every sibling importing the real stdlib logging picked up a false in-tree edge.
  2. A module resolves to itself. That same service/app/logging.py got the edge service/app/logging.py → service/app/logging.py. Separately, import svc.main from src/svc/main.py reached itself whenever an enclosing namespace directory was a root candidate.

Closes #621.

Changes

In understand-anything-plugin/skills/understand/extract-import-map.mjs, the absolute-import branch of resolvePythonImport():

  • skips a candidate root that carries an __init__.py;
  • drops a match equal to the importing file, so the walk continues to a shallower root rather than returning early.

The two rules are independent: the first covers a package under a real root, the second covers the PEP 420 namespace-package case where there is no __init__.py to key on.

The multi-service case is preserved

This is the case the walk-up exists for, and both rules leave it alone: src/emailservice/ carries no __init__.py, so it stays a valid implicit root and import demo_pb2_grpc still resolves to src/emailservice/demo_pb2_grpc.py. The existing regression test for it is unchanged and still passes. A skipped package's parent is still a candidate on the next iteration, so the genuine from app.logging import get_logger edge survives.

Verification

Two new regression tests in tests/skill/understand/test_extract_import_map.test.mjs, both of which fail on main and pass with this change:

  • does not treat a package directory as a sys.path root — asserts service/app/logging.py gets no edges for its bare import logging, and that service/app/main.py still resolves to service/app/logging.py.
  • never resolves a python import to the importing file — asserts import svc.main from src/svc/main.py is not a self-edge while the real import helpers sibling edge is kept.

Before the fix, the first test observed the self-edge directly:

AssertionError: expected [ 'service/app/logging.py' ] to be undefined
AssertionError: expected [ 'src/svc/helpers.py', …(1) ] to deeply equal [ 'src/svc/helpers.py' ]
+   "src/svc/main.py",

After:

pnpm install
pnpm --filter @understand-anything/core build
pnpm vitest run tests/skill/understand/test_extract_import_map.test.mjs --testTimeout=60000
# Test Files  1 passed (1)
#      Tests  69 passed (69)

pnpm eslint understand-anything-plugin/skills/understand/extract-import-map.mjs \
  tests/skill/understand/test_extract_import_map.test.mjs   # clean

All 69 tests in the file pass, including the 67 pre-existing ones. --testTimeout is only needed on a loaded machine: the suite spawns a node subprocess per case, and the default 5 s per-test timeout can trip on an unrelated case.

Notes

  • No CLA or AI-contribution restriction applies to this repository.
  • Scope is the resolver only. The issue also mentions broader stdlib-shadowing cases (types.py, json.py, email.py, queue.py) — those are the same defect and are covered by the same rule, since the skip keys on __init__.py rather than on any module name.

The absolute-import walk-up offered every ancestor directory as a
candidate sys.path root, including directories holding an
`__init__.py`. Python puts a package's PARENT on sys.path, never the
package itself, so a package directory must not be a root.

Two consequences followed. A module inside a package that shadows a
stdlib or third-party name (`logging`, `types`, `json`, `email`,
`queue`, ...) resolved to the in-tree file instead of the real module,
attracting a false in-tree edge from every sibling that imported the
genuine stdlib module. And a module could resolve to itself:
`import logging` inside `service/app/logging.py` produced the edge
`service/app/logging.py -> service/app/logging.py`.

Both are now excluded: a candidate root carrying `__init__.py` is
skipped, and a match equal to the importing file is dropped so the walk
continues to a shallower root. The multi-service case the walk exists
for is unaffected \u2014 those service directories carry no `__init__.py`,
and the parent of a skipped package is still a candidate, so
`from app.logging import ...` keeps resolving to the in-tree module.

This branch has not been deployed

No deployments
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.

Python import resolver treats package directories as sys.path roots, producing false in-tree edges and self-loops

2 participants