From devpi
devpi is the long-standing Python answer to this problem: a caching pypi.org mirror plus
user-owned indexes with inheritance, a web UI, primary/replica replication, and a
pluggy-based plugin ecosystem. It runs as a
Pyramid application on an embedded
waitress server, keeps its state in an
SQLite key-value store with release files on the filesystem, and expects an nginx and
supervisor front for production (its own devpi-gen-config generates those files). peryx covers the same read-through
core in one static Rust binary.
Comparison against peryx
Overlap
Both are read-through pypi.org mirrors that cache what they fetch, host private uploads, and let hosted names shadow the cached index. For a caching mirror the two overlap almost completely:
- Read-through mirroring of pypi.org (or any simple index), cached on first use.
- Private uploads over the twine API, served from the same host as the cached index.
- Composition: devpi's index inheritance (
bases) maps onto peryx's virtual indexes, and hosted files shadow upstream ones in both. - Yank and delete of hosted files.
- A web UI for browsing packages (devpi-web; built into peryx at
/). - Streaming artifact downloads: devpi's
FileStreamerand peryx both tee a wheel to disk while the client reads it, and both address stored files by sha256.
Extra: what devpi does that peryx does not
Migrating to peryx means giving these up:
- Users and per-index ACLs. devpi indexes belong to users, each with its own
acl_upload. peryx has one upload token per hosted index and open reads. - Replication. devpi's primary/replica protocol streams a changelog to read-only replicas. peryx has no equivalent; you run one instance per site and let each warm itself.
- Promotion (
push). devpi can promote a release from one index to another server-side. In peryx that is a re-upload. - A plugin ecosystem. devpi-ldap, devpi-lockdown, and friends hook devpi through pluggy. peryx's extension points are its HTTP API and its configuration, nothing loadable.
Missing: what peryx adds
- PEP 658 metadata by default. devpi 6.x ships core-metadata as experimental,
behind
--enable-core-metadata. peryx serves it out of the box and synthesizes it with HTTP byte-range reads when an upstream lacks it, so resolution can beat the upstream once metadata is cached. - Correctness under a concurrent cold burst. On the first parallel fetch of a project, devpi can evaluate the
request against an as-yet-empty project list, return a
404, and cache that "does not exist" for its 30-minute mirror window; uv then fails the install. peryx single-flights concurrent misses onto one upstream fetch, so ten cold installs of the same project all succeed. - Built-in observability. Prometheus metrics and per-file usage counters are part of the server, not plugin territory.
- One process. A single static binary with no nginx or supervisor front, no
devpi-initstep, and no external database.
Performance vs peryx
The benchmark suite runs both servers from their published packages against the same workload. Cold and warm installs through uv:
| peryx | devpi | |
|---|---|---|
| cold cache net | 33.4 s ±29% (9.26x) | 12.7 s ±5% (3.50x) |
| warm cache | 8.4 s ±30% (2.37x) | 6.5 s ±4% (1.84x) |
| server CPU | 40.0 s ±16% (1.00x) | 16.1 s ±2% (0.40x) |
| server peak memory | 570 MB ±11% (1.00x) | 1,179 MB ±9% (2.07x) |
The parallel-install workload is where the concurrency difference shows up: ten virtualenvs install the same project at once, each with an empty client cache.
| peryx | devpi | |
|---|---|---|
| cold cache: 10 parallel installs net | 6.8 s ±52% (0.76x) | error (n/a) |
| warm cache: 10 parallel installs | 7.6 s ±35% (0.96x) | error (n/a) |
| server CPU | 2.2 s ±8% (1.00x) | 68 ms ±82% (0.03x) |
| server peak memory | 154 MB ±14% (1.00x) | 995 MB ±27% (6.47x) |
The request workload drives a swarm of resolvers reading full project pages:
| peryx | devpi | |
|---|---|---|
| 1 user: requests/s | 573 req/s ±3% (2.16x) | 74 req/s ±1% (0.28x) |
| 1 user: p95 latency | 9 ms ±1% (0.86x) | 94 ms ±8% (8.87x) |
| 32 users: requests/s | 2,512 req/s ±2% (2.87x) | 79 req/s ±1% (0.09x) |
| 32 users: p95 latency | 16 ms ±1% (0.36x) | 483 ms ±2% (10.95x) |
| server CPU | 2m 06.1s ±2% (1.00x) | 32.0 s ±1% (0.25x) |
| server peak memory | 336 MB ±4% (1.00x) | 1,152 MB ±0% (3.43x) |
How to migrate
devpi's mirror state does not migrate and does not need to: peryx's cache refills on first use. Only your uploaded
packages need a twine upload pass into the new hosted index. Map the commands and knobs across:
| devpi | peryx |
|---|---|
devpi-init then devpi-server --port 3141 | peryx serve (no init step) |
http://host:3141/{user}/{index}/+simple/ | http://host:4433/{route}/simple/ |
devpi index -c dev bases=root/pypi | a virtual index with layers = ["dev-hosted", "pypi"] in TOML |
devpi login + devpi upload | twine upload --repository-url http://host:4433/{route}/ dist/* (any username, upload_token as password) |
devpi remove pkg==1.0 | DELETE /{route}/{project}/{version}/ (removal guide) |
volatile=False | volatile = false on the hosted index |
mirror_whitelist | not needed: hosted names shadow the cached index by default (why) |
acl_upload | one upload_token per hosted index |
| devpi-web plugin | built in at / |
Gotchas
- One upload token replaces per-person write control. If you relied on distinct
acl_uploadper user, issue a distinct hosted index (and token) per team instead. - No
pushbetween indexes. Promoting a release is a re-upload into the target index. - Plugin hooks have no counterpart. Anything you drove through devpi-ldap or devpi-lockdown moves to a layer in front of peryx or into your own automation against its HTTP API.
- Replica topologies collapse to one instance per site. There is no changelog to follow; each peryx warms independently.