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