Skip to content

Custom Error Handling Prototype - #145

Draft
ianjosephwilson wants to merge 78 commits into
t-strings:mainfrom
ianjosephwilson:ian/custom_error_handling
Draft

Custom Error Handling Prototype#145
ianjosephwilson wants to merge 78 commits into
t-strings:mainfrom
ianjosephwilson:ian/custom_error_handling

Conversation

@ianjosephwilson

@ianjosephwilson ianjosephwilson commented Jun 28, 2026

Copy link
Copy Markdown
Contributor
  • Reworked the SourceTracker
    • I think our original thinking was that the current HTMLParser.getpos() could be reconciled with the current string/interpolation index of the Template while feeding parts but there does not seem to be an easy way to do that.
  • Added ParserPositionTranslator, LineLocation and PartLocation
    • Instead we take the target parser position, parser_pos: LineLocation and a current position pos: MutableLineLocation, and walk pos through the Template parts with the embedded placeholders looping via index and offset until parser_pos == pos and source_pos = PartLocation(index, offset).
  • Added SourceInfo (and OpenSourceInfo), TTree and source_pos for TNode subclasses
    • We store the source_pos on each tnode after we translate it from the parser_pos.
    • In addition to the source_pos we can also store if a tag was self closing (or startend), the full starttag_text as starttag_ref and the endtag_pos (if startend==False). This info is in SourceInfo (for accessing via completed nodes) and OpenSourceInfo for incomplete nodes
    • When we create the completed SourceInfo we store it separately from the nodes and pack it into the TTree so we can unpack when an exception occurs. The reasoning for this was to try to keep most of the error/source details separate from the core processing to keep the core flows clear from debugging/error info churn and also in case that information is not available.
  • Added SourceReader
    • Now that we have translated source positions we have to "read" the source on errors so we have a utility class that is spawned by the SourceTracker with the appropriate template to extract the source with the interpolation expressions (when already raising errors) as well as translate PartLocation to LineLocation (but dependent on the string representations of the interpolations).
  • Added ParsingError(s)
    • Involves one template (that might contain inlined component children)
    • Should not involve the actual values of the template (unless we use them AFTER an error already occurred)
  • Added ProcessingError(s)
    • Recursive: Can occur in a template that was included in another template which was included in another template ... etc.
    • Once an exception occurs try to pack up a state that parallels each template in the recursive stack
      • Try to connect each "leaf" entrypoint with the nearest TNode
      • Try to connect the template and TTree (contains the template TNode root) to the exception
      • Try to catch regular exception in places where they might occur, ie. we try to call a user function, and then chain them with raise ProcessingError() from e
      • We also catch and chain a ParsingError to a ProcessingError
    • When we get back to TemplateProcessor.process() we should have a single ProcessingError that has a stack of error states that go down until we reach the lowest level Template where an error first occurred so we could print something LIKE this (TBD):
div > ... {nested_t}
    div > ... {slider_t}
        span > AttributeProcessingError: aria attribute must be dictionary
Error occurred in <span aria={('role','slider')}></span> at line 1 offset 15 in 
  • Improve various error messages
    • Mismatched tags, unclosed tags, etc. should have better error messages.
    • Special messages for some hard to catch errors around the now notorious trailing slash (self closing or not self closing).
    • Try to show the relevant tag when applicable.
  • TODO:
    • Come back and try to plug all the "holes" where an exception might be expected and need to be wrapped in a ProcessingError or ParsingError.
    • The tests I think need a bit of refactoring.
    • Test that component invocation errors are propagating correctly.
    • More thorough unrolling of processing errors when coming out of nested templates.
    • Utilize values_index and iter_index when displaying an error.
    • I have the "raw_attrs" stored in the SourceInfo right now but we might want to try to just get by with the tattrs on TComponent/TElement/OpenTComponent/OpenTElement themselves and remove this.

@davepeck

Copy link
Copy Markdown
Contributor

@ianjosephwilson Just saw you flipped this out of draft. Will take some time to work through it, but... wow!

@ianjosephwilson

Copy link
Copy Markdown
Contributor Author

@davepeck Yeah I think we want to start wading through it before I add even more LOC... I ended up having to follow the concept all the way through the processor to determine how things played out to then backtrack that to the parser to get a working design.. working. It is a lot of parts but I find it easier to see all the parts fitting together (or not fitting).

@ianjosephwilson

Copy link
Copy Markdown
Contributor Author

@davepeck I'm not sure if it's possible but do you want me to try to break this up?

@davepeck

davepeck commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

@davepeck I'm not sure if it's possible but do you want me to try to break this up?

I've been trying to figure out how to break it apart, but I don't really have great suggestions at the moment. In general, smaller incremental PRs to tackle our bigger projects would be helpful, but I'm not really sure how feasible that is with this particular PR! :-) Maybe there's a meaningful divide between the stuff that's tied to the parser and the stuff that's tied to the processor, not sure...

I'm so sorry it's been taking me this long to get through this.

@davepeck davepeck left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay!

I've been sitting on a whole bunch of comments for a while, but they were very scattered. I realized I needed to do them in batches if I had any hope of providing useful and timely (okay, too late for that!) feedback.

I'll start with small/point-wise comments and try to cover one corner of the code at the time.

Then I'll work my way up to the bigger picture.

Thanks for hanging in there! :-)

Comment thread tdom/template_utils.py Outdated
Comment thread tdom/template_utils.py


@dataclass(slots=True, frozen=True)
class PartPosition:

@davepeck davepeck Jul 24, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would it look like if PartPosition included accessor methods and the underlying math necessary to get at specific items in a TemplateRef?

I see us using // 2, % 2 in a few other bits of code that are all effectively trying to interpret a PartPosition — and I worry the "right" math may be easy to miss if we keep repeating it.

Dunno; maybe the math is isolated enough (if slice() is the one true user of PartPosition) or maybe this would just prove to be annoying in practice...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now we should probably just try to keep it contained in places that use it internally but stop it from leaking out. For example, if part_pos.index % 2 != 0 and part_pos.offset != 0: in parser_utils.py:ParserPositionTranslator should probably be some sort of function call into template_utils.py. It is in the SourceTracker iterator but that might be able to be refactored eventually.

Comment thread tdom/template_utils.py Outdated
)
else:
# @NOTE: No offset OR limit applied to interpolations.
return TemplateRef(strings=("", ""), i_indexes=((first - 1) // 2,))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This case feels surprising to me. I guess my default assumption for intervals is that they're half open aka start is inclusive and stop is exclusive. When making sure I understood slice(), I tried this example and was surprised by the result:

>>> tr = TemplateRef(strings=("ABC", "DEF", "GHI"), i_indexes=(0, 1))
>>> tr.slice(start=PartPosition(1, 0), stop=PartPosition(1, 0))
TemplateRef(strings=('', ''), i_indexes=(0,))  # actual result
TemplateRef(strings=('', ), i_indexes=())      # what I expected

Going a little further, if I really wanted just the interpolation, I'd do:

>>> tr.slice(start=PartPosition(1, 0), stop=PartPosition(2, 0))
TemplateRef(strings=('', ''), i_indexes=(0,))  # actual result *and* what I expected

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the double indexing if you want to call it that makes this more confusing. We cut up to but excluding limit. So if you have a stop then it is inclusive but only up to its limit and excludes after that... For an interpolation setting stop's offset does not make sense so its more like offset=None which is interpreted as "the end of thing". Similarly start's offset for an interpolation would be considered None or "the very start of the thing". So in that case it kind of makes sense that we are doing something like interpolations[start][None:None] which is the interpolation, this parallels the strings version (if it was coded correctly). Although maybe the offset should always be 0 for interpolations?

There is some sort of mental model that seems to be missing here. I might be trying to force a goat to masquerade as a horse by using "slice" instead of fillet, ie. where the parts are kind of awkwardly cut out depending on the structure of things to keep things sane and furthermore they must be re-assembled in a template compatible manner.

I'm more concerned that the overall strategy might not be viable for some reason. It seems to be "working" but how do you feel about the general strategy?
Parser LinePosition --> Template neutral PartPosition --> Template specific LinePosition

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, sorry, what I said was wrong, the default offset is always 0 for interpolations. So what you said makes sense and maybe clears up some confusion. Matching up these different "coordinate" systems is still a little convaluted though. Maybe I need to make a matrix of the different configurations to check the edge cases on this. My point about addressing the overall strategy still stands though.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to correct this and added assertions. Maybe the offset = 0 is a rules that should apply to all PartLocation's with odd indices (ie. interpolations)?

@davepeck davepeck Jul 26, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm more concerned that the overall strategy might not be viable for some reason. It seems to be "working" but how do you feel about the general strategy?

Sorry. I decided to start with very narrowly scoped feedback on individual functions/lines of code, but the big picture really is the important thing to tackle.

I've pretty much convinced myself that your general approach is a good one. In my notes I called it the "three coordinate system" approach: parser coordinates: LinePosition measured against placeholder text, part coordinates: PartPosition which, importantly, can safely sit behind a cache, and source coordinates: LinePosition against the original Python source text. I like that the final translation from part -> source coordinates is lazy, only if there's an error

There are two general things that I noted while reading this PR that seem worth calling out explicitly:

  1. Nothing that's cached should reference a live Template or its values -- aka TTree, TagSourceInfo, etc. This remains true with your PR.
  2. We never look at Interpolation values unless we've already decided there's an error; they are only used to make the messages better

I like both of those as constraints on tdom's code. There's sort of a third I'd like to be true, but I'm not sure it is just yet: error reporting should never hide an original error. I'm still sort of mulling over a bunch of cases here. And looking at a few more possible bugs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think 1 and 2 are good so far. For number 3 I used chaining to build on original errors and I think this is a good way to not lose that information. It can make reading the exception trace almost impossible though so I think eventually there might be a way to configure how things are displayed but as long as we store the right data I think that can all be adjusted.

Comment thread tdom/template_utils.py
# @NOTE: No limit applied to interpolations.
if index != last:
i_indexes.append((index - 1) // 2)
return TemplateRef(strings=tuple(strings), i_indexes=tuple(i_indexes))

@davepeck davepeck Jul 24, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I landed on this variant when I was trying to wrap my head around slice():

def slice(
    self,
    start: PartPosition | None = None,
    stop: PartPosition | None = None,
) -> TemplateRef:
    """
    Slice template ref based on the given start and stop. `start` is
	inclusive; `stop` is exclusive.
    """
    size = 2 * len(self.strings) - 1
    first = start.index if start else 0
    last = stop.index if stop else size - 1
    assert 0 <= first < size
    assert 0 <= last < size

    # If `first` is odd, we need an extra empty string
    strings: list[str] = [""] if first % 2 else []
    i_indexes: list[int] = []
    for index in range(first, last + 1):
        if index % 2:
		    # `offset` is meaningless for interpolations;
			# stop sits "before" an interpolation.
            if index == last:
                break
            i_indexes.append((index - 1) // 2)
        else:
            lo = start.offset if start and index == first else None
            hi = stop.offset if stop and index == last else None
            strings.append(self.strings[index // 2][lo:hi])
    return TemplateRef(strings=tuple(strings), i_indexes=tuple(i_indexes))

I think it has the same output on every case, except for the start=PartPosition(1, 0), stop=PartPosition(1, 0) cases.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks more concise but maybe we can keep the sort of "brainless" walking through the different cases version I have now until we know its working correctly or what we define as "correctly". Maybe I should even unroll the offset/limit like you have into the cases to make it even more "written out"?

@davepeck davepeck Jul 26, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think what's missing here is really a spec for exactly what we expect, and a set of test cases to match.

It's basically "half-open intervals aka start inclusive, stop exclusive; interpolation offsets are always 0".

Here's what I landed on for t"ABC{0}DEF{1}GHI":

start stop shorthand representation of output changed?
None None ['ABC', 0, 'DEF', 1, 'GHI']
(0,0) (0,0) ['']
(0,1) (0,2) ['B']
(0,0) (0,3) ['ABC']
(0,0) (0,9) ['ABC']
(0,1) (1,0) ['BC']
(0,1) (2,0) ['BC', 0, '']
(0,1) (2,2) ['BC', 0, 'DE']
(0,1) (3,0) ['BC', 0, 'DEF']
(0,1) (4,0) ['BC', 0, 'DEF', 1, '']
(0,1) (4,2) ['BC', 0, 'DEF', 1, 'GH']
(1,0) (1,0) [''] (was ['', 0, ''])
(1,0) (2,0) ['', 0, '']
(1,0) (2,2) ['', 0, 'DE']
(1,0) (3,0) ['', 0, 'DEF']
(1,0) (4,0) ['', 0, 'DEF', 1, '']
(2,0) (2,3) ['DEF']
(2,3) (3,0) ['']
(2,3) (4,0) ['', 1, '']
(3,0) (3,0) ['']  (was ['', 1, ''])
(3,0) (4,0) ['', 1, '']
(0,1) None ['BC', 0, 'DEF', 1, 'GHI']
(1,0) None ['', 0, 'DEF', 1, 'GHI']
(2,1) None ['EF', 1, 'GHI']
(3,0) None ['', 1, 'GHI']
(4,1) None ['HI']
None (2,1) ['ABC', 0, 'D']

Hopefully the shorthand is easy to understand, but, for example, ['EF', 1, 'GHI'] means TemplateRef(strings=('EF', 'GHI'), i_indexes=(1,))

Comment thread tdom/template_utils.py Outdated
def combine_template_refs(*template_refs: TemplateRef) -> TemplateRef:
"""Concatenate multiple template refs together into a single ref."""
# trefs -> naive templates -> naive template -> tref
return TemplateRef.from_naive_template(

@davepeck davepeck Jul 24, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(for the future: the from_naive/to_naive dance feels kinda silly here; i realize it's been around for a while, but seems like avoidable frobnication that I think only exists to deal with joining end strings?)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to remove it but I think we should rename "naive" templates to something else because when you read the usage its easy to miss that template values are being used as indexes. I think when I wrote it that was obvious but coming back later it is not obvious. It is really handy during testing though because you can initialize a TemplateRef right up from a t-string. Maybe something like load_tref_indexes_from_template() or something complicated and dangerous sounding.

Comment thread tdom/source.py
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}}}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great and doesn't need change.

But because this came up in another context (behavior in annotationlib after 3.14 shipped): there are several things PEP 750 left under- or un-specified. The cpython implementation made its choices. They lead to some super obscure edge cases here:

# cpython handling of the debug specifier (`=`) makes it impossible to reconstruct
>>> interpolation_repr(t"{42=}".interpolations[0])
'{42!r}'
# This one is courtesy of the current cpython parser; might be worth pushing a change
# to cpython; I doubt very many people care about or depend on the current behavior  
>>> interpolation_repr(t"{ 42 }".interpolations[0])
'{ 42}'
# I extra shrug my shoulders at this edge case, but:
>>> interpolation_repr(t"{42:{99}}".interpolations[0])
'{42:99}'

Okay, I've lived through my PEP 750 implementation trauma. Carry on. 😅

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess we'll have to be careful to try to print the source out with wiggle room so the source can print wrong but the error reporting itself doesn't crash as well. That {99} example is unexpected. I guess it was to make it compatible with f-strings. We can only hope it gets figured out in python 4.

@ianjosephwilson
ianjosephwilson force-pushed the ian/custom_error_handling branch from 4e6bc06 to 657e7d1 Compare July 26, 2026 15:53
Comment thread tdom/parser_utils.py Outdated
if index != last_index and first_nl_index == -1:
return PartPosition(index + 1, 0)
else:
return PartPosition(last_index, offset_found)

@davepeck davepeck Jul 27, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking my understanding: shouldn't this be return PartPosition(index, offset_found)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I think so. Looks like I introduced it by adding the check for first_nl_index == -1 after-the-fact. I'll try to see if I can make a test case for it to see what's happening.

@ianjosephwilson

Copy link
Copy Markdown
Contributor Author

I started paring this out, starting with #149 , I'm just going to move this back to draft.

@ianjosephwilson
ianjosephwilson marked this pull request as draft July 29, 2026 20:07
@davepeck

davepeck commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

I started paring this out, starting with #149 , I'm just going to move this back to draft.

Oh, amazing. Great. #149 looks like a good place to start, and very nicely contained. I'll review it soon.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants