You want a command-line front end for a web API — your company's deployment service, an issue tracker, a metrics backend — that the team can use from a terminal and from scripts. The goal is not just "it makes requests" but a tool that stays pleasant as it grows: commands that read like the domain, errors that tell people what to do, output that is readable for humans and stable for jq, and tests that run offline in milliseconds. This guide builds that tool end to end with httpx and Typer, using a small projects API as the running example. It is the hands-on companion to calling HTTP APIs from Python CLIs.
Prerequisites
- Python 3.10+,
httpx0.27 or newer,typerandrich(uv add httpx typer rich). - An API to talk to. The examples assume
GET /projectsreturns a JSON list of{"name", "owner", "build_count"}objects andGET /projects/{name}returns one. - An API token in the
MYTOOL_TOKENenvironment variable. Storing it properly is covered in storing tokens with keyring.
The shape of the tool
Three modules, each with one job. api.py knows HTTP and the API's JSON shapes and returns typed objects. cli.py knows arguments and output formats. A small settings.py (folded into the CLI callback here) knows where the base URL and token come from. The command asks the API module for objects; the API module reuses a single httpx.Client for every request in the run.
The recipe: the API module
# src/mytool/api.py
from __future__ import annotations
from dataclasses import asdict, dataclass
from typing import Any
from urllib.parse import quote
import httpx
__version__ = "1.4.0"
EXIT_USAGE = 2
EXIT_AUTH = 4
EXIT_UNAVAILABLE = 69 # EX_UNAVAILABLE from sysexits.h
@dataclass(frozen=True)
class Project:
name: str
owner: str
builds: int
def to_json(self) -> dict[str, Any]:
return asdict(self)
class ApiError(Exception):
def __init__(self, message: str, exit_code: int = 1) -> None:
super().__init__(message)
self.exit_code = exit_code
def make_client(base_url: str, token: str | None, *,
transport: httpx.BaseTransport | None = None) -> httpx.Client:
headers = {"User-Agent": f"mytool/{__version__}", "Accept": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
return httpx.Client(
base_url=base_url,
headers=headers,
timeout=httpx.Timeout(30.0, connect=5.0),
transport=transport or httpx.HTTPTransport(retries=2),
)
class ProjectsApi:
def __init__(self, client: httpx.Client) -> None:
self._client = client
def _get(self, path: str, **params: Any) -> Any:
try:
response = self._client.get(path, params=params or None)
except httpx.TimeoutException:
raise ApiError(f"{self._client.base_url.host} did not respond in time",
EXIT_UNAVAILABLE) from None
except httpx.TransportError as exc:
raise ApiError(f"cannot reach {self._client.base_url.host}: {exc}",
EXIT_UNAVAILABLE) from None
return self._check(response)
@staticmethod
def _check(response: httpx.Response) -> Any:
if response.is_success:
return response.json()
code = response.status_code
detail = ""
if "json" in response.headers.get("content-type", ""):
detail = response.json().get("message", "")
if code in (401, 403):
raise ApiError("not authorised — set MYTOOL_TOKEN or run: mytool login", EXIT_AUTH)
if code == 404:
raise ApiError(detail or "not found")
if code == 422:
raise ApiError(detail or "the server rejected the request", EXIT_USAGE)
if code >= 500:
raise ApiError(f"the service returned {code}; try again shortly", EXIT_UNAVAILABLE)
raise ApiError(f"unexpected response {code}: {detail}".rstrip(": "))
def list_projects(self) -> list[Project]:
return [Project(p["name"], p["owner"], p["build_count"]) for p in self._get("/projects")]
def get_project(self, name: str) -> Project:
p = self._get(f"/projects/{quote(name, safe='')}") # "a/b" must not become a path
return Project(p["name"], p["owner"], p["build_count"])
Three decisions are doing most of the work.
One error type with an exit code. Every failure — network, timeout, HTTP status — becomes ApiError, carrying the message the user should see and the exit code the process should use. The command layer then needs exactly one except clause. The codes follow the conventions in choosing exit codes for CLI tools: 2 for bad input, 69 (EX_UNAVAILABLE) when the service cannot be reached, and a distinct code for authentication so scripts can detect "needs login".
Typed objects out. Commands receive Project instances, never response dictionaries. When the API renames build_count, one line in api.py changes and every command keeps working. It also means your --json output is your schema, not a pass-through of the server's, so you control its stability.
An injectable transport. make_client accepts an optional transport. Production code passes nothing and gets real HTTP with connection retries; tests pass httpx.MockTransport and get no network at all.
The recipe: the command layer
# src/mytool/cli.py
from __future__ import annotations
import json
import os
from collections.abc import Callable
import httpx
import typer
from rich.console import Console
from rich.table import Table
from mytool.api import ApiError, ProjectsApi, make_client
app = typer.Typer(no_args_is_help=True)
err = Console(stderr=True)
# Tests replace this factory to inject a mock transport.
client_factory: Callable[[str, str | None], httpx.Client] = make_client
@app.callback()
def main(
ctx: typer.Context,
api_url: str = typer.Option("https://api.example.com/v1", envvar="MYTOOL_API_URL"),
) -> None:
"""Command-line access to the projects API."""
client = client_factory(api_url, os.environ.get("MYTOOL_TOKEN"))
ctx.call_on_close(client.close)
ctx.obj = ProjectsApi(client)
def fail(exc: ApiError) -> None:
err.print(f"[red]error:[/red] {exc}")
raise typer.Exit(exc.exit_code)
@app.command()
def projects(ctx: typer.Context, as_json: bool = typer.Option(False, "--json")) -> None:
"""List projects."""
try:
items = ctx.obj.list_projects()
except ApiError as exc:
fail(exc)
if as_json:
typer.echo(json.dumps([p.to_json() for p in items], indent=2))
return
table = Table("NAME", "OWNER", "BUILDS", box=None, header_style="bold")
for p in sorted(items, key=lambda p: p.name):
table.add_row(p.name, p.owner, str(p.builds))
Console().print(table)
@app.command()
def show(ctx: typer.Context, name: str, as_json: bool = typer.Option(False, "--json")) -> None:
"""Show one project."""
try:
p = ctx.obj.get_project(name)
except ApiError as exc:
fail(exc)
if as_json:
typer.echo(json.dumps(p.to_json(), indent=2))
else:
typer.echo(f"{p.name} owner={p.owner} builds={p.builds}")
if __name__ == "__main__":
app()
ctx.call_on_close(client.close) closes the connection pool when the command finishes, whether it succeeded or raised — the Click context's cleanup hook, which is more reliable than remembering a finally in every command. The client and API object travel on ctx.obj, the shared-state mechanism described in sharing state with Click context objects.
UX considerations
- Human by default, machine on request. A table with sensible column order and no borders for people;
--jsonwith a stable, documented schema for scripts. Keep diagnostics on stderr so--jsonoutput can be piped straight intojq. The contract is spelled out in emitting JSON output for scripting. - Errors that end in an action. "not authorised — set MYTOOL_TOKEN or run: mytool login" is worth ten "401 Unauthorized"s. Prefer the server's own validation message for 422 responses; it usually names the offending field.
- Show which server you are talking to. A
--verboseline naming the base URL saves a lot of confusion between staging and production. - Do not hide slow calls. For requests that can take more than a second, a Rich
console.status("Fetching projects...")spinner on stderr tells the user the tool is working, as covered in adding progress bars and spinners to Python CLIs. - Sort client-side when order matters. APIs often return items in insertion or arbitrary order. Sorting by name makes output diffable between runs.
Testing the behaviour
Replace the client factory with one that builds a client on httpx.MockTransport. The handler is an ordinary function that inspects the request and returns a response, so each test states exactly what the server does:
# tests/test_cli.py
import json
import httpx
import pytest
from typer.testing import CliRunner
from mytool import cli
from mytool.api import make_client
runner = CliRunner()
@pytest.fixture
def server(monkeypatch):
routes: dict[str, httpx.Response] = {}
def handler(request: httpx.Request) -> httpx.Response:
assert request.headers["User-Agent"].startswith("mytool/")
return routes.get(request.url.path, httpx.Response(404, json={"message": "no such thing"}))
monkeypatch.setattr(cli, "client_factory",
lambda url, token: make_client(url, token, transport=httpx.MockTransport(handler)))
return routes
def test_projects_table(server):
server["/v1/projects"] = httpx.Response(200, json=[
{"name": "web", "owner": "ana", "build_count": 214},
{"name": "billing", "owner": "ops", "build_count": 87},
])
result = runner.invoke(cli.app, ["projects"])
assert result.exit_code == 0
assert result.output.index("billing") < result.output.index("web")
def test_projects_json_is_our_schema(server):
server["/v1/projects"] = httpx.Response(200, json=[{"name": "web", "owner": "ana", "build_count": 1}])
result = runner.invoke(cli.app, ["projects", "--json"])
assert json.loads(result.output) == [{"name": "web", "owner": "ana", "builds": 1}]
def test_auth_failure_exit_code(server):
server["/v1/projects"] = httpx.Response(401)
result = runner.invoke(cli.app, ["projects"])
assert result.exit_code == 4
assert "mytool login" in result.output
def test_not_found_uses_server_message(server):
result = runner.invoke(cli.app, ["show", "nope"])
assert result.exit_code == 1
assert "no such thing" in result.output
def test_network_failure(monkeypatch):
def boom(request):
raise httpx.ConnectError("connection refused", request=request)
monkeypatch.setattr(cli, "client_factory",
lambda url, token: make_client(url, token, transport=httpx.MockTransport(boom)))
result = runner.invoke(cli.app, ["projects"])
assert result.exit_code == 69
Note the JSON test asserts the renamed key builds, proving that the output schema is decoupled from the server's. For more on keeping CLI tests fast and offline, see mocking filesystem and network in CLI tests.
Conclusion
An API client CLI is three small, separate things: a configured httpx.Client, an API module that converts HTTP into typed objects and one error type, and a command layer that renders those objects for people or for scripts. Keep the boundaries firm, inject the transport so tests never touch the network, and give every failure a message that ends in something the user can do. From here, add retries and backoff for flaky services and pagination for lists that outgrow one response.
Frequently asked questions
Should I generate the client from an OpenAPI spec?
Generators such as openapi-python-client save typing for large APIs and keep models in sync with the spec. They also produce a lot of code and a dependency on the generator's style. For a CLI that uses a dozen endpoints, a hand-written module is usually smaller and easier to read; for one that wraps hundreds, generate the models and keep a hand-written layer on top.
Pydantic or dataclasses for the response models?
Dataclasses are enough when you control the parsing and want zero import cost. Pydantic earns its place when responses are deeply nested or you want validation errors that name the bad field. If startup time matters, measure: Pydantic adds noticeable import time.
Where should the base URL and token come from?
A flag, then an environment variable, then a config file, then a default — the standard precedence. Tokens should prefer the keychain over config files. Both are covered in supporting multiple profiles and accounts.
How do I avoid creating the client for commands that do not need it?
Create it lazily: store a factory on the context and build the client the first time a command asks for it. That keeps --help and purely local commands free of network setup.