Coverage for typer/cli.py: 100%

212 statements  

« 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

6 

7import click 1abcdefghi

8import typer 1abcdefghi

9import typer.core 1abcdefghi

10from click import Command, Group, Option 1abcdefghi

11 

12from . import __version__ 1abcdefghi

13from .core import HAS_RICH 1abcdefghi

14 

15default_app_names = ("app", "cli", "main") 1abcdefghi

16default_func_names = ("main", "cli", "app") 1abcdefghi

17 

18app = typer.Typer() 1abcdefghi

19utils_app = typer.Typer(help="Extra utility commands for Typer apps.") 1abcdefghi

20app.add_typer(utils_app, name="utils") 1abcdefghi

21 

22 

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

29 

30 

31state = State() 1abcdefghi

32 

33 

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

53 

54 

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

59 

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

63 

64 def invoke(self, ctx: click.Context) -> Any: 1abcdefghi

65 self.maybe_add_run(ctx) 1abcdefghi

66 return super().invoke(ctx) 1abcdefghi

67 

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

71 

72 

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

119 

120 

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

138 

139 

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

151 

152 

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

158 

159 

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. 

176 

177 You probably want to install completion for the typer command: 

178 

179 $ typer --install-completion 

180 

181 https://typer.tiangolo.com/ 

182 """ 

183 maybe_update_state(ctx) 1abcdefghi

184 

185 

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

264 

265 

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

270 

271 return rich_utils.rich_to_html(input_text) 1abcdefghi

272 

273 

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

305 

306 

307def main() -> Any: 1abcdefghi

308 return app() 1abcdefghi