Languages

Python Packaging and Dependency Locking: From pyproject.toml to PyPI

For turning scripts into publishable packages: pyproject.toml for metadata, venv isolation, lock files for reproducible environments, and releasing to PyPI with twine.

By LaoHand Team·8 min read·Updated 2026-09-06

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.lock

Mistake: 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.3

Local 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

Official References

Each command links to its official documentation below, so you can verify the latest usage and read deeper.