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.
- python_picnic_api2-1.3.3/.envrc +1 -0
- {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/.github/ISSUE_TEMPLATE/config.yml +2 -2
- {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/.github/workflows/ci.yaml +3 -3
- {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/.github/workflows/it.yaml +4 -4
- {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/.github/workflows/release.yml +7 -7
- {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/PKG-INFO +2 -2
- {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/README.md +3 -1
- python_picnic_api2-1.3.3/flake.lock +60 -0
- python_picnic_api2-1.3.3/flake.nix +19 -0
- {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/integration_tests/test_client.py +10 -11
- {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/pyproject.toml +3 -3
- python_picnic_api2-1.3.3/src/python_picnic_api2/__init__.py +7 -0
- {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/src/python_picnic_api2/client.py +131 -19
- {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/src/python_picnic_api2/helper.py +52 -25
- {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/src/python_picnic_api2/session.py +25 -1
- python_picnic_api2-1.3.3/tests/test_client.py +425 -0
- python_picnic_api2-1.3.3/uv.lock +234 -0
- python_picnic_api2-1.3.1/src/python_picnic_api2/__init__.py +0 -6
- python_picnic_api2-1.3.1/tests/test_client.py +0 -237
- python_picnic_api2-1.3.1/uv.lock +0 -324
- {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/.devcontainer/devcontainer.json +0 -0
- {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/.env.example +0 -0
- {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/.github/dependabot.yml +0 -0
- {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/.github/release.yml +0 -0
- {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/.gitignore +0 -0
- {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/LICENSE.md +0 -0
- {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/README.rst +0 -0
- {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/codecov.yml +0 -0
- {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/integration_tests/__init__.py +0 -0
- {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/integration_tests/test_helper.py +0 -0
- {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/integration_tests/test_session.py +0 -0
- {python_picnic_api2-1.3.1 → python_picnic_api2-1.3.3}/tests/__init__.py +0 -0
- {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://
|
|
11
|
-
about: Please use the Home Assistant
|
|
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.
|
|
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@
|
|
20
|
+
- uses: actions/checkout@v6
|
|
21
21
|
|
|
22
22
|
- name: Set up Python ${{ matrix.python-version }}
|
|
23
|
-
uses: actions/setup-python@
|
|
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@
|
|
14
|
+
- uses: actions/checkout@v6
|
|
15
15
|
|
|
16
|
-
- name: Set up Python 3.
|
|
17
|
-
uses: actions/setup-python@
|
|
16
|
+
- name: Set up Python 3.13
|
|
17
|
+
uses: actions/setup-python@v6
|
|
18
18
|
with:
|
|
19
|
-
python-version: 3.
|
|
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@
|
|
7
|
+
- uses: actions/checkout@v6
|
|
8
8
|
|
|
9
|
-
- name: Set up Python 3.
|
|
10
|
-
uses: actions/setup-python@
|
|
9
|
+
- name: Set up Python 3.13
|
|
10
|
+
uses: actions/setup-python@v6
|
|
11
11
|
with:
|
|
12
|
-
python-version: 3.
|
|
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@
|
|
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@
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
56
|
-
|
|
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(
|
|
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
|
-
|
|
148
|
-
|
|
150
|
+
with pytest.raises(NotImplementedError):
|
|
151
|
+
picnic.get_categories()
|
|
149
152
|
|
|
150
153
|
|
|
151
154
|
def test_print_categories(capsys):
|
|
152
|
-
|
|
153
|
-
|
|
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.
|
|
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.
|
|
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 = "
|
|
24
|
+
target-version = "py313"
|
|
25
25
|
|
|
26
26
|
[tool.ruff.lint]
|
|
27
27
|
select = [
|
|
@@ -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.
|
|
20
|
-
"x-picnic-did": "
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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(
|
|
118
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
131
|
+
nodes = find_nodes_by_content(body.get("child", {}), {
|
|
132
|
+
"type": "SELLING_UNIT_TILE", "sellingUnit": {}})
|
|
120
133
|
|
|
121
|
-
|
|
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"]
|