pymenderio 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.
pymenderio/__init__.py ADDED
@@ -0,0 +1,40 @@
1
+ # SPDX-License-Identifier: MIT
2
+
3
+ """
4
+ pymenderio - Python client library for the Mender API.
5
+
6
+ For a complete public SDK reference (methods, classes, and errors), see API.md.
7
+
8
+ Example usage:
9
+
10
+ from pymenderio import MenderClient
11
+
12
+ # Sync usage with mender-cli config and token defaults
13
+ with MenderClient.from_mender_cli() as client:
14
+ devices = client.devices.list(status="accepted")
15
+ """
16
+
17
+ from pymenderio.client import AsyncMenderClient, MenderClient
18
+ from pymenderio.exceptions import (
19
+ AuthenticationError,
20
+ ConflictError,
21
+ MenderError,
22
+ NotFoundError,
23
+ RateLimitError,
24
+ ServerError,
25
+ ValidationError,
26
+ )
27
+
28
+ __version__ = "0.1.0"
29
+
30
+ __all__ = [
31
+ "MenderClient",
32
+ "AsyncMenderClient",
33
+ "MenderError",
34
+ "AuthenticationError",
35
+ "NotFoundError",
36
+ "ConflictError",
37
+ "ValidationError",
38
+ "RateLimitError",
39
+ "ServerError",
40
+ ]
pymenderio/_http.py ADDED
@@ -0,0 +1,417 @@
1
+ # SPDX-License-Identifier: MIT
2
+
3
+ """Internal HTTP client wrapper with transparent pagination and error handling."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ from typing import Any, TypeVar, cast
9
+
10
+ import httpx
11
+
12
+ from pymenderio.exceptions import (
13
+ AuthenticationError,
14
+ ConflictError,
15
+ MenderError,
16
+ NotFoundError,
17
+ RateLimitError,
18
+ ServerError,
19
+ ValidationError,
20
+ )
21
+
22
+ T = TypeVar("T")
23
+
24
+ # Maximum items per page (Mender API limit)
25
+ _PER_PAGE = 500
26
+
27
+
28
+ def _parse_link_header(link_header: str) -> dict[str, str]:
29
+ """Parse RFC 5988 Link header into a dict of rel -> url."""
30
+ links: dict[str, str] = {}
31
+ for part in link_header.split(","):
32
+ match = re.match(r'\s*<([^>]+)>;\s*rel="([^"]+)"', part.strip())
33
+ if match:
34
+ url, rel = match.groups()
35
+ links[rel] = url
36
+ return links
37
+
38
+
39
+ def _raise_for_status(response: httpx.Response) -> None:
40
+ """Raise appropriate MenderError based on HTTP status code."""
41
+ if response.status_code < 400:
42
+ return
43
+
44
+ # Try to extract error details from response body
45
+ request_id: str | None = response.headers.get("X-Request-Id")
46
+ body: dict[str, Any] | None = None
47
+ try:
48
+ raw_body: Any = response.json()
49
+ if isinstance(raw_body, dict):
50
+ body = cast(dict[str, Any], raw_body)
51
+
52
+ if body is not None:
53
+ raw_message = body.get("error")
54
+ message = raw_message if isinstance(raw_message, str) else (response.reason_phrase or "Unknown error")
55
+
56
+ # Also check for request_id in the JSON body
57
+ if not request_id:
58
+ raw_request_id = body.get("request_id")
59
+ if isinstance(raw_request_id, str):
60
+ request_id = raw_request_id
61
+ else:
62
+ message = response.reason_phrase or "Unknown error"
63
+ except Exception:
64
+ message = response.reason_phrase or "Unknown error"
65
+
66
+ if response.status_code == 401:
67
+ raise AuthenticationError(
68
+ message,
69
+ status_code=response.status_code,
70
+ request_id=request_id,
71
+ response_body=body,
72
+ )
73
+ elif response.status_code == 404:
74
+ raise NotFoundError(
75
+ message,
76
+ status_code=response.status_code,
77
+ request_id=request_id,
78
+ response_body=body,
79
+ )
80
+ elif response.status_code == 409:
81
+ raise ConflictError(
82
+ message,
83
+ status_code=response.status_code,
84
+ request_id=request_id,
85
+ response_body=body,
86
+ )
87
+ elif response.status_code == 422:
88
+ raise ValidationError(
89
+ message,
90
+ status_code=response.status_code,
91
+ request_id=request_id,
92
+ response_body=body,
93
+ )
94
+ elif response.status_code == 429:
95
+ raise RateLimitError(
96
+ message,
97
+ status_code=response.status_code,
98
+ request_id=request_id,
99
+ response_body=body,
100
+ )
101
+ elif response.status_code >= 500:
102
+ raise ServerError(
103
+ message,
104
+ status_code=response.status_code,
105
+ request_id=request_id,
106
+ response_body=body,
107
+ )
108
+ else:
109
+ raise MenderError(
110
+ message,
111
+ status_code=response.status_code,
112
+ request_id=request_id,
113
+ response_body=body,
114
+ )
115
+
116
+
117
+ class HTTPClient:
118
+ """
119
+ Synchronous HTTP client wrapper for Mender API.
120
+
121
+ Handles:
122
+ - Bearer token authentication
123
+ - Transparent pagination (fetches all pages, returns complete list)
124
+ - Error translation to MenderError hierarchy
125
+ """
126
+
127
+ def __init__(
128
+ self,
129
+ base_url: str,
130
+ token: str,
131
+ *,
132
+ verify: bool = True,
133
+ timeout: float = 30.0,
134
+ ) -> None:
135
+ self._base_url = base_url.rstrip("/")
136
+ self._token = token
137
+ self._client = httpx.Client(
138
+ base_url=self._base_url,
139
+ headers={
140
+ "Authorization": f"Bearer {token}",
141
+ "Accept": "application/json",
142
+ },
143
+ verify=verify,
144
+ timeout=timeout,
145
+ )
146
+
147
+ def close(self) -> None:
148
+ """Close the underlying HTTP client."""
149
+ self._client.close()
150
+
151
+ def __enter__(self) -> HTTPClient:
152
+ return self
153
+
154
+ def __exit__(self, *args: Any) -> None:
155
+ self.close()
156
+
157
+ def request(
158
+ self,
159
+ method: str,
160
+ path: str,
161
+ *,
162
+ params: dict[str, Any] | None = None,
163
+ json: Any = None,
164
+ data: Any = None,
165
+ files: Any = None,
166
+ headers: dict[str, str] | None = None,
167
+ ) -> httpx.Response:
168
+ """Make a single HTTP request."""
169
+ response = self._client.request(
170
+ method,
171
+ path,
172
+ params=params,
173
+ json=json,
174
+ data=data,
175
+ files=files,
176
+ headers=headers,
177
+ )
178
+ _raise_for_status(response)
179
+ return response
180
+
181
+ def get(
182
+ self,
183
+ path: str,
184
+ *,
185
+ params: dict[str, Any] | None = None,
186
+ ) -> httpx.Response:
187
+ """Make a GET request."""
188
+ return self.request("GET", path, params=params)
189
+
190
+ def post(
191
+ self,
192
+ path: str,
193
+ *,
194
+ json: Any = None,
195
+ data: Any = None,
196
+ files: Any = None,
197
+ params: dict[str, Any] | None = None,
198
+ ) -> httpx.Response:
199
+ """Make a POST request."""
200
+ return self.request("POST", path, json=json, data=data, files=files, params=params)
201
+
202
+ def put(
203
+ self,
204
+ path: str,
205
+ *,
206
+ json: Any = None,
207
+ ) -> httpx.Response:
208
+ """Make a PUT request."""
209
+ return self.request("PUT", path, json=json)
210
+
211
+ def patch(
212
+ self,
213
+ path: str,
214
+ *,
215
+ json: Any = None,
216
+ ) -> httpx.Response:
217
+ """Make a PATCH request."""
218
+ return self.request("PATCH", path, json=json)
219
+
220
+ def delete(
221
+ self,
222
+ path: str,
223
+ *,
224
+ params: dict[str, Any] | None = None,
225
+ ) -> httpx.Response:
226
+ """Make a DELETE request."""
227
+ return self.request("DELETE", path, params=params)
228
+
229
+ def get_list(
230
+ self,
231
+ path: str,
232
+ *,
233
+ params: dict[str, Any] | None = None,
234
+ ) -> list[Any]:
235
+ """
236
+ Fetch a paginated list endpoint, returning all items.
237
+
238
+ Pagination is handled transparently - all pages are fetched
239
+ and merged into a single list.
240
+ """
241
+ params = dict(params) if params else {}
242
+ params["per_page"] = _PER_PAGE
243
+ params["page"] = 1
244
+
245
+ all_items: list[Any] = []
246
+
247
+ while True:
248
+ response = self.get(path, params=params)
249
+ items = response.json()
250
+
251
+ if not isinstance(items, list):
252
+ # Some endpoints return a wrapper object
253
+ raise MenderError(f"Expected list response, got {type(items)}")
254
+
255
+ all_items.extend(cast(list[Any], items)) # type: ignore[redundant-cast]
256
+
257
+ # Check for next page via Link header
258
+ link_header = response.headers.get("Link", "")
259
+ links = _parse_link_header(link_header)
260
+
261
+ if "next" not in links:
262
+ break
263
+
264
+ params["page"] += 1
265
+
266
+ return all_items
267
+
268
+
269
+ class AsyncHTTPClient:
270
+ """
271
+ Asynchronous HTTP client wrapper for Mender API.
272
+
273
+ Handles:
274
+ - Bearer token authentication
275
+ - Transparent pagination (fetches all pages, returns complete list)
276
+ - Error translation to MenderError hierarchy
277
+ """
278
+
279
+ def __init__(
280
+ self,
281
+ base_url: str,
282
+ token: str,
283
+ *,
284
+ verify: bool = True,
285
+ timeout: float = 30.0,
286
+ ) -> None:
287
+ self._base_url = base_url.rstrip("/")
288
+ self._token = token
289
+ self._client = httpx.AsyncClient(
290
+ base_url=self._base_url,
291
+ headers={
292
+ "Authorization": f"Bearer {token}",
293
+ "Accept": "application/json",
294
+ },
295
+ verify=verify,
296
+ timeout=timeout,
297
+ )
298
+
299
+ async def close(self) -> None:
300
+ """Close the underlying HTTP client."""
301
+ await self._client.aclose()
302
+
303
+ async def __aenter__(self) -> AsyncHTTPClient:
304
+ return self
305
+
306
+ async def __aexit__(self, *args: Any) -> None:
307
+ await self.close()
308
+
309
+ async def request(
310
+ self,
311
+ method: str,
312
+ path: str,
313
+ *,
314
+ params: dict[str, Any] | None = None,
315
+ json: Any = None,
316
+ data: Any = None,
317
+ files: Any = None,
318
+ headers: dict[str, str] | None = None,
319
+ ) -> httpx.Response:
320
+ """Make a single HTTP request."""
321
+ response = await self._client.request(
322
+ method,
323
+ path,
324
+ params=params,
325
+ json=json,
326
+ data=data,
327
+ files=files,
328
+ headers=headers,
329
+ )
330
+ _raise_for_status(response)
331
+ return response
332
+
333
+ async def get(
334
+ self,
335
+ path: str,
336
+ *,
337
+ params: dict[str, Any] | None = None,
338
+ ) -> httpx.Response:
339
+ """Make a GET request."""
340
+ return await self.request("GET", path, params=params)
341
+
342
+ async def post(
343
+ self,
344
+ path: str,
345
+ *,
346
+ json: Any = None,
347
+ data: Any = None,
348
+ files: Any = None,
349
+ params: dict[str, Any] | None = None,
350
+ ) -> httpx.Response:
351
+ """Make a POST request."""
352
+ return await self.request("POST", path, json=json, data=data, files=files, params=params)
353
+
354
+ async def put(
355
+ self,
356
+ path: str,
357
+ *,
358
+ json: Any = None,
359
+ ) -> httpx.Response:
360
+ """Make a PUT request."""
361
+ return await self.request("PUT", path, json=json)
362
+
363
+ async def patch(
364
+ self,
365
+ path: str,
366
+ *,
367
+ json: Any = None,
368
+ ) -> httpx.Response:
369
+ """Make a PATCH request."""
370
+ return await self.request("PATCH", path, json=json)
371
+
372
+ async def delete(
373
+ self,
374
+ path: str,
375
+ *,
376
+ params: dict[str, Any] | None = None,
377
+ ) -> httpx.Response:
378
+ """Make a DELETE request."""
379
+ return await self.request("DELETE", path, params=params)
380
+
381
+ async def get_list(
382
+ self,
383
+ path: str,
384
+ *,
385
+ params: dict[str, Any] | None = None,
386
+ ) -> list[Any]:
387
+ """
388
+ Fetch a paginated list endpoint, returning all items.
389
+
390
+ Pagination is handled transparently - all pages are fetched
391
+ and merged into a single list.
392
+ """
393
+ params = dict(params) if params else {}
394
+ params["per_page"] = _PER_PAGE
395
+ params["page"] = 1
396
+
397
+ all_items: list[Any] = []
398
+
399
+ while True:
400
+ response = await self.get(path, params=params)
401
+ items = response.json()
402
+
403
+ if not isinstance(items, list):
404
+ raise MenderError(f"Expected list response, got {type(items)}")
405
+
406
+ all_items.extend(cast(list[Any], items)) # type: ignore[redundant-cast]
407
+
408
+ # Check for next page via Link header
409
+ link_header = response.headers.get("Link", "")
410
+ links = _parse_link_header(link_header)
411
+
412
+ if "next" not in links:
413
+ break
414
+
415
+ params["page"] += 1
416
+
417
+ return all_items
@@ -0,0 +1,43 @@
1
+ # SPDX-License-Identifier: MIT
2
+
3
+ """API client modules for pymenderio."""
4
+
5
+ from pymenderio.api.artifacts import ArtifactsAPI, AsyncArtifactsAPI
6
+ from pymenderio.api.deployments import AsyncDeploymentsAPI, DeploymentsAPI
7
+ from pymenderio.api.deviceconfigure import AsyncDeviceconfigureAPI, DeviceconfigureAPI
8
+ from pymenderio.api.deviceconnect import AsyncDeviceconnectAPI, DeviceconnectAPI
9
+ from pymenderio.api.devicemonitor import AsyncDevicemonitorAPI, DevicemonitorAPI
10
+ from pymenderio.api.devices import AsyncDevicesAPI, DevicesAPI
11
+ from pymenderio.api.filters import AsyncFiltersAPI, FiltersAPI
12
+ from pymenderio.api.inventory import AsyncInventoryAPI, InventoryAPI
13
+ from pymenderio.api.iot_manager import AsyncIotManagerAPI, IotManagerAPI
14
+ from pymenderio.api.releases import AsyncReleasesAPI, ReleasesAPI
15
+ from pymenderio.api.tenantadm import AsyncTenantadmAPI, TenantadmAPI
16
+ from pymenderio.api.useradm import AsyncUseradmAPI, UseradmAPI
17
+
18
+ __all__ = [
19
+ "DevicesAPI",
20
+ "AsyncDevicesAPI",
21
+ "DeploymentsAPI",
22
+ "AsyncDeploymentsAPI",
23
+ "ArtifactsAPI",
24
+ "AsyncArtifactsAPI",
25
+ "DeviceconnectAPI",
26
+ "AsyncDeviceconnectAPI",
27
+ "DeviceconfigureAPI",
28
+ "AsyncDeviceconfigureAPI",
29
+ "DevicemonitorAPI",
30
+ "AsyncDevicemonitorAPI",
31
+ "IotManagerAPI",
32
+ "AsyncIotManagerAPI",
33
+ "ReleasesAPI",
34
+ "AsyncReleasesAPI",
35
+ "InventoryAPI",
36
+ "AsyncInventoryAPI",
37
+ "FiltersAPI",
38
+ "AsyncFiltersAPI",
39
+ "TenantadmAPI",
40
+ "AsyncTenantadmAPI",
41
+ "UseradmAPI",
42
+ "AsyncUseradmAPI",
43
+ ]