Skip to content

Move your packages off devpi, Artifactory, or Nexus

Pull your private packages out of an old index and into pypiron in one command. They land as your own packages — private, served from your index, never fetched from PyPI.

pypiron sync \
  --from https://devpi.corp/team/dev/+simple \
  --source-user "$SRC_USER" --source-pass "$SRC_PASS" \
  --as-private \
  --to http://localhost:8080 \
  --admin-user admin --admin-pass "$PYPIRON_ADMIN_PASS" \
  --include-package internal-app \
  --include-package internal-lib

Each named package is downloaded from the old index (authenticated) and re-uploaded to pypiron as private. Install it like anything else:

uv pip install internal-app --index-url http://localhost:8080/simple/

What migrating does — and doesn't

  • Packages land private: your own packages, served from your index. Not a mirror of a public project — pypiron never falls through to PyPI for them.
  • Timestamps and yank state are not preserved. Migrated files carry the migration date. (Mirroring public PyPI keeps real upload times; a private migration doesn't.)
  • Don't set --private-prefix during a migration. It reserves a namespace for private names, and a package whose name falls outside it is refused. Migrate first, add the prefix after.

Re-running is safe: files already migrated are skipped, so a second pass only carries what's new.

devpi

devpi serves each index's package list at <base>/<user>/<index>/+simple. Point --from at that:

pypiron sync \
  --from https://devpi.example.com/acme/prod/+simple \
  --source-user acme --source-pass "$DEVPI_PASS" \
  --as-private \
  --to http://localhost:8080 \
  --admin-user admin --admin-pass "$PYPIRON_ADMIN_PASS" \
  --include-package acme-billing --include-package acme-auth

Artifactory and Nexus

Same command, different source URL — point --from at the repository's simple endpoint:

  • Artifactory: https://<host>/artifactory/api/pypi/<repo>/simple
  • Nexus: https://<host>/repository/<repo>/simple
pypiron sync \
  --from https://nexus.example.com/repository/pypi-internal/simple \
  --source-user "$SRC_USER" --source-pass "$SRC_PASS" \
  --as-private \
  --to http://localhost:8080 \
  --admin-user admin --admin-pass "$PYPIRON_ADMIN_PASS" \
  --include-package internal-app

One prerequisite: the source must serve the JSON simple API (PEP 691). pypiron reads the modern JSON index; it does not scrape the older HTML index.

  • Artifactory serves HTML by default. Turn on JSON per repository: Administration > Artifactory Settings > Packages Settings > PyPI > Enable simple json format. (If JSON is off, Artifactory falls back to HTML.)
  • Nexus serves the JSON simple API from 3.93 onward (3.94 adds file sizes and upload times). Older Nexus is HTML-only — upgrade before migrating.

If the source is still HTML-only, the migration stops with a clear message instead of a cryptic parse error:

source returned an HTML page (Content-Type: text/html), not the PEP 691 JSON
pypiron migration requires — point --from at a JSON-capable endpoint, or check
credentials if this is a login page.

The same message appears if a wrong credential lands you on a login page — check --source-user/--source-pass before assuming the endpoint is wrong.

devpi is tested end-to-end. The Artifactory and Nexus paths above are their standard simple-API endpoints; the JSON-indexing prerequisite is the one thing to confirm first.

Migrating everything

sync migrates the packages you name — it won't enumerate the whole source for you. Most teams already know their list. If you don't, read it from the source index once:

curl -s -u "$SRC_USER:$SRC_PASS" \
  -H 'Accept: application/vnd.pypi.simple.v1+json' \
  https://devpi.example.com/acme/prod/+simple/ \
  | python3 -c 'import sys, json; print("\n".join(p["name"] for p in json.load(sys.stdin)["projects"]))' \
  > packages.txt

pypiron sync --from https://devpi.example.com/acme/prod/+simple \
  --source-user "$SRC_USER" --source-pass "$SRC_PASS" --as-private \
  --to http://localhost:8080 --admin-user admin --admin-pass "$PYPIRON_ADMIN_PASS" \
  --include-packages-from packages.txt

Credentials

Keep passwords out of the command line — use the environment: PYPIRON_SYNC_SOURCE_USER / PYPIRON_SYNC_SOURCE_PASS for the source, PYPIRON_SYNC_ADMIN_PASS for pypiron. Source credentials go to the source host only; they are never forwarded to a redirect somewhere else.

Full flag list: Configuration → Sync.