Coverage for typer / cli.py: 100%
224 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-03-26 21:46 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-03-26 21:46 +0000
1import importlib.util 1abcdefg
2import re 1abcdefg
3import sys 1abcdefg
4from pathlib import Path 1abcdefg
5from typing import Any 1abcdefg
7import click 1abcdefg
8import typer 1abcdefg
9import typer.core 1abcdefg
10from click import Command, Group, Option 1abcdefg
12from . import __version__ 1abcdefg
13from .core import HAS_RICH, MARKUP_MODE_KEY 1abcdefg
15default_app_names = ("app", "cli", "main") 1abcdefg
16default_func_names = ("main", "cli", "app") 1abcdefg
18app = typer.Typer() 1abcdefg
19utils_app = typer.Typer(help="Extra utility commands for Typer apps.") 1abcdefg
20app.add_typer(utils_app, name="utils") 1abcdefg
23class State: 1abcdefg
24 def __init__(self) -> None: 1abcdefg
25 self.app: str | None = None 1abcdefg
26 self.func: str | None = None 1abcdefg
27 self.file: Path | None = None 1abcdefg
28 self.module: str | None = None 1abcdefg
31state = State() 1abcdefg
34def maybe_update_state(ctx: click.Context) -> None: 1abcdefg
35 path_or_module = ctx.params.get("path_or_module") 1abcdefg
36 if path_or_module: 1abcdefg
37 file_path = Path(path_or_module) 1abcdefg
38 if file_path.exists() and file_path.is_file(): 1abcdefg
39 state.file = file_path 1abcdefg
40 else:
41 if not re.fullmatch(r"[a-zA-Z_]\w*(\.[a-zA-Z_]\w*)*", path_or_module): 1abcdefg
42 typer.echo( 1abcdefg
43 f"Not a valid file or Python module: {path_or_module}", err=True
44 )
45 sys.exit(1) 1abcdefg
46 state.module = path_or_module 1abcdefg
47 app_name = ctx.params.get("app") 1abcdefg
48 if app_name: 1abcdefg
49 state.app = app_name 1abcdefg
50 func_name = ctx.params.get("func") 1abcdefg
51 if func_name: 1abcdefg
52 state.func = func_name 1abcdefg
55class TyperCLIGroup(typer.core.TyperGroup): 1abcdefg
56 def list_commands(self, ctx: click.Context) -> list[str]: 1abcdefg
57 self.maybe_add_run(ctx) 1abcdefg
58 return super().list_commands(ctx) 1abcdefg
60 def get_command(self, ctx: click.Context, name: str) -> Command | None: # ty: ignore[invalid-method-override] 1abcdefg
61 self.maybe_add_run(ctx) 1abcdefg
62 return super().get_command(ctx, name) 1abcdefg
64 def invoke(self, ctx: click.Context) -> Any: 1abcdefg
65 self.maybe_add_run(ctx) 1abcdefg
66 return super().invoke(ctx) 1abcdefg
68 def maybe_add_run(self, ctx: click.Context) -> None: 1abcdefg
69 maybe_update_state(ctx) 1abcdefg
70 maybe_add_run_to_cli(self) 1abcdefg
73def get_typer_from_module(module: Any) -> typer.Typer | None: 1abcdefg
74 # Try to get defined app
75 if state.app: 1abcdefg
76 obj = getattr(module, state.app, None) 1abcdefg
77 if not isinstance(obj, typer.Typer): 1abcdefg
78 typer.echo(f"Not a Typer object: --app {state.app}", err=True) 1abcdefg
79 sys.exit(1) 1abcdefg
80 return obj 1abcdefg
81 # Try to get defined function
82 if state.func: 1abcdefg
83 func_obj = getattr(module, state.func, None) 1abcdefg
84 if not callable(func_obj): 1abcdefg
85 typer.echo(f"Not a function: --func {state.func}", err=True) 1abcdefg
86 raise typer.Exit(1) 1abcdefg
87 sub_app = typer.Typer() 1abcdefg
88 sub_app.command()(func_obj) 1abcdefg
89 return sub_app 1abcdefg
90 # Iterate and get a default object to use as CLI
91 local_names = dir(module) 1abcdefg
92 local_names_set = set(local_names) 1abcdefg
93 # Try to get a default Typer app
94 for name in default_app_names: 1abcdefg
95 if name in local_names_set: 1abcdefg
96 obj = getattr(module, name, None) 1abcdefg
97 if isinstance(obj, typer.Typer): 1abcdefg
98 return obj 1abcdefg
99 # Try to get any Typer app
100 for name in local_names_set - set(default_app_names): 1abcdefg
101 obj = getattr(module, name) 1abcdefg
102 if isinstance(obj, typer.Typer): 1abcdefg
103 return obj 1abcdefg
104 # Try to get a default function
105 for func_name in default_func_names: 1abcdefg
106 func_obj = getattr(module, func_name, None) 1abcdefg
107 if callable(func_obj): 1abcdefg
108 sub_app = typer.Typer() 1abcdefg
109 sub_app.command()(func_obj) 1abcdefg
110 return sub_app 1abcdefg
111 # Try to get any func app
112 for func_name in local_names_set - set(default_func_names): 1abcdefg
113 func_obj = getattr(module, func_name) 1abcdefg
114 if callable(func_obj): 1abcdefg
115 sub_app = typer.Typer() 1abcdefg
116 sub_app.command()(func_obj) 1abcdefg
117 return sub_app 1abcdefg
118 return None 1abcdefg
121def get_typer_from_state() -> typer.Typer | None: 1abcdefg
122 spec = None 1abcdefg
123 if state.file: 1abcdefg
124 module_name = state.file.name 1abcdefg
125 spec = importlib.util.spec_from_file_location(module_name, str(state.file)) 1abcdefg
126 elif state.module: 1abcdefg
127 spec = importlib.util.find_spec(state.module) 1abcdefg
128 if spec is None: 1abcdefg
129 if state.file: 1abcdefg
130 typer.echo(f"Could not import as Python file: {state.file}", err=True) 1abcdefg
131 else:
132 typer.echo(f"Could not import as Python module: {state.module}", err=True) 1abcdefg
133 sys.exit(1) 1abcdefg
134 assert spec is not None 1abcdefg
135 module = importlib.util.module_from_spec(spec) 1abcdefg
136 spec.loader.exec_module(module) # type: ignore 1abcdefg
137 obj = get_typer_from_module(module) 1abcdefg
138 return obj 1abcdefg
141def maybe_add_run_to_cli(cli: click.Group) -> None: 1abcdefg
142 if "run" not in cli.commands: 1abcdefg
143 if state.file or state.module: 1abcdefg
144 obj = get_typer_from_state() 1abcdefg
145 if obj: 1abcdefg
146 obj._add_completion = False 1abcdefg
147 click_obj = typer.main.get_command(obj) 1abcdefg
148 click_obj.name = "run" 1abcdefg
149 if not click_obj.help: 1abcdefg
150 click_obj.help = "Run the provided Typer app." 1abcdefg
151 cli.add_command(click_obj) 1abcdefg
154def print_version(ctx: click.Context, param: Option, value: bool) -> None: 1abcdefg
155 if not value or ctx.resilient_parsing: 1abcdefg
156 return 1abcdefg
157 typer.echo(f"Typer version: {__version__}") 1abcdefg
158 raise typer.Exit() 1abcdefg
161@app.callback(cls=TyperCLIGroup, no_args_is_help=True) 1abcdefg
162def callback( 1abcdefg
163 ctx: typer.Context,
164 *,
165 path_or_module: str = typer.Argument(None),
166 app: str = typer.Option(None, help="The typer app object/variable to use."),
167 func: str = typer.Option(None, help="The function to convert to Typer."),
168 version: bool = typer.Option(
169 False,
170 "--version",
171 help="Print version and exit.",
172 callback=print_version,
173 ),
174) -> None:
175 """
176 Run Typer scripts with completion, without having to create a package.
178 You probably want to install completion for the typer command:
180 $ typer --install-completion
182 https://typer.tiangolo.com/
183 """
184 maybe_update_state(ctx) 1abcdefg
187def get_docs_for_click( 1abcdefg
188 *,
189 obj: Command,
190 ctx: typer.Context,
191 indent: int = 0,
192 name: str = "",
193 call_prefix: str = "",
194 title: str | None = None,
195) -> str:
196 docs = "#" * (1 + indent) 1abcdefg
197 command_name = name or obj.name 1abcdefg
198 if call_prefix: 1abcdefg
199 command_name = f"{call_prefix} {command_name}" 1abcdefg
200 if not title: 1abcdefg
201 title = f"`{command_name}`" if command_name else "CLI" 1abcdefg
202 docs += f" {title}\n\n" 1abcdefg
203 rich_markup_mode = None 1abcdefg
204 if hasattr(ctx, "obj") and isinstance(ctx.obj, dict): 1abcdefg
205 rich_markup_mode = ctx.obj.get(MARKUP_MODE_KEY, None) 1abcdefg
206 to_parse: bool = bool(HAS_RICH and (rich_markup_mode == "rich")) 1abcdefg
207 if obj.help: 1abcdefg
208 docs += f"{_parse_html(to_parse, obj.help)}\n\n" 1abcdefg
209 usage_pieces = obj.collect_usage_pieces(ctx) 1abcdefg
210 if usage_pieces: 1abcdefg
211 docs += "**Usage**:\n\n" 1abcdefg
212 docs += "```console\n" 1abcdefg
213 docs += "$ " 1abcdefg
214 if command_name: 1abcdefg
215 docs += f"{command_name} " 1abcdefg
216 docs += f"{' '.join(usage_pieces)}\n" 1abcdefg
217 docs += "```\n\n" 1abcdefg
218 args = [] 1abcdefg
219 opts = [] 1abcdefg
220 for param in obj.get_params(ctx): 1abcdefg
221 rv = param.get_help_record(ctx) 1abcdefg
222 if rv is not None: 1abcdefg
223 if param.param_type_name == "argument": 1abcdefg
224 args.append(rv) 1abcdefg
225 elif param.param_type_name == "option": 1abcdefg
226 opts.append(rv) 1abcdefg
227 if args: 1abcdefg
228 docs += "**Arguments**:\n\n" 1abcdefg
229 for arg_name, arg_help in args: 1abcdefg
230 docs += f"* `{arg_name}`" 1abcdefg
231 if arg_help: 1abcdefg
232 docs += f": {_parse_html(to_parse, arg_help)}" 1abcdefg
233 docs += "\n" 1abcdefg
234 docs += "\n" 1abcdefg
235 if opts: 1abcdefg
236 docs += "**Options**:\n\n" 1abcdefg
237 for opt_name, opt_help in opts: 1abcdefg
238 docs += f"* `{opt_name}`" 1abcdefg
239 if opt_help: 1abcdefg
240 docs += f": {_parse_html(to_parse, opt_help)}" 1abcdefg
241 docs += "\n" 1abcdefg
242 docs += "\n" 1abcdefg
243 if obj.epilog: 1abcdefg
244 docs += f"{obj.epilog}\n\n" 1abcdefg
245 if isinstance(obj, Group): 1abcdefg
246 group = obj 1abcdefg
247 commands = group.list_commands(ctx) 1abcdefg
248 if commands: 1abcdefg
249 docs += "**Commands**:\n\n" 1abcdefg
250 for command in commands: 1abcdefg
251 command_obj = group.get_command(ctx, command) 1abcdefg
252 assert command_obj 1abcdefg
253 docs += f"* `{command_obj.name}`" 1abcdefg
254 command_help = command_obj.get_short_help_str() 1abcdefg
255 if command_help: 1abcdefg
256 docs += f": {_parse_html(to_parse, command_help)}" 1abcdefg
257 docs += "\n" 1abcdefg
258 docs += "\n" 1abcdefg
259 for command in commands: 1abcdefg
260 command_obj = group.get_command(ctx, command) 1abcdefg
261 assert command_obj 1abcdefg
262 use_prefix = "" 1abcdefg
263 if command_name: 1abcdefg
264 use_prefix += f"{command_name}" 1abcdefg
265 docs += get_docs_for_click( 1abcdefg
266 obj=command_obj, ctx=ctx, indent=indent + 1, call_prefix=use_prefix
267 )
268 return docs 1abcdefg
271def _parse_html(to_parse: bool, input_text: str) -> str: 1abcdefg
272 if not to_parse: 1abcdefg
273 return input_text 1abcdefg
274 from . import rich_utils 1abcdefg
276 return rich_utils.rich_to_html(input_text) 1abcdefg
279@utils_app.command() 1abcdefg
280def docs( 1abcdefg
281 ctx: typer.Context,
282 name: str = typer.Option("", help="The name of the CLI program to use in docs."),
283 output: Path | None = typer.Option(
284 None,
285 help="An output file to write docs to, like README.md.",
286 file_okay=True,
287 dir_okay=False,
288 ),
289 title: str | None = typer.Option(
290 None,
291 help="The title for the documentation page. If not provided, the name of "
292 "the program is used.",
293 ),
294) -> None:
295 """
296 Generate Markdown docs for a Typer app.
297 """
298 typer_obj = get_typer_from_state() 1abcdefg
299 if not typer_obj: 1abcdefg
300 typer.echo("No Typer app found", err=True) 1abcdefg
301 raise typer.Abort() 1abcdefg
302 if hasattr(typer_obj, "rich_markup_mode"): 1abcdefg
303 if not hasattr(ctx, "obj") or ctx.obj is None: 1abcdefg
304 ctx.ensure_object(dict) 1abcdefg
305 if isinstance(ctx.obj, dict): 1abcdefg
306 ctx.obj[MARKUP_MODE_KEY] = typer_obj.rich_markup_mode 1abcdefg
307 click_obj = typer.main.get_command(typer_obj) 1abcdefg
308 docs = get_docs_for_click(obj=click_obj, ctx=ctx, name=name, title=title) 1abcdefg
309 clean_docs = f"{docs.strip()}\n" 1abcdefg
310 if output: 1abcdefg
311 output.write_text(clean_docs) 1abcdefg
312 typer.echo(f"Docs saved to: {output}") 1abcdefg
313 else:
314 typer.echo(clean_docs) 1abcdefg
317def main() -> Any: 1abcdefg
318 return app() 1abcdefg