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

109 statements  

« prev     ^ index     » next       coverage.py v7.2.1, created at 2023-03-06 19:52 +0100

1#!./runmodule.sh 

2 

3''' 

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

5''' 

6 

7import re 

8import argparse 

9import inspect 

10import textwrap 

11import shlex 

12import functools 

13import enum 

14import typing 

15 

16if typing.TYPE_CHECKING: 

17 from typing_extensions import Unpack, Self 

18 

19 

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

21 

22def 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:`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 

42class 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 Passing :paramref:`descending` 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 

93class 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:`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:`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:`max_width` then :attr:`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:`_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:`_split_lines` for that. 

156 

157 If you want to customize the formatting you need to override this method or :meth:`_split_lines` 

158 but you should *not* call this method directly because if :class:`argparse.HelpFormatter` changes 

159 this method may need to be changed in a non-backward compatible way, too. 

160 Call :meth:`fill_text` instead. 

161 ''' 

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

163 

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

165 ''' 

166 This method cleans :paramref:`text` with :meth:`inspect.cleandoc` and 

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

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

169 

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

171 indirectly through :meth:`_fill_text` to format text blocks such as description. 

172 

173 :param text: The text to be formatted 

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

175 :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:`_fill_text`. 

176 ''' 

177 lines = [] 

178 # The original implementation does not use cleandoc 

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

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

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

182 text = inspect.cleandoc(text) 

183 wrapper = textwrap.TextWrapper(width=width, 

184 break_long_words=self.break_long_words, break_on_hyphens=self.break_on_hyphens) 

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

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

187 wrapper.initial_indent = indent 

188 wrapper.subsequent_indent = indent 

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

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

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

192 bullet = pre_bullet_items[i] 

193 item = pre_bullet_items[i+1] 

194 add_indent = ' ' * len(bullet) 

195 wrapper.initial_indent = indent + bullet 

196 wrapper.subsequent_indent = indent + add_indent 

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

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

199 lines.append('') 

200 

201 if lines: 

202 lines = lines[:-1] 

203 

204 return lines 

205 

206 

207if typing.TYPE_CHECKING: 

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

209 prog: str 

210 indent_increment: int 

211 max_help_position: int 

212 width: int 

213 

214 

215class HelpFormatterWrapper: 

216 

217 ''' 

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

219 

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

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

222 

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

224 ''' 

225 

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

227 ''' 

228 :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:`HelpFormatter`) 

229 :param prog: The name of the program 

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

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

232 :param width: Maximal number of characters per line 

233 ''' 

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

235 self.formatter = formatter_class(**kw) 

236 

237 

238 # ------- format directly ------- 

239 

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

241 ''' 

242 Format a text and return it immediately without adding it to :meth:`format_help`. 

243 ''' 

244 return self.formatter._format_text(text) 

245 

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

247 ''' 

248 Format a list item and return it immediately without adding it to :meth:`format_help`. 

249 ''' 

250 # apply section indentation 

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

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

253 

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

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

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

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

258 indent = ' ' * len(bullet) 

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

260 return text + '\n' 

261 

262 

263 # ------- input ------- 

264 

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

266 ''' 

267 Start a new section. 

268 

269 This influences the formatting of following calls to :meth:`add_text` and :meth:`add_item`. 

270 

271 You can call this method again before calling :meth:`add_end_section` to create a subsection. 

272 ''' 

273 self.formatter.start_section(heading) 

274 

275 def add_end_section(self) -> None: 

276 ''' 

277 End the last section which has been started with :meth:`add_start_section`. 

278 ''' 

279 self.formatter.end_section() 

280 

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

282 ''' 

283 Add some text which will be formatted when calling :meth:`format_help`. 

284 ''' 

285 self.formatter.add_text(text) 

286 

287 def add_start_list(self) -> None: 

288 ''' 

289 Start a new list which can be filled with :meth:`add_item`. 

290 ''' 

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

292 

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

294 ''' 

295 Add a list item which will be formatted when calling :meth:`format_help`. 

296 A list must be started with :meth:`add_start_list` and ended with :meth:`add_end_list`. 

297 ''' 

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

299 

300 def add_end_list(self) -> None: 

301 ''' 

302 End a list. This must be called after the last :meth:`add_item`. 

303 ''' 

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

305 return x 

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

307 

308 # ------- output ------- 

309 

310 def format_help(self) -> str: 

311 ''' 

312 Format everything that has been added with :meth:`add_start_section`, :meth:`add_text` and :meth:`add_item`. 

313 ''' 

314 return self.formatter.format_help()