Coverage for src/mactime/_cli_interface.py: 83%
147 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-02-24 19:31 +0100
« prev ^ index » next coverage.py v7.6.12, created at 2025-02-24 19:31 +0100
1from __future__ import annotations
3import dataclasses
4import logging
5import sys
6from abc import ABC
7from abc import abstractmethod
8from argparse import ArgumentParser, Namespace
9from argparse import RawDescriptionHelpFormatter
10from argparse import SUPPRESS
11from contextlib import contextmanager
12from dataclasses import Field
13from dataclasses import dataclass
14from dataclasses import field
15from inspect import cleandoc
16from typing import Any
17from typing import Literal
18from typing import Type, TypeVar, get_type_hints
19from mactime.errors import ArgumentsError
20from mactime.errors import MacTimeError
22from mactime.logger import logger
25T = TypeVar("T", bound="Command")
28class HelpfulParser(ArgumentParser):
29 def error(self, message: str):
30 print(f"{self.prog}: error: {message}\n", file=sys.stderr)
31 self.print_help(file=sys.stderr)
32 sys.exit(2)
35@contextmanager
36def handle_cli_exception(command: "Command"):
37 try:
38 yield
39 except Exception as e:
40 if not command.is_cli or command.verbose:
41 raise
43 if isinstance(e, MacTimeError):
44 message = str(e)
45 return_code = e.exit_code
46 else:
47 message = f"Unexpected error {e.__class__}: {str(e)}"
48 return_code = 1
50 sys.stderr.write(message)
51 sys.exit(return_code)
54def arg(
55 *flags: str,
56 default: Any = dataclasses.MISSING,
57 field_default: Any = dataclasses.MISSING, # if wasn't populated from argparse.Namespace
58 action: str | None = None,
59 nargs: str | int | Literal["?", "*", "+"] | None = None,
60 choices: set[str] | None = None,
61 suppress: bool = False,
62 help: str,
63) -> Any:
64 metadata = {
65 "help": help if not suppress else SUPPRESS,
66 "argument": list(flags) if flags else None,
67 }
69 if action is not None:
70 metadata["action"] = action
71 if nargs is not None:
72 metadata["nargs"] = nargs
73 if choices is not None:
74 metadata["choices"] = choices
76 if default is not dataclasses.MISSING:
77 metadata["default"] = default
79 if field_default is not dataclasses.MISSING:
80 default = field_default
82 return field(default=default, metadata=metadata, kw_only=True)
85@dataclass
86class Command(ABC):
87 """Base class for all CLI commands."""
89 verbose: int = arg(
90 "-v",
91 "--verbose",
92 action="count",
93 default=0,
94 field_default=None,
95 help="Increase output verbosity",
96 )
98 @property
99 def is_cli(self):
100 return self.verbose is not None
102 def __post_init__(self) -> None:
103 if self.verbose is None:
104 return None
106 log_levels = {
107 0: logging.WARNING,
108 1: logging.INFO, # -v
109 2: logging.DEBUG, # -vv
110 3: logging.DEBUG - 5, # -vvv (could add even more detailed debugging)
111 }
113 # Get the requested log level, defaulting to the most verbose if count is too high
114 level = log_levels.get(self.verbose, logging.DEBUG)
115 if level is not None:
116 logging.basicConfig(
117 level=level,
118 format="%(message)s"
119 if self.verbose <= 1
120 else "%(levelname)s [%(filename)s:%(lineno)d]: %(message)s",
121 )
123 @abstractmethod
124 def __call__(self) -> int:
125 raise NotImplementedError
127 @classmethod
128 def run(cls, argv: list[str] | None = None) -> int:
129 parser = HelpfulParser()
130 namespace = parser.parse_args(argv)
131 cls.populate_arguments(parser)
133 try:
134 command = cls.from_namespace(namespace)
135 except ArgumentsError as e:
136 parser.error(str(e))
138 with handle_cli_exception(command):
139 command()
141 return 0
143 @classmethod
144 def from_namespace(cls: Type[T], namespace: Namespace) -> T:
145 fields = cls.__dataclass_fields__
146 values = {
147 name: value for name, value in namespace.__dict__.items() if name in fields
148 }
149 return cls(**values) # add custom validation in __post_init__
151 @classmethod
152 def populate_arguments(cls, parser: ArgumentParser) -> None:
153 """
154 :param parser: subcommand parser instance to set arguments for
155 """
157 fields: dict[str, Field] = cls.__dataclass_fields__.copy()
158 annotations = get_type_hints(cls)
159 for field_name, annotation in annotations.items():
160 try:
161 field_info = fields.pop(field_name)
162 except KeyError:
163 continue
165 metadata = field_info.metadata
167 if not field_info.init: # pragma: no cover
168 if metadata:
169 raise TypeError(
170 f"{field_name} on {cls} has argument metadata but has is no-init."
171 )
172 continue
174 if not metadata:
175 if (
176 field_info.default is dataclasses.MISSING
177 and field_info.default_factory is dataclasses.MISSING
178 ): # pragma: no cover
179 raise TypeError(
180 f"{field_name} on {cls} is missing argument metadata and default value."
181 )
182 continue
184 kwargs = {"help": metadata["help"]}
186 if annotation is bool and "action" not in metadata:
187 kwargs["action"] = "store_true"
188 elif "action" in metadata:
189 kwargs["action"] = metadata["action"]
191 if metadata.get("default") is not None:
192 kwargs["default"] = metadata["default"]
193 if "nargs" in metadata:
194 kwargs["nargs"] = metadata["nargs"]
195 if "choices" in metadata:
196 kwargs["choices"] = metadata["choices"]
198 if flags := metadata.get("argument"):
199 first, *rest = flags
200 if not rest and len(first) == 2:
201 flags.append("--" + field_name.replace("_", "-"))
202 args = flags
203 if "choices" not in kwargs and kwargs.get("action") not in {"store_true", "count"}:
204 kwargs["metavar"] = f"<{field_name}>"
205 else:
206 args = (field_name,)
207 try:
208 parser.add_argument(*args, **kwargs)
209 except Exception as e: # pragma: no cover
210 raise TypeError(f"Failed to populate arguments for {cls}") from e
212 if fields: # pragma: no cover
213 raise TypeError(f"{list(fields)} on {cls.__name__} must have annotations")
216class CLI(ABC):
217 """Base class for CLI applications."""
219 @classmethod
220 def run(cls, argv: list[str] | None = None) -> int:
221 parser, subparsers = cls.create_parser()
222 namespace = parser.parse_args(argv)
223 command_cls: Command = getattr(cls, namespace.command)
224 try:
225 command = command_cls.from_namespace(namespace)
226 except ArgumentsError as e:
227 # ugly and brittle, but I just want this to work at the moment.
228 # less brittle strategy would be assign a classvar on each command
229 subparsers[namespace.command].error(str(e))
231 with handle_cli_exception(command):
232 command()
234 return 0
236 @classmethod
237 def create_parser(
238 cls, command: str | None = None
239 ) -> tuple[HelpfulParser, dict[str, HelpfulParser]]:
240 parser = HelpfulParser(
241 description=cls.__doc__ and cleandoc(cls.__doc__).split("\n")[0],
242 )
244 subparsers_map = {}
245 subparsers = parser.add_subparsers(
246 parser_class=HelpfulParser,
247 dest="command",
248 required=True,
249 title="commands",
250 )
252 for name, attr in cls.__dict__.items():
253 if not isinstance(attr, type) or not issubclass(attr, Command):
254 continue
256 command_cls = attr
257 doc = cleandoc(command_cls.__doc__)
258 help_text, *desc_lines = doc.splitlines(True)
259 subparsers_map[name] = subparser = subparsers.add_parser(
260 name,
261 help=help_text,
262 description=doc,
263 formatter_class=RawDescriptionHelpFormatter,
264 )
265 command_cls.populate_arguments(subparser)
266 command_cls._active_parser = subparser
268 return parser, subparsers_map