PyPI Onboarding · No. 01 Series index pypi/warehouse 6,509 words · ~33 min Architecture

Warehouse Overview

PyPI is a website, an upload endpoint, an admin console, and a fleet of background workers. All four are the same Pyramid application, running the same code, told apart by environment variables and a hostname. This is the shape of it.

Orientation

One codebase, six process types

“Warehouse” and “PyPI” name the same thing at two layers. PyPI is the service the world talks to. Warehouse is the Python code that answers. There is one repository, one WSGI application object, and one database. Everything that looks like a separate service is the same application wearing a different hostname or a different gunicorn config.

The Procfile declares the whole production fleet in seven lines:

release:      bin/release
web:          gunicorn -c gunicorn-prod.conf.py    warehouse.wsgi:application
web-api:      gunicorn -c gunicorn-prod.conf.py    warehouse.wsgi:application
web-uploads:  gunicorn -c gunicorn-uploads.conf.py warehouse.wsgi:application
worker:       celery -A warehouse worker -Q default --max-tasks-per-child 1024
worker-beat:  celery -A warehouse beat -S redbeat.RedBeatScheduler
worker-traced: celery worker, same thing, with tracing and a smaller child budget

Read the first four lines again. web and web-api are byte-identical commands. They exist as separate process types so the deployment can scale browser traffic and API traffic apart, and so a flood of one does not starve the other. Nothing in the Python knows which one it is running as.

web-uploads is the only web process with its own config, and the diff is short and revealing:

Settinggunicorn-prodgunicorn-uploads
timeout60 s240 s
max_requests204832
backlog2048512
worker_connections1000256
limit_request_field_size8190 (default)8190 × 4
bindunix:/var/run/cabotage/cabotage.sock

Each number is a scar. The four-minute timeout is there because a single upload runs validation, hashing, a YARA scan of every archive member, and an object-storage PUT inside the request. The whole gauntlet is in No. 05, the PEP 694 guide. max_requests = 32 recycles an upload worker after 32 requests instead of 2048, because uploads leak memory faster than they leak anything else. And the quadrupled header limit exists for one reason: a PyPI API token is a macaroon, and macaroons with several caveats attached do not fit in gunicorn's default 8 KiB header field.

The admin console is not a fourth web process. It is /admin/ on the ordinary Warehouse domain, registered by warehouse/admin/routes.py against the same warehouse.domain setting as the public site, and guarded by an ACL on the root factory rather than by a network boundary. There is no admin deploy to fail over to.

Terminology

Three hostnames, one app. pypi.org serves the site and the read APIs. upload.pypi.org serves uploads and exists as a distinct name only so uploads can skip the CDN. auth.pypi.org handles the Trusted Publishing token exchange. All three are the same warehouse.wsgi:application; the split is a Pyramid route predicate reading WAREHOUSE_DOMAIN, FORKLIFT_DOMAIN, and AUTH_DOMAIN out of the environment.

The read path

Cache first, origin second

Warehouse's own docs are blunt about the traffic mix. Of the four classes of user it names (anonymous visitors, project maintainers, moderators, administrators) the first accounts for “the majority of browser traffic and all API download traffic,” and the last numbers “fewer than ten.” Almost everything is a read by a stranger. The conclusion the docs draw from that is one sentence long:

“Since reads are much more common than writes (much more goes out than goes in), we try to cache as much as possible.”

warehouse → docs/dev/application.md

So Fastly sits in front of the origin, and the origin's job is partly to tell Fastly when it is wrong. Warehouse registers cache keys per model class; a SQLAlchemy after_flush listener collects purge keys for every new, dirty, or deleted object, and an after_commit listener sends the purges. Edit a project, and the purge goes out when the transaction commits, not when the view returns.

Package files never come from Warehouse at all. Responses point clients at files.pythonhosted.org, which Fastly serves from a Backblaze B2 bucket, falling back to S3 when B2 is down or missing the file. The reason is money: B2 is primary “for cost savings over S3 for egress, as Backblaze has an agreement with Fastly to waive egress fees.” Nothing about that choice is visible in the application code. It is a storage backend behind an interface, which is No. 03's subject, not this guide's.

Writes go the other way. Uploads bypass Fastly entirely and hit the origin ingress directly, because there is nothing to cache in a 100 MiB POST and a CDN in the path only adds buffering and its own size limits.

browser · pip cacheable GETs Fastly cache, shield, purge Cabotage ingress signs Warehouse-* headers gunicorn unix socket, preload wsgi:app one object twine · uv publish upload.pypi.org no CDN in the path inside the one WSGI application ProxyFixer hmac token, then trust VhmRootRemover drops X-Vhm-Root sanity ingress bad bytes → 400 tween stack https, gzip, tm, retry, CSP router domain predicate root factory traverse /{name}/{version} context = Release carries its own __acl__ view renders, writes, queues sanity egress last thing on the way out Redis, then worker only after commit
Fig. 1. One request, end to end. The two WSGI middlewares run outside Pyramid; everything from the sanity ingress tween inward is Pyramid. Note the last hop: a task queued by a view is not sent until the database transaction commits.

Anatomy

What happens between the socket and the view

Follow one request: GET https://pypi.org/project/requests/2.32.3/, a cache miss.

  1. 01

    Fastly misses, and asks the origin

    pypi/infra → terraform/warehouse · gunicorn-prod.conf.py

    The edge forwards to the Cabotage ingress, which hands the request to gunicorn over a Unix domain socket at /var/run/cabotage/cabotage.sock. There is no TCP port to point at, which is worth knowing the first time you try to reproduce a production networking problem. preload_app = True means the application was built once before forking, so every worker shares one already-configured Pyramid registry.

    Cabotage is not a PyPI-specific tool, and it is not PSF-exclusive either: it's an open-source, MIT-licensed platform-as-a-service, and anyone can run it or try the public preview at preview.cabotage.io. Its README calls it “deployment tooling built on top of Kubernetes, Hashicorp Consul, Hashicorp Vault, Docker, and Python to power automated rollout and management of software and configuration changes,” providing end-to-end verified TLS, secure secret management, and continuous delivery, while its tagline settles for the “We're doing our best” Platform-as-a-Service. The PSF deploys its own instance to run Warehouse, camo, conveyor, and everything else PSF infrastructure puts through it, which is as close to a production reference as the project has. The socket above is the convention it imposes: every app under Cabotage exposes a Unix domain socket at a well-known path instead of a TCP port, and Cabotage's ingress is what terminates TLS and proxies into that socket.

  2. 02

    ProxyFixer decides whether to believe the headers

    warehouse/utils/wsgi.py → ProxyFixer.__call__

    This runs as WSGI middleware, outside Pyramid, and it is the security boundary for every piece of client identity in the system. It reads Warehouse-Token and compares it against the configured WAREHOUSE_TOKEN with hmac.compare_digest. On a match it trusts the sibling Warehouse-* headers: protocol, client IP, pre-hashed IP, host, and eight GeoIP fields the edge resolved. On a miss it falls back to X-Forwarded-*, counting in from the right by WAREHOUSE_NUM_PROXIES so a client-supplied prefix cannot win, and hashes the IP itself with WAREHOUSE_IP_SALT.

    Then it deletes every one of those headers from the environ. An application-level view cannot see them, cannot accidentally trust them, and cannot leak them. A token that is present but wrong is not silently ignored either. It raises a Sentry warning called Invalid Proxy Token, because that means either a misconfiguration or somebody guessing.

  3. 03

    VhmRootRemover deletes one header, for one reason

    warehouse/utils/wsgi.py → VhmRootRemover

    Four lines that drop X-Vhm-Root. It exists because that header changes how a URL generator writes absolute links, so a client that could set it could poison the cache with URLs pointing somewhere else. It is an ugly piece of code, and removing it would be a security bug.

  4. 04

    The tween stack, with sanity bolted on either end

    warehouse/config.py · warehouse/sanity.py · warehouse/cache/http.py

    Seven modules add tweens: structlog request context, require_https (403 on any :action URL over plain HTTP, with the Fastly error code attached), gzip compression, conditional-HTTP handling for ETag and If-Modified-Since, rate-limit response headers, Content-Security-Policy, and Referrer-Policy. pyramid_tm wraps the request in a transaction (skipped for /static/ and the debug toolbar), and pyramid_retry will replay the whole request up to three times if the transaction hits a retryable conflict.

    Around all of that sits warehouse/sanity.py, which does not add a tween so much as rebuild the chain: it takes the ingress tween, then re-adds every implicit tween in order, then the egress tween. That guarantees it is genuinely first in and last out. The ingress half turns undecodable query strings and unparseable form bodies into 400s instead of 500s; the egress half percent-encodes non-ASCII redirect targets. The module comment is the honest one: “I'm doing bad things, I'm sorry. - dstufft”. It is included last in configure() for exactly this reason.

  5. 05

    The router matches a route, and a domain

    warehouse/routes.py · warehouse/predicates.py → DomainPredicate

    Warehouse registers around 130 routes, and nearly every public one carries domain=warehouse. The predicate is four lines: compare request.domain against the configured value with Pyramid's is_same_domain, and, in the branch that matters, match everything if the setting is None. That is how the whole site, the uploader, and the auth endpoint can share one process in local development and on Test PyPI, and how they separate cleanly in production where all three settings are filled in.

  6. 06

    A factory turns the URL into a model object

    warehouse/routes.py · warehouse/packaging/models.py → ProjectFactory, Project.__getitem__

    The route is /project/{name}/{version}/ with factory=ProjectFactory and traverse="/{name}/{version}". Pyramid matches the pattern by dispatch, then traverses the matched segments: ProjectFactory["requests"] queries for a project whose normalized_name equals normalize_pep426_name('requests'), and the resulting Project["2.32.3"] canonicalizes the version and looks up the release. A miss raises KeyError, which Pyramid renders as a 404. The view never sees a string; it receives a Release that already carries its own __acl__.

    This hybrid of dispatch and traversal is the pattern that surprises people arriving from Django or Flask, and it is the subject of No. 02. Here, one sentence is enough: the URL is resolved to an object before authorization runs, and permissions live on the object.

  7. 07

    The view runs, and the response unwinds

    warehouse/packaging/views.py · warehouse/cache/origin/__init__.py

    The view renders a Jinja2 template. Any task it queued is still sitting on the transaction manager's after-commit hook. Any object it modified has already contributed purge keys to the session. pyramid_tm commits; the purges fire; the tasks are sent; the egress tween tidies the response; gzip and CSP headers go on; Fastly stores the result and every subsequent visitor gets it from the edge.

Checkpoint 1 · the request path

Q1Your local dev instance answers requests for localhost on routes registered with domain=warehouse. Why?

B. DomainPredicate.__call__ returns True outright when self.val is None, with a comment naming the two reasons: local development, and test.pypi.io hosting its own uploader in the same instance. A is wrong in an interesting way: Environment has exactly two members, production and development, and it gates debug tooling and HTTPS enforcement, never routing.

Q2A view reads request.remote_addr. What guarantees it is not whatever the client typed into X-Forwarded-For?

D. _forwarded_value takes the n-th value from the end, where n is WAREHOUSE_NUM_PROXIES, so a client prepending fake hops cannot reach that position. The middleware then removes every X-Forwarded-* and Warehouse-* key before dispatching, so nothing downstream can re-derive them. A is real but is defence in depth, not the guarantee; uploads never touch Fastly at all. B has a grain of truth: request.ip_address exists and upserts an IpAddress row, but it is built from the value ProxyFixer settled.

The data model

Project, Release, File, and the scars

Three tables carry the packaging world. A Project is a name. A Release is a version of that name. A File is one artifact of that version: an sdist, a wheel. Everything else in warehouse/packaging/models.py hangs off those three.

They are also older than Warehouse. The docs say so plainly:

“Since Warehouse was built on top of an existing database (for legacy PyPI) and developers had to fit our ORM to the existing tables, some of the code in the ORM may not look like code from the SQLAlchemy documentation. There are some places where joins are done using name-based logic instead of a foreign key.”

warehouse → docs/dev/application.md

That warning is not decorative. Here is where it bites.

normalized_name is written by Postgres, not by Python

Project.normalized_name is a real, unique column, but the model declares it with server_default=FetchedValue() and server_onupdate=FetchedValue(): SQLAlchemy is being told somebody else fills this in, go read it back. That somebody is a trigger. Migration 8bee9c119e41 (June 2022) added the column, backfilled it, dropped the old functional unique index, and installed projects_update_normalized_name, a BEFORE INSERT OR UPDATE OF name trigger calling normalize_pep426_name(NEW.name). The model file carries a matching comment: TODO: Cannot update columns that are used in triggers.

The practical consequence: you cannot normalize a name in Python and trust it to match. Traversal itself calls the SQL function, Project.normalized_name == func.normalize_pep426_name(project), so that lookups use exactly the same normalization the trigger used at write time.

Uniqueness on File is aggressive, and then apologetic

File declares unique=True on five columns at once: filename, path, md5_digest, sha256_digest, and blake2_256_digest. The last two are CITEXT with check constraints matching ^[A-F0-9]{64}$ case-insensitively. A filename is globally unique across all of PyPI forever, which is why a deleted release does not free its filenames.

Then there is the partial index release_files_single_sdist: unique on (release_id, packagetype), but only WHERE packagetype = 'sdist' AND allow_multiple_sdist = false. One sdist per release, unless the row is one of the pre-existing double sdists, which get an escape hatch column. The comment is candid: “Eventually we should try to get rid of all of them and then remove this column.” That is 2018 data still shaping the 2026 schema.

The journal is a serial number the database cannot supply

JournalEntry records every mutating action, and downstream consumers (mirrors, the XML-RPC changelog, last_serial) depend on its integer id being monotonic in commit order. Postgres SERIAL does not promise that: it hands out the next integer when the INSERT runs, not when the transaction commits, so two overlapping transactions can commit out of ID order. Not even SERIALIZABLE fixes it.

So Warehouse serializes journal writes itself. A before_flush listener takes a Postgres advisory transaction lock keyed on the journals table's OID before any journal insert. And there is a second layer of care underneath: if the same flush also contains non-journal changes, holding the advisory lock while those rows do their own constraint checks can deadlock, so the listener expunges the journal entries, stashes them in session.info, and an after_flush listener adds them back for a flush of their own. Two listeners and a stash, to buy one guarantee no SQL type could give.

Small things worth knowing before you read further

Six more details in the same file, none of them big enough for a heading, all of them capable of costing you an afternoon.

Project · projects name normalized_name ← trigger, unique upload_limit ≤ 1 GiB (check) total_size_limit, total_size last_serial lifecycle_status __getitem__ → Release Release · releases unique (project_id, version) canonical_version is_prerelease, published _pypi_ordering ← sort key description_id → own table lifecycle_status __parent__ = project File · release_files filename, path unique globally md5 / sha256 / blake2 unique one sdist per release (partial) allow_multiple_sdist ← legacy cached, archived flags metadata_file_* → PEP 658 provenance → PEP 740 1 : N 1 : N User · users id UUID (primary key) username CITEXT, unique roles → owner / maintainer JournalEntry · journals id must rise in commit order pg_advisory_xact_lock per flush deferred if flush is mixed Filename · file_registry every filename ever used, so none is ever reused FK → users.username every write journals
Fig. 2. The core model, with its inherited quirks marked. The dashed edge from journals to users is a foreign key on a username string, not an id: the name-based join the developer docs warn about.

Checkpoint 2 · the model

Q3You add a migration that renames projects.name in place for one row. What sets the new normalized_name?

D. The trigger calls maintain_projects_normalized_name(), which sets NEW.normalized_name := normalize_pep426_name(NEW.name). The Python side only declares FetchedValue() so the ORM knows to read the value back rather than write it. A is the modern way to do this and is not what the table does; it predates the column being a column at all, which is why the model still carries TODO: Cannot update columns that are used in triggers.

Q4Why does Warehouse take an advisory lock around journal inserts instead of trusting the SERIAL primary key?

A. Consumers poll the journal for “everything after serial N.” If transaction A takes id 100 and commits after transaction B takes 101 and commits, a consumer that reached 101 will never see 100. Gaps (C) are fine; inversions are not. The comment in ensure_monotonic_journals notes that not even SERIALIZABLE gives the ordering, hence the hand-rolled pg_advisory_xact_lock, and hence the deferral dance that keeps that lock from deadlocking against a concurrent File insert checking its own unique constraints.

The map of the source

Forty-odd subpackages, two of them called “legacy”

warehouse/ is flat and wide. Most directories are a self-contained Pyramid plugin with an includeme(), and config.py pulls in 54 of them by name. The naming is mostly honest; the exceptions are worth learning before they cost you an hour.

PackageWhat lives there
packaging/Project, Release, File, Role. Storage services, search documents, upload-limit logic, typo detection. The core.
accounts/User model, login and registration, the security policy, OAuth client support.
manage/Everything behind a login: account settings, project settings, publisher config. Views only, no models.
admin/The /admin/ console: its own templates, static bundle, routes, feature flags, and ban tooling.
forklift/The upload API. legacy.py is the one enormous file_upload view; metadata.py parses core metadata.
legacy/Read APIs inherited from old PyPI: the JSON API, XML-RPC and its cache, and ?:action= route wiring.
api/The modern API surface: the Simple index, PEP 740 integrity, billing, and an OpenAPI document.
macaroons/API tokens. Minting, verification, and the caveat types that scope a token to a project or an expiry.
oidc/Trusted Publishing. Publisher models per forge, JWT verification, the token exchange.
attestations/PEP 740 provenance: models, the integrity service, verification errors.
organizations/Organizations, teams, invitations, and the paid-plan side of project ownership.
subscriptions/Stripe-backed billing records for those organizations.
integrations/Inbound feeds from other people's systems: leaked-secret reports, vulnerability advisories.
observations/Malware and abuse reports filed against projects and releases.
events/The security event log mixed into other models via HasEvents.
helpdesk/The bridge to the support desk, behind an interface so it can be a no-op locally.
cli/Operator commands and the interactive shell, run as python -m warehouse.
utils/Not a junk drawer. Holds the WSGI middlewares, the security policy helpers, the readme renderer, wheel-tag parsing, and the archive scanner.
authnz/One file. The Permissions enum every ACL in the codebase spells its entries from.
mock/Fake billing for local development.
tuf/, policies/Near-empty. Placeholders for work that lives elsewhere or has not landed. Do not go looking for substance.
The trap

“Legacy” names two unrelated things. warehouse/legacy/ is the read side: the JSON API and XML-RPC that old tooling still calls. warehouse/forklift/legacy.py is the write side: the current, only, entirely non-optional upload API that every twine upload on earth hits today. Grep for “legacy” and you will find both. Only one of them is safe to ignore.

forklift/ carries its own disclaimer at the top of __init__.py: a “temporary package… it is assumed that it will go away eventually, once we split forklift out into its own project.” That note has been true for years.

Configuration

Environment variables, all the way down

Warehouse has no configuration file. configure() in warehouse/config.py builds a settings dict from os.environ, hands it to a Pyramid Configurator, includes 54 subpackages, adds tweens, scans for views and tasks, and commits. Just under a thousand lines, read top to bottom, and it is the best single file to read first.

Three helpers do nearly all the work:

maybe_set(settings, name, envvar, coercer, default)
Copy one environment variable into one setting, optionally coerced, optionally defaulted. Uses setdefault, so a value passed into configure() (from a test, say) always wins over the environment.
maybe_set_compound(settings, base, name, envvar)
The interesting one. It shlex.splits the variable: the first token becomes base.name, and every remaining key=value becomes base.key. So FILES_BACKEND="warehouse.packaging.services.LocalFileStorage path=/var/opt/warehouse/packages/ url=http://files:9001/packages/{path}" selects a class and configures it, in one string. Fourteen subsystems are wired this way: file storage, archive storage, the simple index, docs, mail, metrics, billing, the breach-check services, forge OAuth, the origin cache.
maybe_set_redis(settings, name, envvar, db=N)
Takes one REDIS_URL and rewrites its path to pick a numbered database, so eight subsystems share one Redis without sharing a keyspace.

The docstring on that last one states the convention: general caches in databases 1–9, Celery in 10–15, nothing above 15. In practice sessions take 2, rate limiting 3, the XML-RPC cache 4, query results 5, the OIDC key cache 1, the Celery broker 10, and results 12. The scheduler takes 0, which is neither a cache nor in 10–15. Conventions drift; the code is the record.

That maybe_set_compound string reaches further than configuration. Every one of those backends is selected by dotted path at boot and registered as a service against a zope.interface, which is how local disk stands in for S3 and a console printer stands in for SES without a single if environment == "development" anywhere in the application. Naming the mechanism is enough here; No. 03 is entirely about it.

Two small things that confuse newcomers. SOURCE_COMMIT becomes the warehouse.commit setting, which is how a running instance reports the code it is on. And development.ini in the repository root is two lines long. It exists purely so Pyramid's ptweens and proutes scripts, which insist on a PasteDeploy file, can reach a factory that ignores the file and calls configure() anyway. If you want the real tween order rather than the one inferred from reading config.py, that file is how you ask.

Background work

Celery, and the commit that gates it

The workers are the same application again. celery -A warehouse boots the identical Pyramid configuration, and config.scan() picks up task functions the same way it picks up views. A @tasks.task() decorator attaches a venusian callback that registers the function on the Celery app during the scan. One queue, default. Redis as broker, result backend, and schedule store. Redbeat holds the schedule so that worker-beat can be restarted without losing time.

Two mechanics in warehouse/tasks.py matter more than the rest.

Tasks are sent after the commit, not at the call. WarehouseTask.apply_async looks for the current request, and if it finds one with a transaction manager it does not send anything. It registers an after-commit hook and returns None. The task goes to Redis only if the transaction succeeds. This closes the oldest race in web development: queueing work about a row, then rolling back the row. The price is stated in the docstring: you get None instead of an AsyncResult, so you cannot wait on the task, which inside a request you should not be doing anyway.

Tasks get a fake request. So much of the codebase assumes request exists that get_request() manufactures one with pyramid.scripting.prepare, gives it an explicit transaction manager, and sets remote_addr = "127.0.0.1". Every task body runs inside with request.tm, wrapped in metrics, and a pyramid_retry-retryable exception is turned into a Celery retry. If you have ever wondered why a task signature starts with self, request, that is where the request comes from, and why its IP is always localhost.

Twenty-three periodic tasks are registered across the codebase via config.add_periodic_task. They range from every minute (checking whether the file-cache sync is falling behind, notifying users of terms updates) to daily at a fixed hour (search reindex, expiring OIDC macaroons, SES cleanup). They are declared next to the code they serve, not in one schedule file, so the way to find them all is to grep.

The dev environment fakes the cloud with an HTTP server

docker-compose.yml brings up nineteen services. Some are the real thing at a smaller size: Postgres, Redis, OpenSearch, the Camo image proxy, the RSTUF pair. Some are the real application: web, worker, tests, the static asset watcher, three documentation sites.

The rest are stand-ins, and the naming is not subtle. notdatadog is a Python script that listens on the statsd port and throws metrics away. notgithub impersonates GitHub's token-scanning callback. stripe-mock answers billing calls. mailpit catches every outgoing email and shows it in a browser at port 1080.

And object storage (S3, B2, the whole file-hosting stack) is a container running python -m http.server 9001 over a shared volume. The worker service sets FILES_BACKEND, ARCHIVE_FILES_BACKEND, and SIMPLE_BACKEND to the Local* storage classes pointed at that volume, and the application is none the wiser. That is the swappable-backend pattern paying for itself: the most expensive external dependency in production is a standard-library HTTP server on a laptop.

Checkpoint 3 · configuration and tasks

Q5A view calls request.task(send_email).delay(...) and then raises. What reaches the worker?

B. WarehouseTask.apply_async registers _after_commit_hook on the request's transaction manager and returns None. The hook only calls the real apply_async when it is handed success=True. C is the failure mode this design exists to prevent, and it is exactly what happens if a task is queued outside a request, since with no request the override falls straight through to sending immediately.

Q6Local development stores uploaded files where?

C. The files service runs python -m http.server 9001 over the packages, packages-archive, and simple volumes, and the backends are set to LocalFileStorage, LocalArchiveFileStorage, and LocalSimpleStorage through maybe_set_compound strings in the compose file. Because the selection happens at boot through an interface, nothing in the view code changes between that and B2 in production.

History

What Warehouse deliberately did not bring with it

The old PyPI codebase ran the index from the early 2000s until April 2018. Warehouse replaced it, and the replacement was not feature-complete on purpose. The developer docs keep the list of dropped features, and it reads as a statement of what an index is for:

Every one of those removals shifted work from the index to the client, or from a human to a token. Read the list and the modern design follows: files are immutable, metadata comes from the artifact, identity is an API token or a Trusted Publisher, and analytics live outside the request path.

The map

Where everything is

Everything this guide asserted, and the file it came from.

ThingWhereWhy you'd look
The whole application, assembled warehouse/config.py → configure() Every setting, every include, every tween, in execution order
The production fleet Procfile, gunicorn-prod.conf.py, gunicorn-uploads.conf.py What actually runs, and why uploads get four minutes and 32 KiB of headers
Trust boundary for client identity warehouse/utils/wsgi.py → ProxyFixer, VhmRootRemover Before you believe an IP, a scheme, or a host
First in, last out warehouse/sanity.py Rebuilds the tween chain to bracket everything; included last, deliberately
URL → object warehouse/routes.py, warehouse/predicates.py, packaging/models.py factories Domain predicates and traversal; the deep version is No. 02
The core schema warehouse/packaging/models.py Project, Release, File, JournalEntry, and every inherited constraint
Size limits warehouse/constants.py plus the projects_upload_limit_max_value check constraint 100 MiB per file, 10 GiB per project, 1 GiB hard cap on any override
Task machinery warehouse/tasks.py → WarehouseTask The after-commit hook and the manufactured request
Dev stack docker-compose.yml, dev/environment, Makefile Nineteen services, several of them polite fictions
The project's own overview docs/dev/application.md, docs/dev/architecture.md User classes, the caching rationale, C4 diagrams, the dropped-features list
Upload, in full No. 05 · PEP 694 field guide The one view this guide keeps pointing at and refusing to open
Download telemetry No. 10 · Linehaul field guide Where the download counts went after they left the API

If you're going to work on this

In order, and the order matters more than any single item on the list.

  1. Read config.py start to finish before anything else. It is the table of contents for the entire application, and every unfamiliar subpackage appears in it as one line you can go read next.
  2. Bring the dev stack up and use ptweens and proutes through development.ini. The real tween order and the real route table beat any diagram, including the one above.
  3. When a model surprises you, check the migration that created the column before you assume the ORM is the whole story. Triggers, partial indexes, and check constraints are doing real work that models.py only hints at.
  4. Treat every “temporary” and “eventually” comment as a permanent feature with a plan attached. forklift/, allow_multiple_sdist, and the name-based journal foreign key have all outlived several roadmaps.
  5. Then read No. 02 on the plugin architecture and hybrid dispatch, and No. 03 on swappable backends. This guide named both mechanisms and explained neither; that was the deal.

Pyramid terms, plain English

ACL / __acl__
A list of (allow or deny, who, permission) rules a resource can define. Pyramid's authorization layer checks it before letting a request through.
config.scan()
Walks the codebase looking for decorators like @view_config and registers whatever it finds. Without a scan, a decorator alone does nothing.
directive
A custom method bolted onto Pyramid's Configurator object (via add_directive), so a plugin can offer its own verb, like config.add_periodic_task(...), instead of every caller repeating the same boilerplate.
find_service()
Asks the registry for "whatever implements this interface" and gets an object back, without the caller knowing or caring which concrete class it is.
includeme(config)
The one function every Pyramid plugin must define. config.include("some.package") calls it and hands over the Configurator, so the plugin can register its own routes, views, and services.
predicate
A condition attached to a route or view, like request_method="POST", that Pyramid checks before it will match that route or view to an incoming request.
register_service_factory()
The other half of find_service(): tells the registry "when someone asks for this interface, call this factory to build the answer."
registry
Pyramid's central lookup table, assembled once at startup, holding every route, view, service, and setting the running app knows about.
resource
In traversal, an object reachable by walking a chain of __getitem__ calls from the root. The traversal equivalent of a URL path segment.
root factory
The function that builds the very first resource in a traversal chain, given a request. Every other resource is reached by indexing into it.
subscriber
A function registered to run automatically whenever a specific event fires, like a transaction commit, without the code that fires the event knowing the subscriber exists.
traversal
Pyramid's alternative to matching a URL against a pattern: walk a resource tree one path segment at a time via __getitem__, and whatever object you land on becomes the view's context.
tween
Short for "between." Middleware that sits between Pyramid's router and the view, in an explicit, orderable chain, wrapping the whole request/response cycle.
venusian
The library config.scan() actually uses. A decorator like @view_config just leaves a note for venusian to find later; it does nothing by itself until scanned.
view deriver
A wrapper Pyramid can apply to every view during registration, used for cross-cutting behavior (like injecting a metrics call) without every view author remembering to add it by hand.