snap-python 0.0.1__tar.gz → 0.0.3__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.
Files changed (25) hide show
  1. {snap_python-0.0.1 → snap_python-0.0.3}/PKG-INFO +7 -3
  2. {snap_python-0.0.1 → snap_python-0.0.3}/pyproject.toml +6 -3
  3. snap_python-0.0.3/src/snap_python/__init__.py +7 -0
  4. {snap_python-0.0.1 → snap_python-0.0.3}/src/snap_python/client.py +53 -1
  5. snap_python-0.0.3/src/snap_python/components/config.py +54 -0
  6. snap_python-0.0.3/src/snap_python/components/snaps.py +175 -0
  7. {snap_python-0.0.1 → snap_python-0.0.3}/src/snap_python/schemas/changes.py +23 -2
  8. snap_python-0.0.3/src/snap_python/schemas/config.py +5 -0
  9. {snap_python-0.0.1 → snap_python-0.0.3}/src/snap_python/schemas/snaps.py +8 -2
  10. {snap_python-0.0.1 → snap_python-0.0.3}/src/snap_python/utils.py +13 -0
  11. snap_python-0.0.1/src/snap_python/components/snaps.py +0 -92
  12. snap_python-0.0.1/src/snap_python/schemas/store/__init__.py +0 -0
  13. {snap_python-0.0.1 → snap_python-0.0.3}/LICENSE +0 -0
  14. {snap_python-0.0.1 → snap_python-0.0.3}/README.md +0 -0
  15. {snap_python-0.0.1/src/snap_python → snap_python-0.0.3/src/snap_python/components}/__init__.py +0 -0
  16. {snap_python-0.0.1 → snap_python-0.0.3}/src/snap_python/components/store.py +0 -0
  17. {snap_python-0.0.1 → snap_python-0.0.3}/src/snap_python/schemas/common.py +0 -0
  18. {snap_python-0.0.1/src/snap_python/components → snap_python-0.0.3/src/snap_python/schemas/store}/__init__.py +0 -0
  19. {snap_python-0.0.1 → snap_python-0.0.3}/src/snap_python/schemas/store/categories.py +0 -0
  20. {snap_python-0.0.1 → snap_python-0.0.3}/src/snap_python/schemas/store/info.py +0 -0
  21. {snap_python-0.0.1 → snap_python-0.0.3}/src/snap_python/schemas/store/json/category_by_name_schema.json +0 -0
  22. {snap_python-0.0.1 → snap_python-0.0.3}/src/snap_python/schemas/store/json/category_schema.json +0 -0
  23. {snap_python-0.0.1 → snap_python-0.0.3}/src/snap_python/schemas/store/json/snaps_find_schema.json +0 -0
  24. {snap_python-0.0.1 → snap_python-0.0.3}/src/snap_python/schemas/store/json/snaps_info_schema.json +0 -0
  25. {snap_python-0.0.1 → snap_python-0.0.3}/src/snap_python/schemas/store/search.py +0 -0
@@ -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,13 +1,14 @@
1
1
  [tool.poetry]
2
2
  name = "snap-python"
3
- version = "0.0.1"
4
- description = ""
3
+ version = "0.0.3"
4
+ description = "An api to interact with snapd and snap stores"
5
5
  authors = ["Alexander Lukens <alexdlukens@gmail.com>"]
6
6
  license = "MIT"
7
7
  readme = "README.md"
8
+ repository = "https://github.com/alexlukens/snap-python"
8
9
 
9
10
  [tool.poetry.dependencies]
10
- python = "^3.12"
11
+ python = "^3.10"
11
12
  httpx = "^0.27.2"
12
13
  pydantic = "^2.9.2"
13
14
  retry = "^0.9.2"
@@ -20,6 +21,8 @@ ruff = "^0.7.4"
20
21
  pre-commit = "^4.0.1"
21
22
  ipykernel = "^6.29.5"
22
23
  pytest-cov = "^6.0.0"
24
+ sphinx = "^8.1.3"
25
+ sphinx-rtd-theme = "^3.0.2"
23
26
 
24
27
  [build-system]
25
28
  requires = ["poetry-core"]
@@ -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)
@@ -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
@@ -0,0 +1,175 @@
1
+ import asyncio
2
+ import logging
3
+ from pathlib import Path
4
+
5
+ import httpx
6
+
7
+ from snap_python.schemas.changes import ChangesResponse
8
+ from snap_python.schemas.common import AsyncResponse
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")
13
+
14
+
15
+ class SnapsEndpoints:
16
+ def __init__(self, client: AbstractSnapsClient) -> None:
17
+ self._client = client
18
+ self.common_endpoint = "snaps"
19
+
20
+ async def list_installed_snaps(self) -> SnapListResponse:
21
+ response: httpx.Response = await self._client.request(
22
+ "GET", self.common_endpoint
23
+ )
24
+
25
+ response = SnapListResponse.model_validate_json(response.content)
26
+ if response.status_code > 299:
27
+ raise httpx.HTTPStatusError(
28
+ request=response.request,
29
+ response=response,
30
+ message=f"Invalid status code in response: {response.status_code}",
31
+ )
32
+ return response
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
+
55
+ async def install_snap(
56
+ self,
57
+ snap: str,
58
+ channel: str = "stable",
59
+ classic: bool = False,
60
+ devmode: bool = False,
61
+ ignore_validation: bool = False,
62
+ jailmode: bool = False,
63
+ revision: int = None,
64
+ filename: str = None,
65
+ wait: bool = False,
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
+ """
89
+ request_data = {
90
+ "action": "install",
91
+ "channel": channel,
92
+ "classic": classic,
93
+ "devmode": devmode,
94
+ "ignore_validation": ignore_validation,
95
+ "jailmode": jailmode,
96
+ }
97
+ if revision:
98
+ request_data["revision"] = revision
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
+ )
115
+ response = AsyncResponse.model_validate_json(raw_response.content)
116
+ if wait:
117
+ changes_id = response.change
118
+ previous_changes = None
119
+ while True:
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
+
129
+ if changes.ready:
130
+ break
131
+ if changes.result.err:
132
+ raise Exception(f"Error in snap install: {changes.result.err}")
133
+ await asyncio.sleep(0.1)
134
+ previous_changes = changes
135
+ return changes
136
+ return response
137
+
138
+ async def remove_snap(
139
+ self,
140
+ snap: str,
141
+ purge: bool = False,
142
+ terminate: bool = False,
143
+ wait: bool = False,
144
+ ) -> AsyncResponse | ChangesResponse:
145
+ request_data = {
146
+ "action": "remove",
147
+ "purge": purge,
148
+ "terminate": terminate,
149
+ }
150
+
151
+ raw_response: httpx.Response = await self._client.request(
152
+ "POST", f"{self.common_endpoint}/{snap}", json=request_data
153
+ )
154
+ response = AsyncResponse.model_validate_json(raw_response.content)
155
+
156
+ if wait:
157
+ changes_id = response.change
158
+ previous_changes = None
159
+ while True:
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
167
+ if changes.ready:
168
+ break
169
+ if changes.result.err:
170
+ raise Exception(f"Error in snap remove: {changes.result.err}")
171
+ await asyncio.sleep(0.1)
172
+ previous_changes = changes
173
+ return changes
174
+
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
 
@@ -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,92 +0,0 @@
1
- import asyncio
2
-
3
- import httpx
4
-
5
- from snap_python.schemas.changes import ChangesResponse
6
- from snap_python.schemas.common import AsyncResponse
7
- from snap_python.schemas.snaps import SnapListResponse
8
- from snap_python.utils import AbstractSnapsClient
9
-
10
-
11
- class SnapsEndpoints:
12
- def __init__(self, client: AbstractSnapsClient) -> None:
13
- self._client = client
14
- self.common_endpoint = "snaps"
15
-
16
- async def list_installed_snaps(self) -> SnapListResponse:
17
- response: httpx.Response = await self._client.request(
18
- "GET", self.common_endpoint
19
- )
20
-
21
- response = SnapListResponse.model_validate_json(response.content)
22
- if response.status_code > 299:
23
- raise httpx.HTTPStatusError(
24
- request=response.request,
25
- response=response,
26
- message=f"Invalid status code in response: {response.status_code}",
27
- )
28
- return response
29
-
30
- async def install_snap(
31
- self,
32
- snap: str,
33
- channel: str = "stable",
34
- classic: bool = False,
35
- devmode: bool = False,
36
- ignore_validation: bool = False,
37
- jailmode: bool = False,
38
- revision: int = None,
39
- wait: bool = False,
40
- ) -> AsyncResponse | ChangesResponse:
41
- request_data = {
42
- "action": "install",
43
- "channel": channel,
44
- "classic": classic,
45
- "devmode": devmode,
46
- "ignore_validation": ignore_validation,
47
- "jailmode": jailmode,
48
- }
49
- if revision:
50
- request_data["revision"] = revision
51
- raw_response: httpx.Response = await self._client.request(
52
- "POST", f"{self.common_endpoint}/{snap}", json=request_data
53
- )
54
- response = AsyncResponse.model_validate_json(raw_response.content)
55
- if wait:
56
- changes_id = response.change
57
- while True:
58
- changes = await self._client.get_changes_by_id(changes_id)
59
- if changes.ready:
60
- break
61
- if changes.result.err:
62
- raise Exception(f"Error in snap install: {changes.result.err}")
63
- await asyncio.sleep(2.0)
64
- return changes
65
- return response
66
-
67
- async def remove_snap(
68
- self, snap: str, purge: bool, terminate: bool, wait: bool = False
69
- ) -> AsyncResponse | ChangesResponse:
70
- request_data = {
71
- "action": "remove",
72
- "purge": purge,
73
- "terminate": terminate,
74
- }
75
-
76
- raw_response: httpx.Response = await self._client.request(
77
- "POST", f"{self.common_endpoint}/{snap}", json=request_data
78
- )
79
- response = AsyncResponse.model_validate_json(raw_response.content)
80
-
81
- if wait:
82
- changes_id = response.change
83
- while True:
84
- changes = await self._client.get_changes_by_id(changes_id)
85
- if changes.ready:
86
- break
87
- if changes.result.err:
88
- raise Exception(f"Error in snap remove: {changes.result.err}")
89
- await asyncio.sleep(2.0)
90
- return changes
91
-
92
- return response
File without changes
File without changes