Coverage for typer / cli.py: 100%

223 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-02-09 12:36 +0000

1import importlib.util 1abcdefgh

2import re 1abcdefgh

3import sys 1abcdefgh

4from pathlib import Path 1abcdefgh

5from typing import Any, Optional 1abcdefgh

6 

7import click 1abcdefgh

8import typer 1abcdefgh

9import typer.core 1abcdefgh

10from click import Command, Group, Option 1abcdefgh

11 

12from . import __version__ 1abcdefgh

13from .core import HAS_RICH, MARKUP_MODE_KEY 1abcdefgh

14 

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

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

17 

18app = typer.Typer() 1abcdefgh

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

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

21 

22 

23class State: 1abcdefgh

24 def __init__(self) -> None: 1abcdefgh

25 self.app: Optional[str] = None 1abcdefgh

26 self.func: Optional[str] = None 1abcdefgh

27 self.file: Optional[Path] = None 1abcdefgh

28 self.module: Optional[str] = None 1abcdefgh

29 

30 

31state = State() 1abcdefgh

32 

33 

34def maybe_update_state(ctx: click.Context) -> None: 1abcdefgh

35 path_or_module = ctx.params.get("path_or_module") 1abcdefgh

36 if path_or_module: 1abcdefgh

37 file_path = Path(path_or_module) 1abcdefgh

38 if file_path.exists() and file_path.is_file(): 1abcdefgh

39 state.file = file_path 1abcdefgh

40 else: 

41 if not re.fullmatch(r"[a-zA-Z_]\w*(\.[a-zA-Z_]\w*)*", path_or_module): 1abcdefgh

42 typer.echo( 1abcdefgh

43 f"Not a valid file or Python module: {path_or_module}", err=True 

44 ) 

45 sys.exit(1) 1abcdefgh

46 state.module = path_or_module 1abcdefgh

47 app_name = ctx.params.get("app") 1abcdefgh

48 if app_name: 1abcdefgh

49 state.app = app_name 1abcdefgh

50 func_name = ctx.params.get("func") 1abcdefgh

51 if func_name: 1abcdefgh

52 state.func = func_name 1abcdefgh

53 

54 

55class TyperCLIGroup(typer.core.TyperGroup): 1abcdefgh

56 def list_commands(self, ctx: click.Context) -> list[str]: 1abcdefgh

57 self.maybe_add_run(ctx) 1abcdefgh

58 return super().list_commands(ctx) 1abcdefgh

59 

60 def get_command(self, ctx: click.Context, name: str) -> Optional[Command]: 1abcdefgh

61 self.maybe_add_run(ctx) 1abcdefgh

62 return super().get_command(ctx, name) 1abcdefgh

63 

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

65 self.maybe_add_run(ctx) 1abcdefgh

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

67 

68 def maybe_add_run(self, ctx: click.Context) -> None: 1abcdefgh

69 maybe_update_state(ctx) 1abcdefgh

70 maybe_add_run_to_cli(self) 1abcdefgh

71 

72 

73def get_typer_from_module(module: Any) -> Optional[typer.Typer]: 1abcdefgh

74 # Try to get defined app 

75 if state.app: 1abcdefgh

76 obj = getattr(module, state.app, None) 1abcdefgh

77 if not isinstance(obj, typer.Typer): 1abcdefgh

78 typer.echo(f"Not a Typer object: --app {state.app}", err=True) 1abcdefgh

79 sys.exit(1) 1abcdefgh

80 return obj 1abcdefgh

81 # Try to get defined function 

82 if state.func: 1abcdefgh

83 func_obj = getattr(module, state.func, None) 1abcdefgh

84 if not callable(func_obj): 1abcdefgh

85 typer.echo(f"Not a function: --func {state.func}", err=True) 1abcdefgh

86 sys.exit(1) 1abcdefgh

87 sub_app = typer.Typer() 1abcdefgh

88 sub_app.command()(func_obj) 1abcdefgh

89 return sub_app 1abcdefgh

90 # Iterate and get a default object to use as CLI 

91 local_names = dir(module) 1abcdefgh

92 local_names_set = set(local_names) 1abcdefgh

93 # Try to get a default Typer app 

94 for name in default_app_names: 1abcdefgh

95 if name in local_names_set: 1abcdefgh

96 obj = getattr(module, name, None) 1abcdefgh

97 if isinstance(obj, typer.Typer): 1abcdefgh

98 return obj 1abcdefgh

99 # Try to get any Typer app 

100 for name in local_names_set - set(default_app_names): 1abcdefgh

101 obj = getattr(module, name) 1abcdefgh

102 if isinstance(obj, typer.Typer): 1abcdefgh

103 return obj 1abcdefgh

104 # Try to get a default function 

105 for func_name in default_func_names: 1abcdefgh

106 func_obj = getattr(module, func_name, None) 1abcdefgh

107 if callable(func_obj): 1abcdefgh

108 sub_app = typer.Typer() 1abcdefgh

109 sub_app.command()(func_obj) 1abcdefgh

110 return sub_app 1abcdefgh

111 # Try to get any func app 

112 for func_name in local_names_set - set(default_func_names): 1abcdefgh

113 func_obj = getattr(module, func_name) 1abcdefgh

114 if callable(func_obj): 1abcdefgh

115 sub_app = typer.Typer() 1abcdefgh

116 sub_app.command()(func_obj) 1abcdefgh

117 return sub_app 1abcdefgh

118 return None 1abcdefgh

119 

120 

121def get_typer_from_state() -> Optional[typer.Typer]: 1abcdefgh

122 spec = None 1abcdefgh

123 if state.file: 1abcdefgh

124 module_name = state.file.name 1abcdefgh

125 spec = importlib.util.spec_from_file_location(module_name, str(state.file)) 1abcdefgh

126 elif state.module: 1abcdefgh

127 spec = importlib.util.find_spec(state.module) 1abcdefgh

128 if spec is None: 1abcdefgh

129 if state.file: 1abcdefgh

130 typer.echo(f"Could not import as Python file: {state.file}", err=True) 1abcdefgh

131 else: 

132 typer.echo(f"Could not import as Python module: {state.module}", err=True) 1abcdefgh

133 sys.exit(1) 1abcdefgh

134 module = importlib.util.module_from_spec(spec) 1abcdefgh

135 spec.loader.exec_module(module) # type: ignore 1abcdefgh

136 obj = get_typer_from_module(module) 1abcdefgh

137 return obj 1abcdefgh

138 

139 

140def maybe_add_run_to_cli(cli: click.Group) -> None: 1abcdefgh

141 if "run" not in cli.commands: 1abcdefgh

142 if state.file or state.module: 1abcdefgh

143 obj = get_typer_from_state() 1abcdefgh

144 if obj: 1abcdefgh

145 obj._add_completion = False 1abcdefgh

146 click_obj = typer.main.get_command(obj) 1abcdefgh

147 click_obj.name = "run" 1abcdefgh

148 if not click_obj.help: 1abcdefgh

149 click_obj.help = "Run the provided Typer app." 1abcdefgh

150 cli.add_command(click_obj) 1abcdefgh

151 

152 

153def print_version(ctx: click.Context, param: Option, value: bool) -> None: 1abcdefgh

154 if not value or ctx.resilient_parsing: 1abcdefgh

155 return 1abcdefgh

156 typer.echo(f"Typer version: {__version__}") 1abcdefgh

157 raise typer.Exit() 1abcdefgh

158 

159 

160@app.callback(cls=TyperCLIGroup, no_args_is_help=True) 1abcdefgh

161def callback( 1abcdefgh

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) 1abcdefgh

184 

185 

186def get_docs_for_click( 1abcdefgh

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) 1abcdefgh

196 command_name = name or obj.name 1abcdefgh

197 if call_prefix: 1abcdefgh

198 command_name = f"{call_prefix} {command_name}" 1abcdefgh

199 if not title: 1abcdefgh

200 title = f"`{command_name}`" if command_name else "CLI" 1abcdefgh

201 docs += f" {title}\n\n" 1abcdefgh

202 rich_markup_mode = None 1abcdefgh

203 if hasattr(ctx, "obj") and isinstance(ctx.obj, dict): 1abcdefgh

204 rich_markup_mode = ctx.obj.get(MARKUP_MODE_KEY, None) 1abcdefgh

205 to_parse: bool = bool(HAS_RICH and (rich_markup_mode == "rich")) 1abcdefgh

206 if obj.help: 1abcdefgh

207 docs += f"{_parse_html(to_parse, obj.help)}\n\n" 1abcdefgh

208 usage_pieces = obj.collect_usage_pieces(ctx) 1abcdefgh

209 if usage_pieces: 1abcdefgh

210 docs += "**Usage**:\n\n" 1abcdefgh

211 docs += "```console\n" 1abcdefgh

212 docs += "$ " 1abcdefgh

213 if command_name: 1abcdefgh

214 docs += f"{command_name} " 1abcdefgh

215 docs += f"{' '.join(usage_pieces)}\n" 1abcdefgh

216 docs += "```\n\n" 1abcdefgh

217 args = [] 1abcdefgh

218 opts = [] 1abcdefgh

219 for param in obj.get_params(ctx): 1abcdefgh

220 rv = param.get_help_record(ctx) 1abcdefgh

221 if rv is not None: 1abcdefgh

222 if param.param_type_name == "argument": 1abcdefgh

223 args.append(rv) 1abcdefgh

224 elif param.param_type_name == "option": 1abcdefgh

225 opts.append(rv) 1abcdefgh

226 if args: 1abcdefgh

227 docs += "**Arguments**:\n\n" 1abcdefgh

228 for arg_name, arg_help in args: 1abcdefgh

229 docs += f"* `{arg_name}`" 1abcdefgh

230 if arg_help: 1abcdefgh

231 docs += f": {_parse_html(to_parse, arg_help)}" 1abcdefgh

232 docs += "\n" 1abcdefgh

233 docs += "\n" 1abcdefgh

234 if opts: 1abcdefgh

235 docs += "**Options**:\n\n" 1abcdefgh

236 for opt_name, opt_help in opts: 1abcdefgh

237 docs += f"* `{opt_name}`" 1abcdefgh

238 if opt_help: 1abcdefgh

239 docs += f": {_parse_html(to_parse, opt_help)}" 1abcdefgh

240 docs += "\n" 1abcdefgh

241 docs += "\n" 1abcdefgh

242 if obj.epilog: 1abcdefgh

243 docs += f"{obj.epilog}\n\n" 1abcdefgh

244 if isinstance(obj, Group): 1abcdefgh

245 group = obj 1abcdefgh

246 commands = group.list_commands(ctx) 1abcdefgh

247 if commands: 1abcdefgh

248 docs += "**Commands**:\n\n" 1abcdefgh

249 for command in commands: 1abcdefgh

250 command_obj = group.get_command(ctx, command) 1abcdefgh

251 assert command_obj 1abcdefgh

252 docs += f"* `{command_obj.name}`" 1abcdefgh

253 command_help = command_obj.get_short_help_str() 1abcdefgh

254 if command_help: 1abcdefgh

255 docs += f": {_parse_html(to_parse, command_help)}" 1abcdefgh

256 docs += "\n" 1abcdefgh

257 docs += "\n" 1abcdefgh

258 for command in commands: 1abcdefgh

259 command_obj = group.get_command(ctx, command) 1abcdefgh

260 assert command_obj 1abcdefgh

261 use_prefix = "" 1abcdefgh

262 if command_name: 1abcdefgh

263 use_prefix += f"{command_name}" 1abcdefgh

264 docs += get_docs_for_click( 1abcdefgh

265 obj=command_obj, ctx=ctx, indent=indent + 1, call_prefix=use_prefix 

266 ) 

267 return docs 1abcdefgh

268 

269 

270def _parse_html(to_parse: bool, input_text: str) -> str: 1abcdefgh

271 if not to_parse: 1abcdefgh

272 return input_text 1abcdefgh

273 from . import rich_utils 1abcdefgh

274 

275 return rich_utils.rich_to_html(input_text) 1abcdefgh

276 

277 

278@utils_app.command() 1abcdefgh

279def docs( 1abcdefgh

280 ctx: typer.Context, 

281 name: str = typer.Option("", help="The name of the CLI program to use in docs."), 

282 output: Optional[Path] = typer.Option( 

283 None, 

284 help="An output file to write docs to, like README.md.", 

285 file_okay=True, 

286 dir_okay=False, 

287 ), 

288 title: Optional[str] = typer.Option( 

289 None, 

290 help="The title for the documentation page. If not provided, the name of " 

291 "the program is used.", 

292 ), 

293) -> None: 

294 """ 

295 Generate Markdown docs for a Typer app. 

296 """ 

297 typer_obj = get_typer_from_state() 1abcdefgh

298 if not typer_obj: 1abcdefgh

299 typer.echo("No Typer app found", err=True) 1abcdefgh

300 raise typer.Abort() 1abcdefgh

301 if hasattr(typer_obj, "rich_markup_mode"): 1abcdefgh

302 if not hasattr(ctx, "obj") or ctx.obj is None: 1abcdefgh

303 ctx.ensure_object(dict) 1abcdefgh

304 if isinstance(ctx.obj, dict): 1abcdefgh

305 ctx.obj[MARKUP_MODE_KEY] = typer_obj.rich_markup_mode 1abcdefgh

306 click_obj = typer.main.get_command(typer_obj) 1abcdefgh

307 docs = get_docs_for_click(obj=click_obj, ctx=ctx, name=name, title=title) 1abcdefgh

308 clean_docs = f"{docs.strip()}\n" 1abcdefgh

309 if output: 1abcdefgh

310 output.write_text(clean_docs) 1abcdefgh

311 typer.echo(f"Docs saved to: {output}") 1abcdefgh

312 else: 

313 typer.echo(clean_docs) 1abcdefgh

314 

315 

316def main() -> Any: 1abcdefgh

317 return app() 1abcdefgh