Virtual Environments and Packaging

This section documents the current Python release line as published at the official Python documentation, which is the reference these pages are written and verified against. No specific patch version is pinned.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

Two projects on the same machine often need different, conflicting versions of the same third-party package. A virtual environment gives each project its own private set of installed packages, so installing one project’s dependencies never breaks another’s.

Why Virtual Environments

Without isolation, every pip install writes into the one global Python installation shared by every script and project on the machine. Upgrading a package for project A can silently break project B, and there is no record of which packages a given project actually needs. A virtual environment is a self-contained directory holding its own Python interpreter (or a link to one) and its own site-packages — packages installed while it is active are invisible to any other environment. The standard library ships this as the venv module, covered in the tutorial’s Virtual Environments and Packages chapter and specified in full under venv.

Creating and Activating an Environment

Create one with python -m venv, naming the directory that will hold it — .venv is the common convention:

python -m venv .venv

Activation adjusts the current shell so python and pip resolve to the environment’s own copies; the command differs per OS and shell:

# macOS / Linux (bash or zsh)
source .venv/bin/activate
# Windows (Command Prompt)
.venv\Scripts\activate.bat
# Windows (PowerShell)
.venv\Scripts\Activate.ps1

Once active, the shell prompt is usually prefixed with the environment’s name, and python -m pip list shows only what has been installed inside it:

(.venv) $ which python
/path/to/project/.venv/bin/python

Deactivate with a single command that works the same on every platform:

deactivate

Deleting the environment is just deleting its directory — nothing outside .venv/ is ever touched:

rm -rf .venv

Installing Packages with pip

pip is the package installer bundled with Python; it downloads a package and its dependencies and installs them into whichever environment is currently active. Its full command reference lives at pip documentation.

pip install requests
pip install requests==2.31.0        # pin an exact version
pip install "requests>=2.28,<3"     # constrain a range

Uninstalling removes a package the same way:

pip uninstall requests

Freezing and requirements.txt

pip freeze prints every installed package with the exact version currently resolved, in a format pip can read back:

pip freeze > requirements.txt
certifi==2024.7.4
charset-normalizer==3.3.2
idna==3.7
requests==2.32.3
urllib3==2.2.2

Committing requirements.txt to version control lets anyone — a teammate, or a CI job — recreate the exact same set of installed packages in a fresh environment:

python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt

PyPI and pyproject.toml

PyPI (the Python Package Index, https://pypi.org) is the public repository pip install <name> downloads from by default — searching it by name or keyword is how most third-party packages are discovered before they are added to a project. The full packaging ecosystem built around it — how a project declares its own metadata, dependencies, and build process — is documented at the Python Packaging User Guide.

A modern project declares that metadata in a single file, pyproject.toml, at its root. Unlike requirements.txt (a flat list of installed versions), pyproject.toml describes the project itself — its name, dependencies, and the tool used to build a distributable package from it:

[project]
name = "my-project"
version = "0.1.0"
description = "A small example project"
dependencies = [
    "requests>=2.28",
]

[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"

The [build-system] table only names which build backend to use (setuptools above); the backend itself is a separate tool responsible for turning the project into an installable distribution, a topic the Packaging User Guide covers on its own. The rest of this section covers building and publishing a release with it.

Building Distributions

python -m build (the build package, pip install build) reads pyproject.toml and produces two distributable files under dist/: a sdist (.tar.gz, the source distribution) and a wheel (.whl, a prebuilt distribution pip installs without running the build backend again). The [build-system] table above only names which backend does that work — setuptools is this page’s example, but hatchling and poetry-core are interchangeable alternatives that read the same [project] metadata:

python -m build              # writes dist/my_project-0.1.0.tar.gz and dist/my_project-0.1.0-py3-none-any.whl

Creating PyPI and TestPyPI Accounts

Publishing needs an account at pypi.org, and PyPI requires two-factor authentication to be enabled on that account before it will accept a publish. A separate instance, test.pypi.org, exists specifically for dry-run publishes — it has its own independent account and package namespace, so a broken upload or a name collision there never touches the real index.

Generating an API Token

Account Settings → API tokens creates a scoped token; PyPI recommends scoping it to a single project rather than the whole account, which is only possible after that project’s first manual upload has created it on the index. The token is used with twine as a username/password pair, where the username is always the literal string token:

export TWINE_USERNAME=__token__
export TWINE_PASSWORD=pypi-AgEIcHlwaS5vcmc...   # the generated token, including its pypi- prefix

Publishing with twine

twine (pip install twine) uploads the files python -m build produced. A dry run against TestPyPI first catches metadata or packaging problems without touching the real index:

twine upload --repository testpypi dist/*
twine upload dist/*                        # the real release, once the TestPyPI upload looks right

PyPI Trusted Publishing (OIDC)

The recommended approach for a CI-driven release is PyPI’s Trusted Publishing, which needs no stored API token at all. A Trusted Publisher is configured once on pypi.org (under the project’s Publishing settings), naming the exact GitHub repository, workflow filename, and (optionally) environment allowed to publish; PyPI then accepts a GitHub Actions OIDC token asserting the workflow run matches, in place of any credential:

name: build

on:
  push:
    branches: [ main ]
    tags: [ 'v*' ]
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      id-token: write        # required for PyPI Trusted Publishing

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: '3.13'
          cache: 'pip'

      - run: pip install build
      - run: python -m build

      - name: Publish to PyPI
        if: startsWith(github.ref, 'refs/tags/v')
        uses: pypa/gh-action-pypi-publish@release/v1

See Also