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,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,5 @@
1
+ """Client tests that live beside the implementation package.
2
+
3
+ Keeping this package next to the client modules makes it easier to evolve the
4
+ internal layout without maintaining a separate top-level test tree.
5
+ """
@@ -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