snap-python 0.0.1__py3-none-any.whl → 0.0.3__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
@@ -0,0 +1,7 @@
1
+ import logging
2
+ import os
3
+
4
+ log_level = os.getenv("SNAP_PYTHON_LOG_LEVEL", "DEBUG").upper()
5
+ numeric_level = getattr(logging, log_level, logging.DEBUG)
6
+ logger = logging.getLogger("snap_python")
7
+ logger.setLevel(numeric_level)
snap_python/client.py CHANGED
@@ -1,7 +1,11 @@
1
+ import asyncio
1
2
  import logging
3
+ from typing import AsyncGenerator
2
4
 
3
5
  import httpx
6
+ from retry import retry
4
7
 
8
+ from snap_python.components.config import ConfigEndpoints
5
9
  from snap_python.components.snaps import SnapsEndpoints
6
10
  from snap_python.components.store import StoreEndpoints
7
11
  from snap_python.schemas.changes import ChangesResponse
@@ -14,6 +18,8 @@ logger.setLevel(logging.DEBUG)
14
18
 
15
19
 
16
20
  class SnapClient(AbstractSnapsClient):
21
+ """Core class for interacting with the Snap Store and Snapd."""
22
+
17
23
  def __init__(
18
24
  self,
19
25
  version: str = "v2",
@@ -48,16 +54,42 @@ class SnapClient(AbstractSnapsClient):
48
54
  version=self.version,
49
55
  headers=self.store_headers,
50
56
  )
57
+ self.config = ConfigEndpoints(self)
51
58
 
59
+ @retry(httpx.HTTPError, tries=3, delay=1, backoff=1)
52
60
  async def request(self, method: str, endpoint: str, **kwargs) -> httpx.Response:
61
+ """
62
+ Sends an HTTP request to the specified endpoint using the given method and parameters.
63
+ Args:
64
+ method (str): The HTTP method to use for the request (e.g., 'GET', 'POST').
65
+ endpoint (str): The API endpoint to send the request to.
66
+ **kwargs: Additional keyword arguments to pass to the request.
67
+ Returns:
68
+ httpx.Response: The response object from the HTTP request.
69
+ Raises:
70
+ httpx.HTTPStatusError: If the response contains an HTTP status code indicating an error.
71
+ """
72
+
53
73
  response = await self.snapd_client.request(
54
74
  method, f"{self._base_url}/{self.version}/{endpoint}", **kwargs
55
75
  )
56
- logger.debug(f"response: {response.content}")
57
76
  response.raise_for_status()
58
77
  return response
59
78
 
79
+ @retry(httpx.HTTPError, tries=3, delay=1, backoff=1)
60
80
  async def request_raw(self, method: str, endpoint: str, **kwargs) -> httpx.Response:
81
+ """
82
+ Sends an HTTP request using the specified method and endpoint.
83
+ Args:
84
+ method (str): The HTTP method to use for the request (e.g., 'GET', 'POST').
85
+ endpoint (str): The endpoint URL to send the request to.
86
+ **kwargs: Additional keyword arguments to pass to the request.
87
+ Returns:
88
+ httpx.Response: The response object resulting from the HTTP request.
89
+ Raises:
90
+ httpx.HTTPStatusError: If the response contains an HTTP status code indicating an error.
91
+ """
92
+
61
93
  response = await self.snapd_client.request(method, endpoint, **kwargs)
62
94
 
63
95
  response.raise_for_status()
@@ -84,3 +116,23 @@ class SnapClient(AbstractSnapsClient):
84
116
  pass
85
117
 
86
118
  return ChangesResponse.model_validate_json(response.content)
119
+
120
+ async def get_changes_by_id_generator(
121
+ self, change_id: str
122
+ ) -> AsyncGenerator[ChangesResponse, None]:
123
+ while True:
124
+ try:
125
+ response = await self.request("GET", f"changes/{change_id}")
126
+ except httpx.HTTPStatusError as e:
127
+ logger.warning(
128
+ f"Bad response status code for get_changes_by_id: {e.response.status_code}"
129
+ )
130
+ response = e.response
131
+ pass
132
+
133
+ change_response = ChangesResponse.model_validate_json(response.content)
134
+ yield change_response
135
+
136
+ await asyncio.sleep(
137
+ 0.01
138
+ ) # Add a delay to avoid spamming the server with requests
@@ -0,0 +1,54 @@
1
+ import asyncio
2
+ import logging
3
+
4
+ import httpx
5
+
6
+ from snap_python.schemas.changes import ChangesResponse
7
+ from snap_python.schemas.common import AsyncResponse
8
+ from snap_python.schemas.config import ConfigResponse
9
+ from snap_python.utils import AbstractSnapsClient
10
+
11
+ logger = logging.getLogger("snap_python.components.snaps")
12
+
13
+
14
+ class ConfigEndpoints:
15
+ def __init__(self, client: AbstractSnapsClient) -> None:
16
+ self._client = client
17
+ self.common_endpoint = "snaps"
18
+
19
+ async def get_configuration(
20
+ self, snap: str, keys: list[str] | None = None
21
+ ) -> ConfigResponse:
22
+ if keys:
23
+ response: httpx.Response = await self._client.request(
24
+ "GET",
25
+ f"{self.common_endpoint}/{snap}/conf",
26
+ params={"keys": ",".join(keys)},
27
+ )
28
+ else:
29
+ response: httpx.Response = await self._client.request(
30
+ "GET", f"{self.common_endpoint}/{snap}/conf"
31
+ )
32
+
33
+ return ConfigResponse.model_validate_json(response.content)
34
+
35
+ async def set_configuration(
36
+ self, snap: str, configuration: dict, wait: bool = True
37
+ ) -> AsyncResponse | ChangesResponse:
38
+ # TODO: should check that snap supports configure hook before trying to set configuration
39
+ response: httpx.Response = await self._client.request(
40
+ "PUT", f"{self.common_endpoint}/{snap}/conf", json=configuration
41
+ )
42
+
43
+ async_response = AsyncResponse.model_validate_json(response.content)
44
+ if not wait:
45
+ 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
@@ -1,11 +1,15 @@
1
1
  import asyncio
2
+ import logging
3
+ from pathlib import Path
2
4
 
3
5
  import httpx
4
6
 
5
7
  from snap_python.schemas.changes import ChangesResponse
6
8
  from snap_python.schemas.common import AsyncResponse
7
- from snap_python.schemas.snaps import SnapListResponse
8
- from snap_python.utils import AbstractSnapsClient
9
+ from snap_python.schemas.snaps import SingleSnapResponse, SnapListResponse
10
+ from snap_python.utils import AbstractSnapsClient, going_to_reload_daemon
11
+
12
+ logger = logging.getLogger("snap_python.components.snaps")
9
13
 
10
14
 
11
15
  class SnapsEndpoints:
@@ -27,6 +31,27 @@ class SnapsEndpoints:
27
31
  )
28
32
  return response
29
33
 
34
+ async def get_snap_info(self, snap: str) -> SingleSnapResponse:
35
+ try:
36
+ response: httpx.Response = await self._client.request(
37
+ "GET", f"{self.common_endpoint}/{snap}"
38
+ )
39
+ except httpx.HTTPStatusError as e:
40
+ logger.debug(
41
+ "Bad status code from get_snap_info on snap %s: %s",
42
+ snap,
43
+ e.response.status_code,
44
+ )
45
+ response = e.response
46
+
47
+ return SingleSnapResponse.model_validate_json(response.content)
48
+
49
+ async def is_snap_installed(self, snap: str) -> bool:
50
+ snap_info = await self.get_snap_info(snap)
51
+ if snap_info.status == "OK":
52
+ return True
53
+ return False
54
+
30
55
  async def install_snap(
31
56
  self,
32
57
  snap: str,
@@ -36,8 +61,31 @@ class SnapsEndpoints:
36
61
  ignore_validation: bool = False,
37
62
  jailmode: bool = False,
38
63
  revision: int = None,
64
+ filename: str = None,
39
65
  wait: bool = False,
40
66
  ) -> AsyncResponse | ChangesResponse:
67
+ """Install or sideload a snap
68
+
69
+ To sideload a snap, provide the filename parameter with the path to the snap file
70
+
71
+
72
+ Args:
73
+ snap (str): name of the snap to install
74
+ channel (str, optional): Channel to install. Defaults to "stable".
75
+ classic (bool, optional): Install with classic confinement. Defaults to False.
76
+ devmode (bool, optional): Install with devmode. Defaults to False.
77
+ ignore_validation (bool, optional): _description_. Defaults to False.
78
+ jailmode (bool, optional): Install snap with jailmode. Defaults to False.
79
+ revision (int, optional): install a specific revision of the snap. Defaults to None.
80
+ filename (str, optional): Path to snap to sideload. Defaults to None.
81
+ wait (bool, optional): Whether to wait for snap to install. If not waiting, will return async response with change id. Defaults to False.
82
+
83
+ Raises:
84
+ Exception: If error occurs during snap install
85
+
86
+ Returns:
87
+ AsyncResponse | ChangesResponse: If wait is True, will return ChangesResponse. Otherwise, will return AsyncResponse
88
+ """
41
89
  request_data = {
42
90
  "action": "install",
43
91
  "channel": channel,
@@ -48,24 +96,51 @@ class SnapsEndpoints:
48
96
  }
49
97
  if revision:
50
98
  request_data["revision"] = revision
51
- raw_response: httpx.Response = await self._client.request(
52
- "POST", f"{self.common_endpoint}/{snap}", json=request_data
53
- )
99
+ if filename:
100
+ # sideload
101
+ if not Path(filename).exists():
102
+ raise FileNotFoundError(f"File {filename} does not exist")
103
+ request_data["dangerous"] = True
104
+ raw_response: httpx.Response = await self._client.request(
105
+ "POST",
106
+ f"{self.common_endpoint}",
107
+ data=request_data,
108
+ files={"snap": open(filename, "rb")},
109
+ )
110
+ else:
111
+ # install from default snap store
112
+ raw_response: httpx.Response = await self._client.request(
113
+ "POST", f"{self.common_endpoint}/{snap}", json=request_data
114
+ )
54
115
  response = AsyncResponse.model_validate_json(raw_response.content)
55
116
  if wait:
56
117
  changes_id = response.change
118
+ previous_changes = None
57
119
  while True:
58
- changes = await self._client.get_changes_by_id(changes_id)
120
+ try:
121
+ changes = await self._client.get_changes_by_id(changes_id)
122
+ logger.debug(f"Progress: {changes.result.overall_progress}")
123
+ except httpx.HTTPError as e:
124
+ if going_to_reload_daemon(previous_changes):
125
+ logger.debug("Waiting for daemon to reload")
126
+ await asyncio.sleep(0.1)
127
+ continue
128
+
59
129
  if changes.ready:
60
130
  break
61
131
  if changes.result.err:
62
132
  raise Exception(f"Error in snap install: {changes.result.err}")
63
- await asyncio.sleep(2.0)
133
+ await asyncio.sleep(0.1)
134
+ previous_changes = changes
64
135
  return changes
65
136
  return response
66
137
 
67
138
  async def remove_snap(
68
- self, snap: str, purge: bool, terminate: bool, wait: bool = False
139
+ self,
140
+ snap: str,
141
+ purge: bool = False,
142
+ terminate: bool = False,
143
+ wait: bool = False,
69
144
  ) -> AsyncResponse | ChangesResponse:
70
145
  request_data = {
71
146
  "action": "remove",
@@ -80,13 +155,21 @@ class SnapsEndpoints:
80
155
 
81
156
  if wait:
82
157
  changes_id = response.change
158
+ previous_changes = None
83
159
  while True:
84
- changes = await self._client.get_changes_by_id(changes_id)
160
+ try:
161
+ changes = await self._client.get_changes_by_id(changes_id)
162
+ except httpx.HTTPError as e:
163
+ if going_to_reload_daemon(previous_changes):
164
+ logger.debug("Waiting for daemon to reload")
165
+ await asyncio.sleep(0.1)
166
+ continue
85
167
  if changes.ready:
86
168
  break
87
169
  if changes.result.err:
88
170
  raise Exception(f"Error in snap remove: {changes.result.err}")
89
- await asyncio.sleep(2.0)
171
+ await asyncio.sleep(0.1)
172
+ previous_changes = changes
90
173
  return changes
91
174
 
92
175
  return response
@@ -3,12 +3,23 @@ from pydantic import AliasChoices, AwareDatetime, BaseModel, Field
3
3
  from snap_python.schemas.common import BaseErrorResult, BaseResponse
4
4
 
5
5
 
6
+ class MaintenanceInfo(BaseModel):
7
+ kind: str
8
+ message: str
9
+
10
+
11
+ class ProgressInfo(BaseModel):
12
+ label: str
13
+ done: int
14
+ total: int
15
+
16
+
6
17
  class Task(BaseModel):
7
18
  id: str
8
19
  kind: str
9
20
  summary: str
10
21
  status: str
11
- progress: dict
22
+ progress: ProgressInfo
12
23
  spawn_time: AwareDatetime = Field(
13
24
  validation_alias=AliasChoices("spawn-time", "spawn_time"),
14
25
  serialization_alias="spawn-time",
@@ -40,12 +51,22 @@ class ChangesResult(BaseModel):
40
51
  err: str | None = None
41
52
  data: dict | None = None
42
53
 
54
+ @property
55
+ def overall_progress(self) -> ProgressInfo:
56
+ if not self.tasks:
57
+ return ProgressInfo(label="Overall", done=0, total=-1)
58
+ total = sum(task.progress.total for task in self.tasks)
59
+ done = sum(task.progress.done for task in self.tasks)
60
+
61
+ return ProgressInfo(label="Overall", done=done, total=total)
62
+
43
63
 
44
64
  class ChangesResponse(BaseResponse):
45
65
  result: ChangesResult | BaseErrorResult
66
+ maintenance: MaintenanceInfo | None = None
46
67
 
47
68
  @property
48
69
  def ready(self) -> bool:
49
70
  if isinstance(self.result, BaseErrorResult):
50
- return False
71
+ return True
51
72
  return self.result.ready
@@ -0,0 +1,5 @@
1
+ from snap_python.schemas.common import BaseResponse
2
+
3
+
4
+ class ConfigResponse(BaseResponse):
5
+ result: dict
@@ -1,6 +1,7 @@
1
1
  from pydantic import AliasChoices, AwareDatetime, BaseModel, ConfigDict, Field
2
2
 
3
3
  from snap_python.schemas.common import (
4
+ BaseErrorResult,
4
5
  BaseResponse,
5
6
  Media,
6
7
  SnapApp,
@@ -43,20 +44,25 @@ class Snap(BaseModel):
43
44
  )
44
45
  name: str
45
46
  private: bool
46
- publisher: dict[str, str]
47
+ publisher: dict[str, str] | None = None
47
48
  revision: str
48
49
  status: str
49
50
  summary: str
50
51
  title: str | None = None
51
- tracking_channel: str = Field(
52
+ tracking_channel: str | None = Field(
52
53
  validation_alias=AliasChoices("tracking-channel", "tracking_channel"),
53
54
  serialization_alias="tracking-channel",
55
+ default=None,
54
56
  )
55
57
  type: str
56
58
  version: str
57
59
  website: str | None = None
58
60
 
59
61
 
62
+ class SingleSnapResponse(BaseResponse):
63
+ result: Snap | BaseErrorResult
64
+
65
+
60
66
  class SnapListResponse(BaseResponse):
61
67
  result: list[Snap]
62
68
 
snap_python/utils.py CHANGED
@@ -17,3 +17,16 @@ class AbstractSnapsClient(ABC): # pragma: no cover
17
17
  @abstractmethod
18
18
  async def get_changes_by_id(self, change_id: str) -> ChangesResponse:
19
19
  pass
20
+
21
+
22
+ def going_to_reload_daemon(changes: ChangesResponse | None) -> bool:
23
+ if changes is None or not isinstance(changes, ChangesResponse):
24
+ return False
25
+
26
+ if changes.maintenance and changes.maintenance.kind in [
27
+ "daemon-reload",
28
+ "daemon-restart",
29
+ ]:
30
+ return True
31
+
32
+ return False
@@ -1,17 +1,21 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: snap-python
3
- Version: 0.0.1
4
- Summary:
3
+ Version: 0.0.3
4
+ Summary: An api to interact with snapd and snap stores
5
+ Home-page: https://github.com/alexlukens/snap-python
5
6
  License: MIT
6
7
  Author: Alexander Lukens
7
8
  Author-email: alexdlukens@gmail.com
8
- Requires-Python: >=3.12,<4.0
9
+ Requires-Python: >=3.10,<4.0
9
10
  Classifier: License :: OSI Approved :: MIT License
10
11
  Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
11
14
  Classifier: Programming Language :: Python :: 3.12
12
15
  Requires-Dist: httpx (>=0.27.2,<0.28.0)
13
16
  Requires-Dist: pydantic (>=2.9.2,<3.0.0)
14
17
  Requires-Dist: retry (>=0.9.2,<0.10.0)
18
+ Project-URL: Repository, https://github.com/alexlukens/snap-python
15
19
  Description-Content-Type: text/markdown
16
20
 
17
21
  # snap_python
@@ -1,11 +1,13 @@
1
- snap_python/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- snap_python/client.py,sha256=5AOo71wxmiEQUv4Y5Y0Fwj-3ZFLZoJ6XPXN1El_lgt4,2985
1
+ snap_python/__init__.py,sha256=gV43Nempk7HFKD1UCvxUhmP9x6dBmPjotcITREr81Vg,222
2
+ snap_python/client.py,sha256=PsmO9EIszUsYc6zYOOijFjFr1jdHQR2dxl3by2bWUMo,5150
3
3
  snap_python/components/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- snap_python/components/snaps.py,sha256=zR--BC9OvWtTI3eww9o7Zs1QRajzo4aDg-6_fEwmzVQ,3166
4
+ snap_python/components/config.py,sha256=IO_gCrumphEZioINFmXxb8I5OVsKC9cgSa28sQbZ700,1775
5
+ snap_python/components/snaps.py,sha256=Hw1dPlgmkAo9q3YQUOnCa3Dz6b7lnA5JkkdZF71HSrs,6631
5
6
  snap_python/components/store.py,sha256=_o_2OYMvGbq_bLEQXw9zHjBrCipQ92qZaSkwdAfngAc,6451
6
- snap_python/schemas/changes.py,sha256=bAXYvOwEJ6sYjRh_rU3bpZ1BTT_zL0Mw_zS-fZZomTs,1368
7
+ snap_python/schemas/changes.py,sha256=7SYUFXAQq9arl7hAA3YQ0YubMurPJtknTOVxP8ryqew,1919
7
8
  snap_python/schemas/common.py,sha256=n0npzv0CAaSK6GPY1Y9PxywWRR0KE7Wf2JML8G5e9Dk,1459
8
- snap_python/schemas/snaps.py,sha256=IufXEGBmMZKFlThzqKI-frEgXf4c6WpSGzDoIhtGAoo,1819
9
+ snap_python/schemas/config.py,sha256=xXTGhk6TpwI8RlR6_qnfWlBHrBnTwr3hRCO5qCnN-6U,107
10
+ snap_python/schemas/snaps.py,sha256=L6iXB4j9MiSR57siIkxP5MbB7I0wmDBC1dHmGs0QkY8,1960
9
11
  snap_python/schemas/store/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
12
  snap_python/schemas/store/categories.py,sha256=PewotRqoPs7cMKtlZKsHVNfnJoItmNKMekS1Be-46l0,1082
11
13
  snap_python/schemas/store/info.py,sha256=AKe93ichq8gOIQQxSaEEWHmFHeeONOqe5hfOADJP1gU,3330
@@ -14,8 +16,8 @@ snap_python/schemas/store/json/category_schema.json,sha256=c4h_eyyBbChOiNby3hYfj
14
16
  snap_python/schemas/store/json/snaps_find_schema.json,sha256=TfKpnievs5QOc7ah97ATMCQTOEfKspONTZnwSwq_0fI,8866
15
17
  snap_python/schemas/store/json/snaps_info_schema.json,sha256=udoOovb6Ea7EnE_R9RuZ1CpY1wOJ3-K8xP4-8zKHgrQ,15888
16
18
  snap_python/schemas/store/search.py,sha256=ewsc00BV4S1AIZMCj-HCvB7lOznGpWImHv7YZbkRBhc,4496
17
- snap_python/utils.py,sha256=cQOh5hE9gdLwFA1dAN3CCeC1MYJh9J8yQZstcg0JMUE,434
18
- snap_python-0.0.1.dist-info/LICENSE,sha256=lYXNZszxC_l2rlBvrHYs-esDegxWh8MKtjrdxA24uOk,1072
19
- snap_python-0.0.1.dist-info/METADATA,sha256=StMCrHoDOGdhrDA4hl6ylXs13gvVRDlpE5gLhjCCbxU,829
20
- snap_python-0.0.1.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
21
- snap_python-0.0.1.dist-info/RECORD,,
19
+ snap_python/utils.py,sha256=pE-mVtoq5KMZH_Xg1O9lJlAmktLyuIdAVBEomuLZjWI,752
20
+ snap_python-0.0.3.dist-info/LICENSE,sha256=lYXNZszxC_l2rlBvrHYs-esDegxWh8MKtjrdxA24uOk,1072
21
+ snap_python-0.0.3.dist-info/METADATA,sha256=N-hc-RSJiouLpnb7QGXB2OAHUBdrU_YctJalRJqfxY8,1096
22
+ snap_python-0.0.3.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
23
+ snap_python-0.0.3.dist-info/RECORD,,