Coverage for typer/cli.py: 100%

208 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-09-09 18:26 +0000

1import importlib.util 1habcdefg

2import re 1habcdefg

3import sys 1habcdefg

4from pathlib import Path 1habcdefg

5from typing import Any, List, Optional 1habcdefg

6 

7import click 1habcdefg

8import typer 1habcdefg

9import typer.core 1habcdefg

10from click import Command, Group, Option 1habcdefg

11 

12from . import __version__ 1habcdefg

13 

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

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

16 

17app = typer.Typer() 1habcdefg

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

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

20 

21 

22class State: 1habcdefg

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

24 self.app: Optional[str] = None 1habcdefg

25 self.func: Optional[str] = None 1habcdefg

26 self.file: Optional[Path] = None 1habcdefg

27 self.module: Optional[str] = None 1habcdefg

28 

29 

30state = State() 1habcdefg

31 

32 

33def maybe_update_state(ctx: click.Context) -> None: 1habcdefg

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

35 if path_or_module: 1habcdefg

36 file_path = Path(path_or_module) 1habcdefg

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

38 state.file = file_path 1habcdefg

39 else: 

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

41 typer.echo( 1habcdefg

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

43 ) 

44 sys.exit(1) 1habcdefg

45 state.module = path_or_module 1habcdefg

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

47 if app_name: 1habcdefg

48 state.app = app_name 1habcdefg

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

50 if func_name: 1habcdefg

51 state.func = func_name 1habcdefg

52 

53 

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

55 def list_commands(self, ctx: click.Context) -> List[str]: 1habcdefg

56 self.maybe_add_run(ctx) 1habcdefg

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

58 

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

60 self.maybe_add_run(ctx) 1habcdefg

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

62 

63 def invoke(self, ctx: click.Context) -> Any: 1habcdefg

64 self.maybe_add_run(ctx) 1habcdefg

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

66 

67 def maybe_add_run(self, ctx: click.Context) -> None: 1habcdefg

68 maybe_update_state(ctx) 1habcdefg

69 maybe_add_run_to_cli(self) 1habcdefg

70 

71 

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

73 # Try to get defined app 

74 if state.app: 1habcdefg

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

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

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

78 sys.exit(1) 1habcdefg

79 return obj 1habcdefg

80 # Try to get defined function 

81 if state.func: 1habcdefg

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

83 if not callable(func_obj): 1habcdefg

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

85 sys.exit(1) 1habcdefg

86 sub_app = typer.Typer() 1habcdefg

87 sub_app.command()(func_obj) 1habcdefg

88 return sub_app 1habcdefg

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

90 local_names = dir(module) 1habcdefg

91 local_names_set = set(local_names) 1habcdefg

92 # Try to get a default Typer app 

93 for name in default_app_names: 1habcdefg

94 if name in local_names_set: 1habcdefg

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

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

97 return obj 1habcdefg

98 # Try to get any Typer app 

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

100 obj = getattr(module, name) 1habcdefg

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

102 return obj 1habcdefg

103 # Try to get a default function 

104 for func_name in default_func_names: 1habcdefg

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

106 if callable(func_obj): 1habcdefg

107 sub_app = typer.Typer() 1habcdefg

108 sub_app.command()(func_obj) 1habcdefg

109 return sub_app 1habcdefg

110 # Try to get any func app 

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

112 func_obj = getattr(module, func_name) 1habcdefg

113 if callable(func_obj): 1habcdefg

114 sub_app = typer.Typer() 1habcdefg

115 sub_app.command()(func_obj) 1habcdefg

116 return sub_app 1habcdefg

117 return None 1habcdefg

118 

119 

120def get_typer_from_state() -> Optional[typer.Typer]: 1habcdefg

121 spec = None 1habcdefg

122 if state.file: 1habcdefg

123 module_name = state.file.name 1habcdefg

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

125 elif state.module: 1habcdefg

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

127 if spec is None: 1habcdefg

128 if state.file: 1habcdefg

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

130 else: 

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

132 sys.exit(1) 1habcdefg

133 module = importlib.util.module_from_spec(spec) 1habcdefg

134 spec.loader.exec_module(module) # type: ignore 1habcdefg

135 obj = get_typer_from_module(module) 1habcdefg

136 return obj 1habcdefg

137 

138 

139def maybe_add_run_to_cli(cli: click.Group) -> None: 1habcdefg

140 if "run" not in cli.commands: 1habcdefg

141 if state.file or state.module: 1habcdefg

142 obj = get_typer_from_state() 1habcdefg

143 if obj: 1habcdefg

144 obj._add_completion = False 1habcdefg

145 click_obj = typer.main.get_command(obj) 1habcdefg

146 click_obj.name = "run" 1habcdefg

147 if not click_obj.help: 1habcdefg

148 click_obj.help = "Run the provided Typer app." 1habcdefg

149 cli.add_command(click_obj) 1habcdefg

150 

151 

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

153 if not value or ctx.resilient_parsing: 1habcdefg

154 return 1habcdefg

155 typer.echo(f"Typer version: {__version__}") 1habcdefg

156 raise typer.Exit() 1habcdefg

157 

158 

159@app.callback(cls=TyperCLIGroup, no_args_is_help=True) 1habcdefg

160def callback( 1abcdefg

161 ctx: typer.Context, 

162 *, 

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

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

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

166 version: bool = typer.Option( 

167 False, 

168 "--version", 

169 help="Print version and exit.", 

170 callback=print_version, 

171 ), 

172) -> None: 

173 """ 

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

175 

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

177 

178 $ typer --install-completion 

179 

180 https://typer.tiangolo.com/ 

181 """ 

182 maybe_update_state(ctx) 1habcdefg

183 

184 

185def get_docs_for_click( 1abcdefg

186 *, 

187 obj: Command, 

188 ctx: typer.Context, 

189 indent: int = 0, 

190 name: str = "", 

191 call_prefix: str = "", 

192 title: Optional[str] = None, 

193) -> str: 

194 docs = "#" * (1 + indent) 1habcdefg

195 command_name = name or obj.name 1habcdefg

196 if call_prefix: 1habcdefg

197 command_name = f"{call_prefix} {command_name}" 1habcdefg

198 if not title: 1habcdefg

199 title = f"`{command_name}`" if command_name else "CLI" 1habcdefg

200 docs += f" {title}\n\n" 1habcdefg

201 if obj.help: 1habcdefg

202 docs += f"{obj.help}\n\n" 1habcdefg

203 usage_pieces = obj.collect_usage_pieces(ctx) 1habcdefg

204 if usage_pieces: 1habcdefg

205 docs += "**Usage**:\n\n" 1habcdefg

206 docs += "```console\n" 1habcdefg

207 docs += "$ " 1habcdefg

208 if command_name: 1habcdefg

209 docs += f"{command_name} " 1habcdefg

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

211 docs += "```\n\n" 1habcdefg

212 args = [] 1habcdefg

213 opts = [] 1habcdefg

214 for param in obj.get_params(ctx): 1habcdefg

215 rv = param.get_help_record(ctx) 1habcdefg

216 if rv is not None: 1habcdefg

217 if param.param_type_name == "argument": 1habcdefg

218 args.append(rv) 1habcdefg

219 elif param.param_type_name == "option": 1habcdefg

220 opts.append(rv) 1habcdefg

221 if args: 1habcdefg

222 docs += "**Arguments**:\n\n" 1habcdefg

223 for arg_name, arg_help in args: 1habcdefg

224 docs += f"* `{arg_name}`" 1habcdefg

225 if arg_help: 1habcdefg

226 docs += f": {arg_help}" 1habcdefg

227 docs += "\n" 1habcdefg

228 docs += "\n" 1habcdefg

229 if opts: 1habcdefg

230 docs += "**Options**:\n\n" 1habcdefg

231 for opt_name, opt_help in opts: 1habcdefg

232 docs += f"* `{opt_name}`" 1habcdefg

233 if opt_help: 1habcdefg

234 docs += f": {opt_help}" 1habcdefg

235 docs += "\n" 1habcdefg

236 docs += "\n" 1habcdefg

237 if obj.epilog: 1habcdefg

238 docs += f"{obj.epilog}\n\n" 1habcdefg

239 if isinstance(obj, Group): 1habcdefg

240 group = obj 1habcdefg

241 commands = group.list_commands(ctx) 1habcdefg

242 if commands: 1habcdefg

243 docs += "**Commands**:\n\n" 1habcdefg

244 for command in commands: 1habcdefg

245 command_obj = group.get_command(ctx, command) 1habcdefg

246 assert command_obj 1habcdefg

247 docs += f"* `{command_obj.name}`" 1habcdefg

248 command_help = command_obj.get_short_help_str() 1habcdefg

249 if command_help: 1habcdefg

250 docs += f": {command_help}" 1habcdefg

251 docs += "\n" 1habcdefg

252 docs += "\n" 1habcdefg

253 for command in commands: 1habcdefg

254 command_obj = group.get_command(ctx, command) 1habcdefg

255 assert command_obj 1habcdefg

256 use_prefix = "" 1habcdefg

257 if command_name: 1habcdefg

258 use_prefix += f"{command_name}" 1habcdefg

259 docs += get_docs_for_click( 1habcdefg

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

261 ) 

262 return docs 1habcdefg

263 

264 

265@utils_app.command() 1habcdefg

266def docs( 1abcdefg

267 ctx: typer.Context, 

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

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

270 None, 

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

272 file_okay=True, 

273 dir_okay=False, 

274 ), 

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

276 None, 

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

278 "the program is used.", 

279 ), 

280) -> None: 

281 """ 

282 Generate Markdown docs for a Typer app. 

283 """ 

284 typer_obj = get_typer_from_state() 1habcdefg

285 if not typer_obj: 1habcdefg

286 typer.echo("No Typer app found", err=True) 1habcdefg

287 raise typer.Abort() 1habcdefg

288 click_obj = typer.main.get_command(typer_obj) 1habcdefg

289 docs = get_docs_for_click(obj=click_obj, ctx=ctx, name=name, title=title) 1habcdefg

290 clean_docs = f"{docs.strip()}\n" 1habcdefg

291 if output: 1habcdefg

292 output.write_text(clean_docs) 1habcdefg

293 typer.echo(f"Docs saved to: {output}") 1habcdefg

294 else: 

295 typer.echo(clean_docs) 1habcdefg

296 

297 

298def main() -> Any: 1habcdefg

299 return app() 1habcdefg