Skip to content

Commit 6f257ca

Browse files
authored
Typed models for PML data (#49)
1 parent bffd381 commit 6f257ca

39 files changed

Lines changed: 3733 additions & 389 deletions

.github/workflows/ci.yaml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,6 @@ jobs:
3535
run: uv sync
3636

3737
- name: Test with pytest
38-
env:
39-
USERNAME: ${{ secrets.PICNIC_USERNAME }}
40-
PASSWORD: ${{ secrets.PICNIC_PASSWORD }}
41-
COUNTRY_CODE: ${{ secrets.PICNIC_COUNTRY_CODE }}
4238
run: |
4339
uv run pytest tests/ --cov --cov-report=xml
4440

README.md

Lines changed: 99 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
**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)**
66

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.
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.
88

99
This library is not affiliated with Picnic and retrieves data from the endpoints of the mobile application. **Use at your own risk.**
1010

@@ -53,45 +53,90 @@ except Picnic2FARequired:
5353

5454
After successful verification, the session is authenticated and you can use the API normally. If the code is invalid, `Picnic2FAError` is raised.
5555

56-
## Searching for an article
56+
## Typed models (2.x)
5757

58-
```python
59-
picnic.search('coffee')
60-
```
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.
6166

62-
```python
63-
[{'items': [{'id': 's1019822', 'name': 'Lavazza Caffè Crema e Aroma Bohnen', 'decorators': [], 'display_price': 1799, 'image_id': 'aecbf7d3b018025ec78daf5a1099b6842a860a2e3faeceec777c13d708ce442c', 'max_count': 99, 'unit_quantity': '1kg', 'sole_article_id': None}, ... ]}]
64-
```
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).
6571

66-
## Get article by ID
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
6777

6878
```python
69-
picnic.get_article("s1019822")
70-
```
71-
```python
72-
{'name': 'Lavazza Caffè Crema e Aroma Bohnen', 'id': 's1019822'}
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
7383
```
7484

75-
## Get article by GTIN (EAN)
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+
7690
```python
77-
picnic.get_article_by_gtin("8000070025400")
78-
```
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+
79113
```python
80-
{'name': 'Lavazza Caffè Crema e Aroma Bohnen', 'id': 's1019822'}
114+
article = picnic.get_article_by_gtin("8000070025400") # -> Article | None
115+
article.name # 'Lavazza Caffè Crema e Aroma Bohnen'
81116
```
82117

83-
## Check cart
118+
### Get the user
84119

85120
```python
86-
picnic.get_cart()
121+
user = picnic.get_user() # -> User
122+
user.contact_email # 'you@example.com'
123+
user.address.city # 'Amsterdam'
124+
user.total_deliveries # 25
87125
```
88126

127+
### Check cart
128+
89129
```python
90-
{'type': 'ORDER', 'id': 'shopping_cart', 'items': [{'type': 'ORDER_LINE', 'id': '1470', 'items': [{'type': 'ORDER_ARTICLE', 'id': 's1019822', 'name': 'Lavazza Caffè Crema e Aroma Bohnen',...
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
91135
```
92136

93-
## Manipulating your cart
94-
All of these methods will return the shopping cart.
137+
### Manipulating your cart
138+
139+
All of these methods return the updated `Cart`.
95140

96141
```python
97142
# Add product with ID "s1019822" 2x
@@ -104,22 +149,42 @@ picnic.remove_product("s1019822")
104149
picnic.clear_cart()
105150
```
106151

107-
## See upcoming deliveries
152+
### See upcoming deliveries
108153

109154
```python
110-
picnic.get_current_deliveries()
111-
```
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'
112159

113-
```python
114-
[{'delivery_id': 'XXYYZZ', 'creation_time': '2025-04-28T08:08:41.666+02:00', 'slot': {'slot_id': 'XXYYZZ', 'hub_id': '...
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
115163
```
116164

117-
## See available delivery slots
165+
### See available delivery slots
118166

119167
```python
120-
picnic.get_delivery_slots()
121-
```
122-
123-
```python
124-
{'delivery_slots': [{'slot_id': 'XXYYZZ', 'hub_id': 'YYY', 'fc_id': 'FCX', 'window_start': '2025-04-29T17:15:00.000+02:00', 'window_end': '2025-04-29T19:15:00.000+02:00'...
125-
```
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`.

README.rst

Lines changed: 0 additions & 81 deletions
This file was deleted.

flake.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

flake.nix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
in {
1111
devShell = pkgs.mkShell { buildInputs = with pkgs; [
1212
python313Packages.requests
13+
python313Packages.pydantic
1314
python313Packages.python-lsp-ruff
1415
python313Packages.ruff
1516
python313Packages.typing-extensions

0 commit comments

Comments
 (0)