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