python-picnic-api2 1.3.3__tar.gz → 2.0.0__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 (65) hide show
  1. python_picnic_api2-2.0.0/.env.example +4 -0
  2. {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/.github/workflows/ci.yaml +2 -6
  3. {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/.github/workflows/it.yaml +3 -5
  4. {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/.github/workflows/release.yml +4 -4
  5. python_picnic_api2-2.0.0/PKG-INFO +205 -0
  6. python_picnic_api2-2.0.0/README.md +190 -0
  7. {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/flake.lock +3 -3
  8. {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/flake.nix +1 -0
  9. python_picnic_api2-2.0.0/integration_tests/test_client.py +151 -0
  10. {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/integration_tests/test_session.py +0 -12
  11. {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/pyproject.toml +9 -8
  12. python_picnic_api2-2.0.0/scripts/capture_fixtures.py +161 -0
  13. python_picnic_api2-2.0.0/src/python_picnic_api2/__init__.py +50 -0
  14. {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/src/python_picnic_api2/client.py +76 -78
  15. python_picnic_api2-2.0.0/src/python_picnic_api2/exceptions.py +32 -0
  16. python_picnic_api2-2.0.0/src/python_picnic_api2/helper.py +27 -0
  17. python_picnic_api2-2.0.0/src/python_picnic_api2/models/__init__.py +45 -0
  18. python_picnic_api2-2.0.0/src/python_picnic_api2/models/article.py +262 -0
  19. python_picnic_api2-2.0.0/src/python_picnic_api2/models/base.py +42 -0
  20. python_picnic_api2-2.0.0/src/python_picnic_api2/models/cart.py +28 -0
  21. python_picnic_api2-2.0.0/src/python_picnic_api2/models/category.py +30 -0
  22. python_picnic_api2-2.0.0/src/python_picnic_api2/models/common.py +125 -0
  23. python_picnic_api2-2.0.0/src/python_picnic_api2/models/delivery.py +50 -0
  24. python_picnic_api2-2.0.0/src/python_picnic_api2/models/pml.py +173 -0
  25. python_picnic_api2-2.0.0/src/python_picnic_api2/models/search.py +65 -0
  26. python_picnic_api2-2.0.0/src/python_picnic_api2/models/user.py +58 -0
  27. python_picnic_api2-2.0.0/tests/fixtures/cart_de.json +62 -0
  28. python_picnic_api2-2.0.0/tests/fixtures/category_page_nl.json +69 -0
  29. python_picnic_api2-2.0.0/tests/fixtures/current_deliveries_de.json +29 -0
  30. python_picnic_api2-2.0.0/tests/fixtures/delivery_de.json +91 -0
  31. python_picnic_api2-2.0.0/tests/fixtures/delivery_slots_de.json +32 -0
  32. python_picnic_api2-2.0.0/tests/fixtures/product_details_bundle_de.json +389 -0
  33. python_picnic_api2-2.0.0/tests/fixtures/product_details_multipack_nl.json +226 -0
  34. python_picnic_api2-2.0.0/tests/fixtures/product_details_page_de.json +212 -0
  35. python_picnic_api2-2.0.0/tests/fixtures/product_details_page_nl.json +220 -0
  36. python_picnic_api2-2.0.0/tests/fixtures/search_page_de.json +62 -0
  37. python_picnic_api2-2.0.0/tests/fixtures/search_page_nl.json +62 -0
  38. python_picnic_api2-2.0.0/tests/fixtures/user_de.json +56 -0
  39. {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/tests/test_client.py +87 -25
  40. python_picnic_api2-2.0.0/tests/test_fixtures.py +189 -0
  41. python_picnic_api2-2.0.0/tests/test_models.py +340 -0
  42. python_picnic_api2-2.0.0/tests/test_pml.py +73 -0
  43. python_picnic_api2-2.0.0/uv.lock +364 -0
  44. python_picnic_api2-1.3.3/.env.example +0 -4
  45. python_picnic_api2-1.3.3/PKG-INFO +0 -95
  46. python_picnic_api2-1.3.3/README.md +0 -104
  47. python_picnic_api2-1.3.3/README.rst +0 -81
  48. python_picnic_api2-1.3.3/integration_tests/test_client.py +0 -157
  49. python_picnic_api2-1.3.3/src/python_picnic_api2/__init__.py +0 -7
  50. python_picnic_api2-1.3.3/src/python_picnic_api2/helper.py +0 -150
  51. python_picnic_api2-1.3.3/uv.lock +0 -234
  52. {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/.devcontainer/devcontainer.json +0 -0
  53. {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/.envrc +0 -0
  54. {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  55. {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  56. {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/.github/dependabot.yml +0 -0
  57. {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/.github/release.yml +0 -0
  58. {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/.gitignore +0 -0
  59. {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/LICENSE.md +0 -0
  60. {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/codecov.yml +0 -0
  61. {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/integration_tests/__init__.py +0 -0
  62. {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/integration_tests/test_helper.py +0 -0
  63. {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/src/python_picnic_api2/session.py +0 -0
  64. {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/tests/__init__.py +0 -0
  65. {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/tests/test_session.py +0 -0
@@ -0,0 +1,4 @@
1
+ # Used when running integration tests locally.
2
+ # Obtain AUTH_TOKEN by logging in once (including 2FA) and reading client.session.auth_token.
3
+ AUTH_TOKEN="your-auth-token-here"
4
+ COUNTRY_CODE="NL" # use either "NL" or "DE"
@@ -17,7 +17,7 @@ jobs:
17
17
  runs-on: ubuntu-latest
18
18
 
19
19
  steps:
20
- - uses: actions/checkout@v6
20
+ - uses: actions/checkout@v7
21
21
 
22
22
  - name: Set up Python ${{ matrix.python-version }}
23
23
  uses: actions/setup-python@v6
@@ -35,10 +35,6 @@ jobs:
35
35
  run: uv sync
36
36
 
37
37
  - name: Test with pytest
38
- env:
39
- USERNAME: ${{ secrets.PICNIC_USERNAME }}
40
- PASSWORD: ${{ secrets.PICNIC_PASSWORD }}
41
- COUNTRY_CODE: ${{ secrets.PICNIC_COUNTRY_CODE }}
42
38
  run: |
43
39
  uv run pytest tests/ --cov --cov-report=xml
44
40
 
@@ -47,7 +43,7 @@ jobs:
47
43
  uv run ruff check --output-format=github --ignore FIX
48
44
 
49
45
  - name: Upload coverage reports to Codecov
50
- uses: codecov/codecov-action@v5
46
+ uses: codecov/codecov-action@v7
51
47
  with:
52
48
  token: ${{ secrets.CODECOV_TOKEN }}
53
49
 
@@ -11,7 +11,7 @@ jobs:
11
11
  runs-on: ubuntu-latest
12
12
 
13
13
  steps:
14
- - uses: actions/checkout@v6
14
+ - uses: actions/checkout@v7
15
15
 
16
16
  - name: Set up Python 3.13
17
17
  uses: actions/setup-python@v6
@@ -22,7 +22,7 @@ jobs:
22
22
  run: |
23
23
  python -m ensurepip
24
24
  python -m pip install --upgrade pip
25
- python -m pip install uv==0.6.6
25
+ python -m pip install uv==0.6.6
26
26
 
27
27
  - name: Install dependencies
28
28
  shell: bash
@@ -30,9 +30,7 @@ jobs:
30
30
 
31
31
  - name: Test with pytest
32
32
  env:
33
- USERNAME: ${{ secrets.PICNIC_USERNAME }}
34
- PASSWORD: ${{ secrets.PICNIC_PASSWORD }}
33
+ AUTH_TOKEN: ${{ secrets.PICNIC_AUTH_TOKEN }}
35
34
  COUNTRY_CODE: ${{ secrets.PICNIC_COUNTRY_CODE }}
36
35
  run: |
37
36
  uv run pytest -v integration_tests
38
-
@@ -4,7 +4,7 @@ jobs:
4
4
  build:
5
5
  runs-on: ubuntu-latest
6
6
  steps:
7
- - uses: actions/checkout@v6
7
+ - uses: actions/checkout@v7
8
8
 
9
9
  - name: Set up Python 3.13
10
10
  uses: actions/setup-python@v6
@@ -26,7 +26,7 @@ jobs:
26
26
  run: uv build
27
27
 
28
28
  - name: Upload artifacts
29
- uses: actions/upload-artifact@v6
29
+ uses: actions/upload-artifact@v7
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@v5
43
+ uses: actions/download-artifact@v8
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.13.0
49
+ uses: pypa/gh-action-pypi-publish@v1.14.0
@@ -0,0 +1,205 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-picnic-api2
3
+ Version: 2.0.0
4
+ Project-URL: homepage, https://github.com/codesalatdev/python-picnic-api
5
+ Project-URL: repository, https://github.com/codesalatdev/python-picnic-api
6
+ Author-email: Mike Brink <mjh.brink@icloud.com>, CodeSalat <pypi@codesalat.dev>
7
+ Maintainer-email: CodeSalat <pypi@codesalat.dev>
8
+ License: Apache-2.0
9
+ License-File: LICENSE.md
10
+ Requires-Python: >=3.13
11
+ Requires-Dist: pydantic>=2.7
12
+ Requires-Dist: requests>=2.34.2
13
+ Requires-Dist: typing-extensions>=4.16.0
14
+ Description-Content-Type: text/markdown
15
+
16
+ # Python-Picnic-API
17
+
18
+ **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.**
19
+
20
+ **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](https://jobs.picnic.app/en/blogs/adding-write-functionality-to-pages-with-self-service-apis)**
21
+
22
+ 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 is available.
23
+
24
+ This library is not affiliated with Picnic and retrieves data from the endpoints of the mobile application. **Use at your own risk.**
25
+
26
+ ## Credits
27
+
28
+ A big thanks to @MikeBrink for building the first versions of this library.
29
+
30
+ @maartenpaul and @thijmen-j continously provided fixes that were then merged into this fork.
31
+
32
+ ## Getting started
33
+
34
+ The easiest way to install is directly from pip:
35
+
36
+ ```bash
37
+ $ pip install python-picnic-api2
38
+ ```
39
+
40
+ Then create a new instance of `PicnicAPI` and login using your credentials:
41
+
42
+ ```python
43
+ from python_picnic_api2 import PicnicAPI
44
+
45
+ picnic = PicnicAPI(username='username', password='password', country_code="NL")
46
+ ```
47
+
48
+ The country_code parameter defaults to `NL`, but you have to change it if you live in a different country than the Netherlands (ISO 3166-1 Alpha-2). This obviously only works for countries that picnic services.
49
+
50
+ ### Two-factor authentication (2FA)
51
+
52
+ For new logins, Picnic may require two-factor authentication. When 2FA is required, logging in raises a `Picnic2FARequired` exception. You then need to request a code and verify it:
53
+
54
+ ```python
55
+ from python_picnic_api2 import PicnicAPI, Picnic2FARequired, Picnic2FAError
56
+
57
+ picnic = PicnicAPI(country_code="NL")
58
+
59
+ try:
60
+ picnic.login(username='username', password='password')
61
+ except Picnic2FARequired:
62
+ # Request a code via SMS or EMAIL
63
+ picnic.generate_2fa_code(channel="SMS")
64
+
65
+ code = input("Enter the code you received: ")
66
+ picnic.verify_2fa_code(code)
67
+ ```
68
+
69
+ After successful verification, the session is authenticated and you can use the API normally. If the code is invalid, `Picnic2FAError` is raised.
70
+
71
+ ## Typed models (2.x)
72
+
73
+ As of 2.x the API returns typed [pydantic](https://docs.pydantic.dev) models
74
+ instead of raw dicts. This covers both the "page" endpoints Picnic serves as a
75
+ layout tree of widgets (`search`, `get_article`, `get_category_by_ids`) and the
76
+ domain-JSON endpoints (`get_user`, `get_cart`, `get_delivery_slots`,
77
+ `get_delivery`, `get_deliveries` / `get_current_deliveries`, and the cart-mutation
78
+ methods). Every model exposes `.raw` with the original, untouched payload as an
79
+ escape hatch for data that isn't modelled yet, and `.model_dump()` for a
80
+ plain-dict view.
81
+
82
+ A couple of endpoints still return raw dicts: `get_delivery_scenario` and
83
+ `get_delivery_position` (only populated while a delivery is en route, so there is
84
+ no stable shape to model), and `get_article_category` (appears to have been
85
+ removed by Picnic — use `get_article(id, add_category=True)` instead).
86
+
87
+ If you are upgrading from 1.x, see the [migration notes](#migrating-from-1x-to-20).
88
+
89
+ ## Usage
90
+
91
+ ### Searching for an article
92
+
93
+ ```python
94
+ result = picnic.search('coffee') # -> SearchResult
95
+ result.items[0].name # 'Lavazza Caffè Crema e Aroma Bohnen'
96
+ result.items[0].display_price # 1799 (price shown on the tile, in cents)
97
+ result.items[0].raw # original tile payload
98
+ ```
99
+
100
+ Search tiles only carry `display_price` (the price shown, in integer cents) — the
101
+ raw payload has no separate `price` key — so read `display_price`.
102
+
103
+ ### Get article by ID
104
+
105
+ ```python
106
+ article = picnic.get_article("s1019822") # -> Article | None
107
+ article.id # 's1019822'
108
+ article.name # 'Lavazza Caffè Crema e Aroma Bohnen'
109
+ article.product_name # 'Caffè Crema e Aroma Bohnen'
110
+ article.producer # 'Lavazza' (None for unbranded produce)
111
+ article.unit_quantity # '1kg'
112
+ article.price_per_unit # '€17.99/kg' (comparative price, may be None)
113
+ article.price # 1799 (current price, integer cents)
114
+ article.original_price # 2249 when on sale, else None
115
+ article.image_id # hero product image id
116
+ article.description # product description (markdown)
117
+ article.highlights # ['Lange **haltbar**', ...] feature bullets
118
+ article.is_bundle # True for multipacks with other pack sizes
119
+ article.bundle_variant_ids # ['s1018999', ...] other pack-size article ids
120
+
121
+ # Optionally resolve the article's category (an extra request):
122
+ article = picnic.get_article("s1019822", add_category=True)
123
+ article.category.name # 'Koffiebonen'
124
+ ```
125
+
126
+ ### Get article by GTIN (EAN)
127
+
128
+ ```python
129
+ article = picnic.get_article_by_gtin("8000070025400") # -> Article | None
130
+ article.name # 'Lavazza Caffè Crema e Aroma Bohnen'
131
+ ```
132
+
133
+ ### Get the user
134
+
135
+ ```python
136
+ user = picnic.get_user() # -> User
137
+ user.contact_email # 'you@example.com'
138
+ user.address.city # 'Amsterdam'
139
+ user.total_deliveries # 25
140
+ ```
141
+
142
+ ### Check cart
143
+
144
+ ```python
145
+ cart = picnic.get_cart() # -> Cart
146
+ cart.total_count # 3
147
+ cart.total_price # 1234 (integer cents)
148
+ cart.items[0].items[0].name # 'Lavazza Caffè Crema e Aroma Bohnen'
149
+ cart.raw # original cart payload
150
+ ```
151
+
152
+ ### Manipulating your cart
153
+
154
+ All of these methods return the updated `Cart`.
155
+
156
+ ```python
157
+ # Add product with ID "s1019822" 2x
158
+ picnic.add_product("s1019822", 2)
159
+
160
+ # Remove product with ID "s1019822" 1x
161
+ picnic.remove_product("s1019822")
162
+
163
+ # Clear your cart
164
+ picnic.clear_cart()
165
+ ```
166
+
167
+ ### See upcoming deliveries
168
+
169
+ ```python
170
+ deliveries = picnic.get_current_deliveries() # -> list[DeliverySummary]
171
+ deliveries[0].delivery_id
172
+ deliveries[0].status # 'CURRENT'
173
+ deliveries[0].slot.window_start # '2025-04-29T17:15:00.000+02:00'
174
+
175
+ # Full detail (order lines, articles, payment info) for one delivery:
176
+ delivery = picnic.get_delivery(deliveries[0].delivery_id) # -> Delivery
177
+ delivery.orders[0].items[0].items[0].name
178
+ ```
179
+
180
+ ### See available delivery slots
181
+
182
+ ```python
183
+ slots = picnic.get_delivery_slots() # -> DeliverySlots
184
+ slots.delivery_slots[0].window_start # '2025-04-29T17:15:00.000+02:00'
185
+ slots.selected_slot.slot_id
186
+ ```
187
+
188
+ ## Migrating from 1.x to 2.0
189
+
190
+ - `search()` now returns a `SearchResult` (`.items` is a list of `SearchResultItem`)
191
+ instead of `[{"items": [...]}]`.
192
+ - `get_article()` / `get_article_by_gtin()` now return an `Article` (or `None`)
193
+ instead of a `dict`; use `.id` / `.name` / `.category` instead of key access.
194
+ - `get_category_by_ids()` now returns a `Category` instead of a `dict`.
195
+ - Missing/unexpected PML nodes now raise `PicnicParseError` (from
196
+ `python_picnic_api2`) instead of a bare `KeyError`.
197
+ - The domain-JSON methods now return typed models instead of raw dicts:
198
+ `get_user()` → `User`, `get_cart()` / `add_product()` / `remove_product()` /
199
+ `clear_cart()` → `Cart`, `get_delivery_slots()` → `DeliverySlots`,
200
+ `get_delivery()` → `Delivery`, and `get_deliveries()` /
201
+ `get_current_deliveries()` → `list[DeliverySummary]`. Use attribute access
202
+ (`cart.items`, `user.contact_email`) instead of `["items"]` / `["contact_email"]`.
203
+ - `get_delivery_scenario()`, `get_delivery_position()` and `get_article_category()`
204
+ still return raw dicts (see [Typed models](#typed-models-2x)).
205
+ - Any field you need that isn't modelled yet is available on `model.raw`.
@@ -0,0 +1,190 @@
1
+ # Python-Picnic-API
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 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](https://jobs.picnic.app/en/blogs/adding-write-functionality-to-pages-with-self-service-apis)**
6
+
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 is available.
8
+
9
+ This library is not affiliated with Picnic and retrieves data from the endpoints of the mobile application. **Use at your own risk.**
10
+
11
+ ## Credits
12
+
13
+ A big thanks to @MikeBrink for building the first versions of this library.
14
+
15
+ @maartenpaul and @thijmen-j continously provided fixes that were then merged into this fork.
16
+
17
+ ## Getting started
18
+
19
+ The easiest way to install is directly from pip:
20
+
21
+ ```bash
22
+ $ pip install python-picnic-api2
23
+ ```
24
+
25
+ Then create a new instance of `PicnicAPI` and login using your credentials:
26
+
27
+ ```python
28
+ from python_picnic_api2 import PicnicAPI
29
+
30
+ picnic = PicnicAPI(username='username', password='password', country_code="NL")
31
+ ```
32
+
33
+ The country_code parameter defaults to `NL`, but you have to change it if you live in a different country than the Netherlands (ISO 3166-1 Alpha-2). This obviously only works for countries that picnic services.
34
+
35
+ ### Two-factor authentication (2FA)
36
+
37
+ For new logins, Picnic may require two-factor authentication. When 2FA is required, logging in raises a `Picnic2FARequired` exception. You then need to request a code and verify it:
38
+
39
+ ```python
40
+ from python_picnic_api2 import PicnicAPI, Picnic2FARequired, Picnic2FAError
41
+
42
+ picnic = PicnicAPI(country_code="NL")
43
+
44
+ try:
45
+ picnic.login(username='username', password='password')
46
+ except Picnic2FARequired:
47
+ # Request a code via SMS or EMAIL
48
+ picnic.generate_2fa_code(channel="SMS")
49
+
50
+ code = input("Enter the code you received: ")
51
+ picnic.verify_2fa_code(code)
52
+ ```
53
+
54
+ After successful verification, the session is authenticated and you can use the API normally. If the code is invalid, `Picnic2FAError` is raised.
55
+
56
+ ## Typed models (2.x)
57
+
58
+ As of 2.x the API returns typed [pydantic](https://docs.pydantic.dev) models
59
+ instead of raw dicts. This covers both the "page" endpoints Picnic serves as a
60
+ layout tree of widgets (`search`, `get_article`, `get_category_by_ids`) and the
61
+ domain-JSON endpoints (`get_user`, `get_cart`, `get_delivery_slots`,
62
+ `get_delivery`, `get_deliveries` / `get_current_deliveries`, and the cart-mutation
63
+ methods). Every model exposes `.raw` with the original, untouched payload as an
64
+ escape hatch for data that isn't modelled yet, and `.model_dump()` for a
65
+ plain-dict view.
66
+
67
+ A couple of endpoints still return raw dicts: `get_delivery_scenario` and
68
+ `get_delivery_position` (only populated while a delivery is en route, so there is
69
+ no stable shape to model), and `get_article_category` (appears to have been
70
+ removed by Picnic — use `get_article(id, add_category=True)` instead).
71
+
72
+ If you are upgrading from 1.x, see the [migration notes](#migrating-from-1x-to-20).
73
+
74
+ ## Usage
75
+
76
+ ### Searching for an article
77
+
78
+ ```python
79
+ result = picnic.search('coffee') # -> SearchResult
80
+ result.items[0].name # 'Lavazza Caffè Crema e Aroma Bohnen'
81
+ result.items[0].display_price # 1799 (price shown on the tile, in cents)
82
+ result.items[0].raw # original tile payload
83
+ ```
84
+
85
+ Search tiles only carry `display_price` (the price shown, in integer cents) — the
86
+ raw payload has no separate `price` key — so read `display_price`.
87
+
88
+ ### Get article by ID
89
+
90
+ ```python
91
+ article = picnic.get_article("s1019822") # -> Article | None
92
+ article.id # 's1019822'
93
+ article.name # 'Lavazza Caffè Crema e Aroma Bohnen'
94
+ article.product_name # 'Caffè Crema e Aroma Bohnen'
95
+ article.producer # 'Lavazza' (None for unbranded produce)
96
+ article.unit_quantity # '1kg'
97
+ article.price_per_unit # '€17.99/kg' (comparative price, may be None)
98
+ article.price # 1799 (current price, integer cents)
99
+ article.original_price # 2249 when on sale, else None
100
+ article.image_id # hero product image id
101
+ article.description # product description (markdown)
102
+ article.highlights # ['Lange **haltbar**', ...] feature bullets
103
+ article.is_bundle # True for multipacks with other pack sizes
104
+ article.bundle_variant_ids # ['s1018999', ...] other pack-size article ids
105
+
106
+ # Optionally resolve the article's category (an extra request):
107
+ article = picnic.get_article("s1019822", add_category=True)
108
+ article.category.name # 'Koffiebonen'
109
+ ```
110
+
111
+ ### Get article by GTIN (EAN)
112
+
113
+ ```python
114
+ article = picnic.get_article_by_gtin("8000070025400") # -> Article | None
115
+ article.name # 'Lavazza Caffè Crema e Aroma Bohnen'
116
+ ```
117
+
118
+ ### Get the user
119
+
120
+ ```python
121
+ user = picnic.get_user() # -> User
122
+ user.contact_email # 'you@example.com'
123
+ user.address.city # 'Amsterdam'
124
+ user.total_deliveries # 25
125
+ ```
126
+
127
+ ### Check cart
128
+
129
+ ```python
130
+ cart = picnic.get_cart() # -> Cart
131
+ cart.total_count # 3
132
+ cart.total_price # 1234 (integer cents)
133
+ cart.items[0].items[0].name # 'Lavazza Caffè Crema e Aroma Bohnen'
134
+ cart.raw # original cart payload
135
+ ```
136
+
137
+ ### Manipulating your cart
138
+
139
+ All of these methods return the updated `Cart`.
140
+
141
+ ```python
142
+ # Add product with ID "s1019822" 2x
143
+ picnic.add_product("s1019822", 2)
144
+
145
+ # Remove product with ID "s1019822" 1x
146
+ picnic.remove_product("s1019822")
147
+
148
+ # Clear your cart
149
+ picnic.clear_cart()
150
+ ```
151
+
152
+ ### See upcoming deliveries
153
+
154
+ ```python
155
+ deliveries = picnic.get_current_deliveries() # -> list[DeliverySummary]
156
+ deliveries[0].delivery_id
157
+ deliveries[0].status # 'CURRENT'
158
+ deliveries[0].slot.window_start # '2025-04-29T17:15:00.000+02:00'
159
+
160
+ # Full detail (order lines, articles, payment info) for one delivery:
161
+ delivery = picnic.get_delivery(deliveries[0].delivery_id) # -> Delivery
162
+ delivery.orders[0].items[0].items[0].name
163
+ ```
164
+
165
+ ### See available delivery slots
166
+
167
+ ```python
168
+ slots = picnic.get_delivery_slots() # -> DeliverySlots
169
+ slots.delivery_slots[0].window_start # '2025-04-29T17:15:00.000+02:00'
170
+ slots.selected_slot.slot_id
171
+ ```
172
+
173
+ ## Migrating from 1.x to 2.0
174
+
175
+ - `search()` now returns a `SearchResult` (`.items` is a list of `SearchResultItem`)
176
+ instead of `[{"items": [...]}]`.
177
+ - `get_article()` / `get_article_by_gtin()` now return an `Article` (or `None`)
178
+ instead of a `dict`; use `.id` / `.name` / `.category` instead of key access.
179
+ - `get_category_by_ids()` now returns a `Category` instead of a `dict`.
180
+ - Missing/unexpected PML nodes now raise `PicnicParseError` (from
181
+ `python_picnic_api2`) instead of a bare `KeyError`.
182
+ - The domain-JSON methods now return typed models instead of raw dicts:
183
+ `get_user()` → `User`, `get_cart()` / `add_product()` / `remove_product()` /
184
+ `clear_cart()` → `Cart`, `get_delivery_slots()` → `DeliverySlots`,
185
+ `get_delivery()` → `Delivery`, and `get_deliveries()` /
186
+ `get_current_deliveries()` → `list[DeliverySummary]`. Use attribute access
187
+ (`cart.items`, `user.contact_email`) instead of `["items"]` / `["contact_email"]`.
188
+ - `get_delivery_scenario()`, `get_delivery_position()` and `get_article_category()`
189
+ still return raw dicts (see [Typed models](#typed-models-2x)).
190
+ - Any field you need that isn't modelled yet is available on `model.raw`.
@@ -20,11 +20,11 @@
20
20
  },
21
21
  "nixpkgs": {
22
22
  "locked": {
23
- "lastModified": 1757194111,
24
- "narHash": "sha256-4I5rftBv5fUuLkGJ5TZ+LWBz8c+tZ/ZEkUJ/uB0QCOM=",
23
+ "lastModified": 1784058464,
24
+ "narHash": "sha256-DX6zSuTutFjT5ffgBZc7iFui7Eq0K4p2fccEPLS+GDM=",
25
25
  "owner": "nixos",
26
26
  "repo": "nixpkgs",
27
- "rev": "64028f19ae7ae87174d168286073ec0dc2b61395",
27
+ "rev": "d82977ddd7855999b82ba10d4f2bc8c4187848d4",
28
28
  "type": "github"
29
29
  },
30
30
  "original": {
@@ -10,6 +10,7 @@
10
10
  in {
11
11
  devShell = pkgs.mkShell { buildInputs = with pkgs; [
12
12
  python313Packages.requests
13
+ python313Packages.pydantic
13
14
  python313Packages.python-lsp-ruff
14
15
  python313Packages.ruff
15
16
  python313Packages.typing-extensions
@@ -0,0 +1,151 @@
1
+ import os
2
+ import time
3
+
4
+ import pytest
5
+ from dotenv import load_dotenv
6
+
7
+ from python_picnic_api2 import (
8
+ Article,
9
+ Cart,
10
+ Delivery,
11
+ DeliverySlots,
12
+ DeliverySummary,
13
+ PicnicAPI,
14
+ SearchResult,
15
+ User,
16
+ )
17
+
18
+ load_dotenv()
19
+
20
+ auth_token = os.getenv("AUTH_TOKEN")
21
+ country_code = os.getenv("COUNTRY_CODE")
22
+
23
+ picnic = PicnicAPI(auth_token=auth_token, country_code=country_code)
24
+
25
+
26
+ @pytest.fixture(autouse=True)
27
+ def slow_down_tests():
28
+ yield
29
+ time.sleep(2)
30
+
31
+
32
+ def _get_amount(cart: Cart, product_id: str):
33
+ items = cart.items[0].items
34
+ product = next((item for item in items if item.id == product_id), None)
35
+ quantities = [d.quantity for d in product.decorators if d.type == "QUANTITY"]
36
+ return quantities[0]
37
+
38
+
39
+ def test_auth_token_valid():
40
+ assert picnic.logged_in(), (
41
+ "Auth token is invalid or expired — renew the PICNIC_AUTH_TOKEN secret"
42
+ )
43
+
44
+
45
+ def test_get_user():
46
+ response = picnic.get_user()
47
+ assert isinstance(response, User)
48
+ assert response.contact_email
49
+
50
+
51
+ def test_search():
52
+ response = picnic.search("kaffee")
53
+ assert isinstance(response, SearchResult)
54
+ assert isinstance(response.items, list)
55
+ assert response.items[0].id
56
+
57
+
58
+ def test_get_article():
59
+ response = picnic.get_article("s1018620")
60
+ assert isinstance(response, Article)
61
+ assert response.id == "s1018620"
62
+ assert response.name == "Gut&Günstig H-Milch 3,5%"
63
+
64
+
65
+ def test_get_article_with_category_name():
66
+ response = picnic.get_article("s1018620", add_category=True)
67
+ assert isinstance(response, Article)
68
+ assert response.category is not None
69
+ assert response.category.name == "H-Milch"
70
+
71
+
72
+ def test_get_article_by_gtin():
73
+ response = picnic.get_article_by_gtin("4311501044209")
74
+ assert response.id == "s1018620"
75
+ assert response.name == "Gut&Günstig H-Milch 3,5%"
76
+
77
+
78
+ def test_get_article_by_gtin_unknown():
79
+ response = picnic.get_article_by_gtin("4311501040000")
80
+ assert response is None
81
+
82
+
83
+ def test_get_cart():
84
+ response = picnic.get_cart()
85
+ assert isinstance(response, Cart)
86
+ assert response.id == "shopping_cart"
87
+
88
+
89
+ def test_add_product():
90
+ # need a clear cart for reproducibility
91
+ picnic.clear_cart()
92
+ response = picnic.add_product("s1018620", count=2)
93
+
94
+ assert isinstance(response, Cart)
95
+ assert any(item.id == "s1018620" for item in response.items[0].items)
96
+ assert _get_amount(response, "s1018620") == 2
97
+
98
+
99
+ def test_remove_product():
100
+ # need a clear cart for reproducibility
101
+ picnic.clear_cart()
102
+ # add two milk to the cart so we can remove 1
103
+ picnic.add_product("s1018620", count=2)
104
+
105
+ response = picnic.remove_product("s1018620", count=1)
106
+ amount = _get_amount(response, "s1018620")
107
+
108
+ assert isinstance(response, Cart)
109
+ assert amount == 1
110
+
111
+
112
+ def test_clear_cart():
113
+ # need a clear cart for reproducibility
114
+ picnic.clear_cart()
115
+ # add two coffee to the cart so we can clear it
116
+ picnic.add_product("s1018620", count=2)
117
+
118
+ response = picnic.clear_cart()
119
+
120
+ assert isinstance(response, Cart)
121
+ assert len(response.items) == 0
122
+
123
+
124
+ def test_get_delivery_slots():
125
+ response = picnic.get_delivery_slots()
126
+ assert isinstance(response, DeliverySlots)
127
+ assert isinstance(response.delivery_slots, list)
128
+
129
+
130
+ def test_get_deliveries():
131
+ response = picnic.get_deliveries()
132
+
133
+ assert isinstance(response, list)
134
+ assert isinstance(response[0], DeliverySummary)
135
+ assert response[0].status == "COMPLETED"
136
+
137
+
138
+ def test_get_delivery():
139
+ # get a id to test against
140
+ summaries = picnic.get_deliveries()
141
+ deliveryId = summaries[0].delivery_id
142
+
143
+ response = picnic.get_delivery(deliveryId)
144
+ assert isinstance(response, Delivery)
145
+ assert response.status == "COMPLETED"
146
+ assert response.id == deliveryId
147
+
148
+
149
+ def test_get_current_deliveries():
150
+ response = picnic.get_current_deliveries()
151
+ assert isinstance(response, list)
@@ -8,25 +8,13 @@ from python_picnic_api2.session import PicnicAPISession, PicnicAuthError
8
8
 
9
9
  load_dotenv()
10
10
 
11
- username = os.getenv("USERNAME")
12
- password = os.getenv("PASSWORD")
13
11
  country_code = os.getenv("COUNTRY_CODE")
14
12
 
15
- DEFAULT_URL = "https://storefront-prod.{}.picnicinternational.com/api/{}"
16
- DEFAULT_API_VERSION = "15"
17
-
18
13
 
19
14
  def test_init():
20
15
  assert issubclass(PicnicAPISession, Session)
21
16
 
22
17
 
23
- def test_login():
24
- client = PicnicAPI(
25
- username=username, password=password, country_code=country_code
26
- )
27
- assert "x-picnic-auth" in client.session.headers
28
-
29
-
30
18
  def test_login_auth_error():
31
19
  try:
32
20
  PicnicAPI(