从 JS/TS 到 Python:前端开发者的无缝迁移指南(三) 工程化篇

前两篇分别讲完了基础语法服务端开发。这一篇聚焦到工程化——代码规范、类型检查、构建发布、CI/CD、文档体系。这些是让代码从”能跑”到”能维护”的关键基础设施。

对于习惯了 ESLint + Prettier + TypeScript + package.json 一条龙的前端开发者来说,Python 的工程化生态会给你一种”既熟悉又散装”的感觉——原理相通,但工具分散在不同维度,需要你主动拼装。

阅读前提:本文假设你已经读过前两篇,或至少熟悉 Python 基础语法、pyproject.toml 的基本概念、asyncio 和 FastAPI。


一、项目结构与目录约定

Node 项目的目录结构没有”官方标准”——src/ 还是根目录放源码、lib/ 还是 dist/ 放构建产物,全看团队偏好。Python 在这方面有更强的社区共识,但也分两类主流方案。

先看总览。

特性/概念 JS/TS Python 核心差异与避坑说明
源码目录 src/ 或根目录,无强约定 src layout 或 flat layout Python 强烈推荐 src layout 避免导入陷阱
包标识 package.json 即项目根 pyproject.tomlsetup.py 所在目录 Python 多一个”可编辑安装”概念
模块即文件 *.ts / *.js *.py Python 文件层次直接映射为导入路径
命名空间包 npm workspace / scoped packages 隐式命名空间包(PEP 420) Python 不需要 package.json 来声明 workspace
测试目录 __tests__/*.test.ts(co-located 或独立) tests/(几乎总是独立目录) Python 社区强烈倾向于测试代码与源码分离

1.1 src layout vs flat layout

Python 社区有两种主流项目布局。

Flat layout(扁平布局):

my_project/
  pyproject.toml
  my_package/
    __init__.py
    core.py
  tests/
    test_core.py

src layout(推荐):

my_project/
  pyproject.toml
  src/
    my_package/
      __init__.py
      core.py
  tests/
    test_core.py

Flat layout 看起来更直接——源码就在项目根目录下。但强烈推荐 src layout,原因很实际:

// Node:这个问题在 Python 中同样存在
// 如果源码在根目录,import 的解析起点就是项目根
// 你可能会意外导入到"开发中的版本"而不是"安装后的版本"
# Python flat layout 的陷阱:
# 在项目根目录直接运行 python 时,当前目录默认在 sys.path 中
# 这会让你"意外地能导入",但安装后就可能找不到包
# src layout 强制你先 pip install -e .,从而更早暴露导入问题

避坑警告:如果你的项目里有 pyproject.toml + 源码目录,但没做可编辑安装(pip install -e .),那么”为什么 import 不到自己的包”会是你遇到的第一类问题。src layout 通过强制可编辑安装,让这个问题直接暴露,反而减少了隐蔽 bug。

安装时如何配置 pyproject.toml

# pyproject.toml — src layout 的包发现配置
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/my_package"]

1.2 __init__.py 的工程角色

第一篇讲过 __init__.py 是”告诉 Python 这个目录是包”。在工程化层面,它的角色远不止于此。

// Node:index.ts 常用于"统一再导出"
// lib/index.ts
export { fetchUser } from "./user";
export { formatDate } from "./date";

// 使用者可以:
// import { fetchUser, formatDate } from "./lib";
# Python:__init__.py 承担同样的"对外整理出口"职责
# my_package/__init__.py
from .user import fetch_user
from .date import format_date

# 使用者可以:
# from my_package import fetch_user, format_date
特性/概念 JS/TS Python 核心差异与避坑说明
目录级入口 index.ts(约定,非强制) __init__.py(语言级机制) Python 的 __init__.py 有运行时语义
子模块再导出 常见 export * from(星导出) from .module import * 配合 __all__ Python 的 __all__ 明确控制星导入范围
包初始化 无语言级等价物 __init__.py 顶层代码会在导入时执行 注意不要放进重副作用逻辑
延迟导入 import() 动态导入 __init__.py 里按需 import Python 同样可以延迟加载子模块

__all__ 控制星导入

# my_package/__init__.py
__all__ = ["fetch_user", "format_date"]  # 只暴露这两个名字

from .user import fetch_user
from .date import format_date
from .internal import _helper  # 不放进 __all__,星导入看不到
# 使用时:
from my_package import *  # 只会导入 fetch_user 和 format_date

避坑警告__all__ 只影响 from package import *,不影响显式导入。from my_package import _helper 依然能导进来。Python 的”私有”靠 _ 前缀约定而非语言强制——这和 JS 的 #private 不同。

1.3 包命名规范

Python 官方的包命名指南来自 PEP 8PEP 423

规范项 JS/TS Python
包名格式 无统一标准(camelCasekebab-case 全小写 + 下划线snake_case
命名唯一性 npm scope(@myorg/pkg PyPI 全局命名空间,先到先得
包名与导入名 可以不同(lodash 导入 _ 强烈建议包名 ≈ 导入名
顶层包名冲突 npm scope 可解决 无 scope 机制,同名就是冲突
// Node:npm 的 scope 机制天然避免冲突
// import { foo } from "@myorg/utils";
// import { foo } from "@otherorg/utils";  // 同名包不冲突
# Python:PyPI 是全局扁平的,同名直接冲突
# 包名 my_utils 只能有一个发布者
# 所以 Python 包的命名往往更"具体化"
# pip install requests          # ✅ 知名 HTTP 库
# pip install requests-toolbelt  # ✅ 相关但不同的包名

1.4 pyproject.toml 深度解析

pyproject.toml 是 Python 项目的”中央配置文件”,对标 Node 的 package.json。但二者不是简单的 1:1 关系。

先看一个完整的对照结构:

// package.json — Node 项目配置文件
{
  "name": "my-project",
  "version": "1.0.0",
  "description": "A sample package",
  "scripts": { "dev": "tsx src/index.ts" },
  "dependencies": { "express": "^4.18.0" },
  "devDependencies": { "typescript": "^5.0" },
  "engines": { "node": ">=18" }
}
# pyproject.toml — Python 项目配置文件
[project]
name = "my-project"
version = "1.0.0"
description = "A sample package"
requires-python = ">=3.10"
dependencies = [
    "fastapi>=0.100.0",
]

[project.optional-dependencies]
dev = [
    "pytest>=8.0",
    "ruff>=0.3.0",
]

[project.scripts]
my-cli = "my_package.cli:main"  # 安装后生成命令行入口

两者的关键差异:

维度 package.json pyproject.toml
配置文件格式 JSON TOML
脚本/命令 npm scripts(内置) [project.scripts](命令行入口)或 [project.gui-scripts]
依赖分组 dependencies / devDependencies dependencies / optional-dependencies
不区分 dev/prod devDependencies 只开发时安装 optional-dependencies 按需安装,无内置 dev/prod 概念
元数据字段 nameversiondescription 相同字段,但遵循 PEP 621
运行时要求 engines.node requires-python
入口点 main / module / exports [project.scripts][project.entry-points]
发布配置 单独的 publishConfig 统一在 pyproject.toml

[project.scripts]:Python 的命令行入口

这是 package.json"bin" 的直接对应物:

// package.json
{ "bin": { "my-cli": "./cli.js" } }
# pyproject.toml
[project.scripts]
my-cli = "my_package.cli:main"
# 格式:命令名 = "模块路径:函数名"
# 安装后,终端直接运行 `my-cli`
# my_package/cli.py 中只需要定义一个 main 函数
def main():
    print("Hello from my-cli!")

避坑警告pyproject.toml 不等于 package.json。它只声明元数据和依赖,不负责运行脚本。Python 没有 npm run dev 的等价物——你需要 Makefile、task runner、或直接 uv run

1.5 命名空间包与 Monorepo 基础

Python 的命名空间包机制比 npm workspace 更轻量。npm 需要 package.json + workspace 配置声明包之间的关系;Python 的命名空间包完全是路径约定

# npm workspace — 显式声明
my-monorepo/
  package.json          # { "workspaces": ["packages/*"] }
  packages/
    lib-a/
      package.json      # { "name": "@myorg/lib-a" }
    lib-b/
      package.json      # { "name": "@myorg/lib-b" }
# Python 命名空间包 — 路径即结构(PEP 420)
my-monorepo/
  pyproject.toml
  packages/
    myorg-lib-a/
      src/
        myorg/          # 同一个命名空间
          lib_a/
            __init__.py
    myorg-lib-b/
      src/
        myorg/          # 同一个命名空间
          lib_b/
            __init__.py

关键:两个包的 myorg/ 目录都不需要 __init__.py——这就是 PEP 420 的隐式命名空间包。把两个包都安装后:

from myorg.lib_a import something
from myorg.lib_b import another

两边都能正常导入,就像它们来自同一个顶层包。

避坑警告:如果你想用命名空间包,不要在命名空间目录里放 __init__.py。放了就变成普通包,Python 不会去其他安装路径寻找同名包的子模块。

1.6 本节小结

这一节最值得记住的工程习惯:

  1. 新项目默认用 src layout——pip install -e . 做可编辑安装,提前暴露导入问题
  2. __init__.py 是你的对外出口整理文件——合理使用 __all__ 控制公开 API
  3. 包名全小写 + 下划线——不要带进 npm 的 kebab-casecamelCase 习惯
  4. pyproject.toml 是中央配置但不是 package.json 的完整替代品——脚本执行靠 Makefile 或其他工具
  5. 命名空间包不需要声明——路径结构对了就行

二、代码规范与自动格式化

Node 前端项目几乎标配 ESLint + Prettier。Python 这部分的工具链也在走向统一,而且速度更快——你不需要组合三个工具,两个就够。

先看总览。

特性/概念 JS/TS Python 核心差异与避坑说明
风格指南 社区惯例,无单一权威规范 PEP 8 是事实标准 Python 有官方风格指南且被广泛遵守
格式化工具 Prettier Black / Ruff format Black 是”零配置、格式化即风格”的先驱
Lint 工具 ESLint Ruff Ruff 同时做格式化和 lint,且用 Rust 实现
Import 排序 eslint-plugin-import isort / Ruff Python 的 import 排序有更明确的分类规则
配置入口 .eslintrc.* / eslint.config.mjs pyproject.toml[tool.ruff] 等) Python 生态倾向于统一用 pyproject.toml
自动修复 eslint --fix ruff check --fixruff format 语义相似

2.1 PEP 8:Python 社区的代码风格宪章

如果你只记住 PEP 8 的几条高频规则,就覆盖了 80% 的日常场景:

规则 PEP 8 要求 JS/TS 惯例对照
缩进 4 空格 2 空格(最常见)
行长 最多 79 字符(文档/注释 72) 80-120 字符(Prettier 默认 80)
空行 顶层定义之间 2 空行,方法之间 1 空行 无强制约定
import 每个 import 独占一行,分组排序 无强制约定(靠 eslint-plugin-import)
空格 运算符两侧各一个空格,逗号后一个空格 基本一致
命名 snake_case 变量/函数,PascalCase camelCase 变量/函数
UTF-8 源码默认 UTF-8 源码默认 UTF-8

PEP 8 不是法律,但它减少了你和任意 Python 开发者之间的认知摩擦。真正重要的是工具自动执行,而不是逐条死记。

2.2 Black:Python 的 Prettier

Black 的哲学:格式化没有选项可调,风格不可配置。这比 Prettier 更激进——Prettier 好歹有几项可配置;Black 几乎只有 --line-length

// Prettier: 少量可配置项
// .prettierrc
{
  "semi": true,
  "singleQuote": true,
  "tabWidth": 2,
  "printWidth": 80
}
# pyproject.toml — Black 配置
[tool.black]
line-length = 88     # Black 的默认值(比 PEP 8 的 79 略大,故意的)
target-version = ["py310"]

为什么 Black 选择 88?作者的解释是:比 80 多 10% 的余地,显著减少换行次数,同时仍适合两个编辑器面板并排显示。

使用方式:

# 检查格式
$ black --check src/

# 自动格式化
$ black src/

# 等价于 Node:
# $ prettier --check src/
# $ prettier --write src/

2.3 Ruff:Python 的杀手级 Linter

Ruff 是近两年 Python 生态最大的单体工具突破。用 Rust 实现,同时替代了:

JS/TS 工具 Ruff 对应能力
ESLint ruff check(lint 规则)
Prettier ruff format(格式化)
eslint-plugin-import ruff check(import 排序规则 I
eslint-plugin-unicorn 大量内置规则(PLSIM 等)
各种插件拼装 Ruff 内置 800+ 规则,覆盖 Flake8、pyflakes、isort、pydocstyle 等

推荐配置:

# pyproject.toml — Ruff 推荐配置
[tool.ruff]
line-length = 88
target-version = "py310"

[tool.ruff.lint]
select = [
    "E",     # pycodestyle errors
    "F",     # pyflakes
    "I",     # isort (import 排序)
    "N",     # pep8-naming
    "UP",    # pyupgrade(用新版语法)
    "B",     # flake8-bugbear(常见陷阱)
    "SIM",   # flake8-simplify(简化代码)
    "C4",    # flake8-comprehensions(推导式建议)
]
ignore = ["E501"]  # 行长交给 Black/Ruff format 处理

[tool.ruff.format]
quote-style = "double"  # "double" 或 "single"

使用方式:

# Lint 检查
$ ruff check src/

# Lint 自动修复
$ ruff check --fix src/

# 格式化
$ ruff format src/

# 等价于 Node:
# $ eslint src/
# $ eslint --fix src/
# $ prettier --write src/

避坑警告:Ruff 的 select 规则前缀对应不同的工具来源。不加 select 时 Ruff 默认只启用 EF——远少于你预期的覆盖面。务必显式配置 select,或者直接用 select = ["ALL"] 然后按需 ignore

Import 排序是 Ruff 的一个高频特性,对标 eslint-plugin-importorder 规则:

// JS: import 分为 builtin → external → internal
import fs from "node:fs";            // builtin
import express from "express";       // external
import { helper } from "./utils";    // internal
# Python: import 分为 stdlib → third-party → first-party
# Ruff 的 I 规则自动排序和组织:
import os                    # 标准库
import sys                   # (同一组内按字母序)

import fastapi                # 第三方
import uvicorn                # (同一组内按字母序)

from my_package import core   # 本地/项目内
from my_package import utils

2.4 工程建议:统一用 Ruff 就够了

两年前你还需要 Black + isort + Flake8 + ... 拼装全家桶。现在一个工具就够了:

# 安装
$ uv add --dev ruff

# 日常使用
$ ruff check --fix src/ tests/    # lint + 自动修复
$ ruff format src/ tests/         # 格式化

对比 Node 的同类工作流:

# Node 需要至少两个工具
$ npx eslint --fix src/
$ npx prettier --write src/

# Python 一个就够了
$ ruff check --fix src/ && ruff format src/

如果你怀念 ESLint 的可配置性和 Prettier 的无脑格式化,Ruff 把两者很好地统一到了一个工具里。Ruff format ≈ Prettier(零配置优先),Ruff check ≈ ESLint(可配置的 lint 规则库)

2.5 本节小结

  1. PEP 8 是 Python 的风格基线——用工具执行,不要靠记忆
  2. Ruff 一个工具替代 Black + isort + Flake8 + 各种插件——对标 ESLint + Prettier
  3. 务必显式配置 select——默认规则覆盖面太窄
  4. ruff check --fixruff format 是两个互补命令——前者修逻辑问题,后者修格式问题

三、静态类型检查

TypeScript 开发者最自然的恐惧是:”Python 没有编译时类型检查,代码安全吗?”答案是:Python 有静态类型检查,但它不是语言内置的——而是通过可选的第三方工具实现。

先看总览。

特性/概念 JS/TS Python 核心差异与避坑说明
类型系统 TypeScript(编译时) 类型标注 + mypy/pyright(可选运行时前检查) Python 的类型检查是渐进式可选的
类型擦除 TS 编译后类型消失 Python 运行时忽略类型标注 Python 的类型标注默认只是注释
检查工具 tsc --noEmit mypy / pyright / pylance Python 有多个竞争工具
IDE 支持 tsserver 统一 Pyright(VS Code)/ PyCharm 内置 Pyright 是 VS Code Python 体验的核心
严格模式 strict: true strict = true(mypy)、typeCheckingMode: "strict"(pyright) 两边思路惊人相似
类型文件 .d.ts .pyi(stub 文件) 相似的接口声明文件机制

3.1 Python 类型标注的本质

TS 类型在编译时就生效——没通过类型检查,代码不会产出 JS。Python 完全不同:

// TypeScript:类型错误 = 编译失败
const x: number = "hello";  // ❌ Type 'string' is not assignable to type 'number'
# Python:类型标注运行时完全不检查
x: int = "hello"  # ✅ Python 解释器根本不管,运行正常

你需要在独立步骤中运行类型检查工具:

# 等价于 tsc --noEmit
$ mypy src/           # mypy
$ pyright src/        # pyright

3.2 mypy:Python 类型检查器的鼻祖

mypy 是最早的 Python 静态类型检查器,对标 tsc --noEmit

// 定义一个带类型的函数
function greet(name: string): string {
  return `Hello, ${name}`;
}

greet(123);  // ❌ tsc: Argument of type 'number' is not assignable to parameter of type 'string'
# 同样的类型标注
def greet(name: str) -> str:
    return f"Hello, {name}"

greet(123)  # ✅ Python 解释器不报错
# 但 mypy 会报:error: Argument 1 to "greet" has incompatible type "int"; expected "str"

mypy 配置

# pyproject.toml
[tool.mypy]
strict = true                          # 启用所有严格检查
python_version = "3.10"
warn_return_any = true                 # 返回 Any 时警告
warn_unused_configs = true             # 检查配置是否被使用
disallow_untyped_defs = true           # 禁止无类型标注的函数
disallow_incomplete_defs = true        # 禁止部分标注的函数

[[tool.mypy.overrides]]
module = ["tests.*"]
ignore_errors = false                  # 测试文件也检查

避坑警告strict = true 是 mypy 最有价值的模式,但它对存量和第三方库都要求类型标注。如果从无到有地给旧项目加 mypy,先从 strict = false 开始,逐步收紧规则。

3.3 Pyright:微软出品,VS Code 原生搭档

Pyright 是微软开发的类型检查器,也是 VS Code Pylance 的底层引擎。对标 TS 的 tsserver——它更偏向 IDE 体验和增量检查。

维度 mypy pyright
实现 Python(核心用 C 加速) TypeScript(运行在 Node 上)
速度 中等 (增量检查极快)
严格程度 可配置,默认相对宽松 可配置,默认更激进
VS Code 集成 需要配置 零配置(Pylance 内置)
第三方 stub 需要 types-* 自动解析 + 需要 types-*
配置复杂度 中等
社区使用 历史最长,CI 中广泛使用 快速增长,IDE 体验更好

Pyright 配置pyproject.toml):

[tool.pyright]
typeCheckingMode = "strict"      # basic / standard / strict
pythonVersion = "3.10"
reportMissingTypeStubs = false   # 对缺 stub 的第三方库降低警告
reportUnnecessaryTypeIgnoreComment = true  # 提醒多余的 type: ignore

选型建议:IDE 里 Pyright/Pylance 完胜——你根本不需要额外配置就能在 VS Code 中获得类型提示和错误标记。CI 流程中 mypy 仍然更常见和稳定,但 Pyright 增长迅速。两者都配置也不冲突——像同时开了 tsc 和 eslint 的类型规则检查。

3.4 渐进式类型的迁移策略

这是 Python 类型系统最吸引人的特性——你可以逐步加类型,不需要一次全改。这和 TypeScript 的 strict: false → strict: true 迁移路径类似。

# 阶段一:完全无类型
def get_user(user_id):
    user = db.query(user_id)
    return {"name": user.name, "age": user.age}

# 阶段二:加上最关键的参数和返回值类型
def get_user(user_id: int) -> dict[str, str | int]:
    user = db.query(user_id)
    return {"name": user.name, "age": user.age}

# 阶段三:使用更精确的类型
from typing import TypedDict

class UserInfo(TypedDict):
    name: str
    age: int

def get_user(user_id: int) -> UserInfo:
    user = db.query(user_id)
    return {"name": user.name, "age": user.age}

迁移的工程顺序:

优先级 迁移内容 理由
1 公共 API 的函数签名 调用方最多,收益最大
2 数据结构的类型定义 数据流清晰后,bug 明显减少
3 内部工具函数 依赖关系越深,类型越有价值
4 测试文件 测试本身就是类型检查的补充
5 脚本和一次性代码 收益低,可以不标

3.5 Stub 文件:.d.ts 的 Python 对应物

TS 用 .d.ts 为 JS 库提供类型声明。Python 用 .pyi stub 文件做同样的事:

// DefinitelyTyped: node_modules/@types/express/index.d.ts
declare module "express" {
  export function json(): RequestHandler;
}
# typeshed: types-requests/requests/__init__.pyi(简化示例)
def get(url: str, params: dict | None = None) -> Response: ...
def post(url: str, data: dict | None = None) -> Response: ...

安装第三方 stub 包:

# 给 requests 加类型
$ uv add --dev types-requests

# 等价于 Node:
# $ npm install -D @types/express

types-* 包来自 typeshed,是 Python 版的 DefinitelyTyped。

3.6 本节小结

  1. Python 的类型标注默认不检查——需要 mypy 或 pyright 作为独立步骤运行
  2. IDE 里 Pyright/Pylance 零配置——和 VS Code 的 TS 体验几乎一样
  3. CI 里跑 mypy 或 pyright——等价于 tsc --noEmit,防止类型错误的代码合入
  4. 渐进式迁移——先标公共 API,再标数据结构,最后覆盖内部代码
  5. types-* = @types/*——安装第三方库的类型 stub

四、Python 包构建与发布

Node 生态里 npm publish 一条命令从构建到发布全搞定。Python 这边的流程历史包袱更重,但现在已基本收束到标准化路径。

先看总览。

特性/概念 JS/TS Python 核心差异与避坑说明
分发包格式 .tgz(源码 + 构建产物) sdist (.tar.gz) + wheel (.whl) Python 严格区分源码分发包和构建后分发包
构建工具 tsc / esbuild / rollup build 前端 + setuptools / hatchling / flit 后端 Python 的 build frontend/backend 是两层概念
发布目标 npm registry PyPI (pypi.org) 类似的概念
版本规范 SemVer PEP 440(基本兼容 SemVer) Python 的版本号语法更精细
发布命令 npm publish twine upload dist/* Python 的工具链更离散
发布令牌 npm token.npmrc ~/.pypircTWINE_PASSWORD 环境变量 同样是 token 认证

4.1 sdist vs wheel:两种分发包

这是 Python 打包最核心的概念,Node 没有完全对应的区分:

格式 扩展名 内容 Node 类比
sdist(源码分发包) .tar.gz 源码 + pyproject.toml,安装时需要构建步骤 类似于发布源码到 npm(没有 build 步骤)
wheel(构建分发包) .whl 预构建好的安装包,可直接安装 类似于发布 dist/ 目录
# 构建两种分发包
$ python -m build

# 产物:
# dist/
#   my_project-1.0.0.tar.gz    # sdist
#   my_project-1.0.0-py3-none-any.whl  # wheel(纯 Python,跨平台)

其中 wheel 的文件名编码了兼容性信息:

my_project-1.0.0-py3-none-any.whl
           ^^^^^ ^^^ ^^^^ ^^^
           版本   Python  ABI  平台
                  版本    (none=纯Python)
  • py3:支持 Python 3 任意版本
  • none:纯 Python 无 ABI 依赖
  • any:跨平台

如果包含 C 扩展,wheel 会变成平台特定格式,例如 my_pkg-1.0.0-cp310-cp310-manylinux_x86_64.whl

避坑警告:PyPI 上至少要上传 sdist + 一个 wheel。只传 sdist 会让使用者在安装时每次都要重新构建,可能导致构建工具版本不一致的问题。

4.2 build backend 选型

Python 的构建系统分为前端(用户直接调用的命令)和后端(实际执行构建的库):

构建后端 前端体验 Node 类比 选型建议
hatchling 配置简单,pyproject.toml 原生支持 esbuild(快、现代) 推荐新项目默认用
setuptools 最老牌,生态最大 webpack(老牌、生态大) 存量项目最常见
flit 极致简化,纯 Python 包 tsup(零配置优先) 简单纯 Python 库
poetry 集成度高,包管理+构建 npm(管理+构建一体) 包管理和构建一起用
uv Rust 实现,极快 pnpm(快、现代化) 强烈推荐,正在迅速成为新标准

一个典型的 pyproject.toml 构建配置(hatchling):

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "my-project"
version = "1.0.0"
requires-python = ">=3.10"

[tool.hatch.build.targets.wheel]
packages = ["src/my_package"]

选型建议:如果你用 uv 管理依赖,直接搭配 hatchling 作为构建后端——uv 专注于包管理,hatchling 专注于构建,各司其职。

4.3 发布到 PyPI

# 1. 构建
$ python -m build

# 2. 发布
$ twine upload dist/*

# 测试 PyPI 先试水:
$ twine upload -r testpypi dist/*

认证配置:

# ~/.pypirc
[pypi]
username = __token__
password = pypi-xxxxxxxx

[testpypi]
repository = https://test.pypi.org/legacy/
username = __token__
password = pypi-xxxxxxxx
# 或用环境变量(CI 友好)
$ export TWINE_USERNAME=__token__
$ export TWINE_PASSWORD=pypi-xxxxxxxx
$ twine upload dist/*

对比 Node 流程:

# Node
$ npm run build
$ npm publish

# Python
$ python -m build
$ twine upload dist/*

避坑警告:PyPI 上曾经可删除和覆盖已发布的包,现在已不再允许——发布出去就永远在那里。这和 npm 的 unpublish 限制类似。测试用 Test PyPI

4.4 版本号管理

Python 官方版本规范是 PEP 440

版本形式 示例 是否允许
正式版 1.0.0
预发布 1.0.0a1(alpha)、1.0.0b1(beta)、1.0.0rc1
开发版 1.0.0.dev1
后发布修复 1.0.0.post1
语义化版本 ^1.0.0(npm 范围) ❌ Python 不用 ^ / ~ 范围语法

Python 依赖声明中不使用 ^1.0.0 这种范围语法——而是用 PEP 440 的比较运算符:

# pyproject.toml 中的依赖版本规范
dependencies = [
    "fastapi>=0.100.0,<1.0.0",     # 0.100.0 到 1.0.0(不含)
    "uvicorn[standard]>=0.20.0",    # 带 extras 的依赖
    "httpx~=0.24.0",               # ~= 兼容版本,≈ npm 的 ^0.24.0
]

~= 是最接近 npm ^ 的运算符——它锁定主版本号和第二个非零位置:

~= 1.4.2   → >=1.4.2, ==1.4.*    # 兼容 1.4.x 系列
~= 1.4     → >=1.4, ==1.*        # 兼容 1.x 系列

4.5 本节小结

  1. sdist + wheel 都要上传——sdist 给源码,wheel 给快速安装
  2. hatchling 是新项目的默认构建后端——配置简单、速度快
  3. twine 发布,不是 pip publish——Python 没有 npm 那样大一统的 CLI
  4. 版本号用 PEP 440,不用 npm 的 ^ / ~ 语法——~= 最接近 ^
  5. PyPI 不可删除——先用 Test PyPI 验证

五、CI/CD 实践

Python 和 Node 的 CI/CD 结构在 GitHub Actions 层面惊人相似——差异主要在缓存策略和依赖安装步骤。

先看总览。

特性/概念 JS/TS Python 核心差异与避坑说明
包管理器缓存 node_modules 或 npm cache pip cache / uv cache Python 缓存体积更小
多版本测试 node-version: [18, 20, 22] python-version: ["3.9", "3.10", "3.11", "3.12"] 语法完全一致
Lint 检查 npm run lint ruff check src/ 工具不同,流程一致
类型检查 tsc --noEmit mypy src/pyright src/ 语义对等
测试运行 npm test(jest/vitest) pytest 命令不同,流程一致
构建发布 npm run build && npm publish python -m build && twine upload dist/* Python 多一个 twine 步骤

5.1 最小 CI 工作流

# .github/workflows/ci.yml — Node 项目
name: CI
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18, 20, 22]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: "npm"
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck
      - run: npm test
# .github/workflows/ci.yml — Python 项目
name: CI
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.9", "3.10", "3.11", "3.12"]
    steps:
      - uses: actions/checkout@v4
      - name: Install uv
        uses: astral-sh/setup-uv@v3
        with:
          python-version: ${{ matrix.python-version }}
          enable-cache: true
      - run: uv sync
      - run: ruff check src/ tests/
      - run: mypy src/
      - run: pytest -v

对比之下,关键差异只有这几个替换:

Node CI 步骤 Python CI 步骤
actions/setup-node astral-sh/setup-uv(推荐)或 actions/setup-python
npm ci uv sync(或 pip install -e ".[dev]"
npm run lint ruff check src/ tests/
tsc --noEmit mypy src/
npm test pytest -v

5.2 缓存策略

Node 的 node_modules 动辄几百 MB,npm/yarn/pnpm 各有缓存目录。Python 的依赖体积通常更小——但缓存策略同样重要:

# actions/setup-python 自带的 pip 缓存
- uses: actions/setup-python@v5
  with:
    python-version: "3.12"
    cache: "pip"                     # 自动缓存 pip 依赖

# uv 的缓存——astral-sh/setup-uv 自带 enable-cache: true
- uses: astral-sh/setup-uv@v3
  with:
    enable-cache: true               # 一键开启,无需额外配置

uv 的缓存比 pip 快一个数量级——这一点和 pnpm 对 npm 的体验提升类似。

5.3 发布工作流

# .github/workflows/publish.yml — Node
name: Publish
on:
  push:
    tags: ["v*"]
jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          registry-url: "https://registry.npmjs.org"
      - run: npm ci
      - run: npm run build
      - run: npm publish
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# .github/workflows/publish.yml — Python
name: Publish
on:
  push:
    tags: ["v*"]
jobs:
  publish:
    runs-on: ubuntu-latest
    environment: release             # GitHub Environment(推荐用于 PyPI)
    permissions:
      id-token: write                # 用于 trusted publishing
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v3
      - run: uv sync --no-dev
      - run: python -m build
      - name: Publish to PyPI
        uses: pypa/gh-action-pypi-publish@release/v1
        # 无需手动传 token——GitHub OIDC trusted publishing

推荐 PyPI trusted publishing:从 GitHub Actions 发布时,不再需要手动管理 PyPI token。配置方法见 PyPI 文档

5.4 本节小结

  1. CI 结构 Node 和 Python 几乎一致——checkout → install → lint → typecheck → test → (publish)
  2. astral-sh/setup-uv 替代 actions/setup-python——更快、缓存更好、配置更少
  3. 发布用 PyPI trusted publishing——不用手动管理 token

六、文档体系

TypeScript 项目的文档通常是 JSDoc/TSDoc 注释 + 独立文档站(VitePress/Docusaurus)。Python 的习惯更”一体化”——docstring 本身就够被工具提取,生成 HTML 文档站几乎零配置。

先看总览。

特性/概念 JS/TS Python 核心差异与避坑说明
代码内文档 JSDoc / TSDoc docstring(Google / NumPy / Sphinx 风格) Python 的 docstring 有更多工具链支持
文档生成器 TypeDoc / JSDoc Sphinx / MkDocs Sphinx 是 Python 文档的老牌王者
自动 API 文档 TypeDoc 自动提取 Sphinx autodoc / MkDocs mkdocstrings 理念一致
文档托管 GitHub Pages / Vercel Read the Docs / GitHub Pages Read the Docs 是 Python 生态特色
示例代码测试 无标准等价物 doctest(内联测试) Python 提供语言级别的文档即测试

6.1 Docstring 风格

Python 社区有三种主流 docstring 风格:

Google 风格(推荐新项目):

def fetch_user(user_id: int, include_posts: bool = False) -> dict | None:
    """Fetch a user by their ID.

    Args:
        user_id: The unique identifier of the user.
        include_posts: If True, also fetch the user's posts.

    Returns:
        A dict with user data, or None if the user doesn't exist.

    Raises:
        ValueError: If user_id is not positive.
    """
    if user_id <= 0:
        raise ValueError("user_id must be positive")
    ...

NumPy 风格(科学计算项目常用):

def fetch_user(user_id, include_posts=False):
    """Fetch a user by their ID.

    Parameters
    ----------
    user_id : int
        The unique identifier of the user.
    include_posts : bool, optional
        If True, also fetch the user's posts. Default is False.

    Returns
    -------
    dict or None
        User data, or None if not found.
    """
    ...

Sphinx 风格(最老牌,rst 指令):

def fetch_user(user_id, include_posts=False):
    """Fetch a user by their ID.

    :param user_id: The unique identifier of the user.
    :type user_id: int
    :param include_posts: If True, also fetch the user's posts.
    :returns: User data as dict, or None.
    :rtype: dict or None
    """
    ...
风格 可读性(原始源码) 工具支持 推荐场景
Google ⭐⭐⭐ 最好 所有工具都支持 新项目首选
NumPy ⭐⭐ 好 Sphinx + napoleon 科学计算/数据项目
Sphinx ⭐ 一般 Sphinx 原生 老项目遗留,不推荐新用

避坑警告:不管你选哪种风格,整个项目保持一致比风格本身更重要。Ruff 的 pydocstyle 规则(D 前缀)可以检查 docstring 是否缺失和格式是否一致。

6.2 MkDocs:最小配置生成文档站

MkDocs + Material for MkDocs 是 Python 文档站的最快路径。

# mkdocs.yml — 最小配置
site_name: My Project
theme:
  name: material          # Material 主题(最流行的文档主题)

plugins:
  - search
  - mkdocstrings:         # 自动从 docstring 生成 API 文档
      handlers:
        python:
          paths: [src]
          options:
            show_source: true
# 安装 + 预览
$ uv add --dev mkdocs mkdocs-material mkdocstrings[python]
$ mkdocs serve            # 本地预览 http://localhost:8000

# 构建静态站
$ mkdocs build            # → site/

如果用 Node 类比:

Node Python
VitePress MkDocs + Material 主题
TypeDoc 插件 mkdocstrings 插件
vitepress dev mkdocs serve
vitepress build mkdocs build
Vercel / Netlify Read the Docs / GitHub Pages

6.3 从 docstring 到 API 文档:mkdocstrings

Mkdocstrings 对标 TypeDoc——从代码注释自动生成 API 文档:


# API 参考

## User 模块

::: my_package.user
    options:
      members:
        - fetch_user
        - create_user
      show_root_heading: true

构建后,mkdocstrings 会读取 my_package/user.py 中所有函数的 docstring 和类型标注,自动渲染出带参数表格、返回值说明、异常列表的 API 文档页面。

6.4 doctest:文档即测试

这是 Node 生态里没有直接对应物的特性。在 docstring 中写 >>> 开头的交互式示例,运行 python -m doctest 就能验证它们是否仍然正确:

def add(a: int, b: int) -> int:
    """Add two integers.

    >>> add(1, 2)
    3
    >>> add(0, 0)
    0
    >>> add(-1, 1)
    0
    """
    return a + b
# 运行 doctest——自动执行所有 docstring 中的示例
$ python -m doctest -v src/my_package/core.py
# 输出每个测试的结果,失败的会明确报错

doctest 不是写核心测试的主力工具,但非常适合保证”文档中的示例代码不会过时”。

6.5 本节小结

  1. Google 风格 docstring 是新项目首选——源码可读性最好,所有工具都支持
  2. MkDocs + Material + mkdocstrings = Python 的 VitePress + TypeDoc
  3. doctest 让文档中的示例代码变成可执行测试——防止文档过时

七、Git 工作流增强

这一节只讲两个高频场景:pre-commit hooks 和约定式提交。

7.1 pre-commit hooks

pre-commit 是 Python 生态的 Git hooks 管理框架,对标 lint-staged + husky。

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.4.0
    hooks:
      - id: ruff              # lint
      - id: ruff-format       # 格式化

  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.10.0
    hooks:
      - id: mypy
        args: [--strict]
# 安装 hooks
$ pre-commit install
# 此后每次 git commit 前自动运行检查

# 手动全量运行
$ pre-commit run --all-files

对比 Node 的 husky + lint-staged:

// Node: .lintstagedrc.json
{
  "*.ts": ["eslint --fix", "prettier --write"]
}
# Python: pre-commit 天然支持只检查变更文件
# 不需要额外的 lint-staged 包
JS/TS 工具链 Python 工具链
husky pre-commit
lint-staged pre-commit(内置)
commitlint commitizen / 手动校验

7.2 约定式提交

Commitizen 在 Python 生态中也有对应方案:

# 安装
$ uv add --dev commitizen

# 交互式提交
$ cz commit
# ? Select the type of change: feat
# ? What is the scope?:
# ? Write a short description: add user authentication
# ? ...
# 输出: feat: add user authentication
# pyproject.toml
[tool.commitizen]
name = "cz_conventional_commits"
version = "1.0.0"
tag_format = "v$version"

7.3 本节小结

  1. pre-commit 比 husky + lint-staged 的组合更一体化——配置文件和工具都更简单
  2. commitizen 在 Python 中和 Node 版本功能一致——交互式生成规范提交

八、任务自动化

Python 没有 npm scripts。这一直是 Node 开发者吐槽最集中的差距。但替代方案并不复杂。

8.1 Makefile:最通用的脚本入口

# Makefile — 跨语言通用方案
.PHONY: dev test lint format build

dev:
	uvicorn app.main:app --reload

test:
	pytest -v --cov=src --cov-report=term-missing

lint:
	ruff check src/ tests/

format:
	ruff format src/ tests/
	ruff check --fix src/ tests/

build:
	python -m build
$ make dev      # 启动开发服务器
$ make test     # 运行测试
$ make lint     # 代码检查

对比 package.json scripts:

// Node: npm scripts
{
  "scripts": {
    "dev": "tsx --watch src/index.ts",
    "test": "vitest --coverage",
    "lint": "eslint src/",
    "format": "prettier --write src/"
  }
}

Makefile 的缺点是 Windows 用户需要额外安装 make(或使用 just)。

8.2 Just:现代 Makefile

just 是一个 Rust 写的命令运行器,语法比 Make 更友好,且跨平台:

# justfile
dev:
    uvicorn app.main:app --reload

test *args:
    pytest -v {{args}}

lint:
    ruff check src/ tests/
    mypy src/

format:
    ruff format src/ tests/
    ruff check --fix src/ tests/

# 带参数的命令
test-one test_name:
    pytest -v -k {{test_name}}
$ just dev
$ just test
$ just test-one test_fetch_user

8.3 uv run:Python 的 npx

uv 提供了 uv run,相当于 npx——在项目虚拟环境中直接运行命令:

# Node: npx 运行项目依赖中的可执行文件
$ npx jest --coverage
$ npx eslint src/

# Python: uv run 等价行为
$ uv run pytest --cov
$ uv run ruff check src/

这意味着如果你用 uv 管理项目,甚至不需要 Makefile 来记住虚拟环境路径:

# 这些命令在不同机器上都能以相同方式运行——uv run 自动处理环境
$ uv run pytest
$ uv run ruff check src/
$ uv run mypy src/

推荐方案uv run 解决”激活 venv”的路径问题,Makefile 或 justfile 定义标准化命令入口。两者结合,最接近 npm scripts 的体验。

8.4 本节小结

  1. Python 没有 npm scripts——用 Makefile、justfile 或 uv run 替代
  2. uv runnpx——自动使用项目虚拟环境
  3. Makefile + uv run 是最稳妥的跨环境组合——Makefile 定义命令,uv run 管理环境

九、总结:Python 工程化全景对应图

工程化维度 JS/TS 工具 Python 工具 关键差异
项目配置文件 package.json pyproject.toml TOML vs JSON,Python 无内置 scripts
包管理器 npm / yarn / pnpm pip / uv 推荐 uv,对标 pnpm 速度
虚拟环境 node_modules(自动) venv(手动创建激活) Python 必须手动管理环境隔离
代码格式化 Prettier Ruff format(或 Black) Ruff 一个工具同时做格式化和 lint
Linting ESLint Ruff check Ruff 更快,内置规则更多
Import 排序 eslint-plugin-import Ruff(I 规则)/ isort Ruff 的 I 规则内置实现
类型检查 tsc –noEmit mypy / pyright Python 类型检查是可选的独立步骤
测试 Jest / Vitest pytest pytest fixture 是杀手特性
构建 tsc / esbuild / rollup build + hatchling / uv 构建前后端分离
发布 npm publish build + twine Python 构建和发布分离为两步
Git hooks husky + lint-staged pre-commit pre-commit 一个工具搞定
文档生成 TypeDoc / VitePress MkDocs / Sphinx MkDocs + Material 体验最接近 VitePress
脚本执行 npm run xxx Makefile / just / uv run 最需要适应的差距
CI 安装 npm ci uv sync 结构完全一致

最后:迁移路径建议

如果你刚从前端转到 Python 工程化,推荐的搭建顺序:

  1. uv init + src layout——项目骨架
  2. Ruff(lint + format)——代码规范
  3. mypy 或 pyright——类型检查
  4. pre-commit——自动化检查门禁
  5. pytest——测试框架
  6. GitHub Actions CI——持续的 lint + typecheck + test
  7. MkDocs——文档站
  8. build + twine——发布到 PyPI

全文完。