---
title: "Set up pip, uv, and Poetry | PackageMaze docs"
description: "Configure pip, uv, and Poetry so a PackageMaze Feed is the only Python index — never an extra one — plus publishing with twine, uv, or Poetry."
canonical: https://www.packagemaze.com/docs/set-up-pip-uv-and-poetry/
---

> Documentation index: https://www.packagemaze.com/llms.txt

# Set up pip, uv, and Poetry

Make your PackageMaze Feed the default index for a Python project, so every package — yours and the public ones you depend on — resolves through one Feed URL. Publishing goes to the same Feed, with twine, uv, or Poetry.

**install — PyPI Simple API**

```
https://pkg.packagemaze.com/<organization>/<feed>/simple/
```

## Why one index, not two

Python clients make it easy to add PackageMaze _alongside_ public PyPI. Do not. An additional index is not a weaker version of the same setup; it is a different, worse one:

- **Dependency confusion.** With two indexes in the resolver path, the client may pick either. Someone who registers your internal package name on public PyPI can win that race. pip's own documentation warns about this.
- **Supply-chain rules that hold.** Blocked names, delisted versions, and Reserved Package Namespaces stop nothing if the client can still reach public PyPI directly.
- **Minimum Age Policy.** Your Feed withholds brand-new upstream versions — 24 hours by default — long enough for a bad release to be caught. A fallback index hands the client that release anyway.
- **A record of what you actually use.** [Usage history](https://www.packagemaze.com/docs/see-whats-installed/) can only report the requests that reach PackageMaze.

So: no `--extra-index-url`, no Poetry `supplemental` or `explicit` source, no second entry left in `pyproject.toml` pointing at `pypi.org`. A Unified Feed already proxies its linked External Feeds, so public packages arrive over the same endpoint as yours.

## With your agent

> Tell your connected agent: "Make PackageMaze the only Python package index for this project."

- Finds every index, source, and publish target the project configures.
- Replaces them with one PackageMaze index, set as the default.
- Removes extra-index-url and fallback sources instead of layering on top of them.
- Keeps Token Secrets out of committed files — credentials stay in the environment.

Requires a connected agent. See [Connect your agent](https://www.packagemaze.com/docs/connect-your-agent/).

## Two URLs under one Feed

Installing and publishing are different Artifact Protocol endpoints beneath the same Feed URL. Point install configuration at the Simple API path and publish configuration at the Legacy Upload API path; neither works in the other's place.

**install — PyPI Simple API**

```
https://pkg.packagemaze.com/<organization>/<feed>/simple/
```

**publish — PyPI Legacy Upload API**

```
https://pkg.packagemaze.com/<organization>/<feed>/legacy/
```

## Machine setup

Once per machine. Create a Token with Read access for installing, or Read-and-publish if you will also publish, and put its Secret in your shell environment. PackageMaze shows a Token Secret once, at creation.

**shell**

```
export MAZE_TOKEN="<Token Secret>"
```

Every Python client below authenticates as the fixed username `__token__` with the Token Secret as the password. Prefer your operating system's secure storage — Keychain, Secret Service, or Credential Manager — over a long-lived exported variable in a shell profile.

## Project setup

Once per project, committed. Each of these files names the Feed and refers to `MAZE_TOKEN`; none contains a literal Token Secret.

### pip

Put the index on the first line of `requirements.txt`. pip expands environment variables in requirements files, so the credential stays out of Git.

**requirements.txt**

```
--index-url https://__token__:${MAZE_TOKEN}@pkg.packagemaze.com/<organization>/<feed>/simple/
```

`--index-url` replaces the default index. `--extra-index-url` adds one next to public PyPI — that is the anti-pattern this page exists to prevent.

### uv

Declare a named index in `pyproject.toml`. `default = true` replaces uv's built-in PyPI index rather than adding to it, and `authenticate = "always"` sends credentials on the first request instead of waiting for a challenge.

**pyproject.toml**

```
[[tool.uv.index]]
name = "packagemaze"
url = "https://pkg.packagemaze.com/<organization>/<feed>/simple/"
publish-url = "https://pkg.packagemaze.com/<organization>/<feed>/legacy/"
default = true
authenticate = "always"
```

### Poetry

Declare the Feed as a `primary` source. That is the whole trick: a primary source disables Poetry's implicit PyPI source for the project, which is exactly the all-packages behaviour you want.

**pyproject.toml**

```
[[tool.poetry.source]]
name = "packagemaze"
url = "https://pkg.packagemaze.com/<organization>/<feed>/simple/"
priority = "primary"
```

Do not use `supplemental` (searched only after PyPI) or `explicit` (searched only for packages pinned to it). And once the source is primary, ordinary `poetry add` is correct — `poetry add --source packagemaze` is for pinning one package in a multi-source project and says the opposite of what this setup means.

## Install

### pip

For commands outside a requirements file, set `PIP_INDEX_URL` in the environment.

**shell**

```
export PIP_INDEX_URL="https://__token__:${MAZE_TOKEN}@pkg.packagemaze.com/<organization>/<feed>/simple/"
pip install <package-name>
```

### uv

uv reads credentials for the named index from environment variables built from the index name.

**shell**

```
export UV_INDEX_PACKAGEMAZE_USERNAME="__token__"
export UV_INDEX_PACKAGEMAZE_PASSWORD="${MAZE_TOKEN}"
uv sync
uv add <package-name>
```

### Poetry

Store the credential against the source name once, then use ordinary Poetry commands.

**shell**

```
poetry config http-basic.packagemaze __token__ "${MAZE_TOKEN}"
poetry install
poetry add <package-name>
```

## Publish

With standard Python publishing tools, add all distribution files within one hour of the first accepted file's upload. Later uploads do not extend that window. Existing filenames always keep their original bytes; correcting a published file requires a new version.

When using `maze publish dist/*`, include the complete release. PackageMaze reserves that declared file set and closes it when publishing completes. Resume the same Plan after an interruption; another Plan or standard upload cannot append files to that release.

Publishing from CI is the recommended path — the credential is short-lived and the upload is attributed to a verified job. Publish locally when you must; the commands below are the fallback, not the default. All three need a Token with Read-and-publish access and target the Legacy Upload API path.

### twine

**shell**

```
python -m build
TWINE_REPOSITORY_URL="https://pkg.packagemaze.com/<organization>/<feed>/legacy/" \
  TWINE_USERNAME="__token__" \
  TWINE_PASSWORD="${MAZE_TOKEN}" \
  TWINE_NON_INTERACTIVE=1 \
  twine upload dist/*
```

A machine-local `.pypirc` works too, but it holds a literal Token Secret. Keep it out of the repository.

### uv

The publish target is the `publish-url` already in `pyproject.toml`, so the command names the index rather than a URL.

**shell**

```
export UV_PUBLISH_TOKEN="${MAZE_TOKEN}"
uv publish --index packagemaze --no-attestations
```

> **Watch out:** `--no-attestations` is required: PackageMaze accepts the PyPI Legacy Upload API for publishing and does not take uv's upload attestations.

### Poetry

Poetry keeps publish targets separate from install sources, so the Feed is registered a second time — as a repository this time, with the Legacy Upload API URL.

**shell**

```
poetry config repositories.packagemaze https://pkg.packagemaze.com/<organization>/<feed>/legacy/
poetry config http-basic.packagemaze __token__ "${MAZE_TOKEN}"
poetry publish --build --repository packagemaze
```

First-party versions you publish are installable immediately; Minimum Age Policy applies only to upstream versions. Once the CI publish job works, turning on Only allow publishing from approved CI jobs in Feed settings makes PackageMaze reject these local publishes outright.

## CI

CI never stores a PackageMaze Token. Each job mints a short-lived one for the step that needs it. See [Set up CI](https://www.packagemaze.com/docs/set-up-ci/) for the access rules and permissions this depends on.

### GitHub Actions, pip

**pip install job**

```
- id: packagemaze-token
  uses: packagemaze/setup-maze@v0.0.5
  with:
    feed: <organization>/<feed>
    purpose: install
- uses: actions/setup-python@v6
  with:
    python-version: "3.13"
- run: pip install -r requirements.txt
  env:
    PIP_INDEX_URL: https://__token__:${{ steps.packagemaze-token.outputs.token }}@pkg.packagemaze.com/<organization>/<feed>/simple/
    MAZE_TOKEN: ${{ steps.packagemaze-token.outputs.token }}
```

### GitHub Actions, uv

**uv install job**

```
- id: packagemaze-token
  uses: packagemaze/setup-maze@v0.0.5
  with:
    feed: <organization>/<feed>
    purpose: install
- uses: astral-sh/setup-uv@v8.1.0
- run: uv sync
  env:
    UV_INDEX_PACKAGEMAZE_USERNAME: "__token__"
    UV_INDEX_PACKAGEMAZE_PASSWORD: ${{ steps.packagemaze-token.outputs.token }}
```

### GitHub Actions, twine

Publish jobs use a second setup step with `purpose: publish`, which carries the Package name PackageMaze authorizes the upload against.

**twine publish job**

```
- id: packagemaze-publish-token
  uses: packagemaze/setup-maze@v0.0.5
  with:
    feed: <organization>/<feed>
    purpose: publish
    package: "<package-name>"
- run: twine upload dist/*
  env:
    TWINE_REPOSITORY_URL: "https://pkg.packagemaze.com/<organization>/<feed>/legacy/"
    TWINE_USERNAME: "__token__"
    TWINE_PASSWORD: ${{ steps.packagemaze-publish-token.outputs.token }}
    TWINE_NON_INTERACTIVE: "1"
```

### CircleCI

CircleCI exchanges its OIDC token through the `maze` CLI inside the step that needs the Token. Publish steps use `--purpose publish` and pass the Package name.

**uv CircleCI install step**

```
export MAZE_TOKEN="$(maze auth exchange-oidc --feed "<organization>/<feed>" --purpose install)"
UV_INDEX_PACKAGEMAZE_USERNAME="__token__" UV_INDEX_PACKAGEMAZE_PASSWORD="${MAZE_TOKEN}" uv sync
```

For Poetry, install Poetry in an earlier step and commit the project source shown above. Configure the fresh Token in the step that installs dependencies.

**Poetry CircleCI install step**

```
export MAZE_TOKEN="$(maze auth exchange-oidc --feed "<organization>/<feed>" --purpose install)"
poetry config http-basic.packagemaze __token__ "${MAZE_TOKEN}"
poetry install
```

Feed Configuration provides complete CircleCI install and publish jobs for Poetry. The publish step configures the upload repository and exchanges a separate Token for the Package it publishes.

## Checking it worked

Feed Doctor checks a Feed's setup — Feed URL, Token access, and committed pip, uv, or Poetry configuration — and names the problem rather than returning a generic failure. It never asks for a Token Secret in committed files. If an install is already failing, [Fix a failing install](https://www.packagemaze.com/docs/fix-a-failing-install/) maps the symptom to a cause.

PDM and Hatch have Setup Instructions on the Feed Configuration page in the app, but no guide here; Pipenv has neither.

Next: [Set up CI](https://www.packagemaze.com/docs/set-up-ci/)

Related: [Set up CI](https://www.packagemaze.com/docs/set-up-ci/), [Fix a failing install](https://www.packagemaze.com/docs/fix-a-failing-install/)
