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,246 @@
1
+ """Synchronous GLPI v2 transport methods.
2
+
3
+ This module owns the authenticated ``requests`` call helpers used by the sync
4
+ mixins to communicate with the GLPI high-level API.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import TYPE_CHECKING, Any, cast
11
+
12
+ import requests
13
+ from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
14
+
15
+ from glpi_python_client.clients.v2.common.request_http import (
16
+ build_request_headers,
17
+ build_request_url,
18
+ finalize_request_response,
19
+ request_params,
20
+ require_access_token,
21
+ )
22
+
23
+ if TYPE_CHECKING:
24
+ from glpi_python_client.auth.auth import GLPITokenManager
25
+ from glpi_python_client.clients.api_v1_session import GLPIV1Session
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class SyncTransportMixin:
31
+ """Synchronous GLPI API transport helpers.
32
+
33
+ The transport mixin keeps token handling, header construction, retries, and
34
+ request dispatch out of the endpoint-specific synchronous mixins.
35
+ """
36
+
37
+ _auth: GLPITokenManager
38
+ _auth_lock: Any
39
+ _closed: bool = False
40
+ _session: requests.Session
41
+ _v1: GLPIV1Session | None
42
+ entity_recursive: bool
43
+ glpi_api_url: str
44
+ glpi_entity: int | None
45
+ glpi_profile: int | None
46
+ language: str
47
+
48
+ def _ensure_open(self) -> None:
49
+ """Raise when the client has already been closed.
50
+
51
+ All synchronous transport helpers call this guard before touching the
52
+ shared HTTP session.
53
+ """
54
+
55
+ if self._closed:
56
+ raise RuntimeError("GLPI client is closed")
57
+
58
+ def _ensure_token(self) -> None:
59
+ """Ensure that a valid GLPI access token is available.
60
+
61
+ Token refresh is protected by the client lock so concurrent sync calls
62
+ do not race while updating shared authentication state.
63
+ """
64
+
65
+ self._ensure_open()
66
+ with self._auth_lock:
67
+ self._auth.ensure_token()
68
+
69
+ def _get_headers(
70
+ self,
71
+ *,
72
+ include_content_type: bool = False,
73
+ skip_entity: bool = False,
74
+ ) -> dict[str, str]:
75
+ """Build GLPI request headers for the current client state.
76
+
77
+ This convenience wrapper forwards the current transport state to the
78
+ shared header builder used across sync and async implementations.
79
+ """
80
+
81
+ return build_request_headers(
82
+ access_token=self._auth.access_token,
83
+ language=self.language,
84
+ glpi_entity=self.glpi_entity,
85
+ glpi_profile=self.glpi_profile,
86
+ entity_recursive=self.entity_recursive,
87
+ include_content_type=include_content_type,
88
+ skip_entity=skip_entity,
89
+ )
90
+
91
+ def _send_request(
92
+ self,
93
+ method: str,
94
+ url: str,
95
+ **kwargs: object,
96
+ ) -> requests.Response:
97
+ request_method = getattr(self._session, method)
98
+ return cast(requests.Response, request_method(url, **kwargs))
99
+
100
+ def _execute_request(
101
+ self,
102
+ *,
103
+ method: str,
104
+ endpoint: str,
105
+ success_statuses: tuple[int, ...],
106
+ params: dict[str, object] | None = None,
107
+ json_body: dict[str, object] | None = None,
108
+ skip_entity: bool = False,
109
+ include_content_type: bool = False,
110
+ ) -> requests.Response:
111
+ """Execute one authenticated GLPI request.
112
+
113
+ The helper normalizes the endpoint URL, headers, timeout, and payload
114
+ placement before handing the response to the shared validation logic.
115
+ """
116
+
117
+ self._ensure_token()
118
+ access_token = require_access_token(self._auth.access_token)
119
+ url = build_request_url(self.glpi_api_url, endpoint)
120
+
121
+ request_kwargs: dict[str, object] = {
122
+ "headers": build_request_headers(
123
+ access_token=access_token,
124
+ language=self.language,
125
+ glpi_entity=self.glpi_entity,
126
+ glpi_profile=self.glpi_profile,
127
+ entity_recursive=self.entity_recursive,
128
+ include_content_type=include_content_type,
129
+ skip_entity=skip_entity,
130
+ ),
131
+ "timeout": 30,
132
+ }
133
+ if method == "get":
134
+ request_kwargs["params"] = request_params(params)
135
+ else:
136
+ request_kwargs["json"] = json_body
137
+
138
+ response = self._send_request(method, url, **request_kwargs)
139
+ return finalize_request_response(
140
+ response,
141
+ method=method,
142
+ url=url,
143
+ success_statuses=success_statuses,
144
+ logger=logger,
145
+ )
146
+
147
+ @retry(
148
+ retry=retry_if_exception_type(requests.RequestException),
149
+ stop=stop_after_attempt(3),
150
+ wait=wait_fixed(3),
151
+ )
152
+ def _get_request(
153
+ self,
154
+ endpoint: str,
155
+ params: dict[str, object] | None = None,
156
+ skip_entity: bool = False,
157
+ ) -> requests.Response:
158
+ """Execute one authenticated GLPI ``GET`` request.
159
+
160
+ Network-level request exceptions are retried according to the transport
161
+ retry policy before the response is returned to the caller.
162
+ """
163
+
164
+ return self._execute_request(
165
+ method="get",
166
+ endpoint=endpoint,
167
+ success_statuses=(200, 206),
168
+ params=params,
169
+ skip_entity=skip_entity,
170
+ )
171
+
172
+ @retry(
173
+ retry=retry_if_exception_type(requests.RequestException),
174
+ stop=stop_after_attempt(3),
175
+ wait=wait_fixed(3),
176
+ )
177
+ def _post_request(
178
+ self,
179
+ endpoint: str,
180
+ json_body: dict[str, object] | None = None,
181
+ skip_entity: bool = False,
182
+ ) -> requests.Response:
183
+ """Execute one authenticated GLPI ``POST`` request.
184
+
185
+ JSON request bodies automatically include the content-type header needed
186
+ by the GLPI API.
187
+ """
188
+
189
+ return self._execute_request(
190
+ method="post",
191
+ endpoint=endpoint,
192
+ success_statuses=(200, 201),
193
+ json_body=json_body,
194
+ skip_entity=skip_entity,
195
+ include_content_type=True,
196
+ )
197
+
198
+ @retry(
199
+ retry=retry_if_exception_type(requests.RequestException),
200
+ stop=stop_after_attempt(3),
201
+ wait=wait_fixed(3),
202
+ )
203
+ def _update_request(
204
+ self,
205
+ endpoint: str,
206
+ json_body: dict[str, object] | None = None,
207
+ ) -> requests.Response:
208
+ """Execute one authenticated GLPI ``PATCH`` request.
209
+
210
+ The helper uses the same authenticated execution path as the other HTTP
211
+ verbs while targeting the success codes expected from update calls.
212
+ """
213
+
214
+ return self._execute_request(
215
+ method="patch",
216
+ endpoint=endpoint,
217
+ success_statuses=(200, 204),
218
+ json_body=json_body,
219
+ include_content_type=True,
220
+ )
221
+
222
+ @retry(
223
+ retry=retry_if_exception_type(requests.RequestException),
224
+ stop=stop_after_attempt(3),
225
+ wait=wait_fixed(3),
226
+ )
227
+ def _delete_request(
228
+ self,
229
+ endpoint: str,
230
+ json_body: dict[str, object] | None = None,
231
+ skip_entity: bool = False,
232
+ ) -> requests.Response:
233
+ """Execute one authenticated GLPI ``DELETE`` request.
234
+
235
+ Some delete endpoints accept a JSON body, so the content-type header is
236
+ enabled automatically when a body is supplied.
237
+ """
238
+
239
+ return self._execute_request(
240
+ method="delete",
241
+ endpoint=endpoint,
242
+ success_statuses=(200, 204),
243
+ json_body=json_body,
244
+ skip_entity=skip_entity,
245
+ include_content_type=json_body is not None,
246
+ )
@@ -0,0 +1,11 @@
1
+ """Public content-layer exports for the GLPI client package.
2
+
3
+ The content package contains helpers that translate between GLPI transport
4
+ payloads and the package's canonical Markdown and typed-record representations.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from glpi_python_client.content.conversion import GlpiContentConverter
10
+
11
+ __all__ = ["GlpiContentConverter"]
@@ -0,0 +1,58 @@
1
+ """Content conversion helpers for GLPI payloads.
2
+
3
+ This module translates between GLPI's HTML transport format and the package's
4
+ canonical Markdown representation used by the rich content models.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from markdown import markdown as markdown_to_html
10
+ from markdownify import markdownify as html_to_markdown
11
+
12
+
13
+ class GlpiContentConverter:
14
+ """Convert content between GLPI HTML payloads and canonical Markdown.
15
+
16
+ The converter keeps the translation rules in one place so ticket, followup,
17
+ task, and solution parsing all share the same content normalization.
18
+ """
19
+
20
+ @staticmethod
21
+ def from_transport(value: object) -> str:
22
+ """Convert one GLPI transport value into canonical Markdown.
23
+
24
+ Empty input stays empty, plain text is preserved, and HTML content is
25
+ normalized through ``markdownify`` with the package's preferred options.
26
+ """
27
+
28
+ content = str(value or "")
29
+ if not content.strip():
30
+ return ""
31
+ if "<" not in content or ">" not in content:
32
+ return content.strip()
33
+
34
+ markdown = html_to_markdown(
35
+ content,
36
+ heading_style="ATX",
37
+ bullets="-",
38
+ strip=["script", "style"],
39
+ )
40
+ return str(markdown).strip()
41
+
42
+ @staticmethod
43
+ def to_transport(value: object) -> str:
44
+ """Convert one canonical Markdown value into GLPI HTML.
45
+
46
+ Empty Markdown stays empty, while non-empty content is rendered through
47
+ the configured Markdown extensions used by the package.
48
+ """
49
+
50
+ markdown = str(value or "")
51
+ if not markdown.strip():
52
+ return ""
53
+ html = markdown_to_html(
54
+ markdown,
55
+ extensions=["nl2br", "sane_lists"],
56
+ output_format="html5",
57
+ )
58
+ return str(html).strip()
@@ -0,0 +1,84 @@
1
+ """Compatibility exports for GLPI record parsing helpers.
2
+
3
+ The real parsing implementation is split between ``core`` shared helpers and
4
+ ``parsers`` for model-specific payload conversion. This package preserves the
5
+ older aggregate import path for internal callers.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from glpi_python_client.content.records.core.document_links import (
11
+ _glpi_document_id_from_url,
12
+ _glpi_followup_attachment_document_ids,
13
+ _strip_glpi_document_references,
14
+ )
15
+ from glpi_python_client.content.records.core.normalization import (
16
+ _normalize_ticket_record,
17
+ _normalize_timeline_records,
18
+ _unwrap_timeline_item,
19
+ )
20
+ from glpi_python_client.content.records.core.references import (
21
+ _glpi_id_reference,
22
+ _glpi_id_value,
23
+ _glpi_reference,
24
+ _glpi_text_reference,
25
+ _glpi_ticket_user_payload,
26
+ )
27
+ from glpi_python_client.content.records.core.scalars import (
28
+ _coerce_bool,
29
+ _first_int,
30
+ _optional_int,
31
+ _optional_text,
32
+ _parse_glpi_datetime,
33
+ )
34
+ from glpi_python_client.content.records.parsers.directory import (
35
+ _glpi_location_record,
36
+ _glpi_user_record,
37
+ )
38
+ from glpi_python_client.content.records.parsers.documents import _glpi_document_record
39
+ from glpi_python_client.content.records.parsers.team import (
40
+ _glpi_team_member_record,
41
+ _resolve_glpi_member_type,
42
+ )
43
+ from glpi_python_client.content.records.parsers.tickets import (
44
+ _filter_visible_ticket_batch,
45
+ _glpi_ticket_record,
46
+ _is_deleted_ticket,
47
+ )
48
+ from glpi_python_client.content.records.parsers.timeline import (
49
+ _glpi_author_id,
50
+ _glpi_followup_record,
51
+ _glpi_solution_record,
52
+ _glpi_task_record,
53
+ )
54
+
55
+ __all__ = [
56
+ "_coerce_bool",
57
+ "_filter_visible_ticket_batch",
58
+ "_first_int",
59
+ "_glpi_author_id",
60
+ "_glpi_document_id_from_url",
61
+ "_glpi_document_record",
62
+ "_glpi_followup_attachment_document_ids",
63
+ "_glpi_followup_record",
64
+ "_glpi_id_reference",
65
+ "_glpi_id_value",
66
+ "_glpi_location_record",
67
+ "_glpi_reference",
68
+ "_glpi_solution_record",
69
+ "_glpi_task_record",
70
+ "_glpi_team_member_record",
71
+ "_glpi_text_reference",
72
+ "_glpi_ticket_record",
73
+ "_glpi_ticket_user_payload",
74
+ "_glpi_user_record",
75
+ "_is_deleted_ticket",
76
+ "_normalize_ticket_record",
77
+ "_normalize_timeline_records",
78
+ "_optional_int",
79
+ "_optional_text",
80
+ "_parse_glpi_datetime",
81
+ "_resolve_glpi_member_type",
82
+ "_strip_glpi_document_references",
83
+ "_unwrap_timeline_item",
84
+ ]
@@ -0,0 +1,6 @@
1
+ """Shared low-level helpers for GLPI record parsing.
2
+
3
+ Modules in this package handle reusable normalization, scalar coercion,
4
+ reference extraction, and attachment-link parsing that multiple record parsers
5
+ depend on.
6
+ """
@@ -0,0 +1,100 @@
1
+ """Helpers for parsing GLPI document references in timeline HTML.
2
+
3
+ GLPI followups and solutions can embed attachments as links or images pointing
4
+ to ``document.send.php`` with a ``docid`` query parameter. These helpers keep
5
+ that transport-specific HTML parsing isolated from the model parsers.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from urllib.parse import parse_qs, urlsplit
11
+
12
+ from bs4 import BeautifulSoup
13
+
14
+
15
+ def _glpi_followup_attachment_document_ids(raw_content: object) -> tuple[str, ...]:
16
+ """Return attachment document IDs referenced by followup HTML.
17
+
18
+ GLPI timeline content may contain one or more ``a`` or ``img`` tags that
19
+ point at ``document.send.php``. The parser scans both ``href`` and ``src``
20
+ attributes, preserves the order in which document IDs appear, and removes
21
+ duplicates so callers can safely request metadata once per attachment.
22
+ """
23
+
24
+ content = str(raw_content or "")
25
+ if "document.send.php" not in content.casefold():
26
+ return ()
27
+
28
+ document_ids: list[str] = []
29
+ seen_document_ids: set[str] = set()
30
+ soup = BeautifulSoup(content, features="lxml")
31
+ root = soup.body or soup
32
+
33
+ for tag in root.find_all(["a", "img"]):
34
+ if getattr(tag, "attrs", None) is None:
35
+ continue
36
+ for attribute_name in ("href", "src"):
37
+ document_id = _glpi_document_id_from_url(tag.get(attribute_name))
38
+ if document_id is None or document_id in seen_document_ids:
39
+ continue
40
+ seen_document_ids.add(document_id)
41
+ document_ids.append(document_id)
42
+
43
+ return tuple(document_ids)
44
+
45
+
46
+ def _strip_glpi_document_references(raw_content: object) -> str:
47
+ """Return followup HTML with GLPI attachment references removed.
48
+
49
+ Attachment images are removed completely because their source points at the
50
+ GLPI document endpoint rather than meaningful inline content. Attachment
51
+ links are replaced by their visible text when possible so the human-written
52
+ part of the followup remains readable after document references are split
53
+ into structured attachment IDs.
54
+ """
55
+
56
+ content = str(raw_content or "")
57
+ if "document.send.php" not in content.casefold():
58
+ return content
59
+
60
+ soup = BeautifulSoup(content, features="lxml")
61
+ root = soup.body or soup
62
+
63
+ for tag in list(root.find_all(["a", "img"])):
64
+ if tag.name is None or getattr(tag, "attrs", None) is None:
65
+ continue
66
+ target = tag.get("src") if tag.name == "img" else tag.get("href")
67
+ if _glpi_document_id_from_url(target) is None:
68
+ continue
69
+ if tag.name == "img" or tag.find("img") is not None:
70
+ tag.decompose()
71
+ continue
72
+ replacement = tag.get_text(" ", strip=True)
73
+ if replacement:
74
+ tag.replace_with(replacement)
75
+ else:
76
+ tag.decompose()
77
+
78
+ normalized_root = soup.body or soup
79
+ return "".join(str(child) for child in normalized_root.contents)
80
+
81
+
82
+ def _glpi_document_id_from_url(url: object) -> str | None:
83
+ """Extract one GLPI document ID from a document download URL.
84
+
85
+ Only GLPI ``document.send.php`` URLs are accepted. Missing URLs, non-string
86
+ values, unrelated paths, missing ``docid`` parameters, and blank document IDs
87
+ all return ``None`` so callers can probe arbitrary HTML attributes without
88
+ handling parsing exceptions.
89
+ """
90
+
91
+ if not isinstance(url, str) or not url.strip():
92
+ return None
93
+ parsed = urlsplit(url)
94
+ if not parsed.path.casefold().endswith("document.send.php"):
95
+ return None
96
+ document_ids = parse_qs(parsed.query).get("docid")
97
+ if not document_ids:
98
+ return None
99
+ document_id = str(document_ids[0]).strip()
100
+ return document_id or None
@@ -0,0 +1,53 @@
1
+ """Raw GLPI payload normalization helpers.
2
+
3
+ These helpers smooth over the small shape differences between GLPI ticket and
4
+ timeline payloads before the model-specific parsers consume them.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+
12
+ def _normalize_ticket_record(data: object) -> dict[str, Any] | object:
13
+ """Return a shallow mapping copy for one raw GLPI ticket payload.
14
+
15
+ Ticket payloads are usually already flat mappings, so normalization here is
16
+ limited to copying dictionaries before downstream mutation.
17
+ """
18
+
19
+ if not isinstance(data, dict):
20
+ return data
21
+ return dict(data)
22
+
23
+
24
+ def _normalize_timeline_records(data: object) -> list[dict[str, Any]]:
25
+ """Normalize one GLPI timeline payload to a list of item mappings.
26
+
27
+ GLPI timeline responses may contain wrapper objects around the actual item
28
+ payload. This helper unwraps those entries and keeps only mapping items.
29
+ """
30
+
31
+ if not isinstance(data, list):
32
+ return []
33
+
34
+ records: list[dict[str, Any]] = []
35
+ for entry in data:
36
+ if not isinstance(entry, dict):
37
+ continue
38
+ item = _unwrap_timeline_item(entry)
39
+ if isinstance(item, dict):
40
+ records.append(dict(item))
41
+ return records
42
+
43
+
44
+ def _unwrap_timeline_item(entry: dict[str, Any]) -> dict[str, Any]:
45
+ """Return the nested timeline item mapping when GLPI wraps it in ``item``.
46
+
47
+ Some timeline endpoints return an outer record with the real payload stored
48
+ under the ``item`` key; others already return the payload directly.
49
+ """
50
+
51
+ if "item" in entry and isinstance(entry["item"], dict):
52
+ return entry["item"]
53
+ return entry
@@ -0,0 +1,98 @@
1
+ """GLPI nested reference parsing helpers.
2
+
3
+ These helpers normalize the mixed scalar-or-mapping reference fields that GLPI
4
+ uses for related objects such as users, entities, and categories.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from glpi_python_client.models import GlpiUser
12
+
13
+ from .scalars import _optional_int, _optional_text
14
+
15
+
16
+ def _glpi_id_value(value: Any) -> Any:
17
+ """Return the ``id`` field from one GLPI mapping payload.
18
+
19
+ Non-mapping values return ``None`` so callers can safely probe optional
20
+ nested references without type checks at every call site.
21
+ """
22
+
23
+ if isinstance(value, dict):
24
+ return value.get("id")
25
+ return None
26
+
27
+
28
+ def _glpi_reference(value: Any) -> dict[str, object] | int | str | None:
29
+ """Normalize one GLPI reference field using the API field shape.
30
+
31
+ Mapping values are reduced to the supported reference keys, while scalar
32
+ values are preserved as integers when possible and text otherwise.
33
+ """
34
+
35
+ if isinstance(value, dict):
36
+ reference = _reference_mapping(value)
37
+ return reference or None
38
+ integer_value = _optional_int(value)
39
+ if integer_value is not None:
40
+ return integer_value
41
+ return _optional_text(value)
42
+
43
+
44
+ def _glpi_id_reference(value: Any) -> dict[str, object] | int | None:
45
+ """Normalize one GLPI reference whose scalar form must be an ID.
46
+
47
+ This is used for fields that should never surface free text when GLPI sends
48
+ a scalar reference representation.
49
+ """
50
+
51
+ if isinstance(value, dict):
52
+ reference = _reference_mapping(value)
53
+ return reference or None
54
+ return _optional_int(value)
55
+
56
+
57
+ def _glpi_text_reference(value: Any) -> dict[str, object] | str | None:
58
+ """Normalize one GLPI reference whose scalar form is textual.
59
+
60
+ Mapping payloads are preserved as reduced references, while scalar values
61
+ are coerced to stripped text.
62
+ """
63
+
64
+ if isinstance(value, dict):
65
+ reference = _reference_mapping(value)
66
+ return reference or None
67
+ return _optional_text(value)
68
+
69
+
70
+ def _glpi_ticket_user_payload(value: Any) -> GlpiUser | None:
71
+ """Build a lightweight ``GlpiUser`` from one nested ticket field.
72
+
73
+ Ticket payloads often embed partial user records. This helper keeps only the
74
+ fields that are meaningful for the package's ticket models.
75
+ """
76
+
77
+ if not isinstance(value, dict):
78
+ return None
79
+ user_id = _optional_text(value.get("id"))
80
+ name = _optional_text(value.get("name"))
81
+ email = _optional_text(value.get("email"))
82
+ if user_id is None and name is None and email is None:
83
+ return None
84
+ return GlpiUser(user_id=user_id, name=name, email=email)
85
+
86
+
87
+ def _reference_mapping(value: dict[str, Any]) -> dict[str, object]:
88
+ """Return the supported GLPI reference fields from one nested payload.
89
+
90
+ Only the small subset of keys used by the package's models is preserved so
91
+ reference payloads stay predictable.
92
+ """
93
+
94
+ return {
95
+ key: field_value
96
+ for key, field_value in value.items()
97
+ if key in {"id", "name", "completename"} and field_value is not None
98
+ }