Coverage for src/hatch_ci/cli.py: 0%

1 from __future__ import annotations

2

3 import argparse

4 import functools

5 import logging

6 import sys

7 from typing import Any, Callable, Protocol

8

9 from . import tools

10

11

12 class ErrorFn(Protocol):

13 def __call__(self, message: str, explain: str | None, hint: str | None) -> None:

14 ...

15

16

17 class AbortExecutionError(Exception):

18 @staticmethod

19 def _strip(txt):

20 txt = txt or ""

21 txt = txt[1:] if txt.startswith("\n") else txt

22 txt = tools.indent(txt, pre="")

23 return txt[:-1] if txt.endswith("\n") else txt

24

25 def __init__(

26 self,

27 message: str,

28 explain: str | None = None,

29 hint: str | None = None,

30 usage: str | None = None,

31 ):

32 self.message = message.strip()

33 self.explain = explain

34 self.hint = hint

35 self.usage = usage

36

37 def __str__(self):

38 out = []

39 if self.usage:

40 out.extend(self.usage.strip().split("\n"))

41 if self.message:

42 out.extend(self._strip(self.message).split("\n"))

43 if self.explain:

44 out.append("reason:")

45 out.extend(tools.indent(self.explain).split("\n"))

46 if self.hint:

47 out.append("hint:")

48 out.extend(tools.indent(self.hint).split("\n"))

49 return "\n".join((line.strip() if not line.strip() else line) for line in out)

50

51

52 def _add_arguments(

53 parser: argparse.ArgumentParser,

54) -> None:

55 """parses args from the command line

56

57 Args:

58 args: command line arguments or None to pull from sys.argv

59 doc: text to use in cli description

60 """

61 parser.add_argument("-n", "--dry-run", dest="dryrun", action="store_true")

62 parser.add_argument("-v", "--verbose", action="store_true")

63

64

65 def _process_options(

66 options: argparse.Namespace, errorfn: ErrorFn

67) -> argparse.Namespace | None:

68 logging.basicConfig(

69 format="%(levelname)s:%(name)s:(dry-run) %(message)s"

70 if options.dryrun

71 else "%(levelname)s:%(name)s:%(message)s",

72 level=logging.DEBUG if options.verbose else logging.INFO,

73 )

74

75 for d in [

76 "verbose",

77 ]:

78 delattr(options, d)

79 return options

80

81

82 def cli(

83 add_arguments: Callable[[argparse.ArgumentParser], None] | None = None,

84 process_options: Callable[[argparse.Namespace, ErrorFn], argparse.Namespace | None]

85 | None = None,

86 doc: str | None = None,

87):

88 @functools.wraps(cli)

89 def _fn(main: Callable[[argparse.Namespace], Any]):

90 @functools.wraps(main)

91 def _fn1(args: None | list[str] = None) -> Any:

92 try:

93

94 class ParserFormatter(

95 argparse.ArgumentDefaultsHelpFormatter,

96 argparse.RawDescriptionHelpFormatter,

97 ):

98 pass

99

100 description, _, epilog = (doc or "").partition("\n")

101 parser = argparse.ArgumentParser(

102 formatter_class=ParserFormatter,

103 description=description,

104 epilog=epilog,

105 )

106 _add_arguments(parser)

107 if add_arguments:

108 add_arguments(parser)

109

110 options = parser.parse_args(args=args)

111

112 def error(

113 message: str,

114 explain: str = "",

115 hint: str = "",

116 usage: str | None = None,

117 ):

118 raise AbortExecutionError(message, explain, hint, usage)

119

120 errorfn: ErrorFn = functools.partial(error, usage=parser.format_usage())

121 options.error = errorfn

122

123 options = _process_options(options, errorfn) or options

124 if process_options:

125 options = process_options(options, errorfn) or options

126

127 return main(options)

128 except AbortExecutionError as err:

129 print(str(err), file=sys.stderr) # noqa: T201

130 raise SystemExit(2) from None

131 except Exception:

132 raise

133

134 return _fn1

135

136 return _fn