python-picnic-api2 1.3.1__tar.gz → 1.3.3__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 (34) hide show
  1. python_picnic_api2-1.3.3/.envrc +1 -0
  2. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/.github/ISSUE_TEMPLATE/config.yml +2 -2
  3. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/.github/workflows/ci.yaml +3 -3
  4. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/.github/workflows/it.yaml +4 -4
  5. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/.github/workflows/release.yml +7 -7
  6. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/PKG-INFO +2 -2
  7. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/README.md +3 -1
  8. python_picnic_api2-1.3.3/flake.lock +60 -0
  9. python_picnic_api2-1.3.3/flake.nix +19 -0
  10. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/integration_tests/test_client.py +10 -11
  11. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/pyproject.toml +3 -3
  12. python_picnic_api2-1.3.3/src/python_picnic_api2/__init__.py +7 -0
  13. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/src/python_picnic_api2/client.py +131 -19
  14. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/src/python_picnic_api2/helper.py +52 -25
  15. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/src/python_picnic_api2/session.py +25 -1
  16. python_picnic_api2-1.3.3/tests/test_client.py +425 -0
  17. python_picnic_api2-1.3.3/uv.lock +234 -0
  18. python_picnic_api2-1.3.1/src/python_picnic_api2/__init__.py +0 -6
  19. python_picnic_api2-1.3.1/tests/test_client.py +0 -237
  20. python_picnic_api2-1.3.1/uv.lock +0 -324
  21. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/.devcontainer/devcontainer.json +0 -0
  22. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/.env.example +0 -0
  23. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  24. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/.github/dependabot.yml +0 -0
  25. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/.github/release.yml +0 -0
  26. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/.gitignore +0 -0
  27. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/LICENSE.md +0 -0
  28. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/README.rst +0 -0
  29. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/codecov.yml +0 -0
  30. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/integration_tests/__init__.py +0 -0
  31. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/integration_tests/test_helper.py +0 -0
  32. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/integration_tests/test_session.py +0 -0
  33. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/tests/__init__.py +0 -0
  34. {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/tests/test_session.py +0 -0
@@ -0,0 +1 @@
1
+ use flake
@@ -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!
@@ -11,16 +11,16 @@ jobs:
11
11
  strategy:
12
12
  fail-fast: false
13
13
  matrix:
14
- python-version: [3.11,3.12]
14
+ python-version: [3.13,3.14]
15
15
  uv-version: [0.6.6]
16
16
 
17
17
  runs-on: ubuntu-latest
18
18
 
19
19
  steps:
20
- - uses: actions/checkout@v4
20
+ - uses: actions/checkout@v6
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,12 +11,12 @@ jobs:
11
11
  runs-on: ubuntu-latest
12
12
 
13
13
  steps:
14
- - uses: actions/checkout@v4
14
+ - uses: actions/checkout@v6
15
15
 
16
- - name: Set up Python 3.12
17
- uses: actions/setup-python@v5
16
+ - name: Set up Python 3.13
17
+ uses: actions/setup-python@v6
18
18
  with:
19
- python-version: 3.12
19
+ python-version: 3.13
20
20
 
21
21
  - name: Install uv 0.6.6
22
22
  run: |
@@ -4,12 +4,12 @@ jobs:
4
4
  build:
5
5
  runs-on: ubuntu-latest
6
6
  steps:
7
- - uses: actions/checkout@v4
7
+ - uses: actions/checkout@v6
8
8
 
9
- - name: Set up Python 3.11
10
- uses: actions/setup-python@v5
9
+ - name: Set up Python 3.13
10
+ uses: actions/setup-python@v6
11
11
  with:
12
- python-version: 3.11
12
+ python-version: 3.13
13
13
 
14
14
  - name: Install uv 0.6.6
15
15
  run: |
@@ -26,7 +26,7 @@ jobs:
26
26
  run: uv build
27
27
 
28
28
  - name: Upload artifacts
29
- uses: actions/upload-artifact@v4
29
+ uses: actions/upload-artifact@v6
30
30
  with:
31
31
  name: release-dists
32
32
  path: dist/
@@ -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,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-picnic-api2
3
- Version: 1.3.1
3
+ Version: 1.3.3
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>
7
7
  Maintainer-email: CodeSalat <pypi@codesalat.dev>
8
8
  License: Apache-2.0
9
9
  License-File: LICENSE.md
10
- Requires-Python: >=3.11
10
+ Requires-Python: >=3.13
11
11
  Requires-Dist: requests>=2.24.0
12
12
  Requires-Dist: typing-extensions>=4.12.2
13
13
  Description-Content-Type: text/x-rst
@@ -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
 
@@ -0,0 +1,60 @@
1
+ {
2
+ "nodes": {
3
+ "flake-utils": {
4
+ "inputs": {
5
+ "systems": "systems"
6
+ },
7
+ "locked": {
8
+ "lastModified": 1731533236,
9
+ "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
10
+ "owner": "numtide",
11
+ "repo": "flake-utils",
12
+ "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
13
+ "type": "github"
14
+ },
15
+ "original": {
16
+ "owner": "numtide",
17
+ "repo": "flake-utils",
18
+ "type": "github"
19
+ }
20
+ },
21
+ "nixpkgs": {
22
+ "locked": {
23
+ "lastModified": 1757194111,
24
+ "narHash": "sha256-4I5rftBv5fUuLkGJ5TZ+LWBz8c+tZ/ZEkUJ/uB0QCOM=",
25
+ "owner": "nixos",
26
+ "repo": "nixpkgs",
27
+ "rev": "64028f19ae7ae87174d168286073ec0dc2b61395",
28
+ "type": "github"
29
+ },
30
+ "original": {
31
+ "owner": "nixos",
32
+ "repo": "nixpkgs",
33
+ "type": "github"
34
+ }
35
+ },
36
+ "root": {
37
+ "inputs": {
38
+ "flake-utils": "flake-utils",
39
+ "nixpkgs": "nixpkgs"
40
+ }
41
+ },
42
+ "systems": {
43
+ "locked": {
44
+ "lastModified": 1681028828,
45
+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
46
+ "owner": "nix-systems",
47
+ "repo": "default",
48
+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
49
+ "type": "github"
50
+ },
51
+ "original": {
52
+ "owner": "nix-systems",
53
+ "repo": "default",
54
+ "type": "github"
55
+ }
56
+ }
57
+ },
58
+ "root": "root",
59
+ "version": 7
60
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ inputs = {
3
+ nixpkgs.url = "github:nixos/nixpkgs";
4
+ flake-utils.url = "github:numtide/flake-utils";
5
+ };
6
+
7
+ outputs = { self, nixpkgs, flake-utils }:
8
+ flake-utils.lib.eachDefaultSystem (system:
9
+ let pkgs = nixpkgs.legacyPackages.${system};
10
+ in {
11
+ devShell = pkgs.mkShell { buildInputs = with pkgs; [
12
+ python313Packages.requests
13
+ python313Packages.python-lsp-ruff
14
+ python313Packages.ruff
15
+ python313Packages.typing-extensions
16
+ ];
17
+ };
18
+ });
19
+ }
@@ -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
 
@@ -144,15 +147,11 @@ def test_get_current_deliveries():
144
147
 
145
148
 
146
149
  def test_get_categories():
147
- response = picnic.get_categories()
148
- assert isinstance(response, list)
150
+ with pytest.raises(NotImplementedError):
151
+ picnic.get_categories()
149
152
 
150
153
 
151
154
  def test_print_categories(capsys):
152
- picnic.print_categories()
153
- captured = capsys.readouterr()
154
-
155
- assert isinstance(captured.out, str)
156
-
155
+ with pytest.raises(NotImplementedError):
156
+ picnic.print_categories()
157
157
 
158
- # TODO: add test for re-logging
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-picnic-api2"
3
- version = "1.3.1"
3
+ version = "1.3.3"
4
4
  description = ""
5
5
  readme = "README.rst"
6
6
  license = {text = "Apache-2.0"}
@@ -12,7 +12,7 @@ authors = [
12
12
  { name = "CodeSalat", email = "pypi@codesalat.dev"}
13
13
  ]
14
14
  urls = {homepage = "https://github.com/codesalatdev/python-picnic-api", repository = "https://github.com/codesalatdev/python-picnic-api"}
15
- requires-python = ">=3.11"
15
+ requires-python = ">=3.13"
16
16
  dependencies = [
17
17
  "requests>=2.24.0",
18
18
  "typing_extensions>=4.12.2"
@@ -21,7 +21,7 @@ dependencies = [
21
21
  [tool.ruff]
22
22
  line-length = 88
23
23
  indent-width = 4
24
- target-version = "py311"
24
+ target-version = "py313"
25
25
 
26
26
  [tool.ruff.lint]
27
27
  select = [
@@ -0,0 +1,7 @@
1
+ from .client import PicnicAPI
2
+ from .session import Picnic2FAError, Picnic2FARequired
3
+
4
+ __all__ = ["PicnicAPI", "Picnic2FAError", "Picnic2FARequired"]
5
+ __title__ = "python-picnic-api"
6
+ __version__ = "1.1.0"
7
+ __author__ = "Mike Brink"
@@ -8,16 +8,22 @@ from .helper import (
8
8
  _extract_search_results,
9
9
  _tree_generator,
10
10
  _url_generator,
11
+ find_nodes_by_content,
12
+ )
13
+ from .session import (
14
+ Picnic2FAError,
15
+ Picnic2FARequired,
16
+ PicnicAPISession,
17
+ PicnicAuthError,
11
18
  )
12
- from .session import PicnicAPISession, PicnicAuthError
13
19
 
14
20
  DEFAULT_URL = "https://storefront-prod.{}.picnicinternational.com/api/{}"
15
21
  GLOBAL_GATEWAY_URL = "https://gateway-prod.global.picnicinternational.com"
16
22
  DEFAULT_COUNTRY_CODE = "NL"
17
23
  DEFAULT_API_VERSION = "15"
18
24
  _HEADERS = {
19
- "x-picnic-agent": "30100;1.15.272-15295;",
20
- "x-picnic-did": "3C417201548B2E3B",
25
+ "x-picnic-agent": "30100;1.206.1-#15408",
26
+ "x-picnic-did": "598F770380CA54B6",
21
27
  }
22
28
 
23
29
 
@@ -59,14 +65,18 @@ class PicnicAPI:
59
65
 
60
66
  return response
61
67
 
62
- def _post(self, path: str, data=None, base_url_override=None):
68
+ def _post(
69
+ self, path: str, data=None, base_url_override=None, add_picnic_headers=False
70
+ ):
63
71
  url = (base_url_override if base_url_override else self._base_url) + path
64
- response = self.session.post(url, json=data).json()
72
+ kwargs = {"json": data}
73
+ if add_picnic_headers:
74
+ kwargs["headers"] = _HEADERS
75
+ response = self.session.post(url, **kwargs).json()
65
76
 
66
77
  if self._contains_auth_error(response):
67
78
  raise PicnicAuthError(
68
- f"Picnic authentication error: \
69
- {response['error'].get('message')}"
79
+ f"Picnic authentication error: {response['error'].get('message')}"
70
80
  )
71
81
 
72
82
  return response
@@ -79,12 +89,82 @@ class PicnicAPI:
79
89
  error_code = response.setdefault("error", {}).get("code")
80
90
  return error_code == "AUTH_ERROR" or error_code == "AUTH_INVALID_CRED"
81
91
 
92
+ @staticmethod
93
+ def _requires_2fa(response):
94
+ if not isinstance(response, dict):
95
+ return False
96
+
97
+ error_code = response.get("error", {}).get("code")
98
+ return error_code == "TWO_FACTOR_AUTHENTICATION_REQUIRED"
99
+
82
100
  def login(self, username: str, password: str):
83
101
  path = "/user/login"
84
102
  secret = md5(password.encode("utf-8")).hexdigest()
85
103
  data = {"key": username, "secret": secret, "client_id": 30100}
86
104
 
87
- return self._post(path, data)
105
+ response = self._post(path, data, add_picnic_headers=True)
106
+
107
+ if self._requires_2fa(response):
108
+ raise Picnic2FARequired(
109
+ message=response.get("error", {}).get(
110
+ "message", "Two-factor authentication required"
111
+ ),
112
+ response=response,
113
+ )
114
+
115
+ return response
116
+
117
+ def _post_2fa(self, path: str, data=None):
118
+ """POST for 2FA endpoints that may return empty (204) or JSON error bodies."""
119
+ url = self._base_url + path
120
+ response = self.session.post(url, json=data, headers=_HEADERS)
121
+
122
+ if response.status_code == 204 or not response.content:
123
+ return None
124
+
125
+ json_body = response.json()
126
+
127
+ # This should not happen because password auth is already done
128
+ # at this point, but just in case.
129
+ if self._contains_auth_error(json_body):
130
+ raise PicnicAuthError(
131
+ f"Picnic authentication error: {json_body['error'].get('message')}"
132
+ )
133
+
134
+ error = json_body.get("error", {})
135
+ if error.get("code"):
136
+ raise Picnic2FAError(
137
+ message=error.get("message", "Two-factor authentication failed"),
138
+ code=error["code"],
139
+ )
140
+
141
+ return json_body
142
+
143
+ def generate_2fa_code(self, channel: str = "SMS"):
144
+ """Request a 2FA code to be sent via the specified channel.
145
+
146
+ Args:
147
+ channel: The delivery channel ("SMS" or "EMAIL").
148
+
149
+ Raises:
150
+ Picnic2FAError: If the server returns an error (e.g. invalid channel).
151
+ """
152
+ path = "/user/2fa/generate"
153
+ data = {"channel": channel}
154
+ self._post_2fa(path, data)
155
+
156
+ def verify_2fa_code(self, code: str):
157
+ """Verify the 2FA code to complete authentication.
158
+
159
+ Args:
160
+ code: The OTP code received via SMS or email.
161
+
162
+ Raises:
163
+ Picnic2FAError: If the OTP code is invalid.
164
+ """
165
+ path = "/user/2fa/verify"
166
+ data = {"otp": code}
167
+ self._post_2fa(path, data)
88
168
 
89
169
  def logged_in(self):
90
170
  return self.session.authenticated
@@ -100,24 +180,44 @@ class PicnicAPI:
100
180
  def get_cart(self):
101
181
  return self._get("/cart")
102
182
 
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}"
183
+ def get_article(self, article_id: str, add_category=False):
184
+ path = f"/pages/product-details-page-root?id={article_id}" + \
185
+ "&show_category_action=true"
107
186
  data = self._get(path, add_picnic_headers=True)
108
187
  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"]
188
+
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:
192
+ return None
193
+
194
+ article_details = root_container[0]["pml"]["component"]["children"]
112
195
 
113
196
  if len(article_details) == 0:
114
197
  return None
115
198
 
199
+ article = {}
200
+ 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
+
116
213
  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"]))
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"]))
119
218
 
120
- article = {"name": f"{producer} {article_name}", "id": article_id}
219
+ article["name"] = f"{producer} {article_name}"
220
+ article["id"] = article_id
121
221
 
122
222
  return article
123
223
 
@@ -168,7 +268,19 @@ class PicnicAPI:
168
268
  return self.get_deliveries(data=["CURRENT"])
169
269
 
170
270
  def get_categories(self, depth: int = 0):
171
- return self._get(f"/my_store?depth={depth}")["catalog"]
271
+ raise NotImplementedError("This endpoint has been removed by picnic\
272
+ and is no longer functional.")
273
+
274
+ def get_category_by_ids(self, l2_id: int, l3_id: int):
275
+ path = "/pages/L2-category-page-root" + \
276
+ f"?category_id={l2_id}&l3_category_id={l3_id}"
277
+ 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"]}
172
284
 
173
285
  def print_categories(self, depth: int = 0):
174
286
  tree = "\n".join(_tree_generator(self.get_categories(depth=depth)))
@@ -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}]
@@ -5,6 +5,30 @@ class PicnicAuthError(Exception):
5
5
  """Indicates an error when authenticating to the Picnic API."""
6
6
 
7
7
 
8
+ class Picnic2FARequired(Exception):
9
+ """Indicates that two-factor authentication is required."""
10
+
11
+ def __init__(
12
+ self,
13
+ message: str = "Two-factor authentication required",
14
+ response: dict = None,
15
+ ):
16
+ super().__init__(message)
17
+ self.response = response or {}
18
+
19
+
20
+ class Picnic2FAError(Exception):
21
+ """Indicates an error during two-factor authentication (e.g. invalid OTP)."""
22
+
23
+ def __init__(
24
+ self,
25
+ message: str = "Two-factor authentication failed",
26
+ code: str = None,
27
+ ):
28
+ super().__init__(message)
29
+ self.code = code
30
+
31
+
8
32
  class PicnicAPISession(Session):
9
33
  AUTH_HEADER = "x-picnic-auth"
10
34
 
@@ -52,4 +76,4 @@ class PicnicAPISession(Session):
52
76
  return response
53
77
 
54
78
 
55
- __all__ = ["PicnicAuthError", "PicnicAPISession"]
79
+ __all__ = ["PicnicAuthError", "Picnic2FARequired", "Picnic2FAError", "PicnicAPISession"]