"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.encoding import force_bytes 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 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 force_bytes('<%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 = {} if content is None: content = [] 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 = {} if content is None: content = [] 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