Python 导入排查:同名文件遮住标准库时,为什么 -P 还不够?

先证明导入了哪份文件,再修正命名和启动路径;用固定反例看清 -P 为什么仍会遇到同名模块。

你能带走什么
  • 先保存模块来源,再判断是否需要改名或修改启动路径
  • -E 和 -P 控制不同输入,-P 仍可能从 PYTHONPATH 导入同名模块
  • -I 不包含 -S,路径选项也不能替代安全沙箱
  • 固定预期保留失败样例,修复后必须回到原项目入口复验

1. 先看报错指向哪份文件

代码只写了一句 from decimal import Decimal,却收到 cannot import name 的错误。这个时候,重新安装依赖未必有用:项目里一个叫 decimal.py 的业务文件,就足以让导入得到完全不同的内容。先把模块的真实来源留在报告里,后面的修复才有依据。

本文让两份只有 LABEL 常量的 decimal.py 分别出现在脚本旁边和 PYTHONPATH 目录。它们都没有 Decimal。用同一个解释器逐次启动新进程,就能看到 -E、-P 和 -I 分别改变了什么,以及改名后为什么仍可能报错。

如果你已经确认错误来自项目自己的同名文件,最小修复通常是给它换一个业务名称、修改相关引用,再用原启动方式开启新进程验证。只有来源仍然异常时,才沿着启动器和搜索路径继续查。本文下面先给出完整可复现实验,末尾再给出交给 Codex 的任务说明。

要求已有 CPython 3.11 或更高版本;本次实测是 Linux / CPython 3.12.14。整个示例只使用标准库与临时目录,没有安装依赖、修改用户仓库或全局配置,也没有运行外部模型。

2. 脚本旁边的文件,和当前目录不是一回事

在本例的常规路径搜索中,直接执行 project/labprobe.py 时,自动放到搜索路径前面的是脚本所在目录。即使终端当前在空的 neutral 目录,project/decimal.py 仍会被找到。换成在 project 内运行 -m labprobe,解释器先从模块搜索路径寻找入口,此时通常自动前置当前目录。这个区别由 Python 路径初始化文档说明。

PYTHONPATH 还能加入其他搜索位置。它与自动前置入口路径是两项不同的输入。示例特意把当前目录设为空目录,并逐次清理继承的 PYTHON* 变量,再按案例设置 PYTHONPATH,避免把机器上原有的启动习惯混进结果。

直接脚本和 -m 入口对比:脚本目录或当前目录在前,随后是环境路径与标准库
图 1:本例中的入口路径。实际 sys.path 的完整列表保留在每次运行报告里。

3. 固定输入很小,失败条件也要写清

驱动会创建下列临时目录。两份 decimal.py 的字节内容分别是 LABEL = "PROJECT" 后跟一个 LF,以及 LABEL = "INJECTED" 后跟一个 LF。labprobe.py 来自后文提供的完整源码,neutral 为空目录。实验结束后临时目录自动清理,指定的 JSON 报告会留下。

fixture/
  project/
    labprobe.py
    decimal.py        # LABEL = "PROJECT"
  injected/
    decimal.py        # LABEL = "INJECTED"
  neutral/

每一项都用当前解释器的同一绝对路径,加上 -B -S 启动。-B 避免写入新字节码,-S 在这次实验中排除 site 初始化带来的路径变化;它们都是实验控制。-B 不会删除旧缓存,本例依靠全新目录排除旧缓存。程序没有使用 shell=True,也不需要更改 shell 启动文件。

探针首先检查 sys.modules,确认 decimal 尚未加载,再调用顶层名称 find_spec("decimal")。随后真正导入并比对 spec.origin、模块的 __spec__.origin 与 __file__,最后调用 Decimal("0.1") + Decimal("0.2")。标准库应得到字符串 0.3;同名夹具应记录 ImportError、missing_Decimal,并退出 2。每项都核对来源、结果和退出码。

find_spec 的官方说明提醒,点分名称可能先导入父包,所以这个探针仅用于顶层 decimal。即便只查顶层,它仍经过导入查找机制,不是执行不可信代码前的安全检查。真实导入也会执行模块内容,只应在你信任的项目中诊断。

驱动另开一个 -I -B -S 进程观察标准库来源,并要求其路径正好位于 sysconfig 给出的标准库目录、文件名为 decimal.py,且运算成立。遇到不同的解释器布局会停止。PROJECT、INJECTED 与这个已核实来源之外的文件,一律报 Unknown source,不会靠“看起来像路径”就替换成标准库标签。

4. 11 项预期在运行之前已经写定

下表中的所有命令都额外带 -B -S。脚本方式的 cwd 为 neutral;模块方式的 cwd 为 project。PROJECT 和 INJECTED 指向两份已知夹具,STDLIB 指向经过独立检查的标准库文件。预期保存在 expected.json,驱动只读取它,不从观测输出生成答案。

启动方式PYTHONPATH来源结果退出码
直接执行脚本无PROJECTmissing_Decimal2
脚本 + -E无PROJECTmissing_Decimal2
脚本 + -P无STDLIB0.30
脚本 + -PinjectedINJECTEDmissing_Decimal2
脚本 + -EinjectedPROJECTmissing_Decimal2
脚本 + -E -PinjectedSTDLIB0.30
脚本 + -IinjectedSTDLIB0.30
在 project 内 -m labprobe无PROJECTmissing_Decimal2
在 project 内 -P -m labprobe无NO_ENTRYprobe_not_started1
同名文件改名后执行脚本无STDLIB0.30
保留改名后执行脚本injectedINJECTEDmissing_Decimal2

这 11 项已全部实测通过。这里的“通过”包括正确复现退出 2 和退出 1 的负例。尤其是第 9 项,stdout 必须为空,stderr 必须精确对应当前解释器的 No module named labprobe,不能把任意报错都算作入口被隔离。

最后两项只在临时夹具里把 decimal.py 改成 decimal_fixture.py。清洁环境下导入恢复到标准库,但保留 PYTHONPATH 时,injected/decimal.py 接着被选中。驱动最后把夹具名字恢复,并核对两份文件字节及不存在新生成的 pyc。

5. -P 的反例,解释了为什么不能只加一个开关

-E 让解释器忽略 PYTHON* 环境变量,但项目目录仍可能排在前面。-P 去掉启动时自动前置的入口路径,却仍允许 PYTHONPATH 参与搜索。所以本例的 -P 加注入路径,恰好把错误从 PROJECT 转移到了 INJECTED。

-I 包含 -E、-P 和小写 -s。大写 -S 是另一项选项,本实验显式添加了它;额外测试也实际核对了单独 -I 时 no_site 为 0。系统级 site 初始化与用户 site 目录是不同范围,不能把 isolated mode 写成停止所有 site 行为。具体选项及 -P 自 3.11 起提供的版本边界,见 Python 命令行文档。

-E 忽略环境但保留脚本目录,-P 去除脚本目录但保留环境,-I 组合相关选项而不含 -S
图 2:直接脚本模式下的路径输入对比,黄色一行对应本文保留的反例。

第 9 项还说明另一个成本:入口模块本身也要能被找到。直接给出文件路径时,-P 不妨碍打开这份脚本;-P -m labprobe 却可能在探针开始之前就失败。实际包项目应该采用自身明确的安装与启动方式,不能直接把 -P 或 -I 填进所有启动命令。

这些开关适合做诊断对照,不能把一个进程变成不可信代码的安全沙箱。本文也没有覆盖 .pth、editable install、namespace package、自定义 finder、打包可执行文件与 IDE 额外注入路径。

6. 复制完整文件,保留一份自己的原始报告

将下面五份文件保存到同一个新目录,在该目录执行命令。没有额外下载步骤。请选用已安装的 CPython;-E 放在驱动启动命令上,是为了避免继承的 PYTHONHOME 等变量在驱动运行之前就改变解释器启动。

python -E -B -S run_matrix.py --output report.json
python -E -B -S -m unittest -v test_matrix.py

第一条应显示 PASS 11 fixed cases,并给出报告路径。第二条应显示 Ran 13 tests 和 OK。再次运行请使用另一个报告名;程序以独占创建方式写报告,已有文件不会被覆盖。成功矩阵报告保存绝对命令、cwd、受控 PYTHONPATH、完整 sys.path、模块来源、stdout 和 stderr,分享前先检查是否含有你的本机路径信息。失败报告的 status 为 failed,不能当成矩阵通过。

labprobe.py(完整内容)
"""Only diagnose the top-level decimal module in this controlled fixture."""
import sys

# Check before any diagnostic helper could import decimal.
preloaded = "decimal" in sys.modules
import importlib.util
import json
import os

record = {
    "preloaded": preloaded,
    "executable": sys.executable,
    "version": sys.version,
    "argv": sys.argv,
    "cwd": os.getcwd(),
    "pythonpath": os.environ.get("PYTHONPATH"),
    "sys_path": sys.path,
    "flags": {key: getattr(sys.flags, key) for key in
              ("ignore_environment", "safe_path", "isolated", "no_site",
               "no_user_site", "dont_write_bytecode")},
}


def finish(result, code):
    record["result"] = result
    print(json.dumps(record, ensure_ascii=False, sort_keys=True))
    raise SystemExit(code)


if preloaded or "decimal" in sys.modules:
    finish("preloaded_decimal", 3)
try:
    # Never substitute a dotted name: its parent package may execute.
    spec = importlib.util.find_spec("decimal")
    record["spec_origin"] = None if spec is None else spec.origin
    import decimal
    record["module_origin"] = decimal.__spec__.origin
    record["module_file"] = decimal.__file__
    record["label"] = getattr(decimal, "LABEL", None)
    try:
        from decimal import Decimal
    except ImportError as exc:
        record["exception"] = type(exc).__name__
        record["message"] = str(exc)
        finish("missing_Decimal", 2)
    finish(str(Decimal("0.1") + Decimal("0.2")), 0)
except Exception as exc:
    record["exception"] = type(exc).__name__
    record["message"] = str(exc)
    finish("probe_error", 4)
run_matrix.py(完整内容)
"""Run fresh CPython subprocesses; never derive expected.json from results."""
import argparse
import base64
import hashlib
import io
import json
import os
from pathlib import Path
import platform
import subprocess
import sys
import tempfile

HERE = Path(__file__).absolute().parent
PYTHON = str(Path(sys.executable).absolute())
EXPECTED_BYTES = (HERE / "expected.json").read_bytes()
EXPECTED = json.loads(EXPECTED_BYTES)
PROJECT_BYTES = b'LABEL = "PROJECT"\n'
INJECTED_BYTES = b'LABEL = "INJECTED"\n'
# id, extra flags, injected PYTHONPATH, entry mode, renamed fixture
CASES = [
    ("script", [], False, "script", False),
    ("script_E", ["-E"], False, "script", False),
    ("script_P", ["-P"], False, "script", False),
    ("script_P_injected", ["-P"], True, "script", False),
    ("script_E_injected", ["-E"], True, "script", False),
    ("script_EP_injected", ["-E", "-P"], True, "script", False),
    ("script_I_injected", ["-I"], True, "script", False),
    ("module", [], False, "module", False),
    ("module_P", ["-P"], False, "module", False),
    ("renamed", [], False, "script", True),
    ("renamed_injected", [], True, "script", True),
]
BASELINE = r'''
import decimal, hashlib, json, pathlib, sys, sysconfig
p = pathlib.Path(decimal.__file__).resolve()
stdlib = pathlib.Path(sysconfig.get_path("stdlib")).resolve()
if p.parent != stdlib or p.name != "decimal.py":
    raise RuntimeError("Unexpected stdlib layout: " + str(p))
if pathlib.Path(decimal.__spec__.origin).resolve() != p:
    raise RuntimeError("Baseline origin mismatch")
v = str(decimal.Decimal("0.1") + decimal.Decimal("0.2"))
if v != "0.3":
    raise RuntimeError("Baseline Decimal contract failed")
print(json.dumps({"origin":str(p), "stdlib":str(stdlib), "result":v,
 "sha256":hashlib.sha256(p.read_bytes()).hexdigest(),
 "executable":sys.executable, "version":sys.version}))
'''


class ValidationError(RuntimeError):
    pass


class ProbeTimeout(ValidationError):
    def __init__(self, receipt):
        self.receipt = receipt
        super().__init__(f"Subprocess timed out after {receipt['timeout_seconds']} seconds")


def clean_env(inherited=None):
    source = os.environ if inherited is None else inherited
    return {key: value for key, value in source.items()
            if not key.upper().startswith("PYTHON")}


def execute(args, cwd, env):
    command = [PYTHON, "-B", "-S", *args]
    try:
        proc = subprocess.run(command, cwd=cwd, env=env,
                              capture_output=True, text=False, timeout=15)
    except subprocess.TimeoutExpired as exc:
        # Capture in binary mode on every platform. Keep exact partial bytes,
        # including incomplete UTF-8, rather than decoding or discarding.
        def captured(value):
            return None if value is None else base64.b64encode(value).decode("ascii")
        receipt = {"command": command, "cwd": str(cwd),
                   "target_pythonpath": env.get("PYTHONPATH"),
                   "timeout_seconds": exc.timeout,
                   "stdout_b64": captured(exc.stdout),
                   "stderr_b64": captured(exc.stderr)}
        # run() has killed and waited for its direct child; no returncode is
        # exposed by TimeoutExpired, and this is not a successful probe row.
        raise ProbeTimeout(receipt) from exc
    def decoded(value):
        # Match the former text-mode receipt: default encoding and universal
        # newline translation, but only after a process completes normally.
        with io.TextIOWrapper(io.BytesIO(value)) as stream:
            return stream.read()
    return {"command": proc.args, "cwd": str(cwd),
            "target_pythonpath": env.get("PYTHONPATH"),
            "returncode": proc.returncode, "stdout": decoded(proc.stdout),
            "stderr": decoded(proc.stderr)}


def check_record(raw, expected, known):
    if expected["source"] == "NO_ENTRY":
        # Do not accept any arbitrary failing command as an entry failure.
        suffix = ": No module named labprobe\n"
        if not (raw["returncode"] == 1 and raw["stdout"] == "" and
                raw["stderr"] == PYTHON + suffix):
            raise ValidationError("Unexpected -m entry failure: " + repr(raw))
        return dict(expected)
    if raw["stderr"]:
        raise ValidationError("Unexpected stderr: " + raw["stderr"])
    try:
        data = json.loads(raw["stdout"])
    except (ValueError, TypeError) as exc:
        raise ValidationError("Probe output is not one JSON record") from exc
    if data.get("preloaded") is not False:
        raise ValidationError("decimal was already loaded")
    origins = [data.get(k) for k in ("spec_origin", "module_origin", "module_file")]
    if any(not isinstance(p, str) for p in origins) or len(set(origins)) != 1:
        raise ValidationError("Missing or different module origins")
    origin = str(Path(origins[0]).resolve())
    if origin not in known:
        raise ValidationError("Unknown source, refusing to normalize: " + origin)
    source = known[origin]
    if data.get("label") != {"PROJECT":"PROJECT", "INJECTED":"INJECTED",
                             "STDLIB":None}[source]:
        raise ValidationError("Source label mismatch")
    if data.get("executable") != PYTHON:
        raise ValidationError("Interpreter changed")
    if not data["flags"]["no_site"] or not data["flags"]["dont_write_bytecode"]:
        raise ValidationError("Missing common -B -S controls")
    if data.get("pythonpath") != raw["target_pythonpath"]:
        raise ValidationError("Environment receipt mismatch")
    actual = {"case": expected["case"], "source": source,
              "result": data.get("result"), "returncode": raw["returncode"]}
    if actual != expected:
        raise ValidationError(f"Expected {expected!r}; got {actual!r}")
    if source != "STDLIB" and data.get("exception") != "ImportError":
        raise ValidationError("Expected missing Decimal ImportError")
    raw["probe"] = data
    return actual


def run_matrix(root, inherited=None):
    if platform.python_implementation() != "CPython" or sys.version_info < (3, 11):
        raise RuntimeError("This lab requires CPython 3.11+")
    root = Path(root).absolute()
    root.mkdir(parents=True, exist_ok=False)
    project, injected, neutral = [root / name for name in
                                  ("project", "injected", "neutral")]
    for directory in (project, injected, neutral):
        directory.mkdir()
    (project / "decimal.py").write_bytes(PROJECT_BYTES)
    (injected / "decimal.py").write_bytes(INJECTED_BYTES)
    (project / "labprobe.py").write_bytes((HERE / "labprobe.py").read_bytes())
    env = clean_env(inherited)
    baseline_raw = execute(["-I", "-c", BASELINE], neutral, env)
    if baseline_raw["returncode"] != 0 or baseline_raw["stderr"]:
        raise ValidationError("Baseline failed: " + repr(baseline_raw))
    baseline = json.loads(baseline_raw["stdout"])
    known = {str((project / "decimal.py").resolve()): "PROJECT",
             str((injected / "decimal.py").resolve()): "INJECTED",
             baseline["origin"]: "STDLIB"}
    if [row[0] for row in CASES] != [row["case"] for row in EXPECTED]:
        raise ValidationError("Case order differs from fixed expected.json")
    rows, normalized = [], []
    for (name, flags, inject, mode, rename), expected in zip(CASES, EXPECTED):
        if rename and (project / "decimal.py").exists():
            (project / "decimal.py").rename(project / "decimal_fixture.py")
        case_env = dict(env)
        if inject:
            case_env["PYTHONPATH"] = str(injected)
        cwd = project if mode == "module" else neutral
        entry = ["-m", "labprobe"] if mode == "module" else [str(project / "labprobe.py")]
        raw = execute([*flags, *entry], cwd, case_env)
        raw["case"] = name
        rows.append(raw)
        try:
            normalized.append(check_record(raw, expected, known))
        except ValidationError as exc:
            raise ValidationError(f"{name}: {exc}; raw={raw!r}") from exc
    (project / "decimal_fixture.py").rename(project / "decimal.py")
    if (project / "decimal.py").read_bytes() != PROJECT_BYTES:
        raise ValidationError("Project fixture bytes changed")
    if (injected / "decimal.py").read_bytes() != INJECTED_BYTES:
        raise ValidationError("Injected fixture bytes changed")
    if list(root.rglob("*.pyc")) or list(root.rglob("__pycache__")):
        raise ValidationError("Bytecode appeared in the new fixture")
    return {"root":str(root), "platform":platform.platform(),
            "expected_sha256":hashlib.sha256(EXPECTED_BYTES).hexdigest(),
            "baseline":baseline, "baseline_raw":baseline_raw,
            "normalized":normalized, "rows":rows,
            "restored_fixture_sha256":{
                "project/decimal.py":hashlib.sha256(PROJECT_BYTES).hexdigest(),
                "injected/decimal.py":hashlib.sha256(INJECTED_BYTES).hexdigest()}}


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--output", required=True)
    args = parser.parse_args()
    output = Path(args.output).absolute()
    # Exclusive creation avoids silently overwriting an earlier receipt.
    with output.open("x", encoding="utf-8") as stream:
        try:
            with tempfile.TemporaryDirectory(prefix="import-shadow-") as temp:
                report = run_matrix(Path(temp) / "fixture")
            json.dump(report, stream, ensure_ascii=False, indent=2)
            stream.write("\n")
        except Exception as exc:
            failure = {"status":"failed", "error":repr(exc)}
            if isinstance(exc, ProbeTimeout):
                failure["timeout"] = exc.receipt
            json.dump(failure, stream, ensure_ascii=False, indent=2)
            stream.write("\n")
            raise
    print(f"PASS {len(report['normalized'])} fixed cases; receipt: {output}")


if __name__ == "__main__":
    main()
expected.json(完整内容)
[
  {"case":"script","source":"PROJECT","result":"missing_Decimal","returncode":2},
  {"case":"script_E","source":"PROJECT","result":"missing_Decimal","returncode":2},
  {"case":"script_P","source":"STDLIB","result":"0.3","returncode":0},
  {"case":"script_P_injected","source":"INJECTED","result":"missing_Decimal","returncode":2},
  {"case":"script_E_injected","source":"PROJECT","result":"missing_Decimal","returncode":2},
  {"case":"script_EP_injected","source":"STDLIB","result":"0.3","returncode":0},
  {"case":"script_I_injected","source":"STDLIB","result":"0.3","returncode":0},
  {"case":"module","source":"PROJECT","result":"missing_Decimal","returncode":2},
  {"case":"module_P","source":"NO_ENTRY","result":"probe_not_started","returncode":1},
  {"case":"renamed","source":"STDLIB","result":"0.3","returncode":0},
  {"case":"renamed_injected","source":"INJECTED","result":"missing_Decimal","returncode":2}
]
test_matrix.py(完整内容)
"""Boundary tests plus two independently created experiment roots."""
import base64
import copy
import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest import mock
import run_matrix as lab


class ImportMatrixTests(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        cls.temp = tempfile.TemporaryDirectory(prefix="import-boundaries-")
        cls.addClassCleanup(cls.temp.cleanup)
        cls.base = Path(cls.temp.name)
        cls.report = lab.run_matrix(cls.base / "first")
        cls.known = {
            str(cls.base / "first/project/decimal.py"): "PROJECT",
            str(cls.base / "first/injected/decimal.py"): "INJECTED",
            cls.report["baseline"]["origin"]: "STDLIB",
        }

    def valid_record(self):
        return copy.deepcopy(self.report["rows"][0])

    def reject(self, raw, message):
        with self.assertRaisesRegex(lab.ValidationError, message):
            lab.check_record(raw, lab.EXPECTED[0], self.known)

    def test_two_roots_and_poisoned_parent_environment(self):
        poison = dict(os.environ, PYTHONPATH="/not-used/from-parent",
                      PYTHONHOME="/nonexistent-python-home", PYTHONSAFEPATH="1",
                      PYTHONWARNINGS="error", PYTHONINSPECT="1")
        second = lab.run_matrix(self.base / "second", poison)
        self.assertNotEqual(self.report["root"], second["root"])
        self.assertEqual(self.report["normalized"], lab.EXPECTED)
        self.assertEqual(second["normalized"], lab.EXPECTED)
        self.assertEqual(self.report["baseline"], second["baseline"])
        self.assertEqual(self.report["restored_fixture_sha256"],
                         second["restored_fixture_sha256"])
        for report in (self.report, second):
            for row in report["rows"]:
                if "probe" in row:
                    self.assertNotIn("/not-used/from-parent", row["probe"]["sys_path"])

    def test_environment_removes_all_python_variables(self):
        self.assertEqual(lab.clean_env({"PYTHONHOME":"bad", "PYTHONPATH":"bad",
                                       "PYTHONSAFEPATH":"1", "PATH":"keep"}),
                         {"PATH":"keep"})

    def test_preloaded_module_is_a_real_process_failure(self):
        code = ("import decimal, runpy; runpy.run_path(" +
                repr(str(lab.HERE / "labprobe.py")) + ", run_name='__main__')")
        raw = lab.execute(["-I", "-c", code], self.base, lab.clean_env())
        self.assertEqual(raw["returncode"], 3)
        self.assertEqual(json.loads(raw["stdout"])["result"], "preloaded_decimal")
        self.reject(raw, "already loaded")

    def test_unknown_source_is_not_normalized(self):
        unknown = self.base / "unknown"
        unknown.mkdir()
        (unknown / "decimal.py").write_text('LABEL = "PROJECT"\n', encoding="utf-8")
        env = lab.clean_env()
        env["PYTHONPATH"] = str(unknown)
        raw = lab.execute(["-P", str(lab.HERE / "labprobe.py")], self.base, env)
        self.assertEqual(raw["returncode"], 2)
        self.reject(raw, "Unknown source")

    def test_malformed_stdout_is_rejected(self):
        raw = self.valid_record()
        raw["stdout"] += "unexpected second line\n"
        self.reject(raw, "not one JSON")

    def test_mismatched_origins_are_rejected(self):
        raw = self.valid_record()
        data = json.loads(raw["stdout"])
        data["module_file"] = "/different/decimal.py"
        raw["stdout"] = json.dumps(data)
        self.reject(raw, "different module origins")

    def test_wrong_exit_status_is_rejected(self):
        raw = self.valid_record()
        raw["returncode"] = 0
        self.reject(raw, "Expected")

    def test_unexpected_stderr_is_rejected(self):
        raw = self.valid_record()
        raw["stderr"] = "surprise\n"
        self.reject(raw, "Unexpected stderr")

    def test_any_failure_is_not_a_missing_entry(self):
        raw = copy.deepcopy(self.report["rows"][8])
        raw["stderr"] = "SyntaxError: unrelated failure\n"
        with self.assertRaisesRegex(lab.ValidationError, "entry failure"):
            lab.check_record(raw, lab.EXPECTED[8], self.known)

    def test_binary_capture_keeps_normal_text_receipt_contract(self):
        command = [lab.PYTHON, "-B", "-S", "-c", "pass"]
        completed = subprocess.CompletedProcess(command, 0, b"first\r\nsecond\n", b"note\r\n")
        with mock.patch.object(lab.subprocess, "run", return_value=completed) as run:
            raw = lab.execute(["-c", "pass"], self.base, lab.clean_env())
        self.assertIs(run.call_args.kwargs["text"], False)
        self.assertEqual(raw["stdout"], "first\nsecond\n")
        self.assertEqual(raw["stderr"], "note\n")
        self.assertEqual(raw["returncode"], 0)

    def test_timeout_report_retains_exact_partial_bytes(self):
        output = self.base / "timeout-receipt.json"
        code = ("import sys, time; "
                "sys.stdout.buffer.write(b'partial-out-\\xff'); sys.stdout.flush(); "
                "sys.stderr.buffer.write(b'partial-err-\\xfe'); sys.stderr.flush(); "
                "time.sleep(30)")
        observed = {}

        def stalled_matrix(root):
            observed["cwd"] = str(root.parent)
            return lab.execute(["-c", code], root.parent, lab.clean_env())

        with mock.patch.object(lab, "run_matrix", side_effect=stalled_matrix), \
                mock.patch.object(lab.sys, "argv", ["run_matrix.py", "--output", str(output)]):
            with self.assertRaises(lab.ProbeTimeout):
                lab.main()
        failure = json.loads(output.read_text(encoding="utf-8"))
        self.assertEqual(failure["status"], "failed")
        receipt = failure["timeout"]
        self.assertEqual(receipt["command"], [lab.PYTHON, "-B", "-S", "-c", code])
        self.assertEqual(receipt["cwd"], observed["cwd"])
        self.assertIsNone(receipt["target_pythonpath"])
        self.assertEqual(receipt["timeout_seconds"], 15)
        self.assertEqual(base64.b64decode(receipt["stdout_b64"], validate=True),
                         b"partial-out-\xff")
        self.assertEqual(base64.b64decode(receipt["stderr_b64"], validate=True),
                         b"partial-err-\xfe")
        self.assertNotIn("returncode", receipt)
        self.assertNotIn("normalized", failure)

    def test_timeout_preserves_unobserved_versus_empty_output(self):
        for value, expected in ((None, None), (b"", "")):
            with self.subTest(value=value):
                timeout = subprocess.TimeoutExpired([lab.PYTHON], 15,
                                                    output=value, stderr=value)
                with mock.patch.object(lab.subprocess, "run", side_effect=timeout):
                    with self.assertRaises(lab.ProbeTimeout) as result:
                        lab.execute(["-c", "pass"], self.base, lab.clean_env())
                self.assertEqual(result.exception.receipt["stdout_b64"], expected)
                self.assertEqual(result.exception.receipt["stderr_b64"], expected)

    def test_isolated_flag_does_not_imply_no_site(self):
        # Deliberately omit -S for this separate flag test, not the matrix.
        proc = subprocess.run(
            [lab.PYTHON, "-B", "-I", "-c",
             "import sys; print(sys.flags.no_site, sys.flags.no_user_site, "
             "sys.flags.ignore_environment, int(sys.flags.safe_path))"],
            cwd=self.base, env=lab.clean_env(), capture_output=True,
            text=True, timeout=15)
        self.assertEqual((proc.returncode, proc.stdout, proc.stderr),
                         (0, "0 1 1 1\n", ""))


if __name__ == "__main__":
    unittest.main(verbosity=2)
README.md(完整内容)
# Python 导入来源实验

要求:CPython 3.11+,标准文件布局,标准库即可。本文实际验证 Linux / CPython 3.12.14。

将 labprobe.py、run_matrix.py、test_matrix.py、expected.json 与本文件保存到同一目录。
在该目录运行(选择你已安装的 CPython;无需安装任何包):

    python -E -B -S run_matrix.py --output report.json
    python -E -B -S -m unittest -v test_matrix.py

report.json 必须尚不存在;再次运行请更换输出文件名。驱动使用当前解释器的同一绝对路径启动所有子进程,先清掉继承的 PYTHON*,再按案例注入 PYTHONPATH。
每个案例都新建进程并显式使用 -B -S。临时夹具位于系统临时目录,结束后清理。只有指定的报告留在磁盘上。
不读取用户项目,不安装依赖,不改全局环境或 site-packages。

固定预期有 11 项,由源码作者先写定。成功应输出 PASS 11 fixed cases;unittest 应输出 Ran 13 tests 和 OK。
PROJECT / INJECTED 均缺少 Decimal,探针预期退出 2;NO_ENTRY 预期退出 1。它们是被验证的失败样例,不是忽略错误。
报告保留完整 stdout/stderr、命令、cwd、sys.path 和来源。陌生来源、来源不一致、提前加载或额外 stderr 都使验收失败;不要为了通过而改 expected.json。
不支持的解释器、布局、超时或失败案例使驱动非零退出,并在报告写入失败原因。报告名已存在则直接报错,不覆盖原报告。
超时报告额外包含 timeout 对象:command、cwd、target_pythonpath、timeout_seconds,以及 stdout_b64 / stderr_b64。后两项保存 TimeoutExpired 已捕获的原始字节,用 base64.b64decode(value, validate=True) 还原;不是普通 stdout/stderr 字符串。null 表示异常没有提供该流的输出,空字符串表示零字节,不能混为一谈。
超时仍是 status=failed,不生成成功矩阵或伪造 returncode;该失败报告只保留本次超时的上下文,不包含此前完成的矩阵行。未刷新、尚未写出或未捕获的内容无法恢复。
新增的真实超时测试会额外等待约 15 秒,并核对含非 UTF-8 字节的两路输出;另外两项回归测试区分 null 和空字节,并确认二进制捕获仍保留正常报告的文本和换行约定。subprocess.run 终止并等待直接子进程,不保证管理任意后代进程或构成严格的总时限。

-I 含 -E、-P、-s,不含 -S。测试中的额外 -S 仅用于控制 site 初始化。它们不是运行不可信代码的安全沙箱。
find_spec 仅用于顶层 decimal;对点分名称调用它可能执行父包。
不覆盖 IDE、.pth、自定义 finder、editable install、包布局与已运行会话缓存。

以下 SHA-256 可核对复制后的字节。换行不同也会改变哈希;先核对是否完整复制,不要直接把不匹配视为功能故障。

b917de4d8fc74f1aa86d6f04b1212c01bc65e01042995ece03016a3dc99337c3  labprobe.py
2c30af1028c2e30db73b593d4ab4ed7584f9bd06610874b512677e101833df5a  run_matrix.py
b7959e3860dc485ac45befa5b1a3e02a0491ddda2ecfff11634d57b995c704c9  expected.json
854ae0f52f0d7d12d695c081a0462b9d51ae523b455d0c0fc79fb2eaa6993594  test_matrix.py
d8abc63856bfed08b5781af34bab28f2e0def3180bc33c8f082ccaeaca97c5cc  README.md

13 项 unittest 中包含两个独立实验根目录,第二次特意给驱动传入污染过的继承环境:无效 PYTHONHOME、额外 PYTHONPATH、PYTHONSAFEPATH 等。两次仍必须等于固定预期。未知来源和提前加载使用真实子进程制造;另几项则向校验器输入受控的损坏报告,检查多余输出、来源不一致、错误退出码、额外 stderr 和冒充入口失败都会被拒绝。

超时也要留下已经捕获的诊断。此前驱动只写异常摘要,真实超时前已经刷新到 stdout/stderr 的内容会丢失;现在失败报告额外保存 timeout 对象,包含 command、cwd、target_pythonpath、timeout_seconds 与 stdout_b64 / stderr_b64。这里显式使用二进制捕获,只有进程正常结束后,才按原有默认编码和通用换行规则转成普通报告文本;超时的部分输出则使用 Base64 保留原始字节,避免非 UTF-8 字节或半个多字节字符引发二次解码错误。子进程超时和输出字段的语义见 TimeoutExpired 官方说明。可以用 base64.b64decode(value, validate=True) 还原非 null 字段;null 表示异常没有提供该流的输出,空字符串才表示零字节。

新增三项回归:一项实际启动子进程,向两路输出写入并刷新含非 UTF-8 字节的内容,再触发原有的 15 秒超时,核对失败报告逐字节保留输出;另一项用受控异常区分未观察到输出与空字节;第三项核对二进制捕获与正常报告的文本、换行转换保持原约定。完整测试会因此多等待约 15 秒。超时仍向外抛出异常、使 CLI 非零退出,不生成成功矩阵,也不伪造 returncode。失败报告只保留当次超时上下文,不包含此前已完成的矩阵行;未刷新、尚未写出或未捕获的输出无法恢复。subprocess.run 会终止并等待直接子进程,但这不是管理任意后代进程的方案,也不是严格的总时限保证。

若失败,先保留报告与完整异常。超时为每个子进程 15 秒;异常布局、超时或不符预期都会让驱动非零退出。不要修改 expected.json 来适配未知来源,也不要把失败命令改成忽略错误。若系统使用另一种标准库布局,需要重新设计并审查来源识别规则,本例不会自动放宽。

7. 回到真实项目,按已经查明的来源修复

先保留原来的解释器路径、启动命令和工作目录,用独立进程复现。若导入指向项目自有的 decimal.py,就给它换成能表达用途且不冲突的文件名,并查找所有相关引用;若来源是某个注入目录,查看实际启动脚本、IDE 配置或任务配置里是谁设置了 PYTHONPATH,再只修正已确认多余的项。

不要以删除整个虚拟环境或重装全部依赖开局,也不要随手清掉全局环境变量来掩盖来源。在一个新进程中确认导入来自预期文件,再用项目原本的入口跑回归测试。若文件已改名但常驻会话仍异常,重启该会话再验收:导入缓存文档说明 sys.modules 会先被检查;删除缓存字典的一项也不保证其他对象没有保留旧引用。

从已查明的项目来源或环境来源分别做最小修复,最后回到原启动方式重新验证
图 3:修复的依据是实际来源。未知来源需要继续查证,不能被规范化为预期答案。

下面的任务说明可用于让 Codex 排查一个你授权它修改的项目。它是可复用文本,本教程未在真实用户仓库或模型会话中执行。

先阅读项目启动入口和现有测试,复现当前 Python 导入错误。
记录实际解释器、完整启动命令、cwd、相关 PYTHONPATH、sys.path 与模块来源。
先检查目标模块是否已在 sys.modules;不要把旧会话缓存当作新启动结果。

若确认项目同名文件遮蔽标准库,提出最小改名与引用更新。
若来源来自环境路径,指出设置它的具体启动配置,再提出局部修改。
不要先重装全部依赖,不要改全局 shell 配置,不要执行不可信模块。

用新进程对比修改前后的来源与失败信息,运行项目原有测试。
保留无法解释的来源;-P/-I 导致入口不可见时明确报告。
提交实际改动、验证结果及未覆盖范围,不把开关对照通过当作项目修复完成。

验收时至少能回答:原来导入了哪份文件,为什么它先被找到,修改后来源是什么,以及原项目入口是否仍可运行。只有最后一个问题也验证过,实验里的结论才真正回到了项目上。

8. 资料与本次实际验证范围

资料核对与创作日期:2026-10-13。Linux / CPython 3.12.14 上,11 项子进程矩阵和 10 项 unittest 通过;完整示例包在另一临时目录解压后重新运行通过。这里没有 Windows、macOS、其他 Python 版本或真实模型效果测试,也没有性能基准。

三张原创 SVG 已离线渲染查看,使用自然宽高比例。完整浏览器页面、线上样式和窄屏表现尚未实测;已检查 HTML 结构、嵌入源码与文件哈希、图片数量及尺寸。这些图是实验结构示意,不是真实产品截图。

2026-10-20 复核修正了超时丢失已捕获输出的问题:11 项固定矩阵、13 项 unittest 与独立目录复测通过,另用实际 CLI 验证超时仍非零退出且两路字节可还原。原有图示及不覆盖范围不变;未测试任意进程树清理或严格总时限。

-E 为什么没有修好脚本旁边的 decimal.py?

-E 忽略 PYTHON* 环境变量,普通脚本的目录仍可自动前置。本例中它继续导入 PROJECT。

-P 能彻底排除其他目录的同名模块吗?

不能。它不忽略 PYTHONPATH,本例的 -P 加注入路径实际导入 INJECTED。

-I 是否也包含 -S?

不包含。-I 包含 -E、-P、-s;矩阵显式加 -S 是单独的实验控制。

为什么 -P -m labprobe 连探针都没启动?

-m 要从模块搜索路径找到入口,移除当前目录后入口可能不可见;直接给出脚本路径是不同启动方式。

改名后原来的交互会话为什么可能仍异常?

已加载模块可能留在 sys.modules,其他对象也可能保留引用。本文每项使用新进程,不承诺改名能更新已运行会话。