From f2e974cfaa1c1a82ca6b8abdad78030604b96e4e Mon Sep 17 00:00:00 2001 From: Max Stepanov Date: Fri, 9 Oct 2026 13:01:50 +0100 Subject: [PATCH 1/3] feat(report_tools): put blocks and table rows on their own lines (ENG-3358) Pages built with report_tools were one long line after , so line-based tools (read_text_file ranges, html_lint line numbers, diffs) could not address a single section. Block helpers now end their markup with a line break, one table row, list item, chart bar and option per line. Line breaks go only where the browser shows no whitespace; rendering is pixel-identical. update and insert keep that layout: a kept section heading keeps its line break, a whole-element replacement does not double the break, and insert(after=) puts a block after the element's line break. New markup uses the page's line endings. Co-Authored-By: Claude Opus 5.5 --- anton/core/artifacts/report_tools.py | 99 +++++++++++++++++++--------- tests/test_report_tools.py | 56 ++++++++++++++-- 2 files changed, 117 insertions(+), 38 deletions(-) diff --git a/anton/core/artifacts/report_tools.py b/anton/core/artifacts/report_tools.py index e84651b5a..ba923da9f 100644 --- a/anton/core/artifacts/report_tools.py +++ b/anton/core/artifacts/report_tools.py @@ -14,6 +14,11 @@ They are imported from the installed package rather than copied into artifact folders, so a report made with one release opens and edits with the next. + +Block-level markup (sections, paragraphs, list items, table rows, chart bars) +ends with a line break, so line-based tools can read and diff a page part by +part. Line breaks go only between elements where the browser does not show +whitespace, never inside text. """ from __future__ import annotations @@ -86,36 +91,39 @@ def _slug(text: str) -> str: if(empty){empty.hidden=shown>0;}} sel.addEventListener('change',apply);apply();});})(); """ -_FILTER_SCRIPT = f"" +_FILTER_SCRIPT = f"\n" def page(title: str, *parts, lang: str = "en", theme: str = "light", subtitle: str | None = None) -> Html: """A complete document: lang, charset, viewport, inline CSS, light or dark theme.""" if theme not in ("light", "dark"): raise ValueError('theme must be "light" or "dark"') + # Parts are joined without separators: a line break between two inline + # parts (text and a link) would show as a space. Block parts end with one. body = "".join(_esc(p) for p in parts) script = _FILTER_SCRIPT if "data-rt-filter" in body else "" - sub = f'

{_esc(subtitle)}

' if subtitle else "" + sub = f'

{_esc(subtitle)}

\n' if subtitle else "" return Html( - f'' - f'{_esc(title)}' - f"

{_esc(title)}

{sub}{body}{script}
\n" + f'\n\n\n\n' + f'\n{_esc(title)}\n' + f"\n\n\n
\n

{_esc(title)}

\n{sub}{body}{script}" + "
\n\n\n" ) def section(heading: str, *parts, id: str | None = None) -> Html: """A titled block. ``id`` defaults to the heading's slug plus "-section".""" - return Html(f'

{_esc(heading)}

' - + "".join(_esc(p) for p in parts) + "
") + return Html(f'

{_esc(heading)}

\n' + + "".join(_esc(p) for p in parts) + "
\n") def para(*texts) -> Html: """One paragraph per text.""" - return Html("".join(f"

{_esc(t)}

" for t in texts)) + return Html("".join(f"

{_esc(t)}

\n" for t in texts)) def bullets(items) -> Html: - return Html("") + return Html("\n") def _is_number(value) -> bool: @@ -140,9 +148,16 @@ def _numeric_columns(body, count) -> list[bool]: for i in range(count)] -def _thead(columns, numeric) -> str: - return "" + "".join( +def _tds(cells, numeric) -> str: + return "".join(f'{_esc(v)}' for v, n in zip(cells, numeric)) + + +def _table_wrap(columns, numeric, trs, *, table_attrs: str = "", caption: str = "") -> str: + """A scrollable table, one row per line. ``trs`` are whole ```` elements.""" + head = "" + "".join( f'{_esc(c)}' for c, n in zip(columns, numeric)) + "" + return (f'
{caption}\n{head}\n\n' + + "".join(tr + "\n" for tr in trs) + "
\n") def table(columns, rows, *, caption: str | None = None, id: str | None = None) -> Html: @@ -151,9 +166,8 @@ def table(columns, rows, *, caption: str | None = None, id: str | None = None) - body = [_row_cells(columns, r) for r in rows] numeric = _numeric_columns(body, len(columns)) cap = f"{_esc(caption)}" if caption else "" - trs = "".join("" + "".join(f'{_esc(v)}' for v, n in zip(r, numeric)) - + "" for r in body) - return Html(f'
{cap}{_thead(columns, numeric)}{trs}
') + trs = [f"{_tds(r, numeric)}" for r in body] + return Html(_table_wrap(columns, numeric, trs, table_attrs=_attr_id(id), caption=cap)) def bar_chart(labels, values, *, name: str, value_label: str = "Value", threshold: float | None = None, @@ -177,13 +191,14 @@ def bar_chart(labels, values, *, name: str, value_label: str = "Value", threshol bars.append(f'{_esc(label)}' f'' f"{_esc(label)}: {_esc(value)} {_esc(value_label)}" - f'{_esc(value)}') + f'{_esc(value)}\n') mark = "" if threshold is not None: x = left + width * threshold / top - mark = f'Threshold {_esc(threshold)}' + mark = (f'' + f"Threshold {_esc(threshold)}\n") return Html(f'{_esc(name)}{"".join(bars)}{mark}') + f'viewBox="0 0 {left + width + 60} {height}">{_esc(name)}\n{"".join(bars)}{mark}\n') def filter_table(columns, rows, *, key: str, label: str, region_name: str, value: str | None = None, @@ -213,19 +228,20 @@ def filter_table(columns, rows, *, key: str, label: str, region_name: str, value total += r[v] if visible and v is not None and _is_number(r[v]) else 0 val_attr = f' data-rt-value="{_esc(r[v])}"' if v is not None and _is_number(r[v]) else "" trs.append(f'' - + "".join(f'{_esc(c)}' for c, n in zip(r, numeric)) + "") + f"{_tds(r, numeric)}") total_html = "" if v is not None: tl = total_label or f"Total {value}" - total_html = f'

{_esc(tl)}: {_esc(round(total, 6))}

' + total_html = f'

{_esc(tl)}: {_esc(round(total, 6))}

\n' + # The label and the select are inline: a line break between them would show as a space. return Html( f'
' f'' - f'" - f'

{_esc(region_name)}

{total_html}' - f'

{_esc(empty_text)}

' - f'
{_thead(columns, numeric)}{"".join(trs)}
' + f'\n" + f'

{_esc(region_name)}

\n{total_html}' + f'

{_esc(empty_text)}

\n' + + _table_wrap(columns, numeric, trs) + "
\n\n" ) @@ -253,7 +269,7 @@ def _json_text(value) -> str: def data(id: str, value) -> Html: """An inert JSON block, for data the author chooses to embed (nothing is embedded otherwise).""" - return Html(f'') + return Html(f'\n') def save(path, html: str) -> Path: @@ -435,7 +451,20 @@ def handle_endtag(self, tag): return -_LEADING_HEADING = re.compile(r"\s*]*>.*?", re.I | re.S) +# The line break after a kept heading stays with it, so new content starts on its own line. +_LEADING_HEADING = re.compile(r"\s*]*>.*?(?:\r?\n)?", re.I | re.S) + + +def _newline_at(text: str, at: int) -> str: + """The line break that starts at ``at``, or "".""" + if text.startswith("\r\n", at): + return "\r\n" + return "\n" if text.startswith("\n", at) else "" + + +def _in_page_line_endings(markup: str, page: str) -> str: + """``markup`` with "\\r\\n" line breaks when the page uses them.""" + return re.sub(r"\r?\n", "\r\n", markup) if "\r\n" in page else markup def _locate(text: str) -> _Locator: @@ -463,7 +492,7 @@ def _with_filter_script(text: str) -> str: return text body_ends = list(re.finditer(r"", text, re.I)) at = body_ends[-1].start() if body_ends else len(text) - return text[:at] + _FILTER_SCRIPT + text[at:] + return text[:at] + _in_page_line_endings(_FILTER_SCRIPT, text) + text[at:] def _carries_id(markup: str, id_: str) -> bool: @@ -495,10 +524,14 @@ def update(path, changes: dict) -> dict: for id_, content in changes.items(): if isinstance(content, Html) and _carries_id(content, id_): start, end = _one(locator.outer, id_) - new = str(content) + new = _in_page_line_endings(str(content), text) + # The page already breaks the line after the element. + eol = _newline_at(text, end) + if eol and new.endswith(eol): + new = new[:-len(eol)] elif isinstance(content, str): start, end = _one(locator.spans, id_) - new = _esc(content) + new = _in_page_line_endings(_esc(content), text) heading = _LEADING_HEADING.match(text, start, end) if heading and not re.match(r"\s*"):].replace(rt._FILTER_SCRIPT, "") + # A line break inside text would show as a space. + assert all(body[i - 1] == ">" and body[i + 1:i + 2] in ("<", "") for i, c in enumerate(body) if c == "\n") + lines = body.splitlines() + for line in ("

One.

", "
  • y
  • ", "C-300North25", + "", "", ''): + assert line in lines + assert sum(line.startswith('\n" f'

    {_esc(region_name)}

    \n{total_html}' f'

    {_esc(empty_text)}

    \n' - + _table_wrap(columns, numeric, trs) + "
    \n\n" + + _table_html(columns, numeric, trs) + "\n\n" ) @@ -458,11 +458,13 @@ def handle_endtag(self, tag): _LEADING_HEADING = re.compile(r"\s*]*>.*?(?:\r?\n)?", re.I | re.S) -def _newline_at(text: str, at: int) -> str: - """The line break that starts at ``at``, or "".""" - if text.startswith("\r\n", at): - return "\r\n" - return "\n" if text.startswith("\n", at) else "" +_EOL = re.compile(r"\r?\n") + + +def _block_end(text: str, end: int) -> int: + """``end`` moved past the line break that ends a block there, if any.""" + found = _EOL.match(text, end) + return found.end() if found else end def _page_eol(text: str) -> str: @@ -470,12 +472,12 @@ def _page_eol(text: str) -> str: Not "any \\r\\n in the page": a value shown as text may carry one. """ - first = re.search(r"\r?\n", text) + first = _EOL.search(text) return first.group() if first else "\n" def _with_eol(markup: str, eol: str) -> str: - return re.sub(r"\r?\n", eol, markup) if eol != "\n" else markup + return _EOL.sub(eol, markup) if eol != "\n" else markup def _locate(text: str) -> _Locator: @@ -537,24 +539,22 @@ def update(path, changes: dict) -> dict: for id_, content in changes.items(): if isinstance(content, Html) and _carries_id(content, id_): start, end = _one(locator.outer, id_) - new = _with_eol(str(content), eol) + new = str(content) # rt.table brings its own wrapper; replacing only the would nest it in the old one. if new.startswith(_TABLE_WRAP) and text.endswith(_TABLE_WRAP, 0, start) and text.startswith("", end): start, end = start - len(_TABLE_WRAP), end + len("") - # The page already breaks the line after the element. - after = _newline_at(text, end) - if after and new.endswith(after): - new = new[:-len(after)] + if new.endswith("\n"): + end = _block_end(text, end) # the new block brings its own line break elif isinstance(content, str): start, end = _one(locator.spans, id_) - new = _with_eol(_esc(content), eol) + new = _esc(content) heading = _LEADING_HEADING.match(text, start, end) if heading and not re.match(r"\s*"):].replace(rt._FILTER_SCRIPT, "") # A line break inside text would show as a space. - assert all(body[i - 1] == ">" and body[i + 1:i + 2] in ("<", "") for i, c in enumerate(body) if c == "\n") + assert not re.search(r"[^>]\n|\n[^<]", body) lines = body.splitlines() for line in ("

    One.

    ", "
  • y
  • ", "
    ", "", "", ''):
    C-300North25