python-picnic-api2 1.3.3__py3-none-any.whl → 2.0.0__py3-none-any.whl

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.
@@ -1,7 +1,50 @@
1
1
  from .client import PicnicAPI
2
- from .session import Picnic2FAError, Picnic2FARequired
2
+ from .exceptions import PicnicParseError
3
+ from .models import (
4
+ Address,
5
+ Article,
6
+ Cart,
7
+ Category,
8
+ Delivery,
9
+ DeliverySlots,
10
+ DeliverySummary,
11
+ HouseholdDetails,
12
+ Order,
13
+ OrderArticle,
14
+ OrderLine,
15
+ PicnicModel,
16
+ SearchResult,
17
+ SearchResultItem,
18
+ Slot,
19
+ Subscription,
20
+ User,
21
+ )
22
+ from .session import Picnic2FAError, Picnic2FARequired, PicnicAuthError
3
23
 
4
- __all__ = ["PicnicAPI", "Picnic2FAError", "Picnic2FARequired"]
24
+ __all__ = [
25
+ "PicnicAPI",
26
+ "PicnicAuthError",
27
+ "Picnic2FAError",
28
+ "Picnic2FARequired",
29
+ "PicnicParseError",
30
+ "PicnicModel",
31
+ "Article",
32
+ "Category",
33
+ "SearchResult",
34
+ "SearchResultItem",
35
+ "User",
36
+ "Address",
37
+ "Subscription",
38
+ "HouseholdDetails",
39
+ "Cart",
40
+ "DeliverySlots",
41
+ "Delivery",
42
+ "DeliverySummary",
43
+ "Slot",
44
+ "Order",
45
+ "OrderLine",
46
+ "OrderArticle",
47
+ ]
5
48
  __title__ = "python-picnic-api"
6
- __version__ = "1.1.0"
49
+ __version__ = "2.0.0"
7
50
  __author__ = "Mike Brink"
@@ -1,14 +1,20 @@
1
- import re
2
1
  from hashlib import md5
3
2
  from urllib.parse import quote
4
3
 
5
4
  import typing_extensions
6
5
 
7
- from .helper import (
8
- _extract_search_results,
9
- _tree_generator,
10
- _url_generator,
11
- find_nodes_by_content,
6
+ from .exceptions import PicnicParseError
7
+ from .helper import _url_generator
8
+ from .models import (
9
+ Article,
10
+ Cart,
11
+ Category,
12
+ Delivery,
13
+ DeliverySlots,
14
+ DeliverySummary,
15
+ SearchResult,
16
+ User,
17
+ pml,
12
18
  )
13
19
  from .session import (
14
20
  Picnic2FAError,
@@ -18,7 +24,6 @@ from .session import (
18
24
  )
19
25
 
20
26
  DEFAULT_URL = "https://storefront-prod.{}.picnicinternational.com/api/{}"
21
- GLOBAL_GATEWAY_URL = "https://gateway-prod.global.picnicinternational.com"
22
27
  DEFAULT_COUNTRY_CODE = "NL"
23
28
  DEFAULT_API_VERSION = "15"
24
29
  _HEADERS = {
@@ -46,13 +51,6 @@ class PicnicAPI:
46
51
  if not self.session.authenticated and username and password:
47
52
  self.login(username, password)
48
53
 
49
- self.high_level_categories = None
50
-
51
- def initialize_high_level_categories(self):
52
- """Initialize high-level categories once to avoid multiple requests."""
53
- if not self.high_level_categories:
54
- self.high_level_categories = self.get_categories(depth=1)
55
-
56
54
  def _get(self, path: str, add_picnic_headers=False):
57
55
  url = self._base_url + path
58
56
 
@@ -86,7 +84,7 @@ class PicnicAPI:
86
84
  if not isinstance(response, dict):
87
85
  return False
88
86
 
89
- error_code = response.setdefault("error", {}).get("code")
87
+ error_code = response.get("error", {}).get("code")
90
88
  return error_code == "AUTH_ERROR" or error_code == "AUTH_INVALID_CRED"
91
89
 
92
90
  @staticmethod
@@ -94,8 +92,8 @@ class PicnicAPI:
94
92
  if not isinstance(response, dict):
95
93
  return False
96
94
 
97
- error_code = response.get("error", {}).get("code")
98
- return error_code == "TWO_FACTOR_AUTHENTICATION_REQUIRED"
95
+ return "second_factor_authentication_required" in response \
96
+ and response["second_factor_authentication_required"] is True
99
97
 
100
98
  def login(self, username: str, password: str):
101
99
  path = "/user/login"
@@ -169,85 +167,82 @@ class PicnicAPI:
169
167
  def logged_in(self):
170
168
  return self.session.authenticated
171
169
 
172
- def get_user(self):
173
- return self._get("/user")
170
+ def get_user(self) -> User:
171
+ return User.from_api(self._get("/user"))
174
172
 
175
- def search(self, term: str):
173
+ def search(self, term: str) -> SearchResult:
176
174
  path = f"/pages/search-page-results?search_term={quote(term)}"
177
175
  raw_results = self._get(path, add_picnic_headers=True)
178
- return _extract_search_results(raw_results)
176
+ return SearchResult.from_page(raw_results)
179
177
 
180
- def get_cart(self):
181
- return self._get("/cart")
178
+ def get_cart(self) -> Cart:
179
+ return Cart.from_api(self._get("/cart"))
182
180
 
183
- def get_article(self, article_id: str, add_category=False):
181
+ def get_article(self, article_id: str, add_category=False) -> Article | None:
184
182
  path = f"/pages/product-details-page-root?id={article_id}" + \
185
183
  "&show_category_action=true"
186
184
  data = self._get(path, add_picnic_headers=True)
187
- article_details = []
188
185
 
189
- root_container = find_nodes_by_content(
190
- data, {"id": "product-details-page-root-main-container"}, max_nodes=1)
191
- if len(root_container) == 0:
186
+ article = Article.from_page(data, article_id)
187
+ if article is None:
192
188
  return None
193
189
 
194
- article_details = root_container[0]["pml"]["component"]["children"]
195
-
196
- if len(article_details) == 0:
197
- return None
198
-
199
- article = {}
200
190
  if add_category:
201
- cat_node = find_nodes_by_content(
202
- data, {"id": "category-button"}, max_nodes=1)
203
- if len(cat_node) == 0:
204
- raise KeyError(
205
- f"Could not extract category from article with id {article_id}")
206
- category_regex = re.compile(
207
- "app\\.picnic:\\/\\/categories\\/(\\d+)\\/l2\\/(\\d+)\\/l3\\/(\\d+)")
208
- cat_ids = category_regex.match(
209
- cat_node[0]["pml"]["component"]["onPress"]["target"]).groups()
210
- article["category"] = self.get_category_by_ids(
211
- int(cat_ids[1]), int(cat_ids[2]))
212
-
213
- color_regex = re.compile(r"#\(#\d{6}\)")
214
- producer = re.sub(color_regex, "", str(
215
- article_details[1].get("markdown", "")))
216
- article_name = re.sub(color_regex, "", str(
217
- article_details[0]["markdown"]))
218
-
219
- article["name"] = f"{producer} {article_name}"
220
- article["id"] = article_id
191
+ cat_ids = Article.category_ids_from_page(data)
192
+ if cat_ids is None:
193
+ raise PicnicParseError(
194
+ f"Could not extract category from article with id {article_id}",
195
+ endpoint="product-details-page-root",
196
+ )
197
+ _, l2_id, l3_id = cat_ids
198
+ article.category = self.get_category_by_ids(l2_id, l3_id)
221
199
 
222
200
  return article
223
201
 
224
202
  def get_article_category(self, article_id: str):
203
+ """Return the raw category payload for an article.
204
+
205
+ Not modelled: this endpoint appears to have been removed by Picnic (it
206
+ returns an error object, like ``get_categories``). Kept for backwards
207
+ compatibility; returns the raw dict. Use ``get_article(id,
208
+ add_category=True)`` to resolve an article's category instead.
209
+ """
225
210
  path = "/articles/" + article_id + "/category"
226
211
  return self._get(path)
227
212
 
228
- def add_product(self, product_id: str, count: int = 1):
213
+ def add_product(self, product_id: str, count: int = 1) -> Cart:
229
214
  data = {"product_id": product_id, "count": count}
230
- return self._post("/cart/add_product", data)
215
+ return Cart.from_api(self._post("/cart/add_product", data))
231
216
 
232
- def remove_product(self, product_id: str, count: int = 1):
217
+ def remove_product(self, product_id: str, count: int = 1) -> Cart:
233
218
  data = {"product_id": product_id, "count": count}
234
- return self._post("/cart/remove_product", data)
219
+ return Cart.from_api(self._post("/cart/remove_product", data))
235
220
 
236
- def clear_cart(self):
237
- return self._post("/cart/clear")
221
+ def clear_cart(self) -> Cart:
222
+ return Cart.from_api(self._post("/cart/clear"))
238
223
 
239
- def get_delivery_slots(self):
240
- return self._get("/cart/delivery_slots")
224
+ def get_delivery_slots(self) -> DeliverySlots:
225
+ return DeliverySlots.from_api(self._get("/cart/delivery_slots"))
241
226
 
242
- def get_delivery(self, delivery_id: str):
227
+ def get_delivery(self, delivery_id: str) -> Delivery:
243
228
  path = "/deliveries/" + delivery_id
244
- return self._get(path)
229
+ return Delivery.from_api(self._get(path))
245
230
 
246
231
  def get_delivery_scenario(self, delivery_id: str):
232
+ """Return the raw driving-scenario payload for a delivery.
233
+
234
+ Not modelled: it is only populated while a delivery is en route, so
235
+ there is no stable sample to build a model against. Returns the raw dict.
236
+ """
247
237
  path = "/deliveries/" + delivery_id + "/scenario"
248
238
  return self._get(path, add_picnic_headers=True)
249
239
 
250
240
  def get_delivery_position(self, delivery_id: str):
241
+ """Return the raw driver-position payload for a delivery.
242
+
243
+ Not modelled: only populated while a delivery is en route (otherwise
244
+ empty). Returns the raw dict.
245
+ """
251
246
  path = "/deliveries/" + delivery_id + "/position"
252
247
  return self._get(path, add_picnic_headers=True)
253
248
 
@@ -258,33 +253,36 @@ class PicnicAPI:
258
253
  You can ignore this warning if you do not pass the 'summary' argument to
259
254
  this function."""
260
255
  )
261
- def get_deliveries(self, summary: bool = True, data: list = None):
256
+ def get_deliveries(
257
+ self, summary: bool = True, data: list = None
258
+ ) -> list[DeliverySummary]:
262
259
  data = [] if data is None else data
263
260
  if not summary:
264
261
  raise NotImplementedError()
265
- return self._post("/deliveries/summary", data=data)
262
+ raw = self._post("/deliveries/summary", data=data)
263
+ return [DeliverySummary.from_api(item) for item in raw]
266
264
 
267
- def get_current_deliveries(self):
265
+ def get_current_deliveries(self) -> list[DeliverySummary]:
268
266
  return self.get_deliveries(data=["CURRENT"])
269
267
 
270
268
  def get_categories(self, depth: int = 0):
271
269
  raise NotImplementedError("This endpoint has been removed by picnic\
272
270
  and is no longer functional.")
273
271
 
274
- def get_category_by_ids(self, l2_id: int, l3_id: int):
272
+ def get_category_by_ids(self, l2_id: int, l3_id: int) -> Category:
275
273
  path = "/pages/L2-category-page-root" + \
276
274
  f"?category_id={l2_id}&l3_category_id={l3_id}"
277
275
  data = self._get(path, add_picnic_headers=True)
278
- nodes = find_nodes_by_content(
279
- data, {"id": f"vertical-article-tiles-sub-header-{l3_id}"}, max_nodes=1)
280
- if len(nodes) == 0:
281
- raise KeyError("Could not find category with specified IDs")
282
- return {"l2_id": l2_id, "l3_id": l3_id,
283
- "name": nodes[0]["pml"]["component"]["accessibilityLabel"]}
284
-
285
- def print_categories(self, depth: int = 0):
286
- tree = "\n".join(_tree_generator(self.get_categories(depth=depth)))
287
- print(tree)
276
+ node = pml.find(
277
+ data, id=f"vertical-article-tiles-sub-header-{l3_id}")
278
+ if node is None:
279
+ raise PicnicParseError(
280
+ "Could not find category with specified IDs",
281
+ endpoint="L2-category-page-root",
282
+ )
283
+ return Category(
284
+ l2_id=l2_id, l3_id=l3_id,
285
+ name=pml.accessibility_label(node), raw=data)
288
286
 
289
287
  def get_article_by_gtin(self, etan: str, maxRedirects: int = 5):
290
288
  # Finds the article ID for a gtin/ean (barcode).
@@ -0,0 +1,32 @@
1
+ """Exceptions raised by the Picnic API client.
2
+
3
+ Authentication-related exceptions live in :mod:`python_picnic_api2.session` for
4
+ historical reasons and are re-exported here so that all public exceptions can be
5
+ imported from a single place.
6
+ """
7
+
8
+ from .session import Picnic2FAError, Picnic2FARequired, PicnicAuthError
9
+
10
+
11
+ class PicnicParseError(Exception):
12
+ """Raised when a Picnic PML ("layout") response cannot be parsed into a model.
13
+
14
+ The Picnic ``/pages/*`` endpoints return a UI layout tree of widgets that
15
+ varies by country and A/B test. When an expected node or field is missing
16
+ this is raised instead of a bare ``KeyError`` so callers get a clear,
17
+ actionable message about what was being looked for.
18
+ """
19
+
20
+ def __init__(self, message: str, *, endpoint: str | None = None):
21
+ if endpoint:
22
+ message = f"{message} (endpoint: {endpoint})"
23
+ super().__init__(message)
24
+ self.endpoint = endpoint
25
+
26
+
27
+ __all__ = [
28
+ "PicnicParseError",
29
+ "PicnicAuthError",
30
+ "Picnic2FARequired",
31
+ "Picnic2FAError",
32
+ ]
@@ -1,73 +1,15 @@
1
- import json
2
1
  import logging
3
- import re
4
-
5
- # prefix components:
6
- space = " "
7
- branch = "│ "
8
- # pointers:
9
- tee = "├── "
10
- last = "└── "
11
2
 
12
3
  LOGGER = logging.getLogger(__name__)
13
4
 
14
5
  IMAGE_SIZES = ["small", "medium", "regular", "large", "extra-large"]
15
6
  IMAGE_BASE_URL = "https://storefront-prod.nl.picnicinternational.com/static/images"
16
7
 
17
- SOLE_ARTICLE_ID_PATTERN = re.compile(r'"sole_article_id":"(\w+)"')
18
-
19
-
20
- def _tree_generator(response: list, prefix: str = ""):
21
- """A recursive tree generator,
22
- will yield a visual tree structure line by line
23
- with each line prefixed by the same characters
24
- """
25
- # response each get pointers that are ├── with a final └── :
26
- pointers = [tee] * (len(response) - 1) + [last]
27
- for pointer, item in zip(pointers, response, strict=False):
28
- if "name" in item: # print the item
29
- pre = ""
30
- if "unit_quantity" in item:
31
- pre = f"{item['unit_quantity']} "
32
- after = ""
33
- if "display_price" in item:
34
- after = f" €{int(item['display_price']) / 100.0:.2f}"
35
-
36
- yield prefix + pointer + pre + item["name"] + after
37
- if "items" in item: # extend the prefix and recurse:
38
- extension = branch if pointer == tee else space
39
- # i.e. space because last, └── , above so no more |
40
- yield from _tree_generator(item["items"], prefix=prefix + extension)
41
-
42
8
 
43
9
  def _url_generator(url: str, country_code: str, api_version: str):
44
10
  return url.format(country_code.lower(), api_version)
45
11
 
46
12
 
47
- def _get_category_id_from_link(category_link: str) -> str | None:
48
- pattern = r"categories/(\d+)"
49
- first_number = re.search(pattern, category_link)
50
- if first_number:
51
- result = str(first_number.group(1))
52
- return result
53
- else:
54
- return None
55
-
56
-
57
- def _get_category_name(category_link: str, categories: list) -> str | None:
58
- category_id = _get_category_id_from_link(category_link)
59
- if category_id:
60
- category = next(
61
- (item for item in categories if item["id"] == category_id), None
62
- )
63
- if category:
64
- return category["name"]
65
- else:
66
- return None
67
- else:
68
- return None
69
-
70
-
71
13
  def get_recipe_image(id: str, size="regular"):
72
14
  sizes = IMAGE_SIZES + ["1250x1250"]
73
15
  assert size in sizes, "size must be one of: " + ", ".join(sizes)
@@ -83,68 +25,3 @@ def get_image(id: str, size="regular", suffix="webp"):
83
25
 
84
26
  assert size in sizes, "size must be one of: " + ", ".join(sizes)
85
27
  return f"{IMAGE_BASE_URL}/{id}/{size}.{suffix}"
86
-
87
-
88
- def find_nodes_by_content(node, filter, max_nodes: int = 10):
89
- nodes = []
90
-
91
- if len(nodes) >= 10:
92
- return nodes
93
-
94
- def is_dict_included(node_dict, filter_dict):
95
- for k, v in filter_dict.items():
96
- if k not in node_dict:
97
- return False
98
- if isinstance(v, dict) and isinstance(node_dict[k], dict):
99
- if not is_dict_included(node_dict[k], v):
100
- return False
101
- elif node_dict[k] != v and v is not None:
102
- return False
103
- return True
104
-
105
- if is_dict_included(node, filter):
106
- nodes.append(node)
107
-
108
- if isinstance(node, dict):
109
- for _, v in node.items():
110
- if isinstance(v, dict):
111
- nodes.extend(find_nodes_by_content(v, filter, max_nodes))
112
- continue
113
- if isinstance(v, list):
114
- for item in v:
115
- if isinstance(v, dict | list):
116
- nodes.extend(find_nodes_by_content(
117
- item, filter, max_nodes))
118
- continue
119
-
120
- return nodes
121
-
122
-
123
- def _extract_search_results(raw_results, max_items: int = 10):
124
- """Extract search results from the nested dictionary structure returned by
125
- Picnic search. Number of max items can be defined to reduce excessive nested
126
- search"""
127
-
128
- LOGGER.debug(f"Extracting search results from {raw_results}")
129
-
130
- body = raw_results.get("body", {})
131
- nodes = find_nodes_by_content(body.get("child", {}), {
132
- "type": "SELLING_UNIT_TILE", "sellingUnit": {}})
133
-
134
- search_results = []
135
- for node in nodes:
136
- selling_unit = node["sellingUnit"]
137
- sole_article_ids = SOLE_ARTICLE_ID_PATTERN.findall(
138
- json.dumps(node))
139
- sole_article_id = sole_article_ids[0] if sole_article_ids else None
140
- result_entry = {
141
- **selling_unit,
142
- "sole_article_id": sole_article_id,
143
- }
144
- LOGGER.debug(f"Found article {result_entry}")
145
- search_results.append(result_entry)
146
-
147
- LOGGER.debug(
148
- f"Found {len(search_results)}/{max_items} products after extraction")
149
-
150
- return [{"items": search_results}]
@@ -0,0 +1,45 @@
1
+ """Typed data models for Picnic API responses."""
2
+
3
+ from .article import Article
4
+ from .base import PicnicModel
5
+ from .cart import Cart
6
+ from .category import Category
7
+ from .common import (
8
+ Decorator,
9
+ Eta,
10
+ Order,
11
+ OrderArticle,
12
+ OrderLine,
13
+ SelectedSlot,
14
+ Slot,
15
+ TransactionInfo,
16
+ )
17
+ from .delivery import Delivery, DeliverySlots, DeliverySummary
18
+ from .search import SearchResult, SearchResultItem
19
+ from .user import Address, HouseholdDetails, Subscription, User
20
+
21
+ __all__ = [
22
+ "PicnicModel",
23
+ "Article",
24
+ "Category",
25
+ "SearchResult",
26
+ "SearchResultItem",
27
+ # Domain-JSON models
28
+ "User",
29
+ "Address",
30
+ "Subscription",
31
+ "HouseholdDetails",
32
+ "Cart",
33
+ "DeliverySlots",
34
+ "Delivery",
35
+ "DeliverySummary",
36
+ # Shared building blocks
37
+ "Slot",
38
+ "SelectedSlot",
39
+ "Eta",
40
+ "TransactionInfo",
41
+ "Order",
42
+ "OrderLine",
43
+ "OrderArticle",
44
+ "Decorator",
45
+ ]