Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion tdom/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from .placeholders import (
make_placeholder_config as default_make_placeholder_config,
)
from .source import LinePosition
from .source import LinePosition, SourceReader
from .template_utils import PartPosition, TemplateRef, TemplateSpan
from .tnodes import (
TagSourceInfo,
Expand Down Expand Up @@ -146,6 +146,9 @@ def __next__(self):
else:
raise StopIteration

def get_reader(self) -> SourceReader:
return SourceReader(template=self.template)

def remove_placeholders(self, text: str) -> TemplateRef:
"""
Find tracked placeholders in text and mark them as found.
Expand Down
83 changes: 83 additions & 0 deletions tdom/source.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import typing as t
from dataclasses import dataclass
from string.templatelib import Interpolation, Template

from .template_utils import PartPosition, TemplateRef, TemplateSpan


@dataclass(slots=True, frozen=True)
Expand All @@ -9,3 +13,82 @@ class LinePosition:
"""Line of code, starts at 1."""
offset: int = 0
"""Offset from the start of the line, starts at 0."""


def template_repr_iter(template: Template) -> t.Generator[str]:
"""
Yield a string representation of each part of a given template.

@NOTE: This will not yield empty strings because it uses the underlying
template iterator which does not.
"""
for part in template:
if isinstance(part, str):
yield part
else:
yield interpolation_repr(part)


def template_repr(template: Template) -> str:
"""
Create a string representation of the given template.
"""
return "".join(template_repr_iter(template))


def interpolation_repr(ip: Interpolation) -> str:
"""
Create a string representation of the given interpolation.
"""
expr_str = ip.expression
conversion_str = f"!{ip.conversion}" if ip.conversion is not None else ""
format_spec_str = f":{ip.format_spec}" if ip.format_spec else ""
return f"{{{expr_str}{conversion_str}{format_spec_str}}}"


@dataclass
class SourceReader:
"""Format report-like strings from template source for error reporting."""

template: Template

def values_match(self, i_index1: int, i_index2: int) -> bool:
Comment thread
ianjosephwilson marked this conversation as resolved.
"""Check if the two interpolation values match.

@NOTE: This is meant to be used for reporting *better* error messages
after an error has already occurred.

@TODO: Consider pulling this into another helper class with other
"inspection" type methods.
"""
return (
self.template.interpolations[i_index1].value
== self.template.interpolations[i_index2].value
)

def ref_to_repr(self, ref: TemplateRef, limit: int | None = None) -> str:
"""
Convert tref to string representation of the underlying template.
"""
filled_template = ref.bind(self.template.interpolations)
return template_repr(filled_template)[:limit]

def make_template_pos_msg(self, source_pos: PartPosition) -> str:
"""
Make a message to display the line number and offset number.
"""
template_pos = self.to_template_pos(source_pos)
return f"line {template_pos.line} offset {template_pos.offset}"

def make_interpolation_repr(self, i_index: int) -> str:
return interpolation_repr(self.template.interpolations[i_index])

def to_template_pos(self, source_pos: PartPosition) -> LinePosition:
"""
Convert a (template) part position into a line position based on the
string representation of the template.
"""
span_up_to_pos = TemplateSpan(start=PartPosition(0, 0), stop=source_pos)
repr_up_to_pos = template_repr(span_up_to_pos.extract(self.template))
lines_up_to_pos = repr_up_to_pos.split("\n")
return LinePosition(line=len(lines_up_to_pos), offset=len(lines_up_to_pos[-1]))
118 changes: 118 additions & 0 deletions tdom/source_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
from string.templatelib import Template

import pytest

from .source import LinePosition, SourceReader, template_repr
from .template_utils import PartPosition, TemplateRef


class TestSourceReader:
"""
Top-level tests for SourceReader class.

More in depth tests are handled in more specialized test.
"""

def test_values_match(self):
def comp() -> Template:
return t""

reader = SourceReader(template=t"<{comp}></{comp}>{'content'}")
assert reader.values_match(0, 1)
assert not reader.values_match(0, 2)

def test_ref_to_repr(self):
reader = SourceReader(template=t"a{'b'!s}c")
assert (
reader.ref_to_repr(TemplateRef(strings=("A", ""), i_start=0)) == "A{'b'!s}"
)

def test_make_template_pos_msg(self):
reader = SourceReader(template=t"<div>{'content'}</div>")
msg = reader.make_template_pos_msg(source_pos=PartPosition(s_index=0, offset=1))
assert msg == "line 1 offset 1"

def test_make_interpolation_repr(self):
reader = SourceReader(template=t"<div>{'content'}</div>")
assert reader.make_interpolation_repr(0) == "{'content'}"

def test_to_template_pos(self):
reader = SourceReader(template=t"<div>{'content'}</div>")
assert reader.to_template_pos(
PartPosition(s_index=0, offset=len("<div>"))
) == LinePosition(line=1, offset=len("<div>"))


class TestTemplateRepresentation:
# whitespace is part of test
# fmt: off
@pytest.mark.parametrize(
("t", "result"),
(
(t"<div>{15!s:formatspec}</div>", "<div>{15!s:formatspec}</div>"),
(t"<div>{15:formatspec}</div>", "<div>{15:formatspec}</div>"),
(t"<div>{15!s}</div>", "<div>{15!s}</div>"),
(t"<div>{15}</div>", "<div>{15}</div>"),
(t"{15}", "{15}"),
(t"", ""),
(t"A{0}B{1}{2}C", "A{0}B{1}{2}C"),
(t"ABC", "ABC"),
(t"""<div>
</div>""", """<div>\n</div>"""),
(t"""{'''
'''}""", """{'''\n'''}"""),
)
)
def test_repr(self, t: Template, result: str):
assert template_repr(t) == result
# fmt: on


class TestToTemplatePosition:
def test_origin(self):
t = t"<div>{'content'}</div>"
reader = SourceReader(template=t)
source_pos = PartPosition(s_index=0, offset=0)
assert reader.to_template_pos(source_pos) == LinePosition(line=1, offset=0)

def test_offset_no_lines(self):
t = t"<div>{'content'}</div>"
reader = SourceReader(template=t)
source_pos = PartPosition(s_index=0, offset=len(t.strings[0]))
assert reader.to_template_pos(source_pos) == LinePosition(
line=1, offset=len(t.strings[0])
)

def test_offset_full_interpolation(self):
t = t"<div>{''!s:lower}</div>" # conversion and formatspec
reader = SourceReader(template=t)
source_pos = PartPosition(s_index=1, offset=0)
assert reader.to_template_pos(source_pos) == LinePosition(
line=1, offset=len('<div>{""!s:lower}')
)

def test_line(self):
# whitespace is part of test
# fmt: off
t = t"""<div>
{"content"}</div>"""
# fmt: on
reader = SourceReader(template=t)
source_pos = PartPosition(s_index=1, offset=0)
assert reader.to_template_pos(source_pos) == LinePosition(
line=2, offset=len('{"content"}')
)

def test_line_in_interpolation(self):
# whitespace is part of test
# fmt: off
t = t"""<div>
{'''
content
'''}</div>"""
# fmt: on
reader = SourceReader(template=t)
source_pos = PartPosition(s_index=1, offset=0)
assert reader.to_template_pos(source_pos) == LinePosition(
line=4, offset=len("'''}")
)