You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

utils.py 7.8KB

5 years ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. "Misc. utility functions/classes for admin documentation generator."
  2. import re
  3. from email.errors import HeaderParseError
  4. from email.parser import HeaderParser
  5. from django.urls import reverse
  6. from django.utils.safestring import mark_safe
  7. try:
  8. import docutils.core
  9. import docutils.nodes
  10. import docutils.parsers.rst.roles
  11. except ImportError:
  12. docutils_is_available = False
  13. else:
  14. docutils_is_available = True
  15. def get_view_name(view_func):
  16. mod_name = view_func.__module__
  17. view_name = getattr(view_func, '__qualname__', view_func.__class__.__name__)
  18. return mod_name + '.' + view_name
  19. def trim_docstring(docstring):
  20. """
  21. Uniformly trim leading/trailing whitespace from docstrings.
  22. Based on https://www.python.org/dev/peps/pep-0257/#handling-docstring-indentation
  23. """
  24. if not docstring or not docstring.strip():
  25. return ''
  26. # Convert tabs to spaces and split into lines
  27. lines = docstring.expandtabs().splitlines()
  28. indent = min(len(line) - len(line.lstrip()) for line in lines if line.lstrip())
  29. trimmed = [lines[0].lstrip()] + [line[indent:].rstrip() for line in lines[1:]]
  30. return "\n".join(trimmed).strip()
  31. def parse_docstring(docstring):
  32. """
  33. Parse out the parts of a docstring. Return (title, body, metadata).
  34. """
  35. docstring = trim_docstring(docstring)
  36. parts = re.split(r'\n{2,}', docstring)
  37. title = parts[0]
  38. if len(parts) == 1:
  39. body = ''
  40. metadata = {}
  41. else:
  42. parser = HeaderParser()
  43. try:
  44. metadata = parser.parsestr(parts[-1])
  45. except HeaderParseError:
  46. metadata = {}
  47. body = "\n\n".join(parts[1:])
  48. else:
  49. metadata = dict(metadata.items())
  50. if metadata:
  51. body = "\n\n".join(parts[1:-1])
  52. else:
  53. body = "\n\n".join(parts[1:])
  54. return title, body, metadata
  55. def parse_rst(text, default_reference_context, thing_being_parsed=None):
  56. """
  57. Convert the string from reST to an XHTML fragment.
  58. """
  59. overrides = {
  60. 'doctitle_xform': True,
  61. 'initial_header_level': 3,
  62. "default_reference_context": default_reference_context,
  63. "link_base": reverse('django-admindocs-docroot').rstrip('/'),
  64. 'raw_enabled': False,
  65. 'file_insertion_enabled': False,
  66. }
  67. thing_being_parsed = thing_being_parsed and '<%s>' % thing_being_parsed
  68. # Wrap ``text`` in some reST that sets the default role to ``cmsreference``,
  69. # then restores it.
  70. source = """
  71. .. default-role:: cmsreference
  72. %s
  73. .. default-role::
  74. """
  75. parts = docutils.core.publish_parts(
  76. source % text,
  77. source_path=thing_being_parsed, destination_path=None,
  78. writer_name='html', settings_overrides=overrides,
  79. )
  80. return mark_safe(parts['fragment'])
  81. #
  82. # reST roles
  83. #
  84. ROLES = {
  85. 'model': '%s/models/%s/',
  86. 'view': '%s/views/%s/',
  87. 'template': '%s/templates/%s/',
  88. 'filter': '%s/filters/#%s',
  89. 'tag': '%s/tags/#%s',
  90. }
  91. def create_reference_role(rolename, urlbase):
  92. def _role(name, rawtext, text, lineno, inliner, options=None, content=None):
  93. if options is None:
  94. options = {}
  95. node = docutils.nodes.reference(
  96. rawtext,
  97. text,
  98. refuri=(urlbase % (
  99. inliner.document.settings.link_base,
  100. text.lower(),
  101. )),
  102. **options
  103. )
  104. return [node], []
  105. docutils.parsers.rst.roles.register_canonical_role(rolename, _role)
  106. def default_reference_role(name, rawtext, text, lineno, inliner, options=None, content=None):
  107. if options is None:
  108. options = {}
  109. context = inliner.document.settings.default_reference_context
  110. node = docutils.nodes.reference(
  111. rawtext,
  112. text,
  113. refuri=(ROLES[context] % (
  114. inliner.document.settings.link_base,
  115. text.lower(),
  116. )),
  117. **options
  118. )
  119. return [node], []
  120. if docutils_is_available:
  121. docutils.parsers.rst.roles.register_canonical_role('cmsreference', default_reference_role)
  122. for name, urlbase in ROLES.items():
  123. create_reference_role(name, urlbase)
  124. # Match the beginning of a named or unnamed group.
  125. named_group_matcher = re.compile(r'\(\?P(<\w+>)')
  126. unnamed_group_matcher = re.compile(r'\(')
  127. def replace_named_groups(pattern):
  128. r"""
  129. Find named groups in `pattern` and replace them with the group name. E.g.,
  130. 1. ^(?P<a>\w+)/b/(\w+)$ ==> ^<a>/b/(\w+)$
  131. 2. ^(?P<a>\w+)/b/(?P<c>\w+)/$ ==> ^<a>/b/<c>/$
  132. """
  133. named_group_indices = [
  134. (m.start(0), m.end(0), m.group(1))
  135. for m in named_group_matcher.finditer(pattern)
  136. ]
  137. # Tuples of (named capture group pattern, group name).
  138. group_pattern_and_name = []
  139. # Loop over the groups and their start and end indices.
  140. for start, end, group_name in named_group_indices:
  141. # Handle nested parentheses, e.g. '^(?P<a>(x|y))/b'.
  142. unmatched_open_brackets, prev_char = 1, None
  143. for idx, val in enumerate(list(pattern[end:])):
  144. # If brackets are balanced, the end of the string for the current
  145. # named capture group pattern has been reached.
  146. if unmatched_open_brackets == 0:
  147. group_pattern_and_name.append((pattern[start:end + idx], group_name))
  148. break
  149. # Check for unescaped `(` and `)`. They mark the start and end of a
  150. # nested group.
  151. if val == '(' and prev_char != '\\':
  152. unmatched_open_brackets += 1
  153. elif val == ')' and prev_char != '\\':
  154. unmatched_open_brackets -= 1
  155. prev_char = val
  156. # Replace the string for named capture groups with their group names.
  157. for group_pattern, group_name in group_pattern_and_name:
  158. pattern = pattern.replace(group_pattern, group_name)
  159. return pattern
  160. def replace_unnamed_groups(pattern):
  161. r"""
  162. Find unnamed groups in `pattern` and replace them with '<var>'. E.g.,
  163. 1. ^(?P<a>\w+)/b/(\w+)$ ==> ^(?P<a>\w+)/b/<var>$
  164. 2. ^(?P<a>\w+)/b/((x|y)\w+)$ ==> ^(?P<a>\w+)/b/<var>$
  165. """
  166. unnamed_group_indices = [m.start(0) for m in unnamed_group_matcher.finditer(pattern)]
  167. # Indices of the start of unnamed capture groups.
  168. group_indices = []
  169. # Loop over the start indices of the groups.
  170. for start in unnamed_group_indices:
  171. # Handle nested parentheses, e.g. '^b/((x|y)\w+)$'.
  172. unmatched_open_brackets, prev_char = 1, None
  173. for idx, val in enumerate(list(pattern[start + 1:])):
  174. if unmatched_open_brackets == 0:
  175. group_indices.append((start, start + 1 + idx))
  176. break
  177. # Check for unescaped `(` and `)`. They mark the start and end of
  178. # a nested group.
  179. if val == '(' and prev_char != '\\':
  180. unmatched_open_brackets += 1
  181. elif val == ')' and prev_char != '\\':
  182. unmatched_open_brackets -= 1
  183. prev_char = val
  184. # Remove unnamed group matches inside other unnamed capture groups.
  185. group_start_end_indices = []
  186. prev_end = None
  187. for start, end in group_indices:
  188. if prev_end and start > prev_end or not prev_end:
  189. group_start_end_indices.append((start, end))
  190. prev_end = end
  191. if group_start_end_indices:
  192. # Replace unnamed groups with <var>. Handle the fact that replacing the
  193. # string between indices will change string length and thus indices
  194. # will point to the wrong substring if not corrected.
  195. final_pattern, prev_end = [], None
  196. for start, end in group_start_end_indices:
  197. if prev_end:
  198. final_pattern.append(pattern[prev_end:start])
  199. final_pattern.append(pattern[:start] + '<var>')
  200. prev_end = end
  201. final_pattern.append(pattern[prev_end:])
  202. return ''.join(final_pattern)
  203. else:
  204. return pattern