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
« prev ^ index » next coverage.py v7.6.3, created at 2024-10-17 17:18 +0200
1"""Module that contains the command line application."""
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`.
14from __future__ import annotations
16import argparse
17import inspect
18import sys
19import textwrap
20from typing import Any
22from failprint.cli import ArgParser, add_flags
24from duty import debug
25from duty.collection import Collection, Duty
26from duty.exceptions import DutyFailure
27from duty.validation import validate
29empty = inspect.Signature.empty
32class _DebugInfo(argparse.Action):
33 def __init__(self, nargs: int | str | None = 0, **kwargs: Any) -> None:
34 super().__init__(nargs=nargs, **kwargs)
36 def __call__(self, *args: Any, **kwargs: Any) -> None: # noqa: ARG002
37 debug.print_debug_info()
38 sys.exit(0)
41def get_parser() -> ArgParser:
42 """Return the CLI argument parser.
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)
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.")
75 add_flags(parser, set_defaults=False)
76 parser.add_argument("remainder", nargs=argparse.REMAINDER)
78 parser._optionals.title = "Global options"
80 return parser
83def split_args(args: list[str], names: list[str]) -> list[list[str]]:
84 """Split command line arguments into duty commands.
86 Parameters:
87 args: The CLI arguments.
88 names: The known duty names.
90 Raises:
91 ValueError: When a duty name is missing before an argument,
92 or when the duty name is unknown.
94 Returns:
95 The split commands.
96 """
97 arg_lists = []
98 current_arg_list: list[str] = []
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")
115 # Don't forget the last arg list.
116 if current_arg_list:
117 arg_lists.append(current_arg_list)
119 return arg_lists
122def get_duty_parser(duty: Duty) -> ArgParser:
123 """Get a duty-specific options parser.
125 Parameters:
126 duty: The duty to parse for.
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
141def specified_options(opts: argparse.Namespace, exclude: set[str] | None = None) -> dict:
142 """Cast an argparse Namespace into a dictionary of options.
144 Remove all options that were not specified (equal to None).
146 Parameters:
147 opts: The namespace to cast.
148 exclude: Names of options to exclude from the result.
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}
158def parse_options(duty: Duty, args: list[str]) -> tuple[dict, list[str]]:
159 """Parse options for a duty.
161 Parameters:
162 duty: The duty to parse for.
163 args: The CLI args passed for this duty.
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
173def parse_args(duty: Duty, args: list[str]) -> tuple:
174 """Parse the positional and keyword arguments of a duty.
176 Parameters:
177 duty: The duty to parse for.
178 args: The list of arguments.
180 Returns:
181 The positional and keyword arguments.
182 """
183 posargs = []
184 kwargs = {}
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)
195 return validate(duty.function, *posargs, **kwargs)
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.
201 Parameters:
202 arg_lists: Lists of arguments lists.
203 global_opts: The global options.
204 collection: The duties collection.
206 Returns:
207 A list of tuples composed of:
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
224def print_help(parser: ArgParser, opts: argparse.Namespace, collection: Collection) -> None:
225 """Print general help or duties help.
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=" "))
246def main(args: list[str] | None = None) -> int:
247 """Run the main program.
249 This function is executed when you type `duty` or `python -m duty`.
251 Parameters:
252 args: Arguments passed from the command line.
254 Returns:
255 An exit code.
256 """
257 parser = get_parser()
258 opts = parser.parse_args(args=args)
259 remainder = opts.remainder
261 collection = Collection(opts.duties_file)
262 collection.load()
264 if opts.help is not None:
265 print_help(parser, opts, collection)
266 return 0
268 if opts.list:
269 print(textwrap.indent(collection.format_help(), prefix=" "))
270 return 0
272 try:
273 arg_lists = split_args(remainder, collection.names())
274 except ValueError as error:
275 print(error, file=sys.stderr)
276 return 1
278 if not arg_lists:
279 print_help(parser, opts, collection)
280 return 1
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
289 for duty, posargs, kwargs in commands:
290 try:
291 duty.run(*posargs, **kwargs)
292 except DutyFailure as failure:
293 return failure.code
295 return 0