问题与目标
代码只有几十行时,把所有内容写进 app.py 很省事。变量在上面,函数在下面,运行一次就能看到结果。
可当程序开始读取多份文档、清洗文本、构造 Prompt、记录日志时,一个文件很快就会变成长卷轴。修改文件读取逻辑时可能碰到 Prompt,测试一个函数却要执行整段程序,名称也越来越容易冲突。
更现实的问题是,文件和外部输入不会永远配合:
- 路径可能写错。
- 文件可能不存在。
- 编码可能不一致。
- 文件内容可能为空。
- 当前进程可能没有读取权限。
真正需要的不只是“把文件读出来”,而是让代码有边界,让失败也在设计之内。
核心理解
这四个概念分别解决不同层面的问题:
模块:拆分单个 Python 文件
包:组织一组相关模块
文件操作:让程序读取和保存外部数据
异常:让预期中的失败有明确出口
它们放在一起后,脚本才开始具备项目的样子。
我对“可运行”的标准也发生了变化。以前只要成功路径能输出结果就算完成;现在还要知道输入不合法时会在哪里失败,谁负责处理,以及用户能看到什么信息。
完成标准
完成这一篇后,应当能够:
- 使用文本模式和二进制模式读取、写入文件。
- 区分覆盖、追加和独占创建等文件模式。
- 使用
Path构造、检查和遍历路径。 - 使用
with确保文件被正确关闭。 - 捕获具体异常,并理解
else、finally和raise。 - 把函数拆进模块,把相关模块组织成包。
- 区分标准库、第三方包和自己的模块。
- 建立一个可以从项目根目录运行的多文件程序。
核心概念
一个 .py 文件就是一个模块
假设把文本读取逻辑放进 text_loader.py:
def load_text(path: str) -> str:
...
其他文件可以导入它:
from text_loader import load_text
模块让一个文件围绕一个相对清楚的职责展开。模块名应该说明它负责什么,例如 text_loader.py、prompt_builder.py,而不是 utils2.py、common_new.py。
导入模块时,Python 会执行模块顶层代码。因此可直接运行的入口通常写成:
if __name__ == "__main__":
main()
文件被直接执行时,__name__ 是 "__main__";被其他模块导入时,它是模块名。这样测试或启动逻辑不会在导入时意外执行。
包是一组有层次的模块
当模块继续增加,可以按职责放进目录:
prompt_app/
├── app.py
├── data/
│ └── python.txt
└── knowledge/
├── __init__.py
└── text_loader.py
knowledge 是包,text_loader 是包中的模块。__init__.py 可以为空;保留它能明确表达“这个目录是一个 Python 包”,也兼容更多工具和项目结构。
导入时写:
from knowledge.text_loader import load_text
项目中优先使用明确导入,避免 from module import *。明确写出名字,发生冲突时更容易追踪来源。
文件路径不是一段普通字符串
使用 pathlib.Path 可以更清楚地表达路径操作:
from pathlib import Path
data_path = Path("data") / "python.txt"
相对路径仍然与当前工作目录有关。如果资源确定放在模块旁边,可以从当前文件位置构造绝对路径:
project_dir = Path(__file__).resolve().parent
data_path = project_dir / "data" / "python.txt"
这比假设程序永远从某个目录启动更稳。
用 with 管理文件生命周期
with path.open("r", encoding="utf-8") as file:
content = file.read()
进入 with 代码块时文件被打开,离开时文件会被关闭,即使中间发生异常也能完成资源清理。
读取文本时明确写出 encoding="utf-8",可以减少程序在不同操作系统上出现不一致结果。
文件打开模式决定读写行为
open() 和 Path.open() 常见模式如下:
| 模式 | 含义 | 文件不存在时 | 文件存在时 |
|---|---|---|---|
r | 只读文本 | 报错 | 从开头读取 |
w | 写入文本 | 创建 | 清空后写入 |
a | 追加文本 | 创建 | 从末尾追加 |
x | 独占创建 | 创建 | 报错 |
b | 二进制模式 | 与其他模式组合 | 读写 bytes |
+ | 同时读写 | 与其他模式组合 | 取决于主模式 |
最常用的组合包括 rb、wb 和 r+。第一阶段不需要记住所有组合,但必须知道 w 会清空原文件,写重要数据前要格外谨慎。
读取少量文本可以一次完成:
from pathlib import Path
path = Path("note.txt")
content = path.read_text(encoding="utf-8")
print(content)
逐行处理更适合大文件:
from pathlib import Path
path = Path("note.txt")
with path.open("r", encoding="utf-8") as file:
for line_number, line in enumerate(file, start=1):
print(line_number, line.rstrip("\n"))
read() 返回剩余全部内容,readline() 返回一行,readlines() 返回行列表。大文件如果直接使用 read() 或 readlines(),会一次占用较多内存;直接遍历文件对象通常更稳。
写入和追加文本:
from pathlib import Path
result_path = Path("result.txt")
result_path.write_text("第一次结果\n", encoding="utf-8")
with result_path.open("a", encoding="utf-8") as file:
file.write("追加结果\n")
file.writelines(["第三行\n", "第四行\n"])
writelines() 不会自动增加换行符,需要由传入字符串自己携带。
文件对象会记录当前读写位置。tell() 返回当前位置,seek() 移动位置:
from pathlib import Path
path = Path("data.bin")
path.write_bytes(b"abcdef")
with path.open("rb") as file:
print(file.read(2)) # b'ab'
print(file.tell()) # 2
file.seek(0)
print(file.read(3)) # b'abc'
文本模式会受到字符编码和换行转换影响,需要精确控制字节位置时优先使用二进制模式。普通逐行读取通常不需要手工移动游标。
图片、压缩包、模型权重等不是文本,应使用二进制模式:
from pathlib import Path
source = Path("source.bin")
target = Path("copy.bin")
with source.open("rb") as input_file, target.open("wb") as output_file:
while chunk := input_file.read(8192):
output_file.write(chunk)
这里每次复制 8192 字节,避免一次把整个大文件载入内存。
Path 不只用于拼接路径
from pathlib import Path
data_dir = Path("data")
data_dir.mkdir(parents=True, exist_ok=True)
for path in data_dir.glob("*.md"):
print(path.name, path.suffix, path.stat().st_size)
常用能力包括:
exists():路径是否存在。is_file():是否为普通文件。is_dir():是否为目录。mkdir():创建目录。glob():按照模式查找路径。read_text()、write_text():便捷读写小型文本。resolve():得到解析后的绝对路径。
文件删除和覆盖具有破坏性,不能只因为 Path 提供了方法就直接调用。应先确认目标范围,并根据项目需求设计备份或恢复方式。
异常不是所有错误的统称
语法错误让代码根本无法正常解析;异常则发生在程序运行期间。例如文件不存在会触发 FileNotFoundError,字节无法按指定编码还原为文本会触发 UnicodeDecodeError。
处理异常的基本结构是:
try:
result = risky_operation()
except SomeError as error:
handle(error)
else:
use(result)
finally:
cleanup()
不是每次都要四部分全写。关键是只捕获自己能够解释或处理的异常,不要用一个空泛的 except Exception 把所有问题悄悄吞掉。
else 只在没有异常时执行,适合把成功逻辑与风险操作分开:
from pathlib import Path
try:
content = Path("note.txt").read_text(encoding="utf-8")
except FileNotFoundError:
print("文件不存在")
except UnicodeDecodeError:
print("文件不是有效的 UTF-8 文本")
else:
print(f"读取成功,共 {len(content)} 个字符")
finally:
print("读取流程结束")
finally 无论成功还是失败都会执行,适合必须完成的清理工作。文件对象通常优先交给 with 管理,不必为了关闭文件手工编写 finally。
当函数发现输入违反自己的约定,可以主动抛出异常:
def validate_chunk_size(chunk_size: int) -> None:
if chunk_size <= 0:
raise ValueError("chunk_size 必须大于 0")
需要表达领域中特有的失败,可以继承 Exception:
class DocumentLoadError(Exception):
"""文档无法进入后续处理流程。"""
def require_content(content: str) -> str:
if not content.strip():
raise DocumentLoadError("文档内容为空")
return content.strip()
自定义异常不是越多越好。只有调用方确实需要把这类失败与普通 ValueError、OSError 区分处理时,它才有价值。
异常会沿调用栈向上传递,直到被某一层捕获,或者到达程序入口:
from pathlib import Path
def read_document(path: Path) -> str:
return path.read_text(encoding="utf-8")
def build_context(path: Path) -> str:
content = read_document(path)
return content.strip()
try:
context = build_context(Path("missing.txt"))
except FileNotFoundError as error:
print(f"入口统一处理:{error}")
read_document() 和 build_context() 都没有捕获异常,FileNotFoundError 最终由入口处理。底层函数不必在每一层重复打印同一个错误;更合理的方式是由真正能够恢复、转换或展示错误的那一层处理。
基础阶段常见异常包括:
| 异常 | 常见原因 |
|---|---|
TypeError | 对不合适的类型执行操作 |
ValueError | 类型正确,但值不符合要求 |
KeyError | 字典中不存在指定键 |
IndexError | 序列索引超出范围 |
NameError | 使用了当前作用域不存在的名称 |
AttributeError | 对象没有指定属性或方法 |
FileNotFoundError | 目标文件不存在 |
UnicodeDecodeError | 字节无法按指定编码解码 |
知道异常名称不是为了写一个覆盖所有类型的巨大 except,而是为了从报错中更快定位问题所在的层次。
导入模块的几种方式
导入整个模块时,调用位置保留模块名:
import json
data = json.loads('{"name": "Python"}')
只导入明确成员时,可以直接使用成员名:
from pathlib import Path
path = Path("note.txt")
别名适合解决名称过长或明确区分来源:
import datetime as dt
today = dt.date.today()
导入来源可以分成三类:
- 标准库:随 Python 一起安装,例如
json、pathlib、datetime。 - 第三方包:通过
pip等工具安装,例如requests。 - 项目模块:当前代码库自己编写的
.py文件和包。
这三类导入通常分组书写,中间留空行。出现模块找不到时,先判断它属于哪一类,再检查模块名、解释器、安装位置和启动目录。
Python 到哪里查找模块
导入模块时,Python 会按照模块搜索路径查找。可以用 sys.path 查看当前顺序:
import sys
for path in sys.path:
print(path)
搜索路径通常受启动方式、当前环境、标准库位置和已安装包位置影响。遇到 ModuleNotFoundError 时,应优先检查:
- 模块名是否拼写正确。
- 当前解释器是否正确。
- 第三方包是否安装在当前环境。
- 是否从项目约定的根目录启动。
- 自己的模块是否被同名文件遮住。
虽然可以临时修改 sys.path,但把 sys.path.append(...) 散落在业务代码中通常是在绕过项目结构问题。更稳妥的办法是修正包结构、安装方式或启动位置。
dir() 可以查看模块或对象暴露的名称:
import json
print("loads" in dir(json))
模块中的 __all__ 可以约束 from module import * 导出的名称,但项目仍应优先使用明确导入。知道 __all__ 的作用即可,不必把星号导入作为常规写法。
可运行实现:把文件读取拆成独立模块
先创建项目结构:
prompt_app/
├── app.py
├── data/
│ └── python.txt
└── knowledge/
├── __init__.py
└── text_loader.py
data/python.txt 内容:
Python 的虚拟环境可以隔离不同项目的依赖。
knowledge/text_loader.py:
from pathlib import Path
def load_text(path: Path) -> str:
if not path.is_file():
raise FileNotFoundError(f"文档不存在:{path}")
content = path.read_text(encoding="utf-8").strip()
if not content:
raise ValueError(f"文档内容为空:{path}")
return content
app.py:
from pathlib import Path
from knowledge.text_loader import load_text
def main() -> None:
project_dir = Path(__file__).resolve().parent
document_path = project_dir / "data" / "python.txt"
try:
context = load_text(document_path)
except FileNotFoundError as error:
print(f"加载失败:{error}")
return
except UnicodeDecodeError:
print("加载失败:文档不是有效的 UTF-8 文本")
return
except ValueError as error:
print(f"加载失败:{error}")
return
question = "虚拟环境有什么作用?"
prompt = f"请根据资料回答。\n\n资料:{context}\n\n问题:{question}"
print(prompt)
if __name__ == "__main__":
main()
在 prompt_app 目录运行:
python app.py
这个例子里,text_loader 只负责读取并验证文本,app 负责流程和用户反馈。底层函数发现问题后抛出具体异常,入口决定如何展示失败。
这种职责分配比“所有地方都 try 一下”更清楚。
动手练习:增加批量加载入口
在最小例子的基础上,为 knowledge/text_loader.py 增加批量加载函数。要求:
- 只读取指定目录下的
.txt文件。 - 单个文件失败时记录错误,但继续处理其他文件。
- 返回成功文档和错误信息两个列表。
from pathlib import Path
def load_text(path: Path) -> str:
content = path.read_text(encoding="utf-8").strip()
if not content:
raise ValueError("内容为空")
return content
def load_directory(directory: Path) -> tuple[list[dict[str, str]], list[str]]:
if not directory.is_dir():
raise NotADirectoryError(f"目录不存在:{directory}")
documents = []
errors = []
for path in sorted(directory.glob("*.txt")):
try:
content = load_text(path)
except (OSError, UnicodeDecodeError, ValueError) as error:
errors.append(f"{path.name}: {error}")
continue
documents.append({"title": path.stem, "content": content})
return documents, errors
可以准备正常文件、空文件和非 UTF-8 文件进行验证。这个练习的重点不是一次读取多少文件,而是明确“批次继续”和“单条失败”的边界。
常见问题与排查
用 except Exception 后什么也不做
下面的写法会让程序表面安静,问题却更难定位:
try:
content = load_text(path)
except Exception:
pass
如果当前层不能恢复,就应该让异常继续向上传递;如果能处理,就捕获具体类型并给出足够信息。
捕获异常的范围太大
try 中塞进十几步操作,出错后很难判断究竟是哪一步失败。更好的方式是缩小 try 范围,让它只包住真正可能出现该异常的操作。
导入时执行了业务代码
把读取文件、发网络请求或打印测试结果直接写在模块顶层,会让其他文件仅仅 import 一次就触发副作用。业务入口应放进函数,并使用 if __name__ == "__main__" 保护。
相对导入和运行方式混在一起
包内相对导入、从项目根目录运行模块、直接运行某个子文件,它们的模块搜索上下文不同。第一阶段先保持简单:从项目根目录启动入口文件,包内使用稳定、明确的导入路径。
写文件时忘记区分覆盖和追加
"w" 会覆盖原文件,"a" 会在末尾追加。保存日志或结果前必须确认预期模式;重要数据还要考虑临时文件、备份和原子替换,不能只因为 write() 没报错就认为安全。
小结
这一篇厘清了一个问题:Python 项目不应该只是“一个越来越长的脚本”。
模块负责拆职责,包负责建层次,文件让程序接触外部数据,异常让失败被看见并得到处理。它们共同把代码从一次性运行,推向了可维护、可排查的状态。
本篇检查清单:
- 能说明
r、w、a、b的主要区别。 - 能选择一次读取或逐行读取。
- 能使用
Path构造不依赖字符串拼接的路径。 - 能捕获具体异常,并知道何时继续向上抛出。
- 能解释
else和finally的执行时机。 - 能说明异常如何沿调用栈向上传递。
- 能根据异常类型判断问题的大致来源。
- 能创建模块、包和稳定的程序入口。
- 能区分标准库、第三方包和项目模块。
- 能使用
sys.path辅助排查模块查找问题。 - 知道
tell()和seek()适合解决什么问题。 - 能让批处理记录单文件错误并继续运行。
不过,目前这些模块主要还是函数和数据。当某个组件既要长期保存状态,又要提供一组相关行为时,类和对象会成为更合适的组织方式。
License: CC BY-NC 4.0
Updated 4 hours ago
Was this article helpful? Give it a like.
0 comments


