snap-python 0.0.5__tar.gz → 0.1.1__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 (26) hide show
  1. snap_python-0.1.1/PKG-INFO +66 -0
  2. snap_python-0.1.1/README.md +44 -0
  3. {snap_python-0.0.5 → snap_python-0.1.1}/pyproject.toml +11 -3
  4. {snap_python-0.0.5 → snap_python-0.1.1}/src/snap_python/client.py +65 -28
  5. snap_python-0.1.1/src/snap_python/components/snaps.py +333 -0
  6. {snap_python-0.0.5 → snap_python-0.1.1}/src/snap_python/components/store.py +142 -2
  7. {snap_python-0.0.5 → snap_python-0.1.1}/src/snap_python/schemas/store/search.py +26 -1
  8. {snap_python-0.0.5 → snap_python-0.1.1}/src/snap_python/utils.py +20 -2
  9. snap_python-0.0.5/PKG-INFO +0 -53
  10. snap_python-0.0.5/README.md +0 -32
  11. snap_python-0.0.5/src/snap_python/components/snaps.py +0 -184
  12. {snap_python-0.0.5 → snap_python-0.1.1}/LICENSE +0 -0
  13. {snap_python-0.0.5 → snap_python-0.1.1}/src/snap_python/__init__.py +0 -0
  14. {snap_python-0.0.5 → snap_python-0.1.1}/src/snap_python/components/__init__.py +0 -0
  15. {snap_python-0.0.5 → snap_python-0.1.1}/src/snap_python/components/config.py +0 -0
  16. {snap_python-0.0.5 → snap_python-0.1.1}/src/snap_python/schemas/changes.py +0 -0
  17. {snap_python-0.0.5 → snap_python-0.1.1}/src/snap_python/schemas/common.py +0 -0
  18. {snap_python-0.0.5 → snap_python-0.1.1}/src/snap_python/schemas/config.py +0 -0
  19. {snap_python-0.0.5 → snap_python-0.1.1}/src/snap_python/schemas/snaps.py +0 -0
  20. {snap_python-0.0.5 → snap_python-0.1.1}/src/snap_python/schemas/store/__init__.py +0 -0
  21. {snap_python-0.0.5 → snap_python-0.1.1}/src/snap_python/schemas/store/categories.py +0 -0
  22. {snap_python-0.0.5 → snap_python-0.1.1}/src/snap_python/schemas/store/info.py +0 -0
  23. {snap_python-0.0.5 → snap_python-0.1.1}/src/snap_python/schemas/store/json/category_by_name_schema.json +0 -0
  24. {snap_python-0.0.5 → snap_python-0.1.1}/src/snap_python/schemas/store/json/category_schema.json +0 -0
  25. {snap_python-0.0.5 → snap_python-0.1.1}/src/snap_python/schemas/store/json/snaps_find_schema.json +0 -0
  26. {snap_python-0.0.5 → snap_python-0.1.1}/src/snap_python/schemas/store/json/snaps_info_schema.json +0 -0
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.3
2
+ Name: snap-python
3
+ Version: 0.1.1
4
+ Summary: Interact with snapd and snapstore(s) from Python
5
+ License: MIT
6
+ Author: Alexander Lukens
7
+ Author-email: alexdlukens@gmail.com
8
+ Requires-Python: >=3.10,<4.0
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Requires-Dist: httpx (>=0.27.2,<0.28.0)
16
+ Requires-Dist: pydantic (>=2.9.2,<3.0.0)
17
+ Requires-Dist: retry (>=0.9.2,<0.10.0)
18
+ Project-URL: Documentation, https://alexdlukens.github.io/snap-python/
19
+ Project-URL: Repository, https://github.com/alexdlukens/snap-python
20
+ Description-Content-Type: text/markdown
21
+
22
+ # snap-python
23
+
24
+ snap-python provides an API client to interact with snapd
25
+
26
+ [Click here to read the documentation](https://alexdlukens.github.io/snap-python/)
27
+
28
+ ## Installation
29
+
30
+ To install snap-python, you can use pip:
31
+
32
+ ```
33
+ pip install snap-python
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ Here is a simple example of how to use snap-python:
39
+
40
+ ```python
41
+ # uses asyncio, must be run in an event loop
42
+ >>> import asyncio
43
+ >>> from snap_python.client import SnapClient
44
+ >>> sc = SnapClient()
45
+ >>> installed_snaps = asyncio.run(sc.snaps.list_installed_snaps())
46
+ >>> [snap.name for snap in installed_snaps.result]
47
+ [..., 'vlc', 'snapcraft', 'firefox', 'store-tui', 'gtk-common-themes', 'thunderbird', ...]
48
+ ```
49
+
50
+ Get details for installed snap `vlc`
51
+
52
+ ```python
53
+
54
+ # uses asyncio, must be run in an event loop
55
+ >>> import asyncio
56
+ >>> from snap_python.client import SnapClient
57
+ >>> sc = SnapClient()
58
+ >>> vlc_snap = asyncio.run(sc.snaps.get_snap_info("vlc"))
59
+ >>> vlc_snap.result.version
60
+ '3.0.20-1-g2617de71b6'
61
+ ```
62
+
63
+ ## License
64
+
65
+ This project is licensed under the MIT License. See the LICENSE file for more details.
66
+
@@ -0,0 +1,44 @@
1
+ # snap-python
2
+
3
+ snap-python provides an API client to interact with snapd
4
+
5
+ [Click here to read the documentation](https://alexdlukens.github.io/snap-python/)
6
+
7
+ ## Installation
8
+
9
+ To install snap-python, you can use pip:
10
+
11
+ ```
12
+ pip install snap-python
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ Here is a simple example of how to use snap-python:
18
+
19
+ ```python
20
+ # uses asyncio, must be run in an event loop
21
+ >>> import asyncio
22
+ >>> from snap_python.client import SnapClient
23
+ >>> sc = SnapClient()
24
+ >>> installed_snaps = asyncio.run(sc.snaps.list_installed_snaps())
25
+ >>> [snap.name for snap in installed_snaps.result]
26
+ [..., 'vlc', 'snapcraft', 'firefox', 'store-tui', 'gtk-common-themes', 'thunderbird', ...]
27
+ ```
28
+
29
+ Get details for installed snap `vlc`
30
+
31
+ ```python
32
+
33
+ # uses asyncio, must be run in an event loop
34
+ >>> import asyncio
35
+ >>> from snap_python.client import SnapClient
36
+ >>> sc = SnapClient()
37
+ >>> vlc_snap = asyncio.run(sc.snaps.get_snap_info("vlc"))
38
+ >>> vlc_snap.result.version
39
+ '3.0.20-1-g2617de71b6'
40
+ ```
41
+
42
+ ## License
43
+
44
+ This project is licensed under the MIT License. See the LICENSE file for more details.
@@ -1,11 +1,12 @@
1
1
  [tool.poetry]
2
2
  name = "snap-python"
3
- version = "0.0.5"
4
- description = "An api to interact with snapd and snap stores"
3
+ version = "0.1.1"
4
+ description = "Interact with snapd and snapstore(s) from Python"
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
+ repository = "https://github.com/alexdlukens/snap-python"
9
+ documentation = "https://alexdlukens.github.io/snap-python/"
9
10
 
10
11
  [tool.poetry.dependencies]
11
12
  python = "^3.10"
@@ -23,7 +24,14 @@ ipykernel = "^6.29.5"
23
24
  pytest-cov = "^6.0.0"
24
25
  sphinx = "^8.1.3"
25
26
  sphinx-rtd-theme = "^3.0.2"
27
+ toml = "^0.10.2"
26
28
 
27
29
  [build-system]
28
30
  requires = ["poetry-core"]
29
31
  build-backend = "poetry.core.masonry.api"
32
+
33
+ [tool.pytest.ini_options]
34
+ asyncio_mode = "auto"
35
+ asyncio_default_fixture_loop_scope = "function"
36
+ addopts = "--cov=snap_python --cov-report=html"
37
+ testpaths = "tests"
@@ -44,15 +44,15 @@ class SnapClient(AbstractSnapsClient):
44
44
  if store_headers is None:
45
45
  store_headers = {"Snap-Device-Series": "16", "X-Ubuntu-Series": "16"}
46
46
 
47
- snapd_headers = {}
47
+ self.snapd_headers = {}
48
48
  if prompt_for_authentication:
49
- snapd_headers = {"X-Allow-Interaction": "true"}
49
+ self.snapd_headers = {"X-Allow-Interaction": "true"}
50
50
 
51
51
  self.version = version
52
52
  self.store_base_url = store_base_url
53
53
  self.store_headers = store_headers
54
54
  self.snapd_client = httpx.AsyncClient(
55
- transport=self._transport, headers=snapd_headers
55
+ transport=self._transport, headers=self.snapd_headers
56
56
  )
57
57
  self.snaps = SnapsEndpoints(self)
58
58
  self.store = StoreEndpoints(
@@ -61,19 +61,23 @@ class SnapClient(AbstractSnapsClient):
61
61
  headers=self.store_headers,
62
62
  )
63
63
  self.config = ConfigEndpoints(self)
64
+ self.snapd_client = httpx.AsyncClient(transport=self._transport)
64
65
 
65
66
  @retry(httpx.HTTPError, tries=3, delay=1, backoff=1)
66
67
  async def request(self, method: str, endpoint: str, **kwargs) -> httpx.Response:
67
- """
68
+ r"""
68
69
  Sends an HTTP request to the specified endpoint using the given method and parameters.
69
- Args:
70
- method (str): The HTTP method to use for the request (e.g., 'GET', 'POST').
71
- endpoint (str): The API endpoint to send the request to.
72
- **kwargs: Additional keyword arguments to pass to the request.
73
- Returns:
74
- httpx.Response: The response object from the HTTP request.
75
- Raises:
76
- httpx.HTTPStatusError: If the response contains an HTTP status code indicating an error.
70
+
71
+ :param method: The HTTP method to use for the request (e.g., 'GET', 'POST').
72
+ :type method: str
73
+ :param endpoint: The API endpoint to send the request to.
74
+ :type endpoint: str
75
+ :param `**kwargs`: Additional keyword arguments to pass to the request.
76
+
77
+ :returns: The response object from the HTTP request.
78
+ :rtype: httpx.Response
79
+
80
+ :raises httpx.HTTPStatusError: If the response contains an HTTP status code indicating an error.
77
81
  """
78
82
 
79
83
  response = await self.snapd_client.request(
@@ -84,16 +88,19 @@ class SnapClient(AbstractSnapsClient):
84
88
 
85
89
  @retry(httpx.HTTPError, tries=3, delay=1, backoff=1)
86
90
  async def request_raw(self, method: str, endpoint: str, **kwargs) -> httpx.Response:
87
- """
91
+ r"""
88
92
  Sends an HTTP request using the specified method and endpoint.
89
- Args:
90
- method (str): The HTTP method to use for the request (e.g., 'GET', 'POST').
91
- endpoint (str): The endpoint URL to send the request to.
92
- **kwargs: Additional keyword arguments to pass to the request.
93
- Returns:
94
- httpx.Response: The response object resulting from the HTTP request.
95
- Raises:
96
- httpx.HTTPStatusError: If the response contains an HTTP status code indicating an error.
93
+
94
+ :param method: The HTTP method to use for the request (e.g., 'GET', 'POST').
95
+ :type method: str
96
+ :param endpoint: The endpoint URL to send the request to.
97
+ :type endpoint: str
98
+ :param `**kwargs`: Additional keyword arguments to pass to the request.
99
+
100
+ :returns: The response object resulting from the HTTP request.
101
+ :rtype: httpx.Response
102
+
103
+ :raises httpx.HTTPStatusError: If the response contains an HTTP status code indicating an error.
97
104
  """
98
105
 
99
106
  response = await self.snapd_client.request(method, endpoint, **kwargs)
@@ -102,39 +109,69 @@ class SnapClient(AbstractSnapsClient):
102
109
  return response
103
110
 
104
111
  async def ping(self) -> httpx.Response:
105
- """Reserved for human-readable content describing the service.
112
+ """
113
+ Reserved for human-readable content describing the service.
106
114
 
107
- Returns:
108
- httpx.Response: _description_
115
+ :returns: The response from the ping request.
116
+ :rtype: httpx.Response
109
117
  """
110
118
  response = await self.snapd_client.get(f"{self._base_url}/")
111
119
 
112
120
  return response
113
121
 
122
+ @retry(httpx.HTTPError, tries=3, delay=0.5)
114
123
  async def get_changes_by_id(self, change_id: str) -> ChangesResponse:
124
+ """
125
+ Asynchronously retrieves changes by their ID.
126
+
127
+ :param change_id: The ID of the change to retrieve.
128
+ :type change_id: str
129
+
130
+ :returns: The response object containing the changes.
131
+ :rtype: ChangesResponse
132
+
133
+ :raises httpx.HTTPStatusError: If the request results in an HTTP error.
134
+ """
135
+
115
136
  try:
116
137
  response = await self.request("GET", f"changes/{change_id}")
117
138
  except httpx.HTTPStatusError as e:
118
139
  logger.warning(
119
- f"Bad response status code for get_changes_by_id: {e.response.status_code}"
140
+ "Bad response status code for get_changes_by_id: %s",
141
+ e.response.status_code,
120
142
  )
121
143
  response = e.response
122
- pass
123
144
 
124
145
  return ChangesResponse.model_validate_json(response.content)
125
146
 
126
147
  async def get_changes_by_id_generator(
127
148
  self, change_id: str
128
149
  ) -> AsyncGenerator[ChangesResponse, None]:
150
+ """
151
+ Asynchronous generator to fetch changes by change ID.
152
+
153
+ This generator continuously requests changes from the server using the provided
154
+ change ID and yields the response as a ChangesResponse object. It includes error
155
+ handling for HTTP status errors and adds a small delay between requests to avoid
156
+ overwhelming the server.
157
+
158
+ :param change_id: The ID of the change to fetch.
159
+ :type change_id: str
160
+
161
+ :yields: The response object containing the changes.
162
+ :rtype: ChangesResponse
163
+
164
+ :raises httpx.HTTPStatusError: If the request fails with an HTTP status error.
165
+ """
129
166
  while True:
130
167
  try:
131
168
  response = await self.request("GET", f"changes/{change_id}")
132
169
  except httpx.HTTPStatusError as e:
133
170
  logger.warning(
134
- f"Bad response status code for get_changes_by_id: {e.response.status_code}"
171
+ "Bad response status code for get_changes_by_id: %s",
172
+ e.response.status_code,
135
173
  )
136
174
  response = e.response
137
- pass
138
175
 
139
176
  change_response = ChangesResponse.model_validate_json(response.content)
140
177
  yield change_response
@@ -0,0 +1,333 @@
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 (
10
+ InstalledSnapListResponse,
11
+ SingleInstalledSnapResponse,
12
+ )
13
+ from snap_python.utils import AbstractSnapsClient, SnapdAPIError, going_to_reload_daemon
14
+
15
+ logger = logging.getLogger("snap_python.components.snaps")
16
+
17
+
18
+ class SnapsEndpoints:
19
+ def __init__(self, client: AbstractSnapsClient) -> None:
20
+ self._client = client
21
+ self.common_endpoint = "snaps"
22
+
23
+ async def list_installed_snaps(self) -> InstalledSnapListResponse:
24
+ """
25
+ Asynchronously retrieves a list of installed snaps.
26
+
27
+ :returns: The response containing the list of installed snaps.
28
+ :rtype: InstalledSnapListResponse
29
+
30
+ :raises httpx.HTTPStatusError: If the response status code does not indicate success.
31
+ """
32
+
33
+ response: httpx.Response = await self._client.request(
34
+ "GET", self.common_endpoint
35
+ )
36
+
37
+ response = InstalledSnapListResponse.model_validate_json(response.content)
38
+ if response.status_code > 299:
39
+ raise httpx.HTTPStatusError(
40
+ request=response.request,
41
+ response=response,
42
+ message=f"Invalid status code in response: {response.status_code}",
43
+ )
44
+ return response
45
+
46
+ async def get_snap_info(self, snap: str) -> SingleInstalledSnapResponse:
47
+ """
48
+ Retrieves information about a specific snap.
49
+
50
+ :param snap: The name of the snap to retrieve information for.
51
+ :type snap: str
52
+
53
+ :returns: The response containing information about the snap.
54
+ :rtype: SingleInstalledSnapResponse
55
+
56
+ :raises httpx.HTTPStatusError: If the response status code does not indicate success.
57
+ """
58
+ try:
59
+ response: httpx.Response = await self._client.request(
60
+ "GET", f"{self.common_endpoint}/{snap}"
61
+ )
62
+ except httpx.HTTPStatusError as e:
63
+ logger.debug(
64
+ "Bad status code from get_snap_info on snap %s: %s",
65
+ snap,
66
+ e.response.status_code,
67
+ )
68
+ response = e.response
69
+
70
+ return SingleInstalledSnapResponse.model_validate_json(response.content)
71
+
72
+ async def is_snap_installed(self, snap: str) -> bool:
73
+ """
74
+ Check if a snap package is installed.
75
+
76
+ :param snap: The name of the snap package to check.
77
+ :type snap: str
78
+
79
+ :returns: True if the snap package is installed, False otherwise.
80
+ :rtype: bool
81
+ """
82
+
83
+ snap_info = await self.get_snap_info(snap)
84
+ if snap_info.status == "OK":
85
+ return True
86
+ return False
87
+
88
+ async def install_snap(
89
+ self,
90
+ snap: str,
91
+ channel: str = "stable",
92
+ classic: bool = False,
93
+ dangerous: bool = False,
94
+ devmode: bool = False,
95
+ jailmode: bool = False,
96
+ revision: int = None,
97
+ filename: str = None,
98
+ wait: bool = False,
99
+ ) -> AsyncResponse | ChangesResponse:
100
+ """
101
+ Install or sideload a snap.
102
+
103
+ To sideload a snap, provide the filename parameter with the path to the snap file.
104
+
105
+ :param snap: Name of the snap to install.
106
+ :type snap: str
107
+ :param channel: Channel to install, defaults to "stable".
108
+ :type channel: str, optional
109
+ :param classic: Install with classic confinement, defaults to False.
110
+ :type classic: bool, optional
111
+ :param dangerous: Install the given snap files even if there are no pre-acknowledged signatures for them, meaning they are not verified and could be dangerous if true (optional, implied by devmode), defaults to False.
112
+ :type dangerous: bool, optional
113
+ :param devmode: Install with devmode, defaults to False.
114
+ :type devmode: bool, optional
115
+ :param jailmode: Install snap with jailmode, defaults to False.
116
+ :type jailmode: bool, optional
117
+ :param revision: Install a specific revision of the snap, defaults to None.
118
+ :type revision: int, optional
119
+ :param filename: Path to snap to sideload, defaults to None.
120
+ :type filename: str, optional
121
+ :param wait: Whether to wait for snap to install. If not waiting, will return async response with change id, defaults to False.
122
+ :type wait: bool, optional
123
+
124
+ :raises FileNotFoundError: If the specified snap file does not exist.
125
+ :raises ValueError: If attempting to sideload without the dangerous flag set to True.
126
+ :raises SnapdAPIError: If there is an error during the snap install.
127
+
128
+ :returns: If wait is True, will return ChangesResponse. Otherwise, will return AsyncResponse.
129
+ :rtype: AsyncResponse | ChangesResponse
130
+ """
131
+ request_data = {
132
+ "action": "install",
133
+ "channel": channel,
134
+ "classic": classic,
135
+ "dangerous": dangerous,
136
+ "devmode": devmode,
137
+ "jailmode": jailmode,
138
+ }
139
+ if revision:
140
+ request_data["revision"] = revision
141
+ if filename:
142
+ # sideload
143
+ if not Path(filename).exists():
144
+ raise FileNotFoundError(f"File {filename} does not exist")
145
+ if request_data.get("dangerous") is not True:
146
+ raise ValueError(
147
+ "Cannot sideload snap without dangerous flag set to True"
148
+ )
149
+ raw_response: httpx.Response = await self._client.request(
150
+ "POST",
151
+ f"{self.common_endpoint}",
152
+ data=request_data,
153
+ files={"snap": open(filename, "rb")},
154
+ )
155
+ else:
156
+ # install from default snap store
157
+ raw_response: httpx.Response = await self._client.request(
158
+ "POST", f"{self.common_endpoint}/{snap}", json=request_data
159
+ )
160
+ response = AsyncResponse.model_validate_json(raw_response.content)
161
+ if wait:
162
+ changes_id = response.change
163
+ previous_changes = None
164
+ while True:
165
+ try:
166
+ changes = await self._client.get_changes_by_id(changes_id)
167
+ logger.debug("Progress: %s", changes.result.overall_progress)
168
+ except httpx.HTTPError:
169
+ if going_to_reload_daemon(previous_changes):
170
+ logger.debug("Waiting for daemon to reload")
171
+ changes = previous_changes
172
+
173
+ if changes.ready:
174
+ break
175
+ if changes.result.err:
176
+ raise SnapdAPIError(f"Error in snap install: {changes.result.err}")
177
+ await asyncio.sleep(0.1)
178
+ previous_changes = changes
179
+ return changes
180
+ return response
181
+
182
+ async def remove_snap(
183
+ self,
184
+ snap: str,
185
+ purge: bool = False,
186
+ terminate: bool = False,
187
+ wait: bool = False,
188
+ ) -> AsyncResponse | ChangesResponse:
189
+ """
190
+ Asynchronously removes a snap package.
191
+
192
+ :param snap: The name of the snap package to remove.
193
+ :type snap: str
194
+ :param purge: If True, purges the snap package. Defaults to False.
195
+ :type purge: bool, optional
196
+ :param terminate: If True, terminates the snap package. Defaults to False.
197
+ :type terminate: bool, optional
198
+ :param wait: If True, waits for the removal process to complete. Defaults to False.
199
+ :type wait: bool, optional
200
+
201
+ :returns: The response from the snapd API, either an asynchronous response or a changes response if waiting for completion.
202
+ :rtype: AsyncResponse | ChangesResponse
203
+
204
+ :raises SnapdAPIError: If there is an error in the snap removal process.
205
+ """
206
+ request_data = {
207
+ "action": "remove",
208
+ "purge": purge,
209
+ "terminate": terminate,
210
+ }
211
+
212
+ raw_response: httpx.Response = await self._client.request(
213
+ "POST", f"{self.common_endpoint}/{snap}", json=request_data
214
+ )
215
+ response = AsyncResponse.model_validate_json(raw_response.content)
216
+
217
+ if wait:
218
+ changes_id = response.change
219
+ previous_changes = None
220
+ while True:
221
+ try:
222
+ changes = await self._client.get_changes_by_id(changes_id)
223
+ except httpx.HTTPError:
224
+ if going_to_reload_daemon(previous_changes):
225
+ logger.debug("Waiting for daemon to reload")
226
+ await asyncio.sleep(0.1)
227
+ continue
228
+ if changes.ready:
229
+ break
230
+ if changes.result.err:
231
+ raise SnapdAPIError(f"Error in snap remove: {changes.result.err}")
232
+ await asyncio.sleep(0.1)
233
+ previous_changes = changes
234
+ return changes
235
+
236
+ return response
237
+
238
+ async def refresh_snap(
239
+ self,
240
+ snap: str,
241
+ channel: str = "stable",
242
+ classic: bool = False,
243
+ dangerous: bool = False,
244
+ devmode: bool = False,
245
+ ignore_validation: bool = False,
246
+ jailmode: bool = False,
247
+ revision: int = None,
248
+ filename: str = None,
249
+ wait: bool = False,
250
+ ) -> AsyncResponse | ChangesResponse:
251
+ """
252
+ Refreshes a snap package.
253
+
254
+ :param snap: The name of the snap package to refresh.
255
+ :type snap: str
256
+ :param channel: The channel to refresh the snap from. Defaults to "stable".
257
+ :type channel: str, optional
258
+ :param classic: Whether to use classic confinement. Defaults to False.
259
+ :type classic: bool, optional
260
+ :param dangerous: Whether to allow installation of unasserted snaps. Defaults to False.
261
+ :type dangerous: bool, optional
262
+ :param devmode: Whether to use development mode. Defaults to False.
263
+ :type devmode: bool, optional
264
+ :param ignore_validation: Whether to ignore validation. Defaults to False.
265
+ :type ignore_validation: bool, optional
266
+ :param jailmode: Whether to use jail mode. Defaults to False.
267
+ :type jailmode: bool, optional
268
+ :param revision: The specific revision to refresh to. Defaults to None.
269
+ :type revision: int, optional
270
+ :param filename: The path to the snap file for sideloading. Defaults to None.
271
+ :type filename: str, optional
272
+ :param wait: Whether to wait for the refresh operation to complete. Defaults to False.
273
+ :type wait: bool, optional
274
+
275
+ :returns: The response from the refresh operation.
276
+ :rtype: AsyncResponse | ChangesResponse
277
+
278
+ :raises FileNotFoundError: If the specified snap file does not exist.
279
+ :raises ValueError: If attempting to sideload without the dangerous flag set to True.
280
+ :raises SnapdAPIError: If there is an error during the snap refresh.
281
+ """
282
+ request_data = {
283
+ "action": "refresh",
284
+ "channel": channel,
285
+ "classic": classic,
286
+ "dangerous": dangerous,
287
+ "devmode": devmode,
288
+ "ignore_validation": ignore_validation,
289
+ "jailmode": jailmode,
290
+ }
291
+ if revision:
292
+ request_data["revision"] = revision
293
+ if filename:
294
+ # sideload
295
+ if not Path(filename).exists():
296
+ raise FileNotFoundError(f"File {filename} does not exist")
297
+ if request_data.get("dangerous") is not True:
298
+ raise ValueError(
299
+ "Cannot sideload snap without dangerous flag set to True"
300
+ )
301
+ raw_response: httpx.Response = await self._client.request(
302
+ "POST",
303
+ f"{self.common_endpoint}",
304
+ data=request_data,
305
+ files={"snap": open(filename, "rb")},
306
+ )
307
+ else:
308
+ # install from default snap store
309
+ raw_response: httpx.Response = await self._client.request(
310
+ "POST", f"{self.common_endpoint}/{snap}", json=request_data
311
+ )
312
+ response = AsyncResponse.model_validate_json(raw_response.content)
313
+ if wait:
314
+ changes_id = response.change
315
+ previous_changes = None
316
+ while True:
317
+ try:
318
+ changes = await self._client.get_changes_by_id(changes_id)
319
+ logger.debug("Progress: %s", changes.result.overall_progress)
320
+ except httpx.HTTPError:
321
+ if going_to_reload_daemon(previous_changes):
322
+ logger.debug("Waiting for daemon to reload")
323
+ await asyncio.sleep(0.1)
324
+ continue
325
+
326
+ if changes.ready:
327
+ break
328
+ if changes.result.err:
329
+ raise SnapdAPIError(f"Error in snap remove: {changes.result.err}")
330
+ await asyncio.sleep(0.1)
331
+ previous_changes = changes
332
+ return changes
333
+ return response
@@ -11,6 +11,7 @@ from snap_python.schemas.store.categories import (
11
11
  from snap_python.schemas.store.info import VALID_SNAP_INFO_FIELDS, InfoResponse
12
12
  from snap_python.schemas.store.search import (
13
13
  VALID_SEARCH_CATEGORY_FIELDS,
14
+ ArchSearchResponse,
14
15
  SearchResponse,
15
16
  )
16
17
 
@@ -36,6 +37,19 @@ class StoreEndpoints:
36
37
  self.store_client.headers.update(headers)
37
38
 
38
39
  async def get_snap_details(self, snap_name: str, fields: list[str] | None = None):
40
+ """
41
+ Get details of a snap.
42
+
43
+ :param snap_name: The name of the snap.
44
+ :type snap_name: str
45
+ :param fields: The fields to include in the response.
46
+ :type fields: list[str], optional
47
+
48
+ :returns: The snap details.
49
+ :rtype: dict
50
+
51
+ :raises ValueError: If invalid fields are provided.
52
+ """
39
53
  query = {}
40
54
  if fields is not None:
41
55
  if not all(field in VALID_SEARCH_CATEGORY_FIELDS for field in fields):
@@ -51,6 +65,19 @@ class StoreEndpoints:
51
65
  return response.json()
52
66
 
53
67
  async def get_snap_info(self, snap_name: str, fields: list[str] | None = None):
68
+ """
69
+ Get information about a snap.
70
+
71
+ :param snap_name: The name of the snap.
72
+ :type snap_name: str
73
+ :param fields: The fields to include in the response.
74
+ :type fields: list[str], optional
75
+
76
+ :returns: The snap information.
77
+ :rtype: InfoResponse
78
+
79
+ :raises ValueError: If invalid fields are provided.
80
+ """
54
81
  query = {}
55
82
  if fields is not None:
56
83
  if not all(field in VALID_SNAP_INFO_FIELDS for field in fields):
@@ -68,6 +95,19 @@ class StoreEndpoints:
68
95
  async def get_categories(
69
96
  self, type: str | None = None, fields: list[str] | None = None
70
97
  ) -> CategoryResponse:
98
+ """
99
+ Get categories of snaps.
100
+
101
+ :param type: The type of categories.
102
+ :type type: str, optional
103
+ :param fields: The fields to include in the response.
104
+ :type fields: list[str], optional
105
+
106
+ :returns: The categories.
107
+ :rtype: CategoryResponse
108
+
109
+ :raises ValueError: If invalid fields are provided.
110
+ """
71
111
  query = {}
72
112
  if fields is not None:
73
113
  if not all(field in VALID_CATEGORY_FIELDS for field in fields):
@@ -85,6 +125,19 @@ class StoreEndpoints:
85
125
  async def get_category_by_name(
86
126
  self, name: str, fields: list[str] | None = None
87
127
  ) -> SingleCategoryResponse:
128
+ """
129
+ Get a category by name.
130
+
131
+ :param name: The name of the category.
132
+ :type name: str
133
+ :param fields: The fields to include in the response.
134
+ :type fields: list[str], optional
135
+
136
+ :returns: The category.
137
+ :rtype: SingleCategoryResponse
138
+
139
+ :raises ValueError: If invalid fields are provided.
140
+ """
88
141
  query = {}
89
142
  if fields is not None:
90
143
  if not all(field in VALID_CATEGORY_FIELDS for field in fields):
@@ -113,10 +166,39 @@ class StoreEndpoints:
113
166
  publisher: str | None = None,
114
167
  headers: dict[str, str] | None = None,
115
168
  ) -> SearchResponse:
116
- """from https://api.snapcraft.io/docs/search.html#snaps_find
117
- and https://snapcraft.io/docs/snapd-api#heading--find
118
169
  """
170
+ Search for snaps in the store.
171
+
172
+ :param query: The search query.
173
+ :type query: str, optional
174
+ :param fields: The fields to include in the response.
175
+ :type fields: str, optional
176
+ :param name_startswith: Filter snaps by name prefix.
177
+ :type name_startswith: str, optional
178
+ :param architecture: Filter snaps by architecture.
179
+ :type architecture: str, optional
180
+ :param common_id: Filter snaps by common ID.
181
+ :type common_id: str, optional
182
+ :param category: Filter snaps by category.
183
+ :type category: str, optional
184
+ :param channel: Filter snaps by channel.
185
+ :type channel: str, optional
186
+ :param confiement: Filter snaps by confinement.
187
+ :type confiement: str, optional
188
+ :param featured: Filter snaps by featured status.
189
+ :type featured: bool, optional
190
+ :param private: Filter snaps by private status.
191
+ :type private: bool, optional
192
+ :param publisher: Filter snaps by publisher.
193
+ :type publisher: str, optional
194
+ :param headers: Additional headers to include in the request.
195
+ :type headers: dict[str, str], optional
196
+
197
+ :returns: The search response.
198
+ :rtype: SearchResponse
119
199
 
200
+ :raises ValueError: If invalid fields are provided.
201
+ """
120
202
  route = "/snaps/find"
121
203
  query_dict: dict = {
122
204
  "q": query,
@@ -159,9 +241,67 @@ class StoreEndpoints:
159
241
 
160
242
  @retry.retry(Exception, tries=3, delay=2, backoff=2)
161
243
  async def retry_get_snap_info(self, snap_name: str, fields: list[str]):
244
+ """
245
+ Retry getting snap information.
246
+
247
+ :param snap_name: The name of the snap.
248
+ :type snap_name: str
249
+ :param fields: The fields to include in the response.
250
+ :type fields: list[str]
251
+
252
+ :returns: The snap information.
253
+ :rtype: InfoResponse
254
+ """
162
255
  return await self.get_snap_info(snap_name=snap_name, fields=fields)
163
256
 
164
257
  async def get_top_snaps_from_category(self, category: str) -> SearchResponse:
258
+ """
259
+ Get top snaps from a category.
260
+
261
+ :param category: The category name.
262
+ :type category: str
263
+
264
+ :returns: The search response.
265
+ :rtype: SearchResponse
266
+ """
165
267
  return await self.find(
166
268
  category=category, fields=["title", "store-url", "summary"]
167
269
  )
270
+
271
+ async def get_all_snaps_for_arch(self, arch: str) -> ArchSearchResponse:
272
+ """
273
+ Get all snaps for a given architecture.
274
+
275
+ :param arch: The architecture.
276
+ :type arch: str
277
+
278
+ :returns: The search response.
279
+ :rtype: ArchSearchResponse
280
+
281
+ :raises ValueError: If invalid architecture is provided.
282
+ """
283
+ # use the old "/api/v1/snaps/names" to get all snaps for a given architecture
284
+
285
+ # ensure valid arch
286
+ if arch not in [
287
+ "amd64",
288
+ "arm64",
289
+ "armhf",
290
+ "i386",
291
+ "ppc64el",
292
+ "s390x",
293
+ "riscv64",
294
+ ]:
295
+ raise ValueError(f"Invalid architecture: {arch}")
296
+
297
+ route = "/api/v1/snaps/names"
298
+ extra_headers = {"X-Ubuntu-Architecture": arch}
299
+
300
+ response = await self.store_client.get(
301
+ f"{self._raw_base_url}{route}", headers=extra_headers, timeout=60
302
+ )
303
+ response.raise_for_status()
304
+
305
+ response_json = response.json()
306
+ response_json["arch"] = arch
307
+ return ArchSearchResponse.model_validate(response_json)
@@ -1,6 +1,13 @@
1
1
  from typing import Any, Dict, List, Optional
2
2
 
3
- from pydantic import AliasChoices, AwareDatetime, BaseModel, ConfigDict, Field
3
+ from pydantic import (
4
+ AliasChoices,
5
+ AliasPath,
6
+ AwareDatetime,
7
+ BaseModel,
8
+ ConfigDict,
9
+ Field,
10
+ )
4
11
 
5
12
  from snap_python.schemas.common import Revision
6
13
  from snap_python.schemas.snaps import InstalledSnap, Snap, StoreSnap, StoreSnapFields
@@ -120,3 +127,21 @@ class SearchResponse(BaseModel):
120
127
 
121
128
  error_list: Optional[List[ErrorListItem]] = Field(None, alias="error-list")
122
129
  results: List[SearchResult]
130
+
131
+
132
+ class ArchSearchItem(BaseModel):
133
+ model_config = ConfigDict(extra="ignore")
134
+
135
+ aliases: Optional[list[dict[str, str]]]
136
+ apps: list[str]
137
+ package_name: str
138
+ summary: str
139
+ title: str
140
+ version: str
141
+
142
+
143
+ class ArchSearchResponse(BaseModel):
144
+ results: list[ArchSearchItem] = Field(
145
+ alias=AliasPath("_embedded", "clickindex:package")
146
+ )
147
+ arch: str
@@ -5,13 +5,21 @@ import httpx
5
5
  from snap_python.schemas.changes import ChangesResponse
6
6
 
7
7
 
8
+ class SnapdAPIError(Exception):
9
+ """
10
+ General Exception raised for errors in the Snapd API.
11
+ """
12
+
13
+ pass
14
+
15
+
8
16
  class AbstractSnapsClient(ABC): # pragma: no cover
9
17
  @abstractmethod
10
- async def request(self) -> httpx.Response:
18
+ async def request(self, method: str, endpoint: str, **kwargs) -> httpx.Response:
11
19
  pass
12
20
 
13
21
  @abstractmethod
14
- async def request_raw(self) -> httpx.Response:
22
+ async def request_raw(self, method: str, endpoint: str, **kwargs) -> httpx.Response:
15
23
  pass
16
24
 
17
25
  @abstractmethod
@@ -20,6 +28,16 @@ class AbstractSnapsClient(ABC): # pragma: no cover
20
28
 
21
29
 
22
30
  def going_to_reload_daemon(changes: ChangesResponse | None) -> bool:
31
+ """
32
+ Determines if a daemon reload is required based on the provided changes.
33
+
34
+ :param changes: An instance of ChangesResponse or None.
35
+ :type changes: ChangesResponse | None
36
+
37
+ :returns: True if a daemon reload or restart is required, False otherwise.
38
+ :rtype: bool
39
+ """
40
+
23
41
  if changes is None or not isinstance(changes, ChangesResponse):
24
42
  return False
25
43
 
@@ -1,53 +0,0 @@
1
- Metadata-Version: 2.1
2
- Name: snap-python
3
- Version: 0.0.5
4
- Summary: An api to interact with snapd and snap stores
5
- Home-page: https://github.com/alexlukens/snap-python
6
- License: MIT
7
- Author: Alexander Lukens
8
- Author-email: alexdlukens@gmail.com
9
- Requires-Python: >=3.10,<4.0
10
- Classifier: License :: OSI Approved :: MIT License
11
- Classifier: Programming Language :: Python :: 3
12
- Classifier: Programming Language :: Python :: 3.10
13
- Classifier: Programming Language :: Python :: 3.11
14
- Classifier: Programming Language :: Python :: 3.12
15
- Classifier: Programming Language :: Python :: 3.13
16
- Requires-Dist: httpx (>=0.27.2,<0.28.0)
17
- Requires-Dist: pydantic (>=2.9.2,<3.0.0)
18
- Requires-Dist: retry (>=0.9.2,<0.10.0)
19
- Project-URL: Repository, https://github.com/alexlukens/snap-python
20
- Description-Content-Type: text/markdown
21
-
22
- # snap_python
23
-
24
- snap_python provides an API client to interact with snapd
25
-
26
- ## Installation
27
-
28
- To install snap_python, you can use pip:
29
-
30
- ```
31
- pip install snap_python
32
- ```
33
-
34
- ## Usage
35
-
36
- Here is a simple example of how to use snap_python:
37
-
38
- ```python
39
- # TODO
40
- ```
41
-
42
- ## Create a Release
43
-
44
- ```bash
45
- git tag -a v0.x.x -m "Release v0.x.x"
46
- gh release create v0.x.x --title "Release v0.x.x"
47
- ```
48
-
49
- The automation will build a wheel and push it to pypi
50
-
51
- ## License
52
-
53
- This project is licensed under the MIT License. See the LICENSE file for more details.
@@ -1,32 +0,0 @@
1
- # snap_python
2
-
3
- snap_python provides an API client to interact with snapd
4
-
5
- ## Installation
6
-
7
- To install snap_python, you can use pip:
8
-
9
- ```
10
- pip install snap_python
11
- ```
12
-
13
- ## Usage
14
-
15
- Here is a simple example of how to use snap_python:
16
-
17
- ```python
18
- # TODO
19
- ```
20
-
21
- ## Create a Release
22
-
23
- ```bash
24
- git tag -a v0.x.x -m "Release v0.x.x"
25
- gh release create v0.x.x --title "Release v0.x.x"
26
- ```
27
-
28
- The automation will build a wheel and push it to pypi
29
-
30
- ## License
31
-
32
- This project is licensed under the MIT License. See the LICENSE file for more details.
@@ -1,184 +0,0 @@
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 (
10
- InstalledSnapListResponse,
11
- SingleInstalledSnapResponse,
12
- )
13
- from snap_python.utils import AbstractSnapsClient, going_to_reload_daemon
14
-
15
- logger = logging.getLogger("snap_python.components.snaps")
16
-
17
-
18
- class SnapsEndpoints:
19
- def __init__(self, client: AbstractSnapsClient) -> None:
20
- self._client = client
21
- self.common_endpoint = "snaps"
22
-
23
- async def list_installed_snaps(self) -> InstalledSnapListResponse:
24
- response: httpx.Response = await self._client.request(
25
- "GET", self.common_endpoint
26
- )
27
-
28
- response = InstalledSnapListResponse.model_validate_json(response.content)
29
- if response.status_code > 299:
30
- raise httpx.HTTPStatusError(
31
- request=response.request,
32
- response=response,
33
- message=f"Invalid status code in response: {response.status_code}",
34
- )
35
- return response
36
-
37
- async def get_snap_info(self, snap: str) -> SingleInstalledSnapResponse:
38
- try:
39
- response: httpx.Response = await self._client.request(
40
- "GET", f"{self.common_endpoint}/{snap}"
41
- )
42
- except httpx.HTTPStatusError as e:
43
- logger.debug(
44
- "Bad status code from get_snap_info on snap %s: %s",
45
- snap,
46
- e.response.status_code,
47
- )
48
- response = e.response
49
-
50
- return SingleInstalledSnapResponse.model_validate_json(response.content)
51
-
52
- async def is_snap_installed(self, snap: str) -> bool:
53
- snap_info = await self.get_snap_info(snap)
54
- if snap_info.status == "OK":
55
- return True
56
- return False
57
-
58
- async def install_snap(
59
- self,
60
- snap: str,
61
- channel: str = "stable",
62
- classic: bool = False,
63
- dangerous: bool = False,
64
- devmode: bool = False,
65
- ignore_validation: bool = False,
66
- jailmode: bool = False,
67
- revision: int = None,
68
- filename: str = None,
69
- wait: bool = False,
70
- ) -> AsyncResponse | ChangesResponse:
71
- """Install or sideload a snap
72
-
73
- To sideload a snap, provide the filename parameter with the path to the snap file
74
-
75
-
76
- Args:
77
- snap (str): name of the snap to install
78
- channel (str, optional): Channel to install. Defaults to "stable".
79
- classic (bool, optional): Install with classic confinement. Defaults to False.
80
- dangerous (bool, optional): install the given snap files even if there are no pre-acknowledged signatures for them, meaning they are not verified and could be dangerous if true (optional, implied by devmode). Defaults to False.
81
- devmode (bool, optional): Install with devmode. Defaults to False.
82
- ignore_validation (bool, optional): _description_. Defaults to False.
83
- jailmode (bool, optional): Install snap with jailmode. Defaults to False.
84
- revision (int, optional): install a specific revision of the snap. Defaults to None.
85
- filename (str, optional): Path to snap to sideload. Defaults to None.
86
- wait (bool, optional): Whether to wait for snap to install. If not waiting, will return async response with change id. Defaults to False.
87
-
88
- Raises:
89
- Exception: If error occurs during snap install
90
-
91
- Returns:
92
- AsyncResponse | ChangesResponse: If wait is True, will return ChangesResponse. Otherwise, will return AsyncResponse
93
- """
94
- request_data = {
95
- "action": "install",
96
- "channel": channel,
97
- "classic": classic,
98
- "dangerous": dangerous,
99
- "devmode": devmode,
100
- "ignore_validation": ignore_validation,
101
- "jailmode": jailmode,
102
- }
103
- if revision:
104
- request_data["revision"] = revision
105
- if filename:
106
- # sideload
107
- if not Path(filename).exists():
108
- raise FileNotFoundError(f"File {filename} does not exist")
109
- if request_data.get("dangerous") is not True:
110
- raise ValueError(
111
- "Cannot sideload snap without dangerous flag set to True"
112
- )
113
- raw_response: httpx.Response = await self._client.request(
114
- "POST",
115
- f"{self.common_endpoint}",
116
- data=request_data,
117
- files={"snap": open(filename, "rb")},
118
- )
119
- else:
120
- # install from default snap store
121
- raw_response: httpx.Response = await self._client.request(
122
- "POST", f"{self.common_endpoint}/{snap}", json=request_data
123
- )
124
- response = AsyncResponse.model_validate_json(raw_response.content)
125
- if wait:
126
- changes_id = response.change
127
- previous_changes = None
128
- while True:
129
- try:
130
- changes = await self._client.get_changes_by_id(changes_id)
131
- logger.debug(f"Progress: {changes.result.overall_progress}")
132
- except httpx.HTTPError as e:
133
- if going_to_reload_daemon(previous_changes):
134
- logger.debug("Waiting for daemon to reload")
135
- await asyncio.sleep(0.1)
136
- continue
137
-
138
- if changes.ready:
139
- break
140
- if changes.result.err:
141
- raise Exception(f"Error in snap install: {changes.result.err}")
142
- await asyncio.sleep(0.1)
143
- previous_changes = changes
144
- return changes
145
- return response
146
-
147
- async def remove_snap(
148
- self,
149
- snap: str,
150
- purge: bool = False,
151
- terminate: bool = False,
152
- wait: bool = False,
153
- ) -> AsyncResponse | ChangesResponse:
154
- request_data = {
155
- "action": "remove",
156
- "purge": purge,
157
- "terminate": terminate,
158
- }
159
-
160
- raw_response: httpx.Response = await self._client.request(
161
- "POST", f"{self.common_endpoint}/{snap}", json=request_data
162
- )
163
- response = AsyncResponse.model_validate_json(raw_response.content)
164
-
165
- if wait:
166
- changes_id = response.change
167
- previous_changes = None
168
- while True:
169
- try:
170
- changes = await self._client.get_changes_by_id(changes_id)
171
- except httpx.HTTPError as e:
172
- if going_to_reload_daemon(previous_changes):
173
- logger.debug("Waiting for daemon to reload")
174
- await asyncio.sleep(0.1)
175
- continue
176
- if changes.ready:
177
- break
178
- if changes.result.err:
179
- raise Exception(f"Error in snap remove: {changes.result.err}")
180
- await asyncio.sleep(0.1)
181
- previous_changes = changes
182
- return changes
183
-
184
- return response
File without changes