from typing import Callable, FrozenSet, List, Tuple
DEFAULT_HALF_WIDTH = 40
[docs]
def truncate_text_horizontally(
lines: List[str], column: int, half_width: int = DEFAULT_HALF_WIDTH
) -> Tuple[List[str], int]:
"""
Given a list of lines, a target column (the column index where parsing failed),
and the target width to truncate to - truncate all lines while keeping them aligned,
centered around the target column.
"""
max_line_len = max(len(line) for line in lines)
left_index = column - half_width
right_index = column + half_width
if left_index < 0:
# Close to the left edge
left_index = 0
right_index = 2 * half_width
if right_index > max_line_len:
# Close to the right edge of the block of text
right_index = max_line_len
# Keep the start index >= 0 for short lines
left_index = max(0, right_index - half_width * 2)
truncated_lines = [line[left_index:right_index] for line in lines]
return (truncated_lines, column - left_index)
[docs]
def line_context(
stream: str,
index: int,
lines_of_context: int = 2,
half_width: int = DEFAULT_HALF_WIDTH,
) -> Tuple[List[str], str, List[str], int]:
"""Select a window of rows and columns of text
Break text based on newlines, then given an index into that text, return:
- The lines before the target line
- The target line
- The lines after the target line
- The column index of the target character
"""
if index > len(stream):
raise ValueError("invalid index")
# Find the line and column indices
line_index = stream.count("\n", 0, index)
start_of_line = stream.rfind("\n", 0, index) + 1
column = index - start_of_line
# Select just the lines around the target index
lines = stream.split("\n")
n_before = min(line_index, lines_of_context)
n_after = min(len(lines) - line_index, lines_of_context)
# tabs are replaced with space to make sure the pointer lines up with the char at
# `line[column]``
context_lines = [
line.replace("\t", " ")
for line in lines[line_index - n_before : line_index + n_after + 1]
]
# Truncate the text so it fits in a limited width output
context_lines, column = truncate_text_horizontally(
context_lines, column, half_width
)
lines_before = context_lines[:n_before]
lines_after = context_lines[n_before + 1 :]
line = context_lines[n_before]
return (lines_before, line, lines_after, column)
[docs]
def display_line_context(error: "ParseError") -> str:
"""Create a string containing the context of a file around a parsy parsing error"""
(lines_before, line, lines_after, col) = line_context(error.stream, error.index)
display = "\n".join(
[
"Context, showing up to 5 lines of up to 80 characters each.",
"Longer lines are truncated, view as fixed-width text.",
"-" * 80,
*lines_before,
line,
"~" * col + "^ Parsing failed here",
*lines_after,
"-" * 80,
]
)
return display
[docs]
def parser_failure_message(
error: "ParseError", show_expected: bool = True, show_context: bool = True
) -> str:
"""
Create a message from a Parsy parsing error, including the lines of text where
parsing failed, along with the list of parser descriptions which were expected
to match this part of the text. The expected parser text comes from the
`parsy.Parser.desc` method.
For example, an error output could look like this::
Parsing failed, expected to match one of:
- End of row of `Measurement Data` section containing 1 column(s)
- `Measurement Data` section comment surrounded by double quotes
--------------------------------------------------------------------------------
Wavelength: 450nm
Interval: 1
A01: 0.040, 1.10
~~~~~~~~~~^ Parsing failed here
A02: 0.040
A03: 0.039
--------------------------------------------------------------------------------
"""
expected_parsers = "\n".join(f" - {parser}" for parser in sorted(error.expected))
return (
"Parsing failed, expected to match one of:\n"
+ expected_parsers
+ "\n"
+ display_line_context(error)
)
[docs]
class ParseError(Exception):
def __init__(
self,
expected: FrozenSet[str],
stream: str,
index: int,
message: Callable[["ParseError"], str] = parser_failure_message,
):
self.expected: FrozenSet[str] = expected
self.stream: str = stream
self.index: int = index
self.message: Callable[["ParseError"], str] = message
def __str__(self) -> str:
return self.message(self)