跳到正文
Mofan
返回

面向 Java 开发者的 Python 工程化实践

发表于 更新于
系列文章: Python for Java Developers 3 / 3
  1. 面向 Java 开发者的 Python
  2. 面向 Java 开发者的 Python 拾遗
  3. 面向 Java 开发者的 Python 工程化实践 当前文章

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 工具链中的一部分组合:

关键差异是: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

参数解析:

进入 incident-agent 目录后,可以看到以下文件:

.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 依赖,同时还会:

  1. 更新 pyproject.toml 的直接依赖声明
  2. 更新 uv.lock 的精确解析结果
  3. 同步项目虚拟环境

成功引入 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 能力的组合:

然后修改 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 进行对比:

默认配置优先级如下,自左向右逐渐降低:

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 标记,常见目录约定和默认发现规则如下:

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_fileBaseSettings 构造函数提供的配置源控制参数,用于为当前实例指定 .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 主要包含两类能力:

  1. 代码检查(linter)
  2. 代码格式化(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 类似,不过有两点要注意:

使用 print() 能够输出内容,使用 logging 也能输出内容,这俩有什么区别呢?

print() 默认输出到标准输出,logging 默认通过 Handler 输出到标准错误流,也可以配置到文件。

print() 用于输出程序的正式结果,比如做了一个 CLI 工具,程序输出的内容就应该使用 print() 完成。

logging 用于输出程序运行时的诊断信息,在后续排查程序 BUG 时很有帮助。

6. 处理 JSON 文件

参考链接:JSON encoder and decoder

与 Java 对照的 API:

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
如果这篇文章对你有帮助,可以通过
支付宝
支付宝
微信
微信
请我喝杯 Coffee ☕


下一篇
面向 Java 开发者的 Python 拾遗