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