Dynamic Command Resolution
The Typer CLI tool (the typer command) provides a unique capability: it can execute arbitrary Python scripts or modules as if they were pre-defined CLI applications. This functionality is powered by Dynamic Command Resolution, a mechanism implemented via the TyperCLIGroup class in typer/cli.py.
By extending the standard Click command resolution lifecycle, Typer is able to inspect command-line arguments, discover Typer applications within external files, and inject them into the active command tree at runtime.
The Invocation Lifecycle Hooks
Standard Click groups resolve commands by looking at a static dictionary of registered sub-commands. However, the Typer CLI needs to decide whether a run command exists based on whether the user provided a file path or module name as an argument.
To achieve this, TyperCLIGroup overrides three critical methods of the click.Group lifecycle:
class TyperCLIGroup(typer.core.TyperGroup):
def list_commands(self, ctx: click.Context) -> list[str]:
self.maybe_add_run(ctx)
return super().list_commands(ctx)
def get_command(self, ctx: click.Context, name: str) -> Command | None:
self.maybe_add_run(ctx)
return super().get_command(ctx, name)
def invoke(self, ctx: click.Context) -> Any:
self.maybe_add_run(ctx)
return super().invoke(ctx)
By intercepting these methods, the group ensures that the run command is dynamically injected before Click attempts to:
- List commands (e.g., for
--helpoutput). - Resolve a command (e.g., when the user types
typer script.py run). - Invoke the group (the final execution phase).
State-Driven Injection
The decision to inject a command depends on the arguments passed to the CLI. Because Click's command resolution happens before the main callback is executed, TyperCLIGroup must manually extract these arguments from the click.Context.
This is handled by a global state object and the maybe_update_state helper:
def maybe_update_state(ctx: click.Context) -> None:
path_or_module = ctx.params.get("path_or_module")
if path_or_module:
# Logic to determine if it's a file or module
# ...
state.file = file_path # or state.module
# ... updates state.app and state.func from ctx.params
The maybe_add_run method calls this state update and then triggers the injection logic in maybe_add_run_to_cli. If a valid file or module is detected in the state, the code dynamically imports it and wraps the discovered Typer app as a new click.Command named "run".
def maybe_add_run_to_cli(cli: click.Group) -> None:
if "run" not in cli.commands:
if state.file or state.module:
obj = get_typer_from_state()
if obj:
click_obj = typer.main.get_command(obj)
click_obj.name = "run"
cli.add_command(click_obj)
Dynamic Discovery Logic
The actual discovery of the Typer app within a module is performed by get_typer_from_module. This function implements a heuristic search to find a suitable entry point:
- Explicit Name: If the user provided
--appor--func, it looks for that specific attribute. - Default Names: It searches for common variable names like
app,cli, ormain. - Type Inspection: If no defaults are found, it iterates through all attributes in the module to find the first instance of
typer.Typer. - Function Wrapping: If no
Typerobject is found, it looks for functions (starting with defaults likemain) and automatically wraps them in a Typer app.
This tiered approach allows the CLI to be "zero-config" for most scripts while still providing explicit control when multiple apps exist in a single file.
Design Tradeoffs and Constraints
The implementation of TyperCLIGroup reflects several specific design choices:
Global State Coupling
The use of a global state object in typer/cli.py is a departure from standard functional patterns. This choice was made to bridge the gap between Click's argument parsing and its command resolution. Since get_command is called before the CLI callback, the group needs a way to "peek" at the parameters and store them in a location accessible to the injection logic.
Lifecycle Redundancy
The maybe_add_run method is called in three different lifecycle hooks. This redundancy is necessary because Click does not guarantee which hook will be called first in all scenarios (e.g., shell completion vs. help vs. execution). By calling it in all three, Typer ensures the run command is consistently available.
Import Side Effects
Because get_typer_from_state uses importlib to load the target script, any code at the top level of the user's script will be executed during the command resolution phase. This is a standard behavior for Python CLI tools that load external modules, but it means that the "discovery" phase is not side-effect-free.