diff --git a/CHANGELOG.md b/CHANGELOG.md index ceda2f5..220c30d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,33 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## 0.6.3 — 2026-10-08 + +A patch release of the converter's reading: a caller can recognise the +documents a body embeds or links and decide, link by link, what a read +writes. Without the new argument a read is unchanged, byte for byte; writing +is unchanged. `easyvista-python-client` 0.4.3 adds the same hook. + +### 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 `glpi_python_client.content`. +- **`GlpiContentConverter.document_id_of(url)`** returns the document id a URL + to `front/document.send.php` names (relative, rooted or absolute, one + `docid` in any position), and **`document_image(document_id, alt=..., + itemtype=..., items_id=...)`** the Markdown of an embedded document image as + GLPI's editor writes one -- the image inside a link to the same URL -- + spelled as the reader spells it. A `Link` whose URL names a document carries + its `document_id`. + ## 0.6.2 — 2026-10-07 A patch release of the converter's writing: a link written into GLPI opens in diff --git a/docs/user_guide.rst b/docs/user_guide.rst index d537927..600ac92 100644 --- a/docs/user_guide.rst +++ b/docs/user_guide.rst @@ -1912,6 +1912,63 @@ text. Prefer a patched interpreter anyway: the guard covers the converter's input, and the CPython fix covers the parser itself. +Document images, and rewriting links as they are read +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +GLPI's editor keeps an image pasted into a body as a document and embeds it +from the page that serves documents, inside a link to the same URL: +```` +around ````. Without a callback the reader writes +that as any linked image, ``[![alt](url)](url)``, which displays nowhere but in +GLPI. Three helpers let a caller do better. + +``GlpiContentConverter.document_id_of(url)`` returns the document id a URL +names -- ``front/document.send.php`` with one ``docid``, relative, rooted or +absolute, under any path -- or ``None``. ``document_image(document_id, +alt=..., itemtype=..., items_id=...)`` returns the Markdown of the editor's +form, spelled exactly as the reader spells it, so ``to_transport`` writes it +and ``from_transport`` reads it back unchanged. Its URL is rooted, as the +editor wrote it on the instance measured. + +``from_transport(..., rewrite_link=callback)`` calls ``callback`` with a +:class:`~glpi_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``, the ``document_id`` its URL names, 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 body 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 glpi_python_client.content import GlpiContentConverter, Link + + def name_documents(link: Link) -> Link | str | None: + if link.document_id is None: + return None + if link.image: + return f"[document {link.document_id}]" + return Link("") # the editor's link around it: keep only its content + + GlpiContentConverter.from_transport(body, rewrite_link=name_documents) + +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. +``easyvista-python-client`` 0.4.3 carries the same hook. + It is not a sanitiser ^^^^^^^^^^^^^^^^^^^^^ diff --git a/glpi_python_client/__init__.py b/glpi_python_client/__init__.py index 3bdb976..0f3a045 100644 --- a/glpi_python_client/__init__.py +++ b/glpi_python_client/__init__.py @@ -131,7 +131,7 @@ date_window, ) -__version__ = "0.6.2" +__version__ = "0.6.3" __all__ = [ "AsyncGlpiClient", diff --git a/glpi_python_client/content/__init__.py b/glpi_python_client/content/__init__.py index 40b7ec3..4e348b2 100644 --- a/glpi_python_client/content/__init__.py +++ b/glpi_python_client/content/__init__.py @@ -6,6 +6,10 @@ from __future__ import annotations -from glpi_python_client.content.conversion import GlpiContentConverter +from glpi_python_client.content.conversion import ( + GlpiContentConverter, + Link, + RewriteLink, +) -__all__ = ["GlpiContentConverter"] +__all__ = ["GlpiContentConverter", "Link", "RewriteLink"] diff --git a/glpi_python_client/content/conversion.py b/glpi_python_client/content/conversion.py index 2b55f3e..0a7f826 100644 --- a/glpi_python_client/content/conversion.py +++ b/glpi_python_client/content/conversion.py @@ -40,9 +40,12 @@ 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 urllib.parse import parse_qs, urlsplit import cmarkgfm import mdformat_tables @@ -59,7 +62,7 @@ ) from mdformat.renderer.typing import Postprocess -from glpi_python_client._errors import GlpiContentError +from glpi_python_client._errors import GlpiContentError, GlpiValidationError #: Element names that make a ``<...>`` sequence markup rather than text: the #: HTML5 elements, then the obsolete ones old editors and mail clients write. @@ -363,6 +366,97 @@ def _title(title: str) -> str: return ' "' + re.sub(r'[\\"]', r"\\\g<0>", title) + '"' if title else "" +#: The page GLPI serves a document from. Its editor embeds a pasted image as +#: ```` +#: inside a link to the same URL, and links an attachment the same way. +_DOCUMENT_PAGE = "front/document.send.php" + +#: A document id, as the ``docid`` parameter carries one: ASCII digits only, +#: since ``int`` also reads other scripts' digits. +_DOCUMENT_ID = re.compile(r"[0-9]{1,18}") + +#: An item type, as ``itemtype`` names one: a PHP class name. +_ITEMTYPE = re.compile(r"[A-Za-z_][A-Za-z0-9_\\]{0,99}") + + +@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 : int or None + For a link or an image whose URL is GLPI's document page with a + ``docid`` (:meth:`GlpiContentConverter.document_id_of`), that id; + ``None`` otherwise. + 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: int | 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. @@ -520,15 +614,34 @@ 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, + document_id=GlpiContentConverter.document_id_of(href), + ) + ) + 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}" @@ -536,12 +649,37 @@ 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: + around = el.find_parent("a") + answer = _rewritten( + Link( + src, + alt, + title, + image=True, + document_id=GlpiContentConverter.document_id_of(src), + 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): @@ -752,11 +890,45 @@ def _text_of(html: str) -> str: return "\n".join(line for line in lines if line) +def _converted(content: str) -> str: + """Read 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 GlpiContentError( + "Could not convert GLPI HTML content to Markdown " + f"({type(exc).__name__}: {exc})." + ) from exc + + +def _positive(value: object) -> bool: + """Whether ``value`` is a positive ``int`` and not a ``bool``.""" + + return isinstance(value, int) and not isinstance(value, bool) and value > 0 + + class GlpiContentConverter: """Convert content between GLPI HTML payloads and canonical Markdown.""" @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 GLPI transport value into Markdown. Parameters @@ -774,6 +946,12 @@ def from_transport(value: object, *, plain_text_is_markdown: bool = False) -> st 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 ------- @@ -793,6 +971,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 ``GlpiContentError`` (the user guide 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() @@ -808,22 +990,96 @@ 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 GlpiContentError( - "Could not convert GLPI HTML content 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(url: str) -> int | None: + """Return the document id a URL to GLPI's document page names. + + ``None`` unless ``url`` -- relative, rooted or absolute, with any + path in front -- is ``front/document.send.php`` with exactly one + ``docid`` parameter, in any position, made of digits. + """ + + try: + parts = urlsplit(url) + except ValueError: # an unclosed "[" in a host, among others + return None + path = parts.path + if path != _DOCUMENT_PAGE and not path.endswith("/" + _DOCUMENT_PAGE): + return None + values = parse_qs(parts.query).get("docid", []) + if len(values) != 1 or not _DOCUMENT_ID.fullmatch(values[0]): + return None + document_id = int(values[0]) + return document_id if document_id > 0 else None + + @staticmethod + def document_image( + document_id: int, + *, + alt: str = "", + itemtype: str | None = None, + items_id: int | None = None, + ) -> str: + """Return the Markdown of an image embedding one of GLPI's documents. + + :meth:`to_transport` renders it as GLPI's editor writes a pasted image + -- the ```` inside a link to the same URL, + ``/front/document.send.php?docid=``, then + ``&itemtype=...&items_id=...`` when given -- and :meth:`from_transport` + reads that back as this same Markdown: it is spelled by the reader + itself. The URL is rooted, as the editor wrote it on the instance + measured; GLPI checks the reader's right to the document when it + serves it. + + Parameters + ---------- + document_id : int + The document's id. + alt : str, optional + The image's alternative text. + itemtype, items_id : str and int, optional + The item the document is attached to, such as ``"Ticket"`` and + its id. Both or neither. + + Raises + ------ + GlpiValidationError + ``document_id`` or ``items_id`` is not a positive ``int``, + ``itemtype`` is not a class name, or only one of the two was + given. + """ + + if not _positive(document_id): + raise GlpiValidationError(f"not a GLPI document id: {document_id!r}") + url = f"/{_DOCUMENT_PAGE}?docid={document_id}" + if (itemtype is None) != (items_id is None): + raise GlpiValidationError( + "itemtype and items_id go together: give both or neither" + ) + if itemtype is not None: + if not _ITEMTYPE.fullmatch(itemtype): + raise GlpiValidationError(f"not a GLPI item type: {itemtype!r}") + if not _positive(items_id): + raise GlpiValidationError(f"not a GLPI item id: {items_id!r}") + url += f"&itemtype={itemtype}&items_id={items_id}" + native = ( + f'

' + f'{escape(

' + ) + 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/glpi_python_client/content/tests/test_rewrite_link.py b/glpi_python_client/content/tests/test_rewrite_link.py new file mode 100644 index 0000000..7750ea1 --- /dev/null +++ b/glpi_python_client/content/tests/test_rewrite_link.py @@ -0,0 +1,404 @@ +"""The read's ``rewrite_link`` hook, and the document image helpers built beside it. + +A caller that mirrors bodies between systems has to recognise an image a body +embeds -- one of GLPI's documents, served by ``front/document.send.php`` -- 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. ``easyvista-python-client`` 0.4.3 carries the same hook, +with EasyVista's own embedded form. +""" + +from __future__ import annotations + +import threading + +import pytest + +from glpi_python_client import GlpiValidationError +from glpi_python_client.content import GlpiContentConverter, Link, RewriteLink +from glpi_python_client.content.tests.test_round_trip import ( + E_MAIL_SHAPES, + EARLIER_REGRESSIONS, + REALISTIC, +) + +read = GlpiContentConverter.from_transport +render = GlpiContentConverter.to_transport +document_image = GlpiContentConverter.document_image +document_id_of = GlpiContentConverter.document_id_of + +#: What every rendered link carries: GLPI's editor writes it on a link it makes. +NEW_WINDOW = ' target="_blank" rel="noopener noreferrer"' + +#: The shape GLPI's editor writes for a pasted image (synthetic ids). +DOCUMENT = "/front/document.send.php?docid=7310&itemtype=Ticket&items_id=4211" +PASTED = ( + f'

' + f'5f1a-upload-tag' + "

" +) + + +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_the_editors_pasted_image_is_offered_as_one_document_twice() -> None: + assert offered(PASTED) == [ + Link( + DOCUMENT, + "5f1a-upload-tag", + image=True, + document_id=7310, + enclosing_href=DOCUMENT, + ), + Link(DOCUMENT, "", document_id=7310), + ] + + +@pytest.mark.parametrize( + "url", + [ + "/front/document.send.php?docid=4820", + "front/document.send.php?docid=4820", + "/glpi/front/document.send.php?docid=4820", + "https://glpi.example.org/front/document.send.php?itemtype=Ticket&docid=4820", + "https://glpi.example.org/glpi/front/document.send.php?items_id=1&docid=4820&x=y", + ], +) +def test_a_document_url_names_its_document_wherever_glpi_is_served(url: str) -> None: + assert document_id_of(url) == 4820 + + +@pytest.mark.parametrize( + "url", + [ + "", + "https://x.example/i.png", + "/front/document.send.php", + "/front/document.send.php?docid=", + "/front/document.send.php?docid=0", + "/front/document.send.php?docid=12a", + "/front/document.send.php?docid=٣", + "/front/document.send.php?docid=1&docid=2", + "/front/xdocument.send.php?docid=4820", + "/xfront/document.send.php?docid=4820", + "/front/document.send.php/x?docid=4820", + "/front/ticket.form.php?docid=4820", + "http://[::1/front/document.send.php?docid=4820", + ], +) +def test_any_other_url_names_no_document(url: str) -> None: + assert document_id_of(url) is None + + +def test_an_image_or_link_elsewhere_carries_no_document_id() -> None: + links = offered( + '

x

' + ) + assert [link.document_id for link in links] == [None, None] + + +@pytest.mark.parametrize( + "html", + [ + f'
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: + # The image is rewritten, then the link around it -- to the same document + # -- dropped, its content, the new image, kept. + def rewrite(link: Link) -> Link | None: + if link.document_id is None: + return None + if link.image: + return Link(f"https://files.example/{link.document_id}", image=True) + return Link("") + + assert read(PASTED, rewrite_link=rewrite) == "![](https://files.example/7310)" + + +# --------------------------------------------------------------------------- +# 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"]} + + +# --------------------------------------------------------------------------- +# Document images +# --------------------------------------------------------------------------- + + +def test_document_image_renders_as_the_editor_writes_a_pasted_image() -> None: + url = DOCUMENT.replace("&", "&") + + assert render( + document_image(7310, alt="capture.png", itemtype="Ticket", items_id=4211) + ) == ( + f'

capture.png

' + ) + + +def test_document_image_without_an_item_names_the_document_alone() -> None: + url = "/front/document.send.php?docid=7310" + + assert render(document_image(7310)) == ( + f'

' + ) + + +@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(7310, alt=alt, itemtype="Ticket", items_id=4211) + + assert read(render(markdown)) == markdown + image, link = offered(render(markdown)) + assert (image.document_id, image.text, link.document_id) == ( + 7310, + " ".join(alt.split()), + 7310, + ) + + +@pytest.mark.parametrize( + ("document_id", "itemtype", "items_id", "message"), + [ + (0, None, None, "not a GLPI document id"), + (-1, None, None, "not a GLPI document id"), + (True, None, None, "not a GLPI document id"), + ("7310", None, None, "not a GLPI document id"), + (7310, "Ticket", None, "give both or neither"), + (7310, None, 4211, "give both or neither"), + (7310, "Ticket&x=1", 4211, "not a GLPI item type"), + (7310, "", 4211, "not a GLPI item type"), + (7310, "Ticket", 0, "not a GLPI item id"), + (7310, "Ticket", "4211", "not a GLPI item id"), + ], +) +def test_document_image_refuses_what_is_not_a_document( + document_id: object, itemtype: str | None, items_id: object, message: str +) -> None: + with pytest.raises(GlpiValidationError, match=message): + document_image(document_id, itemtype=itemtype, items_id=items_id) # type: ignore[arg-type] + + +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(7310, alt="x")) + + read('

x

', rewrite_link=rewrite) + + url = "/front/document.send.php?docid=7310" + assert written == [f"[![x]({url})]({url})"] + + +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/glpi_python_client/testing/tests/test_raise_site_audit.py b/glpi_python_client/testing/tests/test_raise_site_audit.py index 6f9e96f..1e839c8 100644 --- a/glpi_python_client/testing/tests/test_raise_site_audit.py +++ b/glpi_python_client/testing/tests/test_raise_site_audit.py @@ -41,6 +41,12 @@ "transport_error_from", "RuntimeError", # exempt by design -- see module docstring "TypeError", # exempt by design -- see module docstring + # The content reader's rewrite_link hook: a private carrier taking what the + # caller's own callback raised past the reader's ValueError fallback, and + # from_transport re-raising that same exception. It is the caller's error, + # not the library's, so it reaches the caller unchanged. + "_CallbackRaised", + "callback_error", } diff --git a/pyproject.toml b/pyproject.toml index 03008e1..b07267c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ exclude = [ [project] name = "glpi-python-client" -version = "0.6.2" +version = "0.6.3" description = "A typed Python client for GLPI ITSM APIs." readme = "README.md" requires-python = ">=3.11" diff --git a/skills/glpi-asset-workflow/SKILL.md b/skills/glpi-asset-workflow/SKILL.md index 530e935..1cee0bf 100644 --- a/skills/glpi-asset-workflow/SKILL.md +++ b/skills/glpi-asset-workflow/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to read or write assets." metadata: package: glpi-python-client - version: "0.6.2" + version: "0.6.3" --- # GLPI Asset Workflow diff --git a/skills/glpi-client-setup/SKILL.md b/skills/glpi-client-setup/SKILL.md index d1b7606..6be697c 100644 --- a/skills/glpi-client-setup/SKILL.md +++ b/skills/glpi-client-setup/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, network access to a GLPI v2 API, and valid GLPI credentials." metadata: package: glpi-python-client - version: "0.6.2" + version: "0.6.3" --- # GLPI Client Setup diff --git a/skills/glpi-contract-workflow/SKILL.md b/skills/glpi-contract-workflow/SKILL.md index 221be9a..f7fcf38 100644 --- a/skills/glpi-contract-workflow/SKILL.md +++ b/skills/glpi-contract-workflow/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to read or write contracts." metadata: package: glpi-python-client - version: "0.6.2" + version: "0.6.3" --- # GLPI Contract Workflow diff --git a/skills/glpi-document-workflow/SKILL.md b/skills/glpi-document-workflow/SKILL.md index 8fa369e..66a2f02 100644 --- a/skills/glpi-document-workflow/SKILL.md +++ b/skills/glpi-document-workflow/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and v1 credentials configured on the client for binary uploads." metadata: package: glpi-python-client - version: "0.6.2" + version: "0.6.3" --- # GLPI Document Workflow diff --git a/skills/glpi-knowledge-base/SKILL.md b/skills/glpi-knowledge-base/SKILL.md index 1a0ec2b..6085d94 100644 --- a/skills/glpi-knowledge-base/SKILL.md +++ b/skills/glpi-knowledge-base/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and — for category writes only — a legacy v1 session (v1_base_url + v1_user_token)." metadata: package: glpi-python-client - version: "0.6.2" + version: "0.6.3" --- # GLPI Knowledge Base diff --git a/skills/glpi-plugin-fields/SKILL.md b/skills/glpi-plugin-fields/SKILL.md index 9f70f6f..2a4b739 100644 --- a/skills/glpi-plugin-fields/SKILL.md +++ b/skills/glpi-plugin-fields/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, the GLPI Fields plugin installed server-side, and a legacy v1 session (v1_base_url + v1_user_token) — every method in this family goes over the v1 API." metadata: package: glpi-python-client - version: "0.6.2" + version: "0.6.3" --- # GLPI Plugin Fields diff --git a/skills/glpi-reporting-and-context/SKILL.md b/skills/glpi-reporting-and-context/SKILL.md index 2d291c8..23f9547 100644 --- a/skills/glpi-reporting-and-context/SKILL.md +++ b/skills/glpi-reporting-and-context/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to read tickets, tasks, users, entities, and timeline records." metadata: package: glpi-python-client - version: "0.6.2" + version: "0.6.3" --- # GLPI Reporting And Context diff --git a/skills/glpi-team-members/SKILL.md b/skills/glpi-team-members/SKILL.md index 19fbf34..25bd868 100644 --- a/skills/glpi-team-members/SKILL.md +++ b/skills/glpi-team-members/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to manage ticket teams." metadata: package: glpi-python-client - version: "0.6.2" + version: "0.6.3" --- # GLPI Team Members diff --git a/skills/glpi-ticket-timeline/SKILL.md b/skills/glpi-ticket-timeline/SKILL.md index 55faeb5..bdc0775 100644 --- a/skills/glpi-ticket-timeline/SKILL.md +++ b/skills/glpi-ticket-timeline/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, and network access to the GLPI v2 API." metadata: package: glpi-python-client - version: "0.6.2" + version: "0.6.3" --- # GLPI Ticket Timeline diff --git a/skills/glpi-ticket-workflow/SKILL.md b/skills/glpi-ticket-workflow/SKILL.md index e1ec173..aed54c0 100644 --- a/skills/glpi-ticket-workflow/SKILL.md +++ b/skills/glpi-ticket-workflow/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and credentials accepted by GlpiClient." metadata: package: glpi-python-client - version: "0.6.2" + version: "0.6.3" --- # GLPI Ticket Workflow diff --git a/skills/glpi-user-location-provisioning/SKILL.md b/skills/glpi-user-location-provisioning/SKILL.md index 34618cf..dc78226 100644 --- a/skills/glpi-user-location-provisioning/SKILL.md +++ b/skills/glpi-user-location-provisioning/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to read or write users, locations, and entities." metadata: package: glpi-python-client - version: "0.6.2" + version: "0.6.3" --- # GLPI User, Location, And Entity Provisioning