1#!./runmodule.sh

2

3'''

4 This module contains classes and functions that :mod:`confattr` uses internally but which might be useful for other python projects, too.

5'''

6

7 import re

8 import argparse

9 import inspect

10 import textwrap

11 import shlex

12 import functools

13 import enum

14 import typing

15 from collections.abc import Sequence, Callable

16

17 if typing.TYPE_CHECKING:

18 from typing_extensions import Unpack, Self

19

20

21# ---------- shlex quote ----------

22

23 def readable_quote(value: str) -> str:

24 '''

25 This function has the same goal like :func:`shlex.quote` but tries to generate better readable output.

26

27 :param value: A value which is intended to be used as a command line argument

28 :return: A POSIX compliant quoted version of :paramref:`~confattr.utils.readable_quote.value`

29 '''

30 out = shlex.quote(value)

31 if out == value:

32 return out

33

34 if '"\'"' in out and '"' not in value:

35 return '"' + value + '"'

36

37 return out

38

39

40# ---------- sorted enum ----------

41

42@functools.total_ordering

43 class SortedEnum(enum.Enum):

44

45 '''

46 By default it is assumed that the values are defined in ascending order ``ONE='one'; TWO='two'; THREE='three'``.

47 If you want to define them in descending order ``THREE='three'; TWO='two'; ONE='one'`` you can pass ``descending = True`` to the subclass.

48 This requires Python 3.10.0a4 or newer.

49 On older versions it causes a ``TypeError: __prepare__() got an unexpected keyword argument 'descending'``.

50 This was fixed in `commit 6ec0adefad <https://github.com/python/cpython/commit/6ec0adefad60ec7cdec61c44baecf1dccc1461ab>`__.

51 '''

52

53 descending: bool

54

55 @classmethod

56 def __init_subclass__(cls, descending: bool = False):

57 cls.descending = descending

58

59 def __lt__(self, other: typing.Any) -> bool:

60 if self.__class__ is other.__class__:

61 l: 'tuple[SortedEnum, ...]' = tuple(type(self))

62 if self.descending:

63 left = other

64 right = self

65 else:

66 left = self

67 right = other

68 return l.index(left) < l.index(right)

69 return NotImplemented

70

71 def __add__(self, other: object) -> 'Self':

72 if isinstance(other, int):

73 l: 'tuple[Self, ...]' = tuple(type(self))

74 i = l.index(self)

75 if self.descending:

76 other = -other

77 i += other

78 if i < 0:

79 i = 0

80 elif i >= len(l):

81 i = len(l) - 1

82 return l[i]

83 return NotImplemented

84

85 def __sub__(self, other: object) -> 'Self':

86 if isinstance(other, int):

87 return self + (-other)

88 return NotImplemented

89

90

91

92# ---------- argparse help formatter ----------

93

94 class HelpFormatter(argparse.RawDescriptionHelpFormatter):

95

96 '''

97 A subclass of :class:`argparse.HelpFormatter` which keeps paragraphs

98 separated by an empty line as separate paragraphs and

99 and which does *not* merge different list items to a single line.

100

101 Lines are wrapped to not exceed a length of :attr:`~confattr.utils.HelpFormatter.max_width` characters,

102 although not strictly to prevent URLs from breaking.

103

104 If a line ends with a double backslash this line will not be merged with the following line

105 and the double backslash (and spaces directly before it) will be removed.

106

107 As the doc string of :class:`argparse.HelpFormatter` states

108

109 Only the name of this class is considered a public API.

110 All the methods provided by the class are considered an implementation detail.

111

112 Therefore I may be forced to change the methods' signatures if :class:`argparse.HelpFormatter` is changed.

113 But I hope that I can keep the class attributes backward compatible so that you can create your own formatter class

114 by subclassing this class and changing the values of the class variables.

115

116 If you want to use this class without an :class:`argparse.ArgumentParser` pass it to the constructor of :class:`~confattr.utils.HelpFormatterWrapper` and use that instead.

117 '''

118

119 #: Wrap lines so that they are no longer than this number of characters.

120 max_width = 70

121

122 #: This value is assigned to :attr:`textwrap.TextWrapper.break_long_words`. This defaults to False to prevent URLs from breaking.

123 break_long_words = False

124

125 #: This value is assigned to :attr:`textwrap.TextWrapper.break_on_hyphens`. This defaults to False to prevent URLs from breaking.

126 break_on_hyphens = False

127

128 #: If a match is found this line is not merged with the following and the match is removed. This may *not* contain any capturing groups.

129 regex_linebreak = re.escape(r'\\') + '(?:\n|$)'

130

131 #: If a match is found this line is not merged with the preceeding line. This regular expression must contain exactly one capturing group. This group defines the indentation. Everything that is matched but not part of that group is removed.

132 regex_list_item = '(?:^|\n)' + r'(\s*(?:[-+*!/.]|[0-9]+[.)])(?: \[[ x~]\])? )'

133

134 def __init__(self,

135 prog: str,

136 indent_increment: int = 2,

137 max_help_position: int = 24,

138 width: 'int|None' = None,

139 ) -> None:

140 '''

141 :param prog: The name of the program

142 :param width: Wrap lines so that they are no longer than this number of characters. If this value is None or bigger than :attr:`~confattr.utils.HelpFormatter.max_width` then :attr:`~confattr.utils.HelpFormatter.max_width` is used instead.

143 '''

144 if width is None or width >= self.max_width:

145 width = self.max_width

146 super().__init__(prog, indent_increment, max_help_position, width)

147

148

149 # ------- override methods of parent class -------

150

151 def _fill_text(self, text: str, width: int, indent: str) -> str:

152 '''

153 This method joins the lines returned by :meth:`~confattr.utils.HelpFormatter._split_lines`.

154

155 This method is used to format text blocks such as the description.

156 It is *not* used to format the help of arguments—see :meth:`~confattr.utils.HelpFormatter._split_lines` for that.

157 '''

158 return '\n'.join(self._split_lines(text, width, indent=indent))

159

160 def _split_lines(self, text: str, width: int, *, indent: str = '') -> 'list[str]':

161 '''

162 This method cleans :paramref:`~confattr.utils.HelpFormatter._split_lines.text` with :func:`inspect.cleandoc` and

163 wraps the lines with :meth:`textwrap.TextWrapper.wrap`.

164 Paragraphs separated by an empty line are kept as separate paragraphs.

165

166 This method is used to format the help of arguments and

167 indirectly through :meth:`~confattr.utils.HelpFormatter._fill_text` to format text blocks such as description.

168

169 :param text: The text to be formatted

170 :param width: The maximum width of the resulting lines (Depending on the values of :attr:`~confattr.utils.HelpFormatter.break_long_words` and :attr:`~confattr.utils.HelpFormatter.break_on_hyphens` this width can be exceeded in order to not break URLs.)

171 :param indent: A str to be prepended to all lines. The original :class:`argparse.HelpFormatter` does not have this parameter, I have added it so that I can use this method in :meth:`~confattr.utils.HelpFormatter._fill_text`.

172 '''

173 lines = []

174 # The original implementation does not use cleandoc

175 # it simply gets rid of all indentation and line breaks with

176 # self._whitespace_matcher.sub(' ', text).strip()

177 # https://github.com/python/cpython/blob/main/Lib/argparse.py

178 text = inspect.cleandoc(text)

179 wrapper = textwrap.TextWrapper(width=width,

180 break_long_words=self.break_long_words, break_on_hyphens=self.break_on_hyphens)

181 for par in re.split('\n\\s*\n', text):

182 for ln in re.split(self.regex_linebreak, par):

183 wrapper.initial_indent = indent

184 wrapper.subsequent_indent = indent

185 pre_bullet_items = re.split(self.regex_list_item, ln)

186 lines.extend(wrapper.wrap(pre_bullet_items[0]))

187 for i in range(1, len(pre_bullet_items), 2):

188 bullet = pre_bullet_items[i]

189 item = pre_bullet_items[i+1]

190 add_indent = ' ' * len(bullet)

191 wrapper.initial_indent = indent + bullet

192 wrapper.subsequent_indent = indent + add_indent

193 item = item.replace('\n'+add_indent, '\n')

194 lines.extend(wrapper.wrap(item))

195 lines.append('')

196

197 lines = lines[:-1]

198

199 return lines

200

201

202 if typing.TYPE_CHECKING:

203 class HelpFormatterKwargs(typing.TypedDict, total=False):

204 prog: str

205 indent_increment: int

206 max_help_position: int

207 width: int

208

209

210 class HelpFormatterWrapper:

211

212 '''

213 The doc string of :class:`argparse.HelpFormatter` states:

214

215 Only the name of this class is considered a public API.

216 All the methods provided by the class are considered an implementation detail.

217

218 This is a wrapper which tries to stay backward compatible even if :class:`argparse.HelpFormatter` changes.

219 '''

220

221 def __init__(self, formatter_class: 'type[argparse.HelpFormatter]', **kw: 'Unpack[HelpFormatterKwargs]') -> None:

222 '''

223 :param formatter_class: :class:`argparse.HelpFormatter` or any of it's subclasses (:class:`argparse.RawDescriptionHelpFormatter`, :class:`argparse.RawTextHelpFormatter`, :class:`argparse.ArgumentDefaultsHelpFormatter`, :class:`argparse.MetavarTypeHelpFormatter` or :class:`~confattr.utils.HelpFormatter`)

224 :param prog: The name of the program

225 :param indent_increment: The number of spaces by which to indent the contents of a section

226 :param max_help_position: The maximal indentation of the help of arguments. If argument names + meta vars + separators are longer than this the help starts on the next line.

227 :param width: Maximal number of characters per line

228 '''

229 kw.setdefault('prog', '')

230 self.formatter = formatter_class(**kw)

231

232

233 # ------- format directly -------

234

235 def format_text(self, text: str) -> str:

236 '''

237 Format a text and return it immediately without adding it to :meth:`~confattr.utils.HelpFormatterWrapper.format_help`.

238 '''

239 return self.formatter._format_text(text)

240

241 def format_item(self, bullet: str, text: str) -> str:

242 '''

243 Format a list item and return it immediately without adding it to :meth:`~confattr.utils.HelpFormatterWrapper.format_help`.

244 '''

245 # apply section indentation

246 bullet = ' ' * self.formatter._current_indent + bullet

247 width = max(self.formatter._width - self.formatter._current_indent, 11)

248

249 # _fill_text does not distinguish between textwrap's initial_indent and subsequent_indent

250 # instead I am using bullet for both and then replace the bullet with whitespace on all but the first line

251 text = self.formatter._fill_text(text, width, bullet)

252 pattern_bullet = '(?<=\n)' + re.escape(bullet)

253 indent = ' ' * len(bullet)

254 text = re.sub(pattern_bullet, indent, text)

255 return text + '\n'

256

257

258 # ------- input -------

259

260 def add_start_section(self, heading: str) -> None:

261 '''

262 Start a new section.

263

264 This influences the formatting of following calls to :meth:`~confattr.utils.HelpFormatterWrapper.add_text` and :meth:`~confattr.utils.HelpFormatterWrapper.add_item`.

265

266 You can call this method again before calling :meth:`~confattr.utils.HelpFormatterWrapper.add_end_section` to create a subsection.

267 '''

268 self.formatter.start_section(heading)

269

270 def add_end_section(self) -> None:

271 '''

272 End the last section which has been started with :meth:`~confattr.utils.HelpFormatterWrapper.add_start_section`.

273 '''

274 self.formatter.end_section()

275

276 def add_text(self, text: str) -> None:

277 '''

278 Add some text which will be formatted when calling :meth:`~confattr.utils.HelpFormatterWrapper.format_help`.

279 '''

280 self.formatter.add_text(text)

281

282 def add_start_list(self) -> None:

283 '''

284 Start a new list which can be filled with :meth:`~confattr.utils.HelpFormatterWrapper.add_item`.

285 '''

286 # nothing to do, this exists only as counter piece for add_end_list

287

288 def add_item(self, text: str, bullet: str = '- ') -> None:

289 '''

290 Add a list item which will be formatted when calling :meth:`~confattr.utils.HelpFormatterWrapper.format_help`.

291 A list must be started with :meth:`~confattr.utils.HelpFormatterWrapper.add_start_list` and ended with :meth:`~confattr.utils.HelpFormatterWrapper.add_end_list`.

292 '''

293 self.formatter._add_item(self.format_item, (bullet, text))

294

295 def add_end_list(self) -> None:

296 '''

297 End a list. This must be called after the last :meth:`~confattr.utils.HelpFormatterWrapper.add_item`.

298 '''

299 def identity(x: str) -> str:

300 return x

301 self.formatter._add_item(identity, ('\n',))

302

303 # ------- output -------

304

305 def format_help(self) -> str:

306 '''

307 Format everything that has been added with :meth:`~confattr.utils.HelpFormatterWrapper.add_start_section`, :meth:`~confattr.utils.HelpFormatterWrapper.add_text` and :meth:`~confattr.utils.HelpFormatterWrapper.add_item`.

308 '''

309 return self.formatter.format_help()

310

311

312# ---------- argparse actions ----------

313

314 class CallAction(argparse.Action):

315

316 def __init__(self, option_strings: 'Sequence[str]', dest: str, callback: 'Callable[[], None]', help: 'str|None' = None, nargs: 'int|str' = 0) -> None:

317 if help is None:

318 if callback.__doc__ is None:

319 raise TypeError("missing doc string for function %s" % callback.__name__)

320 help = callback.__doc__.strip()

321 argparse.Action.__init__(self, option_strings, dest, nargs=nargs, help=help)

322 self.callback = callback

323

324 def __call__(self, parser: argparse.ArgumentParser, namespace: argparse.Namespace, values: object, option_string: 'str|None' = None) -> None:

325 self.callback(*values)