Python 包开发实战——从写工具脚本到发布 PyPI 包的完整流程
Python 包开发实战——从写工具脚本到发布 PyPI 包的完整流程
适读人群:想把自己的工具分享出去的 Python 开发者、第一次发布 PyPI 包感觉无从下手的工程师 | 阅读时长:约13分钟 | 核心价值:完整走通一次发布流程,踩坑都帮你踩过了
两年前我写了一个小工具,用来批量处理 JSON 配置文件,在几个项目里都用到了,每次用都要把脚本文件复制过去。
有一天复制第四次的时候,我忍不了了,决定把它打成包发布到 PyPI,以后 pip install 就行了。
以为很简单,结果踩了不少坑。今天把整个流程写清楚,你能少踩几个。
现代 Python 包的目录结构
my-tool/
├── src/
│ └── mytool/
│ ├── __init__.py
│ ├── core.py
│ ├── cli.py
│ └── utils.py
├── tests/
│ ├── __init__.py
│ ├── test_core.py
│ └── test_cli.py
├── pyproject.toml # 现代项目的核心配置文件
├── README.md
├── LICENSE
└── .gitignore为什么用 src/ 布局?
不用 src/ 布局时,你的包可以直接从项目根目录 import,这意味着本地开发时 import 的是文件系统里的代码,而不是安装好的包。这会掩盖一些安装问题(比如某个文件没有被正确包含进 package)。用 src/ 布局后,必须先 pip install -e .,确保行为和用户 pip install 后一致。
pyproject.toml:现代包配置
不要用老的 setup.py 了,用 pyproject.toml:
[build-system]
requires = ["hatchling"] # 构建后端,也可以用 setuptools、flit 等
build-backend = "hatchling.build"
[project]
name = "mytool" # PyPI 上的包名,全球唯一
version = "0.1.0"
description = "A tool for batch processing JSON config files"
readme = "README.md"
license = {file = "LICENSE"}
requires-python = ">=3.10"
authors = [
{name = "老张", email = "laozhanng@example.com"},
]
keywords = ["json", "config", "batch-processing"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Utilities",
]
# 运行时依赖
dependencies = [
"click>=8.0",
"pydantic>=2.0",
"rich>=13.0", # 漂亮的终端输出
]
# 可选依赖(用于扩展功能)
[project.optional-dependencies]
dev = [
"pytest>=7.0",
"pytest-cov>=4.0",
"mypy>=1.0",
"ruff>=0.2.0",
]
yaml = [
"pyyaml>=6.0", # 如果需要处理 YAML 配置
]
# CLI 入口点(pip install 后可以直接在命令行运行)
[project.scripts]
mytool = "mytool.cli:main"
[project.urls]
Homepage = "https://github.com/laozhanng/mytool"
Documentation = "https://mytool.readthedocs.io"
Repository = "https://github.com/laozhanng/mytool"
Issues = "https://github.com/laozhanng/mytool/issues"
[tool.hatch.version]
path = "src/mytool/__init__.py" # 版本号从这里读取
[tool.hatch.build.targets.sdist]
include = [
"/src",
"/tests",
]
[tool.hatch.build.targets.wheel]
packages = ["src/mytool"]包的核心代码
# src/mytool/__init__.py
"""mytool - Batch JSON config processor"""
__version__ = "0.1.0"
__author__ = "老张"
from mytool.core import JsonProcessor, ProcessResult
__all__ = ["JsonProcessor", "ProcessResult"]# src/mytool/core.py
from pathlib import Path
from typing import Optional
from pydantic import BaseModel
import json
import logging
logger = logging.getLogger(__name__)
class ProcessResult(BaseModel):
"""处理结果"""
file: str
success: bool
message: str = ""
output: Optional[dict] = None
class JsonProcessor:
"""JSON 配置文件批处理器"""
def __init__(self, schema: Optional[dict] = None):
self.schema = schema
def process_file(self, file_path: Path) -> ProcessResult:
"""处理单个文件"""
try:
content = file_path.read_text(encoding="utf-8")
data = json.loads(content)
if self.schema:
self._validate(data)
output = self._transform(data)
return ProcessResult(
file=str(file_path),
success=True,
output=output,
)
except json.JSONDecodeError as e:
return ProcessResult(
file=str(file_path),
success=False,
message=f"Invalid JSON: {e}",
)
except Exception as e:
return ProcessResult(
file=str(file_path),
success=False,
message=str(e),
)
def process_directory(self, dir_path: Path, pattern: str = "*.json") -> list[ProcessResult]:
"""批量处理目录下的所有 JSON 文件"""
files = list(dir_path.glob(pattern))
if not files:
logger.warning(f"No files matching {pattern} in {dir_path}")
return []
return [self.process_file(f) for f in files]
def _transform(self, data: dict) -> dict:
"""数据转换逻辑(子类可覆盖)"""
return data
def _validate(self, data: dict):
"""Schema 验证"""
# 简化的验证逻辑
pass# src/mytool/cli.py
import click
from pathlib import Path
from rich.console import Console
from rich.table import Table
from mytool.core import JsonProcessor
console = Console()
@click.group()
@click.version_option()
def main():
"""mytool - Batch JSON config processor"""
pass
@main.command()
@click.argument("path", type=click.Path(exists=True))
@click.option("--pattern", default="*.json", help="File pattern to match")
@click.option("--output-dir", "-o", type=click.Path(), help="Output directory")
@click.option("--verbose", "-v", is_flag=True, help="Verbose output")
def process(path: str, pattern: str, output_dir: str, verbose: bool):
"""Process JSON files in PATH"""
processor = JsonProcessor()
dir_path = Path(path)
if dir_path.is_file():
results = [processor.process_file(dir_path)]
else:
results = processor.process_directory(dir_path, pattern)
# 用 rich 输出美观的表格
table = Table(title=f"Processing Results ({len(results)} files)")
table.add_column("File", style="cyan")
table.add_column("Status", style="green")
table.add_column("Message")
success_count = 0
for result in results:
status = "✓" if result.success else "✗"
status_style = "green" if result.success else "red"
table.add_row(
result.file,
f"[{status_style}]{status}[/{status_style}]",
result.message or "",
)
if result.success:
success_count += 1
console.print(table)
console.print(f"\n[bold]{'Success' if success_count == len(results) else 'Partial'}:[/bold] {success_count}/{len(results)} files processed")踩坑实录一:包名冲突
现象: pyproject.toml 里把包名设为 config-tools,注册 PyPI 时报错:包名已存在。
原因: PyPI 上有几十万个包,很多通用名字早就被占了。而且 PyPI 的包名是大小写不敏感的,config-tools、Config-Tools、config_tools 会被认为是同一个名字。
解法:
- 先在 PyPI 上搜一下你想用的名字
- 加上前缀区分,比如
laozhanng-config-tools(用你的 GitHub 用户名) - 名字里用连字符
-(安装时),包内部用下划线_(import 时)
踩坑实录二:测试环境和正式环境分开
PyPI 有一个测试环境 Test PyPI(testpypi.pypa.io),第一次发布强烈建议先发到测试环境验证。
# 构建
python -m build
# 上传到 Test PyPI(不影响正式 PyPI)
twine upload --repository testpypi dist/*
# 从 Test PyPI 安装验证
pip install --index-url https://test.pypi.org/simple/ mytool我就是没有先用测试环境,直接发了正式版,结果发现 README 里的代码示例有个语法错误,已经发出去的包改不了了(必须发新版本)。
完整发布流程
# 1. 安装发布工具
pip install build twine
# 2. 构建
python -m build
# 生成 dist/ 目录,里面有 .whl(wheel)和 .tar.gz(source distribution)
# 3. 检查构建结果
twine check dist/*
# 4. 先发 Test PyPI
twine upload --repository testpypi dist/*
# 5. 测试安装
pip install --index-url https://test.pypi.org/simple/ mytool
python -c "import mytool; print(mytool.__version__)"
mytool --help # 验证 CLI 入口点
# 6. 没问题,发正式 PyPI
twine upload dist/*配置 PyPI token(比用户名密码更安全):
在 PyPI 账号设置里生成 API token,然后创建 ~/.pypirc:
[pypi]
username = __token__
password = pypi-AgEIcH...(你的 token)
[testpypi]
username = __token__
password = pypi-AgEIcH...(Test PyPI 的 token)踩坑实录三:依赖版本策略
现象: 把某个依赖写成 pydantic==2.5.3(精确版本),用户安装时报错 "version conflict with other packages"。
原因: 精确版本限制太严格,和用户其他已安装的包版本冲突。
正确的依赖版本策略:
dependencies = [
"click>=8.0,<9.0", # 大版本锁定:接受 8.x 的所有小版本
"pydantic>=2.0", # 最低版本:2.0 及以上都行
"rich>=13.0", # 同上
# 不要用 == 精确版本,那是 lock file 的事
]精确版本锁定(pip freeze > requirements.txt 那种)是应用程序用的,包库不应该这样做,否则安装冲突。
自动化版本管理和 CI 发布
用 GitHub Actions 自动化发布流程:
# .github/workflows/publish.yml
name: Publish to PyPI
on:
push:
tags:
- 'v*' # 只有打 tag(如 v0.1.0)时才触发
jobs:
publish:
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/mytool
permissions:
id-token: write # 用 OIDC,不需要存储 token
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: pip install build
- name: Build
run: python -m build
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
# OIDC 方式,不需要在 Secrets 里存 token发布新版本时:
git tag v0.2.0
git push origin v0.2.0
# GitHub Actions 自动构建并发布到 PyPI发布第一个包之后,我觉得最值的不是"让别人用上了我的工具",而是整个过程迫使我认真想清楚了包的接口设计、版本兼容性、文档——这些在写普通脚本时很容易忽略的东西。
