Python后端工程化与测试03:为什么换个目录就导入失败——用src布局验证安装结果
“我电脑上能运行”的一个常见前提是:终端恰好停在源码目录,Python恰好把当前目录加入了导入路径。此时import quoteledger成功,只能证明磁盘上有一个同名目录,不能证明安装包包含它。本课把项目整理为src布局,让“可以安装”成为明确的交付能力。报价规则、状态策略和HTTP合同都不改变。
上一课答案:先确认隔离仍然成立
E02的参考答案使用两个应用实例,直接断言A创建的id在B中不存在。数量边界通过pytest.mark.parametrize展开为四个案例,True必须保留为布尔值,不能写成字符串’true’,因为那是另一种输入。运行命令:
$PY=(Resolve-Path.\.venv\Scripts\python.exe).Path &$PY-m pytest.\lessons\03\answer_previous.py--rootdir.\lessons\03-q预期为5 passed。这个答案仍指向01阶段,说明上一课练习使用的材料没有随着本课安装布局改变而漂移。也可以直接运行该文件,它会在独立pytest进程中执行相同测试。
lessons/03/answer_previous.py
"""E02:pytest文件也可作为脚本启动独立pytest进程。"""frompathlibimportPathimportsubprocessimportsysimportpytestfromfastapi.testclientimportTestClient STAGE=Path(__file__).resolve().parents[2]/"checkpoints"/"01"sys.path.insert(0,str(STAGE))importappasbaseline# noqa: E402defpayload(quantity):return{"sku":"BOOK-001","supplier":"alpha","quantity":quantity,"unit_cents":1299,"shipping_cents":500,}deftest_apps_do_not_share_quotes():assertPath(baseline.__file__).resolve()==(STAGE/"app.py").resolve()with(TestClient(baseline.create_app())asfirst,TestClient(baseline.create_app())assecond,):response=first.post("/quotes",json=payload(1))assertresponse.status_code==201quote_id=response.json()["id"]assertfirst.get(f"/quotes/{quote_id}").status_code==200assertsecond.get(f"/quotes/{quote_id}").status_code==404@pytest.mark.parametrize("quantity, status",[(1,201),(100,201),(0,422),(True,422)])deftest_quantity_boundaries(quantity,status):withTestClient(baseline.create_app())asclient:response=client.post("/quotes",json=payload(quantity))assertresponse.status_code==status,response.textifstatus==201:assertresponse.json()["total_cents"]==1299*quantity+500if__name__=="__main__":# check=True让子进程测试失败变成当前脚本的非零退出。here=Path(__file__).resolve()subprocess.run([sys.executable,"-m","pytest",str(here),"--rootdir",str(here.parent),"-q",],check=True,timeout=60,)项目目录与Python包不是同一回事
03阶段有一个pyproject.toml和一个src/quoteledger目录。前者描述构建系统、发行名称、版本、依赖及工具配置;后者才是Python导入包。发行包名称可包含连字符,Python导入名通常使用下划线或普通标识符,二者不要求完全相同。本课程直接from quoteledger.api import create_app,不会导入名为src的包。
src布局把可导入代码放在项目根目录下一层,降低“靠当前目录侥幸导入”的可能。测试、文档和临时脚本留在包外,也不必为了导入而把整棵项目目录塞进sys.path。__init__.py明确标记包;它不应在导入时连接数据库、启动服务器或者读取只有生产机器才有的配置。导入应当轻量,这会让测试发现、命令行工具和类型分析更可靠。
项目元数据里的requires-python约束解释器版本;dependencies是运行这个包所需的依赖。pytest属于开发与验证工具,并不是处理一张报价单所需的运行依赖。把两者分开,交付时才不会让客户为了启动API也安装所有测试工具。具体安装约束以本课完整pyproject为准;需要逐字节可复现的构建时,还要进一步冻结传递依赖的解析结果和构建环境。
两种安装方式,各自验证什么
先安装03阶段,当前环境不必同时安装多个同名阶段。一个虚拟环境里同一发行包只能有一个当前有效的安装结果,顺序安装后面的阶段会替换前面的入口。因此本课程练习答案会显式选择对应快照,最终交付验收再使用独立环境。
&$PY-m pip install-e.\checkpoints\03 &$PY-c"import quoteledger; print(quoteledger.__file__)"&$PY-m uvicorn quoteledger.api:create_app--factory--host 127.0.0.1--port 9200-e表示editable可编辑安装:环境保存与源码的关联,修改Python源文件通常立刻影响下一次导入。它适合日常开发,却容易掩盖构建遗漏。例如源码存在但没有被构建进wheel,editable可能仍然能读到,客户安装wheel却失败。--factory告诉Uvicorn调用create_app创建应用,不能把函数本身当作ASGI应用直接处理请求。
普通安装会构建并安装发行物中的文件;wheel是这种预构建分发格式,扩展名.whl,本质是带元数据的归档。第15课会从干净虚拟环境安装wheel。本课先建立基本意识:验证导入时,要同时检查“从哪里导入”,不能只看“有没有报错”。
$outside=Join-Path([IO.Path]::GetTempPath())'quoteledger-outside'New-Item-ItemType Directory-Path$outside-Force|Out-NullPush-Location$outside&$PY-c"import quoteledger; print(quoteledger.__file__)"Pop-Location$PY前面被解析成绝对路径,所以切换目录后仍能找到解释器。Push-Location保存原目录,Pop-Location返回。如果看到路径仍在checkpoints/03/src/quoteledger,在editable安装下这是预期;如果我们声称验证的是普通安装,这个路径就不合格。
Python究竟先找到了谁
sys.path是导入搜索目录列表,Python按顺序查找。某个目录下有一个名为quoteledger.py的临时文件,可能遮住真正的quoteledger包;把测试文件命名为sqlite3.py、typing.py也会遮住标准库。错误经常表现为“部分初始化模块”“不是一个package”,根源却是命名冲突。
PYTHONPATH可以临时加目录,但它不是打包方案。若开发机长期设置了指向最终project的PYTHONPATH,运行03阶段测试时可能偷偷用到未来功能。参考答案为此在新子进程中验证安装目标,并检查模块实际路径。子进程让sys.modules重新开始,避免同一Python进程先导入08、再把路径换回03却仍复用旧模块对象。
命令python -I启用隔离模式,会忽略PYTHONPATH等环境影响,并减少当前目录带来的导入污染。它不代表新建了一个全新的虚拟环境:解释器已安装的依赖依然存在。下一课答案使用临时--target安装目录和隔离子进程,验证本课自己的安装结果;第15课再加“干净环境”这一更强约束。这两种证据回答的问题不同,文章会明确写清。
移动文件为什么也需要行为测试
把01单文件移到包里时,很容易顺手整理变量名、修改异常返回、增加version字段。这样测试失败后,就无法区分是导入路径出错,还是业务真的改变。03阶段只改变组织与安装方式,仍然没有请求版本号:confirm和cancel没有body。先保持行为,再做结构提取,可以让每次失败都更容易解释。
如果测试在源码根目录通过,在临时目录失败,先看安装是否成功,以及构建配置是否找到src里的包。不要立即在测试里堆叠sys.path.append直到变绿;这会让测试绕过你真正想交付的安装行为。路径修补适合教学快照的显式选取,不应伪装成发行物验证。
另一个常见误区是直接运行python src/quoteledger/api.py。包内相对导入依赖包上下文,直接执行文件时上下文可能丢失。用python -m 模块名或者工具提供的工厂入口,让解释器按包语义加载。入口设计清晰后,IDE、命令行和CI才能运行同一条路径。
读懂pyproject里几个容易混淆的位置
[build-system]声明“用什么工具制造发行物”,并不声明运行服务需要哪些库。这里使用setuptools构建后端,构建前端pip或build根据这份配置调用它。[project]才描述发行物本身;可选依赖dev属于开发安装组,pip install -e '路径[dev]'会把这一组也安装进环境。运行依赖和开发依赖都要有可审查的版本策略。
[tool.setuptools.packages.find]里的where指向src,表示从那里寻找包。若误写成tests,构建过程可能仍然生成一个wheel文件,但里面没有应用代码。于是“文件成功生成”只是第一层证据,导入与行为验证是下一层。类似地,wheel里的版本元数据来自project.version,而不是文件夹名03;文件夹只是我们保存教学阶段的编号。
03阶段使用确定的运行依赖版本,便于复现本次课程验收;setuptools的构建约束仍是范围,因此环境并非完整锁定。需要审计所有依赖变化时,应同时保存解析后的依赖清单并检验供应来源。不要把一个写了等号的requirements文件,自动等同于已经解决所有跨平台和构建差异。
清理虚拟环境时也要尊重文件边界。可重新创建自己的.venv,但不要删除全局site-packages,更不要为了让某个import消失而手工改共享Python安装。使用& $PY -m pip show quoteledger可以观察当前环境的安装元数据;用quoteledger.__file__观察实际加载文件。两项结果不一致时,往往存在editable关联、同名模块或者额外路径影响。
课程保留多个快照,是为了让每课有稳定材料;它们不是要同时作为七个不同服务安装在一个环境里。同一Python进程也不适合先后加载多个名为quoteledger的阶段。即使你删除sys.path里的旧目录,已导入的模块仍保存在sys.modules中。独立子进程是最容易解释的隔离方式,答案因此避免在一个长脚本里循环切换包版本。
最后,路径问题要能在任何目录复现。脚本通过自身的__file__定位材料,与用户当前终端位置无关;命令通过绝对解释器路径定位环境。把这两种路径分开处理,你就不会因为换一个文件夹,既丢失Python入口又意外导入另一份业务代码。
安装验证还应检查行为是否属于当前阶段。答案特意断言响应中没有version,避免一个已经装好的后续版本恰好通过金额测试,却让阶段验收失去意义。
E03:在源码目录外证明安装成功
将checkpoints/03复制到临时构建目录,使用当前解释器的pip把它普通安装到一个临时目标目录,或者安装到独立虚拟环境。再从与源码无关的工作目录启动一个新的Python进程,打印quoteledger.__file__并断言来源属于安装目录。通过TestClient创建quantity=2、unit_cents=1299、shipping_cents=500的报价,断言201和3098;确认后再次确认应409;读取缺失id应404。测试进程不能把checkpoint/src或最终project/src加到sys.path。下一课提供包含临时目录清理的完整答案。
本课把“能导入”变成了“知道导入来源,且安装结果具备预期行为”。接下来我们才开始拆业务规则;目录整齐并不自动意味着责任清晰,领域提取要解决的是另一个问题。
对当前快照运行回归测试
完成上面的本阶段editable安装后,在课程根目录执行:
&$PY-m pytest.\checkpoints\03\tests--rootdir.\checkpoints\03-q本次独立进程验收结果为9 passed;若依赖库发出弃用警告,应区分警告与测试失败。这里的测试只导入03阶段,不能用最终project的安装结果替代。若此前设置过PYTHONPATH,先恢复它,再确认quoteledger.__file__指向本阶段的src目录。
本课完整阶段源码与打包配置
checkpoints/03/pyproject.toml
[build-system] requires = ["setuptools>=75"] build-backend = "setuptools.build_meta" [project] name = "quoteledger" version = "0.3.0" description = "逐步演进的报价单服务" requires-python = ">=3.11" dependencies = ["fastapi==0.115.12", "pydantic==2.13.5", "uvicorn==0.34.2"] [project.optional-dependencies] dev = ["pytest==8.3.5", "httpx==0.28.1"] [tool.setuptools.packages.find] where = ["src"] [tool.pytest.ini_options] testpaths = ["tests"]checkpoints/03/src/quoteledger/__init__.py
"""QuoteLedger 教学项目。"""checkpoints/03/src/quoteledger/api.py
"""单文件基线:先让合同可测试,存储与业务还耦合在路由闭包中。"""fromdatetimeimportUTC,datetimefromthreadingimportLockfromuuidimportuuid4fromfastapiimportFastAPI,HTTPException,ResponsefrompydanticimportBaseModel,ConfigDict,FieldclassCreateQuoteBody(BaseModel):model_config=ConfigDict(extra="forbid",str_strip_whitespace=True)sku:str=Field(min_length=1,max_length=64)supplier:str=Field(min_length=1,max_length=32)quantity:int=Field(strict=True,ge=1,le=100)unit_cents:int=Field(strict=True,ge=0,le=100_000_000)shipping_cents:int=Field(default=0,strict=True,ge=0,le=100_000_000)defcreate_app()->FastAPI:"""每个工厂拥有独立字典;锁保护同进程读改写,不能提供跨进程持久化。"""app=FastAPI(title="QuoteLedger baseline")quotes:dict[str,dict[str,object]]={}lock=Lock()@app.get("/health")defhealth()->dict[str,str]:return{"status":"ok"}@app.post("/quotes",status_code=201)defcreate(body:CreateQuoteBody,response:Response)->dict[str,object]:quote_id=uuid4().hexquote={"id":quote_id,**body.model_dump(),"total_cents":body.unit_cents*body.quantity+body.shipping_cents,"status":"draft","created_at":datetime.now(UTC).isoformat(),}withlock:quotes[quote_id]=quote response.headers["Location"]=f"/quotes/{quote_id}"returndict(quote)@app.get("/quotes/{quote_id}")defget(quote_id:str)->dict[str,object]:withlock:ifquote_idnotinquotes:raiseHTTPException(404,"quote not found")returndict(quotes[quote_id])defchange(quote_id:str,target:str)->dict[str,object]:withlock:ifquote_idnotinquotes:raiseHTTPException(404,"quote not found")current=quotes[quote_id]ifcurrent["status"]!="draft":raiseHTTPException(409,"terminal quote cannot change")# 替换对象而非原地改字典,避免已返回引用被后续操作悄悄改变。changed={**current,"status":target}quotes[quote_id]=changedreturndict(changed)@app.post("/quotes/{quote_id}/confirm")defconfirm(quote_id:str)->dict[str,object]:returnchange(quote_id,"confirmed")@app.post("/quotes/{quote_id}/cancel")defcancel(quote_id:str)->dict[str,object]:returnchange(quote_id,"cancelled")returnappcheckpoints/03/tests/test_behavior.py
importpytestfromfastapi.testclientimportTestClientfromquoteledger.apiimportcreate_app@pytest.fixturedefclient():withTestClient(create_app())asvalue:yieldvalue@pytest.mark.parametrize("quantity,total",[(1,1090),(2,2080),(100,99100)])deftest_total_and_terminal_state(client,quantity,total):response=client.post("/quotes",json={"sku":"A","supplier":"S","quantity":quantity,"unit_cents":990,"shipping_cents":100,},)assertresponse.status_code==201quote=response.json()assertquote["total_cents"]==totalassertresponse.headers["location"]=="/quotes/"+quote["id"]assert(client.post("/quotes/"+quote["id"]+"/confirm").json()["status"]=="confirmed")assertclient.post("/quotes/"+quote["id"]+"/cancel").status_code==409assertclient.post("/quotes/"+quote["id"]+"/confirm").status_code==409@pytest.mark.parametrize("quantity",[0,101,True,"1"])deftest_invalid_quantity(client,quantity):assert(client.post("/quotes",json={"sku":"A","supplier":"S","quantity":quantity,"unit_cents":1},).status_code==422)deftest_factory_isolation_and_missing(client):quote=client.post("/quotes",json={"sku":"A","supplier":"S","quantity":1,"unit_cents":0}).json()withTestClient(create_app())assecond:assertsecond.get("/quotes/"+quote["id"]).status_code==404assertclient.get("/quotes/missing").status_code==404assertclient.get("/health").json()=={"status":"ok"}deftest_extra_fields_rejected(client):response=client.post("/quotes",json={"sku":"A","supplier":"S","quantity":1,"unit_cents":1,"admin":True,},)assertresponse.status_code==422