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,219 @@
1
+ """Configuration and resource setup for GLPI v2 clients.
2
+
3
+ This module centralizes environment parsing, URL normalization, SSL warning
4
+ behavior, and the construction of shared runtime resources used by both the
5
+ sync and async high-level clients.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Mapping
11
+ from dataclasses import dataclass
12
+ from typing import TYPE_CHECKING
13
+
14
+ import requests
15
+ import urllib3
16
+
17
+ if TYPE_CHECKING:
18
+ from glpi_python_client.auth.auth import GLPITokenManager
19
+ from glpi_python_client.clients.api_v1_session import GLPIV1Session
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class ClientResources:
24
+ """Runtime resources shared by sync and async v2 clients.
25
+
26
+ The concrete client classes use this immutable bundle to keep setup logic
27
+ centralized while still owning the resource lifecycle themselves.
28
+ """
29
+
30
+ glpi_api_url: str
31
+ session: requests.Session
32
+ auth: GLPITokenManager
33
+ v1: GLPIV1Session | None
34
+
35
+
36
+ def configure_ssl_warning_policy(*, verify_ssl: bool) -> None:
37
+ """Adjust insecure-request warning behavior for the configured SSL policy.
38
+
39
+ When certificate verification is disabled, urllib3 warnings are muted so
40
+ callers do not get repeated noise from every request made by the client.
41
+ """
42
+
43
+ if verify_ssl:
44
+ return
45
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
46
+
47
+
48
+ def build_v2_client_resources(
49
+ *,
50
+ glpi_api_url: object,
51
+ client_name: str,
52
+ client_id: str | None,
53
+ client_secret: str | None,
54
+ username: str | None,
55
+ password: str | None,
56
+ verify_ssl: bool,
57
+ auth_token_refresh: int | None,
58
+ v1_base_url: str | None,
59
+ v1_user_token: str | None,
60
+ v1_app_token: str | None,
61
+ ) -> ClientResources:
62
+ """Build the shared resources required by a v2 client instance.
63
+
64
+ This includes the normalized API URL, the shared ``requests`` session, the
65
+ OAuth token manager, and the optional legacy v1 session used for document
66
+ uploads.
67
+ """
68
+
69
+ from glpi_python_client.auth.auth import GLPITokenManager
70
+ from glpi_python_client.clients.api_v1_session import GLPIV1Session
71
+
72
+ normalized_api_url = normalize_client_api_url(
73
+ glpi_api_url,
74
+ client_name=client_name,
75
+ )
76
+ validate_v1_document_config(
77
+ v1_base_url=v1_base_url,
78
+ v1_user_token=v1_user_token,
79
+ )
80
+ configure_ssl_warning_policy(verify_ssl=verify_ssl)
81
+
82
+ session = requests.Session()
83
+ session.verify = verify_ssl
84
+ try:
85
+ auth = GLPITokenManager(
86
+ token_url=f"{normalized_api_url}/token",
87
+ client_id=client_id,
88
+ client_secret=client_secret,
89
+ username=username,
90
+ password=password,
91
+ session=session,
92
+ auth_token_refresh=auth_token_refresh,
93
+ )
94
+ except Exception:
95
+ session.close()
96
+ raise
97
+
98
+ v1: GLPIV1Session | None = None
99
+ if v1_base_url and v1_user_token:
100
+ v1 = GLPIV1Session(
101
+ base_url=v1_base_url,
102
+ user_token=v1_user_token,
103
+ app_token=v1_app_token or "",
104
+ verify_ssl=verify_ssl,
105
+ )
106
+
107
+ return ClientResources(
108
+ glpi_api_url=normalized_api_url,
109
+ session=session,
110
+ auth=auth,
111
+ v1=v1,
112
+ )
113
+
114
+
115
+ def parse_optional_env_int(value: object) -> int | None:
116
+ """Parse one optional integer from an environment-style value.
117
+
118
+ ``None`` is preserved, native integers are accepted as-is, and strings are
119
+ converted through ``int()`` so explicit overrides and environment values
120
+ follow the same normalization path.
121
+ """
122
+
123
+ if value is None:
124
+ return None
125
+ if isinstance(value, int):
126
+ return value
127
+ if isinstance(value, str):
128
+ return int(value)
129
+ raise TypeError("Integer environment values must be strings or integers")
130
+
131
+
132
+ def parse_optional_env_bool(value: object, *, default: bool) -> bool:
133
+ """Parse one optional boolean from an environment-style value.
134
+
135
+ String values follow the conventional true and false spellings accepted by
136
+ the package configuration helpers, while ``None`` falls back to the caller
137
+ provided default.
138
+ """
139
+
140
+ if value is None:
141
+ return default
142
+ if isinstance(value, bool):
143
+ return value
144
+ if not isinstance(value, str):
145
+ raise TypeError("Boolean environment values must be strings or booleans")
146
+ if value.casefold() in {"1", "true", "yes", "on"}:
147
+ return True
148
+ if value.casefold() in {"0", "false", "no", "off"}:
149
+ return False
150
+ raise ValueError(f"Invalid boolean environment value: {value!r}")
151
+
152
+
153
+ def build_client_env_config(
154
+ *,
155
+ prefix: str,
156
+ env: Mapping[str, str],
157
+ overrides: Mapping[str, object],
158
+ ) -> dict[str, object]:
159
+ """Build common GLPI client config values from environment variables.
160
+
161
+ The returned mapping matches the constructor keyword arguments accepted by
162
+ both public client classes, making it suitable for direct unpacking.
163
+ """
164
+
165
+ config: dict[str, object] = {
166
+ "glpi_api_url": env.get(f"{prefix}API_URL"),
167
+ "client_id": env.get(f"{prefix}CLIENT_ID"),
168
+ "client_secret": env.get(f"{prefix}CLIENT_SECRET"),
169
+ "username": env.get(f"{prefix}USERNAME"),
170
+ "password": env.get(f"{prefix}PASSWORD"),
171
+ "glpi_entity": parse_optional_env_int(env.get(f"{prefix}ENTITY")),
172
+ "glpi_profile": parse_optional_env_int(env.get(f"{prefix}PROFILE")),
173
+ "entity_recursive": parse_optional_env_bool(
174
+ env.get(f"{prefix}ENTITY_RECURSIVE"),
175
+ default=False,
176
+ ),
177
+ "language": env.get(f"{prefix}LANGUAGE") or "en_GB",
178
+ "verify_ssl": parse_optional_env_bool(
179
+ env.get(f"{prefix}VERIFY_SSL"),
180
+ default=True,
181
+ ),
182
+ "auth_token_refresh": parse_optional_env_int(
183
+ env.get(f"{prefix}AUTH_TOKEN_REFRESH")
184
+ ),
185
+ "v1_base_url": env.get(f"{prefix}V1_BASE_URL"),
186
+ "v1_user_token": env.get(f"{prefix}V1_USER_TOKEN"),
187
+ "v1_app_token": env.get(f"{prefix}V1_APP_TOKEN"),
188
+ }
189
+ config.update(overrides)
190
+ return config
191
+
192
+
193
+ def normalize_client_api_url(glpi_api_url: object, *, client_name: str) -> str:
194
+ """Validate and normalize the configured GLPI API base URL.
195
+
196
+ The helper rejects missing or non-string values early and strips a trailing
197
+ slash so endpoint assembly remains consistent across the client codebase.
198
+ """
199
+
200
+ if not isinstance(glpi_api_url, str) or not glpi_api_url:
201
+ raise ValueError(f"{client_name} requires glpi_api_url")
202
+ return glpi_api_url.rstrip("/")
203
+
204
+
205
+ def validate_v1_document_config(
206
+ *,
207
+ v1_base_url: str | None,
208
+ v1_user_token: str | None,
209
+ ) -> None:
210
+ """Validate the paired legacy v1 document configuration values.
211
+
212
+ Document uploads require both the legacy base URL and the user token. This
213
+ helper rejects partial configuration before a client is constructed.
214
+ """
215
+
216
+ if bool(v1_base_url) != bool(v1_user_token):
217
+ raise ValueError(
218
+ "GLPI v1 document support requires both v1_base_url and v1_user_token."
219
+ )
@@ -0,0 +1,45 @@
1
+ """GLPI v2 endpoint names and shared type aliases.
2
+
3
+ This module keeps string constants and lightweight aliases in one place so the
4
+ client layers can share endpoint paths and request parameter types without
5
+ repeating literals.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TypeAlias
11
+
12
+ GlpiId: TypeAlias = str | int
13
+ RequestParamValue: TypeAlias = str | int | float | bytes | None
14
+
15
+ TICKET_ENDPOINT = "Assistance/Ticket"
16
+ FOLLOWUP_SUFFIX = "Timeline/Followup"
17
+ TASK_SUFFIX = "Timeline/Task"
18
+ SOLUTION_SUFFIX = "Timeline/Solution"
19
+ DOCUMENT_SUFFIX = "Timeline/Document"
20
+ TEAM_MEMBER_SUFFIX = "TeamMember"
21
+ USER_ENDPOINT = "Administration/User"
22
+ LOCATION_ENDPOINT = "Dropdowns/Location"
23
+
24
+ LIST_TICKET_CORE_FIELDS = [
25
+ "id",
26
+ "name",
27
+ "content",
28
+ "is_deleted",
29
+ "status",
30
+ "urgency",
31
+ "impact",
32
+ "priority",
33
+ "type",
34
+ "external_id",
35
+ "date_creation",
36
+ "date_mod",
37
+ "date_close",
38
+ "category",
39
+ "entity",
40
+ "location",
41
+ "request_type",
42
+ "team",
43
+ "user_recipient",
44
+ "user_editor",
45
+ ]
@@ -0,0 +1,23 @@
1
+ """Error formatting helpers for GLPI v2 client operations.
2
+
3
+ These helpers normalize exception messages so retry wrappers and direct remote
4
+ call failures surface a readable error string to higher-level client methods.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from tenacity import RetryError
10
+
11
+
12
+ def remote_error_message(exc: Exception) -> str:
13
+ """Return a readable message for one remote-call exception.
14
+
15
+ ``tenacity.RetryError`` instances are unwrapped to expose the underlying
16
+ failure message instead of the retry wrapper representation.
17
+ """
18
+
19
+ if isinstance(exc, RetryError):
20
+ inner_exception = exc.last_attempt.exception()
21
+ if isinstance(inner_exception, Exception):
22
+ return str(inner_exception)
23
+ return str(exc)
@@ -0,0 +1,30 @@
1
+ """RSQL filter helpers for GLPI v2 search endpoints.
2
+
3
+ The high-level client uses these helpers to build safe text-search filters for
4
+ GLPI endpoints that accept RSQL-like query expressions.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ def rsql_contains_filter(field: str, value: str) -> str | None:
11
+ """Build a contains-style RSQL filter for one text field.
12
+
13
+ Blank search input returns ``None`` so callers can skip adding the filter,
14
+ while non-empty input is escaped before being wrapped in wildcard syntax.
15
+ """
16
+
17
+ text = value.strip()
18
+ if not text:
19
+ return None
20
+ return f'{field}=like="*{escape_rsql_like_value(text)}*"'
21
+
22
+
23
+ def escape_rsql_like_value(value: str) -> str:
24
+ """Escape user text embedded in a quoted RSQL ``like`` value.
25
+
26
+ The helper protects backslashes, quotes, and wildcard characters so caller
27
+ input is treated as text instead of modifying the filter expression.
28
+ """
29
+
30
+ return value.replace("\\", "\\\\").replace('"', '\\"').replace("*", "\\*")
@@ -0,0 +1,57 @@
1
+ """Request payload builders for GLPI v2 client operations.
2
+
3
+ These helpers keep small but repeated mutation payload rules out of the public
4
+ client methods so sync and async implementations can share them directly.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .constants import GlpiId
10
+
11
+
12
+ def build_team_member_payload(
13
+ *,
14
+ member_type: str,
15
+ member_id: int,
16
+ role: str,
17
+ ) -> dict[str, object]:
18
+ """Build the API payload used to add or remove one team member.
19
+
20
+ The returned mapping matches the shape expected by the GLPI team-member
21
+ endpoint for both creation and removal workflows.
22
+ """
23
+
24
+ return {
25
+ "type": member_type,
26
+ "id": member_id,
27
+ "role": role,
28
+ }
29
+
30
+
31
+ def prepare_document_upload(
32
+ *,
33
+ ticket_id: GlpiId | None,
34
+ filename: str | None,
35
+ content: bytes | None,
36
+ mime_type: str | None,
37
+ ) -> tuple[int, str, bytes, str, str]:
38
+ """Validate a document upload request and return normalized upload data.
39
+
40
+ This helper enforces the package-level upload prerequisites and converts the
41
+ mixed model fields into the concrete values required by the legacy v1 upload
42
+ API.
43
+ """
44
+
45
+ if ticket_id is None:
46
+ raise ValueError("GLPI document upload requires a ticket_id")
47
+ if filename is None:
48
+ raise ValueError("GLPI document upload requires a filename")
49
+ if content is None:
50
+ raise ValueError("GLPI document upload requires file content")
51
+
52
+ parsed_ticket_id = int(ticket_id)
53
+ document_name = f"Document ticket {parsed_ticket_id}"
54
+ effective_mime_type = (
55
+ mime_type if mime_type is not None else "application/octet-stream"
56
+ )
57
+ return parsed_ticket_id, filename, content, effective_mime_type, document_name
@@ -0,0 +1,195 @@
1
+ """HTTP request and response helpers for GLPI v2 clients.
2
+
3
+ This module contains the small transport-agnostic helpers used by both sync
4
+ and async request layers to normalize parameters, assemble headers, and handle
5
+ common response validation rules.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from collections.abc import Mapping
12
+
13
+ import requests
14
+
15
+ from glpi_python_client.content.records.core.scalars import _optional_text
16
+
17
+ from .constants import RequestParamValue
18
+
19
+
20
+ def request_params(
21
+ params: dict[str, object] | None,
22
+ ) -> dict[str, RequestParamValue] | None:
23
+ """Normalize query parameters into ``requests``-compatible values.
24
+
25
+ Each value is converted through ``request_param_value`` so callers can pass
26
+ richer Python objects without repeating serialization logic.
27
+ """
28
+
29
+ if params is None:
30
+ return None
31
+ return {key: request_param_value(value) for key, value in params.items()}
32
+
33
+
34
+ def request_param_value(value: object) -> RequestParamValue:
35
+ """Normalize one query parameter value for ``requests``.
36
+
37
+ Native scalar values are preserved and any other object is stringified so
38
+ higher-level client code can pass enums and IDs without special handling.
39
+ """
40
+
41
+ if value is None or isinstance(value, str | int | float | bytes):
42
+ return value
43
+ return str(value)
44
+
45
+
46
+ def require_access_token(access_token: str | None) -> str:
47
+ """Return a usable access token or raise when it is missing.
48
+
49
+ Transport helpers call this right before request dispatch so missing token
50
+ state turns into a clear local error instead of a malformed API call.
51
+ """
52
+
53
+ if not access_token:
54
+ raise ValueError("Failed to acquire access token for API request")
55
+ return access_token
56
+
57
+
58
+ def build_request_headers(
59
+ *,
60
+ access_token: str | None,
61
+ language: str,
62
+ glpi_entity: int | None,
63
+ glpi_profile: int | None,
64
+ entity_recursive: bool,
65
+ include_content_type: bool = False,
66
+ skip_entity: bool = False,
67
+ ) -> dict[str, str]:
68
+ """Build GLPI request headers from one client state snapshot.
69
+
70
+ The header set includes authorization and language settings, with optional
71
+ entity, profile, recursion, and content-type headers derived from the
72
+ current client configuration.
73
+ """
74
+
75
+ headers = {
76
+ "Authorization": f"Bearer {access_token}",
77
+ "Accept": "application/json",
78
+ "Accept-Language": language,
79
+ }
80
+ if include_content_type:
81
+ headers["Content-Type"] = "application/json"
82
+ if not skip_entity:
83
+ if glpi_entity is not None:
84
+ headers["GLPI-Entity"] = str(glpi_entity)
85
+ if glpi_profile is not None:
86
+ headers["GLPI-Profile"] = str(glpi_profile)
87
+ if entity_recursive:
88
+ headers["GLPI-Entity-Recursive"] = "true"
89
+ return headers
90
+
91
+
92
+ def build_request_url(glpi_api_url: str, endpoint: str) -> str:
93
+ """Return the absolute URL for one GLPI endpoint path.
94
+
95
+ Callers provide the normalized API base URL and the endpoint suffix that is
96
+ already specific to the requested resource.
97
+ """
98
+
99
+ return f"{glpi_api_url}/{endpoint}"
100
+
101
+
102
+ def finalize_request_response(
103
+ response: requests.Response,
104
+ *,
105
+ method: str,
106
+ url: str,
107
+ success_statuses: tuple[int, ...],
108
+ logger: logging.Logger,
109
+ ) -> requests.Response:
110
+ """Validate one GLPI transport response and preserve warning behavior.
111
+
112
+ Server errors are raised immediately while non-success statuses outside the
113
+ accepted set are logged for higher-level mutation and lookup helpers to
114
+ interpret consistently.
115
+ """
116
+
117
+ method_name = method.upper()
118
+ if 500 <= response.status_code < 600:
119
+ message = (
120
+ f"GLPI {method_name} {url} failed with "
121
+ f"{response.status_code} {response.reason}"
122
+ )
123
+ logger.warning(message)
124
+ raise requests.HTTPError(message)
125
+ if response.status_code not in success_statuses:
126
+ logger.warning(
127
+ "GLPI %s %s returned %s: %s",
128
+ method_name,
129
+ url,
130
+ response.status_code,
131
+ response.text[:200],
132
+ )
133
+ return response
134
+
135
+
136
+ def ensure_response_status(
137
+ response: requests.Response,
138
+ *,
139
+ success_statuses: tuple[int, ...],
140
+ failure_message: str,
141
+ ) -> None:
142
+ """Raise a consistent ``ValueError`` for an unexpected response status.
143
+
144
+ Higher-level client methods use this helper to keep their mutation and fetch
145
+ failure messages aligned across sync and async call sites.
146
+ """
147
+
148
+ if response.status_code not in success_statuses:
149
+ raise ValueError(
150
+ f"{failure_message}: {response.status_code} {response.text[:200]}"
151
+ )
152
+
153
+
154
+ def response_json_mapping(response: requests.Response) -> Mapping[str, object]:
155
+ """Return the JSON response payload as a mapping when possible.
156
+
157
+ Empty response bodies become an empty mapping and non-mapping JSON payloads
158
+ are intentionally ignored so callers can safely probe expected keys.
159
+ """
160
+
161
+ result = response.json() if response.content else {}
162
+ return result if isinstance(result, Mapping) else {}
163
+
164
+
165
+ def require_response_text(
166
+ response: requests.Response,
167
+ *,
168
+ keys: tuple[str, ...],
169
+ missing_message: str,
170
+ ) -> str:
171
+ """Return the first non-empty text field from a JSON response mapping.
172
+
173
+ This is primarily used for create responses that may expose the created ID
174
+ under one of several field names.
175
+ """
176
+
177
+ result = response_json_mapping(response)
178
+ for key in keys:
179
+ value = _optional_text(result.get(key))
180
+ if value is not None:
181
+ return value
182
+ raise ValueError(missing_message)
183
+
184
+
185
+ def require_non_empty_text(value: object, *, error_message: str) -> str:
186
+ """Return stripped text or raise when the value is empty.
187
+
188
+ Validation stays centralized here so operation-level preconditions use the
189
+ same message and whitespace-trimming behavior throughout the package.
190
+ """
191
+
192
+ text = _optional_text(value)
193
+ if text is None:
194
+ raise ValueError(error_message)
195
+ return text
@@ -0,0 +1,76 @@
1
+ """Response payload extraction helpers for GLPI v2 clients.
2
+
3
+ These functions turn raw JSON and timeline payloads into predictable lists of
4
+ mapping items before higher-level parsers convert them into typed models.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable
10
+ from typing import Any, TypeVar
11
+
12
+ import requests
13
+
14
+ from glpi_python_client.content.records.core.normalization import (
15
+ _normalize_timeline_records,
16
+ )
17
+
18
+ RecordT = TypeVar("RecordT")
19
+
20
+
21
+ def list_payload_items(payload: object) -> list[dict[str, Any]]:
22
+ """Return dictionary items from one plain JSON list payload.
23
+
24
+ Non-list payloads are treated as empty so callers can safely use this on
25
+ API responses that may vary or fail validation upstream.
26
+ """
27
+
28
+ if not isinstance(payload, list):
29
+ return []
30
+ return [item for item in payload if isinstance(item, dict)]
31
+
32
+
33
+ def timeline_payload_items(payload: object) -> list[dict[str, Any]]:
34
+ """Return dictionary items from one GLPI timeline payload.
35
+
36
+ Timeline payloads go through the shared normalization step first because
37
+ GLPI can nest timeline items in multiple container shapes.
38
+ """
39
+
40
+ return [
41
+ item for item in _normalize_timeline_records(payload) if isinstance(item, dict)
42
+ ]
43
+
44
+
45
+ def list_payload_records(
46
+ payload: object,
47
+ *,
48
+ record_factory: Callable[[dict[str, Any]], RecordT | None],
49
+ ) -> list[RecordT]:
50
+ """Build typed records from one plain JSON list payload.
51
+
52
+ The provided factory may return ``None`` to skip individual raw items while
53
+ preserving the rest of the batch.
54
+ """
55
+
56
+ records: list[RecordT] = []
57
+ for item in list_payload_items(payload):
58
+ record = record_factory(item)
59
+ if record is not None:
60
+ records.append(record)
61
+ return records
62
+
63
+
64
+ def timeline_records_from_response(
65
+ response: requests.Response,
66
+ *,
67
+ record_factory: Callable[[dict[str, Any]], RecordT],
68
+ ) -> list[RecordT]:
69
+ """Build typed records from one successful GLPI timeline response.
70
+
71
+ Unlike plain list payload handling, timeline parsing assumes each normalized
72
+ item should produce a record and lets the record factory raise on invalid
73
+ data.
74
+ """
75
+
76
+ return [record_factory(item) for item in timeline_payload_items(response.json())]