python-picnic-api2 1.3.1__tar.gz → 1.3.2__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 (28) hide show
  1. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.2}/.github/ISSUE_TEMPLATE/config.yml +2 -2
  2. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.2}/.github/workflows/ci.yaml +2 -2
  3. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.2}/.github/workflows/it.yaml +2 -2
  4. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.2}/.github/workflows/release.yml +4 -4
  5. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.2}/PKG-INFO +1 -1
  6. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.2}/README.md +3 -1
  7. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.2}/integration_tests/test_client.py +6 -3
  8. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.2}/pyproject.toml +1 -1
  9. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.2}/src/python_picnic_api2/client.py +44 -12
  10. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.2}/src/python_picnic_api2/helper.py +52 -25
  11. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.2}/tests/test_client.py +122 -4
  12. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.2}/uv.lock +1 -1
  13. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.2}/.devcontainer/devcontainer.json +0 -0
  14. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.2}/.env.example +0 -0
  15. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  16. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.2}/.github/dependabot.yml +0 -0
  17. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.2}/.github/release.yml +0 -0
  18. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.2}/.gitignore +0 -0
  19. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.2}/LICENSE.md +0 -0
  20. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.2}/README.rst +0 -0
  21. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.2}/codecov.yml +0 -0
  22. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.2}/integration_tests/__init__.py +0 -0
  23. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.2}/integration_tests/test_helper.py +0 -0
  24. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.2}/integration_tests/test_session.py +0 -0
  25. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.2}/src/python_picnic_api2/__init__.py +0 -0
  26. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.2}/src/python_picnic_api2/session.py +0 -0
  27. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.2}/tests/__init__.py +0 -0
  28. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.2}/tests/test_session.py +0 -0
@@ -7,8 +7,8 @@ contact_links:
7
7
  url: https://www.home-assistant.io/help
8
8
  about: We use GitHub for tracking bugs, check the Home Assistant website for resources on getting help.
9
9
  - name: Feature Request
10
- url: https://community.home-assistant.io/c/feature-requests
11
- about: Please use the Home Assistant Community Forum for making feature requests.
10
+ url: https://github.com/orgs/home-assistant/discussions/new?category=integration-enhancements&integration_name=picnic
11
+ about: Please use the Home Assistant Discussions for making feature requests.
12
12
  - name: I'm unsure where to go
13
13
  url: https://www.home-assistant.io/join-chat
14
14
  about: If you are unsure where to go, then joining our chat is recommended; Just ask!
@@ -17,10 +17,10 @@ jobs:
17
17
  runs-on: ubuntu-latest
18
18
 
19
19
  steps:
20
- - uses: actions/checkout@v4
20
+ - uses: actions/checkout@v5
21
21
 
22
22
  - name: Set up Python ${{ matrix.python-version }}
23
- uses: actions/setup-python@v5
23
+ uses: actions/setup-python@v6
24
24
  with:
25
25
  python-version: ${{ matrix.python-version }}
26
26
 
@@ -11,10 +11,10 @@ jobs:
11
11
  runs-on: ubuntu-latest
12
12
 
13
13
  steps:
14
- - uses: actions/checkout@v4
14
+ - uses: actions/checkout@v5
15
15
 
16
16
  - name: Set up Python 3.12
17
- uses: actions/setup-python@v5
17
+ uses: actions/setup-python@v6
18
18
  with:
19
19
  python-version: 3.12
20
20
 
@@ -4,10 +4,10 @@ jobs:
4
4
  build:
5
5
  runs-on: ubuntu-latest
6
6
  steps:
7
- - uses: actions/checkout@v4
7
+ - uses: actions/checkout@v5
8
8
 
9
9
  - name: Set up Python 3.11
10
- uses: actions/setup-python@v5
10
+ uses: actions/setup-python@v6
11
11
  with:
12
12
  python-version: 3.11
13
13
 
@@ -40,10 +40,10 @@ jobs:
40
40
 
41
41
  steps:
42
42
  - name: Retrieve release distributions
43
- uses: actions/download-artifact@v4
43
+ uses: actions/download-artifact@v5
44
44
  with:
45
45
  name: release-dists
46
46
  path: dist/
47
47
 
48
48
  - name: Publish release distributions to PyPI
49
- uses: pypa/gh-action-pypi-publish@v1.12.4
49
+ uses: pypa/gh-action-pypi-publish@v1.13.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-picnic-api2
3
- Version: 1.3.1
3
+ Version: 1.3.2
4
4
  Project-URL: homepage, https://github.com/codesalatdev/python-picnic-api
5
5
  Project-URL: repository, https://github.com/codesalatdev/python-picnic-api
6
6
  Author-email: Mike Brink <mjh.brink@icloud.com>, CodeSalat <pypi@codesalat.dev>
@@ -1,6 +1,8 @@
1
1
  # Python-Picnic-API
2
2
 
3
- **This library is undergoing rapid changes as is the Picnic API itself. It is mainly intended for use within Home Assistant, but there are integration tests running regularly checking for failures in features not used bu the Home Assistant integration**
3
+ **This library is undergoing rapid changes as is the Picnic API itself. It is mainly intended for use within Home Assistant, but there are integration tests running regularly checking for failures in features not used by the Home Assistant integration.**
4
+
5
+ **If you want to know why interacting with Picnic is getting harder than ever, check out their blogpost about architectural changes: https://blog.picnic.nl/adding-write-functionality-to-pages-with-self-service-apis-d09aa7dbc9c0**
4
6
 
5
7
  Fork of the Unofficial Python wrapper for the [Picnic](https://picnic.app) API. While not all API methods have been implemented yet, you'll find most of what you need to build a working application are available.
6
8
 
@@ -52,8 +52,10 @@ def test_get_article():
52
52
 
53
53
 
54
54
  def test_get_article_with_category_name():
55
- with pytest.raises(NotImplementedError):
56
- picnic.get_article("s1018620", add_category_name=True)
55
+ response = picnic.get_article("s1018620", add_category=True)
56
+ assert isinstance(response, dict)
57
+ assert "category" in response
58
+ assert response["category"]["name"] == "H-Milch"
57
59
 
58
60
 
59
61
  def test_get_article_by_gtin():
@@ -81,7 +83,8 @@ def test_add_product():
81
83
 
82
84
  assert isinstance(response, dict)
83
85
  assert "items" in response
84
- assert any(item["id"] == "s1018620" for item in response["items"][0]["items"])
86
+ assert any(
87
+ item["id"] == "s1018620" for item in response["items"][0]["items"])
85
88
  assert _get_amount(response, "s1018620") == 2
86
89
 
87
90
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-picnic-api2"
3
- version = "1.3.1"
3
+ version = "1.3.2"
4
4
  description = ""
5
5
  readme = "README.rst"
6
6
  license = {text = "Apache-2.0"}
@@ -8,6 +8,7 @@ from .helper import (
8
8
  _extract_search_results,
9
9
  _tree_generator,
10
10
  _url_generator,
11
+ find_nodes_by_content,
11
12
  )
12
13
  from .session import PicnicAPISession, PicnicAuthError
13
14
 
@@ -16,8 +17,8 @@ GLOBAL_GATEWAY_URL = "https://gateway-prod.global.picnicinternational.com"
16
17
  DEFAULT_COUNTRY_CODE = "NL"
17
18
  DEFAULT_API_VERSION = "15"
18
19
  _HEADERS = {
19
- "x-picnic-agent": "30100;1.15.272-15295;",
20
- "x-picnic-did": "3C417201548B2E3B",
20
+ "x-picnic-agent": "30100;1.206.1-#15408",
21
+ "x-picnic-did": "598F770380CA54B6",
21
22
  }
22
23
 
23
24
 
@@ -100,24 +101,44 @@ class PicnicAPI:
100
101
  def get_cart(self):
101
102
  return self._get("/cart")
102
103
 
103
- def get_article(self, article_id: str, add_category_name=False):
104
- if add_category_name:
105
- raise NotImplementedError()
106
- path = f"/pages/product-details-page-root?id={article_id}"
104
+ def get_article(self, article_id: str, add_category=False):
105
+ path = f"/pages/product-details-page-root?id={article_id}" + \
106
+ "&show_category_action=true"
107
107
  data = self._get(path, add_picnic_headers=True)
108
108
  article_details = []
109
- for block in data["body"]["child"]["child"]["children"]:
110
- if block["id"] == "product-details-page-root-main-container":
111
- article_details = block["pml"]["component"]["children"]
109
+
110
+ root_container = find_nodes_by_content(
111
+ data, {"id": "product-details-page-root-main-container"}, max_nodes=1)
112
+ if len(root_container) == 0:
113
+ return None
114
+
115
+ article_details = root_container[0]["pml"]["component"]["children"]
112
116
 
113
117
  if len(article_details) == 0:
114
118
  return None
115
119
 
120
+ article = {}
121
+ if add_category:
122
+ cat_node = find_nodes_by_content(
123
+ data, {"id": "category-button"}, max_nodes=1)
124
+ if len(cat_node) == 0:
125
+ raise KeyError(
126
+ f"Could not extract category from article with id {article_id}")
127
+ category_regex = re.compile(
128
+ "app\\.picnic:\\/\\/categories\\/(\\d+)\\/l2\\/(\\d+)\\/l3\\/(\\d+)")
129
+ cat_ids = category_regex.match(
130
+ cat_node[0]["pml"]["component"]["onPress"]["target"]).groups()
131
+ article["category"] = self.get_category_by_ids(
132
+ int(cat_ids[1]), int(cat_ids[2]))
133
+
116
134
  color_regex = re.compile(r"#\(#\d{6}\)")
117
- producer = re.sub(color_regex, "", str(article_details[1].get("markdown", "")))
118
- article_name = re.sub(color_regex, "", str(article_details[0]["markdown"]))
135
+ producer = re.sub(color_regex, "", str(
136
+ article_details[1].get("markdown", "")))
137
+ article_name = re.sub(color_regex, "", str(
138
+ article_details[0]["markdown"]))
119
139
 
120
- article = {"name": f"{producer} {article_name}", "id": article_id}
140
+ article["name"] = f"{producer} {article_name}"
141
+ article["id"] = article_id
121
142
 
122
143
  return article
123
144
 
@@ -170,6 +191,17 @@ class PicnicAPI:
170
191
  def get_categories(self, depth: int = 0):
171
192
  return self._get(f"/my_store?depth={depth}")["catalog"]
172
193
 
194
+ def get_category_by_ids(self, l2_id: int, l3_id: int):
195
+ path = "/pages/L2-category-page-root" + \
196
+ f"?category_id={l2_id}&l3_category_id={l3_id}"
197
+ data = self._get(path, add_picnic_headers=True)
198
+ nodes = find_nodes_by_content(
199
+ data, {"id": f"vertical-article-tiles-sub-header-{l3_id}"}, max_nodes=1)
200
+ if len(nodes) == 0:
201
+ raise KeyError("Could not find category with specified IDs")
202
+ return {"l2_id": l2_id, "l3_id": l3_id,
203
+ "name": nodes[0]["pml"]["component"]["accessibilityLabel"]}
204
+
173
205
  def print_categories(self, depth: int = 0):
174
206
  tree = "\n".join(_tree_generator(self.get_categories(depth=depth)))
175
207
  print(tree)
@@ -85,39 +85,66 @@ def get_image(id: str, size="regular", suffix="webp"):
85
85
  return f"{IMAGE_BASE_URL}/{id}/{size}.{suffix}"
86
86
 
87
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
+
88
123
  def _extract_search_results(raw_results, max_items: int = 10):
89
124
  """Extract search results from the nested dictionary structure returned by
90
125
  Picnic search. Number of max items can be defined to reduce excessive nested
91
126
  search"""
92
- search_results = []
93
127
 
94
128
  LOGGER.debug(f"Extracting search results from {raw_results}")
95
129
 
96
- def find_articles(node):
97
- if len(search_results) >= max_items:
98
- return
99
-
100
- content = node.get("content", {})
101
- if content.get("type") == "SELLING_UNIT_TILE" and "sellingUnit" in content:
102
- selling_unit = content["sellingUnit"]
103
- sole_article_ids = SOLE_ARTICLE_ID_PATTERN.findall(json.dumps(node))
104
- sole_article_id = sole_article_ids[0] if sole_article_ids else None
105
- result_entry = {
106
- **selling_unit,
107
- "sole_article_id": sole_article_id,
108
- }
109
- LOGGER.debug(f"Found article {result_entry}")
110
- search_results.append(result_entry)
111
-
112
- for child in node.get("children", []):
113
- find_articles(child)
114
-
115
- if "child" in node:
116
- find_articles(node.get("child"))
117
-
118
130
  body = raw_results.get("body", {})
119
- find_articles(body.get("child", {}))
131
+ nodes = find_nodes_by_content(body.get("child", {}), {
132
+ "type": "SELLING_UNIT_TILE", "sellingUnit": {}})
120
133
 
121
- LOGGER.debug(f"Found {len(search_results)}/{max_items} products after extraction")
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")
122
149
 
123
150
  return [{"items": search_results}]
@@ -8,8 +8,8 @@ from python_picnic_api2.client import DEFAULT_URL
8
8
  from python_picnic_api2.session import PicnicAuthError
9
9
 
10
10
  PICNIC_HEADERS = {
11
- "x-picnic-agent": "30100;1.15.272-15295;",
12
- "x-picnic-did": "3C417201548B2E3B",
11
+ "x-picnic-agent": "30100;1.206.1-#15408",
12
+ "x-picnic-did": "598F770380CA54B6",
13
13
  }
14
14
 
15
15
 
@@ -108,12 +108,106 @@ class TestClient(unittest.TestCase):
108
108
  )
109
109
 
110
110
  def test_get_article(self):
111
- self.client.get_article("p3f2qa")
111
+ self.session_mock().get.return_value = self.MockResponse(
112
+ {"body": {"child": {"child": {"children": [{
113
+ "id": "product-details-page-root-main-container",
114
+ "pml": {
115
+ "component": {
116
+ "children": [
117
+ {
118
+ "markdown": "#(#333333)Goede start halvarine#(#333333)",
119
+ },
120
+ {
121
+ "markdown": "Blue Band",
122
+ },
123
+
124
+ ]
125
+ }
126
+ }
127
+ }]}}}},
128
+ 200
129
+ )
130
+
131
+ article = self.client.get_article("p3f2qa")
132
+ self.session_mock().get.assert_called_with(
133
+ "https://storefront-prod.nl.picnicinternational.com/api/15/pages/product-details-page-root?id=p3f2qa&show_category_action=true",
134
+ headers=PICNIC_HEADERS,
135
+ )
136
+
137
+ self.assertEqual(
138
+ article, {'name': 'Blue Band Goede start halvarine', 'id': 'p3f2qa'})
139
+
140
+ def test_get_article_with_category(self):
141
+ self.session_mock().get.return_value = self.MockResponse(
142
+ {"body": {"child": {"child": {"children": [{
143
+ "id": "product-details-page-root-main-container",
144
+ "pml": {
145
+ "component": {
146
+ "children": [
147
+ {
148
+ "markdown": "#(#333333)Goede start halvarine#(#333333)",
149
+ },
150
+ {
151
+ "markdown": "Blue Band",
152
+ },
153
+
154
+ ]
155
+ }
156
+ }
157
+ },
158
+ {
159
+ "id": "category-button",
160
+ "pml": {"component": {"onPress": {"target": "app.picnic://categories/1000/l2/2000/l3/3000"}}}
161
+ }]}}}},
162
+ 200
163
+ )
164
+
165
+ category_patch = patch(
166
+ "python_picnic_api2.client.PicnicAPI.get_category_by_ids")
167
+ category_patch.start().return_value = {
168
+ "l2_id": 2000, "l3_id": 3000, "name": "Test"}
169
+
170
+ article = self.client.get_article("p3f2qa", True)
171
+
172
+ category_patch.stop()
112
173
  self.session_mock().get.assert_called_with(
113
- "https://storefront-prod.nl.picnicinternational.com/api/15/pages/product-details-page-root?id=p3f2qa",
174
+ "https://storefront-prod.nl.picnicinternational.com/api/15/pages/product-details-page-root?id=p3f2qa&show_category_action=true",
114
175
  headers=PICNIC_HEADERS,
115
176
  )
116
177
 
178
+ self.assertEqual(
179
+ article, {'name': 'Blue Band Goede start halvarine', 'id': 'p3f2qa',
180
+ "category": {"l2_id": 2000, "l3_id": 3000, "name": "Test"}})
181
+
182
+ def test_get_article_with_unsupported_structure(self):
183
+ self.session_mock().get.return_value = self.MockResponse(
184
+ {"body": {"child": {"child": {"children": [{
185
+ "id": "unsupported-root-container",
186
+ "pml": {
187
+ "component": {
188
+ "children": [
189
+ {
190
+ "markdown": "#(#333333)Goede start halvarine#(#333333)",
191
+ },
192
+ {
193
+ "markdown": "Blue Band",
194
+ },
195
+
196
+ ]
197
+ }
198
+ }
199
+ }]}}}},
200
+ 200
201
+ )
202
+
203
+ article = self.client.get_article("p3f2qa")
204
+ self.session_mock().get.assert_called_with(
205
+ "https://storefront-prod.nl.picnicinternational.com/api/15/pages/product-details-page-root?id=p3f2qa&show_category_action=true",
206
+ headers=PICNIC_HEADERS,
207
+ )
208
+
209
+ assert article is None
210
+
117
211
  def test_get_article_by_gtin(self):
118
212
  self.client.get_article_by_gtin("123456789")
119
213
  self.session_mock().get.assert_called_with(
@@ -220,6 +314,30 @@ class TestClient(unittest.TestCase):
220
314
  {"type": "CATEGORY", "id": "purchases", "name": "Besteld"},
221
315
  )
222
316
 
317
+ def test_get_category_by_ids(self):
318
+ self.session_mock().get.return_value = self.MockResponse(
319
+ {"children": [
320
+ {
321
+ "id": "vertical-article-tiles-sub-header-22193",
322
+ "pml": {
323
+ "component": {
324
+ "accessibilityLabel": "Halvarine"
325
+ }
326
+ }
327
+ }
328
+ ]},
329
+ 200
330
+ )
331
+
332
+ category = self.client.get_category_by_ids(1000, 22193)
333
+ self.session_mock().get.assert_called_with(
334
+ f"{self.expected_base_url}/pages/L2-category-page-root" +
335
+ "?category_id=1000&l3_category_id=22193", headers=PICNIC_HEADERS
336
+ )
337
+
338
+ self.assertDictEqual(
339
+ category, {"name": "Halvarine", "l2_id": 1000, "l3_id": 22193})
340
+
223
341
  def test_get_auth_exception(self):
224
342
  self.session_mock().get.return_value = self.MockResponse(
225
343
  {"error": {"code": "AUTH_ERROR"}}, 400
@@ -197,7 +197,7 @@ wheels = [
197
197
 
198
198
  [[package]]
199
199
  name = "python-picnic-api2"
200
- version = "1.3.1"
200
+ version = "1.3.2"
201
201
  source = { editable = "." }
202
202
  dependencies = [
203
203
  { name = "requests" },