glpi-python-client 0.3.2__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.
Files changed (32) hide show
  1. glpi_python_client/__init__.py +9 -1
  2. glpi_python_client/auth/_v1_session.py +173 -49
  3. glpi_python_client/auth/tests/test_v1_session.py +184 -21
  4. glpi_python_client/clients/_base_client.py +178 -0
  5. glpi_python_client/clients/api/__init__.py +2 -0
  6. glpi_python_client/clients/api/assistance/timeline/_document.py +21 -20
  7. glpi_python_client/clients/api/management/_document.py +1 -7
  8. glpi_python_client/clients/api/plugins/__init__.py +10 -0
  9. glpi_python_client/clients/api/plugins/_fields.py +411 -0
  10. glpi_python_client/clients/api/plugins/tests/__init__.py +0 -0
  11. glpi_python_client/clients/api/plugins/tests/test_fields_mixin.py +377 -0
  12. glpi_python_client/clients/async_client.py +14 -90
  13. glpi_python_client/clients/commons/_config.py +1 -1
  14. glpi_python_client/clients/commons/_http.py +14 -0
  15. glpi_python_client/clients/commons/_transport.py +29 -0
  16. glpi_python_client/clients/custom/_statistics.py +21 -16
  17. glpi_python_client/clients/custom/_statistics_async.py +19 -14
  18. glpi_python_client/clients/custom/tests/test_statistics.py +2 -2
  19. glpi_python_client/clients/sync_client.py +7 -141
  20. glpi_python_client/clients/tests/test_api_coverage.py +2 -2
  21. glpi_python_client/models/__init__.py +10 -0
  22. glpi_python_client/models/api_schema/plugins/__init__.py +19 -0
  23. glpi_python_client/models/api_schema/plugins/_fields.py +177 -0
  24. glpi_python_client/models/api_schema/plugins/tests/__init__.py +0 -0
  25. glpi_python_client/models/api_schema/plugins/tests/test_fields_schemas.py +82 -0
  26. glpi_python_client/models/custom_schema/_ticket_context.py +12 -9
  27. glpi_python_client/models/custom_schema/tests/test_ticket_context.py +7 -7
  28. glpi_python_client/testing/utils.py +2 -0
  29. {glpi_python_client-0.3.2.dist-info → glpi_python_client-0.3.4.dist-info}/METADATA +1 -1
  30. {glpi_python_client-0.3.2.dist-info → glpi_python_client-0.3.4.dist-info}/RECORD +32 -23
  31. {glpi_python_client-0.3.2.dist-info → glpi_python_client-0.3.4.dist-info}/WHEEL +0 -0
  32. {glpi_python_client-0.3.2.dist-info → glpi_python_client-0.3.4.dist-info}/licenses/LICENSE +0 -0
@@ -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.2"
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 exclusively for document uploads.
2
-
3
- The high-level async ``GlpiClient`` only relies on the legacy v1 API for the
4
- ``POST /Document`` multipart upload endpoint. The session wrapper below owns
5
- the authenticated v1 lifecycle (init, refresh, kill) and exposes a single
6
- ``upload_document`` operation that the management mixin calls through
7
- ``asyncio.to_thread`` at the blocking HTTP boundary.
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
- @retry(stop=stop_after_attempt(3), wait=wait_fixed(3))
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
- response = self._http.get(
79
- f"{self._base_url}/initSession",
80
- headers=headers,
81
- timeout=30,
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
- old_token = self._session_token
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: object,
210
+ **kwargs: Any,
177
211
  ) -> requests.Response:
178
- """Send one authenticated GLPI v1 request with one auth-failure retry.
179
-
180
- When the GLPI server rejects the current token, the helper renews the
181
- session and retries the request once before returning the response.
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 not _is_auth_failure_response(response):
191
- return response
192
-
193
- logger.warning(
194
- "GLPI v1 session token was rejected; refreshing session and retrying "
195
- "request once."
196
- )
197
- self._renew_session()
198
- request_headers = {**self._headers(), **(headers or {})}
199
- return cast(
200
- requests.Response,
201
- request_method(url, headers=request_headers, **kwargs),
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
- @retry(stop=stop_after_attempt(3), wait=wait_fixed(3))
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
- if response.status_code not in (200, 201):
265
- raise ValueError(
266
- "GLPI v1 document upload failed: "
267
- f"{response.status_code} {response.text[:300]}"
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(self, url: str, headers: dict[str, str], timeout: int) -> FakeResponse:
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
- {"method": "GET", "url": url, "headers": headers, "timeout": timeout}
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
- raise AssertionError(f"Unexpected GET {url}")
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("upload")
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 test_v1_upload_raises_on_non_success() -> None:
165
- """Non-success upload responses raise ``ValueError`` (after retries)."""
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, ValueError) and "document upload failed" in str(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`` (after retries)."""
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"])] * 3,
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(tenacity.RetryError) as excinfo:
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 test_v1_init_raises_on_failure() -> None:
199
- """``initSession`` failure raises ``ValueError`` after retries exhaust."""
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, ValueError) and "initSession failed" in str(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`` after retries."""
295
+ """``initSession`` returning no token raises ``ValueError`` without retry."""
215
296
 
216
297
  http = _FakeV1Http(
217
- responses={"init": [FakeResponse(status_code=200, payload={})] * 3},
298
+ responses={"init": [FakeResponse(status_code=200, payload={})]},
218
299
  )
219
300
  session = _make(http)
220
- with pytest.raises(tenacity.RetryError) as excinfo:
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