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,317 @@
|
|
|
1
|
+
"""Concrete synchronous client for the GLPI high-level API.
|
|
2
|
+
|
|
3
|
+
This module defines the public ``GlpiClient`` class that assembles the shared
|
|
4
|
+
v2 sync mixins, owns runtime resources, and exposes the small v1-backed
|
|
5
|
+
document-upload surface.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import threading
|
|
12
|
+
from types import TracebackType
|
|
13
|
+
|
|
14
|
+
from glpi_python_client.clients.api_v1_session import GLPIV1Session
|
|
15
|
+
from glpi_python_client.clients.v2.common.client_config import (
|
|
16
|
+
build_client_env_config,
|
|
17
|
+
build_v2_client_resources,
|
|
18
|
+
)
|
|
19
|
+
from glpi_python_client.clients.v2.common.constants import (
|
|
20
|
+
LOCATION_ENDPOINT,
|
|
21
|
+
USER_ENDPOINT,
|
|
22
|
+
GlpiId,
|
|
23
|
+
)
|
|
24
|
+
from glpi_python_client.clients.v2.common.payloads import prepare_document_upload
|
|
25
|
+
from glpi_python_client.clients.v2.common.request_http import (
|
|
26
|
+
ensure_response_status,
|
|
27
|
+
require_non_empty_text,
|
|
28
|
+
require_response_text,
|
|
29
|
+
)
|
|
30
|
+
from glpi_python_client.clients.v2.sync import GlpiApiClientMixin
|
|
31
|
+
from glpi_python_client.content.records.core.scalars import _optional_text
|
|
32
|
+
from glpi_python_client.models import GlpiDocument, GlpiLocation, GlpiUser
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class GlpiClient(GlpiApiClientMixin):
|
|
36
|
+
"""Concrete GLPI API client.
|
|
37
|
+
|
|
38
|
+
Provide either ``client_id`` and ``client_secret``, ``username`` and
|
|
39
|
+
``password``, or both credential sets depending on your GLPI instance's
|
|
40
|
+
authentication requirements.
|
|
41
|
+
|
|
42
|
+
Parameters
|
|
43
|
+
----------
|
|
44
|
+
glpi_api_url : str
|
|
45
|
+
Base URL of the GLPI high-level API.
|
|
46
|
+
client_id : str | None, optional
|
|
47
|
+
OAuth2 client ID. Provide it together with ``client_secret`` when the
|
|
48
|
+
GLPI instance requires client authentication.
|
|
49
|
+
client_secret : str | None, optional
|
|
50
|
+
OAuth2 client secret. Provide it together with ``client_id``.
|
|
51
|
+
username : str | None, optional
|
|
52
|
+
GLPI username for password-grant authentication. Provide it together
|
|
53
|
+
with ``password``.
|
|
54
|
+
password : str | None, optional
|
|
55
|
+
GLPI password for password-grant authentication. Provide it together
|
|
56
|
+
with ``username``.
|
|
57
|
+
glpi_entity : int | None
|
|
58
|
+
Default GLPI entity scope.
|
|
59
|
+
glpi_profile : int | None
|
|
60
|
+
Default GLPI profile scope.
|
|
61
|
+
entity_recursive : bool
|
|
62
|
+
Whether entity recursion is enabled.
|
|
63
|
+
language : str
|
|
64
|
+
Language used for API responses.
|
|
65
|
+
verify_ssl : bool, optional
|
|
66
|
+
Whether TLS certificates are verified.
|
|
67
|
+
auth_token_refresh : int | None, optional
|
|
68
|
+
Maximum OAuth token age in seconds before a refresh is attempted.
|
|
69
|
+
``None`` disables interval-based refreshes.
|
|
70
|
+
v1_base_url : str | None, optional
|
|
71
|
+
Legacy GLPI v1 base URL for document upload.
|
|
72
|
+
v1_user_token : str | None, optional
|
|
73
|
+
GLPI v1 user token.
|
|
74
|
+
v1_app_token : str | None, optional
|
|
75
|
+
GLPI v1 app token.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
*,
|
|
81
|
+
glpi_api_url: str,
|
|
82
|
+
client_id: str | None = None,
|
|
83
|
+
client_secret: str | None = None,
|
|
84
|
+
username: str | None = None,
|
|
85
|
+
password: str | None = None,
|
|
86
|
+
glpi_entity: int | None = None,
|
|
87
|
+
glpi_profile: int | None = None,
|
|
88
|
+
entity_recursive: bool = False,
|
|
89
|
+
language: str = "en_GB",
|
|
90
|
+
verify_ssl: bool = True,
|
|
91
|
+
auth_token_refresh: int | None = None,
|
|
92
|
+
v1_base_url: str | None = None,
|
|
93
|
+
v1_user_token: str | None = None,
|
|
94
|
+
v1_app_token: str | None = None,
|
|
95
|
+
) -> None:
|
|
96
|
+
resources = build_v2_client_resources(
|
|
97
|
+
glpi_api_url=glpi_api_url,
|
|
98
|
+
client_name="GlpiClient",
|
|
99
|
+
client_id=client_id,
|
|
100
|
+
client_secret=client_secret,
|
|
101
|
+
username=username,
|
|
102
|
+
password=password,
|
|
103
|
+
verify_ssl=verify_ssl,
|
|
104
|
+
auth_token_refresh=auth_token_refresh,
|
|
105
|
+
v1_base_url=v1_base_url,
|
|
106
|
+
v1_user_token=v1_user_token,
|
|
107
|
+
v1_app_token=v1_app_token,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
self.glpi_api_url = resources.glpi_api_url
|
|
111
|
+
self.glpi_entity = glpi_entity
|
|
112
|
+
self.glpi_profile = glpi_profile
|
|
113
|
+
self.entity_recursive = entity_recursive
|
|
114
|
+
self.language = language
|
|
115
|
+
self._auth_lock = threading.RLock()
|
|
116
|
+
self._closed = False
|
|
117
|
+
self._session = resources.session
|
|
118
|
+
self._auth = resources.auth
|
|
119
|
+
self._v1 = resources.v1
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
def from_env(cls, *, prefix: str = "GLPI_", **overrides: object) -> GlpiClient:
|
|
123
|
+
"""Build a client from environment variables.
|
|
124
|
+
|
|
125
|
+
This convenience constructor maps environment variables to the standard
|
|
126
|
+
client arguments. Explicit keyword overrides win over values read from
|
|
127
|
+
the environment. At least one complete auth pair must be supplied:
|
|
128
|
+
``CLIENT_ID`` and ``CLIENT_SECRET``, ``USERNAME`` and ``PASSWORD``, or
|
|
129
|
+
both pairs.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
config = build_client_env_config(
|
|
133
|
+
prefix=prefix,
|
|
134
|
+
env=os.environ,
|
|
135
|
+
overrides=overrides,
|
|
136
|
+
)
|
|
137
|
+
return cls(**config) # type: ignore[arg-type]
|
|
138
|
+
|
|
139
|
+
def create_user(self, user: GlpiUser) -> str:
|
|
140
|
+
"""Create a GLPI user and return the identifier assigned by GLPI.
|
|
141
|
+
|
|
142
|
+
The provided model is serialized through ``GlpiUser.to_api_payload()``
|
|
143
|
+
and the method raises when the server accepts the request but does not
|
|
144
|
+
return the created user ID.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
payload = user.to_api_payload()
|
|
148
|
+
response = self._post_request(USER_ENDPOINT, payload)
|
|
149
|
+
ensure_response_status(
|
|
150
|
+
response,
|
|
151
|
+
success_statuses=(200, 201),
|
|
152
|
+
failure_message=f"Failed to create user {payload.get('username')!r}",
|
|
153
|
+
)
|
|
154
|
+
return require_response_text(
|
|
155
|
+
response,
|
|
156
|
+
keys=("id",),
|
|
157
|
+
missing_message="GLPI user create response did not include an ID",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def delete_user(self, user_id: GlpiId, *, skip_entity: bool = False) -> None:
|
|
161
|
+
"""Delete a GLPI user by identifier.
|
|
162
|
+
|
|
163
|
+
This method succeeds only when GLPI returns a normal delete status and
|
|
164
|
+
otherwise raises a ``ValueError`` with the server response context.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
response = self._delete_request(
|
|
168
|
+
f"{USER_ENDPOINT}/{user_id}",
|
|
169
|
+
skip_entity=skip_entity,
|
|
170
|
+
)
|
|
171
|
+
ensure_response_status(
|
|
172
|
+
response,
|
|
173
|
+
success_statuses=(200, 204),
|
|
174
|
+
failure_message=f"Failed to delete user {user_id}",
|
|
175
|
+
)
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
def create_location(self, location: GlpiLocation) -> str:
|
|
179
|
+
"""Create a GLPI location and return the identifier assigned by GLPI.
|
|
180
|
+
|
|
181
|
+
The method performs the local name validation required by the package
|
|
182
|
+
before sending the payload to the remote API.
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
require_non_empty_text(
|
|
186
|
+
location.name,
|
|
187
|
+
error_message="GLPI location creation requires a name",
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
response = self._post_request(
|
|
191
|
+
LOCATION_ENDPOINT,
|
|
192
|
+
location.to_api_payload(),
|
|
193
|
+
)
|
|
194
|
+
ensure_response_status(
|
|
195
|
+
response,
|
|
196
|
+
success_statuses=(200, 201),
|
|
197
|
+
failure_message=f"Failed to create location {location.name!r}",
|
|
198
|
+
)
|
|
199
|
+
return require_response_text(
|
|
200
|
+
response,
|
|
201
|
+
keys=("id",),
|
|
202
|
+
missing_message="GLPI location create response did not include an ID",
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def delete_location(self, location_id: GlpiId) -> None:
|
|
206
|
+
"""Delete a GLPI location by identifier.
|
|
207
|
+
|
|
208
|
+
Successful deletes return ``None`` and keep the behavior consistent
|
|
209
|
+
with the other mutation helpers on the synchronous client.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
response = self._delete_request(f"{LOCATION_ENDPOINT}/{location_id}")
|
|
213
|
+
ensure_response_status(
|
|
214
|
+
response,
|
|
215
|
+
success_statuses=(200, 204),
|
|
216
|
+
failure_message=f"Failed to delete location {location_id}",
|
|
217
|
+
)
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
def upload_document_to_ticket(
|
|
221
|
+
self,
|
|
222
|
+
document: GlpiDocument,
|
|
223
|
+
) -> GlpiDocument:
|
|
224
|
+
"""Upload a document to a ticket through the legacy v1 document API.
|
|
225
|
+
|
|
226
|
+
The input model is validated and normalized first, then copied with the
|
|
227
|
+
resulting ticket and document identifiers so callers receive the final
|
|
228
|
+
upload metadata in one object.
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
parsed_ticket_id, filename, content, mime_type, document_name = (
|
|
232
|
+
prepare_document_upload(
|
|
233
|
+
ticket_id=document.ticket_id,
|
|
234
|
+
filename=document.filename,
|
|
235
|
+
content=document.content,
|
|
236
|
+
mime_type=document.mime_type,
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
v1 = self._require_v1()
|
|
240
|
+
result = v1.upload_document(
|
|
241
|
+
filename,
|
|
242
|
+
content,
|
|
243
|
+
mime_type,
|
|
244
|
+
document_name=document_name,
|
|
245
|
+
ticket_id=parsed_ticket_id,
|
|
246
|
+
entity_id=self.glpi_entity,
|
|
247
|
+
)
|
|
248
|
+
document_id = _optional_text(result.get("id"))
|
|
249
|
+
return document.model_copy(
|
|
250
|
+
update={
|
|
251
|
+
"ticket_id": parsed_ticket_id,
|
|
252
|
+
"document_id": document_id,
|
|
253
|
+
"document_name": document_name,
|
|
254
|
+
"filename": filename,
|
|
255
|
+
}
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
def _require_v1(self) -> GLPIV1Session:
|
|
259
|
+
"""Return the configured v1 session required for document upload.
|
|
260
|
+
|
|
261
|
+
Document upload is the only remaining workflow that still depends on
|
|
262
|
+
the legacy GLPI API, so this guard fails fast when the client was not
|
|
263
|
+
configured with the required v1 credentials.
|
|
264
|
+
"""
|
|
265
|
+
|
|
266
|
+
self._ensure_open()
|
|
267
|
+
if self._v1 is None:
|
|
268
|
+
raise RuntimeError(
|
|
269
|
+
"Document upload requires GLPI v1 API credentials "
|
|
270
|
+
"(v1_base_url and v1_user_token)."
|
|
271
|
+
)
|
|
272
|
+
return self._v1
|
|
273
|
+
|
|
274
|
+
def close(self) -> None:
|
|
275
|
+
"""Release client-owned authentication and HTTP resources.
|
|
276
|
+
|
|
277
|
+
Closing is idempotent. The method clears cached OAuth state, closes the
|
|
278
|
+
optional v1 session when present, and then closes the shared v2 HTTP
|
|
279
|
+
session.
|
|
280
|
+
"""
|
|
281
|
+
|
|
282
|
+
if self._closed:
|
|
283
|
+
return
|
|
284
|
+
|
|
285
|
+
self._closed = True
|
|
286
|
+
self._auth.logout()
|
|
287
|
+
|
|
288
|
+
if self._v1 is not None:
|
|
289
|
+
self._v1.close()
|
|
290
|
+
self._session.close()
|
|
291
|
+
|
|
292
|
+
def __enter__(self) -> GlpiClient:
|
|
293
|
+
"""Return the client instance for ``with`` statement usage.
|
|
294
|
+
|
|
295
|
+
The synchronous client uses itself as the context-manager value and
|
|
296
|
+
defers cleanup to ``__exit__``.
|
|
297
|
+
"""
|
|
298
|
+
|
|
299
|
+
return self
|
|
300
|
+
|
|
301
|
+
def __exit__(
|
|
302
|
+
self,
|
|
303
|
+
exc_type: type[BaseException] | None,
|
|
304
|
+
exc_value: BaseException | None,
|
|
305
|
+
traceback: TracebackType | None,
|
|
306
|
+
) -> None:
|
|
307
|
+
"""Close client resources when leaving a ``with`` block.
|
|
308
|
+
|
|
309
|
+
Exception details are ignored because cleanup is unconditional and the
|
|
310
|
+
context manager does not suppress caller exceptions.
|
|
311
|
+
"""
|
|
312
|
+
|
|
313
|
+
_ = (exc_type, exc_value, traceback)
|
|
314
|
+
self.close()
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
__all__ = ["GlpiClient"]
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Concrete async client for the GLPI high-level API (v2)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
from types import TracebackType
|
|
8
|
+
|
|
9
|
+
from glpi_python_client.clients.api_v1_session import GLPIV1Session
|
|
10
|
+
from glpi_python_client.clients.v2.async_.api import AsyncGlpiApiClientMixin
|
|
11
|
+
from glpi_python_client.clients.v2.common.client_config import (
|
|
12
|
+
build_client_env_config,
|
|
13
|
+
build_v2_client_resources,
|
|
14
|
+
)
|
|
15
|
+
from glpi_python_client.clients.v2.common.constants import (
|
|
16
|
+
LOCATION_ENDPOINT,
|
|
17
|
+
USER_ENDPOINT,
|
|
18
|
+
GlpiId,
|
|
19
|
+
)
|
|
20
|
+
from glpi_python_client.clients.v2.common.payloads import prepare_document_upload
|
|
21
|
+
from glpi_python_client.clients.v2.common.request_http import (
|
|
22
|
+
ensure_response_status,
|
|
23
|
+
require_non_empty_text,
|
|
24
|
+
require_response_text,
|
|
25
|
+
)
|
|
26
|
+
from glpi_python_client.content.records.core.scalars import _optional_text
|
|
27
|
+
from glpi_python_client.models import GlpiDocument, GlpiLocation, GlpiUser
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AsyncGlpiClient(AsyncGlpiApiClientMixin):
|
|
31
|
+
"""Concrete asynchronous GLPI API client.
|
|
32
|
+
|
|
33
|
+
The async client exposes the same high-level operations as
|
|
34
|
+
:class:`glpi_python_client.GlpiClient`, but methods that perform remote API
|
|
35
|
+
work are awaitable and use async transport wrappers.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
glpi_api_url: str,
|
|
42
|
+
client_id: str | None = None,
|
|
43
|
+
client_secret: str | None = None,
|
|
44
|
+
username: str | None = None,
|
|
45
|
+
password: str | None = None,
|
|
46
|
+
glpi_entity: int | None = None,
|
|
47
|
+
glpi_profile: int | None = None,
|
|
48
|
+
entity_recursive: bool = False,
|
|
49
|
+
language: str = "en_GB",
|
|
50
|
+
verify_ssl: bool = True,
|
|
51
|
+
auth_token_refresh: int | None = None,
|
|
52
|
+
v1_base_url: str | None = None,
|
|
53
|
+
v1_user_token: str | None = None,
|
|
54
|
+
v1_app_token: str | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
resources = build_v2_client_resources(
|
|
57
|
+
glpi_api_url=glpi_api_url,
|
|
58
|
+
client_name="AsyncGlpiClient",
|
|
59
|
+
client_id=client_id,
|
|
60
|
+
client_secret=client_secret,
|
|
61
|
+
username=username,
|
|
62
|
+
password=password,
|
|
63
|
+
verify_ssl=verify_ssl,
|
|
64
|
+
auth_token_refresh=auth_token_refresh,
|
|
65
|
+
v1_base_url=v1_base_url,
|
|
66
|
+
v1_user_token=v1_user_token,
|
|
67
|
+
v1_app_token=v1_app_token,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
self.glpi_api_url = resources.glpi_api_url
|
|
71
|
+
self.glpi_entity = glpi_entity
|
|
72
|
+
self.glpi_profile = glpi_profile
|
|
73
|
+
self.entity_recursive = entity_recursive
|
|
74
|
+
self.language = language
|
|
75
|
+
self._auth_lock = asyncio.Lock()
|
|
76
|
+
self._closed = False
|
|
77
|
+
self._session = resources.session
|
|
78
|
+
self._auth = resources.auth
|
|
79
|
+
self._v1 = resources.v1
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def from_env(cls, *, prefix: str = "GLPI_", **overrides: object) -> AsyncGlpiClient:
|
|
83
|
+
"""Build an async client from environment variables."""
|
|
84
|
+
|
|
85
|
+
config = build_client_env_config(
|
|
86
|
+
prefix=prefix,
|
|
87
|
+
env=os.environ,
|
|
88
|
+
overrides=overrides,
|
|
89
|
+
)
|
|
90
|
+
return cls(**config) # type: ignore[arg-type]
|
|
91
|
+
|
|
92
|
+
async def create_user(self, user: GlpiUser) -> str:
|
|
93
|
+
"""Create one GLPI user asynchronously and return its identifier."""
|
|
94
|
+
|
|
95
|
+
payload = user.to_api_payload()
|
|
96
|
+
response = await self._post_request(USER_ENDPOINT, payload)
|
|
97
|
+
ensure_response_status(
|
|
98
|
+
response,
|
|
99
|
+
success_statuses=(200, 201),
|
|
100
|
+
failure_message=f"Failed to create user {payload.get('username')!r}",
|
|
101
|
+
)
|
|
102
|
+
return require_response_text(
|
|
103
|
+
response,
|
|
104
|
+
keys=("id",),
|
|
105
|
+
missing_message="GLPI user create response did not include an ID",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
async def delete_user(
|
|
109
|
+
self,
|
|
110
|
+
user_id: GlpiId,
|
|
111
|
+
*,
|
|
112
|
+
skip_entity: bool = False,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Delete one GLPI user asynchronously."""
|
|
115
|
+
|
|
116
|
+
response = await self._delete_request(
|
|
117
|
+
f"{USER_ENDPOINT}/{user_id}",
|
|
118
|
+
skip_entity=skip_entity,
|
|
119
|
+
)
|
|
120
|
+
ensure_response_status(
|
|
121
|
+
response,
|
|
122
|
+
success_statuses=(200, 204),
|
|
123
|
+
failure_message=f"Failed to delete user {user_id}",
|
|
124
|
+
)
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
async def create_location(self, location: GlpiLocation) -> str:
|
|
128
|
+
"""Create one GLPI location asynchronously and return its identifier."""
|
|
129
|
+
|
|
130
|
+
require_non_empty_text(
|
|
131
|
+
location.name,
|
|
132
|
+
error_message="GLPI location creation requires a name",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
response = await self._post_request(
|
|
136
|
+
LOCATION_ENDPOINT,
|
|
137
|
+
location.to_api_payload(),
|
|
138
|
+
)
|
|
139
|
+
ensure_response_status(
|
|
140
|
+
response,
|
|
141
|
+
success_statuses=(200, 201),
|
|
142
|
+
failure_message=f"Failed to create location {location.name!r}",
|
|
143
|
+
)
|
|
144
|
+
return require_response_text(
|
|
145
|
+
response,
|
|
146
|
+
keys=("id",),
|
|
147
|
+
missing_message="GLPI location create response did not include an ID",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
async def delete_location(self, location_id: GlpiId) -> None:
|
|
151
|
+
"""Delete one GLPI location asynchronously."""
|
|
152
|
+
|
|
153
|
+
response = await self._delete_request(f"{LOCATION_ENDPOINT}/{location_id}")
|
|
154
|
+
ensure_response_status(
|
|
155
|
+
response,
|
|
156
|
+
success_statuses=(200, 204),
|
|
157
|
+
failure_message=f"Failed to delete location {location_id}",
|
|
158
|
+
)
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
async def upload_document_to_ticket(
|
|
162
|
+
self,
|
|
163
|
+
document: GlpiDocument,
|
|
164
|
+
) -> GlpiDocument:
|
|
165
|
+
"""Upload one GLPI document to one ticket asynchronously."""
|
|
166
|
+
|
|
167
|
+
parsed_ticket_id, filename, content, mime_type, document_name = (
|
|
168
|
+
prepare_document_upload(
|
|
169
|
+
ticket_id=document.ticket_id,
|
|
170
|
+
filename=document.filename,
|
|
171
|
+
content=document.content,
|
|
172
|
+
mime_type=document.mime_type,
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
v1 = self._require_v1()
|
|
176
|
+
result = await asyncio.to_thread(
|
|
177
|
+
v1.upload_document,
|
|
178
|
+
filename,
|
|
179
|
+
content,
|
|
180
|
+
mime_type,
|
|
181
|
+
document_name=document_name,
|
|
182
|
+
ticket_id=parsed_ticket_id,
|
|
183
|
+
entity_id=self.glpi_entity,
|
|
184
|
+
)
|
|
185
|
+
document_id = _optional_text(result.get("id"))
|
|
186
|
+
return document.model_copy(
|
|
187
|
+
update={
|
|
188
|
+
"ticket_id": parsed_ticket_id,
|
|
189
|
+
"document_id": document_id,
|
|
190
|
+
"document_name": document_name,
|
|
191
|
+
"filename": filename,
|
|
192
|
+
}
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def _require_v1(self) -> GLPIV1Session:
|
|
196
|
+
"""Return the configured GLPI v1 session required for document upload."""
|
|
197
|
+
|
|
198
|
+
self._ensure_open()
|
|
199
|
+
if self._v1 is None:
|
|
200
|
+
raise RuntimeError(
|
|
201
|
+
"Document upload requires GLPI v1 API credentials "
|
|
202
|
+
"(v1_base_url and v1_user_token)."
|
|
203
|
+
)
|
|
204
|
+
return self._v1
|
|
205
|
+
|
|
206
|
+
async def close(self) -> None:
|
|
207
|
+
"""Log out locally and release HTTP sessions held by the async client."""
|
|
208
|
+
|
|
209
|
+
if self._closed:
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
self._closed = True
|
|
213
|
+
self._auth.logout()
|
|
214
|
+
|
|
215
|
+
if self._v1 is not None:
|
|
216
|
+
await asyncio.to_thread(self._v1.close)
|
|
217
|
+
await asyncio.to_thread(self._session.close)
|
|
218
|
+
|
|
219
|
+
async def __aenter__(self) -> AsyncGlpiClient:
|
|
220
|
+
"""Return this client for async context-manager usage."""
|
|
221
|
+
|
|
222
|
+
return self
|
|
223
|
+
|
|
224
|
+
async def __aexit__(
|
|
225
|
+
self,
|
|
226
|
+
exc_type: type[BaseException] | None,
|
|
227
|
+
exc_value: BaseException | None,
|
|
228
|
+
traceback: TracebackType | None,
|
|
229
|
+
) -> None:
|
|
230
|
+
"""Log out and close network sessions when leaving an async context manager."""
|
|
231
|
+
|
|
232
|
+
_ = (exc_type, exc_value, traceback)
|
|
233
|
+
await self.close()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
__all__ = ["AsyncGlpiClient"]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from glpi_python_client import GLPIV1Session
|
|
8
|
+
|
|
9
|
+
_V1_BASE_URLS = (
|
|
10
|
+
pytest.param("https://glpi.example.test/api.php/v1", id="api.php-v1"),
|
|
11
|
+
pytest.param("https://glpi.example.test/apirest.php", id="apirest.php"),
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.mark.parametrize("base_url", _V1_BASE_URLS)
|
|
16
|
+
def test_v1_session_rejects_non_positive_refresh_interval(base_url: str) -> None:
|
|
17
|
+
with pytest.raises(ValueError, match="session_refresh_interval_seconds"):
|
|
18
|
+
GLPIV1Session(
|
|
19
|
+
base_url=base_url,
|
|
20
|
+
user_token="user-token",
|
|
21
|
+
app_token="app-token",
|
|
22
|
+
session_refresh_interval_seconds=0,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.mark.parametrize("base_url", _V1_BASE_URLS)
|
|
27
|
+
def test_v1_session_close_releases_http_session_before_init(
|
|
28
|
+
base_url: str,
|
|
29
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
30
|
+
) -> None:
|
|
31
|
+
session = GLPIV1Session(
|
|
32
|
+
base_url=base_url,
|
|
33
|
+
user_token="user-token",
|
|
34
|
+
app_token="app-token",
|
|
35
|
+
)
|
|
36
|
+
events: list[str] = []
|
|
37
|
+
|
|
38
|
+
def get(*args: object, **kwargs: object) -> None:
|
|
39
|
+
events.append("get")
|
|
40
|
+
|
|
41
|
+
def close() -> None:
|
|
42
|
+
events.append("close")
|
|
43
|
+
|
|
44
|
+
monkeypatch.setattr(session._http, "get", get)
|
|
45
|
+
monkeypatch.setattr(session._http, "close", close)
|
|
46
|
+
|
|
47
|
+
session.close()
|
|
48
|
+
|
|
49
|
+
assert events == ["close"]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.mark.parametrize("base_url", _V1_BASE_URLS)
|
|
53
|
+
def test_v1_session_close_kills_active_session_and_releases_http_session(
|
|
54
|
+
base_url: str,
|
|
55
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
56
|
+
) -> None:
|
|
57
|
+
session = GLPIV1Session(
|
|
58
|
+
base_url=base_url,
|
|
59
|
+
user_token="user-token",
|
|
60
|
+
app_token="app-token",
|
|
61
|
+
)
|
|
62
|
+
session._session_token = "session-token"
|
|
63
|
+
session._session_started_at = datetime.now(tz=timezone.utc)
|
|
64
|
+
events: list[str] = []
|
|
65
|
+
|
|
66
|
+
def get(url: str, **kwargs: object) -> None:
|
|
67
|
+
events.append(f"get:{url}")
|
|
68
|
+
|
|
69
|
+
def close() -> None:
|
|
70
|
+
events.append("close")
|
|
71
|
+
|
|
72
|
+
monkeypatch.setattr(session._http, "get", get)
|
|
73
|
+
monkeypatch.setattr(session._http, "close", close)
|
|
74
|
+
|
|
75
|
+
session.close()
|
|
76
|
+
session.close()
|
|
77
|
+
|
|
78
|
+
assert events == [
|
|
79
|
+
f"get:{base_url}/killSession",
|
|
80
|
+
"close",
|
|
81
|
+
"close",
|
|
82
|
+
]
|
|
83
|
+
session_state = vars(session)
|
|
84
|
+
assert session_state["_session_token"] is None
|
|
85
|
+
assert session_state["_session_started_at"] is None
|