snap-python 0.1.6__py3-none-any.whl → 0.2.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.
snap_python/__init__.py CHANGED
@@ -1,7 +1,7 @@
1
1
  import logging
2
2
  import os
3
3
 
4
- log_level = os.getenv("SNAP_PYTHON_LOG_LEVEL", "DEBUG").upper()
5
- numeric_level = getattr(logging, log_level, logging.DEBUG)
4
+ log_level = os.getenv("SNAP_PYTHON_LOG_LEVEL", "INFO").upper()
5
+ numeric_level = getattr(logging, log_level, logging.INFO)
6
6
  logger = logging.getLogger("snap_python")
7
7
  logger.setLevel(numeric_level)
snap_python/client.py CHANGED
@@ -1,24 +1,82 @@
1
- import asyncio
2
1
  import logging
3
2
  from typing import AsyncGenerator
4
3
 
5
4
  import httpx
6
- from retry import retry
7
5
 
6
+ from snap_python.components.changes import ChangeEndpoints
8
7
  from snap_python.components.config import ConfigEndpoints
8
+ from snap_python.components.interfaces import InterfacesEndpoints
9
+ from snap_python.components.logs import LogsEndpoints
9
10
  from snap_python.components.snaps import SnapsEndpoints
10
11
  from snap_python.components.store import StoreEndpoints
11
12
  from snap_python.schemas.changes import ChangesResponse
12
- from snap_python.utils import AbstractSnapsClient
13
+ from snap_python.utils import (
14
+ IDEMPOTENT_HTTP_METHODS,
15
+ AbstractSnapsClient,
16
+ SnapdAPIError,
17
+ async_retry,
18
+ is_transient_exception,
19
+ )
13
20
 
14
21
  SNAPD_SOCKET = "/run/snapd.socket"
15
22
 
16
23
  logger = logging.getLogger("snap_python.client")
17
- logger.setLevel(logging.DEBUG)
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
+ )
18
70
 
19
71
 
20
72
  class SnapClient(AbstractSnapsClient):
21
- """Core class for interacting with the Snap Store and Snapd."""
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
+ """
22
80
 
23
81
  def __init__(
24
82
  self,
@@ -28,6 +86,7 @@ class SnapClient(AbstractSnapsClient):
28
86
  store_base_url: str = "https://api.snapcraft.io",
29
87
  store_headers: dict[str, str] = None,
30
88
  prompt_for_authentication: bool = False,
89
+ timeout: float | httpx.Timeout | None = 60.0,
31
90
  ):
32
91
  if tcp_location and snapd_socket_location:
33
92
  raise ValueError(
@@ -52,7 +111,7 @@ class SnapClient(AbstractSnapsClient):
52
111
  self.store_base_url = store_base_url
53
112
  self.store_headers = store_headers
54
113
  self.snapd_client = httpx.AsyncClient(
55
- transport=self._transport, headers=self.snapd_headers
114
+ transport=self._transport, headers=self.snapd_headers, timeout=timeout
56
115
  )
57
116
  self.snaps = SnapsEndpoints(self)
58
117
  self.store = StoreEndpoints(
@@ -61,8 +120,25 @@ class SnapClient(AbstractSnapsClient):
61
120
  headers=self.store_headers,
62
121
  )
63
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}"
64
138
 
65
- @retry(httpx.HTTPError, tries=3, delay=1, backoff=1)
139
+ @async_retry(
140
+ httpx.HTTPError, tries=3, delay=1, backoff=1, retry_if=_is_retryable_request
141
+ )
66
142
  async def request(self, method: str, endpoint: str, **kwargs) -> httpx.Response:
67
143
  r"""
68
144
  Sends an HTTP request to the specified endpoint using the given method and parameters.
@@ -76,21 +152,30 @@ class SnapClient(AbstractSnapsClient):
76
152
  :returns: The response object from the HTTP request.
77
153
  :rtype: httpx.Response
78
154
 
79
- :raises httpx.HTTPStatusError: If the response contains an HTTP status code indicating an error.
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.
80
159
  """
81
160
 
82
161
  response = await self.snapd_client.request(
83
- method, f"{self._base_url}/{self.version}/{endpoint}", **kwargs
162
+ method, self.endpoint_url(endpoint), **kwargs
84
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
85
168
  response.raise_for_status()
86
169
  return response
87
170
 
88
- @retry(httpx.HTTPError, tries=3, delay=1, backoff=1)
171
+ @async_retry(
172
+ httpx.HTTPError, tries=3, delay=1, backoff=1, retry_if=_is_retryable_request
173
+ )
89
174
  async def request_raw(self, method: str, endpoint: str, **kwargs) -> httpx.Response:
90
175
  r"""
91
176
  Sends an HTTP request using the specified method and endpoint.
92
177
 
93
- :param method: The HTTP method to use for the request (e.g., 'GET', 'POST').
178
+ :param method: The HTTP method to use for the request (e.g., 'POST').
94
179
  :type method: str
95
180
  :param endpoint: The endpoint URL to send the request to.
96
181
  :type endpoint: str
@@ -99,11 +184,18 @@ class SnapClient(AbstractSnapsClient):
99
184
  :returns: The response object resulting from the HTTP request.
100
185
  :rtype: httpx.Response
101
186
 
102
- :raises httpx.HTTPStatusError: If the response contains an HTTP status code indicating an error.
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.
103
191
  """
104
192
 
105
193
  response = await self.snapd_client.request(method, endpoint, **kwargs)
106
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
107
199
  response.raise_for_status()
108
200
  return response
109
201
 
@@ -113,16 +205,46 @@ class SnapClient(AbstractSnapsClient):
113
205
 
114
206
  :returns: The response from the ping request.
115
207
  :rtype: httpx.Response
208
+
209
+ :raises httpx.HTTPStatusError: If snapd is not reachable or reports an
210
+ error status.
116
211
  """
117
212
  response = await self.snapd_client.get(f"{self._base_url}/")
213
+ response.raise_for_status()
118
214
 
119
215
  return response
120
216
 
121
- @retry(httpx.HTTPError, tries=3, delay=0.5)
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
+
122
242
  async def get_changes_by_id(self, change_id: str) -> ChangesResponse:
123
243
  """
124
244
  Asynchronously retrieves changes by their ID.
125
245
 
246
+ Thin delegate to :meth:`snap_python.components.changes.ChangeEndpoints.get`.
247
+
126
248
  :param change_id: The ID of the change to retrieve.
127
249
  :type change_id: str
128
250
 
@@ -131,50 +253,50 @@ class SnapClient(AbstractSnapsClient):
131
253
 
132
254
  :raises httpx.HTTPStatusError: If the request results in an HTTP error.
133
255
  """
134
-
135
- try:
136
- response = await self.request("GET", f"changes/{change_id}")
137
- except httpx.HTTPStatusError as e:
138
- logger.warning(
139
- "Bad response status code for get_changes_by_id: %s",
140
- e.response.status_code,
141
- )
142
- response = e.response
143
-
144
- return ChangesResponse.model_validate_json(response.content)
256
+ return await self.changes.get(change_id)
145
257
 
146
258
  async def get_changes_by_id_generator(
147
- self, change_id: str
259
+ self,
260
+ change_id: str,
261
+ timeout: float | None = None,
262
+ poll_interval: float = 0.5,
148
263
  ) -> AsyncGenerator[ChangesResponse, None]:
149
264
  """
150
265
  Asynchronous generator to fetch changes by change ID.
151
266
 
152
- This generator continuously requests changes from the server using the provided
153
- change ID and yields the response as a ChangesResponse object. It includes error
154
- handling for HTTP status errors and adds a small delay between requests to avoid
155
- overwhelming the server.
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.
156
271
 
157
272
  :param change_id: The ID of the change to fetch.
158
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
159
279
 
160
280
  :yields: The response object containing the changes.
161
281
  :rtype: ChangesResponse
162
282
 
163
- :raises httpx.HTTPStatusError: If the request fails with an HTTP status error.
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.
164
286
  """
165
- while True:
166
- try:
167
- response = await self.request("GET", f"changes/{change_id}")
168
- except httpx.HTTPStatusError as e:
169
- logger.warning(
170
- "Bad response status code for get_changes_by_id: %s",
171
- e.response.status_code,
172
- )
173
- response = e.response
174
-
175
- change_response = ChangesResponse.model_validate_json(response.content)
287
+ async for change_response in self.changes.watch(
288
+ change_id, timeout=timeout, poll_interval=poll_interval
289
+ ):
176
290
  yield change_response
177
291
 
178
- await asyncio.sleep(
179
- 0.01
180
- ) # Add a delay to avoid spamming the server with requests
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.snaps")
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, snap: str, configuration: dict, wait: bool = True
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
- while True:
47
- changes = await self._client.get_changes_by_id(async_response.change)
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
+ )