glpi-python-client 0.3.3__py3-none-any.whl → 0.3.4__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 +9 -1
- glpi_python_client/auth/_v1_session.py +173 -49
- glpi_python_client/auth/tests/test_v1_session.py +184 -21
- glpi_python_client/clients/_base_client.py +178 -0
- glpi_python_client/clients/api/__init__.py +2 -0
- glpi_python_client/clients/api/management/_document.py +1 -7
- glpi_python_client/clients/api/plugins/__init__.py +10 -0
- glpi_python_client/clients/api/plugins/_fields.py +411 -0
- glpi_python_client/clients/api/plugins/tests/__init__.py +0 -0
- glpi_python_client/clients/api/plugins/tests/test_fields_mixin.py +377 -0
- glpi_python_client/clients/async_client.py +14 -90
- glpi_python_client/clients/commons/_config.py +1 -1
- glpi_python_client/clients/commons/_http.py +14 -0
- glpi_python_client/clients/commons/_transport.py +29 -0
- glpi_python_client/clients/sync_client.py +7 -141
- glpi_python_client/models/__init__.py +10 -0
- glpi_python_client/models/api_schema/plugins/__init__.py +19 -0
- glpi_python_client/models/api_schema/plugins/_fields.py +177 -0
- glpi_python_client/models/api_schema/plugins/tests/__init__.py +0 -0
- glpi_python_client/models/api_schema/plugins/tests/test_fields_schemas.py +82 -0
- glpi_python_client/testing/utils.py +2 -0
- {glpi_python_client-0.3.3.dist-info → glpi_python_client-0.3.4.dist-info}/METADATA +1 -1
- {glpi_python_client-0.3.3.dist-info → glpi_python_client-0.3.4.dist-info}/RECORD +25 -16
- {glpi_python_client-0.3.3.dist-info → glpi_python_client-0.3.4.dist-info}/WHEEL +0 -0
- {glpi_python_client-0.3.3.dist-info → glpi_python_client-0.3.4.dist-info}/licenses/LICENSE +0 -0
glpi_python_client/__init__.py
CHANGED
|
@@ -30,6 +30,9 @@ from glpi_python_client.models import (
|
|
|
30
30
|
GetEntity,
|
|
31
31
|
GetFollowup,
|
|
32
32
|
GetLocation,
|
|
33
|
+
GetPluginFieldsContainer,
|
|
34
|
+
GetPluginFieldsField,
|
|
35
|
+
GetPluginFieldsValueRow,
|
|
33
36
|
GetSolution,
|
|
34
37
|
GetTeamMember,
|
|
35
38
|
GetTicket,
|
|
@@ -63,6 +66,7 @@ from glpi_python_client.models import (
|
|
|
63
66
|
PostEntity,
|
|
64
67
|
PostFollowup,
|
|
65
68
|
PostLocation,
|
|
69
|
+
PostPluginFieldsValueRow,
|
|
66
70
|
PostSolution,
|
|
67
71
|
PostTeamMember,
|
|
68
72
|
PostTicket,
|
|
@@ -72,7 +76,7 @@ from glpi_python_client.models import (
|
|
|
72
76
|
TicketMarkdownOptions,
|
|
73
77
|
)
|
|
74
78
|
|
|
75
|
-
__version__ = "0.3.
|
|
79
|
+
__version__ = "0.3.4"
|
|
76
80
|
|
|
77
81
|
__all__ = [
|
|
78
82
|
"AsyncGlpiClient",
|
|
@@ -90,6 +94,9 @@ __all__ = [
|
|
|
90
94
|
"GetEntity",
|
|
91
95
|
"GetFollowup",
|
|
92
96
|
"GetLocation",
|
|
97
|
+
"GetPluginFieldsContainer",
|
|
98
|
+
"GetPluginFieldsField",
|
|
99
|
+
"GetPluginFieldsValueRow",
|
|
93
100
|
"GetSolution",
|
|
94
101
|
"GetTeamMember",
|
|
95
102
|
"GetTicket",
|
|
@@ -124,6 +131,7 @@ __all__ = [
|
|
|
124
131
|
"PostEntity",
|
|
125
132
|
"PostFollowup",
|
|
126
133
|
"PostLocation",
|
|
134
|
+
"PostPluginFieldsValueRow",
|
|
127
135
|
"PostSolution",
|
|
128
136
|
"PostTeamMember",
|
|
129
137
|
"PostTicket",
|
|
@@ -1,10 +1,27 @@
|
|
|
1
|
-
"""GLPI v1 REST session used
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
the
|
|
6
|
-
``
|
|
7
|
-
|
|
1
|
+
"""GLPI v1 REST session used for legacy endpoints not exposed by v2.
|
|
2
|
+
|
|
3
|
+
Two consumers currently share this session:
|
|
4
|
+
|
|
5
|
+
* the management :class:`DocumentMixin` for the multipart
|
|
6
|
+
``POST /Document`` upload (the v2 API does not advertise a binary
|
|
7
|
+
upload route), and
|
|
8
|
+
* the :class:`PluginFieldsMixin` for the GLPI "Fields" plugin endpoints
|
|
9
|
+
(``PluginFieldsContainer``, ``PluginFieldsField`` and the per-item
|
|
10
|
+
value itemtypes), which the v2 contract does not surface at all.
|
|
11
|
+
|
|
12
|
+
The session wrapper owns the authenticated v1 lifecycle (init, refresh,
|
|
13
|
+
kill) and exposes the typed ``upload_document`` helper plus the generic
|
|
14
|
+
``request_json`` JSON-only HTTP helper that newer mixins build on.
|
|
15
|
+
|
|
16
|
+
Retry policy
|
|
17
|
+
------------
|
|
18
|
+
Every public dispatch helper (``_init_session``, ``request_json``,
|
|
19
|
+
``upload_document``) carries the same :mod:`tenacity` retry decorator
|
|
20
|
+
used by the v2 transport: three attempts spaced by three seconds,
|
|
21
|
+
triggered exclusively by :class:`requests.RequestException` (which
|
|
22
|
+
:func:`finalize_request_response` raises for 5xx server errors).
|
|
23
|
+
:class:`ValueError` raised by status-code or payload checks does not
|
|
24
|
+
trigger a retry — client-side or 4xx failures are surfaced immediately.
|
|
8
25
|
"""
|
|
9
26
|
|
|
10
27
|
from __future__ import annotations
|
|
@@ -12,15 +29,26 @@ from __future__ import annotations
|
|
|
12
29
|
import json
|
|
13
30
|
import logging
|
|
14
31
|
from datetime import datetime, timedelta, timezone
|
|
15
|
-
from typing import cast
|
|
32
|
+
from typing import Any, cast
|
|
16
33
|
|
|
17
34
|
import requests
|
|
18
|
-
from tenacity import retry, stop_after_attempt, wait_fixed
|
|
35
|
+
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
|
|
36
|
+
|
|
37
|
+
from glpi_python_client.clients.commons._http import (
|
|
38
|
+
ensure_response_status,
|
|
39
|
+
finalize_request_response,
|
|
40
|
+
response_json_or_empty,
|
|
41
|
+
)
|
|
19
42
|
|
|
20
43
|
logger = logging.getLogger(__name__)
|
|
21
44
|
|
|
22
45
|
_DEFAULT_SESSION_REFRESH_INTERVAL_SECONDS = 15 * 60
|
|
23
46
|
_AUTH_FAILURE_STATUS_CODES = frozenset({401, 403})
|
|
47
|
+
_RETRY_ON_NETWORK_ERRORS = retry(
|
|
48
|
+
retry=retry_if_exception_type(requests.RequestException),
|
|
49
|
+
stop=stop_after_attempt(3),
|
|
50
|
+
wait=wait_fixed(3),
|
|
51
|
+
)
|
|
24
52
|
|
|
25
53
|
|
|
26
54
|
class GLPIV1Session:
|
|
@@ -36,7 +64,7 @@ class GLPIV1Session:
|
|
|
36
64
|
*,
|
|
37
65
|
base_url: str,
|
|
38
66
|
user_token: str,
|
|
39
|
-
app_token: str,
|
|
67
|
+
app_token: str | None = None,
|
|
40
68
|
verify_ssl: bool = True,
|
|
41
69
|
session_refresh_interval_seconds: int = (
|
|
42
70
|
_DEFAULT_SESSION_REFRESH_INTERVAL_SECONDS
|
|
@@ -59,12 +87,14 @@ class GLPIV1Session:
|
|
|
59
87
|
self._session_token: str | None = None
|
|
60
88
|
self._session_started_at: datetime | None = None
|
|
61
89
|
|
|
62
|
-
@
|
|
90
|
+
@_RETRY_ON_NETWORK_ERRORS
|
|
63
91
|
def _init_session(self) -> None:
|
|
64
92
|
"""Acquire one fresh GLPI v1 session token via ``GET /initSession``.
|
|
65
93
|
|
|
66
94
|
The call replaces any existing session state and stores the
|
|
67
95
|
authentication timestamp used by the refresh-interval check.
|
|
96
|
+
Network errors and 5xx responses are retried; 4xx and payload
|
|
97
|
+
errors propagate immediately as :class:`ValueError`.
|
|
68
98
|
"""
|
|
69
99
|
|
|
70
100
|
headers: dict[str, str] = {
|
|
@@ -75,16 +105,20 @@ class GLPIV1Session:
|
|
|
75
105
|
if self._app_token:
|
|
76
106
|
headers["App-Token"] = self._app_token
|
|
77
107
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
108
|
+
url = f"{self._base_url}/initSession"
|
|
109
|
+
response = self._http.get(url, headers=headers, timeout=30)
|
|
110
|
+
finalize_request_response(
|
|
111
|
+
response,
|
|
112
|
+
method="get",
|
|
113
|
+
url=url,
|
|
114
|
+
success_statuses=(200,),
|
|
115
|
+
logger=logger,
|
|
116
|
+
)
|
|
117
|
+
ensure_response_status(
|
|
118
|
+
response,
|
|
119
|
+
success_statuses=(200,),
|
|
120
|
+
failure_message="GLPI v1 initSession failed",
|
|
82
121
|
)
|
|
83
|
-
if response.status_code != 200:
|
|
84
|
-
raise ValueError(
|
|
85
|
-
"GLPI v1 initSession failed: "
|
|
86
|
-
f"{response.status_code} {response.text[:300]}"
|
|
87
|
-
)
|
|
88
122
|
|
|
89
123
|
token = response.json().get("session_token")
|
|
90
124
|
if not token:
|
|
@@ -140,11 +174,12 @@ class GLPIV1Session:
|
|
|
140
174
|
"""Drop the current GLPI v1 session token and acquire a new one.
|
|
141
175
|
|
|
142
176
|
The previous token is best-effort killed so the GLPI server can release
|
|
143
|
-
the associated session state immediately.
|
|
177
|
+
the associated session state immediately. ``_init_session`` will set
|
|
178
|
+
the new token on success or raise, leaving the existing state
|
|
179
|
+
untouched on failure (the retry decorator handles transients).
|
|
144
180
|
"""
|
|
145
181
|
|
|
146
|
-
|
|
147
|
-
if old_token is not None:
|
|
182
|
+
if self._session_token is not None:
|
|
148
183
|
try:
|
|
149
184
|
self._http.get(
|
|
150
185
|
f"{self._base_url}/killSession",
|
|
@@ -153,8 +188,6 @@ class GLPIV1Session:
|
|
|
153
188
|
)
|
|
154
189
|
except Exception:
|
|
155
190
|
logger.warning("Failed to kill stale GLPI v1 session.", exc_info=True)
|
|
156
|
-
self._session_token = None
|
|
157
|
-
self._session_started_at = None
|
|
158
191
|
self._init_session()
|
|
159
192
|
|
|
160
193
|
def _headers(self) -> dict[str, str]:
|
|
@@ -172,13 +205,19 @@ class GLPIV1Session:
|
|
|
172
205
|
method: str,
|
|
173
206
|
url: str,
|
|
174
207
|
*,
|
|
208
|
+
success_statuses: tuple[int, ...],
|
|
175
209
|
headers: dict[str, str] | None = None,
|
|
176
|
-
**kwargs:
|
|
210
|
+
**kwargs: Any,
|
|
177
211
|
) -> requests.Response:
|
|
178
|
-
"""Send one authenticated GLPI v1 request
|
|
179
|
-
|
|
180
|
-
When the GLPI server rejects the current token
|
|
181
|
-
session and retries the request once
|
|
212
|
+
"""Send one authenticated GLPI v1 request and finalize the response.
|
|
213
|
+
|
|
214
|
+
When the GLPI server rejects the current token the helper renews
|
|
215
|
+
the session and retries the request once. The returned response
|
|
216
|
+
has already been passed through :func:`finalize_request_response`
|
|
217
|
+
so 5xx errors surface as :class:`requests.HTTPError` for the
|
|
218
|
+
outer tenacity retry to catch; non-success statuses outside the
|
|
219
|
+
``success_statuses`` set are logged but otherwise returned for
|
|
220
|
+
the caller to validate with :func:`ensure_response_status`.
|
|
182
221
|
"""
|
|
183
222
|
|
|
184
223
|
request_headers = {**self._headers(), **(headers or {})}
|
|
@@ -187,18 +226,23 @@ class GLPIV1Session:
|
|
|
187
226
|
requests.Response,
|
|
188
227
|
request_method(url, headers=request_headers, **kwargs),
|
|
189
228
|
)
|
|
190
|
-
if
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
229
|
+
if _is_auth_failure_response(response):
|
|
230
|
+
logger.warning(
|
|
231
|
+
"GLPI v1 session token was rejected; refreshing session and "
|
|
232
|
+
"retrying request once."
|
|
233
|
+
)
|
|
234
|
+
self._renew_session()
|
|
235
|
+
request_headers = {**self._headers(), **(headers or {})}
|
|
236
|
+
response = cast(
|
|
237
|
+
requests.Response,
|
|
238
|
+
request_method(url, headers=request_headers, **kwargs),
|
|
239
|
+
)
|
|
240
|
+
return finalize_request_response(
|
|
241
|
+
response,
|
|
242
|
+
method=method,
|
|
243
|
+
url=url,
|
|
244
|
+
success_statuses=success_statuses,
|
|
245
|
+
logger=logger,
|
|
202
246
|
)
|
|
203
247
|
|
|
204
248
|
def close(self) -> None:
|
|
@@ -223,7 +267,84 @@ class GLPIV1Session:
|
|
|
223
267
|
self._session_started_at = None
|
|
224
268
|
self._http.close()
|
|
225
269
|
|
|
226
|
-
@
|
|
270
|
+
@_RETRY_ON_NETWORK_ERRORS
|
|
271
|
+
def request_json(
|
|
272
|
+
self,
|
|
273
|
+
method: str,
|
|
274
|
+
path: str,
|
|
275
|
+
*,
|
|
276
|
+
params: dict[str, object] | None = None,
|
|
277
|
+
json_body: dict[str, object] | None = None,
|
|
278
|
+
success_statuses: tuple[int, ...] = (200, 201, 204, 206),
|
|
279
|
+
failure_message: str | None = None,
|
|
280
|
+
) -> object:
|
|
281
|
+
"""Send one JSON-only authenticated request to the GLPI v1 API.
|
|
282
|
+
|
|
283
|
+
The helper centralises session-token handling, the one-shot retry
|
|
284
|
+
on token rejection, status validation and JSON parsing so callers
|
|
285
|
+
can stay focused on their endpoint semantics. Network errors and
|
|
286
|
+
5xx responses are retried; 4xx and payload errors propagate
|
|
287
|
+
immediately as :class:`ValueError`.
|
|
288
|
+
|
|
289
|
+
Parameters
|
|
290
|
+
----------
|
|
291
|
+
method : str
|
|
292
|
+
HTTP verb (``"GET"``, ``"POST"``, ``"PUT"``, ``"DELETE"``).
|
|
293
|
+
path : str
|
|
294
|
+
Resource path appended to the v1 base URL (without leading
|
|
295
|
+
slash, e.g. ``"PluginFieldsContainer"``).
|
|
296
|
+
params : dict[str, object] | None, optional
|
|
297
|
+
Query-string parameters forwarded to ``requests``.
|
|
298
|
+
json_body : dict[str, object] | None, optional
|
|
299
|
+
JSON body serialised into the request when set. The
|
|
300
|
+
``Content-Type: application/json`` header is added
|
|
301
|
+
automatically.
|
|
302
|
+
success_statuses : tuple[int, ...], optional
|
|
303
|
+
HTTP status codes considered successful (default covers the
|
|
304
|
+
CRUD codes returned by the v1 API).
|
|
305
|
+
failure_message : str | None, optional
|
|
306
|
+
Prefix used in the :class:`ValueError` raised on a
|
|
307
|
+
non-success status. Defaults to ``"GLPI v1 {METHOD} {path}
|
|
308
|
+
failed"``.
|
|
309
|
+
|
|
310
|
+
Returns
|
|
311
|
+
-------
|
|
312
|
+
object
|
|
313
|
+
Parsed JSON body for non-empty responses; an empty ``dict``
|
|
314
|
+
when the body is empty or contains only whitespace.
|
|
315
|
+
|
|
316
|
+
Raises
|
|
317
|
+
------
|
|
318
|
+
ValueError
|
|
319
|
+
If the v1 server returns a non-success HTTP status outside
|
|
320
|
+
the 5xx range (which surfaces as :class:`requests.HTTPError`
|
|
321
|
+
and is retried).
|
|
322
|
+
"""
|
|
323
|
+
|
|
324
|
+
url = f"{self._base_url}/{path.lstrip('/')}"
|
|
325
|
+
kwargs: dict[str, Any] = {"timeout": 30}
|
|
326
|
+
if params is not None:
|
|
327
|
+
kwargs["params"] = params
|
|
328
|
+
headers: dict[str, str] = {}
|
|
329
|
+
if json_body is not None:
|
|
330
|
+
kwargs["data"] = json.dumps(json_body)
|
|
331
|
+
headers["Content-Type"] = "application/json"
|
|
332
|
+
response = self._authenticated_request(
|
|
333
|
+
method,
|
|
334
|
+
url,
|
|
335
|
+
success_statuses=success_statuses,
|
|
336
|
+
headers=headers or None,
|
|
337
|
+
**kwargs,
|
|
338
|
+
)
|
|
339
|
+
ensure_response_status(
|
|
340
|
+
response,
|
|
341
|
+
success_statuses=success_statuses,
|
|
342
|
+
failure_message=failure_message
|
|
343
|
+
or f"GLPI v1 {method.upper()} {path} failed",
|
|
344
|
+
)
|
|
345
|
+
return response_json_or_empty(response)
|
|
346
|
+
|
|
347
|
+
@_RETRY_ON_NETWORK_ERRORS
|
|
227
348
|
def upload_document(
|
|
228
349
|
self,
|
|
229
350
|
filename: str,
|
|
@@ -238,7 +359,9 @@ class GLPIV1Session:
|
|
|
238
359
|
|
|
239
360
|
The legacy v1 endpoint uses a multipart upload manifest so the GLPI
|
|
240
361
|
server can create the document, link it to the optional parent ticket,
|
|
241
|
-
and assign it to the provided entity in a single round-trip.
|
|
362
|
+
and assign it to the provided entity in a single round-trip. Network
|
|
363
|
+
errors and 5xx responses are retried; 4xx and payload errors
|
|
364
|
+
propagate immediately as :class:`ValueError`.
|
|
242
365
|
"""
|
|
243
366
|
|
|
244
367
|
manifest_input: dict[str, object] = {
|
|
@@ -255,17 +378,18 @@ class GLPIV1Session:
|
|
|
255
378
|
response = self._authenticated_request(
|
|
256
379
|
"POST",
|
|
257
380
|
f"{self._base_url}/Document",
|
|
381
|
+
success_statuses=(200, 201),
|
|
258
382
|
files=[
|
|
259
383
|
("uploadManifest", (None, manifest, "application/json")),
|
|
260
384
|
("filename[]", (filename, content, mime_type)),
|
|
261
385
|
],
|
|
262
386
|
timeout=60,
|
|
263
387
|
)
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
388
|
+
ensure_response_status(
|
|
389
|
+
response,
|
|
390
|
+
success_statuses=(200, 201),
|
|
391
|
+
failure_message="GLPI v1 document upload failed",
|
|
392
|
+
)
|
|
269
393
|
payload = response.json()
|
|
270
394
|
if not isinstance(payload, dict):
|
|
271
395
|
raise ValueError(
|
|
@@ -32,22 +32,35 @@ class _FakeV1Http:
|
|
|
32
32
|
def _next(self, key: str) -> FakeResponse:
|
|
33
33
|
return self._responses[key].pop(0)
|
|
34
34
|
|
|
35
|
-
def get(
|
|
35
|
+
def get(
|
|
36
|
+
self,
|
|
37
|
+
url: str,
|
|
38
|
+
headers: dict[str, str],
|
|
39
|
+
timeout: int,
|
|
40
|
+
**kwargs: Any,
|
|
41
|
+
) -> FakeResponse:
|
|
36
42
|
self.calls.append(
|
|
37
|
-
{
|
|
43
|
+
{
|
|
44
|
+
"method": "GET",
|
|
45
|
+
"url": url,
|
|
46
|
+
"headers": headers,
|
|
47
|
+
"timeout": timeout,
|
|
48
|
+
**kwargs,
|
|
49
|
+
}
|
|
38
50
|
)
|
|
39
51
|
if url.endswith("/initSession"):
|
|
40
52
|
return self._next("init")
|
|
41
53
|
if url.endswith("/killSession"):
|
|
42
54
|
return self._next("kill")
|
|
43
|
-
|
|
55
|
+
return self._next("json")
|
|
44
56
|
|
|
45
57
|
def post(
|
|
46
58
|
self,
|
|
47
59
|
url: str,
|
|
48
60
|
headers: dict[str, str],
|
|
49
|
-
files: list[Any],
|
|
50
61
|
timeout: int,
|
|
62
|
+
files: list[Any] | None = None,
|
|
63
|
+
**kwargs: Any,
|
|
51
64
|
) -> FakeResponse:
|
|
52
65
|
self.calls.append(
|
|
53
66
|
{
|
|
@@ -56,9 +69,48 @@ class _FakeV1Http:
|
|
|
56
69
|
"headers": headers,
|
|
57
70
|
"files": files,
|
|
58
71
|
"timeout": timeout,
|
|
72
|
+
**kwargs,
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
if files is not None:
|
|
76
|
+
return self._next("upload")
|
|
77
|
+
return self._next("json")
|
|
78
|
+
|
|
79
|
+
def put(
|
|
80
|
+
self,
|
|
81
|
+
url: str,
|
|
82
|
+
headers: dict[str, str],
|
|
83
|
+
timeout: int,
|
|
84
|
+
**kwargs: Any,
|
|
85
|
+
) -> FakeResponse:
|
|
86
|
+
self.calls.append(
|
|
87
|
+
{
|
|
88
|
+
"method": "PUT",
|
|
89
|
+
"url": url,
|
|
90
|
+
"headers": headers,
|
|
91
|
+
"timeout": timeout,
|
|
92
|
+
**kwargs,
|
|
59
93
|
}
|
|
60
94
|
)
|
|
61
|
-
return self._next("
|
|
95
|
+
return self._next("json")
|
|
96
|
+
|
|
97
|
+
def delete(
|
|
98
|
+
self,
|
|
99
|
+
url: str,
|
|
100
|
+
headers: dict[str, str],
|
|
101
|
+
timeout: int,
|
|
102
|
+
**kwargs: Any,
|
|
103
|
+
) -> FakeResponse:
|
|
104
|
+
self.calls.append(
|
|
105
|
+
{
|
|
106
|
+
"method": "DELETE",
|
|
107
|
+
"url": url,
|
|
108
|
+
"headers": headers,
|
|
109
|
+
"timeout": timeout,
|
|
110
|
+
**kwargs,
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
return self._next("json")
|
|
62
114
|
|
|
63
115
|
def close(self) -> None:
|
|
64
116
|
self.closed = True
|
|
@@ -161,8 +213,8 @@ def test_v1_upload_renews_session_on_401() -> None:
|
|
|
161
213
|
]
|
|
162
214
|
|
|
163
215
|
|
|
164
|
-
def
|
|
165
|
-
"""
|
|
216
|
+
def test_v1_upload_raises_on_5xx_after_retries() -> None:
|
|
217
|
+
"""5xx upload responses surface as ``HTTPError`` after retries exhaust."""
|
|
166
218
|
|
|
167
219
|
http = _FakeV1Http(
|
|
168
220
|
responses={
|
|
@@ -175,28 +227,44 @@ def test_v1_upload_raises_on_non_success() -> None:
|
|
|
175
227
|
with pytest.raises(tenacity.RetryError) as excinfo:
|
|
176
228
|
session.upload_document("a.txt", b"x", "text/plain")
|
|
177
229
|
inner = excinfo.value.last_attempt.exception()
|
|
178
|
-
assert isinstance(inner,
|
|
230
|
+
assert isinstance(inner, requests.HTTPError)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_v1_upload_raises_on_4xx_without_retry() -> None:
|
|
234
|
+
"""Non-5xx non-success upload responses raise ``ValueError`` without retry."""
|
|
235
|
+
|
|
236
|
+
http = _FakeV1Http(
|
|
237
|
+
responses={
|
|
238
|
+
"init": [FakeResponse(status_code=200, payload={"session_token": "tk"})],
|
|
239
|
+
"upload": [FakeResponse(status_code=400, payload={"err": "bad"})],
|
|
240
|
+
"kill": [FakeResponse(status_code=200, payload={})],
|
|
241
|
+
}
|
|
242
|
+
)
|
|
243
|
+
session = _make(http)
|
|
244
|
+
with pytest.raises(ValueError, match="document upload failed"):
|
|
245
|
+
session.upload_document("a.txt", b"x", "text/plain")
|
|
246
|
+
# A single attempt was performed (init + one upload).
|
|
247
|
+
upload_calls = [c for c in http.calls if c["url"].endswith("/Document")]
|
|
248
|
+
assert len(upload_calls) == 1
|
|
179
249
|
|
|
180
250
|
|
|
181
251
|
def test_v1_upload_raises_on_unexpected_payload() -> None:
|
|
182
|
-
"""A non-mapping JSON payload raises ``ValueError``
|
|
252
|
+
"""A non-mapping JSON payload raises ``ValueError`` without retry."""
|
|
183
253
|
|
|
184
254
|
http = _FakeV1Http(
|
|
185
255
|
responses={
|
|
186
256
|
"init": [FakeResponse(status_code=200, payload={"session_token": "tk"})],
|
|
187
|
-
"upload": [FakeResponse(status_code=200, payload=["unexpected"])]
|
|
257
|
+
"upload": [FakeResponse(status_code=200, payload=["unexpected"])],
|
|
188
258
|
"kill": [FakeResponse(status_code=200, payload={})],
|
|
189
259
|
}
|
|
190
260
|
)
|
|
191
261
|
session = _make(http)
|
|
192
|
-
with pytest.raises(
|
|
262
|
+
with pytest.raises(ValueError, match="unexpected payload"):
|
|
193
263
|
session.upload_document("a.txt", b"x", "text/plain")
|
|
194
|
-
inner = excinfo.value.last_attempt.exception()
|
|
195
|
-
assert isinstance(inner, ValueError) and "unexpected payload" in str(inner)
|
|
196
264
|
|
|
197
265
|
|
|
198
|
-
def
|
|
199
|
-
"""``initSession``
|
|
266
|
+
def test_v1_init_raises_on_5xx_after_retries() -> None:
|
|
267
|
+
"""5xx ``initSession`` responses surface as ``HTTPError`` after retries."""
|
|
200
268
|
|
|
201
269
|
http = _FakeV1Http(
|
|
202
270
|
responses={
|
|
@@ -207,20 +275,31 @@ def test_v1_init_raises_on_failure() -> None:
|
|
|
207
275
|
with pytest.raises(tenacity.RetryError) as excinfo:
|
|
208
276
|
session._init_session()
|
|
209
277
|
inner = excinfo.value.last_attempt.exception()
|
|
210
|
-
assert isinstance(inner,
|
|
278
|
+
assert isinstance(inner, requests.HTTPError)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def test_v1_init_raises_on_4xx_without_retry() -> None:
|
|
282
|
+
"""Non-5xx ``initSession`` responses raise ``ValueError`` immediately."""
|
|
283
|
+
|
|
284
|
+
http = _FakeV1Http(
|
|
285
|
+
responses={
|
|
286
|
+
"init": [FakeResponse(status_code=401, payload={"err": "denied"})],
|
|
287
|
+
}
|
|
288
|
+
)
|
|
289
|
+
session = _make(http)
|
|
290
|
+
with pytest.raises(ValueError, match="initSession failed"):
|
|
291
|
+
session._init_session()
|
|
211
292
|
|
|
212
293
|
|
|
213
294
|
def test_v1_init_raises_when_token_missing() -> None:
|
|
214
|
-
"""``initSession`` returning no token raises ``ValueError``
|
|
295
|
+
"""``initSession`` returning no token raises ``ValueError`` without retry."""
|
|
215
296
|
|
|
216
297
|
http = _FakeV1Http(
|
|
217
|
-
responses={"init": [FakeResponse(status_code=200, payload={})]
|
|
298
|
+
responses={"init": [FakeResponse(status_code=200, payload={})]},
|
|
218
299
|
)
|
|
219
300
|
session = _make(http)
|
|
220
|
-
with pytest.raises(
|
|
301
|
+
with pytest.raises(ValueError, match="no session_token"):
|
|
221
302
|
session._init_session()
|
|
222
|
-
inner = excinfo.value.last_attempt.exception()
|
|
223
|
-
assert isinstance(inner, ValueError) and "no session_token" in str(inner)
|
|
224
303
|
|
|
225
304
|
|
|
226
305
|
def test_v1_close_kills_session_and_closes_http() -> None:
|
|
@@ -260,6 +339,90 @@ def test_v1_close_tolerates_kill_failure() -> None:
|
|
|
260
339
|
assert http.closed is True
|
|
261
340
|
|
|
262
341
|
|
|
342
|
+
def test_request_json_sends_body_and_returns_parsed_payload() -> None:
|
|
343
|
+
"""``request_json`` serialises the body and decodes the JSON response."""
|
|
344
|
+
|
|
345
|
+
http = _FakeV1Http(
|
|
346
|
+
responses={
|
|
347
|
+
"init": [FakeResponse(status_code=200, payload={"session_token": "tk"})],
|
|
348
|
+
"json": [FakeResponse(status_code=200, payload={"ok": True})],
|
|
349
|
+
"kill": [FakeResponse(status_code=200, payload={})],
|
|
350
|
+
}
|
|
351
|
+
)
|
|
352
|
+
session = _make(http)
|
|
353
|
+
result = session.request_json(
|
|
354
|
+
"POST",
|
|
355
|
+
"PluginFieldsContainer",
|
|
356
|
+
json_body={"input": {"name": "x"}},
|
|
357
|
+
)
|
|
358
|
+
assert result == {"ok": True}
|
|
359
|
+
post_call = next(call for call in http.calls if call["method"] == "POST")
|
|
360
|
+
assert post_call["url"].endswith("/PluginFieldsContainer")
|
|
361
|
+
assert post_call["data"] == jsonlib.dumps({"input": {"name": "x"}})
|
|
362
|
+
assert post_call["headers"]["Content-Type"] == "application/json"
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def test_request_json_supports_get_with_params() -> None:
|
|
366
|
+
"""``request_json`` forwards query params on GET calls."""
|
|
367
|
+
|
|
368
|
+
http = _FakeV1Http(
|
|
369
|
+
responses={
|
|
370
|
+
"init": [FakeResponse(status_code=200, payload={"session_token": "tk"})],
|
|
371
|
+
"json": [FakeResponse(status_code=200, payload=[{"id": 1}])],
|
|
372
|
+
}
|
|
373
|
+
)
|
|
374
|
+
session = _make(http)
|
|
375
|
+
out = session.request_json("GET", "PluginFieldsContainer", params={"range": "0-1"})
|
|
376
|
+
assert out == [{"id": 1}]
|
|
377
|
+
get_call = next(
|
|
378
|
+
call for call in http.calls if call["url"].endswith("/PluginFieldsContainer")
|
|
379
|
+
)
|
|
380
|
+
assert get_call["params"] == {"range": "0-1"}
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def test_request_json_returns_empty_dict_on_empty_body() -> None:
|
|
384
|
+
"""An empty response body decodes as an empty dict instead of raising."""
|
|
385
|
+
|
|
386
|
+
http = _FakeV1Http(
|
|
387
|
+
responses={
|
|
388
|
+
"init": [FakeResponse(status_code=200, payload={"session_token": "tk"})],
|
|
389
|
+
"json": [FakeResponse(status_code=204, payload={}, content=b"")],
|
|
390
|
+
}
|
|
391
|
+
)
|
|
392
|
+
session = _make(http)
|
|
393
|
+
assert session.request_json("DELETE", "Some/Resource/1") == {}
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def test_request_json_raises_on_4xx_without_retry() -> None:
|
|
397
|
+
"""Non-5xx non-success statuses raise ``ValueError`` without retry."""
|
|
398
|
+
|
|
399
|
+
http = _FakeV1Http(
|
|
400
|
+
responses={
|
|
401
|
+
"init": [FakeResponse(status_code=200, payload={"session_token": "tk"})],
|
|
402
|
+
"json": [FakeResponse(status_code=404, payload={"err": "missing"})],
|
|
403
|
+
}
|
|
404
|
+
)
|
|
405
|
+
session = _make(http)
|
|
406
|
+
with pytest.raises(ValueError, match="failed"):
|
|
407
|
+
session.request_json("GET", "PluginFieldsContainer")
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def test_request_json_retries_on_5xx() -> None:
|
|
411
|
+
"""5xx responses surface as ``HTTPError`` after retries exhaust."""
|
|
412
|
+
|
|
413
|
+
http = _FakeV1Http(
|
|
414
|
+
responses={
|
|
415
|
+
"init": [FakeResponse(status_code=200, payload={"session_token": "tk"})],
|
|
416
|
+
"json": [FakeResponse(status_code=500, payload={"err": "boom"})] * 3,
|
|
417
|
+
}
|
|
418
|
+
)
|
|
419
|
+
session = _make(http)
|
|
420
|
+
with pytest.raises(tenacity.RetryError) as excinfo:
|
|
421
|
+
session.request_json("GET", "PluginFieldsContainer")
|
|
422
|
+
inner = excinfo.value.last_attempt.exception()
|
|
423
|
+
assert isinstance(inner, requests.HTTPError)
|
|
424
|
+
|
|
425
|
+
|
|
263
426
|
def test_session_token_invalid_marker_triggers_renew() -> None:
|
|
264
427
|
"""An ``ERROR_SESSION_TOKEN_INVALID`` body marker counts as an auth failure."""
|
|
265
428
|
|