Skip to content

Code Analysis Python Imports

Tu Shaokun edited this page Nov 6, 2026 · 4 revisions

Python 包和同名源码文件,应该指向哪一个?

Ansible v2.18.7 的集成测试把 subpkg_with_init.py 和 subpkg_with_init/__init__.py 放在同一目录。调用方从这个名字导入三个函数。包提供了全部三个,另一份文件的同名函数会主动抛错。上游给三个结果都写了断言。调用方第 10–12 行 · 上游断言

对照 结果
CPython 3.13 从固定源码直接导入,得到包的 __init__.py。实际调用三个函数,全部返回了上游断言的字符串。
公开 Labs bf0a29e7 在这份 collection 测试目录捕获 52 个 Python 文件、108 条导入。第 10 行标记为已解析,却指向同名的 .py。只看已解析数量会漏掉这个错误。
Initial #1216 revision(297a2089f) 每个搜索目录内先找包的初始化文件,再找同名模块。108 条记录中只改正了这一个目标。另一组 pluggy 变更前后的 235 条导入记录逐条与上一版相同。

新增回归覆盖绝对和相对导入、与命名空间目录同名的普通模块,以及较早搜索目录里的模块。先在父版本观察到失败,再在候选版本运行,231 项模块测试通过。此提交以条件导入修复 #1213 为基线。从重复构建的 ZIP 解包重跑 Ansible 和 pluggy,原始事实与源码版相同。这轮实测使用标准 Python 加载器和 Ansible 的固定测试源码;完整 Ansible 集成套件另有自己的运行环境。包入口修复 #1216 · 基线 #1213

用同一份 Ansible 源码比较公开 Labs、Draft #1216 和 Python

准备 Git、Node 22、npm 和 Python 3.13。分析根目录是单个 collection 测试目录。Python 进程直接从该目录加载包,无须安装 Ansible。默认运行固定的 Labs 版本。复核 Draft #1216 时,先执行 export ARCHIFY_REF=297a2089fe7eaefac8eb89bb46af06e85c1f762e。两次运行的预期目标分别为 subpkg_with_init.py 和 subpkg_with_init/__init__.py。

set -eu
w=$(mktemp -d)
git init -q "$w/ansible"
git -C "$w/ansible" fetch -q --depth=1 https://github.com/ansible/ansible.git a7e289870529620963946ee00bbc374404d8ff4b
git -C "$w/ansible" checkout -q FETCH_HEAD -- test/integration/targets/collections/collection_root_user
root="$w/ansible/test/integration/targets/collections/collection_root_user"
git init -q "$w/archify"
git -C "$w/archify" fetch -q --depth=1 https://github.com/tt-a1i/archify.git "${ARCHIFY_REF:-bf0a29e7664f52973d06a8e6a608df2db1ecbd39}"
git -C "$w/archify" checkout -q --detach FETCH_HEAD
(cd "$w/archify/archify/modules/code-analysis" && npm ci --ignore-scripts --no-audit --no-fund)
BAUIFY_PYTHON="$(command -v python3.13)" node "$w/archify/archify/bin/archify.mjs" code-analysis check "$root" --language py --fail-on never --json --out "$w/facts" > "$w/receipt.json"
python3.13 -I -B -S - "$root" "$w/facts/raw-facts.json" <<'PYCODE'
from pathlib import Path
import importlib
import json
import sys

root = Path(sys.argv[1]).resolve()
sys.path.insert(0, str(root))
module = importlib.import_module("ansible_collections.testns.testcoll.plugins.module_utils.subpkg_with_init")
edges = json.loads(Path(sys.argv[2]).read_text())["imports"]
site, = [e for e in edges if e["from"].endswith("/uses_leaf_mu_module_import_from.py") and e["line"] == 10]
print("Code Analysis", site["to"])
print("Python", Path(module.__file__).relative_to(root))
print(module.thingtocall(), module.submod_thingtocall(), module.cousin_submod_thingtocall(), sep="\n")
PYCODE

Parent package and namespace follow-up

The absolute-namespace follow-up in Draft #1216 was validated at fab79183128c2b63d203a87a40063a327772d137 on top of the first package-priority revision. It remains unmerged and based on #1213. This follow-up came from source inspection and authored probes, not a reported application incident.

A full dotted-path lookup could bypass a parent that Python had already chosen. With pkg.py beside pkg/child.py, for example, import pkg.child was marked resolved even though Python rejects it because pkg is not a package. A regular package in one search root could also be bypassed in favor of a child from another root. Conversely, from ns import left, right could miss a namespace portion under src.

The resolver now walks each component using captured sources. A regular package confines child lookup; a plain module blocks it. Namespace directories accumulate across the existing root/src search paths until a regular package or module wins. One component walk replaces the separate namespace/full-path scans; its directory index is derived once from the captured file paths. No dependency, installation, or search-root configuration was added.

Independent expectations

Python's import reference requires parent imports before children. PEP 420 defines namespace portions and regular-package precedence. The authored probes were executed with isolated CPython 3.13.16 using exactly the adapter's root/src search order.

Research also inspected Pyright's import-resolution design, component traversal and four nested namespace regression cases. Pyright's stubs, environments and configurable roots serve a broader type-analysis model; they are not imported wholesale. For these four cases, its first/second roots were mapped to root/src, fixture contents were retained, and a caller was added. Independent CPython runs establish the expected result.

Input compared with previous head 297a2089f Result
Six authored probes Three false resolved targets removed, one target corrected, one missing namespace member recovered; ordinary namespace control preserved.
Four adapted Pyright cases Cases 1 and 2 remain valid; false resolved targets in cases 3 and 4 become unknown. All four match CPython.
Ansible collection scope, 52 files / 108 references All import records unchanged; the original package-priority correction remains intact.
pluggy, 31 / 235 Every import record unchanged.
pytest, 274 / 4,066 Every import record unchanged.

Repository pins: Ansible a7e289870529620963946ee00bbc374404d8ff4b, pluggy 3d5417a4eff449e17e5bb3e1545b63cbf0e0c768, pytest 08017d9ada4c72a7b6a78f7873c238070e24c982. The three repository scans are preservation controls, not evidence of a new improvement in their applications. Files, function measurements and parse coverage were unchanged; all selected inputs had zero parse errors.

Two new regression tests fail on the previous public head and pass here. The final module suite passes 233 tests on official Node 22.23.2. Its first complete run caught stale generated task examples after advancing Python's extraction revision to 4; those examples were regenerated and checked to differ only in revision, interpreter version and snapshot metadata. Two canonical archive builds matched SHA256 a8001e4bd04132c6ad9ac8776b6481557c14d0429412b5c8c83b74dbce3b4266. Normal source CLI and unpacked archive facts match byte for byte across all 13 inputs. A fresh public fetch reran the script below successfully.

Boundaries remain explicit: root/src is a convention rather than an observed runtime sys.path; installed environments, import hooks, compiled modules and runtime __path__ changes are not modeled. At this revision, relative imports keep their lexical directory model, including its cross-portion limitation. A failed full import does not yet retain partial initialization edges. This is not a complete Python execution model.

Reproduce all four adapted Pyright cases on previous head, candidate and archive

Requires Git, Node 22, npm, unzip and Python 3.13. Only the tiny upstream fixture modules and the authored callers are executed by Python. The script retrieves fixed public commits and verifies both positive and negative cases.

#!/usr/bin/env bash
set -euo pipefail
w=$(mktemp -d)
printf '%s\n' "$w"
git init -q "$w/archify"
git -C "$w/archify" fetch -q --depth=2 https://github.com/tt-a1i/archify.git fab79183128c2b63d203a87a40063a327772d137
git -C "$w/archify" checkout -q --detach FETCH_HEAD
git -C "$w/archify" worktree add -q --detach "$w/parent" 297a2089fe7eaefac8eb89bb46af06e85c1f762e
(cd "$w/archify/archify/modules/code-analysis" && npm ci --ignore-scripts --no-audit --no-fund)
ln -s "$w/archify/archify/modules/code-analysis/node_modules" "$w/parent/archify/modules/code-analysis/node_modules"
unzip -q "$w/archify/archify.zip" -d "$w/unpacked"
git init -q "$w/pyright"
git -C "$w/pyright" fetch -q --depth=1 https://github.com/microsoft/pyright.git 3afce1fa9c668e051f9f762c5e9d7b43d0494933
git -C "$w/pyright" checkout -q FETCH_HEAD -- packages/pyright-internal/src/tests/importResolver.test.ts
export BAUIFY_PYTHON="$(command -v python3.13)"
python3.13 - "$w" <<'PY'
from pathlib import Path
import json, os, re, subprocess, sys
w=Path(sys.argv[1])
source=(w/'pyright/packages/pyright-internal/src/tests/importResolver.test.ts').read_text()
for i in range(1,5):
    start=source.index("test('nested namespace package %s'" % i)
    block=source[start:source.index('\n        test(',start+1)]
    root=w/('fixture-%s' % i)
    for args,text in re.findall(r"path: combinePaths\(([^\n]+)\),\s*content: '([^']*)'",block):
        parts=re.findall(r"'([^']*)'",args)
        assert parts[0]=='/' and parts[1] in ('packages1','packages2')
        dest=root.joinpath(*(['src'] if parts[1]=='packages2' else []),*parts[2:])
        dest.parent.mkdir(parents=True,exist_ok=True);dest.write_text(text)
    mod='a.b.c' if i==4 else 'a.b.c.d'
    (root/'app.py').write_text('import '+mod+'\n')
    code="""import os,sys,importlib,json
sys.path=[os.getcwd(),os.path.join(os.getcwd(),'src')]
try:
    m=importlib.import_module(%r)
    result={'ok':True,'to':os.path.relpath(m.__file__)}
except ModuleNotFoundError:
    result={'ok':False}
print(json.dumps(result))
""" % mod
    oracle=json.loads(subprocess.check_output([sys.executable,'-I','-S','-B','-c',code],cwd=root,text=True))
    assert oracle['ok']==(i<=2)
    facts={}
    for label,checkout in [('parent','parent'),('candidate','archify'),('packaged','unpacked')]:
        out=w/('%s-%s' % (label,i))
        command=['node',str(w/checkout/'archify/bin/archify.mjs'),'code-analysis','check',str(root),'--language','py','--fail-on','never','--out',str(out),'--json']
        subprocess.run(command,check=True,stdout=subprocess.PIPE,text=True)
        facts[label]=(out/'raw-facts.json').read_text()
        edge=next(e for e in json.loads(facts[label])['imports'] if e['from']=='app.py' and not e.get('implicit'))
        if label!='parent':
            assert edge['resolved']==oracle['ok']
            if edge['resolved']: assert edge['to']==oracle['to']
        else: assert edge['resolved']
    assert facts['candidate']==facts['packaged']
    print('Pyright nested case',i,': candidate and archive match CPython; parent resolved = true')
PY

返回团队验证记录

Relative namespace follow-up

Current Draft #1216 head 48d31661fe941f7b1985c61484268e71fda6674e follows inferred namespace portions for relative imports as well. Three missing and two wrong targets are corrected under the same root/src context, while regular-package and analyzed-root boundaries remain intact. All 235 module tests and ten source/archive scopes pass; Ansible, pluggy and pytest import records are unchanged. Source expectations, limitations and executed public reproduction. Earlier results above retain their stated revisions.

Clone this wiki locally