Coverage for .tox/cov/lib/python3.11/site-packages/confattr/utils.py: 100%

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

16 if typing.TYPE_CHECKING:

17 from typing_extensions import Unpack, Self

18

19

20# ---------- shlex quote ----------

21

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

23 '''

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

25

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

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

28 '''

29 out = shlex.quote(value)

30 if out == value:

31 return out

32

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

34 return '"' + value + '"'

35

36 return out

37

38

39# ---------- sorted enum ----------

40

41@functools.total_ordering

42 class SortedEnum(enum.Enum):

43

44 '''

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

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

47 This requires Python 3.10.0a4 or newer.

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

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

50 '''

51

52 descending: bool

53

54 @classmethod

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

56 cls.descending = descending

57

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

59 if self.__class__ is other.__class__:

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

61 if self.descending:

62 left = other

63 right = self

64 else:

65 left = self

66 right = other

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

68 return NotImplemented

69

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

71 if isinstance(other, int):

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

73 i = l.index(self)

74 if self.descending:

75 other = -other

76 i += other

77 if i < 0:

78 i = 0

79 elif i >= len(l):

80 i = len(l) - 1

81 return l[i]

82 return NotImplemented

83

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

85 if isinstance(other, int):

86 return self + (-other)

87 return NotImplemented

88

89

90

91# ---------- argparse help formatter ----------

92

93 class HelpFormatter(argparse.RawDescriptionHelpFormatter):

94

95 '''

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

97 separated by an empty line as separate paragraphs and

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

99

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

101 although not strictly to prevent URLs from breaking.

102

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

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

105

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

107

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

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

110

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

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

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

114

115 If you want to use this class pass it to the constructor of :class:`~confattr.utils.HelpFormatterWrapper` and use that instead.

116 '''

117

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

119 max_width = 70

120

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

122 break_long_words = False

123

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

125 break_on_hyphens = False

126

127 #: 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.

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

129

130 #: 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.

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

132

133 def __init__(self,

134 prog: str,

135 indent_increment: int = 2,

136 max_help_position: int = 24,

137 width: 'int|None' = None,

138 ) -> None:

139 '''

140 :param prog: The name of the program

141 :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.

142 '''

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

144 width = self.max_width

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

146

147

148 # ------- override methods of parent class -------

149

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

151 '''

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

153

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

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

156 '''

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

158

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

160 '''

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

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

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

164

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

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

167

168 :param text: The text to be formatted

169 :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.)

170 :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`.

171 '''

172 lines = []

173 # The original implementation does not use cleandoc

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

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

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

177 text = inspect.cleandoc(text)

178 wrapper = textwrap.TextWrapper(width=width,

179 break_long_words=self.break_long_words, break_on_hyphens=self.break_on_hyphens)

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

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

182 wrapper.initial_indent = indent

183 wrapper.subsequent_indent = indent

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

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

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

187 bullet = pre_bullet_items[i]

188 item = pre_bullet_items[i+1]

189 add_indent = ' ' * len(bullet)

190 wrapper.initial_indent = indent + bullet

191 wrapper.subsequent_indent = indent + add_indent

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

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

194 lines.append('')

195

196 lines = lines[:-1]

197

198 return lines

199

200

201 if typing.TYPE_CHECKING:

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

203 prog: str

204 indent_increment: int

205 max_help_position: int

206 width: int

207

208

209 class HelpFormatterWrapper:

210

211 '''

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

213

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

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

216

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

218 '''

219

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

221 '''

222 :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`)

223 :param prog: The name of the program

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

225 :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.

226 :param width: Maximal number of characters per line

227 '''

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

229 self.formatter = formatter_class(**kw)

230

231

232 # ------- format directly -------

233

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

235 '''

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

237 '''

238 return self.formatter._format_text(text)

239

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

241 '''

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

243 '''

244 # apply section indentation

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

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

247

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

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

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

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

252 indent = ' ' * len(bullet)

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

254 return text + '\n'

255

256

257 # ------- input -------

258

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

260 '''

261 Start a new section.

262

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

264

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

266 '''

267 self.formatter.start_section(heading)

268

269 def add_end_section(self) -> None:

270 '''

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

272 '''

273 self.formatter.end_section()

274

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

276 '''

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

278 '''

279 self.formatter.add_text(text)

280

281 def add_start_list(self) -> None:

282 '''

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

284 '''

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

286

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

288 '''

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

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

291 '''

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

293

294 def add_end_list(self) -> None:

295 '''

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

297 '''

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

299 return x

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

301

302 # ------- output -------

303

304 def format_help(self) -> str:

305 '''

306 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`.

307 '''

308 return self.formatter.format_help()