📑 本页目录(点开跳转)
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 '(空串 = 当前目录)'}")
本机实跑输出(路径已缩短):
信息关系
⭐ 这张表里有两条信息值钱:
[0]排在标准库前面 —— 下一节整节都在讲这一条的后果。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 什么都没打 —— 它只是从 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>) |
⭐ 包内绝对导入正常工作 |
💡 所以这几条经验规则的来历就清楚了:
- ⭐ 包里的模块一律用
python -m pkg.tool跑,不要python pkg/tool.py。 - ⭐ 一律用
python -m pip install ...而不是pip install ...—— 前者明确说了是给哪个解释器装,后者取决于 PATH 上第一个叫pip的是谁。 「装了但 import 不到」的头号原因就是装到了另一个解释器里。 python -m venv/python -m json.tool/python -m http.server同理。
🧩 四、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
⭐ 全部机密都在这几行里:
sys.base_prefix没变 —— venv 没有复制一份 Python,标准库还是原来那份, 靠pyvenv.cfg里的home =指回去。所以一个 venv 通常只有几 MB。sys.prefix变了 —— 而site-packages的位置是从sys.prefix推出来的,于是第三方包换了地方。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 写的是「我想要什么」,装上的是「解析器算出来的一整棵树」。
你只钉了树根,其余 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 = 2,跑出来是 1。 因为 .pyc 的头部记的是
源文件的 mtime(取整到秒)+ 字节数,两者都对得上就直接用缓存,根本不读源码。
⚠️ 平时你不会遇到它(改代码总会让 mtime 变新),但这几种情况会撞上:
- 从压缩包 / 备份里还原文件,工具把 mtime 一起还原了
git checkout在同一秒内来回切分支- 容器构建时
COPY进去的文件时间戳被统一设成同一个值 - 时钟被 NTP 往回调过
💀 症状是最坏的那种:代码是新的、行为是旧的、没有任何报错。
⭐ 两个开关: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 本身。本章管环境,那章管种子 |
✅ 检查点
import X分哪三步?「改了源码但行为没变」最可能卡在第几步?sys.path[0]在python x.py、python -m pkg.x、python -c三种启动方式下分别是什么?- 为什么
python pkg/tool.py会ModuleNotFoundError: No module named 'pkg',而python -m pkg.tool不会? - 目录里放一个自己写的
random.py会发生什么?怎么一行确认「导到的是不是我想要的那个」? pip install pyyaml之后import pyyaml为什么找不到?- venv 到底改了什么?
sys.prefix和sys.base_prefix在 venv 里外分别是什么关系? - 「激活 venv」这个动作做了什么?为什么 VS Code 里「激活了」还是可能 import 不到?
requirements.txt和锁文件的分工是什么?为什么pip freeze > requirements.txt不算锁文件?pip install -e .往site-packages里放了什么?为什么改源码不用重装?.pyc的失效判据是什么?举两种会让它「代码是新的、行为是旧的」的场景。- 本项目那次「读新的、写旧的」事故,根因是哪一行?正解怎么写?为什么全套校验脚本都是绿的?
- 排查「我这能跑你那不能跑」,第一条命令该敲什么?哪一类报错不该在这一章找答案?
👀 答案
- ① 查
sys.modules缓存 → ② 沿sys.path从前往后找,用第一个匹配的 → ③ 执行模块体。「改了源码但行为没变」卡在第 ①(进程内已经缓存了,压根没重读)—— 实跑里第二次import heavy一个字都没打。 python x.py→ 脚本所在目录;python -m pkg.x→ 当前工作目录;python -c/ 交互式 →''(当前目录),实跑打出来就是空串。- 因为
sys.path[0]不同:前者是<root>/pkg,项目根不在路径上,所以from pkg.conf import NAME找不到pkg;后者是<root>,能看见自己的包。同一个文件同一个解释器,一个崩一个不崩。 - 它会顶掉标准库的
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__)"。 - 装的名字和 import 的名字是两套东西:
pip install pyyaml装出来的模块叫yaml。实跑里find_spec("yaml")有结果,find_spec("pyyaml")是「没找到」。同类:scikit-learn→sklearn、opencv-python→cv2、Pillow→PIL。 - venv 只换了
sys.prefix。实跑:venv 里sys.prefix指向.venv,而sys.base_prefix仍然指向原来的 Python —— 说明它没复制标准库,靠pyvenv.cfg的home =指回去。site-packages的位置是从sys.prefix推的,所以第三方包换了地方;include-system-site-packages = false让全局包看不见。 activate只做一件事:把.venv/Scripts(或bin)插到PATH最前面。所以直接写全路径.venv/Scripts/python.exe效果完全一样。VS Code / Jupyter 按自己的配置挑解释器,不看终端 PATH,所以终端里激活了对它们没用。判断方法:sys.prefix != sys.base_prefix。requirements.txt/pyproject.toml的dependencies写意图(我直接用到什么,范围尽量宽),锁文件写结果(每一个包含传递依赖的精确版本 + 哈希)。实跑:只写一行requests实际拉进 5 个包,只写torch拉进 17 个。pip freeze把意图和结果糊成一坨,之后你分不清idna==3.7是自己要的还是被拖进来的,也就不敢升级任何东西。- 只放了一个
__editable__.mypkg-0.1.0.pth和一个__editable___mypkg_0_1_0_finder.py(外加dist-info),没有复制任何代码 —— 它们把你的源码目录接进sys.path的查找逻辑。实跑里mypkg.__file__指的就是源码树,改成v2不重装再 import 就是v2。⚠️ 对 C 扩展不适用,改了还得重编译。 - 判据是源文件的 mtime(取整到秒)+ 字节数,两者都对得上就直接用缓存、不读源码。实跑第 ② 步:内容改成
VALUE = 2、字节数不变、mtime 还原,跑出来还是VALUE = 1。场景:从备份/压缩包还原(mtime 被一起还原)、同一秒内git checkout来回切、容器COPY统一时间戳、NTP 把时钟往回调。开关是python -B/PYTHONDONTWRITEBYTECODE=1,怀疑就删__pycache__。 - 根因是
ROOT = r"C:\desktop\claude"硬编码指向老位置,而SCRATCH用__file__指向新位置 → 读新的、写旧的。正解ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))。校验脚本全绿是因为它们的ROOT也硬编码成老目录,查的是同一份老产物 —— 一整轮跑完 8 个脚本全过,实际 107 个文件还是旧文本。 - 第一条:
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、importyaml)。⭐
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