🏠 总目录📚 本教程 08 · 环境、依赖和 import ← →
📑 本页目录(点开跳转)

08 · 环境、依赖和 import

⏱ 118 分钟 | ⭐ 「我这能跑,你那不能跑」不是玄学 —— 它有确定的几个来源,每一个都能用一行命令查出来


🎯 一句话

import X 不是「加载库 X」,是「按一个有序列表挨个找名字叫 X 的东西,找到第一个就用,然后把它缓存起来」。 虚拟环境、pip install、-m、__pycache__ —— 这一章的每一个坑,都是这句话里某一个词在起作用。


🧩 一、import X 到底做了哪三步

按顺序,缺一不可:

步 干什么 出问题的样子
① 查 sys.modules(本次进程已经导入过的缓存) 改了源码但行为没变 —— 因为根本没重新读
② 沿 sys.path 从前往后找,用第一个匹配的 ModuleNotFoundError,或者更糟:找到了错的那个
③ 执行模块体,把结果塞进 sys.modules 循环 import、模块级副作用

⚠️ ModuleNotFoundError 只告诉你「没找到」,从不告诉你「在哪找过」。 自己打出来:

# whereis.py —— import 失败时,把「解释器到底在哪找过」打出来
import importlib.util
import sys

for name in ("json", "yaml", "pyyaml", "pandas"):
    spec = importlib.util.find_spec(name)
    print(f"{name:8s} → {spec.origin if spec else '(没找到)'}")

print("\n它按这个顺序找(sys.path):")
for i, p in enumerate(sys.path):
    print(f"  [{i}] {p or '(空串 = 当前目录)'}")

本机实跑输出(路径已缩短):

信息关系

json→<python>\Lib\json\__init__.py
yaml→<python>\Lib\site-packages\yaml\__init__.py
pyyaml→(没找到)
pandas→(没找到)
它按这个顺序找(sys.path):
[0] <当前脚本所在目录>
[1] <python>\python313.zip
[2] <python>\DLLs
[3] <python>\Lib
[4] <python>
[5] <python>\Lib\site-packages

⭐ 这张表里有两条信息值钱:

  1. [0] 排在标准库前面 —— 下一节整节都在讲这一条的后果。
  2. pyyaml 找不到,yaml 找得到 —— 💀 pip install 的名字和 import 的名字是两套东西: pip install pyyaml → import yaml;pip install scikit-learn → import sklearn; pip install opencv-python → import cv2;pip install Pillow → import PIL。 ⚠️ 「我明明装了啊」十次里有一次就是这个,去包的 PyPI 页面上抄那行 import,别猜。

🚦 sys.path 是怎么来的

位置 谁放进去的 你能怎么动
[0] ⭐ 入口决定:python x.py → x.py 所在目录;python -m pkg → 当前工作目录;python -c / 交互式 → ''(当前目录) 见第三节
中间几条 PYTHONPATH 环境变量(有就插在这) 临时救急能用,⚠️ 长期用它 = 把 bug 藏进环境变量
标准库那几条 解释器自己按 sys.prefix 推 见第四节
site-packages site 模块按 sys.prefix 推 见第四节

🚦 第 ① 步的缓存:模块体一个进程里只跑一次

# modcache.py —— import 两次,模块体只跑一次
import os
import subprocess
import sys
import tempfile

d = tempfile.mkdtemp()
with open(os.path.join(d, "heavy.py"), "w", encoding="utf-8") as fh:
    fh.write("print('  [heavy.py 的模块体在跑]')\nX = 1\n")
with open(os.path.join(d, "main.py"), "w", encoding="utf-8") as fh:
    fh.write("import importlib, sys\n"
             "print('第一次 import:')\n"
             "import heavy\n"
             "print('第二次 import:')\n"
             "import heavy\n"
             "print('  heavy in sys.modules ->', 'heavy' in sys.modules)\n"
             "print('reload:')\n"
             "importlib.reload(heavy)\n")
r = subprocess.run([sys.executable, "main.py"], cwd=d,
                   capture_output=True, text=True, encoding="utf-8")
print((r.stdout + r.stderr).strip())

实跑输出:

操作步骤

第一次 import:
[heavy.py 的模块体在跑]
第二次 import:
heavy in sys.modules -> True
reload:
[heavy.py 的模块体在跑]

⭐ 第二次 import heavy 什么都没打 —— 它只是从 sys.modules 取了个引用出来。 这就是为什么在 Jupyter / IPython 里改了 .py 再 import 一点变化都没有, 而重启内核就好了。⚠️ importlib.reload() 能重跑模块体,但已经 from heavy import X 绑出去的名字不会跟着变 (那是第 01 章讲的「名字绑到对象」)—— 所以 reload 只能救一半,能重启就重启。

💡 反过来说:模块顶层的代码是「被 import 就会跑」的。在模块顶层连数据库、读大文件、 print 一堆东西,任何人 import 你都得付这个代价。⭐ 顶层只放定义,副作用放进函数。


🧯 二、[0] 排在标准库前面:你自己写的 random.py 会顶掉标准库

# shadow.py —— 自己写的 random.py 会顶掉标准库
import os
import subprocess
import sys
import tempfile

d = tempfile.mkdtemp()
with open(os.path.join(d, "random.py"), "w", encoding="utf-8") as fh:
    fh.write("MY_OWN = 1\n")                       # 我自己的小工具
with open(os.path.join(d, "main.py"), "w", encoding="utf-8") as fh:
    fh.write("import random\n"
             "print('random.__file__ =', random.__file__)\n"
             "print(random.randint(1, 6))\n")

r = subprocess.run([sys.executable, "main.py"], cwd=d,
                   capture_output=True, text=True, encoding="utf-8")
print((r.stdout + r.stderr).strip())
print("退出码 =", r.returncode)

实跑输出(路径已缩短):

对照

random.__file__ = <tmp>\random.py

Traceback (most recent call last):

File "<tmp>\main.py", line 3, in <module>

print(random.randint(1, 6))

^^^^^^^^^^^^^^

AttributeError: module 'random' has no attribute 'randint' (consider renaming

'<tmp>\random.py' since it has the same name as the standard library module

named 'random' and prevents importing that standard library module)

退出码 = 1

⭐ 3.11 之后这条报错自带了提示(consider renaming ...),是白送的诊断,别扫过去。 ⚠️ 但它只在「名字撞了且属性缺了」时才出现。真正难查的是属性没缺的情况 —— 你写的 json.py 里恰好也有个 loads,行为不同但不报错,症状是数据莫名其妙。

💀 高发名单(在数据/ML 目录里格外常见): random.py json.py types.py token.py code.py email.py select.py string.py queue.py copy.py logging.py test.py parser.py platform.py

⭐ 一秒确认:python -c "import 那个名字 as m; print(m.__file__)"。指到你自己的目录就是撞了。

🚦 同一类的另一个形态:循环 import

# circular.py —— 循环 import 的报错长什么样,以及为什么换个写法就好了
import os
import subprocess
import sys
import tempfile

d = tempfile.mkdtemp()
files = {
    "models.py":  "from db import save\n\nclass User:\n    pass\n",
    "db.py":      "from models import User\n\ndef save(u):\n    return isinstance(u, User)\n",
    "main.py":    "import models\nprint('导入成功')\n",
    # ⭐ 换个写法:把 import 挪进函数体
    "models2.py": "from db2 import save\n\nclass User:\n    pass\n",
    "db2.py":     "def save(u):\n    from models2 import User\n    return isinstance(u, User)\n",
    "main2.py":   "import models2\nprint('导入成功')\n",
}
for name, body in files.items():
    with open(os.path.join(d, name), "w", encoding="utf-8") as fh:
        fh.write(body)

for entry in ("main.py", "main2.py"):
    r = subprocess.run([sys.executable, entry], cwd=d,
                       capture_output=True, text=True, encoding="utf-8")
    print(f"--- python {entry}")
    print((r.stdout + r.stderr).strip().replace(d, "<tmp>"))

实跑输出(截取关键行):

对照

--- python main.py

File "<tmp>\models.py", line 1, in <module>

from db import save

File "<tmp>\db.py", line 1, in <module>

from models import User

ImportError: cannot import name 'User' from 'models'

(3.13 同一行还跟着半句 consider renaming ' · models.py' —— 它猜你和标准库重名了,这里其实是循环)

--- python main2.py

导入成功

⭐ 机制就是第 ③ 步:models 的模块体跑到第一行就去导 db,db 又回头要 models.User —— 而此刻 models 在 sys.modules 里但只执行了半行,User 那个 class 语句还没轮到, 所以「有这个模块,但没有这个名字」。

⭐ 三种修法,按优先级:

修法 什么时候用
⭐ 把公共的东西抽到第三个模块(entities.py) 首选。循环 import 通常是分层错了的信号,不是技术问题
把 import 挪进函数体 上面 db2.py 的写法。⚠️ 能救急,但等于把依赖藏起来了
import models 而不是 from models import User,用时写 models.User 只要模块体执行期间不取属性就行

🛑 读到这里可以停 —— 已经读了约 29 分钟。 后面还有(约 21 分钟):python x.py 和 python -m x 不是一回事 · venv 只做了一件事:换掉 sys.prefix 回来的时候不用重读,直接从下一节接着看就行。


🧩 三、python x.py 和 python -m x 不是一回事

# dashm.py —— python pkg/tool.py 和 python -m pkg.tool 不是一回事
import os
import subprocess
import sys
import tempfile

root = tempfile.mkdtemp()
pkg = os.path.join(root, "pkg")
os.makedirs(pkg)
open(os.path.join(pkg, "__init__.py"), "w").close()
with open(os.path.join(pkg, "conf.py"), "w", encoding="utf-8") as fh:
    fh.write("NAME = 'prod'\n")
with open(os.path.join(pkg, "tool.py"), "w", encoding="utf-8") as fh:
    fh.write("import sys\n"
             "print('  sys.path[0] =', sys.path[0])\n"
             "from pkg.conf import NAME\n"          # ⭐ 就是这一行的成败不同
             "print('  NAME =', NAME)\n")

for argv in (["pkg/tool.py"], ["-m", "pkg.tool"]):
    r = subprocess.run([sys.executable, *argv], cwd=root,
                       capture_output=True, text=True, encoding="utf-8")
    print(f"--- python {' '.join(argv)}   (cwd = 项目根)")
    print((r.stdout + r.stderr).strip().replace(root, "<root>"))

实跑输出:

对照

--- python pkg/tool.py (cwd = 项目根)

sys.path[0] = <root>\pkg

Traceback (most recent call last):

File "<root>\pkg\tool.py", line 3, in <module>

from pkg.conf import NAME

ModuleNotFoundError: No module named 'pkg'

--- python -m pkg.tool (cwd = 项目根)

sys.path[0] = <root>

NAME = prod

⭐ 同一个文件、同一个目录、同一个解释器,一个崩一个不崩 —— 差别只有 sys.path[0]:

启动方式 sys.path[0] 后果
python pkg/tool.py 脚本所在目录(<root>/pkg) 项目根不在路径上 → 看不见自己的包
python -m pkg.tool 当前工作目录(<root>) ⭐ 包内绝对导入正常工作

💡 所以这几条经验规则的来历就清楚了:


🧩 四、venv 只做了一件事:换掉 sys.prefix

# venv_probe.py —— venv 到底改了什么:建一个,然后拿它和外面的解释器对比
import os
import subprocess
import sys
import tempfile
import venv

root = tempfile.mkdtemp()
env_dir = os.path.join(root, ".venv")
venv.EnvBuilder(with_pip=False).create(env_dir)          # ⭐ 不装 pip,快很多

py = os.path.join(env_dir, "Scripts", "python.exe")      # Windows
if not os.path.exists(py):
    py = os.path.join(env_dir, "bin", "python")          # Linux / macOS

CODE = ("import sys, site;"
        "print('  sys.prefix      =', sys.prefix);"
        "print('  sys.base_prefix =', sys.base_prefix);"
        "print('  site-packages   =', site.getsitepackages()[-1])")

for label, exe in (("外面的解释器", sys.executable), ("venv 里的解释器", py)):
    r = subprocess.run([exe, "-c", CODE], capture_output=True, text=True,
                       encoding="utf-8")
    print(label)
    print(r.stdout.rstrip().replace(root, "<tmp>"))

print("\n.venv/pyvenv.cfg 的内容:")
with open(os.path.join(env_dir, "pyvenv.cfg"), encoding="utf-8") as fh:
    print(fh.read().strip())

实跑输出(路径已缩短):

对照

外面的解释器

sys.prefix = <python>

sys.base_prefix = <python>

site-packages = <python>\Lib\site-packages

venv 里的解释器

sys.prefix = <tmp>\.venv

sys.base_prefix = <python>

site-packages = <tmp>\.venv\Lib\site-packages

.venv/pyvenv.cfg 的内容:

home = <python>

include-system-site-packages = false

command = <python>\python.exe -m venv <tmp>\.venv ← 3.13 起会记下建它的命令

version = 3.13.14

executable = <python>\python.exe

⭐ 全部机密都在这几行里:

  1. sys.base_prefix 没变 —— venv 没有复制一份 Python,标准库还是原来那份, 靠 pyvenv.cfg 里的 home = 指回去。所以一个 venv 通常只有几 MB。
  2. sys.prefix 变了 —— 而 site-packages 的位置是从 sys.prefix 推出来的,于是第三方包换了地方。
  3. include-system-site-packages = false —— 全局装的包看不见了。

⭐ 所以「激活 venv」这个动作本身没有任何魔法:activate 脚本做的就是 把 .venv/Scripts(或 bin)插到 PATH 最前面,让你敲 python 时命中的是里面那个。 直接写全路径 .venv/Scripts/python.exe x.py 效果完全一样,不用激活。 ⚠️ 这也解释了一个常见困惑:在 VS Code / Jupyter 里「激活了 venv」但还是 import 不到 —— 那些工具是自己按配置挑解释器的,根本不看你终端里的 PATH。

💡 一行确认自己在不在 venv 里:

# in_venv.py —— 两行判断当前解释器属于哪个环境
import sys

print("解释器:", sys.executable)
print("在 venv 里吗:", sys.prefix != sys.base_prefix)

⚠️ conda 是另一套东西,不要混用:venv 只管 Python 包;conda 的环境里还装 非 Python 的二进制依赖(CUDA 运行库、MKL、libstdc++、编译器)。

⭐ 「pip 装完 import 时报 DLL load failed / libXXX.so: cannot open shared object file」 已经不是 Python 层的问题了 —— 那是二进制兼容, 另一半在 框架底下是 C++ · 04 · ABI 与二进制兼容。

本章只负责到「Python 找不找得到这个包」为止。


🛑 读到这里可以停 —— 前半章讲完了(约 48 分钟)。 后半章还有:声明的和装上的不是一回事 · __pycache__ 认的不是内容,是 mtime 和字节数 · 本项目自己的事故:读新的、写旧的,两边都不报错 · 「我这能跑,你那不能跑」的排查顺序 回来的时候不用重读,直接从下一节接着看就行。


🧩 五、声明的和装上的不是一回事

🚦 一行 requirements 会拉进来多少个包

# deps.py —— 你写了 1 行 requirements,实际装进来几个包
import importlib.metadata as md


def closure(name, seen=None):
    seen = seen if seen is not None else set()
    key = name.lower().replace("_", "-")
    if key in seen:
        return seen
    seen.add(key)
    try:
        reqs = md.requires(key) or []
    except md.PackageNotFoundError:
        return seen
    for r in reqs:
        if ";" in r and "extra ==" in r:          # ⭐ 可选依赖,默认不装
            continue
        dep = r.split(";")[0].split("[")[0].strip()
        for sep in ("==", ">=", "<=", "~=", "!=", ">", "<", " ", "("):
            dep = dep.split(sep)[0]
        if dep:
            closure(dep, seen)
    return seen


for top in ("requests", "torch"):
    got = closure(top)
    print(f"requirements.txt 只写了 `{top}` →  实际拉进来 {len(got)} 个包")
    print("   ", ", ".join(sorted(got)))

本机实跑输出(数字取决于你机器上装的版本):

结果对照

requirements.txt 只写了 `requests`→实际拉进来 5 个包
certifi, charset-normalizer, idna, requests, urllib3
requirements.txt 只写了 `torch`→实际拉进来 17 个包
cuda-bindings, cuda-toolkit, filelock, fsspec, jinja2, markupsafe, mpmath,
networkx, nvidia-cudnn-cu13, nvidia-cusparselt-cu13, nvidia-nccl-cu13,
nvidia-nvshmem-cu13, setuptools, sympy, torch, triton, typing-extensions

⭐ requirements.txt 写的是「我想要什么」,装上的是「解析器算出来的一整棵树」。 你只钉了树根,其余 4 个 / 16 个的版本是「今天 PyPI 上最新的能装上的」 —— 明天再装就可能不是同一批。这就是「上周还好好的」的完整机制。

文件 是什么 谁读它
pyproject.toml 的 dependencies ⭐ 意图:我直接用到的包,范围尽量宽(requests>=2.28) 人 + 打包工具
requirements.txt(手写的) 同上,只是格式老一点 人
⭐ 锁文件(uv.lock / poetry.lock / pip-compile 出的 requirements.txt) 结果:每一个包(含传递依赖)的精确版本 + 哈希 机器

⭐ 规则:意图文件手写并提交,锁文件生成并提交,部署只认锁文件。 ⚠️ pip freeze > requirements.txt 不是锁文件 —— 它把「意图」和「结果」糊成一坨, 下次你就分不清 idna==3.7 到底是你要的还是被拖进来的,也就再不敢升级任何东西。

💡 模型上线之后 · 17 章把「环境快照」列进可复现清单、 把 sklearn 从 1.2 悄悄升到 1.4 当成事故来讲 —— ⭐ 那条事故的技术根因就在这张表里: 镜像里写的是意图不是锁,重建时解析器给了个更新的版本。

🚦 pip install -e . 装进去的是什么

# editable.py —— pip install -e . 装进去的到底是什么
import os
import subprocess
import sys
import tempfile
import venv

root = tempfile.mkdtemp()
src = os.path.join(root, "proj", "mypkg")
os.makedirs(src)
with open(os.path.join(src, "__init__.py"), "w", encoding="utf-8") as fh:
    fh.write("VERSION = 'v1'\n")
with open(os.path.join(root, "proj", "pyproject.toml"), "w", encoding="utf-8") as fh:
    fh.write('[project]\nname = "mypkg"\nversion = "0.1.0"\n'
             '[build-system]\nrequires = ["setuptools"]\n'
             'build-backend = "setuptools.build_meta"\n')

env_dir = os.path.join(root, ".venv")
venv.EnvBuilder(with_pip=True).create(env_dir)
py = os.path.join(env_dir, "Scripts", "python.exe")
if not os.path.exists(py):
    py = os.path.join(env_dir, "bin", "python")

subprocess.run([py, "-m", "pip", "install", "-q", "-e", "."],
               cwd=os.path.join(root, "proj"), capture_output=True, text=True)

r = subprocess.run([py, "-c", "import mypkg; print(mypkg.__file__); print(mypkg.VERSION)"],
                   capture_output=True, text=True, encoding="utf-8")
print("装完之后 import 到的是:")
print(" ", r.stdout.strip().replace(root, "<tmp>"))

sp = [p for p in (os.path.join(env_dir, "Lib", "site-packages"),
                  os.path.join(env_dir, "lib")) if os.path.isdir(p)][0]
print("\nsite-packages 里和 mypkg 有关的条目:")
for n in sorted(os.listdir(sp)):
    if "mypkg" in n or "editable" in n.lower():
        print("  ", n)

with open(os.path.join(src, "__init__.py"), "w", encoding="utf-8") as fh:
    fh.write("VERSION = 'v2-改过没重装'\n")          # ⭐ 改源码,不重装
r = subprocess.run([py, "-c", "import mypkg; print(mypkg.VERSION)"],
                   capture_output=True, text=True, encoding="utf-8")
print("\n改完源码不重装,再 import:", r.stdout.strip())

实跑输出(路径已缩短):

对照

装完之后 import 到的是:

<tmp>\proj\mypkg\__init__.py

v1

site-packages 里和 mypkg 有关的条目:

__editable__.mypkg-0.1.0.pth

__editable___mypkg_0_1_0_finder.py

mypkg-0.1.0.dist-info

改完源码不重装,再 import: v2-改过没重装

⭐ -e 没有复制任何代码进 site-packages,只放了一个 .pth 和一个 finder —— 它们做的事就是把你的源码目录接进 sys.path 的查找逻辑。所以改源码立刻生效。

⭐ 这是「我的项目自己的模块 import 不到」的正解。三种常见做法的对比:

做法 问题
sys.path.append("..") 写在文件开头 💀 依赖从哪启动,换个目录就崩;且每个文件都得写一遍
设 PYTHONPATH 环境变量 ⚠️ 环境的一部分,不进版本库,队友和 CI 都拿不到
⭐ 项目根放 pyproject.toml,一次 python -m pip install -e . 声明写在代码库里,谁 clone 谁一样

⚠️ -e 只对纯 Python 立即生效。包里有 C 扩展时,改了 .pyx / .cpp 仍然要重新编译。


🛑 第二个休息点 —— 中段讲完了(约 21 分钟)。 最后一段还有:__pycache__ 认的不是内容,是 mtime 和字节数 · 本项目自己的事故:读新的、写旧的,两边都不报错 · 「我这能跑,你那不能跑」的排查顺序 这一章确实长,分三次读完全没问题 —— 回来直接从下一节接着看。


🧩 六、__pycache__ 认的不是内容,是 mtime 和字节数

# pycache.py —— .pyc 失效认的是「源文件 mtime(秒)+ 字节数」,不认内容
import os
import subprocess
import sys
import tempfile
import time

d = tempfile.mkdtemp()
mod, main = os.path.join(d, "conf.py"), os.path.join(d, "main.py")
with open(main, "w", encoding="utf-8") as fh:
    fh.write("import conf\nprint('VALUE =', conf.VALUE)\n")


def write(text):
    with open(mod, "w", encoding="utf-8") as fh:
        fh.write(text)


def run(tag):
    r = subprocess.run([sys.executable, "main.py"], cwd=d,
                       capture_output=True, text=True, encoding="utf-8")
    print(f"{tag} → {r.stdout.strip()}")


write("VALUE = 1\n")
run("① 首次导入            ")
print("   __pycache__ 里有:", os.listdir(os.path.join(d, "__pycache__")))
st = os.stat(mod)

write("VALUE = 2\n")                                  # 内容改了,字节数没变
os.utime(mod, (st.st_atime, st.st_mtime))             # ⭐ mtime 也还原
run("② 改内容 + 还原 mtime")

time.sleep(1.2)                                       # ⭐ 让 mtime 真的走过一秒
write("VALUE = 2\n")
run("③ 重写一次(mtime 变新)")

实跑输出:

流程图

① 首次导入→VALUE = 1
__pycache__ 里有: ['conf.cpython-313.pyc']
② 改内容 + 还原 mtime→VALUE = 1
③ 重写一次(mtime 变新)→VALUE = 2

⭐ 第 ② 步是重点:源码明明是 VALUE = 2,跑出来是 1。 因为 .pyc 的头部记的是 源文件的 mtime(取整到秒)+ 字节数,两者都对得上就直接用缓存,根本不读源码。

⚠️ 平时你不会遇到它(改代码总会让 mtime 变新),但这几种情况会撞上:

💀 症状是最坏的那种:代码是新的、行为是旧的、没有任何报错。

⭐ 两个开关:python -B x.py 或 PYTHONDONTWRITEBYTECODE=1 不写 .pyc; 怀疑撞上了就把 __pycache__ 目录全删掉再跑一次。 💡 顺带:.pyc 名字里带 cpython-313 —— 换个小版本的解释器会用不同的缓存文件,不会互相污染。


🛑 读到这里可以停 —— 已经读了约 79 分钟。 最后一段还有(约 36 分钟):本项目自己的事故:读新的、写旧的,两边都不报错 · 「我这能跑,你那不能跑」的排查顺序 · 检查点与走神救援 回来的时候不用重读,直接从下一节接着看就行。


💀 七、本项目自己的事故:读新的、写旧的,两边都不报错

这个站的构建脚本曾经这么写:

# 🧩 骨架:`os` 来自你自己的代码,这一段只看写法
ROOT    = r"C:\desktop\claude"                          # ← 硬编码,指向【老位置】
SCRATCH = os.path.dirname(os.path.abspath(__file__))    # ← 跟着脚本走,指向【新位置】

项目搬进 git 仓库之后,构建读的是仓库里的新 md,却把 HTML 写进了老目录。

⚠️ 为什么难发现:构建不报错,还照常打印「内联 SVG 合计 197 张」这种一切正常的收尾; 全套校验脚本全绿,因为它们的 ROOT 也硬编码成老目录,查的是同一份老产物。 实况是:一整轮改完 md、构建、跑完 8 个校验脚本全部通过,回头一看 107 个文件还是旧文本。

⭐ 正解只有一句话:路径要么来自 __file__,要么来自参数,绝不来自 os.getcwd(),更不能硬编码。

# roots.py —— 同一个脚本,从两个不同目录启动,两种路径写法的差别
import os
import subprocess
import sys
import tempfile

root = tempfile.mkdtemp()
tools = os.path.join(root, "tools")
elsewhere = os.path.join(root, "elsewhere")
os.makedirs(tools)
os.makedirs(elsewhere)
with open(os.path.join(tools, "build.py"), "w", encoding="utf-8") as fh:
    fh.write("import os\n"
             "print('  cwd                    =', os.getcwd())\n"
             "print('  abspath(\"out\")   [cwd] =', os.path.abspath('out'))\n"
             "here = os.path.dirname(os.path.abspath(__file__))\n"
             "print('  __file__ 推出来  [脚本] =', os.path.join(here, 'out'))\n")

for cwd in (tools, elsewhere):
    r = subprocess.run([sys.executable, os.path.join(tools, "build.py")],
                       cwd=cwd, capture_output=True, text=True, encoding="utf-8")
    print(f"--- 在 {os.path.basename(cwd)}/ 下启动")
    print(r.stdout.rstrip().replace(root, "<root>"))

实跑输出:

对照

--- 在 tools/ 下启动

cwd = <root>\tools

abspath("out") [cwd] = <root>\tools\out

__file__ 推出来 [脚本] = <root>\tools\out

--- 在 elsewhere/ 下启动

cwd = <root>\elsewhere

abspath("out") [cwd] = <root>\elsewhere\out

__file__ 推出来 [脚本] = <root>\tools\out

⭐ 两次启动,__file__ 推出来的那一行完全相同,cwd 推出来的那一行变了。 「在编辑器里点运行好使、在终端里 cd 到别处跑就找不到文件」,全部是这一行的差别。

现在全站脚本统一写成:

import os

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))   # ⭐ 「我上一级的上一级」
print("ROOT =", ROOT)

⚠️ 这条和 IDE 的关系:大多数编辑器的「运行」按钮默认把 cwd 设成项目根,而终端里 cwd 是你 cd 到的地方 —— 所以 cwd 相关的 bug 在编辑器里几乎永远不会复现。


📋 八、「我这能跑,你那不能跑」的排查顺序

⭐ 照这个顺序问,五步之内能定位到具体是哪一类:

步 命令 排除掉什么
① python -c "import sys; print(sys.executable)" ⭐ 两边是不是同一个解释器。八成的问题在这一步就结束了
② python -c "import sys; print(sys.prefix != sys.base_prefix)" 有没有真的在 venv 里(激活了 ≠ 你的工具在用它)
③ python -c "import 那个包 as m; print(m.__file__)" 是没装,还是装了但导到了另一个同名的东西
④ python -m pip list 版本对不对。⚠️ 一定带 -m,否则你查的可能是别的环境
⑤ python -c "import sys; print(*sys.path, sep=chr(10))" 路径顺序、有没有莫名其妙的 PYTHONPATH

💡 两个附加动作:怀疑缓存就删 __pycache__;import 慢就 python -X importtime -c "import 那个包" (它按毫秒列出每个子模块的耗时,最后一行是总账)。

⚠️ 这五步之外还有一整类问题不归 Python 管:ImportError: DLL load failed、 GLIBC_2.32 not found、undefined symbol: _ZN3c10...、ldd 找不到库 —— 那些是二进制层的,在 框架底下是 C++ · 04 · ABI 与二进制兼容。 ⭐ 分界线很清楚:报错里出现文件路径是本章;报错里出现符号名 / 版本号 / .so / .dll 是那一章。


🔗 这一章连到哪里

相关的地方 为什么
07 · 异常、traceback 和调试 那一章讲怎么读 traceback。ModuleNotFoundError 是特例:traceback 只说「找不到」,不说在哪找过 —— 本章第一节补上这一半
01 · 名字、对象和绑定 importlib.reload() 只能救一半,是因为 from m import X 已经把名字绑到了旧对象上。那一章讲这个绑定模型
框架底下是 C++ · 04 · ABI 与二进制兼容 ⭐ 同一个问题的另一半。本章负责到「Python 找不找得到这个包」为止;DLL load failed / undefined symbol / GLIBC / manylinux 归那一章
模型上线之后 · 17 · 版本回溯与可复现 它把「环境快照」列进可复现清单,并假定环境能重建。本章第五节讲的正是那个假定成不成立:意图文件和锁文件的差别
AI全栈 · 14 · 容器化与部署 它的 Dockerfile 里有 venv 和 .dockerignore 排除 __pycache__,但没解释为什么。本章第四、六节是那两行的说明书
09 · 容器、哈希与顺序 环境一样了,结果还是不一样?下一章讲同一个环境里两次运行也可能不同的那个来源
NumPy 与向量化思维 · 10 · 随机数、种子与可复现 可复现的第三层:随机数 API 本身。本章管环境,那章管种子

✅ 检查点

  1. import X 分哪三步?「改了源码但行为没变」最可能卡在第几步?
  2. sys.path[0] 在 python x.py、python -m pkg.x、python -c 三种启动方式下分别是什么?
  3. 为什么 python pkg/tool.py 会 ModuleNotFoundError: No module named 'pkg',而 python -m pkg.tool 不会?
  4. 目录里放一个自己写的 random.py 会发生什么?怎么一行确认「导到的是不是我想要的那个」?
  5. pip install pyyaml 之后 import pyyaml 为什么找不到?
  6. venv 到底改了什么?sys.prefix 和 sys.base_prefix 在 venv 里外分别是什么关系?
  7. 「激活 venv」这个动作做了什么?为什么 VS Code 里「激活了」还是可能 import 不到?
  8. requirements.txt 和锁文件的分工是什么?为什么 pip freeze > requirements.txt 不算锁文件?
  9. pip install -e . 往 site-packages 里放了什么?为什么改源码不用重装?
  10. .pyc 的失效判据是什么?举两种会让它「代码是新的、行为是旧的」的场景。
  11. 本项目那次「读新的、写旧的」事故,根因是哪一行?正解怎么写?为什么全套校验脚本都是绿的?
  12. 排查「我这能跑你那不能跑」,第一条命令该敲什么?哪一类报错不该在这一章找答案?
👀 答案
  1. ① 查 sys.modules 缓存 → ② 沿 sys.path 从前往后找,用第一个匹配的 → ③ 执行模块体。「改了源码但行为没变」卡在第 ①(进程内已经缓存了,压根没重读)—— 实跑里第二次 import heavy 一个字都没打。
  2. python x.py → 脚本所在目录;python -m pkg.x → 当前工作目录;python -c / 交互式 → ''(当前目录),实跑打出来就是空串。
  3. 因为 sys.path[0] 不同:前者是 <root>/pkg,项目根不在路径上,所以 from pkg.conf import NAME 找不到 pkg;后者是 <root>,能看见自己的包。同一个文件同一个解释器,一个崩一个不崩。
  4. 它会顶掉标准库的 random,因为 sys.path[0] 排在标准库前面。实跑报的是 AttributeError: module 'random' has no attribute 'randint',3.11 后还附带 consider renaming ... 的提示。⚠️ 真正难查的是属性没缺的情况(你的 json.py 里恰好也有 loads)—— 那时不报错,只是结果不对。一行确认:python -c "import 名字 as m; print(m.__file__)"。
  5. 装的名字和 import 的名字是两套东西:pip install pyyaml 装出来的模块叫 yaml。实跑里 find_spec("yaml") 有结果,find_spec("pyyaml") 是「没找到」。同类:scikit-learn→sklearn、opencv-python→cv2、Pillow→PIL。
  6. venv 只换了 sys.prefix。实跑:venv 里 sys.prefix 指向 .venv,而 sys.base_prefix 仍然指向原来的 Python —— 说明它没复制标准库,靠 pyvenv.cfg 的 home = 指回去。site-packages 的位置是从 sys.prefix 推的,所以第三方包换了地方;include-system-site-packages = false 让全局包看不见。
  7. activate 只做一件事:把 .venv/Scripts(或 bin)插到 PATH 最前面。所以直接写全路径 .venv/Scripts/python.exe 效果完全一样。VS Code / Jupyter 按自己的配置挑解释器,不看终端 PATH,所以终端里激活了对它们没用。判断方法:sys.prefix != sys.base_prefix。
  8. requirements.txt / pyproject.toml 的 dependencies 写意图(我直接用到什么,范围尽量宽),锁文件写结果(每一个包含传递依赖的精确版本 + 哈希)。实跑:只写一行 requests 实际拉进 5 个包,只写 torch 拉进 17 个。pip freeze 把意图和结果糊成一坨,之后你分不清 idna==3.7 是自己要的还是被拖进来的,也就不敢升级任何东西。
  9. 只放了一个 __editable__.mypkg-0.1.0.pth 和一个 __editable___mypkg_0_1_0_finder.py(外加 dist-info),没有复制任何代码 —— 它们把你的源码目录接进 sys.path 的查找逻辑。实跑里 mypkg.__file__ 指的就是源码树,改成 v2 不重装再 import 就是 v2。⚠️ 对 C 扩展不适用,改了还得重编译。
  10. 判据是源文件的 mtime(取整到秒)+ 字节数,两者都对得上就直接用缓存、不读源码。实跑第 ② 步:内容改成 VALUE = 2、字节数不变、mtime 还原,跑出来还是 VALUE = 1。场景:从备份/压缩包还原(mtime 被一起还原)、同一秒内 git checkout 来回切、容器 COPY 统一时间戳、NTP 把时钟往回调。开关是 python -B / PYTHONDONTWRITEBYTECODE=1,怀疑就删 __pycache__。
  11. 根因是 ROOT = r"C:\desktop\claude" 硬编码指向老位置,而 SCRATCH 用 __file__ 指向新位置 → 读新的、写旧的。正解 ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))。校验脚本全绿是因为它们的 ROOT 也硬编码成老目录,查的是同一份老产物 —— 一整轮跑完 8 个脚本全过,实际 107 个文件还是旧文本。
  12. 第一条:python -c "import sys; print(sys.executable)" —— 先确认两边是不是同一个解释器。⚠️ 不该在这一章找答案的:ImportError: DLL load failed、GLIBC_2.32 not found、undefined symbol: _ZN3c10...、ldd 找不到库 —— 那些是二进制层,归 框架底下是 C++ 04。分界线:报错里出现文件路径是本章,出现符号名 / 版本号 / .so / .dll 是那一章。

🛑 可以停在这里

⚡ 走神救援

⭐ import X 不是「加载库 X」,是「按 sys.path 挨个找,用第一个匹配的,然后缓存」——三步:查缓存、沿路径找、执行模块体。

第一步解释了「改了源码行为没变」:reload 能重跑模块体,但 from m import X 绑出去的名字不跟着变——⭐ 能重启就重启。

第二步是本章大半个坑的来源:⭐ sys.path[0] 排在标准库前面,所以目录里放一个自己的 random.py 就会顶掉标准库。💀 真正难查的是属性没缺那种:不报错,只是结果不对。 ⭐ 一行确认:python -c "import 名字 as m; print(m.__file__)"。

⚠️ 另一个高频:装的名字和 import 的名字是两套(装 pyyaml、import yaml)。

⭐ sys.path[0] 由启动方式决定:python x.py 是脚本所在目录,python -m pkg.x 是当前工作目录——同一个文件两种跑法,一种崩一种正常。所以包里的模块一律用 -m 跑,装包一律 python -m pip install(否则你不知道是给哪个解释器装的)。

循环 import 的修法优先级:抽第三个模块 > 把 import 挪进函数体 > 用 import m 而不是 from m import X。

⭐ venv 只做了一件事:换掉 sys.prefix;「激活」只是把它的目录插到 PATH 最前面——⚠️ 而 VS Code / Jupyter 按自己的配置挑解释器,不看终端 PATH。

⚠️ requirements.txt 是意图,锁文件是结果:只写一行 requests 实际会拉进好几个包,版本是「今天最新的能装上的」——这就是「上周还好好的」。⭐ pip freeze 不是锁文件,它把意图和结果糊成一坨。

下一节 👉 09-容器、哈希与顺序.md

打卡记录保存在你的浏览器里,首页能看到总进度