PyPI · Onboarding No. 03 pypi/warehouse PyPI Onboarding · No. 03 6,536 words · ~33 min Services

Swappable Backends Field Guide

One line of Warehouse asks for somewhere to put a file. It gets a directory on a laptop, or a bucket at a cloud provider, and it never finds out which. This guide follows that one request from an environment variable to a live object.

The trick

One call, six possible objects

The view that accepts every upload to PyPI puts the file away in two statements. Here they are, from warehouse/forklift/legacy.py:

storage = request.find_service(IFileStorage, name="archive")
storage.store(
    file_.path,
    os.path.join(tmpdir, filename),
    meta={
        "project": file_.release.project.normalized_name,
        "version": file_.release.version,
        "package-type": file_.packagetype,
        "python-version": file_.python_version,
    },
)

On a laptop, store copies bytes into a directory inside a container. In production it uploads an object to a bucket. The view does not know which happened, and there is nothing in those two statements that could tell it. IFileStorage is not a class you can instantiate. It has no code in it at all. It is a label, and something else decided at startup what the label points at.

Six classes in Warehouse claim that label. Two write to local disk, two talk to S3, one talks to Google Cloud Storage, and one talks to Backblaze B2. Any of them can be the answer, and picking is not the view's problem.

The obvious alternative would be a branch on the environment. Warehouse compares the running environment against a constant in exactly eleven places across the whole tree: a block of development-only settings, the debug toolbar include, two Content Security Policy rules, log formatting, Celery's broker TLS flag, one API detail, and four CLI commands that refuse to run outside development. None of those eleven choose a storage backend, a mail transport, or a rate limiter. There is no such comparison anywhere in warehouse/packaging/services.py, warehouse/email/services.py, or warehouse/rate_limiting/.

The choice runs the other way. The environment hands the application a dotted path, and the application imports whatever is on the end of it. This guide follows one interface, IFileStorage, from an environment variable through to a running object, then makes two shorter passes over email and rate limiting to show where the same pattern bends.

Two pieces of machinery sit underneath and are not re-explained here. pyramid_services supplies config.register_service_factory and request.find_service, and Pyramid's includeme convention is how each part of Warehouse gets a chance to register anything. Both are the subject of No. 02, Warehouse's Plugin Architecture. Read that first if config.include(".packaging") does not yet mean anything to you.

Vocabulary

An interface is a class with nothing in it

Warehouse gets its interfaces from zope.interface. Here is the whole of the storage contract, from warehouse/packaging/interfaces.py, trimmed only of docstring prose:

class IGenericFileStorage(Interface):
    def create_service(context, request):
        """Create the service, given the context and request..."""

    def get(path: str):
        """Return a file like object that can be read..."""

    def get_metadata(path: str):
        """Return a dictionary containing any user-created metadata..."""

    def get_checksum(path: str):
        """Return the md5 digest of the file at a given path..."""

    def store(path: str, file_path, *, meta=None):
        """Save the file located at file_path to the file storage..."""


class IFileStorage(IGenericFileStorage):
    pass


class ISimpleStorage(IGenericFileStorage):
    pass

Read the method signatures again. There is no self. There are no bodies. Nothing here is ever instantiated, subclassed for behaviour, or called. If you put a print in one of these methods it would never run.

The declaration does two jobs. It writes down the contract in one screen, which is the only place a reader can see what all six storage classes owe each other. And it produces an importable, hashable Python object, which makes it usable as a key. That second job is the load-bearing one.

Look at the two subclasses at the bottom. IFileStorage and ISimpleStorage add nothing. They have the same five methods and the same meaning for each. They exist because Warehouse needs to look up the store for distribution files separately from the store for the Simple API pages, and a registry keyed by object identity cannot tell two uses apart if they share a key. An empty subclass is the cheapest way to mint a second key with the same contract. IDocsStorage, in the same file, is a genuinely different contract: two methods, one of which is remove_by_prefix.

@implementer is a claim, not a check

On the implementation side the decorator looks like this:

@implementer(IFileStorage)
class LocalFileStorage(GenericLocalBlobStorage):
    @classmethod
    def create_service(cls, context, request):
        return cls(request.registry.settings["files.path"])

@implementer records a declaration on the class. It does not compare method names against the interface, and it will happily decorate a class that implements none of the five methods. Import succeeds either way.

The check exists, but it lives in the test suite. zope.interface.verify.verifyClass compares a class against an interface and raises if a method is missing or its signature does not fit. Warehouse calls it at 47 sites, covering 23 distinct interfaces. So the guarantee is real and it is enforced at the moment you would want it enforced, which is before merge, not at every import in production. The interfaces that no test verifies are worth knowing about: IRateLimiter is one of them, and it comes up again in section six.

Scale of the pattern

Across the tree: 29 interfaces, spread over seventeen interfaces.py files; 60 classes carrying @implementer; 41 calls to register_service_factory. Storage is the largest single family, with six classes behind IFileStorage alone.

The implementations

Six ways to store a file

All six live in warehouse/packaging/services.py. Each is a thin subclass of a shared generic base, and each contributes exactly one thing of its own: a create_service classmethod that knows which settings keys to read. The table adds a seventh row, LocalSimpleStorage, because it shows the pattern at its thinnest: same base, same methods, different settings key, different interface.

ClassBaseReads settingsTalks to
LocalFileStorageGenericLocalBlobStoragefiles.paththe filesystem
LocalArchiveFileStorageGenericLocalBlobStoragearchive_files.paththe filesystem
LocalSimpleStorageGenericLocalBlobStoragesimple.paththe filesystem (registered under ISimpleStorage)
S3FileStorageGenericS3BlobStoragefiles.bucket, files.prefixa bucket, via the aws.session service
S3ArchiveFileStorageGenericS3BlobStoragearchive_files.bucket, archive_files.prefixa different bucket, same session
B2FileStorageGenericB2BlobStoragefiles.bucket, files.prefixBackblaze B2, via the b2.api service
GCSFileStorageGenericGCSBlobStoragefiles.bucket, files.prefixGoogle Cloud Storage, via gcloud.gcs

create_service is the factory, and it runs late

Every one of these classmethods takes (cls, context, request) and returns an instance. That signature is what pyramid_services wants from a factory. Nothing is built at startup: the registry stores the callable, and calls it the first time some code in a request asks for the interface. The result is cached for the rest of that request.

Late construction is what makes the cloud implementations affordable. S3FileStorage.create_service asks for another service by name, aws.session, and builds a bucket resource from it. B2FileStorage asks for b2.api. GCSFileStorage asks for gcloud.gcs and then does a live get_bucket call, wrapped in a retry that catches ServiceUnavailable. If those ran at import time, every management command and every test collection would need cloud credentials. Because they run per request and only on demand, a request that never touches a file never authenticates to anything.

The local one shouts about itself

The shared local base refuses to be quiet:

class GenericLocalBlobStorage:
    def __init__(self, base):
        # This class should not be used in production, it's trivial for it to
        # be used to read arbitrary files from the disk. ...
        warnings.warn(
            "LocalFileStorage is intended only for use in development, you "
            "should not use it in production due to the lack of safe guards "
            "for safely locating files on disk.",
            InsecureStorageWarning,
            stacklevel=2,
        )
        self.base = base

    @classmethod
    def create_service(cls, context, request):
        raise NotImplementedError

Two things to take from that. First, InsecureStorageWarning subclasses DevelopmentModeWarning, which subclasses UserWarning. Warehouse's pytest config sets filterwarnings = ["error", ...] and then adds one line ignoring DevelopmentModeWarning, so the warning is loud enough that a developer sees it and quiet enough that it does not fail a test run. Three other warnings in warehouse/utils/exceptions.py follow the same shape for the null OIDC publisher, the null integrity service, and the null OAuth provider.

Second, the base class's create_service raises. The base cannot be named in an environment variable, because service factories are stored at startup and only invoked lazily, on first lookup. Naming the base would resolve cleanly and fail later, on the first request that needs it, not at startup. Only the three concrete subclasses can be named, and each differs from the others by exactly one dictionary key.

Not every implementation implements everything

This is the sharpest limit of the pattern, and it is easier to see in the code than to be told:

class GenericGCSBlobStorage(GenericBlobStorage):
    def get(self, path: str):
        # Note: this is not actually used in to serve files, instead our CDN is
        # configured to connect directly to our storage bucket.
        raise NotImplementedError

    def get_metadata(self, path: str):
        raise NotImplementedError

    def get_checksum(self, path: str):
        raise NotImplementedError

GCSFileStorage passes verifyClass(IFileStorage, ...). All five method names are present with the right signatures. Three of them raise the moment you call them. The reasoning in the comment is sound: nothing in Warehouse reads a distribution back out through the storage service, because the CDN is pointed at the bucket directly, so a read path would be dead code with a credential requirement attached.

The S3 base makes the same argument and reaches the opposite conclusion. Its get carries a near-identical comment about the CDN, then implements the read anyway, translating NoSuchKey into FileNotFoundError. B2 implements all four. So among the cloud backends, one is read-write, one is read-write, and one is write-only with three landmines.

An interface guarantees that a method exists. It does not guarantee the method works. When you swap a backend, read the implementation you are swapping to, not just the interface it claims.

The reads are not hypothetical, either. reconcile_file_storages, a Celery task that runs every fifteen minutes, asks for both storages by name and calls get_checksum on each to decide whether a file needs copying from one to the other. That task is exactly the code that would break against a write-only backend.

The switch

One environment variable, parsed by shlex

Here is the line from dev/environment that decides everything above for a development checkout:

FILES_BACKEND=warehouse.packaging.services.LocalFileStorage path=/var/opt/warehouse/packages/ url=http://localhost:9001/packages/{path}

A dotted path, then keyword arguments in shell syntax. Seven lines of warehouse/config.py turn it into settings:

def maybe_set_compound(settings, base, name, envvar):
    if envvar in os.environ:
        value = shlex.split(os.environ[envvar])
        kwargs = dict(i.split("=") for i in value[1:])
        settings[f"{base}.{name}"] = value[0]
        for key, value in kwargs.items():
            settings[f"{base}.{key}"] = value

shlex.split applies the quoting rules you already know from a shell, so a value with spaces in it can be quoted and a long chain of keywords needs no escaping. The commented-out BILLING_BACKEND alternative in dev/environment carries five keyword arguments on one line. The first token becomes files.backend. Every remaining key=value becomes files.<key>. So one variable produces three settings:

SettingValueWho reads it
files.backendwarehouse.packaging.services.LocalFileStoragepackaging's includeme
files.path/var/opt/warehouse/packages/LocalFileStorage.create_service
files.urlhttp://localhost:9001/packages/{path}warehouse/routes.py

That last row surprises people. files.url never reaches a storage class. routes.py reads it on its second line and uses it as the route pattern for packaging.file, plus a redirect from /packages/{path:.*}. One variable, two unrelated consumers: the class path goes to the service registry, the URL goes to the router. Change the backend and you are usually changing where files are served from as well, so the two travel together.

Fourteen backends are configured this way. The list is worth reading in one go, because it is the full inventory of what Warehouse considers swappable at deploy time:

maybe_set_compound(settings, "billing", "backend", "BILLING_BACKEND")
maybe_set_compound(settings, "files", "backend", "FILES_BACKEND")
maybe_set_compound(settings, "archive_files", "backend", "ARCHIVE_FILES_BACKEND")
maybe_set_compound(settings, "simple", "backend", "SIMPLE_BACKEND")
maybe_set_compound(settings, "docs", "backend", "DOCS_BACKEND")
maybe_set_compound(settings, "sponsorlogos", "backend", "SPONSORLOGOS_BACKEND")
maybe_set_compound(settings, "origin_cache", "backend", "ORIGIN_CACHE")
maybe_set_compound(settings, "mail", "backend", "MAIL_BACKEND")
maybe_set_compound(settings, "metrics", "backend", "METRICS_BACKEND")
maybe_set_compound(settings, "breached_emails", "backend", "BREACHED_EMAILS")
maybe_set_compound(settings, "breached_passwords", "backend", "BREACHED_PASSWORDS")
maybe_set_compound(settings, "domain_status", "backend", "DOMAIN_STATUS_BACKEND")
maybe_set_compound(settings, "github.oauth", "backend", "GITHUB_OAUTH_BACKEND")
maybe_set_compound(settings, "gitlab.oauth", "backend", "GITLAB_OAUTH_BACKEND")

Four more backends use the plain maybe_set instead: CAPTCHA_BACKEND, OIDC_BACKEND, INTEGRITY_BACKEND, and HELPDESK_BACKEND. Those take a bare dotted path and nothing else, because their implementations read no extra settings. The compound form exists only for backends that need arguments.

The dotted path becomes a class

Now the packaging plugin's includeme, the first four statements of it:

def includeme(config):
    files_storage_class = config.maybe_dotted(config.registry.settings["files.backend"])
    config.register_service_factory(
        files_storage_class.create_service, IFileStorage, name="cache"
    )

    archive_files_storage_class = config.maybe_dotted(
        config.registry.settings["archive_files.backend"]
    )
    config.register_service_factory(
        archive_files_storage_class.create_service, IFileStorage, name="archive"
    )

config.maybe_dotted is Pyramid's resolver. Given a string it imports the module and pulls the attribute; given anything else it returns it unchanged, which is how metrics can pass a class object as its default. The result is a class. Warehouse then reaches straight past it for one attribute, create_service, and hands that bound classmethod to the registry as the factory. The class itself is never stored.

Look at what those two blocks do together. IFileStorage gets registered twice, under two different names, from two different environment variables. Production runs two storage backends behind one interface at the same time: an archive that receives every upload, and a cache that the CDN serves from. reconcile_file_storages copies from one to the other. The upload view writes only to "archive". Nothing in any of that code names a vendor.

The same file registers ISimpleStorage and IDocsStorage with no name at all, from SIMPLE_BACKEND and DOCS_BACKEND. Four registrations, four environment variables, one includeme.

1 · ENVIRONMENT FILES_BACKEND ...LocalFileStorage path=... url=... 2 · maybe_set_compound shlex.split token 0 → files.backend k=v → files.k 3 · SETTINGS files.backend files.path files.url read by warehouse/routes.py, never by a storage class 4 · packaging/__init__.py · includeme config.maybe_dotted(settings["files.backend"]) → LocalFileStorage config.register_service_factory(cls.create_service, IFileStorage, name="cache") the class is discarded; only the create_service classmethod is stored 5 · THE REGISTRY, AFTER STARTUP IFileStorage + "cache" ← FILES_BACKEND IFileStorage + "archive" ← ARCHIVE_FILES_BACKEND 6 · AT REQUEST TIME request.find_service(   IFileStorage, name="archive")
Fig. 1. The full path for file storage, from a line in dev/environment to an object in a view. Steps 1 through 4 happen once, at startup. Steps 5 and 6 repeat per request, and the factory does not run until something asks. The dashed branch is files.url, which leaves the pipeline early and is read by the router instead.

Checkpoint 1 · interfaces and the switch

Q1Why do IFileStorage and ISimpleStorage both subclass IGenericFileStorage and add nothing?

A. The parent has no bodies to inherit, so D is impossible. The registry is keyed on the interface object, so distribution files and Simple API pages need different objects even though the contract is identical. A one-line empty subclass is the whole mechanism. IDocsStorage, by contrast, does not subclass anything: it has a different contract.

Q2FILES_BACKEND ends with url=http://localhost:9001/packages/{path}. Which code reads that value?

A. maybe_set_compound flattens every keyword into the same files. namespace, and the consumers of that namespace are unrelated to each other. create_service reads only files.path. The router reads files.url and never touches the storage service. Knowing this saves you an hour the first time you change a backend and files stop being served.

Q3A test asserts verifyClass(IFileStorage, GCSFileStorage) and it passes. What have you learned?

B. Three of those five methods raise NotImplementedError on the GCS backend, on purpose, because the CDN reads the bucket directly. C is the trap: reconcile_file_storages calls get_checksum on both configured storages, so a write-only backend on either side of that pair breaks the task. A is wrong because @implementer only records a declaration; verifyClass in a test is what actually compares.

Second worked example

Email, where the implementations differ in capability

Storage shows the pattern at its cleanest, where every backend does the same job through a different vendor. Email shows what happens when they do not.

warehouse/email/interfaces.py is twenty-one lines and declares three methods:

class IEmailSender(Interface):
    def create_service(context, request):
        """Create the service, given the context and request..."""

    def send(recipient, message):
        """Sends an EmailMessage to the given recipient."""

    def last_sent(to, subject):
        """Determines when an email was last sent, if at all"""

The wiring is two lines at the bottom of warehouse/email/__init__.py, a file otherwise 1,193 lines long and full of message-sending functions:

email_sending_class = config.maybe_dotted(config.registry.settings["mail.backend"])
config.register_service_factory(email_sending_class.create_service, IEmailSender)

Same two moves as packaging, one interface, no name. Three classes can land on the other end.

SMTPEmailSender wraps pyramid_mailer and calls send_immediately. SESEmailSender builds a raw MIME message and calls send_raw_email on an Amazon SES client, which it gets from the aws.session service, the same one S3FileStorage uses. And ConsoleAndSMTPEmailSender subclasses the SMTP one, calls super().send(...), then prints the message to standard output with a pointer to a web inbox.

That third class is the development default:

MAIL_BACKEND=warehouse.email.services.ConsoleAndSMTPEmailSender host=mailpit port=1025 ssl=false sender=noreply@pypi.org

The keyword arguments here go to pyramid_mailer's own settings under mail., not to the sender class. mailpit is a container in docker-compose.yml listening on 1025 for SMTP and publishing its web UI on 1080, which is the URL the printed block tells you to open. So a developer gets the email three ways at once: delivered over SMTP, browsable in a fake inbox, and dumped in the application log.

last_sent is where the abstraction leaks

Compare the two implementations of the third method. SMTP:

def last_sent(self, to, subject):
    # We don't store previously sent emails, so nothing to compare against
    return None

SES:

def last_sent(self, to, subject):
    last_email = (
        self._db.query(SESEmailMessage)
        .filter(
            SESEmailMessage.to == to,
            SESEmailMessage.subject == subject,
        )
        .order_by(SESEmailMessage.created.desc())
        .first()
    )
    if last_email:
        return last_email.created
    return None

The SES sender writes an SESEmailMessage row on every send, so it can answer the question. The SMTP sender writes nothing, so it cannot, and it says so in a comment rather than pretending. This is not a vendor difference. It is a capability difference wearing the same interface.

It has a visible consequence. _send_email_to_user takes a repeat_window argument, and when one is set it does this:

sender = request.find_service(IEmailSender)
last_sent = sender.last_sent(to=email.email, subject=msg.subject)
if last_sent and (datetime.datetime.now() - last_sent) <= repeat_window:
    return "repeat-window"

In production that suppresses a duplicate. In development last_sent is always None, the condition is always false, and the suppression never fires. A developer testing a "we already emailed you about this" path locally will watch it not happen, and nothing in the log will explain why. The interface hid the difference from the caller, which is what it is for, and then the difference showed up anyway as behaviour.

Rule of thumb

When an interface method has an implementation that returns a constant, treat that method as optional and go read every caller. The constant is usually chosen so callers degrade quietly, which is exactly what makes the difference hard to notice.

Third worked example

Rate limiting, and the object that does nothing

The third variation drops the environment variable entirely. IRateLimiter declares five methods and, unlike the other two interfaces here, no create_service: test, hit, resets_in, clear, and get_window_stats. Two classes implement it, both in warehouse/rate_limiting/__init__.py. (There is no services.py in that package. The implementations sit in the package __init__ alongside the includeme.)

RateLimiter is real. It wraps a MovingWindowRateLimiter from the limits library over a storage built from the ratelimit.url setting, which points at Redis. Every public method carries the same decorator:

def _return_on_exception(rvalue, *exceptions):
    def deco(fn):
        @functools.wraps(fn)
        def wrapper(self, *args, **kwargs):
            try:
                return fn(self, *args, **kwargs)
            except exceptions as exc:
                logger.warning("Error computing rate limits", error=repr(exc))
                self._metrics.increment(
                    "warehouse.ratelimiter.error", tags=[f"call:{fn.__name__}"]
                )
                return rvalue

        return wrapper

    return deco

The return values are chosen one method at a time. test and hit return True on a RedisError, which means allow the action. clear and resets_in return None. get_window_stats returns an empty list. Warehouse fails open: if Redis is unreachable, rate limiting stops happening and uploads keep working, with a warning log and a warehouse.ratelimiter.error metric per call so somebody finds out. That is a deliberate trade, and the direction is the same one PyPI takes elsewhere. An index that rejects every upload is worse than an index that briefly forgets its limits.

Now read the other implementation:

@implementer(IRateLimiter)
class DummyRateLimiter:
    def test(self, *identifiers):
        return True

    def hit(self, *identifiers):
        return True

    def clear(self, *identifiers):
        return None

    def resets_in(self, *identifiers):
        return None

    def get_window_stats(self, *identifiers):
        return []

Five methods, five constants, and they match the fail-open values from the decorator exactly. DummyRateLimiter is the null object: an implementation whose entire content is the absence of behaviour, so that callers never need a None check. ProjectService leans on it directly, holding its limiters in a collections.defaultdict(DummyRateLimiter, ratelimiters), so asking for a limiter that was never configured returns a permissive one instead of raising KeyError.

A different registration style, for a different reason

Storage and email vary by environment, so their switch is an environment variable. Rate limiters vary by purpose: there is one for project creation by IP, another for project creation by user, and more elsewhere. All of them are the same class with different limit strings. So the registration is wrapped in a custom directive instead:

def _register_rate_limiter(config, limit_string, name):
    config.register_service_factory(
        RateLimit(limit_string, identifiers=[name]),
        IRateLimiter,
        name=name,
    )


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")

Callers then write one line each. Packaging's includeme registers two:

config.register_rate_limiter(project_create_user_limit_string, "project.create.user")
config.register_rate_limiter(project_create_ip_limit_string, "project.create.ip")

RateLimit is a small callable class, not a function, with __eq__ and __repr__ defined so tests can compare registrations by value. It also takes a limiter_class argument defaulting to RateLimiter, which is the seam: pass DummyRateLimiter and the same registration produces a limiter that permits everything.

The same move, at three different distances

Null implementations appear across Warehouse, and they are not all swapped the same way. Three examples mark out the range.

Metrics swap through an environment variable, with a twist. warehouse/metrics/__init__.py defaults to the class object itself rather than a string, which maybe_dotted passes through untouched:

metrics_class = config.maybe_dotted(
    config.registry.settings.get("metrics.backend", NullMetrics)
)
config.register_service_factory(metrics_class.create_service, IMetricsService)

So a deployment that sets nothing gets NullMetrics, whose methods all return None. Development, oddly, does not use it: dev/environment sets METRICS_BACKEND=warehouse.metrics.DataDogMetrics host=notdatadog, which sends real statsd packets to a hostname that does not resolve. Both approaches produce no metrics. Only one of them exercises the real code path.

Rate limiting swaps through a constructor argument and a defaultdict, as above. No environment variable exists for it.

Search swaps only in tests. SearchService and NullSearchService both implement ISearchService, and the second is a no-op, but warehouse/search/__init__.py registers only SearchService. The single place NullSearchService is used outside its own unit test is tests/conftest.py. There is no SEARCH_BACKEND variable to find, so do not go looking for one.

The pattern is a spectrum, not a rule. Ask where the swap happens before you assume it is configuration.

Checkpoint 2 · email, limiters, null objects

Q4Redis goes down. What happens to a project-creation request that would normally be rate limited?

C. _return_on_exception(True, redis.RedisError) on both methods. The failure is recorded twice, in the log and as warehouse.ratelimiter.error tagged with the method name, so it is visible without being fatal. Note that resets_in and get_window_stats get different fallback values, None and [], chosen so their callers also degrade quietly.

Q5You add a "do not resend within an hour" rule to an email and test it locally. It never suppresses. Why?

D. Only SESEmailSender writes an SESEmailMessage row per send, so only it can answer last_sent. The SMTP path returns None with a comment saying why. A is the trap worth naming: there is no environment check in that file, and looking for one is how you lose an afternoon.

Q6Why does rate limiting use a custom register_rate_limiter directive rather than a RATELIMIT_BACKEND variable?

C. The directive is one function that closes over register_service_factory, so a caller writes one line per limiter instead of four. The swap seam is still there: RateLimit takes a limiter_class argument, and ProjectService holds its limiters in a defaultdict(DummyRateLimiter, ...). B is close but backwards: RateLimit.__call__ is itself the factory, so the missing create_service is a consequence of the design rather than the reason for it.

The payoff

What this buys, and what it costs

Three concrete returns, and one bill.

Tests get a registry made of dictionaries

Because every dependency arrives through find_service, a test can replace the whole service layer without touching Pyramid. tests/conftest.py does it in ten lines:

class _Services:
    def __init__(self):
        self._services = defaultdict(lambda: defaultdict(dict))

    def register_service(self, service_obj, iface=None, context=None, name=""):
        self._services[iface][context][name] = service_obj

    def find_service(self, iface=None, context=None, name=""):
        return self._services[iface][context][name]

A fixture then registers a stub per interface and assigns dummy_request.find_service = pyramid_services.find_service. A view under test asks for IEmailSender and gets a stub that records calls. No monkeypatch, no import surgery, no configuration. This only works because the view never named a class.

Development needs no credentials

A fresh checkout runs the full upload path, the full email path, and the full rate-limiting path against local disk, a local fake inbox, and a local Redis. Nobody hands a new contributor an AWS key. The parity table is short and worth memorising:

InterfaceDevelopment (dev/environment)ProductionChosen by
IFileStorage name cacheLocalFileStoragean object-storage classFILES_BACKEND
IFileStorage name archiveLocalArchiveFileStoragean object-storage classARCHIVE_FILES_BACKEND
ISimpleStorageLocalSimpleStoragean object-storage classSIMPLE_BACKEND
IDocsStorageLocalDocsStorageS3DocsStorageDOCS_BACKEND
IEmailSenderConsoleAndSMTPEmailSender → mailpitSESEmailSenderMAIL_BACKEND
IMetricsServiceDataDogMetrics host=notdatadogDataDogMetricsMETRICS_BACKEND, default NullMetrics
IRateLimiterRateLimiter on local RedisRateLimiterregister_rate_limiter, per purpose
ISearchServiceSearchService on local OpenSearchSearchServicenothing. NullSearchService is test-only

The production column deliberately says "an object-storage class" rather than naming one. Which class runs where is deployment configuration, and it is not in the repository. Read the code for the shape and the operators' configuration for the contents.

The bill: a bug report that says "the wrong backend"

Indirection costs you the ability to read the answer off the call site. When a file goes missing, an email does not arrive, or a limit does not apply, the stack trace names a generic base class and the view names an interface. Neither tells you what was running. The recovery is a fixed procedure, and it is short.

  1. 01

    Find the interface and the name

    grep the failing view for find_service

    Two arguments matter: the interface, and the name=. IFileStorage alone is ambiguous, because there are two of them. Getting "cache" and "archive" the wrong way round explains a surprising share of confusion.

  2. 02

    Find the registration

    grep for the interface name in the owning package's __init__.py

    You are looking for the register_service_factory call and the settings key it reads. That gives you the environment variable, which is the only thing that actually decides.

  3. 03

    Read the variable in the failing environment

    dev/environment, docker-compose.yml, or the deployment's configuration

    The first token is the class. Now you know what was running. Everything before this step was navigation.

  4. 04

    Read that class, not the interface

    warehouse/<package>/services.py

    Check for methods that raise, return constants, or swallow exceptions. GCSFileStorage.get, SMTPEmailSender.last_sent, and every method on DummyRateLimiter are all cases where the interface promises something the implementation does not deliver. The interface tells you what should happen. Only the class tells you what did.

Four steps, all greps, no debugger. Cheap for what it buys: the upload view has not needed to know a vendor's name in its whole history, and swapping one is a change to a deployment variable rather than a change to application code.

The map

Where everything is

ThingWhereWhy you'd look
The storage contract warehouse/packaging/interfaces.py IGenericFileStorage and its two empty subclasses. Read this before any storage implementation
All six file-storage classes warehouse/packaging/services.py Local, S3, GCS, B2, plus the docs and simple variants. Check which methods raise
Storage registration warehouse/packaging/__init__.py → includeme, first four statements Where IFileStorage is registered twice, under "cache" and "archive"
The parser warehouse/config.py → maybe_set_compound, and its 14 call sites The complete inventory of deploy-time swappable backends
Email warehouse/email/interfaces.py, email/services.py, last two lines of email/__init__.py's includeme Three senders, one interface, and the last_sent capability gap
Rate limiting warehouse/rate_limiting/interfaces.py and rate_limiting/__init__.py The fail-open decorator, the null object, and the register_rate_limiter directive. There is no services.py here
Null implementations elsewhere warehouse/metrics/services.py, warehouse/search/services.py NullMetrics is a configurable default; NullSearchService is test-only
What development actually runs dev/environment and docker-compose.yml Every *_BACKEND value, plus the mailpit ports
The test-side registry tests/conftest.py → _Services and the pyramid_services fixture How a stub gets in front of a view, in ten lines
The contract checks tests/unit/packaging/test_services.py and siblings 47 verifyClass call sites over 23 interfaces. Add one when you add an implementation
The registry machinery itself pyramid_services, included first in configure() Covered in No. 02, not repeated here

If you're going to work on this

Five habits, in the order you will need them.

  1. When you add an implementation, add a verifyClass assertion in the same commit. It is one line, and it is the only automated check that @implementer was telling the truth.
  2. When you add a method to an interface, grep for every @implementer of it and decide, per class, what the honest answer is. Returning a constant is allowed. Returning a constant without a comment saying why is how last_sent would have become a mystery.
  3. Do not write if statements about the environment in a service. If two environments need different behaviour, that is two classes and a settings key. The twelve existing environment comparisons are all about developer tooling, and they are the precedent to stay inside.
  4. Before swapping a backend in any environment, read the class you are swapping to for methods that raise. A backend can satisfy the interface and still break a periodic task, which is how a change that passes review fails fifteen minutes after deploy.
  5. When you register a second implementation of one interface, use name= and be exact about it. The "cache" and "archive" pair is the only case in the tree today, and it is worth reading reconcile_file_storages once so the two names stop being interchangeable in your head.

Next up

No. 05 in this series, Uploads, Then and Now, follows the other half of the upload path: the protocol a client speaks to forklift, why it looks the way it does, and what PEP 694 proposes to replace it with. The find_service(IFileStorage, name="archive") call that opened this guide is the last thing that view does. That guide covers everything before it.

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.