Coverage for typer / cli.py: 100%

224 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-03-26 21:46 +0000

1import importlib.util 1abcdefg

2import re 1abcdefg

3import sys 1abcdefg

4from pathlib import Path 1abcdefg

5from typing import Any 1abcdefg

6 

7import click 1abcdefg

8import typer 1abcdefg

9import typer.core 1abcdefg

10from click import Command, Group, Option 1abcdefg

11 

12from . import __version__ 1abcdefg

13from .core import HAS_RICH, MARKUP_MODE_KEY 1abcdefg

14 

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

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

17 

18app = typer.Typer() 1abcdefg

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

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

21 

22 

23class State: 1abcdefg

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

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

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

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

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

29 

30 

31state = State() 1abcdefg

32 

33 

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

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

36 if path_or_module: 1abcdefg

37 file_path = Path(path_or_module) 1abcdefg

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

39 state.file = file_path 1abcdefg

40 else: 

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

42 typer.echo( 1abcdefg

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

44 ) 

45 sys.exit(1) 1abcdefg

46 state.module = path_or_module 1abcdefg

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

48 if app_name: 1abcdefg

49 state.app = app_name 1abcdefg

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

51 if func_name: 1abcdefg

52 state.func = func_name 1abcdefg

53 

54 

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

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

57 self.maybe_add_run(ctx) 1abcdefg

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

59 

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

61 self.maybe_add_run(ctx) 1abcdefg

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

63 

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

65 self.maybe_add_run(ctx) 1abcdefg

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

67 

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

69 maybe_update_state(ctx) 1abcdefg

70 maybe_add_run_to_cli(self) 1abcdefg

71 

72 

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

74 # Try to get defined app 

75 if state.app: 1abcdefg

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

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

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

79 sys.exit(1) 1abcdefg

80 return obj 1abcdefg

81 # Try to get defined function 

82 if state.func: 1abcdefg

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

84 if not callable(func_obj): 1abcdefg

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

86 raise typer.Exit(1) 1abcdefg

87 sub_app = typer.Typer() 1abcdefg

88 sub_app.command()(func_obj) 1abcdefg

89 return sub_app 1abcdefg

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

91 local_names = dir(module) 1abcdefg

92 local_names_set = set(local_names) 1abcdefg

93 # Try to get a default Typer app 

94 for name in default_app_names: 1abcdefg

95 if name in local_names_set: 1abcdefg

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

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

98 return obj 1abcdefg

99 # Try to get any Typer app 

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

101 obj = getattr(module, name) 1abcdefg

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

103 return obj 1abcdefg

104 # Try to get a default function 

105 for func_name in default_func_names: 1abcdefg

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

107 if callable(func_obj): 1abcdefg

108 sub_app = typer.Typer() 1abcdefg

109 sub_app.command()(func_obj) 1abcdefg

110 return sub_app 1abcdefg

111 # Try to get any func app 

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

113 func_obj = getattr(module, func_name) 1abcdefg

114 if callable(func_obj): 1abcdefg

115 sub_app = typer.Typer() 1abcdefg

116 sub_app.command()(func_obj) 1abcdefg

117 return sub_app 1abcdefg

118 return None 1abcdefg

119 

120 

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

122 spec = None 1abcdefg

123 if state.file: 1abcdefg

124 module_name = state.file.name 1abcdefg

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

126 elif state.module: 1abcdefg

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

128 if spec is None: 1abcdefg

129 if state.file: 1abcdefg

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

131 else: 

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

133 sys.exit(1) 1abcdefg

134 assert spec is not None 1abcdefg

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

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

137 obj = get_typer_from_module(module) 1abcdefg

138 return obj 1abcdefg

139 

140 

141def maybe_add_run_to_cli(cli: click.Group) -> None: 1abcdefg

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

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

144 obj = get_typer_from_state() 1abcdefg

145 if obj: 1abcdefg

146 obj._add_completion = False 1abcdefg

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

148 click_obj.name = "run" 1abcdefg

149 if not click_obj.help: 1abcdefg

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

151 cli.add_command(click_obj) 1abcdefg

152 

153 

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

155 if not value or ctx.resilient_parsing: 1abcdefg

156 return 1abcdefg

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

158 raise typer.Exit() 1abcdefg

159 

160 

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

162def callback( 1abcdefg

163 ctx: typer.Context, 

164 *, 

165 path_or_module: str = typer.Argument(None), 

166 app: str = typer.Option(None, help="The typer app object/variable to use."), 

167 func: str = typer.Option(None, help="The function to convert to Typer."), 

168 version: bool = typer.Option( 

169 False, 

170 "--version", 

171 help="Print version and exit.", 

172 callback=print_version, 

173 ), 

174) -> None: 

175 """ 

176 Run Typer scripts with completion, without having to create a package. 

177 

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

179 

180 $ typer --install-completion 

181 

182 https://typer.tiangolo.com/ 

183 """ 

184 maybe_update_state(ctx) 1abcdefg

185 

186 

187def get_docs_for_click( 1abcdefg

188 *, 

189 obj: Command, 

190 ctx: typer.Context, 

191 indent: int = 0, 

192 name: str = "", 

193 call_prefix: str = "", 

194 title: str | None = None, 

195) -> str: 

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

197 command_name = name or obj.name 1abcdefg

198 if call_prefix: 1abcdefg

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

200 if not title: 1abcdefg

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

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

203 rich_markup_mode = None 1abcdefg

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

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

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

207 if obj.help: 1abcdefg

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

209 usage_pieces = obj.collect_usage_pieces(ctx) 1abcdefg

210 if usage_pieces: 1abcdefg

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

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

213 docs += "$ " 1abcdefg

214 if command_name: 1abcdefg

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

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

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

218 args = [] 1abcdefg

219 opts = [] 1abcdefg

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

221 rv = param.get_help_record(ctx) 1abcdefg

222 if rv is not None: 1abcdefg

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

224 args.append(rv) 1abcdefg

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

226 opts.append(rv) 1abcdefg

227 if args: 1abcdefg

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

229 for arg_name, arg_help in args: 1abcdefg

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

231 if arg_help: 1abcdefg

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

233 docs += "\n" 1abcdefg

234 docs += "\n" 1abcdefg

235 if opts: 1abcdefg

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

237 for opt_name, opt_help in opts: 1abcdefg

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

239 if opt_help: 1abcdefg

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

241 docs += "\n" 1abcdefg

242 docs += "\n" 1abcdefg

243 if obj.epilog: 1abcdefg

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

245 if isinstance(obj, Group): 1abcdefg

246 group = obj 1abcdefg

247 commands = group.list_commands(ctx) 1abcdefg

248 if commands: 1abcdefg

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

250 for command in commands: 1abcdefg

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

252 assert command_obj 1abcdefg

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

254 command_help = command_obj.get_short_help_str() 1abcdefg

255 if command_help: 1abcdefg

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

257 docs += "\n" 1abcdefg

258 docs += "\n" 1abcdefg

259 for command in commands: 1abcdefg

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

261 assert command_obj 1abcdefg

262 use_prefix = "" 1abcdefg

263 if command_name: 1abcdefg

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

265 docs += get_docs_for_click( 1abcdefg

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

267 ) 

268 return docs 1abcdefg

269 

270 

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

272 if not to_parse: 1abcdefg

273 return input_text 1abcdefg

274 from . import rich_utils 1abcdefg

275 

276 return rich_utils.rich_to_html(input_text) 1abcdefg

277 

278 

279@utils_app.command() 1abcdefg

280def docs( 1abcdefg

281 ctx: typer.Context, 

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

283 output: Path | None = typer.Option( 

284 None, 

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

286 file_okay=True, 

287 dir_okay=False, 

288 ), 

289 title: str | None = typer.Option( 

290 None, 

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

292 "the program is used.", 

293 ), 

294) -> None: 

295 """ 

296 Generate Markdown docs for a Typer app. 

297 """ 

298 typer_obj = get_typer_from_state() 1abcdefg

299 if not typer_obj: 1abcdefg

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

301 raise typer.Abort() 1abcdefg

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

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

304 ctx.ensure_object(dict) 1abcdefg

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

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

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

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

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

310 if output: 1abcdefg

311 output.write_text(clean_docs) 1abcdefg

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

313 else: 

314 typer.echo(clean_docs) 1abcdefg

315 

316 

317def main() -> Any: 1abcdefg

318 return app() 1abcdefg