../

Packages & libraries

Python projects with uv 0.12 (Astral's all-in-one tool: Python installs, venvs, locking, running, building, publishing): virtual environments, pyproject.toml, layouts, imports, lockfiles, tooling config and the go-to library for each job. Targets Python 3.14. Tests are in Testing; the TypeScript equivalent is Modules & packages.

uv at a glance

curl -LsSf https://astral.sh/uv/install.sh | sh  # or:
brew install uv
uv self update                 # standalone installs only
CommandDoes
uv initnew project: pyproject.toml, .python-version, README, git
uv add httpx / uv remove httpxedit dependencies, re-lock, sync .venv
uv syncmake .venv match uv.lock exactly (creates both if missing)
uv lockresolve and write uv.lock without installing
uv run CMDlock + sync if stale, then run CMD inside .venv
uv treedependency tree; --outdated shows newer versions
uv version --bump minorbump project.version (major, minor, patch, alpha, rc, …)
uv formatrun the Ruff formatter over the project
uv checktype-check with ty (experimental in 0.12)
uv auditscan locked deps for known vulnerabilities (experimental)
uv exportuv.lock to requirements.txt, pylock.toml or CycloneDX
uv build / uv publishsdist + wheel into dist/; upload to PyPI
uv tool install / uvxglobal CLI tools / run a tool once
uv python installdownload a managed CPython or PyPy
uv pip …, uv venvpip-compatible low-level interface

There's no source .venv/bin/activate step: uv run finds the project, checks the lock is fresh and runs in its venv. Activate only if you want bare python/pytest in your shell. What a venv is and how to drive one by hand is in Virtual environments.

Python versions

uv python install 3.14         # managed CPython
uv python install 3.14t        # free-threaded (no GIL) build
uv python install --default    # python, python3 on PATH
uv python pin 3.14             # writes .python-version
uv python list --only-installed
uv python upgrade 3.14         # latest patch release
uv run -p 3.13 pytest          # one run on another version
File or fieldRole
.python-versionthe interpreter uv uses for this project's .venv (like .nvmrc)
requires-python = ">=3.14"versions the project supports; the lock resolves for all of them
uv python findwhich interpreter uv would pick here

uv downloads a missing interpreter on first use, so a fresh clone needs only uv sync.

Virtual environments

A virtual environment (venv) is a folder with its own python and its own site-packages, so each project's packages are kept apart from every other project's and from the operating system's Python. It is not a virtual machine or a container: it reuses an installed interpreter (a symlink, or a copy on Windows) and only isolates installed packages. Python has one version of each package per environment, so two projects that need different versions of the same library each need their own.

base Python 3.14 interpreter and standard library system site-packages the OS's packages a venv can't see these shop/.venv/ bin/python: a link to the base lib/…/site-packages/ httpx 0.28.1 · pydantic report/.venv/ bin/python: a link to the base lib/…/site-packages/ httpx 0.27.2 · pandas one interpreter, many venvs: each project has its own packages and versions
Venvs share one interpreter but not packages, so two projects can pin different versions of the same library.
.venv/ on macOS and Linux
.venv/pyvenv.cfg              # home = base interpreter's dirbin/python              # -> python3.14 (a symlink)pipactivate            # for bash and zshactivate.fishlib/python3.14/site-packages/  # what pip installs lands here

On Windows the folders are .venv\Scripts\ (with python.exe, activate.bat, Activate.ps1) and .venv\Lib\site-packages\.

Create, activate, use

cd myproject
python3 -m venv .venv        # create it (standard library)
source .venv/bin/activate    # bash/zsh; prompt: (.venv)
# fish: source .venv/bin/activate.fish
which python                 # …/myproject/.venv/bin/python
python -m pip install httpx  # installs into .venv only
python -m pip list
deactivate                   # back to the outer python
python -m venv optionDoes
--prompt NAMElabel shown in the shell prompt instead of the folder name
--upgrade-depsupgrade pip in the new venv to the latest release
--clearempty an existing venv folder and start over
--system-site-packagesalso see the base interpreter's packages (rarely what you want)
--without-pipno pip at all (uv-managed venvs work this way)
--upgradere-point the venv after the base Python was upgraded in place (patch releases)

What activation does

Activation is a convenience, nothing more: it puts .venv/bin (or .venv\Scripts) first on PATH, sets VIRTUAL_ENV, adds (.venv) to the prompt and defines deactivate. Running the venv's interpreter by path does the same job without it, which is what you want in scripts, cron jobs, systemd units and Dockerfiles:

.venv/bin/python -m pytest        # no activation needed
/srv/app/.venv/bin/python app.py  # absolute path
before $ python3 -c 'import httpx' PATH, searched in order: /usr/local/bin no python3 /usr/bin python3 /bin → /usr/bin/python3, system packages ModuleNotFoundError: httpx after source .venv/bin/activate (.venv) $ python3 -c 'import httpx' PATH, searched in order: /home/alice/shop/.venv/bin python3 /usr/local/bin /usr/bin /bin → .venv/bin/python3, the venv's packages imports httpx VIRTUAL_ENV=/home/alice/shop/.venv
Activation only puts .venv/bin first on PATH; the first python3 the shell finds wins.

Am I in the right one?

python -c "import sys; print(sys.prefix)"  # venv folder
python -c "import sys; print(sys.prefix != sys.base_prefix)"
echo "$VIRTUAL_ENV"           # set by activate
python -m pip -V              # pip's site-packages path

sys.prefix != sys.base_prefix is True inside any venv. Always install with python -m pip rather than bare pip: it guarantees the pip belongs to the python you are about to run.

With uv

uv venv                  # .venv, uv picks the Python
uv venv --python 3.14    # a specific version
uv venv --seed           # also put pip in it
uv pip install httpx     # into .venv, no activation
uv pip install -r requirements.txt
uv run python app.py     # uses .venv automatically

In a uv project you rarely create a venv yourself: uv sync, uv add and uv run create .venv and keep it matching uv.lock. A uv venv has no pip unless you pass --seed; use uv pip instead.

Rules of thumb

DoWhy
One venv per project, named .venv, in the project rootVS Code, PyCharm and uv find it on their own
Keep it out of git (.venv/ in .gitignore)it is machine-specific; rebuild it from the lockfile or requirements.txt. Python 3.13+ also writes a .gitignore inside new venvs
Delete and recreate instead of repairingrm -rf .venv and reinstall takes seconds and fixes most mysteries
Don't move or rename a venv folderabsolute paths are written into its scripts and activate
Recreate it after a Python minor upgrade (3.13 → 3.14)the venv points at the old interpreter, and compiled packages target its ABI
Never sudo pip install into the system Pythonit can break OS tools; most Linux distributions now refuse it outright (PEP 668)
Install command-line tools with uv tool install or pipxeach tool gets its own venv, and the tool lands on PATH

Editors and notebooks

  • VS Code: Python: Select Interpreter from the command palette, then pick .venv; new terminals then activate it automatically.
  • PyCharm: Settings → Project → Python Interpreter → Add → existing environment, choose .venv.
  • Jupyter: give the notebook server a kernel that runs in the venv: python -m pip install ipykernel, then python -m ipykernel install --user --name myproject, and choose myproject as the kernel.

Troubleshooting

SymptomCauseFix
error: externally-managed-environmentthe OS Python is marked as managed by the package manager (PEP 668)make a venv and install there; uv tool or pipx for CLIs
ModuleNotFoundError although you installed itit went into a different interpretercompare which python with python -m pip -V; activate or select .venv in the editor
ensurepip is not available when creating a venvDebian and Ubuntu ship venv separatelysudo apt install python3-venv (or python3.14-venv)
bad interpreter: No such file or directorythe venv was moved, or its base Python was removed or upgradeddelete .venv and recreate it
pip still runs the system pip after activatingthe shell cached the old pathhash -r (bash) or rehash (zsh), or use python -m pip
Activate.ps1 cannot be loadedPowerShell's execution policySet-ExecutionPolicy -Scope CurrentUser RemoteSigned
ToolWhat it manages
venvstandard library; makes the environment only
virtualenvthe older third-party tool venv was based on; more options, faster creation
uvPython versions, venvs, locking and installs (this sheet)
pyenvPython versions only; pair it with venv
conda / mambaenvironments that also hold non-Python libraries (CUDA, GDAL); its own package channels
Poetry, PDM, Hatchproject managers that create and manage a venv per project

Project workflow

uv init shop                   # packaged app, src/ layout
uv init --lib shop             # library (adds py.typed)
uv init --no-package scripts   # flat main.py, no build
cd shop
uv add httpx "pydantic>=2.11"  # [project] dependencies
uv add --dev pytest ruff       # dependency group "dev"
uv add --group docs mkdocs     # another group
uv add --optional cli typer    # extra: pip install shop[cli]
uv add "git+https://github.com/org/lib@v1.2.0"
uv add --editable ../shared    # local path dependency
uv add -r requirements.txt     # import an old project
uv remove httpx
uv lock --upgrade-package httpx  # bump one dep in the lock
uv lock --upgrade              # bump everything allowed
uv sync --locked               # CI: fail if lock is stale
uv run python -m shop
uv run --with rich python      # extra package, this run only
uv run --env-file .env uvicorn app:app
uv sync / uv run flagEffect
--lockederror if uv.lock doesn't match pyproject.toml (use in CI)
--frozenuse uv.lock as is, never re-lock
--no-devskip the dev group (production images)
--group NAME / --all-groupsadd groups on top of the defaults
--extra NAME / --all-extrasinstall optional dependencies
--no-install-projectdeps only; good for a cached Docker layer
--inexactkeep packages that aren't in the lock (sync is exact by default)
--all-packages / --package NAMEworkspace: every member / one member

uv add writes a lower bound (httpx>=0.28.1) by default; --bounds major writes a caret-style range instead (rich>=15.0.0,<16.0.0, or httpx>=0.28.1,<0.29.0 for 0.x). The lock pins exact versions and hashes either way.

Tools & scripts

uvx ruff check .               # run a tool in a cached env
uvx ruff@0.16.9 check .        # a specific version
uvx --from httpie http GET example.com  # package ≠ command
uv tool install ruff           # put ruff on PATH for good
uv tool list
uv tool upgrade --all
uv tool install -e .           # your own CLI, editable

uvx is uv tool run. Tools get their own isolated venvs, so they never clash with a project; to use a tool that must import your code (pytest, mypy), add it to the project's dev group and uv run it.

Scripts with inline metadata (PEP 723)

A single .py file declares its own Python and dependencies in a comment block; uv run builds a cached env for it. No project needed.

uv init --script tool.py --python 3.14
uv add --script tool.py httpx rich  # edits the header
uv run tool.py
uv lock --script tool.py           # tool.py.lock beside it

pip & venv equivalents

Taskuv projectuv pip (drop-in)pip / venv
create venvautomatic (.venv)uv venvpython -m venv .venv
install a packageuv add httpxuv pip install httpxpip install httpx
from a fileuv add -r requirements.txtuv pip install -r requirements.txtpip install -r requirements.txt
editable installuv sync (project is editable)uv pip install -e .pip install -e .
lockuv lockuv pip compile requirements.in -o requirements.txtpip-compile (pip-tools), pip lock
install exactly the lockuv syncuv pip sync requirements.txtpip-sync
list / freezeuv tree / uv exportuv pip list / uv pip freezepip list / pip freeze
uninstalluv remove httpxuv pip uninstall httpxpip uninstall httpx
run a tool onceuvx blackpipx run black
install a tool globallyuv tool install blackpipx install black

uv pip works on whatever venv is active (or .venv) and never touches pyproject.toml; use it for legacy requirements.txt repos. Prefer the project commands for anything new.

Coming from Bun & pnpm

Bun / pnpm / npmuv / Python
package.jsonpyproject.toml
bun.lock, pnpm-lock.yamluv.lock
node_modules/.venv/ (one per project; site-packages inside)
bun install, pnpm installuv sync
bun add zoduv add pydantic
bun add -d vitest / devDependenciesuv add --dev pytest / [dependency-groups]
optionalDependenciesextras ([project.optional-dependencies]), chosen by the installer: shop[cli]
bun update zoduv lock --upgrade-package pydantic
bun outdateduv tree --outdated
bun run dev ("scripts")uv run CMD; no scripts table, so use [project.scripts], a Makefile or just
bunx, pnpm dlx, npxuvx
bun add -guv tool install
"bin"[project.scripts]
.nvmrc, "engines".python-version, requires-python
pnpm-workspace.yaml, "workspace:*"[tool.uv.workspace], { workspace = true }
.npmrc registry[[tool.uv.index]]
npm publishuv build && uv publish
npm registryPyPI
^1.2.3>=1.2.3,<2
~1.2.3~=1.2.3
ESLint + PrettierRuff (ruff check, ruff format)
tsc --noEmit / tsconfig.jsonmypy, Pyright or ty / [tool.mypy], [tool.pyright]

The big difference: Python has one version of each package per environment. There's no nested node_modules, so two dependencies that need incompatible versions of a third can't be installed together.

pyproject.toml

pyproject.toml
[project]
name = "shop"              # PyPI name; import name is shop
version = "0.1.0"
description = "Carts and invoices"
readme = "README.md"
requires-python = ">=3.14"
license = "MIT"            # SPDX expression
authors = [{ name = "Ada", email = "ada@example.com" }]
dependencies = [
  "httpx>=0.28",
  "pydantic>=2.11,<3",
  "uvloop>=0.21; sys_platform != 'win32'",  # marker
]
 
[project.optional-dependencies]  # extras: shop[cli]
cli = ["typer>=0.27"]
 
[project.scripts]          # console commands
shop = "shop.cli:app"
 
[project.entry-points."pytest11"]  # plugin hooks
shop = "shop.pytest_plugin"
 
[project.urls]
Homepage = "https://github.com/acme/shop"
 
[dependency-groups]        # dev-only, never published
dev = ["pytest>=9", "ruff>=0.16"]
docs = ["mkdocs-material>=9"]
ci = [{ include-group = "dev" }, "pytest-cov>=7"]
 
[build-system]
requires = ["uv_build>=0.12.19,<0.13"]
build-backend = "uv_build"
 
[tool.uv]
default-groups = ["dev", "docs"]  # synced by default
TableHoldsPublished?
[project]name, version, requires-python, runtime dependenciesyes (package metadata)
[project.optional-dependencies]extras users opt into: pip install "shop[cli]"yes
[dependency-groups]dev, test, docs deps (PEP 735); include-group composes themno
[project.scripts]name = "module:callable" commands created on installyes
[project.entry-points."group"]plugin registration other packages discoveryes
[build-system]backend that turns the source into a wheelused at build time
[tool.*]per-tool config: uv, ruff, mypy, pytest, coverageno

Build backends: uv_build (uv's default, pure Python only), hatchling, setuptools, flit_core, pdm-backend, and maturin or scikit-build-core for Rust or C extensions. Pick one with uv init --build-backend hatch. A project without [build-system] is never installed as a package: fine for an app you only uv run.

Versions & lockfiles

SpecifierMeansnpm
==1.4.2exactly this version1.4.2
>=1.4,<2range^1.4.0
~=1.4.2>=1.4.2, ==1.4.* (compatible release)~1.4.2
~=1.4>=1.4, ==1.*^1.4
==1.4.*any 1.4.x1.4.x
!=1.5.0exclude a broken release
pkg[extra]>=1with the package's extra
pkg; sys_platform == 'linux'environment marker (also python_version, platform_machine)
pkg @ git+https://…@tagdirect URL (not allowed on PyPI uploads)

Libraries: wide ranges with lower bounds, upper bounds only for known breaks. Apps: anything goes, because the lockfile pins exact versions.

LockfileMade byNotes
uv.lockuv lock (auto on add/run/sync)universal: one file for every OS, Python version, extra and group; commit it
pylock.tomluv export --format pylock.toml, pip lockPEP 751 standard (accepted 2025); pip 26.1+ installs it experimentally with pip install -r pylock.toml; uv pip install -r pylock.toml works too
requirements.txtuv export --format requirements.txt, uv pip compilefor tools and hosts that only read pip format; add --no-hashes if they choke on hashes

Keep uv.lock as the source of truth; export the others in CI when something downstream needs them.

Layout & imports

src layout (default for uv init)
shop/pyproject.tomluv.lock.python-versionsrc/shop/              # import shop__init__.py__main__.py    # python -m shoppy.typed       # ships type hintscart.py        # shop.cartpayments/__init__.pystripe.py  # shop.payments.stripetests/test_cart.py
flat layout
shop/pyproject.tomlshop/  # importable from the repo root__init__.pycart.pytests/
src layoutFlat layout
Import worksonly after install (uv sync does an editable install)from the repo root, installed or not
Tests run againstthe installed package, as users get itwhatever is on disk, which can hide packaging bugs
Good forlibraries, anything published, most appsscripts, notebooks, tiny apps
uv_builddefaultset module-root = "" under [tool.uv.build-backend]
TermWhat it is
moduleone .py file; import name = file name
packagea directory with __init__.py; can hold modules and subpackages
namespace packagea directory with no __init__.py (PEP 420); several distributions can share it (acme.core, acme.api)
distributionwhat you install from PyPI (pip install scikit-learn); its import name can differ (import sklearn)
__init__.pyruns on first import of the package; re-export the public API here
__main__.pyruns on python -m shop
py.typedempty marker file: the package ships inline type hints (PEP 561)
src/shop/__init__.py
"""Public API: callers import from shop, not submodules."""
 
from shop.cart import Cart, Line
 
__all__ = ["Cart", "Line"]  # what `from shop import *` gets
src/shop/payments/stripe.py
from shop.cart import Cart  # absolute: the default choice
 
from ..cart import Line  # relative: up one package
from .errors import PaymentError  # same package
 
 
def charge(cart: Cart) -> Line:
    if not cart.lines:
        raise PaymentError("empty cart")
    return cart.lines[-1]
src/shop/__main__.py
import sys
 
from shop.cli import main
 
sys.exit(main())
Import ruleDetail
Prefer absolute importsfrom shop.cart import Cart; relative ones (., ..) only work inside a package
Run package modules with -mpython src/shop/cart.py breaks relative imports; use uv run python -m shop.cart
Imports run oncethe module is cached in sys.modules; top-level code runs on first import only
Guard script codeif __name__ == "__main__": keeps it from running on import
Break import cyclesmove shared code to a third module, import inside the function, or import under if TYPE_CHECKING: for type-only use
Annotations are lazy (3.14)PEP 649: annotations are evaluated on demand, so TYPE_CHECKING imports work in hints without quotes or from __future__ import annotations
Package dataimportlib.resources.files("shop") / "data.json", not paths built from __file__

Workspaces

Several packages in one repo sharing one uv.lock and one .venv, like a pnpm monorepo (see pnpm & monorepos).

uv workspace
acme/pyproject.toml          # [tool.uv.workspace]uv.lock                 # one lock for all membersapps/api/pyproject.toml  # depends on coresrc/packages/core/pyproject.tomlsrc/
pyproject.toml (root)
[tool.uv.workspace]
members = ["apps/*", "packages/*"]
apps/api/pyproject.toml
[project]
name = "api"
dependencies = ["core"]
 
[tool.uv.sources]
core = { workspace = true }  # use the local member
uv init apps/api               # joins the workspace
uv add --package api core      # writes both lines above
uv sync --all-packages
uv run --package api pytest
uv workspace list

Every member shares one resolution, so they can't pin conflicting versions of a dependency. When they must, keep them as separate projects linked by path: { path = "../core", editable = true }.

Building & publishing

uv build                       # dist/*.tar.gz + *.whl
uv build --package core        # one workspace member
uv version --bump patch
uv publish                     # token or trusted publishing
uv publish --index testpypi    # index with publish-url
ArtifactWhat
sdist (.tar.gz)source plus pyproject.toml; the installer builds it
wheel (.whl)pre-built zip installed by copying; pure Python is py3-none-any

Trusted publishing: PyPI trusts a GitHub Actions workflow through OIDC, so there's no API token to store or leak. On PyPI, add a publisher (repo, workflow file, environment); then:

.github/workflows/release.yml
name: release
on:
  push:
    tags: ["v*"]
jobs:
  pypi:
    runs-on: ubuntu-latest
    environment: pypi
    permissions:
      id-token: write  # OIDC token for PyPI
      contents: read
    steps:
      - uses: actions/checkout@v7
      - uses: astral-sh/setup-uv@v10
      - run: uv build
      - run: uv publish  # detects trusted publishing

Lint, format & types

pyproject.toml
[tool.ruff]
line-length = 88  # target-version from requires-python
 
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
 
[tool.mypy]
strict = true
files = ["src", "tests"]
 
[tool.pyright]
typeCheckingMode = "strict"
 
[tool.ty.rules]
possibly-unresolved-reference = "error"
ToolJobRunStatus
Rufflinter + formatter + import sorter (replaces flake8, isort, Black)uvx ruff check --fix, uv formatstable
mypyreference type checkeruv run mypystable (2.x)
Pyrightfast checker; powers Pylance in VS Codeuv run pyrightstable
tyAstral's Rust type checker and language serveruvx ty check, uv checkbeta (0.0.x); fast, still filling gaps

Run type checkers from the project env (uv run, dev group) so they see your dependencies. Ruff doesn't need them, so uvx is fine. Pick one type checker for CI; mypy or Pyright if you want no surprises, ty if speed matters and you can live with rough edges.

Go-to libraries

JobPickNotes
HTTP clienthttpxsync + async, HTTP/2; httpx2 is Pydantic's maintained continuation
Validation, modelspydantictyped models, JSON (de)serialization
Web APIsfastapipydantic + type hints; FastAPI
CLItyper (or click)typer builds on click from type hints
ArraysnumpyNumPy
DataFramespandas or polarspandas; polars is faster and lazy
Classic MLscikit-learnscikit-learn
Deep learningpytorchPyTorch
Testingpytest, hypothesisTesting
Lint, formatruffone tool for both
Type checkingmypy or pyright (ty coming)see above
Settings, env varspydantic-settingstyped .env and env var loading
Dates, time zonesstdlib datetime + zoneinfo, or wheneverwhenever makes DST mistakes hard
Loggingstructlogstructured key-value logs; stdlib logging underneath
SQLsqlalchemy + psycopgpsycopg 3 is the Postgres driver; see Postgres
Asyncanyiostructured concurrency over asyncio or trio

Recipes

New app project

When starting a service or CLI you'll run but not publish.

uv init --app shop --python 3.14
cd shop
uv add fastapi uvicorn pydantic-settings
uv add --dev pytest ruff mypy
# write src/shop/main.py with app = FastAPI()
uv run uvicorn shop.main:app --reload
git add pyproject.toml uv.lock .python-version

New library with src layout

When the code will be published or shared between projects.

uv init --lib acme-geo         # import name: acme_geo
cd acme-geo
uv add "httpx>=0.28"           # keep lower bounds loose
uv add --dev pytest mypy ruff
uv run pytest && uv run mypy src
uv build && ls dist

Add a CLI entry point

When the project should install a command, like "bin" in package.json.

pyproject.toml
[project.scripts]
acme = "acme_cli.cli:app"  # module:callable
src/acme_cli/cli.py
from typing import Annotated
 
import typer
 
app = typer.Typer(no_args_is_help=True)
 
 
@app.command()
def greet(
    name: str,
    shout: Annotated[bool, typer.Option("--shout")] = False,
) -> None:
    """Say hello."""
    msg = f"Hello, {name}!"
    typer.echo(msg.upper() if shout else msg)
 
 
@app.command()
def version() -> None:
    typer.echo("0.1.0")

uv run acme greet Ada --shout in the project; uv tool install . puts acme on your PATH.

Pin Python per project

When one repo needs 3.14 and another is stuck on 3.12.

uv python pin 3.14             # .python-version, commit it
# pyproject.toml: requires-python = ">=3.14"
uv sync                        # rebuilds .venv on 3.14
uv run python --version
uv python pin --global 3.14    # default outside projects

Single-file script

When a script needs a couple of packages and you don't want a project. exclude-newer makes re-runs reproducible.

stars.py
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.14"
# dependencies = ["httpx>=0.28", "rich>=14"]
# [tool.uv]
# exclude-newer = "2026-09-01T00:00:00Z"
# ///
import sys
 
import httpx
from rich import print
 
 
def stars(repo: str) -> int:
    url = f"https://api.github.com/repos/{repo}"
    r = httpx.get(url, timeout=10)
    r.raise_for_status()
    return int(r.json()["stargazers_count"])
 
 
if __name__ == "__main__":
    repo = sys.argv[1] if sys.argv[1:] else "astral-sh/uv"
    print(f"[bold]{repo}[/]: {stars(repo):,} stars")

chmod +x stars.py && ./stars.py pola-rs/polars, or uv run stars.py.

Private index

When some packages come from a company registry (Artifactory, CodeArtifact, GitLab) and the rest from PyPI.

pyproject.toml
[[tool.uv.index]]
name = "internal"
url = "https://pkgs.example.com/simple/"
explicit = true  # only for packages pinned below
 
[tool.uv.sources]
acme-auth = { index = "internal" }
export UV_INDEX_INTERNAL_USERNAME=ci
export UV_INDEX_INTERNAL_PASSWORD="$TOKEN"
uv add acme-auth

explicit = true stops a public package with the same name from being picked up (dependency confusion).

CI install with cache

When GitHub Actions should install exactly the lockfile, fast.

.github/workflows/test.yml
name: test
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python: ["3.13", "3.14"]
    steps:
      - uses: actions/checkout@v7
      - uses: astral-sh/setup-uv@v10
        with:
          enable-cache: true
          python-version: ${{ matrix.python }}
      - run: uv sync --locked --all-extras
      - run: uv run ruff check
      - run: uv run pytest

For Docker images see Dockerfile: copy uv.lock first, uv sync --locked --no-dev --no-install-project, then copy the source.

References