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( + '', + 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( + '
voir
ici

**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) == "" + + +# --------------------------------------------------------------------------- +# 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('', 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( + '', rewrite_link=_raise_value_error + ) + + assert read('') == "[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'', + rewrite_link=lambda seen: calls.append(f"inner {seen.href}"), # type: ignore[func-returns-value] + ) + + read( + '', + 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'', 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'' + ) + + +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`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