diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6c2828..87d61d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.11", "3.12", "3.13", "3.14"] steps: - name: Check out repository diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c49ddf3..fcd63a3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.11", "3.12", "3.13", "3.14"] steps: - name: Check out repository @@ -100,10 +100,7 @@ jobs: normalized_tag="${release_tag#v}" pyproject_version="$(python - <<'PY' from pathlib import Path - try: - from tomllib import loads as toml_loads - except ModuleNotFoundError: - from tomli import loads as toml_loads + from tomllib import loads as toml_loads pyproject = toml_loads(Path('pyproject.toml').read_text(encoding='utf-8')) print(pyproject['project']['version']) diff --git a/CHANGELOG.md b/CHANGELOG.md index b184fea..1d77c27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,32 +4,94 @@ 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/). -## Unreleased +## 0.6.0 — 2026-10-01 -### Added +### Changed (breaking) -- `Assets/Computer` endpoint support: `search_computers`, - `iter_search_computers`, `get_computer`, `create_computer`, - `update_computer`, `delete_computer`, and the `GetComputer` / - `PostComputer` / `PatchComputer` / `DeleteComputer` models. -- Computer-to-contract links: `list_computer_contracts`, - `get_computer_contract`, `link_computer_contract`, - `update_computer_contract`, `unlink_computer_contract`. The client sets - the link's `itemtype` itself, because the GLPI contract types it as a - free string. -- `Management/Contract` endpoint support, including the cost sub-resource - and the `Dropdowns/ContractType` dropdown. -- `GlpiContractRenewalType` for the contract's documented `renewal_type` - enum (no renewal, tacit, explicit). -- Two agent skills: `glpi-asset-workflow` and `glpi-contract-workflow`. +- **Python 3.10 is no longer supported; 3.11 is the minimum.** The + `typing-extensions` and `tomli` backports it needed are dropped. +- **Content conversion is rebuilt on three libraries: markdownify, + mdformat and cmark-gfm.** `from_transport` reads GLPI's HTML with + `markdownify`, and `mdformat` re-renders that Markdown from its syntax + tree, so it keeps only the escapes CommonMark needs. `to_transport` + renders through `cmark-gfm`, the GitHub reference implementation, in + place of python-markdown. A thin layer of glue sits on top. Measured on + 346 real bodies sampled from a GLPI 11 instance: + - 322 display the same after a round trip, against 299 before; + - 344 read back as the same Markdown, against 240. + + Of 205 realistic caller-written Markdown documents, all 205 survive + Markdown → HTML → Markdown with the same display. +- **Markdown is rendered as CommonMark with GFM tables.** A newline is a + line break, as `nl2br` made it before. Raw HTML passes through. + Differences you may see in Markdown you write: + - a list or a table written straight after a line now starts a list or a + table, where python-markdown wanted a blank line first; + - lists nested by two or three spaces nest; + - `1)` starts a numbered list; + - `#Important`, with no space, is text rather than a heading; + - `*a **b** c*` keeps its bold; + - a backslash ending a line is a line break, so write `C:\Temp\` at the + end of a line as `` `C:\Temp\` ``; + - `` is read as an HTML tag, so put a placeholder such as `` + in backticks. +- **`.content` is spelled as canonical CommonMark.** A line break reads + back as `\` and a newline, a nested list is indented by its bullet's + width, and a table comes back unpadded. Text that would otherwise read as + syntax is escaped: `__init__` reads `\_\_init\_\_`, and a `* point` line + reads `\* point`. Stored digests of `.content` change once. +- **A plain-text body is literal text on the read path.** A value with no + HTML element used to come back verbatim and be rendered as Markdown. It is + now read as GLPI displays it, its lines as lines. + `GlpiContentConverter.from_transport` takes `plain_text_is_markdown`. + `True` is what the write models' validator passes: caller-authored + Markdown passes verbatim unless it starts with an HTML tag, so Markdown + carrying an inline `
` or `` stays Markdown. +- **Dependencies.** + - Added: `cmarkgfm>=2025.10` (compiled wheels for CPython 3.11–3.14 on + Linux, macOS and Windows), `mdformat>=0.7.22,<0.8`, + `mdformat-tables>=1.0` and `markdown-it-py>=3.0`. + - Dropped: `markdown`. python-markdown 3.11 had broken the previous + reader. + - Raised: `beautifulsoup4>=4.15`, which fixed the parser defect that + dropped the text after a `
` in a body that also held a bare + `
`. That removes the workaround. -### Notes +### Fixed -- `Contract.date_begin` is modelled as `datetime.date`, not `datetime`. - The GLPI contract declares `format: date`, and keeping it a plain date - keeps it out of the server-clock conversion that rewrites aware - timestamps — which on a date-only field could roll the value to the - previous or next day. +- **Literal text came back as Markdown syntax.** These now read back as the + text a user typed: + - `\serveur\compta`, which had lost a backslash; + - `__init__` and `______`, which had become bold; + - a `-----` line under text, which had made a heading; + - `* point` and `> merci` lines, which had become a list and a quote; + - `[1]: https://...`, which had been consumed as a reference definition; + - a `|` in a table cell, which had dropped the rest of the row. +- **A table nested in a table cell lost all its text.** That is the usual + layout of an e-mail signature. The inner table is now written as its + cells' text, its line breaks kept as `
`. +- **Nested lists flattened on the first write.** Nested items, the text + after a nested list, and numbering, `start` included, survive. +- `", True, id="script-body"), - pytest.param("", True, id="style-body"), - pytest.param("", True, id="marked-section"), - pytest.param("", True, id="unterminated-marked-section"), - pytest.param("", True, id="processing-instruction"), - ], -) -def test_the_degraded_path_keeps_exactly_what_the_converter_keeps( - construct: str, kept: bool +@pytest.mark.parametrize("html", _MALFORMED_DECLARATIONS) +def test_markup_the_parser_rejects_degrades_to_its_text( + monkeypatch: pytest.MonkeyPatch, html: str ) -> None: - """Parity, construct by construct, and not one of these was a guess. - - Each expectation here was read off the converting path rather than - reasoned about, and three came back the opposite way round from the - obvious answer -- a ``x", id="in-a-script-body"), - pytest.param("x", id="in-a-comment"), - pytest.param("

a
b

", id="slash-not-abutting-gt"), - ], -) -def test_the_void_rewrite_leaves_everything_else_alone(html: str) -> None: - """The rewrite is confined to void tags in real tag position. - - ``
`` is left as it is -- rewriting it would change what the - document means, and it cannot be affected anyway, since only a void - name is ever recorded as already closed. The last case is the one - worth pinning: ``
`` reaches the parser as an ordinary start - tag, because its ``/`` does not abut the ``>``, so it never takes the - path that loses text and needs no rewriting. - """ - - assert conversion._canonicalise_void_elements(html) == html - - -def test_the_void_rewrite_changes_nothing_for_one_spelling_alone() -> None: - """A body that picks a spelling and keeps it converts exactly as before. - - The rewrite exists to remove an asymmetry between two spellings of the - same node, so it must be invisible to every body that does not mix - them. Measured over 4000 fuzzed documents of each spelling: not one - output moved. - """ - - bare = "

a
b


c

" - slashed = "

a
b


c

" - - assert GlpiContentConverter.from_transport(bare) == "a \nb![]()\n\n---\n\nc" - assert GlpiContentConverter.from_transport(slashed) == "a \nb![]()\n\n---\n\nc" - - -def test_both_paths_agree_on_a_body_using_both_spellings() -> None: - """The degraded path already kept this text; now the converting one does. - - This body was the one place where the fallback said *more* than the - conversion it stands in for, which is the wrong way round for a - fallback and was how the defect was noticed at all. - """ - - shallow = "

one
two
three

" - deep = "
" * 600 + shallow + "
" * 600 - - converted = GlpiContentConverter.from_transport(shallow) - degraded = GlpiContentConverter.from_transport(deep) - - for word in ("one", "two", "three"): - assert word in converted - assert word in degraded - - -@pytest.mark.parametrize( - "fragment", - [ - pytest.param('', id="end-tag-with-a-quoted-attribute"), - pytest.param('
', id="name-less-equals-quote"), - pytest.param('

', id="quoted-gt-then-tag"), - ], -) -def test_a_misread_tag_end_does_not_swallow_the_body_after_it(fragment: str) -> None: - """Scaled past the cliff: degrades quietly, and keeps its words. - - Reading an end tag with start-tag rules consumed everything up to the - next quote, which deleted prose at any depth and under-counted the - nesting 1:1 with the repetition back when the nesting was predicted. - """ - - html = fragment * 600 + "the printer is offline" - - assert "the printer is offline" in GlpiContentConverter.from_transport(html) - - -def test_a_misread_tag_end_does_not_delete_prose() -> None: - """The other half of the same defect, and it needs no depth at all. - - Reading an end tag with attribute rules consumed everything between - the opening quote and its partner, so a degraded body said less than - the converting one -- the divergence the parity test forbids. - """ - - body = '

Bonjour

fin' - - assert_the_degraded_path_says_no_less(body) - assert "Bonjour" in GlpiContentConverter.from_transport(body) - - -def test_a_document_with_no_closing_bracket_is_answered_without_scanning() -> None: - """No ``>`` means no element, and saying so keeps a bad shape cheap. - - ``html.parser`` cannot finish a tag that never closes, so ``close()`` - flushes it one character at a time and rescans the tail at each step: - measured, 32 KB of ``'
None: - """The give-up point is not the end of the body. - - The scan stops where the parser stopped, so everything past that - construct would go missing unless it is handed back explicitly -- - and a body is far more likely to carry the marked section in the - middle than at the end. - """ - - html = "

avant

apres SECRET

" - - assert "avant" in _strip_tags(html) - assert "SECRET" in _strip_tags(html) - assert "SECRET" in GlpiContentConverter.from_transport(html) - assert "avant" in GlpiContentConverter.from_transport(html) - - -def test_stripping_a_document_with_no_closing_bracket_keeps_all_of_it() -> None: - """The degraded path needs the same guard the depth scan needs. - - ``html.parser`` cannot complete a tag that never closes, so - ``close()`` flushes it one character at a time and rescans the tail - at each step: measured, 32 KB of ``'

" * 60 + "kept", id="stray-close-p"), - pytest.param("
" * 60 + "kept", id="stray-close-span"), - pytest.param("

" * 60 + "kept", id="stray-close-b"), - pytest.param("x", id="interleaved"), - pytest.param("

" * 100 + "
" + "
" * 100, id="void-leaf"), - pytest.param("
" * 100 + "" + "
" * 100, id="self-closed-leaf"), - pytest.param("
" * 5000, id="void-only"), - pytest.param("
x
", id="close-of-a-void"), - pytest.param("

The printer is offline.

", id="realistic"), - pytest.param("

x

", id="tags-in-a-comment"), - pytest.param( - "
x
", id="tags-in-js" - ), - pytest.param("
" * 30 + "x", id="tables"), - pytest.param("
" * 100 + "x", id="blockquotes"), - pytest.param("

a

" * 100, id="siblings"), - ], -) -def test_both_paths_agree_about_what_is_markup(html: str) -> None: - """The two renderings of one body must not disagree about its markup. - - This corpus was built against a flat scan that predicted the nesting - depth, and it caught the scan reading markup differently from the - parser -- a stray close popping an element the parser keeps, a void - element counted as a parent, a tag inside a comment or a script body - counted at all. The prediction is gone; the corpus is not, because - the same disagreements would now show up as the fallback deleting or - inventing text relative to the converting path. - """ - - assert_the_degraded_path_says_no_less(html) - - -@pytest.mark.parametrize( - "html", - [ - # A comment with no ``-->`` is a *bogus comment*: the parser gives up - # at the first ``>``, so the ```` inside it is text and the - # ``
`` lands inside ````. Read that ```` as a - # real close and the count comes back one level short -- which is - # how a document that needed degrading reached the converter. - pytest.param("", id="pi-overlapping-a-comment"), - pytest.param("
", id="terminated-comment"), - pytest.param("

x

", id="doctype"), - pytest.param("
b]]>

x

", id="marked-section"), - pytest.param("

x

", id="raw-text"), - pytest.param("
", id="script"), + pytest.param( + "RE: Imprimante" + "

Bonjour

", + id="title", + ), + ], +) +def test_what_a_browser_does_not_display_is_dropped(html: str) -> None: + assert read(html) == "Bonjour" + + +def test_a_fence_keeps_its_language() -> None: + markdown = "```powershell\nGet-Service\n```" + + assert read(render(markdown)) == markdown + + +# --------------------------------------------------------------------------- +# Plain text and the write path +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "text", + [ + "__init__ et _x_", + "# pas un titre", + "* point un\n* point deux", + r"\\serveur\partage", + "if x0", + "Bonjour,\n\nMerci.", + ], +) +def test_a_plain_text_body_reads_as_the_text_it_is(text: str) -> None: + """A body with no HTML element is literal text, its lines lines.""" + + markdown = read(text) + shown = displayed(render(markdown)) + expected = displayed( + "

" + text.replace("<", "<").replace("\n", "
") + "

" + ) + + assert shown == expected + assert read(render(markdown)) == markdown + + +@pytest.mark.parametrize( + "markdown", + [ + "Run **passwd**, then check `logs`.", + "| a | b
c |\n| --- | --- |", + "Press Ctrl + C.", + "line one\nline two", + ], +) +def test_the_write_path_keeps_caller_markdown_verbatim(markdown: str) -> None: + """The write models' validator never rewrites the caller's Markdown.""" + + assert read(markdown, plain_text_is_markdown=True) == markdown + + +def test_the_write_path_still_converts_an_html_document() -> None: + assert read("

A bold move

", plain_text_is_markdown=True) == ( + "A **bold** move" + ) + + +# --------------------------------------------------------------------------- +# Markdown first +# --------------------------------------------------------------------------- + +#: What an integrator writes into followups, tasks, solutions and articles. +CALLER_MARKDOWN = [ + "The printer is **offline**.", + "Line one\nline two", + "# Procédure\n\n1. Arrêter le service\n2. Vider le cache\n3. Redémarrer", + "- Réseau\n - switch 3\n - borne wifi\n- Imprimante", + "- Réseau\n - switch 3\n- Imprimante", + "1. Sauvegarder\n 1. la base\n 2. les fichiers\n2. Redémarrer", + 'Voir [la procédure](https://example.org/kb/42 "KB 42").', + "Lien direct : ", + "![capture](https://example.org/c.png)", + "Lancer `ipconfig /all` puis envoyer le résultat.", + "```powershell\nGet-Service | Where-Object Status -eq Running\n```", + "> Le 30/09, Jean a écrit :\n> merci", + "| Poste | IP |\n| :--- | ---: |\n| PC12 | 10.0.0.12 |", + "| Commande | Effet |\n| --- | --- |\n| `ps aux \\| grep java` | processus |", + "Fichier `mon_fichier_final.docx` et chemin C:\\Temp\\logs.", + "Contact : support@example.org, R&D, 5 * 3 = 15.", + "Résolu ✅ — merci 👍", + "**Cause :** disque plein.\n\n**Solution :** purge des journaux.", + "Avant :\n\n---\n\nAprès.", + "Étapes :\n- ouvrir la session\n- lancer Outlook", +] + + +@pytest.mark.parametrize("markdown", CALLER_MARKDOWN) +def test_caller_markdown_displays_the_same_after_the_round_trip(markdown: str) -> None: + html = render(markdown) + back = read(html) + + assert displayed(render(back)) == displayed(html) + assert read(render(back)) == back + + +# --------------------------------------------------------------------------- +# Cost and depth +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "html", + [ + pytest.param("

" + "ligne
" * 20_000 + "

", id="line-breaks"), + pytest.param("
    " + "
  • x
  • " * 20_000 + "
", id="list-items"), + pytest.param("
    " + "
  • " * 20_000 + "
", id="empty-items"), + pytest.param("

" + "gras mot " * 20_000 + "

", id="emphasis"), + ], +) +def test_a_long_body_converts_in_linear_time(html: str) -> None: + """The budget is generous on purpose: this guards the complexity, not the speed.""" + + started = time.perf_counter() + read(html) + + assert time.perf_counter() - started < 20 + + +def test_a_body_too_deep_to_convert_keeps_its_text() -> None: + """markdownify recurses per nesting level; past the stack, the text is kept.""" + + html = "
" * 3000 + "

__init__ au fond

" + "
" * 3000 + + markdown = read(html) + + assert "init" in markdown + assert "au fond" in render(markdown) diff --git a/glpi_python_client/models/api_schema/_content.py b/glpi_python_client/models/api_schema/_content.py index 13fd521..50d0a5c 100644 --- a/glpi_python_client/models/api_schema/_content.py +++ b/glpi_python_client/models/api_schema/_content.py @@ -44,19 +44,18 @@ Pydantic resolves them, and would otherwise divert the wire's ``content`` into ``extra_payload``. -Plain-text content is preserved verbatim on the inbound path and rendered -as HTML paragraphs on the outbound path, matching the converter's default -behaviour. "Plain text" means text carrying no recognised HTML element: -``use the key`` and ``if x0`` are text, because ``Enter`` -and ``y`` are not elements, while ``ac`` is treated as markup because -``b`` is. ``None`` values are passed through unchanged so optional fields -and ``exclude_none`` semantics keep working. +On the read path a plain-text body -- one carrying no recognised HTML +element, so ``use the key`` and ``if x0`` are text while +``ac`` is markup -- is literal text, read as GLPI displays it. +``None`` values are passed through unchanged so optional fields and +``exclude_none`` semantics keep working. Note that the inbound converter also runs on **outbound** content: the ``BeforeValidator`` below fires when a caller constructs a ``Post*`` model, so caller-authored Markdown passes through it before the serializer renders -it. That is why the plain-text path has to stay verbatim -- routing Markdown -through the HTML normaliser escapes it, and GLPI receives literal asterisks. +it. It runs with ``plain_text_is_markdown=True``, which keeps the caller's +Markdown verbatim unless it starts with an HTML tag -- reading it as literal +text would escape it, and GLPI would receive literal asterisks. One sharp edge comes with the read side, from ``functools.cached_property``: assigning to ``content_html`` after ``content`` has been read leaves the @@ -110,7 +109,7 @@ def _from_transport(value: object) -> str | None: if value is None: return None - return GlpiContentConverter.from_transport(value) + return GlpiContentConverter.from_transport(value, plain_text_is_markdown=True) def markdown_view(raw: str | None) -> str | None: diff --git a/glpi_python_client/models/api_schema/tests/test_content.py b/glpi_python_client/models/api_schema/tests/test_content.py index 00cc8c1..52892e9 100644 --- a/glpi_python_client/models/api_schema/tests/test_content.py +++ b/glpi_python_client/models/api_schema/tests/test_content.py @@ -19,6 +19,7 @@ from glpi_python_client import GlpiContentError, GlpiError, GlpiValidationError from glpi_python_client._sync._testing import TransportRecorder, make_client from glpi_python_client._sync.clients.commons._payloads import model_to_payload +from glpi_python_client.content import conversion from glpi_python_client.models.api_schema._content import ( _from_transport, _to_transport, @@ -79,7 +80,9 @@ def test_a_read_model_reads_none_when_glpi_sent_no_body() -> None: assert ticket.content is None -def test_a_content_fault_on_the_write_path_stays_in_the_taxonomy() -> None: +def test_a_content_fault_on_the_write_path_stays_in_the_taxonomy( + monkeypatch: pytest.MonkeyPatch, +) -> None: """pydantic-core destroys a serializer's exception; this puts it back. Outbound conversion runs in a ``PlainSerializer``, and everything it @@ -88,19 +91,25 @@ def test_a_content_fault_on_the_write_path_stays_in_the_taxonomy() -> None: ``__context__`` both ``None``. Every ``create_*``/``update_*`` carrying a body was affected, so ``except GlpiError`` did not fire on the one path the package's error contract is most explicit about. + + cmark-gfm renders without recursing, so no Markdown makes it fail; the + fault is injected. """ - deep_markdown = "".join(" " * (4 * level) + "- x\n" for level in range(600)) + def failing_renderer(markdown: str) -> str: + raise RuntimeError("renderer fault") + + monkeypatch.setattr(conversion, "markdown_to_html", failing_renderer) client = make_client() TransportRecorder().install(client) with pytest.raises(GlpiContentError) as caught: - client.create_ticket(PostTicket(name="round trip", content=deep_markdown)) + client.create_ticket(PostTicket(name="round trip", content="**x**")) assert isinstance(caught.value, GlpiError) assert not isinstance(caught.value, ValueError) # the original fault, which pydantic-core had discarded - assert isinstance(caught.value.__cause__, RecursionError) + assert isinstance(caught.value.__cause__, RuntimeError) # and the serializer wrapper, kept where a reader would look for it assert isinstance(caught.value.__context__, PydanticSerializationError) diff --git a/glpi_python_client/models/custom_schema/_ticket_context.py b/glpi_python_client/models/custom_schema/_ticket_context.py index d645f4f..e5034e4 100644 --- a/glpi_python_client/models/custom_schema/_ticket_context.py +++ b/glpi_python_client/models/custom_schema/_ticket_context.py @@ -9,12 +9,13 @@ from __future__ import annotations from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import UTC, datetime from enum import Enum from typing import Any from pydantic import Field +from glpi_python_client.content.conversion import GlpiContentConverter from glpi_python_client.models._base import GlpiModel from glpi_python_client.models.api_schema._common import IdNameRef from glpi_python_client.models.api_schema.assistance._ticket import GetTicket @@ -29,7 +30,18 @@ ) from glpi_python_client.models.api_schema.management._document import GetDocument -_MAX_DATETIME = datetime.max.replace(tzinfo=timezone.utc) +_MAX_DATETIME = datetime.max.replace(tzinfo=UTC) + + +def _literal(text: str) -> str: + """Spell a name or a file name as one line of Markdown that shows it as typed. + + User data is literal text, as a body's text is: ``__init__`` must not turn + bold, nor a file name starting ``1.`` start a list. The converter reads + plain text that way already. + """ + + return GlpiContentConverter.from_transport(" ".join(text.split())) def _ref_label(ref: IdNameRef | None) -> str | None: @@ -83,7 +95,7 @@ def _subtitle_line(*parts: tuple[str, object | None]) -> str | None: for label, value in parts: rendered_value = _render_value(value) if rendered_value: - rendered_parts.append(f"{label}: {rendered_value}") + rendered_parts.append(f"{label}: {_literal(rendered_value)}") if not rendered_parts: return None return f"> {' | '.join(rendered_parts)}" @@ -180,7 +192,7 @@ def _event_sort_key(event: Any) -> datetime: if created is None: return _MAX_DATETIME if created.tzinfo is None: - return created.replace(tzinfo=timezone.utc) + return created.replace(tzinfo=UTC) return created @@ -255,7 +267,7 @@ def to_markdown( lines: list[str] = [] ticket = self.ticket - ticket_label = ticket.name or "(unnamed ticket)" + ticket_label = _literal(ticket.name) if ticket.name else "(unnamed ticket)" if ticket.id is not None: lines.append(f"# Ticket #{ticket.id} \u2014 {ticket_label}") else: @@ -364,7 +376,7 @@ def to_markdown( else "document" ) ) - lines.append(f"- {label}") + lines.append(f"- {_literal(label)}") return "\n".join(lines).rstrip() diff --git a/glpi_python_client/models/custom_schema/tests/test_ticket_context.py b/glpi_python_client/models/custom_schema/tests/test_ticket_context.py index a9ad98a..288ea48 100644 --- a/glpi_python_client/models/custom_schema/tests/test_ticket_context.py +++ b/glpi_python_client/models/custom_schema/tests/test_ticket_context.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime, timezone +from datetime import UTC, datetime import pytest from pydantic import ValidationError @@ -75,8 +75,8 @@ def test_to_markdown_renders_ticket_subtitle_metadata() -> None: "name": "Printer broken", "user_recipient": {"id": 7, "name": "Alice"}, "user_editor": {"id": 8, "name": "Bob"}, - "date_creation": datetime(2024, 1, 1, 9, 30, tzinfo=timezone.utc), - "date_mod": datetime(2024, 1, 2, 11, 45, tzinfo=timezone.utc), + "date_creation": datetime(2024, 1, 1, 9, 30, tzinfo=UTC), + "date_mod": datetime(2024, 1, 2, 11, 45, tzinfo=UTC), } } ) @@ -99,12 +99,12 @@ def test_to_markdown_orders_events_by_creation_when_no_position() -> None: { "id": 2, "content": "second note", - "date_creation": datetime(2024, 1, 2, tzinfo=timezone.utc), + "date_creation": datetime(2024, 1, 2, tzinfo=UTC), }, { "id": 1, "content": "first note", - "date_creation": datetime(2024, 1, 1, tzinfo=timezone.utc), + "date_creation": datetime(2024, 1, 1, tzinfo=UTC), }, ], } @@ -123,7 +123,7 @@ def test_to_markdown_ignores_timeline_position_for_ordering() -> None: { "id": 1, "content": "no position late", - "date_creation": datetime(2024, 1, 5, tzinfo=timezone.utc), + "date_creation": datetime(2024, 1, 5, tzinfo=UTC), }, ], "tasks": [ @@ -131,7 +131,7 @@ def test_to_markdown_ignores_timeline_position_for_ordering() -> None: "id": 2, "content": "left positioned", "timeline_position": 1, - "date_creation": datetime(2024, 1, 10, tzinfo=timezone.utc), + "date_creation": datetime(2024, 1, 10, tzinfo=UTC), }, ], } @@ -197,8 +197,8 @@ def test_to_markdown_renders_event_creator_editor_and_timestamps() -> None: "content": "note", "user": {"id": 7, "name": "Alice"}, "user_editor": {"id": 8, "name": "Bob"}, - "date_creation": datetime(2024, 1, 2, 10, 0, tzinfo=timezone.utc), - "date_mod": datetime(2024, 1, 2, 10, 5, tzinfo=timezone.utc), + "date_creation": datetime(2024, 1, 2, 10, 0, tzinfo=UTC), + "date_mod": datetime(2024, 1, 2, 10, 5, tzinfo=UTC), } ], } @@ -226,8 +226,8 @@ def test_to_markdown_renders_event_creator_editor_and_timestamps() -> None: "status": {"id": 2, "name": "Open"}, "user_recipient": {"id": 3, "name": "Alice"}, "user_editor": {"id": 4, "name": "Bob"}, - "date_creation": datetime(2024, 1, 1, tzinfo=timezone.utc), - "date_mod": datetime(2024, 1, 2, tzinfo=timezone.utc), + "date_creation": datetime(2024, 1, 1, tzinfo=UTC), + "date_mod": datetime(2024, 1, 2, tzinfo=UTC), }, "followups": [{"id": 10, "content": "followup body"}], "tasks": [{"id": 20, "content": "task body", "duration": 600}], @@ -368,7 +368,7 @@ def test_options_hide_event_dates() -> None: { "id": 5, "content": "note", - "date_creation": datetime(2024, 3, 1, tzinfo=timezone.utc), + "date_creation": datetime(2024, 3, 1, tzinfo=UTC), } ], } @@ -415,7 +415,7 @@ def test_to_markdown_sorts_aware_events_when_one_lacks_a_creation_date() -> None { "id": 1, "content": "dated note", - "date_creation": datetime(2024, 1, 1, tzinfo=timezone.utc), + "date_creation": datetime(2024, 1, 1, tzinfo=UTC), }, ], } @@ -440,7 +440,7 @@ def test_to_markdown_orders_events_across_mixed_datetime_awareness() -> None: { "id": 2, "content": "aware second", - "date_creation": datetime(2024, 1, 2, tzinfo=timezone.utc), + "date_creation": datetime(2024, 1, 2, tzinfo=UTC), }, { "id": 1, @@ -454,3 +454,42 @@ def test_to_markdown_orders_events_across_mixed_datetime_awareness() -> None: rendered = context.to_markdown() assert rendered.index("naive first") < rendered.index("aware second") + + +def test_to_markdown_shows_names_and_file_names_as_typed() -> None: + """A ticket name, a user name or a file name is literal text, like a body. + + ``__init__`` in a name used to turn bold and a file name's ``*final*`` + italic, and a file name starting ``1.`` became a numbered list. + """ + + from bs4 import BeautifulSoup + + from glpi_python_client.content import GlpiContentConverter + + context = GlpiTicketContext.model_validate( + { + "ticket": { + "id": 7, + "name": "__init__ échoue", + "content": "

corps

", + "user_recipient": {"id": 3, "name": "*admin* [ext]"}, + }, + "documents": [ + {"id": 1, "filename": "rapport_*final*_v2.pdf"}, + {"id": 2, "filename": "1. lisez-moi.txt"}, + ], + } + ) + + html = GlpiContentConverter.to_transport(context.to_markdown()) + soup = BeautifulSoup(html, "html.parser") + + h1 = soup.find("h1") + assert h1 is not None + assert h1.get_text() == "Ticket #7 \u2014 __init__ échoue" + quote = soup.find("blockquote") + assert quote is not None + assert "Requester: *admin* [ext]" in quote.get_text() + items = [item.get_text() for item in soup.find_all("li")] + assert items == ["rapport_*final*_v2.pdf", "1. lisez-moi.txt"] diff --git a/glpi_python_client/models/tests/test_base.py b/glpi_python_client/models/tests/test_base.py index 8e0fbe1..2990880 100644 --- a/glpi_python_client/models/tests/test_base.py +++ b/glpi_python_client/models/tests/test_base.py @@ -7,7 +7,7 @@ from __future__ import annotations -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta, timezone from typing import Annotated import pytest @@ -145,7 +145,7 @@ def test_aware_datetime_is_converted_to_the_server_clock_and_stripped() -> None: the conversion rather than written out. """ - stamped = _Stamped(id=1, date=datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc)) + stamped = _Stamped(id=1, date=datetime(2024, 1, 1, 12, 0, tzinfo=UTC)) dumped = stamped.model_dump(mode="json", context={"server_timezone": _PARIS_WINTER}) @@ -169,7 +169,7 @@ def test_serialisation_without_a_context_leaves_the_offset_alone() -> None: one would be the same silent shift the conversion exists to prevent. """ - stamped = _Stamped(id=1, date=datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc)) + stamped = _Stamped(id=1, date=datetime(2024, 1, 1, 12, 0, tzinfo=UTC)) assert stamped.model_dump(mode="json")["date"] == "2024-01-01T12:00:00Z" @@ -179,7 +179,7 @@ def test_the_serialisation_timezone_reaches_a_nested_model() -> None: nested = _Nested( id=1, - inner=_Stamped(id=2, date=datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc)), + inner=_Stamped(id=2, date=datetime(2024, 1, 1, 12, 0, tzinfo=UTC)), ) dumped = nested.model_dump(mode="json", context={"server_timezone": _PARIS_WINTER}) diff --git a/glpi_python_client/testing/tests/test_content_roundtrip.py b/glpi_python_client/testing/tests/test_content_roundtrip.py index 5f685b1..a2ce94b 100644 --- a/glpi_python_client/testing/tests/test_content_roundtrip.py +++ b/glpi_python_client/testing/tests/test_content_roundtrip.py @@ -16,6 +16,7 @@ from glpi_python_client._sync.clients.commons._payloads import model_to_payload from glpi_python_client.content import conversion +from glpi_python_client.content.tests.display import displayed from glpi_python_client.models._base import GlpiModel from glpi_python_client.models.api_schema.assistance import ( GetTicket, @@ -46,9 +47,9 @@ def _count_conversions(monkeypatch: pytest.MonkeyPatch) -> list[str]: seen: list[str] = [] real = conversion.GlpiContentConverter.from_transport - def _record(value: object) -> str: + def _record(value: object, **options: bool) -> str: seen.append(str(value)) - return real(value) + return real(value, **options) monkeypatch.setattr( conversion.GlpiContentConverter, "from_transport", staticmethod(_record) @@ -135,16 +136,15 @@ def test_outgoing_empty_string_renders_empty() -> None: # Round-trip corpus # --------------------------------------------------------------------------- # -# ``from_transport(to_transport(m)) == m`` is the property the content layer -# would like to hold. It does not hold universally, and cannot: the two -# libraries either side of the wire disagree about a handful of constructs, -# and no option on either fixes them. +# Markdown written by a caller goes to GLPI as HTML and reads back as +# Markdown. It reads back in the converter's canonical spelling -- a line +# break as a backslash, a nested list indented by its bullet's width, a table +# unpadded -- which displays the same and reads back as itself. Each entry +# names that spelling when it differs from the caller's, and the test asserts +# both: the canonical Markdown, and the same display as what was sent. # -# So the corpus is an inventory rather than a property test. Every case is -# listed, the lossy ones carry ``xfail(strict=True)``, and that strictness is -# the point -- fixing one of them turns its xfail into an XPASS and fails the -# suite, forcing the inventory to be updated rather than quietly drifting out -# of date. A regression in a passing case fails immediately. +# A loss carries ``xfail(strict=True)``, so fixing it fails the suite until +# the inventory is updated. def _lossy(reason: str) -> pytest.MarkDecorator: @@ -154,68 +154,77 @@ def _lossy(reason: str) -> pytest.MarkDecorator: ROUND_TRIP_CORPUS = [ - pytest.param("The printer is offline.", id="plain"), - pytest.param("The printer is **offline**.", id="bold"), - pytest.param("This is *emphasis*.", id="italic"), - pytest.param("Run `systemctl restart` now.", id="inline-code"), - pytest.param("# Title\n\nBody text.", id="heading"), - pytest.param("## Section\n\nBody text.", id="subheading"), - pytest.param("First para.\n\nSecond para.", id="paragraphs"), - pytest.param("line one \nline two", id="hard-break"), - pytest.param("- alpha\n- beta\n- gamma", id="bullets"), - pytest.param("1. one\n2. two", id="numbered"), - pytest.param("> quoted text", id="blockquote"), - pytest.param("See [the doc](https://example.test/doc).", id="link"), - pytest.param("```\nx = 1\n```", id="fence"), - pytest.param("| a | b |\n| --- | --- |\n| 1 | 2 |", id="table"), - pytest.param("The snake_case name.", id="underscore"), - pytest.param("5 * 3 = 15", id="asterisk"), - pytest.param("# Title\n\n- alpha\n- beta\n\nClosing **note**.", id="mixed"), + pytest.param("The printer is offline.", None, id="plain"), + pytest.param("The printer is **offline**.", None, id="bold"), + pytest.param("This is *emphasis*.", None, id="italic"), + pytest.param("Run `systemctl restart` now.", None, id="inline-code"), + pytest.param("# Title\n\nBody text.", None, id="heading"), + pytest.param("## Section\n\nBody text.", None, id="subheading"), + pytest.param("First para.\n\nSecond para.", None, id="paragraphs"), + pytest.param("line one \nline two", "line one\\\nline two", id="hard-break"), + pytest.param("line one\nline two", "line one\\\nline two", id="soft-newline"), + pytest.param("- alpha\n- beta\n- gamma", None, id="bullets"), + pytest.param("1. one\n2. two", None, id="numbered"), + pytest.param("> quoted text", None, id="blockquote"), + pytest.param("See [the doc](https://example.test/doc).", None, id="link"), + pytest.param("```\nx = 1\n```", None, id="fence"), + pytest.param("```python\nx = 1\n```", None, id="fence-with-language"), pytest.param( - "line one\nline two", - id="soft-newline", - marks=_lossy( - "nl2br renders a lone newline as
, which markdownify reads " - "back as a hard break (two trailing spaces). Semantically " - "equivalent and stable after one cycle; see issue #32." - ), + "| a | b |\n| --- | --- |\n| 1 | 2 |", + "| a | b |\n| -- | -- |\n| 1 | 2 |", + id="table", ), + pytest.param("The snake_case name.", None, id="underscore"), + pytest.param("5 * 3 = 15", None, id="asterisk"), + pytest.param("# Title\n\n- alpha\n- beta\n\nClosing **note**.", None, id="mixed"), pytest.param( - "- alpha\n - inner\n- beta", - id="nested-list", - marks=_lossy( - "markdownify indents nested items by 2 spaces; python-markdown " - "needs 4 to keep the nesting, so a second cycle flattens it." - ), + "- alpha\n - inner\n- beta", "- alpha\n - inner\n- beta", id="nested-list" ), pytest.param( - "```python\nx = 1\n```", - id="fence-with-language", - marks=_lossy( - "fenced_code emits class='language-python' and markdownify drops " - "the class, so the language tag cannot survive." - ), + "1. one\n 1. inner\n2. two", + "1. one\n 1. inner\n2. two", + id="nested-numbered-list", + ), + pytest.param("intro\n\n3. three\n4. four", None, id="numbered-from-three"), + pytest.param( + r"\#4521: module \_\_init\_\_.", + r"#4521: module \_\_init\_\_.", + id="escaped-literals", + ), + pytest.param(r"Share \\\server\share and C:\Temp.", None, id="backslashes"), + pytest.param( + r"Press <Enter> and see \*x\*.", + r"Press \ and see \*x\*.", + id="escaped-markup", + ), + pytest.param( + "| cmd |\n| --- |\n| ps aux \\| grep java |", + "| cmd |\n| -- |\n| ps aux \\| grep java |", + id="pipe-in-a-cell", ), pytest.param( "use the key", + None, id="angle-bracket-text", marks=_lossy( - "to_transport does not escape raw markup, so the text reaches " - "GLPI as a live unknown tag -- which the web UI drops too. " - "Escaping it is a separate change to the outbound direction." + "to_transport passes raw markup through, so the text reaches GLPI " + "as a live unknown tag, which the web UI drops too. Write it in " + "backticks." ), ), ] -@pytest.mark.parametrize("markdown", ROUND_TRIP_CORPUS) -def test_round_trip_corpus(markdown: str) -> None: +@pytest.mark.parametrize(("markdown", "canonical"), ROUND_TRIP_CORPUS) +def test_round_trip_corpus(markdown: str, canonical: str | None) -> None: """Markdown survives a full write-then-read cycle through GLPI's HTML.""" outgoing = model_to_payload(PostTicket(name="Round trip", content=markdown)) incoming = GetTicket.model_validate({"name": "Round trip", **outgoing}) - assert incoming.content == markdown + assert incoming.content == (canonical or markdown) + rendered = conversion.GlpiContentConverter.to_transport(incoming.content) + assert displayed(rendered) == displayed(outgoing["content"]) # --------------------------------------------------------------------------- diff --git a/glpi_python_client/testing/tests/test_method_invocation.py b/glpi_python_client/testing/tests/test_method_invocation.py index 5aa2f07..9111401 100644 --- a/glpi_python_client/testing/tests/test_method_invocation.py +++ b/glpi_python_client/testing/tests/test_method_invocation.py @@ -25,7 +25,7 @@ import inspect from collections.abc import AsyncIterator, Iterator from contextlib import asynccontextmanager, contextmanager -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from typing import Any, ClassVar, get_type_hints import pytest @@ -212,7 +212,7 @@ async def _astream( # Pretend a valid, non-expiring token is already held so no OAuth round # trip happens and the call log contains only endpoint traffic. client._auth.access_token = "stub-token" - client._auth.token_expires_at = datetime.now(tz=timezone.utc) + timedelta(days=365) + client._auth.token_expires_at = datetime.now(tz=UTC) + timedelta(days=365) # Several features (plugin fields, KB category writes, document upload, # actor statistics) run on the legacy v1 session rather than the v2 # transport. Stub it into the same log so they are exercised too. diff --git a/glpi_python_client/testing/tests/test_packaging.py b/glpi_python_client/testing/tests/test_packaging.py index c6e7563..348e82f 100644 --- a/glpi_python_client/testing/tests/test_packaging.py +++ b/glpi_python_client/testing/tests/test_packaging.py @@ -13,12 +13,7 @@ from __future__ import annotations import pathlib -import sys - -if sys.version_info >= (3, 11): - import tomllib -else: # pragma: no cover - exercised on 3.10 only - import tomli as tomllib +import tomllib _REPO_ROOT = pathlib.Path(__file__).resolve().parents[3] diff --git a/glpi_python_client/testing/tests/test_version_agreement.py b/glpi_python_client/testing/tests/test_version_agreement.py index 4e2e55b..93b8a8e 100644 --- a/glpi_python_client/testing/tests/test_version_agreement.py +++ b/glpi_python_client/testing/tests/test_version_agreement.py @@ -17,12 +17,7 @@ import pathlib import re -import sys - -if sys.version_info >= (3, 11): - import tomllib -else: # pragma: no cover - exercised on 3.10 only - import tomli as tomllib +import tomllib import glpi_python_client diff --git a/glpi_python_client/tests/test_rsql.py b/glpi_python_client/tests/test_rsql.py index a8692a9..a3342e2 100644 --- a/glpi_python_client/tests/test_rsql.py +++ b/glpi_python_client/tests/test_rsql.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import date, datetime, timezone +from datetime import UTC, date, datetime from zoneinfo import ZoneInfo import pytest @@ -98,7 +98,7 @@ def test_changed_since_converts_an_aware_datetime_into_the_server_zone() -> None must be the server's rendering of that same instant. """ - aware = datetime(2026, 8, 12, 7, 33, tzinfo=timezone.utc) + aware = datetime(2026, 8, 12, 7, 33, tzinfo=UTC) assert changed_since(aware, tz=ZoneInfo("Europe/Paris")) == ( "date_mod=ge=2026-08-12 09:33:00" @@ -113,8 +113,8 @@ def test_changed_since_follows_dst_in_the_server_zone() -> None: """ paris = ZoneInfo("Europe/Paris") - winter = datetime(2026, 1, 15, 12, 0, tzinfo=timezone.utc) - summer = datetime(2026, 7, 15, 12, 0, tzinfo=timezone.utc) + winter = datetime(2026, 1, 15, 12, 0, tzinfo=UTC) + summer = datetime(2026, 7, 15, 12, 0, tzinfo=UTC) assert changed_since(winter, tz=paris) == "date_mod=ge=2026-01-15 13:00:00" assert changed_since(summer, tz=paris) == "date_mod=ge=2026-07-15 14:00:00" @@ -129,7 +129,7 @@ def test_changed_since_rejects_an_aware_datetime_without_a_zone() -> None: over-reads east of UTC and skips modifications west of it. """ - aware = datetime(2026, 1, 1, 13, 45, 30, tzinfo=timezone.utc) + aware = datetime(2026, 1, 1, 13, 45, 30, tzinfo=UTC) with pytest.raises(GlpiValidationError): changed_since(aware) diff --git a/pyproject.toml b/pyproject.toml index da9fa8a..62f048e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,10 +35,10 @@ exclude = [ [project] name = "glpi-python-client" -version = "0.5.0" +version = "0.6.0" description = "A typed Python client for GLPI ITSM APIs." readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.11" license = { text = "MIT" } authors = [{ name = "glpi-python-client contributors" }] keywords = ["glpi", "itsm", "api", "client"] @@ -48,7 +48,6 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", @@ -58,11 +57,18 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "beautifulsoup4>=4.12", + # 4.15 fixed a parser defect that dropped the text after a "
" in a + # body that also held a bare "
". + "beautifulsoup4>=4.15", + # Renders Markdown to HTML: CommonMark with GFM tables. + "cmarkgfm>=2025.10", "httpx>=0.28", "lxml>=4.9", - "markdown>=3.6", - "markdownify>=0.13", + "markdown-it-py>=3.0", + "markdownify>=1.2", + # The reader extends mdformat's renderer, an API that may move at 0.8. + "mdformat>=0.7.22,<0.8", + "mdformat-tables>=1.0", "pydantic>=2.8", # Never imported by this package, and deliberately so. httpcore decides # whether it is running under asyncio or trio by probing for sniffio on @@ -82,7 +88,6 @@ dependencies = [ # CI and raises ZoneInfoNotFoundError on a developer machine. "tzdata>=2024.1; platform_system == 'Windows'", "tenacity>=8.2", - "typing-extensions>=4.7; python_version < '3.11'", ] [project.optional-dependencies] @@ -90,7 +95,6 @@ docs = [ "numpydoc>=1.8", "sphinx>=7.2,<8.2", "sphinx-rtd-theme>=2.0", - "tomli>=2.0; python_version < '3.11'", ] dev = [ "build>=1.2", @@ -103,7 +107,6 @@ dev = [ "ruff>=0.6", "sphinx>=7.2,<8.2", "sphinx-rtd-theme>=2.0", - "tomli>=2.0; python_version < '3.11'", "twine>=5.1", "unasync>=0.6", "vulture>=2.11", @@ -178,7 +181,7 @@ exclude_lines = [ [tool.ruff] line-length = 88 -target-version = "py310" +target-version = "py311" # The sync client tree is generated by unasync_build.py from _async/ and # must stay byte-identical to what regeneration produces -- that identity # is what the CI gate checks. Formatting it would fight the generator, and @@ -190,7 +193,7 @@ extend-exclude = ["glpi_python_client/_sync"] select = ["B", "E", "F", "I", "RUF", "UP"] [tool.mypy] -python_version = "3.10" +python_version = "3.11" packages = ["glpi_python_client"] strict = true warn_unreachable = true diff --git a/skills/glpi-asset-workflow/SKILL.md b/skills/glpi-asset-workflow/SKILL.md index a88b819..e7b32f9 100644 --- a/skills/glpi-asset-workflow/SKILL.md +++ b/skills/glpi-asset-workflow/SKILL.md @@ -2,10 +2,10 @@ name: glpi-asset-workflow description: "Search, fetch, create, update, and delete GLPI computers, and read or write the contracts covering them, with the synchronous glpi_python_client.GlpiClient or the asynchronous AsyncGlpiClient, and the GetComputer/PostComputer/PatchComputer/DeleteComputer and GetContractItem/PostContractItem models. Use for GLPI asset inventory, computer records, asset serial numbers, asset locations, or finding which contracts cover a machine." license: MIT -compatibility: "Requires Python 3.10+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to read or write assets." +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.5.0" + version: "0.6.0" --- # GLPI Asset Workflow diff --git a/skills/glpi-client-setup/SKILL.md b/skills/glpi-client-setup/SKILL.md index 0e2598f..367709a 100644 --- a/skills/glpi-client-setup/SKILL.md +++ b/skills/glpi-client-setup/SKILL.md @@ -2,10 +2,10 @@ name: glpi-client-setup description: "Create and configure the synchronous glpi_python_client.GlpiClient or the asynchronous glpi_python_client.AsyncGlpiClient, including from_env, OAuth credential pairs, entity/profile headers, SSL settings, and the optional legacy v1 session (v1_base_url / v1_user_token) that backs document uploads, the Fields plugin helpers, KB category writes and actor-based statistics. Use before calling GLPI APIs, when configuring the v1 session for any of those features, or when the user asks how to connect to GLPI with glpi_python_client." license: MIT -compatibility: "Requires Python 3.10+, glpi-python-client, network access to a GLPI v2 API, and valid GLPI credentials." +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.5.0" + version: "0.6.0" --- # GLPI Client Setup diff --git a/skills/glpi-contract-workflow/SKILL.md b/skills/glpi-contract-workflow/SKILL.md index 6f0ff9c..d0498a3 100644 --- a/skills/glpi-contract-workflow/SKILL.md +++ b/skills/glpi-contract-workflow/SKILL.md @@ -2,10 +2,10 @@ name: glpi-contract-workflow description: "Search, fetch, create, update, and delete GLPI contracts, their cost lines, and the contract-type dropdown, with the synchronous glpi_python_client.GlpiClient or the asynchronous AsyncGlpiClient, and the GetContract/PostContract/PatchContract/DeleteContract, GetContractCost/PostContractCost, and GetContractType/PostContractType models. Use for GLPI contract coverage, maintenance agreements, contract cost/budget lines, contract renewal type, or the contract-type dropdown." license: MIT -compatibility: "Requires Python 3.10+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to read or write contracts." +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.5.0" + version: "0.6.0" --- # GLPI Contract Workflow diff --git a/skills/glpi-document-workflow/SKILL.md b/skills/glpi-document-workflow/SKILL.md index 4fe0272..3c119f3 100644 --- a/skills/glpi-document-workflow/SKILL.md +++ b/skills/glpi-document-workflow/SKILL.md @@ -2,10 +2,10 @@ name: glpi-document-workflow description: "Manage GLPI document metadata, upload binary content via the legacy v1 fallback, download document binaries, and link documents to a ticket timeline with the synchronous glpi_python_client.GlpiClient or the asynchronous AsyncGlpiClient, and the GetDocument/PostDocument/PatchDocument/DeleteDocument models. Use for ticket attachments, document binary content, document metadata, or saving downloaded files." license: MIT -compatibility: "Requires Python 3.10+, glpi-python-client, network access to the GLPI v2 API, and v1 credentials configured on the client for binary uploads." +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.5.0" + version: "0.6.0" --- # GLPI Document Workflow diff --git a/skills/glpi-knowledge-base/SKILL.md b/skills/glpi-knowledge-base/SKILL.md index 146d747..10875e1 100644 --- a/skills/glpi-knowledge-base/SKILL.md +++ b/skills/glpi-knowledge-base/SKILL.md @@ -2,10 +2,10 @@ name: glpi-knowledge-base description: "Search, read, create, update, and delete GLPI knowledge base articles, categories, comments, and revisions with the synchronous glpi_python_client.GlpiClient or the asynchronous AsyncGlpiClient, and the GetKBArticle/PostKBArticle/GetKBCategory/GetKBArticleComment/GetKBArticleRevision models. Use for GLPI knowledge base content, FAQ articles, article categories, article comments, article revision history, or assigning categories to a KB article." license: MIT -compatibility: "Requires Python 3.10+, 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)." +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.5.0" + version: "0.6.0" --- # GLPI Knowledge Base diff --git a/skills/glpi-plugin-fields/SKILL.md b/skills/glpi-plugin-fields/SKILL.md index e979583..ce8a0ec 100644 --- a/skills/glpi-plugin-fields/SKILL.md +++ b/skills/glpi-plugin-fields/SKILL.md @@ -2,10 +2,10 @@ name: glpi-plugin-fields description: "Discover and read/write GLPI Fields-plugin custom fields with the synchronous glpi_python_client.GlpiClient or the asynchronous AsyncGlpiClient — list_plugin_fields_containers, list_plugin_fields_fields, list_item_plugin_field_rows, create_item_plugin_field_row, update_item_plugin_field_row, and the Ticket-only get_ticket_custom_fields/set_ticket_custom_fields. Use for GLPI custom fields, the Fields plugin, per-instance extra ticket attributes, or reading a ticket's custom-field values." license: MIT -compatibility: "Requires Python 3.10+, 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." +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.5.0" + version: "0.6.0" --- # GLPI Plugin Fields diff --git a/skills/glpi-reporting-and-context/SKILL.md b/skills/glpi-reporting-and-context/SKILL.md index 427d595..774fbb2 100644 --- a/skills/glpi-reporting-and-context/SKILL.md +++ b/skills/glpi-reporting-and-context/SKILL.md @@ -2,10 +2,10 @@ name: glpi-reporting-and-context description: "Aggregate GLPI ticket and task statistics and load grouped ticket contexts with the synchronous glpi_python_client.GlpiClient or the asynchronous AsyncGlpiClient. Use for operational reporting, ticket counts grouped by entity/status/priority/type, task duration totals grouped by user/entity/ticket, per-user activity reports, batch-streamed pagination of search results, or one-call ticket context retrieval bundling tickets with timeline records." license: MIT -compatibility: "Requires Python 3.10+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to read tickets, tasks, users, entities, and timeline records." +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.5.0" + version: "0.6.0" --- # GLPI Reporting And Context diff --git a/skills/glpi-team-members/SKILL.md b/skills/glpi-team-members/SKILL.md index 7f1a070..d6b811e 100644 --- a/skills/glpi-team-members/SKILL.md +++ b/skills/glpi-team-members/SKILL.md @@ -2,10 +2,10 @@ name: glpi-team-members description: "List, add, and remove GLPI ticket team members with the synchronous glpi_python_client.GlpiClient or the asynchronous AsyncGlpiClient, and the GetTeamMember/PostTeamMember models. Use when assigning users or groups to tickets, inspecting ticket teams, or removing GLPI ticket participants." license: MIT -compatibility: "Requires Python 3.10+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to manage ticket teams." +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.5.0" + version: "0.6.0" --- # GLPI Team Members diff --git a/skills/glpi-ticket-timeline/SKILL.md b/skills/glpi-ticket-timeline/SKILL.md index 7e70892..02be123 100644 --- a/skills/glpi-ticket-timeline/SKILL.md +++ b/skills/glpi-ticket-timeline/SKILL.md @@ -2,10 +2,10 @@ name: glpi-ticket-timeline description: "Read GLPI ticket timeline records and create or update followups, tasks, solutions, and timeline document links with the synchronous glpi_python_client.GlpiClient or the asynchronous AsyncGlpiClient. Use when handling ticket notes, followups, tasks, solutions, or attached documents on a GLPI ticket timeline." license: MIT -compatibility: "Requires Python 3.10+, glpi-python-client, and network access to the GLPI v2 API." +compatibility: "Requires Python 3.11+, glpi-python-client, and network access to the GLPI v2 API." metadata: package: glpi-python-client - version: "0.5.0" + version: "0.6.0" --- # GLPI Ticket Timeline @@ -120,6 +120,7 @@ await client.update_ticket_timeline_document( - `create_*` methods return new identifiers as plain `int`. `update_*` and `delete_*`/`unlink_*` return `None`. - Three enums carry this family's value vocabularies, all exported from `glpi_python_client` and all subclasses of `GlpiEnum` (itself an `IntEnum`, so a member serialises as its number and compares equal to one): `GlpiTaskState` on `PostTicketTask.state`/`PatchTicketTask.state` (`INFORMATION = 0`, `TODO = 1`, `DONE = 2` -- note `INFORMATION` is `0`, so `if task.state:` is false for it; test against `None`), `GlpiSolutionStatus` on `PostSolution.status`/`PatchSolution.status` (`NONE = 1`, `WAITING = 2`, `ACCEPTED = 3`, `REFUSED = 4`), and `GlpiTimelinePosition` on `timeline_position` (`INVALID = -1`, `NONE = 0`, `LEFT = 1`, `RIGHT = 2`, `LEFT_BIG = 3`, `RIGHT_BIG = 4`), which the followup, task and document models carry -- the solution models do not have the field at all. - Timeline `content` fields are Markdown on the Python side, not HTML. `PostFollowup`/`PostTicketTask`/`PostSolution` render Markdown to GLPI's HTML on serialisation. On `GetFollowup`/`GetTicketTask`/`GetSolution` the field is `content_html` -- the server's HTML verbatim -- and `content` is a cached property that converts it on **first read**, not on validation (`GetFollowup(content="

Hello world

").content == "Hello **world**"` still holds; the wire spelling `content` is accepted as a validation alias). So `record.content` is always Markdown, but `list_ticket_followups` converts nothing until you read a body, and a body that cannot be converted no longer breaks the whole list. Authoring raw HTML on a write model is not an error but is round-tripped through the Markdown converter and can be reshaped; write Markdown. -- HTML too deeply nested to walk is **stripped to text instead of converted**: `markdownify` recurses per level and dies around 494 from a shallow stack. The conversion is attempted rather than the depth predicted, so the real limit is whatever stack is left at the call site. Every character the normal rendering would have produced still appears, but structure does not: link targets, image alt text and code fencing are gone. Nothing raises. Any other conversion failure raises `GlpiContentError` -- from the `.content` read, not from the `list_*` call. Note that `GlpiTicketContext.to_markdown()` is usually the first thing to read every body, so it is where such an error surfaces. +- **`.content` is CommonMark that spells literal text so it stays text.** Rendering it (the package uses cmark-gfm, with GFM tables and a newline as a line break) displays what GLPI displayed: a user's `__init__` reads back as `\_\_init\_\_`, `\\serveur` as `\\\serveur`, `# titre` at a line start as `\# titre`. Ordinary prose -- `fichier_de_test_v2.xlsx`, `C:\Temp`, `R&D` -- comes back as typed. A line break reads back as `\` and a newline. Render the Markdown to display it; do not strip the backslashes. Markdown you write is kept verbatim unless it starts with an HTML tag; put a placeholder such as `` in backticks, since raw HTML passes through to GLPI. +- HTML too deeply nested to walk is **stripped to text instead of converted**: `markdownify` recurses per level and runs out of stack a few hundred levels deep. The conversion is attempted rather than the depth predicted, so the real limit is whatever stack is left at the call site. Every character the normal rendering would have produced still appears, but structure does not: link targets, image alt text and code fencing are gone. Nothing raises. Any other conversion failure raises `GlpiContentError` -- from the `.content` read, not from the `list_*` call. Note that `GlpiTicketContext.to_markdown()` is usually the first thing to read every body, so it is where such an error surfaces. - Extra server fields (e.g. plugin keys) flow into `record.extra_payload` rather than raising. - `delete_ticket_*` and `unlink_ticket_timeline_document` accept a keyword-only `force` parameter; pass `force=True` to permanently delete. \ No newline at end of file diff --git a/skills/glpi-ticket-workflow/SKILL.md b/skills/glpi-ticket-workflow/SKILL.md index bb986b5..b8ef0b8 100644 --- a/skills/glpi-ticket-workflow/SKILL.md +++ b/skills/glpi-ticket-workflow/SKILL.md @@ -2,10 +2,10 @@ name: glpi-ticket-workflow description: "Search, fetch, create, update, and delete GLPI tickets with the synchronous glpi_python_client.GlpiClient or the asynchronous AsyncGlpiClient, and the GetTicket/PostTicket/PatchTicket/DeleteTicket models. Use for GLPI ticket records, ticket filters, fields, pagination, status, priority, category, location, or instance-specific extra_payload values." license: MIT -compatibility: "Requires Python 3.10+, glpi-python-client, network access to the GLPI v2 API, and credentials accepted by GlpiClient." +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.5.0" + version: "0.6.0" --- # GLPI Ticket Workflow @@ -74,7 +74,8 @@ ticket = PostTicket( - **`search_tickets` and every other `search_*` raise `GlpiStatusError` on a 4xx.** This changed: they used to check the response status only when the caller passed a `failure_message`, which none of the seven `search_*` helpers does, so a GLPI error body was coerced to `[]` and a malformed RSQL filter, a 403, a missing route and a genuinely empty result set were indistinguishable. `_resource_list` now checks the status on every call, so **an empty list means the server said the result set is empty**. The iterators inherit that: a 4xx raises instead of making the first page short and ending the walk silently. Note the *other* fail-open path is unchanged and still bites -- GLPI v2 ignores a filter field it does not recognise and answers 200 with the whole unfiltered table, so a filter that returns rows is still not proof it was applied. - `create_ticket` returns the new ticket ID. `update_ticket` and `delete_ticket` return `None`. - **`GetTicket` has no `content` field; it has `content_html` and a `content` property.** `content_html` holds the server's HTML verbatim (and accepts the wire spelling `content` on construction); `content` is a cached property that converts it to Markdown on **first read**, not on validation. Reading `ticket.content` is unchanged and still gives Markdown, so read-side code needs no edit — but `search_tickets` now converts nothing until a body is read, and a body that cannot be converted no longer breaks the rest of the page. Two things do change if you were relying on them: `"content" not in GetTicket.model_fields`, and `GetTicket(...).model_dump()` emits `content_html` with HTML where it used to emit `content` with Markdown (`by_alias=True` gives you the GLPI key). `PostTicket`/`PatchTicket` are untouched: plain `content` field, converted eagerly. -- HTML too deeply nested to walk is **stripped to text rather than converted** — `markdownify` recurses about twice per nesting level and dies around 494 from a shallow stack. The converter attempts the conversion and answers the `RecursionError` rather than predicting the depth, so the real limit is whatever stack is left at the call site, and the same body can convert from one and degrade from a deeper one. It degrades rather than truncating — every character the normal rendering would have produced still appears — but structure does not survive: link targets, image alt text, code fencing and `
` indentation are gone. Anything else that goes wrong converting a body raises `GlpiContentError`, from the attribute read rather than from `get_ticket`. Treat a fetched ticket as immutable afterwards: the conversion is cached, so assigning to `content_html` (or `model_copy(update={"content_html": ...})`) leaves stale Markdown on `.content` with nothing in `repr`, `==` or `model_dump` to show it.
+- **`ticket.content` is CommonMark that spells literal text so it stays text.** Rendering it (the package uses cmark-gfm, with GFM tables and a newline as a line break) displays what GLPI displayed: a user's `__init__` reads back as `\_\_init\_\_`, `# titre` at a line start as `\# titre`, while `fichier_de_test_v2.xlsx` or `R&D` come back as typed. A plain-text body is read as the text it is. Render the Markdown to display it; do not strip the backslashes. `PostTicket`/`PatchTicket` keep your Markdown verbatim unless it starts with an HTML tag; put a placeholder such as `` in backticks, since raw HTML passes through to GLPI.
+- HTML too deeply nested to walk is **stripped to text rather than converted** — `markdownify` recurses about twice per nesting level and runs out of stack a few hundred levels deep. The converter attempts the conversion and answers the `RecursionError` rather than predicting the depth, so the real limit is whatever stack is left at the call site, and the same body can convert from one and degrade from a deeper one. It degrades rather than truncating — every character the normal rendering would have produced still appears — but structure does not survive: link targets, image alt text, code fencing and `
` indentation are gone. Anything else that goes wrong converting a body raises `GlpiContentError`, from the attribute read rather than from `get_ticket`. Treat a fetched ticket as immutable afterwards: the conversion is cached, so assigning to `content_html` (or `model_copy(update={"content_html": ...})`) leaves stale Markdown on `.content` with nothing in `repr`, `==` or `model_dump` to show it.
 - The GLPI server is the authoritative validator. Extra keys returned by the server flow into `ticket.extra_payload` rather than raising. Caller-provided `extra_payload` keys win on conflicts.
 - **`status` is writable, on `PatchTicket` only.** `client.update_ticket(tid, PatchTicket(status=GlpiTicketStatus.PENDING))` moves the ticket. This corrects an earlier claim in this skill that the field was read-only: GLPI's own contract publishes `Ticket.status.id` as `readOnly: true`, and that is simply wrong — a live GLPI 11 instance honours the `PATCH`. The same contract omits the `Major` level from `priority`, so treat it as a hint, not an authority. Two things it does *not* cover: `POST` **ignores** `status` (201, then the ticket reads back as `New`), which is why the field is declared on `PatchTicket` and not on `PostTicket`; and `status_id` is silently dropped, so use `status`. Posting a solution still moves the ticket to `SOLVED` on its own and remains the better call when there is a resolution to record — a bare status change leaves no trace of why.
 - **Key on `status.id`, never on `status.name`.** The id set is hardcoded in GLPI's core — there is no `TicketStatus` itemtype (the v1 API answers 400 for it, while `ITILCategory` and `RequestType` return instance rows), and `listSearchOptions` reports `status` as `{"table": "glpi_tickets", "datatype": "specific"}`, i.e. a column of the tickets table rather than a foreign key into a configurable one. An administrator cannot add or rename a status. The **labels** are another matter: they are translations, so the same id reads back as `"Nouveau"` on a French instance and `"New"` on an English one. The ids `7`, `8`, `9`, `11`–`14` exist but belong to the sibling ITIL types, and they are literal integers in GLPI's source: `CommonITILObject` declares `INCOMING = 1` through `OBSERVED = 8` plus `APPROVAL = 10`, and `Change` adds `EVALUATION = 9`, `TEST = 11`, `QUALIFICATION = 12`, `REFUSED = 13`, `CANCELED = 14`. Each type's `getAllStatusArray()` is a hardcoded PHP array mapping those constants to *translated* labels — no database, no config table. The set is therefore coupled to the GLPI *version*, not to the instance.
diff --git a/skills/glpi-user-location-provisioning/SKILL.md b/skills/glpi-user-location-provisioning/SKILL.md
index caab841..b99557c 100644
--- a/skills/glpi-user-location-provisioning/SKILL.md
+++ b/skills/glpi-user-location-provisioning/SKILL.md
@@ -2,10 +2,10 @@
 name: glpi-user-location-provisioning
 description: "Search GLPI users, locations, and entities, or create, update, and delete users and locations and entities with the synchronous glpi_python_client.GlpiClient or the asynchronous AsyncGlpiClient, and the matching Get/Post/Patch/Delete models. Use for user lookup, entity lookup, location lookup, user provisioning, location creation, GLPI entity defaults, or RSQL filters."
 license: MIT
-compatibility: "Requires Python 3.10+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to read or write users, locations, and entities."
+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.5.0"
+  version: "0.6.0"
 ---
 
 # GLPI User, Location, And Entity Provisioning