Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7902844
fix: serialise request bodies in JSON mode so datetime writes work
baraline Aug 12, 2026
27e9c63
fix: order timeline events across mixed datetime awareness
baraline Aug 12, 2026
545421e
fix: page every corpus walk in the statistics helpers
baraline Aug 12, 2026
78734c7
fix: recover create ids from strings, data envelopes and Location
baraline Aug 12, 2026
1a648cb
fix: decide HTML by element name, not by stray angle brackets
baraline Aug 12, 2026
57fdcdb
fix: keep fenced code, tables and prose punctuation intact
baraline Aug 12, 2026
053cfcd
test: inventory the content round trip as strict xfails
baraline Aug 12, 2026
505818a
docs: record that tolerant searches swallow 4xx and end iteration
baraline Aug 12, 2026
0d2ca69
feat: add batch iterators for KB articles, categories, documents, loc…
baraline Aug 12, 2026
96d5271
feat: stream document downloads instead of buffering the whole body
baraline Aug 12, 2026
b4f7afb
feat: add public RSQL date builders and dedupe the statistics windows
baraline Aug 12, 2026
13c970f
feat: add find_user_by_email as a client-side scan
baraline Aug 12, 2026
03cf53d
fix!: raise on a 4xx from a search instead of returning []
baraline Aug 12, 2026
d13df47
test: add a live probe for the two undecided wire-format questions
baraline Aug 12, 2026
b64cabd
test: diagnose an unreachable GLPI host before the probe connects
baraline Aug 12, 2026
0438c0b
fix: document the sort syntax GLPI actually accepts
baraline Aug 12, 2026
ce4eed7
feat!: require server_timezone and localise the timestamps GLPI sends…
baraline Aug 12, 2026
ed5b4bc
docs: bring the nine skills and the guides in line with this branch
baraline Aug 13, 2026
392383e
fix: write datetimes on the server's clock, not with an offset
baraline Aug 13, 2026
f50024b
test: compile every Python snippet in the skills, guide and README
baraline Aug 13, 2026
997b0c3
[mnt] Fix changelog, update version numbers
baraline Aug 13, 2026
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
150 changes: 149 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,149 @@ 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.4.3 — 2026-08-13

### Changed (breaking)

- **`server_timezone` is now a required client argument** (`GLPI_SERVER_TIMEZONE`
for `from_env`). It takes an IANA zone name — `"Europe/Paris"` — or a
`tzinfo`.

GLPI 11 sends most timestamps with the correct historical offset, but not
all of them. Measured against a live instance: 19 of the 20 datetime fields
across every resource are offset-bearing, and `KBArticle.revisions[].date`
is not. One response therefore carries both kinds, and comparing them raises
`TypeError: can't compare offset-naive and offset-aware datetimes` — sorting
an article's revision history against the article's own dates was enough to
trigger it.

There is deliberately **no default**. Every candidate is wrong somewhere:
against a Europe/Paris instance, assuming UTC shifts the affected timestamps
by one or two hours *and stops raising*, turning a loud failure into a quiet
wrong answer. An IANA name is preferred over a fixed offset because a name
follows DST — the same instance emits both `+01:00` and `+02:00`.

An offset already on the wire always wins over the configured zone, and a
model built outside the client (no validation context) keeps its naive values
rather than being stamped with a guess.

Adds `tzdata` as a dependency on Windows, which ships no system timezone
database; without it `zoneinfo` resolves on Linux CI and raises on a
developer machine.

- **Search endpoints now raise on a 4xx instead of returning `[]`.** The seven
`search_*` helpers passed no `failure_message` to `_resource_list`, which
skipped the status check entirely, so a 400, 401, 403 or 404 came back as an
empty list — indistinguishable from a filter that legitimately matched
nothing. (5xx already raised.) It composed badly with the batch iterators:
they stop on a page shorter than `batch_size`, so a 403 on the first page
ended the walk having yielded nothing and the caller saw a *successful*
empty result. This reverses decision D2 of the 0.4.0 error work, which chose
tolerance deliberately; the silent-empty failure mode has proved worse than
the exception. An empty list now means the server said the result set is
empty. **Callers that relied on `[]` after a permission error must catch
`GlpiStatusError`.**

### Added

- **`glpi_python_client.rsql`** — public date builders for the v2 filter
grammar: `created_between`, `date_window` and `changed_since`, all exported
from the package root. The end-of-day detail on a window's upper bound is
easy to get wrong and impossible to notice, since GLPI answers a malformed
filter by ignoring it and returning the whole table.

- **`find_user_by_email(email)`** — resolves a person by address. It scans,
because GLPI exposes addresses as the nested array `User.emails` and the v2
filter engine cannot join a nested array. Narrow it with `rsql_filter` and
cache the id; do not hand-roll an RSQL e-mail filter.

- **`stream_document_content(document_id, chunk_size=...)`** — yields a
document body in chunks instead of buffering it whole, as
`download_document_content` does. Upload still buffers.

- **Batch iterators for the four resources that lacked one**:
`iter_search_kb_articles`, `iter_search_kb_categories`,
`iter_search_documents` and `iter_search_locations`.

### Fixed

- **Every `datetime` write raised `TypeError`.** `model_to_payload` dumped in
Pydantic's python mode, so a request body reached `json.dumps` still holding
a live `datetime`. The failure landed at the encoder — after the model had
validated and outside any transport stub — which is why the suite never saw
it. The dump now runs in JSON mode.

- **GLPI discards the offset on every datetime it is sent, so aware values
were written as the wrong moment.** Measured against a live Europe/Paris
instance: `2026-08-01T12:30:00` written bare, as `...Z`, and with `+02:00`,
`+09:00`, `-08:00` and `+14:00` all store 12:30 Paris. The server reads the
naive prefix, interprets it in its own timezone, and throws the rest away —
with a 200. It does parse the offset first, since `+99:99` answers HTTP 500,
which is the worst combination: a malformed offset crashes, a well-formed
wrong one is silent. `12:30-08:00` is 21:30 in Paris and landed nine hours
early.

An aware `datetime` is now converted onto the server's clock and the offset
dropped, via a serialisation context mirroring the validation context used
on the inbound half. Naive values are untouched — they already mean the
server's clock — and no context means no conversion, so a model dumped
outside the client is unchanged. This is the second half of the
`server_timezone` contract above; `mode="json"` alone would have shipped
writes wrong by up to twelve hours.

- **`get_ticket_statistics` silently truncated at 200 tickets**, on an
instance whose own docstring records 59,690. It issued one `search_tickets`
call with no paging loop, so every statistic was computed over whichever
200 tickets came back first and reported as if it covered the corpus. The
two entity and one user name-resolution sites had the same shape. All four
now page through `iter_search_*`.

- **`from_transport` silently deleted text.** The HTML path was taken whenever
the content held both `<` and `>`, so `"use the <Enter> key"` became
`"use the key"` — an unknown tag's markup is dropped and its empty body
kept, removing the word with nothing left to show it was ever there.
`"cmd </dev/null > out"` and `"if x<y then z>0"` lost text the same way. The
decision is now made on the element *name*.

- **Fenced code blocks, tables and prose punctuation were mangled.** Without
the `fenced_code` extension a fence rendered as inline `<code>`, which the
GLPI web UI shows as one run-on line and which a later read wrote back as
inline code — so a pasted log degraded further on every edit. Without
`tables`, a table rendered as literal pipes. Inbound, `markdownify` escaped
underscores and asterisks, so `snake_case` came back as `snake\_case` and
accumulated a backslash on every read-modify-write cycle.

- **`_MAX_DATETIME` was naive, so `to_markdown()` raised on a mixed-awareness
timeline.** Sorting events padded absent timestamps with `datetime.max`,
which cannot be compared against the offset-bearing values GLPI sends. The
sort key now normalises both sides to UTC. The live probe confirmed the
mixed population is real, not hypothetical.

- **`require_response_int` rejected create responses GLPI actually returns.**
A numeric-string id, an id nested under a `data` envelope, and a create that
reports only a `Location` header all raised a protocol error over a
perfectly usable identifier. It now probes top-level keys, then the
envelope, then the header.

- **`sort="date_mod desc"` — the library's own documented example — is HTTP
400.** Found while running the wire-format probe. The accepted syntax is
`field:direction`; a bare `date_mod` is accepted but sorts *ascending*, and
`order=` is ignored entirely.

- **Three client construction examples had `server_timezone` inserted twice
and misindented**, by the sweep that added it. A repeated keyword argument
is a `SyntaxError`, so those examples could not be copied at all. Two older
documentation defects surfaced alongside them: three lines of expected
output stranded inside a `code-block:: python` (they belong to the example
above), and an import indented four spaces inside a three-space block. A new
test compiles all 77 Python snippets in the skills, the guide and the
README.

## 0.4.0 – 0.4.2

These three releases were tagged without the changelog ever being
sectioned, so their notes accumulated under a single `Unreleased`
heading. They are grouped here rather than split retroactively.

### Fixed

Expand Down Expand Up @@ -314,6 +456,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- The `requests` intersphinx mapping is removed; it survived the transport
swap and made every docs build fetch an inventory nothing referenced.

## Pre-0.4.0 notes

Kept for history. Written before the httpx and unasync rewrites, so the
status they describe is superseded by everything above — the transport is
no longer `requests`, and tolerant searches no longer swallow a 4xx.

### Unchanged (deliberately)

- Retry semantics: 5xx retried 3 times with a 3-second fixed wait, 4xx never
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ from glpi_python_client import GlpiClient, PostTicket

with GlpiClient(
glpi_api_url="https://glpi.example.com/api.php/v2",
server_timezone="Europe/Paris",
client_id="oauth-client-id",
client_secret="oauth-client-secret",
username="api-user",
Expand All @@ -83,6 +84,7 @@ from glpi_python_client import AsyncGlpiClient, PostTicket
async def main() -> None:
async with AsyncGlpiClient(
glpi_api_url="https://glpi.example.com/api.php/v2",
server_timezone="Europe/Paris",
client_id="oauth-client-id",
client_secret="oauth-client-secret",
username="api-user",
Expand Down
16 changes: 12 additions & 4 deletions docs/user_guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ pair. The OAuth password grant accepts either ``client_id`` /

with GlpiClient(
glpi_api_url="https://glpi.example.com/api.php/v2",
server_timezone="Europe/Paris",
client_id="oauth-client-id",
client_secret="oauth-client-secret",
username="api-user",
Expand All @@ -92,6 +93,7 @@ The asynchronous client takes the same arguments and is used inside an
async def main() -> None:
async with AsyncGlpiClient(
glpi_api_url="https://glpi.example.com/api.php/v2",
server_timezone="Europe/Paris",
client_id="oauth-client-id",
client_secret="oauth-client-secret",
username="api-user",
Expand Down Expand Up @@ -134,6 +136,11 @@ build the client for you:
* ``GLPI_CLIENT_ID`` and ``GLPI_CLIENT_SECRET``
* ``GLPI_USERNAME`` and ``GLPI_PASSWORD``
* ``GLPI_ENTITY``, ``GLPI_PROFILE``, ``GLPI_ENTITY_RECURSIVE``
* ``GLPI_SERVER_TIMEZONE`` -- **required**. IANA name of the timezone the
GLPI server runs in (e.g. ``Europe/Paris``). GLPI does not advertise it,
and it is needed to interpret the timestamps the server sends without an
offset. There is no default: guessing UTC against a Europe/Paris instance
shifts those values by an hour or two and never raises.
* ``GLPI_LANGUAGE``, ``GLPI_VERIFY_SSL``
* ``GLPI_V1_BASE_URL``, ``GLPI_V1_USER_TOKEN``, ``GLPI_V1_APP_TOKEN``

Expand Down Expand Up @@ -628,6 +635,9 @@ so the client sets it through a legacy fallback — see
Example output::

['Networking']
42 Reset a Wi-Fi controller
42 Reset a Wi-Fi controller
1 revision(s)

Assigning categories
^^^^^^^^^^^^^^^^^^^^^
Expand Down Expand Up @@ -659,9 +669,6 @@ article's full category set; passing an empty list clears every category.
# Or set them explicitly at any time.
client.set_kb_article_categories(article_id, [14]) # replace the full set
client.set_kb_article_categories(article_id, []) # clear all
42 Reset a Wi-Fi controller
42 Reset a Wi-Fi controller
1 revision(s)

Enums
~~~~~
Expand Down Expand Up @@ -710,10 +717,11 @@ internal container and field names:

.. code-block:: python

from glpi_python_client import GlpiClient
from glpi_python_client import GlpiClient

with GlpiClient(
glpi_api_url="https://glpi.example.com/api.php/v2",
server_timezone="Europe/Paris",
client_id="oauth-client-id",
client_secret="oauth-client-secret",
username="api-user",
Expand Down
10 changes: 9 additions & 1 deletion glpi_python_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,13 @@
PostUser,
TicketMarkdownOptions,
)
from glpi_python_client.rsql import (
changed_since,
created_between,
date_window,
)

__version__ = "0.4.2"
__version__ = "0.4.3"

__all__ = [
"AsyncGlpiClient",
Expand Down Expand Up @@ -190,4 +195,7 @@
"PostUser",
"TicketMarkdownOptions",
"__version__",
"changed_since",
"created_between",
"date_window",
]
26 changes: 25 additions & 1 deletion glpi_python_client/_async/clients/_base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@
from glpi_python_client._async.clients.commons._config import (
build_client_env_config,
build_client_resources,
resolve_server_timezone,
)

if TYPE_CHECKING:
from collections.abc import Mapping
from datetime import tzinfo

logger = logging.getLogger(__name__)

Expand All @@ -45,6 +47,7 @@ def __init__(
self,
*,
glpi_api_url: str,
server_timezone: str | tzinfo,
client_id: str | None = None,
client_secret: str | None = None,
username: str | None = None,
Expand All @@ -66,6 +69,25 @@ def __init__(
glpi_api_url : str
Base URL of the GLPI v2 REST API, e.g.
``https://glpi.example.com/api.php/v2``.
server_timezone : str | tzinfo
IANA name of the timezone the GLPI server runs in (e.g.
``"Europe/Paris"``), or a ``tzinfo``. **Required**: GLPI does
not advertise it, and it governs both directions of every
timestamp the client exchanges.

Reading, it interprets the timestamps the server sends without
an offset. Writing, it is what makes an aware ``datetime``
arrive as the moment it names: GLPI reads the naive prefix of a
timestamp and discards the offset, so the value has to be
converted onto the server's clock before it is sent. Measured on
a live instance, offsets from ``-08:00`` to ``+14:00`` written
to one field all stored the same wall clock.

There is no default because every candidate is wrong somewhere
-- guessing UTC against a Europe/Paris instance shifts those
timestamps by an hour or two and never raises. Prefer a name
over a fixed offset: a name follows DST, and one instance emits
both ``+01:00`` and ``+02:00``.
client_id : str | None, optional
OAuth client identifier used to obtain access tokens.
client_secret : str | None, optional
Expand Down Expand Up @@ -103,6 +125,7 @@ def __init__(
missing OAuth credentials together with no v1 fallback).
"""

self.server_timezone = resolve_server_timezone(server_timezone)
resources = build_client_resources(
glpi_api_url=glpi_api_url,
client_name=type(self).__name__,
Expand Down Expand Up @@ -142,7 +165,8 @@ def from_env(
``GLPI_USERNAME``, ``GLPI_PASSWORD``, ``GLPI_VERIFY_SSL``,
``GLPI_V1_BASE_URL``, ``GLPI_V1_USER_TOKEN``, ``GLPI_V1_APP_TOKEN``,
``GLPI_ENTITY``, ``GLPI_PROFILE``, ``GLPI_ENTITY_RECURSIVE``,
``GLPI_LANGUAGE``, ``GLPI_AUTH_TOKEN_REFRESH``).
``GLPI_LANGUAGE``, ``GLPI_AUTH_TOKEN_REFRESH``,
``GLPI_SERVER_TIMEZONE``).

Parameters
----------
Expand Down
Loading
Loading