From 7b88c337748c50ff3bc01a5c70ffb872a8690bfe Mon Sep 17 00:00:00 2001 From: baraline Date: Thu, 8 Oct 2026 09:58:26 +0200 Subject: [PATCH] feat(content): a rewrite_link hook and document image helpers for the reader (0.6.3) A caller that mirrors bodies to another system has to recognise the documents a body embeds and decide what they become, without parsing HTML or Markdown itself. GLPI's editor embeds a pasted image from front/document.send.php?docid=N, inside a link to the same URL, which the reader wrote as a linked image that displays nowhere but in GLPI. The twin of easyvista-python-client 0.4.3. - 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); a Link whose URL names a document carries its document_id. 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. The raise-site audit allows the private carrier and that re-raise, since the exception is the caller's own. - document_id_of(url): relative, rooted or absolute, under any path, exactly one docid of ASCII digits, above zero. document_image(document_id, alt=..., itemtype=..., items_id=...): the editor's link-wrapped image, spelled by the reader itself; GlpiValidationError on a bad argument. Tests: test_rewrite_link.py (none-answer parity over the round-trip corpus, offer order, the editor's wrap, URL forms accepted and refused, each answer kind, code skipped, exceptions, threads, nesting, helpers). 33 mutants of the new guards, all killed after one URL case was added. Full suite 1989 passed; mypy, ruff, unasync check and sphinx -W clean. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 27 ++ docs/user_guide.rst | 57 +++ glpi_python_client/__init__.py | 2 +- glpi_python_client/content/__init__.py | 8 +- glpi_python_client/content/conversion.py | 298 ++++++++++++- .../content/tests/test_rewrite_link.py | 404 ++++++++++++++++++ .../testing/tests/test_raise_site_audit.py | 6 + pyproject.toml | 2 +- skills/glpi-asset-workflow/SKILL.md | 2 +- skills/glpi-client-setup/SKILL.md | 2 +- skills/glpi-contract-workflow/SKILL.md | 2 +- skills/glpi-document-workflow/SKILL.md | 2 +- skills/glpi-knowledge-base/SKILL.md | 2 +- skills/glpi-plugin-fields/SKILL.md | 2 +- skills/glpi-reporting-and-context/SKILL.md | 2 +- skills/glpi-team-members/SKILL.md | 2 +- skills/glpi-ticket-timeline/SKILL.md | 2 +- skills/glpi-ticket-workflow/SKILL.md | 2 +- .../glpi-user-location-provisioning/SKILL.md | 2 +- 19 files changed, 790 insertions(+), 36 deletions(-) create mode 100644 glpi_python_client/content/tests/test_rewrite_link.py 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