From 1b5c68307a5ea177d21645c5003a971fd6247341 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Wed, 29 Jul 2026 15:11:33 -0700 Subject: [PATCH 1/4] Add source reader and template repr. --- tdom/parser.py | 5 +- tdom/source.py | 104 ++++++++++++++++++++++++++++++++++++++ tdom/source_test.py | 118 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 tdom/source_test.py diff --git a/tdom/parser.py b/tdom/parser.py index f0f71ff..40277b8 100644 --- a/tdom/parser.py +++ b/tdom/parser.py @@ -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, @@ -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. diff --git a/tdom/source.py b/tdom/source.py index e9b4fae..6a538f7 100644 --- a/tdom/source.py +++ b/tdom/source.py @@ -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) @@ -9,3 +13,103 @@ class LinePosition: """Line of code, starts at 1.""" offset: int = 0 """Offset from the start of the line, starts at 0.""" + + +@dataclass(slots=True) +class MutableLinePosition: + """A mutable position in a block of source code.""" + + line: int = 1 + """ Line of code, starts at 1. """ + offset: int = 0 + """ Offset from the start of the line, starts at 0. """ + + def freeze(self) -> LinePosition: + """Freeze ourself into an immutable object with the same values.""" + return LinePosition(line=self.line, offset=self.offset) + + +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: + """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. + """ + 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. + """ + pos = MutableLinePosition() + span_up_to_pos = TemplateSpan(start=PartPosition(0, 0), stop=source_pos) + for part in span_up_to_pos.extract(self.template): + if isinstance(part, str): + text = part + else: + text = interpolation_repr(part) + nls = text.count("\n") + if nls: + pos.offset = len(text) - (text.rfind("\n") + 1) + pos.line += nls + else: + pos.offset += len(text) + return pos.freeze() diff --git a/tdom/source_test.py b/tdom/source_test.py new file mode 100644 index 0000000..e8d0a5b --- /dev/null +++ b/tdom/source_test.py @@ -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}>{'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"
{'content'}
") + msg = reader.make_template_pos_msg(source_pos=PartPosition(index=0, offset=1)) + assert msg == "line 1 offset 1" + + def test_make_interpolation_repr(self): + reader = SourceReader(template=t"
{'content'}
") + assert reader.make_interpolation_repr(0) == "{'content'}" + + def test_to_template_pos(self): + reader = SourceReader(template=t"
{'content'}
") + assert reader.to_template_pos(PartPosition(index=1, offset=0)) == LinePosition( + line=1, offset=len("
") + ) + + +class TestTemplateRepresentation: + # whitespace is part of test + # fmt: off + @pytest.mark.parametrize( + ("t", "result"), + ( + (t"
{15!s:formatspec}
", "
{15!s:formatspec}
"), + (t"
{15:formatspec}
", "
{15:formatspec}
"), + (t"
{15!s}
", "
{15!s}
"), + (t"
{15}
", "
{15}
"), + (t"{15}", "{15}"), + (t"", ""), + (t"A{0}B{1}{2}C", "A{0}B{1}{2}C"), + (t"ABC", "ABC"), + (t"""
+
""", """
\n
"""), + (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"
{'content'}
" + reader = SourceReader(template=t) + source_pos = PartPosition(index=0, offset=0) + assert reader.to_template_pos(source_pos) == LinePosition(line=1, offset=0) + + def test_offset_no_lines(self): + t = t"
{'content'}
" + reader = SourceReader(template=t) + source_pos = PartPosition(index=1, offset=0) + assert reader.to_template_pos(source_pos) == LinePosition( + line=1, offset=len(t.strings[0]) + ) + + def test_offset_full_interpolation(self): + t = t"
{''!s:lower}
" # conversion and formatspec + reader = SourceReader(template=t) + source_pos = PartPosition(index=2, offset=0) + assert reader.to_template_pos(source_pos) == LinePosition( + line=1, offset=len('
{""!s:lower}') + ) + + def test_line(self): + # whitespace is part of test + # fmt: off + t = t"""
+{"content"}
""" + # fmt: on + reader = SourceReader(template=t) + source_pos = PartPosition(index=2, 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"""
+{''' +content +'''}
""" + # fmt: on + reader = SourceReader(template=t) + source_pos = PartPosition(index=2, offset=0) + assert reader.to_template_pos(source_pos) == LinePosition( + line=4, offset=len("'''}") + ) From 4031a2a799ae4be9ab31b2f43ebb1f8a81060463 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 24 Aug 2026 14:04:04 -0700 Subject: [PATCH 2/4] Update tests to usenew PartPosition --- tdom/source_test.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tdom/source_test.py b/tdom/source_test.py index e8d0a5b..d839ae7 100644 --- a/tdom/source_test.py +++ b/tdom/source_test.py @@ -29,7 +29,7 @@ def test_ref_to_repr(self): def test_make_template_pos_msg(self): reader = SourceReader(template=t"
{'content'}
") - msg = reader.make_template_pos_msg(source_pos=PartPosition(index=0, offset=1)) + 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): @@ -38,9 +38,9 @@ def test_make_interpolation_repr(self): def test_to_template_pos(self): reader = SourceReader(template=t"
{'content'}
") - assert reader.to_template_pos(PartPosition(index=1, offset=0)) == LinePosition( - line=1, offset=len("
") - ) + assert reader.to_template_pos( + PartPosition(s_index=0, offset=len("
")) + ) == LinePosition(line=1, offset=len("
")) class TestTemplateRepresentation: @@ -72,13 +72,13 @@ class TestToTemplatePosition: def test_origin(self): t = t"
{'content'}
" reader = SourceReader(template=t) - source_pos = PartPosition(index=0, offset=0) + 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"
{'content'}
" reader = SourceReader(template=t) - source_pos = PartPosition(index=1, offset=0) + 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]) ) @@ -86,7 +86,7 @@ def test_offset_no_lines(self): def test_offset_full_interpolation(self): t = t"
{''!s:lower}
" # conversion and formatspec reader = SourceReader(template=t) - source_pos = PartPosition(index=2, offset=0) + source_pos = PartPosition(s_index=1, offset=0) assert reader.to_template_pos(source_pos) == LinePosition( line=1, offset=len('
{""!s:lower}') ) @@ -98,7 +98,7 @@ def test_line(self): {"content"}
""" # fmt: on reader = SourceReader(template=t) - source_pos = PartPosition(index=2, offset=0) + source_pos = PartPosition(s_index=1, offset=0) assert reader.to_template_pos(source_pos) == LinePosition( line=2, offset=len('{"content"}') ) @@ -112,7 +112,7 @@ def test_line_in_interpolation(self): '''}
""" # fmt: on reader = SourceReader(template=t) - source_pos = PartPosition(index=2, offset=0) + source_pos = PartPosition(s_index=1, offset=0) assert reader.to_template_pos(source_pos) == LinePosition( line=4, offset=len("'''}") ) From 1ad1c8ddb9394cd43d620e19d813b9ffcd187804 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Mon, 24 Aug 2026 16:33:09 -0700 Subject: [PATCH 3/4] Cleanup source line position calculation and drop now unused class. --- tdom/source.py | 30 +++--------------------------- 1 file changed, 3 insertions(+), 27 deletions(-) diff --git a/tdom/source.py b/tdom/source.py index 6a538f7..382655c 100644 --- a/tdom/source.py +++ b/tdom/source.py @@ -15,20 +15,6 @@ class LinePosition: """Offset from the start of the line, starts at 0.""" -@dataclass(slots=True) -class MutableLinePosition: - """A mutable position in a block of source code.""" - - line: int = 1 - """ Line of code, starts at 1. """ - offset: int = 0 - """ Offset from the start of the line, starts at 0. """ - - def freeze(self) -> LinePosition: - """Freeze ourself into an immutable object with the same values.""" - return LinePosition(line=self.line, offset=self.offset) - - def template_repr_iter(template: Template) -> t.Generator[str]: """ Yield a string representation of each part of a given template. @@ -99,17 +85,7 @@ 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. """ - pos = MutableLinePosition() span_up_to_pos = TemplateSpan(start=PartPosition(0, 0), stop=source_pos) - for part in span_up_to_pos.extract(self.template): - if isinstance(part, str): - text = part - else: - text = interpolation_repr(part) - nls = text.count("\n") - if nls: - pos.offset = len(text) - (text.rfind("\n") + 1) - pos.line += nls - else: - pos.offset += len(text) - return pos.freeze() + 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])) From 9aec34316f63fc15c1080aa8b93f420b2ef334a6 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Mon, 24 Aug 2026 16:33:36 -0700 Subject: [PATCH 4/4] Add note that we might need to refactor. --- tdom/source.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tdom/source.py b/tdom/source.py index 382655c..9a8ca04 100644 --- a/tdom/source.py +++ b/tdom/source.py @@ -57,6 +57,9 @@ def values_match(self, i_index1: int, i_index2: int) -> bool: @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