From 79028440f8c2ddb51876dea737f8c6ac7de8125b Mon Sep 17 00:00:00 2001 From: baraline Date: Wed, 12 Aug 2026 12:46:02 +0200 Subject: [PATCH 01/21] fix: serialise request bodies in JSON mode so datetime writes work model_to_payload dumped in pydantic's python mode, leaving datetime fields as live objects in the body mapping. _execute_request hands that mapping to httpx as json=, whose encoder is json.dumps, so every write of a date field raised "Object of type datetime is not JSON serializable" before the request left the process. The suite could not see it: TransportRecorder stubs at client._session.request, above the JSON encoder, so no unit test ever encoded a payload. The new tests assert encodability rather than shape, which is the property that actually holds the bug down. Affects PostTicketTask/PatchTicketTask (planned_begin, planned_end, date), PostFollowup, PostSolution, PostUser/PatchUser (begin_date, end_date, substitution_*) and the KB post models. Closes #22 Co-Authored-By: Claude Opus 5 (1M context) --- .../_async/clients/commons/_payloads.py | 9 ++++- .../clients/commons/tests/test_payloads.py | 38 +++++++++++++++++++ .../_sync/clients/commons/_payloads.py | 9 ++++- .../clients/commons/tests/test_payloads.py | 38 +++++++++++++++++++ 4 files changed, 92 insertions(+), 2 deletions(-) diff --git a/glpi_python_client/_async/clients/commons/_payloads.py b/glpi_python_client/_async/clients/commons/_payloads.py index 15e24b3..8e70538 100644 --- a/glpi_python_client/_async/clients/commons/_payloads.py +++ b/glpi_python_client/_async/clients/commons/_payloads.py @@ -21,9 +21,16 @@ def model_to_payload(model: GlpiModel) -> dict[str, object]: excluded from the dump, and any user-provided ``extra_payload`` keys are merged on top so callers can inject contract-validated extras the package does not yet model. + + The dump runs in JSON mode. The returned mapping is handed to the HTTP + library as a JSON body, and its encoder is :func:`json.dumps`, which + cannot represent a ``datetime`` -- python mode leaves those as live + objects and every write of a date field then fails at the encoder, past + the point any transport stub can see. JSON mode renders them as ISO-8601 + strings instead, so what the model validated is what GLPI receives. """ - body = model.model_dump(exclude_none=True, exclude={"extra_payload"}) + body = model.model_dump(mode="json", exclude_none=True, exclude={"extra_payload"}) if model.extra_payload: body.update(model.extra_payload) return body diff --git a/glpi_python_client/_async/clients/commons/tests/test_payloads.py b/glpi_python_client/_async/clients/commons/tests/test_payloads.py index 10a29ce..d3ac9ed 100644 --- a/glpi_python_client/_async/clients/commons/tests/test_payloads.py +++ b/glpi_python_client/_async/clients/commons/tests/test_payloads.py @@ -2,6 +2,9 @@ from __future__ import annotations +import json +from datetime import datetime, timezone + from glpi_python_client._async.clients.commons._payloads import ( model_from_payload, model_to_payload, @@ -10,6 +13,9 @@ GetUser, PostUser, ) +from glpi_python_client.models.api_schema.assistance.timeline._task import ( + PostTicketTask, +) def test_model_to_payload_excludes_none_and_extra_payload_meta() -> None: @@ -62,3 +68,35 @@ def test_capture_extra_keys_merges_with_existing_extra_payload_dict() -> None: "stranger": "explicit", "caller_wins": True, } + + +def test_model_to_payload_body_is_json_encodable() -> None: + """Every value in the body survives the JSON encoder httpx will use. + + ``_execute_request`` hands the mapping straight to ``httpx`` as ``json=``, + whose encoder is ``json.dumps``. A body holding a live ``datetime`` object + raises ``TypeError`` there -- after the model validated, and outside any + transport stub -- so the assertion has to be encodability, not shape. + """ + + body = model_to_payload(PostTicketTask(planned_begin=datetime(2024, 1, 1, 12, 0))) + + assert json.dumps(body) + + +def test_model_to_payload_renders_naive_datetime_without_offset() -> None: + """A naive datetime reaches GLPI as the bare timestamp it was given.""" + + body = model_to_payload(PostTicketTask(planned_begin=datetime(2024, 1, 1, 12, 0))) + + assert body["planned_begin"] == "2024-01-01T12:00:00" + + +def test_model_to_payload_preserves_aware_datetime_offset() -> None: + """An aware datetime keeps its offset rather than being silently dropped.""" + + aware = datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc) + + body = model_to_payload(PostTicketTask(planned_begin=aware)) + + assert body["planned_begin"] == "2024-01-01T12:00:00Z" diff --git a/glpi_python_client/_sync/clients/commons/_payloads.py b/glpi_python_client/_sync/clients/commons/_payloads.py index 15e24b3..8e70538 100644 --- a/glpi_python_client/_sync/clients/commons/_payloads.py +++ b/glpi_python_client/_sync/clients/commons/_payloads.py @@ -21,9 +21,16 @@ def model_to_payload(model: GlpiModel) -> dict[str, object]: excluded from the dump, and any user-provided ``extra_payload`` keys are merged on top so callers can inject contract-validated extras the package does not yet model. + + The dump runs in JSON mode. The returned mapping is handed to the HTTP + library as a JSON body, and its encoder is :func:`json.dumps`, which + cannot represent a ``datetime`` -- python mode leaves those as live + objects and every write of a date field then fails at the encoder, past + the point any transport stub can see. JSON mode renders them as ISO-8601 + strings instead, so what the model validated is what GLPI receives. """ - body = model.model_dump(exclude_none=True, exclude={"extra_payload"}) + body = model.model_dump(mode="json", exclude_none=True, exclude={"extra_payload"}) if model.extra_payload: body.update(model.extra_payload) return body diff --git a/glpi_python_client/_sync/clients/commons/tests/test_payloads.py b/glpi_python_client/_sync/clients/commons/tests/test_payloads.py index 443dbc2..b6c9d94 100644 --- a/glpi_python_client/_sync/clients/commons/tests/test_payloads.py +++ b/glpi_python_client/_sync/clients/commons/tests/test_payloads.py @@ -2,6 +2,9 @@ from __future__ import annotations +import json +from datetime import datetime, timezone + from glpi_python_client._sync.clients.commons._payloads import ( model_from_payload, model_to_payload, @@ -10,6 +13,9 @@ GetUser, PostUser, ) +from glpi_python_client.models.api_schema.assistance.timeline._task import ( + PostTicketTask, +) def test_model_to_payload_excludes_none_and_extra_payload_meta() -> None: @@ -62,3 +68,35 @@ def test_capture_extra_keys_merges_with_existing_extra_payload_dict() -> None: "stranger": "explicit", "caller_wins": True, } + + +def test_model_to_payload_body_is_json_encodable() -> None: + """Every value in the body survives the JSON encoder httpx will use. + + ``_execute_request`` hands the mapping straight to ``httpx`` as ``json=``, + whose encoder is ``json.dumps``. A body holding a live ``datetime`` object + raises ``TypeError`` there -- after the model validated, and outside any + transport stub -- so the assertion has to be encodability, not shape. + """ + + body = model_to_payload(PostTicketTask(planned_begin=datetime(2024, 1, 1, 12, 0))) + + assert json.dumps(body) + + +def test_model_to_payload_renders_naive_datetime_without_offset() -> None: + """A naive datetime reaches GLPI as the bare timestamp it was given.""" + + body = model_to_payload(PostTicketTask(planned_begin=datetime(2024, 1, 1, 12, 0))) + + assert body["planned_begin"] == "2024-01-01T12:00:00" + + +def test_model_to_payload_preserves_aware_datetime_offset() -> None: + """An aware datetime keeps its offset rather than being silently dropped.""" + + aware = datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc) + + body = model_to_payload(PostTicketTask(planned_begin=aware)) + + assert body["planned_begin"] == "2024-01-01T12:00:00Z" From 27e9c635d5e597c423f5e1d8c3414415e77f024e Mon Sep 17 00:00:00 2001 From: baraline Date: Wed, 12 Aug 2026 12:49:08 +0200 Subject: [PATCH 02/21] fix: order timeline events across mixed datetime awareness _MAX_DATETIME was datetime.max, a naive value, and _event_sort_key returned either it or a date_creation straight off the model. Awareness is not uniform across a timeline -- GLPI omits the offset on some resources and sends it on others -- so one response can carry both spellings. Sorting then raised "can't compare offset-naive and offset-aware datetimes", surfacing as a crash in to_markdown() rather than as a mis-ordering. The sentinel is now aware and the key normalises a naive date_creation to UTC, so all three populations sort: all-naive, all-aware, and mixed. The assumption is confined to ordering; the rendered output still prints the value the server sent. Closes #25 Co-Authored-By: Claude Opus 5 (1M context) --- .../models/custom_schema/_ticket_context.py | 21 ++++++- .../tests/test_ticket_context.py | 58 +++++++++++++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/glpi_python_client/models/custom_schema/_ticket_context.py b/glpi_python_client/models/custom_schema/_ticket_context.py index b00eb57..07d9d1e 100644 --- a/glpi_python_client/models/custom_schema/_ticket_context.py +++ b/glpi_python_client/models/custom_schema/_ticket_context.py @@ -9,7 +9,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from datetime import datetime +from datetime import datetime, timezone from enum import Enum from typing import Any @@ -29,7 +29,7 @@ ) from glpi_python_client.models.api_schema.management._document import GetDocument -_MAX_DATETIME = datetime.max +_MAX_DATETIME = datetime.max.replace(tzinfo=timezone.utc) def _ref_label(ref: IdNameRef | None) -> str | None: @@ -164,9 +164,24 @@ def _event_sort_key(event: Any) -> datetime: are therefore always ordered by ``date_creation`` and items missing a creation timestamp are pushed to the end while preserving the sort's stability. + + Every key is returned as an aware datetime. Awareness is not uniform + across a timeline: GLPI omits the offset on some resources and sends + it on others, so one response can carry both spellings, and the + end-of-time sentinel has to compare against whichever arrives. + Comparing the two kinds raises ``TypeError``, which would surface as a + crash in :meth:`GlpiTicketContext.to_markdown` rather than as a + mis-ordering. A naive value is the server's own wall clock, so it is + read as UTC here; that assumption orders events and never reaches the + rendered output, which prints the original value. """ - return getattr(event, "date_creation", None) or _MAX_DATETIME + created: datetime | None = getattr(event, "date_creation", None) + if created is None: + return _MAX_DATETIME + if created.tzinfo is None: + return created.replace(tzinfo=timezone.utc) + return created class GlpiTicketContext(GlpiModel): 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 b64c658..a9ad98a 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 @@ -396,3 +396,61 @@ def test_options_default_reproduces_original_output() -> None: context = GlpiTicketContext.model_validate(_FULL_PAYLOAD) assert context.to_markdown() == context.to_markdown(TicketMarkdownOptions()) + + +def test_to_markdown_sorts_aware_events_when_one_lacks_a_creation_date() -> None: + """An event with no ``date_creation`` sorts last among aware timestamps. + + The undated event falls back to the sentinel used by ``_event_sort_key``. + When the server sends offset-bearing timestamps -- the format GLPI emits + for the knowledge base -- a naive sentinel makes that comparison raise + ``TypeError`` instead of ordering. + """ + + context = GlpiTicketContext.model_validate( + { + "ticket": {"id": 1, "name": "x"}, + "followups": [ + {"id": 2, "content": "undated note"}, + { + "id": 1, + "content": "dated note", + "date_creation": datetime(2024, 1, 1, tzinfo=timezone.utc), + }, + ], + } + ) + + rendered = context.to_markdown() + + assert rendered.index("dated note") < rendered.index("undated note") + + +def test_to_markdown_orders_events_across_mixed_datetime_awareness() -> None: + """Naive and aware timestamps in one timeline still order chronologically. + + Nothing guarantees one wire format per response: a naive value is read as + the server's wall clock, so it is treated as UTC for ordering purposes. + """ + + context = GlpiTicketContext.model_validate( + { + "ticket": {"id": 1, "name": "x"}, + "followups": [ + { + "id": 2, + "content": "aware second", + "date_creation": datetime(2024, 1, 2, tzinfo=timezone.utc), + }, + { + "id": 1, + "content": "naive first", + "date_creation": datetime(2024, 1, 1), + }, + ], + } + ) + + rendered = context.to_markdown() + + assert rendered.index("naive first") < rendered.index("aware second") From 545421e9bca60e4bb6f47d1b8d90c5a2d5280299 Mon Sep 17 00:00:00 2001 From: baraline Date: Wed, 12 Aug 2026 12:54:02 +0200 Subject: [PATCH 03/21] fix: page every corpus walk in the statistics helpers get_ticket_statistics fetched tickets with a single search_tickets call at limit=200. _resource_list issues exactly one GET per call, so any corpus larger than the page size was silently truncated and the helper reported a plausible number that was really just the page size. The module's own docstring records 59,690 live tickets on the target instance, so aggregations there were wrong by three orders of magnitude. The two entity-name resolutions and the user-name resolution had the same shape and are paged too: a name prefix shared by more than 200 entities dropped the remainder from the OR group, and the tickets belonging to them vanished from the aggregate with no error. get_task_durations and get_user_activity already iterated iter_search_tickets; this brings the rest of the module to that pattern. The pre-existing fake_search stubs gained **kwargs because the iterator forwards sort and fields, which a direct search_tickets call did not. Closes #23 Co-Authored-By: Claude Opus 5 (1M context) --- .../_async/clients/custom/_statistics.py | 44 ++++--- .../clients/custom/tests/test_statistics.py | 111 +++++++++++++++++- .../_sync/clients/custom/_statistics.py | 44 ++++--- .../clients/custom/tests/test_statistics.py | 111 +++++++++++++++++- 4 files changed, 266 insertions(+), 44 deletions(-) diff --git a/glpi_python_client/_async/clients/custom/_statistics.py b/glpi_python_client/_async/clients/custom/_statistics.py index e3e075f..08a726b 100644 --- a/glpi_python_client/_async/clients/custom/_statistics.py +++ b/glpi_python_client/_async/clients/custom/_statistics.py @@ -382,10 +382,12 @@ async def get_ticket_statistics( entity_filter = f"entity.id=={entity_id}" elif entity_name is not None: name_filter = rsql_contains_filter("name", entity_name) or "" - entities = await self.search_entities( # type: ignore[attr-defined] - rsql_filter=name_filter, - limit=200, - ) + entities = [] + async for entity_batch in self.iter_search_entities( # type: ignore[attr-defined] + name_filter, + batch_size=200, + ): + entities.extend(entity_batch) if not entities: return {"entities": {}} entity_filter = rsql_any_filter( @@ -399,10 +401,16 @@ async def get_ticket_statistics( _LIVE_TICKETS, extra_filter, ) - tickets: list[GetTicket] = await self.search_tickets( # type: ignore[attr-defined] - rsql_filter=query or "", - limit=200, - ) + # Paged rather than fetched in one call. A single ``search_tickets`` + # returns one page and nothing else, so any corpus larger than the + # page size was silently truncated and the helper reported a + # plausible number that was really just the page size. + tickets: list[GetTicket] = [] + async for batch in self.iter_search_tickets( # type: ignore[attr-defined] + query or "", + batch_size=200, + ): + tickets.extend(batch) return _summarize_tickets(tickets) async def get_task_statistics( @@ -526,10 +534,12 @@ async def get_task_durations( entity_filter = f"entity.id=={entity_id}" elif entity_name is not None: name_filter = rsql_contains_filter("name", entity_name) or "" - entities = await self.search_entities( # type: ignore[attr-defined] - rsql_filter=name_filter, - limit=200, - ) + entities = [] + async for entity_batch in self.iter_search_entities( # type: ignore[attr-defined] + name_filter, + batch_size=200, + ): + entities.extend(entity_batch) if not entities: return TaskDurationsResult( start_date=start.isoformat(), @@ -714,10 +724,12 @@ async def get_user_activity( rsql_contains_filter("firstname", firstname) if firstname else None, ] user_rsql = rsql_all_filter(*name_parts) or "" - matched_users = await self.search_users( # type: ignore[attr-defined] - rsql_filter=user_rsql, - limit=200, - ) + matched_users = [] + async for user_batch in self.iter_search_users( # type: ignore[attr-defined] + user_rsql, + batch_size=200, + ): + matched_users.extend(user_batch) if not matched_users: raise GlpiValidationError("No users matched the supplied criteria") resolved_user_ids = [u.id for u in matched_users if u.id is not None] diff --git a/glpi_python_client/_async/clients/custom/tests/test_statistics.py b/glpi_python_client/_async/clients/custom/tests/test_statistics.py index 46fdc79..f46a67f 100644 --- a/glpi_python_client/_async/clients/custom/tests/test_statistics.py +++ b/glpi_python_client/_async/clients/custom/tests/test_statistics.py @@ -105,7 +105,7 @@ async def test_get_ticket_statistics_aggregates_by_entity_status_priority_type( captured: dict[str, Any] = {} async def fake_search( - rsql_filter: str = "", *, limit: int = 50, start: int = 0 + rsql_filter: str = "", *, limit: int = 50, start: int = 0, **kwargs: Any ) -> list[GetTicket]: captured["filter"] = rsql_filter return [ @@ -154,7 +154,7 @@ async def test_get_ticket_statistics_default_window_uses_today( captured: dict[str, Any] = {} async def fake_search( - rsql_filter: str = "", *, limit: int = 50, start: int = 0 + rsql_filter: str = "", *, limit: int = 50, start: int = 0, **kwargs: Any ) -> list[GetTicket]: captured["filter"] = rsql_filter return [] @@ -267,7 +267,7 @@ async def test_get_ticket_statistics_entity_id_filter(client: Any) -> None: captured: dict[str, Any] = {} async def fake_search( - rsql_filter: str = "", *, limit: int = 50, start: int = 0 + rsql_filter: str = "", *, limit: int = 50, start: int = 0, **kwargs: Any ) -> list[GetTicket]: captured["filter"] = rsql_filter return [] @@ -299,7 +299,7 @@ async def fake_search_entities( return [GetEntity(id=3, name="Acme"), GetEntity(id=4, name="Acme Sub")] async def fake_search( - rsql_filter: str = "", *, limit: int = 50, start: int = 0 + rsql_filter: str = "", *, limit: int = 50, start: int = 0, **kwargs: Any ) -> list[GetTicket]: captured_tickets["filter"] = rsql_filter return [] @@ -344,7 +344,7 @@ async def test_get_ticket_statistics_extra_filter_appended(client: Any) -> None: captured: dict[str, Any] = {} async def fake_search( - rsql_filter: str = "", *, limit: int = 50, start: int = 0 + rsql_filter: str = "", *, limit: int = 50, start: int = 0, **kwargs: Any ) -> list[GetTicket]: captured["filter"] = rsql_filter return [] @@ -367,7 +367,7 @@ async def test_get_ticket_statistics_default_days_window(client: Any) -> None: captured: dict[str, Any] = {} async def fake_search( - rsql_filter: str = "", *, limit: int = 50, start: int = 0 + rsql_filter: str = "", *, limit: int = 50, start: int = 0, **kwargs: Any ) -> list[GetTicket]: captured["filter"] = rsql_filter return [] @@ -784,3 +784,102 @@ async def fake_task_stats(ticket_ids: list[int]) -> dict[str, Any]: assert result["task_count"] == 0 assert calls == [[1]] + + +async def test_get_ticket_statistics_counts_beyond_one_page(client: Any) -> None: + """Aggregates cover every matching ticket, not just the first page. + + A single ``search_tickets`` call returns one page and nothing else, so a + corpus larger than the page size was silently truncated: the helper + reported a plausible number that happened to be the page size. The stub + answers a full page followed by a short one, which is the only shape that + tells a paging walk apart from a single request. + """ + + pages = [[_ticket() for _ in range(200)], [_ticket() for _ in range(30)]] + calls: list[int] = [] + + async def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0, **kwargs: Any + ) -> list[GetTicket]: + calls.append(start) + index = start // limit + return pages[index] if index < len(pages) else [] + + client.search_tickets = fake_search # type: ignore[method-assign] + + result = await client.get_ticket_statistics(default_days=7) + + assert calls == [0, 200] + assert result["entities"]["1"]["total"] == 230 + + +async def test_get_ticket_statistics_resolves_entities_beyond_one_page( + client: Any, +) -> None: + """Entity-name resolution walks every page of matches. + + A name prefix shared by many entities matches more than one page. A + single ``search_entities`` call drops the rest, so tickets belonging to + the unlisted entities vanish from the aggregate with no error. + """ + + from glpi_python_client.models.api_schema.administration._entity import GetEntity + + pages = [ + [GetEntity(id=i, name="Acme") for i in range(1, 201)], + [GetEntity(id=201, name="Acme Sub")], + ] + captured_tickets: dict[str, Any] = {} + + async def fake_search_entities( + rsql_filter: str = "", *, limit: int = 200, start: int = 0, **kwargs: Any + ) -> list[GetEntity]: + index = start // limit + return pages[index] if index < len(pages) else [] + + async def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0, **kwargs: Any + ) -> list[GetTicket]: + captured_tickets["filter"] = rsql_filter + return [] + + client.search_entities = fake_search_entities # type: ignore[method-assign] + client.search_tickets = fake_search # type: ignore[method-assign] + + await client.get_ticket_statistics(default_days=7, entity_name="Acme") + + assert "entity.id==201" in captured_tickets["filter"] + + +async def test_get_task_durations_resolves_entities_beyond_one_page( + client: Any, +) -> None: + """``get_task_durations`` pages entity-name resolution the same way.""" + + from glpi_python_client.models.api_schema.administration._entity import GetEntity + + pages = [ + [GetEntity(id=i, name="Acme") for i in range(1, 201)], + [GetEntity(id=201, name="Acme Sub")], + ] + captured_tickets: dict[str, Any] = {} + + async def fake_search_entities( + rsql_filter: str = "", *, limit: int = 200, start: int = 0, **kwargs: Any + ) -> list[GetEntity]: + index = start // limit + return pages[index] if index < len(pages) else [] + + async def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0, **kwargs: Any + ) -> list[GetTicket]: + captured_tickets["filter"] = rsql_filter + return [] + + client.search_entities = fake_search_entities # type: ignore[method-assign] + client.search_tickets = fake_search # type: ignore[method-assign] + + await client.get_task_durations(default_days=7, entity_name="Acme") + + assert "entity.id==201" in captured_tickets["filter"] diff --git a/glpi_python_client/_sync/clients/custom/_statistics.py b/glpi_python_client/_sync/clients/custom/_statistics.py index 0e2a25b..1cf13ca 100644 --- a/glpi_python_client/_sync/clients/custom/_statistics.py +++ b/glpi_python_client/_sync/clients/custom/_statistics.py @@ -382,10 +382,12 @@ def get_ticket_statistics( entity_filter = f"entity.id=={entity_id}" elif entity_name is not None: name_filter = rsql_contains_filter("name", entity_name) or "" - entities = self.search_entities( # type: ignore[attr-defined] - rsql_filter=name_filter, - limit=200, - ) + entities = [] + for entity_batch in self.iter_search_entities( # type: ignore[attr-defined] + name_filter, + batch_size=200, + ): + entities.extend(entity_batch) if not entities: return {"entities": {}} entity_filter = rsql_any_filter( @@ -399,10 +401,16 @@ def get_ticket_statistics( _LIVE_TICKETS, extra_filter, ) - tickets: list[GetTicket] = self.search_tickets( # type: ignore[attr-defined] - rsql_filter=query or "", - limit=200, - ) + # Paged rather than fetched in one call. A single ``search_tickets`` + # returns one page and nothing else, so any corpus larger than the + # page size was silently truncated and the helper reported a + # plausible number that was really just the page size. + tickets: list[GetTicket] = [] + for batch in self.iter_search_tickets( # type: ignore[attr-defined] + query or "", + batch_size=200, + ): + tickets.extend(batch) return _summarize_tickets(tickets) def get_task_statistics( @@ -526,10 +534,12 @@ def get_task_durations( entity_filter = f"entity.id=={entity_id}" elif entity_name is not None: name_filter = rsql_contains_filter("name", entity_name) or "" - entities = self.search_entities( # type: ignore[attr-defined] - rsql_filter=name_filter, - limit=200, - ) + entities = [] + for entity_batch in self.iter_search_entities( # type: ignore[attr-defined] + name_filter, + batch_size=200, + ): + entities.extend(entity_batch) if not entities: return TaskDurationsResult( start_date=start.isoformat(), @@ -714,10 +724,12 @@ def get_user_activity( rsql_contains_filter("firstname", firstname) if firstname else None, ] user_rsql = rsql_all_filter(*name_parts) or "" - matched_users = self.search_users( # type: ignore[attr-defined] - rsql_filter=user_rsql, - limit=200, - ) + matched_users = [] + for user_batch in self.iter_search_users( # type: ignore[attr-defined] + user_rsql, + batch_size=200, + ): + matched_users.extend(user_batch) if not matched_users: raise GlpiValidationError("No users matched the supplied criteria") resolved_user_ids = [u.id for u in matched_users if u.id is not None] diff --git a/glpi_python_client/_sync/clients/custom/tests/test_statistics.py b/glpi_python_client/_sync/clients/custom/tests/test_statistics.py index 6939b83..4daf77d 100644 --- a/glpi_python_client/_sync/clients/custom/tests/test_statistics.py +++ b/glpi_python_client/_sync/clients/custom/tests/test_statistics.py @@ -105,7 +105,7 @@ def test_get_ticket_statistics_aggregates_by_entity_status_priority_type( captured: dict[str, Any] = {} def fake_search( - rsql_filter: str = "", *, limit: int = 50, start: int = 0 + rsql_filter: str = "", *, limit: int = 50, start: int = 0, **kwargs: Any ) -> list[GetTicket]: captured["filter"] = rsql_filter return [ @@ -154,7 +154,7 @@ def test_get_ticket_statistics_default_window_uses_today( captured: dict[str, Any] = {} def fake_search( - rsql_filter: str = "", *, limit: int = 50, start: int = 0 + rsql_filter: str = "", *, limit: int = 50, start: int = 0, **kwargs: Any ) -> list[GetTicket]: captured["filter"] = rsql_filter return [] @@ -267,7 +267,7 @@ def test_get_ticket_statistics_entity_id_filter(client: Any) -> None: captured: dict[str, Any] = {} def fake_search( - rsql_filter: str = "", *, limit: int = 50, start: int = 0 + rsql_filter: str = "", *, limit: int = 50, start: int = 0, **kwargs: Any ) -> list[GetTicket]: captured["filter"] = rsql_filter return [] @@ -299,7 +299,7 @@ def fake_search_entities( return [GetEntity(id=3, name="Acme"), GetEntity(id=4, name="Acme Sub")] def fake_search( - rsql_filter: str = "", *, limit: int = 50, start: int = 0 + rsql_filter: str = "", *, limit: int = 50, start: int = 0, **kwargs: Any ) -> list[GetTicket]: captured_tickets["filter"] = rsql_filter return [] @@ -344,7 +344,7 @@ def test_get_ticket_statistics_extra_filter_appended(client: Any) -> None: captured: dict[str, Any] = {} def fake_search( - rsql_filter: str = "", *, limit: int = 50, start: int = 0 + rsql_filter: str = "", *, limit: int = 50, start: int = 0, **kwargs: Any ) -> list[GetTicket]: captured["filter"] = rsql_filter return [] @@ -367,7 +367,7 @@ def test_get_ticket_statistics_default_days_window(client: Any) -> None: captured: dict[str, Any] = {} def fake_search( - rsql_filter: str = "", *, limit: int = 50, start: int = 0 + rsql_filter: str = "", *, limit: int = 50, start: int = 0, **kwargs: Any ) -> list[GetTicket]: captured["filter"] = rsql_filter return [] @@ -784,3 +784,102 @@ def fake_task_stats(ticket_ids: list[int]) -> dict[str, Any]: assert result["task_count"] == 0 assert calls == [[1]] + + +def test_get_ticket_statistics_counts_beyond_one_page(client: Any) -> None: + """Aggregates cover every matching ticket, not just the first page. + + A single ``search_tickets`` call returns one page and nothing else, so a + corpus larger than the page size was silently truncated: the helper + reported a plausible number that happened to be the page size. The stub + answers a full page followed by a short one, which is the only shape that + tells a paging walk apart from a single request. + """ + + pages = [[_ticket() for _ in range(200)], [_ticket() for _ in range(30)]] + calls: list[int] = [] + + def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0, **kwargs: Any + ) -> list[GetTicket]: + calls.append(start) + index = start // limit + return pages[index] if index < len(pages) else [] + + client.search_tickets = fake_search # type: ignore[method-assign] + + result = client.get_ticket_statistics(default_days=7) + + assert calls == [0, 200] + assert result["entities"]["1"]["total"] == 230 + + +def test_get_ticket_statistics_resolves_entities_beyond_one_page( + client: Any, +) -> None: + """Entity-name resolution walks every page of matches. + + A name prefix shared by many entities matches more than one page. A + single ``search_entities`` call drops the rest, so tickets belonging to + the unlisted entities vanish from the aggregate with no error. + """ + + from glpi_python_client.models.api_schema.administration._entity import GetEntity + + pages = [ + [GetEntity(id=i, name="Acme") for i in range(1, 201)], + [GetEntity(id=201, name="Acme Sub")], + ] + captured_tickets: dict[str, Any] = {} + + def fake_search_entities( + rsql_filter: str = "", *, limit: int = 200, start: int = 0, **kwargs: Any + ) -> list[GetEntity]: + index = start // limit + return pages[index] if index < len(pages) else [] + + def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0, **kwargs: Any + ) -> list[GetTicket]: + captured_tickets["filter"] = rsql_filter + return [] + + client.search_entities = fake_search_entities # type: ignore[method-assign] + client.search_tickets = fake_search # type: ignore[method-assign] + + client.get_ticket_statistics(default_days=7, entity_name="Acme") + + assert "entity.id==201" in captured_tickets["filter"] + + +def test_get_task_durations_resolves_entities_beyond_one_page( + client: Any, +) -> None: + """``get_task_durations`` pages entity-name resolution the same way.""" + + from glpi_python_client.models.api_schema.administration._entity import GetEntity + + pages = [ + [GetEntity(id=i, name="Acme") for i in range(1, 201)], + [GetEntity(id=201, name="Acme Sub")], + ] + captured_tickets: dict[str, Any] = {} + + def fake_search_entities( + rsql_filter: str = "", *, limit: int = 200, start: int = 0, **kwargs: Any + ) -> list[GetEntity]: + index = start // limit + return pages[index] if index < len(pages) else [] + + def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0, **kwargs: Any + ) -> list[GetTicket]: + captured_tickets["filter"] = rsql_filter + return [] + + client.search_entities = fake_search_entities # type: ignore[method-assign] + client.search_tickets = fake_search # type: ignore[method-assign] + + client.get_task_durations(default_days=7, entity_name="Acme") + + assert "entity.id==201" in captured_tickets["filter"] From 78734c7fa131ea9e9e0c8f368996f954d2d86196 Mon Sep 17 00:00:00 2001 From: baraline Date: Wed, 12 Aug 2026 12:56:48 +0200 Subject: [PATCH 04/21] fix: recover create ids from strings, data envelopes and Location require_response_int accepted only a top-level key holding a native int, so three shapes the server considers successful raised GlpiProtocolError over an identifier that was right there: a numeric string, an id nested one level under "data", and an empty 201 whose Location header names the new resource. Each cost the caller the id of a record that now exists, with no way to recover it. GLPI is PHP-backed and PHP-backed APIs routinely render integers as strings, which makes the first shape the likely one. Live runs indicate GLPI 11 currently returns a native int, so this is hardening rather than a break -- but it is the single point through which all 12 create_* methods and link_ticket_timeline_document pass. bool stays rejected (it is an int subclass in Python) and floats stay rejected: a fractional id is a misread payload, not a value to round. Closes #26 Co-Authored-By: Claude Opus 5 (1M context) --- .../_async/clients/commons/_http.py | 69 +++++++++++++++++-- .../_async/clients/commons/tests/test_http.py | 49 +++++++++++++ .../_sync/clients/commons/_http.py | 69 +++++++++++++++++-- .../_sync/clients/commons/tests/test_http.py | 49 +++++++++++++ 4 files changed, 226 insertions(+), 10 deletions(-) diff --git a/glpi_python_client/_async/clients/commons/_http.py b/glpi_python_client/_async/clients/commons/_http.py index 765cb40..7f75887 100644 --- a/glpi_python_client/_async/clients/commons/_http.py +++ b/glpi_python_client/_async/clients/commons/_http.py @@ -310,6 +310,45 @@ def response_json_mapping(response: httpx.Response) -> Mapping[str, object]: return result if isinstance(result, Mapping) else {} +def coerce_response_int(value: object) -> int | None: + """Return ``value`` as an ``int`` when it plainly denotes one. + + Native integers pass through. A string is accepted when it parses + cleanly, because GLPI is PHP-backed and PHP-backed APIs routinely + render integers as strings; refusing one would be a protocol error + raised over a perfectly usable identifier. ``bool`` is rejected even + though it is an ``int`` subclass in Python, and floats are rejected + because a fractional identifier is a sign of a misread payload rather + than a value to round. + """ + + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, str): + try: + return int(value.strip()) + except ValueError: + return None + return None + + +def response_id_from_location(response: httpx.Response) -> int | None: + """Return the trailing identifier of the response ``Location`` header. + + A create that answers with an empty body and the URL of the new + resource is an ordinary REST shape. Reading the last path segment + recovers the identifier instead of failing a request that succeeded. + Returns ``None`` when there is no header or its tail is not numeric. + """ + + location = response.headers.get("Location") or response.headers.get("location") + if not location: + return None + return coerce_response_int(location.rstrip("/").rsplit("/", 1)[-1]) + + def require_response_int( response: httpx.Response, *, @@ -322,17 +361,35 @@ def require_response_int( set of keys. Callers list the candidate keys explicitly so the behaviour stays predictable. + Three shapes beyond a plain top-level integer are accepted, because + each is a response the server considers successful and each cost the + caller the identifier of a record that already exists: a numeric + string, an id nested one level under ``data``, and an empty body + whose ``Location`` header names the new resource. Top-level keys are + probed before nested ones, and the header is consulted last. + Raises ------ GlpiProtocolError - When none of ``keys`` maps to an integer value in the response. + When no candidate key and no ``Location`` header yields an + identifier. """ result = response_json_mapping(response) - for key in keys: - value = result.get(key) - if isinstance(value, int) and not isinstance(value, bool): - return value + candidates: list[Mapping[str, object]] = [result] + nested = result.get("data") + if isinstance(nested, Mapping): + candidates.append(nested) + + for mapping in candidates: + for key in keys: + found = coerce_response_int(mapping.get(key)) + if found is not None: + return found + + from_location = response_id_from_location(response) + if from_location is not None: + return from_location raise GlpiProtocolError(missing_message) @@ -380,6 +437,7 @@ def unwrap_timeline_items(payload: object) -> list[dict[str, object]]: __all__ = [ "build_request_headers", "build_request_url", + "coerce_response_int", "ensure_response_status", "finalize_request_response", "list_payload_items", @@ -387,6 +445,7 @@ def unwrap_timeline_items(payload: object) -> list[dict[str, object]]: "request_params", "require_access_token", "require_response_int", + "response_id_from_location", "response_json_mapping", "response_json_or_empty", "transport_error_from", diff --git a/glpi_python_client/_async/clients/commons/tests/test_http.py b/glpi_python_client/_async/clients/commons/tests/test_http.py index 2862214..989276e 100644 --- a/glpi_python_client/_async/clients/commons/tests/test_http.py +++ b/glpi_python_client/_async/clients/commons/tests/test_http.py @@ -195,3 +195,52 @@ def test_4xx_raises_a_typed_status_error_from_ensure_response_status() -> None: assert excinfo.value.status_code == 404 assert isinstance(excinfo.value, ValueError) assert str(excinfo.value) == "Failed to fetch ticket 1: 404 nope" + + +def test_require_response_int_accepts_a_numeric_string_id() -> None: + """A quoted identifier is still an identifier. + + GLPI is PHP-backed and PHP-backed APIs commonly render integers as + strings. Rejecting one is a protocol error raised over a usable value. + """ + + response = FakeResponse(payload={"id": "4242"}) + + assert require_response_int(response, keys=("id",), missing_message="x") == 4242 + + +def test_require_response_int_ignores_a_non_numeric_string_id() -> None: + """A string that is not a number is not silently coerced.""" + + response = FakeResponse(payload={"id": "not-a-number"}) + + with pytest.raises(GlpiProtocolError): + require_response_int(response, keys=("id",), missing_message="x") + + +def test_require_response_int_reads_a_nested_data_envelope() -> None: + """An id wrapped in a ``data`` envelope is found rather than refused.""" + + response = FakeResponse(payload={"data": {"id": 4242}}) + + assert require_response_int(response, keys=("id",), missing_message="x") == 4242 + + +def test_require_response_int_falls_back_to_the_location_header() -> None: + """A 201 whose id lives only in ``Location`` still yields the id. + + A create that answers with an empty body and a resource URL is a normal + REST shape; without this the client raises a protocol error on a request + that succeeded, and the caller loses the id of a record that now exists. + """ + + response = FakeResponse( + status_code=201, + payload=None, + content=b"", + headers={ + "Location": "https://glpi.example.test/api.php/v2/Assistance/Ticket/4242" + }, + ) + + assert require_response_int(response, keys=("id",), missing_message="x") == 4242 diff --git a/glpi_python_client/_sync/clients/commons/_http.py b/glpi_python_client/_sync/clients/commons/_http.py index 550922e..e4dca06 100644 --- a/glpi_python_client/_sync/clients/commons/_http.py +++ b/glpi_python_client/_sync/clients/commons/_http.py @@ -310,6 +310,45 @@ def response_json_mapping(response: httpx.Response) -> Mapping[str, object]: return result if isinstance(result, Mapping) else {} +def coerce_response_int(value: object) -> int | None: + """Return ``value`` as an ``int`` when it plainly denotes one. + + Native integers pass through. A string is accepted when it parses + cleanly, because GLPI is PHP-backed and PHP-backed APIs routinely + render integers as strings; refusing one would be a protocol error + raised over a perfectly usable identifier. ``bool`` is rejected even + though it is an ``int`` subclass in Python, and floats are rejected + because a fractional identifier is a sign of a misread payload rather + than a value to round. + """ + + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, str): + try: + return int(value.strip()) + except ValueError: + return None + return None + + +def response_id_from_location(response: httpx.Response) -> int | None: + """Return the trailing identifier of the response ``Location`` header. + + A create that answers with an empty body and the URL of the new + resource is an ordinary REST shape. Reading the last path segment + recovers the identifier instead of failing a request that succeeded. + Returns ``None`` when there is no header or its tail is not numeric. + """ + + location = response.headers.get("Location") or response.headers.get("location") + if not location: + return None + return coerce_response_int(location.rstrip("/").rsplit("/", 1)[-1]) + + def require_response_int( response: httpx.Response, *, @@ -322,17 +361,35 @@ def require_response_int( set of keys. Callers list the candidate keys explicitly so the behaviour stays predictable. + Three shapes beyond a plain top-level integer are accepted, because + each is a response the server considers successful and each cost the + caller the identifier of a record that already exists: a numeric + string, an id nested one level under ``data``, and an empty body + whose ``Location`` header names the new resource. Top-level keys are + probed before nested ones, and the header is consulted last. + Raises ------ GlpiProtocolError - When none of ``keys`` maps to an integer value in the response. + When no candidate key and no ``Location`` header yields an + identifier. """ result = response_json_mapping(response) - for key in keys: - value = result.get(key) - if isinstance(value, int) and not isinstance(value, bool): - return value + candidates: list[Mapping[str, object]] = [result] + nested = result.get("data") + if isinstance(nested, Mapping): + candidates.append(nested) + + for mapping in candidates: + for key in keys: + found = coerce_response_int(mapping.get(key)) + if found is not None: + return found + + from_location = response_id_from_location(response) + if from_location is not None: + return from_location raise GlpiProtocolError(missing_message) @@ -380,6 +437,7 @@ def unwrap_timeline_items(payload: object) -> list[dict[str, object]]: __all__ = [ "build_request_headers", "build_request_url", + "coerce_response_int", "ensure_response_status", "finalize_request_response", "list_payload_items", @@ -387,6 +445,7 @@ def unwrap_timeline_items(payload: object) -> list[dict[str, object]]: "request_params", "require_access_token", "require_response_int", + "response_id_from_location", "response_json_mapping", "response_json_or_empty", "transport_error_from", diff --git a/glpi_python_client/_sync/clients/commons/tests/test_http.py b/glpi_python_client/_sync/clients/commons/tests/test_http.py index b34fec1..7348c7f 100644 --- a/glpi_python_client/_sync/clients/commons/tests/test_http.py +++ b/glpi_python_client/_sync/clients/commons/tests/test_http.py @@ -195,3 +195,52 @@ def test_4xx_raises_a_typed_status_error_from_ensure_response_status() -> None: assert excinfo.value.status_code == 404 assert isinstance(excinfo.value, ValueError) assert str(excinfo.value) == "Failed to fetch ticket 1: 404 nope" + + +def test_require_response_int_accepts_a_numeric_string_id() -> None: + """A quoted identifier is still an identifier. + + GLPI is PHP-backed and PHP-backed APIs commonly render integers as + strings. Rejecting one is a protocol error raised over a usable value. + """ + + response = FakeResponse(payload={"id": "4242"}) + + assert require_response_int(response, keys=("id",), missing_message="x") == 4242 + + +def test_require_response_int_ignores_a_non_numeric_string_id() -> None: + """A string that is not a number is not silently coerced.""" + + response = FakeResponse(payload={"id": "not-a-number"}) + + with pytest.raises(GlpiProtocolError): + require_response_int(response, keys=("id",), missing_message="x") + + +def test_require_response_int_reads_a_nested_data_envelope() -> None: + """An id wrapped in a ``data`` envelope is found rather than refused.""" + + response = FakeResponse(payload={"data": {"id": 4242}}) + + assert require_response_int(response, keys=("id",), missing_message="x") == 4242 + + +def test_require_response_int_falls_back_to_the_location_header() -> None: + """A 201 whose id lives only in ``Location`` still yields the id. + + A create that answers with an empty body and a resource URL is a normal + REST shape; without this the client raises a protocol error on a request + that succeeded, and the caller loses the id of a record that now exists. + """ + + response = FakeResponse( + status_code=201, + payload=None, + content=b"", + headers={ + "Location": "https://glpi.example.test/api.php/v2/Assistance/Ticket/4242" + }, + ) + + assert require_response_int(response, keys=("id",), missing_message="x") == 4242 From 1a648cbe388aa66c545f35ba38bad72bf842f35d Mon Sep 17 00:00:00 2001 From: baraline Date: Wed, 12 Aug 2026 13:02:04 +0200 Subject: [PATCH 05/21] fix: decide HTML by element name, not by stray angle brackets from_transport routed content to markdownify whenever it contained both "<" and ">". That guard is not an HTML test, and it deleted text: "use the key" became "use the key", "cmd out" became "cmd out", "if x0" became "if x0". An unknown tag's markup is dropped while its empty body is kept, so the token vanished from the middle of a sentence with nothing left to show it existed. _looks_like_html now decides on the tag *name*, against the HTML5 element set, and requires "<" to abut the name the way a parser does. Arithmetic ("2 < 3 > 1", "x <= y") never reaches the HTML path at all. The guard could not simply be removed. from_transport is wired as a BeforeValidator on the content fields, so it also runs on caller-authored Markdown on the way out; routing that through markdownify escapes it and GLPI receives literal asterisks. Both regression directions now have a test. ac is still read as markup, and always will be: "b" is both a real element and a plausible variable, and no probe reading the text alone can resolve that. Recorded in the module docstring rather than papered over. Closes #27 Co-Authored-By: Claude Opus 5 (1M context) --- glpi_python_client/content/conversion.py | 57 ++++++++++++++++++- .../content/tests/test_conversion.py | 47 +++++++++++++++ .../models/api_schema/_content.py | 17 ++++-- 3 files changed, 116 insertions(+), 5 deletions(-) diff --git a/glpi_python_client/content/conversion.py b/glpi_python_client/content/conversion.py index dce7c3d..244da56 100644 --- a/glpi_python_client/content/conversion.py +++ b/glpi_python_client/content/conversion.py @@ -6,9 +6,58 @@ from __future__ import annotations +import re + from markdown import markdown as markdown_to_html from markdownify import markdownify as html_to_markdown +#: Element names that make a ``<...>`` sequence markup rather than text. +#: +#: The HTML5 element set, which is what the parser behind ``markdownify`` +#: will actually recognise. Anything outside it -- ````, ````, +#: ```` -- parses as an *unknown* tag, whose markup is dropped +#: while its (usually empty) body is kept, so the token silently vanishes +#: from the middle of a sentence. +_HTML_ELEMENTS = frozenset( + """ + a abbr address area article aside audio b base bdi bdo blockquote body br + button canvas caption cite code col colgroup data datalist dd del details + dfn dialog div dl dt em embed fieldset figcaption figure footer form h1 h2 + h3 h4 h5 h6 head header hgroup hr html i iframe img input ins kbd label + legend li link main map mark menu meta meter nav noscript object ol optgroup + option output p param picture pre progress q rp rt ruby s samp script search + section select slot small source span strong style sub summary sup table + tbody td template textarea tfoot th thead time title tr track u ul var video + wbr + """.split() +) + +#: One candidate tag: ``<`` or `` 1`` and ``x <= y`` text: a space after ``<`` +#: means no tag, so arithmetic never reaches the HTML path in the first +#: place. +_CANDIDATE_TAG = re.compile(r"]*>") + + +def _looks_like_html(content: str) -> bool: + """Return whether ``content`` carries at least one real HTML element. + + Deciding on the element *name* rather than on the presence of angle + brackets is what separates markup from prose that merely contains + ``<`` and ``>``. It cannot separate them perfectly: ``ac`` is + genuinely ambiguous, because ``b`` is both a real element and a + plausible variable, and no probe reading the text alone can resolve + that. It resolves every case where the name is not an element at all, + which is where the silent deletions came from. + """ + + return any( + match.group(1).lower() in _HTML_ELEMENTS + for match in _CANDIDATE_TAG.finditer(content) + ) + class GlpiContentConverter: """Convert content between GLPI HTML payloads and canonical Markdown. @@ -23,12 +72,18 @@ def from_transport(value: object) -> str: Empty input stays empty, plain text is preserved, and HTML content is normalized through ``markdownify`` with the package's preferred options. + + The HTML path is taken only when :func:`_looks_like_html` finds a real + element. Both directions of that decision matter, because this method + is also wired as the inbound validator for caller-authored content: + text sent down the HTML path loses whatever the parser does not + recognise, and Markdown sent down it comes back escaped. """ content = str(value or "") if not content.strip(): return "" - if "<" not in content or ">" not in content: + if not _looks_like_html(content): return content.strip() markdown = html_to_markdown( diff --git a/glpi_python_client/content/tests/test_conversion.py b/glpi_python_client/content/tests/test_conversion.py index 5802d10..4e8e5b7 100644 --- a/glpi_python_client/content/tests/test_conversion.py +++ b/glpi_python_client/content/tests/test_conversion.py @@ -1,5 +1,7 @@ from __future__ import annotations +import pytest + from glpi_python_client.content.conversion import GlpiContentConverter @@ -11,3 +13,48 @@ def test_content_converter_uses_markdown_in_python_and_html_for_glpi() -> None: assert markdown == "Hello **world**" assert html == "

Hello world

" + + +@pytest.mark.parametrize( + "text", + [ + "use the key", + "cmd out", + "if x0", + "tempmin", + "a b", + "", + "generic in the signature", + ], +) +def test_from_transport_preserves_text_whose_tags_are_not_html(text: str) -> None: + """Angle brackets around a non-element name are text, not markup. + + ```` parses as an unknown tag, and an unknown tag's markup is + dropped while its (empty) body is kept -- so the word disappears from the + middle of a sentence with nothing to show it was ever there. + """ + + assert GlpiContentConverter.from_transport(text) == text + + +def test_from_transport_still_converts_real_html() -> None: + """Tightening the probe must not stop genuine HTML being normalised.""" + + html = "

The printer is offline.

" + + assert GlpiContentConverter.from_transport(html) == "The printer is **offline**." + + +def test_from_transport_leaves_caller_markdown_untouched() -> None: + """Markdown authored by a caller survives the inbound normaliser. + + ``from_transport`` is wired as a Pydantic ``BeforeValidator``, so it also + runs on outbound content. Anything that sends caller Markdown down the + HTML path escapes it, and the ticket reaches GLPI showing literal + asterisks. + """ + + markdown = "The printer is **offline** and 5 * 3 = 15." + + assert GlpiContentConverter.from_transport(markdown) == markdown diff --git a/glpi_python_client/models/api_schema/_content.py b/glpi_python_client/models/api_schema/_content.py index 6110af4..61ea729 100644 --- a/glpi_python_client/models/api_schema/_content.py +++ b/glpi_python_client/models/api_schema/_content.py @@ -16,10 +16,19 @@ the Markdown value is rendered back to HTML so GLPI receives the format it expects. -Plain-text content (no ``<...>`` markup) is preserved verbatim on the -inbound path and rendered as HTML paragraphs on the outbound path, matching -the converter's default behaviour. ``None`` values are passed through -unchanged so optional fields and ``exclude_none`` semantics keep working. +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. + +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. """ from __future__ import annotations From 57fdcdbd8e780fca5061660a85d6f96978e323b0 Mon Sep 17 00:00:00 2001 From: baraline Date: Wed, 12 Aug 2026 13:04:22 +0200 Subject: [PATCH 06/21] fix: keep fenced code, tables and prose punctuation intact to_transport rendered with only nl2br and sane_lists, so a fenced code block became inline : the GLPI web UI showed a pasted log as one run-on line, and the next read wrote it back as inline code, degrading a little more on every edit. A Markdown table rendered as literal pipes. fenced_code and tables fix both. markdownify also escaped underscores and asterisks in prose, so every read turned snake_case into snake\_case and the backslash accumulated across read-modify-write cycles. Both escapes are off now. A language tag is still lost -- markdownify drops the class="language-python" that fenced_code emits -- which is a limitation of the library pair rather than of the extension list. Recorded next to the list instead of left to be rediscovered. Closes #24 Co-Authored-By: Claude Opus 5 (1M context) --- glpi_python_client/content/conversion.py | 19 ++++++- .../content/tests/test_conversion.py | 50 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/glpi_python_client/content/conversion.py b/glpi_python_client/content/conversion.py index 244da56..bacfeb4 100644 --- a/glpi_python_client/content/conversion.py +++ b/glpi_python_client/content/conversion.py @@ -32,6 +32,21 @@ """.split() ) +#: python-markdown extensions applied when rendering outbound content. +#: +#: ``fenced_code`` and ``tables`` are here because without them the two +#: constructs do not survive at all. A fence rendered without +#: ``fenced_code`` becomes inline ````, which the GLPI web UI shows +#: as one run-on line and which a later read writes back as inline code -- +#: so a pasted log degrades a little more on every edit. A table without +#: ``tables`` renders as literal pipe characters. +#: +#: A language tag is still lost: ``markdownify`` drops the +#: ``class="language-python"`` that ``fenced_code`` emits, so ```` ```python ```` +#: comes back as a bare fence. That is a limitation of the pair of +#: libraries, not something an extension list can fix. +_MARKDOWN_EXTENSIONS = ["nl2br", "sane_lists", "fenced_code", "tables"] + #: One candidate tag: ``<`` or `` str: heading_style="ATX", bullets="-", strip=["script", "style"], + escape_underscores=False, + escape_asterisks=False, ) return str(markdown).strip() @@ -107,7 +124,7 @@ def to_transport(value: object) -> str: return "" html = markdown_to_html( markdown, - extensions=["nl2br", "sane_lists"], + extensions=_MARKDOWN_EXTENSIONS, output_format="html5", ) return str(html).strip() diff --git a/glpi_python_client/content/tests/test_conversion.py b/glpi_python_client/content/tests/test_conversion.py index 4e8e5b7..af9ff67 100644 --- a/glpi_python_client/content/tests/test_conversion.py +++ b/glpi_python_client/content/tests/test_conversion.py @@ -58,3 +58,53 @@ def test_from_transport_leaves_caller_markdown_untouched() -> None: markdown = "The printer is **offline** and 5 * 3 = 15." assert GlpiContentConverter.from_transport(markdown) == markdown + + +def test_fenced_code_block_survives_the_round_trip() -> None: + """A fence stays a fence. Pasted logs are the common case for this.""" + + markdown = "```\nblock\n```" + + assert ( + GlpiContentConverter.from_transport(GlpiContentConverter.to_transport(markdown)) + == markdown + ) + + +def test_fenced_code_block_renders_as_a_pre_block() -> None: + """Outbound, a fence becomes ``
`` rather than inline code.
+
+    Inline ```` is what collapsed a multi-line log into one line in the
+    GLPI web UI, and what a read-modify-write then wrote back as inline code.
+    """
+
+    assert GlpiContentConverter.to_transport("```\nblock\n```") == (
+        "
block\n
" + ) + + +def test_table_survives_the_round_trip() -> None: + """A Markdown table stays a table instead of degrading to text.""" + + rendered = GlpiContentConverter.from_transport( + GlpiContentConverter.to_transport("| a | b |\n| - | - |\n| 1 | 2 |") + ) + + assert rendered == "| a | b |\n| --- | --- |\n| 1 | 2 |" + + +@pytest.mark.parametrize( + ("html", "expected"), + [ + ("

snake_case name

", "snake_case name"), + ("

5 * 3 = 15

", "5 * 3 = 15"), + ], +) +def test_incoming_text_is_not_backslash_escaped(html: str, expected: str) -> None: + """Underscores and asterisks in prose stay readable. + + Escaping them turns ``snake_case`` into ``snake\_case`` on every read, + and the backslash accumulates across read-modify-write cycles. + """ + + assert GlpiContentConverter.from_transport(html) == expected From 053cfcd25d5534bb651e9de84acab701f1ce1ea4 Mon Sep 17 00:00:00 2001 From: baraline Date: Wed, 12 Aug 2026 13:06:26 +0200 Subject: [PATCH 07/21] test: inventory the content round trip as strict xfails The only round-trip assertion was one hand-picked single-line string, which structurally could not exercise any of the module's losses. This adds a 21-case corpus. It is an inventory, not a property test, because from_transport(to_transport(m)) == m does not hold universally and cannot: markdownify and python-markdown disagree about nested-list indentation and about the language class on a fence, and neither has an option that resolves it. The four known losses carry xfail(strict=True) so fixing one turns into an XPASS and fails the suite. That is the point -- the inventory has to be updated deliberately rather than drifting out of date, and a regression in any of the 17 passing cases fails immediately. Refs #32 Co-Authored-By: Claude Opus 5 (1M context) --- .../testing/tests/test_content_roundtrip.py | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/glpi_python_client/testing/tests/test_content_roundtrip.py b/glpi_python_client/testing/tests/test_content_roundtrip.py index b05e65d..817aeb0 100644 --- a/glpi_python_client/testing/tests/test_content_roundtrip.py +++ b/glpi_python_client/testing/tests/test_content_roundtrip.py @@ -100,3 +100,90 @@ def test_outgoing_empty_string_renders_empty() -> None: payload = model_to_payload(PostTicket(content="")) assert payload["content"] == "" + + +# --------------------------------------------------------------------------- +# 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. +# +# 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. + + +def _lossy(reason: str) -> pytest.MarkDecorator: + """Mark one corpus entry as a known, recorded round-trip loss.""" + + return pytest.mark.xfail(strict=True, reason=reason) + + +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( + "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." + ), + ), + 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." + ), + ), + 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." + ), + ), + pytest.param( + "use the key", + 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." + ), + ), +] + + +@pytest.mark.parametrize("markdown", ROUND_TRIP_CORPUS) +def test_round_trip_corpus(markdown: str) -> 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 From 505818a628946fc084b6740604c7b0a3dc2230a1 Mon Sep 17 00:00:00 2001 From: baraline Date: Wed, 12 Aug 2026 13:11:04 +0200 Subject: [PATCH 08/21] docs: record that tolerant searches swallow 4xx and end iteration search_tickets and its six siblings pass no failure_message to _resource_list, so a 400/401/403/404 is logged and returns []. That is a deliberate decision (0.4.0 plan-1, D2), not an oversight, and reversing it would flip seven endpoints from "return []" to "raise" -- so this records the contract rather than changing it. Verified against a mock transport: 400, 401, 403 and 404 all return []; 500 raises GlpiServerError. The issue's claim that 5xx is swallowed too was wrong. The consequence worth documenting is that it does not compose with the batch iterators: they stop on a page shorter than batch_size, so a 403 on page one ends the walk having yielded nothing and the caller sees a successful empty result. Noted on the three iterators and on the shared helper, which is where someone reading an empty result would look. Refs #28 Co-Authored-By: Claude Opus 5 (1M context) --- .../_async/clients/api/administration/_entity.py | 9 +++++++++ .../_async/clients/api/administration/_user.py | 9 +++++++++ .../_async/clients/api/assistance/_ticket.py | 9 +++++++++ .../_async/clients/commons/_transport.py | 15 +++++++++++++++ .../_sync/clients/api/administration/_entity.py | 9 +++++++++ .../_sync/clients/api/administration/_user.py | 9 +++++++++ .../_sync/clients/api/assistance/_ticket.py | 9 +++++++++ .../_sync/clients/commons/_transport.py | 15 +++++++++++++++ 8 files changed, 84 insertions(+) diff --git a/glpi_python_client/_async/clients/api/administration/_entity.py b/glpi_python_client/_async/clients/api/administration/_entity.py index 0a4242e..dccb843 100644 --- a/glpi_python_client/_async/clients/api/administration/_entity.py +++ b/glpi_python_client/_async/clients/api/administration/_entity.py @@ -78,6 +78,15 @@ async def iter_search_entities( batch_size : int, optional Number of records requested per page (default 50). + Notes + ----- + A 4xx response is swallowed by the underlying search helper, which + returns ``[]``. Because iteration stops on a page shorter than + ``batch_size``, a 4xx on the first page ends the walk having yielded + nothing -- indistinguishable from a filter that matched nothing. + Check the caller's permissions and entity scope before reading an + empty walk as an empty result set. 5xx still raises. + Yields ------ list[GetEntity] diff --git a/glpi_python_client/_async/clients/api/administration/_user.py b/glpi_python_client/_async/clients/api/administration/_user.py index 57e6fbc..af6957e 100644 --- a/glpi_python_client/_async/clients/api/administration/_user.py +++ b/glpi_python_client/_async/clients/api/administration/_user.py @@ -89,6 +89,15 @@ async def iter_search_users( When ``True`` the ``GLPI-Entity`` header is omitted so the search spans every entity the caller has access to. + Notes + ----- + A 4xx response is swallowed by the underlying search helper, which + returns ``[]``. Because iteration stops on a page shorter than + ``batch_size``, a 4xx on the first page ends the walk having yielded + nothing -- indistinguishable from a filter that matched nothing. + Check the caller's permissions and entity scope before reading an + empty walk as an empty result set. 5xx still raises. + Yields ------ list[GetUser] diff --git a/glpi_python_client/_async/clients/api/assistance/_ticket.py b/glpi_python_client/_async/clients/api/assistance/_ticket.py index e080333..9125e63 100644 --- a/glpi_python_client/_async/clients/api/assistance/_ticket.py +++ b/glpi_python_client/_async/clients/api/assistance/_ticket.py @@ -98,6 +98,15 @@ async def iter_search_tickets( fields : tuple[str, ...], optional Restricted set of contract field names to request. + Notes + ----- + A 4xx response is swallowed by the underlying search helper, which + returns ``[]``. Because iteration stops on a page shorter than + ``batch_size``, a 4xx on the first page ends the walk having yielded + nothing -- indistinguishable from a filter that matched nothing. + Check the caller's permissions and entity scope before reading an + empty walk as an empty result set. 5xx still raises. + Yields ------ list[GetTicket] diff --git a/glpi_python_client/_async/clients/commons/_transport.py b/glpi_python_client/_async/clients/commons/_transport.py index 4303eea..a43450f 100644 --- a/glpi_python_client/_async/clients/commons/_transport.py +++ b/glpi_python_client/_async/clients/commons/_transport.py @@ -357,6 +357,21 @@ async def _resource_list( When provided, response status is checked with this message; search-style endpoints that tolerate empty results pass ``None``. + + Passing ``None`` means a **4xx is swallowed**: the status is + logged and this returns ``[]``, so a 400, 401, 403 or 404 is + indistinguishable from a search that legitimately matched + nothing. 5xx still raises + :class:`~glpi_python_client.GlpiServerError`, from + :func:`~glpi_python_client._async.clients.commons._http.finalize_request_response`. + + That is deliberate (0.4.0 plan-1, decision D2) rather than an + oversight, and it is load-bearing for the seven tolerant + search call sites. It is worth knowing about because it does + not compose well: the batch iterators stop when a page comes + back shorter than ``batch_size``, so a 403 on the first page + ends iteration having yielded nothing at all, and the caller + sees a successful empty walk. success_statuses : tuple[int, ...], optional HTTP status codes considered successful when ``failure_message`` is set. diff --git a/glpi_python_client/_sync/clients/api/administration/_entity.py b/glpi_python_client/_sync/clients/api/administration/_entity.py index 3301c4e..2afc439 100644 --- a/glpi_python_client/_sync/clients/api/administration/_entity.py +++ b/glpi_python_client/_sync/clients/api/administration/_entity.py @@ -78,6 +78,15 @@ def iter_search_entities( batch_size : int, optional Number of records requested per page (default 50). + Notes + ----- + A 4xx response is swallowed by the underlying search helper, which + returns ``[]``. Because iteration stops on a page shorter than + ``batch_size``, a 4xx on the first page ends the walk having yielded + nothing -- indistinguishable from a filter that matched nothing. + Check the caller's permissions and entity scope before reading an + empty walk as an empty result set. 5xx still raises. + Yields ------ list[GetEntity] diff --git a/glpi_python_client/_sync/clients/api/administration/_user.py b/glpi_python_client/_sync/clients/api/administration/_user.py index 2a9cb19..462c5bc 100644 --- a/glpi_python_client/_sync/clients/api/administration/_user.py +++ b/glpi_python_client/_sync/clients/api/administration/_user.py @@ -89,6 +89,15 @@ def iter_search_users( When ``True`` the ``GLPI-Entity`` header is omitted so the search spans every entity the caller has access to. + Notes + ----- + A 4xx response is swallowed by the underlying search helper, which + returns ``[]``. Because iteration stops on a page shorter than + ``batch_size``, a 4xx on the first page ends the walk having yielded + nothing -- indistinguishable from a filter that matched nothing. + Check the caller's permissions and entity scope before reading an + empty walk as an empty result set. 5xx still raises. + Yields ------ list[GetUser] diff --git a/glpi_python_client/_sync/clients/api/assistance/_ticket.py b/glpi_python_client/_sync/clients/api/assistance/_ticket.py index 546d6ea..2d3a2c5 100644 --- a/glpi_python_client/_sync/clients/api/assistance/_ticket.py +++ b/glpi_python_client/_sync/clients/api/assistance/_ticket.py @@ -98,6 +98,15 @@ def iter_search_tickets( fields : tuple[str, ...], optional Restricted set of contract field names to request. + Notes + ----- + A 4xx response is swallowed by the underlying search helper, which + returns ``[]``. Because iteration stops on a page shorter than + ``batch_size``, a 4xx on the first page ends the walk having yielded + nothing -- indistinguishable from a filter that matched nothing. + Check the caller's permissions and entity scope before reading an + empty walk as an empty result set. 5xx still raises. + Yields ------ list[GetTicket] diff --git a/glpi_python_client/_sync/clients/commons/_transport.py b/glpi_python_client/_sync/clients/commons/_transport.py index ec24d51..6f656f6 100644 --- a/glpi_python_client/_sync/clients/commons/_transport.py +++ b/glpi_python_client/_sync/clients/commons/_transport.py @@ -357,6 +357,21 @@ def _resource_list( When provided, response status is checked with this message; search-style endpoints that tolerate empty results pass ``None``. + + Passing ``None`` means a **4xx is swallowed**: the status is + logged and this returns ``[]``, so a 400, 401, 403 or 404 is + indistinguishable from a search that legitimately matched + nothing. 5xx still raises + :class:`~glpi_python_client.GlpiServerError`, from + :func:`~glpi_python_client._sync.clients.commons._http.finalize_request_response`. + + That is deliberate (0.4.0 plan-1, decision D2) rather than an + oversight, and it is load-bearing for the seven tolerant + search call sites. It is worth knowing about because it does + not compose well: the batch iterators stop when a page comes + back shorter than ``batch_size``, so a 403 on the first page + ends iteration having yielded nothing at all, and the caller + sees a successful empty walk. success_statuses : tuple[int, ...], optional HTTP status codes considered successful when ``failure_message`` is set. From 0d2ca6907269a28d1b52d2d113afbad39298ff42 Mon Sep 17 00:00:00 2001 From: baraline Date: Wed, 12 Aug 2026 13:19:17 +0200 Subject: [PATCH 09/21] feat: add batch iterators for KB articles, categories, documents, locations Three of the seven searchable resources had a batch iterator; four did not, so callers of those four drove the start cursor by hand. The four new helpers copy the existing loop and forward their own resource's options (sort and language for the two knowledge base resources, nothing extra for documents and locations). Duplicating the eight-line loop is deliberate. Factoring it onto TransportMixin was tried: the Callable[..., Awaitable[...]] spelling generates type-broken sync code, and the Protocol alternative only type-checks with an invariant TypeVar. The loop was already written three times; four more copies beat a generic indirection here. Also corrects a skill claim that was already false before this change: glpi-ticket-workflow said "There is no batch iterator" while iter_search_tickets has existed for some time. The knowledge base and plugin-fields skills live only on docs/glpi-skills-refresh, which is not merged, so the two new KB iterators still need a mention there when that branch lands. Closes #29 Co-Authored-By: Claude Opus 5 (1M context) --- .../_async/clients/api/dropdowns/_location.py | 53 ++++++++++++ .../api/dropdowns/tests/test_location.py | 61 ++++++++++++++ .../clients/api/knowledgebase/_article.py | 62 +++++++++++++- .../clients/api/knowledgebase/_category.py | 62 ++++++++++++++ .../api/knowledgebase/tests/test_article.py | 84 +++++++++++++++++++ .../api/knowledgebase/tests/test_category.py | 84 +++++++++++++++++++ .../clients/api/management/_document.py | 52 ++++++++++++ .../api/management/tests/test_document.py | 61 ++++++++++++++ .../_sync/clients/api/dropdowns/_location.py | 53 ++++++++++++ .../api/dropdowns/tests/test_location.py | 61 ++++++++++++++ .../clients/api/knowledgebase/_article.py | 62 +++++++++++++- .../clients/api/knowledgebase/_category.py | 62 ++++++++++++++ .../api/knowledgebase/tests/test_article.py | 84 +++++++++++++++++++ .../api/knowledgebase/tests/test_category.py | 84 +++++++++++++++++++ .../_sync/clients/api/management/_document.py | 52 ++++++++++++ .../api/management/tests/test_document.py | 61 ++++++++++++++ skills/glpi-document-workflow/SKILL.md | 2 +- skills/glpi-reporting-and-context/SKILL.md | 4 +- skills/glpi-ticket-workflow/SKILL.md | 2 +- .../glpi-user-location-provisioning/SKILL.md | 5 +- 20 files changed, 1043 insertions(+), 8 deletions(-) diff --git a/glpi_python_client/_async/clients/api/dropdowns/_location.py b/glpi_python_client/_async/clients/api/dropdowns/_location.py index 8cc9916..5c7e129 100644 --- a/glpi_python_client/_async/clients/api/dropdowns/_location.py +++ b/glpi_python_client/_async/clients/api/dropdowns/_location.py @@ -7,6 +7,8 @@ from __future__ import annotations +from collections.abc import AsyncIterator + from glpi_python_client._async.clients.commons._constants import ( LOCATION_ENDPOINT, GlpiId, @@ -52,6 +54,57 @@ async def search_locations( params["filter"] = rsql_filter return await self._resource_list(LOCATION_ENDPOINT, GetLocation, params=params) + async def iter_search_locations( + self, + rsql_filter: str = "", + *, + batch_size: int = 50, + ) -> AsyncIterator[list[GetLocation]]: + """Yield successive pages of GLPI locations until exhausted. + + The generator drives pagination automatically by advancing the + ``start`` offset after each batch. Iteration stops when the server + returns fewer items than ``batch_size``, which signals the last page. + + Parameters + ---------- + rsql_filter : str, optional + Raw RSQL filter forwarded as the ``filter`` query parameter. + Empty by default, which lists every visible record. + batch_size : int, optional + Number of records requested per page (default 50). Acts as the + ``limit`` parameter on each underlying :meth:`search_locations` + call. + + Notes + ----- + A 4xx response is swallowed by the underlying search helper, which + returns ``[]``. Because iteration stops on a page shorter than + ``batch_size``, a 4xx on the first page ends the walk having yielded + nothing -- indistinguishable from a filter that matched nothing. + Check the caller's permissions and entity scope before reading an + empty walk as an empty result set. 5xx still raises. + + Yields + ------ + list[GetLocation] + One page per iteration. The last yielded batch may be shorter + than ``batch_size``. + """ + + start = 0 + while True: + batch = await self.search_locations( + rsql_filter, + limit=batch_size, + start=start, + ) + if batch: + yield batch + if len(batch) < batch_size: + break + start += batch_size + async def get_location(self, location_id: GlpiId) -> GetLocation: """Fetch one GLPI location by identifier. diff --git a/glpi_python_client/_async/clients/api/dropdowns/tests/test_location.py b/glpi_python_client/_async/clients/api/dropdowns/tests/test_location.py index bff93c9..d5d7957 100644 --- a/glpi_python_client/_async/clients/api/dropdowns/tests/test_location.py +++ b/glpi_python_client/_async/clients/api/dropdowns/tests/test_location.py @@ -126,3 +126,64 @@ async def test_delete_helpers_raise_on_failure_status( FailingTransportRecorder(500).install(client) with pytest.raises(ValueError): await call(client) + + +async def test_iter_search_locations_yields_every_page(client: Any) -> None: + """The generator advances ``start`` until a short page ends the walk.""" + + from glpi_python_client.models.api_schema.dropdowns import GetLocation + + pages = [[GetLocation(id=i) for i in range(3)], [GetLocation(id=99)]] + starts: list[int] = [] + + async def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0 + ) -> list[GetLocation]: + starts.append(start) + index = start // limit + return pages[index] if index < len(pages) else [] + + client.search_locations = fake_search # type: ignore[method-assign] + + batches = [ + batch async for batch in client.iter_search_locations("name==x", batch_size=3) + ] + + assert starts == [0, 3] + assert [len(b) for b in batches] == [3, 1] + + +async def test_iter_search_locations_stops_on_a_single_short_page(client: Any) -> None: + """One short page is the last page; no second request is made.""" + + from glpi_python_client.models.api_schema.dropdowns import GetLocation + + starts: list[int] = [] + + async def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0 + ) -> list[GetLocation]: + starts.append(start) + return [GetLocation(id=1)] + + client.search_locations = fake_search # type: ignore[method-assign] + + batches = [batch async for batch in client.iter_search_locations(batch_size=50)] + + assert starts == [0] + assert len(batches) == 1 + + +async def test_iter_search_locations_yields_nothing_when_empty(client: Any) -> None: + """An empty first page yields no batch at all rather than one empty list.""" + + from glpi_python_client.models.api_schema.dropdowns import GetLocation + + async def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0 + ) -> list[GetLocation]: + return [] + + client.search_locations = fake_search # type: ignore[method-assign] + + assert [batch async for batch in client.iter_search_locations()] == [] diff --git a/glpi_python_client/_async/clients/api/knowledgebase/_article.py b/glpi_python_client/_async/clients/api/knowledgebase/_article.py index 19dac76..8af88f1 100644 --- a/glpi_python_client/_async/clients/api/knowledgebase/_article.py +++ b/glpi_python_client/_async/clients/api/knowledgebase/_article.py @@ -8,7 +8,7 @@ from __future__ import annotations -from collections.abc import Sequence +from collections.abc import AsyncIterator, Sequence from glpi_python_client._async.clients.commons._constants import ( KB_ARTICLE_ENDPOINT, @@ -72,6 +72,66 @@ async def search_kb_articles( KB_ARTICLE_ENDPOINT, GetKBArticle, params=params ) + async def iter_search_kb_articles( + self, + rsql_filter: str = "", + *, + batch_size: int = 50, + sort: str | None = None, + language: str | None = None, + ) -> AsyncIterator[list[GetKBArticle]]: + """Yield successive pages of GLPI knowledge base articles until exhausted. + + The generator drives pagination automatically by advancing the + ``start`` offset after each batch. Iteration stops when the server + returns fewer items than ``batch_size``, which signals the last page. + + Parameters + ---------- + rsql_filter : str, optional + Raw RSQL filter forwarded as the ``filter`` query parameter. + Empty by default, which lists every visible record. + batch_size : int, optional + Number of records requested per page (default 50). Acts as the + ``limit`` parameter on each underlying :meth:`search_kb_articles` + call. + sort : str | None, optional + ``sort`` query parameter forwarded as-is to each page request. + language : str | None, optional + GLPI language code forwarded to each page request to select + a translated view. + + Notes + ----- + A 4xx response is swallowed by the underlying search helper, which + returns ``[]``. Because iteration stops on a page shorter than + ``batch_size``, a 4xx on the first page ends the walk having yielded + nothing -- indistinguishable from a filter that matched nothing. + Check the caller's permissions and entity scope before reading an + empty walk as an empty result set. 5xx still raises. + + Yields + ------ + list[GetKBArticle] + One page per iteration. The last yielded batch may be shorter + than ``batch_size``. + """ + + start = 0 + while True: + batch = await self.search_kb_articles( + rsql_filter, + limit=batch_size, + start=start, + sort=sort, + language=language, + ) + if batch: + yield batch + if len(batch) < batch_size: + break + start += batch_size + async def get_kb_article(self, article_id: GlpiId) -> GetKBArticle: """Fetch one knowledge base article by identifier. diff --git a/glpi_python_client/_async/clients/api/knowledgebase/_category.py b/glpi_python_client/_async/clients/api/knowledgebase/_category.py index c1a063b..3b20ce7 100644 --- a/glpi_python_client/_async/clients/api/knowledgebase/_category.py +++ b/glpi_python_client/_async/clients/api/knowledgebase/_category.py @@ -7,6 +7,8 @@ from __future__ import annotations +from collections.abc import AsyncIterator + from glpi_python_client._async.clients.commons._constants import ( KB_CATEGORY_ENDPOINT, GlpiId, @@ -65,6 +67,66 @@ async def search_kb_categories( KB_CATEGORY_ENDPOINT, GetKBCategory, params=params ) + async def iter_search_kb_categories( + self, + rsql_filter: str = "", + *, + batch_size: int = 50, + sort: str | None = None, + language: str | None = None, + ) -> AsyncIterator[list[GetKBCategory]]: + """Yield successive pages of GLPI knowledge base categories until exhausted. + + The generator drives pagination automatically by advancing the + ``start`` offset after each batch. Iteration stops when the server + returns fewer items than ``batch_size``, which signals the last page. + + Parameters + ---------- + rsql_filter : str, optional + Raw RSQL filter forwarded as the ``filter`` query parameter. + Empty by default, which lists every visible record. + batch_size : int, optional + Number of records requested per page (default 50). Acts as the + ``limit`` parameter on each underlying :meth:`search_kb_categories` + call. + sort : str | None, optional + ``sort`` query parameter forwarded as-is to each page request. + language : str | None, optional + GLPI language code forwarded to each page request to select + a translated view. + + Notes + ----- + A 4xx response is swallowed by the underlying search helper, which + returns ``[]``. Because iteration stops on a page shorter than + ``batch_size``, a 4xx on the first page ends the walk having yielded + nothing -- indistinguishable from a filter that matched nothing. + Check the caller's permissions and entity scope before reading an + empty walk as an empty result set. 5xx still raises. + + Yields + ------ + list[GetKBCategory] + One page per iteration. The last yielded batch may be shorter + than ``batch_size``. + """ + + start = 0 + while True: + batch = await self.search_kb_categories( + rsql_filter, + limit=batch_size, + start=start, + sort=sort, + language=language, + ) + if batch: + yield batch + if len(batch) < batch_size: + break + start += batch_size + async def get_kb_category(self, category_id: GlpiId) -> GetKBCategory: """Fetch one knowledge base category by identifier. diff --git a/glpi_python_client/_async/clients/api/knowledgebase/tests/test_article.py b/glpi_python_client/_async/clients/api/knowledgebase/tests/test_article.py index 4a22076..861ac72 100644 --- a/glpi_python_client/_async/clients/api/knowledgebase/tests/test_article.py +++ b/glpi_python_client/_async/clients/api/knowledgebase/tests/test_article.py @@ -345,3 +345,87 @@ async def test_delete_helpers_raise_on_failure( FailingTransportRecorder(500).install(client) with pytest.raises(ValueError): await call(client) + + +async def test_iter_search_kb_articles_yields_every_page(client: Any) -> None: + """The generator advances ``start`` until a short page ends the walk.""" + + from glpi_python_client.models.api_schema.knowledgebase import GetKBArticle + + pages = [[GetKBArticle(id=i) for i in range(3)], [GetKBArticle(id=99)]] + starts: list[int] = [] + forwarded: dict[str, Any] = {} + + async def fake_search( + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + sort: str | None = None, + language: str | None = None, + ) -> list[GetKBArticle]: + starts.append(start) + forwarded["sort"] = sort + index = start // limit + return pages[index] if index < len(pages) else [] + + client.search_kb_articles = fake_search # type: ignore[method-assign] + + batches = [ + batch + async for batch in client.iter_search_kb_articles( + "name==x", batch_size=3, sort="name asc" + ) + ] + + assert starts == [0, 3] + assert [len(b) for b in batches] == [3, 1] + assert forwarded["sort"] == "name asc" + + +async def test_iter_search_kb_articles_stops_on_a_single_short_page( + client: Any, +) -> None: + """One short page is the last page; no second request is made.""" + + from glpi_python_client.models.api_schema.knowledgebase import GetKBArticle + + starts: list[int] = [] + + async def fake_search( + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + sort: str | None = None, + language: str | None = None, + ) -> list[GetKBArticle]: + starts.append(start) + return [GetKBArticle(id=1)] + + client.search_kb_articles = fake_search # type: ignore[method-assign] + + batches = [batch async for batch in client.iter_search_kb_articles(batch_size=50)] + + assert starts == [0] + assert len(batches) == 1 + + +async def test_iter_search_kb_articles_yields_nothing_when_empty(client: Any) -> None: + """An empty first page yields no batch at all rather than one empty list.""" + + from glpi_python_client.models.api_schema.knowledgebase import GetKBArticle + + async def fake_search( + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + sort: str | None = None, + language: str | None = None, + ) -> list[GetKBArticle]: + return [] + + client.search_kb_articles = fake_search # type: ignore[method-assign] + + assert [batch async for batch in client.iter_search_kb_articles()] == [] diff --git a/glpi_python_client/_async/clients/api/knowledgebase/tests/test_category.py b/glpi_python_client/_async/clients/api/knowledgebase/tests/test_category.py index 4fe4cea..0f823fe 100644 --- a/glpi_python_client/_async/clients/api/knowledgebase/tests/test_category.py +++ b/glpi_python_client/_async/clients/api/knowledgebase/tests/test_category.py @@ -183,3 +183,87 @@ async def test_delete_helpers_raise_on_failure( FailingTransportRecorder(500).install(client) with pytest.raises(ValueError): await call(client) + + +async def test_iter_search_kb_categories_yields_every_page(client: Any) -> None: + """The generator advances ``start`` until a short page ends the walk.""" + + from glpi_python_client.models.api_schema.knowledgebase import GetKBCategory + + pages = [[GetKBCategory(id=i) for i in range(3)], [GetKBCategory(id=99)]] + starts: list[int] = [] + forwarded: dict[str, Any] = {} + + async def fake_search( + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + sort: str | None = None, + language: str | None = None, + ) -> list[GetKBCategory]: + starts.append(start) + forwarded["language"] = language + index = start // limit + return pages[index] if index < len(pages) else [] + + client.search_kb_categories = fake_search # type: ignore[method-assign] + + batches = [ + batch + async for batch in client.iter_search_kb_categories( + "name==x", batch_size=3, language="fr_FR" + ) + ] + + assert starts == [0, 3] + assert [len(b) for b in batches] == [3, 1] + assert forwarded["language"] == "fr_FR" + + +async def test_iter_search_kb_categories_stops_on_a_single_short_page( + client: Any, +) -> None: + """One short page is the last page; no second request is made.""" + + from glpi_python_client.models.api_schema.knowledgebase import GetKBCategory + + starts: list[int] = [] + + async def fake_search( + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + sort: str | None = None, + language: str | None = None, + ) -> list[GetKBCategory]: + starts.append(start) + return [GetKBCategory(id=1)] + + client.search_kb_categories = fake_search # type: ignore[method-assign] + + batches = [batch async for batch in client.iter_search_kb_categories(batch_size=50)] + + assert starts == [0] + assert len(batches) == 1 + + +async def test_iter_search_kb_categories_yields_nothing_when_empty(client: Any) -> None: + """An empty first page yields no batch at all rather than one empty list.""" + + from glpi_python_client.models.api_schema.knowledgebase import GetKBCategory + + async def fake_search( + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + sort: str | None = None, + language: str | None = None, + ) -> list[GetKBCategory]: + return [] + + client.search_kb_categories = fake_search # type: ignore[method-assign] + + assert [batch async for batch in client.iter_search_kb_categories()] == [] diff --git a/glpi_python_client/_async/clients/api/management/_document.py b/glpi_python_client/_async/clients/api/management/_document.py index 11a32bb..4231f4e 100644 --- a/glpi_python_client/_async/clients/api/management/_document.py +++ b/glpi_python_client/_async/clients/api/management/_document.py @@ -8,6 +8,7 @@ from __future__ import annotations import logging +from collections.abc import AsyncIterator from glpi_python_client._async.clients.commons._constants import ( DOCUMENT_ENDPOINT, @@ -63,6 +64,57 @@ async def search_documents( DOCUMENT_ENDPOINT, GetDocument, params=params, skip_entity=True ) + async def iter_search_documents( + self, + rsql_filter: str = "", + *, + batch_size: int = 50, + ) -> AsyncIterator[list[GetDocument]]: + """Yield successive pages of GLPI documents until exhausted. + + The generator drives pagination automatically by advancing the + ``start`` offset after each batch. Iteration stops when the server + returns fewer items than ``batch_size``, which signals the last page. + + Parameters + ---------- + rsql_filter : str, optional + Raw RSQL filter forwarded as the ``filter`` query parameter. + Empty by default, which lists every visible record. + batch_size : int, optional + Number of records requested per page (default 50). Acts as the + ``limit`` parameter on each underlying :meth:`search_documents` + call. + + Notes + ----- + A 4xx response is swallowed by the underlying search helper, which + returns ``[]``. Because iteration stops on a page shorter than + ``batch_size``, a 4xx on the first page ends the walk having yielded + nothing -- indistinguishable from a filter that matched nothing. + Check the caller's permissions and entity scope before reading an + empty walk as an empty result set. 5xx still raises. + + Yields + ------ + list[GetDocument] + One page per iteration. The last yielded batch may be shorter + than ``batch_size``. + """ + + start = 0 + while True: + batch = await self.search_documents( + rsql_filter, + limit=batch_size, + start=start, + ) + if batch: + yield batch + if len(batch) < batch_size: + break + start += batch_size + async def get_document(self, document_id: GlpiId) -> GetDocument: """Fetch one GLPI document by identifier. diff --git a/glpi_python_client/_async/clients/api/management/tests/test_document.py b/glpi_python_client/_async/clients/api/management/tests/test_document.py index 4a1bd31..c3c9027 100644 --- a/glpi_python_client/_async/clients/api/management/tests/test_document.py +++ b/glpi_python_client/_async/clients/api/management/tests/test_document.py @@ -224,3 +224,64 @@ async def test_delete_helpers_raise_on_failure_status( FailingTransportRecorder(500).install(client) with pytest.raises(ValueError): await call(client) + + +async def test_iter_search_documents_yields_every_page(client: Any) -> None: + """The generator advances ``start`` until a short page ends the walk.""" + + from glpi_python_client.models.api_schema.management import GetDocument + + pages = [[GetDocument(id=i) for i in range(3)], [GetDocument(id=99)]] + starts: list[int] = [] + + async def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0 + ) -> list[GetDocument]: + starts.append(start) + index = start // limit + return pages[index] if index < len(pages) else [] + + client.search_documents = fake_search # type: ignore[method-assign] + + batches = [ + batch async for batch in client.iter_search_documents("name==x", batch_size=3) + ] + + assert starts == [0, 3] + assert [len(b) for b in batches] == [3, 1] + + +async def test_iter_search_documents_stops_on_a_single_short_page(client: Any) -> None: + """One short page is the last page; no second request is made.""" + + from glpi_python_client.models.api_schema.management import GetDocument + + starts: list[int] = [] + + async def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0 + ) -> list[GetDocument]: + starts.append(start) + return [GetDocument(id=1)] + + client.search_documents = fake_search # type: ignore[method-assign] + + batches = [batch async for batch in client.iter_search_documents(batch_size=50)] + + assert starts == [0] + assert len(batches) == 1 + + +async def test_iter_search_documents_yields_nothing_when_empty(client: Any) -> None: + """An empty first page yields no batch at all rather than one empty list.""" + + from glpi_python_client.models.api_schema.management import GetDocument + + async def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0 + ) -> list[GetDocument]: + return [] + + client.search_documents = fake_search # type: ignore[method-assign] + + assert [batch async for batch in client.iter_search_documents()] == [] diff --git a/glpi_python_client/_sync/clients/api/dropdowns/_location.py b/glpi_python_client/_sync/clients/api/dropdowns/_location.py index 99c1f1f..a14476a 100644 --- a/glpi_python_client/_sync/clients/api/dropdowns/_location.py +++ b/glpi_python_client/_sync/clients/api/dropdowns/_location.py @@ -7,6 +7,8 @@ from __future__ import annotations +from collections.abc import Iterator + from glpi_python_client._sync.clients.commons._constants import ( LOCATION_ENDPOINT, GlpiId, @@ -52,6 +54,57 @@ def search_locations( params["filter"] = rsql_filter return self._resource_list(LOCATION_ENDPOINT, GetLocation, params=params) + def iter_search_locations( + self, + rsql_filter: str = "", + *, + batch_size: int = 50, + ) -> Iterator[list[GetLocation]]: + """Yield successive pages of GLPI locations until exhausted. + + The generator drives pagination automatically by advancing the + ``start`` offset after each batch. Iteration stops when the server + returns fewer items than ``batch_size``, which signals the last page. + + Parameters + ---------- + rsql_filter : str, optional + Raw RSQL filter forwarded as the ``filter`` query parameter. + Empty by default, which lists every visible record. + batch_size : int, optional + Number of records requested per page (default 50). Acts as the + ``limit`` parameter on each underlying :meth:`search_locations` + call. + + Notes + ----- + A 4xx response is swallowed by the underlying search helper, which + returns ``[]``. Because iteration stops on a page shorter than + ``batch_size``, a 4xx on the first page ends the walk having yielded + nothing -- indistinguishable from a filter that matched nothing. + Check the caller's permissions and entity scope before reading an + empty walk as an empty result set. 5xx still raises. + + Yields + ------ + list[GetLocation] + One page per iteration. The last yielded batch may be shorter + than ``batch_size``. + """ + + start = 0 + while True: + batch = self.search_locations( + rsql_filter, + limit=batch_size, + start=start, + ) + if batch: + yield batch + if len(batch) < batch_size: + break + start += batch_size + def get_location(self, location_id: GlpiId) -> GetLocation: """Fetch one GLPI location by identifier. diff --git a/glpi_python_client/_sync/clients/api/dropdowns/tests/test_location.py b/glpi_python_client/_sync/clients/api/dropdowns/tests/test_location.py index 72c13a8..3f17866 100644 --- a/glpi_python_client/_sync/clients/api/dropdowns/tests/test_location.py +++ b/glpi_python_client/_sync/clients/api/dropdowns/tests/test_location.py @@ -126,3 +126,64 @@ def test_delete_helpers_raise_on_failure_status( FailingTransportRecorder(500).install(client) with pytest.raises(ValueError): call(client) + + +def test_iter_search_locations_yields_every_page(client: Any) -> None: + """The generator advances ``start`` until a short page ends the walk.""" + + from glpi_python_client.models.api_schema.dropdowns import GetLocation + + pages = [[GetLocation(id=i) for i in range(3)], [GetLocation(id=99)]] + starts: list[int] = [] + + def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0 + ) -> list[GetLocation]: + starts.append(start) + index = start // limit + return pages[index] if index < len(pages) else [] + + client.search_locations = fake_search # type: ignore[method-assign] + + batches = [ + batch for batch in client.iter_search_locations("name==x", batch_size=3) + ] + + assert starts == [0, 3] + assert [len(b) for b in batches] == [3, 1] + + +def test_iter_search_locations_stops_on_a_single_short_page(client: Any) -> None: + """One short page is the last page; no second request is made.""" + + from glpi_python_client.models.api_schema.dropdowns import GetLocation + + starts: list[int] = [] + + def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0 + ) -> list[GetLocation]: + starts.append(start) + return [GetLocation(id=1)] + + client.search_locations = fake_search # type: ignore[method-assign] + + batches = [batch for batch in client.iter_search_locations(batch_size=50)] + + assert starts == [0] + assert len(batches) == 1 + + +def test_iter_search_locations_yields_nothing_when_empty(client: Any) -> None: + """An empty first page yields no batch at all rather than one empty list.""" + + from glpi_python_client.models.api_schema.dropdowns import GetLocation + + def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0 + ) -> list[GetLocation]: + return [] + + client.search_locations = fake_search # type: ignore[method-assign] + + assert [batch for batch in client.iter_search_locations()] == [] diff --git a/glpi_python_client/_sync/clients/api/knowledgebase/_article.py b/glpi_python_client/_sync/clients/api/knowledgebase/_article.py index fe1bc4d..80c0deb 100644 --- a/glpi_python_client/_sync/clients/api/knowledgebase/_article.py +++ b/glpi_python_client/_sync/clients/api/knowledgebase/_article.py @@ -8,7 +8,7 @@ from __future__ import annotations -from collections.abc import Sequence +from collections.abc import Iterator, Sequence from glpi_python_client._sync.clients.commons._constants import ( KB_ARTICLE_ENDPOINT, @@ -72,6 +72,66 @@ def search_kb_articles( KB_ARTICLE_ENDPOINT, GetKBArticle, params=params ) + def iter_search_kb_articles( + self, + rsql_filter: str = "", + *, + batch_size: int = 50, + sort: str | None = None, + language: str | None = None, + ) -> Iterator[list[GetKBArticle]]: + """Yield successive pages of GLPI knowledge base articles until exhausted. + + The generator drives pagination automatically by advancing the + ``start`` offset after each batch. Iteration stops when the server + returns fewer items than ``batch_size``, which signals the last page. + + Parameters + ---------- + rsql_filter : str, optional + Raw RSQL filter forwarded as the ``filter`` query parameter. + Empty by default, which lists every visible record. + batch_size : int, optional + Number of records requested per page (default 50). Acts as the + ``limit`` parameter on each underlying :meth:`search_kb_articles` + call. + sort : str | None, optional + ``sort`` query parameter forwarded as-is to each page request. + language : str | None, optional + GLPI language code forwarded to each page request to select + a translated view. + + Notes + ----- + A 4xx response is swallowed by the underlying search helper, which + returns ``[]``. Because iteration stops on a page shorter than + ``batch_size``, a 4xx on the first page ends the walk having yielded + nothing -- indistinguishable from a filter that matched nothing. + Check the caller's permissions and entity scope before reading an + empty walk as an empty result set. 5xx still raises. + + Yields + ------ + list[GetKBArticle] + One page per iteration. The last yielded batch may be shorter + than ``batch_size``. + """ + + start = 0 + while True: + batch = self.search_kb_articles( + rsql_filter, + limit=batch_size, + start=start, + sort=sort, + language=language, + ) + if batch: + yield batch + if len(batch) < batch_size: + break + start += batch_size + def get_kb_article(self, article_id: GlpiId) -> GetKBArticle: """Fetch one knowledge base article by identifier. diff --git a/glpi_python_client/_sync/clients/api/knowledgebase/_category.py b/glpi_python_client/_sync/clients/api/knowledgebase/_category.py index 3e597bc..3641bbd 100644 --- a/glpi_python_client/_sync/clients/api/knowledgebase/_category.py +++ b/glpi_python_client/_sync/clients/api/knowledgebase/_category.py @@ -7,6 +7,8 @@ from __future__ import annotations +from collections.abc import Iterator + from glpi_python_client._sync.clients.commons._constants import ( KB_CATEGORY_ENDPOINT, GlpiId, @@ -65,6 +67,66 @@ def search_kb_categories( KB_CATEGORY_ENDPOINT, GetKBCategory, params=params ) + def iter_search_kb_categories( + self, + rsql_filter: str = "", + *, + batch_size: int = 50, + sort: str | None = None, + language: str | None = None, + ) -> Iterator[list[GetKBCategory]]: + """Yield successive pages of GLPI knowledge base categories until exhausted. + + The generator drives pagination automatically by advancing the + ``start`` offset after each batch. Iteration stops when the server + returns fewer items than ``batch_size``, which signals the last page. + + Parameters + ---------- + rsql_filter : str, optional + Raw RSQL filter forwarded as the ``filter`` query parameter. + Empty by default, which lists every visible record. + batch_size : int, optional + Number of records requested per page (default 50). Acts as the + ``limit`` parameter on each underlying :meth:`search_kb_categories` + call. + sort : str | None, optional + ``sort`` query parameter forwarded as-is to each page request. + language : str | None, optional + GLPI language code forwarded to each page request to select + a translated view. + + Notes + ----- + A 4xx response is swallowed by the underlying search helper, which + returns ``[]``. Because iteration stops on a page shorter than + ``batch_size``, a 4xx on the first page ends the walk having yielded + nothing -- indistinguishable from a filter that matched nothing. + Check the caller's permissions and entity scope before reading an + empty walk as an empty result set. 5xx still raises. + + Yields + ------ + list[GetKBCategory] + One page per iteration. The last yielded batch may be shorter + than ``batch_size``. + """ + + start = 0 + while True: + batch = self.search_kb_categories( + rsql_filter, + limit=batch_size, + start=start, + sort=sort, + language=language, + ) + if batch: + yield batch + if len(batch) < batch_size: + break + start += batch_size + def get_kb_category(self, category_id: GlpiId) -> GetKBCategory: """Fetch one knowledge base category by identifier. diff --git a/glpi_python_client/_sync/clients/api/knowledgebase/tests/test_article.py b/glpi_python_client/_sync/clients/api/knowledgebase/tests/test_article.py index a0a2f67..5759ec8 100644 --- a/glpi_python_client/_sync/clients/api/knowledgebase/tests/test_article.py +++ b/glpi_python_client/_sync/clients/api/knowledgebase/tests/test_article.py @@ -345,3 +345,87 @@ def test_delete_helpers_raise_on_failure( FailingTransportRecorder(500).install(client) with pytest.raises(ValueError): call(client) + + +def test_iter_search_kb_articles_yields_every_page(client: Any) -> None: + """The generator advances ``start`` until a short page ends the walk.""" + + from glpi_python_client.models.api_schema.knowledgebase import GetKBArticle + + pages = [[GetKBArticle(id=i) for i in range(3)], [GetKBArticle(id=99)]] + starts: list[int] = [] + forwarded: dict[str, Any] = {} + + def fake_search( + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + sort: str | None = None, + language: str | None = None, + ) -> list[GetKBArticle]: + starts.append(start) + forwarded["sort"] = sort + index = start // limit + return pages[index] if index < len(pages) else [] + + client.search_kb_articles = fake_search # type: ignore[method-assign] + + batches = [ + batch + for batch in client.iter_search_kb_articles( + "name==x", batch_size=3, sort="name asc" + ) + ] + + assert starts == [0, 3] + assert [len(b) for b in batches] == [3, 1] + assert forwarded["sort"] == "name asc" + + +def test_iter_search_kb_articles_stops_on_a_single_short_page( + client: Any, +) -> None: + """One short page is the last page; no second request is made.""" + + from glpi_python_client.models.api_schema.knowledgebase import GetKBArticle + + starts: list[int] = [] + + def fake_search( + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + sort: str | None = None, + language: str | None = None, + ) -> list[GetKBArticle]: + starts.append(start) + return [GetKBArticle(id=1)] + + client.search_kb_articles = fake_search # type: ignore[method-assign] + + batches = [batch for batch in client.iter_search_kb_articles(batch_size=50)] + + assert starts == [0] + assert len(batches) == 1 + + +def test_iter_search_kb_articles_yields_nothing_when_empty(client: Any) -> None: + """An empty first page yields no batch at all rather than one empty list.""" + + from glpi_python_client.models.api_schema.knowledgebase import GetKBArticle + + def fake_search( + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + sort: str | None = None, + language: str | None = None, + ) -> list[GetKBArticle]: + return [] + + client.search_kb_articles = fake_search # type: ignore[method-assign] + + assert [batch for batch in client.iter_search_kb_articles()] == [] diff --git a/glpi_python_client/_sync/clients/api/knowledgebase/tests/test_category.py b/glpi_python_client/_sync/clients/api/knowledgebase/tests/test_category.py index f4deceb..6cd95a3 100644 --- a/glpi_python_client/_sync/clients/api/knowledgebase/tests/test_category.py +++ b/glpi_python_client/_sync/clients/api/knowledgebase/tests/test_category.py @@ -183,3 +183,87 @@ def test_delete_helpers_raise_on_failure( FailingTransportRecorder(500).install(client) with pytest.raises(ValueError): call(client) + + +def test_iter_search_kb_categories_yields_every_page(client: Any) -> None: + """The generator advances ``start`` until a short page ends the walk.""" + + from glpi_python_client.models.api_schema.knowledgebase import GetKBCategory + + pages = [[GetKBCategory(id=i) for i in range(3)], [GetKBCategory(id=99)]] + starts: list[int] = [] + forwarded: dict[str, Any] = {} + + def fake_search( + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + sort: str | None = None, + language: str | None = None, + ) -> list[GetKBCategory]: + starts.append(start) + forwarded["language"] = language + index = start // limit + return pages[index] if index < len(pages) else [] + + client.search_kb_categories = fake_search # type: ignore[method-assign] + + batches = [ + batch + for batch in client.iter_search_kb_categories( + "name==x", batch_size=3, language="fr_FR" + ) + ] + + assert starts == [0, 3] + assert [len(b) for b in batches] == [3, 1] + assert forwarded["language"] == "fr_FR" + + +def test_iter_search_kb_categories_stops_on_a_single_short_page( + client: Any, +) -> None: + """One short page is the last page; no second request is made.""" + + from glpi_python_client.models.api_schema.knowledgebase import GetKBCategory + + starts: list[int] = [] + + def fake_search( + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + sort: str | None = None, + language: str | None = None, + ) -> list[GetKBCategory]: + starts.append(start) + return [GetKBCategory(id=1)] + + client.search_kb_categories = fake_search # type: ignore[method-assign] + + batches = [batch for batch in client.iter_search_kb_categories(batch_size=50)] + + assert starts == [0] + assert len(batches) == 1 + + +def test_iter_search_kb_categories_yields_nothing_when_empty(client: Any) -> None: + """An empty first page yields no batch at all rather than one empty list.""" + + from glpi_python_client.models.api_schema.knowledgebase import GetKBCategory + + def fake_search( + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + sort: str | None = None, + language: str | None = None, + ) -> list[GetKBCategory]: + return [] + + client.search_kb_categories = fake_search # type: ignore[method-assign] + + assert [batch for batch in client.iter_search_kb_categories()] == [] diff --git a/glpi_python_client/_sync/clients/api/management/_document.py b/glpi_python_client/_sync/clients/api/management/_document.py index 71cda34..32ce504 100644 --- a/glpi_python_client/_sync/clients/api/management/_document.py +++ b/glpi_python_client/_sync/clients/api/management/_document.py @@ -8,6 +8,7 @@ from __future__ import annotations import logging +from collections.abc import Iterator from glpi_python_client._sync.clients.commons._constants import ( DOCUMENT_ENDPOINT, @@ -63,6 +64,57 @@ def search_documents( DOCUMENT_ENDPOINT, GetDocument, params=params, skip_entity=True ) + def iter_search_documents( + self, + rsql_filter: str = "", + *, + batch_size: int = 50, + ) -> Iterator[list[GetDocument]]: + """Yield successive pages of GLPI documents until exhausted. + + The generator drives pagination automatically by advancing the + ``start`` offset after each batch. Iteration stops when the server + returns fewer items than ``batch_size``, which signals the last page. + + Parameters + ---------- + rsql_filter : str, optional + Raw RSQL filter forwarded as the ``filter`` query parameter. + Empty by default, which lists every visible record. + batch_size : int, optional + Number of records requested per page (default 50). Acts as the + ``limit`` parameter on each underlying :meth:`search_documents` + call. + + Notes + ----- + A 4xx response is swallowed by the underlying search helper, which + returns ``[]``. Because iteration stops on a page shorter than + ``batch_size``, a 4xx on the first page ends the walk having yielded + nothing -- indistinguishable from a filter that matched nothing. + Check the caller's permissions and entity scope before reading an + empty walk as an empty result set. 5xx still raises. + + Yields + ------ + list[GetDocument] + One page per iteration. The last yielded batch may be shorter + than ``batch_size``. + """ + + start = 0 + while True: + batch = self.search_documents( + rsql_filter, + limit=batch_size, + start=start, + ) + if batch: + yield batch + if len(batch) < batch_size: + break + start += batch_size + def get_document(self, document_id: GlpiId) -> GetDocument: """Fetch one GLPI document by identifier. diff --git a/glpi_python_client/_sync/clients/api/management/tests/test_document.py b/glpi_python_client/_sync/clients/api/management/tests/test_document.py index 57dd25e..41ca11b 100644 --- a/glpi_python_client/_sync/clients/api/management/tests/test_document.py +++ b/glpi_python_client/_sync/clients/api/management/tests/test_document.py @@ -224,3 +224,64 @@ def test_delete_helpers_raise_on_failure_status( FailingTransportRecorder(500).install(client) with pytest.raises(ValueError): call(client) + + +def test_iter_search_documents_yields_every_page(client: Any) -> None: + """The generator advances ``start`` until a short page ends the walk.""" + + from glpi_python_client.models.api_schema.management import GetDocument + + pages = [[GetDocument(id=i) for i in range(3)], [GetDocument(id=99)]] + starts: list[int] = [] + + def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0 + ) -> list[GetDocument]: + starts.append(start) + index = start // limit + return pages[index] if index < len(pages) else [] + + client.search_documents = fake_search # type: ignore[method-assign] + + batches = [ + batch for batch in client.iter_search_documents("name==x", batch_size=3) + ] + + assert starts == [0, 3] + assert [len(b) for b in batches] == [3, 1] + + +def test_iter_search_documents_stops_on_a_single_short_page(client: Any) -> None: + """One short page is the last page; no second request is made.""" + + from glpi_python_client.models.api_schema.management import GetDocument + + starts: list[int] = [] + + def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0 + ) -> list[GetDocument]: + starts.append(start) + return [GetDocument(id=1)] + + client.search_documents = fake_search # type: ignore[method-assign] + + batches = [batch for batch in client.iter_search_documents(batch_size=50)] + + assert starts == [0] + assert len(batches) == 1 + + +def test_iter_search_documents_yields_nothing_when_empty(client: Any) -> None: + """An empty first page yields no batch at all rather than one empty list.""" + + from glpi_python_client.models.api_schema.management import GetDocument + + def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0 + ) -> list[GetDocument]: + return [] + + client.search_documents = fake_search # type: ignore[method-assign] + + assert [batch for batch in client.iter_search_documents()] == [] diff --git a/skills/glpi-document-workflow/SKILL.md b/skills/glpi-document-workflow/SKILL.md index 56f73c4..df16b6d 100644 --- a/skills/glpi-document-workflow/SKILL.md +++ b/skills/glpi-document-workflow/SKILL.md @@ -18,7 +18,7 @@ The `GLPIV1Session` class is no longer part of the public surface; the v1 sessio ## Procedure 1. Create a `GlpiClient`. For uploads, also pass `v1_base_url` and `v1_user_token` (and optionally `v1_app_token`). -2. Search documents with `await client.search_documents(rsql_filter, limit=..., start=...)`. +2. Search documents with `await client.search_documents(rsql_filter, limit=..., start=...)`, or walk every page with `async for batch in client.iter_search_documents(rsql_filter, batch_size=50)`. 3. Fetch one document's metadata with `await client.get_document(document_id)`. 4. Create a metadata-only record with `PostDocument(...)` and `await client.create_document(document)`. The method returns the new ID. 5. Update with `PatchDocument(...)` and `await client.update_document(document_id, document)`. diff --git a/skills/glpi-reporting-and-context/SKILL.md b/skills/glpi-reporting-and-context/SKILL.md index db93bce..13898b8 100644 --- a/skills/glpi-reporting-and-context/SKILL.md +++ b/skills/glpi-reporting-and-context/SKILL.md @@ -18,7 +18,7 @@ Custom helpers on `GlpiClient` build on top of the contract-aligned API mixins: - `get_task_statistics(ticket_ids)` returns task duration totals grouped by user and ticket for a caller-supplied list of ticket IDs. - `get_task_durations(...)` is a higher-level helper that internally iterates `iter_search_tickets` with a date/entity RSQL filter, computes per-user and per-entity duration totals, and optionally returns a flat per-task list when `return_task_details=True`. `user_id` is **not** part of the RSQL filter: the v2 `team` array cannot be joined by the RSQL engine, so the ticket ids for that actor are resolved through the legacy v1 search engine (searchOptions 5 `Technicien` and 4 `Demandeur`, OR-ed) and intersected client-side. Passing `user_id` therefore requires a client built with `v1_base_url` + `v1_user_token`, or the call raises `RuntimeError`; a non-positive or non-`int` id raises `GlpiValidationError`. Once the matched ticket set reaches 25 tickets and a v1 session is present, task aggregation switches from the per-ticket v2 fan-out to one bulk sweep of the v1 `TicketTask` collection (paged 1000 rows at a time); the aggregate is identical either way. - `get_user_activity(...)` aggregates per-user activity (tickets as technician, tickets as recipient, task durations) over a date window; resolves users by `user_id`, `username`, `realname`, or `firstname` and merges users that share the same display key. The technician and recipient counts have **no v2 equivalent** and are resolved through the legacy v1 search engine (searchOption 5 `Technicien`, 4 `Demandeur`), intersected with the ids returned by a single walk of the date window. This helper therefore **always** requires a client built with `v1_base_url` + `v1_user_token` and raises `RuntimeError` naming the missing options when they are absent. -- `iter_search_tickets`, `iter_search_users`, `iter_search_entities` yield successive `list[...]` batches of contract models and stop on the first short batch. They handle pagination so callers do not manage `start` cursors manually. +- `iter_search_tickets`, `iter_search_users`, `iter_search_entities`, `iter_search_locations`, `iter_search_documents`, `iter_search_kb_articles` and `iter_search_kb_categories` yield successive `list[...]` batches of contract models and stop on the first short batch. They handle pagination so callers do not manage `start` cursors manually. A 4xx raises `GlpiStatusError` rather than ending the walk quietly, so an empty walk does mean the filter matched nothing. Returned identifiers are raw GLPI numeric values; resolve them with the appropriate `search_*` helpers when human-readable labels are needed. @@ -29,7 +29,7 @@ Returned identifiers are raw GLPI numeric values; resolve them with the appropri 3. For ticket counts, call `await client.get_ticket_statistics(start_date=..., end_date=..., default_days=..., entity_id=..., entity_name=..., extra_filter=...)`. All keyword arguments are optional; the default window is the last 30 days ending today. 4. For task duration totals on a known ticket list, call `await client.get_task_statistics(ticket_ids)`. For an end-to-end "duration over a window with filters" report, call `await client.get_task_durations(...)` instead; it gathers the ticket IDs internally. 5. For a per-user activity report, call `await client.get_user_activity(username=..., start_date=..., end_date=...)`. Supply at least one of `user_id`, `username`, `realname`, `firstname`. -6. For memory-bounded pagination over large result sets, iterate `iter_search_tickets` / `iter_search_users` / `iter_search_entities` with `async for batch in client.iter_search_*(...): ...`. +6. For memory-bounded pagination over large result sets, iterate any `iter_search_*` helper with `async for batch in client.iter_search_*(...): ...`. 7. Use the public enums when composing additional RSQL filters. There are eight, all exported from `glpi_python_client`, and this is the whole list: `GlpiTicketStatus` (`NEW = 1`, `ASSIGNED = 2`, `PLANNED = 3`, `PENDING = 4`, `SOLVED = 5`, `CLOSED = 6`, `VALIDATION = 10`), `GlpiTicketType` (`INCIDENT = 1`, `REQUEST = 2`), `GlpiPriority` (`VERY_LOW = 1` .. `VERY_HIGH = 5`, `MAJOR = 6`), `GlpiGlobalValidation` and `GlpiSolutionStatus` (both `NONE = 1`, `WAITING = 2`, `ACCEPTED = 3`, `REFUSED = 4`), `GlpiTaskState` (`INFORMATION = 0`, `TODO = 1`, `DONE = 2`), `GlpiTimelinePosition` (`INVALID = -1`, `NONE = 0`, `LEFT = 1`, `RIGHT = 2`, `LEFT_BIG = 3`, `RIGHT_BIG = 4`) and `GlpiUserAuthType` (`LOCAL = 1`, `LDAP = 2`, `MAIL = 3`, `CAS = 4`, `X509 = 5`, `EXTERNAL = 6`). All eight subclass `GlpiEnum`, which is exported too and is a plain `IntEnum` with two conveniences for filter building: `.glpi_id` returns the number, and `.rsql_equals("status")` returns the RSQL fragment, so `GlpiTicketStatus.NEW.rsql_equals("status")` replaces the hand-written `f"status=={int(GlpiTicketStatus.NEW)}"` below. ## Examples diff --git a/skills/glpi-ticket-workflow/SKILL.md b/skills/glpi-ticket-workflow/SKILL.md index 2009c73..16102ee 100644 --- a/skills/glpi-ticket-workflow/SKILL.md +++ b/skills/glpi-ticket-workflow/SKILL.md @@ -71,7 +71,7 @@ ticket = PostTicket( - On `AsyncGlpiClient` every ticket method is a coroutine and must be awaited; on `GlpiClient` the same methods are ordinary blocking calls and must not be awaited. `iter_search_tickets` is the exception to the shape: it is an async generator on `AsyncGlpiClient` (`async for`) and a plain generator on `GlpiClient` (`for`), so it is iterated, not awaited, on either surface. - `search_tickets` accepts a raw RSQL filter string; pagination is via the keyword-only `limit` and `start` (it also takes `sort` and `fields`). To walk a whole result set use the batch iterator `iter_search_tickets(rsql_filter, batch_size=50, sort=..., fields=...)`, which advances `start` itself and yields one `list[GetTicket]` page per step, stopping when a page comes back shorter than `batch_size` — `async for batch in client.iter_search_tickets(...)` on `AsyncGlpiClient`, `for batch in client.iter_search_tickets(...)` on `GlpiClient`. -- **`search_tickets` swallows 4xx and returns `[]`.** This is a library-wide contract, not a ticket peculiarity: `_resource_list` checks the response status only when the caller passes a `failure_message`, and none of the seven `search_*` helpers (`search_tickets`, `search_users`, `search_locations`, `search_entities`, `search_documents`, `search_kb_articles`, `search_kb_categories`) passes one -- a GLPI error body is not a JSON list, so it is coerced to `[]`. A malformed RSQL filter, a 403, a missing route and a genuinely empty result set are therefore indistinguishable at the call site. `iter_search_tickets` inherits it: a swallowed 4xx yields a short first page, so the loop simply ends and you process nothing. `get_ticket` and every `list_*`/`get_*` helper do pass a `failure_message` and raise `GlpiStatusError` (narrowed to `GlpiAuthError` / `GlpiNotFoundError` / `GlpiServerError`) normally, so probe with one of those before believing an empty search -- and never treat `[]` as proof a ticket does not exist before creating a replacement. +- **`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`. - 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. - Read-only fields such as `status` are intentionally absent from `PostTicket`/`PatchTicket`; the server controls those transitions. `global_validation` is the exception -- it *is* declared on both write models, typed `GlpiGlobalValidation | None`, whose members are `NONE = 1`, `WAITING = 2`, `ACCEPTED = 3`, `REFUSED = 4`. `GlpiGlobalValidation` is exported from the package root alongside `GlpiTicketStatus` (`NEW = 1`, `ASSIGNED = 2`, `PLANNED = 3`, `PENDING = 4`, `SOLVED = 5`, `CLOSED = 6`, `VALIDATION = 10`), `GlpiTicketType` (`INCIDENT = 1`, `REQUEST = 2`) and `GlpiPriority`; `status` and the rest stay readable on `GetTicket`. \ No newline at end of file diff --git a/skills/glpi-user-location-provisioning/SKILL.md b/skills/glpi-user-location-provisioning/SKILL.md index 2a17260..a523116 100644 --- a/skills/glpi-user-location-provisioning/SKILL.md +++ b/skills/glpi-user-location-provisioning/SKILL.md @@ -97,10 +97,11 @@ for entity in entities: ## Gotchas -- **`search_users`, `search_locations` and `search_entities` swallow 4xx and return `[]`, and this family is where that hurts most.** It is a library-wide contract, not a peculiarity of these three: `_resource_list` checks the response status only when the caller passes a `failure_message`, and none of the seven `search_*` helpers (`search_users`, `search_locations`, `search_entities`, `search_tickets`, `search_documents`, `search_kb_articles`, `search_kb_categories`) passes one -- a GLPI error body is not a JSON list, so it is coerced to `[]`. A malformed RSQL filter, a 403, a missing route and "no such record" are therefore indistinguishable. `iter_search_users` / `iter_search_entities` inherit it: a swallowed 4xx makes the first page short, so the loop ends having yielded nothing. **The find-or-create pattern is the trap**: `matches[0].id if matches else create(...)` provisions a duplicate user or location every time the search fails, and the duplicate is then a data-repair job, not an exception someone sees. Guard both halves as the example above does -- validate anything you interpolate into the filter, and corroborate an empty result with an unfiltered control search or with a `get_user`/`get_location`/`get_entity` on a known id, all of which do pass a `failure_message` and raise `GlpiStatusError` (narrowed to `GlpiAuthError` / `GlpiNotFoundError` / `GlpiServerError`) normally. +- **`search_users`, `search_locations` and `search_entities` 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. **The find-or-create pattern was the trap this protected against**: `matches[0].id if matches else create(...)` provisioned a duplicate every time the search failed. A 403 now raises there. It is still worth validating anything you interpolate into a filter, because the silent-drop path returns a *non-empty* wrong answer that no status check can catch. - On `AsyncGlpiClient` every method shown is a coroutine -- always `await` it -- and `iter_search_users` / `iter_search_entities` are async generators consumed with `async for`, not `await`. The generated `GlpiClient` carries the same names and signatures as ordinary blocking calls: no `await`, and a plain `for` over the iterators. - `PostUser` has **no** client-side required fields: every declared field defaults to `None` (bar `extra_payload`, which defaults to an empty dict) and `model_dump(exclude_none=True)` strips the unset ones, so `PostUser()` validates fine and it is the GLPI server that rejects a create without `username` and enforces the `password`/`password2` pair for local accounts. Tweak according to your auth backend: `authtype` on `PostUser`/`PatchUser`/`GetUser` is typed `GlpiUserAuthType | None`, exported from the package root, with members `LOCAL = 1`, `LDAP = 2`, `MAIL = 3`, `CAS = 4`, `X509 = 5`, `EXTERNAL = 6` -- pass the member (`authtype=GlpiUserAuthType.LDAP`) rather than a bare integer. Like every public enum in the package it subclasses `GlpiEnum`, itself an `IntEnum`, so it serialises as its number and compares equal to one. `password`/`password2` are `SecretStr` (plain `str` is coerced) and are masked in `repr` and logs, unmasked only when the request body is serialised. -- Search filters are raw RSQL strings. `search_*` pages manually with `limit` and `start`, but for users and entities the client drives pagination for you: `async for page in client.iter_search_users(rsql_filter, batch_size=50, skip_entity=False)` and `iter_search_entities(rsql_filter, batch_size=50)` yield successive pages and stop on the first short page (plain `for` on `GlpiClient`). `iter_search_users` carries the same `skip_entity` flag as `search_users`, so pass `skip_entity=True` there too when paging across every entity; `iter_search_entities` has no such parameter and always spans them. There is no `iter_search_locations` -- page `search_locations` yourself with `limit`/`start`. +- Search filters are raw RSQL strings. `search_*` pages manually with `limit` and `start`, but all three resources now have a batch iterator that drives pagination for you: `iter_search_users(rsql_filter, batch_size=50, skip_entity=False)`, `iter_search_entities(rsql_filter, batch_size=50)` and `iter_search_locations(rsql_filter, batch_size=50)` yield successive pages and stop on the first short page (plain `for` on `GlpiClient`). `iter_search_users` carries the same `skip_entity` flag as `search_users`, so pass `skip_entity=True` there too when paging across every entity; the other two have no such parameter. +- `find_user_by_email("a@b.test")` resolves a person by address and returns `GetUser | None`. It **scans** -- GLPI exposes addresses as the nested array `User.emails`, which the v2 filter engine cannot join -- so narrow it with `rsql_filter="is_active==true"` where you can and cache the id instead of calling it per request. Do not hand-roll an RSQL e-mail filter: v2 ignores a field it does not recognise and answers with the whole table, so the first row would be the wrong person. - Extra keys returned by the live server (`display_name`, plugin fields, ...) flow into `record.extra_payload` rather than raising. - `delete_*(force=True)` permanently deletes the record; omit (or `False`/`None`) to move it to the trash. - If the user provides a name rather than an ID, search first and confirm the ID before changing or deleting records. \ No newline at end of file From 96d5271fb68958370f9c692a7dc327e59604bf8a Mon Sep 17 00:00:00 2001 From: baraline Date: Wed, 12 Aug 2026 13:27:02 +0200 Subject: [PATCH 10/21] feat: stream document downloads instead of buffering the whole body download_document_content returns response.content after a non-streaming dispatch, so httpx materialises the entire body first: a 500 MB attachment costs 500 MB of process memory even when the caller only writes it straight to disk. stream_document_content yields it in chunks instead. Three things this needed that are worth recording, because each fails quietly rather than loudly: - "aiter_bytes": "iter_bytes" in TOKEN_REPLACEMENTS. async with, async for and AsyncIterator are all handled by unasync already; aiter_bytes is not, and httpx defines both readers on one Response class, so the un-rewritten sync twin fails at *iteration* with "'async_generator' object is not iterable" rather than at the call. - The matching _INTENTIONAL_RENAMES entry, in the same commit, or the codegen collision guard flags it. - A .stream stub in test_method_invocation's _install_stub. Streaming does not route through session.request on either surface, so the stub seam missed it entirely: the public-surface test failed "made no HTTP call" while trying to open a real socket to the fake host. Written by hand for each surface since that file is not unasync-generated. The status is checked inside the context manager, after reading the body: the error helpers format response.text, and reading text off an unread stream raises instead of reporting the status. No @retry here -- tenacity does not wrap an async generator and would degrade to the sync path silently. Upload still buffers; that is a separate change. Closes #30 Co-Authored-By: Claude Opus 5 (1M context) --- .../clients/api/management/_document.py | 49 +++++++++++ .../api/management/tests/test_document.py | 84 ++++++++++++++++++- .../_async/clients/commons/_transport.py | 61 +++++++++++++- .../_sync/clients/api/management/_document.py | 49 +++++++++++ .../api/management/tests/test_document.py | 84 ++++++++++++++++++- .../_sync/clients/commons/_transport.py | 61 +++++++++++++- .../testing/tests/test_method_invocation.py | 44 +++++++++- .../testing/tests/test_unasync_codegen.py | 2 + unasync_build.py | 8 ++ 9 files changed, 437 insertions(+), 5 deletions(-) diff --git a/glpi_python_client/_async/clients/api/management/_document.py b/glpi_python_client/_async/clients/api/management/_document.py index 4231f4e..5f16129 100644 --- a/glpi_python_client/_async/clients/api/management/_document.py +++ b/glpi_python_client/_async/clients/api/management/_document.py @@ -266,6 +266,55 @@ async def download_document_content(self, document_id: GlpiId) -> bytes: ) return response.content + async def stream_document_content( + self, + document_id: GlpiId, + *, + chunk_size: int = 65536, + ) -> AsyncIterator[bytes]: + """Stream the binary payload of one GLPI document in chunks. + + Use this instead of :meth:`download_document_content` when the file + may be large: that method holds the whole body in memory before + returning, so a 500 MB attachment costs 500 MB of process memory + even if the caller only writes it straight to disk. + + Parameters + ---------- + document_id : GlpiId + Numeric identifier of the document whose binary content is + requested. + chunk_size : int, optional + Bytes requested per chunk (defaults to 64 KiB). + + Yields + ------ + bytes + Successive chunks of the document body. The final chunk may be + shorter than ``chunk_size``. + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + + Examples + -------- + Writing a document to disk without buffering it:: + + with open("attachment.pdf", "wb") as handle: + async for chunk in client.stream_document_content(42): + handle.write(chunk) + """ + + async for chunk in self._stream_request( + f"{DOCUMENT_ENDPOINT}/{document_id}/Download", + chunk_size=chunk_size, + skip_entity=True, + failure_message=f"Failed to download document {document_id}", + ): + yield chunk + async def upload_document( self, *, diff --git a/glpi_python_client/_async/clients/api/management/tests/test_document.py b/glpi_python_client/_async/clients/api/management/tests/test_document.py index c3c9027..1e7f1a1 100644 --- a/glpi_python_client/_async/clients/api/management/tests/test_document.py +++ b/glpi_python_client/_async/clients/api/management/tests/test_document.py @@ -9,7 +9,7 @@ from __future__ import annotations from collections.abc import Callable -from typing import Any +from typing import Any, ClassVar import pytest @@ -285,3 +285,85 @@ async def fake_search( client.search_documents = fake_search # type: ignore[method-assign] assert [batch async for batch in client.iter_search_documents()] == [] + + +def _pin_token(client: Any) -> None: + """Pretend a valid token is held so no OAuth round trip happens. + + The transport recorders stub above ``_ensure_token``; the streaming + helper runs through it, so the token has to be supplied here instead. + """ + + from datetime import datetime, timedelta, timezone + + client._auth.access_token = "stub-token" + client._auth.token_expires_at = datetime.now(tz=timezone.utc) + timedelta(days=1) + + +async def test_stream_document_content_yields_chunks(client: Any) -> None: + """The generator yields the body in chunks instead of buffering it.""" + + seen: dict[str, Any] = {} + + class _Stream: + async def __aenter__(self) -> Any: + return self + + async def __aexit__(self, *exc: Any) -> None: + return None + + status_code = 200 + headers: ClassVar[dict[str, str]] = {} + + async def aread(self) -> bytes: + return b"" + + async def aiter_bytes(self, chunk_size: int | None = None) -> Any: + seen["chunk_size"] = chunk_size + for chunk in (b"abc", b"def"): + yield chunk + + def _stream(method: str, url: str, **kwargs: Any) -> Any: + seen["method"] = method + seen["url"] = url + return _Stream() + + _pin_token(client) + client._session.stream = _stream # type: ignore[method-assign] + + chunks = [c async for c in client.stream_document_content(7, chunk_size=4)] + + assert chunks == [b"abc", b"def"] + assert seen["method"] == "GET" + assert seen["url"].endswith("Management/Document/7/Download") + assert seen["chunk_size"] == 4 + + +async def test_stream_document_content_raises_on_error_status(client: Any) -> None: + """A non-success status raises rather than yielding an error page as bytes.""" + + class _Stream: + async def __aenter__(self) -> Any: + return self + + async def __aexit__(self, *exc: Any) -> None: + return None + + status_code = 404 + headers: ClassVar[dict[str, str]] = {} + url = "https://glpi.example.test/Management/Document/7/Download" + text = "not found" + + async def aread(self) -> bytes: + return b"not found" + + async def aiter_bytes(self, chunk_size: int | None = None) -> Any: + yield b"not found" + + _pin_token(client) + client._session.stream = lambda method, url, **kw: _Stream() # type: ignore[method-assign] + + from glpi_python_client import GlpiStatusError + + with pytest.raises(GlpiStatusError): + [c async for c in client.stream_document_content(7)] diff --git a/glpi_python_client/_async/clients/commons/_transport.py b/glpi_python_client/_async/clients/commons/_transport.py index a43450f..05d42a9 100644 --- a/glpi_python_client/_async/clients/commons/_transport.py +++ b/glpi_python_client/_async/clients/commons/_transport.py @@ -25,7 +25,7 @@ from __future__ import annotations import logging -from collections.abc import Callable +from collections.abc import AsyncIterator, Callable from typing import TYPE_CHECKING, Any, TypeVar import httpx @@ -192,6 +192,65 @@ async def _send_request( except httpx.HTTPError as exc: raise transport_error_from(exc, method=method, url=url) from exc + async def _stream_request( + self, + endpoint: str, + *, + chunk_size: int, + skip_entity: bool = False, + failure_message: str, + ) -> AsyncIterator[bytes]: + """Stream one authenticated GLPI ``GET`` body in chunks. + + The non-streaming helpers materialise the whole body before the + caller sees any of it, which is fine for JSON and wrong for a + document that may be hundreds of megabytes. + + Two details differ from the buffered path and both are load-bearing. + The status has to be checked *inside* the context manager, and the + body read first: the error helpers format the response text, and + reading text off an unread stream raises rather than reporting the + status. And no retry decorator belongs here -- tenacity does not + wrap an async generator, so a decorator would silently degrade to + the sync path instead of failing loudly. + + Raises + ------ + GlpiStatusError + When the response status is not 200. + GlpiTransportError + When the request never produced a response. + """ + + await self._ensure_token() + access_token = require_access_token(self._auth.access_token) + url = build_request_url(self.glpi_api_url, endpoint) + headers = build_request_headers( + access_token=access_token, + language=self.language, + glpi_entity=self.glpi_entity, + glpi_profile=self.glpi_profile, + entity_recursive=self.entity_recursive, + include_content_type=False, + skip_entity=skip_entity, + ) + + try: + async with self._session.stream( + "GET", url, headers=headers, timeout=30 + ) as response: + if response.status_code != 200: + await response.aread() + ensure_response_status( + response, + success_statuses=(200,), + failure_message=failure_message, + ) + async for chunk in response.aiter_bytes(chunk_size): + yield chunk + except httpx.HTTPError as exc: + raise transport_error_from(exc, method="get", url=url) from exc + async def _execute_request( self, *, diff --git a/glpi_python_client/_sync/clients/api/management/_document.py b/glpi_python_client/_sync/clients/api/management/_document.py index 32ce504..679cfff 100644 --- a/glpi_python_client/_sync/clients/api/management/_document.py +++ b/glpi_python_client/_sync/clients/api/management/_document.py @@ -266,6 +266,55 @@ def download_document_content(self, document_id: GlpiId) -> bytes: ) return response.content + def stream_document_content( + self, + document_id: GlpiId, + *, + chunk_size: int = 65536, + ) -> Iterator[bytes]: + """Stream the binary payload of one GLPI document in chunks. + + Use this instead of :meth:`download_document_content` when the file + may be large: that method holds the whole body in memory before + returning, so a 500 MB attachment costs 500 MB of process memory + even if the caller only writes it straight to disk. + + Parameters + ---------- + document_id : GlpiId + Numeric identifier of the document whose binary content is + requested. + chunk_size : int, optional + Bytes requested per chunk (defaults to 64 KiB). + + Yields + ------ + bytes + Successive chunks of the document body. The final chunk may be + shorter than ``chunk_size``. + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + + Examples + -------- + Writing a document to disk without buffering it:: + + with open("attachment.pdf", "wb") as handle: + async for chunk in client.stream_document_content(42): + handle.write(chunk) + """ + + for chunk in self._stream_request( + f"{DOCUMENT_ENDPOINT}/{document_id}/Download", + chunk_size=chunk_size, + skip_entity=True, + failure_message=f"Failed to download document {document_id}", + ): + yield chunk + def upload_document( self, *, diff --git a/glpi_python_client/_sync/clients/api/management/tests/test_document.py b/glpi_python_client/_sync/clients/api/management/tests/test_document.py index 41ca11b..87fb963 100644 --- a/glpi_python_client/_sync/clients/api/management/tests/test_document.py +++ b/glpi_python_client/_sync/clients/api/management/tests/test_document.py @@ -9,7 +9,7 @@ from __future__ import annotations from collections.abc import Callable -from typing import Any +from typing import Any, ClassVar import pytest @@ -285,3 +285,85 @@ def fake_search( client.search_documents = fake_search # type: ignore[method-assign] assert [batch for batch in client.iter_search_documents()] == [] + + +def _pin_token(client: Any) -> None: + """Pretend a valid token is held so no OAuth round trip happens. + + The transport recorders stub above ``_ensure_token``; the streaming + helper runs through it, so the token has to be supplied here instead. + """ + + from datetime import datetime, timedelta, timezone + + client._auth.access_token = "stub-token" + client._auth.token_expires_at = datetime.now(tz=timezone.utc) + timedelta(days=1) + + +def test_stream_document_content_yields_chunks(client: Any) -> None: + """The generator yields the body in chunks instead of buffering it.""" + + seen: dict[str, Any] = {} + + class _Stream: + def __enter__(self) -> Any: + return self + + def __exit__(self, *exc: Any) -> None: + return None + + status_code = 200 + headers: ClassVar[dict[str, str]] = {} + + def read(self) -> bytes: + return b"" + + def iter_bytes(self, chunk_size: int | None = None) -> Any: + seen["chunk_size"] = chunk_size + for chunk in (b"abc", b"def"): + yield chunk + + def _stream(method: str, url: str, **kwargs: Any) -> Any: + seen["method"] = method + seen["url"] = url + return _Stream() + + _pin_token(client) + client._session.stream = _stream # type: ignore[method-assign] + + chunks = [c for c in client.stream_document_content(7, chunk_size=4)] + + assert chunks == [b"abc", b"def"] + assert seen["method"] == "GET" + assert seen["url"].endswith("Management/Document/7/Download") + assert seen["chunk_size"] == 4 + + +def test_stream_document_content_raises_on_error_status(client: Any) -> None: + """A non-success status raises rather than yielding an error page as bytes.""" + + class _Stream: + def __enter__(self) -> Any: + return self + + def __exit__(self, *exc: Any) -> None: + return None + + status_code = 404 + headers: ClassVar[dict[str, str]] = {} + url = "https://glpi.example.test/Management/Document/7/Download" + text = "not found" + + def read(self) -> bytes: + return b"not found" + + def iter_bytes(self, chunk_size: int | None = None) -> Any: + yield b"not found" + + _pin_token(client) + client._session.stream = lambda method, url, **kw: _Stream() # type: ignore[method-assign] + + from glpi_python_client import GlpiStatusError + + with pytest.raises(GlpiStatusError): + [c for c in client.stream_document_content(7)] diff --git a/glpi_python_client/_sync/clients/commons/_transport.py b/glpi_python_client/_sync/clients/commons/_transport.py index 6f656f6..909c7ca 100644 --- a/glpi_python_client/_sync/clients/commons/_transport.py +++ b/glpi_python_client/_sync/clients/commons/_transport.py @@ -25,7 +25,7 @@ from __future__ import annotations import logging -from collections.abc import Callable +from collections.abc import Iterator, Callable from typing import TYPE_CHECKING, Any, TypeVar import httpx @@ -192,6 +192,65 @@ def _send_request( except httpx.HTTPError as exc: raise transport_error_from(exc, method=method, url=url) from exc + def _stream_request( + self, + endpoint: str, + *, + chunk_size: int, + skip_entity: bool = False, + failure_message: str, + ) -> Iterator[bytes]: + """Stream one authenticated GLPI ``GET`` body in chunks. + + The non-streaming helpers materialise the whole body before the + caller sees any of it, which is fine for JSON and wrong for a + document that may be hundreds of megabytes. + + Two details differ from the buffered path and both are load-bearing. + The status has to be checked *inside* the context manager, and the + body read first: the error helpers format the response text, and + reading text off an unread stream raises rather than reporting the + status. And no retry decorator belongs here -- tenacity does not + wrap an async generator, so a decorator would silently degrade to + the sync path instead of failing loudly. + + Raises + ------ + GlpiStatusError + When the response status is not 200. + GlpiTransportError + When the request never produced a response. + """ + + self._ensure_token() + access_token = require_access_token(self._auth.access_token) + url = build_request_url(self.glpi_api_url, endpoint) + headers = build_request_headers( + access_token=access_token, + language=self.language, + glpi_entity=self.glpi_entity, + glpi_profile=self.glpi_profile, + entity_recursive=self.entity_recursive, + include_content_type=False, + skip_entity=skip_entity, + ) + + try: + with self._session.stream( + "GET", url, headers=headers, timeout=30 + ) as response: + if response.status_code != 200: + response.read() + ensure_response_status( + response, + success_statuses=(200,), + failure_message=failure_message, + ) + for chunk in response.iter_bytes(chunk_size): + yield chunk + except httpx.HTTPError as exc: + raise transport_error_from(exc, method="get", url=url) from exc + def _execute_request( self, *, diff --git a/glpi_python_client/testing/tests/test_method_invocation.py b/glpi_python_client/testing/tests/test_method_invocation.py index 23cb896..5aa2f07 100644 --- a/glpi_python_client/testing/tests/test_method_invocation.py +++ b/glpi_python_client/testing/tests/test_method_invocation.py @@ -23,8 +23,10 @@ import asyncio import inspect +from collections.abc import AsyncIterator, Iterator +from contextlib import asynccontextmanager, contextmanager from datetime import datetime, timedelta, timezone -from typing import Any, get_type_hints +from typing import Any, ClassVar, get_type_hints import pytest @@ -167,6 +169,46 @@ async def _arequest(method: str, url: str, **kwargs: Any) -> _StubResponse: return _stub_response_for(method, url) client._session.request = _arequest if is_async else _request # type: ignore[method-assign,union-attr,assignment] + + # Streaming does NOT route through ``session.request`` on either + # surface, so stubbing that seam alone leaves ``session.stream`` on the + # real transport -- and the "made no HTTP call" assertion below then + # fails while the test tries to open a socket to the fake host. The two + # context managers are written out by hand because this file is not + # unasync-generated. + class _StubStream: + status_code = 200 + headers: ClassVar[dict[str, str]] = {} + url = "https://glpi.example.test/stub" + text = "" + + def read(self) -> bytes: + return b"" + + async def aread(self) -> bytes: + return b"" + + def iter_bytes(self, chunk_size: int | None = None) -> Iterator[bytes]: + yield b"stub" + + async def aiter_bytes( + self, chunk_size: int | None = None + ) -> AsyncIterator[bytes]: + yield b"stub" + + @contextmanager + def _stream(method: str, url: str, **kwargs: Any) -> Iterator[_StubStream]: + calls.append(f"{method} {url}") + yield _StubStream() + + @asynccontextmanager + async def _astream( + method: str, url: str, **kwargs: Any + ) -> AsyncIterator[_StubStream]: + calls.append(f"{method} {url}") + yield _StubStream() + + client._session.stream = _astream if is_async else _stream # type: ignore[method-assign,union-attr,assignment] # 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" diff --git a/glpi_python_client/testing/tests/test_unasync_codegen.py b/glpi_python_client/testing/tests/test_unasync_codegen.py index 1e6ccb2..9a5f8c7 100644 --- a/glpi_python_client/testing/tests/test_unasync_codegen.py +++ b/glpi_python_client/testing/tests/test_unasync_codegen.py @@ -52,6 +52,8 @@ "AsyncHTTPTransport", "aclose", "aread", + # httpx's streaming body reader, called by the document stream helper. + "aiter_bytes", } diff --git a/unasync_build.py b/unasync_build.py index a2f7a7f..6d9c9f7 100644 --- a/unasync_build.py +++ b/unasync_build.py @@ -96,6 +96,14 @@ "AsyncHTTPTransport": "HTTPTransport", "aclose": "close", "aread": "read", + # httpx spells the streaming body readers with an ``a`` prefix and + # defines both surfaces' versions on one ``Response`` class. Left + # un-rewritten, the generated tree calls ``aiter_bytes`` on a sync + # response and fails at *iteration* with "'async_generator' object is + # not iterable" -- not with an AttributeError at the call, which is why + # this is easy to miss. ``aiter_text``/``aiter_lines``/``aiter_raw`` + # are equally un-rewritten and would each need an entry here. + "aiter_bytes": "iter_bytes", } From b4f7afbe40bf86eeb016fd2a5933a75fa7a43506 Mon Sep 17 00:00:00 2001 From: baraline Date: Wed, 12 Aug 2026 13:33:12 +0200 Subject: [PATCH 11/21] feat: add public RSQL date builders and dedupe the statistics windows The date_creation window was concatenated by hand at three sites in _statistics.py. glpi_python_client.rsql now owns the grammar: created_between, date_window and changed_since, exported from the package root. Retargeted from the originally proposed changed_since(date_mod): this codebase never filters on date_mod -- it appears only as a sort value -- so that builder would have deduplicated nothing. All three real sites build a date_creation window, so that is the one that pays for itself today. changed_since ships too, for incremental sync, but as a forward-looking helper with no current in-repo caller. The module is tree-neutral rather than living beside the six internal composition helpers, which are under _async/ and therefore duplicated into the generated tree -- a public API there could not be exported without picking a tree. GlpiEnum.rsql_equals is the precedent. The builders validate rather than concatenate, because GLPI v2 fails open on both counts: an unparsable bound produces an expression the server ignores, and an ignored filter returns the whole table with a 200. A reversed window is rejected for the same reason -- GLPI answers it with zero rows, which reads as "nothing matched" rather than "your dates are backwards". _filters.py's module docstring now records both fail-open behaviours and the object-vs-array join asymmetry, which were documented only in _statistics.py comments -- nowhere a filter author would look. Closes #34 Co-Authored-By: Claude Opus 5 (1M context) --- glpi_python_client/__init__.py | 8 + .../_async/clients/commons/_filters.py | 24 +++ .../_async/clients/custom/_statistics.py | 10 +- .../_sync/clients/commons/_filters.py | 24 +++ .../_sync/clients/custom/_statistics.py | 10 +- glpi_python_client/rsql.py | 188 ++++++++++++++++++ glpi_python_client/tests/test_rsql.py | 95 +++++++++ 7 files changed, 347 insertions(+), 12 deletions(-) create mode 100644 glpi_python_client/rsql.py create mode 100644 glpi_python_client/tests/test_rsql.py diff --git a/glpi_python_client/__init__.py b/glpi_python_client/__init__.py index 66ef03d..6fc4a2b 100644 --- a/glpi_python_client/__init__.py +++ b/glpi_python_client/__init__.py @@ -103,6 +103,11 @@ PostUser, TicketMarkdownOptions, ) +from glpi_python_client.rsql import ( + changed_since, + created_between, + date_window, +) __version__ = "0.4.2" @@ -190,4 +195,7 @@ "PostUser", "TicketMarkdownOptions", "__version__", + "changed_since", + "created_between", + "date_window", ] diff --git a/glpi_python_client/_async/clients/commons/_filters.py b/glpi_python_client/_async/clients/commons/_filters.py index 12f9b49..cdf31bc 100644 --- a/glpi_python_client/_async/clients/commons/_filters.py +++ b/glpi_python_client/_async/clients/commons/_filters.py @@ -4,6 +4,30 @@ for GLPI endpoints that accept RSQL-like query expressions. All functions return ``None`` when the supplied input is empty so callers can compose filters without sprinkling conditional blocks at every call site. + +Two properties of the v2 filter engine shape everything here, and both +fail *open*: + +* **An unrecognised filter field is ignored, not rejected.** The query + then returns the entire unfiltered table with a 200. A typo in a field + name does not raise -- it succeeds and answers with far too many rows, + which is why a filter that returns results is not evidence the filter + was applied. When validating a new expression against a live instance, + check that it returns *fewer* rows than the unfiltered baseline. +* **``;`` (AND) binds tighter than ``,`` (OR)**, so an unparenthesised + OR group silently drops every preceding AND clause for all but its + first alternative. See :func:`rsql_any_filter`, which exists for that + reason and carries the measured numbers. + +Nested *object* subfields can be joined (``status.id==1``, +``entity.id==3``). Nested *arrays* cannot: ``Ticket.team`` answers HTTP +500 for its contract-declared subfields and is silently ignored for every +other spelling, so actor selection has to go through the v1 search engine. +The same is expected of ``User.emails``. + +Date windows live on the public surface instead, in +:mod:`glpi_python_client.rsql`, because their grammar has an end-of-day +detail that is easy to get wrong and impossible to notice. """ from __future__ import annotations diff --git a/glpi_python_client/_async/clients/custom/_statistics.py b/glpi_python_client/_async/clients/custom/_statistics.py index 08a726b..1958f57 100644 --- a/glpi_python_client/_async/clients/custom/_statistics.py +++ b/glpi_python_client/_async/clients/custom/_statistics.py @@ -33,6 +33,7 @@ GlpiPriority, GlpiTicketType, ) +from glpi_python_client.rsql import created_between #: The GLPI v2 ticket search includes soft-deleted ("trashed") tickets by #: default, while the v1 search excludes them. Every aggregation here is @@ -393,8 +394,7 @@ async def get_ticket_statistics( entity_filter = rsql_any_filter( *(f"entity.id=={e.id}" for e in entities if e.id is not None) ) - date_filter = f"date_creation=ge={start.isoformat()};" - date_filter += f"date_creation=le={end.isoformat()} 23:59:59" + date_filter = created_between(start, end) query = rsql_all_filter( date_filter, entity_filter, @@ -526,8 +526,7 @@ async def get_task_durations( end_date=end_date, default_days=default_days, ) - date_filter = f"date_creation=ge={start.isoformat()};" - date_filter += f"date_creation=le={end.isoformat()} 23:59:59" + date_filter = created_between(start, end) entity_filter: str | None = None if entity_id is not None: @@ -743,8 +742,7 @@ async def get_user_activity( if u.id is not None } - date_range = f"date_creation=ge={start.isoformat()};" - date_range += f"date_creation=le={end.isoformat()} 23:59:59" + date_range = created_between(start, end) # The date window is resolved once for every user rather than once # per user per role. Previously each user drove two full pagings of diff --git a/glpi_python_client/_sync/clients/commons/_filters.py b/glpi_python_client/_sync/clients/commons/_filters.py index 12f9b49..cdf31bc 100644 --- a/glpi_python_client/_sync/clients/commons/_filters.py +++ b/glpi_python_client/_sync/clients/commons/_filters.py @@ -4,6 +4,30 @@ for GLPI endpoints that accept RSQL-like query expressions. All functions return ``None`` when the supplied input is empty so callers can compose filters without sprinkling conditional blocks at every call site. + +Two properties of the v2 filter engine shape everything here, and both +fail *open*: + +* **An unrecognised filter field is ignored, not rejected.** The query + then returns the entire unfiltered table with a 200. A typo in a field + name does not raise -- it succeeds and answers with far too many rows, + which is why a filter that returns results is not evidence the filter + was applied. When validating a new expression against a live instance, + check that it returns *fewer* rows than the unfiltered baseline. +* **``;`` (AND) binds tighter than ``,`` (OR)**, so an unparenthesised + OR group silently drops every preceding AND clause for all but its + first alternative. See :func:`rsql_any_filter`, which exists for that + reason and carries the measured numbers. + +Nested *object* subfields can be joined (``status.id==1``, +``entity.id==3``). Nested *arrays* cannot: ``Ticket.team`` answers HTTP +500 for its contract-declared subfields and is silently ignored for every +other spelling, so actor selection has to go through the v1 search engine. +The same is expected of ``User.emails``. + +Date windows live on the public surface instead, in +:mod:`glpi_python_client.rsql`, because their grammar has an end-of-day +detail that is easy to get wrong and impossible to notice. """ from __future__ import annotations diff --git a/glpi_python_client/_sync/clients/custom/_statistics.py b/glpi_python_client/_sync/clients/custom/_statistics.py index 1cf13ca..b493a4b 100644 --- a/glpi_python_client/_sync/clients/custom/_statistics.py +++ b/glpi_python_client/_sync/clients/custom/_statistics.py @@ -33,6 +33,7 @@ GlpiPriority, GlpiTicketType, ) +from glpi_python_client.rsql import created_between #: The GLPI v2 ticket search includes soft-deleted ("trashed") tickets by #: default, while the v1 search excludes them. Every aggregation here is @@ -393,8 +394,7 @@ def get_ticket_statistics( entity_filter = rsql_any_filter( *(f"entity.id=={e.id}" for e in entities if e.id is not None) ) - date_filter = f"date_creation=ge={start.isoformat()};" - date_filter += f"date_creation=le={end.isoformat()} 23:59:59" + date_filter = created_between(start, end) query = rsql_all_filter( date_filter, entity_filter, @@ -526,8 +526,7 @@ def get_task_durations( end_date=end_date, default_days=default_days, ) - date_filter = f"date_creation=ge={start.isoformat()};" - date_filter += f"date_creation=le={end.isoformat()} 23:59:59" + date_filter = created_between(start, end) entity_filter: str | None = None if entity_id is not None: @@ -743,8 +742,7 @@ def get_user_activity( if u.id is not None } - date_range = f"date_creation=ge={start.isoformat()};" - date_range += f"date_creation=le={end.isoformat()} 23:59:59" + date_range = created_between(start, end) # The date window is resolved once for every user rather than once # per user per role. Previously each user drove two full pagings of diff --git a/glpi_python_client/rsql.py b/glpi_python_client/rsql.py new file mode 100644 index 0000000..0c1bc03 --- /dev/null +++ b/glpi_python_client/rsql.py @@ -0,0 +1,188 @@ +"""Public RSQL builders for GLPI v2 date filters. + +GLPI v2 accepts RSQL expressions on the ``filter`` query parameter. Its +date grammar has two details that are easy to get subtly wrong and +impossible to notice when you do: + +* the upper bound of a day-granular window has to be spelled through + ``23:59:59``, because ``date_creation=le=2026-01-31`` compares against + midnight and silently excludes everything that happened on the 31st; +* a filter field the server does not recognise is **ignored**, and the + query then returns the whole unfiltered table rather than an error. + A window built with a typo does not fail -- it succeeds, loudly and + wrongly, with far too many rows. + +That second point is why these live on the public surface. Concatenating +the grammar at each call site means each call site is a fresh chance to +produce a filter that quietly matches everything. + +This module is deliberately tree-neutral: it holds pure string logic with +no I/O, so unlike the internal composition helpers it is not duplicated +into the generated synchronous tree and can be imported from one place on +either surface. + +Examples +-------- +Counting one month of tickets:: + + from glpi_python_client import created_between + + window = created_between("2026-01-01", "2026-01-31") + tickets = await client.search_tickets(window) + +Incremental sync since the last run:: + + from glpi_python_client import changed_since + + async for batch in client.iter_search_tickets(changed_since(last_run)): + ... +""" + +from __future__ import annotations + +from datetime import date, datetime, timezone + +from glpi_python_client._errors import GlpiValidationError + +#: Spelled-out end of day, appended to the upper bound of a date window. +#: +#: Without it GLPI compares against midnight and the final day of the +#: window is excluded -- a window that looks inclusive and is not. +_END_OF_DAY = "23:59:59" + + +def _coerce_date(value: date | str, *, parameter: str) -> date: + """Return ``value`` as a :class:`datetime.date`. + + ``datetime`` is accepted and truncated, because a day-granular window + built from ``datetime.now()`` is a normal thing to write. + + Raises + ------ + GlpiValidationError + When a string is not an ISO ``YYYY-MM-DD`` date. Failing here is + the point: an unparsable value concatenated into a filter yields + an expression GLPI ignores, and the query returns everything. + """ + + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + try: + return date.fromisoformat(value) + except (TypeError, ValueError) as exc: + raise GlpiValidationError( + f"{parameter} must be a date or an ISO YYYY-MM-DD string; got {value!r}" + ) from exc + + +def _render_moment(value: date | datetime | str, *, parameter: str) -> str: + """Render one date or datetime the way GLPI's date columns expect. + + An aware ``datetime`` is converted to UTC and rendered without its + offset. GLPI stores naive server-local timestamps and does not accept + an offset suffix, so passing one through would produce a filter the + server ignores -- and an ignored filter returns the whole table. + """ + + if isinstance(value, datetime): + moment = value.astimezone(timezone.utc) if value.tzinfo else value + return moment.strftime("%Y-%m-%d %H:%M:%S") + return _coerce_date(value, parameter=parameter).isoformat() + + +def date_window( + field: str, + start: date | str, + end: date | str, +) -> str: + """Build an inclusive day-granular RSQL window on one date field. + + Parameters + ---------- + field : str + Name of the GLPI date column, e.g. ``"date_creation"``. + start : date | str + First day included, as a ``date`` or an ISO ``YYYY-MM-DD`` string. + end : date | str + Last day included. Rendered through ``23:59:59`` so the day itself + is inside the window. + + Returns + ------- + str + An RSQL expression joining the two bounds with ``;`` (AND), ready + to pass as ``rsql_filter``. + + Raises + ------ + GlpiValidationError + If either bound is unparsable, or ``start`` falls after ``end``. + + Examples + -------- + >>> date_window("date_creation", "2026-01-01", "2026-01-31") + 'date_creation=ge=2026-01-01;date_creation=le=2026-01-31 23:59:59' + """ + + first = _coerce_date(start, parameter="start") + last = _coerce_date(end, parameter="end") + if first > last: + raise GlpiValidationError( + f"start ({first.isoformat()}) must not be after end ({last.isoformat()}); " + "GLPI answers a contradictory window with zero rows rather than an error." + ) + return f"{field}=ge={first.isoformat()};{field}=le={last.isoformat()} {_END_OF_DAY}" + + +def created_between(start: date | str, end: date | str) -> str: + """Build an inclusive window on ``date_creation``. + + The common case, and the one every reporting helper in this package + uses. See :func:`date_window` for the parameters. + + Examples + -------- + >>> created_between("2026-01-01", "2026-01-31") + 'date_creation=ge=2026-01-01;date_creation=le=2026-01-31 23:59:59' + """ + + return date_window("date_creation", start, end) + + +def changed_since(moment: date | datetime | str, *, field: str = "date_mod") -> str: + """Build an open-ended lower bound on a date field. + + Intended for incremental sync: fetch what changed since the last run, + with no upper bound. + + Parameters + ---------- + moment : date | datetime | str + Lower bound. A ``datetime`` keeps its time component so a re-sync + does not re-read a whole day; an aware one is converted to UTC and + rendered without its offset, which is the only form GLPI accepts. + field : str, optional + Date column to compare (defaults to ``"date_mod"``). + + Returns + ------- + str + An RSQL expression ready to pass as ``rsql_filter``. + + Raises + ------ + GlpiValidationError + If ``moment`` is a string that is not an ISO date. + + Examples + -------- + >>> changed_since("2026-01-01") + 'date_mod=ge=2026-01-01' + """ + + return f"{field}=ge={_render_moment(moment, parameter='moment')}" + + +__all__ = ["changed_since", "created_between", "date_window"] diff --git a/glpi_python_client/tests/test_rsql.py b/glpi_python_client/tests/test_rsql.py new file mode 100644 index 0000000..f8cbed4 --- /dev/null +++ b/glpi_python_client/tests/test_rsql.py @@ -0,0 +1,95 @@ +"""Unit tests for the public RSQL date builders.""" + +from __future__ import annotations + +from datetime import date, datetime, timezone + +import pytest + +from glpi_python_client import GlpiValidationError +from glpi_python_client.rsql import changed_since, created_between, date_window + + +def test_created_between_builds_an_inclusive_window() -> None: + """The window covers both endpoints, the end through end-of-day.""" + + assert created_between(date(2026, 1, 1), date(2026, 1, 31)) == ( + "date_creation=ge=2026-01-01;date_creation=le=2026-01-31 23:59:59" + ) + + +def test_created_between_accepts_iso_strings() -> None: + """ISO ``YYYY-MM-DD`` strings are accepted alongside date objects.""" + + assert created_between("2026-01-01", "2026-01-31") == created_between( + date(2026, 1, 1), date(2026, 1, 31) + ) + + +def test_created_between_accepts_a_single_day() -> None: + """A one-day window is a legitimate window, not an empty one.""" + + assert created_between(date(2026, 1, 1), date(2026, 1, 1)) == ( + "date_creation=ge=2026-01-01;date_creation=le=2026-01-01 23:59:59" + ) + + +def test_created_between_rejects_a_reversed_window() -> None: + """A start after the end is a caller error, not an empty result set. + + GLPI answers a contradictory window with zero rows, which reads as "no + tickets matched" rather than "your dates are backwards". + """ + + with pytest.raises(GlpiValidationError): + created_between(date(2026, 1, 31), date(2026, 1, 1)) + + +def test_created_between_rejects_a_malformed_date() -> None: + """A string that is not an ISO date fails here, not on the server.""" + + with pytest.raises(GlpiValidationError): + created_between("31/01/2026", "2026-01-31") + + +def test_date_window_targets_any_field() -> None: + """The field is a parameter so the same grammar serves other columns.""" + + assert date_window("date_mod", date(2026, 1, 1), date(2026, 1, 2)) == ( + "date_mod=ge=2026-01-01;date_mod=le=2026-01-02 23:59:59" + ) + + +def test_changed_since_builds_an_open_ended_lower_bound() -> None: + """Incremental sync needs a lower bound with no end.""" + + assert changed_since(date(2026, 1, 1)) == "date_mod=ge=2026-01-01" + + +def test_changed_since_renders_a_datetime_to_the_second() -> None: + """A datetime keeps its time component so a re-sync does not re-read a day.""" + + moment = datetime(2026, 1, 1, 13, 45, 30) + + assert changed_since(moment) == "date_mod=ge=2026-01-01 13:45:30" + + +def test_changed_since_drops_the_offset_of_an_aware_datetime() -> None: + """GLPI stores naive server-local timestamps and rejects an offset. + + An aware value is converted to UTC and rendered without the offset + rather than being refused, so callers holding aware datetimes do not + have to strip the tzinfo at every call site. + """ + + aware = datetime(2026, 1, 1, 13, 45, 30, tzinfo=timezone.utc) + + assert changed_since(aware) == "date_mod=ge=2026-01-01 13:45:30" + + +def test_changed_since_targets_any_field() -> None: + """``field`` is overridable for columns other than ``date_mod``.""" + + assert changed_since(date(2026, 1, 1), field="date_creation") == ( + "date_creation=ge=2026-01-01" + ) From 13c970f6fcbf38d24701c3e58416b5941ee19273 Mon Sep 17 00:00:00 2001 From: baraline Date: Wed, 12 Aug 2026 13:36:18 +0200 Subject: [PATCH 12/21] feat: add find_user_by_email as a client-side scan Every consumer resolving a person by e-mail was inventing its own approach. This gives them one implementation whose cost and limits are stated rather than discovered. It scans, and that is not a shortcut taken for expedience. GLPI exposes addresses as User.emails, a nested array, and the v2 filter engine cannot join a nested array: the structurally identical Ticket.team answers HTTP 500 for its declared subfields and is silently ignored for every other spelling. So there is no server-side e-mail filter to use. A guessed server-side fast path is deliberately not attempted, because the failure mode is invisible. v2 ignores a filter field it does not recognise and answers 200 with the whole unfiltered table, so a wrong e-mail filter returns a plausible non-empty page whose first row is somebody else. The documented "empty is not proof of absence" guard does not help -- that failure is not empty. Adding an RSQL fast path needs a live instance and a check that it returns FEWER rows than the unfiltered baseline, not merely that it returns rows. skip_entity defaults to True: a user outside the client's configured entity is invisible otherwise, and the helper would answer None for somebody who exists. Closes #33 Co-Authored-By: Claude Opus 5 (1M context) --- .../clients/api/administration/_user.py | 76 +++++++++ .../api/administration/tests/test_user.py | 155 ++++++++++++++++++ .../_sync/clients/api/administration/_user.py | 76 +++++++++ .../api/administration/tests/test_user.py | 155 ++++++++++++++++++ .../glpi-user-location-provisioning/SKILL.md | 1 + 5 files changed, 463 insertions(+) diff --git a/glpi_python_client/_async/clients/api/administration/_user.py b/glpi_python_client/_async/clients/api/administration/_user.py index af6957e..853d381 100644 --- a/glpi_python_client/_async/clients/api/administration/_user.py +++ b/glpi_python_client/_async/clients/api/administration/_user.py @@ -12,6 +12,7 @@ from glpi_python_client._async.clients.commons._constants import USER_ENDPOINT, GlpiId from glpi_python_client._async.clients.commons._transport import TransportMixin +from glpi_python_client._errors import GlpiValidationError from glpi_python_client.models.api_schema.administration._user import ( DeleteUser, GetUser, @@ -119,6 +120,81 @@ async def iter_search_users( break start += batch_size + async def find_user_by_email( + self, + email: str, + *, + rsql_filter: str = "", + batch_size: int = 100, + skip_entity: bool = True, + ) -> GetUser | None: + """Return the first user holding ``email``, or ``None``. + + **This scans.** GLPI exposes e-mail addresses as ``User.emails``, a + nested *array*, and the v2 filter engine cannot join a nested array + -- the structurally identical ``Ticket.team`` answers HTTP 500 for + its declared subfields and is silently ignored for every other + spelling. So there is no server-side e-mail filter to use, and the + addresses have to be compared client-side. + + Nothing about that is cheap: the scan costs one request per + ``batch_size`` users until it matches, so it is meant for + occasional resolution, not for a per-request lookup. Narrow it with + ``rsql_filter`` when you can (``"is_active==true"`` is the usual + one), and cache the resulting id rather than calling this again. + + A server-side fast path is deliberately *not* attempted. GLPI v2 + ignores a filter field it does not recognise and answers with the + whole unfiltered table, so a guessed e-mail filter would not fail + -- it would return a plausible non-empty page whose first row is + the wrong person. Guessing is the one thing this helper exists to + stop each caller doing separately. + + Parameters + ---------- + email : str + Address to look for. Compared case-insensitively after + trimming surrounding whitespace, the way mail systems treat it. + rsql_filter : str, optional + Raw RSQL filter narrowing the population scanned. Empty by + default, which scans every visible user. + batch_size : int, optional + Users fetched per request while scanning (defaults to 100). + skip_entity : bool, optional + When ``True`` (the default) the ``GLPI-Entity`` header is + omitted so the scan spans every entity the caller can see. A + user whose account lives outside the client's configured entity + is invisible otherwise, and the helper would answer ``None`` + for somebody who exists. + + Returns + ------- + GetUser | None + The first user with a matching address, or ``None`` when the + scanned population holds none. + + Raises + ------ + GlpiValidationError + If ``email`` is blank -- which would otherwise scan the whole + directory and match nothing. + """ + + needle = email.strip().casefold() + if not needle: + raise GlpiValidationError("find_user_by_email requires a non-empty address") + + async for batch in self.iter_search_users( + rsql_filter, + batch_size=batch_size, + skip_entity=skip_entity, + ): + for user in batch: + for entry in user.emails or (): + if entry.email and entry.email.strip().casefold() == needle: + return user + return None + async def get_user(self, user_id: GlpiId) -> GetUser: """Fetch one GLPI user by identifier. diff --git a/glpi_python_client/_async/clients/api/administration/tests/test_user.py b/glpi_python_client/_async/clients/api/administration/tests/test_user.py index b378e05..a3bc1f6 100644 --- a/glpi_python_client/_async/clients/api/administration/tests/test_user.py +++ b/glpi_python_client/_async/clients/api/administration/tests/test_user.py @@ -192,3 +192,158 @@ async def fake_search( batches = [batch async for batch in client.iter_search_users("", batch_size=2)] assert call_count == 2 assert sum(len(b) for b in batches) == 3 + + +async def test_find_user_by_email_matches_a_nested_email_entry(client: Any) -> None: + """The address is matched inside the ``emails`` array, not on a top field.""" + + from glpi_python_client.models.api_schema.administration._user import GetUser + + async def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0, **kwargs: Any + ) -> list[GetUser]: + if start: + return [] + return [ + GetUser.model_validate( + {"id": 1, "username": "alice", "emails": [{"email": "alice@x.test"}]} + ), + GetUser.model_validate( + {"id": 2, "username": "bob", "emails": [{"email": "bob@x.test"}]} + ), + ] + + client.search_users = fake_search # type: ignore[method-assign] + + found = await client.find_user_by_email("bob@x.test") + + assert found is not None + assert found.id == 2 + + +async def test_find_user_by_email_is_case_insensitive(client: Any) -> None: + """Addresses are compared case-insensitively, as mail systems treat them.""" + + from glpi_python_client.models.api_schema.administration._user import GetUser + + async def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0, **kwargs: Any + ) -> list[GetUser]: + if start: + return [] + return [ + GetUser.model_validate( + {"id": 1, "username": "alice", "emails": [{"email": "Alice@X.test"}]} + ) + ] + + client.search_users = fake_search # type: ignore[method-assign] + + found = await client.find_user_by_email(" ALICE@x.TEST ") + + assert found is not None + assert found.id == 1 + + +async def test_find_user_by_email_returns_none_when_absent(client: Any) -> None: + """No match is ``None`` rather than an arbitrary first user.""" + + from glpi_python_client.models.api_schema.administration._user import GetUser + + async def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0, **kwargs: Any + ) -> list[GetUser]: + if start: + return [] + return [ + GetUser.model_validate( + {"id": 1, "username": "alice", "emails": [{"email": "alice@x.test"}]} + ) + ] + + client.search_users = fake_search # type: ignore[method-assign] + + assert await client.find_user_by_email("nobody@x.test") is None + + +async def test_find_user_by_email_scans_past_the_first_page(client: Any) -> None: + """The match may live on any page, so the scan pages until it finds one.""" + + from glpi_python_client.models.api_schema.administration._user import GetUser + + pages = [ + [GetUser.model_validate({"id": i, "username": f"u{i}"}) for i in range(1, 3)], + [ + GetUser.model_validate( + {"id": 9, "username": "zoe", "emails": [{"email": "zoe@x.test"}]} + ), + ], + ] + + async def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0, **kwargs: Any + ) -> list[GetUser]: + index = start // limit + return pages[index] if index < len(pages) else [] + + client.search_users = fake_search # type: ignore[method-assign] + + found = await client.find_user_by_email("zoe@x.test", batch_size=2) + + assert found is not None + assert found.id == 9 + + +async def test_find_user_by_email_stops_at_the_matching_page(client: Any) -> None: + """The scan stops as soon as it matches instead of walking the directory.""" + + from glpi_python_client.models.api_schema.administration._user import GetUser + + starts: list[int] = [] + + async def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0, **kwargs: Any + ) -> list[GetUser]: + starts.append(start) + return [ + GetUser.model_validate( + {"id": 1, "username": "alice", "emails": [{"email": "alice@x.test"}]} + ) + ] * limit + + client.search_users = fake_search # type: ignore[method-assign] + + await client.find_user_by_email("alice@x.test", batch_size=2) + + assert starts == [0] + + +async def test_find_user_by_email_scans_every_entity_by_default(client: Any) -> None: + """The scan spans entities, or a match outside the header scope is missed.""" + + from glpi_python_client.models.api_schema.administration._user import GetUser + + seen: dict[str, Any] = {} + + async def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0, **kwargs: Any + ) -> list[GetUser]: + seen.update(kwargs) + seen["filter"] = rsql_filter + return [] + + client.search_users = fake_search # type: ignore[method-assign] + + await client.find_user_by_email("alice@x.test", rsql_filter="is_active==true") + + assert seen["skip_entity"] is True + assert seen["filter"] == "is_active==true" + + +async def test_find_user_by_email_rejects_a_blank_address(client: Any) -> None: + """A blank address would scan the whole directory and match nothing.""" + + from glpi_python_client import GlpiValidationError + + with pytest.raises(GlpiValidationError): + await client.find_user_by_email(" ") diff --git a/glpi_python_client/_sync/clients/api/administration/_user.py b/glpi_python_client/_sync/clients/api/administration/_user.py index 462c5bc..b1c6d5c 100644 --- a/glpi_python_client/_sync/clients/api/administration/_user.py +++ b/glpi_python_client/_sync/clients/api/administration/_user.py @@ -12,6 +12,7 @@ from glpi_python_client._sync.clients.commons._constants import USER_ENDPOINT, GlpiId from glpi_python_client._sync.clients.commons._transport import TransportMixin +from glpi_python_client._errors import GlpiValidationError from glpi_python_client.models.api_schema.administration._user import ( DeleteUser, GetUser, @@ -119,6 +120,81 @@ def iter_search_users( break start += batch_size + def find_user_by_email( + self, + email: str, + *, + rsql_filter: str = "", + batch_size: int = 100, + skip_entity: bool = True, + ) -> GetUser | None: + """Return the first user holding ``email``, or ``None``. + + **This scans.** GLPI exposes e-mail addresses as ``User.emails``, a + nested *array*, and the v2 filter engine cannot join a nested array + -- the structurally identical ``Ticket.team`` answers HTTP 500 for + its declared subfields and is silently ignored for every other + spelling. So there is no server-side e-mail filter to use, and the + addresses have to be compared client-side. + + Nothing about that is cheap: the scan costs one request per + ``batch_size`` users until it matches, so it is meant for + occasional resolution, not for a per-request lookup. Narrow it with + ``rsql_filter`` when you can (``"is_active==true"`` is the usual + one), and cache the resulting id rather than calling this again. + + A server-side fast path is deliberately *not* attempted. GLPI v2 + ignores a filter field it does not recognise and answers with the + whole unfiltered table, so a guessed e-mail filter would not fail + -- it would return a plausible non-empty page whose first row is + the wrong person. Guessing is the one thing this helper exists to + stop each caller doing separately. + + Parameters + ---------- + email : str + Address to look for. Compared case-insensitively after + trimming surrounding whitespace, the way mail systems treat it. + rsql_filter : str, optional + Raw RSQL filter narrowing the population scanned. Empty by + default, which scans every visible user. + batch_size : int, optional + Users fetched per request while scanning (defaults to 100). + skip_entity : bool, optional + When ``True`` (the default) the ``GLPI-Entity`` header is + omitted so the scan spans every entity the caller can see. A + user whose account lives outside the client's configured entity + is invisible otherwise, and the helper would answer ``None`` + for somebody who exists. + + Returns + ------- + GetUser | None + The first user with a matching address, or ``None`` when the + scanned population holds none. + + Raises + ------ + GlpiValidationError + If ``email`` is blank -- which would otherwise scan the whole + directory and match nothing. + """ + + needle = email.strip().casefold() + if not needle: + raise GlpiValidationError("find_user_by_email requires a non-empty address") + + for batch in self.iter_search_users( + rsql_filter, + batch_size=batch_size, + skip_entity=skip_entity, + ): + for user in batch: + for entry in user.emails or (): + if entry.email and entry.email.strip().casefold() == needle: + return user + return None + def get_user(self, user_id: GlpiId) -> GetUser: """Fetch one GLPI user by identifier. diff --git a/glpi_python_client/_sync/clients/api/administration/tests/test_user.py b/glpi_python_client/_sync/clients/api/administration/tests/test_user.py index 56d94bf..af25eef 100644 --- a/glpi_python_client/_sync/clients/api/administration/tests/test_user.py +++ b/glpi_python_client/_sync/clients/api/administration/tests/test_user.py @@ -192,3 +192,158 @@ def fake_search( batches = [batch for batch in client.iter_search_users("", batch_size=2)] assert call_count == 2 assert sum(len(b) for b in batches) == 3 + + +def test_find_user_by_email_matches_a_nested_email_entry(client: Any) -> None: + """The address is matched inside the ``emails`` array, not on a top field.""" + + from glpi_python_client.models.api_schema.administration._user import GetUser + + def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0, **kwargs: Any + ) -> list[GetUser]: + if start: + return [] + return [ + GetUser.model_validate( + {"id": 1, "username": "alice", "emails": [{"email": "alice@x.test"}]} + ), + GetUser.model_validate( + {"id": 2, "username": "bob", "emails": [{"email": "bob@x.test"}]} + ), + ] + + client.search_users = fake_search # type: ignore[method-assign] + + found = client.find_user_by_email("bob@x.test") + + assert found is not None + assert found.id == 2 + + +def test_find_user_by_email_is_case_insensitive(client: Any) -> None: + """Addresses are compared case-insensitively, as mail systems treat them.""" + + from glpi_python_client.models.api_schema.administration._user import GetUser + + def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0, **kwargs: Any + ) -> list[GetUser]: + if start: + return [] + return [ + GetUser.model_validate( + {"id": 1, "username": "alice", "emails": [{"email": "Alice@X.test"}]} + ) + ] + + client.search_users = fake_search # type: ignore[method-assign] + + found = client.find_user_by_email(" ALICE@x.TEST ") + + assert found is not None + assert found.id == 1 + + +def test_find_user_by_email_returns_none_when_absent(client: Any) -> None: + """No match is ``None`` rather than an arbitrary first user.""" + + from glpi_python_client.models.api_schema.administration._user import GetUser + + def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0, **kwargs: Any + ) -> list[GetUser]: + if start: + return [] + return [ + GetUser.model_validate( + {"id": 1, "username": "alice", "emails": [{"email": "alice@x.test"}]} + ) + ] + + client.search_users = fake_search # type: ignore[method-assign] + + assert client.find_user_by_email("nobody@x.test") is None + + +def test_find_user_by_email_scans_past_the_first_page(client: Any) -> None: + """The match may live on any page, so the scan pages until it finds one.""" + + from glpi_python_client.models.api_schema.administration._user import GetUser + + pages = [ + [GetUser.model_validate({"id": i, "username": f"u{i}"}) for i in range(1, 3)], + [ + GetUser.model_validate( + {"id": 9, "username": "zoe", "emails": [{"email": "zoe@x.test"}]} + ), + ], + ] + + def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0, **kwargs: Any + ) -> list[GetUser]: + index = start // limit + return pages[index] if index < len(pages) else [] + + client.search_users = fake_search # type: ignore[method-assign] + + found = client.find_user_by_email("zoe@x.test", batch_size=2) + + assert found is not None + assert found.id == 9 + + +def test_find_user_by_email_stops_at_the_matching_page(client: Any) -> None: + """The scan stops as soon as it matches instead of walking the directory.""" + + from glpi_python_client.models.api_schema.administration._user import GetUser + + starts: list[int] = [] + + def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0, **kwargs: Any + ) -> list[GetUser]: + starts.append(start) + return [ + GetUser.model_validate( + {"id": 1, "username": "alice", "emails": [{"email": "alice@x.test"}]} + ) + ] * limit + + client.search_users = fake_search # type: ignore[method-assign] + + client.find_user_by_email("alice@x.test", batch_size=2) + + assert starts == [0] + + +def test_find_user_by_email_scans_every_entity_by_default(client: Any) -> None: + """The scan spans entities, or a match outside the header scope is missed.""" + + from glpi_python_client.models.api_schema.administration._user import GetUser + + seen: dict[str, Any] = {} + + def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0, **kwargs: Any + ) -> list[GetUser]: + seen.update(kwargs) + seen["filter"] = rsql_filter + return [] + + client.search_users = fake_search # type: ignore[method-assign] + + client.find_user_by_email("alice@x.test", rsql_filter="is_active==true") + + assert seen["skip_entity"] is True + assert seen["filter"] == "is_active==true" + + +def test_find_user_by_email_rejects_a_blank_address(client: Any) -> None: + """A blank address would scan the whole directory and match nothing.""" + + from glpi_python_client import GlpiValidationError + + with pytest.raises(GlpiValidationError): + client.find_user_by_email(" ") diff --git a/skills/glpi-user-location-provisioning/SKILL.md b/skills/glpi-user-location-provisioning/SKILL.md index a523116..b0e406a 100644 --- a/skills/glpi-user-location-provisioning/SKILL.md +++ b/skills/glpi-user-location-provisioning/SKILL.md @@ -17,6 +17,7 @@ Users live under `/Administration/User`, entities under `/Administration/Entity` 1. Create a `GlpiClient` with the correct entity/profile scope. 2. Search before creating duplicates: `search_users(rsql_filter, limit=..., start=..., skip_entity=False)`, `search_locations(rsql_filter, limit=..., start=...)`, `search_entities(rsql_filter, limit=..., start=...)`. Scope matters here: `search_users` and `search_locations` are narrowed by the client's `GLPI-Entity` / `GLPI-Profile` headers, so pass `skip_entity=True` to `search_users` (the only one of the three `search_*` helpers that has the flag — `iter_search_users` takes it too) to look across every entity the caller can see before deciding a user does not exist; `search_entities` always bypasses those headers. + To resolve a person by e-mail use `await client.find_user_by_email("a@b.test")`, which returns `GetUser | None` and defaults to `skip_entity=True` for the reason just given. 3. Fetch one record with `get_user(user_id)`, `get_location(location_id)`, or `get_entity(entity_id)`. 4. Create with `create_user(PostUser(...))`, `create_location(PostLocation(...))`, or `create_entity(PostEntity(...))`. Each returns the new ID. 5. Update with `update_user(user_id, PatchUser(...))`, `update_location(location_id, PatchLocation(...))`, or `update_entity(entity_id, PatchEntity(...))`. From 03cf53d0427ba19ac771f0a9e21a620ab8b9d742 Mon Sep 17 00:00:00 2001 From: baraline Date: Wed, 12 Aug 2026 14:17:41 +0200 Subject: [PATCH 13/21] fix!: raise on a 4xx from a search instead of returning [] BREAKING: 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. The failure mode that decides it is the batch iterators. They stop on a page shorter than batch_size, so a 403 on page one ended the walk having yielded nothing and the caller saw a *successful* empty result. Combined with v2's other fail-open behaviour -- an unknown filter field is ignored and the whole table is returned -- the library had two silent wrong answers in opposite directions and neither raised. This reverses decision D2 of the 0.4.0 error work, which chose tolerance deliberately. The test that pinned the old behaviour is replaced by its inverse, plus a guard that a 200 carrying an empty list is still an ordinary empty result, and one that an iterator surfaces the error rather than ending quietly. Callers relying on [] after a permission error must now catch GlpiStatusError. Closes #28 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 34 +++++++++++++ .../clients/api/administration/_entity.py | 9 ---- .../clients/api/administration/_user.py | 9 ---- .../_async/clients/api/assistance/_ticket.py | 9 ---- .../_async/clients/api/dropdowns/_location.py | 9 ---- .../clients/api/knowledgebase/_article.py | 9 ---- .../clients/api/knowledgebase/_category.py | 9 ---- .../clients/api/management/_document.py | 9 ---- .../_async/clients/commons/_transport.py | 39 ++++++--------- .../clients/commons/tests/test_transport.py | 49 ++++++++++++++++--- .../clients/api/administration/_entity.py | 9 ---- .../_sync/clients/api/administration/_user.py | 9 ---- .../_sync/clients/api/assistance/_ticket.py | 9 ---- .../_sync/clients/api/dropdowns/_location.py | 9 ---- .../clients/api/knowledgebase/_article.py | 9 ---- .../clients/api/knowledgebase/_category.py | 9 ---- .../_sync/clients/api/management/_document.py | 9 ---- .../_sync/clients/commons/_transport.py | 39 ++++++--------- .../clients/commons/tests/test_transport.py | 49 ++++++++++++++++--- 19 files changed, 150 insertions(+), 186 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2031e7c..893ed67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## Unreleased +### Changed (breaking) + +- **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`.** + ### Fixed - **The unit test suite was published inside the wheel and the sdist.** Both @@ -152,6 +167,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### 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`. + - `glpi_python_client/clients/tests/test_async_selfcall_guard.py`: a structural AST guard that fails the suite if any public method on `GlpiClient` transitively reaches another public method through a diff --git a/glpi_python_client/_async/clients/api/administration/_entity.py b/glpi_python_client/_async/clients/api/administration/_entity.py index dccb843..0a4242e 100644 --- a/glpi_python_client/_async/clients/api/administration/_entity.py +++ b/glpi_python_client/_async/clients/api/administration/_entity.py @@ -78,15 +78,6 @@ async def iter_search_entities( batch_size : int, optional Number of records requested per page (default 50). - Notes - ----- - A 4xx response is swallowed by the underlying search helper, which - returns ``[]``. Because iteration stops on a page shorter than - ``batch_size``, a 4xx on the first page ends the walk having yielded - nothing -- indistinguishable from a filter that matched nothing. - Check the caller's permissions and entity scope before reading an - empty walk as an empty result set. 5xx still raises. - Yields ------ list[GetEntity] diff --git a/glpi_python_client/_async/clients/api/administration/_user.py b/glpi_python_client/_async/clients/api/administration/_user.py index 853d381..5cf5fc4 100644 --- a/glpi_python_client/_async/clients/api/administration/_user.py +++ b/glpi_python_client/_async/clients/api/administration/_user.py @@ -90,15 +90,6 @@ async def iter_search_users( When ``True`` the ``GLPI-Entity`` header is omitted so the search spans every entity the caller has access to. - Notes - ----- - A 4xx response is swallowed by the underlying search helper, which - returns ``[]``. Because iteration stops on a page shorter than - ``batch_size``, a 4xx on the first page ends the walk having yielded - nothing -- indistinguishable from a filter that matched nothing. - Check the caller's permissions and entity scope before reading an - empty walk as an empty result set. 5xx still raises. - Yields ------ list[GetUser] diff --git a/glpi_python_client/_async/clients/api/assistance/_ticket.py b/glpi_python_client/_async/clients/api/assistance/_ticket.py index 9125e63..e080333 100644 --- a/glpi_python_client/_async/clients/api/assistance/_ticket.py +++ b/glpi_python_client/_async/clients/api/assistance/_ticket.py @@ -98,15 +98,6 @@ async def iter_search_tickets( fields : tuple[str, ...], optional Restricted set of contract field names to request. - Notes - ----- - A 4xx response is swallowed by the underlying search helper, which - returns ``[]``. Because iteration stops on a page shorter than - ``batch_size``, a 4xx on the first page ends the walk having yielded - nothing -- indistinguishable from a filter that matched nothing. - Check the caller's permissions and entity scope before reading an - empty walk as an empty result set. 5xx still raises. - Yields ------ list[GetTicket] diff --git a/glpi_python_client/_async/clients/api/dropdowns/_location.py b/glpi_python_client/_async/clients/api/dropdowns/_location.py index 5c7e129..8698fe4 100644 --- a/glpi_python_client/_async/clients/api/dropdowns/_location.py +++ b/glpi_python_client/_async/clients/api/dropdowns/_location.py @@ -76,15 +76,6 @@ async def iter_search_locations( ``limit`` parameter on each underlying :meth:`search_locations` call. - Notes - ----- - A 4xx response is swallowed by the underlying search helper, which - returns ``[]``. Because iteration stops on a page shorter than - ``batch_size``, a 4xx on the first page ends the walk having yielded - nothing -- indistinguishable from a filter that matched nothing. - Check the caller's permissions and entity scope before reading an - empty walk as an empty result set. 5xx still raises. - Yields ------ list[GetLocation] diff --git a/glpi_python_client/_async/clients/api/knowledgebase/_article.py b/glpi_python_client/_async/clients/api/knowledgebase/_article.py index 8af88f1..3d6a0ae 100644 --- a/glpi_python_client/_async/clients/api/knowledgebase/_article.py +++ b/glpi_python_client/_async/clients/api/knowledgebase/_article.py @@ -101,15 +101,6 @@ async def iter_search_kb_articles( GLPI language code forwarded to each page request to select a translated view. - Notes - ----- - A 4xx response is swallowed by the underlying search helper, which - returns ``[]``. Because iteration stops on a page shorter than - ``batch_size``, a 4xx on the first page ends the walk having yielded - nothing -- indistinguishable from a filter that matched nothing. - Check the caller's permissions and entity scope before reading an - empty walk as an empty result set. 5xx still raises. - Yields ------ list[GetKBArticle] diff --git a/glpi_python_client/_async/clients/api/knowledgebase/_category.py b/glpi_python_client/_async/clients/api/knowledgebase/_category.py index 3b20ce7..42aafcc 100644 --- a/glpi_python_client/_async/clients/api/knowledgebase/_category.py +++ b/glpi_python_client/_async/clients/api/knowledgebase/_category.py @@ -96,15 +96,6 @@ async def iter_search_kb_categories( GLPI language code forwarded to each page request to select a translated view. - Notes - ----- - A 4xx response is swallowed by the underlying search helper, which - returns ``[]``. Because iteration stops on a page shorter than - ``batch_size``, a 4xx on the first page ends the walk having yielded - nothing -- indistinguishable from a filter that matched nothing. - Check the caller's permissions and entity scope before reading an - empty walk as an empty result set. 5xx still raises. - Yields ------ list[GetKBCategory] diff --git a/glpi_python_client/_async/clients/api/management/_document.py b/glpi_python_client/_async/clients/api/management/_document.py index 5f16129..0c3d1aa 100644 --- a/glpi_python_client/_async/clients/api/management/_document.py +++ b/glpi_python_client/_async/clients/api/management/_document.py @@ -86,15 +86,6 @@ async def iter_search_documents( ``limit`` parameter on each underlying :meth:`search_documents` call. - Notes - ----- - A 4xx response is swallowed by the underlying search helper, which - returns ``[]``. Because iteration stops on a page shorter than - ``batch_size``, a 4xx on the first page ends the walk having yielded - nothing -- indistinguishable from a filter that matched nothing. - Check the caller's permissions and entity scope before reading an - empty walk as an empty result set. 5xx still raises. - Yields ------ list[GetDocument] diff --git a/glpi_python_client/_async/clients/commons/_transport.py b/glpi_python_client/_async/clients/commons/_transport.py index 05d42a9..e6978e9 100644 --- a/glpi_python_client/_async/clients/commons/_transport.py +++ b/glpi_python_client/_async/clients/commons/_transport.py @@ -413,24 +413,9 @@ async def _resource_list( skip_entity : bool, optional When ``True`` the ``GLPI-Entity`` header is omitted. failure_message : str | None, optional - When provided, response status is checked with this message; - search-style endpoints that tolerate empty results pass - ``None``. - - Passing ``None`` means a **4xx is swallowed**: the status is - logged and this returns ``[]``, so a 400, 401, 403 or 404 is - indistinguishable from a search that legitimately matched - nothing. 5xx still raises - :class:`~glpi_python_client.GlpiServerError`, from - :func:`~glpi_python_client._async.clients.commons._http.finalize_request_response`. - - That is deliberate (0.4.0 plan-1, decision D2) rather than an - oversight, and it is load-bearing for the seven tolerant - search call sites. It is worth knowing about because it does - not compose well: the batch iterators stop when a page comes - back shorter than ``batch_size``, so a 403 on the first page - ends iteration having yielded nothing at all, and the caller - sees a successful empty walk. + Message embedded in the raised ``GlpiStatusError``. ``None`` + derives one from the endpoint; the status is checked either + way. success_statuses : tuple[int, ...], optional HTTP status codes considered successful when ``failure_message`` is set. @@ -447,12 +432,18 @@ async def _resource_list( response = await self._get_request( endpoint, params=params, skip_entity=skip_entity ) - if failure_message is not None: - ensure_response_status( - response, - success_statuses=success_statuses, - failure_message=failure_message, - ) + # The status is checked on every list call, search included. It used + # not to be, and a refused search then came back as ``[]``: a 403 was + # indistinguishable from a filter that matched nothing. It composed + # badly with the batch iterators, which stop on a page shorter than + # ``batch_size`` -- so a 403 on page one ended the walk having + # yielded nothing and the caller saw a successful empty result. An + # empty list now means the server said the result set is empty. + ensure_response_status( + response, + success_statuses=success_statuses, + failure_message=failure_message or f"Failed to list {endpoint}", + ) payload = response.json() items = ( unwrap_timeline_items(payload) diff --git a/glpi_python_client/_async/clients/commons/tests/test_transport.py b/glpi_python_client/_async/clients/commons/tests/test_transport.py index b3d35a4..677f0c5 100644 --- a/glpi_python_client/_async/clients/commons/tests/test_transport.py +++ b/glpi_python_client/_async/clients/commons/tests/test_transport.py @@ -25,6 +25,7 @@ AsyncGlpiClient, GlpiError, GlpiServerError, + GlpiStatusError, GlpiTimeoutError, GlpiTransportError, ) @@ -330,20 +331,56 @@ async def _send(method: str, url: str, **kw: Any) -> FakeResponse: assert response.status_code == 404 -async def test_tolerant_search_still_returns_empty_on_4xx(retry_client: Any) -> None: - """Search endpoints that pass no ``failure_message`` still swallow a 4xx. - - Guards the 7 tolerant ``_resource_list`` call sites against the 4xx raise - being moved into ``finalize_request_response``. +@pytest.mark.parametrize("status", [400, 401, 403, 404]) +async def test_search_raises_on_4xx_instead_of_returning_empty( + retry_client: Any, status: int +) -> None: + """A refused search is an error, not an empty result set. + + Search endpoints pass no ``failure_message``, and that used to mean the + status went unchecked: a 403 came back as ``[]``, indistinguishable from + a filter that matched nothing. It composed badly with the batch + iterators, which stop on a short page -- so a 403 on page one ended the + walk having yielded nothing and the caller saw a successful empty + result. """ async def _send(method: str, url: str, **kw: Any) -> FakeResponse: - return FakeResponse(status_code=400, payload=[], text="[]") + return FakeResponse(status_code=status, payload=[], text="[]") + + retry_client._send_request = _send # type: ignore[method-assign] + + with pytest.raises(GlpiStatusError): + await retry_client.search_tickets() + + +async def test_search_still_returns_empty_on_a_genuine_empty_result( + retry_client: Any, +) -> None: + """A 200 carrying an empty list is still an ordinary empty result.""" + + async def _send(method: str, url: str, **kw: Any) -> FakeResponse: + return FakeResponse(status_code=200, payload=[], text="[]") retry_client._send_request = _send # type: ignore[method-assign] + assert await retry_client.search_tickets() == [] +async def test_iterators_surface_a_4xx_instead_of_ending_silently( + retry_client: Any, +) -> None: + """The iterator raises rather than yielding nothing and looking finished.""" + + async def _send(method: str, url: str, **kw: Any) -> FakeResponse: + return FakeResponse(status_code=403, payload=[], text="[]") + + retry_client._send_request = _send # type: ignore[method-assign] + + with pytest.raises(GlpiStatusError): + [batch async for batch in retry_client.iter_search_tickets()] + + @pytest.mark.parametrize("method_name", _RETRIED_METHODS) async def test_network_errors_are_still_retried( retry_client: Any, method_name: str diff --git a/glpi_python_client/_sync/clients/api/administration/_entity.py b/glpi_python_client/_sync/clients/api/administration/_entity.py index 2afc439..3301c4e 100644 --- a/glpi_python_client/_sync/clients/api/administration/_entity.py +++ b/glpi_python_client/_sync/clients/api/administration/_entity.py @@ -78,15 +78,6 @@ def iter_search_entities( batch_size : int, optional Number of records requested per page (default 50). - Notes - ----- - A 4xx response is swallowed by the underlying search helper, which - returns ``[]``. Because iteration stops on a page shorter than - ``batch_size``, a 4xx on the first page ends the walk having yielded - nothing -- indistinguishable from a filter that matched nothing. - Check the caller's permissions and entity scope before reading an - empty walk as an empty result set. 5xx still raises. - Yields ------ list[GetEntity] diff --git a/glpi_python_client/_sync/clients/api/administration/_user.py b/glpi_python_client/_sync/clients/api/administration/_user.py index b1c6d5c..11eb40a 100644 --- a/glpi_python_client/_sync/clients/api/administration/_user.py +++ b/glpi_python_client/_sync/clients/api/administration/_user.py @@ -90,15 +90,6 @@ def iter_search_users( When ``True`` the ``GLPI-Entity`` header is omitted so the search spans every entity the caller has access to. - Notes - ----- - A 4xx response is swallowed by the underlying search helper, which - returns ``[]``. Because iteration stops on a page shorter than - ``batch_size``, a 4xx on the first page ends the walk having yielded - nothing -- indistinguishable from a filter that matched nothing. - Check the caller's permissions and entity scope before reading an - empty walk as an empty result set. 5xx still raises. - Yields ------ list[GetUser] diff --git a/glpi_python_client/_sync/clients/api/assistance/_ticket.py b/glpi_python_client/_sync/clients/api/assistance/_ticket.py index 2d3a2c5..546d6ea 100644 --- a/glpi_python_client/_sync/clients/api/assistance/_ticket.py +++ b/glpi_python_client/_sync/clients/api/assistance/_ticket.py @@ -98,15 +98,6 @@ def iter_search_tickets( fields : tuple[str, ...], optional Restricted set of contract field names to request. - Notes - ----- - A 4xx response is swallowed by the underlying search helper, which - returns ``[]``. Because iteration stops on a page shorter than - ``batch_size``, a 4xx on the first page ends the walk having yielded - nothing -- indistinguishable from a filter that matched nothing. - Check the caller's permissions and entity scope before reading an - empty walk as an empty result set. 5xx still raises. - Yields ------ list[GetTicket] diff --git a/glpi_python_client/_sync/clients/api/dropdowns/_location.py b/glpi_python_client/_sync/clients/api/dropdowns/_location.py index a14476a..283428b 100644 --- a/glpi_python_client/_sync/clients/api/dropdowns/_location.py +++ b/glpi_python_client/_sync/clients/api/dropdowns/_location.py @@ -76,15 +76,6 @@ def iter_search_locations( ``limit`` parameter on each underlying :meth:`search_locations` call. - Notes - ----- - A 4xx response is swallowed by the underlying search helper, which - returns ``[]``. Because iteration stops on a page shorter than - ``batch_size``, a 4xx on the first page ends the walk having yielded - nothing -- indistinguishable from a filter that matched nothing. - Check the caller's permissions and entity scope before reading an - empty walk as an empty result set. 5xx still raises. - Yields ------ list[GetLocation] diff --git a/glpi_python_client/_sync/clients/api/knowledgebase/_article.py b/glpi_python_client/_sync/clients/api/knowledgebase/_article.py index 80c0deb..30fef19 100644 --- a/glpi_python_client/_sync/clients/api/knowledgebase/_article.py +++ b/glpi_python_client/_sync/clients/api/knowledgebase/_article.py @@ -101,15 +101,6 @@ def iter_search_kb_articles( GLPI language code forwarded to each page request to select a translated view. - Notes - ----- - A 4xx response is swallowed by the underlying search helper, which - returns ``[]``. Because iteration stops on a page shorter than - ``batch_size``, a 4xx on the first page ends the walk having yielded - nothing -- indistinguishable from a filter that matched nothing. - Check the caller's permissions and entity scope before reading an - empty walk as an empty result set. 5xx still raises. - Yields ------ list[GetKBArticle] diff --git a/glpi_python_client/_sync/clients/api/knowledgebase/_category.py b/glpi_python_client/_sync/clients/api/knowledgebase/_category.py index 3641bbd..964d598 100644 --- a/glpi_python_client/_sync/clients/api/knowledgebase/_category.py +++ b/glpi_python_client/_sync/clients/api/knowledgebase/_category.py @@ -96,15 +96,6 @@ def iter_search_kb_categories( GLPI language code forwarded to each page request to select a translated view. - Notes - ----- - A 4xx response is swallowed by the underlying search helper, which - returns ``[]``. Because iteration stops on a page shorter than - ``batch_size``, a 4xx on the first page ends the walk having yielded - nothing -- indistinguishable from a filter that matched nothing. - Check the caller's permissions and entity scope before reading an - empty walk as an empty result set. 5xx still raises. - Yields ------ list[GetKBCategory] diff --git a/glpi_python_client/_sync/clients/api/management/_document.py b/glpi_python_client/_sync/clients/api/management/_document.py index 679cfff..74c4627 100644 --- a/glpi_python_client/_sync/clients/api/management/_document.py +++ b/glpi_python_client/_sync/clients/api/management/_document.py @@ -86,15 +86,6 @@ def iter_search_documents( ``limit`` parameter on each underlying :meth:`search_documents` call. - Notes - ----- - A 4xx response is swallowed by the underlying search helper, which - returns ``[]``. Because iteration stops on a page shorter than - ``batch_size``, a 4xx on the first page ends the walk having yielded - nothing -- indistinguishable from a filter that matched nothing. - Check the caller's permissions and entity scope before reading an - empty walk as an empty result set. 5xx still raises. - Yields ------ list[GetDocument] diff --git a/glpi_python_client/_sync/clients/commons/_transport.py b/glpi_python_client/_sync/clients/commons/_transport.py index 909c7ca..2f083a6 100644 --- a/glpi_python_client/_sync/clients/commons/_transport.py +++ b/glpi_python_client/_sync/clients/commons/_transport.py @@ -413,24 +413,9 @@ def _resource_list( skip_entity : bool, optional When ``True`` the ``GLPI-Entity`` header is omitted. failure_message : str | None, optional - When provided, response status is checked with this message; - search-style endpoints that tolerate empty results pass - ``None``. - - Passing ``None`` means a **4xx is swallowed**: the status is - logged and this returns ``[]``, so a 400, 401, 403 or 404 is - indistinguishable from a search that legitimately matched - nothing. 5xx still raises - :class:`~glpi_python_client.GlpiServerError`, from - :func:`~glpi_python_client._sync.clients.commons._http.finalize_request_response`. - - That is deliberate (0.4.0 plan-1, decision D2) rather than an - oversight, and it is load-bearing for the seven tolerant - search call sites. It is worth knowing about because it does - not compose well: the batch iterators stop when a page comes - back shorter than ``batch_size``, so a 403 on the first page - ends iteration having yielded nothing at all, and the caller - sees a successful empty walk. + Message embedded in the raised ``GlpiStatusError``. ``None`` + derives one from the endpoint; the status is checked either + way. success_statuses : tuple[int, ...], optional HTTP status codes considered successful when ``failure_message`` is set. @@ -447,12 +432,18 @@ def _resource_list( response = self._get_request( endpoint, params=params, skip_entity=skip_entity ) - if failure_message is not None: - ensure_response_status( - response, - success_statuses=success_statuses, - failure_message=failure_message, - ) + # The status is checked on every list call, search included. It used + # not to be, and a refused search then came back as ``[]``: a 403 was + # indistinguishable from a filter that matched nothing. It composed + # badly with the batch iterators, which stop on a page shorter than + # ``batch_size`` -- so a 403 on page one ended the walk having + # yielded nothing and the caller saw a successful empty result. An + # empty list now means the server said the result set is empty. + ensure_response_status( + response, + success_statuses=success_statuses, + failure_message=failure_message or f"Failed to list {endpoint}", + ) payload = response.json() items = ( unwrap_timeline_items(payload) diff --git a/glpi_python_client/_sync/clients/commons/tests/test_transport.py b/glpi_python_client/_sync/clients/commons/tests/test_transport.py index c1c24c8..e4d4a69 100644 --- a/glpi_python_client/_sync/clients/commons/tests/test_transport.py +++ b/glpi_python_client/_sync/clients/commons/tests/test_transport.py @@ -25,6 +25,7 @@ GlpiClient, GlpiError, GlpiServerError, + GlpiStatusError, GlpiTimeoutError, GlpiTransportError, ) @@ -330,20 +331,56 @@ def _send(method: str, url: str, **kw: Any) -> FakeResponse: assert response.status_code == 404 -def test_tolerant_search_still_returns_empty_on_4xx(retry_client: Any) -> None: - """Search endpoints that pass no ``failure_message`` still swallow a 4xx. - - Guards the 7 tolerant ``_resource_list`` call sites against the 4xx raise - being moved into ``finalize_request_response``. +@pytest.mark.parametrize("status", [400, 401, 403, 404]) +def test_search_raises_on_4xx_instead_of_returning_empty( + retry_client: Any, status: int +) -> None: + """A refused search is an error, not an empty result set. + + Search endpoints pass no ``failure_message``, and that used to mean the + status went unchecked: a 403 came back as ``[]``, indistinguishable from + a filter that matched nothing. It composed badly with the batch + iterators, which stop on a short page -- so a 403 on page one ended the + walk having yielded nothing and the caller saw a successful empty + result. """ def _send(method: str, url: str, **kw: Any) -> FakeResponse: - return FakeResponse(status_code=400, payload=[], text="[]") + return FakeResponse(status_code=status, payload=[], text="[]") + + retry_client._send_request = _send # type: ignore[method-assign] + + with pytest.raises(GlpiStatusError): + retry_client.search_tickets() + + +def test_search_still_returns_empty_on_a_genuine_empty_result( + retry_client: Any, +) -> None: + """A 200 carrying an empty list is still an ordinary empty result.""" + + def _send(method: str, url: str, **kw: Any) -> FakeResponse: + return FakeResponse(status_code=200, payload=[], text="[]") retry_client._send_request = _send # type: ignore[method-assign] + assert retry_client.search_tickets() == [] +def test_iterators_surface_a_4xx_instead_of_ending_silently( + retry_client: Any, +) -> None: + """The iterator raises rather than yielding nothing and looking finished.""" + + def _send(method: str, url: str, **kw: Any) -> FakeResponse: + return FakeResponse(status_code=403, payload=[], text="[]") + + retry_client._send_request = _send # type: ignore[method-assign] + + with pytest.raises(GlpiStatusError): + [batch for batch in retry_client.iter_search_tickets()] + + @pytest.mark.parametrize("method_name", _RETRIED_METHODS) def test_network_errors_are_still_retried( retry_client: Any, method_name: str From d13df4764ed6306ff68542874d8b1ad638b722df Mon Sep 17 00:00:00 2001 From: baraline Date: Wed, 12 Aug 2026 14:19:54 +0200 Subject: [PATCH 14/21] test: add a live probe for the two undecided wire-format questions Both #31 and #35 turn on what GLPI 11 actually puts on the wire, which this repository cannot answer from inside. #31 assumes datetimes arrive without an offset. That is unproven, and the project's own fixtures disagree with each other: the knowledge base tests use +00:00 (already parsing aware) while the timeline, management and administration tests use bare timestamps. If the live server sends an offset, #31 closes unbuilt. #35 assumes a POST returns only an id. _resource_create parses the whole body and keeps one integer, so if the body carries the full record the fix is to stop discarding it rather than to add a second request behind a create_*_and_fetch helper. The probe is read-mostly: it creates one ticket to observe a create response and deletes it in a finally. Credentials load exactly as the integration suite loads them. Refs #31, #35 Co-Authored-By: Claude Opus 5 (1M context) --- integration_tests/probe_wire_format.py | 211 +++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 integration_tests/probe_wire_format.py diff --git a/integration_tests/probe_wire_format.py b/integration_tests/probe_wire_format.py new file mode 100644 index 0000000..54f30f5 --- /dev/null +++ b/integration_tests/probe_wire_format.py @@ -0,0 +1,211 @@ +"""Record what GLPI 11 actually puts on the wire, for two open decisions. + +Run this against preprod and paste the output into issues #31 and #35. It +answers two questions the repository cannot answer from inside, and both +decide whether a proposed change is worth building at all: + +**#31 -- do datetimes arrive naive?** The proposal to attach a server +timezone assumes GLPI omits the UTC offset. Nothing here records a real v2 +response, and the project's own fixtures disagree: the knowledge base tests +use ``+00:00`` (which already parses aware) while the timeline, management +and administration tests use a bare ``2024-01-02T03:04:05``. If the live +server sends an offset, #31 can be closed unbuilt. + +**#35 -- what does a POST return?** ``_resource_create`` parses the whole +body and keeps one integer. If the body already carries the full record, +the fix is to stop discarding it, not to add a second request behind a +``create_*_and_fetch`` helper. + +This is a **read-mostly** probe. It creates exactly one ticket, to see a +create response, and deletes it again in a ``finally``. Nothing else is +written. + +Usage +----- + python integration_tests/probe_wire_format.py + +Credentials load the same way the integration suite loads them: from +``secrets/`` files, falling back to ``GLPI_*`` environment variables. +""" + +from __future__ import annotations + +import json +import os +import re +import sys +from pathlib import Path +from typing import Any + +import httpx + +_REPO_ROOT = Path(__file__).resolve().parents[1] +_SECRETS_DIR = _REPO_ROOT / "secrets" + +#: Wire values that look like a timestamp, offset-bearing or not. +_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}") + + +def _read_value(secret_name: str, env_name: str) -> str | None: + """Return the secret file's contents, or the environment fallback.""" + + path = _SECRETS_DIR / secret_name + if path.exists(): + value = path.read_text(encoding="utf-8").strip() + if value: + return value + env_value = os.environ.get(env_name) + return env_value.strip() if env_value else None + + +def _load() -> dict[str, str]: + """Resolve the live configuration or exit with what is missing.""" + + wanted = { + "api_url": ("glpi_api_url", "GLPI_API_URL"), + "client_id": ("glpi_client_id_test", "GLPI_CLIENT_ID"), + "client_secret": ("glpi_client_secret_test", "GLPI_CLIENT_SECRET"), + "username": ("glpi_username", "GLPI_USERNAME"), + "password": ("glpi_password", "GLPI_PASSWORD"), + } + config: dict[str, str] = {} + missing: list[str] = [] + for key, (secret, env) in wanted.items(): + value = _read_value(secret, env) + if value is None: + missing.append(secret) + else: + config[key] = value + if missing: + sys.exit("missing credentials: " + ", ".join(missing)) + return config + + +def _token(client: httpx.Client, config: dict[str, str]) -> str: + """Obtain an access token with the password grant.""" + + response = client.post( + f"{config['api_url'].rstrip('/')}/token", + data={ + "grant_type": "password", + "client_id": config["client_id"], + "client_secret": config["client_secret"], + "username": config["username"], + "password": config["password"], + "scope": "api", + }, + timeout=30, + ) + response.raise_for_status() + return str(response.json()["access_token"]) + + +def _walk_timestamps(payload: Any, prefix: str = "") -> list[tuple[str, str]]: + """Return every ``(path, value)`` in ``payload`` that looks like a timestamp.""" + + found: list[tuple[str, str]] = [] + if isinstance(payload, dict): + for key, value in payload.items(): + found.extend(_walk_timestamps(value, f"{prefix}.{key}" if prefix else key)) + elif isinstance(payload, list): + for index, value in enumerate(payload[:2]): + found.extend(_walk_timestamps(value, f"{prefix}[{index}]")) + elif isinstance(payload, str) and _TIMESTAMP.match(payload): + found.append((prefix, payload)) + return found + + +def _report_timestamps(label: str, payload: Any) -> None: + """Print every timestamp in one payload and whether it carries an offset.""" + + stamps = _walk_timestamps(payload) + if not stamps: + print(f" {label}: no timestamp-shaped values found") + return + for path, value in stamps: + aware = bool(re.search(r"(Z|[+-]\d{2}:?\d{2})$", value)) + verdict = "AWARE (carries an offset)" if aware else "NAIVE (no offset)" + print(f" {label}.{path} = {value!r} -> {verdict}") + + +def main() -> None: + """Run both probes and print a report to paste into the issues.""" + + config = _load() + base = config["api_url"].rstrip("/") + + with httpx.Client(verify=False, follow_redirects=True, timeout=30) as client: + headers = { + "Authorization": f"Bearer {_token(client, config)}", + "Content-Type": "application/json", + } + + print("=" * 72) + print("PROBE 1 (issue #31) -- datetime wire format") + print("=" * 72) + for label, path in ( + ("Ticket", "/Assistance/Ticket?limit=1"), + ("User", "/Administration/User?limit=1"), + ("KBArticle", "/Knowledgebase/Article?limit=1"), + ): + response = client.get(f"{base}{path}", headers=headers) + if response.status_code >= 400: + print(f" {label}: HTTP {response.status_code} -- skipped") + continue + _report_timestamps(label, response.json()) + + print() + print("=" * 72) + print("PROBE 2 (issue #35) -- what a create returns") + print("=" * 72) + created_id: int | None = None + try: + response = client.post( + f"{base}/Assistance/Ticket", + headers=headers, + json={ + "name": "py_glpi wire-format probe (safe to delete)", + "content": "

Automated probe. Deleted immediately.

", + }, + ) + print(f" status : {response.status_code}") + print(f" Location : {response.headers.get('Location', '')}") + print(f" body bytes : {len(response.content)}") + try: + body = response.json() + except ValueError: + print(f" body : {response.text[:200]!r}") + else: + keys = sorted(body) if isinstance(body, dict) else "" + print(f" body keys : {keys}") + print(" body :") + print(json.dumps(body, indent=4, ensure_ascii=False)[:2000]) + if isinstance(body, dict): + created_id = body.get("id") + print() + print( + " VERDICT: " + + ( + "id only -- a fetch is genuinely needed (#35)" + if set(body) <= {"id", "href"} + else f"{len(body)} fields returned -- the client is " + "DISCARDING a fuller record; fix that instead of " + "adding create_*_and_fetch (#35)" + ) + ) + finally: + if created_id: + cleanup = client.delete( + f"{base}/Assistance/Ticket/{created_id}", + headers=headers, + json={"force": True}, + ) + print() + print(f" cleanup: deleted {created_id} -> {cleanup.status_code}") + else: + print() + print(" cleanup: nothing to delete (no id parsed from the response)") + + +if __name__ == "__main__": + main() From b64cabdc0013ec2200308c5e38524d58695af8de Mon Sep 17 00:00:00 2001 From: baraline Date: Wed, 12 Aug 2026 14:58:15 +0200 Subject: [PATCH 15/21] test: diagnose an unreachable GLPI host before the probe connects The instance lives on an internal .local name, so running the probe off the corporate VPN failed during DNS with a forty-line httpx traceback ending in "getaddrinfo failed" -- which reads as a bug in the probe rather than a missing network. It now resolves the host first and, when that fails, distinguishes the two cases: public DNS also down (no connectivity) versus public DNS fine but the internal domain unresolvable (not on the VPN). The second prints one line naming the fix. The same check would help anyone running the integration suite, which fails identically for the same reason. Refs #31, #35 Co-Authored-By: Claude Opus 5 (1M context) --- integration_tests/probe_wire_format.py | 31 ++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/integration_tests/probe_wire_format.py b/integration_tests/probe_wire_format.py index 54f30f5..dc8ecdf 100644 --- a/integration_tests/probe_wire_format.py +++ b/integration_tests/probe_wire_format.py @@ -33,9 +33,11 @@ import json import os import re +import socket import sys from pathlib import Path from typing import Any +from urllib.parse import urlparse import httpx @@ -81,6 +83,34 @@ def _load() -> dict[str, str]: return config +def _check_reachable(api_url: str) -> None: + """Exit with a diagnosis when the API host does not resolve. + + The GLPI instance lives on an internal ``.local`` name, so running this + off the corporate VPN fails during DNS with a forty-line httpx traceback + ending in ``getaddrinfo failed`` -- which looks like a bug in the probe + rather than a missing network. Checking first turns that into one line. + """ + + host = urlparse(api_url).hostname + if not host: + sys.exit(f"glpi_api_url is not a URL: {api_url!r}") + try: + socket.getaddrinfo(host, None) + except socket.gaierror: + try: + socket.getaddrinfo("github.com", None) + except socket.gaierror: + sys.exit(f"cannot resolve {host} -- and public DNS is down too.") + sys.exit( + f"cannot resolve {host}.\n" + "Public DNS works, so this is name resolution for the internal " + "domain: connect to the corporate VPN and run this again.\n" + "(The integration suite cannot reach the instance either while " + "this fails.)" + ) + + def _token(client: httpx.Client, config: dict[str, str]) -> str: """Obtain an access token with the password grant.""" @@ -133,6 +163,7 @@ def main() -> None: config = _load() base = config["api_url"].rstrip("/") + _check_reachable(base) with httpx.Client(verify=False, follow_redirects=True, timeout=30) as client: headers = { From 0438c0bc19e9786e8c34ab0ef796fe260f3a6d4b Mon Sep 17 00:00:00 2001 From: baraline Date: Wed, 12 Aug 2026 15:11:14 +0200 Subject: [PATCH 16/21] fix: document the sort syntax GLPI actually accepts Three docstrings gave "date_mod desc" / "name asc" as the example sort value. Measured against live GLPI 11, that is HTTP 400 "Invalid property for sorting: date_mod desc" -- the documented example never worked. The correct spelling is field:direction. Also measured, because both are easy to get wrong in the quiet direction: a bare `field` is accepted but sorts *ascending*, and a separate `order` parameter is ignored, so `sort=date_mod&order=desc` silently returns oldest-first. sort='date_mod desc' -> 400 sort='-date_mod' -> 400 sort='date_mod,desc' -> 400 sort='date_mod' -> 206, ASCENDING (2018 first) sort='date_mod:desc' -> 206, correct (2026 first) The unit tests asserted the broken spelling was forwarded verbatim. What they are really pinning is that `sort` reaches the query string, so they now pin a spelling the server accepts. Note this got worse before it got better: until the 4xx flip in d4095de a caller following the docs got a silent [], not an error. Also fixes the probe's own two bugs found running it live -- it sent Content-Type: application/json on GETs, which makes GLPI parse the absent body and answer 400 "Contenu du JSON invalide", and it used client.delete(json=...), which httpx does not accept, so its cleanup crashed and left the test ticket behind. Co-Authored-By: Claude Opus 5 (1M context) --- .../_async/clients/api/assistance/_ticket.py | 10 +++++-- .../api/assistance/tests/test_ticket.py | 4 +-- .../clients/api/knowledgebase/_article.py | 10 +++++-- .../clients/api/knowledgebase/_category.py | 10 +++++-- .../api/knowledgebase/tests/test_article.py | 8 +++--- .../api/knowledgebase/tests/test_category.py | 4 +-- .../_sync/clients/api/assistance/_ticket.py | 10 +++++-- .../api/assistance/tests/test_ticket.py | 4 +-- .../clients/api/knowledgebase/_article.py | 10 +++++-- .../clients/api/knowledgebase/_category.py | 10 +++++-- .../api/knowledgebase/tests/test_article.py | 8 +++--- .../api/knowledgebase/tests/test_category.py | 4 +-- integration_tests/probe_wire_format.py | 26 ++++++++++++++----- 13 files changed, 84 insertions(+), 34 deletions(-) diff --git a/glpi_python_client/_async/clients/api/assistance/_ticket.py b/glpi_python_client/_async/clients/api/assistance/_ticket.py index e080333..dcad77a 100644 --- a/glpi_python_client/_async/clients/api/assistance/_ticket.py +++ b/glpi_python_client/_async/clients/api/assistance/_ticket.py @@ -49,7 +49,11 @@ async def search_tickets( start : int, optional Zero-based offset of the first record returned. sort : str | None, optional - ``sort`` query parameter forwarded as-is, e.g. ``"date_mod desc"``. + ``sort`` query parameter, spelled ``field`` or ``field:direction`` + (e.g. ``"date_mod:desc"``). Measured against GLPI 11: a space + before the direction answers **HTTP 400** ("Invalid property for + sorting"), a bare ``field`` sorts *ascending*, and a separate + ``order`` parameter is ignored. fields : tuple[str, ...], optional Restricted set of contract field names to request. Empty tuple lets the GLPI server pick its default field set. @@ -94,7 +98,9 @@ async def iter_search_tickets( the ``limit`` parameter on each underlying :meth:`search_tickets` call. sort : str | None, optional - ``sort`` query parameter forwarded as-is to each page request. + ``sort`` query parameter forwarded to each page request, + spelled ``field`` or ``field:direction`` (e.g. ``"date_mod:desc"``). + A space before the direction answers HTTP 400. fields : tuple[str, ...], optional Restricted set of contract field names to request. diff --git a/glpi_python_client/_async/clients/api/assistance/tests/test_ticket.py b/glpi_python_client/_async/clients/api/assistance/tests/test_ticket.py index 989cdcd..1675254 100644 --- a/glpi_python_client/_async/clients/api/assistance/tests/test_ticket.py +++ b/glpi_python_client/_async/clients/api/assistance/tests/test_ticket.py @@ -29,14 +29,14 @@ async def test_search_tickets_forwards_sort_and_fields(client: Any) -> None: rec = TransportRecorder(get_payload=[{"id": 1, "name": "n", "content": "c"}]) rec.install(client) tickets = await client.search_tickets( - "status==1", limit=5, start=10, sort="date_mod desc", fields=("id", "name") + "status==1", limit=5, start=10, sort="date_mod:desc", fields=("id", "name") ) assert len(tickets) == 1 assert rec.calls[0]["params"]["filter"] == "status==1" assert rec.calls[0]["params"]["limit"] == 5 assert rec.calls[0]["params"]["start"] == 10 - assert rec.calls[0]["params"]["sort"] == "date_mod desc" + assert rec.calls[0]["params"]["sort"] == "date_mod:desc" assert rec.calls[0]["params"]["fields"] == "id,name" diff --git a/glpi_python_client/_async/clients/api/knowledgebase/_article.py b/glpi_python_client/_async/clients/api/knowledgebase/_article.py index 3d6a0ae..1929eb7 100644 --- a/glpi_python_client/_async/clients/api/knowledgebase/_article.py +++ b/glpi_python_client/_async/clients/api/knowledgebase/_article.py @@ -50,7 +50,11 @@ async def search_kb_articles( start : int, optional Zero-based offset of the first record returned. sort : str | None, optional - ``sort`` query parameter forwarded as-is. + ``sort`` query parameter, spelled ``field`` or ``field:direction`` + (e.g. ``"date_mod:desc"``). Measured against GLPI 11: a space + before the direction answers **HTTP 400** ("Invalid property for + sorting"), a bare ``field`` sorts *ascending*, and a separate + ``order`` parameter is ignored. language : str | None, optional GLPI language code forwarded as the ``language`` query parameter to select a translated view. @@ -96,7 +100,9 @@ async def iter_search_kb_articles( ``limit`` parameter on each underlying :meth:`search_kb_articles` call. sort : str | None, optional - ``sort`` query parameter forwarded as-is to each page request. + ``sort`` query parameter forwarded to each page request, + spelled ``field`` or ``field:direction`` (e.g. ``"date_mod:desc"``). + A space before the direction answers HTTP 400. language : str | None, optional GLPI language code forwarded to each page request to select a translated view. diff --git a/glpi_python_client/_async/clients/api/knowledgebase/_category.py b/glpi_python_client/_async/clients/api/knowledgebase/_category.py index 42aafcc..10453c4 100644 --- a/glpi_python_client/_async/clients/api/knowledgebase/_category.py +++ b/glpi_python_client/_async/clients/api/knowledgebase/_category.py @@ -45,7 +45,11 @@ async def search_kb_categories( start : int, optional Zero-based offset of the first record returned. sort : str | None, optional - ``sort`` query parameter forwarded as-is, e.g. ``"name asc"``. + ``sort`` query parameter, spelled ``field`` or ``field:direction`` + (e.g. ``"name:asc"``). Measured against GLPI 11: a space + before the direction answers **HTTP 400** ("Invalid property for + sorting"), a bare ``field`` sorts *ascending*, and a separate + ``order`` parameter is ignored. language : str | None, optional GLPI language code forwarded as the ``language`` query parameter to select a translated view. @@ -91,7 +95,9 @@ async def iter_search_kb_categories( ``limit`` parameter on each underlying :meth:`search_kb_categories` call. sort : str | None, optional - ``sort`` query parameter forwarded as-is to each page request. + ``sort`` query parameter forwarded to each page request, + spelled ``field`` or ``field:direction`` (e.g. ``"date_mod:desc"``). + A space before the direction answers HTTP 400. language : str | None, optional GLPI language code forwarded to each page request to select a translated view. diff --git a/glpi_python_client/_async/clients/api/knowledgebase/tests/test_article.py b/glpi_python_client/_async/clients/api/knowledgebase/tests/test_article.py index 861ac72..e35dba4 100644 --- a/glpi_python_client/_async/clients/api/knowledgebase/tests/test_article.py +++ b/glpi_python_client/_async/clients/api/knowledgebase/tests/test_article.py @@ -111,7 +111,7 @@ async def test_search_kb_articles_forwards_params(client: Any) -> None: rec = _Recorder(get_payload=[{"id": 1, "name": "Reset", "content": "

c

"}]) rec.install(client) result = await client.search_kb_articles( - "is_faq==1", limit=3, start=1, sort="date_mod desc", language="en_GB" + "is_faq==1", limit=3, start=1, sort="date_mod:desc", language="en_GB" ) assert result[0].id == 1 call = rec.calls[0] @@ -119,7 +119,7 @@ async def test_search_kb_articles_forwards_params(client: Any) -> None: assert call["params"]["filter"] == "is_faq==1" assert call["params"]["limit"] == 3 assert call["params"]["start"] == 1 - assert call["params"]["sort"] == "date_mod desc" + assert call["params"]["sort"] == "date_mod:desc" assert call["params"]["language"] == "en_GB" @@ -374,13 +374,13 @@ async def fake_search( batches = [ batch async for batch in client.iter_search_kb_articles( - "name==x", batch_size=3, sort="name asc" + "name==x", batch_size=3, sort="name:asc" ) ] assert starts == [0, 3] assert [len(b) for b in batches] == [3, 1] - assert forwarded["sort"] == "name asc" + assert forwarded["sort"] == "name:asc" async def test_iter_search_kb_articles_stops_on_a_single_short_page( diff --git a/glpi_python_client/_async/clients/api/knowledgebase/tests/test_category.py b/glpi_python_client/_async/clients/api/knowledgebase/tests/test_category.py index 0f823fe..b82f570 100644 --- a/glpi_python_client/_async/clients/api/knowledgebase/tests/test_category.py +++ b/glpi_python_client/_async/clients/api/knowledgebase/tests/test_category.py @@ -73,7 +73,7 @@ async def test_search_kb_categories_forwards_filter_and_language(client: Any) -> rec = _Recorder(get_payload=[{"id": 1, "name": "Network"}]) rec.install(client) result = await client.search_kb_categories( - "name==Network", limit=5, start=2, sort="name asc", language="fr_FR" + "name==Network", limit=5, start=2, sort="name:asc", language="fr_FR" ) assert result[0].id == 1 call = rec.calls[0] @@ -81,7 +81,7 @@ async def test_search_kb_categories_forwards_filter_and_language(client: Any) -> assert call["params"]["filter"] == "name==Network" assert call["params"]["limit"] == 5 assert call["params"]["start"] == 2 - assert call["params"]["sort"] == "name asc" + assert call["params"]["sort"] == "name:asc" assert call["params"]["language"] == "fr_FR" diff --git a/glpi_python_client/_sync/clients/api/assistance/_ticket.py b/glpi_python_client/_sync/clients/api/assistance/_ticket.py index 546d6ea..0b39cc1 100644 --- a/glpi_python_client/_sync/clients/api/assistance/_ticket.py +++ b/glpi_python_client/_sync/clients/api/assistance/_ticket.py @@ -49,7 +49,11 @@ def search_tickets( start : int, optional Zero-based offset of the first record returned. sort : str | None, optional - ``sort`` query parameter forwarded as-is, e.g. ``"date_mod desc"``. + ``sort`` query parameter, spelled ``field`` or ``field:direction`` + (e.g. ``"date_mod:desc"``). Measured against GLPI 11: a space + before the direction answers **HTTP 400** ("Invalid property for + sorting"), a bare ``field`` sorts *ascending*, and a separate + ``order`` parameter is ignored. fields : tuple[str, ...], optional Restricted set of contract field names to request. Empty tuple lets the GLPI server pick its default field set. @@ -94,7 +98,9 @@ def iter_search_tickets( the ``limit`` parameter on each underlying :meth:`search_tickets` call. sort : str | None, optional - ``sort`` query parameter forwarded as-is to each page request. + ``sort`` query parameter forwarded to each page request, + spelled ``field`` or ``field:direction`` (e.g. ``"date_mod:desc"``). + A space before the direction answers HTTP 400. fields : tuple[str, ...], optional Restricted set of contract field names to request. diff --git a/glpi_python_client/_sync/clients/api/assistance/tests/test_ticket.py b/glpi_python_client/_sync/clients/api/assistance/tests/test_ticket.py index e7cfeba..ec25ebf 100644 --- a/glpi_python_client/_sync/clients/api/assistance/tests/test_ticket.py +++ b/glpi_python_client/_sync/clients/api/assistance/tests/test_ticket.py @@ -29,14 +29,14 @@ def test_search_tickets_forwards_sort_and_fields(client: Any) -> None: rec = TransportRecorder(get_payload=[{"id": 1, "name": "n", "content": "c"}]) rec.install(client) tickets = client.search_tickets( - "status==1", limit=5, start=10, sort="date_mod desc", fields=("id", "name") + "status==1", limit=5, start=10, sort="date_mod:desc", fields=("id", "name") ) assert len(tickets) == 1 assert rec.calls[0]["params"]["filter"] == "status==1" assert rec.calls[0]["params"]["limit"] == 5 assert rec.calls[0]["params"]["start"] == 10 - assert rec.calls[0]["params"]["sort"] == "date_mod desc" + assert rec.calls[0]["params"]["sort"] == "date_mod:desc" assert rec.calls[0]["params"]["fields"] == "id,name" diff --git a/glpi_python_client/_sync/clients/api/knowledgebase/_article.py b/glpi_python_client/_sync/clients/api/knowledgebase/_article.py index 30fef19..753ebd5 100644 --- a/glpi_python_client/_sync/clients/api/knowledgebase/_article.py +++ b/glpi_python_client/_sync/clients/api/knowledgebase/_article.py @@ -50,7 +50,11 @@ def search_kb_articles( start : int, optional Zero-based offset of the first record returned. sort : str | None, optional - ``sort`` query parameter forwarded as-is. + ``sort`` query parameter, spelled ``field`` or ``field:direction`` + (e.g. ``"date_mod:desc"``). Measured against GLPI 11: a space + before the direction answers **HTTP 400** ("Invalid property for + sorting"), a bare ``field`` sorts *ascending*, and a separate + ``order`` parameter is ignored. language : str | None, optional GLPI language code forwarded as the ``language`` query parameter to select a translated view. @@ -96,7 +100,9 @@ def iter_search_kb_articles( ``limit`` parameter on each underlying :meth:`search_kb_articles` call. sort : str | None, optional - ``sort`` query parameter forwarded as-is to each page request. + ``sort`` query parameter forwarded to each page request, + spelled ``field`` or ``field:direction`` (e.g. ``"date_mod:desc"``). + A space before the direction answers HTTP 400. language : str | None, optional GLPI language code forwarded to each page request to select a translated view. diff --git a/glpi_python_client/_sync/clients/api/knowledgebase/_category.py b/glpi_python_client/_sync/clients/api/knowledgebase/_category.py index 964d598..73bd4e5 100644 --- a/glpi_python_client/_sync/clients/api/knowledgebase/_category.py +++ b/glpi_python_client/_sync/clients/api/knowledgebase/_category.py @@ -45,7 +45,11 @@ def search_kb_categories( start : int, optional Zero-based offset of the first record returned. sort : str | None, optional - ``sort`` query parameter forwarded as-is, e.g. ``"name asc"``. + ``sort`` query parameter, spelled ``field`` or ``field:direction`` + (e.g. ``"name:asc"``). Measured against GLPI 11: a space + before the direction answers **HTTP 400** ("Invalid property for + sorting"), a bare ``field`` sorts *ascending*, and a separate + ``order`` parameter is ignored. language : str | None, optional GLPI language code forwarded as the ``language`` query parameter to select a translated view. @@ -91,7 +95,9 @@ def iter_search_kb_categories( ``limit`` parameter on each underlying :meth:`search_kb_categories` call. sort : str | None, optional - ``sort`` query parameter forwarded as-is to each page request. + ``sort`` query parameter forwarded to each page request, + spelled ``field`` or ``field:direction`` (e.g. ``"date_mod:desc"``). + A space before the direction answers HTTP 400. language : str | None, optional GLPI language code forwarded to each page request to select a translated view. diff --git a/glpi_python_client/_sync/clients/api/knowledgebase/tests/test_article.py b/glpi_python_client/_sync/clients/api/knowledgebase/tests/test_article.py index 5759ec8..f2e1786 100644 --- a/glpi_python_client/_sync/clients/api/knowledgebase/tests/test_article.py +++ b/glpi_python_client/_sync/clients/api/knowledgebase/tests/test_article.py @@ -111,7 +111,7 @@ def test_search_kb_articles_forwards_params(client: Any) -> None: rec = _Recorder(get_payload=[{"id": 1, "name": "Reset", "content": "

c

"}]) rec.install(client) result = client.search_kb_articles( - "is_faq==1", limit=3, start=1, sort="date_mod desc", language="en_GB" + "is_faq==1", limit=3, start=1, sort="date_mod:desc", language="en_GB" ) assert result[0].id == 1 call = rec.calls[0] @@ -119,7 +119,7 @@ def test_search_kb_articles_forwards_params(client: Any) -> None: assert call["params"]["filter"] == "is_faq==1" assert call["params"]["limit"] == 3 assert call["params"]["start"] == 1 - assert call["params"]["sort"] == "date_mod desc" + assert call["params"]["sort"] == "date_mod:desc" assert call["params"]["language"] == "en_GB" @@ -374,13 +374,13 @@ def fake_search( batches = [ batch for batch in client.iter_search_kb_articles( - "name==x", batch_size=3, sort="name asc" + "name==x", batch_size=3, sort="name:asc" ) ] assert starts == [0, 3] assert [len(b) for b in batches] == [3, 1] - assert forwarded["sort"] == "name asc" + assert forwarded["sort"] == "name:asc" def test_iter_search_kb_articles_stops_on_a_single_short_page( diff --git a/glpi_python_client/_sync/clients/api/knowledgebase/tests/test_category.py b/glpi_python_client/_sync/clients/api/knowledgebase/tests/test_category.py index 6cd95a3..fa8c5d6 100644 --- a/glpi_python_client/_sync/clients/api/knowledgebase/tests/test_category.py +++ b/glpi_python_client/_sync/clients/api/knowledgebase/tests/test_category.py @@ -73,7 +73,7 @@ def test_search_kb_categories_forwards_filter_and_language(client: Any) -> None: rec = _Recorder(get_payload=[{"id": 1, "name": "Network"}]) rec.install(client) result = client.search_kb_categories( - "name==Network", limit=5, start=2, sort="name asc", language="fr_FR" + "name==Network", limit=5, start=2, sort="name:asc", language="fr_FR" ) assert result[0].id == 1 call = rec.calls[0] @@ -81,7 +81,7 @@ def test_search_kb_categories_forwards_filter_and_language(client: Any) -> None: assert call["params"]["filter"] == "name==Network" assert call["params"]["limit"] == 5 assert call["params"]["start"] == 2 - assert call["params"]["sort"] == "name asc" + assert call["params"]["sort"] == "name:asc" assert call["params"]["language"] == "fr_FR" diff --git a/integration_tests/probe_wire_format.py b/integration_tests/probe_wire_format.py index dc8ecdf..4a536ed 100644 --- a/integration_tests/probe_wire_format.py +++ b/integration_tests/probe_wire_format.py @@ -166,8 +166,15 @@ def main() -> None: _check_reachable(base) with httpx.Client(verify=False, follow_redirects=True, timeout=30) as client: - headers = { - "Authorization": f"Bearer {_token(client, config)}", + token = _token(client, config) + # A GET must NOT advertise a JSON content type. GLPI sees the header, + # tries to parse the (absent) body, and answers 400 "Contenu du JSON + # invalide" -- an error about the request body on a request that has + # none. The library avoids this with its `include_content_type` flag, + # which is False for GET; the two header sets here mirror that. + read_headers = {"Authorization": f"Bearer {token}"} + write_headers = { + "Authorization": f"Bearer {token}", "Content-Type": "application/json", } @@ -179,7 +186,7 @@ def main() -> None: ("User", "/Administration/User?limit=1"), ("KBArticle", "/Knowledgebase/Article?limit=1"), ): - response = client.get(f"{base}{path}", headers=headers) + response = client.get(f"{base}{path}", headers=read_headers) if response.status_code >= 400: print(f" {label}: HTTP {response.status_code} -- skipped") continue @@ -193,7 +200,7 @@ def main() -> None: try: response = client.post( f"{base}/Assistance/Ticket", - headers=headers, + headers=write_headers, json={ "name": "py_glpi wire-format probe (safe to delete)", "content": "

Automated probe. Deleted immediately.

", @@ -226,9 +233,16 @@ def main() -> None: ) finally: if created_id: - cleanup = client.delete( + # `client.delete(...)` rejects `json=` -- httpx exposes a body + # only on `request()`, because DELETE-with-a-body is unusual. + # GLPI needs one for `force`, so this must not be "simplified" + # back to the convenience method; the library's own + # `_delete_request` goes through `session.request` for the + # same reason. + cleanup = client.request( + "DELETE", f"{base}/Assistance/Ticket/{created_id}", - headers=headers, + headers=write_headers, json={"force": True}, ) print() From ce4eed7ac5fe8e9a2b70d8446e35db20e04f2b71 Mon Sep 17 00:00:00 2001 From: baraline Date: Wed, 12 Aug 2026 18:02:07 +0200 Subject: [PATCH 17/21] feat!: require server_timezone and localise the timestamps GLPI sends bare BREAKING: `server_timezone` is now a required client argument, read from GLPI_SERVER_TIMEZONE by from_env. It takes an IANA name ("Europe/Paris") or a tzinfo. Measured against a live GLPI 11 instance, 19 of the 20 datetime fields across every resource arrive with the correct historical offset. One does not: KBArticle.revisions[].date. So a single response carries both kinds, and comparing them raises "can't compare offset-naive and offset-aware datetimes" -- sorting an article's revision history against the article's own dates was enough to hit it. There is deliberately no default. Every candidate is wrong somewhere: against this instance, assuming UTC shifts the affected values by two hours AND stops them raising, which converts a loud failure into a quiet wrong answer. Requiring the operator to declare it is the only option that cannot be silently wrong, and GLPI does not advertise it anywhere. An IANA name rather than a fixed offset because a name follows DST -- the same instance emits +01:00 and +02:00 depending on the date, so a fixed offset would be wrong for half the year. Two rules keep it safe: an offset already on the wire always wins over the configured zone, and a model validated without a context keeps its naive values instead of being stamped with a guess. The zone is threaded through model_from_payload as a pydantic validation context, which reaches nested submodels -- necessary, since the naive field is nested. The three model_validate calls in plugins/_fields.py that bypassed the helper now go through it, so the choke point is real rather than nearly-real. Adds tzdata on Windows, which ships no system tz database; without it zoneinfo resolves in Linux CI and raises on a developer machine. Closes #31 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 26 ++++ docs/user_guide.rst | 5 + .../_async/clients/_base_client.py | 17 ++- .../api/knowledgebase/tests/test_article.py | 38 +++++- .../_async/clients/api/plugins/_fields.py | 22 ++- .../_async/clients/commons/_config.py | 61 +++++++++ .../_async/clients/commons/_payloads.py | 31 ++++- .../_async/clients/commons/_transport.py | 11 +- .../clients/commons/tests/test_config.py | 47 +++++++ .../_async/clients/tests/test_client.py | 12 +- .../_sync/clients/_base_client.py | 17 ++- .../api/knowledgebase/tests/test_article.py | 38 +++++- .../_sync/clients/api/plugins/_fields.py | 22 ++- .../_sync/clients/commons/_config.py | 61 +++++++++ .../_sync/clients/commons/_payloads.py | 31 ++++- .../_sync/clients/commons/_transport.py | 11 +- .../clients/commons/tests/test_config.py | 47 +++++++ .../_sync/clients/tests/test_client.py | 12 +- glpi_python_client/models/_base.py | 55 +++++++- glpi_python_client/models/tests/__init__.py | 1 + glpi_python_client/models/tests/test_base.py | 128 ++++++++++++++++++ glpi_python_client/testing/utils.py | 1 + integration_tests/test_integration.py | 4 + integration_tests/test_integration_async.py | 1 + integration_tests/test_integration_kb.py | 1 + pyproject.toml | 7 + skills/glpi-client-setup/SKILL.md | 1 + 27 files changed, 681 insertions(+), 27 deletions(-) create mode 100644 glpi_python_client/models/tests/__init__.py create mode 100644 glpi_python_client/models/tests/test_base.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 893ed67..79453ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,32 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### 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 diff --git a/docs/user_guide.rst b/docs/user_guide.rst index 9a2fb9f..3c0b8d2 100644 --- a/docs/user_guide.rst +++ b/docs/user_guide.rst @@ -134,6 +134,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`` diff --git a/glpi_python_client/_async/clients/_base_client.py b/glpi_python_client/_async/clients/_base_client.py index 90c9c40..52e3433 100644 --- a/glpi_python_client/_async/clients/_base_client.py +++ b/glpi_python_client/_async/clients/_base_client.py @@ -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__) @@ -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, @@ -66,6 +69,16 @@ 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 is needed to interpret the timestamps + the server sends without an offset. 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 @@ -103,6 +116,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__, @@ -142,7 +156,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 ---------- diff --git a/glpi_python_client/_async/clients/api/knowledgebase/tests/test_article.py b/glpi_python_client/_async/clients/api/knowledgebase/tests/test_article.py index e35dba4..7f7f4bd 100644 --- a/glpi_python_client/_async/clients/api/knowledgebase/tests/test_article.py +++ b/glpi_python_client/_async/clients/api/knowledgebase/tests/test_article.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable +from datetime import timedelta from typing import Any import pytest @@ -13,7 +14,10 @@ PatchKBArticle, PostKBArticle, ) -from glpi_python_client._async._testing import FailingTransportRecorder +from glpi_python_client._async._testing import ( + FailingTransportRecorder, + TransportRecorder, +) from glpi_python_client.testing.utils import FakeResponse @@ -429,3 +433,35 @@ async def fake_search( client.search_kb_articles = fake_search # type: ignore[method-assign] assert [batch async for batch in client.iter_search_kb_articles()] == [] + + +async def test_get_kb_article_localises_the_naive_revision_date(client: Any) -> None: + """A KB article's revision dates come back comparable with its own. + + This is the shape GLPI 11 actually sends, measured on a live instance: + the article's own timestamps carry an offset and the nested revision + dates do not. Before the server timezone was threaded through, sorting + an article's history against the article itself raised + ``TypeError: can't compare offset-naive and offset-aware datetimes``. + """ + + rec = TransportRecorder( + get_payload={ + "id": 1, + "name": "article", + "date_creation": "2018-04-06T17:38:15+02:00", + "revisions": [{"id": 9, "date": "2018-04-06 17:39:44"}], + } + ) + rec.install(client) + + article = await client.get_kb_article(1) + + assert article.revisions is not None + revision_date = article.revisions[0].date + assert revision_date is not None + assert revision_date.tzinfo is not None + # The comparison itself is the regression: it used to raise. + assert article.date_creation is not None + assert revision_date > article.date_creation + assert revision_date - article.date_creation == timedelta(seconds=89) diff --git a/glpi_python_client/_async/clients/api/plugins/_fields.py b/glpi_python_client/_async/clients/api/plugins/_fields.py index 616e953..3a25c93 100644 --- a/glpi_python_client/_async/clients/api/plugins/_fields.py +++ b/glpi_python_client/_async/clients/api/plugins/_fields.py @@ -33,6 +33,7 @@ import json from typing import Any +from glpi_python_client._async.clients.commons._payloads import model_from_payload from glpi_python_client._async.clients.commons._transport import TransportMixin from glpi_python_client._errors import GlpiProtocolError, GlpiValidationError from glpi_python_client.models.api_schema.plugins import ( @@ -147,7 +148,12 @@ async def list_plugin_fields_containers( failure_message="Failed to list PluginFieldsContainer", ) rows = payload if isinstance(payload, list) else [] - containers = [GetPluginFieldsContainer.model_validate(row) for row in rows] + containers = [ + model_from_payload( + GetPluginFieldsContainer, row, server_timezone=self.server_timezone + ) + for row in rows + ] if itemtype is None: return containers return [c for c in containers if _container_targets_itemtype(c, itemtype)] @@ -179,7 +185,12 @@ async def list_plugin_fields_fields( failure_message="Failed to list PluginFieldsField", ) rows = payload if isinstance(payload, list) else [] - fields = [GetPluginFieldsField.model_validate(row) for row in rows] + fields = [ + model_from_payload( + GetPluginFieldsField, row, server_timezone=self.server_timezone + ) + for row in rows + ] if container_id is None: return fields return [f for f in fields if f.plugin_fields_containers_id == container_id] @@ -219,7 +230,12 @@ async def list_item_plugin_field_rows( failure_message=f"Failed to list {endpoint}", ) rows = payload if isinstance(payload, list) else [] - return [GetPluginFieldsValueRow.model_validate(row) for row in rows] + return [ + model_from_payload( + GetPluginFieldsValueRow, row, server_timezone=self.server_timezone + ) + for row in rows + ] async def create_item_plugin_field_row( self, diff --git a/glpi_python_client/_async/clients/commons/_config.py b/glpi_python_client/_async/clients/commons/_config.py index 8a7300e..0236345 100644 --- a/glpi_python_client/_async/clients/commons/_config.py +++ b/glpi_python_client/_async/clients/commons/_config.py @@ -9,7 +9,9 @@ from collections.abc import Mapping from dataclasses import dataclass +from datetime import tzinfo from typing import TYPE_CHECKING, Protocol +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError import httpx @@ -183,6 +185,63 @@ def build_client_resources( ) +def resolve_server_timezone(value: object) -> tzinfo: + """Resolve the configured GLPI server timezone into a ``tzinfo``. + + The timezone is **required** and has no default. GLPI does not + advertise it anywhere in the API, so only the operator knows it, and + every candidate default is wrong somewhere: guessing UTC against a + Europe/Paris instance shifts every naive timestamp by one or two hours + *without raising*, which is worse than the ``TypeError`` a naive value + produces on comparison. + + An IANA name is preferred over a fixed offset because a fixed offset + cannot follow DST, and a single GLPI instance demonstrably emits both + ``+01:00`` and ``+02:00`` depending on the date. + + Parameters + ---------- + value : object + An IANA zone name (``"Europe/Paris"``) or a ``tzinfo`` instance. + + Returns + ------- + tzinfo + The resolved timezone. + + Raises + ------ + GlpiValidationError + When the value is missing, blank, not a string or ``tzinfo``, or + names a zone the system database does not know. + """ + + if isinstance(value, tzinfo): + return value + if value is None: + raise GlpiValidationError( + "server_timezone is required: GLPI does not advertise its own " + "timezone, so it has to be declared (e.g. 'Europe/Paris', or " + "GLPI_SERVER_TIMEZONE in the environment)." + ) + if not isinstance(value, str): + raise GlpiValidationError( + f"server_timezone must be an IANA name or a tzinfo; got {value!r}" + ) + name = value.strip() + if not name: + raise GlpiValidationError( + "server_timezone is empty: set it to an IANA name (e.g. 'Europe/Paris')." + ) + try: + return ZoneInfo(name) + except (ZoneInfoNotFoundError, ValueError) as exc: + raise GlpiValidationError( + f"Unknown server_timezone {name!r}. Use an IANA zone name such as " + "'Europe/Paris' or 'UTC'." + ) from exc + + def parse_optional_env_int(value: object) -> int | None: """Parse one optional integer from an environment-style value. @@ -248,6 +307,7 @@ def build_client_env_config( config: dict[str, object] = { "glpi_api_url": env.get(f"{prefix}API_URL"), + "server_timezone": env.get(f"{prefix}SERVER_TIMEZONE"), "client_id": env.get(f"{prefix}CLIENT_ID"), "client_secret": env.get(f"{prefix}CLIENT_SECRET"), "username": env.get(f"{prefix}USERNAME"), @@ -313,5 +373,6 @@ def validate_v1_document_config( "normalize_client_api_url", "parse_optional_env_bool", "parse_optional_env_int", + "resolve_server_timezone", "validate_v1_document_config", ] diff --git a/glpi_python_client/_async/clients/commons/_payloads.py b/glpi_python_client/_async/clients/commons/_payloads.py index 8e70538..1925bc4 100644 --- a/glpi_python_client/_async/clients/commons/_payloads.py +++ b/glpi_python_client/_async/clients/commons/_payloads.py @@ -7,9 +7,13 @@ from __future__ import annotations +from datetime import tzinfo from typing import TypeVar -from glpi_python_client.models._base import GlpiModel +from glpi_python_client.models._base import ( + SERVER_TIMEZONE_CONTEXT_KEY, + GlpiModel, +) ModelT = TypeVar("ModelT", bound=GlpiModel) @@ -36,14 +40,31 @@ def model_to_payload(model: GlpiModel) -> dict[str, object]: return body -def model_from_payload(model_class: type[ModelT], payload: object) -> ModelT: +def model_from_payload( + model_class: type[ModelT], + payload: object, + *, + server_timezone: tzinfo | None = None, +) -> ModelT: """Validate one raw GLPI payload into the requested ``GlpiModel`` class. - The helper is a thin wrapper around ``model_validate`` that keeps the - mixin call sites concise and consistent with :func:`model_to_payload`. + The helper wraps ``model_validate`` so the mixin call sites stay concise + and consistent with :func:`model_to_payload`, and so the server timezone + is threaded through one place instead of forty field declarations. + + ``server_timezone`` is passed as a Pydantic validation context, which + reaches nested submodels as well as the top-level one -- necessary + because the timestamps GLPI sends without an offset are nested + (``KBArticle.revisions[].date``). ``None`` supplies no context at all, + so naive values stay naive rather than being stamped with a guess. """ - return model_class.model_validate(payload) + context = ( + {SERVER_TIMEZONE_CONTEXT_KEY: server_timezone} + if server_timezone is not None + else None + ) + return model_class.model_validate(payload, context=context) __all__ = ["model_from_payload", "model_to_payload"] diff --git a/glpi_python_client/_async/clients/commons/_transport.py b/glpi_python_client/_async/clients/commons/_transport.py index e6978e9..581f5a3 100644 --- a/glpi_python_client/_async/clients/commons/_transport.py +++ b/glpi_python_client/_async/clients/commons/_transport.py @@ -26,6 +26,7 @@ import logging from collections.abc import AsyncIterator, Callable +from datetime import tzinfo from typing import TYPE_CHECKING, Any, TypeVar import httpx @@ -108,6 +109,7 @@ class TransportMixin: glpi_entity: int | None glpi_profile: int | None language: str + server_timezone: tzinfo def _ensure_open(self) -> None: """Raise when the client has already been closed. @@ -450,7 +452,10 @@ async def _resource_list( if unwrap_envelope else list_payload_items(payload) ) - return [model_from_payload(model, item) for item in items] + return [ + model_from_payload(model, item, server_timezone=self.server_timezone) + for item in items + ] async def _resource_get( self, @@ -486,7 +491,9 @@ async def _resource_get( success_statuses=(200, 206), failure_message=failure_message, ) - return model_from_payload(model, response.json()) + return model_from_payload( + model, response.json(), server_timezone=self.server_timezone + ) async def _resource_create( self, diff --git a/glpi_python_client/_async/clients/commons/tests/test_config.py b/glpi_python_client/_async/clients/commons/tests/test_config.py index 5c47974..038f0d7 100644 --- a/glpi_python_client/_async/clients/commons/tests/test_config.py +++ b/glpi_python_client/_async/clients/commons/tests/test_config.py @@ -7,6 +7,8 @@ from __future__ import annotations +from datetime import datetime, timedelta, timezone + import pytest from glpi_python_client import GlpiValidationError @@ -15,6 +17,7 @@ normalize_client_api_url, parse_optional_env_bool, parse_optional_env_int, + resolve_server_timezone, validate_v1_document_config, ) @@ -160,3 +163,47 @@ def test_build_client_env_config_overrides_win() -> None: assert config["glpi_api_url"] == "https://override" assert config["verify_ssl"] is False assert config["language"] == "en_GB" + + +def test_resolve_server_timezone_accepts_an_iana_name() -> None: + """An IANA zone name resolves to a DST-aware timezone. + + A name rather than a fixed offset is what makes winter and summer both + correct: the same GLPI instance sends ``+01:00`` and ``+02:00``. + """ + + resolved = resolve_server_timezone("Europe/Paris") + + assert datetime(2019, 1, 15, tzinfo=resolved).utcoffset() == timedelta(hours=1) + assert datetime(2018, 7, 15, tzinfo=resolved).utcoffset() == timedelta(hours=2) + + +def test_resolve_server_timezone_passes_a_tzinfo_through() -> None: + """A caller holding a tzinfo object may supply it directly.""" + + fixed = timezone(timedelta(hours=2)) + + assert resolve_server_timezone(fixed) is fixed + + +def test_resolve_server_timezone_rejects_an_unknown_name() -> None: + """A typo fails at construction, not silently at the first timestamp.""" + + with pytest.raises(GlpiValidationError) as excinfo: + resolve_server_timezone("Europe/Pariss") + + assert "Europe/Pariss" in str(excinfo.value) + + +def test_resolve_server_timezone_rejects_a_missing_value() -> None: + """The timezone is required; GLPI does not advertise it.""" + + with pytest.raises(GlpiValidationError): + resolve_server_timezone(None) + + +def test_resolve_server_timezone_rejects_a_blank_name() -> None: + """An empty environment variable is a missing value, not a valid one.""" + + with pytest.raises(GlpiValidationError): + resolve_server_timezone(" ") diff --git a/glpi_python_client/_async/clients/tests/test_client.py b/glpi_python_client/_async/clients/tests/test_client.py index 99ff47c..4b80471 100644 --- a/glpi_python_client/_async/clients/tests/test_client.py +++ b/glpi_python_client/_async/clients/tests/test_client.py @@ -21,6 +21,7 @@ async def test_glpi_client_from_env_uses_overrides_and_defaults( "GLPI_API_URL": "https://glpi.example.test/api.php/v2", "GLPI_USERNAME": "u", "GLPI_PASSWORD": "p", + "GLPI_SERVER_TIMEZONE": "Europe/Paris", } client = AsyncGlpiClient.from_env(env=env) try: @@ -34,6 +35,7 @@ async def test_glpi_client_close_is_idempotent() -> None: client = AsyncGlpiClient( glpi_api_url="https://glpi.example.test/api.php/v2", + server_timezone="Europe/Paris", username="u", password="p", ) @@ -61,7 +63,10 @@ async def test_glpi_client_rejects_invalid_credentials() -> None: """Constructor refuses to build a client with no usable credentials.""" with pytest.raises(ValueError): - AsyncGlpiClient(glpi_api_url="https://glpi.example.test/api.php/v2") + AsyncGlpiClient( + glpi_api_url="https://glpi.example.test/api.php/v2", + server_timezone="Europe/Paris", + ) async def test_glpi_client_v1_session_built_when_configured() -> None: @@ -69,6 +74,7 @@ async def test_glpi_client_v1_session_built_when_configured() -> None: client = AsyncGlpiClient( glpi_api_url="https://glpi.example.test/api.php/v2", + server_timezone="Europe/Paris", username="u", password="p", v1_base_url="https://glpi.example.test/apirest.php", @@ -87,6 +93,7 @@ async def test_glpi_client_rejects_partial_v1_config() -> None: with pytest.raises(ValueError, match="v1_base_url and v1_user_token"): AsyncGlpiClient( glpi_api_url="https://glpi.example.test/api.php/v2", + server_timezone="Europe/Paris", username="u", password="p", v1_base_url="https://glpi.example.test/apirest.php", @@ -101,6 +108,7 @@ async def test_environ_default_is_used_when_env_argument_omitted( monkeypatch.setenv("GLPI_API_URL", "https://from-environ.example/api.php/v2") monkeypatch.setenv("GLPI_USERNAME", "u") monkeypatch.setenv("GLPI_PASSWORD", "p") + monkeypatch.setenv("GLPI_SERVER_TIMEZONE", "Europe/Paris") client = AsyncGlpiClient.from_env() try: assert client.glpi_api_url.endswith("/api.php/v2") @@ -113,6 +121,7 @@ async def test_async_transport_ensure_open_blocks_after_close() -> None: client = AsyncGlpiClient( glpi_api_url="https://glpi.example.test/api.php/v2", + server_timezone="Europe/Paris", username="u", password="p", ) @@ -149,6 +158,7 @@ def _track_init(self: httpx.AsyncClient, *args: Any, **kwargs: Any) -> None: with pytest.raises(ValueError): AsyncGlpiClient( glpi_api_url="https://glpi.example.test/api.php/v2", + server_timezone="Europe/Paris", client_id="only-id-no-secret", ) assert constructed == [], "a transport session was built for a rejected config" diff --git a/glpi_python_client/_sync/clients/_base_client.py b/glpi_python_client/_sync/clients/_base_client.py index d5ee4c3..f05092d 100644 --- a/glpi_python_client/_sync/clients/_base_client.py +++ b/glpi_python_client/_sync/clients/_base_client.py @@ -25,10 +25,12 @@ from glpi_python_client._sync.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__) @@ -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, @@ -66,6 +69,16 @@ 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 is needed to interpret the timestamps + the server sends without an offset. 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 @@ -103,6 +116,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__, @@ -142,7 +156,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 ---------- diff --git a/glpi_python_client/_sync/clients/api/knowledgebase/tests/test_article.py b/glpi_python_client/_sync/clients/api/knowledgebase/tests/test_article.py index f2e1786..01817e2 100644 --- a/glpi_python_client/_sync/clients/api/knowledgebase/tests/test_article.py +++ b/glpi_python_client/_sync/clients/api/knowledgebase/tests/test_article.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable +from datetime import timedelta from typing import Any import pytest @@ -13,7 +14,10 @@ PatchKBArticle, PostKBArticle, ) -from glpi_python_client._sync._testing import FailingTransportRecorder +from glpi_python_client._sync._testing import ( + FailingTransportRecorder, + TransportRecorder, +) from glpi_python_client.testing.utils import FakeResponse @@ -429,3 +433,35 @@ def fake_search( client.search_kb_articles = fake_search # type: ignore[method-assign] assert [batch for batch in client.iter_search_kb_articles()] == [] + + +def test_get_kb_article_localises_the_naive_revision_date(client: Any) -> None: + """A KB article's revision dates come back comparable with its own. + + This is the shape GLPI 11 actually sends, measured on a live instance: + the article's own timestamps carry an offset and the nested revision + dates do not. Before the server timezone was threaded through, sorting + an article's history against the article itself raised + ``TypeError: can't compare offset-naive and offset-aware datetimes``. + """ + + rec = TransportRecorder( + get_payload={ + "id": 1, + "name": "article", + "date_creation": "2018-04-06T17:38:15+02:00", + "revisions": [{"id": 9, "date": "2018-04-06 17:39:44"}], + } + ) + rec.install(client) + + article = client.get_kb_article(1) + + assert article.revisions is not None + revision_date = article.revisions[0].date + assert revision_date is not None + assert revision_date.tzinfo is not None + # The comparison itself is the regression: it used to raise. + assert article.date_creation is not None + assert revision_date > article.date_creation + assert revision_date - article.date_creation == timedelta(seconds=89) diff --git a/glpi_python_client/_sync/clients/api/plugins/_fields.py b/glpi_python_client/_sync/clients/api/plugins/_fields.py index 46f1b3d..d96dd86 100644 --- a/glpi_python_client/_sync/clients/api/plugins/_fields.py +++ b/glpi_python_client/_sync/clients/api/plugins/_fields.py @@ -33,6 +33,7 @@ import json from typing import Any +from glpi_python_client._sync.clients.commons._payloads import model_from_payload from glpi_python_client._sync.clients.commons._transport import TransportMixin from glpi_python_client._errors import GlpiProtocolError, GlpiValidationError from glpi_python_client.models.api_schema.plugins import ( @@ -147,7 +148,12 @@ def list_plugin_fields_containers( failure_message="Failed to list PluginFieldsContainer", ) rows = payload if isinstance(payload, list) else [] - containers = [GetPluginFieldsContainer.model_validate(row) for row in rows] + containers = [ + model_from_payload( + GetPluginFieldsContainer, row, server_timezone=self.server_timezone + ) + for row in rows + ] if itemtype is None: return containers return [c for c in containers if _container_targets_itemtype(c, itemtype)] @@ -179,7 +185,12 @@ def list_plugin_fields_fields( failure_message="Failed to list PluginFieldsField", ) rows = payload if isinstance(payload, list) else [] - fields = [GetPluginFieldsField.model_validate(row) for row in rows] + fields = [ + model_from_payload( + GetPluginFieldsField, row, server_timezone=self.server_timezone + ) + for row in rows + ] if container_id is None: return fields return [f for f in fields if f.plugin_fields_containers_id == container_id] @@ -219,7 +230,12 @@ def list_item_plugin_field_rows( failure_message=f"Failed to list {endpoint}", ) rows = payload if isinstance(payload, list) else [] - return [GetPluginFieldsValueRow.model_validate(row) for row in rows] + return [ + model_from_payload( + GetPluginFieldsValueRow, row, server_timezone=self.server_timezone + ) + for row in rows + ] def create_item_plugin_field_row( self, diff --git a/glpi_python_client/_sync/clients/commons/_config.py b/glpi_python_client/_sync/clients/commons/_config.py index b611cc3..140e5e7 100644 --- a/glpi_python_client/_sync/clients/commons/_config.py +++ b/glpi_python_client/_sync/clients/commons/_config.py @@ -9,7 +9,9 @@ from collections.abc import Mapping from dataclasses import dataclass +from datetime import tzinfo from typing import TYPE_CHECKING, Protocol +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError import httpx @@ -183,6 +185,63 @@ def build_client_resources( ) +def resolve_server_timezone(value: object) -> tzinfo: + """Resolve the configured GLPI server timezone into a ``tzinfo``. + + The timezone is **required** and has no default. GLPI does not + advertise it anywhere in the API, so only the operator knows it, and + every candidate default is wrong somewhere: guessing UTC against a + Europe/Paris instance shifts every naive timestamp by one or two hours + *without raising*, which is worse than the ``TypeError`` a naive value + produces on comparison. + + An IANA name is preferred over a fixed offset because a fixed offset + cannot follow DST, and a single GLPI instance demonstrably emits both + ``+01:00`` and ``+02:00`` depending on the date. + + Parameters + ---------- + value : object + An IANA zone name (``"Europe/Paris"``) or a ``tzinfo`` instance. + + Returns + ------- + tzinfo + The resolved timezone. + + Raises + ------ + GlpiValidationError + When the value is missing, blank, not a string or ``tzinfo``, or + names a zone the system database does not know. + """ + + if isinstance(value, tzinfo): + return value + if value is None: + raise GlpiValidationError( + "server_timezone is required: GLPI does not advertise its own " + "timezone, so it has to be declared (e.g. 'Europe/Paris', or " + "GLPI_SERVER_TIMEZONE in the environment)." + ) + if not isinstance(value, str): + raise GlpiValidationError( + f"server_timezone must be an IANA name or a tzinfo; got {value!r}" + ) + name = value.strip() + if not name: + raise GlpiValidationError( + "server_timezone is empty: set it to an IANA name (e.g. 'Europe/Paris')." + ) + try: + return ZoneInfo(name) + except (ZoneInfoNotFoundError, ValueError) as exc: + raise GlpiValidationError( + f"Unknown server_timezone {name!r}. Use an IANA zone name such as " + "'Europe/Paris' or 'UTC'." + ) from exc + + def parse_optional_env_int(value: object) -> int | None: """Parse one optional integer from an environment-style value. @@ -248,6 +307,7 @@ def build_client_env_config( config: dict[str, object] = { "glpi_api_url": env.get(f"{prefix}API_URL"), + "server_timezone": env.get(f"{prefix}SERVER_TIMEZONE"), "client_id": env.get(f"{prefix}CLIENT_ID"), "client_secret": env.get(f"{prefix}CLIENT_SECRET"), "username": env.get(f"{prefix}USERNAME"), @@ -313,5 +373,6 @@ def validate_v1_document_config( "normalize_client_api_url", "parse_optional_env_bool", "parse_optional_env_int", + "resolve_server_timezone", "validate_v1_document_config", ] diff --git a/glpi_python_client/_sync/clients/commons/_payloads.py b/glpi_python_client/_sync/clients/commons/_payloads.py index 8e70538..1925bc4 100644 --- a/glpi_python_client/_sync/clients/commons/_payloads.py +++ b/glpi_python_client/_sync/clients/commons/_payloads.py @@ -7,9 +7,13 @@ from __future__ import annotations +from datetime import tzinfo from typing import TypeVar -from glpi_python_client.models._base import GlpiModel +from glpi_python_client.models._base import ( + SERVER_TIMEZONE_CONTEXT_KEY, + GlpiModel, +) ModelT = TypeVar("ModelT", bound=GlpiModel) @@ -36,14 +40,31 @@ def model_to_payload(model: GlpiModel) -> dict[str, object]: return body -def model_from_payload(model_class: type[ModelT], payload: object) -> ModelT: +def model_from_payload( + model_class: type[ModelT], + payload: object, + *, + server_timezone: tzinfo | None = None, +) -> ModelT: """Validate one raw GLPI payload into the requested ``GlpiModel`` class. - The helper is a thin wrapper around ``model_validate`` that keeps the - mixin call sites concise and consistent with :func:`model_to_payload`. + The helper wraps ``model_validate`` so the mixin call sites stay concise + and consistent with :func:`model_to_payload`, and so the server timezone + is threaded through one place instead of forty field declarations. + + ``server_timezone`` is passed as a Pydantic validation context, which + reaches nested submodels as well as the top-level one -- necessary + because the timestamps GLPI sends without an offset are nested + (``KBArticle.revisions[].date``). ``None`` supplies no context at all, + so naive values stay naive rather than being stamped with a guess. """ - return model_class.model_validate(payload) + context = ( + {SERVER_TIMEZONE_CONTEXT_KEY: server_timezone} + if server_timezone is not None + else None + ) + return model_class.model_validate(payload, context=context) __all__ = ["model_from_payload", "model_to_payload"] diff --git a/glpi_python_client/_sync/clients/commons/_transport.py b/glpi_python_client/_sync/clients/commons/_transport.py index 2f083a6..5c48302 100644 --- a/glpi_python_client/_sync/clients/commons/_transport.py +++ b/glpi_python_client/_sync/clients/commons/_transport.py @@ -26,6 +26,7 @@ import logging from collections.abc import Iterator, Callable +from datetime import tzinfo from typing import TYPE_CHECKING, Any, TypeVar import httpx @@ -108,6 +109,7 @@ class TransportMixin: glpi_entity: int | None glpi_profile: int | None language: str + server_timezone: tzinfo def _ensure_open(self) -> None: """Raise when the client has already been closed. @@ -450,7 +452,10 @@ def _resource_list( if unwrap_envelope else list_payload_items(payload) ) - return [model_from_payload(model, item) for item in items] + return [ + model_from_payload(model, item, server_timezone=self.server_timezone) + for item in items + ] def _resource_get( self, @@ -486,7 +491,9 @@ def _resource_get( success_statuses=(200, 206), failure_message=failure_message, ) - return model_from_payload(model, response.json()) + return model_from_payload( + model, response.json(), server_timezone=self.server_timezone + ) def _resource_create( self, diff --git a/glpi_python_client/_sync/clients/commons/tests/test_config.py b/glpi_python_client/_sync/clients/commons/tests/test_config.py index 58884ae..3ac66c1 100644 --- a/glpi_python_client/_sync/clients/commons/tests/test_config.py +++ b/glpi_python_client/_sync/clients/commons/tests/test_config.py @@ -7,6 +7,8 @@ from __future__ import annotations +from datetime import datetime, timedelta, timezone + import pytest from glpi_python_client import GlpiValidationError @@ -15,6 +17,7 @@ normalize_client_api_url, parse_optional_env_bool, parse_optional_env_int, + resolve_server_timezone, validate_v1_document_config, ) @@ -160,3 +163,47 @@ def test_build_client_env_config_overrides_win() -> None: assert config["glpi_api_url"] == "https://override" assert config["verify_ssl"] is False assert config["language"] == "en_GB" + + +def test_resolve_server_timezone_accepts_an_iana_name() -> None: + """An IANA zone name resolves to a DST-aware timezone. + + A name rather than a fixed offset is what makes winter and summer both + correct: the same GLPI instance sends ``+01:00`` and ``+02:00``. + """ + + resolved = resolve_server_timezone("Europe/Paris") + + assert datetime(2019, 1, 15, tzinfo=resolved).utcoffset() == timedelta(hours=1) + assert datetime(2018, 7, 15, tzinfo=resolved).utcoffset() == timedelta(hours=2) + + +def test_resolve_server_timezone_passes_a_tzinfo_through() -> None: + """A caller holding a tzinfo object may supply it directly.""" + + fixed = timezone(timedelta(hours=2)) + + assert resolve_server_timezone(fixed) is fixed + + +def test_resolve_server_timezone_rejects_an_unknown_name() -> None: + """A typo fails at construction, not silently at the first timestamp.""" + + with pytest.raises(GlpiValidationError) as excinfo: + resolve_server_timezone("Europe/Pariss") + + assert "Europe/Pariss" in str(excinfo.value) + + +def test_resolve_server_timezone_rejects_a_missing_value() -> None: + """The timezone is required; GLPI does not advertise it.""" + + with pytest.raises(GlpiValidationError): + resolve_server_timezone(None) + + +def test_resolve_server_timezone_rejects_a_blank_name() -> None: + """An empty environment variable is a missing value, not a valid one.""" + + with pytest.raises(GlpiValidationError): + resolve_server_timezone(" ") diff --git a/glpi_python_client/_sync/clients/tests/test_client.py b/glpi_python_client/_sync/clients/tests/test_client.py index 70624f8..d4980fc 100644 --- a/glpi_python_client/_sync/clients/tests/test_client.py +++ b/glpi_python_client/_sync/clients/tests/test_client.py @@ -21,6 +21,7 @@ def test_glpi_client_from_env_uses_overrides_and_defaults( "GLPI_API_URL": "https://glpi.example.test/api.php/v2", "GLPI_USERNAME": "u", "GLPI_PASSWORD": "p", + "GLPI_SERVER_TIMEZONE": "Europe/Paris", } client = GlpiClient.from_env(env=env) try: @@ -34,6 +35,7 @@ def test_glpi_client_close_is_idempotent() -> None: client = GlpiClient( glpi_api_url="https://glpi.example.test/api.php/v2", + server_timezone="Europe/Paris", username="u", password="p", ) @@ -61,7 +63,10 @@ def test_glpi_client_rejects_invalid_credentials() -> None: """Constructor refuses to build a client with no usable credentials.""" with pytest.raises(ValueError): - GlpiClient(glpi_api_url="https://glpi.example.test/api.php/v2") + GlpiClient( + glpi_api_url="https://glpi.example.test/api.php/v2", + server_timezone="Europe/Paris", + ) def test_glpi_client_v1_session_built_when_configured() -> None: @@ -69,6 +74,7 @@ def test_glpi_client_v1_session_built_when_configured() -> None: client = GlpiClient( glpi_api_url="https://glpi.example.test/api.php/v2", + server_timezone="Europe/Paris", username="u", password="p", v1_base_url="https://glpi.example.test/apirest.php", @@ -87,6 +93,7 @@ def test_glpi_client_rejects_partial_v1_config() -> None: with pytest.raises(ValueError, match="v1_base_url and v1_user_token"): GlpiClient( glpi_api_url="https://glpi.example.test/api.php/v2", + server_timezone="Europe/Paris", username="u", password="p", v1_base_url="https://glpi.example.test/apirest.php", @@ -101,6 +108,7 @@ def test_environ_default_is_used_when_env_argument_omitted( monkeypatch.setenv("GLPI_API_URL", "https://from-environ.example/api.php/v2") monkeypatch.setenv("GLPI_USERNAME", "u") monkeypatch.setenv("GLPI_PASSWORD", "p") + monkeypatch.setenv("GLPI_SERVER_TIMEZONE", "Europe/Paris") client = GlpiClient.from_env() try: assert client.glpi_api_url.endswith("/api.php/v2") @@ -113,6 +121,7 @@ def test_async_transport_ensure_open_blocks_after_close() -> None: client = GlpiClient( glpi_api_url="https://glpi.example.test/api.php/v2", + server_timezone="Europe/Paris", username="u", password="p", ) @@ -149,6 +158,7 @@ def _track_init(self: httpx.Client, *args: Any, **kwargs: Any) -> None: with pytest.raises(ValueError): GlpiClient( glpi_api_url="https://glpi.example.test/api.php/v2", + server_timezone="Europe/Paris", client_id="only-id-no-secret", ) assert constructed == [], "a transport session was built for a rejected config" diff --git a/glpi_python_client/models/_base.py b/glpi_python_client/models/_base.py index 4c27872..27a83b0 100644 --- a/glpi_python_client/models/_base.py +++ b/glpi_python_client/models/_base.py @@ -16,9 +16,18 @@ from __future__ import annotations +from datetime import datetime from typing import Any -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, model_validator + +#: Validation-context key carrying the GLPI server's timezone. +#: +#: Set by ``model_from_payload`` in the client's ``commons._payloads`` +#: module from the client's ``server_timezone``. Absent when a model is built +#: outside the client, which is deliberate -- see +#: :meth:`GlpiModel._localise_naive_datetimes`. +SERVER_TIMEZONE_CONTEXT_KEY = "server_timezone" class GlpiModel(BaseModel): @@ -59,3 +68,47 @@ def _capture_unknown_fields(cls, data: Any) -> Any: merged.update(existing_extras) data["extra_payload"] = merged return data + + @model_validator(mode="after") + def _localise_naive_datetimes(self, info: ValidationInfo) -> GlpiModel: + """Stamp the server's timezone onto timestamps that arrived without one. + + GLPI 11 sends most timestamps with the correct historical offset -- + measured on a live instance, 19 of the 20 datetime fields across + every resource, and the same article carries ``+02:00`` in summer + and ``+01:00`` in winter. ``KBArticle.revisions[].date`` is the + exception and arrives bare, so one response can hold both kinds and + comparing them raises ``TypeError``. This closes that gap. + + Two rules make it safe: + + * **An offset already on the wire wins.** GLPI's own offset follows + DST; a single configured zone does not, so overwriting would + corrupt half the year. + * **No context means no guess.** A model built outside the client + keeps its naive values. Stamping an arbitrary offset on an unknown + timestamp would convert a loud ``TypeError`` into a quietly wrong + answer, which is strictly worse. + + The stamped values are written onto a copy rather than assigned in + place, because a ``mode="after"`` validator receives the instance + itself -- and ``model_validate`` accepts an existing model, so + mutating would reach back into an object the caller still holds. + """ + + context = info.context + if not isinstance(context, dict): + return self + tzinfo = context.get(SERVER_TIMEZONE_CONTEXT_KEY) + if tzinfo is None: + return self + + localised = { + name: value.replace(tzinfo=tzinfo) + for name in type(self).model_fields + if isinstance(value := getattr(self, name, None), datetime) + and value.tzinfo is None + } + if not localised: + return self + return self.model_copy(update=localised) diff --git a/glpi_python_client/models/tests/__init__.py b/glpi_python_client/models/tests/__init__.py new file mode 100644 index 0000000..87301ea --- /dev/null +++ b/glpi_python_client/models/tests/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the shared model base.""" diff --git a/glpi_python_client/models/tests/test_base.py b/glpi_python_client/models/tests/test_base.py new file mode 100644 index 0000000..f76c834 --- /dev/null +++ b/glpi_python_client/models/tests/test_base.py @@ -0,0 +1,128 @@ +"""Unit tests for :mod:`glpi_python_client.models._base`. + +The timezone tests here pin the inbound half of the server-timezone +contract: GLPI sends most timestamps with an offset but not all of them, +and a payload carrying both kinds is what makes a plain comparison raise. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from glpi_python_client.models._base import GlpiModel + +_PARIS_SUMMER = timezone(timedelta(hours=2)) + + +class _Stamped(GlpiModel): + """Model with one optional datetime field, used by the tests below.""" + + id: int | None = None + date: datetime | None = None + + +class _Nested(GlpiModel): + """Model holding a submodel, to prove the context reaches nested values.""" + + id: int | None = None + inner: _Stamped | None = None + + +def test_naive_datetime_gains_the_server_timezone() -> None: + """A timestamp sent without an offset is stamped with the server's.""" + + parsed = _Stamped.model_validate( + {"id": 1, "date": "2018-04-06 17:39:44"}, + context={"server_timezone": _PARIS_SUMMER}, + ) + + assert parsed.date is not None + assert parsed.date.utcoffset() == timedelta(hours=2) + assert parsed.date.isoformat() == "2018-04-06T17:39:44+02:00" + + +def test_aware_datetime_keeps_the_offset_the_server_sent() -> None: + """An offset already on the wire wins over the configured timezone. + + GLPI sends the correct historical offset -- the same instance emits + ``+02:00`` in summer and ``+01:00`` in winter -- so overwriting it with + a single configured zone would corrupt half the year. + """ + + parsed = _Stamped.model_validate( + {"id": 1, "date": "2019-01-15T09:00:00+01:00"}, + context={"server_timezone": _PARIS_SUMMER}, + ) + + assert parsed.date is not None + assert parsed.date.utcoffset() == timedelta(hours=1) + + +def test_naive_datetime_stays_naive_without_a_context() -> None: + """No configured timezone means no guess. + + Stamping an arbitrary offset on an unknown value would turn a loud + ``TypeError`` on comparison into a silently wrong result, so a model + validated outside the client keeps what it was given. + """ + + parsed = _Stamped.model_validate({"id": 1, "date": "2018-04-06 17:39:44"}) + + assert parsed.date is not None + assert parsed.date.tzinfo is None + + +def test_the_timezone_reaches_a_nested_model() -> None: + """Submodels are stamped too, which is where the naive values live. + + The one field GLPI 11 sends naive is ``KBArticle.revisions[].date`` -- + nested inside an article whose own timestamps are aware. + """ + + parsed = _Nested.model_validate( + {"id": 1, "inner": {"id": 2, "date": "2018-04-06 17:39:44"}}, + context={"server_timezone": _PARIS_SUMMER}, + ) + + assert parsed.inner is not None + assert parsed.inner.date is not None + assert parsed.inner.date.utcoffset() == timedelta(hours=2) + + +def test_none_datetime_is_left_alone() -> None: + """An absent timestamp stays absent rather than becoming an epoch.""" + + parsed = _Stamped.model_validate( + {"id": 1}, context={"server_timezone": _PARIS_SUMMER} + ) + + assert parsed.date is None + + +def test_non_datetime_fields_are_untouched() -> None: + """Only datetime fields are considered; the rest pass through.""" + + parsed = _Stamped.model_validate( + {"id": 7, "date": None}, context={"server_timezone": _PARIS_SUMMER} + ) + + assert parsed.id == 7 + + +def test_validating_an_existing_instance_does_not_mutate_the_original() -> None: + """Re-validation returns a stamped copy and leaves the caller's object. + + A ``mode="after"`` validator receives the model itself, so assigning to + it in place would reach back into an object the caller still holds. + """ + + original = _Stamped(id=1, date=datetime(2018, 4, 6, 17, 39, 44)) + + revalidated = _Stamped.model_validate( + original, context={"server_timezone": _PARIS_SUMMER} + ) + + assert revalidated.date is not None + assert revalidated.date.utcoffset() == timedelta(hours=2) + assert original.date is not None + assert original.date.tzinfo is None diff --git a/glpi_python_client/testing/utils.py b/glpi_python_client/testing/utils.py index 6fff29c..d6c87b0 100644 --- a/glpi_python_client/testing/utils.py +++ b/glpi_python_client/testing/utils.py @@ -21,6 +21,7 @@ "client_secret": "client-secret", "username": "api-user", "password": "api-password", + "server_timezone": "Europe/Paris", } diff --git a/integration_tests/test_integration.py b/integration_tests/test_integration.py index 8c599b8..4546b39 100644 --- a/integration_tests/test_integration.py +++ b/integration_tests/test_integration.py @@ -52,6 +52,7 @@ class _LiveGlpiConfig: v1_user_token: str | None v1_app_token: str | None team_member_role: str + server_timezone: str def _read_value(secret_name: str, *env_names: str) -> str | None: @@ -138,6 +139,8 @@ def _load_config() -> _LiveGlpiConfig: v1_base_url=_read_value("glpi_api_v1_url", "GLPI_API_V1_URL"), v1_user_token=_read_value("glpi_api_v1_token_user", "GLPI_V1_USER_TOKEN"), v1_app_token=_read_value("glpi_api_v1_app_token", "GLPI_V1_APP_TOKEN"), + server_timezone=_read_value("glpi_server_timezone", "GLPI_SERVER_TIMEZONE") + or "UTC", team_member_role=_read_value("glpi_team_member_role", "GLPI_TEAM_MEMBER_ROLE") or "assigned", ) @@ -162,6 +165,7 @@ def client( glpi_client = GlpiClient( glpi_api_url=live_config.api_url, + server_timezone=live_config.server_timezone, client_id=live_config.client_id, client_secret=live_config.client_secret, username=live_config.username, diff --git a/integration_tests/test_integration_async.py b/integration_tests/test_integration_async.py index d35570d..02521cf 100644 --- a/integration_tests/test_integration_async.py +++ b/integration_tests/test_integration_async.py @@ -58,6 +58,7 @@ def _build_async_client(config: _LiveGlpiConfig) -> AsyncGlpiClient: return AsyncGlpiClient( glpi_api_url=config.api_url, + server_timezone=config.server_timezone, client_id=config.client_id, client_secret=config.client_secret, username=config.username, diff --git a/integration_tests/test_integration_kb.py b/integration_tests/test_integration_kb.py index 7b0c519..222479c 100644 --- a/integration_tests/test_integration_kb.py +++ b/integration_tests/test_integration_kb.py @@ -45,6 +45,7 @@ def client(live_config: _LiveGlpiConfig) -> Iterator[GlpiClient]: glpi_client = GlpiClient( glpi_api_url=live_config.api_url, + server_timezone=live_config.server_timezone, client_id=live_config.client_id, client_secret=live_config.client_secret, username=live_config.username, diff --git a/pyproject.toml b/pyproject.toml index df59d3d..872a127 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,6 +74,13 @@ dependencies = [ # 3354ms without it, 1304ms with. `pip check` reports no broken # requirements either way, so this pin is the only thing holding the fix. "sniffio>=1.3", + # Required because `server_timezone` takes an IANA zone name, and a name + # is what makes DST correct -- a fixed offset would be wrong for half the + # year against an instance that emits both +01:00 and +02:00. Windows + # ships no system tz database, so `zoneinfo` finds nothing there while + # Linux reads /usr/share/zoneinfo: without this pin the client works in + # 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'", ] diff --git a/skills/glpi-client-setup/SKILL.md b/skills/glpi-client-setup/SKILL.md index 2550fcd..bcee2c8 100644 --- a/skills/glpi-client-setup/SKILL.md +++ b/skills/glpi-client-setup/SKILL.md @@ -85,6 +85,7 @@ call `client.close()` (or `await client.close()`) when finished. - `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 GLPI server's timezone (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, because guessing UTC against a Europe/Paris instance shifts those values silently. - `GLPI_LANGUAGE`, `GLPI_VERIFY_SSL`, `GLPI_AUTH_TOKEN_REFRESH` - `GLPI_V1_BASE_URL`, `GLPI_V1_USER_TOKEN`, `GLPI_V1_APP_TOKEN` From ed5b4bcdd2645df74d26caaab94865b0a5b0e8a1 Mon Sep 17 00:00:00 2001 From: baraline Date: Thu, 13 Aug 2026 10:00:10 +0200 Subject: [PATCH 18/21] docs: bring the nine skills and the guides in line with this branch The knowledge-base and plugin-fields skills landed on main after this branch was cut, so they never saw its changes. Reconciled, along with three claims elsewhere that the branch had made false: - Every client construction example -- 7 across the skills, 5 across docs/user_guide.rst and README.md -- gained the now-required server_timezone. They would all have raised TypeError as written. - The knowledge-base skill's search example used sort="date_mod desc", which is HTTP 400 against a live instance. The same bug the three docstrings carried. - The 4xx-swallowing contract is documented in five skills, at length, because it was a genuine trap. It is now reversed, so each says what happens instead -- while keeping the warning about the fail-open path that has NOT changed: v2 still ignores an unknown filter field and answers 200 with the whole table, which no status check can catch. - The knowledge-base skill gained iter_search_kb_articles and iter_search_kb_categories; document-workflow gained stream_document_content and iter_search_documents. stream_document_content is what test_every_public_method_is_named_by_some_skill flagged after the rebase -- a guard that landed on main in 08fb8df and that I had wrongly reported as not existing, having read it from a stale checkout. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 ++ docs/user_guide.rst | 3 +++ .../content/tests/test_conversion.py | 2 +- skills/glpi-client-setup/SKILL.md | 4 ++++ skills/glpi-document-workflow/SKILL.md | 8 ++++---- skills/glpi-knowledge-base/SKILL.md | 7 ++++--- skills/glpi-plugin-fields/SKILL.md | 2 ++ skills/glpi-user-location-provisioning/SKILL.md | 15 ++++++++------- 8 files changed, 28 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 261c068..188d330 100644 --- a/README.md +++ b/README.md @@ -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", @@ -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", diff --git a/docs/user_guide.rst b/docs/user_guide.rst index 3c0b8d2..2e04282 100644 --- a/docs/user_guide.rst +++ b/docs/user_guide.rst @@ -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", @@ -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", @@ -719,6 +721,7 @@ internal container and field names: 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", diff --git a/glpi_python_client/content/tests/test_conversion.py b/glpi_python_client/content/tests/test_conversion.py index af9ff67..e708c95 100644 --- a/glpi_python_client/content/tests/test_conversion.py +++ b/glpi_python_client/content/tests/test_conversion.py @@ -101,7 +101,7 @@ def test_table_survives_the_round_trip() -> None: ], ) def test_incoming_text_is_not_backslash_escaped(html: str, expected: str) -> None: - """Underscores and asterisks in prose stay readable. + r"""Underscores and asterisks in prose stay readable. Escaping them turns ``snake_case`` into ``snake\_case`` on every read, and the backslash accumulates across read-modify-write cycles. diff --git a/skills/glpi-client-setup/SKILL.md b/skills/glpi-client-setup/SKILL.md index bcee2c8..ab50751 100644 --- a/skills/glpi-client-setup/SKILL.md +++ b/skills/glpi-client-setup/SKILL.md @@ -102,6 +102,8 @@ from glpi_python_client import GlpiClient def main() -> None: with GlpiClient( glpi_api_url="https://glpi.example.com/api.php/v2", + server_timezone="Europe/Paris", + server_timezone="Europe/Paris", client_id="oauth-client-id", client_secret="oauth-client-secret", username="api-user", @@ -126,6 +128,8 @@ from glpi_python_client import AsyncGlpiClient async def main() -> None: async with AsyncGlpiClient( glpi_api_url="https://glpi.example.com/api.php/v2", + server_timezone="Europe/Paris", + server_timezone="Europe/Paris", client_id="oauth-client-id", client_secret="oauth-client-secret", username="api-user", diff --git a/skills/glpi-document-workflow/SKILL.md b/skills/glpi-document-workflow/SKILL.md index df16b6d..3d4d011 100644 --- a/skills/glpi-document-workflow/SKILL.md +++ b/skills/glpi-document-workflow/SKILL.md @@ -11,7 +11,7 @@ metadata: # GLPI Document Workflow > The snippets below use `AsyncGlpiClient` (`async with` + `await`). Every method shown also exists on the synchronous `GlpiClient` with the same signature -- replace `async with` with `with`, drop the `await` keyword, and skip the surrounding `async def`/`asyncio.run` scaffolding. -Document metadata uses the standard `Get`/`Post`/`Patch`/`Delete` shape on `/Management/Document`. Binary content uses two dedicated helpers: `download_document_content` for downloads and `upload_document` for uploads through the legacy v1 fallback session (the v2 contract does not advertise a binary upload endpoint). +Document metadata uses the standard `Get`/`Post`/`Patch`/`Delete` shape on `/Management/Document`. Binary content uses three dedicated helpers: `download_document_content` for whole-file downloads, `stream_document_content` for chunked ones, and `upload_document` for uploads through the legacy v1 fallback session (the v2 contract does not advertise a binary upload endpoint). The `GLPIV1Session` class is no longer part of the public surface; the v1 session is fully internal and is configured by passing `v1_base_url` and `v1_user_token` to `GlpiClient`. @@ -23,7 +23,7 @@ The `GLPIV1Session` class is no longer part of the public surface; the v1 sessio 4. Create a metadata-only record with `PostDocument(...)` and `await client.create_document(document)`. The method returns the new ID. 5. Update with `PatchDocument(...)` and `await client.update_document(document_id, document)`. 6. Delete with `await client.delete_document(document_id, force=True|False|None)`. -7. Download bytes with `content = await client.download_document_content(document_id)`. +7. Download bytes with `content = await client.download_document_content(document_id)`, which buffers the whole body. For a large attachment use `async for chunk in client.stream_document_content(document_id, chunk_size=65536)` and write each chunk out as it arrives. 8. Upload bytes with `await client.upload_document(filename=..., content=..., mime_type=..., ticket_id=..., entity_id=...)`. 9. To put a file on a ticket timeline, use `upload_document(..., ticket_id=...)` -- it creates the document *and* the ticket link in one call. `link_ticket_timeline_document` from the timeline skill cannot be told **which** existing document to link: `PostTimelineDocument` declares only `extra_payload` and `timeline_position`, and the POST URL carries only the ticket id, so there is no typed slot for a document id. See the timeline skill for the `extra_payload` escape hatch and its caveat. @@ -64,10 +64,10 @@ document_id = await client.create_document(PostDocument(name="Diagnostic notes") ## Gotchas -- **`search_documents` swallows 4xx and returns `[]`.** This is a library-wide contract, not a document peculiarity: `_resource_list` checks the response status only when the caller passes a `failure_message`, and none of the seven `search_*` helpers (`search_documents`, `search_tickets`, `search_users`, `search_locations`, `search_entities`, `search_kb_articles`, `search_kb_categories`) passes one -- a GLPI error body is not a JSON list, so it is coerced to `[]`. A malformed RSQL filter, a 403 on `/Management/Document`, a missing route and "no such document" all look identical. `get_document`, `download_document_content` and every `list_*` helper do pass a `failure_message` and raise `GlpiStatusError` (narrowed to `GlpiAuthError` / `GlpiNotFoundError` / `GlpiServerError`) normally. So never conclude from an empty `search_documents` that a file is not on the server and re-upload it -- that is how duplicate documents get created; corroborate with a call that raises first. +- **`search_documents` raises `GlpiStatusError` on a 4xx.** This changed: `_resource_list` 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 on `/Management/Document`, a missing route and "no such document" all looked identical. The status is now checked on every call, so **an empty list means the server said there are no matches**. The *other* fail-open path is unchanged: GLPI v2 ignores a filter field it does not recognise and answers 200 with the whole unfiltered table, so a search that returns rows is still not proof the filter applied. Never conclude from a search that a file is absent and re-upload it without corroborating -- that is how duplicate documents get created. - `upload_document` raises `RuntimeError` when the v1 session is not configured. Pass `v1_base_url` and `v1_user_token` to the client constructor or `from_env`. - `upload_document` requires a non-empty `filename`. On the async client the multipart POST is awaited like any other call, so the event loop is not blocked. -- `download_document_content` returns `bytes` and raises on non-200 responses. +- `download_document_content` returns `bytes` and raises on non-200 responses. It holds the entire file in memory, so a 500 MB attachment costs 500 MB of process memory even when the caller only writes it straight to disk -- prefer `stream_document_content(document_id, chunk_size=65536)`, an async generator on `AsyncGlpiClient` (`async for`) and a plain generator on `GlpiClient` (`for`), which yields the body in chunks and raises the same `GlpiStatusError` on a non-200. Uploads still buffer. - `mime_type` defaults to `application/octet-stream` when omitted on `upload_document`. - The snippets above use `AsyncGlpiClient`, so every call is awaited. The same methods on the synchronous `GlpiClient` are plain blocking calls -- drop the `await`. - The `delete_document(force=True)` flag permanently deletes; omit (or `False`) to move to the trash. \ No newline at end of file diff --git a/skills/glpi-knowledge-base/SKILL.md b/skills/glpi-knowledge-base/SKILL.md index ebe30fa..8ffa4cc 100644 --- a/skills/glpi-knowledge-base/SKILL.md +++ b/skills/glpi-knowledge-base/SKILL.md @@ -16,7 +16,7 @@ The GLPI knowledge base lives under `/Knowledgebase/*` on the v2 API and covers ## Procedure 1. Create a client from the `glpi-client-setup` skill. Add `v1_base_url` and `v1_user_token` **only** if you will write article categories. -2. Articles: `search_kb_articles(rsql_filter, limit=..., start=..., sort=..., language=...)` for lists and `get_kb_article(article_id)` for one. Write with `create_kb_article(PostKBArticle(...))` (returns the new id), `update_kb_article(article_id, PatchKBArticle(...))` and `delete_kb_article(article_id, force=...)` (both return `None`). +2. Articles: `search_kb_articles(rsql_filter, limit=..., start=..., sort=..., language=...)` for lists, `iter_search_kb_articles(rsql_filter, batch_size=50, sort=..., language=...)` to walk every page without managing `start` yourself, and `get_kb_article(article_id)` for one. `sort` is spelled `field` or `field:direction` (`"date_mod:desc"`); a space before the direction is HTTP 400, and a bare field sorts *ascending*. Write with `create_kb_article(PostKBArticle(...))` (returns the new id), `update_kb_article(article_id, PatchKBArticle(...))` and `delete_kb_article(article_id, force=...)` (both return `None`). 3. Article categories: `set_kb_article_categories(article_id, category_ids)`. The ids **replace** the whole set; an empty sequence clears it. Ids are not validated against the server -- an unknown id is simply not linked. 4. Categories: `search_kb_categories(...)` (same parameters as the article search), `get_kb_category(category_id)`, `create_kb_category(PostKBCategory(...))`, `update_kb_category(category_id, PatchKBCategory(...))`, `delete_kb_category(category_id, force=...)`. `completename` and `level` are server-managed and absent from the write models. 5. Comments: `list_kb_article_comments(article_id)`, `get_kb_article_comment(article_id, comment_id)`, `create_kb_article_comment(article_id, PostKBArticleComment(...))` (returns the new id), `update_kb_article_comment(article_id, comment_id, PatchKBArticleComment(...))`, `delete_kb_article_comment(article_id, comment_id, force=...)`. The parent article comes from the URL, so `PostKBArticleComment` has no `kbarticle` field. @@ -32,6 +32,7 @@ from glpi_python_client import AsyncGlpiClient, IdNameRef, PostKBArticle, PostKB 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", v1_base_url="https://glpi.example.com/api.php/v1", @@ -89,7 +90,7 @@ Search, and disambiguate an empty result. `language` is a query parameter on bot from glpi_python_client import GlpiNotFoundError faq = await client.search_kb_articles( - "is_faq==1", limit=25, start=0, sort="date_mod desc", language="fr_FR" + "is_faq==1", limit=25, start=0, sort="date_mod:desc", language="fr_FR" ) categories = await client.search_kb_categories("name==Network", limit=10) print([(c.id, c.completename) for c in categories]) @@ -159,7 +160,7 @@ await client.delete_kb_article(42, force=True) - `update_kb_article` does not wrap the failure the way create does -- the raw error propagates (`GlpiValidationError` for a category reference with no `id`, `RuntimeError` for a missing v1 session, `GlpiStatusError` for a legacy non-success). The v2 field changes are already applied and are not reverted. - `categories=[]` means opposite things on create and update. On create it is skipped entirely: no v1 call, no v1 session needed. On update it *clears* every category, which is a legacy write and does need v1. Only `categories=None` (the default) is a no-op on both. - `categories` is still sent inside the v2 POST/PATCH body and GLPI silently ignores it; the body is never stripped. So a create-with-categories against a client with no v1 session yields an uncategorised article *and* an error, not a clean rejection -- and no code path persists a category through v2 alone. -- **Every `search_*` helper in the library swallows 4xx and returns `[]`. This is a library-wide contract, not a KB peculiarity.** `_resource_list` checks the response status only when the caller passes a `failure_message`, and none of the seven searches -- `search_kb_articles`, `search_kb_categories`, `search_tickets`, `search_users`, `search_locations`, `search_entities`, `search_documents` -- passes one. A GLPI error body is not a JSON list, so it is coerced to `[]`. Every `list_*` and `get_*` helper does pass a `failure_message` and raises normally: here that is `list_kb_article_comments`, `list_kb_article_revisions`, `get_kb_article`, `get_kb_category` and `get_kb_article_revision` (`GlpiNotFoundError` on a 404). So an empty list from a search means "no matches" *or* "bad RSQL filter" *or* "403" *or* "this GLPI serves no `/Knowledgebase` routes at all" (they need High-Level API >= 2.2.0), indistinguishably; an empty list from a list helper is unambiguous. Never treat `[]` from a search as proof a record is absent before creating one. +- **Every `search_*` helper raises `GlpiStatusError` on a 4xx.** This changed: `_resource_list` used to check the response status only when the caller passed a `failure_message`, which none of the seven searches does, so a GLPI error body was coerced to `[]`. An empty list from a search meant "no matches" *or* "bad RSQL filter" *or* "403" *or* "this GLPI serves no `/Knowledgebase` routes at all" (they need High-Level API >= 2.2.0), indistinguishably. The status is now checked on every call, so **an empty list means the server said there are no matches** -- and a GLPI without the Knowledgebase routes raises rather than looking like an empty knowledge base. The *other* fail-open path is unchanged: v2 ignores a filter field it does not recognise and answers 200 with the whole unfiltered table, so a search that returns rows is still not proof the filter applied. - `language` has two different mechanics in this family. On `search_kb_articles`/`search_kb_categories` it is a **query parameter**. On `list_kb_article_revisions`/`get_kb_article_revision` it is a **path segment** between the id and `Revision` (`Knowledgebase/Article/5/fr_FR/Revision`). `get_kb_article`, the comment helpers and every write helper take no `language` at all; they inherit the client-level value, sent as `Accept-Language` (default `en_GB`). - KB write models use `IdNameRef` for every foreign key -- `categories[]`, `entity`, `user`, `parent` -- not `IdRef`. Passing `IdRef(id=4)` raises a pydantic `ValidationError`. (`GetKBArticleComment.parent` is the one KB field genuinely typed `IdRef`, and it is read-only.) - Article `content`/`description` and revision `content` are Markdown on the Python side and HTML on the wire; the conversion is automatic, so never author HTML. Comment `comment` is a plain `str` with no conversion at all -- the inconsistency is real, not an omission here. diff --git a/skills/glpi-plugin-fields/SKILL.md b/skills/glpi-plugin-fields/SKILL.md index 9f424a4..2414f49 100644 --- a/skills/glpi-plugin-fields/SKILL.md +++ b/skills/glpi-plugin-fields/SKILL.md @@ -63,6 +63,8 @@ PLUGIN_ABSENT = "ERROR_RESOURCE_NOT_FOUND_NOR_COMMONDBTM" async def main() -> None: async with AsyncGlpiClient( glpi_api_url="https://glpi.example.com/api.php/v2", + server_timezone="Europe/Paris", + server_timezone="Europe/Paris", client_id="oauth-client-id", client_secret="oauth-client-secret", v1_base_url="https://glpi.example.com/api.php/v1", diff --git a/skills/glpi-user-location-provisioning/SKILL.md b/skills/glpi-user-location-provisioning/SKILL.md index b0e406a..9ed8c5e 100644 --- a/skills/glpi-user-location-provisioning/SKILL.md +++ b/skills/glpi-user-location-provisioning/SKILL.md @@ -72,13 +72,14 @@ async def find_or_create_location( if matches and matches[0].id is not None: return matches[0].id - # Guard 2. Empty is not proof of absence. Re-run the same route with no - # filter at all -- same URL, same auth, same entity scope, nothing to - # reject -- so a swallowed 403/404/5xx shows up here too. An empty - # answer here has exactly two causes: the search layer failed, or the - # Locations dropdown is genuinely empty (a fresh GLPI ships it empty). - # This cannot tell them apart either, so it fails closed and makes the - # second one something the caller states on purpose. + # Guard 2. Empty is not proof of absence. A 4xx now raises, so the + # failure this used to catch is loud -- but the *other* fail-open path + # is not: v2 silently ignores a filter field it does not know and + # answers 200 with the whole table, and a filter that is dropped + # entirely can still yield nothing useful. Re-running with no filter + # at all distinguishes "this dropdown is empty" from "my filter was + # the problem", and it fails closed so the empty case is something the + # caller states on purpose. if not await client.search_locations("", limit=1) and not dropdown_may_be_empty: raise RuntimeError( "search_locations returned nothing even unfiltered: assume a failed " From 392383ec50925e3ced327409128b03af462c42ec Mon Sep 17 00:00:00 2001 From: baraline Date: Thu, 13 Aug 2026 10:31:29 +0200 Subject: [PATCH 19/21] fix: write datetimes on the server's clock, not with an offset GLPI 11 does not read the offset it sends. Measured on preprod against one ticket field, `12:30:00` written bare, as `...Z`, and with `+02:00`, `+09:00`, `-08:00` and `+14:00` all store the same moment -- 12:30 Paris. The server takes the naive prefix, interprets it in its own timezone, and discards the rest. It is not ignoring the offset unparsed: `+99:99` answers HTTP 500. The value is read and then thrown away, which is the bad half of both worlds -- a malformed offset crashes, and a well-formed one is silently wrong. Writing `12:30-08:00` (21:30 in Paris) stores 12:30: nine hours early, with a 200 and nothing in the response that looks off. This is the second job the earlier `mode="json"` change implied but did not do. That fix stopped every datetime write raising TypeError, but rendering an aware value *with* its offset is only correct if someone reads it. So the offset is now spent converting the value onto the server's clock and then dropped, via a serialisation context mirroring the validation context already threaded for 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. `test_model_to_payload_preserves_aware_datetime_offset` asserted the behaviour this replaces. It was reasonable when written; the measurement is what makes preserving an offset the wrong goal, so it is replaced by its inverse rather than deleted. Writes now go through `TransportMixin._body`, which binds the client's timezone once, and an audit test fails if any library module calls `model_to_payload` directly. That guard exists because the argument is optional in the signature and omitting it produces no error at all -- just a timestamp GLPI reinterprets. Both halves of the audit were verified by breaking them. Probe 3 in probe_wire_format.py records the measurement. Co-Authored-By: Claude Opus 5 (1M context) --- .../_async/clients/_base_client.py | 23 ++++-- .../_async/clients/api/assistance/_team.py | 5 +- .../_async/clients/commons/_payloads.py | 29 ++++++- .../_async/clients/commons/_transport.py | 18 +++- .../clients/commons/tests/test_payloads.py | 25 +++++- .../_sync/clients/_base_client.py | 23 ++++-- .../_sync/clients/api/assistance/_team.py | 5 +- .../_sync/clients/commons/_payloads.py | 29 ++++++- .../_sync/clients/commons/_transport.py | 18 +++- .../clients/commons/tests/test_payloads.py | 25 +++++- glpi_python_client/models/_base.py | 64 ++++++++++++++- glpi_python_client/models/tests/test_base.py | 55 +++++++++++++ .../tests/test_write_timezone_audit.py | 73 +++++++++++++++++ integration_tests/probe_wire_format.py | 82 ++++++++++++++++++- 14 files changed, 434 insertions(+), 40 deletions(-) create mode 100644 glpi_python_client/testing/tests/test_write_timezone_audit.py diff --git a/glpi_python_client/_async/clients/_base_client.py b/glpi_python_client/_async/clients/_base_client.py index 52e3433..cf12737 100644 --- a/glpi_python_client/_async/clients/_base_client.py +++ b/glpi_python_client/_async/clients/_base_client.py @@ -72,13 +72,22 @@ def __init__( 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 is needed to interpret the timestamps - the server sends without an offset. 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``. + 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 diff --git a/glpi_python_client/_async/clients/api/assistance/_team.py b/glpi_python_client/_async/clients/api/assistance/_team.py index 1d52375..16538fb 100644 --- a/glpi_python_client/_async/clients/api/assistance/_team.py +++ b/glpi_python_client/_async/clients/api/assistance/_team.py @@ -15,7 +15,6 @@ GlpiId, ) from glpi_python_client._async.clients.commons._http import ensure_response_status -from glpi_python_client._async.clients.commons._payloads import model_to_payload from glpi_python_client._async.clients.commons._transport import TransportMixin from glpi_python_client.models.api_schema.assistance._team import ( GetTeamMember, @@ -79,7 +78,7 @@ async def add_ticket_team_member( """ endpoint = f"{TICKET_ENDPOINT}/{ticket_id}/{TEAM_MEMBER_SUFFIX}" - response = await self._post_request(endpoint, model_to_payload(member)) + response = await self._post_request(endpoint, self._body(member)) ensure_response_status( response, success_statuses=(200, 201), @@ -114,7 +113,7 @@ async def remove_ticket_team_member( f"{TICKET_ENDPOINT}/{ticket_id}/{TEAM_MEMBER_SUFFIX}", failure_message=f"Failed to remove team member on ticket {ticket_id}", log_message=f"GLPI API removed team member on ticket {ticket_id}", - body=model_to_payload(member), + body=self._body(member), ) diff --git a/glpi_python_client/_async/clients/commons/_payloads.py b/glpi_python_client/_async/clients/commons/_payloads.py index 1925bc4..69e1232 100644 --- a/glpi_python_client/_async/clients/commons/_payloads.py +++ b/glpi_python_client/_async/clients/commons/_payloads.py @@ -18,7 +18,11 @@ ModelT = TypeVar("ModelT", bound=GlpiModel) -def model_to_payload(model: GlpiModel) -> dict[str, object]: +def model_to_payload( + model: GlpiModel, + *, + server_timezone: tzinfo | None = None, +) -> dict[str, object]: """Serialise one :class:`GlpiModel` into a request body. ``None`` fields are omitted, the meta ``extra_payload`` field is @@ -32,9 +36,30 @@ def model_to_payload(model: GlpiModel) -> dict[str, object]: objects and every write of a date field then fails at the encoder, past the point any transport stub can see. JSON mode renders them as ISO-8601 strings instead, so what the model validated is what GLPI receives. + + ``server_timezone`` is passed as a Pydantic serialisation context, the + mirror of the validation context in :func:`model_from_payload` and + threaded the same way, so it reaches nested submodels. It is needed + because JSON mode alone renders an aware datetime *with* its offset, and + GLPI 11 does not read that offset: it takes the naive prefix, interprets + it in the server's own timezone, and discards the rest -- measured across + offsets from ``-08:00`` to ``+14:00``, which all stored the same moment. + The context lets the value be converted onto that clock first -- see + :meth:`GlpiModel._render_datetimes_on_the_server_clock`. ``None`` + converts nothing, so a model dumped outside the client is unchanged. """ - body = model.model_dump(mode="json", exclude_none=True, exclude={"extra_payload"}) + context = ( + {SERVER_TIMEZONE_CONTEXT_KEY: server_timezone} + if server_timezone is not None + else None + ) + 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 diff --git a/glpi_python_client/_async/clients/commons/_transport.py b/glpi_python_client/_async/clients/commons/_transport.py index 581f5a3..a0706c3 100644 --- a/glpi_python_client/_async/clients/commons/_transport.py +++ b/glpi_python_client/_async/clients/commons/_transport.py @@ -495,6 +495,18 @@ async def _resource_get( model, response.json(), server_timezone=self.server_timezone ) + def _body(self, model: GlpiModel) -> dict[str, object]: + """Serialise one model into a request body on the server's clock. + + Every write in the package goes through here rather than calling + :func:`model_to_payload` directly, because the timezone argument is + not optional in practice and a call site that forgets it produces no + error -- just a timestamp GLPI silently reinterprets. Binding it once + leaves nothing to remember at the next endpoint. + """ + + return model_to_payload(model, server_timezone=self.server_timezone) + async def _resource_create( self, endpoint: str, @@ -538,7 +550,7 @@ async def _resource_create( """ response = await self._post_request( - endpoint, model_to_payload(body_model), skip_entity=skip_entity + endpoint, self._body(body_model), skip_entity=skip_entity ) ensure_response_status( response, @@ -579,7 +591,7 @@ async def _resource_update( None """ - response = await self._update_request(endpoint, model_to_payload(body_model)) + response = await self._update_request(endpoint, self._body(body_model)) ensure_response_status( response, success_statuses=(200, 204), @@ -630,7 +642,7 @@ async def _resource_delete( request_body = body if request_body is None and delete_model_cls is not None and force is not None: - request_body = model_to_payload(delete_model_cls(force=force)) # type: ignore[call-arg] + request_body = self._body(delete_model_cls(force=force)) # type: ignore[call-arg] response = await self._delete_request( endpoint, request_body, skip_entity=skip_entity ) diff --git a/glpi_python_client/_async/clients/commons/tests/test_payloads.py b/glpi_python_client/_async/clients/commons/tests/test_payloads.py index d3ac9ed..cbb6251 100644 --- a/glpi_python_client/_async/clients/commons/tests/test_payloads.py +++ b/glpi_python_client/_async/clients/commons/tests/test_payloads.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from glpi_python_client._async.clients.commons._payloads import ( model_from_payload, @@ -92,8 +92,27 @@ def test_model_to_payload_renders_naive_datetime_without_offset() -> None: assert body["planned_begin"] == "2024-01-01T12:00:00" -def test_model_to_payload_preserves_aware_datetime_offset() -> None: - """An aware datetime keeps its offset rather than being silently dropped.""" +def test_model_to_payload_rewrites_an_aware_datetime_onto_the_server_clock() -> None: + """An aware datetime is converted to server-local time and sent bare. + + GLPI 11 ignores the offset it is sent -- measured, three spellings of one + moment all stored the same wall clock -- so writing ``12:00Z`` to a + ``+01:00`` instance stores 12:00 there and loses an hour silently. The + offset has to be spent on the conversion instead of written out. + """ + + aware = datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc) + + body = model_to_payload( + PostTicketTask(planned_begin=aware), + server_timezone=timezone(timedelta(hours=1)), + ) + + assert body["planned_begin"] == "2024-01-01T13:00:00" + + +def test_model_to_payload_leaves_an_aware_datetime_alone_without_a_timezone() -> None: + """Outside the client there is no server clock to convert onto.""" aware = datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc) diff --git a/glpi_python_client/_sync/clients/_base_client.py b/glpi_python_client/_sync/clients/_base_client.py index f05092d..a30b8c2 100644 --- a/glpi_python_client/_sync/clients/_base_client.py +++ b/glpi_python_client/_sync/clients/_base_client.py @@ -72,13 +72,22 @@ def __init__( 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 is needed to interpret the timestamps - the server sends without an offset. 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``. + 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 diff --git a/glpi_python_client/_sync/clients/api/assistance/_team.py b/glpi_python_client/_sync/clients/api/assistance/_team.py index 4247412..9771971 100644 --- a/glpi_python_client/_sync/clients/api/assistance/_team.py +++ b/glpi_python_client/_sync/clients/api/assistance/_team.py @@ -15,7 +15,6 @@ GlpiId, ) from glpi_python_client._sync.clients.commons._http import ensure_response_status -from glpi_python_client._sync.clients.commons._payloads import model_to_payload from glpi_python_client._sync.clients.commons._transport import TransportMixin from glpi_python_client.models.api_schema.assistance._team import ( GetTeamMember, @@ -79,7 +78,7 @@ def add_ticket_team_member( """ endpoint = f"{TICKET_ENDPOINT}/{ticket_id}/{TEAM_MEMBER_SUFFIX}" - response = self._post_request(endpoint, model_to_payload(member)) + response = self._post_request(endpoint, self._body(member)) ensure_response_status( response, success_statuses=(200, 201), @@ -114,7 +113,7 @@ def remove_ticket_team_member( f"{TICKET_ENDPOINT}/{ticket_id}/{TEAM_MEMBER_SUFFIX}", failure_message=f"Failed to remove team member on ticket {ticket_id}", log_message=f"GLPI API removed team member on ticket {ticket_id}", - body=model_to_payload(member), + body=self._body(member), ) diff --git a/glpi_python_client/_sync/clients/commons/_payloads.py b/glpi_python_client/_sync/clients/commons/_payloads.py index 1925bc4..69e1232 100644 --- a/glpi_python_client/_sync/clients/commons/_payloads.py +++ b/glpi_python_client/_sync/clients/commons/_payloads.py @@ -18,7 +18,11 @@ ModelT = TypeVar("ModelT", bound=GlpiModel) -def model_to_payload(model: GlpiModel) -> dict[str, object]: +def model_to_payload( + model: GlpiModel, + *, + server_timezone: tzinfo | None = None, +) -> dict[str, object]: """Serialise one :class:`GlpiModel` into a request body. ``None`` fields are omitted, the meta ``extra_payload`` field is @@ -32,9 +36,30 @@ def model_to_payload(model: GlpiModel) -> dict[str, object]: objects and every write of a date field then fails at the encoder, past the point any transport stub can see. JSON mode renders them as ISO-8601 strings instead, so what the model validated is what GLPI receives. + + ``server_timezone`` is passed as a Pydantic serialisation context, the + mirror of the validation context in :func:`model_from_payload` and + threaded the same way, so it reaches nested submodels. It is needed + because JSON mode alone renders an aware datetime *with* its offset, and + GLPI 11 does not read that offset: it takes the naive prefix, interprets + it in the server's own timezone, and discards the rest -- measured across + offsets from ``-08:00`` to ``+14:00``, which all stored the same moment. + The context lets the value be converted onto that clock first -- see + :meth:`GlpiModel._render_datetimes_on_the_server_clock`. ``None`` + converts nothing, so a model dumped outside the client is unchanged. """ - body = model.model_dump(mode="json", exclude_none=True, exclude={"extra_payload"}) + context = ( + {SERVER_TIMEZONE_CONTEXT_KEY: server_timezone} + if server_timezone is not None + else None + ) + 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 diff --git a/glpi_python_client/_sync/clients/commons/_transport.py b/glpi_python_client/_sync/clients/commons/_transport.py index 5c48302..6ec2e12 100644 --- a/glpi_python_client/_sync/clients/commons/_transport.py +++ b/glpi_python_client/_sync/clients/commons/_transport.py @@ -495,6 +495,18 @@ def _resource_get( model, response.json(), server_timezone=self.server_timezone ) + def _body(self, model: GlpiModel) -> dict[str, object]: + """Serialise one model into a request body on the server's clock. + + Every write in the package goes through here rather than calling + :func:`model_to_payload` directly, because the timezone argument is + not optional in practice and a call site that forgets it produces no + error -- just a timestamp GLPI silently reinterprets. Binding it once + leaves nothing to remember at the next endpoint. + """ + + return model_to_payload(model, server_timezone=self.server_timezone) + def _resource_create( self, endpoint: str, @@ -538,7 +550,7 @@ def _resource_create( """ response = self._post_request( - endpoint, model_to_payload(body_model), skip_entity=skip_entity + endpoint, self._body(body_model), skip_entity=skip_entity ) ensure_response_status( response, @@ -579,7 +591,7 @@ def _resource_update( None """ - response = self._update_request(endpoint, model_to_payload(body_model)) + response = self._update_request(endpoint, self._body(body_model)) ensure_response_status( response, success_statuses=(200, 204), @@ -630,7 +642,7 @@ def _resource_delete( request_body = body if request_body is None and delete_model_cls is not None and force is not None: - request_body = model_to_payload(delete_model_cls(force=force)) # type: ignore[call-arg] + request_body = self._body(delete_model_cls(force=force)) # type: ignore[call-arg] response = self._delete_request( endpoint, request_body, skip_entity=skip_entity ) diff --git a/glpi_python_client/_sync/clients/commons/tests/test_payloads.py b/glpi_python_client/_sync/clients/commons/tests/test_payloads.py index b6c9d94..3bcf86f 100644 --- a/glpi_python_client/_sync/clients/commons/tests/test_payloads.py +++ b/glpi_python_client/_sync/clients/commons/tests/test_payloads.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from glpi_python_client._sync.clients.commons._payloads import ( model_from_payload, @@ -92,8 +92,27 @@ def test_model_to_payload_renders_naive_datetime_without_offset() -> None: assert body["planned_begin"] == "2024-01-01T12:00:00" -def test_model_to_payload_preserves_aware_datetime_offset() -> None: - """An aware datetime keeps its offset rather than being silently dropped.""" +def test_model_to_payload_rewrites_an_aware_datetime_onto_the_server_clock() -> None: + """An aware datetime is converted to server-local time and sent bare. + + GLPI 11 ignores the offset it is sent -- measured, three spellings of one + moment all stored the same wall clock -- so writing ``12:00Z`` to a + ``+01:00`` instance stores 12:00 there and loses an hour silently. The + offset has to be spent on the conversion instead of written out. + """ + + aware = datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc) + + body = model_to_payload( + PostTicketTask(planned_begin=aware), + server_timezone=timezone(timedelta(hours=1)), + ) + + assert body["planned_begin"] == "2024-01-01T13:00:00" + + +def test_model_to_payload_leaves_an_aware_datetime_alone_without_a_timezone() -> None: + """Outside the client there is no server clock to convert onto.""" aware = datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc) diff --git a/glpi_python_client/models/_base.py b/glpi_python_client/models/_base.py index 27a83b0..d534a1f 100644 --- a/glpi_python_client/models/_base.py +++ b/glpi_python_client/models/_base.py @@ -19,7 +19,16 @@ from datetime import datetime from typing import Any -from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, model_validator +from pydantic import ( + BaseModel, + ConfigDict, + Field, + SerializationInfo, + SerializerFunctionWrapHandler, + ValidationInfo, + model_serializer, + model_validator, +) #: Validation-context key carrying the GLPI server's timezone. #: @@ -112,3 +121,56 @@ def _localise_naive_datetimes(self, info: ValidationInfo) -> GlpiModel: if not localised: return self return self.model_copy(update=localised) + + @model_serializer(mode="wrap") + def _render_datetimes_on_the_server_clock( + self, + handler: SerializerFunctionWrapHandler, + info: SerializationInfo, + ) -> Any: + """Convert aware datetimes to server-local time and drop the offset. + + GLPI 11 does not read the offset it sends. Measured on a live + Europe/Paris instance, ``12:30:00`` written bare, as ``...Z``, and + with ``+02:00``, ``+09:00``, ``-08:00`` and ``+14:00`` all store the + same moment -- 12:30 Paris. The server takes the naive prefix, + interprets it in its own timezone, and discards the rest. It is not + ignoring the offset unparsed either: ``+99:99`` answers HTTP 500. So + the value is read and then thrown away, and ``12:30-08:00`` -- 21:30 + in Paris -- lands nine hours early with a 200 and nothing to read + back that looks wrong. + + An offset is therefore not something to preserve on the way out. The + only spelling GLPI cannot misread is one whose naive prefix is + already server-local, so the offset is spent converting the value + and then removed. + + The two rules mirror the inbound half: + + * **Naive values are left alone.** A naive datetime already means + "the server's clock" -- converting it would require guessing which + zone the caller meant. + * **No context means no conversion.** A model dumped outside the + client has no server to be local to. + + Converted values go onto a copy for the same reason the inbound + validator copies: the caller still holds the model being dumped, and + serialising is not allowed to change it. + """ + + context = info.context + if not isinstance(context, dict): + return handler(self) + tzinfo = context.get(SERVER_TIMEZONE_CONTEXT_KEY) + if tzinfo is None: + return handler(self) + + server_local = { + name: value.astimezone(tzinfo).replace(tzinfo=None) + for name in type(self).model_fields + if isinstance(value := getattr(self, name, None), datetime) + and value.tzinfo is not None + } + if not server_local: + return handler(self) + return handler(self.model_copy(update=server_local)) diff --git a/glpi_python_client/models/tests/test_base.py b/glpi_python_client/models/tests/test_base.py index f76c834..bdb6188 100644 --- a/glpi_python_client/models/tests/test_base.py +++ b/glpi_python_client/models/tests/test_base.py @@ -12,6 +12,7 @@ from glpi_python_client.models._base import GlpiModel _PARIS_SUMMER = timezone(timedelta(hours=2)) +_PARIS_WINTER = timezone(timedelta(hours=1)) class _Stamped(GlpiModel): @@ -126,3 +127,57 @@ def test_validating_an_existing_instance_does_not_mutate_the_original() -> None: assert revalidated.date.utcoffset() == timedelta(hours=2) assert original.date is not None assert original.date.tzinfo is None + + +def test_aware_datetime_is_converted_to_the_server_clock_and_stripped() -> None: + """An offset is not information GLPI keeps, so the value must carry none. + + Measured against GLPI 11: the server reads the naive prefix of a + timestamp, interprets it in its own timezone, and discards whatever + offset followed. ``12:30:00Z`` written to a Europe/Paris instance is + stored as 12:30 Paris -- two hours before the moment that was sent, with + a 200 and no complaint. The only spelling that survives is one whose + naive prefix is already server-local, so the offset has to be spent on + the conversion rather than written out. + """ + + stamped = _Stamped(id=1, date=datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc)) + + dumped = stamped.model_dump(mode="json", context={"server_timezone": _PARIS_WINTER}) + + assert dumped["date"] == "2024-01-01T13:00:00" + + +def test_naive_datetime_serialises_unchanged() -> None: + """A naive value already means "the server's clock" and is left alone.""" + + stamped = _Stamped(id=1, date=datetime(2024, 1, 1, 12, 0)) + + dumped = stamped.model_dump(mode="json", context={"server_timezone": _PARIS_WINTER}) + + assert dumped["date"] == "2024-01-01T12:00:00" + + +def test_serialisation_without_a_context_leaves_the_offset_alone() -> None: + """No timezone means no conversion, matching the inbound half. + + A model dumped outside the client has no server to be local to. Guessing + 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)) + + assert stamped.model_dump(mode="json")["date"] == "2024-01-01T12:00:00Z" + + +def test_the_serialisation_timezone_reaches_a_nested_model() -> None: + """Submodels are converted too, since request bodies nest.""" + + nested = _Nested( + id=1, + inner=_Stamped(id=2, date=datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc)), + ) + + dumped = nested.model_dump(mode="json", context={"server_timezone": _PARIS_WINTER}) + + assert dumped["inner"]["date"] == "2024-01-01T13:00:00" diff --git a/glpi_python_client/testing/tests/test_write_timezone_audit.py b/glpi_python_client/testing/tests/test_write_timezone_audit.py new file mode 100644 index 0000000..881c8a6 --- /dev/null +++ b/glpi_python_client/testing/tests/test_write_timezone_audit.py @@ -0,0 +1,73 @@ +"""Audit that every library write serialises on the server's clock. + +This is a structural guard, not a behavioural one, and it exists because +the failure it prevents is silent. :func:`model_to_payload` takes +``server_timezone`` as an optional keyword, so a call site that omits it +still returns a valid body, still passes every transport stub, and still +gets a 200 from GLPI. What it does not do is name the right moment: GLPI 11 +reads the naive prefix of a timestamp and discards the offset, so an aware +datetime written without the conversion is wrong by that offset -- measured +at up to twelve hours, with nothing in the response to show it. + +The rule is that library code calls ``self._body(...)`` on the transport, +which binds the client's timezone once. Tests and the helper's own module +are exempt; nothing else calls :func:`model_to_payload` directly. +""" + +from __future__ import annotations + +import ast +import pathlib + +_PACKAGE_ROOT = pathlib.Path(__file__).resolve().parents[2] + +#: Modules allowed to name ``model_to_payload`` directly. +#: +#: ``_payloads`` defines it, and ``_transport`` wraps it in the one helper +#: that supplies the timezone. +_EXEMPT = {"_payloads.py", "_transport.py"} + + +def _library_modules() -> list[pathlib.Path]: + """Return every non-test, non-testing module in the package.""" + + return [ + path + for path in sorted(_PACKAGE_ROOT.rglob("*.py")) + if "tests" not in path.parts + and "testing" not in path.parts + and not path.name.startswith("test_") + and path.name not in _EXEMPT + ] + + +def test_no_library_module_serialises_a_body_without_the_server_timezone() -> None: + """Only the transport helper may call ``model_to_payload``.""" + + offenders = [ + f"{path.relative_to(_PACKAGE_ROOT)}:{node.lineno}" + for path in _library_modules() + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "model_to_payload" + ] + + assert not offenders, ( + "these call model_to_payload directly and so send timestamps GLPI " + f"will reinterpret; use self._body(...) instead: {offenders}" + ) + + +def test_the_transport_helper_passes_the_client_timezone() -> None: + """The one exempt call site actually supplies what the others delegate. + + Without this, the audit above could pass while the helper it points + everyone at had quietly dropped the argument. + """ + + source = ( + _PACKAGE_ROOT / "_async" / "clients" / "commons" / "_transport.py" + ).read_text(encoding="utf-8") + + assert "model_to_payload(model, server_timezone=self.server_timezone)" in source diff --git a/integration_tests/probe_wire_format.py b/integration_tests/probe_wire_format.py index 4a536ed..401a972 100644 --- a/integration_tests/probe_wire_format.py +++ b/integration_tests/probe_wire_format.py @@ -16,8 +16,14 @@ the fix is to stop discarding it, not to add a second request behind a ``create_*_and_fetch`` helper. -This is a **read-mostly** probe. It creates exactly one ticket, to see a -create response, and deletes it again in a ``finally``. Nothing else is +**#22 follow-up -- does GLPI honour an offset on write?** The fix for #22 +changed outbound serialisation to Pydantic's JSON mode, so an aware +``datetime`` now goes out as ``...+02:00`` or ``...Z``. Accepting it and +honouring it are different questions, and only the second is dangerous to +get wrong: a truncated offset moves the moment silently, with a 200. + +This is a **read-mostly** probe. It creates exactly one ticket -- reused by +both write probes -- and deletes it again in a ``finally``. Nothing else is written. Usage @@ -158,8 +164,70 @@ def _report_timestamps(label: str, payload: Any) -> None: print(f" {label}.{path} = {value!r} -> {verdict}") +#: The three ways this package can now render one moment onto the wire. +#: +#: These are not hypothetical spellings -- they are the literal bytes +#: ``model_to_payload`` produces for a ``PatchTicket(date=...)`` built with a +#: UTC ``tzinfo``, a ``Europe/Paris`` one, and none at all. Pydantic's JSON +#: mode writes UTC as ``Z`` and every other zone as a numeric offset. +#: +#: ``12:30Z`` is the discriminating case, because it is ``14:30`` in Paris: +#: a server that honours the offset stores a different wall clock than one +#: that ignores it, and one read-back separates them. The ``+02:00`` case +#: cannot discriminate -- it names the same wall clock either way -- but it +#: does answer whether an offset-bearing string is accepted at all. +_WRITE_CASES = ( + ("naive (control)", "2026-08-01T12:30:00"), + ("UTC (Z form) ", "2026-08-01T12:30:00Z"), + ("Paris (+02:00) ", "2026-08-01T12:30:00+02:00"), + ("Tokyo (+09:00) ", "2026-08-01T12:30:00+09:00"), + ("LA (-08:00) ", "2026-08-01T12:30:00-08:00"), + ("Kiritim (+14:00) ", "2026-08-01T12:30:00+14:00"), + ("nonsense(+99:99) ", "2026-08-01T12:30:00+99:99"), +) + + +def _probe_write_offsets( + client: httpx.Client, + base: str, + read_headers: dict[str, str], + write_headers: dict[str, str], + ticket_id: int, +) -> None: + """Write each spelling of one moment and report what GLPI stored. + + Issue #22 changed outbound serialisation to Pydantic's JSON mode, so an + aware ``datetime`` now leaves as ``...+02:00`` or ``...Z`` where it + previously left as a live object that ``json.dumps`` refused. That fixed + the crash but moved the question rather than answering it: nothing here + had ever recorded whether GLPI *accepts* an offset, nor -- the part that + matters more -- whether it *honours* one. + + A rejection is loud and harmless. Silent truncation is neither: writing + ``12:30Z`` and having 12:30 stored as Paris local time moves the moment + two hours with a 200 and no complaint. + """ + + for label, wire in _WRITE_CASES: + patch = client.request( + "PATCH", + f"{base}/Assistance/Ticket/{ticket_id}", + headers=write_headers, + json={"date": wire}, + ) + if patch.status_code >= 400: + print(f" {label} {wire!r}") + print(f" PATCH -> HTTP {patch.status_code} REJECTED") + print(f" body -> {patch.text[:200]!r}") + continue + read = client.get(f"{base}/Assistance/Ticket/{ticket_id}", headers=read_headers) + stored = read.json().get("date") if read.status_code < 400 else None + print(f" {label} {wire!r}") + print(f" PATCH -> HTTP {patch.status_code} read back -> {stored!r}") + + def main() -> None: - """Run both probes and print a report to paste into the issues.""" + """Run every probe and print a report to paste into the issues.""" config = _load() base = config["api_url"].rstrip("/") @@ -231,6 +299,14 @@ def main() -> None: "adding create_*_and_fetch (#35)" ) ) + if created_id: + print() + print("=" * 72) + print("PROBE 3 (#22 follow-up) -- does GLPI honour an offset on write?") + print("=" * 72) + _probe_write_offsets( + client, base, read_headers, write_headers, created_id + ) finally: if created_id: # `client.delete(...)` rejects `json=` -- httpx exposes a body From f50024b0a3b6897a59b1a9362be64e76821e25d5 Mon Sep 17 00:00:00 2001 From: baraline Date: Thu, 13 Aug 2026 10:31:57 +0200 Subject: [PATCH 20/21] test: compile every Python snippet in the skills, guide and README The snippets are the package's primary teaching surface -- nine SKILL.md files an agent reads before writing any GLPI code -- and nothing checked them. This adds a guard that compiles all 77. It was written after finding that the previous commit's own sweep, which added `server_timezone` to twelve construction examples, had left three of them with the argument inserted twice and misindented. `GlpiClient(...)` with a keyword repeated is a SyntaxError, so those three examples could not be copied at all, and reviewing the diff by eye did not catch it. Two further defects the guard found, both predating that sweep: - user_guide.rst had three lines of expected output stranded inside a `code-block:: python`. They are the last three prints of the example further up, whose "Example output" block kept only the first; they are moved back to it. - One import in a three-space block was indented four, so copying that snippet raised IndentationError. It compiles rather than parses because `ast.parse` accepts a call with the same keyword twice -- the duplicate is only rejected when the tree is compiled. Parsing alone would have passed the exact file this was written to catch, which is worth stating in the module docstring since `ast.parse` is the obvious first reach. `PyCF_ALLOW_TOP_LEVEL_AWAIT` is set: most async examples are fragments with no surrounding `async def`, which is how they are meant to be read. Without it the guard rejects 28 good snippets and teaches the next author to wrap examples in scaffolding no reader needs. A block that is illustrative rather than runnable can opt out with `# doc: no-parse`. `server_timezone` now governs both directions, so its parameter docstring says what it does on write as well as on read. Co-Authored-By: Claude Opus 5 (1M context) --- docs/user_guide.rst | 8 +- .../tests/test_documentation_snippets.py | 99 +++++++++++++++++++ skills/glpi-client-setup/SKILL.md | 2 - skills/glpi-plugin-fields/SKILL.md | 1 - 4 files changed, 103 insertions(+), 7 deletions(-) create mode 100644 glpi_python_client/testing/tests/test_documentation_snippets.py diff --git a/docs/user_guide.rst b/docs/user_guide.rst index 2e04282..834b518 100644 --- a/docs/user_guide.rst +++ b/docs/user_guide.rst @@ -635,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 ^^^^^^^^^^^^^^^^^^^^^ @@ -666,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 ~~~~~ @@ -717,7 +717,7 @@ 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", diff --git a/glpi_python_client/testing/tests/test_documentation_snippets.py b/glpi_python_client/testing/tests/test_documentation_snippets.py new file mode 100644 index 0000000..1f2ee70 --- /dev/null +++ b/glpi_python_client/testing/tests/test_documentation_snippets.py @@ -0,0 +1,99 @@ +"""Parse every Python snippet in the skills, the guide and the README. + +The snippets are the package's primary teaching surface -- nine ``SKILL.md`` +files an agent reads before writing any GLPI code -- and nothing compiles +them. A sweep that adds an argument to every client construction example can +therefore leave a duplicated keyword or a broken indent in a block that still +*looks* right in a diff, and the first thing to notice is whoever copies it. + +This guard only compiles: a snippet is checked for syntax, not executed and +not type-checked. That is deliberate -- most blocks are fragments referring +to names they never define -- but it is enough to catch the class of damage a +mechanical edit does. + +It uses :func:`compile` rather than :func:`ast.parse` because the two differ +on exactly the case that prompted it. ``ast.parse`` accepts a call with the +same keyword twice; the duplicate is rejected later, when the tree is +compiled. A sweep that inserts an argument into every construction example +can produce precisely that, so parsing alone would have passed the file it +was written to catch. + +``PyCF_ALLOW_TOP_LEVEL_AWAIT`` is set because most async examples are +fragments -- ``ticket = await client.get_ticket(1)`` with no surrounding +``async def``, which is how they are meant to be read. Without the flag the +guard would reject 28 perfectly good snippets and teach whoever hit it to +wrap examples in scaffolding no reader needs. + +Blocks that are illustrative rather than runnable opt out with a +``# doc: no-parse`` comment on the fence line. +""" + +from __future__ import annotations + +import ast +import pathlib +import re +import textwrap + +import pytest + +_REPO_ROOT = pathlib.Path(__file__).resolve().parents[3] + +#: A fenced block introduced as Python, capturing any fence-line flags. +_MARKDOWN_BLOCK = re.compile( + r"^```py(?:thon)?[^\S\n]*(?P[^\n]*)\n(?P.*?)^```", + re.MULTILINE | re.DOTALL, +) + +#: A Sphinx ``code-block:: python`` and everything indented beneath it. +_RST_BLOCK = re.compile( + r"^\.\.[^\S\n]+code-block::[^\S\n]+python\n" + r"(?:^[^\S\n]+:[a-z]+:[^\n]*\n)*" + r"\n" + r"(?P(?:^(?:[^\S\n]+[^\n]*)?\n)+)", + re.MULTILINE, +) + + +def _documents() -> list[pathlib.Path]: + """Return every file whose Python snippets are meant to be copied.""" + + return [ + *sorted((_REPO_ROOT / "skills").rglob("SKILL.md")), + *sorted((_REPO_ROOT / "docs").rglob("*.rst")), + _REPO_ROOT / "README.md", + ] + + +def _snippets() -> list[tuple[str, str]]: + """Return every ``(location, source)`` pair worth parsing.""" + + found: list[tuple[str, str]] = [] + for path in _documents(): + text = path.read_text(encoding="utf-8") + where = path.relative_to(_REPO_ROOT).as_posix() + pattern = _RST_BLOCK if path.suffix == ".rst" else _MARKDOWN_BLOCK + for match in pattern.finditer(text): + if "no-parse" in (match.groupdict().get("flags") or ""): + continue + line = text[: match.start()].count("\n") + 1 + found.append((f"{where}:{line}", textwrap.dedent(match["code"]))) + return found + + +def test_the_documents_actually_contain_snippets() -> None: + """Guard the guard: a broken regex must not silently check nothing.""" + + assert len(_snippets()) > 40 + + +@pytest.mark.parametrize( + ("location", "source"), _snippets(), ids=[where for where, _ in _snippets()] +) +def test_documentation_snippet_compiles(location: str, source: str) -> None: + """Every documented snippet is syntactically valid Python.""" + + try: + compile(source, location, "exec", flags=ast.PyCF_ALLOW_TOP_LEVEL_AWAIT) + except SyntaxError as error: # pragma: no cover - the message is the point + pytest.fail(f"{location} does not compile: {error}") diff --git a/skills/glpi-client-setup/SKILL.md b/skills/glpi-client-setup/SKILL.md index ab50751..d606971 100644 --- a/skills/glpi-client-setup/SKILL.md +++ b/skills/glpi-client-setup/SKILL.md @@ -103,7 +103,6 @@ def main() -> None: with GlpiClient( glpi_api_url="https://glpi.example.com/api.php/v2", server_timezone="Europe/Paris", - server_timezone="Europe/Paris", client_id="oauth-client-id", client_secret="oauth-client-secret", username="api-user", @@ -129,7 +128,6 @@ async def main() -> None: async with AsyncGlpiClient( glpi_api_url="https://glpi.example.com/api.php/v2", server_timezone="Europe/Paris", - server_timezone="Europe/Paris", client_id="oauth-client-id", client_secret="oauth-client-secret", username="api-user", diff --git a/skills/glpi-plugin-fields/SKILL.md b/skills/glpi-plugin-fields/SKILL.md index 2414f49..36314e3 100644 --- a/skills/glpi-plugin-fields/SKILL.md +++ b/skills/glpi-plugin-fields/SKILL.md @@ -64,7 +64,6 @@ async def main() -> None: async with AsyncGlpiClient( glpi_api_url="https://glpi.example.com/api.php/v2", server_timezone="Europe/Paris", - server_timezone="Europe/Paris", client_id="oauth-client-id", client_secret="oauth-client-secret", v1_base_url="https://glpi.example.com/api.php/v1", From 997b0c35da29e4ea0da7fafaa1a61d036fa1cd83 Mon Sep 17 00:00:00 2001 From: baraline Date: Thu, 13 Aug 2026 11:08:24 +0200 Subject: [PATCH 21/21] [mnt] Fix changelog, update version numbers --- CHANGELOG.md | 128 +++++++++++++++--- glpi_python_client/__init__.py | 2 +- .../testing/tests/test_version_agreement.py | 89 ++++++++++++ pyproject.toml | 2 +- skills/glpi-client-setup/SKILL.md | 2 +- skills/glpi-document-workflow/SKILL.md | 2 +- skills/glpi-knowledge-base/SKILL.md | 2 +- skills/glpi-plugin-fields/SKILL.md | 2 +- skills/glpi-reporting-and-context/SKILL.md | 2 +- skills/glpi-team-members/SKILL.md | 2 +- skills/glpi-ticket-timeline/SKILL.md | 2 +- skills/glpi-ticket-workflow/SKILL.md | 2 +- .../glpi-user-location-provisioning/SKILL.md | 2 +- 13 files changed, 208 insertions(+), 31 deletions(-) create mode 100644 glpi_python_client/testing/tests/test_version_agreement.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 79453ac..6052112 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ 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) @@ -47,6 +47,107 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). 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 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 out"` and `"if x0"` 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 ``, 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 - **The unit test suite was published inside the wheel and the sdist.** Both @@ -193,25 +294,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### 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`. - - `glpi_python_client/clients/tests/test_async_selfcall_guard.py`: a structural AST guard that fails the suite if any public method on `GlpiClient` transitively reaches another public method through a @@ -374,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 diff --git a/glpi_python_client/__init__.py b/glpi_python_client/__init__.py index 6fc4a2b..13e3eb8 100644 --- a/glpi_python_client/__init__.py +++ b/glpi_python_client/__init__.py @@ -109,7 +109,7 @@ date_window, ) -__version__ = "0.4.2" +__version__ = "0.4.3" __all__ = [ "AsyncGlpiClient", diff --git a/glpi_python_client/testing/tests/test_version_agreement.py b/glpi_python_client/testing/tests/test_version_agreement.py new file mode 100644 index 0000000..4e2e55b --- /dev/null +++ b/glpi_python_client/testing/tests/test_version_agreement.py @@ -0,0 +1,89 @@ +"""The version is written down in eleven places; they must agree. + +``pyproject.toml`` is the source of truth -- ``release.yml`` validates the +git tag against it, and that is the only version check anything performs. +Nothing checks the other ten, so they drift silently, and they had: at the +time this was written ``__version__`` was one release behind and all nine +skills were two. + +That matters most for the skills. ``metadata.version`` tells a consumer +which release the skill describes, and nine of them claimed 0.4.1 while +documenting a branch that had since made ``server_timezone`` required and +turned a swallowed 4xx into a raise. An agent trusting the stamp would have +written client constructions that raise ``TypeError`` on the first call. +""" + +from __future__ import annotations + +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 glpi_python_client + +_REPO_ROOT = pathlib.Path(__file__).resolve().parents[3] + +#: ``version:`` under the ``metadata:`` key of a SKILL.md front-matter block. +_SKILL_VERSION = re.compile(r'^\s+version:\s*"(?P[^"]+)"', re.MULTILINE) + + +def _declared_version() -> str: + """Return the version in ``pyproject.toml``, the source of truth.""" + + with (_REPO_ROOT / "pyproject.toml").open("rb") as handle: + config = tomllib.load(handle) + version = config["project"]["version"] + assert isinstance(version, str) + return version + + +def _skills() -> list[pathlib.Path]: + """Return every skill definition that stamps a package version.""" + + return sorted((_REPO_ROOT / "skills").rglob("SKILL.md")) + + +def test_package_dunder_version_matches_pyproject() -> None: + """``glpi_python_client.__version__`` is what the build publishes.""" + + assert glpi_python_client.__version__ == _declared_version() + + +def test_every_skill_stamps_the_released_version() -> None: + """No skill claims to describe a release other than this one.""" + + expected = _declared_version() + stamped = { + path.relative_to(_REPO_ROOT).as_posix(): match["version"] + for path in _skills() + if (match := _SKILL_VERSION.search(path.read_text(encoding="utf-8"))) + } + + assert len(stamped) == len(_skills()), f"a skill has no metadata.version: {stamped}" + assert set(stamped.values()) == {expected}, ( + f"skills disagree with pyproject ({expected}): " + f"{ {k: v for k, v in stamped.items() if v != expected} }" + ) + + +def test_the_changelog_records_the_released_version() -> None: + """A release has a section; the notes are not left under 'Unreleased'. + + Every release through 0.4.2 was tagged with its notes still under an + ``## Unreleased`` heading, so three releases' entries accumulated into + one undifferentiated block. + """ + + changelog = (_REPO_ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + expected = _declared_version() + + headings = re.findall(r"^## +(.+)$", changelog, re.MULTILINE) + + assert any(head.startswith(expected) for head in headings), ( + f"CHANGELOG.md has no section for {expected}; found {headings}" + ) diff --git a/pyproject.toml b/pyproject.toml index 872a127..66835b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ exclude = [ [project] name = "glpi-python-client" -version = "0.4.2" +version = "0.4.3" description = "A typed Python client for GLPI ITSM APIs." readme = "README.md" requires-python = ">=3.10" diff --git a/skills/glpi-client-setup/SKILL.md b/skills/glpi-client-setup/SKILL.md index d606971..aef3468 100644 --- a/skills/glpi-client-setup/SKILL.md +++ b/skills/glpi-client-setup/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.10+, glpi-python-client, network access to a GLPI v2 API, and valid GLPI credentials." metadata: package: glpi-python-client - version: "0.4.1" + version: "0.4.3" --- # GLPI Client Setup diff --git a/skills/glpi-document-workflow/SKILL.md b/skills/glpi-document-workflow/SKILL.md index 3d4d011..bb93d36 100644 --- a/skills/glpi-document-workflow/SKILL.md +++ b/skills/glpi-document-workflow/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.10+, 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.4.1" + version: "0.4.3" --- # GLPI Document Workflow diff --git a/skills/glpi-knowledge-base/SKILL.md b/skills/glpi-knowledge-base/SKILL.md index 8ffa4cc..43bd1dc 100644 --- a/skills/glpi-knowledge-base/SKILL.md +++ b/skills/glpi-knowledge-base/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.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)." metadata: package: glpi-python-client - version: "0.4.1" + version: "0.4.3" --- # GLPI Knowledge Base diff --git a/skills/glpi-plugin-fields/SKILL.md b/skills/glpi-plugin-fields/SKILL.md index 36314e3..f3bdcdb 100644 --- a/skills/glpi-plugin-fields/SKILL.md +++ b/skills/glpi-plugin-fields/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.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." metadata: package: glpi-python-client - version: "0.4.1" + version: "0.4.3" --- # GLPI Plugin Fields diff --git a/skills/glpi-reporting-and-context/SKILL.md b/skills/glpi-reporting-and-context/SKILL.md index 13898b8..6a1055c 100644 --- a/skills/glpi-reporting-and-context/SKILL.md +++ b/skills/glpi-reporting-and-context/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.10+, 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.4.1" + version: "0.4.3" --- # GLPI Reporting And Context diff --git a/skills/glpi-team-members/SKILL.md b/skills/glpi-team-members/SKILL.md index f6777e9..bfcc1db 100644 --- a/skills/glpi-team-members/SKILL.md +++ b/skills/glpi-team-members/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.10+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to manage ticket teams." metadata: package: glpi-python-client - version: "0.4.1" + version: "0.4.3" --- # GLPI Team Members diff --git a/skills/glpi-ticket-timeline/SKILL.md b/skills/glpi-ticket-timeline/SKILL.md index b1f65d0..bb59cf1 100644 --- a/skills/glpi-ticket-timeline/SKILL.md +++ b/skills/glpi-ticket-timeline/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.10+, glpi-python-client, and network access to the GLPI v2 API." metadata: package: glpi-python-client - version: "0.4.1" + version: "0.4.3" --- # GLPI Ticket Timeline diff --git a/skills/glpi-ticket-workflow/SKILL.md b/skills/glpi-ticket-workflow/SKILL.md index 16102ee..101fdc3 100644 --- a/skills/glpi-ticket-workflow/SKILL.md +++ b/skills/glpi-ticket-workflow/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.10+, glpi-python-client, network access to the GLPI v2 API, and credentials accepted by GlpiClient." metadata: package: glpi-python-client - version: "0.4.1" + version: "0.4.3" --- # GLPI Ticket Workflow diff --git a/skills/glpi-user-location-provisioning/SKILL.md b/skills/glpi-user-location-provisioning/SKILL.md index 9ed8c5e..742de53 100644 --- a/skills/glpi-user-location-provisioning/SKILL.md +++ b/skills/glpi-user-location-provisioning/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.10+, 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.4.1" + version: "0.4.3" --- # GLPI User, Location, And Entity Provisioning