import os
import re
import textwrap
from pythonkss import markdownformatter
from pythonkss.example import Example
from pythonkss.exceptions import NotSectionError, InvalidMergeSectionTypeError, InvalidMergeNotSameReferenceError
EXAMPLE_START = 'Example:'
intented_line_re = re.compile(r'^\s\s+.*$')
reference_re = re.compile(
r'^Styleguide(?P<type>(?:ExtendBefore|ExtendAfter|Replace))? '
r'(?P<reference>(?:[0-9a-z_-]*\.)*(?:(?:\d+:)?[0-9a-z_-]+))$')
extend_title_re = re.compile(r'Title:(?P<title>.+)$')
class SectionParser(object):
def __init__(self, comment):
self.comment = comment
self.title = None
self.section_type = None
self.examples = []
self.raw_reference = None
self.reference = None
self.raw_reference_segment_list = []
self.reference_segment_list = []
self.sortkey = None
self.description = None
self.in_example = False
self.description_lines = []
self.example_lines = []
self.example_argumentstring = None
def _reset_in_booleans(self):
self.in_example = False
def _parse_last_reference_segment(self, last_reference_segment):
sortkey = None
if last_reference_segment.isdigit():
sortkey = int(last_reference_segment)
text = last_reference_segment
elif ':' in last_reference_segment:
sortkey, text = last_reference_segment.split(':')
sortkey = int(sortkey)
else:
text = last_reference_segment
return sortkey, text
def _parse_example_start(self, line):
if self.example_lines:
self.examples.append([self.example_lines, self.example_argumentstring])
self.example_lines = []
self._reset_in_booleans()
self.in_example = True
arguments = line.split(':', 1)
if len(arguments) > 1:
self.example_argumentstring = arguments[1]
def _parse_in_example(self, line):
self.example_lines.append(line)
def _parse_description(self, line):
self._reset_in_booleans()
self.description_lines.append(line)
def parse_body_line(self, line):
if line.startswith(EXAMPLE_START):
self._parse_example_start(line=line)
elif self.in_example is True and (intented_line_re.match(line) or line.strip() == ''):
self._parse_in_example(line=line)
else:
self._parse_description(line=line)
def _parse_extend_title(self, line):
match = extend_title_re.match(line)
if match:
return match.groupdict()['title'].strip()
else:
return None
def _parse_title(self, line):
title_line_consumed = False
line = line.strip()
if self.section_type in Section.EXTEND_TYPES:
title = self._parse_extend_title(line)
if title:
self.title = title
title_line_consumed = True
else:
self.title = line
title_line_consumed = True
return title_line_consumed
def _parse_raw_reference(self, raw_reference):
self.raw_reference = raw_reference
if raw_reference:
self.raw_reference_segment_list = self.raw_reference.split('.')
self.sortkey, text = self._parse_last_reference_segment(self.raw_reference_segment_list[-1])
self.reference_segment_list = self.raw_reference_segment_list[0:-1] + [text]
self.reference = '.'.join(self.reference_segment_list)
def _parse_styleguide_line(self, line):
match = reference_re.match(line)
if match:
groupdict = match.groupdict()
self.section_type = groupdict['type'] or Section.TYPE_DEFAULT
self._parse_raw_reference(groupdict['reference'])
def parse(self, reference=None, title=None):
"""
Parse the section.
Args:
reference: If provided, we parse the provided reference
instead of extracting it from the content
after ``Styleguide`` on the last line of the comment.
title: If provided, we use the provided title
instead of extracting it from the first line of the comment.
"""
lines = self.comment.strip().splitlines()
minimum_lines = 2
if reference:
minimum_lines -= 1
if title:
minimum_lines -= 1
if len(lines) < minimum_lines:
raise NotSectionError('Not a section. A section must have at least 2 lines.',
comment_lines=lines)
if reference:
self._parse_raw_reference(reference)
else:
styleguide_line = lines.pop()
self._parse_styleguide_line(styleguide_line)
if self.reference is None:
raise NotSectionError('Not a section. A section must have the reference on the last line.',
comment_lines=lines)
if title:
self._parse_title(title)
else:
title_line_consumed = self._parse_title(lines[0])
if title_line_consumed:
lines = lines[1:]
self._reset_in_booleans()
for line in lines:
self.parse_body_line(line=line)
self.description = '\n'.join(self.description_lines).strip()
if self.example_lines:
self.examples.append([self.example_lines, self.example_argumentstring])
[docs]class Section(object):
"""
A section in the documentation.
"""
TYPE_DEFAULT = 'Default'
TYPE_EXTEND_AFTER = 'ExtendAfter'
TYPE_EXTEND_BEFORE = 'ExtendBefore'
EXTEND_TYPES = {TYPE_EXTEND_BEFORE, TYPE_EXTEND_AFTER}
TYPE_REPLACE = 'Replace'
def __init__(self, comment=None, filepath=None):
self.comment = comment or ''
self.filepath = filepath
self._parsed = False
@property
def filename(self):
if self.filepath:
return os.path.basename(self.filepath)
else:
return self.filepath
[docs] def parse(self, **kwargs):
"""
Parse the section.
Args:
**kwargs: Forwarded to :meth:`.SectionParser.parse`.
"""
sectionparser = SectionParser(comment=self.comment)
sectionparser.parse(**kwargs)
self._section_type = sectionparser.section_type
self._title = sectionparser.title
self._description = sectionparser.description
self._reference = sectionparser.reference
self._raw_reference = sectionparser.raw_reference
self._raw_reference_segment_list = sectionparser.raw_reference_segment_list
self._reference_segment_list = sectionparser.reference_segment_list
self._sortkey = sectionparser.sortkey
self._examples = []
for lines, argumentstring in sectionparser.examples:
self._add_example_linelist(example_lines=lines, argumentstring=argumentstring)
def parse_if_needed(self):
if not self._parsed:
self.parse()
@property
def section_type(self):
"""
Get the title (the first line of the comment).
"""
if not hasattr(self, '_section_type'):
self.parse()
return self._section_type
@property
def title(self):
"""
Get the title (the first line of the comment).
"""
if not hasattr(self, '_title'):
self.parse()
return self._title
@property
def title(self):
"""
Get the title (the first line of the comment).
"""
if not hasattr(self, '_title'):
self.parse()
return self._title
@property
def description(self):
"""
Get the description as plain text.
"""
if not hasattr(self, '_description'):
self.parse()
return self._description
@property
def description_html(self):
"""
Get the :meth:`.description` converted to markdown using
:class:`pythonkss.markdownformatter.MarkdownFormatter`.
"""
return markdownformatter.MarkdownFormatter.to_html(markdowntext=self.description)
@property
def examples(self):
"""
Get all ``Example:`` sections as a list of :class:`pythonkss.example.Example` objects.
"""
if not hasattr(self, '_examples'):
self.parse()
return self._examples
[docs] def has_examples(self):
"""
Returns ``True`` if the section has at least one ``Example:`` section.
"""
return len(self._examples) > 0
[docs] def has_multiple_examples(self):
"""
Returns ``True`` if the section more than one ``Example:`` section.
"""
return len(self._examples) > 1
@property
def reference(self):
"""
Get the reference.
This is the part after ``Styleguide`` at the end of the comment.
If the reference format is ``<number>:<text>``, this is only the ``<text>``.
"""
if not hasattr(self, '_reference'):
self.parse()
return self._reference
@property
def raw_reference(self):
"""
Get the raw reference.
This is the part after ``Styleguide`` at the end of the comment.
How a reference is parsed:
- Split the reference into segments by ``"."``.
- All the segments except the last refer to the parent.
- The part after the last ``"."`` is in one of the following formats:
- ``[a-z0-9_-]+``
- ``<number>:<[a-z0-9_-]+>``
- All segments except the last can only contain ``[a-z0-9_-]+``.
"""
if not hasattr(self, '_reference'):
self.parse()
return self._raw_reference
@property
def raw_reference_segment_list(self):
"""
Get :meth:`.raw_reference` as a list of segments.
Just a shortcut for ``raw_reference.split('.')``, but slightly
faster because the list is created when the section is parsed.
"""
if not hasattr(self, '_reference'):
self.parse()
return self._raw_reference_segment_list
@property
def reference_segment_list(self):
"""
Get :meth:`.reference` as a list of segments.
Just a shortcut for ``reference.split('.')``, but slightly
faster because the list is created when the section is parsed.
"""
if not hasattr(self, '_reference'):
self.parse()
return self._reference_segment_list
[docs] def iter_reference_segments_expanded(self):
"""
Iterate over :meth:`.reference_segment_list`, and return the
reference of each segment.
So if the :meth:`.reference` is ``a.b.c``, this will yield:
- a
- a.b
- a.b.c
"""
collected = []
for segment in self.reference_segment_list:
collected.append(segment)
yield '.'.join(collected)
@property
def sortkey(self):
"""
Get the sortkey for this reference within the parent section.
Parses the last segment of the reference, and extracts a sort key.
Extracted as follows:
- If the segment is a number, return the number.
- If the segment starts with ``<number>:``, return the number.
- Otherwise, return ``None``.
See :meth:`.reference` for information about what we mean by "segment".
Some examples (reference -> sortkey):
- 1 -> 1
- 4.3 -> 3
- 4.5.2 -> 2
- 4.myapp-lists -> None
- 4.12:myapp-lists -> 12
"""
if not hasattr(self, '_reference'):
self.parse()
return self._sortkey
def _add_example_linelist(self, example_lines, **kwargs):
text = '\n'.join(example_lines)
text = textwrap.dedent(text).strip()
self.add_example(text=text, **kwargs)
[docs] def add_example(self, text, **kwargs):
"""
Add a example block to the section.
Args:
text: The text for the example.
**kwargs: Kwargs for :class:`pythonkss.example.Example`.
"""
example = Example(
text=text,
filename=self.filename,
**kwargs)
self._examples.append(example)
def _merge_title_into_section(self, target_section, after):
if after:
formattingstring = '{target} {source}'
else:
formattingstring = '{source} {target}'
target_section._title = formattingstring.format(
source=self.title,
target=target_section.title)
def _merge_description_into_section(self, target_section, after):
if after:
formattingstring = '{target}\n\n{source}'
else:
formattingstring = '{source}\n\n{target}'
target_section._description = formattingstring.format(
source=self.description,
target=target_section.description)
def _merge_examples_into_section(self, target_section, after):
if after:
target_section._examples.extend(self._examples)
else:
target_section._examples = self._examples + target_section._examples
def merge_into_section(self, target_section):
if self.section_type not in self.EXTEND_TYPES:
raise InvalidMergeSectionTypeError(
'Can only merge sections of the following types '
'into other sections: {extend_types}'.format(
extend_types=', '.join(self.EXTEND_TYPES)
))
elif self.reference != target_section.reference:
raise InvalidMergeNotSameReferenceError(
'Can only merge sections with the same reference.'
'Trying to merge {source} into {target}'.format(
source=self.reference,
target=target_section.reference
))
after = self.section_type == self.TYPE_EXTEND_AFTER
if self.title:
self._merge_title_into_section(target_section=target_section, after=after)
if self.description:
self._merge_description_into_section(target_section=target_section, after=after)
if self.examples:
self._merge_examples_into_section(target_section=target_section, after=after)