snap-python 0.1.3__tar.gz → 0.1.5__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.1.3 → snap_python-0.1.5}/PKG-INFO +1 -1
  2. {snap_python-0.1.3 → snap_python-0.1.5}/pyproject.toml +1 -1
  3. {snap_python-0.1.3 → snap_python-0.1.5}/src/snap_python/components/snaps.py +138 -3
  4. {snap_python-0.1.3 → snap_python-0.1.5}/src/snap_python/components/store.py +98 -0
  5. {snap_python-0.1.3 → snap_python-0.1.5}/src/snap_python/schemas/snaps.py +25 -1
  6. {snap_python-0.1.3 → snap_python-0.1.5}/src/snap_python/schemas/store/search.py +33 -1
  7. {snap_python-0.1.3 → snap_python-0.1.5}/LICENSE +0 -0
  8. {snap_python-0.1.3 → snap_python-0.1.5}/README.md +0 -0
  9. {snap_python-0.1.3 → snap_python-0.1.5}/src/snap_python/__init__.py +0 -0
  10. {snap_python-0.1.3 → snap_python-0.1.5}/src/snap_python/client.py +0 -0
  11. {snap_python-0.1.3 → snap_python-0.1.5}/src/snap_python/components/__init__.py +0 -0
  12. {snap_python-0.1.3 → snap_python-0.1.5}/src/snap_python/components/config.py +0 -0
  13. {snap_python-0.1.3 → snap_python-0.1.5}/src/snap_python/schemas/changes.py +0 -0
  14. {snap_python-0.1.3 → snap_python-0.1.5}/src/snap_python/schemas/common.py +0 -0
  15. {snap_python-0.1.3 → snap_python-0.1.5}/src/snap_python/schemas/config.py +0 -0
  16. {snap_python-0.1.3 → snap_python-0.1.5}/src/snap_python/schemas/store/__init__.py +0 -0
  17. {snap_python-0.1.3 → snap_python-0.1.5}/src/snap_python/schemas/store/categories.py +0 -0
  18. {snap_python-0.1.3 → snap_python-0.1.5}/src/snap_python/schemas/store/info.py +0 -0
  19. {snap_python-0.1.3 → snap_python-0.1.5}/src/snap_python/schemas/store/json/category_by_name_schema.json +0 -0
  20. {snap_python-0.1.3 → snap_python-0.1.5}/src/snap_python/schemas/store/json/category_schema.json +0 -0
  21. {snap_python-0.1.3 → snap_python-0.1.5}/src/snap_python/schemas/store/json/snaps_find_schema.json +0 -0
  22. {snap_python-0.1.3 → snap_python-0.1.5}/src/snap_python/schemas/store/json/snaps_info_schema.json +0 -0
  23. {snap_python-0.1.3 → snap_python-0.1.5}/src/snap_python/schemas/store/refresh.py +0 -0
  24. {snap_python-0.1.3 → snap_python-0.1.5}/src/snap_python/scrape.py +0 -0
  25. {snap_python-0.1.3 → snap_python-0.1.5}/src/snap_python/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: snap-python
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: Interact with snapd and snapstore(s) from Python
5
5
  License: MIT
6
6
  Author: Alexander Lukens
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "snap-python"
3
- version = "0.1.3"
3
+ version = "0.1.5"
4
4
  description = "Interact with snapd and snapstore(s) from Python"
5
5
  authors = ["Alexander Lukens <alexdlukens@gmail.com>"]
6
6
  license = "MIT"
@@ -1,12 +1,14 @@
1
1
  import asyncio
2
2
  import logging
3
3
  from pathlib import Path
4
+ from typing import Any
4
5
 
5
6
  import httpx
6
7
 
7
8
  from snap_python.schemas.changes import ChangesResponse
8
- from snap_python.schemas.common import AsyncResponse
9
+ from snap_python.schemas.common import AsyncResponse, BaseErrorResult
9
10
  from snap_python.schemas.snaps import (
11
+ AppsResponse,
10
12
  InstalledSnapListResponse,
11
13
  SingleInstalledSnapResponse,
12
14
  )
@@ -34,14 +36,13 @@ class SnapsEndpoints:
34
36
  "GET", self.common_endpoint
35
37
  )
36
38
 
37
- response = InstalledSnapListResponse.model_validate_json(response.content)
38
39
  if response.status_code > 299:
39
40
  raise httpx.HTTPStatusError(
40
41
  request=response.request,
41
42
  response=response,
42
43
  message=f"Invalid status code in response: {response.status_code}",
43
44
  )
44
- return response
45
+ return InstalledSnapListResponse.model_validate_json(response.content)
45
46
 
46
47
  async def get_snap_info(self, snap: str) -> SingleInstalledSnapResponse:
47
48
  """
@@ -331,3 +332,137 @@ class SnapsEndpoints:
331
332
  previous_changes = changes
332
333
  return changes
333
334
  return response
335
+
336
+ async def disable_snap(
337
+ self,
338
+ snap: str,
339
+ wait: bool = False,
340
+ ) -> AsyncResponse | ChangesResponse:
341
+ """
342
+ Disables a snap package.
343
+
344
+ :param snap: The name of the snap package to disable.
345
+ :type snap: str
346
+ :param wait: Whether to wait for the disable operation to complete. Defaults to False.
347
+ :type wait: bool, optional
348
+
349
+ :returns: The response from the disable operation.
350
+ :rtype: AsyncResponse | ChangesResponse
351
+
352
+ :raises SnapdAPIError: If there is an error during the snap disable operation.
353
+ """
354
+ request_data = {
355
+ "action": "disable",
356
+ }
357
+
358
+ raw_response: httpx.Response = await self._client.request(
359
+ "POST", f"{self.common_endpoint}/{snap}", json=request_data
360
+ )
361
+ response = AsyncResponse.model_validate_json(raw_response.content)
362
+
363
+ if wait:
364
+ changes_id = response.change
365
+ previous_changes = None
366
+ while True:
367
+ try:
368
+ changes = await self._client.get_changes_by_id(changes_id)
369
+ except httpx.HTTPError:
370
+ if going_to_reload_daemon(previous_changes):
371
+ logger.debug("Waiting for daemon to reload")
372
+ await asyncio.sleep(0.1)
373
+ continue
374
+ raise
375
+ if changes.ready:
376
+ break
377
+ if isinstance(changes.result, BaseErrorResult):
378
+ raise SnapdAPIError(
379
+ f"Error in snap disable: {changes.result.message}"
380
+ )
381
+ if changes.result.err:
382
+ raise SnapdAPIError(f"Error in snap disable: {changes.result.err}")
383
+ await asyncio.sleep(0.1)
384
+ previous_changes = changes
385
+ return changes
386
+
387
+ return response
388
+
389
+ async def enable_snap(
390
+ self,
391
+ snap: str,
392
+ wait: bool = False,
393
+ ) -> AsyncResponse | ChangesResponse:
394
+ """
395
+ enables a snap package.
396
+
397
+ :param snap: The name of the snap package to enable.
398
+ :type snap: str
399
+ :param wait: Whether to wait for the enable operation to complete. Defaults to False.
400
+ :type wait: bool, optional
401
+
402
+ :returns: The response from the enable operation.
403
+ :rtype: AsyncResponse | ChangesResponse
404
+
405
+ :raises SnapdAPIError: If there is an error during the snap enable operation.
406
+ """
407
+ request_data = {
408
+ "action": "enable",
409
+ }
410
+
411
+ raw_response: httpx.Response = await self._client.request(
412
+ "POST", f"{self.common_endpoint}/{snap}", json=request_data
413
+ )
414
+ response = AsyncResponse.model_validate_json(raw_response.content)
415
+
416
+ if wait:
417
+ changes_id = response.change
418
+ previous_changes = None
419
+ while True:
420
+ try:
421
+ changes = await self._client.get_changes_by_id(changes_id)
422
+ except httpx.HTTPError:
423
+ if going_to_reload_daemon(previous_changes):
424
+ logger.debug("Waiting for daemon to reload")
425
+ await asyncio.sleep(0.1)
426
+ continue
427
+ raise
428
+ if changes.ready:
429
+ break
430
+ if isinstance(changes.result, BaseErrorResult):
431
+ raise SnapdAPIError(
432
+ f"Error in snap enable: {changes.result.message}"
433
+ )
434
+ if changes.result.err:
435
+ raise SnapdAPIError(f"Error in snap enable: {changes.result.err}")
436
+ await asyncio.sleep(0.1)
437
+ previous_changes = changes
438
+ return changes
439
+
440
+ return response
441
+
442
+ async def get_snap_apps(
443
+ self,
444
+ snap: str,
445
+ services_only: bool = False,
446
+ ) -> AppsResponse:
447
+ """
448
+ Retrieves the applications associated with a specific snap.
449
+
450
+ :param snap: The name of the snap to retrieve applications for.
451
+ :type snap: str
452
+ :param services_only: Whether to retrieve only service applications.
453
+ :type services_only: bool
454
+
455
+ :returns: The response containing the list of applications for the specified snap.
456
+ :rtype: AppsResponse
457
+ """
458
+
459
+ payload: dict[str, Any] = {"names": [snap]}
460
+ if services_only:
461
+ payload["select"] = "service"
462
+
463
+ response: httpx.Response = await self._client.request(
464
+ "GET", "apps", params=payload
465
+ )
466
+ if response.status_code != 200:
467
+ raise SnapdAPIError(f"Failed to retrieve apps for snap: {snap}")
468
+ return AppsResponse.model_validate_json(response.content)
@@ -20,6 +20,7 @@ from snap_python.schemas.store.refresh import (
20
20
  from snap_python.schemas.store.search import (
21
21
  VALID_SEARCH_CATEGORY_FIELDS,
22
22
  ArchSearchResponse,
23
+ PaginatedSnapSearchResponse,
23
24
  SearchResponse,
24
25
  )
25
26
 
@@ -396,3 +397,100 @@ class StoreEndpoints:
396
397
  )
397
398
  response.raise_for_status()
398
399
  return RefreshRevisionResponse.model_validate_json(response.content)
400
+
401
+ async def get_many_snap_revision_info(
402
+ self,
403
+ snap_name: str,
404
+ from_revision: int,
405
+ to_revision: int,
406
+ arch: str,
407
+ fields=None,
408
+ ) -> RefreshRevisionResponse:
409
+ """Get information about a snap revision.
410
+
411
+ :param snap_name: The name of the snap.
412
+ :type snap_name: str
413
+ :param revision: The revision of the snap.
414
+ :type revision: int
415
+ :param arch: The architecture of the snap to retrieve details about (e.g. amd64, arm64, riscv64, etc).
416
+ :type arch: str
417
+
418
+ :returns: The snap revision information.
419
+ :rtype: RefreshRevisionResponse
420
+ """
421
+
422
+ # cast revision to int
423
+ from_revision = int(from_revision)
424
+ to_revision = int(to_revision)
425
+ extra_headers = {"Snap-Device-Architecture": arch}
426
+ snap_info = await self.get_snap_info(snap_name=snap_name)
427
+
428
+ # I don't think this matters for the "context" field, so using info from the first available
429
+ channel_map_item = snap_info.channel_map[0]
430
+
431
+ payload = {
432
+ "context": [
433
+ {
434
+ "tracking-channel": "stable",
435
+ "snap-id": snap_info.snap_id,
436
+ "revision": channel_map_item.revision,
437
+ "instance-key": str(uuid.uuid4()),
438
+ }
439
+ ],
440
+ "actions": [],
441
+ }
442
+ for revision in range(from_revision, to_revision + 1):
443
+ payload["actions"].append(
444
+ {
445
+ "action": "download",
446
+ "name": snap_name,
447
+ "revision": revision,
448
+ "instance-key": str(uuid.uuid4()),
449
+ }
450
+ )
451
+
452
+ if fields is not None:
453
+ if not all(field in VALID_SNAP_REFRESH_FIELDS for field in fields):
454
+ raise ValueError(
455
+ f"Invalid fields. Allowed fields: {VALID_SNAP_REFRESH_FIELDS}"
456
+ )
457
+ payload["fields"] = fields
458
+
459
+ response = await self.snap_refresh(
460
+ snap_name=snap_name,
461
+ payload=payload,
462
+ extra_headers=extra_headers,
463
+ )
464
+ response.raise_for_status()
465
+ return RefreshRevisionResponse.model_validate_json(response.content)
466
+
467
+ async def get_snap_search_paginated(
468
+ self,
469
+ q: str = "",
470
+ scope: str = "wide",
471
+ arch: str = "wide",
472
+ page: int = 1,
473
+ limit: int = 100,
474
+ confinement: str = "strict,classic",
475
+ ) -> PaginatedSnapSearchResponse:
476
+ payload = {
477
+ "scope": scope,
478
+ "arch": arch,
479
+ "page": page,
480
+ "size": limit,
481
+ "confinement": confinement,
482
+ }
483
+ if q:
484
+ payload["q"] = q
485
+
486
+ response = await self.store_client.get(
487
+ f"{self._raw_base_url}/api/v1/snaps/search",
488
+ params=payload,
489
+ )
490
+
491
+ response.raise_for_status()
492
+ response_json = response.json()
493
+ response_json["page"] = page
494
+ response_json["limit"] = limit
495
+
496
+ return PaginatedSnapSearchResponse.model_validate(response_json)
@@ -32,7 +32,8 @@ class InstalledSnapFields(Revision):
32
32
  validation_alias=AliasChoices("ignore-validation", "ignore_validation"),
33
33
  serialization_alias="ignore-validation",
34
34
  )
35
- install_date: AwareDatetime = Field(
35
+ install_date: Optional[AwareDatetime] = Field(
36
+ default=None,
36
37
  validation_alias=AliasChoices("install-date", "install_date"),
37
38
  serialization_alias="install-date",
38
39
  )
@@ -98,3 +99,26 @@ class InstalledSnapListResponse(BaseResponse):
98
99
 
99
100
  def __len__(self):
100
101
  return len(self.result)
102
+
103
+
104
+ class SnapAppInfo(BaseModel):
105
+ snap: Optional[str] = None
106
+ name: str
107
+ desktop_file: Optional[str] = Field(
108
+ default=None,
109
+ validation_alias=AliasChoices("desktop-file", "desktop_file"),
110
+ serialization_alias="desktop-file",
111
+ )
112
+ daemon: Optional[str] = None
113
+ enabled: Optional[bool] = None
114
+ active: Optional[bool] = None
115
+ common_id: Optional[str] = Field(
116
+ default=None,
117
+ validation_alias=AliasChoices("common-id", "common_id"),
118
+ serialization_alias="common-id",
119
+ )
120
+ activators: Optional[list[dict]] = None
121
+
122
+
123
+ class AppsResponse(BaseResponse):
124
+ result: list[SnapAppInfo]
@@ -7,6 +7,7 @@ from pydantic import (
7
7
  BaseModel,
8
8
  ConfigDict,
9
9
  Field,
10
+ computed_field,
10
11
  )
11
12
 
12
13
  from snap_python.schemas.common import Revision
@@ -144,4 +145,35 @@ class ArchSearchResponse(BaseModel):
144
145
  results: list[ArchSearchItem] = Field(
145
146
  alias=AliasChoices(AliasPath("_embedded", "clickindex:package"), "results")
146
147
  )
147
- arch: str
148
+ arch: str = "wide"
149
+
150
+
151
+ class PaginatedResponse(BaseModel):
152
+ total: int
153
+ page: int = Field(ge=0)
154
+ limit: int = Field(ge=1, le=100)
155
+
156
+ @computed_field
157
+ @property
158
+ def next_page(self) -> Optional[int]:
159
+ if self.page * self.limit + self.limit < self.total:
160
+ return self.page + 1
161
+ return None
162
+
163
+ @computed_field
164
+ @property
165
+ def previous_page(self) -> Optional[int]:
166
+ if self.page > 0:
167
+ return self.page - 1
168
+ return None
169
+
170
+ @computed_field
171
+ @property
172
+ def has_more(self) -> bool:
173
+ return self.page * self.limit + self.limit < self.total
174
+
175
+
176
+ class PaginatedSnapSearchResponse(PaginatedResponse):
177
+ results: list[ArchSearchItem] = Field(
178
+ alias=AliasChoices(AliasPath("_embedded", "clickindex:package"), "results")
179
+ )
File without changes
File without changes