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

1from __future__ import annotations 

2 

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 

21 

22from mactime.logger import logger 

23 

24 

25T = TypeVar("T", bound="Command") 

26 

27 

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) 

33 

34 

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 

42 

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 

49 

50 sys.stderr.write(message) 

51 sys.exit(return_code) 

52 

53 

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 } 

68 

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 

75 

76 if default is not dataclasses.MISSING: 

77 metadata["default"] = default 

78 

79 if field_default is not dataclasses.MISSING: 

80 default = field_default 

81 

82 return field(default=default, metadata=metadata, kw_only=True) 

83 

84 

85@dataclass 

86class Command(ABC): 

87 """Base class for all CLI commands.""" 

88 

89 verbose: int = arg( 

90 "-v", 

91 "--verbose", 

92 action="count", 

93 default=0, 

94 field_default=None, 

95 help="Increase output verbosity", 

96 ) 

97 

98 @property 

99 def is_cli(self): 

100 return self.verbose is not None 

101 

102 def __post_init__(self) -> None: 

103 if self.verbose is None: 

104 return None 

105 

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 } 

112 

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 ) 

122 

123 @abstractmethod 

124 def __call__(self) -> int: 

125 raise NotImplementedError 

126 

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) 

132 

133 try: 

134 command = cls.from_namespace(namespace) 

135 except ArgumentsError as e: 

136 parser.error(str(e)) 

137 

138 with handle_cli_exception(command): 

139 command() 

140 

141 return 0 

142 

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__ 

150 

151 @classmethod 

152 def populate_arguments(cls, parser: ArgumentParser) -> None: 

153 """ 

154 :param parser: subcommand parser instance to set arguments for 

155 """ 

156 

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 

164 

165 metadata = field_info.metadata 

166 

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 

173 

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 

183 

184 kwargs = {"help": metadata["help"]} 

185 

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"] 

190 

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"] 

197 

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 

211 

212 if fields: # pragma: no cover 

213 raise TypeError(f"{list(fields)} on {cls.__name__} must have annotations") 

214 

215 

216class CLI(ABC): 

217 """Base class for CLI applications.""" 

218 

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)) 

230 

231 with handle_cli_exception(command): 

232 command() 

233 

234 return 0 

235 

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 ) 

243 

244 subparsers_map = {} 

245 subparsers = parser.add_subparsers( 

246 parser_class=HelpfulParser, 

247 dest="command", 

248 required=True, 

249 title="commands", 

250 ) 

251 

252 for name, attr in cls.__dict__.items(): 

253 if not isinstance(attr, type) or not issubclass(attr, Command): 

254 continue 

255 

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 

267 

268 return parser, subparsers_map