Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/docutils/transforms/universal.py: 27%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

181 statements  

1# $Id: universal.py 10402 2026-09-18 11:52:14Z milde $ 

2# Authors: David Goodger <goodger@python.org>; Ueli Schlaepfer; Günter Milde 

3# Maintainer: docutils-develop@lists.sourceforge.net 

4# Copyright: This module has been placed in the public domain. 

5 

6""" 

7Transforms needed by most or all documents: 

8 

9- `Decorations`: Generate a document's header & footer. 

10- `ExposeInternals`: Expose internal attributes. 

11- `Messages`: Placement of system messages generated after parsing. 

12- `FilterMessages`: Remove system messages below verbosity threshold. 

13- `TestMessages`: Like `Messages`, used on test runs. 

14- `StripComments`: Remove comment elements from the document tree. 

15- `StripClassesAndElements`: Remove elements with classes 

16 in `self.document.settings.strip_elements_with_classes` 

17 and class values in `self.document.settings.strip_classes`. 

18- `SmartQuotes`: Replace ASCII quotation marks with typographic form. 

19- `Validate`: Validate the document tree, report violations as warning. 

20""" 

21 

22from __future__ import annotations 

23 

24__docformat__ = 'reStructuredText' 

25 

26import os 

27import re 

28import time 

29from docutils import nodes, utils 

30from docutils.transforms import Transform 

31from docutils.utils import smartquotes 

32 

33 

34class Decorations(Transform): 

35 

36 """ 

37 Populate a document's decoration element (header, footer). 

38 """ 

39 

40 default_priority = 820 

41 

42 def apply(self) -> None: 

43 header_nodes = self.generate_header() 

44 if header_nodes: 

45 decoration = self.document.get_decoration() 

46 header = decoration.get_header() 

47 header.extend(header_nodes) 

48 footer_nodes = self.generate_footer() 

49 if footer_nodes: 

50 decoration = self.document.get_decoration() 

51 footer = decoration.get_footer() 

52 footer.extend(footer_nodes) 

53 

54 def generate_header(self): 

55 return None 

56 

57 def generate_footer(self): 

58 # @@@ Text is hard-coded for now. 

59 # Should be made dynamic (language-dependent). 

60 settings = self.document.settings 

61 if (settings.generator or settings.datestamp 

62 or settings.source_link or settings.source_url): 

63 text = [] 

64 if (settings.source_link and settings._source 

65 or settings.source_url): 

66 if settings.source_url: 

67 source = settings.source_url 

68 else: 

69 source = utils.relative_path(settings.output_path, 

70 settings._source) 

71 text.extend([ 

72 nodes.reference('', 'View document source', 

73 refuri=source), 

74 nodes.Text('.\n')]) 

75 if settings.datestamp: 

76 # If set, use timestamp from the `SOURCE_DATE_EPOCH` 

77 # environment variable (cf. https://reproducible-builds.org/): 

78 source_date_epoch = os.environ.get('SOURCE_DATE_EPOCH') 

79 if source_date_epoch: 

80 timetuple = time.gmtime(int(source_date_epoch)) 

81 datestamp = time.strftime(settings.datestamp, timetuple) 

82 else: 

83 datestamp = time.strftime(settings.datestamp, 

84 time.gmtime()) 

85 text.append(nodes.Text('Generated on: ' + datestamp + '.\n')) 

86 if settings.generator: 

87 text.extend([ 

88 nodes.Text('Generated by '), 

89 nodes.reference('', 'Docutils', 

90 refuri='https://docutils.sourceforge.io/'), 

91 nodes.Text(' from '), 

92 nodes.reference('', 'reStructuredText', 

93 refuri='https://docutils.sourceforge.io/' 

94 'rst.html'), 

95 nodes.Text(' source.\n')]) 

96 return [nodes.paragraph('', '', *text)] 

97 else: 

98 return None 

99 

100 

101class ExposeInternals(Transform): 

102 

103 """ 

104 Expose internal attributes if ``expose_internals`` setting is set. 

105 """ 

106 

107 default_priority = 840 

108 

109 def not_Text(self, node) -> bool: 

110 return not isinstance(node, nodes.Text) 

111 

112 def apply(self) -> None: 

113 if self.document.settings.expose_internals: 

114 for node in self.document.findall(self.not_Text): 

115 for att in self.document.settings.expose_internals: 

116 value = getattr(node, att, None) 

117 if value is not None: 

118 node['internal:' + att] = value 

119 

120 

121class Messages(Transform): 

122 

123 """Handle "loose" messages. 

124 

125 Place system messages generated by parsing or transforms that are not 

126 attached to the document tree into a dedicated section of the document. 

127 """ 

128 

129 default_priority = 860 

130 

131 def apply(self) -> None: 

132 messages = [*self.document.parse_messages, 

133 *self.document.transform_messages] 

134 loose_messages = [msg for msg in messages if not msg.parent] 

135 if loose_messages: 

136 section = nodes.section(classes=['system-messages']) 

137 # @@@ get this from the language module? 

138 section += nodes.title('', 'Docutils System Messages') 

139 section += loose_messages 

140 self.document.transform_messages[:] = [] 

141 self.document += section 

142 

143 

144class FilterMessages(Transform): 

145 

146 """ 

147 Remove system messages below verbosity threshold. 

148 

149 Also convert <problematic> nodes referencing removed messages 

150 to <Text> nodes and remove "System Messages" section if empty. 

151 """ 

152 

153 default_priority = 870 

154 

155 def apply(self) -> None: 

156 removed_ids = [] # IDs of removed system messages 

157 for node in tuple(self.document.findall(nodes.system_message)): 

158 if node['level'] < self.document.reporter.report_level: 

159 node.parent.remove(node) 

160 for _id in node['ids']: 

161 self.document.ids.pop(_id, None) # remove ID registration 

162 removed_ids.append(_id) 

163 for node in tuple(self.document.findall(nodes.problematic)): 

164 if 'refid' in node and node['refid'] in removed_ids: 

165 node.parent.replace(node, nodes.Text(node.astext())) 

166 for node in self.document.findall(nodes.section): 

167 if "system-messages" in node['classes'] and len(node) == 1: 

168 node.parent.remove(node) 

169 

170 

171class TestMessages(Transform): 

172 

173 """ 

174 Append all post-parse system messages to the end of the document. 

175 

176 Used for testing purposes. 

177 """ 

178 

179 # marker for pytest to ignore this class during test discovery 

180 __test__ = False 

181 

182 default_priority = 880 

183 

184 def apply(self) -> None: 

185 for msg in self.document.transform_messages: 

186 if not msg.parent: 

187 self.document += msg 

188 

189 

190class StripComments(Transform): 

191 

192 """ 

193 Remove comment elements from the document tree (only if the 

194 ``strip_comments`` setting is enabled). 

195 """ 

196 

197 default_priority = 740 

198 

199 def apply(self) -> None: 

200 if self.document.settings.strip_comments: 

201 for node in tuple(self.document.findall(nodes.comment)): 

202 node.parent.remove(node) 

203 

204 

205class StripClassesAndElements(Transform): 

206 

207 """ 

208 Remove from the document tree all elements with classes in 

209 `self.document.settings.strip_elements_with_classes` and all "classes" 

210 attribute values in `self.document.settings.strip_classes`. 

211 """ 

212 

213 default_priority = 420 

214 

215 def apply(self) -> None: 

216 if self.document.settings.strip_elements_with_classes: 

217 self.strip_elements = {*self.document.settings 

218 .strip_elements_with_classes} 

219 # Iterate over a tuple as removing the current node 

220 # corrupts the iterator returned by `iter`: 

221 for node in tuple(self.document.findall(self.check_classes)): 

222 node.parent.remove(node) 

223 

224 if not self.document.settings.strip_classes: 

225 return 

226 strip_classes = self.document.settings.strip_classes 

227 for node in self.document.findall(nodes.Element): 

228 for class_value in strip_classes: 

229 try: 

230 node['classes'].remove(class_value) 

231 except ValueError: 

232 pass 

233 

234 def check_classes(self, node) -> bool: 

235 if not isinstance(node, nodes.Element): 

236 return False 

237 for class_value in node['classes'][:]: 

238 if class_value in self.strip_elements: 

239 return True 

240 return False 

241 

242 

243class SmartQuotes(Transform): 

244 

245 """ 

246 Replace ASCII quotation marks with typographic form. 

247 

248 Also replace multiple dashes with em-dash/en-dash characters. 

249 """ 

250 

251 default_priority = 510 

252 

253 nodes_to_skip = (nodes.FixedTextElement, nodes.Special) 

254 """Do not apply "smartquotes" to instances of these block-level nodes.""" 

255 

256 literal_nodes = (nodes.FixedTextElement, nodes.Special, 

257 nodes.image, nodes.literal, nodes.math, 

258 nodes.raw, nodes.problematic) 

259 """Do not apply smartquotes to instances of these inline nodes.""" 

260 

261 smartquotes_action = 'qDe' 

262 """Setting to select smartquote transformations. 

263 

264 The default 'qDe' educates normal quote characters: (", '), 

265 em- and en-dashes (---, --) and ellipses (...). 

266 """ 

267 

268 def __init__(self, document, startnode) -> None: 

269 Transform.__init__(self, document, startnode=startnode) 

270 self.unsupported_languages = set() 

271 

272 def get_tokens(self, txtnodes): 

273 # A generator that yields ``(texttype, nodetext)`` tuples for a list 

274 # of "Text" nodes (interface to ``smartquotes.educate_tokens()``). 

275 for node in txtnodes: 

276 if (isinstance(node.parent, self.literal_nodes) 

277 or isinstance(node.parent.parent, self.literal_nodes)): 

278 yield 'literal', str(node) 

279 else: 

280 # SmartQuotes uses backslash escapes instead of null-escapes 

281 # Insert backslashes before escaped "active" characters. 

282 txt = re.sub('(?<=\x00)([-\\\'".`])', r'\\\1', str(node)) 

283 yield 'plain', txt 

284 

285 def apply(self) -> None: 

286 smart_quotes = self.document.settings.setdefault('smart_quotes', 

287 False) 

288 if not smart_quotes: 

289 return 

290 try: 

291 alternative = smart_quotes.startswith('alt') 

292 except AttributeError: 

293 alternative = False 

294 

295 document_language = self.document.settings.language_code 

296 lc_smartquotes = self.document.settings.smartquotes_locales 

297 if lc_smartquotes: 

298 smartquotes.smartchars.quotes.update(dict(lc_smartquotes)) 

299 

300 # "Educate" quotes in normal text. Handle each block of text 

301 # (TextElement node) as a unit to keep context around inline nodes: 

302 for node in self.document.findall(nodes.TextElement): 

303 # skip preformatted text blocks and special elements: 

304 if isinstance(node, self.nodes_to_skip): 

305 continue 

306 # nested TextElements are not "block-level" elements: 

307 if isinstance(node.parent, nodes.TextElement): 

308 continue 

309 

310 # list of text nodes in the "text block": 

311 txtnodes = [txtnode for txtnode in node.findall(nodes.Text) 

312 if not isinstance(txtnode.parent, 

313 nodes.option_string)] 

314 

315 # language: use typographical quotes for language "lang" 

316 lang = node.get_language_code(document_language) 

317 # use alternative form if `smart-quotes` setting starts with "alt": 

318 if alternative: 

319 if '-x-altquot' in lang: 

320 lang = lang.replace('-x-altquot', '') 

321 else: 

322 lang += '-x-altquot' 

323 # drop unsupported subtags: 

324 for tag in utils.normalize_language_tag(lang): 

325 if tag in smartquotes.smartchars.quotes: 

326 lang = tag 

327 break 

328 else: # language not supported -- keep ASCII quotes 

329 if lang not in self.unsupported_languages: 

330 self.document.reporter.warning( 

331 'No smart quotes defined for language "%s".' % lang, 

332 base_node=node) 

333 self.unsupported_languages.add(lang) 

334 lang = '' 

335 

336 # Iterator educating quotes in plain text: 

337 # (see "utils/smartquotes.py" for the attribute setting) 

338 teacher = smartquotes.educate_tokens( 

339 self.get_tokens(txtnodes), 

340 attr=self.smartquotes_action, language=lang) 

341 

342 for txtnode, newtext in zip(txtnodes, teacher): 

343 txtnode.parent.replace(txtnode, nodes.Text(newtext)) 

344 

345 self.unsupported_languages.clear() 

346 

347 

348class Validate(Transform): 

349 

350 """ 

351 Validate the document tree, report violations as warning. 

352 """ 

353 

354 default_priority = 835 # between misc.Transitions and universal.Messages 

355 

356 def apply(self) -> None: 

357 if not getattr(self.document.settings, 'validate', False): 

358 return 

359 for node in self.document.findall(): 

360 try: 

361 node.validate(recursive=False) 

362 except nodes.ValidationError as e: 

363 self.document.reporter.warning( 

364 str(e), base_node=e.problematic_element or node) 

365 # TODO: append a link to the Document Tree documentation? 

366 # nodes.paragraph('', 'See ', 

367 # nodes.reference('', 'doctree.html#document', 

368 # refuri='https://docutils.sourceforge.io/' 

369 # 'docs/ref/doctree.html#document'),