glpi-python-client 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. glpi_python_client/__init__.py +36 -0
  2. glpi_python_client/auth/__init__.py +11 -0
  3. glpi_python_client/auth/auth.py +310 -0
  4. glpi_python_client/auth/tests/test_auth.py +189 -0
  5. glpi_python_client/clients/__init__.py +18 -0
  6. glpi_python_client/clients/api_v1_session.py +460 -0
  7. glpi_python_client/clients/api_v2_client.py +317 -0
  8. glpi_python_client/clients/async_api_v2_client.py +236 -0
  9. glpi_python_client/clients/tests/__init__.py +5 -0
  10. glpi_python_client/clients/tests/test_api_v1_session.py +85 -0
  11. glpi_python_client/clients/tests/test_api_v2_client.py +349 -0
  12. glpi_python_client/clients/tests/test_async_api_v2_client.py +257 -0
  13. glpi_python_client/clients/v2/__init__.py +8 -0
  14. glpi_python_client/clients/v2/async_/__init__.py +12 -0
  15. glpi_python_client/clients/v2/async_/api.py +29 -0
  16. glpi_python_client/clients/v2/async_/directory.py +88 -0
  17. glpi_python_client/clients/v2/async_/documents.py +144 -0
  18. glpi_python_client/clients/v2/async_/team.py +125 -0
  19. glpi_python_client/clients/v2/async_/tests/__init__.py +5 -0
  20. glpi_python_client/clients/v2/async_/tests/test_directory.py +43 -0
  21. glpi_python_client/clients/v2/async_/tests/test_documents.py +41 -0
  22. glpi_python_client/clients/v2/async_/tests/test_team.py +44 -0
  23. glpi_python_client/clients/v2/async_/tests/test_tickets.py +174 -0
  24. glpi_python_client/clients/v2/async_/tests/test_timeline.py +126 -0
  25. glpi_python_client/clients/v2/async_/tickets.py +312 -0
  26. glpi_python_client/clients/v2/async_/timeline.py +312 -0
  27. glpi_python_client/clients/v2/async_/transport.py +251 -0
  28. glpi_python_client/clients/v2/common/__init__.py +6 -0
  29. glpi_python_client/clients/v2/common/client_config.py +219 -0
  30. glpi_python_client/clients/v2/common/constants.py +45 -0
  31. glpi_python_client/clients/v2/common/errors.py +23 -0
  32. glpi_python_client/clients/v2/common/filters.py +30 -0
  33. glpi_python_client/clients/v2/common/payloads.py +57 -0
  34. glpi_python_client/clients/v2/common/request_http.py +195 -0
  35. glpi_python_client/clients/v2/common/response_payloads.py +76 -0
  36. glpi_python_client/clients/v2/common/ticket_search.py +113 -0
  37. glpi_python_client/clients/v2/sync/__init__.py +12 -0
  38. glpi_python_client/clients/v2/sync/api.py +29 -0
  39. glpi_python_client/clients/v2/sync/directory.py +90 -0
  40. glpi_python_client/clients/v2/sync/documents.py +144 -0
  41. glpi_python_client/clients/v2/sync/team.py +125 -0
  42. glpi_python_client/clients/v2/sync/tests/__init__.py +5 -0
  43. glpi_python_client/clients/v2/sync/tests/test_directory.py +57 -0
  44. glpi_python_client/clients/v2/sync/tests/test_documents.py +99 -0
  45. glpi_python_client/clients/v2/sync/tests/test_team.py +64 -0
  46. glpi_python_client/clients/v2/sync/tests/test_tickets.py +430 -0
  47. glpi_python_client/clients/v2/sync/tests/test_timeline.py +77 -0
  48. glpi_python_client/clients/v2/sync/tests/test_transport.py +89 -0
  49. glpi_python_client/clients/v2/sync/tickets.py +312 -0
  50. glpi_python_client/clients/v2/sync/timeline.py +308 -0
  51. glpi_python_client/clients/v2/sync/transport.py +246 -0
  52. glpi_python_client/content/__init__.py +11 -0
  53. glpi_python_client/content/conversion.py +58 -0
  54. glpi_python_client/content/records/__init__.py +84 -0
  55. glpi_python_client/content/records/core/__init__.py +6 -0
  56. glpi_python_client/content/records/core/document_links.py +100 -0
  57. glpi_python_client/content/records/core/normalization.py +53 -0
  58. glpi_python_client/content/records/core/references.py +98 -0
  59. glpi_python_client/content/records/core/scalars.py +83 -0
  60. glpi_python_client/content/records/parsers/__init__.py +6 -0
  61. glpi_python_client/content/records/parsers/directory.py +62 -0
  62. glpi_python_client/content/records/parsers/documents.py +49 -0
  63. glpi_python_client/content/records/parsers/team.py +58 -0
  64. glpi_python_client/content/records/parsers/tests/__init__.py +5 -0
  65. glpi_python_client/content/records/parsers/tests/test_tickets.py +35 -0
  66. glpi_python_client/content/records/parsers/tests/test_timeline.py +20 -0
  67. glpi_python_client/content/records/parsers/tickets.py +96 -0
  68. glpi_python_client/content/records/parsers/timeline.py +119 -0
  69. glpi_python_client/content/tests/__init__.py +5 -0
  70. glpi_python_client/content/tests/test_conversion.py +13 -0
  71. glpi_python_client/models/__init__.py +30 -0
  72. glpi_python_client/models/_base.py +22 -0
  73. glpi_python_client/models/_payload.py +79 -0
  74. glpi_python_client/models/_shared.py +37 -0
  75. glpi_python_client/models/glpi/__init__.py +27 -0
  76. glpi_python_client/models/glpi/_document.py +59 -0
  77. glpi_python_client/models/glpi/_followup.py +77 -0
  78. glpi_python_client/models/glpi/_location.py +53 -0
  79. glpi_python_client/models/glpi/_solution.py +57 -0
  80. glpi_python_client/models/glpi/_task.py +41 -0
  81. glpi_python_client/models/glpi/_team_member.py +33 -0
  82. glpi_python_client/models/glpi/_ticket.py +303 -0
  83. glpi_python_client/models/glpi/_user.py +92 -0
  84. glpi_python_client/models/glpi/tests/__init__.py +5 -0
  85. glpi_python_client/models/glpi/tests/test__document.py +12 -0
  86. glpi_python_client/models/glpi/tests/test__followup.py +31 -0
  87. glpi_python_client/models/glpi/tests/test__location.py +12 -0
  88. glpi_python_client/models/glpi/tests/test__solution.py +11 -0
  89. glpi_python_client/models/glpi/tests/test__ticket.py +59 -0
  90. glpi_python_client/models/glpi/tests/test__user.py +29 -0
  91. glpi_python_client/py.typed +0 -0
  92. glpi_python_client/testing/__init__.py +27 -0
  93. glpi_python_client/testing/fixtures.py +52 -0
  94. glpi_python_client/testing/utils.py +149 -0
  95. glpi_python_client-0.1.0.dist-info/METADATA +144 -0
  96. glpi_python_client-0.1.0.dist-info/RECORD +98 -0
  97. glpi_python_client-0.1.0.dist-info/WHEEL +4 -0
  98. glpi_python_client-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+
5
+ import pytest
6
+
7
+ from glpi_python_client import AsyncGlpiClient, GlpiFollowup, GlpiSolution
8
+ from glpi_python_client.testing.utils import FakeResponse
9
+
10
+
11
+ def _empty_response() -> FakeResponse:
12
+ return FakeResponse(status_code=204, payload={}, text="", content=b"")
13
+
14
+
15
+ def test_async_update_followup_returns_none(
16
+ monkeypatch: pytest.MonkeyPatch,
17
+ ) -> None:
18
+ async def run_test() -> None:
19
+ client = AsyncGlpiClient(
20
+ glpi_api_url="https://glpi.example.test/api.php/",
21
+ client_id="client-id",
22
+ client_secret="client-secret",
23
+ )
24
+
25
+ async def fake_update_request(
26
+ endpoint: str,
27
+ payload: dict[str, object] | None = None,
28
+ skip_entity: bool = False,
29
+ ) -> FakeResponse:
30
+ assert endpoint == "Assistance/Ticket/321/Timeline/Followup/654"
31
+ assert skip_entity is False
32
+ assert payload == {
33
+ "content": "<p>Updated followup</p>",
34
+ "is_private": False,
35
+ }
36
+ response = FakeResponse()
37
+ response.status_code = 204
38
+ response.text = ""
39
+ response.content = b""
40
+ return response
41
+
42
+ monkeypatch.setattr(client, "_update_request", fake_update_request)
43
+ try:
44
+ await client.update_followup(
45
+ "321",
46
+ "654",
47
+ GlpiFollowup(content="Updated followup"),
48
+ )
49
+ finally:
50
+ await client.close()
51
+
52
+ asyncio.run(run_test())
53
+
54
+
55
+ def test_async_create_solution_accepts_solution_id_response_key(
56
+ monkeypatch: pytest.MonkeyPatch,
57
+ ) -> None:
58
+ async def run_test() -> None:
59
+ client = AsyncGlpiClient(
60
+ glpi_api_url="https://glpi.example.test/api.php/",
61
+ client_id="client-id",
62
+ client_secret="client-secret",
63
+ )
64
+
65
+ async def fake_post_request(
66
+ endpoint: str,
67
+ payload: dict[str, object] | None = None,
68
+ skip_entity: bool = False,
69
+ ) -> FakeResponse:
70
+ assert endpoint == "Assistance/Ticket/321/Timeline/Solution"
71
+ assert skip_entity is False
72
+ assert payload == {"content": "<p>Resolved</p>"}
73
+ return FakeResponse(payload={"solution_id": "987"})
74
+
75
+ monkeypatch.setattr(client, "_post_request", fake_post_request)
76
+ try:
77
+ created = await client.create_solution(
78
+ 321,
79
+ GlpiSolution(content="Resolved"),
80
+ )
81
+
82
+ assert created == "987"
83
+ finally:
84
+ await client.close()
85
+
86
+ asyncio.run(run_test())
87
+
88
+
89
+ @pytest.mark.parametrize(
90
+ ("method_name", "args", "expected_endpoint"),
91
+ [
92
+ ("delete_followup", (321, 654), "Assistance/Ticket/321/Timeline/Followup/654"),
93
+ ("delete_solution", (321, 987), "Assistance/Ticket/321/Timeline/Solution/987"),
94
+ ],
95
+ )
96
+ def test_async_delete_timeline_operations_return_none(
97
+ monkeypatch: pytest.MonkeyPatch,
98
+ method_name: str,
99
+ args: tuple[object, ...],
100
+ expected_endpoint: str,
101
+ ) -> None:
102
+ async def run_test() -> None:
103
+ client = AsyncGlpiClient(
104
+ glpi_api_url="https://glpi.example.test/api.php/",
105
+ client_id="client-id",
106
+ client_secret="client-secret",
107
+ )
108
+
109
+ async def fake_delete_request(
110
+ endpoint: str,
111
+ payload: dict[str, object] | None = None,
112
+ skip_entity: bool = False,
113
+ ) -> FakeResponse:
114
+ assert endpoint == expected_endpoint
115
+ assert payload is None
116
+ assert skip_entity is False
117
+ return _empty_response()
118
+
119
+ monkeypatch.setattr(client, "_delete_request", fake_delete_request)
120
+ try:
121
+ operation = getattr(client, method_name)
122
+ await operation(*args)
123
+ finally:
124
+ await client.close()
125
+
126
+ asyncio.run(run_test())
@@ -0,0 +1,312 @@
1
+ """Asynchronous ticket operations for GLPI v2 clients.
2
+
3
+ This module contains the async ticket search, fetch, create, update, and
4
+ delete helpers used by the asynchronous high-level client.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from collections.abc import AsyncIterator
11
+ from typing import Any, cast, overload
12
+
13
+ from glpi_python_client.clients.v2.common.constants import TICKET_ENDPOINT, GlpiId
14
+ from glpi_python_client.clients.v2.common.request_http import (
15
+ ensure_response_status,
16
+ require_non_empty_text,
17
+ require_response_text,
18
+ )
19
+ from glpi_python_client.clients.v2.common.ticket_search import (
20
+ advance_ticket_search_pagination,
21
+ build_ticket_search_params,
22
+ filter_ticket_search_batch,
23
+ is_deleted_ticket,
24
+ merge_list_ticket_fields,
25
+ )
26
+ from glpi_python_client.content.records.core.normalization import (
27
+ _normalize_ticket_record,
28
+ )
29
+ from glpi_python_client.content.records.parsers.tickets import (
30
+ _glpi_ticket_record,
31
+ )
32
+ from glpi_python_client.models import GlpiTicket
33
+
34
+ from .transport import AsyncTransportMixin
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ class AsyncTicketMixin(AsyncTransportMixin):
40
+ """Asynchronous GLPI ticket search and mutation helpers.
41
+
42
+ The mixin exposes the typed async ticket operations while delegating shared
43
+ field and pagination rules to the common helper modules.
44
+ """
45
+
46
+ @overload
47
+ async def search_ticket_records(
48
+ self,
49
+ query: str | None = None,
50
+ *,
51
+ fields: tuple[str, ...] = (),
52
+ sort: str | None = None,
53
+ batch_size: None = None,
54
+ include_deleted_ticket: bool = False,
55
+ ) -> list[GlpiTicket]: ...
56
+
57
+ @overload
58
+ async def search_ticket_records(
59
+ self,
60
+ query: str | None = None,
61
+ *,
62
+ fields: tuple[str, ...] = (),
63
+ sort: str | None = None,
64
+ batch_size: int,
65
+ include_deleted_ticket: bool = False,
66
+ ) -> AsyncIterator[list[GlpiTicket]]: ...
67
+
68
+ async def search_ticket_records(
69
+ self,
70
+ query: str | None = None,
71
+ *,
72
+ fields: tuple[str, ...] = (),
73
+ sort: str | None = None,
74
+ batch_size: int | None = None,
75
+ include_deleted_ticket: bool = False,
76
+ ) -> list[GlpiTicket] | AsyncIterator[list[GlpiTicket]]:
77
+ """Search GLPI tickets and return either a full list or record batches.
78
+
79
+ Passing ``batch_size`` switches the method into streaming mode and
80
+ returns an async iterator of typed ticket batches instead of a fully
81
+ materialized list.
82
+ """
83
+
84
+ if batch_size is not None and batch_size < 1:
85
+ raise ValueError("batch_size must be a positive integer or None")
86
+
87
+ merged_fields = merge_list_ticket_fields(list(fields) or None)
88
+ batches = self._iter_ticket_record_batches(
89
+ query=query,
90
+ fields=merged_fields,
91
+ sort=sort,
92
+ batch_size=batch_size,
93
+ include_deleted_ticket=include_deleted_ticket,
94
+ )
95
+ if batch_size is not None:
96
+ return batches
97
+
98
+ records: list[GlpiTicket] = []
99
+ async for page in batches:
100
+ records.extend(page)
101
+ return records
102
+
103
+ async def _iter_ticket_record_batches(
104
+ self,
105
+ *,
106
+ query: str | None = None,
107
+ fields: list[str] | None = None,
108
+ sort: str | None = None,
109
+ batch_size: int | None = None,
110
+ include_deleted_ticket: bool = False,
111
+ ) -> AsyncIterator[list[GlpiTicket]]:
112
+ """Yield typed ticket batches produced from paginated raw payloads.
113
+
114
+ This helper sits between raw payload pagination and public return types,
115
+ ensuring each yielded batch already contains parsed ``GlpiTicket``
116
+ objects.
117
+ """
118
+
119
+ async for page in self._yield_ticket_payloads(
120
+ query=query,
121
+ fields=fields,
122
+ sort=sort,
123
+ batch_size=batch_size,
124
+ include_deleted_ticket=include_deleted_ticket,
125
+ ):
126
+ yield [
127
+ _glpi_ticket_record(raw_ticket)
128
+ for raw_ticket in page
129
+ if isinstance(raw_ticket, dict)
130
+ ]
131
+
132
+ async def get_ticket_record(
133
+ self,
134
+ ticket_id: GlpiId,
135
+ *,
136
+ include_deleted_ticket: bool = False,
137
+ ) -> GlpiTicket:
138
+ """Fetch one GLPI ticket by identifier.
139
+
140
+ Deleted tickets are rejected by default so single-record fetch behavior
141
+ stays aligned with the default async search behavior.
142
+ """
143
+
144
+ response = await self._get_request(f"{TICKET_ENDPOINT}/{ticket_id}")
145
+ if response.status_code not in (200, 206):
146
+ raise ValueError(
147
+ f"Failed to get ticket {ticket_id}: {response.status_code}"
148
+ )
149
+ payload = _normalize_ticket_record(response.json())
150
+ if not isinstance(payload, dict):
151
+ raise ValueError(f"Unexpected GLPI ticket payload for {ticket_id}")
152
+ if not include_deleted_ticket and is_deleted_ticket(payload):
153
+ raise ValueError(
154
+ f"Ticket {ticket_id} is deleted and excluded from fetch results"
155
+ )
156
+ return _glpi_ticket_record(payload)
157
+
158
+ async def create_ticket(self, ticket: GlpiTicket) -> str:
159
+ """Create a GLPI ticket and return the identifier assigned by GLPI.
160
+
161
+ The ticket name precondition is enforced locally before the awaited
162
+ request is sent, and the response must contain a created ticket ID.
163
+ """
164
+
165
+ require_non_empty_text(
166
+ ticket.name,
167
+ error_message="GLPI ticket creation requires a name",
168
+ )
169
+
170
+ payload_data = ticket.to_api_payload(
171
+ entity_id=self.glpi_entity,
172
+ include_entity=True,
173
+ )
174
+ response = await self._post_request(TICKET_ENDPOINT, payload_data)
175
+ ensure_response_status(
176
+ response,
177
+ success_statuses=(200, 201),
178
+ failure_message="Failed to create ticket",
179
+ )
180
+ ticket_id = require_response_text(
181
+ response,
182
+ keys=("id",),
183
+ missing_message="GLPI create response did not include a ticket ID",
184
+ )
185
+ logger.info(
186
+ "GLPI API created ticket %s fields=%s",
187
+ ticket_id,
188
+ sorted(payload_data),
189
+ )
190
+ return ticket_id
191
+
192
+ async def update_ticket(
193
+ self,
194
+ ticket_id: GlpiId,
195
+ ticket: GlpiTicket,
196
+ *,
197
+ field_mask: tuple[str, ...] = (),
198
+ ) -> None:
199
+ """Update one GLPI ticket with the provided field changes.
200
+
201
+ Optional field masks are forwarded to the model serializer so callers
202
+ can restrict the update payload to a specific subset of fields.
203
+ """
204
+
205
+ payload_data = ticket.to_api_payload(
206
+ entity_id=self.glpi_entity,
207
+ include_entity=False,
208
+ field_mask=field_mask,
209
+ )
210
+ response = await self._update_request(
211
+ f"{TICKET_ENDPOINT}/{ticket_id}",
212
+ payload_data,
213
+ )
214
+ ensure_response_status(
215
+ response,
216
+ success_statuses=(200, 204),
217
+ failure_message=f"Failed to update ticket {ticket_id}",
218
+ )
219
+ logger.info(
220
+ "GLPI API updated ticket %s fields=%s",
221
+ ticket_id,
222
+ sorted(payload_data),
223
+ )
224
+ return None
225
+
226
+ async def delete_ticket(self, ticket_id: GlpiId) -> None:
227
+ """Delete one GLPI ticket by identifier.
228
+
229
+ The method logs successful deletion and returns ``None`` to match the
230
+ package's async mutation-helper conventions.
231
+ """
232
+
233
+ response = await self._delete_request(f"{TICKET_ENDPOINT}/{ticket_id}")
234
+ ensure_response_status(
235
+ response,
236
+ success_statuses=(200, 204),
237
+ failure_message=f"Failed to delete ticket {ticket_id}",
238
+ )
239
+ logger.info("GLPI API deleted ticket %s", ticket_id)
240
+ return None
241
+
242
+ async def _yield_ticket_payloads(
243
+ self,
244
+ *,
245
+ query: str | None = None,
246
+ fields: list[str] | None = None,
247
+ sort: str | None = None,
248
+ batch_size: int | None = None,
249
+ include_deleted_ticket: bool = False,
250
+ ) -> AsyncIterator[list[dict[str, Any]]]:
251
+ """Yield paginated raw ticket payload batches from the GLPI API.
252
+
253
+ Pagination continues until the server indicates no more content or the
254
+ observed page-size heuristic shows that iteration is complete.
255
+ """
256
+
257
+ params = build_ticket_search_params(
258
+ query=query,
259
+ fields=fields,
260
+ sort=sort,
261
+ batch_size=batch_size,
262
+ )
263
+
264
+ observed_page_size = batch_size
265
+ while True:
266
+ current_start = cast(int, params["start"])
267
+ response = await self._get_request(TICKET_ENDPOINT, params)
268
+ if response.status_code not in (200, 206):
269
+ logger.info(
270
+ "GLPI ticket search returned status %s (start=%d)",
271
+ response.status_code,
272
+ current_start,
273
+ )
274
+ return
275
+
276
+ batch = response.json()
277
+ if not isinstance(batch, list) or not batch:
278
+ logger.info(
279
+ "GLPI ticket search returned empty batch (start=%d)",
280
+ current_start,
281
+ )
282
+ return
283
+
284
+ logger.info(
285
+ "GLPI ticket search: batch of %d tickets (start=%d)",
286
+ len(batch),
287
+ current_start,
288
+ )
289
+ result_batch, deleted_count = filter_ticket_search_batch(
290
+ batch,
291
+ include_deleted_ticket=include_deleted_ticket,
292
+ )
293
+ if deleted_count:
294
+ logger.info(
295
+ "GLPI ticket search: excluded %d deleted tickets (start=%d)",
296
+ deleted_count,
297
+ current_start,
298
+ )
299
+ if result_batch:
300
+ yield result_batch
301
+
302
+ next_start, observed_page_size, should_continue = (
303
+ advance_ticket_search_pagination(
304
+ current_start=current_start,
305
+ page_size=len(batch),
306
+ content_range=response.headers.get("Content-Range", ""),
307
+ observed_page_size=observed_page_size,
308
+ )
309
+ )
310
+ params["start"] = next_start
311
+ if not should_continue:
312
+ return