Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,8 @@ docs/glpi_api_contract.json
# Coverage data: rewritten by every test run (and by the venv .pth hook).
.coverage
.coverage.*

# One-shot live-instance probe scripts. They are standalone investigations
# (a main() run by hand against preprod), not collected tests -- the findings
# get written up in CHANGELOG.md, the scripts stay local.
integration_tests/probe_*.py
355 changes: 355 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ The goal is to let GLPI integrations work with domain objects instead of raw
JSON payloads. The package exposes Pydantic models for tickets, users,
followups, documents, locations, and related records, while converting GLPI
HTML content into Markdown for Python-side workflows and rendering Markdown
back to HTML for outgoing payloads.
back to HTML for outgoing payloads. On a response model that conversion is
lazy — `.content` converts on first read and caches, so listing records
costs nothing per body — and it degrades to plain text rather than failing
on pathologically nested HTML. See
[Rich-text content](https://glpi-python-client.readthedocs.io/en/latest/user_guide.html#content-conversion).

It currently focuses on ticket-centric workflows and exposes two high-level
clients built on top of the GLPI v2 REST API:
Expand Down
61 changes: 56 additions & 5 deletions docs/api_reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,13 @@ from it, so neither wraps the other and the two cannot drift apart.
Exceptions
----------

Exceptions raised for a bad argument, an unexpected HTTP status, or an
unusable response body derive from :class:`GlpiError`.
:class:`GlpiStatusError`, :class:`GlpiValidationError` and
:class:`GlpiProtocolError` also inherit :class:`ValueError` for backwards
compatibility with releases that raised bare ``ValueError``.
Exceptions raised for a bad argument, an unexpected HTTP status, an
unusable response body, or content that cannot be converted derive from
:class:`GlpiError`. :class:`GlpiStatusError`, :class:`GlpiValidationError`
and :class:`GlpiProtocolError` also inherit :class:`ValueError` for
backwards compatibility with releases that raised bare ``ValueError``;
:class:`GlpiContentError` and :class:`GlpiTransportError` do not, because
nothing was passed in wrongly in either case.

Network-level faults (connection failures, DNS errors, timeouts) are
raised as :class:`GlpiTransportError`, or its :class:`GlpiTimeoutError`
Expand Down Expand Up @@ -80,6 +82,55 @@ guide for the full picture, including which methods raise which type.
:members:
:show-inheritance:

.. autoexception:: GlpiContentError
:members:
:show-inheritance:

Rich-text content
-----------------

GLPI exchanges ticket, followup, task, solution and knowledge-base bodies
as HTML. The package's surface is Markdown in both directions, but the two
directions work differently, and the difference is visible.

A **write** model (``Post*``, ``Patch*``) takes Markdown in ``content`` and
renders it to HTML when the request is built. Nothing to think about.

A **read** model (``Get*``) keeps two views of the same body:

``content_html``
what GLPI sent, verbatim. Also accepts the wire spelling ``content`` on
construction.

``content``
the same body as Markdown, converted on the first read and cached.
:class:`GetKBArticle` has ``description`` / ``description_html`` as
well.

Reading ``.content`` is what a caller wants and what earlier releases
returned, so no read-side code needs changing. What changed is *when* the
conversion happens, which buys two things: listing records costs nothing
per body, and a body that cannot be converted no longer stops the rest of
its page being read.

Very deeply nested HTML is the case worth knowing about.
``markdownify`` walks the document recursively and runs out of stack at
around 494 levels of nesting. The converter does not try to predict
that: it attempts the conversion and, if the walk does not fit, strips
tags instead. It degrades, it never truncates, and it does not raise:
every character the normal rendering would have produced still appears.
What is lost is structure rather than words — link targets and image alt
text, code fencing and ``<pre>`` indentation, ``&nbsp;`` alignment.
Because the budget is the stack left when the conversion starts, the
same body can convert from one call site and degrade from a deeper one.
Anything else that goes wrong in either direction raises
:class:`GlpiContentError`.

Because the conversion is cached on first read, a read model should be
treated as immutable afterwards: assigning to ``content_html``, or
``model_copy(update={"content_html": ...})``, leaves the cached Markdown
in place. Rebuild through ``model_validate`` if you need to change it.

Aggregated Models
-----------------

Expand Down
13 changes: 12 additions & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,18 @@ python -m pytest
- `glpi_python_client.models` contains typed request and response
models.
- `glpi_python_client.content` handles HTML/Markdown conversion for
ticket descriptions, followups, tasks, and solutions.
ticket descriptions, followups, tasks, solutions, knowledge-base
articles (`content` and `description`) and article revisions. It is
wired into the models by `models/api_schema/_content.py`, eagerly on
the write models and through a cached property on the read ones.
Inbound HTML too deep for `markdownify` to walk is tag-stripped
rather than parsed. The depth is not predicted: the conversion is
attempted and the `RecursionError` answered, because the budget is the
caller's remaining stack and no bound computed in advance can know it.
The module docstring carries the derivation and the rejected
alternatives (`sys.setrecursionlimit`, and a thread with a larger
stack, which needs the same global). The prohibition is enforced by
`testing/tests/test_raise_site_audit.py`, not just written down.
- `glpi_python_client.testing` exposes `make_client` and
`make_async_client` factories that produce in-memory clients with no
real HTTP plumbing for downstream test suites, plus the shared
Expand Down
89 changes: 81 additions & 8 deletions docs/user_guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -586,7 +586,11 @@ Knowledge base
The knowledge base mixins map to ``/Knowledgebase``. Articles and
categories expose the ``search_ / get_ / create_ / update_ / delete_``
shape; comments are nested under an article; revisions are read-only.
Article ``content`` and ``description`` accept and return Markdown. An
Article ``content`` and ``description`` accept and return Markdown; on
:class:`~glpi_python_client.GetKBArticle` they are properties over
``content_html`` and ``description_html``, converted on first read -- see
:ref:`content-conversion`, which matters here because searching the
knowledge base returns whole article bodies. An
article's ``categories`` association is read-only in the v2 GLPI contract,
so the client sets it through a legacy fallback — see
`Assigning categories`_.
Expand Down Expand Up @@ -1429,9 +1433,9 @@ Example output::
-----------------

Exceptions the client raises for a bad argument, an unexpected HTTP
status, or an unusable response body derive from
:class:`~glpi_python_client.GlpiError`, so one handler covers that part
of the library surface:
status, an unusable response body, or content it cannot convert derive
from :class:`~glpi_python_client.GlpiError`, so one handler covers that
part of the library surface:

.. code-block:: python

Expand Down Expand Up @@ -1474,15 +1478,17 @@ The hierarchy lets you narrow as far as you need:
.. code-block:: text

GlpiError
├── GlpiTransportError reserved for the httpx transport swap;
│ └── GlpiTimeoutError not raised yet -- see the note above
├── GlpiTransportError the request never produced a response
│ └── GlpiTimeoutError GLPI was too slow
├── GlpiStatusError GLPI answered with an unexpected status
│ ├── GlpiAuthError 401 / 403
│ ├── GlpiNotFoundError 404
│ └── GlpiServerError 5xx (retried up to 3 attempts before it
│ reaches you)
├── GlpiValidationError the client rejected your argument
└── GlpiProtocolError GLPI answered 2xx with an unusable body
├── GlpiProtocolError GLPI answered 2xx with an unusable body
└── GlpiContentError a rich-text body could not be converted
between HTML and Markdown

:class:`~glpi_python_client.GlpiStatusError` carries the diagnostics you
usually want:
Expand All @@ -1505,6 +1511,73 @@ usually want:
:class:`~glpi_python_client.GlpiProtocolError` also inherit
:class:`ValueError`. Code written against earlier releases, which
raised bare ``ValueError``, keeps working unchanged.
:class:`~glpi_python_client.GlpiContentError` and
:class:`~glpi_python_client.GlpiTransportError` do not inherit it:
there was never a bare ``ValueError`` at either kind of site, and
neither is a value the caller got wrong.

.. _content-conversion:

Rich-text content: Markdown in, Markdown out
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Ticket, followup, task, solution and knowledge-base bodies travel to GLPI
as HTML. You work in Markdown in both directions and the package handles
the translation, but the two directions are not symmetric and the
difference shows up in the field names.

Writing is the simple half: give ``content`` Markdown and it is rendered
to HTML when the request is built.

Reading gives you two views of the same body:

.. code-block:: python

ticket = client.get_ticket(42)

ticket.content_html # '<p>The printer is <strong>offline</strong>.</p>'
ticket.content # 'The printer is **offline**.'

``.content`` is what you want and what earlier releases gave you, so
read-side code needs no change. What changed is *when* the conversion
runs: on the first read of ``.content``, cached afterwards, rather than
while the model is being built. Two things follow.

Listing records is cheap. ``client.search_tickets()`` used to convert
every body on the page whether or not you looked at one; now a search
that only reads ``id`` and ``date_mod`` converts nothing at all.

A body that cannot be converted no longer takes its page down with it.
The whole page is built in one pass, so a single unconvertible record used
to make its page-mates unreadable too. The failure is now scoped to the
record whose body you actually read.

.. note::

Deeply nested HTML is the case worth knowing about. The HTML-to-Markdown
converter walks the document recursively and exhausts the interpreter's
stack at around 494 levels of nesting. ``.content`` does not try to
predict that -- it attempts the conversion and, when the walk does not
fit, strips the tags instead. **It degrades, it never truncates, and it
does not raise**: every character the normal rendering would have
produced still appears, so a body never says less because of how deeply
it happened to nest. What you lose is structure,
not words — link targets and image alt text, code-block fencing and
``<pre>`` indentation, and ``&nbsp;``-padded alignment. Anything else
that goes wrong raises
:class:`~glpi_python_client.GlpiContentError`.

Because the result is cached on first read, treat a read model as
immutable afterwards. Assigning to ``content_html`` -- or
``model_copy(update={"content_html": ...})`` -- leaves the cached
Markdown in place, and nothing in ``repr``, ``==`` or ``model_dump``
will tell you. Rebuild through ``model_validate`` instead.

Two smaller consequences, if you are upgrading from 0.4.x: ``content`` is
no longer in ``GetTicket.model_fields``, and ``GetTicket(...).model_dump()``
emits ``content_html`` holding HTML where it used to emit ``content``
holding Markdown. Pass ``by_alias=True`` for a dump keyed the way GLPI
keys it.

Retry behaviour
~~~~~~~~~~~~~~~
Expand Down Expand Up @@ -1533,4 +1606,4 @@ most 2 POST requests.

Search methods are deliberately tolerant: ``search_tickets`` and its
siblings return an empty list rather than raising when GLPI rejects the
query. Methods that fetch or mutate one specific record always raise.
query. Methods that fetch or mutate one specific record always raise.
4 changes: 3 additions & 1 deletion glpi_python_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from glpi_python_client._async.clients import AsyncGlpiClient
from glpi_python_client._errors import (
GlpiAuthError,
GlpiContentError,
GlpiError,
GlpiNotFoundError,
GlpiProtocolError,
Expand Down Expand Up @@ -109,7 +110,7 @@
date_window,
)

__version__ = "0.4.3"
__version__ = "0.5.0"

__all__ = [
"AsyncGlpiClient",
Expand Down Expand Up @@ -145,6 +146,7 @@
"GetUser",
"GlpiAuthError",
"GlpiClient",
"GlpiContentError",
"GlpiEnum",
"GlpiError",
"GlpiGlobalValidation",
Expand Down
14 changes: 8 additions & 6 deletions glpi_python_client/_async/clients/commons/_payloads.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
SERVER_TIMEZONE_CONTEXT_KEY,
GlpiModel,
)
from glpi_python_client.models.api_schema._content import restoring_content_faults

ModelT = TypeVar("ModelT", bound=GlpiModel)

Expand Down Expand Up @@ -54,12 +55,13 @@ def model_to_payload(
if server_timezone is not None
else None
)
body = model.model_dump(
mode="json",
exclude_none=True,
exclude={"extra_payload"},
context=context,
)
with restoring_content_faults():
body = model.model_dump(
mode="json",
exclude_none=True,
exclude={"extra_payload"},
context=context,
)
if model.extra_payload:
body.update(model.extra_payload)
return body
Expand Down
40 changes: 36 additions & 4 deletions glpi_python_client/_errors.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"""Public exception hierarchy raised by :mod:`glpi_python_client`.

Every exception the client raises for a bad argument, an unexpected HTTP
status, an unusable response body, or a network-level fault deliberately
derives from :class:`GlpiError`, so callers can catch the library's failure
surface with a single ``except`` clause and never need to import the
underlying HTTP library.
status, an unusable response body, a network-level fault, or content it
cannot convert deliberately derives from :class:`GlpiError`, so callers can
catch the library's failure surface with a single ``except`` clause and
never need to import the underlying HTTP library.

Two deliberate exceptions to that rule remain:

Expand All @@ -17,6 +17,8 @@
:class:`GlpiStatusError`, :class:`GlpiValidationError` and
:class:`GlpiProtocolError` also inherit :class:`ValueError` so code written
against earlier releases — which raised bare ``ValueError`` — keeps working.
:class:`GlpiContentError` and :class:`GlpiTransportError` do not, for the
reason given on each: nothing was passed in wrongly.
"""

from __future__ import annotations
Expand Down Expand Up @@ -137,6 +139,35 @@ class GlpiProtocolError(GlpiError, ValueError):
"""


class GlpiContentError(GlpiError):
"""A rich-text content value could not be converted.

Raised when :class:`~glpi_python_client.content.GlpiContentConverter`
cannot translate a value between GLPI's HTML transport format and the
package's canonical Markdown — in either direction. The underlying
fault is always attached as ``__cause__``.

This exists so that no failure of the content layer escapes the
package's taxonomy. The conversion runs third-party parsers
(``markdownify`` inbound, ``markdown`` outbound), and a parser fault
used to reach the caller as a bare builtin — most visibly a
``RecursionError``, which ``except GlpiError`` does not catch and which
a caller reading a ticket has no reason to expect from
``get_ticket``. Deeply nested HTML is caught and answered with the
body's text instead of raising at all (see
:meth:`glpi_python_client.content.conversion.GlpiContentConverter.from_transport`);
this is the backstop for everything else.

Unlike :class:`GlpiStatusError`, :class:`GlpiValidationError` and
:class:`GlpiProtocolError` this does **not** inherit ``ValueError``.
Those three do so for back-compatibility with releases that raised
bare ``ValueError`` at the same sites; there was never a
``ValueError`` here to be compatible with, and a parser exhausting the
interpreter's stack is not a value the caller got wrong. The reasoning
matches :class:`GlpiTransportError`.
"""


def status_error_class(status_code: int) -> type[GlpiStatusError]:
"""Return the most specific status-error class for one status code.

Expand Down Expand Up @@ -164,6 +195,7 @@ def status_error_class(status_code: int) -> type[GlpiStatusError]:

__all__ = [
"GlpiAuthError",
"GlpiContentError",
"GlpiError",
"GlpiNotFoundError",
"GlpiProtocolError",
Expand Down
14 changes: 8 additions & 6 deletions glpi_python_client/_sync/clients/commons/_payloads.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
SERVER_TIMEZONE_CONTEXT_KEY,
GlpiModel,
)
from glpi_python_client.models.api_schema._content import restoring_content_faults

ModelT = TypeVar("ModelT", bound=GlpiModel)

Expand Down Expand Up @@ -54,12 +55,13 @@ def model_to_payload(
if server_timezone is not None
else None
)
body = model.model_dump(
mode="json",
exclude_none=True,
exclude={"extra_payload"},
context=context,
)
with restoring_content_faults():
body = model.model_dump(
mode="json",
exclude_none=True,
exclude={"extra_payload"},
context=context,
)
if model.extra_payload:
body.update(model.extra_payload)
return body
Expand Down
Loading
Loading