Coverage for typer/cli.py: 100%

214 statements  

« 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

6 

7import click 1iabcdefgh

8import typer 1iabcdefgh

9import typer.core 1iabcdefgh

10from click import Command, Group, Option 1iabcdefgh

11 

12from . import __version__ 1iabcdefgh

13 

14try: 1iabcdefgh

15 import rich 1iabcdefgh

16 

17 has_rich = True 1iabcdefgh

18 from . import rich_utils 1iabcdefgh

19 

20except ImportError: # pragma: no cover 

21 has_rich = False 

22 rich = None # type: ignore 

23 

24default_app_names = ("app", "cli", "main") 1iabcdefgh

25default_func_names = ("main", "cli", "app") 1iabcdefgh

26 

27app = typer.Typer() 1iabcdefgh

28utils_app = typer.Typer(help="Extra utility commands for Typer apps.") 1iabcdefgh

29app.add_typer(utils_app, name="utils") 1iabcdefgh

30 

31 

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

38 

39 

40state = State() 1iabcdefgh

41 

42 

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

62 

63 

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

68 

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

72 

73 def invoke(self, ctx: click.Context) -> Any: 1iabcdefgh

74 self.maybe_add_run(ctx) 1iabcdefgh

75 return super().invoke(ctx) 1iabcdefgh

76 

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

80 

81 

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

128 

129 

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

147 

148 

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

160 

161 

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

167 

168 

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. 

185 

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

187 

188 $ typer --install-completion 

189 

190 https://typer.tiangolo.com/ 

191 """ 

192 maybe_update_state(ctx) 1iabcdefgh

193 

194 

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

273 

274 

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

279 

280 

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

312 

313 

314def main() -> Any: 1iabcdefgh

315 return app() 1iabcdefgh