canonicalwebteam.store-api 7.1.0__tar.gz → 7.3.8__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 (15) hide show
  1. {canonicalwebteam_store_api-7.1.0 → canonicalwebteam_store_api-7.3.8}/PKG-INFO +4 -2
  2. canonicalwebteam_store_api-7.3.8/canonicalwebteam/snap_recommendations.py +74 -0
  3. {canonicalwebteam_store_api-7.1.0 → canonicalwebteam_store_api-7.3.8}/canonicalwebteam/store_api/base.py +65 -0
  4. {canonicalwebteam_store_api-7.1.0 → canonicalwebteam_store_api-7.3.8}/canonicalwebteam/store_api/dashboard.py +1 -1
  5. {canonicalwebteam_store_api-7.1.0 → canonicalwebteam_store_api-7.3.8}/canonicalwebteam/store_api/devicegw.py +10 -5
  6. {canonicalwebteam_store_api-7.1.0 → canonicalwebteam_store_api-7.3.8}/canonicalwebteam/stores_web_redis/utility.py +60 -18
  7. {canonicalwebteam_store_api-7.1.0 → canonicalwebteam_store_api-7.3.8}/pyproject.toml +1 -1
  8. {canonicalwebteam_store_api-7.1.0 → canonicalwebteam_store_api-7.3.8}/LICENSE +0 -0
  9. {canonicalwebteam_store_api-7.1.0 → canonicalwebteam_store_api-7.3.8}/README.md +0 -0
  10. {canonicalwebteam_store_api-7.1.0 → canonicalwebteam_store_api-7.3.8}/canonicalwebteam/__init__.py +0 -0
  11. {canonicalwebteam_store_api-7.1.0 → canonicalwebteam_store_api-7.3.8}/canonicalwebteam/exceptions.py +0 -0
  12. {canonicalwebteam_store_api-7.1.0 → canonicalwebteam_store_api-7.3.8}/canonicalwebteam/retry_utils.py +0 -0
  13. {canonicalwebteam_store_api-7.1.0 → canonicalwebteam_store_api-7.3.8}/canonicalwebteam/store_api/__init__.py +0 -0
  14. {canonicalwebteam_store_api-7.1.0 → canonicalwebteam_store_api-7.3.8}/canonicalwebteam/store_api/publishergw.py +0 -0
  15. {canonicalwebteam_store_api-7.1.0 → canonicalwebteam_store_api-7.3.8}/canonicalwebteam/stores_web_redis/__init__.py +0 -0
@@ -1,8 +1,9 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: canonicalwebteam.store-api
3
- Version: 7.1.0
3
+ Version: 7.3.8
4
4
  Summary:
5
5
  License: LGPL-3.0
6
+ License-File: LICENSE
6
7
  Author: Canonical Web Team
7
8
  Author-email: webteam@canonical.com
8
9
  Requires-Python: >=3.9,<4.0
@@ -13,6 +14,7 @@ Classifier: Programming Language :: Python :: 3.10
13
14
  Classifier: Programming Language :: Python :: 3.11
14
15
  Classifier: Programming Language :: Python :: 3.12
15
16
  Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
16
18
  Requires-Dist: cachetools (>=6.2.0,<7.0.0)
17
19
  Requires-Dist: coverage (>=7.0,<8.0)
18
20
  Requires-Dist: mypy (>=1.14.1,<2.0.0)
@@ -0,0 +1,74 @@
1
+ from os import getenv
2
+
3
+ from requests import Session
4
+
5
+
6
+ RECOMMENDATIONS_API_URL = getenv(
7
+ "SNAP_RECOMMENDATIONS_API_URL",
8
+ "https://recommendations.snapcraft.io/api/",
9
+ )
10
+
11
+
12
+ class SnapRecommendations:
13
+ """Helpers for Snap Recommendation Service."""
14
+
15
+ def __init__(self, session=Session()):
16
+ self.session = session
17
+ self.base_url = RECOMMENDATIONS_API_URL
18
+
19
+ def get_endpoint_url(self, endpoint: str) -> str:
20
+ return f"{self.base_url}{endpoint}"
21
+
22
+ def _process_response(self, response):
23
+ """
24
+ Process the response from the recommendation service.
25
+ Raises an HTTPError if the request was not successful.
26
+ """
27
+ response.raise_for_status()
28
+ return response.json()
29
+
30
+ def get_categories(self) -> list:
31
+ """
32
+ Return the list of recommendation categories.
33
+
34
+ Endpoint: [GET] /categories
35
+ """
36
+ url = self.get_endpoint_url("categories")
37
+ response = self.session.get(url)
38
+ return self._process_response(response)
39
+
40
+ def get_category(self, category_id: str) -> list:
41
+ """
42
+ Return ranked snaps for a given category.
43
+
44
+ Endpoint: [GET] /category/{id}
45
+ """
46
+ url = self.get_endpoint_url(f"category/{category_id}")
47
+ response = self.session.get(url)
48
+ return self._process_response(response)
49
+
50
+ def get_popular(self) -> list:
51
+ return self.get_category("popular")
52
+
53
+ def get_recent(self) -> list:
54
+ return self.get_category("recent")
55
+
56
+ def get_trending(self) -> list:
57
+ return self.get_category("trending")
58
+
59
+ def get_top_rated(self) -> list:
60
+ return self.get_category("top_rated")
61
+
62
+ def get_recently_updated(
63
+ self, page: int = 1, size: int = 10, timeout: int = 10
64
+ ) -> dict:
65
+ """
66
+ Return recently updated snaps with pagination.
67
+
68
+ Endpoint: [GET] /recently-updated?page=<page>&size=<size>
69
+ Returns: { "page": n, "size": n, "snaps": [ ... ] }
70
+ """
71
+ params = {"page": page, "size": size}
72
+ url = self.get_endpoint_url("recently-updated")
73
+ response = self.session.get(url, params=params, timeout=timeout)
74
+ return self._process_response(response)
@@ -1,3 +1,5 @@
1
+ import logging
2
+
1
3
  from canonicalwebteam.exceptions import (
2
4
  PublisherAgreementNotSigned,
3
5
  PublisherMacaroonRefreshRequired,
@@ -15,13 +17,71 @@ from canonicalwebteam.exceptions import (
15
17
  )
16
18
 
17
19
 
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def _sanitize_dict(dictionary):
24
+ result = {}
25
+ for k, v in dictionary.items():
26
+ if isinstance(v, str):
27
+ result[k] = f"<len {len(v)}>"
28
+ else:
29
+ result[k] = None
30
+ return result
31
+
32
+
33
+ def _get_request_body(request) -> str:
34
+ body = request.body
35
+ if isinstance(body, (bytes, bytearray)):
36
+ # try to decode utf-8
37
+ try:
38
+ body = body.decode()
39
+ except UnicodeError:
40
+ # we don't want to guess so we just print the body's length
41
+ body = f"<len {len(body)}>"
42
+ elif not isinstance(body, str):
43
+ # we don't know if the type will be JSON serializable
44
+ # so we just print the type
45
+ body = f"{type(body)}"
46
+ return body
47
+
48
+
49
+ def _loggable_request(request):
50
+ return {
51
+ "url": request.url,
52
+ "headers": _sanitize_dict(request.headers),
53
+ "cookies": _sanitize_dict(request._cookies),
54
+ "body": _get_request_body(request),
55
+ }
56
+
57
+
58
+ def _loggable_response(response):
59
+ return {
60
+ "status": response.status_code,
61
+ "url": response.url,
62
+ "headers": _sanitize_dict(response.headers),
63
+ "cookies": _sanitize_dict(response.cookies),
64
+ "text": response.text,
65
+ }
66
+
67
+
18
68
  class Base:
19
69
  def __init__(self, session):
20
70
  self.session = session
21
71
 
72
+ def log_detailed_error(self, response):
73
+ logger.error(
74
+ "Request failed",
75
+ extra={
76
+ "request": _loggable_request(response.request),
77
+ "response": _loggable_response(response),
78
+ },
79
+ )
80
+
22
81
  def process_response(self, response):
23
82
  # 5xx responses are not in JSON format
24
83
  if response.status_code >= 500:
84
+ self.log_detailed_error(response)
25
85
  if response.status_code == 500:
26
86
  raise StoreApiInternalError("Internal error upstream")
27
87
  elif response.status_code == 501:
@@ -42,15 +102,20 @@ class Base:
42
102
  try:
43
103
  body = response.json()
44
104
  except ValueError as decode_error:
105
+ logger.error(
106
+ "JSON decoding failed. Response text: %s", response.text
107
+ )
45
108
  api_error_exception = StoreApiResponseDecodeError(
46
109
  "JSON decoding failed: {}".format(decode_error)
47
110
  )
48
111
  raise api_error_exception
49
112
 
50
113
  if self._is_macaroon_expired(response.headers):
114
+ logger.error("Publisher macaroon refresh required")
51
115
  raise PublisherMacaroonRefreshRequired
52
116
 
53
117
  if not response.ok:
118
+ self.log_detailed_error(response)
54
119
  error_list = (
55
120
  body["error_list"]
56
121
  if "error_list" in body
@@ -10,7 +10,7 @@ from canonicalwebteam.exceptions import (
10
10
 
11
11
 
12
12
  DASHBOARD_API_URL = getenv(
13
- "DASHBOARD_API_URL", "https://dashboard.snapcraft.io/"
13
+ "SNAPSTORE_DASHBOARD_API_URL", "https://dashboard.snapcraft.io/"
14
14
  )
15
15
 
16
16
 
@@ -60,7 +60,7 @@ class DeviceGW(Base):
60
60
  Endpoint: https://api.snapcraft.io/api/v1/snaps/search
61
61
  """
62
62
  url = self.get_endpoint_url("search", api_version)
63
- headers = self.config[api_version].get("headers")
63
+ headers = self.config[api_version].get("headers", {}).copy()
64
64
 
65
65
  if "publisher:" not in search:
66
66
  search = search.replace(":", " ")
@@ -245,7 +245,10 @@ class DeviceGW(Base):
245
245
  params = {}
246
246
  if fields:
247
247
  params = {"fields": ",".join(fields)}
248
- if channel:
248
+ # Having an empty channel string tells details endpoint to
249
+ # not filter by channel. Having None and not including the channel
250
+ # parameter makes the endpoint default to latest/stable channel
251
+ if channel is not None:
249
252
  params["channel"] = channel
250
253
  headers = self.config[api_version].get("headers")
251
254
 
@@ -307,7 +310,7 @@ class DeviceGW(Base):
307
310
  )["revisions"]
308
311
 
309
312
  def get_featured_snaps(
310
- self, api_version: int = 1, fields: str = "snap_id"
313
+ self, api_version: int = 1, fields: str = "snap_id", headers: dict = {}
311
314
  ) -> dict:
312
315
  """
313
316
  Documentation: (link to spec)
@@ -315,7 +318,9 @@ class DeviceGW(Base):
315
318
  Endpoint: https://api.snapcraft.io/api/v1/snaps/search
316
319
  """
317
320
  url = self.get_endpoint_url("search")
318
- headers = self.config[api_version].get("headers")
321
+ default_headers = self.config[api_version].get("headers", {})
322
+
323
+ merged_headers = {**default_headers, **headers}
319
324
 
320
325
  params = {
321
326
  "scope": "wide",
@@ -326,5 +331,5 @@ class DeviceGW(Base):
326
331
  }
327
332
 
328
333
  return self.process_response(
329
- self.session.get(url, params=params, headers=headers)
334
+ self.session.get(url, params=params, headers=merged_headers)
330
335
  )
@@ -3,7 +3,7 @@ from cachetools import TTLCache
3
3
  import redis
4
4
  import json
5
5
  import logging
6
- from typing import Optional, Any
6
+ from typing import Optional, Any, Union
7
7
 
8
8
  logger = logging.getLogger(__name__)
9
9
 
@@ -12,6 +12,24 @@ port = os.getenv("REDIS_DB_PORT", "6379")
12
12
  password = os.getenv("REDIS_DB_PASSWORD", None)
13
13
 
14
14
 
15
+ class SafeJSONEncoder(json.JSONEncoder):
16
+ def default(self, obj):
17
+ if isinstance(obj, (bytes, bytearray)):
18
+ try:
19
+ return bytes(obj).decode("utf-8")
20
+ except UnicodeDecodeError:
21
+ return f"non-decodable-bytes ({len(obj)} bytes)"
22
+
23
+ if isinstance(obj, set):
24
+ try:
25
+ return sorted(obj)
26
+ except Exception:
27
+ return list(obj)
28
+ if isinstance(obj, tuple):
29
+ return list(obj)
30
+ return super().default(obj)
31
+
32
+
15
33
  class RedisCache:
16
34
  def __init__(self, namespace: str, maxsize: int, ttl: int = 300):
17
35
  self.namespace = namespace
@@ -29,20 +47,23 @@ class RedisCache:
29
47
  logger.warning("Redis unavailable: %s", e)
30
48
  self.redis_available = False
31
49
 
32
- def _build_key(self, base_key: str, **parts) -> str:
50
+ def _build_key(
51
+ self, key: Union[str, tuple[str, Optional[dict[str, Any]]]]
52
+ ) -> str:
53
+ base_key, parts = key if isinstance(key, tuple) else (key, {})
33
54
  key_parts = ":".join(f"{k}-{v}" for k, v in parts.items() if v)
34
- key = (
55
+ full_key = (
35
56
  f"{self.namespace}:{base_key}:{key_parts}"
36
57
  if key_parts
37
58
  else f"{self.namespace}:{base_key}"
38
59
  )
39
- return key
60
+ return full_key
40
61
 
41
62
  def _serialize(self, value: Any) -> str:
42
63
  if isinstance(value, str):
43
64
  return value
44
65
  try:
45
- return json.dumps(value)
66
+ return json.dumps(value, cls=SafeJSONEncoder)
46
67
  except (TypeError, ValueError) as e:
47
68
  logger.error("Serialization error: %s", e)
48
69
  raise
@@ -60,34 +81,55 @@ class RedisCache:
60
81
  logger.error("Deserialization error: %s", e)
61
82
  raise
62
83
 
63
- def get(self, key: str, expected_type: type = str) -> Any:
84
+ def get(
85
+ self,
86
+ key: Union[str, tuple[str, Optional[dict[str, Any]]]],
87
+ expected_type: type = str,
88
+ ) -> Any:
89
+ full_key = self._build_key(key)
64
90
  if self.redis_available:
65
- full_key = self._build_key(key)
66
91
  try:
67
- value = self.client.get(full_key)
68
- return self._deserialize(value, expected_type)
92
+ return self._deserialize(
93
+ self.client.get(full_key), expected_type
94
+ )
69
95
  except redis.RedisError as e:
70
96
  logger.error("Redis get error: %s", e)
71
- value = self.fallback.get(key)
72
- return value
97
+ else:
98
+ try:
99
+ return self._deserialize(
100
+ self.fallback[full_key], expected_type
101
+ )
102
+ except KeyError:
103
+ return None
104
+ except Exception as e:
105
+ logger.error("Fallback cache get error: %s", e)
73
106
 
74
- def set(self, key: str, value: Any, ttl=300):
107
+ def set(
108
+ self,
109
+ key: Union[str, tuple[str, Optional[dict[str, Any]]]],
110
+ value: Any,
111
+ ttl=300,
112
+ ):
113
+ full_key = self._build_key(key)
114
+ serialized = self._serialize(value)
75
115
  if self.redis_available:
76
- full_key = self._build_key(key)
77
116
  try:
78
- serialized = self._serialize(value)
79
117
  self.client.setex(full_key, ttl, serialized)
80
118
  return
81
119
  except redis.RedisError as e:
82
120
  logger.error("Redis set error: %s", e)
83
- self.fallback[key] = value
121
+ else:
122
+ try:
123
+ self.fallback[full_key] = serialized
124
+ except Exception as e:
125
+ logger.error("Fallback cache set error: %s", e)
84
126
 
85
- def delete(self, key: str):
127
+ def delete(self, key: Union[str, tuple[str, Optional[dict[str, Any]]]]):
128
+ full_key = self._build_key(key)
86
129
  if self.redis_available:
87
- full_key = self._build_key(key)
88
130
  try:
89
131
  self.client.delete(full_key)
90
132
  except redis.RedisError as e:
91
133
  logger.error("Redis delete error: %s", e)
92
134
  else:
93
- self.fallback.pop(key, None)
135
+ self.fallback.pop(full_key, None)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = 'canonicalwebteam.store-api'
3
- version = '7.1.0'
3
+ version = '7.3.8'
4
4
  description = ''
5
5
  authors = ['Canonical Web Team <webteam@canonical.com>']
6
6
  license = 'LGPL-3.0'