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