snap-python 0.1.5__tar.gz → 0.1.7__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.5 → snap_python-0.1.7}/PKG-INFO +3 -3
  2. {snap_python-0.1.5 → snap_python-0.1.7}/pyproject.toml +3 -3
  3. {snap_python-0.1.5 → snap_python-0.1.7}/src/snap_python/client.py +0 -1
  4. {snap_python-0.1.5 → snap_python-0.1.7}/src/snap_python/components/store.py +53 -3
  5. {snap_python-0.1.5 → snap_python-0.1.7}/src/snap_python/schemas/common.py +4 -4
  6. {snap_python-0.1.5 → snap_python-0.1.7}/src/snap_python/schemas/snaps.py +1 -3
  7. {snap_python-0.1.5 → snap_python-0.1.7}/src/snap_python/schemas/store/categories.py +4 -4
  8. {snap_python-0.1.5 → snap_python-0.1.7}/src/snap_python/schemas/store/info.py +15 -13
  9. {snap_python-0.1.5 → snap_python-0.1.7}/src/snap_python/schemas/store/refresh.py +1 -1
  10. {snap_python-0.1.5 → snap_python-0.1.7}/src/snap_python/schemas/store/search.py +1 -7
  11. snap_python-0.1.7/src/snap_python/schemas/store/track.py +138 -0
  12. {snap_python-0.1.5 → snap_python-0.1.7}/LICENSE +0 -0
  13. {snap_python-0.1.5 → snap_python-0.1.7}/README.md +0 -0
  14. {snap_python-0.1.5 → snap_python-0.1.7}/src/snap_python/__init__.py +0 -0
  15. {snap_python-0.1.5 → snap_python-0.1.7}/src/snap_python/components/__init__.py +0 -0
  16. {snap_python-0.1.5 → snap_python-0.1.7}/src/snap_python/components/config.py +0 -0
  17. {snap_python-0.1.5 → snap_python-0.1.7}/src/snap_python/components/snaps.py +0 -0
  18. {snap_python-0.1.5 → snap_python-0.1.7}/src/snap_python/schemas/changes.py +0 -0
  19. {snap_python-0.1.5 → snap_python-0.1.7}/src/snap_python/schemas/config.py +0 -0
  20. {snap_python-0.1.5 → snap_python-0.1.7}/src/snap_python/schemas/store/__init__.py +0 -0
  21. {snap_python-0.1.5 → snap_python-0.1.7}/src/snap_python/schemas/store/json/category_by_name_schema.json +0 -0
  22. {snap_python-0.1.5 → snap_python-0.1.7}/src/snap_python/schemas/store/json/category_schema.json +0 -0
  23. {snap_python-0.1.5 → snap_python-0.1.7}/src/snap_python/schemas/store/json/snaps_find_schema.json +0 -0
  24. {snap_python-0.1.5 → snap_python-0.1.7}/src/snap_python/schemas/store/json/snaps_info_schema.json +0 -0
  25. {snap_python-0.1.5 → snap_python-0.1.7}/src/snap_python/scrape.py +0 -0
  26. {snap_python-0.1.5 → snap_python-0.1.7}/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.5
3
+ Version: 0.1.7
4
4
  Summary: Interact with snapd and snapstore(s) from Python
5
5
  License: MIT
6
6
  Author: Alexander Lukens
@@ -12,8 +12,8 @@ Classifier: Programming Language :: Python :: 3.10
12
12
  Classifier: Programming Language :: Python :: 3.11
13
13
  Classifier: Programming Language :: Python :: 3.12
14
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)
15
+ Requires-Dist: httpx (>0.27,<1)
16
+ Requires-Dist: pydantic (>2.9,<3)
17
17
  Requires-Dist: retry (>=0.9.2,<0.10.0)
18
18
  Project-URL: Documentation, https://alexdlukens.github.io/snap-python/
19
19
  Project-URL: Repository, https://github.com/alexdlukens/snap-python
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "snap-python"
3
- version = "0.1.5"
3
+ version = "0.1.7"
4
4
  description = "Interact with snapd and snapstore(s) from Python"
5
5
  authors = ["Alexander Lukens <alexdlukens@gmail.com>"]
6
6
  license = "MIT"
@@ -10,8 +10,8 @@ documentation = "https://alexdlukens.github.io/snap-python/"
10
10
 
11
11
  [tool.poetry.dependencies]
12
12
  python = "^3.10"
13
- httpx = "^0.27.2"
14
- pydantic = "^2.9.2"
13
+ httpx = ">0.27,<1"
14
+ pydantic = ">2.9,<3"
15
15
  retry = "^0.9.2"
16
16
 
17
17
  [tool.poetry.group.dev.dependencies]
@@ -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,5 +1,6 @@
1
1
  import functools
2
2
  import uuid
3
+ from typing import Optional
3
4
 
4
5
  import retry
5
6
  from httpx import AsyncClient, Response
@@ -23,6 +24,10 @@ from snap_python.schemas.store.search import (
23
24
  PaginatedSnapSearchResponse,
24
25
  SearchResponse,
25
26
  )
27
+ from snap_python.schemas.store.track import (
28
+ TrackRiskMap,
29
+ channel_map_to_current_track_map,
30
+ )
26
31
 
27
32
 
28
33
  class StoreEndpoints:
@@ -73,8 +78,19 @@ class StoreEndpoints:
73
78
  response.raise_for_status()
74
79
  return response.json()
75
80
 
81
+ async def get_snap_name_from_snap_id(self, snap_id: str) -> str | None:
82
+ response = await self.store_client.get(
83
+ f"https://api.snapcraft.io/v2/assertions/snap-declaration/16/{snap_id}"
84
+ )
85
+ response.raise_for_status()
86
+ response_json = response.json()
87
+ return response_json.get("headers", {}).get("snap-name", None)
88
+
76
89
  async def get_snap_info(
77
- self, snap_name: str, fields: list[str] | None = None
90
+ self,
91
+ snap_name: Optional[str] = None,
92
+ snap_id: Optional[str] = None,
93
+ fields: list[str] | None = None,
78
94
  ) -> InfoResponse:
79
95
  """
80
96
  Get information about a snap.
@@ -89,6 +105,15 @@ class StoreEndpoints:
89
105
 
90
106
  :raises ValueError: If invalid fields are provided.
91
107
  """
108
+ if snap_name is None and snap_id is None:
109
+ raise ValueError("Either snap_name or snap_id must be provided.")
110
+ if snap_name is not None and snap_id is not None:
111
+ raise ValueError("Only one of snap_name or snap_id must be provided.")
112
+
113
+ # get snap name from id, then proceed as usual
114
+ if snap_id is not None:
115
+ snap_name = await self.get_snap_name_from_snap_id(snap_id)
116
+
92
117
  query = {}
93
118
  if fields is not None:
94
119
  if not all(field in VALID_SNAP_INFO_FIELDS for field in fields):
@@ -165,7 +190,7 @@ class StoreEndpoints:
165
190
  async def find(
166
191
  self,
167
192
  query: str | None = None,
168
- fields: str | None = None,
193
+ fields: list[str] | None = None,
169
194
  name_startswith: str | None = None,
170
195
  architecture: str | None = None,
171
196
  common_id: str | None = None,
@@ -183,7 +208,7 @@ class StoreEndpoints:
183
208
  :param query: The search query.
184
209
  :type query: str, optional
185
210
  :param fields: The fields to include in the response.
186
- :type fields: str, optional
211
+ :type fields: list[str], optional
187
212
  :param name_startswith: Filter snaps by name prefix.
188
213
  :type name_startswith: str, optional
189
214
  :param architecture: Filter snaps by architecture.
@@ -494,3 +519,28 @@ class StoreEndpoints:
494
519
  response_json["limit"] = limit
495
520
 
496
521
  return PaginatedSnapSearchResponse.model_validate(response_json)
522
+
523
+ async def get_track_risk_map(
524
+ self, snap_name: str
525
+ ) -> dict[str, dict[str, TrackRiskMap]]:
526
+ """Get the track risk map for a snap.
527
+
528
+ :param snap_name: The name of the snap.
529
+ :type snap_name: str
530
+
531
+ :returns: A dictionary mapping tracks to their risk maps.
532
+ :rtype: dict[str, TrackRiskMap]
533
+ """
534
+ snap_info = await self.get_snap_info(
535
+ snap_name=snap_name,
536
+ fields=[
537
+ "channel-map",
538
+ "architectures",
539
+ "base",
540
+ "revision",
541
+ "confinement",
542
+ "version",
543
+ "created-at",
544
+ ],
545
+ )
546
+ return channel_map_to_current_track_map(snap_info.channel_map)
@@ -52,7 +52,7 @@ class SnapApp(BaseModel):
52
52
 
53
53
 
54
54
  class Media(BaseModel):
55
- model_config = ConfigDict(extra="forbid", exclude_unset=True)
55
+ model_config = ConfigDict(exclude_unset=True)
56
56
 
57
57
  height: Optional[float] = None
58
58
  type: str
@@ -65,13 +65,13 @@ class AsyncResponse(BaseResponse):
65
65
 
66
66
 
67
67
  class Download(BaseModel):
68
- model_config = ConfigDict(extra="forbid", exclude_unset=True)
68
+ model_config = ConfigDict(exclude_unset=True)
69
69
 
70
70
  size: float
71
71
 
72
72
 
73
73
  class Revision(BaseModel):
74
- model_config = ConfigDict(extra="forbid", exclude_unset=True)
74
+ model_config = ConfigDict(exclude_unset=True)
75
75
 
76
76
  base: Optional[SnapBaseVersion] = None
77
77
  channel: Optional[str] = None
@@ -86,7 +86,7 @@ class Revision(BaseModel):
86
86
 
87
87
 
88
88
  class Publisher(BaseModel):
89
- model_config = ConfigDict(extra="forbid", exclude_unset=True)
89
+ model_config = ConfigDict(exclude_unset=True)
90
90
 
91
91
  display_name: str = Field(
92
92
  ...,
@@ -1,6 +1,6 @@
1
1
  from typing import Optional
2
2
 
3
- from pydantic import AliasChoices, AwareDatetime, BaseModel, ConfigDict, Field
3
+ from pydantic import AliasChoices, AwareDatetime, BaseModel, Field
4
4
 
5
5
  from snap_python.schemas.common import (
6
6
  BaseErrorResult,
@@ -66,8 +66,6 @@ class StoreSnapFields(BaseModel):
66
66
 
67
67
 
68
68
  class Snap(BaseModel):
69
- model_config = ConfigDict(extra="forbid")
70
-
71
69
  contact: Optional[str] = None
72
70
  description: Optional[str] = None
73
71
  license: str = "unset"
@@ -10,7 +10,7 @@ VALID_CATEGORY_FIELDS = ["name", "type", "title", "summary", "description", "med
10
10
 
11
11
 
12
12
  class Media(BaseModel):
13
- model_config = ConfigDict(extra="forbid", exclude_unset=True)
13
+ model_config = ConfigDict(exclude_unset=True)
14
14
 
15
15
  height: Optional[float] = None
16
16
  type: str
@@ -19,7 +19,7 @@ class Media(BaseModel):
19
19
 
20
20
 
21
21
  class Category(BaseModel):
22
- model_config = ConfigDict(extra="forbid", exclude_unset=True)
22
+ model_config = ConfigDict(exclude_unset=True)
23
23
 
24
24
  description: Optional[str] = None
25
25
  media: Optional[Media] = None
@@ -31,12 +31,12 @@ class Category(BaseModel):
31
31
 
32
32
 
33
33
  class CategoryResponse(BaseModel):
34
- model_config = ConfigDict(extra="forbid", exclude_unset=True)
34
+ model_config = ConfigDict(exclude_unset=True)
35
35
 
36
36
  categories: Optional[List[Category]] = None
37
37
 
38
38
 
39
39
  class SingleCategoryResponse(BaseModel):
40
- model_config = ConfigDict(extra="forbid", exclude_unset=True)
40
+ model_config = ConfigDict(exclude_unset=True)
41
41
 
42
42
  category: Category
@@ -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
 
@@ -43,7 +45,7 @@ VALID_SNAP_INFO_FIELDS = [
43
45
 
44
46
 
45
47
  class Channel(BaseModel):
46
- model_config = ConfigDict(extra="forbid", exclude_unset=True)
48
+ model_config = ConfigDict(exclude_unset=True)
47
49
 
48
50
  architecture: str
49
51
  name: str
@@ -57,7 +59,7 @@ class Channel(BaseModel):
57
59
 
58
60
 
59
61
  class Delta(BaseModel):
60
- model_config = ConfigDict(extra="forbid", exclude_unset=True)
62
+ model_config = ConfigDict(exclude_unset=True)
61
63
 
62
64
  format: str
63
65
  sha3_384: Optional[str] = Field(
@@ -70,7 +72,7 @@ class Delta(BaseModel):
70
72
 
71
73
 
72
74
  class Download(BaseModel):
73
- model_config = ConfigDict(extra="forbid", exclude_unset=True)
75
+ model_config = ConfigDict(exclude_unset=True)
74
76
 
75
77
  deltas: List[Delta]
76
78
  sha3_384: Optional[str] = Field(
@@ -81,7 +83,7 @@ class Download(BaseModel):
81
83
 
82
84
 
83
85
  class Epoch(BaseModel):
84
- model_config = ConfigDict(extra="forbid", exclude_unset=True)
86
+ model_config = ConfigDict(exclude_unset=True)
85
87
 
86
88
  read: List[float]
87
89
  write: List[float]
@@ -96,7 +98,7 @@ class Download1(BaseModel):
96
98
 
97
99
 
98
100
  class Resource(BaseModel):
99
- model_config = ConfigDict(extra="forbid", exclude_unset=True)
101
+ model_config = ConfigDict(exclude_unset=True)
100
102
 
101
103
  architectures: Optional[List[str]] = None
102
104
  created_at: Optional[str] = Field(
@@ -113,7 +115,7 @@ class Resource(BaseModel):
113
115
 
114
116
 
115
117
  class ChannelMapItem(BaseModel):
116
- model_config = ConfigDict(extra="forbid", exclude_unset=True)
118
+ model_config = ConfigDict(exclude_unset=True)
117
119
 
118
120
  architectures: Optional[List[str]] = None
119
121
  base: Optional[str] = None
@@ -143,14 +145,14 @@ class ChannelMapItem(BaseModel):
143
145
 
144
146
 
145
147
  class ErrorListItem(BaseModel):
146
- model_config = ConfigDict(extra="forbid", exclude_unset=True)
148
+ model_config = ConfigDict(exclude_unset=True)
147
149
 
148
150
  code: str
149
151
  message: str
150
152
 
151
153
 
152
154
  class InfoResponse(BaseModel):
153
- model_config = ConfigDict(extra="forbid", exclude_unset=True)
155
+ model_config = ConfigDict(exclude_unset=True)
154
156
 
155
157
  channel_map: List[ChannelMapItem] = Field(
156
158
  ...,
@@ -79,7 +79,7 @@ class RefreshResultError(BaseModel):
79
79
 
80
80
 
81
81
  class RefreshResultData(BaseModel):
82
- model_config = ConfigDict(extra="forbid", exclude_unset=True)
82
+ model_config = ConfigDict(exclude_unset=True)
83
83
 
84
84
  default_track: Optional[str] = Field(
85
85
  None,
@@ -38,7 +38,7 @@ VALID_SEARCH_CATEGORY_FIELDS = [
38
38
 
39
39
 
40
40
  class ErrorListItem(BaseModel):
41
- model_config = ConfigDict(extra="forbid", exclude_unset=True)
41
+ model_config = ConfigDict(exclude_unset=True)
42
42
 
43
43
  code: str
44
44
  message: str
@@ -90,8 +90,6 @@ class SnapDetails(BaseModel):
90
90
 
91
91
 
92
92
  class SearchResult(BaseModel):
93
- model_config = ConfigDict(extra="forbid")
94
-
95
93
  name: str
96
94
  revision: Optional[Revision] = None
97
95
  snap: StoreSnap
@@ -124,15 +122,11 @@ class SearchResult(BaseModel):
124
122
 
125
123
 
126
124
  class SearchResponse(BaseModel):
127
- model_config = ConfigDict(extra="forbid")
128
-
129
125
  error_list: Optional[List[ErrorListItem]] = Field(None, alias="error-list")
130
126
  results: List[SearchResult]
131
127
 
132
128
 
133
129
  class ArchSearchItem(BaseModel):
134
- model_config = ConfigDict(extra="ignore")
135
-
136
130
  aliases: Optional[list[dict[str, str]]]
137
131
  apps: list[str]
138
132
  package_name: str
@@ -0,0 +1,138 @@
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
+ """Contains details for a specific track/risk/architecture
17
+
18
+
19
+ Raises:
20
+ ValueError: If the architecture is invalid.
21
+ """
22
+
23
+ arch: str
24
+ base: str
25
+ channel: Channel
26
+ confinement: str
27
+ created_at: AwareDatetime
28
+ released_at: AwareDatetime
29
+ revision: int
30
+ risk: str
31
+ track: str
32
+ version: str
33
+
34
+ @field_validator("arch")
35
+ @classmethod
36
+ def validate_arch(cls, value):
37
+ valid_architectures = {
38
+ "amd64",
39
+ "arm64",
40
+ "armhf",
41
+ "i386",
42
+ "ppc64el",
43
+ "s390x",
44
+ "riscv64",
45
+ }
46
+ if value not in valid_architectures:
47
+ raise ValueError(
48
+ f"Invalid architecture: {value}. Must be one of {', '.join(valid_architectures)}."
49
+ )
50
+ return value
51
+
52
+
53
+ class TrackRiskMap(BaseModel):
54
+ """Map of architectures to their revision details for a specific risk level."""
55
+
56
+ amd64: Optional[TrackRevisionDetails] = None
57
+ arm64: Optional[TrackRevisionDetails] = None
58
+ armhf: Optional[TrackRevisionDetails] = None
59
+ i386: Optional[TrackRevisionDetails] = None
60
+ ppc64el: Optional[TrackRevisionDetails] = None
61
+ s390x: Optional[TrackRevisionDetails] = None
62
+ riscv64: Optional[TrackRevisionDetails] = None
63
+
64
+ @property
65
+ def architectures(self) -> list[str]:
66
+ """Return a list of architectures that have revisions."""
67
+ return [
68
+ arch for arch, details in self.model_dump().items() if details is not None
69
+ ]
70
+
71
+
72
+ def channel_map_to_current_track_map(
73
+ channel_map: list[ChannelMapItem],
74
+ ) -> dict[str, dict[str, TrackRiskMap]]:
75
+ """Convert a list of ChannelMapItem to a map of track -> risk -> TrackRiskMap."""
76
+ # track -> risk -> arch
77
+ current_track_map: dict[str, dict[str, TrackRiskMap]] = {}
78
+
79
+ all_tracks = set(item.channel.track for item in channel_map)
80
+ logger.debug(f"Found tracks: {', '.join(all_tracks)}")
81
+
82
+ for track in all_tracks:
83
+ current_track_map[track] = {}
84
+
85
+ for item in channel_map:
86
+ track = item.channel.track
87
+ risk = item.channel.risk
88
+
89
+ if not item.architectures:
90
+ logger.error(
91
+ f"Warning: Channel {item} has no architectures defined. Skipping."
92
+ )
93
+ continue
94
+ assert (
95
+ len(item.architectures) == 1
96
+ ), "Channel must have exactly one architecture."
97
+
98
+ assert (
99
+ item.revision is not None
100
+ ), f"Channel {item} must have a revision defined."
101
+ assert item.channel is not None, f"Channel {item} must have a channel defined."
102
+ assert item.base is not None, f"Channel {item} must have a base defined."
103
+ assert (
104
+ item.confinement is not None
105
+ ), f"Channel {item} must have a confinement defined."
106
+ assert item.version is not None, f"Channel {item} must have a version defined."
107
+ assert (
108
+ item.created_at is not None
109
+ ), f"Channel {item} must have a created_at defined."
110
+ assert (
111
+ item.channel.released_at is not None
112
+ ), f"Channel {item} must have a released_at defined."
113
+
114
+ arch = item.architectures[0]
115
+
116
+ revision_details = TrackRevisionDetails(
117
+ arch=arch,
118
+ base=item.base,
119
+ channel=item.channel,
120
+ confinement=item.confinement,
121
+ created_at=item.created_at,
122
+ released_at=item.channel.released_at,
123
+ revision=item.revision,
124
+ risk=risk,
125
+ track=track,
126
+ version=item.version,
127
+ )
128
+
129
+ if risk not in current_track_map[track]:
130
+ current_track_map[track][risk] = TrackRiskMap()
131
+ setattr(current_track_map[track][risk], arch, revision_details)
132
+
133
+ # re validate the track map to ensure it has all required fields
134
+ for track, risk_map in current_track_map.items():
135
+ for risk in risk_map:
136
+ risk_map[risk] = TrackRiskMap.model_validate(risk_map[risk])
137
+
138
+ return current_track_map
File without changes
File without changes