DEV Community

Cover image for Swapping implementations from the command line
Mauricio Villegas
Mauricio Villegas

Posted on Originally published at github.com AI-assisted

Swapping implementations from the command line

The tool built over the last four posts asks the catalog the same questions again and again. summary alone is six requests, one per magnitude band plus one for the strongest event. Run it, change the end date by a day, run it again, and five of those six answers were already on your screen a moment ago.

So the client wants a cache. The awkward part is not writing one. It is that there is no single right one:

  • At a terminal the process dies after every command, so anything kept in memory is gone before it is useful. The cache has to be files on disk.
  • In a notebook or a web service the process stays alive, and a dictionary in memory is both faster and simpler than touching the disk.
  • On a group of machines that share work, neither of those is right, and the answer is redis or memcached, which this project has no business depending on.

The usual way to offer a choice like that on a command line is a flag with a few allowed words, --cache-type={none,memory,disk}, plus --cache-path and --cache-ttl that mean something for one of those words and nothing for the others, plus a small block of if and elif inside the program that turns the word into an object. And when someone wants the redis one, they cannot have it without editing your tool.

That block is a copy of something already written down, in the same way that the completion script in part 3 and the os.environ.get calls in part 4 were copies. What is already written down, this time, is a type hint:

cache: Cache | None = None
Enter fullscreen mode Exit fullscreen mode

A collaborator, in plain Python

Nothing below is about command lines. It is the code you would write anyway, in quakes_client.py, if the client were only ever used from Python.

A base class that says what a cache has to be able to do:

class Cache(ABC):
    """Store for responses that were already received from the catalog.

    Subclasses decide where the entries are kept and for how long. The client
    only asks for a key and hands back whatever the catalog answered.
    """

    @abstractmethod
    def get(self, key: str) -> dict | None:
        """Return the entry kept for a key, or None if there is no valid one.

        Args:
            key: Identifier of the entry, the URL of the request.
        """

    @abstractmethod
    def set(self, key: str, value: dict) -> None:
        """Keep an entry for a key.

        Args:
            key: Identifier of the entry, the URL of the request.
            value: The response to keep.
        """
Enter fullscreen mode Exit fullscreen mode

And two implementations of it, one for each of the first two situations above:

class MemoryCache(Cache):
    """Cache that keeps entries in memory, for as long as the process lives."""

    def __init__(self, max_entries: int = 128):
        """Initialize the cache.

        Args:
            max_entries: How many entries to keep. The oldest one is dropped first.
        """
        self.max_entries = max_entries
        self._entries: dict[str, dict] = {}

    def get(self, key: str) -> dict | None:
        return self._entries.get(key)

    def set(self, key: str, value: dict) -> None:
        if len(self._entries) >= self.max_entries:
            del self._entries[next(iter(self._entries))]
        self._entries[key] = value


class DiskCache(Cache):
    """Cache that keeps entries as files, so that they outlive the process."""

    def __init__(self, path: Path = Path("~/.cache/quakes"), ttl: float = 3600.0):
        """Initialize the cache.

        Args:
            path: Directory in which the entries are written.
            ttl: Seconds an entry stays valid before the catalog is asked again.
        """
        self.path = path.expanduser()
        self.ttl = ttl

    def get(self, key: str) -> dict | None:
        entry = self._entry(key)
        if not entry.exists() or time.time() - entry.stat().st_mtime > self.ttl:
            return None
        return json.loads(entry.read_text())

    def set(self, key: str, value: dict) -> None:
        self.path.mkdir(parents=True, exist_ok=True)
        self._entry(key).write_text(json.dumps(value))

    def _entry(self, key: str) -> Path:
        return self.path / f"{hashlib.sha256(key.encode()).hexdigest()[:32]}.json"
Enter fullscreen mode Exit fullscreen mode

The two have different parameters, which is the whole point of the example. max_entries only makes sense for a cache in memory. path and ttl only make sense for one on disk.

The client takes one, as a new constructor parameter next to the ones from part 1:

    def __init__(
        self,
        distance_unit: Literal["km", "mi"] = "km",
        min_magnitude: float = 2.5,
        timeout: float = 30.0,
        cache: Cache | None = None,
    ):
        """Initialize the client.

        Args:
            distance_unit: Unit used for all distances, both given and returned.
            min_magnitude: Magnitude below which events are ignored by default.
            timeout: Seconds to wait for a response before giving up.
            cache: Where to keep the responses already received, if anywhere.
        """
Enter fullscreen mode Exit fullscreen mode

and consults it in the one private method that performs a request:

    def _get(self, path: str, **params) -> dict:
        url = f"{USGS_URL}{path}?{urlencode({'format': 'geojson', **params})}"
        if self.cache is not None and (kept := self.cache.get(url)) is not None:
            return kept
        try:
            with urlopen(Request(url), timeout=self.timeout) as response:
                data = json.loads(response.read())
        except HTTPError as ex:
            reported = ex.read().decode(errors="replace").strip().splitlines()
            raise CatalogError(f"{reported[0] if reported else ex.reason} (requesting {path})") from ex
        if self.cache is not None:
            self.cache.set(url, data)
        return data
Enter fullscreen mode Exit fullscreen mode

Note what the client does not do. It does not choose a cache, it does not build one, and it never names MemoryCache or DiskCache. It says what it needs, as a type, and waits to be given one:

from quakes_client import EarthquakeCatalog, MemoryCache

catalog = EarthquakeCatalog(min_magnitude=6, cache=MemoryCache())
Enter fullscreen mode Exit fullscreen mode

That is dependency injection, and there is nothing clever about it. It is ordinary Python, it is what makes the class easy to test, and every part of it is useful with no command line involved. The rest of this post is about what jsonargparse does with it.

An option nobody added

quakes_cli.py was not touched. The help has one more option:

$ quakes --help
Client for the earthquake catalog of the U.S. Geological Survey:
  ...
  ARG:   --cache.help [CLASS_PATH_OR_NAME]
                        Show the help for the given subclass of Cache and
                        exit.
  ARG:   --cache CACHE
  ENV:   QUAKES_CACHE
                        Where to keep the responses already received, if
                        anywhere. (type: Cache | null, default: null, known
                        subclasses: quakes_client.MemoryCache,
                        quakes_client.DiskCache)
Enter fullscreen mode Exit fullscreen mode

known subclasses is the list nobody had to write. jsonargparse looks for subclasses of Cache in the modules the program has imported, and those are what it found. Adding a third one to the client adds a third name to that line.

This is a different treatment from the one Area got in part 1, and the difference comes from the classes, not from any setting. Area is a concrete dataclass, so there is nothing to decide and its three fields became options directly: --area.latitude and friends. Cache is a base class with more than one implementation, so there is something to decide, and the decision comes first. Which options exist depends on what you decided.

Choosing one

The value of --cache is a class:

$ quakes --cache=DiskCache summary --start=2026-07-01 --end=2026-08-01
{
  "start": "2026-07-01",
  "end": "2026-08-01",
  "total": 3817,
  "by_magnitude": {
    "minor 2.0-3.9": 2330,
    "light 4.0-4.9": 1262,
    "moderate 5.0-5.9": 214,
    "strong 6.0-6.9": 10,
    "major 7.0+": 1
  },
  "strongest": {
    "time": "2026-07-17 14:48:40.227000+00:00",
    "magnitude": 7.3,
    "depth": 22,
    "latitude": 14.6361,
    "longitude": -92.8969,
    "id": "us7000t1bu",
    "place": "52 km W of Puerto Madero, Mexico"
  }
}
Enter fullscreen mode Exit fullscreen mode

Run it a second time and the six requests are not made:

$ time quakes --cache=DiskCache summary --start=2026-07-01 --end=2026-08-01 > /dev/null
real    0m0.670s
$ time quakes --cache=DiskCache summary --start=2026-07-01 --end=2026-08-01 > /dev/null
real    0m0.237s
Enter fullscreen mode Exit fullscreen mode

Most of that second number is Python starting up and the parser being built. The catalog was not contacted at all.

The parser imported the class, built it with its defaults, and passed the instance to EarthquakeCatalog.__init__. That is the injection, performed by the command line instead of by a line of Python.

DiskCache is the short name and works because the class is one of the known subclasses above. The full import path, quakes_client.DiskCache, always works and is what a config file or a script should use, since it cannot become ambiguous later.

The options come from the class you chose

--cache.help prints the help of an implementation, built from that implementation's own signature and docstring, the same way the main help was built from the client:

$ quakes --cache.help DiskCache
usage: quakes [--cache.path PATH] [--cache.ttl TTL]

Help for --cache.help=quakes_client.DiskCache

Cache that keeps entries as files, so that they outlive the process:
  --cache.path PATH  Directory in which the entries are written. (type: <class
                     'Path'>, default: ~/.cache/quakes)
  --cache.ttl TTL    Seconds an entry stays valid before the catalog is asked
                     again. (type: float, default: 3600.0)
Enter fullscreen mode Exit fullscreen mode
$ quakes --cache.help MemoryCache
usage: quakes [--cache.max_entries MAX_ENTRIES]

Help for --cache.help=quakes_client.MemoryCache

Cache that keeps entries in memory, for as long as the process lives:
  --cache.max_entries MAX_ENTRIES
                        How many entries to keep. The oldest one is dropped
                        first. (type: int, default: 128)
Enter fullscreen mode Exit fullscreen mode

Two implementations, two different help pages, no shared list of flags between them. And they are set the way you would expect:

$ quakes --cache=DiskCache --cache.ttl=600 --cache.path=/tmp/quakes \
      count --start=2026-07-01 --min_magnitude=6
20
Enter fullscreen mode Exit fullscreen mode

Two small rules go with that.

The class comes before its options. The parser has to know which class it is building before it can decide what --cache.ttl means, so the reverse order is an error:

$ quakes --cache.ttl=600 --cache=DiskCache count
error: Parser key "cache":
  ...
    - Expected an instantiatable class, but quakes_client.Cache is abstract
Enter fullscreen mode Exit fullscreen mode

An option that the chosen class does not have is an error, not a value that is ignored:

$ quakes --cache=MemoryCache --cache.ttl=600 count
error: Parser key "cache":
  ...
    - Problem with given class_path 'quakes_client.MemoryCache':
        Option 'ttl' is not accepted
Enter fullscreen mode Exit fullscreen mode

Which is the behaviour you want and the one a hand written --cache-type flag almost never has. There, --cache-ttl next to --cache-type=memory is usually accepted and quietly does nothing.

A class the tool has never heard of

Here is the part that a fixed list of words cannot do.

Say you want to know which requests are being served from the cache and which are not. That is a cache of your own, and it is short, because it only changes one of the two methods:

# logging_cache.py
"""A cache of my own, in a file the quakes command knows nothing about."""

import sys

from quakes_client import DiskCache


class LoggingCache(DiskCache):
    """Disk cache that reports on standard error whether each key was found."""

    def get(self, key: str) -> dict | None:
        value = super().get(key)
        print(f"cache {'hit ' if value else 'miss'}: {key}", file=sys.stderr)
        return value
Enter fullscreen mode Exit fullscreen mode

Put that file anywhere the Python process can import it from, and name it:

$ PYTHONPATH=. quakes --cache=logging_cache.LoggingCache --cache.ttl=600 \
      count --start=2026-07-01 --min_magnitude=6
cache miss: https://earthquake.usgs.gov/fdsnws/event/1/count?format=geojson&starttime=2026-07-01&minmagnitude=6.0
20
$ PYTHONPATH=. quakes --cache=logging_cache.LoggingCache --cache.ttl=600 \
      count --start=2026-07-01 --min_magnitude=6
cache hit : https://earthquake.usgs.gov/fdsnws/event/1/count?format=geojson&starttime=2026-07-01&minmagnitude=6.0
20
Enter fullscreen mode Exit fullscreen mode

--cache.ttl still works, because LoggingCache inherits the constructor of DiskCache, and the help page of a class written five minutes ago is as complete as the ones that ship with the tool:

$ PYTHONPATH=. quakes --cache.help logging_cache.LoggingCache
usage: quakes [--cache.path PATH] [--cache.ttl TTL]

Help for --cache.help=logging_cache.LoggingCache

Disk cache that reports on standard error whether each key was found:
  --cache.path PATH  Directory in which the entries are written. (type: <class
                     'Path'>, default: ~/.cache/quakes)
  --cache.ttl TTL    Seconds an entry stays valid before the catalog is asked
                     again. (type: float, default: 3600.0)
Enter fullscreen mode Exit fullscreen mode

quakes was not reinstalled, not edited, and not told that this class exists. There is no plug-in registry, no entry point group, no --load-plugin argument. The redis cache from the beginning of this post is now somebody else's file, in somebody else's package, and it costs this project nothing.

That is the property worth naming, because it is what the fixed list of words cannot give you. A tool whose choices are {none,memory,disk} is a tool whose author has to say yes before you can have a fourth. A tool whose choice is a type hint is one where the author is not involved.

What is checked, and what is not

Two conditions have to hold, and both produce a clear failure at startup rather than a strange one later.

The class has to be importable by the process. Nothing is searched for; the dotted path is imported the way Python imports anything. PYTHONPATH=. above is what makes a file in the current directory reachable, since the quakes command is a script installed elsewhere and does not add your working directory to sys.path. If the class lives in an installed package, nothing extra is needed.

$ quakes --cache=logging_cache.LoggingCache count
error: Parser key "cache":
  ...
    - Problem with given class_path 'logging_cache.LoggingCache':
        No module named 'logging_cache'
Enter fullscreen mode Exit fullscreen mode

The class has to satisfy the hint. Any importable name is accepted as text; only a subclass of Cache survives:

$ quakes --cache=quakes_client.Area count
error: Parser key "cache":
  ...
    - Import path does not correspond to a subclass of Cache
Enter fullscreen mode Exit fullscreen mode

And the base class itself is not a choice, because it is abstract and cannot be built:

$ quakes --cache=Cache count
error: Parser key "cache":
  ...
    - Expected an instantiatable class, but is abstract
Enter fullscreen mode Exit fullscreen mode

It is worth being explicit about what all of this means: the command line can now name a class, and the program will import the module that contains it. Importing a Python module runs its top level code. In practice this is the same trust boundary a command line already has — anyone who can choose your arguments can usually choose your PYTHONPATH, and on that machine they can just run Python — but it is a real widening of what an argument can do, and it deserves a thought before a tool that takes arguments from somewhere less trusted than a person's keyboard.

If you want a fixed set instead, say so in the hint. Write the parameter as a union of concrete classes rather than as the base class:

cache: MemoryCache | DiskCache | None = None
Enter fullscreen mode Exit fullscreen mode

and those two, plus anything derived from them, are all that is accepted. A different subclass of Cache is not:

$ quakes --cache=other_cache.NullCache count
error: Parser key "cache":
  Does not validate against any of the Union subtypes
  Subtypes: [<class 'quakes_client.MemoryCache'>, <class 'quakes_client.DiskCache'>, <class 'NoneType'>]
  Errors:
    - Import path does not correspond to a subclass of MemoryCache
    - Import path does not correspond to a subclass of DiskCache
    ...
Enter fullscreen mode Exit fullscreen mode

Which is the same trade as everywhere else in this series. The command line is as open, or as closed, as the type hint says it is, and the type hint is in the client where a reader of the code will see it.

The other four posts still apply

Nothing about --cache is a special case, so everything the earlier posts added works on it.

A config file (part 2) writes the choice as a class and its arguments. This is the full form of the value, and it is what the dotted options on the command line are a shorthand for:

# config.yaml
distance_unit: mi
min_magnitude: 4.0
timeout: 60.0
cache:
  class_path: quakes_client.DiskCache
  init_args:
    ttl: 21600
Enter fullscreen mode Exit fullscreen mode

The two layers combine per option rather than per value, so the command line can change one setting of the cache without repeating which cache it is:

$ quakes --config config.yaml --cache.ttl=60 --print_config count
distance_unit: mi
min_magnitude: 4.0
timeout: 60.0
cache:
  class_path: quakes_client.DiskCache
  init_args:
    path: ~/.cache/quakes
    ttl: 60.0
...
Enter fullscreen mode Exit fullscreen mode

And --cache=null turns it off for one run, whatever the file said.

If all you want is the class with its own defaults, the file can also say just the name, exactly as the command line does:

cache: MemoryCache
Enter fullscreen mode Exit fullscreen mode

Environment variables (part 4) take the same two forms, and this is a case where the JSON one is bearable, because it is one small object:

$ QUAKES_CACHE=DiskCache quakes count --start=2026-07-01
3979
$ QUAKES_CACHE='{"class_path": "quakes_client.DiskCache", "init_args": {"ttl": 60}}' \
      quakes count --start=2026-07-01
3979
Enter fullscreen mode Exit fullscreen mode

Completion (part 3) offers the implementations, since the parser knows them:

$ quakes --cache <TAB><TAB>
Expected type: Cache | null; 3/3 matched choices
null                       quakes_client.MemoryCache
quakes_client.DiskCache
Enter fullscreen mode Exit fullscreen mode

and it offers the options of every known implementation, telling you which one each belongs to, since it cannot know yet what you are about to choose:

$ quakes --cache.<TAB><TAB>
--cache.help         --cache.path
--cache.max_entries  --cache.ttl

$ quakes --cache.ttl <TAB><TAB>
Expected type: float; Accepted by subclasses: DiskCache
Enter fullscreen mode Exit fullscreen mode

--print_config is the one to reach for when a run does not behave as expected, because it prints the finished recipe: which class, with which arguments, after the file, the environment and the command line have all had their turn. It is also the easiest way to write the config file above — run the command once with the options you want, and paste.

The same hint in other shapes

Cache | None is one arrangement. A few others behave the way you would guess, and are worth knowing exist:

  • A parameter typed list[Cache] takes several of them. Each += appends one more, and the dotted options that follow apply to the one most recently appended.
  • Callable[[], Cache] asks for a factory instead of a finished object, for when one shared cache is wrong and multiple are needed: one per thread, one per request, one per retry.

The factory is the one worth showing, because nothing about the command line changes. Written this way:

new_cache: Callable[[], Cache] = MemoryCache
Enter fullscreen mode Exit fullscreen mode

the option offers the same implementations, and its value is still a class with its arguments:

  --new_cache NEW_CACHE
                        Builds a fresh cache each time it is called. (type:
                        Callable[[], Cache], default: {'class_path':
                        'quakes_client.MemoryCache'}, known subclasses:
                        quakes_client.MemoryCache, quakes_client.DiskCache)
Enter fullscreen mode Exit fullscreen mode

So --new_cache=DiskCache --new_cache.ttl=60 is typed exactly as it was before. What the constructor receives is not a DiskCache, though. It is something it can call, with the choice and the arguments already decided:

functools.partial(<function default_class_instantiator at ...>, <class 'quakes_client.DiskCache'>,
                  path=PosixPath('~/.cache/quakes'), ttl=60.0)
Enter fullscreen mode Exit fullscreen mode

Every call to it builds a new cache. That splits the two decisions the way you want them: what to build is chosen where the command is typed, and when to build it stays in the code that knows how many are needed and when they stop being valid.

If the callable's own signature has parameters, they are taken off the command line, because the caller is going to supply them. With Callable it is their number that counts: Callable[[int, str], Cache] reserves the first two parameters of whichever class was chosen, whatever they are called, and --new_cache.help then offers only the rest. A callable Protocol, a class with a __call__ method whose signature is the one you want, reserves them by name instead, so the ones left for the caller do not have to be the leading ones.

The shape this post is about, though, is the common one, and it appears far away from caches. An optimizer in a training script, an authentication method, a storage backend, a notifier, an exporter: anything a program should be able to swap without being rewritten is a parameter whose type is a base class. It is how Lightning, which builds its CLI on jsonargparse, lets --optimizer=torch.optim.AdamW --optimizer.lr=0.001 reach a training run that does not know AdamW exists.

What the other libraries do here

Part 1 compared the alternatives and pinned the versions it checked, so here is the same exercise for this one feature, against click 8.4, typer 0.27, fire 0.7 and tyro 1.0. The example is a shortened Catalog class with the same cache: Cache | None = None parameter and the same two implementations.

Fire accepts the argument and passes the text through untouched. --cache=DiskCache arrives at the constructor as the string 'DiskCache', and --cache='{"class_path": "DiskCache"}' arrives as a plain dict. Nothing is imported, nothing is built, nothing is checked.

Typer stops before the program starts, in the same way part 1 found it stopping on a dataclass: RuntimeError: Type not yet supported: <class 'catalog.Cache'>.

Click infers nothing by design, so this is a thing you write: a click.Choice of names, a mapping from name to class, and one option per constructor argument of every implementation. Which is exactly the hand written version this post started from. Click will not stop you from doing it well, but it is your code, and it grows with each implementation.

Tyro is the one with a real answer. Written as a union of concrete classes, the choice becomes a subcommand, tyro builds that class from the arguments that follow it, and the program runs with the instance. The subcommands cost a few lines, because tyro builds a CLI from a function or a class rather than from a class's methods. An unbound method is a function whose first parameter is self, so annotating it with the client class is enough for tyro to build the client and pass it in:

EarthquakeCatalog.__init__.__annotations__["cache"] = Union[MemoryCache, DiskCache, None]

subcommands = {}
for name, method in inspect.getmembers(EarthquakeCatalog, inspect.isfunction):
    if not name.startswith("_"):
        method.__annotations__["self"] = Annotated[EarthquakeCatalog, tyro.conf.arg(name="client")]
        subcommands[name] = method

print(tyro.extras.subcommand_cli_from_dict(subcommands))
Enter fullscreen mode Exit fullscreen mode

All five methods arrive with their own parameters and their docstrings, and nothing is restated. The equivalent of the command earlier in this post:

quakes --cache=DiskCache --cache.ttl=60 summary --start=2026-07-01 --end=2026-08-01
Enter fullscreen mode Exit fullscreen mode

is this:

python quakes_tyro.py summary --start 2026-07-01 --end 2026-08-01 \
    client.cache:disk-cache --client.cache.ttl 60
Enter fullscreen mode Exit fullscreen mode

The difference is the type hint, and the type hint lives in the client. EarthquakeCatalog declares cache: Cache | None, which is what the author of a client writes when no command line is in the picture. tyro reads it as naming the Cache class itself, which is abstract, so there is nothing to build: the option becomes --client.cache {fixed} (fixed to: None), and asking for client.cache:disk-cache is an unrecognized option. To get a choice, the parameter has to be rewritten as MemoryCache | DiskCache | None — an edit to quakes_client.py made for the benefit of the command line, and repeated every time an implementation is added. The snippet above makes that edit from the outside, so that the client in the repository stays the one the other posts describe.

Closed sets are a good design, and the union above says exactly that in jsonargparse too. The point is which way the dependency runs. What a closed set cannot accept is the name of a class written by a user of your tool, in a module your tool has never imported.

Neither of the other two shapes from earlier is available. A list[DiskCache] produces no option at all, the parameter simply not appearing in the help. And an instance factory, Callable[[], DiskCache], is fixed to whatever its default is and refuses any value given for it.

What this post leaves you with

Five posts, and the client has still not imported jsonargparse. What it gained this time was a collaborator it does not construct, described by a base class and a type hint, which is the shape a testable class has anyway.

Out of that, without a line in quakes_cli.py: an option that takes an implementation, a help page per implementation built from its own docstring, its constructor arguments as options, all of it settable from a config file or the environment, completed at the prompt, validated before anything runs, and open to classes that neither the tool nor its author has ever seen.

The trade is the one the series keeps making, and this is the largest version of it. A plug-in system is normally a subproject: a registry, a way to name plug-ins, a way to load them, a way to configure them, documentation for all of that. Here it is a parameter, typed honestly, in a class that does not know it has users at a terminal.

Top comments (0)