Post

Python Build with pip, setuptools and build

The classic Python build workflow with pip, setuptools, build and twine, the files it needs, how to lint, scan and test, and a complete GitHub Actions pipeline.

Python Build with pip, setuptools and build

The classic Python toolchain splits responsibilities between small tools: pip installs packages, setuptools (or another backend) knows how to build a package, build is the standard front end that calls the backend, and twine uploads the result. It is still the most common setup in enterprise codebases and the base every newer tool builds on.

This post is part of Build Ecosystems by Programming Language. The modern lockfile-based alternative is covered in uv and Poetry, and deployment (Docker, AWS SAM, Azure Functions) in the Python Build and Deployment Tools Guide.

At a glance

CategoryCommon tools
Build toolsbuild (PyPA front end), setuptools, hatchling, flit; pip-tools for locking; tox / nox for task running
Package registryPyPI, private Artifactory / Nexus / devpi
Build filespyproject.toml, requirements.txt, requirements.in, setup.cfg / setup.py (legacy), MANIFEST.in, tox.ini
Lint and formatRuff, Black, Flake8, Pylint, isort; mypy / Pyright for types
SAST (open source)Bandit, Semgrep, CodeQL, SonarQube Community Build
SAST (commercial)Checkmarx One, Veracode, Snyk Code, OpenText Fortify, SonarQube Server
SCApip-audit, Safety, OSV-Scanner, Trivy, Snyk Open Source, Mend, Black Duck
FrameworksDjango, Flask, FastAPI
Test and coveragepytest, unittest, Hypothesis; coverage.py / pytest-cov
Build outputwheel (.whl), source distribution (.tar.gz)
CI cache~/.cache/pip

How the build works

In CI, each stage of the generic build pipeline maps to a pip and build command, plugin or scanner. Press play, or step through it:

  1. Source + pyproject.toml: A commit triggers CI. pyproject.toml declares the project and build backend, and requirements.txt (compiled by pip-tools) pins versions with hashes.
  2. Install: pip install -r requirements.txt --require-hashes in a fresh venv installs exactly the locked versions; CI caches ~/.cache/pip.
  3. Lint and format: ruff check . && ruff format --check . lint and check formatting; mypy or Pyright add type checking.
  4. Install the project: pip install -e ".[dev]" installs the project and its dev tools, so tests run against the real package metadata.
  5. Test and coverage: pytest --cov=src --cov-fail-under=80 runs the tests and fails if line coverage drops below 80%.
  6. Security scans in parallel: bandit -r src -ll checks our code, pip-audit checks the dependencies against known vulnerabilities, and Gitleaks scans for secrets.
  7. Quality gate: Lint, tests with the 80% coverage gate and both scanners are required checks; one failure stops the pipeline.
  8. Fail the build (fail): A failed check stops the pipeline at the gate, so nothing is packaged, signed or published.
  9. Build the distributions (pass): python -m build creates the wheel (.whl) and sdist (.tar.gz) in dist/, and twine check validates their metadata.
  10. SBOM and signing (pass): cyclonedx-py writes sbom.json from requirements.txt, and the distributions are signed with Sigstore cosign.
  11. Publish (pass): twine upload publishes dist/ to PyPI or a private index (Artifactory, Nexus, devpi).
  • Two separate flows. Libraries are built into wheels and published; applications are usually installed from a pinned requirements.txt into a virtual environment or container.
  • PEP 517/518 made the backend pluggable: the [build-system] table tells any front end which backend to install and call. python setup.py install and setup.py bdist_wheel are deprecated.
  • pip-tools (pip-compile) turns loose requirements in requirements.in into a fully pinned, hashed requirements.txt, which is the classic way to get a lockfile.

Build files

FilePurposeCommit?
pyproject.tomlBuild backend, project metadata, tool config (Ruff, pytest, mypy)Yes
requirements.inTop-level application dependencies (input for pip-compile)Yes
requirements.txtFully pinned dependencies, ideally with --hashYes
requirements-dev.txtLint, test and scan toolsYes
setup.cfg / setup.pyLegacy setuptools configurationYes, if still used
MANIFEST.inExtra files to include in the sdistYes
tox.ini / noxfile.pyTest matrix across Python versionsYes
pip.conf / pip.iniIndex URL for a private registryNot with credentials
.pypircUpload credentials for twineNo
dist/, build/, *.egg-info/, .venv/Outputs and environmentsNo

Commands by category

1
2
3
4
5
6
7
8
9
python -m venv .venv && . .venv/bin/activate     # isolated environment
pip install -r requirements.txt --require-hashes # reproducible install
pip-compile --generate-hashes requirements.in    # lock (pip-tools)
ruff check . && ruff format --check .            # lint + format
bandit -r src -ll                                # SAST
pip-audit -r requirements.txt                    # SCA
pytest --cov=src --cov-fail-under=80             # tests + coverage gate
cyclonedx-py requirements requirements.txt -o sbom.json   # SBOM
python -m build && twine check dist/*            # build wheel + sdist and validate metadata

Minimal project

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# pyproject.toml (src layout)
[build-system]
requires = ["setuptools>=75", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "demo"
version = "1.0.0"
requires-python = ">=3.11"
dependencies = ["requests>=2.32"]

[project.optional-dependencies]
dev = ["pytest", "pytest-cov", "ruff", "bandit", "pip-audit", "build", "twine"]

[tool.setuptools.packages.find]
where = ["src"]

[tool.pytest.ini_options]
addopts = "--cov=demo --cov-report=term-missing"

[tool.ruff]
line-length = 100

Frameworks

FrameworkStart a project
Djangopip install django && django-admin startproject mysite
Flaskpip install flask then flask --app app run
FastAPIpip install "fastapi[standard]" then fastapi dev main.py

CI with GitHub Actions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
name: python-ci
on: [push, pull_request]
permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ['3.11', '3.12', '3.13']
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
          cache: pip

      - run: pip install -e ".[dev]"
      - run: ruff check . && ruff format --check .
      - run: bandit -r src -ll
      - run: pip-audit
      - run: pytest --cov-fail-under=80
      - run: python -m build && twine check dist/*

      - uses: actions/upload-artifact@v4
        with:
          name: dist-${{ matrix.python-version }}
          path: dist/

  publish:
    if: startsWith(github.ref, 'refs/tags/v')
    needs: build
    runs-on: ubuntu-latest
    permissions:
      id-token: write                          # PyPI trusted publishing, no API token
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: dist-3.13
          path: dist/
      - uses: pypa/gh-action-pypi-publish@release/v1

Best practices

  • Always use a virtual environment; never sudo pip install on build agents.
  • Use the src/ layout so tests run against the installed package, not the working directory.
  • Pin with hashes (pip-compile --generate-hashes, pip install --require-hashes) for applications; keep ranges in pyproject.toml for libraries.
  • Move configuration into pyproject.toml and drop setup.py unless you need dynamic logic.
  • Use trusted publishing to PyPI from CI instead of long-lived API tokens.
  • Build native wheels with cibuildwheel when the package contains C extensions.
  • Route installs through a private index (PIP_INDEX_URL) to cache and control packages.
This post is licensed under CC BY 4.0 by the author.