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())