Architecture

Quick CLIs from Functions with Python Fire

Turn functions and classes into a CLI with Python Fire in one line, understand its literal parsing and loose types, add a type guard, and test it.

Updated

Every team has a scripts/ folder full of Python functions that someone runs by editing the bottom of the file, or with python -c "from tools import x; x('arg')". Python Fire, from Google, removes that friction: fire.Fire(obj) turns any function, class, dictionary or module into a command-line interface by introspection, with no decorators or argument declarations. That makes it the fastest route from "a function I wrote" to "a command my colleagues can run". It also makes Fire loose in ways that matter once a tool has users who are not its author. This guide shows both sides — the one-line wins and the guard rails worth adding — as part of the alternative Python CLI frameworks topic.

Prerequisites

  • Python 3.10+ and fire (uv add fire); examples were checked against Fire 0.7.
  • A module of functions you already call from Python.
  • An understanding of where Fire fits; the topic overview compares it with Cyclopts, docopt-ng and Cleo.

What Fire exposes

Fire walks whatever you give it. A function becomes a command whose parameters are positional arguments or --flags. A class becomes a group: constructor parameters become flags available before the method name, and each public method becomes a subcommand. A dictionary maps names to components, which is the cleanest way to choose exactly what is exposed.

What Fire turns into commands A dictionary of components passed to Fire, where a class becomes a group with constructor flags and methods become subcommands. What Fire turns into commands fire.Fire({...}) explicit public surface images class → group --quality __init__ flag resize method → command formats method → command version function → command names starting with an underscore stay hidden A dictionary keeps imported helpers from appearing as commands.
# src/imgtool/cli.py
from __future__ import annotations

import fire


class Images:
    """Resize and inspect images."""

    def __init__(self, quality: int = 85):
        self.quality = quality

    def resize(self, path: str, width: int, height: int | None = None) -> dict:
        """Resize one image to WIDTH pixels (keeping aspect ratio unless HEIGHT is set)."""
        return {"path": path, "width": width, "height": height, "quality": self.quality}

    def formats(self) -> list[str]:
        """List the output formats this tool can write."""
        return ["png", "jpeg", "webp"]

    def _load(self, path):          # leading underscore: not exposed
        ...


def version() -> str:
    """Print the tool version."""
    return "imgtool 0.4.0"


def main() -> None:
    fire.Fire({"images": Images, "version": version})


if __name__ == "__main__":
    main()

Running it shows how return values are printed: dictionaries as aligned key: value lines, lists as one item per line, strings as-is.

Fire prints return values Terminal session running a Fire-based tool: a dictionary result printed as aligned lines and a list printed one item per line. Fire prints return values bash $ imgtool images --quality=70 resize cat.png 640 path: cat.png width: 640 height: null quality: 70 $ imgtool images formats png jpeg Constructor flags come before the method name; method arguments come after it.

Because main() is a plain function, it works as a console-script entry point in pyproject.toml (imgtool = "imgtool.cli:main"), exactly as described in best practices for Python CLI entry points.

How Fire parses values — and why it matters

Fire does not use your type hints. It parses every argument as a Python literal if it can, and leaves it as a string if it cannot:

What your function actually receives How Python Fire parses command line values as Python literals regardless of the type hint on the parameter. What your function actually receives Typed on the shell Hint says Function receives --width=640 int 640 (int) --width=six-forty int "six-forty" (str) --name=123 str 123 (int) --name=None str None --name='"007"' str "007" (str) Fire ignores type hints; a small guard decorator restores them for simple types.

So --width=640 arrives as the integer 640, but --width=six-forty arrives as the string "six-forty" and the function receives it without complaint. --name=123 arrives as an integer even though the hint says str, --name=None arrives as None, and --tags=[a,b] is a string while --tags='["a","b"]' is a list. For the author, who thinks in Python, this is convenient. For anyone else it is a source of bugs that appear far from the command line.

The fix is a small decorator that converts arguments using the annotations Fire ignores, and reports failures as usage errors:

# src/imgtool/typed.py
from __future__ import annotations

import functools
import inspect
import sys
import types
import typing
from pathlib import Path

SIMPLE = {int, float, str, Path}


def enforce_types(func):
    """Convert Fire's literal-parsed arguments to the annotated simple types."""
    sig = inspect.signature(func)
    hints = typing.get_type_hints(func)

    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        bound = sig.bind(*args, **kwargs)
        for name, value in bound.arguments.items():
            target = hints.get(name)
            is_union = typing.get_origin(target) in (typing.Union, types.UnionType)
            options = typing.get_args(target) if is_union else (target,)
            simple = [t for t in options if t in SIMPLE]
            if value is None or not simple or isinstance(value, tuple(simple)):
                continue
            try:
                bound.arguments[name] = simple[0](str(value))
            except ValueError:
                print(f"error: --{name.replace('_', '-')} expects {simple[0].__name__}, "
                      f"got {value!r}", file=sys.stderr)
                raise SystemExit(2) from None
        return func(*bound.args, **bound.kwargs)

    return wrapper

Apply it to the methods users call — @enforce_types above def resize(...) — and --width=six-forty becomes a clear error with exit code 2, while --name=123 becomes the string "123" the hint promised. It handles only simple types on purpose; if you need lists, enums and validators, you have outgrown Fire, and Cyclopts or Typer is the better tool.

Fire for a team's scripts folder

The best use of Fire is the shared scripts/ directory where operational one-offs accumulate. A single entry module that maps names to the useful functions turns the folder into a coherent tool without rewriting anything:

# scripts/ops.py
import fire

from scripts import backfill, reports, users


def main() -> None:
    fire.Fire({
        "backfill": backfill.run,            # ops backfill --since=2026-09-01
        "report": {
            "weekly": reports.weekly,         # ops report weekly
            "churn": reports.churn,
        },
        "user": {
            "disable": users.disable,         # ops user disable alice@example.com
        },
    })


if __name__ == "__main__":
    main()

Nested dictionaries become nested command groups, so the structure is visible at a glance and nothing is exposed by accident. Run it with uv run scripts/ops.py ..., or give it inline dependencies as described in running one-off CLI scripts with uv run so colleagues need no setup at all.

Two habits keep such a tool safe. Functions that change data should default to a dry run (def disable(email, apply=False)) so that --apply is an explicit choice, and functions that touch production should print what they are about to do before doing it. Fire will not add those guard rails for you; the dry-run and confirmation patterns carry over directly.

Fire's -- --interactive flag deserves a mention here too. It opens a Python REPL with the component already constructed — ops user -- --interactive drops you into a shell with the users functions at hand — which makes Fire a decent debugging console for the same scripts.

UX considerations

  • Expose a dictionary, not a module. fire.Fire() with no argument exposes everything in the calling module, including imported helpers. A dictionary makes the public surface explicit.
  • Fire's own flags hide behind --. imgtool images -- --help shows help for the component, -- --interactive drops into a REPL with the object loaded, and -- --trace explains how Fire resolved the command. Tell users; they will not guess.
  • Return data, do not print it. Fire prints return values in a readable form, and returning keeps the function useful from Python and easy to test. For machine output, return a JSON string explicitly — Fire's dictionary formatting is not JSON.
  • Errors exit with 2. Fire exits with code 2 for a missing argument or unknown command, matching Click and argparse. Exceptions from your own code produce a traceback; catch the expected ones and turn them into short messages, as in friendly error messages and tracebacks.
  • Keep it internal. Fire is excellent for a team's own tooling. For a published tool, its Python-flavoured help and loose parsing are a support burden.

Testing the behaviour

fire.Fire accepts a command list and returns the result, which makes tests straightforward. Test the functions directly first; they are plain Python:

# tests/test_cli.py
import fire
import pytest

from imgtool.cli import Images, version
from imgtool.typed import enforce_types

COMPONENTS = {"images": Images, "version": version}


def test_functions_work_without_fire():
    assert Images(quality=70).resize("a.png", 640)["quality"] == 70


def test_fire_routes_constructor_flags_and_method_args(capsys):
    result = fire.Fire(COMPONENTS, command=["images", "--quality=70", "resize", "a.png", "640"])
    assert result == {"path": "a.png", "width": 640, "height": None, "quality": 70}


def test_fire_does_not_enforce_hints(capsys):
    result = fire.Fire(COMPONENTS, command=["images", "resize", "a.png", "wide"])
    assert result["width"] == "wide"          # the problem enforce_types solves


@enforce_types
def scale(name: str, width: int, ratio: float | None = None):
    return name, width, ratio


def test_enforce_types_converts_and_rejects(capsys):
    assert fire.Fire(scale, command=["--name=123", "--width=640"]) == ("123", 640, None)
    assert fire.Fire(scale, command=["x", "10", "--ratio=2"]) == ("x", 10, 2.0)
    with pytest.raises(SystemExit) as exc:
        fire.Fire(scale, command=["x", "wide"])
    assert exc.value.code == 2
    assert "expects int" in capsys.readouterr().err


def test_missing_argument_is_usage_error():
    with pytest.raises(fire.core.FireExit) as exc:
        fire.Fire(COMPONENTS, command=["images", "resize"])
    assert exc.value.code == 2

fire.Fire also prints the result while returning it; the capsys fixture keeps that out of the test output. One test deliberately documents Fire's loose typing, so anyone who removes the guard later sees why it was there.

Conclusion

Fire is the shortest path from a Python function to a shell command, and for internal scripts that is exactly what you want. Expose an explicit dictionary of components, return values rather than printing them, wrap user-facing functions with a small type guard, and test through fire.Fire(..., command=[...]). When the tool grows real users, the functions are already framework-free — moving them under Cyclopts or Typer is a matter of adding declarations.

Frequently asked questions

How do I pass a string that looks like a number?

Quote it twice, so the shell passes the inner quotes to Fire: --name='"007"'. With the enforce_types guard and a str hint, the double quoting is unnecessary for plain numbers, which is one of the reasons to add it.

Can Fire chain method calls?

Yes. If a method returns an object, the next token is looked up on that object, so tool load data.csv filter --col=a summary calls three methods in sequence. It is clever and occasionally useful; it is also confusing for users, so do not design a public interface around it.

Does Fire support async functions?

Fire calls the function and prints whatever it returns, so an async def returns a coroutine object. Wrap it: expose a synchronous function that calls asyncio.run(...), as in running async code in Typer and Click.

Is there shell completion?

Fire can print a bash completion script with mytool -- --completion. It completes command and flag names, not values. For anything richer, a framework with dynamic completion is a better fit — see shell completion for Python CLIs.