From 7a4643476cc22370ec8950b05ed87c9f84dd7501 Mon Sep 17 00:00:00 2001 From: baraline Date: Wed, 14 Oct 2026 09:49:46 +0200 Subject: [PATCH 1/4] feat(content): offer an image's id attribute as Link.image_id GLPI leaves a large pasted image as a raw data: URI in the stored body, and sets the id of the to the tag of the Document it created. The reader dropped that id, so a caller could not find the Document. Link gains a last field, image_id, filled by convert_img only from the trimmed id attribute (None when absent or blank). convert_a leaves it None. No existing field moves, so positional construction is unchanged. Co-Authored-By: Claude Opus 5.5 --- glpi_python_client/content/conversion.py | 8 +++ glpi_python_client/content/tests/test_cost.py | 17 +++++ .../content/tests/test_rewrite_link.py | 62 ++++++++++++++++++- 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/glpi_python_client/content/conversion.py b/glpi_python_client/content/conversion.py index 0a7f826..756ba8f 100644 --- a/glpi_python_client/content/conversion.py +++ b/glpi_python_client/content/conversion.py @@ -402,6 +402,12 @@ class Link: 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. + image_id : str or None + For an ````, the value of its ``id`` attribute, trimmed, or + ``None`` when it has none or the element is a link. GLPI sets it to + the tag of the Document it created for an image it has not rewritten + into a document link, and that Document carries the same value as its + tag, so a caller can find it. """ href: str @@ -410,6 +416,7 @@ class Link: image: bool = False document_id: int | None = None enclosing_href: str | None = None + image_id: str | None = None #: A ``rewrite_link`` callback: called with each link and image a read meets, @@ -664,6 +671,7 @@ def convert_img(self, el: Tag, text: str, parent_tags: set[str]) -> str: enclosing_href=( None if around is None else str(around.get("href") or "") ), + image_id=str(el.get("id") or "").strip() or None, ) ) if isinstance(answer, str): diff --git a/glpi_python_client/content/tests/test_cost.py b/glpi_python_client/content/tests/test_cost.py index 6c0841c..7f93f20 100644 --- a/glpi_python_client/content/tests/test_cost.py +++ b/glpi_python_client/content/tests/test_cost.py @@ -242,3 +242,20 @@ def test_a_body_too_deep_to_convert_keeps_its_text() -> None: assert "init" in markdown assert "au fond" in render(markdown) + + +def test_a_large_data_uri_is_read_within_the_cost_bound() -> None: + """A pasted image GLPI keeps as a raw data URI runs to about a megabyte.""" + + html = ( + '

' + ) + + started = time.perf_counter() + seen: list[object] = [] + GlpiContentConverter.from_transport(html, rewrite_link=seen.append) + + assert len(seen) == 1 + assert time.perf_counter() - started < 10 diff --git a/glpi_python_client/content/tests/test_rewrite_link.py b/glpi_python_client/content/tests/test_rewrite_link.py index 7750ea1..aa13395 100644 --- a/glpi_python_client/content/tests/test_rewrite_link.py +++ b/glpi_python_client/content/tests/test_rewrite_link.py @@ -39,6 +39,11 @@ ) +#: A pasted image GLPI left as a raw data URI (synthetic tag and bytes). +DATA_URI = "data:image/png;base64,iVBORw0KGgo=" +DATA_IMAGE = f'

' + + def offered(html: str) -> list[Link]: """Read ``html`` with a callback that keeps everything, and return what it saw.""" @@ -60,12 +65,67 @@ def keep(link: Link) -> None: "html", REALISTIC + E_MAIL_SHAPES - + [html for bodies in EARLIER_REGRESSIONS.values() for html in bodies], + + [html for bodies in EARLIER_REGRESSIONS.values() for html in bodies] + + [DATA_IMAGE], ) def test_a_callback_answering_none_changes_nothing(html: str) -> None: assert read(html, rewrite_link=lambda link: None) == read(html) +def test_a_pasted_data_image_is_offered_with_its_id() -> None: + assert offered(DATA_IMAGE) == [ + Link(href=DATA_URI, image=True, image_id="tag-1", document_id=None) + ] + + +def test_an_image_without_an_id_has_no_image_id() -> None: + (link,) = offered(f'

') + + assert link.image_id is None + + +@pytest.mark.parametrize("attribute", ['id=""', 'id=" "', "id"]) +def test_an_empty_or_blank_id_reads_as_none(attribute: str) -> None: + (link,) = offered(f'

') + + assert link.image_id is None + + +def test_an_image_inside_a_link_keeps_its_own_id_and_the_link_has_none() -> None: + html = ( + '

' + f'

' + ) + + image, anchor = offered(html) + + assert image.image_id == "tag-2" + assert anchor.image_id is None + + +def test_an_id_is_trimmed() -> None: + (link,) = offered(f'

') + + assert link.image_id == "tag-3" + + +def test_link_positional_construction_still_works() -> None: + link = Link("h", "t", "ti", True, 5, "https://x.example") + + assert (link.href, link.text, link.title, link.image) == ("h", "t", "ti", True) + assert (link.document_id, link.enclosing_href) == (5, "https://x.example") + assert link.image_id is None + + +def test_answering_with_a_link_rewrites_a_data_image() -> None: + markdown = read( + DATA_IMAGE, + rewrite_link=lambda link: Link("https://files.example/9", "alt", image=True), + ) + + assert markdown == "![alt](https://files.example/9)" + + def test_each_link_and_image_is_offered_once_an_image_before_its_link() -> None: html = ( '

' From f6ed52db3cc55ac79a30eda5abe9292c2e1ce9da Mon Sep 17 00:00:00 2001 From: baraline Date: Wed, 14 Oct 2026 09:54:14 +0200 Subject: [PATCH 2/4] feat(content): data_image_ids and GetDocument.tag name a pasted image's Document GLPI creates a Document for a pasted image it leaves as a raw data: URI, and gives it a tag equal to the id of the . A caller had no way to decide cheaply whether a body holds such an image, nor to read the tag without knowing the vendor key. GlpiContentConverter.data_image_ids(html) returns the distinct ids, in document order, of the images whose src a browser reads as a data: URI (case-insensitive, leading controls and spaces stripped, tab and line breaks dropped), without decoding the base64. Link.image_id shares the same id reading. GetDocument.tag returns extra_payload["tag"] when it is a non-empty string, so the vendor key stays in the library. Co-Authored-By: Claude Opus 5.5 --- glpi_python_client/content/conversion.py | 47 +++++++++- .../content/tests/test_rewrite_link.py | 86 +++++++++++++++++++ .../models/api_schema/management/_document.py | 14 +++ .../tests/test_management_schemas.py | 17 ++++ 4 files changed, 163 insertions(+), 1 deletion(-) diff --git a/glpi_python_client/content/conversion.py b/glpi_python_client/content/conversion.py index 756ba8f..ebb7394 100644 --- a/glpi_python_client/content/conversion.py +++ b/glpi_python_client/content/conversion.py @@ -179,6 +179,27 @@ def _plain_text_html(text: str) -> str: return "

" + "
".join(lines) + "

" +#: What a browser strips from the front of a URL, and drops from inside one. +_URL_LEADING = "".join(chr(c) for c in range(0x21)) +_URL_INNER = str.maketrans("", "", "\t\r\n") + + +def _is_data_uri(src: str) -> bool: + """Whether a browser reads ``src`` as a ``data:`` URI. + + The scheme is case-insensitive, leading C0 controls and spaces are + stripped, and tabs and line breaks are dropped wherever they are. + """ + + return src.lstrip(_URL_LEADING).translate(_URL_INNER).casefold().startswith("data:") + + +def _image_id(el: Tag) -> str | None: + """The trimmed ``id`` of an element, or ``None`` when it has none.""" + + return str(el.get("id") or "").strip() or None + + def _soup(html: str) -> BeautifulSoup: """Parse ``html`` and drop what a browser does not display.""" @@ -671,7 +692,7 @@ def convert_img(self, el: Tag, text: str, parent_tags: set[str]) -> str: enclosing_href=( None if around is None else str(around.get("href") or "") ), - image_id=str(el.get("id") or "").strip() or None, + image_id=_image_id(el), ) ) if isinstance(answer, str): @@ -1029,6 +1050,30 @@ def document_id_of(url: str) -> int | None: document_id = int(values[0]) return document_id if document_id > 0 else None + @staticmethod + def data_image_ids(html: str) -> tuple[str, ...]: + """Return the ``id`` of every image a body keeps as a ``data:`` URI. + + Each ```` whose ``src`` a browser reads as a ``data:`` URI and + that has a non-empty ``id`` contributes it, in document order and + without repeats. The base64 is not decoded. + + GLPI sets a tag on the Documents it creates from an upload or an + editor paste, images or not. For an image it has not rewritten into a + document link, that tag is the ``id`` of the ```` + (:attr:`Link.image_id`, :attr:`GetDocument.tag`). A caller calls this + first, to decide whether it needs to read any Document at all. + """ + + if "<" not in html: + return () + ids = ( + _image_id(img) + for img in _soup(html).find_all("img") + if _is_data_uri(str(img.get("src") or "")) + ) + return tuple(dict.fromkeys(i for i in ids if i is not None)) + @staticmethod def document_image( document_id: int, diff --git a/glpi_python_client/content/tests/test_rewrite_link.py b/glpi_python_client/content/tests/test_rewrite_link.py index aa13395..fd6cefb 100644 --- a/glpi_python_client/content/tests/test_rewrite_link.py +++ b/glpi_python_client/content/tests/test_rewrite_link.py @@ -26,6 +26,7 @@ render = GlpiContentConverter.to_transport document_image = GlpiContentConverter.document_image document_id_of = GlpiContentConverter.document_id_of +data_image_ids = GlpiContentConverter.data_image_ids #: What every rendered link carries: GLPI's editor writes it on a link it makes. NEW_WINDOW = ' target="_blank" rel="noopener noreferrer"' @@ -462,3 +463,88 @@ def rewrite(link: Link) -> None: def test_rewrite_link_is_a_public_type() -> None: callback: RewriteLink = lambda link: None # noqa: E731 assert read("

x

", rewrite_link=callback) == "x" + + +# --------------------------------------------------------------------------- +# Which images are raw data, and what they are called +# --------------------------------------------------------------------------- + + +def test_data_image_ids_lists_ids_in_document_order() -> None: + html = ( + f'

' + f'

' + ) + + assert data_image_ids(html) == ("b", "a", "c") + + +def test_data_image_ids_drops_duplicates() -> None: + html = ( + f'

' + f'

' + ) + + assert data_image_ids(html) == ("a", "b") + + +def test_data_image_ids_ignores_images_without_an_id() -> None: + html = ( + f'

' + f'

' + ) + + assert data_image_ids(html) == () + + +def test_data_image_ids_ignores_a_non_data_src() -> None: + html = ( + '

' + f"{PASTED.replace('

', '').replace('

', '')}" + '

' + ) + + assert data_image_ids(html) == () + + +def test_data_image_ids_sees_a_scheme_in_mixed_case_spaced_and_split() -> None: + html = '

' + + assert data_image_ids(html) == ("t",) + + +@pytest.mark.parametrize("html", ["", "plain text", "

no image

"]) +def test_data_image_ids_of_an_empty_body_is_empty(html: str) -> None: + assert data_image_ids(html) == () + + +@pytest.mark.parametrize( + "html", + [ + DATA_IMAGE, + '

', + '

', + '

', + '

', + '

', + '

', + '

', + '

', + '

', + '

', + '

', + '

', + ], +) +def test_data_image_ids_agrees_with_the_ids_the_reader_offers(html: str) -> None: + from_the_reader = tuple( + dict.fromkeys( + link.image_id + for link in offered(html) + if link.image + and link.image_id + and "".join(link.href.split()).lstrip().casefold().startswith("data:") + ) + ) + + assert data_image_ids(html) == from_the_reader diff --git a/glpi_python_client/models/api_schema/management/_document.py b/glpi_python_client/models/api_schema/management/_document.py index 83e090a..b176f94 100644 --- a/glpi_python_client/models/api_schema/management/_document.py +++ b/glpi_python_client/models/api_schema/management/_document.py @@ -61,6 +61,20 @@ class GetDocument(GlpiModel): mime: str | None = None sha1sum: str | None = None + @property + def tag(self) -> str | None: + """The tag GLPI gave the Document, or ``None``. + + GLPI sets a tag on the Documents it creates from an upload or an + editor paste, images or not. For an image it has not rewritten into + a document link, it is the ``id`` of the ```` left in the body + (see ``GlpiContentConverter.data_image_ids``). Read from + ``extra_payload``; ``None`` when absent, empty or not a string. + """ + + value = self.extra_payload.get("tag") + return value if isinstance(value, str) and value else None + class PostDocument(GlpiModel): """Request body for ``POST /Management/Document``. diff --git a/glpi_python_client/models/api_schema/management/tests/test_management_schemas.py b/glpi_python_client/models/api_schema/management/tests/test_management_schemas.py index 1ef125d..dab11ad 100644 --- a/glpi_python_client/models/api_schema/management/tests/test_management_schemas.py +++ b/glpi_python_client/models/api_schema/management/tests/test_management_schemas.py @@ -2,6 +2,8 @@ from __future__ import annotations +import pytest + from glpi_python_client.models.api_schema.management import ( DeleteDocument, GetDocument, @@ -48,3 +50,18 @@ def test_delete_document_default() -> None: """``DeleteDocument`` exposes ``force`` as optional.""" assert DeleteDocument().force is None + + +def test_get_document_tag_reads_the_extra_payload() -> None: + doc = GetDocument.model_validate({"id": 1, "tag": "tag-1"}) + + assert doc.tag == "tag-1" + + +def test_get_document_tag_is_none_when_absent() -> None: + assert GetDocument.model_validate({"id": 1}).tag is None + + +@pytest.mark.parametrize("value", [None, "", 7, ["tag-1"]]) +def test_get_document_tag_is_none_when_not_a_non_empty_string(value: object) -> None: + assert GetDocument.model_validate({"id": 1, "tag": value}).tag is None From eb9c8236f7dab806e4c136468c6f112f2b987f26 Mon Sep 17 00:00:00 2001 From: baraline Date: Wed, 14 Oct 2026 10:06:14 +0200 Subject: [PATCH 3/4] docs(release): 0.6.5 documents Link.image_id, data_image_ids and GetDocument.tag Adds the 0.6.5 changelog entry and a user-guide paragraph on matching a raw data image to its Document by tag, with the caution that the id is body text and must be matched only against trusted Documents. Bumps the version to 0.6.5 in pyproject, __init__ and the eleven SKILL.md files. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 22 +++++++++++++++++++ docs/user_guide.rst | 19 ++++++++++++++++ glpi_python_client/__init__.py | 2 +- 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 +- 15 files changed, 54 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7d23ac..6c0f279 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,28 @@ 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.5 — 2026-10-14 + +A patch release that lets a caller find the Document behind an image GLPI left +as a raw `data:` URI. GLPI may keep a large pasted image in the stored body as +`` instead of rewriting it into +a document link, while still creating its Document, whose tag equals the +image's `id`. Existing methods are unchanged. + +### Added + +- **`Link.image_id`**, a new last field of the `Link` the `rewrite_link` + callback receives: for an ``, the value of its `id` attribute (trimmed; + `None` when absent or blank); always `None` for a link. +- **`GlpiContentConverter.data_image_ids(html)`** returns, in document order + and without duplicates, the `id` of every `` whose `src` is a `data:` + URI, so a caller can tell whether it needs to read any Document at all. It + does not decode the base64. +- **`GetDocument.tag`**, a read-only property: the Document's tag from + `extra_payload`, or `None` when absent or not a non-empty string. GLPI sets a + tag on the Documents it creates from an upload or an editor paste, images or + not. + ## 0.6.4 — 2026-10-12 A patch release that reads the documents GLPI links to a followup. GLPI's diff --git a/docs/user_guide.rst b/docs/user_guide.rst index f27c045..be755ca 100644 --- a/docs/user_guide.rst +++ b/docs/user_guide.rst @@ -2007,6 +2007,25 @@ 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. +GLPI does not always rewrite a pasted image into a document link. A large one +can stay in the stored body as ```` +while GLPI still creates its Document, whose tag is the image's ``id``. For +such an image ``Link.image_id`` is that ``id`` and ``href`` is the data URI; +``GlpiContentConverter.data_image_ids(html)`` lists those ids without +converting, so a caller reads Documents only when a body needs it. A +:class:`~glpi_python_client.models.GetDocument` exposes its tag as ``tag``: + +.. code-block:: python + + ids = GlpiContentConverter.data_image_ids(body) + if ids: + by_tag = {d.tag: d for d in documents if d.tag} + matched = [by_tag[i] for i in ids if i in by_tag] + +The ``id`` is body text, and anyone who can write the body can forge it. Match +it only against Documents the caller already trusts, such as the same +ticket's own, never against every Document the instance holds. + It is not a sanitiser ^^^^^^^^^^^^^^^^^^^^^ diff --git a/glpi_python_client/__init__.py b/glpi_python_client/__init__.py index 7b8a525..386ed73 100644 --- a/glpi_python_client/__init__.py +++ b/glpi_python_client/__init__.py @@ -133,7 +133,7 @@ date_window, ) -__version__ = "0.6.4" +__version__ = "0.6.5" __all__ = [ "AsyncGlpiClient", diff --git a/pyproject.toml b/pyproject.toml index 802d8a2..a6fd02c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ exclude = [ [project] name = "glpi-python-client" -version = "0.6.4" +version = "0.6.5" 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 883a92d..188d137 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.4" + version: "0.6.5" --- # GLPI Asset Workflow diff --git a/skills/glpi-client-setup/SKILL.md b/skills/glpi-client-setup/SKILL.md index 1de40c9..2d0ee23 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.4" + version: "0.6.5" --- # GLPI Client Setup diff --git a/skills/glpi-contract-workflow/SKILL.md b/skills/glpi-contract-workflow/SKILL.md index 46ae603..704189b 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.4" + version: "0.6.5" --- # GLPI Contract Workflow diff --git a/skills/glpi-document-workflow/SKILL.md b/skills/glpi-document-workflow/SKILL.md index d73761b..1ab3445 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.4" + version: "0.6.5" --- # GLPI Document Workflow diff --git a/skills/glpi-knowledge-base/SKILL.md b/skills/glpi-knowledge-base/SKILL.md index 2c78d7e..3505808 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.4" + version: "0.6.5" --- # GLPI Knowledge Base diff --git a/skills/glpi-plugin-fields/SKILL.md b/skills/glpi-plugin-fields/SKILL.md index 56a1532..5fb9156 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.4" + version: "0.6.5" --- # GLPI Plugin Fields diff --git a/skills/glpi-reporting-and-context/SKILL.md b/skills/glpi-reporting-and-context/SKILL.md index 1eb2fe7..54719b0 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.4" + version: "0.6.5" --- # GLPI Reporting And Context diff --git a/skills/glpi-team-members/SKILL.md b/skills/glpi-team-members/SKILL.md index 9fb9513..3d0fdb7 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.4" + version: "0.6.5" --- # GLPI Team Members diff --git a/skills/glpi-ticket-timeline/SKILL.md b/skills/glpi-ticket-timeline/SKILL.md index f0aa959..0893a29 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.4" + version: "0.6.5" --- # GLPI Ticket Timeline diff --git a/skills/glpi-ticket-workflow/SKILL.md b/skills/glpi-ticket-workflow/SKILL.md index 219bd52..ee7e0d0 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.4" + version: "0.6.5" --- # GLPI Ticket Workflow diff --git a/skills/glpi-user-location-provisioning/SKILL.md b/skills/glpi-user-location-provisioning/SKILL.md index 0e7d941..7763019 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.4" + version: "0.6.5" --- # GLPI User, Location, And Entity Provisioning From 52455c0946e906e6afd2d999afaa1056adc4210e Mon Sep 17 00:00:00 2001 From: baraline Date: Wed, 14 Oct 2026 10:19:20 +0200 Subject: [PATCH 4/4] fix(content): data_image_ids agrees with the reader on code and control characters data_image_ids listed the id of a data-URI image inside pre, code, kbd or samp, which the reader never offers to rewrite_link, so a caller could read a Document for an image the Markdown never shows. It now leaves those out. Tests now cover leading C0 control characters before data:, a leading no-break space (not stripped, as in a browser), and images under each unformatted element. The agreement test uses _is_data_uri as its oracle instead of a second reimplementation of the browser rules. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +- glpi_python_client/content/conversion.py | 8 +++- .../content/tests/test_rewrite_link.py | 41 +++++++++++++++++-- 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c0f279..5e354f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,8 @@ image's `id`. Existing methods are unchanged. - **`GlpiContentConverter.data_image_ids(html)`** returns, in document order and without duplicates, the `id` of every `` whose `src` is a `data:` URI, so a caller can tell whether it needs to read any Document at all. It - does not decode the base64. + does not decode the base64, and leaves out an image inside `
`, ``,
+  `` or ``, which the reader never offers to `rewrite_link`.
 - **`GetDocument.tag`**, a read-only property: the Document's tag from
   `extra_payload`, or `None` when absent or not a non-empty string. GLPI sets a
   tag on the Documents it creates from an upload or an editor paste, images or
diff --git a/glpi_python_client/content/conversion.py b/glpi_python_client/content/conversion.py
index ebb7394..b45e9bd 100644
--- a/glpi_python_client/content/conversion.py
+++ b/glpi_python_client/content/conversion.py
@@ -183,6 +183,9 @@ def _plain_text_html(text: str) -> str:
 _URL_LEADING = "".join(chr(c) for c in range(0x21))
 _URL_INNER = str.maketrans("", "", "\t\r\n")
 
+#: The elements inside which the reader writes no image, so offers none.
+_UNFORMATTED = ("pre", "code", "kbd", "samp")
+
 
 def _is_data_uri(src: str) -> bool:
     """Whether a browser reads ``src`` as a ``data:`` URI.
@@ -1056,7 +1059,9 @@ def data_image_ids(html: str) -> tuple[str, ...]:
 
         Each ```` whose ``src`` a browser reads as a ``data:`` URI and
         that has a non-empty ``id`` contributes it, in document order and
-        without repeats. The base64 is not decoded.
+        without repeats. The base64 is not decoded. An image inside ``
``,
+        ````, ```` or ```` is left out: the reader shows it
+        as no image, so it never offers it to ``rewrite_link`` either.
 
         GLPI sets a tag on the Documents it creates from an upload or an
         editor paste, images or not. For an image it has not rewritten into a
@@ -1071,6 +1076,7 @@ def data_image_ids(html: str) -> tuple[str, ...]:
             _image_id(img)
             for img in _soup(html).find_all("img")
             if _is_data_uri(str(img.get("src") or ""))
+            and img.find_parent(_UNFORMATTED) is None
         )
         return tuple(dict.fromkeys(i for i in ids if i is not None))
 
diff --git a/glpi_python_client/content/tests/test_rewrite_link.py b/glpi_python_client/content/tests/test_rewrite_link.py
index fd6cefb..ca18a96 100644
--- a/glpi_python_client/content/tests/test_rewrite_link.py
+++ b/glpi_python_client/content/tests/test_rewrite_link.py
@@ -16,6 +16,7 @@
 
 from glpi_python_client import GlpiValidationError
 from glpi_python_client.content import GlpiContentConverter, Link, RewriteLink
+from glpi_python_client.content.conversion import _is_data_uri
 from glpi_python_client.content.tests.test_round_trip import (
     E_MAIL_SHAPES,
     EARLIER_REGRESSIONS,
@@ -513,6 +514,35 @@ def test_data_image_ids_sees_a_scheme_in_mixed_case_spaced_and_split() -> None:
     assert data_image_ids(html) == ("t",)
 
 
+@pytest.mark.parametrize("lead", ["\x01", "\x0b", "\x1f", " \x0b\x01 "])
+def test_data_image_ids_strips_leading_control_characters(lead: str) -> None:
+    html = f'

' + + assert data_image_ids(html) == ("c",) + + +def test_data_image_ids_does_not_strip_a_leading_no_break_space() -> None: + html = '

' + + assert data_image_ids(html) == () + + +@pytest.mark.parametrize("wrapper", ["pre", "code", "kbd", "samp"]) +def test_data_image_ids_skips_an_image_the_reader_never_offers(wrapper: str) -> None: + html = f'<{wrapper}>' + + assert data_image_ids(html) == () + + +def test_data_image_ids_skips_an_image_nested_inside_code() -> None: + html = ( + f'

x' + f'

' + ) + + assert data_image_ids(html) == ("b",) + + @pytest.mark.parametrize("html", ["", "plain text", "

no image

"]) def test_data_image_ids_of_an_empty_body_is_empty(html: str) -> None: assert data_image_ids(html) == () @@ -534,6 +564,13 @@ def test_data_image_ids_of_an_empty_body_is_empty(html: str) -> None: '

', '

', '

', + '

', + '

', + '

', + '
', + '

x

', + '

' + '

', ], ) def test_data_image_ids_agrees_with_the_ids_the_reader_offers(html: str) -> None: @@ -541,9 +578,7 @@ def test_data_image_ids_agrees_with_the_ids_the_reader_offers(html: str) -> None dict.fromkeys( link.image_id for link in offered(html) - if link.image - and link.image_id - and "".join(link.href.split()).lstrip().casefold().startswith("data:") + if link.image and link.image_id and _is_data_uri(link.href) ) )