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