snap-python 0.1.7__tar.gz → 0.2.0__tar.gz
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.
- {snap_python-0.1.7 → snap_python-0.2.0}/PKG-INFO +5 -3
- {snap_python-0.1.7 → snap_python-0.2.0}/pyproject.toml +1 -2
- snap_python-0.2.0/src/snap_python/__init__.py +7 -0
- snap_python-0.2.0/src/snap_python/client.py +302 -0
- snap_python-0.2.0/src/snap_python/components/changes.py +157 -0
- {snap_python-0.1.7 → snap_python-0.2.0}/src/snap_python/components/config.py +10 -13
- snap_python-0.2.0/src/snap_python/components/interfaces.py +283 -0
- snap_python-0.2.0/src/snap_python/components/logs.py +162 -0
- snap_python-0.2.0/src/snap_python/components/snaps.py +1322 -0
- {snap_python-0.1.7 → snap_python-0.2.0}/src/snap_python/components/store.py +43 -27
- {snap_python-0.1.7 → snap_python-0.2.0}/src/snap_python/schemas/changes.py +21 -1
- {snap_python-0.1.7 → snap_python-0.2.0}/src/snap_python/schemas/common.py +41 -4
- snap_python-0.2.0/src/snap_python/schemas/interfaces.py +251 -0
- snap_python-0.2.0/src/snap_python/schemas/logs.py +17 -0
- snap_python-0.2.0/src/snap_python/schemas/snaps.py +232 -0
- {snap_python-0.1.7 → snap_python-0.2.0}/src/snap_python/schemas/store/info.py +17 -0
- {snap_python-0.1.7 → snap_python-0.2.0}/src/snap_python/schemas/store/refresh.py +4 -4
- {snap_python-0.1.7 → snap_python-0.2.0}/src/snap_python/schemas/store/track.py +61 -49
- snap_python-0.2.0/src/snap_python/utils.py +232 -0
- snap_python-0.1.7/src/snap_python/__init__.py +0 -7
- snap_python-0.1.7/src/snap_python/client.py +0 -180
- snap_python-0.1.7/src/snap_python/components/snaps.py +0 -468
- snap_python-0.1.7/src/snap_python/schemas/snaps.py +0 -122
- snap_python-0.1.7/src/snap_python/utils.py +0 -50
- {snap_python-0.1.7 → snap_python-0.2.0}/LICENSE +0 -0
- {snap_python-0.1.7 → snap_python-0.2.0}/README.md +0 -0
- {snap_python-0.1.7 → snap_python-0.2.0}/src/snap_python/components/__init__.py +0 -0
- {snap_python-0.1.7 → snap_python-0.2.0}/src/snap_python/schemas/config.py +0 -0
- {snap_python-0.1.7 → snap_python-0.2.0}/src/snap_python/schemas/store/__init__.py +0 -0
- {snap_python-0.1.7 → snap_python-0.2.0}/src/snap_python/schemas/store/categories.py +0 -0
- {snap_python-0.1.7 → snap_python-0.2.0}/src/snap_python/schemas/store/json/category_by_name_schema.json +0 -0
- {snap_python-0.1.7 → snap_python-0.2.0}/src/snap_python/schemas/store/json/category_schema.json +0 -0
- {snap_python-0.1.7 → snap_python-0.2.0}/src/snap_python/schemas/store/json/snaps_find_schema.json +0 -0
- {snap_python-0.1.7 → snap_python-0.2.0}/src/snap_python/schemas/store/json/snaps_info_schema.json +0 -0
- {snap_python-0.1.7 → snap_python-0.2.0}/src/snap_python/schemas/store/search.py +0 -0
- {snap_python-0.1.7 → snap_python-0.2.0}/src/snap_python/scrape.py +0 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: snap-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Interact with snapd and snapstore(s) from Python
|
|
5
5
|
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
6
7
|
Author: Alexander Lukens
|
|
7
8
|
Author-email: alexdlukens@gmail.com
|
|
8
9
|
Requires-Python: >=3.10,<4.0
|
|
@@ -12,9 +13,10 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
12
13
|
Classifier: Programming Language :: Python :: 3.11
|
|
13
14
|
Classifier: Programming Language :: Python :: 3.12
|
|
14
15
|
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
15
18
|
Requires-Dist: httpx (>0.27,<1)
|
|
16
19
|
Requires-Dist: pydantic (>2.9,<3)
|
|
17
|
-
Requires-Dist: retry (>=0.9.2,<0.10.0)
|
|
18
20
|
Project-URL: Documentation, https://alexdlukens.github.io/snap-python/
|
|
19
21
|
Project-URL: Repository, https://github.com/alexdlukens/snap-python
|
|
20
22
|
Description-Content-Type: text/markdown
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "snap-python"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
description = "Interact with snapd and snapstore(s) from Python"
|
|
5
5
|
authors = ["Alexander Lukens <alexdlukens@gmail.com>"]
|
|
6
6
|
license = "MIT"
|
|
@@ -12,7 +12,6 @@ documentation = "https://alexdlukens.github.io/snap-python/"
|
|
|
12
12
|
python = "^3.10"
|
|
13
13
|
httpx = ">0.27,<1"
|
|
14
14
|
pydantic = ">2.9,<3"
|
|
15
|
-
retry = "^0.9.2"
|
|
16
15
|
|
|
17
16
|
[tool.poetry.group.dev.dependencies]
|
|
18
17
|
pylxd = "^2.3.5"
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import AsyncGenerator
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
from snap_python.components.changes import ChangeEndpoints
|
|
7
|
+
from snap_python.components.config import ConfigEndpoints
|
|
8
|
+
from snap_python.components.interfaces import InterfacesEndpoints
|
|
9
|
+
from snap_python.components.logs import LogsEndpoints
|
|
10
|
+
from snap_python.components.snaps import SnapsEndpoints
|
|
11
|
+
from snap_python.components.store import StoreEndpoints
|
|
12
|
+
from snap_python.schemas.changes import ChangesResponse
|
|
13
|
+
from snap_python.utils import (
|
|
14
|
+
IDEMPOTENT_HTTP_METHODS,
|
|
15
|
+
AbstractSnapsClient,
|
|
16
|
+
SnapdAPIError,
|
|
17
|
+
async_retry,
|
|
18
|
+
is_transient_exception,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
SNAPD_SOCKET = "/run/snapd.socket"
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("snap_python.client")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _is_retryable_request(exc: BaseException, args: tuple, kwargs: dict) -> bool:
|
|
27
|
+
"""Retry predicate for :meth:`SnapClient.request` and :meth:`request_raw`.
|
|
28
|
+
|
|
29
|
+
Only idempotent methods are retried: replaying a POST/PUT/DELETE can
|
|
30
|
+
duplicate side effects (installing a snap twice, for instance).
|
|
31
|
+
"""
|
|
32
|
+
method = kwargs.get("method")
|
|
33
|
+
if method is None and len(args) >= 2:
|
|
34
|
+
method = args[1]
|
|
35
|
+
if str(method).upper() not in IDEMPOTENT_HTTP_METHODS:
|
|
36
|
+
return False
|
|
37
|
+
return is_transient_exception(exc)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _snapd_error_from_response(response: httpx.Response) -> SnapdAPIError | None:
|
|
41
|
+
"""Build a :class:`SnapdAPIError` from a snapd error envelope.
|
|
42
|
+
|
|
43
|
+
snapd reports failures as
|
|
44
|
+
``{"type": "error", "status-code": N, "result": {"message", "kind", "value"}}``.
|
|
45
|
+
Returns ``None`` when the body is not such an envelope, so callers can fall
|
|
46
|
+
back to :meth:`httpx.Response.raise_for_status`.
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
body = response.json()
|
|
50
|
+
except ValueError:
|
|
51
|
+
return None
|
|
52
|
+
if not isinstance(body, dict) or body.get("type") != "error":
|
|
53
|
+
return None
|
|
54
|
+
result = body.get("result")
|
|
55
|
+
if not isinstance(result, dict):
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
request = response.request
|
|
59
|
+
request_desc = (
|
|
60
|
+
f"{request.method} {request.url}" if request is not None else "snapd request"
|
|
61
|
+
)
|
|
62
|
+
detail = result.get("message") or "snapd returned an error"
|
|
63
|
+
return SnapdAPIError(
|
|
64
|
+
f"HTTP {response.status_code} for {request_desc}: {detail}",
|
|
65
|
+
status_code=response.status_code,
|
|
66
|
+
kind=result.get("kind"),
|
|
67
|
+
value=result.get("value"),
|
|
68
|
+
response=response,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class SnapClient(AbstractSnapsClient):
|
|
73
|
+
"""Core class for interacting with the Snap Store and Snapd.
|
|
74
|
+
|
|
75
|
+
:param timeout: Timeout applied to every snapd request, in seconds or as an
|
|
76
|
+
:class:`httpx.Timeout`. ``None`` disables the timeout (httpx interprets
|
|
77
|
+
it that way), which is useful for large sideloads. Defaults to 60.0;
|
|
78
|
+
httpx's own 5 second default is too short for uploads.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
version: str = "v2",
|
|
84
|
+
snapd_socket_location: str = None,
|
|
85
|
+
tcp_location: str = None,
|
|
86
|
+
store_base_url: str = "https://api.snapcraft.io",
|
|
87
|
+
store_headers: dict[str, str] = None,
|
|
88
|
+
prompt_for_authentication: bool = False,
|
|
89
|
+
timeout: float | httpx.Timeout | None = 60.0,
|
|
90
|
+
):
|
|
91
|
+
if tcp_location and snapd_socket_location:
|
|
92
|
+
raise ValueError(
|
|
93
|
+
"Only one of snapd_socket_location or tcp_location can be provided."
|
|
94
|
+
)
|
|
95
|
+
if tcp_location is not None:
|
|
96
|
+
self._base_url = tcp_location
|
|
97
|
+
self._transport = httpx.AsyncHTTPTransport()
|
|
98
|
+
else:
|
|
99
|
+
self._base_url = "http://localhost"
|
|
100
|
+
self._transport = httpx.AsyncHTTPTransport(
|
|
101
|
+
uds=snapd_socket_location or SNAPD_SOCKET
|
|
102
|
+
)
|
|
103
|
+
if store_headers is None:
|
|
104
|
+
store_headers = {"Snap-Device-Series": "16", "X-Ubuntu-Series": "16"}
|
|
105
|
+
|
|
106
|
+
self.snapd_headers = {}
|
|
107
|
+
if prompt_for_authentication:
|
|
108
|
+
self.snapd_headers = {"X-Allow-Interaction": "true"}
|
|
109
|
+
|
|
110
|
+
self.version = version
|
|
111
|
+
self.store_base_url = store_base_url
|
|
112
|
+
self.store_headers = store_headers
|
|
113
|
+
self.snapd_client = httpx.AsyncClient(
|
|
114
|
+
transport=self._transport, headers=self.snapd_headers, timeout=timeout
|
|
115
|
+
)
|
|
116
|
+
self.snaps = SnapsEndpoints(self)
|
|
117
|
+
self.store = StoreEndpoints(
|
|
118
|
+
base_url=self.store_base_url,
|
|
119
|
+
version=self.version,
|
|
120
|
+
headers=self.store_headers,
|
|
121
|
+
)
|
|
122
|
+
self.config = ConfigEndpoints(self)
|
|
123
|
+
self.changes = ChangeEndpoints(self)
|
|
124
|
+
self.interfaces = InterfacesEndpoints(self)
|
|
125
|
+
self.logs = LogsEndpoints(self)
|
|
126
|
+
|
|
127
|
+
def endpoint_url(self, endpoint: str) -> str:
|
|
128
|
+
"""
|
|
129
|
+
Build the absolute URL of a snapd ``/v2`` endpoint.
|
|
130
|
+
|
|
131
|
+
:param endpoint: Endpoint relative to ``/v2/``, for example ``"logs"``.
|
|
132
|
+
:type endpoint: str
|
|
133
|
+
|
|
134
|
+
:returns: The absolute URL, using the configured base URL and version.
|
|
135
|
+
:rtype: str
|
|
136
|
+
"""
|
|
137
|
+
return f"{self._base_url}/{self.version}/{endpoint}"
|
|
138
|
+
|
|
139
|
+
@async_retry(
|
|
140
|
+
httpx.HTTPError, tries=3, delay=1, backoff=1, retry_if=_is_retryable_request
|
|
141
|
+
)
|
|
142
|
+
async def request(self, method: str, endpoint: str, **kwargs) -> httpx.Response:
|
|
143
|
+
r"""
|
|
144
|
+
Sends an HTTP request to the specified endpoint using the given method and parameters.
|
|
145
|
+
|
|
146
|
+
:param method: The HTTP method to use for the request (e.g., 'GET', 'POST').
|
|
147
|
+
:type method: str
|
|
148
|
+
:param endpoint: The API endpoint to send the request to.
|
|
149
|
+
:type endpoint: str
|
|
150
|
+
:param `**kwargs`: Additional keyword arguments to pass to the request.
|
|
151
|
+
|
|
152
|
+
:returns: The response object from the HTTP request.
|
|
153
|
+
:rtype: httpx.Response
|
|
154
|
+
|
|
155
|
+
:raises SnapdAPIError: If snapd answered with an error envelope; the
|
|
156
|
+
parsed ``kind`` and ``value`` are available on the exception.
|
|
157
|
+
:raises httpx.HTTPStatusError: If the response contains an HTTP status
|
|
158
|
+
code indicating an error and the body is not a snapd envelope.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
response = await self.snapd_client.request(
|
|
162
|
+
method, self.endpoint_url(endpoint), **kwargs
|
|
163
|
+
)
|
|
164
|
+
if response.is_error:
|
|
165
|
+
snapd_error = _snapd_error_from_response(response)
|
|
166
|
+
if snapd_error is not None:
|
|
167
|
+
raise snapd_error
|
|
168
|
+
response.raise_for_status()
|
|
169
|
+
return response
|
|
170
|
+
|
|
171
|
+
@async_retry(
|
|
172
|
+
httpx.HTTPError, tries=3, delay=1, backoff=1, retry_if=_is_retryable_request
|
|
173
|
+
)
|
|
174
|
+
async def request_raw(self, method: str, endpoint: str, **kwargs) -> httpx.Response:
|
|
175
|
+
r"""
|
|
176
|
+
Sends an HTTP request using the specified method and endpoint.
|
|
177
|
+
|
|
178
|
+
:param method: The HTTP method to use for the request (e.g., 'POST').
|
|
179
|
+
:type method: str
|
|
180
|
+
:param endpoint: The endpoint URL to send the request to.
|
|
181
|
+
:type endpoint: str
|
|
182
|
+
:param `**kwargs`: Additional keyword arguments to pass to the request.
|
|
183
|
+
|
|
184
|
+
:returns: The response object resulting from the HTTP request.
|
|
185
|
+
:rtype: httpx.Response
|
|
186
|
+
|
|
187
|
+
:raises SnapdAPIError: If snapd answered with an error envelope; the
|
|
188
|
+
parsed ``kind`` and ``value`` are available on the exception.
|
|
189
|
+
:raises httpx.HTTPStatusError: If the response contains an HTTP status
|
|
190
|
+
code indicating an error and the body is not a snapd envelope.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
response = await self.snapd_client.request(method, endpoint, **kwargs)
|
|
194
|
+
|
|
195
|
+
if response.is_error:
|
|
196
|
+
snapd_error = _snapd_error_from_response(response)
|
|
197
|
+
if snapd_error is not None:
|
|
198
|
+
raise snapd_error
|
|
199
|
+
response.raise_for_status()
|
|
200
|
+
return response
|
|
201
|
+
|
|
202
|
+
async def ping(self) -> httpx.Response:
|
|
203
|
+
"""
|
|
204
|
+
Reserved for human-readable content describing the service.
|
|
205
|
+
|
|
206
|
+
:returns: The response from the ping request.
|
|
207
|
+
:rtype: httpx.Response
|
|
208
|
+
|
|
209
|
+
:raises httpx.HTTPStatusError: If snapd is not reachable or reports an
|
|
210
|
+
error status.
|
|
211
|
+
"""
|
|
212
|
+
response = await self.snapd_client.get(f"{self._base_url}/")
|
|
213
|
+
response.raise_for_status()
|
|
214
|
+
|
|
215
|
+
return response
|
|
216
|
+
|
|
217
|
+
async def is_available(self) -> bool:
|
|
218
|
+
"""
|
|
219
|
+
Check whether snapd is reachable and healthy.
|
|
220
|
+
|
|
221
|
+
:returns: True if snapd answered with a 2xx status, False otherwise.
|
|
222
|
+
:rtype: bool
|
|
223
|
+
"""
|
|
224
|
+
try:
|
|
225
|
+
await self.ping()
|
|
226
|
+
except httpx.HTTPError as exc:
|
|
227
|
+
logger.debug("snapd is not available: %s", exc)
|
|
228
|
+
return False
|
|
229
|
+
return True
|
|
230
|
+
|
|
231
|
+
async def close(self) -> None:
|
|
232
|
+
"""Close the underlying snapd and store HTTP clients."""
|
|
233
|
+
await self.snapd_client.aclose()
|
|
234
|
+
await self.store.close()
|
|
235
|
+
|
|
236
|
+
async def __aenter__(self) -> "SnapClient":
|
|
237
|
+
return self
|
|
238
|
+
|
|
239
|
+
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
240
|
+
await self.close()
|
|
241
|
+
|
|
242
|
+
async def get_changes_by_id(self, change_id: str) -> ChangesResponse:
|
|
243
|
+
"""
|
|
244
|
+
Asynchronously retrieves changes by their ID.
|
|
245
|
+
|
|
246
|
+
Thin delegate to :meth:`snap_python.components.changes.ChangeEndpoints.get`.
|
|
247
|
+
|
|
248
|
+
:param change_id: The ID of the change to retrieve.
|
|
249
|
+
:type change_id: str
|
|
250
|
+
|
|
251
|
+
:returns: The response object containing the changes.
|
|
252
|
+
:rtype: ChangesResponse
|
|
253
|
+
|
|
254
|
+
:raises httpx.HTTPStatusError: If the request results in an HTTP error.
|
|
255
|
+
"""
|
|
256
|
+
return await self.changes.get(change_id)
|
|
257
|
+
|
|
258
|
+
async def get_changes_by_id_generator(
|
|
259
|
+
self,
|
|
260
|
+
change_id: str,
|
|
261
|
+
timeout: float | None = None,
|
|
262
|
+
poll_interval: float = 0.5,
|
|
263
|
+
) -> AsyncGenerator[ChangesResponse, None]:
|
|
264
|
+
"""
|
|
265
|
+
Asynchronous generator to fetch changes by change ID.
|
|
266
|
+
|
|
267
|
+
Thin delegate to
|
|
268
|
+
:meth:`snap_python.components.changes.ChangeEndpoints.watch`: polls
|
|
269
|
+
snapd and yields every :class:`ChangesResponse` until the change is
|
|
270
|
+
ready. Error results are raised instead of being yielded silently.
|
|
271
|
+
|
|
272
|
+
:param change_id: The ID of the change to fetch.
|
|
273
|
+
:type change_id: str
|
|
274
|
+
:param timeout: Maximum number of seconds to poll. ``None`` polls until
|
|
275
|
+
the change is ready.
|
|
276
|
+
:type timeout: float | None
|
|
277
|
+
:param poll_interval: Seconds between two polls.
|
|
278
|
+
:type poll_interval: float
|
|
279
|
+
|
|
280
|
+
:yields: The response object containing the changes.
|
|
281
|
+
:rtype: ChangesResponse
|
|
282
|
+
|
|
283
|
+
:raises SnapdAPIError: If the change reports an error or the timeout
|
|
284
|
+
expires.
|
|
285
|
+
:raises httpx.HTTPStatusError: If the request fails with an HTTP error.
|
|
286
|
+
"""
|
|
287
|
+
async for change_response in self.changes.watch(
|
|
288
|
+
change_id, timeout=timeout, poll_interval=poll_interval
|
|
289
|
+
):
|
|
290
|
+
yield change_response
|
|
291
|
+
|
|
292
|
+
async def get_system_info(self) -> dict:
|
|
293
|
+
"""
|
|
294
|
+
Retrieves system information from the snapd service.
|
|
295
|
+
|
|
296
|
+
:returns: A dictionary containing system information.
|
|
297
|
+
:rtype: dict
|
|
298
|
+
|
|
299
|
+
:raises httpx.HTTPStatusError: If the request results in an HTTP error.
|
|
300
|
+
"""
|
|
301
|
+
response = await self.request("GET", "system-info")
|
|
302
|
+
return response.json()
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
from typing import AsyncGenerator, Literal
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from snap_python.schemas.changes import ChangesListResponse, ChangesResponse
|
|
8
|
+
from snap_python.schemas.common import BaseErrorResult
|
|
9
|
+
from snap_python.utils import AbstractSnapsClient, SnapdAPIError, async_retry
|
|
10
|
+
|
|
11
|
+
#: Values ``GET /v2/changes`` accepts for ``select``.
|
|
12
|
+
ChangeSelect = Literal["all", "in-progress", "ready"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ChangeEndpoints:
|
|
16
|
+
"""Endpoints under ``/v2/changes``.
|
|
17
|
+
|
|
18
|
+
:param client: Client used to talk to snapd.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, client: AbstractSnapsClient) -> None:
|
|
22
|
+
self._client = client
|
|
23
|
+
self.common_endpoint = "changes"
|
|
24
|
+
|
|
25
|
+
@async_retry(httpx.HTTPError, tries=3, delay=0.5)
|
|
26
|
+
async def get(self, change_id: str) -> ChangesResponse:
|
|
27
|
+
"""
|
|
28
|
+
Retrieve a single change.
|
|
29
|
+
|
|
30
|
+
:param change_id: The ID of the change to retrieve.
|
|
31
|
+
:type change_id: str
|
|
32
|
+
|
|
33
|
+
:returns: The response object containing the change.
|
|
34
|
+
:rtype: ChangesResponse
|
|
35
|
+
|
|
36
|
+
:raises SnapdAPIError: If snapd does not know the change (404
|
|
37
|
+
``cannot find change with id ...``).
|
|
38
|
+
:raises httpx.HTTPStatusError: If the request results in an HTTP error.
|
|
39
|
+
"""
|
|
40
|
+
response = await self._client.request(
|
|
41
|
+
"GET", f"{self.common_endpoint}/{change_id}"
|
|
42
|
+
)
|
|
43
|
+
return ChangesResponse.model_validate_json(response.content)
|
|
44
|
+
|
|
45
|
+
async def list_changes(
|
|
46
|
+
self,
|
|
47
|
+
select: ChangeSelect | None = None,
|
|
48
|
+
for_snap: str | None = None,
|
|
49
|
+
) -> ChangesListResponse:
|
|
50
|
+
"""
|
|
51
|
+
Retrieve changes, optionally filtered by state and snap name.
|
|
52
|
+
|
|
53
|
+
:param select: ``"all"``, ``"in-progress"`` or ``"ready"``. None
|
|
54
|
+
(default) omits the parameter, so snapd applies its own default,
|
|
55
|
+
``"in-progress"``. Any other value is rejected by snapd with a 400.
|
|
56
|
+
:type select: str, optional
|
|
57
|
+
:param for_snap: Only return changes recorded against this snap name.
|
|
58
|
+
snapd matches the ``snap-names`` change data, splitting
|
|
59
|
+
``"<snap>.<app>"`` entries on the snap part. None (default) returns
|
|
60
|
+
changes for every snap. Sent as the ``for`` query parameter.
|
|
61
|
+
:type for_snap: str, optional
|
|
62
|
+
|
|
63
|
+
:returns: The response object containing the list of changes.
|
|
64
|
+
:rtype: ChangesListResponse
|
|
65
|
+
|
|
66
|
+
:raises SnapdAPIError: If snapd rejects the request, for example for an
|
|
67
|
+
unknown ``select`` value.
|
|
68
|
+
:raises httpx.HTTPStatusError: If the request results in an HTTP error.
|
|
69
|
+
"""
|
|
70
|
+
params: dict[str, str] = {}
|
|
71
|
+
if select is not None:
|
|
72
|
+
params["select"] = select
|
|
73
|
+
if for_snap is not None:
|
|
74
|
+
params["for"] = for_snap
|
|
75
|
+
|
|
76
|
+
response = await self._client.request(
|
|
77
|
+
"GET", self.common_endpoint, params=params
|
|
78
|
+
)
|
|
79
|
+
return ChangesListResponse.model_validate_json(response.content)
|
|
80
|
+
|
|
81
|
+
async def abort(self, change_id: str) -> ChangesResponse:
|
|
82
|
+
"""
|
|
83
|
+
Abort a pending change.
|
|
84
|
+
|
|
85
|
+
The request itself is synchronous: snapd flags the change and returns
|
|
86
|
+
its current info with HTTP 200 straight away. The abort then proceeds
|
|
87
|
+
asynchronously, so the returned change is usually still in
|
|
88
|
+
``"Doing"``/``"Abort"`` state; poll it to observe completion.
|
|
89
|
+
|
|
90
|
+
:param change_id: The ID of the change to abort.
|
|
91
|
+
:type change_id: str
|
|
92
|
+
|
|
93
|
+
:returns: The response object containing the current change info.
|
|
94
|
+
:rtype: ChangesResponse
|
|
95
|
+
|
|
96
|
+
:raises SnapdAPIError: If snapd rejects the abort, for example with 400
|
|
97
|
+
``cannot abort change <id> with nothing pending`` when the change
|
|
98
|
+
is already ready, or 404 for an unknown change.
|
|
99
|
+
:raises httpx.HTTPStatusError: If the request results in an HTTP error.
|
|
100
|
+
"""
|
|
101
|
+
response = await self._client.request(
|
|
102
|
+
"POST",
|
|
103
|
+
f"{self.common_endpoint}/{change_id}",
|
|
104
|
+
json={"action": "abort"},
|
|
105
|
+
)
|
|
106
|
+
return ChangesResponse.model_validate_json(response.content)
|
|
107
|
+
|
|
108
|
+
async def watch(
|
|
109
|
+
self,
|
|
110
|
+
change_id: str,
|
|
111
|
+
timeout: float | None = None,
|
|
112
|
+
poll_interval: float = 0.5,
|
|
113
|
+
) -> AsyncGenerator[ChangesResponse, None]:
|
|
114
|
+
"""
|
|
115
|
+
Poll a change until it is ready.
|
|
116
|
+
|
|
117
|
+
:param change_id: The ID of the change to poll.
|
|
118
|
+
:type change_id: str
|
|
119
|
+
:param timeout: Maximum number of seconds to poll. None (default)
|
|
120
|
+
polls until the change is ready.
|
|
121
|
+
:type timeout: float | None
|
|
122
|
+
:param poll_interval: Seconds between two polls.
|
|
123
|
+
:type poll_interval: float
|
|
124
|
+
|
|
125
|
+
:yields: Every :class:`ChangesResponse`, the ready one last.
|
|
126
|
+
:rtype: ChangesResponse
|
|
127
|
+
|
|
128
|
+
:raises SnapdAPIError: If the change reports an error or the timeout
|
|
129
|
+
expires.
|
|
130
|
+
:raises httpx.HTTPStatusError: If a poll fails with an HTTP error.
|
|
131
|
+
"""
|
|
132
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
133
|
+
|
|
134
|
+
while True:
|
|
135
|
+
change_response = await self.get(change_id)
|
|
136
|
+
|
|
137
|
+
if isinstance(change_response.result, BaseErrorResult):
|
|
138
|
+
raise SnapdAPIError(
|
|
139
|
+
f"Error waiting for change {change_id}: "
|
|
140
|
+
f"{change_response.result.message}"
|
|
141
|
+
)
|
|
142
|
+
if change_response.result.err:
|
|
143
|
+
raise SnapdAPIError(
|
|
144
|
+
f"Error waiting for change {change_id}: "
|
|
145
|
+
f"{change_response.result.err}"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
yield change_response
|
|
149
|
+
|
|
150
|
+
if change_response.ready:
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
154
|
+
raise SnapdAPIError(
|
|
155
|
+
f"Timed out after {timeout}s waiting for change {change_id}"
|
|
156
|
+
)
|
|
157
|
+
await asyncio.sleep(poll_interval)
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import asyncio
|
|
2
1
|
import logging
|
|
3
2
|
|
|
4
3
|
import httpx
|
|
@@ -6,9 +5,9 @@ import httpx
|
|
|
6
5
|
from snap_python.schemas.changes import ChangesResponse
|
|
7
6
|
from snap_python.schemas.common import AsyncResponse
|
|
8
7
|
from snap_python.schemas.config import ConfigResponse
|
|
9
|
-
from snap_python.utils import AbstractSnapsClient
|
|
8
|
+
from snap_python.utils import AbstractSnapsClient, wait_for_change
|
|
10
9
|
|
|
11
|
-
logger = logging.getLogger("snap_python.components.
|
|
10
|
+
logger = logging.getLogger("snap_python.components.config")
|
|
12
11
|
|
|
13
12
|
|
|
14
13
|
class ConfigEndpoints:
|
|
@@ -33,7 +32,11 @@ class ConfigEndpoints:
|
|
|
33
32
|
return ConfigResponse.model_validate_json(response.content)
|
|
34
33
|
|
|
35
34
|
async def set_configuration(
|
|
36
|
-
self,
|
|
35
|
+
self,
|
|
36
|
+
snap: str,
|
|
37
|
+
configuration: dict,
|
|
38
|
+
wait: bool = True,
|
|
39
|
+
timeout: float | None = None,
|
|
37
40
|
) -> AsyncResponse | ChangesResponse:
|
|
38
41
|
# TODO: should check that snap supports configure hook before trying to set configuration
|
|
39
42
|
response: httpx.Response = await self._client.request(
|
|
@@ -43,12 +46,6 @@ class ConfigEndpoints:
|
|
|
43
46
|
async_response = AsyncResponse.model_validate_json(response.content)
|
|
44
47
|
if not wait:
|
|
45
48
|
return async_response
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if changes.ready:
|
|
50
|
-
break
|
|
51
|
-
|
|
52
|
-
await asyncio.sleep(0.1)
|
|
53
|
-
|
|
54
|
-
return changes
|
|
49
|
+
return await wait_for_change(
|
|
50
|
+
self._client, async_response.change, timeout=timeout
|
|
51
|
+
)
|