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