python-picnic-api2 1.3.2__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 (33) hide show
  1. python_picnic_api2-1.3.3/.envrc +1 -0
  2. {python_picnic_api2-1.3.2 → python_picnic_api2-1.3.3}/.github/workflows/ci.yaml +2 -2
  3. {python_picnic_api2-1.3.2 → python_picnic_api2-1.3.3}/.github/workflows/it.yaml +3 -3
  4. {python_picnic_api2-1.3.2 → python_picnic_api2-1.3.3}/.github/workflows/release.yml +4 -4
  5. {python_picnic_api2-1.3.2 → python_picnic_api2-1.3.3}/PKG-INFO +2 -2
  6. python_picnic_api2-1.3.3/flake.lock +60 -0
  7. python_picnic_api2-1.3.3/flake.nix +19 -0
  8. {python_picnic_api2-1.3.2 → python_picnic_api2-1.3.3}/integration_tests/test_client.py +4 -8
  9. {python_picnic_api2-1.3.2 → python_picnic_api2-1.3.3}/pyproject.toml +3 -3
  10. python_picnic_api2-1.3.3/src/python_picnic_api2/__init__.py +7 -0
  11. {python_picnic_api2-1.3.2 → python_picnic_api2-1.3.3}/src/python_picnic_api2/client.py +87 -7
  12. {python_picnic_api2-1.3.2 → python_picnic_api2-1.3.3}/src/python_picnic_api2/session.py +25 -1
  13. {python_picnic_api2-1.3.2 → python_picnic_api2-1.3.3}/tests/test_client.py +93 -23
  14. python_picnic_api2-1.3.3/uv.lock +234 -0
  15. python_picnic_api2-1.3.2/src/python_picnic_api2/__init__.py +0 -6
  16. python_picnic_api2-1.3.2/uv.lock +0 -324
  17. {python_picnic_api2-1.3.2 → python_picnic_api2-1.3.3}/.devcontainer/devcontainer.json +0 -0
  18. {python_picnic_api2-1.3.2 → python_picnic_api2-1.3.3}/.env.example +0 -0
  19. {python_picnic_api2-1.3.2 → python_picnic_api2-1.3.3}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  20. {python_picnic_api2-1.3.2 → python_picnic_api2-1.3.3}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  21. {python_picnic_api2-1.3.2 → python_picnic_api2-1.3.3}/.github/dependabot.yml +0 -0
  22. {python_picnic_api2-1.3.2 → python_picnic_api2-1.3.3}/.github/release.yml +0 -0
  23. {python_picnic_api2-1.3.2 → python_picnic_api2-1.3.3}/.gitignore +0 -0
  24. {python_picnic_api2-1.3.2 → python_picnic_api2-1.3.3}/LICENSE.md +0 -0
  25. {python_picnic_api2-1.3.2 → python_picnic_api2-1.3.3}/README.md +0 -0
  26. {python_picnic_api2-1.3.2 → python_picnic_api2-1.3.3}/README.rst +0 -0
  27. {python_picnic_api2-1.3.2 → python_picnic_api2-1.3.3}/codecov.yml +0 -0
  28. {python_picnic_api2-1.3.2 → python_picnic_api2-1.3.3}/integration_tests/__init__.py +0 -0
  29. {python_picnic_api2-1.3.2 → python_picnic_api2-1.3.3}/integration_tests/test_helper.py +0 -0
  30. {python_picnic_api2-1.3.2 → python_picnic_api2-1.3.3}/integration_tests/test_session.py +0 -0
  31. {python_picnic_api2-1.3.2 → python_picnic_api2-1.3.3}/src/python_picnic_api2/helper.py +0 -0
  32. {python_picnic_api2-1.3.2 → python_picnic_api2-1.3.3}/tests/__init__.py +0 -0
  33. {python_picnic_api2-1.3.2 → python_picnic_api2-1.3.3}/tests/test_session.py +0 -0
@@ -0,0 +1 @@
1
+ use flake
@@ -11,13 +11,13 @@ 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@v5
20
+ - uses: actions/checkout@v6
21
21
 
22
22
  - name: Set up Python ${{ matrix.python-version }}
23
23
  uses: actions/setup-python@v6
@@ -11,12 +11,12 @@ jobs:
11
11
  runs-on: ubuntu-latest
12
12
 
13
13
  steps:
14
- - uses: actions/checkout@v5
14
+ - uses: actions/checkout@v6
15
15
 
16
- - name: Set up Python 3.12
16
+ - name: Set up Python 3.13
17
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@v5
7
+ - uses: actions/checkout@v6
8
8
 
9
- - name: Set up Python 3.11
9
+ - name: Set up Python 3.13
10
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/
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-picnic-api2
3
- Version: 1.3.2
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
@@ -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
+ }
@@ -147,15 +147,11 @@ def test_get_current_deliveries():
147
147
 
148
148
 
149
149
  def test_get_categories():
150
- response = picnic.get_categories()
151
- assert isinstance(response, list)
150
+ with pytest.raises(NotImplementedError):
151
+ picnic.get_categories()
152
152
 
153
153
 
154
154
  def test_print_categories(capsys):
155
- picnic.print_categories()
156
- captured = capsys.readouterr()
157
-
158
- assert isinstance(captured.out, str)
159
-
155
+ with pytest.raises(NotImplementedError):
156
+ picnic.print_categories()
160
157
 
161
- # TODO: add test for re-logging
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-picnic-api2"
3
- version = "1.3.2"
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"
@@ -10,7 +10,12 @@ from .helper import (
10
10
  _url_generator,
11
11
  find_nodes_by_content,
12
12
  )
13
- from .session import PicnicAPISession, PicnicAuthError
13
+ from .session import (
14
+ Picnic2FAError,
15
+ Picnic2FARequired,
16
+ PicnicAPISession,
17
+ PicnicAuthError,
18
+ )
14
19
 
15
20
  DEFAULT_URL = "https://storefront-prod.{}.picnicinternational.com/api/{}"
16
21
  GLOBAL_GATEWAY_URL = "https://gateway-prod.global.picnicinternational.com"
@@ -60,14 +65,18 @@ class PicnicAPI:
60
65
 
61
66
  return response
62
67
 
63
- 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
+ ):
64
71
  url = (base_url_override if base_url_override else self._base_url) + path
65
- 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()
66
76
 
67
77
  if self._contains_auth_error(response):
68
78
  raise PicnicAuthError(
69
- f"Picnic authentication error: \
70
- {response['error'].get('message')}"
79
+ f"Picnic authentication error: {response['error'].get('message')}"
71
80
  )
72
81
 
73
82
  return response
@@ -80,12 +89,82 @@ class PicnicAPI:
80
89
  error_code = response.setdefault("error", {}).get("code")
81
90
  return error_code == "AUTH_ERROR" or error_code == "AUTH_INVALID_CRED"
82
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
+
83
100
  def login(self, username: str, password: str):
84
101
  path = "/user/login"
85
102
  secret = md5(password.encode("utf-8")).hexdigest()
86
103
  data = {"key": username, "secret": secret, "client_id": 30100}
87
104
 
88
- 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)
89
168
 
90
169
  def logged_in(self):
91
170
  return self.session.authenticated
@@ -189,7 +268,8 @@ class PicnicAPI:
189
268
  return self.get_deliveries(data=["CURRENT"])
190
269
 
191
270
  def get_categories(self, depth: int = 0):
192
- 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.")
193
273
 
194
274
  def get_category_by_ids(self, l2_id: int, l3_id: int):
195
275
  path = "/pages/L2-category-page-root" + \
@@ -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"]
@@ -5,7 +5,11 @@ import pytest
5
5
 
6
6
  from python_picnic_api2 import PicnicAPI
7
7
  from python_picnic_api2.client import DEFAULT_URL
8
- from python_picnic_api2.session import PicnicAuthError
8
+ from python_picnic_api2.session import (
9
+ Picnic2FAError,
10
+ Picnic2FARequired,
11
+ PicnicAuthError,
12
+ )
9
13
 
10
14
  PICNIC_HEADERS = {
11
15
  "x-picnic-agent": "30100;1.206.1-#15408",
@@ -15,9 +19,10 @@ PICNIC_HEADERS = {
15
19
 
16
20
  class TestClient(unittest.TestCase):
17
21
  class MockResponse:
18
- def __init__(self, json_data, status_code):
22
+ def __init__(self, json_data, status_code, content=b"data"):
19
23
  self.json_data = json_data
20
24
  self.status_code = status_code
25
+ self.content = content
21
26
 
22
27
  def json(self):
23
28
  return self.json_data
@@ -42,6 +47,7 @@ class TestClient(unittest.TestCase):
42
47
  "secret": "098f6bcd4621d373cade4e832627b4f6",
43
48
  "client_id": 30100,
44
49
  },
50
+ headers=PICNIC_HEADERS,
45
51
  )
46
52
 
47
53
  def test_login_auth_token(self):
@@ -292,27 +298,8 @@ class TestClient(unittest.TestCase):
292
298
  )
293
299
 
294
300
  def test_get_categories(self):
295
- self.session_mock().get.return_value = self.MockResponse(
296
- {
297
- "type": "MY_STORE",
298
- "catalog": [
299
- {"type": "CATEGORY", "id": "purchases", "name": "Besteld"},
300
- {"type": "CATEGORY", "id": "promotions", "name": "Acties"},
301
- ],
302
- "user": {},
303
- },
304
- 200,
305
- )
306
-
307
- categories = self.client.get_categories()
308
- self.session_mock().get.assert_called_with(
309
- self.expected_base_url + "/my_store?depth=0", headers=None
310
- )
311
-
312
- self.assertDictEqual(
313
- categories[0],
314
- {"type": "CATEGORY", "id": "purchases", "name": "Besteld"},
315
- )
301
+ with pytest.raises(NotImplementedError):
302
+ self.client.get_categories()
316
303
 
317
304
  def test_get_category_by_ids(self):
318
305
  self.session_mock().get.return_value = self.MockResponse(
@@ -353,3 +340,86 @@ class TestClient(unittest.TestCase):
353
340
 
354
341
  with self.assertRaises(PicnicAuthError):
355
342
  self.client.clear_cart()
343
+
344
+ def test_login_requires_2fa(self):
345
+ response = {
346
+ "error": {
347
+ "code": "TWO_FACTOR_AUTHENTICATION_REQUIRED",
348
+ "message": "User must verify their second factor",
349
+ "details": {},
350
+ }
351
+ }
352
+ self.session_mock().post.return_value = self.MockResponse(response, 200)
353
+
354
+ client = PicnicAPI()
355
+ with self.assertRaises(Picnic2FARequired) as ctx:
356
+ client.login("test-user", "test-password")
357
+ self.assertEqual(
358
+ str(ctx.exception), "User must verify their second factor"
359
+ )
360
+ self.assertEqual(ctx.exception.response, response)
361
+
362
+ def test_generate_2fa_code(self):
363
+ self.session_mock().post.return_value = self.MockResponse(
364
+ None, 204, content=b""
365
+ )
366
+
367
+ result = self.client.generate_2fa_code()
368
+ self.session_mock().post.assert_called_with(
369
+ self.expected_base_url + "/user/2fa/generate",
370
+ json={"channel": "SMS"},
371
+ headers=PICNIC_HEADERS,
372
+ )
373
+ self.assertIsNone(result)
374
+
375
+ def test_generate_2fa_code_email(self):
376
+ self.session_mock().post.return_value = self.MockResponse(
377
+ None, 204, content=b""
378
+ )
379
+
380
+ self.client.generate_2fa_code(channel="EMAIL")
381
+ self.session_mock().post.assert_called_with(
382
+ self.expected_base_url + "/user/2fa/generate",
383
+ json={"channel": "EMAIL"},
384
+ headers=PICNIC_HEADERS,
385
+ )
386
+
387
+ def test_verify_2fa_code_success(self):
388
+ self.session_mock().post.return_value = self.MockResponse(
389
+ None, 204, content=b""
390
+ )
391
+
392
+ result = self.client.verify_2fa_code("123456")
393
+ self.session_mock().post.assert_called_with(
394
+ self.expected_base_url + "/user/2fa/verify",
395
+ json={"otp": "123456"},
396
+ headers=PICNIC_HEADERS,
397
+ )
398
+ self.assertIsNone(result)
399
+
400
+ def test_verify_2fa_code_invalid(self):
401
+ response = {
402
+ "error": {
403
+ "code": "OTP_NOT_VALID",
404
+ "message": "Otp is not valid",
405
+ "details": {},
406
+ }
407
+ }
408
+ self.session_mock().post.return_value = self.MockResponse(response, 200)
409
+
410
+ with self.assertRaises(Picnic2FAError) as ctx:
411
+ self.client.verify_2fa_code("000000")
412
+ self.assertEqual(str(ctx.exception), "Otp is not valid")
413
+ self.assertEqual(ctx.exception.code, "OTP_NOT_VALID")
414
+
415
+ def test_2fa_auth_error(self):
416
+ response = {
417
+ "error": {
418
+ "code": "AUTH_ERROR",
419
+ "message": "Authentication failed.",
420
+ }
421
+ }
422
+ self.session_mock().post.return_value = self.MockResponse(response, 400)
423
+
424
+ with self.assertRaises(PicnicAuthError):
425
+ self.client.verify_2fa_code("123456")