# Python

SlowShield serves PyPI at `https://slowshield.example.com/pypi/simple/`, a standard package index (PEP 503 and PEP 691). Every tool that takes an index URL works with it: pip, uv, Poetry, PDM, Pipenv, and what installs through them, such as `uvx`, `pipx` and pre-commit hooks.

## Set it up

pip and uv read environment variables, so a few lines in your shell profile cover them in every new terminal (the Setup page has bash on macOS, zsh and fish too):

```
cat >> ~/.bashrc <<'EOF'
export PIP_INDEX_URL=https://slowshield.example.com/pypi/simple/
export UV_DEFAULT_INDEX=https://slowshield.example.com/pypi/simple/
export npm_config_registry=https://slowshield.example.com/npm/
export GOPROXY=https://slowshield.example.com/go
export PIP_UPLOADED_PRIOR_TO=P3D
export npm_config_min_release_age=3
EOF
source ~/.bashrc
```

Remove the `PIP_UPLOADED_PRIOR_TO` and npm lines if you don't want the second layer or don't use npm. Each tool, with the version its own release age needs:

### pip

Release age: pip 26.1 or later (pip 26.0 fails with it, 25 and older ignore it). Installs from pylock.toml fail with it, because pip doesn't record upload times there

*command*

```
pip config set global.index-url https://slowshield.example.com/pypi/simple/
```

*release age*

```
pip config set global.uploaded-prior-to P3D
```

### uv

Release age: uv 0.9.17 or later (older versions fail with it). In pyproject.toml, not the environment: uv records it in uv.lock, so a different value elsewhere breaks uv sync --locked

*pyproject.toml*

```
[[tool.uv.index]]
name = "slowshield"
url = "https://slowshield.example.com/pypi/simple/"
default = true

[tool.uv]
exclude-newer = "P3D"
```

*environment*

```
export UV_DEFAULT_INDEX=https://slowshield.example.com/pypi/simple/
```

### Poetry

Release age: Poetry 2.4 or later

*command*

```
poetry source add --priority=primary slowshield https://slowshield.example.com/pypi/simple/
```

*release age*

```
poetry config solver.min-release-age 3
```

### PDM

Release age: PDM 2.27 or later. In pyproject.toml, not as pdm lock --exclude-newer, which isn't kept and re-resolves every pin

*pyproject.toml*

```
[[tool.pdm.source]]
name = "pypi"
url = "https://slowshield.example.com/pypi/simple/"

[tool.pdm.resolution]
exclude-newer = "3d"
```

### Pipenv

*Pipfile*

```
[[source]]
url = "https://slowshield.example.com/pypi/simple/"
verify_ssl = true
name = "slowshield"
```

## What changes

- **Files wait, not just versions.** Each wheel and sdist becomes installable 7 days after its own `upload-time` (PEP 700), which PyPI sets and authors can't. A wheel added to an old release waits too. A release none of whose files is old enough is left out of the index, so resolvers pick the newest release that has one.

- **A pin that is too new.** Resolvers don't see held files, so `pip install pkg==1.2.3` reports that no matching distribution exists. A lockfile that records file URLs, such as `uv.lock`, downloads the file directly and gets `403` with `Retry-After` and the time it becomes available.

- **Malware** from the OSV and GitHub feeds is refused with `451` and the advisory, also when it is older than the delay.

- **Brand-new packages** none of whose files is old enough are served and recorded as fail-open (`X-SlowShield-Fail-Open: 1`), unless your administrator set `fail_open = false`. That's what the second layer is for.

- **Lockfiles.** `uv.lock` and `Pipfile.lock` record the index URL; lock with SlowShield in place so they name it. Hashes don't change: SlowShield serves PyPI's files byte for byte.

## The second layer

pip 26.1, uv 0.9.17, Poetry 2.4 and PDM 2.27 can refuse releases younger than a few days themselves. Set them to 3 days, below SlowShield's 7, and they stay silent on a normal day. Notes from testing them on 2026-10-06:

- **uv**: put `exclude-newer` in `pyproject.toml`, not the environment. uv records it in `uv.lock`, so a different value elsewhere (CI, a Dockerfile) breaks `uv sync --locked`. For one package you need early: `exclude-newer-package`.

- **pip**: `PIP_UPLOADED_PRIOR_TO=P3D` needs pip 26.1; pip 26.0 refuses to run with it and older versions ignore it. Installs from `pylock.toml` fail with it, because pip doesn't record upload times there.

- **PDM**: in `[tool.pdm.resolution]`; `pdm lock --exclude-newer` isn't kept and re-resolves every pin.

## In Docker and CI

A build container doesn't read your shell profile. Pass the index as a build argument: an `ARG` is visible to `RUN` as an environment variable and isn't kept in the image.

```
FROM python:3.13-slim
ARG PIP_INDEX_URL=https://slowshield.example.com/pypi/simple/
# uv: ARG UV_DEFAULT_INDEX=https://slowshield.example.com/pypi/simple/
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
```

With a default in the Dockerfile, every build uses SlowShield and `--build-arg PIP_INDEX_URL=…` can still override it. [Building images](https://slowshield.org/docs/container-builds/) covers the base image too, and how to check that nothing in a build goes around SlowShield. In CI, set the variables for the job:

```
# GitHub Actions (workflow or job)
env:
  PIP_INDEX_URL: https://slowshield.example.com/pypi/simple/
  UV_DEFAULT_INDEX: https://slowshield.example.com/pypi/simple/
  npm_config_registry: https://slowshield.example.com/npm/
  GOPROXY: https://slowshield.example.com/go
  PIP_UPLOADED_PRIOR_TO: P3D
  npm_config_min_release_age: 3
```

## Limits

- Requirements from git repositories or direct URLs (`pkg @ https://…`) don't go through an index, so SlowShield doesn't see them.

- `--extra-index-url` pointing at pypi.org goes around SlowShield: pip picks the best version across all indexes. Use private indexes for private packages only, and block pypi.org and files.pythonhosted.org at the firewall.

- Plain HTTP (a local test instance) needs `PIP_TRUSTED_HOST` for pip.

---

This page as HTML: https://slowshield.org/docs/python/. All of the guide in one file: https://slowshield.org/llms-full.txt
