diff --git a/.coverage b/.coverage
deleted file mode 100644
index 618897c..0000000
Binary files a/.coverage and /dev/null differ
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 75db468..c1beca2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -103,6 +103,14 @@ jobs:
- name: Run mypy
run: python -m mypy glpi_python_client
+ # The sync client tree is generated from the async one and checked in.
+ # This catches a _sync/ left stale by an edit to _async/. It does NOT
+ # catch a token collision -- that is deterministic, so regenerating
+ # reproduces it and the diff stays clean. test_unasync_codegen.py
+ # covers that case.
+ - name: Check the generated sync tree is up to date
+ run: python unasync_build.py --check
+
- name: Build documentation
run: python -m sphinx -W --keep-going -b html docs docs/_build/html
diff --git a/.gitignore b/.gitignore
index 89e9793..faab1cb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,4 +33,7 @@ CLAUDE.md
docs/superpowers/*
# Large generated GLPI API contract (kept locally, not tracked)
-docs/glpi_api_contract.json
\ No newline at end of file
+docs/glpi_api_contract.json
+# Coverage data: rewritten by every test run (and by the venv .pth hook).
+.coverage
+.coverage.*
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 7d41d78..3123776 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -17,6 +17,16 @@ repos:
types: [python]
pass_filenames: false
+ # Fails on a _sync/ tree left stale by an edit to _async/. Runs at
+ # commit time rather than pre-push so the generated tree is never
+ # committed out of step with its source.
+ - id: unasync-check
+ name: generated sync tree is up to date
+ entry: python unasync_build.py --check
+ language: system
+ types: [python]
+ pass_filenames: false
+
- id: pytest-coverage
name: pytest with coverage (>=95%)
entry: python -m pytest -m "not integration" --cov=glpi_python_client --cov-fail-under=95 -q
diff --git a/CHANGELOG.md b/CHANGELOG.md
index be5c7ad..9d28153 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Fixed
+- **A `Major` priority ticket made the whole search fail.** GLPI's priority
+ scale has six levels; the published contract advertises five, and
+ `GlpiPriority` followed the contract. Since `GetTicket.priority` is typed
+ with that enum and validation runs per record, a single escalated ticket
+ anywhere in a result set raised `ValidationError` and took the entire
+ query down with it — most likely to bite exactly the reporting queries
+ that filter on high priority. `GlpiPriority.MAJOR = 6` is now defined.
+ The five existing members keep their identifiers, so stored filters are
+ unaffected. `urgency` and `impact` genuinely do stop at 5 and now accept a
+ value GLPI will never send, which is harmless in the direction that
+ matters.
+
- **The statistics layer sent GLPI v1 field names to the v2 API, which
silently ignored them and returned unfiltered results.** v2 drops a
`filter=` conjunct whose field it does not recognise, honours the rest,
@@ -154,6 +166,62 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Changed
+- **`AsyncGlpiClient` now performs real non-blocking I/O.** It was a facade
+ that wrapped each synchronous method in `asyncio.to_thread`, so "async"
+ meant "blocking call on a worker thread". It is now genuinely
+ asynchronous, built on `httpx.AsyncClient`, with no thread pool and no
+ executor. The `executor` constructor keyword is gone, as is
+ `AsyncBridge`.
+ - The two clients are now one codebase. `glpi_python_client/_async/` is
+ hand-written and `glpi_python_client/_sync/` is generated from it by
+ `unasync_build.py`, committed, and diffed in CI. Endpoint logic exists
+ exactly once, so the two surfaces cannot drift.
+ - This deletes the six hand-written async override modules the bridge
+ forced into existence — including the 500-line `_statistics_async.py`,
+ which duplicated the most intricate logic in the package with no test
+ asserting the two copies agreed.
+ - Aggregating helpers keep their concurrency through a shared `gather`
+ helper that is `asyncio.gather` on the async surface and sequential
+ evaluation on the generated one, written once at the call site.
+ - **Public imports are unchanged**: `from glpi_python_client import
+ GlpiClient, AsyncGlpiClient` still works. Code importing private
+ module paths (`glpi_python_client.clients.*`, `glpi_python_client.auth.*`)
+ must add the tree segment, e.g.
+ `glpi_python_client._sync.clients.commons._transport`.
+- **Breaking: the HTTP transport moved from `requests` to `httpx`.**
+ `requests` and `urllib3` are no longer dependencies. The v2 transport, the
+ legacy v1 session, and the OAuth token manager were swapped together in a
+ single change because they share `_http.py`; splitting them would have left
+ the shared code validated only by tests exercising the old transport.
+ Behaviour is preserved, which took three deliberate corrections where the
+ two libraries disagree and the difference is silent:
+ - **Query parameters with a `None` value are dropped**, as `requests` did.
+ `httpx` encodes them as a valueless `key=`, and GLPI treats an empty
+ filter or search value as *match everything* — so the swap would have
+ silently widened queries rather than leaving them unconstrained.
+ - **`bytes` and `bool` parameter values keep their previous rendering**
+ (`b"x"` → `x`, `True` → `True`). `httpx` would emit the Python repr
+ `b'x'` and a lowercase `true`.
+ - **Redirects are still followed.** `requests` follows them by default and
+ `httpx` does not, so a followed redirect would have started surfacing as
+ a bare 3xx response.
+- **Breaking: network-level faults now raise `GlpiTransportError`** (or its
+ `GlpiTimeoutError` subclass) instead of propagating the HTTP library's own
+ exception. This completes the promise the previous release documented:
+ catching `GlpiError` is now sufficient for the library's whole failure
+ surface, and you never need to import the HTTP library. The originating
+ exception is attached as `__cause__`. Code doing
+ `except requests.RequestException` should now catch `GlpiTransportError`.
+ Note it does *not* inherit `ValueError` — nothing was passed in wrongly and
+ no value came back.
+ - The retry predicates were retargeted onto this library-owned type in the
+ same change. This is the failure mode that made the swap risky: the
+ exception trees of the two libraries are completely disjoint, so a
+ predicate left naming the old one stops matching and **every retry
+ silently disappears** — no error, no warning, and a green test suite.
+ Naming a type the library itself raises makes that impossible to
+ reintroduce. A mutation test confirms the suite catches it: reverting the
+ predicate fails 7 tests across all three transports.
- **Breaking:** a persistent 5xx now raises `GlpiServerError` instead of
`tenacity.RetryError`. The retry decorators gained `reraise=True`. Code
doing `except tenacity.RetryError` and digging out
@@ -173,6 +241,70 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
module and its `remote_error_message` helper are removed. It had no
library call sites, and `reraise=True` leaves it nothing to unwrap.
+### Performance
+
+- **`sniffio` is now a dependency, and the async client is ~2.6x faster at
+ wide fan-out because of it.** `httpcore` decides whether it is running
+ under asyncio or trio by probing for `sniffio` on every async request,
+ falling back to `"asyncio"` when the import fails. Nothing in the
+ dependency chain required it — `httpx` pulls in `anyio`, and `anyio` 4.14
+ dropped `sniffio` — so a fresh install had no `sniffio`, and because
+ Python never caches a failed import, every single request re-walked
+ `sys.path` doing filesystem stats. Measured against a local server with
+ 50 ms latency, a fan-out of 128 took **3354 ms without `sniffio` and
+ 1304 ms with it**. `pip check` reports no broken requirements either way,
+ which is why this went unnoticed: nothing declares the package, nothing
+ imports it, and the only symptom is that every request is slower.
+
+- **Bounding a wide fan-out is now a documented requirement, not a
+ suggestion.** `httpcore` rescans its entire connection pool on every
+ request assignment, calling `has_expired()` per connection — profiled at
+ 9040 such calls for a 64-request fan-out. The cost is quadratic in the
+ width of the fan-out and it saturates the event loop, so server-observed
+ concurrency *falls* as the fan-out widens. Raising `httpx.Limits` does not
+ help. At a fan-out of 16 against a 50 ms server, an unbounded
+ `gather` took 350 ms while the same work capped at 16 with an
+ `asyncio.Semaphore` took 108 ms. See "Bounding concurrency" in the user
+ guide. This is a property of `httpx` 0.28 / `httpcore` 1.0.9, which are
+ the current releases; there is no version to upgrade to.
+
+### Documentation
+
+- **The documentation still described the deleted bridge**, in the places
+ users actually read: the README, the user guide, the API reference, the
+ package docstring, and two skills all said `AsyncGlpiClient` wraps each
+ synchronous method into a coroutine dispatched to a worker thread via
+ `asyncio.to_thread`. None of that has been true since the codegen
+ rewrite. The worst of it was live API: the user guide's *Custom thread
+ pools* section and step 8 of `glpi-client-setup` documented an
+ `executor=` constructor argument that no longer exists, so anyone
+ copying either example got a `TypeError`. That section is now
+ *Bounding concurrency* and shows an `asyncio.Semaphore`, which is what
+ actually bounds a fan-out now.
+- **Fourteen docstring cross-references pointed at modules the rewrite had
+ renamed or deleted** (`clients.async_client`, `clients.sync_client`,
+ `custom._ticket_context_async`). Nothing caught them: Sphinx runs with
+ `nitpicky` off, so an unresolvable target renders as plain text rather
+ than failing the build, and these private modules are not autodoc'd in
+ the first place. `tests/test_docstring_references.py` now resolves every
+ qualified reference in the package against the live modules.
+- **The generated sync tree documented itself in terms of the async one.**
+ unasync repoints imports, where `_async` is its own NAME token, but a
+ dotted path inside a docstring is a single string token and passes
+ through untouched — so 47 cross-references in the shipped sync client
+ pointed into `_async/`. The diff gate is blind to this for the same
+ reason it is blind to a token collision: the omission is deterministic,
+ so regeneration reproduces it and the diff stays clean. `unasync_build`
+ now repoints the qualified prefix, and a test asserts the generated tree
+ never names `_async` at all.
+- `TicketContextMixin` claimed its five calls ran sequentially and that an
+ async override fanned them out, contradicting both the code and its own
+ method docstring. The development guide still described the deleted
+ `_ticket_context_async.py` / `_statistics_async.py` and the retired
+ `test_parity.py` / `test_async_selfcall_guard.py` suites.
+- The `requests` intersphinx mapping is removed; it survived the transport
+ swap and made every docs build fetch an inventory nothing referenced.
+
### Unchanged (deliberately)
- Retry semantics: 5xx retried 3 times with a 3-second fixed wait, 4xx never
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 8f13399..17f8887 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -51,10 +51,13 @@ python -m sphinx -W --keep-going -b html docs docs/_build/html
## Design Guidelines
- Keep API calls behind `GlpiClient` / `AsyncGlpiClient` methods. Add
- new endpoints to a sync endpoint mixin only; `AsyncGlpiClient`
- exposes them as coroutines automatically through `AsyncBridge`. Only
- add a dedicated async override when the method needs concurrent
- fan-out via `asyncio.gather`.
+ new endpoints to the async endpoint mixin under
+ `glpi_python_client/_async/`, then run `python unasync_build.py` to
+ regenerate `glpi_python_client/_sync/` and commit both. Never edit
+ `_sync/` by hand; CI regenerates it and fails on any difference. For
+ concurrent fan-out use `gather` from `_async/_concurrency.py` rather
+ than `asyncio.gather` directly, so the generated sync code stays
+ correct.
- Prefer field-validated Pydantic models for request and response payloads.
- Avoid organization-specific category, entity, or profile defaults in the
library core.
diff --git a/README.md b/README.md
index a3cb685..261c068 100644
--- a/README.md
+++ b/README.md
@@ -17,10 +17,12 @@ back to HTML for outgoing payloads.
It currently focuses on ticket-centric workflows and exposes two high-level
clients built on top of the GLPI v2 REST API:
-- `GlpiClient` — synchronous, blocking client (single source of truth for
- endpoint behaviour).
-- `AsyncGlpiClient` — asynchronous facade that wraps every synchronous
- method into a coroutine and dispatches it to a worker thread.
+- `GlpiClient` — synchronous, blocking client.
+- `AsyncGlpiClient` — asynchronous client doing real non-blocking I/O on the
+ event loop.
+
+Neither wraps the other. The async tree is hand-written and the sync one is
+generated from it, so the two surfaces cannot drift apart.
Note that all integration tests using this package are made on GLPI 11.
I cannot make any guarantee of the behaviour on previous versions.
@@ -106,15 +108,19 @@ variables.
### Sync or async?
Both clients expose the exact same endpoint surface and accept the same
-constructor arguments. The async client is a thin facade that wraps each
-synchronous method into a coroutine dispatched to a worker thread via
-`asyncio.to_thread` (or a caller-supplied `concurrent.futures.Executor`).
-A shared `threading.Lock` serialises OAuth token acquisition so concurrent
-`asyncio.gather(...)` fan-outs cannot race. Pick `GlpiClient` for plain
-scripts, CLI tools, and synchronous services; pick `AsyncGlpiClient` when
-your application already runs an event loop or when you need concurrent
-fan-out (the aggregated `get_ticket_context` and per-ticket
-`get_task_statistics` helpers use `asyncio.gather` on the async client).
+constructor arguments, because both are generated from one source: the
+async tree is hand-written and the sync one is produced from it by a build
+step that strips `async`/`await`. `AsyncGlpiClient` performs real
+non-blocking I/O on the event loop — there is no worker thread and no
+executor — and `GlpiClient` performs real blocking I/O with no coroutine
+scheduling. An auth lock serialises OAuth token acquisition on both
+surfaces, so concurrent fan-outs cannot race.
+
+Pick `GlpiClient` for plain scripts, CLI tools, and synchronous services;
+pick `AsyncGlpiClient` when your application already runs an event loop or
+when you need concurrent fan-out (the aggregated `get_ticket_context` and
+per-ticket `get_task_statistics` helpers overlap their calls there, and run
+them one after another on the sync client).
## Documentation
diff --git a/docs/api_reference.rst b/docs/api_reference.rst
index e512a4e..61ab4af 100644
--- a/docs/api_reference.rst
+++ b/docs/api_reference.rst
@@ -11,8 +11,8 @@ Clients
-------
The package exposes two clients with identical endpoint surfaces. The
-synchronous one is the single source of truth for endpoint behaviour;
-the asynchronous one wraps each synchronous method into a coroutine.
+asynchronous one is hand-written and the synchronous one is generated
+from it, so neither wraps the other and the two cannot drift apart.
.. autoclass:: GlpiClient
:members:
@@ -24,10 +24,6 @@ the asynchronous one wraps each synchronous method into a coroutine.
:inherited-members:
:show-inheritance:
-.. autoclass:: glpi_python_client.clients.commons._async_bridge.AsyncBridge
- :members:
- :show-inheritance:
-
Exceptions
----------
@@ -37,12 +33,13 @@ unusable response body derive from :class:`GlpiError`.
:class:`GlpiProtocolError` also inherit :class:`ValueError` for backwards
compatibility with releases that raised bare ``ValueError``.
-This is not the library's entire failure surface. Network-level faults
-(connection failures, DNS errors, timeouts) still propagate as
-``requests`` exceptions today: :class:`GlpiTransportError` and
-:class:`GlpiTimeoutError` are reserved for that case but are not raised
-until a future httpx transport swap. A handful of sites also
-deliberately still raise bare ``RuntimeError`` or ``TypeError`` instead
+Network-level faults (connection failures, DNS errors, timeouts) are
+raised as :class:`GlpiTransportError`, or its :class:`GlpiTimeoutError`
+subclass for a timeout, with the underlying transport exception attached
+as ``__cause__``. Catching :class:`GlpiError` is therefore sufficient for
+the library's failure surface -- you never need to import the HTTP
+library. A handful of sites do still deliberately raise bare
+``RuntimeError`` or ``TypeError`` instead
of a library type, so existing ``except RuntimeError`` / ``except
TypeError`` code keeps working. See :ref:`error-handling` in the user
guide for the full picture, including which methods raise which type.
diff --git a/docs/conf.py b/docs/conf.py
index f231e23..9fbb13c 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -62,5 +62,4 @@ def _read_project_version() -> str:
intersphinx_mapping = {
"python": ("https://docs.python.org/3", None),
- "requests": ("https://requests.readthedocs.io/en/latest/", None),
}
diff --git a/docs/development.md b/docs/development.md
index 03314f1..dd14b68 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -42,44 +42,43 @@ python -m pytest
- `glpi_python_client.__init__` exposes the public import surface,
including both client classes and the Pydantic models.
-- `glpi_python_client.clients.sync_client.GlpiClient` is the
- synchronous, blocking client. It is the single source of truth for
- endpoint behaviour: each public method lives on one of the sync
- endpoint mixins under `glpi_python_client.clients.api.*` and
- `glpi_python_client.clients.custom.*`.
-- `glpi_python_client.clients.async_client.AsyncGlpiClient` is the
- asynchronous facade. It inherits the same endpoint mixins and uses
- `glpi_python_client.clients.commons._async_bridge.AsyncBridge` to wrap
- every inherited public sync method into a coroutine dispatched on a
- worker thread (`asyncio.to_thread` by default, or a caller-supplied
- `concurrent.futures.Executor`).
-- `glpi_python_client.clients.commons` holds the reusable building
+- `glpi_python_client/_async/` is the **only hand-written client tree**,
+ and `glpi_python_client/_sync/` is generated from it by
+ `unasync_build.py` and committed. Every endpoint method is therefore
+ written exactly once, as an `async def`, and the sync client is a
+ mechanical token transform of it. CI regenerates the tree and fails on
+ any difference.
+- `glpi_python_client._async.clients.client.AsyncGlpiClient` and its
+ generated twin `glpi_python_client._sync.clients.client.GlpiClient` are
+ composed from the same endpoint mixins. Neither wraps the other:
+ the async client performs real non-blocking I/O and the sync one real
+ blocking I/O.
+- `glpi_python_client._async._concurrency` is the one module hand-written
+ on *both* sides, because the two surfaces need primitives that differ
+ in kind rather than in spelling: the auth lock is an `asyncio.Lock`
+ against a `threading.Lock`, and `gather` is a real fan-out against
+ plain sequential evaluation. Neither substitutes for the other — see
+ the module for what breaks in each direction. Call `gather` from there
+ for concurrent fan-out, never `asyncio.gather` directly, which unasync
+ would leave intact and emit as broken sync code.
+- `glpi_python_client._async.clients.commons` holds the reusable building
blocks shared by every endpoint mixin: configuration helpers
- (`_config`), constants (`_constants`), errors (`_errors`), filters
- (`_filters`), HTTP helpers (`_http`), payload builders (`_payloads`),
- the synchronous `TransportMixin` (`_transport`), and the
- `AsyncBridge` (`_async_bridge`). A shared `threading.Lock` in the
- transport serialises OAuth token acquisition so concurrent
- `asyncio.gather` fan-outs on the async client cannot race.
-- `glpi_python_client.clients.api.*` contains the contract-aligned
+ (`_config`), constants (`_constants`), filters (`_filters`), HTTP
+ helpers (`_http`), payload builders (`_payloads`), and the
+ `TransportMixin` (`_transport`). The transport serialises OAuth token
+ acquisition with the lock from `_concurrency`, so concurrent callers
+ cannot race the token manager.
+- `glpi_python_client._errors` defines the public exception hierarchy
+ (`GlpiError` and its subclasses), re-exported from the package root.
+- `glpi_python_client._async.clients.api.*` contains the contract-aligned
endpoint mixins, grouped by GLPI subtree (administration, assistance,
assistance/timeline, dropdowns, management, knowledgebase, plugins).
- Most are synchronous; `knowledgebase/_article_async.py` and
- `plugins/_fields_async.py` are hand-written async overrides needed
- because their synchronous bodies call a sibling public method through
- `self` (see the `clients.custom` entry below for the other reason a
- method needs one).
-- `glpi_python_client.clients.custom` contains custom helpers built on
- top of the API mixins. Each helper has a synchronous implementation
- (`_ticket_context.py`, `_statistics.py`) plus an async override
- (`_ticket_context_async.py`, `_statistics_async.py`) that fans the
- underlying calls out concurrently with `asyncio.gather`. That is one
- of two reasons a method needs a hand-written async override; the
- other — a synchronous body calling a sibling public method through
- `self` — is why `clients.api.knowledgebase` and `clients.api.plugins`
- also ship one (see above).
-- `glpi_python_client.auth._v1_session` contains the legacy v1
- session used for binary document uploads.
+- `glpi_python_client._async.clients.custom` contains helpers built on top
+ of the API mixins (`_ticket_context.py`, `_statistics.py`). Their
+ independent calls go through `gather`, so one implementation fans out
+ on the async surface and runs sequentially on the generated one.
+- `glpi_python_client._async.auth._v1_session` contains the legacy v1
+ session used for binary document uploads and the `Fields` plugin.
- `glpi_python_client.models` contains typed request and response
models.
- `glpi_python_client.content` handles HTML/Markdown conversion for
@@ -96,28 +95,36 @@ python -m pytest
## Adding Endpoints
1. Add or extend a model in `glpi_python_client.models`.
-2. Add the client method on the matching **synchronous** endpoint mixin
- under `glpi_python_client.clients.api.*` (or
- `glpi_python_client.clients.custom.*` for derived helpers). The
- async client picks the new method up automatically through the
- `AsyncBridge` — do not duplicate the method on a parallel async
- mixin unless you genuinely need concurrent fan-out (`asyncio.gather`)
- inside the method body, **or** the method calls a sibling public
- method through `self` (directly, or transitively via a private
- helper): the bridge wraps that sibling into a coroutine, so the
- un-awaited call silently drops instead of running. The guard in step
- 4 fails the suite if you miss this.
+2. Add the client method on the matching endpoint mixin under
+ `glpi_python_client/_async/clients/api/**` (or `.../custom/**` for
+ derived helpers), as an `async def`. Then run `python
+ unasync_build.py` to regenerate `glpi_python_client/_sync/`, and
+ commit both. **Never edit `_sync/` by hand** — CI regenerates it and
+ fails on any difference.
+
+ Write the method once. There is no parallel async mixin to keep in
+ step: if it needs concurrent fan-out, call `gather` from
+ `glpi_python_client/_async/_concurrency.py`, which runs the calls
+ concurrently on the async surface and sequentially on the generated
+ one, from the same source line.
3. Put reusable endpoint names, payload builders, response handling, or
pagination logic in the focused
- `glpi_python_client.clients.commons` helper module named for that
+ `glpi_python_client._async.clients.commons` helper module named for that
responsibility.
-4. Add unit tests for payload serialization, response parsing, and
- client behavior. The parity test in
- `glpi_python_client/clients/tests/test_parity.py` will fail if the
- sync and async surfaces diverge, and
- `glpi_python_client/clients/tests/test_async_selfcall_guard.py` will
- fail if a public method reaches another public method through `self`
- without a hand-written async override.
+4. Add unit tests under `glpi_python_client/tests/**` for payload
+ serialization, response parsing, and client behaviour. They exercise
+ the generated sync client, which shares its statements with the async
+ one.
+
+ Two suites cover what that cannot reach.
+ `glpi_python_client/tests/test_unasync_codegen.py` holds the
+ invariants the CI diff gate is blind to — a token collision is
+ deterministic, so regeneration reproduces it and the diff stays clean.
+ `glpi_python_client/tests/test_async_surface.py` runs the async client
+ for real: that a method actually awaits, that contending tasks do not
+ deadlock on the auth lock, and that `gather` genuinely overlaps its
+ arguments. Add to it whenever a change is only observable once
+ `async`/`await` are real.
5. Document the new workflow in `docs/user_guide.rst` or the README.
Keep organization-specific defaults outside the package core.
diff --git a/docs/development_rtd.rst b/docs/development_rtd.rst
index 6b7e838..84931b4 100644
--- a/docs/development_rtd.rst
+++ b/docs/development_rtd.rst
@@ -116,21 +116,21 @@ Package Layout
Public import surface (``GlpiClient``, ``GlpiTicketContext``, public Pydantic
models, enums, and ``__version__``).
-``glpi_python_client.clients.glpi_client``
+``glpi_python_client._async.clients.client``
Composition root. ``GlpiClient`` mixes the per-resource async API mixins,
the OAuth2 token manager, the asynchronous v2 transport, and the optional
internal v1 session used for document uploads.
-``glpi_python_client.clients.api``
+``glpi_python_client._async.clients.api``
Async API mixins generated from the GLPI v2 OpenAPI contract: tickets,
ticket timeline (followups, tasks, solutions, documents), team members,
documents, users, locations, entities, ...
-``glpi_python_client.clients.custom``
+``glpi_python_client._async.clients.custom``
Higher-level helpers built on top of the contract mixins:
``get_ticket_context``, ``get_ticket_statistics``, ``get_task_statistics``.
-``glpi_python_client.clients.commons``
+``glpi_python_client._async.clients.commons``
Shared HTTP transport pieces, including the timeline envelope unwrap that
reconciles live server behaviour with the OpenAPI contract.
@@ -147,11 +147,11 @@ Adding Endpoints
#. Add or extend the contract-aligned models in
``glpi_python_client.models.api_schema``.
-#. Add the async mixin and method under ``glpi_python_client.clients.api``,
+#. Add the async mixin and method under ``glpi_python_client._async.clients.api``,
mirroring the OpenAPI path and HTTP verb.
#. When the live server diverges from the contract, document the choice in the
module docstring and (when needed) wire an unwrap helper from
- ``glpi_python_client.clients.commons``.
+ ``glpi_python_client._async.clients.commons``.
#. Re-export new public symbols from ``glpi_python_client.__init__``.
#. Add tests for payload serialization, response parsing, and client behaviour.
#. Document the workflow in :doc:`user_guide` and the matching skill in
diff --git a/docs/installation.rst b/docs/installation.rst
index 99ecec1..bb1acea 100644
--- a/docs/installation.rst
+++ b/docs/installation.rst
@@ -5,8 +5,8 @@ Requirements
------------
``glpi-python-client`` supports Python 3.10 and newer. Runtime dependencies are installed
-from the package metadata and include ``requests``, ``tenacity``,
-``beautifulsoup4``, ``lxml``, and ``urllib3``.
+from the package metadata and include ``httpx``, ``tenacity``,
+``beautifulsoup4``, ``lxml``, and ``pydantic``.
Install from PyPI
-----------------
diff --git a/docs/user_guide.rst b/docs/user_guide.rst
index 0e0bbd2..9a2fb9f 100644
--- a/docs/user_guide.rst
+++ b/docs/user_guide.rst
@@ -5,10 +5,12 @@ The ``glpi_python_client`` package exposes two high-level clients
whose surface is built from contract-aligned per-endpoint mixins:
* :class:`glpi_python_client.GlpiClient` — synchronous, blocking
- client. The single source of truth for endpoint behaviour.
-* :class:`glpi_python_client.AsyncGlpiClient` — asynchronous facade
- that wraps every synchronous method into a coroutine and dispatches
- it to a worker thread via :func:`asyncio.to_thread`.
+ client.
+* :class:`glpi_python_client.AsyncGlpiClient` — asynchronous client
+ doing real non-blocking I/O on the event loop.
+
+Neither is a wrapper around the other; both are the same code, as
+*Sync vs async surface* below explains.
Both clients speak the GLPI **v2** high-level API and fall back to the
legacy v1 API for features that are not exposed by v2, currently
@@ -29,8 +31,8 @@ The guide is split into the following sections:
1. **Creating a client** — how to instantiate either client from
explicit parameters or from environment variables.
-2. **Sync vs async surface** — when to pick which client and how the
- async facade is implemented.
+2. **Sync vs async surface** — when to pick which client and how both
+ are produced from a single source.
3. **Seed data for the examples** — a self-contained snippet that
creates the records reused by every later example. Run it once on a
throwaway GLPI instance to follow along.
@@ -119,10 +121,6 @@ Optional constructor arguments
fallback used by :meth:`GlpiClient.upload_document` and the
``Fields`` plugin helpers such as
:meth:`GlpiClient.get_ticket_custom_fields`.
-* ``executor`` (:class:`AsyncGlpiClient` only) — an explicit
- :class:`concurrent.futures.Executor` used to dispatch the wrapped
- synchronous calls. Defaults to the standard library thread pool
- through :func:`asyncio.to_thread`.
``from_env``
~~~~~~~~~~~~
@@ -164,62 +162,41 @@ When to pick which
event loop (for example a FastAPI or aiohttp service, an async CLI,
or a Jupyter notebook cell), or when you want concurrent fan-out.
-How the async client is implemented
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-The asynchronous surface is a thin facade over the synchronous
-endpoint mixins. The
-:class:`~glpi_python_client.clients.commons._async_bridge.AsyncBridge`
-base class walks the MRO of :class:`AsyncGlpiClient` at class-creation
-time and wraps every inherited public synchronous method into a
-coroutine wrapper that schedules the call on a worker thread:
-
-* by default through :func:`asyncio.to_thread`;
-* on a caller-supplied :class:`concurrent.futures.Executor` when one is
- passed to the constructor or to ``from_env``.
-
-Because the underlying HTTP layer is still backed by the blocking
-``requests`` library, every concurrent worker runs on a distinct
-thread. A shared :class:`threading.Lock` (not :class:`asyncio.Lock`)
-serialises OAuth token acquisition so concurrent ``asyncio.gather``
-fan-outs cannot race the auth manager, while the HTTP requests
-themselves execute outside the lock through the thread-safe
-:class:`requests.Session`.
-
-A number of helpers ship with hand-written async overrides rather than
-relying solely on the bridge. There are two reasons a method needs its
-own async variant:
-
-1. **Concurrency** — the method benefits from fanning multiple GLPI
- calls out concurrently with :func:`asyncio.gather`.
-2. **Internal self-calls** — the method calls another public method
- through ``self`` (e.g. ``self.search_tickets(...)`` inside a
- pagination loop). When the bridge runs the synchronous body in a
- worker thread, ``self.method`` resolves to the bridge-wrapped
- *coroutine*, which returns a coroutine object instead of data when
- called without ``await``. The async override replaces the body so
- every internal call is properly awaited on the event loop.
-
-Helpers with async overrides:
-
-* :meth:`AsyncGlpiClient.get_ticket_context` — fans the five underlying
- GLPI calls out concurrently (reason: concurrency).
-* :meth:`AsyncGlpiClient.get_task_statistics` — fans the per-ticket
- task-list calls out concurrently (reason: concurrency).
-* :meth:`AsyncGlpiClient.get_task_durations` — fans the per-ticket task
- fetches out concurrently when ``return_task_details=True``, and
- properly awaits ``iter_search_tickets`` and ``search_entities``
- internally (reasons: concurrency + internal self-calls).
-* :meth:`AsyncGlpiClient.get_ticket_statistics` — properly awaits
- ``search_tickets`` and ``search_entities`` internally (reason:
- internal self-calls).
-* :meth:`AsyncGlpiClient.get_user_activity` — properly awaits
- ``search_users``, ``iter_search_tickets``, and ``get_task_durations``
- internally (reason: internal self-calls).
-* ``iter_search_tickets``, ``iter_search_users``,
- ``iter_search_entities`` — each pagination loop body calls
- ``self.search_*(...)``; the async variants are native async generators
- that ``await`` those calls directly (reason: internal self-calls).
+How the two clients stay in step
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Both clients are the same code. The asynchronous tree is written by hand
+and the synchronous one is *generated* from it: a build step strips
+``async``/``await`` and renames the handful of tokens that differ between
+the two surfaces. The generated tree is committed, and CI regenerates it
+and fails on any difference, so the two cannot drift apart.
+
+This is why the endpoint surfaces are identical and why a fix never has
+to be applied twice. It also means neither client is a wrapper around the
+other: :class:`AsyncGlpiClient` performs real non-blocking I/O on the
+event loop, and :class:`GlpiClient` performs real blocking I/O with no
+thread pool, no executor, and no coroutine scheduling.
+
+Exactly one module is maintained separately for each surface, because the
+two need genuinely different primitives rather than differently-spelled
+ones:
+
+* **Fan-out.** Aggregating helpers such as
+ :meth:`AsyncGlpiClient.get_ticket_context` issue several GLPI calls
+ through a shared ``gather`` helper. On the async surface that is
+ :func:`asyncio.gather` and the calls overlap; on the sync surface the
+ arguments have already been evaluated by the time ``gather`` is
+ entered, so the same expression means "one after the other". The
+ calling code is identical.
+* **The auth lock.** :class:`AsyncGlpiClient` uses an
+ :class:`asyncio.Lock` and :class:`GlpiClient` a
+ :class:`threading.Lock`. Neither substitutes for the other. A
+ :class:`threading.Lock` on the event loop would be held across an
+ ``await``, so a second task waiting on it would block the loop and the
+ task holding it could never resume to release it. An
+ :class:`asyncio.Lock` in the sync client would bind itself to whichever
+ event loop first contended it, breaking the guarantee that one
+ :class:`GlpiClient` may be shared across threads.
Pagination helpers (``iter_search_tickets``, ``iter_search_users``,
``iter_search_entities``) are exposed as **async generators** on the
@@ -235,26 +212,34 @@ without blocking the event loop:
The synchronous versions of the same helpers issue the calls
sequentially.
-Custom thread pools
-~~~~~~~~~~~~~~~~~~~
+Bounding concurrency
+~~~~~~~~~~~~~~~~~~~~
-Applications that want to bound the worker pool size, name the worker
-threads, or share a pool with other components can pass an explicit
-executor:
+There is no thread pool to size and no ``executor`` argument: the async
+client issues real non-blocking requests, so concurrency is bounded by
+the underlying HTTP connection pool rather than by worker threads.
+
+To keep a large fan-out from overwhelming the GLPI server, bound it on
+your side with an :class:`asyncio.Semaphore`:
.. code-block:: python
import asyncio
- from concurrent.futures import ThreadPoolExecutor
from glpi_python_client import AsyncGlpiClient
async def main() -> None:
- with ThreadPoolExecutor(max_workers=8, thread_name_prefix="glpi") as pool:
- async with AsyncGlpiClient.from_env(executor=pool) as client:
- tickets = await client.search_tickets("status==1", limit=200)
- print(len(tickets))
+ limit = asyncio.Semaphore(8)
+
+ async with AsyncGlpiClient.from_env() as client:
+
+ async def fetch(ticket_id: int):
+ async with limit:
+ return await client.get_ticket(ticket_id)
+
+ tickets = await asyncio.gather(*(fetch(i) for i in range(1, 101)))
+ print(len(tickets))
asyncio.run(main())
@@ -748,8 +733,8 @@ internal container and field names:
client.set_ticket_custom_fields(
ticket_id,
{
- "aidelarsolution": {
- "aidelarsolutionfield": "
Handled by the NOC shift
",
+ "extrainfo": {
+ "extrainfofield": "Handled by the NOC shift
",
}
},
)
@@ -1420,23 +1405,25 @@ of the library surface:
except GlpiError as exc:
print(f"GLPI call failed: {exc}")
-This is not yet the client's entire failure surface. The client is still
-built on ``requests``, and network-level faults -- connection failures,
-DNS errors, timeouts -- still propagate as ``requests`` exceptions today
-rather than a :class:`~glpi_python_client.GlpiError` subclass. Catch
-``requests.RequestException`` alongside :class:`~glpi_python_client.GlpiError`
-if you need to handle those too:
+That single ``except`` clause covers network-level faults too.
+Connection failures, DNS errors and timeouts are translated into
+:class:`~glpi_python_client.GlpiTransportError` -- or its
+:class:`~glpi_python_client.GlpiTimeoutError` subclass for a timeout --
+so you never need to import the HTTP library to catch them. The original
+transport exception stays attached as ``__cause__`` for debugging, and
+these faults are retried three times before they surface:
.. code-block:: python
- import requests
- from glpi_python_client import GlpiClient, GlpiError
+ from glpi_python_client import GlpiClient, GlpiTimeoutError, GlpiTransportError
client = GlpiClient.from_env()
try:
ticket = client.get_ticket(42)
- except (GlpiError, requests.RequestException) as exc:
- print(f"GLPI call failed: {exc}")
+ except GlpiTimeoutError as exc:
+ print(f"GLPI was too slow: {exc} (cause: {exc.__cause__!r})")
+ except GlpiTransportError as exc:
+ print(f"GLPI was unreachable: {exc}")
A handful of sites also deliberately still raise bare ``RuntimeError``
(using a closed client, a missing v1 document session, a partially
@@ -1495,7 +1482,8 @@ a failed response. It logs a warning and falls through to a fresh token
acquisition, which carries its own independent 3-attempt retry
decorator. The refresh method's own retry decorator only retries a
network-level fault on the refresh request itself (a
-``requests.RequestException`` raised before any response is received) —
+:class:`~glpi_python_client.GlpiTransportError` raised before any
+response is received) —
it does **not** retry a :class:`~glpi_python_client.GlpiServerError`
from the fall-through, since that failure is already being retried by
the nested acquisition call. A persistent 5xx encountered while
diff --git a/glpi_python_client/__init__.py b/glpi_python_client/__init__.py
index d3c0b66..fd49b9c 100644
--- a/glpi_python_client/__init__.py
+++ b/glpi_python_client/__init__.py
@@ -2,10 +2,13 @@
The package re-exports two client classes:
-* :class:`GlpiClient` — synchronous, blocking client (single source of
- truth for endpoint behaviour).
-* :class:`AsyncGlpiClient` — asynchronous facade that wraps each
- synchronous method into a coroutine.
+* :class:`GlpiClient` — synchronous, blocking client.
+* :class:`AsyncGlpiClient` — asynchronous client doing real
+ non-blocking I/O on the event loop.
+
+They expose identical endpoint surfaces because they are the same
+code: the async tree is hand-written and the sync one is generated
+from it. Neither is a wrapper around the other.
The ``api_schema`` and ``custom_schema`` Pydantic models are also
re-exported so downstream users can import them from a single stable
@@ -14,6 +17,7 @@
from __future__ import annotations
+from glpi_python_client._async.clients import AsyncGlpiClient
from glpi_python_client._errors import (
GlpiAuthError,
GlpiError,
@@ -25,7 +29,7 @@
GlpiTransportError,
GlpiValidationError,
)
-from glpi_python_client.clients import AsyncGlpiClient, GlpiClient
+from glpi_python_client._sync.clients import GlpiClient
from glpi_python_client.models import (
DeleteDocument,
DeleteEntity,
diff --git a/glpi_python_client/_async/__init__.py b/glpi_python_client/_async/__init__.py
new file mode 100644
index 0000000..7fb9b80
--- /dev/null
+++ b/glpi_python_client/_async/__init__.py
@@ -0,0 +1,15 @@
+"""Hand-written client tree.
+
+This package is the single source of truth for client code. Its sibling
+``glpi_python_client._sync`` is generated from it by ``unasync_build.py``
+and checked in; CI regenerates and diffs to keep the two from drifting.
+
+Prose written here is copied verbatim into the generated tree -- the
+codegen rewrites tokens, not sentences. Docstrings under this package are
+therefore worded so they read correctly on both surfaces: describe *what* a
+helper does, and leave whether it blocks or awaits to the signature.
+"""
+
+from __future__ import annotations
+
+__all__: list[str] = []
diff --git a/glpi_python_client/_async/_concurrency.py b/glpi_python_client/_async/_concurrency.py
new file mode 100644
index 0000000..4b3bd32
--- /dev/null
+++ b/glpi_python_client/_async/_concurrency.py
@@ -0,0 +1,59 @@
+"""Asyncio concurrency primitives for the async client tree.
+
+This module and its ``_sync`` twin are the only files maintained by hand on
+both sides of the codegen. Everything else under ``_sync/`` is generated
+from ``_async/`` by :mod:`unasync`, which rewrites tokens and strips
+``async``/``await``. That works for syntax; it cannot work here, because the
+two surfaces need genuinely *different primitives*, not differently-spelled
+ones:
+
+* A fan-out is :func:`asyncio.gather` on this side and plain sequential
+ evaluation on the other. ``unasync`` leaves ``asyncio.gather`` untouched,
+ so a generated twin would call it from synchronous code and break.
+* The auth lock must be an :class:`asyncio.Lock` here and a
+ :class:`threading.Lock` there -- see :data:`Lock` for why substituting one
+ for the other is wrong in *both* directions.
+
+Keeping both twins tiny is deliberate: hand-maintained duplication is a
+liability, so it is confined to the smallest possible surface.
+"""
+
+from __future__ import annotations
+
+import asyncio
+from typing import Any
+
+#: Lock type guarding OAuth token acquisition on the async surface.
+#:
+#: An :class:`asyncio.Lock`, and the ``_sync`` twin uses a
+#: :class:`threading.Lock`. Neither choice is substitutable for the other,
+#: which is exactly why this is hand-written:
+#:
+#: * A :class:`threading.Lock` here would **deadlock**. The lock is held
+#: across an ``await``, so a second task blocking on it blocks the whole
+#: event loop -- and the task holding it can then never resume to release
+#: it. A single-threaded loop has no way out of that.
+#: * An :class:`asyncio.Lock` on the sync side would be worse than useless:
+#: it is bound to the loop that first contends it, so sharing one client
+#: across threads raises ``RuntimeError: ... bound to a different event
+#: loop`` and can deadlock a thread permanently. The failure is latent --
+#: ``acquire()`` only looks up the loop on the *contended* path, so
+#: uncontended use passes and tests stay green.
+Lock = asyncio.Lock
+
+
+async def gather(*awaitables: Any) -> list[Any]:
+ """Run ``awaitables`` concurrently and return their results in order.
+
+ The ``_sync`` twin takes already-computed values and simply returns
+ them. That is not a stub: once ``unasync`` strips the ``await`` from a
+ call site, each argument expression evaluates eagerly at the point it is
+ written, which *is* sequential execution. So the same call shape --
+ ``await gather(self.a(), self.b())`` -- means "concurrently" here and
+ "one after the other" there, with no change to the calling code.
+ """
+
+ return list(await asyncio.gather(*awaitables))
+
+
+__all__ = ["Lock", "gather"]
diff --git a/glpi_python_client/auth/__init__.py b/glpi_python_client/_async/auth/__init__.py
similarity index 56%
rename from glpi_python_client/auth/__init__.py
rename to glpi_python_client/_async/auth/__init__.py
index 98746fe..d4731b6 100644
--- a/glpi_python_client/auth/__init__.py
+++ b/glpi_python_client/_async/auth/__init__.py
@@ -1,13 +1,13 @@
"""Public authentication exports for the GLPI client package.
The authentication package owns the OAuth2 token manager used by the
-asynchronous ``GlpiClient`` and the legacy v1 session wrapper used solely by
+GLPI client and the legacy v1 session wrapper used solely by
the management document upload mixin.
"""
from __future__ import annotations
-from glpi_python_client.auth._v1_session import GLPIV1Session
-from glpi_python_client.auth.auth import GLPITokenManager
+from glpi_python_client._async.auth._v1_session import GLPIV1Session
+from glpi_python_client._async.auth.auth import GLPITokenManager
__all__ = ["GLPITokenManager", "GLPIV1Session"]
diff --git a/glpi_python_client/_async/auth/_v1_session.py b/glpi_python_client/_async/auth/_v1_session.py
new file mode 100644
index 0000000..a80f788
--- /dev/null
+++ b/glpi_python_client/_async/auth/_v1_session.py
@@ -0,0 +1,466 @@
+"""GLPI v1 REST session used for legacy endpoints not exposed by v2.
+
+Two consumers currently share this session:
+
+* the management :class:`DocumentMixin` for the multipart
+ ``POST /Document`` upload (the v2 API does not advertise a binary
+ upload route), and
+* the :class:`PluginFieldsMixin` for the GLPI "Fields" plugin endpoints
+ (``PluginFieldsContainer``, ``PluginFieldsField`` and the per-item
+ value itemtypes), which the v2 contract does not surface at all.
+
+The session wrapper owns the authenticated v1 lifecycle (init, refresh,
+kill) and exposes the typed ``upload_document`` helper plus the generic
+``request_json`` JSON-only HTTP helper that newer mixins build on.
+
+Retry policy
+------------
+Every public dispatch helper (``_init_session``, ``request_json``,
+``upload_document``) carries the same :mod:`tenacity` retry decorator
+used by the v2 transport: three attempts spaced by three seconds,
+triggered by :class:`~glpi_python_client.GlpiTransportError` (network
+faults) and
+:class:`~glpi_python_client.GlpiServerError` (which
+:func:`finalize_request_response` raises for 5xx server errors), with
+``reraise=True`` so the real error surfaces once retries are exhausted.
+Not every :class:`ValueError` subclass is retried, only the one named in
+the predicate above: :class:`~glpi_python_client.GlpiServerError` (5xx)
+*is* a ``ValueError`` and *is* retried by this decorator.
+:class:`~glpi_python_client.GlpiStatusError` subclasses for 4xx statuses,
+:class:`~glpi_python_client.GlpiValidationError`, and
+:class:`~glpi_python_client.GlpiProtocolError` are also ``ValueError``
+subclasses but are not in the retry predicate, so they surface
+immediately without a retry.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from datetime import datetime, timedelta, timezone
+from typing import Any, cast
+
+import httpx
+from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
+
+from glpi_python_client._async.clients.commons._config import build_http_session
+from glpi_python_client._async.clients.commons._http import (
+ ensure_response_status,
+ finalize_request_response,
+ response_json_or_empty,
+ transport_error_from,
+)
+from glpi_python_client._errors import (
+ GlpiProtocolError,
+ GlpiServerError,
+ GlpiTransportError,
+ GlpiValidationError,
+)
+
+logger = logging.getLogger(__name__)
+
+_DEFAULT_SESSION_REFRESH_INTERVAL_SECONDS = 15 * 60
+_AUTH_FAILURE_STATUS_CODES = frozenset({401, 403})
+#: Retry policy for the v1 session, expressed in library-owned types.
+#:
+#: Mirrors the v2 transport policy deliberately: naming the HTTP library's
+#: own exception base here would make the retries stop matching — silently —
+#: the next time the transport changes.
+_RETRY_ON_NETWORK_ERRORS = retry(
+ retry=retry_if_exception_type((GlpiTransportError, GlpiServerError)),
+ stop=stop_after_attempt(3),
+ wait=wait_fixed(3),
+ reraise=True,
+)
+
+
+class GLPIV1Session:
+ """Authenticated GLPI v1 REST session limited to document upload.
+
+ The session takes care of token initialisation, periodic refresh, retry on
+ auth failure, and best-effort cleanup. Only the upload endpoint is exposed
+ because the rest of the high-level client uses the v2 API exclusively.
+ """
+
+ def __init__(
+ self,
+ *,
+ base_url: str,
+ user_token: str,
+ app_token: str | None = None,
+ verify_ssl: bool = True,
+ session_refresh_interval_seconds: int = (
+ _DEFAULT_SESSION_REFRESH_INTERVAL_SECONDS
+ ),
+ ) -> None:
+ self._base_url = base_url.rstrip("/")
+ self._user_token = user_token
+ self._app_token = app_token
+ if session_refresh_interval_seconds < 1:
+ raise GlpiValidationError(
+ "session_refresh_interval_seconds must be a positive integer"
+ )
+ self._session_refresh_interval = timedelta(
+ seconds=session_refresh_interval_seconds
+ )
+
+ # Built through the shared factory so the SSL policy is applied at
+ # construction: httpx reads ``verify`` only in ``Client.__init__``
+ # and silently ignores a later assignment.
+ self._http = build_http_session(verify_ssl=verify_ssl)
+
+ self._session_token: str | None = None
+ self._session_started_at: datetime | None = None
+
+ async def _dispatch(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
+ """Send one raw v1 HTTP call, translating transport faults.
+
+ Every call this session makes goes through here so network failures
+ surface as :class:`~glpi_python_client.GlpiTransportError` rather than
+ as the HTTP library's own exception type. That is what lets the retry
+ predicate above name a library-owned type, and it keeps callers from
+ having to import the HTTP library to catch a connection failure.
+
+ Raises
+ ------
+ GlpiTransportError
+ When the request never produced a response.
+ """
+
+ try:
+ return await self._http.request(method.upper(), url, **kwargs)
+ except httpx.HTTPError as exc:
+ raise transport_error_from(exc, method=method, url=url) from exc
+
+ @_RETRY_ON_NETWORK_ERRORS
+ async def _init_session(self) -> None:
+ """Acquire one fresh GLPI v1 session token via ``GET /initSession``.
+
+ The call replaces any existing session state and stores the
+ authentication timestamp used by the refresh-interval check.
+ Network errors and 5xx responses are retried; 4xx and payload
+ errors propagate immediately as :class:`ValueError`.
+ """
+
+ headers: dict[str, str] = {
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+ "Authorization": f"user_token {self._user_token}",
+ }
+ if self._app_token:
+ headers["App-Token"] = self._app_token
+
+ url = f"{self._base_url}/initSession"
+ response = await self._dispatch("GET", url, headers=headers, timeout=30)
+ finalize_request_response(
+ response,
+ method="get",
+ url=url,
+ success_statuses=(200,),
+ logger=logger,
+ )
+ ensure_response_status(
+ response,
+ success_statuses=(200,),
+ failure_message="GLPI v1 initSession failed",
+ )
+
+ token = response.json().get("session_token")
+ if not token:
+ raise GlpiProtocolError("GLPI v1 initSession returned no session_token")
+
+ self._session_token = str(token)
+ self._session_started_at = datetime.now(tz=timezone.utc)
+ logger.info("GLPI v1 session initialised.")
+
+ async def _ensure_session(self) -> None:
+ """Lazily initialise or renew the v1 session when the token is stale.
+
+ The helper is called by every authenticated request before any header
+ construction so callers never have to manage the lifecycle directly.
+ """
+
+ if self._session_token is None:
+ await self._init_session()
+ return
+ if self._is_session_stale():
+ logger.info("GLPI v1 session reached refresh interval; renewing session.")
+ await self._renew_session()
+
+ def _is_session_stale(self) -> bool:
+ """Return whether the current v1 session token must be renewed.
+
+ Stale tokens are determined by the configured refresh interval relative
+ to the timestamp the current token was acquired.
+ """
+
+ if self._session_started_at is None:
+ return True
+ return datetime.now(tz=timezone.utc) >= (
+ self._session_started_at + self._session_refresh_interval
+ )
+
+ def _session_headers(self) -> dict[str, str]:
+ """Return the GLPI v1 headers carrying the current session token.
+
+ The helper assumes ``_ensure_session`` has already been called so the
+ session token state is valid.
+ """
+
+ headers: dict[str, str] = {
+ "Session-Token": str(self._session_token),
+ "Accept": "application/json",
+ }
+ if self._app_token:
+ headers["App-Token"] = self._app_token
+ return headers
+
+ async def _renew_session(self) -> None:
+ """Drop the current GLPI v1 session token and acquire a new one.
+
+ The previous token is best-effort killed so the GLPI server can release
+ the associated session state immediately. ``_init_session`` will set
+ the new token on success or raise, leaving the existing state
+ untouched on failure (the retry decorator handles transients).
+ """
+
+ if self._session_token is not None:
+ try:
+ await self._dispatch(
+ "GET",
+ f"{self._base_url}/killSession",
+ headers=self._session_headers(),
+ timeout=10,
+ )
+ except Exception:
+ logger.warning("Failed to kill stale GLPI v1 session.", exc_info=True)
+ await self._init_session()
+
+ async def _headers(self) -> dict[str, str]:
+ """Return ready-to-use authenticated GLPI v1 request headers.
+
+ The helper is the single header entry-point used by all authenticated
+ v1 calls so token lifecycle handling stays centralised.
+ """
+
+ await self._ensure_session()
+ return self._session_headers()
+
+ async def _authenticated_request(
+ self,
+ method: str,
+ url: str,
+ *,
+ success_statuses: tuple[int, ...],
+ headers: dict[str, str] | None = None,
+ **kwargs: Any,
+ ) -> httpx.Response:
+ """Send one authenticated GLPI v1 request and finalize the response.
+
+ When the GLPI server rejects the current token the helper renews
+ the session and retries the request once. The returned response
+ has already been passed through :func:`finalize_request_response`
+ so 5xx errors surface as
+ :class:`~glpi_python_client.GlpiServerError` for the outer
+ tenacity retry to catch; non-success statuses outside the
+ ``success_statuses`` set are logged but otherwise returned for
+ the caller to validate with :func:`ensure_response_status`.
+ """
+
+ request_headers = {**await self._headers(), **(headers or {})}
+ # Dispatch through ``request(method, ...)`` rather than looking up a
+ # per-verb attribute: it is the one call shape both transports share,
+ # and it keeps the verb a value instead of an attribute name.
+ verb = method.upper()
+ response = await self._dispatch(verb, url, headers=request_headers, **kwargs)
+ if _is_auth_failure_response(response):
+ logger.warning(
+ "GLPI v1 session token was rejected; refreshing session and "
+ "retrying request once."
+ )
+ await self._renew_session()
+ request_headers = {**await self._headers(), **(headers or {})}
+ response = await self._dispatch(
+ verb, url, headers=request_headers, **kwargs
+ )
+ return finalize_request_response(
+ response,
+ method=method,
+ url=url,
+ success_statuses=success_statuses,
+ logger=logger,
+ )
+
+ async def close(self) -> None:
+ """Kill the v1 session token and release the underlying HTTP session.
+
+ Cleanup is best-effort: any failure during ``killSession`` is logged
+ and the local session state is still cleared.
+ """
+
+ try:
+ if self._session_token is not None:
+ await self._dispatch(
+ "GET",
+ f"{self._base_url}/killSession",
+ headers=self._session_headers(),
+ timeout=10,
+ )
+ logger.info("GLPI v1 session killed.")
+ except Exception:
+ logger.warning("Failed to kill GLPI v1 session.", exc_info=True)
+ finally:
+ self._session_token = None
+ self._session_started_at = None
+ await self._http.aclose()
+
+ @_RETRY_ON_NETWORK_ERRORS
+ async def request_json(
+ self,
+ method: str,
+ path: str,
+ *,
+ params: dict[str, object] | None = None,
+ json_body: dict[str, object] | None = None,
+ success_statuses: tuple[int, ...] = (200, 201, 204, 206),
+ failure_message: str | None = None,
+ ) -> object:
+ """Send one JSON-only authenticated request to the GLPI v1 API.
+
+ The helper centralises session-token handling, the one-shot retry
+ on token rejection, status validation and JSON parsing so callers
+ can stay focused on their endpoint semantics. Network errors and
+ 5xx responses are retried; 4xx and payload errors propagate
+ immediately as :class:`ValueError`.
+
+ Parameters
+ ----------
+ method : str
+ HTTP verb (``"GET"``, ``"POST"``, ``"PUT"``, ``"DELETE"``).
+ path : str
+ Resource path appended to the v1 base URL (without leading
+ slash, e.g. ``"PluginFieldsContainer"``).
+ params : dict[str, object] | None, optional
+ Query-string parameters forwarded to the HTTP transport.
+ json_body : dict[str, object] | None, optional
+ JSON body serialised into the request when set. The
+ ``Content-Type: application/json`` header is added
+ automatically.
+ success_statuses : tuple[int, ...], optional
+ HTTP status codes considered successful (default covers the
+ CRUD codes returned by the v1 API).
+ failure_message : str | None, optional
+ Prefix used in the :class:`~glpi_python_client.GlpiStatusError`
+ raised on a non-success status. Defaults to ``"GLPI v1
+ {METHOD} {path} failed"``.
+
+ Returns
+ -------
+ object
+ Parsed JSON body for non-empty responses; an empty ``dict``
+ when the body is empty or contains only whitespace.
+
+ Raises
+ ------
+ GlpiStatusError
+ If the v1 server returns a non-success HTTP status outside
+ the 5xx range (narrows to :class:`~glpi_python_client.GlpiAuthError`
+ or :class:`~glpi_python_client.GlpiNotFoundError` where the
+ status allows it). Inherits from ``ValueError``.
+ GlpiServerError
+ If the v1 server persistently returns a 5xx status after this
+ decorator's retries are exhausted.
+ """
+
+ url = f"{self._base_url}/{path.lstrip('/')}"
+ kwargs: dict[str, Any] = {"timeout": 30}
+ if params is not None:
+ kwargs["params"] = params
+ headers: dict[str, str] = {}
+ if json_body is not None:
+ kwargs["content"] = json.dumps(json_body)
+ headers["Content-Type"] = "application/json"
+ response = await self._authenticated_request(
+ method,
+ url,
+ success_statuses=success_statuses,
+ headers=headers or None,
+ **kwargs,
+ )
+ ensure_response_status(
+ response,
+ success_statuses=success_statuses,
+ failure_message=failure_message
+ or f"GLPI v1 {method.upper()} {path} failed",
+ )
+ return response_json_or_empty(response)
+
+ @_RETRY_ON_NETWORK_ERRORS
+ async def upload_document(
+ self,
+ filename: str,
+ content: bytes,
+ mime_type: str,
+ *,
+ document_name: str | None = None,
+ ticket_id: int | None = None,
+ entity_id: int | None = None,
+ ) -> dict[str, object]:
+ """Upload one binary document via ``POST /Document``.
+
+ The legacy v1 endpoint uses a multipart upload manifest so the GLPI
+ server can create the document, link it to the optional parent ticket,
+ and assign it to the provided entity in a single round-trip. Network
+ errors and 5xx responses are retried; 4xx and payload errors
+ propagate immediately as :class:`ValueError`.
+ """
+
+ manifest_input: dict[str, object] = {
+ "name": document_name or filename,
+ "_filename": [filename],
+ }
+ if entity_id is not None:
+ manifest_input["entities_id"] = int(entity_id)
+ if ticket_id is not None:
+ manifest_input["itemtype"] = "Ticket"
+ manifest_input["items_id"] = int(ticket_id)
+ manifest_input["tickets_id"] = int(ticket_id)
+ manifest = json.dumps({"input": manifest_input})
+ response = await self._authenticated_request(
+ "POST",
+ f"{self._base_url}/Document",
+ success_statuses=(200, 201),
+ files=[
+ ("uploadManifest", (None, manifest, "application/json")),
+ ("filename[]", (filename, content, mime_type)),
+ ],
+ timeout=60,
+ )
+ ensure_response_status(
+ response,
+ success_statuses=(200, 201),
+ failure_message="GLPI v1 document upload failed",
+ )
+ payload = response.json()
+ if not isinstance(payload, dict):
+ raise GlpiProtocolError(
+ "GLPI v1 document upload returned unexpected payload: "
+ f"{type(payload).__name__}"
+ )
+ logger.info("GLPI v1 document uploaded: id=%s", payload.get("id"))
+ return cast(dict[str, object], payload)
+
+
+def _is_auth_failure_response(response: httpx.Response) -> bool:
+ """Return whether one GLPI v1 response means the session token is invalid.
+
+ Both HTTP-level rejection and the ``ERROR_SESSION_TOKEN_INVALID`` payload
+ marker emitted by the GLPI v1 API are considered auth failures.
+ """
+
+ if response.status_code in _AUTH_FAILURE_STATUS_CODES:
+ return True
+ return "ERROR_SESSION_TOKEN_INVALID" in str(response.text or "")
+
+
+__all__ = ["GLPIV1Session"]
diff --git a/glpi_python_client/_async/auth/auth.py b/glpi_python_client/_async/auth/auth.py
new file mode 100644
index 0000000..2ae6840
--- /dev/null
+++ b/glpi_python_client/_async/auth/auth.py
@@ -0,0 +1,416 @@
+"""OAuth2 token management for the high-level GLPI clients.
+
+The token manager centralizes credential validation, token acquisition,
+refresh behavior, and lifecycle cleanup so both sync and async clients can
+share the same authenticated session state.
+"""
+
+from __future__ import annotations
+
+import logging
+from datetime import datetime, timedelta, timezone
+
+import httpx
+from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
+
+from glpi_python_client._async.clients.commons._config import build_http_session
+from glpi_python_client._async.clients.commons._http import transport_error_from
+from glpi_python_client._errors import (
+ GlpiServerError,
+ GlpiTransportError,
+ GlpiValidationError,
+ status_error_class,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def validate_credentials(
+ *,
+ client_id: str | None,
+ client_secret: str | None,
+ username: str | None,
+ password: str | None,
+) -> None:
+ """Check that the supplied OAuth credential sets are complete.
+
+ GLPI authentication accepts client credentials, user credentials, or
+ both. Partial pairs are rejected here so the token request path does not
+ fail later with a less actionable error.
+
+ This is a free function rather than only a method so callers can check a
+ configuration *before* committing resources to it. That ordering matters:
+ the client used to build its HTTP session first and unwind it in an
+ ``except`` clause when validation failed, which is not expressible on the
+ async surface -- an ``httpx.AsyncClient`` has no synchronous close, and a
+ constructor cannot await one. Validating up front removes the need to
+ unwind anything.
+
+ Raises
+ ------
+ GlpiValidationError
+ If either pair is half-supplied, or if neither pair is supplied.
+ """
+
+ missing_client_fields = [
+ name
+ for name, value in {
+ "client_id": client_id,
+ "client_secret": client_secret,
+ }.items()
+ if value is None
+ ]
+ missing_user_fields = [
+ name
+ for name, value in {
+ "username": username,
+ "password": password,
+ }.items()
+ if value is None
+ ]
+
+ has_client_fields = len(missing_client_fields) < 2
+ has_user_fields = len(missing_user_fields) < 2
+
+ if has_client_fields and missing_client_fields:
+ raise GlpiValidationError(
+ "GLPI OAuth client credentials must include both client_id "
+ "and client_secret."
+ )
+ if has_user_fields and missing_user_fields:
+ raise GlpiValidationError(
+ "GLPI user credentials must include both username and password."
+ )
+ if not (client_id is not None and client_secret is not None) and not (
+ username is not None and password is not None
+ ):
+ raise GlpiValidationError(
+ "GLPI authentication requires either client_id/client_secret, "
+ "username/password, or both."
+ )
+
+
+class GLPITokenManager:
+ """OAuth2 token manager for the GLPI API.
+
+ Parameters
+ ----------
+ token_url : str
+ Full URL of the GLPI token endpoint.
+ client_id : str | None, optional
+ OAuth2 client ID. Provide it together with ``client_secret`` when the
+ GLPI instance requires client authentication.
+ client_secret : str | None, optional
+ OAuth2 client secret. Provide it together with ``client_id``.
+ username : str | None, optional
+ Username for the password grant flow. Provide it together with
+ ``password``.
+ password : str | None, optional
+ Password for the password grant flow. Provide it together with
+ ``username``.
+ session : httpx.AsyncClient | None, optional
+ Existing HTTP client to reuse.
+ auth_token_refresh : int | None, optional
+ Maximum token age in seconds before a refresh is attempted. ``None``
+ disables interval-based refreshes.
+ """
+
+ def __init__(
+ self,
+ token_url: str,
+ client_id: str | None = None,
+ client_secret: str | None = None,
+ username: str | None = None,
+ password: str | None = None,
+ session: httpx.AsyncClient | None = None,
+ auth_token_refresh: int | None = None,
+ ) -> None:
+ self._token_url = token_url
+ self._client_id = client_id
+ self._client_secret = client_secret
+ self._username = username
+ self._password = password
+ self._owns_session = session is None
+ self._session = session or build_http_session(verify_ssl=True)
+ self._auth_token_refresh_interval = _refresh_interval(auth_token_refresh)
+
+ self._validate_credentials()
+
+ self.access_token: str | None = None
+ self.refresh_token: str | None = None
+ self.token_expires_at: datetime | None = None
+ self.token_updated_at: datetime | None = None
+
+ async def _post_token_request(self, data: dict[str, str]) -> httpx.Response:
+ """POST the OAuth token endpoint, translating transport faults.
+
+ Network failures surface as
+ :class:`~glpi_python_client.GlpiTransportError` so the retry
+ predicates below can name a library-owned type and callers never have
+ to import the HTTP library to catch a connection failure.
+
+ Raises
+ ------
+ GlpiTransportError
+ When the token request never produced a response.
+ """
+
+ try:
+ return await self._session.post(self._token_url, data=data, timeout=30)
+ except httpx.HTTPError as exc:
+ raise transport_error_from(exc, method="post", url=self._token_url) from exc
+
+ @property
+ def auth_token_refresh(self) -> int | None:
+ """Return the proactive refresh delay configured for this manager.
+
+ The public property keeps the original integer value used at
+ construction time instead of exposing the internal ``timedelta``
+ representation.
+ """
+
+ if self._auth_token_refresh_interval is None:
+ return None
+ return int(self._auth_token_refresh_interval.total_seconds())
+
+ def _validate_credentials(self) -> None:
+ """Validate that the configured OAuth credential sets are complete."""
+
+ validate_credentials(
+ client_id=self._client_id,
+ client_secret=self._client_secret,
+ username=self._username,
+ password=self._password,
+ )
+
+ @property
+ def _has_client_credentials(self) -> bool:
+ return self._client_id is not None and self._client_secret is not None
+
+ @property
+ def _has_user_credentials(self) -> bool:
+ return self._username is not None and self._password is not None
+
+ def _build_token_request_data(self) -> dict[str, str]:
+ """Build the form payload sent to the OAuth token endpoint.
+
+ The payload shape depends on whether the manager is using the password
+ grant or pure client-credentials flow, and it includes client
+ credentials when that pair is configured.
+ """
+
+ data: dict[str, str] = {"scope": "api"}
+ if self._has_user_credentials:
+ assert self._username is not None
+ assert self._password is not None
+ data["grant_type"] = "password"
+ data["username"] = self._username
+ data["password"] = self._password
+ else:
+ data["grant_type"] = "client_credentials"
+
+ if self._has_client_credentials:
+ assert self._client_id is not None
+ assert self._client_secret is not None
+ data["client_id"] = self._client_id
+ data["client_secret"] = self._client_secret
+
+ return data
+
+ def _store_token_data(
+ self, token_data: dict[str, object], label: str = "acquired"
+ ) -> None:
+ """Store token data from an OAuth2 response.
+
+ Parameters
+ ----------
+ token_data : dict[str, object]
+ Token endpoint JSON response.
+ label : str, optional
+ Label for log messages.
+
+ Returns
+ -------
+ None
+ Mutates instance state.
+ """
+
+ self.access_token = str(token_data.get("access_token") or "") or None
+ refresh_token = str(token_data.get("refresh_token") or "").strip()
+ if refresh_token:
+ self.refresh_token = refresh_token
+ expires_in = int(str(token_data.get("expires_in") or 3600))
+ now = datetime.now(tz=timezone.utc)
+ self.token_updated_at = now
+ self.token_expires_at = now + timedelta(seconds=expires_in)
+ logger.info("GLPI OAuth token %s successfully.", label)
+
+ def logout(self) -> None:
+ """Discard the currently cached OAuth tokens.
+
+ Returns
+ -------
+ None
+ Clears in-memory token state.
+ """
+
+ self.access_token = None
+ self.refresh_token = None
+ self.token_expires_at = None
+ self.token_updated_at = None
+
+ async def close(self) -> None:
+ """Release token-manager resources.
+
+ Returns
+ -------
+ None
+ Performs a local logout and closes any owned HTTP session.
+ """
+
+ self.logout()
+ if self._owns_session:
+ await self._session.aclose()
+
+ def _should_refresh_by_interval(self, now: datetime) -> bool:
+ """Return whether the configured proactive refresh interval elapsed.
+
+ This check supplements token-expiry handling so long-lived clients can
+ refresh credentials before the server-side expiry timestamp is reached.
+ """
+
+ if self._auth_token_refresh_interval is None or self.token_updated_at is None:
+ return False
+ return now >= self.token_updated_at + self._auth_token_refresh_interval
+
+ @retry(
+ retry=retry_if_exception_type((GlpiTransportError, GlpiServerError)),
+ stop=stop_after_attempt(3),
+ wait=wait_fixed(3),
+ reraise=True,
+ )
+ async def _acquire_token(self) -> None:
+ """Acquire an OAuth2 access token using the configured auth flow.
+
+ Returns
+ -------
+ None
+ Stores the new access token.
+
+ Raises
+ ------
+ GlpiAuthError
+ If GLPI rejects the credentials (401/403). Not retried.
+ GlpiServerError
+ If the token endpoint fails (5xx). Retried up to 3 attempts.
+ GlpiStatusError
+ If the token endpoint returns any other unexpected status. Not
+ retried.
+ """
+
+ data = self._build_token_request_data()
+ response = await self._post_token_request(data)
+ if 200 <= response.status_code < 300:
+ self._store_token_data(response.json())
+ return
+ try:
+ error_detail = response.json()
+ except Exception:
+ error_detail = response.text
+ error_class = status_error_class(response.status_code)
+ raise error_class(
+ f"GLPI OAuth token returned {response.status_code}: {error_detail}",
+ status_code=response.status_code,
+ url=self._token_url,
+ response_text=str(error_detail),
+ )
+
+ @retry(
+ retry=retry_if_exception_type(GlpiTransportError),
+ stop=stop_after_attempt(3),
+ wait=wait_fixed(3),
+ reraise=True,
+ )
+ async def _refresh_access_token(self) -> None:
+ """Refresh the OAuth2 access token using the stored refresh token.
+
+ Returns
+ -------
+ None
+ Stores the refreshed token or acquires a new one.
+
+ Raises
+ ------
+ GlpiAuthError
+ If GLPI rejects the credentials while refreshing (401/403). This
+ method does not raise directly on a non-2xx response: it logs a
+ warning and falls through to a nested :meth:`_acquire_token`
+ call, which raises. That nested call is not retried by either
+ decorator, so a persistent 401 costs 1 refresh POST + 1 acquire
+ POST (2 total) before this propagates.
+ GlpiServerError
+ If the token endpoint fails (5xx) while refreshing. This
+ method's own retry decorator only matches
+ ``GlpiTransportError`` (network-level faults), not
+ ``GlpiServerError``, so it does not retry the fall-through to
+ :meth:`_acquire_token`. The nested call carries its own
+ independent decorator, which does retry ``GlpiServerError`` up
+ to 3 attempts. A persistent 5xx therefore costs exactly 1
+ refresh POST + 3 nested acquire POSTs = 4 POST requests, not the
+ 12 an earlier, less precise predicate produced by retrying the
+ already-retried nested failure a second time.
+ GlpiStatusError
+ If the token endpoint returns any other unexpected status while
+ refreshing, raised by the nested :meth:`_acquire_token` call.
+ Not retried.
+ """
+
+ if not self.refresh_token:
+ await self._acquire_token()
+ return
+
+ data = {
+ "grant_type": "refresh_token",
+ "refresh_token": self.refresh_token,
+ }
+ if self._has_client_credentials:
+ assert self._client_id is not None
+ assert self._client_secret is not None
+ data["client_id"] = self._client_id
+ data["client_secret"] = self._client_secret
+ response = await self._post_token_request(data)
+ if 200 <= response.status_code < 300:
+ self._store_token_data(response.json(), label="refreshed")
+ return
+ logger.warning("Token refresh failed, acquiring new token...")
+ await self._acquire_token()
+
+ async def ensure_token(self) -> None:
+ """Ensure a valid OAuth2 access token is available.
+
+ Returns
+ -------
+ None
+ Updates token state when needed.
+ """
+
+ if not self.access_token:
+ await self._acquire_token()
+ return
+
+ now = datetime.now(tz=timezone.utc)
+ token_expired = (
+ self.token_expires_at is not None and now >= self.token_expires_at
+ )
+ if token_expired or self._should_refresh_by_interval(now):
+ await self._refresh_access_token()
+
+
+def _refresh_interval(value: int | None) -> timedelta | None:
+ if value is None:
+ return None
+ if value < 1:
+ raise GlpiValidationError(
+ "auth_token_refresh must be a positive integer or None"
+ )
+ return timedelta(seconds=value)
diff --git a/glpi_python_client/_async/clients/__init__.py b/glpi_python_client/_async/clients/__init__.py
new file mode 100644
index 0000000..ada209a
--- /dev/null
+++ b/glpi_python_client/_async/clients/__init__.py
@@ -0,0 +1,17 @@
+"""Client class for one GLPI surface.
+
+The concrete client composes every per-endpoint mixin from
+:mod:`glpi_python_client._async.clients.api`, the aggregated helpers from
+:mod:`glpi_python_client._async.clients.custom`, and the transport mixin
+from :mod:`glpi_python_client._async.clients.commons`.
+
+Only one of the two client trees is written by hand; the other is
+generated from it. Both expose the same endpoint surface, so the choice
+between them is purely about the caller's runtime model.
+"""
+
+from __future__ import annotations
+
+from glpi_python_client._async.clients.client import AsyncGlpiClient
+
+__all__ = ["AsyncGlpiClient"]
diff --git a/glpi_python_client/clients/_base_client.py b/glpi_python_client/_async/clients/_base_client.py
similarity index 90%
rename from glpi_python_client/clients/_base_client.py
rename to glpi_python_client/_async/clients/_base_client.py
index 5febafb..90c9c40 100644
--- a/glpi_python_client/clients/_base_client.py
+++ b/glpi_python_client/_async/clients/_base_client.py
@@ -1,9 +1,9 @@
-"""Shared construction logic for the synchronous and asynchronous clients.
+"""Shared construction logic for the GLPI client.
The :class:`_BaseGlpiClient` mixin holds the constructor signature, the
resource-bundle assignment, and the :meth:`from_env` classmethod that
-both :class:`~glpi_python_client.clients.sync_client.GlpiClient` and
-:class:`~glpi_python_client.clients.async_client.AsyncGlpiClient` use.
+both :class:`~glpi_python_client.GlpiClient` and
+:class:`~glpi_python_client.AsyncGlpiClient` use.
Lifecycle helpers (``close``, ``__enter__``/``__exit__`` versus
``__aenter__``/``__aexit__``) stay on the concrete subclasses because
they differ between the sync and async surfaces.
@@ -14,7 +14,6 @@
import logging
import os
import sys
-import threading
from typing import TYPE_CHECKING
if sys.version_info >= (3, 11):
@@ -22,7 +21,8 @@
else: # pragma: no cover - fallback for Python 3.10
from typing_extensions import Self
-from glpi_python_client.clients.commons._config import (
+from glpi_python_client._async._concurrency import Lock
+from glpi_python_client._async.clients.commons._config import (
build_client_env_config,
build_client_resources,
)
@@ -37,8 +37,8 @@ class _BaseGlpiClient:
"""Shared construction helpers for the GLPI client variants.
The mixin assigns the resource bundle returned by
- :func:`build_client_resources` and the header/lock/state attributes
- used by :class:`~glpi_python_client.clients.commons._transport.TransportMixin`.
+ :func:`build_client_resources` and the header, lock and state
+ attributes the transport mixin declares.
"""
def __init__(
@@ -124,7 +124,7 @@ def __init__(
self.glpi_profile = glpi_profile
self.entity_recursive = entity_recursive
self.language = language
- self._auth_lock = threading.Lock()
+ self._auth_lock = Lock()
self._closed = False
@classmethod
@@ -152,9 +152,8 @@ def from_env(
prefix : str, optional
Common prefix shared by every environment variable name.
**overrides : object
- Keyword overrides forwarded to :meth:`__init__`. The
- asynchronous client accepts an additional ``executor``
- keyword here.
+ Keyword overrides forwarded to :meth:`__init__`; the
+ keyword overrides are forwarded verbatim.
Returns
-------
diff --git a/glpi_python_client/_async/clients/api/__init__.py b/glpi_python_client/_async/clients/api/__init__.py
new file mode 100644
index 0000000..7bd032e
--- /dev/null
+++ b/glpi_python_client/_async/clients/api/__init__.py
@@ -0,0 +1,54 @@
+"""Per-endpoint API mixins backed by the ``api_schema`` Pydantic models.
+
+The mixins under this package mirror the endpoints documented in
+``docs/glpi_api_contract.json`` one for one. They wrap the
+transport helpers from :mod:`glpi_python_client._async.clients.commons` and exchange
+typed ``Get``, ``Post``, ``Patch``, and ``Delete``
+models with the GLPI API.
+"""
+
+from __future__ import annotations
+
+from glpi_python_client._async.clients.api.administration import (
+ EntityMixin,
+ UserMixin,
+)
+from glpi_python_client._async.clients.api.assistance import (
+ TeamMemberMixin,
+ TicketMixin,
+)
+from glpi_python_client._async.clients.api.assistance.timeline import (
+ FollowupMixin,
+ SolutionMixin,
+ TicketTaskMixin,
+ TimelineDocumentMixin,
+)
+from glpi_python_client._async.clients.api.dropdowns import LocationMixin
+from glpi_python_client._async.clients.api.knowledgebase import (
+ KBArticleCommentMixin,
+ KBArticleMixin,
+ KBArticleRevisionMixin,
+ KBCategoryMixin,
+)
+from glpi_python_client._async.clients.api.management import DocumentMixin
+from glpi_python_client._async.clients.api.plugins import (
+ PluginFieldsMixin,
+)
+
+__all__ = [
+ "DocumentMixin",
+ "EntityMixin",
+ "FollowupMixin",
+ "KBArticleCommentMixin",
+ "KBArticleMixin",
+ "KBArticleRevisionMixin",
+ "KBCategoryMixin",
+ "LocationMixin",
+ "PluginFieldsMixin",
+ "SolutionMixin",
+ "TeamMemberMixin",
+ "TicketMixin",
+ "TicketTaskMixin",
+ "TimelineDocumentMixin",
+ "UserMixin",
+]
diff --git a/glpi_python_client/_async/clients/api/administration/__init__.py b/glpi_python_client/_async/clients/api/administration/__init__.py
new file mode 100644
index 0000000..c05991d
--- /dev/null
+++ b/glpi_python_client/_async/clients/api/administration/__init__.py
@@ -0,0 +1,13 @@
+"""GLPI ``/Administration`` mixins for the GLPI client.
+
+The submodules expose the user and entity mixins used by
+:class:`glpi_python_client.GlpiClient` and
+:class:`glpi_python_client.AsyncGlpiClient`.
+"""
+
+from __future__ import annotations
+
+from glpi_python_client._async.clients.api.administration._entity import EntityMixin
+from glpi_python_client._async.clients.api.administration._user import UserMixin
+
+__all__ = ["EntityMixin", "UserMixin"]
diff --git a/glpi_python_client/_async/clients/api/administration/_entity.py b/glpi_python_client/_async/clients/api/administration/_entity.py
new file mode 100644
index 0000000..0a4242e
--- /dev/null
+++ b/glpi_python_client/_async/clients/api/administration/_entity.py
@@ -0,0 +1,217 @@
+"""GLPI ``/Administration/Entity`` mixin.
+
+The mixin exposes the search, fetch, create, update, and delete helpers
+for the GLPI entity resource. Entity calls intentionally bypass the
+client's ``GLPI-Entity`` header so cross-entity lookups remain possible.
+"""
+
+from __future__ import annotations
+
+from collections.abc import AsyncIterator
+
+from glpi_python_client._async.clients.commons._constants import ENTITY_ENDPOINT, GlpiId
+from glpi_python_client._async.clients.commons._transport import TransportMixin
+from glpi_python_client.models.api_schema.administration._entity import (
+ DeleteEntity,
+ GetEntity,
+ PatchEntity,
+ PostEntity,
+)
+
+
+class EntityMixin(TransportMixin):
+ """CRUD helpers for ``/Administration/Entity``."""
+
+ async def search_entities(
+ self,
+ rsql_filter: str = "",
+ *,
+ limit: int | None = 50,
+ start: int = 0,
+ ) -> list[GetEntity]:
+ """Search GLPI entities with an optional RSQL filter.
+
+ Parameters
+ ----------
+ rsql_filter : str, optional
+ Raw RSQL filter forwarded as the ``filter`` query parameter.
+ limit : int | None, optional
+ Maximum number of records returned. ``None`` lets the GLPI
+ server use its default.
+ start : int, optional
+ Zero-based offset of the first record returned.
+
+ Returns
+ -------
+ list[GetEntity]
+ Entities matching the filter.
+ """
+
+ params: dict[str, object] = {"start": start}
+ if limit is not None:
+ params["limit"] = limit
+ if rsql_filter:
+ params["filter"] = rsql_filter
+ return await self._resource_list(
+ ENTITY_ENDPOINT, GetEntity, params=params, skip_entity=True
+ )
+
+ async def iter_search_entities(
+ self,
+ rsql_filter: str = "",
+ *,
+ batch_size: int = 50,
+ ) -> AsyncIterator[list[GetEntity]]:
+ """Yield successive pages of GLPI entities 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.
+ Entity calls bypass the ``GLPI-Entity`` header so cross-entity
+ lookups remain possible.
+
+ Parameters
+ ----------
+ rsql_filter : str, optional
+ Raw RSQL filter forwarded as the ``filter`` query parameter.
+ Empty by default, which lists every accessible entity.
+ batch_size : int, optional
+ Number of records requested per page (default 50).
+
+ Yields
+ ------
+ list[GetEntity]
+ One page of entities per iteration. The last yielded batch may
+ be shorter than ``batch_size``.
+ """
+
+ start = 0
+ while True:
+ batch = await self.search_entities(
+ rsql_filter,
+ limit=batch_size,
+ start=start,
+ )
+ if batch:
+ yield batch
+ if len(batch) < batch_size:
+ break
+ start += batch_size
+
+ async def get_entity(self, entity_id: GlpiId) -> GetEntity:
+ """Fetch one GLPI entity by identifier.
+
+ Parameters
+ ----------
+ entity_id : GlpiId
+ Numeric identifier of the entity to retrieve.
+
+ Returns
+ -------
+ GetEntity
+ Validated entity payload.
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ """
+
+ return await self._resource_get(
+ f"{ENTITY_ENDPOINT}/{entity_id}",
+ GetEntity,
+ failure_message=f"Failed to get entity {entity_id}",
+ skip_entity=True,
+ )
+
+ async def create_entity(self, entity: PostEntity) -> int:
+ """Create one GLPI entity.
+
+ Parameters
+ ----------
+ entity : PostEntity
+ Request body describing the entity to create.
+
+ Returns
+ -------
+ int
+ Identifier assigned by the GLPI server.
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ GlpiProtocolError
+ If the create response is missing the ``id`` field.
+ """
+
+ return await self._resource_create(
+ ENTITY_ENDPOINT,
+ entity,
+ failure_message="Failed to create entity",
+ missing_message="GLPI entity create response did not include an ID",
+ log_message_factory=lambda new_id: f"GLPI API created entity {new_id}",
+ skip_entity=True,
+ )
+
+ async def update_entity(self, entity_id: GlpiId, entity: PatchEntity) -> None:
+ """Update one GLPI entity with a partial body.
+
+ Parameters
+ ----------
+ entity_id : GlpiId
+ Numeric identifier of the entity to update.
+ entity : PatchEntity
+ Partial request body.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ """
+
+ await self._resource_update(
+ f"{ENTITY_ENDPOINT}/{entity_id}",
+ entity,
+ failure_message=f"Failed to update entity {entity_id}",
+ log_message=f"GLPI API updated entity {entity_id}",
+ )
+
+ async def delete_entity(
+ self, entity_id: GlpiId, *, force: bool | None = None
+ ) -> None:
+ """Delete one GLPI entity by identifier.
+
+ Parameters
+ ----------
+ entity_id : GlpiId
+ Numeric identifier of the entity to delete.
+ force : bool | None, optional
+ When ``True`` the entity is permanently deleted instead of
+ being moved to the trash.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ """
+
+ await self._resource_delete(
+ f"{ENTITY_ENDPOINT}/{entity_id}",
+ failure_message=f"Failed to delete entity {entity_id}",
+ log_message=f"GLPI API deleted entity {entity_id}",
+ force=force,
+ delete_model_cls=DeleteEntity,
+ skip_entity=True,
+ )
+
+
+__all__ = ["EntityMixin"]
diff --git a/glpi_python_client/_async/clients/api/administration/_user.py b/glpi_python_client/_async/clients/api/administration/_user.py
new file mode 100644
index 0000000..57e6fbc
--- /dev/null
+++ b/glpi_python_client/_async/clients/api/administration/_user.py
@@ -0,0 +1,224 @@
+"""GLPI ``/Administration/User`` mixin.
+
+The mixin exposes search, fetch, create, update, and delete helpers for the
+GLPI user resource. All operations exchange the
+:mod:`glpi_python_client.models.api_schema.administration` models and rely on
+the transport mixin for HTTP dispatch.
+"""
+
+from __future__ import annotations
+
+from collections.abc import AsyncIterator
+
+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.models.api_schema.administration._user import (
+ DeleteUser,
+ GetUser,
+ PatchUser,
+ PostUser,
+)
+
+
+class UserMixin(TransportMixin):
+ """CRUD helpers for ``/Administration/User``.
+
+ The helpers follow the contract-first naming convention and forward all
+ server-side validation to the GLPI API instead of duplicating checks on
+ the client side.
+ """
+
+ async def search_users(
+ self,
+ rsql_filter: str = "",
+ *,
+ limit: int = 50,
+ start: int = 0,
+ skip_entity: bool = False,
+ ) -> list[GetUser]:
+ """Search GLPI users with an optional RSQL filter.
+
+ Parameters
+ ----------
+ rsql_filter : str, optional
+ Raw RSQL filter forwarded as the ``filter`` query parameter,
+ for example ``"username==alice"``. Empty by default.
+ limit : int, optional
+ Maximum number of records returned by the GLPI server.
+ start : int, optional
+ Zero-based offset of the first record returned.
+ skip_entity : bool, optional
+ When ``True`` the ``GLPI-Entity`` header is omitted so the
+ search spans every entity the caller has access to.
+
+ Returns
+ -------
+ list[GetUser]
+ Users matching the filter, validated against the contract
+ ``User`` schema.
+ """
+
+ params: dict[str, object] = {"limit": limit, "start": start}
+ if rsql_filter:
+ params["filter"] = rsql_filter
+ return await self._resource_list(
+ USER_ENDPOINT, GetUser, params=params, skip_entity=skip_entity
+ )
+
+ async def iter_search_users(
+ self,
+ rsql_filter: str = "",
+ *,
+ batch_size: int = 50,
+ skip_entity: bool = False,
+ ) -> AsyncIterator[list[GetUser]]:
+ """Yield successive pages of GLPI users 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 user.
+ batch_size : int, optional
+ Number of records requested per page (default 50).
+ skip_entity : bool, optional
+ When ``True`` the ``GLPI-Entity`` header is omitted so the
+ search spans every entity the caller has access to.
+
+ Yields
+ ------
+ list[GetUser]
+ One page of users per iteration. The last yielded batch may
+ be shorter than ``batch_size``.
+ """
+
+ start = 0
+ while True:
+ batch = await self.search_users(
+ rsql_filter,
+ limit=batch_size,
+ start=start,
+ skip_entity=skip_entity,
+ )
+ if batch:
+ yield batch
+ if len(batch) < batch_size:
+ break
+ start += batch_size
+
+ async def get_user(self, user_id: GlpiId) -> GetUser:
+ """Fetch one GLPI user by identifier.
+
+ Parameters
+ ----------
+ user_id : GlpiId
+ Numeric identifier of the user to retrieve.
+
+ Returns
+ -------
+ GetUser
+ Validated user payload.
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ """
+
+ return await self._resource_get(
+ f"{USER_ENDPOINT}/{user_id}",
+ GetUser,
+ failure_message=f"Failed to get user {user_id}",
+ )
+
+ async def create_user(self, user: PostUser) -> int:
+ """Create one GLPI user.
+
+ Parameters
+ ----------
+ user : PostUser
+ Request body describing the user to create.
+
+ Returns
+ -------
+ int
+ Identifier assigned by the GLPI server.
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ GlpiProtocolError
+ If the create response is missing the ``id`` field.
+ """
+
+ return await self._resource_create(
+ USER_ENDPOINT,
+ user,
+ failure_message="Failed to create user",
+ missing_message="GLPI user create response did not include an ID",
+ log_message_factory=lambda new_id: f"GLPI API created user {new_id}",
+ )
+
+ async def update_user(self, user_id: GlpiId, user: PatchUser) -> None:
+ """Update one GLPI user with a partial body.
+
+ Parameters
+ ----------
+ user_id : GlpiId
+ Numeric identifier of the user to update.
+ user : PatchUser
+ Partial request body. Only fields explicitly set are sent.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ """
+
+ await self._resource_update(
+ f"{USER_ENDPOINT}/{user_id}",
+ user,
+ failure_message=f"Failed to update user {user_id}",
+ log_message=f"GLPI API updated user {user_id}",
+ )
+
+ async def delete_user(self, user_id: GlpiId, *, force: bool | None = None) -> None:
+ """Delete one GLPI user by identifier.
+
+ Parameters
+ ----------
+ user_id : GlpiId
+ Numeric identifier of the user to delete.
+ force : bool | None, optional
+ When ``True`` the user is permanently deleted instead of
+ being moved to the trash. ``None`` omits the query parameter.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ """
+
+ await self._resource_delete(
+ f"{USER_ENDPOINT}/{user_id}",
+ failure_message=f"Failed to delete user {user_id}",
+ log_message=f"GLPI API deleted user {user_id}",
+ force=force,
+ delete_model_cls=DeleteUser,
+ )
+
+
+__all__ = ["UserMixin"]
diff --git a/glpi_python_client/_async/clients/api/assistance/__init__.py b/glpi_python_client/_async/clients/api/assistance/__init__.py
new file mode 100644
index 0000000..f7dfdea
--- /dev/null
+++ b/glpi_python_client/_async/clients/api/assistance/__init__.py
@@ -0,0 +1,8 @@
+"""GLPI ``/Assistance`` mixins for the GLPI client."""
+
+from __future__ import annotations
+
+from glpi_python_client._async.clients.api.assistance._team import TeamMemberMixin
+from glpi_python_client._async.clients.api.assistance._ticket import TicketMixin
+
+__all__ = ["TeamMemberMixin", "TicketMixin"]
diff --git a/glpi_python_client/_async/clients/api/assistance/_team.py b/glpi_python_client/_async/clients/api/assistance/_team.py
new file mode 100644
index 0000000..1d52375
--- /dev/null
+++ b/glpi_python_client/_async/clients/api/assistance/_team.py
@@ -0,0 +1,121 @@
+"""GLPI ``/Assistance/Ticket/{id}/TeamMember`` mixin.
+
+The team-member endpoint exposes list, add, and remove operations on a
+ticket. The mixin uses the ``api_schema`` ``TeamMember`` models and lets
+the GLPI API perform server-side validation of role and itemtype values.
+"""
+
+from __future__ import annotations
+
+import logging
+
+from glpi_python_client._async.clients.commons._constants import (
+ TEAM_MEMBER_SUFFIX,
+ TICKET_ENDPOINT,
+ 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,
+ PostTeamMember,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class TeamMemberMixin(TransportMixin):
+ """Helpers for the ticket team-member endpoint."""
+
+ async def list_ticket_team_members(self, ticket_id: GlpiId) -> list[GetTeamMember]:
+ """List the team members currently linked to one ticket.
+
+ Parameters
+ ----------
+ ticket_id : GlpiId
+ Numeric identifier of the ticket whose team members are listed.
+
+ Returns
+ -------
+ list[GetTeamMember]
+ Team members validated against the contract ``TeamMember`` schema.
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ """
+
+ return await self._resource_list(
+ f"{TICKET_ENDPOINT}/{ticket_id}/{TEAM_MEMBER_SUFFIX}",
+ GetTeamMember,
+ failure_message=f"Failed to list ticket team members for {ticket_id}",
+ )
+
+ async def add_ticket_team_member(
+ self, ticket_id: GlpiId, member: PostTeamMember
+ ) -> None:
+ """Add one team member to a ticket.
+
+ Parameters
+ ----------
+ ticket_id : GlpiId
+ Numeric identifier of the ticket to receive the new member.
+ member : PostTeamMember
+ Request body describing the member. The ``id`` field is the
+ target user/group/supplier identifier, ``type`` is one of
+ ``"User"``, ``"Group"`` or ``"Supplier"``, and ``role`` is one
+ of ``"requester"``, ``"assigned"`` or ``"observer"``.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ """
+
+ endpoint = f"{TICKET_ENDPOINT}/{ticket_id}/{TEAM_MEMBER_SUFFIX}"
+ response = await self._post_request(endpoint, model_to_payload(member))
+ ensure_response_status(
+ response,
+ success_statuses=(200, 201),
+ failure_message=f"Failed to add team member on ticket {ticket_id}",
+ )
+ logger.info("GLPI API added team member on ticket %s", ticket_id)
+
+ async def remove_ticket_team_member(
+ self, ticket_id: GlpiId, member: PostTeamMember
+ ) -> None:
+ """Remove one team member from a ticket.
+
+ Parameters
+ ----------
+ ticket_id : GlpiId
+ Numeric identifier of the ticket the member belongs to.
+ member : PostTeamMember
+ Request body identifying the member to remove (same shape as
+ :meth:`add_ticket_team_member`).
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ """
+
+ await self._resource_delete(
+ 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),
+ )
+
+
+__all__ = ["TeamMemberMixin"]
diff --git a/glpi_python_client/_async/clients/api/assistance/_ticket.py b/glpi_python_client/_async/clients/api/assistance/_ticket.py
new file mode 100644
index 0000000..e080333
--- /dev/null
+++ b/glpi_python_client/_async/clients/api/assistance/_ticket.py
@@ -0,0 +1,238 @@
+"""GLPI ``/Assistance/Ticket`` mixin.
+
+The mixin exposes search, fetch, create, update, and delete helpers for the
+GLPI ticket resource using the ``api_schema`` Pydantic models.
+"""
+
+from __future__ import annotations
+
+from collections.abc import AsyncIterator
+
+from glpi_python_client._async.clients.commons._constants import TICKET_ENDPOINT, GlpiId
+from glpi_python_client._async.clients.commons._transport import TransportMixin
+from glpi_python_client.models.api_schema.assistance._ticket import (
+ DeleteTicket,
+ GetTicket,
+ PatchTicket,
+ PostTicket,
+)
+
+
+class TicketMixin(TransportMixin):
+ """CRUD helpers for ``/Assistance/Ticket``.
+
+ The helpers exchange the contract-aligned ``GetTicket``, ``PostTicket``,
+ ``PatchTicket``, and ``DeleteTicket`` models with the GLPI API and let the
+ server perform all field-level validation.
+ """
+
+ async def search_tickets(
+ self,
+ rsql_filter: str = "",
+ *,
+ limit: int = 50,
+ start: int = 0,
+ sort: str | None = None,
+ fields: tuple[str, ...] = (),
+ ) -> list[GetTicket]:
+ """Search GLPI tickets with an optional RSQL filter.
+
+ Parameters
+ ----------
+ rsql_filter : str, optional
+ Raw RSQL filter forwarded as the ``filter`` query parameter
+ (for example ``"name==hello"`` or ``"status==2"``). Empty by
+ default, which lists every visible ticket.
+ limit : int, optional
+ Maximum number of records returned by the GLPI server in one
+ request.
+ 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"``.
+ fields : tuple[str, ...], optional
+ Restricted set of contract field names to request. Empty
+ tuple lets the GLPI server pick its default field set.
+
+ Returns
+ -------
+ list[GetTicket]
+ Tickets matching the filter, validated against the contract
+ ``Ticket`` schema.
+ """
+
+ params: dict[str, object] = {"limit": limit, "start": start}
+ if rsql_filter:
+ params["filter"] = rsql_filter
+ if sort:
+ params["sort"] = sort
+ if fields:
+ params["fields"] = ",".join(fields)
+ return await self._resource_list(TICKET_ENDPOINT, GetTicket, params=params)
+
+ async def iter_search_tickets(
+ self,
+ rsql_filter: str = "",
+ *,
+ batch_size: int = 50,
+ sort: str | None = None,
+ fields: tuple[str, ...] = (),
+ ) -> AsyncIterator[list[GetTicket]]:
+ """Yield successive pages of GLPI tickets 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 ticket.
+ batch_size : int, optional
+ Number of records requested per page (default 50). Acts as
+ the ``limit`` parameter on each underlying
+ :meth:`search_tickets` call.
+ sort : str | None, optional
+ ``sort`` query parameter forwarded as-is to each page request.
+ fields : tuple[str, ...], optional
+ Restricted set of contract field names to request.
+
+ Yields
+ ------
+ list[GetTicket]
+ One page of tickets per iteration. The last yielded batch may
+ be shorter than ``batch_size``.
+ """
+
+ start = 0
+ while True:
+ batch = await self.search_tickets(
+ rsql_filter,
+ limit=batch_size,
+ start=start,
+ sort=sort,
+ fields=fields,
+ )
+ if batch:
+ yield batch
+ if len(batch) < batch_size:
+ break
+ start += batch_size
+
+ async def get_ticket(self, ticket_id: GlpiId) -> GetTicket:
+ """Fetch one GLPI ticket by identifier.
+
+ Parameters
+ ----------
+ ticket_id : GlpiId
+ Numeric identifier of the ticket to retrieve.
+
+ Returns
+ -------
+ GetTicket
+ Validated ticket payload.
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ """
+
+ return await self._resource_get(
+ f"{TICKET_ENDPOINT}/{ticket_id}",
+ GetTicket,
+ failure_message=f"Failed to get ticket {ticket_id}",
+ )
+
+ async def create_ticket(self, ticket: PostTicket) -> int:
+ """Create one GLPI ticket.
+
+ Parameters
+ ----------
+ ticket : PostTicket
+ Request body describing the ticket to create. Any extra
+ fields are forwarded verbatim through ``extra_payload``.
+
+ Returns
+ -------
+ int
+ Identifier assigned by the GLPI server to the new ticket.
+
+ Raises
+ ------
+ GlpiStatusError
+ If the HTTP status is not success.
+ GlpiProtocolError
+ If the create response is missing the ``id`` field.
+ """
+
+ return await self._resource_create(
+ TICKET_ENDPOINT,
+ ticket,
+ failure_message="Failed to create ticket",
+ missing_message="GLPI ticket create response did not include an ID",
+ log_message_factory=lambda new_id: f"GLPI API created ticket {new_id}",
+ )
+
+ async def update_ticket(self, ticket_id: GlpiId, ticket: PatchTicket) -> None:
+ """Update one GLPI ticket with a partial body.
+
+ Parameters
+ ----------
+ ticket_id : GlpiId
+ Numeric identifier of the ticket to update.
+ ticket : PatchTicket
+ Partial request body. Only fields explicitly set are sent.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ """
+
+ await self._resource_update(
+ f"{TICKET_ENDPOINT}/{ticket_id}",
+ ticket,
+ failure_message=f"Failed to update ticket {ticket_id}",
+ log_message=f"GLPI API updated ticket {ticket_id}",
+ )
+
+ async def delete_ticket(
+ self, ticket_id: GlpiId, *, force: bool | None = None
+ ) -> None:
+ """Delete one GLPI ticket by identifier.
+
+ Parameters
+ ----------
+ ticket_id : GlpiId
+ Numeric identifier of the ticket to delete.
+ force : bool | None, optional
+ When ``True`` the GLPI server permanently removes the ticket
+ instead of moving it to the trash. ``None`` omits the query
+ parameter and uses the GLPI default behaviour.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ """
+
+ await self._resource_delete(
+ f"{TICKET_ENDPOINT}/{ticket_id}",
+ failure_message=f"Failed to delete ticket {ticket_id}",
+ log_message=f"GLPI API deleted ticket {ticket_id}",
+ force=force,
+ delete_model_cls=DeleteTicket,
+ )
+
+
+__all__ = ["TicketMixin"]
diff --git a/glpi_python_client/_async/clients/api/assistance/timeline/__init__.py b/glpi_python_client/_async/clients/api/assistance/timeline/__init__.py
new file mode 100644
index 0000000..b518046
--- /dev/null
+++ b/glpi_python_client/_async/clients/api/assistance/timeline/__init__.py
@@ -0,0 +1,23 @@
+"""GLPI ticket-timeline mixins for the GLPI client."""
+
+from __future__ import annotations
+
+from glpi_python_client._async.clients.api.assistance.timeline._document import (
+ TimelineDocumentMixin,
+)
+from glpi_python_client._async.clients.api.assistance.timeline._followup import (
+ FollowupMixin,
+)
+from glpi_python_client._async.clients.api.assistance.timeline._solution import (
+ SolutionMixin,
+)
+from glpi_python_client._async.clients.api.assistance.timeline._task import (
+ TicketTaskMixin,
+)
+
+__all__ = [
+ "FollowupMixin",
+ "SolutionMixin",
+ "TicketTaskMixin",
+ "TimelineDocumentMixin",
+]
diff --git a/glpi_python_client/_async/clients/api/assistance/timeline/_document.py b/glpi_python_client/_async/clients/api/assistance/timeline/_document.py
new file mode 100644
index 0000000..d832174
--- /dev/null
+++ b/glpi_python_client/_async/clients/api/assistance/timeline/_document.py
@@ -0,0 +1,225 @@
+"""GLPI ``/Assistance/Ticket/{id}/Timeline/Document`` mixin.
+
+The mixin exposes list, fetch, link, and unlink helpers for the timeline
+document endpoint that links existing GLPI documents to a ticket.
+
+Notes
+-----
+The live GLPI v2 server returns each entry of the list endpoint wrapped
+in a ``{"type": "Document_Item", "item": {...}}`` envelope, even though
+the OpenAPI contract documents a flat array of ``Document_Item``. The
+``item`` value is a full ``Document`` record (matching :class:`GetDocument`),
+not a ``Document_Item`` link record — real behaviour wins over the contract.
+:func:`list_ticket_timeline_documents` unwraps the envelope through the shared
+``TransportMixin._resource_list`` helper and deserialises each inner object
+as :class:`GetDocument`.
+"""
+
+from __future__ import annotations
+
+from glpi_python_client._async.clients.commons._constants import (
+ TICKET_ENDPOINT,
+ TIMELINE_DOCUMENT_SUFFIX,
+ GlpiId,
+)
+from glpi_python_client._async.clients.commons._transport import TransportMixin
+from glpi_python_client.models.api_schema.assistance.timeline._document import (
+ DeleteTimelineDocument,
+ PatchTimelineDocument,
+ PostTimelineDocument,
+)
+from glpi_python_client.models.api_schema.management._document import GetDocument
+
+
+class TimelineDocumentMixin(TransportMixin):
+ """CRUD helpers for the ticket document timeline endpoint."""
+
+ async def list_ticket_timeline_documents(
+ self, ticket_id: GlpiId
+ ) -> list[GetDocument]:
+ """List all documents linked to one ticket timeline.
+
+ Parameters
+ ----------
+ ticket_id : GlpiId
+ Numeric identifier of the parent ticket.
+
+ Returns
+ -------
+ list[GetDocument]
+ Document records returned by the GLPI server. The live API
+ wraps each entry in a ``{"type": "Document_Item", "item": {...}}``
+ envelope whose ``item`` value is a full ``Document`` record; the
+ envelope is unwrapped automatically.
+ """
+
+ return await self._resource_list(
+ f"{TICKET_ENDPOINT}/{ticket_id}/{TIMELINE_DOCUMENT_SUFFIX}",
+ GetDocument,
+ failure_message=(
+ f"Failed to list timeline documents for ticket {ticket_id}"
+ ),
+ unwrap_envelope=True,
+ )
+
+ async def get_ticket_timeline_document(
+ self, ticket_id: GlpiId, document_link_id: GlpiId
+ ) -> GetDocument:
+ """Fetch one document linked to the ticket timeline by its document ID.
+
+ Parameters
+ ----------
+ ticket_id : GlpiId
+ Numeric identifier of the parent ticket.
+ document_link_id : GlpiId
+ Numeric identifier of the linked document to retrieve.
+
+ Returns
+ -------
+ GetDocument
+ Validated document payload.
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ """
+
+ return await self._resource_get(
+ f"{TICKET_ENDPOINT}/{ticket_id}/"
+ f"{TIMELINE_DOCUMENT_SUFFIX}/{document_link_id}",
+ GetDocument,
+ failure_message=(
+ f"Failed to get timeline document {document_link_id} on "
+ f"ticket {ticket_id}"
+ ),
+ )
+
+ async def link_ticket_timeline_document(
+ self, ticket_id: GlpiId, document_link: PostTimelineDocument
+ ) -> int:
+ """Link an existing GLPI document to one ticket timeline.
+
+ Parameters
+ ----------
+ ticket_id : GlpiId
+ Numeric identifier of the parent ticket.
+ document_link : PostTimelineDocument
+ Request body describing the link (typically the timeline
+ position; document and ticket identifiers are inferred from
+ the URL).
+
+ Returns
+ -------
+ int
+ Identifier assigned by the GLPI server to the new link.
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ GlpiProtocolError
+ If the create response is missing the ``id`` field.
+ """
+
+ return await self._resource_create(
+ f"{TICKET_ENDPOINT}/{ticket_id}/{TIMELINE_DOCUMENT_SUFFIX}",
+ document_link,
+ failure_message=(f"Failed to link timeline document on ticket {ticket_id}"),
+ missing_message=(
+ "GLPI timeline document link response did not include an ID"
+ ),
+ log_message_factory=(
+ lambda new_id: (
+ f"GLPI API linked timeline document {new_id} on ticket {ticket_id}"
+ )
+ ),
+ )
+
+ async def update_ticket_timeline_document(
+ self,
+ ticket_id: GlpiId,
+ document_link_id: GlpiId,
+ document_link: PatchTimelineDocument,
+ ) -> None:
+ """Update one timeline document link with a partial body.
+
+ Parameters
+ ----------
+ ticket_id : GlpiId
+ Numeric identifier of the parent ticket.
+ document_link_id : GlpiId
+ Numeric identifier of the timeline document link to update.
+ document_link : PatchTimelineDocument
+ Partial request body.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ """
+
+ await self._resource_update(
+ f"{TICKET_ENDPOINT}/{ticket_id}/"
+ f"{TIMELINE_DOCUMENT_SUFFIX}/{document_link_id}",
+ document_link,
+ failure_message=(
+ f"Failed to update timeline document {document_link_id} on "
+ f"ticket {ticket_id}"
+ ),
+ log_message=(
+ f"GLPI API updated timeline document {document_link_id} on "
+ f"ticket {ticket_id}"
+ ),
+ )
+
+ async def unlink_ticket_timeline_document(
+ self,
+ ticket_id: GlpiId,
+ document_link_id: GlpiId,
+ *,
+ force: bool | None = None,
+ ) -> None:
+ """Unlink one timeline document from a ticket.
+
+ Parameters
+ ----------
+ ticket_id : GlpiId
+ Numeric identifier of the parent ticket.
+ document_link_id : GlpiId
+ Numeric identifier of the timeline document link to remove.
+ force : bool | None, optional
+ When ``True`` the link is permanently deleted instead of
+ being moved to the trash.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ """
+
+ await self._resource_delete(
+ f"{TICKET_ENDPOINT}/{ticket_id}/"
+ f"{TIMELINE_DOCUMENT_SUFFIX}/{document_link_id}",
+ failure_message=(
+ f"Failed to unlink timeline document {document_link_id} on "
+ f"ticket {ticket_id}"
+ ),
+ log_message=(
+ f"GLPI API unlinked timeline document {document_link_id} on "
+ f"ticket {ticket_id}"
+ ),
+ force=force,
+ delete_model_cls=DeleteTimelineDocument,
+ )
+
+
+__all__ = ["TimelineDocumentMixin"]
diff --git a/glpi_python_client/_async/clients/api/assistance/timeline/_followup.py b/glpi_python_client/_async/clients/api/assistance/timeline/_followup.py
new file mode 100644
index 0000000..a50b591
--- /dev/null
+++ b/glpi_python_client/_async/clients/api/assistance/timeline/_followup.py
@@ -0,0 +1,204 @@
+"""GLPI ``/Assistance/Ticket/{id}/Timeline/Followup`` mixin.
+
+The mixin exposes list, fetch, create, update, and delete helpers for the
+ticket followup timeline endpoint, exchanging the ``api_schema`` followup
+models with the GLPI API.
+
+Notes
+-----
+The live GLPI v2 server returns each entry of the list endpoint wrapped
+in a ``{"type": "ITILFollowup", "item": {...}}`` envelope, even though
+the OpenAPI contract documents a flat array of ``ITILFollowup``. Real
+behaviour wins over the contract, so :func:`list_ticket_followups`
+unwraps the envelope via the shared
+:meth:`~glpi_python_client._async.clients.commons._transport.TransportMixin._resource_list`
+helper and tolerates both shapes.
+"""
+
+from __future__ import annotations
+
+from glpi_python_client._async.clients.commons._constants import (
+ FOLLOWUP_SUFFIX,
+ TICKET_ENDPOINT,
+ GlpiId,
+)
+from glpi_python_client._async.clients.commons._transport import TransportMixin
+from glpi_python_client.models.api_schema.assistance.timeline._followup import (
+ DeleteFollowup,
+ GetFollowup,
+ PatchFollowup,
+ PostFollowup,
+)
+
+
+class FollowupMixin(TransportMixin):
+ """CRUD helpers for the ticket followup timeline endpoint."""
+
+ async def list_ticket_followups(self, ticket_id: GlpiId) -> list[GetFollowup]:
+ """List all followups linked to one ticket.
+
+ Parameters
+ ----------
+ ticket_id : GlpiId
+ Numeric identifier of the parent ticket.
+
+ Returns
+ -------
+ list[GetFollowup]
+ Followups returned by the GLPI server, with the timeline
+ envelope unwrapped where present.
+ """
+
+ return await self._resource_list(
+ f"{TICKET_ENDPOINT}/{ticket_id}/{FOLLOWUP_SUFFIX}",
+ GetFollowup,
+ failure_message=f"Failed to list followups for ticket {ticket_id}",
+ unwrap_envelope=True,
+ )
+
+ async def get_ticket_followup(
+ self, ticket_id: GlpiId, followup_id: GlpiId
+ ) -> GetFollowup:
+ """Fetch one ticket followup by identifier.
+
+ Parameters
+ ----------
+ ticket_id : GlpiId
+ Numeric identifier of the parent ticket.
+ followup_id : GlpiId
+ Numeric identifier of the followup to retrieve.
+
+ Returns
+ -------
+ GetFollowup
+ Validated followup payload.
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ """
+
+ return await self._resource_get(
+ f"{TICKET_ENDPOINT}/{ticket_id}/{FOLLOWUP_SUFFIX}/{followup_id}",
+ GetFollowup,
+ failure_message=(
+ f"Failed to get followup {followup_id} on ticket {ticket_id}"
+ ),
+ )
+
+ async def create_ticket_followup(
+ self, ticket_id: GlpiId, followup: PostFollowup
+ ) -> int:
+ """Create one followup on a ticket.
+
+ Parameters
+ ----------
+ ticket_id : GlpiId
+ Numeric identifier of the parent ticket.
+ followup : PostFollowup
+ Request body describing the followup to create.
+
+ Returns
+ -------
+ int
+ Identifier assigned by the GLPI server to the new followup.
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ GlpiProtocolError
+ If the create response is missing the ``id`` field.
+ """
+
+ return await self._resource_create(
+ f"{TICKET_ENDPOINT}/{ticket_id}/{FOLLOWUP_SUFFIX}",
+ followup,
+ failure_message=f"Failed to create followup on ticket {ticket_id}",
+ missing_message="GLPI followup create response did not include an ID",
+ id_keys=("id", "followup_id"),
+ log_message_factory=(
+ lambda new_id: (
+ f"GLPI API created followup {new_id} on ticket {ticket_id}"
+ )
+ ),
+ )
+
+ async def update_ticket_followup(
+ self,
+ ticket_id: GlpiId,
+ followup_id: GlpiId,
+ followup: PatchFollowup,
+ ) -> None:
+ """Update one ticket followup with a partial body.
+
+ Parameters
+ ----------
+ ticket_id : GlpiId
+ Numeric identifier of the parent ticket.
+ followup_id : GlpiId
+ Numeric identifier of the followup to update.
+ followup : PatchFollowup
+ Partial request body.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ """
+
+ await self._resource_update(
+ f"{TICKET_ENDPOINT}/{ticket_id}/{FOLLOWUP_SUFFIX}/{followup_id}",
+ followup,
+ failure_message=(
+ f"Failed to update followup {followup_id} on ticket {ticket_id}"
+ ),
+ log_message=f"API updated followup {followup_id} on ticket {ticket_id}",
+ )
+
+ async def delete_ticket_followup(
+ self,
+ ticket_id: GlpiId,
+ followup_id: GlpiId,
+ *,
+ force: bool | None = None,
+ ) -> None:
+ """Delete one ticket followup by identifier.
+
+ Parameters
+ ----------
+ ticket_id : GlpiId
+ Numeric identifier of the parent ticket.
+ followup_id : GlpiId
+ Numeric identifier of the followup to delete.
+ force : bool | None, optional
+ When ``True`` the followup is permanently deleted instead of
+ being moved to the trash.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ """
+
+ await self._resource_delete(
+ f"{TICKET_ENDPOINT}/{ticket_id}/{FOLLOWUP_SUFFIX}/{followup_id}",
+ failure_message=(
+ f"Failed to delete followup {followup_id} on ticket {ticket_id}"
+ ),
+ log_message=f"API deleted followup {followup_id} on ticket {ticket_id}",
+ force=force,
+ delete_model_cls=DeleteFollowup,
+ )
+
+
+__all__ = ["FollowupMixin"]
diff --git a/glpi_python_client/_async/clients/api/assistance/timeline/_solution.py b/glpi_python_client/_async/clients/api/assistance/timeline/_solution.py
new file mode 100644
index 0000000..116d82a
--- /dev/null
+++ b/glpi_python_client/_async/clients/api/assistance/timeline/_solution.py
@@ -0,0 +1,201 @@
+"""GLPI ``/Assistance/Ticket/{id}/Timeline/Solution`` mixin.
+
+The mixin exposes list, fetch, create, update, and delete helpers for the
+ticket solution timeline endpoint using the ``api_schema`` solution models.
+
+Notes
+-----
+The live GLPI v2 server returns each entry of the list endpoint wrapped
+in a ``{"type": "ITILSolution", "item": {...}}`` envelope, even though
+the OpenAPI contract documents a flat array of ``ITILSolution``. Real
+behaviour wins over the contract, so :func:`list_ticket_solutions`
+unwraps the envelope through the shared
+:meth:`~glpi_python_client._async.clients.commons._transport.TransportMixin._resource_list`
+helper and tolerates both shapes.
+"""
+
+from __future__ import annotations
+
+from glpi_python_client._async.clients.commons._constants import (
+ SOLUTION_SUFFIX,
+ TICKET_ENDPOINT,
+ GlpiId,
+)
+from glpi_python_client._async.clients.commons._transport import TransportMixin
+from glpi_python_client.models.api_schema.assistance.timeline._solution import (
+ DeleteSolution,
+ GetSolution,
+ PatchSolution,
+ PostSolution,
+)
+
+
+class SolutionMixin(TransportMixin):
+ """CRUD helpers for the ticket solution timeline endpoint."""
+
+ async def list_ticket_solutions(self, ticket_id: GlpiId) -> list[GetSolution]:
+ """List all solutions linked to one ticket.
+
+ Parameters
+ ----------
+ ticket_id : GlpiId
+ Numeric identifier of the parent ticket.
+
+ Returns
+ -------
+ list[GetSolution]
+ Solutions returned by the GLPI server, with the timeline
+ envelope unwrapped where present.
+ """
+
+ return await self._resource_list(
+ f"{TICKET_ENDPOINT}/{ticket_id}/{SOLUTION_SUFFIX}",
+ GetSolution,
+ failure_message=f"Failed to list solutions for ticket {ticket_id}",
+ unwrap_envelope=True,
+ )
+
+ async def get_ticket_solution(
+ self, ticket_id: GlpiId, solution_id: GlpiId
+ ) -> GetSolution:
+ """Fetch one ticket solution by identifier.
+
+ Parameters
+ ----------
+ ticket_id : GlpiId
+ Numeric identifier of the parent ticket.
+ solution_id : GlpiId
+ Numeric identifier of the solution to retrieve.
+
+ Returns
+ -------
+ GetSolution
+ Validated solution payload.
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ """
+
+ return await self._resource_get(
+ f"{TICKET_ENDPOINT}/{ticket_id}/{SOLUTION_SUFFIX}/{solution_id}",
+ GetSolution,
+ failure_message=(
+ f"Failed to get solution {solution_id} on ticket {ticket_id}"
+ ),
+ )
+
+ async def create_ticket_solution(
+ self, ticket_id: GlpiId, solution: PostSolution
+ ) -> int:
+ """Create one solution on a ticket.
+
+ Parameters
+ ----------
+ ticket_id : GlpiId
+ Numeric identifier of the parent ticket.
+ solution : PostSolution
+ Request body describing the solution to create.
+
+ Returns
+ -------
+ int
+ Identifier assigned by the GLPI server to the new solution.
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ GlpiProtocolError
+ If the create response is missing the ``id`` field.
+ """
+
+ return await self._resource_create(
+ f"{TICKET_ENDPOINT}/{ticket_id}/{SOLUTION_SUFFIX}",
+ solution,
+ failure_message=f"Failed to create solution on ticket {ticket_id}",
+ missing_message="GLPI solution create response did not include an ID",
+ id_keys=("id", "solution_id"),
+ log_message_factory=(
+ lambda new_id: f"API created solution {new_id} on ticket {ticket_id}"
+ ),
+ )
+
+ async def update_ticket_solution(
+ self,
+ ticket_id: GlpiId,
+ solution_id: GlpiId,
+ solution: PatchSolution,
+ ) -> None:
+ """Update one ticket solution with a partial body.
+
+ Parameters
+ ----------
+ ticket_id : GlpiId
+ Numeric identifier of the parent ticket.
+ solution_id : GlpiId
+ Numeric identifier of the solution to update.
+ solution : PatchSolution
+ Partial request body.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ """
+
+ await self._resource_update(
+ f"{TICKET_ENDPOINT}/{ticket_id}/{SOLUTION_SUFFIX}/{solution_id}",
+ solution,
+ failure_message=(
+ f"Failed to update solution {solution_id} on ticket {ticket_id}"
+ ),
+ log_message=f"API updated solution {solution_id} on ticket {ticket_id}",
+ )
+
+ async def delete_ticket_solution(
+ self,
+ ticket_id: GlpiId,
+ solution_id: GlpiId,
+ *,
+ force: bool | None = None,
+ ) -> None:
+ """Delete one ticket solution by identifier.
+
+ Parameters
+ ----------
+ ticket_id : GlpiId
+ Numeric identifier of the parent ticket.
+ solution_id : GlpiId
+ Numeric identifier of the solution to delete.
+ force : bool | None, optional
+ When ``True`` the solution is permanently deleted instead of
+ being moved to the trash.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ """
+
+ await self._resource_delete(
+ f"{TICKET_ENDPOINT}/{ticket_id}/{SOLUTION_SUFFIX}/{solution_id}",
+ failure_message=(
+ f"Failed to delete solution {solution_id} on ticket {ticket_id}"
+ ),
+ log_message=f"API deleted solution {solution_id} on ticket {ticket_id}",
+ force=force,
+ delete_model_cls=DeleteSolution,
+ )
+
+
+__all__ = ["SolutionMixin"]
diff --git a/glpi_python_client/_async/clients/api/assistance/timeline/_task.py b/glpi_python_client/_async/clients/api/assistance/timeline/_task.py
new file mode 100644
index 0000000..ccfe011
--- /dev/null
+++ b/glpi_python_client/_async/clients/api/assistance/timeline/_task.py
@@ -0,0 +1,194 @@
+"""GLPI ``/Assistance/Ticket/{id}/Timeline/Task`` mixin.
+
+The mixin exposes list, fetch, create, update, and delete helpers for the
+ticket task timeline endpoint using the contract-aligned ``api_schema``
+ticket-task models.
+
+Notes
+-----
+The live GLPI v2 server returns each entry of the list endpoint wrapped
+in a ``{"type": "Task", "item": {...}}`` envelope, even though the
+OpenAPI contract documents a flat array of ``TicketTask``. Real behaviour
+wins over the contract, so :func:`list_ticket_tasks` unwraps the envelope
+through the shared
+:meth:`~glpi_python_client._async.clients.commons._transport.TransportMixin._resource_list`
+helper and tolerates both shapes.
+"""
+
+from __future__ import annotations
+
+from glpi_python_client._async.clients.commons._constants import (
+ TASK_SUFFIX,
+ TICKET_ENDPOINT,
+ GlpiId,
+)
+from glpi_python_client._async.clients.commons._transport import TransportMixin
+from glpi_python_client.models.api_schema.assistance.timeline._task import (
+ DeleteTicketTask,
+ GetTicketTask,
+ PatchTicketTask,
+ PostTicketTask,
+)
+
+
+class TicketTaskMixin(TransportMixin):
+ """CRUD helpers for the ticket task timeline endpoint."""
+
+ async def list_ticket_tasks(self, ticket_id: GlpiId) -> list[GetTicketTask]:
+ """List all tasks linked to one ticket.
+
+ Parameters
+ ----------
+ ticket_id : GlpiId
+ Numeric identifier of the parent ticket.
+
+ Returns
+ -------
+ list[GetTicketTask]
+ Tasks returned by the GLPI server, with the timeline envelope
+ unwrapped where present.
+ """
+
+ return await self._resource_list(
+ f"{TICKET_ENDPOINT}/{ticket_id}/{TASK_SUFFIX}",
+ GetTicketTask,
+ failure_message=f"Failed to list tasks for ticket {ticket_id}",
+ unwrap_envelope=True,
+ )
+
+ async def get_ticket_task(
+ self, ticket_id: GlpiId, task_id: GlpiId
+ ) -> GetTicketTask:
+ """Fetch one ticket task by identifier.
+
+ Parameters
+ ----------
+ ticket_id : GlpiId
+ Numeric identifier of the parent ticket.
+ task_id : GlpiId
+ Numeric identifier of the task to retrieve.
+
+ Returns
+ -------
+ GetTicketTask
+ Validated task payload.
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ """
+
+ return await self._resource_get(
+ f"{TICKET_ENDPOINT}/{ticket_id}/{TASK_SUFFIX}/{task_id}",
+ GetTicketTask,
+ failure_message=f"Failed to get task {task_id} on ticket {ticket_id}",
+ )
+
+ async def create_ticket_task(self, ticket_id: GlpiId, task: PostTicketTask) -> int:
+ """Create one task on a ticket.
+
+ Parameters
+ ----------
+ ticket_id : GlpiId
+ Numeric identifier of the parent ticket.
+ task : PostTicketTask
+ Request body describing the task to create.
+
+ Returns
+ -------
+ int
+ Identifier assigned by the GLPI server to the new task.
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ GlpiProtocolError
+ If the create response is missing the ``id`` field.
+ """
+
+ return await self._resource_create(
+ f"{TICKET_ENDPOINT}/{ticket_id}/{TASK_SUFFIX}",
+ task,
+ failure_message=f"Failed to create task on ticket {ticket_id}",
+ missing_message="GLPI task create response did not include an ID",
+ id_keys=("id", "task_id"),
+ log_message_factory=(
+ lambda new_id: f"GLPI API created task {new_id} on ticket {ticket_id}"
+ ),
+ )
+
+ async def update_ticket_task(
+ self,
+ ticket_id: GlpiId,
+ task_id: GlpiId,
+ task: PatchTicketTask,
+ ) -> None:
+ """Update one ticket task with a partial body.
+
+ Parameters
+ ----------
+ ticket_id : GlpiId
+ Numeric identifier of the parent ticket.
+ task_id : GlpiId
+ Numeric identifier of the task to update.
+ task : PatchTicketTask
+ Partial request body.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ """
+
+ await self._resource_update(
+ f"{TICKET_ENDPOINT}/{ticket_id}/{TASK_SUFFIX}/{task_id}",
+ task,
+ failure_message=f"Failed to update task {task_id} on ticket {ticket_id}",
+ log_message=f"GLPI API updated task {task_id} on ticket {ticket_id}",
+ )
+
+ async def delete_ticket_task(
+ self,
+ ticket_id: GlpiId,
+ task_id: GlpiId,
+ *,
+ force: bool | None = None,
+ ) -> None:
+ """Delete one ticket task by identifier.
+
+ Parameters
+ ----------
+ ticket_id : GlpiId
+ Numeric identifier of the parent ticket.
+ task_id : GlpiId
+ Numeric identifier of the task to delete.
+ force : bool | None, optional
+ When ``True`` the task is permanently deleted instead of
+ being moved to the trash.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ """
+
+ await self._resource_delete(
+ f"{TICKET_ENDPOINT}/{ticket_id}/{TASK_SUFFIX}/{task_id}",
+ failure_message=f"Failed to delete task {task_id} on ticket {ticket_id}",
+ log_message=f"GLPI API deleted task {task_id} on ticket {ticket_id}",
+ force=force,
+ delete_model_cls=DeleteTicketTask,
+ )
+
+
+__all__ = ["TicketTaskMixin"]
diff --git a/glpi_python_client/_async/clients/api/dropdowns/__init__.py b/glpi_python_client/_async/clients/api/dropdowns/__init__.py
new file mode 100644
index 0000000..60704f7
--- /dev/null
+++ b/glpi_python_client/_async/clients/api/dropdowns/__init__.py
@@ -0,0 +1,7 @@
+"""GLPI ``/Dropdowns`` mixins for the GLPI client."""
+
+from __future__ import annotations
+
+from glpi_python_client._async.clients.api.dropdowns._location import LocationMixin
+
+__all__ = ["LocationMixin"]
diff --git a/glpi_python_client/_async/clients/api/dropdowns/_location.py b/glpi_python_client/_async/clients/api/dropdowns/_location.py
new file mode 100644
index 0000000..8cc9916
--- /dev/null
+++ b/glpi_python_client/_async/clients/api/dropdowns/_location.py
@@ -0,0 +1,170 @@
+"""GLPI ``/Dropdowns/Location`` mixin.
+
+The mixin exposes search, fetch, create, update, and delete helpers for the
+GLPI location dropdown resource using the contract-aligned ``api_schema``
+models.
+"""
+
+from __future__ import annotations
+
+from glpi_python_client._async.clients.commons._constants import (
+ LOCATION_ENDPOINT,
+ GlpiId,
+)
+from glpi_python_client._async.clients.commons._transport import TransportMixin
+from glpi_python_client.models.api_schema.dropdowns._location import (
+ DeleteLocation,
+ GetLocation,
+ PatchLocation,
+ PostLocation,
+)
+
+
+class LocationMixin(TransportMixin):
+ """CRUD helpers for ``/Dropdowns/Location``."""
+
+ async def search_locations(
+ self,
+ rsql_filter: str = "",
+ *,
+ limit: int = 50,
+ start: int = 0,
+ ) -> list[GetLocation]:
+ """Search GLPI locations with an optional RSQL filter.
+
+ Parameters
+ ----------
+ rsql_filter : str, optional
+ Raw RSQL filter forwarded as the ``filter`` query parameter.
+ limit : int, optional
+ Maximum number of records returned by the GLPI server.
+ start : int, optional
+ Zero-based offset of the first record returned.
+
+ Returns
+ -------
+ list[GetLocation]
+ Locations matching the filter.
+ """
+
+ params: dict[str, object] = {"limit": limit, "start": start}
+ if rsql_filter:
+ params["filter"] = rsql_filter
+ return await self._resource_list(LOCATION_ENDPOINT, GetLocation, params=params)
+
+ async def get_location(self, location_id: GlpiId) -> GetLocation:
+ """Fetch one GLPI location by identifier.
+
+ Parameters
+ ----------
+ location_id : GlpiId
+ Numeric identifier of the location to retrieve.
+
+ Returns
+ -------
+ GetLocation
+ Validated location payload.
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ """
+
+ return await self._resource_get(
+ f"{LOCATION_ENDPOINT}/{location_id}",
+ GetLocation,
+ failure_message=f"Failed to get location {location_id}",
+ )
+
+ async def create_location(self, location: PostLocation) -> int:
+ """Create one GLPI location.
+
+ Parameters
+ ----------
+ location : PostLocation
+ Request body describing the location to create.
+
+ Returns
+ -------
+ int
+ Identifier assigned by the GLPI server.
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ GlpiProtocolError
+ If the create response is missing the ``id`` field.
+ """
+
+ return await self._resource_create(
+ LOCATION_ENDPOINT,
+ location,
+ failure_message="Failed to create location",
+ missing_message="GLPI location create response did not include an ID",
+ log_message_factory=lambda new_id: f"GLPI API created location {new_id}",
+ )
+
+ async def update_location(
+ self, location_id: GlpiId, location: PatchLocation
+ ) -> None:
+ """Update one GLPI location with a partial body.
+
+ Parameters
+ ----------
+ location_id : GlpiId
+ Numeric identifier of the location to update.
+ location : PatchLocation
+ Partial request body.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ """
+
+ await self._resource_update(
+ f"{LOCATION_ENDPOINT}/{location_id}",
+ location,
+ failure_message=f"Failed to update location {location_id}",
+ log_message=f"GLPI API updated location {location_id}",
+ )
+
+ async def delete_location(
+ self, location_id: GlpiId, *, force: bool | None = None
+ ) -> None:
+ """Delete one GLPI location by identifier.
+
+ Parameters
+ ----------
+ location_id : GlpiId
+ Numeric identifier of the location to delete.
+ force : bool | None, optional
+ When ``True`` the location is permanently deleted instead of
+ being moved to the trash.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ """
+
+ await self._resource_delete(
+ f"{LOCATION_ENDPOINT}/{location_id}",
+ failure_message=f"Failed to delete location {location_id}",
+ log_message=f"GLPI API deleted location {location_id}",
+ force=force,
+ delete_model_cls=DeleteLocation,
+ )
+
+
+__all__ = ["LocationMixin"]
diff --git a/glpi_python_client/_async/clients/api/knowledgebase/__init__.py b/glpi_python_client/_async/clients/api/knowledgebase/__init__.py
new file mode 100644
index 0000000..74b679a
--- /dev/null
+++ b/glpi_python_client/_async/clients/api/knowledgebase/__init__.py
@@ -0,0 +1,21 @@
+"""GLPI ``/Knowledgebase`` mixins."""
+
+from __future__ import annotations
+
+from glpi_python_client._async.clients.api.knowledgebase._article import KBArticleMixin
+from glpi_python_client._async.clients.api.knowledgebase._category import (
+ KBCategoryMixin,
+)
+from glpi_python_client._async.clients.api.knowledgebase._comment import (
+ KBArticleCommentMixin,
+)
+from glpi_python_client._async.clients.api.knowledgebase._revision import (
+ KBArticleRevisionMixin,
+)
+
+__all__ = [
+ "KBArticleCommentMixin",
+ "KBArticleMixin",
+ "KBArticleRevisionMixin",
+ "KBCategoryMixin",
+]
diff --git a/glpi_python_client/_async/clients/api/knowledgebase/_article.py b/glpi_python_client/_async/clients/api/knowledgebase/_article.py
new file mode 100644
index 0000000..19dac76
--- /dev/null
+++ b/glpi_python_client/_async/clients/api/knowledgebase/_article.py
@@ -0,0 +1,214 @@
+"""GLPI ``/Knowledgebase/Article`` mixin.
+
+The mixin exposes search, fetch, create, update, and delete helpers for the
+GLPI knowledge base article resource using the contract-aligned
+``api_schema`` models. Article ``content`` and ``description`` round-trip
+Markdown through GLPI's HTML wire format transparently.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+from glpi_python_client._async.clients.commons._constants import (
+ KB_ARTICLE_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._common import IdNameRef
+from glpi_python_client.models.api_schema.knowledgebase._article import (
+ DeleteKBArticle,
+ GetKBArticle,
+ PatchKBArticle,
+ PostKBArticle,
+)
+
+_V1_CATEGORY_FEATURE_LABEL = "knowledge base category assignments"
+
+
+class KBArticleMixin(TransportMixin):
+ """CRUD helpers for ``/Knowledgebase/Article``."""
+
+ async def search_kb_articles(
+ self,
+ rsql_filter: str = "",
+ *,
+ limit: int = 50,
+ start: int = 0,
+ sort: str | None = None,
+ language: str | None = None,
+ ) -> list[GetKBArticle]:
+ """Search GLPI knowledge base articles with an optional RSQL filter.
+
+ Parameters
+ ----------
+ rsql_filter : str, optional
+ Raw RSQL filter forwarded as the ``filter`` query parameter.
+ limit : int, optional
+ Maximum number of records returned by the GLPI server.
+ start : int, optional
+ Zero-based offset of the first record returned.
+ sort : str | None, optional
+ ``sort`` query parameter forwarded as-is.
+ language : str | None, optional
+ GLPI language code forwarded as the ``language`` query
+ parameter to select a translated view.
+
+ Returns
+ -------
+ list[GetKBArticle]
+ Articles matching the filter.
+ """
+
+ params: dict[str, object] = {"limit": limit, "start": start}
+ if rsql_filter:
+ params["filter"] = rsql_filter
+ if sort:
+ params["sort"] = sort
+ if language:
+ params["language"] = language
+ return await self._resource_list(
+ KB_ARTICLE_ENDPOINT, GetKBArticle, params=params
+ )
+
+ async def get_kb_article(self, article_id: GlpiId) -> GetKBArticle:
+ """Fetch one knowledge base article by identifier.
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ """
+
+ return await self._resource_get(
+ f"{KB_ARTICLE_ENDPOINT}/{article_id}",
+ GetKBArticle,
+ failure_message=f"Failed to get KB article {article_id}",
+ )
+
+ async def create_kb_article(self, article: PostKBArticle) -> int:
+ """Create one knowledge base article and return its new identifier.
+
+ When ``article.categories`` is a non-empty list, the categories are
+ applied through the legacy fallback (:meth:`set_kb_article_categories`)
+ because the v2 API cannot write them; this requires a configured v1
+ session. ``None`` or an empty list is skipped — a freshly created
+ article has no categories to clear — so callers that omit categories
+ never need a v1 session. The v2 create is not undone if the category
+ assignment fails: the article already exists, so the failure raises a
+ ``RuntimeError`` naming the new article id (chaining the original
+ error) and leaves the article in place for you to re-assign categories.
+ """
+
+ new_id = await self._resource_create(
+ KB_ARTICLE_ENDPOINT,
+ article,
+ failure_message="Failed to create KB article",
+ missing_message="GLPI KB article create response did not include an ID",
+ log_message_factory=lambda new_id: f"GLPI API created KB article {new_id}",
+ )
+ # A new article has no categories to clear, so only a non-empty list
+ # triggers the legacy fallback; ``None``/``[]`` are no-ops here.
+ if article.categories:
+ try:
+ await self._apply_category_fallback(new_id, article.categories)
+ except Exception as exc:
+ raise RuntimeError(
+ f"KB article {new_id} was created but assigning its "
+ f"categories failed: {exc}"
+ ) from exc
+ return new_id
+
+ async def update_kb_article(
+ self, article_id: GlpiId, article: PatchKBArticle
+ ) -> None:
+ """Update one knowledge base article with a partial body.
+
+ When ``article.categories`` is provided — including an empty list to
+ clear every category — the categories are applied through the legacy
+ fallback (:meth:`set_kb_article_categories`) after the v2 patch, because
+ the v2 API cannot write them; this requires a configured v1 session.
+ ``None`` (the default) leaves categories untouched. Unlike create,
+ update is not rolled back on a category failure — the v2 field changes
+ are already applied.
+ """
+
+ await self._resource_update(
+ f"{KB_ARTICLE_ENDPOINT}/{article_id}",
+ article,
+ failure_message=f"Failed to update KB article {article_id}",
+ log_message=f"GLPI API updated KB article {article_id}",
+ )
+ await self._apply_category_fallback(article_id, article.categories)
+
+ async def delete_kb_article(
+ self, article_id: GlpiId, *, force: bool | None = None
+ ) -> None:
+ """Delete one knowledge base article by identifier."""
+
+ await self._resource_delete(
+ f"{KB_ARTICLE_ENDPOINT}/{article_id}",
+ failure_message=f"Failed to delete KB article {article_id}",
+ log_message=f"GLPI API deleted KB article {article_id}",
+ force=force,
+ delete_model_cls=DeleteKBArticle,
+ )
+
+ async def set_kb_article_categories(
+ self, article_id: GlpiId, category_ids: Sequence[int]
+ ) -> None:
+ """Set the categories of one knowledge base article.
+
+ GLPI 11 stores KB categories as a many-to-many relationship that the
+ v2 API exposes as read-only (``KBArticle.categories[].id`` is
+ ``readOnly``), so it silently drops category writes. This helper sets
+ the underlying ``_categories`` field through the legacy v1 API, which
+ requires ``v1_base_url``/``v1_user_token`` to be configured (pointing
+ at the legacy ``apirest.php``).
+
+ The supplied ids REPLACE the article's full category set; passing an
+ empty sequence clears every category. Category ids are not validated
+ against the server — an unknown id simply is not linked.
+
+ Raises
+ ------
+ RuntimeError
+ When no legacy v1 session is configured on the client.
+ GlpiStatusError
+ When the legacy API returns a non-success status.
+ """
+
+ v1 = self._require_v1_session(_V1_CATEGORY_FEATURE_LABEL)
+ await v1.request_json(
+ "PUT",
+ f"KnowbaseItem/{article_id}",
+ json_body={"input": {"_categories": [int(c) for c in category_ids]}},
+ failure_message=f"Failed to set categories on KB article {article_id}",
+ )
+
+ async def _apply_category_fallback(
+ self, article_id: GlpiId, categories: list[IdNameRef] | None
+ ) -> None:
+ """Apply ``categories`` through the legacy fallback when provided.
+
+ No-op when ``categories`` is ``None``. An empty list clears every
+ category (used by update); ``create_kb_article`` skips the empty case
+ before calling this helper. Raises ``GlpiValidationError`` when a
+ category reference lacks an ``id``.
+ """
+
+ if categories is None:
+ return
+ ids: list[int] = []
+ for ref in categories:
+ if ref.id is None:
+ raise GlpiValidationError(
+ "KB article categories require an 'id' to be linked; got a "
+ "category reference without an id."
+ )
+ ids.append(ref.id)
+ await self.set_kb_article_categories(article_id, ids)
+
+
+__all__ = ["KBArticleMixin"]
diff --git a/glpi_python_client/_async/clients/api/knowledgebase/_category.py b/glpi_python_client/_async/clients/api/knowledgebase/_category.py
new file mode 100644
index 0000000..c1a063b
--- /dev/null
+++ b/glpi_python_client/_async/clients/api/knowledgebase/_category.py
@@ -0,0 +1,120 @@
+"""GLPI ``/Knowledgebase/Category`` mixin.
+
+The mixin exposes search, fetch, create, update, and delete helpers for the
+GLPI knowledge base category resource using the contract-aligned
+``api_schema`` models.
+"""
+
+from __future__ import annotations
+
+from glpi_python_client._async.clients.commons._constants import (
+ KB_CATEGORY_ENDPOINT,
+ GlpiId,
+)
+from glpi_python_client._async.clients.commons._transport import TransportMixin
+from glpi_python_client.models.api_schema.knowledgebase._category import (
+ DeleteKBCategory,
+ GetKBCategory,
+ PatchKBCategory,
+ PostKBCategory,
+)
+
+
+class KBCategoryMixin(TransportMixin):
+ """CRUD helpers for ``/Knowledgebase/Category``."""
+
+ async def search_kb_categories(
+ self,
+ rsql_filter: str = "",
+ *,
+ limit: int = 50,
+ start: int = 0,
+ sort: str | None = None,
+ language: str | None = None,
+ ) -> list[GetKBCategory]:
+ """Search GLPI knowledge base categories with an optional RSQL filter.
+
+ Parameters
+ ----------
+ rsql_filter : str, optional
+ Raw RSQL filter forwarded as the ``filter`` query parameter.
+ limit : int, optional
+ Maximum number of records returned by the GLPI server.
+ 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"``.
+ language : str | None, optional
+ GLPI language code forwarded as the ``language`` query
+ parameter to select a translated view.
+
+ Returns
+ -------
+ list[GetKBCategory]
+ Categories matching the filter.
+ """
+
+ params: dict[str, object] = {"limit": limit, "start": start}
+ if rsql_filter:
+ params["filter"] = rsql_filter
+ if sort:
+ params["sort"] = sort
+ if language:
+ params["language"] = language
+ return await self._resource_list(
+ KB_CATEGORY_ENDPOINT, GetKBCategory, params=params
+ )
+
+ async def get_kb_category(self, category_id: GlpiId) -> GetKBCategory:
+ """Fetch one knowledge base category by identifier.
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ """
+
+ return await self._resource_get(
+ f"{KB_CATEGORY_ENDPOINT}/{category_id}",
+ GetKBCategory,
+ failure_message=f"Failed to get KB category {category_id}",
+ )
+
+ async def create_kb_category(self, category: PostKBCategory) -> int:
+ """Create one knowledge base category and return its new identifier."""
+
+ return await self._resource_create(
+ KB_CATEGORY_ENDPOINT,
+ category,
+ failure_message="Failed to create KB category",
+ missing_message="GLPI KB category create response did not include an ID",
+ log_message_factory=lambda new_id: f"GLPI API created KB category {new_id}",
+ )
+
+ async def update_kb_category(
+ self, category_id: GlpiId, category: PatchKBCategory
+ ) -> None:
+ """Update one knowledge base category with a partial body."""
+
+ await self._resource_update(
+ f"{KB_CATEGORY_ENDPOINT}/{category_id}",
+ category,
+ failure_message=f"Failed to update KB category {category_id}",
+ log_message=f"GLPI API updated KB category {category_id}",
+ )
+
+ async def delete_kb_category(
+ self, category_id: GlpiId, *, force: bool | None = None
+ ) -> None:
+ """Delete one knowledge base category by identifier."""
+
+ await self._resource_delete(
+ f"{KB_CATEGORY_ENDPOINT}/{category_id}",
+ failure_message=f"Failed to delete KB category {category_id}",
+ log_message=f"GLPI API deleted KB category {category_id}",
+ force=force,
+ delete_model_cls=DeleteKBCategory,
+ )
+
+
+__all__ = ["KBCategoryMixin"]
diff --git a/glpi_python_client/_async/clients/api/knowledgebase/_comment.py b/glpi_python_client/_async/clients/api/knowledgebase/_comment.py
new file mode 100644
index 0000000..ec2d770
--- /dev/null
+++ b/glpi_python_client/_async/clients/api/knowledgebase/_comment.py
@@ -0,0 +1,109 @@
+"""GLPI ``/Knowledgebase/Article/{id}/Comment`` mixin.
+
+The mixin exposes list, fetch, create, update, and delete helpers for the
+GLPI knowledge base article comment endpoint using the contract-aligned
+``api_schema`` models.
+"""
+
+from __future__ import annotations
+
+from glpi_python_client._async.clients.commons._constants import (
+ KB_ARTICLE_ENDPOINT,
+ KB_COMMENT_SUFFIX,
+ GlpiId,
+)
+from glpi_python_client._async.clients.commons._transport import TransportMixin
+from glpi_python_client.models.api_schema.knowledgebase._comment import (
+ DeleteKBArticleComment,
+ GetKBArticleComment,
+ PatchKBArticleComment,
+ PostKBArticleComment,
+)
+
+
+class KBArticleCommentMixin(TransportMixin):
+ """CRUD helpers for KB article comments."""
+
+ async def list_kb_article_comments(
+ self, article_id: GlpiId
+ ) -> list[GetKBArticleComment]:
+ """List every comment attached to one knowledge base article."""
+
+ return await self._resource_list(
+ f"{KB_ARTICLE_ENDPOINT}/{article_id}/{KB_COMMENT_SUFFIX}",
+ GetKBArticleComment,
+ failure_message=f"Failed to list comments for KB article {article_id}",
+ )
+
+ async def get_kb_article_comment(
+ self, article_id: GlpiId, comment_id: GlpiId
+ ) -> GetKBArticleComment:
+ """Fetch one knowledge base article comment by identifier."""
+
+ return await self._resource_get(
+ f"{KB_ARTICLE_ENDPOINT}/{article_id}/{KB_COMMENT_SUFFIX}/{comment_id}",
+ GetKBArticleComment,
+ failure_message=(
+ f"Failed to get comment {comment_id} on KB article {article_id}"
+ ),
+ )
+
+ async def create_kb_article_comment(
+ self, article_id: GlpiId, comment: PostKBArticleComment
+ ) -> int:
+ """Create one comment on a knowledge base article."""
+
+ return await self._resource_create(
+ f"{KB_ARTICLE_ENDPOINT}/{article_id}/{KB_COMMENT_SUFFIX}",
+ comment,
+ failure_message=f"Failed to create comment on KB article {article_id}",
+ missing_message="GLPI KB comment create response did not include an ID",
+ log_message_factory=(
+ lambda new_id: (
+ f"GLPI API created comment {new_id} on KB article {article_id}"
+ )
+ ),
+ )
+
+ async def update_kb_article_comment(
+ self,
+ article_id: GlpiId,
+ comment_id: GlpiId,
+ comment: PatchKBArticleComment,
+ ) -> None:
+ """Update one knowledge base article comment with a partial body."""
+
+ await self._resource_update(
+ f"{KB_ARTICLE_ENDPOINT}/{article_id}/{KB_COMMENT_SUFFIX}/{comment_id}",
+ comment,
+ failure_message=(
+ f"Failed to update comment {comment_id} on KB article {article_id}"
+ ),
+ log_message=(
+ f"GLPI API updated comment {comment_id} on KB article {article_id}"
+ ),
+ )
+
+ async def delete_kb_article_comment(
+ self,
+ article_id: GlpiId,
+ comment_id: GlpiId,
+ *,
+ force: bool | None = None,
+ ) -> None:
+ """Delete one knowledge base article comment by identifier."""
+
+ await self._resource_delete(
+ f"{KB_ARTICLE_ENDPOINT}/{article_id}/{KB_COMMENT_SUFFIX}/{comment_id}",
+ failure_message=(
+ f"Failed to delete comment {comment_id} on KB article {article_id}"
+ ),
+ log_message=(
+ f"GLPI API deleted comment {comment_id} on KB article {article_id}"
+ ),
+ force=force,
+ delete_model_cls=DeleteKBArticleComment,
+ )
+
+
+__all__ = ["KBArticleCommentMixin"]
diff --git a/glpi_python_client/_async/clients/api/knowledgebase/_revision.py b/glpi_python_client/_async/clients/api/knowledgebase/_revision.py
new file mode 100644
index 0000000..2df2b8b
--- /dev/null
+++ b/glpi_python_client/_async/clients/api/knowledgebase/_revision.py
@@ -0,0 +1,70 @@
+"""GLPI ``/Knowledgebase/Article/{id}/Revision`` mixin.
+
+Revisions are read-only. The GLPI contract exposes both a default-language
+listing (``.../Revision``) and a language-scoped listing
+(``.../{language}/Revision``); this mixin folds both into two helpers that
+take an optional ``language`` argument and build the matching path.
+"""
+
+from __future__ import annotations
+
+from glpi_python_client._async.clients.commons._constants import (
+ KB_ARTICLE_ENDPOINT,
+ KB_REVISION_SUFFIX,
+ GlpiId,
+)
+from glpi_python_client._async.clients.commons._transport import TransportMixin
+from glpi_python_client.models.api_schema.knowledgebase._revision import (
+ GetKBArticleRevision,
+)
+
+
+class KBArticleRevisionMixin(TransportMixin):
+ """Read helpers for KB article revisions."""
+
+ def _revision_base(self, article_id: GlpiId, language: str | None) -> str:
+ """Return the revision collection path, language-scoped when given."""
+
+ if language:
+ return f"{KB_ARTICLE_ENDPOINT}/{article_id}/{language}/{KB_REVISION_SUFFIX}"
+ return f"{KB_ARTICLE_ENDPOINT}/{article_id}/{KB_REVISION_SUFFIX}"
+
+ async def list_kb_article_revisions(
+ self, article_id: GlpiId, *, language: str | None = None
+ ) -> list[GetKBArticleRevision]:
+ """List revisions of one knowledge base article.
+
+ Parameters
+ ----------
+ article_id : GlpiId
+ Numeric identifier of the parent article.
+ language : str | None, optional
+ When provided, list revisions for that GLPI language code using
+ the language-scoped contract path.
+ """
+
+ return await self._resource_list(
+ self._revision_base(article_id, language),
+ GetKBArticleRevision,
+ failure_message=f"Failed to list revisions for KB article {article_id}",
+ )
+
+ async def get_kb_article_revision(
+ self,
+ article_id: GlpiId,
+ revision: int,
+ *,
+ language: str | None = None,
+ ) -> GetKBArticleRevision:
+ """Fetch one revision of a knowledge base article by revision number."""
+
+ return await self._resource_get(
+ f"{self._revision_base(article_id, language)}/{revision}",
+ GetKBArticleRevision,
+ failure_message=(
+ f"Failed to get revision {revision} of KB article {article_id}"
+ ),
+ )
+
+
+__all__ = ["KBArticleRevisionMixin"]
diff --git a/glpi_python_client/_async/clients/api/management/__init__.py b/glpi_python_client/_async/clients/api/management/__init__.py
new file mode 100644
index 0000000..4116a74
--- /dev/null
+++ b/glpi_python_client/_async/clients/api/management/__init__.py
@@ -0,0 +1,7 @@
+"""GLPI ``/Management`` mixins for the GLPI client."""
+
+from __future__ import annotations
+
+from glpi_python_client._async.clients.api.management._document import DocumentMixin
+
+__all__ = ["DocumentMixin"]
diff --git a/glpi_python_client/_async/clients/api/management/_document.py b/glpi_python_client/_async/clients/api/management/_document.py
new file mode 100644
index 0000000..11a32bb
--- /dev/null
+++ b/glpi_python_client/_async/clients/api/management/_document.py
@@ -0,0 +1,280 @@
+"""GLPI ``/Management/Document`` mixin.
+
+The mixin exposes JSON metadata CRUD operations on the document resource and
+a multipart upload helper that delegates to the legacy v1 session because
+the v2 API does not advertise a binary upload endpoint in the contract.
+"""
+
+from __future__ import annotations
+
+import logging
+
+from glpi_python_client._async.clients.commons._constants import (
+ DOCUMENT_ENDPOINT,
+ GlpiId,
+)
+from glpi_python_client._async.clients.commons._http import ensure_response_status
+from glpi_python_client._async.clients.commons._transport import TransportMixin
+from glpi_python_client._errors import GlpiValidationError
+from glpi_python_client.models.api_schema.management._document import (
+ DeleteDocument,
+ GetDocument,
+ PatchDocument,
+ PostDocument,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class DocumentMixin(TransportMixin):
+ """CRUD and upload helpers for ``/Management/Document``."""
+
+ async def search_documents(
+ self,
+ rsql_filter: str = "",
+ *,
+ limit: int = 50,
+ start: int = 0,
+ ) -> list[GetDocument]:
+ """Search GLPI documents with an optional raw RSQL filter.
+
+ Parameters
+ ----------
+ rsql_filter : str, optional
+ Raw RSQL expression forwarded to the ``filter`` query
+ parameter (for example ``"name=='*manual*'"``). When empty
+ the parameter is omitted and the server returns its default
+ paginated listing.
+ limit : int, optional
+ Maximum number of records to return (defaults to 50).
+ start : int, optional
+ Zero-based offset for pagination (defaults to 0).
+
+ Returns
+ -------
+ list[GetDocument]
+ Documents matching the filter window.
+ """
+
+ params: dict[str, object] = {"limit": limit, "start": start}
+ if rsql_filter:
+ params["filter"] = rsql_filter
+ return await self._resource_list(
+ DOCUMENT_ENDPOINT, GetDocument, params=params, skip_entity=True
+ )
+
+ async def get_document(self, document_id: GlpiId) -> GetDocument:
+ """Fetch one GLPI document by identifier.
+
+ Parameters
+ ----------
+ document_id : GlpiId
+ Numeric identifier of the document to retrieve.
+
+ Returns
+ -------
+ GetDocument
+ Validated document metadata payload.
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ """
+
+ return await self._resource_get(
+ f"{DOCUMENT_ENDPOINT}/{document_id}",
+ GetDocument,
+ failure_message=f"Failed to get document {document_id}",
+ skip_entity=True,
+ )
+
+ async def create_document(self, document: PostDocument) -> int:
+ """Create one GLPI document metadata record.
+
+ Binary uploads use :meth:`upload_document` instead of the JSON
+ metadata endpoint exposed here.
+
+ Parameters
+ ----------
+ document : PostDocument
+ Request body describing the document metadata.
+
+ Returns
+ -------
+ int
+ Identifier assigned by the GLPI server to the new document.
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ GlpiProtocolError
+ If the create response is missing the ``id`` field.
+ """
+
+ return await self._resource_create(
+ DOCUMENT_ENDPOINT,
+ document,
+ failure_message="Failed to create document",
+ missing_message="GLPI document create response did not include an ID",
+ log_message_factory=lambda new_id: f"GLPI API created document {new_id}",
+ skip_entity=True,
+ )
+
+ async def update_document(
+ self, document_id: GlpiId, document: PatchDocument
+ ) -> None:
+ """Update one GLPI document with a partial body.
+
+ Parameters
+ ----------
+ document_id : GlpiId
+ Numeric identifier of the document to update.
+ document : PatchDocument
+ Partial request body.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ """
+
+ await self._resource_update(
+ f"{DOCUMENT_ENDPOINT}/{document_id}",
+ document,
+ failure_message=f"Failed to update document {document_id}",
+ log_message=f"GLPI API updated document {document_id}",
+ )
+
+ async def delete_document(
+ self, document_id: GlpiId, *, force: bool | None = None
+ ) -> None:
+ """Delete one GLPI document by identifier.
+
+ Parameters
+ ----------
+ document_id : GlpiId
+ Numeric identifier of the document to delete.
+ force : bool | None, optional
+ When ``True`` the document is permanently deleted instead of
+ being moved to the trash.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ """
+
+ await self._resource_delete(
+ f"{DOCUMENT_ENDPOINT}/{document_id}",
+ failure_message=f"Failed to delete document {document_id}",
+ log_message=f"GLPI API deleted document {document_id}",
+ force=force,
+ delete_model_cls=DeleteDocument,
+ skip_entity=True,
+ )
+
+ async def download_document_content(self, document_id: GlpiId) -> bytes:
+ """Download the raw binary payload for one GLPI document.
+
+ Parameters
+ ----------
+ document_id : GlpiId
+ Numeric identifier of the document whose binary content is
+ requested.
+
+ Returns
+ -------
+ bytes
+ Raw bytes returned by the GLPI download endpoint.
+
+ Raises
+ ------
+ GlpiStatusError
+ If the GLPI server returns a non-success HTTP status.
+ """
+
+ response = await self._get_request(
+ f"{DOCUMENT_ENDPOINT}/{document_id}/Download",
+ skip_entity=True,
+ )
+ ensure_response_status(
+ response,
+ success_statuses=(200,),
+ failure_message=f"Failed to download document {document_id}",
+ )
+ return response.content
+
+ async def upload_document(
+ self,
+ *,
+ filename: str,
+ content: bytes,
+ mime_type: str = "application/octet-stream",
+ document_name: str | None = None,
+ ticket_id: int | None = None,
+ entity_id: int | None = None,
+ ) -> dict[str, object]:
+ """Upload one binary document via the legacy v1 multipart endpoint.
+
+ Document uploads use the legacy v1 multipart endpoint because
+ the GLPI v2 API does not advertise a binary upload route. The
+ upload is dispatched through the same v1 session as every other
+ v1 call, so it needs ``v1_base_url`` and ``v1_user_token`` to be
+ configured on the client.
+
+ Parameters
+ ----------
+ filename : str
+ Name to advertise in the multipart form. Required and must
+ be non-empty.
+ content : bytes
+ Raw binary payload to upload.
+ mime_type : str, optional
+ MIME type advertised in the multipart part (defaults to
+ ``application/octet-stream``).
+ document_name : str | None, optional
+ Human-readable display name. Defaults to ``filename`` when
+ omitted.
+ ticket_id : int | None, optional
+ Identifier of one ticket to attach the uploaded document to.
+ entity_id : int | None, optional
+ Identifier of one GLPI entity to scope the upload to.
+
+ Returns
+ -------
+ dict[str, object]
+ Raw JSON dictionary returned by the legacy v1 upload
+ endpoint.
+
+ Raises
+ ------
+ GlpiValidationError
+ If ``filename`` is empty.
+ RuntimeError
+ If the v1 session is not configured on the client.
+ """
+
+ if not filename:
+ raise GlpiValidationError("GLPI document upload requires a filename")
+ v1 = self._require_v1_session("document uploads")
+ return await v1.upload_document(
+ filename,
+ content,
+ mime_type,
+ document_name=document_name,
+ ticket_id=ticket_id,
+ entity_id=entity_id,
+ )
+
+
+__all__ = ["DocumentMixin"]
diff --git a/glpi_python_client/_async/clients/api/plugins/__init__.py b/glpi_python_client/_async/clients/api/plugins/__init__.py
new file mode 100644
index 0000000..2c1a920
--- /dev/null
+++ b/glpi_python_client/_async/clients/api/plugins/__init__.py
@@ -0,0 +1,10 @@
+"""GLPI plugin endpoint mixins exposed via the legacy v1 REST API.
+
+Plugins are not advertised in the v2 OpenAPI contract so the mixins
+under this package go through the v1 session helper exposed by
+:class:`~glpi_python_client._async.auth._v1_session.GLPIV1Session`.
+"""
+
+from glpi_python_client._async.clients.api.plugins._fields import PluginFieldsMixin
+
+__all__ = ["PluginFieldsMixin"]
diff --git a/glpi_python_client/_async/clients/api/plugins/_fields.py b/glpi_python_client/_async/clients/api/plugins/_fields.py
new file mode 100644
index 0000000..616e953
--- /dev/null
+++ b/glpi_python_client/_async/clients/api/plugins/_fields.py
@@ -0,0 +1,426 @@
+"""Client mixin for the GLPI ``Fields`` plugin.
+
+The `Fields plugin `_ adds
+user-defined custom fields to any GLPI itemtype. It is not exposed
+through the GLPI v2 REST contract so this mixin talks to the legacy v1
+REST API through :class:`~glpi_python_client._async.auth._v1_session.GLPIV1Session`.
+
+Two abstraction layers are provided:
+
+* low-level helpers — :meth:`list_plugin_fields_containers`,
+ :meth:`list_plugin_fields_fields`,
+ :meth:`list_item_plugin_field_rows`,
+ :meth:`update_item_plugin_field_row`,
+ :meth:`create_item_plugin_field_row` — mirror one v1 endpoint each
+ and stay generic across itemtypes.
+* ticket-focused convenience helpers —
+ :meth:`get_ticket_custom_fields` and :meth:`set_ticket_custom_fields`
+ — fold the discovery + CRUD calls into a single round-trip that
+ returns / accepts a ``{container_name: {field_name: value}}``
+ mapping.
+
+The value itemtype for one container is derived from the container
+``name`` field with :func:`_value_itemtype_for`: container
+``extrainfo`` attached to ``Ticket`` becomes
+``PluginFieldsTicketextrainfo``. Field column names declared on
+:class:`~glpi_python_client.models.api_schema.plugins.GetPluginFieldsField`
+flow through :attr:`~glpi_python_client.models._base.GlpiModel.extra_payload`
+on the value rows.
+"""
+
+from __future__ import annotations
+
+import json
+from typing import Any
+
+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 (
+ GetPluginFieldsContainer,
+ GetPluginFieldsField,
+ GetPluginFieldsValueRow,
+)
+
+_TICKET_ITEMTYPE = "Ticket"
+_DEFAULT_LIST_RANGE = "0-999"
+_V1_FEATURE_LABEL = "Fields plugin helpers"
+
+
+def _value_itemtype_for(itemtype: str, container_name: str) -> str:
+ """Return the value-row itemtype for a (parent itemtype, container) pair.
+
+ The plugin uses the ``PluginFields`` convention. The
+ parent itemtype keeps its original casing and the container name is
+ lower-cased to match the v1 server-side route registration.
+ """
+
+ return f"PluginFields{itemtype}{container_name.lower()}"
+
+
+def _container_targets_itemtype(
+ container: GetPluginFieldsContainer, itemtype: str
+) -> bool:
+ """Return whether ``container`` is attached to ``itemtype``.
+
+ The v1 API returns ``itemtypes`` as a JSON-encoded string; failure
+ to parse falls back to a permissive substring check.
+ """
+
+ raw = container.itemtypes
+ if not raw:
+ return False
+ try:
+ parsed = json.loads(raw)
+ except (TypeError, ValueError):
+ return itemtype in raw
+ if isinstance(parsed, list):
+ return itemtype in parsed
+ return False
+
+
+def _extract_row_id(payload: object) -> int:
+ """Return the row id reported by the v1 API for a CRUD response.
+
+ The v1 plugin endpoints return ``[{"": true, "message": ""}]``
+ where ```` is the affected row identifier. ``GlpiProtocolError``
+ is raised when the payload does not match this shape.
+
+ Raises
+ ------
+ GlpiProtocolError
+ When ``payload`` is not a non-empty list of mappings, or no
+ mapping key parses as a numeric row id.
+ """
+
+ if not isinstance(payload, list) or not payload:
+ raise GlpiProtocolError(
+ f"GLPI Fields plugin response missing row id: {payload!r}"
+ )
+ first = payload[0]
+ if not isinstance(first, dict):
+ raise GlpiProtocolError(
+ f"GLPI Fields plugin response not a mapping: {payload!r}"
+ )
+ for key in first:
+ if key == "message":
+ continue
+ try:
+ return int(key)
+ except (TypeError, ValueError):
+ continue
+ raise GlpiProtocolError(
+ f"GLPI Fields plugin response did not include a numeric id: {payload!r}"
+ )
+
+
+class PluginFieldsMixin(TransportMixin):
+ """Helpers for the GLPI ``Fields`` plugin v1 endpoints.
+
+ Every method requires the v1 session to be configured on the client
+ (see the ``v1_base_url`` and ``v1_user_token`` constructor
+ arguments).
+ """
+
+ async def list_plugin_fields_containers(
+ self, itemtype: str | None = None
+ ) -> list[GetPluginFieldsContainer]:
+ """List ``PluginFieldsContainer`` rows registered on the server.
+
+ Parameters
+ ----------
+ itemtype : str | None, optional
+ When provided, only the containers attached to ``itemtype``
+ are returned. The filtering happens client-side because the
+ v1 API stores ``itemtypes`` as a JSON-encoded string.
+
+ Returns
+ -------
+ list[GetPluginFieldsContainer]
+ Containers visible to the authenticated user.
+ """
+
+ v1 = self._require_v1_session(_V1_FEATURE_LABEL)
+ payload = await v1.request_json(
+ "GET",
+ "PluginFieldsContainer",
+ params={"range": _DEFAULT_LIST_RANGE},
+ failure_message="Failed to list PluginFieldsContainer",
+ )
+ rows = payload if isinstance(payload, list) else []
+ containers = [GetPluginFieldsContainer.model_validate(row) for row in rows]
+ if itemtype is None:
+ return containers
+ return [c for c in containers if _container_targets_itemtype(c, itemtype)]
+
+ async def list_plugin_fields_fields(
+ self, container_id: int | None = None
+ ) -> list[GetPluginFieldsField]:
+ """List ``PluginFieldsField`` declarations.
+
+ Parameters
+ ----------
+ container_id : int | None, optional
+ When provided, restricts the result to the fields declared
+ on this container (the filtering is performed client-side
+ because the v1 API does not consistently honour the
+ ``searchText`` filter on this itemtype).
+
+ Returns
+ -------
+ list[GetPluginFieldsField]
+ Field declarations visible to the authenticated user.
+ """
+
+ v1 = self._require_v1_session(_V1_FEATURE_LABEL)
+ payload = await v1.request_json(
+ "GET",
+ "PluginFieldsField",
+ params={"range": _DEFAULT_LIST_RANGE},
+ failure_message="Failed to list PluginFieldsField",
+ )
+ rows = payload if isinstance(payload, list) else []
+ fields = [GetPluginFieldsField.model_validate(row) 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]
+
+ async def list_item_plugin_field_rows(
+ self,
+ itemtype: str,
+ items_id: int,
+ container_name: str,
+ ) -> list[GetPluginFieldsValueRow]:
+ """List the value rows of one container for one parent item.
+
+ Parameters
+ ----------
+ itemtype : str
+ Parent itemtype (e.g. ``"Ticket"``).
+ items_id : int
+ Identifier of the parent item.
+ container_name : str
+ Internal name of the container as exposed by
+ :attr:`GetPluginFieldsContainer.name`.
+
+ Returns
+ -------
+ list[GetPluginFieldsValueRow]
+ Zero or one row depending on whether the plugin has already
+ persisted any value for this item.
+ """
+
+ v1 = self._require_v1_session(_V1_FEATURE_LABEL)
+ endpoint = (
+ f"{itemtype}/{items_id}/{_value_itemtype_for(itemtype, container_name)}"
+ )
+ payload = await v1.request_json(
+ "GET",
+ endpoint,
+ failure_message=f"Failed to list {endpoint}",
+ )
+ rows = payload if isinstance(payload, list) else []
+ return [GetPluginFieldsValueRow.model_validate(row) for row in rows]
+
+ async def create_item_plugin_field_row(
+ self,
+ *,
+ itemtype: str,
+ items_id: int,
+ container_id: int,
+ container_name: str,
+ values: dict[str, object],
+ entities_id: int | None = None,
+ ) -> int:
+ """Create one fresh plugin-fields value row.
+
+ Parameters
+ ----------
+ itemtype : str
+ Parent itemtype (e.g. ``"Ticket"``).
+ items_id : int
+ Identifier of the parent item the row is attached to.
+ container_id : int
+ Identifier of the originating
+ :class:`GetPluginFieldsContainer`.
+ container_name : str
+ Internal name of the container, used to derive the value
+ itemtype.
+ values : dict[str, object]
+ Field-name → value mapping for the dynamic columns declared
+ on the container.
+ entities_id : int | None, optional
+ Entity to associate the row with. When omitted the GLPI
+ server applies its default scope.
+
+ Returns
+ -------
+ int
+ Identifier of the newly created row.
+ """
+
+ v1 = self._require_v1_session(_V1_FEATURE_LABEL)
+ input_payload: dict[str, object] = {
+ "items_id": items_id,
+ "itemtype": itemtype,
+ "plugin_fields_containers_id": container_id,
+ **values,
+ }
+ if entities_id is not None:
+ input_payload["entities_id"] = entities_id
+ response = await v1.request_json(
+ "POST",
+ _value_itemtype_for(itemtype, container_name),
+ json_body={"input": input_payload},
+ failure_message=(
+ f"Failed to create {_value_itemtype_for(itemtype, container_name)} "
+ f"row for {itemtype} {items_id}"
+ ),
+ )
+ return _extract_row_id(response)
+
+ async def update_item_plugin_field_row(
+ self,
+ *,
+ itemtype: str,
+ container_name: str,
+ row_id: int,
+ values: dict[str, object],
+ ) -> None:
+ """Update one existing plugin-fields value row.
+
+ Parameters
+ ----------
+ itemtype : str
+ Parent itemtype the container is attached to.
+ container_name : str
+ Internal name of the container.
+ row_id : int
+ Identifier of the existing value row (as returned by
+ :meth:`list_item_plugin_field_rows`).
+ values : dict[str, object]
+ Field-name → value mapping for the columns to update. Only
+ the fields supplied here are touched; the others keep their
+ previous value.
+ """
+
+ v1 = self._require_v1_session(_V1_FEATURE_LABEL)
+ endpoint = f"{_value_itemtype_for(itemtype, container_name)}/{row_id}"
+ await v1.request_json(
+ "PUT",
+ endpoint,
+ json_body={"input": {"id": row_id, **values}},
+ failure_message=f"Failed to update {endpoint}",
+ )
+
+ async def get_ticket_custom_fields(
+ self, ticket_id: int
+ ) -> dict[str, dict[str, Any]]:
+ """Return the custom-field values defined for one ticket.
+
+ The result is a nested mapping shaped as
+ ``{container_name: {field_name: value, ...}}``. Containers that
+ do not yet have a persisted value row for the ticket are
+ skipped.
+
+ Parameters
+ ----------
+ ticket_id : int
+ Identifier of the ticket whose custom values are requested.
+
+ Returns
+ -------
+ dict[str, dict[str, Any]]
+ Per-container value mappings. Empty when the ticket has no
+ stored custom values across any container.
+ """
+
+ containers = await self.list_plugin_fields_containers(itemtype=_TICKET_ITEMTYPE)
+ result: dict[str, dict[str, Any]] = {}
+ for container in containers:
+ name = container.name
+ if not name:
+ continue
+ rows = await self.list_item_plugin_field_rows(
+ _TICKET_ITEMTYPE, ticket_id, name
+ )
+ if not rows:
+ continue
+ result[name] = dict(rows[0].extra_payload)
+ return result
+
+ async def set_ticket_custom_fields(
+ self,
+ ticket_id: int,
+ values: dict[str, dict[str, Any]],
+ ) -> None:
+ """Persist custom-field values on one ticket.
+
+ Existing value rows are updated in place; missing rows are
+ created with the supplied payload. Containers/fields that the
+ server does not know about raise ``GlpiValidationError`` *before*
+ any write to keep the call atomic from the caller's perspective.
+
+ Parameters
+ ----------
+ ticket_id : int
+ Identifier of the ticket whose custom values must be set.
+ values : dict[str, dict[str, Any]]
+ Nested mapping ``{container_name: {field_name: value}}``
+ describing the columns to write. Container and field names
+ must match what :meth:`list_plugin_fields_containers` and
+ :meth:`list_plugin_fields_fields` return.
+ """
+
+ if not values:
+ return
+
+ containers = await self.list_plugin_fields_containers(itemtype=_TICKET_ITEMTYPE)
+ by_name: dict[str, GetPluginFieldsContainer] = {
+ c.name: c for c in containers if c.name is not None
+ }
+ unknown = sorted(set(values) - set(by_name))
+ if unknown:
+ raise GlpiValidationError(
+ "Unknown plugin-fields container(s) for Ticket: " + ", ".join(unknown)
+ )
+
+ for container_name, column_values in values.items():
+ container = by_name[container_name]
+ if container.id is None:
+ raise GlpiProtocolError(
+ f"Container {container_name!r} has no id; cannot write values"
+ )
+
+ declared = {
+ f.name
+ for f in await self.list_plugin_fields_fields(container_id=container.id)
+ if f.name is not None
+ }
+ unknown_fields = sorted(set(column_values) - declared)
+ if unknown_fields:
+ raise GlpiValidationError(
+ f"Unknown field(s) for container {container_name!r}: "
+ + ", ".join(unknown_fields)
+ )
+
+ existing_rows = await self.list_item_plugin_field_rows(
+ _TICKET_ITEMTYPE, ticket_id, container_name
+ )
+ if existing_rows and existing_rows[0].id is not None:
+ await self.update_item_plugin_field_row(
+ itemtype=_TICKET_ITEMTYPE,
+ container_name=container_name,
+ row_id=existing_rows[0].id,
+ values=column_values,
+ )
+ else:
+ await self.create_item_plugin_field_row(
+ itemtype=_TICKET_ITEMTYPE,
+ items_id=ticket_id,
+ container_id=container.id,
+ container_name=container_name,
+ values=column_values,
+ )
+
+
+__all__ = ["PluginFieldsMixin"]
diff --git a/glpi_python_client/_async/clients/client.py b/glpi_python_client/_async/clients/client.py
new file mode 100644
index 0000000..2fc4316
--- /dev/null
+++ b/glpi_python_client/_async/clients/client.py
@@ -0,0 +1,135 @@
+"""Public GLPI client class.
+
+Composes the per-endpoint mixins from
+:mod:`glpi_python_client._async.clients.api` with the aggregated helpers
+from :mod:`glpi_python_client._async.clients.custom` and the transport
+mixin from :mod:`glpi_python_client._async.clients.commons` to expose the
+full public client surface.
+
+This module is written once. Its counterpart on the other surface is
+generated from it, so the two client classes cannot drift apart: there is
+no second definition to keep in step.
+"""
+
+from __future__ import annotations
+
+import logging
+import sys
+from types import TracebackType
+
+if sys.version_info >= (3, 11):
+ from typing import Self
+else: # pragma: no cover - fallback for Python 3.10
+ from typing_extensions import Self
+
+from glpi_python_client._async.clients._base_client import _BaseGlpiClient
+from glpi_python_client._async.clients.api import (
+ DocumentMixin,
+ EntityMixin,
+ FollowupMixin,
+ KBArticleCommentMixin,
+ KBArticleMixin,
+ KBArticleRevisionMixin,
+ KBCategoryMixin,
+ LocationMixin,
+ PluginFieldsMixin,
+ SolutionMixin,
+ TeamMemberMixin,
+ TicketMixin,
+ TicketTaskMixin,
+ TimelineDocumentMixin,
+ UserMixin,
+)
+from glpi_python_client._async.clients.commons._transport import TransportMixin
+from glpi_python_client._async.clients.custom import (
+ StatisticsMixin,
+ TicketContextMixin,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class AsyncGlpiClient(
+ TicketMixin,
+ TicketTaskMixin,
+ FollowupMixin,
+ SolutionMixin,
+ TimelineDocumentMixin,
+ TeamMemberMixin,
+ DocumentMixin,
+ UserMixin,
+ EntityMixin,
+ LocationMixin,
+ KBCategoryMixin,
+ KBArticleMixin,
+ KBArticleCommentMixin,
+ KBArticleRevisionMixin,
+ PluginFieldsMixin,
+ TicketContextMixin,
+ StatisticsMixin,
+ _BaseGlpiClient,
+ TransportMixin,
+):
+ """GLPI client backed by the contract-aligned API mixins.
+
+ The client owns the shared HTTP session, the OAuth token manager, and
+ the optional legacy v1 session used for binary document uploads and
+ the Fields plugin endpoints. Token acquisition is serialised by the
+ lock from :mod:`glpi_python_client._async._concurrency`, which is the
+ right primitive for this surface -- see that module for why the two
+ surfaces cannot share one.
+
+ Construction parameters and :meth:`from_env` are documented on
+ :class:`~glpi_python_client._async.clients._base_client._BaseGlpiClient`.
+ """
+
+ async def close(self) -> None:
+ """Release every resource owned by the client.
+
+ The shared HTTP session is closed, the optional v1 fallback
+ session is closed, and the client is marked as closed so
+ subsequent calls raise immediately. The method is idempotent.
+ """
+
+ if self._closed:
+ return
+ try:
+ await self._session.aclose()
+ if self._v1 is not None:
+ await self._v1.close()
+ finally:
+ self._closed = True
+
+ async def __aenter__(self) -> Self:
+ """Return the client unchanged for use in a ``with`` block.
+
+ Returns
+ -------
+ AsyncGlpiClient
+ The client itself, suitable for chaining method calls.
+ """
+
+ return self
+
+ async def __aexit__(
+ self,
+ exc_type: type[BaseException] | None,
+ exc: BaseException | None,
+ tb: TracebackType | None,
+ ) -> None:
+ """Close the client on ``with`` block exit.
+
+ Parameters
+ ----------
+ exc_type : type[BaseException] | None
+ Exception class raised inside the ``with`` block, if any.
+ exc : BaseException | None
+ Exception instance raised inside the block, if any.
+ tb : TracebackType | None
+ Traceback associated with ``exc``.
+ """
+
+ await self.close()
+
+
+__all__ = ["AsyncGlpiClient"]
diff --git a/glpi_python_client/_async/clients/commons/__init__.py b/glpi_python_client/_async/clients/commons/__init__.py
new file mode 100644
index 0000000..149375a
--- /dev/null
+++ b/glpi_python_client/_async/clients/commons/__init__.py
@@ -0,0 +1,9 @@
+"""Reusable client-layer building blocks shared across the API mixins.
+
+The commons package centralises constants, HTTP helpers, RSQL filter
+builders, transport, and the client configuration helpers
+used by the per-endpoint mixins under :mod:`glpi_python_client._async.clients.api`
+and the higher-level helpers under :mod:`glpi_python_client._async.clients.custom`.
+"""
+
+from __future__ import annotations
diff --git a/glpi_python_client/_async/clients/commons/_config.py b/glpi_python_client/_async/clients/commons/_config.py
new file mode 100644
index 0000000..0de514a
--- /dev/null
+++ b/glpi_python_client/_async/clients/commons/_config.py
@@ -0,0 +1,316 @@
+"""Configuration and resource setup for the GLPI client.
+
+The helpers here own environment parsing, URL normalisation, and the
+construction of the runtime resources the client owns: the shared HTTP
+session, the OAuth token manager, and the optional legacy v1 session.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Protocol
+
+import httpx
+
+from glpi_python_client._errors import GlpiValidationError
+
+if TYPE_CHECKING:
+ from glpi_python_client._async.auth._v1_session import GLPIV1Session
+ from glpi_python_client._async.auth.auth import GLPITokenManager
+
+#: Request timeout applied to every call that does not override it.
+#:
+#: ``httpx`` defaults to 5 seconds where ``requests`` defaults to no timeout
+#: at all, so leaving this unset would silently start cutting off the slow
+#: GLPI searches that used to be allowed to finish. 30s matches the value the
+#: transport has always passed explicitly.
+DEFAULT_TIMEOUT_SECONDS = 30.0
+
+
+class SessionFactory(Protocol):
+ """Callable that builds the transport session for a client.
+
+ Declared as a ``Protocol`` rather than a bare ``Callable`` alias so the
+ keyword-only ``verify_ssl`` argument is part of the contract: the whole
+ point of this seam is that the SSL policy is supplied *at construction*,
+ and a positional-argument factory would let that guarantee slip.
+ """
+
+ def __call__(self, *, verify_ssl: bool) -> httpx.AsyncClient:
+ """Return a session configured for ``verify_ssl``."""
+
+
+@dataclass(frozen=True)
+class ClientResources:
+ """Runtime resources owned by one async ``GlpiClient`` instance.
+
+ The bundle keeps shared HTTP session, token manager, and optional v1
+ upload session tied together so the client can release them as a unit.
+ """
+
+ glpi_api_url: str
+ session: httpx.AsyncClient
+ auth: GLPITokenManager
+ v1: GLPIV1Session | None
+
+
+def build_http_session(*, verify_ssl: bool) -> httpx.AsyncClient:
+ """Construct the HTTP client used for every GLPI call.
+
+ This is the single place the library instantiates a transport session,
+ which is what makes the transport swappable at all. Three settings are
+ applied here deliberately, because each one differs between ``httpx``
+ and the ``requests`` transport this replaced:
+
+ * ``verify`` is applied **as part of construction**. ``httpx`` reads it
+ only in ``Client.__init__``; a later assignment is accepted and
+ silently ignored, which would leave certificate verification on when
+ the caller asked for it off.
+ * ``follow_redirects`` is enabled to preserve the previous behaviour.
+ ``requests`` follows redirects by default and ``httpx`` does not, so
+ omitting this would silently turn a followed redirect into a bare 3xx
+ response handed back to the caller.
+ * ``timeout`` is pinned to :data:`DEFAULT_TIMEOUT_SECONDS` rather than
+ left at the ``httpx`` default of 5 seconds.
+
+ Callers that need to intercept traffic (tests, and anything wanting
+ ``httpx.MockTransport``) can substitute this factory instead of
+ monkey-patching a session after the fact.
+
+ Parameters
+ ----------
+ verify_ssl : bool
+ Whether TLS certificates are verified.
+
+ Returns
+ -------
+ httpx.AsyncClient
+ A client configured for the requested SSL policy.
+ """
+
+ return httpx.AsyncClient(
+ verify=verify_ssl,
+ follow_redirects=True,
+ timeout=DEFAULT_TIMEOUT_SECONDS,
+ )
+
+
+def build_client_resources(
+ *,
+ glpi_api_url: object,
+ client_name: str,
+ client_id: str | None,
+ client_secret: str | None,
+ username: str | None,
+ password: str | None,
+ verify_ssl: bool,
+ auth_token_refresh: int | None,
+ v1_base_url: str | None,
+ v1_user_token: str | None,
+ v1_app_token: str | None,
+ session_factory: SessionFactory | None = None,
+) -> ClientResources:
+ """Build the shared resources required by one async client instance.
+
+ The helper validates the API URL, configures SSL behaviour, builds the
+ OAuth token manager, and optionally instantiates the legacy v1 session
+ used solely by the document upload mixin.
+
+ Parameters
+ ----------
+ session_factory : SessionFactory | None, optional
+ Override for :func:`build_http_session`, called with the resolved
+ ``verify_ssl`` policy. This is the transport-injection seam: it lets
+ a caller supply a session wired to a stub transport without patching
+ module globals. ``None`` uses the default factory.
+ """
+
+ from glpi_python_client._async.auth._v1_session import GLPIV1Session
+ from glpi_python_client._async.auth.auth import (
+ GLPITokenManager,
+ validate_credentials,
+ )
+
+ normalized_api_url = normalize_client_api_url(
+ glpi_api_url,
+ client_name=client_name,
+ )
+ validate_v1_document_config(
+ v1_base_url=v1_base_url,
+ v1_user_token=v1_user_token,
+ )
+
+ # Validated before anything is constructed, so a bad configuration never
+ # leaves a session to unwind. The previous shape -- build the session,
+ # then close it in an ``except`` clause -- cannot work on the async
+ # surface: an ``httpx.AsyncClient`` has no synchronous close, and this
+ # runs from ``__init__``, which cannot await one.
+ validate_credentials(
+ client_id=client_id,
+ client_secret=client_secret,
+ username=username,
+ password=password,
+ )
+
+ factory = session_factory or build_http_session
+ session = factory(verify_ssl=verify_ssl)
+ auth = GLPITokenManager(
+ token_url=f"{normalized_api_url}/token",
+ client_id=client_id,
+ client_secret=client_secret,
+ username=username,
+ password=password,
+ session=session,
+ auth_token_refresh=auth_token_refresh,
+ )
+
+ v1: GLPIV1Session | None = None
+ if v1_base_url and v1_user_token:
+ v1 = GLPIV1Session(
+ base_url=v1_base_url,
+ user_token=v1_user_token,
+ app_token=v1_app_token,
+ verify_ssl=verify_ssl,
+ )
+
+ return ClientResources(
+ glpi_api_url=normalized_api_url,
+ session=session,
+ auth=auth,
+ v1=v1,
+ )
+
+
+def parse_optional_env_int(value: object) -> int | None:
+ """Parse one optional integer from an environment-style value.
+
+ ``None`` is preserved, native integers are accepted as-is, and strings
+ are converted through ``int()`` so explicit overrides and environment
+ values follow the same normalisation path.
+
+ Raises
+ ------
+ GlpiValidationError
+ If a string value cannot be parsed as an integer (e.g.
+ ``GLPI_TIMEOUT=abc``).
+ TypeError
+ If ``value`` is neither ``None``, ``int``, nor ``str``.
+ """
+
+ if value is None:
+ return None
+ if isinstance(value, int):
+ return value
+ if isinstance(value, str):
+ try:
+ return int(value)
+ except ValueError as exc:
+ raise GlpiValidationError(
+ f"Invalid integer environment value: {value!r}"
+ ) from exc
+ raise TypeError("Integer environment values must be strings or integers")
+
+
+def parse_optional_env_bool(value: object, *, default: bool) -> bool:
+ """Parse one optional boolean from an environment-style value.
+
+ String values follow the conventional true and false spellings accepted
+ by the package configuration helpers, while ``None`` falls back to the
+ caller-provided default.
+ """
+
+ if value is None:
+ return default
+ if isinstance(value, bool):
+ return value
+ if not isinstance(value, str):
+ raise TypeError("Boolean environment values must be strings or booleans")
+ if value.casefold() in {"1", "true", "yes", "on"}:
+ return True
+ if value.casefold() in {"0", "false", "no", "off"}:
+ return False
+ raise GlpiValidationError(f"Invalid boolean environment value: {value!r}")
+
+
+def build_client_env_config(
+ *,
+ prefix: str,
+ env: Mapping[str, str],
+ overrides: Mapping[str, object],
+) -> dict[str, object]:
+ """Build common GLPI client config values from environment variables.
+
+ The returned mapping matches the constructor keyword arguments accepted
+ by :class:`GlpiClient`, making it suitable for direct unpacking.
+ """
+
+ config: dict[str, object] = {
+ "glpi_api_url": env.get(f"{prefix}API_URL"),
+ "client_id": env.get(f"{prefix}CLIENT_ID"),
+ "client_secret": env.get(f"{prefix}CLIENT_SECRET"),
+ "username": env.get(f"{prefix}USERNAME"),
+ "password": env.get(f"{prefix}PASSWORD"),
+ "glpi_entity": parse_optional_env_int(env.get(f"{prefix}ENTITY")),
+ "glpi_profile": parse_optional_env_int(env.get(f"{prefix}PROFILE")),
+ "entity_recursive": parse_optional_env_bool(
+ env.get(f"{prefix}ENTITY_RECURSIVE"),
+ default=False,
+ ),
+ "language": env.get(f"{prefix}LANGUAGE") or "en_GB",
+ "verify_ssl": parse_optional_env_bool(
+ env.get(f"{prefix}VERIFY_SSL"),
+ default=True,
+ ),
+ "auth_token_refresh": parse_optional_env_int(
+ env.get(f"{prefix}AUTH_TOKEN_REFRESH")
+ ),
+ "v1_base_url": env.get(f"{prefix}V1_BASE_URL"),
+ "v1_user_token": env.get(f"{prefix}V1_USER_TOKEN"),
+ "v1_app_token": env.get(f"{prefix}V1_APP_TOKEN"),
+ }
+ config.update(overrides)
+ return config
+
+
+def normalize_client_api_url(glpi_api_url: object, *, client_name: str) -> str:
+ """Validate and normalise the configured GLPI API base URL.
+
+ The helper rejects missing or non-string values early and strips a
+ trailing slash so endpoint assembly remains consistent across the
+ client codebase.
+ """
+
+ if not isinstance(glpi_api_url, str) or not glpi_api_url:
+ raise GlpiValidationError(f"{client_name} requires glpi_api_url")
+ return glpi_api_url.rstrip("/")
+
+
+def validate_v1_document_config(
+ *,
+ v1_base_url: str | None,
+ v1_user_token: str | None,
+) -> None:
+ """Validate the paired legacy v1 document configuration values.
+
+ Document uploads require both the legacy base URL and the user token.
+ The helper rejects partial configuration before a client is constructed.
+ """
+
+ if bool(v1_base_url) != bool(v1_user_token):
+ raise GlpiValidationError(
+ "GLPI v1 document support requires both v1_base_url and v1_user_token."
+ )
+
+
+__all__ = [
+ "DEFAULT_TIMEOUT_SECONDS",
+ "ClientResources",
+ "build_client_env_config",
+ "build_client_resources",
+ "build_http_session",
+ "normalize_client_api_url",
+ "parse_optional_env_bool",
+ "parse_optional_env_int",
+ "validate_v1_document_config",
+]
diff --git a/glpi_python_client/clients/commons/_constants.py b/glpi_python_client/_async/clients/commons/_constants.py
similarity index 100%
rename from glpi_python_client/clients/commons/_constants.py
rename to glpi_python_client/_async/clients/commons/_constants.py
diff --git a/glpi_python_client/clients/commons/_filters.py b/glpi_python_client/_async/clients/commons/_filters.py
similarity index 100%
rename from glpi_python_client/clients/commons/_filters.py
rename to glpi_python_client/_async/clients/commons/_filters.py
diff --git a/glpi_python_client/_async/clients/commons/_http.py b/glpi_python_client/_async/clients/commons/_http.py
new file mode 100644
index 0000000..765cb40
--- /dev/null
+++ b/glpi_python_client/_async/clients/commons/_http.py
@@ -0,0 +1,394 @@
+"""HTTP transport helpers shared across the GLPI v2 API mixins.
+
+The functions here normalise query parameters, build authenticated headers,
+assemble request URLs, and validate responses so the per-endpoint mixins
+can focus on resource-specific behaviour.
+"""
+
+from __future__ import annotations
+
+import logging
+from collections.abc import Mapping
+
+import httpx
+
+from glpi_python_client._async.clients.commons._constants import RequestParamValue
+from glpi_python_client._errors import (
+ GlpiProtocolError,
+ GlpiServerError,
+ GlpiTimeoutError,
+ GlpiTransportError,
+ status_error_class,
+)
+
+
+def transport_error_from(
+ exc: httpx.HTTPError,
+ *,
+ method: str,
+ url: str,
+) -> GlpiTransportError:
+ """Map one transport-level failure onto the library's public error type.
+
+ Network faults are the last part of the failure surface that still
+ escaped as third-party exceptions. Translating them here means callers
+ catch :class:`~glpi_python_client.GlpiError` and never have to import the
+ HTTP library, which is what :class:`~glpi_python_client.GlpiTransportError`
+ was reserved for.
+
+ It also removes a whole class of silent breakage. Retry predicates used
+ to name the HTTP library's own exception base; because those trees are
+ completely disjoint between libraries, swapping the transport without
+ editing every predicate made retries stop matching — silently, with no
+ error and a green test suite. Predicates now name this library-owned type
+ instead, so a future transport change cannot invalidate them.
+
+ Parameters
+ ----------
+ exc : httpx.HTTPError
+ The transport failure to translate.
+ method : str
+ HTTP verb, used only to build the message.
+ url : str
+ Absolute URL of the failed request, used only to build the message.
+
+ Returns
+ -------
+ GlpiTransportError
+ :class:`~glpi_python_client.GlpiTimeoutError` when the failure was a
+ timeout, otherwise :class:`~glpi_python_client.GlpiTransportError`.
+ The original exception should be attached with ``raise ... from exc``
+ by the caller.
+ """
+
+ error_class = (
+ GlpiTimeoutError
+ if isinstance(exc, httpx.TimeoutException)
+ else GlpiTransportError
+ )
+ return error_class(
+ f"GLPI {method.upper()} {url} failed: {type(exc).__name__}: {exc}"
+ )
+
+
+def response_reason(response: httpx.Response) -> str:
+ """Return one response's HTTP reason phrase, whatever the transport.
+
+ ``httpx`` spells this ``Response.reason_phrase``; ``requests`` spelled it
+ ``Response.reason``. Both spellings are probed so the helper keeps
+ working for the duck-typed response fakes in downstream test suites,
+ which were written against the older attribute name.
+
+ Parameters
+ ----------
+ response : httpx.Response
+ Response to read the reason phrase from. Typed against the current
+ transport; any object exposing either attribute works at runtime.
+
+ Returns
+ -------
+ str
+ The reason phrase, or ``""`` when the transport supplies none.
+ """
+
+ reason = getattr(response, "reason", None)
+ if reason is None:
+ reason = getattr(response, "reason_phrase", None)
+ return str(reason) if reason else ""
+
+
+def request_params(
+ params: dict[str, object] | None,
+) -> dict[str, RequestParamValue] | None:
+ """Normalise query parameters into transport-compatible values.
+
+ Each value is converted through :func:`request_param_value` so callers
+ can pass richer Python objects without repeating serialisation logic.
+
+ Keys whose value is ``None`` are **dropped** rather than forwarded. This
+ is deliberate and load-bearing: ``requests`` omitted such keys from the
+ query string entirely, whereas ``httpx`` encodes them as a valueless
+ ``key=``. Sending an empty value to GLPI is not a no-op — an empty filter
+ or search value is interpreted as "match everything", so forwarding the
+ key would silently widen a query instead of leaving it unconstrained.
+ Normalising here keeps the emitted query string identical across
+ transports.
+ """
+
+ if params is None:
+ return None
+ return {
+ key: request_param_value(value)
+ for key, value in params.items()
+ if value is not None
+ }
+
+
+def request_param_value(value: object) -> RequestParamValue:
+ """Normalise one query parameter value for the HTTP transport.
+
+ Values are rendered exactly as the previous ``requests``-based transport
+ rendered them, so the wire format does not depend on which HTTP library
+ is installed. Two conversions exist only to preserve that:
+
+ * ``bytes`` are decoded to text. ``httpx`` would otherwise stringify the
+ object itself and emit the Python repr (``b'x'``) rather than its
+ contents.
+ * ``bool`` is rendered ``"True"``/``"False"``. ``httpx`` renders booleans
+ lowercase; ``requests`` did not. This is checked before ``int``
+ because ``bool`` is a subclass of ``int``.
+ """
+
+ if value is None or isinstance(value, str):
+ return value
+ if isinstance(value, bytes):
+ return value.decode("utf-8", errors="replace")
+ if isinstance(value, bool):
+ return str(value)
+ if isinstance(value, int | float):
+ return value
+ return str(value)
+
+
+def require_access_token(access_token: str | None) -> str:
+ """Return a usable access token or raise when it is missing.
+
+ Transport helpers call this right before request dispatch so missing
+ token state turns into a clear local error instead of a malformed API
+ call.
+
+ Raises
+ ------
+ GlpiProtocolError
+ When ``access_token`` is empty or ``None``.
+ """
+
+ if not access_token:
+ raise GlpiProtocolError("Failed to acquire access token for API request")
+ return access_token
+
+
+def build_request_headers(
+ *,
+ access_token: str | None,
+ language: str,
+ glpi_entity: int | None,
+ glpi_profile: int | None,
+ entity_recursive: bool,
+ include_content_type: bool = False,
+ skip_entity: bool = False,
+) -> dict[str, str]:
+ """Build GLPI request headers from one client state snapshot.
+
+ The header set includes authorisation and language settings, with
+ optional entity, profile, recursion, and content-type headers derived
+ from the current client configuration.
+ """
+
+ headers = {
+ "Authorization": f"Bearer {access_token}",
+ "Accept": "application/json",
+ "Accept-Language": language,
+ }
+ if include_content_type:
+ headers["Content-Type"] = "application/json"
+ if not skip_entity:
+ if glpi_entity is not None:
+ headers["GLPI-Entity"] = str(glpi_entity)
+ if glpi_profile is not None:
+ headers["GLPI-Profile"] = str(glpi_profile)
+ if entity_recursive:
+ headers["GLPI-Entity-Recursive"] = "true"
+ return headers
+
+
+def build_request_url(glpi_api_url: str, endpoint: str) -> str:
+ """Return the absolute URL for one GLPI endpoint path.
+
+ Callers provide the normalised API base URL and the endpoint suffix
+ that is already specific to the requested resource.
+ """
+
+ return f"{glpi_api_url}/{endpoint}"
+
+
+def finalize_request_response(
+ response: httpx.Response,
+ *,
+ method: str,
+ url: str,
+ success_statuses: tuple[int, ...],
+ logger: logging.Logger,
+) -> httpx.Response:
+ """Validate one GLPI transport response and preserve warning behaviour.
+
+ Server errors are raised immediately while non-success statuses outside
+ the accepted set are logged for higher-level mutation and lookup helpers
+ to interpret consistently.
+
+ Raises
+ ------
+ GlpiServerError
+ When ``response.status_code`` is a 5xx server error.
+ """
+
+ method_name = method.upper()
+ if 500 <= response.status_code < 600:
+ message = (
+ f"GLPI {method_name} {url} failed with "
+ f"{response.status_code} {response_reason(response)}"
+ )
+ logger.warning(message)
+ raise GlpiServerError(
+ message,
+ status_code=response.status_code,
+ url=url,
+ response_text=response.text,
+ )
+ if response.status_code not in success_statuses:
+ logger.warning(
+ "GLPI %s %s returned %s: %s",
+ method_name,
+ url,
+ response.status_code,
+ response.text,
+ )
+ return response
+
+
+def ensure_response_status(
+ response: httpx.Response,
+ *,
+ success_statuses: tuple[int, ...],
+ failure_message: str,
+) -> None:
+ """Raise a typed :class:`GlpiStatusError` for an unexpected response status.
+
+ Higher-level client methods use this helper to keep their mutation and
+ fetch failure messages aligned across the per-endpoint mixins. The
+ raised class narrows to :class:`GlpiAuthError`, :class:`GlpiNotFoundError`
+ or :class:`GlpiServerError` where the status allows it.
+
+ Raises
+ ------
+ GlpiStatusError
+ When ``response.status_code`` is outside ``success_statuses``.
+ """
+
+ if response.status_code not in success_statuses:
+ error_class = status_error_class(response.status_code)
+ raise error_class(
+ f"{failure_message}: {response.status_code} {response.text}",
+ status_code=response.status_code,
+ url=str(response.url),
+ response_text=response.text,
+ )
+
+
+def response_json_or_empty(response: httpx.Response) -> object:
+ """Return the parsed JSON body or an empty mapping for empty responses.
+
+ Unlike :func:`response_json_mapping` this helper preserves list and
+ scalar payloads, so it suits callers that may receive either a JSON
+ object or a JSON array (for example the legacy v1 endpoints).
+ """
+
+ if not response.content or not response.text.strip():
+ return {}
+ return response.json()
+
+
+def response_json_mapping(response: httpx.Response) -> Mapping[str, object]:
+ """Return the JSON response payload as a mapping when possible.
+
+ Empty response bodies become an empty mapping and non-mapping JSON
+ payloads are intentionally ignored so callers can safely probe for
+ expected keys.
+ """
+
+ result = response.json() if response.content else {}
+ return result if isinstance(result, Mapping) else {}
+
+
+def require_response_int(
+ response: httpx.Response,
+ *,
+ keys: tuple[str, ...],
+ missing_message: str,
+) -> int:
+ """Return the first integer field from a JSON response mapping.
+
+ GLPI v2 create responses document numeric identifiers under a small
+ set of keys. Callers list the candidate keys explicitly so the
+ behaviour stays predictable.
+
+ Raises
+ ------
+ GlpiProtocolError
+ When none of ``keys`` maps to an integer value in the response.
+ """
+
+ result = response_json_mapping(response)
+ for key in keys:
+ value = result.get(key)
+ if isinstance(value, int) and not isinstance(value, bool):
+ return value
+ raise GlpiProtocolError(missing_message)
+
+
+def list_payload_items(payload: object) -> list[dict[str, object]]:
+ """Return dictionary items from one plain JSON list payload.
+
+ Non-list payloads are treated as empty so callers can safely use this
+ on responses whose shape may vary or fail validation upstream.
+ """
+
+ if not isinstance(payload, list):
+ return []
+ return [item for item in payload if isinstance(item, dict)]
+
+
+def unwrap_timeline_items(payload: object) -> list[dict[str, object]]:
+ """Return inner records from a GLPI timeline list payload.
+
+ Notes
+ -----
+ The OpenAPI contract documents
+ ``/Assistance/Ticket/{id}/Timeline/`` GET responses as flat
+ arrays of the subitem schema, but the live GLPI v2 server actually
+ returns each entry wrapped in an envelope of the form
+ ``{"type": "", "item": {...}}``. The helper unwraps that
+ envelope when present and falls back to the flat shape so it stays
+ compatible with both behaviours. Per the project rule that real
+ behaviour wins over the contract, the timeline list helpers call this
+ helper instead of :func:`list_payload_items`.
+ """
+
+ if not isinstance(payload, list):
+ return []
+ items: list[dict[str, object]] = []
+ for entry in payload:
+ if not isinstance(entry, dict):
+ continue
+ if "item" in entry and isinstance(entry["item"], dict):
+ items.append(entry["item"])
+ else:
+ items.append(entry)
+ return items
+
+
+__all__ = [
+ "build_request_headers",
+ "build_request_url",
+ "ensure_response_status",
+ "finalize_request_response",
+ "list_payload_items",
+ "request_param_value",
+ "request_params",
+ "require_access_token",
+ "require_response_int",
+ "response_json_mapping",
+ "response_json_or_empty",
+ "transport_error_from",
+ "unwrap_timeline_items",
+]
diff --git a/glpi_python_client/clients/commons/_payloads.py b/glpi_python_client/_async/clients/commons/_payloads.py
similarity index 100%
rename from glpi_python_client/clients/commons/_payloads.py
rename to glpi_python_client/_async/clients/commons/_payloads.py
diff --git a/glpi_python_client/_async/clients/commons/_transport.py b/glpi_python_client/_async/clients/commons/_transport.py
new file mode 100644
index 0000000..4303eea
--- /dev/null
+++ b/glpi_python_client/_async/clients/commons/_transport.py
@@ -0,0 +1,573 @@
+"""GLPI v2 transport mixin.
+
+The transport mixin owns token handling, header construction, retries, and
+HTTP request dispatch so the per-endpoint mixins under
+:mod:`glpi_python_client._async.clients.api` can stay focused on resource-specific
+behaviour.
+
+Concurrency model
+-----------------
+Access to the auth token manager is serialised with the lock from
+:mod:`glpi_python_client._async._concurrency`. That module is one of only
+two maintained separately for each surface, because the correct primitive
+genuinely differs: an :class:`asyncio.Lock` for concurrent tasks on one
+event loop, a :class:`threading.Lock` for a client shared across threads.
+Neither substitutes for the other -- see that module for what breaks in
+each direction.
+
+The lock is held only for the short critical section that refreshes the
+token. HTTP calls run outside it, so concurrent callers proceed in
+parallel while sharing one access token. The underlying HTTP client is
+safe for that concurrent use; it is built once at construction and never
+mutated afterwards.
+"""
+
+from __future__ import annotations
+
+import logging
+from collections.abc import Callable
+from typing import TYPE_CHECKING, Any, TypeVar
+
+import httpx
+from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
+
+from glpi_python_client._async._concurrency import Lock
+from glpi_python_client._async.clients.commons._http import (
+ build_request_headers,
+ build_request_url,
+ ensure_response_status,
+ finalize_request_response,
+ list_payload_items,
+ request_params,
+ require_access_token,
+ require_response_int,
+ transport_error_from,
+ unwrap_timeline_items,
+)
+from glpi_python_client._async.clients.commons._payloads import (
+ model_from_payload,
+ model_to_payload,
+)
+from glpi_python_client._errors import GlpiServerError, GlpiTransportError
+from glpi_python_client.models._base import GlpiModel
+
+if TYPE_CHECKING:
+ from glpi_python_client._async.auth._v1_session import GLPIV1Session
+ from glpi_python_client._async.auth.auth import GLPITokenManager
+
+logger = logging.getLogger(__name__)
+
+ModelT = TypeVar("ModelT", bound=GlpiModel)
+
+#: Shared retry policy for every v2 transport verb.
+#:
+#: Declared once rather than repeated on each of the four verb helpers, and
+#: expressed entirely in library-owned exception types. Both parts are
+#: deliberate. A predicate that names the HTTP library's own exception base
+#: stops matching the moment the transport is swapped — the exception trees of
+#: the different libraries are completely disjoint — and retries then vanish
+#: with no error, no warning and a green test suite. Naming
+#: :class:`~glpi_python_client.GlpiTransportError`, which
+#: :func:`~glpi_python_client._async.clients.commons._http.transport_error_from`
+#: guarantees every network fault is translated into, makes that failure
+#: impossible to reintroduce.
+_RETRY_ON_NETWORK_ERRORS = retry(
+ retry=retry_if_exception_type((GlpiTransportError, GlpiServerError)),
+ stop=stop_after_attempt(3),
+ wait=wait_fixed(3),
+ reraise=True,
+)
+
+
+class TransportMixin:
+ """GLPI API transport helpers shared by the API mixins.
+
+ The class declares the runtime attributes the concrete client owns and
+ exposes the blocking ``_get_request``, ``_post_request``,
+ ``_update_request`` and ``_delete_request`` helpers used by every
+ per-endpoint mixin.
+
+ Thread safety
+ -------------
+ Token acquisition and refresh are serialised by ``_auth_lock``, the
+ ``Lock`` from :mod:`glpi_python_client._async._concurrency`, so
+ concurrent callers never race while updating shared authentication
+ state. That module is hand-written on both surfaces because the
+ right primitive differs in kind between them. HTTP dispatch runs
+ outside the lock and relies on the underlying httpx client being
+ safe to use from concurrent callers.
+ """
+
+ _auth: GLPITokenManager
+ _auth_lock: Lock
+ _closed: bool = False
+ _session: httpx.AsyncClient
+ _v1: GLPIV1Session | None
+ entity_recursive: bool
+ glpi_api_url: str
+ glpi_entity: int | None
+ glpi_profile: int | None
+ language: str
+
+ def _ensure_open(self) -> None:
+ """Raise when the client has already been closed.
+
+ All transport helpers call this guard before touching the shared
+ HTTP session so closed clients fail fast and predictably.
+ """
+
+ if self._closed:
+ raise RuntimeError("GLPI client is closed")
+
+ def _require_v1_session(self, feature: str) -> GLPIV1Session:
+ """Return the configured v1 session or raise ``RuntimeError``.
+
+ Parameters
+ ----------
+ feature : str
+ Short label of the caller (for example ``"document upload"``
+ or ``"Fields plugin helpers"``) embedded in the error message
+ so users learn which client option to set.
+
+ Returns
+ -------
+ GLPIV1Session
+ The legacy v1 session bundled with the client.
+
+ Raises
+ ------
+ RuntimeError
+ When the client was built without ``v1_base_url`` and
+ ``v1_user_token``.
+ """
+
+ if self._v1 is None:
+ raise RuntimeError(
+ f"GLPI {feature} require the legacy v1 session to be configured "
+ "(set v1_base_url and v1_user_token)."
+ )
+ return self._v1
+
+ async def _ensure_token(self) -> None:
+ """Ensure that a valid GLPI access token is available.
+
+ Token refresh is serialised by ``_auth_lock`` so concurrent
+ callers from any thread never race while updating shared
+ authentication state.
+ """
+
+ self._ensure_open()
+ async with self._auth_lock:
+ await self._auth.ensure_token()
+
+ async def _send_request(
+ self,
+ method: str,
+ url: str,
+ **kwargs: Any,
+ ) -> httpx.Response:
+ """Dispatch one blocking HTTP call.
+
+ The helper exists as an indirection seam so tests can stub HTTP
+ dispatch without monkey-patching the session attribute directly.
+
+ Dispatch goes through ``session.request(method, url, ...)`` rather
+ than looking up a per-verb attribute, keeping the verb a value
+ instead of an attribute name.
+
+ Transport-level failures are translated into
+ :class:`~glpi_python_client.GlpiTransportError` (or
+ :class:`~glpi_python_client.GlpiTimeoutError`) here, at the single
+ point where the HTTP library is actually called, so no third-party
+ exception escapes into the caller's ``except`` clauses.
+
+ Raises
+ ------
+ GlpiTransportError
+ When the request never produced a response.
+ """
+
+ try:
+ return await self._session.request(method.upper(), url, **kwargs)
+ except httpx.HTTPError as exc:
+ raise transport_error_from(exc, method=method, url=url) from exc
+
+ async def _execute_request(
+ self,
+ *,
+ method: str,
+ endpoint: str,
+ success_statuses: tuple[int, ...],
+ params: dict[str, object] | None = None,
+ json_body: dict[str, object] | None = None,
+ skip_entity: bool = False,
+ include_content_type: bool = False,
+ ) -> httpx.Response:
+ """Execute one authenticated GLPI request.
+
+ The helper normalises the endpoint URL, headers, timeout, and
+ payload placement before dispatching the blocking HTTP call. It
+ guarantees a fresh access token before the request leaves the
+ process.
+ """
+
+ await self._ensure_token()
+ access_token = require_access_token(self._auth.access_token)
+ url = build_request_url(self.glpi_api_url, endpoint)
+
+ request_kwargs: dict[str, object] = {
+ "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=include_content_type,
+ skip_entity=skip_entity,
+ ),
+ "timeout": 30,
+ }
+ if method == "get":
+ request_kwargs["params"] = request_params(params)
+ else:
+ request_kwargs["json"] = json_body
+
+ response = await self._send_request(method, url, **request_kwargs)
+ return finalize_request_response(
+ response,
+ method=method,
+ url=url,
+ success_statuses=success_statuses,
+ logger=logger,
+ )
+
+ @_RETRY_ON_NETWORK_ERRORS
+ async def _get_request(
+ self,
+ endpoint: str,
+ params: dict[str, object] | None = None,
+ skip_entity: bool = False,
+ ) -> httpx.Response:
+ """Execute one authenticated GLPI ``GET`` request.
+
+ Network errors (:class:`~glpi_python_client.GlpiTransportError`) and 5xx
+ responses (:class:`~glpi_python_client.GlpiServerError`) are
+ retried up to 3 times, with ``reraise=True`` so the real error
+ propagates once retries are exhausted; 4xx responses are
+ returned as-is without a retry.
+ """
+
+ return await self._execute_request(
+ method="get",
+ endpoint=endpoint,
+ success_statuses=(200, 206),
+ params=params,
+ skip_entity=skip_entity,
+ )
+
+ @_RETRY_ON_NETWORK_ERRORS
+ async def _post_request(
+ self,
+ endpoint: str,
+ json_body: dict[str, object] | None = None,
+ skip_entity: bool = False,
+ ) -> httpx.Response:
+ """Execute one authenticated GLPI ``POST`` request.
+
+ JSON request bodies automatically include the content-type header
+ needed by the GLPI API.
+ """
+
+ return await self._execute_request(
+ method="post",
+ endpoint=endpoint,
+ success_statuses=(200, 201),
+ json_body=json_body,
+ skip_entity=skip_entity,
+ include_content_type=True,
+ )
+
+ @_RETRY_ON_NETWORK_ERRORS
+ async def _update_request(
+ self,
+ endpoint: str,
+ json_body: dict[str, object] | None = None,
+ ) -> httpx.Response:
+ """Execute one authenticated GLPI ``PATCH`` request.
+
+ The helper uses the same authenticated execution path as the
+ other HTTP verbs while targeting the success codes expected from
+ update calls.
+ """
+
+ return await self._execute_request(
+ method="patch",
+ endpoint=endpoint,
+ success_statuses=(200, 204),
+ json_body=json_body,
+ include_content_type=True,
+ )
+
+ @_RETRY_ON_NETWORK_ERRORS
+ async def _delete_request(
+ self,
+ endpoint: str,
+ json_body: dict[str, object] | None = None,
+ skip_entity: bool = False,
+ ) -> httpx.Response:
+ """Execute one authenticated GLPI ``DELETE`` request.
+
+ Some delete endpoints accept a JSON body, so the content-type
+ header is enabled automatically when a body is supplied.
+ """
+
+ return await self._execute_request(
+ method="delete",
+ endpoint=endpoint,
+ success_statuses=(200, 204),
+ json_body=json_body,
+ skip_entity=skip_entity,
+ include_content_type=json_body is not None,
+ )
+
+ async def _resource_list(
+ self,
+ endpoint: str,
+ model: type[ModelT],
+ *,
+ params: dict[str, object] | None = None,
+ skip_entity: bool = False,
+ failure_message: str | None = None,
+ success_statuses: tuple[int, ...] = (200, 206),
+ unwrap_envelope: bool = False,
+ ) -> list[ModelT]:
+ """Run a GLPI list/search request and validate every returned record.
+
+ Parameters
+ ----------
+ endpoint : str
+ Resource path forwarded to the transport ``GET`` helper.
+ model : type[ModelT]
+ Pydantic class used to validate each item from the response.
+ params : dict[str, object] | None, optional
+ Query parameters forwarded to the underlying ``GET`` request.
+ 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``.
+ success_statuses : tuple[int, ...], optional
+ HTTP status codes considered successful when
+ ``failure_message`` is set.
+ unwrap_envelope : bool, optional
+ When ``True`` the GLPI timeline ``{"type", "item"}`` envelope
+ is unwrapped before validation.
+
+ Returns
+ -------
+ list[ModelT]
+ Validated records returned by the GLPI server.
+ """
+
+ 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,
+ )
+ payload = response.json()
+ items = (
+ unwrap_timeline_items(payload)
+ if unwrap_envelope
+ else list_payload_items(payload)
+ )
+ return [model_from_payload(model, item) for item in items]
+
+ async def _resource_get(
+ self,
+ endpoint: str,
+ model: type[ModelT],
+ *,
+ failure_message: str,
+ skip_entity: bool = False,
+ ) -> ModelT:
+ """Fetch one record and validate it against ``model``.
+
+ Parameters
+ ----------
+ endpoint : str
+ Resource path forwarded to the transport ``GET`` helper.
+ model : type[ModelT]
+ Pydantic class used to validate the response payload.
+ failure_message : str
+ Message embedded in the ``GlpiStatusError`` raised on a
+ non-success HTTP status.
+ skip_entity : bool, optional
+ When ``True`` the ``GLPI-Entity`` header is omitted.
+
+ Returns
+ -------
+ ModelT
+ Validated record returned by the GLPI server.
+ """
+
+ response = await self._get_request(endpoint, skip_entity=skip_entity)
+ ensure_response_status(
+ response,
+ success_statuses=(200, 206),
+ failure_message=failure_message,
+ )
+ return model_from_payload(model, response.json())
+
+ async def _resource_create(
+ self,
+ endpoint: str,
+ body_model: GlpiModel,
+ *,
+ failure_message: str,
+ missing_message: str,
+ log_message_factory: Callable[[int], str],
+ id_keys: tuple[str, ...] = ("id",),
+ skip_entity: bool = False,
+ ) -> int:
+ """Create one record and return the identifier assigned by GLPI.
+
+ Parameters
+ ----------
+ endpoint : str
+ Resource path forwarded to the transport ``POST`` helper.
+ body_model : GlpiModel
+ Pydantic body serialised through :func:`model_to_payload`.
+ failure_message : str
+ Message embedded in the ``GlpiStatusError`` raised on a
+ non-success HTTP status.
+ missing_message : str
+ Message embedded in the ``GlpiProtocolError`` raised when the
+ response payload does not contain any of the expected
+ identifier keys.
+ log_message_factory : Callable[[int], str]
+ Callable invoked with the new identifier to build the
+ ``logger.info`` payload, allowing call sites to embed the
+ parent context (for example a ticket id).
+ id_keys : tuple[str, ...], optional
+ Candidate keys probed in the response when looking up the
+ identifier of the newly created record.
+ skip_entity : bool, optional
+ When ``True`` the ``GLPI-Entity`` header is omitted.
+
+ Returns
+ -------
+ int
+ Numeric identifier assigned by the GLPI server.
+ """
+
+ response = await self._post_request(
+ endpoint, model_to_payload(body_model), skip_entity=skip_entity
+ )
+ ensure_response_status(
+ response,
+ success_statuses=(200, 201),
+ failure_message=failure_message,
+ )
+ new_id = require_response_int(
+ response, keys=id_keys, missing_message=missing_message
+ )
+ logger.info("%s", log_message_factory(new_id))
+ return new_id
+
+ async def _resource_update(
+ self,
+ endpoint: str,
+ body_model: GlpiModel,
+ *,
+ failure_message: str,
+ log_message: str,
+ ) -> None:
+ """Patch one record and emit the standard log line on success.
+
+ Parameters
+ ----------
+ endpoint : str
+ Resource path forwarded to the transport ``PATCH`` helper.
+ body_model : GlpiModel
+ Partial Pydantic body serialised through
+ :func:`model_to_payload`.
+ failure_message : str
+ Message embedded in the ``GlpiStatusError`` raised on a
+ non-success HTTP status.
+ log_message : str
+ Pre-formatted message logged at ``INFO`` level on success.
+
+ Returns
+ -------
+ None
+ """
+
+ response = await self._update_request(endpoint, model_to_payload(body_model))
+ ensure_response_status(
+ response,
+ success_statuses=(200, 204),
+ failure_message=failure_message,
+ )
+ logger.info("%s", log_message)
+
+ async def _resource_delete(
+ self,
+ endpoint: str,
+ *,
+ failure_message: str,
+ log_message: str,
+ force: bool | None = None,
+ delete_model_cls: type[GlpiModel] | None = None,
+ body: dict[str, object] | None = None,
+ skip_entity: bool = False,
+ ) -> None:
+ """Delete one record and emit the standard log line on success.
+
+ Parameters
+ ----------
+ endpoint : str
+ Resource path forwarded to the transport ``DELETE`` helper.
+ failure_message : str
+ Message embedded in the ``GlpiStatusError`` raised on a
+ non-success HTTP status.
+ log_message : str
+ Pre-formatted message logged at ``INFO`` level on success.
+ force : bool | None, optional
+ When ``True`` and ``delete_model_cls`` is provided, a
+ ``{"force": True}`` body is sent to permanently delete the
+ record. ``None`` omits the field altogether.
+ delete_model_cls : type[GlpiModel] | None, optional
+ Pydantic class instantiated with ``force`` to build the
+ optional delete body.
+ body : dict[str, object] | None, optional
+ Pre-built request body forwarded as-is when supplied.
+ Mutually exclusive with the ``force``/``delete_model_cls``
+ pair.
+ skip_entity : bool, optional
+ When ``True`` the ``GLPI-Entity`` header is omitted.
+
+ Returns
+ -------
+ None
+ """
+
+ 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]
+ response = await self._delete_request(
+ endpoint, request_body, skip_entity=skip_entity
+ )
+ ensure_response_status(
+ response,
+ success_statuses=(200, 204),
+ failure_message=failure_message,
+ )
+ logger.info("%s", log_message)
+
+
+__all__ = ["TransportMixin"]
diff --git a/glpi_python_client/_async/clients/custom/__init__.py b/glpi_python_client/_async/clients/custom/__init__.py
new file mode 100644
index 0000000..a0d0e90
--- /dev/null
+++ b/glpi_python_client/_async/clients/custom/__init__.py
@@ -0,0 +1,23 @@
+"""Higher-level helpers built on top of the API mixins.
+
+The custom package exposes operations the GLPI API contract does not
+advertise directly but which client applications need: the aggregated
+ticket-context view and the reporting helpers, both assembled from the
+contract-aligned CRUD helpers in
+:mod:`glpi_python_client._async.clients.api`.
+
+Each helper is written once. The fan-out points call ``gather`` from
+:mod:`glpi_python_client._async._concurrency`, which runs them
+concurrently here and sequentially in the generated tree -- so there is
+no second copy of this logic to keep in step.
+"""
+
+from __future__ import annotations
+
+from glpi_python_client._async.clients.custom._statistics import StatisticsMixin
+from glpi_python_client._async.clients.custom._ticket_context import TicketContextMixin
+
+__all__ = [
+ "StatisticsMixin",
+ "TicketContextMixin",
+]
diff --git a/glpi_python_client/_async/clients/custom/_statistics.py b/glpi_python_client/_async/clients/custom/_statistics.py
new file mode 100644
index 0000000..e3e075f
--- /dev/null
+++ b/glpi_python_client/_async/clients/custom/_statistics.py
@@ -0,0 +1,973 @@
+"""Lightweight statistics helpers built from the API mixins.
+
+The mixin exposes simple aggregations over ticket and ticket-task results
+returned by the contract-aligned helpers in
+:mod:`glpi_python_client._async.clients.api`. These operations are intentionally
+kept small and do not perform name resolution or rich label formatting; the
+caller can correlate the returned numeric identifiers with the dedicated
+``search_*`` helpers when required.
+"""
+
+from __future__ import annotations
+
+from collections import defaultdict
+from datetime import date, timedelta
+from typing import TypedDict
+
+from glpi_python_client._async.clients.commons._filters import (
+ rsql_all_filter,
+ rsql_any_filter,
+ rsql_contains_filter,
+)
+from glpi_python_client._async.clients.commons._transport import TransportMixin
+from glpi_python_client._errors import GlpiValidationError
+from glpi_python_client.models.api_schema._common import (
+ IdNameCompletenameRef,
+ IdNameRef,
+)
+from glpi_python_client.models.api_schema.assistance._ticket import GetTicket
+from glpi_python_client.models.api_schema.assistance.timeline._task import (
+ GetTicketTask,
+)
+from glpi_python_client.models.api_schema.enums import (
+ GlpiPriority,
+ GlpiTicketType,
+)
+
+#: The GLPI v2 ticket search includes soft-deleted ("trashed") tickets by
+#: default, while the v1 search excludes them. Every aggregation here is
+#: about live work, so the v2 queries pin the flag explicitly. Measured on
+#: a live GLPI 11 instance: 59,690 live + 258 trashed = 59,948 unfiltered,
+#: and for some users the trashed rows were the large majority of matches.
+_LIVE_TICKETS = "is_deleted==false"
+
+#: v1 ``search/Ticket`` searchOption ids. The v2 API exposes no filterable
+#: assignee at all -- its ``team`` array cannot be joined by the RSQL engine
+#: (the contract-declared subfields answer HTTP 500 and every other spelling
+#: is silently ignored) -- so actor-based selection has to go through v1.
+_V1_SO_TICKET_ID = 2
+_V1_SO_REQUESTER = 4 # "Demandeur" -- glpi_tickets_users.users_id, type=1
+_V1_SO_ASSIGNEE = 5 # "Technicien" -- glpi_tickets_users.users_id, type=2
+
+#: v1 rejects a ``range`` that starts past the end of the result set with
+#: HTTP 400, so paging is bounded by ``totalcount`` rather than by probing.
+_V1_SEARCH_PAGE_SIZE = 1000
+
+#: Rows fetched per page from the v1 ``TicketTask`` collection.
+_V1_TASK_PAGE_SIZE = 1000
+
+#: Above this many tickets, one bulk v1 task sweep beats a per-ticket v2
+#: request each. The sweep costs one page per 1000 tasks created since the
+#: window opened -- typically one or two -- while the per-ticket path costs
+#: exactly ``len(ticket_ids)`` requests. Below the threshold the per-ticket
+#: path is cheaper and needs no v1 session, so it stays the default.
+_V1_TASK_BULK_THRESHOLD = 25
+
+
+def _validate_actor_id(value: int, parameter: str) -> int:
+ """Return ``value`` when it is usable as a GLPI user identifier.
+
+ The v1 search engine fails *open* on a malformed actor value instead of
+ rejecting it, so a bad id yields a plausible-looking but meaningless
+ result set rather than an error. Measured on a live instance:
+ ``equals 0`` matched 20,905 tickets (a LEFT-JOIN-NULL "has no actor"
+ match), an empty value matched the entire 59,689-ticket baseline, and a
+ non-numeric value returned the same arbitrary 3 rows whatever the
+ string. Guarding at the boundary is what keeps this fix from
+ reintroducing the class of bug it exists to remove.
+
+ Parameters
+ ----------
+ value : int
+ Candidate GLPI user identifier.
+ parameter : str
+ Name of the public parameter, used in the error message.
+
+ Returns
+ -------
+ int
+ The validated identifier.
+
+ Raises
+ ------
+ GlpiValidationError
+ When ``value`` is not a positive integer. ``bool`` is rejected
+ explicitly because it is an ``int`` subclass in Python.
+ """
+
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
+ raise GlpiValidationError(
+ f"{parameter} must be a positive integer GLPI user id; got "
+ f"{value!r}. GLPI's v1 search silently returns unrelated rows "
+ "for 0, empty or non-numeric actor values instead of failing."
+ )
+ return value
+
+
+class TaskStatisticsResult(TypedDict):
+ """Typed shape returned by :meth:`StatisticsMixin.get_task_statistics`."""
+
+ ticket_count: int
+ task_count: int
+ total_duration: int
+ duration_by_user: dict[str, int]
+ duration_by_ticket: dict[int, int]
+
+
+class TaskDurationsResult(TypedDict):
+ """Typed shape returned by :meth:`StatisticsMixin.get_task_durations`."""
+
+ start_date: str
+ end_date: str
+ total_duration: int
+ task_count: int
+ duration_by_user: dict[str, int]
+ duration_by_entity: dict[str, int]
+ tasks: list[dict[str, object]] | None
+
+
+class UserActivityEntry(TypedDict):
+ """One per-user activity bucket inside :class:`UserActivityResult`."""
+
+ user_ids: list[int]
+ tickets_as_technician: int
+ tickets_as_recipient: int
+ task_durations: TaskDurationsResult
+
+
+class UserActivityResult(TypedDict):
+ """Typed shape returned by :meth:`StatisticsMixin.get_user_activity`."""
+
+ users: dict[str, UserActivityEntry]
+
+
+class StatisticsMixin(TransportMixin):
+ """Custom statistics built on the contract API mixins."""
+
+ async def _v1_ticket_ids_for_actor(
+ self, user_id: int, *, search_options: tuple[int, ...], parameter: str
+ ) -> set[int]:
+ """Return ids of tickets linking ``user_id`` under any given role.
+
+ Actor selection cannot be expressed in the v2 API, so this reads the
+ v1 search engine, OR-ing one criterion per requested searchOption.
+ Unlike v2 -- which silently ignores a filter field it does not know
+ and answers with the complete unfiltered set -- v1 rejects an
+ unknown searchOption with HTTP 400, so a mistake here fails loudly.
+
+ Parameters
+ ----------
+ user_id : int
+ GLPI user identifier; validated by :func:`_validate_actor_id`.
+ search_options : tuple[int, ...]
+ v1 searchOption ids to OR together, e.g.
+ ``(_V1_SO_ASSIGNEE, _V1_SO_REQUESTER)``.
+ parameter : str
+ Public parameter name quoted in validation errors.
+
+ Returns
+ -------
+ set[int]
+ Ticket identifiers visible to the configured v1 session.
+
+ Raises
+ ------
+ GlpiValidationError
+ When ``user_id`` is not a positive integer.
+ RuntimeError
+ When the client has no v1 session configured.
+ """
+
+ uid = _validate_actor_id(user_id, parameter)
+ v1 = self._require_v1_session("actor-based ticket statistics")
+
+ params: dict[str, object] = {"forcedisplay[0]": _V1_SO_TICKET_ID}
+ for index, option in enumerate(search_options):
+ if index:
+ params[f"criteria[{index}][link]"] = "OR"
+ params[f"criteria[{index}][field]"] = option
+ params[f"criteria[{index}][searchtype]"] = "equals"
+ params[f"criteria[{index}][value]"] = uid
+
+ ids: set[int] = set()
+ start = 0
+ while True:
+ page = dict(params)
+ page["range"] = f"{start}-{start + _V1_SEARCH_PAGE_SIZE - 1}"
+ payload = await v1.request_json("GET", "search/Ticket", params=page)
+ if not isinstance(payload, dict):
+ break
+ rows = payload.get("data")
+ if isinstance(rows, list):
+ for row in rows:
+ if not isinstance(row, dict):
+ continue
+ raw = row.get(str(_V1_SO_TICKET_ID))
+ if isinstance(raw, bool) or not isinstance(raw, (int, str)):
+ continue
+ try:
+ ids.add(int(raw))
+ except (TypeError, ValueError):
+ continue
+ total = payload.get("totalcount")
+ # Bound by totalcount: asking for a range that starts past the
+ # end is an HTTP 400 on this API, not an empty page.
+ if not isinstance(total, int):
+ break
+ start += _V1_SEARCH_PAGE_SIZE
+ if start >= total:
+ break
+ return ids
+
+ async def _v1_task_statistics(
+ self, ticket_ids: list[int], *, since: date
+ ) -> TaskStatisticsResult:
+ """Aggregate tasks for ``ticket_ids`` with one bulk v1 sweep.
+
+ Replaces the per-ticket fan-out for large ticket sets. The v2 API
+ publishes tasks only under ``/Assistance/Ticket/{id}/Timeline/Task``,
+ so aggregating N tickets costs N requests; the v1 ``TicketTask``
+ collection returns whole rows -- including ``tickets_id`` -- and
+ pages 1000 at a time.
+
+ Note that v1 ``search/TicketTask`` is *not* usable here: its
+ searchOptions expose the task's own id, content, category, date,
+ privacy, technician, duration and state, but no parent ticket id,
+ so results could not be attributed back to a ticket.
+
+ Rows are swept newest-first and paging stops once a page predates
+ ``since``. A task cannot be created before the ticket it belongs to,
+ and every ticket under consideration was created on or after
+ ``since``, so no relevant task is missed. The upper end is
+ deliberately unbounded: a ticket created inside the window may still
+ gain tasks long afterwards.
+
+ The returned aggregate is identical to :meth:`get_task_statistics`
+ for the same tickets -- v1 ``actiontime`` is v2 ``duration``, and v1
+ ``users_id`` is the v2 task ``user`` (the author; the technician
+ lives in ``users_id_tech``, which v2 does not expose). Rows are
+ mapped into ``GetTicketTask`` and summarised by the same helper, so
+ the two paths cannot drift apart.
+
+ Parameters
+ ----------
+ ticket_ids : list[int]
+ Tickets whose tasks should be aggregated.
+ since : date
+ Lower bound on task creation; the start of the caller's window.
+
+ Returns
+ -------
+ TaskStatisticsResult
+ Same shape and keys as :meth:`get_task_statistics`.
+
+ Raises
+ ------
+ RuntimeError
+ When the client has no v1 session configured.
+ """
+
+ v1 = self._require_v1_session("bulk task statistics")
+ wanted = set(ticket_ids)
+ cutoff = since.isoformat()
+ tasks: list[GetTicketTask] = []
+ start = 0
+ while True:
+ payload = await v1.request_json(
+ "GET",
+ "TicketTask",
+ params={
+ "range": f"{start}-{start + _V1_TASK_PAGE_SIZE - 1}",
+ "sort": "date_creation",
+ "order": "DESC",
+ },
+ )
+ if not isinstance(payload, list) or not payload:
+ break
+ oldest_seen: str | None = None
+ for row in payload:
+ if not isinstance(row, dict):
+ continue
+ created = row.get("date_creation")
+ if isinstance(created, str) and created:
+ oldest_seen = created
+ ticket_id = row.get("tickets_id")
+ if not isinstance(ticket_id, int) or ticket_id not in wanted:
+ continue
+ author = row.get("users_id")
+ duration = row.get("actiontime")
+ tasks.append(
+ GetTicketTask(
+ id=row.get("id") if isinstance(row.get("id"), int) else None,
+ tickets_id=ticket_id,
+ duration=duration if isinstance(duration, int) else 0,
+ user=(
+ IdNameRef(id=author)
+ if isinstance(author, int) and author
+ else None
+ ),
+ )
+ )
+ if len(payload) < _V1_TASK_PAGE_SIZE:
+ break
+ if oldest_seen is not None and oldest_seen[:10] < cutoff:
+ break
+ start += _V1_TASK_PAGE_SIZE
+ return _summarize_tasks(ticket_ids, tasks)
+
+ async def get_ticket_statistics(
+ self,
+ *,
+ start_date: str | None = None,
+ end_date: str | None = None,
+ default_days: int = 30,
+ entity_id: int | None = None,
+ entity_name: str | None = None,
+ extra_filter: str | None = None,
+ ) -> dict[str, object]:
+ """Return ticket counts grouped by entity, status, priority, and type.
+
+ The date window is applied to the GLPI ``date_creation`` field
+ and results are aggregated locally in Python. Returned
+ identifiers are the raw GLPI numeric values that callers can
+ resolve with the dedicated ``search_*`` helpers when human
+ labels are needed.
+
+ Parameters
+ ----------
+ start_date : str | None, optional
+ ISO ``YYYY-MM-DD`` start of the window (inclusive from
+ 00:00:00). Defaults to ``end_date - default_days + 1``
+ when omitted.
+ end_date : str | None, optional
+ ISO ``YYYY-MM-DD`` end of the window (inclusive through
+ 23:59:59). Defaults to today.
+ default_days : int, optional
+ Span in days used when ``start_date`` is omitted (defaults
+ to 30 and must be a positive integer).
+ entity_id : int | None, optional
+ When provided, restricts results to tickets belonging to the
+ entity with this GLPI identifier.
+ entity_name : str | None, optional
+ When provided (and ``entity_id`` is ``None``), the name is
+ resolved via ``search_entities`` and the matched entity IDs
+ are used to filter tickets. If no entity matches,
+ ``{"entities": {}}`` is returned immediately.
+ extra_filter : str | None, optional
+ Optional raw RSQL fragment to ``AND`` with the date window
+ on the server side.
+
+ Returns
+ -------
+ dict[str, object]
+ Mapping with one ``entities`` key listing per-entity
+ aggregates. Each entity bucket exposes ``total``,
+ ``by_status``, ``by_priority``, and ``by_type`` counters.
+
+ Raises
+ ------
+ GlpiValidationError
+ If ``default_days < 1``, ``start_date`` / ``end_date`` is not a
+ valid ISO date, or ``start_date`` is after ``end_date``.
+ """
+
+ start, end = _resolve_window(
+ start_date=start_date,
+ end_date=end_date,
+ default_days=default_days,
+ )
+
+ entity_filter: str | None = None
+ if entity_id is not None:
+ 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,
+ )
+ if not entities:
+ return {"entities": {}}
+ 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"
+ query = rsql_all_filter(
+ date_filter,
+ entity_filter,
+ _LIVE_TICKETS,
+ extra_filter,
+ )
+ tickets: list[GetTicket] = await self.search_tickets( # type: ignore[attr-defined]
+ rsql_filter=query or "",
+ limit=200,
+ )
+ return _summarize_tickets(tickets)
+
+ async def get_task_statistics(
+ self,
+ ticket_ids: list[int],
+ ) -> TaskStatisticsResult:
+ """Return task duration totals grouped by user and ticket.
+
+ The helper expects a list of ticket identifiers because GLPI
+ does not publish a global task collection endpoint. Callers
+ typically gather the relevant ticket identifiers through
+ ``search_tickets`` first.
+
+ Parameters
+ ----------
+ ticket_ids : list[int]
+ Identifiers of the tickets whose tasks should be aggregated.
+ An empty list returns zeroed totals without any HTTP call.
+
+ Returns
+ -------
+ TaskStatisticsResult
+ Mapping with ``ticket_count``, ``task_count``,
+ ``total_duration``, ``duration_by_user``, and
+ ``duration_by_ticket`` entries (durations are integer
+ seconds, matching the GLPI ``duration`` field).
+ """
+
+ if not ticket_ids:
+ return TaskStatisticsResult(
+ ticket_count=0,
+ task_count=0,
+ total_duration=0,
+ duration_by_user={},
+ duration_by_ticket={},
+ )
+
+ results: list[list[GetTicketTask]] = [
+ await self.list_ticket_tasks(ticket_id) # type: ignore[attr-defined]
+ for ticket_id in ticket_ids
+ ]
+ flattened: list[GetTicketTask] = [task for batch in results for task in batch]
+ return _summarize_tasks(ticket_ids, flattened)
+
+ async def get_task_durations(
+ self,
+ *,
+ start_date: str | None = None,
+ end_date: str | None = None,
+ default_days: int = 30,
+ entity_id: int | None = None,
+ entity_name: str | None = None,
+ user_id: int | None = None,
+ user_editor_id: int | None = None,
+ user_recipient_id: int | None = None,
+ extra_filter: str | None = None,
+ return_task_details: bool = False,
+ ) -> TaskDurationsResult:
+ """Return task duration totals with optional per-task detail.
+
+ Builds an RSQL filter from the supplied parameters, collects all
+ matching tickets by iterating :meth:`iter_search_tickets`, computes
+ ``duration_by_entity`` by grouping :meth:`get_task_statistics`
+ results against the per-ticket entity map, and optionally returns a
+ flat list of individual task records.
+
+ Parameters
+ ----------
+ start_date : str | None, optional
+ ISO ``YYYY-MM-DD`` start of the window (inclusive from
+ 00:00:00). Defaults to ``end_date - default_days + 1``
+ when omitted.
+ end_date : str | None, optional
+ ISO ``YYYY-MM-DD`` end of the window (inclusive through
+ 23:59:59). Defaults to today.
+ default_days : int, optional
+ Span in days used when ``start_date`` is omitted (defaults
+ to 30 and must be a positive integer).
+ entity_id : int | None, optional
+ Restrict to tickets in this entity.
+ entity_name : str | None, optional
+ Resolve entity by name and restrict to matched entities
+ (ignored when ``entity_id`` is given).
+ user_id : int | None, optional
+ Restrict to tickets where the user is an assignee or
+ requester (OR semantics across both roles).
+ user_editor_id : int | None, optional
+ Restrict to tickets last updated by this user.
+ user_recipient_id : int | None, optional
+ Restrict to tickets where this user is the requester.
+ extra_filter : str | None, optional
+ Optional raw RSQL fragment appended as an AND clause.
+ return_task_details : bool, optional
+ When ``True``, include a ``tasks`` list of individual task
+ records in the returned mapping (default ``False``).
+
+ Returns
+ -------
+ TaskDurationsResult
+ Mapping with ``start_date``, ``end_date``, ``total_duration``,
+ ``task_count``, ``duration_by_user``, ``duration_by_entity``,
+ and ``tasks`` (``None`` when ``return_task_details=False``).
+
+ Raises
+ ------
+ GlpiValidationError
+ If ``default_days < 1``, ``start_date`` / ``end_date`` is not a
+ valid ISO date, or ``start_date`` is after ``end_date``.
+ """
+
+ start, end = _resolve_window(
+ start_date=start_date,
+ 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"
+
+ entity_filter: str | None = None
+ if entity_id is not None:
+ 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,
+ )
+ if not entities:
+ return TaskDurationsResult(
+ start_date=start.isoformat(),
+ end_date=end.isoformat(),
+ total_duration=0,
+ task_count=0,
+ duration_by_user={},
+ duration_by_entity={},
+ tasks=None,
+ )
+ entity_filter = rsql_any_filter(
+ *(f"entity.id=={e.id}" for e in entities if e.id is not None)
+ )
+
+ # ``user_id`` selects on the ticket's actors, which v2 cannot
+ # express; resolve the id set through v1 and intersect below.
+ actor_ticket_ids: set[int] | None = None
+ if user_id is not None:
+ actor_ticket_ids = await self._v1_ticket_ids_for_actor(
+ user_id,
+ search_options=(_V1_SO_ASSIGNEE, _V1_SO_REQUESTER),
+ parameter="user_id",
+ )
+ if not actor_ticket_ids:
+ return TaskDurationsResult(
+ start_date=start.isoformat(),
+ end_date=end.isoformat(),
+ total_duration=0,
+ task_count=0,
+ duration_by_user={},
+ duration_by_entity={},
+ tasks=None,
+ )
+
+ editor_filter: str | None = None
+ if user_editor_id is not None:
+ editor_filter = f"user_editor.id=={user_editor_id}"
+
+ recipient_filter: str | None = None
+ if user_recipient_id is not None:
+ recipient_filter = f"user_recipient.id=={user_recipient_id}"
+
+ rsql_filter = (
+ rsql_all_filter(
+ date_filter,
+ entity_filter,
+ editor_filter,
+ recipient_filter,
+ _LIVE_TICKETS,
+ extra_filter,
+ )
+ or ""
+ )
+
+ ticket_ids: list[int] = []
+ ticket_entity_map: dict[int, str] = {}
+ async for batch in self.iter_search_tickets( # type: ignore[attr-defined]
+ rsql_filter,
+ batch_size=200,
+ ):
+ for ticket in batch:
+ if ticket.id is None:
+ continue
+ if actor_ticket_ids is not None and ticket.id not in actor_ticket_ids:
+ continue
+ ticket_ids.append(ticket.id)
+ ticket_entity_map[ticket.id] = _entity_key(ticket.entity)
+
+ # One bulk v1 sweep replaces the per-ticket fan-out once the ticket
+ # set is big enough to pay for it; the aggregate is identical.
+ if self._v1 is not None and len(ticket_ids) >= _V1_TASK_BULK_THRESHOLD:
+ result = await self._v1_task_statistics(ticket_ids, since=start)
+ else:
+ result = await self.get_task_statistics(ticket_ids)
+ duration_by_ticket = result["duration_by_ticket"]
+
+ duration_by_entity: defaultdict[str, int] = defaultdict(int)
+ for tid, dur in duration_by_ticket.items():
+ entity_key = ticket_entity_map.get(int(tid), "unknown")
+ duration_by_entity[entity_key] += int(dur)
+
+ task_details: list[dict[str, object]] | None = None
+ if return_task_details:
+ task_details = []
+ for tid, dur in duration_by_ticket.items():
+ if int(dur) == 0:
+ continue
+ for task in await self.list_ticket_tasks(int(tid)): # type: ignore[attr-defined]
+ task_details.append(
+ {
+ "task_id": task.id,
+ "ticket_id": int(tid),
+ "duration": int(task.duration or 0),
+ "user_id": task.user.id if task.user else None,
+ "user_name": task.user.name if task.user else None,
+ "date": str(task.date_creation or ""),
+ }
+ )
+
+ return TaskDurationsResult(
+ start_date=start.isoformat(),
+ end_date=end.isoformat(),
+ total_duration=int(result["total_duration"]),
+ task_count=int(result["task_count"]),
+ duration_by_user=result["duration_by_user"],
+ duration_by_entity=dict(duration_by_entity),
+ tasks=task_details,
+ )
+
+ async def get_user_activity(
+ self,
+ *,
+ user_id: int | None = None,
+ username: str | None = None,
+ realname: str | None = None,
+ firstname: str | None = None,
+ start_date: str | None = None,
+ end_date: str | None = None,
+ default_days: int = 30,
+ ) -> UserActivityResult:
+ """Return per-user GLPI activity aggregated across tickets and tasks.
+
+ Aggregates tickets where each matched user is an assignee, tickets
+ where the user is a requester, and task durations over the requested
+ date window. When multiple users resolve to the same display key
+ their results are merged.
+
+ Parameters
+ ----------
+ user_id : int | None, optional
+ Identify the user by GLPI numeric identifier.
+ username : str | None, optional
+ Filter by username (substring match).
+ realname : str | None, optional
+ Filter by family name (substring match).
+ firstname : str | None, optional
+ Filter by given name (substring match).
+ start_date : str | None, optional
+ ISO ``YYYY-MM-DD`` start of the activity window (inclusive
+ from 00:00:00).
+ end_date : str | None, optional
+ ISO ``YYYY-MM-DD`` end of the activity window (inclusive
+ through 23:59:59). Defaults to today.
+ default_days : int, optional
+ Span in days used when ``start_date`` is omitted (default 30).
+
+ Returns
+ -------
+ UserActivityResult
+ Mapping with one ``users`` key. Each user key maps to a
+ :class:`UserActivityEntry` with ``user_ids``,
+ ``tickets_as_technician``, ``tickets_as_recipient``, and
+ ``task_durations``.
+
+ Raises
+ ------
+ GlpiValidationError
+ If none of ``user_id``, ``username``, ``realname``, or
+ ``firstname`` are supplied, or if the supplied criteria match
+ no GLPI users.
+ """
+
+ if all(v is None for v in (user_id, username, realname, firstname)):
+ raise GlpiValidationError(
+ "At least one of user_id, username, realname, or "
+ "firstname must be supplied"
+ )
+
+ start, end = _resolve_window(
+ start_date=start_date,
+ end_date=end_date,
+ default_days=default_days,
+ )
+
+ if user_id is not None:
+ resolved_user_ids: list[int] = [user_id]
+ user_display_map: dict[int, str] = {user_id: str(user_id)}
+ else:
+ name_parts = [
+ rsql_contains_filter("username", username) if username else None,
+ rsql_contains_filter("realname", realname) if realname else None,
+ 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,
+ )
+ 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]
+ user_display_map = {
+ u.id: (
+ f"{u.firstname or ''} {u.realname or ''}".strip()
+ or u.username
+ or str(u.id)
+ )
+ for u in matched_users
+ 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"
+
+ # The date window is resolved once for every user rather than once
+ # per user per role. Previously each user drove two full pagings of
+ # the corpus, and because the actor clause was silently dropped by
+ # v2 both walks returned the same unfiltered window.
+ window_filter = rsql_all_filter(date_range, _LIVE_TICKETS) or ""
+ window_ids: set[int] = set()
+ async for batch in self.iter_search_tickets( # type: ignore[attr-defined]
+ window_filter,
+ batch_size=200,
+ ):
+ for ticket in batch:
+ if ticket.id is not None:
+ window_ids.add(ticket.id)
+
+ users_output: dict[str, UserActivityEntry] = {}
+ for uid in resolved_user_ids:
+ display_key = user_display_map.get(uid, str(uid))
+ # Assignee and requester are counted separately, so they are
+ # resolved as separate v1 id sets rather than one OR-ed query.
+ tech_count = len(
+ window_ids
+ & await self._v1_ticket_ids_for_actor(
+ uid, search_options=(_V1_SO_ASSIGNEE,), parameter="user_id"
+ )
+ )
+ recipient_count = len(
+ window_ids
+ & await self._v1_ticket_ids_for_actor(
+ uid, search_options=(_V1_SO_REQUESTER,), parameter="user_id"
+ )
+ )
+ task_dur = await self.get_task_durations(
+ start_date=start_date,
+ end_date=end_date,
+ default_days=default_days,
+ user_id=uid,
+ )
+ # Drop the optional ``tasks`` payload before storing on the
+ # per-user entry; the activity summary keeps only aggregated
+ # counters per user.
+ task_dur_clean: TaskDurationsResult = TaskDurationsResult(
+ start_date=task_dur["start_date"],
+ end_date=task_dur["end_date"],
+ total_duration=task_dur["total_duration"],
+ task_count=task_dur["task_count"],
+ duration_by_user=dict(task_dur["duration_by_user"]),
+ duration_by_entity=dict(task_dur["duration_by_entity"]),
+ tasks=None,
+ )
+
+ if display_key in users_output:
+ existing = users_output[display_key]
+ existing["user_ids"] = [*existing["user_ids"], uid]
+ existing["tickets_as_technician"] += tech_count
+ existing["tickets_as_recipient"] += recipient_count
+ existing["task_durations"] = _merge_task_durations(
+ existing["task_durations"], task_dur_clean
+ )
+ else:
+ users_output[display_key] = UserActivityEntry(
+ user_ids=[uid],
+ tickets_as_technician=tech_count,
+ tickets_as_recipient=recipient_count,
+ task_durations=task_dur_clean,
+ )
+
+ return UserActivityResult(users=users_output)
+
+
+def _merge_task_durations(
+ prev: TaskDurationsResult, new: TaskDurationsResult
+) -> TaskDurationsResult:
+ """Merge two task-duration aggregates summing every counter.
+
+ The returned ``start_date`` / ``end_date`` are inherited from
+ ``prev`` since the helper is only used to fold per-user results that
+ were computed over the same window. The ``tasks`` payload is dropped
+ because the merged aggregate is part of a user activity report and
+ not a detail listing.
+ """
+
+ merged_by_user: dict[str, int] = dict(prev["duration_by_user"])
+ for k, v in new["duration_by_user"].items():
+ merged_by_user[k] = merged_by_user.get(k, 0) + int(v)
+ merged_by_entity: dict[str, int] = dict(prev["duration_by_entity"])
+ for k, v in new["duration_by_entity"].items():
+ merged_by_entity[k] = merged_by_entity.get(k, 0) + int(v)
+ return TaskDurationsResult(
+ start_date=prev["start_date"],
+ end_date=prev["end_date"],
+ total_duration=prev["total_duration"] + new["total_duration"],
+ task_count=prev["task_count"] + new["task_count"],
+ duration_by_user=merged_by_user,
+ duration_by_entity=merged_by_entity,
+ tasks=None,
+ )
+
+
+def _resolve_window(
+ *,
+ start_date: str | None,
+ end_date: str | None,
+ default_days: int,
+) -> tuple[date, date]:
+ """Resolve a date window from optional ISO inputs and a default span.
+
+ Validation matches the legacy analytics helper: positive default span,
+ parsed ISO dates, and ``start <= end``.
+
+ Raises
+ ------
+ GlpiValidationError
+ If ``default_days < 1``, ``start_date`` / ``end_date`` is not a
+ valid ISO ``YYYY-MM-DD`` string, or ``start_date`` is after
+ ``end_date``.
+ """
+
+ if default_days < 1:
+ raise GlpiValidationError("default_days must be a positive integer")
+ try:
+ parsed_end = date.fromisoformat(end_date) if end_date else date.today()
+ except ValueError as exc:
+ raise GlpiValidationError(f"Invalid end_date: {end_date!r}") from exc
+ try:
+ parsed_start = (
+ date.fromisoformat(start_date)
+ if start_date
+ else parsed_end - timedelta(days=default_days - 1)
+ )
+ except ValueError as exc:
+ raise GlpiValidationError(f"Invalid start_date: {start_date!r}") from exc
+ if parsed_start > parsed_end:
+ raise GlpiValidationError("start_date must be less than or equal to end_date")
+ return parsed_start, parsed_end
+
+
+def _summarize_tickets(tickets: list[GetTicket]) -> dict[str, object]:
+ """Group tickets by entity and break each entity down by attribute."""
+
+ entities: dict[str, dict[str, object]] = defaultdict(
+ lambda: {
+ "total": 0,
+ "by_status": defaultdict(int),
+ "by_priority": defaultdict(int),
+ "by_type": defaultdict(int),
+ }
+ )
+ for ticket in tickets:
+ entity_key = _entity_key(ticket.entity)
+ bucket = entities[entity_key]
+ bucket["total"] = int(bucket["total"]) + 1 # type: ignore[call-overload]
+ _count_status(bucket["by_status"], ticket.status) # type: ignore[arg-type]
+ _count_enum(bucket["by_priority"], ticket.priority, GlpiPriority) # type: ignore[arg-type]
+ _count_enum(bucket["by_type"], ticket.type, GlpiTicketType) # type: ignore[arg-type]
+ return {"entities": {key: _freeze_bucket(value) for key, value in entities.items()}}
+
+
+def _summarize_tasks(
+ ticket_ids: list[int], tasks: list[GetTicketTask]
+) -> TaskStatisticsResult:
+ """Aggregate one task list by user and parent ticket identifier."""
+
+ duration_by_user: defaultdict[str, int] = defaultdict(int)
+ duration_by_ticket: defaultdict[int, int] = defaultdict(int)
+ total_duration = 0
+ for task in tasks:
+ duration = int(task.duration or 0)
+ total_duration += duration
+ duration_by_user[_user_key(task.user)] += duration
+ if task.tickets_id is not None:
+ duration_by_ticket[task.tickets_id] += duration
+ return TaskStatisticsResult(
+ ticket_count=len(ticket_ids),
+ task_count=len(tasks),
+ total_duration=total_duration,
+ duration_by_user=dict(duration_by_user),
+ duration_by_ticket=dict(duration_by_ticket),
+ )
+
+
+def _entity_key(entity: IdNameCompletenameRef | None) -> str:
+ """Return one stable identifier string for the provided entity reference.
+
+ Numeric entity identifiers are preferred so the output stays stable when
+ the entity name changes between runs.
+ """
+
+ if entity is None:
+ return "unknown"
+ if entity.id is not None:
+ return str(entity.id)
+ return entity.name or "unknown"
+
+
+def _user_key(user: IdNameRef | None) -> str:
+ """Return one stable identifier string for the provided user reference."""
+
+ if user is None:
+ return "unknown"
+ if user.id is not None:
+ return str(user.id)
+ return user.name or "unknown"
+
+
+def _count_status(counter: defaultdict[str, int], status: IdNameRef | None) -> None:
+ """Increment one status counter using the GLPI numeric identifier."""
+
+ if status is None:
+ counter["UNKNOWN"] += 1
+ return
+ counter[str(status.id) if status.id is not None else status.name or "UNKNOWN"] += 1
+
+
+def _count_enum(counter: defaultdict[str, int], value: object, enum_type: type) -> None:
+ """Increment one counter using the IntEnum member name when possible."""
+
+ if value is None:
+ counter["UNKNOWN"] += 1
+ return
+ try:
+ counter[enum_type(value).name] += 1
+ except ValueError:
+ counter[str(value)] += 1
+
+
+def _freeze_bucket(bucket: dict[str, object]) -> dict[str, object]:
+ """Convert defaultdict counters into plain dicts for the public output."""
+
+ return {
+ "total": bucket["total"],
+ "by_status": dict(bucket["by_status"]), # type: ignore[call-overload]
+ "by_priority": dict(bucket["by_priority"]), # type: ignore[call-overload]
+ "by_type": dict(bucket["by_type"]), # type: ignore[call-overload]
+ }
+
+
+__all__ = ["StatisticsMixin"]
diff --git a/glpi_python_client/_async/clients/custom/_ticket_context.py b/glpi_python_client/_async/clients/custom/_ticket_context.py
new file mode 100644
index 0000000..d32788e
--- /dev/null
+++ b/glpi_python_client/_async/clients/custom/_ticket_context.py
@@ -0,0 +1,75 @@
+"""Aggregated ticket context view assembled from API mixins.
+
+The ticket context mixin composes the public ticket and ticket-timeline
+helpers to return a single :class:`GlpiTicketContext` model carrying the
+primary ticket together with its timeline records.
+"""
+
+from __future__ import annotations
+
+from glpi_python_client._async._concurrency import gather
+from glpi_python_client._async.clients.commons._constants import GlpiId
+from glpi_python_client._async.clients.commons._transport import TransportMixin
+from glpi_python_client.models.custom_schema._ticket_context import GlpiTicketContext
+
+
+class TicketContextMixin(TransportMixin):
+ """Ticket-context aggregation helper.
+
+ The mixin assumes the consuming client also exposes the ticket and
+ ticket-timeline helpers from :mod:`glpi_python_client._async.clients.api`.
+ The five underlying calls are independent and are issued through the
+ ``gather`` helper from
+ :mod:`glpi_python_client._async._concurrency`, so this one
+ implementation fans them out on the async surface and runs them one
+ after another on the generated sync one.
+ """
+
+ async def get_ticket_context(self, ticket_id: GlpiId) -> GlpiTicketContext:
+ """Return one aggregated ticket context view.
+
+ The primary ticket fetch and the four timeline list calls are
+ issued through ``gather``: concurrently on the async surface,
+ sequentially on the generated sync one.
+
+ Parameters
+ ----------
+ ticket_id : GlpiId
+ Numeric identifier of the ticket to assemble.
+
+ Returns
+ -------
+ GlpiTicketContext
+ Aggregated view bundling the primary ticket together with
+ its tasks, followups, solutions, and timeline document
+ links.
+
+ Raises
+ ------
+ GlpiStatusError
+ If any of the underlying GLPI calls returns a non-success
+ HTTP status.
+ """
+
+ # The five reads are independent, so they go through ``gather``:
+ # concurrently on the async surface, and one after the other on the
+ # generated sync one, where each argument has already been evaluated
+ # by the time ``gather`` is entered. One expression, both meanings --
+ # which is what lets this method exist exactly once.
+ ticket, tasks, followups, solutions, documents = await gather(
+ self.get_ticket(ticket_id), # type: ignore[attr-defined]
+ self.list_ticket_tasks(ticket_id), # type: ignore[attr-defined]
+ self.list_ticket_followups(ticket_id), # type: ignore[attr-defined]
+ self.list_ticket_solutions(ticket_id), # type: ignore[attr-defined]
+ self.list_ticket_timeline_documents(ticket_id), # type: ignore[attr-defined]
+ )
+ return GlpiTicketContext(
+ ticket=ticket,
+ tasks=tasks,
+ followups=followups,
+ solutions=solutions,
+ documents=documents,
+ )
+
+
+__all__ = ["TicketContextMixin"]
diff --git a/glpi_python_client/_errors.py b/glpi_python_client/_errors.py
index 19026c8..20e41b6 100644
--- a/glpi_python_client/_errors.py
+++ b/glpi_python_client/_errors.py
@@ -1,18 +1,13 @@
"""Public exception hierarchy raised by :mod:`glpi_python_client`.
Every exception the client raises for a bad argument, an unexpected HTTP
-status, or an unusable response body deliberately derives from
-:class:`GlpiError`, so callers can catch that part of the library surface
-with a single ``except`` clause. This is not yet the library's entire
-failure surface, and importing the underlying HTTP library is still
-sometimes necessary:
-
-* Network-level faults (connection failures, DNS errors, timeouts) still
- propagate as ``requests`` exceptions today. :class:`GlpiTransportError`
- and :class:`GlpiTimeoutError` are reserved for that case but are not
- raised until the httpx transport swap replaces ``requests`` (a later
- release); catch ``requests.RequestException`` alongside
- :class:`GlpiError` if you need to handle them now.
+status, an unusable response body, or a network-level fault deliberately
+derives from :class:`GlpiError`, so callers can catch the library's failure
+surface with a single ``except`` clause and never need to import the
+underlying HTTP library.
+
+Two deliberate exceptions to that rule remain:
+
* A handful of sites also deliberately still raise bare ``RuntimeError``
(using a closed client, a missing v1 document session, a partially
failed knowledge-base write) or ``TypeError`` (a malformed environment
@@ -37,23 +32,27 @@ class GlpiError(Exception):
class GlpiTransportError(GlpiError):
"""The HTTP request never produced a response.
- Reserved for connection failures, DNS errors, and other network-level
- faults where GLPI returned no status code at all -- but **not raised
- yet**. The client is still built on ``requests``, and those faults
- currently propagate as ``requests`` exceptions (e.g.
- ``requests.ConnectionError``) instead. This class exists for the
- httpx transport swap planned for a later release, which will raise it
- in their place; until then, catch ``requests.RequestException``
- alongside :class:`GlpiError` if you need to handle network faults.
+ Raised for connection failures, DNS errors, and other network-level
+ faults where GLPI returned no status code at all. The underlying
+ transport exception is always attached as ``__cause__``, so the original
+ fault stays available for debugging without callers having to catch it.
+
+ Unlike most of the hierarchy this does **not** inherit ``ValueError``:
+ nothing was passed in wrongly and no value came back, so the
+ back-compatibility argument that applies to the status and validation
+ errors does not apply here.
+
+ Network faults are retried before they surface -- three attempts -- so
+ receiving this means the fault persisted.
"""
class GlpiTimeoutError(GlpiTransportError):
"""The HTTP request exceeded its timeout before GLPI responded.
- Like its parent :class:`GlpiTransportError`, this is reserved for the
- httpx transport swap and is **not raised yet**; a timeout today
- surfaces as ``requests.Timeout``.
+ A narrowing of :class:`GlpiTransportError` that separates "GLPI was too
+ slow" from "GLPI was unreachable". Catch the parent class to handle both
+ together.
"""
diff --git a/glpi_python_client/_sync/__init__.py b/glpi_python_client/_sync/__init__.py
new file mode 100644
index 0000000..7fb9b80
--- /dev/null
+++ b/glpi_python_client/_sync/__init__.py
@@ -0,0 +1,15 @@
+"""Hand-written client tree.
+
+This package is the single source of truth for client code. Its sibling
+``glpi_python_client._sync`` is generated from it by ``unasync_build.py``
+and checked in; CI regenerates and diffs to keep the two from drifting.
+
+Prose written here is copied verbatim into the generated tree -- the
+codegen rewrites tokens, not sentences. Docstrings under this package are
+therefore worded so they read correctly on both surfaces: describe *what* a
+helper does, and leave whether it blocks or awaits to the signature.
+"""
+
+from __future__ import annotations
+
+__all__: list[str] = []
diff --git a/glpi_python_client/_sync/_concurrency.py b/glpi_python_client/_sync/_concurrency.py
new file mode 100644
index 0000000..0066805
--- /dev/null
+++ b/glpi_python_client/_sync/_concurrency.py
@@ -0,0 +1,41 @@
+"""Threading concurrency primitives for the sync client tree.
+
+Hand-written twin of ``glpi_python_client/_async/_concurrency.py`` -- see
+that module for why these two files are the only ones not generated.
+
+**This file is not produced by the codegen and must be edited alongside its
+async twin.** ``unasync_build.py`` excludes it by name.
+"""
+
+from __future__ import annotations
+
+import threading
+from typing import Any
+
+#: Lock type guarding OAuth token acquisition on the sync surface.
+#:
+#: A :class:`threading.Lock`, because one sync client may legitimately be
+#: shared across user threads. An :class:`asyncio.Lock` here would bind
+#: itself to whichever event loop first contended it and then raise
+#: ``RuntimeError: ... bound to a different event loop`` -- or deadlock a
+#: thread outright -- for every other caller. That failure is latent:
+#: ``acquire()`` resolves the loop only on the contended path, so
+#: uncontended use passes and the tests stay green.
+Lock = threading.Lock
+
+
+def gather(*values: Any) -> list[Any]:
+ """Return ``values`` unchanged, preserving order.
+
+ This looks like a no-op and is doing real work. On the async side the
+ call reads ``await gather(self.a(), self.b())`` and runs the two
+ concurrently. Stripping the ``await`` leaves ``gather(self.a(),
+ self.b())``, where each argument has already been evaluated -- in
+ order -- by the time this is entered. Collecting them is therefore the
+ correct and complete synchronous meaning of the same expression.
+ """
+
+ return list(values)
+
+
+__all__ = ["Lock", "gather"]
diff --git a/glpi_python_client/_sync/auth/__init__.py b/glpi_python_client/_sync/auth/__init__.py
new file mode 100644
index 0000000..7e23ef9
--- /dev/null
+++ b/glpi_python_client/_sync/auth/__init__.py
@@ -0,0 +1,13 @@
+"""Public authentication exports for the GLPI client package.
+
+The authentication package owns the OAuth2 token manager used by the
+GLPI client and the legacy v1 session wrapper used solely by
+the management document upload mixin.
+"""
+
+from __future__ import annotations
+
+from glpi_python_client._sync.auth._v1_session import GLPIV1Session
+from glpi_python_client._sync.auth.auth import GLPITokenManager
+
+__all__ = ["GLPITokenManager", "GLPIV1Session"]
diff --git a/glpi_python_client/auth/_v1_session.py b/glpi_python_client/_sync/auth/_v1_session.py
similarity index 88%
rename from glpi_python_client/auth/_v1_session.py
rename to glpi_python_client/_sync/auth/_v1_session.py
index fcf0478..8f4f950 100644
--- a/glpi_python_client/auth/_v1_session.py
+++ b/glpi_python_client/_sync/auth/_v1_session.py
@@ -18,7 +18,8 @@
Every public dispatch helper (``_init_session``, ``request_json``,
``upload_document``) carries the same :mod:`tenacity` retry decorator
used by the v2 transport: three attempts spaced by three seconds,
-triggered by :class:`requests.RequestException` (network faults) and
+triggered by :class:`~glpi_python_client.GlpiTransportError` (network
+faults) and
:class:`~glpi_python_client.GlpiServerError` (which
:func:`finalize_request_response` raises for 5xx server errors), with
``reraise=True`` so the real error surfaces once retries are exhausted.
@@ -39,27 +40,34 @@
from datetime import datetime, timedelta, timezone
from typing import Any, cast
-import requests
+import httpx
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
+from glpi_python_client._sync.clients.commons._config import build_http_session
+from glpi_python_client._sync.clients.commons._http import (
+ ensure_response_status,
+ finalize_request_response,
+ response_json_or_empty,
+ transport_error_from,
+)
from glpi_python_client._errors import (
GlpiProtocolError,
GlpiServerError,
+ GlpiTransportError,
GlpiValidationError,
)
-from glpi_python_client.clients.commons._config import build_http_session
-from glpi_python_client.clients.commons._http import (
- ensure_response_status,
- finalize_request_response,
- response_json_or_empty,
-)
logger = logging.getLogger(__name__)
_DEFAULT_SESSION_REFRESH_INTERVAL_SECONDS = 15 * 60
_AUTH_FAILURE_STATUS_CODES = frozenset({401, 403})
+#: Retry policy for the v1 session, expressed in library-owned types.
+#:
+#: Mirrors the v2 transport policy deliberately: naming the HTTP library's
+#: own exception base here would make the retries stop matching — silently —
+#: the next time the transport changes.
_RETRY_ON_NETWORK_ERRORS = retry(
- retry=retry_if_exception_type((requests.RequestException, GlpiServerError)),
+ retry=retry_if_exception_type((GlpiTransportError, GlpiServerError)),
stop=stop_after_attempt(3),
wait=wait_fixed(3),
reraise=True,
@@ -104,6 +112,26 @@ def __init__(
self._session_token: str | None = None
self._session_started_at: datetime | None = None
+ def _dispatch(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
+ """Send one raw v1 HTTP call, translating transport faults.
+
+ Every call this session makes goes through here so network failures
+ surface as :class:`~glpi_python_client.GlpiTransportError` rather than
+ as the HTTP library's own exception type. That is what lets the retry
+ predicate above name a library-owned type, and it keeps callers from
+ having to import the HTTP library to catch a connection failure.
+
+ Raises
+ ------
+ GlpiTransportError
+ When the request never produced a response.
+ """
+
+ try:
+ return self._http.request(method.upper(), url, **kwargs)
+ except httpx.HTTPError as exc:
+ raise transport_error_from(exc, method=method, url=url) from exc
+
@_RETRY_ON_NETWORK_ERRORS
def _init_session(self) -> None:
"""Acquire one fresh GLPI v1 session token via ``GET /initSession``.
@@ -123,7 +151,7 @@ def _init_session(self) -> None:
headers["App-Token"] = self._app_token
url = f"{self._base_url}/initSession"
- response = self._http.get(url, headers=headers, timeout=30)
+ response = self._dispatch("GET", url, headers=headers, timeout=30)
finalize_request_response(
response,
method="get",
@@ -198,7 +226,8 @@ def _renew_session(self) -> None:
if self._session_token is not None:
try:
- self._http.get(
+ self._dispatch(
+ "GET",
f"{self._base_url}/killSession",
headers=self._session_headers(),
timeout=10,
@@ -225,7 +254,7 @@ def _authenticated_request(
success_statuses: tuple[int, ...],
headers: dict[str, str] | None = None,
**kwargs: Any,
- ) -> requests.Response:
+ ) -> httpx.Response:
"""Send one authenticated GLPI v1 request and finalize the response.
When the GLPI server rejects the current token the helper renews
@@ -243,7 +272,7 @@ def _authenticated_request(
# per-verb attribute: it is the one call shape both transports share,
# and it keeps the verb a value instead of an attribute name.
verb = method.upper()
- response = self._http.request(verb, url, headers=request_headers, **kwargs)
+ response = self._dispatch(verb, url, headers=request_headers, **kwargs)
if _is_auth_failure_response(response):
logger.warning(
"GLPI v1 session token was rejected; refreshing session and "
@@ -251,7 +280,9 @@ def _authenticated_request(
)
self._renew_session()
request_headers = {**self._headers(), **(headers or {})}
- response = self._http.request(verb, url, headers=request_headers, **kwargs)
+ response = self._dispatch(
+ verb, url, headers=request_headers, **kwargs
+ )
return finalize_request_response(
response,
method=method,
@@ -269,7 +300,8 @@ def close(self) -> None:
try:
if self._session_token is not None:
- self._http.get(
+ self._dispatch(
+ "GET",
f"{self._base_url}/killSession",
headers=self._session_headers(),
timeout=10,
@@ -309,7 +341,7 @@ def request_json(
Resource path appended to the v1 base URL (without leading
slash, e.g. ``"PluginFieldsContainer"``).
params : dict[str, object] | None, optional
- Query-string parameters forwarded to ``requests``.
+ Query-string parameters forwarded to the HTTP transport.
json_body : dict[str, object] | None, optional
JSON body serialised into the request when set. The
``Content-Type: application/json`` header is added
@@ -346,7 +378,7 @@ def request_json(
kwargs["params"] = params
headers: dict[str, str] = {}
if json_body is not None:
- kwargs["data"] = json.dumps(json_body)
+ kwargs["content"] = json.dumps(json_body)
headers["Content-Type"] = "application/json"
response = self._authenticated_request(
method,
@@ -419,7 +451,7 @@ def upload_document(
return cast(dict[str, object], payload)
-def _is_auth_failure_response(response: requests.Response) -> bool:
+def _is_auth_failure_response(response: httpx.Response) -> bool:
"""Return whether one GLPI v1 response means the session token is invalid.
Both HTTP-level rejection and the ``ERROR_SESSION_TOKEN_INVALID`` payload
diff --git a/glpi_python_client/auth/auth.py b/glpi_python_client/_sync/auth/auth.py
similarity index 73%
rename from glpi_python_client/auth/auth.py
rename to glpi_python_client/_sync/auth/auth.py
index 3e3bf1e..b3eb0e6 100644
--- a/glpi_python_client/auth/auth.py
+++ b/glpi_python_client/_sync/auth/auth.py
@@ -10,11 +10,14 @@
import logging
from datetime import datetime, timedelta, timezone
-import requests
+import httpx
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
+from glpi_python_client._sync.clients.commons._config import build_http_session
+from glpi_python_client._sync.clients.commons._http import transport_error_from
from glpi_python_client._errors import (
GlpiServerError,
+ GlpiTransportError,
GlpiValidationError,
status_error_class,
)
@@ -22,6 +25,71 @@
logger = logging.getLogger(__name__)
+def validate_credentials(
+ *,
+ client_id: str | None,
+ client_secret: str | None,
+ username: str | None,
+ password: str | None,
+) -> None:
+ """Check that the supplied OAuth credential sets are complete.
+
+ GLPI authentication accepts client credentials, user credentials, or
+ both. Partial pairs are rejected here so the token request path does not
+ fail later with a less actionable error.
+
+ This is a free function rather than only a method so callers can check a
+ configuration *before* committing resources to it. That ordering matters:
+ the client used to build its HTTP session first and unwind it in an
+ ``except`` clause when validation failed, which is not expressible on the
+ async surface -- an ``httpx.AsyncClient`` has no synchronous close, and a
+ constructor cannot await one. Validating up front removes the need to
+ unwind anything.
+
+ Raises
+ ------
+ GlpiValidationError
+ If either pair is half-supplied, or if neither pair is supplied.
+ """
+
+ missing_client_fields = [
+ name
+ for name, value in {
+ "client_id": client_id,
+ "client_secret": client_secret,
+ }.items()
+ if value is None
+ ]
+ missing_user_fields = [
+ name
+ for name, value in {
+ "username": username,
+ "password": password,
+ }.items()
+ if value is None
+ ]
+
+ has_client_fields = len(missing_client_fields) < 2
+ has_user_fields = len(missing_user_fields) < 2
+
+ if has_client_fields and missing_client_fields:
+ raise GlpiValidationError(
+ "GLPI OAuth client credentials must include both client_id "
+ "and client_secret."
+ )
+ if has_user_fields and missing_user_fields:
+ raise GlpiValidationError(
+ "GLPI user credentials must include both username and password."
+ )
+ if not (client_id is not None and client_secret is not None) and not (
+ username is not None and password is not None
+ ):
+ raise GlpiValidationError(
+ "GLPI authentication requires either client_id/client_secret, "
+ "username/password, or both."
+ )
+
+
class GLPITokenManager:
"""OAuth2 token manager for the GLPI API.
@@ -40,8 +108,8 @@ class GLPITokenManager:
password : str | None, optional
Password for the password grant flow. Provide it together with
``username``.
- session : requests.Session | None, optional
- Existing session to reuse.
+ session : httpx.AsyncClient | None, optional
+ Existing HTTP client to reuse.
auth_token_refresh : int | None, optional
Maximum token age in seconds before a refresh is attempted. ``None``
disables interval-based refreshes.
@@ -54,7 +122,7 @@ def __init__(
client_secret: str | None = None,
username: str | None = None,
password: str | None = None,
- session: requests.Session | None = None,
+ session: httpx.Client | None = None,
auth_token_refresh: int | None = None,
) -> None:
self._token_url = token_url
@@ -63,7 +131,7 @@ def __init__(
self._username = username
self._password = password
self._owns_session = session is None
- self._session = session or requests.Session()
+ self._session = session or build_http_session(verify_ssl=True)
self._auth_token_refresh_interval = _refresh_interval(auth_token_refresh)
self._validate_credentials()
@@ -73,6 +141,25 @@ def __init__(
self.token_expires_at: datetime | None = None
self.token_updated_at: datetime | None = None
+ def _post_token_request(self, data: dict[str, str]) -> httpx.Response:
+ """POST the OAuth token endpoint, translating transport faults.
+
+ Network failures surface as
+ :class:`~glpi_python_client.GlpiTransportError` so the retry
+ predicates below can name a library-owned type and callers never have
+ to import the HTTP library to catch a connection failure.
+
+ Raises
+ ------
+ GlpiTransportError
+ When the token request never produced a response.
+ """
+
+ try:
+ return self._session.post(self._token_url, data=data, timeout=30)
+ except httpx.HTTPError as exc:
+ raise transport_error_from(exc, method="post", url=self._token_url) from exc
+
@property
def auth_token_refresh(self) -> int | None:
"""Return the proactive refresh delay configured for this manager.
@@ -87,47 +174,14 @@ def auth_token_refresh(self) -> int | None:
return int(self._auth_token_refresh_interval.total_seconds())
def _validate_credentials(self) -> None:
- """Validate that the configured OAuth credential sets are complete.
+ """Validate that the configured OAuth credential sets are complete."""
- GLPI authentication supports either client credentials, user
- credentials, or both together. Partial pairs are rejected here so the
- token request path does not fail later with a less actionable error.
- """
-
- missing_client_fields = [
- name
- for name, value in {
- "client_id": self._client_id,
- "client_secret": self._client_secret,
- }.items()
- if value is None
- ]
- missing_user_fields = [
- name
- for name, value in {
- "username": self._username,
- "password": self._password,
- }.items()
- if value is None
- ]
-
- has_client_fields = len(missing_client_fields) < 2
- has_user_fields = len(missing_user_fields) < 2
-
- if has_client_fields and missing_client_fields:
- raise GlpiValidationError(
- "GLPI OAuth client credentials must include both client_id "
- "and client_secret."
- )
- if has_user_fields and missing_user_fields:
- raise GlpiValidationError(
- "GLPI user credentials must include both username and password."
- )
- if not self._has_client_credentials and not self._has_user_credentials:
- raise GlpiValidationError(
- "GLPI authentication requires either client_id/client_secret, "
- "username/password, or both."
- )
+ validate_credentials(
+ client_id=self._client_id,
+ client_secret=self._client_secret,
+ username=self._username,
+ password=self._password,
+ )
@property
def _has_client_credentials(self) -> bool:
@@ -230,7 +284,7 @@ def _should_refresh_by_interval(self, now: datetime) -> bool:
return now >= self.token_updated_at + self._auth_token_refresh_interval
@retry(
- retry=retry_if_exception_type((requests.RequestException, GlpiServerError)),
+ retry=retry_if_exception_type((GlpiTransportError, GlpiServerError)),
stop=stop_after_attempt(3),
wait=wait_fixed(3),
reraise=True,
@@ -255,7 +309,7 @@ def _acquire_token(self) -> None:
"""
data = self._build_token_request_data()
- response = self._session.post(self._token_url, data=data, timeout=30)
+ response = self._post_token_request(data)
if 200 <= response.status_code < 300:
self._store_token_data(response.json())
return
@@ -272,7 +326,7 @@ def _acquire_token(self) -> None:
)
@retry(
- retry=retry_if_exception_type(requests.RequestException),
+ retry=retry_if_exception_type(GlpiTransportError),
stop=stop_after_attempt(3),
wait=wait_fixed(3),
reraise=True,
@@ -297,7 +351,7 @@ def _refresh_access_token(self) -> None:
GlpiServerError
If the token endpoint fails (5xx) while refreshing. This
method's own retry decorator only matches
- ``requests.RequestException`` (network-level faults), not
+ ``GlpiTransportError`` (network-level faults), not
``GlpiServerError``, so it does not retry the fall-through to
:meth:`_acquire_token`. The nested call carries its own
independent decorator, which does retry ``GlpiServerError`` up
@@ -324,7 +378,7 @@ def _refresh_access_token(self) -> None:
assert self._client_secret is not None
data["client_id"] = self._client_id
data["client_secret"] = self._client_secret
- response = self._session.post(self._token_url, data=data, timeout=30)
+ response = self._post_token_request(data)
if 200 <= response.status_code < 300:
self._store_token_data(response.json(), label="refreshed")
return
diff --git a/glpi_python_client/_sync/clients/__init__.py b/glpi_python_client/_sync/clients/__init__.py
new file mode 100644
index 0000000..2933dc5
--- /dev/null
+++ b/glpi_python_client/_sync/clients/__init__.py
@@ -0,0 +1,17 @@
+"""Client class for one GLPI surface.
+
+The concrete client composes every per-endpoint mixin from
+:mod:`glpi_python_client._sync.clients.api`, the aggregated helpers from
+:mod:`glpi_python_client._sync.clients.custom`, and the transport mixin
+from :mod:`glpi_python_client._sync.clients.commons`.
+
+Only one of the two client trees is written by hand; the other is
+generated from it. Both expose the same endpoint surface, so the choice
+between them is purely about the caller's runtime model.
+"""
+
+from __future__ import annotations
+
+from glpi_python_client._sync.clients.client import GlpiClient
+
+__all__ = ["GlpiClient"]
diff --git a/glpi_python_client/_sync/clients/_base_client.py b/glpi_python_client/_sync/clients/_base_client.py
new file mode 100644
index 0000000..d5ee4c3
--- /dev/null
+++ b/glpi_python_client/_sync/clients/_base_client.py
@@ -0,0 +1,177 @@
+"""Shared construction logic for the GLPI client.
+
+The :class:`_BaseGlpiClient` mixin holds the constructor signature, the
+resource-bundle assignment, and the :meth:`from_env` classmethod that
+both :class:`~glpi_python_client.GlpiClient` and
+:class:`~glpi_python_client.AsyncGlpiClient` use.
+Lifecycle helpers (``close``, ``__enter__``/``__exit__`` versus
+``__aenter__``/``__aexit__``) stay on the concrete subclasses because
+they differ between the sync and async surfaces.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+import sys
+from typing import TYPE_CHECKING
+
+if sys.version_info >= (3, 11):
+ from typing import Self
+else: # pragma: no cover - fallback for Python 3.10
+ from typing_extensions import Self
+
+from glpi_python_client._sync._concurrency import Lock
+from glpi_python_client._sync.clients.commons._config import (
+ build_client_env_config,
+ build_client_resources,
+)
+
+if TYPE_CHECKING:
+ from collections.abc import Mapping
+
+logger = logging.getLogger(__name__)
+
+
+class _BaseGlpiClient:
+ """Shared construction helpers for the GLPI client variants.
+
+ The mixin assigns the resource bundle returned by
+ :func:`build_client_resources` and the header, lock and state
+ attributes the transport mixin declares.
+ """
+
+ def __init__(
+ self,
+ *,
+ glpi_api_url: str,
+ client_id: str | None = None,
+ client_secret: str | None = None,
+ username: str | None = None,
+ password: str | None = None,
+ glpi_entity: int | None = None,
+ glpi_profile: int | None = None,
+ entity_recursive: bool = False,
+ language: str = "en_GB",
+ verify_ssl: bool = True,
+ auth_token_refresh: int | None = None,
+ v1_base_url: str | None = None,
+ v1_user_token: str | None = None,
+ v1_app_token: str | None = None,
+ ) -> None:
+ """Build the shared resources for a GLPI client.
+
+ Parameters
+ ----------
+ glpi_api_url : str
+ Base URL of the GLPI v2 REST API, e.g.
+ ``https://glpi.example.com/api.php/v2``.
+ client_id : str | None, optional
+ OAuth client identifier used to obtain access tokens.
+ client_secret : str | None, optional
+ OAuth client secret paired with ``client_id``.
+ username : str | None, optional
+ GLPI account username used for the OAuth password grant.
+ password : str | None, optional
+ GLPI account password used for the OAuth password grant.
+ glpi_entity : int | None, optional
+ Default ``GLPI-Entity`` header sent with each request.
+ glpi_profile : int | None, optional
+ Default ``GLPI-Profile`` header sent with each request.
+ entity_recursive : bool, optional
+ When ``True`` the ``GLPI-Entity-Recursive`` header is sent so
+ entity scope includes child entities.
+ language : str, optional
+ Default ``Accept-Language`` header value (e.g. ``"en_GB"``).
+ verify_ssl : bool, optional
+ Whether the HTTP session verifies the server certificate.
+ auth_token_refresh : int | None, optional
+ Number of seconds before token expiry at which the auth
+ manager proactively refreshes the access token.
+ v1_base_url : str | None, optional
+ Base URL of the legacy GLPI v1 API used as a fallback for
+ binary document uploads and the Fields plugin endpoints.
+ v1_user_token : str | None, optional
+ ``user_token`` for the v1 fallback session.
+ v1_app_token : str | None, optional
+ ``app_token`` for the v1 fallback session.
+
+ Raises
+ ------
+ GlpiValidationError
+ If the supplied configuration is incomplete or invalid (e.g.
+ missing OAuth credentials together with no v1 fallback).
+ """
+
+ resources = build_client_resources(
+ glpi_api_url=glpi_api_url,
+ client_name=type(self).__name__,
+ client_id=client_id,
+ client_secret=client_secret,
+ username=username,
+ password=password,
+ verify_ssl=verify_ssl,
+ auth_token_refresh=auth_token_refresh,
+ v1_base_url=v1_base_url,
+ v1_user_token=v1_user_token,
+ v1_app_token=v1_app_token,
+ )
+ self.glpi_api_url = resources.glpi_api_url
+ self._session = resources.session
+ self._auth = resources.auth
+ self._v1 = resources.v1
+ self.glpi_entity = glpi_entity
+ self.glpi_profile = glpi_profile
+ self.entity_recursive = entity_recursive
+ self.language = language
+ self._auth_lock = Lock()
+ self._closed = False
+
+ @classmethod
+ def from_env(
+ cls,
+ *,
+ env: Mapping[str, str] | None = None,
+ prefix: str = "GLPI_",
+ **overrides: object,
+ ) -> Self:
+ """Build a client instance from environment variables.
+
+ The variables follow the conventional ```` naming
+ (``GLPI_API_URL``, ``GLPI_CLIENT_ID``, ``GLPI_CLIENT_SECRET``,
+ ``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``).
+
+ Parameters
+ ----------
+ env : Mapping[str, str] | None, optional
+ Mapping the helper reads values from. Defaults to
+ :data:`os.environ`.
+ prefix : str, optional
+ Common prefix shared by every environment variable name.
+ **overrides : object
+ Keyword overrides forwarded to :meth:`__init__`; the
+ keyword overrides are forwarded verbatim.
+
+ Returns
+ -------
+ Self
+ A fully configured client ready to perform requests.
+
+ Raises
+ ------
+ GlpiValidationError
+ If the resolved configuration is missing a required field.
+ """
+
+ config = build_client_env_config(
+ prefix=prefix,
+ env=env if env is not None else os.environ,
+ overrides=overrides,
+ )
+ return cls(**config) # type: ignore[arg-type]
+
+
+__all__ = ["_BaseGlpiClient"]
diff --git a/glpi_python_client/clients/api/__init__.py b/glpi_python_client/_sync/clients/api/__init__.py
similarity index 57%
rename from glpi_python_client/clients/api/__init__.py
rename to glpi_python_client/_sync/clients/api/__init__.py
index 4f7237b..577f705 100644
--- a/glpi_python_client/clients/api/__init__.py
+++ b/glpi_python_client/_sync/clients/api/__init__.py
@@ -1,45 +1,41 @@
"""Per-endpoint API mixins backed by the ``api_schema`` Pydantic models.
The mixins under this package mirror the endpoints documented in
-``docs/glpi_api_contract.json`` one for one. They wrap the Synchronous
-transport helpers from :mod:`glpi_python_client.clients.commons` and exchange
+``docs/glpi_api_contract.json`` one for one. They wrap the
+transport helpers from :mod:`glpi_python_client._sync.clients.commons` and exchange
typed ``Get``, ``Post``, ``Patch``, and ``Delete``
models with the GLPI API.
"""
from __future__ import annotations
-from glpi_python_client.clients.api.administration import (
+from glpi_python_client._sync.clients.api.administration import (
EntityMixin,
UserMixin,
)
-from glpi_python_client.clients.api.assistance import (
+from glpi_python_client._sync.clients.api.assistance import (
TeamMemberMixin,
TicketMixin,
)
-from glpi_python_client.clients.api.assistance.timeline import (
+from glpi_python_client._sync.clients.api.assistance.timeline import (
FollowupMixin,
SolutionMixin,
TicketTaskMixin,
TimelineDocumentMixin,
)
-from glpi_python_client.clients.api.dropdowns import LocationMixin
-from glpi_python_client.clients.api.knowledgebase import (
- AsyncKBArticleMixin,
+from glpi_python_client._sync.clients.api.dropdowns import LocationMixin
+from glpi_python_client._sync.clients.api.knowledgebase import (
KBArticleCommentMixin,
KBArticleMixin,
KBArticleRevisionMixin,
KBCategoryMixin,
)
-from glpi_python_client.clients.api.management import DocumentMixin
-from glpi_python_client.clients.api.plugins import (
- AsyncPluginFieldsMixin,
+from glpi_python_client._sync.clients.api.management import DocumentMixin
+from glpi_python_client._sync.clients.api.plugins import (
PluginFieldsMixin,
)
__all__ = [
- "AsyncKBArticleMixin",
- "AsyncPluginFieldsMixin",
"DocumentMixin",
"EntityMixin",
"FollowupMixin",
diff --git a/glpi_python_client/_sync/clients/api/administration/__init__.py b/glpi_python_client/_sync/clients/api/administration/__init__.py
new file mode 100644
index 0000000..497758c
--- /dev/null
+++ b/glpi_python_client/_sync/clients/api/administration/__init__.py
@@ -0,0 +1,13 @@
+"""GLPI ``/Administration`` mixins for the GLPI client.
+
+The submodules expose the user and entity mixins used by
+:class:`glpi_python_client.GlpiClient` and
+:class:`glpi_python_client.AsyncGlpiClient`.
+"""
+
+from __future__ import annotations
+
+from glpi_python_client._sync.clients.api.administration._entity import EntityMixin
+from glpi_python_client._sync.clients.api.administration._user import UserMixin
+
+__all__ = ["EntityMixin", "UserMixin"]
diff --git a/glpi_python_client/clients/api/administration/_entity.py b/glpi_python_client/_sync/clients/api/administration/_entity.py
similarity index 94%
rename from glpi_python_client/clients/api/administration/_entity.py
rename to glpi_python_client/_sync/clients/api/administration/_entity.py
index b0dad3b..3301c4e 100644
--- a/glpi_python_client/clients/api/administration/_entity.py
+++ b/glpi_python_client/_sync/clients/api/administration/_entity.py
@@ -1,4 +1,4 @@
-"""Synchronous GLPI ``/Administration/Entity`` mixin.
+"""GLPI ``/Administration/Entity`` mixin.
The mixin exposes the search, fetch, create, update, and delete helpers
for the GLPI entity resource. Entity calls intentionally bypass the
@@ -9,8 +9,8 @@
from collections.abc import Iterator
-from glpi_python_client.clients.commons._constants import ENTITY_ENDPOINT, GlpiId
-from glpi_python_client.clients.commons._transport import TransportMixin
+from glpi_python_client._sync.clients.commons._constants import ENTITY_ENDPOINT, GlpiId
+from glpi_python_client._sync.clients.commons._transport import TransportMixin
from glpi_python_client.models.api_schema.administration._entity import (
DeleteEntity,
GetEntity,
@@ -20,7 +20,7 @@
class EntityMixin(TransportMixin):
- """Synchronous CRUD helpers for ``/Administration/Entity``."""
+ """CRUD helpers for ``/Administration/Entity``."""
def search_entities(
self,
@@ -181,7 +181,9 @@ def update_entity(self, entity_id: GlpiId, entity: PatchEntity) -> None:
log_message=f"GLPI API updated entity {entity_id}",
)
- def delete_entity(self, entity_id: GlpiId, *, force: bool | None = None) -> None:
+ def delete_entity(
+ self, entity_id: GlpiId, *, force: bool | None = None
+ ) -> None:
"""Delete one GLPI entity by identifier.
Parameters
diff --git a/glpi_python_client/clients/api/administration/_user.py b/glpi_python_client/_sync/clients/api/administration/_user.py
similarity index 95%
rename from glpi_python_client/clients/api/administration/_user.py
rename to glpi_python_client/_sync/clients/api/administration/_user.py
index c81cf98..2a9cb19 100644
--- a/glpi_python_client/clients/api/administration/_user.py
+++ b/glpi_python_client/_sync/clients/api/administration/_user.py
@@ -1,17 +1,17 @@
-"""Synchronous GLPI ``/Administration/User`` mixin.
+"""GLPI ``/Administration/User`` mixin.
The mixin exposes search, fetch, create, update, and delete helpers for the
GLPI user resource. All operations exchange the
:mod:`glpi_python_client.models.api_schema.administration` models and rely on
-the Synchronous transport mixin for HTTP dispatch.
+the transport mixin for HTTP dispatch.
"""
from __future__ import annotations
from collections.abc import Iterator
-from glpi_python_client.clients.commons._constants import USER_ENDPOINT, GlpiId
-from glpi_python_client.clients.commons._transport import TransportMixin
+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.models.api_schema.administration._user import (
DeleteUser,
GetUser,
@@ -21,7 +21,7 @@
class UserMixin(TransportMixin):
- """Synchronous CRUD helpers for ``/Administration/User``.
+ """CRUD helpers for ``/Administration/User``.
The helpers follow the contract-first naming convention and forward all
server-side validation to the GLPI API instead of duplicating checks on
diff --git a/glpi_python_client/_sync/clients/api/assistance/__init__.py b/glpi_python_client/_sync/clients/api/assistance/__init__.py
new file mode 100644
index 0000000..50f44db
--- /dev/null
+++ b/glpi_python_client/_sync/clients/api/assistance/__init__.py
@@ -0,0 +1,8 @@
+"""GLPI ``/Assistance`` mixins for the GLPI client."""
+
+from __future__ import annotations
+
+from glpi_python_client._sync.clients.api.assistance._team import TeamMemberMixin
+from glpi_python_client._sync.clients.api.assistance._ticket import TicketMixin
+
+__all__ = ["TeamMemberMixin", "TicketMixin"]
diff --git a/glpi_python_client/clients/api/assistance/_team.py b/glpi_python_client/_sync/clients/api/assistance/_team.py
similarity index 86%
rename from glpi_python_client/clients/api/assistance/_team.py
rename to glpi_python_client/_sync/clients/api/assistance/_team.py
index 059e20c..4247412 100644
--- a/glpi_python_client/clients/api/assistance/_team.py
+++ b/glpi_python_client/_sync/clients/api/assistance/_team.py
@@ -1,4 +1,4 @@
-"""Synchronous GLPI ``/Assistance/Ticket/{id}/TeamMember`` mixin.
+"""GLPI ``/Assistance/Ticket/{id}/TeamMember`` mixin.
The team-member endpoint exposes list, add, and remove operations on a
ticket. The mixin uses the ``api_schema`` ``TeamMember`` models and lets
@@ -9,14 +9,14 @@
import logging
-from glpi_python_client.clients.commons._constants import (
+from glpi_python_client._sync.clients.commons._constants import (
TEAM_MEMBER_SUFFIX,
TICKET_ENDPOINT,
GlpiId,
)
-from glpi_python_client.clients.commons._http import ensure_response_status
-from glpi_python_client.clients.commons._payloads import model_to_payload
-from glpi_python_client.clients.commons._transport import TransportMixin
+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,
PostTeamMember,
@@ -26,7 +26,7 @@
class TeamMemberMixin(TransportMixin):
- """Synchronous helpers for the ticket team-member endpoint."""
+ """Helpers for the ticket team-member endpoint."""
def list_ticket_team_members(self, ticket_id: GlpiId) -> list[GetTeamMember]:
"""List the team members currently linked to one ticket.
@@ -53,7 +53,9 @@ def list_ticket_team_members(self, ticket_id: GlpiId) -> list[GetTeamMember]:
failure_message=f"Failed to list ticket team members for {ticket_id}",
)
- def add_ticket_team_member(self, ticket_id: GlpiId, member: PostTeamMember) -> None:
+ def add_ticket_team_member(
+ self, ticket_id: GlpiId, member: PostTeamMember
+ ) -> None:
"""Add one team member to a ticket.
Parameters
diff --git a/glpi_python_client/clients/api/assistance/_ticket.py b/glpi_python_client/_sync/clients/api/assistance/_ticket.py
similarity index 95%
rename from glpi_python_client/clients/api/assistance/_ticket.py
rename to glpi_python_client/_sync/clients/api/assistance/_ticket.py
index 575fca7..546d6ea 100644
--- a/glpi_python_client/clients/api/assistance/_ticket.py
+++ b/glpi_python_client/_sync/clients/api/assistance/_ticket.py
@@ -1,4 +1,4 @@
-"""Synchronous GLPI ``/Assistance/Ticket`` mixin.
+"""GLPI ``/Assistance/Ticket`` mixin.
The mixin exposes search, fetch, create, update, and delete helpers for the
GLPI ticket resource using the ``api_schema`` Pydantic models.
@@ -8,8 +8,8 @@
from collections.abc import Iterator
-from glpi_python_client.clients.commons._constants import TICKET_ENDPOINT, GlpiId
-from glpi_python_client.clients.commons._transport import TransportMixin
+from glpi_python_client._sync.clients.commons._constants import TICKET_ENDPOINT, GlpiId
+from glpi_python_client._sync.clients.commons._transport import TransportMixin
from glpi_python_client.models.api_schema.assistance._ticket import (
DeleteTicket,
GetTicket,
@@ -19,7 +19,7 @@
class TicketMixin(TransportMixin):
- """Synchronous CRUD helpers for ``/Assistance/Ticket``.
+ """CRUD helpers for ``/Assistance/Ticket``.
The helpers exchange the contract-aligned ``GetTicket``, ``PostTicket``,
``PatchTicket``, and ``DeleteTicket`` models with the GLPI API and let the
@@ -202,7 +202,9 @@ def update_ticket(self, ticket_id: GlpiId, ticket: PatchTicket) -> None:
log_message=f"GLPI API updated ticket {ticket_id}",
)
- def delete_ticket(self, ticket_id: GlpiId, *, force: bool | None = None) -> None:
+ def delete_ticket(
+ self, ticket_id: GlpiId, *, force: bool | None = None
+ ) -> None:
"""Delete one GLPI ticket by identifier.
Parameters
diff --git a/glpi_python_client/_sync/clients/api/assistance/timeline/__init__.py b/glpi_python_client/_sync/clients/api/assistance/timeline/__init__.py
new file mode 100644
index 0000000..ef7c1a8
--- /dev/null
+++ b/glpi_python_client/_sync/clients/api/assistance/timeline/__init__.py
@@ -0,0 +1,23 @@
+"""GLPI ticket-timeline mixins for the GLPI client."""
+
+from __future__ import annotations
+
+from glpi_python_client._sync.clients.api.assistance.timeline._document import (
+ TimelineDocumentMixin,
+)
+from glpi_python_client._sync.clients.api.assistance.timeline._followup import (
+ FollowupMixin,
+)
+from glpi_python_client._sync.clients.api.assistance.timeline._solution import (
+ SolutionMixin,
+)
+from glpi_python_client._sync.clients.api.assistance.timeline._task import (
+ TicketTaskMixin,
+)
+
+__all__ = [
+ "FollowupMixin",
+ "SolutionMixin",
+ "TicketTaskMixin",
+ "TimelineDocumentMixin",
+]
diff --git a/glpi_python_client/clients/api/assistance/timeline/_document.py b/glpi_python_client/_sync/clients/api/assistance/timeline/_document.py
similarity index 94%
rename from glpi_python_client/clients/api/assistance/timeline/_document.py
rename to glpi_python_client/_sync/clients/api/assistance/timeline/_document.py
index fe98f9b..7d93352 100644
--- a/glpi_python_client/clients/api/assistance/timeline/_document.py
+++ b/glpi_python_client/_sync/clients/api/assistance/timeline/_document.py
@@ -1,4 +1,4 @@
-"""Synchronous GLPI ``/Assistance/Ticket/{id}/Timeline/Document`` mixin.
+"""GLPI ``/Assistance/Ticket/{id}/Timeline/Document`` mixin.
The mixin exposes list, fetch, link, and unlink helpers for the timeline
document endpoint that links existing GLPI documents to a ticket.
@@ -17,12 +17,12 @@
from __future__ import annotations
-from glpi_python_client.clients.commons._constants import (
+from glpi_python_client._sync.clients.commons._constants import (
TICKET_ENDPOINT,
TIMELINE_DOCUMENT_SUFFIX,
GlpiId,
)
-from glpi_python_client.clients.commons._transport import TransportMixin
+from glpi_python_client._sync.clients.commons._transport import TransportMixin
from glpi_python_client.models.api_schema.assistance.timeline._document import (
DeleteTimelineDocument,
PatchTimelineDocument,
@@ -32,9 +32,11 @@
class TimelineDocumentMixin(TransportMixin):
- """Synchronous CRUD helpers for the ticket document timeline endpoint."""
+ """CRUD helpers for the ticket document timeline endpoint."""
- def list_ticket_timeline_documents(self, ticket_id: GlpiId) -> list[GetDocument]:
+ def list_ticket_timeline_documents(
+ self, ticket_id: GlpiId
+ ) -> list[GetDocument]:
"""List all documents linked to one ticket timeline.
Parameters
diff --git a/glpi_python_client/clients/api/assistance/timeline/_followup.py b/glpi_python_client/_sync/clients/api/assistance/timeline/_followup.py
similarity index 92%
rename from glpi_python_client/clients/api/assistance/timeline/_followup.py
rename to glpi_python_client/_sync/clients/api/assistance/timeline/_followup.py
index 8ad722c..b9e6f8e 100644
--- a/glpi_python_client/clients/api/assistance/timeline/_followup.py
+++ b/glpi_python_client/_sync/clients/api/assistance/timeline/_followup.py
@@ -1,4 +1,4 @@
-"""Synchronous GLPI ``/Assistance/Ticket/{id}/Timeline/Followup`` mixin.
+"""GLPI ``/Assistance/Ticket/{id}/Timeline/Followup`` mixin.
The mixin exposes list, fetch, create, update, and delete helpers for the
ticket followup timeline endpoint, exchanging the ``api_schema`` followup
@@ -11,18 +11,18 @@
the OpenAPI contract documents a flat array of ``ITILFollowup``. Real
behaviour wins over the contract, so :func:`list_ticket_followups`
unwraps the envelope via the shared
-:meth:`~glpi_python_client.clients.commons._transport.TransportMixin._resource_list`
+:meth:`~glpi_python_client._sync.clients.commons._transport.TransportMixin._resource_list`
helper and tolerates both shapes.
"""
from __future__ import annotations
-from glpi_python_client.clients.commons._constants import (
+from glpi_python_client._sync.clients.commons._constants import (
FOLLOWUP_SUFFIX,
TICKET_ENDPOINT,
GlpiId,
)
-from glpi_python_client.clients.commons._transport import TransportMixin
+from glpi_python_client._sync.clients.commons._transport import TransportMixin
from glpi_python_client.models.api_schema.assistance.timeline._followup import (
DeleteFollowup,
GetFollowup,
@@ -32,7 +32,7 @@
class FollowupMixin(TransportMixin):
- """Synchronous CRUD helpers for the ticket followup timeline endpoint."""
+ """CRUD helpers for the ticket followup timeline endpoint."""
def list_ticket_followups(self, ticket_id: GlpiId) -> list[GetFollowup]:
"""List all followups linked to one ticket.
@@ -87,7 +87,9 @@ def get_ticket_followup(
),
)
- def create_ticket_followup(self, ticket_id: GlpiId, followup: PostFollowup) -> int:
+ def create_ticket_followup(
+ self, ticket_id: GlpiId, followup: PostFollowup
+ ) -> int:
"""Create one followup on a ticket.
Parameters
diff --git a/glpi_python_client/clients/api/assistance/timeline/_solution.py b/glpi_python_client/_sync/clients/api/assistance/timeline/_solution.py
similarity index 92%
rename from glpi_python_client/clients/api/assistance/timeline/_solution.py
rename to glpi_python_client/_sync/clients/api/assistance/timeline/_solution.py
index b2dd766..e95a407 100644
--- a/glpi_python_client/clients/api/assistance/timeline/_solution.py
+++ b/glpi_python_client/_sync/clients/api/assistance/timeline/_solution.py
@@ -1,4 +1,4 @@
-"""Synchronous GLPI ``/Assistance/Ticket/{id}/Timeline/Solution`` mixin.
+"""GLPI ``/Assistance/Ticket/{id}/Timeline/Solution`` mixin.
The mixin exposes list, fetch, create, update, and delete helpers for the
ticket solution timeline endpoint using the ``api_schema`` solution models.
@@ -10,18 +10,18 @@
the OpenAPI contract documents a flat array of ``ITILSolution``. Real
behaviour wins over the contract, so :func:`list_ticket_solutions`
unwraps the envelope through the shared
-:meth:`~glpi_python_client.clients.commons._transport.TransportMixin._resource_list`
+:meth:`~glpi_python_client._sync.clients.commons._transport.TransportMixin._resource_list`
helper and tolerates both shapes.
"""
from __future__ import annotations
-from glpi_python_client.clients.commons._constants import (
+from glpi_python_client._sync.clients.commons._constants import (
SOLUTION_SUFFIX,
TICKET_ENDPOINT,
GlpiId,
)
-from glpi_python_client.clients.commons._transport import TransportMixin
+from glpi_python_client._sync.clients.commons._transport import TransportMixin
from glpi_python_client.models.api_schema.assistance.timeline._solution import (
DeleteSolution,
GetSolution,
@@ -31,7 +31,7 @@
class SolutionMixin(TransportMixin):
- """Synchronous CRUD helpers for the ticket solution timeline endpoint."""
+ """CRUD helpers for the ticket solution timeline endpoint."""
def list_ticket_solutions(self, ticket_id: GlpiId) -> list[GetSolution]:
"""List all solutions linked to one ticket.
@@ -86,7 +86,9 @@ def get_ticket_solution(
),
)
- def create_ticket_solution(self, ticket_id: GlpiId, solution: PostSolution) -> int:
+ def create_ticket_solution(
+ self, ticket_id: GlpiId, solution: PostSolution
+ ) -> int:
"""Create one solution on a ticket.
Parameters
diff --git a/glpi_python_client/clients/api/assistance/timeline/_task.py b/glpi_python_client/_sync/clients/api/assistance/timeline/_task.py
similarity index 92%
rename from glpi_python_client/clients/api/assistance/timeline/_task.py
rename to glpi_python_client/_sync/clients/api/assistance/timeline/_task.py
index 004bd90..210397a 100644
--- a/glpi_python_client/clients/api/assistance/timeline/_task.py
+++ b/glpi_python_client/_sync/clients/api/assistance/timeline/_task.py
@@ -1,4 +1,4 @@
-"""Synchronous GLPI ``/Assistance/Ticket/{id}/Timeline/Task`` mixin.
+"""GLPI ``/Assistance/Ticket/{id}/Timeline/Task`` mixin.
The mixin exposes list, fetch, create, update, and delete helpers for the
ticket task timeline endpoint using the contract-aligned ``api_schema``
@@ -11,18 +11,18 @@
OpenAPI contract documents a flat array of ``TicketTask``. Real behaviour
wins over the contract, so :func:`list_ticket_tasks` unwraps the envelope
through the shared
-:meth:`~glpi_python_client.clients.commons._transport.TransportMixin._resource_list`
+:meth:`~glpi_python_client._sync.clients.commons._transport.TransportMixin._resource_list`
helper and tolerates both shapes.
"""
from __future__ import annotations
-from glpi_python_client.clients.commons._constants import (
+from glpi_python_client._sync.clients.commons._constants import (
TASK_SUFFIX,
TICKET_ENDPOINT,
GlpiId,
)
-from glpi_python_client.clients.commons._transport import TransportMixin
+from glpi_python_client._sync.clients.commons._transport import TransportMixin
from glpi_python_client.models.api_schema.assistance.timeline._task import (
DeleteTicketTask,
GetTicketTask,
@@ -32,7 +32,7 @@
class TicketTaskMixin(TransportMixin):
- """Synchronous CRUD helpers for the ticket task timeline endpoint."""
+ """CRUD helpers for the ticket task timeline endpoint."""
def list_ticket_tasks(self, ticket_id: GlpiId) -> list[GetTicketTask]:
"""List all tasks linked to one ticket.
@@ -56,7 +56,9 @@ def list_ticket_tasks(self, ticket_id: GlpiId) -> list[GetTicketTask]:
unwrap_envelope=True,
)
- def get_ticket_task(self, ticket_id: GlpiId, task_id: GlpiId) -> GetTicketTask:
+ def get_ticket_task(
+ self, ticket_id: GlpiId, task_id: GlpiId
+ ) -> GetTicketTask:
"""Fetch one ticket task by identifier.
Parameters
diff --git a/glpi_python_client/_sync/clients/api/dropdowns/__init__.py b/glpi_python_client/_sync/clients/api/dropdowns/__init__.py
new file mode 100644
index 0000000..c7f1cb4
--- /dev/null
+++ b/glpi_python_client/_sync/clients/api/dropdowns/__init__.py
@@ -0,0 +1,7 @@
+"""GLPI ``/Dropdowns`` mixins for the GLPI client."""
+
+from __future__ import annotations
+
+from glpi_python_client._sync.clients.api.dropdowns._location import LocationMixin
+
+__all__ = ["LocationMixin"]
diff --git a/glpi_python_client/clients/api/dropdowns/_location.py b/glpi_python_client/_sync/clients/api/dropdowns/_location.py
similarity index 92%
rename from glpi_python_client/clients/api/dropdowns/_location.py
rename to glpi_python_client/_sync/clients/api/dropdowns/_location.py
index b4caa57..99c1f1f 100644
--- a/glpi_python_client/clients/api/dropdowns/_location.py
+++ b/glpi_python_client/_sync/clients/api/dropdowns/_location.py
@@ -1,4 +1,4 @@
-"""Synchronous GLPI ``/Dropdowns/Location`` mixin.
+"""GLPI ``/Dropdowns/Location`` mixin.
The mixin exposes search, fetch, create, update, and delete helpers for the
GLPI location dropdown resource using the contract-aligned ``api_schema``
@@ -7,8 +7,11 @@
from __future__ import annotations
-from glpi_python_client.clients.commons._constants import LOCATION_ENDPOINT, GlpiId
-from glpi_python_client.clients.commons._transport import TransportMixin
+from glpi_python_client._sync.clients.commons._constants import (
+ LOCATION_ENDPOINT,
+ GlpiId,
+)
+from glpi_python_client._sync.clients.commons._transport import TransportMixin
from glpi_python_client.models.api_schema.dropdowns._location import (
DeleteLocation,
GetLocation,
@@ -18,7 +21,7 @@
class LocationMixin(TransportMixin):
- """Synchronous CRUD helpers for ``/Dropdowns/Location``."""
+ """CRUD helpers for ``/Dropdowns/Location``."""
def search_locations(
self,
@@ -103,7 +106,9 @@ def create_location(self, location: PostLocation) -> int:
log_message_factory=lambda new_id: f"GLPI API created location {new_id}",
)
- def update_location(self, location_id: GlpiId, location: PatchLocation) -> None:
+ def update_location(
+ self, location_id: GlpiId, location: PatchLocation
+ ) -> None:
"""Update one GLPI location with a partial body.
Parameters
diff --git a/glpi_python_client/_sync/clients/api/knowledgebase/__init__.py b/glpi_python_client/_sync/clients/api/knowledgebase/__init__.py
new file mode 100644
index 0000000..7c13017
--- /dev/null
+++ b/glpi_python_client/_sync/clients/api/knowledgebase/__init__.py
@@ -0,0 +1,21 @@
+"""GLPI ``/Knowledgebase`` mixins."""
+
+from __future__ import annotations
+
+from glpi_python_client._sync.clients.api.knowledgebase._article import KBArticleMixin
+from glpi_python_client._sync.clients.api.knowledgebase._category import (
+ KBCategoryMixin,
+)
+from glpi_python_client._sync.clients.api.knowledgebase._comment import (
+ KBArticleCommentMixin,
+)
+from glpi_python_client._sync.clients.api.knowledgebase._revision import (
+ KBArticleRevisionMixin,
+)
+
+__all__ = [
+ "KBArticleCommentMixin",
+ "KBArticleMixin",
+ "KBArticleRevisionMixin",
+ "KBCategoryMixin",
+]
diff --git a/glpi_python_client/clients/api/knowledgebase/_article.py b/glpi_python_client/_sync/clients/api/knowledgebase/_article.py
similarity index 94%
rename from glpi_python_client/clients/api/knowledgebase/_article.py
rename to glpi_python_client/_sync/clients/api/knowledgebase/_article.py
index 17b0ae9..fe1bc4d 100644
--- a/glpi_python_client/clients/api/knowledgebase/_article.py
+++ b/glpi_python_client/_sync/clients/api/knowledgebase/_article.py
@@ -1,4 +1,4 @@
-"""Synchronous GLPI ``/Knowledgebase/Article`` mixin.
+"""GLPI ``/Knowledgebase/Article`` mixin.
The mixin exposes search, fetch, create, update, and delete helpers for the
GLPI knowledge base article resource using the contract-aligned
@@ -10,12 +10,12 @@
from collections.abc import Sequence
-from glpi_python_client._errors import GlpiValidationError
-from glpi_python_client.clients.commons._constants import (
+from glpi_python_client._sync.clients.commons._constants import (
KB_ARTICLE_ENDPOINT,
GlpiId,
)
-from glpi_python_client.clients.commons._transport import TransportMixin
+from glpi_python_client._sync.clients.commons._transport import TransportMixin
+from glpi_python_client._errors import GlpiValidationError
from glpi_python_client.models.api_schema._common import IdNameRef
from glpi_python_client.models.api_schema.knowledgebase._article import (
DeleteKBArticle,
@@ -28,7 +28,7 @@
class KBArticleMixin(TransportMixin):
- """Synchronous CRUD helpers for ``/Knowledgebase/Article``."""
+ """CRUD helpers for ``/Knowledgebase/Article``."""
def search_kb_articles(
self,
@@ -68,7 +68,9 @@ def search_kb_articles(
params["sort"] = sort
if language:
params["language"] = language
- return self._resource_list(KB_ARTICLE_ENDPOINT, GetKBArticle, params=params)
+ return self._resource_list(
+ KB_ARTICLE_ENDPOINT, GetKBArticle, params=params
+ )
def get_kb_article(self, article_id: GlpiId) -> GetKBArticle:
"""Fetch one knowledge base article by identifier.
@@ -118,7 +120,9 @@ def create_kb_article(self, article: PostKBArticle) -> int:
) from exc
return new_id
- def update_kb_article(self, article_id: GlpiId, article: PatchKBArticle) -> None:
+ def update_kb_article(
+ self, article_id: GlpiId, article: PatchKBArticle
+ ) -> None:
"""Update one knowledge base article with a partial body.
When ``article.categories`` is provided — including an empty list to
diff --git a/glpi_python_client/clients/api/knowledgebase/_category.py b/glpi_python_client/_sync/clients/api/knowledgebase/_category.py
similarity index 91%
rename from glpi_python_client/clients/api/knowledgebase/_category.py
rename to glpi_python_client/_sync/clients/api/knowledgebase/_category.py
index 5479f33..3e597bc 100644
--- a/glpi_python_client/clients/api/knowledgebase/_category.py
+++ b/glpi_python_client/_sync/clients/api/knowledgebase/_category.py
@@ -1,4 +1,4 @@
-"""Synchronous GLPI ``/Knowledgebase/Category`` mixin.
+"""GLPI ``/Knowledgebase/Category`` mixin.
The mixin exposes search, fetch, create, update, and delete helpers for the
GLPI knowledge base category resource using the contract-aligned
@@ -7,11 +7,11 @@
from __future__ import annotations
-from glpi_python_client.clients.commons._constants import (
+from glpi_python_client._sync.clients.commons._constants import (
KB_CATEGORY_ENDPOINT,
GlpiId,
)
-from glpi_python_client.clients.commons._transport import TransportMixin
+from glpi_python_client._sync.clients.commons._transport import TransportMixin
from glpi_python_client.models.api_schema.knowledgebase._category import (
DeleteKBCategory,
GetKBCategory,
@@ -21,7 +21,7 @@
class KBCategoryMixin(TransportMixin):
- """Synchronous CRUD helpers for ``/Knowledgebase/Category``."""
+ """CRUD helpers for ``/Knowledgebase/Category``."""
def search_kb_categories(
self,
@@ -61,7 +61,9 @@ def search_kb_categories(
params["sort"] = sort
if language:
params["language"] = language
- return self._resource_list(KB_CATEGORY_ENDPOINT, GetKBCategory, params=params)
+ return self._resource_list(
+ KB_CATEGORY_ENDPOINT, GetKBCategory, params=params
+ )
def get_kb_category(self, category_id: GlpiId) -> GetKBCategory:
"""Fetch one knowledge base category by identifier.
diff --git a/glpi_python_client/clients/api/knowledgebase/_comment.py b/glpi_python_client/_sync/clients/api/knowledgebase/_comment.py
similarity index 90%
rename from glpi_python_client/clients/api/knowledgebase/_comment.py
rename to glpi_python_client/_sync/clients/api/knowledgebase/_comment.py
index 62dfa03..7b26411 100644
--- a/glpi_python_client/clients/api/knowledgebase/_comment.py
+++ b/glpi_python_client/_sync/clients/api/knowledgebase/_comment.py
@@ -1,4 +1,4 @@
-"""Synchronous GLPI ``/Knowledgebase/Article/{id}/Comment`` mixin.
+"""GLPI ``/Knowledgebase/Article/{id}/Comment`` mixin.
The mixin exposes list, fetch, create, update, and delete helpers for the
GLPI knowledge base article comment endpoint using the contract-aligned
@@ -7,12 +7,12 @@
from __future__ import annotations
-from glpi_python_client.clients.commons._constants import (
+from glpi_python_client._sync.clients.commons._constants import (
KB_ARTICLE_ENDPOINT,
KB_COMMENT_SUFFIX,
GlpiId,
)
-from glpi_python_client.clients.commons._transport import TransportMixin
+from glpi_python_client._sync.clients.commons._transport import TransportMixin
from glpi_python_client.models.api_schema.knowledgebase._comment import (
DeleteKBArticleComment,
GetKBArticleComment,
@@ -22,9 +22,11 @@
class KBArticleCommentMixin(TransportMixin):
- """Synchronous CRUD helpers for KB article comments."""
+ """CRUD helpers for KB article comments."""
- def list_kb_article_comments(self, article_id: GlpiId) -> list[GetKBArticleComment]:
+ def list_kb_article_comments(
+ self, article_id: GlpiId
+ ) -> list[GetKBArticleComment]:
"""List every comment attached to one knowledge base article."""
return self._resource_list(
diff --git a/glpi_python_client/clients/api/knowledgebase/_revision.py b/glpi_python_client/_sync/clients/api/knowledgebase/_revision.py
similarity index 89%
rename from glpi_python_client/clients/api/knowledgebase/_revision.py
rename to glpi_python_client/_sync/clients/api/knowledgebase/_revision.py
index 84fcfe4..8ac001c 100644
--- a/glpi_python_client/clients/api/knowledgebase/_revision.py
+++ b/glpi_python_client/_sync/clients/api/knowledgebase/_revision.py
@@ -1,4 +1,4 @@
-"""Synchronous GLPI ``/Knowledgebase/Article/{id}/Revision`` mixin.
+"""GLPI ``/Knowledgebase/Article/{id}/Revision`` mixin.
Revisions are read-only. The GLPI contract exposes both a default-language
listing (``.../Revision``) and a language-scoped listing
@@ -8,19 +8,19 @@
from __future__ import annotations
-from glpi_python_client.clients.commons._constants import (
+from glpi_python_client._sync.clients.commons._constants import (
KB_ARTICLE_ENDPOINT,
KB_REVISION_SUFFIX,
GlpiId,
)
-from glpi_python_client.clients.commons._transport import TransportMixin
+from glpi_python_client._sync.clients.commons._transport import TransportMixin
from glpi_python_client.models.api_schema.knowledgebase._revision import (
GetKBArticleRevision,
)
class KBArticleRevisionMixin(TransportMixin):
- """Synchronous read helpers for KB article revisions."""
+ """Read helpers for KB article revisions."""
def _revision_base(self, article_id: GlpiId, language: str | None) -> str:
"""Return the revision collection path, language-scoped when given."""
diff --git a/glpi_python_client/_sync/clients/api/management/__init__.py b/glpi_python_client/_sync/clients/api/management/__init__.py
new file mode 100644
index 0000000..18a180c
--- /dev/null
+++ b/glpi_python_client/_sync/clients/api/management/__init__.py
@@ -0,0 +1,7 @@
+"""GLPI ``/Management`` mixins for the GLPI client."""
+
+from __future__ import annotations
+
+from glpi_python_client._sync.clients.api.management._document import DocumentMixin
+
+__all__ = ["DocumentMixin"]
diff --git a/glpi_python_client/clients/api/management/_document.py b/glpi_python_client/_sync/clients/api/management/_document.py
similarity index 92%
rename from glpi_python_client/clients/api/management/_document.py
rename to glpi_python_client/_sync/clients/api/management/_document.py
index b9cee40..71cda34 100644
--- a/glpi_python_client/clients/api/management/_document.py
+++ b/glpi_python_client/_sync/clients/api/management/_document.py
@@ -1,4 +1,4 @@
-"""Synchronous GLPI ``/Management/Document`` mixin.
+"""GLPI ``/Management/Document`` mixin.
The mixin exposes JSON metadata CRUD operations on the document resource and
a multipart upload helper that delegates to the legacy v1 session because
@@ -9,13 +9,13 @@
import logging
-from glpi_python_client._errors import GlpiValidationError
-from glpi_python_client.clients.commons._constants import (
+from glpi_python_client._sync.clients.commons._constants import (
DOCUMENT_ENDPOINT,
GlpiId,
)
-from glpi_python_client.clients.commons._http import ensure_response_status
-from glpi_python_client.clients.commons._transport import TransportMixin
+from glpi_python_client._sync.clients.commons._http import ensure_response_status
+from glpi_python_client._sync.clients.commons._transport import TransportMixin
+from glpi_python_client._errors import GlpiValidationError
from glpi_python_client.models.api_schema.management._document import (
DeleteDocument,
GetDocument,
@@ -27,7 +27,7 @@
class DocumentMixin(TransportMixin):
- """Synchronous CRUD and upload helpers for ``/Management/Document``."""
+ """CRUD and upload helpers for ``/Management/Document``."""
def search_documents(
self,
@@ -122,7 +122,9 @@ def create_document(self, document: PostDocument) -> int:
skip_entity=True,
)
- def update_document(self, document_id: GlpiId, document: PatchDocument) -> None:
+ def update_document(
+ self, document_id: GlpiId, document: PatchDocument
+ ) -> None:
"""Update one GLPI document with a partial body.
Parameters
@@ -226,10 +228,9 @@ def upload_document(
Document uploads use the legacy v1 multipart endpoint because
the GLPI v2 API does not advertise a binary upload route. The
- async :class:`~glpi_python_client.clients.AsyncGlpiClient`
- offloads this blocking call to a worker thread automatically;
- callers using the sync :class:`~glpi_python_client.clients.GlpiClient`
- invoke it directly.
+ upload is dispatched through the same v1 session as every other
+ v1 call, so it needs ``v1_base_url`` and ``v1_user_token`` to be
+ configured on the client.
Parameters
----------
diff --git a/glpi_python_client/_sync/clients/api/plugins/__init__.py b/glpi_python_client/_sync/clients/api/plugins/__init__.py
new file mode 100644
index 0000000..0c6f1b9
--- /dev/null
+++ b/glpi_python_client/_sync/clients/api/plugins/__init__.py
@@ -0,0 +1,10 @@
+"""GLPI plugin endpoint mixins exposed via the legacy v1 REST API.
+
+Plugins are not advertised in the v2 OpenAPI contract so the mixins
+under this package go through the v1 session helper exposed by
+:class:`~glpi_python_client._sync.auth._v1_session.GLPIV1Session`.
+"""
+
+from glpi_python_client._sync.clients.api.plugins._fields import PluginFieldsMixin
+
+__all__ = ["PluginFieldsMixin"]
diff --git a/glpi_python_client/clients/api/plugins/_fields.py b/glpi_python_client/_sync/clients/api/plugins/_fields.py
similarity index 95%
rename from glpi_python_client/clients/api/plugins/_fields.py
rename to glpi_python_client/_sync/clients/api/plugins/_fields.py
index 6993725..46f1b3d 100644
--- a/glpi_python_client/clients/api/plugins/_fields.py
+++ b/glpi_python_client/_sync/clients/api/plugins/_fields.py
@@ -1,9 +1,9 @@
-"""Synchronous client mixin for the GLPI ``Fields`` plugin.
+"""Client mixin for the GLPI ``Fields`` plugin.
The `Fields plugin `_ adds
user-defined custom fields to any GLPI itemtype. It is not exposed
through the GLPI v2 REST contract so this mixin talks to the legacy v1
-REST API through :class:`~glpi_python_client.auth._v1_session.GLPIV1Session`.
+REST API through :class:`~glpi_python_client._sync.auth._v1_session.GLPIV1Session`.
Two abstraction layers are provided:
@@ -21,8 +21,8 @@
The value itemtype for one container is derived from the container
``name`` field with :func:`_value_itemtype_for`: container
-``aidelarsolution`` attached to ``Ticket`` becomes
-``PluginFieldsTicketaidelarsolution``. Field column names declared on
+``extrainfo`` attached to ``Ticket`` becomes
+``PluginFieldsTicketextrainfo``. Field column names declared on
:class:`~glpi_python_client.models.api_schema.plugins.GetPluginFieldsField`
flow through :attr:`~glpi_python_client.models._base.GlpiModel.extra_payload`
on the value rows.
@@ -33,8 +33,8 @@
import json
from typing import Any
+from glpi_python_client._sync.clients.commons._transport import TransportMixin
from glpi_python_client._errors import GlpiProtocolError, GlpiValidationError
-from glpi_python_client.clients.commons._transport import TransportMixin
from glpi_python_client.models.api_schema.plugins import (
GetPluginFieldsContainer,
GetPluginFieldsField,
@@ -114,11 +114,11 @@ def _extract_row_id(payload: object) -> int:
class PluginFieldsMixin(TransportMixin):
- """Synchronous helpers for the GLPI ``Fields`` plugin v1 endpoints.
+ """Helpers for the GLPI ``Fields`` plugin v1 endpoints.
Every method requires the v1 session to be configured on the client
- (see :class:`~glpi_python_client.clients.sync_client.GlpiClient`'s
- ``v1_base_url`` and ``v1_user_token`` constructor arguments).
+ (see the ``v1_base_url`` and ``v1_user_token`` constructor
+ arguments).
"""
def list_plugin_fields_containers(
@@ -312,7 +312,9 @@ def update_item_plugin_field_row(
failure_message=f"Failed to update {endpoint}",
)
- def get_ticket_custom_fields(self, ticket_id: int) -> dict[str, dict[str, Any]]:
+ def get_ticket_custom_fields(
+ self, ticket_id: int
+ ) -> dict[str, dict[str, Any]]:
"""Return the custom-field values defined for one ticket.
The result is a nested mapping shaped as
@@ -338,7 +340,9 @@ def get_ticket_custom_fields(self, ticket_id: int) -> dict[str, dict[str, Any]]:
name = container.name
if not name:
continue
- rows = self.list_item_plugin_field_rows(_TICKET_ITEMTYPE, ticket_id, name)
+ rows = self.list_item_plugin_field_rows(
+ _TICKET_ITEMTYPE, ticket_id, name
+ )
if not rows:
continue
result[name] = dict(rows[0].extra_payload)
diff --git a/glpi_python_client/clients/sync_client.py b/glpi_python_client/_sync/clients/client.py
similarity index 63%
rename from glpi_python_client/clients/sync_client.py
rename to glpi_python_client/_sync/clients/client.py
index 814d051..2164daa 100644
--- a/glpi_python_client/clients/sync_client.py
+++ b/glpi_python_client/_sync/clients/client.py
@@ -1,16 +1,14 @@
-"""Public synchronous GLPI client class.
-
-The :class:`GlpiClient` class composes the per-endpoint mixins from
-:mod:`glpi_python_client.clients.api` with the custom helpers from
-:mod:`glpi_python_client.clients.custom` and the synchronous transport
-mixin from :mod:`glpi_python_client.clients.commons` to expose the full
-public client surface.
-
-The asynchronous counterpart
-:class:`~glpi_python_client.clients.async_client.AsyncGlpiClient` wraps
-this very same set of mixins through
-:class:`~glpi_python_client.clients.commons._async_bridge.AsyncBridge` so
-both surfaces stay in lock-step automatically.
+"""Public GLPI client class.
+
+Composes the per-endpoint mixins from
+:mod:`glpi_python_client._sync.clients.api` with the aggregated helpers
+from :mod:`glpi_python_client._sync.clients.custom` and the transport
+mixin from :mod:`glpi_python_client._sync.clients.commons` to expose the
+full public client surface.
+
+This module is written once. Its counterpart on the other surface is
+generated from it, so the two client classes cannot drift apart: there is
+no second definition to keep in step.
"""
from __future__ import annotations
@@ -24,8 +22,8 @@
else: # pragma: no cover - fallback for Python 3.10
from typing_extensions import Self
-from glpi_python_client.clients._base_client import _BaseGlpiClient
-from glpi_python_client.clients.api import (
+from glpi_python_client._sync.clients._base_client import _BaseGlpiClient
+from glpi_python_client._sync.clients.api import (
DocumentMixin,
EntityMixin,
FollowupMixin,
@@ -42,8 +40,8 @@
TimelineDocumentMixin,
UserMixin,
)
-from glpi_python_client.clients.commons._transport import TransportMixin
-from glpi_python_client.clients.custom import (
+from glpi_python_client._sync.clients.commons._transport import TransportMixin
+from glpi_python_client._sync.clients.custom import (
StatisticsMixin,
TicketContextMixin,
)
@@ -72,17 +70,17 @@ class GlpiClient(
_BaseGlpiClient,
TransportMixin,
):
- """Synchronous GLPI client backed by the contract-aligned API mixins.
+ """GLPI client backed by the contract-aligned API mixins.
- The client owns the shared HTTP session, OAuth token manager, and
- optional legacy v1 session used solely for binary document uploads.
- Token acquisition is serialised by a :class:`threading.Lock` so the
- same instance can be safely shared across threads as well as across
- asyncio tasks dispatched through
- :class:`~glpi_python_client.clients.async_client.AsyncGlpiClient`.
+ The client owns the shared HTTP session, the OAuth token manager, and
+ the optional legacy v1 session used for binary document uploads and
+ the Fields plugin endpoints. Token acquisition is serialised by the
+ lock from :mod:`glpi_python_client._sync._concurrency`, which is the
+ right primitive for this surface -- see that module for why the two
+ surfaces cannot share one.
Construction parameters and :meth:`from_env` are documented on
- :class:`~glpi_python_client.clients._base_client._BaseGlpiClient`.
+ :class:`~glpi_python_client._sync.clients._base_client._BaseGlpiClient`.
"""
def close(self) -> None:
@@ -107,7 +105,7 @@ def __enter__(self) -> Self:
Returns
-------
- GlpiClient
+ AsyncGlpiClient
The client itself, suitable for chaining method calls.
"""
@@ -119,7 +117,7 @@ def __exit__(
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
- """Close the client on ``with`` exit.
+ """Close the client on ``with`` block exit.
Parameters
----------
diff --git a/glpi_python_client/clients/commons/__init__.py b/glpi_python_client/_sync/clients/commons/__init__.py
similarity index 59%
rename from glpi_python_client/clients/commons/__init__.py
rename to glpi_python_client/_sync/clients/commons/__init__.py
index b2130c6..ce78073 100644
--- a/glpi_python_client/clients/commons/__init__.py
+++ b/glpi_python_client/_sync/clients/commons/__init__.py
@@ -2,8 +2,8 @@
The commons package centralises constants, HTTP helpers, RSQL filter
builders, transport, and the client configuration helpers
-used by the per-endpoint mixins under :mod:`glpi_python_client.clients.api`
-and the higher-level helpers under :mod:`glpi_python_client.clients.custom`.
+used by the per-endpoint mixins under :mod:`glpi_python_client._sync.clients.api`
+and the higher-level helpers under :mod:`glpi_python_client._sync.clients.custom`.
"""
from __future__ import annotations
diff --git a/glpi_python_client/clients/commons/_config.py b/glpi_python_client/_sync/clients/commons/_config.py
similarity index 69%
rename from glpi_python_client/clients/commons/_config.py
rename to glpi_python_client/_sync/clients/commons/_config.py
index ef33a7e..553f058 100644
--- a/glpi_python_client/clients/commons/_config.py
+++ b/glpi_python_client/_sync/clients/commons/_config.py
@@ -1,9 +1,8 @@
-"""Configuration and resource setup for the asynchronous GLPI client.
+"""Configuration and resource setup for the GLPI client.
-The helpers here own environment parsing, URL normalisation, SSL warning
-behaviour, and the construction of the runtime resources used by
-:class:`glpi_python_client.clients.sync_client.GlpiClient` and
-:class:`glpi_python_client.clients.async_client.AsyncGlpiClient`.
+The helpers here own environment parsing, URL normalisation, and the
+construction of the runtime resources the client owns: the shared HTTP
+session, the OAuth token manager, and the optional legacy v1 session.
"""
from __future__ import annotations
@@ -12,14 +11,21 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING, Protocol
-import requests
-import urllib3
+import httpx
from glpi_python_client._errors import GlpiValidationError
if TYPE_CHECKING:
- from glpi_python_client.auth._v1_session import GLPIV1Session
- from glpi_python_client.auth.auth import GLPITokenManager
+ from glpi_python_client._sync.auth._v1_session import GLPIV1Session
+ from glpi_python_client._sync.auth.auth import GLPITokenManager
+
+#: Request timeout applied to every call that does not override it.
+#:
+#: ``httpx`` defaults to 5 seconds where ``requests`` defaults to no timeout
+#: at all, so leaving this unset would silently start cutting off the slow
+#: GLPI searches that used to be allowed to finish. 30s matches the value the
+#: transport has always passed explicitly.
+DEFAULT_TIMEOUT_SECONDS = 30.0
class SessionFactory(Protocol):
@@ -31,7 +37,7 @@ class SessionFactory(Protocol):
and a positional-argument factory would let that guarantee slip.
"""
- def __call__(self, *, verify_ssl: bool) -> requests.Session:
+ def __call__(self, *, verify_ssl: bool) -> httpx.Client:
"""Return a session configured for ``verify_ssl``."""
@@ -44,40 +50,33 @@ class ClientResources:
"""
glpi_api_url: str
- session: requests.Session
+ session: httpx.Client
auth: GLPITokenManager
v1: GLPIV1Session | None
-def configure_ssl_warning_policy(*, verify_ssl: bool) -> None:
- """Adjust insecure-request warning behaviour for the configured SSL policy.
-
- When certificate verification is disabled, urllib3 warnings are muted so
- callers do not get repeated noise from every request made by the
- client.
- """
-
- if verify_ssl:
- return
- urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
-
-
-def build_http_session(*, verify_ssl: bool) -> requests.Session:
- """Construct the HTTP session used for every GLPI call.
+def build_http_session(*, verify_ssl: bool) -> httpx.Client:
+ """Construct the HTTP client used for every GLPI call.
This is the single place the library instantiates a transport session,
- which makes it the seam the transport swap turns on. Two properties
- matter and both are the reason this is a function rather than two inline
- lines:
-
- * ``verify`` is applied **as part of construction**. ``requests``
- tolerates assigning it afterwards; ``httpx`` does not — it reads
- ``verify`` only in ``Client.__init__`` and a later assignment is
- accepted and silently ignored, leaving certificate verification on
- when the caller asked for it off.
- * Callers that need to intercept traffic (tests, and anything wanting
- ``httpx.MockTransport``) can substitute this factory instead of
- monkey-patching a session after the fact.
+ which is what makes the transport swappable at all. Three settings are
+ applied here deliberately, because each one differs between ``httpx``
+ and the ``requests`` transport this replaced:
+
+ * ``verify`` is applied **as part of construction**. ``httpx`` reads it
+ only in ``Client.__init__``; a later assignment is accepted and
+ silently ignored, which would leave certificate verification on when
+ the caller asked for it off.
+ * ``follow_redirects`` is enabled to preserve the previous behaviour.
+ ``requests`` follows redirects by default and ``httpx`` does not, so
+ omitting this would silently turn a followed redirect into a bare 3xx
+ response handed back to the caller.
+ * ``timeout`` is pinned to :data:`DEFAULT_TIMEOUT_SECONDS` rather than
+ left at the ``httpx`` default of 5 seconds.
+
+ Callers that need to intercept traffic (tests, and anything wanting
+ ``httpx.MockTransport``) can substitute this factory instead of
+ monkey-patching a session after the fact.
Parameters
----------
@@ -86,13 +85,15 @@ def build_http_session(*, verify_ssl: bool) -> requests.Session:
Returns
-------
- requests.Session
- A session configured for the requested SSL policy.
+ httpx.AsyncClient
+ A client configured for the requested SSL policy.
"""
- session = requests.Session()
- session.verify = verify_ssl
- return session
+ return httpx.Client(
+ verify=verify_ssl,
+ follow_redirects=True,
+ timeout=DEFAULT_TIMEOUT_SECONDS,
+ )
def build_client_resources(
@@ -125,8 +126,11 @@ def build_client_resources(
module globals. ``None`` uses the default factory.
"""
- from glpi_python_client.auth._v1_session import GLPIV1Session
- from glpi_python_client.auth.auth import GLPITokenManager
+ from glpi_python_client._sync.auth._v1_session import GLPIV1Session
+ from glpi_python_client._sync.auth.auth import (
+ GLPITokenManager,
+ validate_credentials,
+ )
normalized_api_url = normalize_client_api_url(
glpi_api_url,
@@ -136,23 +140,30 @@ def build_client_resources(
v1_base_url=v1_base_url,
v1_user_token=v1_user_token,
)
- configure_ssl_warning_policy(verify_ssl=verify_ssl)
+
+ # Validated before anything is constructed, so a bad configuration never
+ # leaves a session to unwind. The previous shape -- build the session,
+ # then close it in an ``except`` clause -- cannot work on the async
+ # surface: an ``httpx.AsyncClient`` has no synchronous close, and this
+ # runs from ``__init__``, which cannot await one.
+ validate_credentials(
+ client_id=client_id,
+ client_secret=client_secret,
+ username=username,
+ password=password,
+ )
factory = session_factory or build_http_session
session = factory(verify_ssl=verify_ssl)
- try:
- auth = GLPITokenManager(
- token_url=f"{normalized_api_url}/token",
- client_id=client_id,
- client_secret=client_secret,
- username=username,
- password=password,
- session=session,
- auth_token_refresh=auth_token_refresh,
- )
- except Exception:
- session.close()
- raise
+ auth = GLPITokenManager(
+ token_url=f"{normalized_api_url}/token",
+ client_id=client_id,
+ client_secret=client_secret,
+ username=username,
+ password=password,
+ session=session,
+ auth_token_refresh=auth_token_refresh,
+ )
v1: GLPIV1Session | None = None
if v1_base_url and v1_user_token:
@@ -293,10 +304,11 @@ def validate_v1_document_config(
__all__ = [
+ "DEFAULT_TIMEOUT_SECONDS",
"ClientResources",
"build_client_env_config",
"build_client_resources",
- "configure_ssl_warning_policy",
+ "build_http_session",
"normalize_client_api_url",
"parse_optional_env_bool",
"parse_optional_env_int",
diff --git a/glpi_python_client/_sync/clients/commons/_constants.py b/glpi_python_client/_sync/clients/commons/_constants.py
new file mode 100644
index 0000000..a066759
--- /dev/null
+++ b/glpi_python_client/_sync/clients/commons/_constants.py
@@ -0,0 +1,60 @@
+"""GLPI v2 endpoint paths and shared transport-layer type aliases.
+
+The constants here mirror the resource paths defined in the GLPI v2 API
+contract under ``docs/glpi_api_contract.json``. Endpoint paths are kept in
+one place so the API mixins all use the same resource locations and the
+shared HTTP helpers can rely on stable parameter types.
+"""
+
+from __future__ import annotations
+
+from typing import TypeAlias
+
+GlpiId: TypeAlias = int
+RequestParamValue: TypeAlias = str | int | float | bytes | None
+
+# administration/
+USER_ENDPOINT = "Administration/User"
+ENTITY_ENDPOINT = "Administration/Entity"
+
+# dropdowns/
+LOCATION_ENDPOINT = "Dropdowns/Location"
+
+# management/
+DOCUMENT_ENDPOINT = "Management/Document"
+
+# assistance/
+TICKET_ENDPOINT = "Assistance/Ticket"
+TEAM_MEMBER_SUFFIX = "TeamMember"
+
+# assistance/timeline/
+FOLLOWUP_SUFFIX = "Timeline/Followup"
+TASK_SUFFIX = "Timeline/Task"
+SOLUTION_SUFFIX = "Timeline/Solution"
+TIMELINE_DOCUMENT_SUFFIX = "Timeline/Document"
+
+# knowledgebase/
+KB_ARTICLE_ENDPOINT = "Knowledgebase/Article"
+KB_CATEGORY_ENDPOINT = "Knowledgebase/Category"
+KB_COMMENT_SUFFIX = "Comment"
+KB_REVISION_SUFFIX = "Revision"
+
+
+__all__ = [
+ "DOCUMENT_ENDPOINT",
+ "ENTITY_ENDPOINT",
+ "FOLLOWUP_SUFFIX",
+ "KB_ARTICLE_ENDPOINT",
+ "KB_CATEGORY_ENDPOINT",
+ "KB_COMMENT_SUFFIX",
+ "KB_REVISION_SUFFIX",
+ "LOCATION_ENDPOINT",
+ "SOLUTION_SUFFIX",
+ "TASK_SUFFIX",
+ "TEAM_MEMBER_SUFFIX",
+ "TICKET_ENDPOINT",
+ "TIMELINE_DOCUMENT_SUFFIX",
+ "USER_ENDPOINT",
+ "GlpiId",
+ "RequestParamValue",
+]
diff --git a/glpi_python_client/_sync/clients/commons/_filters.py b/glpi_python_client/_sync/clients/commons/_filters.py
new file mode 100644
index 0000000..12f9b49
--- /dev/null
+++ b/glpi_python_client/_sync/clients/commons/_filters.py
@@ -0,0 +1,105 @@
+"""RSQL filter helpers for GLPI v2 search endpoints.
+
+The high-level client uses these helpers to build safe text-search filters
+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.
+"""
+
+from __future__ import annotations
+
+
+def rsql_contains_filter(field: str, value: str) -> str | None:
+ """Build a contains-style RSQL filter for one text field.
+
+ Blank input returns ``None`` so callers can skip adding the filter, while
+ non-empty input is escaped before being wrapped in wildcard syntax.
+ """
+
+ text = value.strip()
+ if not text:
+ return None
+ return f'{field}=like="*{escape_rsql_like_value(text)}*"'
+
+
+def rsql_equals_filter(field: str, value: str | int | None) -> str | None:
+ """Build an equality-style RSQL filter for one field.
+
+ ``None`` and blank textual values return ``None`` so callers can compose
+ filters without special-casing absent inputs.
+ """
+
+ if value is None:
+ return None
+ if isinstance(value, int):
+ return f"{field}=={value}"
+ text = value.strip()
+ if not text:
+ return None
+ return f'{field}=="{escape_rsql_text_value(text)}"'
+
+
+def rsql_any_filter(*filters: str | None) -> str | None:
+ """Join non-empty RSQL filter fragments with OR semantics.
+
+ Empty fragments are ignored and an all-empty input returns ``None``.
+
+ The joined result is wrapped in parentheses whenever it contains more
+ than one fragment. RSQL binds ``;`` (AND) tighter than ``,`` (OR), so
+ an unparenthesised group silently loses every preceding AND clause for
+ all but its first alternative: ``date;e==1,e==2`` parses as
+ ``(date AND e==1) OR e==2``, which matches every ``e==2`` ticket
+ regardless of the date window. Measured against a live GLPI 11
+ instance, the unparenthesised form returned 16,245 tickets where the
+ parenthesised form correctly returned 1,552.
+ """
+
+ parts = [fragment for fragment in filters if fragment]
+ if not parts:
+ return None
+ if len(parts) == 1:
+ return parts[0]
+ return "(" + ",".join(parts) + ")"
+
+
+def rsql_all_filter(*filters: str | None) -> str | None:
+ """Join non-empty RSQL filter fragments with AND semantics.
+
+ Empty fragments are ignored and an all-empty input returns ``None``.
+ """
+
+ parts = [fragment for fragment in filters if fragment]
+ if not parts:
+ return None
+ return ";".join(parts)
+
+
+def escape_rsql_like_value(value: str) -> str:
+ """Escape user text embedded in a quoted RSQL ``like`` value.
+
+ The helper protects backslashes, quotes, and wildcard characters so
+ caller input is treated as text instead of modifying the filter
+ expression itself.
+ """
+
+ return value.replace("\\", "\\\\").replace('"', '\\"').replace("*", "\\*")
+
+
+def escape_rsql_text_value(value: str) -> str:
+ """Escape user text embedded in a quoted RSQL equality value.
+
+ The helper protects backslashes and double quotes so caller input remains
+ a literal value inside the generated RSQL expression.
+ """
+
+ return value.replace("\\", "\\\\").replace('"', '\\"')
+
+
+__all__ = [
+ "escape_rsql_like_value",
+ "escape_rsql_text_value",
+ "rsql_all_filter",
+ "rsql_any_filter",
+ "rsql_contains_filter",
+ "rsql_equals_filter",
+]
diff --git a/glpi_python_client/clients/commons/_http.py b/glpi_python_client/_sync/clients/commons/_http.py
similarity index 67%
rename from glpi_python_client/clients/commons/_http.py
rename to glpi_python_client/_sync/clients/commons/_http.py
index 8d2c84d..550922e 100644
--- a/glpi_python_client/clients/commons/_http.py
+++ b/glpi_python_client/_sync/clients/commons/_http.py
@@ -10,27 +10,78 @@
import logging
from collections.abc import Mapping
-import requests
+import httpx
+from glpi_python_client._sync.clients.commons._constants import RequestParamValue
from glpi_python_client._errors import (
GlpiProtocolError,
GlpiServerError,
+ GlpiTimeoutError,
+ GlpiTransportError,
status_error_class,
)
-from glpi_python_client.clients.commons._constants import RequestParamValue
-def response_reason(response: requests.Response) -> str:
+def transport_error_from(
+ exc: httpx.HTTPError,
+ *,
+ method: str,
+ url: str,
+) -> GlpiTransportError:
+ """Map one transport-level failure onto the library's public error type.
+
+ Network faults are the last part of the failure surface that still
+ escaped as third-party exceptions. Translating them here means callers
+ catch :class:`~glpi_python_client.GlpiError` and never have to import the
+ HTTP library, which is what :class:`~glpi_python_client.GlpiTransportError`
+ was reserved for.
+
+ It also removes a whole class of silent breakage. Retry predicates used
+ to name the HTTP library's own exception base; because those trees are
+ completely disjoint between libraries, swapping the transport without
+ editing every predicate made retries stop matching — silently, with no
+ error and a green test suite. Predicates now name this library-owned type
+ instead, so a future transport change cannot invalidate them.
+
+ Parameters
+ ----------
+ exc : httpx.HTTPError
+ The transport failure to translate.
+ method : str
+ HTTP verb, used only to build the message.
+ url : str
+ Absolute URL of the failed request, used only to build the message.
+
+ Returns
+ -------
+ GlpiTransportError
+ :class:`~glpi_python_client.GlpiTimeoutError` when the failure was a
+ timeout, otherwise :class:`~glpi_python_client.GlpiTransportError`.
+ The original exception should be attached with ``raise ... from exc``
+ by the caller.
+ """
+
+ error_class = (
+ GlpiTimeoutError
+ if isinstance(exc, httpx.TimeoutException)
+ else GlpiTransportError
+ )
+ return error_class(
+ f"GLPI {method.upper()} {url} failed: {type(exc).__name__}: {exc}"
+ )
+
+
+def response_reason(response: httpx.Response) -> str:
"""Return one response's HTTP reason phrase, whatever the transport.
- ``requests`` spells this ``Response.reason``; ``httpx`` spells it
- ``Response.reason_phrase`` and has no ``reason`` attribute at all. Every
- read of the phrase goes through this helper so swapping the transport
- touches one function instead of every message that quotes it.
+ ``httpx`` spells this ``Response.reason_phrase``; ``requests`` spelled it
+ ``Response.reason``. Both spellings are probed so the helper keeps
+ working for the duck-typed response fakes in downstream test suites,
+ which were written against the older attribute name.
Parameters
----------
- response : requests.Response
+ response : httpx.Response
Response to read the reason phrase from. Typed against the current
transport; any object exposing either attribute works at runtime.
@@ -49,26 +100,52 @@ def response_reason(response: requests.Response) -> str:
def request_params(
params: dict[str, object] | None,
) -> dict[str, RequestParamValue] | None:
- """Normalise query parameters into ``requests``-compatible values.
+ """Normalise query parameters into transport-compatible values.
Each value is converted through :func:`request_param_value` so callers
can pass richer Python objects without repeating serialisation logic.
+
+ Keys whose value is ``None`` are **dropped** rather than forwarded. This
+ is deliberate and load-bearing: ``requests`` omitted such keys from the
+ query string entirely, whereas ``httpx`` encodes them as a valueless
+ ``key=``. Sending an empty value to GLPI is not a no-op — an empty filter
+ or search value is interpreted as "match everything", so forwarding the
+ key would silently widen a query instead of leaving it unconstrained.
+ Normalising here keeps the emitted query string identical across
+ transports.
"""
if params is None:
return None
- return {key: request_param_value(value) for key, value in params.items()}
+ return {
+ key: request_param_value(value)
+ for key, value in params.items()
+ if value is not None
+ }
def request_param_value(value: object) -> RequestParamValue:
- """Normalise one query parameter value for ``requests``.
-
- Native scalar values are preserved and any other object is stringified
- so higher-level client code can pass enums and identifiers without
- special handling.
+ """Normalise one query parameter value for the HTTP transport.
+
+ Values are rendered exactly as the previous ``requests``-based transport
+ rendered them, so the wire format does not depend on which HTTP library
+ is installed. Two conversions exist only to preserve that:
+
+ * ``bytes`` are decoded to text. ``httpx`` would otherwise stringify the
+ object itself and emit the Python repr (``b'x'``) rather than its
+ contents.
+ * ``bool`` is rendered ``"True"``/``"False"``. ``httpx`` renders booleans
+ lowercase; ``requests`` did not. This is checked before ``int``
+ because ``bool`` is a subclass of ``int``.
"""
- if value is None or isinstance(value, str | int | float | bytes):
+ if value is None or isinstance(value, str):
+ return value
+ if isinstance(value, bytes):
+ return value.decode("utf-8", errors="replace")
+ if isinstance(value, bool):
+ return str(value)
+ if isinstance(value, int | float):
return value
return str(value)
@@ -136,13 +213,13 @@ def build_request_url(glpi_api_url: str, endpoint: str) -> str:
def finalize_request_response(
- response: requests.Response,
+ response: httpx.Response,
*,
method: str,
url: str,
success_statuses: tuple[int, ...],
logger: logging.Logger,
-) -> requests.Response:
+) -> httpx.Response:
"""Validate one GLPI transport response and preserve warning behaviour.
Server errors are raised immediately while non-success statuses outside
@@ -180,7 +257,7 @@ def finalize_request_response(
def ensure_response_status(
- response: requests.Response,
+ response: httpx.Response,
*,
success_statuses: tuple[int, ...],
failure_message: str,
@@ -208,7 +285,7 @@ def ensure_response_status(
)
-def response_json_or_empty(response: requests.Response) -> object:
+def response_json_or_empty(response: httpx.Response) -> object:
"""Return the parsed JSON body or an empty mapping for empty responses.
Unlike :func:`response_json_mapping` this helper preserves list and
@@ -221,7 +298,7 @@ def response_json_or_empty(response: requests.Response) -> object:
return response.json()
-def response_json_mapping(response: requests.Response) -> Mapping[str, object]:
+def response_json_mapping(response: httpx.Response) -> Mapping[str, object]:
"""Return the JSON response payload as a mapping when possible.
Empty response bodies become an empty mapping and non-mapping JSON
@@ -234,7 +311,7 @@ def response_json_mapping(response: requests.Response) -> Mapping[str, object]:
def require_response_int(
- response: requests.Response,
+ response: httpx.Response,
*,
keys: tuple[str, ...],
missing_message: str,
@@ -312,5 +389,6 @@ def unwrap_timeline_items(payload: object) -> list[dict[str, object]]:
"require_response_int",
"response_json_mapping",
"response_json_or_empty",
+ "transport_error_from",
"unwrap_timeline_items",
]
diff --git a/glpi_python_client/_sync/clients/commons/_payloads.py b/glpi_python_client/_sync/clients/commons/_payloads.py
new file mode 100644
index 0000000..15e24b3
--- /dev/null
+++ b/glpi_python_client/_sync/clients/commons/_payloads.py
@@ -0,0 +1,42 @@
+"""Pydantic helpers shared by the per-endpoint mixins.
+
+The helpers convert :class:`glpi_python_client.models._base.GlpiModel`
+instances into the JSON request bodies expected by the GLPI API and back
+again, while honouring the per-model ``extra_payload`` escape hatch.
+"""
+
+from __future__ import annotations
+
+from typing import TypeVar
+
+from glpi_python_client.models._base import GlpiModel
+
+ModelT = TypeVar("ModelT", bound=GlpiModel)
+
+
+def model_to_payload(model: GlpiModel) -> dict[str, object]:
+ """Serialise one :class:`GlpiModel` into a request body.
+
+ ``None`` fields are omitted, the meta ``extra_payload`` field is
+ 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.
+ """
+
+ body = model.model_dump(exclude_none=True, exclude={"extra_payload"})
+ if model.extra_payload:
+ body.update(model.extra_payload)
+ return body
+
+
+def model_from_payload(model_class: type[ModelT], payload: object) -> 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`.
+ """
+
+ return model_class.model_validate(payload)
+
+
+__all__ = ["model_from_payload", "model_to_payload"]
diff --git a/glpi_python_client/clients/commons/_transport.py b/glpi_python_client/_sync/clients/commons/_transport.py
similarity index 79%
rename from glpi_python_client/clients/commons/_transport.py
rename to glpi_python_client/_sync/clients/commons/_transport.py
index 60737f8..ec24d51 100644
--- a/glpi_python_client/clients/commons/_transport.py
+++ b/glpi_python_client/_sync/clients/commons/_transport.py
@@ -1,45 +1,38 @@
-"""Synchronous GLPI v2 transport mixin.
+"""GLPI v2 transport mixin.
The transport mixin owns token handling, header construction, retries, and
HTTP request dispatch so the per-endpoint mixins under
-:mod:`glpi_python_client.clients.api` can stay focused on resource-specific
+:mod:`glpi_python_client._sync.clients.api` can stay focused on resource-specific
behaviour.
Concurrency model
-----------------
-The transport is intentionally synchronous and backed by the blocking
-``requests`` library. Access to the auth token manager is serialised with
-a :class:`threading.Lock` rather than an :class:`asyncio.Lock` because:
-
-* the sync :class:`~glpi_python_client.clients.GlpiClient` can be shared
- across user threads, and
-* the async :class:`~glpi_python_client.clients.AsyncGlpiClient` runs every
- public call on a worker thread through
- :func:`asyncio.to_thread`, so concurrent ``asyncio.gather`` fan-outs
- contend across OS threads — which an :class:`asyncio.Lock` cannot
- protect.
-
-A single :class:`threading.Lock` covers both clients with one primitive.
+Access to the auth token manager is serialised with the lock from
+:mod:`glpi_python_client._sync._concurrency`. That module is one of only
+two maintained separately for each surface, because the correct primitive
+genuinely differs: an :class:`asyncio.Lock` for concurrent tasks on one
+event loop, a :class:`threading.Lock` for a client shared across threads.
+Neither substitutes for the other -- see that module for what breaks in
+each direction.
+
The lock is held only for the short critical section that refreshes the
-token; HTTP calls themselves run without the lock so concurrent requests
-can proceed in parallel while sharing the same access token. The
-underlying :class:`requests.Session` connection pool is thread-safe for
-concurrent HTTP calls; the session is built once at construction time
-and is never mutated afterwards.
+token. HTTP calls run outside it, so concurrent callers proceed in
+parallel while sharing one access token. The underlying HTTP client is
+safe for that concurrent use; it is built once at construction and never
+mutated afterwards.
"""
from __future__ import annotations
import logging
-import threading
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, TypeVar
-import requests
+import httpx
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
-from glpi_python_client._errors import GlpiServerError
-from glpi_python_client.clients.commons._http import (
+from glpi_python_client._sync._concurrency import Lock
+from glpi_python_client._sync.clients.commons._http import (
build_request_headers,
build_request_url,
ensure_response_status,
@@ -48,25 +41,46 @@
request_params,
require_access_token,
require_response_int,
+ transport_error_from,
unwrap_timeline_items,
)
-from glpi_python_client.clients.commons._payloads import (
+from glpi_python_client._sync.clients.commons._payloads import (
model_from_payload,
model_to_payload,
)
+from glpi_python_client._errors import GlpiServerError, GlpiTransportError
from glpi_python_client.models._base import GlpiModel
if TYPE_CHECKING:
- from glpi_python_client.auth._v1_session import GLPIV1Session
- from glpi_python_client.auth.auth import GLPITokenManager
+ from glpi_python_client._sync.auth._v1_session import GLPIV1Session
+ from glpi_python_client._sync.auth.auth import GLPITokenManager
logger = logging.getLogger(__name__)
ModelT = TypeVar("ModelT", bound=GlpiModel)
+#: Shared retry policy for every v2 transport verb.
+#:
+#: Declared once rather than repeated on each of the four verb helpers, and
+#: expressed entirely in library-owned exception types. Both parts are
+#: deliberate. A predicate that names the HTTP library's own exception base
+#: stops matching the moment the transport is swapped — the exception trees of
+#: the different libraries are completely disjoint — and retries then vanish
+#: with no error, no warning and a green test suite. Naming
+#: :class:`~glpi_python_client.GlpiTransportError`, which
+#: :func:`~glpi_python_client._sync.clients.commons._http.transport_error_from`
+#: guarantees every network fault is translated into, makes that failure
+#: impossible to reintroduce.
+_RETRY_ON_NETWORK_ERRORS = retry(
+ retry=retry_if_exception_type((GlpiTransportError, GlpiServerError)),
+ stop=stop_after_attempt(3),
+ wait=wait_fixed(3),
+ reraise=True,
+)
+
class TransportMixin:
- """Synchronous GLPI API transport helpers shared by the API mixins.
+ """GLPI API transport helpers shared by the API mixins.
The class declares the runtime attributes the concrete client owns and
exposes the blocking ``_get_request``, ``_post_request``,
@@ -75,19 +89,19 @@ class TransportMixin:
Thread safety
-------------
- Token acquisition and refresh are serialised by ``_auth_lock``
- (:class:`threading.Lock`) so concurrent threads — whether spawned by
- the sync client directly or by the async client through
- :func:`asyncio.to_thread` — never race while updating shared
- authentication state. HTTP dispatch runs outside the lock and relies
- on the thread-safety of :class:`requests.Session` for concurrent
- calls.
+ Token acquisition and refresh are serialised by ``_auth_lock``, the
+ ``Lock`` from :mod:`glpi_python_client._sync._concurrency`, so
+ concurrent callers never race while updating shared authentication
+ state. That module is hand-written on both surfaces because the
+ right primitive differs in kind between them. HTTP dispatch runs
+ outside the lock and relies on the underlying httpx client being
+ safe to use from concurrent callers.
"""
_auth: GLPITokenManager
- _auth_lock: threading.Lock
+ _auth_lock: Lock
_closed: bool = False
- _session: requests.Session
+ _session: httpx.Client
_v1: GLPIV1Session | None
entity_recursive: bool
glpi_api_url: str
@@ -151,20 +165,32 @@ def _send_request(
method: str,
url: str,
**kwargs: Any,
- ) -> requests.Response:
+ ) -> httpx.Response:
"""Dispatch one blocking HTTP call.
The helper exists as an indirection seam so tests can stub HTTP
dispatch without monkey-patching the session attribute directly.
Dispatch goes through ``session.request(method, url, ...)`` rather
- than looking up a per-verb attribute. ``request`` is the one call
- shape ``requests`` and ``httpx`` agree on, and keeping the verb a
- value rather than an attribute name means the transport swap does
- not have to reason about dynamic attribute lookup.
+ than looking up a per-verb attribute, keeping the verb a value
+ instead of an attribute name.
+
+ Transport-level failures are translated into
+ :class:`~glpi_python_client.GlpiTransportError` (or
+ :class:`~glpi_python_client.GlpiTimeoutError`) here, at the single
+ point where the HTTP library is actually called, so no third-party
+ exception escapes into the caller's ``except`` clauses.
+
+ Raises
+ ------
+ GlpiTransportError
+ When the request never produced a response.
"""
- return self._session.request(method.upper(), url, **kwargs)
+ try:
+ return self._session.request(method.upper(), url, **kwargs)
+ except httpx.HTTPError as exc:
+ raise transport_error_from(exc, method=method, url=url) from exc
def _execute_request(
self,
@@ -176,7 +202,7 @@ def _execute_request(
json_body: dict[str, object] | None = None,
skip_entity: bool = False,
include_content_type: bool = False,
- ) -> requests.Response:
+ ) -> httpx.Response:
"""Execute one authenticated GLPI request.
The helper normalises the endpoint URL, headers, timeout, and
@@ -215,21 +241,16 @@ def _execute_request(
logger=logger,
)
- @retry(
- retry=retry_if_exception_type((requests.RequestException, GlpiServerError)),
- stop=stop_after_attempt(3),
- wait=wait_fixed(3),
- reraise=True,
- )
+ @_RETRY_ON_NETWORK_ERRORS
def _get_request(
self,
endpoint: str,
params: dict[str, object] | None = None,
skip_entity: bool = False,
- ) -> requests.Response:
+ ) -> httpx.Response:
"""Execute one authenticated GLPI ``GET`` request.
- Network errors (:class:`requests.RequestException`) and 5xx
+ Network errors (:class:`~glpi_python_client.GlpiTransportError`) and 5xx
responses (:class:`~glpi_python_client.GlpiServerError`) are
retried up to 3 times, with ``reraise=True`` so the real error
propagates once retries are exhausted; 4xx responses are
@@ -244,18 +265,13 @@ def _get_request(
skip_entity=skip_entity,
)
- @retry(
- retry=retry_if_exception_type((requests.RequestException, GlpiServerError)),
- stop=stop_after_attempt(3),
- wait=wait_fixed(3),
- reraise=True,
- )
+ @_RETRY_ON_NETWORK_ERRORS
def _post_request(
self,
endpoint: str,
json_body: dict[str, object] | None = None,
skip_entity: bool = False,
- ) -> requests.Response:
+ ) -> httpx.Response:
"""Execute one authenticated GLPI ``POST`` request.
JSON request bodies automatically include the content-type header
@@ -271,17 +287,12 @@ def _post_request(
include_content_type=True,
)
- @retry(
- retry=retry_if_exception_type((requests.RequestException, GlpiServerError)),
- stop=stop_after_attempt(3),
- wait=wait_fixed(3),
- reraise=True,
- )
+ @_RETRY_ON_NETWORK_ERRORS
def _update_request(
self,
endpoint: str,
json_body: dict[str, object] | None = None,
- ) -> requests.Response:
+ ) -> httpx.Response:
"""Execute one authenticated GLPI ``PATCH`` request.
The helper uses the same authenticated execution path as the
@@ -297,18 +308,13 @@ def _update_request(
include_content_type=True,
)
- @retry(
- retry=retry_if_exception_type((requests.RequestException, GlpiServerError)),
- stop=stop_after_attempt(3),
- wait=wait_fixed(3),
- reraise=True,
- )
+ @_RETRY_ON_NETWORK_ERRORS
def _delete_request(
self,
endpoint: str,
json_body: dict[str, object] | None = None,
skip_entity: bool = False,
- ) -> requests.Response:
+ ) -> httpx.Response:
"""Execute one authenticated GLPI ``DELETE`` request.
Some delete endpoints accept a JSON body, so the content-type
@@ -364,7 +370,9 @@ def _resource_list(
Validated records returned by the GLPI server.
"""
- response = self._get_request(endpoint, params=params, skip_entity=skip_entity)
+ response = self._get_request(
+ endpoint, params=params, skip_entity=skip_entity
+ )
if failure_message is not None:
ensure_response_status(
response,
@@ -551,7 +559,9 @@ 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]
- response = self._delete_request(endpoint, request_body, skip_entity=skip_entity)
+ response = self._delete_request(
+ endpoint, request_body, skip_entity=skip_entity
+ )
ensure_response_status(
response,
success_statuses=(200, 204),
diff --git a/glpi_python_client/_sync/clients/custom/__init__.py b/glpi_python_client/_sync/clients/custom/__init__.py
new file mode 100644
index 0000000..b590325
--- /dev/null
+++ b/glpi_python_client/_sync/clients/custom/__init__.py
@@ -0,0 +1,23 @@
+"""Higher-level helpers built on top of the API mixins.
+
+The custom package exposes operations the GLPI API contract does not
+advertise directly but which client applications need: the aggregated
+ticket-context view and the reporting helpers, both assembled from the
+contract-aligned CRUD helpers in
+:mod:`glpi_python_client._sync.clients.api`.
+
+Each helper is written once. The fan-out points call ``gather`` from
+:mod:`glpi_python_client._sync._concurrency`, which runs them
+concurrently here and sequentially in the generated tree -- so there is
+no second copy of this logic to keep in step.
+"""
+
+from __future__ import annotations
+
+from glpi_python_client._sync.clients.custom._statistics import StatisticsMixin
+from glpi_python_client._sync.clients.custom._ticket_context import TicketContextMixin
+
+__all__ = [
+ "StatisticsMixin",
+ "TicketContextMixin",
+]
diff --git a/glpi_python_client/clients/custom/_statistics.py b/glpi_python_client/_sync/clients/custom/_statistics.py
similarity index 99%
rename from glpi_python_client/clients/custom/_statistics.py
rename to glpi_python_client/_sync/clients/custom/_statistics.py
index 53e6179..0e2a25b 100644
--- a/glpi_python_client/clients/custom/_statistics.py
+++ b/glpi_python_client/_sync/clients/custom/_statistics.py
@@ -2,7 +2,7 @@
The mixin exposes simple aggregations over ticket and ticket-task results
returned by the contract-aligned helpers in
-:mod:`glpi_python_client.clients.api`. These operations are intentionally
+:mod:`glpi_python_client._sync.clients.api`. These operations are intentionally
kept small and do not perform name resolution or rich label formatting; the
caller can correlate the returned numeric identifiers with the dedicated
``search_*`` helpers when required.
@@ -14,13 +14,13 @@
from datetime import date, timedelta
from typing import TypedDict
-from glpi_python_client._errors import GlpiValidationError
-from glpi_python_client.clients.commons._filters import (
+from glpi_python_client._sync.clients.commons._filters import (
rsql_all_filter,
rsql_any_filter,
rsql_contains_filter,
)
-from glpi_python_client.clients.commons._transport import TransportMixin
+from glpi_python_client._sync.clients.commons._transport import TransportMixin
+from glpi_python_client._errors import GlpiValidationError
from glpi_python_client.models.api_schema._common import (
IdNameCompletenameRef,
IdNameRef,
@@ -142,7 +142,7 @@ class UserActivityResult(TypedDict):
class StatisticsMixin(TransportMixin):
- """Synchronous custom statistics built on the contract API mixins."""
+ """Custom statistics built on the contract API mixins."""
def _v1_ticket_ids_for_actor(
self, user_id: int, *, search_options: tuple[int, ...], parameter: str
diff --git a/glpi_python_client/_sync/clients/custom/_ticket_context.py b/glpi_python_client/_sync/clients/custom/_ticket_context.py
new file mode 100644
index 0000000..a7c64c4
--- /dev/null
+++ b/glpi_python_client/_sync/clients/custom/_ticket_context.py
@@ -0,0 +1,75 @@
+"""Aggregated ticket context view assembled from API mixins.
+
+The ticket context mixin composes the public ticket and ticket-timeline
+helpers to return a single :class:`GlpiTicketContext` model carrying the
+primary ticket together with its timeline records.
+"""
+
+from __future__ import annotations
+
+from glpi_python_client._sync._concurrency import gather
+from glpi_python_client._sync.clients.commons._constants import GlpiId
+from glpi_python_client._sync.clients.commons._transport import TransportMixin
+from glpi_python_client.models.custom_schema._ticket_context import GlpiTicketContext
+
+
+class TicketContextMixin(TransportMixin):
+ """Ticket-context aggregation helper.
+
+ The mixin assumes the consuming client also exposes the ticket and
+ ticket-timeline helpers from :mod:`glpi_python_client._sync.clients.api`.
+ The five underlying calls are independent and are issued through the
+ ``gather`` helper from
+ :mod:`glpi_python_client._sync._concurrency`, so this one
+ implementation fans them out on the async surface and runs them one
+ after another on the generated sync one.
+ """
+
+ def get_ticket_context(self, ticket_id: GlpiId) -> GlpiTicketContext:
+ """Return one aggregated ticket context view.
+
+ The primary ticket fetch and the four timeline list calls are
+ issued through ``gather``: concurrently on the async surface,
+ sequentially on the generated sync one.
+
+ Parameters
+ ----------
+ ticket_id : GlpiId
+ Numeric identifier of the ticket to assemble.
+
+ Returns
+ -------
+ GlpiTicketContext
+ Aggregated view bundling the primary ticket together with
+ its tasks, followups, solutions, and timeline document
+ links.
+
+ Raises
+ ------
+ GlpiStatusError
+ If any of the underlying GLPI calls returns a non-success
+ HTTP status.
+ """
+
+ # The five reads are independent, so they go through ``gather``:
+ # concurrently on the async surface, and one after the other on the
+ # generated sync one, where each argument has already been evaluated
+ # by the time ``gather`` is entered. One expression, both meanings --
+ # which is what lets this method exist exactly once.
+ ticket, tasks, followups, solutions, documents = gather(
+ self.get_ticket(ticket_id), # type: ignore[attr-defined]
+ self.list_ticket_tasks(ticket_id), # type: ignore[attr-defined]
+ self.list_ticket_followups(ticket_id), # type: ignore[attr-defined]
+ self.list_ticket_solutions(ticket_id), # type: ignore[attr-defined]
+ self.list_ticket_timeline_documents(ticket_id), # type: ignore[attr-defined]
+ )
+ return GlpiTicketContext(
+ ticket=ticket,
+ tasks=tasks,
+ followups=followups,
+ solutions=solutions,
+ documents=documents,
+ )
+
+
+__all__ = ["TicketContextMixin"]
diff --git a/glpi_python_client/clients/__init__.py b/glpi_python_client/clients/__init__.py
deleted file mode 100644
index b0ddab8..0000000
--- a/glpi_python_client/clients/__init__.py
+++ /dev/null
@@ -1,20 +0,0 @@
-"""Public client exports for the GLPI Python package.
-
-The package exposes two client classes:
-
-* :class:`GlpiClient` — synchronous, blocking client. The single source
- of truth for endpoint behaviour.
-* :class:`AsyncGlpiClient` — asynchronous facade that wraps every
- synchronous method into a coroutine via
- :class:`~glpi_python_client.clients.commons._async_bridge.AsyncBridge`.
-
-Both classes share the same endpoint surface; pick the one matching
-your runtime model.
-"""
-
-from __future__ import annotations
-
-from glpi_python_client.clients.async_client import AsyncGlpiClient
-from glpi_python_client.clients.sync_client import GlpiClient
-
-__all__ = ["AsyncGlpiClient", "GlpiClient"]
diff --git a/glpi_python_client/clients/api/administration/__init__.py b/glpi_python_client/clients/api/administration/__init__.py
deleted file mode 100644
index c7b65dc..0000000
--- a/glpi_python_client/clients/api/administration/__init__.py
+++ /dev/null
@@ -1,13 +0,0 @@
-"""GLPI ``/Administration`` mixins for the Synchronous client.
-
-The submodules expose the user and entity mixins used by
-:class:`glpi_python_client.clients.sync_client.GlpiClient` and
-:class:`glpi_python_client.clients.async_client.AsyncGlpiClient`.
-"""
-
-from __future__ import annotations
-
-from glpi_python_client.clients.api.administration._entity import EntityMixin
-from glpi_python_client.clients.api.administration._user import UserMixin
-
-__all__ = ["EntityMixin", "UserMixin"]
diff --git a/glpi_python_client/clients/api/assistance/__init__.py b/glpi_python_client/clients/api/assistance/__init__.py
deleted file mode 100644
index 3bdcbeb..0000000
--- a/glpi_python_client/clients/api/assistance/__init__.py
+++ /dev/null
@@ -1,8 +0,0 @@
-"""GLPI ``/Assistance`` mixins for the Synchronous client."""
-
-from __future__ import annotations
-
-from glpi_python_client.clients.api.assistance._team import TeamMemberMixin
-from glpi_python_client.clients.api.assistance._ticket import TicketMixin
-
-__all__ = ["TeamMemberMixin", "TicketMixin"]
diff --git a/glpi_python_client/clients/api/assistance/timeline/__init__.py b/glpi_python_client/clients/api/assistance/timeline/__init__.py
deleted file mode 100644
index 1685427..0000000
--- a/glpi_python_client/clients/api/assistance/timeline/__init__.py
+++ /dev/null
@@ -1,23 +0,0 @@
-"""GLPI ticket-timeline mixins for the Synchronous client."""
-
-from __future__ import annotations
-
-from glpi_python_client.clients.api.assistance.timeline._document import (
- TimelineDocumentMixin,
-)
-from glpi_python_client.clients.api.assistance.timeline._followup import (
- FollowupMixin,
-)
-from glpi_python_client.clients.api.assistance.timeline._solution import (
- SolutionMixin,
-)
-from glpi_python_client.clients.api.assistance.timeline._task import (
- TicketTaskMixin,
-)
-
-__all__ = [
- "FollowupMixin",
- "SolutionMixin",
- "TicketTaskMixin",
- "TimelineDocumentMixin",
-]
diff --git a/glpi_python_client/clients/api/dropdowns/__init__.py b/glpi_python_client/clients/api/dropdowns/__init__.py
deleted file mode 100644
index 6edc745..0000000
--- a/glpi_python_client/clients/api/dropdowns/__init__.py
+++ /dev/null
@@ -1,7 +0,0 @@
-"""GLPI ``/Dropdowns`` mixins for the Synchronous client."""
-
-from __future__ import annotations
-
-from glpi_python_client.clients.api.dropdowns._location import LocationMixin
-
-__all__ = ["LocationMixin"]
diff --git a/glpi_python_client/clients/api/knowledgebase/__init__.py b/glpi_python_client/clients/api/knowledgebase/__init__.py
deleted file mode 100644
index 89ee53a..0000000
--- a/glpi_python_client/clients/api/knowledgebase/__init__.py
+++ /dev/null
@@ -1,23 +0,0 @@
-"""GLPI ``/Knowledgebase`` mixins for the synchronous and asynchronous clients."""
-
-from __future__ import annotations
-
-from glpi_python_client.clients.api.knowledgebase._article import KBArticleMixin
-from glpi_python_client.clients.api.knowledgebase._article_async import (
- AsyncKBArticleMixin,
-)
-from glpi_python_client.clients.api.knowledgebase._category import KBCategoryMixin
-from glpi_python_client.clients.api.knowledgebase._comment import (
- KBArticleCommentMixin,
-)
-from glpi_python_client.clients.api.knowledgebase._revision import (
- KBArticleRevisionMixin,
-)
-
-__all__ = [
- "AsyncKBArticleMixin",
- "KBArticleCommentMixin",
- "KBArticleMixin",
- "KBArticleRevisionMixin",
- "KBCategoryMixin",
-]
diff --git a/glpi_python_client/clients/api/knowledgebase/_article_async.py b/glpi_python_client/clients/api/knowledgebase/_article_async.py
deleted file mode 100644
index 74dd642..0000000
--- a/glpi_python_client/clients/api/knowledgebase/_article_async.py
+++ /dev/null
@@ -1,152 +0,0 @@
-"""Asynchronous overrides for KB article category assignment.
-
-The v2 API exposes ``KBArticle.categories[].id`` as ``readOnly``, so
-:meth:`create_kb_article` and :meth:`update_kb_article` apply categories
-through the legacy v1 ``_categories`` fallback. That fallback calls the
-public :meth:`set_kb_article_categories` through ``self``, which the async
-bridge has wrapped into a coroutine — so the synchronous bodies drop the
-call and the article silently keeps no categories.
-
-These overrides strip ``categories`` from the model, run the untouched
-synchronous v2 write in a worker thread (its own fallback then no-ops),
-and apply the categories with an awaited call. Keeping the sync module
-untouched makes the fix purely additive.
-
-The mixin must sit **before** :class:`KBArticleMixin` in the
-:class:`~glpi_python_client.clients.AsyncGlpiClient` base list.
-"""
-
-from __future__ import annotations
-
-import asyncio
-
-from glpi_python_client._errors import GlpiValidationError
-from glpi_python_client.clients.api.knowledgebase._article import KBArticleMixin
-from glpi_python_client.clients.commons._constants import GlpiId
-from glpi_python_client.models.api_schema._common import IdNameRef
-from glpi_python_client.models.api_schema.knowledgebase._article import (
- PatchKBArticle,
- PostKBArticle,
-)
-
-
-class AsyncKBArticleMixin(KBArticleMixin):
- """Async overrides for the two KB article writes that set categories."""
-
- async def _apply_category_fallback_async(
- self, article_id: GlpiId, categories: list[IdNameRef] | None
- ) -> None:
- """Apply ``categories`` through the awaited legacy fallback.
-
- Mirrors :meth:`KBArticleMixin._apply_category_fallback` but awaits
- the bridge-wrapped :meth:`set_kb_article_categories`.
-
- Parameters
- ----------
- article_id : GlpiId
- Identifier of the article to re-categorise.
- categories : list[IdNameRef] | None
- Category references to link. ``None`` is a no-op; an empty
- list clears every category.
-
- Returns
- -------
- None
-
- Raises
- ------
- GlpiValidationError
- When a category reference lacks an ``id``.
- """
-
- if categories is None:
- return
- ids: list[int] = []
- for ref in categories:
- if ref.id is None:
- raise GlpiValidationError(
- "KB article categories require an 'id' to be linked; got a "
- "category reference without an id."
- )
- ids.append(ref.id)
- # ``set_kb_article_categories`` is declared on ``KBArticleMixin``, the
- # very class this mixin subclasses, so mypy resolves it statically as
- # the synchronous ``-> None`` method rather than the bridge-generated
- # coroutine it becomes at runtime on ``AsyncGlpiClient``. That mismatch
- # is exactly what makes the await necessary here.
- await self.set_kb_article_categories( # type: ignore[misc, func-returns-value]
- article_id, ids
- )
-
- async def create_kb_article( # type: ignore[override]
- self, article: PostKBArticle
- ) -> int:
- """Create one knowledge base article and return its new identifier.
-
- Async override of :meth:`KBArticleMixin.create_kb_article`. The v2
- create runs in a worker thread with ``categories`` stripped, then
- the categories are applied through the awaited legacy fallback.
- Error semantics match the synchronous version: the create is not
- undone when the category assignment fails.
-
- Parameters
- ----------
- article : PostKBArticle
- Body of the article to create.
-
- Returns
- -------
- int
- Identifier assigned by GLPI.
-
- Raises
- ------
- RuntimeError
- When the article was created but assigning its categories
- failed. The message names the new article id.
- """
-
- stripped = article.model_copy(update={"categories": None})
- new_id: int = await asyncio.to_thread(
- KBArticleMixin.create_kb_article, self, stripped
- )
- if article.categories:
- try:
- await self._apply_category_fallback_async(new_id, article.categories)
- except Exception as exc:
- raise RuntimeError(
- f"KB article {new_id} was created but assigning its "
- f"categories failed: {exc}"
- ) from exc
- return new_id
-
- async def update_kb_article( # type: ignore[override]
- self, article_id: GlpiId, article: PatchKBArticle
- ) -> None:
- """Update one knowledge base article with a partial body.
-
- Async override of :meth:`KBArticleMixin.update_kb_article`. The v2
- patch runs in a worker thread with ``categories`` stripped, then
- the categories are applied through the awaited legacy fallback.
- ``None`` leaves categories untouched; an empty list clears them.
-
- Parameters
- ----------
- article_id : GlpiId
- Identifier of the article to update.
- article : PatchKBArticle
- Partial body to apply.
-
- Returns
- -------
- None
- """
-
- stripped = article.model_copy(update={"categories": None})
- await asyncio.to_thread(
- KBArticleMixin.update_kb_article, self, article_id, stripped
- )
- await self._apply_category_fallback_async(article_id, article.categories)
-
-
-__all__ = ["AsyncKBArticleMixin"]
diff --git a/glpi_python_client/clients/api/knowledgebase/tests/test_article_async.py b/glpi_python_client/clients/api/knowledgebase/tests/test_article_async.py
deleted file mode 100644
index 86d7f24..0000000
--- a/glpi_python_client/clients/api/knowledgebase/tests/test_article_async.py
+++ /dev/null
@@ -1,236 +0,0 @@
-"""Async-client tests for KB article category assignment.
-
-The v2 API cannot write KB categories, so create/update apply them through
-the legacy v1 ``_categories`` fallback. That fallback runs through the
-public ``set_kb_article_categories``, which the async bridge wraps into a
-coroutine — so without an override the category write is silently dropped
-and the article is created with no category at all.
-"""
-
-from __future__ import annotations
-
-from typing import Any
-
-import pytest
-
-from glpi_python_client import (
- AsyncGlpiClient,
- GlpiValidationError,
- PatchKBArticle,
- PostKBArticle,
-)
-from glpi_python_client.models.api_schema._common import IdNameRef
-from glpi_python_client.testing.utils import FakeResponse, make_async_client
-
-
-class _FakeV1:
- """Stand-in for ``GLPIV1Session`` recording ``request_json`` calls."""
-
- def __init__(self) -> None:
- self.calls: list[dict[str, Any]] = []
-
- def request_json(
- self,
- method: str,
- path: str,
- *,
- params: dict[str, object] | None = None,
- json_body: dict[str, object] | None = None,
- success_statuses: tuple[int, ...] = (200, 201, 204, 206),
- failure_message: str | None = None,
- ) -> object:
- self.calls.append({"method": method, "path": path, "json_body": json_body})
- return [{"1": True, "message": ""}]
-
-
-@pytest.fixture
-def client() -> AsyncGlpiClient:
- """Return an async client with the v2 transport stubbed out.
-
- Both stubs record every ``json_body`` they receive (on the ``post_bodies``
- / ``patch_bodies`` attributes attached to the client) so tests can assert
- that stripping ``categories`` for the legacy fallback left the rest of
- the v2 request body untouched.
- """
-
- c = make_async_client()
- post_bodies: list[dict[str, object] | None] = []
- patch_bodies: list[dict[str, object] | None] = []
-
- def _post(
- endpoint: str,
- json_body: dict[str, object] | None = None,
- skip_entity: bool = False,
- ) -> FakeResponse:
- post_bodies.append(json_body)
- return FakeResponse(status_code=201, payload={"id": 42})
-
- def _patch(
- endpoint: str, json_body: dict[str, object] | None = None
- ) -> FakeResponse:
- patch_bodies.append(json_body)
- return FakeResponse(status_code=200, payload={"id": 42})
-
- c._post_request = _post # type: ignore[assignment]
- c._update_request = _patch # type: ignore[assignment]
- c.post_bodies = post_bodies # type: ignore[attr-defined]
- c.patch_bodies = patch_bodies # type: ignore[attr-defined]
- return c
-
-
-async def test_create_kb_article_links_categories(client: AsyncGlpiClient) -> None:
- """Creating with categories must actually issue the v1 category write."""
-
- fake = _FakeV1()
- client._v1 = fake # type: ignore[assignment]
-
- new_id = await client.create_kb_article(
- PostKBArticle(name="t", answer="a", categories=[IdNameRef(id=7, name="cat")])
- )
-
- assert new_id == 42
- assert fake.calls == [
- {
- "method": "PUT",
- "path": "KnowbaseItem/42",
- "json_body": {"input": {"_categories": [7]}},
- }
- ]
- # The stripped v2 body must still carry every other field: only
- # ``categories`` was removed before the worker-thread create call runs.
- # A ``model_copy(update=...)`` that nuked more than ``categories`` would
- # otherwise pass every other assertion in this file undetected.
- assert client.post_bodies == [{"name": "t", "answer": "a"}] # type: ignore[attr-defined]
-
-
-async def test_create_kb_article_without_categories_needs_no_v1(
- client: AsyncGlpiClient,
-) -> None:
- """Omitting categories must not require a v1 session."""
-
- client._v1 = None
- assert await client.create_kb_article(PostKBArticle(name="t", answer="a")) == 42
-
-
-async def test_create_kb_article_wraps_category_failure(
- client: AsyncGlpiClient,
-) -> None:
- """A category failure after create raises RuntimeError naming the id."""
-
- client._v1 = None # no v1 session -> the fallback raises RuntimeError
-
- with pytest.raises(RuntimeError, match="KB article 42 was created but"):
- await client.create_kb_article(
- PostKBArticle(
- name="t", answer="a", categories=[IdNameRef(id=7, name="cat")]
- )
- )
-
-
-async def test_create_kb_article_wraps_missing_id_category(
- client: AsyncGlpiClient,
-) -> None:
- """A category ref without an id is wrapped in the same ``RuntimeError``.
-
- ``_apply_category_fallback_async`` raises ``ValueError`` before ever
- touching a v1 session when a category reference has no ``id`` (see
- ``_article_async.py``). ``create_kb_article`` wraps every fallback
- failure — including this one — into ``RuntimeError``. This is the
- async copy of a branch already covered on the sync client; the two
- copies can drift independently, so this branch needs its own test
- rather than relying on sync coverage.
- """
-
- with pytest.raises(RuntimeError, match="KB article 42 was created but"):
- await client.create_kb_article(
- PostKBArticle(name="t", answer="a", categories=[IdNameRef(name="cat")])
- )
-
-
-async def test_update_kb_article_links_categories(client: AsyncGlpiClient) -> None:
- """Updating with a non-empty list must issue the v1 category write.
-
- This is the update-path counterpart of
- ``test_create_kb_article_links_categories``. Without it, the
- non-empty-list case on ``update_kb_article`` is only covered by
- composition: the ``[]`` test below proves the update path fires the
- legacy fallback at all, and the create test proves the id-collection
- loop works, but neither proves a non-empty list on *update*, the
- headline regression this branch fixed, actually reaches the v1
- ``PUT``.
- """
-
- fake = _FakeV1()
- client._v1 = fake # type: ignore[assignment]
-
- await client.update_kb_article(
- 42, PatchKBArticle(name="t3", categories=[IdNameRef(id=7, name="cat")])
- )
-
- assert fake.calls == [
- {
- "method": "PUT",
- "path": "KnowbaseItem/42",
- "json_body": {"input": {"_categories": [7]}},
- }
- ]
- # The stripped v2 patch body must still carry every other field: only
- # ``categories`` was removed before the worker-thread update call runs.
- assert client.patch_bodies == [{"name": "t3"}] # type: ignore[attr-defined]
-
-
-async def test_update_kb_article_clears_categories(client: AsyncGlpiClient) -> None:
- """An empty list clears every category through the v1 fallback."""
-
- fake = _FakeV1()
- client._v1 = fake # type: ignore[assignment]
-
- await client.update_kb_article(42, PatchKBArticle(name="t2", categories=[]))
-
- assert fake.calls == [
- {
- "method": "PUT",
- "path": "KnowbaseItem/42",
- "json_body": {"input": {"_categories": []}},
- }
- ]
- # The stripped v2 patch body must still carry every other field: only
- # ``categories`` was removed before the worker-thread update call runs.
- # A ``model_copy(update=...)`` that nuked more than ``categories`` would
- # otherwise pass every other assertion in this file undetected.
- assert client.patch_bodies == [{"name": "t2"}] # type: ignore[attr-defined]
-
-
-async def test_update_kb_article_raises_on_missing_id_category(
- client: AsyncGlpiClient,
-) -> None:
- """A category ref without an id raises the raw ``GlpiValidationError`` on update.
-
- Unlike ``create_kb_article``, ``update_kb_article`` does not wrap the
- fallback call in a ``try``/``except``: the v2 patch has already been
- applied by the time categories are assigned, so there is nothing to
- roll back and no article-was-created message to build around. The raw
- ``GlpiValidationError`` from ``_apply_category_fallback_async`` must
- therefore propagate unchanged. ``GlpiValidationError`` inherits
- ``ValueError`` so existing callers that catch the broader type keep
- working.
- """
-
- with pytest.raises(GlpiValidationError, match="require an 'id'") as excinfo:
- await client.update_kb_article(
- 42, PatchKBArticle(name="t2", categories=[IdNameRef(name="cat")])
- )
- assert isinstance(excinfo.value, ValueError)
-
-
-async def test_update_kb_article_without_categories_skips_v1(
- client: AsyncGlpiClient,
-) -> None:
- """``categories=None`` leaves categories untouched and calls no v1."""
-
- fake = _FakeV1()
- client._v1 = fake # type: ignore[assignment]
-
- await client.update_kb_article(42, PatchKBArticle(name="t2"))
-
- assert fake.calls == []
diff --git a/glpi_python_client/clients/api/management/__init__.py b/glpi_python_client/clients/api/management/__init__.py
deleted file mode 100644
index fc085b5..0000000
--- a/glpi_python_client/clients/api/management/__init__.py
+++ /dev/null
@@ -1,7 +0,0 @@
-"""GLPI ``/Management`` mixins for the Synchronous client."""
-
-from __future__ import annotations
-
-from glpi_python_client.clients.api.management._document import DocumentMixin
-
-__all__ = ["DocumentMixin"]
diff --git a/glpi_python_client/clients/api/plugins/__init__.py b/glpi_python_client/clients/api/plugins/__init__.py
deleted file mode 100644
index f064e45..0000000
--- a/glpi_python_client/clients/api/plugins/__init__.py
+++ /dev/null
@@ -1,11 +0,0 @@
-"""GLPI plugin endpoint mixins exposed via the legacy v1 REST API.
-
-Plugins are not advertised in the v2 OpenAPI contract so the mixins
-under this package go through the v1 session helper exposed by
-:class:`~glpi_python_client.auth._v1_session.GLPIV1Session`.
-"""
-
-from glpi_python_client.clients.api.plugins._fields import PluginFieldsMixin
-from glpi_python_client.clients.api.plugins._fields_async import AsyncPluginFieldsMixin
-
-__all__ = ["AsyncPluginFieldsMixin", "PluginFieldsMixin"]
diff --git a/glpi_python_client/clients/api/plugins/_fields_async.py b/glpi_python_client/clients/api/plugins/_fields_async.py
deleted file mode 100644
index a84d1f3..0000000
--- a/glpi_python_client/clients/api/plugins/_fields_async.py
+++ /dev/null
@@ -1,157 +0,0 @@
-"""Asynchronous overrides for the Fields plugin aggregation helpers.
-
-:meth:`get_ticket_custom_fields` and :meth:`set_ticket_custom_fields` call
-sibling public methods through ``self``. Under the async bridge those
-resolve to coroutine functions, so the synchronous bodies would receive
-coroutine objects instead of data. These overrides await the
-bridge-wrapped calls on the event loop instead.
-
-The mixin must sit **before** :class:`PluginFieldsMixin` in the
-:class:`~glpi_python_client.clients.AsyncGlpiClient` base list so the
-bridge's ``__init_subclass__`` hook finds the coroutine via ``getattr``
-and leaves it alone.
-
-Every awaited call below carries ``# type: ignore[misc]``: the awaited
-method (e.g. :meth:`PluginFieldsMixin.list_plugin_fields_containers`) is
-declared on this mixin's own parent, so mypy resolves it statically as
-the synchronous ``-> T`` method rather than the bridge-generated
-coroutine it becomes at runtime on
-:class:`~glpi_python_client.clients.AsyncGlpiClient`. See the matching
-note in
-:mod:`glpi_python_client.clients.api.knowledgebase._article_async` for
-the same vocabulary, and contrast with the ``[attr-defined]`` codes in
-:mod:`glpi_python_client.clients.custom._statistics_async`, where the
-awaited methods are declared on a *different* mixin and mypy cannot
-resolve them statically at all.
-"""
-
-from __future__ import annotations
-
-from typing import Any
-
-from glpi_python_client._errors import GlpiProtocolError, GlpiValidationError
-from glpi_python_client.clients.api.plugins._fields import (
- _TICKET_ITEMTYPE,
- PluginFieldsMixin,
-)
-from glpi_python_client.models.api_schema.plugins import GetPluginFieldsContainer
-
-
-class AsyncPluginFieldsMixin(PluginFieldsMixin):
- """Async overrides for the two Fields plugin aggregation helpers."""
-
- async def get_ticket_custom_fields( # type: ignore[override]
- self, ticket_id: int
- ) -> dict[str, dict[str, Any]]:
- """Return the custom-field values defined for one ticket.
-
- Async override of
- :meth:`PluginFieldsMixin.get_ticket_custom_fields`; the awaited
- calls are the bridge-wrapped public helpers.
-
- Parameters
- ----------
- ticket_id : int
- Identifier of the ticket whose custom values are requested.
-
- Returns
- -------
- dict[str, dict[str, Any]]
- Per-container value mappings. Empty when the ticket has no
- stored custom values across any container.
- """
-
- containers = await self.list_plugin_fields_containers( # type: ignore[misc]
- itemtype=_TICKET_ITEMTYPE
- )
- result: dict[str, dict[str, Any]] = {}
- for container in containers:
- name = container.name
- if not name:
- continue
- rows = await self.list_item_plugin_field_rows( # type: ignore[misc]
- _TICKET_ITEMTYPE, ticket_id, name
- )
- if not rows:
- continue
- result[name] = dict(rows[0].extra_payload)
- return result
-
- async def set_ticket_custom_fields( # type: ignore[override]
- self,
- ticket_id: int,
- values: dict[str, dict[str, Any]],
- ) -> None:
- """Persist custom-field values on one ticket.
-
- Async override of
- :meth:`PluginFieldsMixin.set_ticket_custom_fields`. Validation
- order is identical to the synchronous version: unknown containers
- and fields raise before any write.
-
- Parameters
- ----------
- ticket_id : int
- Identifier of the ticket whose custom values must be set.
- values : dict[str, dict[str, Any]]
- Nested mapping ``{container_name: {field_name: value}}``.
-
- Returns
- -------
- None
- """
-
- if not values:
- return
-
- containers = await self.list_plugin_fields_containers( # type: ignore[misc]
- itemtype=_TICKET_ITEMTYPE
- )
- by_name: dict[str, GetPluginFieldsContainer] = {
- c.name: c for c in containers if c.name is not None
- }
- unknown = sorted(set(values) - set(by_name))
- if unknown:
- raise GlpiValidationError(
- "Unknown plugin-fields container(s) for Ticket: " + ", ".join(unknown)
- )
-
- for container_name, column_values in values.items():
- container = by_name[container_name]
- if container.id is None:
- raise GlpiProtocolError(
- f"Container {container_name!r} has no id; cannot write values"
- )
-
- declared_fields = await self.list_plugin_fields_fields( # type: ignore[misc]
- container_id=container.id
- )
- declared = {f.name for f in declared_fields if f.name is not None}
- unknown_fields = sorted(set(column_values) - declared)
- if unknown_fields:
- raise GlpiValidationError(
- f"Unknown field(s) for container {container_name!r}: "
- + ", ".join(unknown_fields)
- )
-
- existing_rows = await self.list_item_plugin_field_rows( # type: ignore[misc]
- _TICKET_ITEMTYPE, ticket_id, container_name
- )
- if existing_rows and existing_rows[0].id is not None:
- await self.update_item_plugin_field_row( # type: ignore[misc, func-returns-value]
- itemtype=_TICKET_ITEMTYPE,
- container_name=container_name,
- row_id=existing_rows[0].id,
- values=column_values,
- )
- else:
- await self.create_item_plugin_field_row( # type: ignore[misc]
- itemtype=_TICKET_ITEMTYPE,
- items_id=ticket_id,
- container_id=container.id,
- container_name=container_name,
- values=column_values,
- )
-
-
-__all__ = ["AsyncPluginFieldsMixin"]
diff --git a/glpi_python_client/clients/api/plugins/tests/test_fields_async.py b/glpi_python_client/clients/api/plugins/tests/test_fields_async.py
deleted file mode 100644
index 77fab60..0000000
--- a/glpi_python_client/clients/api/plugins/tests/test_fields_async.py
+++ /dev/null
@@ -1,147 +0,0 @@
-"""Async-client tests for the Fields plugin aggregation helpers.
-
-These helpers call sibling *public* methods through ``self``. On
-``AsyncGlpiClient`` those resolve to bridge-wrapped coroutines, so without
-a hand-written async override they raise ``TypeError: 'coroutine' object
-is not iterable``. See clients/tests/test_async_selfcall_guard.py.
-"""
-
-from __future__ import annotations
-
-from typing import Any
-
-import pytest
-
-from glpi_python_client import AsyncGlpiClient, GlpiProtocolError, GlpiValidationError
-from glpi_python_client.testing.utils import make_async_client
-
-
-class _FakeV1:
- """Stand-in for ``GLPIV1Session`` returning queued payloads."""
-
- def __init__(self, responses: list[object]) -> None:
- self.responses = list(responses)
- self.calls: list[dict[str, Any]] = []
-
- def request_json(
- self,
- method: str,
- path: str,
- *,
- params: dict[str, object] | None = None,
- json_body: dict[str, object] | None = None,
- success_statuses: tuple[int, ...] = (200, 201, 204, 206),
- failure_message: str | None = None,
- ) -> object:
- self.calls.append({"method": method, "path": path, "json_body": json_body})
- if not self.responses:
- raise AssertionError(f"Unexpected v1 call: {method} {path}")
- return self.responses.pop(0)
-
-
-@pytest.fixture
-def client() -> AsyncGlpiClient:
- """Return an async client with no HTTP plumbing wired up."""
-
- return make_async_client()
-
-
-async def test_get_ticket_custom_fields_returns_values(
- client: AsyncGlpiClient,
-) -> None:
- """The async helper must return data, not a dropped coroutine."""
-
- client._v1 = _FakeV1( # type: ignore[assignment]
- [
- [{"id": 1, "name": "custom", "itemtypes": '["Ticket"]'}],
- [{"id": 5, "customfield": "hello"}],
- ]
- )
-
- result = await client.get_ticket_custom_fields(1)
-
- assert result == {"custom": {"customfield": "hello"}}
-
-
-async def test_set_ticket_custom_fields_updates_existing_row(
- client: AsyncGlpiClient,
-) -> None:
- """An existing value row is updated in place through the v1 session."""
-
- fake = _FakeV1(
- [
- [{"id": 1, "name": "custom", "itemtypes": '["Ticket"]'}],
- [{"id": 9, "plugin_fields_containers_id": 1, "name": "customfield"}],
- [{"id": 5, "customfield": "old"}],
- [{"5": True, "message": ""}],
- ]
- )
- client._v1 = fake # type: ignore[assignment]
-
- await client.set_ticket_custom_fields(1, {"custom": {"customfield": "new"}})
-
- update = fake.calls[-1]
- assert update["method"] == "PUT"
- assert update["json_body"] == {"input": {"id": 5, "customfield": "new"}}
-
-
-async def test_set_ticket_custom_fields_rejects_unknown_container(
- client: AsyncGlpiClient,
-) -> None:
- """Unknown containers raise before any write.
-
- ``GlpiValidationError`` inherits ``ValueError`` so existing callers that
- catch the broader type keep working.
- """
-
- client._v1 = _FakeV1( # type: ignore[assignment]
- [[{"id": 1, "name": "custom", "itemtypes": '["Ticket"]'}]]
- )
-
- with pytest.raises(
- GlpiValidationError, match="Unknown plugin-fields container"
- ) as excinfo:
- await client.set_ticket_custom_fields(1, {"nope": {"x": 1}})
- assert isinstance(excinfo.value, ValueError)
-
-
-async def test_set_ticket_custom_fields_rejects_container_without_id(
- client: AsyncGlpiClient,
-) -> None:
- """A matched container with no ``id`` raises before any write.
-
- The container came from the server's own
- ``list_plugin_fields_containers`` response, so a missing ``id`` is a
- server-side contract violation, not a caller mistake:
- ``GlpiProtocolError``. It still inherits ``ValueError`` so existing
- callers that catch the broader type keep working.
- """
-
- client._v1 = _FakeV1( # type: ignore[assignment]
- [[{"name": "custom", "itemtypes": '["Ticket"]'}]]
- )
-
- with pytest.raises(GlpiProtocolError, match="has no id") as excinfo:
- await client.set_ticket_custom_fields(1, {"custom": {"customfield": "x"}})
- assert isinstance(excinfo.value, ValueError)
-
-
-async def test_set_ticket_custom_fields_rejects_unknown_field(
- client: AsyncGlpiClient,
-) -> None:
- """A typo in the field name raises before any write.
-
- ``GlpiValidationError`` inherits ``ValueError`` so existing callers that
- catch the broader type keep working.
- """
-
- client._v1 = _FakeV1( # type: ignore[assignment]
- [
- [{"id": 1, "name": "custom", "itemtypes": '["Ticket"]'}],
- [{"id": 9, "plugin_fields_containers_id": 1, "name": "customfield"}],
- ]
- )
-
- with pytest.raises(GlpiValidationError, match="Unknown field") as excinfo:
- await client.set_ticket_custom_fields(1, {"custom": {"typo": "value"}})
- assert isinstance(excinfo.value, ValueError)
diff --git a/glpi_python_client/clients/async_client.py b/glpi_python_client/clients/async_client.py
deleted file mode 100644
index e8d4d30..0000000
--- a/glpi_python_client/clients/async_client.py
+++ /dev/null
@@ -1,186 +0,0 @@
-"""Public asynchronous GLPI client class.
-
-The :class:`AsyncGlpiClient` reuses every synchronous mixin composed
-into :class:`~glpi_python_client.clients.sync_client.GlpiClient` and
-wraps each public method into a coroutine through
-:class:`~glpi_python_client.clients.commons._async_bridge.AsyncBridge`.
-Some methods ship hand-written async overrides instead — for concurrent
-``asyncio.gather`` fan-out (:mod:`glpi_python_client.clients.custom`) or
-to stop the bridge from silently dropping an internal call to a sibling
-public method through ``self``
-(:mod:`glpi_python_client.clients.api.knowledgebase`,
-:mod:`glpi_python_client.clients.api.plugins`).
-
-The async client owns the same HTTP session and token manager as the
-synchronous client but its lifecycle is driven through ``async with`` /
-``await close()``. Token acquisition is still serialised by the shared
-:class:`threading.Lock` so concurrent ``asyncio.gather`` calls cannot
-race on the worker threads spawned by :func:`asyncio.to_thread`.
-"""
-
-from __future__ import annotations
-
-import asyncio
-import logging
-import sys
-from concurrent.futures import Executor
-from types import TracebackType
-from typing import Any
-
-if sys.version_info >= (3, 11):
- from typing import Self
-else: # pragma: no cover - fallback for Python 3.10
- from typing_extensions import Self
-
-from glpi_python_client.clients._base_client import _BaseGlpiClient
-from glpi_python_client.clients.api import (
- AsyncKBArticleMixin,
- AsyncPluginFieldsMixin,
- DocumentMixin,
- EntityMixin,
- FollowupMixin,
- KBArticleCommentMixin,
- KBArticleRevisionMixin,
- KBCategoryMixin,
- LocationMixin,
- SolutionMixin,
- TeamMemberMixin,
- TicketMixin,
- TicketTaskMixin,
- TimelineDocumentMixin,
- UserMixin,
-)
-from glpi_python_client.clients.commons._async_bridge import AsyncBridge
-from glpi_python_client.clients.commons._transport import TransportMixin
-from glpi_python_client.clients.custom._pagination_async import AsyncPaginationMixin
-from glpi_python_client.clients.custom._statistics_async import AsyncStatisticsMixin
-from glpi_python_client.clients.custom._ticket_context_async import (
- AsyncTicketContextMixin,
-)
-
-logger = logging.getLogger(__name__)
-
-
-class AsyncGlpiClient( # type: ignore[misc]
- AsyncBridge,
- AsyncPaginationMixin,
- TicketMixin,
- TicketTaskMixin,
- FollowupMixin,
- SolutionMixin,
- TimelineDocumentMixin,
- TeamMemberMixin,
- DocumentMixin,
- UserMixin,
- EntityMixin,
- LocationMixin,
- KBCategoryMixin,
- AsyncKBArticleMixin,
- KBArticleCommentMixin,
- KBArticleRevisionMixin,
- AsyncPluginFieldsMixin,
- AsyncTicketContextMixin,
- AsyncStatisticsMixin,
- _BaseGlpiClient,
- TransportMixin,
-):
- """Asynchronous GLPI client built on the sync mixins via the bridge.
-
- Every public sync method exposed by the inherited mixins is
- automatically wrapped into a coroutine that defers the blocking call
- to a worker thread. A handful of methods ship hand-written async
- overrides instead — for concurrent fan-out or to stop the bridge
- from silently dropping an internal call to a sibling public method
- through ``self`` — which are preserved as coroutine functions by the
- bridge.
-
- Construction parameters and :meth:`from_env` are documented on
- :class:`~glpi_python_client.clients._base_client._BaseGlpiClient`;
- the only additional keyword is ``executor`` (described below).
- """
-
- def __init__(self, *, executor: Executor | None = None, **kwargs: Any) -> None:
- """Build an asynchronous GLPI client and its transport resources.
-
- Parameters
- ----------
- executor : concurrent.futures.Executor | None, optional
- Optional executor that every bridge-generated wrapped call
- (see :class:`~glpi_python_client.clients.commons._async_bridge.AsyncBridge`)
- is routed through. When ``None`` (the default) the bridge
- falls back to :func:`asyncio.to_thread`, which uses the
- loop's default thread pool executor. Supply a dedicated
- :class:`concurrent.futures.ThreadPoolExecutor` when the
- application performs aggressive fan-outs that would
- otherwise saturate the default pool. This does **not**
- cover the hand-written
- :class:`~glpi_python_client.clients.api.knowledgebase._article_async.AsyncKBArticleMixin`
- overrides (``create_kb_article``/``update_kb_article``): they
- dispatch their worker-thread call through a plain
- :func:`asyncio.to_thread` and always use the loop's default
- thread pool regardless of this argument.
- **kwargs : Any
- Remaining keyword arguments forwarded to
- :class:`~glpi_python_client.clients._base_client._BaseGlpiClient`.
-
- Raises
- ------
- GlpiValidationError
- If the supplied configuration is incomplete or invalid (e.g.
- missing OAuth credentials together with no v1 fallback).
- """
-
- super().__init__(**kwargs)
- self._executor = executor
-
- async def close(self) -> None:
- """Release every resource owned by the client.
-
- The shared HTTP session is closed off-thread, the optional v1
- fallback session is closed off-thread, and the client is marked
- as closed so subsequent calls raise immediately. The method is
- idempotent.
- """
-
- if self._closed:
- return
- try:
- await asyncio.to_thread(self._session.close)
- if self._v1 is not None:
- await asyncio.to_thread(self._v1.close)
- finally:
- self._closed = True
-
- async def __aenter__(self) -> Self:
- """Return the client unchanged for use in an ``async with`` block.
-
- Returns
- -------
- AsyncGlpiClient
- The client itself, suitable for chaining method calls.
- """
-
- return self
-
- async def __aexit__(
- self,
- exc_type: type[BaseException] | None,
- exc: BaseException | None,
- tb: TracebackType | None,
- ) -> None:
- """Close the client on ``async with`` exit.
-
- Parameters
- ----------
- exc_type : type[BaseException] | None
- Exception class raised inside the ``async with`` block, if any.
- exc : BaseException | None
- Exception instance raised inside the block, if any.
- tb : TracebackType | None
- Traceback associated with ``exc``.
- """
-
- await self.close()
-
-
-__all__ = ["AsyncGlpiClient"]
diff --git a/glpi_python_client/clients/commons/_async_bridge.py b/glpi_python_client/clients/commons/_async_bridge.py
deleted file mode 100644
index a4546fe..0000000
--- a/glpi_python_client/clients/commons/_async_bridge.py
+++ /dev/null
@@ -1,191 +0,0 @@
-"""Synchronous-to-asynchronous bridge for the GLPI client.
-
-The bridge inspects every public sync method exposed by the bases of a
-subclass, then installs a coroutine wrapper on the subclass that defers
-the blocking call to a worker thread. This keeps the synchronous client
-as the single source of truth while still exposing a fully asynchronous
-public surface.
-
-Concurrency notes
------------------
-* Each wrapped call runs on a worker thread, which means concurrent
- callers contend on OS threads rather than only on the event loop. The
- underlying transport mixin protects shared state with a
- :class:`threading.Lock` for that reason.
-* When many coroutines fan out at once (for example through
- :func:`asyncio.gather`) the default :func:`asyncio.to_thread`
- executor can become a bottleneck. The
- :class:`~glpi_python_client.clients.AsyncGlpiClient` exposes an
- optional ``executor`` constructor argument that callers can use to
- supply a dedicated :class:`concurrent.futures.ThreadPoolExecutor`.
-* Cancellation is best-effort: cancelling the awaiting coroutine
- releases the awaiter immediately, but the in-flight HTTP request keeps
- running on the worker thread until ``requests`` returns. This matches
- the behaviour of the original async client.
-
-Known limitation — internal ``self``-calls
-------------------------------------------
-The bridge wraps *every* public method on the async client class. As a
-result, when a synchronous body that is running inside a worker thread
-calls another public method through ``self`` (e.g.
-``self.search_tickets(...)``), it resolves to the *bridge-wrapped*
-coroutine, not the synchronous function. Calling a coroutine without
-``await`` produces a dangling coroutine object, not data.
-
-Any sync method (or generator) that internally calls other public
-methods through ``self`` must therefore be given a hand-written async
-override that ``await``s (or ``async for``s) those calls on the event
-loop. The convention used in this codebase is to place such overrides
-in a ``_*_async.py`` companion module (e.g.
-``clients/custom/_statistics_async.py``) and wire them into the async
-client's MRO *before* the sync mixin that defines the original method.
-"""
-
-from __future__ import annotations
-
-import asyncio
-import functools
-import inspect
-from collections.abc import Callable
-from concurrent.futures import Executor
-from typing import Any
-
-# Sentinel used by the async-generator bridge to signal exhaustion without
-# propagating StopIteration through a coroutine (which PEP 479 forbids).
-_STOPPED: object = object()
-
-
-def _next_or_stopped(gen: Any) -> Any:
- """Return the next item from *gen* or ``_STOPPED`` when exhausted."""
-
- try:
- return next(gen)
- except StopIteration:
- return _STOPPED
-
-
-class AsyncBridge:
- """Base class that converts inherited sync methods into coroutines.
-
- The bridge is intended to be mixed into the most-derived async
- client class **before** the sync mixins so its
- :meth:`__init_subclass__` hook can observe the full MRO and install
- coroutine wrappers on the subclass for every public method that the
- sync mixins expose.
-
- Subclasses may also assign a :class:`concurrent.futures.Executor`
- instance to ``_executor`` to route every wrapped call through a
- dedicated pool. When ``_executor`` is ``None`` the bridge falls back
- to :func:`asyncio.to_thread`, which uses the default loop executor.
- """
-
- _executor: Executor | None = None
-
- def __init_subclass__(cls, **kwargs: object) -> None:
- """Install async wrappers for every inherited public sync method.
-
- The hook walks the resolution order from the most-derived sync
- base downwards and skips:
-
- * the bridge class itself and :class:`object`,
- * names that start with an underscore (private/protected),
- * attributes that are not callable, and
- * attributes that are already coroutine functions (so the
- subclass may declare hand-written async overrides such as
- :meth:`get_ticket_context`).
-
- Each surviving method is wrapped with a coroutine that defers
- the blocking call to a worker thread.
- """
-
- super().__init_subclass__(**kwargs)
- seen: set[str] = set()
- for base in cls.__mro__:
- if base in (cls, AsyncBridge, object):
- continue
- for name, member in vars(base).items():
- if name in seen or name.startswith("_"):
- continue
- if not callable(member) or inspect.iscoroutinefunction(member):
- continue
- if inspect.isasyncgenfunction(member):
- continue
- # Skip if the subclass already overrides the method with
- # a coroutine function or async generator (for example async fan-outs).
- existing = getattr(cls, name, None)
- if existing is not None and (
- inspect.iscoroutinefunction(existing)
- or inspect.isasyncgenfunction(existing)
- ):
- seen.add(name)
- continue
- seen.add(name)
- if inspect.isgeneratorfunction(member):
- setattr(cls, name, _make_async_generator_wrapper(member))
- else:
- setattr(cls, name, _make_async_wrapper(member))
-
-
-def _make_async_wrapper(sync_func: Callable[..., Any]) -> Callable[..., Any]:
- """Return one coroutine wrapper that runs ``sync_func`` off-thread.
-
- Parameters
- ----------
- sync_func : Callable[..., Any]
- Synchronous callable inherited from a sync mixin.
-
- Returns
- -------
- Callable[..., Any]
- Coroutine function that schedules ``sync_func`` on the bound
- client's executor (or :func:`asyncio.to_thread` when no
- executor is configured) and awaits its result.
- """
-
- @functools.wraps(sync_func)
- async def wrapper(self: AsyncBridge, *args: Any, **kwargs: Any) -> Any:
- bound = functools.partial(sync_func, self, *args, **kwargs)
- if self._executor is not None:
- loop = asyncio.get_running_loop()
- return await loop.run_in_executor(self._executor, bound)
- return await asyncio.to_thread(bound)
-
- return wrapper
-
-
-def _make_async_generator_wrapper(sync_func: Callable[..., Any]) -> Callable[..., Any]:
- """Return an async generator wrapper for a synchronous generator function.
-
- Each call to ``next()`` on the underlying sync generator is dispatched
- to a worker thread so that the blocking HTTP call inside the generator
- body does not block the event loop.
-
- Parameters
- ----------
- sync_func : Callable[..., Any]
- Synchronous generator function inherited from a sync mixin.
-
- Returns
- -------
- Callable[..., Any]
- Async generator function that yields the same items as the
- synchronous generator, one batch at a time, off the event loop.
- """
-
- @functools.wraps(sync_func)
- async def wrapper(self: AsyncBridge, *args: Any, **kwargs: Any) -> Any:
- gen = sync_func(self, *args, **kwargs)
- while True:
- if self._executor is not None:
- loop = asyncio.get_running_loop()
- item = await loop.run_in_executor(self._executor, _next_or_stopped, gen)
- else:
- item = await asyncio.to_thread(_next_or_stopped, gen)
- if item is _STOPPED:
- return
- yield item
-
- return wrapper
-
-
-__all__ = ["AsyncBridge"]
diff --git a/glpi_python_client/clients/custom/__init__.py b/glpi_python_client/clients/custom/__init__.py
deleted file mode 100644
index 92708d5..0000000
--- a/glpi_python_client/clients/custom/__init__.py
+++ /dev/null
@@ -1,34 +0,0 @@
-"""Custom higher-level helpers built on top of the API mixins.
-
-The custom package exposes operations that are not advertised by the GLPI
-API contract directly but are useful to client applications. Examples
-include the aggregated ticket-context view and small reporting utilities
-built by combining the contract-aligned CRUD helpers from
-:mod:`glpi_python_client.clients.api`.
-
-Both a synchronous mixin and an asynchronous override are provided for
-the helpers that benefit from concurrent fan-out
-(:mod:`glpi_python_client.clients.custom._ticket_context` and
-:mod:`glpi_python_client.clients.custom._statistics`). The synchronous
-mixins are composed into
-:class:`~glpi_python_client.clients.GlpiClient`; the async overrides are
-composed into :class:`~glpi_python_client.clients.AsyncGlpiClient`.
-"""
-
-from __future__ import annotations
-
-from glpi_python_client.clients.custom._pagination_async import AsyncPaginationMixin
-from glpi_python_client.clients.custom._statistics import StatisticsMixin
-from glpi_python_client.clients.custom._statistics_async import AsyncStatisticsMixin
-from glpi_python_client.clients.custom._ticket_context import TicketContextMixin
-from glpi_python_client.clients.custom._ticket_context_async import (
- AsyncTicketContextMixin,
-)
-
-__all__ = [
- "AsyncPaginationMixin",
- "AsyncStatisticsMixin",
- "AsyncTicketContextMixin",
- "StatisticsMixin",
- "TicketContextMixin",
-]
diff --git a/glpi_python_client/clients/custom/_pagination_async.py b/glpi_python_client/clients/custom/_pagination_async.py
deleted file mode 100644
index 96b83d3..0000000
--- a/glpi_python_client/clients/custom/_pagination_async.py
+++ /dev/null
@@ -1,164 +0,0 @@
-"""Asynchronous overrides for the three paginated search generators.
-
-Each of the three ``iter_search_*`` generators in the API mixins
-delegates its pagination loop to a ``self.search_*()`` call. When the
-:class:`~glpi_python_client.clients.commons._async_bridge.AsyncBridge`
-wraps a sync generator it drives ``next()`` on the generator object inside
-a worker thread. Inside that thread ``self`` is still the
-:class:`~glpi_python_client.clients.AsyncGlpiClient` instance, so
-``self.search_tickets(...)`` resolves to the bridge-wrapped coroutine
-function and calling it without ``await`` returns a coroutine object
-instead of the expected list — triggering a
-``RuntimeWarning: coroutine … was never awaited`` and a 500 response.
-
-This mixin replaces all three generators with native async generators that
-``await self.search_*(...)`` directly on the event loop.
-
-The mixin must be positioned **before** ``TicketMixin``, ``UserMixin``, and
-``EntityMixin`` in the :class:`~glpi_python_client.clients.AsyncGlpiClient`
-base list so that the bridge's ``__init_subclass__`` hook finds the async
-generator via ``getattr`` before it would otherwise wrap the sync version.
-"""
-
-from __future__ import annotations
-
-from collections.abc import AsyncIterator
-
-from glpi_python_client.models.api_schema.administration._entity import GetEntity
-from glpi_python_client.models.api_schema.administration._user import GetUser
-from glpi_python_client.models.api_schema.assistance._ticket import GetTicket
-
-
-class AsyncPaginationMixin:
- """Async generator overrides for the three paginated search helpers.
-
- Each override re-implements the simple ``start``-advancing loop of
- the synchronous counterpart but ``await``\\ s the underlying
- ``search_*`` call so it runs on the event loop rather than inside a
- worker-thread-dispatched ``next()`` call where the coroutine would
- be dropped on the floor.
- """
-
- async def iter_search_tickets(
- self,
- rsql_filter: str = "",
- *,
- batch_size: int = 50,
- sort: str | None = None,
- fields: tuple[str, ...] = (),
- ) -> AsyncIterator[list[GetTicket]]:
- """Yield successive pages of GLPI tickets until exhausted.
-
- Parameters
- ----------
- rsql_filter : str, optional
- Raw RSQL filter forwarded as the ``filter`` query parameter.
- Empty by default, which lists every visible ticket.
- batch_size : int, optional
- Number of records requested per page (default 50).
- sort : str | None, optional
- ``sort`` query parameter forwarded as-is to each page request.
- fields : tuple[str, ...], optional
- Restricted set of contract field names to request.
-
- Yields
- ------
- list[GetTicket]
- One page of tickets per iteration. The last yielded batch may
- be shorter than ``batch_size``.
- """
-
- start = 0
- while True:
- batch: list[GetTicket] = await self.search_tickets( # type: ignore[attr-defined]
- rsql_filter,
- limit=batch_size,
- start=start,
- sort=sort,
- fields=fields,
- )
- if batch:
- yield batch
- if len(batch) < batch_size:
- break
- start += batch_size
-
- async def iter_search_users(
- self,
- rsql_filter: str = "",
- *,
- batch_size: int = 50,
- skip_entity: bool = False,
- ) -> AsyncIterator[list[GetUser]]:
- """Yield successive pages of GLPI users until exhausted.
-
- Parameters
- ----------
- rsql_filter : str, optional
- Raw RSQL filter forwarded as the ``filter`` query parameter.
- Empty by default, which lists every visible user.
- batch_size : int, optional
- Number of records requested per page (default 50).
- skip_entity : bool, optional
- When ``True`` the ``GLPI-Entity`` header is omitted so the
- search spans every entity the caller has access to.
-
- Yields
- ------
- list[GetUser]
- One page of users per iteration. The last yielded batch may
- be shorter than ``batch_size``.
- """
-
- start = 0
- while True:
- batch: list[GetUser] = await self.search_users( # type: ignore[attr-defined]
- rsql_filter,
- limit=batch_size,
- start=start,
- skip_entity=skip_entity,
- )
- if batch:
- yield batch
- if len(batch) < batch_size:
- break
- start += batch_size
-
- async def iter_search_entities(
- self,
- rsql_filter: str = "",
- *,
- batch_size: int = 50,
- ) -> AsyncIterator[list[GetEntity]]:
- """Yield successive pages of GLPI entities until exhausted.
-
- Parameters
- ----------
- rsql_filter : str, optional
- Raw RSQL filter forwarded as the ``filter`` query parameter.
- Empty by default, which lists every accessible entity.
- batch_size : int, optional
- Number of records requested per page (default 50).
-
- Yields
- ------
- list[GetEntity]
- One page of entities per iteration. The last yielded batch
- may be shorter than ``batch_size``.
- """
-
- start = 0
- while True:
- batch: list[GetEntity] = await self.search_entities( # type: ignore[attr-defined]
- rsql_filter,
- limit=batch_size,
- start=start,
- )
- if batch:
- yield batch
- if len(batch) < batch_size:
- break
- start += batch_size
-
-
-__all__ = ["AsyncPaginationMixin"]
diff --git a/glpi_python_client/clients/custom/_statistics_async.py b/glpi_python_client/clients/custom/_statistics_async.py
deleted file mode 100644
index 0cb4914..0000000
--- a/glpi_python_client/clients/custom/_statistics_async.py
+++ /dev/null
@@ -1,555 +0,0 @@
-"""Asynchronous overrides for the statistics aggregation helper.
-
-The async mixin overrides :meth:`get_ticket_statistics`,
-:meth:`get_task_statistics`, :meth:`get_task_durations`, and
-:meth:`get_user_activity` so that every internal ``self.*`` call that
-would otherwise be dispatched as a bridge-wrapped coroutine is properly
-awaited. Without these overrides the bridge runs the synchronous mixin
-bodies in a worker thread where the bridge-wrapped methods on ``self``
-return unawaited coroutines instead of data, producing a
-``RuntimeWarning: coroutine … was never awaited`` error.
-
-:meth:`get_task_statistics` and :meth:`get_task_durations` additionally
-dispatch their per-ticket ``list_ticket_tasks`` fan-outs concurrently
-using :func:`asyncio.gather`.
-"""
-
-from __future__ import annotations
-
-import asyncio
-
-from glpi_python_client._errors import GlpiValidationError
-from glpi_python_client.clients.custom._statistics import (
- _LIVE_TICKETS,
- _V1_SO_ASSIGNEE,
- _V1_SO_REQUESTER,
- _V1_TASK_BULK_THRESHOLD,
- StatisticsMixin,
- TaskDurationsResult,
- TaskStatisticsResult,
- UserActivityResult,
- _entity_key,
- _summarize_tasks,
-)
-from glpi_python_client.models.api_schema.assistance.timeline._task import (
- GetTicketTask,
-)
-
-
-class AsyncStatisticsMixin(StatisticsMixin):
- """Asynchronous custom statistics with concurrent task fan-out.
-
- The override calls the bridge-wrapped ``list_ticket_tasks`` for each
- ticket identifier and awaits the resulting coroutines together via
- :func:`asyncio.gather`. Empty inputs return zeroed totals without
- any HTTP traffic.
- """
-
- async def get_task_statistics( # type: ignore[override]
- self,
- ticket_ids: list[int],
- ) -> TaskStatisticsResult:
- """Return task duration totals with concurrent per-ticket fetches.
-
- Parameters
- ----------
- ticket_ids : list[int]
- Identifiers of the tickets whose tasks should be aggregated.
- An empty list returns zeroed totals without any HTTP call.
-
- Returns
- -------
- TaskStatisticsResult
- Mapping with ``ticket_count``, ``task_count``,
- ``total_duration``, ``duration_by_user``, and
- ``duration_by_ticket`` entries.
- """
-
- if not ticket_ids:
- return TaskStatisticsResult(
- ticket_count=0,
- task_count=0,
- total_duration=0,
- duration_by_user={},
- duration_by_ticket={},
- )
- results = await asyncio.gather(
- *(
- self.list_ticket_tasks(ticket_id) # type: ignore[attr-defined]
- for ticket_id in ticket_ids
- )
- )
- flattened: list[GetTicketTask] = [task for batch in results for task in batch]
- return _summarize_tasks(ticket_ids, flattened)
-
- async def get_task_durations( # type: ignore[override]
- self,
- *,
- start_date: str | None = None,
- end_date: str | None = None,
- default_days: int = 30,
- entity_id: int | None = None,
- entity_name: str | None = None,
- user_id: int | None = None,
- user_editor_id: int | None = None,
- user_recipient_id: int | None = None,
- extra_filter: str | None = None,
- return_task_details: bool = False,
- ) -> TaskDurationsResult:
- """Return task duration totals with concurrent per-ticket fetches.
-
- Overrides the synchronous implementation so that when
- ``return_task_details=True`` the per-ticket
- :meth:`list_ticket_tasks` calls are dispatched concurrently using
- :func:`asyncio.gather`. The date-window, entity, and user filter
- logic is identical to the synchronous version.
-
- Parameters
- ----------
- start_date : str | None, optional
- ISO ``YYYY-MM-DD`` start of the window (inclusive from
- 00:00:00).
- end_date : str | None, optional
- ISO ``YYYY-MM-DD`` end of the window (inclusive through
- 23:59:59). Defaults to today.
- default_days : int, optional
- Span in days used when ``start_date`` is omitted (default 30).
- entity_id : int | None, optional
- Restrict to tickets in this entity.
- entity_name : str | None, optional
- Resolve entity by name and restrict to matched entities.
- user_id : int | None, optional
- Restrict to tickets where the user is assignee or requester.
- user_editor_id : int | None, optional
- Restrict to tickets last updated by this user.
- user_recipient_id : int | None, optional
- Restrict to tickets where this user is the requester.
- extra_filter : str | None, optional
- Optional raw RSQL fragment appended as an AND clause.
- return_task_details : bool, optional
- When ``True``, fan-out per-ticket task fetches concurrently
- and include a ``tasks`` list in the result.
-
- Returns
- -------
- TaskDurationsResult
- Same shape as the synchronous :meth:`get_task_durations`.
- """
-
- from collections import defaultdict
-
- from glpi_python_client.clients.commons._filters import (
- rsql_all_filter,
- rsql_any_filter,
- rsql_contains_filter,
- )
- from glpi_python_client.clients.custom._statistics import _resolve_window
-
- start, end = _resolve_window(
- start_date=start_date,
- 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"
- entity_filter: str | None = None
- if entity_id is not None:
- 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,
- )
- if not entities:
- return TaskDurationsResult(
- start_date=start.isoformat(),
- end_date=end.isoformat(),
- total_duration=0,
- task_count=0,
- duration_by_user={},
- duration_by_entity={},
- tasks=None,
- )
- entity_filter = rsql_any_filter(
- *(f"entity.id=={e.id}" for e in entities if e.id is not None)
- )
-
- # Mirrors the synchronous mixin: v2 cannot filter on ticket actors,
- # so the id set comes from v1 and is intersected below. The v1 call
- # is blocking, so it runs in a worker thread.
- actor_ticket_ids: set[int] | None = None
- if user_id is not None:
- actor_ticket_ids = await asyncio.to_thread(
- StatisticsMixin._v1_ticket_ids_for_actor,
- self,
- user_id,
- search_options=(_V1_SO_ASSIGNEE, _V1_SO_REQUESTER),
- parameter="user_id",
- )
- if not actor_ticket_ids:
- return TaskDurationsResult(
- start_date=start.isoformat(),
- end_date=end.isoformat(),
- total_duration=0,
- task_count=0,
- duration_by_user={},
- duration_by_entity={},
- tasks=None,
- )
-
- editor_filter: str | None = None
- if user_editor_id is not None:
- editor_filter = f"user_editor.id=={user_editor_id}"
-
- recipient_filter: str | None = None
- if user_recipient_id is not None:
- recipient_filter = f"user_recipient.id=={user_recipient_id}"
-
- rsql_filter = (
- rsql_all_filter(
- date_filter,
- entity_filter,
- editor_filter,
- recipient_filter,
- _LIVE_TICKETS,
- extra_filter,
- )
- or ""
- )
-
- ticket_ids: list[int] = []
- ticket_entity_map: dict[int, str] = {}
- async for batch in self.iter_search_tickets( # type: ignore[attr-defined]
- rsql_filter,
- batch_size=200,
- ):
- for ticket in batch:
- if ticket.id is None:
- continue
- if actor_ticket_ids is not None and ticket.id not in actor_ticket_ids:
- continue
- ticket_ids.append(ticket.id)
- ticket_entity_map[ticket.id] = _entity_key(ticket.entity)
-
- # Mirrors the synchronous mixin: one bulk v1 sweep replaces the
- # per-ticket fan-out for large ticket sets. The v1 call is blocking,
- # so it runs in a worker thread.
- if self._v1 is not None and len(ticket_ids) >= _V1_TASK_BULK_THRESHOLD:
- result = await asyncio.to_thread(
- StatisticsMixin._v1_task_statistics,
- self,
- ticket_ids,
- since=start,
- )
- else:
- result = await self.get_task_statistics(ticket_ids)
-
- duration_by_entity: defaultdict[str, int] = defaultdict(int)
- for tid, dur in result["duration_by_ticket"].items():
- entity_key = ticket_entity_map.get(int(tid), "unknown")
- duration_by_entity[entity_key] += int(dur)
-
- task_details: list[dict[str, object]] | None = None
- if return_task_details:
- tasks_per_ticket: list[list[GetTicketTask]] = await asyncio.gather(
- *(
- self.list_ticket_tasks(int(tid)) # type: ignore[attr-defined]
- for tid, dur in result["duration_by_ticket"].items()
- if int(dur) > 0
- )
- )
- non_zero_tids = [
- int(tid)
- for tid, dur in result["duration_by_ticket"].items()
- if int(dur) > 0
- ]
- task_details = []
- for tid, tasks in zip(non_zero_tids, tasks_per_ticket, strict=True):
- for task in tasks:
- task_details.append(
- {
- "task_id": task.id,
- "ticket_id": tid,
- "duration": int(task.duration or 0),
- "user_id": task.user.id if task.user else None,
- "user_name": task.user.name if task.user else None,
- "date": str(task.date_creation or ""),
- }
- )
-
- return TaskDurationsResult(
- start_date=start.isoformat(),
- end_date=end.isoformat(),
- total_duration=result["total_duration"],
- task_count=result["task_count"],
- duration_by_user=result["duration_by_user"],
- duration_by_entity=dict(duration_by_entity),
- tasks=task_details,
- )
-
- async def get_ticket_statistics( # type: ignore[override]
- self,
- *,
- start_date: str | None = None,
- end_date: str | None = None,
- default_days: int = 30,
- entity_id: int | None = None,
- entity_name: str | None = None,
- extra_filter: str | None = None,
- ) -> dict[str, object]:
- """Return ticket counts grouped by entity, status, priority, and type.
-
- Async override of :meth:`StatisticsMixin.get_ticket_statistics`.
- The synchronous base runs in a worker thread where ``self.search_tickets``
- and ``self.search_entities`` resolve to bridge-wrapped coroutines that
- would be silently dropped. This override awaits those calls directly on
- the event loop instead.
-
- Parameters
- ----------
- start_date : str | None, optional
- ISO ``YYYY-MM-DD`` start of the window (inclusive from
- 00:00:00).
- end_date : str | None, optional
- ISO ``YYYY-MM-DD`` end of the window (inclusive through
- 23:59:59). Defaults to today.
- default_days : int, optional
- Span in days used when ``start_date`` is omitted (default 30).
- entity_id : int | None, optional
- Restrict to tickets in this entity.
- entity_name : str | None, optional
- Resolve entity by name and restrict to matched entities.
- extra_filter : str | None, optional
- Optional raw RSQL fragment appended as an AND clause.
-
- Returns
- -------
- dict[str, object]
- Same shape as the synchronous :meth:`get_ticket_statistics`.
-
- Raises
- ------
- GlpiValidationError
- If ``default_days < 1``, ``start_date`` / ``end_date`` is not a
- valid ISO date, or ``start_date`` is after ``end_date``.
- """
-
- from glpi_python_client.clients.commons._filters import (
- rsql_all_filter,
- rsql_any_filter,
- rsql_contains_filter,
- )
- from glpi_python_client.clients.custom._statistics import (
- _resolve_window,
- _summarize_tickets,
- )
-
- start, end = _resolve_window(
- start_date=start_date,
- end_date=end_date,
- default_days=default_days,
- )
-
- entity_filter: str | None = None
- if entity_id is not None:
- 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,
- )
- if not entities:
- return {"entities": {}}
- 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"
- query = rsql_all_filter(
- date_filter,
- entity_filter,
- _LIVE_TICKETS,
- extra_filter,
- )
- tickets = await self.search_tickets( # type: ignore[attr-defined]
- rsql_filter=query or "",
- limit=200,
- )
- return _summarize_tickets(tickets)
-
- async def get_user_activity( # type: ignore[override]
- self,
- *,
- user_id: int | None = None,
- username: str | None = None,
- realname: str | None = None,
- firstname: str | None = None,
- start_date: str | None = None,
- end_date: str | None = None,
- default_days: int = 30,
- ) -> UserActivityResult:
- """Return per-user GLPI activity aggregated across tickets and tasks.
-
- Async override of :meth:`StatisticsMixin.get_user_activity`. The
- synchronous base calls ``self.search_users``, ``self.iter_search_tickets``,
- and ``self.get_task_durations`` through ``self``, which on the async
- client resolve to bridge-wrapped coroutines. This override awaits those
- calls and uses ``async for`` on the async generator.
-
- Parameters
- ----------
- user_id : int | None, optional
- Identify the user by GLPI numeric identifier.
- username : str | None, optional
- Filter by username (substring match).
- realname : str | None, optional
- Filter by family name (substring match).
- firstname : str | None, optional
- Filter by given name (substring match).
- start_date : str | None, optional
- ISO ``YYYY-MM-DD`` start of the activity window (inclusive
- from 00:00:00).
- end_date : str | None, optional
- ISO ``YYYY-MM-DD`` end of the activity window (inclusive
- through 23:59:59). Defaults to today.
- default_days : int, optional
- Span in days used when ``start_date`` is omitted (default 30).
-
- Returns
- -------
- UserActivityResult
- Same shape as the synchronous :meth:`get_user_activity`.
-
- Raises
- ------
- GlpiValidationError
- If none of ``user_id``, ``username``, ``realname``, or
- ``firstname`` are supplied, or if the supplied criteria match
- no GLPI users.
- """
-
- from glpi_python_client.clients.commons._filters import (
- rsql_all_filter,
- rsql_contains_filter,
- )
- from glpi_python_client.clients.custom._statistics import (
- UserActivityEntry,
- UserActivityResult,
- _merge_task_durations,
- _resolve_window,
- )
-
- if all(v is None for v in (user_id, username, realname, firstname)):
- raise GlpiValidationError(
- "At least one of user_id, username, realname, or "
- "firstname must be supplied"
- )
-
- start, end = _resolve_window(
- start_date=start_date,
- end_date=end_date,
- default_days=default_days,
- )
-
- if user_id is not None:
- resolved_user_ids: list[int] = [user_id]
- user_display_map: dict[int, str] = {user_id: str(user_id)}
- else:
- name_parts = [
- rsql_contains_filter("username", username) if username else None,
- rsql_contains_filter("realname", realname) if realname else None,
- 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,
- )
- 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]
- user_display_map = {
- u.id: (
- f"{u.firstname or ''} {u.realname or ''}".strip()
- or u.username
- or str(u.id)
- )
- for u in matched_users
- 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"
-
- # Mirrors the synchronous mixin: the window is walked once for all
- # users, and the per-role split comes from intersecting v1 id sets.
- window_filter = rsql_all_filter(date_range, _LIVE_TICKETS) or ""
- window_ids: set[int] = set()
- async for batch in self.iter_search_tickets( # type: ignore[attr-defined]
- window_filter,
- batch_size=200,
- ):
- for ticket in batch:
- if ticket.id is not None:
- window_ids.add(ticket.id)
-
- users_output: dict[str, UserActivityEntry] = {}
- for uid in resolved_user_ids:
- display_key = user_display_map.get(uid, str(uid))
- assigned_ids, requested_ids = await asyncio.gather(
- asyncio.to_thread(
- StatisticsMixin._v1_ticket_ids_for_actor,
- self,
- uid,
- search_options=(_V1_SO_ASSIGNEE,),
- parameter="user_id",
- ),
- asyncio.to_thread(
- StatisticsMixin._v1_ticket_ids_for_actor,
- self,
- uid,
- search_options=(_V1_SO_REQUESTER,),
- parameter="user_id",
- ),
- )
- tech_count = len(window_ids & assigned_ids)
- recipient_count = len(window_ids & requested_ids)
- task_dur = await self.get_task_durations(
- start_date=start_date,
- end_date=end_date,
- default_days=default_days,
- user_id=uid,
- )
- task_dur_clean: TaskDurationsResult = TaskDurationsResult(
- start_date=task_dur["start_date"],
- end_date=task_dur["end_date"],
- total_duration=task_dur["total_duration"],
- task_count=task_dur["task_count"],
- duration_by_user=dict(task_dur["duration_by_user"]),
- duration_by_entity=dict(task_dur["duration_by_entity"]),
- tasks=None,
- )
-
- if display_key in users_output:
- existing = users_output[display_key]
- existing["user_ids"] = [*existing["user_ids"], uid]
- existing["tickets_as_technician"] += tech_count
- existing["tickets_as_recipient"] += recipient_count
- existing["task_durations"] = _merge_task_durations(
- existing["task_durations"], task_dur_clean
- )
- else:
- users_output[display_key] = UserActivityEntry(
- user_ids=[uid],
- tickets_as_technician=tech_count,
- tickets_as_recipient=recipient_count,
- task_durations=task_dur_clean,
- )
-
- return UserActivityResult(users=users_output)
-
-
-__all__ = ["AsyncStatisticsMixin"]
diff --git a/glpi_python_client/clients/custom/_ticket_context.py b/glpi_python_client/clients/custom/_ticket_context.py
deleted file mode 100644
index 7bcd32d..0000000
--- a/glpi_python_client/clients/custom/_ticket_context.py
+++ /dev/null
@@ -1,67 +0,0 @@
-"""Aggregated ticket context view assembled from API mixins.
-
-The ticket context mixin composes the public ticket and ticket-timeline
-helpers to return a single :class:`GlpiTicketContext` model carrying the
-primary ticket together with its timeline records.
-"""
-
-from __future__ import annotations
-
-from glpi_python_client.clients.commons._constants import GlpiId
-from glpi_python_client.clients.commons._transport import TransportMixin
-from glpi_python_client.models.custom_schema._ticket_context import GlpiTicketContext
-
-
-class TicketContextMixin(TransportMixin):
- """Synchronous ticket-context aggregation helper.
-
- The mixin assumes the consuming client also exposes the ticket and
- ticket-timeline helpers from :mod:`glpi_python_client.clients.api`.
- The five underlying calls are executed sequentially; the async
- variant under
- :mod:`glpi_python_client.clients.custom._ticket_context_async`
- overrides :meth:`get_ticket_context` to fan them out concurrently.
- """
-
- def get_ticket_context(self, ticket_id: GlpiId) -> GlpiTicketContext:
- """Return one aggregated ticket context view.
-
- The primary ticket fetch and the four timeline list calls are
- executed sequentially in this synchronous implementation.
-
- Parameters
- ----------
- ticket_id : GlpiId
- Numeric identifier of the ticket to assemble.
-
- Returns
- -------
- GlpiTicketContext
- Aggregated view bundling the primary ticket together with
- its tasks, followups, solutions, and timeline document
- links.
-
- Raises
- ------
- GlpiStatusError
- If any of the underlying GLPI calls returns a non-success
- HTTP status.
- """
-
- ticket = self.get_ticket(ticket_id) # type: ignore[attr-defined]
- tasks = self.list_ticket_tasks(ticket_id) # type: ignore[attr-defined]
- followups = self.list_ticket_followups(ticket_id) # type: ignore[attr-defined]
- solutions = self.list_ticket_solutions(ticket_id) # type: ignore[attr-defined]
- documents = self.list_ticket_timeline_documents( # type: ignore[attr-defined]
- ticket_id
- )
- return GlpiTicketContext(
- ticket=ticket,
- tasks=tasks,
- followups=followups,
- solutions=solutions,
- documents=documents,
- )
-
-
-__all__ = ["TicketContextMixin"]
diff --git a/glpi_python_client/clients/custom/_ticket_context_async.py b/glpi_python_client/clients/custom/_ticket_context_async.py
deleted file mode 100644
index b1f386b..0000000
--- a/glpi_python_client/clients/custom/_ticket_context_async.py
+++ /dev/null
@@ -1,73 +0,0 @@
-"""Asynchronous override for the ticket-context aggregation helper.
-
-The async mixin overrides :meth:`get_ticket_context` so the five
-underlying GLPI calls are dispatched concurrently using
-:func:`asyncio.gather`. The endpoint methods themselves are exposed as
-coroutines by the
-:class:`~glpi_python_client.clients.commons._async_bridge.AsyncBridge`
-applied to :class:`~glpi_python_client.clients.AsyncGlpiClient`, so this
-override simply awaits the bridge-wrapped versions concurrently.
-"""
-
-from __future__ import annotations
-
-import asyncio
-
-from glpi_python_client.clients.commons._constants import GlpiId
-from glpi_python_client.clients.custom._ticket_context import TicketContextMixin
-from glpi_python_client.models.custom_schema._ticket_context import GlpiTicketContext
-
-
-class AsyncTicketContextMixin(TicketContextMixin):
- """Asynchronous ticket-context aggregation helper.
-
- The override calls the five underlying GLPI endpoint helpers, which
- the async bridge has wrapped as coroutines, and awaits them
- concurrently with :func:`asyncio.gather`. The async runtime keeps
- every concurrent worker on a distinct thread because the underlying
- HTTP layer is still backed by the blocking ``requests`` library.
- """
-
- async def get_ticket_context( # type: ignore[override]
- self, ticket_id: GlpiId
- ) -> GlpiTicketContext:
- """Return one aggregated ticket context with concurrent fan-out.
-
- Parameters
- ----------
- ticket_id : GlpiId
- Numeric identifier of the ticket to assemble.
-
- Returns
- -------
- GlpiTicketContext
- Aggregated view bundling the primary ticket together with
- its tasks, followups, solutions, and timeline document
- links.
-
- Raises
- ------
- GlpiStatusError
- If any of the underlying GLPI calls returns a non-success
- HTTP status.
- """
-
- ticket, tasks, followups, solutions, documents = await asyncio.gather(
- self.get_ticket(ticket_id), # type: ignore[attr-defined]
- self.list_ticket_tasks(ticket_id), # type: ignore[attr-defined]
- self.list_ticket_followups(ticket_id), # type: ignore[attr-defined]
- self.list_ticket_solutions(ticket_id), # type: ignore[attr-defined]
- self.list_ticket_timeline_documents( # type: ignore[attr-defined]
- ticket_id
- ),
- )
- return GlpiTicketContext(
- ticket=ticket,
- tasks=tasks,
- followups=followups,
- solutions=solutions,
- documents=documents,
- )
-
-
-__all__ = ["AsyncTicketContextMixin"]
diff --git a/glpi_python_client/clients/custom/tests/test_statistics_async.py b/glpi_python_client/clients/custom/tests/test_statistics_async.py
deleted file mode 100644
index 778d519..0000000
--- a/glpi_python_client/clients/custom/tests/test_statistics_async.py
+++ /dev/null
@@ -1,249 +0,0 @@
-"""Unit tests for the asynchronous statistics mixin.
-
-These tests stub :meth:`iter_search_tickets`, :meth:`list_ticket_tasks`,
-:meth:`search_entities`, and :meth:`get_task_statistics` directly on an
-:class:`AsyncGlpiClient` instance so the async aggregations exercise
-their real summarization logic without any HTTP call.
-"""
-
-from __future__ import annotations
-
-from collections.abc import AsyncIterator
-from typing import Any
-
-import pytest
-
-from glpi_python_client import AsyncGlpiClient
-from glpi_python_client.models.api_schema._common import (
- IdNameCompletenameRef,
- IdNameRef,
-)
-from glpi_python_client.models.api_schema.administration._entity import GetEntity
-from glpi_python_client.models.api_schema.assistance._ticket import GetTicket
-from glpi_python_client.models.api_schema.assistance.timeline._task import (
- GetTicketTask,
-)
-from glpi_python_client.testing.utils import make_async_client
-
-
-@pytest.fixture
-def aclient() -> AsyncGlpiClient:
- """Return one in-memory asynchronous test client."""
-
- return make_async_client()
-
-
-def _make_ticket(ticket_id: int, entity_id: int | None = 1) -> GetTicket:
- """Build a minimal ``GetTicket`` for the duration aggregations."""
-
- payload: dict[str, Any] = {
- "id": ticket_id,
- "name": f"t{ticket_id}",
- "content": "c",
- }
- if entity_id is not None:
- payload["entity"] = IdNameCompletenameRef(
- id=entity_id, name=f"E{entity_id}", completename=f"E{entity_id}"
- )
- return GetTicket(**payload)
-
-
-async def _aiter_batches(
- batches: list[list[GetTicket]],
-) -> AsyncIterator[list[GetTicket]]:
- """Yield ticket batches as an async iterator (mirrors the bridge wrapper)."""
-
- for batch in batches:
- yield batch
-
-
-async def test_async_get_task_durations_empty_iterator(
- aclient: AsyncGlpiClient,
-) -> None:
- """An empty ticket iterator returns zeroed totals without task fetches."""
-
- def fake_iter(
- rsql_filter: str = "", *, batch_size: int = 200
- ) -> AsyncIterator[list[GetTicket]]:
- return _aiter_batches([])
-
- aclient.iter_search_tickets = fake_iter # type: ignore[method-assign]
- result = await aclient.get_task_durations(
- start_date="2026-01-01", end_date="2026-01-31"
- )
- assert result["total_duration"] == 0
- assert result["task_count"] == 0
- assert result["duration_by_entity"] == {}
- assert result["tasks"] is None
- await aclient.close()
-
-
-async def test_async_get_task_durations_entity_grouping(
- aclient: AsyncGlpiClient,
-) -> None:
- """``duration_by_entity`` is grouped from the per-ticket statistics."""
-
- tickets = [_make_ticket(1, entity_id=10), _make_ticket(2, entity_id=20)]
-
- def fake_iter(
- rsql_filter: str = "", *, batch_size: int = 200
- ) -> AsyncIterator[list[GetTicket]]:
- return _aiter_batches([tickets])
-
- async def fake_stats(ticket_ids: list[int]) -> dict[str, Any]:
- return {
- "ticket_count": 2,
- "task_count": 2,
- "total_duration": 1200,
- "duration_by_user": {"42": 1200},
- "duration_by_ticket": {1: 600, 2: 600},
- }
-
- aclient.iter_search_tickets = fake_iter # type: ignore[method-assign]
- aclient.get_task_statistics = fake_stats # type: ignore[method-assign]
- result = await aclient.get_task_durations(
- start_date="2026-01-01", end_date="2026-01-31"
- )
- assert result["duration_by_entity"] == {"10": 600, "20": 600}
- assert result["tasks"] is None
- await aclient.close()
-
-
-async def test_async_get_task_durations_return_task_details(
- aclient: AsyncGlpiClient,
-) -> None:
- """``return_task_details=True`` returns a flat task list with metadata."""
-
- tickets = [_make_ticket(1, entity_id=10)]
-
- def fake_iter(
- rsql_filter: str = "", *, batch_size: int = 200
- ) -> AsyncIterator[list[GetTicket]]:
- return _aiter_batches([tickets])
-
- async def fake_stats(ticket_ids: list[int]) -> dict[str, Any]:
- return {
- "ticket_count": 1,
- "task_count": 1,
- "total_duration": 300,
- "duration_by_user": {"7": 300},
- "duration_by_ticket": {1: 300},
- }
-
- async def fake_list_tasks(ticket_id: int) -> list[GetTicketTask]:
- return [
- GetTicketTask(
- id=99,
- tickets_id=ticket_id,
- duration=300,
- user=IdNameRef(id=7, name="alice"),
- )
- ]
-
- aclient.iter_search_tickets = fake_iter # type: ignore[method-assign]
- aclient.get_task_statistics = fake_stats # type: ignore[method-assign]
- aclient.list_ticket_tasks = fake_list_tasks # type: ignore[method-assign]
- result = await aclient.get_task_durations(
- start_date="2026-01-01",
- end_date="2026-01-31",
- return_task_details=True,
- )
- tasks = result["tasks"]
- assert isinstance(tasks, list)
- assert len(tasks) == 1
- assert tasks[0]["task_id"] == 99
- assert tasks[0]["ticket_id"] == 1
- assert tasks[0]["duration"] == 300
- assert tasks[0]["user_id"] == 7
- await aclient.close()
-
-
-async def test_async_get_task_durations_entity_name_no_match(
- aclient: AsyncGlpiClient,
-) -> None:
- """When ``entity_name`` matches nothing the helper short-circuits."""
-
- async def fake_search_entities(
- rsql_filter: str = "", *, limit: int = 50
- ) -> list[GetEntity]:
- return []
-
- aclient.search_entities = fake_search_entities # type: ignore[method-assign]
- result = await aclient.get_task_durations(
- start_date="2026-01-01",
- end_date="2026-01-31",
- entity_name="nope",
- )
- assert result["total_duration"] == 0
- assert result["task_count"] == 0
- assert result["duration_by_entity"] == {}
- assert result["tasks"] is None
- await aclient.close()
-
-
-async def test_async_get_task_durations_entity_name_match(
- aclient: AsyncGlpiClient,
-) -> None:
- """When ``entity_name`` matches entities the helper combines RSQL filters."""
-
- tickets = [_make_ticket(1, entity_id=42)]
-
- async def fake_search_entities(
- rsql_filter: str = "", *, limit: int = 50
- ) -> list[GetEntity]:
- return [GetEntity(id=42, name="acme", completename="root > acme")]
-
- captured: dict[str, str] = {}
-
- def fake_iter(
- rsql_filter: str = "", *, batch_size: int = 200
- ) -> AsyncIterator[list[GetTicket]]:
- captured["filter"] = rsql_filter
- return _aiter_batches([tickets])
-
- async def fake_stats(ticket_ids: list[int]) -> dict[str, Any]:
- return {
- "ticket_count": 1,
- "task_count": 0,
- "total_duration": 0,
- "duration_by_user": {},
- "duration_by_ticket": {1: 0},
- }
-
- class _FakeV1:
- """v1 stand-in: ``user_id`` is resolved through the v1 search."""
-
- def request_json(self, method: str, path: str, **kwargs: Any) -> object:
- return {"totalcount": 1, "data": [{"2": 1}]}
-
- def close(self) -> None:
- """No-op."""
-
- aclient.search_entities = fake_search_entities # type: ignore[method-assign]
- aclient.iter_search_tickets = fake_iter # type: ignore[method-assign]
- aclient.get_task_statistics = fake_stats # type: ignore[method-assign]
- aclient._v1 = _FakeV1() # type: ignore[assignment]
-
- result = await aclient.get_task_durations(
- start_date="2026-01-01",
- end_date="2026-01-31",
- entity_name="acme",
- user_id=7,
- user_editor_id=8,
- user_recipient_id=9,
- extra_filter="status==1",
- )
- assert result["task_count"] == 0
- assert "entity.id==42" in captured["filter"]
- assert "user_editor.id==8" in captured["filter"]
- assert "user_recipient.id==9" in captured["filter"]
- assert "status==1" in captured["filter"]
- assert "is_deleted==false" in captured["filter"]
- # ``user_id`` selects on actors, which v2 cannot express, so it is
- # resolved through v1 and must not appear in the v2 filter at all.
- assert "users_id_assign" not in captured["filter"]
- assert "user_id" not in captured["filter"]
- # None of the dropped v1 spellings may come back.
- for dead in ("entities_id", "users_id_lastupdater", "users_id_requester"):
- assert dead not in captured["filter"]
- await aclient.close()
diff --git a/glpi_python_client/clients/tests/test_async_branches.py b/glpi_python_client/clients/tests/test_async_branches.py
deleted file mode 100644
index 016059b..0000000
--- a/glpi_python_client/clients/tests/test_async_branches.py
+++ /dev/null
@@ -1,792 +0,0 @@
-"""Tests for async-only branches: bridge executor, custom mixins, close."""
-
-from __future__ import annotations
-
-from concurrent.futures import ThreadPoolExecutor
-from typing import Any
-
-import pytest
-
-from glpi_python_client import AsyncGlpiClient, GlpiValidationError
-from glpi_python_client.testing.utils import FakeResponse, make_async_client
-
-
-class _FakeV1Ids:
- """Minimal v1 session returning a fixed ticket-id set for any actor query.
-
- ``get_user_activity`` resolves assignee/requester through v1 because the
- v2 API has no filterable assignee, so these tests need a v1 stand-in.
- """
-
- def __init__(self, ticket_ids: list[int]) -> None:
- self.ticket_ids = ticket_ids
-
- def request_json(self, method: str, path: str, **kwargs: Any) -> object:
- rows = [{"2": ticket_id} for ticket_id in self.ticket_ids]
- return {"totalcount": len(rows), "data": rows}
-
- def close(self) -> None:
- """No-op; the real session is closed with the client."""
-
-
-class _StubTicket:
- """Stand-in ticket exposing only what the aggregation reads."""
-
- def __init__(self, ticket_id: int) -> None:
- self.id = ticket_id
- self.entity = None
-
-
-async def test_async_bridge_uses_provided_executor() -> None:
- """A custom executor is used to dispatch the wrapped sync call."""
-
- with ThreadPoolExecutor(max_workers=1, thread_name_prefix="glpi-test") as pool:
- client = make_async_client(executor=pool)
- captured: dict[str, str] = {}
-
- def _get(
- endpoint: str, params: Any = None, skip_entity: bool = False
- ) -> FakeResponse:
- import threading
-
- captured["thread"] = threading.current_thread().name
- return FakeResponse(status_code=200, payload=[])
-
- client._get_request = _get # type: ignore[method-assign]
- await client.search_tickets("status==1")
- await client.close()
- assert captured["thread"].startswith("glpi-test")
-
-
-async def test_async_get_ticket_context_fan_out_uses_gather() -> None:
- """The async override aggregates the five endpoint coroutines concurrently."""
-
- client = make_async_client()
- calls: list[str] = []
-
- def _get(
- endpoint: str, params: Any = None, skip_entity: bool = False
- ) -> FakeResponse:
- calls.append(endpoint)
- if endpoint.endswith("/Timeline/Followup"):
- return FakeResponse(status_code=200, payload=[])
- if endpoint.endswith("/Timeline/Solution"):
- return FakeResponse(status_code=200, payload=[])
- if endpoint.endswith("/Timeline/Task"):
- return FakeResponse(status_code=200, payload=[])
- if endpoint.endswith("/Timeline/Document"):
- return FakeResponse(status_code=200, payload=[])
- return FakeResponse(
- status_code=200,
- payload={"id": 42, "name": "t", "content": "c
"},
- )
-
- client._get_request = _get # type: ignore[method-assign]
- ctx = await client.get_ticket_context(42)
- assert ctx.ticket.id == 42
- assert any("Timeline/Task" in c for c in calls)
- assert any("Timeline/Followup" in c for c in calls)
- assert any("Timeline/Solution" in c for c in calls)
- assert any("Timeline/Document" in c for c in calls)
- await client.close()
-
-
-async def test_async_get_task_statistics_with_tickets_uses_gather() -> None:
- """The async override fetches per-ticket tasks concurrently."""
-
- client = make_async_client()
- seen: list[str] = []
-
- def _get(
- endpoint: str, params: Any = None, skip_entity: bool = False
- ) -> FakeResponse:
- seen.append(endpoint)
- return FakeResponse(status_code=200, payload=[])
-
- client._get_request = _get # type: ignore[method-assign]
- stats = await client.get_task_statistics([1, 2, 3])
- assert stats["ticket_count"] == 3
- assert stats["task_count"] == 0
- assert sum(1 for e in seen if "/Timeline/Task" in e) == 3
- await client.close()
-
-
-async def test_async_get_task_statistics_empty_short_circuits() -> None:
- """An empty ticket list returns zeroed totals without HTTP traffic."""
-
- client = make_async_client()
-
- def _fail(*_args: Any, **_kwargs: Any) -> FakeResponse:
- pytest.fail("no HTTP call expected for empty ticket list")
-
- client._get_request = _fail # type: ignore[method-assign]
- stats = await client.get_task_statistics([])
- assert stats == {
- "ticket_count": 0,
- "task_count": 0,
- "total_duration": 0,
- "duration_by_user": {},
- "duration_by_ticket": {},
- }
- await client.close()
-
-
-async def test_async_close_closes_v1_session_when_configured() -> None:
- """The async ``close`` also closes the optional v1 fallback session."""
-
- client = AsyncGlpiClient(
- glpi_api_url="https://glpi.example.test/api.php/v2",
- username="u",
- password="p",
- v1_base_url="https://glpi.example.test/apirest.php",
- v1_user_token="user-token",
- v1_app_token="app-token",
- )
- assert client._v1 is not None
- await client.close()
- assert client._closed is True
-
-
-async def test_async_from_env_accepts_executor() -> None:
- """``AsyncGlpiClient.from_env`` accepts an executor and forwards it."""
-
- env = {
- "GLPI_API_URL": "https://glpi.example.test/api.php/v2",
- "GLPI_USERNAME": "u",
- "GLPI_PASSWORD": "p",
- }
- with ThreadPoolExecutor(max_workers=1) as pool:
- client = AsyncGlpiClient.from_env(env=env, executor=pool)
- try:
- assert client._executor is pool
- finally:
- await client.close()
-
-
-async def test_async_generator_wrapper_yields_then_stops_default_executor() -> None:
- """The bridge wrapper drives a sync generator function to completion."""
-
- from glpi_python_client.clients.commons._async_bridge import (
- AsyncBridge,
- _make_async_generator_wrapper,
- )
-
- def sync_gen(self: AsyncBridge, n: int) -> Any:
- for i in range(n):
- yield [i]
-
- wrapper = _make_async_generator_wrapper(sync_gen)
-
- class _Owner(AsyncBridge):
- pass
-
- owner = _Owner()
- collected: list[list[int]] = []
- async for batch in wrapper(owner, 3):
- collected.append(batch)
- assert collected == [[0], [1], [2]]
-
-
-async def test_async_generator_wrapper_with_executor() -> None:
- """The wrapper dispatches generator advancement to the supplied executor."""
-
- from glpi_python_client.clients.commons._async_bridge import (
- AsyncBridge,
- _make_async_generator_wrapper,
- )
-
- captured_threads: list[str] = []
-
- def sync_gen(self: AsyncBridge) -> Any:
- import threading
-
- captured_threads.append(threading.current_thread().name)
- yield ["one"]
- captured_threads.append(threading.current_thread().name)
-
- wrapper = _make_async_generator_wrapper(sync_gen)
-
- with ThreadPoolExecutor(max_workers=1, thread_name_prefix="glpi-gen") as pool:
-
- class _Owner(AsyncBridge):
- pass
-
- owner = _Owner()
- owner._executor = pool
- batches: list[list[str]] = []
- async for batch in wrapper(owner):
- batches.append(batch)
- assert batches == [["one"]]
- assert captured_threads
- assert all(name.startswith("glpi-gen") for name in captured_threads)
-
-
-# ---------------------------------------------------------------------------
-# AsyncPaginationMixin — iter_search_tickets
-# ---------------------------------------------------------------------------
-
-
-async def test_async_iter_search_tickets_single_page() -> None:
- """A response shorter than batch_size yields one batch then stops."""
-
- client = make_async_client()
- call_count = 0
-
- async def fake_search(
- rsql_filter: str = "",
- *,
- limit: int = 50,
- start: int = 0,
- sort: str | None = None,
- fields: tuple[str, ...] = (),
- ) -> list[Any]:
- nonlocal call_count
- call_count += 1
- return [{"id": 1, "name": "t1", "content": "c"}]
-
- client.search_tickets = fake_search # type: ignore[method-assign]
- batches: list[Any] = []
- async for batch in client.iter_search_tickets("status==1", batch_size=50):
- batches.append(batch)
- assert call_count == 1
- assert len(batches) == 1
- assert len(batches[0]) == 1
- await client.close()
-
-
-async def test_async_iter_search_tickets_multi_page_stops_on_short_batch() -> None:
- """Iteration stops after the first batch shorter than batch_size."""
-
- client = make_async_client()
- responses = [
- [
- {"id": 1, "name": "a", "content": "c"},
- {"id": 2, "name": "b", "content": "c"},
- ],
- [{"id": 3, "name": "c", "content": "c"}],
- ]
- call_count = 0
-
- async def fake_search(
- rsql_filter: str = "",
- *,
- limit: int = 50,
- start: int = 0,
- sort: str | None = None,
- fields: tuple[str, ...] = (),
- ) -> list[Any]:
- nonlocal call_count
- result = responses[min(call_count, len(responses) - 1)]
- call_count += 1
- return result
-
- client.search_tickets = fake_search # type: ignore[method-assign]
- batches: list[Any] = []
- async for batch in client.iter_search_tickets("", batch_size=2):
- batches.append(batch)
- assert call_count == 2
- assert len(batches) == 2
- assert sum(len(b) for b in batches) == 3
- await client.close()
-
-
-async def test_async_iter_search_tickets_empty_page_not_yielded() -> None:
- """An empty response is not yielded but still terminates the loop."""
-
- client = make_async_client()
-
- async def fake_search(
- rsql_filter: str = "",
- *,
- limit: int = 50,
- start: int = 0,
- sort: str | None = None,
- fields: tuple[str, ...] = (),
- ) -> list[Any]:
- return []
-
- client.search_tickets = fake_search # type: ignore[method-assign]
- batches: list[Any] = []
- async for batch in client.iter_search_tickets("status==1", batch_size=50):
- batches.append(batch)
- assert batches == []
- await client.close()
-
-
-# ---------------------------------------------------------------------------
-# AsyncPaginationMixin — iter_search_users
-# ---------------------------------------------------------------------------
-
-
-async def test_async_iter_search_users_single_page() -> None:
- """A response shorter than batch_size yields one batch then stops."""
-
- client = make_async_client()
- call_count = 0
-
- async def fake_search(
- rsql_filter: str = "",
- *,
- limit: int = 50,
- start: int = 0,
- skip_entity: bool = False,
- ) -> list[Any]:
- nonlocal call_count
- call_count += 1
- return [{"id": 1, "username": "alice"}]
-
- client.search_users = fake_search # type: ignore[method-assign]
- batches: list[Any] = []
- async for batch in client.iter_search_users("username==alice", batch_size=50):
- batches.append(batch)
- assert call_count == 1
- assert len(batches) == 1
- await client.close()
-
-
-async def test_async_iter_search_users_multi_page_stops_on_short_batch() -> None:
- """Iteration stops after the first short user batch."""
-
- client = make_async_client()
- responses = [
- [{"id": 1, "username": "alice"}, {"id": 2, "username": "bob"}],
- [{"id": 3, "username": "carol"}],
- ]
- call_count = 0
-
- async def fake_search(
- rsql_filter: str = "",
- *,
- limit: int = 50,
- start: int = 0,
- skip_entity: bool = False,
- ) -> list[Any]:
- nonlocal call_count
- result = responses[min(call_count, len(responses) - 1)]
- call_count += 1
- return result
-
- client.search_users = fake_search # type: ignore[method-assign]
- batches: list[Any] = []
- async for batch in client.iter_search_users("", batch_size=2):
- batches.append(batch)
- assert call_count == 2
- assert sum(len(b) for b in batches) == 3
- await client.close()
-
-
-# ---------------------------------------------------------------------------
-# AsyncPaginationMixin — iter_search_entities
-# ---------------------------------------------------------------------------
-
-
-async def test_async_iter_search_entities_single_page() -> None:
- """A response shorter than batch_size yields one batch then stops."""
-
- client = make_async_client()
- call_count = 0
-
- async def fake_search(
- rsql_filter: str = "",
- *,
- limit: int = 50,
- start: int = 0,
- ) -> list[Any]:
- nonlocal call_count
- call_count += 1
- return [{"id": 1, "name": "root"}]
-
- client.search_entities = fake_search # type: ignore[method-assign]
- batches: list[Any] = []
- async for batch in client.iter_search_entities("", batch_size=50):
- batches.append(batch)
- assert call_count == 1
- assert len(batches) == 1
- await client.close()
-
-
-async def test_async_iter_search_entities_multi_page_stops_on_short_batch() -> None:
- """Iteration stops after the first short entity batch."""
-
- client = make_async_client()
- responses = [
- [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}],
- [{"id": 3, "name": "c"}],
- ]
- call_count = 0
-
- async def fake_search(
- rsql_filter: str = "",
- *,
- limit: int = 50,
- start: int = 0,
- ) -> list[Any]:
- nonlocal call_count
- result = responses[min(call_count, len(responses) - 1)]
- call_count += 1
- return result
-
- client.search_entities = fake_search # type: ignore[method-assign]
- batches: list[Any] = []
- async for batch in client.iter_search_entities("", batch_size=2):
- batches.append(batch)
- assert call_count == 2
- assert sum(len(b) for b in batches) == 3
- await client.close()
-
-
-# ---------------------------------------------------------------------------
-# AsyncStatisticsMixin — get_task_durations with entity_id
-# ---------------------------------------------------------------------------
-
-
-async def test_async_get_task_durations_with_entity_id() -> None:
- """Providing entity_id builds the entity filter without an HTTP lookup."""
-
- client = make_async_client()
- search_calls: list[str] = []
-
- async def fake_search_tickets(rsql_filter: str = "", **kwargs: Any) -> list[Any]:
- search_calls.append(rsql_filter)
- return []
-
- async def fake_iter(
- rsql_filter: str = "",
- **kwargs: Any,
- ) -> Any:
- return
- yield # make it an async generator
-
- async def fake_task_stats(ticket_ids: list[int]) -> Any:
- return {
- "total_duration": 0,
- "task_count": 0,
- "duration_by_user": {},
- "duration_by_ticket": {},
- }
-
- client.search_tickets = fake_search_tickets # type: ignore[method-assign]
- client.iter_search_tickets = fake_iter # type: ignore[method-assign]
- client.get_task_statistics = fake_task_stats # type: ignore[method-assign]
-
- result = await client.get_task_durations(entity_id=5)
- assert result["total_duration"] == 0
- assert any("entities_id==5" in c for c in search_calls) or True
- await client.close()
-
-
-# ---------------------------------------------------------------------------
-# AsyncStatisticsMixin — get_ticket_statistics
-# ---------------------------------------------------------------------------
-
-
-async def test_async_get_ticket_statistics_returns_summary() -> None:
- """get_ticket_statistics awaits search_tickets and summarises results."""
-
- client = make_async_client()
-
- async def fake_search_tickets(rsql_filter: str = "", **kwargs: Any) -> list[Any]:
- # Return empty list — _summarize_tickets([]) is valid and avoids
- # constructing full GetTicket model objects in this smoke test.
- return []
-
- client.search_tickets = fake_search_tickets # type: ignore[method-assign]
-
- result = await client.get_ticket_statistics()
- assert isinstance(result, dict)
- assert "entities" in result
- await client.close()
-
-
-async def test_async_get_ticket_statistics_with_entity_name_no_match() -> None:
- """When entity lookup returns nothing, an empty dict is returned early."""
-
- client = make_async_client()
-
- async def fake_search_entities(rsql_filter: str = "", **kwargs: Any) -> list[Any]:
- return []
-
- client.search_entities = fake_search_entities # type: ignore[method-assign]
-
- result = await client.get_ticket_statistics(entity_name="nonexistent")
- assert result == {"entities": {}}
- await client.close()
-
-
-async def test_async_get_ticket_statistics_with_entity_id() -> None:
- """Providing entity_id builds the filter without calling search_entities."""
-
- client = make_async_client()
- entity_calls: list[str] = []
-
- async def fake_search_entities(**kwargs: Any) -> list[Any]:
- entity_calls.append("called")
- return []
-
- async def fake_search_tickets(rsql_filter: str = "", **kwargs: Any) -> list[Any]:
- return []
-
- client.search_entities = fake_search_entities # type: ignore[method-assign]
- client.search_tickets = fake_search_tickets # type: ignore[method-assign]
-
- result = await client.get_ticket_statistics(entity_id=3)
- assert entity_calls == []
- assert "entities" in result
- await client.close()
-
-
-# ---------------------------------------------------------------------------
-# AsyncStatisticsMixin — get_user_activity
-# ---------------------------------------------------------------------------
-
-
-async def test_async_get_user_activity_raises_without_criteria() -> None:
- """``GlpiValidationError`` is raised when no user criteria are supplied.
-
- ``GlpiValidationError`` inherits ``ValueError`` so existing callers that
- catch the broader type keep working.
- """
-
- client = make_async_client()
- with pytest.raises(GlpiValidationError, match="At least one of") as excinfo:
- await client.get_user_activity()
- assert isinstance(excinfo.value, ValueError)
- await client.close()
-
-
-async def test_async_get_user_activity_by_user_id() -> None:
- """get_user_activity accepts user_id and returns a UserActivityResult."""
-
- client = make_async_client()
-
- async def fake_iter_tickets(rsql_filter: str = "", **kwargs: Any) -> Any:
- if False:
- yield []
-
- async def fake_task_durations(**kwargs: Any) -> Any:
- from glpi_python_client.clients.custom._statistics import TaskDurationsResult
-
- return TaskDurationsResult(
- start_date="2025-01-01",
- end_date="2025-01-31",
- total_duration=0,
- task_count=0,
- duration_by_user={},
- duration_by_entity={},
- tasks=None,
- )
-
- client.iter_search_tickets = fake_iter_tickets # type: ignore[method-assign]
- client.get_task_durations = fake_task_durations # type: ignore[method-assign]
- client._v1 = _FakeV1Ids([]) # type: ignore[assignment]
-
- result = await client.get_user_activity(user_id=42)
- assert "users" in result
- await client.close()
-
-
-async def test_async_get_user_activity_by_username_no_match_raises() -> None:
- """``GlpiValidationError`` is raised when no users match the criteria.
-
- ``GlpiValidationError`` inherits ``ValueError`` so existing callers that
- catch the broader type keep working.
- """
-
- client = make_async_client()
-
- async def fake_search_users(rsql_filter: str = "", **kwargs: Any) -> list[Any]:
- return []
-
- client.search_users = fake_search_users # type: ignore[method-assign]
-
- with pytest.raises(GlpiValidationError, match="No users matched") as excinfo:
- await client.get_user_activity(username="ghost")
- assert isinstance(excinfo.value, ValueError)
- await client.close()
-
-
-async def test_async_get_user_activity_by_username() -> None:
- """get_user_activity resolves username to user_id then aggregates."""
-
- from glpi_python_client.models.api_schema.administration._user import GetUser
-
- client = make_async_client()
-
- async def fake_search_users(rsql_filter: str = "", **kwargs: Any) -> list[Any]:
- return [GetUser(id=7, username="alice", realname="A", firstname="B")]
-
- async def fake_iter_tickets(rsql_filter: str = "", **kwargs: Any) -> Any:
- if False:
- yield []
-
- async def fake_task_durations(**kwargs: Any) -> Any:
- from glpi_python_client.clients.custom._statistics import TaskDurationsResult
-
- return TaskDurationsResult(
- start_date="2025-01-01",
- end_date="2025-01-31",
- total_duration=0,
- task_count=0,
- duration_by_user={},
- duration_by_entity={},
- tasks=None,
- )
-
- client.search_users = fake_search_users # type: ignore[method-assign]
- client.iter_search_tickets = fake_iter_tickets # type: ignore[method-assign]
- client.get_task_durations = fake_task_durations # type: ignore[method-assign]
- client._v1 = _FakeV1Ids([]) # type: ignore[assignment]
-
- result = await client.get_user_activity(username="alice")
- assert "users" in result
- await client.close()
-
-
-async def test_async_get_ticket_statistics_with_entity_name_found() -> None:
- """When entity lookup returns matches, ticket filter uses their IDs."""
-
- from glpi_python_client.models.api_schema.administration._entity import GetEntity
-
- client = make_async_client()
-
- async def fake_search_entities(rsql_filter: str = "", **kwargs: Any) -> list[Any]:
- return [GetEntity(id=10, name="IT")]
-
- async def fake_search_tickets(rsql_filter: str = "", **kwargs: Any) -> list[Any]:
- return []
-
- client.search_entities = fake_search_entities # type: ignore[method-assign]
- client.search_tickets = fake_search_tickets # type: ignore[method-assign]
-
- result = await client.get_ticket_statistics(entity_name="IT")
- assert isinstance(result, dict)
- assert "entities" in result
- await client.close()
-
-
-async def test_async_get_user_activity_counts_ticket_batches() -> None:
- """Tech/recipient counts increase when iter_search_tickets yields batches."""
-
- from glpi_python_client.clients.custom._statistics import TaskDurationsResult
-
- client = make_async_client()
-
- async def fake_iter_tickets(rsql_filter: str = "", **kwargs: Any) -> Any:
- yield [_StubTicket(1)]
-
- async def fake_task_durations(**kwargs: Any) -> Any:
- return TaskDurationsResult(
- start_date="2025-01-01",
- end_date="2025-01-31",
- total_duration=0,
- task_count=0,
- duration_by_user={},
- duration_by_entity={},
- tasks=None,
- )
-
- client.iter_search_tickets = fake_iter_tickets # type: ignore[method-assign]
- client.get_task_durations = fake_task_durations # type: ignore[method-assign]
- # Ticket 1 is in the window and is linked to the user under both roles.
- client._v1 = _FakeV1Ids([1]) # type: ignore[assignment]
-
- result = await client.get_user_activity(user_id=99)
- entry = list(result["users"].values())
- assert entry[0]["tickets_as_technician"] == 1
- assert entry[0]["tickets_as_recipient"] == 1
- await client.close()
-
-
-async def test_async_get_user_activity_counts_are_role_specific() -> None:
- """Assignee and requester counts come from independent v1 id sets.
-
- The previous implementation sent v1 field names to v2, which silently
- ignored them, so both counts collapsed to "every ticket in the window"
- and were always equal. Here the window holds two tickets but the user
- is linked to only one, under one role.
- """
-
- from glpi_python_client.clients.custom._statistics import TaskDurationsResult
-
- client = make_async_client()
-
- async def fake_iter_tickets(rsql_filter: str = "", **kwargs: Any) -> Any:
- yield [_StubTicket(1), _StubTicket(2)]
-
- async def fake_task_durations(**kwargs: Any) -> Any:
- return TaskDurationsResult(
- start_date="2025-01-01",
- end_date="2025-01-31",
- total_duration=0,
- task_count=0,
- duration_by_user={},
- duration_by_entity={},
- tasks=None,
- )
-
- class _RoleAwareV1:
- def request_json(self, method: str, path: str, **kwargs: Any) -> object:
- params = kwargs.get("params") or {}
- option = int(str(params.get("criteria[0][field]")))
- # searchOption 5 == assignee, 4 == requester.
- ids = [1] if option == 5 else []
- rows = [{"2": ticket_id} for ticket_id in ids]
- return {"totalcount": len(rows), "data": rows}
-
- def close(self) -> None:
- """No-op; the real session is closed with the client."""
-
- client.iter_search_tickets = fake_iter_tickets # type: ignore[method-assign]
- client.get_task_durations = fake_task_durations # type: ignore[method-assign]
- client._v1 = _RoleAwareV1() # type: ignore[assignment]
-
- result = await client.get_user_activity(user_id=99)
- entry = next(iter(result["users"].values()))
- assert entry["tickets_as_technician"] == 1
- assert entry["tickets_as_recipient"] == 0
- await client.close()
-
-
-async def test_async_get_user_activity_merges_duplicate_display_keys() -> None:
- """Two users with the same display name are merged into one entry."""
-
- from glpi_python_client.clients.custom._statistics import TaskDurationsResult
- from glpi_python_client.models.api_schema.administration._user import GetUser
-
- client = make_async_client()
-
- # Both users have no firstname/realname → display key is just username "" or id
- # Use users with empty names so they get the same display key via str(id) fallback
- # Actually easier: give them same realname+firstname so display key collides
- user_a = GetUser(id=1, username="a", realname="Smith", firstname="John")
- user_b = GetUser(id=2, username="b", realname="Smith", firstname="John")
-
- async def fake_search_users(rsql_filter: str = "", **kwargs: Any) -> list[Any]:
- return [user_a, user_b]
-
- async def fake_iter_tickets(rsql_filter: str = "", **kwargs: Any) -> Any:
- if False:
- yield []
-
- async def fake_task_durations(**kwargs: Any) -> Any:
- return TaskDurationsResult(
- start_date="2025-01-01",
- end_date="2025-01-31",
- total_duration=0,
- task_count=0,
- duration_by_user={},
- duration_by_entity={},
- tasks=None,
- )
-
- client.search_users = fake_search_users # type: ignore[method-assign]
- client.iter_search_tickets = fake_iter_tickets # type: ignore[method-assign]
- client.get_task_durations = fake_task_durations # type: ignore[method-assign]
- client._v1 = _FakeV1Ids([]) # type: ignore[assignment]
-
- result = await client.get_user_activity(username="Smith")
- # Both users merge under "John Smith" key
- assert len(result["users"]) == 1
- merged = list(result["users"].values())
- assert set(merged[0]["user_ids"]) == {1, 2}
- await client.close()
diff --git a/glpi_python_client/clients/tests/test_async_selfcall_guard.py b/glpi_python_client/clients/tests/test_async_selfcall_guard.py
deleted file mode 100644
index 42fa6a2..0000000
--- a/glpi_python_client/clients/tests/test_async_selfcall_guard.py
+++ /dev/null
@@ -1,226 +0,0 @@
-"""Structural guard against the async bridge's self-call trap.
-
-``AsyncBridge`` wraps every public sync method into a coroutine. A sync
-body running inside a worker thread that calls a sibling *public* method
-through ``self`` therefore receives a coroutine object, not data: the call
-is silently dropped (``RuntimeWarning: coroutine ... was never awaited``).
-
-Any public method that transitively reaches a public method through
-``self`` must be given a hand-written async override on
-``AsyncGlpiClient``. This test enforces that rule so the bug class cannot
-be reintroduced by a future endpoint.
-"""
-
-from __future__ import annotations
-
-import ast
-import inspect
-import textwrap
-
-from glpi_python_client import AsyncGlpiClient, GlpiClient
-
-# Lifecycle helpers differ between the surfaces on purpose.
-_EXCLUDED = {"from_env", "close"}
-
-
-def _public_names(cls: type) -> set[str]:
- """Return the public callable names exposed by ``cls``."""
-
- return {
- name
- for name, _ in inspect.getmembers(cls, predicate=callable)
- if not name.startswith("_") and name not in _EXCLUDED
- }
-
-
-def _self_call_map(cls: type) -> dict[str, set[str]]:
- """Map every method of ``cls`` to the ``self.X()`` names it calls."""
-
- out: dict[str, set[str]] = {}
- for klass in cls.__mro__:
- if klass is object:
- continue
- for name, member in vars(klass).items():
- if name in out:
- continue
- func = member
- if isinstance(func, (classmethod, staticmethod)):
- func = func.__func__
- if not inspect.isfunction(func):
- continue
- try:
- tree = ast.parse(textwrap.dedent(inspect.getsource(func)))
- except (OSError, TypeError, SyntaxError): # pragma: no cover
- continue
- calls: set[str] = set()
- for node in ast.walk(tree):
- if (
- isinstance(node, ast.Call)
- and isinstance(node.func, ast.Attribute)
- and isinstance(node.func.value, ast.Name)
- and node.func.value.id == "self"
- ):
- calls.add(node.func.attr)
- out[name] = calls
- return out
-
-
-def _reaches_public(
- name: str,
- call_map: dict[str, set[str]],
- public: set[str],
- seen: set[str] | None = None,
-) -> bool:
- """Return whether ``name`` reaches a public method through ``self``.
-
- Recurses through private helpers, which is what catches
- ``create_kb_article`` -> ``_apply_category_fallback`` ->
- ``set_kb_article_categories``.
- """
-
- if seen is None:
- seen = set()
- if name in seen:
- return False
- seen.add(name)
- for callee in call_map.get(name, set()):
- if callee in public:
- return True
- if callee.startswith("_") and _reaches_public(callee, call_map, public, seen):
- return True
- return False
-
-
-def _is_real_async_override(member: object) -> bool:
- """Return whether ``member`` is a hand-written async override.
-
- The bridge builds its wrappers with ``functools.wraps``, so a
- bridge-generated coroutine carries ``__wrapped__`` while a real
- override does not.
- """
-
- if not (inspect.iscoroutinefunction(member) or inspect.isasyncgenfunction(member)):
- return False
- return not hasattr(member, "__wrapped__")
-
-
-def _offenders() -> list[str]:
- """Return public methods that self-call but lack an async override."""
-
- public = _public_names(GlpiClient)
- call_map = _self_call_map(GlpiClient)
- return sorted(
- name
- for name in public
- if _reaches_public(name, call_map, public)
- and not _is_real_async_override(getattr(AsyncGlpiClient, name))
- )
-
-
-def test_no_new_self_call_offenders() -> None:
- """No public method may self-call without a hand-written async override."""
-
- assert _offenders() == [], (
- f"These public methods call a public method through self with no async "
- f"override, so AsyncGlpiClient will silently drop those calls: "
- f"{_offenders()}. Add an async override mixin (see "
- "clients/custom/_statistics_async.py) and register it in "
- "clients/async_client.py before the sync mixin."
- )
-
-
-def test_guard_detects_the_covered_methods() -> None:
- """The guard must recognise the existing overrides as valid.
-
- Without this, a guard that classified every method as covered would
- pass ``test_no_new_self_call_offenders`` vacuously.
- """
-
- for name in ("get_ticket_context", "get_task_statistics", "iter_search_tickets"):
- assert _is_real_async_override(getattr(AsyncGlpiClient, name)), (
- f"{name} should be a hand-written async override"
- )
- assert not _is_real_async_override(AsyncGlpiClient.get_ticket), (
- "get_ticket should be bridge-generated, not a hand-written override"
- )
-
-
-def test_reaches_public_detects_transitive_and_direct_self_calls() -> None:
- """Pin ``_reaches_public`` against a synthetic call map.
-
- ``test_no_new_self_call_offenders`` now asserts ``_offenders() == []``.
- That assertion passes both when every real offender has an async
- override *and* when ``_reaches_public`` has regressed to returning
- ``False`` for everything — the two cases are indistinguishable from the
- assertion alone. The previous non-empty ``_KNOWN_UNCOVERED`` constant
- protected against that regression by accident, since a broken detector
- would drop the known offenders out of ``_offenders()`` and fail the
- equality check; emptying it removed that safety net.
-
- This test replaces it with a direct, synthetic check of the detection
- logic itself, independent of whatever offenders exist on the real
- client today. It fixes a call map shaped like the historical
- ``create_kb_article`` bug — a public method reaching another public
- method only transitively, through a private helper — and a call map
- that never reaches anything public, and asserts ``_reaches_public``
- still tells them apart.
- """
-
- public = {"create_thing", "set_thing_categories", "isolated_public"}
- call_map: dict[str, set[str]] = {
- # Mirrors create_kb_article -> _apply_category_fallback ->
- # set_kb_article_categories: the public caller only reaches the
- # public callee transitively, through a private helper.
- "create_thing": {"_apply_fallback"},
- "_apply_fallback": {"set_thing_categories"},
- "set_thing_categories": set(),
- # A public method that only ever touches private helpers which
- # themselves reach nothing public must not be flagged.
- "isolated_public": {"_do_local_work"},
- "_do_local_work": {"_do_more_local_work"},
- "_do_more_local_work": set(),
- }
-
- assert _reaches_public("create_thing", call_map, public) is True, (
- "a public method reaching a public method through a private helper "
- "must be detected"
- )
- assert _reaches_public("isolated_public", call_map, public) is False, (
- "a public method whose private helpers reach nothing public must not be flagged"
- )
-
-
-def test_reaches_public_fires_on_real_create_kb_article_shape() -> None:
- """Pin all three ``_offenders`` helpers together against the real client.
-
- ``test_reaches_public_detects_transitive_and_direct_self_calls`` only
- proves ``_reaches_public`` is correct against a hand-built ``public``
- set and ``call_map``. It cannot notice a regression in the other two
- helpers ``_offenders`` depends on: if ``_public_names(GlpiClient)``
- returned an empty set, or ``_self_call_map(GlpiClient)`` returned an
- empty dict, ``_offenders()`` would be ``[]`` regardless of what
- ``_reaches_public`` does, and ``test_no_new_self_call_offenders`` would
- pass vacuously. ``_self_call_map`` is especially fragile here: it
- silently ``continue``s past ``OSError``/``TypeError``/``SyntaxError``
- raised by ``inspect.getsource``, so a source-less install (e.g. a
- zipapp or a stripped wheel) would empty the map and green the guard
- without detecting anything.
-
- ``create_kb_article`` is a permanently-valid end-to-end canary for
- this: ``glpi_python_client/clients/api/knowledgebase/_article.py`` is
- untouched by the async-bridge fix and still has the exact shape that
- caused the original bug — public ``create_kb_article`` reaches public
- ``set_kb_article_categories`` only transitively, through the private
- ``_apply_category_fallback``. Running the three real helpers together
- against ``GlpiClient`` and asserting the detector still fires proves
- that ``create_kb_article`` is absent from ``_offenders()`` today
- because ``AsyncKBArticleMixin`` (in
- :mod:`glpi_python_client.clients.api.knowledgebase._article_async`)
- now supplies a real async override, not because the detector went
- blind. This is the exact protection the old (now-removed)
- ``_KNOWN_UNCOVERED`` constant used to provide by accident.
- """
-
- assert _reaches_public(
- "create_kb_article", _self_call_map(GlpiClient), _public_names(GlpiClient)
- ), "the detector must still fire on the real create_kb_article shape"
diff --git a/glpi_python_client/clients/tests/test_async_smoke.py b/glpi_python_client/clients/tests/test_async_smoke.py
deleted file mode 100644
index b605091..0000000
--- a/glpi_python_client/clients/tests/test_async_smoke.py
+++ /dev/null
@@ -1,150 +0,0 @@
-"""Smoke tests for the asynchronous client and its async bridge.
-
-The tests exercise a few representative endpoint methods on
-:class:`~glpi_python_client.AsyncGlpiClient` to confirm the bridge
-correctly:
-
-* wraps inherited sync methods into awaitable coroutines,
-* dispatches the blocking call off the event loop, and
-* preserves the synchronous transport call signatures so test recorders
- can stub the same hooks as the sync test suite.
-"""
-
-from __future__ import annotations
-
-from typing import Any
-
-import pytest
-
-from glpi_python_client import (
- AsyncGlpiClient,
- PostFollowup,
- PostTicket,
- PostUser,
-)
-from glpi_python_client.testing.utils import FakeResponse, make_async_client
-
-
-class _AsyncRecorder:
- """Synchronous transport recorder installed on the async client.
-
- The recorder relies on the fact that the async bridge wraps the
- inherited synchronous transport hooks; the underlying ``_get_*``,
- ``_post_*``, ``_update_*`` and ``_delete_*`` helpers themselves
- remain synchronous and run inside the bridge worker thread.
- """
-
- def __init__(self) -> None:
- self.calls: list[dict[str, Any]] = []
-
- def install(self, client: AsyncGlpiClient) -> None:
- """Replace the transport methods on ``client`` with sync stubs."""
-
- def _get(
- endpoint: str,
- params: dict[str, Any] | None = None,
- skip_entity: bool = False,
- ) -> FakeResponse:
- self.calls.append(
- {
- "method": "GET",
- "endpoint": endpoint,
- "params": params,
- "skip_entity": skip_entity,
- }
- )
- return FakeResponse(
- status_code=200, payload=[{"id": 1, "name": "n", "content": "c"}]
- )
-
- def _post(
- endpoint: str,
- json_body: dict[str, Any] | None = None,
- skip_entity: bool = False,
- ) -> FakeResponse:
- self.calls.append(
- {
- "method": "POST",
- "endpoint": endpoint,
- "json": json_body,
- "skip_entity": skip_entity,
- }
- )
- return FakeResponse(status_code=201, payload={"id": 999})
-
- client._get_request = _get # type: ignore[method-assign]
- client._post_request = _post # type: ignore[method-assign]
-
-
-@pytest.fixture
-def async_client() -> AsyncGlpiClient:
- """Return one in-memory async client without any real HTTP plumbing."""
-
- return make_async_client()
-
-
-@pytest.fixture
-def async_recorder(async_client: AsyncGlpiClient) -> _AsyncRecorder:
- """Return one transport recorder already wired onto ``async_client``."""
-
- rec = _AsyncRecorder()
- rec.install(async_client)
- return rec
-
-
-async def test_async_create_user_returns_awaitable(
- async_client: AsyncGlpiClient, async_recorder: _AsyncRecorder
-) -> None:
- """The async ``create_user`` returns an awaitable that resolves to the id."""
-
- user_id = await async_client.create_user(PostUser(username="alice"))
- assert user_id == 999
- assert async_recorder.calls[0]["endpoint"] == "Administration/User"
-
-
-async def test_async_search_tickets_returns_models(
- async_client: AsyncGlpiClient, async_recorder: _AsyncRecorder
-) -> None:
- """The async ``search_tickets`` returns validated ticket models."""
-
- tickets = await async_client.search_tickets("status==1")
- assert len(tickets) == 1
- assert async_recorder.calls[0]["endpoint"] == "Assistance/Ticket"
-
-
-async def test_async_create_ticket_followup_targets_timeline_endpoint(
- async_client: AsyncGlpiClient, async_recorder: _AsyncRecorder
-) -> None:
- """The async followup helper still hits the timeline endpoint."""
-
- await async_client.create_ticket_followup(7, PostFollowup(content="hi
"))
- assert (
- async_recorder.calls[0]["endpoint"] == "Assistance/Ticket/7/Timeline/Followup"
- )
-
-
-async def test_async_create_ticket_serialises_enums(
- async_client: AsyncGlpiClient, async_recorder: _AsyncRecorder
-) -> None:
- """The async create_ticket serialises enums identically to the sync surface."""
-
- await async_client.create_ticket(PostTicket(name="t", content="c
"))
- assert async_recorder.calls[0]["endpoint"] == "Assistance/Ticket"
- assert async_recorder.calls[0]["json"]["name"] == "t"
-
-
-async def test_async_close_is_idempotent() -> None:
- """Calling ``close`` twice on the async client does not raise."""
-
- client = make_async_client()
- await client.close()
- await client.close()
-
-
-async def test_async_context_manager_closes_client() -> None:
- """The async context manager closes the client on exit."""
-
- async with make_async_client() as client:
- assert client.glpi_api_url.endswith("/api.php")
- with pytest.raises(RuntimeError, match="closed"):
- client._ensure_open()
diff --git a/glpi_python_client/clients/tests/test_parity.py b/glpi_python_client/clients/tests/test_parity.py
deleted file mode 100644
index 0e91c29..0000000
--- a/glpi_python_client/clients/tests/test_parity.py
+++ /dev/null
@@ -1,70 +0,0 @@
-"""Parity tests asserting that the sync and async clients expose the same surface.
-
-These tests guarantee that any public method added to the sync mixins is
-automatically reflected on the async client through
-:class:`~glpi_python_client.clients.commons._async_bridge.AsyncBridge`
-without requiring a parallel async implementation, and conversely that
-the async client does not gain methods the sync client lacks.
-"""
-
-from __future__ import annotations
-
-import inspect
-
-from glpi_python_client import AsyncGlpiClient, GlpiClient
-
-
-def _public_callable_names(cls: type) -> set[str]:
- """Return the public method names exposed by ``cls``.
-
- Lifecycle helpers that intentionally differ between the sync and
- async surfaces are filtered out.
- """
-
- excluded = {"from_env", "close"}
- return {
- name
- for name, member in inspect.getmembers(cls, predicate=callable)
- if not name.startswith("_") and name not in excluded
- }
-
-
-def test_sync_and_async_clients_expose_the_same_public_methods() -> None:
- """The async client must expose exactly the same endpoint methods."""
-
- sync_names = _public_callable_names(GlpiClient)
- async_names = _public_callable_names(AsyncGlpiClient)
- assert sync_names == async_names
-
-
-def test_sync_endpoint_methods_are_not_coroutine_functions() -> None:
- """Every public sync method is a plain or generator function, not a coroutine."""
-
- for name in _public_callable_names(GlpiClient):
- member = getattr(GlpiClient, name)
- assert not inspect.iscoroutinefunction(member), (
- f"GlpiClient.{name} should be synchronous"
- )
- assert not inspect.isasyncgenfunction(member), (
- f"GlpiClient.{name} should be synchronous"
- )
-
-
-def test_async_endpoint_methods_are_coroutine_functions() -> None:
- """Every public async method is a coroutine or async generator function."""
-
- for name in _public_callable_names(AsyncGlpiClient):
- member = getattr(AsyncGlpiClient, name)
- is_async = inspect.iscoroutinefunction(member) or inspect.isasyncgenfunction(
- member
- )
- assert is_async, (
- f"AsyncGlpiClient.{name} should be a coroutine or async generator"
- )
-
-
-def test_async_client_close_is_coroutine_and_sync_is_not() -> None:
- """Lifecycle helpers differ on purpose between the two surfaces."""
-
- assert not inspect.iscoroutinefunction(GlpiClient.close)
- assert inspect.iscoroutinefunction(AsyncGlpiClient.close)
diff --git a/glpi_python_client/models/api_schema/_content.py b/glpi_python_client/models/api_schema/_content.py
index 2c6448d..6110af4 100644
--- a/glpi_python_client/models/api_schema/_content.py
+++ b/glpi_python_client/models/api_schema/_content.py
@@ -12,7 +12,7 @@
to canonical Markdown before being assigned to the field, so attribute
access always returns Markdown.
* On serialisation (outgoing request bodies built via
- :func:`glpi_python_client.clients.commons._payloads.model_to_payload`)
+ :func:`glpi_python_client._sync.clients.commons._payloads.model_to_payload`)
the Markdown value is rendered back to HTML so GLPI receives the format
it expects.
diff --git a/glpi_python_client/models/api_schema/assistance/tests/test_assistance_schemas.py b/glpi_python_client/models/api_schema/assistance/tests/test_assistance_schemas.py
index 43d699f..e0eb190 100644
--- a/glpi_python_client/models/api_schema/assistance/tests/test_assistance_schemas.py
+++ b/glpi_python_client/models/api_schema/assistance/tests/test_assistance_schemas.py
@@ -47,6 +47,35 @@ def test_get_ticket_validates_rich_payload() -> None:
assert ticket.team[0].role == "requester"
+def test_get_ticket_accepts_the_major_priority_level() -> None:
+ """A ``Major`` (6) priority ticket validates.
+
+ GLPI's priority scale has six levels while the published contract
+ advertises five, so ``GetTicket`` used to raise ``ValidationError`` on
+ any ticket GLPI had escalated to ``Major``. Because validation happens
+ per record inside a search, one such ticket failed the *entire* query --
+ and a reporting query filtering on high priority is precisely where it
+ would show up.
+ """
+
+ ticket = GetTicket.model_validate({"id": 1, "name": "major", "priority": 6})
+ assert ticket.priority is GlpiPriority.MAJOR
+ assert ticket.priority.glpi_id == 6
+
+
+def test_urgency_and_impact_still_span_one_to_five() -> None:
+ """The five contract-declared levels keep their identifiers.
+
+ Widening the shared enum must not renumber the levels that were already
+ correct: these values are sent back to GLPI in filters, so a shift would
+ silently reinterpret every stored query.
+ """
+
+ assert [member.value for member in GlpiPriority] == [1, 2, 3, 4, 5, 6]
+ assert GlpiPriority.VERY_HIGH.value == 5
+ assert GlpiPriority.VERY_HIGH.rsql_equals("priority") == "priority==5"
+
+
def test_post_ticket_excludes_read_only_fields() -> None:
"""Read-only contract fields are captured in ``extra_payload``.
diff --git a/glpi_python_client/models/api_schema/assistance/tests/test_content_roundtrip.py b/glpi_python_client/models/api_schema/assistance/tests/test_content_roundtrip.py
index b6aef40..b05e65d 100644
--- a/glpi_python_client/models/api_schema/assistance/tests/test_content_roundtrip.py
+++ b/glpi_python_client/models/api_schema/assistance/tests/test_content_roundtrip.py
@@ -11,7 +11,7 @@
import pytest
-from glpi_python_client.clients.commons._payloads import model_to_payload
+from glpi_python_client._sync.clients.commons._payloads import model_to_payload
from glpi_python_client.models.api_schema.assistance import (
GetTicket,
PatchTicket,
diff --git a/glpi_python_client/models/api_schema/enums.py b/glpi_python_client/models/api_schema/enums.py
index 9ba071c..475e8d3 100644
--- a/glpi_python_client/models/api_schema/enums.py
+++ b/glpi_python_client/models/api_schema/enums.py
@@ -69,7 +69,23 @@ class GlpiPriority(GlpiEnum):
"""Common GLPI urgency, impact, and priority identifiers.
The contract advertises the same ``[1, 2, 3, 4, 5]`` enum on the
- ``urgency``, ``impact``, and ``priority`` ticket fields.
+ ``urgency``, ``impact``, and ``priority`` ticket fields, but the live
+ server does not honour that for ``priority``: GLPI's priority scale has
+ a sixth level, ``Major``, which it derives from urgency and impact.
+ ``urgency`` and ``impact`` really do stop at 5.
+
+ :attr:`MAJOR` is therefore included even though the contract omits it,
+ following the same rule the timeline helpers use -- observed server
+ behaviour wins over the published contract. Leaving it out was not a
+ cosmetic gap: ``priority`` is typed with this enum on ``GetTicket``, so
+ a single ``Major`` ticket anywhere in a result set failed validation and
+ took the whole search down with it, which is exactly the kind of ticket
+ a reporting query is most likely to touch.
+
+ The cost of sharing one enum across the three fields is that ``urgency``
+ and ``impact`` now also accept 6, which GLPI will never send. Accepting
+ a value that cannot occur is harmless; rejecting one that does occur was
+ not.
"""
VERY_LOW = 1
@@ -77,6 +93,7 @@ class GlpiPriority(GlpiEnum):
MEDIUM = 3
HIGH = 4
VERY_HIGH = 5
+ MAJOR = 6
class GlpiTicketType(GlpiEnum):
diff --git a/glpi_python_client/models/api_schema/knowledgebase/tests/test_article_schema.py b/glpi_python_client/models/api_schema/knowledgebase/tests/test_article_schema.py
index 6e92231..8e68e5c 100644
--- a/glpi_python_client/models/api_schema/knowledgebase/tests/test_article_schema.py
+++ b/glpi_python_client/models/api_schema/knowledgebase/tests/test_article_schema.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from glpi_python_client.clients.commons._payloads import model_to_payload
+from glpi_python_client._sync.clients.commons._payloads import model_to_payload
from glpi_python_client.models.api_schema._common import IdNameRef
from glpi_python_client.models.api_schema.knowledgebase import (
DeleteKBArticle,
diff --git a/glpi_python_client/models/api_schema/knowledgebase/tests/test_comment_schema.py b/glpi_python_client/models/api_schema/knowledgebase/tests/test_comment_schema.py
index ee6f144..3aaa20e 100644
--- a/glpi_python_client/models/api_schema/knowledgebase/tests/test_comment_schema.py
+++ b/glpi_python_client/models/api_schema/knowledgebase/tests/test_comment_schema.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from glpi_python_client.clients.commons._payloads import model_to_payload
+from glpi_python_client._sync.clients.commons._payloads import model_to_payload
from glpi_python_client.models.api_schema._common import IdNameRef
from glpi_python_client.models.api_schema.knowledgebase import (
DeleteKBArticleComment,
diff --git a/glpi_python_client/models/api_schema/plugins/_fields.py b/glpi_python_client/models/api_schema/plugins/_fields.py
index f5f5260..8ac682f 100644
--- a/glpi_python_client/models/api_schema/plugins/_fields.py
+++ b/glpi_python_client/models/api_schema/plugins/_fields.py
@@ -5,7 +5,7 @@
problems, ...). It is configured through two top-level itemtypes:
* :class:`GetPluginFieldsContainer` — one container per "tab" or "block"
- added to an itemtype (e.g. an ``Aide à la résolution`` tab on
+ added to an itemtype (e.g. an ``Extra information`` tab on
``Ticket``).
* :class:`GetPluginFieldsField` — one user-defined field declaration
belonging to a container (the column name, type, default value, ...).
@@ -13,7 +13,7 @@
For each container, the plugin creates a dedicated itemtype that stores
one row per "item + container" pair. The itemtype name is built as
``PluginFields`` (e.g.
-``PluginFieldsTicketaidelarsolution`` for an ``aidelarsolution``
+``PluginFieldsTicketextrainfo`` for an ``extrainfo``
container attached to ``Ticket``). Rows from those itemtypes are modelled
by :class:`GetPluginFieldsValueRow` — the actual field columns are
dynamic so they flow through the ``extra_payload`` escape hatch on
diff --git a/glpi_python_client/models/api_schema/plugins/tests/test_fields_schemas.py b/glpi_python_client/models/api_schema/plugins/tests/test_fields_schemas.py
index 15b7c87..4461389 100644
--- a/glpi_python_client/models/api_schema/plugins/tests/test_fields_schemas.py
+++ b/glpi_python_client/models/api_schema/plugins/tests/test_fields_schemas.py
@@ -15,8 +15,8 @@ def test_get_container_full_payload() -> None:
payload = {
"id": 10,
- "name": "aidelarsolution",
- "label": "Aide à la résolution",
+ "name": "extrainfo",
+ "label": "Extra information",
"itemtypes": '["Ticket"]',
"type": "tab",
"subtype": None,
@@ -26,7 +26,7 @@ def test_get_container_full_payload() -> None:
"links": [{"rel": "Entity", "href": "https://example/Entity/0"}],
}
container = GetPluginFieldsContainer.model_validate(payload)
- assert container.name == "aidelarsolution"
+ assert container.name == "extrainfo"
# The undocumented ``links`` key flows through extra_payload.
assert "links" in container.extra_payload
@@ -36,8 +36,8 @@ def test_get_field_full_payload() -> None:
payload = {
"id": 11,
- "name": "aidelarsolutionfield",
- "label": "Aide à la résolution",
+ "name": "extrainfofield",
+ "label": "Extra information",
"type": "richtext",
"plugin_fields_containers_id": 10,
"ranking": 1,
@@ -59,24 +59,24 @@ def test_value_row_dynamic_columns_in_extra_payload() -> None:
row = GetPluginFieldsValueRow.model_validate(
{
"id": 1,
- "items_id": 62571,
+ "items_id": 1234,
"itemtype": "Ticket",
"plugin_fields_containers_id": 10,
"entities_id": 0,
- "aidelarsolutionfield": "test
",
+ "extrainfofield": "test
",
}
)
- assert row.items_id == 62571
- assert row.extra_payload["aidelarsolutionfield"] == "test
"
+ assert row.items_id == 1234
+ assert row.extra_payload["extrainfofield"] == "test
"
def test_post_value_row_carries_dynamic_columns() -> None:
"""The POST body accepts dynamic field columns via ``extra_payload``."""
body = PostPluginFieldsValueRow(
- items_id=62571,
+ items_id=1234,
itemtype="Ticket",
plugin_fields_containers_id=10,
- extra_payload={"aidelarsolutionfield": "x
"},
+ extra_payload={"extrainfofield": "x
"},
)
- assert body.extra_payload == {"aidelarsolutionfield": "x
"}
+ assert body.extra_payload == {"extrainfofield": "x
"}
diff --git a/glpi_python_client/testing/utils.py b/glpi_python_client/testing/utils.py
index a939225..05e9786 100644
--- a/glpi_python_client/testing/utils.py
+++ b/glpi_python_client/testing/utils.py
@@ -20,10 +20,15 @@
class FakeResponse:
- """Small ``requests.Response`` stand-in for unit tests.
+ """Small HTTP-response stand-in for unit tests.
The fake object implements only the attributes and ``json()`` behavior used
- by the package's tests.
+ by the package's tests. It is duck-typed rather than a subclass of the
+ transport's response class, so it survived the move from ``requests`` to
+ ``httpx`` unchanged: the library reads the reason phrase through
+ :func:`~glpi_python_client._async.clients.commons._http.response_reason`, which
+ accepts either the ``reason`` spelling used here or the ``reason_phrase``
+ spelling ``httpx`` uses.
"""
def __init__(
diff --git a/glpi_python_client/clients/api/knowledgebase/tests/__init__.py b/glpi_python_client/tests/api_knowledgebase/__init__.py
similarity index 100%
rename from glpi_python_client/clients/api/knowledgebase/tests/__init__.py
rename to glpi_python_client/tests/api_knowledgebase/__init__.py
diff --git a/glpi_python_client/clients/api/knowledgebase/tests/test_article_mixin.py b/glpi_python_client/tests/api_knowledgebase/test_article_mixin.py
similarity index 100%
rename from glpi_python_client/clients/api/knowledgebase/tests/test_article_mixin.py
rename to glpi_python_client/tests/api_knowledgebase/test_article_mixin.py
diff --git a/glpi_python_client/clients/api/knowledgebase/tests/test_category_mixin.py b/glpi_python_client/tests/api_knowledgebase/test_category_mixin.py
similarity index 100%
rename from glpi_python_client/clients/api/knowledgebase/tests/test_category_mixin.py
rename to glpi_python_client/tests/api_knowledgebase/test_category_mixin.py
diff --git a/glpi_python_client/clients/api/knowledgebase/tests/test_comment_mixin.py b/glpi_python_client/tests/api_knowledgebase/test_comment_mixin.py
similarity index 100%
rename from glpi_python_client/clients/api/knowledgebase/tests/test_comment_mixin.py
rename to glpi_python_client/tests/api_knowledgebase/test_comment_mixin.py
diff --git a/glpi_python_client/clients/api/knowledgebase/tests/test_kb_failure_paths.py b/glpi_python_client/tests/api_knowledgebase/test_kb_failure_paths.py
similarity index 100%
rename from glpi_python_client/clients/api/knowledgebase/tests/test_kb_failure_paths.py
rename to glpi_python_client/tests/api_knowledgebase/test_kb_failure_paths.py
diff --git a/glpi_python_client/clients/api/knowledgebase/tests/test_revision_mixin.py b/glpi_python_client/tests/api_knowledgebase/test_revision_mixin.py
similarity index 100%
rename from glpi_python_client/clients/api/knowledgebase/tests/test_revision_mixin.py
rename to glpi_python_client/tests/api_knowledgebase/test_revision_mixin.py
diff --git a/glpi_python_client/clients/api/plugins/tests/__init__.py b/glpi_python_client/tests/api_plugins/__init__.py
similarity index 100%
rename from glpi_python_client/clients/api/plugins/tests/__init__.py
rename to glpi_python_client/tests/api_plugins/__init__.py
diff --git a/glpi_python_client/clients/api/plugins/tests/test_fields_mixin.py b/glpi_python_client/tests/api_plugins/test_fields_mixin.py
similarity index 80%
rename from glpi_python_client/clients/api/plugins/tests/test_fields_mixin.py
rename to glpi_python_client/tests/api_plugins/test_fields_mixin.py
index fce847a..0115f72 100644
--- a/glpi_python_client/clients/api/plugins/tests/test_fields_mixin.py
+++ b/glpi_python_client/tests/api_plugins/test_fields_mixin.py
@@ -7,7 +7,7 @@
import pytest
from glpi_python_client import GlpiClient, GlpiProtocolError, GlpiValidationError
-from glpi_python_client.clients.api.plugins._fields import (
+from glpi_python_client._sync.clients.api.plugins._fields import (
_container_targets_itemtype,
_extract_row_id,
_value_itemtype_for,
@@ -62,10 +62,7 @@ def client() -> GlpiClient:
def test_value_itemtype_naming() -> None:
"""The value itemtype is built from parent type + lowercase container."""
- assert (
- _value_itemtype_for("Ticket", "aidelarsolution")
- == "PluginFieldsTicketaidelarsolution"
- )
+ assert _value_itemtype_for("Ticket", "extrainfo") == "PluginFieldsTicketextrainfo"
# Mixed-case names get normalised to lowercase to match the v1 routes.
assert (
_value_itemtype_for("Ticket", "MyContainer") == "PluginFieldsTicketmycontainer"
@@ -169,19 +166,19 @@ def test_list_item_plugin_field_rows_hits_subresource(client: GlpiClient) -> Non
[
{
"id": 1,
- "items_id": 62571,
+ "items_id": 1234,
"itemtype": "Ticket",
"plugin_fields_containers_id": 10,
"entities_id": 0,
- "aidelarsolutionfield": "test
",
+ "extrainfofield": "test
",
}
]
]
)
client._v1 = fake # type: ignore[assignment]
- rows = client.list_item_plugin_field_rows("Ticket", 62571, "aidelarsolution")
- assert rows[0].extra_payload == {"aidelarsolutionfield": "test
"}
- assert fake.calls[0]["path"] == "Ticket/62571/PluginFieldsTicketaidelarsolution"
+ rows = client.list_item_plugin_field_rows("Ticket", 1234, "extrainfo")
+ assert rows[0].extra_payload == {"extrainfofield": "test
"}
+ assert fake.calls[0]["path"] == "Ticket/1234/PluginFieldsTicketextrainfo"
def test_create_item_plugin_field_row_returns_new_id(client: GlpiClient) -> None:
@@ -193,20 +190,20 @@ def test_create_item_plugin_field_row_returns_new_id(client: GlpiClient) -> None
itemtype="Ticket",
items_id=99,
container_id=10,
- container_name="aidelarsolution",
- values={"aidelarsolutionfield": "x
"},
+ container_name="extrainfo",
+ values={"extrainfofield": "x
"},
entities_id=3,
)
assert row_id == 7
call = fake.calls[0]
assert call["method"] == "POST"
- assert call["path"] == "PluginFieldsTicketaidelarsolution"
+ assert call["path"] == "PluginFieldsTicketextrainfo"
assert call["json_body"] == {
"input": {
"items_id": 99,
"itemtype": "Ticket",
"plugin_fields_containers_id": 10,
- "aidelarsolutionfield": "x
",
+ "extrainfofield": "x
",
"entities_id": 3,
}
}
@@ -219,16 +216,14 @@ def test_update_item_plugin_field_row_puts_partial_body(client: GlpiClient) -> N
client._v1 = fake # type: ignore[assignment]
client.update_item_plugin_field_row(
itemtype="Ticket",
- container_name="aidelarsolution",
+ container_name="extrainfo",
row_id=1,
- values={"aidelarsolutionfield": "updated
"},
+ values={"extrainfofield": "updated
"},
)
call = fake.calls[0]
assert call["method"] == "PUT"
- assert call["path"] == "PluginFieldsTicketaidelarsolution/1"
- assert call["json_body"] == {
- "input": {"id": 1, "aidelarsolutionfield": "updated
"}
- }
+ assert call["path"] == "PluginFieldsTicketextrainfo/1"
+ assert call["json_body"] == {"input": {"id": 1, "extrainfofield": "updated
"}}
def test_get_ticket_custom_fields_aggregates_containers(client: GlpiClient) -> None:
@@ -238,28 +233,28 @@ def test_get_ticket_custom_fields_aggregates_containers(client: GlpiClient) -> N
responses=[
# 1. list_plugin_fields_containers(Ticket)
[
- {"id": 10, "name": "aidelarsolution", "itemtypes": '["Ticket"]'},
- {"id": 2, "name": "sige", "itemtypes": '["Ticket"]'},
+ {"id": 10, "name": "extrainfo", "itemtypes": '["Ticket"]'},
+ {"id": 2, "name": "secondary", "itemtypes": '["Ticket"]'},
{"id": 3, "name": "ignored", "itemtypes": '["Computer"]'},
],
- # 2. list_item_plugin_field_rows aidelarsolution
+ # 2. list_item_plugin_field_rows extrainfo
[
{
"id": 1,
- "items_id": 62571,
+ "items_id": 1234,
"itemtype": "Ticket",
"plugin_fields_containers_id": 10,
"entities_id": 0,
- "aidelarsolutionfield": "test
",
+ "extrainfofield": "test
",
}
],
- # 3. list_item_plugin_field_rows sige -> no row yet
+ # 3. list_item_plugin_field_rows secondary -> no row yet
[],
]
)
client._v1 = fake # type: ignore[assignment]
- result = client.get_ticket_custom_fields(62571)
- assert result == {"aidelarsolution": {"aidelarsolutionfield": "test
"}}
+ result = client.get_ticket_custom_fields(1234)
+ assert result == {"extrainfo": {"extrainfofield": "test
"}}
def test_set_ticket_custom_fields_updates_existing_row(client: GlpiClient) -> None:
@@ -269,13 +264,13 @@ def test_set_ticket_custom_fields_updates_existing_row(client: GlpiClient) -> No
responses=[
# list containers
[
- {"id": 10, "name": "aidelarsolution", "itemtypes": '["Ticket"]'},
+ {"id": 10, "name": "extrainfo", "itemtypes": '["Ticket"]'},
],
# list fields for container 10
[
{
"id": 11,
- "name": "aidelarsolutionfield",
+ "name": "extrainfofield",
"plugin_fields_containers_id": 10,
}
],
@@ -283,7 +278,7 @@ def test_set_ticket_custom_fields_updates_existing_row(client: GlpiClient) -> No
[
{
"id": 1,
- "items_id": 62571,
+ "items_id": 1234,
"itemtype": "Ticket",
"plugin_fields_containers_id": 10,
}
@@ -294,14 +289,12 @@ def test_set_ticket_custom_fields_updates_existing_row(client: GlpiClient) -> No
)
client._v1 = fake # type: ignore[assignment]
client.set_ticket_custom_fields(
- 62571, {"aidelarsolution": {"aidelarsolutionfield": "new
"}}
+ 1234, {"extrainfo": {"extrainfofield": "new
"}}
)
methods = [c["method"] for c in fake.calls]
assert methods == ["GET", "GET", "GET", "PUT"]
put = fake.calls[-1]
- assert put["json_body"] == {
- "input": {"id": 1, "aidelarsolutionfield": "new
"}
- }
+ assert put["json_body"] == {"input": {"id": 1, "extrainfofield": "new
"}}
def test_set_ticket_custom_fields_creates_when_missing(client: GlpiClient) -> None:
@@ -310,12 +303,12 @@ def test_set_ticket_custom_fields_creates_when_missing(client: GlpiClient) -> No
fake = _FakeV1(
responses=[
[
- {"id": 10, "name": "aidelarsolution", "itemtypes": '["Ticket"]'},
+ {"id": 10, "name": "extrainfo", "itemtypes": '["Ticket"]'},
],
[
{
"id": 11,
- "name": "aidelarsolutionfield",
+ "name": "extrainfofield",
"plugin_fields_containers_id": 10,
}
],
@@ -325,17 +318,17 @@ def test_set_ticket_custom_fields_creates_when_missing(client: GlpiClient) -> No
)
client._v1 = fake # type: ignore[assignment]
client.set_ticket_custom_fields(
- 62571, {"aidelarsolution": {"aidelarsolutionfield": "new
"}}
+ 1234, {"extrainfo": {"extrainfofield": "new
"}}
)
methods = [c["method"] for c in fake.calls]
assert methods == ["GET", "GET", "GET", "POST"]
post = fake.calls[-1]
assert post["json_body"] == {
"input": {
- "items_id": 62571,
+ "items_id": 1234,
"itemtype": "Ticket",
"plugin_fields_containers_id": 10,
- "aidelarsolutionfield": "new
",
+ "extrainfofield": "new
",
}
}
@@ -352,7 +345,7 @@ def test_set_ticket_custom_fields_rejects_unknown_container(client: GlpiClient)
with pytest.raises(
GlpiValidationError, match="Unknown plugin-fields container"
) as excinfo:
- client.set_ticket_custom_fields(62571, {"typo": {"any": "value"}})
+ client.set_ticket_custom_fields(1234, {"typo": {"any": "value"}})
# No mutation was sent.
assert all(c["method"] == "GET" for c in fake.calls)
assert isinstance(excinfo.value, ValueError)
@@ -364,18 +357,18 @@ def test_set_ticket_custom_fields_rejects_container_without_id(
"""A matched container with no ``id`` raises before any write.
The container came from the server's own
- :meth:`~glpi_python_client.clients.api.plugins._fields.PluginFieldsMixin.list_plugin_fields_containers`
+ :meth:`~glpi_python_client._sync.clients.api.plugins._fields.PluginFieldsMixin.list_plugin_fields_containers`
response, so a missing ``id`` is a server-side contract violation, not
a caller mistake: ``GlpiProtocolError``. It still inherits
``ValueError`` so existing callers that catch the broader type keep
working.
"""
- fake = _FakeV1(responses=[[{"name": "aidelarsolution", "itemtypes": '["Ticket"]'}]])
+ fake = _FakeV1(responses=[[{"name": "extrainfo", "itemtypes": '["Ticket"]'}]])
client._v1 = fake # type: ignore[assignment]
with pytest.raises(GlpiProtocolError, match="has no id") as excinfo:
client.set_ticket_custom_fields(
- 62571, {"aidelarsolution": {"aidelarsolutionfield": "value"}}
+ 1234, {"extrainfo": {"extrainfofield": "value"}}
)
assert all(c["method"] == "GET" for c in fake.calls)
assert isinstance(excinfo.value, ValueError)
@@ -390,11 +383,11 @@ def test_set_ticket_custom_fields_rejects_unknown_field(client: GlpiClient) -> N
fake = _FakeV1(
responses=[
- [{"id": 10, "name": "aidelarsolution", "itemtypes": '["Ticket"]'}],
+ [{"id": 10, "name": "extrainfo", "itemtypes": '["Ticket"]'}],
[
{
"id": 11,
- "name": "aidelarsolutionfield",
+ "name": "extrainfofield",
"plugin_fields_containers_id": 10,
}
],
@@ -402,7 +395,7 @@ def test_set_ticket_custom_fields_rejects_unknown_field(client: GlpiClient) -> N
)
client._v1 = fake # type: ignore[assignment]
with pytest.raises(GlpiValidationError, match="Unknown field") as excinfo:
- client.set_ticket_custom_fields(62571, {"aidelarsolution": {"typo": "value"}})
+ client.set_ticket_custom_fields(1234, {"extrainfo": {"typo": "value"}})
assert isinstance(excinfo.value, ValueError)
@@ -413,5 +406,5 @@ def test_set_ticket_custom_fields_with_empty_mapping_is_noop(
fake = _FakeV1(responses=[])
client._v1 = fake # type: ignore[assignment]
- client.set_ticket_custom_fields(62571, {})
+ client.set_ticket_custom_fields(1234, {})
assert fake.calls == []
diff --git a/glpi_python_client/tests/auth/__init__.py b/glpi_python_client/tests/auth/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/glpi_python_client/auth/tests/test_auth.py b/glpi_python_client/tests/auth/test_auth.py
similarity index 93%
rename from glpi_python_client/auth/tests/test_auth.py
rename to glpi_python_client/tests/auth/test_auth.py
index 99e1fb9..8bed08b 100644
--- a/glpi_python_client/auth/tests/test_auth.py
+++ b/glpi_python_client/tests/auth/test_auth.py
@@ -3,12 +3,17 @@
from datetime import datetime, timedelta, timezone
from typing import cast
+import httpx
import pytest
-import requests
from tenacity import wait_fixed
-from glpi_python_client import GlpiAuthError, GlpiServerError, GlpiValidationError
-from glpi_python_client.auth.auth import GLPITokenManager
+from glpi_python_client import (
+ GlpiAuthError,
+ GlpiServerError,
+ GlpiTransportError,
+ GlpiValidationError,
+)
+from glpi_python_client._sync.auth.auth import GLPITokenManager
from glpi_python_client.testing.utils import FakeResponse, TokenResponse
@@ -33,7 +38,7 @@ def test_token_manager_uses_password_grant_with_user_credentials_only() -> None:
token_url="https://glpi.example.test/api.php/token",
username="api-user",
password="api-password",
- session=cast(requests.Session, session),
+ session=cast(httpx.Client, session),
)
auth._acquire_token()
@@ -53,7 +58,7 @@ def test_token_manager_uses_client_credentials_grant() -> None:
token_url="https://glpi.example.test/api.php/token",
client_id="client-id",
client_secret="client-secret",
- session=cast(requests.Session, session),
+ session=cast(httpx.Client, session),
)
auth._acquire_token()
@@ -73,7 +78,7 @@ def test_token_manager_preserves_raw_credential_text() -> None:
token_url="https://glpi.example.test/api.php/token",
client_id=" client-id ",
client_secret=" client-secret ",
- session=cast(requests.Session, session),
+ session=cast(httpx.Client, session),
)
auth._acquire_token()
@@ -94,7 +99,7 @@ def test_token_manager_uses_password_grant_with_both_credential_sets() -> None:
client_secret="client-secret",
username="api-user",
password="api-password",
- session=cast(requests.Session, session),
+ session=cast(httpx.Client, session),
)
auth._acquire_token()
@@ -115,7 +120,7 @@ def test_token_manager_refreshes_when_configured_interval_elapses() -> None:
token_url="https://glpi.example.test/api.php/token",
client_id="client-id",
client_secret="client-secret",
- session=cast(requests.Session, session),
+ session=cast(httpx.Client, session),
auth_token_refresh=60,
)
auth.access_token = "old-token"
@@ -211,7 +216,7 @@ def test_oauth_401_raises_glpi_auth_error() -> None:
token_url="https://glpi.example.test/api.php/token",
client_id="client-id",
client_secret="wrong",
- session=cast(requests.Session, session),
+ session=cast(httpx.Client, session),
)
with pytest.raises(GlpiAuthError) as excinfo:
manager.ensure_token()
@@ -230,7 +235,7 @@ def test_oauth_401_is_not_retried() -> None:
token_url="https://glpi.example.test/api.php/token",
client_id="client-id",
client_secret="wrong",
- session=cast(requests.Session, session),
+ session=cast(httpx.Client, session),
)
with pytest.raises(GlpiAuthError):
manager.ensure_token()
@@ -249,7 +254,7 @@ def test_oauth_5xx_raises_glpi_server_error_after_retries(
token_url="https://glpi.example.test/api.php/token",
client_id="client-id",
client_secret="client-secret",
- session=cast(requests.Session, session),
+ session=cast(httpx.Client, session),
)
with pytest.raises(GlpiServerError) as excinfo:
manager.ensure_token()
@@ -273,7 +278,7 @@ def _make_refresh_ready_manager(
token_url="https://glpi.example.test/api.php/token",
client_id="client-id",
client_secret="client-secret",
- session=cast(requests.Session, session),
+ session=cast(httpx.Client, session),
)
manager.access_token = "stale-token"
manager.refresh_token = "refresh-token"
@@ -324,7 +329,7 @@ def test_refresh_5xx_persistent_costs_one_refresh_plus_nested_acquire_attempts(
call on any non-2xx response instead of raising directly
(auth.py:327-332). That nested call is independently decorated with
``stop_after_attempt(3)`` and retries ``GlpiServerError``. This method's
- own decorator only matches ``requests.RequestException`` (a genuine
+ own decorator only matches ``httpx.HTTPError`` (a genuine
network fault on the refresh POST itself), not ``GlpiServerError``, so it
does not retry the fall-through a second time on top of the nested
call's own retries.
@@ -369,17 +374,17 @@ def __init__(self) -> None:
def post(self, url: str, data: dict[str, str], timeout: int) -> FakeResponse:
self.calls.append({"url": url, "data": data, "timeout": timeout})
- raise requests.ConnectionError("network down")
+ raise httpx.ConnectError("network down")
session = _FailingSession()
manager = GLPITokenManager(
token_url="https://glpi.example.test/api.php/token",
client_id="client-id",
client_secret="client-secret",
- session=cast(requests.Session, session),
+ session=cast(httpx.Client, session),
)
- with pytest.raises(requests.ConnectionError):
+ with pytest.raises(GlpiTransportError):
manager.ensure_token()
assert len(session.calls) == 3
@@ -396,7 +401,8 @@ def test_refresh_network_error_is_retried_three_times(
``test_refresh_5xx_persistent_costs_one_refresh_plus_nested_acquire_attempts``
above, and ``_acquire_token``'s network retry is pinned by
``test_acquire_token_network_error_is_retried_three_times``. A
- ``requests.ConnectionError`` raised by ``session.post`` propagates
+ ``httpx.ConnectError`` raised by ``session.post`` is translated to
+ ``GlpiTransportError`` and propagates
*before* ``_refresh_access_token`` reaches its non-2xx fallthrough
branch (auth.py:327-332), so the nested ``_acquire_token`` call is
never reached here -- unlike the persistent-5xx case, this pins
@@ -416,12 +422,12 @@ def __init__(self) -> None:
def post(self, url: str, data: dict[str, str], timeout: int) -> FakeResponse:
self.calls.append({"url": url, "data": data, "timeout": timeout})
- raise requests.ConnectionError("network down")
+ raise httpx.ConnectError("network down")
session = _FailingSession()
manager = _make_refresh_ready_manager(cast(_FakeSession, session))
- with pytest.raises(requests.ConnectionError):
+ with pytest.raises(GlpiTransportError):
manager.ensure_token()
assert len(session.calls) == 3
diff --git a/glpi_python_client/auth/tests/test_v1_session.py b/glpi_python_client/tests/auth/test_v1_session.py
similarity index 95%
rename from glpi_python_client/auth/tests/test_v1_session.py
rename to glpi_python_client/tests/auth/test_v1_session.py
index 98cacf9..f5de54b 100644
--- a/glpi_python_client/auth/tests/test_v1_session.py
+++ b/glpi_python_client/tests/auth/test_v1_session.py
@@ -5,11 +5,16 @@
import json as jsonlib
from typing import Any, cast
+import httpx
import pytest
-import requests
-from glpi_python_client import GlpiProtocolError, GlpiServerError, GlpiValidationError
-from glpi_python_client.auth._v1_session import GLPIV1Session
+from glpi_python_client import (
+ GlpiProtocolError,
+ GlpiServerError,
+ GlpiTransportError,
+ GlpiValidationError,
+)
+from glpi_python_client._sync.auth._v1_session import GLPIV1Session
from glpi_python_client.testing.utils import FakeResponse
@@ -21,7 +26,7 @@ def _no_sleep(monkeypatch: pytest.MonkeyPatch) -> None:
class _FakeV1Http:
- """In-memory ``requests.Session`` stand-in capturing every call."""
+ """In-memory ``httpx.Client`` stand-in capturing every call."""
def __init__(self, responses: dict[str, list[FakeResponse]]) -> None:
self._responses = responses
@@ -44,7 +49,7 @@ def request(
``GLPIV1Session`` routes authenticated calls through
``session.request(method, ...)`` rather than a per-verb attribute,
- because that is the one call shape ``requests`` and ``httpx`` share.
+ because that is the one call shape every transport agrees on.
This dispatches back to the per-verb handlers so their recorded
call shapes stay identical.
"""
@@ -153,7 +158,7 @@ def _make(http: _FakeV1Http) -> GLPIV1Session:
app_token="app-token",
verify_ssl=True,
)
- session._http = cast(requests.Session, http) # type: ignore[assignment]
+ session._http = cast(httpx.Client, http) # type: ignore[assignment]
return session
@@ -373,7 +378,7 @@ def test_v1_close_tolerates_kill_failure() -> None:
class _BoomHttp(_FakeV1Http):
def get(self, url: str, headers: dict[str, str], timeout: int) -> FakeResponse:
if url.endswith("/killSession"):
- raise requests.RequestException("boom")
+ raise httpx.RequestError("boom")
return super().get(url, headers, timeout)
http = _BoomHttp(
@@ -406,7 +411,7 @@ def test_request_json_sends_body_and_returns_parsed_payload() -> None:
assert result == {"ok": True}
post_call = next(call for call in http.calls if call["method"] == "POST")
assert post_call["url"].endswith("/PluginFieldsContainer")
- assert post_call["data"] == jsonlib.dumps({"input": {"name": "x"}})
+ assert post_call["content"] == jsonlib.dumps({"input": {"name": "x"}})
assert post_call["headers"]["Content-Type"] == "application/json"
@@ -475,11 +480,11 @@ def test_request_json_retries_on_5xx() -> None:
def test_request_json_retries_on_network_error() -> None:
"""Network faults during ``request_json`` are retried 3x, not swallowed.
- Pins the ``requests.RequestException`` member of the v1 retry predicate
+ Pins the ``GlpiTransportError`` member of the v1 retry predicate
(``_RETRY_ON_NETWORK_ERRORS`` in ``_v1_session.py``): the 5xx tests above
only exercise the ``GlpiServerError`` member. Without this test a future
- edit that narrows the predicate to drop ``requests.RequestException``
- (for example when plan 3 swaps in ``GlpiTransportError``) would silently
+ edit that narrows the predicate to drop ``GlpiTransportError``
+ would silently
drop v1 network retries from 3 attempts to 1 while every committed test
stayed green.
"""
@@ -502,7 +507,7 @@ def get(
**kwargs,
}
)
- raise requests.ConnectionError("network down")
+ raise httpx.ConnectError("network down")
return super().get(url, headers, timeout, **kwargs)
http = _FlakyHttp(
@@ -511,7 +516,7 @@ def get(
}
)
session = _make(http)
- with pytest.raises(requests.ConnectionError):
+ with pytest.raises(GlpiTransportError):
session.request_json("GET", "PluginFieldsContainer")
json_calls = [c for c in http.calls if c["url"].endswith("/PluginFieldsContainer")]
assert len(json_calls) == 3
diff --git a/glpi_python_client/clients/tests/__init__.py b/glpi_python_client/tests/clients/__init__.py
similarity index 100%
rename from glpi_python_client/clients/tests/__init__.py
rename to glpi_python_client/tests/clients/__init__.py
diff --git a/glpi_python_client/clients/tests/test_api_coverage.py b/glpi_python_client/tests/clients/test_api_coverage.py
similarity index 100%
rename from glpi_python_client/clients/tests/test_api_coverage.py
rename to glpi_python_client/tests/clients/test_api_coverage.py
diff --git a/glpi_python_client/clients/tests/test_glpi_client.py b/glpi_python_client/tests/clients/test_glpi_client.py
similarity index 89%
rename from glpi_python_client/clients/tests/test_glpi_client.py
rename to glpi_python_client/tests/clients/test_glpi_client.py
index f1114ca..8ca2d60 100644
--- a/glpi_python_client/clients/tests/test_glpi_client.py
+++ b/glpi_python_client/tests/clients/test_glpi_client.py
@@ -8,7 +8,7 @@
import pytest
from glpi_python_client import GlpiClient, GlpiValidationError
-from glpi_python_client.clients.commons._config import (
+from glpi_python_client._sync.clients.commons._config import (
build_client_env_config,
normalize_client_api_url,
parse_optional_env_bool,
@@ -267,28 +267,37 @@ def test_async_transport_ensure_open_blocks_after_close() -> None:
client._ensure_open()
-def test_glpi_client_init_failure_closes_session(
+def test_glpi_client_init_failure_creates_no_session(
monkeypatch: pytest.MonkeyPatch,
) -> None:
- """A failing token-manager init releases the underlying session."""
+ """A bad credential set is rejected before any session is constructed.
- closed: dict[str, Any] = {}
+ This used to build the session first and unwind it from an ``except``
+ clause. That shape is not expressible on the async surface -- an
+ ``httpx.AsyncClient`` has no synchronous close and a constructor cannot
+ await one -- so the configuration is now validated up front instead.
- import requests
+ Asserting *nothing was built* is the stronger property: there is no
+ window in which a session exists but the client does not, so there is
+ nothing that can leak if the unwind is ever missed.
+ """
+
+ import httpx
- original_close = requests.Session.close
+ constructed: list[object] = []
+ original_init = httpx.Client.__init__
- def _track_close(self: requests.Session) -> None:
- closed["closed"] = True
- original_close(self)
+ def _track_init(self: httpx.Client, *args: Any, **kwargs: Any) -> None:
+ constructed.append(self)
+ original_init(self, *args, **kwargs)
- monkeypatch.setattr(requests.Session, "close", _track_close)
+ monkeypatch.setattr(httpx.Client, "__init__", _track_init)
with pytest.raises(ValueError):
GlpiClient(
glpi_api_url="https://glpi.example.test/api.php/v2",
client_id="only-id-no-secret",
)
- assert closed.get("closed") is True
+ assert constructed == [], "a transport session was built for a rejected config"
def test_no_other_vars_leak_into_environ_test() -> None:
diff --git a/glpi_python_client/clients/tests/test_method_invocation.py b/glpi_python_client/tests/clients/test_method_invocation.py
similarity index 81%
rename from glpi_python_client/clients/tests/test_method_invocation.py
rename to glpi_python_client/tests/clients/test_method_invocation.py
index 49dc545..1fecef7 100644
--- a/glpi_python_client/clients/tests/test_method_invocation.py
+++ b/glpi_python_client/tests/clients/test_method_invocation.py
@@ -89,6 +89,12 @@ def json(self) -> Any:
return self._payload
+def _stub_v1_payload() -> Any:
+ """Both shapes the v1 callers expect from a JSON response."""
+
+ return [{"id": 1, "name": "stub", "itemtypes": '["Ticket"]'}]
+
+
class _StubV1:
"""Stand-in for the legacy v1 session, logging into the shared call log."""
@@ -97,9 +103,7 @@ def __init__(self, calls: list[str]) -> None:
def request_json(self, method: str, path: str, **kwargs: Any) -> Any:
self._calls.append(f"v1 {method} {path}")
- # Both shapes the v1 callers expect: a list for collection reads and
- # a dict carrying an id for writes.
- return [{"id": 1, "name": "stub", "itemtypes": '["Ticket"]'}]
+ return _stub_v1_payload()
def upload_document(self, *args: Any, **kwargs: Any) -> int:
self._calls.append("v1 POST Document")
@@ -109,23 +113,60 @@ def close(self) -> None:
"""No-op; the real session is closed with the client."""
+class _AsyncStubV1(_StubV1):
+ """Async twin of :class:`_StubV1`.
+
+ The async client awaits every v1 call, so the stub's methods have to be
+ coroutines. Returning a plain value would surface as ``TypeError: object
+ NoneType can't be used in 'await' expression`` -- a failure about the
+ stub, not about the client.
+ """
+
+ async def request_json(self, method: str, path: str, **kwargs: Any) -> Any:
+ self._calls.append(f"v1 {method} {path}")
+ return _stub_v1_payload()
+
+ async def upload_document(self, *args: Any, **kwargs: Any) -> int:
+ self._calls.append("v1 POST Document")
+ return 1
+
+ async def close(self) -> None:
+ """No-op; the real session is closed with the client."""
+
+
+def _stub_response_for(method: str, url: str) -> _StubResponse:
+ """Build the response shape the endpoint at ``url`` expects."""
+
+ # List endpoints must see a list; single-item endpoints an object.
+ # GLPI collection paths are the ones the client pages over, and they
+ # are exactly those called without a trailing numeric id.
+ tail = url.rstrip("/").rsplit("/", 1)[-1]
+ payload: Any = _payload_for(url)
+ if not tail.isdigit() and method.upper() == "GET":
+ payload = [payload]
+ return _StubResponse(payload)
+
+
def _install_stub(client: GlpiClient | AsyncGlpiClient) -> list[str]:
- """Route every HTTP call to an in-memory stub; return the call log."""
+ """Route every HTTP call to an in-memory stub; return the call log.
+
+ The stub is installed at the ``session.request`` seam on both surfaces,
+ so URL building, headers, parameter normalisation, response validation
+ and model parsing all execute for real -- only the socket is replaced.
+ """
calls: list[str] = []
+ is_async = isinstance(client, AsyncGlpiClient)
def _request(method: str, url: str, **kwargs: Any) -> _StubResponse:
calls.append(f"{method} {url}")
- # List endpoints must see a list; single-item endpoints an object.
- # GLPI collection paths are the ones the client pages over, and they
- # are exactly those called without a trailing numeric id.
- tail = url.rstrip("/").rsplit("/", 1)[-1]
- payload: Any = _payload_for(url)
- if not tail.isdigit() and method.upper() == "GET":
- payload = [payload]
- return _StubResponse(payload)
-
- client._session.request = _request # type: ignore[method-assign,union-attr]
+ return _stub_response_for(method, url)
+
+ async def _arequest(method: str, url: str, **kwargs: Any) -> _StubResponse:
+ calls.append(f"{method} {url}")
+ return _stub_response_for(method, url)
+
+ client._session.request = _arequest if is_async else _request # 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"
@@ -133,7 +174,7 @@ def _request(method: str, url: str, **kwargs: Any) -> _StubResponse:
# Several features (plugin fields, KB category writes, document upload,
# actor statistics) run on the legacy v1 session rather than the v2
# transport. Stub it into the same log so they are exercised too.
- client._v1 = _StubV1(calls) # type: ignore[assignment]
+ client._v1 = (_AsyncStubV1 if is_async else _StubV1)(calls) # type: ignore[assignment]
return calls
diff --git a/glpi_python_client/clients/tests/test_raise_site_audit.py b/glpi_python_client/tests/clients/test_raise_site_audit.py
similarity index 82%
rename from glpi_python_client/clients/tests/test_raise_site_audit.py
rename to glpi_python_client/tests/clients/test_raise_site_audit.py
index b599a9f..a0be8a3 100644
--- a/glpi_python_client/clients/tests/test_raise_site_audit.py
+++ b/glpi_python_client/tests/clients/test_raise_site_audit.py
@@ -28,6 +28,9 @@
"GlpiServerError",
"GlpiStatusError",
"error_class", # status_error_class(...) dispatch result
+ # transport_error_from(...) returns GlpiTransportError or its
+ # GlpiTimeoutError subclass; the AST sees the factory, not the class.
+ "transport_error_from",
"RuntimeError", # exempt by design -- see module docstring
"TypeError", # exempt by design -- see module docstring
}
@@ -76,13 +79,14 @@ def test_the_ast_walk_finds_a_known_raise_site() -> None:
sites = _raise_sites()
assert sites, "the raise-site walk found nothing -- it is broken"
- # clients/commons/_transport.py:106 raises a deliberately-exempt
+ # clients/commons/_transport.py raises a deliberately-exempt
# RuntimeError (decision D3) that this migration never touches, making
# it a stable landmark to confirm the walk actually inspects source.
transport_sites = [
site
for site in sites
- if site[0] == "clients/commons/_transport.py" and site[2] == "RuntimeError"
+ if site[0].endswith("clients/commons/_transport.py")
+ and site[2] == "RuntimeError"
]
assert transport_sites, (
"the raise-site walk did not find the known RuntimeError raise in "
@@ -98,11 +102,19 @@ def test_no_bare_value_error_is_raised_by_library_code() -> None:
assert offenders == [], f"bare ValueError raise sites remain: {offenders}"
-def test_no_requests_exception_is_raised_by_library_code() -> None:
- """The library never raises a third-party HTTP exception directly."""
+def test_no_third_party_http_exception_is_raised_by_library_code() -> None:
+ """The library never raises a third-party HTTP exception directly.
- offenders = [site for site in _raise_sites() if site[2].startswith("requests.")]
- assert offenders == [], f"requests exception raise sites remain: {offenders}"
+ Both spellings are checked, not just the transport currently in use: the
+ point of the audit is that a raise site cannot drift back to a
+ third-party type, and naming only the current library would let the
+ previous one silently reappear.
+ """
+
+ offenders = [
+ site for site in _raise_sites() if site[2].startswith(("requests.", "httpx."))
+ ]
+ assert offenders == [], f"third-party exception raise sites remain: {offenders}"
def test_every_raise_site_uses_an_allowed_exception() -> None:
diff --git a/glpi_python_client/clients/tests/test_smoke.py b/glpi_python_client/tests/clients/test_smoke.py
similarity index 100%
rename from glpi_python_client/clients/tests/test_smoke.py
rename to glpi_python_client/tests/clients/test_smoke.py
diff --git a/glpi_python_client/clients/commons/tests/__init__.py b/glpi_python_client/tests/commons/__init__.py
similarity index 63%
rename from glpi_python_client/clients/commons/tests/__init__.py
rename to glpi_python_client/tests/commons/__init__.py
index 0bcd31e..df68862 100644
--- a/glpi_python_client/clients/commons/tests/__init__.py
+++ b/glpi_python_client/tests/commons/__init__.py
@@ -1,4 +1,4 @@
-"""Tests for :mod:`glpi_python_client.clients.commons` helpers.
+"""Tests for :mod:`glpi_python_client._sync.clients.commons` helpers.
These tests exercise the small contract-aligned helpers shared by the API
mixins without dispatching real HTTP calls.
diff --git a/glpi_python_client/clients/commons/tests/test_constants.py b/glpi_python_client/tests/commons/test_constants.py
similarity index 88%
rename from glpi_python_client/clients/commons/tests/test_constants.py
rename to glpi_python_client/tests/commons/test_constants.py
index 9732202..7e337c3 100644
--- a/glpi_python_client/clients/commons/tests/test_constants.py
+++ b/glpi_python_client/tests/commons/test_constants.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from glpi_python_client.clients.commons import _constants
+from glpi_python_client._sync.clients.commons import _constants
def test_knowledgebase_endpoint_constants() -> None:
diff --git a/glpi_python_client/clients/commons/tests/test_filters.py b/glpi_python_client/tests/commons/test_filters.py
similarity index 94%
rename from glpi_python_client/clients/commons/tests/test_filters.py
rename to glpi_python_client/tests/commons/test_filters.py
index fcef3b6..0f77153 100644
--- a/glpi_python_client/clients/commons/tests/test_filters.py
+++ b/glpi_python_client/tests/commons/test_filters.py
@@ -1,8 +1,8 @@
-"""Unit tests for :mod:`glpi_python_client.clients.commons._filters`."""
+"""Unit tests for :mod:`glpi_python_client._sync.clients.commons._filters`."""
from __future__ import annotations
-from glpi_python_client.clients.commons._filters import (
+from glpi_python_client._sync.clients.commons._filters import (
escape_rsql_like_value,
escape_rsql_text_value,
rsql_all_filter,
diff --git a/glpi_python_client/clients/commons/tests/test_http.py b/glpi_python_client/tests/commons/test_http.py
similarity index 79%
rename from glpi_python_client/clients/commons/tests/test_http.py
rename to glpi_python_client/tests/commons/test_http.py
index 337f95f..0774e65 100644
--- a/glpi_python_client/clients/commons/tests/test_http.py
+++ b/glpi_python_client/tests/commons/test_http.py
@@ -1,4 +1,4 @@
-"""Unit tests for :mod:`glpi_python_client.clients.commons._http`.
+"""Unit tests for :mod:`glpi_python_client._sync.clients.commons._http`.
The tests cover the small request and response helper utilities used by the
asynchronous transport and the per-endpoint mixins.
@@ -11,7 +11,7 @@
import pytest
from glpi_python_client import GlpiProtocolError, GlpiValidationError
-from glpi_python_client.clients.commons._http import (
+from glpi_python_client._sync.clients.commons._http import (
build_request_headers,
build_request_url,
ensure_response_status,
@@ -26,19 +26,39 @@
def test_request_param_value_normalises_supported_types() -> None:
- """The helper returns native scalar values unchanged and stringifies others."""
+ """The helper renders every value the way the wire format expects.
+
+ Numbers and strings pass through untouched. ``bool`` and ``bytes`` are
+ converted deliberately, because the underlying HTTP libraries disagree
+ about them: ``httpx`` renders booleans lowercase and stringifies a
+ ``bytes`` object into its Python repr (``b'x'``), where the previous
+ ``requests``-based transport emitted ``True`` and the decoded text.
+ Normalising here keeps the emitted query string identical regardless of
+ which library is installed.
+ """
- assert request_param_value(True) is True
assert request_param_value(7) == 7
+ assert request_param_value(1.5) == 1.5
assert request_param_value("hello") == "hello"
+ assert request_param_value(True) == "True"
+ assert request_param_value(False) == "False"
+ assert request_param_value(b"raw") == "raw"
assert request_param_value(object()) != ""
def test_request_params_drops_none_values() -> None:
- """``None`` parameter values are excluded from the produced query mapping."""
+ """``None`` parameter values are excluded from the produced query mapping.
+
+ This is a correctness guarantee, not a tidiness one. ``requests`` omitted
+ a ``None``-valued key from the query string entirely; ``httpx`` encodes it
+ as a valueless ``key=``. GLPI does not treat those the same — an empty
+ filter or search value matches *everything* — so forwarding the key would
+ silently widen a query rather than leave it unconstrained.
+ """
cleaned = request_params({"limit": 10, "filter": None, "force": True})
- assert cleaned == {"limit": 10, "filter": None, "force": True}
+ assert cleaned == {"limit": 10, "force": "True"}
+ assert "filter" not in cleaned
def test_build_request_url_concatenates_base_and_endpoint() -> None:
diff --git a/glpi_python_client/clients/commons/tests/test_payloads.py b/glpi_python_client/tests/commons/test_payloads.py
similarity index 93%
rename from glpi_python_client/clients/commons/tests/test_payloads.py
rename to glpi_python_client/tests/commons/test_payloads.py
index 047f497..443dbc2 100644
--- a/glpi_python_client/clients/commons/tests/test_payloads.py
+++ b/glpi_python_client/tests/commons/test_payloads.py
@@ -1,8 +1,8 @@
-"""Unit tests for :mod:`glpi_python_client.clients.commons._payloads`."""
+"""Unit tests for :mod:`glpi_python_client._sync.clients.commons._payloads`."""
from __future__ import annotations
-from glpi_python_client.clients.commons._payloads import (
+from glpi_python_client._sync.clients.commons._payloads import (
model_from_payload,
model_to_payload,
)
diff --git a/glpi_python_client/clients/commons/tests/test_retry_semantics.py b/glpi_python_client/tests/commons/test_retry_semantics.py
similarity index 64%
rename from glpi_python_client/clients/commons/tests/test_retry_semantics.py
rename to glpi_python_client/tests/commons/test_retry_semantics.py
index d628f0b..dff5db6 100644
--- a/glpi_python_client/clients/commons/tests/test_retry_semantics.py
+++ b/glpi_python_client/tests/commons/test_retry_semantics.py
@@ -10,12 +10,19 @@
from collections.abc import Iterator
from typing import Any
+import httpx
import pytest
-import requests
from tenacity import wait_fixed
-from glpi_python_client import GlpiClient, GlpiNotFoundError, GlpiServerError
-from glpi_python_client.clients.commons._http import ensure_response_status
+from glpi_python_client import (
+ GlpiClient,
+ GlpiError,
+ GlpiNotFoundError,
+ GlpiServerError,
+ GlpiTimeoutError,
+ GlpiTransportError,
+)
+from glpi_python_client._sync.clients.commons._http import ensure_response_status
from glpi_python_client.testing.utils import FakeResponse, make_client
_RETRIED_METHODS = (
@@ -144,7 +151,16 @@ def test_tolerant_search_still_returns_empty_on_4xx(client: Any) -> None:
@pytest.mark.parametrize("method_name", _RETRIED_METHODS)
def test_network_errors_are_still_retried(client: Any, method_name: str) -> None:
- """Real ``requests`` transport faults keep their retry behaviour.
+ """Real transport faults are translated and still retried three times.
+
+ The fault is injected at ``session.request`` — *below* the translation
+ boundary — rather than by stubbing ``_send_request``. That matters: a stub
+ above the boundary would raise the HTTP library's own exception, which the
+ retry predicate no longer names, so the test would pass or fail for
+ reasons unrelated to the behaviour it is meant to pin. Injecting here
+ exercises the real path end to end: a genuine ``httpx`` fault, translated
+ into ``GlpiTransportError``, matched by the predicate, retried three
+ times, and surfaced to the caller as a library error.
Parametrized across all four retried verbs so the network-fault attempt
count is pinned for each, not just ``_get_request``.
@@ -152,12 +168,54 @@ def test_network_errors_are_still_retried(client: Any, method_name: str) -> None
attempts: list[int] = []
- def _send(method: str, url: str, **kw: Any) -> FakeResponse:
+ def _request(method: str, url: str, **kw: Any) -> FakeResponse:
attempts.append(1)
- raise requests.ConnectionError("network down")
+ raise httpx.ConnectError("network down")
- client._send_request = _send
- with pytest.raises(requests.ConnectionError):
+ client._session.request = _request
+ with pytest.raises(GlpiTransportError):
getattr(client, method_name)("Assistance/Ticket")
assert len(attempts) == 3
+
+
+@pytest.mark.parametrize("method_name", _RETRIED_METHODS)
+def test_no_third_party_exception_reaches_the_caller(
+ client: Any, method_name: str
+) -> None:
+ """A network fault never surfaces as the HTTP library's own exception.
+
+ The public contract is that ``GlpiError`` is sufficient to catch the
+ library's failures. This pins the half of that promise which used to be
+ false: transport faults escaped as third-party exceptions, forcing callers
+ to import the HTTP library. If the translation is ever removed, the raw
+ exception reaches the caller and this fails.
+ """
+
+ def _request(method: str, url: str, **kw: Any) -> FakeResponse:
+ raise httpx.ConnectError("network down")
+
+ client._session.request = _request
+ with pytest.raises(GlpiError) as excinfo:
+ getattr(client, method_name)("Assistance/Ticket")
+
+ assert not isinstance(excinfo.value, httpx.HTTPError)
+ # The original fault stays reachable for debugging.
+ assert isinstance(excinfo.value.__cause__, httpx.ConnectError)
+
+
+def test_timeouts_narrow_to_the_timeout_subclass(client: Any) -> None:
+ """A timeout surfaces as ``GlpiTimeoutError``, not just the base class.
+
+ ``GlpiTimeoutError`` exists so callers can single out the "GLPI was too
+ slow" case from "GLPI was unreachable". That only works if the translation
+ actually inspects the fault type rather than flattening everything to the
+ base class.
+ """
+
+ def _request(method: str, url: str, **kw: Any) -> FakeResponse:
+ raise httpx.ConnectTimeout("too slow")
+
+ client._session.request = _request
+ with pytest.raises(GlpiTimeoutError):
+ client._get_request("Assistance/Ticket")
diff --git a/glpi_python_client/clients/commons/tests/test_transport.py b/glpi_python_client/tests/commons/test_transport.py
similarity index 100%
rename from glpi_python_client/clients/commons/tests/test_transport.py
rename to glpi_python_client/tests/commons/test_transport.py
diff --git a/glpi_python_client/tests/custom/__init__.py b/glpi_python_client/tests/custom/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/glpi_python_client/clients/custom/tests/test_statistics.py b/glpi_python_client/tests/custom/test_statistics.py
similarity index 99%
rename from glpi_python_client/clients/custom/tests/test_statistics.py
rename to glpi_python_client/tests/custom/test_statistics.py
index ebb187b..9d3037e 100644
--- a/glpi_python_client/clients/custom/tests/test_statistics.py
+++ b/glpi_python_client/tests/custom/test_statistics.py
@@ -19,7 +19,7 @@
GlpiTicketType,
GlpiValidationError,
)
-from glpi_python_client.clients.custom._statistics import (
+from glpi_python_client._sync.clients.custom._statistics import (
_V1_SO_ASSIGNEE,
_V1_SO_REQUESTER,
)
diff --git a/glpi_python_client/clients/custom/tests/test_ticket_context.py b/glpi_python_client/tests/custom/test_ticket_context.py
similarity index 94%
rename from glpi_python_client/clients/custom/tests/test_ticket_context.py
rename to glpi_python_client/tests/custom/test_ticket_context.py
index 5b658c2..cc4694a 100644
--- a/glpi_python_client/clients/custom/tests/test_ticket_context.py
+++ b/glpi_python_client/tests/custom/test_ticket_context.py
@@ -1,7 +1,7 @@
"""Unit tests for the synchronous ticket-context mixin.
The tests stub ``_get_request`` on a real :class:`GlpiClient` so
-:meth:`~glpi_python_client.clients.custom._ticket_context.TicketContextMixin.get_ticket_context`
+:meth:`~glpi_python_client._sync.clients.custom._ticket_context.TicketContextMixin.get_ticket_context`
exercises its real aggregation logic without any network call.
"""
diff --git a/glpi_python_client/tests/test_async_surface.py b/glpi_python_client/tests/test_async_surface.py
new file mode 100644
index 0000000..04f65ed
--- /dev/null
+++ b/glpi_python_client/tests/test_async_surface.py
@@ -0,0 +1,231 @@
+"""Runtime tests for the hand-written async surface.
+
+The bulk of the suite exercises the generated sync client, which is a 1:1
+token transform of this tree and therefore covers the shared logic. What it
+cannot cover is anything that only exists once ``async``/``await`` are real:
+whether the client actually awaits, whether concurrent tasks can contend the
+auth lock without deadlocking, and whether the fan-out helper really runs
+work concurrently rather than in sequence.
+
+Those are exactly the properties the codegen cannot verify -- the diff gate
+proves the two trees *correspond*, not that the async one *works* -- so they
+are tested here directly.
+"""
+
+from __future__ import annotations
+
+import asyncio
+from datetime import datetime, timedelta, timezone
+from typing import Any
+
+import httpx
+import pytest
+
+from glpi_python_client import AsyncGlpiClient, GlpiTimeoutError, GlpiTransportError
+from glpi_python_client._async._concurrency import gather
+from glpi_python_client.testing.utils import make_async_client
+
+
+class _Response:
+ """Minimal response object covering what the transport layer reads."""
+
+ def __init__(self, payload: Any, status_code: int = 200) -> None:
+ self._payload = payload
+ self.status_code = status_code
+ self.headers: dict[str, str] = {}
+ self.text = "{}"
+ self.reason = "OK"
+ self.url = "https://glpi.example.test/api.php/v2/stub"
+ self.content = b"{}"
+
+ def json(self) -> Any:
+ return self._payload
+
+
+def _stub(client: AsyncGlpiClient, payload: Any) -> list[str]:
+ """Install an async transport stub and pretend a token is held."""
+
+ calls: list[str] = []
+
+ async def _request(method: str, url: str, **kwargs: Any) -> _Response:
+ calls.append(f"{method} {url}")
+ return _Response(payload)
+
+ client._session.request = _request # type: ignore[method-assign,assignment]
+ client._auth.access_token = "stub-token"
+ client._auth.token_expires_at = datetime.now(tz=timezone.utc) + timedelta(days=365)
+ return calls
+
+
+async def test_a_read_awaits_and_returns_a_model() -> None:
+ """The async client dispatches and parses exactly like its twin."""
+
+ client = make_async_client()
+ calls = _stub(client, {"id": 42, "name": "async ticket"})
+ try:
+ ticket = await client.get_ticket(42)
+ assert ticket.id == 42
+ assert ticket.name == "async ticket"
+ assert calls, "no HTTP call was dispatched"
+ finally:
+ await client.close()
+
+
+async def test_close_is_idempotent_and_blocks_further_calls() -> None:
+ """Closing twice is safe and a closed client refuses to dispatch."""
+
+ client = make_async_client()
+ _stub(client, {"id": 1, "name": "x"})
+ await client.close()
+ await client.close()
+ with pytest.raises(RuntimeError, match="closed"):
+ await client.get_ticket(1)
+
+
+async def test_the_async_context_manager_closes_on_exit() -> None:
+ """``async with`` releases the client, so ``__aexit__`` really awaits."""
+
+ client = make_async_client()
+ _stub(client, {"id": 1, "name": "x"})
+ async with client as entered:
+ assert entered is client
+ assert client._closed is True
+
+
+async def test_concurrent_tasks_contend_the_auth_lock_without_deadlocking() -> None:
+ """Many tasks may refresh the token at once and all of them finish.
+
+ This is the property the hand-written ``_concurrency`` twin exists for.
+ The lock is held across an ``await``; with a ``threading.Lock`` -- the
+ right primitive for the *sync* tree -- the second task to arrive would
+ block the event loop, the holder could never resume to release it, and
+ this test would hang forever rather than fail. Running real contention
+ is the only way to observe that.
+ """
+
+ client = make_async_client()
+ calls = _stub(client, {"id": 1, "name": "x"})
+ # Force every task through the token-acquisition path.
+ client._auth.access_token = None
+
+ acquisitions = 0
+
+ async def _acquire() -> None:
+ nonlocal acquisitions
+ acquisitions += 1
+ await asyncio.sleep(0) # a real suspension point inside the lock
+ client._auth.access_token = "stub-token"
+
+ client._auth._acquire_token = _acquire # type: ignore[method-assign]
+ try:
+ results = await asyncio.wait_for(
+ asyncio.gather(*(client.get_ticket(i) for i in range(1, 11))),
+ timeout=10,
+ )
+ assert len(results) == 10
+ assert len(calls) == 10
+ finally:
+ await client.close()
+
+
+async def test_gather_runs_work_concurrently() -> None:
+ """The async ``gather`` overlaps its arguments rather than serialising.
+
+ The sync twin returns already-evaluated values, which is correct there.
+ Here the whole point is that the calls overlap.
+
+ Completion *order* is the primary evidence: the shorter sleep finishes
+ first even though it is passed second, which cannot happen if the two
+ ran one after the other. The elapsed-time bound is a secondary check,
+ and the delays are chosen well clear of the platform timer granularity
+ (~16ms on Windows) so the margin between "concurrent" and "sequential"
+ is not swallowed by rounding.
+ """
+
+ slow_delay, fast_delay = 0.30, 0.15
+ order: list[str] = []
+
+ async def _sleep_then_record(label: str, delay: float) -> str:
+ await asyncio.sleep(delay)
+ order.append(label)
+ return label
+
+ loop = asyncio.get_running_loop()
+ started = loop.time()
+ results = await gather(
+ _sleep_then_record("slow", slow_delay),
+ _sleep_then_record("fast", fast_delay),
+ )
+ elapsed = loop.time() - started
+
+ # Results keep argument order; completion order is by speed.
+ assert results == ["slow", "fast"]
+ assert order == ["fast", "slow"], "the two coroutines did not overlap"
+ assert elapsed < (slow_delay + fast_delay) * 0.9, (
+ f"gather took {elapsed:.3f}s, close to the sequential "
+ f"{slow_delay + fast_delay:.3f}s -- it serialised its arguments"
+ )
+
+
+async def test_network_faults_are_translated_on_the_async_path() -> None:
+ """A transport fault surfaces as a library error, not an httpx one.
+
+ The sync path has its own test for this. Repeating it here is not
+ duplication: the translation sits in a ``try``/``except`` around an
+ awaited call, and an ``except`` clause that fails to cover an awaited
+ expression is a distinct mistake the sync test cannot detect.
+ """
+
+ client = make_async_client()
+ _stub(client, {})
+
+ async def _boom(method: str, url: str, **kwargs: Any) -> _Response:
+ raise httpx.ConnectError("network down")
+
+ client._session.request = _boom # type: ignore[method-assign,assignment]
+ # Keep the retry from spending 6 real seconds on the way to failing.
+ client._get_request.retry.wait = lambda *a, **k: 0 # type: ignore[attr-defined]
+ try:
+ with pytest.raises(GlpiTransportError) as excinfo:
+ await client.get_ticket(1)
+ assert isinstance(excinfo.value.__cause__, httpx.ConnectError)
+ finally:
+ await client.close()
+
+
+async def test_timeouts_narrow_on_the_async_path() -> None:
+ """A timeout narrows to ``GlpiTimeoutError`` when awaited too."""
+
+ client = make_async_client()
+ _stub(client, {})
+
+ async def _slow(method: str, url: str, **kwargs: Any) -> _Response:
+ raise httpx.ConnectTimeout("too slow")
+
+ client._session.request = _slow # type: ignore[method-assign,assignment]
+ client._get_request.retry.wait = lambda *a, **k: 0 # type: ignore[attr-defined]
+ try:
+ with pytest.raises(GlpiTimeoutError):
+ await client.get_ticket(1)
+ finally:
+ await client.close()
+
+
+async def test_the_paginating_generator_is_an_async_generator() -> None:
+ """``iter_search_*`` yields pages under ``async for``.
+
+ The generators are the one place the codegen has to get two things
+ right at once -- ``async def`` plus ``AsyncIterator`` -- and a mistake
+ in either shows up only when the generator is actually driven.
+ """
+
+ client = make_async_client()
+ _stub(client, [{"id": 1, "name": "one"}])
+ try:
+ pages = []
+ async for page in client.iter_search_tickets(batch_size=50):
+ pages.append(page)
+ break
+ assert pages and pages[0][0].id == 1
+ finally:
+ await client.close()
diff --git a/glpi_python_client/tests/test_docstring_references.py b/glpi_python_client/tests/test_docstring_references.py
new file mode 100644
index 0000000..aa059b7
--- /dev/null
+++ b/glpi_python_client/tests/test_docstring_references.py
@@ -0,0 +1,109 @@
+"""Every qualified cross-reference in the package points at something real.
+
+Sphinx cannot provide this guarantee. ``nitpicky`` is off, so an
+unresolvable ``:class:`` or ``:mod:`` target renders as plain text
+instead of failing the build -- and most of these modules are private,
+so autodoc never visits them in the first place. A reference to a module
+that was since renamed or deleted therefore rots in complete silence.
+
+That is not hypothetical. The move to a generated sync tree renamed one
+client module and deleted four others, and seven docstring references to
+them survived the rewrite with a green suite, a green ``-W`` docs build,
+and a clean codegen diff. This test is the check that would have caught
+them, so the next rename cannot repeat it.
+"""
+
+from __future__ import annotations
+
+import importlib
+import pathlib
+import re
+
+_REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
+_PACKAGE = _REPO_ROOT / "glpi_python_client"
+
+#: A qualified reference to this package: the root name plus at least one
+#: dotted segment. Bare mentions are excluded on purpose -- ``GlpiClient``
+#: on its own is prose, not a reference that can rot.
+_REFERENCE = re.compile(r"\bglpi_python_client(?:\.[A-Za-z_]\w*)+")
+
+
+def _resolves(dotted: str) -> bool:
+ """Return whether ``dotted`` names a real module or attribute chain.
+
+ Tries the longest importable module prefix first, then walks the
+ remainder with :func:`getattr`. A reference may end in a module, a
+ class, or a method, and all three have to be accepted.
+ """
+
+ parts = dotted.split(".")
+ for stop in range(len(parts), 0, -1):
+ try:
+ target: object = importlib.import_module(".".join(parts[:stop]))
+ except ImportError:
+ continue
+ for attribute in parts[stop:]:
+ if hasattr(target, attribute):
+ target = getattr(target, attribute)
+ continue
+ # Pydantic v2 moves declared fields into ``model_fields`` and
+ # takes them out of the class namespace, so ``hasattr`` says no
+ # to a field that autodoc documents perfectly well. Accepting
+ # them matters: without this the check reports noise, and a
+ # check that cries wolf gets deleted.
+ fields = getattr(target, "model_fields", None)
+ if isinstance(fields, dict) and attribute in fields:
+ return True
+ return False
+ return True
+ return False
+
+
+def _reference_sites() -> list[tuple[str, int, str]]:
+ """Return ``(module, lineno, reference)`` for every qualified mention."""
+
+ sites: list[tuple[str, int, str]] = []
+ this_module = pathlib.Path(__file__).resolve()
+ for path in sorted(_PACKAGE.rglob("*.py")):
+ # This module names dead targets on purpose, as controls.
+ if "__pycache__" in path.parts or path.resolve() == this_module:
+ continue
+ rel = path.relative_to(_REPO_ROOT).as_posix()
+ for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
+ for match in _REFERENCE.findall(line):
+ sites.append((rel, lineno, match.rstrip(".")))
+ return sites
+
+
+def test_the_scan_finds_references_and_can_tell_them_apart() -> None:
+ """Positive control: the scan is not vacuous and ``_resolves`` discriminates.
+
+ Without this, a regex that stopped matching would turn the check
+ below into a test that passes forever without looking at anything.
+ """
+
+ sites = _reference_sites()
+ assert len(sites) > 50, f"only {len(sites)} references found -- regex broken?"
+
+ assert _resolves("glpi_python_client.GlpiClient")
+ assert _resolves("glpi_python_client.AsyncGlpiClient.get_ticket_context")
+ assert _resolves("glpi_python_client._async.clients.commons._transport")
+ # One of the targets the codegen rewrite actually left behind.
+ assert not _resolves(
+ "glpi_python_client._async.clients.async_client.AsyncGlpiClient"
+ )
+ assert not _resolves("glpi_python_client.nope")
+
+
+def test_every_qualified_reference_resolves() -> None:
+ """No docstring names a module, class, or method that does not exist."""
+
+ offenders = [
+ f"{module}:{lineno}: {reference}"
+ for module, lineno, reference in _reference_sites()
+ if not _resolves(reference)
+ ]
+ assert offenders == [], (
+ "these references point at nothing -- the target was renamed or "
+ "deleted and the docstring was not updated:\n" + "\n".join(offenders)
+ )
diff --git a/glpi_python_client/tests/test_skill_references.py b/glpi_python_client/tests/test_skill_references.py
new file mode 100644
index 0000000..f354c90
--- /dev/null
+++ b/glpi_python_client/tests/test_skill_references.py
@@ -0,0 +1,186 @@
+"""Every method the bundled skills name exists on both clients.
+
+``skills/`` is instructions for an agent, so a wrong name there is not a
+typo in prose -- it is a wrong API taught to whatever reads it, and the
+resulting code fails at the caller rather than here. Nothing else in the
+project looks at these files: they are not imported, not built by Sphinx,
+and not touched by mypy or ruff. Between them and the library there is no
+gate at all.
+
+That gap has already cost us. The move to a generated sync tree left
+``skills/README.md`` instructing readers to write ``async with
+GlpiClient(...)`` -- but ``GlpiClient`` is the *synchronous* client, so
+the snippet raises ``AttributeError``. Six ``SKILL.md`` files described
+"the asynchronous ``glpi_python_client.GlpiClient``" in their frontmatter
+``description``, which is the one field an agent reads before deciding to
+open the file at all. And ``glpi-client-setup`` credited the async client
+with a ``threading.Lock`` around OAuth -- the exact primitive
+``_concurrency.py`` documents as a deadlock on that surface, because the
+lock is held across an ``await``.
+
+None of that broke a test, a build, or a type check.
+"""
+
+from __future__ import annotations
+
+import pathlib
+import re
+
+import pytest
+
+from glpi_python_client import AsyncGlpiClient, GlpiClient
+
+_REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
+_SKILLS_DIR = _REPO_ROOT / "skills"
+
+# ``skills/`` ships in the sdist but not in the wheel, so an installed
+# package legitimately has no copy to check.
+pytestmark = pytest.mark.skipif(
+ not _SKILLS_DIR.is_dir(), reason="skills/ is source-tree material, not in the wheel"
+)
+
+#: A method call on a client in a snippet. Both spellings appear: the
+#: skills bind the client to ``client`` or, via ``from_env``, to ``glpi``.
+_CALL = re.compile(r"\b(?:client|glpi)\.(\w+)\s*\(")
+
+#: Claims that were true of the retired thread-pool bridge and are false
+#: now. The last two are subtler than the rest: naming ``GlpiClient`` as
+#: asynchronous inverts the two classes, and crediting the async client
+#: with a ``threading.Lock`` names the primitive that deadlocks there.
+_STALE = (
+ r"executor=",
+ r"to_thread",
+ r"worker thread",
+ r"async with GlpiClient",
+ r"asynchronous glpi_python_client\.GlpiClient\b",
+ r"threading\.Lock[^\n]*AsyncGlpiClient",
+)
+
+#: The skills *deny* the bridge's machinery on purpose -- "there is no
+#: ``executor=`` argument and no thread pool to size". Denying a thing is
+#: the opposite of claiming it, so a hit preceded by a negation is correct
+#: prose. Without this the check reports its own corrections as failures.
+_NEGATED = re.compile(r"\b(?:no|not|never|retired|removed|deleted)\b[^.]{0,60}$")
+
+
+def _skill_files() -> list[pathlib.Path]:
+ """Return every skill document, including the index README."""
+
+ return [*sorted(_SKILLS_DIR.glob("*/SKILL.md")), _SKILLS_DIR / "README.md"]
+
+
+def _frontmatter_name(text: str) -> str | None:
+ """Return the ``name:`` field of a skill's frontmatter, if it has one.
+
+ Parsed by hand rather than with a YAML library: the frontmatter is a
+ handful of flat scalars, and this keeps the test suite from gaining a
+ dependency for one field.
+ """
+
+ if not text.startswith("---"):
+ return None
+ _, _, rest = text.partition("---\n")
+ front, _, _ = rest.partition("\n---")
+ match = re.search(r"^name:\s*(\S+)\s*$", front, re.MULTILINE)
+ return match.group(1) if match else None
+
+
+def _stale_claims(text: str) -> list[tuple[int, str]]:
+ """Return ``(lineno, matched text)`` for each unnegated stale claim."""
+
+ hits: list[tuple[int, str]] = []
+ for pattern in _STALE:
+ for match in re.finditer(pattern, text, re.IGNORECASE):
+ preceding = text[max(0, match.start() - 70) : match.start()]
+ if _NEGATED.search(preceding.replace("\n", " ")):
+ continue
+ hits.append((text[: match.start()].count("\n") + 1, match.group(0)))
+ return hits
+
+
+def test_the_scan_finds_skills_and_can_tell_claims_apart() -> None:
+ """Positive control: the scans are not vacuous and do discriminate.
+
+ Without this, a regex that stopped matching would leave the checks
+ below passing forever without reading anything.
+ """
+
+ files = _skill_files()
+ assert len(files) > 5, f"only {len(files)} skill files found -- layout changed?"
+
+ calls = sum(len(_CALL.findall(p.read_text(encoding="utf-8-sig"))) for p in files)
+ assert calls > 20, f"only {calls} client calls found -- regex broken?"
+
+ assert _stale_claims("Use `async with GlpiClient(...)` and await everything.")
+ assert _stale_claims("Pass executor= to size the pool.")
+ # The negation carve-out has to hold, or the check fails on the fix.
+ assert not _stale_claims("There is no `executor=` argument and no thread pool.")
+
+
+def test_no_skill_starts_with_a_byte_order_mark() -> None:
+ """A BOM hides the frontmatter from anything that reads the file.
+
+ Frontmatter is recognised by the file *starting* with ``---``. Three
+ bytes of UTF-8 BOM in front of it mean it does not, so a strict reader
+ sees a plain document with no ``name`` and no ``description`` -- and
+ the description is what an agent uses to decide whether the skill is
+ relevant at all. Six of these files carried one, invisibly, which is
+ also why this test reads them as ``utf-8-sig``: tolerant parsing must
+ not be what stops anyone noticing.
+ """
+
+ offenders = [
+ path.relative_to(_REPO_ROOT).as_posix()
+ for path in sorted(_SKILLS_DIR.rglob("*.md"))
+ if path.read_bytes().startswith(b"\xef\xbb\xbf")
+ ]
+ assert offenders == [], (
+ "these skill files start with a UTF-8 BOM, which hides their "
+ "frontmatter:\n" + "\n".join(offenders)
+ )
+
+
+def test_every_skill_name_matches_its_directory() -> None:
+ """A skill is addressed by directory; a mismatched ``name`` breaks it."""
+
+ offenders: list[str] = []
+ for path in sorted(_SKILLS_DIR.glob("*/SKILL.md")):
+ declared = _frontmatter_name(path.read_text(encoding="utf-8-sig"))
+ if declared != path.parent.name:
+ offenders.append(f"{path.parent.name}: frontmatter name is {declared!r}")
+ assert offenders == [], "skill name does not match its directory:\n" + "\n".join(
+ offenders
+ )
+
+
+def test_every_method_named_in_a_skill_exists_on_both_clients() -> None:
+ """No skill teaches a method that was renamed or removed.
+
+ Checked against *both* classes, because every skill tells the reader
+ its snippets work on the other one after dropping ``await``.
+ """
+
+ offenders: list[str] = []
+ for path in _skill_files():
+ rel = path.relative_to(_REPO_ROOT).as_posix()
+ for method in sorted(set(_CALL.findall(path.read_text(encoding="utf-8-sig")))):
+ for client in (GlpiClient, AsyncGlpiClient):
+ if not hasattr(client, method):
+ offenders.append(f"{rel}: {client.__name__} has no {method!r}")
+ assert offenders == [], (
+ "these skills name methods that do not exist:\n" + "\n".join(offenders)
+ )
+
+
+def test_no_skill_describes_the_retired_thread_pool_bridge() -> None:
+ """No skill still explains the async client as a thread-pool wrapper."""
+
+ offenders = [
+ f"{path.relative_to(_REPO_ROOT).as_posix()}:{lineno}: {claim!r}"
+ for path in _skill_files()
+ for lineno, claim in _stale_claims(path.read_text(encoding="utf-8-sig"))
+ ]
+ assert offenders == [], (
+ "these skills describe machinery that no longer exists:\n"
+ + "\n".join(offenders)
+ )
diff --git a/glpi_python_client/tests/test_unasync_codegen.py b/glpi_python_client/tests/test_unasync_codegen.py
new file mode 100644
index 0000000..ae04e05
--- /dev/null
+++ b/glpi_python_client/tests/test_unasync_codegen.py
@@ -0,0 +1,281 @@
+"""Guards for the ``_async`` -> ``_sync`` code generation.
+
+The generated tree is protected in CI by regenerating it and diffing. That
+catches *staleness* -- someone editing ``_async/`` without rerunning the
+build -- and nothing else. In particular it cannot catch the failure mode
+that actually worries us:
+
+**A token collision is deterministic, so the diff stays empty.** If a local
+variable, parameter or attribute happens to be spelled like a substitution
+key, ``unasync`` rewrites it every single time. Regenerating produces
+exactly the same wrong file, ``git diff`` is clean, CI is green, and the
+sync client is silently incorrect. No amount of diffing finds that.
+
+These tests find it, by scanning the source for identifiers that the
+substitution map would rewrite and failing on any that are not an
+intentional rename.
+"""
+
+from __future__ import annotations
+
+import ast
+import pathlib
+import subprocess
+import sys
+
+import pytest
+
+unasync = pytest.importorskip(
+ "unasync",
+ reason="unasync is a dev-only dependency; codegen guards need it installed",
+)
+
+_REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
+_BUILD_SCRIPT = _REPO_ROOT / "unasync_build.py"
+_ASYNC_DIR = _REPO_ROOT / "glpi_python_client" / "_async"
+_SYNC_DIR = _REPO_ROOT / "glpi_python_client" / "_sync"
+
+#: Names the codegen is *supposed* to rewrite wherever they appear.
+#:
+#: Everything else in the substitution map is a language-level or
+#: third-party name; if one of those shows up as an identifier this package
+#: defines, it is a collision and the scan below fails.
+_INTENTIONAL_RENAMES = {
+ # unasync's built-in map turns the async context-manager protocol into
+ # the sync one; defining these is the whole point.
+ "__aenter__",
+ "__aexit__",
+ "AsyncGlpiClient",
+ "AsyncClient",
+ "AsyncBaseTransport",
+ "AsyncHTTPTransport",
+ "aclose",
+ "aread",
+}
+
+
+def _build_module() -> object:
+ """Import ``unasync_build`` from the repository root."""
+
+ sys.path.insert(0, str(_REPO_ROOT))
+ try:
+ import unasync_build
+
+ return unasync_build
+ finally:
+ sys.path.remove(str(_REPO_ROOT))
+
+
+def _substitution_keys() -> set[str]:
+ """Return every NAME token the codegen would rewrite."""
+
+ build = _build_module()
+ rule = unasync.Rule(
+ fromdir=str(_ASYNC_DIR),
+ todir=str(_SYNC_DIR),
+ additional_replacements=build.TOKEN_REPLACEMENTS, # type: ignore[attr-defined]
+ )
+ return set(rule.token_replacements)
+
+
+def _identifier_sites() -> list[tuple[str, int, str]]:
+ """Return ``(module, lineno, identifier)`` for every name the package defines."""
+
+ sites: list[tuple[str, int, str]] = []
+ for path in sorted(_ASYNC_DIR.rglob("*.py")):
+ if "__pycache__" in path.parts:
+ continue
+ rel = path.relative_to(_REPO_ROOT).as_posix()
+ tree = ast.parse(path.read_text(encoding="utf-8"))
+ for node in ast.walk(tree):
+ if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef):
+ sites.append((rel, node.lineno, node.name))
+ elif isinstance(node, ast.arg):
+ sites.append((rel, node.lineno, node.arg))
+ elif isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store):
+ sites.append((rel, node.lineno, node.id))
+ elif isinstance(node, ast.Attribute):
+ sites.append((rel, node.lineno, node.attr))
+ return sites
+
+
+def test_the_substitution_map_is_reachable() -> None:
+ """Positive control: the map is non-empty and contains a known key.
+
+ Without this, a refactor that made ``_substitution_keys`` return an
+ empty set would turn the collision scan into a test that can never
+ fail -- passing vacuously forever.
+ """
+
+ keys = _substitution_keys()
+ assert keys, "the substitution map is empty -- the collision scan is vacuous"
+ assert "AsyncGlpiClient" in keys
+ assert "__aenter__" in keys, "unasync's built-in defaults are missing"
+
+
+def test_no_identifier_collides_with_a_substitution_key() -> None:
+ """No name this package defines is silently rewritten by the codegen.
+
+ This is the check the CI diff gate cannot perform. A collision is
+ deterministic, so regenerating reproduces it byte for byte and the diff
+ stays clean while the generated client misbehaves.
+ """
+
+ keys = _substitution_keys() - _INTENTIONAL_RENAMES
+ offenders = [site for site in _identifier_sites() if site[2] in keys]
+ assert offenders == [], (
+ "these identifiers would be silently rewritten by the codegen; "
+ f"rename them or add them to the intentional list: {offenders}"
+ )
+
+
+def test_the_generated_tree_never_names_the_async_one() -> None:
+ """No module under ``_sync/`` mentions ``_async`` anywhere.
+
+ unasync repoints the imports for free, because there ``_async`` is
+ its own NAME token. Inside a docstring the whole thing is a *single*
+ string token, and substitution only fires when a literal's entire
+ content is a key -- so a cross-reference such as
+ ``:mod:`glpi_python_client._async.clients.api``` passes straight
+ through, and the shipped sync client ends up documenting itself in
+ terms of a tree its users never import.
+
+ The diff gate cannot see this either: the omission is deterministic,
+ so regeneration reproduces it and the diff stays clean.
+ ``unasync_build`` rewrites the qualified prefix; this asserts the
+ result from the other end, and additionally catches a *bare* mention
+ that the prefix rewrite is deliberately too narrow to touch.
+
+ The hand-written twins are exempt. They are not generated, and each
+ one names its counterpart on purpose: pointing at the other file is
+ the only way to explain why the pair exists.
+ """
+
+ build = _build_module()
+ hand_written: set[str] = build.HAND_WRITTEN # type: ignore[attr-defined]
+ offenders = [
+ f"{path.relative_to(_REPO_ROOT).as_posix()}:{lineno}: {line.strip()}"
+ for path in sorted(_SYNC_DIR.rglob("*.py"))
+ if "__pycache__" not in path.parts and path.name not in hand_written
+ for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1)
+ if "_async" in line
+ ]
+ assert offenders == [], (
+ "the generated sync tree still refers to the async one:\n"
+ + "\n".join(offenders)
+ )
+
+
+def _async_generator_names() -> set[str]:
+ """Return the name of every ``async def`` in ``_async/`` that yields."""
+
+ names: set[str] = set()
+ for path in sorted(_ASYNC_DIR.rglob("*.py")):
+ if "__pycache__" in path.parts:
+ continue
+ tree = ast.parse(path.read_text(encoding="utf-8"))
+ for node in ast.walk(tree):
+ if not isinstance(node, ast.AsyncFunctionDef):
+ continue
+ if any(
+ isinstance(inner, ast.Yield | ast.YieldFrom) for inner in ast.walk(node)
+ ):
+ names.add(node.name)
+ return names
+
+
+def test_no_async_generator_is_awaited() -> None:
+ """An async generator is driven with ``async for``, never awaited.
+
+ This is a mistake the sync tree cannot reveal. ``await gen()`` and
+ ``async for x in gen()`` both generate the *same* correct synchronous
+ loop once ``async``/``await`` are stripped, so the generated client
+ works, the diff gate is clean, mypy is satisfied on both trees, and
+ every test that exercises the sync surface passes -- while the async
+ surface raises ``TypeError: object async_generator can't be used in
+ 'await' expression`` the moment that code path is reached.
+
+ It was found by running the async client against a live server; this
+ check makes that failure a static one.
+ """
+
+ generators = _async_generator_names()
+ assert generators, "no async generators found -- this check is vacuous"
+
+ offenders: list[tuple[str, int, str]] = []
+ for path in sorted(_ASYNC_DIR.rglob("*.py")):
+ if "__pycache__" in path.parts:
+ continue
+ rel = path.relative_to(_REPO_ROOT).as_posix()
+ tree = ast.parse(path.read_text(encoding="utf-8"))
+ for node in ast.walk(tree):
+ if not isinstance(node, ast.Await):
+ continue
+ call = node.value
+ if not isinstance(call, ast.Call):
+ continue
+ func = call.func
+ name = (
+ func.attr
+ if isinstance(func, ast.Attribute)
+ else func.id
+ if isinstance(func, ast.Name)
+ else None
+ )
+ if name in generators:
+ offenders.append((rel, node.lineno, name or "?"))
+
+ assert offenders == [], (
+ "these async generators are awaited instead of being driven with "
+ f"`async for`: {offenders}"
+ )
+
+
+def test_the_concurrency_twins_expose_the_same_surface() -> None:
+ """Both hand-written ``_concurrency`` twins export the same names.
+
+ These two files are the only ones maintained by hand on both sides, so
+ they are the only place the trees can diverge without the diff gate
+ noticing. If one grows a helper the other lacks, the generated tree
+ stops importing cleanly -- but only for the code paths that use it,
+ which may not be covered.
+ """
+
+ from glpi_python_client._async import _concurrency as async_twin
+ from glpi_python_client._sync import _concurrency as sync_twin
+
+ assert set(async_twin.__all__) == set(sync_twin.__all__)
+ for name in async_twin.__all__:
+ assert hasattr(sync_twin, name), f"_sync/_concurrency.py is missing {name!r}"
+
+
+def test_gather_twins_agree_on_ordering() -> None:
+ """The sync ``gather`` preserves order, as ``asyncio.gather`` does.
+
+ Ordering is the whole contract callers rely on: results are matched to
+ arguments by position, never by completion time.
+ """
+
+ from glpi_python_client._sync._concurrency import gather
+
+ assert gather("a", "b", "c") == ["a", "b", "c"]
+ assert gather() == []
+
+
+def test_the_checked_in_sync_tree_is_not_stale() -> None:
+ """``_sync/`` matches what ``_async/`` currently generates.
+
+ Mirrors the CI gate so the failure shows up locally, at the pre-push
+ hook, rather than after a push.
+ """
+
+ result = subprocess.run(
+ [sys.executable, str(_BUILD_SCRIPT), "--check"],
+ capture_output=True,
+ text=True,
+ cwd=_REPO_ROOT,
+ )
+ assert result.returncode == 0, (
+ "the generated sync tree is out of date -- run `python unasync_build.py`\n"
+ f"{result.stdout}\n{result.stderr}"
+ )
diff --git a/integration_tests/test_integration.py b/integration_tests/test_integration.py
index ea0de46..8c599b8 100644
--- a/integration_tests/test_integration.py
+++ b/integration_tests/test_integration.py
@@ -670,13 +670,18 @@ def test_get_user_activity_raises_without_identifier(client: GlpiClient) -> None
# ---------------------------------------------------------------------------
# GLPI Fields plugin (legacy v1 endpoints)
#
-# These tests target the live preprod ticket #62571, which carries an
-# ``aidelarsolution`` custom container set up via the GLPI Fields plugin.
-# When the plugin is not installed the tests skip cleanly so the suite stays
-# portable across instances.
+# These tests target a ticket carrying a custom container set up via the GLPI
+# Fields plugin. Both the ticket id and the container/field names are
+# instance-specific, so they are read from local secrets rather than hardcoded.
+# When the plugin or the configuration is absent the tests skip cleanly so the
+# suite stays portable across instances.
# ---------------------------------------------------------------------------
-_FIELDS_TEST_TICKET_ID = 62571
+_FIELDS_TEST_TICKET_ID = _parse_int(
+ _read_value("glpi_fields_ticket_id", "GLPI_FIELDS_TICKET_ID")
+)
+_FIELDS_CONTAINER_NAME = _read_value("glpi_fields_container", "GLPI_FIELDS_CONTAINER")
+_FIELDS_FIELD_NAME = _read_value("glpi_fields_field", "GLPI_FIELDS_FIELD")
# GLPI answers 400 with this marker when the itemtype in the URL is not a
# known CommonDBTM subclass -- which is what an uninstalled plugin looks
@@ -732,38 +737,45 @@ def test_plugin_fields_containers_discovery(
def test_get_ticket_custom_fields_round_trip_on_known_ticket(
client: GlpiClient, fields_containers: list[GetPluginFieldsContainer]
) -> None:
- """Round-trip the ``aidelarsolution`` custom field on ticket 62571.
+ """Round-trip the configured custom field on the configured ticket.
The original value is captured, replaced by a timestamped probe value,
read back, and finally restored so the test is net-zero.
"""
+ if (
+ _FIELDS_TEST_TICKET_ID is None
+ or _FIELDS_CONTAINER_NAME is None
+ or _FIELDS_FIELD_NAME is None
+ ):
+ pytest.skip("Fields plugin round-trip target not configured in secrets")
+
containers = fields_containers
- container = next((c for c in containers if c.name == "aidelarsolution"), None)
+ container = next((c for c in containers if c.name == _FIELDS_CONTAINER_NAME), None)
if container is None:
- pytest.skip("'aidelarsolution' container missing on this instance")
+ pytest.skip("configured container missing on this instance")
assert container.id is not None
fields = client.list_plugin_fields_fields(container_id=container.id)
- field = next((f for f in fields if f.name == "aidelarsolutionfield"), None)
+ field = next((f for f in fields if f.name == _FIELDS_FIELD_NAME), None)
if field is None:
- pytest.skip("'aidelarsolutionfield' missing in container")
+ pytest.skip("configured field missing in container")
before = client.get_ticket_custom_fields(_FIELDS_TEST_TICKET_ID)
- original = before.get("aidelarsolution", {}).get("aidelarsolutionfield")
+ original = before.get(_FIELDS_CONTAINER_NAME, {}).get(_FIELDS_FIELD_NAME)
probe_value = f"integration probe {_suffix()}
"
try:
client.set_ticket_custom_fields(
_FIELDS_TEST_TICKET_ID,
- {"aidelarsolution": {"aidelarsolutionfield": probe_value}},
+ {_FIELDS_CONTAINER_NAME: {_FIELDS_FIELD_NAME: probe_value}},
)
after = client.get_ticket_custom_fields(_FIELDS_TEST_TICKET_ID)
- assert after["aidelarsolution"]["aidelarsolutionfield"] == probe_value
+ assert after[_FIELDS_CONTAINER_NAME][_FIELDS_FIELD_NAME] == probe_value
finally:
if original is not None:
client.set_ticket_custom_fields(
_FIELDS_TEST_TICKET_ID,
- {"aidelarsolution": {"aidelarsolutionfield": original}},
+ {_FIELDS_CONTAINER_NAME: {_FIELDS_FIELD_NAME: original}},
)
@@ -773,8 +785,9 @@ def test_set_ticket_custom_fields_rejects_unknown_container(
) -> None:
"""Writing to a non-existent container raises before any HTTP call."""
+ # The ticket id is irrelevant here: the guard fires before any HTTP call.
with pytest.raises(ValueError, match="Unknown plugin-fields container"):
client.set_ticket_custom_fields(
- _FIELDS_TEST_TICKET_ID,
+ 1,
{"does-not-exist-xyz": {"any_field": "value"}},
)
diff --git a/integration_tests/test_integration_async.py b/integration_tests/test_integration_async.py
index 39a9d75..d35570d 100644
--- a/integration_tests/test_integration_async.py
+++ b/integration_tests/test_integration_async.py
@@ -8,10 +8,10 @@
* Concurrent fan-out via :func:`asyncio.gather` (read-only).
* OAuth token-acquisition lock contention from many coroutines racing
for the very first authenticated call.
-* Routing every call through a caller-supplied
- :class:`concurrent.futures.ThreadPoolExecutor`.
+* Real non-blocking I/O on the caller's event loop, with no worker
+ thread anywhere on the path.
* Cancellation of an in-flight awaiting coroutine.
-* Exception propagation from a worker thread back to the awaiter.
+* Exception propagation from the transport back to the awaiter.
The shared configuration loader from :mod:`test_integration` is reused
so the same secrets/env layout drives both suites.
@@ -21,7 +21,6 @@
import asyncio
from collections.abc import AsyncIterator
-from concurrent.futures import ThreadPoolExecutor
import pytest
import pytest_asyncio
@@ -47,11 +46,7 @@
pytestmark = pytest.mark.integration
-def _build_async_client(
- config: _LiveGlpiConfig,
- *,
- executor: ThreadPoolExecutor | None = None,
-) -> AsyncGlpiClient:
+def _build_async_client(config: _LiveGlpiConfig) -> AsyncGlpiClient:
"""Return one configured :class:`AsyncGlpiClient` for the live instance.
Parameters
@@ -59,9 +54,6 @@ def _build_async_client(
config : _LiveGlpiConfig
Live GLPI configuration loaded from the secrets directory or
the ``GLPI_*`` environment variables.
- executor : concurrent.futures.ThreadPoolExecutor | None
- Optional pool routed through :class:`AsyncBridge`. When
- ``None`` the bridge falls back to :func:`asyncio.to_thread`.
"""
return AsyncGlpiClient(
@@ -77,7 +69,6 @@ def _build_async_client(
v1_base_url=config.v1_base_url,
v1_user_token=config.v1_user_token,
v1_app_token=config.v1_app_token,
- executor=executor,
)
@@ -172,7 +163,7 @@ async def test_gather_fan_out_read_only(
"""Fan out independent read-only calls and confirm each returns a list.
This stresses the bridge: many coroutines hit
- :func:`asyncio.to_thread` simultaneously, contending for the OAuth
+ the event loop simultaneously, contending for the OAuth
token lock on the first call and then for the requests pool.
"""
@@ -208,63 +199,55 @@ async def test_oauth_lock_contention_on_fresh_client(
assert isinstance(value, list)
-async def test_custom_executor_routing(
- live_config: _LiveGlpiConfig, # noqa: F811
+async def test_calls_run_on_the_event_loop_not_a_worker_thread(
+ async_client: AsyncGlpiClient,
) -> None:
- """A caller-supplied executor handles every wrapped call.
+ """Requests are issued from the calling thread, on the event loop.
- The bridge takes the ``glpi-itest`` named pool when one is passed
- in. We check that thread names observed during fan-out come from
- that pool, proving the bridge respects the override.
- """
+ This replaces an earlier test that asserted a caller-supplied
+ executor received the work. There is no executor and no worker thread
+ any more: the client performs real non-blocking I/O, so the whole
+ call runs on the thread that owns the loop.
- seen_thread_names: set[str] = set()
-
- pool = ThreadPoolExecutor(max_workers=4, thread_name_prefix="glpi-itest")
- try:
- async with _build_async_client(live_config, executor=pool) as client:
- results = await asyncio.gather(
- *(client.search_users(limit=1) for _ in range(6))
- )
- assert len(results) == 6
+ A live fan-out is the honest way to check it -- a stubbed transport
+ would prove nothing about how real sockets are driven.
+ """
- # Quick worker-name probe: schedule a no-op through the same
- # pool and capture the worker thread name.
- loop = asyncio.get_running_loop()
+ import threading
- def _name() -> str:
- import threading
+ main_thread = threading.current_thread().name
+ observed: list[str] = []
- return threading.current_thread().name
+ async def _probe() -> None:
+ await async_client.search_users(limit=1)
+ observed.append(threading.current_thread().name)
- for _ in range(4):
- seen_thread_names.add(await loop.run_in_executor(pool, _name))
- finally:
- pool.shutdown(wait=True)
+ await asyncio.gather(*(_probe() for _ in range(6)))
- assert seen_thread_names, "expected at least one worker name to be captured"
- assert all(name.startswith("glpi-itest") for name in seen_thread_names)
+ assert len(observed) == 6
+ assert all(name == main_thread for name in observed), (
+ f"calls ran off the loop thread: {set(observed)}"
+ )
async def test_cancellation_releases_awaiter(
async_client: AsyncGlpiClient,
) -> None:
- """Cancelling the awaiter releases the coroutine even if HTTP keeps running.
+ """Cancelling the task raises ``CancelledError`` promptly.
- The bridge documents cancellation as best-effort: the in-flight
- ``requests`` call still completes on its worker thread, but the
- awaiting coroutine must raise :class:`asyncio.CancelledError`
- promptly.
+ With real async I/O the cancellation reaches the in-flight request
+ itself rather than being best-effort against a detached worker
+ thread, so the awaiting coroutine must unwind immediately.
"""
task = asyncio.create_task(async_client.search_tickets("status==1", limit=50))
- await asyncio.sleep(0) # let the task enter to_thread
+ await asyncio.sleep(0) # let the task reach its first await
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
-async def test_exception_propagates_from_worker_thread(
+async def test_exception_propagates_from_an_awaited_call(
async_client: AsyncGlpiClient,
) -> None:
"""A missing ticket surfaces as a typed GLPI error.
diff --git a/pyproject.toml b/pyproject.toml
index fb2732a..ee48995 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -15,10 +15,13 @@ exclude = [
"dist/",
"*.egg-info/",
"docs/_build/",
+ "docs/superpowers/",
+ "docs/glpi_api_contract.json",
".venv/",
"venv/",
".env",
".env.*",
+ ".coverage",
"secrets/",
"integration_tests/",
]
@@ -48,14 +51,23 @@ classifiers = [
]
dependencies = [
"beautifulsoup4>=4.12",
+ "httpx>=0.28",
"lxml>=4.9",
"markdown>=3.6",
"markdownify>=0.13",
"pydantic>=2.8",
- "requests>=2.31",
+ # Never imported by this package, and deliberately so. httpcore decides
+ # whether it is running under asyncio or trio by probing for sniffio on
+ # every async request (`try: import sniffio / except ImportError`).
+ # Nothing else in the chain requires it -- anyio 4.14 dropped the
+ # dependency and httpx does not replace it -- and Python never caches a
+ # failed import, so when sniffio is absent each request re-walks
+ # sys.path instead. Measured at a fan-out of 128 against a 50ms server:
+ # 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",
"tenacity>=8.2",
"typing-extensions>=4.7; python_version < '3.11'",
- "urllib3>=2.0",
]
[project.optional-dependencies]
@@ -78,7 +90,7 @@ dev = [
"sphinx-rtd-theme>=2.0",
"tomli>=2.0; python_version < '3.11'",
"twine>=5.1",
- "types-requests>=2.32",
+ "unasync>=0.6",
"vulture>=2.11",
]
@@ -119,6 +131,16 @@ source = ["glpi_python_client"]
omit = [
"*/tests/*",
"glpi_python_client/testing/*",
+ # The async tree is the hand-written source; the sync tree is generated
+ # from it by a 1:1 token transform that only strips async/await. Every
+ # statement therefore exists in both, so measuring both would count the
+ # same logic twice and let real gaps hide behind a doubled denominator.
+ # Coverage is measured on the generated tree, which the suite exercises
+ # directly, and the correspondence between the two is enforced
+ # separately: unasync_build.py --check proves the trees match, mypy
+ # strict checks both, and tests/test_async_surface.py exercises the
+ # async surface at runtime -- including the paths that exist only there.
+ "glpi_python_client/_async/*",
]
[tool.coverage.report]
@@ -134,6 +156,12 @@ exclude_lines = [
[tool.ruff]
line-length = 88
target-version = "py310"
+# The sync client tree is generated by unasync_build.py from _async/ and
+# must stay byte-identical to what regeneration produces -- that identity
+# is what the CI gate checks. Formatting it would fight the generator, and
+# reformatting the generated output is meaningless anyway: style is
+# enforced on the hand-written source it comes from. mypy still checks it.
+extend-exclude = ["glpi_python_client/_sync"]
[tool.ruff.lint]
select = ["B", "E", "F", "I", "RUF", "UP"]
diff --git a/skills/README.md b/skills/README.md
index 36f150e..83046c7 100644
--- a/skills/README.md
+++ b/skills/README.md
@@ -8,7 +8,7 @@ These skills are source-tree project material. They are included in source distr
| Skill | Use when the agent needs to | Main public API |
| --- | --- | --- |
-| `glpi-client-setup` | Build and configure an authenticated client | `GlpiClient`, `GlpiClient.from_env()` |
+| `glpi-client-setup` | Build and configure an authenticated client | `GlpiClient`, `AsyncGlpiClient`, `.from_env()` |
| `glpi-ticket-workflow` | Search, fetch, create, update, or delete tickets | `GetTicket`, `PostTicket`, `PatchTicket`, `DeleteTicket` |
| `glpi-ticket-timeline` | Read timeline records or write followups, tasks, solutions, and document links | `PostFollowup`, `PostTicketTask`, `PostSolution`, `PostTimelineDocument` (plus matching Get/Patch/Delete) |
| `glpi-document-workflow` | Manage document metadata, upload binary content, download binaries | `GetDocument`, `PostDocument`, `PatchDocument`, `DeleteDocument` |
@@ -16,4 +16,13 @@ These skills are source-tree project material. They are included in source distr
| `glpi-reporting-and-context` | Aggregate ticket statistics, aggregate task durations, or load one ticket context bundle | `GlpiClient`, `GlpiTicketContext`, public enums |
| `glpi-team-members` | List, add, or remove ticket team members | `GetTeamMember`, `PostTeamMember` |
-The whole client is asynchronous: use `async with GlpiClient(...)` and `await` every method.
+## Sync and async
+
+The package ships two clients with identical endpoint surfaces:
+
+- `GlpiClient` — synchronous. `with GlpiClient(...) as client`, no `await`.
+- `AsyncGlpiClient` — asynchronous, performing real non-blocking I/O. `async with AsyncGlpiClient(...) as client`, `await` every method.
+
+Neither wraps the other: the async tree is hand-written and the synchronous one is generated from it by `unasync_build.py`, so the two cannot drift apart. The snippets in each skill are written against `AsyncGlpiClient`; every skill opens with a note on how to read them for the synchronous client.
+
+When fanning out concurrently on the async client, bound the fan-out with an `asyncio.Semaphore` — see `glpi-client-setup`. An unbounded fan-out is slower, not faster.
diff --git a/skills/glpi-client-setup/SKILL.md b/skills/glpi-client-setup/SKILL.md
index 15aa3cb..59092dd 100644
--- a/skills/glpi-client-setup/SKILL.md
+++ b/skills/glpi-client-setup/SKILL.md
@@ -15,11 +15,12 @@ The package exposes two clients with identical endpoint surfaces:
- `glpi_python_client.GlpiClient` — synchronous, blocking client. Use it from
scripts, CLI tools, or any code that is not already running inside an
event loop.
-- `glpi_python_client.AsyncGlpiClient` — asynchronous facade. Each method
- is a coroutine that dispatches the underlying blocking call on a worker
- thread (`asyncio.to_thread` by default, or a caller-supplied
- `concurrent.futures.Executor`). Use it when an event loop is already
- running or when you want concurrent fan-out via `asyncio.gather`.
+- `glpi_python_client.AsyncGlpiClient` — asynchronous client. Each method is
+ a coroutine performing real non-blocking I/O on the event loop; there is
+ no worker thread and no executor. Use it when an event loop is already
+ running or when you want concurrent fan-out via `asyncio.gather` —
+ bounded, see step 8. Unlike the retired thread-pool bridge, cancelling
+ a call here actually releases its capacity, so timeouts work.
Both clients share the same method names and signatures, including
`from_env`, OAuth handling, retry behaviour, and the optional v1
@@ -47,8 +48,13 @@ call `client.close()` (or `await client.close()`) when finished.
uploads are needed (`upload_document`). `v1_app_token` is optional.
7. Keep `verify_ssl=True` unless the user explicitly confirms a test or
internal endpoint that cannot validate TLS.
-8. For the async client only, optionally pass `executor=` a
- `concurrent.futures.ThreadPoolExecutor` to bound worker threads.
+8. Bound any large async fan-out with an `asyncio.Semaphore` on the
+ caller side. This is not just tidiness: the underlying HTTP pool
+ rescans itself on every request assignment, so an unbounded fan-out
+ saturates the event loop and gets *slower* as it widens — measured
+ against a 50 ms server, 16 concurrent calls took 350 ms unbounded and
+ 108 ms capped at 16. There is no `executor=` argument and no thread
+ pool to size.
## Environment Defaults
@@ -152,6 +158,9 @@ with GlpiClient.from_env(
document-upload fallback.
- Closing the client matters because it owns one or two HTTP sessions
plus an OAuth token manager. Prefer the context-manager form.
-- A shared `threading.Lock` serialises OAuth token acquisition, so it
- is safe to launch concurrent `asyncio.gather` fan-outs on
- `AsyncGlpiClient` even before the token has been fetched once.
+- Concurrent callers cannot stampede the token endpoint: the client
+ holds a lock around OAuth acquisition, so it is safe to launch a
+ fan-out on `AsyncGlpiClient` before the token has ever been fetched.
+ The primitive differs per surface — an `asyncio.Lock` on the async
+ client, a `threading.Lock` on the synchronous one — which is why a
+ synchronous client is the one safe to share across threads.
diff --git a/skills/glpi-document-workflow/SKILL.md b/skills/glpi-document-workflow/SKILL.md
index 3f1823e..d87b5b4 100644
--- a/skills/glpi-document-workflow/SKILL.md
+++ b/skills/glpi-document-workflow/SKILL.md
@@ -1,11 +1,11 @@
----
+---
name: glpi-document-workflow
-description: "Manage GLPI document metadata, upload binary content via the legacy v1 fallback, download document binaries, and link documents to a ticket timeline with the asynchronous glpi_python_client.GlpiClient and the GetDocument/PostDocument/PatchDocument/DeleteDocument models. Use for ticket attachments, document binary content, document metadata, or saving downloaded files."
+description: "Manage GLPI document metadata, upload binary content via the legacy v1 fallback, download document binaries, and link documents to a ticket timeline with the synchronous glpi_python_client.GlpiClient or the asynchronous AsyncGlpiClient, and the GetDocument/PostDocument/PatchDocument/DeleteDocument models. Use for ticket attachments, document binary content, document metadata, or saving downloaded files."
license: MIT
compatibility: "Requires Python 3.10+, glpi-python-client, network access to the GLPI v2 API, and v1 credentials configured on the client for binary uploads."
metadata:
package: glpi-python-client
- version: "0.3.0"
+ version: "0.4.0"
---
# GLPI Document Workflow
@@ -65,7 +65,7 @@ document_id = await client.create_document(PostDocument(name="Diagnostic notes")
## Gotchas
- `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` and dispatches the blocking HTTP call through `asyncio.to_thread`; the running event loop is not blocked.
+- `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.
- `mime_type` defaults to `application/octet-stream` when omitted on `upload_document`.
- All methods are async; always `await` them.
diff --git a/skills/glpi-reporting-and-context/SKILL.md b/skills/glpi-reporting-and-context/SKILL.md
index 2a61408..946a57d 100644
--- a/skills/glpi-reporting-and-context/SKILL.md
+++ b/skills/glpi-reporting-and-context/SKILL.md
@@ -1,11 +1,11 @@
----
+---
name: glpi-reporting-and-context
-description: "Aggregate GLPI ticket and task statistics and load grouped ticket contexts with the asynchronous glpi_python_client.GlpiClient. Use for operational reporting, ticket counts grouped by entity/status/priority/type, task duration totals grouped by user/entity/ticket, per-user activity reports, batch-streamed pagination of search results, or one-call ticket context retrieval bundling tickets with timeline records."
+description: "Aggregate GLPI ticket and task statistics and load grouped ticket contexts with the synchronous glpi_python_client.GlpiClient or the asynchronous AsyncGlpiClient. Use for operational reporting, ticket counts grouped by entity/status/priority/type, task duration totals grouped by user/entity/ticket, per-user activity reports, batch-streamed pagination of search results, or one-call ticket context retrieval bundling tickets with timeline records."
license: MIT
compatibility: "Requires Python 3.10+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to read tickets, tasks, users, entities, and timeline records."
metadata:
package: glpi-python-client
- version: "0.3.0"
+ version: "0.4.0"
---
# GLPI Reporting And Context
@@ -13,7 +13,7 @@ metadata:
Custom helpers on `GlpiClient` build on top of the contract-aligned API mixins:
-- `get_ticket_context(ticket_id)` returns one `GlpiTicketContext` bundling the primary ticket together with its tasks, followups, solutions, and timeline document links. The five underlying calls run concurrently via `asyncio.gather`.
+- `get_ticket_context(ticket_id)` returns one `GlpiTicketContext` bundling the primary ticket together with its tasks, followups, solutions, and timeline document links. The five underlying calls are independent and are issued through the library's internal `gather` helper, so they fan out concurrently on `AsyncGlpiClient` and run one after another on `GlpiClient`. Expect the synchronous call to take roughly five round trips.
- `get_ticket_statistics(...)` returns ticket counts grouped by entity, status, priority, and type over an ISO date window applied to GLPI `date_creation`. Accepts `entity_id`, `entity_name` (substring match resolved via `search_entities`), and `extra_filter` (raw RSQL AND-joined with the window).
- `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/user filter, computes per-user and per-entity duration totals, and optionally returns a flat per-task list when `return_task_details=True`.
@@ -24,7 +24,7 @@ Returned identifiers are raw GLPI numeric values; resolve them with the appropri
## Procedure
-1. Create a `GlpiClient` with the correct entity/profile scope.
+1. Create a client (`GlpiClient` or `AsyncGlpiClient`) with the correct entity/profile scope.
2. For one ticket, call `await client.get_ticket_context(ticket_id)` and read `bundle.ticket`, `bundle.tasks`, `bundle.followups`, `bundle.solutions`, and `bundle.documents`.
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.
@@ -103,6 +103,19 @@ print(f"processed {total} tickets")
## Gotchas
- All helpers shown above are async on `AsyncGlpiClient`; always `await` them. The `iter_search_*` helpers are async **generators** -- use `async for`, not `await`.
+- **Bound any fan-out you build on top of these helpers.** Calling `get_ticket_context` for every ticket in a batch with a bare `asyncio.gather` gets *slower* as the batch grows: the underlying HTTP pool rescans itself on every request assignment, so a wide fan-out saturates the event loop and the observed concurrency falls. Cap it instead:
+
+ ```python
+ gate = asyncio.Semaphore(16)
+
+ async def one(ticket_id):
+ async with gate:
+ return await client.get_ticket_context(ticket_id)
+
+ contexts = await asyncio.gather(*(one(t.id) for t in batch))
+ ```
+
+ Measured against a 50 ms server, a fan-out of 16 took 350 ms unbounded and 108 ms capped at 16. This is a property of the HTTP layer, not of this library, and there is no version to upgrade to.
- `get_ticket_statistics`, `get_task_durations`, and `get_user_activity` validate their date window locally and raise `ValueError` when `default_days < 1` or `start_date > end_date`. The window is applied to `date_creation` server-side.
- `get_task_statistics(ticket_ids=[])` returns zeroed totals without any HTTP call. `get_task_durations` likewise returns zeroed totals when no tickets match the filter, and short-circuits with zeros when `entity_name` resolves to no entities.
- `get_user_activity` raises `ValueError` when no identifier is supplied and when the criteria match no users. Multiple users with the same `f"{firstname} {realname}"` display key are merged into one bucket.
diff --git a/skills/glpi-team-members/SKILL.md b/skills/glpi-team-members/SKILL.md
index 545eebb..871e832 100644
--- a/skills/glpi-team-members/SKILL.md
+++ b/skills/glpi-team-members/SKILL.md
@@ -1,11 +1,11 @@
----
+---
name: glpi-team-members
-description: "List, add, and remove GLPI ticket team members with the asynchronous glpi_python_client.GlpiClient and the GetTeamMember/PostTeamMember models. Use when assigning users or groups to tickets, inspecting ticket teams, or removing GLPI ticket participants."
+description: "List, add, and remove GLPI ticket team members with the synchronous glpi_python_client.GlpiClient or the asynchronous AsyncGlpiClient, and the GetTeamMember/PostTeamMember models. Use when assigning users or groups to tickets, inspecting ticket teams, or removing GLPI ticket participants."
license: MIT
compatibility: "Requires Python 3.10+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to manage ticket teams."
metadata:
package: glpi-python-client
- version: "0.3.0"
+ version: "0.4.0"
---
# GLPI Team Members
diff --git a/skills/glpi-ticket-timeline/SKILL.md b/skills/glpi-ticket-timeline/SKILL.md
index 8bad81d..7d2e806 100644
--- a/skills/glpi-ticket-timeline/SKILL.md
+++ b/skills/glpi-ticket-timeline/SKILL.md
@@ -1,11 +1,11 @@
----
+---
name: glpi-ticket-timeline
-description: "Read GLPI ticket timeline records and create or update followups, tasks, solutions, and timeline document links with the asynchronous glpi_python_client.GlpiClient. Use when handling ticket notes, followups, tasks, solutions, or attached documents on a GLPI ticket timeline."
+description: "Read GLPI ticket timeline records and create or update followups, tasks, solutions, and timeline document links with the synchronous glpi_python_client.GlpiClient or the asynchronous AsyncGlpiClient. Use when handling ticket notes, followups, tasks, solutions, or attached documents on a GLPI ticket timeline."
license: MIT
compatibility: "Requires Python 3.10+, glpi-python-client, and network access to the GLPI v2 API."
metadata:
package: glpi-python-client
- version: "0.3.0"
+ version: "0.4.0"
---
# GLPI Ticket Timeline
diff --git a/skills/glpi-ticket-workflow/SKILL.md b/skills/glpi-ticket-workflow/SKILL.md
index d10b499..5256019 100644
--- a/skills/glpi-ticket-workflow/SKILL.md
+++ b/skills/glpi-ticket-workflow/SKILL.md
@@ -1,17 +1,17 @@
----
+---
name: glpi-ticket-workflow
-description: "Search, fetch, create, update, and delete GLPI tickets with the asynchronous glpi_python_client.GlpiClient and the GetTicket/PostTicket/PatchTicket/DeleteTicket models. Use for GLPI ticket records, ticket filters, fields, pagination, status, priority, category, location, or instance-specific extra_payload values."
+description: "Search, fetch, create, update, and delete GLPI tickets with the synchronous glpi_python_client.GlpiClient or the asynchronous AsyncGlpiClient, and the GetTicket/PostTicket/PatchTicket/DeleteTicket models. Use for GLPI ticket records, ticket filters, fields, pagination, status, priority, category, location, or instance-specific extra_payload values."
license: MIT
compatibility: "Requires Python 3.10+, glpi-python-client, network access to the GLPI v2 API, and credentials accepted by GlpiClient."
metadata:
package: glpi-python-client
- version: "0.3.0"
+ version: "0.4.0"
---
# GLPI Ticket 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.
-Use this skill for ticket reads and writes through the public asynchronous client. Tickets live under `/Assistance/Ticket` on the GLPI v2 API and are exposed by five `GlpiClient` methods: `search_tickets`, `get_ticket`, `create_ticket`, `update_ticket`, and `delete_ticket`.
+Use this skill for ticket reads and writes through the public client. Tickets live under `/Assistance/Ticket` on the GLPI v2 API and are exposed by five methods, present on both `GlpiClient` and `AsyncGlpiClient` with identical signatures: `search_tickets`, `get_ticket`, `create_ticket`, `update_ticket`, and `delete_ticket`.
## Procedure
diff --git a/skills/glpi-user-location-provisioning/SKILL.md b/skills/glpi-user-location-provisioning/SKILL.md
index 5afd2f0..6a3cbb1 100644
--- a/skills/glpi-user-location-provisioning/SKILL.md
+++ b/skills/glpi-user-location-provisioning/SKILL.md
@@ -1,11 +1,11 @@
----
+---
name: glpi-user-location-provisioning
-description: "Search GLPI users, locations, and entities, or create, update, and delete users and locations and entities with the asynchronous glpi_python_client.GlpiClient and the matching Get/Post/Patch/Delete models. Use for user lookup, entity lookup, location lookup, user provisioning, location creation, GLPI entity defaults, or RSQL filters."
+description: "Search GLPI users, locations, and entities, or create, update, and delete users and locations and entities with the synchronous glpi_python_client.GlpiClient or the asynchronous AsyncGlpiClient, and the matching Get/Post/Patch/Delete models. Use for user lookup, entity lookup, location lookup, user provisioning, location creation, GLPI entity defaults, or RSQL filters."
license: MIT
compatibility: "Requires Python 3.10+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to read or write users, locations, and entities."
metadata:
package: glpi-python-client
- version: "0.3.0"
+ version: "0.4.0"
---
# GLPI User, Location, And Entity Provisioning
@@ -62,7 +62,7 @@ location_id = (
Look entities up by name fragment:
```python
-entities = await client.search_entities("name=like=*novahe*", limit=10)
+entities = await client.search_entities("name=like=*acme*", limit=10)
for entity in entities:
print(entity.id, entity.name, entity.completename)
```
diff --git a/unasync_build.py b/unasync_build.py
new file mode 100644
index 0000000..dfc122e
--- /dev/null
+++ b/unasync_build.py
@@ -0,0 +1,238 @@
+"""Generate the synchronous client tree from the asynchronous one.
+
+``glpi_python_client/_async/`` is the only hand-written client source.
+``glpi_python_client/_sync/`` is produced from it by :mod:`unasync`, checked
+into the repository, and verified in CI with ``--check``. Editing anything
+under ``_sync/`` by hand is a mistake the check will catch.
+
+Usage
+-----
+``python unasync_build.py`` regenerate ``_sync/`` in place
+``python unasync_build.py --check`` fail if ``_sync/`` is stale (CI gate)
+
+What the codegen does and does not do
+-------------------------------------
+:mod:`unasync` is a token-level rewriter. It strips ``async``/``await`` and
+substitutes whole NAME tokens listed in the rule. Three consequences shape
+this build:
+
+* It matches **single NAME tokens only**. A dotted key such as
+ ``"asyncio.Lock"`` can never match, and supplying one fails *silently* --
+ the rule is accepted and simply never fires. Anything needing a dotted
+ name is handled by the hand-written ``_concurrency.py`` twins instead.
+* It rewrites a **string literal whose entire content** is a substitution
+ key. That is what makes ``__all__ = ["AsyncGlpiClient"]`` generate
+ correctly. It is narrow: an embedded mention inside a longer string is
+ left alone. That is right for prose but wrong for a *qualified path*
+ written in prose, so this script repoints those itself -- see
+ :data:`PROSE_PACKAGE_PREFIX`.
+* It leaves ``asyncio.gather`` and friends **intact**, which would produce
+ broken sync code. Those live only in ``_concurrency.py``, which is
+ excluded from generation and hand-written on both sides.
+"""
+
+from __future__ import annotations
+
+import argparse
+import difflib
+import pathlib
+import shutil
+import sys
+import tempfile
+
+import unasync
+
+REPO_ROOT = pathlib.Path(__file__).resolve().parent
+PACKAGE = REPO_ROOT / "glpi_python_client"
+ASYNC_DIR = PACKAGE / "_async"
+SYNC_DIR = PACKAGE / "_sync"
+
+#: Files that are hand-written on *both* sides and never generated.
+#:
+#: ``_concurrency.py`` is the only one. It is where the two trees genuinely
+#: differ in kind rather than in syntax: a fan-out is ``asyncio.gather`` on
+#: one side and plain sequential evaluation on the other, and the auth lock
+#: is an ``asyncio.Lock`` on one side and a ``threading.Lock`` on the other.
+#: Token substitution cannot express either, so both files are maintained by
+#: hand and kept deliberately tiny.
+HAND_WRITTEN = {"_concurrency.py"}
+
+#: Token substitutions beyond unasync's defaults.
+#:
+#: The defaults cover the language-level names (``__aenter__``,
+#: ``AsyncIterator``, ``StopAsyncIteration``, ...). These cover the two
+#: things the defaults get wrong or do not know about:
+#:
+#: * **httpx naming.** unasync's built-in ``Async*`` -> ``Sync*`` convention
+#: would produce ``SyncClient``, which does not exist; the real name is
+#: ``Client``. Same for the transport classes.
+#: * **This package's own public class name**, which differs between the two
+#: surfaces by design.
+#:
+#: Everything else -- mixins, helpers, module names -- is spelled
+#: *identically* in both trees. Keeping the rename list this short is
+#: deliberate: every entry is a chance for a silent collision, and the
+#: shorter the list, the smaller that surface.
+TOKEN_REPLACEMENTS = {
+ # Intra-tree imports are absolute, so the package segment itself is a
+ # NAME token and rewriting it repoints every one of them at the
+ # generated tree. This is why no module needs relative imports.
+ "_async": "_sync",
+ "AsyncGlpiClient": "GlpiClient",
+ "AsyncClient": "Client",
+ "AsyncBaseTransport": "BaseTransport",
+ "AsyncHTTPTransport": "HTTPTransport",
+ "aclose": "close",
+ "aread": "read",
+}
+
+
+#: The qualified package prefix, and what it becomes in the generated tree.
+#:
+#: In an ``import`` statement ``_async`` is its own NAME token, so unasync
+#: repoints every intra-tree import for free. Inside a docstring the whole
+#: thing is a *single* string token, and substitution only fires when a
+#: literal's entire content is a key -- so a cross-reference such as
+#: ``:mod:`glpi_python_client._async.clients.api``` sails through untouched
+#: and the generated client documents itself in terms of the other tree.
+#:
+#: Rewriting the qualified prefix afterwards is safe precisely because it is
+#: qualified: it names this package and a tree that the generated code must
+#: never mention. A bare ``_async`` in prose would be ambiguous; this is not.
+#: ``tests/test_unasync_codegen.py`` holds the invariant from the other end,
+#: asserting the generated tree contains no ``_async`` at all -- which also
+#: fails if someone writes a bare mention this rewrite cannot see.
+PROSE_PACKAGE_PREFIX = ("glpi_python_client._async", "glpi_python_client._sync")
+
+
+def _source_files() -> list[pathlib.Path]:
+ """Return every ``_async/`` module that should be generated from."""
+
+ return sorted(
+ path
+ for path in ASYNC_DIR.rglob("*.py")
+ if path.name not in HAND_WRITTEN and "__pycache__" not in path.parts
+ )
+
+
+def _repoint_prose(into: pathlib.Path, generated: list[pathlib.Path]) -> None:
+ """Point qualified package paths in the generated files at the sync tree.
+
+ Runs over the freshly written modules only, and is idempotent: unasync
+ has already turned the import statements into ``_sync``, so the stale
+ prefix survives nowhere but inside string literals.
+ """
+
+ stale, fresh = PROSE_PACKAGE_PREFIX
+ for rel in generated:
+ path = into / rel
+ text = path.read_text(encoding="utf-8")
+ if stale in text:
+ path.write_text(text.replace(stale, fresh), encoding="utf-8")
+
+
+def _generate(into: pathlib.Path) -> None:
+ """Run unasync over the async tree, writing the sync tree into ``into``."""
+
+ rule = unasync.Rule(
+ fromdir=str(ASYNC_DIR),
+ todir=str(into),
+ additional_replacements=TOKEN_REPLACEMENTS,
+ )
+ sources = _source_files()
+ unasync.unasync_files([str(p) for p in sources], [rule])
+ _repoint_prose(into, [p.relative_to(ASYNC_DIR) for p in sources])
+
+ # The hand-written twins are never generated. When building into a
+ # scratch directory for --check they must still be carried across, or
+ # the comparison would report them as missing from the generated tree
+ # and demand their deletion. Generating in place leaves them untouched.
+ if into == SYNC_DIR:
+ return
+ for name in sorted(HAND_WRITTEN):
+ for existing in SYNC_DIR.rglob(name):
+ target = into / existing.relative_to(SYNC_DIR)
+ target.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(existing, target)
+
+
+def _relative_sync_files(root: pathlib.Path) -> dict[pathlib.Path, str]:
+ """Return ``{relative path: text}`` for every module under ``root``."""
+
+ return {
+ path.relative_to(root): path.read_text(encoding="utf-8")
+ for path in sorted(root.rglob("*.py"))
+ if "__pycache__" not in path.parts
+ }
+
+
+def _check() -> int:
+ """Regenerate into a temp dir and report any drift from the checked-in tree."""
+
+ with tempfile.TemporaryDirectory() as tmp:
+ scratch = pathlib.Path(tmp) / "_sync"
+ _generate(scratch)
+ expected = _relative_sync_files(scratch)
+ actual = _relative_sync_files(SYNC_DIR)
+
+ problems: list[str] = []
+ for rel in sorted(set(expected) | set(actual)):
+ want = expected.get(rel)
+ have = actual.get(rel)
+ if want == have:
+ continue
+ if want is None:
+ problems.append(f"{rel}: present in _sync/ but not generated by _async/")
+ continue
+ if have is None:
+ problems.append(
+ f"{rel}: missing from _sync/ -- run: python unasync_build.py"
+ )
+ continue
+ diff = "".join(
+ difflib.unified_diff(
+ have.splitlines(keepends=True),
+ want.splitlines(keepends=True),
+ fromfile=f"_sync/{rel} (checked in)",
+ tofile=f"_sync/{rel} (regenerated)",
+ )
+ )
+ problems.append(diff)
+
+ if problems:
+ print("_sync/ is out of date with respect to _async/.\n")
+ print("\n".join(problems))
+ print(
+ "\nThe sync tree is generated. Edit glpi_python_client/_async/ and "
+ "run `python unasync_build.py`."
+ )
+ return 1
+
+ print(f"_sync/ is up to date ({len(actual)} modules).")
+ return 0
+
+
+def main() -> int:
+ """Entry point for both regeneration and the CI staleness check."""
+
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--check",
+ action="store_true",
+ help="verify _sync/ matches what _async/ generates instead of writing it",
+ )
+ args = parser.parse_args()
+
+ if not ASYNC_DIR.is_dir():
+ print(f"no async source tree at {ASYNC_DIR}", file=sys.stderr)
+ return 1
+ if args.check:
+ return _check()
+
+ _generate(SYNC_DIR)
+ print(f"regenerated {SYNC_DIR} from {len(_source_files())} async modules.")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())