snap-python 0.0.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Alexander Lukens
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,39 @@
1
+ Metadata-Version: 2.1
2
+ Name: snap-python
3
+ Version: 0.0.1
4
+ Summary:
5
+ License: MIT
6
+ Author: Alexander Lukens
7
+ Author-email: alexdlukens@gmail.com
8
+ Requires-Python: >=3.12,<4.0
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Requires-Dist: httpx (>=0.27.2,<0.28.0)
13
+ Requires-Dist: pydantic (>=2.9.2,<3.0.0)
14
+ Requires-Dist: retry (>=0.9.2,<0.10.0)
15
+ Description-Content-Type: text/markdown
16
+
17
+ # snap_python
18
+
19
+ snap_python provides an API client to interact with snapd
20
+
21
+ ## Installation
22
+
23
+ To install snap_python, you can use pip:
24
+
25
+ ```
26
+ pip install snap_python
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ Here is a simple example of how to use snap_python:
32
+
33
+ ```python
34
+ # TODO
35
+ ```
36
+
37
+ ## License
38
+
39
+ This project is licensed under the MIT License. See the LICENSE file for more details.
@@ -0,0 +1,23 @@
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
+ ## License
22
+
23
+ This project is licensed under the MIT License. See the LICENSE file for more details.
@@ -0,0 +1,26 @@
1
+ [tool.poetry]
2
+ name = "snap-python"
3
+ version = "0.0.1"
4
+ description = ""
5
+ authors = ["Alexander Lukens <alexdlukens@gmail.com>"]
6
+ license = "MIT"
7
+ readme = "README.md"
8
+
9
+ [tool.poetry.dependencies]
10
+ python = "^3.12"
11
+ httpx = "^0.27.2"
12
+ pydantic = "^2.9.2"
13
+ retry = "^0.9.2"
14
+
15
+ [tool.poetry.group.dev.dependencies]
16
+ pylxd = "^2.3.5"
17
+ pytest = "^8.3.3"
18
+ pytest-asyncio = "^0.24.0"
19
+ ruff = "^0.7.4"
20
+ pre-commit = "^4.0.1"
21
+ ipykernel = "^6.29.5"
22
+ pytest-cov = "^6.0.0"
23
+
24
+ [build-system]
25
+ requires = ["poetry-core"]
26
+ build-backend = "poetry.core.masonry.api"
File without changes
@@ -0,0 +1,86 @@
1
+ import logging
2
+
3
+ import httpx
4
+
5
+ from snap_python.components.snaps import SnapsEndpoints
6
+ from snap_python.components.store import StoreEndpoints
7
+ from snap_python.schemas.changes import ChangesResponse
8
+ from snap_python.utils import AbstractSnapsClient
9
+
10
+ SNAPD_SOCKET = "/run/snapd.socket"
11
+
12
+ logger = logging.getLogger("snap_python.client")
13
+ logger.setLevel(logging.DEBUG)
14
+
15
+
16
+ class SnapClient(AbstractSnapsClient):
17
+ def __init__(
18
+ self,
19
+ version: str = "v2",
20
+ snapd_socket_location: str = None,
21
+ tcp_location: str = None,
22
+ store_base_url: str = "https://api.snapcraft.io",
23
+ store_headers: dict[str, str] = None,
24
+ ):
25
+ if tcp_location and snapd_socket_location:
26
+ raise ValueError(
27
+ "Only one of snapd_socket_location or tcp_location can be provided."
28
+ )
29
+ if tcp_location is not None:
30
+ self._base_url = tcp_location
31
+ self._transport = httpx.AsyncHTTPTransport()
32
+ else:
33
+ self._base_url = "http://localhost"
34
+ self._transport = httpx.AsyncHTTPTransport(
35
+ uds=snapd_socket_location or SNAPD_SOCKET
36
+ )
37
+
38
+ if store_headers is None:
39
+ store_headers = {"Snap-Device-Series": "16", "X-Ubuntu-Series": "16"}
40
+
41
+ self.version = version
42
+ self.store_base_url = store_base_url
43
+ self.store_headers = store_headers
44
+ self.snapd_client = httpx.AsyncClient(transport=self._transport)
45
+ self.snaps = SnapsEndpoints(self)
46
+ self.store = StoreEndpoints(
47
+ base_url=self.store_base_url,
48
+ version=self.version,
49
+ headers=self.store_headers,
50
+ )
51
+
52
+ async def request(self, method: str, endpoint: str, **kwargs) -> httpx.Response:
53
+ response = await self.snapd_client.request(
54
+ method, f"{self._base_url}/{self.version}/{endpoint}", **kwargs
55
+ )
56
+ logger.debug(f"response: {response.content}")
57
+ response.raise_for_status()
58
+ return response
59
+
60
+ async def request_raw(self, method: str, endpoint: str, **kwargs) -> httpx.Response:
61
+ response = await self.snapd_client.request(method, endpoint, **kwargs)
62
+
63
+ response.raise_for_status()
64
+ return response
65
+
66
+ async def ping(self) -> httpx.Response:
67
+ """Reserved for human-readable content describing the service.
68
+
69
+ Returns:
70
+ httpx.Response: _description_
71
+ """
72
+ response = await self.snapd_client.get(f"{self._base_url}/")
73
+
74
+ return response
75
+
76
+ async def get_changes_by_id(self, change_id: str) -> ChangesResponse:
77
+ try:
78
+ response = await self.request("GET", f"changes/{change_id}")
79
+ except httpx.HTTPStatusError as e:
80
+ logger.warning(
81
+ f"Bad response status code for get_changes_by_id: {e.response.status_code}"
82
+ )
83
+ response = e.response
84
+ pass
85
+
86
+ return ChangesResponse.model_validate_json(response.content)
@@ -0,0 +1,92 @@
1
+ import asyncio
2
+
3
+ import httpx
4
+
5
+ from snap_python.schemas.changes import ChangesResponse
6
+ from snap_python.schemas.common import AsyncResponse
7
+ from snap_python.schemas.snaps import SnapListResponse
8
+ from snap_python.utils import AbstractSnapsClient
9
+
10
+
11
+ class SnapsEndpoints:
12
+ def __init__(self, client: AbstractSnapsClient) -> None:
13
+ self._client = client
14
+ self.common_endpoint = "snaps"
15
+
16
+ async def list_installed_snaps(self) -> SnapListResponse:
17
+ response: httpx.Response = await self._client.request(
18
+ "GET", self.common_endpoint
19
+ )
20
+
21
+ response = SnapListResponse.model_validate_json(response.content)
22
+ if response.status_code > 299:
23
+ raise httpx.HTTPStatusError(
24
+ request=response.request,
25
+ response=response,
26
+ message=f"Invalid status code in response: {response.status_code}",
27
+ )
28
+ return response
29
+
30
+ async def install_snap(
31
+ self,
32
+ snap: str,
33
+ channel: str = "stable",
34
+ classic: bool = False,
35
+ devmode: bool = False,
36
+ ignore_validation: bool = False,
37
+ jailmode: bool = False,
38
+ revision: int = None,
39
+ wait: bool = False,
40
+ ) -> AsyncResponse | ChangesResponse:
41
+ request_data = {
42
+ "action": "install",
43
+ "channel": channel,
44
+ "classic": classic,
45
+ "devmode": devmode,
46
+ "ignore_validation": ignore_validation,
47
+ "jailmode": jailmode,
48
+ }
49
+ if revision:
50
+ request_data["revision"] = revision
51
+ raw_response: httpx.Response = await self._client.request(
52
+ "POST", f"{self.common_endpoint}/{snap}", json=request_data
53
+ )
54
+ response = AsyncResponse.model_validate_json(raw_response.content)
55
+ if wait:
56
+ changes_id = response.change
57
+ while True:
58
+ changes = await self._client.get_changes_by_id(changes_id)
59
+ if changes.ready:
60
+ break
61
+ if changes.result.err:
62
+ raise Exception(f"Error in snap install: {changes.result.err}")
63
+ await asyncio.sleep(2.0)
64
+ return changes
65
+ return response
66
+
67
+ async def remove_snap(
68
+ self, snap: str, purge: bool, terminate: bool, wait: bool = False
69
+ ) -> AsyncResponse | ChangesResponse:
70
+ request_data = {
71
+ "action": "remove",
72
+ "purge": purge,
73
+ "terminate": terminate,
74
+ }
75
+
76
+ raw_response: httpx.Response = await self._client.request(
77
+ "POST", f"{self.common_endpoint}/{snap}", json=request_data
78
+ )
79
+ response = AsyncResponse.model_validate_json(raw_response.content)
80
+
81
+ if wait:
82
+ changes_id = response.change
83
+ while True:
84
+ changes = await self._client.get_changes_by_id(changes_id)
85
+ if changes.ready:
86
+ break
87
+ if changes.result.err:
88
+ raise Exception(f"Error in snap remove: {changes.result.err}")
89
+ await asyncio.sleep(2.0)
90
+ return changes
91
+
92
+ return response
@@ -0,0 +1,167 @@
1
+ import functools
2
+
3
+ import retry
4
+ from httpx import AsyncClient
5
+
6
+ from snap_python.schemas.store.categories import (
7
+ VALID_CATEGORY_FIELDS,
8
+ CategoryResponse,
9
+ SingleCategoryResponse,
10
+ )
11
+ from snap_python.schemas.store.info import VALID_SNAP_INFO_FIELDS, InfoResponse
12
+ from snap_python.schemas.store.search import (
13
+ VALID_SEARCH_CATEGORY_FIELDS,
14
+ SearchResponse,
15
+ )
16
+
17
+
18
+ class StoreEndpoints:
19
+ """Query snap store that is available at <base_url> for information about snaps.
20
+ Calls made directly with store to enable querying the non-default snap store for information.
21
+ Snapd only supports the store at snapcraft.io
22
+
23
+ Certain functionality is not available in snapd, such as querying for/by categories
24
+ """
25
+
26
+ def __init__(
27
+ self, base_url: str, version: str, headers: dict[str, str] = None
28
+ ) -> None:
29
+ self.store_client = AsyncClient()
30
+ self.store_client.request = functools.partial(
31
+ self.store_client.request, timeout=5
32
+ )
33
+ self.base_url = f"{base_url}/{version}"
34
+ self._raw_base_url = base_url
35
+ if headers is not None:
36
+ self.store_client.headers.update(headers)
37
+
38
+ async def get_snap_details(self, snap_name: str, fields: list[str] | None = None):
39
+ query = {}
40
+ if fields is not None:
41
+ if not all(field in VALID_SEARCH_CATEGORY_FIELDS for field in fields):
42
+ raise ValueError(
43
+ f"Invalid fields. Allowed fields: {VALID_SEARCH_CATEGORY_FIELDS}"
44
+ )
45
+ query["fields"] = ",".join(fields)
46
+ route = f"/api/v1/snaps/details/{snap_name}"
47
+ response = await self.store_client.get(
48
+ f"{self._raw_base_url}{route}", params=query
49
+ )
50
+ response.raise_for_status()
51
+ return response.json()
52
+
53
+ async def get_snap_info(self, snap_name: str, fields: list[str] | None = None):
54
+ query = {}
55
+ if fields is not None:
56
+ if not all(field in VALID_SNAP_INFO_FIELDS for field in fields):
57
+ raise ValueError(
58
+ f"Invalid fields. Allowed fields: {VALID_SNAP_INFO_FIELDS}"
59
+ )
60
+ query["fields"] = ",".join(fields)
61
+ route = f"/v2/snaps/info/{snap_name}"
62
+ response = await self.store_client.get(
63
+ f"{self._raw_base_url}{route}", params=query
64
+ )
65
+ response.raise_for_status()
66
+ return InfoResponse.model_validate_json(response.content)
67
+
68
+ async def get_categories(
69
+ self, type: str | None = None, fields: list[str] | None = None
70
+ ) -> CategoryResponse:
71
+ query = {}
72
+ if fields is not None:
73
+ if not all(field in VALID_CATEGORY_FIELDS for field in fields):
74
+ raise ValueError(
75
+ f"Invalid fields. Allowed fields: {VALID_CATEGORY_FIELDS}"
76
+ )
77
+ query["fields"] = ",".join(fields)
78
+ if type is not None:
79
+ query["type"] = type
80
+ route = "/snaps/categories"
81
+ response = await self.store_client.get(f"{self.base_url}{route}", params=query)
82
+ response.raise_for_status()
83
+ return CategoryResponse.model_validate_json(response.content)
84
+
85
+ async def get_category_by_name(
86
+ self, name: str, fields: list[str] | None = None
87
+ ) -> SingleCategoryResponse:
88
+ query = {}
89
+ if fields is not None:
90
+ if not all(field in VALID_CATEGORY_FIELDS for field in fields):
91
+ raise ValueError(
92
+ f"Invalid fields. Allowed fields: {VALID_CATEGORY_FIELDS}"
93
+ )
94
+ query["fields"] = ",".join(fields)
95
+
96
+ route = f"/snaps/category/{name}"
97
+ response = await self.store_client.get(f"{self.base_url}{route}", params=query)
98
+ response.raise_for_status()
99
+ return SingleCategoryResponse.model_validate_json(response.content)
100
+
101
+ async def find(
102
+ self,
103
+ query: str | None = None,
104
+ fields: str | None = None,
105
+ name_startswith: str | None = None,
106
+ architecture: str | None = None,
107
+ common_id: str | None = None,
108
+ category: str | None = None,
109
+ channel: str | None = None,
110
+ confiement: str | None = None,
111
+ featured: bool = False,
112
+ private: bool = False,
113
+ publisher: str | None = None,
114
+ headers: dict[str, str] | None = None,
115
+ ) -> SearchResponse:
116
+ """from https://api.snapcraft.io/docs/search.html#snaps_find
117
+ and https://snapcraft.io/docs/snapd-api#heading--find
118
+ """
119
+
120
+ route = "/snaps/find"
121
+ query_dict: dict = {
122
+ "q": query,
123
+ "name_startswith": name_startswith,
124
+ "architecture": architecture,
125
+ "common_id": common_id,
126
+ "category": category,
127
+ "channel": channel,
128
+ "confinement": confiement,
129
+ "featured": featured,
130
+ "private": private,
131
+ "publisher": publisher,
132
+ }
133
+ if fields is not None:
134
+ if not all(field in VALID_SEARCH_CATEGORY_FIELDS for field in fields):
135
+ bad_fields = [
136
+ field
137
+ for field in fields
138
+ if field not in VALID_SEARCH_CATEGORY_FIELDS
139
+ ]
140
+ raise ValueError(
141
+ f"Invalid fields: ({bad_fields}). Allowed fields: {VALID_SEARCH_CATEGORY_FIELDS}"
142
+ )
143
+ query_dict["fields"] = ",".join(fields)
144
+ extra_headers = headers or {}
145
+ query_dict = {
146
+ k: v for k, v in query_dict.items() if (v is not None) and (v != "")
147
+ }
148
+
149
+ # snap store expects "true" or "false" for boolean values
150
+ for key in ["featured", "private"]:
151
+ if key in query_dict:
152
+ query_dict[key] = str(query_dict[key]).lower()
153
+
154
+ response = await self.store_client.get(
155
+ f"{self.base_url}{route}", params=query_dict, headers=extra_headers
156
+ )
157
+ response.raise_for_status()
158
+ return SearchResponse.model_validate_json(response.content)
159
+
160
+ @retry.retry(Exception, tries=3, delay=2, backoff=2)
161
+ async def retry_get_snap_info(self, snap_name: str, fields: list[str]):
162
+ return await self.get_snap_info(snap_name=snap_name, fields=fields)
163
+
164
+ async def get_top_snaps_from_category(self, category: str) -> SearchResponse:
165
+ return await self.find(
166
+ category=category, fields=["title", "store-url", "summary"]
167
+ )
@@ -0,0 +1,51 @@
1
+ from pydantic import AliasChoices, AwareDatetime, BaseModel, Field
2
+
3
+ from snap_python.schemas.common import BaseErrorResult, BaseResponse
4
+
5
+
6
+ class Task(BaseModel):
7
+ id: str
8
+ kind: str
9
+ summary: str
10
+ status: str
11
+ progress: dict
12
+ spawn_time: AwareDatetime = Field(
13
+ validation_alias=AliasChoices("spawn-time", "spawn_time"),
14
+ serialization_alias="spawn-time",
15
+ )
16
+ ready_time: AwareDatetime | None = Field(
17
+ validation_alias=AliasChoices("ready-time", "ready_time"),
18
+ serialization_alias="ready-time",
19
+ default=None,
20
+ )
21
+ data: dict | None = None
22
+
23
+
24
+ class ChangesResult(BaseModel):
25
+ id: str
26
+ kind: str
27
+ summary: str
28
+ status: str
29
+ tasks: list[Task]
30
+ ready: bool
31
+ spawn_time: AwareDatetime = Field(
32
+ validation_alias=AliasChoices("spawn-time", "spawn_time"),
33
+ serialization_alias="spawn-time",
34
+ )
35
+ ready_time: AwareDatetime | None = Field(
36
+ validation_alias=AliasChoices("ready-time", "ready_time"),
37
+ serialization_alias="ready-time",
38
+ default=None,
39
+ )
40
+ err: str | None = None
41
+ data: dict | None = None
42
+
43
+
44
+ class ChangesResponse(BaseResponse):
45
+ result: ChangesResult | BaseErrorResult
46
+
47
+ @property
48
+ def ready(self) -> bool:
49
+ if isinstance(self.result, BaseErrorResult):
50
+ return False
51
+ return self.result.ready
@@ -0,0 +1,64 @@
1
+ from enum import Enum
2
+ from typing import Literal, Optional
3
+
4
+ from pydantic import AliasChoices, BaseModel, ConfigDict, Field
5
+
6
+
7
+ class BaseErrorResult(BaseModel):
8
+ message: str
9
+
10
+
11
+ class BaseResponse(BaseModel):
12
+ status_code: int = Field(
13
+ validation_alias=AliasChoices("status-code", "status_code"),
14
+ serialization_alias="status-code",
15
+ )
16
+ type: Literal["sync", "async", "error"]
17
+ status: str
18
+
19
+
20
+ class SnapBaseVersion(Enum):
21
+ core16 = "core16"
22
+ core18 = "core18"
23
+ core20 = "core20"
24
+ core22 = "core22"
25
+ core24 = "core24"
26
+ bare = "bare"
27
+
28
+
29
+ class SnapConfinement(Enum):
30
+ strict = "strict"
31
+ classic = "classic"
32
+ devmode = "devmode"
33
+ jailmode = "jailmode"
34
+
35
+
36
+ class SnapApp(BaseModel):
37
+ snap: str | None = None
38
+ name: str
39
+ desktop_file: str | None = Field(
40
+ default=None,
41
+ validation_alias=AliasChoices("desktop-file", "desktop_file"),
42
+ serialization_alias="desktop-file",
43
+ )
44
+ daemon: str | None = None
45
+ enabled: bool | None = None
46
+ active: bool | None = None
47
+ common_id: str | None = Field(
48
+ default=None,
49
+ validation_alias=AliasChoices("common-id", "common_id"),
50
+ serialization_alias="common-id",
51
+ )
52
+
53
+
54
+ class Media(BaseModel):
55
+ model_config = ConfigDict(extra="forbid", exclude_unset=True)
56
+
57
+ height: Optional[float] = None
58
+ type: str
59
+ url: str
60
+ width: Optional[float] = None
61
+
62
+
63
+ class AsyncResponse(BaseResponse):
64
+ change: str
@@ -0,0 +1,64 @@
1
+ from pydantic import AliasChoices, AwareDatetime, BaseModel, ConfigDict, Field
2
+
3
+ from snap_python.schemas.common import (
4
+ BaseResponse,
5
+ Media,
6
+ SnapApp,
7
+ SnapBaseVersion,
8
+ SnapConfinement,
9
+ )
10
+
11
+
12
+ class Snap(BaseModel):
13
+ model_config = ConfigDict(extra="allow")
14
+ apps: list[SnapApp] = Field(default_factory=list)
15
+ base: SnapBaseVersion | None = None
16
+ channel: str
17
+ confinement: SnapConfinement
18
+ contact: str
19
+ description: str
20
+ developer: str
21
+ devmode: bool
22
+ icon: str | None = None
23
+ id: str
24
+ ignore_validation: bool = Field(
25
+ validation_alias=AliasChoices("ignore-validation", "ignore_validation"),
26
+ serialization_alias="ignore-validation",
27
+ )
28
+ install_date: AwareDatetime = Field(
29
+ validation_alias=AliasChoices("install-date", "install_date"),
30
+ serialization_alias="install-date",
31
+ )
32
+ installed_size: int = Field(
33
+ validation_alias=AliasChoices("installed-size", "installed_size"),
34
+ serialization_alias="installed-size",
35
+ )
36
+ jailmode: bool
37
+ license: str = "unset"
38
+ links: dict[str, list[str]] | None = None
39
+ media: list[Media] = Field(default_factory=list)
40
+ mounted_from: str = Field(
41
+ validation_alias=AliasChoices("mounted-from", "mounted_from"),
42
+ serialization_alias="mounted-from",
43
+ )
44
+ name: str
45
+ private: bool
46
+ publisher: dict[str, str]
47
+ revision: str
48
+ status: str
49
+ summary: str
50
+ title: str | None = None
51
+ tracking_channel: str = Field(
52
+ validation_alias=AliasChoices("tracking-channel", "tracking_channel"),
53
+ serialization_alias="tracking-channel",
54
+ )
55
+ type: str
56
+ version: str
57
+ website: str | None = None
58
+
59
+
60
+ class SnapListResponse(BaseResponse):
61
+ result: list[Snap]
62
+
63
+ def __len__(self):
64
+ return len(self.result)
@@ -0,0 +1,42 @@
1
+ # generated by datamodel-codegen:
2
+ # filename: category_schema.json
3
+ # timestamp: 2024-09-17T00:34:03+00:00
4
+
5
+ from typing import List, Optional
6
+
7
+ from pydantic import BaseModel, ConfigDict
8
+
9
+ VALID_CATEGORY_FIELDS = ["name", "type", "title", "summary", "description", "media"]
10
+
11
+
12
+ class Media(BaseModel):
13
+ model_config = ConfigDict(extra="forbid", exclude_unset=True)
14
+
15
+ height: Optional[float] = None
16
+ type: str
17
+ url: str
18
+ width: Optional[float] = None
19
+
20
+
21
+ class Category(BaseModel):
22
+ model_config = ConfigDict(extra="forbid", exclude_unset=True)
23
+
24
+ description: Optional[str] = None
25
+ media: Optional[Media] = None
26
+ name: Optional[str] = None
27
+ summary: Optional[str] = None
28
+ title: Optional[str] = None
29
+ type: Optional[str] = None
30
+ featured: Optional[bool] = None
31
+
32
+
33
+ class CategoryResponse(BaseModel):
34
+ model_config = ConfigDict(extra="forbid", exclude_unset=True)
35
+
36
+ categories: Optional[List[Category]] = None
37
+
38
+
39
+ class SingleCategoryResponse(BaseModel):
40
+ model_config = ConfigDict(extra="forbid", exclude_unset=True)
41
+
42
+ category: Category