def report(message, line, *args, colno=None, **kwargs): message = full_stop(message) culprits = get_culprit() if culprits: kwargs['source'] = culprits[0] if line: kwargs['culprit'] = get_culprit(line.lineno) if colno is not None: # build codicil that shows both the line and the preceding line if line.prev_line is not None: codicil = [ f'{line.prev_line.lineno:>4} «{line.prev_line.text}»' ] else: codicil = [] codicil += [ f'{line.lineno:>4} «{line.text}»', ' ' + (colno * ' ') + '▲', ] kwargs['codicil'] = '\n'.join(codicil) kwargs['colno'] = colno else: kwargs['codicil'] = f'{line.lineno:>4} «{line.text}»' kwargs['line'] = line.text kwargs['lineno'] = line.lineno if line.prev_line: kwargs['prev_line'] = line.prev_line.text else: kwargs['culprit'] = culprits # pragma: no cover raise NestedTextError(template=message, *args, **kwargs)
def from_text(cls, text): """ Read a protocol from the given text. See `Protocol.parse()` for more information. This function adds some additional error handling and sanity checking. """ io = cls() try: io.protocol = Protocol.parse(text) except ParseError as err: if not cls.all_errors: warn( "the protocol could not be properly rendered due to error(s):" ) err.report(informant=warn) io.protocol = err.content io.errors = 1 else: io.protocol.set_current_date() if not io.protocol.steps: warn("protocol is empty.", culprit=inform.get_culprit()) return io
def _check_version_control(path): """ Raise a warning if the given path has changes that haven't been committed. """ from subprocess import run # Check that the file is in a repository. p1 = run(shlex.split('git rev-parse --show-toplevel'), cwd=path.parent, capture_output=True, text=True) if p1.returncode != 0: raise VersionControlWarning(f"not in a git repository!", culprit=get_culprit() or path) git_dir = Path(p1.stdout.strip()).resolve() git_relpath = path.relative_to(git_dir) # Check that the file is being tracked. p2 = run( ['git', 'log', '-n1', '--pretty=format:%H', '--', git_relpath], cwd=git_dir, capture_output=True, ) if p2.returncode != 0: raise VersionControlWarning(f"not committed", culprit=get_culprit() or path) # Check that the file doesn't have any uncommitted changes. p3 = run( shlex.split( 'git ls-files --modified --deleted --others --exclude-standard'), cwd=git_dir, capture_output=True, text=True, ) if str(git_relpath) in p3.stdout: raise VersionControlWarning(f"uncommitted changes", culprit=get_culprit() or path)
def check_footnotes(footnotes): """ Complain if there are any footnotes that don't refer to anything. """ for i, line in enumerate(lines, start=1): for match in re.finditer(cls.FOOTNOTE_REGEX, line): refs = parse_range(match.group(1)) unknown_refs = set(refs) - set(footnotes) if unknown_refs: raise ParseError( template=f"unknown {plural(unknown_refs):footnote/s} [{format_range(unknown_refs)}]", culprit=inform.get_culprit(i), unknown_refs=unknown_refs, )
class Protocol: # Steps can either be strings or formatting objects. Strings will be # automatically wrapped; use `format.preformatted` to prevent this. BLANK_REGEX = r'^\s*$|^#.*' DATE_FORMAT = 'MMMM D, YYYY' COMMAND_REGEX = r'^[$] (.+)' STEP_REGEX = r'^(- |\s*\d+\. )(.+)' FOOTNOTE_HEADER_REGEX = r'Notes?:|Footnotes?:' FOOTNOTE_REGEX = r'\[(\d+(?:[-,]\d+)*)\]' FOOTNOTE_DEF_REGEX = fr'^\s*(\[(\d+)\] )(.+)' INDENT_OR_BLANK_REGEX = lambda n: fr'^({" "*n}|\s*$)(.*)$' def __init__(self, *, date=None, commands=None, steps=None, footnotes=None): self.date = date self.commands = commands or [] self.steps = steps or [] self.footnotes = footnotes or {} self.attachments = [] def __repr__(self): return f'Protocol(date={self.date!r}, commands={self.commands!r}, steps={self.steps!r}, footnotes={self.footnotes!r})' def __bool__(self): return bool(self.steps) def __add__(self, other): return Protocol.merge(self, other) def __iadd__(self, other): self.append(other) return self def __getitem__(self, i): return self.steps[i] @classmethod def parse(cls, x): """ Construct a protocol from a stream (i.e. an open file object), a string, of a list of lines. No line-wrapping will be applied to the content parsed by this method. """ from io import IOBase from collections.abc import Sequence if isinstance(x, IOBase): return cls._parse_stream(x) if isinstance(x, str): return cls._parse_str(x) if isinstance(x, Sequence): return cls._parse_lines(x) raise ParseError(f"expected stream or string or list-like, not {type(x)}") @classmethod def _parse_stream(cls, stream): return cls._parse_str(stream.read()) @classmethod def _parse_str(cls, text): return cls._parse_lines(text.splitlines()) @classmethod def _parse_lines(cls, lines): protocol = cls() class Transition: def __init__(self, next_parser, state=None, line_parsed=True): self.next_parser = next_parser self.next_state = state or {} self.line_parsed = line_parsed def parse_date(line, state): """ If the first non-empty line contains a date, parse it. """ if re.match(cls.BLANK_REGEX, line): return Transition(parse_date) try: protocol.date = arrow.get(line, cls.DATE_FORMAT) return Transition(parse_command) except arrow.ParserError: return Transition(parse_command, line_parsed=False) def parse_command(line, state): """ Interpret each line beginning with '$ ' as a command. """ if match := re.match(cls.COMMAND_REGEX, line): protocol.commands.append(match.group(1)) return Transition(parse_command) if re.match(cls.BLANK_REGEX, line): return Transition(parse_command) return Transition(parse_new_step, line_parsed=False) def parse_new_step(line, state): if re.match(cls.FOOTNOTE_HEADER_REGEX, line): return Transition(parse_new_footnote) if match := re.match(cls.STEP_REGEX, line): state['indent'] = len(match.group(1)) protocol.steps.append(match.group(2) + '\n') return Transition(parse_continued_step, state=state) raise ParseError( template=truncate_error("expected a step (e.g. '- …' or '1. …'), not '{}'", line), culprit=inform.get_culprit(), )
return Transition(parse_new_step, line_parsed=False) def parse_new_footnote(line, state): if match := re.match(cls.FOOTNOTE_DEF_REGEX, line): id = state['id'] = int(match.group(2)) state['indent'] = len(match.group(1)) protocol.footnotes[id] = match.group(3) + '\n' return Transition(parse_continued_footnote, state=state) if re.match(cls.BLANK_REGEX, line): return Transition(parse_new_footnote) raise ParseError( template=truncate_error("expected a footnote (e.g. '[1] …'), not '{}'", line), culprit=inform.get_culprit(), ) def parse_continued_footnote(line, state): id = state['id'] indent = state['indent'] assert id in protocol.footnotes if match := re.match(cls.INDENT_OR_BLANK_REGEX(indent), line): protocol.footnotes[id] += match.group(2) + '\n' return Transition(parse_continued_footnote, state=state) return Transition(parse_new_footnote, line_parsed=False) def truncate_error(message, problem):
def test_carbuncle(): with messenger() as (msg, stdout, stderr, logfile): display('fuzzy', file=stdout) assert get_culprit() == () assert get_culprit('x') == ('x',) assert get_culprit(('x', 'y')) == ('x', 'y') assert get_culprit(('x', 'y', 1)) == ('x', 'y', 1) with set_culprit('a'): assert get_culprit() == ('a',) assert get_culprit('x') == ('a', 'x') assert get_culprit(('x', 'y')) == ('a', 'x', 'y') with set_culprit('b'): assert get_culprit() == ('b',) assert get_culprit('x') == ('b', 'x') assert get_culprit(('x', 'y')) == ('b', 'x', 'y') with set_culprit('c'): assert get_culprit() == ('c',) assert get_culprit('x') == ('c', 'x') assert get_culprit(('x', 'y')) == ('c', 'x', 'y') with add_culprit('a'): assert get_culprit() == ('a',) assert get_culprit('x') == ('a', 'x') assert get_culprit(('x', 'y')) == ('a', 'x', 'y') with add_culprit('b'): assert get_culprit() == ('a', 'b',) assert get_culprit('x') == ('a', 'b', 'x') assert get_culprit(('x', 'y')) == ('a', 'b', 'x', 'y') with add_culprit('c'): assert get_culprit() == ('a', 'b', 'c',) assert get_culprit('x') == ('a', 'b', 'c', 'x') assert get_culprit(('x', 'y')) == ('a', 'b', 'c', 'x', 'y') assert join_culprit(get_culprit((45, 99))) == 'a, b, c, 45, 99'