diff --git a/anton/core/artifacts/report_tools.py b/anton/core/artifacts/report_tools.py index e84651b5a..a882a9a12 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,19 @@ 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)) + + +_TABLE_WRAP = '
' + + +def _table_html(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"{_TABLE_WRAP}{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 +169,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_html(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 +194,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 +231,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_html(columns, numeric, trs) + "
\n\n" ) @@ -253,7 +272,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 +454,30 @@ 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) + + +_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: + """The page's line break, taken from its first one. + + Not "any \\r\\n in the page": a value shown as text may carry one. + """ + first = _EOL.search(text) + return first.group() if first else "\n" + + +def _with_eol(markup: str, eol: str) -> str: + return _EOL.sub(eol, markup) if eol != "\n" else markup def _locate(text: str) -> _Locator: @@ -463,7 +505,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] + _with_eol(_FILTER_SCRIPT, _page_eol(text)) + text[at:] def _carries_id(markup: str, id_: str) -> bool: @@ -483,19 +525,26 @@ def update(path, changes: dict) -> dict: is kept unless the new content starts with a heading of its own. Markup that itself carries an element with the same id, such as a whole ``rt.section(..., id=)`` or ``rt.table(..., id=)``, replaces the element - rather than its content, so the id is never duplicated. Each id must match - exactly one element. A filter added to a page without the filter script - also adds the script. Returns a receipt of the ids changed and the old and - new file sizes. + rather than its content, so the id is never duplicated; a table's scroll + wrapper is replaced along with it. Each id must match exactly one element. + A filter added to a page without the filter script also adds the script. + Line breaks in new markup follow the page's ("\\n" or "\\r\\n"). Returns a + receipt of the ids changed and the old and new file sizes. """ path = Path(path) text = _read(path) + eol = _page_eol(text) locator = _locate(text) edits = [] for id_, content in changes.items(): if isinstance(content, Html) and _carries_id(content, id_): start, end = _one(locator.outer, id_) 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("") + 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 = _esc(content) @@ -505,7 +554,7 @@ def update(path, changes: dict) -> dict: else: start, end = _one(locator.spans, id_) new = _json_text(content) - edits.append((start, end, new)) + edits.append((start, end, _with_eol(new, eol))) edits.sort() for (s1, e1, _), (s2, _e2, _) in zip(edits, edits[1:]): if s2 < e1: @@ -524,17 +573,22 @@ def insert(path, content, *, before: str | None = None, after: str | None = None For new parts of an existing page, e.g. a decision summary above the detail: ``rt.insert(path, rt.section("Decision summary", rt.para(...)), - before="detail-section")``. Every other byte is unchanged, except that a - filter added to a page without the filter script also adds the script. - Plain text is escaped, as elsewhere. Returns a receipt like ``update``. + before="detail-section")``. A block from these helpers inserted after an + element goes after the line break that ends it, so it gets a line of its + own; other content goes right next to the element. Every other byte is + unchanged, except that a filter added to a page without the filter script + also adds the script. Plain text is escaped, as elsewhere. Line breaks in + new markup follow the page's. Returns a receipt like ``update``. """ if (before is None) == (after is None): raise ValueError("give exactly one of before= or after=") path = Path(path) text = _read(path) start, end = _one(_locate(text).outer, before or after) - at = start if before is not None else end - new = _esc(content) + new = _with_eol(_esc(content), _page_eol(text)) + at = start + if after is not None: + at = _block_end(text, end) if isinstance(content, Html) and new.endswith("\n") else end out = text[:at] + new + text[at:] if "data-rt-filter" in new: out = _with_filter_script(out) diff --git a/tests/test_report_tools.py b/tests/test_report_tools.py index af943a78e..422131bab 100644 --- a/tests/test_report_tools.py +++ b/tests/test_report_tools.py @@ -2,6 +2,7 @@ from __future__ import annotations import json +import re import shutil import subprocess from html.parser import HTMLParser @@ -28,6 +29,26 @@ def test_page_is_a_complete_offline_document(tmp_path): assert rt.check(out)["title"] == "Weekly review" +def test_blocks_and_rows_are_on_their_own_lines_and_text_has_no_line_breaks(): + page = _page(rt.section("Summary", rt.para("One.", "Two."), rt.bullets(["x", "y"])), + rt.section("Detail", rt.table(COLS, ROWS, caption="Gaps"), rt.bar_chart(["a", "b"], [1, 2], name="n")), + rt.filter_table(COLS, ROWS, key="Region", label="Region", region_name="Items", value="Gap"), + rt.data("state", {"v": 1}), subtitle="Week 41") + body = page[page.index(""):].replace(rt._FILTER_SCRIPT, "") + # A line break inside text would show as a space. + assert not re.search(r"[^>]\n|\n[^<]", body) + lines = body.splitlines() + for line in ("

One.

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