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.
- python_picnic_api2-2.0.0/.env.example +4 -0
- {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/.github/workflows/ci.yaml +2 -6
- {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/.github/workflows/it.yaml +3 -5
- {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/.github/workflows/release.yml +4 -4
- python_picnic_api2-2.0.0/PKG-INFO +205 -0
- python_picnic_api2-2.0.0/README.md +190 -0
- {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/flake.lock +3 -3
- {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/flake.nix +1 -0
- python_picnic_api2-2.0.0/integration_tests/test_client.py +151 -0
- {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/integration_tests/test_session.py +0 -12
- {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/pyproject.toml +9 -8
- python_picnic_api2-2.0.0/scripts/capture_fixtures.py +161 -0
- python_picnic_api2-2.0.0/src/python_picnic_api2/__init__.py +50 -0
- {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/src/python_picnic_api2/client.py +76 -78
- python_picnic_api2-2.0.0/src/python_picnic_api2/exceptions.py +32 -0
- python_picnic_api2-2.0.0/src/python_picnic_api2/helper.py +27 -0
- python_picnic_api2-2.0.0/src/python_picnic_api2/models/__init__.py +45 -0
- python_picnic_api2-2.0.0/src/python_picnic_api2/models/article.py +262 -0
- python_picnic_api2-2.0.0/src/python_picnic_api2/models/base.py +42 -0
- python_picnic_api2-2.0.0/src/python_picnic_api2/models/cart.py +28 -0
- python_picnic_api2-2.0.0/src/python_picnic_api2/models/category.py +30 -0
- python_picnic_api2-2.0.0/src/python_picnic_api2/models/common.py +125 -0
- python_picnic_api2-2.0.0/src/python_picnic_api2/models/delivery.py +50 -0
- python_picnic_api2-2.0.0/src/python_picnic_api2/models/pml.py +173 -0
- python_picnic_api2-2.0.0/src/python_picnic_api2/models/search.py +65 -0
- python_picnic_api2-2.0.0/src/python_picnic_api2/models/user.py +58 -0
- python_picnic_api2-2.0.0/tests/fixtures/cart_de.json +62 -0
- python_picnic_api2-2.0.0/tests/fixtures/category_page_nl.json +69 -0
- python_picnic_api2-2.0.0/tests/fixtures/current_deliveries_de.json +29 -0
- python_picnic_api2-2.0.0/tests/fixtures/delivery_de.json +91 -0
- python_picnic_api2-2.0.0/tests/fixtures/delivery_slots_de.json +32 -0
- python_picnic_api2-2.0.0/tests/fixtures/product_details_bundle_de.json +389 -0
- python_picnic_api2-2.0.0/tests/fixtures/product_details_multipack_nl.json +226 -0
- python_picnic_api2-2.0.0/tests/fixtures/product_details_page_de.json +212 -0
- python_picnic_api2-2.0.0/tests/fixtures/product_details_page_nl.json +220 -0
- python_picnic_api2-2.0.0/tests/fixtures/search_page_de.json +62 -0
- python_picnic_api2-2.0.0/tests/fixtures/search_page_nl.json +62 -0
- python_picnic_api2-2.0.0/tests/fixtures/user_de.json +56 -0
- {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/tests/test_client.py +87 -25
- python_picnic_api2-2.0.0/tests/test_fixtures.py +189 -0
- python_picnic_api2-2.0.0/tests/test_models.py +340 -0
- python_picnic_api2-2.0.0/tests/test_pml.py +73 -0
- python_picnic_api2-2.0.0/uv.lock +364 -0
- python_picnic_api2-1.3.3/.env.example +0 -4
- python_picnic_api2-1.3.3/PKG-INFO +0 -95
- python_picnic_api2-1.3.3/README.md +0 -104
- python_picnic_api2-1.3.3/README.rst +0 -81
- python_picnic_api2-1.3.3/integration_tests/test_client.py +0 -157
- python_picnic_api2-1.3.3/src/python_picnic_api2/__init__.py +0 -7
- python_picnic_api2-1.3.3/src/python_picnic_api2/helper.py +0 -150
- python_picnic_api2-1.3.3/uv.lock +0 -234
- {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/.devcontainer/devcontainer.json +0 -0
- {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/.envrc +0 -0
- {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/.github/dependabot.yml +0 -0
- {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/.github/release.yml +0 -0
- {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/.gitignore +0 -0
- {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/LICENSE.md +0 -0
- {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/codecov.yml +0 -0
- {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/integration_tests/__init__.py +0 -0
- {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/integration_tests/test_helper.py +0 -0
- {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/src/python_picnic_api2/session.py +0 -0
- {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/tests/__init__.py +0 -0
- {python_picnic_api2-1.3.3 → python_picnic_api2-2.0.0}/tests/test_session.py +0 -0
|
@@ -17,7 +17,7 @@ jobs:
|
|
|
17
17
|
runs-on: ubuntu-latest
|
|
18
18
|
|
|
19
19
|
steps:
|
|
20
|
-
- uses: actions/checkout@
|
|
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@
|
|
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@
|
|
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
|
-
|
|
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@
|
|
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@
|
|
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@
|
|
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.
|
|
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":
|
|
24
|
-
"narHash": "sha256-
|
|
23
|
+
"lastModified": 1784058464,
|
|
24
|
+
"narHash": "sha256-DX6zSuTutFjT5ffgBZc7iFui7Eq0K4p2fccEPLS+GDM=",
|
|
25
25
|
"owner": "nixos",
|
|
26
26
|
"repo": "nixpkgs",
|
|
27
|
-
"rev": "
|
|
27
|
+
"rev": "d82977ddd7855999b82ba10d4f2bc8c4187848d4",
|
|
28
28
|
"type": "github"
|
|
29
29
|
},
|
|
30
30
|
"original": {
|
|
@@ -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(
|