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 behavior in one process.
Comparison against peryx
Overlap
Both are read-through pypi.org mirrors that cache what they fetch and host private uploads. Their caching behavior overlaps in these areas:
- 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. The default PyPI mode unions distinct filenames; configure project isolation when migrating a private name boundary. - 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.
devpi-only behavior
Migrating to peryx changes these areas:
- User-owned indexes. devpi indexes belong to users and carry
acl_upload. peryx indexes belong to configuration. Per-index access tokens carry resource-scopedread,write, anddeletegrants; server users and external groups use the shared role model. - Replication protocol. devpi replicas consume its changelog. peryx selects
dcorhathrough[availability]and uses its own journal, frontier, placement, and authority contracts. No devpi replication state migrates. - Promotion (
push). devpi can promote a release from one index to another server-side. In peryx that is a re-upload. - Runtime plugins. devpi-ldap, devpi-lockdown, and related packages load through pluggy. peryx owners are linked into the binary and activated by index configuration; it does not load third-party code at startup.
peryx-only behavior
- 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 executable. The same binary serves indexes and contains the
none,dc, andhaavailability implementations. It needs nodevpi-initstep or 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 | 4.4 s ±6% (1.18x) | 12.7 s ±12% (3.42x) |
| warm cache | 3.3 s ±1% (0.90x) | 4.3 s ±40% (1.19x) |
| server CPU | 1.8 s ±4% (1.00x) | 17.2 s ±15% (9.71x) |
| server peak memory | 699 MB ±2% (1.00x) | 1,373 MB ±2% (1.96x) |
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 | 1.5 s ±11% (0.33x) | error (n/a) |
| warm cache: 10 parallel installs | 1.5 s ±1% (0.32x) | error (n/a) |
| server CPU | 673 ms ±7% (1.00x) | 247 ms ±109% (0.37x) |
| server peak memory | 182 MB ±8% (1.00x) | 1,175 MB ±0% (6.45x) |
The request workload drives a swarm of resolvers reading full project pages:
| peryx | devpi | |
|---|---|---|
| 1 user: requests/s | 7,658 req/s ±2% (128.49x) | 107 req/s ±0% (1.80x) |
| 1 user: p95 latency | 2.3 ms ±2% (0.06x) | 43 ms ±1% (1.10x) |
| 32 users: requests/s | 17,707 req/s ±5% (23.84x) | 114 req/s ±1% (0.15x) |
| 32 users: p95 latency | 3.8 ms ±6% (0.04x) | 307 ms ±2% (3.31x) |
| server CPU per 1k requests | 168 ms ±1% (1.00x) | 9.6 s ±1% (57.16x) |
| server peak memory | 108 MB ±6% (1.00x) | 1,193 MB ±0% (11.02x) |
Migration procedure
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 |
http://host:3141/{user}/{index}/+simple/ | http://host:4433/{route}/simple/ |
devpi index -c dev bases=root/pypi | Virtual index with layers = ["dev-hosted", "pypi"] |
devpi login and devpi upload | twine upload --repository-url http://host:4433/{route}/ dist/* |
devpi remove pkg==1.0 | DELETE /{route}/{project}/{version}/ |
volatile=False | volatile = false on the hosted index |
mirror_whitelist | Explicit fallback_mode and protected_names source policy |
acl_upload | One or more scoped [[index.access_token]] grants |
| devpi-web plugin | Built in at / |
| primary/replica options | [availability] with mode = "dc" or mode = "ha" |
Gotchas
- ACLs move into configuration. Create separate scoped access tokens or server-role grants for principals that must retain distinct permissions.
- No
pushbetween indexes. Promoting a release is a re-upload into the target index. - Pluggy hooks have no runtime counterpart. Move custom hooks to a gateway or automation against the HTTP API.
- Replication configuration must be rebuilt. Configure peryx membership and roles; do not copy devpi changelog or replica state.