Coverage for typer / cli.py: 100%

223 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-11-09 00:15 +0000

1import importlib.util 1abcdefg

2import re 1abcdefg

3import sys 1abcdefg

4from pathlib import Path 1abcdefg

5from typing import Any 1abcdefg

6 

7import typer 1abcdefg

8import typer.core 1abcdefg

9 

10from . import __version__, _click 1abcdefg

11from ._click import Command 1abcdefg

12from .core import HAS_RICH, MARKUP_MODE_KEY, TyperGroup, TyperOption 1abcdefg

13 

14default_app_names = ("app", "cli", "main") 1abcdefg

15default_func_names = ("main", "cli", "app") 1abcdefg

16 

17app = typer.Typer() 1abcdefg

18utils_app = typer.Typer(help="Extra utility commands for Typer apps.") 1abcdefg

19app.add_typer(utils_app, name="utils") 1abcdefg

20 

21 

22class State: 1abcdefg

23 def __init__(self) -> None: 1abcdefg

24 self.app: str | None = None 1abcdefg

25 self.func: str | None = None 1abcdefg

26 self.file: Path | None = None 1abcdefg

27 self.module: str | None = None 1abcdefg

28 

29 

30state = State() 1abcdefg

31 

32 

33def maybe_update_state(ctx: _click.Context) -> None: 1abcdefg

34 path_or_module = ctx.params.get("path_or_module") 1abcdefg

35 if path_or_module: 1abcdefg

36 file_path = Path(path_or_module) 1abcdefg

37 if file_path.exists() and file_path.is_file(): 1abcdefg

38 state.file = file_path 1abcdefg

39 else: 

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

41 typer.echo( 1abcdefg

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

43 ) 

44 sys.exit(1) 1abcdefg

45 state.module = path_or_module 1abcdefg

46 app_name = ctx.params.get("app") 1abcdefg

47 if app_name: 1abcdefg

48 state.app = app_name 1abcdefg

49 func_name = ctx.params.get("func") 1abcdefg

50 if func_name: 1abcdefg

51 state.func = func_name 1abcdefg

52 

53 

54class TyperCLIGroup(typer.core.TyperGroup): 1abcdefg

55 def list_commands(self, ctx: _click.Context) -> list[str]: 1abcdefg

56 self.maybe_add_run(ctx) 1abcdefg

57 return super().list_commands(ctx) 1abcdefg

58 

59 def get_command(self, ctx: _click.Context, name: str) -> Command | None: # ty: ignore[invalid-method-override] 1abcdefg

60 self.maybe_add_run(ctx) 1abcdefg

61 return super().get_command(ctx, name) 1abcdefg

62 

63 def invoke(self, ctx: _click.Context) -> Any: 1abcdefg

64 self.maybe_add_run(ctx) 1abcdefg

65 return super().invoke(ctx) 1abcdefg

66 

67 def maybe_add_run(self, ctx: _click.Context) -> None: 1abcdefg

68 maybe_update_state(ctx) 1abcdefg

69 maybe_add_run_to_cli(self) 1abcdefg

70 

71 

72def get_typer_from_module(module: Any) -> typer.Typer | None: 1abcdefg

73 # Try to get defined app 

74 if state.app: 1abcdefg

75 obj = getattr(module, state.app, None) 1abcdefg

76 if not isinstance(obj, typer.Typer): 1abcdefg

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

78 sys.exit(1) 1abcdefg

79 return obj 1abcdefg

80 # Try to get defined function 

81 if state.func: 1abcdefg

82 func_obj = getattr(module, state.func, None) 1abcdefg

83 if not callable(func_obj): 1abcdefg

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

85 raise typer.Exit(1) 1abcdefg

86 sub_app = typer.Typer() 1abcdefg

87 sub_app.command()(func_obj) 1abcdefg

88 return sub_app 1abcdefg

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

90 local_names = dir(module) 1abcdefg

91 local_names_set = set(local_names) 1abcdefg

92 # Try to get a default Typer app 

93 for name in default_app_names: 1abcdefg

94 if name in local_names_set: 1abcdefg

95 obj = getattr(module, name, None) 1abcdefg

96 if isinstance(obj, typer.Typer): 1abcdefg

97 return obj 1abcdefg

98 # Try to get any Typer app 

99 for name in local_names_set - set(default_app_names): 1abcdefg

100 obj = getattr(module, name) 1abcdefg

101 if isinstance(obj, typer.Typer): 1abcdefg

102 return obj 1abcdefg

103 # Try to get a default function 

104 for func_name in default_func_names: 1abcdefg

105 func_obj = getattr(module, func_name, None) 1abcdefg

106 if callable(func_obj): 1abcdefg

107 sub_app = typer.Typer() 1abcdefg

108 sub_app.command()(func_obj) 1abcdefg

109 return sub_app 1abcdefg

110 # Try to get any func app 

111 for func_name in local_names_set - set(default_func_names): 1abcdefg

112 func_obj = getattr(module, func_name) 1abcdefg

113 if callable(func_obj): 1abcdefg

114 sub_app = typer.Typer() 1abcdefg

115 sub_app.command()(func_obj) 1abcdefg

116 return sub_app 1abcdefg

117 return None 1abcdefg

118 

119 

120def get_typer_from_state() -> typer.Typer | None: 1abcdefg

121 spec = None 1abcdefg

122 if state.file: 1abcdefg

123 module_name = state.file.name 1abcdefg

124 spec = importlib.util.spec_from_file_location(module_name, str(state.file)) 1abcdefg

125 elif state.module: 1abcdefg

126 spec = importlib.util.find_spec(state.module) 1abcdefg

127 if spec is None: 1abcdefg

128 if state.file: 1abcdefg

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

130 else: 

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

132 sys.exit(1) 1abcdefg

133 assert spec is not None 1abcdefg

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

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

136 obj = get_typer_from_module(module) 1abcdefg

137 return obj 1abcdefg

138 

139 

140def maybe_add_run_to_cli(cli: TyperGroup) -> None: 1abcdefg

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

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

143 obj = get_typer_from_state() 1abcdefg

144 if obj: 1abcdefg

145 obj._add_completion = False 1abcdefg

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

147 click_obj.name = "run" 1abcdefg

148 if not click_obj.help: 1abcdefg

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

150 cli.add_command(click_obj) 1abcdefg

151 

152 

153def print_version(ctx: _click.Context, param: TyperOption, value: bool) -> None: 1abcdefg

154 if not value or ctx.resilient_parsing: 1abcdefg

155 return 1abcdefg

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

157 raise typer.Exit() 1abcdefg

158 

159 

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

161def callback( 1abcdefg

162 ctx: typer.Context, 

163 *, 

164 path_or_module: str = typer.Argument(None, metavar="PATH_OR_MODULE"), 

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

184 

185 

186def get_docs_for_click( 1abcdefg

187 *, 

188 obj: Command, 

189 ctx: typer.Context, 

190 indent: int = 0, 

191 name: str = "", 

192 call_prefix: str = "", 

193 title: str | None = None, 

194) -> str: 

195 docs = "#" * (1 + indent) 1abcdefg

196 command_name = name or obj.name 1abcdefg

197 if call_prefix: 1abcdefg

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

199 if not title: 1abcdefg

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

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

202 rich_markup_mode = None 1abcdefg

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

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

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

206 if obj.help: 1abcdefg

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

208 usage_pieces = obj.collect_usage_pieces(ctx) 1abcdefg

209 if usage_pieces: 1abcdefg

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

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

212 docs += "$ " 1abcdefg

213 if command_name: 1abcdefg

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

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

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

217 args = [] 1abcdefg

218 opts = [] 1abcdefg

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

220 rv = param.get_help_record(ctx) 1abcdefg

221 if rv is not None: 1abcdefg

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

223 args.append(rv) 1abcdefg

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

225 opts.append(rv) 1abcdefg

226 if args: 1abcdefg

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

228 for arg_name, arg_help in args: 1abcdefg

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

230 if arg_help: 1abcdefg

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

232 docs += "\n" 1abcdefg

233 docs += "\n" 1abcdefg

234 if opts: 1abcdefg

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

236 for opt_name, opt_help in opts: 1abcdefg

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

238 if opt_help: 1abcdefg

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

240 docs += "\n" 1abcdefg

241 docs += "\n" 1abcdefg

242 if obj.epilog: 1abcdefg

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

244 if isinstance(obj, TyperGroup): 1abcdefg

245 group = obj 1abcdefg

246 commands = group.list_commands(ctx) 1abcdefg

247 if commands: 1abcdefg

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

249 for command in commands: 1abcdefg

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

251 assert command_obj 1abcdefg

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

253 command_help = command_obj.get_short_help_str() 1abcdefg

254 if command_help: 1abcdefg

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

256 docs += "\n" 1abcdefg

257 docs += "\n" 1abcdefg

258 for command in commands: 1abcdefg

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

260 assert command_obj 1abcdefg

261 use_prefix = "" 1abcdefg

262 if command_name: 1abcdefg

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

264 docs += get_docs_for_click( 1abcdefg

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

266 ) 

267 return docs 1abcdefg

268 

269 

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

271 if not to_parse: 1abcdefg

272 return input_text 1abcdefg

273 from . import rich_utils 1abcdefg

274 

275 return rich_utils.rich_to_html(input_text) 1abcdefg

276 

277 

278@utils_app.command() 1abcdefg

279def docs( 1abcdefg

280 ctx: typer.Context, 

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

282 output: Path | None = 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: str | None = 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() 1abcdefg

298 if not typer_obj: 1abcdefg

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

300 raise typer.Abort() 1abcdefg

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

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

303 ctx.ensure_object(dict) 1abcdefg

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

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

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

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

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

309 if output: 1abcdefg

310 output.write_text(clean_docs) 1abcdefg

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

312 else: 

313 typer.echo(clean_docs) 1abcdefg

314 

315 

316def main() -> Any: 1abcdefg

317 return app() 1abcdefg