snap-python 0.1.4__tar.gz → 0.1.6__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.4 → snap_python-0.1.6}/PKG-INFO +1 -1
  2. {snap_python-0.1.4 → snap_python-0.1.6}/pyproject.toml +1 -1
  3. {snap_python-0.1.4 → snap_python-0.1.6}/src/snap_python/client.py +0 -1
  4. {snap_python-0.1.4 → snap_python-0.1.6}/src/snap_python/components/snaps.py +138 -3
  5. {snap_python-0.1.4 → snap_python-0.1.6}/src/snap_python/components/store.py +31 -2
  6. {snap_python-0.1.4 → snap_python-0.1.6}/src/snap_python/schemas/snaps.py +25 -1
  7. {snap_python-0.1.4 → snap_python-0.1.6}/src/snap_python/schemas/store/info.py +7 -5
  8. snap_python-0.1.6/src/snap_python/schemas/store/track.py +132 -0
  9. {snap_python-0.1.4 → snap_python-0.1.6}/LICENSE +0 -0
  10. {snap_python-0.1.4 → snap_python-0.1.6}/README.md +0 -0
  11. {snap_python-0.1.4 → snap_python-0.1.6}/src/snap_python/__init__.py +0 -0
  12. {snap_python-0.1.4 → snap_python-0.1.6}/src/snap_python/components/__init__.py +0 -0
  13. {snap_python-0.1.4 → snap_python-0.1.6}/src/snap_python/components/config.py +0 -0
  14. {snap_python-0.1.4 → snap_python-0.1.6}/src/snap_python/schemas/changes.py +0 -0
  15. {snap_python-0.1.4 → snap_python-0.1.6}/src/snap_python/schemas/common.py +0 -0
  16. {snap_python-0.1.4 → snap_python-0.1.6}/src/snap_python/schemas/config.py +0 -0
  17. {snap_python-0.1.4 → snap_python-0.1.6}/src/snap_python/schemas/store/__init__.py +0 -0
  18. {snap_python-0.1.4 → snap_python-0.1.6}/src/snap_python/schemas/store/categories.py +0 -0
  19. {snap_python-0.1.4 → snap_python-0.1.6}/src/snap_python/schemas/store/json/category_by_name_schema.json +0 -0
  20. {snap_python-0.1.4 → snap_python-0.1.6}/src/snap_python/schemas/store/json/category_schema.json +0 -0
  21. {snap_python-0.1.4 → snap_python-0.1.6}/src/snap_python/schemas/store/json/snaps_find_schema.json +0 -0
  22. {snap_python-0.1.4 → snap_python-0.1.6}/src/snap_python/schemas/store/json/snaps_info_schema.json +0 -0
  23. {snap_python-0.1.4 → snap_python-0.1.6}/src/snap_python/schemas/store/refresh.py +0 -0
  24. {snap_python-0.1.4 → snap_python-0.1.6}/src/snap_python/schemas/store/search.py +0 -0
  25. {snap_python-0.1.4 → snap_python-0.1.6}/src/snap_python/scrape.py +0 -0
  26. {snap_python-0.1.4 → snap_python-0.1.6}/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.4
3
+ Version: 0.1.6
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.4"
3
+ version = "0.1.6"
4
4
  description = "Interact with snapd and snapstore(s) from Python"
5
5
  authors = ["Alexander Lukens <alexdlukens@gmail.com>"]
6
6
  license = "MIT"
@@ -61,7 +61,6 @@ 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)
65
64
 
66
65
  @retry(httpx.HTTPError, tries=3, delay=1, backoff=1)
67
66
  async def request(self, method: str, endpoint: str, **kwargs) -> httpx.Response:
@@ -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)
@@ -23,6 +23,10 @@ from snap_python.schemas.store.search import (
23
23
  PaginatedSnapSearchResponse,
24
24
  SearchResponse,
25
25
  )
26
+ from snap_python.schemas.store.track import (
27
+ TrackRiskMap,
28
+ channel_map_to_current_track_map,
29
+ )
26
30
 
27
31
 
28
32
  class StoreEndpoints:
@@ -165,7 +169,7 @@ class StoreEndpoints:
165
169
  async def find(
166
170
  self,
167
171
  query: str | None = None,
168
- fields: str | None = None,
172
+ fields: list[str] | None = None,
169
173
  name_startswith: str | None = None,
170
174
  architecture: str | None = None,
171
175
  common_id: str | None = None,
@@ -183,7 +187,7 @@ class StoreEndpoints:
183
187
  :param query: The search query.
184
188
  :type query: str, optional
185
189
  :param fields: The fields to include in the response.
186
- :type fields: str, optional
190
+ :type fields: list[str], optional
187
191
  :param name_startswith: Filter snaps by name prefix.
188
192
  :type name_startswith: str, optional
189
193
  :param architecture: Filter snaps by architecture.
@@ -494,3 +498,28 @@ class StoreEndpoints:
494
498
  response_json["limit"] = limit
495
499
 
496
500
  return PaginatedSnapSearchResponse.model_validate(response_json)
501
+
502
+ async def get_track_risk_map(
503
+ self, snap_name: str
504
+ ) -> dict[str, dict[str, TrackRiskMap]]:
505
+ """Get the track risk map for a snap.
506
+
507
+ :param snap_name: The name of the snap.
508
+ :type snap_name: str
509
+
510
+ :returns: A dictionary mapping tracks to their risk maps.
511
+ :rtype: dict[str, TrackRiskMap]
512
+ """
513
+ snap_info = await self.get_snap_info(
514
+ snap_name=snap_name,
515
+ fields=[
516
+ "channel-map",
517
+ "architectures",
518
+ "base",
519
+ "revision",
520
+ "confinement",
521
+ "version",
522
+ "created-at",
523
+ ],
524
+ )
525
+ return channel_map_to_current_track_map(snap_info.channel_map)
@@ -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]
@@ -1,10 +1,12 @@
1
- # generated by datamodel-codegen:
2
- # filename: snaps_info_schema.json
3
- # timestamp: 2024-10-17T01:47:02+00:00
4
-
5
1
  from typing import List, Optional
6
2
 
7
- from pydantic import AliasChoices, AwareDatetime, BaseModel, ConfigDict, Field
3
+ from pydantic import (
4
+ AliasChoices,
5
+ AwareDatetime,
6
+ BaseModel,
7
+ ConfigDict,
8
+ Field,
9
+ )
8
10
 
9
11
  from snap_python.schemas.snaps import StoreSnap
10
12
 
@@ -0,0 +1,132 @@
1
+ import logging
2
+ from typing import Optional
3
+
4
+ from pydantic import (
5
+ AwareDatetime,
6
+ BaseModel,
7
+ field_validator,
8
+ )
9
+
10
+ from snap_python.schemas.store.info import Channel, ChannelMapItem
11
+
12
+ logger = logging.getLogger("snap_python.schemas.store.track")
13
+
14
+
15
+ class TrackRevisionDetails(BaseModel):
16
+ arch: str
17
+ base: str
18
+ channel: Channel
19
+ confinement: str
20
+ created_at: AwareDatetime
21
+ released_at: AwareDatetime
22
+ revision: int
23
+ risk: str
24
+ track: str
25
+ version: str
26
+
27
+ @field_validator("arch")
28
+ @classmethod
29
+ def validate_arch(cls, value):
30
+ valid_architectures = {
31
+ "amd64",
32
+ "arm64",
33
+ "armhf",
34
+ "i386",
35
+ "ppc64el",
36
+ "s390x",
37
+ "riscv64",
38
+ }
39
+ if value not in valid_architectures:
40
+ raise ValueError(
41
+ f"Invalid architecture: {value}. Must be one of {', '.join(valid_architectures)}."
42
+ )
43
+ return value
44
+
45
+
46
+ class TrackRiskMap(BaseModel):
47
+ amd64: Optional[TrackRevisionDetails] = None
48
+ arm64: Optional[TrackRevisionDetails] = None
49
+ armhf: Optional[TrackRevisionDetails] = None
50
+ i386: Optional[TrackRevisionDetails] = None
51
+ ppc64el: Optional[TrackRevisionDetails] = None
52
+ s390x: Optional[TrackRevisionDetails] = None
53
+ riscv64: Optional[TrackRevisionDetails] = None
54
+
55
+ @property
56
+ def architectures(self) -> list[str]:
57
+ """Return a list of architectures that have revisions."""
58
+ return [
59
+ arch for arch, details in self.model_dump().items() if details is not None
60
+ ]
61
+
62
+
63
+ def channel_map_to_current_track_map(
64
+ channel_map: list[ChannelMapItem],
65
+ ) -> dict[str, dict[str, TrackRiskMap]]:
66
+ """Convert a list of ChannelMapItem to a map of TrackRiskMap.
67
+
68
+ track -> risk -> arch
69
+ """
70
+ # track -> risk -> arch
71
+ current_track_map: dict[str, dict[str, TrackRiskMap]] = {}
72
+
73
+ all_tracks = set(item.channel.track for item in channel_map)
74
+ logger.debug(f"Found tracks: {', '.join(all_tracks)}")
75
+
76
+ for track in all_tracks:
77
+ current_track_map[track] = {}
78
+
79
+ for item in channel_map:
80
+ track = item.channel.track
81
+ risk = item.channel.risk
82
+
83
+ if not item.architectures:
84
+ logger.error(
85
+ f"Warning: Channel {item} has no architectures defined. Skipping."
86
+ )
87
+ continue
88
+ assert (
89
+ len(item.architectures) == 1
90
+ ), "Channel must have exactly one architecture."
91
+
92
+ assert (
93
+ item.revision is not None
94
+ ), f"Channel {item} must have a revision defined."
95
+ assert item.channel is not None, f"Channel {item} must have a channel defined."
96
+ assert item.base is not None, f"Channel {item} must have a base defined."
97
+ assert (
98
+ item.confinement is not None
99
+ ), f"Channel {item} must have a confinement defined."
100
+ assert item.version is not None, f"Channel {item} must have a version defined."
101
+ assert (
102
+ item.created_at is not None
103
+ ), f"Channel {item} must have a created_at defined."
104
+ assert (
105
+ item.channel.released_at is not None
106
+ ), f"Channel {item} must have a released_at defined."
107
+
108
+ arch = item.architectures[0]
109
+
110
+ revision_details = TrackRevisionDetails(
111
+ arch=arch,
112
+ base=item.base,
113
+ channel=item.channel,
114
+ confinement=item.confinement,
115
+ created_at=item.created_at,
116
+ released_at=item.channel.released_at,
117
+ revision=item.revision,
118
+ risk=risk,
119
+ track=track,
120
+ version=item.version,
121
+ )
122
+
123
+ if risk not in current_track_map[track]:
124
+ current_track_map[track][risk] = TrackRiskMap()
125
+ setattr(current_track_map[track][risk], arch, revision_details)
126
+
127
+ # re validate the track map to ensure it has all required fields
128
+ for track, risk_map in current_track_map.items():
129
+ for risk in risk_map:
130
+ risk_map[risk] = TrackRiskMap.model_validate(risk_map[risk])
131
+
132
+ return current_track_map
File without changes
File without changes