Coverage for src/duty/cli.py: 98.67%

120 statements  

« prev     ^ index     » next       coverage.py v7.6.3, created at 2024-10-17 17:18 +0200

1"""Module that contains the command line application.""" 

2 

3# Why does this file exist, and why not put this in `__main__`? 

4# 

5# You might be tempted to import things from `__main__` later, 

6# but that will cause problems: the code will get executed twice: 

7# 

8# - When you run `python -m duty` python will execute 

9# `__main__.py` as a script. That means there won't be any 

10# `duty.__main__` in `sys.modules`. 

11# - When you import `__main__` it will get executed again (as a module) because 

12# there's no `duty.__main__` in `sys.modules`. 

13 

14from __future__ import annotations 

15 

16import argparse 

17import inspect 

18import sys 

19import textwrap 

20from typing import Any 

21 

22from failprint.cli import ArgParser, add_flags 

23 

24from duty import debug 

25from duty.collection import Collection, Duty 

26from duty.exceptions import DutyFailure 

27from duty.validation import validate 

28 

29empty = inspect.Signature.empty 

30 

31 

32class _DebugInfo(argparse.Action): 

33 def __init__(self, nargs: int | str | None = 0, **kwargs: Any) -> None: 

34 super().__init__(nargs=nargs, **kwargs) 

35 

36 def __call__(self, *args: Any, **kwargs: Any) -> None: # noqa: ARG002 

37 debug.print_debug_info() 

38 sys.exit(0) 

39 

40 

41def get_parser() -> ArgParser: 

42 """Return the CLI argument parser. 

43 

44 Returns: 

45 An argparse parser. 

46 """ 

47 usage = "duty [GLOBAL_OPTS...] [DUTY [DUTY_OPTS...] [DUTY_PARAMS...]...]" 

48 description = "A simple task runner." 

49 parser = ArgParser(add_help=False, usage=usage, description=description) 

50 

51 parser.add_argument( 

52 "-d", 

53 "--duties-file", 

54 default="duties.py", 

55 help="Python file where the duties are defined.", 

56 ) 

57 parser.add_argument( 

58 "-l", 

59 "--list", 

60 action="store_true", 

61 dest="list", 

62 help="List the available duties.", 

63 ) 

64 parser.add_argument( 

65 "-h", 

66 "--help", 

67 dest="help", 

68 nargs="*", 

69 metavar="DUTY", 

70 help="Show this help message and exit. Pass duties names to print their help.", 

71 ) 

72 parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {debug.get_version()}") 

73 parser.add_argument("--debug-info", action=_DebugInfo, help="Print debug information.") 

74 

75 add_flags(parser, set_defaults=False) 

76 parser.add_argument("remainder", nargs=argparse.REMAINDER) 

77 

78 parser._optionals.title = "Global options" 

79 

80 return parser 

81 

82 

83def split_args(args: list[str], names: list[str]) -> list[list[str]]: 

84 """Split command line arguments into duty commands. 

85 

86 Parameters: 

87 args: The CLI arguments. 

88 names: The known duty names. 

89 

90 Raises: 

91 ValueError: When a duty name is missing before an argument, 

92 or when the duty name is unknown. 

93 

94 Returns: 

95 The split commands. 

96 """ 

97 arg_lists = [] 

98 current_arg_list: list[str] = [] 

99 

100 for arg in args: 

101 if arg in names: 

102 # We found a duty name. 

103 if current_arg_list: 

104 # Append the previous arg list to the result and reset it. 

105 arg_lists.append(current_arg_list) 

106 current_arg_list = [] 

107 current_arg_list.append(arg) 

108 elif current_arg_list: 

109 # We found an argument. 

110 current_arg_list.append(arg) 

111 else: 

112 # We found an argument but no duty name. 

113 raise ValueError(f"> Missing duty name before argument '{arg}', or unknown duty name") 

114 

115 # Don't forget the last arg list. 

116 if current_arg_list: 

117 arg_lists.append(current_arg_list) 

118 

119 return arg_lists 

120 

121 

122def get_duty_parser(duty: Duty) -> ArgParser: 

123 """Get a duty-specific options parser. 

124 

125 Parameters: 

126 duty: The duty to parse for. 

127 

128 Returns: 

129 A duty-specific parser. 

130 """ 

131 parser = ArgParser( 

132 prog=f"duty {duty.name}", 

133 add_help=False, 

134 description=duty.description, 

135 formatter_class=argparse.RawDescriptionHelpFormatter, 

136 ) 

137 add_flags(parser, set_defaults=False) 

138 return parser 

139 

140 

141def specified_options(opts: argparse.Namespace, exclude: set[str] | None = None) -> dict: 

142 """Cast an argparse Namespace into a dictionary of options. 

143 

144 Remove all options that were not specified (equal to None). 

145 

146 Parameters: 

147 opts: The namespace to cast. 

148 exclude: Names of options to exclude from the result. 

149 

150 Returns: 

151 A dictionary of specified-only options. 

152 """ 

153 exclude = exclude or set() 

154 options = opts.__dict__.items() 

155 return {opt: value for opt, value in options if value is not None and opt not in exclude} 

156 

157 

158def parse_options(duty: Duty, args: list[str]) -> tuple[dict, list[str]]: 

159 """Parse options for a duty. 

160 

161 Parameters: 

162 duty: The duty to parse for. 

163 args: The CLI args passed for this duty. 

164 

165 Returns: 

166 The parsed opts, and the remaining arguments. 

167 """ 

168 parser = get_duty_parser(duty) 

169 opts, remainder = parser.parse_known_args(args) 

170 return specified_options(opts), remainder 

171 

172 

173def parse_args(duty: Duty, args: list[str]) -> tuple: 

174 """Parse the positional and keyword arguments of a duty. 

175 

176 Parameters: 

177 duty: The duty to parse for. 

178 args: The list of arguments. 

179 

180 Returns: 

181 The positional and keyword arguments. 

182 """ 

183 posargs = [] 

184 kwargs = {} 

185 

186 for arg in args: 

187 if "=" in arg: 

188 # we found a keyword argument 

189 arg_name, arg_value = arg.split("=", 1) 

190 kwargs[arg_name] = arg_value 

191 else: 

192 # we found a positional argument 

193 posargs.append(arg) 

194 

195 return validate(duty.function, *posargs, **kwargs) 

196 

197 

198def parse_commands(arg_lists: list[list[str]], global_opts: dict[str, Any], collection: Collection) -> list[tuple]: 

199 """Parse argument lists into ready-to-run duties. 

200 

201 Parameters: 

202 arg_lists: Lists of arguments lists. 

203 global_opts: The global options. 

204 collection: The duties collection. 

205 

206 Returns: 

207 A list of tuples composed of: 

208 

209 - a duty 

210 - its positional arguments 

211 - its keyword arguments 

212 """ 

213 commands = [] 

214 for arg_list in arg_lists: 

215 duty = collection.get(arg_list[0]) 

216 opts, remainder = parse_options(duty, arg_list[1:]) 

217 if remainder and remainder[0] == "--": 217 ↛ 218line 217 didn't jump to line 218 because the condition on line 217 was never true

218 remainder = remainder[1:] 

219 duty.options_override = {**global_opts, **opts} 

220 commands.append((duty, *parse_args(duty, remainder))) 

221 return commands 

222 

223 

224def print_help(parser: ArgParser, opts: argparse.Namespace, collection: Collection) -> None: 

225 """Print general help or duties help. 

226 

227 Parameters: 

228 parser: The main parser. 

229 opts: The main parsed options. 

230 collection: A collection of duties. 

231 """ 

232 if opts.help: 

233 for duty_name in opts.help: 

234 try: 

235 duty = collection.get(duty_name) 

236 except KeyError: 

237 print(f"> Unknown duty '{duty_name}'") 

238 else: 

239 print(get_duty_parser(duty).format_help()) 

240 else: 

241 print(parser.format_help()) 

242 print("Available duties:") 

243 print(textwrap.indent(collection.format_help(), prefix=" ")) 

244 

245 

246def main(args: list[str] | None = None) -> int: 

247 """Run the main program. 

248 

249 This function is executed when you type `duty` or `python -m duty`. 

250 

251 Parameters: 

252 args: Arguments passed from the command line. 

253 

254 Returns: 

255 An exit code. 

256 """ 

257 parser = get_parser() 

258 opts = parser.parse_args(args=args) 

259 remainder = opts.remainder 

260 

261 collection = Collection(opts.duties_file) 

262 collection.load() 

263 

264 if opts.help is not None: 

265 print_help(parser, opts, collection) 

266 return 0 

267 

268 if opts.list: 

269 print(textwrap.indent(collection.format_help(), prefix=" ")) 

270 return 0 

271 

272 try: 

273 arg_lists = split_args(remainder, collection.names()) 

274 except ValueError as error: 

275 print(error, file=sys.stderr) 

276 return 1 

277 

278 if not arg_lists: 

279 print_help(parser, opts, collection) 

280 return 1 

281 

282 global_opts = specified_options(opts, exclude={"duties_file", "list", "help", "remainder"}) 

283 try: 

284 commands = parse_commands(arg_lists, global_opts, collection) 

285 except TypeError as error: 

286 print(f"> {error}", file=sys.stderr) 

287 return 1 

288 

289 for duty, posargs, kwargs in commands: 

290 try: 

291 duty.run(*posargs, **kwargs) 

292 except DutyFailure as failure: 

293 return failure.code 

294 

295 return 0