peps.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. # $Id: peps.py 9037 2022-03-05 23:31:10Z milde $
  2. # Author: David Goodger <goodger@python.org>
  3. # Copyright: This module has been placed in the public domain.
  4. """
  5. Transforms for PEP processing.
  6. - `Headers`: Used to transform a PEP's initial RFC-2822 header. It remains a
  7. field list, but some entries get processed.
  8. - `Contents`: Auto-inserts a table of contents.
  9. - `PEPZero`: Special processing for PEP 0.
  10. """
  11. __docformat__ = 'reStructuredText'
  12. import os
  13. import re
  14. import time
  15. from docutils import nodes, utils, languages
  16. from docutils import DataError
  17. from docutils.transforms import Transform
  18. from docutils.transforms import parts, references, misc
  19. class Headers(Transform):
  20. """
  21. Process fields in a PEP's initial RFC-2822 header.
  22. """
  23. default_priority = 360
  24. pep_url = 'pep-%04d'
  25. pep_cvs_url = ('http://hg.python.org'
  26. '/peps/file/default/pep-%04d.txt')
  27. rcs_keyword_substitutions = (
  28. (re.compile(r'\$' r'RCSfile: (.+),v \$$', re.IGNORECASE), r'\1'),
  29. (re.compile(r'\$[a-zA-Z]+: (.+) \$$'), r'\1'),)
  30. def apply(self):
  31. if not len(self.document):
  32. # @@@ replace these DataErrors with proper system messages
  33. raise DataError('Document tree is empty.')
  34. header = self.document[0]
  35. if (not isinstance(header, nodes.field_list)
  36. or 'rfc2822' not in header['classes']):
  37. raise DataError('Document does not begin with an RFC-2822 '
  38. 'header; it is not a PEP.')
  39. pep = None
  40. for field in header:
  41. if field[0].astext().lower() == 'pep': # should be the first field
  42. value = field[1].astext()
  43. try:
  44. pep = int(value)
  45. cvs_url = self.pep_cvs_url % pep
  46. except ValueError:
  47. pep = value
  48. cvs_url = None
  49. msg = self.document.reporter.warning(
  50. '"PEP" header must contain an integer; "%s" is an '
  51. 'invalid value.' % pep, base_node=field)
  52. msgid = self.document.set_id(msg)
  53. prb = nodes.problematic(value, value or '(none)',
  54. refid=msgid)
  55. prbid = self.document.set_id(prb)
  56. msg.add_backref(prbid)
  57. if len(field[1]):
  58. field[1][0][:] = [prb]
  59. else:
  60. field[1] += nodes.paragraph('', '', prb)
  61. break
  62. if pep is None:
  63. raise DataError('Document does not contain an RFC-2822 "PEP" '
  64. 'header.')
  65. if pep == 0:
  66. # Special processing for PEP 0.
  67. pending = nodes.pending(PEPZero)
  68. self.document.insert(1, pending)
  69. self.document.note_pending(pending)
  70. if len(header) < 2 or header[1][0].astext().lower() != 'title':
  71. raise DataError('No title!')
  72. for field in header:
  73. name = field[0].astext().lower()
  74. body = field[1]
  75. if len(body) > 1:
  76. raise DataError('PEP header field body contains multiple '
  77. 'elements:\n%s' % field.pformat(level=1))
  78. elif len(body) == 1:
  79. if not isinstance(body[0], nodes.paragraph):
  80. raise DataError('PEP header field body may only contain '
  81. 'a single paragraph:\n%s'
  82. % field.pformat(level=1))
  83. elif name == 'last-modified':
  84. try:
  85. date = time.strftime(
  86. '%d-%b-%Y',
  87. time.localtime(os.stat(self.document['source'])[8]))
  88. except OSError:
  89. date = 'unknown'
  90. if cvs_url:
  91. body += nodes.paragraph(
  92. '', '', nodes.reference('', date, refuri=cvs_url))
  93. else:
  94. # empty
  95. continue
  96. para = body[0]
  97. if name == 'author':
  98. for node in para:
  99. if isinstance(node, nodes.reference):
  100. node.replace_self(mask_email(node))
  101. elif name == 'discussions-to':
  102. for node in para:
  103. if isinstance(node, nodes.reference):
  104. node.replace_self(mask_email(node, pep))
  105. elif name in ('replaces', 'replaced-by', 'requires'):
  106. newbody = []
  107. space = nodes.Text(' ')
  108. for refpep in re.split(r',?\s+', body.astext()):
  109. pepno = int(refpep)
  110. newbody.append(nodes.reference(
  111. refpep, refpep,
  112. refuri=(self.document.settings.pep_base_url
  113. + self.pep_url % pepno)))
  114. newbody.append(space)
  115. para[:] = newbody[:-1] # drop trailing space
  116. elif name == 'last-modified':
  117. utils.clean_rcs_keywords(para, self.rcs_keyword_substitutions)
  118. if cvs_url:
  119. date = para.astext()
  120. para[:] = [nodes.reference('', date, refuri=cvs_url)]
  121. elif name == 'content-type':
  122. pep_type = para.astext()
  123. uri = self.document.settings.pep_base_url + self.pep_url % 12
  124. para[:] = [nodes.reference('', pep_type, refuri=uri)]
  125. elif name == 'version' and len(body):
  126. utils.clean_rcs_keywords(para, self.rcs_keyword_substitutions)
  127. class Contents(Transform):
  128. """
  129. Insert an empty table of contents topic and a transform placeholder into
  130. the document after the RFC 2822 header.
  131. """
  132. default_priority = 380
  133. def apply(self):
  134. language = languages.get_language(self.document.settings.language_code,
  135. self.document.reporter)
  136. name = language.labels['contents']
  137. title = nodes.title('', name)
  138. topic = nodes.topic('', title, classes=['contents'])
  139. name = nodes.fully_normalize_name(name)
  140. if not self.document.has_name(name):
  141. topic['names'].append(name)
  142. self.document.note_implicit_target(topic)
  143. pending = nodes.pending(parts.Contents)
  144. topic += pending
  145. self.document.insert(1, topic)
  146. self.document.note_pending(pending)
  147. class TargetNotes(Transform):
  148. """
  149. Locate the "References" section, insert a placeholder for an external
  150. target footnote insertion transform at the end, and schedule the
  151. transform to run immediately.
  152. """
  153. default_priority = 520
  154. def apply(self):
  155. doc = self.document
  156. i = len(doc) - 1
  157. refsect = copyright = None
  158. while i >= 0 and isinstance(doc[i], nodes.section):
  159. title_words = doc[i][0].astext().lower().split()
  160. if 'references' in title_words:
  161. refsect = doc[i]
  162. break
  163. elif 'copyright' in title_words:
  164. copyright = i
  165. i -= 1
  166. if not refsect:
  167. refsect = nodes.section()
  168. refsect += nodes.title('', 'References')
  169. doc.set_id(refsect)
  170. if copyright:
  171. # Put the new "References" section before "Copyright":
  172. doc.insert(copyright, refsect)
  173. else:
  174. # Put the new "References" section at end of doc:
  175. doc.append(refsect)
  176. pending = nodes.pending(references.TargetNotes)
  177. refsect.append(pending)
  178. self.document.note_pending(pending, 0)
  179. pending = nodes.pending(misc.CallBack,
  180. details={'callback': self.cleanup_callback})
  181. refsect.append(pending)
  182. self.document.note_pending(pending, 1)
  183. def cleanup_callback(self, pending):
  184. """
  185. Remove an empty "References" section.
  186. Called after the `references.TargetNotes` transform is complete.
  187. """
  188. if len(pending.parent) == 2: # <title> and <pending>
  189. pending.parent.parent.remove(pending.parent)
  190. class PEPZero(Transform):
  191. """
  192. Special processing for PEP 0.
  193. """
  194. default_priority = 760
  195. def apply(self):
  196. visitor = PEPZeroSpecial(self.document)
  197. self.document.walk(visitor)
  198. self.startnode.parent.remove(self.startnode)
  199. class PEPZeroSpecial(nodes.SparseNodeVisitor):
  200. """
  201. Perform the special processing needed by PEP 0:
  202. - Mask email addresses.
  203. - Link PEP numbers in the second column of 4-column tables to the PEPs
  204. themselves.
  205. """
  206. pep_url = Headers.pep_url
  207. def unknown_visit(self, node):
  208. pass
  209. def visit_reference(self, node):
  210. node.replace_self(mask_email(node))
  211. def visit_field_list(self, node):
  212. if 'rfc2822' in node['classes']:
  213. raise nodes.SkipNode
  214. def visit_tgroup(self, node):
  215. self.pep_table = node['cols'] == 4
  216. self.entry = 0
  217. def visit_colspec(self, node):
  218. self.entry += 1
  219. if self.pep_table and self.entry == 2:
  220. node['classes'].append('num')
  221. def visit_row(self, node):
  222. self.entry = 0
  223. def visit_entry(self, node):
  224. self.entry += 1
  225. if self.pep_table and self.entry == 2 and len(node) == 1:
  226. node['classes'].append('num')
  227. p = node[0]
  228. if isinstance(p, nodes.paragraph) and len(p) == 1:
  229. text = p.astext()
  230. try:
  231. pep = int(text)
  232. ref = (self.document.settings.pep_base_url
  233. + self.pep_url % pep)
  234. p[0] = nodes.reference(text, text, refuri=ref)
  235. except ValueError:
  236. pass
  237. non_masked_addresses = ('peps@python.org',
  238. 'python-list@python.org',
  239. 'python-dev@python.org')
  240. def mask_email(ref, pepno=None):
  241. """
  242. Mask the email address in `ref` and return a replacement node.
  243. `ref` is returned unchanged if it contains no email address.
  244. For email addresses such as "user@host", mask the address as "user at
  245. host" (text) to thwart simple email address harvesters (except for those
  246. listed in `non_masked_addresses`). If a PEP number (`pepno`) is given,
  247. return a reference including a default email subject.
  248. """
  249. if ref.hasattr('refuri') and ref['refuri'].startswith('mailto:'):
  250. if ref['refuri'][8:] in non_masked_addresses:
  251. replacement = ref[0]
  252. else:
  253. replacement_text = ref.astext().replace('@', '&#32;&#97;t&#32;')
  254. replacement = nodes.raw('', replacement_text, format='html')
  255. if pepno is None:
  256. return replacement
  257. else:
  258. ref['refuri'] += '?subject=PEP%%20%s' % pepno
  259. ref[:] = [replacement]
  260. return ref
  261. else:
  262. return ref