Skip to main content

CLI State Management

The typer command-line tool allows users to run Typer scripts and generate documentation without requiring the scripts to be part of a formal Python package. To achieve this, the CLI must maintain a context of which file or module it is currently targeting. This context is managed by the State class in typer/cli.py.

The State Container

The State class is a simple data container that tracks the target of the CLI execution. It stores information about the Python file or module being inspected, as well as specific identifiers for the Typer application or function within that source.

class State:
def __init__(self) -> None:
self.app: str | None = None
self.func: str | None = None
self.file: Path | None = None
self.module: str | None = None

In typer/cli.py, a global singleton instance is initialized:

state = State()

This global state object acts as the "memory" for the CLI lifecycle, allowing different parts of the command execution—such as command discovery and documentation generation—to access the same context.

Context Extraction

The state is populated during the CLI's initialization phase via the maybe_update_state function. This function extracts parameters from the click.Context and determines whether the user provided a file path or a Python module name.

def maybe_update_state(ctx: click.Context) -> None:
path_or_module = ctx.params.get("path_or_module")
if path_or_module:
file_path = Path(path_or_module)
if file_path.exists() and file_path.is_file():
state.file = file_path
else:
# Validate as a Python module name if it's not a file
if not re.fullmatch(r"[a-zA-Z_]\w*(\.[a-zA-Z_]\w*)*", path_or_module):
typer.echo(
f"Not a valid file or Python module: {path_or_module}", err=True
)
sys.exit(1)
state.module = path_or_module
app_name = ctx.params.get("app")
if app_name:
state.app = app_name
func_name = ctx.params.get("func")
if func_name:
state.func = func_name

This function is called by the main callback of the Typer CLI and by the TyperCLIGroup during command resolution. This ensures that the state is updated before any subcommands (like run or utils docs) are processed.

Dynamic Application Loading

Once the state is populated, the CLI uses get_typer_from_state() to dynamically import the target code and locate a typer.Typer instance.

Module Import

The function uses importlib to load the code based on whether state.file or state.module was set:

def get_typer_from_state() -> typer.Typer | None:
spec = None
if state.file:
module_name = state.file.name
spec = importlib.util.spec_from_file_location(module_name, str(state.file))
elif state.module:
spec = importlib.util.find_spec(state.module)
# ... (error handling) ...
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
obj = get_typer_from_module(module)
return obj

Application Discovery Logic

The get_typer_from_module function implements the discovery hierarchy used to find the Typer app within the loaded module:

  1. Explicit App: If state.app is set (via --app), it looks for that specific attribute.
  2. Explicit Function: If state.func is set (via --func), it wraps that function in a new Typer app.
  3. Default Names: It searches for attributes named app, cli, or main.
  4. Type Scanning: It iterates through all attributes in the module to find any instance of typer.Typer.
  5. Function Scanning: It looks for default function names (main, cli, app) or any callable to wrap as a command.

Integration with CLI Lifecycle

The State management is tightly integrated into the TyperCLIGroup, a custom Click group class. This group overrides list_commands, get_command, and invoke to ensure that the state is updated and the dynamic run command is added to the CLI if a valid file or module is detected.

class TyperCLIGroup(typer.core.TyperGroup):
def maybe_add_run(self, ctx: click.Context) -> None:
maybe_update_state(ctx)
maybe_add_run_to_cli(self)

def get_command(self, ctx: click.Context, name: str) -> Command | None:
self.maybe_add_run(ctx)
return super().get_command(ctx, name)

This architecture allows the typer CLI to be highly flexible, adapting its available commands based on the Python script it is currently pointing to. For example, the docs command in utils_app relies on get_typer_from_state() to know which application it should generate documentation for.