From dc6ca7e5f4dd7c2c1a4f7ee775bd908034614bd5 Mon Sep 17 00:00:00 2001 From: baraline Date: Thu, 8 Oct 2026 09:58:14 +0200 Subject: [PATCH] feat(content): a rewrite_link hook and embedded image helpers for the reader (0.4.3) A caller that mirrors memos to another system has to recognise the images a memo embeds and decide what they become, without parsing HTML or Markdown itself. EasyVista's editor keeps a pasted image as an attachment of the request and embeds it as , which the reader wrote as an image that displays nowhere else. - from_transport(..., rewrite_link=callback) calls the callback with a Link for each link and image met outside code, an image before the link around it (enclosing_href). Its answer is written: None keeps the reader's output, a str is literal text escaped as a text node, a Link replaces the target, and an empty href drops a link (content kept) or writes an image as its text. Without the argument a read is unchanged, byte for byte. - The callback lives in a ContextVar, so threads and tasks each read with their own, and a read inside a callback has its own. What it raises reaches the caller unchanged: carried past the reader's ValueError text fallback, which would otherwise have taken a callback's ValueError for markdownify's and quietly dropped the body's formatting. - document_id_of(src) and document_image(document_id, alt=...): the second is spelled by the reader itself, so from_transport(to_transport(x)) == x. Measured on one instance on 2026-10-07: an image written that way through the API displays, for an attachment pasted in the editor and for one uploaded with add_document. The upload's id has another shape than a pasted one's; the id class covers both. Tests: test_rewrite_link.py (none-answer parity over the round-trip corpus, offer order, each answer kind, code skipped, exceptions, threads, nesting, helpers). 21 mutants of the new guards, all killed. Full suite 2304 passed; mypy, ruff, sync lint and sphinx -W clean. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 32 +- docs/content.rst | 54 +++ easyvista_python_client/_version.py | 2 +- easyvista_python_client/content/__init__.py | 8 +- easyvista_python_client/content/conversion.py | 256 +++++++++++- .../content/tests/test_rewrite_link.py | 372 ++++++++++++++++++ .../testing/test_public_api.py | 2 +- pyproject.toml | 2 +- skills/easyvista-asset-workflow/SKILL.md | 2 +- skills/easyvista-client-setup/SKILL.md | 2 +- skills/easyvista-directory/SKILL.md | 2 +- skills/easyvista-document-workflow/SKILL.md | 2 +- skills/easyvista-instance-discovery/SKILL.md | 2 +- .../easyvista-reporting-and-context/SKILL.md | 2 +- skills/easyvista-search-syntax/SKILL.md | 2 +- skills/easyvista-ticket-actions/SKILL.md | 2 +- skills/easyvista-ticket-workflow/SKILL.md | 2 +- 17 files changed, 710 insertions(+), 36 deletions(-) create mode 100644 easyvista_python_client/content/tests/test_rewrite_link.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 14d3385..42afa9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,35 @@ is the error. Tags carry no `v` prefix. ## [Unreleased] +## [0.4.3] - 2026-10-08 + +A patch release of the content reader: a caller can recognise the images a +memo embeds and decide, link by link, what a read writes. Without the new +argument a read is unchanged, byte for byte; writing is unchanged, and so is +everything outside `easyvista_python_client.content`. + +### Added + +- **`from_transport(..., rewrite_link=callback)`.** The callback is called with + a `Link` for each link and image the read meets outside code -- an image + before the link around it, with that link's `enclosing_href` -- and its + answer is written: `None` keeps the reader's output, a `str` is written as + literal text, a `Link` replaces the target (an empty `href` drops a link and + keeps its content, or writes an image as its text). The callback is held in + a context variable, so concurrent reads each use their own. What it raises + reaches the caller unchanged; a `ValueError` is not taken for markdownify's + and answered with the text fallback. +- **`Link` and `RewriteLink`**, exported from `easyvista_python_client.content`. +- **`EasyvistaContentConverter.document_id_of(src)`** returns the + `DOCUMENT_ID` of an embedded image's `src` + (`@@EMBEDDED_IMAGE_PATH@@`, how EasyVista's editor embeds a + pasted image), and **`document_image(document_id, alt=...)`** the Markdown + of one, spelled as the reader spells it. A `Link` for such an image carries + its `document_id`. Measured on one instance on 2026-10-07: an image written + that way through the API displays, for an attachment pasted in the editor + and for one uploaded with `add_document`. +- `EasyvistaContentConverter.EMBEDDED_IMAGE_PREFIX`. + ## [0.4.2] - 2026-10-07 A patch release of the content writer: a link written into a memo opens in a @@ -1661,7 +1690,8 @@ Initial public release. status/error code, with non-retryable validation errors (HTTP 590, code 2013). - `py.typed` marker — the package ships inline type information. -[Unreleased]: https://github.com/baraline/easyvista_python_client/compare/0.4.2...HEAD +[Unreleased]: https://github.com/baraline/easyvista_python_client/compare/0.4.3...HEAD +[0.4.3]: https://github.com/baraline/easyvista_python_client/compare/0.4.2...0.4.3 [0.4.2]: https://github.com/baraline/easyvista_python_client/compare/0.4.1...0.4.2 [0.4.1]: https://github.com/baraline/easyvista_python_client/compare/0.4.0...0.4.1 [0.4.0]: https://github.com/baraline/easyvista_python_client/compare/0.3.0...0.4.0 diff --git a/docs/content.rst b/docs/content.rst index 89312ad..0f3a514 100644 --- a/docs/content.rst +++ b/docs/content.rst @@ -234,6 +234,60 @@ reported as :class:`~easyvista_python_client.exceptions.EasyvistaContentError`. A block quote nested 100,000 levels deep rendered without one (measured 2026-10-02). +Embedded images, and rewriting links as they are read +----------------------------------------------------- + +EasyVista's editor keeps an image pasted into a memo as an attachment of the +request, named like ``image_emb_001.png``, and embeds it as +````. Without a callback the +reader writes that as any other image, ``![](@@EMBEDDED_IMAGE_PATH@@...)``, +which displays nowhere but in EasyVista. Three helpers let a caller do better. + +``EasyvistaContentConverter.document_id_of(src)`` returns the ``DOCUMENT_ID`` +an image's ``src`` embeds, or ``None``. ``document_image(document_id, alt=...)`` +returns the Markdown of such an image, spelled exactly as the reader spells +it, so ``to_transport`` writes the editor's form and ``from_transport`` reads +it back unchanged. **Tier 4** -- measured on one instance on 2026-10-07: an +image written that way through the API displays, both for an attachment +pasted in the editor and for one uploaded with ``add_document``. The memo +displays it only while the attachment is on the same request. + +``from_transport(..., rewrite_link=callback)`` calls ``callback`` with a +:class:`~easyvista_python_client.content.Link` for each link and each image it +meets outside code, an image before the link around it. A ``Link`` carries the +``href`` (an image's ``src``), the ``text`` (an image's ``alt``), the +``title``, whether it is an ``image``, an embedded image's ``document_id``, and +for an image inside a link that link's ``enclosing_href``. What the callback +answers is written: + +* ``None``: what the reader writes without a callback; +* a ``str``: that text, literally -- escaped as any text the memo displays, so + it cannot become markup; +* a ``Link``: a link's ``href`` and ``title`` around the link's content as + read, or an image's ``href`` as its ``src``, ``text`` as its ``alt`` and + ``title``; +* a ``Link`` with an empty ``href``: a link is dropped and its content kept, an + image is written as its ``text``. + +.. code-block:: python + + from easyvista_python_client.content import EasyvistaContentConverter, Link + + def name_attachments(link: Link) -> Link | str | None: + if link.document_id is not None: + return f"[attachment {link.document_id}]" + return None + + EasyvistaContentConverter.from_transport(memo, rewrite_link=name_attachments) + +The callback is held for the duration of one read, per thread and per task, +so concurrent reads each use their own, and a read inside a callback has its +own too. What the callback raises reaches the caller unchanged -- a +``ValueError`` included, which the reader would otherwise take for one of +markdownify's and answer by reading the body as its text. An answer of any +other type raises ``TypeError``. It is not called for a value passed through +as Markdown (``plain_text_is_markdown=True``), nor for a body read as its text. + It is not a sanitiser --------------------- diff --git a/easyvista_python_client/_version.py b/easyvista_python_client/_version.py index 3ce3d4c..0907569 100644 --- a/easyvista_python_client/_version.py +++ b/easyvista_python_client/_version.py @@ -5,4 +5,4 @@ live only in ``__init__``. """ -__version__ = "0.4.2" +__version__ = "0.4.3" diff --git a/easyvista_python_client/content/__init__.py b/easyvista_python_client/content/__init__.py index fd04f8c..be1bd6a 100644 --- a/easyvista_python_client/content/__init__.py +++ b/easyvista_python_client/content/__init__.py @@ -13,6 +13,10 @@ from __future__ import annotations -from easyvista_python_client.content.conversion import EasyvistaContentConverter +from easyvista_python_client.content.conversion import ( + EasyvistaContentConverter, + Link, + RewriteLink, +) -__all__ = ["EasyvistaContentConverter"] +__all__ = ["EasyvistaContentConverter", "Link", "RewriteLink"] diff --git a/easyvista_python_client/content/conversion.py b/easyvista_python_client/content/conversion.py index 45a4960..0308b3d 100644 --- a/easyvista_python_client/content/conversion.py +++ b/easyvista_python_client/content/conversion.py @@ -59,9 +59,11 @@ import string import unicodedata from collections.abc import Callable, Iterator, Mapping, MutableMapping, Sequence +from contextvars import ContextVar +from dataclasses import dataclass from functools import cached_property from html import escape, unescape -from typing import Any, cast +from typing import Any, ClassVar, cast from easyvista_python_client.exceptions import EasyvistaContentError @@ -396,6 +398,97 @@ def _title(title: str) -> str: return ' "' + re.sub(r'[\\"]', r"\\\g<0>", title) + '"' if title else "" +#: How a memo embeds an image: EasyVista's editor stores a pasted image as an +#: attachment of the request and writes ```` +#: with that attachment's ``DOCUMENT_ID``. Measured 2026-10-07 on one instance +#: (tier 4): an image written that way through the API displays too, for an +#: attachment pasted in the editor and for one uploaded with ``add_document``. +EMBEDDED_IMAGE_PREFIX = "@@EMBEDDED_IMAGE_PATH@@" + +#: A ``DOCUMENT_ID``, as an embedded image's ``src`` carries one. Measured: 40 hex +#: digits for an image pasted in the editor, an account number, ``_`` and 64 hex +#: digits for a file uploaded through the API. The class is wider than both. +_DOCUMENT_ID = re.compile(r"[A-Za-z0-9_.-]{1,128}") + + +@dataclass(frozen=True, slots=True) +class Link: + """A link or an image the reader meets, as a ``rewrite_link`` callback sees it. + + Attributes + ---------- + href : str + An ````'s ``href`` or an ````'s ``src``, character references + decoded. + text : str + An ````'s displayed text or an ````'s ``alt``, whitespace + collapsed. + title : str + The ``title``, or ``""``. + image : bool + ``True`` for an ````. + document_id : str or None + For an ```` embedding an attachment of the request + (:data:`EMBEDDED_IMAGE_PREFIX` and a ``DOCUMENT_ID``), that + ``DOCUMENT_ID``; ``None`` otherwise, and always for an ````. + enclosing_href : str or None + For an ```` inside a link, that link's ``href``; ``None`` + otherwise. The image is read before the link around it. + """ + + href: str + text: str = "" + title: str = "" + image: bool = False + document_id: str | None = None + enclosing_href: str | None = None + + +#: A ``rewrite_link`` callback: called with each link and image a read meets, +#: outside code. ``None`` keeps what the reader writes without one. A ``str`` is +#: written instead, as literal text. A :class:`Link` is written in the reader's +#: own spelling: a link's ``href`` and ``title``, around the link's content as +#: read; an image's ``href`` as its ``src``, ``text`` as its ``alt`` and +#: ``title``. An empty ``href`` drops a link and keeps its content, and writes an +#: image as its ``text``. +RewriteLink = Callable[[Link], "Link | str | None"] + +_REWRITE: ContextVar[RewriteLink | None] = ContextVar("rewrite_link", default=None) +"""The callback of the read in progress. A context variable rather than state +on the shared converter: each thread and each task reads with its own.""" + + +class _CallbackRaised(Exception): + """What a ``rewrite_link`` callback raised, carried past the reader's fallbacks. + + The reader answers a ``ValueError`` by reading the body as its text; a + callback's own ``ValueError`` must not pass for markdownify's and quietly + cost the body its formatting. + """ + + def __init__(self, error: Exception) -> None: + super().__init__(error) + self.error = error + + +def _rewritten(link: Link) -> Link | str | None: + """Ask the read's callback about ``link``; ``None`` when there is none.""" + + rewrite = _REWRITE.get() + if rewrite is None: + return None + try: + answer: object = rewrite(link) # typed as what it is, not what it should be + except Exception as exc: + raise _CallbackRaised(exc) from exc + if answer is None or isinstance(answer, (Link, str)): + return answer + error = TypeError( + f"rewrite_link returned {type(answer).__name__}; expected Link, str or None" + ) + raise _CallbackRaised(error) + + class _Converter(MarkdownConverter): """``markdownify``, with what CommonMark and the reader's glue need on top. @@ -553,15 +646,27 @@ def convert_pre(self, el: Tag, text: str, parent_tags: set[str]) -> str: body, _, tail = rest.rpartition("```") return f"{head}{fence}{body}{fence}{tail}" + def _literal(self, text: str, parent_tags: set[str]) -> str: + """``text`` escaped as the reader escapes a text node: displayed as it is.""" + + return self.escape(" ".join(text.split()), parent_tags) + def convert_a(self, el: Tag, text: str, parent_tags: set[str]) -> str: """A link; ```` when its text is its URL and reads back unchanged.""" + if "_noformat" in parent_tags: + return text href = str(el.get("href") or "") + title = str(el.get("title") or "") + answer = _rewritten(Link(href, " ".join(el.get_text().split()), title)) + if isinstance(answer, str): + return self._literal(answer, parent_tags) + if answer is not None: + href, title = answer.href, answer.title edges = _EDGES.fullmatch(text) - if "_noformat" in parent_tags or not href or edges is None or not edges[2]: + if not href or edges is None or not edges[2]: return text before, inner, after = edges.groups() - title = str(el.get("title") or "") if not title and el.get_text() == href and _AUTOLINK.fullmatch(href): return f"{before}<{href}>{after}" return f"{before}[{inner}]({_destination(href)}{_title(title)}){after}" @@ -569,12 +674,38 @@ def convert_a(self, el: Tag, text: str, parent_tags: set[str]) -> str: def convert_img(self, el: Tag, text: str, parent_tags: set[str]) -> str: """An image, wherever it is: a cell or a heading holds one too.""" + src = str(el.get("src") or "") alt = " ".join(str(el.get("alt") or "").split()) + title = str(el.get("title") or "") + if "_noformat" not in parent_tags: + embedded = EasyvistaContentConverter.document_id_of(src) + around = el.find_parent("a") + answer = _rewritten( + Link( + src, + alt, + title, + image=True, + document_id=embedded, + enclosing_href=( + None if around is None else str(around.get("href") or "") + ), + ) + ) + if isinstance(answer, str): + return self._literal(answer, parent_tags) + if answer is not None and not answer.href: + return self._literal(answer.text, parent_tags) + if answer is not None: + src, alt, title = ( + answer.href, + " ".join(answer.text.split()), + answer.title, + ) alt = str(self._inherited("escape")(alt, parent_tags)) if alt.startswith("^") and "_noformat" not in parent_tags: alt = "\\" + alt # cmark-gfm reads "![^" as "!" and a link - src = _destination(str(el.get("src") or "")) - return f"![{alt}]({src}{_title(str(el.get('title') or ''))})" + return f"![{alt}]({_destination(src)}{_title(title)})" class _Node(RenderTreeNode): @@ -784,11 +915,41 @@ def _text_of(html: str) -> str: return "\n".join(line for line in lines if line) +def _converted(content: str) -> str: + """Read memo HTML, falling back to its text where the conversion cannot.""" + + try: + try: + return html_to_markdown(content) + except (RecursionError, ValueError): # ValueError: markdownify's + # int() of a colspan or start such as "²" or 5,000 digits + return html_to_markdown(_plain_text_html(_text_of(content))) + except ParserRejectedMarkup: + # html.parser gives up on a few malformed declarations; the + # body's words are still worth more than an exception. + text = unescape(_ANY_TAG.sub(" ", content)) + return html_to_markdown(_plain_text_html(" ".join(text.split()))) + except _CallbackRaised: + raise + except Exception as exc: + raise EasyvistaContentError( + "Could not convert EasyVista memo HTML to Markdown " + f"({type(exc).__name__}: {exc})." + ) from exc + + class EasyvistaContentConverter: """Convert content between EasyVista memo HTML and canonical Markdown.""" + EMBEDDED_IMAGE_PREFIX: ClassVar[str] = EMBEDDED_IMAGE_PREFIX + @staticmethod - def from_transport(value: object, *, plain_text_is_markdown: bool = False) -> str: + def from_transport( + value: object, + *, + plain_text_is_markdown: bool = False, + rewrite_link: RewriteLink | None = None, + ) -> str: """Convert one EasyVista memo value into Markdown. Parameters @@ -806,6 +967,12 @@ def from_transport(value: object, *, plain_text_is_markdown: bool = False) -> st ```` is still Markdown, but Markdown that opens with an autolink or other angle-bracketed text and carries inline HTML further on is read as HTML, and loses that autolink. + rewrite_link : callable, optional + Called with a :class:`Link` for each link and image the read + meets outside code, an image before the link around it; its + answer decides what is written (:data:`RewriteLink`). Not called + for a value passed through as Markdown, nor for a body read as + its text. Without one, the read is what it always was. Returns ------- @@ -825,6 +992,10 @@ def from_transport(value: object, *, plain_text_is_markdown: bool = False) -> st limit, where no stack is left even to report the failure as ``EasyvistaContentError`` (``docs/content.rst`` gives the measured depths). + Exception + Whatever ``rewrite_link`` raised, unchanged; ``TypeError`` when it + answered something other than a :class:`Link`, a ``str`` or + ``None``. """ content = str(value or "").strip() @@ -840,22 +1011,65 @@ def from_transport(value: object, *, plain_text_is_markdown: bool = False) -> st # 3.11.14/3.12.12/3.13.6 rescans to the end for each (quadratic). head, end, tail = content.rpartition(">") content = head + end + tail.replace("<", "<") + token = _REWRITE.set(rewrite_link) try: - try: - return html_to_markdown(content) - except (RecursionError, ValueError): # ValueError: markdownify's - # int() of a colspan or start such as "²" or 5,000 digits - return html_to_markdown(_plain_text_html(_text_of(content))) - except ParserRejectedMarkup: - # html.parser gives up on a few malformed declarations; the - # body's words are still worth more than an exception. - text = unescape(_ANY_TAG.sub(" ", content)) - return html_to_markdown(_plain_text_html(" ".join(text.split()))) - except Exception as exc: - raise EasyvistaContentError( - "Could not convert EasyVista memo HTML to Markdown " - f"({type(exc).__name__}: {exc})." - ) from exc + return _converted(content) + except _CallbackRaised as raised: + callback_error = raised.error + finally: + _REWRITE.reset(token) + raise callback_error # outside the handler: the callback's, as it raised it + + @staticmethod + def document_id_of(src: str) -> str | None: + """Return the ``DOCUMENT_ID`` an embedded image's ``src`` names. + + ``None`` unless ``src`` is exactly :data:`EMBEDDED_IMAGE_PREFIX` + followed by a ``DOCUMENT_ID`` -- how EasyVista's editor refers to an + image pasted into a memo, which it keeps as an attachment of the + request. + """ + + if not src.startswith(EMBEDDED_IMAGE_PREFIX): + return None + document_id = src[len(EMBEDDED_IMAGE_PREFIX) :] + return document_id if _DOCUMENT_ID.fullmatch(document_id) else None + + @staticmethod + def document_image(document_id: str, *, alt: str = "") -> str: + """Return the Markdown of an image embedding one of the request's attachments. + + :meth:`to_transport` renders it as EasyVista's editor writes a pasted + image, ``...``, + and :meth:`from_transport` reads that back as this same Markdown: it + is spelled by the reader itself. The memo displays the image once the + attachment is on the same request. + + Parameters + ---------- + document_id : str + The attachment's ``DOCUMENT_ID``, as ``list_documents`` gives it. + alt : str, optional + The image's alternative text. + + Raises + ------ + ValueError + ``document_id`` is not one: empty, or holding a character no + ``DOCUMENT_ID`` has. + """ + + if not _DOCUMENT_ID.fullmatch(document_id): + raise ValueError(f"not an EasyVista DOCUMENT_ID: {document_id!r}") + native = ( + f'

' + ) + token = _REWRITE.set(None) # a callback in force for an enclosing read + try: + return html_to_markdown(native) + finally: + _REWRITE.reset(token) @staticmethod def to_transport(value: object) -> str: diff --git a/easyvista_python_client/content/tests/test_rewrite_link.py b/easyvista_python_client/content/tests/test_rewrite_link.py new file mode 100644 index 0000000..2270184 --- /dev/null +++ b/easyvista_python_client/content/tests/test_rewrite_link.py @@ -0,0 +1,372 @@ +"""The read's ``rewrite_link`` hook, and the embedded image helpers built beside it. + +A caller that mirrors memos between systems has to recognise an image the memo +embeds -- an attachment of the request, referred to by its ``DOCUMENT_ID`` -- +and say what it becomes, without parsing the HTML or the Markdown itself. The +hook hands it each link and image as the reader meets it; ``document_image`` +writes the native form back. +""" + +from __future__ import annotations + +import threading + +import pytest + +from easyvista_python_client.content import ( + EasyvistaContentConverter, + Link, + RewriteLink, +) +from easyvista_python_client.content.tests.test_round_trip import ( + E_MAIL_SHAPES, + EARLIER_REGRESSIONS, + REALISTIC, +) + +read = EasyvistaContentConverter.from_transport +render = EasyvistaContentConverter.to_transport +document_image = EasyvistaContentConverter.document_image +document_id_of = EasyvistaContentConverter.document_id_of + +#: An editor-pasted image's ``DOCUMENT_ID`` has this shape, and an uploaded file's +#: ``ACCOUNT``. Both synthetic. +PASTED = "0123456789abcdef0123456789abcdef01234567" +UPLOADED = "12345_" + "ab" * 32 + + +def offered(html: str) -> list[Link]: + """Read ``html`` with a callback that keeps everything, and return what it saw.""" + + seen: list[Link] = [] + + def keep(link: Link) -> None: + seen.append(link) + + read(html, rewrite_link=keep) + return seen + + +# --------------------------------------------------------------------------- +# What the callback is offered +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "html", + REALISTIC + + E_MAIL_SHAPES + + [html for bodies in EARLIER_REGRESSIONS.values() for html in bodies], +) +def test_a_callback_answering_none_changes_nothing(html: str) -> None: + assert read(html, rewrite_link=lambda link: None) == read(html) + + +def test_each_link_and_image_is_offered_once_an_image_before_its_link() -> None: + html = ( + '

' + ' un  logo ' + ' et le & site

' + ) + + assert offered(html) == [ + Link( + "https://x.example/i.png", + "un logo", + "it", + image=True, + enclosing_href="https://x.example/p", + ), + Link("https://x.example/p", "", "t"), + Link("https://y.example", "le & site"), + ] + + +def test_an_embedded_image_is_offered_with_its_document_id() -> None: + html = ( + f'

' + f'x

' + ) + + assert [link.document_id for link in offered(html)] == [PASTED, UPLOADED] + + +@pytest.mark.parametrize( + "src", + [ + "https://x.example/i.png", + f"/@@EMBEDDED_IMAGE_PATH@@{PASTED}", + "@@EMBEDDED_IMAGE_PATH@@", + "@@EMBEDDED_IMAGE_PATH@@a b", + "@@EMBEDDED_IMAGE_PATH@@a)b", + "@@embedded_image_path@@" + PASTED, + ], +) +def test_any_other_src_carries_no_document_id(src: str) -> None: + [link] = offered(f'

x

') + assert link.document_id is None + + +def test_a_link_carries_no_document_id_whatever_its_href() -> None: + [link] = offered(f'

x

') + assert link.document_id is None + assert not link.image + + +@pytest.mark.parametrize( + "html", + [ + '
x
', + '

x

', + ], +) +def test_nothing_inside_code_is_offered(html: str) -> None: + # Code is displayed as written: what looks like a link there is not one. + assert offered(html) == [] + + +def test_nothing_is_offered_for_markdown_passed_through() -> None: + assert offered("[x](https://x.example)") == [] + read("[x](https://x.example)", plain_text_is_markdown=True, rewrite_link=_fail) + + +def _fail(link: Link) -> None: + raise AssertionError(f"offered {link!r}") + + +# --------------------------------------------------------------------------- +# What the answer writes +# --------------------------------------------------------------------------- + + +def test_a_link_answered_with_a_link_is_written_with_its_href_and_title() -> None: + markdown = read( + '

le site

', + rewrite_link=lambda link: Link("https://b.example", title="T"), + ) + + assert markdown == '[le **site**](https://b.example "T")' + + +def test_a_link_answered_with_no_href_is_dropped_and_its_content_kept() -> None: + markdown = read( + '

voir le site ici

', + rewrite_link=lambda link: Link(""), + ) + + assert markdown == "voir le **site** ici" + + +def test_an_image_answered_with_a_link_is_written_with_its_src_alt_and_title() -> None: + markdown = read( + '

a

', + rewrite_link=lambda link: Link("https://b.example/j.png", "b", "t", image=True), + ) + + assert markdown == '![b](https://b.example/j.png "t")' + + +def test_an_image_answered_with_no_href_is_written_as_its_text() -> None: + markdown = read( + '

voir a ici

', + rewrite_link=lambda link: Link("", "capture_1.png", image=True), + ) + + assert markdown == "voir capture_1.png ici" + + +@pytest.mark.parametrize( + "html", + ['

x

', '

x

'], +) +def test_a_string_answer_is_literal_text(html: str) -> None: + # Escaped as the reader escapes a text node, so it displays as it is and + # cannot become markup -- whatever the callback hands back. + text = "**pas gras** [x](javascript:alert(1)) " + + markdown = read(html, rewrite_link=lambda link: text) + + assert render(markdown) == "

**pas gras** [x](javascript:alert(1)) <b>

" + assert read(render(markdown)) == markdown + + +def test_the_editor_wrap_collapses_to_the_image_alone() -> None: + # A link around an image of the same attachment: the image is rewritten, + # then the link around it dropped, its content -- the new image -- kept. + html = ( + f'

' + f'x

' + ) + + def rewrite(link: Link) -> Link | None: + if link.document_id is not None: + return Link(f"https://files.example/{link.document_id}", image=True) + if link.href.startswith("@@EMBEDDED_IMAGE_PATH@@"): + return Link("") + return None + + assert read(html, rewrite_link=rewrite) == f"![](https://files.example/{PASTED})" + + +# --------------------------------------------------------------------------- +# What a callback raises, and where its state lives +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "error", [ValueError("bad id"), RecursionError(), KeyError("k")] +) +def test_what_the_callback_raises_reaches_the_caller_unchanged( + error: Exception, +) -> None: + # A ValueError above all: the reader answers markdownify's by reading the + # body as its text, and a callback's must not pass for one and quietly + # cost the body its formatting. + def fail(link: Link) -> None: + raise error + + with pytest.raises(type(error)) as caught: + read('

gras x

', rewrite_link=fail) + + assert caught.value is error + + +def test_an_answer_of_another_type_is_a_type_error() -> None: + with pytest.raises(TypeError, match="rewrite_link returned int"): + read('

x

', rewrite_link=lambda link: 1) # type: ignore[arg-type,return-value] + + +def test_the_callback_is_gone_once_the_read_ends_however_it_ends() -> None: + with pytest.raises(ValueError): + read( + '

x

', rewrite_link=_raise_value_error + ) + + assert read('

x

') == "[x](https://x.example)" + + +def _raise_value_error(link: Link) -> None: + raise ValueError(link.href) + + +def test_a_read_inside_a_callback_has_its_own_callback() -> None: + # And the outer read gets its own back: it is asked about its second link + # after the nested read has ended. + calls: list[str] = [] + + def outer(link: Link) -> None: + calls.append(f"outer {link.href}") + read( + f'

i

', + rewrite_link=lambda seen: calls.append(f"inner {seen.href}"), # type: ignore[func-returns-value] + ) + + read( + '

1 2

', + rewrite_link=outer, + ) + + assert calls == [ + "outer https://one.example", + "inner https://one.example/inner", + "outer https://two.example", + "inner https://two.example/inner", + ] + + +def test_each_thread_reads_with_its_own_callback() -> None: + # Both reads are inside their callbacks at once: a callback held on the + # shared converter would be the other thread's by the time it is called. + barrier = threading.Barrier(2, timeout=10) + seen: dict[str, list[str]] = {"a": [], "b": []} + failures: list[BaseException] = [] + + def reader(name: str) -> None: + def note(link: Link) -> None: + barrier.wait() + seen[name].append(link.href) + + try: + read(f'

1

', rewrite_link=note) + except BaseException as exc: + failures.append(exc) + + threads = [threading.Thread(target=reader, args=(name,)) for name in ("a", "b")] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert failures == [] + assert seen == {"a": ["https://a.example/1"], "b": ["https://b.example/1"]} + + +# --------------------------------------------------------------------------- +# Embedded images +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("document_id", [PASTED, UPLOADED]) +def test_document_id_of_reads_an_embedded_images_src(document_id: str) -> None: + assert document_id_of(f"@@EMBEDDED_IMAGE_PATH@@{document_id}") == document_id + + +@pytest.mark.parametrize( + "src", + [ + "", + "@@EMBEDDED_IMAGE_PATH@@", + "https://x.example/i.png", + f" @@EMBEDDED_IMAGE_PATH@@{PASTED}", + ], +) +def test_document_id_of_answers_none_for_anything_else(src: str) -> None: + assert document_id_of(src) is None + + +def test_the_prefix_is_published() -> None: + assert EasyvistaContentConverter.EMBEDDED_IMAGE_PREFIX == "@@EMBEDDED_IMAGE_PATH@@" + + +@pytest.mark.parametrize("document_id", [PASTED, UPLOADED]) +def test_document_image_renders_as_the_editor_writes_a_pasted_image( + document_id: str, +) -> None: + assert render(document_image(document_id, alt="capture.png")) == ( + f'

capture.png

' + ) + + +@pytest.mark.parametrize( + "alt", ["", "capture.png", "a_b*c [d] `e` !", " deux mots "] +) +def test_document_image_is_what_the_reader_writes_for_the_native_image( + alt: str, +) -> None: + markdown = document_image(PASTED, alt=alt) + + assert read(render(markdown)) == markdown + [link] = offered(render(markdown)) + assert (link.document_id, link.text) == (PASTED, " ".join(alt.split())) + + +@pytest.mark.parametrize("document_id", ["", "a b", "a)b", "x" * 129, "é"]) +def test_document_image_refuses_what_is_not_a_document_id(document_id: str) -> None: + with pytest.raises(ValueError, match="not an EasyVista DOCUMENT_ID"): + document_image(document_id) + + +def test_document_image_is_not_rewritten_by_a_read_in_progress() -> None: + written: list[str] = [] + + def rewrite(link: Link) -> None: + written.append(document_image(PASTED, alt="x")) + + read('

x

', rewrite_link=rewrite) + + assert written == [f"![x](@@EMBEDDED_IMAGE_PATH@@{PASTED})"] + + +def test_rewrite_link_is_a_public_type() -> None: + callback: RewriteLink = lambda link: None # noqa: E731 + assert read("

x

", rewrite_link=callback) == "x" diff --git a/easyvista_python_client/testing/test_public_api.py b/easyvista_python_client/testing/test_public_api.py index dab76e6..d5959d7 100644 --- a/easyvista_python_client/testing/test_public_api.py +++ b/easyvista_python_client/testing/test_public_api.py @@ -60,7 +60,7 @@ def _run_python(code: str) -> subprocess.CompletedProcess[str]: def test_package_imports_and_has_version(): - assert easyvista_python_client.__version__ == "0.4.2" + assert easyvista_python_client.__version__ == "0.4.3" def test_public_exports_available(): diff --git a/pyproject.toml b/pyproject.toml index bc167a7..17dbe48 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ exclude = [ [project] name = "easyvista-python-client" -version = "0.4.2" +version = "0.4.3" description = "Typed Python client for the EasyVista Service Manager REST API" readme = "README.md" requires-python = ">=3.11" diff --git a/skills/easyvista-asset-workflow/SKILL.md b/skills/easyvista-asset-workflow/SKILL.md index b8f3d7b..f75b991 100644 --- a/skills/easyvista-asset-workflow/SKILL.md +++ b/skills/easyvista-asset-workflow/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, easyvista-python-client, network access to an EasyVista Service Manager REST API, and a profile authorized for the assets resource." metadata: package: easyvista-python-client - version: "0.4.2" + version: "0.4.3" --- > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`, diff --git a/skills/easyvista-client-setup/SKILL.md b/skills/easyvista-client-setup/SKILL.md index 81a5b3e..8094999 100644 --- a/skills/easyvista-client-setup/SKILL.md +++ b/skills/easyvista-client-setup/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, easyvista-python-client, network access to an EasyVista Service Manager REST API, and valid EasyVista credentials." metadata: package: easyvista-python-client - version: "0.4.2" + version: "0.4.3" --- `easyvista_python_client` ships both clients over one surface: diff --git a/skills/easyvista-directory/SKILL.md b/skills/easyvista-directory/SKILL.md index a2f00bb..022b7e8 100644 --- a/skills/easyvista-directory/SKILL.md +++ b/skills/easyvista-directory/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, easyvista-python-client, network access to an EasyVista Service Manager REST API, and a profile authorized for the departments and employees resources (writes are additionally profile-gated)." metadata: package: easyvista-python-client - version: "0.4.2" + version: "0.4.3" --- > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`, diff --git a/skills/easyvista-document-workflow/SKILL.md b/skills/easyvista-document-workflow/SKILL.md index c24a3e2..5bb7ce5 100644 --- a/skills/easyvista-document-workflow/SKILL.md +++ b/skills/easyvista-document-workflow/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, easyvista-python-client, network access to an EasyVista Service Manager REST API, and a profile authorized for the documents sub-resource." metadata: package: easyvista-python-client - version: "0.4.2" + version: "0.4.3" --- > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`, diff --git a/skills/easyvista-instance-discovery/SKILL.md b/skills/easyvista-instance-discovery/SKILL.md index 898e3fe..7b6d541 100644 --- a/skills/easyvista-instance-discovery/SKILL.md +++ b/skills/easyvista-instance-discovery/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, easyvista-python-client, and network access to an EasyVista Service Manager REST API. Every call here is a GET; nothing is created, updated or deleted." metadata: package: easyvista-python-client - version: "0.4.2" + version: "0.4.3" --- > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`, diff --git a/skills/easyvista-reporting-and-context/SKILL.md b/skills/easyvista-reporting-and-context/SKILL.md index 7b02873..a42c5fb 100644 --- a/skills/easyvista-reporting-and-context/SKILL.md +++ b/skills/easyvista-reporting-and-context/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, easyvista-python-client, and network access to an EasyVista Service Manager REST API." metadata: package: easyvista-python-client - version: "0.4.2" + version: "0.4.3" --- > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`, diff --git a/skills/easyvista-search-syntax/SKILL.md b/skills/easyvista-search-syntax/SKILL.md index 51360b0..b2d1ac6 100644 --- a/skills/easyvista-search-syntax/SKILL.md +++ b/skills/easyvista-search-syntax/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, easyvista-python-client, and network access to an EasyVista Service Manager REST API." metadata: package: easyvista-python-client - version: "0.4.2" + version: "0.4.3" --- > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`, diff --git a/skills/easyvista-ticket-actions/SKILL.md b/skills/easyvista-ticket-actions/SKILL.md index ecda4a9..cc6141c 100644 --- a/skills/easyvista-ticket-actions/SKILL.md +++ b/skills/easyvista-ticket-actions/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, easyvista-python-client, network access to an EasyVista Service Manager REST API, and a profile authorized for the actions sub-resource." metadata: package: easyvista-python-client - version: "0.4.2" + version: "0.4.3" --- > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`, diff --git a/skills/easyvista-ticket-workflow/SKILL.md b/skills/easyvista-ticket-workflow/SKILL.md index 7e62323..aa3e09f 100644 --- a/skills/easyvista-ticket-workflow/SKILL.md +++ b/skills/easyvista-ticket-workflow/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, easyvista-python-client, network access to an EasyVista Service Manager REST API, and a profile authorized for the requests resource." metadata: package: easyvista-python-client - version: "0.4.2" + version: "0.4.3" --- > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`,