0. 学习计划
直接跟着 AI 学。复制以下提示词到新会话中,循序渐进完成学习:
# Python 工程化学习导师
你是我的 Python 工程化导师。请采用「最小必要知识 + Java 概念映射 + 项目实践 + 可验证验收」的方式,逐步带我完成 Python 工程化学习。
## 我的背景
- 我是一名有实际项目经验的 Java 后端开发工程师
- 我熟悉 Java、Maven、JUnit、Spring Boot、依赖注入、DTO、配置管理、日志和常见项目结构
- 我已经完成 Imperial College 的 *Python for Java Programmers*
- 我已经完成 SyntaxShift 的 *Python for Java Developers*,包括后面的进阶内容
- 我已经掌握 Python 基础语法,不需要重新学习变量、循环、函数、列表、字典、类等通用编程基础
- 我使用 Windows 和 PowerShell
- 我的最终目标是使用 Python 开发一个可以持续迭代的 AI Agent
- 当前阶段只学习 Python 工程化,不学习模型 SDK、LangChain 或 LangGraph
## 学习原则
1. 采用项目驱动学习,不安排需要从头到尾通读的课程
2. 每次只推进一个可在 30~90 分钟内完成的小步骤
3. 不要在一次回复中输出完整学习计划的全部教学内容
4. 只有当前步骤验收通过后,才能进入下一步
5. 不要因为我是 Python 新手,就重新解释我已经熟悉的通用编程概念
6. 遇到 Python 特有机制时,使用 Java 概念进行对照,同时指出两者不能完全类比的地方
7. 优先让我自己写代码,不要未经请求直接生成完整项目或完整答案
8. 当我遇到困难时,依次采用以下方式提供帮助:
- 首先指出问题并给出一个提示
- 仍然无法解决时,给出伪代码或局部示例
- 只有我明确要求时,才给出完整代码
9. 如果我贴出代码或命令输出,先检查实际内容,再作判断,不要凭空猜测
10. 如果我的理解、代码或方案存在问题,请直接说明原因和影响,不要为了鼓励而回避问题
11. 不要为未来可能出现的需求增加不必要的抽象、依赖或配置
## 官方资料使用规则
- 技术事实优先依据当前版本的官方文档
- 每个学习步骤最多提供 1~2 个官方文档链接
- 必须明确告诉我:
- 当前需要阅读的具体章节
- 暂时跳过的章节
- 阅读这些章节是为了解决当前项目中的什么问题
- 不要只给我一串文档链接
- 不要求我顺序通读整份官方文档
- 如果你只检查了文档的一部分,要明确说明,不要声称完整阅读
- 文档只是当前任务的补充资料,项目实践才是学习主线
## 实践项目
项目名称:`incident-agent`
项目目标:实现一个暂时不接入大模型的 Java 项目故障分析工具。
输入信息包括:
- 应用名称
- Java 异常堆栈
- 问题描述
需要实现以下工具函数:
- `get_application_info`
- `search_historical_incidents`
- `search_project_documents`
第一阶段使用本地 JSON 文件模拟应用信息、历史故障和项目文档。
最终输出一份结构化故障报告,包含:
- 可能原因
- 事实依据
- 建议排查步骤
所有工具保持只读,不连接真实数据库或公司系统。
## 当前学习范围
请通过上述项目逐步覆盖以下内容:
1. `uv` 安装、Python 版本和依赖管理
2. `pyproject.toml`
3. `src layout`、模块、包、`import` 和项目入口
4. Python 类型注解
5. Pydantic 数据模型和数据校验
6. Pydantic Settings、环境变量和 `.env`
7. pytest
8. `fixture`、`monkeypatch`、`parametrize` 和 `tmp_path` 的必要用法
9. Ruff 代码检查和格式化
10. Python `logging`
11. 配置、模型、工具函数和测试的合理模块划分
12. README 和本地运行说明
暂时不要引入以下内容:
- 模型厂商 SDK
- LangChain
- LangGraph
- RAG
- 向量数据库
- FastAPI
- HTTPX
- `async` 和 `await`
- Docker
- 数据库
- 多 Agent
- 长期记忆
- CI/CD
- 发布到 PyPI
- 复杂设计模式
这些内容等当前工程化阶段完成后再学习。
## 每一步的回复格式
每个步骤必须使用以下结构:
- `## 当前步骤`
用一句话说明本步骤要完成什么
- `## 为什么需要学习`
结合 `incident-agent` 项目说明这个知识解决了什么问题
- `## Java 对照`
说明它与 Maven、JUnit、DTO、Spring 配置、SLF4J 等 Java 概念的对应关系,以及关键差异
- `## 最小必要知识`
只解释完成当前任务必须知道的内容,不展开无关知识
- `## 官方资料`
最多提供 1~2 个官方链接,并明确标注必读章节、暂时跳过的章节和预计阅读时间
- `## 实践任务`
给我一组明确、有限的操作任务。除非我明确要求,否则不要直接给出完整代码
- `## 验收标准`
列出可以客观验证的结果,包括需要执行的命令和预期现象
- `## 完成后回复什么`
明确告诉我应该把哪些命令输出、目录结构或代码贴回来
## 教学过程要求
- 每次只给出当前步骤,不提前展开后续步骤
- 等我提交结果后再检查
- 检查通过后,简短总结本步骤,再进入下一步
- 如果检查不通过,只处理当前问题,不进入下一步
- 不要用是否「看完文档」作为完成标准,要以项目是否能够正确运行作为标准
- 每经过 2~3 个步骤,安排一次小型复盘,让我用自己的语言解释关键概念
- 如果我只是机械复制而没有理解,请通过一个小改动或排错题验证我的理解
- 不要让我手动输入没有解释用途的大段配置
- 命令需要兼容 Windows PowerShell
- 涉及文本编码时默认使用 UTF-8
## 工程化阶段的最终验收标准
完成工程化阶段后,项目至少应满足以下要求:
- 使用 `uv` 管理 Python、依赖和锁文件
- 使用标准 `src layout`
- 输入、工具结果和故障报告使用 Pydantic 建模
- 配置通过 Pydantic Settings 从环境变量或 `.env` 读取
- 3 个只读工具可以查询本地 JSON 数据
- 使用 `logging` 记录主要执行过程
- pytest 覆盖输入校验、配置读取和工具查询
- Ruff 检查和格式化通过
- README 能让其他开发者从零运行项目
- 以下命令能够成功执行:
```powershell
uv run pytest
uv run ruff check .
uv run ruff format --check .
```
完成上述标准后,请停止继续扩展这个项目,并告诉我已经可以进入「模型官方 SDK 和结构化输出」阶段。
## 现在开始
你的第一条回复只能给出「步骤 0:环境检查」。
让我检查 Python、PowerShell 和 `uv` 的当前状态,并把结果回复给你。
不要在第一条回复中创建项目、安装依赖、提供完整目录结构或讲解后续步骤。
1. uv
参考链接:uv 官方文档
1.1 安装 uv
使用 uv 统一管理 Python 版本、虚拟环境、依赖和锁文件。
与 Java 进行对比,uv 的职责覆盖了 Java 工具链中的一部分组合:
- Python 版本管理近似于 SDKMAN/JDK 版本选择
- 依赖管理近似于 Maven
uv.lock近似于精确锁定后的依赖解析结果
关键差异是:uv 还可以创建和管理项目虚拟环境,而 Maven 通常直接使用系统选择的 JDK。
安装方式
先检查当前 Python 版本:
python --version
# Python 3.13.5
# 记住,当前是 3.13 版本
先检查 WinGet:
winget --version
如果检查成功,执行以下命令安装 uv:
winget install --id astral-sh.uv --exact --source winget
关闭当前 PowerShell,然后新打开一个 PowerShell 执行以下命令验证:
uv --version
Get-Command uv |
Select-Object Name, CommandType, Source, Version
如果上述命令正常,继续执行:
uv python find 3.13
uv python list --only-installed
uv 的版本正常输出,Get-Command uv 也能显示明确路径,并且能够发现 Python 3.13,证明 uv 安装成功。
1.2 初始化项目
参考链接:pyproject.toml 官方指南
使用 uv 初始化 incident-agent 项目,需要使用到 uv init --package 命令,类似于精简版的 Maven Archetype。
uv init --package --python 3.13 --vcs none incident-agent
参数解析:
--package:创建可安装、可执行的src layout项目--python 3.13:使用 Python 3.13 构建项目--vcs none:不创建 Git 仓库
进入 incident-agent 目录后,可以看到以下文件:
pyproject.toml:类似pom.xml,声明项目元数据、Python 版本要求、依赖和工具配置.python-version:类似 Java 项目的 Project SDK 或项目级JAVA_HOME,告诉uv在当前项目中默认选择哪个 Python 解释器src:源码目录,类似 Maven 的src目录。src/incident_agent类似src/main/java下的业务包,内部还有一个__init__.py文件
.python-version 负责告诉 uv 默认选择哪个 Python 解释器,pyproject.toml 中的 requires-python 负责声明项目允许使用哪些 Python 版本。例如,>=3.13 允许使用 Python 3.13、3.14 等版本,>=3.13,<3.14 则只允许使用 Python 3.13 系列。
pyproject.toml 中的 [project.scripts] 用于配置项目入口:
[project.scripts]
incident-agent = "incident_agent:main"
incident-agent 是命令名称,incident_agent:main 表示调用 incident_agent 包中的 main() 函数。使用以下命令运行:
uv run incident-agent
第一次运行时会在项目下生成 .venv 目录,这是项目独立的 Python 运行环境。
此外还会生成 uv.lock 文件,它记录了当前项目解析出的精确依赖版本及其传递依赖,使不同机器得到一致的依赖集合。它不是 pyproject.toml 的替代品,pyproject.toml 声明项目和依赖要求,uv.lock 记录具体解析结果。
1.3 拆分最小模块
参考链接:
在 src\incident_agent 目录下新建 startup.py 文件,实现 startup_message() 函数:
def startup_message() -> str:
return 'incident-agent is ready'
startup.py 是一个 Python 模块,类似一个可以承载多个相关类或函数的 Java 源文件,但它不要求一个文件一个 public 类。
incident_agent 目录是 Python 包,类似 Java 包,但 Python 的包边界和导入规则并不完全等同于 Java 包。
修改现有的 __init__.py 文件,导入 startup_message,并调用这个函数:
from .startup import startup_message
def main() -> None:
print(startup_message())
其中 from .startup 表示从当前 Python 包中导入函数。
也就是 . 表示当前包,.. 表示父包。
1.4 Python 类型注解
参考链接:Python typing 模块
Java 编译器会检查类型,普通 Python 运行时不会自动执行类型校验。类型注解主要供 IDE、静态检查工具和 Pydantic 等框架读取。
新建 incident.py,实现函数 build_incident_summary 函数:
def build_incident_summary(application_name: str, stack_trace: str, description: str) -> str:
return f'''
Application: {application_name}
Description: {description}
Stack trace: {stack_trace}
'''
调用 get_type_hints(build_incident_summary) 后能够看到 build_incident_summary 函数的参数类型和返回值类型。
由于 Python 运行时不会进行类型校验,因此像下面这种调用方式也会调用成功:
# 第一个参数传入的类型是整数,而不是字符串
build_incident_summary(123, 'trace', 'description')
2. Pydantic
参考链接:Pydantic Models
2.1 运行时校验
为了让 Python 拥有运行时校验的能力,可以引入 Pydantic:
uv add pydantic
这条命令类似于添加 Maven 依赖,同时还会:
- 更新
pyproject.toml的直接依赖声明 - 更新
uv.lock的精确解析结果 - 同步项目虚拟环境
成功引入 Pydantic 后,新建 models.py,定义 IncidentInput 并继承 BaseModel:
from pydantic import BaseModel, ConfigDict, Field
class IncidentInput(BaseModel):
# Field(min_length=1) 用于禁止空字符串
application_name: str = Field(min_length=1)
stack_trace: str = Field(min_length=1)
description: str = Field(min_length=1)
# strict=True 启用严格模式,避免将错误类型宽松转换为目标类型
# str_strip_whitespace=True 校验前去除字符串首尾的空白
model_config = ConfigDict(strict=True, str_strip_whitespace=True)
BaseModel 类似以下 Java 能力的组合:
- DTO 或
record:定义字段结构 - Jackson:把输入转换为对象、把对象转换为字典
- Bean Validation:在创建对象时校验字段
然后修改 build_incident_summary:
from .models import IncidentInput
def build_incident_summary(incident_input: IncidentInput) -> str:
return f'''
Application: {incident_input.application_name}
Description: {incident_input.description}
Stack trace: {incident_input.stack_trace}
'''
再修改项目入口:
from .incident import build_incident_summary
from .models import IncidentInput
def main() -> None:
value = IncidentInput(
application_name=' order-service ',
stack_trace=' java.lang.NullPointerException ',
description=' 下单失败 '
)
print(build_incident_summary(value))
如果参数传递错误,则会在运行时抛出 ValidationError 并进行友好提示。
2.2 读取项目配置
参考链接:Pydantic Settings
安装以下依赖,用于从配置中读取信息:
uv add pydantic-settings
新增 settings.py 文件:
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
# 默认值 data
data_dir: Path = Path("data")
# 配置从 .env 文件中读取,编码是 utf-8,配置前缀是 INCIDENT_AGENT_
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
env_prefix="INCIDENT_AGENT_",
)
在项目根目录下创建 .env 文件:
INCIDENT_AGENT_DATA_DIR=sample-data
创建 Settings 对象时,会读取 .env 文件。env_prefix="INCIDENT_AGENT_" 与字段名 data_dir 组合成环境变量名 INCIDENT_AGENT_DATA_DIR。Pydantic Settings 默认对字段名称大小写不敏感,因此 DATA_DIR 能绑定到 data_dir。
.env 默认相对于程序的当前工作目录查找,不是相对于 settings.py 文件查找。命令行程序通常应从项目根目录启动,或者显式指定 _env_file。
如果与 Java 进行对比:
-
BaseSettings类似 Spring Boot 的@ConfigurationProperties -
环境变量类似 Spring 的外部化配置来源
-
.env是本地开发辅助文件,但不是 Spring Boot 默认的标准配置文件 -
Path字段类似配置绑定到Path或File类型,而不是永远手动操作字符串 -
Pydantic Settings 在创建
Settings()时读取配置,它不依赖依赖注入容器
默认配置优先级如下,自左向右逐渐降低:
Settings 初始化参数 > 环境变量 > .env > secrets 目录 > 字段默认值
这个顺序可以通过自定义 settings sources 调整。
3. pytest
参考链接:pytest 官方文档
3.1 测试固定输入
执行以下命令安装 pytest:
uv add --dev pytest
在 pyproject.toml 文件中,pytest 位于开发依赖组,而不是 [project].dependencies 下。
[project]
# --snip--
dependencies = [
"pydantic>=2.13.4",
"pydantic-settings>=2.15.0",
]
# --snip--
[dependency-groups]
dev = [
"pytest>=9.1.1",
]
pytest 和 JUnit 差不多:
-
pytest测试函数类似 JUnit 的@Test方法,但不要求测试类 -
Python 原生
assert类似 JUnit 或 AssertJ 断言,使用pytest会增强失败信息 -
pytest.raises(ValidationError)类似assertThrows
pytest 不要求像 JUnit 那样必须使用测试类和 @Test 标记,常见目录约定和默认发现规则如下:
tests目录:项目存放测试代码的常用目录,但 pytest 不强制使用这个名称test_*.py和*_test.py文件test_*测试函数Test*测试类中的test_*方法
import pytest
from pydantic import ValidationError
from incident_agent import IncidentInput
def test_legal_param():
model = IncidentInput(
application_name=' application_name ',
stack_trace='stack_trace ',
description=' description',
)
assert model.application_name == 'application_name'
assert model.stack_trace == 'stack_trace'
assert model.description == 'description'
def test_rejects_non_string_application_name():
with pytest.raises(ValidationError):
model = IncidentInput(
application_name=123,
stack_trace='stack_trace',
description='description',
)
from incident_agent import IncidentInput, build_incident_summary
def test_build_incident_summary():
incident_input = IncidentInput(
application_name='application_name',
stack_trace='stack_trace',
description='description',
)
value = build_incident_summary(incident_input)
assert 'Application: application_name' in value
assert 'Stack trace: stack_trace' in value
assert 'Description: description' in value
3.2 测试模型与配置
先前的测试只能覆盖固定输入,真实工程中还有更多的情况。
重复使用同一故障输入
新建 conftest.py,定义 valid_incident_input() 函数,该函数返回 IncidentInput 实例。
使用 @pytest.fixture 装饰器标记这个函数,用于在测试中构造相同的输入:
@pytest.fixture
def valid_incident_input() -> IncidentInput:
return IncidentInput(
application_name='application_name',
stack_trace='stack_trace',
description='description',
)
后续使用时,让测试函数的入参与 fixture 的函数名一致,pytest 就会自动注入:
# 参数名与 fixture 装饰的函数名称一致
def test_build_incident_summary(valid_incident_input):
value = build_incident_summary(valid_incident_input)
assert 'Application: application_name' in value
assert 'Stack trace: stack_trace' in value
assert 'Description: description' in value
一次测试多组非法输入
为更好地进行参数化测试,pytest 提供了 @pytest.mark.parametrize 装饰器:
@pytest.mark.parametrize("application_name", ["", " ", 123])
def test_illegal_application_name_param(application_name: str | int):
with pytest.raises(ValidationError):
IncidentInput(
application_name=application_name,
stack_trace='stack_trace',
description='description',
)
使用临时文件,而不修改已有配置
测试不能改变开发者正在使用的配置,否则会产生状态污染、顺序依赖,并可能在并行测试时互相干扰。
pytest 提供了 tmp_path 用于创建临时文件:
def test_data_dir(tmp_path):
env_file = tmp_path / '.env'
env_file.write_text(
"INCIDENT_AGENT_DATA_DIR=file-data",
encoding="utf-8",
)
# 使用 _env_file 参数覆盖本次读取的 .env 文件
settings = Settings(_env_file=env_file)
assert settings.data_dir == Path("file-data")
_env_file 是 BaseSettings 构造函数提供的配置源控制参数,用于为当前实例指定 .env 文件,它不是 Settings 中定义的业务字段。PyCharm 如果提示 Unexpected argument,属于对 Pydantic 构造签名识别不完整,不表示运行时不支持该参数。
临时修改环境变量
pytest 更进一步还提供了 monkeypatch 用于设置临时环境变量:
def test_environment_vars(tmp_path, monkeypatch):
monkeypatch.setenv("INCIDENT_AGENT_DATA_DIR", "env-data")
env_file = tmp_path / '.env'
env_file.write_text(
data="INCIDENT_AGENT_DATA_DIR=file-data",
encoding="utf-8",
)
settings = Settings(_env_file=env_file)
assert settings.data_dir == Path("env-data")
monkeypatch 会在测试结束后自动恢复被修改的环境变量,避免测试之间互相污染。
4. Ruff
参考链接:Ruff 官方文档
执行以下命令安装 Ruff:
uv add --dev ruff
Ruff 也同样位于开发依赖组。
Ruff 的相关配置需要在 pyproject.toml 中完成,比如在末尾添加:
[tool.ruff.lint]
extend-select = ["I"]
I 表示 import sorting。
Ruff 主要包含两类能力:
- 代码检查(linter)
- 代码格式化(formatter)
代码检查
# 检查整个项目的代码问题,只输出结果,不修改文件
uv run ruff check .
# 检查并自动修复可安全修复的问题
uv run ruff check --fix .
代码格式化
# 检查整个项目的代码格式,不修改文件
uv run ruff format --check .
# 显示格式化会产生的差异,不修改文件
uv run ruff format --diff .
# 直接格式化整个项目
uv run ruff format .
ruff format 负责代码格式,不负责导入排序。启用 I 规则后,需要通过 ruff check --fix . 修复可自动处理的导入顺序问题。
如果是在 PyCharm 中编写 Python 代码,推荐额外下载 Kodai Aono 的 Ruff 插件,该插件能够将 Ruff 的格式化方式集成到 PyCharm 原生的 Ctrl+Alt+L 代码格式化快捷键上。
5. 日志
5.1 配置
参考链接:Logging HOWTO
Python 中,日志由标准库 logging 实现。
如果要对日志进行配置,可以调用 logging.basicConfig() 方法,这个方法是全局的,调用一次即可。
默认情况下,二次调用 logging.basicConfig() 方法不会覆盖原配置,比如:
logging.basicConfig(level=logging.INFO)
logging.basicConfig(level=logging.DEBUG)
第二次调用不会把级别改成 DEBUG,程序仍按第一次的 INFO 配置运行。
但如果额外使用 force 参数,则会覆盖原有配置:
logging.basicConfig(level=logging.DEBUG, force=True)
如果想动态调整根 logger 的日志级别,可以修改它的级别:
logging.getLogger().setLevel(logging.DEBUG)
5.2 使用
如果要打印日志,需要先调用 logging.getLogger(name) 获取 Logger 对象。在同一个模块中使用相同名称得到的是同一个逻辑 logger,并按照名称形成层级。通常统一使用当前模块名:
logger = logging.getLogger(__name__)
后续使用方式与 Java 类似,不过有两点要注意:
- 如果要在日志中输出一些参数,尤其是参数的组装还涉及到复杂逻辑时,不要把输出的字符串写成提前拼接的字符串,比如使用
f-string拼接参数,因为f-string会在调用日志方法前完成字符串的格式化,即使不是对应的日志级别,也会先完成字符串的转换,因此这种情况下应该优先使用参数化日志,也就是使用占位符 - 日志占位符使用
%s,而不是{}
使用
print()能够输出内容,使用logging也能输出内容,这俩有什么区别呢?
print() 默认输出到标准输出,logging 默认通过 Handler 输出到标准错误流,也可以配置到文件。
print() 用于输出程序的正式结果,比如做了一个 CLI 工具,程序输出的内容就应该使用 print() 完成。
logging 用于输出程序运行时的诊断信息,在后续排查程序 BUG 时很有帮助。
6. 处理 JSON 文件
与 Java 对照的 API:
- 使用
Path.read_text()方法读取指定文件中的文本内容,类似 Java 中的Files.readString() - 使用
json.loads()方法从字符串中解析出 JSON 对象,类似 Java Jackson 中的ObjectMapper.readValue()。loads()中的s表示 string,不表示列表。该方法会根据 JSON 根节点的类型返回对应的类型,根节点是数组时返回列表,是对象时返回字典 - 使用
json.dumps()方法将 Python 对象转换为 JSON 字符串,类似ObjectMapper.writeValueAsString()
def get_application_info(
application_name: str, data_dir: Path
) -> ApplicationInfo | None:
app_file = data_dir / "applications.json"
records = json.loads(app_file.read_text(encoding="utf-8"))
for record in records:
app = ApplicationInfo.model_validate(record)
if app.application_name == application_name:
logger.info("Application %s found", application_name)
return app
logger.info("Application %s not found", application_name)
return None