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,36 @@
1
+ """Public import surface for the GLPI Python client package.
2
+
3
+ This module re-exports the supported high-level clients and typed GLPI models
4
+ so applications can import them from a single stable package root.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from glpi_python_client.clients import AsyncGlpiClient, GlpiClient, GLPIV1Session
10
+ from glpi_python_client.models import (
11
+ GlpiDocument,
12
+ GlpiFollowup,
13
+ GlpiLocation,
14
+ GlpiSolution,
15
+ GlpiTask,
16
+ GlpiTeamMember,
17
+ GlpiTicket,
18
+ GlpiUser,
19
+ )
20
+
21
+ __version__ = "0.1.0"
22
+
23
+ __all__ = [
24
+ "AsyncGlpiClient",
25
+ "GLPIV1Session",
26
+ "GlpiClient",
27
+ "GlpiDocument",
28
+ "GlpiFollowup",
29
+ "GlpiLocation",
30
+ "GlpiSolution",
31
+ "GlpiTask",
32
+ "GlpiTeamMember",
33
+ "GlpiTicket",
34
+ "GlpiUser",
35
+ "__version__",
36
+ ]
@@ -0,0 +1,11 @@
1
+ """Public authentication exports for the GLPI client package.
2
+
3
+ The authentication package stays small on purpose and currently exposes the
4
+ token manager used by the synchronous and asynchronous high-level clients.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from glpi_python_client.auth.auth import GLPITokenManager
10
+
11
+ __all__ = ["GLPITokenManager"]
@@ -0,0 +1,310 @@
1
+ """OAuth2 token management for the high-level GLPI clients.
2
+
3
+ The token manager centralizes credential validation, token acquisition,
4
+ refresh behavior, and lifecycle cleanup so both sync and async clients can
5
+ share the same authenticated session state.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from datetime import datetime, timedelta, timezone
12
+
13
+ import requests
14
+ from tenacity import retry, stop_after_attempt, wait_fixed
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class GLPITokenManager:
20
+ """OAuth2 token manager for the GLPI API.
21
+
22
+ Parameters
23
+ ----------
24
+ token_url : str
25
+ Full URL of the GLPI token endpoint.
26
+ client_id : str | None, optional
27
+ OAuth2 client ID. Provide it together with ``client_secret`` when the
28
+ GLPI instance requires client authentication.
29
+ client_secret : str | None, optional
30
+ OAuth2 client secret. Provide it together with ``client_id``.
31
+ username : str | None, optional
32
+ Username for the password grant flow. Provide it together with
33
+ ``password``.
34
+ password : str | None, optional
35
+ Password for the password grant flow. Provide it together with
36
+ ``username``.
37
+ session : requests.Session | None, optional
38
+ Existing session to reuse.
39
+ auth_token_refresh : int | None, optional
40
+ Maximum token age in seconds before a refresh is attempted. ``None``
41
+ disables interval-based refreshes.
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ token_url: str,
47
+ client_id: str | None = None,
48
+ client_secret: str | None = None,
49
+ username: str | None = None,
50
+ password: str | None = None,
51
+ session: requests.Session | None = None,
52
+ auth_token_refresh: int | None = None,
53
+ ) -> None:
54
+ self._token_url = token_url
55
+ self._client_id = client_id
56
+ self._client_secret = client_secret
57
+ self._username = username
58
+ self._password = password
59
+ self._owns_session = session is None
60
+ self._session = session or requests.Session()
61
+ self._auth_token_refresh_interval = _refresh_interval(auth_token_refresh)
62
+
63
+ self._validate_credentials()
64
+
65
+ self.access_token: str | None = None
66
+ self.refresh_token: str | None = None
67
+ self.token_expires_at: datetime | None = None
68
+ self.token_updated_at: datetime | None = None
69
+
70
+ @property
71
+ def auth_token_refresh(self) -> int | None:
72
+ """Return the proactive refresh delay configured for this manager.
73
+
74
+ The public property keeps the original integer value used at
75
+ construction time instead of exposing the internal ``timedelta``
76
+ representation.
77
+ """
78
+
79
+ if self._auth_token_refresh_interval is None:
80
+ return None
81
+ return int(self._auth_token_refresh_interval.total_seconds())
82
+
83
+ def _validate_credentials(self) -> None:
84
+ """Validate that the configured OAuth credential sets are complete.
85
+
86
+ GLPI authentication supports either client credentials, user
87
+ credentials, or both together. Partial pairs are rejected here so the
88
+ token request path does not fail later with a less actionable error.
89
+ """
90
+
91
+ missing_client_fields = [
92
+ name
93
+ for name, value in {
94
+ "client_id": self._client_id,
95
+ "client_secret": self._client_secret,
96
+ }.items()
97
+ if value is None
98
+ ]
99
+ missing_user_fields = [
100
+ name
101
+ for name, value in {
102
+ "username": self._username,
103
+ "password": self._password,
104
+ }.items()
105
+ if value is None
106
+ ]
107
+
108
+ has_client_fields = len(missing_client_fields) < 2
109
+ has_user_fields = len(missing_user_fields) < 2
110
+
111
+ if has_client_fields and missing_client_fields:
112
+ raise ValueError(
113
+ "GLPI OAuth client credentials must include both client_id "
114
+ "and client_secret."
115
+ )
116
+ if has_user_fields and missing_user_fields:
117
+ raise ValueError(
118
+ "GLPI user credentials must include both username and password."
119
+ )
120
+ if not self._has_client_credentials and not self._has_user_credentials:
121
+ raise ValueError(
122
+ "GLPI authentication requires either client_id/client_secret, "
123
+ "username/password, or both."
124
+ )
125
+
126
+ @property
127
+ def _has_client_credentials(self) -> bool:
128
+ return self._client_id is not None and self._client_secret is not None
129
+
130
+ @property
131
+ def _has_user_credentials(self) -> bool:
132
+ return self._username is not None and self._password is not None
133
+
134
+ def _build_token_request_data(self) -> dict[str, str]:
135
+ """Build the form payload sent to the OAuth token endpoint.
136
+
137
+ The payload shape depends on whether the manager is using the password
138
+ grant or pure client-credentials flow, and it includes client
139
+ credentials when that pair is configured.
140
+ """
141
+
142
+ data: dict[str, str] = {"scope": "api"}
143
+ if self._has_user_credentials:
144
+ assert self._username is not None
145
+ assert self._password is not None
146
+ data["grant_type"] = "password"
147
+ data["username"] = self._username
148
+ data["password"] = self._password
149
+ else:
150
+ data["grant_type"] = "client_credentials"
151
+
152
+ if self._has_client_credentials:
153
+ assert self._client_id is not None
154
+ assert self._client_secret is not None
155
+ data["client_id"] = self._client_id
156
+ data["client_secret"] = self._client_secret
157
+
158
+ return data
159
+
160
+ def _store_token_data(
161
+ self, token_data: dict[str, object], label: str = "acquired"
162
+ ) -> None:
163
+ """Store token data from an OAuth2 response.
164
+
165
+ Parameters
166
+ ----------
167
+ token_data : dict[str, object]
168
+ Token endpoint JSON response.
169
+ label : str, optional
170
+ Label for log messages.
171
+
172
+ Returns
173
+ -------
174
+ None
175
+ Mutates instance state.
176
+ """
177
+
178
+ self.access_token = str(token_data.get("access_token") or "") or None
179
+ refresh_token = str(token_data.get("refresh_token") or "").strip()
180
+ if refresh_token:
181
+ self.refresh_token = refresh_token
182
+ expires_in = int(str(token_data.get("expires_in") or 3600))
183
+ now = datetime.now(tz=timezone.utc)
184
+ self.token_updated_at = now
185
+ self.token_expires_at = now + timedelta(seconds=expires_in)
186
+ logger.info("GLPI OAuth token %s successfully.", label)
187
+
188
+ def logout(self) -> None:
189
+ """Discard the currently cached OAuth tokens.
190
+
191
+ Returns
192
+ -------
193
+ None
194
+ Clears in-memory token state.
195
+ """
196
+
197
+ self.access_token = None
198
+ self.refresh_token = None
199
+ self.token_expires_at = None
200
+ self.token_updated_at = None
201
+
202
+ def close(self) -> None:
203
+ """Release token-manager resources.
204
+
205
+ Returns
206
+ -------
207
+ None
208
+ Performs a local logout and closes any owned HTTP session.
209
+ """
210
+
211
+ self.logout()
212
+ if self._owns_session:
213
+ self._session.close()
214
+
215
+ def _should_refresh_by_interval(self, now: datetime) -> bool:
216
+ """Return whether the configured proactive refresh interval elapsed.
217
+
218
+ This check supplements token-expiry handling so long-lived clients can
219
+ refresh credentials before the server-side expiry timestamp is reached.
220
+ """
221
+
222
+ if self._auth_token_refresh_interval is None or self.token_updated_at is None:
223
+ return False
224
+ return now >= self.token_updated_at + self._auth_token_refresh_interval
225
+
226
+ @retry(stop=stop_after_attempt(3), wait=wait_fixed(3))
227
+ def _acquire_token(self) -> None:
228
+ """Acquire an OAuth2 access token using the configured auth flow.
229
+
230
+ Returns
231
+ -------
232
+ None
233
+ Stores the new access token.
234
+
235
+ Raises
236
+ ------
237
+ ValueError
238
+ If token acquisition fails.
239
+ """
240
+
241
+ data = self._build_token_request_data()
242
+ response = self._session.post(self._token_url, data=data, timeout=30)
243
+ if 200 <= response.status_code < 300:
244
+ self._store_token_data(response.json())
245
+ return
246
+ try:
247
+ error_detail = response.json()
248
+ except Exception:
249
+ error_detail = response.text
250
+ raise ValueError(
251
+ f"GLPI OAuth token returned {response.status_code}: {error_detail}"
252
+ )
253
+
254
+ @retry(stop=stop_after_attempt(3), wait=wait_fixed(3))
255
+ def _refresh_access_token(self) -> None:
256
+ """Refresh the OAuth2 access token using the stored refresh token.
257
+
258
+ Returns
259
+ -------
260
+ None
261
+ Stores the refreshed token or acquires a new one.
262
+ """
263
+
264
+ if not self.refresh_token:
265
+ self._acquire_token()
266
+ return
267
+
268
+ data = {
269
+ "grant_type": "refresh_token",
270
+ "refresh_token": self.refresh_token,
271
+ }
272
+ if self._has_client_credentials:
273
+ assert self._client_id is not None
274
+ assert self._client_secret is not None
275
+ data["client_id"] = self._client_id
276
+ data["client_secret"] = self._client_secret
277
+ response = self._session.post(self._token_url, data=data, timeout=30)
278
+ if 200 <= response.status_code < 300:
279
+ self._store_token_data(response.json(), label="refreshed")
280
+ return
281
+ logger.warning("Token refresh failed, acquiring new token...")
282
+ self._acquire_token()
283
+
284
+ def ensure_token(self) -> None:
285
+ """Ensure a valid OAuth2 access token is available.
286
+
287
+ Returns
288
+ -------
289
+ None
290
+ Updates token state when needed.
291
+ """
292
+
293
+ if not self.access_token:
294
+ self._acquire_token()
295
+ return
296
+
297
+ now = datetime.now(tz=timezone.utc)
298
+ token_expired = (
299
+ self.token_expires_at is not None and now >= self.token_expires_at
300
+ )
301
+ if token_expired or self._should_refresh_by_interval(now):
302
+ self._refresh_access_token()
303
+
304
+
305
+ def _refresh_interval(value: int | None) -> timedelta | None:
306
+ if value is None:
307
+ return None
308
+ if value < 1:
309
+ raise ValueError("auth_token_refresh must be a positive integer or None")
310
+ return timedelta(seconds=value)
@@ -0,0 +1,189 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timedelta, timezone
4
+ from typing import cast
5
+
6
+ import pytest
7
+ import requests
8
+
9
+ from glpi_python_client.auth.auth import GLPITokenManager
10
+ from glpi_python_client.testing.utils import FakeResponse, TokenResponse
11
+
12
+
13
+ class _FakeSession:
14
+ def __init__(self, response: FakeResponse | None = None):
15
+ self.response = response or TokenResponse()
16
+ self.calls: list[dict[str, object]] = []
17
+
18
+ def post(
19
+ self,
20
+ url: str,
21
+ data: dict[str, str],
22
+ timeout: int,
23
+ ) -> FakeResponse:
24
+ self.calls.append({"url": url, "data": data, "timeout": timeout})
25
+ return self.response
26
+
27
+
28
+ def test_token_manager_uses_password_grant_with_user_credentials_only() -> None:
29
+ session = _FakeSession()
30
+ auth = GLPITokenManager(
31
+ token_url="https://glpi.example.test/api.php/token",
32
+ username="api-user",
33
+ password="api-password",
34
+ session=cast(requests.Session, session),
35
+ )
36
+
37
+ auth._acquire_token()
38
+
39
+ assert session.calls[0]["data"] == {
40
+ "grant_type": "password",
41
+ "username": "api-user",
42
+ "password": "api-password",
43
+ "scope": "api",
44
+ }
45
+ assert auth.access_token == "token"
46
+
47
+
48
+ def test_token_manager_uses_client_credentials_grant() -> None:
49
+ session = _FakeSession(response=TokenResponse(status_code=201))
50
+ auth = GLPITokenManager(
51
+ token_url="https://glpi.example.test/api.php/token",
52
+ client_id="client-id",
53
+ client_secret="client-secret",
54
+ session=cast(requests.Session, session),
55
+ )
56
+
57
+ auth._acquire_token()
58
+
59
+ assert session.calls[0]["data"] == {
60
+ "grant_type": "client_credentials",
61
+ "client_id": "client-id",
62
+ "client_secret": "client-secret",
63
+ "scope": "api",
64
+ }
65
+ assert auth.access_token == "token"
66
+
67
+
68
+ def test_token_manager_preserves_raw_credential_text() -> None:
69
+ session = _FakeSession(response=TokenResponse(status_code=201))
70
+ auth = GLPITokenManager(
71
+ token_url="https://glpi.example.test/api.php/token",
72
+ client_id=" client-id ",
73
+ client_secret=" client-secret ",
74
+ session=cast(requests.Session, session),
75
+ )
76
+
77
+ auth._acquire_token()
78
+
79
+ assert session.calls[0]["data"] == {
80
+ "grant_type": "client_credentials",
81
+ "client_id": " client-id ",
82
+ "client_secret": " client-secret ",
83
+ "scope": "api",
84
+ }
85
+
86
+
87
+ def test_token_manager_uses_password_grant_with_both_credential_sets() -> None:
88
+ session = _FakeSession()
89
+ auth = GLPITokenManager(
90
+ token_url="https://glpi.example.test/api.php/token",
91
+ client_id="client-id",
92
+ client_secret="client-secret",
93
+ username="api-user",
94
+ password="api-password",
95
+ session=cast(requests.Session, session),
96
+ )
97
+
98
+ auth._acquire_token()
99
+
100
+ assert session.calls[0]["data"] == {
101
+ "grant_type": "password",
102
+ "client_id": "client-id",
103
+ "client_secret": "client-secret",
104
+ "username": "api-user",
105
+ "password": "api-password",
106
+ "scope": "api",
107
+ }
108
+
109
+
110
+ def test_token_manager_refreshes_when_configured_interval_elapses() -> None:
111
+ session = _FakeSession()
112
+ auth = GLPITokenManager(
113
+ token_url="https://glpi.example.test/api.php/token",
114
+ client_id="client-id",
115
+ client_secret="client-secret",
116
+ session=cast(requests.Session, session),
117
+ auth_token_refresh=60,
118
+ )
119
+ auth.access_token = "old-token"
120
+ auth.refresh_token = "refresh-token"
121
+ auth.token_updated_at = datetime.now(tz=timezone.utc) - timedelta(seconds=61)
122
+ auth.token_expires_at = datetime.now(tz=timezone.utc) + timedelta(hours=1)
123
+
124
+ auth.ensure_token()
125
+
126
+ assert auth.auth_token_refresh == 60
127
+ assert session.calls[0]["data"] == {
128
+ "grant_type": "refresh_token",
129
+ "refresh_token": "refresh-token",
130
+ "client_id": "client-id",
131
+ "client_secret": "client-secret",
132
+ }
133
+ assert auth.access_token == "token"
134
+
135
+
136
+ @pytest.mark.parametrize("refresh_interval", [0, -1])
137
+ def test_token_manager_rejects_non_positive_refresh_interval(
138
+ refresh_interval: int,
139
+ ) -> None:
140
+ with pytest.raises(ValueError, match="auth_token_refresh"):
141
+ GLPITokenManager(
142
+ token_url="https://glpi.example.test/api.php/token",
143
+ client_id="client-id",
144
+ client_secret="client-secret",
145
+ auth_token_refresh=refresh_interval,
146
+ )
147
+
148
+
149
+ def test_token_manager_logout_clears_cached_tokens() -> None:
150
+ auth = GLPITokenManager(
151
+ token_url="https://glpi.example.test/api.php/token",
152
+ client_id="client-id",
153
+ client_secret="client-secret",
154
+ )
155
+ auth.access_token = "access-token"
156
+ auth.refresh_token = "refresh-token"
157
+ auth.token_updated_at = datetime.now(tz=timezone.utc)
158
+ auth.token_expires_at = auth.token_updated_at + timedelta(hours=1)
159
+
160
+ auth.logout()
161
+
162
+ assert cast(str | None, auth.access_token) is None
163
+ assert cast(str | None, auth.refresh_token) is None
164
+ assert cast(datetime | None, auth.token_updated_at) is None
165
+ assert cast(datetime | None, auth.token_expires_at) is None
166
+
167
+
168
+ @pytest.mark.parametrize(
169
+ ("kwargs", "message"),
170
+ [
171
+ ({}, "either client_id/client_secret, username/password, or both"),
172
+ ({"client_id": "client-id"}, "both client_id and client_secret"),
173
+ ({"client_secret": "client-secret"}, "both client_id and client_secret"),
174
+ ({"username": "api-user"}, "both username and password"),
175
+ ({"password": "api-password"}, "both username and password"),
176
+ ],
177
+ )
178
+ def test_token_manager_rejects_incomplete_credentials(
179
+ kwargs: dict[str, str],
180
+ message: str,
181
+ ) -> None:
182
+ with pytest.raises(ValueError, match=message):
183
+ GLPITokenManager(
184
+ token_url="https://glpi.example.test/api.php/token",
185
+ client_id=kwargs.get("client_id"),
186
+ client_secret=kwargs.get("client_secret"),
187
+ username=kwargs.get("username"),
188
+ password=kwargs.get("password"),
189
+ )
@@ -0,0 +1,18 @@
1
+ """Public client exports for the GLPI package.
2
+
3
+ This package entrypoint exposes the supported synchronous client,
4
+ asynchronous client, and legacy v1 session wrapper without re-exporting the
5
+ internal mixin hierarchy used to implement them.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from glpi_python_client.clients.api_v1_session import GLPIV1Session
11
+ from glpi_python_client.clients.api_v2_client import GlpiClient
12
+ from glpi_python_client.clients.async_api_v2_client import AsyncGlpiClient
13
+
14
+ __all__ = [
15
+ "AsyncGlpiClient",
16
+ "GLPIV1Session",
17
+ "GlpiClient",
18
+ ]