Architecture

Offering a Python API Alongside Your CLI

Design a CLI so other Python code can use it without subprocesses: a public api module returning data, typed exceptions mapped to exit codes, no printing, and stable versioning.

Updated

Sooner or later someone wants to use your CLI from Python. A data engineer wants to call mytool sync from an Airflow task; a colleague wants the same report your CLI prints, but as a list of dictionaries in a notebook; another tool wants to embed your validation logic. If the only interface is the command line, they shell out with subprocess.run(["mytool", ...]), parse text output, and break whenever the formatting changes. The better answer is to offer a small, deliberate Python API next to the CLI, so the command line becomes one client of your code and other programs can be another. That is less about adding code than about where you draw a line — and a CLI built this way is also easier to test. This guide shows the structure, the rules that keep the API usable, and how to version it. It belongs to the multi-command structure topic.

Prerequisites

Two clients, one core

One core, two clients A command line tool structured as a core with a public Python API, used both by a thin CLI layer and directly by other Python programs. One core, two clients mytool package one distribution mytool.api returns data, raises MytoolError mytool.cli parse, call, format, exit codes mytool._engine internal, not promised Terminal users → mytool.cli Python callers → mytool.api Both share the same core The CLI is just the first client of the API.

The core does the work and knows nothing about terminals: it takes typed arguments, returns data, and raises typed exceptions. The CLI is a thin adapter: it parses arguments, calls the core, formats results for humans or machines, and turns exceptions into messages and exit codes. A Python caller uses the core directly. The line between them is the public API — the functions and types you promise to keep stable.

The recipe

The public API module

# src/mytool/api.py
"""Public Python API for mytool. Everything in __all__ is supported; the rest is internal."""
from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime, timezone

__all__ = ["MytoolError", "NotFound", "Conflict", "Server", "list_servers", "restart"]


class MytoolError(Exception):
    """Base class for every error the API raises on purpose."""


class NotFound(MytoolError):
    pass


class Conflict(MytoolError):
    pass


@dataclass(frozen=True)
class Server:
    name: str
    region: str
    status: str
    restarted_at: datetime | None = None


_FLEET = {
    "web-1": Server("web-1", "eu-west", "ok"),
    "web-2": Server("web-2", "eu-west", "restarting"),
}


def list_servers(*, region: str | None = None) -> list[Server]:
    """Return servers, optionally filtered by region."""
    return [s for s in _FLEET.values() if region is None or s.region == region]


def restart(name: str) -> Server:
    """Restart a server and return its new state."""
    server = _FLEET.get(name)
    if server is None:
        raise NotFound(f"no server named {name!r}")
    if server.status == "restarting":
        raise Conflict(f"{name} is already restarting")
    updated = Server(server.name, server.region, "restarting", datetime.now(timezone.utc))
    _FLEET[name] = updated
    return updated

The rules are visible in the code. Functions return data — dataclasses rather than strings — so callers can use the result without parsing. They never print and never call sys.exit; that is the CLI's job. They raise typed exceptions from one base class, so callers can catch everything the API raises deliberately with except MytoolError, or a specific case such as NotFound. Keyword-only arguments (*, region) keep calls readable and let you add parameters later without breaking positional callers. And __all__ plus the module docstring say exactly what is public.

The CLI as a thin client

# src/mytool/cli.py
import json
from dataclasses import asdict

import typer

from mytool import api

app = typer.Typer()
EXIT_CODES = {api.NotFound: 4, api.Conflict: 5}


@app.callback()
def main() -> None:
    """Manage the fleet."""


@app.command("list")
def list_cmd(region: str = typer.Option(None), as_json: bool = typer.Option(False, "--json")):
    """List servers."""
    servers = api.list_servers(region=region)
    if as_json:
        typer.echo(json.dumps([asdict(s) for s in servers], default=str))
    else:
        for s in servers:
            typer.echo(f"{s.name:<8} {s.region:<8} {s.status}")


@app.command("restart")
def restart_cmd(name: str) -> None:
    """Restart a server."""
    try:
        server = api.restart(name)
    except api.MytoolError as exc:
        typer.echo(f"error: {exc}", err=True)
        raise typer.Exit(EXIT_CODES.get(type(exc), 1)) from None
    typer.echo(f"restarting {server.name}")

Every command is a few lines: parse, call, format, map errors. The mapping from exception types to exit codes lives in one place, so the CLI's exit-code contract — see choosing exit codes for CLI tools — is easy to read and test.

A Python caller now writes:

from mytool.api import Conflict, list_servers, restart

for server in list_servers(region="eu-west"):
    try:
        restart(server.name)
    except Conflict:
        pass                        # already restarting: fine for our purposes

No subprocess, no output parsing, real exceptions, and editor completion for every field.

The same operation, two ways Terminal session restarting a server through the command line, getting an exit code for a conflict, and doing the same from Python. The same operation, two ways bash $ mytool restart web-2; echo $? error: web-2 is already restarting 5 $ python -c "from mytool.api import restart; print(restart('web-1').status)" restarting Exceptions map to exit codes in exactly one place — the CLI layer.

python -m mytool

Add a __main__.py that calls the CLI. Users who have the package installed in a project environment can then run the CLI with that environment's interpreter, python -m mytool list, without relying on a console script being on PATH — handy in containers and CI, and covered in best practices for Python CLI entry points.

Versioning the API

Once other code imports mytool.api, it is part of your public interface alongside the command line, and the same semantic-versioning rules apply: adding functions, optional keyword arguments and dataclass fields with defaults is a minor change; removing or renaming any of them, or changing what an exception means, is a major one. Say so in the documentation, and keep anything you are not ready to support out of __all__ and out of mytool.api — internal modules can be prefixed with an underscore so nobody mistakes them for API. Semantic versioning policy for CLI tools covers the CLI side of the same policy.

Rules for the public API Practices for a Python API offered alongside a command line tool and the habits that make it unusable. Rules for the public API Do ✓ Return dataclasses or plain data ✓ Raise subclasses of one base error ✓ Keyword-only optional arguments ✓ Declare the surface in __all__ Never ✗ print() or sys.exit() in the core ✗ Typer, Click or Rich types in signatures ✗ Hidden global configuration ✗ Changing exception meaning in a minor Version the API exactly like the command line.

UX considerations

  • Keep the API smaller than the CLI. Not every command needs a Python equivalent. Start with the operations people actually script, and grow on request.
  • Return the same data the JSON output contains. If mytool list --json emits asdict(server), Python callers and shell callers see identical field names, which halves the documentation.
  • Do not leak framework types. No typer.Context, no Rich renderables, no click.Path in the API. Callers should not need the CLI framework installed to understand your signatures.
  • Avoid global state in the API. Configuration should be passed in (or read through an explicit function), so two callers in one process can use different settings.
  • Log, do not print. The API can use logging.getLogger("mytool"); the CLI decides how logs are shown, as in structured logging for CLI apps.

Testing the behaviour

The split pays off in tests: the core is tested as plain Python, and the CLI tests only check the adaptation.

# tests/test_api_and_cli.py
import json

import pytest
from typer.testing import CliRunner

from mytool import api
from mytool.cli import app

runner = CliRunner()


def test_api_returns_data_and_prints_nothing(capsys):
    servers = api.list_servers(region="eu-west")
    assert [s.name for s in servers] == ["web-1", "web-2"]
    assert capsys.readouterr() == ("", "")


def test_api_raises_typed_errors():
    with pytest.raises(api.NotFound):
        api.restart("nope")
    with pytest.raises(api.Conflict):
        api.restart("web-2")


def test_cli_maps_errors_to_exit_codes():
    assert runner.invoke(app, ["restart", "nope"]).exit_code == 4
    assert runner.invoke(app, ["restart", "web-2"]).exit_code == 5


def test_cli_json_matches_api_fields():
    rows = json.loads(runner.invoke(app, ["list", "--json"]).stdout)
    assert set(rows[0]) == {"name", "region", "status", "restarted_at"}


def test_public_names_are_exported():
    assert {"list_servers", "restart", "MytoolError"} <= set(api.__all__)

The first test is the guard rail: if a future change adds a print to the core, the API stops being usable from a notebook or a server, and this test fails.

Conclusion

A CLI that other programs can use without a subprocess is mostly a matter of structure. Put the work in a core that returns data, raises typed exceptions and never prints; make the CLI a thin client that parses, calls, formats and maps exceptions to exit codes; publish the core's supported surface in an api module with __all__; and version it like the command line. Python users get a real API, and you get a CLI that is easier to test.

Frequently asked questions

Should the API and CLI be separate packages?

Usually not. One distribution with mytool.api and a console script is simpler to version and install. Split them only if the CLI's dependencies (Typer, Rich) are unwelcome for API users — and even then, an optional cli extra often suffices, as in optional dependencies and extras for CLIs.

What about calling Typer commands directly from Python?

It works — command functions are ordinary functions — but they print instead of returning, exit instead of raising, and take CLI-shaped arguments. That is exactly what an API should not do. Call the core instead.

Can the API be async?

Yes, if the work is I/O-bound and callers are async. Offer async functions in the API and run them from the CLI with asyncio.run, as described in running async code in Typer and Click. Avoid offering both sync and async versions of everything; pick what most callers need.

How do I document the API?

Docstrings on everything in __all__, plus a short "Using mytool from Python" page with the example above. Tools such as mkdocstrings can render the docstrings into reference pages.

How do I stop internal modules being imported as if they were API?

You cannot prevent imports in Python, but you can make intent unmistakable: prefix internal modules with an underscore (mytool/_engine.py), keep mytool/__init__.py free of re-exports except what you support, and say in the docs that only mytool.api is covered by the versioning policy. An import-boundary check, as in enforcing import boundaries in a CLI codebase, can also stop your own CLI layer from reaching around the API into internals.