PyPI · Onboarding No. 02 pypi/warehouse PyPI Onboarding series 5,657 words · ~28 min Architecture

Warehouse Architecture Field Guide

Warehouse has no plugin framework. It has one Pyramid convention (includeme) used fifty times, and one function that calls them in an order you must not shuffle. This guide reads that function, then follows a URL all the way to a database row.

Orientation

Fifty plugins, one function

Open warehouse/config.py and scroll to configure(). Past four hundred lines of pulling settings out of the environment, it builds a Configurator and then does this, fifty-four times:

config.include("pyramid_services")
config.include(".metrics")
config.include(".csrf")
…
config.include(".sanity")
config.commit()

That is the whole architecture. Pyramid's config.include(x) imports x, finds a function named includeme, and calls it with the configurator. Whatever that function registers, the application gets. Warehouse takes the convention seriously: grep the tree and you find 50 modules that define includeme. Forty-six of them are named directly in configure(); the other four hang off two of those (admin pulls in admin.routes, admin.flags and admin.bans; forklift pulls in forklift.action_routing). Eight more include calls reach third-party packages: pyramid_services, pyramid_jinja2, pyramid_tm, pyramid_retry, pyramid_mailer, pyramid_rpc.xmlrpc, pyramid_redirect, and the debug toolbar in development only.

So there is no registry to discover, no entry-point scan, no dependency resolver. The order of the plugins is the order of the lines. If you want to know why something is available on a request, you read one function top to bottom and stop at the line that put it there.

Terms

Throughout: a plugin is a module with an includeme. A directive is a method a plugin bolts onto the configurator for later plugins to call. A tween is Pyramid's middleware, sitting between the router and the view. A predicate is a match test attached to a route or a view. A service is an object looked up at request time by interface, through pyramid_services.

The cost of this design is that ordering is a real constraint held only by comments and by whoever wrote the line. The payoff is that the whole app is legible. Any subpackage's __init__.py tells you exactly what that subpackage contributes to the running process, and nothing contributes anything else.

Anatomy

Four plugins, dissected

Plugins vary from four lines to a hundred. Read these four and you have seen every move the rest make.

  1. 01

    warehouse/db.py: an engine, a request attribute, a directive

    warehouse/db.py → includeme

    Twenty lines at the bottom of a two-hundred-line module. It adds an alembic_config directive so migration commands can build an Alembic config from live settings; creates the SQLAlchemy engine (pool_size=35, max_overflow=65, READ COMMITTED) and parks it in config.registry["sqlalchemy.engine"]; attaches request.db as a reified request method; and sets a JSON serializer on psycopg.

    The session factory is overridable from settings (warehouse.db_create_session) purely so tests can swap it. Note what _create_session does on every request: it asks for the metrics service, registers the session with zope.sqlalchemy against request.tm, adds a finished-callback to close the connection, and reads the READ_ONLY admin flag, dooming the transaction if it is set. Four other plugins are already required for that one attribute to work.

  2. 02

    warehouse/rate_limiting/__init__.py: three lines that fifteen callers depend on

    warehouse/rate_limiting/__init__.py → includeme

    The entire body:

    def includeme(config):
        config.add_directive("register_rate_limiter", _register_rate_limiter)
        config.registry["ratelimiter.storage"] = storage_from_string(
            config.registry.settings["ratelimit.url"]
        )
        config.add_tween("warehouse.rate_limiting.headers.rate_limit_headers_tween_factory")

    A directive, a Redis-backed limits store, and a tween that stamps rate-limit headers on responses. The directive is the point. _register_rate_limiter registers a named IRateLimiter service, and fifteen calls in five later plugins use it: nine in accounts (login, 2FA, email add, password reset, account search), two in packaging (project creation per user and per IP), two in manage, one in search, one in legacy.api.xmlrpc. Every one of those plugins is included after this one. That is not a coincidence, it is the ordering constraint.

  3. 03

    warehouse/metrics/__init__.py: a service, six subscribers, a deriver

    warehouse/metrics/__init__.py → includeme

    The widest surface for its size. It registers the metrics backend as an IMetricsService factory, chosen by dotted path from settings. It subscribes handlers to five Pyramid events (NewRequest, BeforeTraversal, ContextFound, BeforeRender, NewResponse) plus IBeforeRetry, which is how route-match time, traversal time, view time and render time all get timed separately. It adds a view deriver under viewderivers.INGRESS to wrap and time every view. And it attaches request.metrics.

    This is why .metrics is the second include in the whole function, right after pyramid_services. It must be in place before anything it measures.

  4. 04

    warehouse/email/__init__.py: nine lines at the end of 1,193

    warehouse/email/__init__.py → includeme (line 1185 of 1193)

    The email module holds every transactional message Warehouse sends. Its includeme registers one service factory for IEmailSender and one nightly periodic task to trim the EmailMessage table. That is all.

    Take the general lesson: plugin surface has nothing to do with plugin size. A plugin's includeme tells you what it hands the application. Everything else in the module is reached by import, by config.scan(), or not at all.

Checkpoint 1 · reading a plugin

Q1Someone moves config.include(".rate_limiting") to just before config.include(".sanity"), at the very bottom. What happens?

C. add_directive mutates the configurator immediately. Any plugin included afterwards can call the new method; any plugin included before it cannot, and dies with an AttributeError while configure() is still running. Pyramid does defer and conflict-resolve many actions until commit(), but directives are not among them. This is the most common way to break the build by moving one line, and it fails loudly, which is the good case.

Q2Where does request.find_service(...) come from?

D. config.include("pyramid_services") is the very first include, before even metrics, because it supplies both config.register_service_factory and request.find_service. Twenty-one plugins register services through it (forty-one registrations in total), and everything else looks them up by interface at request time. Nothing else in Warehouse could work if this line moved down. How those interfaces are defined and swapped per environment is the subject of the next guide in this series.

The centerpiece

The wiring order, annotated

Below is the include sequence from configure(), with the settings and Jinja plumbing elided. The comments quoted here are the load-bearing ones. They document constraints, not intentions.

IncludeWhy here
1pyramid_servicesSupplies register_service_factory and request.find_service. Everything downstream needs both.
2.metricsTiming subscribers and the timing view deriver must exist before anything worth timing does.
3.csrfSource comment: "immediately after we've created the Configurator instance so that we ensure to get our defaults set ASAP before anything else has a chance to set them and possibly call Configurator().commit()". It calls set_default_csrf_options(require_csrf=True), so CSRF is on by default and views opt out.
4.logging, pyramid_jinja2, .filters, pyramid_mailerRenderers and the structlog tween. .filters also attaches request.camo_url and request.canonical_url.
5pyramid_retry, pyramid_tmRetry (3 attempts) then transactions. db.py marks IntegrityError retryable so a double-submitted form resolves instead of 500ing.
6.rate_limitingThe register_rate_limiter directive. Fifteen later calls depend on this position.
7.legacy.api.xmlrpc, .legacy.api.xmlrpc.cache, pyramid_rpc.xmlrpcFirst consumer of the rate limiter, one line later. XML-RPC gets its own renderer with allow_none=True.
8.legacy.action_routingRegisters the pypi_action route predicate and the add_pypi_action_route / add_pypi_action_redirect directives. Must precede .routes and .forklift, which both call them.
9.predicatesRegisters the domain and auth_methods route predicates and two view predicates. Nearly every route in the app passes domain=, so this must precede .routes.
10.i18n, .db, .tasks, .static, .search.tasks adds add_periodic_task; twenty-three calls to it follow, in nine plugins. .static adds the three whitenoise_* directives used later in configure() itself.
11.aws, .b2, .gcloudCloud clients as services, before the storage backends in .packaging ask for them.
12.sessions, .cache.http, .cache.origin, .cache.cache.origin adds the register_origin_cache_keys directive that .packaging calls eleven times to wire Fastly purge keys to ORM events.
13.email, .accounts, .macaroons, .oidc, .attestationsIdentity, in dependency order: mail sender, then user services, then token-based auth, then Trusted Publishing, then PEP 740 attestations.
14.manage, .organizations, .subscriptions, .packagingThe domain plugins. .packaging is the heaviest: five storage services, two rate limiters, eleven origin-cache key registrations, seven periodic tasks.
15.redirects, pyramid_redirectSource comment: # internal then # external. The internal one adds add_redirect, which .routes uses on the next line.
16.routesLate on purpose. It consumes add_template_view, add_redirect, add_pypi_action_route, the domain predicate, and factories from .packaging, .organizations and .accounts. 127 add_route calls in 767 lines.
17.sponsors, .banners, .admin.admin is a small nested application: its own service, its own Jinja search path, its own static bundle at /admin/static, and its own routes, flags and bans plugins.
18.forkliftSource comment: "Register forklift, at least until we split it out into it's own project." See below.
19tweens, view deriver, static assets, .api.configThe HTTPS tween for legacy ?:action= POSTs; the compression tween, positioned over= the conditional-HTTP tween and the exception view; a deriver that rejects duplicate POST keys; the whitenoise static wiring; the OpenAPI spec.
20.utils.wsgi, .sentry, .csp, .referrer_policy, .captcha, .helpdesk, .http, .utils.row_counterWSGI middleware (ProxyFixer, VhmRootRemover) is registered here too, via Warehouse's own add_wsgi_middleware directive on its Configurator subclass.
21config.scan(...)Venusian scan over two categories, "pyramid" and "warehouse", skipping the migrations, celery and wsgi entrypoints. This is what turns every @view_config into a registered view, and every @db.listens_for into a live SQLAlchemy event handler.
22.sanitySource comment: "It is very important that this go last. We need everything else that might have added a tween to be registered prior to this."
23config.commit()Every deferred action fires, conflicts resolve, the registry is sealed.

Why sanity has to cheat

Tweens are normally positioned with over= and under= hints, and Pyramid sorts them. The sanity plugin does not want a position in that sort; it wants to be the outermost and the innermost wrapper, so that a request with undecodable bytes in the query string returns a 400 instead of a 500, and a redirect to a non-ASCII location gets quoted on the way out. So it converts the whole implicit tween chain into an explicit one at the last possible moment:

def includeme(config):
    # I'm doing bad things, I'm sorry. - dstufft
    config.action(
        ("tween", "warehouse.sanity.sanity_tween_factory", True),
        _add_tween,
        args=(config,),
        order=PHASE3_CONFIG,
    )

_add_tween adds the ingress tween explicitly, copies every implicit tween in order, then adds the egress tween. Deferring it to PHASE3_CONFIG means it runs after every other plugin's tween registration, at commit() time. The apology has survived in the source for over a decade. It is still the clearest single example of what "the ordering comments are load-bearing" means in this codebase.

The toolbox

Six ways a plugin extends the app

Every one of the fifty plugins uses some subset of these. There is nothing else.

register_service_factory
Bind an implementation to an interface, optionally under a name. packaging registers IFileStorage twice, as "cache" and "archive", plus ISimpleStorage, IDocsStorage and IProjectService. Views then ask for what they need by interface: request.find_service(IFileStorage, name="archive"). 41 registrations across 21 plugins. The interface pattern that makes local disk, S3, GCS and B2 interchangeable is its own topic; see Swappable Backends, next in this series.
add_request_method
Hang an attribute on every request, usually reify=True so it is computed once. Twenty-three calls, which together are the request object you actually use: db, metrics, user, oidc_publisher, oidc_claims, task, log, id, locale, _, flags, banned, http, opensearch, sentry, sponsors, camo_url, canonical_url, ip_address, remote_addr_hashed, and forklift's own help_url and user_docs_url.
add_directive
Extend the configurator itself, for later plugins. Fourteen calls: register_rate_limiter, register_origin_cache_keys, alembic_config, add_periodic_task, make_celery_app, task, add_redirect, add_template_view, add_pypi_action_route, add_pypi_action_redirect, add_legacy_action_route, and three whitenoise_* directives. Every one creates an ordering constraint.
add_tween
Middleware inside Pyramid, so it sees the request object rather than the raw WSGI environ. Seven ordinary ones: structlog request context, conditional HTTP, rate-limit headers, HTTPS enforcement on legacy action POSTs, response compression, CSP headers, referrer policy. Plus the sanity pair described above.
add_view_deriver
Wrap every view that opts in. Ten of them, and their relative order is spelled out in the arguments: csrf_view goes under=INGRESS, over="secured_view" because, in the source's words, "we do not want to access the ambient authority provided by the session cookie without first checking to ensure that this is not a cross-site request." The method check goes over CSRF so that CSRF never fires on a view that was never going to accept the method anyway.
add_subscriber, predicates, periodic tasks
Six event subscribers, all in metrics. Two route predicates (domain, auth_methods) and one more from action routing (pypi_action); two view predicates (require_headers, require_active_organization). Twenty-three add_periodic_task calls declaring the Celery beat schedule inline, next to the code they schedule. packaging alone contributes seven, including a per-minute file-cache check and a quarter-hourly storage reconcile.

Worth noticing what is not on this list: there is no lifecycle hook, no plugin-to-plugin messaging, no configuration schema. Plugins talk to each other in exactly two ways: by calling a directive an earlier plugin added, or by asking for a service at request time.

Dispatch

The router matches; the ORM supplies the context

Most Python web frameworks pick a view by matching the URL, then hand the view some string captures and let it query. Pyramid can do that, and it can also do traversal, where the URL path is walked segment by segment through a graph of objects. Warehouse does both at once. The dev docs call it by name: hybrid URL traversal and dispatch.

Of 127 routes in warehouse/routes.py, 54 use traverse=. They name a factory and a traversal path built from the matched pattern:

config.add_route(
    "packaging.release",
    "/project/{name}/{version}/",
    factory="warehouse.packaging.models:ProjectFactory",
    traverse="/{name}/{version}",
    domain=warehouse,
)

Seven factories cover all of them: ProjectFactory on 26 routes, OrganizationFactory on 17, TeamFactory and UserFactory on 5 each, plus OrganizationApplicationFactory and two JSON-API release factories. The traversal itself is nothing but __getitem__ on ordinary SQLAlchemy models.

GET /integrity/flask/3.0.0/ flask-3.0.0.whl/provenance on pypi.org router: pattern + predicates domain = warehouse.domain ✓ matchdict: project_name, release, filename ProjectFactory(request) the traversal root traverse = "/{project_name}/ {release}/{filename}" ["flask"] → Project WHERE normalized_name = normalize_pep426_name(...) ["3.0.0"] → Release WHERE canonical_version = ... MultipleResultsFound → exact ["flask-3.0.0.whl"] → File WHERE release = self AND filename = ... context = that File @view_config(context=File) provenance_for_file(file, request) any KeyError → 404 traversal stops, context is wrong type, no view matches NoResultFound → raise KeyError from None
Fig. 1. Hybrid dispatch and traversal. The route pattern selects the factory; the factory and two ORM models supply the context by walking the rest of the path. The view is chosen by the type of the object at the end of the walk, not by the URL.

The three __getitem__ methods are four to twenty lines each and live in warehouse/packaging/models.py. All three end the same way:

    except NoResultFound:
        raise KeyError from None

That translation is the whole error-handling story. Pyramid's traverser catches KeyError, stops walking, and hands the view lookup whatever object it had reached plus the leftover segments. The views are registered against a type (@view_config(route_name="packaging.release", context=Release, ...)), so a walk that stopped early produces a context of the wrong type, no view matches, and Pyramid returns 404. The view function never runs. Its first argument is guaranteed to be a real, loaded ORM object:

@view_config(
    route_name="packaging.release",
    context=Release,
    renderer="warehouse:templates/packaging/detail.html",
    decorator=[origin_cache(1 * 24 * 60 * 60, stale_if_error=5 * 24 * 60 * 60)],
    has_translations=True,
)
def release_detail(release, request):
    project = release.project
    ...

Two consequences worth carrying around. First, authorization gets easier: the context object is loaded before the permission check, so a route can declare a permission and Pyramid evaluates it against that object's __acl__. Project.__acl__ builds its list from the project's roles and its organization. Second, "not found" and "no permission" are decided by the same machinery, in the same place, for every one of those 54 routes, and you never write the lookup twice.

The other 73 routes are plain dispatch: static pages, JSON endpoints that do their own querying, health checks, form handlers. Traversal is a tool here, not a religion.

Checkpoint 2 · dispatch and traversal

Q3A request arrives for /project/requests/99.99.99/. The project exists; that version does not. What is the context when the view is selected?

A. ProjectFactory["requests"] succeeds and returns the Project; Project["99.99.99"] raises KeyError, so traversal stops there with the Project as context and 99.99.99 left over as the view name. View lookup is (route, context type, view name), and release_detail wants context=Release. Nothing matches, so 404 without a single line of view code running. D is the trap: project_detail really is registered with context=Project, but on the other route, packaging.project. The route name is part of the match.

Q4You are adding a route for a new per-project page. What is the cheapest way to get a loaded Project and its ACL enforced?

A. Two keyword arguments on the route and one on the view. You get normalized-name lookup, the 404, the ACL, and a typed first argument, all from code that already exists and is already tested. This is the single most repeated pattern in routes.py (26 routes share ProjectFactory), and matching it is nearly always the right call. B works but duplicates four things; D is what you do only for a genuinely new resource type.

Domains

One process, more than one website

Warehouse serves pypi.org and upload.pypi.org from the same Python process, the same registry, the same database session. The only thing separating them is a route predicate, seventeen lines of it in warehouse/predicates.py:

class DomainPredicate:
    def __call__(self, info, request):
        # Support running under the same instance for local development and for
        # test.pypi.io which will continue to host it's own uploader.
        if self.val is None:
            return True
        return is_same_domain(request.domain, self.val)

Every route in routes.py passes domain=warehouse, where warehouse is the warehouse.domain setting. Every route in forklift/__init__.py passes domain=forklift, from FORKLIFT_DOMAIN. The predicate's None branch is the escape hatch that lets one dev container answer to both, and lets TestPyPI host its own uploader on its own hostname.

Four routes deliberately carry no domain= at all, so they answer on any hostname the process is reachable at: health, force-status, packaging.file, and legacy.docs. The first two are for the load balancer and for exercising error templates; the other two are URL shapes that predate the domain split.

one WSGI process one registry, one router, one DB session per request domain = warehouse.domain 123 routes · the website and its APIs domain = forklift.domain /legacy/ + 4 · the upload API no domain predicate health, force-status, file, docs pypi.org Fastly → warehouse upload.pypi.org bare A record, no CDN any hostname load balancer, dev container GET/POST /pypi one path, three protocols Content-Type: text/xml → XML-RPC endpoint ?:action=<name> → 1 of 10 legacy views neither → redirect to /project/
Fig. 2. Above: one process, three route sets, separated by a predicate that compares request.domain to a setting. Below: /pypi carrying three protocols at once, disambiguated by a header predicate and a query-parameter predicate.

The /pypi pile-up

The same predicate mechanism resolves a much older mess. The single path /pypi on pypi.org is claimed by three different protocols, and the router picks between them on request properties:

The predicate is a dozen lines and does the obvious thing:

class PyPIActionPredicate:
    def __call__(self, context, request) -> bool:
        return self.action_name == request.params.get(":action", None)

Four of those ten action routes, the upload-shaped ones, are served by a single view, forklifted, in warehouse/legacy/api/pypi.py. It returns 410 Gone with a message pointing at the forklift domain. The upload path exists on the main site only so that a client using a decade-old URL gets told where to go.

The tenant

Forklift, which never moved out

Forklift is the upload API. It has been about to become its own project for roughly ten years, and the file says so at the top, before any code:

"NOTE: warehouse.forklift is a temporary package, and it is assumed that it will go away eventually, once we split forklift out into it's own project."

warehouse/forklift/__init__.py, lines 3–5

Read as architecture rather than as a promise, that comment is a design constraint that has held: forklift is coupled to the rest of Warehouse only through the shared toolbox, so it stays extractable. Its includeme is 58 lines and contributes exactly six things.

  1. 01

    Its own copy of the action-routing directive

    warehouse/forklift/action_routing.py

    Eleven lines, near-identical to warehouse/legacy/action_routing.py, with one difference that is the whole point: the legacy version's add_pypi_action_route registers the path /pypi, forklift's add_legacy_action_route registers /legacy/. Both reuse the same pypi_action route predicate, which the legacy module registered earlier in configure(). Duplicating eleven lines rather than sharing a parameterized helper is what keeps the eventual split a directory move.

  2. 02

    Five routes, all domain-scoped

    warehouse/forklift/__init__.py → includeme

    file_upload, submit, submit_pkg_info and doc_upload on /legacy/, plus /legacy without the slash so a missing slash gets a 308 rather than a 404. Only file_upload still does anything; the other three return 410 Gone. Note the one that carries auth_methods={"basic-auth", "macaroon"} , a storage-only predicate that always matches and exists purely so the security policy can read it back off request.matched_route.predicates.

  3. 03

    Three template views and two request methods

    warehouse/forklift/__init__.py → includeme

    The upload host serves an index page, a robots.txt, and a catch-all for /legacy/ requests with no valid :action. It attaches request.help_url and request.user_docs_url, which build absolute URLs back to the main site and to docs.pypi.org. They exist because every error message the upload host returns has to point somewhere else.

  4. 04

    Three service lookups, and that is the whole coupling

    warehouse/forklift/legacy.py (1,899 lines)

    file_upload is one of the largest view functions in the codebase, and it reaches into the shared machinery through three find_service calls: IProjectService to create a project, IIntegrityService for PEP 740 attestations, and IFileStorage named "archive" for the object-storage PUT. Everything else it needs it gets from the request attributes other plugins attached (request.db, request.metrics, request.user, request.help_url) and from view-config options other plugins registered, such as require_csrf=False, require_methods=["POST"] and permit_duplicate_post_keys=True.

    What that view actually validates (the metadata forms, hashing, size limits, YARA scan, filename rules, attestations) is the subject of the PEP 694 field guide, which walks the gauntlet in order. This guide is only about how it is plugged in.

Add it up and forklift is a second website in the same process: its own hostname, its own routes, its own request methods, its own directive, and 1,899 lines of view code. It is joined to everything else by five service and request-method names.

Checkpoint 3 · one process, two sites

Q5What stops POST https://pypi.org/legacy/ from reaching the upload view?

B. Route selection ends it. is_same_domain(request.domain, "upload.pypi.org") is false, the route is skipped, and nothing on pypi.org claims /legacy/, so it is a 404. The 410 you have seen elsewhere is a different thing: it comes from the forklifted view on the ten-route /pypi?:action= family, which is the pre-2018 URL. One process, one route table, two hostnames, and a predicate that decides which half of the table is even considered.

Q6Both warehouse/legacy/action_routing.py and warehouse/forklift/action_routing.py exist and are nearly identical. Which reading is right?

D. The legacy module registers /pypi routes and owns PyPIActionPredicate; the forklift module registers /legacy/ routes and reuses that predicate through the configurator. Both are live and both are included by configure(): the legacy one directly, the forklift one from inside forklift's own includeme. Eleven duplicated lines are the price of the split staying a directory move instead of an untangling. A is the reflex to resist here.

The map

Where everything is

ThingWhereWhy you'd look
The whole wiring warehouse/config.py → configure() The one function. Read the comments; several are constraints.
Every plugin rg '^def includeme' warehouse/ 50 hits. The fastest inventory of what the app is made of.
Route table warehouse/routes.py (127 routes) · warehouse/forklift/__init__.py · warehouse/admin/routes.py Which factory, which traversal, which domain
Traversal chain warehouse/packaging/models.py → ProjectFactory.__getitem__, Project.__getitem__, Release.__getitem__ Three methods; every project URL in the site goes through them
Predicates warehouse/predicates.py (domain, auth_methods, view predicates) · warehouse/legacy/action_routing.py (pypi_action) How one route table serves several sites and several protocols
Request attributes rg 'add_request_method' warehouse/ 23 hits: the definitive list of what request.* has, and who put it there
Directives rg 'add_directive' warehouse/ 14 hits: every ordering constraint in the file, made explicit
The tween hack warehouse/sanity.py Why .sanity is last, and what PHASE3_CONFIG buys
Admin sub-app warehouse/admin/__init__.py (+ routes, flags, bans) A nested application with its own template path and static bundle
Upload API wiring warehouse/forklift/__init__.py, warehouse/forklift/action_routing.py The second website, in 95 lines, including its directive module
Framework docs docs/dev/application.md · Pyramid's hybrid dispatch and traversal chapters Warehouse's own docs name the pattern and link both chapters

If you're going to work on this

Six things worth carrying into your first patch, roughly in the order they bite.

  1. Read configure() once, end to end, before you touch anything. It is long but it is linear, and it is the only place the whole application exists at once.
  2. Before moving an include line, grep for the directives that plugin adds. A directive moved down the file breaks startup; a service moved down usually does not. Know which one you have.
  3. When adding a resource-shaped route, copy the factory= plus traverse= plus context= pattern rather than querying in the view. You inherit the 404 and the ACL for free.
  4. When adding a plugin, put its includeme at the bottom of the module and keep it to registrations only. If it contains logic, that logic is untestable without booting a configurator.
  5. Anything you add to a subpackage that is not in its includeme and not decorated for config.scan() is dead code. Check which category yours is in.
  6. Next in this series, Swappable Backends: the zope.interface and pyramid_services pattern that lets the same find_service(IFileStorage) return local disk in development and S3 in production. See the series page for status.

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.