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 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 `Non breaking spaces <https://en.wikipedia.org/wiki/Nbsp>`_ can be used to prevent line breaks.

108 They will be replaced with normal spaces after line breaking.

109

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

111

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

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

114

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

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

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

118

119 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.

120 '''

121

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

123 max_width = 70

124

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

126 break_long_words = False

127

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

129 break_on_hyphens = False

130

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

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

133

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

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

136

137 def __init__(self,

138 prog: str,

139 indent_increment: int = 2,

140 max_help_position: int = 24,

141 width: 'int|None' = None,

142 ) -> None:

143 '''

144 :param prog: The name of the program

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

146 '''

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

148 width = self.max_width

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

150

151

152 # ------- override methods of parent class -------

153

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

155 '''

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

157

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

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

160 '''

161 return '\n'.join(self._replace_nbsp(ln) for ln in self._split_lines(text, width, indent=indent, replace_nbsp=False))

162

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

164 '''

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

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

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

168

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

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

171

172 :param text: The text to be formatted

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

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

175 '''

176 lines = []

177 # The original implementation does not use cleandoc

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

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

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

181 text = inspect.cleandoc(text)

182 wrapper = textwrap.TextWrapper(width=width,

183 break_long_words=self.break_long_words, break_on_hyphens=self.break_on_hyphens)

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

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

186 wrapper.initial_indent = indent

187 wrapper.subsequent_indent = indent

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

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

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

191 bullet = pre_bullet_items[i]

192 item = pre_bullet_items[i+1]

193 add_indent = ' ' * len(bullet)

194 wrapper.initial_indent = indent + bullet

195 wrapper.subsequent_indent = indent + add_indent

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

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

198 lines.append('')

199

200 lines = lines[:-1]

201

202 if replace_nbsp:

203 lines = [self._replace_nbsp(ln) for ln in lines]

204

205 return lines

206

207 @staticmethod

208 def _replace_nbsp(ln: str) -> str:

209 return ln.replace(' ', ' ')

210

211

212

213 if typing.TYPE_CHECKING:

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

215 prog: str

216 indent_increment: int

217 max_help_position: int

218 width: int

219

220

221 class HelpFormatterWrapper:

222

223 '''

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

225

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

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

228

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

230 '''

231

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

233 '''

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

235 :param prog: The name of the program

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

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

238 :param width: Maximal number of characters per line

239 '''

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

241 self.formatter = formatter_class(**kw)

242

243

244 # ------- format directly -------

245

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

247 '''

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

249 '''

250 return self.formatter._format_text(text)

251

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

253 '''

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

255 '''

256 # apply section indentation

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

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

259

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

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

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

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

264 indent = ' ' * len(bullet)

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

266 return text + '\n'

267

268

269 # ------- input -------

270

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

272 '''

273 Start a new section.

274

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

276

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

278 '''

279 self.formatter.start_section(heading)

280

281 def add_end_section(self) -> None:

282 '''

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

284 '''

285 self.formatter.end_section()

286

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

288 '''

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

290 '''

291 self.formatter.add_text(text)

292

293 def add_start_list(self) -> None:

294 '''

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

296 '''

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

298

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

300 '''

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

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

303 '''

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

305

306 def add_end_list(self) -> None:

307 '''

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

309 '''

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

311 return x

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

313

314 # ------- output -------

315

316 def format_help(self) -> str:

317 '''

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

319 '''

320 return self.formatter.format_help()

321

322

323# ---------- argparse actions ----------

324

325 class CallAction(argparse.Action):

326

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

328 if help is None:

329 if callback.__doc__ is None:

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

331 help = callback.__doc__.strip()

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

333 self.callback = callback

334

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

336 self.callback(*values)