diff --git a/.coverage b/.coverage
index 8e2f02b..ef820ab 100644
Binary files a/.coverage and b/.coverage differ
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 91abb0a..8f13399 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -50,7 +50,11 @@ python -m sphinx -W --keep-going -b html docs docs/_build/html
## Design Guidelines
-- Keep API calls behind `GlpiClient` methods.
+- 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`.
- 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 ebdafb9..a3cb685 100644
--- a/README.md
+++ b/README.md
@@ -14,8 +14,14 @@ followups, documents, locations, and related records, while converting GLPI
HTML content into Markdown for Python-side workflows and rendering Markdown
back to HTML for outgoing payloads.
-It currently focuses on ticket-centric workflows and exposes a single
-asynchronous high-level client built on top of the GLPI v2 REST API.
+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.
+
Note that all integration tests using this package are made on GLPI 11.
I cannot make any guarantee of the behaviour on previous versions.
@@ -42,14 +48,38 @@ Create a client with your GLPI v2 API URL and at least one complete auth pair:
- `username` and `password`
- both pairs together
+### Synchronous client
+
+```python
+from glpi_python_client import GlpiClient, PostTicket
+
+with GlpiClient(
+ glpi_api_url="https://glpi.example.com/api.php/v2",
+ client_id="oauth-client-id",
+ client_secret="oauth-client-secret",
+ username="api-user",
+ password="api-password",
+) as glpi:
+ ticket_id = glpi.create_ticket(
+ PostTicket(
+ name="Printer issue",
+ content="The printer is not reachable from the office network.",
+ )
+ )
+ ticket = glpi.get_ticket(ticket_id)
+ print(ticket.id, ticket.name)
+```
+
+### Asynchronous client
+
```python
import asyncio
-from glpi_python_client import GlpiClient, PostTicket
+from glpi_python_client import AsyncGlpiClient, PostTicket
async def main() -> None:
- async with GlpiClient(
+ async with AsyncGlpiClient(
glpi_api_url="https://glpi.example.com/api.php/v2",
client_id="oauth-client-id",
client_secret="oauth-client-secret",
@@ -69,39 +99,22 @@ async def main() -> None:
asyncio.run(main())
```
-If your application already provides `GLPI_` environment variables,
-`GlpiClient.from_env()` is also available.
-
-### Calling from synchronous code
-For now, I provide only an async client, but if necessary, could
-duplicate the code to make a sync client. Until then you can make
-it works from sync programs through
-`asyncio.run`. Wrap the calls in a coroutine and execute it once:
+`GlpiClient.from_env()` and `AsyncGlpiClient.from_env()` are also available
+when the credentials are already exposed as `GLPI_`-prefixed environment
+variables.
-```python
-import asyncio
-
-from glpi_python_client import GlpiClient
-
-
-def fetch_open_tickets() -> list[int]:
- async def _run() -> list[int]:
- async with GlpiClient.from_env() as glpi:
- tickets = await glpi.search_tickets("status==1", limit=10)
- return [ticket.id for ticket in tickets]
-
- return asyncio.run(_run())
+### Sync or async?
-
-if __name__ == "__main__":
- print(fetch_open_tickets())
-```
-
-For long-lived sync services that need many calls, run a dedicated
-event loop on a background thread and dispatch with
-`asyncio.run_coroutine_threadsafe`. See the
-[user guide](https://glpi-python-client.readthedocs.io/en/latest/user_guide.html#calling-the-client-from-synchronous-code)
-for the full pattern.
+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).
## Documentation
@@ -116,3 +129,9 @@ To build the Sphinx documentation locally:
python -m pip install -e .[docs]
python -m sphinx -b html docs docs/_build/html
```
+
+## Sponsoring & Professional services
+The development of this package is indirectly supported by [Novahé](https://www.novahe.fr/) & [Constellation](https://www.constellation.fr/).
+
+If you need professional help or services around GLPI, we offer consulting and engineering services to install, maintain or upgarde GLPI instance, as an [official GLPI partner](https://www.glpi-project.org/fr/new-glpi-silver-partner-in-france-novahe/).
+
diff --git a/docs/api_reference.rst b/docs/api_reference.rst
index 3a2503d..0ab6a7f 100644
--- a/docs/api_reference.rst
+++ b/docs/api_reference.rst
@@ -7,14 +7,27 @@ underscore-prefixed helpers are intentionally omitted.
.. currentmodule:: glpi_python_client
-Client
-------
+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.
.. autoclass:: GlpiClient
:members:
:inherited-members:
:show-inheritance:
+.. autoclass:: AsyncGlpiClient
+ :members:
+ :inherited-members:
+ :show-inheritance:
+
+.. autoclass:: glpi_python_client.clients.commons._async_bridge.AsyncBridge
+ :members:
+ :show-inheritance:
+
Aggregated Models
-----------------
diff --git a/docs/development.md b/docs/development.md
index 2d06a21..81b17f6 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -40,57 +40,70 @@ python -m pytest
## Package Layout
-- `glpi_python_client.__init__` exposes the public import surface.
-- `glpi_python_client.clients.api_v2_client.GlpiClient` owns synchronous API
- configuration, authentication, context-manager cleanup, and the small
- user/location/document provisioning surface.
-- `glpi_python_client.clients.async_api_v2_client.AsyncGlpiClient` owns the
- matching awaitable client surface and keeps blocking requests behind
- `asyncio.to_thread()` boundaries.
-- `glpi_python_client.clients.v2` contains the internal v2 implementation
- packages.
-- `glpi_python_client.clients.v2.common` holds reusable setup, endpoint,
- request, pagination, payload, filter, and error helpers shared by both
- execution models.
-- `glpi_python_client.clients.v2.sync` contains the synchronous endpoint mixins:
- `transport`, `tickets`, `timeline`, `documents`, `team`, and `directory`.
- `sync.api` assembles those mixins.
-- `glpi_python_client.clients.v2.async_` contains the matching asynchronous
- endpoint mixins and keeps `asyncio.to_thread()` at the blocking request and
- v1-session boundaries. `async_.api` assembles those mixins.
-- `glpi_python_client.clients._shared` is a compatibility module that re-exports
- the scoped v2 helper modules for older internal imports.
-- `glpi_python_client.clients.api_v1_session` contains the legacy v1 session
- used for document operations.
-- `glpi_python_client.models` contains typed request and response models.
-- `glpi_python_client.content.records` is a compatibility package for raw GLPI
- payload conversion.
-- `glpi_python_client.content.records.core` contains shared normalization,
- scalar coercion, nested-reference parsing, and timeline document-link
- helpers.
-- `glpi_python_client.content.records.parsers` contains model-specific parsers
- for tickets, timeline items, documents, team members, users, and locations.
+- `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
+ 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
+ synchronous endpoint mixins, grouped by GLPI subtree (administration,
+ assistance, assistance/timeline, dropdowns, management).
+- `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 optional async
+ override (`_ticket_context_async.py`, `_statistics_async.py`) that
+ fans the underlying calls out concurrently with `asyncio.gather`.
+- `glpi_python_client.auth._v1_session` contains the legacy v1
+ session used for binary document uploads.
+- `glpi_python_client.models` contains typed request and response
+ models.
+- `glpi_python_client.content` handles HTML/Markdown conversion for
+ ticket descriptions, followups, tasks, and solutions.
+- `glpi_python_client.testing` exposes `make_client` and
+ `make_async_client` factories that produce in-memory clients with no
+ real HTTP plumbing for downstream test suites.
- `docs` contains the Read the Docs/Sphinx documentation source.
-- `skills` contains contributor-facing Agent Skills for repository workflows.
- The source distribution includes them for source consumers and contributors,
- but the wheel still installs only the `glpi_python_client` runtime package.
+- `skills` contains contributor-facing Agent Skills for repository
+ workflows. The source distribution includes them for source consumers
+ and contributors, but the wheel still installs only the
+ `glpi_python_client` runtime package.
## Adding Endpoints
1. Add or extend a model in `glpi_python_client.models`.
-2. Add response parsing in the matching
- `glpi_python_client.content.records.parsers` module when the endpoint returns
- structured data, and put shared parsing helpers in
- `glpi_python_client.content.records.core` only when multiple parsers need
- them.
-3. Add the client method in the matching
- `glpi_python_client.clients.v2.sync` module and the matching
- `glpi_python_client.clients.v2.async_` module when applicable.
-4. Put reusable endpoint names, payload builders, response handling, or
- pagination logic in the focused `glpi_python_client.clients.v2.common`
- helper module named for that responsibility.
-5. Add tests for payload serialization, response parsing, and client behavior.
-6. Document the new workflow in `docs/usage.md` or the README.
+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.
+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
+ 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.
+5. Document the new workflow in `docs/user_guide.rst` or the README.
-Keep organization-specific defaults outside the package core. Applications can
-map their own entities, profiles, and categories before calling the client.
+Keep organization-specific defaults outside the package core.
+Applications can map their own entities, profiles, and categories
+before calling the client.
diff --git a/docs/index.rst b/docs/index.rst
index 6614a2a..7fed9cc 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -24,6 +24,7 @@ handling, and helpers for ticket, user, location, and document workflows.
development_rtd
publishing_rtd
+ sponsoring
Indices and Tables
==================
diff --git a/docs/sponsoring.rst b/docs/sponsoring.rst
new file mode 100644
index 0000000..e748238
--- /dev/null
+++ b/docs/sponsoring.rst
@@ -0,0 +1,9 @@
+Sponsoring & Professional Services
+===================================
+
+The development of this package is indirectly supported by
+`Novahé `_ & `Constellation `_.
+
+If you need professional help or services around GLPI, we offer consulting and
+engineering services to install, maintain, or upgrade GLPI instances, as an
+`official GLPI partner `_.
diff --git a/docs/usage.md b/docs/usage.md
deleted file mode 100644
index 4d010a5..0000000
--- a/docs/usage.md
+++ /dev/null
@@ -1,381 +0,0 @@
-# Usage Guide
-
-`glpi-python-client` exposes synchronous and asynchronous client objects:
-`glpi_python_client.GlpiClient` and `glpi_python_client.AsyncGlpiClient`.
-
-## Create a Client
-
-Create the client with the GLPI API URL and at least one complete authentication
-pair that your application already manages:
-
-- `glpi_api_url` is required.
-- `client_id` and `client_secret` are a complete OAuth client credential pair.
-- `username` and `password` are a complete user credential pair.
-- Provide either credential pair, or both pairs together, depending on your GLPI
- instance configuration.
-- `glpi_entity` is optional and sends the `GLPI-Entity` header when requests
- must stay in one explicit GLPI entity.
-- `glpi_profile` is optional and sends the `GLPI-Profile` header when the API
- user must switch to one explicit profile.
-- `auth_token_refresh` is optional and sets the number of seconds between
- proactive OAuth token refreshes. Use `None` to disable interval-based
- refresh.
-
-```python
-from glpi_python_client import GlpiClient
-
-client = GlpiClient(
- glpi_api_url="https://glpi.example.com/api.php",
- client_id="oauth-client-id",
- client_secret="oauth-client-secret",
- username="api-user",
- password="api-password",
- glpi_entity=1,
- glpi_profile=4,
-)
-```
-
-Use the client as a context manager when possible so cached OAuth tokens are
-cleared and HTTP sessions are closed automatically:
-
-```python
-from glpi_python_client import GlpiClient
-
-with GlpiClient(
- glpi_api_url="https://glpi.example.com/api.php",
- client_id="oauth-client-id",
- client_secret="oauth-client-secret",
- username="api-user",
- password="api-password",
-) as glpi:
- tickets = glpi.search_ticket_records(query='status.id=in=(1,2)')
-```
-
-`search_ticket_records()` returns a full `list[GlpiTicket]` by default. Pass a
-`query` for normal searches. Unfiltered ticket collection is allowed by the GLPI
-contract but disabled by default because some deployments reject it; pass
-`allow_unfiltered=True` only when you intentionally want that behavior. When you
-pass `batch_size`, the method returns a lazy iterator of ticket batches.
-
-Deleted tickets are excluded by default from `search_ticket_records()` and
-`get_ticket_record()`. Pass `include_deleted_ticket=True` when you need GLPI
-tickets marked as deleted.
-
-```python
-for batch in glpi.search_ticket_records(
- query='status.id=in=(1,2)',
- batch_size=200,
-):
- for ticket in batch:
- print(ticket.id)
-```
-
-Async applications can use the matching async client surface. The async client
-currently wraps the shared `requests` transport so applications can await client
-methods without changing the sync transport behavior:
-
-```python
-from glpi_python_client import AsyncGlpiClient
-
-async with AsyncGlpiClient(
- glpi_api_url="https://glpi.example.com/api.php",
- client_id="oauth-client-id",
- client_secret="oauth-client-secret",
- username="api-user",
- password="api-password",
- auth_token_refresh=900,
-) as glpi:
- tickets = await glpi.search_ticket_records(query='status.id=in=(1,2)')
-```
-
-## Utility Constructor
-
-When the same values are already exposed as environment variables,
-`GlpiClient.from_env()` offers a convenience constructor.
-
-`GlpiClient.from_env()` reads the following variables by default.
-
-Required variables:
-
-- `GLPI_API_URL`: GLPI high-level API URL, usually ending
- in `/api.php`.
-- At least one complete auth pair: `GLPI_CLIENT_ID` with
- `GLPI_CLIENT_SECRET`, `GLPI_USERNAME` with `GLPI_PASSWORD`, or both pairs.
-
-Optional variables:
-
-- `GLPI_ENTITY`: entity routing header.
-- `GLPI_PROFILE`: profile routing header.
-- `GLPI_ENTITY_RECURSIVE`: enables recursive entity scope when truthy.
-- `GLPI_LANGUAGE`: Accept-Language header. Defaults to `en_GB`.
-- `GLPI_VERIFY_SSL`: set to `false` only for trusted internal test instances.
-- `GLPI_AUTH_TOKEN_REFRESH`: seconds between proactive OAuth token refreshes.
-- `GLPI_V1_BASE_URL`: explicit v1 document API URL, for example
- `/api.php/v1` or `/apirest.php`.
-- `GLPI_V1_USER_TOKEN`: legacy v1 user token.
-- `GLPI_V1_APP_TOKEN`: legacy v1 app token.
-
-## Tickets
-
-```python
-from glpi_python_client import GlpiTicketCreate
-
-ticket = GlpiTicketCreate(
- name="Printer issue",
- content="The printer is not reachable from the office network.",
- urgency=3,
- impact=3,
-)
-ticket_id = glpi.create_ticket(ticket)
-created = glpi.get_ticket_record(ticket_id)
-glpi.delete_ticket(ticket_id)
-```
-
-Create operations return the created GLPI identifier. Update, add, remove, and
-delete operations return `None` and raise on error.
-
-The client only forwards fields that you set explicitly on the model. It does
-not inject package-owned defaults for ticket status, priority, type, or
-category. If your GLPI workflow requires any of those values, set them on
-`GlpiTicketCreate` before calling `create_ticket()` or `GlpiTicketUpdate`
-before calling `update_ticket()`.
-`create_ticket()` requires a non-empty ticket `name`.
-
-`GlpiTicket` uses the GLPI ticket field names directly, including `id`,
-`status`, `type`, `category`, `location`, `date_creation`, `date_mod`,
-`date_close`, `user_recipient`, `user_editor`, and `team`.
-
-When you request extra ticket fields that do not map to typed `GlpiTicket`
-attributes, the package preserves them in `ticket.extra_payload` instead of
-dropping them. This keeps the modeled fields typed while still exposing the raw
-requested GLPI keys through a public field.
-
-```python
-tickets = glpi.search_ticket_records(
- query='status.id=in=(1,2)',
- fields=("resolution_date", "date_solve"),
-)
-
-first_ticket = tickets[0]
-print(first_ticket.extra_payload["resolution_date"])
-print(first_ticket.extra_payload["date_solve"])
-```
-
-## Entities
-
-Use `search_entities()` when you need typed entity lookup from the public
-package root.
-
-```python
-from glpi_python_client import GlpiEntity
-
-entities = glpi.search_entities(
- rsql_filter='name=like=*novahe*',
- limit=50,
- start=0,
-)
-
-for entity in entities:
- print(entity.entity_id, entity.name, entity.complete_name)
-```
-
-Unmodeled entity payload keys are preserved in `GlpiEntity.extra_payload`.
-
-## Models and Content Formatting
-
-Public GLPI objects are field-validated Pydantic models. Create and update GLPI
-data with dedicated input models such as `GlpiTicketCreate`,
-`GlpiTicketUpdate`, `GlpiFollowupCreate`, `GlpiFollowupUpdate`,
-`GlpiSolutionCreate`, `GlpiDocumentUpload`, `GlpiUserCreate`, and
-`GlpiLocationCreate` instead of passing raw dictionaries through application
-code.
-
-Ticket descriptions, followups, tasks, and solutions use Markdown in Python.
-When data is fetched from GLPI, HTML is converted to Markdown before it is stored
-on the model. When data is sent to GLPI, Markdown is rendered to HTML for the API
-payload:
-
-```python
-ticket = GlpiTicketCreate(
- name="Laptop cannot join corporate Wi-Fi",
- content=(
- "User sees **certificate rejected** during 802.1X authentication.\n"
- "- Device: Latitude 7450\n"
- "- Location: Paris office"
- ),
-)
-ticket_id = glpi.create_ticket(ticket)
-```
-
-Document file content remains `bytes`, because uploads and downloads must
-preserve the original binary content.
-
-## Custom Payload Keys
-
-If your GLPI instance expects plugin fields or instance-specific payload keys,
-use the public `extra_payload` field on the model you send through the client.
-The validated model fields stay typed, and `extra_payload` is merged into the
-outgoing GLPI request body.
-
-```python
-from glpi_python_client import GlpiTicketCreate
-
-ticket = GlpiTicketCreate(
- name="Access badge reader offline",
- content="Reader in **Paris / 3rd floor** is unreachable.",
- extra_payload={
- "_room_code": "PAR-3F-12",
- "_asset_tag": "BADGE-READER-044",
- },
-)
-
-ticket_id = glpi.create_ticket(ticket)
-```
-
-Replace `_room_code` and `_asset_tag` with the raw GLPI field names expected by
-your own instance or plugin. This same `extra_payload` pattern works on the
-other payload-backed public models as well.
-
-Search and fetch operations return typed models:
-
-```python
-tickets = glpi.search_ticket_records(query='status.id=in=(1,2)')
-ticket = glpi.get_ticket_record(123)
-followups = glpi.get_followup_records(123)
-tasks = glpi.get_task_records(123)
-solutions = glpi.get_solution_records(123)
-```
-
-Public v2 client methods accept GLPI identifiers as integers, matching the
-published GLPI API contract.
-
-## Tasks And Duration Statistics
-
-Use `get_task_records(ticket_id)` for task records from one ticket timeline.
-`search_task_records()` keeps the higher-level search workflow by first finding
-candidate tickets with the published ticket endpoint, then reading each ticket's
-`Timeline/Task` records.
-
-```python
-tasks = glpi.search_task_records(
- query='date=ge=2026-01-01;date=le=2026-01-31',
- sort="date:desc",
-)
-
-scoped_tasks = glpi.search_task_records(
- query="users_id==7",
- ticket_query='status.id=in=(1,2)',
-)
-
-ticket_tasks = glpi.get_task_records(123)
-
-for task in ticket_tasks:
- print(task.task_id, task.duration)
-```
-
-`GlpiTask` keeps typed fields such as `ticket_id`, `user_id`, `duration`,
-`date`, and `entity`. Additional task payload keys remain available through
-`GlpiTask.extra_payload`.
-
-## Ticket Statistics And User Activity
-
-Public enums keep the GLPI numeric constants at the package root and can be
-used directly in filters.
-
-```python
-from glpi_python_client import GlpiPriority, GlpiTicketStatus, GlpiTicketType
-
-open_ticket_query = GlpiTicketStatus.NEW.rsql_equals("status")
-request_query = GlpiTicketType.REQUEST.rsql_equals("type")
-
-stats = glpi.get_ticket_statistics(
- entity_name="Novahe",
- start_date="2026-01-01",
- end_date="2026-01-31",
- extra_filter=f"{open_ticket_query};{request_query}",
-)
-
-activity = glpi.get_user_activity(
- email="jane.doe@example.com",
- start_date="2026-01-01",
- end_date="2026-01-31",
-)
-
-print(stats["entities"])
-print(activity["users"])
-```
-
-The statistics output groups counts by entity, status, priority, and type. The
-activity output groups requester counts, technician counts, and nested task
-duration summaries by user.
-
-## Ticket Context
-
-Use `get_ticket_context()` when you need the core ticket together with the
-common timeline and document records in one public object.
-
-```python
-bundle = glpi.get_ticket_context(123)
-
-print(bundle.ticket.id)
-print(len(bundle.tasks), len(bundle.followups), len(bundle.solutions))
-print(len(bundle.documents))
-```
-
-## Users and Locations
-
-```python
-from glpi_python_client import GlpiLocationCreate, GlpiUserCreate
-
-user_id = glpi.create_user(
- GlpiUserCreate(
- username="jane.doe",
- email="jane@example.com",
- firstname="Jane",
- realname="Doe",
- )
-)
-location_id = glpi.create_location(GlpiLocationCreate(name="Paris office"))
-glpi.delete_user(user_id)
-glpi.delete_location(location_id)
-```
-
-`create_user()` requires an explicit username on `GlpiUserCreate`.
-`create_location()` requires a non-empty location `name`.
-
-## Documents
-
-Document upload uses the legacy v1 API credentials when your GLPI instance
-requires them:
-
-- `v1_base_url` is optional and only needed when your GLPI instance exposes
- document upload through a separate v1 endpoint such as `/api.php/v1` or
- `/apirest.php`.
-- `v1_user_token` is required when `v1_base_url` is supplied.
-- `v1_app_token` is optional when that v1 endpoint does not require an app
- token.
-
-```python
-from glpi_python_client import GlpiDocumentUpload
-
-uploaded = glpi.upload_document_to_ticket(
- GlpiDocumentUpload(
- ticket_id=123,
- filename="diagnostic.txt",
- content=b"network trace",
- mime_type="text/plain",
- )
-)
-if uploaded.document_id is not None:
- glpi.delete_document(uploaded.document_id)
-```
-
-`GlpiDocumentUpload` is the input model for uploads and requires `ticket_id`,
-`filename`, and `content`. `GlpiDocument` is the read/result model returned by
-document metadata and upload operations.
-
-## Error Handling
-
-Transport helpers raise `ValueError` for non-successful GLPI responses that
-cannot be represented as a normal return value. Retriable server-side errors are
-retried with `tenacity` before surfacing to the caller.
diff --git a/docs/user_guide.rst b/docs/user_guide.rst
index 8252b40..a92e78b 100644
--- a/docs/user_guide.rst
+++ b/docs/user_guide.rst
@@ -1,14 +1,20 @@
User Guide
==========
-The ``glpi_python_client`` package exposes a single asynchronous
-:class:`glpi_python_client.GlpiClient` whose surface is built from
-contract-aligned per-endpoint mixins. The client speaks the GLPI **v2**
-high-level API and falls back to the legacy v1 endpoint only for binary
-document uploads.
+The ``glpi_python_client`` package exposes two high-level clients
+whose surface is built from contract-aligned per-endpoint mixins:
-The whole client is async-only. Public methods always return Pydantic
-models (or simple Python types) and never raw dictionaries.
+* :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`.
+
+Both clients speak the GLPI **v2** high-level API and fall back to the
+legacy v1 endpoint only for binary document uploads. They expose the
+exact same endpoint methods and accept the same constructor arguments.
+Public methods always return Pydantic models (or simple Python types)
+and never raw dictionaries.
.. contents::
:local:
@@ -19,10 +25,10 @@ How this guide is organised
The guide is split into the following sections:
-1. **Creating a client** — how to instantiate :class:`GlpiClient` from
+1. **Creating a client** — how to instantiate either client from
explicit parameters or from environment variables.
-2. **Calling the client from synchronous code** — recommended patterns
- for one-shot scripts, long-lived sync services, and tests.
+2. **Sync vs async surface** — when to pick which client and how the
+ async facade is implemented.
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.
@@ -34,6 +40,12 @@ The guide is split into the following sections:
6. **End-to-end examples** — full workflows that combine the previous
building blocks.
+The sample snippets in sections 3 to 6 use the synchronous
+:class:`GlpiClient`. Every snippet works on the asynchronous client by
+replacing ``with ... as client:`` with ``async with ... as client:`` and
+prefixing every client method call with ``await`` — the public method
+names and signatures are identical.
+
.. _create-a-client:
1. Create a client
@@ -43,40 +55,48 @@ Provide the GLPI v2 API URL and at least one complete authentication
pair. The OAuth password grant accepts either ``client_id`` /
``client_secret``, ``username`` / ``password``, or both pairs at once.
+.. code-block:: python
+
+ from glpi_python_client import GlpiClient
+
+ with GlpiClient(
+ glpi_api_url="https://glpi.example.com/api.php/v2",
+ client_id="oauth-client-id",
+ client_secret="oauth-client-secret",
+ username="api-user",
+ password="api-password",
+ glpi_entity=1,
+ glpi_profile=4,
+ ) as client:
+ tickets = client.search_tickets("status==1", limit=10)
+ for ticket in tickets:
+ print(ticket.id, ticket.name)
+
+The asynchronous client takes the same arguments and is used inside an
+``async with`` block:
+
.. code-block:: python
import asyncio
- from glpi_python_client import GlpiClient
+ from glpi_python_client import AsyncGlpiClient
async def main() -> None:
- client = GlpiClient(
+ async with AsyncGlpiClient(
glpi_api_url="https://glpi.example.com/api.php/v2",
client_id="oauth-client-id",
client_secret="oauth-client-secret",
username="api-user",
password="api-password",
- glpi_entity=1,
- glpi_profile=4,
- )
- try:
+ ) as client:
tickets = await client.search_tickets("status==1", limit=10)
for ticket in tickets:
print(ticket.id, ticket.name)
- finally:
- await client.close()
asyncio.run(main())
-The client is also usable as an async context manager:
-
-.. code-block:: python
-
- async with GlpiClient(glpi_api_url="...", client_id="...", client_secret="...") as client:
- tickets = await client.search_tickets("status==1")
-
Optional constructor arguments
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -92,13 +112,18 @@ Optional constructor arguments
which the auth manager proactively refreshes the OAuth access token.
* ``v1_base_url`` and ``v1_user_token`` — together enable the legacy v1
fallback used by :meth:`GlpiClient.upload_document`.
+* ``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``
~~~~~~~~~~~~
When the same configuration is already exposed through environment
-variables, :meth:`GlpiClient.from_env` reads the ``GLPI_``-prefixed
-keys and builds the client for you:
+variables, :meth:`GlpiClient.from_env` (and
+:meth:`AsyncGlpiClient.from_env`) read the ``GLPI_``-prefixed keys and
+build the client for you:
* ``GLPI_API_URL``
* ``GLPI_CLIENT_ID`` and ``GLPI_CLIENT_SECRET``
@@ -109,166 +134,85 @@ keys and builds the client for you:
.. code-block:: python
- from glpi_python_client import GlpiClient
-
- client = GlpiClient.from_env()
-
-.. _calling-from-sync-code:
-
-2. Calling the client from synchronous code
--------------------------------------------
-
-The client is async-only by design, but every public coroutine can be
-driven from a synchronous program. The recommended patterns are listed
-below in order of preference.
-
-One-shot scripts: ``asyncio.run``
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-When the synchronous caller is a CLI, a cron entry, or any other
-process that performs a single GLPI interaction and exits, wrap the
-call in a coroutine and hand it to :func:`asyncio.run`:
-
-.. code-block:: python
-
- import asyncio
-
- from glpi_python_client import GlpiClient
-
-
- def fetch_open_tickets() -> list[int]:
- """Return the IDs of the first ten open tickets (sync wrapper)."""
-
- async def _run() -> list[int]:
- async with GlpiClient.from_env() as client:
- tickets = await client.search_tickets("status==1", limit=10)
- return [ticket.id for ticket in tickets]
-
- return asyncio.run(_run())
-
-
- if __name__ == "__main__":
- print(fetch_open_tickets())
-
-Example output::
-
- [42, 43, 47, 51, 58, 60, 64, 68, 70, 72]
-
-:func:`asyncio.run` creates a fresh event loop, runs the coroutine to
-completion, and closes the loop. It must **not** be called while another
-event loop is already running in the same thread (for example inside
-Jupyter, FastAPI, or another async framework); use one of the patterns
-below instead.
-
-Long-lived sync applications: a dedicated event loop
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-If a synchronous service needs to issue many GLPI calls during its
-lifetime, building and tearing down a loop on every call is wasteful.
-Open the client once on a dedicated background loop and dispatch
-coroutines to it from any synchronous thread:
-
-.. code-block:: python
-
- import asyncio
- import threading
-
- from glpi_python_client import GlpiClient, PostTicket
-
+ from glpi_python_client import AsyncGlpiClient, GlpiClient
- class SyncGlpi:
- """Run an async ``GlpiClient`` on a background event loop."""
+ sync_client = GlpiClient.from_env()
+ async_client = AsyncGlpiClient.from_env()
- def __init__(self, **client_kwargs: object) -> None:
- self._loop = asyncio.new_event_loop()
- self._thread = threading.Thread(
- target=self._loop.run_forever, name="glpi-loop", daemon=True
- )
- self._thread.start()
- self._client = GlpiClient(**client_kwargs) # type: ignore[arg-type]
+.. _sync-vs-async:
- def _submit(self, coro): # type: ignore[no-untyped-def]
- """Schedule ``coro`` on the background loop and block on the result."""
+2. Sync vs async surface
+------------------------
- future = asyncio.run_coroutine_threadsafe(coro, self._loop)
- return future.result()
+Both :class:`GlpiClient` and :class:`AsyncGlpiClient` expose the same
+public endpoint methods. The parity is enforced by a unit test so any
+new sync endpoint is automatically reflected on the async client.
- def create_ticket(self, name: str, content: str) -> int:
- return self._submit(
- self._client.create_ticket(PostTicket(name=name, content=content))
- )
+When to pick which
+~~~~~~~~~~~~~~~~~~
- def close(self) -> None:
- self._submit(self._client.close())
- self._loop.call_soon_threadsafe(self._loop.stop)
- self._thread.join()
- self._loop.close()
+* Use :class:`GlpiClient` for plain Python scripts, CLI tools, cron
+ entries, and synchronous services. No event loop, no ``await``.
+* Use :class:`AsyncGlpiClient` when your application already runs an
+ 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
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- if __name__ == "__main__":
- glpi = SyncGlpi(
- glpi_api_url="https://glpi.example.com/api.php/v2",
- client_id="oauth-client-id",
- client_secret="oauth-client-secret",
- username="api-user",
- password="api-password",
- )
- try:
- ticket_id = glpi.create_ticket(
- "Printer issue", "The printer is offline."
- )
- print("created ticket", ticket_id)
- finally:
- glpi.close()
+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:
-Example output::
+* 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``.
- created ticket 123
+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`.
-This pattern keeps the OAuth token cache and the underlying HTTP
-connection pool alive across calls while exposing a regular blocking
-API to the rest of the application.
+A small number of helpers exist in async-only variants because they
+need real concurrency:
-Calling from inside a running event loop
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+* :meth:`AsyncGlpiClient.get_ticket_context` fans the five underlying
+ GLPI calls out concurrently with :func:`asyncio.gather`.
+* :meth:`AsyncGlpiClient.get_task_statistics` fans the per-ticket task
+ list calls out concurrently with :func:`asyncio.gather`.
-Synchronous code that runs *inside* an already-running event loop (for
-example a Jupyter notebook cell or a sync route in an async web
-framework) cannot use :func:`asyncio.run`. Use :func:`asyncio.to_thread`
-to off-load the synchronous wrapper to a worker thread, or call the
-client directly with ``await`` if the surrounding code can be made
-async. The :class:`SyncGlpi` helper above also works because it owns
-its own loop on a separate thread.
+The synchronous versions of the same helpers issue the calls
+sequentially.
-Using GLPI helpers in test suites
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+Custom thread pools
+~~~~~~~~~~~~~~~~~~~
-Synchronous test functions can drive the client with ``asyncio.run``
-inside a small helper, which keeps the test signature plain ``def``:
+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:
.. code-block:: python
import asyncio
+ from concurrent.futures import ThreadPoolExecutor
- from glpi_python_client import GlpiClient
-
-
- def _run(coro): # type: ignore[no-untyped-def]
- """Execute ``coro`` to completion on a fresh event loop."""
-
- return asyncio.run(coro)
+ from glpi_python_client import AsyncGlpiClient
- def test_search_tickets_returns_models() -> None:
- async def scenario() -> int:
- async with GlpiClient.from_env() as client:
- return len(await client.search_tickets("status==1", limit=1))
+ 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))
- assert _run(scenario()) >= 0
-For ``pytest``-style async tests, install ``pytest-asyncio`` and mark
-the coroutine directly with ``@pytest.mark.asyncio`` instead of
-wrapping with ``asyncio.run``.
+ asyncio.run(main())
.. _seed-data:
@@ -289,8 +233,6 @@ it prints are available under the variable names ``location_id``,
.. code-block:: python
- import asyncio
-
from glpi_python_client import (
GlpiClient,
PostFollowup,
@@ -301,14 +243,14 @@ it prints are available under the variable names ``location_id``,
)
- async def seed() -> dict[str, int]:
+ def seed() -> dict[str, int]:
"""Create the demo records reused by the rest of the user guide."""
- async with GlpiClient.from_env() as client:
- location_id = await client.create_location(
+ with GlpiClient.from_env() as client:
+ location_id = client.create_location(
PostLocation(name="HQ Paris")
)
- alice_id = await client.create_user(
+ alice_id = client.create_user(
PostUser(
username="alice.dupont",
password="initial-pwd",
@@ -317,7 +259,7 @@ it prints are available under the variable names ``location_id``,
firstname="Alice",
)
)
- bob_id = await client.create_user(
+ bob_id = client.create_user(
PostUser(
username="bob.martin",
password="initial-pwd",
@@ -326,17 +268,17 @@ it prints are available under the variable names ``location_id``,
firstname="Bob",
)
)
- ticket_id = await client.create_ticket(
+ ticket_id = client.create_ticket(
PostTicket(
name="Wi-Fi unreachable",
content="802.1X handshake fails on the 5 GHz radio.",
)
)
- await client.add_ticket_team_member(
+ client.add_ticket_team_member(
ticket_id,
PostTeamMember(type="User", id=bob_id, role="assigned"),
)
- await client.create_ticket_followup(
+ client.create_ticket_followup(
ticket_id,
PostFollowup(content="Reproduced on the lab laptop."),
)
@@ -349,7 +291,7 @@ it prints are available under the variable names ``location_id``,
if __name__ == "__main__":
- print(asyncio.run(seed()))
+ print(seed())
Example output (identifiers vary across instances)::
@@ -360,17 +302,17 @@ complete:
.. code-block:: python
- async def cleanup(ids: dict[str, int]) -> None:
+ def cleanup(ids: dict[str, int]) -> None:
"""Delete the seed records previously created by ``seed``."""
- async with GlpiClient.from_env() as client:
- await client.delete_ticket(ids["ticket_id"], force=True)
- await client.delete_user(ids["alice_id"], force=True)
- await client.delete_user(ids["bob_id"], force=True)
- await client.delete_location(ids["location_id"], force=True)
+ with GlpiClient.from_env() as client:
+ client.delete_ticket(ids["ticket_id"], force=True)
+ client.delete_user(ids["alice_id"], force=True)
+ client.delete_user(ids["bob_id"], force=True)
+ client.delete_location(ids["location_id"], force=True)
In the rest of the guide every snippet is wrapped in an
-``async with GlpiClient.from_env() as client:`` block. The integer
+``with GlpiClient.from_env() as client:`` block. The integer
variables ``ticket_id``, ``alice_id``, ``bob_id``, and ``location_id``
are assumed to come from the seed dictionary above.
@@ -418,9 +360,9 @@ ambient extras when both are present.
content="The third-floor printer cannot be reached.",
extra_payload={"_room_code": "PAR-3F-12"},
)
- new_id = await client.create_ticket(ticket)
+ new_id = client.create_ticket(ticket)
- fetched = await client.get_ticket(new_id)
+ fetched = client.get_ticket(new_id)
print(fetched.id, fetched.name)
print(fetched.extra_payload)
@@ -439,14 +381,14 @@ helpers under ``/Assistance/Ticket``.
from glpi_python_client import PatchTicket
- await client.update_ticket(
+ client.update_ticket(
ticket_id,
PatchTicket(content="Updated diagnosis: radius timeout."),
)
- ticket = await client.get_ticket(ticket_id)
+ ticket = client.get_ticket(ticket_id)
print(ticket.id, ticket.name, ticket.status)
- results = await client.search_tickets("status==1", limit=3)
+ results = client.search_tickets("status==1", limit=3)
for t in results:
print(t.id, t.name)
@@ -478,22 +420,22 @@ delete_`` shape (``link_`` / ``unlink_`` for documents).
PostTicketTask,
)
- followup_id = await client.create_ticket_followup(
+ followup_id = client.create_ticket_followup(
ticket_id,
PostFollowup(content="Triaged: ongoing"),
)
- task_id = await client.create_ticket_task(
+ task_id = client.create_ticket_task(
ticket_id,
PostTicketTask(content="On-site visit", duration=900),
)
- solution_id = await client.create_ticket_solution(
+ solution_id = client.create_ticket_solution(
ticket_id,
PostSolution(content="Replaced the access point"),
)
- followups = await client.list_ticket_followups(ticket_id)
- tasks = await client.list_ticket_tasks(ticket_id)
- solutions = await client.list_ticket_solutions(ticket_id)
+ followups = client.list_ticket_followups(ticket_id)
+ tasks = client.list_ticket_tasks(ticket_id)
+ solutions = client.list_ticket_solutions(ticket_id)
print(len(followups), len(tasks), len(solutions))
print(followups[0].content)
@@ -520,15 +462,15 @@ Team members are managed via ``/Assistance/Ticket/{id}/TeamMember``.
from glpi_python_client import PostTeamMember
- await client.add_ticket_team_member(
+ client.add_ticket_team_member(
ticket_id,
PostTeamMember(type="User", id=alice_id, role="observer"),
)
- members = await client.list_ticket_team_members(ticket_id)
+ members = client.list_ticket_team_members(ticket_id)
for m in members:
print(m.id, m.type, m.name, m.role)
- await client.remove_ticket_team_member(
+ client.remove_ticket_team_member(
ticket_id,
team_member_id=members[0].id,
)
@@ -551,16 +493,16 @@ update_ / delete_`` shape:
.. code-block:: python
- alice = await client.get_user(alice_id)
+ alice = client.get_user(alice_id)
print(alice.id, alice.username, alice.realname, alice.firstname)
- matches = await client.search_users(f"username=={alice.username}")
+ matches = client.search_users(f"username=={alice.username}")
print([(u.id, u.username) for u in matches])
- location = await client.get_location(location_id)
+ location = client.get_location(location_id)
print(location.id, location.name)
- entities = await client.search_entities(limit=2)
+ entities = client.search_entities(limit=2)
for e in entities:
print(e.id, e.name, e.completename)
@@ -581,7 +523,7 @@ dedicated helpers:
.. code-block:: python
- uploaded_id = await client.upload_document(
+ uploaded_id = client.upload_document(
filename="diagnostic.txt",
content=b"link layer ok\nradius timeout 3s\n",
mime_type="text/plain",
@@ -589,7 +531,7 @@ dedicated helpers:
)
print("uploaded document", uploaded_id)
- raw_bytes = await client.download_document_content(uploaded_id)
+ raw_bytes = client.download_document_content(uploaded_id)
print(len(raw_bytes), "bytes downloaded")
Example output::
@@ -619,7 +561,7 @@ the package root for easy use in RSQL filters:
from glpi_python_client import GlpiTicketStatus
- solved = await client.search_tickets(
+ solved = client.search_tickets(
f"status=={int(GlpiTicketStatus.SOLVED)}", limit=2
)
print([(t.id, t.name) for t in solved])
@@ -645,7 +587,7 @@ timeline list calls concurrently and returns a single
.. code-block:: python
- bundle = await client.get_ticket_context(ticket_id)
+ bundle = client.get_ticket_context(ticket_id)
print(bundle.ticket.id, bundle.ticket.name)
print(
len(bundle.followups),
@@ -781,7 +723,7 @@ entity, status, priority, and type.
.. code-block:: python
- stats = await client.get_ticket_statistics(
+ stats = client.get_ticket_statistics(
start_date="2026-01-01",
end_date="2026-01-31",
)
@@ -825,9 +767,9 @@ callers typically collect the relevant ticket IDs through
.. code-block:: python
ticket_ids = [
- t.id for t in await client.search_tickets("status==2", limit=200)
+ t.id for t in client.search_tickets("status==2", limit=200)
]
- tasks = await client.get_task_statistics(ticket_ids)
+ tasks = client.get_task_statistics(ticket_ids)
print(tasks)
Returned shape (durations are integer seconds, matching the GLPI
@@ -845,7 +787,7 @@ string, ``"unknown"`` when missing)::
Returned identifiers are the raw GLPI numeric values; resolve them with
the appropriate ``search_*`` helpers when human-readable labels are
needed (for example
-``await client.get_user(22)`` to turn user key ``"22"`` into a full
+``client.get_user(22)`` to turn user key ``"22"`` into a full
:class:`GetUser` model).
.. _end-to-end-examples:
@@ -867,14 +809,14 @@ Example 1 — Create a ticket and read it back
from glpi_python_client import GlpiClient, PostTicket
- async with GlpiClient.from_env() as client:
- new_id = await client.create_ticket(
+ with GlpiClient.from_env() as client:
+ new_id = client.create_ticket(
PostTicket(
name="Printer offline",
content="The third-floor printer cannot be reached.",
)
)
- context = await client.get_ticket_context(new_id)
+ context = client.get_ticket_context(new_id)
print(context.to_markdown())
Expected Markdown (abridged)::
@@ -893,11 +835,11 @@ Example 2 — Add a followup response
from glpi_python_client import PostFollowup
- await client.create_ticket_followup(
+ client.create_ticket_followup(
ticket_id,
PostFollowup(content="Capturing radius logs."),
)
- context = await client.get_ticket_context(ticket_id)
+ context = client.get_ticket_context(ticket_id)
print(context.to_markdown())
Expected Markdown (abridged)::
@@ -918,14 +860,14 @@ Example 3 — Add a task with a duration
from glpi_python_client import PostTicketTask
- await client.create_ticket_task(
+ client.create_ticket_task(
ticket_id,
PostTicketTask(
content="On-site visit to swap the access point.",
duration=1800,
),
)
- context = await client.get_ticket_context(ticket_id)
+ context = client.get_ticket_context(ticket_id)
print(context.to_markdown())
Expected Markdown (abridged)::
@@ -946,11 +888,11 @@ status from the v2 API.
from glpi_python_client import PostSolution
- await client.create_ticket_solution(
+ client.create_ticket_solution(
ticket_id,
PostSolution(content="Replaced the access point firmware."),
)
- context = await client.get_ticket_context(ticket_id)
+ context = client.get_ticket_context(ticket_id)
print(context.to_markdown())
Expected Markdown (abridged)::
@@ -973,13 +915,13 @@ session (``v1_base_url`` and ``v1_user_token``).
.. code-block:: python
- await client.upload_document(
+ client.upload_document(
filename="diagnostic.txt",
content=b"link layer ok\nradius timeout 3s\n",
mime_type="text/plain",
ticket_id=ticket_id,
)
- context = await client.get_ticket_context(ticket_id)
+ context = client.get_ticket_context(ticket_id)
print(context.to_markdown())
Expected Markdown (abridged)::
@@ -996,8 +938,6 @@ technician, and tears the records down at the end.
.. code-block:: python
- import asyncio
-
from glpi_python_client import (
GlpiClient,
PostFollowup,
@@ -1009,9 +949,9 @@ technician, and tears the records down at the end.
)
- async def workflow() -> None:
- async with GlpiClient.from_env() as client:
- user_id = await client.create_user(
+ def workflow() -> None:
+ with GlpiClient.from_env() as client:
+ user_id = client.create_user(
PostUser(
username="bob.workflow",
password="initial-pwd",
@@ -1020,34 +960,34 @@ technician, and tears the records down at the end.
firstname="Bob",
)
)
- new_ticket_id = await client.create_ticket(
+ new_ticket_id = client.create_ticket(
PostTicket(name="VPN drops", content="Daily VPN drops at 11:00")
)
try:
- await client.create_ticket_followup(
+ client.create_ticket_followup(
new_ticket_id,
PostFollowup(content="Reproduced on lab laptop"),
)
- await client.create_ticket_task(
+ client.create_ticket_task(
new_ticket_id,
PostTicketTask(content="Capture VPN logs", duration=1800),
)
- await client.add_ticket_team_member(
+ client.add_ticket_team_member(
new_ticket_id,
PostTeamMember(type="User", id=user_id, role="assigned"),
)
- await client.create_ticket_solution(
+ client.create_ticket_solution(
new_ticket_id,
PostSolution(content="Upgraded VPN client"),
)
- context = await client.get_ticket_context(new_ticket_id)
+ context = client.get_ticket_context(new_ticket_id)
print(context.ticket.name, len(context.followups))
finally:
- await client.delete_ticket(new_ticket_id, force=True)
- await client.delete_user(user_id, force=True)
+ client.delete_ticket(new_ticket_id, force=True)
+ client.delete_user(user_id, force=True)
- asyncio.run(workflow())
+ workflow()
Example output::
@@ -1061,27 +1001,25 @@ to summarise a calendar month.
.. code-block:: python
- import asyncio
-
from glpi_python_client import GlpiClient
- async def monthly_report(start: str, end: str) -> dict[str, object]:
- async with GlpiClient.from_env() as client:
- ticket_stats = await client.get_ticket_statistics(
+ def monthly_report(start: str, end: str) -> dict[str, object]:
+ with GlpiClient.from_env() as client:
+ ticket_stats = client.get_ticket_statistics(
start_date=start, end_date=end
)
- solved_tickets = await client.search_tickets(
+ solved_tickets = client.search_tickets(
"status==5", limit=200
)
- task_stats = await client.get_task_statistics(
+ task_stats = client.get_task_statistics(
[t.id for t in solved_tickets]
)
return {"tickets": ticket_stats, "tasks": task_stats}
if __name__ == "__main__":
- print(asyncio.run(monthly_report("2026-01-01", "2026-01-31")))
+ print(monthly_report("2026-01-01", "2026-01-31"))
Example output::
@@ -1103,4 +1041,4 @@ Example output::
'duration_by_user': {'22': 4500, '21': 1800},
'duration_by_ticket': {120: 1800, 121: 900, 122: 1800, 123: 1800},
},
- }
+ }
\ No newline at end of file
diff --git a/glpi_python_client/__init__.py b/glpi_python_client/__init__.py
index 2081a34..ce9bf6a 100644
--- a/glpi_python_client/__init__.py
+++ b/glpi_python_client/__init__.py
@@ -1,13 +1,20 @@
"""Public import surface for the GLPI Python client package.
-The package re-exports the asynchronous :class:`GlpiClient` and the
-``api_schema`` and ``custom_schema`` Pydantic models so downstream users
-can import them from a single stable package root.
+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.
+
+The ``api_schema`` and ``custom_schema`` Pydantic models are also
+re-exported so downstream users can import them from a single stable
+package root.
"""
from __future__ import annotations
-from glpi_python_client.clients import GlpiClient
+from glpi_python_client.clients import AsyncGlpiClient, GlpiClient
from glpi_python_client.models import (
DeleteDocument,
DeleteEntity,
@@ -65,9 +72,10 @@
TicketMarkdownOptions,
)
-__version__ = "0.2.1"
+__version__ = "0.3.0"
__all__ = [
+ "AsyncGlpiClient",
"DeleteDocument",
"DeleteEntity",
"DeleteFollowup",
diff --git a/glpi_python_client/clients/__init__.py b/glpi_python_client/clients/__init__.py
index 1290650..b0ddab8 100644
--- a/glpi_python_client/clients/__init__.py
+++ b/glpi_python_client/clients/__init__.py
@@ -1,13 +1,20 @@
"""Public client exports for the GLPI Python package.
-Only one client class is supported: the asynchronous
-:class:`glpi_python_client.clients.glpi_client.GlpiClient`. The legacy
-synchronous and dual-stack clients have been removed in favour of the
-async-only design described in ``update.md``.
+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.glpi_client import GlpiClient
+from glpi_python_client.clients.async_client import AsyncGlpiClient
+from glpi_python_client.clients.sync_client import GlpiClient
-__all__ = ["GlpiClient"]
+__all__ = ["AsyncGlpiClient", "GlpiClient"]
diff --git a/glpi_python_client/clients/api/__init__.py b/glpi_python_client/clients/api/__init__.py
index 70bd18d..166549d 100644
--- a/glpi_python_client/clients/api/__init__.py
+++ b/glpi_python_client/clients/api/__init__.py
@@ -1,7 +1,7 @@
"""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 asynchronous
+``docs/glpi_api_contract.json`` one for one. They wrap the Synchronous
transport helpers from :mod:`glpi_python_client.clients.commons` and exchange
typed ``Get``, ``Post``, ``Patch``, and ``Delete``
models with the GLPI API.
@@ -10,31 +10,31 @@
from __future__ import annotations
from glpi_python_client.clients.api.administration import (
- AsyncEntityMixin,
- AsyncUserMixin,
+ EntityMixin,
+ UserMixin,
)
from glpi_python_client.clients.api.assistance import (
- AsyncTeamMemberMixin,
- AsyncTicketMixin,
+ TeamMemberMixin,
+ TicketMixin,
)
from glpi_python_client.clients.api.assistance.timeline import (
- AsyncFollowupMixin,
- AsyncSolutionMixin,
- AsyncTicketTaskMixin,
- AsyncTimelineDocumentMixin,
+ FollowupMixin,
+ SolutionMixin,
+ TicketTaskMixin,
+ TimelineDocumentMixin,
)
-from glpi_python_client.clients.api.dropdowns import AsyncLocationMixin
-from glpi_python_client.clients.api.management import AsyncDocumentMixin
+from glpi_python_client.clients.api.dropdowns import LocationMixin
+from glpi_python_client.clients.api.management import DocumentMixin
__all__ = [
- "AsyncDocumentMixin",
- "AsyncEntityMixin",
- "AsyncFollowupMixin",
- "AsyncLocationMixin",
- "AsyncSolutionMixin",
- "AsyncTeamMemberMixin",
- "AsyncTicketMixin",
- "AsyncTicketTaskMixin",
- "AsyncTimelineDocumentMixin",
- "AsyncUserMixin",
+ "DocumentMixin",
+ "EntityMixin",
+ "FollowupMixin",
+ "LocationMixin",
+ "SolutionMixin",
+ "TeamMemberMixin",
+ "TicketMixin",
+ "TicketTaskMixin",
+ "TimelineDocumentMixin",
+ "UserMixin",
]
diff --git a/glpi_python_client/clients/api/administration/__init__.py b/glpi_python_client/clients/api/administration/__init__.py
index e43e61e..c7b65dc 100644
--- a/glpi_python_client/clients/api/administration/__init__.py
+++ b/glpi_python_client/clients/api/administration/__init__.py
@@ -1,12 +1,13 @@
-"""GLPI ``/Administration`` mixins for the asynchronous client.
+"""GLPI ``/Administration`` mixins for the Synchronous client.
The submodules expose the user and entity mixins used by
-:class:`glpi_python_client.clients.glpi_client.GlpiClient`.
+: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 AsyncEntityMixin
-from glpi_python_client.clients.api.administration._user import AsyncUserMixin
+from glpi_python_client.clients.api.administration._entity import EntityMixin
+from glpi_python_client.clients.api.administration._user import UserMixin
-__all__ = ["AsyncEntityMixin", "AsyncUserMixin"]
+__all__ = ["EntityMixin", "UserMixin"]
diff --git a/glpi_python_client/clients/api/administration/_entity.py b/glpi_python_client/clients/api/administration/_entity.py
index b354cf1..1dc4ead 100644
--- a/glpi_python_client/clients/api/administration/_entity.py
+++ b/glpi_python_client/clients/api/administration/_entity.py
@@ -1,4 +1,4 @@
-"""Asynchronous GLPI ``/Administration/Entity`` mixin.
+"""Synchronous 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
@@ -8,7 +8,7 @@
from __future__ import annotations
from glpi_python_client.clients.commons._constants import ENTITY_ENDPOINT, GlpiId
-from glpi_python_client.clients.commons._transport import AsyncTransportMixin
+from glpi_python_client.clients.commons._transport import TransportMixin
from glpi_python_client.models.api_schema.administration._entity import (
DeleteEntity,
GetEntity,
@@ -17,10 +17,10 @@
)
-class AsyncEntityMixin(AsyncTransportMixin):
- """Asynchronous CRUD helpers for ``/Administration/Entity``."""
+class EntityMixin(TransportMixin):
+ """Synchronous CRUD helpers for ``/Administration/Entity``."""
- async def search_entities(
+ def search_entities(
self,
rsql_filter: str = "",
*,
@@ -50,11 +50,11 @@ async def search_entities(
params["limit"] = limit
if rsql_filter:
params["filter"] = rsql_filter
- return await self._resource_list(
+ return self._resource_list(
ENTITY_ENDPOINT, GetEntity, params=params, skip_entity=True
)
- async def get_entity(self, entity_id: GlpiId) -> GetEntity:
+ def get_entity(self, entity_id: GlpiId) -> GetEntity:
"""Fetch one GLPI entity by identifier.
Parameters
@@ -73,14 +73,14 @@ async def get_entity(self, entity_id: GlpiId) -> GetEntity:
If the GLPI server returns a non-success HTTP status.
"""
- return await self._resource_get(
+ return 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:
+ def create_entity(self, entity: PostEntity) -> int:
"""Create one GLPI entity.
Parameters
@@ -100,7 +100,7 @@ async def create_entity(self, entity: PostEntity) -> int:
non-success HTTP status.
"""
- return await self._resource_create(
+ return self._resource_create(
ENTITY_ENDPOINT,
entity,
failure_message="Failed to create entity",
@@ -109,7 +109,7 @@ async def create_entity(self, entity: PostEntity) -> int:
skip_entity=True,
)
- async def update_entity(self, entity_id: GlpiId, entity: PatchEntity) -> None:
+ def update_entity(self, entity_id: GlpiId, entity: PatchEntity) -> None:
"""Update one GLPI entity with a partial body.
Parameters
@@ -129,16 +129,14 @@ async def update_entity(self, entity_id: GlpiId, entity: PatchEntity) -> None:
If the GLPI server returns a non-success HTTP status.
"""
- await self._resource_update(
+ 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:
+ def delete_entity(self, entity_id: GlpiId, *, force: bool | None = None) -> None:
"""Delete one GLPI entity by identifier.
Parameters
@@ -159,7 +157,7 @@ async def delete_entity(
If the GLPI server returns a non-success HTTP status.
"""
- await self._resource_delete(
+ 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}",
@@ -169,4 +167,4 @@ async def delete_entity(
)
-__all__ = ["AsyncEntityMixin"]
+__all__ = ["EntityMixin"]
diff --git a/glpi_python_client/clients/api/administration/_user.py b/glpi_python_client/clients/api/administration/_user.py
index 7caeaee..4c73b6b 100644
--- a/glpi_python_client/clients/api/administration/_user.py
+++ b/glpi_python_client/clients/api/administration/_user.py
@@ -1,15 +1,15 @@
-"""Asynchronous GLPI ``/Administration/User`` mixin.
+"""Synchronous 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 asynchronous transport mixin for HTTP dispatch.
+the Synchronous transport mixin for HTTP dispatch.
"""
from __future__ import annotations
from glpi_python_client.clients.commons._constants import USER_ENDPOINT, GlpiId
-from glpi_python_client.clients.commons._transport import AsyncTransportMixin
+from glpi_python_client.clients.commons._transport import TransportMixin
from glpi_python_client.models.api_schema.administration._user import (
DeleteUser,
GetUser,
@@ -18,15 +18,15 @@
)
-class AsyncUserMixin(AsyncTransportMixin):
- """Asynchronous CRUD helpers for ``/Administration/User``.
+class UserMixin(TransportMixin):
+ """Synchronous 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(
+ def search_users(
self,
rsql_filter: str = "",
*,
@@ -59,11 +59,11 @@ async def search_users(
params: dict[str, object] = {"limit": limit, "start": start}
if rsql_filter:
params["filter"] = rsql_filter
- return await self._resource_list(
+ return self._resource_list(
USER_ENDPOINT, GetUser, params=params, skip_entity=skip_entity
)
- async def get_user(self, user_id: GlpiId) -> GetUser:
+ def get_user(self, user_id: GlpiId) -> GetUser:
"""Fetch one GLPI user by identifier.
Parameters
@@ -82,13 +82,13 @@ async def get_user(self, user_id: GlpiId) -> GetUser:
If the GLPI server returns a non-success HTTP status.
"""
- return await self._resource_get(
+ return 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:
+ def create_user(self, user: PostUser) -> int:
"""Create one GLPI user.
Parameters
@@ -108,7 +108,7 @@ async def create_user(self, user: PostUser) -> int:
non-success HTTP status.
"""
- return await self._resource_create(
+ return self._resource_create(
USER_ENDPOINT,
user,
failure_message="Failed to create user",
@@ -116,7 +116,7 @@ async def create_user(self, user: PostUser) -> int:
log_message_factory=lambda new_id: f"GLPI API created user {new_id}",
)
- async def update_user(self, user_id: GlpiId, user: PatchUser) -> None:
+ def update_user(self, user_id: GlpiId, user: PatchUser) -> None:
"""Update one GLPI user with a partial body.
Parameters
@@ -136,14 +136,14 @@ async def update_user(self, user_id: GlpiId, user: PatchUser) -> None:
If the GLPI server returns a non-success HTTP status.
"""
- await self._resource_update(
+ 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:
+ def delete_user(self, user_id: GlpiId, *, force: bool | None = None) -> None:
"""Delete one GLPI user by identifier.
Parameters
@@ -164,7 +164,7 @@ async def delete_user(self, user_id: GlpiId, *, force: bool | None = None) -> No
If the GLPI server returns a non-success HTTP status.
"""
- await self._resource_delete(
+ 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}",
@@ -173,4 +173,4 @@ async def delete_user(self, user_id: GlpiId, *, force: bool | None = None) -> No
)
-__all__ = ["AsyncUserMixin"]
+__all__ = ["UserMixin"]
diff --git a/glpi_python_client/clients/api/assistance/__init__.py b/glpi_python_client/clients/api/assistance/__init__.py
index 1b6d899..3bdcbeb 100644
--- a/glpi_python_client/clients/api/assistance/__init__.py
+++ b/glpi_python_client/clients/api/assistance/__init__.py
@@ -1,8 +1,8 @@
-"""GLPI ``/Assistance`` mixins for the asynchronous client."""
+"""GLPI ``/Assistance`` mixins for the Synchronous client."""
from __future__ import annotations
-from glpi_python_client.clients.api.assistance._team import AsyncTeamMemberMixin
-from glpi_python_client.clients.api.assistance._ticket import AsyncTicketMixin
+from glpi_python_client.clients.api.assistance._team import TeamMemberMixin
+from glpi_python_client.clients.api.assistance._ticket import TicketMixin
-__all__ = ["AsyncTeamMemberMixin", "AsyncTicketMixin"]
+__all__ = ["TeamMemberMixin", "TicketMixin"]
diff --git a/glpi_python_client/clients/api/assistance/_team.py b/glpi_python_client/clients/api/assistance/_team.py
index 008b8be..738c2b6 100644
--- a/glpi_python_client/clients/api/assistance/_team.py
+++ b/glpi_python_client/clients/api/assistance/_team.py
@@ -1,4 +1,4 @@
-"""Asynchronous GLPI ``/Assistance/Ticket/{id}/TeamMember`` mixin.
+"""Synchronous 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
@@ -16,7 +16,7 @@
)
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 AsyncTransportMixin
+from glpi_python_client.clients.commons._transport import TransportMixin
from glpi_python_client.models.api_schema.assistance._team import (
GetTeamMember,
PostTeamMember,
@@ -25,10 +25,10 @@
logger = logging.getLogger(__name__)
-class AsyncTeamMemberMixin(AsyncTransportMixin):
- """Asynchronous helpers for the ticket team-member endpoint."""
+class TeamMemberMixin(TransportMixin):
+ """Synchronous helpers for the ticket team-member endpoint."""
- async def list_ticket_team_members(self, ticket_id: GlpiId) -> list[GetTeamMember]:
+ def list_ticket_team_members(self, ticket_id: GlpiId) -> list[GetTeamMember]:
"""List the team members currently linked to one ticket.
Parameters
@@ -47,15 +47,13 @@ async def list_ticket_team_members(self, ticket_id: GlpiId) -> list[GetTeamMembe
If the GLPI server returns a non-success HTTP status.
"""
- return await self._resource_list(
+ return 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:
+ def add_ticket_team_member(self, ticket_id: GlpiId, member: PostTeamMember) -> None:
"""Add one team member to a ticket.
Parameters
@@ -79,7 +77,7 @@ async def add_ticket_team_member(
"""
endpoint = f"{TICKET_ENDPOINT}/{ticket_id}/{TEAM_MEMBER_SUFFIX}"
- response = await self._post_request(endpoint, model_to_payload(member))
+ response = self._post_request(endpoint, model_to_payload(member))
ensure_response_status(
response,
success_statuses=(200, 201),
@@ -87,7 +85,7 @@ async def add_ticket_team_member(
)
logger.info("GLPI API added team member on ticket %s", ticket_id)
- async def remove_ticket_team_member(
+ def remove_ticket_team_member(
self, ticket_id: GlpiId, member: PostTeamMember
) -> None:
"""Remove one team member from a ticket.
@@ -110,7 +108,7 @@ async def remove_ticket_team_member(
If the GLPI server returns a non-success HTTP status.
"""
- await self._resource_delete(
+ 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}",
@@ -118,4 +116,4 @@ async def remove_ticket_team_member(
)
-__all__ = ["AsyncTeamMemberMixin"]
+__all__ = ["TeamMemberMixin"]
diff --git a/glpi_python_client/clients/api/assistance/_ticket.py b/glpi_python_client/clients/api/assistance/_ticket.py
index 61e1283..01a9e1d 100644
--- a/glpi_python_client/clients/api/assistance/_ticket.py
+++ b/glpi_python_client/clients/api/assistance/_ticket.py
@@ -1,4 +1,4 @@
-"""Asynchronous GLPI ``/Assistance/Ticket`` mixin.
+"""Synchronous 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.
@@ -7,7 +7,7 @@
from __future__ import annotations
from glpi_python_client.clients.commons._constants import TICKET_ENDPOINT, GlpiId
-from glpi_python_client.clients.commons._transport import AsyncTransportMixin
+from glpi_python_client.clients.commons._transport import TransportMixin
from glpi_python_client.models.api_schema.assistance._ticket import (
DeleteTicket,
GetTicket,
@@ -16,15 +16,15 @@
)
-class AsyncTicketMixin(AsyncTransportMixin):
- """Asynchronous CRUD helpers for ``/Assistance/Ticket``.
+class TicketMixin(TransportMixin):
+ """Synchronous 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(
+ def search_tickets(
self,
rsql_filter: str = "",
*,
@@ -66,9 +66,9 @@ async def search_tickets(
params["sort"] = sort
if fields:
params["fields"] = ",".join(fields)
- return await self._resource_list(TICKET_ENDPOINT, GetTicket, params=params)
+ return self._resource_list(TICKET_ENDPOINT, GetTicket, params=params)
- async def get_ticket(self, ticket_id: GlpiId) -> GetTicket:
+ def get_ticket(self, ticket_id: GlpiId) -> GetTicket:
"""Fetch one GLPI ticket by identifier.
Parameters
@@ -87,13 +87,13 @@ async def get_ticket(self, ticket_id: GlpiId) -> GetTicket:
If the GLPI server returns a non-success HTTP status.
"""
- return await self._resource_get(
+ return 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:
+ def create_ticket(self, ticket: PostTicket) -> int:
"""Create one GLPI ticket.
Parameters
@@ -114,7 +114,7 @@ async def create_ticket(self, ticket: PostTicket) -> int:
HTTP status is not success.
"""
- return await self._resource_create(
+ return self._resource_create(
TICKET_ENDPOINT,
ticket,
failure_message="Failed to create ticket",
@@ -122,7 +122,7 @@ async def create_ticket(self, ticket: PostTicket) -> int:
log_message_factory=lambda new_id: f"GLPI API created ticket {new_id}",
)
- async def update_ticket(self, ticket_id: GlpiId, ticket: PatchTicket) -> None:
+ def update_ticket(self, ticket_id: GlpiId, ticket: PatchTicket) -> None:
"""Update one GLPI ticket with a partial body.
Parameters
@@ -142,16 +142,14 @@ async def update_ticket(self, ticket_id: GlpiId, ticket: PatchTicket) -> None:
If the GLPI server returns a non-success HTTP status.
"""
- await self._resource_update(
+ 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:
+ def delete_ticket(self, ticket_id: GlpiId, *, force: bool | None = None) -> None:
"""Delete one GLPI ticket by identifier.
Parameters
@@ -173,7 +171,7 @@ async def delete_ticket(
If the GLPI server returns a non-success HTTP status.
"""
- await self._resource_delete(
+ 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}",
@@ -182,4 +180,4 @@ async def delete_ticket(
)
-__all__ = ["AsyncTicketMixin"]
+__all__ = ["TicketMixin"]
diff --git a/glpi_python_client/clients/api/assistance/timeline/__init__.py b/glpi_python_client/clients/api/assistance/timeline/__init__.py
index 1ed0e2b..1685427 100644
--- a/glpi_python_client/clients/api/assistance/timeline/__init__.py
+++ b/glpi_python_client/clients/api/assistance/timeline/__init__.py
@@ -1,23 +1,23 @@
-"""GLPI ticket-timeline mixins for the asynchronous client."""
+"""GLPI ticket-timeline mixins for the Synchronous client."""
from __future__ import annotations
from glpi_python_client.clients.api.assistance.timeline._document import (
- AsyncTimelineDocumentMixin,
+ TimelineDocumentMixin,
)
from glpi_python_client.clients.api.assistance.timeline._followup import (
- AsyncFollowupMixin,
+ FollowupMixin,
)
from glpi_python_client.clients.api.assistance.timeline._solution import (
- AsyncSolutionMixin,
+ SolutionMixin,
)
from glpi_python_client.clients.api.assistance.timeline._task import (
- AsyncTicketTaskMixin,
+ TicketTaskMixin,
)
__all__ = [
- "AsyncFollowupMixin",
- "AsyncSolutionMixin",
- "AsyncTicketTaskMixin",
- "AsyncTimelineDocumentMixin",
+ "FollowupMixin",
+ "SolutionMixin",
+ "TicketTaskMixin",
+ "TimelineDocumentMixin",
]
diff --git a/glpi_python_client/clients/api/assistance/timeline/_document.py b/glpi_python_client/clients/api/assistance/timeline/_document.py
index c672dcc..779fb38 100644
--- a/glpi_python_client/clients/api/assistance/timeline/_document.py
+++ b/glpi_python_client/clients/api/assistance/timeline/_document.py
@@ -1,4 +1,4 @@
-"""Asynchronous GLPI ``/Assistance/Ticket/{id}/Timeline/Document`` mixin.
+"""Synchronous 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.
@@ -10,7 +10,7 @@
the OpenAPI contract documents a flat array of ``Document_Item``. Real
behaviour wins over the contract, so :func:`list_ticket_timeline_documents`
unwraps the envelope through the shared
-:meth:`~glpi_python_client.clients.commons._transport.AsyncTransportMixin._resource_list`
+:meth:`~glpi_python_client.clients.commons._transport.TransportMixin._resource_list`
helper and tolerates both shapes.
"""
@@ -21,7 +21,7 @@
TIMELINE_DOCUMENT_SUFFIX,
GlpiId,
)
-from glpi_python_client.clients.commons._transport import AsyncTransportMixin
+from glpi_python_client.clients.commons._transport import TransportMixin
from glpi_python_client.models.api_schema.assistance.timeline._document import (
DeleteTimelineDocument,
GetTimelineDocument,
@@ -30,10 +30,10 @@
)
-class AsyncTimelineDocumentMixin(AsyncTransportMixin):
- """Asynchronous CRUD helpers for the ticket document timeline endpoint."""
+class TimelineDocumentMixin(TransportMixin):
+ """Synchronous CRUD helpers for the ticket document timeline endpoint."""
- async def list_ticket_timeline_documents(
+ def list_ticket_timeline_documents(
self, ticket_id: GlpiId
) -> list[GetTimelineDocument]:
"""List all timeline documents linked to one ticket.
@@ -50,7 +50,7 @@ async def list_ticket_timeline_documents(
envelope unwrapped where present.
"""
- return await self._resource_list(
+ return self._resource_list(
f"{TICKET_ENDPOINT}/{ticket_id}/{TIMELINE_DOCUMENT_SUFFIX}",
GetTimelineDocument,
failure_message=(
@@ -59,7 +59,7 @@ async def list_ticket_timeline_documents(
unwrap_envelope=True,
)
- async def get_ticket_timeline_document(
+ def get_ticket_timeline_document(
self, ticket_id: GlpiId, document_link_id: GlpiId
) -> GetTimelineDocument:
"""Fetch one timeline document link by identifier.
@@ -82,7 +82,7 @@ async def get_ticket_timeline_document(
If the GLPI server returns a non-success HTTP status.
"""
- return await self._resource_get(
+ return self._resource_get(
f"{TICKET_ENDPOINT}/{ticket_id}/"
f"{TIMELINE_DOCUMENT_SUFFIX}/{document_link_id}",
GetTimelineDocument,
@@ -92,7 +92,7 @@ async def get_ticket_timeline_document(
),
)
- async def link_ticket_timeline_document(
+ def link_ticket_timeline_document(
self, ticket_id: GlpiId, document_link: PostTimelineDocument
) -> int:
"""Link an existing GLPI document to one ticket timeline.
@@ -118,7 +118,7 @@ async def link_ticket_timeline_document(
non-success HTTP status.
"""
- return await self._resource_create(
+ return 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}"),
@@ -132,7 +132,7 @@ async def link_ticket_timeline_document(
),
)
- async def update_ticket_timeline_document(
+ def update_ticket_timeline_document(
self,
ticket_id: GlpiId,
document_link_id: GlpiId,
@@ -159,7 +159,7 @@ async def update_ticket_timeline_document(
If the GLPI server returns a non-success HTTP status.
"""
- await self._resource_update(
+ self._resource_update(
f"{TICKET_ENDPOINT}/{ticket_id}/"
f"{TIMELINE_DOCUMENT_SUFFIX}/{document_link_id}",
document_link,
@@ -173,7 +173,7 @@ async def update_ticket_timeline_document(
),
)
- async def unlink_ticket_timeline_document(
+ def unlink_ticket_timeline_document(
self,
ticket_id: GlpiId,
document_link_id: GlpiId,
@@ -202,7 +202,7 @@ async def unlink_ticket_timeline_document(
If the GLPI server returns a non-success HTTP status.
"""
- await self._resource_delete(
+ self._resource_delete(
f"{TICKET_ENDPOINT}/{ticket_id}/"
f"{TIMELINE_DOCUMENT_SUFFIX}/{document_link_id}",
failure_message=(
@@ -218,4 +218,4 @@ async def unlink_ticket_timeline_document(
)
-__all__ = ["AsyncTimelineDocumentMixin"]
+__all__ = ["TimelineDocumentMixin"]
diff --git a/glpi_python_client/clients/api/assistance/timeline/_followup.py b/glpi_python_client/clients/api/assistance/timeline/_followup.py
index c1948f4..9e7294d 100644
--- a/glpi_python_client/clients/api/assistance/timeline/_followup.py
+++ b/glpi_python_client/clients/api/assistance/timeline/_followup.py
@@ -1,4 +1,4 @@
-"""Asynchronous GLPI ``/Assistance/Ticket/{id}/Timeline/Followup`` mixin.
+"""Synchronous 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,7 +11,7 @@
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.AsyncTransportMixin._resource_list`
+:meth:`~glpi_python_client.clients.commons._transport.TransportMixin._resource_list`
helper and tolerates both shapes.
"""
@@ -22,7 +22,7 @@
TICKET_ENDPOINT,
GlpiId,
)
-from glpi_python_client.clients.commons._transport import AsyncTransportMixin
+from glpi_python_client.clients.commons._transport import TransportMixin
from glpi_python_client.models.api_schema.assistance.timeline._followup import (
DeleteFollowup,
GetFollowup,
@@ -31,10 +31,10 @@
)
-class AsyncFollowupMixin(AsyncTransportMixin):
- """Asynchronous CRUD helpers for the ticket followup timeline endpoint."""
+class FollowupMixin(TransportMixin):
+ """Synchronous CRUD helpers for the ticket followup timeline endpoint."""
- async def list_ticket_followups(self, ticket_id: GlpiId) -> list[GetFollowup]:
+ def list_ticket_followups(self, ticket_id: GlpiId) -> list[GetFollowup]:
"""List all followups linked to one ticket.
Parameters
@@ -49,14 +49,14 @@ async def list_ticket_followups(self, ticket_id: GlpiId) -> list[GetFollowup]:
envelope unwrapped where present.
"""
- return await self._resource_list(
+ return 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(
+ def get_ticket_followup(
self, ticket_id: GlpiId, followup_id: GlpiId
) -> GetFollowup:
"""Fetch one ticket followup by identifier.
@@ -79,7 +79,7 @@ async def get_ticket_followup(
If the GLPI server returns a non-success HTTP status.
"""
- return await self._resource_get(
+ return self._resource_get(
f"{TICKET_ENDPOINT}/{ticket_id}/{FOLLOWUP_SUFFIX}/{followup_id}",
GetFollowup,
failure_message=(
@@ -87,9 +87,7 @@ async def get_ticket_followup(
),
)
- async 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
@@ -111,7 +109,7 @@ async def create_ticket_followup(
non-success HTTP status.
"""
- return await self._resource_create(
+ return self._resource_create(
f"{TICKET_ENDPOINT}/{ticket_id}/{FOLLOWUP_SUFFIX}",
followup,
failure_message=f"Failed to create followup on ticket {ticket_id}",
@@ -124,7 +122,7 @@ async def create_ticket_followup(
),
)
- async def update_ticket_followup(
+ def update_ticket_followup(
self,
ticket_id: GlpiId,
followup_id: GlpiId,
@@ -151,7 +149,7 @@ async def update_ticket_followup(
If the GLPI server returns a non-success HTTP status.
"""
- await self._resource_update(
+ self._resource_update(
f"{TICKET_ENDPOINT}/{ticket_id}/{FOLLOWUP_SUFFIX}/{followup_id}",
followup,
failure_message=(
@@ -160,7 +158,7 @@ async def update_ticket_followup(
log_message=f"API updated followup {followup_id} on ticket {ticket_id}",
)
- async def delete_ticket_followup(
+ def delete_ticket_followup(
self,
ticket_id: GlpiId,
followup_id: GlpiId,
@@ -189,7 +187,7 @@ async def delete_ticket_followup(
If the GLPI server returns a non-success HTTP status.
"""
- await self._resource_delete(
+ 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}"
@@ -200,4 +198,4 @@ async def delete_ticket_followup(
)
-__all__ = ["AsyncFollowupMixin"]
+__all__ = ["FollowupMixin"]
diff --git a/glpi_python_client/clients/api/assistance/timeline/_solution.py b/glpi_python_client/clients/api/assistance/timeline/_solution.py
index 346c585..d37ca9d 100644
--- a/glpi_python_client/clients/api/assistance/timeline/_solution.py
+++ b/glpi_python_client/clients/api/assistance/timeline/_solution.py
@@ -1,4 +1,4 @@
-"""Asynchronous GLPI ``/Assistance/Ticket/{id}/Timeline/Solution`` mixin.
+"""Synchronous 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,7 +10,7 @@
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.AsyncTransportMixin._resource_list`
+:meth:`~glpi_python_client.clients.commons._transport.TransportMixin._resource_list`
helper and tolerates both shapes.
"""
@@ -21,7 +21,7 @@
TICKET_ENDPOINT,
GlpiId,
)
-from glpi_python_client.clients.commons._transport import AsyncTransportMixin
+from glpi_python_client.clients.commons._transport import TransportMixin
from glpi_python_client.models.api_schema.assistance.timeline._solution import (
DeleteSolution,
GetSolution,
@@ -30,10 +30,10 @@
)
-class AsyncSolutionMixin(AsyncTransportMixin):
- """Asynchronous CRUD helpers for the ticket solution timeline endpoint."""
+class SolutionMixin(TransportMixin):
+ """Synchronous CRUD helpers for the ticket solution timeline endpoint."""
- async def list_ticket_solutions(self, ticket_id: GlpiId) -> list[GetSolution]:
+ def list_ticket_solutions(self, ticket_id: GlpiId) -> list[GetSolution]:
"""List all solutions linked to one ticket.
Parameters
@@ -48,14 +48,14 @@ async def list_ticket_solutions(self, ticket_id: GlpiId) -> list[GetSolution]:
envelope unwrapped where present.
"""
- return await self._resource_list(
+ return 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(
+ def get_ticket_solution(
self, ticket_id: GlpiId, solution_id: GlpiId
) -> GetSolution:
"""Fetch one ticket solution by identifier.
@@ -78,7 +78,7 @@ async def get_ticket_solution(
If the GLPI server returns a non-success HTTP status.
"""
- return await self._resource_get(
+ return self._resource_get(
f"{TICKET_ENDPOINT}/{ticket_id}/{SOLUTION_SUFFIX}/{solution_id}",
GetSolution,
failure_message=(
@@ -86,9 +86,7 @@ async def get_ticket_solution(
),
)
- async 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
@@ -110,7 +108,7 @@ async def create_ticket_solution(
non-success HTTP status.
"""
- return await self._resource_create(
+ return self._resource_create(
f"{TICKET_ENDPOINT}/{ticket_id}/{SOLUTION_SUFFIX}",
solution,
failure_message=f"Failed to create solution on ticket {ticket_id}",
@@ -121,7 +119,7 @@ async def create_ticket_solution(
),
)
- async def update_ticket_solution(
+ def update_ticket_solution(
self,
ticket_id: GlpiId,
solution_id: GlpiId,
@@ -148,7 +146,7 @@ async def update_ticket_solution(
If the GLPI server returns a non-success HTTP status.
"""
- await self._resource_update(
+ self._resource_update(
f"{TICKET_ENDPOINT}/{ticket_id}/{SOLUTION_SUFFIX}/{solution_id}",
solution,
failure_message=(
@@ -157,7 +155,7 @@ async def update_ticket_solution(
log_message=f"API updated solution {solution_id} on ticket {ticket_id}",
)
- async def delete_ticket_solution(
+ def delete_ticket_solution(
self,
ticket_id: GlpiId,
solution_id: GlpiId,
@@ -186,7 +184,7 @@ async def delete_ticket_solution(
If the GLPI server returns a non-success HTTP status.
"""
- await self._resource_delete(
+ 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}"
@@ -197,4 +195,4 @@ async def delete_ticket_solution(
)
-__all__ = ["AsyncSolutionMixin"]
+__all__ = ["SolutionMixin"]
diff --git a/glpi_python_client/clients/api/assistance/timeline/_task.py b/glpi_python_client/clients/api/assistance/timeline/_task.py
index b98783a..5d3f72e 100644
--- a/glpi_python_client/clients/api/assistance/timeline/_task.py
+++ b/glpi_python_client/clients/api/assistance/timeline/_task.py
@@ -1,4 +1,4 @@
-"""Asynchronous GLPI ``/Assistance/Ticket/{id}/Timeline/Task`` mixin.
+"""Synchronous 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,7 +11,7 @@
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.AsyncTransportMixin._resource_list`
+:meth:`~glpi_python_client.clients.commons._transport.TransportMixin._resource_list`
helper and tolerates both shapes.
"""
@@ -22,7 +22,7 @@
TICKET_ENDPOINT,
GlpiId,
)
-from glpi_python_client.clients.commons._transport import AsyncTransportMixin
+from glpi_python_client.clients.commons._transport import TransportMixin
from glpi_python_client.models.api_schema.assistance.timeline._task import (
DeleteTicketTask,
GetTicketTask,
@@ -31,10 +31,10 @@
)
-class AsyncTicketTaskMixin(AsyncTransportMixin):
- """Asynchronous CRUD helpers for the ticket task timeline endpoint."""
+class TicketTaskMixin(TransportMixin):
+ """Synchronous CRUD helpers for the ticket task timeline endpoint."""
- async def list_ticket_tasks(self, ticket_id: GlpiId) -> list[GetTicketTask]:
+ def list_ticket_tasks(self, ticket_id: GlpiId) -> list[GetTicketTask]:
"""List all tasks linked to one ticket.
Parameters
@@ -49,16 +49,14 @@ async def list_ticket_tasks(self, ticket_id: GlpiId) -> list[GetTicketTask]:
unwrapped where present.
"""
- return await self._resource_list(
+ return 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:
+ def get_ticket_task(self, ticket_id: GlpiId, task_id: GlpiId) -> GetTicketTask:
"""Fetch one ticket task by identifier.
Parameters
@@ -79,13 +77,13 @@ async def get_ticket_task(
If the GLPI server returns a non-success HTTP status.
"""
- return await self._resource_get(
+ return 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:
+ def create_ticket_task(self, ticket_id: GlpiId, task: PostTicketTask) -> int:
"""Create one task on a ticket.
Parameters
@@ -107,7 +105,7 @@ async def create_ticket_task(self, ticket_id: GlpiId, task: PostTicketTask) -> i
non-success HTTP status.
"""
- return await self._resource_create(
+ return self._resource_create(
f"{TICKET_ENDPOINT}/{ticket_id}/{TASK_SUFFIX}",
task,
failure_message=f"Failed to create task on ticket {ticket_id}",
@@ -118,7 +116,7 @@ async def create_ticket_task(self, ticket_id: GlpiId, task: PostTicketTask) -> i
),
)
- async def update_ticket_task(
+ def update_ticket_task(
self,
ticket_id: GlpiId,
task_id: GlpiId,
@@ -145,14 +143,14 @@ async def update_ticket_task(
If the GLPI server returns a non-success HTTP status.
"""
- await self._resource_update(
+ 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(
+ def delete_ticket_task(
self,
ticket_id: GlpiId,
task_id: GlpiId,
@@ -181,7 +179,7 @@ async def delete_ticket_task(
If the GLPI server returns a non-success HTTP status.
"""
- await self._resource_delete(
+ 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}",
@@ -190,4 +188,4 @@ async def delete_ticket_task(
)
-__all__ = ["AsyncTicketTaskMixin"]
+__all__ = ["TicketTaskMixin"]
diff --git a/glpi_python_client/clients/api/dropdowns/__init__.py b/glpi_python_client/clients/api/dropdowns/__init__.py
index e4cc45d..6edc745 100644
--- a/glpi_python_client/clients/api/dropdowns/__init__.py
+++ b/glpi_python_client/clients/api/dropdowns/__init__.py
@@ -1,7 +1,7 @@
-"""GLPI ``/Dropdowns`` mixins for the asynchronous client."""
+"""GLPI ``/Dropdowns`` mixins for the Synchronous client."""
from __future__ import annotations
-from glpi_python_client.clients.api.dropdowns._location import AsyncLocationMixin
+from glpi_python_client.clients.api.dropdowns._location import LocationMixin
-__all__ = ["AsyncLocationMixin"]
+__all__ = ["LocationMixin"]
diff --git a/glpi_python_client/clients/api/dropdowns/_location.py b/glpi_python_client/clients/api/dropdowns/_location.py
index edffead..1dac3b9 100644
--- a/glpi_python_client/clients/api/dropdowns/_location.py
+++ b/glpi_python_client/clients/api/dropdowns/_location.py
@@ -1,4 +1,4 @@
-"""Asynchronous GLPI ``/Dropdowns/Location`` mixin.
+"""Synchronous 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``
@@ -8,7 +8,7 @@
from __future__ import annotations
from glpi_python_client.clients.commons._constants import LOCATION_ENDPOINT, GlpiId
-from glpi_python_client.clients.commons._transport import AsyncTransportMixin
+from glpi_python_client.clients.commons._transport import TransportMixin
from glpi_python_client.models.api_schema.dropdowns._location import (
DeleteLocation,
GetLocation,
@@ -17,10 +17,10 @@
)
-class AsyncLocationMixin(AsyncTransportMixin):
- """Asynchronous CRUD helpers for ``/Dropdowns/Location``."""
+class LocationMixin(TransportMixin):
+ """Synchronous CRUD helpers for ``/Dropdowns/Location``."""
- async def search_locations(
+ def search_locations(
self,
rsql_filter: str = "",
*,
@@ -47,9 +47,9 @@ async def search_locations(
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)
+ return self._resource_list(LOCATION_ENDPOINT, GetLocation, params=params)
- async def get_location(self, location_id: GlpiId) -> GetLocation:
+ def get_location(self, location_id: GlpiId) -> GetLocation:
"""Fetch one GLPI location by identifier.
Parameters
@@ -68,13 +68,13 @@ async def get_location(self, location_id: GlpiId) -> GetLocation:
If the GLPI server returns a non-success HTTP status.
"""
- return await self._resource_get(
+ return 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:
+ def create_location(self, location: PostLocation) -> int:
"""Create one GLPI location.
Parameters
@@ -94,7 +94,7 @@ async def create_location(self, location: PostLocation) -> int:
non-success HTTP status.
"""
- return await self._resource_create(
+ return self._resource_create(
LOCATION_ENDPOINT,
location,
failure_message="Failed to create location",
@@ -102,9 +102,7 @@ async def create_location(self, location: PostLocation) -> int:
log_message_factory=lambda new_id: f"GLPI API created location {new_id}",
)
- async 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
@@ -124,14 +122,14 @@ async def update_location(
If the GLPI server returns a non-success HTTP status.
"""
- await self._resource_update(
+ 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(
+ def delete_location(
self, location_id: GlpiId, *, force: bool | None = None
) -> None:
"""Delete one GLPI location by identifier.
@@ -154,7 +152,7 @@ async def delete_location(
If the GLPI server returns a non-success HTTP status.
"""
- await self._resource_delete(
+ 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}",
@@ -163,4 +161,4 @@ async def delete_location(
)
-__all__ = ["AsyncLocationMixin"]
+__all__ = ["LocationMixin"]
diff --git a/glpi_python_client/clients/api/management/__init__.py b/glpi_python_client/clients/api/management/__init__.py
index 265a188..fc085b5 100644
--- a/glpi_python_client/clients/api/management/__init__.py
+++ b/glpi_python_client/clients/api/management/__init__.py
@@ -1,7 +1,7 @@
-"""GLPI ``/Management`` mixins for the asynchronous client."""
+"""GLPI ``/Management`` mixins for the Synchronous client."""
from __future__ import annotations
-from glpi_python_client.clients.api.management._document import AsyncDocumentMixin
+from glpi_python_client.clients.api.management._document import DocumentMixin
-__all__ = ["AsyncDocumentMixin"]
+__all__ = ["DocumentMixin"]
diff --git a/glpi_python_client/clients/api/management/_document.py b/glpi_python_client/clients/api/management/_document.py
index d1a2c92..a12d786 100644
--- a/glpi_python_client/clients/api/management/_document.py
+++ b/glpi_python_client/clients/api/management/_document.py
@@ -1,4 +1,4 @@
-"""Asynchronous GLPI ``/Management/Document`` mixin.
+"""Synchronous 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
@@ -7,7 +7,6 @@
from __future__ import annotations
-import asyncio
import logging
from glpi_python_client.clients.commons._constants import (
@@ -15,7 +14,7 @@
GlpiId,
)
from glpi_python_client.clients.commons._http import ensure_response_status
-from glpi_python_client.clients.commons._transport import AsyncTransportMixin
+from glpi_python_client.clients.commons._transport import TransportMixin
from glpi_python_client.models.api_schema.management._document import (
DeleteDocument,
GetDocument,
@@ -26,10 +25,10 @@
logger = logging.getLogger(__name__)
-class AsyncDocumentMixin(AsyncTransportMixin):
- """Asynchronous CRUD and upload helpers for ``/Management/Document``."""
+class DocumentMixin(TransportMixin):
+ """Synchronous CRUD and upload helpers for ``/Management/Document``."""
- async def search_documents(
+ def search_documents(
self,
rsql_filter: str = "",
*,
@@ -59,11 +58,11 @@ async def search_documents(
params: dict[str, object] = {"limit": limit, "start": start}
if rsql_filter:
params["filter"] = rsql_filter
- return await self._resource_list(
+ return self._resource_list(
DOCUMENT_ENDPOINT, GetDocument, params=params, skip_entity=True
)
- async def get_document(self, document_id: GlpiId) -> GetDocument:
+ def get_document(self, document_id: GlpiId) -> GetDocument:
"""Fetch one GLPI document by identifier.
Parameters
@@ -82,14 +81,14 @@ async def get_document(self, document_id: GlpiId) -> GetDocument:
If the GLPI server returns a non-success HTTP status.
"""
- return await self._resource_get(
+ return 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:
+ def create_document(self, document: PostDocument) -> int:
"""Create one GLPI document metadata record.
Binary uploads use :meth:`upload_document` instead of the JSON
@@ -112,7 +111,7 @@ async def create_document(self, document: PostDocument) -> int:
non-success HTTP status.
"""
- return await self._resource_create(
+ return self._resource_create(
DOCUMENT_ENDPOINT,
document,
failure_message="Failed to create document",
@@ -121,9 +120,7 @@ async def create_document(self, document: PostDocument) -> int:
skip_entity=True,
)
- async 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
@@ -143,14 +140,14 @@ async def update_document(
If the GLPI server returns a non-success HTTP status.
"""
- await self._resource_update(
+ 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(
+ def delete_document(
self, document_id: GlpiId, *, force: bool | None = None
) -> None:
"""Delete one GLPI document by identifier.
@@ -173,7 +170,7 @@ async def delete_document(
If the GLPI server returns a non-success HTTP status.
"""
- await self._resource_delete(
+ 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}",
@@ -182,7 +179,7 @@ async def delete_document(
skip_entity=True,
)
- async def download_document_content(self, document_id: GlpiId) -> bytes:
+ def download_document_content(self, document_id: GlpiId) -> bytes:
"""Download the raw binary payload for one GLPI document.
Parameters
@@ -202,7 +199,7 @@ async def download_document_content(self, document_id: GlpiId) -> bytes:
If the GLPI server returns a non-success HTTP status.
"""
- response = await self._get_request(
+ response = self._get_request(
f"{DOCUMENT_ENDPOINT}/{document_id}/Download",
skip_entity=True,
)
@@ -213,7 +210,7 @@ async def download_document_content(self, document_id: GlpiId) -> bytes:
)
return response.content
- async def upload_document(
+ def upload_document(
self,
*,
filename: str,
@@ -225,10 +222,12 @@ async def upload_document(
) -> dict[str, object]:
"""Upload one binary document via the legacy v1 multipart endpoint.
- The async wrapper enforces that a v1 session was configured on
- the client and dispatches the blocking upload through
- :func:`asyncio.to_thread` so the running event loop is never
- blocked by the underlying HTTP library.
+ 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.
Parameters
----------
@@ -271,8 +270,7 @@ async def upload_document(
)
v1 = self._v1
- result = await asyncio.to_thread(
- v1.upload_document,
+ return v1.upload_document(
filename,
content,
mime_type,
@@ -280,7 +278,6 @@ async def upload_document(
ticket_id=ticket_id,
entity_id=entity_id,
)
- return result
-__all__ = ["AsyncDocumentMixin"]
+__all__ = ["DocumentMixin"]
diff --git a/glpi_python_client/clients/async_client.py b/glpi_python_client/clients/async_client.py
new file mode 100644
index 0000000..4e88c86
--- /dev/null
+++ b/glpi_python_client/clients/async_client.py
@@ -0,0 +1,241 @@
+"""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`.
+Helpers that benefit from concurrent fan-out
+(:meth:`get_ticket_context`, :meth:`get_task_statistics`) are replaced
+by their dedicated async overrides under
+:mod:`glpi_python_client.clients.custom`.
+
+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 os
+import sys
+import threading
+from concurrent.futures import Executor
+from types import TracebackType
+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.clients.api import (
+ DocumentMixin,
+ EntityMixin,
+ FollowupMixin,
+ LocationMixin,
+ SolutionMixin,
+ TeamMemberMixin,
+ TicketMixin,
+ TicketTaskMixin,
+ TimelineDocumentMixin,
+ UserMixin,
+)
+from glpi_python_client.clients.commons._async_bridge import AsyncBridge
+from glpi_python_client.clients.commons._config import (
+ build_client_env_config,
+ build_client_resources,
+)
+from glpi_python_client.clients.commons._transport import TransportMixin
+from glpi_python_client.clients.custom._statistics_async import AsyncStatisticsMixin
+from glpi_python_client.clients.custom._ticket_context_async import (
+ AsyncTicketContextMixin,
+)
+
+if TYPE_CHECKING:
+ from collections.abc import Mapping
+
+logger = logging.getLogger(__name__)
+
+
+class AsyncGlpiClient(
+ AsyncBridge,
+ TicketMixin,
+ TicketTaskMixin,
+ FollowupMixin,
+ SolutionMixin,
+ TimelineDocumentMixin,
+ TeamMemberMixin,
+ DocumentMixin,
+ UserMixin,
+ EntityMixin,
+ LocationMixin,
+ AsyncTicketContextMixin,
+ AsyncStatisticsMixin,
+ 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. The custom helpers that benefit from concurrent
+ fan-out provide hand-written async overrides which are preserved as
+ coroutine functions by the bridge.
+ """
+
+ 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,
+ executor: Executor | None = None,
+ ) -> None:
+ """Build an asynchronous GLPI client and its transport resources.
+
+ Parameters mirror :class:`GlpiClient` with one extra option:
+
+ Parameters
+ ----------
+ executor : concurrent.futures.Executor | None, optional
+ Optional executor every wrapped call 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.
+
+ Raises
+ ------
+ ValueError
+ 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 = threading.Lock()
+ self._closed = False
+ self._executor = executor
+
+ @classmethod
+ def from_env(
+ cls,
+ *,
+ env: Mapping[str, str] | None = None,
+ prefix: str = "GLPI_",
+ executor: Executor | None = None,
+ **overrides: object,
+ ) -> Self:
+ """Build a client instance from environment variables.
+
+ 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.
+ executor : concurrent.futures.Executor | None, optional
+ Optional executor forwarded to the constructor.
+ **overrides : object
+ Keyword overrides forwarded to :meth:`__init__`.
+
+ Returns
+ -------
+ AsyncGlpiClient
+ A fully configured async client ready to perform requests.
+ """
+
+ config = build_client_env_config(
+ prefix=prefix,
+ env=env if env is not None else os.environ,
+ overrides=overrides,
+ )
+ return cls(executor=executor, **config) # type: ignore[arg-type]
+
+ 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
new file mode 100644
index 0000000..d147404
--- /dev/null
+++ b/glpi_python_client/clients/commons/_async_bridge.py
@@ -0,0 +1,118 @@
+"""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.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import functools
+import inspect
+from collections.abc import Callable
+from concurrent.futures import Executor
+from typing import Any
+
+
+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
+ # Skip if the subclass already overrides the method with
+ # a coroutine function (for example async fan-outs).
+ existing = getattr(cls, name, None)
+ if existing is not None and inspect.iscoroutinefunction(existing):
+ seen.add(name)
+ continue
+ seen.add(name)
+ 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
+
+
+__all__ = ["AsyncBridge"]
diff --git a/glpi_python_client/clients/commons/_config.py b/glpi_python_client/clients/commons/_config.py
index 88b07c2..b48546a 100644
--- a/glpi_python_client/clients/commons/_config.py
+++ b/glpi_python_client/clients/commons/_config.py
@@ -2,7 +2,8 @@
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.glpi_client.GlpiClient`.
+:class:`glpi_python_client.clients.sync_client.GlpiClient` and
+:class:`glpi_python_client.clients.async_client.AsyncGlpiClient`.
"""
from __future__ import annotations
diff --git a/glpi_python_client/clients/commons/_transport.py b/glpi_python_client/clients/commons/_transport.py
index b46c19d..c978791 100644
--- a/glpi_python_client/clients/commons/_transport.py
+++ b/glpi_python_client/clients/commons/_transport.py
@@ -1,15 +1,37 @@
-"""Asynchronous GLPI v2 transport mixin.
+"""Synchronous 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
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.
+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.
"""
from __future__ import annotations
-import asyncio
import logging
+import threading
from collections.abc import Callable
from typing import TYPE_CHECKING, TypeVar, cast
@@ -42,17 +64,27 @@
ModelT = TypeVar("ModelT", bound=GlpiModel)
-class AsyncTransportMixin:
- """Asynchronous GLPI API transport helpers shared by the API mixins.
+class TransportMixin:
+ """Synchronous GLPI API transport helpers shared by the API mixins.
The class declares the runtime attributes the concrete client owns and
- exposes the awaitable ``_get_request``, ``_post_request``,
+ 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``
+ (: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.
"""
_auth: GLPITokenManager
- _auth_lock: asyncio.Lock
+ _auth_lock: threading.Lock
_closed: bool = False
_session: requests.Session
_v1: GLPIV1Session | None
@@ -65,43 +97,41 @@ class AsyncTransportMixin:
def _ensure_open(self) -> None:
"""Raise when the client has already been closed.
- All async transport helpers call this guard before touching the
- shared HTTP session so closed clients fail fast and predictably.
+ 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")
- async def _ensure_token(self) -> None:
+ def _ensure_token(self) -> None:
"""Ensure that a valid GLPI access token is available.
- Token refresh is serialised by the async lock so concurrent awaited
- calls do not race while updating shared authentication state.
+ 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 asyncio.to_thread(self._auth.ensure_token)
+ with self._auth_lock:
+ self._auth.ensure_token()
- async def _send_request(
+ def _send_request(
self,
method: str,
url: str,
**kwargs: object,
) -> requests.Response:
- """Dispatch one blocking ``requests`` call from the async loop.
+ """Dispatch one blocking ``requests`` call.
- The blocking HTTP call is wrapped in ``asyncio.to_thread`` so the
- async loop is never blocked by the underlying synchronous library.
+ The helper exists as an indirection seam so tests can stub HTTP
+ dispatch without monkey-patching the session attribute directly.
"""
request_method = getattr(self._session, method)
- return cast(
- requests.Response,
- await asyncio.to_thread(request_method, url, **kwargs),
- )
+ return cast(requests.Response, request_method(url, **kwargs))
- async def _execute_request(
+ def _execute_request(
self,
*,
method: str,
@@ -112,14 +142,15 @@ async def _execute_request(
skip_entity: bool = False,
include_content_type: bool = False,
) -> requests.Response:
- """Execute one authenticated GLPI request asynchronously.
+ """Execute one authenticated GLPI request.
The helper normalises the endpoint URL, headers, timeout, and
- payload placement before dispatching the blocking HTTP call through
- the async transport wrapper.
+ payload placement before dispatching the blocking HTTP call. It
+ guarantees a fresh access token before the request leaves the
+ process.
"""
- await self._ensure_token()
+ self._ensure_token()
access_token = require_access_token(self._auth.access_token)
url = build_request_url(self.glpi_api_url, endpoint)
@@ -140,7 +171,7 @@ async def _execute_request(
else:
request_kwargs["json"] = json_body
- response = await self._send_request(method, url, **request_kwargs)
+ response = self._send_request(method, url, **request_kwargs)
return finalize_request_response(
response,
method=method,
@@ -154,20 +185,20 @@ async def _execute_request(
stop=stop_after_attempt(3),
wait=wait_fixed(3),
)
- async def _get_request(
+ def _get_request(
self,
endpoint: str,
params: dict[str, object] | None = None,
skip_entity: bool = False,
) -> requests.Response:
- """Execute one authenticated GLPI ``GET`` request asynchronously.
+ """Execute one authenticated GLPI ``GET`` request.
Network-level request exceptions are retried according to the
transport retry policy before the response is returned to the
caller.
"""
- return await self._execute_request(
+ return self._execute_request(
method="get",
endpoint=endpoint,
success_statuses=(200, 206),
@@ -180,19 +211,19 @@ async def _get_request(
stop=stop_after_attempt(3),
wait=wait_fixed(3),
)
- async def _post_request(
+ def _post_request(
self,
endpoint: str,
json_body: dict[str, object] | None = None,
skip_entity: bool = False,
) -> requests.Response:
- """Execute one authenticated GLPI ``POST`` request asynchronously.
+ """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(
+ return self._execute_request(
method="post",
endpoint=endpoint,
success_statuses=(200, 201),
@@ -206,19 +237,19 @@ async def _post_request(
stop=stop_after_attempt(3),
wait=wait_fixed(3),
)
- async def _update_request(
+ def _update_request(
self,
endpoint: str,
json_body: dict[str, object] | None = None,
) -> requests.Response:
- """Execute one authenticated GLPI ``PATCH`` request asynchronously.
+ """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.
+ 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(
+ return self._execute_request(
method="patch",
endpoint=endpoint,
success_statuses=(200, 204),
@@ -231,19 +262,19 @@ async def _update_request(
stop=stop_after_attempt(3),
wait=wait_fixed(3),
)
- async def _delete_request(
+ def _delete_request(
self,
endpoint: str,
json_body: dict[str, object] | None = None,
skip_entity: bool = False,
) -> requests.Response:
- """Execute one authenticated GLPI ``DELETE`` request asynchronously.
+ """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.
+ 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(
+ return self._execute_request(
method="delete",
endpoint=endpoint,
success_statuses=(200, 204),
@@ -252,7 +283,7 @@ async def _delete_request(
include_content_type=json_body is not None,
)
- async def _resource_list(
+ def _resource_list(
self,
endpoint: str,
model: type[ModelT],
@@ -292,9 +323,7 @@ async def _resource_list(
Validated records returned by the GLPI server.
"""
- response = await 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,
@@ -309,7 +338,7 @@ async def _resource_list(
)
return [model_from_payload(model, item) for item in items]
- async def _resource_get(
+ def _resource_get(
self,
endpoint: str,
model: type[ModelT],
@@ -337,7 +366,7 @@ async def _resource_get(
Validated record returned by the GLPI server.
"""
- response = await self._get_request(endpoint, skip_entity=skip_entity)
+ response = self._get_request(endpoint, skip_entity=skip_entity)
ensure_response_status(
response,
success_statuses=(200, 206),
@@ -345,7 +374,7 @@ async def _resource_get(
)
return model_from_payload(model, response.json())
- async def _resource_create(
+ def _resource_create(
self,
endpoint: str,
body_model: GlpiModel,
@@ -368,8 +397,9 @@ async def _resource_create(
Message embedded in the ``ValueError`` raised on a non-success
HTTP status.
missing_message : str
- Message embedded in the ``ValueError`` raised when the response
- payload does not contain any of the expected identifier keys.
+ Message embedded in the ``ValueError`` 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
@@ -386,7 +416,7 @@ async def _resource_create(
Numeric identifier assigned by the GLPI server.
"""
- response = await self._post_request(
+ response = self._post_request(
endpoint, model_to_payload(body_model), skip_entity=skip_entity
)
ensure_response_status(
@@ -400,7 +430,7 @@ async def _resource_create(
logger.info("%s", log_message_factory(new_id))
return new_id
- async def _resource_update(
+ def _resource_update(
self,
endpoint: str,
body_model: GlpiModel,
@@ -428,7 +458,7 @@ async def _resource_update(
None
"""
- response = await self._update_request(endpoint, model_to_payload(body_model))
+ response = self._update_request(endpoint, model_to_payload(body_model))
ensure_response_status(
response,
success_statuses=(200, 204),
@@ -436,7 +466,7 @@ async def _resource_update(
)
logger.info("%s", log_message)
- async def _resource_delete(
+ def _resource_delete(
self,
endpoint: str,
*,
@@ -480,9 +510,7 @@ async def _resource_delete(
request_body = body
if request_body is None and delete_model_cls is not None and force is not None:
request_body = model_to_payload(delete_model_cls(force=force)) # type: ignore[call-arg]
- response = await 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),
@@ -491,4 +519,4 @@ async def _resource_delete(
logger.info("%s", log_message)
-__all__ = ["AsyncTransportMixin"]
+__all__ = ["TransportMixin"]
diff --git a/glpi_python_client/clients/commons/tests/test_transport.py b/glpi_python_client/clients/commons/tests/test_transport.py
new file mode 100644
index 0000000..0cb6d24
--- /dev/null
+++ b/glpi_python_client/clients/commons/tests/test_transport.py
@@ -0,0 +1,136 @@
+"""Unit tests for the synchronous GLPI transport mixin.
+
+The tests exercise the core dispatch path — ``_ensure_token``,
+``_send_request``, ``_execute_request``, and the four HTTP-verb helpers —
+using a real :class:`GlpiClient` with its session and auth stubbed out so no
+real network call is made.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+from glpi_python_client.testing.utils import FakeResponse, make_client
+
+
+@pytest.fixture
+def client(): # type: ignore[no-untyped-def]
+ """Return a test client with auth and send_request pre-stubbed."""
+
+ c = make_client()
+ # Inject a ready access token and make ensure_token a no-op so the
+ # transport helpers can be called without network access.
+ c._auth.access_token = "test-token"
+ c._auth.ensure_token = lambda: None # type: ignore[method-assign]
+ # Stub _send_request at the seam level so _execute_request exercises the
+ # real header-building logic while returning a controlled response.
+ c._send_request = lambda method, url, **kw: FakeResponse( # type: ignore[method-assign]
+ status_code=200, payload={"id": 1}
+ )
+ yield c
+ c.close()
+
+
+def test_ensure_token_calls_auth_manager(monkeypatch: pytest.MonkeyPatch) -> None:
+ """``_ensure_token`` invokes ``_auth.ensure_token`` on an open client."""
+
+ c = make_client()
+ called: list[bool] = []
+ c._auth.ensure_token = lambda: called.append(True) # type: ignore[method-assign]
+ c._ensure_token()
+ assert called
+ c.close()
+
+
+def test_send_request_dispatches_to_matching_session_method(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """``_send_request`` calls the method matching the verb on the session."""
+
+ c = make_client()
+ fake = FakeResponse(status_code=200, payload={})
+ monkeypatch.setattr(c._session, "get", lambda url, **kw: fake)
+ result = c._send_request("get", "https://glpi.example.test/api.php/v2/test")
+ assert result is fake
+ c.close()
+
+
+def test_execute_request_get_builds_params(client: Any) -> None:
+ """``_execute_request`` places query params on GET requests."""
+
+ captured: dict[str, Any] = {}
+
+ def _capture(method: str, url: str, **kw: Any) -> FakeResponse:
+ captured.update({"method": method, "url": url, "kw": kw})
+ return FakeResponse(status_code=200, payload={})
+
+ client._send_request = _capture # type: ignore[method-assign]
+ client._execute_request(
+ method="get",
+ endpoint="Assistance/Ticket",
+ success_statuses=(200,),
+ params={"range": "0-49"},
+ )
+ assert captured["method"] == "get"
+ assert "Assistance/Ticket" in captured["url"]
+ assert "params" in captured["kw"]
+
+
+def test_execute_request_post_builds_json_body(client: Any) -> None:
+ """``_execute_request`` places the body in ``json`` for non-GET verbs."""
+
+ captured: dict[str, Any] = {}
+
+ def _capture(method: str, url: str, **kw: Any) -> FakeResponse:
+ captured.update({"method": method, "kw": kw})
+ return FakeResponse(status_code=201, payload={})
+
+ client._send_request = _capture # type: ignore[method-assign]
+ client._execute_request(
+ method="post",
+ endpoint="Assistance/Ticket",
+ success_statuses=(201,),
+ json_body={"name": "t"},
+ include_content_type=True,
+ )
+ assert captured["method"] == "post"
+ assert captured["kw"].get("json") == {"name": "t"}
+
+
+def test_get_request_returns_response(client: Any) -> None:
+ """``_get_request`` dispatches via ``_execute_request`` and returns the response."""
+
+ resp = client._get_request("Assistance/Ticket")
+ assert resp.status_code == 200
+
+
+def test_post_request_returns_response(client: Any) -> None:
+ """``_post_request`` dispatches and returns the response."""
+
+ client._send_request = lambda method, url, **kw: FakeResponse( # type: ignore[method-assign]
+ status_code=201, payload={"id": 99}
+ )
+ resp = client._post_request("Assistance/Ticket", json_body={"name": "t"})
+ assert resp.status_code == 201
+
+
+def test_update_request_returns_response(client: Any) -> None:
+ """``_update_request`` dispatches and returns the response."""
+
+ client._send_request = lambda method, url, **kw: FakeResponse( # type: ignore[method-assign]
+ status_code=200, payload={}
+ )
+ resp = client._update_request("Assistance/Ticket/1", json_body={"name": "u"})
+ assert resp.status_code == 200
+
+
+def test_delete_request_returns_response(client: Any) -> None:
+ """``_delete_request`` dispatches and returns the response."""
+
+ client._send_request = lambda method, url, **kw: FakeResponse( # type: ignore[method-assign]
+ status_code=204, payload={}
+ )
+ resp = client._delete_request("Assistance/Ticket/1")
+ assert resp.status_code == 204
diff --git a/glpi_python_client/clients/custom/__init__.py b/glpi_python_client/clients/custom/__init__.py
index bd5a8db..d275add 100644
--- a/glpi_python_client/clients/custom/__init__.py
+++ b/glpi_python_client/clients/custom/__init__.py
@@ -5,11 +5,28 @@
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._statistics import AsyncStatisticsMixin
-from glpi_python_client.clients.custom._ticket_context import AsyncTicketContextMixin
+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__ = ["AsyncStatisticsMixin", "AsyncTicketContextMixin"]
+__all__ = [
+ "AsyncStatisticsMixin",
+ "AsyncTicketContextMixin",
+ "StatisticsMixin",
+ "TicketContextMixin",
+]
diff --git a/glpi_python_client/clients/custom/_statistics.py b/glpi_python_client/clients/custom/_statistics.py
index 1493bdd..3a213ce 100644
--- a/glpi_python_client/clients/custom/_statistics.py
+++ b/glpi_python_client/clients/custom/_statistics.py
@@ -10,12 +10,11 @@
from __future__ import annotations
-import asyncio
from collections import defaultdict
from datetime import date, timedelta
from glpi_python_client.clients.commons._filters import rsql_all_filter
-from glpi_python_client.clients.commons._transport import AsyncTransportMixin
+from glpi_python_client.clients.commons._transport import TransportMixin
from glpi_python_client.models.api_schema._common import (
IdNameCompletenameRef,
IdNameRef,
@@ -30,10 +29,10 @@
)
-class AsyncStatisticsMixin(AsyncTransportMixin):
- """Asynchronous custom statistics built on the contract API mixins."""
+class StatisticsMixin(TransportMixin):
+ """Synchronous custom statistics built on the contract API mixins."""
- async def get_ticket_statistics(
+ def get_ticket_statistics(
self,
*,
start_date: str | None = None,
@@ -85,13 +84,13 @@ async def get_ticket_statistics(
f"date_creation=ge={start.isoformat()};date_creation=le={end.isoformat()}",
extra_filter,
)
- tickets: list[GetTicket] = await self.search_tickets( # type: ignore[attr-defined]
+ tickets: list[GetTicket] = self.search_tickets( # type: ignore[attr-defined]
rsql_filter=query or "",
limit=200,
)
return _summarize_tickets(tickets)
- async def get_task_statistics(
+ def get_task_statistics(
self,
ticket_ids: list[int],
) -> dict[str, object]:
@@ -126,12 +125,10 @@ async def get_task_statistics(
"duration_by_ticket": {},
}
- results = await asyncio.gather(
- *(
- self.list_ticket_tasks(ticket_id) # type: ignore[attr-defined]
- for ticket_id in ticket_ids
- )
- )
+ results: list[list[GetTicketTask]] = [
+ 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)
@@ -261,4 +258,4 @@ def _freeze_bucket(bucket: dict[str, object]) -> dict[str, object]:
}
-__all__ = ["AsyncStatisticsMixin"]
+__all__ = ["StatisticsMixin"]
diff --git a/glpi_python_client/clients/custom/_statistics_async.py b/glpi_python_client/clients/custom/_statistics_async.py
new file mode 100644
index 0000000..d97fcf7
--- /dev/null
+++ b/glpi_python_client/clients/custom/_statistics_async.py
@@ -0,0 +1,69 @@
+"""Asynchronous override for the statistics aggregation helper.
+
+The async mixin overrides :meth:`get_task_statistics` so the per-ticket
+task-listing calls are dispatched concurrently using
+:func:`asyncio.gather`. ``get_ticket_statistics`` does not need a custom
+override because it issues a single GLPI request and therefore behaves
+correctly when wrapped by the bridge into a coroutine.
+"""
+
+from __future__ import annotations
+
+import asyncio
+
+from glpi_python_client.clients.custom._statistics import StatisticsMixin
+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],
+ ) -> dict[str, object]:
+ """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
+ -------
+ dict[str, object]
+ Mapping with ``ticket_count``, ``task_count``,
+ ``total_duration``, ``duration_by_user``, and
+ ``duration_by_ticket`` entries.
+ """
+
+ from glpi_python_client.clients.custom._statistics import _summarize_tasks
+
+ if not ticket_ids:
+ return {
+ "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)
+
+
+__all__ = ["AsyncStatisticsMixin"]
diff --git a/glpi_python_client/clients/custom/_ticket_context.py b/glpi_python_client/clients/custom/_ticket_context.py
index 8990b29..673b0ee 100644
--- a/glpi_python_client/clients/custom/_ticket_context.py
+++ b/glpi_python_client/clients/custom/_ticket_context.py
@@ -7,26 +7,27 @@
from __future__ import annotations
-import asyncio
-
from glpi_python_client.clients.commons._constants import GlpiId
-from glpi_python_client.clients.commons._transport import AsyncTransportMixin
+from glpi_python_client.clients.commons._transport import TransportMixin
from glpi_python_client.models.custom_schema._ticket_context import GlpiTicketContext
-class AsyncTicketContextMixin(AsyncTransportMixin):
- """Asynchronous ticket-context aggregation helper.
+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.
"""
- async def get_ticket_context(self, ticket_id: GlpiId) -> GlpiTicketContext:
+ def get_ticket_context(self, ticket_id: GlpiId) -> GlpiTicketContext:
"""Return one aggregated ticket context view.
- The primary ticket fetch is dispatched together with the four
- timeline list calls using :func:`asyncio.gather` so they are
- awaited concurrently.
+ The primary ticket fetch and the four timeline list calls are
+ executed sequentially in this synchronous implementation.
Parameters
----------
@@ -47,12 +48,12 @@ async def get_ticket_context(self, ticket_id: GlpiId) -> GlpiTicketContext:
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(ticket_id), # type: ignore[attr-defined]
+ 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,
@@ -63,4 +64,4 @@ async def get_ticket_context(self, ticket_id: GlpiId) -> GlpiTicketContext:
)
-__all__ = ["AsyncTicketContextMixin"]
+__all__ = ["TicketContextMixin"]
diff --git a/glpi_python_client/clients/custom/_ticket_context_async.py b/glpi_python_client/clients/custom/_ticket_context_async.py
new file mode 100644
index 0000000..c1ea55e
--- /dev/null
+++ b/glpi_python_client/clients/custom/_ticket_context_async.py
@@ -0,0 +1,73 @@
+"""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
+ ------
+ ValueError
+ 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.py b/glpi_python_client/clients/custom/tests/test_statistics.py
index 82ffa22..1fb7d79 100644
--- a/glpi_python_client/clients/custom/tests/test_statistics.py
+++ b/glpi_python_client/clients/custom/tests/test_statistics.py
@@ -63,14 +63,14 @@ def _ticket(
return GetTicket(**payload)
-async def test_get_ticket_statistics_aggregates_by_entity_status_priority_type(
+def test_get_ticket_statistics_aggregates_by_entity_status_priority_type(
client: GlpiClient,
) -> None:
"""All aggregation buckets are produced from the search response."""
captured: dict[str, Any] = {}
- async def fake_search(
+ def fake_search(
rsql_filter: str = "", *, limit: int = 50, start: int = 0
) -> list[GetTicket]:
captured["filter"] = rsql_filter
@@ -92,7 +92,7 @@ async def fake_search(
]
client.search_tickets = fake_search # type: ignore[method-assign]
- result = await client.get_ticket_statistics(
+ result = client.get_ticket_statistics(
start_date="2026-01-01",
end_date="2026-01-31",
extra_filter="status==1",
@@ -112,42 +112,40 @@ async def fake_search(
assert entities["unknown"]["by_status"] == {"UNKNOWN": 1}
-async def test_get_ticket_statistics_default_window_uses_today(
+def test_get_ticket_statistics_default_window_uses_today(
client: GlpiClient,
) -> None:
"""When no dates are passed the helper uses today minus default_days."""
captured: dict[str, Any] = {}
- async def fake_search(
+ def fake_search(
rsql_filter: str = "", *, limit: int = 50, start: int = 0
) -> list[GetTicket]:
captured["filter"] = rsql_filter
return []
client.search_tickets = fake_search # type: ignore[method-assign]
- await client.get_ticket_statistics(default_days=7)
+ client.get_ticket_statistics(default_days=7)
end = date.today()
start = end - timedelta(days=6)
assert f"date_creation=ge={start.isoformat()}" in captured["filter"]
assert f"date_creation=le={end.isoformat()}" in captured["filter"]
-async def test_get_ticket_statistics_rejects_invalid_window(client: GlpiClient) -> None:
+def test_get_ticket_statistics_rejects_invalid_window(client: GlpiClient) -> None:
"""Invalid date inputs raise locally before any HTTP request."""
with pytest.raises(ValueError, match="default_days"):
- await client.get_ticket_statistics(default_days=0)
+ client.get_ticket_statistics(default_days=0)
with pytest.raises(ValueError, match="start_date"):
- await client.get_ticket_statistics(
- start_date="2026-02-01", end_date="2026-01-01"
- )
+ client.get_ticket_statistics(start_date="2026-02-01", end_date="2026-01-01")
-async def test_get_task_statistics_zero_for_empty_input(client: GlpiClient) -> None:
+def test_get_task_statistics_zero_for_empty_input(client: GlpiClient) -> None:
"""An empty ticket list returns zeroed totals without any HTTP call."""
- result = await client.get_task_statistics([])
+ result = client.get_task_statistics([])
assert result == {
"ticket_count": 0,
"task_count": 0,
@@ -157,12 +155,12 @@ async def test_get_task_statistics_zero_for_empty_input(client: GlpiClient) -> N
}
-async def test_get_task_statistics_aggregates_by_user_and_ticket(
+def test_get_task_statistics_aggregates_by_user_and_ticket(
client: GlpiClient,
) -> None:
"""Durations group by user and parent ticket."""
- async def fake_list(ticket_id: int) -> list[GetTicketTask]:
+ def fake_list(ticket_id: int) -> list[GetTicketTask]:
if ticket_id == 1:
return [
GetTicketTask(
@@ -188,7 +186,7 @@ async def fake_list(ticket_id: int) -> list[GetTicketTask]:
]
client.list_ticket_tasks = fake_list # type: ignore[method-assign]
- result = await client.get_task_statistics([1, 2])
+ result = client.get_task_statistics([1, 2])
assert result["ticket_count"] == 2
assert result["task_count"] == 3
assert result["total_duration"] == 900
diff --git a/glpi_python_client/clients/custom/tests/test_ticket_context.py b/glpi_python_client/clients/custom/tests/test_ticket_context.py
new file mode 100644
index 0000000..5b658c2
--- /dev/null
+++ b/glpi_python_client/clients/custom/tests/test_ticket_context.py
@@ -0,0 +1,57 @@
+"""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`
+exercises its real aggregation logic without any network call.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from glpi_python_client.testing.utils import FakeResponse, make_client
+
+
+def test_get_ticket_context_assembles_ticket_and_timeline() -> None:
+ """``get_ticket_context`` fetches one ticket and four timeline lists.
+
+ The method must call ``get_ticket`` once and one list helper for each
+ timeline resource (tasks, followups, solutions, documents), then
+ bundle all results into a single :class:`GlpiTicketContext`.
+ """
+
+ client = make_client()
+ calls: list[str] = []
+
+ def _get(
+ endpoint: str,
+ params: Any = None,
+ skip_entity: bool = False,
+ ) -> FakeResponse:
+ calls.append(endpoint)
+ if endpoint.endswith("/Timeline/Task"):
+ return FakeResponse(status_code=200, payload=[])
+ 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/Document"):
+ return FakeResponse(status_code=200, payload=[])
+ # Primary ticket fetch.
+ return FakeResponse(
+ status_code=200,
+ payload={"id": 42, "name": "Test ticket", "content": "body
"},
+ )
+
+ client._get_request = _get # type: ignore[method-assign]
+ ctx = client.get_ticket_context(42)
+
+ assert ctx.ticket.id == 42
+ assert ctx.tasks == []
+ assert ctx.followups == []
+ assert ctx.solutions == []
+ assert ctx.documents == []
+ # Five separate GET calls must have been dispatched.
+ assert len(calls) == 5
+
+ client.close()
diff --git a/glpi_python_client/clients/glpi_client.py b/glpi_python_client/clients/sync_client.py
similarity index 77%
rename from glpi_python_client/clients/glpi_client.py
rename to glpi_python_client/clients/sync_client.py
index e800bb5..ecdfaff 100644
--- a/glpi_python_client/clients/glpi_client.py
+++ b/glpi_python_client/clients/sync_client.py
@@ -1,18 +1,24 @@
-"""Public asynchronous GLPI client class.
+"""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 asynchronous transport
+: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.
"""
from __future__ import annotations
-import asyncio
import logging
import os
import sys
+import threading
from types import TracebackType
from typing import TYPE_CHECKING
@@ -22,25 +28,25 @@
from typing_extensions import Self
from glpi_python_client.clients.api import (
- AsyncDocumentMixin,
- AsyncEntityMixin,
- AsyncFollowupMixin,
- AsyncLocationMixin,
- AsyncSolutionMixin,
- AsyncTeamMemberMixin,
- AsyncTicketMixin,
- AsyncTicketTaskMixin,
- AsyncTimelineDocumentMixin,
- AsyncUserMixin,
+ DocumentMixin,
+ EntityMixin,
+ FollowupMixin,
+ LocationMixin,
+ SolutionMixin,
+ TeamMemberMixin,
+ TicketMixin,
+ TicketTaskMixin,
+ TimelineDocumentMixin,
+ UserMixin,
)
from glpi_python_client.clients.commons._config import (
build_client_env_config,
build_client_resources,
)
-from glpi_python_client.clients.commons._transport import AsyncTransportMixin
+from glpi_python_client.clients.commons._transport import TransportMixin
from glpi_python_client.clients.custom import (
- AsyncStatisticsMixin,
- AsyncTicketContextMixin,
+ StatisticsMixin,
+ TicketContextMixin,
)
if TYPE_CHECKING:
@@ -50,24 +56,28 @@
class GlpiClient(
- AsyncTicketMixin,
- AsyncTicketTaskMixin,
- AsyncFollowupMixin,
- AsyncSolutionMixin,
- AsyncTimelineDocumentMixin,
- AsyncTeamMemberMixin,
- AsyncDocumentMixin,
- AsyncUserMixin,
- AsyncEntityMixin,
- AsyncLocationMixin,
- AsyncTicketContextMixin,
- AsyncStatisticsMixin,
- AsyncTransportMixin,
+ TicketMixin,
+ TicketTaskMixin,
+ FollowupMixin,
+ SolutionMixin,
+ TimelineDocumentMixin,
+ TeamMemberMixin,
+ DocumentMixin,
+ UserMixin,
+ EntityMixin,
+ LocationMixin,
+ TicketContextMixin,
+ StatisticsMixin,
+ TransportMixin,
):
- """Asynchronous GLPI client backed by the contract-aligned API mixins.
+ """Synchronous 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`.
"""
def __init__(
@@ -88,7 +98,7 @@ def __init__(
v1_user_token: str | None = None,
v1_app_token: str | None = None,
) -> None:
- """Build a GLPI client and its underlying transport resources.
+ """Build a synchronous GLPI client and its transport resources.
Parameters
----------
@@ -153,7 +163,7 @@ def __init__(
self.glpi_profile = glpi_profile
self.entity_recursive = entity_recursive
self.language = language
- self._auth_lock = asyncio.Lock()
+ self._auth_lock = threading.Lock()
self._closed = False
@classmethod
@@ -181,8 +191,7 @@ def from_env(
prefix : str, optional
Common prefix shared by every environment variable name.
**overrides : object
- Keyword overrides forwarded to :meth:`__init__`. Any value
- given here wins over the environment lookup.
+ Keyword overrides forwarded to :meth:`__init__`.
Returns
-------
@@ -202,29 +211,25 @@ def from_env(
)
return cls(**config) # type: ignore[arg-type]
- async def close(self) -> None:
+ 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.
-
- Returns
- -------
- None
+ 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 asyncio.to_thread(self._session.close)
+ self._session.close()
if self._v1 is not None:
- await asyncio.to_thread(self._v1.close)
+ self._v1.close()
finally:
self._closed = True
- async def __aenter__(self) -> Self:
- """Return the client unchanged for use in an ``async with`` block.
+ def __enter__(self) -> Self:
+ """Return the client unchanged for use in a ``with`` block.
Returns
-------
@@ -234,29 +239,25 @@ async def __aenter__(self) -> Self:
return self
- async def __aexit__(
+ def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
- """Close the client on ``async with`` exit.
+ """Close the client on ``with`` exit.
Parameters
----------
exc_type : type[BaseException] | None
- Exception class raised inside the ``async with`` block, if any.
+ 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``.
-
- Returns
- -------
- None
"""
- await self.close()
+ self.close()
__all__ = ["GlpiClient"]
diff --git a/glpi_python_client/clients/tests/test_api_coverage.py b/glpi_python_client/clients/tests/test_api_coverage.py
index 337deef..7735687 100644
--- a/glpi_python_client/clients/tests/test_api_coverage.py
+++ b/glpi_python_client/clients/tests/test_api_coverage.py
@@ -57,7 +57,7 @@ def __init__(
def install(self, client: GlpiClient) -> None:
"""Replace the four transport helpers with capturing stubs."""
- async def _get(
+ def _get(
endpoint: str,
params: dict[str, Any] | None = None,
skip_entity: bool = False,
@@ -76,7 +76,7 @@ async def _get(
content=self._get_content,
)
- async def _post(
+ def _post(
endpoint: str,
json_body: dict[str, Any] | None = None,
skip_entity: bool = False,
@@ -93,7 +93,7 @@ async def _post(
status_code=self._post_status, payload=self._post_payload
)
- async def _patch(
+ def _patch(
endpoint: str, json_body: dict[str, Any] | None = None
) -> FakeResponse:
self.calls.append(
@@ -101,7 +101,7 @@ async def _patch(
)
return FakeResponse(status_code=self._patch_status, payload={})
- async def _delete(
+ def _delete(
endpoint: str,
json_body: dict[str, Any] | None = None,
skip_entity: bool = False,
@@ -134,12 +134,12 @@ def client() -> GlpiClient:
# ---------------------------------------------------------------------------
-async def test_search_tickets_forwards_sort_and_fields(client: GlpiClient) -> None:
+def test_search_tickets_forwards_sort_and_fields(client: GlpiClient) -> None:
"""Sort and field selection both flow into the GET query parameters."""
rec = _Recorder(get_payload=[{"id": 1, "name": "n", "content": "c"}])
rec.install(client)
- tickets = await client.search_tickets(
+ tickets = client.search_tickets(
"status==1", limit=5, start=10, sort="date_mod desc", fields=("id", "name")
)
@@ -151,34 +151,34 @@ async def test_search_tickets_forwards_sort_and_fields(client: GlpiClient) -> No
assert rec.calls[0]["params"]["fields"] == "id,name"
-async def test_get_ticket_returns_validated_model(client: GlpiClient) -> None:
+def test_get_ticket_returns_validated_model(client: GlpiClient) -> None:
"""Single ticket responses are validated through ``GetTicket``."""
rec = _Recorder(get_payload={"id": 7, "name": "demo", "content": "c
"})
rec.install(client)
- ticket = await client.get_ticket(7)
+ ticket = client.get_ticket(7)
assert ticket.id == 7
assert rec.calls[0]["endpoint"] == "Assistance/Ticket/7"
-async def test_update_ticket_sends_patch(client: GlpiClient) -> None:
+def test_update_ticket_sends_patch(client: GlpiClient) -> None:
"""Update sends a PATCH with the partial body."""
rec = _Recorder()
rec.install(client)
- await client.update_ticket(7, PatchTicket(content="x
"))
+ client.update_ticket(7, PatchTicket(content="x
"))
call = rec.calls[0]
assert call["method"] == "PATCH"
assert call["endpoint"] == "Assistance/Ticket/7"
assert call["json"] == {"content": "x
"}
-async def test_delete_ticket_omits_body_without_force(client: GlpiClient) -> None:
+def test_delete_ticket_omits_body_without_force(client: GlpiClient) -> None:
"""``delete_ticket(force=None)`` omits the JSON body."""
rec = _Recorder()
rec.install(client)
- await client.delete_ticket(7)
+ client.delete_ticket(7)
call = rec.calls[0]
assert call["method"] == "DELETE"
assert call["endpoint"] == "Assistance/Ticket/7"
@@ -190,33 +190,33 @@ async def test_delete_ticket_omits_body_without_force(client: GlpiClient) -> Non
# ---------------------------------------------------------------------------
-async def test_search_users_forwards_skip_entity(client: GlpiClient) -> None:
+def test_search_users_forwards_skip_entity(client: GlpiClient) -> None:
"""``search_users`` forwards the ``skip_entity`` flag."""
rec = _Recorder(get_payload=[{"id": 1, "username": "alice"}])
rec.install(client)
- users = await client.search_users("username==alice", skip_entity=True)
+ users = client.search_users("username==alice", skip_entity=True)
assert len(users) == 1
assert rec.calls[0]["skip_entity"] is True
assert rec.calls[0]["params"]["filter"] == "username==alice"
-async def test_get_user_targets_user_endpoint(client: GlpiClient) -> None:
+def test_get_user_targets_user_endpoint(client: GlpiClient) -> None:
"""``get_user`` hits the per-id endpoint."""
rec = _Recorder(get_payload={"id": 5, "username": "alice"})
rec.install(client)
- user = await client.get_user(5)
+ user = client.get_user(5)
assert user.id == 5
assert rec.calls[0]["endpoint"] == "Administration/User/5"
-async def test_update_user_sends_patch(client: GlpiClient) -> None:
+def test_update_user_sends_patch(client: GlpiClient) -> None:
"""``update_user`` issues PATCH against the user endpoint."""
rec = _Recorder()
rec.install(client)
- await client.update_user(5, PatchUser(firstname="Alice"))
+ client.update_user(5, PatchUser(firstname="Alice"))
assert rec.calls[0]["method"] == "PATCH"
assert rec.calls[0]["endpoint"] == "Administration/User/5"
@@ -226,42 +226,42 @@ async def test_update_user_sends_patch(client: GlpiClient) -> None:
# ---------------------------------------------------------------------------
-async def test_search_locations_passes_filter(client: GlpiClient) -> None:
+def test_search_locations_passes_filter(client: GlpiClient) -> None:
"""``search_locations`` forwards the RSQL filter through ``filter``."""
rec = _Recorder(get_payload=[{"id": 1, "name": "Paris"}])
rec.install(client)
- locations = await client.search_locations("name==Paris")
+ locations = client.search_locations("name==Paris")
assert locations[0].id == 1
assert rec.calls[0]["endpoint"] == "Dropdowns/Location"
assert rec.calls[0]["params"]["filter"] == "name==Paris"
-async def test_get_location_endpoint(client: GlpiClient) -> None:
+def test_get_location_endpoint(client: GlpiClient) -> None:
"""``get_location`` hits the per-id endpoint."""
rec = _Recorder(get_payload={"id": 9, "name": "Paris"})
rec.install(client)
- loc = await client.get_location(9)
+ loc = client.get_location(9)
assert loc.id == 9
assert rec.calls[0]["endpoint"] == "Dropdowns/Location/9"
-async def test_update_location(client: GlpiClient) -> None:
+def test_update_location(client: GlpiClient) -> None:
"""``update_location`` patches the per-id endpoint."""
rec = _Recorder()
rec.install(client)
- await client.update_location(9, PatchLocation(name="Paris HQ"))
+ client.update_location(9, PatchLocation(name="Paris HQ"))
assert rec.calls[0]["endpoint"] == "Dropdowns/Location/9"
-async def test_delete_location_with_force(client: GlpiClient) -> None:
+def test_delete_location_with_force(client: GlpiClient) -> None:
"""``delete_location(force=True)`` ships the force flag in the body."""
rec = _Recorder()
rec.install(client)
- await client.delete_location(9, force=True)
+ client.delete_location(9, force=True)
call = rec.calls[0]
assert call["method"] == "DELETE"
assert call["endpoint"] == "Dropdowns/Location/9"
@@ -273,55 +273,55 @@ async def test_delete_location_with_force(client: GlpiClient) -> None:
# ---------------------------------------------------------------------------
-async def test_search_entities_skips_entity_header(client: GlpiClient) -> None:
+def test_search_entities_skips_entity_header(client: GlpiClient) -> None:
"""``search_entities`` skips the GLPI-Entity header."""
rec = _Recorder(get_payload=[{"id": 1, "name": "root"}])
rec.install(client)
- entities = await client.search_entities("name==root", limit=None, start=0)
+ entities = client.search_entities("name==root", limit=None, start=0)
assert entities[0].id == 1
assert rec.calls[0]["skip_entity"] is True
assert "limit" not in rec.calls[0]["params"]
-async def test_get_entity_skips_entity_header(client: GlpiClient) -> None:
+def test_get_entity_skips_entity_header(client: GlpiClient) -> None:
"""``get_entity`` also bypasses the entity header."""
rec = _Recorder(get_payload={"id": 2, "name": "root"})
rec.install(client)
- entity = await client.get_entity(2)
+ entity = client.get_entity(2)
assert entity.id == 2
assert rec.calls[0]["endpoint"] == "Administration/Entity/2"
assert rec.calls[0]["skip_entity"] is True
-async def test_update_entity_patch(client: GlpiClient) -> None:
+def test_update_entity_patch(client: GlpiClient) -> None:
"""``update_entity`` patches the per-id endpoint."""
rec = _Recorder()
rec.install(client)
- await client.update_entity(2, PatchEntity(name="renamed"))
+ client.update_entity(2, PatchEntity(name="renamed"))
assert rec.calls[0]["endpoint"] == "Administration/Entity/2"
-async def test_delete_entity_with_force(client: GlpiClient) -> None:
+def test_delete_entity_with_force(client: GlpiClient) -> None:
"""``delete_entity(force=True)`` ships the force flag and skips entity."""
rec = _Recorder()
rec.install(client)
- await client.delete_entity(2, force=True)
+ client.delete_entity(2, force=True)
call = rec.calls[0]
assert call["endpoint"] == "Administration/Entity/2"
assert call["json"] == {"force": True}
assert call["skip_entity"] is True
-async def test_create_entity_id_returned(client: GlpiClient) -> None:
+def test_create_entity_id_returned(client: GlpiClient) -> None:
"""``create_entity`` returns the newly created identifier."""
rec = _Recorder(post_payload={"id": 42})
rec.install(client)
- entity_id = await client.create_entity(PostEntity(name="root"))
+ entity_id = client.create_entity(PostEntity(name="root"))
assert entity_id == 42
assert rec.calls[0]["endpoint"] == "Administration/Entity"
assert rec.calls[0]["skip_entity"] is True
@@ -332,12 +332,12 @@ async def test_create_entity_id_returned(client: GlpiClient) -> None:
# ---------------------------------------------------------------------------
-async def test_search_documents_filter_and_pagination(client: GlpiClient) -> None:
+def test_search_documents_filter_and_pagination(client: GlpiClient) -> None:
"""``search_documents`` forwards the filter, limit, start, and skip_entity."""
rec = _Recorder(get_payload=[{"id": 1, "name": "doc"}])
rec.install(client)
- docs = await client.search_documents("name==*manual*", limit=10, start=20)
+ docs = client.search_documents("name==*manual*", limit=10, start=20)
assert len(docs) == 1
call = rec.calls[0]
assert call["endpoint"] == "Management/Document"
@@ -347,77 +347,77 @@ async def test_search_documents_filter_and_pagination(client: GlpiClient) -> Non
assert call["params"]["filter"] == "name==*manual*"
-async def test_get_document_endpoint(client: GlpiClient) -> None:
+def test_get_document_endpoint(client: GlpiClient) -> None:
"""``get_document`` hits the per-id endpoint."""
rec = _Recorder(get_payload={"id": 3, "name": "doc"})
rec.install(client)
- document = await client.get_document(3)
+ document = client.get_document(3)
assert document.id == 3
assert rec.calls[0]["endpoint"] == "Management/Document/3"
-async def test_create_document_returns_id(client: GlpiClient) -> None:
+def test_create_document_returns_id(client: GlpiClient) -> None:
"""``create_document`` returns the new id and skips entity."""
rec = _Recorder(post_payload={"id": 77})
rec.install(client)
- document_id = await client.create_document(PostDocument(name="manual"))
+ document_id = client.create_document(PostDocument(name="manual"))
assert document_id == 77
assert rec.calls[0]["endpoint"] == "Management/Document"
assert rec.calls[0]["skip_entity"] is True
-async def test_update_document_patches_endpoint(client: GlpiClient) -> None:
+def test_update_document_patches_endpoint(client: GlpiClient) -> None:
"""``update_document`` issues PATCH on the per-id endpoint."""
rec = _Recorder()
rec.install(client)
- await client.update_document(3, PatchDocument(name="x"))
+ client.update_document(3, PatchDocument(name="x"))
assert rec.calls[0]["endpoint"] == "Management/Document/3"
-async def test_delete_document_with_force(client: GlpiClient) -> None:
+def test_delete_document_with_force(client: GlpiClient) -> None:
"""``delete_document(force=True)`` adds the body and skips entity."""
rec = _Recorder()
rec.install(client)
- await client.delete_document(3, force=True)
+ client.delete_document(3, force=True)
call = rec.calls[0]
assert call["endpoint"] == "Management/Document/3"
assert call["json"] == {"force": True}
assert call["skip_entity"] is True
-async def test_download_document_returns_bytes(client: GlpiClient) -> None:
+def test_download_document_returns_bytes(client: GlpiClient) -> None:
"""``download_document_content`` returns the response bytes."""
rec = _Recorder(
get_status=200, get_payload={"ignored": True}, get_content=b"\x00ZZ"
)
rec.install(client)
- content = await client.download_document_content(3)
+ content = client.download_document_content(3)
assert content == b"\x00ZZ"
assert rec.calls[0]["endpoint"] == "Management/Document/3/Download"
-async def test_download_document_raises_on_failure(client: GlpiClient) -> None:
+def test_download_document_raises_on_failure(client: GlpiClient) -> None:
"""A non-200 download status raises ``ValueError``."""
rec = _Recorder(get_status=404, get_payload={"err": "missing"})
rec.install(client)
with pytest.raises(ValueError):
- await client.download_document_content(3)
+ client.download_document_content(3)
-async def test_upload_document_requires_filename(client: GlpiClient) -> None:
+def test_upload_document_requires_filename(client: GlpiClient) -> None:
"""``upload_document`` rejects an empty filename before any HTTP call."""
with pytest.raises(ValueError, match="filename"):
- await client.upload_document(filename="", content=b"x")
+ client.upload_document(filename="", content=b"x")
-async def test_upload_document_dispatches_to_v1(client: GlpiClient) -> None:
+def test_upload_document_dispatches_to_v1(client: GlpiClient) -> None:
"""``upload_document`` forwards arguments to the configured v1 session."""
captured: dict[str, Any] = {}
@@ -446,7 +446,7 @@ def upload_document(
return {"id": 1}
client._v1 = _FakeV1() # type: ignore[assignment]
- result = await client.upload_document(
+ result = client.upload_document(
filename="a.txt",
content=b"abc",
mime_type="text/plain",
@@ -466,7 +466,7 @@ def upload_document(
# ---------------------------------------------------------------------------
-async def test_list_ticket_followups_unwraps_envelope(client: GlpiClient) -> None:
+def test_list_ticket_followups_unwraps_envelope(client: GlpiClient) -> None:
"""Live envelope ``{"type":..,"item":..}`` entries are unwrapped."""
rec = _Recorder(
@@ -476,40 +476,40 @@ async def test_list_ticket_followups_unwraps_envelope(client: GlpiClient) -> Non
]
)
rec.install(client)
- items = await client.list_ticket_followups(7)
+ items = client.list_ticket_followups(7)
assert [i.id for i in items] == [11, 12]
assert rec.calls[0]["endpoint"] == "Assistance/Ticket/7/Timeline/Followup"
-async def test_get_ticket_followup_endpoint(client: GlpiClient) -> None:
+def test_get_ticket_followup_endpoint(client: GlpiClient) -> None:
"""``get_ticket_followup`` hits the per-id endpoint."""
rec = _Recorder(get_payload={"id": 11, "content": "x"})
rec.install(client)
- followup = await client.get_ticket_followup(7, 11)
+ followup = client.get_ticket_followup(7, 11)
assert followup.id == 11
assert rec.calls[0]["endpoint"] == "Assistance/Ticket/7/Timeline/Followup/11"
-async def test_update_ticket_followup_patch(client: GlpiClient) -> None:
+def test_update_ticket_followup_patch(client: GlpiClient) -> None:
"""``update_ticket_followup`` patches the per-id endpoint."""
rec = _Recorder()
rec.install(client)
- await client.update_ticket_followup(7, 11, PatchFollowup(content="up
"))
+ client.update_ticket_followup(7, 11, PatchFollowup(content="up
"))
assert rec.calls[0]["endpoint"] == "Assistance/Ticket/7/Timeline/Followup/11"
-async def test_delete_ticket_followup_force(client: GlpiClient) -> None:
+def test_delete_ticket_followup_force(client: GlpiClient) -> None:
"""``delete_ticket_followup(force=True)`` adds the body."""
rec = _Recorder()
rec.install(client)
- await client.delete_ticket_followup(7, 11, force=True)
+ client.delete_ticket_followup(7, 11, force=True)
assert rec.calls[0]["json"] == {"force": True}
-async def test_list_get_update_delete_ticket_tasks(client: GlpiClient) -> None:
+def test_list_get_update_delete_ticket_tasks(client: GlpiClient) -> None:
"""All four task helpers target the task timeline endpoint."""
rec = _Recorder(
@@ -518,17 +518,17 @@ async def test_list_get_update_delete_ticket_tasks(client: GlpiClient) -> None:
]
)
rec.install(client)
- tasks = await client.list_ticket_tasks(7)
+ tasks = client.list_ticket_tasks(7)
assert tasks[0].id == 1
assert rec.calls[0]["endpoint"] == "Assistance/Ticket/7/Timeline/Task"
rec.calls.clear()
rec._get_payload = {"id": 1, "content": "x"} # type: ignore[attr-defined]
- task = await client.get_ticket_task(7, 1)
+ task = client.get_ticket_task(7, 1)
assert task.id == 1
- await client.update_ticket_task(7, 1, PatchTicketTask(content="up
"))
- await client.delete_ticket_task(7, 1, force=True)
+ client.update_ticket_task(7, 1, PatchTicketTask(content="up
"))
+ client.delete_ticket_task(7, 1, force=True)
endpoints = [c["endpoint"] for c in rec.calls]
assert endpoints == [
@@ -538,7 +538,7 @@ async def test_list_get_update_delete_ticket_tasks(client: GlpiClient) -> None:
]
-async def test_list_get_update_delete_ticket_solutions(client: GlpiClient) -> None:
+def test_list_get_update_delete_ticket_solutions(client: GlpiClient) -> None:
"""All four solution helpers target the solution timeline endpoint."""
rec = _Recorder(
@@ -547,15 +547,15 @@ async def test_list_get_update_delete_ticket_solutions(client: GlpiClient) -> No
]
)
rec.install(client)
- sols = await client.list_ticket_solutions(7)
+ sols = client.list_ticket_solutions(7)
assert sols[0].id == 1
rec._get_payload = {"id": 1, "content": "x"} # type: ignore[attr-defined]
- sol = await client.get_ticket_solution(7, 1)
+ sol = client.get_ticket_solution(7, 1)
assert sol.id == 1
- await client.update_ticket_solution(7, 1, PatchSolution(content="up
"))
- await client.delete_ticket_solution(7, 1, force=True)
+ client.update_ticket_solution(7, 1, PatchSolution(content="up
"))
+ client.delete_ticket_solution(7, 1, force=True)
methods = [c["method"] for c in rec.calls]
assert methods == ["GET", "GET", "PATCH", "DELETE"]
@@ -565,7 +565,7 @@ async def test_list_get_update_delete_ticket_solutions(client: GlpiClient) -> No
assert any("Solution" in e for e in endpoints)
-async def test_list_get_update_unlink_timeline_documents(client: GlpiClient) -> None:
+def test_list_get_update_unlink_timeline_documents(client: GlpiClient) -> None:
"""All four timeline document helpers target the document endpoint."""
rec = _Recorder(
@@ -574,16 +574,16 @@ async def test_list_get_update_unlink_timeline_documents(client: GlpiClient) ->
]
)
rec.install(client)
- items = await client.list_ticket_timeline_documents(7)
+ items = client.list_ticket_timeline_documents(7)
assert items[0].id == 1
assert rec.calls[0]["endpoint"] == "Assistance/Ticket/7/Timeline/Document"
rec._get_payload = {"id": 1, "documents_id": 99} # type: ignore[attr-defined]
- doc = await client.get_ticket_timeline_document(7, 1)
+ doc = client.get_ticket_timeline_document(7, 1)
assert doc.id == 1
- await client.update_ticket_timeline_document(7, 1, PatchTimelineDocument())
- await client.unlink_ticket_timeline_document(7, 1, force=True)
+ client.update_ticket_timeline_document(7, 1, PatchTimelineDocument())
+ client.unlink_ticket_timeline_document(7, 1, force=True)
methods = [c["method"] for c in rec.calls]
assert methods == ["GET", "GET", "PATCH", "DELETE"]
@@ -594,22 +594,22 @@ async def test_list_get_update_unlink_timeline_documents(client: GlpiClient) ->
# ---------------------------------------------------------------------------
-async def test_list_ticket_team_members_endpoint(client: GlpiClient) -> None:
+def test_list_ticket_team_members_endpoint(client: GlpiClient) -> None:
"""``list_ticket_team_members`` hits the team-member endpoint."""
rec = _Recorder(get_payload=[{"id": 1, "type": "User", "role": "assigned"}])
rec.install(client)
- members = await client.list_ticket_team_members(7)
+ members = client.list_ticket_team_members(7)
assert members[0].id == 1
assert rec.calls[0]["endpoint"] == "Assistance/Ticket/7/TeamMember"
-async def test_remove_ticket_team_member_uses_delete(client: GlpiClient) -> None:
+def test_remove_ticket_team_member_uses_delete(client: GlpiClient) -> None:
"""``remove_ticket_team_member`` issues DELETE with the member body."""
rec = _Recorder()
rec.install(client)
- await client.remove_ticket_team_member(
+ client.remove_ticket_team_member(
7, PostTeamMember(type="User", id=42, role="assigned")
)
@@ -643,7 +643,7 @@ async def test_remove_ticket_team_member_uses_delete(client: GlpiClient) -> None
lambda c: c.list_ticket_timeline_documents(1),
],
)
-async def test_get_helpers_raise_on_failure_status(
+def test_get_helpers_raise_on_failure_status(
client: GlpiClient, call: Callable[[GlpiClient], Any]
) -> None:
"""Every read helper raises ``ValueError`` on a non-success status."""
@@ -651,7 +651,7 @@ async def test_get_helpers_raise_on_failure_status(
rec = _Recorder(get_status=404, get_payload={"err": "missing"})
rec.install(client)
with pytest.raises(ValueError):
- await call(client)
+ call(client)
@pytest.mark.parametrize(
@@ -668,7 +668,7 @@ async def test_get_helpers_raise_on_failure_status(
lambda c: c.update_ticket_timeline_document(1, 2, PatchTimelineDocument()),
],
)
-async def test_update_helpers_raise_on_failure_status(
+def test_update_helpers_raise_on_failure_status(
client: GlpiClient, call: Callable[[GlpiClient], Any]
) -> None:
"""Every update helper raises ``ValueError`` on a non-success status."""
@@ -676,7 +676,7 @@ async def test_update_helpers_raise_on_failure_status(
rec = _Recorder(patch_status=500)
rec.install(client)
with pytest.raises(ValueError):
- await call(client)
+ call(client)
@pytest.mark.parametrize(
@@ -696,7 +696,7 @@ async def test_update_helpers_raise_on_failure_status(
),
],
)
-async def test_delete_helpers_raise_on_failure_status(
+def test_delete_helpers_raise_on_failure_status(
client: GlpiClient, call: Callable[[GlpiClient], Any]
) -> None:
"""Every delete helper raises ``ValueError`` on a non-success status."""
@@ -704,4 +704,4 @@ async def test_delete_helpers_raise_on_failure_status(
rec = _Recorder(delete_status=500)
rec.install(client)
with pytest.raises(ValueError):
- await call(client)
+ call(client)
diff --git a/glpi_python_client/clients/tests/test_async_branches.py b/glpi_python_client/clients/tests/test_async_branches.py
new file mode 100644
index 0000000..c48c4fb
--- /dev/null
+++ b/glpi_python_client/clients/tests/test_async_branches.py
@@ -0,0 +1,137 @@
+"""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
+from glpi_python_client.testing.utils import FakeResponse, make_async_client
+
+
+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()
diff --git a/glpi_python_client/clients/tests/test_async_smoke.py b/glpi_python_client/clients/tests/test_async_smoke.py
new file mode 100644
index 0000000..b605091
--- /dev/null
+++ b/glpi_python_client/clients/tests/test_async_smoke.py
@@ -0,0 +1,150 @@
+"""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_glpi_client.py b/glpi_python_client/clients/tests/test_glpi_client.py
index a5caeed..51bfe6a 100644
--- a/glpi_python_client/clients/tests/test_glpi_client.py
+++ b/glpi_python_client/clients/tests/test_glpi_client.py
@@ -124,7 +124,7 @@ def test_build_client_env_config_overrides_win() -> None:
assert config["language"] == "en_GB"
-async def test_glpi_client_from_env_uses_overrides_and_defaults(
+def test_glpi_client_from_env_uses_overrides_and_defaults(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``GlpiClient.from_env`` resolves env vars and applies overrides."""
@@ -138,10 +138,10 @@ async def test_glpi_client_from_env_uses_overrides_and_defaults(
try:
assert client.glpi_api_url.endswith("/api.php/v2")
finally:
- await client.close()
+ client.close()
-async def test_glpi_client_close_is_idempotent() -> None:
+def test_glpi_client_close_is_idempotent() -> None:
"""Calling ``close`` twice does not raise."""
client = GlpiClient(
@@ -149,14 +149,14 @@ async def test_glpi_client_close_is_idempotent() -> None:
username="u",
password="p",
)
- await client.close()
- await client.close()
+ client.close()
+ client.close()
-async def test_glpi_client_async_context_manager() -> None:
+def test_glpi_client_async_context_manager() -> None:
"""Using ``async with`` closes the client on exit."""
- async with GlpiClient(
+ with GlpiClient(
glpi_api_url="https://glpi.example.test/api.php/v2",
username="u",
password="p",
@@ -164,7 +164,7 @@ async def test_glpi_client_async_context_manager() -> None:
assert client.glpi_api_url.endswith("/api.php/v2")
# After __aexit__ the client is closed and rejects further calls.
with pytest.raises(RuntimeError, match="closed"):
- await client._ensure_token()
+ client._ensure_token()
def test_glpi_client_rejects_invalid_credentials() -> None:
@@ -174,7 +174,7 @@ def test_glpi_client_rejects_invalid_credentials() -> None:
GlpiClient(glpi_api_url="https://glpi.example.test/api.php/v2")
-async def test_glpi_client_v1_session_built_when_configured() -> None:
+def test_glpi_client_v1_session_built_when_configured() -> None:
"""Providing v1_base_url + v1_user_token instantiates the v1 session."""
client = GlpiClient(
@@ -188,7 +188,7 @@ async def test_glpi_client_v1_session_built_when_configured() -> None:
try:
assert client._v1 is not None
finally:
- await client.close()
+ client.close()
def test_glpi_client_rejects_partial_v1_config() -> None:
@@ -203,7 +203,7 @@ def test_glpi_client_rejects_partial_v1_config() -> None:
)
-async def test_environ_default_is_used_when_env_argument_omitted(
+def test_environ_default_is_used_when_env_argument_omitted(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When no env mapping is provided ``os.environ`` is used."""
@@ -215,10 +215,10 @@ async def test_environ_default_is_used_when_env_argument_omitted(
try:
assert client.glpi_api_url.endswith("/api.php/v2")
finally:
- await client.close()
+ client.close()
-async def test_async_transport_ensure_open_blocks_after_close() -> None:
+def test_async_transport_ensure_open_blocks_after_close() -> None:
"""Closed clients raise on subsequent transport calls."""
client = GlpiClient(
@@ -226,7 +226,7 @@ async def test_async_transport_ensure_open_blocks_after_close() -> None:
username="u",
password="p",
)
- await client.close()
+ client.close()
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
new file mode 100644
index 0000000..c95adde
--- /dev/null
+++ b/glpi_python_client/clients/tests/test_parity.py
@@ -0,0 +1,64 @@
+"""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 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"
+ )
+
+
+def test_async_endpoint_methods_are_coroutine_functions() -> None:
+ """Every public async method is a coroutine function."""
+
+ for name in _public_callable_names(AsyncGlpiClient):
+ member = getattr(AsyncGlpiClient, name)
+ assert inspect.iscoroutinefunction(member), (
+ f"AsyncGlpiClient.{name} should be a coroutine"
+ )
+
+
+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/clients/tests/test_smoke.py b/glpi_python_client/clients/tests/test_smoke.py
index 1de150f..a4dd0c2 100644
--- a/glpi_python_client/clients/tests/test_smoke.py
+++ b/glpi_python_client/clients/tests/test_smoke.py
@@ -36,7 +36,7 @@ def __init__(self) -> None:
def install(self, client: GlpiClient) -> None:
"""Replace the transport methods on ``client`` with recording stubs."""
- async def _get(
+ def _get(
endpoint: str,
params: dict[str, Any] | None = None,
skip_entity: bool = False,
@@ -51,7 +51,7 @@ async def _get(
)
return FakeResponse(status_code=200, payload=self._next_get_payload())
- async def _post(
+ def _post(
endpoint: str,
json_body: dict[str, Any] | None = None,
skip_entity: bool = False,
@@ -66,7 +66,7 @@ async def _post(
)
return FakeResponse(status_code=201, payload={"id": 999})
- async def _patch(
+ def _patch(
endpoint: str, json_body: dict[str, Any] | None = None
) -> FakeResponse:
self.calls.append(
@@ -74,7 +74,7 @@ async def _patch(
)
return FakeResponse(status_code=204, payload={})
- async def _delete(
+ def _delete(
endpoint: str,
json_body: dict[str, Any] | None = None,
skip_entity: bool = False,
@@ -116,12 +116,12 @@ def recorder(client: GlpiClient) -> _Recorder:
return rec
-async def test_create_user_serialises_post_body(
+def test_create_user_serialises_post_body(
client: GlpiClient, recorder: _Recorder
) -> None:
"""``create_user`` serialises the ``PostUser`` model into the POST body."""
- user_id = await client.create_user(PostUser(username="alice"))
+ user_id = client.create_user(PostUser(username="alice"))
assert user_id == 999
assert recorder.calls == [
{
@@ -133,12 +133,12 @@ async def test_create_user_serialises_post_body(
]
-async def test_search_tickets_uses_filter_query_param(
+def test_search_tickets_uses_filter_query_param(
client: GlpiClient, recorder: _Recorder
) -> None:
"""``search_tickets`` forwards the RSQL filter via the ``filter`` parameter."""
- tickets = await client.search_tickets(rsql_filter="status==1", limit=20)
+ tickets = client.search_tickets(rsql_filter="status==1", limit=20)
assert len(tickets) == 1
assert recorder.calls[0]["method"] == "GET"
assert recorder.calls[0]["endpoint"] == "Assistance/Ticket"
@@ -146,55 +146,55 @@ async def test_search_tickets_uses_filter_query_param(
assert recorder.calls[0]["params"]["limit"] == 20
-async def test_create_ticket_followup_targets_timeline_endpoint(
+def test_create_ticket_followup_targets_timeline_endpoint(
client: GlpiClient, recorder: _Recorder
) -> None:
"""``create_ticket_followup`` posts to the ticket timeline endpoint."""
- await client.create_ticket_followup(7, PostFollowup(content="hi
"))
+ client.create_ticket_followup(7, PostFollowup(content="hi
"))
call = recorder.calls[0]
assert call["endpoint"] == "Assistance/Ticket/7/Timeline/Followup"
assert call["json"] == {"content": "hi
"}
-async def test_create_ticket_task_uses_task_endpoint(
+def test_create_ticket_task_uses_task_endpoint(
client: GlpiClient, recorder: _Recorder
) -> None:
"""``create_ticket_task`` targets the ticket task timeline endpoint."""
- await client.create_ticket_task(8, PostTicketTask(content="task", duration=120))
+ client.create_ticket_task(8, PostTicketTask(content="task", duration=120))
call = recorder.calls[0]
assert call["endpoint"] == "Assistance/Ticket/8/Timeline/Task"
assert call["json"] == {"content": "task
", "duration": 120}
-async def test_create_ticket_solution_uses_solution_endpoint(
+def test_create_ticket_solution_uses_solution_endpoint(
client: GlpiClient, recorder: _Recorder
) -> None:
"""``create_ticket_solution`` targets the ticket solution endpoint."""
- await client.create_ticket_solution(9, PostSolution(content="ok"))
+ client.create_ticket_solution(9, PostSolution(content="ok"))
call = recorder.calls[0]
assert call["endpoint"] == "Assistance/Ticket/9/Timeline/Solution"
-async def test_link_ticket_timeline_document_targets_document_endpoint(
+def test_link_ticket_timeline_document_targets_document_endpoint(
client: GlpiClient, recorder: _Recorder
) -> None:
"""``link_ticket_timeline_document`` targets the document timeline endpoint."""
- await client.link_ticket_timeline_document(10, PostTimelineDocument())
+ client.link_ticket_timeline_document(10, PostTimelineDocument())
call = recorder.calls[0]
assert call["endpoint"] == "Assistance/Ticket/10/Timeline/Document"
assert call["json"] == {}
-async def test_add_ticket_team_member_targets_team_endpoint(
+def test_add_ticket_team_member_targets_team_endpoint(
client: GlpiClient, recorder: _Recorder
) -> None:
"""``add_ticket_team_member`` posts to the ticket team-member endpoint."""
- await client.add_ticket_team_member(
+ client.add_ticket_team_member(
11, PostTeamMember(type="User", id=42, role="assigned")
)
@@ -203,57 +203,57 @@ async def test_add_ticket_team_member_targets_team_endpoint(
assert call["json"] == {"type": "User", "id": 42, "role": "assigned"}
-async def test_create_entity_skips_entity_header(
+def test_create_entity_skips_entity_header(
client: GlpiClient, recorder: _Recorder
) -> None:
"""Entity create requests bypass the GLPI-Entity header."""
from glpi_python_client import PostEntity
- await client.create_entity(PostEntity(name="root"))
+ client.create_entity(PostEntity(name="root"))
call = recorder.calls[0]
assert call["endpoint"] == "Administration/Entity"
assert call["skip_entity"] is True
-async def test_delete_user_supports_force_flag(
+def test_delete_user_supports_force_flag(
client: GlpiClient, recorder: _Recorder
) -> None:
"""``delete_user`` forwards the ``force`` flag inside the JSON body."""
- await client.delete_user(5, force=True)
+ client.delete_user(5, force=True)
call = recorder.calls[0]
assert call["method"] == "DELETE"
assert call["endpoint"] == "Administration/User/5"
assert call["json"] == {"force": True}
-async def test_create_location_targets_dropdown_endpoint(
+def test_create_location_targets_dropdown_endpoint(
client: GlpiClient, recorder: _Recorder
) -> None:
"""``create_location`` posts to the dropdown endpoint."""
- await client.create_location(PostLocation(name="Paris"))
+ client.create_location(PostLocation(name="Paris"))
call = recorder.calls[0]
assert call["endpoint"] == "Dropdowns/Location"
-async def test_upload_document_without_v1_raises(client: GlpiClient) -> None:
+def test_upload_document_without_v1_raises(client: GlpiClient) -> None:
"""``upload_document`` requires a v1 session to be configured."""
with pytest.raises(RuntimeError):
- await client.upload_document(
+ client.upload_document(
filename="a.bin",
content=b"x",
)
-async def test_create_ticket_serialises_enums(
+def test_create_ticket_serialises_enums(
client: GlpiClient, recorder: _Recorder
) -> None:
"""``create_ticket`` serialises enum values as their numeric form."""
- await client.create_ticket(PostTicket(name="t", content="c
"))
+ client.create_ticket(PostTicket(name="t", content="c
"))
call = recorder.calls[0]
assert call["endpoint"] == "Assistance/Ticket"
assert call["json"]["name"] == "t"
diff --git a/glpi_python_client/models/api_schema/administration/_entity.py b/glpi_python_client/models/api_schema/administration/_entity.py
index 2a9e924..a6b7d89 100644
--- a/glpi_python_client/models/api_schema/administration/_entity.py
+++ b/glpi_python_client/models/api_schema/administration/_entity.py
@@ -13,7 +13,28 @@
class GetEntity(GlpiModel):
"""Response shape returned by ``GET /Administration/Entity`` endpoints.
- Mirrors ``components.schemas.Entity``.
+ Mirrors ``components.schemas.Entity``. All fields are optional because
+ the contract does not advertise a ``required`` array.
+
+ Parameters
+ ----------
+ id : int | None, optional
+ Native GLPI identifier (contract field ``id`` — ``ID``,
+ ``readOnly``).
+ name : str | None, optional
+ Display name of the entity (contract field ``name`` — ``Name``).
+ comment : str | None, optional
+ Free-form comment associated with the entity (contract field
+ ``comment`` — ``Comment``).
+ completename : str | None, optional
+ Full hierarchical path of the entity in dotted notation (contract
+ field ``completename`` — ``Complete name``, ``readOnly``).
+ parent : IdNameRef | None, optional
+ Reference to the parent entity in the hierarchy (contract field
+ ``parent`` — ``Parent entity``).
+ level : int | None, optional
+ Depth of the entity in the hierarchy tree (contract field
+ ``level`` — ``Level``, ``readOnly``).
"""
id: int | None = None
@@ -28,7 +49,18 @@ class PostEntity(GlpiModel):
"""Request body for ``POST /Administration/Entity``.
Read-only contract fields (``id``, ``completename``, ``level``) are
- intentionally excluded.
+ intentionally excluded because the server rejects them on input.
+
+ Parameters
+ ----------
+ name : str | None, optional
+ Display name of the entity (contract field ``name`` — ``Name``).
+ comment : str | None, optional
+ Free-form comment associated with the entity (contract field
+ ``comment`` — ``Comment``).
+ parent : IdNameRef | None, optional
+ Reference to the parent entity in the hierarchy (contract field
+ ``parent`` — ``Parent entity``).
"""
name: str | None = None
@@ -37,7 +69,12 @@ class PostEntity(GlpiModel):
class PatchEntity(PostEntity):
- """Request body for ``PATCH /Administration/Entity/{id}``."""
+ """Request body for ``PATCH /Administration/Entity/{id}``.
+
+ The contract uses the same ``Entity`` schema for create and
+ partial-update bodies; ``PatchEntity`` is kept distinct so client
+ mixins can express the intent of the operation explicitly.
+ """
class DeleteEntity(GlpiModel):
@@ -46,7 +83,10 @@ class DeleteEntity(GlpiModel):
Parameters
----------
force : bool | None, optional
- Permanently delete the entity instead of moving it to the trash.
+ When ``True``, permanently delete the entity instead of moving the
+ record to the GLPI trash. When ``False`` or :data:`None`, the
+ server applies its default soft-delete behaviour and the entity
+ can still be restored.
"""
force: bool | None = None
diff --git a/glpi_python_client/models/api_schema/administration/_user.py b/glpi_python_client/models/api_schema/administration/_user.py
index eb6b4e4..baa58c5 100644
--- a/glpi_python_client/models/api_schema/administration/_user.py
+++ b/glpi_python_client/models/api_schema/administration/_user.py
@@ -2,12 +2,18 @@
The field layout mirrors ``components.schemas.User`` from the GLPI OpenAPI
contract. Read-only contract fields are excluded from the request models.
+Sensitive credential fields (``password``, ``password2``) are wrapped in
+:class:`pydantic.SecretStr` so the values are masked in logs, ``repr``
+output and tracebacks, while a dedicated field serializer unmasks them
+when the model is dumped into an outgoing request body.
"""
from __future__ import annotations
from datetime import datetime
+from pydantic import SecretStr, field_serializer
+
from glpi_python_client.models._base import GlpiModel
from glpi_python_client.models.api_schema._common import IdNameRef
from glpi_python_client.models.api_schema.enums import GlpiUserAuthType
@@ -16,16 +22,22 @@
class _EmailAddress(GlpiModel):
"""One e-mail entry of the ``User.emails`` array.
+ Mirrors the inline ``EmailAddress`` object embedded in
+ ``components.schemas.User.emails`` from the GLPI OpenAPI contract.
+
Parameters
----------
id : int | None, optional
- Native GLPI e-mail identifier.
+ Native GLPI identifier (contract field ``id`` — ``ID``).
email : str | None, optional
- E-mail address value.
+ E-mail address value (contract field ``email`` — ``Email address``).
is_default : bool | None, optional
- Whether the e-mail is the default address.
+ Whether this address is the user's default e-mail (contract field
+ ``is_default`` — ``Is default``).
is_dynamic : bool | None, optional
- Whether the e-mail is provisioned dynamically by GLPI.
+ Whether this address was provisioned dynamically by GLPI, for
+ example through an LDAP synchronisation (contract field
+ ``is_dynamic`` — ``Is dynamic``).
"""
id: int | None = None
@@ -37,8 +49,101 @@ class _EmailAddress(GlpiModel):
class GetUser(GlpiModel):
"""Response shape returned by ``GET /Administration/User`` endpoints.
- Mirrors ``components.schemas.User``. All fields are optional because the
- contract does not advertise a ``required`` array.
+ Mirrors ``components.schemas.User`` (GLPI description: ``Utilisateur``).
+ All fields are optional because the contract does not advertise a
+ ``required`` array; absent or ``null`` values from the server are
+ surfaced as :data:`None`. Credential fields are marked ``writeOnly`` in
+ the contract and are therefore never returned by GET endpoints, so they
+ are absent from this model.
+
+ Parameters
+ ----------
+ id : int | None, optional
+ Native GLPI identifier (contract field ``id`` — ``ID``,
+ ``readOnly``).
+ username : str | None, optional
+ Login name of the user (contract field ``username`` — ``Username``).
+ realname : str | None, optional
+ Family name (contract field ``realname`` — ``Real name``).
+ firstname : str | None, optional
+ Given name (contract field ``firstname`` — ``First name``).
+ phone : str | None, optional
+ Primary phone number (contract field ``phone`` — ``Phone number``).
+ phone2 : str | None, optional
+ Secondary phone number (contract field ``phone2`` —
+ ``Phone number 2``).
+ mobile : str | None, optional
+ Mobile phone number (contract field ``mobile`` —
+ ``Mobile phone number``).
+ emails : list[_EmailAddress] | None, optional
+ Collection of e-mail addresses attached to the user (contract field
+ ``emails`` — ``Email addresses``).
+ comment : str | None, optional
+ Free-form comment associated with the user (contract field
+ ``comment`` — ``Comment``).
+ is_active : bool | None, optional
+ Whether the account is currently active (contract field
+ ``is_active`` — ``Is active``).
+ is_deleted : bool | None, optional
+ Whether the account has been moved to the trash (contract field
+ ``is_deleted`` — ``Is deleted``).
+ picture : str | None, optional
+ Path or identifier of the avatar picture (contract field
+ ``picture``, ``readOnly``).
+ date_password_change : datetime | None, optional
+ Timestamp of the last password change (contract field
+ ``date_password_change`` — ``Date of last password change``,
+ ``readOnly``).
+ location : IdNameRef | None, optional
+ Reference to the user's default location (contract field
+ ``location`` — embedded ``Location`` object).
+ authtype : GlpiUserAuthType | None, optional
+ Authentication backend used for this account (contract field
+ ``authtype``). Numeric enumeration: ``1`` GLPI database, ``2``
+ Email, ``3`` LDAP, ``4`` External, ``5`` CAS, ``6`` X.509
+ Certificate.
+ last_login : datetime | None, optional
+ Timestamp of the user's last successful login (contract field
+ ``last_login``).
+ default_profile : IdNameRef | None, optional
+ Default profile assumed by the user at login (contract field
+ ``default_profile`` — ``Default profile``).
+ default_entity : IdNameRef | None, optional
+ Default entity scope assumed by the user at login (contract field
+ ``default_entity`` — ``Default entity``).
+ date_creation : datetime | None, optional
+ Creation timestamp of the user record (contract field
+ ``date_creation``).
+ date_mod : datetime | None, optional
+ Last modification timestamp of the user record (contract field
+ ``date_mod``).
+ date_sync : datetime | None, optional
+ Last synchronisation timestamp with the external directory
+ (contract field ``date_sync``, ``readOnly``).
+ title : IdNameRef | None, optional
+ Reference to the user's ``UserTitle`` (contract field ``title``;
+ no contract description).
+ category : IdNameRef | None, optional
+ Reference to the user's ``UserCategory`` (contract field
+ ``category``).
+ registration_number : str | None, optional
+ Free-form registration/employee number (contract field
+ ``registration_number``).
+ begin_date : datetime | None, optional
+ Beginning of the validity window for the account (contract field
+ ``begin_date`` — ``Valid since``).
+ end_date : datetime | None, optional
+ End of the validity window for the account (contract field
+ ``end_date`` — ``Valid until``).
+ nickname : str | None, optional
+ Display nickname for the user (contract field ``nickname``,
+ ``maxLength=50``).
+ substitution_start_date : datetime | None, optional
+ Start of the period during which this user is acting as a
+ substitute (contract field ``substitution_start_date``).
+ substitution_end_date : datetime | None, optional
+ End of the period during which this user is acting as a substitute
+ (contract field ``substitution_end_date``).
"""
id: int | None = None
@@ -75,8 +180,106 @@ class GetUser(GlpiModel):
class PostUser(GlpiModel):
"""Request body for ``POST /Administration/User``.
- Read-only contract fields (``id``, ``picture``, ``date_password_change``,
- ``date_sync``) are intentionally excluded.
+ Mirrors ``components.schemas.User`` for create operations. Read-only
+ contract fields (``id``, ``picture``, ``date_password_change``,
+ ``date_sync``) are intentionally excluded because the server rejects
+ them on input. The contract marks ``password`` and ``password2`` as
+ ``writeOnly``: they are accepted on create/update but never returned by
+ GET endpoints.
+
+ The two credential fields are typed as :class:`pydantic.SecretStr` so
+ that the cleartext values do not leak through ``repr``, log records,
+ structured tracebacks or interactive debuggers. Their serializer
+ unmasks the value via :meth:`SecretStr.get_secret_value` when the model
+ is dumped into the outgoing JSON request body, so the API still
+ receives the plain credential as it expects.
+
+ Parameters
+ ----------
+ username : str | None, optional
+ Login name of the user (contract field ``username`` — ``Username``).
+ realname : str | None, optional
+ Family name (contract field ``realname`` — ``Real name``).
+ firstname : str | None, optional
+ Given name (contract field ``firstname`` — ``First name``).
+ phone : str | None, optional
+ Primary phone number (contract field ``phone`` — ``Phone number``).
+ phone2 : str | None, optional
+ Secondary phone number (contract field ``phone2`` —
+ ``Phone number 2``).
+ mobile : str | None, optional
+ Mobile phone number (contract field ``mobile`` —
+ ``Mobile phone number``).
+ emails : list[_EmailAddress] | None, optional
+ Collection of e-mail addresses attached to the user (contract field
+ ``emails`` — ``Email addresses``).
+ comment : str | None, optional
+ Free-form comment associated with the user (contract field
+ ``comment`` — ``Comment``).
+ is_active : bool | None, optional
+ Whether the account should be created in the active state
+ (contract field ``is_active`` — ``Is active``).
+ is_deleted : bool | None, optional
+ Whether the account should be created in the trashed state
+ (contract field ``is_deleted`` — ``Is deleted``).
+ password : SecretStr | None, optional
+ Cleartext password to assign to the account (contract field
+ ``password`` — ``Password``, ``format=password``, ``writeOnly``).
+ Wrapped in :class:`SecretStr`; the value is unmasked only when the
+ request body is serialised.
+ password2 : SecretStr | None, optional
+ Confirmation of :attr:`password`; the GLPI server validates that
+ both fields match before persisting the account (contract field
+ ``password2`` — ``Password confirmation``, ``format=password``,
+ ``writeOnly``). Wrapped in :class:`SecretStr`; unmasked only on
+ serialisation.
+ location : IdNameRef | None, optional
+ Reference to the user's default location (contract field
+ ``location`` — embedded ``Location`` object).
+ authtype : GlpiUserAuthType | None, optional
+ Authentication backend used for this account (contract field
+ ``authtype``). Numeric enumeration: ``1`` GLPI database, ``2``
+ Email, ``3`` LDAP, ``4`` External, ``5`` CAS, ``6`` X.509
+ Certificate.
+ last_login : datetime | None, optional
+ Timestamp of the user's last successful login (contract field
+ ``last_login``).
+ default_profile : IdNameRef | None, optional
+ Default profile assumed by the user at login (contract field
+ ``default_profile`` — ``Default profile``).
+ default_entity : IdNameRef | None, optional
+ Default entity scope assumed by the user at login (contract field
+ ``default_entity`` — ``Default entity``).
+ date_creation : datetime | None, optional
+ Creation timestamp to override on the user record (contract field
+ ``date_creation``).
+ date_mod : datetime | None, optional
+ Last modification timestamp to override on the user record
+ (contract field ``date_mod``).
+ title : IdNameRef | None, optional
+ Reference to the user's ``UserTitle`` (contract field ``title``;
+ no contract description).
+ category : IdNameRef | None, optional
+ Reference to the user's ``UserCategory`` (contract field
+ ``category``).
+ registration_number : str | None, optional
+ Free-form registration/employee number (contract field
+ ``registration_number``).
+ begin_date : datetime | None, optional
+ Beginning of the validity window for the account (contract field
+ ``begin_date`` — ``Valid since``).
+ end_date : datetime | None, optional
+ End of the validity window for the account (contract field
+ ``end_date`` — ``Valid until``).
+ nickname : str | None, optional
+ Display nickname for the user (contract field ``nickname``,
+ ``maxLength=50``).
+ substitution_start_date : datetime | None, optional
+ Start of the period during which this user is acting as a
+ substitute (contract field ``substitution_start_date``).
+ substitution_end_date : datetime | None, optional
+ End of the period during which this user is acting as a substitute
+ (contract field ``substitution_end_date``).
"""
username: str | None = None
@@ -89,8 +292,8 @@ class PostUser(GlpiModel):
comment: str | None = None
is_active: bool | None = None
is_deleted: bool | None = None
- password: str | None = None
- password2: str | None = None
+ password: SecretStr | None = None
+ password2: SecretStr | None = None
location: IdNameRef | None = None
authtype: GlpiUserAuthType | None = None
last_login: datetime | None = None
@@ -107,13 +310,32 @@ class PostUser(GlpiModel):
substitution_start_date: datetime | None = None
substitution_end_date: datetime | None = None
+ @field_serializer("password", "password2", when_used="always")
+ def _dump_secret(self, value: SecretStr | None) -> str | None:
+ """Unmask :class:`SecretStr` credentials when serialising the body.
+
+ Pydantic's default :class:`SecretStr` serializer emits the masked
+ placeholder (``'**********'``) for both ``mode='python'`` and
+ ``mode='json'``. The GLPI API expects the actual cleartext, so the
+ serializer returns :meth:`SecretStr.get_secret_value` for non-null
+ values and propagates :data:`None` unchanged so that
+ ``model_dump(exclude_none=True)`` keeps stripping the field when
+ the caller did not set a password.
+ """
+
+ if value is None:
+ return None
+ return value.get_secret_value()
+
class PatchUser(PostUser):
"""Request body for ``PATCH /Administration/User/{id}``.
The contract uses the same ``User`` schema for create and partial-update
bodies; ``PatchUser`` is kept distinct so client mixins can express the
- intent of the operation explicitly.
+ intent of the operation explicitly. All fields, including the
+ ``SecretStr``-wrapped ``password`` / ``password2`` pair inherited from
+ :class:`PostUser`, behave exactly as on create.
"""
@@ -123,7 +345,10 @@ class DeleteUser(GlpiModel):
Parameters
----------
force : bool | None, optional
- Permanently delete the user instead of moving it to the trash.
+ When ``True``, permanently delete the user instead of moving the
+ record to the GLPI trash. When ``False`` or :data:`None`, the
+ server applies its default soft-delete behaviour and the user can
+ still be restored.
"""
force: bool | None = None
diff --git a/glpi_python_client/models/api_schema/administration/tests/test_administration_schemas.py b/glpi_python_client/models/api_schema/administration/tests/test_administration_schemas.py
index 2136714..95c5336 100644
--- a/glpi_python_client/models/api_schema/administration/tests/test_administration_schemas.py
+++ b/glpi_python_client/models/api_schema/administration/tests/test_administration_schemas.py
@@ -7,6 +7,8 @@
from __future__ import annotations
+from pydantic import SecretStr
+
from glpi_python_client.models.api_schema.administration import (
DeleteEntity,
DeleteUser,
@@ -112,3 +114,32 @@ def test_delete_entity_default() -> None:
"""``DeleteEntity`` has an optional ``force`` flag."""
assert DeleteEntity().force is None
+
+
+def test_post_user_secret_serializer_unmasks_password() -> None:
+ """``_dump_secret`` returns plaintext when a non-None ``SecretStr`` is set."""
+
+ user = PostUser(
+ username="alice",
+ password=SecretStr("s3cr3t"),
+ password2=SecretStr("s3cr3t"),
+ )
+ payload = user.model_dump(exclude_none=True, exclude={"extra_payload"})
+ assert payload["password"] == "s3cr3t"
+ assert payload["password2"] == "s3cr3t"
+ # The repr must not expose the value.
+ assert "s3cr3t" not in repr(user)
+
+
+def test_post_user_secret_serializer_none_propagates() -> None:
+ """``_dump_secret`` returns ``None`` when the credential field is ``None``.
+
+ ``model_dump`` is called without ``exclude_none`` so the serializer is
+ invoked with a ``None`` value rather than being skipped by Pydantic's
+ optimisation path.
+ """
+
+ user = PostUser(username="bob")
+ payload = user.model_dump(exclude={"extra_payload"})
+ assert payload.get("password") is None
+ assert payload.get("password2") is None
diff --git a/glpi_python_client/models/api_schema/assistance/_team.py b/glpi_python_client/models/api_schema/assistance/_team.py
index 23977c5..a1808e5 100644
--- a/glpi_python_client/models/api_schema/assistance/_team.py
+++ b/glpi_python_client/models/api_schema/assistance/_team.py
@@ -13,7 +13,21 @@
class GetTeamMember(GlpiModel):
"""Response shape returned by ``GET`` on ticket team-member endpoints.
- Mirrors ``components.schemas.TeamMember``.
+ Mirrors ``components.schemas.TeamMember``. No field carries a
+ ``description`` in the OpenAPI contract; the parameters below are
+ documented by field name and context.
+
+ Parameters
+ ----------
+ id : int | None, optional
+ Native GLPI identifier of the team-member link (``readOnly``).
+ name : str | None, optional
+ Display name of the actor (typically the user or group name).
+ type : str | None, optional
+ GLPI actor type, such as ``"User"`` or ``"Group"``.
+ role : str | None, optional
+ Ticket role assigned to the actor, such as ``"Requester"``,
+ ``"Observer"`` or ``"Assigned to"``.
"""
id: int | None = None
@@ -25,12 +39,20 @@ class GetTeamMember(GlpiModel):
class PostTeamMember(GlpiModel):
"""Request body for ``POST`` on ticket team-member endpoints.
- Notes
- -----
- Mirrors ``components.schemas.TeamMember`` minus the read-only ``name``
- field. The OpenAPI contract marks ``id`` as ``readOnly`` on the request
- body but the live GLPI server still requires the target user's ``id``
- to identify the team member, so we expose it here.
+ The contract marks ``id`` as ``readOnly`` on the schema definition,
+ but the live GLPI server still requires the target actor's ``id``
+ to identify the team member, so the field is exposed here.
+ No field carries a ``description`` in the OpenAPI contract.
+
+ Parameters
+ ----------
+ id : int | None, optional
+ GLPI identifier of the actor to add (user or group ``id``).
+ type : str | None, optional
+ GLPI actor type, such as ``"User"`` or ``"Group"``.
+ role : str | None, optional
+ Ticket role to assign to the actor, such as ``"Requester"``,
+ ``"Observer"`` or ``"Assigned to"``.
"""
id: int | None = None
@@ -39,15 +61,18 @@ class PostTeamMember(GlpiModel):
class PatchTeamMember(PostTeamMember):
- """Request body for ``PATCH`` on ticket team-member endpoints."""
+ """Request body for ``PATCH`` on ticket team-member endpoints.
+
+ Inherits all fields from :class:`PostTeamMember`.
+ """
class DeleteTeamMember(GlpiModel):
"""Placeholder body for ``DELETE`` on ticket team-member endpoints.
- The contract advertises the role/itemtype/user identifiers as path
- parameters and exposes no body or query parameters; this empty model is
- kept for parity with the rest of the ``api_schema`` package.
+ The contract advertises the role, itemtype and user identifiers as
+ path parameters and exposes no body or query parameters; this empty
+ model is kept for parity with the rest of the ``api_schema`` package.
"""
diff --git a/glpi_python_client/models/api_schema/assistance/_ticket.py b/glpi_python_client/models/api_schema/assistance/_ticket.py
index 14a0c5a..d4c6d72 100644
--- a/glpi_python_client/models/api_schema/assistance/_ticket.py
+++ b/glpi_python_client/models/api_schema/assistance/_ticket.py
@@ -34,16 +34,21 @@
class _TicketTeamMember(GlpiModel):
"""One inline team-member entry of the ``Ticket.team`` array.
+ The contract does not provide per-field ``description`` values for
+ this inline object. The parameters below are documented by field name
+ and context.
+
Parameters
----------
id : int | None, optional
- Native GLPI member identifier.
+ Native GLPI identifier of the team-member link.
name : str | None, optional
- Member display name.
+ Display name of the actor (typically the user or group name).
type : str | None, optional
- GLPI member type, such as ``"User"`` or ``"Group"``.
+ GLPI actor type, such as ``"User"`` or ``"Group"``.
role : str | None, optional
- GLPI ticket role assigned to the member.
+ Ticket role assigned to the actor, such as ``"Requester"``,
+ ``"Observer"`` or ``"Assigned to"``.
"""
id: int | None = None
@@ -55,7 +60,124 @@ class _TicketTeamMember(GlpiModel):
class GetTicket(GlpiModel):
"""Response shape returned by ``GET /Assistance/Ticket`` endpoints.
- Mirrors ``components.schemas.Ticket``.
+ Mirrors ``components.schemas.Ticket``. Fields marked ``readOnly`` in
+ the contract are excluded from the write models.
+
+ Parameters
+ ----------
+ id : int | None, optional
+ Native GLPI identifier (``readOnly``).
+ name : str | None, optional
+ Title of the ticket.
+ content : GlpiMarkdownContent
+ Body of the ticket exchanged as HTML over the wire
+ (``format: html``); transparent Markdown conversion is applied
+ on the model boundary. Defaults to :data:`None`.
+ user_recipient : IdNameRef | None, optional
+ Reference to the user designated as the ticket recipient (no
+ contract description).
+ user_editor : IdNameRef | None, optional
+ Reference to the user who last edited the ticket.
+ is_deleted : bool | None, optional
+ Whether the ticket has been moved to the trash.
+ category : IdNameRef | None, optional
+ Reference to the ITIL category of the ticket.
+ location : IdNameRef | None, optional
+ Reference to the location associated with the ticket.
+ urgency : GlpiPriority | None, optional
+ Urgency level (contract enumeration: ``1`` Very Low, ``2`` Low,
+ ``3`` Medium, ``4`` High, ``5`` Very High).
+ impact : GlpiPriority | None, optional
+ Impact level (contract enumeration: ``1`` Very Low, ``2`` Low,
+ ``3`` Medium, ``4`` High, ``5`` Very High).
+ priority : GlpiPriority | None, optional
+ Computed or manually overridden priority (contract enumeration:
+ ``1`` Very Low, ``2`` Low, ``3`` Medium, ``4`` High,
+ ``5`` Very High).
+ actiontime : int | None, optional
+ Total action time in seconds (``readOnly``).
+ begin_waiting_date : datetime | None, optional
+ Timestamp at which the ticket entered the pending state
+ (``readOnly``).
+ waiting_duration : int | None, optional
+ Total waiting duration in seconds (contract field
+ ``waiting_duration`` — ``Total waiting duration in seconds``,
+ ``readOnly``).
+ resolution_duration : int | None, optional
+ Total resolution duration in seconds (contract field
+ ``resolution_duration`` — ``Total resolution duration in
+ seconds``, ``readOnly``).
+ close_duration : int | None, optional
+ Total close duration in seconds (contract field
+ ``close_duration`` — ``Total close duration in seconds``,
+ ``readOnly``).
+ resolution_date : datetime | None, optional
+ Timestamp at which the ticket was resolved (``readOnly``).
+ date_creation : datetime | None, optional
+ Creation timestamp of the ticket record.
+ date_mod : datetime | None, optional
+ Last modification timestamp of the ticket record.
+ date : datetime | None, optional
+ Opening date of the ticket.
+ date_solve : datetime | None, optional
+ Date the ticket was marked as solved.
+ date_close : datetime | None, optional
+ Date the ticket was closed.
+ type : GlpiTicketType | None, optional
+ Ticket category (contract field ``type`` — ``The type of the
+ ticket``; enumeration: ``1`` Incident, ``2`` Request).
+ external_id : str | None, optional
+ Identifier assigned by an external system.
+ request_type : IdNameRef | None, optional
+ Reference to the request type or channel.
+ take_into_account_date : datetime | None, optional
+ Timestamp at which a technician first acknowledged the ticket
+ (``readOnly``).
+ take_into_account_duration : int | None, optional
+ Total take-into-account duration in seconds (contract field
+ ``take_into_account_duration`` — ``Total take into account
+ duration in seconds``, ``readOnly``).
+ sla_ttr : IdNameRef | None, optional
+ SLA applied for time-to-resolve.
+ sla_tto : IdNameRef | None, optional
+ SLA applied for time-to-own.
+ ola_ttr : IdNameRef | None, optional
+ OLA applied for time-to-resolve.
+ ola_tto : IdNameRef | None, optional
+ OLA applied for time-to-own.
+ sla_level_ttr : IdNameRef | None, optional
+ SLA level escalation step for time-to-resolve.
+ ola_level_ttr : IdNameRef | None, optional
+ OLA level escalation step for time-to-resolve.
+ sla_waiting_duration : int | None, optional
+ Total SLA waiting duration in seconds (contract field
+ ``sla_waiting_duration`` — ``Total SLA waiting duration in
+ seconds``, ``readOnly``).
+ ola_waiting_duration : int | None, optional
+ Total OLA waiting duration in seconds (contract field
+ ``ola_waiting_duration`` — ``Total OLA waiting duration in
+ seconds``, ``readOnly``).
+ ola_ttr_begin_date : datetime | None, optional
+ Timestamp at which the OLA TTR clock started (``readOnly``).
+ ola_tto_begin_date : datetime | None, optional
+ Timestamp at which the OLA TTO clock started (``readOnly``).
+ internal_resolution_date : datetime | None, optional
+ Internal resolution date used for OLA computations (``readOnly``;
+ no contract description).
+ internal_take_into_account_date : datetime | None, optional
+ Internal take-into-account date used for OLA computations
+ (``readOnly``).
+ global_validation : GlpiGlobalValidation | None, optional
+ Aggregated validation status of the ticket (contract field
+ ``global_validation`` — ``The global status of the
+ validation``; enumeration: ``1`` None, ``2`` Waiting,
+ ``3`` Accepted, ``4`` Refused).
+ status : IdNameRef | None, optional
+ Current ticket status as an id/name reference.
+ entity : IdNameCompletenameRef | None, optional
+ Reference to the GLPI entity that owns the ticket.
+ team : list[_TicketTeamMember] | None, optional
+ Inline list of actors assigned to the ticket.
"""
id: int | None = None
@@ -106,9 +228,68 @@ class GetTicket(GlpiModel):
class PostTicket(GlpiModel):
"""Request body for ``POST /Assistance/Ticket``.
- Read-only contract fields are excluded. ``status`` is read-only on the
- contract because GLPI manages ticket lifecycle through dedicated routes
- (followups, solutions, validation), so it is omitted from write models.
+ Read-only contract fields are excluded. ``status`` is also excluded
+ because GLPI manages the ticket lifecycle through dedicated timeline
+ routes (followups, solutions, validation).
+
+ Parameters
+ ----------
+ name : str | None, optional
+ Title of the ticket.
+ content : GlpiMarkdownContent
+ Body of the ticket; Markdown is converted to HTML on
+ serialisation (``format: html``). Defaults to :data:`None`.
+ is_deleted : bool | None, optional
+ Whether to create the ticket in the trashed state.
+ category : IdNameRef | None, optional
+ Reference to the ITIL category of the ticket.
+ location : IdNameRef | None, optional
+ Reference to the location associated with the ticket.
+ urgency : GlpiPriority | None, optional
+ Urgency level (contract enumeration: ``1`` Very Low, ``2`` Low,
+ ``3`` Medium, ``4`` High, ``5`` Very High).
+ impact : GlpiPriority | None, optional
+ Impact level (contract enumeration: ``1`` Very Low, ``2`` Low,
+ ``3`` Medium, ``4`` High, ``5`` Very High).
+ priority : GlpiPriority | None, optional
+ Priority override (contract enumeration: ``1`` Very Low, ``2``
+ Low, ``3`` Medium, ``4`` High, ``5`` Very High).
+ date_creation : datetime | None, optional
+ Creation timestamp to set on the ticket record.
+ date_mod : datetime | None, optional
+ Modification timestamp to set on the ticket record.
+ date : datetime | None, optional
+ Opening date to assign to the ticket.
+ date_solve : datetime | None, optional
+ Date to set as the solved timestamp.
+ date_close : datetime | None, optional
+ Date to set as the closed timestamp.
+ type : GlpiTicketType | None, optional
+ Ticket category (contract field ``type`` — ``The type of the
+ ticket``; enumeration: ``1`` Incident, ``2`` Request).
+ external_id : str | None, optional
+ Identifier assigned by an external system.
+ request_type : IdNameRef | None, optional
+ Reference to the request type or channel.
+ sla_ttr : IdNameRef | None, optional
+ SLA to apply for time-to-resolve.
+ sla_tto : IdNameRef | None, optional
+ SLA to apply for time-to-own.
+ ola_ttr : IdNameRef | None, optional
+ OLA to apply for time-to-resolve.
+ ola_tto : IdNameRef | None, optional
+ OLA to apply for time-to-own.
+ sla_level_ttr : IdNameRef | None, optional
+ SLA level escalation step for time-to-resolve.
+ ola_level_ttr : IdNameRef | None, optional
+ OLA level escalation step for time-to-resolve.
+ global_validation : GlpiGlobalValidation | None, optional
+ Aggregated validation status to set on the ticket (contract
+ field ``global_validation`` — ``The global status of the
+ validation``; enumeration: ``1`` None, ``2`` Waiting,
+ ``3`` Accepted, ``4`` Refused).
+ entity : IdNameCompletenameRef | None, optional
+ Reference to the GLPI entity that owns the ticket.
"""
name: str | None = None
@@ -138,7 +319,12 @@ class PostTicket(GlpiModel):
class PatchTicket(PostTicket):
- """Request body for ``PATCH /Assistance/Ticket/{id}``."""
+ """Request body for ``PATCH /Assistance/Ticket/{id}``.
+
+ The contract uses the same ``Ticket`` schema for create and
+ partial-update bodies; ``PatchTicket`` is kept distinct so client
+ mixins can express the intent of the operation explicitly.
+ """
class DeleteTicket(GlpiModel):
@@ -147,7 +333,10 @@ class DeleteTicket(GlpiModel):
Parameters
----------
force : bool | None, optional
- Permanently delete the ticket instead of moving it to the trash.
+ When ``True``, permanently delete the ticket instead of moving
+ the record to the GLPI trash. When ``False`` or :data:`None`,
+ the server applies its default soft-delete behaviour and the
+ ticket can still be restored.
"""
force: bool | None = None
diff --git a/glpi_python_client/models/api_schema/assistance/timeline/_document.py b/glpi_python_client/models/api_schema/assistance/timeline/_document.py
index a04a36e..da7d7d8 100644
--- a/glpi_python_client/models/api_schema/assistance/timeline/_document.py
+++ b/glpi_python_client/models/api_schema/assistance/timeline/_document.py
@@ -19,7 +19,29 @@
class GetTimelineDocument(GlpiModel):
"""Response shape returned by ``GET`` on ticket timeline document endpoints.
- Mirrors ``components.schemas.Document_Item``.
+ Mirrors ``components.schemas.Document_Item``. Most fields are marked
+ ``readOnly`` in the contract and are never accepted on write requests.
+ Only ``timeline_position`` is writable.
+
+ Parameters
+ ----------
+ id : int | None, optional
+ Native GLPI identifier of the document-item link (``readOnly``).
+ itemtype : str | None, optional
+ GLPI item type the document is attached to (``readOnly``).
+ items_id : int | None, optional
+ Identifier of the parent GLPI item (``readOnly``).
+ documents_id : int | None, optional
+ Identifier of the referenced ``Document`` record (``readOnly``;
+ no contract description).
+ filepath : str | None, optional
+ Server-managed storage path of the linked file (``readOnly``;
+ no contract description).
+ timeline_position : GlpiTimelinePosition | None, optional
+ Horizontal position of the document in the GLPI ticket timeline
+ widget (contract field ``timeline_position`` — ``The position
+ in the timeline``; enumeration: ``0`` No timeline, ``1`` Not
+ set, ``2`` Left, ``3`` Mid left, ``4`` Mid right, ``5`` Right).
"""
id: int | None = None
@@ -31,13 +53,30 @@ class GetTimelineDocument(GlpiModel):
class PostTimelineDocument(GlpiModel):
- """Request body for ``POST`` on ticket timeline document endpoints."""
+ """Request body for ``POST`` on ticket timeline document endpoints.
+
+ All read-only contract fields (``id``, ``itemtype``, ``items_id``,
+ ``documents_id``, ``filepath``) are excluded; only the writable
+ ``timeline_position`` field is exposed.
+
+ Parameters
+ ----------
+ timeline_position : GlpiTimelinePosition | None, optional
+ Horizontal position of the document in the GLPI ticket timeline
+ widget (contract field ``timeline_position`` — ``The position
+ in the timeline``; enumeration: ``0`` No timeline, ``1`` Not
+ set, ``2`` Left, ``3`` Mid left, ``4`` Mid right, ``5`` Right).
+ """
timeline_position: GlpiTimelinePosition | None = None
class PatchTimelineDocument(PostTimelineDocument):
- """Request body for ``PATCH`` on ticket timeline document endpoints."""
+ """Request body for ``PATCH`` on ticket timeline document endpoints.
+
+ Inherits the single writable field (``timeline_position``) from
+ :class:`PostTimelineDocument`.
+ """
class DeleteTimelineDocument(GlpiModel):
@@ -46,7 +85,10 @@ class DeleteTimelineDocument(GlpiModel):
Parameters
----------
force : bool | None, optional
- Permanently delete the link instead of moving it to the trash.
+ When ``True``, permanently delete the document-item link instead
+ of moving the record to the GLPI trash. When ``False`` or
+ :data:`None`, the server applies its default soft-delete
+ behaviour and the link can still be restored.
"""
force: bool | None = None
diff --git a/glpi_python_client/models/api_schema/assistance/timeline/_followup.py b/glpi_python_client/models/api_schema/assistance/timeline/_followup.py
index ee85fdf..bddf3bb 100644
--- a/glpi_python_client/models/api_schema/assistance/timeline/_followup.py
+++ b/glpi_python_client/models/api_schema/assistance/timeline/_followup.py
@@ -22,7 +22,48 @@
class GetFollowup(GlpiModel):
"""Response shape returned by ``GET`` on ticket timeline followup endpoints.
- Mirrors ``components.schemas.Followup``.
+ Mirrors ``components.schemas.Followup``. Most fields carry no
+ ``description`` in the contract; ``content`` (``format: html``) and
+ ``timeline_position`` are notable exceptions.
+
+ Parameters
+ ----------
+ id : int | None, optional
+ Native GLPI identifier of the followup (``readOnly``).
+ itemtype : str | None, optional
+ GLPI item type the followup belongs to, typically ``"Ticket"``.
+ items_id : int | None, optional
+ Identifier of the parent GLPI item.
+ content : GlpiMarkdownContent
+ Body of the followup exchanged as HTML over the wire
+ (``format: html``); transparent Markdown conversion is applied
+ on the model boundary. Defaults to :data:`None`.
+ is_private : bool | None, optional
+ Whether the followup is visible only to technicians.
+ user : IdNameRef | None, optional
+ Reference to the author of the followup.
+ user_editor : IdNameRef | None, optional
+ Reference to the user who last edited the followup.
+ request_type : IdNameRef | None, optional
+ Reference to the request type or channel of the followup (no
+ contract description).
+ date : datetime | None, optional
+ Date the followup was written.
+ date_creation : datetime | None, optional
+ Creation timestamp of the followup record.
+ date_mod : datetime | None, optional
+ Last modification timestamp of the followup record.
+ timeline_position : GlpiTimelinePosition | None, optional
+ Horizontal position of the followup in the GLPI ticket timeline
+ widget (contract field ``timeline_position`` — ``The position
+ in the timeline``; enumeration: ``0`` No timeline, ``1`` Not
+ set, ``2`` Left, ``3`` Mid left, ``4`` Mid right, ``5`` Right).
+ source_item_id : int | None, optional
+ Identifier of the source item that generated this followup, if
+ any.
+ source_of_item_id : int | None, optional
+ Identifier of the item for which this followup is a source (no
+ contract description).
"""
id: int | None = None
@@ -42,7 +83,48 @@ class GetFollowup(GlpiModel):
class PostFollowup(GlpiModel):
- """Request body for ``POST`` on ticket timeline followup endpoints."""
+ """Request body for ``POST`` on ticket timeline followup endpoints.
+
+ Read-only contract field (``id``) is excluded. All other fields from
+ ``components.schemas.Followup`` are writable.
+
+ Parameters
+ ----------
+ itemtype : str | None, optional
+ GLPI item type the followup belongs to, typically ``"Ticket"``.
+ items_id : int | None, optional
+ Identifier of the parent GLPI item.
+ content : GlpiMarkdownContent
+ Body of the followup; Markdown is converted to HTML on
+ serialisation (``format: html``). Defaults to :data:`None`.
+ is_private : bool | None, optional
+ Whether the followup is visible only to technicians.
+ user : IdNameRef | None, optional
+ Reference to the author of the followup.
+ user_editor : IdNameRef | None, optional
+ Reference to the user who last edited the followup.
+ request_type : IdNameRef | None, optional
+ Reference to the request type or channel of the followup (no
+ contract description).
+ date : datetime | None, optional
+ Date to assign to the followup.
+ date_creation : datetime | None, optional
+ Creation timestamp to set on the followup record.
+ date_mod : datetime | None, optional
+ Last modification timestamp to set on the followup record (no
+ contract description).
+ timeline_position : GlpiTimelinePosition | None, optional
+ Horizontal position of the followup in the GLPI ticket timeline
+ widget (contract field ``timeline_position`` — ``The position
+ in the timeline``; enumeration: ``0`` No timeline, ``1`` Not
+ set, ``2`` Left, ``3`` Mid left, ``4`` Mid right, ``5`` Right).
+ source_item_id : int | None, optional
+ Identifier of the source item that generated this followup, if
+ any.
+ source_of_item_id : int | None, optional
+ Identifier of the item for which this followup is a source (no
+ contract description).
+ """
itemtype: str | None = None
items_id: int | None = None
@@ -60,7 +142,10 @@ class PostFollowup(GlpiModel):
class PatchFollowup(PostFollowup):
- """Request body for ``PATCH`` on ticket timeline followup endpoints."""
+ """Request body for ``PATCH`` on ticket timeline followup endpoints.
+
+ Inherits all fields from :class:`PostFollowup`.
+ """
class DeleteFollowup(GlpiModel):
@@ -69,7 +154,10 @@ class DeleteFollowup(GlpiModel):
Parameters
----------
force : bool | None, optional
- Permanently delete the followup instead of moving it to the trash.
+ When ``True``, permanently delete the followup instead of moving
+ the record to the GLPI trash. When ``False`` or :data:`None`,
+ the server applies its default soft-delete behaviour and the
+ followup can still be restored.
"""
force: bool | None = None
diff --git a/glpi_python_client/models/api_schema/assistance/timeline/_solution.py b/glpi_python_client/models/api_schema/assistance/timeline/_solution.py
index eaf26f4..a5a106c 100644
--- a/glpi_python_client/models/api_schema/assistance/timeline/_solution.py
+++ b/glpi_python_client/models/api_schema/assistance/timeline/_solution.py
@@ -22,7 +22,44 @@
class GetSolution(GlpiModel):
"""Response shape returned by ``GET`` on ticket timeline solution endpoints.
- Mirrors ``components.schemas.Solution``.
+ Mirrors ``components.schemas.Solution``. Most fields carry no
+ ``description`` in the contract; ``status`` is a notable exception.
+
+ Parameters
+ ----------
+ id : int | None, optional
+ Native GLPI identifier of the solution (``readOnly``).
+ itemtype : str | None, optional
+ GLPI item type the solution belongs to, typically ``"Ticket"``.
+ items_id : int | None, optional
+ Identifier of the parent GLPI item.
+ type : IdNameRef | None, optional
+ Reference to the solution type.
+ content : GlpiMarkdownContent
+ Body of the solution exchanged as HTML over the wire
+ (``format: html``); transparent Markdown conversion is applied
+ on the model boundary. Defaults to :data:`None`.
+ user : IdNameRef | None, optional
+ Reference to the author of the solution.
+ user_editor : IdNameRef | None, optional
+ Reference to the user who last edited the solution.
+ approver : IdNameRef | None, optional
+ Reference to the user who approved or rejected the solution (no
+ contract description).
+ status : GlpiSolutionStatus | None, optional
+ Approval state of the solution (contract field ``status`` —
+ ``The status of the solution``; enumeration: ``1`` None,
+ ``2`` Waiting, ``3`` Accepted, ``4`` Refused).
+ approval_followup : IdNameRef | None, optional
+ Reference to the followup generated when the solution was
+ approved or rejected.
+ date_creation : datetime | None, optional
+ Creation timestamp of the solution record.
+ date_mod : datetime | None, optional
+ Last modification timestamp of the solution record.
+ date_approval : datetime | None, optional
+ Timestamp at which the solution was approved or rejected (no
+ contract description).
"""
id: int | None = None
@@ -41,7 +78,44 @@ class GetSolution(GlpiModel):
class PostSolution(GlpiModel):
- """Request body for ``POST`` on ticket timeline solution endpoints."""
+ """Request body for ``POST`` on ticket timeline solution endpoints.
+
+ Read-only contract field (``id``) is excluded.
+
+ Parameters
+ ----------
+ itemtype : str | None, optional
+ GLPI item type the solution belongs to, typically ``"Ticket"``.
+ items_id : int | None, optional
+ Identifier of the parent GLPI item.
+ type : IdNameRef | None, optional
+ Reference to the solution type.
+ content : GlpiMarkdownContent
+ Body of the solution; Markdown is converted to HTML on
+ serialisation (``format: html``). Defaults to :data:`None`.
+ user : IdNameRef | None, optional
+ Reference to the author of the solution.
+ user_editor : IdNameRef | None, optional
+ Reference to the user who last edited the solution.
+ approver : IdNameRef | None, optional
+ Reference to the user who approved or rejected the solution (no
+ contract description).
+ status : GlpiSolutionStatus | None, optional
+ Approval state of the solution (contract field ``status`` —
+ ``The status of the solution``; enumeration: ``1`` None,
+ ``2`` Waiting, ``3`` Accepted, ``4`` Refused).
+ approval_followup : IdNameRef | None, optional
+ Reference to the followup generated when the solution was
+ approved or rejected.
+ date_creation : datetime | None, optional
+ Creation timestamp to set on the solution record.
+ date_mod : datetime | None, optional
+ Last modification timestamp to set on the solution record (no
+ contract description).
+ date_approval : datetime | None, optional
+ Timestamp at which the solution was approved or rejected (no
+ contract description).
+ """
itemtype: str | None = None
items_id: int | None = None
@@ -58,7 +132,10 @@ class PostSolution(GlpiModel):
class PatchSolution(PostSolution):
- """Request body for ``PATCH`` on ticket timeline solution endpoints."""
+ """Request body for ``PATCH`` on ticket timeline solution endpoints.
+
+ Inherits all fields from :class:`PostSolution`.
+ """
class DeleteSolution(GlpiModel):
@@ -67,7 +144,10 @@ class DeleteSolution(GlpiModel):
Parameters
----------
force : bool | None, optional
- Permanently delete the solution instead of moving it to the trash.
+ When ``True``, permanently delete the solution instead of moving
+ the record to the GLPI trash. When ``False`` or :data:`None`,
+ the server applies its default soft-delete behaviour and the
+ solution can still be restored.
"""
force: bool | None = None
diff --git a/glpi_python_client/models/api_schema/assistance/timeline/_task.py b/glpi_python_client/models/api_schema/assistance/timeline/_task.py
index ffbd3df..20c395f 100644
--- a/glpi_python_client/models/api_schema/assistance/timeline/_task.py
+++ b/glpi_python_client/models/api_schema/assistance/timeline/_task.py
@@ -25,7 +25,63 @@
class GetTicketTask(GlpiModel):
"""Response shape returned by ``GET`` on ticket timeline task endpoints.
- Mirrors ``components.schemas.TicketTask``.
+ Mirrors ``components.schemas.TicketTask``. Several fields carry
+ ``description`` values in the contract; others are documented by
+ field name and type.
+
+ Parameters
+ ----------
+ id : int | None, optional
+ Native GLPI identifier of the task (``readOnly``).
+ uuid : str | None, optional
+ Server-generated universally unique identifier matching the
+ pattern ``/^[0-9a-f]{8}-...-4...-[89ab]...-...$/i``
+ (``readOnly``).
+ content : GlpiMarkdownContent
+ Body of the task exchanged as HTML over the wire
+ (``format: html``); transparent Markdown conversion is applied
+ on the model boundary. Defaults to :data:`None`.
+ is_private : bool | None, optional
+ Whether the task is visible only to technicians.
+ user : IdNameRef | None, optional
+ Reference to the author of the task.
+ user_editor : IdNameRef | None, optional
+ Reference to the user who last edited the task.
+ user_tech : IdNameRef | None, optional
+ Reference to the technician assigned to perform the task (no
+ contract description).
+ group_tech : IdNameRef | None, optional
+ Reference to the group assigned to perform the task.
+ date : datetime | None, optional
+ Date the task was created.
+ date_creation : datetime | None, optional
+ Creation timestamp of the task record.
+ date_mod : datetime | None, optional
+ Last modification timestamp of the task record.
+ duration : int | None, optional
+ Time spent on the task in seconds.
+ planned_begin : datetime | None, optional
+ Planned start date and time for the task.
+ planned_end : datetime | None, optional
+ Planned end date and time for the task.
+ state : GlpiTaskState | None, optional
+ Completion state of the task (contract field ``state`` — ``The
+ state of the task``; enumeration: ``0`` Information, ``1`` To
+ do, ``2`` Done).
+ category : IdNameRef | None, optional
+ Reference to the task category.
+ timeline_position : GlpiTimelinePosition | None, optional
+ Horizontal position of the task in the GLPI ticket timeline
+ widget (contract field ``timeline_position`` — ``The position
+ in the timeline``; enumeration: ``0`` No timeline, ``1`` Not
+ set, ``2`` Left, ``3`` Mid left, ``4`` Mid right, ``5`` Right).
+ tickets_id : int | None, optional
+ Identifier of the parent ticket.
+ source_item_id : int | None, optional
+ Identifier of the source item that generated this task, if any.
+ source_of_item_id : int | None, optional
+ Identifier of the item for which this task is a source (no
+ contract description).
"""
id: int | None = None
@@ -51,7 +107,58 @@ class GetTicketTask(GlpiModel):
class PostTicketTask(GlpiModel):
- """Request body for ``POST`` on ticket timeline task endpoints."""
+ """Request body for ``POST`` on ticket timeline task endpoints.
+
+ Read-only contract fields (``id``, ``uuid``) are excluded.
+
+ Parameters
+ ----------
+ content : GlpiMarkdownContent
+ Body of the task; Markdown is converted to HTML on serialisation
+ (``format: html``). Defaults to :data:`None`.
+ is_private : bool | None, optional
+ Whether the task is visible only to technicians.
+ user : IdNameRef | None, optional
+ Reference to the author of the task.
+ user_editor : IdNameRef | None, optional
+ Reference to the user who last edited the task.
+ user_tech : IdNameRef | None, optional
+ Reference to the technician assigned to perform the task (no
+ contract description).
+ group_tech : IdNameRef | None, optional
+ Reference to the group assigned to perform the task.
+ date : datetime | None, optional
+ Date to assign to the task.
+ date_creation : datetime | None, optional
+ Creation timestamp to set on the task record.
+ date_mod : datetime | None, optional
+ Last modification timestamp to set on the task record (no
+ contract description).
+ duration : int | None, optional
+ Time spent on the task in seconds.
+ planned_begin : datetime | None, optional
+ Planned start date and time for the task.
+ planned_end : datetime | None, optional
+ Planned end date and time for the task.
+ state : GlpiTaskState | None, optional
+ Completion state of the task (contract field ``state`` — ``The
+ state of the task``; enumeration: ``0`` Information, ``1`` To
+ do, ``2`` Done).
+ category : IdNameRef | None, optional
+ Reference to the task category.
+ timeline_position : GlpiTimelinePosition | None, optional
+ Horizontal position of the task in the GLPI ticket timeline
+ widget (contract field ``timeline_position`` — ``The position
+ in the timeline``; enumeration: ``0`` No timeline, ``1`` Not
+ set, ``2`` Left, ``3`` Mid left, ``4`` Mid right, ``5`` Right).
+ tickets_id : int | None, optional
+ Identifier of the parent ticket.
+ source_item_id : int | None, optional
+ Identifier of the source item that generated this task, if any.
+ source_of_item_id : int | None, optional
+ Identifier of the item for which this task is a source (no
+ contract description).
+ """
content: GlpiMarkdownContent = None
is_private: bool | None = None
@@ -74,7 +181,10 @@ class PostTicketTask(GlpiModel):
class PatchTicketTask(PostTicketTask):
- """Request body for ``PATCH`` on ticket timeline task endpoints."""
+ """Request body for ``PATCH`` on ticket timeline task endpoints.
+
+ Inherits all fields from :class:`PostTicketTask`.
+ """
class DeleteTicketTask(GlpiModel):
@@ -83,7 +193,10 @@ class DeleteTicketTask(GlpiModel):
Parameters
----------
force : bool | None, optional
- Permanently delete the task instead of moving it to the trash.
+ When ``True``, permanently delete the task instead of moving the
+ record to the GLPI trash. When ``False`` or :data:`None`, the
+ server applies its default soft-delete behaviour and the task
+ can still be restored.
"""
force: bool | None = None
diff --git a/glpi_python_client/models/api_schema/dropdowns/_location.py b/glpi_python_client/models/api_schema/dropdowns/_location.py
index 3685bf4..1e11c32 100644
--- a/glpi_python_client/models/api_schema/dropdowns/_location.py
+++ b/glpi_python_client/models/api_schema/dropdowns/_location.py
@@ -16,7 +16,59 @@
class GetLocation(GlpiModel):
"""Response shape returned by ``GET /Dropdowns/Location`` endpoints.
- Mirrors ``components.schemas.Location``.
+ Mirrors ``components.schemas.Location``. No field carries a
+ ``description`` in the OpenAPI contract; the parameter notes below
+ reflect the field names, types and ``readOnly`` flags as advertised.
+
+ Parameters
+ ----------
+ id : int | None, optional
+ Native GLPI identifier (``readOnly``).
+ name : str | None, optional
+ Short display name of the location.
+ completename : str | None, optional
+ Full hierarchical path of the location in dotted notation
+ (``readOnly``).
+ code : str | None, optional
+ Short alphanumeric code assigned to the location.
+ alias : str | None, optional
+ Alternate name or alias for the location.
+ comment : str | None, optional
+ Free-form comment associated with the location.
+ entity : IdNameRef | None, optional
+ Reference to the owning GLPI entity.
+ is_recursive : bool | None, optional
+ Whether the location is visible to child entities.
+ parent : IdNameRef | None, optional
+ Reference to the parent location in the hierarchy.
+ level : int | None, optional
+ Depth of the location in the hierarchy tree (``readOnly``).
+ room : str | None, optional
+ Room identifier within the building.
+ building : str | None, optional
+ Building identifier.
+ address : str | None, optional
+ Street address of the location.
+ town : str | None, optional
+ Town or city name.
+ postcode : str | None, optional
+ Postal code.
+ state : str | None, optional
+ State, region or province.
+ country : str | None, optional
+ Country name.
+ latitude : str | None, optional
+ Geographic latitude coordinate.
+ longitude : str | None, optional
+ Geographic longitude coordinate.
+ altitude : str | None, optional
+ Altitude above sea level.
+ date_creation : datetime | None, optional
+ Creation timestamp of the location record
+ (``format: date-time``).
+ date_mod : datetime | None, optional
+ Last modification timestamp of the location record
+ (``format: date-time``).
"""
id: int | None = None
@@ -44,7 +96,54 @@ class GetLocation(GlpiModel):
class PostLocation(GlpiModel):
- """Request body for ``POST /Dropdowns/Location``."""
+ """Request body for ``POST /Dropdowns/Location``.
+
+ Read-only contract fields (``id``, ``completename``, ``level``) are
+ intentionally excluded because the server rejects them on input.
+
+ Parameters
+ ----------
+ name : str | None, optional
+ Short display name of the location.
+ code : str | None, optional
+ Short alphanumeric code assigned to the location.
+ alias : str | None, optional
+ Alternate name or alias for the location.
+ comment : str | None, optional
+ Free-form comment associated with the location.
+ entity : IdNameRef | None, optional
+ Reference to the owning GLPI entity.
+ is_recursive : bool | None, optional
+ Whether the location is visible to child entities.
+ parent : IdNameRef | None, optional
+ Reference to the parent location in the hierarchy.
+ room : str | None, optional
+ Room identifier within the building.
+ building : str | None, optional
+ Building identifier.
+ address : str | None, optional
+ Street address of the location.
+ town : str | None, optional
+ Town or city name.
+ postcode : str | None, optional
+ Postal code.
+ state : str | None, optional
+ State, region or province.
+ country : str | None, optional
+ Country name.
+ latitude : str | None, optional
+ Geographic latitude coordinate.
+ longitude : str | None, optional
+ Geographic longitude coordinate.
+ altitude : str | None, optional
+ Altitude above sea level.
+ date_creation : datetime | None, optional
+ Creation timestamp to set on the location record
+ (``format: date-time``).
+ date_mod : datetime | None, optional
+ Last modification timestamp to set on the location record
+ (``format: date-time``).
+ """
name: str | None = None
code: str | None = None
@@ -68,7 +167,12 @@ class PostLocation(GlpiModel):
class PatchLocation(PostLocation):
- """Request body for ``PATCH /Dropdowns/Location/{id}``."""
+ """Request body for ``PATCH /Dropdowns/Location/{id}``.
+
+ The contract uses the same ``Location`` schema for create and
+ partial-update bodies; ``PatchLocation`` is kept distinct so client
+ mixins can express the intent of the operation explicitly.
+ """
class DeleteLocation(GlpiModel):
@@ -77,7 +181,10 @@ class DeleteLocation(GlpiModel):
Parameters
----------
force : bool | None, optional
- Permanently delete the location instead of moving it to the trash.
+ When ``True``, permanently delete the location instead of moving
+ the record to the GLPI trash. When ``False`` or :data:`None`,
+ the server applies its default soft-delete behaviour and the
+ location can still be restored.
"""
force: bool | None = None
diff --git a/glpi_python_client/models/api_schema/management/_document.py b/glpi_python_client/models/api_schema/management/_document.py
index e2e2299..83e090a 100644
--- a/glpi_python_client/models/api_schema/management/_document.py
+++ b/glpi_python_client/models/api_schema/management/_document.py
@@ -18,7 +18,35 @@
class GetDocument(GlpiModel):
"""Response shape returned by ``GET /Management/Document`` endpoints.
- Mirrors ``components.schemas.Document``.
+ Mirrors ``components.schemas.Document``. No field carries a
+ ``description`` in the OpenAPI contract. The ``filepath`` field is
+ server-managed (``readOnly``).
+
+ Parameters
+ ----------
+ id : int | None, optional
+ Native GLPI identifier (``readOnly``).
+ name : str | None, optional
+ Display name of the document.
+ comment : str | None, optional
+ Free-form comment associated with the document.
+ entity : IdNameRef | None, optional
+ Reference to the owning GLPI entity.
+ date_creation : datetime | None, optional
+ Creation timestamp of the document record (``format: date-time``).
+ date_mod : datetime | None, optional
+ Last modification timestamp of the document record
+ (``format: date-time``).
+ is_deleted : bool | None, optional
+ Whether the document has been moved to the trash.
+ filename : str | None, optional
+ Original file name of the uploaded file.
+ filepath : str | None, optional
+ Server-managed storage path of the file (``readOnly``).
+ mime : str | None, optional
+ MIME type of the uploaded file.
+ sha1sum : str | None, optional
+ SHA-1 checksum of the stored file, used for deduplication.
"""
id: int | None = None
@@ -35,7 +63,34 @@ class GetDocument(GlpiModel):
class PostDocument(GlpiModel):
- """Request body for ``POST /Management/Document``."""
+ """Request body for ``POST /Management/Document``.
+
+ Read-only contract fields (``id``, ``filepath``) are intentionally
+ excluded because the server rejects them on input.
+
+ Parameters
+ ----------
+ name : str | None, optional
+ Display name of the document.
+ comment : str | None, optional
+ Free-form comment associated with the document.
+ entity : IdNameRef | None, optional
+ Reference to the owning GLPI entity.
+ date_creation : datetime | None, optional
+ Creation timestamp to set on the document record
+ (``format: date-time``).
+ date_mod : datetime | None, optional
+ Last modification timestamp to set on the document record
+ (``format: date-time``).
+ is_deleted : bool | None, optional
+ Whether to create the document in the trashed state.
+ filename : str | None, optional
+ Original file name of the uploaded file.
+ mime : str | None, optional
+ MIME type of the uploaded file.
+ sha1sum : str | None, optional
+ SHA-1 checksum of the stored file, used for deduplication.
+ """
name: str | None = None
comment: str | None = None
@@ -49,7 +104,12 @@ class PostDocument(GlpiModel):
class PatchDocument(PostDocument):
- """Request body for ``PATCH /Management/Document/{id}``."""
+ """Request body for ``PATCH /Management/Document/{id}``.
+
+ The contract uses the same ``Document`` schema for create and
+ partial-update bodies; ``PatchDocument`` is kept distinct so client
+ mixins can express the intent of the operation explicitly.
+ """
class DeleteDocument(GlpiModel):
@@ -58,7 +118,10 @@ class DeleteDocument(GlpiModel):
Parameters
----------
force : bool | None, optional
- Permanently delete the document instead of moving it to the trash.
+ When ``True``, permanently delete the document instead of moving
+ the record to the GLPI trash. When ``False`` or :data:`None`,
+ the server applies its default soft-delete behaviour and the
+ document can still be restored.
"""
force: bool | None = None
diff --git a/glpi_python_client/testing/fixtures.py b/glpi_python_client/testing/fixtures.py
index 767277c..8f4ecf4 100644
--- a/glpi_python_client/testing/fixtures.py
+++ b/glpi_python_client/testing/fixtures.py
@@ -16,7 +16,7 @@
@pytest.fixture
def client_factory() -> Callable[..., GlpiClient]:
- """Return the reusable async GLPI client factory fixture.
+ """Return the reusable synchronous GLPI client factory fixture.
Tests can call the returned factory with overrides to create focused
client instances without duplicating the base configuration shared by
diff --git a/glpi_python_client/testing/utils.py b/glpi_python_client/testing/utils.py
index 569ca77..b79766a 100644
--- a/glpi_python_client/testing/utils.py
+++ b/glpi_python_client/testing/utils.py
@@ -8,7 +8,7 @@
from typing import Any
-from glpi_python_client import GlpiClient
+from glpi_python_client import AsyncGlpiClient, GlpiClient
_DEFAULT_CLIENT_CONFIG: dict[str, object] = {
"glpi_api_url": "https://glpi.example.test/api.php/",
@@ -97,7 +97,7 @@ def __init__(
def make_client(**overrides: object) -> GlpiClient:
- """Return a test client configured with sensible defaults.
+ """Return a synchronous test client configured with sensible defaults.
Callers can override any constructor keyword while reusing the shared base
configuration needed by most tests.
@@ -106,3 +106,16 @@ def make_client(**overrides: object) -> GlpiClient:
config = dict(_DEFAULT_CLIENT_CONFIG)
config.update(overrides)
return GlpiClient(**config) # type: ignore[arg-type]
+
+
+def make_async_client(**overrides: object) -> AsyncGlpiClient:
+ """Return an asynchronous test client configured with sensible defaults.
+
+ The helper mirrors :func:`make_client` but instantiates the
+ :class:`AsyncGlpiClient` so tests can exercise the async public
+ surface (and its bridge) without duplicating the base configuration.
+ """
+
+ config = dict(_DEFAULT_CLIENT_CONFIG)
+ config.update(overrides)
+ return AsyncGlpiClient(**config) # type: ignore[arg-type]
diff --git a/pyproject.toml b/pyproject.toml
index 643d74e..4bbf408 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -25,7 +25,7 @@ exclude = [
[project]
name = "glpi-python-client"
-version = "0.2.1"
+version = "0.3.0"
description = "A typed Python client for GLPI ITSM APIs."
readme = "README.md"
requires-python = ">=3.10"
diff --git a/skills/glpi-client-setup/SKILL.md b/skills/glpi-client-setup/SKILL.md
index e85e3a9..15aa3cb 100644
--- a/skills/glpi-client-setup/SKILL.md
+++ b/skills/glpi-client-setup/SKILL.md
@@ -1,32 +1,59 @@
---
name: glpi-client-setup
-description: "Create and configure the asynchronous glpi_python_client.GlpiClient, including from_env, OAuth credential pairs, entity/profile headers, SSL settings, and the optional legacy v1 document-upload session. Use before calling GLPI APIs or when the user asks how to connect to GLPI with glpi_python_client."
+description: "Create and configure the synchronous glpi_python_client.GlpiClient or the asynchronous glpi_python_client.AsyncGlpiClient, including from_env, OAuth credential pairs, entity/profile headers, SSL settings, and the optional legacy v1 document-upload session. Use before calling GLPI APIs or when the user asks how to connect to GLPI with glpi_python_client."
license: MIT
compatibility: "Requires Python 3.10+, glpi-python-client, network access to a GLPI v2 API, and valid GLPI credentials."
metadata:
package: glpi-python-client
- version: "0.3.0"
+ version: "0.4.0"
---
# GLPI Client Setup
-The package exposes a single asynchronous client at the package root: `glpi_python_client.GlpiClient`. The synchronous client and the legacy `AsyncGlpiClient`/`GLPIV1Session` public surface are gone — the v1 endpoint is now an internal fallback used only by `upload_document`.
+The package exposes two clients with identical endpoint surfaces:
-Use the client as an async context manager: `async with GlpiClient(...) as client`. When the client outlives one block, call `await client.close()` when finished.
+- `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`.
+
+Both clients share the same method names and signatures, including
+`from_env`, OAuth handling, retry behaviour, and the optional v1
+document-upload fallback. Pick the one matching the runtime model and
+keep usage consistent within a single application.
+
+Use the sync client as a context manager: `with GlpiClient(...) as
+client`. Use the async client as an async context manager: `async with
+AsyncGlpiClient(...) as client`. When the client outlives one block,
+call `client.close()` (or `await client.close()`) when finished.
## Procedure
-1. Decide whether credentials come from environment variables or explicit arguments.
-2. Provide `glpi_api_url` for the GLPI v2 API (typically ending in `/api.php/v2`).
-3. Provide at least one complete authentication pair: `client_id`/`client_secret`, `username`/`password`, or both pairs together.
-4. Add `glpi_entity`, `glpi_profile`, and `entity_recursive=True` only when the operation must run in a specific GLPI scope.
-5. Add `v1_base_url` and `v1_user_token` only when binary document uploads are needed (`upload_document`). `v1_app_token` is optional.
-6. Keep `verify_ssl=True` unless the user explicitly confirms a test or internal endpoint that cannot validate TLS.
-7. Always `await` client methods inside an async function.
+1. Pick the client class: `GlpiClient` for synchronous code,
+ `AsyncGlpiClient` for asynchronous code.
+2. Decide whether credentials come from environment variables or
+ explicit arguments.
+3. Provide `glpi_api_url` for the GLPI v2 API (typically ending in
+ `/api.php/v2`).
+4. Provide at least one complete authentication pair: `client_id`/
+ `client_secret`, `username`/`password`, or both pairs together.
+5. Add `glpi_entity`, `glpi_profile`, and `entity_recursive=True` only
+ when the operation must run in a specific GLPI scope.
+6. Add `v1_base_url` and `v1_user_token` only when binary document
+ 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.
## Environment Defaults
-`GlpiClient.from_env()` reads `GLPI_`-prefixed settings:
+`GlpiClient.from_env()` and `AsyncGlpiClient.from_env()` read the same
+`GLPI_`-prefixed settings:
- `GLPI_API_URL`
- `GLPI_CLIENT_ID` and `GLPI_CLIENT_SECRET`
@@ -39,16 +66,38 @@ Pass keyword overrides to replace selected environment values.
## Examples
-Explicit setup:
+Explicit setup, synchronous:
+
+```python
+from glpi_python_client import GlpiClient
+
+
+def main() -> None:
+ with GlpiClient(
+ glpi_api_url="https://glpi.example.com/api.php/v2",
+ client_id="oauth-client-id",
+ client_secret="oauth-client-secret",
+ username="api-user",
+ password="api-password",
+ glpi_entity=1,
+ glpi_profile=4,
+ ) as glpi:
+ tickets = glpi.search_tickets("status==1", limit=10)
+
+
+main()
+```
+
+Explicit setup, asynchronous:
```python
import asyncio
-from glpi_python_client import GlpiClient
+from glpi_python_client import AsyncGlpiClient
async def main() -> None:
- async with GlpiClient(
+ async with AsyncGlpiClient(
glpi_api_url="https://glpi.example.com/api.php/v2",
client_id="oauth-client-id",
client_secret="oauth-client-secret",
@@ -63,19 +112,28 @@ async def main() -> None:
asyncio.run(main())
```
-Environment setup:
+Environment setup, synchronous:
```python
from glpi_python_client import GlpiClient
-async with GlpiClient.from_env() as glpi:
+with GlpiClient.from_env() as glpi:
+ tickets = glpi.search_tickets("status==1")
+```
+
+Environment setup, asynchronous:
+
+```python
+from glpi_python_client import AsyncGlpiClient
+
+async with AsyncGlpiClient.from_env() as glpi:
tickets = await glpi.search_tickets("status==1")
```
-Document-upload setup:
+Document-upload setup (works on either client):
```python
-async with GlpiClient.from_env(
+with GlpiClient.from_env(
v1_base_url="https://glpi.example.com/apirest.php",
v1_user_token="legacy-user-token",
) as glpi:
@@ -84,7 +142,16 @@ async with GlpiClient.from_env(
## Gotchas
-- Only one client class exists. There is no longer a `GLPIClient` (sync) or a separate `AsyncGlpiClient`; the public class is named `GlpiClient` and is async.
-- The package no longer exports `GLPIV1Session`. Configure `v1_base_url`/`v1_user_token` on `GlpiClient` and call `upload_document` instead.
-- Use `glpi_api_url` for the v2 API; `v1_base_url` is only for the document-upload fallback.
-- Closing the client matters because it owns one or two HTTP sessions plus an OAuth token manager.
+- The two clients share the same endpoint surface; the only difference
+ is whether methods are blocking or coroutines. Do not mix them in the
+ same application unless you genuinely need both runtime models.
+- The package no longer exports `GLPIV1Session`. Configure
+ `v1_base_url`/`v1_user_token` on the client and call
+ `upload_document` instead.
+- Use `glpi_api_url` for the v2 API; `v1_base_url` is only for the
+ 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.
diff --git a/skills/glpi-document-workflow/SKILL.md b/skills/glpi-document-workflow/SKILL.md
index 100d3a1..3f1823e 100644
--- a/skills/glpi-document-workflow/SKILL.md
+++ b/skills/glpi-document-workflow/SKILL.md
@@ -1,4 +1,4 @@
----
+---
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."
license: MIT
@@ -9,6 +9,7 @@ metadata:
---
# GLPI Document Workflow
+> The snippets below use `AsyncGlpiClient` (`async with` + `await`). Every method shown also exists on the synchronous `GlpiClient` with the same signature -- replace `async with` with `with`, drop the `await` keyword, and skip the surrounding `async def`/`asyncio.run` scaffolding.
Document metadata uses the standard `Get`/`Post`/`Patch`/`Delete` shape on `/Management/Document`. Binary content uses two dedicated helpers: `download_document_content` for downloads and `upload_document` for uploads through the legacy v1 fallback session (the v2 contract does not advertise a binary upload endpoint).
@@ -68,4 +69,4 @@ document_id = await client.create_document(PostDocument(name="Diagnostic notes")
- `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.
-- The `delete_document(force=True)` flag permanently deletes; omit (or `False`) to move to the trash.
+- The `delete_document(force=True)` flag permanently deletes; omit (or `False`) to move to the trash.
\ No newline at end of file
diff --git a/skills/glpi-reporting-and-context/SKILL.md b/skills/glpi-reporting-and-context/SKILL.md
index 74debd8..c1f7e96 100644
--- a/skills/glpi-reporting-and-context/SKILL.md
+++ b/skills/glpi-reporting-and-context/SKILL.md
@@ -1,4 +1,4 @@
----
+---
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 and ticket, or one-call ticket context retrieval bundling tickets with timeline records."
license: MIT
@@ -9,6 +9,7 @@ metadata:
---
# GLPI Reporting And Context
+> 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.
Three custom helpers on `GlpiClient` build on top of the contract-aligned API mixins:
@@ -68,4 +69,4 @@ print(task_stats["duration_by_ticket"])
- `get_ticket_statistics` validates its date window locally and raises `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.
- Returned counter keys are raw GLPI numeric identifiers (entity IDs, status numbers, ...) for stable behaviour. Resolve to labels with `search_entities` or the appropriate enum.
-- Extra ticket fields (plugin keys, custom dropdowns) flow through `ticket.extra_payload` and are visible on `context.ticket` as well.
+- Extra ticket fields (plugin keys, custom dropdowns) flow through `ticket.extra_payload` and are visible on `context.ticket` as well.
\ No newline at end of file
diff --git a/skills/glpi-team-members/SKILL.md b/skills/glpi-team-members/SKILL.md
index 6b628ba..545eebb 100644
--- a/skills/glpi-team-members/SKILL.md
+++ b/skills/glpi-team-members/SKILL.md
@@ -1,4 +1,4 @@
----
+---
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."
license: MIT
@@ -9,6 +9,7 @@ metadata:
---
# GLPI Team Members
+> 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.
Ticket team members are exposed under `/Assistance/Ticket/{id}/TeamMember`. The `GlpiClient` exposes three methods: `list_ticket_team_members`, `add_ticket_team_member`, and `remove_ticket_team_member`.
@@ -58,4 +59,4 @@ await client.remove_ticket_team_member(
- The OpenAPI contract marks `PostTeamMember.id` as read-only, but the live GLPI server requires it on the `POST` body. The client honours the live behaviour and exposes `id` as a writable field; this is a deliberate "behaviour wins over the contract" decision.
- The server returns extra fields such as `display_name`, `firstname`, `realname`, and `href` on `GetTeamMember`. These flow into `member.extra_payload`.
- All methods are async; always `await` them.
-- If the user provides a name rather than an ID, look the user or group up first with `search_users` (or the equivalent group search) and confirm the ID before changing membership.
+- If the user provides a name rather than an ID, look the user or group up first with `search_users` (or the equivalent group search) and confirm the ID before changing membership.
\ No newline at end of file
diff --git a/skills/glpi-ticket-timeline/SKILL.md b/skills/glpi-ticket-timeline/SKILL.md
index e8123a1..8bad81d 100644
--- a/skills/glpi-ticket-timeline/SKILL.md
+++ b/skills/glpi-ticket-timeline/SKILL.md
@@ -1,4 +1,4 @@
----
+---
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."
license: MIT
@@ -9,6 +9,7 @@ metadata:
---
# GLPI Ticket Timeline
+> 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.
The ticket timeline is exposed by four resource families under `/Assistance/Ticket/{id}/Timeline/{Followup|Task|Solution|Document}`. Each family has matching `Get`/`Post`/`Patch`/`Delete` Pydantic models and the same `list_/get_/create_/update_/delete_` (or `link_`/`unlink_` for documents) shape on `GlpiClient`.
@@ -68,4 +69,4 @@ link_id = await client.link_ticket_timeline_document(
- `create_*` methods return new identifiers as plain `int`. `update_*` and `delete_*`/`unlink_*` return `None`.
- Timeline content fields accept GLPI HTML directly.
- Extra server fields (e.g. plugin keys) flow into `record.extra_payload` rather than raising.
-- `delete_ticket_*` and `unlink_ticket_timeline_document` accept a keyword-only `force` parameter; pass `force=True` to permanently delete.
+- `delete_ticket_*` and `unlink_ticket_timeline_document` accept a keyword-only `force` parameter; pass `force=True` to permanently delete.
\ No newline at end of file
diff --git a/skills/glpi-ticket-workflow/SKILL.md b/skills/glpi-ticket-workflow/SKILL.md
index 62d0f95..d10b499 100644
--- a/skills/glpi-ticket-workflow/SKILL.md
+++ b/skills/glpi-ticket-workflow/SKILL.md
@@ -1,4 +1,4 @@
----
+---
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."
license: MIT
@@ -9,6 +9,7 @@ metadata:
---
# 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`.
@@ -72,4 +73,4 @@ ticket = PostTicket(
- `search_tickets` accepts a raw RSQL filter string; pagination is via `limit` and `start`. There is no batch iterator.
- `create_ticket` returns the new ticket ID. `update_ticket` and `delete_ticket` return `None`.
- The GLPI server is the authoritative validator. Extra keys returned by the server flow into `ticket.extra_payload` rather than raising. Caller-provided `extra_payload` keys win on conflicts.
-- Read-only fields such as `status` are intentionally absent from `PostTicket`/`PatchTicket`; the server controls those transitions.
+- Read-only fields such as `status` are intentionally absent from `PostTicket`/`PatchTicket`; the server controls those transitions.
\ No newline at end of file
diff --git a/skills/glpi-user-location-provisioning/SKILL.md b/skills/glpi-user-location-provisioning/SKILL.md
index bd310b5..5afd2f0 100644
--- a/skills/glpi-user-location-provisioning/SKILL.md
+++ b/skills/glpi-user-location-provisioning/SKILL.md
@@ -1,4 +1,4 @@
----
+---
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."
license: MIT
@@ -9,6 +9,7 @@ metadata:
---
# GLPI User, Location, And Entity Provisioning
+> 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.
Users live under `/Administration/User`, entities under `/Administration/Entity`, and locations under `/Dropdown/Location`. Each resource family is exposed by the same `search_/get_/create_/update_/delete_` shape on `GlpiClient` with matching `Get`/`Post`/`Patch`/`Delete` Pydantic models.
@@ -73,4 +74,4 @@ for entity in entities:
- Search filters are raw RSQL strings; pagination is via `limit` and `start`.
- Extra keys returned by the live server (`display_name`, plugin fields, ...) flow into `record.extra_payload` rather than raising.
- `delete_*(force=True)` permanently deletes the record; omit (or `False`/`None`) to move it to the trash.
-- If the user provides a name rather than an ID, search first and confirm the ID before changing or deleting records.
+- If the user provides a name rather than an ID, search first and confirm the ID before changing or deleting records.
\ No newline at end of file