"Misc. utility functions/classes for admin documentation generator."
import re
from email.errors import HeaderParseError
from email.parser import HeaderParser
from django.urls import reverse
from django.utils.safestring import mark_safe
try:
import docutils.core
import docutils.nodes
import docutils.parsers.rst.roles
except ImportError:
docutils_is_available = False
else:
docutils_is_available = True
def get_view_name(view_func):
mod_name = view_func.__module__
view_name = getattr(view_func, '__qualname__', view_func.__class__.__name__)
return mod_name + '.' + view_name
def trim_docstring(docstring):
"""
Uniformly trim leading/trailing whitespace from docstrings.
Based on https://www.python.org/dev/peps/pep-0257/#handling-docstring-indentation
"""
if not docstring or not docstring.strip():
return ''
# Convert tabs to spaces and split into lines
lines = docstring.expandtabs().splitlines()
indent = min(len(line) - len(line.lstrip()) for line in lines if line.lstrip())
trimmed = [lines[0].lstrip()] + [line[indent:].rstrip() for line in lines[1:]]
return "\n".join(trimmed).strip()
def parse_docstring(docstring):
"""
Parse out the parts of a docstring. Return (title, body, metadata).
"""
docstring = trim_docstring(docstring)
parts = re.split(r'\n{2,}', docstring)
title = parts[0]
if len(parts) == 1:
body = ''
metadata = {}
else:
parser = HeaderParser()
try:
metadata = parser.parsestr(parts[-1])
except HeaderParseError:
metadata = {}
body = "\n\n".join(parts[1:])
else:
metadata = dict(metadata.items())
if metadata:
body = "\n\n".join(parts[1:-1])
else:
body = "\n\n".join(parts[1:])
return title, body, metadata
def parse_rst(text, default_reference_context, thing_being_parsed=None):
"""
Convert the string from reST to an XHTML fragment.
"""
overrides = {
'doctitle_xform': True,
'initial_header_level': 3,
"default_reference_context": default_reference_context,
"link_base": reverse('django-admindocs-docroot').rstrip('/'),
'raw_enabled': False,
'file_insertion_enabled': False,
}
thing_being_parsed = thing_being_parsed and '<%s>' % thing_being_parsed
# Wrap ``text`` in some reST that sets the default role to ``cmsreference``,
# then restores it.
source = """
.. default-role:: cmsreference
%s
.. default-role::
"""
parts = docutils.core.publish_parts(
source % text,
source_path=thing_being_parsed, destination_path=None,
writer_name='html', settings_overrides=overrides,
)
return mark_safe(parts['fragment'])
#
# reST roles
#
ROLES = {
'model': '%s/models/%s/',
'view': '%s/views/%s/',
'template': '%s/templates/%s/',
'filter': '%s/filters/#%s',
'tag': '%s/tags/#%s',
}
def create_reference_role(rolename, urlbase):
def _role(name, rawtext, text, lineno, inliner, options=None, content=None):
if options is None:
options = {}
node = docutils.nodes.reference(
rawtext,
text,
refuri=(urlbase % (
inliner.document.settings.link_base,
text.lower(),
)),
**options
)
return [node], []
docutils.parsers.rst.roles.register_canonical_role(rolename, _role)
def default_reference_role(name, rawtext, text, lineno, inliner, options=None, content=None):
if options is None:
options = {}
context = inliner.document.settings.default_reference_context
node = docutils.nodes.reference(
rawtext,
text,
refuri=(ROLES[context] % (
inliner.document.settings.link_base,
text.lower(),
)),
**options
)
return [node], []
if docutils_is_available:
docutils.parsers.rst.roles.register_canonical_role('cmsreference', default_reference_role)
for name, urlbase in ROLES.items():
create_reference_role(name, urlbase)
# Match the beginning of a named or unnamed group.
named_group_matcher = re.compile(r'\(\?P(<\w+>)')
unnamed_group_matcher = re.compile(r'\(')
def replace_named_groups(pattern):
r"""
Find named groups in `pattern` and replace them with the group name. E.g.,
1. ^(?P\w+)/b/(\w+)$ ==> ^/b/(\w+)$
2. ^(?P\w+)/b/(?P\w+)/$ ==> ^/b//$
"""
named_group_indices = [
(m.start(0), m.end(0), m.group(1))
for m in named_group_matcher.finditer(pattern)
]
# Tuples of (named capture group pattern, group name).
group_pattern_and_name = []
# Loop over the groups and their start and end indices.
for start, end, group_name in named_group_indices:
# Handle nested parentheses, e.g. '^(?P(x|y))/b'.
unmatched_open_brackets, prev_char = 1, None
for idx, val in enumerate(list(pattern[end:])):
# If brackets are balanced, the end of the string for the current
# named capture group pattern has been reached.
if unmatched_open_brackets == 0:
group_pattern_and_name.append((pattern[start:end + idx], group_name))
break
# Check for unescaped `(` and `)`. They mark the start and end of a
# nested group.
if val == '(' and prev_char != '\\':
unmatched_open_brackets += 1
elif val == ')' and prev_char != '\\':
unmatched_open_brackets -= 1
prev_char = val
# Replace the string for named capture groups with their group names.
for group_pattern, group_name in group_pattern_and_name:
pattern = pattern.replace(group_pattern, group_name)
return pattern
def replace_unnamed_groups(pattern):
r"""
Find unnamed groups in `pattern` and replace them with ''. E.g.,
1. ^(?P\w+)/b/(\w+)$ ==> ^(?P\w+)/b/$
2. ^(?P\w+)/b/((x|y)\w+)$ ==> ^(?P\w+)/b/$
"""
unnamed_group_indices = [m.start(0) for m in unnamed_group_matcher.finditer(pattern)]
# Indices of the start of unnamed capture groups.
group_indices = []
# Loop over the start indices of the groups.
for start in unnamed_group_indices:
# Handle nested parentheses, e.g. '^b/((x|y)\w+)$'.
unmatched_open_brackets, prev_char = 1, None
for idx, val in enumerate(list(pattern[start + 1:])):
if unmatched_open_brackets == 0:
group_indices.append((start, start + 1 + idx))
break
# Check for unescaped `(` and `)`. They mark the start and end of
# a nested group.
if val == '(' and prev_char != '\\':
unmatched_open_brackets += 1
elif val == ')' and prev_char != '\\':
unmatched_open_brackets -= 1
prev_char = val
# Remove unnamed group matches inside other unnamed capture groups.
group_start_end_indices = []
prev_end = None
for start, end in group_indices:
if prev_end and start > prev_end or not prev_end:
group_start_end_indices.append((start, end))
prev_end = end
if group_start_end_indices:
# Replace unnamed groups with . Handle the fact that replacing the
# string between indices will change string length and thus indices
# will point to the wrong substring if not corrected.
final_pattern, prev_end = [], None
for start, end in group_start_end_indices:
if prev_end:
final_pattern.append(pattern[prev_end:start])
final_pattern.append(pattern[:start] + '')
prev_end = end
final_pattern.append(pattern[prev_end:])
return ''.join(final_pattern)
else:
return pattern