python-picnic-api2 1.3.4__py3-none-any.whl → 2.0.0__py3-none-any.whl

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.
@@ -0,0 +1,262 @@
1
+ """Article model, parsed from the ``product-details-page-root`` PML page."""
2
+
3
+ from .base import PicnicModel
4
+ from .category import Category
5
+ from .pml import find, find_all, on_press_target, strip_colors, text_of, walk
6
+
7
+ # The container holding the article's title/producer/quantity text nodes.
8
+ _MAIN_CONTAINER_ID = "product-details-page-root-main-container"
9
+ _CATEGORY_BUTTON_ID = "category-button"
10
+ # Containers common to every product-details page (present across countries and
11
+ # product types); each holds one section of the page.
12
+ _DESCRIPTION_ID = "product-page-description"
13
+ _HIGHLIGHTS_ID = "product-page-highlights"
14
+ # Bundle/multipack pages carry one "product-page-bundle-item-<id>" container per
15
+ # pack size; ordinary product pages carry none.
16
+ _BUNDLE_ITEM_PREFIX = "product-page-bundle-item-"
17
+ # Leading characters that mark a price-per-unit line (e.g. "€1.15/L").
18
+ _CURRENCY = "€$£"
19
+
20
+
21
+ def _bundle_variant_ids(data, article_id: str) -> list[str]:
22
+ """Return the *other* pack-size article ids on a bundle page, in order.
23
+
24
+ Bundle/multipack pages list several pack sizes, each in a
25
+ ``product-page-bundle-item-<id>`` container. Returns the sibling ids (all but
26
+ the requested article); an empty list means this is not a bundle page.
27
+ """
28
+ ids: list[str] = []
29
+ for node in find_all(data, id_prefix=_BUNDLE_ITEM_PREFIX):
30
+ variant_id = node["id"][len(_BUNDLE_ITEM_PREFIX):]
31
+ if variant_id and variant_id != article_id and variant_id not in ids:
32
+ ids.append(variant_id)
33
+ return ids
34
+
35
+
36
+ def _all_texts(container) -> list[str]:
37
+ """Return every non-empty (color-stripped) text in ``container``, in order."""
38
+ texts: list[str] = []
39
+ for node in walk(container):
40
+ text = text_of(node)
41
+ if text and text.strip():
42
+ texts.append(text.strip())
43
+ return texts
44
+
45
+
46
+ def _raw_text(node) -> str | None:
47
+ """Return a node's raw (un-stripped) markdown/text, if it has any."""
48
+ for key in ("markdown", "text"):
49
+ value = node.get(key)
50
+ if isinstance(value, str):
51
+ return value
52
+ return None
53
+
54
+
55
+ def _price_cents(value) -> int | None:
56
+ """Return an integer-cents price from a node's ``price`` value, or ``None``.
57
+
58
+ DE returns prices as ``int`` (``95``) but NL returns ``float`` (``1035.0``);
59
+ both must parse. ``bool`` is rejected (it is an ``int`` subclass).
60
+ """
61
+ if isinstance(value, bool) or not isinstance(value, int | float):
62
+ return None
63
+ return round(value)
64
+
65
+
66
+ def _scan_prices(scope) -> tuple[int | None, int | None]:
67
+ """Return ``(price, original_price)`` from the ``PRICE`` nodes in ``scope``.
68
+
69
+ ``price`` is the first non-crossed price; ``original_price`` is a
70
+ struck-through (``isCrossed``) price, trusted only while still within the
71
+ product's own price block (before a second, non-crossed price appears — later
72
+ prices belong to recommendation tiles).
73
+ """
74
+ price: int | None = None
75
+ original_price: int | None = None
76
+ non_crossed_seen = 0
77
+ for node in walk(scope):
78
+ if node.get("type") != "PRICE":
79
+ continue
80
+ value = _price_cents(node.get("price"))
81
+ if value is None:
82
+ continue
83
+ if node.get("isCrossed"):
84
+ if original_price is None and non_crossed_seen <= 1:
85
+ original_price = value
86
+ else:
87
+ non_crossed_seen += 1
88
+ if price is None:
89
+ price = value
90
+ return price, original_price
91
+
92
+
93
+ def _find_prices(data, article_id: str) -> tuple[int | None, int | None]:
94
+ """Return ``(price, original_price)`` in integer cents for ``article_id``.
95
+
96
+ Bundle / multipack pages list several pack sizes, each in its own
97
+ ``product-page-bundle-item-<id>`` container with its own price, and the main
98
+ container carries no price at all. The price for *this* article must come from
99
+ its matching bundle-item container — taking the first ``PRICE`` on the page
100
+ would return a different pack size's price. For ordinary pages there is no such
101
+ container and the product's price nodes are simply the first on the page.
102
+ Falls back to any node carrying a numeric ``price`` so a layout variant never
103
+ leaves the price unset.
104
+ """
105
+ bundle_item = find(data, id=f"product-page-bundle-item-{article_id}")
106
+ if bundle_item is not None:
107
+ price, original_price = _scan_prices(bundle_item)
108
+ if price is not None:
109
+ return price, original_price
110
+
111
+ price, original_price = _scan_prices(data)
112
+ if price is None:
113
+ for node in walk(data):
114
+ value = _price_cents(node.get("price"))
115
+ if value is not None and not node.get("isCrossed"):
116
+ price = value
117
+ break
118
+ return price, original_price
119
+
120
+
121
+ def _parse_main_texts(
122
+ container,
123
+ ) -> tuple[str | None, str | None, str | None, str | None]:
124
+ """Classify the main container's texts into (product_name, producer,
125
+ unit_quantity, price_per_unit) by *role*, not position.
126
+
127
+ The title is the ``HEADER1`` node; the unit quantity is a colour-coded line
128
+ starting with a digit (``"1L"``, ``"5 Stück"``); the price-per-unit starts
129
+ with a currency symbol (``"€1.15/L"``); the producer/brand is a plain text
130
+ line. A positional read broke on brand-less products (produce) and trailing
131
+ badges like ``"Tiefkühl"``.
132
+ """
133
+ title = producer = unit_quantity = price_per_unit = None
134
+ fallback_title = None
135
+ for node in walk(container):
136
+ raw = _raw_text(node)
137
+ if raw is None:
138
+ continue
139
+ text = strip_colors(raw).strip()
140
+ if not text:
141
+ continue
142
+ if fallback_title is None:
143
+ fallback_title = text
144
+ if node.get("textType") == "HEADER1" and title is None:
145
+ title = text
146
+ continue
147
+ colour_coded = "#(" in raw
148
+ first = text[0]
149
+ if first in _CURRENCY:
150
+ if price_per_unit is None:
151
+ price_per_unit = text
152
+ elif colour_coded and first.isdigit():
153
+ if unit_quantity is None:
154
+ unit_quantity = text
155
+ elif not colour_coded and producer is None:
156
+ producer = text
157
+ return (title or fallback_title), producer, unit_quantity, price_per_unit
158
+
159
+
160
+ class Article(PicnicModel):
161
+ """A Picnic article (product)."""
162
+
163
+ id: str
164
+ #: The composed display name, ``"<producer> <product_name>"``.
165
+ name: str | None = None
166
+ #: The product title on its own, e.g. ``"H-Milch 3,5%"``.
167
+ product_name: str | None = None
168
+ #: The brand / producer, e.g. ``"Gut&Günstig"``. ``None`` for unbranded
169
+ #: products such as fresh produce.
170
+ producer: str | None = None
171
+ #: The pack size, e.g. ``"1L"`` / ``"5 Stück"``.
172
+ unit_quantity: str | None = None
173
+ #: The comparative price, e.g. ``"€1.15/L"``.
174
+ price_per_unit: str | None = None
175
+ #: The current price in integer cents, e.g. ``95``. NB: for bundles/multipacks
176
+ #: (:attr:`is_bundle`) Picnic exposes only a *per-unit* price here — the full
177
+ #: amount charged for the pack is not in the page payload and only appears once
178
+ #: the item is added to the cart.
179
+ price: int | None = None
180
+ #: The pre-sale price in integer cents when on sale, else ``None``.
181
+ original_price: int | None = None
182
+ #: Other pack-size article ids when this is a bundle/multipack, else ``[]``.
183
+ bundle_variant_ids: list[str] = []
184
+
185
+ @property
186
+ def is_bundle(self) -> bool:
187
+ """Whether this product is a bundle/multipack with other pack sizes."""
188
+ return bool(self.bundle_variant_ids)
189
+ #: The hero product image id (usable with Picnic's image CDN).
190
+ image_id: str | None = None
191
+ #: The product description (markdown, may include ``**bold**`` and newlines).
192
+ description: str | None = None
193
+ #: Short feature bullets shown on the page, e.g. ``["Lange **haltbar**", ...]``.
194
+ highlights: list[str] = []
195
+ category: Category | None = None
196
+
197
+ @classmethod
198
+ def from_page(cls, data: dict, article_id: str) -> "Article | None":
199
+ """Build an :class:`Article` from a ``product-details-page-root`` payload.
200
+
201
+ The page is a layout tree. The article's title, brand, unit quantity and
202
+ price-per-unit are read from the main container by *role* (see
203
+ :func:`_parse_main_texts`), the ``price`` from the first price node (see
204
+ :func:`_find_price`) and the hero ``image_id`` from the page's first
205
+ image-gallery node. Returns ``None`` when the page has no recognizable
206
+ article container (e.g. an unsupported / experimental layout variant).
207
+ """
208
+ container = find(data, id=_MAIN_CONTAINER_ID)
209
+ if container is None:
210
+ return None
211
+
212
+ product_name, producer, unit_quantity, price_per_unit = \
213
+ _parse_main_texts(container)
214
+ if not product_name:
215
+ return None
216
+
217
+ price, original_price = _find_prices(data, article_id)
218
+ bundle_variant_ids = _bundle_variant_ids(data, article_id)
219
+
220
+ # The first image-gallery node on the page is the hero product image.
221
+ gallery = find(data, type="image_gallery")
222
+ image_id = gallery.get("image_id") if gallery else None
223
+
224
+ # Description is a single markdown block; highlights are short bullets.
225
+ description_container = find(data, id=_DESCRIPTION_ID)
226
+ description_texts = _all_texts(description_container) \
227
+ if description_container else []
228
+ description = description_texts[0] if description_texts else None
229
+
230
+ highlights_container = find(data, id=_HIGHLIGHTS_ID)
231
+ highlights = _all_texts(highlights_container) if highlights_container else []
232
+
233
+ # Historical behaviour composes "<producer> <product_name>" as the name.
234
+ display_name = " ".join(part for part in (producer, product_name) if part)
235
+
236
+ return cls(
237
+ id=article_id,
238
+ name=display_name or None,
239
+ product_name=product_name,
240
+ producer=producer,
241
+ unit_quantity=unit_quantity,
242
+ price_per_unit=price_per_unit,
243
+ price=price,
244
+ original_price=original_price,
245
+ bundle_variant_ids=bundle_variant_ids,
246
+ image_id=image_id,
247
+ description=description,
248
+ highlights=highlights,
249
+ raw=data,
250
+ )
251
+
252
+ @staticmethod
253
+ def category_ids_from_page(data: dict) -> tuple[int, int, int] | None:
254
+ """Extract ``(l1_id, l2_id, l3_id)`` from the page's category button."""
255
+ button = find(data, id=_CATEGORY_BUTTON_ID)
256
+ if button is None:
257
+ return None
258
+ target = on_press_target(button)
259
+ return Category.parse_deeplink(target) if target else None
260
+
261
+
262
+ __all__ = ["Article"]
@@ -0,0 +1,42 @@
1
+ """Shared base class for all Picnic data models."""
2
+
3
+ from typing import TypeVar
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field, SkipValidation
6
+
7
+ _T = TypeVar("_T", bound="PicnicModel")
8
+
9
+
10
+ class PicnicModel(BaseModel):
11
+ """Base for every Picnic model.
12
+
13
+ Design goals, given that the Picnic API varies by country and A/B test:
14
+
15
+ - ``extra="ignore"`` so unknown/new fields never break parsing.
16
+ - ``populate_by_name=True`` so fields can carry a ``Field(alias=...)`` for
17
+ Picnic's raw keys while staying accessible by their pythonic name.
18
+ - Every top-level model keeps the original payload in :attr:`raw` (excluded
19
+ from ``model_dump()``) so callers can always reach data we haven't modelled
20
+ yet. It is stored verbatim (no copy/validation) so it stays the exact
21
+ object returned by the API, and is excluded from ``model_dump()``.
22
+ """
23
+
24
+ model_config = ConfigDict(extra="ignore", populate_by_name=True)
25
+
26
+ raw: SkipValidation[dict | None] = Field(default=None, exclude=True, repr=False)
27
+
28
+ @classmethod
29
+ def from_api(cls: type[_T], data: dict) -> _T:
30
+ """Validate a raw domain-JSON payload and keep it verbatim on :attr:`raw`.
31
+
32
+ The shared constructor for the "clean JSON" endpoints (user, cart,
33
+ deliveries, …), analogous to the ``from_page`` builders the PML models
34
+ use. ``data`` is attached untouched so callers keep an escape hatch to
35
+ anything not modelled yet.
36
+ """
37
+ obj = cls.model_validate(data)
38
+ obj.raw = data
39
+ return obj
40
+
41
+
42
+ __all__ = ["PicnicModel"]
@@ -0,0 +1,28 @@
1
+ """Cart model, parsed from the ``/cart`` domain-JSON endpoint.
2
+
3
+ The cart is itself an ``ORDER``: its :attr:`items` are order lines. It also
4
+ carries the currently offered delivery slots and the selected slot.
5
+ """
6
+
7
+ from .base import PicnicModel
8
+ from .common import OrderLine, SelectedSlot, Slot
9
+
10
+
11
+ class Cart(PicnicModel):
12
+ """The shopping cart returned by ``get_cart`` and the cart-mutation methods."""
13
+
14
+ type: str | None = None
15
+ id: str | None = None
16
+ items: list[OrderLine] = []
17
+ delivery_slots: list[Slot] = []
18
+ selected_slot: SelectedSlot | None = None
19
+ total_count: int | None = None
20
+ total_price: int | None = None
21
+ checkout_total_price: int | None = None
22
+ mts: int | None = None
23
+ deposit_breakdown: list = []
24
+ state_token: str | None = None
25
+ membership_savings: int | None = None
26
+
27
+
28
+ __all__ = ["Cart"]
@@ -0,0 +1,30 @@
1
+ """Category model, parsed from the L2 category page and article deep-links."""
2
+
3
+ import re
4
+
5
+ from .base import PicnicModel
6
+
7
+ # Deep-link on a product's category button, e.g.
8
+ # ``app.picnic://categories/1000/l2/2000/l3/3000``.
9
+ _CATEGORY_DEEPLINK = re.compile(
10
+ r"app\.picnic://categories/(\d+)/l2/(\d+)/l3/(\d+)"
11
+ )
12
+
13
+
14
+ class Category(PicnicModel):
15
+ """A Picnic category identified by its L2/L3 ids."""
16
+
17
+ l2_id: int | None = None
18
+ l3_id: int | None = None
19
+ name: str | None = None
20
+
21
+ @classmethod
22
+ def parse_deeplink(cls, target: str) -> tuple[int, int, int] | None:
23
+ """Extract ``(l1_id, l2_id, l3_id)`` from a category deep-link target."""
24
+ match = _CATEGORY_DEEPLINK.match(target or "")
25
+ if not match:
26
+ return None
27
+ return tuple(int(group) for group in match.groups()) # type: ignore[return-value]
28
+
29
+
30
+ __all__ = ["Category"]
@@ -0,0 +1,125 @@
1
+ """Building blocks shared by the cart and delivery domain-JSON models.
2
+
3
+ These endpoints (unlike the PML ``/pages/*`` ones) return clean, nested domain
4
+ JSON, so the models are plain pydantic with no tree traversal. Prices are integer
5
+ cents, timestamps are kept as the raw ISO-8601 strings (e.g.
6
+ ``"2026-07-15T14:15:00.000+02:00"``) to stay byte-faithful to the payload.
7
+ """
8
+
9
+ from .base import PicnicModel
10
+
11
+
12
+ class Decorator(PicnicModel):
13
+ """A UI/pricing decorator attached to an order article (e.g. ``QUANTITY``).
14
+
15
+ ``type`` is the only key present on every variant; the extras below cover the
16
+ common kinds (``QUANTITY`` -> ``quantity``, ``UNIT_QUANTITY`` ->
17
+ ``unit_quantity_text``, ``PRODUCT_SIZE`` -> ``text``). Anything else is kept
18
+ via ``extra="ignore"`` + the parent model's :attr:`raw`.
19
+ """
20
+
21
+ type: str | None = None
22
+ quantity: int | None = None
23
+ unit_quantity_text: str | None = None
24
+ text: str | None = None
25
+
26
+
27
+ class Slot(PicnicModel):
28
+ """A delivery slot, as embedded in the cart, delivery-slot list and deliveries."""
29
+
30
+ slot_id: str | None = None
31
+ hub_id: str | None = None
32
+ fc_id: str | None = None
33
+ window_start: str | None = None
34
+ window_end: str | None = None
35
+ cut_off_time: str | None = None
36
+ is_available: bool | None = None
37
+ selected: bool | None = None
38
+ reserved: bool | None = None
39
+ minimum_order_value: int | None = None
40
+ unavailability_reason: str | None = None
41
+ slot_characteristics: list = []
42
+
43
+
44
+ class SelectedSlot(PicnicModel):
45
+ """The currently selected slot reference (``slot_id`` + ``state``)."""
46
+
47
+ slot_id: str | None = None
48
+ state: str | None = None
49
+
50
+
51
+ class Eta(PicnicModel):
52
+ """An estimated-arrival window (``start``/``end`` ISO timestamps)."""
53
+
54
+ start: str | None = None
55
+ end: str | None = None
56
+
57
+
58
+ class TransactionInfo(PicnicModel):
59
+ """Payment info attached to an order."""
60
+
61
+ bank_id: str | None = None
62
+ payment_type: str | None = None
63
+ redacted_iban: str | None = None
64
+ refund_account: bool | None = None
65
+
66
+
67
+ class OrderArticle(PicnicModel):
68
+ """A single article (product) inside an order line."""
69
+
70
+ type: str | None = None
71
+ id: str | None = None
72
+ name: str | None = None
73
+ image_ids: list[str] = []
74
+ unit_quantity: str | None = None
75
+ price: int | None = None
76
+ max_count: int | None = None
77
+ perishable: bool | None = None
78
+ decorators: list[Decorator] = []
79
+
80
+
81
+ class OrderLine(PicnicModel):
82
+ """An order line: one or more identical articles with a line price."""
83
+
84
+ type: str | None = None
85
+ id: str | None = None
86
+ items: list[OrderArticle] = []
87
+ display_price: int | None = None
88
+ price: int | None = None
89
+
90
+
91
+ class Order(PicnicModel):
92
+ """An order.
93
+
94
+ A single model covers both the rich delivery-detail order (with line items and
95
+ payment info) and the lean order summary returned by ``/deliveries/summary``
96
+ (just totals + status); every field is optional so the missing ones simply
97
+ stay ``None``.
98
+ """
99
+
100
+ type: str | None = None
101
+ id: str | None = None
102
+ items: list[OrderLine] = []
103
+ total_price: int | None = None
104
+ checkout_total_price: int | None = None
105
+ total_savings: int | None = None
106
+ total_deposit: int | None = None
107
+ cancellable: bool | None = None
108
+ cancellation_time: str | None = None
109
+ transaction_info: TransactionInfo | None = None
110
+ creation_time: str | None = None
111
+ status: str | None = None
112
+ deposit_breakdown: list = []
113
+ membership_savings: int | None = None
114
+
115
+
116
+ __all__ = [
117
+ "Decorator",
118
+ "Slot",
119
+ "SelectedSlot",
120
+ "Eta",
121
+ "TransactionInfo",
122
+ "OrderArticle",
123
+ "OrderLine",
124
+ "Order",
125
+ ]
@@ -0,0 +1,50 @@
1
+ """Delivery models, parsed from the ``/deliveries/*`` and ``/cart/delivery_slots``
2
+ domain-JSON endpoints.
3
+ """
4
+
5
+ from .base import PicnicModel
6
+ from .common import Eta, Order, SelectedSlot, Slot
7
+
8
+
9
+ class DeliverySlots(PicnicModel):
10
+ """The available delivery slots, returned by ``get_delivery_slots``."""
11
+
12
+ delivery_slots: list[Slot] = []
13
+ selected_slot: SelectedSlot | None = None
14
+
15
+
16
+ class Delivery(PicnicModel):
17
+ """A single delivery in full detail, returned by ``get_delivery``.
18
+
19
+ Contains the full order tree (orders -> lines -> articles), the slot and,
20
+ once en route, an ETA.
21
+ """
22
+
23
+ type: str | None = None
24
+ delivery_id: str | None = None
25
+ creation_time: str | None = None
26
+ slot: Slot | None = None
27
+ eta2: Eta | None = None
28
+ status: str | None = None
29
+ orders: list[Order] = []
30
+ returned_containers: list = []
31
+ parcels: list = []
32
+ id: str | None = None
33
+
34
+
35
+ class DeliverySummary(PicnicModel):
36
+ """A lightweight delivery from ``/deliveries/summary``.
37
+
38
+ Same shape as :class:`Delivery` but the orders are summaries (totals + status,
39
+ no line items). Returned by ``get_deliveries`` / ``get_current_deliveries``.
40
+ """
41
+
42
+ delivery_id: str | None = None
43
+ creation_time: str | None = None
44
+ slot: Slot | None = None
45
+ eta2: Eta | None = None
46
+ status: str | None = None
47
+ orders: list[Order] = []
48
+
49
+
50
+ __all__ = ["DeliverySlots", "Delivery", "DeliverySummary"]