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.
- glpi_python_client/__init__.py +36 -0
- glpi_python_client/auth/__init__.py +11 -0
- glpi_python_client/auth/auth.py +310 -0
- glpi_python_client/auth/tests/test_auth.py +189 -0
- glpi_python_client/clients/__init__.py +18 -0
- glpi_python_client/clients/api_v1_session.py +460 -0
- glpi_python_client/clients/api_v2_client.py +317 -0
- glpi_python_client/clients/async_api_v2_client.py +236 -0
- glpi_python_client/clients/tests/__init__.py +5 -0
- glpi_python_client/clients/tests/test_api_v1_session.py +85 -0
- glpi_python_client/clients/tests/test_api_v2_client.py +349 -0
- glpi_python_client/clients/tests/test_async_api_v2_client.py +257 -0
- glpi_python_client/clients/v2/__init__.py +8 -0
- glpi_python_client/clients/v2/async_/__init__.py +12 -0
- glpi_python_client/clients/v2/async_/api.py +29 -0
- glpi_python_client/clients/v2/async_/directory.py +88 -0
- glpi_python_client/clients/v2/async_/documents.py +144 -0
- glpi_python_client/clients/v2/async_/team.py +125 -0
- glpi_python_client/clients/v2/async_/tests/__init__.py +5 -0
- glpi_python_client/clients/v2/async_/tests/test_directory.py +43 -0
- glpi_python_client/clients/v2/async_/tests/test_documents.py +41 -0
- glpi_python_client/clients/v2/async_/tests/test_team.py +44 -0
- glpi_python_client/clients/v2/async_/tests/test_tickets.py +174 -0
- glpi_python_client/clients/v2/async_/tests/test_timeline.py +126 -0
- glpi_python_client/clients/v2/async_/tickets.py +312 -0
- glpi_python_client/clients/v2/async_/timeline.py +312 -0
- glpi_python_client/clients/v2/async_/transport.py +251 -0
- glpi_python_client/clients/v2/common/__init__.py +6 -0
- glpi_python_client/clients/v2/common/client_config.py +219 -0
- glpi_python_client/clients/v2/common/constants.py +45 -0
- glpi_python_client/clients/v2/common/errors.py +23 -0
- glpi_python_client/clients/v2/common/filters.py +30 -0
- glpi_python_client/clients/v2/common/payloads.py +57 -0
- glpi_python_client/clients/v2/common/request_http.py +195 -0
- glpi_python_client/clients/v2/common/response_payloads.py +76 -0
- glpi_python_client/clients/v2/common/ticket_search.py +113 -0
- glpi_python_client/clients/v2/sync/__init__.py +12 -0
- glpi_python_client/clients/v2/sync/api.py +29 -0
- glpi_python_client/clients/v2/sync/directory.py +90 -0
- glpi_python_client/clients/v2/sync/documents.py +144 -0
- glpi_python_client/clients/v2/sync/team.py +125 -0
- glpi_python_client/clients/v2/sync/tests/__init__.py +5 -0
- glpi_python_client/clients/v2/sync/tests/test_directory.py +57 -0
- glpi_python_client/clients/v2/sync/tests/test_documents.py +99 -0
- glpi_python_client/clients/v2/sync/tests/test_team.py +64 -0
- glpi_python_client/clients/v2/sync/tests/test_tickets.py +430 -0
- glpi_python_client/clients/v2/sync/tests/test_timeline.py +77 -0
- glpi_python_client/clients/v2/sync/tests/test_transport.py +89 -0
- glpi_python_client/clients/v2/sync/tickets.py +312 -0
- glpi_python_client/clients/v2/sync/timeline.py +308 -0
- glpi_python_client/clients/v2/sync/transport.py +246 -0
- glpi_python_client/content/__init__.py +11 -0
- glpi_python_client/content/conversion.py +58 -0
- glpi_python_client/content/records/__init__.py +84 -0
- glpi_python_client/content/records/core/__init__.py +6 -0
- glpi_python_client/content/records/core/document_links.py +100 -0
- glpi_python_client/content/records/core/normalization.py +53 -0
- glpi_python_client/content/records/core/references.py +98 -0
- glpi_python_client/content/records/core/scalars.py +83 -0
- glpi_python_client/content/records/parsers/__init__.py +6 -0
- glpi_python_client/content/records/parsers/directory.py +62 -0
- glpi_python_client/content/records/parsers/documents.py +49 -0
- glpi_python_client/content/records/parsers/team.py +58 -0
- glpi_python_client/content/records/parsers/tests/__init__.py +5 -0
- glpi_python_client/content/records/parsers/tests/test_tickets.py +35 -0
- glpi_python_client/content/records/parsers/tests/test_timeline.py +20 -0
- glpi_python_client/content/records/parsers/tickets.py +96 -0
- glpi_python_client/content/records/parsers/timeline.py +119 -0
- glpi_python_client/content/tests/__init__.py +5 -0
- glpi_python_client/content/tests/test_conversion.py +13 -0
- glpi_python_client/models/__init__.py +30 -0
- glpi_python_client/models/_base.py +22 -0
- glpi_python_client/models/_payload.py +79 -0
- glpi_python_client/models/_shared.py +37 -0
- glpi_python_client/models/glpi/__init__.py +27 -0
- glpi_python_client/models/glpi/_document.py +59 -0
- glpi_python_client/models/glpi/_followup.py +77 -0
- glpi_python_client/models/glpi/_location.py +53 -0
- glpi_python_client/models/glpi/_solution.py +57 -0
- glpi_python_client/models/glpi/_task.py +41 -0
- glpi_python_client/models/glpi/_team_member.py +33 -0
- glpi_python_client/models/glpi/_ticket.py +303 -0
- glpi_python_client/models/glpi/_user.py +92 -0
- glpi_python_client/models/glpi/tests/__init__.py +5 -0
- glpi_python_client/models/glpi/tests/test__document.py +12 -0
- glpi_python_client/models/glpi/tests/test__followup.py +31 -0
- glpi_python_client/models/glpi/tests/test__location.py +12 -0
- glpi_python_client/models/glpi/tests/test__solution.py +11 -0
- glpi_python_client/models/glpi/tests/test__ticket.py +59 -0
- glpi_python_client/models/glpi/tests/test__user.py +29 -0
- glpi_python_client/py.typed +0 -0
- glpi_python_client/testing/__init__.py +27 -0
- glpi_python_client/testing/fixtures.py +52 -0
- glpi_python_client/testing/utils.py +149 -0
- glpi_python_client-0.1.0.dist-info/METADATA +144 -0
- glpi_python_client-0.1.0.dist-info/RECORD +98 -0
- glpi_python_client-0.1.0.dist-info/WHEEL +4 -0
- glpi_python_client-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Asynchronous timeline operations for GLPI v2 clients.
|
|
2
|
+
|
|
3
|
+
This module groups async followup, task, solution, and attachment-lookup
|
|
4
|
+
helpers for ticket timeline data.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
from tenacity import RetryError
|
|
13
|
+
|
|
14
|
+
from glpi_python_client.clients.v2.common.constants import (
|
|
15
|
+
FOLLOWUP_SUFFIX,
|
|
16
|
+
SOLUTION_SUFFIX,
|
|
17
|
+
TASK_SUFFIX,
|
|
18
|
+
TICKET_ENDPOINT,
|
|
19
|
+
GlpiId,
|
|
20
|
+
)
|
|
21
|
+
from glpi_python_client.clients.v2.common.errors import remote_error_message
|
|
22
|
+
from glpi_python_client.clients.v2.common.request_http import (
|
|
23
|
+
ensure_response_status,
|
|
24
|
+
require_response_text,
|
|
25
|
+
)
|
|
26
|
+
from glpi_python_client.clients.v2.common.response_payloads import (
|
|
27
|
+
timeline_records_from_response,
|
|
28
|
+
)
|
|
29
|
+
from glpi_python_client.content.records.core.scalars import _optional_text
|
|
30
|
+
from glpi_python_client.content.records.parsers.timeline import (
|
|
31
|
+
_glpi_followup_record,
|
|
32
|
+
_glpi_solution_record,
|
|
33
|
+
_glpi_task_record,
|
|
34
|
+
)
|
|
35
|
+
from glpi_python_client.models import GlpiFollowup, GlpiSolution, GlpiTask
|
|
36
|
+
|
|
37
|
+
from .transport import AsyncTransportMixin
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AsyncTimelineMixin(AsyncTransportMixin):
|
|
43
|
+
"""Asynchronous followup, task, and solution helpers.
|
|
44
|
+
|
|
45
|
+
The mixin keeps ticket timeline behavior together because these endpoints
|
|
46
|
+
share parsing, logging, and optional legacy attachment lookup rules.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
async def get_followup_records(self, ticket_id: GlpiId) -> list[GlpiFollowup]:
|
|
50
|
+
"""Fetch the followups associated with one ticket.
|
|
51
|
+
|
|
52
|
+
Non-success responses are logged and normalized to an empty list so the
|
|
53
|
+
helper behaves like the other async timeline list operations.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
endpoint = f"{TICKET_ENDPOINT}/{ticket_id}/{FOLLOWUP_SUFFIX}"
|
|
57
|
+
response = await self._get_request(endpoint)
|
|
58
|
+
if response.status_code not in (200, 206):
|
|
59
|
+
logger.warning(
|
|
60
|
+
"Failed to get followups for ticket %s: %s",
|
|
61
|
+
ticket_id,
|
|
62
|
+
response.status_code,
|
|
63
|
+
)
|
|
64
|
+
return []
|
|
65
|
+
return timeline_records_from_response(
|
|
66
|
+
response,
|
|
67
|
+
record_factory=_glpi_followup_record,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
async def get_task_records(self, ticket_id: GlpiId) -> list[GlpiTask]:
|
|
71
|
+
"""Fetch the tasks associated with one ticket.
|
|
72
|
+
|
|
73
|
+
Returned records are parsed into typed ``GlpiTask`` instances using the
|
|
74
|
+
shared timeline response handling helpers.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
endpoint = f"{TICKET_ENDPOINT}/{ticket_id}/{TASK_SUFFIX}"
|
|
78
|
+
response = await self._get_request(endpoint)
|
|
79
|
+
if response.status_code not in (200, 206):
|
|
80
|
+
logger.warning(
|
|
81
|
+
"Failed to get tasks for ticket %s: %s",
|
|
82
|
+
ticket_id,
|
|
83
|
+
response.status_code,
|
|
84
|
+
)
|
|
85
|
+
return []
|
|
86
|
+
return timeline_records_from_response(
|
|
87
|
+
response,
|
|
88
|
+
record_factory=_glpi_task_record,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
async def get_followup_attachment_document_ids(
|
|
92
|
+
self, followup_id: GlpiId
|
|
93
|
+
) -> tuple[str, ...]:
|
|
94
|
+
"""Fetch document IDs linked directly to one followup through v1.
|
|
95
|
+
|
|
96
|
+
Attachment lookup is best-effort because it depends on the optional
|
|
97
|
+
legacy v1 session and runs through ``asyncio.to_thread`` at that narrow
|
|
98
|
+
blocking boundary.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
v1_session = self._v1
|
|
102
|
+
if v1_session is None:
|
|
103
|
+
return ()
|
|
104
|
+
|
|
105
|
+
document_ids: list[str] = []
|
|
106
|
+
seen_document_ids: set[str] = set()
|
|
107
|
+
try:
|
|
108
|
+
relations = await asyncio.to_thread(
|
|
109
|
+
v1_session.get_sub_items,
|
|
110
|
+
"ITILFollowup",
|
|
111
|
+
followup_id,
|
|
112
|
+
"Document_Item",
|
|
113
|
+
)
|
|
114
|
+
except (RetryError, ValueError) as exc:
|
|
115
|
+
logger.warning(
|
|
116
|
+
"Skipping GLPI followup %s attachment lookup after v1 API failure: %s",
|
|
117
|
+
followup_id,
|
|
118
|
+
remote_error_message(exc),
|
|
119
|
+
)
|
|
120
|
+
return ()
|
|
121
|
+
for relation in relations:
|
|
122
|
+
document_id = _optional_text(relation.get("documents_id"))
|
|
123
|
+
if document_id is None or document_id in seen_document_ids:
|
|
124
|
+
continue
|
|
125
|
+
seen_document_ids.add(document_id)
|
|
126
|
+
document_ids.append(document_id)
|
|
127
|
+
return tuple(document_ids)
|
|
128
|
+
|
|
129
|
+
async def get_solution_attachment_document_ids(
|
|
130
|
+
self, solution_id: GlpiId
|
|
131
|
+
) -> tuple[str, ...]:
|
|
132
|
+
"""Fetch document IDs linked directly to one solution through v1.
|
|
133
|
+
|
|
134
|
+
This mirrors followup attachment lookup and preserves first-seen order
|
|
135
|
+
while removing duplicate document identifiers.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
v1_session = self._v1
|
|
139
|
+
if v1_session is None:
|
|
140
|
+
return ()
|
|
141
|
+
|
|
142
|
+
document_ids: list[str] = []
|
|
143
|
+
seen_document_ids: set[str] = set()
|
|
144
|
+
try:
|
|
145
|
+
relations = await asyncio.to_thread(
|
|
146
|
+
v1_session.get_sub_items,
|
|
147
|
+
"ITILSolution",
|
|
148
|
+
solution_id,
|
|
149
|
+
"Document_Item",
|
|
150
|
+
)
|
|
151
|
+
except (RetryError, ValueError) as exc:
|
|
152
|
+
logger.warning(
|
|
153
|
+
"Skipping GLPI solution %s attachment lookup after v1 API failure: %s",
|
|
154
|
+
solution_id,
|
|
155
|
+
remote_error_message(exc),
|
|
156
|
+
)
|
|
157
|
+
return ()
|
|
158
|
+
for relation in relations:
|
|
159
|
+
document_id = _optional_text(relation.get("documents_id"))
|
|
160
|
+
if document_id is None or document_id in seen_document_ids:
|
|
161
|
+
continue
|
|
162
|
+
seen_document_ids.add(document_id)
|
|
163
|
+
document_ids.append(document_id)
|
|
164
|
+
return tuple(document_ids)
|
|
165
|
+
|
|
166
|
+
async def get_solution_records(self, ticket_id: GlpiId) -> list[GlpiSolution]:
|
|
167
|
+
"""Fetch the solutions associated with one ticket.
|
|
168
|
+
|
|
169
|
+
Each returned item is parsed into a typed ``GlpiSolution`` record using
|
|
170
|
+
the shared timeline parsing helpers.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
endpoint = f"{TICKET_ENDPOINT}/{ticket_id}/{SOLUTION_SUFFIX}"
|
|
174
|
+
response = await self._get_request(endpoint)
|
|
175
|
+
if response.status_code not in (200, 206):
|
|
176
|
+
logger.warning(
|
|
177
|
+
"Failed to get solutions for ticket %s: %s",
|
|
178
|
+
ticket_id,
|
|
179
|
+
response.status_code,
|
|
180
|
+
)
|
|
181
|
+
return []
|
|
182
|
+
return timeline_records_from_response(
|
|
183
|
+
response,
|
|
184
|
+
record_factory=_glpi_solution_record,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
async def create_followup(
|
|
188
|
+
self,
|
|
189
|
+
ticket_id: GlpiId,
|
|
190
|
+
followup: GlpiFollowup,
|
|
191
|
+
) -> str:
|
|
192
|
+
"""Create a GLPI followup and return the identifier assigned by GLPI.
|
|
193
|
+
|
|
194
|
+
The response may expose the created ID under different keys, so the
|
|
195
|
+
helper normalizes that lookup before returning to callers.
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
endpoint = f"{TICKET_ENDPOINT}/{ticket_id}/{FOLLOWUP_SUFFIX}"
|
|
199
|
+
payload_data = followup.to_api_payload()
|
|
200
|
+
response = await self._post_request(endpoint, payload_data)
|
|
201
|
+
ensure_response_status(
|
|
202
|
+
response,
|
|
203
|
+
success_statuses=(200, 201),
|
|
204
|
+
failure_message=f"Failed to post followup on ticket {ticket_id}",
|
|
205
|
+
)
|
|
206
|
+
followup_id = require_response_text(
|
|
207
|
+
response,
|
|
208
|
+
keys=("id", "followup_id"),
|
|
209
|
+
missing_message="GLPI followup create response did not include an ID",
|
|
210
|
+
)
|
|
211
|
+
logger.info(
|
|
212
|
+
"GLPI API created %s followup %s on ticket %s",
|
|
213
|
+
"private" if payload_data.get("is_private") else "public",
|
|
214
|
+
followup_id,
|
|
215
|
+
ticket_id,
|
|
216
|
+
)
|
|
217
|
+
return followup_id
|
|
218
|
+
|
|
219
|
+
async def update_followup(
|
|
220
|
+
self,
|
|
221
|
+
ticket_id: GlpiId,
|
|
222
|
+
followup_id: GlpiId,
|
|
223
|
+
followup: GlpiFollowup,
|
|
224
|
+
) -> None:
|
|
225
|
+
"""Update one GLPI followup on a ticket.
|
|
226
|
+
|
|
227
|
+
The followup model is serialized as-is and the method returns ``None``
|
|
228
|
+
once GLPI accepts the update.
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
endpoint = f"{TICKET_ENDPOINT}/{ticket_id}/{FOLLOWUP_SUFFIX}/{followup_id}"
|
|
232
|
+
payload_data = followup.to_api_payload()
|
|
233
|
+
response = await self._update_request(endpoint, payload_data)
|
|
234
|
+
ensure_response_status(
|
|
235
|
+
response,
|
|
236
|
+
success_statuses=(200, 204),
|
|
237
|
+
failure_message=(
|
|
238
|
+
f"Failed to patch followup {followup_id} on ticket {ticket_id}"
|
|
239
|
+
),
|
|
240
|
+
)
|
|
241
|
+
logger.info(
|
|
242
|
+
"GLPI API updated followup %s on ticket %s fields=%s",
|
|
243
|
+
followup_id,
|
|
244
|
+
ticket_id,
|
|
245
|
+
sorted(payload_data),
|
|
246
|
+
)
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
async def delete_followup(self, ticket_id: GlpiId, followup_id: GlpiId) -> None:
|
|
250
|
+
"""Delete one GLPI ticket followup by identifier.
|
|
251
|
+
|
|
252
|
+
Successful deletes are logged with both ticket and followup context to
|
|
253
|
+
help trace timeline mutations.
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
endpoint = f"{TICKET_ENDPOINT}/{ticket_id}/{FOLLOWUP_SUFFIX}/{followup_id}"
|
|
257
|
+
response = await self._delete_request(endpoint)
|
|
258
|
+
ensure_response_status(
|
|
259
|
+
response,
|
|
260
|
+
success_statuses=(200, 204),
|
|
261
|
+
failure_message=(
|
|
262
|
+
f"Failed to delete followup {followup_id} on ticket {ticket_id}"
|
|
263
|
+
),
|
|
264
|
+
)
|
|
265
|
+
logger.info("GLPI API deleted followup %s on ticket %s", followup_id, ticket_id)
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
async def create_solution(
|
|
269
|
+
self,
|
|
270
|
+
ticket_id: GlpiId,
|
|
271
|
+
solution: GlpiSolution,
|
|
272
|
+
) -> str:
|
|
273
|
+
"""Create a GLPI solution and return the identifier assigned by GLPI.
|
|
274
|
+
|
|
275
|
+
As with followups, the created solution identifier is normalized from
|
|
276
|
+
the response payload before being returned.
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
endpoint = f"{TICKET_ENDPOINT}/{ticket_id}/{SOLUTION_SUFFIX}"
|
|
280
|
+
payload_data = solution.to_api_payload()
|
|
281
|
+
response = await self._post_request(endpoint, payload_data)
|
|
282
|
+
ensure_response_status(
|
|
283
|
+
response,
|
|
284
|
+
success_statuses=(200, 201),
|
|
285
|
+
failure_message=f"Failed to post solution on ticket {ticket_id}",
|
|
286
|
+
)
|
|
287
|
+
solution_id = require_response_text(
|
|
288
|
+
response,
|
|
289
|
+
keys=("id", "solution_id"),
|
|
290
|
+
missing_message="GLPI solution create response did not include an ID",
|
|
291
|
+
)
|
|
292
|
+
logger.info("GLPI API created solution %s on ticket %s", solution_id, ticket_id)
|
|
293
|
+
return solution_id
|
|
294
|
+
|
|
295
|
+
async def delete_solution(self, ticket_id: GlpiId, solution_id: GlpiId) -> None:
|
|
296
|
+
"""Delete one GLPI ticket solution by identifier.
|
|
297
|
+
|
|
298
|
+
The helper returns ``None`` after a successful delete response and logs
|
|
299
|
+
the mutation for troubleshooting and auditability.
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
endpoint = f"{TICKET_ENDPOINT}/{ticket_id}/{SOLUTION_SUFFIX}/{solution_id}"
|
|
303
|
+
response = await self._delete_request(endpoint)
|
|
304
|
+
ensure_response_status(
|
|
305
|
+
response,
|
|
306
|
+
success_statuses=(200, 204),
|
|
307
|
+
failure_message=(
|
|
308
|
+
f"Failed to delete solution {solution_id} on ticket {ticket_id}"
|
|
309
|
+
),
|
|
310
|
+
)
|
|
311
|
+
logger.info("GLPI API deleted solution %s on ticket %s", solution_id, ticket_id)
|
|
312
|
+
return None
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Asynchronous GLPI v2 transport methods.
|
|
2
|
+
|
|
3
|
+
This module owns the awaitable transport helpers that wrap the shared
|
|
4
|
+
``requests`` session and keep blocking calls behind narrow thread boundaries.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
from typing import TYPE_CHECKING, cast
|
|
12
|
+
|
|
13
|
+
import requests
|
|
14
|
+
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
|
|
15
|
+
|
|
16
|
+
from glpi_python_client.clients.v2.common.request_http import (
|
|
17
|
+
build_request_headers,
|
|
18
|
+
build_request_url,
|
|
19
|
+
finalize_request_response,
|
|
20
|
+
request_params,
|
|
21
|
+
require_access_token,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from glpi_python_client.auth.auth import GLPITokenManager
|
|
26
|
+
from glpi_python_client.clients.api_v1_session import GLPIV1Session
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AsyncTransportMixin:
|
|
32
|
+
"""Asynchronous GLPI API transport helpers.
|
|
33
|
+
|
|
34
|
+
The transport mixin keeps token handling, header construction, retries, and
|
|
35
|
+
request dispatch out of the endpoint-specific async mixins.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
_auth: GLPITokenManager
|
|
39
|
+
_auth_lock: asyncio.Lock
|
|
40
|
+
_closed: bool = False
|
|
41
|
+
_session: requests.Session
|
|
42
|
+
_v1: GLPIV1Session | None
|
|
43
|
+
entity_recursive: bool
|
|
44
|
+
glpi_api_url: str
|
|
45
|
+
glpi_entity: int | None
|
|
46
|
+
glpi_profile: int | None
|
|
47
|
+
language: str
|
|
48
|
+
|
|
49
|
+
def _ensure_open(self) -> None:
|
|
50
|
+
"""Raise when the client has already been closed.
|
|
51
|
+
|
|
52
|
+
All async transport helpers call this guard before touching the shared
|
|
53
|
+
HTTP session.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
if self._closed:
|
|
57
|
+
raise RuntimeError("GLPI client is closed")
|
|
58
|
+
|
|
59
|
+
async def _ensure_token(self) -> None:
|
|
60
|
+
"""Ensure that a valid GLPI access token is available.
|
|
61
|
+
|
|
62
|
+
Token refresh is serialized by the async lock so concurrent awaited
|
|
63
|
+
calls do not race while updating shared authentication state.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
self._ensure_open()
|
|
67
|
+
async with self._auth_lock:
|
|
68
|
+
await asyncio.to_thread(self._auth.ensure_token)
|
|
69
|
+
|
|
70
|
+
def _get_headers(
|
|
71
|
+
self,
|
|
72
|
+
*,
|
|
73
|
+
include_content_type: bool = False,
|
|
74
|
+
skip_entity: bool = False,
|
|
75
|
+
) -> dict[str, str]:
|
|
76
|
+
"""Build GLPI request headers for the current client state.
|
|
77
|
+
|
|
78
|
+
This convenience wrapper forwards the current transport state to the
|
|
79
|
+
shared header builder used across sync and async implementations.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
return build_request_headers(
|
|
83
|
+
access_token=self._auth.access_token,
|
|
84
|
+
language=self.language,
|
|
85
|
+
glpi_entity=self.glpi_entity,
|
|
86
|
+
glpi_profile=self.glpi_profile,
|
|
87
|
+
entity_recursive=self.entity_recursive,
|
|
88
|
+
include_content_type=include_content_type,
|
|
89
|
+
skip_entity=skip_entity,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
async def _send_request(
|
|
93
|
+
self,
|
|
94
|
+
method: str,
|
|
95
|
+
url: str,
|
|
96
|
+
**kwargs: object,
|
|
97
|
+
) -> requests.Response:
|
|
98
|
+
request_method = getattr(self._session, method)
|
|
99
|
+
return cast(
|
|
100
|
+
requests.Response,
|
|
101
|
+
await asyncio.to_thread(request_method, url, **kwargs),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
async def _execute_request(
|
|
105
|
+
self,
|
|
106
|
+
*,
|
|
107
|
+
method: str,
|
|
108
|
+
endpoint: str,
|
|
109
|
+
success_statuses: tuple[int, ...],
|
|
110
|
+
params: dict[str, object] | None = None,
|
|
111
|
+
json_body: dict[str, object] | None = None,
|
|
112
|
+
skip_entity: bool = False,
|
|
113
|
+
include_content_type: bool = False,
|
|
114
|
+
) -> requests.Response:
|
|
115
|
+
"""Execute one authenticated GLPI request asynchronously.
|
|
116
|
+
|
|
117
|
+
The helper normalizes the endpoint URL, headers, timeout, and payload
|
|
118
|
+
placement before dispatching the blocking HTTP call through the async
|
|
119
|
+
transport wrapper.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
await self._ensure_token()
|
|
123
|
+
access_token = require_access_token(self._auth.access_token)
|
|
124
|
+
url = build_request_url(self.glpi_api_url, endpoint)
|
|
125
|
+
|
|
126
|
+
request_kwargs: dict[str, object] = {
|
|
127
|
+
"headers": build_request_headers(
|
|
128
|
+
access_token=access_token,
|
|
129
|
+
language=self.language,
|
|
130
|
+
glpi_entity=self.glpi_entity,
|
|
131
|
+
glpi_profile=self.glpi_profile,
|
|
132
|
+
entity_recursive=self.entity_recursive,
|
|
133
|
+
include_content_type=include_content_type,
|
|
134
|
+
skip_entity=skip_entity,
|
|
135
|
+
),
|
|
136
|
+
"timeout": 30,
|
|
137
|
+
}
|
|
138
|
+
if method == "get":
|
|
139
|
+
request_kwargs["params"] = request_params(params)
|
|
140
|
+
else:
|
|
141
|
+
request_kwargs["json"] = json_body
|
|
142
|
+
|
|
143
|
+
response = await self._send_request(method, url, **request_kwargs)
|
|
144
|
+
return finalize_request_response(
|
|
145
|
+
response,
|
|
146
|
+
method=method,
|
|
147
|
+
url=url,
|
|
148
|
+
success_statuses=success_statuses,
|
|
149
|
+
logger=logger,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
@retry(
|
|
153
|
+
retry=retry_if_exception_type(requests.RequestException),
|
|
154
|
+
stop=stop_after_attempt(3),
|
|
155
|
+
wait=wait_fixed(3),
|
|
156
|
+
)
|
|
157
|
+
async def _get_request(
|
|
158
|
+
self,
|
|
159
|
+
endpoint: str,
|
|
160
|
+
params: dict[str, object] | None = None,
|
|
161
|
+
skip_entity: bool = False,
|
|
162
|
+
) -> requests.Response:
|
|
163
|
+
"""Execute one authenticated GLPI ``GET`` request asynchronously.
|
|
164
|
+
|
|
165
|
+
Network-level request exceptions are retried according to the transport
|
|
166
|
+
retry policy before the response is returned to the caller.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
return await self._execute_request(
|
|
170
|
+
method="get",
|
|
171
|
+
endpoint=endpoint,
|
|
172
|
+
success_statuses=(200, 206),
|
|
173
|
+
params=params,
|
|
174
|
+
skip_entity=skip_entity,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
@retry(
|
|
178
|
+
retry=retry_if_exception_type(requests.RequestException),
|
|
179
|
+
stop=stop_after_attempt(3),
|
|
180
|
+
wait=wait_fixed(3),
|
|
181
|
+
)
|
|
182
|
+
async def _post_request(
|
|
183
|
+
self,
|
|
184
|
+
endpoint: str,
|
|
185
|
+
json_body: dict[str, object] | None = None,
|
|
186
|
+
skip_entity: bool = False,
|
|
187
|
+
) -> requests.Response:
|
|
188
|
+
"""Execute one authenticated GLPI ``POST`` request asynchronously.
|
|
189
|
+
|
|
190
|
+
JSON request bodies automatically include the content-type header needed
|
|
191
|
+
by the GLPI API.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
return await self._execute_request(
|
|
195
|
+
method="post",
|
|
196
|
+
endpoint=endpoint,
|
|
197
|
+
success_statuses=(200, 201),
|
|
198
|
+
json_body=json_body,
|
|
199
|
+
skip_entity=skip_entity,
|
|
200
|
+
include_content_type=True,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
@retry(
|
|
204
|
+
retry=retry_if_exception_type(requests.RequestException),
|
|
205
|
+
stop=stop_after_attempt(3),
|
|
206
|
+
wait=wait_fixed(3),
|
|
207
|
+
)
|
|
208
|
+
async def _update_request(
|
|
209
|
+
self,
|
|
210
|
+
endpoint: str,
|
|
211
|
+
json_body: dict[str, object] | None = None,
|
|
212
|
+
) -> requests.Response:
|
|
213
|
+
"""Execute one authenticated GLPI ``PATCH`` request asynchronously.
|
|
214
|
+
|
|
215
|
+
The helper uses the same authenticated execution path as the other HTTP
|
|
216
|
+
verbs while targeting the success codes expected from update calls.
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
return await self._execute_request(
|
|
220
|
+
method="patch",
|
|
221
|
+
endpoint=endpoint,
|
|
222
|
+
success_statuses=(200, 204),
|
|
223
|
+
json_body=json_body,
|
|
224
|
+
include_content_type=True,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
@retry(
|
|
228
|
+
retry=retry_if_exception_type(requests.RequestException),
|
|
229
|
+
stop=stop_after_attempt(3),
|
|
230
|
+
wait=wait_fixed(3),
|
|
231
|
+
)
|
|
232
|
+
async def _delete_request(
|
|
233
|
+
self,
|
|
234
|
+
endpoint: str,
|
|
235
|
+
json_body: dict[str, object] | None = None,
|
|
236
|
+
skip_entity: bool = False,
|
|
237
|
+
) -> requests.Response:
|
|
238
|
+
"""Execute one authenticated GLPI ``DELETE`` request asynchronously.
|
|
239
|
+
|
|
240
|
+
Some delete endpoints accept a JSON body, so the content-type header is
|
|
241
|
+
enabled automatically when a body is supplied.
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
return await self._execute_request(
|
|
245
|
+
method="delete",
|
|
246
|
+
endpoint=endpoint,
|
|
247
|
+
success_statuses=(200, 204),
|
|
248
|
+
json_body=json_body,
|
|
249
|
+
skip_entity=skip_entity,
|
|
250
|
+
include_content_type=json_body is not None,
|
|
251
|
+
)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Shared helper modules for the GLPI v2 clients.
|
|
2
|
+
|
|
3
|
+
This package holds deterministic logic such as endpoint constants, request
|
|
4
|
+
normalization, payload building, and response extraction that is reused by
|
|
5
|
+
both the synchronous and asynchronous client implementations.
|
|
6
|
+
"""
|