Skip to content
← Back to explainers

Python explainer

Ask for Only the Methods You Use with a Protocol

About 2 min to read. Allow extra time to work through examples.

Replace a heavy parameter type with a small Protocol that lives next to the function that uses it, so you can test the function without its database.

This article uses Python. You can make it your practice mode to browse related content across the site.

Your report function needs one number from your metrics database:

from metrics_db import PostgresMetrics


def metric_label(db: PostgresMetrics, name: str) -> str:
    return f"{name}: {db.latest(name):.2f}"

To test it, you need a PostgresMetrics, and importing the report module loads the database driver. The function calls one method, latest, but its signature asks for the whole client.

You met Protocol in the interfaces track. It is a class that lists the methods a parameter needs, and any object with those methods fits, without inheriting from it. Use one to ask for only what the function uses.

Define the Protocol next to the code that uses it

from typing import Protocol


class MetricSource(Protocol):
    def latest(self, name: str) -> float: ...


def metric_label(source: MetricSource, name: str) -> str:
    return f"{name}: {source.latest(name):.2f}"


class MemoryMetrics:
    def __init__(self, values: dict[str, float]) -> None:
        self.values = values

    def latest(self, name: str) -> float:
        return self.values[name]


print(metric_label(MemoryMetrics({"loss": 0.125}), "loss"))  # loss: 0.12

MemoryMetrics does not inherit from MetricSource. When you pass one to metric_label, mypy checks that its latest method matches the Protocol. If latest returned str, mypy would reject that call.

Put MetricSource in the reporting module, or in a small module of its own that does not import the database driver. PostgresMetrics lives elsewhere, and it does not need to import the Protocol at all. The report module now imports without the database driver, and its test runs with MemoryMetrics.

The next articles build on this. The code that uses a dependency states the Protocol it needs. The code that talks to the database only has to have the right methods.

Use a standard type when one fits

For a function that only looks up values, collections.abc.Mapping already says what it needs:

from collections.abc import Mapping
from types import MappingProxyType


def completed(counts: Mapping[str, int]) -> int:
    return counts.get("completed", 0)


counts = {"completed": 3}
print(completed(counts))                    # 3
print(completed(MappingProxyType(counts)))  # 3

It accepts a dict and a read-only view of one. If the function writes entries, ask for MutableMapping instead.

Check your understanding mypy accepts the object you pass. Does that mean it behaves the way the function expects? Show answer

No. mypy checks method names and types, not behavior. A MetricSource can return an old value, or raise for a name it does not know. Write those rules in the Protocol's docstring, and test each implementation against them. mypy also checks nothing at runtime, so an object from untyped code can still lack the method.

See the typing specification on protocols.

step 12 of 41

Modules, Packaging and Tooling

Up next Split Code into Domain, Application and Adapter Layers