From Bare Script to Package: Why pyproject.toml
Throwing a .py at someone means missing deps, mismatched versions, and environments that will not install. pyproject.toml centrally declares name, version, entry points, and dependency ranges — the single description file every modern tool reads.
It carries at least three things: the build-system declaration, project metadata (name/version/dependencies), and optionally tool config sections (ruff, black, pytest).
Either setuptools or hatchling as the backend works; both are configured from pyproject.toml, differing mainly in style and auto-version support, so pick what your team already knows.
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "mypkg"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"requests>=2.31",
"click>=8.0",
]
[project.optional-dependencies]
dev = ["pytest>=8", "ruff>=0.4"]
[project.scripts]
mycli = "mypkg.cli:main"
[tool.setuptools.packages.find]
where = ["src"]venv + Lock Files: Making the Environment Reproducible
pyproject.toml only states dependency upper bounds, so the same config can resolve differently across machines. Reproducibility needs a lock file pinning each dependency to an exact version.
The old-school pip freeze > requirements.txt writes a snapshot of what is installed, which leaks dev clutter; prefer uv, poetry, or pip-tools, which resolve from pyproject and separate dev from prod.
Always install into a virtual env (.venv) locally, commit the lock file, and one command restores dependencies identical to development for colleagues or CI.
# 用 uv 锁依赖(示例)
python -m venv .venv
.venv/Scripts/activate # Windows
# source .venv/bin/activate # macOS/Linux
uv pip compile pyproject.toml -o requirements.lock
uv pip sync requirements.lock
# pip-tools 等价
# pip-compile pyproject.toml -o requirements.lock
# pip-sync requirements.lockMistake: Pinning ==1.2.3 Everywhere Hides Landmines
Hardcoding ==1.2.3 in pyproject leads to unusable new machines (no security patches resolve), drifting transitive versions (your pinned top-level pin does not pin the tree), and version wars when several projects share a package.
Separate the layers: put a meaningful range in pyproject (>=1.2, <1.3 or ~=1.2) so the resolver picks whatever fits; keep a lock file with exact versions for reproducibility.
Bump by editing the lock with pyproject together and run tests before committing, so version changes are never silent.
# 错误示范:把写死的精确版本放进元数据
# dependencies = ["requests==2.31.0", "pydantic==2.5.3"]
# 修复:范围声明放 pyproject,精确版本交给 lock
[project]
dependencies = [
"requests~=2.31.0",
"pydantic>=2.5,<3.0",
]
# lock 文件里才写精确值:
# requests==2.31.0
# pydantic==2.5.3Local Build and Release to PyPI: from sdist/wheel to twine
Before releasing, build verifiable artifacts locally: a wheel (universal or platform-specific) and an sdist (source). Generate both with python -m build; do not ship only a wheel and skip sdist.
Upload with twine to TestPyPI or real PyPI. Before a release, verify README renders, the license field is set, and the version is not a repeat (same versions cannot be re-uploaded).
Step through python -m build, twine check; keep account tokens in environment or a password manager via .pypirc or vars, never hardcode them into scripts.
# 本地构建
python -m build
# dist/ 生成 .whl 与 .tar.gz
# 预检产物
twine check dist/*
# 上传到 PyPI(账号 token 由环境提供)
export TWINE_USERNAME="__token__"
export TWINE_PASSWORD="$PYPI_API_TOKEN"
twine upload dist/*
# 想先上 TestPyPI:
# twine upload --repository testpypi dist/*Verify the Release Installs From a Clean Environment
Releasing is not done until you prove a fresh install from PyPI works — the step many skip.
Create a throwaway venv, install only the wheel/sdist you published, smoke-test the import and the CLI entry point.
Similarly give the publish step in CI its own verification, so you are not hand-installing in a container each time to prove the release.
# 干净环境一次性验证
python -m venv /tmp/verify
/tmp/verify/Scripts/activate # Windows
# source /tmp/verify/bin/activate # macOS/Linux
pip install mypkg==0.1.0
python -c "import mypkg; print(mypkg.__version__)"
mycli --help # CLI 入口冒烟
pip list | grep mypkg