Skip to content

API Reference

CLI

pysainsburys.cli

Command-line interface for pysainsburys.

Command groups mirror the library layout:

  • :mod:pysainsburys.cli.auth — authentication
  • :mod:pysainsburys.cli.customer — customer profile
  • :mod:pysainsburys.cli.basket — basket operations
  • :mod:pysainsburys.cli.favourites — favourite products
  • :mod:pysainsburys.cli.orders — order history and status
  • :mod:pysainsburys.cli.slots — delivery and collection slots
  • :mod:pysainsburys.cli.nectar — Nectar offers and Your Nectar Prices
  • :mod:pysainsburys.cli.product — catalogue search and lookup
  • :mod:pysainsburys.cli.store — stores and in-store product search

build_parser()

Build the CLI argument parser.

Source code in pysainsburys/cli/__init__.py
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
def build_parser() -> argparse.ArgumentParser:
    """Build the CLI argument parser."""
    parser = argparse.ArgumentParser(
        prog="pysainsburys",
        description="Sainsbury's Groceries Online command-line client.",
    )
    parser.add_argument(
        "--version",
        action="version",
        version=f"%(prog)s {__version__}",
    )
    parser.add_argument(
        "--session",
        type=Path,
        default=default_session_path(),
        help=f"Session file path (default: {DEFAULT_SESSION_PATH})",
    )
    output = parser.add_mutually_exclusive_group()
    output.add_argument(
        "--json",
        action="store_true",
        help="Emit machine-readable JSON output",
    )
    output.add_argument(
        "--raw",
        action="store_true",
        help=(
            "Dump records to stdout as a JSON array, including every public attribute"
        ),
    )
    parser.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        help="Enable debug logging",
    )

    subparsers = parser.add_subparsers(dest="command", required=True)

    auth.register(subparsers)
    customer.register(subparsers)
    basket.register(subparsers)
    favourites.register(subparsers)
    orders.register(subparsers)
    slots.register(subparsers)
    nectar.register(subparsers)
    product.register(subparsers)
    store.register(subparsers)

    return parser

default_session_path()

Return the default session file path.

Source code in pysainsburys/cli/session.py
15
16
17
def default_session_path() -> Path:
    """Return the default session file path."""
    return DEFAULT_SESSION_PATH

main(argv=None)

Run the CLI.

Source code in pysainsburys/cli/__init__.py
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
def main(argv: list[str] | None = None) -> int:
    """Run the CLI."""
    args = parse_args(argv)

    if args.verbose:
        logging.basicConfig(level=logging.DEBUG)
    else:
        logging.basicConfig(level=logging.WARNING)

    try:
        return asyncio.run(run_command(args))
    except (
        AuthError,
        BrowserLoginRequiredError,
        HttpException,
        SessionRequiredError,
        ValueError,
        TypeError,
    ) as exc:
        print(str(exc), file=sys.stderr)
        return 1
    except KeyboardInterrupt:
        print("Interrupted.", file=sys.stderr)
        return 130

normalize_argv(argv)

Move global flags in front of the subcommand so they parse in either position.

Source code in pysainsburys/cli/__init__.py
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
def normalize_argv(argv: list[str]) -> list[str]:
    """Move global flags in front of the subcommand so they parse in either position."""
    hoisted: list[str] = []
    rest: list[str] = []
    index = 0
    while index < len(argv):
        arg = argv[index]
        name, separator, _value = arg.partition("=")
        if arg in _GLOBAL_FLAGS:
            hoisted.append(arg)
        elif arg in _GLOBAL_VALUE_FLAGS:
            hoisted.append(arg)
            if index + 1 < len(argv):
                index += 1
                hoisted.append(argv[index])
        elif separator and name in _GLOBAL_VALUE_FLAGS:
            hoisted.append(arg)
        else:
            rest.append(arg)
        index += 1
    return [*hoisted, *rest]

parse_args(argv=None)

Parse CLI arguments, accepting global flags before or after the command.

Source code in pysainsburys/cli/__init__.py
125
126
127
128
129
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    """Parse CLI arguments, accepting global flags before or after the command."""
    if argv is None:
        argv = sys.argv[1:]
    return build_parser().parse_args(normalize_argv(argv))

run_command(args) async

Dispatch a parsed command.

Source code in pysainsburys/cli/__init__.py
132
133
134
135
136
137
138
async def run_command(args: argparse.Namespace) -> int:
    """Dispatch a parsed command."""
    handler = getattr(args, "handler", None)
    if handler is None:
        print("No command handler configured.", file=sys.stderr)
        return 1
    return await handler(args)

Client

pysainsburys.Sainsburys

Async client for Sainsbury's Groceries Online.

Wrap an authenticated :class:~pysainsburys.GOLAuth session to access customer resources, or use the public catalogue methods without signing in.

Example

Authenticated session::

auth = await GOLAuth.from_session_file(
    "~/.config/pysainsburys/session.json"
)
async with Sainsburys(auth) as client:
    customer = await client.get_customer()
    basket = await customer.basket.fetch()

Public catalogue lookup (no login required)::

async with Sainsburys(GOLAuth()) as client:
    products = await client.search_products("bread")
    product = await client.get_product("3236048")
Source code in pysainsburys/__init__.py
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
class Sainsburys:
    """
    Async client for Sainsbury's Groceries Online.

    Wrap an authenticated :class:`~pysainsburys.GOLAuth` session to access
    customer resources, or use the public catalogue methods without signing in.

    Example:
        Authenticated session::

            auth = await GOLAuth.from_session_file(
                "~/.config/pysainsburys/session.json"
            )
            async with Sainsburys(auth) as client:
                customer = await client.get_customer()
                basket = await customer.basket.fetch()

        Public catalogue lookup (no login required)::

            async with Sainsburys(GOLAuth()) as client:
                products = await client.search_products("bread")
                product = await client.get_product("3236048")

    """

    def __init__(self, authenticator: GOLAuth) -> None:
        """Initialize with an authenticated session."""
        self.api = API(authenticator)
        self.customer: Customer | None = None
        self.updated_data_callbacks: list[Callable[[], Any]] = []
        self._first_update = True

    async def close(self) -> None:
        """Close the underlying HTTP session."""
        await self.api.close()

    async def __aenter__(self) -> Sainsburys:
        """Enter async context manager."""
        return self

    async def __aexit__(self, *_args: object) -> None:
        """Close the client on context exit."""
        await self.close()

    async def get_customer(self) -> Customer:
        """Fetch and cache the authenticated customer profile."""
        response = await self.api.send_request(endpoint="customer_profile")
        if not isinstance(response, dict):
            msg = "Customer profile response was not a JSON object."
            raise TypeError(msg)
        self.customer = Customer.from_dict(response, api=self.api)
        return self.customer

    async def get_product(self, product_uid: str) -> Product:
        """Fetch a single product by uid (no login required)."""
        response = await self.api.send_public_request(
            endpoint="get_product",
            PRODUCT_UID=product_uid,
        )
        if not isinstance(response, dict):
            msg = "Product response was not a JSON object."
            raise TypeError(msg)
        product_data = response.get("product", response)
        if not isinstance(product_data, dict):
            msg = "Product payload was not a JSON object."
            raise TypeError(msg)
        return bind_product(self.api, Product.from_dict(product_data))

    async def search_products(
        self,
        keyword: str,
        *,
        page_number: int = 1,
        page_size: int = 24,
    ) -> ProductList:
        """Search products by keyword (no login required)."""
        response = await self.api.send_public_request(
            endpoint="search_products",
            params={
                "page_number": page_number,
                "page_size": page_size,
                "filter[keyword]": keyword,
            },
        )
        if not isinstance(response, dict):
            msg = "Product search response was not a JSON object."
            raise TypeError(msg)
        product_list = ProductList.from_dict(response)
        bind_products(self.api, product_list.products)
        return product_list

    async def find_stores(
        self,
        latitude: float,
        longitude: float,
        *,
        page: int = 1,
        page_size: int = 20,
    ) -> StoreList:
        """Find stores near a latitude and longitude (no login required)."""
        response = await self.api.send_product_finder_request(
            "/v3/stores",
            params={
                "lat": latitude,
                "lon": longitude,
                "page": page,
                "size": page_size,
            },
        )
        if not isinstance(response, dict):
            msg = "Store search response was not a JSON object."
            raise TypeError(msg)
        store_list = StoreList.from_dict(response, api=self.api)
        bind_stores(self.api, store_list.stores)
        return store_list

    async def get_store(self, store_id: str) -> Store:
        """Fetch a single store by Product Finder store id."""
        response = await self.api.send_product_finder_request(
            f"/v3/stores/{store_id}",
        )
        if not isinstance(response, dict):
            msg = "Store response was not a JSON object."
            raise TypeError(msg)
        return bind_store(self.api, Store.from_dict(response))

    async def find_stores_by_postcode(
        self,
        postcode: str,
        *,
        page_number: int = 1,
    ) -> StoreList:
        """Find stores near a UK postcode with click-and-collect availability."""
        response = await self.api.send_public_request(
            endpoint="click_and_collect",
            params={
                "postcode": postcode.replace(" ", "").upper(),
                "page_number": page_number,
            },
        )
        if not isinstance(response, dict):
            msg = "Store search response was not a JSON object."
            raise TypeError(msg)
        store_list = StoreList.from_dict(response, api=self.api)
        bind_stores(self.api, store_list.stores)
        return store_list

    async def update(self) -> None:
        """Refresh commonly used cached data."""
        if self._first_update:
            await self.get_customer()
            self._first_update = False
        if self.customer is not None:
            await self.customer.basket.fetch()
            await self.customer.favourites.fetch()
            order_list = await self.customer.orders.fetch()
            if order_list.orders:
                await self.customer.orders.latest.status()
        for callback in self.updated_data_callbacks:
            if is_awaitable(callback):
                await callback()
            else:
                callback()

    def register_callback(self, callback: Callable[[], Any]) -> None:
        """Register a callback to be called when data is updated."""
        if not callable(callback):
            raise TypeError("Callback must be callable")
        self.updated_data_callbacks.append(callback)

    def remove_callback(self, callback: Callable[[], Any]) -> None:
        """Remove a registered callback."""
        if callback in self.updated_data_callbacks:
            self.updated_data_callbacks.remove(callback)

    def to_dict(self) -> dict[str, Any]:
        """Return the Sainsburys object data as a dictionary."""
        basket = self.customer.basket.cached if self.customer is not None else None
        favourites = (
            self.customer.favourites.cached if self.customer is not None else None
        )
        orders = self.customer.orders.cached if self.customer is not None else None
        return {
            "api": self.api.to_dict(),
            "customer": self.customer.to_dict() if self.customer else None,
            "basket": basket.to_dict() if basket else None,
            "favourites": favourites.to_dict() if favourites else None,
            "orders": orders.to_dict() if orders else None,
        }

    def __iter__(self) -> Iterator[tuple[str, Any]]:
        """Allow ``dict(sainsburys)`` conversion."""
        return iter(self.to_dict().items())

__aenter__() async

Enter async context manager.

Source code in pysainsburys/__init__.py
146
147
148
async def __aenter__(self) -> Sainsburys:
    """Enter async context manager."""
    return self

__aexit__(*_args) async

Close the client on context exit.

Source code in pysainsburys/__init__.py
150
151
152
async def __aexit__(self, *_args: object) -> None:
    """Close the client on context exit."""
    await self.close()

__init__(authenticator)

Initialize with an authenticated session.

Source code in pysainsburys/__init__.py
135
136
137
138
139
140
def __init__(self, authenticator: GOLAuth) -> None:
    """Initialize with an authenticated session."""
    self.api = API(authenticator)
    self.customer: Customer | None = None
    self.updated_data_callbacks: list[Callable[[], Any]] = []
    self._first_update = True

__iter__()

Allow dict(sainsburys) conversion.

Source code in pysainsburys/__init__.py
300
301
302
def __iter__(self) -> Iterator[tuple[str, Any]]:
    """Allow ``dict(sainsburys)`` conversion."""
    return iter(self.to_dict().items())

close() async

Close the underlying HTTP session.

Source code in pysainsburys/__init__.py
142
143
144
async def close(self) -> None:
    """Close the underlying HTTP session."""
    await self.api.close()

find_stores(latitude, longitude, *, page=1, page_size=20) async

Find stores near a latitude and longitude (no login required).

Source code in pysainsburys/__init__.py
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
async def find_stores(
    self,
    latitude: float,
    longitude: float,
    *,
    page: int = 1,
    page_size: int = 20,
) -> StoreList:
    """Find stores near a latitude and longitude (no login required)."""
    response = await self.api.send_product_finder_request(
        "/v3/stores",
        params={
            "lat": latitude,
            "lon": longitude,
            "page": page,
            "size": page_size,
        },
    )
    if not isinstance(response, dict):
        msg = "Store search response was not a JSON object."
        raise TypeError(msg)
    store_list = StoreList.from_dict(response, api=self.api)
    bind_stores(self.api, store_list.stores)
    return store_list

find_stores_by_postcode(postcode, *, page_number=1) async

Find stores near a UK postcode with click-and-collect availability.

Source code in pysainsburys/__init__.py
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
async def find_stores_by_postcode(
    self,
    postcode: str,
    *,
    page_number: int = 1,
) -> StoreList:
    """Find stores near a UK postcode with click-and-collect availability."""
    response = await self.api.send_public_request(
        endpoint="click_and_collect",
        params={
            "postcode": postcode.replace(" ", "").upper(),
            "page_number": page_number,
        },
    )
    if not isinstance(response, dict):
        msg = "Store search response was not a JSON object."
        raise TypeError(msg)
    store_list = StoreList.from_dict(response, api=self.api)
    bind_stores(self.api, store_list.stores)
    return store_list

get_customer() async

Fetch and cache the authenticated customer profile.

Source code in pysainsburys/__init__.py
154
155
156
157
158
159
160
161
async def get_customer(self) -> Customer:
    """Fetch and cache the authenticated customer profile."""
    response = await self.api.send_request(endpoint="customer_profile")
    if not isinstance(response, dict):
        msg = "Customer profile response was not a JSON object."
        raise TypeError(msg)
    self.customer = Customer.from_dict(response, api=self.api)
    return self.customer

get_product(product_uid) async

Fetch a single product by uid (no login required).

Source code in pysainsburys/__init__.py
163
164
165
166
167
168
169
170
171
172
173
174
175
176
async def get_product(self, product_uid: str) -> Product:
    """Fetch a single product by uid (no login required)."""
    response = await self.api.send_public_request(
        endpoint="get_product",
        PRODUCT_UID=product_uid,
    )
    if not isinstance(response, dict):
        msg = "Product response was not a JSON object."
        raise TypeError(msg)
    product_data = response.get("product", response)
    if not isinstance(product_data, dict):
        msg = "Product payload was not a JSON object."
        raise TypeError(msg)
    return bind_product(self.api, Product.from_dict(product_data))

get_store(store_id) async

Fetch a single store by Product Finder store id.

Source code in pysainsburys/__init__.py
226
227
228
229
230
231
232
233
234
async def get_store(self, store_id: str) -> Store:
    """Fetch a single store by Product Finder store id."""
    response = await self.api.send_product_finder_request(
        f"/v3/stores/{store_id}",
    )
    if not isinstance(response, dict):
        msg = "Store response was not a JSON object."
        raise TypeError(msg)
    return bind_store(self.api, Store.from_dict(response))

register_callback(callback)

Register a callback to be called when data is updated.

Source code in pysainsburys/__init__.py
274
275
276
277
278
def register_callback(self, callback: Callable[[], Any]) -> None:
    """Register a callback to be called when data is updated."""
    if not callable(callback):
        raise TypeError("Callback must be callable")
    self.updated_data_callbacks.append(callback)

remove_callback(callback)

Remove a registered callback.

Source code in pysainsburys/__init__.py
280
281
282
283
def remove_callback(self, callback: Callable[[], Any]) -> None:
    """Remove a registered callback."""
    if callback in self.updated_data_callbacks:
        self.updated_data_callbacks.remove(callback)

search_products(keyword, *, page_number=1, page_size=24) async

Search products by keyword (no login required).

Source code in pysainsburys/__init__.py
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
async def search_products(
    self,
    keyword: str,
    *,
    page_number: int = 1,
    page_size: int = 24,
) -> ProductList:
    """Search products by keyword (no login required)."""
    response = await self.api.send_public_request(
        endpoint="search_products",
        params={
            "page_number": page_number,
            "page_size": page_size,
            "filter[keyword]": keyword,
        },
    )
    if not isinstance(response, dict):
        msg = "Product search response was not a JSON object."
        raise TypeError(msg)
    product_list = ProductList.from_dict(response)
    bind_products(self.api, product_list.products)
    return product_list

to_dict()

Return the Sainsburys object data as a dictionary.

Source code in pysainsburys/__init__.py
285
286
287
288
289
290
291
292
293
294
295
296
297
298
def to_dict(self) -> dict[str, Any]:
    """Return the Sainsburys object data as a dictionary."""
    basket = self.customer.basket.cached if self.customer is not None else None
    favourites = (
        self.customer.favourites.cached if self.customer is not None else None
    )
    orders = self.customer.orders.cached if self.customer is not None else None
    return {
        "api": self.api.to_dict(),
        "customer": self.customer.to_dict() if self.customer else None,
        "basket": basket.to_dict() if basket else None,
        "favourites": favourites.to_dict() if favourites else None,
        "orders": orders.to_dict() if orders else None,
    }

update() async

Refresh commonly used cached data.

Source code in pysainsburys/__init__.py
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
async def update(self) -> None:
    """Refresh commonly used cached data."""
    if self._first_update:
        await self.get_customer()
        self._first_update = False
    if self.customer is not None:
        await self.customer.basket.fetch()
        await self.customer.favourites.fetch()
        order_list = await self.customer.orders.fetch()
        if order_list.orders:
            await self.customer.orders.latest.status()
    for callback in self.updated_data_callbacks:
        if is_awaitable(callback):
            await callback()
        else:
            callback()

pysainsburys.GOLAuth

Represent an authenticated Sainsbury's GOL session.

Source code in pysainsburys/auth.py
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
class GOLAuth:
    """Represent an authenticated Sainsbury's GOL session."""

    def __init__(
        self,
        *,
        access_token: str | None = None,
        refresh_token: str | None = None,
        wc_auth_token: str | None = None,
        user_id: str | None = None,
        wc_trusted_token: str | None = None,
        cookies: dict[str, str] | None = None,
        app_version: str | None = None,
        login_hint: str | None = None,
        session: aiohttp.ClientSession | None = None,
    ) -> None:
        self._auth_session = session
        self._owns_session = session is None
        self.access_token = access_token
        self._refresh_token = refresh_token
        self.wc_auth_token = wc_auth_token
        self.user_id = user_id
        self.wc_trusted_token = wc_trusted_token
        self.cookies = dict(cookies or {})
        default_version = GOL_APP_USER_AGENT.removeprefix("GOLAppAndroid/")
        self.app_version = app_version or default_version
        self.login_hint = login_hint
        self.next_refresh: datetime | None = None
        self.personalization_id: str | None = None
        self.oidc_config: dict[str, Any] | None = None
        self.authorization_url: str | None = None
        self._pkce_verifier: str | None = None
        self._oauth_state: str | None = None
        self._login_challenge: str | None = None
        self._login_referer: str | None = None

        if self.wc_auth_token is None:
            self.wc_auth_token = normalize_wc_auth_token(
                user_id=self.user_id,
                wc_trusted_token=self.wc_trusted_token,
            )

    @property
    def session(self) -> aiohttp.ClientSession:
        """Return the aiohttp session, creating one when needed."""
        if self._auth_session is None:
            cookie_jar = aiohttp.CookieJar(unsafe=True)
            self._auth_session = aiohttp.ClientSession(cookie_jar=cookie_jar)
            if self.cookies:
                cookie_jar.update_cookies(
                    self.cookies,
                    response_url=URL(AUTH_BASE_URL),
                )
        return self._auth_session

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> GOLAuth:
        """Create an auth object from a serialized session mapping."""
        cookies = data.get("cookies")
        if isinstance(cookies, str):
            cookies = parse_cookie_header(cookies)
        elif cookies is None:
            cookies = {}
        elif not isinstance(cookies, dict):
            msg = "Session cookies must be a mapping or Cookie header string."
            raise ValueError(msg)

        auth = cls(
            access_token=data.get("access_token"),
            refresh_token=data.get("refresh_token"),
            wc_auth_token=data.get("wc_auth_token"),
            user_id=data.get("user_id"),
            wc_trusted_token=data.get("wc_trusted_token"),
            cookies={str(k): str(v) for k, v in cookies.items()},
            app_version=data.get("app_version"),
            login_hint=data.get("login_hint"),
        )
        return cls._apply_session_metadata(auth, data)

    @classmethod
    def _apply_session_metadata(cls, auth: GOLAuth, data: dict[str, Any]) -> GOLAuth:
        """Apply optional session metadata after constructing auth state."""
        auth.personalization_id = data.get("personalization_id")
        next_refresh = data.get("next_refresh")
        if isinstance(next_refresh, str):
            auth.next_refresh = datetime.fromisoformat(next_refresh)
        auth.wc_auth_token = normalize_wc_auth_token(
            user_id=auth.user_id,
            wc_trusted_token=auth.wc_trusted_token,
            wc_auth_token=auth.wc_auth_token or data.get("wc_auth_token"),
        )
        return auth

    @classmethod
    async def from_session_file(cls, path: str) -> GOLAuth:
        """Load a session export created by tooling or a previous ``to_dict()``."""
        return cls.from_dict(await load_session_file(path))

    @property
    def refresh_token(self) -> str | None:
        """Return the OAuth refresh token."""
        return self._refresh_token

    @property
    def token_endpoint(self) -> str:
        """Return the OAuth token endpoint URL."""
        if self.oidc_config:
            endpoint = self.oidc_config.get("token_endpoint")
            if isinstance(endpoint, str):
                return endpoint
        return AUTH_TOKEN_URL

    @property
    def authorization_endpoint(self) -> str:
        """Return the OAuth authorization endpoint URL."""
        if self.oidc_config:
            endpoint = self.oidc_config.get("authorization_endpoint")
            if isinstance(endpoint, str):
                return endpoint
        return AUTH_AUTHORIZE_URL

    @property
    def authenticated_headers(self) -> dict[str, str]:
        """Return authenticated headers for grocery API calls."""
        headers = {
            "Accept": "application/json",
            "User-Agent": f"GOLAppAndroid/{self.app_version}",
        }
        if self.access_token:
            headers["Authorization"] = f"Bearer {self.access_token}"
        if self.wc_auth_token:
            headers["WCAuthToken"] = self.wc_auth_token
        if self.cookies:
            headers["Cookie"] = cookies_to_header(self.cookies)
        return headers

    def _browser_headers(self, *, referer: str | None = None) -> dict[str, str]:
        """Return browser-like headers for identity provider requests."""
        headers = dict(BROWSER_HEADERS)
        if referer is not None:
            headers["Referer"] = referer
            headers["sec-fetch-site"] = "same-origin"
        return headers

    def _oauth_access_token_expired(self) -> bool:
        """Return whether the OAuth access token is past its refresh schedule."""
        if not self.access_token:
            return True
        if self.next_refresh is None:
            return False
        return self.next_refresh <= datetime.now(UTC)

    def to_dict(self) -> dict[str, Any]:
        """Return the session as a JSON-serializable mapping."""
        return {
            "access_token": self.access_token,
            "refresh_token": self.refresh_token,
            "wc_auth_token": self.wc_auth_token,
            "user_id": self.user_id,
            "wc_trusted_token": self.wc_trusted_token,
            "cookies": self.cookies,
            "app_version": self.app_version,
            "login_hint": self.login_hint,
            "personalization_id": self.personalization_id,
            "next_refresh": (
                self.next_refresh.isoformat() if self.next_refresh is not None else None
            ),
        }

    async def save_session_file(self, path: str) -> None:
        """Persist the current session to disk."""
        self._sync_session_cookies()
        await save_session_file(path, self.to_dict())

    def pending_login_to_dict(self) -> dict[str, Any]:
        """Return in-progress login state for MFA completion."""
        return {
            **self.to_dict(),
            "pkce_verifier": self._pkce_verifier,
            "oauth_state": self._oauth_state,
            "authorization_url": self.authorization_url,
            "login_referer": self._login_referer,
            "login_challenge": self._login_challenge,
        }

    @classmethod
    def from_pending_login_dict(cls, data: dict[str, Any]) -> GOLAuth:
        """Restore in-progress login state from a mapping."""
        payload = dict(data)
        pending_fields = (
            "pkce_verifier",
            "oauth_state",
            "authorization_url",
            "login_referer",
            "login_challenge",
        )
        pending = {field: payload.pop(field, None) for field in pending_fields}
        auth = cls.from_dict(payload)
        auth._pkce_verifier = pending["pkce_verifier"]
        auth._oauth_state = pending["oauth_state"]
        auth.authorization_url = pending["authorization_url"]
        auth._login_referer = pending["login_referer"]
        auth._login_challenge = pending["login_challenge"]
        return auth

    async def save_pending_login(self, path: str) -> None:
        """Persist in-progress login state awaiting MFA verification."""
        await save_session_file(path, self.pending_login_to_dict())

    @classmethod
    async def from_pending_login_file(cls, path: str) -> GOLAuth:
        """Load in-progress login state from disk."""
        return cls.from_pending_login_dict(await load_session_file(path))

    def _sync_session_cookies(self) -> None:
        """Copy cookies from the aiohttp jar into the session mapping."""
        for cookie in self.session.cookie_jar:
            self.cookies[cookie.key] = cookie.value

    def __iter__(self) -> Iterator[tuple[str, Any]]:
        """Allow ``dict(auth)`` conversion."""
        return iter(self.to_dict().items())

    async def close(self) -> None:
        """Close the underlying HTTP session when owned by this object."""
        if (
            self._owns_session
            and self._auth_session is not None
            and not self._auth_session.closed
        ):
            await self._auth_session.close()
            self._auth_session = None

    async def __aenter__(self) -> GOLAuth:
        """Enter async context manager."""
        return self

    async def __aexit__(self, *_args: object) -> None:
        """Close the auth session on context exit."""
        await self.close()

    def _raise_mapped_token_error(self, status: int, response_text: str) -> None:
        """Raise a mapped exception for known OAuth token errors."""
        try:
            error_data = json.loads(response_text)
            if not isinstance(error_data, dict):
                error_data = {}
            error_code = str(error_data.get("error", "unknown")).lower()
            error_message = error_data.get("error_description") or response_text
        except json.JSONDecodeError:
            error_code = "unknown"
            error_message = response_text

        exception_class = OAUTH_ERROR_EXCEPTION_MAP.get(error_code)
        if exception_class is not None:
            raise exception_class(error_message)
        if status != 200:
            raise TokenRequestError(error_message)

    async def fetch_oidc_configuration(self) -> dict[str, Any]:
        """Fetch OIDC discovery metadata using browser-like headers."""
        _LOGGER.debug("GOL Auth: fetching OIDC discovery document")
        async with self.session.get(
            AUTH_DISCOVERY_URL,
            headers=self._browser_headers(),
        ) as response:
            text = await response.text()
            if response.status != 200:
                raise TokenRequestError(
                    f"OIDC discovery failed ({response.status}): {text}"
                )
            data = await response.json()
            if not isinstance(data, dict):
                msg = "OIDC discovery response was not a JSON object."
                raise TokenRequestError(msg)
            self.oidc_config = data
            return data

    def _form_post_headers(
        self, *, referer: str, origin: str = AUTH_BASE_URL
    ) -> dict[str, str]:
        """Return browser headers for identity form submissions."""
        headers = self._browser_headers(referer=referer)
        headers["Content-Type"] = "application/x-www-form-urlencoded"
        headers["Origin"] = origin
        headers["sec-fetch-mode"] = "navigate"
        headers["sec-fetch-user"] = "?1"
        return headers

    async def _identity_request(
        self,
        method: str,
        url: str,
        *,
        data: dict[str, str] | None = None,
        referer: str | None = None,
    ) -> tuple[int, str, str | None]:
        """Send an identity request without auto-following redirects."""
        if data is not None:
            headers = self._form_post_headers(referer=referer or url)
        else:
            headers = self._browser_headers(referer=referer)
        async with self.session.request(
            method=method,
            url=url,
            headers=headers,
            data=data,
            allow_redirects=False,
        ) as response:
            location = response.headers.get("Location")
            text = await response.text()
            return response.status, text, location

    async def _follow_identity_redirects(
        self,
        start_url: str,
        *,
        referer: str | None = None,
        max_redirects: int = 10,
    ) -> str:
        """Follow identity redirects until a terminal URL is reached."""
        url = start_url
        current_referer = referer
        for _ in range(max_redirects):
            status, _text, location = await self._identity_request(
                "GET",
                url,
                referer=current_referer,
            )
            if status in {301, 302, 303, 307, 308} and location:
                next_url = resolve_redirect_url(location)
                current_referer = url
                url = next_url
                continue
            if status == 200:
                return url
            raise AuthError(f"Unexpected identity response ({status}) for {url}")
        raise AuthError("Too many identity redirects.")

    def _authorization_code_from_url(self, url: str) -> str | None:
        """Return an authorization code when the URL matches the OAuth redirect."""
        code, _state = decode_oauth_redirect(url)
        if code is None:
            return None
        parsed = urllib.parse.urlparse(url)
        redirect = urllib.parse.urlparse(AUTH_REDIRECT_URI)
        if parsed.netloc == redirect.netloc and parsed.path == redirect.path:
            return code
        return None

    def _raise_if_identity_error(self, url: str) -> None:
        """Raise when an identity redirect indicates a failed login."""
        error_code = identity_error_code(url)
        if error_code is not None:
            raise AuthError(f"Identity login failed (error_code={error_code}): {url}")
        if is_identity_login_url(url):
            raise AuthError(f"Login was not accepted: {url}")

    async def _raise_mfa_required(self, url: str) -> None:
        """Request an MFA code for *url* and raise ``MFARequiredError``."""
        self._login_referer = url
        challenge = login_challenge_from_url(url)
        if challenge is not None:
            self._login_challenge = challenge
        await self.request_mfa_code()
        raise MFARequiredError(
            "Multi-factor authentication required. A verification code has "
            "been sent; call send_mfa_request() with the code."
        )

    async def _follow_until_authorization_code(
        self,
        start_url: str,
        *,
        referer: str | None = None,
        max_redirects: int = 15,
    ) -> str:
        """Follow redirects until the OAuth authorization code is available."""
        url = resolve_redirect_url(start_url, base_url=AUTH_BASE_URL)
        if url.startswith(("http://", "https://")) and AUTH_BASE_URL not in url:
            code = self._authorization_code_from_url(url)
            if code is not None:
                return code

        current_referer = referer
        for _ in range(max_redirects):
            code = self._authorization_code_from_url(url)
            if code is not None:
                return code

            self._raise_if_identity_error(url)
            if is_identity_mfa_url(url):
                await self._raise_mfa_required(url)

            status, _text, location = await self._identity_request(
                "GET",
                url,
                referer=current_referer,
            )
            if status in {301, 302, 303, 307, 308} and location:
                next_url = resolve_redirect_url(location, base_url=AUTH_BASE_URL)
                if next_url.startswith("/"):
                    next_url = resolve_redirect_url(next_url, base_url=AUTH_BASE_URL)
                elif not next_url.startswith(("http://", "https://")):
                    next_url = resolve_redirect_url(next_url, base_url=AUTH_BASE_URL)
                current_referer = url
                url = next_url
                continue
            raise AuthError(
                "OAuth redirect chain ended without authorization code "
                f"({status}): {url}"
            )
        raise AuthError(
            "Too many OAuth redirects while waiting for authorization code."
        )

    async def _ensure_login_challenge(self) -> str:
        """Start the OAuth login flow and capture the Hydra login challenge."""
        if self._login_challenge is not None:
            return self._login_challenge

        if self.authorization_url is None:
            await self.send_login_request()

        if self.authorization_url is None:
            raise AuthError("Authorization URL was not produced by the login request.")
        url = await self._follow_identity_redirects(self.authorization_url)
        challenge = login_challenge_from_url(url)
        if challenge is None and "/gol/login" in url:
            challenge = login_challenge_from_url(url)
        if challenge is None:
            raise AuthError("login_challenge not found in identity login redirect.")
        self._login_challenge = challenge
        self._login_referer = url
        return challenge

    def build_authorization_url(self) -> str:
        """Build a PKCE authorization URL for browser-based login."""
        self._pkce_verifier = random_string(43, 128)
        code_challenge = build_code_challenge(self._pkce_verifier)
        self._oauth_state = secrets.token_urlsafe(32)
        params: dict[str, str] = {
            "client_id": AUTH_CLIENT_ID,
            "response_type": "code",
            "redirect_uri": AUTH_REDIRECT_URI,
            "scope": AUTH_SCOPE,
            "code_challenge": code_challenge,
            "code_challenge_method": AUTH_CODE_CHALLENGE_METHOD,
            "state": self._oauth_state,
            **AUTH_EXTRA_PARAMS,
        }
        if self.login_hint:
            params["login_hint"] = self.login_hint
        authorization_url = (
            f"{self.authorization_endpoint}?{urllib.parse.urlencode(params)}"
        )
        self.authorization_url = authorization_url
        return authorization_url

    async def send_login_request(self) -> str:
        """Prepare browser login and return the authorization URL."""
        await self.fetch_oidc_configuration()
        return self.build_authorization_url()

    async def exchange_authorization_code(self, code: str) -> dict[str, Any]:
        """Exchange an authorization code for OAuth tokens."""
        if self._pkce_verifier is None:
            raise ConfirmationRedirectError(
                "Missing PKCE verifier. Call send_login_request() first."
            )

        async with self.session.post(
            self.token_endpoint,
            data=urllib.parse.urlencode(
                {
                    "grant_type": "authorization_code",
                    "client_id": AUTH_CLIENT_ID,
                    "redirect_uri": AUTH_REDIRECT_URI,
                    "code": code,
                    "code_verifier": self._pkce_verifier,
                }
            ),
            headers={
                **self._browser_headers(
                    referer=self.authorization_url or self.authorization_endpoint
                ),
                "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
            },
        ) as response:
            text = await response.text()
            if response.status != 200:
                self._raise_mapped_token_error(response.status, text)
            token_data = await response.json()

        self.access_token = token_data.get("access_token")
        if token_data.get("refresh_token"):
            self._refresh_token = token_data["refresh_token"]
        try:
            expires_in = int(token_data.get("expires_in", 3600))
        except (TypeError, ValueError):
            expires_in = 3600
        self.next_refresh = datetime.now(UTC) + timedelta(seconds=expires_in)
        return token_data

    async def finish_login(
        self,
        redirect_or_code: str,
        *,
        expected_state: str | None = None,
        exchange_commerce: bool = True,
    ) -> dict[str, Any]:
        """Complete browser login from a redirect URL or raw authorization code."""
        code, state = parse_authorization_input(redirect_or_code)
        if code is None:
            raise ConfirmationRedirectError("Authorization code not found.")
        if (
            expected_state is None
            and self._oauth_state is not None
            and state is not None
            and state != self._oauth_state
        ):
            raise ConfirmationRedirectError("OAuth state mismatch.")
        if expected_state is not None and state != expected_state:
            raise ConfirmationRedirectError("OAuth state mismatch.")

        token_data = await self.exchange_authorization_code(code)
        if exchange_commerce:
            await self.exchange_commerce_session()
        return token_data

    async def send_credentials(
        self,
        username: str,
        password: str,
        *,
        io_black_box: str | None = None,
    ) -> None:
        """Submit username and password to the web identity login form."""
        login_challenge = await self._ensure_login_challenge()

        referer = self._login_referer or (
            f"{AUTH_LOGIN_URL}?login_challenge={login_challenge}"
        )
        form: dict[str, str] = {
            "web_authn_device": "0",
            "login_challenge": login_challenge,
            "username": username,
            "password": password,
        }
        if io_black_box is not None:
            form["ioBlackBox"] = io_black_box

        status, _text, location = await self._identity_request(
            "POST",
            AUTH_LOGIN_URL,
            data=form,
            referer=referer,
        )
        if status not in {301, 302, 303, 307, 308} or not location:
            raise AuthError(f"Login failed ({status}).")

        code = await self._follow_until_authorization_code(
            resolve_redirect_url(location),
            referer=AUTH_LOGIN_URL,
        )
        await self.exchange_authorization_code(code)

    async def request_mfa_code(self) -> None:
        """Request delivery of an MFA verification code."""
        referer = self._login_referer or AUTH_MFA_URL
        headers = self._browser_headers(referer=referer)
        headers["Accept"] = "*/*"
        headers["Content-Type"] = "application/json"
        headers["x-forwarded-from"] = "gol"
        headers["sec-fetch-dest"] = "empty"
        headers["sec-fetch-mode"] = "cors"
        headers.pop("sec-fetch-user", None)

        async with self.session.post(
            AUTH_SEND_MFA_URL,
            headers=headers,
            allow_redirects=False,
        ) as response:
            text = await response.text()
            if response.status not in {200, 204}:
                raise AuthError(
                    f"Failed to send MFA verification code ({response.status}): {text}"
                )

    async def send_mfa_request(
        self,
        code: str,
        *,
        io_black_box: str | None = None,
        exchange_commerce: bool = True,
    ) -> dict[str, Any]:
        """Submit an MFA verification code and complete OAuth token exchange."""
        referer = self._login_referer or AUTH_MFA_URL
        form: dict[str, str] = {"code": code}
        if io_black_box is not None:
            form["ioBlackBox"] = io_black_box

        status, _text, location = await self._identity_request(
            "POST",
            AUTH_MFA_URL,
            data=form,
            referer=referer,
        )
        if status not in {301, 302, 303, 307, 308} or not location:
            raise AuthError(f"MFA verification failed ({status}).")

        auth_code = await self._follow_until_authorization_code(
            resolve_redirect_url(location),
            referer=AUTH_MFA_URL,
        )
        token_data = await self.exchange_authorization_code(auth_code)
        if exchange_commerce:
            await self.exchange_commerce_session()
        return token_data

    async def login(
        self,
        username: str | None = None,
        password: str | None = None,
        *,
        mfa_code: str | None = None,
        io_black_box: str | None = None,
        exchange_commerce: bool = True,
    ) -> dict[str, Any] | None:
        """Sign in via web credentials or start interactive browser login."""
        if username is not None and password is not None:
            await self.send_login_request()
            try:
                await self.send_credentials(
                    username,
                    password,
                    io_black_box=io_black_box,
                )
            except MFARequiredError:
                if mfa_code is None:
                    raise
                return await self.send_mfa_request(
                    mfa_code,
                    io_black_box=io_black_box,
                    exchange_commerce=exchange_commerce,
                )
            if exchange_commerce:
                await self.exchange_commerce_session()
            return None

        authorization_url = await self.send_login_request()
        raise BrowserLoginRequiredError(
            "Open the authorization URL in a desktop browser, sign in, then "
            "call finish_login() with the redirect URL or authorization code.",
            authorization_url=authorization_url,
        )

    async def send_refresh_request(self) -> None:
        """Refresh the OAuth access token when a refresh token is available."""
        if self.refresh_token is None:
            return
        if self.next_refresh is not None and self.next_refresh > datetime.now(UTC):
            return

        if self.oidc_config is None:
            try:
                await self.fetch_oidc_configuration()
            except TokenRequestError:
                _LOGGER.debug(
                    "GOL Auth: OIDC discovery unavailable during refresh; "
                    "using static token endpoint"
                )

        _LOGGER.debug("GOL Auth: refreshing access token")
        try:
            async with self.session.post(
                self.token_endpoint,
                data=urllib.parse.urlencode(
                    {
                        "grant_type": "refresh_token",
                        "client_id": AUTH_CLIENT_ID,
                        "refresh_token": self.refresh_token,
                    }
                ),
                headers={
                    **self._browser_headers(),
                    "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
                },
            ) as response:
                text = await response.text()
                if response.status != 200:
                    self._raise_mapped_token_error(response.status, text)

                token_data = await response.json()
        except InvalidGrantError:
            _LOGGER.warning("GOL Auth: refresh token rejected")
            self._refresh_token = None
            raise SessionRequiredError(_SESSION_EXPIRED_MESSAGE) from None

        self.access_token = token_data.get("access_token")
        if token_data.get("refresh_token"):
            self._refresh_token = token_data["refresh_token"]
        try:
            expires_in = int(token_data.get("expires_in", 3600))
        except (TypeError, ValueError):
            expires_in = 3600
        self.next_refresh = datetime.now(UTC) + timedelta(seconds=expires_in)
        self._sync_session_cookies()

    async def exchange_commerce_session(
        self,
        *,
        food_profile_create: bool = True,
    ) -> dict[str, Any]:
        """Exchange the OAuth access token for WC commerce session tokens."""
        if self.access_token is None:
            raise SessionRequiredError("Access token required for commerce exchange.")

        endpoint = GOL_ENDPOINTS["login_access_token"]
        url = GOL_BASE_URL + endpoint["endpoint"]
        headers = {
            "Accept": "application/json",
            "Content-Type": "application/json",
            "User-Agent": f"GOLAppAndroid/{self.app_version}",
            "Authorization": f"Bearer {self.access_token}",
        }
        body = {
            "access_token": self.access_token,
            "food_profile_create": food_profile_create,
        }

        async with self.session.request(
            method=endpoint["method"],
            url=url,
            headers=headers,
            json=body,
        ) as response:
            text = await response.text()
            if response.status != 200:
                if response.status == 400 and "INVALID_TOKEN" in text:
                    raise SessionRequiredError(_SESSION_EXPIRED_MESSAGE)
                raise CommerceSessionError(
                    f"Commerce session exchange failed ({response.status}): {text}"
                )
            data = await response.json()
            for cookie in response.cookies.values():
                self.cookies[cookie.key] = cookie.value

        self.user_id = data.get("user_id")
        self.wc_trusted_token = data.get("wc_trusted_token")
        self.personalization_id = data.get("personalization_id")
        self.wc_auth_token = normalize_wc_auth_token(
            user_id=self.user_id,
            wc_trusted_token=self.wc_trusted_token,
        )

        return data

    async def refresh_commerce_session(
        self,
        *,
        food_profile_create: bool = True,
    ) -> dict[str, Any]:
        """Re-exchange OAuth tokens for a fresh commerce session."""
        return await self.exchange_commerce_session(
            food_profile_create=food_profile_create,
        )

    async def send_request(
        self,
        method: str,
        url: str,
        body: dict[str, Any] | list[Any] | None = None,
        *,
        headers: dict[str, str] | None = None,
        params: dict[str, str | int | float | bool] | None = None,
    ) -> dict[str, Any] | list[Any] | None:
        """Send a request to the API and return the JSON response."""
        await self.send_refresh_request()
        if self.wc_auth_token is None and not self.cookies:
            raise SessionRequiredError(
                "Commerce session required. Provide WCAuthToken/cookies or call "
                "exchange_commerce_session()."
            )

        return await self._send_authenticated_request(
            method=method,
            url=url,
            body=body,
            headers=headers,
            params=params,
            retry_commerce=True,
        )

    async def _send_authenticated_request(
        self,
        method: str,
        url: str,
        body: dict[str, Any] | list[Any] | None = None,
        *,
        headers: dict[str, str] | None = None,
        params: dict[str, str | int | float | bool] | None = None,
        retry_commerce: bool = False,
    ) -> dict[str, Any] | list[Any] | None:
        """Send an authenticated grocery API request."""
        request_headers = self.authenticated_headers
        if headers:
            request_headers = {**request_headers, **headers}

        async with self.session.request(
            method=method,
            url=url,
            headers=request_headers,
            json=body,
            params=params,
        ) as response:
            _LOGGER.debug(
                "Request to %s returned with status %s",
                url,
                response.status,
            )
            if response.status == 401 and retry_commerce:
                await response.release()
                if self._oauth_access_token_expired():
                    raise SessionRequiredError(_SESSION_EXPIRED_MESSAGE)
                if self.access_token:
                    _LOGGER.debug("GOL Auth: refreshing commerce session after 401")
                    await self.exchange_commerce_session()
                    return await self._send_authenticated_request(
                        method=method,
                        url=url,
                        body=body,
                        headers=headers,
                        params=params,
                        retry_commerce=False,
                    )
            if response.status == 401:
                raise ExpiredAccessTokenError(_SESSION_EXPIRED_MESSAGE)
            if response.ok:
                if response.content_length == 0:
                    return None
                content_type = response.headers.get("Content-Type", "")
                if "application/json" in content_type:
                    return await response.json()
                text = await response.text()
                if not text:
                    return None
                return json.loads(text)
            raise UnknownEndpointError(response.status, await response.text())

    @property
    def public_headers(self) -> dict[str, str]:
        """Return headers for unauthenticated grocery API requests."""
        return {
            "Accept": "application/json",
            "User-Agent": f"GOLAppAndroid/{self.app_version}",
        }

    async def send_public_request(
        self,
        method: str,
        url: str,
        *,
        headers: dict[str, str] | None = None,
        params: dict[str, str | int | float | bool] | None = None,
    ) -> dict[str, Any] | list[Any] | None:
        """Send a request that does not require a commerce session."""
        request_headers = self.public_headers
        if headers:
            request_headers = {**request_headers, **headers}

        async with self.session.request(
            method=method,
            url=url,
            headers=request_headers,
            params=params,
        ) as response:
            _LOGGER.debug(
                "Public request to %s returned with status %s",
                url,
                response.status,
            )
            if response.ok:
                if response.content_length == 0:
                    return None
                content_type = response.headers.get("Content-Type", "")
                if "application/json" in content_type:
                    return await response.json()
                text = await response.text()
                if not text:
                    return None
                return json.loads(text)
            raise UnknownEndpointError(response.status, await response.text())

authenticated_headers property

Return authenticated headers for grocery API calls.

authorization_endpoint property

Return the OAuth authorization endpoint URL.

public_headers property

Return headers for unauthenticated grocery API requests.

refresh_token property

Return the OAuth refresh token.

session property

Return the aiohttp session, creating one when needed.

token_endpoint property

Return the OAuth token endpoint URL.

__aenter__() async

Enter async context manager.

Source code in pysainsburys/auth.py
320
321
322
async def __aenter__(self) -> GOLAuth:
    """Enter async context manager."""
    return self

__aexit__(*_args) async

Close the auth session on context exit.

Source code in pysainsburys/auth.py
324
325
326
async def __aexit__(self, *_args: object) -> None:
    """Close the auth session on context exit."""
    await self.close()

__iter__()

Allow dict(auth) conversion.

Source code in pysainsburys/auth.py
306
307
308
def __iter__(self) -> Iterator[tuple[str, Any]]:
    """Allow ``dict(auth)`` conversion."""
    return iter(self.to_dict().items())

build_authorization_url()

Build a PKCE authorization URL for browser-based login.

Source code in pysainsburys/auth.py
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
def build_authorization_url(self) -> str:
    """Build a PKCE authorization URL for browser-based login."""
    self._pkce_verifier = random_string(43, 128)
    code_challenge = build_code_challenge(self._pkce_verifier)
    self._oauth_state = secrets.token_urlsafe(32)
    params: dict[str, str] = {
        "client_id": AUTH_CLIENT_ID,
        "response_type": "code",
        "redirect_uri": AUTH_REDIRECT_URI,
        "scope": AUTH_SCOPE,
        "code_challenge": code_challenge,
        "code_challenge_method": AUTH_CODE_CHALLENGE_METHOD,
        "state": self._oauth_state,
        **AUTH_EXTRA_PARAMS,
    }
    if self.login_hint:
        params["login_hint"] = self.login_hint
    authorization_url = (
        f"{self.authorization_endpoint}?{urllib.parse.urlencode(params)}"
    )
    self.authorization_url = authorization_url
    return authorization_url

close() async

Close the underlying HTTP session when owned by this object.

Source code in pysainsburys/auth.py
310
311
312
313
314
315
316
317
318
async def close(self) -> None:
    """Close the underlying HTTP session when owned by this object."""
    if (
        self._owns_session
        and self._auth_session is not None
        and not self._auth_session.closed
    ):
        await self._auth_session.close()
        self._auth_session = None

exchange_authorization_code(code) async

Exchange an authorization code for OAuth tokens.

Source code in pysainsburys/auth.py
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
async def exchange_authorization_code(self, code: str) -> dict[str, Any]:
    """Exchange an authorization code for OAuth tokens."""
    if self._pkce_verifier is None:
        raise ConfirmationRedirectError(
            "Missing PKCE verifier. Call send_login_request() first."
        )

    async with self.session.post(
        self.token_endpoint,
        data=urllib.parse.urlencode(
            {
                "grant_type": "authorization_code",
                "client_id": AUTH_CLIENT_ID,
                "redirect_uri": AUTH_REDIRECT_URI,
                "code": code,
                "code_verifier": self._pkce_verifier,
            }
        ),
        headers={
            **self._browser_headers(
                referer=self.authorization_url or self.authorization_endpoint
            ),
            "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
        },
    ) as response:
        text = await response.text()
        if response.status != 200:
            self._raise_mapped_token_error(response.status, text)
        token_data = await response.json()

    self.access_token = token_data.get("access_token")
    if token_data.get("refresh_token"):
        self._refresh_token = token_data["refresh_token"]
    try:
        expires_in = int(token_data.get("expires_in", 3600))
    except (TypeError, ValueError):
        expires_in = 3600
    self.next_refresh = datetime.now(UTC) + timedelta(seconds=expires_in)
    return token_data

exchange_commerce_session(*, food_profile_create=True) async

Exchange the OAuth access token for WC commerce session tokens.

Source code in pysainsburys/auth.py
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
async def exchange_commerce_session(
    self,
    *,
    food_profile_create: bool = True,
) -> dict[str, Any]:
    """Exchange the OAuth access token for WC commerce session tokens."""
    if self.access_token is None:
        raise SessionRequiredError("Access token required for commerce exchange.")

    endpoint = GOL_ENDPOINTS["login_access_token"]
    url = GOL_BASE_URL + endpoint["endpoint"]
    headers = {
        "Accept": "application/json",
        "Content-Type": "application/json",
        "User-Agent": f"GOLAppAndroid/{self.app_version}",
        "Authorization": f"Bearer {self.access_token}",
    }
    body = {
        "access_token": self.access_token,
        "food_profile_create": food_profile_create,
    }

    async with self.session.request(
        method=endpoint["method"],
        url=url,
        headers=headers,
        json=body,
    ) as response:
        text = await response.text()
        if response.status != 200:
            if response.status == 400 and "INVALID_TOKEN" in text:
                raise SessionRequiredError(_SESSION_EXPIRED_MESSAGE)
            raise CommerceSessionError(
                f"Commerce session exchange failed ({response.status}): {text}"
            )
        data = await response.json()
        for cookie in response.cookies.values():
            self.cookies[cookie.key] = cookie.value

    self.user_id = data.get("user_id")
    self.wc_trusted_token = data.get("wc_trusted_token")
    self.personalization_id = data.get("personalization_id")
    self.wc_auth_token = normalize_wc_auth_token(
        user_id=self.user_id,
        wc_trusted_token=self.wc_trusted_token,
    )

    return data

fetch_oidc_configuration() async

Fetch OIDC discovery metadata using browser-like headers.

Source code in pysainsburys/auth.py
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
async def fetch_oidc_configuration(self) -> dict[str, Any]:
    """Fetch OIDC discovery metadata using browser-like headers."""
    _LOGGER.debug("GOL Auth: fetching OIDC discovery document")
    async with self.session.get(
        AUTH_DISCOVERY_URL,
        headers=self._browser_headers(),
    ) as response:
        text = await response.text()
        if response.status != 200:
            raise TokenRequestError(
                f"OIDC discovery failed ({response.status}): {text}"
            )
        data = await response.json()
        if not isinstance(data, dict):
            msg = "OIDC discovery response was not a JSON object."
            raise TokenRequestError(msg)
        self.oidc_config = data
        return data

finish_login(redirect_or_code, *, expected_state=None, exchange_commerce=True) async

Complete browser login from a redirect URL or raw authorization code.

Source code in pysainsburys/auth.py
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
async def finish_login(
    self,
    redirect_or_code: str,
    *,
    expected_state: str | None = None,
    exchange_commerce: bool = True,
) -> dict[str, Any]:
    """Complete browser login from a redirect URL or raw authorization code."""
    code, state = parse_authorization_input(redirect_or_code)
    if code is None:
        raise ConfirmationRedirectError("Authorization code not found.")
    if (
        expected_state is None
        and self._oauth_state is not None
        and state is not None
        and state != self._oauth_state
    ):
        raise ConfirmationRedirectError("OAuth state mismatch.")
    if expected_state is not None and state != expected_state:
        raise ConfirmationRedirectError("OAuth state mismatch.")

    token_data = await self.exchange_authorization_code(code)
    if exchange_commerce:
        await self.exchange_commerce_session()
    return token_data

from_dict(data) classmethod

Create an auth object from a serialized session mapping.

Source code in pysainsburys/auth.py
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
@classmethod
def from_dict(cls, data: dict[str, Any]) -> GOLAuth:
    """Create an auth object from a serialized session mapping."""
    cookies = data.get("cookies")
    if isinstance(cookies, str):
        cookies = parse_cookie_header(cookies)
    elif cookies is None:
        cookies = {}
    elif not isinstance(cookies, dict):
        msg = "Session cookies must be a mapping or Cookie header string."
        raise ValueError(msg)

    auth = cls(
        access_token=data.get("access_token"),
        refresh_token=data.get("refresh_token"),
        wc_auth_token=data.get("wc_auth_token"),
        user_id=data.get("user_id"),
        wc_trusted_token=data.get("wc_trusted_token"),
        cookies={str(k): str(v) for k, v in cookies.items()},
        app_version=data.get("app_version"),
        login_hint=data.get("login_hint"),
    )
    return cls._apply_session_metadata(auth, data)

from_pending_login_dict(data) classmethod

Restore in-progress login state from a mapping.

Source code in pysainsburys/auth.py
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
@classmethod
def from_pending_login_dict(cls, data: dict[str, Any]) -> GOLAuth:
    """Restore in-progress login state from a mapping."""
    payload = dict(data)
    pending_fields = (
        "pkce_verifier",
        "oauth_state",
        "authorization_url",
        "login_referer",
        "login_challenge",
    )
    pending = {field: payload.pop(field, None) for field in pending_fields}
    auth = cls.from_dict(payload)
    auth._pkce_verifier = pending["pkce_verifier"]
    auth._oauth_state = pending["oauth_state"]
    auth.authorization_url = pending["authorization_url"]
    auth._login_referer = pending["login_referer"]
    auth._login_challenge = pending["login_challenge"]
    return auth

from_pending_login_file(path) async classmethod

Load in-progress login state from disk.

Source code in pysainsburys/auth.py
296
297
298
299
@classmethod
async def from_pending_login_file(cls, path: str) -> GOLAuth:
    """Load in-progress login state from disk."""
    return cls.from_pending_login_dict(await load_session_file(path))

from_session_file(path) async classmethod

Load a session export created by tooling or a previous to_dict().

Source code in pysainsburys/auth.py
180
181
182
183
@classmethod
async def from_session_file(cls, path: str) -> GOLAuth:
    """Load a session export created by tooling or a previous ``to_dict()``."""
    return cls.from_dict(await load_session_file(path))

login(username=None, password=None, *, mfa_code=None, io_black_box=None, exchange_commerce=True) async

Sign in via web credentials or start interactive browser login.

Source code in pysainsburys/auth.py
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
async def login(
    self,
    username: str | None = None,
    password: str | None = None,
    *,
    mfa_code: str | None = None,
    io_black_box: str | None = None,
    exchange_commerce: bool = True,
) -> dict[str, Any] | None:
    """Sign in via web credentials or start interactive browser login."""
    if username is not None and password is not None:
        await self.send_login_request()
        try:
            await self.send_credentials(
                username,
                password,
                io_black_box=io_black_box,
            )
        except MFARequiredError:
            if mfa_code is None:
                raise
            return await self.send_mfa_request(
                mfa_code,
                io_black_box=io_black_box,
                exchange_commerce=exchange_commerce,
            )
        if exchange_commerce:
            await self.exchange_commerce_session()
        return None

    authorization_url = await self.send_login_request()
    raise BrowserLoginRequiredError(
        "Open the authorization URL in a desktop browser, sign in, then "
        "call finish_login() with the redirect URL or authorization code.",
        authorization_url=authorization_url,
    )

pending_login_to_dict()

Return in-progress login state for MFA completion.

Source code in pysainsburys/auth.py
261
262
263
264
265
266
267
268
269
270
def pending_login_to_dict(self) -> dict[str, Any]:
    """Return in-progress login state for MFA completion."""
    return {
        **self.to_dict(),
        "pkce_verifier": self._pkce_verifier,
        "oauth_state": self._oauth_state,
        "authorization_url": self.authorization_url,
        "login_referer": self._login_referer,
        "login_challenge": self._login_challenge,
    }

refresh_commerce_session(*, food_profile_create=True) async

Re-exchange OAuth tokens for a fresh commerce session.

Source code in pysainsburys/auth.py
845
846
847
848
849
850
851
852
853
async def refresh_commerce_session(
    self,
    *,
    food_profile_create: bool = True,
) -> dict[str, Any]:
    """Re-exchange OAuth tokens for a fresh commerce session."""
    return await self.exchange_commerce_session(
        food_profile_create=food_profile_create,
    )

request_mfa_code() async

Request delivery of an MFA verification code.

Source code in pysainsburys/auth.py
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
async def request_mfa_code(self) -> None:
    """Request delivery of an MFA verification code."""
    referer = self._login_referer or AUTH_MFA_URL
    headers = self._browser_headers(referer=referer)
    headers["Accept"] = "*/*"
    headers["Content-Type"] = "application/json"
    headers["x-forwarded-from"] = "gol"
    headers["sec-fetch-dest"] = "empty"
    headers["sec-fetch-mode"] = "cors"
    headers.pop("sec-fetch-user", None)

    async with self.session.post(
        AUTH_SEND_MFA_URL,
        headers=headers,
        allow_redirects=False,
    ) as response:
        text = await response.text()
        if response.status not in {200, 204}:
            raise AuthError(
                f"Failed to send MFA verification code ({response.status}): {text}"
            )

save_pending_login(path) async

Persist in-progress login state awaiting MFA verification.

Source code in pysainsburys/auth.py
292
293
294
async def save_pending_login(self, path: str) -> None:
    """Persist in-progress login state awaiting MFA verification."""
    await save_session_file(path, self.pending_login_to_dict())

save_session_file(path) async

Persist the current session to disk.

Source code in pysainsburys/auth.py
256
257
258
259
async def save_session_file(self, path: str) -> None:
    """Persist the current session to disk."""
    self._sync_session_cookies()
    await save_session_file(path, self.to_dict())

send_credentials(username, password, *, io_black_box=None) async

Submit username and password to the web identity login form.

Source code in pysainsburys/auth.py
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
async def send_credentials(
    self,
    username: str,
    password: str,
    *,
    io_black_box: str | None = None,
) -> None:
    """Submit username and password to the web identity login form."""
    login_challenge = await self._ensure_login_challenge()

    referer = self._login_referer or (
        f"{AUTH_LOGIN_URL}?login_challenge={login_challenge}"
    )
    form: dict[str, str] = {
        "web_authn_device": "0",
        "login_challenge": login_challenge,
        "username": username,
        "password": password,
    }
    if io_black_box is not None:
        form["ioBlackBox"] = io_black_box

    status, _text, location = await self._identity_request(
        "POST",
        AUTH_LOGIN_URL,
        data=form,
        referer=referer,
    )
    if status not in {301, 302, 303, 307, 308} or not location:
        raise AuthError(f"Login failed ({status}).")

    code = await self._follow_until_authorization_code(
        resolve_redirect_url(location),
        referer=AUTH_LOGIN_URL,
    )
    await self.exchange_authorization_code(code)

send_login_request() async

Prepare browser login and return the authorization URL.

Source code in pysainsburys/auth.py
546
547
548
549
async def send_login_request(self) -> str:
    """Prepare browser login and return the authorization URL."""
    await self.fetch_oidc_configuration()
    return self.build_authorization_url()

send_mfa_request(code, *, io_black_box=None, exchange_commerce=True) async

Submit an MFA verification code and complete OAuth token exchange.

Source code in pysainsburys/auth.py
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
async def send_mfa_request(
    self,
    code: str,
    *,
    io_black_box: str | None = None,
    exchange_commerce: bool = True,
) -> dict[str, Any]:
    """Submit an MFA verification code and complete OAuth token exchange."""
    referer = self._login_referer or AUTH_MFA_URL
    form: dict[str, str] = {"code": code}
    if io_black_box is not None:
        form["ioBlackBox"] = io_black_box

    status, _text, location = await self._identity_request(
        "POST",
        AUTH_MFA_URL,
        data=form,
        referer=referer,
    )
    if status not in {301, 302, 303, 307, 308} or not location:
        raise AuthError(f"MFA verification failed ({status}).")

    auth_code = await self._follow_until_authorization_code(
        resolve_redirect_url(location),
        referer=AUTH_MFA_URL,
    )
    token_data = await self.exchange_authorization_code(auth_code)
    if exchange_commerce:
        await self.exchange_commerce_session()
    return token_data

send_public_request(method, url, *, headers=None, params=None) async

Send a request that does not require a commerce session.

Source code in pysainsburys/auth.py
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
async def send_public_request(
    self,
    method: str,
    url: str,
    *,
    headers: dict[str, str] | None = None,
    params: dict[str, str | int | float | bool] | None = None,
) -> dict[str, Any] | list[Any] | None:
    """Send a request that does not require a commerce session."""
    request_headers = self.public_headers
    if headers:
        request_headers = {**request_headers, **headers}

    async with self.session.request(
        method=method,
        url=url,
        headers=request_headers,
        params=params,
    ) as response:
        _LOGGER.debug(
            "Public request to %s returned with status %s",
            url,
            response.status,
        )
        if response.ok:
            if response.content_length == 0:
                return None
            content_type = response.headers.get("Content-Type", "")
            if "application/json" in content_type:
                return await response.json()
            text = await response.text()
            if not text:
                return None
            return json.loads(text)
        raise UnknownEndpointError(response.status, await response.text())

send_refresh_request() async

Refresh the OAuth access token when a refresh token is available.

Source code in pysainsburys/auth.py
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
async def send_refresh_request(self) -> None:
    """Refresh the OAuth access token when a refresh token is available."""
    if self.refresh_token is None:
        return
    if self.next_refresh is not None and self.next_refresh > datetime.now(UTC):
        return

    if self.oidc_config is None:
        try:
            await self.fetch_oidc_configuration()
        except TokenRequestError:
            _LOGGER.debug(
                "GOL Auth: OIDC discovery unavailable during refresh; "
                "using static token endpoint"
            )

    _LOGGER.debug("GOL Auth: refreshing access token")
    try:
        async with self.session.post(
            self.token_endpoint,
            data=urllib.parse.urlencode(
                {
                    "grant_type": "refresh_token",
                    "client_id": AUTH_CLIENT_ID,
                    "refresh_token": self.refresh_token,
                }
            ),
            headers={
                **self._browser_headers(),
                "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
            },
        ) as response:
            text = await response.text()
            if response.status != 200:
                self._raise_mapped_token_error(response.status, text)

            token_data = await response.json()
    except InvalidGrantError:
        _LOGGER.warning("GOL Auth: refresh token rejected")
        self._refresh_token = None
        raise SessionRequiredError(_SESSION_EXPIRED_MESSAGE) from None

    self.access_token = token_data.get("access_token")
    if token_data.get("refresh_token"):
        self._refresh_token = token_data["refresh_token"]
    try:
        expires_in = int(token_data.get("expires_in", 3600))
    except (TypeError, ValueError):
        expires_in = 3600
    self.next_refresh = datetime.now(UTC) + timedelta(seconds=expires_in)
    self._sync_session_cookies()

send_request(method, url, body=None, *, headers=None, params=None) async

Send a request to the API and return the JSON response.

Source code in pysainsburys/auth.py
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
async def send_request(
    self,
    method: str,
    url: str,
    body: dict[str, Any] | list[Any] | None = None,
    *,
    headers: dict[str, str] | None = None,
    params: dict[str, str | int | float | bool] | None = None,
) -> dict[str, Any] | list[Any] | None:
    """Send a request to the API and return the JSON response."""
    await self.send_refresh_request()
    if self.wc_auth_token is None and not self.cookies:
        raise SessionRequiredError(
            "Commerce session required. Provide WCAuthToken/cookies or call "
            "exchange_commerce_session()."
        )

    return await self._send_authenticated_request(
        method=method,
        url=url,
        body=body,
        headers=headers,
        params=params,
        retry_commerce=True,
    )

to_dict()

Return the session as a JSON-serializable mapping.

Source code in pysainsburys/auth.py
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
def to_dict(self) -> dict[str, Any]:
    """Return the session as a JSON-serializable mapping."""
    return {
        "access_token": self.access_token,
        "refresh_token": self.refresh_token,
        "wc_auth_token": self.wc_auth_token,
        "user_id": self.user_id,
        "wc_trusted_token": self.wc_trusted_token,
        "cookies": self.cookies,
        "app_version": self.app_version,
        "login_hint": self.login_hint,
        "personalization_id": self.personalization_id,
        "next_refresh": (
            self.next_refresh.isoformat() if self.next_refresh is not None else None
        ),
    }

pysainsburys.API

API handler for Sainsbury's GOL.

Source code in pysainsburys/api.py
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
class API:
    """API handler for Sainsbury's GOL."""

    def __init__(self, auth_obj: GOLAuth) -> None:
        self._auth = auth_obj

    @property
    def user_id(self) -> str | None:
        """Return the commerce user id when known."""
        return self._auth.user_id

    async def send_request(
        self,
        endpoint: str,
        body: dict[str, Any] | list[Any] | None = None,
        *,
        params: dict[str, str | int | float | bool] | None = None,
        headers: dict[str, str] | None = None,
        **path_params: str,
    ) -> dict[str, Any] | list[Any] | None:
        """Send a request to the API using the authentication handler."""
        if endpoint not in GOL_ENDPOINTS:
            raise ValueError("Provided API endpoint does not exist.")
        _LOGGER.debug(
            "API: sending request to endpoint '%s' with body: %s",
            endpoint,
            body,
        )
        endpoint_map = GOL_ENDPOINTS[endpoint]
        built_url = GOL_BASE_URL + endpoint_map["endpoint"].format(**path_params)
        endpoint_headers = endpoint_map.get("headers")
        request_headers: dict[str, str] | None = None
        if isinstance(endpoint_headers, dict):
            request_headers = dict(endpoint_headers)
        if headers:
            request_headers = {**(request_headers or {}), **headers}
        return await self._auth.send_request(
            method=endpoint_map["method"],
            url=built_url,
            body=body,
            params=params,
            headers=request_headers,
        )

    async def send_public_request(
        self,
        endpoint: str,
        *,
        params: dict[str, str | int | float | bool] | None = None,
        **path_params: str,
    ) -> dict[str, Any] | list[Any] | None:
        """Send a request that does not require a commerce session."""
        if endpoint not in GOL_ENDPOINTS:
            raise ValueError("Provided API endpoint does not exist.")
        endpoint_map = GOL_ENDPOINTS[endpoint]
        built_url = GOL_BASE_URL + endpoint_map["endpoint"].format(**path_params)
        return await self._auth.send_public_request(
            method=endpoint_map["method"],
            url=built_url,
            params=params,
        )

    async def send_product_finder_request(
        self,
        path: str,
        *,
        params: dict[str, str | int | float | bool] | None = None,
    ) -> dict[str, Any] | list[Any] | None:
        """Send a public request to the Product Finder API."""
        url = PRODUCT_FINDER_BASE_URL + path
        return await self._auth.send_public_request(
            method="GET",
            url=url,
            params=params,
        )

    async def exchange_commerce_session(
        self,
        *,
        food_profile_create: bool = True,
    ) -> dict[str, Any]:
        """Exchange OAuth tokens for a commerce session."""
        return await self._auth.exchange_commerce_session(
            food_profile_create=food_profile_create,
        )

    async def token_refresh(self) -> None:
        """Force OAuth token refresh."""
        await self._auth.send_refresh_request()

    async def login(self) -> str:
        """Prepare browser login and return the authorization URL."""
        return await self._auth.send_login_request()

    async def finish_login(
        self,
        redirect_or_code: str,
        *,
        exchange_commerce: bool = True,
    ) -> dict[str, Any]:
        """Complete browser login from a redirect URL or authorization code."""
        return await self._auth.finish_login(
            redirect_or_code,
            exchange_commerce=exchange_commerce,
        )

    async def logout(self) -> None:
        """End the remote commerce session."""
        await self.send_request(endpoint="logout")

    async def close(self) -> None:
        """Close the underlying HTTP session."""
        await self._auth.close()

    def to_dict(self) -> dict[str, Any]:
        """Return the API object data as a dictionary."""
        return {
            "user_id": self.user_id,
            "next_refresh": (
                self._auth.next_refresh.isoformat()
                if self._auth.next_refresh is not None
                else None
            ),
        }

    def __iter__(self) -> Iterator[tuple[str, Any]]:
        """Allow ``dict(api)`` conversion."""
        return iter(self.to_dict().items())

user_id property

Return the commerce user id when known.

__iter__()

Allow dict(api) conversion.

Source code in pysainsburys/api.py
140
141
142
def __iter__(self) -> Iterator[tuple[str, Any]]:
    """Allow ``dict(api)`` conversion."""
    return iter(self.to_dict().items())

close() async

Close the underlying HTTP session.

Source code in pysainsburys/api.py
125
126
127
async def close(self) -> None:
    """Close the underlying HTTP session."""
    await self._auth.close()

exchange_commerce_session(*, food_profile_create=True) async

Exchange OAuth tokens for a commerce session.

Source code in pysainsburys/api.py
91
92
93
94
95
96
97
98
99
async def exchange_commerce_session(
    self,
    *,
    food_profile_create: bool = True,
) -> dict[str, Any]:
    """Exchange OAuth tokens for a commerce session."""
    return await self._auth.exchange_commerce_session(
        food_profile_create=food_profile_create,
    )

finish_login(redirect_or_code, *, exchange_commerce=True) async

Complete browser login from a redirect URL or authorization code.

Source code in pysainsburys/api.py
109
110
111
112
113
114
115
116
117
118
119
async def finish_login(
    self,
    redirect_or_code: str,
    *,
    exchange_commerce: bool = True,
) -> dict[str, Any]:
    """Complete browser login from a redirect URL or authorization code."""
    return await self._auth.finish_login(
        redirect_or_code,
        exchange_commerce=exchange_commerce,
    )

login() async

Prepare browser login and return the authorization URL.

Source code in pysainsburys/api.py
105
106
107
async def login(self) -> str:
    """Prepare browser login and return the authorization URL."""
    return await self._auth.send_login_request()

logout() async

End the remote commerce session.

Source code in pysainsburys/api.py
121
122
123
async def logout(self) -> None:
    """End the remote commerce session."""
    await self.send_request(endpoint="logout")

send_product_finder_request(path, *, params=None) async

Send a public request to the Product Finder API.

Source code in pysainsburys/api.py
77
78
79
80
81
82
83
84
85
86
87
88
89
async def send_product_finder_request(
    self,
    path: str,
    *,
    params: dict[str, str | int | float | bool] | None = None,
) -> dict[str, Any] | list[Any] | None:
    """Send a public request to the Product Finder API."""
    url = PRODUCT_FINDER_BASE_URL + path
    return await self._auth.send_public_request(
        method="GET",
        url=url,
        params=params,
    )

send_public_request(endpoint, *, params=None, **path_params) async

Send a request that does not require a commerce session.

Source code in pysainsburys/api.py
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
async def send_public_request(
    self,
    endpoint: str,
    *,
    params: dict[str, str | int | float | bool] | None = None,
    **path_params: str,
) -> dict[str, Any] | list[Any] | None:
    """Send a request that does not require a commerce session."""
    if endpoint not in GOL_ENDPOINTS:
        raise ValueError("Provided API endpoint does not exist.")
    endpoint_map = GOL_ENDPOINTS[endpoint]
    built_url = GOL_BASE_URL + endpoint_map["endpoint"].format(**path_params)
    return await self._auth.send_public_request(
        method=endpoint_map["method"],
        url=built_url,
        params=params,
    )

send_request(endpoint, body=None, *, params=None, headers=None, **path_params) async

Send a request to the API using the authentication handler.

Source code in pysainsburys/api.py
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
async def send_request(
    self,
    endpoint: str,
    body: dict[str, Any] | list[Any] | None = None,
    *,
    params: dict[str, str | int | float | bool] | None = None,
    headers: dict[str, str] | None = None,
    **path_params: str,
) -> dict[str, Any] | list[Any] | None:
    """Send a request to the API using the authentication handler."""
    if endpoint not in GOL_ENDPOINTS:
        raise ValueError("Provided API endpoint does not exist.")
    _LOGGER.debug(
        "API: sending request to endpoint '%s' with body: %s",
        endpoint,
        body,
    )
    endpoint_map = GOL_ENDPOINTS[endpoint]
    built_url = GOL_BASE_URL + endpoint_map["endpoint"].format(**path_params)
    endpoint_headers = endpoint_map.get("headers")
    request_headers: dict[str, str] | None = None
    if isinstance(endpoint_headers, dict):
        request_headers = dict(endpoint_headers)
    if headers:
        request_headers = {**(request_headers or {}), **headers}
    return await self._auth.send_request(
        method=endpoint_map["method"],
        url=built_url,
        body=body,
        params=params,
        headers=request_headers,
    )

to_dict()

Return the API object data as a dictionary.

Source code in pysainsburys/api.py
129
130
131
132
133
134
135
136
137
138
def to_dict(self) -> dict[str, Any]:
    """Return the API object data as a dictionary."""
    return {
        "user_id": self.user_id,
        "next_refresh": (
            self._auth.next_refresh.isoformat()
            if self._auth.next_refresh is not None
            else None
        ),
    }

token_refresh() async

Force OAuth token refresh.

Source code in pysainsburys/api.py
101
102
103
async def token_refresh(self) -> None:
    """Force OAuth token refresh."""
    await self._auth.send_refresh_request()

Models

pysainsburys.models

Domain models for the Sainsbury's Groceries Online API.

Models are grouped by business domain:

  • :mod:pysainsburys.models.common — shared value types and pagination
  • :mod:pysainsburys.models.product — products, detail sections, and nutrition
  • :mod:pysainsburys.models.basket — basket line items and totals
  • :mod:pysainsburys.models.customer — authenticated customer profile
  • :mod:pysainsburys.models.order — order history and status
  • :mod:pysainsburys.models.store — stores and in-store product search
  • :mod:pysainsburys.models.nectar — Nectar offers and Your Nectar Prices

Import from this package for a stable, organised surface::

from pysainsburys.models import Product, Basket, Customer

AverageWeight dataclass

Typical weight for a loose product.

Source code in pysainsburys/models/product/catalogue.py
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
@dataclass(slots=True)
class AverageWeight:
    """Typical weight for a loose product."""

    amount: float
    measure: str | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> AverageWeight | None:
        """Parse an average weight from grocery API JSON."""
        if not data or data.get("amount") is None:
            return None
        return cls(amount=float(data["amount"]), measure=text(data.get("measure")))

    def to_dict(self) -> dict[str, Any]:
        """Serialise the average weight to a plain dictionary."""
        return {"amount": self.amount, "measure": self.measure}

from_dict(data) classmethod

Parse an average weight from grocery API JSON.

Source code in pysainsburys/models/product/catalogue.py
210
211
212
213
214
215
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> AverageWeight | None:
    """Parse an average weight from grocery API JSON."""
    if not data or data.get("amount") is None:
        return None
    return cls(amount=float(data["amount"]), measure=text(data.get("measure")))

to_dict()

Serialise the average weight to a plain dictionary.

Source code in pysainsburys/models/product/catalogue.py
217
218
219
def to_dict(self) -> dict[str, Any]:
    """Serialise the average weight to a plain dictionary."""
    return {"amount": self.amount, "measure": self.measure}

Basket dataclass

The authenticated customer's grocery basket.

Attributes:

Name Type Description
basket_id str | None

Basket identifier assigned by the commerce platform.

order_id str | None

Associated order id when amending an existing order.

subtotal_price float

Sum of item prices before delivery and savings.

total_price float

Basket total including fees where calculated.

slot_price float

Delivery or collection slot charge when applicable.

savings float

Promotional savings applied to the basket.

nectar_savings float

Nectar-specific savings when applicable.

item_count int

Number of distinct line items.

minimum_spend int

Minimum order value required for checkout.

delivery_instructions str | None

Customer delivery note when set.

is_in_amend_mode bool

Whether the basket is amending a placed order.

slot_type str | None

Reserved slot type string from the API.

has_exceeded_minimum_spend bool

Whether the minimum spend threshold is met.

items list[BasketItem]

Line items currently in the basket.

Source code in pysainsburys/models/basket/basket.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
@dataclass(slots=True)
class Basket:
    """
    The authenticated customer's grocery basket.

    Attributes:
        basket_id: Basket identifier assigned by the commerce platform.
        order_id: Associated order id when amending an existing order.
        subtotal_price: Sum of item prices before delivery and savings.
        total_price: Basket total including fees where calculated.
        slot_price: Delivery or collection slot charge when applicable.
        savings: Promotional savings applied to the basket.
        nectar_savings: Nectar-specific savings when applicable.
        item_count: Number of distinct line items.
        minimum_spend: Minimum order value required for checkout.
        delivery_instructions: Customer delivery note when set.
        is_in_amend_mode: Whether the basket is amending a placed order.
        slot_type: Reserved slot type string from the API.
        has_exceeded_minimum_spend: Whether the minimum spend threshold is met.
        items: Line items currently in the basket.

    """

    basket_id: str | None = None
    order_id: str | None = None
    subtotal_price: float = 0.0
    total_price: float = 0.0
    slot_price: float = 0.0
    savings: float = 0.0
    nectar_savings: float = 0.0
    item_count: int = 0
    minimum_spend: int = 0
    delivery_instructions: str | None = None
    is_in_amend_mode: bool = False
    slot_type: str | None = None
    has_exceeded_minimum_spend: bool = False
    items: list[BasketItem] = field(default_factory=list)

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> Basket:
        """Parse a basket from grocery API JSON."""
        return cls(
            basket_id=data.get("basket_id"),
            order_id=data.get("order_id"),
            subtotal_price=float(data.get("subtotal_price", 0)),
            total_price=float(data.get("total_price", 0)),
            slot_price=float(data.get("slot_price", 0)),
            savings=float(data.get("savings", 0)),
            nectar_savings=float(data.get("nectar_savings", 0)),
            item_count=int(data.get("item_count", 0)),
            minimum_spend=int(data.get("minimum_spend", 0)),
            delivery_instructions=data.get("delivery_instructions"),
            is_in_amend_mode=bool(data.get("is_in_amend_mode", False)),
            slot_type=data.get("slot_type"),
            has_exceeded_minimum_spend=bool(
                data.get("has_exceeded_minimum_spend", False)
            ),
            items=[BasketItem.from_dict(item) for item in data.get("items", [])],
        )

    @property
    def is_empty(self) -> bool:
        """Return ``True`` when the basket contains no items."""
        return self.item_count == 0 and not self.items

    def to_dict(self) -> dict[str, Any]:
        """Serialise the basket to a plain dictionary."""
        return {
            "basket_id": self.basket_id,
            "order_id": self.order_id,
            "subtotal_price": self.subtotal_price,
            "total_price": self.total_price,
            "slot_price": self.slot_price,
            "savings": self.savings,
            "nectar_savings": self.nectar_savings,
            "item_count": self.item_count,
            "minimum_spend": self.minimum_spend,
            "delivery_instructions": self.delivery_instructions,
            "is_in_amend_mode": self.is_in_amend_mode,
            "slot_type": self.slot_type,
            "has_exceeded_minimum_spend": self.has_exceeded_minimum_spend,
            "items": [item.to_dict() for item in self.items],
        }

    def __iter__(self) -> Iterator[tuple[str, Any]]:
        """Allow ``dict(basket)`` conversion."""
        return iter(self.to_dict().items())

is_empty property

Return True when the basket contains no items.

__iter__()

Allow dict(basket) conversion.

Source code in pysainsburys/models/basket/basket.py
173
174
175
def __iter__(self) -> Iterator[tuple[str, Any]]:
    """Allow ``dict(basket)`` conversion."""
    return iter(self.to_dict().items())

from_dict(data) classmethod

Parse a basket from grocery API JSON.

Source code in pysainsburys/models/basket/basket.py
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Basket:
    """Parse a basket from grocery API JSON."""
    return cls(
        basket_id=data.get("basket_id"),
        order_id=data.get("order_id"),
        subtotal_price=float(data.get("subtotal_price", 0)),
        total_price=float(data.get("total_price", 0)),
        slot_price=float(data.get("slot_price", 0)),
        savings=float(data.get("savings", 0)),
        nectar_savings=float(data.get("nectar_savings", 0)),
        item_count=int(data.get("item_count", 0)),
        minimum_spend=int(data.get("minimum_spend", 0)),
        delivery_instructions=data.get("delivery_instructions"),
        is_in_amend_mode=bool(data.get("is_in_amend_mode", False)),
        slot_type=data.get("slot_type"),
        has_exceeded_minimum_spend=bool(
            data.get("has_exceeded_minimum_spend", False)
        ),
        items=[BasketItem.from_dict(item) for item in data.get("items", [])],
    )

to_dict()

Serialise the basket to a plain dictionary.

Source code in pysainsburys/models/basket/basket.py
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
def to_dict(self) -> dict[str, Any]:
    """Serialise the basket to a plain dictionary."""
    return {
        "basket_id": self.basket_id,
        "order_id": self.order_id,
        "subtotal_price": self.subtotal_price,
        "total_price": self.total_price,
        "slot_price": self.slot_price,
        "savings": self.savings,
        "nectar_savings": self.nectar_savings,
        "item_count": self.item_count,
        "minimum_spend": self.minimum_spend,
        "delivery_instructions": self.delivery_instructions,
        "is_in_amend_mode": self.is_in_amend_mode,
        "slot_type": self.slot_type,
        "has_exceeded_minimum_spend": self.has_exceeded_minimum_spend,
        "items": [item.to_dict() for item in self.items],
    }

BasketItem dataclass

A single line item in the grocery basket.

Attributes:

Name Type Description
product_uid str

Catalogue identifier for the product.

quantity float

Number of units in the basket.

name str | None

Display name when returned by the basket endpoint.

item_uid str | None

Basket line identifier used for updates and removals.

subtotal float | None

Line total in pounds sterling.

unit_price Price | None

Price per unit when provided by the API.

product_data dict[str, Any] | None

Nested product JSON when included in the basket response. Use :meth:~pysainsburys.models.product.Product.from_basket_nested to parse this into a :class:~pysainsburys.models.product.Product.

Source code in pysainsburys/models/basket/basket.py
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
@dataclass(slots=True)
class BasketItem:
    """
    A single line item in the grocery basket.

    Attributes:
        product_uid: Catalogue identifier for the product.
        quantity: Number of units in the basket.
        name: Display name when returned by the basket endpoint.
        item_uid: Basket line identifier used for updates and removals.
        subtotal: Line total in pounds sterling.
        unit_price: Price per unit when provided by the API.
        product_data: Nested product JSON when included in the basket response.
            Use :meth:`~pysainsburys.models.product.Product.from_basket_nested`
            to parse this into a :class:`~pysainsburys.models.product.Product`.

    """

    product_uid: str
    quantity: float
    name: str | None = None
    item_uid: str | None = None
    subtotal: float | None = None
    unit_price: Price | None = None
    product_data: dict[str, Any] | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> BasketItem:
        """Parse a basket item from grocery API JSON."""
        product_data = data.get("product")
        nested = product_data if isinstance(product_data, dict) else None
        product_uid = str(data.get("product_uid") or data.get("uid") or "")
        name = data.get("name")
        if nested is not None:
            if not product_uid:
                product_uid = str(nested.get("sku") or nested.get("product_uid") or "")
            if name is None:
                name = nested.get("name")
        return cls(
            product_uid=product_uid,
            quantity=float(data.get("quantity", 0)),
            name=name,
            item_uid=data.get("item_uid") or data.get("itemId"),
            subtotal=(
                float(data["subtotal"])
                if data.get("subtotal") is not None
                else (
                    float(data["subtotal_price"])
                    if data.get("subtotal_price") is not None
                    else None
                )
            ),
            unit_price=Price.from_dict(data.get("unit_price")),
            product_data=nested,
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the basket item to a plain dictionary."""
        return {
            "product_uid": self.product_uid,
            "quantity": self.quantity,
            "name": self.name,
            "item_uid": self.item_uid,
            "subtotal": self.subtotal,
            "unit_price": self.unit_price.to_dict() if self.unit_price else None,
            "product": self.product_data,
        }

from_dict(data) classmethod

Parse a basket item from grocery API JSON.

Source code in pysainsburys/models/basket/basket.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
@classmethod
def from_dict(cls, data: dict[str, Any]) -> BasketItem:
    """Parse a basket item from grocery API JSON."""
    product_data = data.get("product")
    nested = product_data if isinstance(product_data, dict) else None
    product_uid = str(data.get("product_uid") or data.get("uid") or "")
    name = data.get("name")
    if nested is not None:
        if not product_uid:
            product_uid = str(nested.get("sku") or nested.get("product_uid") or "")
        if name is None:
            name = nested.get("name")
    return cls(
        product_uid=product_uid,
        quantity=float(data.get("quantity", 0)),
        name=name,
        item_uid=data.get("item_uid") or data.get("itemId"),
        subtotal=(
            float(data["subtotal"])
            if data.get("subtotal") is not None
            else (
                float(data["subtotal_price"])
                if data.get("subtotal_price") is not None
                else None
            )
        ),
        unit_price=Price.from_dict(data.get("unit_price")),
        product_data=nested,
    )

to_dict()

Serialise the basket item to a plain dictionary.

Source code in pysainsburys/models/basket/basket.py
76
77
78
79
80
81
82
83
84
85
86
def to_dict(self) -> dict[str, Any]:
    """Serialise the basket item to a plain dictionary."""
    return {
        "product_uid": self.product_uid,
        "quantity": self.quantity,
        "name": self.name,
        "item_uid": self.item_uid,
        "subtotal": self.subtotal,
        "unit_price": self.unit_price.to_dict() if self.unit_price else None,
        "product": self.product_data,
    }

Customer dataclass

Authenticated Sainsbury's Groceries Online customer profile.

A customer is returned by :meth:~pysainsburys.Sainsburys.get_customer and exposes convenience accessors for basket, favourites, orders, and slot resources when bound to a client.

Attributes:

Name Type Description
user_id str

Commerce platform user identifier.

customer_id str | None

Customer record identifier when distinct from user_id.

identity_id str | None

Identity provider subject identifier.

email str | None

Account email address.

family_name str | None

Family name from the profile.

given_name str | None

Given name from the profile.

primary_phone str | None

Primary contact telephone number.

postcode str | None

Default delivery postcode when set.

title str | None

Salutation or title when provided.

is_very_important_customer bool

VIP flag from the API.

delivery_pass_expiry_date str | None

Delivery pass expiry when subscribed.

personalization_id str | None

Personalisation token for recommendations.

has_nectar_associated bool

Whether a Nectar card is associated.

has_nectar_linked bool

Whether Nectar is fully linked for rewards.

is_digital_nectar bool

Whether the account uses digital Nectar.

Source code in pysainsburys/models/customer/customer.py
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
@dataclass(slots=True)
class Customer:
    """
    Authenticated Sainsbury's Groceries Online customer profile.

    A customer is returned by :meth:`~pysainsburys.Sainsburys.get_customer` and
    exposes convenience accessors for basket, favourites, orders, and slot
    resources when bound to a client.

    Attributes:
        user_id: Commerce platform user identifier.
        customer_id: Customer record identifier when distinct from ``user_id``.
        identity_id: Identity provider subject identifier.
        email: Account email address.
        family_name: Family name from the profile.
        given_name: Given name from the profile.
        primary_phone: Primary contact telephone number.
        postcode: Default delivery postcode when set.
        title: Salutation or title when provided.
        is_very_important_customer: VIP flag from the API.
        delivery_pass_expiry_date: Delivery pass expiry when subscribed.
        personalization_id: Personalisation token for recommendations.
        has_nectar_associated: Whether a Nectar card is associated.
        has_nectar_linked: Whether Nectar is fully linked for rewards.
        is_digital_nectar: Whether the account uses digital Nectar.

    """

    user_id: str
    customer_id: str | None = None
    identity_id: str | None = None
    email: str | None = None
    family_name: str | None = None
    given_name: str | None = None
    primary_phone: str | None = None
    postcode: str | None = None
    title: str | None = None
    is_very_important_customer: bool = False
    delivery_pass_expiry_date: str | None = None
    personalization_id: str | None = None
    has_nectar_associated: bool = False
    has_nectar_linked: bool = False
    is_digital_nectar: bool = False
    _api: API | None = field(default=None, repr=False, compare=False, hash=False)
    _favourites: Favourites | None = field(
        default=None, init=False, repr=False, compare=False, hash=False
    )
    _basket_access: BasketAccess | None = field(
        default=None, init=False, repr=False, compare=False, hash=False
    )
    _orders: Orders | None = field(
        default=None, init=False, repr=False, compare=False, hash=False
    )
    _nectar: Nectar | None = field(
        default=None, init=False, repr=False, compare=False, hash=False
    )
    _slots: Slots | None = field(
        default=None, init=False, repr=False, compare=False, hash=False
    )

    @classmethod
    def from_dict(cls, data: dict[str, Any], *, api: API | None = None) -> Customer:
        """Parse a customer profile from grocery API JSON."""
        return cls(
            user_id=str(data.get("user_id", "")),
            customer_id=data.get("customer_id"),
            identity_id=data.get("identity_id"),
            email=data.get("email"),
            family_name=data.get("family_name"),
            given_name=data.get("given_name"),
            primary_phone=data.get("primary_phone"),
            postcode=data.get("postcode"),
            title=data.get("title"),
            is_very_important_customer=bool(
                data.get("is_very_important_customer", False)
            ),
            delivery_pass_expiry_date=data.get("delivery_pass_expiry_date"),
            personalization_id=data.get("personalization_id"),
            has_nectar_associated=bool(data.get("has_nectar_associated", False)),
            has_nectar_linked=bool(data.get("has_nectar_linked", False)),
            is_digital_nectar=bool(data.get("is_digital_nectar", False)),
            _api=api,
        )

    def _require_api(self) -> API:
        if self._api is None:
            msg = "Customer is not bound to a Sainsburys client."
            raise NotBoundError(msg)
        return self._api

    @property
    def favourites(self) -> Favourites:
        """Favourites list and add/remove helpers for this customer."""
        from ...favourites import Favourites

        if self._favourites is None:
            self._favourites = Favourites(self._require_api())
        return self._favourites

    @property
    def basket(self) -> BasketAccess:
        """Basket fetch and clear helpers for this customer."""
        from ...basket import BasketAccess

        if self._basket_access is None:
            self._basket_access = BasketAccess(self._require_api())
        return self._basket_access

    @property
    def orders(self) -> Orders:
        """Order history, latest order, and per-order status."""
        from ...orders import Orders

        if self._orders is None:
            self._orders = Orders(self._require_api())
        return self._orders

    @property
    def nectar(self) -> Nectar:
        """Nectar bonus offers and Your Nectar Price helpers."""
        from ...nectar import Nectar

        if self._nectar is None:
            self._nectar = Nectar(self._require_api())
        return self._nectar

    @property
    def slots(self) -> Slots:
        """Delivery and collection slot listing helpers."""
        from ...slots import Slots

        if self._slots is None:
            self._slots = Slots(self._require_api())
        return self._slots

    @property
    def display_name(self) -> str:
        """Return a human-friendly display name."""
        parts = [part for part in (self.given_name, self.family_name) if part]
        if parts:
            return " ".join(parts)
        return self.email or self.user_id

    def to_dict(self) -> dict[str, Any]:
        """Serialise the customer profile to a plain dictionary."""
        return {
            "user_id": self.user_id,
            "customer_id": self.customer_id,
            "identity_id": self.identity_id,
            "email": self.email,
            "family_name": self.family_name,
            "given_name": self.given_name,
            "primary_phone": self.primary_phone,
            "postcode": self.postcode,
            "title": self.title,
            "is_very_important_customer": self.is_very_important_customer,
            "delivery_pass_expiry_date": self.delivery_pass_expiry_date,
            "personalization_id": self.personalization_id,
            "has_nectar_associated": self.has_nectar_associated,
            "has_nectar_linked": self.has_nectar_linked,
            "is_digital_nectar": self.is_digital_nectar,
            "display_name": self.display_name,
        }

    def __iter__(self) -> Iterator[tuple[str, Any]]:
        """Allow ``dict(customer)`` conversion."""
        return iter(self.to_dict().items())

basket property

Basket fetch and clear helpers for this customer.

display_name property

Return a human-friendly display name.

favourites property

Favourites list and add/remove helpers for this customer.

nectar property

Nectar bonus offers and Your Nectar Price helpers.

orders property

Order history, latest order, and per-order status.

slots property

Delivery and collection slot listing helpers.

__iter__()

Allow dict(customer) conversion.

Source code in pysainsburys/models/customer/customer.py
184
185
186
def __iter__(self) -> Iterator[tuple[str, Any]]:
    """Allow ``dict(customer)`` conversion."""
    return iter(self.to_dict().items())

from_dict(data, *, api=None) classmethod

Parse a customer profile from grocery API JSON.

Source code in pysainsburys/models/customer/customer.py
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
@classmethod
def from_dict(cls, data: dict[str, Any], *, api: API | None = None) -> Customer:
    """Parse a customer profile from grocery API JSON."""
    return cls(
        user_id=str(data.get("user_id", "")),
        customer_id=data.get("customer_id"),
        identity_id=data.get("identity_id"),
        email=data.get("email"),
        family_name=data.get("family_name"),
        given_name=data.get("given_name"),
        primary_phone=data.get("primary_phone"),
        postcode=data.get("postcode"),
        title=data.get("title"),
        is_very_important_customer=bool(
            data.get("is_very_important_customer", False)
        ),
        delivery_pass_expiry_date=data.get("delivery_pass_expiry_date"),
        personalization_id=data.get("personalization_id"),
        has_nectar_associated=bool(data.get("has_nectar_associated", False)),
        has_nectar_linked=bool(data.get("has_nectar_linked", False)),
        is_digital_nectar=bool(data.get("is_digital_nectar", False)),
        _api=api,
    )

to_dict()

Serialise the customer profile to a plain dictionary.

Source code in pysainsburys/models/customer/customer.py
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
def to_dict(self) -> dict[str, Any]:
    """Serialise the customer profile to a plain dictionary."""
    return {
        "user_id": self.user_id,
        "customer_id": self.customer_id,
        "identity_id": self.identity_id,
        "email": self.email,
        "family_name": self.family_name,
        "given_name": self.given_name,
        "primary_phone": self.primary_phone,
        "postcode": self.postcode,
        "title": self.title,
        "is_very_important_customer": self.is_very_important_customer,
        "delivery_pass_expiry_date": self.delivery_pass_expiry_date,
        "personalization_id": self.personalization_id,
        "has_nectar_associated": self.has_nectar_associated,
        "has_nectar_linked": self.has_nectar_linked,
        "is_digital_nectar": self.is_digital_nectar,
        "display_name": self.display_name,
    }

DeliverySlot dataclass

A single bookable delivery or collection time window.

Attributes:

Name Type Description
slot_uid str | None

Stable slot identifier from the API when provided.

start_time str | None

Slot start timestamp (ISO-8601).

end_time str | None

Slot end timestamp (ISO-8601).

price float | None

Customer-facing slot price in pounds sterling.

unqualified_price float | None

List price before delivery-pass or promotions.

is_available bool

Whether the slot can be booked.

status str | None

Raw availability status string from the API.

slot_type str | None

Delivery or collection type when returned per slot.

Source code in pysainsburys/models/slot/slot.py
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
@dataclass(slots=True)
class DeliverySlot:
    """
    A single bookable delivery or collection time window.

    Attributes:
        slot_uid: Stable slot identifier from the API when provided.
        start_time: Slot start timestamp (ISO-8601).
        end_time: Slot end timestamp (ISO-8601).
        price: Customer-facing slot price in pounds sterling.
        unqualified_price: List price before delivery-pass or promotions.
        is_available: Whether the slot can be booked.
        status: Raw availability status string from the API.
        slot_type: Delivery or collection type when returned per slot.

    """

    slot_uid: str | None = None
    start_time: str | None = None
    end_time: str | None = None
    price: float | None = None
    unqualified_price: float | None = None
    is_available: bool = True
    status: str | None = None
    slot_type: str | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> DeliverySlot:
        """Parse a slot entry from grocery API JSON."""
        return cls(
            slot_uid=(
                data.get("slot_uid")
                or data.get("slot_id")
                or data.get("uid")
                or data.get("id")
            ),
            start_time=data.get("start_time") or data.get("slot_start_time"),
            end_time=data.get("end_time") or data.get("slot_end_time"),
            price=_optional_float(data.get("price") or data.get("slot_price")),
            unqualified_price=_optional_float(
                data.get("unqualified_price") or data.get("list_price")
            ),
            is_available=_slot_available(data),
            status=data.get("status"),
            slot_type=data.get("slot_type") or data.get("order_type"),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the slot to a plain dictionary."""
        return {
            "slot_uid": self.slot_uid,
            "start_time": self.start_time,
            "end_time": self.end_time,
            "price": self.price,
            "unqualified_price": self.unqualified_price,
            "is_available": self.is_available,
            "status": self.status,
            "slot_type": self.slot_type,
        }

    def __iter__(self) -> Iterator[tuple[str, Any]]:
        """Allow ``dict(slot)`` conversion."""
        return iter(self.to_dict().items())

__iter__()

Allow dict(slot) conversion.

Source code in pysainsburys/models/slot/slot.py
94
95
96
def __iter__(self) -> Iterator[tuple[str, Any]]:
    """Allow ``dict(slot)`` conversion."""
    return iter(self.to_dict().items())

from_dict(data) classmethod

Parse a slot entry from grocery API JSON.

Source code in pysainsburys/models/slot/slot.py
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
@classmethod
def from_dict(cls, data: dict[str, Any]) -> DeliverySlot:
    """Parse a slot entry from grocery API JSON."""
    return cls(
        slot_uid=(
            data.get("slot_uid")
            or data.get("slot_id")
            or data.get("uid")
            or data.get("id")
        ),
        start_time=data.get("start_time") or data.get("slot_start_time"),
        end_time=data.get("end_time") or data.get("slot_end_time"),
        price=_optional_float(data.get("price") or data.get("slot_price")),
        unqualified_price=_optional_float(
            data.get("unqualified_price") or data.get("list_price")
        ),
        is_available=_slot_available(data),
        status=data.get("status"),
        slot_type=data.get("slot_type") or data.get("order_type"),
    )

to_dict()

Serialise the slot to a plain dictionary.

Source code in pysainsburys/models/slot/slot.py
81
82
83
84
85
86
87
88
89
90
91
92
def to_dict(self) -> dict[str, Any]:
    """Serialise the slot to a plain dictionary."""
    return {
        "slot_uid": self.slot_uid,
        "start_time": self.start_time,
        "end_time": self.end_time,
        "price": self.price,
        "unqualified_price": self.unqualified_price,
        "is_available": self.is_available,
        "status": self.status,
        "slot_type": self.slot_type,
    }

FinderPage dataclass

Pagination metadata from the Product Finder API.

Attributes:

Name Type Description
size int

Page size requested.

number int

Zero-based page index returned by Product Finder.

total_elements int

Total matching elements across all pages.

total_pages int

Total number of pages available.

Source code in pysainsburys/models/store/store.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
@dataclass(slots=True)
class FinderPage:
    """
    Pagination metadata from the Product Finder API.

    Attributes:
        size: Page size requested.
        number: Zero-based page index returned by Product Finder.
        total_elements: Total matching elements across all pages.
        total_pages: Total number of pages available.

    """

    size: int
    number: int
    total_elements: int
    total_pages: int

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> FinderPage:
        """Parse Product Finder pagination JSON."""
        data = data or {}
        return cls(
            size=int(data.get("size", 0)),
            number=int(data.get("number", 0)),
            total_elements=int(data.get("totalElements", 0)),
            total_pages=int(data.get("totalPages", 0)),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise pagination metadata to a plain dictionary."""
        return {
            "size": self.size,
            "number": self.number,
            "total_elements": self.total_elements,
            "total_pages": self.total_pages,
        }

from_dict(data) classmethod

Parse Product Finder pagination JSON.

Source code in pysainsburys/models/store/store.py
34
35
36
37
38
39
40
41
42
43
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> FinderPage:
    """Parse Product Finder pagination JSON."""
    data = data or {}
    return cls(
        size=int(data.get("size", 0)),
        number=int(data.get("number", 0)),
        total_elements=int(data.get("totalElements", 0)),
        total_pages=int(data.get("totalPages", 0)),
    )

to_dict()

Serialise pagination metadata to a plain dictionary.

Source code in pysainsburys/models/store/store.py
45
46
47
48
49
50
51
52
def to_dict(self) -> dict[str, Any]:
    """Serialise pagination metadata to a plain dictionary."""
    return {
        "size": self.size,
        "number": self.number,
        "total_elements": self.total_elements,
        "total_pages": self.total_pages,
    }

HfssRestriction dataclass

HFSS advertising restriction for one UK nation.

Source code in pysainsburys/models/product/catalogue.py
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
@dataclass(slots=True)
class HfssRestriction:
    """HFSS advertising restriction for one UK nation."""

    country: str
    restricted: bool
    category: str | None = None
    score: int | None = None
    last_change_date: str | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> HfssRestriction | None:
        """Parse an HFSS restriction from grocery API JSON."""
        if not data:
            return None
        country = text(data.get("country"))
        if not country:
            return None
        return cls(
            country=country,
            restricted=bool(data.get("restricted", False)),
            category=text(data.get("hfss_category")),
            score=_int(data.get("hfss_score")),
            last_change_date=text(data.get("last_change_date")),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the HFSS restriction to a plain dictionary."""
        return {
            "country": self.country,
            "restricted": self.restricted,
            "category": self.category,
            "score": self.score,
            "last_change_date": self.last_change_date,
        }

from_dict(data) classmethod

Parse an HFSS restriction from grocery API JSON.

Source code in pysainsburys/models/product/catalogue.py
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> HfssRestriction | None:
    """Parse an HFSS restriction from grocery API JSON."""
    if not data:
        return None
    country = text(data.get("country"))
    if not country:
        return None
    return cls(
        country=country,
        restricted=bool(data.get("restricted", False)),
        category=text(data.get("hfss_category")),
        score=_int(data.get("hfss_score")),
        last_change_date=text(data.get("last_change_date")),
    )

to_dict()

Serialise the HFSS restriction to a plain dictionary.

Source code in pysainsburys/models/product/catalogue.py
248
249
250
251
252
253
254
255
256
def to_dict(self) -> dict[str, Any]:
    """Serialise the HFSS restriction to a plain dictionary."""
    return {
        "country": self.country,
        "restricted": self.restricted,
        "category": self.category,
        "score": self.score,
        "last_change_date": self.last_change_date,
    }

LocationContext dataclass

Location context used when listing slots.

Source code in pysainsburys/models/slot/slot.py
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
@dataclass(slots=True)
class LocationContext:
    """Location context used when listing slots."""

    slot_type: str | None = None
    postcode: str | None = None
    store_identifier: str | None = None
    location_uid: str | None = None
    region: str | None = None
    order_uid: str | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> LocationContext:
        """Parse location context JSON."""
        return cls(
            slot_type=data.get("slot_type") or data.get("reservation_type"),
            postcode=data.get("postcode"),
            store_identifier=data.get("store_identifier"),
            location_uid=data.get("location_uid"),
            region=data.get("region"),
            order_uid=data.get("order_uid"),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise location context to a plain dictionary."""
        return {
            "slot_type": self.slot_type,
            "postcode": self.postcode,
            "store_identifier": self.store_identifier,
            "location_uid": self.location_uid,
            "region": self.region,
            "order_uid": self.order_uid,
        }

    def __iter__(self) -> Iterator[tuple[str, Any]]:
        """Allow ``dict(location_context)`` conversion."""
        return iter(self.to_dict().items())

__iter__()

Allow dict(location_context) conversion.

Source code in pysainsburys/models/slot/slot.py
314
315
316
def __iter__(self) -> Iterator[tuple[str, Any]]:
    """Allow ``dict(location_context)`` conversion."""
    return iter(self.to_dict().items())

from_dict(data) classmethod

Parse location context JSON.

Source code in pysainsburys/models/slot/slot.py
291
292
293
294
295
296
297
298
299
300
301
@classmethod
def from_dict(cls, data: dict[str, Any]) -> LocationContext:
    """Parse location context JSON."""
    return cls(
        slot_type=data.get("slot_type") or data.get("reservation_type"),
        postcode=data.get("postcode"),
        store_identifier=data.get("store_identifier"),
        location_uid=data.get("location_uid"),
        region=data.get("region"),
        order_uid=data.get("order_uid"),
    )

to_dict()

Serialise location context to a plain dictionary.

Source code in pysainsburys/models/slot/slot.py
303
304
305
306
307
308
309
310
311
312
def to_dict(self) -> dict[str, Any]:
    """Serialise location context to a plain dictionary."""
    return {
        "slot_type": self.slot_type,
        "postcode": self.postcode,
        "store_identifier": self.store_identifier,
        "location_uid": self.location_uid,
        "region": self.region,
        "order_uid": self.order_uid,
    }

NectarOffer dataclass

A personalised Nectar bonus-points offer.

Attributes:

Name Type Description
offer_id str

Offer identifier from the Nectar API.

title str

Short offer headline.

subtitle str

Supporting offer copy.

points int

Bonus Nectar points awarded.

skus list[str]

Product SKUs included in the offer.

expires str | None

Offer expiry timestamp when provided.

Source code in pysainsburys/models/nectar/nectar.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
@dataclass(slots=True)
class NectarOffer:
    """
    A personalised Nectar bonus-points offer.

    Attributes:
        offer_id: Offer identifier from the Nectar API.
        title: Short offer headline.
        subtitle: Supporting offer copy.
        points: Bonus Nectar points awarded.
        skus: Product SKUs included in the offer.
        expires: Offer expiry timestamp when provided.

    """

    offer_id: str
    title: str
    subtitle: str
    points: int
    skus: list[str] = field(default_factory=list)
    expires: str | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> NectarOffer:
        """Parse a Nectar offer from grocery API JSON."""
        skus = data.get("skus")
        if not isinstance(skus, list):
            skus = []
        return cls(
            offer_id=str(data.get("id") or data.get("offer_id") or ""),
            title=str(data.get("title") or ""),
            subtitle=str(data.get("subtitle") or ""),
            points=int(data.get("points", 0)),
            skus=[str(sku) for sku in skus],
            expires=data.get("expires") or data.get("expiry_date"),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the offer to a plain dictionary."""
        return {
            "offer_id": self.offer_id,
            "title": self.title,
            "subtitle": self.subtitle,
            "points": self.points,
            "skus": self.skus,
            "expires": self.expires,
        }

from_dict(data) classmethod

Parse a Nectar offer from grocery API JSON.

Source code in pysainsburys/models/nectar/nectar.py
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@classmethod
def from_dict(cls, data: dict[str, Any]) -> NectarOffer:
    """Parse a Nectar offer from grocery API JSON."""
    skus = data.get("skus")
    if not isinstance(skus, list):
        skus = []
    return cls(
        offer_id=str(data.get("id") or data.get("offer_id") or ""),
        title=str(data.get("title") or ""),
        subtitle=str(data.get("subtitle") or ""),
        points=int(data.get("points", 0)),
        skus=[str(sku) for sku in skus],
        expires=data.get("expires") or data.get("expiry_date"),
    )

to_dict()

Serialise the offer to a plain dictionary.

Source code in pysainsburys/models/nectar/nectar.py
49
50
51
52
53
54
55
56
57
58
def to_dict(self) -> dict[str, Any]:
    """Serialise the offer to a plain dictionary."""
    return {
        "offer_id": self.offer_id,
        "title": self.title,
        "subtitle": self.subtitle,
        "points": self.points,
        "skus": self.skus,
        "expires": self.expires,
    }

NectarOffers dataclass

Nectar bonus-point offers for the signed-in customer.

Attributes:

Name Type Description
account_status str | None

Nectar linkage status from the API.

offers list[NectarOffer]

Active bonus-point offers.

Source code in pysainsburys/models/nectar/nectar.py
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
@dataclass(slots=True)
class NectarOffers:
    """
    Nectar bonus-point offers for the signed-in customer.

    Attributes:
        account_status: Nectar linkage status from the API.
        offers: Active bonus-point offers.

    """

    account_status: str | None = None
    offers: list[NectarOffer] = field(default_factory=list)

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> NectarOffers:
        """Parse Nectar offers from grocery API JSON."""
        offers = [
            NectarOffer.from_dict(item)
            for item in data.get("offers", [])
            if isinstance(item, dict)
        ]
        return cls(
            account_status=data.get("account_status"),
            offers=offers,
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the offers response to a plain dictionary."""
        return {
            "account_status": self.account_status,
            "offers": [offer.to_dict() for offer in self.offers],
        }

    def __iter__(self) -> Iterator[tuple[str, Any]]:
        """Allow ``dict(offers)`` conversion."""
        return iter(self.to_dict().items())

__iter__()

Allow dict(offers) conversion.

Source code in pysainsburys/models/nectar/nectar.py
95
96
97
def __iter__(self) -> Iterator[tuple[str, Any]]:
    """Allow ``dict(offers)`` conversion."""
    return iter(self.to_dict().items())

from_dict(data) classmethod

Parse Nectar offers from grocery API JSON.

Source code in pysainsburys/models/nectar/nectar.py
75
76
77
78
79
80
81
82
83
84
85
86
@classmethod
def from_dict(cls, data: dict[str, Any]) -> NectarOffers:
    """Parse Nectar offers from grocery API JSON."""
    offers = [
        NectarOffer.from_dict(item)
        for item in data.get("offers", [])
        if isinstance(item, dict)
    ]
    return cls(
        account_status=data.get("account_status"),
        offers=offers,
    )

to_dict()

Serialise the offers response to a plain dictionary.

Source code in pysainsburys/models/nectar/nectar.py
88
89
90
91
92
93
def to_dict(self) -> dict[str, Any]:
    """Serialise the offers response to a plain dictionary."""
    return {
        "account_status": self.account_status,
        "offers": [offer.to_dict() for offer in self.offers],
    }

NectarPrice dataclass

Nectar member price for a product.

Attributes:

Name Type Description
retail_price float

Nectar price for the purchasable quantity.

unit_price float | None

Nectar price per unit of measure, when provided.

measure str | None

Unit label for unit_price.

url str | None

Link to the Nectar prices listing.

category_seo_url str | None

SEO path for the Nectar prices category.

Source code in pysainsburys/models/product/product.py
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
@dataclass(slots=True)
class NectarPrice:
    """
    Nectar member price for a product.

    Attributes:
        retail_price: Nectar price for the purchasable quantity.
        unit_price: Nectar price per unit of measure, when provided.
        measure: Unit label for ``unit_price``.
        url: Link to the Nectar prices listing.
        category_seo_url: SEO path for the Nectar prices category.

    """

    retail_price: float
    unit_price: float | None = None
    measure: str | None = None
    url: str | None = None
    category_seo_url: str | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> NectarPrice | None:
        """Parse a Nectar price from grocery API JSON."""
        if not data or data.get("retail_price") is None:
            return None
        unit_price = data.get("unit_price")
        return cls(
            retail_price=float(data["retail_price"]),
            unit_price=float(unit_price) if unit_price is not None else None,
            measure=data.get("measure"),
            url=data.get("url"),
            category_seo_url=data.get("category_seo_url"),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the Nectar price to a plain dictionary."""
        return {
            "retail_price": self.retail_price,
            "unit_price": self.unit_price,
            "measure": self.measure,
            "url": self.url,
            "category_seo_url": self.category_seo_url,
        }

from_dict(data) classmethod

Parse a Nectar price from grocery API JSON.

Source code in pysainsburys/models/product/product.py
175
176
177
178
179
180
181
182
183
184
185
186
187
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> NectarPrice | None:
    """Parse a Nectar price from grocery API JSON."""
    if not data or data.get("retail_price") is None:
        return None
    unit_price = data.get("unit_price")
    return cls(
        retail_price=float(data["retail_price"]),
        unit_price=float(unit_price) if unit_price is not None else None,
        measure=data.get("measure"),
        url=data.get("url"),
        category_seo_url=data.get("category_seo_url"),
    )

to_dict()

Serialise the Nectar price to a plain dictionary.

Source code in pysainsburys/models/product/product.py
189
190
191
192
193
194
195
196
197
def to_dict(self) -> dict[str, Any]:
    """Serialise the Nectar price to a plain dictionary."""
    return {
        "retail_price": self.retail_price,
        "unit_price": self.unit_price,
        "measure": self.measure,
        "url": self.url,
        "category_seo_url": self.category_seo_url,
    }

NectarSearchHit dataclass

A single Nectar search result.

Source code in pysainsburys/models/nectar/nectar.py
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
@dataclass(slots=True)
class NectarSearchHit:
    """A single Nectar search result."""

    kind: str
    offer_id: str | None = None
    title: str | None = None
    subtitle: str | None = None
    points: int | None = None
    sku: str | None = None
    expires: str | None = None
    opted_in: bool | None = None
    product: Product | None = None

    def to_dict(self) -> dict[str, Any]:
        """Serialise the search hit to a plain dictionary."""
        return {
            "kind": self.kind,
            "offer_id": self.offer_id,
            "title": self.title,
            "subtitle": self.subtitle,
            "points": self.points,
            "sku": self.sku,
            "expires": self.expires,
            "opted_in": self.opted_in,
            "product": self.product.to_dict() if self.product else None,
        }

to_dict()

Serialise the search hit to a plain dictionary.

Source code in pysainsburys/models/nectar/nectar.py
245
246
247
248
249
250
251
252
253
254
255
256
257
def to_dict(self) -> dict[str, Any]:
    """Serialise the search hit to a plain dictionary."""
    return {
        "kind": self.kind,
        "offer_id": self.offer_id,
        "title": self.title,
        "subtitle": self.subtitle,
        "points": self.points,
        "sku": self.sku,
        "expires": self.expires,
        "opted_in": self.opted_in,
        "product": self.product.to_dict() if self.product else None,
    }

NectarSearchResults dataclass

Keyword search results across Nectar offers and Your Nectar Prices.

Source code in pysainsburys/models/nectar/nectar.py
260
261
262
263
264
265
266
267
268
269
270
271
272
@dataclass(slots=True)
class NectarSearchResults:
    """Keyword search results across Nectar offers and Your Nectar Prices."""

    query: str
    hits: list[NectarSearchHit] = field(default_factory=list)

    def to_dict(self) -> dict[str, Any]:
        """Serialise search results to a plain dictionary."""
        return {
            "query": self.query,
            "hits": [hit.to_dict() for hit in self.hits],
        }

to_dict()

Serialise search results to a plain dictionary.

Source code in pysainsburys/models/nectar/nectar.py
267
268
269
270
271
272
def to_dict(self) -> dict[str, Any]:
    """Serialise search results to a plain dictionary."""
    return {
        "query": self.query,
        "hits": [hit.to_dict() for hit in self.hits],
    }

NutrientSummary dataclass

Traffic-light style nutrition summary for a single nutrient.

Source code in pysainsburys/models/product/nutrition.py
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
@dataclass(slots=True)
class NutrientSummary:
    """Traffic-light style nutrition summary for a single nutrient."""

    name: str
    values: list[str]
    reference_intake_percent: str | None = None
    level: str | None = None

    def to_dict(self) -> dict[str, Any]:
        """Return the nutrient summary as a dictionary."""
        return {
            "name": self.name,
            "values": self.values,
            "reference_intake_percent": self.reference_intake_percent,
            "level": self.level,
        }

to_dict()

Return the nutrient summary as a dictionary.

Source code in pysainsburys/models/product/nutrition.py
59
60
61
62
63
64
65
66
def to_dict(self) -> dict[str, Any]:
    """Return the nutrient summary as a dictionary."""
    return {
        "name": self.name,
        "values": self.values,
        "reference_intake_percent": self.reference_intake_percent,
        "level": self.level,
    }

NutritionInfo dataclass

Parsed nutrition information for a product.

Source code in pysainsburys/models/product/nutrition.py
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
@dataclass(slots=True)
class NutritionInfo:
    """Parsed nutrition information for a product."""

    summary: list[NutrientSummary] = field(default_factory=list)
    tables: list[NutritionTable] = field(default_factory=list)
    notes: list[str] = field(default_factory=list)

    def to_dict(self) -> dict[str, Any]:
        """Return nutrition information as a dictionary."""
        return {
            "summary": [item.to_dict() for item in self.summary],
            "tables": [table.to_dict() for table in self.tables],
            "notes": self.notes,
        }

to_dict()

Return nutrition information as a dictionary.

Source code in pysainsburys/models/product/nutrition.py
109
110
111
112
113
114
115
def to_dict(self) -> dict[str, Any]:
    """Return nutrition information as a dictionary."""
    return {
        "summary": [item.to_dict() for item in self.summary],
        "tables": [table.to_dict() for table in self.tables],
        "notes": self.notes,
    }

NutritionTable dataclass

A nutrition facts table from a product detail page.

Source code in pysainsburys/models/product/nutrition.py
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
@dataclass(slots=True)
class NutritionTable:
    """A nutrition facts table from a product detail page."""

    columns: list[str]
    rows: list[NutritionTableRow]
    title: str | None = None

    def to_dict(self) -> dict[str, Any]:
        """Return the nutrition table as a dictionary."""
        return {
            "title": self.title,
            "columns": self.columns,
            "rows": [row.to_dict() for row in self.rows],
        }

to_dict()

Return the nutrition table as a dictionary.

Source code in pysainsburys/models/product/nutrition.py
92
93
94
95
96
97
98
def to_dict(self) -> dict[str, Any]:
    """Return the nutrition table as a dictionary."""
    return {
        "title": self.title,
        "columns": self.columns,
        "rows": [row.to_dict() for row in self.rows],
    }

NutritionTableRow dataclass

A single row in a nutrition facts table.

Source code in pysainsburys/models/product/nutrition.py
69
70
71
72
73
74
75
76
77
78
79
80
81
@dataclass(slots=True)
class NutritionTableRow:
    """A single row in a nutrition facts table."""

    name: str
    values: list[str]

    def to_dict(self) -> dict[str, Any]:
        """Return the table row as a dictionary."""
        return {
            "name": self.name,
            "values": self.values,
        }

to_dict()

Return the table row as a dictionary.

Source code in pysainsburys/models/product/nutrition.py
76
77
78
79
80
81
def to_dict(self) -> dict[str, Any]:
    """Return the table row as a dictionary."""
    return {
        "name": self.name,
        "values": self.values,
    }

OrderList dataclass

A paginated list of customer orders.

Source code in pysainsburys/models/order/order.py
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
@dataclass(slots=True)
class OrderList:
    """A paginated list of customer orders."""

    orders: list[OrderSummary]
    controls: PageControls

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> OrderList:
        """Parse an order list from grocery API JSON."""
        return cls(
            orders=[OrderSummary.from_dict(item) for item in data.get("orders", [])],
            controls=PageControls.from_dict(data.get("controls")),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the order list to a plain dictionary."""
        return {
            "orders": [order.to_dict() for order in self.orders],
            "controls": self.controls.to_dict(),
        }

    def __iter__(self) -> Iterator[tuple[str, Any]]:
        """Allow ``dict(order_list)`` conversion."""
        return iter(self.to_dict().items())

__iter__()

Allow dict(order_list) conversion.

Source code in pysainsburys/models/order/order.py
84
85
86
def __iter__(self) -> Iterator[tuple[str, Any]]:
    """Allow ``dict(order_list)`` conversion."""
    return iter(self.to_dict().items())

from_dict(data) classmethod

Parse an order list from grocery API JSON.

Source code in pysainsburys/models/order/order.py
69
70
71
72
73
74
75
@classmethod
def from_dict(cls, data: dict[str, Any]) -> OrderList:
    """Parse an order list from grocery API JSON."""
    return cls(
        orders=[OrderSummary.from_dict(item) for item in data.get("orders", [])],
        controls=PageControls.from_dict(data.get("controls")),
    )

to_dict()

Serialise the order list to a plain dictionary.

Source code in pysainsburys/models/order/order.py
77
78
79
80
81
82
def to_dict(self) -> dict[str, Any]:
    """Serialise the order list to a plain dictionary."""
    return {
        "orders": [order.to_dict() for order in self.orders],
        "controls": self.controls.to_dict(),
    }

OrderStatus dataclass

Live status for the customer's active order slot.

Attributes:

Name Type Description
order_uid str | None

Identifier for the active order.

is_cutoff bool

Whether the amend cutoff has passed.

is_in_amend_mode bool

Whether the order can still be amended.

cutoff_time str | None

Amend cutoff timestamp when provided.

slot_end_time str | None

Reserved slot end timestamp.

slot_start_time str | None

Reserved slot start timestamp.

order_type str | None

Delivery or collection type string.

total float

Current order total in pounds sterling.

failed_payments list[dict[str, Any]]

Payment failure payloads from the API.

Source code in pysainsburys/models/order/order.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
@dataclass(slots=True)
class OrderStatus:
    """
    Live status for the customer's active order slot.

    Attributes:
        order_uid: Identifier for the active order.
        is_cutoff: Whether the amend cutoff has passed.
        is_in_amend_mode: Whether the order can still be amended.
        cutoff_time: Amend cutoff timestamp when provided.
        slot_end_time: Reserved slot end timestamp.
        slot_start_time: Reserved slot start timestamp.
        order_type: Delivery or collection type string.
        total: Current order total in pounds sterling.
        failed_payments: Payment failure payloads from the API.

    """

    order_uid: str | None = None
    is_cutoff: bool = False
    is_in_amend_mode: bool = False
    cutoff_time: str | None = None
    slot_end_time: str | None = None
    slot_start_time: str | None = None
    order_type: str | None = None
    total: float = 0.0
    failed_payments: list[dict[str, Any]] = field(default_factory=list)

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> OrderStatus:
        """Parse order status from grocery API JSON."""
        return cls(
            order_uid=data.get("order_uid"),
            is_cutoff=bool(data.get("is_cutoff", False)),
            is_in_amend_mode=bool(data.get("is_in_amend_mode", False)),
            cutoff_time=data.get("cutoff_time"),
            slot_end_time=data.get("slot_end_time"),
            slot_start_time=data.get("slot_start_time"),
            order_type=data.get("order_type"),
            total=float(data.get("total", 0)),
            failed_payments=list(data.get("failed_payments", [])),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise order status to a plain dictionary."""
        return {
            "order_uid": self.order_uid,
            "is_cutoff": self.is_cutoff,
            "is_in_amend_mode": self.is_in_amend_mode,
            "cutoff_time": self.cutoff_time,
            "slot_end_time": self.slot_end_time,
            "slot_start_time": self.slot_start_time,
            "order_type": self.order_type,
            "total": self.total,
            "failed_payments": self.failed_payments,
        }

    def __iter__(self) -> Iterator[tuple[str, Any]]:
        """Allow ``dict(order_status)`` conversion."""
        return iter(self.to_dict().items())

__iter__()

Allow dict(order_status) conversion.

Source code in pysainsburys/models/order/order.py
146
147
148
def __iter__(self) -> Iterator[tuple[str, Any]]:
    """Allow ``dict(order_status)`` conversion."""
    return iter(self.to_dict().items())

from_dict(data) classmethod

Parse order status from grocery API JSON.

Source code in pysainsburys/models/order/order.py
117
118
119
120
121
122
123
124
125
126
127
128
129
130
@classmethod
def from_dict(cls, data: dict[str, Any]) -> OrderStatus:
    """Parse order status from grocery API JSON."""
    return cls(
        order_uid=data.get("order_uid"),
        is_cutoff=bool(data.get("is_cutoff", False)),
        is_in_amend_mode=bool(data.get("is_in_amend_mode", False)),
        cutoff_time=data.get("cutoff_time"),
        slot_end_time=data.get("slot_end_time"),
        slot_start_time=data.get("slot_start_time"),
        order_type=data.get("order_type"),
        total=float(data.get("total", 0)),
        failed_payments=list(data.get("failed_payments", [])),
    )

to_dict()

Serialise order status to a plain dictionary.

Source code in pysainsburys/models/order/order.py
132
133
134
135
136
137
138
139
140
141
142
143
144
def to_dict(self) -> dict[str, Any]:
    """Serialise order status to a plain dictionary."""
    return {
        "order_uid": self.order_uid,
        "is_cutoff": self.is_cutoff,
        "is_in_amend_mode": self.is_in_amend_mode,
        "cutoff_time": self.cutoff_time,
        "slot_end_time": self.slot_end_time,
        "slot_start_time": self.slot_start_time,
        "order_type": self.order_type,
        "total": self.total,
        "failed_payments": self.failed_payments,
    }

OrderSummary dataclass

Summary information for a past or active order.

Attributes:

Name Type Description
order_id str

Primary order identifier used in URLs and APIs.

order_uid str | None

Alternate order uid when returned separately.

status str | None

Human-readable order status string.

total float | None

Order total in pounds sterling.

slot_start_time str | None

Reserved slot start timestamp.

slot_end_time str | None

Reserved slot end timestamp.

slot_type str | None

Delivery or collection slot type.

Source code in pysainsburys/models/order/order.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
@dataclass(slots=True)
class OrderSummary:
    """
    Summary information for a past or active order.

    Attributes:
        order_id: Primary order identifier used in URLs and APIs.
        order_uid: Alternate order uid when returned separately.
        status: Human-readable order status string.
        total: Order total in pounds sterling.
        slot_start_time: Reserved slot start timestamp.
        slot_end_time: Reserved slot end timestamp.
        slot_type: Delivery or collection slot type.

    """

    order_id: str
    order_uid: str | None = None
    status: str | None = None
    total: float | None = None
    slot_start_time: str | None = None
    slot_end_time: str | None = None
    slot_type: str | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> OrderSummary:
        """Parse an order summary from grocery API JSON."""
        return cls(
            order_id=str(data.get("order_id") or data.get("order_uid") or ""),
            order_uid=data.get("order_uid"),
            status=data.get("status"),
            total=float(data["total"]) if data.get("total") is not None else None,
            slot_start_time=data.get("slot_start_time"),
            slot_end_time=data.get("slot_end_time"),
            slot_type=data.get("slot_type") or data.get("order_type"),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the order summary to a plain dictionary."""
        return {
            "order_id": self.order_id,
            "order_uid": self.order_uid,
            "status": self.status,
            "total": self.total,
            "slot_start_time": self.slot_start_time,
            "slot_end_time": self.slot_end_time,
            "slot_type": self.slot_type,
        }

from_dict(data) classmethod

Parse an order summary from grocery API JSON.

Source code in pysainsburys/models/order/order.py
36
37
38
39
40
41
42
43
44
45
46
47
@classmethod
def from_dict(cls, data: dict[str, Any]) -> OrderSummary:
    """Parse an order summary from grocery API JSON."""
    return cls(
        order_id=str(data.get("order_id") or data.get("order_uid") or ""),
        order_uid=data.get("order_uid"),
        status=data.get("status"),
        total=float(data["total"]) if data.get("total") is not None else None,
        slot_start_time=data.get("slot_start_time"),
        slot_end_time=data.get("slot_end_time"),
        slot_type=data.get("slot_type") or data.get("order_type"),
    )

to_dict()

Serialise the order summary to a plain dictionary.

Source code in pysainsburys/models/order/order.py
49
50
51
52
53
54
55
56
57
58
59
def to_dict(self) -> dict[str, Any]:
    """Serialise the order summary to a plain dictionary."""
    return {
        "order_id": self.order_id,
        "order_uid": self.order_uid,
        "status": self.status,
        "total": self.total,
        "slot_start_time": self.slot_start_time,
        "slot_end_time": self.slot_end_time,
        "slot_type": self.slot_type,
    }

PageControls dataclass

Pagination metadata returned by grocery list endpoints.

Attributes:

Name Type Description
total_record_count int

Total items available across all pages.

returned_record_count int

Items included in the current response.

active_page int

One-based index of the current page.

first_page int

One-based index of the first page.

last_page int

One-based index of the last page.

page_size int

Requested page size.

Source code in pysainsburys/models/common/pagination.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
@dataclass(slots=True)
class PageControls:
    """
    Pagination metadata returned by grocery list endpoints.

    Attributes:
        total_record_count: Total items available across all pages.
        returned_record_count: Items included in the current response.
        active_page: One-based index of the current page.
        first_page: One-based index of the first page.
        last_page: One-based index of the last page.
        page_size: Requested page size.

    """

    total_record_count: int
    returned_record_count: int
    active_page: int
    first_page: int
    last_page: int
    page_size: int

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> PageControls:
        """Parse pagination controls from grocery API JSON."""
        data = data or {}
        page = data.get("page") or {}
        return cls(
            total_record_count=int(data.get("total_record_count", 0)),
            returned_record_count=int(data.get("returned_record_count", 0)),
            active_page=int(page.get("active", 1)),
            first_page=int(page.get("first", 1)),
            last_page=int(page.get("last", 1)),
            page_size=int(page.get("size", 0)),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise pagination controls to a plain dictionary."""
        return {
            "total_record_count": self.total_record_count,
            "returned_record_count": self.returned_record_count,
            "active_page": self.active_page,
            "first_page": self.first_page,
            "last_page": self.last_page,
            "page_size": self.page_size,
        }

from_dict(data) classmethod

Parse pagination controls from grocery API JSON.

Source code in pysainsburys/models/common/pagination.py
31
32
33
34
35
36
37
38
39
40
41
42
43
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> PageControls:
    """Parse pagination controls from grocery API JSON."""
    data = data or {}
    page = data.get("page") or {}
    return cls(
        total_record_count=int(data.get("total_record_count", 0)),
        returned_record_count=int(data.get("returned_record_count", 0)),
        active_page=int(page.get("active", 1)),
        first_page=int(page.get("first", 1)),
        last_page=int(page.get("last", 1)),
        page_size=int(page.get("size", 0)),
    )

to_dict()

Serialise pagination controls to a plain dictionary.

Source code in pysainsburys/models/common/pagination.py
45
46
47
48
49
50
51
52
53
54
def to_dict(self) -> dict[str, Any]:
    """Serialise pagination controls to a plain dictionary."""
    return {
        "total_record_count": self.total_record_count,
        "returned_record_count": self.returned_record_count,
        "active_page": self.active_page,
        "first_page": self.first_page,
        "last_page": self.last_page,
        "page_size": self.page_size,
    }

Price dataclass

A monetary amount with an optional unit of measure.

Attributes:

Name Type Description
price float

Amount in pounds sterling.

measure str | None

Unit label returned by the API (for example ea or kg).

measure_amount float | None

Quantity associated with measure when provided.

Source code in pysainsburys/models/common/price.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@dataclass(slots=True)
class Price:
    """
    A monetary amount with an optional unit of measure.

    Attributes:
        price: Amount in pounds sterling.
        measure: Unit label returned by the API (for example ``ea`` or ``kg``).
        measure_amount: Quantity associated with ``measure`` when provided.

    """

    price: float
    measure: str | None = None
    measure_amount: float | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> Price | None:
        """Parse a price object from grocery API JSON."""
        if not data:
            return None
        return cls(
            price=float(data.get("price", 0)),
            measure=data.get("measure"),
            measure_amount=(
                float(data["measure_amount"])
                if data.get("measure_amount") is not None
                else None
            ),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the price to a plain dictionary."""
        return {
            "price": self.price,
            "measure": self.measure,
            "measure_amount": self.measure_amount,
        }

from_dict(data) classmethod

Parse a price object from grocery API JSON.

Source code in pysainsburys/models/common/price.py
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> Price | None:
    """Parse a price object from grocery API JSON."""
    if not data:
        return None
    return cls(
        price=float(data.get("price", 0)),
        measure=data.get("measure"),
        measure_amount=(
            float(data["measure_amount"])
            if data.get("measure_amount") is not None
            else None
        ),
    )

to_dict()

Serialise the price to a plain dictionary.

Source code in pysainsburys/models/common/price.py
40
41
42
43
44
45
46
def to_dict(self) -> dict[str, Any]:
    """Serialise the price to a plain dictionary."""
    return {
        "price": self.price,
        "measure": self.measure,
        "measure_amount": self.measure_amount,
    }

Product dataclass

A grocery product from the online catalogue.

When bound to a :class:~pysainsburys.Sainsburys client, a product can mutate the authenticated customer's basket directly via :meth:add_to_basket, :meth:set_basket_quantity, and :meth:remove_from_basket.

Nutrition data is parsed automatically from details_html when present on the API response (see :attr:nutrition). The same HTML also supplies description, storage, and related copy on :attr:details. Search results omit details_html, so those sections stay empty until the product is loaded with :meth:~pysainsburys.Sainsburys.get_product.

Attributes:

Name Type Description
product_uid str

Stable Sainsbury's product identifier.

name str

Display name shown on the website and app.

sain_id str | None

Legacy SAIN identifier when returned by the API.

is_favourite bool

Whether the product is in the signed-in customer's favourites list.

favourite_type str | None

Favourite list type when provided by the API.

product_type str | None

Product classification string from the API.

eans list[str]

European article numbers associated with the product.

unit_price Price | None

Price per unit of measure, when available.

retail_price Price | None

Shelf price for the purchasable quantity.

is_available bool

Whether the product can be added to a basket.

is_alcoholic bool

Whether age-restricted checks apply.

reviews ProductReviews | None

Aggregated review metadata.

image_url str | None

Product listing image URL.

nutrition NutritionInfo | None

Parsed nutrition tables and traffic-light summary.

details ProductDetails | None

Description, storage, and other product-text sections.

promotions list[Promotion]

Catalogue offers attached to the product.

nectar_price NectarPrice | None

Nectar member price when the product has one.

favourite_uid str | None

Favourite-list identifier when the product is saved.

short_description str | None

One-line summary from the product payload.

full_url str | None

Absolute product page URL.

original_unit_price Price | None

Unit price before a promotion, when the API returns one.

image str | None

Large product image URL.

image_thumbnail str | None

Medium product image URL.

image_thumbnail_small str | None

Small product image URL.

image_zoom str | None

Zoom image URL when provided.

images list[ProductImage]

Sized image variants from the assets block.

zone str | None

Merchandising zone, such as Drinks.

department str | None

Department name when the API returns one.

labels list[ProductLabel]

Merchandising labels such as British or Chilled.

categories list[ProductCategory]

Catalogue categories that include the product.

breadcrumbs list[ProductBreadcrumb]

Breadcrumb trail for the product page.

attributes dict[str, list[str]]

Attribute groups from the API, including brand.

header ProductHeader | None

Promotional header, such as a Nectar price banner.

is_spotlight bool

Whether the product is flagged as featured.

spotlight_label str | None

Featured label when is_spotlight is set.

not_for_eu bool

Whether the product is marked not for EU sale.

is_intolerant bool

Whether the product carries an intolerance flag.

is_mhra bool

Whether MHRA restrictions apply.

is_supply_chain_orderable bool

Whether supply-chain ordering is enabled.

display_icons list[str]

Icon identifiers shown on the product.

health_rating str | None

Health rating score from health_classification.

hfss_restrictions list[HfssRestriction]

HFSS advertising restrictions by UK nation.

pdp_deep_link str | None

Legacy product-display path.

average_weight AverageWeight | None

Typical weight for a loose product.

promise ProductPromise | None

Delivery promise when a slot context is present.

Source code in pysainsburys/models/product/product.py
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
@dataclass(slots=True)
class Product:
    """
    A grocery product from the online catalogue.

    When bound to a :class:`~pysainsburys.Sainsburys` client, a product can
    mutate the authenticated customer's basket directly via
    :meth:`add_to_basket`, :meth:`set_basket_quantity`, and
    :meth:`remove_from_basket`.

    Nutrition data is parsed automatically from ``details_html`` when present
    on the API response (see :attr:`nutrition`). The same HTML also supplies
    description, storage, and related copy on :attr:`details`. Search results
    omit ``details_html``, so those sections stay empty until the product is
    loaded with :meth:`~pysainsburys.Sainsburys.get_product`.

    Attributes:
        product_uid: Stable Sainsbury's product identifier.
        name: Display name shown on the website and app.
        sain_id: Legacy SAIN identifier when returned by the API.
        is_favourite: Whether the product is in the signed-in customer's
            favourites list.
        favourite_type: Favourite list type when provided by the API.
        product_type: Product classification string from the API.
        eans: European article numbers associated with the product.
        unit_price: Price per unit of measure, when available.
        retail_price: Shelf price for the purchasable quantity.
        is_available: Whether the product can be added to a basket.
        is_alcoholic: Whether age-restricted checks apply.
        reviews: Aggregated review metadata.
        image_url: Product listing image URL.
        nutrition: Parsed nutrition tables and traffic-light summary.
        details: Description, storage, and other product-text sections.
        promotions: Catalogue offers attached to the product.
        nectar_price: Nectar member price when the product has one.
        favourite_uid: Favourite-list identifier when the product is saved.
        short_description: One-line summary from the product payload.
        full_url: Absolute product page URL.
        original_unit_price: Unit price before a promotion, when the API
            returns one.
        image: Large product image URL.
        image_thumbnail: Medium product image URL.
        image_thumbnail_small: Small product image URL.
        image_zoom: Zoom image URL when provided.
        images: Sized image variants from the assets block.
        zone: Merchandising zone, such as ``Drinks``.
        department: Department name when the API returns one.
        labels: Merchandising labels such as British or Chilled.
        categories: Catalogue categories that include the product.
        breadcrumbs: Breadcrumb trail for the product page.
        attributes: Attribute groups from the API, including brand.
        header: Promotional header, such as a Nectar price banner.
        is_spotlight: Whether the product is flagged as featured.
        spotlight_label: Featured label when ``is_spotlight`` is set.
        not_for_eu: Whether the product is marked not for EU sale.
        is_intolerant: Whether the product carries an intolerance flag.
        is_mhra: Whether MHRA restrictions apply.
        is_supply_chain_orderable: Whether supply-chain ordering is enabled.
        display_icons: Icon identifiers shown on the product.
        health_rating: Health rating score from ``health_classification``.
        hfss_restrictions: HFSS advertising restrictions by UK nation.
        pdp_deep_link: Legacy product-display path.
        average_weight: Typical weight for a loose product.
        promise: Delivery promise when a slot context is present.

    """

    product_uid: str
    name: str
    sain_id: str | None = None
    is_favourite: bool = False
    favourite_type: str | None = None
    product_type: str | None = None
    eans: list[str] = field(default_factory=list)
    unit_price: Price | None = None
    retail_price: Price | None = None
    is_available: bool = True
    is_alcoholic: bool = False
    reviews: ProductReviews | None = None
    image_url: str | None = None
    nutrition: NutritionInfo | None = None
    details: ProductDetails | None = None
    promotions: list[Promotion] = field(default_factory=list)
    nectar_price: NectarPrice | None = None
    favourite_uid: str | None = None
    short_description: str | None = None
    full_url: str | None = None
    original_unit_price: Price | None = None
    image: str | None = None
    image_thumbnail: str | None = None
    image_thumbnail_small: str | None = None
    image_zoom: str | None = None
    images: list[ProductImage] = field(default_factory=list)
    zone: str | None = None
    department: str | None = None
    labels: list[ProductLabel] = field(default_factory=list)
    categories: list[ProductCategory] = field(default_factory=list)
    breadcrumbs: list[ProductBreadcrumb] = field(default_factory=list)
    attributes: dict[str, list[str]] = field(default_factory=dict)
    header: ProductHeader | None = None
    is_spotlight: bool = False
    spotlight_label: str | None = None
    not_for_eu: bool = False
    is_intolerant: bool = False
    is_mhra: bool = False
    is_supply_chain_orderable: bool = False
    display_icons: list[str] = field(default_factory=list)
    health_rating: str | None = None
    hfss_restrictions: list[HfssRestriction] = field(default_factory=list)
    pdp_deep_link: str | None = None
    average_weight: AverageWeight | None = None
    promise: ProductPromise | None = None
    _api: API | None = field(default=None, repr=False, compare=False, hash=False)

    @classmethod
    def from_dict(cls, data: dict[str, Any], *, api: API | None = None) -> Product:
        """Parse a product from grocery API JSON."""
        assets_raw = data.get("assets")
        assets: dict[str, Any] = assets_raw if isinstance(assets_raw, dict) else {}
        details_html = data.get("details_html")
        if not isinstance(details_html, str):
            details_html = None
        header_raw = data.get("header")
        header = header_raw if isinstance(header_raw, dict) else None
        weight = data.get("average_weight")
        promise_raw = data.get("promise")
        promise = promise_raw if isinstance(promise_raw, dict) else None
        return cls(
            product_uid=str(data.get("product_uid") or data.get("uid") or ""),
            name=str(data.get("name", "")),
            sain_id=data.get("sainId") or data.get("sain_id"),
            is_favourite=bool(data.get("is_favourite", False)),
            favourite_type=data.get("favourite_type"),
            product_type=data.get("product_type"),
            eans=[str(ean) for ean in data.get("eans", [])],
            unit_price=Price.from_dict(data.get("unit_price")),
            retail_price=Price.from_dict(data.get("retail_price")),
            is_available=bool(data.get("is_available", True)),
            is_alcoholic=bool(data.get("is_alcoholic", False)),
            reviews=ProductReviews.from_dict(data.get("reviews")),
            image_url=text(assets.get("plp_image")),
            nutrition=parse_nutrition_from_details_html(details_html),
            details=product_details_from_api(details_html, data.get("description")),
            promotions=_promotions_from_api(data),
            nectar_price=NectarPrice.from_dict(
                data["nectar_price"]
                if isinstance(data.get("nectar_price"), dict)
                else None
            ),
            favourite_uid=text(data.get("favourite_uid")),
            short_description=text(data.get("short_description")),
            full_url=page_url(data.get("full_url")),
            original_unit_price=Price.from_dict(
                data["original_unit_price"]
                if isinstance(data.get("original_unit_price"), dict)
                else None
            ),
            image=text(data.get("image")),
            image_thumbnail=text(data.get("image_thumbnail")),
            image_thumbnail_small=text(data.get("image_thumbnail_small")),
            image_zoom=text(data.get("image_zoom")),
            images=images_from_api(assets),
            zone=text(data.get("zone")),
            department=text(data.get("department")),
            labels=labels_from_api(data),
            categories=categories_from_api(data),
            breadcrumbs=breadcrumbs_from_api(data),
            attributes=attributes_from_api(data),
            header=ProductHeader.from_dict(header),
            is_spotlight=bool(data.get("is_spotlight", False)),
            spotlight_label=text(data.get("spotlight_label")),
            not_for_eu=bool(data.get("not_for_eu", False)),
            is_intolerant=bool(data.get("is_intolerant", False)),
            is_mhra=bool(data.get("is_mhra", False)),
            is_supply_chain_orderable=bool(
                data.get("is_supply_chain_orderable", False)
            ),
            display_icons=string_list(data.get("display_icons")),
            health_rating=health_rating_from_api(data),
            hfss_restrictions=hfss_from_api(data),
            pdp_deep_link=text(data.get("pdp_deep_link")),
            average_weight=AverageWeight.from_dict(
                weight if isinstance(weight, dict) else None
            ),
            promise=ProductPromise.from_dict(promise),
            _api=api,
        )

    @property
    def brand(self) -> list[str]:
        """Brand names from the product attributes."""
        return list(self.attributes.get("brand", []))

    @classmethod
    def from_basket_nested(
        cls,
        data: dict[str, Any],
        *,
        api: API | None = None,
    ) -> Product:
        """Parse a product object nested inside a basket line item."""
        payload = dict(data)
        if payload.get("sku") and not payload.get("product_uid"):
            payload["product_uid"] = payload["sku"]
        return cls.from_dict(payload, api=api)

    def _require_api(self) -> API:
        if self._api is None:
            msg = (
                "Product is not bound to a Sainsburys client; "
                "fetch it via Sainsburys.get_product() or search_products()."
            )
            raise NotBoundError(msg)
        return self._api

    def bind_api(self, api: API) -> Product:
        """Attach a client for basket and favourites operations."""
        self._api = api
        return self

    def _default_uom(self) -> str:
        if self.retail_price and self.retail_price.measure:
            return self.retail_price.measure
        return "ea"

    async def add_to_basket(
        self,
        quantity: float = 1.0,
        *,
        selected_catchweight: str | None = None,
        uom: str | None = None,
    ) -> Basket:
        """Add this product to the basket (POST increment)."""
        if quantity <= 0:
            return await self.remove_from_basket()
        api = self._require_api()
        body: dict[str, Any] = {
            "product_uid": self.product_uid,
            "quantity": quantity,
            "uom": uom or self._default_uom(),
        }
        if selected_catchweight is not None:
            body["selected_catchweight"] = selected_catchweight
        response = await api.send_request(endpoint="add_basket_item", body=body)
        return basket_from_response(response)

    async def _resolve_basket_item_uid(self, item_uid: str | None) -> str:
        """Resolve a basket line uid without importing basket at module load."""
        from ...basket import resolve_basket_item_uid

        return await resolve_basket_item_uid(
            self._require_api(),
            self.product_uid,
            item_uid,
        )

    async def set_basket_quantity(
        self,
        quantity: float,
        *,
        item_uid: str | None = None,
        selected_catchweight: str | None = None,
        uom: str | None = None,
    ) -> Basket:
        """Set the absolute basket quantity for this product."""
        if quantity <= 0:
            return await self.remove_from_basket(item_uid=item_uid)
        api = self._require_api()
        resolved_item_uid = await self._resolve_basket_item_uid(item_uid)
        item: dict[str, Any] = {
            "product_uid": self.product_uid,
            "quantity": quantity,
            "uom": uom or self._default_uom(),
            "item_uid": resolved_item_uid,
        }
        if selected_catchweight is not None:
            item["selected_catchweight"] = selected_catchweight
        response = await api.send_request(
            endpoint="update_basket",
            body={"items": [item]},
        )
        return basket_from_response(response)

    async def remove_from_basket(
        self,
        *,
        item_uid: str | None = None,
        force_delete: bool = False,
    ) -> Basket:
        """Remove this product from the basket."""
        del force_delete
        resolved_item_uid = await self._resolve_basket_item_uid(item_uid)
        response = await self._require_api().send_request(
            endpoint="update_basket",
            body={
                "items": [
                    {
                        "product_uid": self.product_uid,
                        "quantity": 0,
                        "uom": "ea",
                        "item_uid": resolved_item_uid,
                    }
                ]
            },
        )
        return basket_from_response(response)

    def to_dict(self) -> dict[str, Any]:
        """Serialise the product to a plain dictionary."""
        return {
            "product_uid": self.product_uid,
            "name": self.name,
            "sain_id": self.sain_id,
            "is_favourite": self.is_favourite,
            "favourite_type": self.favourite_type,
            "product_type": self.product_type,
            "eans": self.eans,
            "unit_price": self.unit_price.to_dict() if self.unit_price else None,
            "retail_price": self.retail_price.to_dict() if self.retail_price else None,
            "is_available": self.is_available,
            "is_alcoholic": self.is_alcoholic,
            "reviews": self.reviews.to_dict() if self.reviews else None,
            "image_url": self.image_url,
            "nutrition": self.nutrition.to_dict() if self.nutrition else None,
            "details": self.details.to_dict() if self.details else None,
            "promotions": [promotion.to_dict() for promotion in self.promotions],
            "nectar_price": (
                self.nectar_price.to_dict() if self.nectar_price else None
            ),
            "favourite_uid": self.favourite_uid,
            "short_description": self.short_description,
            "full_url": self.full_url,
            "original_unit_price": (
                self.original_unit_price.to_dict() if self.original_unit_price else None
            ),
            "image": self.image,
            "image_thumbnail": self.image_thumbnail,
            "image_thumbnail_small": self.image_thumbnail_small,
            "image_zoom": self.image_zoom,
            "images": [image.to_dict() for image in self.images],
            "zone": self.zone,
            "department": self.department,
            "labels": [label.to_dict() for label in self.labels],
            "categories": [category.to_dict() for category in self.categories],
            "breadcrumbs": [crumb.to_dict() for crumb in self.breadcrumbs],
            "attributes": self.attributes,
            "brand": self.brand,
            "header": self.header.to_dict() if self.header else None,
            "is_spotlight": self.is_spotlight,
            "spotlight_label": self.spotlight_label,
            "not_for_eu": self.not_for_eu,
            "is_intolerant": self.is_intolerant,
            "is_mhra": self.is_mhra,
            "is_supply_chain_orderable": self.is_supply_chain_orderable,
            "display_icons": self.display_icons,
            "health_rating": self.health_rating,
            "hfss_restrictions": [
                restriction.to_dict() for restriction in self.hfss_restrictions
            ],
            "pdp_deep_link": self.pdp_deep_link,
            "average_weight": (
                self.average_weight.to_dict() if self.average_weight else None
            ),
            "promise": self.promise.to_dict() if self.promise else None,
        }

    def __iter__(self) -> Iterator[tuple[str, Any]]:
        """Allow ``dict(product)`` conversion."""
        return iter(self.to_dict().items())

brand property

Brand names from the product attributes.

__iter__()

Allow dict(product) conversion.

Source code in pysainsburys/models/product/product.py
581
582
583
def __iter__(self) -> Iterator[tuple[str, Any]]:
    """Allow ``dict(product)`` conversion."""
    return iter(self.to_dict().items())

add_to_basket(quantity=1.0, *, selected_catchweight=None, uom=None) async

Add this product to the basket (POST increment).

Source code in pysainsburys/models/product/product.py
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
async def add_to_basket(
    self,
    quantity: float = 1.0,
    *,
    selected_catchweight: str | None = None,
    uom: str | None = None,
) -> Basket:
    """Add this product to the basket (POST increment)."""
    if quantity <= 0:
        return await self.remove_from_basket()
    api = self._require_api()
    body: dict[str, Any] = {
        "product_uid": self.product_uid,
        "quantity": quantity,
        "uom": uom or self._default_uom(),
    }
    if selected_catchweight is not None:
        body["selected_catchweight"] = selected_catchweight
    response = await api.send_request(endpoint="add_basket_item", body=body)
    return basket_from_response(response)

bind_api(api)

Attach a client for basket and favourites operations.

Source code in pysainsburys/models/product/product.py
430
431
432
433
def bind_api(self, api: API) -> Product:
    """Attach a client for basket and favourites operations."""
    self._api = api
    return self

from_basket_nested(data, *, api=None) classmethod

Parse a product object nested inside a basket line item.

Source code in pysainsburys/models/product/product.py
408
409
410
411
412
413
414
415
416
417
418
419
@classmethod
def from_basket_nested(
    cls,
    data: dict[str, Any],
    *,
    api: API | None = None,
) -> Product:
    """Parse a product object nested inside a basket line item."""
    payload = dict(data)
    if payload.get("sku") and not payload.get("product_uid"):
        payload["product_uid"] = payload["sku"]
    return cls.from_dict(payload, api=api)

from_dict(data, *, api=None) classmethod

Parse a product from grocery API JSON.

Source code in pysainsburys/models/product/product.py
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
@classmethod
def from_dict(cls, data: dict[str, Any], *, api: API | None = None) -> Product:
    """Parse a product from grocery API JSON."""
    assets_raw = data.get("assets")
    assets: dict[str, Any] = assets_raw if isinstance(assets_raw, dict) else {}
    details_html = data.get("details_html")
    if not isinstance(details_html, str):
        details_html = None
    header_raw = data.get("header")
    header = header_raw if isinstance(header_raw, dict) else None
    weight = data.get("average_weight")
    promise_raw = data.get("promise")
    promise = promise_raw if isinstance(promise_raw, dict) else None
    return cls(
        product_uid=str(data.get("product_uid") or data.get("uid") or ""),
        name=str(data.get("name", "")),
        sain_id=data.get("sainId") or data.get("sain_id"),
        is_favourite=bool(data.get("is_favourite", False)),
        favourite_type=data.get("favourite_type"),
        product_type=data.get("product_type"),
        eans=[str(ean) for ean in data.get("eans", [])],
        unit_price=Price.from_dict(data.get("unit_price")),
        retail_price=Price.from_dict(data.get("retail_price")),
        is_available=bool(data.get("is_available", True)),
        is_alcoholic=bool(data.get("is_alcoholic", False)),
        reviews=ProductReviews.from_dict(data.get("reviews")),
        image_url=text(assets.get("plp_image")),
        nutrition=parse_nutrition_from_details_html(details_html),
        details=product_details_from_api(details_html, data.get("description")),
        promotions=_promotions_from_api(data),
        nectar_price=NectarPrice.from_dict(
            data["nectar_price"]
            if isinstance(data.get("nectar_price"), dict)
            else None
        ),
        favourite_uid=text(data.get("favourite_uid")),
        short_description=text(data.get("short_description")),
        full_url=page_url(data.get("full_url")),
        original_unit_price=Price.from_dict(
            data["original_unit_price"]
            if isinstance(data.get("original_unit_price"), dict)
            else None
        ),
        image=text(data.get("image")),
        image_thumbnail=text(data.get("image_thumbnail")),
        image_thumbnail_small=text(data.get("image_thumbnail_small")),
        image_zoom=text(data.get("image_zoom")),
        images=images_from_api(assets),
        zone=text(data.get("zone")),
        department=text(data.get("department")),
        labels=labels_from_api(data),
        categories=categories_from_api(data),
        breadcrumbs=breadcrumbs_from_api(data),
        attributes=attributes_from_api(data),
        header=ProductHeader.from_dict(header),
        is_spotlight=bool(data.get("is_spotlight", False)),
        spotlight_label=text(data.get("spotlight_label")),
        not_for_eu=bool(data.get("not_for_eu", False)),
        is_intolerant=bool(data.get("is_intolerant", False)),
        is_mhra=bool(data.get("is_mhra", False)),
        is_supply_chain_orderable=bool(
            data.get("is_supply_chain_orderable", False)
        ),
        display_icons=string_list(data.get("display_icons")),
        health_rating=health_rating_from_api(data),
        hfss_restrictions=hfss_from_api(data),
        pdp_deep_link=text(data.get("pdp_deep_link")),
        average_weight=AverageWeight.from_dict(
            weight if isinstance(weight, dict) else None
        ),
        promise=ProductPromise.from_dict(promise),
        _api=api,
    )

remove_from_basket(*, item_uid=None, force_delete=False) async

Remove this product from the basket.

Source code in pysainsburys/models/product/product.py
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
async def remove_from_basket(
    self,
    *,
    item_uid: str | None = None,
    force_delete: bool = False,
) -> Basket:
    """Remove this product from the basket."""
    del force_delete
    resolved_item_uid = await self._resolve_basket_item_uid(item_uid)
    response = await self._require_api().send_request(
        endpoint="update_basket",
        body={
            "items": [
                {
                    "product_uid": self.product_uid,
                    "quantity": 0,
                    "uom": "ea",
                    "item_uid": resolved_item_uid,
                }
            ]
        },
    )
    return basket_from_response(response)

set_basket_quantity(quantity, *, item_uid=None, selected_catchweight=None, uom=None) async

Set the absolute basket quantity for this product.

Source code in pysainsburys/models/product/product.py
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
async def set_basket_quantity(
    self,
    quantity: float,
    *,
    item_uid: str | None = None,
    selected_catchweight: str | None = None,
    uom: str | None = None,
) -> Basket:
    """Set the absolute basket quantity for this product."""
    if quantity <= 0:
        return await self.remove_from_basket(item_uid=item_uid)
    api = self._require_api()
    resolved_item_uid = await self._resolve_basket_item_uid(item_uid)
    item: dict[str, Any] = {
        "product_uid": self.product_uid,
        "quantity": quantity,
        "uom": uom or self._default_uom(),
        "item_uid": resolved_item_uid,
    }
    if selected_catchweight is not None:
        item["selected_catchweight"] = selected_catchweight
    response = await api.send_request(
        endpoint="update_basket",
        body={"items": [item]},
    )
    return basket_from_response(response)

to_dict()

Serialise the product to a plain dictionary.

Source code in pysainsburys/models/product/product.py
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
def to_dict(self) -> dict[str, Any]:
    """Serialise the product to a plain dictionary."""
    return {
        "product_uid": self.product_uid,
        "name": self.name,
        "sain_id": self.sain_id,
        "is_favourite": self.is_favourite,
        "favourite_type": self.favourite_type,
        "product_type": self.product_type,
        "eans": self.eans,
        "unit_price": self.unit_price.to_dict() if self.unit_price else None,
        "retail_price": self.retail_price.to_dict() if self.retail_price else None,
        "is_available": self.is_available,
        "is_alcoholic": self.is_alcoholic,
        "reviews": self.reviews.to_dict() if self.reviews else None,
        "image_url": self.image_url,
        "nutrition": self.nutrition.to_dict() if self.nutrition else None,
        "details": self.details.to_dict() if self.details else None,
        "promotions": [promotion.to_dict() for promotion in self.promotions],
        "nectar_price": (
            self.nectar_price.to_dict() if self.nectar_price else None
        ),
        "favourite_uid": self.favourite_uid,
        "short_description": self.short_description,
        "full_url": self.full_url,
        "original_unit_price": (
            self.original_unit_price.to_dict() if self.original_unit_price else None
        ),
        "image": self.image,
        "image_thumbnail": self.image_thumbnail,
        "image_thumbnail_small": self.image_thumbnail_small,
        "image_zoom": self.image_zoom,
        "images": [image.to_dict() for image in self.images],
        "zone": self.zone,
        "department": self.department,
        "labels": [label.to_dict() for label in self.labels],
        "categories": [category.to_dict() for category in self.categories],
        "breadcrumbs": [crumb.to_dict() for crumb in self.breadcrumbs],
        "attributes": self.attributes,
        "brand": self.brand,
        "header": self.header.to_dict() if self.header else None,
        "is_spotlight": self.is_spotlight,
        "spotlight_label": self.spotlight_label,
        "not_for_eu": self.not_for_eu,
        "is_intolerant": self.is_intolerant,
        "is_mhra": self.is_mhra,
        "is_supply_chain_orderable": self.is_supply_chain_orderable,
        "display_icons": self.display_icons,
        "health_rating": self.health_rating,
        "hfss_restrictions": [
            restriction.to_dict() for restriction in self.hfss_restrictions
        ],
        "pdp_deep_link": self.pdp_deep_link,
        "average_weight": (
            self.average_weight.to_dict() if self.average_weight else None
        ),
        "promise": self.promise.to_dict() if self.promise else None,
    }

ProductBreadcrumb dataclass

One step in the product page breadcrumb trail.

Source code in pysainsburys/models/product/catalogue.py
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
@dataclass(slots=True)
class ProductBreadcrumb:
    """One step in the product page breadcrumb trail."""

    label: str
    url: str | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> ProductBreadcrumb | None:
        """Parse a breadcrumb from grocery API JSON."""
        if not data:
            return None
        label = text(data.get("label"))
        if not label:
            return None
        return cls(label=label, url=page_url(data.get("url")))

    def to_dict(self) -> dict[str, Any]:
        """Serialise the breadcrumb to a plain dictionary."""
        return {"label": self.label, "url": self.url}

from_dict(data) classmethod

Parse a breadcrumb from grocery API JSON.

Source code in pysainsburys/models/product/catalogue.py
108
109
110
111
112
113
114
115
116
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> ProductBreadcrumb | None:
    """Parse a breadcrumb from grocery API JSON."""
    if not data:
        return None
    label = text(data.get("label"))
    if not label:
        return None
    return cls(label=label, url=page_url(data.get("url")))

to_dict()

Serialise the breadcrumb to a plain dictionary.

Source code in pysainsburys/models/product/catalogue.py
118
119
120
def to_dict(self) -> dict[str, Any]:
    """Serialise the breadcrumb to a plain dictionary."""
    return {"label": self.label, "url": self.url}

ProductCategory dataclass

A catalogue category the product belongs to.

Source code in pysainsburys/models/product/catalogue.py
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
@dataclass(slots=True)
class ProductCategory:
    """A catalogue category the product belongs to."""

    category_id: str
    name: str

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> ProductCategory | None:
        """Parse a category from grocery API JSON."""
        if not data:
            return None
        category_id = text(data.get("id") or data.get("category_id"))
        name = text(data.get("name"))
        if not category_id or not name:
            return None
        return cls(category_id=category_id, name=name)

    def to_dict(self) -> dict[str, Any]:
        """Serialise the category to a plain dictionary."""
        return {"category_id": self.category_id, "name": self.name}

from_dict(data) classmethod

Parse a category from grocery API JSON.

Source code in pysainsburys/models/product/catalogue.py
85
86
87
88
89
90
91
92
93
94
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> ProductCategory | None:
    """Parse a category from grocery API JSON."""
    if not data:
        return None
    category_id = text(data.get("id") or data.get("category_id"))
    name = text(data.get("name"))
    if not category_id or not name:
        return None
    return cls(category_id=category_id, name=name)

to_dict()

Serialise the category to a plain dictionary.

Source code in pysainsburys/models/product/catalogue.py
96
97
98
def to_dict(self) -> dict[str, Any]:
    """Serialise the category to a plain dictionary."""
    return {"category_id": self.category_id, "name": self.name}

ProductDetails dataclass

Catalogue copy parsed from a product detail page.

Each field is a list of paragraphs. A heading the page does not include is None.

Attributes:

Name Type Description
description list[str] | None

Product description paragraphs.

storage list[str] | None

Storage instructions.

dietary_information list[str] | None

Dietary and allergen statements.

ingredients list[str] | None

Ingredient list paragraphs.

manufacturer list[str] | None

Manufacturer or packer details.

preparation list[str] | None

Preparation or serving instructions.

country_of_origin list[str] | None

Origin or packing-country statements.

packaging list[str] | None

Packaging description.

Source code in pysainsburys/models/product/details.py
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
@dataclass(slots=True)
class ProductDetails:
    """
    Catalogue copy parsed from a product detail page.

    Each field is a list of paragraphs. A heading the page does not include
    is ``None``.

    Attributes:
        description: Product description paragraphs.
        storage: Storage instructions.
        dietary_information: Dietary and allergen statements.
        ingredients: Ingredient list paragraphs.
        manufacturer: Manufacturer or packer details.
        preparation: Preparation or serving instructions.
        country_of_origin: Origin or packing-country statements.
        packaging: Packaging description.

    """

    description: list[str] | None = None
    storage: list[str] | None = None
    dietary_information: list[str] | None = None
    ingredients: list[str] | None = None
    manufacturer: list[str] | None = None
    preparation: list[str] | None = None
    country_of_origin: list[str] | None = None
    packaging: list[str] | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> ProductDetails | None:
        """Parse product detail sections from a serialised mapping."""
        if not data:
            return None
        details = cls(
            description=_string_list(data.get("description")),
            storage=_string_list(data.get("storage")),
            dietary_information=_string_list(data.get("dietary_information")),
            ingredients=_string_list(data.get("ingredients")),
            manufacturer=_string_list(data.get("manufacturer")),
            preparation=_string_list(data.get("preparation")),
            country_of_origin=_string_list(data.get("country_of_origin")),
            packaging=_string_list(data.get("packaging")),
        )
        if details.is_empty():
            return None
        return details

    def is_empty(self) -> bool:
        """Return whether every section is missing."""
        return all(
            value is None
            for value in (
                self.description,
                self.storage,
                self.dietary_information,
                self.ingredients,
                self.manufacturer,
                self.preparation,
                self.country_of_origin,
                self.packaging,
            )
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the detail sections to a plain dictionary."""
        return {
            "description": self.description,
            "storage": self.storage,
            "dietary_information": self.dietary_information,
            "ingredients": self.ingredients,
            "manufacturer": self.manufacturer,
            "preparation": self.preparation,
            "country_of_origin": self.country_of_origin,
            "packaging": self.packaging,
        }

from_dict(data) classmethod

Parse product detail sections from a serialised mapping.

Source code in pysainsburys/models/product/details.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> ProductDetails | None:
    """Parse product detail sections from a serialised mapping."""
    if not data:
        return None
    details = cls(
        description=_string_list(data.get("description")),
        storage=_string_list(data.get("storage")),
        dietary_information=_string_list(data.get("dietary_information")),
        ingredients=_string_list(data.get("ingredients")),
        manufacturer=_string_list(data.get("manufacturer")),
        preparation=_string_list(data.get("preparation")),
        country_of_origin=_string_list(data.get("country_of_origin")),
        packaging=_string_list(data.get("packaging")),
    )
    if details.is_empty():
        return None
    return details

is_empty()

Return whether every section is missing.

Source code in pysainsburys/models/product/details.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
def is_empty(self) -> bool:
    """Return whether every section is missing."""
    return all(
        value is None
        for value in (
            self.description,
            self.storage,
            self.dietary_information,
            self.ingredients,
            self.manufacturer,
            self.preparation,
            self.country_of_origin,
            self.packaging,
        )
    )

to_dict()

Serialise the detail sections to a plain dictionary.

Source code in pysainsburys/models/product/details.py
107
108
109
110
111
112
113
114
115
116
117
118
def to_dict(self) -> dict[str, Any]:
    """Serialise the detail sections to a plain dictionary."""
    return {
        "description": self.description,
        "storage": self.storage,
        "dietary_information": self.dietary_information,
        "ingredients": self.ingredients,
        "manufacturer": self.manufacturer,
        "preparation": self.preparation,
        "country_of_origin": self.country_of_origin,
        "packaging": self.packaging,
    }

ProductHeader dataclass

Promotional header shown above the product, such as a Nectar price.

Source code in pysainsburys/models/product/catalogue.py
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
@dataclass(slots=True)
class ProductHeader:
    """Promotional header shown above the product, such as a Nectar price."""

    text: str | None = None
    type: str | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> ProductHeader | None:
        """Parse a product header from grocery API JSON."""
        if not data:
            return None
        header_text = text(data.get("text"))
        header_type = text(data.get("type"))
        if not header_text and not header_type:
            return None
        return cls(text=header_text, type=header_type)

    def to_dict(self) -> dict[str, Any]:
        """Serialise the header to a plain dictionary."""
        return {"text": self.text, "type": self.type}

from_dict(data) classmethod

Parse a product header from grocery API JSON.

Source code in pysainsburys/models/product/catalogue.py
130
131
132
133
134
135
136
137
138
139
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> ProductHeader | None:
    """Parse a product header from grocery API JSON."""
    if not data:
        return None
    header_text = text(data.get("text"))
    header_type = text(data.get("type"))
    if not header_text and not header_type:
        return None
    return cls(text=header_text, type=header_type)

to_dict()

Serialise the header to a plain dictionary.

Source code in pysainsburys/models/product/catalogue.py
141
142
143
def to_dict(self) -> dict[str, Any]:
    """Serialise the header to a plain dictionary."""
    return {"text": self.text, "type": self.type}

ProductImage dataclass

A product image and the sizes the API provides for it.

Source code in pysainsburys/models/product/catalogue.py
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
@dataclass(slots=True)
class ProductImage:
    """A product image and the sizes the API provides for it."""

    image_id: str | None = None
    sizes: list[ProductImageSize] = field(default_factory=list)

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> ProductImage | None:
        """Parse a product image from grocery API JSON."""
        if not data:
            return None
        sizes = [
            size
            for item in _mapping_list(data.get("sizes"))
            if (size := ProductImageSize.from_dict(item)) is not None
        ]
        image_id = text(data.get("id") or data.get("image_id"))
        if not image_id and not sizes:
            return None
        return cls(image_id=image_id, sizes=sizes)

    def to_dict(self) -> dict[str, Any]:
        """Serialise the product image to a plain dictionary."""
        return {
            "image_id": self.image_id,
            "sizes": [size.to_dict() for size in self.sizes],
        }

from_dict(data) classmethod

Parse a product image from grocery API JSON.

Source code in pysainsburys/models/product/catalogue.py
180
181
182
183
184
185
186
187
188
189
190
191
192
193
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> ProductImage | None:
    """Parse a product image from grocery API JSON."""
    if not data:
        return None
    sizes = [
        size
        for item in _mapping_list(data.get("sizes"))
        if (size := ProductImageSize.from_dict(item)) is not None
    ]
    image_id = text(data.get("id") or data.get("image_id"))
    if not image_id and not sizes:
        return None
    return cls(image_id=image_id, sizes=sizes)

to_dict()

Serialise the product image to a plain dictionary.

Source code in pysainsburys/models/product/catalogue.py
195
196
197
198
199
200
def to_dict(self) -> dict[str, Any]:
    """Serialise the product image to a plain dictionary."""
    return {
        "image_id": self.image_id,
        "sizes": [size.to_dict() for size in self.sizes],
    }

ProductImageSize dataclass

One rendered size of a product image.

Source code in pysainsburys/models/product/catalogue.py
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
@dataclass(slots=True)
class ProductImageSize:
    """One rendered size of a product image."""

    url: str
    width: int | None = None
    height: int | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> ProductImageSize | None:
        """Parse an image size from grocery API JSON."""
        if not data:
            return None
        url = text(data.get("url"))
        if not url:
            return None
        return cls(
            url=url,
            width=_int(data.get("width")),
            height=_int(data.get("height")),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the image size to a plain dictionary."""
        return {"url": self.url, "width": self.width, "height": self.height}

from_dict(data) classmethod

Parse an image size from grocery API JSON.

Source code in pysainsburys/models/product/catalogue.py
154
155
156
157
158
159
160
161
162
163
164
165
166
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> ProductImageSize | None:
    """Parse an image size from grocery API JSON."""
    if not data:
        return None
    url = text(data.get("url"))
    if not url:
        return None
    return cls(
        url=url,
        width=_int(data.get("width")),
        height=_int(data.get("height")),
    )

to_dict()

Serialise the image size to a plain dictionary.

Source code in pysainsburys/models/product/catalogue.py
168
169
170
def to_dict(self) -> dict[str, Any]:
    """Serialise the image size to a plain dictionary."""
    return {"url": self.url, "width": self.width, "height": self.height}

ProductLabel dataclass

A merchandising label such as British or Chilled.

Source code in pysainsburys/models/product/catalogue.py
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
@dataclass(slots=True)
class ProductLabel:
    """A merchandising label such as ``British`` or ``Chilled``."""

    label_uid: str
    text: str | None = None
    alt_text: str | None = None
    color: str | None = None
    link: str | None = None
    link_opens_in_new_window: bool = False

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> ProductLabel | None:
        """Parse a label from grocery API JSON."""
        if not data:
            return None
        label_uid = text(data.get("label_uid") or data.get("text"))
        if not label_uid:
            return None
        return cls(
            label_uid=label_uid,
            text=text(data.get("text")),
            alt_text=text(data.get("alt_text")),
            color=text(data.get("color")),
            link=text(data.get("link")),
            link_opens_in_new_window=bool(data.get("link_opens_in_new_window", False)),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the label to a plain dictionary."""
        return {
            "label_uid": self.label_uid,
            "text": self.text,
            "alt_text": self.alt_text,
            "color": self.color,
            "link": self.link,
            "link_opens_in_new_window": self.link_opens_in_new_window,
        }

from_dict(data) classmethod

Parse a label from grocery API JSON.

Source code in pysainsburys/models/product/catalogue.py
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> ProductLabel | None:
    """Parse a label from grocery API JSON."""
    if not data:
        return None
    label_uid = text(data.get("label_uid") or data.get("text"))
    if not label_uid:
        return None
    return cls(
        label_uid=label_uid,
        text=text(data.get("text")),
        alt_text=text(data.get("alt_text")),
        color=text(data.get("color")),
        link=text(data.get("link")),
        link_opens_in_new_window=bool(data.get("link_opens_in_new_window", False)),
    )

to_dict()

Serialise the label to a plain dictionary.

Source code in pysainsburys/models/product/catalogue.py
66
67
68
69
70
71
72
73
74
75
def to_dict(self) -> dict[str, Any]:
    """Serialise the label to a plain dictionary."""
    return {
        "label_uid": self.label_uid,
        "text": self.text,
        "alt_text": self.alt_text,
        "color": self.color,
        "link": self.link,
        "link_opens_in_new_window": self.link_opens_in_new_window,
    }

ProductList dataclass

A paginated list of catalogue products.

Source code in pysainsburys/models/product/product.py
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
@dataclass(slots=True)
class ProductList:
    """A paginated list of catalogue products."""

    products: list[Product]
    controls: PageControls

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> ProductList:
        """Parse a paginated product list from grocery API JSON."""
        products = [Product.from_dict(item) for item in data.get("products", [])]
        return cls(
            products=products,
            controls=PageControls.from_dict(data.get("controls")),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the product list to a plain dictionary."""
        return {
            "products": [product.to_dict() for product in self.products],
            "controls": self.controls.to_dict(),
        }

    def __iter__(self) -> Iterator[tuple[str, Any]]:
        """Allow ``dict(product_list)`` conversion."""
        return iter(self.to_dict().items())

__iter__()

Allow dict(product_list) conversion.

Source code in pysainsburys/models/product/product.py
609
610
611
def __iter__(self) -> Iterator[tuple[str, Any]]:
    """Allow ``dict(product_list)`` conversion."""
    return iter(self.to_dict().items())

from_dict(data) classmethod

Parse a paginated product list from grocery API JSON.

Source code in pysainsburys/models/product/product.py
593
594
595
596
597
598
599
600
@classmethod
def from_dict(cls, data: dict[str, Any]) -> ProductList:
    """Parse a paginated product list from grocery API JSON."""
    products = [Product.from_dict(item) for item in data.get("products", [])]
    return cls(
        products=products,
        controls=PageControls.from_dict(data.get("controls")),
    )

to_dict()

Serialise the product list to a plain dictionary.

Source code in pysainsburys/models/product/product.py
602
603
604
605
606
607
def to_dict(self) -> dict[str, Any]:
    """Serialise the product list to a plain dictionary."""
    return {
        "products": [product.to_dict() for product in self.products],
        "controls": self.controls.to_dict(),
    }

ProductPromise dataclass

Delivery promise attached to a product when a slot context exists.

Source code in pysainsburys/models/product/catalogue.py
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
@dataclass(slots=True)
class ProductPromise:
    """Delivery promise attached to a product when a slot context exists."""

    type: str | None = None
    earliest_promise_date: str | None = None
    last_amendment_date: str | None = None
    status_label: str | None = None
    status_type: str | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> ProductPromise | None:
        """Parse a product promise from grocery API JSON."""
        if not data:
            return None
        status_raw = data.get("status")
        status = status_raw if isinstance(status_raw, dict) else {}
        promise = cls(
            type=text(data.get("type")),
            earliest_promise_date=text(data.get("earliest_promise_date")),
            last_amendment_date=text(data.get("last_amendment_date")),
            status_label=text(status.get("label")),
            status_type=text(status.get("type")),
        )
        if promise.status_type == "NONE":
            promise.status_type = None
        if promise.is_empty():
            return None
        return promise

    def is_empty(self) -> bool:
        """Return whether the promise carries no slot information."""
        return all(
            value is None
            for value in (
                self.type,
                self.earliest_promise_date,
                self.last_amendment_date,
                self.status_label,
                self.status_type,
            )
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the promise to a plain dictionary."""
        return {
            "type": self.type,
            "earliest_promise_date": self.earliest_promise_date,
            "last_amendment_date": self.last_amendment_date,
            "status_label": self.status_label,
            "status_type": self.status_type,
        }

from_dict(data) classmethod

Parse a product promise from grocery API JSON.

Source code in pysainsburys/models/product/catalogue.py
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> ProductPromise | None:
    """Parse a product promise from grocery API JSON."""
    if not data:
        return None
    status_raw = data.get("status")
    status = status_raw if isinstance(status_raw, dict) else {}
    promise = cls(
        type=text(data.get("type")),
        earliest_promise_date=text(data.get("earliest_promise_date")),
        last_amendment_date=text(data.get("last_amendment_date")),
        status_label=text(status.get("label")),
        status_type=text(status.get("type")),
    )
    if promise.status_type == "NONE":
        promise.status_type = None
    if promise.is_empty():
        return None
    return promise

is_empty()

Return whether the promise carries no slot information.

Source code in pysainsburys/models/product/catalogue.py
289
290
291
292
293
294
295
296
297
298
299
300
def is_empty(self) -> bool:
    """Return whether the promise carries no slot information."""
    return all(
        value is None
        for value in (
            self.type,
            self.earliest_promise_date,
            self.last_amendment_date,
            self.status_label,
            self.status_type,
        )
    )

to_dict()

Serialise the promise to a plain dictionary.

Source code in pysainsburys/models/product/catalogue.py
302
303
304
305
306
307
308
309
310
def to_dict(self) -> dict[str, Any]:
    """Serialise the promise to a plain dictionary."""
    return {
        "type": self.type,
        "earliest_promise_date": self.earliest_promise_date,
        "last_amendment_date": self.last_amendment_date,
        "status_label": self.status_label,
        "status_type": self.status_type,
    }

ProductReviews dataclass

Aggregated review metadata for a product.

Attributes:

Name Type Description
is_enabled bool

Whether reviews are shown for this product.

product_uid str | None

Product identifier referenced by the review service.

total int

Number of published reviews.

average_rating float

Mean star rating across reviews.

Source code in pysainsburys/models/product/product.py
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
@dataclass(slots=True)
class ProductReviews:
    """
    Aggregated review metadata for a product.

    Attributes:
        is_enabled: Whether reviews are shown for this product.
        product_uid: Product identifier referenced by the review service.
        total: Number of published reviews.
        average_rating: Mean star rating across reviews.

    """

    is_enabled: bool
    product_uid: str | None
    total: int
    average_rating: float

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> ProductReviews | None:
        """Parse review metadata from grocery API JSON."""
        if not data:
            return None
        return cls(
            is_enabled=bool(data.get("is_enabled", False)),
            product_uid=data.get("product_uid"),
            total=int(data.get("total", 0)),
            average_rating=float(data.get("average_rating", 0)),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise review metadata to a plain dictionary."""
        return {
            "is_enabled": self.is_enabled,
            "product_uid": self.product_uid,
            "total": self.total,
            "average_rating": self.average_rating,
        }

from_dict(data) classmethod

Parse review metadata from grocery API JSON.

Source code in pysainsburys/models/product/product.py
58
59
60
61
62
63
64
65
66
67
68
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> ProductReviews | None:
    """Parse review metadata from grocery API JSON."""
    if not data:
        return None
    return cls(
        is_enabled=bool(data.get("is_enabled", False)),
        product_uid=data.get("product_uid"),
        total=int(data.get("total", 0)),
        average_rating=float(data.get("average_rating", 0)),
    )

to_dict()

Serialise review metadata to a plain dictionary.

Source code in pysainsburys/models/product/product.py
70
71
72
73
74
75
76
77
def to_dict(self) -> dict[str, Any]:
    """Serialise review metadata to a plain dictionary."""
    return {
        "is_enabled": self.is_enabled,
        "product_uid": self.product_uid,
        "total": self.total,
        "average_rating": self.average_rating,
    }

Promotion dataclass

A catalogue promotion attached to a product.

Attributes:

Name Type Description
promotion_uid str

Promotion identifier.

strap_line str | None

Customer-facing offer text, such as Buy 1 for 3.

start_date str | None

Offer start timestamp from the API.

end_date str | None

Offer end timestamp from the API.

original_price float | None

Shelf price before the promotion, in pounds sterling.

is_nectar bool

Whether the offer is a Nectar price.

promo_type str | None

Promotion mechanic type from the API.

promo_group str | None

Promotion grouping from the API.

promo_mechanic_id str | None

Mechanic identifier from the API.

icon str | None

Promotion icon URL when provided.

link str | None

Relative link to the promotion lister.

Source code in pysainsburys/models/product/product.py
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
@dataclass(slots=True)
class Promotion:
    """
    A catalogue promotion attached to a product.

    Attributes:
        promotion_uid: Promotion identifier.
        strap_line: Customer-facing offer text, such as ``Buy 1 for 3``.
        start_date: Offer start timestamp from the API.
        end_date: Offer end timestamp from the API.
        original_price: Shelf price before the promotion, in pounds sterling.
        is_nectar: Whether the offer is a Nectar price.
        promo_type: Promotion mechanic type from the API.
        promo_group: Promotion grouping from the API.
        promo_mechanic_id: Mechanic identifier from the API.
        icon: Promotion icon URL when provided.
        link: Relative link to the promotion lister.

    """

    promotion_uid: str
    strap_line: str | None = None
    start_date: str | None = None
    end_date: str | None = None
    original_price: float | None = None
    is_nectar: bool = False
    promo_type: str | None = None
    promo_group: str | None = None
    promo_mechanic_id: str | None = None
    icon: str | None = None
    link: str | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> Promotion | None:
        """Parse a promotion from grocery API JSON."""
        if not data:
            return None
        promotion_uid = data.get("promotion_uid")
        if not promotion_uid and not data.get("strap_line"):
            return None
        original_price = data.get("original_price")
        mechanic_id = data.get("promo_mechanic_id")
        return cls(
            promotion_uid=str(promotion_uid or ""),
            strap_line=data.get("strap_line"),
            start_date=data.get("start_date"),
            end_date=data.get("end_date"),
            original_price=(
                float(original_price) if original_price is not None else None
            ),
            is_nectar=bool(data.get("is_nectar", False)),
            promo_type=data.get("promo_type"),
            promo_group=data.get("promo_group"),
            promo_mechanic_id=str(mechanic_id) if mechanic_id is not None else None,
            icon=data.get("icon") or None,
            link=data.get("link"),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the promotion to a plain dictionary."""
        return {
            "promotion_uid": self.promotion_uid,
            "strap_line": self.strap_line,
            "start_date": self.start_date,
            "end_date": self.end_date,
            "original_price": self.original_price,
            "is_nectar": self.is_nectar,
            "promo_type": self.promo_type,
            "promo_group": self.promo_group,
            "promo_mechanic_id": self.promo_mechanic_id,
            "icon": self.icon,
            "link": self.link,
        }

from_dict(data) classmethod

Parse a promotion from grocery API JSON.

Source code in pysainsburys/models/product/product.py
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> Promotion | None:
    """Parse a promotion from grocery API JSON."""
    if not data:
        return None
    promotion_uid = data.get("promotion_uid")
    if not promotion_uid and not data.get("strap_line"):
        return None
    original_price = data.get("original_price")
    mechanic_id = data.get("promo_mechanic_id")
    return cls(
        promotion_uid=str(promotion_uid or ""),
        strap_line=data.get("strap_line"),
        start_date=data.get("start_date"),
        end_date=data.get("end_date"),
        original_price=(
            float(original_price) if original_price is not None else None
        ),
        is_nectar=bool(data.get("is_nectar", False)),
        promo_type=data.get("promo_type"),
        promo_group=data.get("promo_group"),
        promo_mechanic_id=str(mechanic_id) if mechanic_id is not None else None,
        icon=data.get("icon") or None,
        link=data.get("link"),
    )

to_dict()

Serialise the promotion to a plain dictionary.

Source code in pysainsburys/models/product/product.py
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
def to_dict(self) -> dict[str, Any]:
    """Serialise the promotion to a plain dictionary."""
    return {
        "promotion_uid": self.promotion_uid,
        "strap_line": self.strap_line,
        "start_date": self.start_date,
        "end_date": self.end_date,
        "original_price": self.original_price,
        "is_nectar": self.is_nectar,
        "promo_type": self.promo_type,
        "promo_group": self.promo_group,
        "promo_mechanic_id": self.promo_mechanic_id,
        "icon": self.icon,
        "link": self.link,
    }

SlotDay dataclass

Slots grouped for a single calendar day.

Source code in pysainsburys/models/slot/slot.py
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
@dataclass(slots=True)
class SlotDay:
    """Slots grouped for a single calendar day."""

    date: str | None = None
    day_label: str | None = None
    slots: list[DeliverySlot] = field(default_factory=list)

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> SlotDay:
        """Parse a day entry from grocery API JSON."""
        raw_slots = data.get("slots") or data.get("available_slots") or []
        return cls(
            date=data.get("date") or data.get("day_date"),
            day_label=data.get("day_label")
            or data.get("label")
            or data.get("day_name"),
            slots=[
                DeliverySlot.from_dict(item)
                for item in raw_slots
                if isinstance(item, dict)
            ],
        )

    @property
    def available_slots(self) -> list[DeliverySlot]:
        """Return only slots marked as available."""
        return [slot for slot in self.slots if slot.is_available]

    def to_dict(self) -> dict[str, Any]:
        """Serialise the day to a plain dictionary."""
        return {
            "date": self.date,
            "day_label": self.day_label,
            "slots": [slot.to_dict() for slot in self.slots],
        }

    def __iter__(self) -> Iterator[tuple[str, Any]]:
        """Allow ``dict(day)`` conversion."""
        return iter(self.to_dict().items())

available_slots property

Return only slots marked as available.

__iter__()

Allow dict(day) conversion.

Source code in pysainsburys/models/slot/slot.py
136
137
138
def __iter__(self) -> Iterator[tuple[str, Any]]:
    """Allow ``dict(day)`` conversion."""
    return iter(self.to_dict().items())

from_dict(data) classmethod

Parse a day entry from grocery API JSON.

Source code in pysainsburys/models/slot/slot.py
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
@classmethod
def from_dict(cls, data: dict[str, Any]) -> SlotDay:
    """Parse a day entry from grocery API JSON."""
    raw_slots = data.get("slots") or data.get("available_slots") or []
    return cls(
        date=data.get("date") or data.get("day_date"),
        day_label=data.get("day_label")
        or data.get("label")
        or data.get("day_name"),
        slots=[
            DeliverySlot.from_dict(item)
            for item in raw_slots
            if isinstance(item, dict)
        ],
    )

to_dict()

Serialise the day to a plain dictionary.

Source code in pysainsburys/models/slot/slot.py
128
129
130
131
132
133
134
def to_dict(self) -> dict[str, Any]:
    """Serialise the day to a plain dictionary."""
    return {
        "date": self.date,
        "day_label": self.day_label,
        "slots": [slot.to_dict() for slot in self.slots],
    }

SlotReservation dataclass

Current slot reservation state for the customer.

Source code in pysainsburys/models/slot/slot.py
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
@dataclass(slots=True)
class SlotReservation:
    """Current slot reservation state for the customer."""

    reservation_type: str | None = None
    postcode: str | None = None
    region: str | None = None
    store_identifier: str | None = None
    location_uid: str | None = None
    is_expired: bool = False
    reserved_until: str | None = None
    is_alcohol_restricted_store: bool = False
    flexi_stores: list[str] = field(default_factory=list)
    slot: DeliverySlot | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> SlotReservation:
        """Parse slot reservation JSON."""
        slot_data = data.get("slot")
        slot = (
            DeliverySlot.from_dict(slot_data) if isinstance(slot_data, dict) else None
        )
        flexi = data.get("flexi_stores") or []
        return cls(
            reservation_type=data.get("reservation_type"),
            postcode=data.get("postcode"),
            region=data.get("region"),
            store_identifier=data.get("store_identifier"),
            location_uid=data.get("location_uid"),
            is_expired=bool(data.get("is_expired", False)),
            reserved_until=data.get("reserved_until"),
            is_alcohol_restricted_store=bool(
                data.get("is_alcohol_restricted_store", False)
            ),
            flexi_stores=[str(value) for value in flexi],
            slot=slot,
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the reservation to a plain dictionary."""
        return {
            "reservation_type": self.reservation_type,
            "postcode": self.postcode,
            "region": self.region,
            "store_identifier": self.store_identifier,
            "location_uid": self.location_uid,
            "is_expired": self.is_expired,
            "reserved_until": self.reserved_until,
            "is_alcohol_restricted_store": self.is_alcohol_restricted_store,
            "flexi_stores": list(self.flexi_stores),
            "slot": self.slot.to_dict() if self.slot else None,
        }

    def __iter__(self) -> Iterator[tuple[str, Any]]:
        """Allow ``dict(reservation)`` conversion."""
        return iter(self.to_dict().items())

__iter__()

Allow dict(reservation) conversion.

Source code in pysainsburys/models/slot/slot.py
275
276
277
def __iter__(self) -> Iterator[tuple[str, Any]]:
    """Allow ``dict(reservation)`` conversion."""
    return iter(self.to_dict().items())

from_dict(data) classmethod

Parse slot reservation JSON.

Source code in pysainsburys/models/slot/slot.py
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
@classmethod
def from_dict(cls, data: dict[str, Any]) -> SlotReservation:
    """Parse slot reservation JSON."""
    slot_data = data.get("slot")
    slot = (
        DeliverySlot.from_dict(slot_data) if isinstance(slot_data, dict) else None
    )
    flexi = data.get("flexi_stores") or []
    return cls(
        reservation_type=data.get("reservation_type"),
        postcode=data.get("postcode"),
        region=data.get("region"),
        store_identifier=data.get("store_identifier"),
        location_uid=data.get("location_uid"),
        is_expired=bool(data.get("is_expired", False)),
        reserved_until=data.get("reserved_until"),
        is_alcohol_restricted_store=bool(
            data.get("is_alcohol_restricted_store", False)
        ),
        flexi_stores=[str(value) for value in flexi],
        slot=slot,
    )

to_dict()

Serialise the reservation to a plain dictionary.

Source code in pysainsburys/models/slot/slot.py
260
261
262
263
264
265
266
267
268
269
270
271
272
273
def to_dict(self) -> dict[str, Any]:
    """Serialise the reservation to a plain dictionary."""
    return {
        "reservation_type": self.reservation_type,
        "postcode": self.postcode,
        "region": self.region,
        "store_identifier": self.store_identifier,
        "location_uid": self.location_uid,
        "is_expired": self.is_expired,
        "reserved_until": self.reserved_until,
        "is_alcohol_restricted_store": self.is_alcohol_restricted_store,
        "flexi_stores": list(self.flexi_stores),
        "slot": self.slot.to_dict() if self.slot else None,
    }

SlotWeek dataclass

Week view of delivery or collection slots.

Attributes:

Name Type Description
slot_type SlotType | None

Requested slot type (delivery or collection).

week_start_date str | None

First day of the returned week when provided.

store_identifier str | None

Fulfilment store number used for the query.

postcode str | None

Delivery postcode context when applicable.

location_uid str | None

Click-and-collect location uid when applicable.

days list[SlotDay]

Day groupings with nested slot windows.

Source code in pysainsburys/models/slot/slot.py
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
@dataclass(slots=True)
class SlotWeek:
    """
    Week view of delivery or collection slots.

    Attributes:
        slot_type: Requested slot type (``delivery`` or ``collection``).
        week_start_date: First day of the returned week when provided.
        store_identifier: Fulfilment store number used for the query.
        postcode: Delivery postcode context when applicable.
        location_uid: Click-and-collect location uid when applicable.
        days: Day groupings with nested slot windows.

    """

    slot_type: SlotType | None = None
    week_start_date: str | None = None
    store_identifier: str | None = None
    postcode: str | None = None
    location_uid: str | None = None
    days: list[SlotDay] = field(default_factory=list)

    @classmethod
    def from_dict(
        cls,
        data: dict[str, Any],
        *,
        slot_type: SlotType | None = None,
        store_identifier: str | None = None,
        postcode: str | None = None,
        location_uid: str | None = None,
    ) -> SlotWeek:
        """Parse a slot week from grocery API JSON."""
        days_data = data.get("days") or data.get("slot_days")
        if days_data is None:
            weeks = data.get("weeks") or data.get("slot_weeks")
            if isinstance(weeks, list):
                days_data = []
                for week in weeks:
                    if isinstance(week, dict):
                        days_data.extend(week.get("days", []))
        days = [
            SlotDay.from_dict(item)
            for item in (days_data or [])
            if isinstance(item, dict)
        ]
        return cls(
            slot_type=slot_type,
            week_start_date=data.get("week_start_date") or data.get("start_date"),
            store_identifier=store_identifier or data.get("store_identifier"),
            postcode=postcode or data.get("postcode"),
            location_uid=location_uid or data.get("location_uid"),
            days=days,
        )

    @property
    def slots(self) -> list[DeliverySlot]:
        """Flatten all slots across days."""
        return [slot for day in self.days for slot in day.slots]

    @property
    def available_slots(self) -> list[DeliverySlot]:
        """Flatten only available slots across days."""
        return [slot for slot in self.slots if slot.is_available]

    def to_dict(self) -> dict[str, Any]:
        """Serialise the slot week to a plain dictionary."""
        return {
            "slot_type": self.slot_type.value if self.slot_type else None,
            "week_start_date": self.week_start_date,
            "store_identifier": self.store_identifier,
            "postcode": self.postcode,
            "location_uid": self.location_uid,
            "days": [day.to_dict() for day in self.days],
        }

    def __iter__(self) -> Iterator[tuple[str, Any]]:
        """Allow ``dict(slot_week)`` conversion."""
        return iter(self.to_dict().items())

available_slots property

Flatten only available slots across days.

slots property

Flatten all slots across days.

__iter__()

Allow dict(slot_week) conversion.

Source code in pysainsburys/models/slot/slot.py
217
218
219
def __iter__(self) -> Iterator[tuple[str, Any]]:
    """Allow ``dict(slot_week)`` conversion."""
    return iter(self.to_dict().items())

from_dict(data, *, slot_type=None, store_identifier=None, postcode=None, location_uid=None) classmethod

Parse a slot week from grocery API JSON.

Source code in pysainsburys/models/slot/slot.py
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
@classmethod
def from_dict(
    cls,
    data: dict[str, Any],
    *,
    slot_type: SlotType | None = None,
    store_identifier: str | None = None,
    postcode: str | None = None,
    location_uid: str | None = None,
) -> SlotWeek:
    """Parse a slot week from grocery API JSON."""
    days_data = data.get("days") or data.get("slot_days")
    if days_data is None:
        weeks = data.get("weeks") or data.get("slot_weeks")
        if isinstance(weeks, list):
            days_data = []
            for week in weeks:
                if isinstance(week, dict):
                    days_data.extend(week.get("days", []))
    days = [
        SlotDay.from_dict(item)
        for item in (days_data or [])
        if isinstance(item, dict)
    ]
    return cls(
        slot_type=slot_type,
        week_start_date=data.get("week_start_date") or data.get("start_date"),
        store_identifier=store_identifier or data.get("store_identifier"),
        postcode=postcode or data.get("postcode"),
        location_uid=location_uid or data.get("location_uid"),
        days=days,
    )

to_dict()

Serialise the slot week to a plain dictionary.

Source code in pysainsburys/models/slot/slot.py
206
207
208
209
210
211
212
213
214
215
def to_dict(self) -> dict[str, Any]:
    """Serialise the slot week to a plain dictionary."""
    return {
        "slot_type": self.slot_type.value if self.slot_type else None,
        "week_start_date": self.week_start_date,
        "store_identifier": self.store_identifier,
        "postcode": self.postcode,
        "location_uid": self.location_uid,
        "days": [day.to_dict() for day in self.days],
    }

Store dataclass

A Sainsbury's store from Product Finder or click-and-collect.

When bound to a :class:~pysainsburys.Sainsburys client, a store can search in-store stock via :meth:search_products.

Attributes:

Name Type Description
name str

Store display name.

address1 str

Primary address line.

city str

Town or city.

post_code str

UK postcode.

is_available bool

Whether the store accepts online orders or collection.

store_id str

Product Finder store identifier.

store_number str | None

Internal store number for click-and-collect locations.

location_uid str | None

Click-and-collect location uid when applicable.

address2 str | None

Secondary address line.

county str | None

County or region.

opening_hours str | None

Opening hours text when provided.

distance float | None

Distance from the search origin in miles or kilometres.

telephone str | None

Store telephone number.

latitude float | None

WGS-84 latitude when available.

longitude float | None

WGS-84 longitude when available.

is_open bool | None

Whether the store is currently open when known.

click_and_collect_available bool

Whether click-and-collect is offered.

Source code in pysainsburys/models/store/store.py
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
@dataclass(slots=True)
class Store:
    """
    A Sainsbury's store from Product Finder or click-and-collect.

    When bound to a :class:`~pysainsburys.Sainsburys` client, a store can
    search in-store stock via :meth:`search_products`.

    Attributes:
        name: Store display name.
        address1: Primary address line.
        city: Town or city.
        post_code: UK postcode.
        is_available: Whether the store accepts online orders or collection.
        store_id: Product Finder store identifier.
        store_number: Internal store number for click-and-collect locations.
        location_uid: Click-and-collect location uid when applicable.
        address2: Secondary address line.
        county: County or region.
        opening_hours: Opening hours text when provided.
        distance: Distance from the search origin in miles or kilometres.
        telephone: Store telephone number.
        latitude: WGS-84 latitude when available.
        longitude: WGS-84 longitude when available.
        is_open: Whether the store is currently open when known.
        click_and_collect_available: Whether click-and-collect is offered.

    """

    name: str
    address1: str
    city: str
    post_code: str
    is_available: bool
    store_id: str = ""
    store_number: str | None = None
    location_uid: str | None = None
    address2: str | None = None
    county: str | None = None
    opening_hours: str | None = None
    distance: float | None = None
    telephone: str | None = None
    latitude: float | None = None
    longitude: float | None = None
    is_open: bool | None = None
    click_and_collect_available: bool = False
    _api: API | None = field(default=None, repr=False, compare=False, hash=False)

    @classmethod
    def from_dict(cls, data: dict[str, Any], *, api: API | None = None) -> Store:
        """Parse a store from Product Finder or click-and-collect JSON."""
        if "location_uid" in data or ("store_number" in data and "id" not in data):
            return cls.from_collect_dict(data, api=api)
        distance_raw = data.get("distance")
        distance = float(distance_raw) if distance_raw not in (None, "") else None
        lat_raw = data.get("latitude")
        lon_raw = data.get("longitude")
        return cls(
            store_id=str(data.get("id") or ""),
            name=str(data.get("name") or ""),
            address1=str(data.get("address1") or ""),
            address2=data.get("address2") or None,
            city=str(data.get("city") or ""),
            post_code=str(data.get("postCode") or data.get("postcode") or ""),
            opening_hours=data.get("openingHours"),
            distance=distance,
            telephone=data.get("telephone"),
            latitude=float(lat_raw) if lat_raw is not None else None,
            longitude=float(lon_raw) if lon_raw is not None else None,
            is_available=bool(data.get("isAvailable", True)),
            is_open=data.get("isOpen"),
            click_and_collect_available=bool(data.get("isAvailable", False)),
            _api=api,
        )

    @classmethod
    def from_collect_dict(
        cls,
        data: dict[str, Any],
        *,
        api: API | None = None,
    ) -> Store:
        """Parse a click-and-collect store location from grocery API JSON."""
        distance_raw = data.get("distance")
        return cls(
            name=str(data.get("name") or ""),
            address1=str(data.get("address1") or ""),
            city=str(data.get("city") or ""),
            post_code=str(data.get("postcode") or ""),
            is_available=bool(data.get("is_available", True)),
            location_uid=str(data.get("location_uid") or "") or None,
            store_number=str(data.get("store_number") or "") or None,
            address2=data.get("address2") or None,
            county=data.get("county") or None,
            distance=float(distance_raw) if distance_raw is not None else None,
            click_and_collect_available=bool(data.get("is_available", True)),
            _api=api,
        )

    def _require_api(self) -> API:
        if self._api is None:
            msg = (
                "Store is not bound to a Sainsburys client; "
                "fetch it via Sainsburys.find_stores(), find_stores_by_postcode(), "
                "or get_store()."
            )
            raise NotBoundError(msg)
        return self._api

    def bind_api(self, api: API) -> Store:
        """Attach a client for in-store product lookups."""
        self._api = api
        return self

    @property
    def product_finder_id(self) -> str:
        """Return the Product Finder store id used for in-store product search."""
        return self.store_id

    async def search_products(
        self,
        keyword: str,
        *,
        page: int = 1,
        page_size: int = 20,
    ) -> StoreProductList:
        """Search in-store products with aisle and stock for this store."""
        response = await self._require_api().send_product_finder_request(
            "/v2/products",
            params={
                "storeId": self.store_id,
                "keyword": keyword,
                "page": page,
                "size": page_size,
            },
        )
        if not isinstance(response, dict):
            msg = "Store product search response was not a JSON object."
            raise TypeError(msg)
        return StoreProductList.from_dict(response)

    def to_dict(self) -> dict[str, Any]:
        """Serialise the store to a plain dictionary."""
        return {
            "store_id": self.store_id,
            "store_number": self.store_number,
            "location_uid": self.location_uid,
            "name": self.name,
            "address1": self.address1,
            "address2": self.address2,
            "city": self.city,
            "county": self.county,
            "post_code": self.post_code,
            "opening_hours": self.opening_hours,
            "distance": self.distance,
            "telephone": self.telephone,
            "latitude": self.latitude,
            "longitude": self.longitude,
            "is_available": self.is_available,
            "is_open": self.is_open,
            "click_and_collect_available": self.click_and_collect_available,
        }

product_finder_id property

Return the Product Finder store id used for in-store product search.

bind_api(api)

Attach a client for in-store product lookups.

Source code in pysainsburys/models/store/store.py
164
165
166
167
def bind_api(self, api: API) -> Store:
    """Attach a client for in-store product lookups."""
    self._api = api
    return self

from_collect_dict(data, *, api=None) classmethod

Parse a click-and-collect store location from grocery API JSON.

Source code in pysainsburys/models/store/store.py
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
@classmethod
def from_collect_dict(
    cls,
    data: dict[str, Any],
    *,
    api: API | None = None,
) -> Store:
    """Parse a click-and-collect store location from grocery API JSON."""
    distance_raw = data.get("distance")
    return cls(
        name=str(data.get("name") or ""),
        address1=str(data.get("address1") or ""),
        city=str(data.get("city") or ""),
        post_code=str(data.get("postcode") or ""),
        is_available=bool(data.get("is_available", True)),
        location_uid=str(data.get("location_uid") or "") or None,
        store_number=str(data.get("store_number") or "") or None,
        address2=data.get("address2") or None,
        county=data.get("county") or None,
        distance=float(distance_raw) if distance_raw is not None else None,
        click_and_collect_available=bool(data.get("is_available", True)),
        _api=api,
    )

from_dict(data, *, api=None) classmethod

Parse a store from Product Finder or click-and-collect JSON.

Source code in pysainsburys/models/store/store.py
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
@classmethod
def from_dict(cls, data: dict[str, Any], *, api: API | None = None) -> Store:
    """Parse a store from Product Finder or click-and-collect JSON."""
    if "location_uid" in data or ("store_number" in data and "id" not in data):
        return cls.from_collect_dict(data, api=api)
    distance_raw = data.get("distance")
    distance = float(distance_raw) if distance_raw not in (None, "") else None
    lat_raw = data.get("latitude")
    lon_raw = data.get("longitude")
    return cls(
        store_id=str(data.get("id") or ""),
        name=str(data.get("name") or ""),
        address1=str(data.get("address1") or ""),
        address2=data.get("address2") or None,
        city=str(data.get("city") or ""),
        post_code=str(data.get("postCode") or data.get("postcode") or ""),
        opening_hours=data.get("openingHours"),
        distance=distance,
        telephone=data.get("telephone"),
        latitude=float(lat_raw) if lat_raw is not None else None,
        longitude=float(lon_raw) if lon_raw is not None else None,
        is_available=bool(data.get("isAvailable", True)),
        is_open=data.get("isOpen"),
        click_and_collect_available=bool(data.get("isAvailable", False)),
        _api=api,
    )

search_products(keyword, *, page=1, page_size=20) async

Search in-store products with aisle and stock for this store.

Source code in pysainsburys/models/store/store.py
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
async def search_products(
    self,
    keyword: str,
    *,
    page: int = 1,
    page_size: int = 20,
) -> StoreProductList:
    """Search in-store products with aisle and stock for this store."""
    response = await self._require_api().send_product_finder_request(
        "/v2/products",
        params={
            "storeId": self.store_id,
            "keyword": keyword,
            "page": page,
            "size": page_size,
        },
    )
    if not isinstance(response, dict):
        msg = "Store product search response was not a JSON object."
        raise TypeError(msg)
    return StoreProductList.from_dict(response)

to_dict()

Serialise the store to a plain dictionary.

Source code in pysainsburys/models/store/store.py
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
def to_dict(self) -> dict[str, Any]:
    """Serialise the store to a plain dictionary."""
    return {
        "store_id": self.store_id,
        "store_number": self.store_number,
        "location_uid": self.location_uid,
        "name": self.name,
        "address1": self.address1,
        "address2": self.address2,
        "city": self.city,
        "county": self.county,
        "post_code": self.post_code,
        "opening_hours": self.opening_hours,
        "distance": self.distance,
        "telephone": self.telephone,
        "latitude": self.latitude,
        "longitude": self.longitude,
        "is_available": self.is_available,
        "is_open": self.is_open,
        "click_and_collect_available": self.click_and_collect_available,
    }

StoreList dataclass

A paginated list of stores.

Source code in pysainsburys/models/store/store.py
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
@dataclass(slots=True)
class StoreList:
    """A paginated list of stores."""

    stores: list[Store]
    page: FinderPage | None = None
    controls: PageControls | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any], *, api: API | None = None) -> StoreList:
        """Parse stores from Product Finder or click-and-collect JSON."""
        if "locations" in data:
            stores = [
                Store.from_dict(item, api=api) for item in data.get("locations", [])
            ]
            return cls(
                stores=stores,
                controls=PageControls.from_dict(data.get("controls")),
            )
        stores = [Store.from_dict(item, api=api) for item in data.get("content", [])]
        return cls(stores=stores, page=FinderPage.from_dict(data.get("page")))

    def to_dict(self) -> dict[str, Any]:
        """Serialise the store list to a plain dictionary."""
        payload: dict[str, Any] = {
            "stores": [store.to_dict() for store in self.stores],
        }
        if self.page is not None:
            payload["page"] = self.page.to_dict()
        if self.controls is not None:
            payload["controls"] = self.controls.to_dict()
        return payload

from_dict(data, *, api=None) classmethod

Parse stores from Product Finder or click-and-collect JSON.

Source code in pysainsburys/models/store/store.py
227
228
229
230
231
232
233
234
235
236
237
238
239
@classmethod
def from_dict(cls, data: dict[str, Any], *, api: API | None = None) -> StoreList:
    """Parse stores from Product Finder or click-and-collect JSON."""
    if "locations" in data:
        stores = [
            Store.from_dict(item, api=api) for item in data.get("locations", [])
        ]
        return cls(
            stores=stores,
            controls=PageControls.from_dict(data.get("controls")),
        )
    stores = [Store.from_dict(item, api=api) for item in data.get("content", [])]
    return cls(stores=stores, page=FinderPage.from_dict(data.get("page")))

to_dict()

Serialise the store list to a plain dictionary.

Source code in pysainsburys/models/store/store.py
241
242
243
244
245
246
247
248
249
250
def to_dict(self) -> dict[str, Any]:
    """Serialise the store list to a plain dictionary."""
    payload: dict[str, Any] = {
        "stores": [store.to_dict() for store in self.stores],
    }
    if self.page is not None:
        payload["page"] = self.page.to_dict()
    if self.controls is not None:
        payload["controls"] = self.controls.to_dict()
    return payload

StoreProduct dataclass

A product with in-store aisle and stock information.

Attributes:

Name Type Description
product_code str

In-store product code used by Product Finder.

name str

Shelf label product name.

stock str

Stock status string (for example In Stock).

price float | None

Shelf price in pounds sterling.

price_per_unit float | None

Normalised unit price when provided.

unit_of_measure str | None

Unit label for price_per_unit.

aisle str | None

Aisle number or location hint in the store.

image_url str | None

Product image URL when available.

is_nectar_price bool

Whether the price is a Nectar offer.

promotions list[dict[str, Any]]

Raw promotion payloads from Product Finder.

Source code in pysainsburys/models/store/store.py
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
@dataclass(slots=True)
class StoreProduct:
    """
    A product with in-store aisle and stock information.

    Attributes:
        product_code: In-store product code used by Product Finder.
        name: Shelf label product name.
        stock: Stock status string (for example ``In Stock``).
        price: Shelf price in pounds sterling.
        price_per_unit: Normalised unit price when provided.
        unit_of_measure: Unit label for ``price_per_unit``.
        aisle: Aisle number or location hint in the store.
        image_url: Product image URL when available.
        is_nectar_price: Whether the price is a Nectar offer.
        promotions: Raw promotion payloads from Product Finder.

    """

    product_code: str
    name: str
    stock: str
    price: float | None = None
    price_per_unit: float | None = None
    unit_of_measure: str | None = None
    aisle: str | None = None
    image_url: str | None = None
    is_nectar_price: bool = False
    promotions: list[dict[str, Any]] = field(default_factory=list)

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> StoreProduct:
        """Parse an in-store product from Product Finder JSON."""
        retail = data.get("retail") or {}
        price_raw = retail.get("price")
        ppu_raw = retail.get("pricePerUnit")
        return cls(
            product_code=str(data.get("productCode") or ""),
            name=str(data.get("productName") or ""),
            stock=str(data.get("stock") or ""),
            price=float(price_raw) if price_raw not in (None, "") else None,
            price_per_unit=float(ppu_raw) if ppu_raw not in (None, "") else None,
            unit_of_measure=data.get("unitOfMeasure"),
            aisle=data.get("aisle"),
            image_url=data.get("image"),
            is_nectar_price=bool(data.get("isNectarPrice", False)),
            promotions=list(data.get("promotions") or []),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the in-store product to a plain dictionary."""
        return {
            "product_code": self.product_code,
            "name": self.name,
            "stock": self.stock,
            "price": self.price,
            "price_per_unit": self.price_per_unit,
            "unit_of_measure": self.unit_of_measure,
            "aisle": self.aisle,
            "image_url": self.image_url,
            "is_nectar_price": self.is_nectar_price,
            "promotions": self.promotions,
        }

from_dict(data) classmethod

Parse an in-store product from Product Finder JSON.

Source code in pysainsburys/models/store/store.py
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
@classmethod
def from_dict(cls, data: dict[str, Any]) -> StoreProduct:
    """Parse an in-store product from Product Finder JSON."""
    retail = data.get("retail") or {}
    price_raw = retail.get("price")
    ppu_raw = retail.get("pricePerUnit")
    return cls(
        product_code=str(data.get("productCode") or ""),
        name=str(data.get("productName") or ""),
        stock=str(data.get("stock") or ""),
        price=float(price_raw) if price_raw not in (None, "") else None,
        price_per_unit=float(ppu_raw) if ppu_raw not in (None, "") else None,
        unit_of_measure=data.get("unitOfMeasure"),
        aisle=data.get("aisle"),
        image_url=data.get("image"),
        is_nectar_price=bool(data.get("isNectarPrice", False)),
        promotions=list(data.get("promotions") or []),
    )

to_dict()

Serialise the in-store product to a plain dictionary.

Source code in pysainsburys/models/store/store.py
302
303
304
305
306
307
308
309
310
311
312
313
314
315
def to_dict(self) -> dict[str, Any]:
    """Serialise the in-store product to a plain dictionary."""
    return {
        "product_code": self.product_code,
        "name": self.name,
        "stock": self.stock,
        "price": self.price,
        "price_per_unit": self.price_per_unit,
        "unit_of_measure": self.unit_of_measure,
        "aisle": self.aisle,
        "image_url": self.image_url,
        "is_nectar_price": self.is_nectar_price,
        "promotions": self.promotions,
    }

StoreProductList dataclass

In-store product search results for a chosen store.

Source code in pysainsburys/models/store/store.py
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
@dataclass(slots=True)
class StoreProductList:
    """In-store product search results for a chosen store."""

    products: list[StoreProduct]
    page: FinderPage
    suggested_search_terms: list[str] = field(default_factory=list)

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> StoreProductList:
        """Parse in-store product results from Product Finder JSON."""
        products = [StoreProduct.from_dict(item) for item in data.get("content", [])]
        return cls(
            products=products,
            page=FinderPage.from_dict(data.get("page")),
            suggested_search_terms=[
                str(term) for term in data.get("suggestedSearchTerms", [])
            ],
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise in-store product results to a plain dictionary."""
        return {
            "products": [product.to_dict() for product in self.products],
            "page": self.page.to_dict(),
            "suggested_search_terms": self.suggested_search_terms,
        }

    def __iter__(self) -> Iterator[tuple[str, Any]]:
        """Allow ``dict(store_product_list)`` conversion."""
        return iter(self.to_dict().items())

__iter__()

Allow dict(store_product_list) conversion.

Source code in pysainsburys/models/store/store.py
346
347
348
def __iter__(self) -> Iterator[tuple[str, Any]]:
    """Allow ``dict(store_product_list)`` conversion."""
    return iter(self.to_dict().items())

from_dict(data) classmethod

Parse in-store product results from Product Finder JSON.

Source code in pysainsburys/models/store/store.py
326
327
328
329
330
331
332
333
334
335
336
@classmethod
def from_dict(cls, data: dict[str, Any]) -> StoreProductList:
    """Parse in-store product results from Product Finder JSON."""
    products = [StoreProduct.from_dict(item) for item in data.get("content", [])]
    return cls(
        products=products,
        page=FinderPage.from_dict(data.get("page")),
        suggested_search_terms=[
            str(term) for term in data.get("suggestedSearchTerms", [])
        ],
    )

to_dict()

Serialise in-store product results to a plain dictionary.

Source code in pysainsburys/models/store/store.py
338
339
340
341
342
343
344
def to_dict(self) -> dict[str, Any]:
    """Serialise in-store product results to a plain dictionary."""
    return {
        "products": [product.to_dict() for product in self.products],
        "page": self.page.to_dict(),
        "suggested_search_terms": self.suggested_search_terms,
    }

UnlockYourNectarPriceResult dataclass

Result of unlocking Your Nectar Price offers.

Source code in pysainsburys/models/nectar/nectar.py
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
@dataclass(slots=True)
class UnlockYourNectarPriceResult:
    """Result of unlocking Your Nectar Price offers."""

    updated_offer_ids: list[str] = field(default_factory=list)
    offer_response_failures: list[dict[str, Any]] = field(default_factory=list)

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> UnlockYourNectarPriceResult:
        """Parse an unlock response from grocery API JSON."""
        failures = data.get("offer_response_failures")
        if not isinstance(failures, list):
            failures = []
        updated = data.get("updated_offer_ids")
        if not isinstance(updated, list):
            updated = []
        return cls(
            updated_offer_ids=[str(offer_id) for offer_id in updated],
            offer_response_failures=[
                failure for failure in failures if isinstance(failure, dict)
            ],
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the unlock response to a plain dictionary."""
        return {
            "updated_offer_ids": self.updated_offer_ids,
            "offer_response_failures": self.offer_response_failures,
        }

from_dict(data) classmethod

Parse an unlock response from grocery API JSON.

Source code in pysainsburys/models/nectar/nectar.py
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
@classmethod
def from_dict(cls, data: dict[str, Any]) -> UnlockYourNectarPriceResult:
    """Parse an unlock response from grocery API JSON."""
    failures = data.get("offer_response_failures")
    if not isinstance(failures, list):
        failures = []
    updated = data.get("updated_offer_ids")
    if not isinstance(updated, list):
        updated = []
    return cls(
        updated_offer_ids=[str(offer_id) for offer_id in updated],
        offer_response_failures=[
            failure for failure in failures if isinstance(failure, dict)
        ],
    )

to_dict()

Serialise the unlock response to a plain dictionary.

Source code in pysainsburys/models/nectar/nectar.py
223
224
225
226
227
228
def to_dict(self) -> dict[str, Any]:
    """Serialise the unlock response to a plain dictionary."""
    return {
        "updated_offer_ids": self.updated_offer_ids,
        "offer_response_failures": self.offer_response_failures,
    }

YourNectarPriceOffer dataclass

A Your Nectar Price weekly offer.

Attributes:

Name Type Description
offer_id str

Offer identifier used for opt-in requests.

sku str

Product SKU the offer applies to.

start_date str | None

Offer start timestamp.

expiry_date str | None

Offer expiry timestamp.

image str | None

Product image URL when provided.

product Product | None

Enriched catalogue product when fetched separately.

Source code in pysainsburys/models/nectar/nectar.py
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
@dataclass(slots=True)
class YourNectarPriceOffer:
    """
    A Your Nectar Price weekly offer.

    Attributes:
        offer_id: Offer identifier used for opt-in requests.
        sku: Product SKU the offer applies to.
        start_date: Offer start timestamp.
        expiry_date: Offer expiry timestamp.
        image: Product image URL when provided.
        product: Enriched catalogue product when fetched separately.

    """

    offer_id: str
    sku: str
    start_date: str | None = None
    expiry_date: str | None = None
    image: str | None = None
    product: Product | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> YourNectarPriceOffer:
        """Parse a Your Nectar Price offer from grocery API JSON."""
        return cls(
            offer_id=str(data.get("offer_id") or ""),
            sku=str(data.get("sku") or ""),
            start_date=data.get("start_date"),
            expiry_date=data.get("expiry_date"),
            image=data.get("image"),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the offer to a plain dictionary."""
        return {
            "offer_id": self.offer_id,
            "sku": self.sku,
            "start_date": self.start_date,
            "expiry_date": self.expiry_date,
            "image": self.image,
            "product": self.product.to_dict() if self.product else None,
        }

from_dict(data) classmethod

Parse a Your Nectar Price offer from grocery API JSON.

Source code in pysainsburys/models/nectar/nectar.py
122
123
124
125
126
127
128
129
130
131
@classmethod
def from_dict(cls, data: dict[str, Any]) -> YourNectarPriceOffer:
    """Parse a Your Nectar Price offer from grocery API JSON."""
    return cls(
        offer_id=str(data.get("offer_id") or ""),
        sku=str(data.get("sku") or ""),
        start_date=data.get("start_date"),
        expiry_date=data.get("expiry_date"),
        image=data.get("image"),
    )

to_dict()

Serialise the offer to a plain dictionary.

Source code in pysainsburys/models/nectar/nectar.py
133
134
135
136
137
138
139
140
141
142
def to_dict(self) -> dict[str, Any]:
    """Serialise the offer to a plain dictionary."""
    return {
        "offer_id": self.offer_id,
        "sku": self.sku,
        "start_date": self.start_date,
        "expiry_date": self.expiry_date,
        "image": self.image,
        "product": self.product.to_dict() if self.product else None,
    }

YourNectarPrices dataclass

Your Nectar Price opt-in state for the signed-in customer.

Attributes:

Name Type Description
opted_in list[YourNectarPriceOffer]

Offers the customer has unlocked.

not_opted_in list[YourNectarPriceOffer]

Offers still waiting to be unlocked.

available_until str | None

When the current YNP selection window closes.

released_on str | None

When the current YNP offers were released.

Source code in pysainsburys/models/nectar/nectar.py
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
@dataclass(slots=True)
class YourNectarPrices:
    """
    Your Nectar Price opt-in state for the signed-in customer.

    Attributes:
        opted_in: Offers the customer has unlocked.
        not_opted_in: Offers still waiting to be unlocked.
        available_until: When the current YNP selection window closes.
        released_on: When the current YNP offers were released.

    """

    opted_in: list[YourNectarPriceOffer] = field(default_factory=list)
    not_opted_in: list[YourNectarPriceOffer] = field(default_factory=list)
    available_until: str | None = None
    released_on: str | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> YourNectarPrices:
        """Parse Your Nectar Price opt-ins from grocery API JSON."""
        return cls(
            opted_in=[
                YourNectarPriceOffer.from_dict(item)
                for item in data.get("opted_in", [])
                if isinstance(item, dict)
            ],
            not_opted_in=[
                YourNectarPriceOffer.from_dict(item)
                for item in data.get("not_opted_in", [])
                if isinstance(item, dict)
            ],
            available_until=data.get("ynps_available_until"),
            released_on=data.get("ynps_released_on"),
        )

    @property
    def all_offers(self) -> list[YourNectarPriceOffer]:
        """Return opted-in and locked offers together."""
        return [*self.opted_in, *self.not_opted_in]

    def to_dict(self) -> dict[str, Any]:
        """Serialise the YNP response to a plain dictionary."""
        return {
            "opted_in": [offer.to_dict() for offer in self.opted_in],
            "not_opted_in": [offer.to_dict() for offer in self.not_opted_in],
            "available_until": self.available_until,
            "released_on": self.released_on,
        }

    def __iter__(self) -> Iterator[tuple[str, Any]]:
        """Allow ``dict(prices)`` conversion."""
        return iter(self.to_dict().items())

all_offers property

Return opted-in and locked offers together.

__iter__()

Allow dict(prices) conversion.

Source code in pysainsburys/models/nectar/nectar.py
195
196
197
def __iter__(self) -> Iterator[tuple[str, Any]]:
    """Allow ``dict(prices)`` conversion."""
    return iter(self.to_dict().items())

from_dict(data) classmethod

Parse Your Nectar Price opt-ins from grocery API JSON.

Source code in pysainsburys/models/nectar/nectar.py
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
@classmethod
def from_dict(cls, data: dict[str, Any]) -> YourNectarPrices:
    """Parse Your Nectar Price opt-ins from grocery API JSON."""
    return cls(
        opted_in=[
            YourNectarPriceOffer.from_dict(item)
            for item in data.get("opted_in", [])
            if isinstance(item, dict)
        ],
        not_opted_in=[
            YourNectarPriceOffer.from_dict(item)
            for item in data.get("not_opted_in", [])
            if isinstance(item, dict)
        ],
        available_until=data.get("ynps_available_until"),
        released_on=data.get("ynps_released_on"),
    )

to_dict()

Serialise the YNP response to a plain dictionary.

Source code in pysainsburys/models/nectar/nectar.py
186
187
188
189
190
191
192
193
def to_dict(self) -> dict[str, Any]:
    """Serialise the YNP response to a plain dictionary."""
    return {
        "opted_in": [offer.to_dict() for offer in self.opted_in],
        "not_opted_in": [offer.to_dict() for offer in self.not_opted_in],
        "available_until": self.available_until,
        "released_on": self.released_on,
    }

basket_from_response(response)

Parse a basket API response into a :class:Basket.

Source code in pysainsburys/models/basket/basket.py
12
13
14
15
16
17
def basket_from_response(response: dict[str, Any] | list[Any] | None) -> Basket:
    """Parse a basket API response into a :class:`Basket`."""
    if not isinstance(response, dict):
        msg = "Basket response was not a JSON object."
        raise TypeError(msg)
    return Basket.from_dict(response)

bind_product(api, product)

Attach an API client to a product for basket and favourites operations.

Source code in pysainsburys/models/product/product.py
614
615
616
def bind_product(api: API, product: Product) -> Product:
    """Attach an API client to a product for basket and favourites operations."""
    return product.bind_api(api)

bind_products(api, products)

Attach an API client to each product in a list.

Source code in pysainsburys/models/product/product.py
619
620
621
622
623
def bind_products(api: API, products: list[Product]) -> list[Product]:
    """Attach an API client to each product in a list."""
    for product in products:
        bind_product(api, product)
    return products

bind_store(api, store)

Attach an API client to a store for in-store product lookups.

Source code in pysainsburys/models/store/store.py
351
352
353
def bind_store(api: API, store: Store) -> Store:
    """Attach an API client to a store for in-store product lookups."""
    return store.bind_api(api)

bind_stores(api, stores)

Attach an API client to each store in a list.

Source code in pysainsburys/models/store/store.py
356
357
358
359
360
def bind_stores(api: API, stores: list[Store]) -> list[Store]:
    """Attach an API client to each store in a list."""
    for store in stores:
        bind_store(api, store)
    return stores

decode_details_html(details_html)

Decode the base64 product details_html field.

Source code in pysainsburys/models/product/nutrition.py
187
188
189
190
191
192
193
194
def decode_details_html(details_html: str | None) -> str | None:
    """Decode the base64 product ``details_html`` field."""
    if not details_html:
        return None
    try:
        return base64.b64decode(details_html).decode("utf-8")
    except (ValueError, UnicodeDecodeError):
        return None

parse_nutrition(html)

Parse nutrition information from decoded product detail HTML.

Source code in pysainsburys/models/product/nutrition.py
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
def parse_nutrition(html: str | None) -> NutritionInfo | None:
    """Parse nutrition information from decoded product detail HTML."""
    if not html or (
        "nutritionTable" not in html and "nutritionalContentSummary" not in html
    ):
        return None

    summary, notes = _parse_summary(html)
    tables: list[NutritionTable] = []
    for table_html in re.findall(
        r'<table class="nutritionTable">.*?</table>',
        html,
        re.DOTALL | re.IGNORECASE,
    ):
        tables.append(_parse_table(html, table_html))

    if not summary and not tables and not notes:
        return None
    return NutritionInfo(summary=summary, tables=tables, notes=notes)

parse_nutrition_from_details_html(details_html)

Parse nutrition information from a product details_html field.

Source code in pysainsburys/models/product/nutrition.py
288
289
290
def parse_nutrition_from_details_html(details_html: str | None) -> NutritionInfo | None:
    """Parse nutrition information from a product ``details_html`` field."""
    return parse_nutrition(decode_details_html(details_html))

parse_product_details(html)

Parse product-text sections from decoded product detail HTML.

Source code in pysainsburys/models/product/details.py
205
206
207
208
209
210
211
212
213
214
def parse_product_details(html: str | None) -> ProductDetails | None:
    """Parse product-text sections from decoded product detail HTML."""
    if not html or "partHead" not in html:
        return None
    parser = _ProductTextParser()
    parser.feed(html)
    parser.close()
    if not parser.sections:
        return None
    return ProductDetails(**parser.sections)

parse_product_details_from_details_html(details_html)

Parse product-text sections from a base64 details_html field.

Source code in pysainsburys/models/product/details.py
217
218
219
220
221
def parse_product_details_from_details_html(
    details_html: str | None,
) -> ProductDetails | None:
    """Parse product-text sections from a base64 ``details_html`` field."""
    return parse_product_details(decode_details_html(details_html))

Common

pysainsburys.models.common

Shared model primitives used across multiple API domains.

PageControls dataclass

Pagination metadata returned by grocery list endpoints.

Attributes:

Name Type Description
total_record_count int

Total items available across all pages.

returned_record_count int

Items included in the current response.

active_page int

One-based index of the current page.

first_page int

One-based index of the first page.

last_page int

One-based index of the last page.

page_size int

Requested page size.

Source code in pysainsburys/models/common/pagination.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
@dataclass(slots=True)
class PageControls:
    """
    Pagination metadata returned by grocery list endpoints.

    Attributes:
        total_record_count: Total items available across all pages.
        returned_record_count: Items included in the current response.
        active_page: One-based index of the current page.
        first_page: One-based index of the first page.
        last_page: One-based index of the last page.
        page_size: Requested page size.

    """

    total_record_count: int
    returned_record_count: int
    active_page: int
    first_page: int
    last_page: int
    page_size: int

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> PageControls:
        """Parse pagination controls from grocery API JSON."""
        data = data or {}
        page = data.get("page") or {}
        return cls(
            total_record_count=int(data.get("total_record_count", 0)),
            returned_record_count=int(data.get("returned_record_count", 0)),
            active_page=int(page.get("active", 1)),
            first_page=int(page.get("first", 1)),
            last_page=int(page.get("last", 1)),
            page_size=int(page.get("size", 0)),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise pagination controls to a plain dictionary."""
        return {
            "total_record_count": self.total_record_count,
            "returned_record_count": self.returned_record_count,
            "active_page": self.active_page,
            "first_page": self.first_page,
            "last_page": self.last_page,
            "page_size": self.page_size,
        }

from_dict(data) classmethod

Parse pagination controls from grocery API JSON.

Source code in pysainsburys/models/common/pagination.py
31
32
33
34
35
36
37
38
39
40
41
42
43
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> PageControls:
    """Parse pagination controls from grocery API JSON."""
    data = data or {}
    page = data.get("page") or {}
    return cls(
        total_record_count=int(data.get("total_record_count", 0)),
        returned_record_count=int(data.get("returned_record_count", 0)),
        active_page=int(page.get("active", 1)),
        first_page=int(page.get("first", 1)),
        last_page=int(page.get("last", 1)),
        page_size=int(page.get("size", 0)),
    )

to_dict()

Serialise pagination controls to a plain dictionary.

Source code in pysainsburys/models/common/pagination.py
45
46
47
48
49
50
51
52
53
54
def to_dict(self) -> dict[str, Any]:
    """Serialise pagination controls to a plain dictionary."""
    return {
        "total_record_count": self.total_record_count,
        "returned_record_count": self.returned_record_count,
        "active_page": self.active_page,
        "first_page": self.first_page,
        "last_page": self.last_page,
        "page_size": self.page_size,
    }

Price dataclass

A monetary amount with an optional unit of measure.

Attributes:

Name Type Description
price float

Amount in pounds sterling.

measure str | None

Unit label returned by the API (for example ea or kg).

measure_amount float | None

Quantity associated with measure when provided.

Source code in pysainsburys/models/common/price.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@dataclass(slots=True)
class Price:
    """
    A monetary amount with an optional unit of measure.

    Attributes:
        price: Amount in pounds sterling.
        measure: Unit label returned by the API (for example ``ea`` or ``kg``).
        measure_amount: Quantity associated with ``measure`` when provided.

    """

    price: float
    measure: str | None = None
    measure_amount: float | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> Price | None:
        """Parse a price object from grocery API JSON."""
        if not data:
            return None
        return cls(
            price=float(data.get("price", 0)),
            measure=data.get("measure"),
            measure_amount=(
                float(data["measure_amount"])
                if data.get("measure_amount") is not None
                else None
            ),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the price to a plain dictionary."""
        return {
            "price": self.price,
            "measure": self.measure,
            "measure_amount": self.measure_amount,
        }

from_dict(data) classmethod

Parse a price object from grocery API JSON.

Source code in pysainsburys/models/common/price.py
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> Price | None:
    """Parse a price object from grocery API JSON."""
    if not data:
        return None
    return cls(
        price=float(data.get("price", 0)),
        measure=data.get("measure"),
        measure_amount=(
            float(data["measure_amount"])
            if data.get("measure_amount") is not None
            else None
        ),
    )

to_dict()

Serialise the price to a plain dictionary.

Source code in pysainsburys/models/common/price.py
40
41
42
43
44
45
46
def to_dict(self) -> dict[str, Any]:
    """Serialise the price to a plain dictionary."""
    return {
        "price": self.price,
        "measure": self.measure,
        "measure_amount": self.measure_amount,
    }

pysainsburys.models.common.price.Price dataclass

A monetary amount with an optional unit of measure.

Attributes:

Name Type Description
price float

Amount in pounds sterling.

measure str | None

Unit label returned by the API (for example ea or kg).

measure_amount float | None

Quantity associated with measure when provided.

Source code in pysainsburys/models/common/price.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@dataclass(slots=True)
class Price:
    """
    A monetary amount with an optional unit of measure.

    Attributes:
        price: Amount in pounds sterling.
        measure: Unit label returned by the API (for example ``ea`` or ``kg``).
        measure_amount: Quantity associated with ``measure`` when provided.

    """

    price: float
    measure: str | None = None
    measure_amount: float | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> Price | None:
        """Parse a price object from grocery API JSON."""
        if not data:
            return None
        return cls(
            price=float(data.get("price", 0)),
            measure=data.get("measure"),
            measure_amount=(
                float(data["measure_amount"])
                if data.get("measure_amount") is not None
                else None
            ),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the price to a plain dictionary."""
        return {
            "price": self.price,
            "measure": self.measure,
            "measure_amount": self.measure_amount,
        }

from_dict(data) classmethod

Parse a price object from grocery API JSON.

Source code in pysainsburys/models/common/price.py
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> Price | None:
    """Parse a price object from grocery API JSON."""
    if not data:
        return None
    return cls(
        price=float(data.get("price", 0)),
        measure=data.get("measure"),
        measure_amount=(
            float(data["measure_amount"])
            if data.get("measure_amount") is not None
            else None
        ),
    )

to_dict()

Serialise the price to a plain dictionary.

Source code in pysainsburys/models/common/price.py
40
41
42
43
44
45
46
def to_dict(self) -> dict[str, Any]:
    """Serialise the price to a plain dictionary."""
    return {
        "price": self.price,
        "measure": self.measure,
        "measure_amount": self.measure_amount,
    }

pysainsburys.models.common.pagination.PageControls dataclass

Pagination metadata returned by grocery list endpoints.

Attributes:

Name Type Description
total_record_count int

Total items available across all pages.

returned_record_count int

Items included in the current response.

active_page int

One-based index of the current page.

first_page int

One-based index of the first page.

last_page int

One-based index of the last page.

page_size int

Requested page size.

Source code in pysainsburys/models/common/pagination.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
@dataclass(slots=True)
class PageControls:
    """
    Pagination metadata returned by grocery list endpoints.

    Attributes:
        total_record_count: Total items available across all pages.
        returned_record_count: Items included in the current response.
        active_page: One-based index of the current page.
        first_page: One-based index of the first page.
        last_page: One-based index of the last page.
        page_size: Requested page size.

    """

    total_record_count: int
    returned_record_count: int
    active_page: int
    first_page: int
    last_page: int
    page_size: int

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> PageControls:
        """Parse pagination controls from grocery API JSON."""
        data = data or {}
        page = data.get("page") or {}
        return cls(
            total_record_count=int(data.get("total_record_count", 0)),
            returned_record_count=int(data.get("returned_record_count", 0)),
            active_page=int(page.get("active", 1)),
            first_page=int(page.get("first", 1)),
            last_page=int(page.get("last", 1)),
            page_size=int(page.get("size", 0)),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise pagination controls to a plain dictionary."""
        return {
            "total_record_count": self.total_record_count,
            "returned_record_count": self.returned_record_count,
            "active_page": self.active_page,
            "first_page": self.first_page,
            "last_page": self.last_page,
            "page_size": self.page_size,
        }

from_dict(data) classmethod

Parse pagination controls from grocery API JSON.

Source code in pysainsburys/models/common/pagination.py
31
32
33
34
35
36
37
38
39
40
41
42
43
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> PageControls:
    """Parse pagination controls from grocery API JSON."""
    data = data or {}
    page = data.get("page") or {}
    return cls(
        total_record_count=int(data.get("total_record_count", 0)),
        returned_record_count=int(data.get("returned_record_count", 0)),
        active_page=int(page.get("active", 1)),
        first_page=int(page.get("first", 1)),
        last_page=int(page.get("last", 1)),
        page_size=int(page.get("size", 0)),
    )

to_dict()

Serialise pagination controls to a plain dictionary.

Source code in pysainsburys/models/common/pagination.py
45
46
47
48
49
50
51
52
53
54
def to_dict(self) -> dict[str, Any]:
    """Serialise pagination controls to a plain dictionary."""
    return {
        "total_record_count": self.total_record_count,
        "returned_record_count": self.returned_record_count,
        "active_page": self.active_page,
        "first_page": self.first_page,
        "last_page": self.last_page,
        "page_size": self.page_size,
    }

Product

pysainsburys.models.product

Catalogue product models, detail sections, and nutrition parsing.

AverageWeight dataclass

Typical weight for a loose product.

Source code in pysainsburys/models/product/catalogue.py
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
@dataclass(slots=True)
class AverageWeight:
    """Typical weight for a loose product."""

    amount: float
    measure: str | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> AverageWeight | None:
        """Parse an average weight from grocery API JSON."""
        if not data or data.get("amount") is None:
            return None
        return cls(amount=float(data["amount"]), measure=text(data.get("measure")))

    def to_dict(self) -> dict[str, Any]:
        """Serialise the average weight to a plain dictionary."""
        return {"amount": self.amount, "measure": self.measure}

from_dict(data) classmethod

Parse an average weight from grocery API JSON.

Source code in pysainsburys/models/product/catalogue.py
210
211
212
213
214
215
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> AverageWeight | None:
    """Parse an average weight from grocery API JSON."""
    if not data or data.get("amount") is None:
        return None
    return cls(amount=float(data["amount"]), measure=text(data.get("measure")))

to_dict()

Serialise the average weight to a plain dictionary.

Source code in pysainsburys/models/product/catalogue.py
217
218
219
def to_dict(self) -> dict[str, Any]:
    """Serialise the average weight to a plain dictionary."""
    return {"amount": self.amount, "measure": self.measure}

HfssRestriction dataclass

HFSS advertising restriction for one UK nation.

Source code in pysainsburys/models/product/catalogue.py
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
@dataclass(slots=True)
class HfssRestriction:
    """HFSS advertising restriction for one UK nation."""

    country: str
    restricted: bool
    category: str | None = None
    score: int | None = None
    last_change_date: str | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> HfssRestriction | None:
        """Parse an HFSS restriction from grocery API JSON."""
        if not data:
            return None
        country = text(data.get("country"))
        if not country:
            return None
        return cls(
            country=country,
            restricted=bool(data.get("restricted", False)),
            category=text(data.get("hfss_category")),
            score=_int(data.get("hfss_score")),
            last_change_date=text(data.get("last_change_date")),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the HFSS restriction to a plain dictionary."""
        return {
            "country": self.country,
            "restricted": self.restricted,
            "category": self.category,
            "score": self.score,
            "last_change_date": self.last_change_date,
        }

from_dict(data) classmethod

Parse an HFSS restriction from grocery API JSON.

Source code in pysainsburys/models/product/catalogue.py
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> HfssRestriction | None:
    """Parse an HFSS restriction from grocery API JSON."""
    if not data:
        return None
    country = text(data.get("country"))
    if not country:
        return None
    return cls(
        country=country,
        restricted=bool(data.get("restricted", False)),
        category=text(data.get("hfss_category")),
        score=_int(data.get("hfss_score")),
        last_change_date=text(data.get("last_change_date")),
    )

to_dict()

Serialise the HFSS restriction to a plain dictionary.

Source code in pysainsburys/models/product/catalogue.py
248
249
250
251
252
253
254
255
256
def to_dict(self) -> dict[str, Any]:
    """Serialise the HFSS restriction to a plain dictionary."""
    return {
        "country": self.country,
        "restricted": self.restricted,
        "category": self.category,
        "score": self.score,
        "last_change_date": self.last_change_date,
    }

NectarPrice dataclass

Nectar member price for a product.

Attributes:

Name Type Description
retail_price float

Nectar price for the purchasable quantity.

unit_price float | None

Nectar price per unit of measure, when provided.

measure str | None

Unit label for unit_price.

url str | None

Link to the Nectar prices listing.

category_seo_url str | None

SEO path for the Nectar prices category.

Source code in pysainsburys/models/product/product.py
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
@dataclass(slots=True)
class NectarPrice:
    """
    Nectar member price for a product.

    Attributes:
        retail_price: Nectar price for the purchasable quantity.
        unit_price: Nectar price per unit of measure, when provided.
        measure: Unit label for ``unit_price``.
        url: Link to the Nectar prices listing.
        category_seo_url: SEO path for the Nectar prices category.

    """

    retail_price: float
    unit_price: float | None = None
    measure: str | None = None
    url: str | None = None
    category_seo_url: str | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> NectarPrice | None:
        """Parse a Nectar price from grocery API JSON."""
        if not data or data.get("retail_price") is None:
            return None
        unit_price = data.get("unit_price")
        return cls(
            retail_price=float(data["retail_price"]),
            unit_price=float(unit_price) if unit_price is not None else None,
            measure=data.get("measure"),
            url=data.get("url"),
            category_seo_url=data.get("category_seo_url"),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the Nectar price to a plain dictionary."""
        return {
            "retail_price": self.retail_price,
            "unit_price": self.unit_price,
            "measure": self.measure,
            "url": self.url,
            "category_seo_url": self.category_seo_url,
        }

from_dict(data) classmethod

Parse a Nectar price from grocery API JSON.

Source code in pysainsburys/models/product/product.py
175
176
177
178
179
180
181
182
183
184
185
186
187
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> NectarPrice | None:
    """Parse a Nectar price from grocery API JSON."""
    if not data or data.get("retail_price") is None:
        return None
    unit_price = data.get("unit_price")
    return cls(
        retail_price=float(data["retail_price"]),
        unit_price=float(unit_price) if unit_price is not None else None,
        measure=data.get("measure"),
        url=data.get("url"),
        category_seo_url=data.get("category_seo_url"),
    )

to_dict()

Serialise the Nectar price to a plain dictionary.

Source code in pysainsburys/models/product/product.py
189
190
191
192
193
194
195
196
197
def to_dict(self) -> dict[str, Any]:
    """Serialise the Nectar price to a plain dictionary."""
    return {
        "retail_price": self.retail_price,
        "unit_price": self.unit_price,
        "measure": self.measure,
        "url": self.url,
        "category_seo_url": self.category_seo_url,
    }

NutrientSummary dataclass

Traffic-light style nutrition summary for a single nutrient.

Source code in pysainsburys/models/product/nutrition.py
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
@dataclass(slots=True)
class NutrientSummary:
    """Traffic-light style nutrition summary for a single nutrient."""

    name: str
    values: list[str]
    reference_intake_percent: str | None = None
    level: str | None = None

    def to_dict(self) -> dict[str, Any]:
        """Return the nutrient summary as a dictionary."""
        return {
            "name": self.name,
            "values": self.values,
            "reference_intake_percent": self.reference_intake_percent,
            "level": self.level,
        }

to_dict()

Return the nutrient summary as a dictionary.

Source code in pysainsburys/models/product/nutrition.py
59
60
61
62
63
64
65
66
def to_dict(self) -> dict[str, Any]:
    """Return the nutrient summary as a dictionary."""
    return {
        "name": self.name,
        "values": self.values,
        "reference_intake_percent": self.reference_intake_percent,
        "level": self.level,
    }

NutritionInfo dataclass

Parsed nutrition information for a product.

Source code in pysainsburys/models/product/nutrition.py
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
@dataclass(slots=True)
class NutritionInfo:
    """Parsed nutrition information for a product."""

    summary: list[NutrientSummary] = field(default_factory=list)
    tables: list[NutritionTable] = field(default_factory=list)
    notes: list[str] = field(default_factory=list)

    def to_dict(self) -> dict[str, Any]:
        """Return nutrition information as a dictionary."""
        return {
            "summary": [item.to_dict() for item in self.summary],
            "tables": [table.to_dict() for table in self.tables],
            "notes": self.notes,
        }

to_dict()

Return nutrition information as a dictionary.

Source code in pysainsburys/models/product/nutrition.py
109
110
111
112
113
114
115
def to_dict(self) -> dict[str, Any]:
    """Return nutrition information as a dictionary."""
    return {
        "summary": [item.to_dict() for item in self.summary],
        "tables": [table.to_dict() for table in self.tables],
        "notes": self.notes,
    }

NutritionTable dataclass

A nutrition facts table from a product detail page.

Source code in pysainsburys/models/product/nutrition.py
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
@dataclass(slots=True)
class NutritionTable:
    """A nutrition facts table from a product detail page."""

    columns: list[str]
    rows: list[NutritionTableRow]
    title: str | None = None

    def to_dict(self) -> dict[str, Any]:
        """Return the nutrition table as a dictionary."""
        return {
            "title": self.title,
            "columns": self.columns,
            "rows": [row.to_dict() for row in self.rows],
        }

to_dict()

Return the nutrition table as a dictionary.

Source code in pysainsburys/models/product/nutrition.py
92
93
94
95
96
97
98
def to_dict(self) -> dict[str, Any]:
    """Return the nutrition table as a dictionary."""
    return {
        "title": self.title,
        "columns": self.columns,
        "rows": [row.to_dict() for row in self.rows],
    }

NutritionTableRow dataclass

A single row in a nutrition facts table.

Source code in pysainsburys/models/product/nutrition.py
69
70
71
72
73
74
75
76
77
78
79
80
81
@dataclass(slots=True)
class NutritionTableRow:
    """A single row in a nutrition facts table."""

    name: str
    values: list[str]

    def to_dict(self) -> dict[str, Any]:
        """Return the table row as a dictionary."""
        return {
            "name": self.name,
            "values": self.values,
        }

to_dict()

Return the table row as a dictionary.

Source code in pysainsburys/models/product/nutrition.py
76
77
78
79
80
81
def to_dict(self) -> dict[str, Any]:
    """Return the table row as a dictionary."""
    return {
        "name": self.name,
        "values": self.values,
    }

Product dataclass

A grocery product from the online catalogue.

When bound to a :class:~pysainsburys.Sainsburys client, a product can mutate the authenticated customer's basket directly via :meth:add_to_basket, :meth:set_basket_quantity, and :meth:remove_from_basket.

Nutrition data is parsed automatically from details_html when present on the API response (see :attr:nutrition). The same HTML also supplies description, storage, and related copy on :attr:details. Search results omit details_html, so those sections stay empty until the product is loaded with :meth:~pysainsburys.Sainsburys.get_product.

Attributes:

Name Type Description
product_uid str

Stable Sainsbury's product identifier.

name str

Display name shown on the website and app.

sain_id str | None

Legacy SAIN identifier when returned by the API.

is_favourite bool

Whether the product is in the signed-in customer's favourites list.

favourite_type str | None

Favourite list type when provided by the API.

product_type str | None

Product classification string from the API.

eans list[str]

European article numbers associated with the product.

unit_price Price | None

Price per unit of measure, when available.

retail_price Price | None

Shelf price for the purchasable quantity.

is_available bool

Whether the product can be added to a basket.

is_alcoholic bool

Whether age-restricted checks apply.

reviews ProductReviews | None

Aggregated review metadata.

image_url str | None

Product listing image URL.

nutrition NutritionInfo | None

Parsed nutrition tables and traffic-light summary.

details ProductDetails | None

Description, storage, and other product-text sections.

promotions list[Promotion]

Catalogue offers attached to the product.

nectar_price NectarPrice | None

Nectar member price when the product has one.

favourite_uid str | None

Favourite-list identifier when the product is saved.

short_description str | None

One-line summary from the product payload.

full_url str | None

Absolute product page URL.

original_unit_price Price | None

Unit price before a promotion, when the API returns one.

image str | None

Large product image URL.

image_thumbnail str | None

Medium product image URL.

image_thumbnail_small str | None

Small product image URL.

image_zoom str | None

Zoom image URL when provided.

images list[ProductImage]

Sized image variants from the assets block.

zone str | None

Merchandising zone, such as Drinks.

department str | None

Department name when the API returns one.

labels list[ProductLabel]

Merchandising labels such as British or Chilled.

categories list[ProductCategory]

Catalogue categories that include the product.

breadcrumbs list[ProductBreadcrumb]

Breadcrumb trail for the product page.

attributes dict[str, list[str]]

Attribute groups from the API, including brand.

header ProductHeader | None

Promotional header, such as a Nectar price banner.

is_spotlight bool

Whether the product is flagged as featured.

spotlight_label str | None

Featured label when is_spotlight is set.

not_for_eu bool

Whether the product is marked not for EU sale.

is_intolerant bool

Whether the product carries an intolerance flag.

is_mhra bool

Whether MHRA restrictions apply.

is_supply_chain_orderable bool

Whether supply-chain ordering is enabled.

display_icons list[str]

Icon identifiers shown on the product.

health_rating str | None

Health rating score from health_classification.

hfss_restrictions list[HfssRestriction]

HFSS advertising restrictions by UK nation.

pdp_deep_link str | None

Legacy product-display path.

average_weight AverageWeight | None

Typical weight for a loose product.

promise ProductPromise | None

Delivery promise when a slot context is present.

Source code in pysainsburys/models/product/product.py
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
@dataclass(slots=True)
class Product:
    """
    A grocery product from the online catalogue.

    When bound to a :class:`~pysainsburys.Sainsburys` client, a product can
    mutate the authenticated customer's basket directly via
    :meth:`add_to_basket`, :meth:`set_basket_quantity`, and
    :meth:`remove_from_basket`.

    Nutrition data is parsed automatically from ``details_html`` when present
    on the API response (see :attr:`nutrition`). The same HTML also supplies
    description, storage, and related copy on :attr:`details`. Search results
    omit ``details_html``, so those sections stay empty until the product is
    loaded with :meth:`~pysainsburys.Sainsburys.get_product`.

    Attributes:
        product_uid: Stable Sainsbury's product identifier.
        name: Display name shown on the website and app.
        sain_id: Legacy SAIN identifier when returned by the API.
        is_favourite: Whether the product is in the signed-in customer's
            favourites list.
        favourite_type: Favourite list type when provided by the API.
        product_type: Product classification string from the API.
        eans: European article numbers associated with the product.
        unit_price: Price per unit of measure, when available.
        retail_price: Shelf price for the purchasable quantity.
        is_available: Whether the product can be added to a basket.
        is_alcoholic: Whether age-restricted checks apply.
        reviews: Aggregated review metadata.
        image_url: Product listing image URL.
        nutrition: Parsed nutrition tables and traffic-light summary.
        details: Description, storage, and other product-text sections.
        promotions: Catalogue offers attached to the product.
        nectar_price: Nectar member price when the product has one.
        favourite_uid: Favourite-list identifier when the product is saved.
        short_description: One-line summary from the product payload.
        full_url: Absolute product page URL.
        original_unit_price: Unit price before a promotion, when the API
            returns one.
        image: Large product image URL.
        image_thumbnail: Medium product image URL.
        image_thumbnail_small: Small product image URL.
        image_zoom: Zoom image URL when provided.
        images: Sized image variants from the assets block.
        zone: Merchandising zone, such as ``Drinks``.
        department: Department name when the API returns one.
        labels: Merchandising labels such as British or Chilled.
        categories: Catalogue categories that include the product.
        breadcrumbs: Breadcrumb trail for the product page.
        attributes: Attribute groups from the API, including brand.
        header: Promotional header, such as a Nectar price banner.
        is_spotlight: Whether the product is flagged as featured.
        spotlight_label: Featured label when ``is_spotlight`` is set.
        not_for_eu: Whether the product is marked not for EU sale.
        is_intolerant: Whether the product carries an intolerance flag.
        is_mhra: Whether MHRA restrictions apply.
        is_supply_chain_orderable: Whether supply-chain ordering is enabled.
        display_icons: Icon identifiers shown on the product.
        health_rating: Health rating score from ``health_classification``.
        hfss_restrictions: HFSS advertising restrictions by UK nation.
        pdp_deep_link: Legacy product-display path.
        average_weight: Typical weight for a loose product.
        promise: Delivery promise when a slot context is present.

    """

    product_uid: str
    name: str
    sain_id: str | None = None
    is_favourite: bool = False
    favourite_type: str | None = None
    product_type: str | None = None
    eans: list[str] = field(default_factory=list)
    unit_price: Price | None = None
    retail_price: Price | None = None
    is_available: bool = True
    is_alcoholic: bool = False
    reviews: ProductReviews | None = None
    image_url: str | None = None
    nutrition: NutritionInfo | None = None
    details: ProductDetails | None = None
    promotions: list[Promotion] = field(default_factory=list)
    nectar_price: NectarPrice | None = None
    favourite_uid: str | None = None
    short_description: str | None = None
    full_url: str | None = None
    original_unit_price: Price | None = None
    image: str | None = None
    image_thumbnail: str | None = None
    image_thumbnail_small: str | None = None
    image_zoom: str | None = None
    images: list[ProductImage] = field(default_factory=list)
    zone: str | None = None
    department: str | None = None
    labels: list[ProductLabel] = field(default_factory=list)
    categories: list[ProductCategory] = field(default_factory=list)
    breadcrumbs: list[ProductBreadcrumb] = field(default_factory=list)
    attributes: dict[str, list[str]] = field(default_factory=dict)
    header: ProductHeader | None = None
    is_spotlight: bool = False
    spotlight_label: str | None = None
    not_for_eu: bool = False
    is_intolerant: bool = False
    is_mhra: bool = False
    is_supply_chain_orderable: bool = False
    display_icons: list[str] = field(default_factory=list)
    health_rating: str | None = None
    hfss_restrictions: list[HfssRestriction] = field(default_factory=list)
    pdp_deep_link: str | None = None
    average_weight: AverageWeight | None = None
    promise: ProductPromise | None = None
    _api: API | None = field(default=None, repr=False, compare=False, hash=False)

    @classmethod
    def from_dict(cls, data: dict[str, Any], *, api: API | None = None) -> Product:
        """Parse a product from grocery API JSON."""
        assets_raw = data.get("assets")
        assets: dict[str, Any] = assets_raw if isinstance(assets_raw, dict) else {}
        details_html = data.get("details_html")
        if not isinstance(details_html, str):
            details_html = None
        header_raw = data.get("header")
        header = header_raw if isinstance(header_raw, dict) else None
        weight = data.get("average_weight")
        promise_raw = data.get("promise")
        promise = promise_raw if isinstance(promise_raw, dict) else None
        return cls(
            product_uid=str(data.get("product_uid") or data.get("uid") or ""),
            name=str(data.get("name", "")),
            sain_id=data.get("sainId") or data.get("sain_id"),
            is_favourite=bool(data.get("is_favourite", False)),
            favourite_type=data.get("favourite_type"),
            product_type=data.get("product_type"),
            eans=[str(ean) for ean in data.get("eans", [])],
            unit_price=Price.from_dict(data.get("unit_price")),
            retail_price=Price.from_dict(data.get("retail_price")),
            is_available=bool(data.get("is_available", True)),
            is_alcoholic=bool(data.get("is_alcoholic", False)),
            reviews=ProductReviews.from_dict(data.get("reviews")),
            image_url=text(assets.get("plp_image")),
            nutrition=parse_nutrition_from_details_html(details_html),
            details=product_details_from_api(details_html, data.get("description")),
            promotions=_promotions_from_api(data),
            nectar_price=NectarPrice.from_dict(
                data["nectar_price"]
                if isinstance(data.get("nectar_price"), dict)
                else None
            ),
            favourite_uid=text(data.get("favourite_uid")),
            short_description=text(data.get("short_description")),
            full_url=page_url(data.get("full_url")),
            original_unit_price=Price.from_dict(
                data["original_unit_price"]
                if isinstance(data.get("original_unit_price"), dict)
                else None
            ),
            image=text(data.get("image")),
            image_thumbnail=text(data.get("image_thumbnail")),
            image_thumbnail_small=text(data.get("image_thumbnail_small")),
            image_zoom=text(data.get("image_zoom")),
            images=images_from_api(assets),
            zone=text(data.get("zone")),
            department=text(data.get("department")),
            labels=labels_from_api(data),
            categories=categories_from_api(data),
            breadcrumbs=breadcrumbs_from_api(data),
            attributes=attributes_from_api(data),
            header=ProductHeader.from_dict(header),
            is_spotlight=bool(data.get("is_spotlight", False)),
            spotlight_label=text(data.get("spotlight_label")),
            not_for_eu=bool(data.get("not_for_eu", False)),
            is_intolerant=bool(data.get("is_intolerant", False)),
            is_mhra=bool(data.get("is_mhra", False)),
            is_supply_chain_orderable=bool(
                data.get("is_supply_chain_orderable", False)
            ),
            display_icons=string_list(data.get("display_icons")),
            health_rating=health_rating_from_api(data),
            hfss_restrictions=hfss_from_api(data),
            pdp_deep_link=text(data.get("pdp_deep_link")),
            average_weight=AverageWeight.from_dict(
                weight if isinstance(weight, dict) else None
            ),
            promise=ProductPromise.from_dict(promise),
            _api=api,
        )

    @property
    def brand(self) -> list[str]:
        """Brand names from the product attributes."""
        return list(self.attributes.get("brand", []))

    @classmethod
    def from_basket_nested(
        cls,
        data: dict[str, Any],
        *,
        api: API | None = None,
    ) -> Product:
        """Parse a product object nested inside a basket line item."""
        payload = dict(data)
        if payload.get("sku") and not payload.get("product_uid"):
            payload["product_uid"] = payload["sku"]
        return cls.from_dict(payload, api=api)

    def _require_api(self) -> API:
        if self._api is None:
            msg = (
                "Product is not bound to a Sainsburys client; "
                "fetch it via Sainsburys.get_product() or search_products()."
            )
            raise NotBoundError(msg)
        return self._api

    def bind_api(self, api: API) -> Product:
        """Attach a client for basket and favourites operations."""
        self._api = api
        return self

    def _default_uom(self) -> str:
        if self.retail_price and self.retail_price.measure:
            return self.retail_price.measure
        return "ea"

    async def add_to_basket(
        self,
        quantity: float = 1.0,
        *,
        selected_catchweight: str | None = None,
        uom: str | None = None,
    ) -> Basket:
        """Add this product to the basket (POST increment)."""
        if quantity <= 0:
            return await self.remove_from_basket()
        api = self._require_api()
        body: dict[str, Any] = {
            "product_uid": self.product_uid,
            "quantity": quantity,
            "uom": uom or self._default_uom(),
        }
        if selected_catchweight is not None:
            body["selected_catchweight"] = selected_catchweight
        response = await api.send_request(endpoint="add_basket_item", body=body)
        return basket_from_response(response)

    async def _resolve_basket_item_uid(self, item_uid: str | None) -> str:
        """Resolve a basket line uid without importing basket at module load."""
        from ...basket import resolve_basket_item_uid

        return await resolve_basket_item_uid(
            self._require_api(),
            self.product_uid,
            item_uid,
        )

    async def set_basket_quantity(
        self,
        quantity: float,
        *,
        item_uid: str | None = None,
        selected_catchweight: str | None = None,
        uom: str | None = None,
    ) -> Basket:
        """Set the absolute basket quantity for this product."""
        if quantity <= 0:
            return await self.remove_from_basket(item_uid=item_uid)
        api = self._require_api()
        resolved_item_uid = await self._resolve_basket_item_uid(item_uid)
        item: dict[str, Any] = {
            "product_uid": self.product_uid,
            "quantity": quantity,
            "uom": uom or self._default_uom(),
            "item_uid": resolved_item_uid,
        }
        if selected_catchweight is not None:
            item["selected_catchweight"] = selected_catchweight
        response = await api.send_request(
            endpoint="update_basket",
            body={"items": [item]},
        )
        return basket_from_response(response)

    async def remove_from_basket(
        self,
        *,
        item_uid: str | None = None,
        force_delete: bool = False,
    ) -> Basket:
        """Remove this product from the basket."""
        del force_delete
        resolved_item_uid = await self._resolve_basket_item_uid(item_uid)
        response = await self._require_api().send_request(
            endpoint="update_basket",
            body={
                "items": [
                    {
                        "product_uid": self.product_uid,
                        "quantity": 0,
                        "uom": "ea",
                        "item_uid": resolved_item_uid,
                    }
                ]
            },
        )
        return basket_from_response(response)

    def to_dict(self) -> dict[str, Any]:
        """Serialise the product to a plain dictionary."""
        return {
            "product_uid": self.product_uid,
            "name": self.name,
            "sain_id": self.sain_id,
            "is_favourite": self.is_favourite,
            "favourite_type": self.favourite_type,
            "product_type": self.product_type,
            "eans": self.eans,
            "unit_price": self.unit_price.to_dict() if self.unit_price else None,
            "retail_price": self.retail_price.to_dict() if self.retail_price else None,
            "is_available": self.is_available,
            "is_alcoholic": self.is_alcoholic,
            "reviews": self.reviews.to_dict() if self.reviews else None,
            "image_url": self.image_url,
            "nutrition": self.nutrition.to_dict() if self.nutrition else None,
            "details": self.details.to_dict() if self.details else None,
            "promotions": [promotion.to_dict() for promotion in self.promotions],
            "nectar_price": (
                self.nectar_price.to_dict() if self.nectar_price else None
            ),
            "favourite_uid": self.favourite_uid,
            "short_description": self.short_description,
            "full_url": self.full_url,
            "original_unit_price": (
                self.original_unit_price.to_dict() if self.original_unit_price else None
            ),
            "image": self.image,
            "image_thumbnail": self.image_thumbnail,
            "image_thumbnail_small": self.image_thumbnail_small,
            "image_zoom": self.image_zoom,
            "images": [image.to_dict() for image in self.images],
            "zone": self.zone,
            "department": self.department,
            "labels": [label.to_dict() for label in self.labels],
            "categories": [category.to_dict() for category in self.categories],
            "breadcrumbs": [crumb.to_dict() for crumb in self.breadcrumbs],
            "attributes": self.attributes,
            "brand": self.brand,
            "header": self.header.to_dict() if self.header else None,
            "is_spotlight": self.is_spotlight,
            "spotlight_label": self.spotlight_label,
            "not_for_eu": self.not_for_eu,
            "is_intolerant": self.is_intolerant,
            "is_mhra": self.is_mhra,
            "is_supply_chain_orderable": self.is_supply_chain_orderable,
            "display_icons": self.display_icons,
            "health_rating": self.health_rating,
            "hfss_restrictions": [
                restriction.to_dict() for restriction in self.hfss_restrictions
            ],
            "pdp_deep_link": self.pdp_deep_link,
            "average_weight": (
                self.average_weight.to_dict() if self.average_weight else None
            ),
            "promise": self.promise.to_dict() if self.promise else None,
        }

    def __iter__(self) -> Iterator[tuple[str, Any]]:
        """Allow ``dict(product)`` conversion."""
        return iter(self.to_dict().items())

brand property

Brand names from the product attributes.

__iter__()

Allow dict(product) conversion.

Source code in pysainsburys/models/product/product.py
581
582
583
def __iter__(self) -> Iterator[tuple[str, Any]]:
    """Allow ``dict(product)`` conversion."""
    return iter(self.to_dict().items())

add_to_basket(quantity=1.0, *, selected_catchweight=None, uom=None) async

Add this product to the basket (POST increment).

Source code in pysainsburys/models/product/product.py
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
async def add_to_basket(
    self,
    quantity: float = 1.0,
    *,
    selected_catchweight: str | None = None,
    uom: str | None = None,
) -> Basket:
    """Add this product to the basket (POST increment)."""
    if quantity <= 0:
        return await self.remove_from_basket()
    api = self._require_api()
    body: dict[str, Any] = {
        "product_uid": self.product_uid,
        "quantity": quantity,
        "uom": uom or self._default_uom(),
    }
    if selected_catchweight is not None:
        body["selected_catchweight"] = selected_catchweight
    response = await api.send_request(endpoint="add_basket_item", body=body)
    return basket_from_response(response)

bind_api(api)

Attach a client for basket and favourites operations.

Source code in pysainsburys/models/product/product.py
430
431
432
433
def bind_api(self, api: API) -> Product:
    """Attach a client for basket and favourites operations."""
    self._api = api
    return self

from_basket_nested(data, *, api=None) classmethod

Parse a product object nested inside a basket line item.

Source code in pysainsburys/models/product/product.py
408
409
410
411
412
413
414
415
416
417
418
419
@classmethod
def from_basket_nested(
    cls,
    data: dict[str, Any],
    *,
    api: API | None = None,
) -> Product:
    """Parse a product object nested inside a basket line item."""
    payload = dict(data)
    if payload.get("sku") and not payload.get("product_uid"):
        payload["product_uid"] = payload["sku"]
    return cls.from_dict(payload, api=api)

from_dict(data, *, api=None) classmethod

Parse a product from grocery API JSON.

Source code in pysainsburys/models/product/product.py
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
@classmethod
def from_dict(cls, data: dict[str, Any], *, api: API | None = None) -> Product:
    """Parse a product from grocery API JSON."""
    assets_raw = data.get("assets")
    assets: dict[str, Any] = assets_raw if isinstance(assets_raw, dict) else {}
    details_html = data.get("details_html")
    if not isinstance(details_html, str):
        details_html = None
    header_raw = data.get("header")
    header = header_raw if isinstance(header_raw, dict) else None
    weight = data.get("average_weight")
    promise_raw = data.get("promise")
    promise = promise_raw if isinstance(promise_raw, dict) else None
    return cls(
        product_uid=str(data.get("product_uid") or data.get("uid") or ""),
        name=str(data.get("name", "")),
        sain_id=data.get("sainId") or data.get("sain_id"),
        is_favourite=bool(data.get("is_favourite", False)),
        favourite_type=data.get("favourite_type"),
        product_type=data.get("product_type"),
        eans=[str(ean) for ean in data.get("eans", [])],
        unit_price=Price.from_dict(data.get("unit_price")),
        retail_price=Price.from_dict(data.get("retail_price")),
        is_available=bool(data.get("is_available", True)),
        is_alcoholic=bool(data.get("is_alcoholic", False)),
        reviews=ProductReviews.from_dict(data.get("reviews")),
        image_url=text(assets.get("plp_image")),
        nutrition=parse_nutrition_from_details_html(details_html),
        details=product_details_from_api(details_html, data.get("description")),
        promotions=_promotions_from_api(data),
        nectar_price=NectarPrice.from_dict(
            data["nectar_price"]
            if isinstance(data.get("nectar_price"), dict)
            else None
        ),
        favourite_uid=text(data.get("favourite_uid")),
        short_description=text(data.get("short_description")),
        full_url=page_url(data.get("full_url")),
        original_unit_price=Price.from_dict(
            data["original_unit_price"]
            if isinstance(data.get("original_unit_price"), dict)
            else None
        ),
        image=text(data.get("image")),
        image_thumbnail=text(data.get("image_thumbnail")),
        image_thumbnail_small=text(data.get("image_thumbnail_small")),
        image_zoom=text(data.get("image_zoom")),
        images=images_from_api(assets),
        zone=text(data.get("zone")),
        department=text(data.get("department")),
        labels=labels_from_api(data),
        categories=categories_from_api(data),
        breadcrumbs=breadcrumbs_from_api(data),
        attributes=attributes_from_api(data),
        header=ProductHeader.from_dict(header),
        is_spotlight=bool(data.get("is_spotlight", False)),
        spotlight_label=text(data.get("spotlight_label")),
        not_for_eu=bool(data.get("not_for_eu", False)),
        is_intolerant=bool(data.get("is_intolerant", False)),
        is_mhra=bool(data.get("is_mhra", False)),
        is_supply_chain_orderable=bool(
            data.get("is_supply_chain_orderable", False)
        ),
        display_icons=string_list(data.get("display_icons")),
        health_rating=health_rating_from_api(data),
        hfss_restrictions=hfss_from_api(data),
        pdp_deep_link=text(data.get("pdp_deep_link")),
        average_weight=AverageWeight.from_dict(
            weight if isinstance(weight, dict) else None
        ),
        promise=ProductPromise.from_dict(promise),
        _api=api,
    )

remove_from_basket(*, item_uid=None, force_delete=False) async

Remove this product from the basket.

Source code in pysainsburys/models/product/product.py
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
async def remove_from_basket(
    self,
    *,
    item_uid: str | None = None,
    force_delete: bool = False,
) -> Basket:
    """Remove this product from the basket."""
    del force_delete
    resolved_item_uid = await self._resolve_basket_item_uid(item_uid)
    response = await self._require_api().send_request(
        endpoint="update_basket",
        body={
            "items": [
                {
                    "product_uid": self.product_uid,
                    "quantity": 0,
                    "uom": "ea",
                    "item_uid": resolved_item_uid,
                }
            ]
        },
    )
    return basket_from_response(response)

set_basket_quantity(quantity, *, item_uid=None, selected_catchweight=None, uom=None) async

Set the absolute basket quantity for this product.

Source code in pysainsburys/models/product/product.py
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
async def set_basket_quantity(
    self,
    quantity: float,
    *,
    item_uid: str | None = None,
    selected_catchweight: str | None = None,
    uom: str | None = None,
) -> Basket:
    """Set the absolute basket quantity for this product."""
    if quantity <= 0:
        return await self.remove_from_basket(item_uid=item_uid)
    api = self._require_api()
    resolved_item_uid = await self._resolve_basket_item_uid(item_uid)
    item: dict[str, Any] = {
        "product_uid": self.product_uid,
        "quantity": quantity,
        "uom": uom or self._default_uom(),
        "item_uid": resolved_item_uid,
    }
    if selected_catchweight is not None:
        item["selected_catchweight"] = selected_catchweight
    response = await api.send_request(
        endpoint="update_basket",
        body={"items": [item]},
    )
    return basket_from_response(response)

to_dict()

Serialise the product to a plain dictionary.

Source code in pysainsburys/models/product/product.py
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
def to_dict(self) -> dict[str, Any]:
    """Serialise the product to a plain dictionary."""
    return {
        "product_uid": self.product_uid,
        "name": self.name,
        "sain_id": self.sain_id,
        "is_favourite": self.is_favourite,
        "favourite_type": self.favourite_type,
        "product_type": self.product_type,
        "eans": self.eans,
        "unit_price": self.unit_price.to_dict() if self.unit_price else None,
        "retail_price": self.retail_price.to_dict() if self.retail_price else None,
        "is_available": self.is_available,
        "is_alcoholic": self.is_alcoholic,
        "reviews": self.reviews.to_dict() if self.reviews else None,
        "image_url": self.image_url,
        "nutrition": self.nutrition.to_dict() if self.nutrition else None,
        "details": self.details.to_dict() if self.details else None,
        "promotions": [promotion.to_dict() for promotion in self.promotions],
        "nectar_price": (
            self.nectar_price.to_dict() if self.nectar_price else None
        ),
        "favourite_uid": self.favourite_uid,
        "short_description": self.short_description,
        "full_url": self.full_url,
        "original_unit_price": (
            self.original_unit_price.to_dict() if self.original_unit_price else None
        ),
        "image": self.image,
        "image_thumbnail": self.image_thumbnail,
        "image_thumbnail_small": self.image_thumbnail_small,
        "image_zoom": self.image_zoom,
        "images": [image.to_dict() for image in self.images],
        "zone": self.zone,
        "department": self.department,
        "labels": [label.to_dict() for label in self.labels],
        "categories": [category.to_dict() for category in self.categories],
        "breadcrumbs": [crumb.to_dict() for crumb in self.breadcrumbs],
        "attributes": self.attributes,
        "brand": self.brand,
        "header": self.header.to_dict() if self.header else None,
        "is_spotlight": self.is_spotlight,
        "spotlight_label": self.spotlight_label,
        "not_for_eu": self.not_for_eu,
        "is_intolerant": self.is_intolerant,
        "is_mhra": self.is_mhra,
        "is_supply_chain_orderable": self.is_supply_chain_orderable,
        "display_icons": self.display_icons,
        "health_rating": self.health_rating,
        "hfss_restrictions": [
            restriction.to_dict() for restriction in self.hfss_restrictions
        ],
        "pdp_deep_link": self.pdp_deep_link,
        "average_weight": (
            self.average_weight.to_dict() if self.average_weight else None
        ),
        "promise": self.promise.to_dict() if self.promise else None,
    }

ProductBreadcrumb dataclass

One step in the product page breadcrumb trail.

Source code in pysainsburys/models/product/catalogue.py
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
@dataclass(slots=True)
class ProductBreadcrumb:
    """One step in the product page breadcrumb trail."""

    label: str
    url: str | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> ProductBreadcrumb | None:
        """Parse a breadcrumb from grocery API JSON."""
        if not data:
            return None
        label = text(data.get("label"))
        if not label:
            return None
        return cls(label=label, url=page_url(data.get("url")))

    def to_dict(self) -> dict[str, Any]:
        """Serialise the breadcrumb to a plain dictionary."""
        return {"label": self.label, "url": self.url}

from_dict(data) classmethod

Parse a breadcrumb from grocery API JSON.

Source code in pysainsburys/models/product/catalogue.py
108
109
110
111
112
113
114
115
116
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> ProductBreadcrumb | None:
    """Parse a breadcrumb from grocery API JSON."""
    if not data:
        return None
    label = text(data.get("label"))
    if not label:
        return None
    return cls(label=label, url=page_url(data.get("url")))

to_dict()

Serialise the breadcrumb to a plain dictionary.

Source code in pysainsburys/models/product/catalogue.py
118
119
120
def to_dict(self) -> dict[str, Any]:
    """Serialise the breadcrumb to a plain dictionary."""
    return {"label": self.label, "url": self.url}

ProductCategory dataclass

A catalogue category the product belongs to.

Source code in pysainsburys/models/product/catalogue.py
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
@dataclass(slots=True)
class ProductCategory:
    """A catalogue category the product belongs to."""

    category_id: str
    name: str

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> ProductCategory | None:
        """Parse a category from grocery API JSON."""
        if not data:
            return None
        category_id = text(data.get("id") or data.get("category_id"))
        name = text(data.get("name"))
        if not category_id or not name:
            return None
        return cls(category_id=category_id, name=name)

    def to_dict(self) -> dict[str, Any]:
        """Serialise the category to a plain dictionary."""
        return {"category_id": self.category_id, "name": self.name}

from_dict(data) classmethod

Parse a category from grocery API JSON.

Source code in pysainsburys/models/product/catalogue.py
85
86
87
88
89
90
91
92
93
94
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> ProductCategory | None:
    """Parse a category from grocery API JSON."""
    if not data:
        return None
    category_id = text(data.get("id") or data.get("category_id"))
    name = text(data.get("name"))
    if not category_id or not name:
        return None
    return cls(category_id=category_id, name=name)

to_dict()

Serialise the category to a plain dictionary.

Source code in pysainsburys/models/product/catalogue.py
96
97
98
def to_dict(self) -> dict[str, Any]:
    """Serialise the category to a plain dictionary."""
    return {"category_id": self.category_id, "name": self.name}

ProductDetails dataclass

Catalogue copy parsed from a product detail page.

Each field is a list of paragraphs. A heading the page does not include is None.

Attributes:

Name Type Description
description list[str] | None

Product description paragraphs.

storage list[str] | None

Storage instructions.

dietary_information list[str] | None

Dietary and allergen statements.

ingredients list[str] | None

Ingredient list paragraphs.

manufacturer list[str] | None

Manufacturer or packer details.

preparation list[str] | None

Preparation or serving instructions.

country_of_origin list[str] | None

Origin or packing-country statements.

packaging list[str] | None

Packaging description.

Source code in pysainsburys/models/product/details.py
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
@dataclass(slots=True)
class ProductDetails:
    """
    Catalogue copy parsed from a product detail page.

    Each field is a list of paragraphs. A heading the page does not include
    is ``None``.

    Attributes:
        description: Product description paragraphs.
        storage: Storage instructions.
        dietary_information: Dietary and allergen statements.
        ingredients: Ingredient list paragraphs.
        manufacturer: Manufacturer or packer details.
        preparation: Preparation or serving instructions.
        country_of_origin: Origin or packing-country statements.
        packaging: Packaging description.

    """

    description: list[str] | None = None
    storage: list[str] | None = None
    dietary_information: list[str] | None = None
    ingredients: list[str] | None = None
    manufacturer: list[str] | None = None
    preparation: list[str] | None = None
    country_of_origin: list[str] | None = None
    packaging: list[str] | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> ProductDetails | None:
        """Parse product detail sections from a serialised mapping."""
        if not data:
            return None
        details = cls(
            description=_string_list(data.get("description")),
            storage=_string_list(data.get("storage")),
            dietary_information=_string_list(data.get("dietary_information")),
            ingredients=_string_list(data.get("ingredients")),
            manufacturer=_string_list(data.get("manufacturer")),
            preparation=_string_list(data.get("preparation")),
            country_of_origin=_string_list(data.get("country_of_origin")),
            packaging=_string_list(data.get("packaging")),
        )
        if details.is_empty():
            return None
        return details

    def is_empty(self) -> bool:
        """Return whether every section is missing."""
        return all(
            value is None
            for value in (
                self.description,
                self.storage,
                self.dietary_information,
                self.ingredients,
                self.manufacturer,
                self.preparation,
                self.country_of_origin,
                self.packaging,
            )
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the detail sections to a plain dictionary."""
        return {
            "description": self.description,
            "storage": self.storage,
            "dietary_information": self.dietary_information,
            "ingredients": self.ingredients,
            "manufacturer": self.manufacturer,
            "preparation": self.preparation,
            "country_of_origin": self.country_of_origin,
            "packaging": self.packaging,
        }

from_dict(data) classmethod

Parse product detail sections from a serialised mapping.

Source code in pysainsburys/models/product/details.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> ProductDetails | None:
    """Parse product detail sections from a serialised mapping."""
    if not data:
        return None
    details = cls(
        description=_string_list(data.get("description")),
        storage=_string_list(data.get("storage")),
        dietary_information=_string_list(data.get("dietary_information")),
        ingredients=_string_list(data.get("ingredients")),
        manufacturer=_string_list(data.get("manufacturer")),
        preparation=_string_list(data.get("preparation")),
        country_of_origin=_string_list(data.get("country_of_origin")),
        packaging=_string_list(data.get("packaging")),
    )
    if details.is_empty():
        return None
    return details

is_empty()

Return whether every section is missing.

Source code in pysainsburys/models/product/details.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
def is_empty(self) -> bool:
    """Return whether every section is missing."""
    return all(
        value is None
        for value in (
            self.description,
            self.storage,
            self.dietary_information,
            self.ingredients,
            self.manufacturer,
            self.preparation,
            self.country_of_origin,
            self.packaging,
        )
    )

to_dict()

Serialise the detail sections to a plain dictionary.

Source code in pysainsburys/models/product/details.py
107
108
109
110
111
112
113
114
115
116
117
118
def to_dict(self) -> dict[str, Any]:
    """Serialise the detail sections to a plain dictionary."""
    return {
        "description": self.description,
        "storage": self.storage,
        "dietary_information": self.dietary_information,
        "ingredients": self.ingredients,
        "manufacturer": self.manufacturer,
        "preparation": self.preparation,
        "country_of_origin": self.country_of_origin,
        "packaging": self.packaging,
    }

ProductHeader dataclass

Promotional header shown above the product, such as a Nectar price.

Source code in pysainsburys/models/product/catalogue.py
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
@dataclass(slots=True)
class ProductHeader:
    """Promotional header shown above the product, such as a Nectar price."""

    text: str | None = None
    type: str | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> ProductHeader | None:
        """Parse a product header from grocery API JSON."""
        if not data:
            return None
        header_text = text(data.get("text"))
        header_type = text(data.get("type"))
        if not header_text and not header_type:
            return None
        return cls(text=header_text, type=header_type)

    def to_dict(self) -> dict[str, Any]:
        """Serialise the header to a plain dictionary."""
        return {"text": self.text, "type": self.type}

from_dict(data) classmethod

Parse a product header from grocery API JSON.

Source code in pysainsburys/models/product/catalogue.py
130
131
132
133
134
135
136
137
138
139
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> ProductHeader | None:
    """Parse a product header from grocery API JSON."""
    if not data:
        return None
    header_text = text(data.get("text"))
    header_type = text(data.get("type"))
    if not header_text and not header_type:
        return None
    return cls(text=header_text, type=header_type)

to_dict()

Serialise the header to a plain dictionary.

Source code in pysainsburys/models/product/catalogue.py
141
142
143
def to_dict(self) -> dict[str, Any]:
    """Serialise the header to a plain dictionary."""
    return {"text": self.text, "type": self.type}

ProductImage dataclass

A product image and the sizes the API provides for it.

Source code in pysainsburys/models/product/catalogue.py
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
@dataclass(slots=True)
class ProductImage:
    """A product image and the sizes the API provides for it."""

    image_id: str | None = None
    sizes: list[ProductImageSize] = field(default_factory=list)

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> ProductImage | None:
        """Parse a product image from grocery API JSON."""
        if not data:
            return None
        sizes = [
            size
            for item in _mapping_list(data.get("sizes"))
            if (size := ProductImageSize.from_dict(item)) is not None
        ]
        image_id = text(data.get("id") or data.get("image_id"))
        if not image_id and not sizes:
            return None
        return cls(image_id=image_id, sizes=sizes)

    def to_dict(self) -> dict[str, Any]:
        """Serialise the product image to a plain dictionary."""
        return {
            "image_id": self.image_id,
            "sizes": [size.to_dict() for size in self.sizes],
        }

from_dict(data) classmethod

Parse a product image from grocery API JSON.

Source code in pysainsburys/models/product/catalogue.py
180
181
182
183
184
185
186
187
188
189
190
191
192
193
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> ProductImage | None:
    """Parse a product image from grocery API JSON."""
    if not data:
        return None
    sizes = [
        size
        for item in _mapping_list(data.get("sizes"))
        if (size := ProductImageSize.from_dict(item)) is not None
    ]
    image_id = text(data.get("id") or data.get("image_id"))
    if not image_id and not sizes:
        return None
    return cls(image_id=image_id, sizes=sizes)

to_dict()

Serialise the product image to a plain dictionary.

Source code in pysainsburys/models/product/catalogue.py
195
196
197
198
199
200
def to_dict(self) -> dict[str, Any]:
    """Serialise the product image to a plain dictionary."""
    return {
        "image_id": self.image_id,
        "sizes": [size.to_dict() for size in self.sizes],
    }

ProductImageSize dataclass

One rendered size of a product image.

Source code in pysainsburys/models/product/catalogue.py
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
@dataclass(slots=True)
class ProductImageSize:
    """One rendered size of a product image."""

    url: str
    width: int | None = None
    height: int | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> ProductImageSize | None:
        """Parse an image size from grocery API JSON."""
        if not data:
            return None
        url = text(data.get("url"))
        if not url:
            return None
        return cls(
            url=url,
            width=_int(data.get("width")),
            height=_int(data.get("height")),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the image size to a plain dictionary."""
        return {"url": self.url, "width": self.width, "height": self.height}

from_dict(data) classmethod

Parse an image size from grocery API JSON.

Source code in pysainsburys/models/product/catalogue.py
154
155
156
157
158
159
160
161
162
163
164
165
166
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> ProductImageSize | None:
    """Parse an image size from grocery API JSON."""
    if not data:
        return None
    url = text(data.get("url"))
    if not url:
        return None
    return cls(
        url=url,
        width=_int(data.get("width")),
        height=_int(data.get("height")),
    )

to_dict()

Serialise the image size to a plain dictionary.

Source code in pysainsburys/models/product/catalogue.py
168
169
170
def to_dict(self) -> dict[str, Any]:
    """Serialise the image size to a plain dictionary."""
    return {"url": self.url, "width": self.width, "height": self.height}

ProductLabel dataclass

A merchandising label such as British or Chilled.

Source code in pysainsburys/models/product/catalogue.py
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
@dataclass(slots=True)
class ProductLabel:
    """A merchandising label such as ``British`` or ``Chilled``."""

    label_uid: str
    text: str | None = None
    alt_text: str | None = None
    color: str | None = None
    link: str | None = None
    link_opens_in_new_window: bool = False

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> ProductLabel | None:
        """Parse a label from grocery API JSON."""
        if not data:
            return None
        label_uid = text(data.get("label_uid") or data.get("text"))
        if not label_uid:
            return None
        return cls(
            label_uid=label_uid,
            text=text(data.get("text")),
            alt_text=text(data.get("alt_text")),
            color=text(data.get("color")),
            link=text(data.get("link")),
            link_opens_in_new_window=bool(data.get("link_opens_in_new_window", False)),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the label to a plain dictionary."""
        return {
            "label_uid": self.label_uid,
            "text": self.text,
            "alt_text": self.alt_text,
            "color": self.color,
            "link": self.link,
            "link_opens_in_new_window": self.link_opens_in_new_window,
        }

from_dict(data) classmethod

Parse a label from grocery API JSON.

Source code in pysainsburys/models/product/catalogue.py
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> ProductLabel | None:
    """Parse a label from grocery API JSON."""
    if not data:
        return None
    label_uid = text(data.get("label_uid") or data.get("text"))
    if not label_uid:
        return None
    return cls(
        label_uid=label_uid,
        text=text(data.get("text")),
        alt_text=text(data.get("alt_text")),
        color=text(data.get("color")),
        link=text(data.get("link")),
        link_opens_in_new_window=bool(data.get("link_opens_in_new_window", False)),
    )

to_dict()

Serialise the label to a plain dictionary.

Source code in pysainsburys/models/product/catalogue.py
66
67
68
69
70
71
72
73
74
75
def to_dict(self) -> dict[str, Any]:
    """Serialise the label to a plain dictionary."""
    return {
        "label_uid": self.label_uid,
        "text": self.text,
        "alt_text": self.alt_text,
        "color": self.color,
        "link": self.link,
        "link_opens_in_new_window": self.link_opens_in_new_window,
    }

ProductList dataclass

A paginated list of catalogue products.

Source code in pysainsburys/models/product/product.py
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
@dataclass(slots=True)
class ProductList:
    """A paginated list of catalogue products."""

    products: list[Product]
    controls: PageControls

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> ProductList:
        """Parse a paginated product list from grocery API JSON."""
        products = [Product.from_dict(item) for item in data.get("products", [])]
        return cls(
            products=products,
            controls=PageControls.from_dict(data.get("controls")),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the product list to a plain dictionary."""
        return {
            "products": [product.to_dict() for product in self.products],
            "controls": self.controls.to_dict(),
        }

    def __iter__(self) -> Iterator[tuple[str, Any]]:
        """Allow ``dict(product_list)`` conversion."""
        return iter(self.to_dict().items())

__iter__()

Allow dict(product_list) conversion.

Source code in pysainsburys/models/product/product.py
609
610
611
def __iter__(self) -> Iterator[tuple[str, Any]]:
    """Allow ``dict(product_list)`` conversion."""
    return iter(self.to_dict().items())

from_dict(data) classmethod

Parse a paginated product list from grocery API JSON.

Source code in pysainsburys/models/product/product.py
593
594
595
596
597
598
599
600
@classmethod
def from_dict(cls, data: dict[str, Any]) -> ProductList:
    """Parse a paginated product list from grocery API JSON."""
    products = [Product.from_dict(item) for item in data.get("products", [])]
    return cls(
        products=products,
        controls=PageControls.from_dict(data.get("controls")),
    )

to_dict()

Serialise the product list to a plain dictionary.

Source code in pysainsburys/models/product/product.py
602
603
604
605
606
607
def to_dict(self) -> dict[str, Any]:
    """Serialise the product list to a plain dictionary."""
    return {
        "products": [product.to_dict() for product in self.products],
        "controls": self.controls.to_dict(),
    }

ProductPromise dataclass

Delivery promise attached to a product when a slot context exists.

Source code in pysainsburys/models/product/catalogue.py
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
@dataclass(slots=True)
class ProductPromise:
    """Delivery promise attached to a product when a slot context exists."""

    type: str | None = None
    earliest_promise_date: str | None = None
    last_amendment_date: str | None = None
    status_label: str | None = None
    status_type: str | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> ProductPromise | None:
        """Parse a product promise from grocery API JSON."""
        if not data:
            return None
        status_raw = data.get("status")
        status = status_raw if isinstance(status_raw, dict) else {}
        promise = cls(
            type=text(data.get("type")),
            earliest_promise_date=text(data.get("earliest_promise_date")),
            last_amendment_date=text(data.get("last_amendment_date")),
            status_label=text(status.get("label")),
            status_type=text(status.get("type")),
        )
        if promise.status_type == "NONE":
            promise.status_type = None
        if promise.is_empty():
            return None
        return promise

    def is_empty(self) -> bool:
        """Return whether the promise carries no slot information."""
        return all(
            value is None
            for value in (
                self.type,
                self.earliest_promise_date,
                self.last_amendment_date,
                self.status_label,
                self.status_type,
            )
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the promise to a plain dictionary."""
        return {
            "type": self.type,
            "earliest_promise_date": self.earliest_promise_date,
            "last_amendment_date": self.last_amendment_date,
            "status_label": self.status_label,
            "status_type": self.status_type,
        }

from_dict(data) classmethod

Parse a product promise from grocery API JSON.

Source code in pysainsburys/models/product/catalogue.py
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> ProductPromise | None:
    """Parse a product promise from grocery API JSON."""
    if not data:
        return None
    status_raw = data.get("status")
    status = status_raw if isinstance(status_raw, dict) else {}
    promise = cls(
        type=text(data.get("type")),
        earliest_promise_date=text(data.get("earliest_promise_date")),
        last_amendment_date=text(data.get("last_amendment_date")),
        status_label=text(status.get("label")),
        status_type=text(status.get("type")),
    )
    if promise.status_type == "NONE":
        promise.status_type = None
    if promise.is_empty():
        return None
    return promise

is_empty()

Return whether the promise carries no slot information.

Source code in pysainsburys/models/product/catalogue.py
289
290
291
292
293
294
295
296
297
298
299
300
def is_empty(self) -> bool:
    """Return whether the promise carries no slot information."""
    return all(
        value is None
        for value in (
            self.type,
            self.earliest_promise_date,
            self.last_amendment_date,
            self.status_label,
            self.status_type,
        )
    )

to_dict()

Serialise the promise to a plain dictionary.

Source code in pysainsburys/models/product/catalogue.py
302
303
304
305
306
307
308
309
310
def to_dict(self) -> dict[str, Any]:
    """Serialise the promise to a plain dictionary."""
    return {
        "type": self.type,
        "earliest_promise_date": self.earliest_promise_date,
        "last_amendment_date": self.last_amendment_date,
        "status_label": self.status_label,
        "status_type": self.status_type,
    }

ProductReviews dataclass

Aggregated review metadata for a product.

Attributes:

Name Type Description
is_enabled bool

Whether reviews are shown for this product.

product_uid str | None

Product identifier referenced by the review service.

total int

Number of published reviews.

average_rating float

Mean star rating across reviews.

Source code in pysainsburys/models/product/product.py
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
@dataclass(slots=True)
class ProductReviews:
    """
    Aggregated review metadata for a product.

    Attributes:
        is_enabled: Whether reviews are shown for this product.
        product_uid: Product identifier referenced by the review service.
        total: Number of published reviews.
        average_rating: Mean star rating across reviews.

    """

    is_enabled: bool
    product_uid: str | None
    total: int
    average_rating: float

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> ProductReviews | None:
        """Parse review metadata from grocery API JSON."""
        if not data:
            return None
        return cls(
            is_enabled=bool(data.get("is_enabled", False)),
            product_uid=data.get("product_uid"),
            total=int(data.get("total", 0)),
            average_rating=float(data.get("average_rating", 0)),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise review metadata to a plain dictionary."""
        return {
            "is_enabled": self.is_enabled,
            "product_uid": self.product_uid,
            "total": self.total,
            "average_rating": self.average_rating,
        }

from_dict(data) classmethod

Parse review metadata from grocery API JSON.

Source code in pysainsburys/models/product/product.py
58
59
60
61
62
63
64
65
66
67
68
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> ProductReviews | None:
    """Parse review metadata from grocery API JSON."""
    if not data:
        return None
    return cls(
        is_enabled=bool(data.get("is_enabled", False)),
        product_uid=data.get("product_uid"),
        total=int(data.get("total", 0)),
        average_rating=float(data.get("average_rating", 0)),
    )

to_dict()

Serialise review metadata to a plain dictionary.

Source code in pysainsburys/models/product/product.py
70
71
72
73
74
75
76
77
def to_dict(self) -> dict[str, Any]:
    """Serialise review metadata to a plain dictionary."""
    return {
        "is_enabled": self.is_enabled,
        "product_uid": self.product_uid,
        "total": self.total,
        "average_rating": self.average_rating,
    }

Promotion dataclass

A catalogue promotion attached to a product.

Attributes:

Name Type Description
promotion_uid str

Promotion identifier.

strap_line str | None

Customer-facing offer text, such as Buy 1 for 3.

start_date str | None

Offer start timestamp from the API.

end_date str | None

Offer end timestamp from the API.

original_price float | None

Shelf price before the promotion, in pounds sterling.

is_nectar bool

Whether the offer is a Nectar price.

promo_type str | None

Promotion mechanic type from the API.

promo_group str | None

Promotion grouping from the API.

promo_mechanic_id str | None

Mechanic identifier from the API.

icon str | None

Promotion icon URL when provided.

link str | None

Relative link to the promotion lister.

Source code in pysainsburys/models/product/product.py
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
@dataclass(slots=True)
class Promotion:
    """
    A catalogue promotion attached to a product.

    Attributes:
        promotion_uid: Promotion identifier.
        strap_line: Customer-facing offer text, such as ``Buy 1 for 3``.
        start_date: Offer start timestamp from the API.
        end_date: Offer end timestamp from the API.
        original_price: Shelf price before the promotion, in pounds sterling.
        is_nectar: Whether the offer is a Nectar price.
        promo_type: Promotion mechanic type from the API.
        promo_group: Promotion grouping from the API.
        promo_mechanic_id: Mechanic identifier from the API.
        icon: Promotion icon URL when provided.
        link: Relative link to the promotion lister.

    """

    promotion_uid: str
    strap_line: str | None = None
    start_date: str | None = None
    end_date: str | None = None
    original_price: float | None = None
    is_nectar: bool = False
    promo_type: str | None = None
    promo_group: str | None = None
    promo_mechanic_id: str | None = None
    icon: str | None = None
    link: str | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> Promotion | None:
        """Parse a promotion from grocery API JSON."""
        if not data:
            return None
        promotion_uid = data.get("promotion_uid")
        if not promotion_uid and not data.get("strap_line"):
            return None
        original_price = data.get("original_price")
        mechanic_id = data.get("promo_mechanic_id")
        return cls(
            promotion_uid=str(promotion_uid or ""),
            strap_line=data.get("strap_line"),
            start_date=data.get("start_date"),
            end_date=data.get("end_date"),
            original_price=(
                float(original_price) if original_price is not None else None
            ),
            is_nectar=bool(data.get("is_nectar", False)),
            promo_type=data.get("promo_type"),
            promo_group=data.get("promo_group"),
            promo_mechanic_id=str(mechanic_id) if mechanic_id is not None else None,
            icon=data.get("icon") or None,
            link=data.get("link"),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the promotion to a plain dictionary."""
        return {
            "promotion_uid": self.promotion_uid,
            "strap_line": self.strap_line,
            "start_date": self.start_date,
            "end_date": self.end_date,
            "original_price": self.original_price,
            "is_nectar": self.is_nectar,
            "promo_type": self.promo_type,
            "promo_group": self.promo_group,
            "promo_mechanic_id": self.promo_mechanic_id,
            "icon": self.icon,
            "link": self.link,
        }

from_dict(data) classmethod

Parse a promotion from grocery API JSON.

Source code in pysainsburys/models/product/product.py
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> Promotion | None:
    """Parse a promotion from grocery API JSON."""
    if not data:
        return None
    promotion_uid = data.get("promotion_uid")
    if not promotion_uid and not data.get("strap_line"):
        return None
    original_price = data.get("original_price")
    mechanic_id = data.get("promo_mechanic_id")
    return cls(
        promotion_uid=str(promotion_uid or ""),
        strap_line=data.get("strap_line"),
        start_date=data.get("start_date"),
        end_date=data.get("end_date"),
        original_price=(
            float(original_price) if original_price is not None else None
        ),
        is_nectar=bool(data.get("is_nectar", False)),
        promo_type=data.get("promo_type"),
        promo_group=data.get("promo_group"),
        promo_mechanic_id=str(mechanic_id) if mechanic_id is not None else None,
        icon=data.get("icon") or None,
        link=data.get("link"),
    )

to_dict()

Serialise the promotion to a plain dictionary.

Source code in pysainsburys/models/product/product.py
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
def to_dict(self) -> dict[str, Any]:
    """Serialise the promotion to a plain dictionary."""
    return {
        "promotion_uid": self.promotion_uid,
        "strap_line": self.strap_line,
        "start_date": self.start_date,
        "end_date": self.end_date,
        "original_price": self.original_price,
        "is_nectar": self.is_nectar,
        "promo_type": self.promo_type,
        "promo_group": self.promo_group,
        "promo_mechanic_id": self.promo_mechanic_id,
        "icon": self.icon,
        "link": self.link,
    }

bind_product(api, product)

Attach an API client to a product for basket and favourites operations.

Source code in pysainsburys/models/product/product.py
614
615
616
def bind_product(api: API, product: Product) -> Product:
    """Attach an API client to a product for basket and favourites operations."""
    return product.bind_api(api)

bind_products(api, products)

Attach an API client to each product in a list.

Source code in pysainsburys/models/product/product.py
619
620
621
622
623
def bind_products(api: API, products: list[Product]) -> list[Product]:
    """Attach an API client to each product in a list."""
    for product in products:
        bind_product(api, product)
    return products

decode_details_html(details_html)

Decode the base64 product details_html field.

Source code in pysainsburys/models/product/nutrition.py
187
188
189
190
191
192
193
194
def decode_details_html(details_html: str | None) -> str | None:
    """Decode the base64 product ``details_html`` field."""
    if not details_html:
        return None
    try:
        return base64.b64decode(details_html).decode("utf-8")
    except (ValueError, UnicodeDecodeError):
        return None

parse_nutrition(html)

Parse nutrition information from decoded product detail HTML.

Source code in pysainsburys/models/product/nutrition.py
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
def parse_nutrition(html: str | None) -> NutritionInfo | None:
    """Parse nutrition information from decoded product detail HTML."""
    if not html or (
        "nutritionTable" not in html and "nutritionalContentSummary" not in html
    ):
        return None

    summary, notes = _parse_summary(html)
    tables: list[NutritionTable] = []
    for table_html in re.findall(
        r'<table class="nutritionTable">.*?</table>',
        html,
        re.DOTALL | re.IGNORECASE,
    ):
        tables.append(_parse_table(html, table_html))

    if not summary and not tables and not notes:
        return None
    return NutritionInfo(summary=summary, tables=tables, notes=notes)

parse_nutrition_from_details_html(details_html)

Parse nutrition information from a product details_html field.

Source code in pysainsburys/models/product/nutrition.py
288
289
290
def parse_nutrition_from_details_html(details_html: str | None) -> NutritionInfo | None:
    """Parse nutrition information from a product ``details_html`` field."""
    return parse_nutrition(decode_details_html(details_html))

parse_product_details(html)

Parse product-text sections from decoded product detail HTML.

Source code in pysainsburys/models/product/details.py
205
206
207
208
209
210
211
212
213
214
def parse_product_details(html: str | None) -> ProductDetails | None:
    """Parse product-text sections from decoded product detail HTML."""
    if not html or "partHead" not in html:
        return None
    parser = _ProductTextParser()
    parser.feed(html)
    parser.close()
    if not parser.sections:
        return None
    return ProductDetails(**parser.sections)

parse_product_details_from_details_html(details_html)

Parse product-text sections from a base64 details_html field.

Source code in pysainsburys/models/product/details.py
217
218
219
220
221
def parse_product_details_from_details_html(
    details_html: str | None,
) -> ProductDetails | None:
    """Parse product-text sections from a base64 ``details_html`` field."""
    return parse_product_details(decode_details_html(details_html))

pysainsburys.models.product.product.Product dataclass

A grocery product from the online catalogue.

When bound to a :class:~pysainsburys.Sainsburys client, a product can mutate the authenticated customer's basket directly via :meth:add_to_basket, :meth:set_basket_quantity, and :meth:remove_from_basket.

Nutrition data is parsed automatically from details_html when present on the API response (see :attr:nutrition). The same HTML also supplies description, storage, and related copy on :attr:details. Search results omit details_html, so those sections stay empty until the product is loaded with :meth:~pysainsburys.Sainsburys.get_product.

Attributes:

Name Type Description
product_uid str

Stable Sainsbury's product identifier.

name str

Display name shown on the website and app.

sain_id str | None

Legacy SAIN identifier when returned by the API.

is_favourite bool

Whether the product is in the signed-in customer's favourites list.

favourite_type str | None

Favourite list type when provided by the API.

product_type str | None

Product classification string from the API.

eans list[str]

European article numbers associated with the product.

unit_price Price | None

Price per unit of measure, when available.

retail_price Price | None

Shelf price for the purchasable quantity.

is_available bool

Whether the product can be added to a basket.

is_alcoholic bool

Whether age-restricted checks apply.

reviews ProductReviews | None

Aggregated review metadata.

image_url str | None

Product listing image URL.

nutrition NutritionInfo | None

Parsed nutrition tables and traffic-light summary.

details ProductDetails | None

Description, storage, and other product-text sections.

promotions list[Promotion]

Catalogue offers attached to the product.

nectar_price NectarPrice | None

Nectar member price when the product has one.

favourite_uid str | None

Favourite-list identifier when the product is saved.

short_description str | None

One-line summary from the product payload.

full_url str | None

Absolute product page URL.

original_unit_price Price | None

Unit price before a promotion, when the API returns one.

image str | None

Large product image URL.

image_thumbnail str | None

Medium product image URL.

image_thumbnail_small str | None

Small product image URL.

image_zoom str | None

Zoom image URL when provided.

images list[ProductImage]

Sized image variants from the assets block.

zone str | None

Merchandising zone, such as Drinks.

department str | None

Department name when the API returns one.

labels list[ProductLabel]

Merchandising labels such as British or Chilled.

categories list[ProductCategory]

Catalogue categories that include the product.

breadcrumbs list[ProductBreadcrumb]

Breadcrumb trail for the product page.

attributes dict[str, list[str]]

Attribute groups from the API, including brand.

header ProductHeader | None

Promotional header, such as a Nectar price banner.

is_spotlight bool

Whether the product is flagged as featured.

spotlight_label str | None

Featured label when is_spotlight is set.

not_for_eu bool

Whether the product is marked not for EU sale.

is_intolerant bool

Whether the product carries an intolerance flag.

is_mhra bool

Whether MHRA restrictions apply.

is_supply_chain_orderable bool

Whether supply-chain ordering is enabled.

display_icons list[str]

Icon identifiers shown on the product.

health_rating str | None

Health rating score from health_classification.

hfss_restrictions list[HfssRestriction]

HFSS advertising restrictions by UK nation.

pdp_deep_link str | None

Legacy product-display path.

average_weight AverageWeight | None

Typical weight for a loose product.

promise ProductPromise | None

Delivery promise when a slot context is present.

Source code in pysainsburys/models/product/product.py
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
@dataclass(slots=True)
class Product:
    """
    A grocery product from the online catalogue.

    When bound to a :class:`~pysainsburys.Sainsburys` client, a product can
    mutate the authenticated customer's basket directly via
    :meth:`add_to_basket`, :meth:`set_basket_quantity`, and
    :meth:`remove_from_basket`.

    Nutrition data is parsed automatically from ``details_html`` when present
    on the API response (see :attr:`nutrition`). The same HTML also supplies
    description, storage, and related copy on :attr:`details`. Search results
    omit ``details_html``, so those sections stay empty until the product is
    loaded with :meth:`~pysainsburys.Sainsburys.get_product`.

    Attributes:
        product_uid: Stable Sainsbury's product identifier.
        name: Display name shown on the website and app.
        sain_id: Legacy SAIN identifier when returned by the API.
        is_favourite: Whether the product is in the signed-in customer's
            favourites list.
        favourite_type: Favourite list type when provided by the API.
        product_type: Product classification string from the API.
        eans: European article numbers associated with the product.
        unit_price: Price per unit of measure, when available.
        retail_price: Shelf price for the purchasable quantity.
        is_available: Whether the product can be added to a basket.
        is_alcoholic: Whether age-restricted checks apply.
        reviews: Aggregated review metadata.
        image_url: Product listing image URL.
        nutrition: Parsed nutrition tables and traffic-light summary.
        details: Description, storage, and other product-text sections.
        promotions: Catalogue offers attached to the product.
        nectar_price: Nectar member price when the product has one.
        favourite_uid: Favourite-list identifier when the product is saved.
        short_description: One-line summary from the product payload.
        full_url: Absolute product page URL.
        original_unit_price: Unit price before a promotion, when the API
            returns one.
        image: Large product image URL.
        image_thumbnail: Medium product image URL.
        image_thumbnail_small: Small product image URL.
        image_zoom: Zoom image URL when provided.
        images: Sized image variants from the assets block.
        zone: Merchandising zone, such as ``Drinks``.
        department: Department name when the API returns one.
        labels: Merchandising labels such as British or Chilled.
        categories: Catalogue categories that include the product.
        breadcrumbs: Breadcrumb trail for the product page.
        attributes: Attribute groups from the API, including brand.
        header: Promotional header, such as a Nectar price banner.
        is_spotlight: Whether the product is flagged as featured.
        spotlight_label: Featured label when ``is_spotlight`` is set.
        not_for_eu: Whether the product is marked not for EU sale.
        is_intolerant: Whether the product carries an intolerance flag.
        is_mhra: Whether MHRA restrictions apply.
        is_supply_chain_orderable: Whether supply-chain ordering is enabled.
        display_icons: Icon identifiers shown on the product.
        health_rating: Health rating score from ``health_classification``.
        hfss_restrictions: HFSS advertising restrictions by UK nation.
        pdp_deep_link: Legacy product-display path.
        average_weight: Typical weight for a loose product.
        promise: Delivery promise when a slot context is present.

    """

    product_uid: str
    name: str
    sain_id: str | None = None
    is_favourite: bool = False
    favourite_type: str | None = None
    product_type: str | None = None
    eans: list[str] = field(default_factory=list)
    unit_price: Price | None = None
    retail_price: Price | None = None
    is_available: bool = True
    is_alcoholic: bool = False
    reviews: ProductReviews | None = None
    image_url: str | None = None
    nutrition: NutritionInfo | None = None
    details: ProductDetails | None = None
    promotions: list[Promotion] = field(default_factory=list)
    nectar_price: NectarPrice | None = None
    favourite_uid: str | None = None
    short_description: str | None = None
    full_url: str | None = None
    original_unit_price: Price | None = None
    image: str | None = None
    image_thumbnail: str | None = None
    image_thumbnail_small: str | None = None
    image_zoom: str | None = None
    images: list[ProductImage] = field(default_factory=list)
    zone: str | None = None
    department: str | None = None
    labels: list[ProductLabel] = field(default_factory=list)
    categories: list[ProductCategory] = field(default_factory=list)
    breadcrumbs: list[ProductBreadcrumb] = field(default_factory=list)
    attributes: dict[str, list[str]] = field(default_factory=dict)
    header: ProductHeader | None = None
    is_spotlight: bool = False
    spotlight_label: str | None = None
    not_for_eu: bool = False
    is_intolerant: bool = False
    is_mhra: bool = False
    is_supply_chain_orderable: bool = False
    display_icons: list[str] = field(default_factory=list)
    health_rating: str | None = None
    hfss_restrictions: list[HfssRestriction] = field(default_factory=list)
    pdp_deep_link: str | None = None
    average_weight: AverageWeight | None = None
    promise: ProductPromise | None = None
    _api: API | None = field(default=None, repr=False, compare=False, hash=False)

    @classmethod
    def from_dict(cls, data: dict[str, Any], *, api: API | None = None) -> Product:
        """Parse a product from grocery API JSON."""
        assets_raw = data.get("assets")
        assets: dict[str, Any] = assets_raw if isinstance(assets_raw, dict) else {}
        details_html = data.get("details_html")
        if not isinstance(details_html, str):
            details_html = None
        header_raw = data.get("header")
        header = header_raw if isinstance(header_raw, dict) else None
        weight = data.get("average_weight")
        promise_raw = data.get("promise")
        promise = promise_raw if isinstance(promise_raw, dict) else None
        return cls(
            product_uid=str(data.get("product_uid") or data.get("uid") or ""),
            name=str(data.get("name", "")),
            sain_id=data.get("sainId") or data.get("sain_id"),
            is_favourite=bool(data.get("is_favourite", False)),
            favourite_type=data.get("favourite_type"),
            product_type=data.get("product_type"),
            eans=[str(ean) for ean in data.get("eans", [])],
            unit_price=Price.from_dict(data.get("unit_price")),
            retail_price=Price.from_dict(data.get("retail_price")),
            is_available=bool(data.get("is_available", True)),
            is_alcoholic=bool(data.get("is_alcoholic", False)),
            reviews=ProductReviews.from_dict(data.get("reviews")),
            image_url=text(assets.get("plp_image")),
            nutrition=parse_nutrition_from_details_html(details_html),
            details=product_details_from_api(details_html, data.get("description")),
            promotions=_promotions_from_api(data),
            nectar_price=NectarPrice.from_dict(
                data["nectar_price"]
                if isinstance(data.get("nectar_price"), dict)
                else None
            ),
            favourite_uid=text(data.get("favourite_uid")),
            short_description=text(data.get("short_description")),
            full_url=page_url(data.get("full_url")),
            original_unit_price=Price.from_dict(
                data["original_unit_price"]
                if isinstance(data.get("original_unit_price"), dict)
                else None
            ),
            image=text(data.get("image")),
            image_thumbnail=text(data.get("image_thumbnail")),
            image_thumbnail_small=text(data.get("image_thumbnail_small")),
            image_zoom=text(data.get("image_zoom")),
            images=images_from_api(assets),
            zone=text(data.get("zone")),
            department=text(data.get("department")),
            labels=labels_from_api(data),
            categories=categories_from_api(data),
            breadcrumbs=breadcrumbs_from_api(data),
            attributes=attributes_from_api(data),
            header=ProductHeader.from_dict(header),
            is_spotlight=bool(data.get("is_spotlight", False)),
            spotlight_label=text(data.get("spotlight_label")),
            not_for_eu=bool(data.get("not_for_eu", False)),
            is_intolerant=bool(data.get("is_intolerant", False)),
            is_mhra=bool(data.get("is_mhra", False)),
            is_supply_chain_orderable=bool(
                data.get("is_supply_chain_orderable", False)
            ),
            display_icons=string_list(data.get("display_icons")),
            health_rating=health_rating_from_api(data),
            hfss_restrictions=hfss_from_api(data),
            pdp_deep_link=text(data.get("pdp_deep_link")),
            average_weight=AverageWeight.from_dict(
                weight if isinstance(weight, dict) else None
            ),
            promise=ProductPromise.from_dict(promise),
            _api=api,
        )

    @property
    def brand(self) -> list[str]:
        """Brand names from the product attributes."""
        return list(self.attributes.get("brand", []))

    @classmethod
    def from_basket_nested(
        cls,
        data: dict[str, Any],
        *,
        api: API | None = None,
    ) -> Product:
        """Parse a product object nested inside a basket line item."""
        payload = dict(data)
        if payload.get("sku") and not payload.get("product_uid"):
            payload["product_uid"] = payload["sku"]
        return cls.from_dict(payload, api=api)

    def _require_api(self) -> API:
        if self._api is None:
            msg = (
                "Product is not bound to a Sainsburys client; "
                "fetch it via Sainsburys.get_product() or search_products()."
            )
            raise NotBoundError(msg)
        return self._api

    def bind_api(self, api: API) -> Product:
        """Attach a client for basket and favourites operations."""
        self._api = api
        return self

    def _default_uom(self) -> str:
        if self.retail_price and self.retail_price.measure:
            return self.retail_price.measure
        return "ea"

    async def add_to_basket(
        self,
        quantity: float = 1.0,
        *,
        selected_catchweight: str | None = None,
        uom: str | None = None,
    ) -> Basket:
        """Add this product to the basket (POST increment)."""
        if quantity <= 0:
            return await self.remove_from_basket()
        api = self._require_api()
        body: dict[str, Any] = {
            "product_uid": self.product_uid,
            "quantity": quantity,
            "uom": uom or self._default_uom(),
        }
        if selected_catchweight is not None:
            body["selected_catchweight"] = selected_catchweight
        response = await api.send_request(endpoint="add_basket_item", body=body)
        return basket_from_response(response)

    async def _resolve_basket_item_uid(self, item_uid: str | None) -> str:
        """Resolve a basket line uid without importing basket at module load."""
        from ...basket import resolve_basket_item_uid

        return await resolve_basket_item_uid(
            self._require_api(),
            self.product_uid,
            item_uid,
        )

    async def set_basket_quantity(
        self,
        quantity: float,
        *,
        item_uid: str | None = None,
        selected_catchweight: str | None = None,
        uom: str | None = None,
    ) -> Basket:
        """Set the absolute basket quantity for this product."""
        if quantity <= 0:
            return await self.remove_from_basket(item_uid=item_uid)
        api = self._require_api()
        resolved_item_uid = await self._resolve_basket_item_uid(item_uid)
        item: dict[str, Any] = {
            "product_uid": self.product_uid,
            "quantity": quantity,
            "uom": uom or self._default_uom(),
            "item_uid": resolved_item_uid,
        }
        if selected_catchweight is not None:
            item["selected_catchweight"] = selected_catchweight
        response = await api.send_request(
            endpoint="update_basket",
            body={"items": [item]},
        )
        return basket_from_response(response)

    async def remove_from_basket(
        self,
        *,
        item_uid: str | None = None,
        force_delete: bool = False,
    ) -> Basket:
        """Remove this product from the basket."""
        del force_delete
        resolved_item_uid = await self._resolve_basket_item_uid(item_uid)
        response = await self._require_api().send_request(
            endpoint="update_basket",
            body={
                "items": [
                    {
                        "product_uid": self.product_uid,
                        "quantity": 0,
                        "uom": "ea",
                        "item_uid": resolved_item_uid,
                    }
                ]
            },
        )
        return basket_from_response(response)

    def to_dict(self) -> dict[str, Any]:
        """Serialise the product to a plain dictionary."""
        return {
            "product_uid": self.product_uid,
            "name": self.name,
            "sain_id": self.sain_id,
            "is_favourite": self.is_favourite,
            "favourite_type": self.favourite_type,
            "product_type": self.product_type,
            "eans": self.eans,
            "unit_price": self.unit_price.to_dict() if self.unit_price else None,
            "retail_price": self.retail_price.to_dict() if self.retail_price else None,
            "is_available": self.is_available,
            "is_alcoholic": self.is_alcoholic,
            "reviews": self.reviews.to_dict() if self.reviews else None,
            "image_url": self.image_url,
            "nutrition": self.nutrition.to_dict() if self.nutrition else None,
            "details": self.details.to_dict() if self.details else None,
            "promotions": [promotion.to_dict() for promotion in self.promotions],
            "nectar_price": (
                self.nectar_price.to_dict() if self.nectar_price else None
            ),
            "favourite_uid": self.favourite_uid,
            "short_description": self.short_description,
            "full_url": self.full_url,
            "original_unit_price": (
                self.original_unit_price.to_dict() if self.original_unit_price else None
            ),
            "image": self.image,
            "image_thumbnail": self.image_thumbnail,
            "image_thumbnail_small": self.image_thumbnail_small,
            "image_zoom": self.image_zoom,
            "images": [image.to_dict() for image in self.images],
            "zone": self.zone,
            "department": self.department,
            "labels": [label.to_dict() for label in self.labels],
            "categories": [category.to_dict() for category in self.categories],
            "breadcrumbs": [crumb.to_dict() for crumb in self.breadcrumbs],
            "attributes": self.attributes,
            "brand": self.brand,
            "header": self.header.to_dict() if self.header else None,
            "is_spotlight": self.is_spotlight,
            "spotlight_label": self.spotlight_label,
            "not_for_eu": self.not_for_eu,
            "is_intolerant": self.is_intolerant,
            "is_mhra": self.is_mhra,
            "is_supply_chain_orderable": self.is_supply_chain_orderable,
            "display_icons": self.display_icons,
            "health_rating": self.health_rating,
            "hfss_restrictions": [
                restriction.to_dict() for restriction in self.hfss_restrictions
            ],
            "pdp_deep_link": self.pdp_deep_link,
            "average_weight": (
                self.average_weight.to_dict() if self.average_weight else None
            ),
            "promise": self.promise.to_dict() if self.promise else None,
        }

    def __iter__(self) -> Iterator[tuple[str, Any]]:
        """Allow ``dict(product)`` conversion."""
        return iter(self.to_dict().items())

brand property

Brand names from the product attributes.

__iter__()

Allow dict(product) conversion.

Source code in pysainsburys/models/product/product.py
581
582
583
def __iter__(self) -> Iterator[tuple[str, Any]]:
    """Allow ``dict(product)`` conversion."""
    return iter(self.to_dict().items())

add_to_basket(quantity=1.0, *, selected_catchweight=None, uom=None) async

Add this product to the basket (POST increment).

Source code in pysainsburys/models/product/product.py
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
async def add_to_basket(
    self,
    quantity: float = 1.0,
    *,
    selected_catchweight: str | None = None,
    uom: str | None = None,
) -> Basket:
    """Add this product to the basket (POST increment)."""
    if quantity <= 0:
        return await self.remove_from_basket()
    api = self._require_api()
    body: dict[str, Any] = {
        "product_uid": self.product_uid,
        "quantity": quantity,
        "uom": uom or self._default_uom(),
    }
    if selected_catchweight is not None:
        body["selected_catchweight"] = selected_catchweight
    response = await api.send_request(endpoint="add_basket_item", body=body)
    return basket_from_response(response)

bind_api(api)

Attach a client for basket and favourites operations.

Source code in pysainsburys/models/product/product.py
430
431
432
433
def bind_api(self, api: API) -> Product:
    """Attach a client for basket and favourites operations."""
    self._api = api
    return self

from_basket_nested(data, *, api=None) classmethod

Parse a product object nested inside a basket line item.

Source code in pysainsburys/models/product/product.py
408
409
410
411
412
413
414
415
416
417
418
419
@classmethod
def from_basket_nested(
    cls,
    data: dict[str, Any],
    *,
    api: API | None = None,
) -> Product:
    """Parse a product object nested inside a basket line item."""
    payload = dict(data)
    if payload.get("sku") and not payload.get("product_uid"):
        payload["product_uid"] = payload["sku"]
    return cls.from_dict(payload, api=api)

from_dict(data, *, api=None) classmethod

Parse a product from grocery API JSON.

Source code in pysainsburys/models/product/product.py
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
@classmethod
def from_dict(cls, data: dict[str, Any], *, api: API | None = None) -> Product:
    """Parse a product from grocery API JSON."""
    assets_raw = data.get("assets")
    assets: dict[str, Any] = assets_raw if isinstance(assets_raw, dict) else {}
    details_html = data.get("details_html")
    if not isinstance(details_html, str):
        details_html = None
    header_raw = data.get("header")
    header = header_raw if isinstance(header_raw, dict) else None
    weight = data.get("average_weight")
    promise_raw = data.get("promise")
    promise = promise_raw if isinstance(promise_raw, dict) else None
    return cls(
        product_uid=str(data.get("product_uid") or data.get("uid") or ""),
        name=str(data.get("name", "")),
        sain_id=data.get("sainId") or data.get("sain_id"),
        is_favourite=bool(data.get("is_favourite", False)),
        favourite_type=data.get("favourite_type"),
        product_type=data.get("product_type"),
        eans=[str(ean) for ean in data.get("eans", [])],
        unit_price=Price.from_dict(data.get("unit_price")),
        retail_price=Price.from_dict(data.get("retail_price")),
        is_available=bool(data.get("is_available", True)),
        is_alcoholic=bool(data.get("is_alcoholic", False)),
        reviews=ProductReviews.from_dict(data.get("reviews")),
        image_url=text(assets.get("plp_image")),
        nutrition=parse_nutrition_from_details_html(details_html),
        details=product_details_from_api(details_html, data.get("description")),
        promotions=_promotions_from_api(data),
        nectar_price=NectarPrice.from_dict(
            data["nectar_price"]
            if isinstance(data.get("nectar_price"), dict)
            else None
        ),
        favourite_uid=text(data.get("favourite_uid")),
        short_description=text(data.get("short_description")),
        full_url=page_url(data.get("full_url")),
        original_unit_price=Price.from_dict(
            data["original_unit_price"]
            if isinstance(data.get("original_unit_price"), dict)
            else None
        ),
        image=text(data.get("image")),
        image_thumbnail=text(data.get("image_thumbnail")),
        image_thumbnail_small=text(data.get("image_thumbnail_small")),
        image_zoom=text(data.get("image_zoom")),
        images=images_from_api(assets),
        zone=text(data.get("zone")),
        department=text(data.get("department")),
        labels=labels_from_api(data),
        categories=categories_from_api(data),
        breadcrumbs=breadcrumbs_from_api(data),
        attributes=attributes_from_api(data),
        header=ProductHeader.from_dict(header),
        is_spotlight=bool(data.get("is_spotlight", False)),
        spotlight_label=text(data.get("spotlight_label")),
        not_for_eu=bool(data.get("not_for_eu", False)),
        is_intolerant=bool(data.get("is_intolerant", False)),
        is_mhra=bool(data.get("is_mhra", False)),
        is_supply_chain_orderable=bool(
            data.get("is_supply_chain_orderable", False)
        ),
        display_icons=string_list(data.get("display_icons")),
        health_rating=health_rating_from_api(data),
        hfss_restrictions=hfss_from_api(data),
        pdp_deep_link=text(data.get("pdp_deep_link")),
        average_weight=AverageWeight.from_dict(
            weight if isinstance(weight, dict) else None
        ),
        promise=ProductPromise.from_dict(promise),
        _api=api,
    )

remove_from_basket(*, item_uid=None, force_delete=False) async

Remove this product from the basket.

Source code in pysainsburys/models/product/product.py
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
async def remove_from_basket(
    self,
    *,
    item_uid: str | None = None,
    force_delete: bool = False,
) -> Basket:
    """Remove this product from the basket."""
    del force_delete
    resolved_item_uid = await self._resolve_basket_item_uid(item_uid)
    response = await self._require_api().send_request(
        endpoint="update_basket",
        body={
            "items": [
                {
                    "product_uid": self.product_uid,
                    "quantity": 0,
                    "uom": "ea",
                    "item_uid": resolved_item_uid,
                }
            ]
        },
    )
    return basket_from_response(response)

set_basket_quantity(quantity, *, item_uid=None, selected_catchweight=None, uom=None) async

Set the absolute basket quantity for this product.

Source code in pysainsburys/models/product/product.py
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
async def set_basket_quantity(
    self,
    quantity: float,
    *,
    item_uid: str | None = None,
    selected_catchweight: str | None = None,
    uom: str | None = None,
) -> Basket:
    """Set the absolute basket quantity for this product."""
    if quantity <= 0:
        return await self.remove_from_basket(item_uid=item_uid)
    api = self._require_api()
    resolved_item_uid = await self._resolve_basket_item_uid(item_uid)
    item: dict[str, Any] = {
        "product_uid": self.product_uid,
        "quantity": quantity,
        "uom": uom or self._default_uom(),
        "item_uid": resolved_item_uid,
    }
    if selected_catchweight is not None:
        item["selected_catchweight"] = selected_catchweight
    response = await api.send_request(
        endpoint="update_basket",
        body={"items": [item]},
    )
    return basket_from_response(response)

to_dict()

Serialise the product to a plain dictionary.

Source code in pysainsburys/models/product/product.py
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
def to_dict(self) -> dict[str, Any]:
    """Serialise the product to a plain dictionary."""
    return {
        "product_uid": self.product_uid,
        "name": self.name,
        "sain_id": self.sain_id,
        "is_favourite": self.is_favourite,
        "favourite_type": self.favourite_type,
        "product_type": self.product_type,
        "eans": self.eans,
        "unit_price": self.unit_price.to_dict() if self.unit_price else None,
        "retail_price": self.retail_price.to_dict() if self.retail_price else None,
        "is_available": self.is_available,
        "is_alcoholic": self.is_alcoholic,
        "reviews": self.reviews.to_dict() if self.reviews else None,
        "image_url": self.image_url,
        "nutrition": self.nutrition.to_dict() if self.nutrition else None,
        "details": self.details.to_dict() if self.details else None,
        "promotions": [promotion.to_dict() for promotion in self.promotions],
        "nectar_price": (
            self.nectar_price.to_dict() if self.nectar_price else None
        ),
        "favourite_uid": self.favourite_uid,
        "short_description": self.short_description,
        "full_url": self.full_url,
        "original_unit_price": (
            self.original_unit_price.to_dict() if self.original_unit_price else None
        ),
        "image": self.image,
        "image_thumbnail": self.image_thumbnail,
        "image_thumbnail_small": self.image_thumbnail_small,
        "image_zoom": self.image_zoom,
        "images": [image.to_dict() for image in self.images],
        "zone": self.zone,
        "department": self.department,
        "labels": [label.to_dict() for label in self.labels],
        "categories": [category.to_dict() for category in self.categories],
        "breadcrumbs": [crumb.to_dict() for crumb in self.breadcrumbs],
        "attributes": self.attributes,
        "brand": self.brand,
        "header": self.header.to_dict() if self.header else None,
        "is_spotlight": self.is_spotlight,
        "spotlight_label": self.spotlight_label,
        "not_for_eu": self.not_for_eu,
        "is_intolerant": self.is_intolerant,
        "is_mhra": self.is_mhra,
        "is_supply_chain_orderable": self.is_supply_chain_orderable,
        "display_icons": self.display_icons,
        "health_rating": self.health_rating,
        "hfss_restrictions": [
            restriction.to_dict() for restriction in self.hfss_restrictions
        ],
        "pdp_deep_link": self.pdp_deep_link,
        "average_weight": (
            self.average_weight.to_dict() if self.average_weight else None
        ),
        "promise": self.promise.to_dict() if self.promise else None,
    }

pysainsburys.models.product.product.ProductList dataclass

A paginated list of catalogue products.

Source code in pysainsburys/models/product/product.py
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
@dataclass(slots=True)
class ProductList:
    """A paginated list of catalogue products."""

    products: list[Product]
    controls: PageControls

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> ProductList:
        """Parse a paginated product list from grocery API JSON."""
        products = [Product.from_dict(item) for item in data.get("products", [])]
        return cls(
            products=products,
            controls=PageControls.from_dict(data.get("controls")),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the product list to a plain dictionary."""
        return {
            "products": [product.to_dict() for product in self.products],
            "controls": self.controls.to_dict(),
        }

    def __iter__(self) -> Iterator[tuple[str, Any]]:
        """Allow ``dict(product_list)`` conversion."""
        return iter(self.to_dict().items())

__iter__()

Allow dict(product_list) conversion.

Source code in pysainsburys/models/product/product.py
609
610
611
def __iter__(self) -> Iterator[tuple[str, Any]]:
    """Allow ``dict(product_list)`` conversion."""
    return iter(self.to_dict().items())

from_dict(data) classmethod

Parse a paginated product list from grocery API JSON.

Source code in pysainsburys/models/product/product.py
593
594
595
596
597
598
599
600
@classmethod
def from_dict(cls, data: dict[str, Any]) -> ProductList:
    """Parse a paginated product list from grocery API JSON."""
    products = [Product.from_dict(item) for item in data.get("products", [])]
    return cls(
        products=products,
        controls=PageControls.from_dict(data.get("controls")),
    )

to_dict()

Serialise the product list to a plain dictionary.

Source code in pysainsburys/models/product/product.py
602
603
604
605
606
607
def to_dict(self) -> dict[str, Any]:
    """Serialise the product list to a plain dictionary."""
    return {
        "products": [product.to_dict() for product in self.products],
        "controls": self.controls.to_dict(),
    }

pysainsburys.models.product.product.Promotion dataclass

A catalogue promotion attached to a product.

Attributes:

Name Type Description
promotion_uid str

Promotion identifier.

strap_line str | None

Customer-facing offer text, such as Buy 1 for 3.

start_date str | None

Offer start timestamp from the API.

end_date str | None

Offer end timestamp from the API.

original_price float | None

Shelf price before the promotion, in pounds sterling.

is_nectar bool

Whether the offer is a Nectar price.

promo_type str | None

Promotion mechanic type from the API.

promo_group str | None

Promotion grouping from the API.

promo_mechanic_id str | None

Mechanic identifier from the API.

icon str | None

Promotion icon URL when provided.

link str | None

Relative link to the promotion lister.

Source code in pysainsburys/models/product/product.py
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
@dataclass(slots=True)
class Promotion:
    """
    A catalogue promotion attached to a product.

    Attributes:
        promotion_uid: Promotion identifier.
        strap_line: Customer-facing offer text, such as ``Buy 1 for 3``.
        start_date: Offer start timestamp from the API.
        end_date: Offer end timestamp from the API.
        original_price: Shelf price before the promotion, in pounds sterling.
        is_nectar: Whether the offer is a Nectar price.
        promo_type: Promotion mechanic type from the API.
        promo_group: Promotion grouping from the API.
        promo_mechanic_id: Mechanic identifier from the API.
        icon: Promotion icon URL when provided.
        link: Relative link to the promotion lister.

    """

    promotion_uid: str
    strap_line: str | None = None
    start_date: str | None = None
    end_date: str | None = None
    original_price: float | None = None
    is_nectar: bool = False
    promo_type: str | None = None
    promo_group: str | None = None
    promo_mechanic_id: str | None = None
    icon: str | None = None
    link: str | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> Promotion | None:
        """Parse a promotion from grocery API JSON."""
        if not data:
            return None
        promotion_uid = data.get("promotion_uid")
        if not promotion_uid and not data.get("strap_line"):
            return None
        original_price = data.get("original_price")
        mechanic_id = data.get("promo_mechanic_id")
        return cls(
            promotion_uid=str(promotion_uid or ""),
            strap_line=data.get("strap_line"),
            start_date=data.get("start_date"),
            end_date=data.get("end_date"),
            original_price=(
                float(original_price) if original_price is not None else None
            ),
            is_nectar=bool(data.get("is_nectar", False)),
            promo_type=data.get("promo_type"),
            promo_group=data.get("promo_group"),
            promo_mechanic_id=str(mechanic_id) if mechanic_id is not None else None,
            icon=data.get("icon") or None,
            link=data.get("link"),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the promotion to a plain dictionary."""
        return {
            "promotion_uid": self.promotion_uid,
            "strap_line": self.strap_line,
            "start_date": self.start_date,
            "end_date": self.end_date,
            "original_price": self.original_price,
            "is_nectar": self.is_nectar,
            "promo_type": self.promo_type,
            "promo_group": self.promo_group,
            "promo_mechanic_id": self.promo_mechanic_id,
            "icon": self.icon,
            "link": self.link,
        }

from_dict(data) classmethod

Parse a promotion from grocery API JSON.

Source code in pysainsburys/models/product/product.py
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> Promotion | None:
    """Parse a promotion from grocery API JSON."""
    if not data:
        return None
    promotion_uid = data.get("promotion_uid")
    if not promotion_uid and not data.get("strap_line"):
        return None
    original_price = data.get("original_price")
    mechanic_id = data.get("promo_mechanic_id")
    return cls(
        promotion_uid=str(promotion_uid or ""),
        strap_line=data.get("strap_line"),
        start_date=data.get("start_date"),
        end_date=data.get("end_date"),
        original_price=(
            float(original_price) if original_price is not None else None
        ),
        is_nectar=bool(data.get("is_nectar", False)),
        promo_type=data.get("promo_type"),
        promo_group=data.get("promo_group"),
        promo_mechanic_id=str(mechanic_id) if mechanic_id is not None else None,
        icon=data.get("icon") or None,
        link=data.get("link"),
    )

to_dict()

Serialise the promotion to a plain dictionary.

Source code in pysainsburys/models/product/product.py
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
def to_dict(self) -> dict[str, Any]:
    """Serialise the promotion to a plain dictionary."""
    return {
        "promotion_uid": self.promotion_uid,
        "strap_line": self.strap_line,
        "start_date": self.start_date,
        "end_date": self.end_date,
        "original_price": self.original_price,
        "is_nectar": self.is_nectar,
        "promo_type": self.promo_type,
        "promo_group": self.promo_group,
        "promo_mechanic_id": self.promo_mechanic_id,
        "icon": self.icon,
        "link": self.link,
    }

pysainsburys.models.product.product.NectarPrice dataclass

Nectar member price for a product.

Attributes:

Name Type Description
retail_price float

Nectar price for the purchasable quantity.

unit_price float | None

Nectar price per unit of measure, when provided.

measure str | None

Unit label for unit_price.

url str | None

Link to the Nectar prices listing.

category_seo_url str | None

SEO path for the Nectar prices category.

Source code in pysainsburys/models/product/product.py
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
@dataclass(slots=True)
class NectarPrice:
    """
    Nectar member price for a product.

    Attributes:
        retail_price: Nectar price for the purchasable quantity.
        unit_price: Nectar price per unit of measure, when provided.
        measure: Unit label for ``unit_price``.
        url: Link to the Nectar prices listing.
        category_seo_url: SEO path for the Nectar prices category.

    """

    retail_price: float
    unit_price: float | None = None
    measure: str | None = None
    url: str | None = None
    category_seo_url: str | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> NectarPrice | None:
        """Parse a Nectar price from grocery API JSON."""
        if not data or data.get("retail_price") is None:
            return None
        unit_price = data.get("unit_price")
        return cls(
            retail_price=float(data["retail_price"]),
            unit_price=float(unit_price) if unit_price is not None else None,
            measure=data.get("measure"),
            url=data.get("url"),
            category_seo_url=data.get("category_seo_url"),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the Nectar price to a plain dictionary."""
        return {
            "retail_price": self.retail_price,
            "unit_price": self.unit_price,
            "measure": self.measure,
            "url": self.url,
            "category_seo_url": self.category_seo_url,
        }

from_dict(data) classmethod

Parse a Nectar price from grocery API JSON.

Source code in pysainsburys/models/product/product.py
175
176
177
178
179
180
181
182
183
184
185
186
187
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> NectarPrice | None:
    """Parse a Nectar price from grocery API JSON."""
    if not data or data.get("retail_price") is None:
        return None
    unit_price = data.get("unit_price")
    return cls(
        retail_price=float(data["retail_price"]),
        unit_price=float(unit_price) if unit_price is not None else None,
        measure=data.get("measure"),
        url=data.get("url"),
        category_seo_url=data.get("category_seo_url"),
    )

to_dict()

Serialise the Nectar price to a plain dictionary.

Source code in pysainsburys/models/product/product.py
189
190
191
192
193
194
195
196
197
def to_dict(self) -> dict[str, Any]:
    """Serialise the Nectar price to a plain dictionary."""
    return {
        "retail_price": self.retail_price,
        "unit_price": self.unit_price,
        "measure": self.measure,
        "url": self.url,
        "category_seo_url": self.category_seo_url,
    }

pysainsburys.models.product.details.ProductDetails dataclass

Catalogue copy parsed from a product detail page.

Each field is a list of paragraphs. A heading the page does not include is None.

Attributes:

Name Type Description
description list[str] | None

Product description paragraphs.

storage list[str] | None

Storage instructions.

dietary_information list[str] | None

Dietary and allergen statements.

ingredients list[str] | None

Ingredient list paragraphs.

manufacturer list[str] | None

Manufacturer or packer details.

preparation list[str] | None

Preparation or serving instructions.

country_of_origin list[str] | None

Origin or packing-country statements.

packaging list[str] | None

Packaging description.

Source code in pysainsburys/models/product/details.py
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
@dataclass(slots=True)
class ProductDetails:
    """
    Catalogue copy parsed from a product detail page.

    Each field is a list of paragraphs. A heading the page does not include
    is ``None``.

    Attributes:
        description: Product description paragraphs.
        storage: Storage instructions.
        dietary_information: Dietary and allergen statements.
        ingredients: Ingredient list paragraphs.
        manufacturer: Manufacturer or packer details.
        preparation: Preparation or serving instructions.
        country_of_origin: Origin or packing-country statements.
        packaging: Packaging description.

    """

    description: list[str] | None = None
    storage: list[str] | None = None
    dietary_information: list[str] | None = None
    ingredients: list[str] | None = None
    manufacturer: list[str] | None = None
    preparation: list[str] | None = None
    country_of_origin: list[str] | None = None
    packaging: list[str] | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> ProductDetails | None:
        """Parse product detail sections from a serialised mapping."""
        if not data:
            return None
        details = cls(
            description=_string_list(data.get("description")),
            storage=_string_list(data.get("storage")),
            dietary_information=_string_list(data.get("dietary_information")),
            ingredients=_string_list(data.get("ingredients")),
            manufacturer=_string_list(data.get("manufacturer")),
            preparation=_string_list(data.get("preparation")),
            country_of_origin=_string_list(data.get("country_of_origin")),
            packaging=_string_list(data.get("packaging")),
        )
        if details.is_empty():
            return None
        return details

    def is_empty(self) -> bool:
        """Return whether every section is missing."""
        return all(
            value is None
            for value in (
                self.description,
                self.storage,
                self.dietary_information,
                self.ingredients,
                self.manufacturer,
                self.preparation,
                self.country_of_origin,
                self.packaging,
            )
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the detail sections to a plain dictionary."""
        return {
            "description": self.description,
            "storage": self.storage,
            "dietary_information": self.dietary_information,
            "ingredients": self.ingredients,
            "manufacturer": self.manufacturer,
            "preparation": self.preparation,
            "country_of_origin": self.country_of_origin,
            "packaging": self.packaging,
        }

from_dict(data) classmethod

Parse product detail sections from a serialised mapping.

Source code in pysainsburys/models/product/details.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> ProductDetails | None:
    """Parse product detail sections from a serialised mapping."""
    if not data:
        return None
    details = cls(
        description=_string_list(data.get("description")),
        storage=_string_list(data.get("storage")),
        dietary_information=_string_list(data.get("dietary_information")),
        ingredients=_string_list(data.get("ingredients")),
        manufacturer=_string_list(data.get("manufacturer")),
        preparation=_string_list(data.get("preparation")),
        country_of_origin=_string_list(data.get("country_of_origin")),
        packaging=_string_list(data.get("packaging")),
    )
    if details.is_empty():
        return None
    return details

is_empty()

Return whether every section is missing.

Source code in pysainsburys/models/product/details.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
def is_empty(self) -> bool:
    """Return whether every section is missing."""
    return all(
        value is None
        for value in (
            self.description,
            self.storage,
            self.dietary_information,
            self.ingredients,
            self.manufacturer,
            self.preparation,
            self.country_of_origin,
            self.packaging,
        )
    )

to_dict()

Serialise the detail sections to a plain dictionary.

Source code in pysainsburys/models/product/details.py
107
108
109
110
111
112
113
114
115
116
117
118
def to_dict(self) -> dict[str, Any]:
    """Serialise the detail sections to a plain dictionary."""
    return {
        "description": self.description,
        "storage": self.storage,
        "dietary_information": self.dietary_information,
        "ingredients": self.ingredients,
        "manufacturer": self.manufacturer,
        "preparation": self.preparation,
        "country_of_origin": self.country_of_origin,
        "packaging": self.packaging,
    }

pysainsburys.models.product.nutrition.NutritionInfo dataclass

Parsed nutrition information for a product.

Source code in pysainsburys/models/product/nutrition.py
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
@dataclass(slots=True)
class NutritionInfo:
    """Parsed nutrition information for a product."""

    summary: list[NutrientSummary] = field(default_factory=list)
    tables: list[NutritionTable] = field(default_factory=list)
    notes: list[str] = field(default_factory=list)

    def to_dict(self) -> dict[str, Any]:
        """Return nutrition information as a dictionary."""
        return {
            "summary": [item.to_dict() for item in self.summary],
            "tables": [table.to_dict() for table in self.tables],
            "notes": self.notes,
        }

to_dict()

Return nutrition information as a dictionary.

Source code in pysainsburys/models/product/nutrition.py
109
110
111
112
113
114
115
def to_dict(self) -> dict[str, Any]:
    """Return nutrition information as a dictionary."""
    return {
        "summary": [item.to_dict() for item in self.summary],
        "tables": [table.to_dict() for table in self.tables],
        "notes": self.notes,
    }

pysainsburys.models.product.nutrition.parse_nutrition_from_details_html(details_html)

Parse nutrition information from a product details_html field.

Source code in pysainsburys/models/product/nutrition.py
288
289
290
def parse_nutrition_from_details_html(details_html: str | None) -> NutritionInfo | None:
    """Parse nutrition information from a product ``details_html`` field."""
    return parse_nutrition(decode_details_html(details_html))

Basket

pysainsburys.models.basket

Basket domain models.

Basket dataclass

The authenticated customer's grocery basket.

Attributes:

Name Type Description
basket_id str | None

Basket identifier assigned by the commerce platform.

order_id str | None

Associated order id when amending an existing order.

subtotal_price float

Sum of item prices before delivery and savings.

total_price float

Basket total including fees where calculated.

slot_price float

Delivery or collection slot charge when applicable.

savings float

Promotional savings applied to the basket.

nectar_savings float

Nectar-specific savings when applicable.

item_count int

Number of distinct line items.

minimum_spend int

Minimum order value required for checkout.

delivery_instructions str | None

Customer delivery note when set.

is_in_amend_mode bool

Whether the basket is amending a placed order.

slot_type str | None

Reserved slot type string from the API.

has_exceeded_minimum_spend bool

Whether the minimum spend threshold is met.

items list[BasketItem]

Line items currently in the basket.

Source code in pysainsburys/models/basket/basket.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
@dataclass(slots=True)
class Basket:
    """
    The authenticated customer's grocery basket.

    Attributes:
        basket_id: Basket identifier assigned by the commerce platform.
        order_id: Associated order id when amending an existing order.
        subtotal_price: Sum of item prices before delivery and savings.
        total_price: Basket total including fees where calculated.
        slot_price: Delivery or collection slot charge when applicable.
        savings: Promotional savings applied to the basket.
        nectar_savings: Nectar-specific savings when applicable.
        item_count: Number of distinct line items.
        minimum_spend: Minimum order value required for checkout.
        delivery_instructions: Customer delivery note when set.
        is_in_amend_mode: Whether the basket is amending a placed order.
        slot_type: Reserved slot type string from the API.
        has_exceeded_minimum_spend: Whether the minimum spend threshold is met.
        items: Line items currently in the basket.

    """

    basket_id: str | None = None
    order_id: str | None = None
    subtotal_price: float = 0.0
    total_price: float = 0.0
    slot_price: float = 0.0
    savings: float = 0.0
    nectar_savings: float = 0.0
    item_count: int = 0
    minimum_spend: int = 0
    delivery_instructions: str | None = None
    is_in_amend_mode: bool = False
    slot_type: str | None = None
    has_exceeded_minimum_spend: bool = False
    items: list[BasketItem] = field(default_factory=list)

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> Basket:
        """Parse a basket from grocery API JSON."""
        return cls(
            basket_id=data.get("basket_id"),
            order_id=data.get("order_id"),
            subtotal_price=float(data.get("subtotal_price", 0)),
            total_price=float(data.get("total_price", 0)),
            slot_price=float(data.get("slot_price", 0)),
            savings=float(data.get("savings", 0)),
            nectar_savings=float(data.get("nectar_savings", 0)),
            item_count=int(data.get("item_count", 0)),
            minimum_spend=int(data.get("minimum_spend", 0)),
            delivery_instructions=data.get("delivery_instructions"),
            is_in_amend_mode=bool(data.get("is_in_amend_mode", False)),
            slot_type=data.get("slot_type"),
            has_exceeded_minimum_spend=bool(
                data.get("has_exceeded_minimum_spend", False)
            ),
            items=[BasketItem.from_dict(item) for item in data.get("items", [])],
        )

    @property
    def is_empty(self) -> bool:
        """Return ``True`` when the basket contains no items."""
        return self.item_count == 0 and not self.items

    def to_dict(self) -> dict[str, Any]:
        """Serialise the basket to a plain dictionary."""
        return {
            "basket_id": self.basket_id,
            "order_id": self.order_id,
            "subtotal_price": self.subtotal_price,
            "total_price": self.total_price,
            "slot_price": self.slot_price,
            "savings": self.savings,
            "nectar_savings": self.nectar_savings,
            "item_count": self.item_count,
            "minimum_spend": self.minimum_spend,
            "delivery_instructions": self.delivery_instructions,
            "is_in_amend_mode": self.is_in_amend_mode,
            "slot_type": self.slot_type,
            "has_exceeded_minimum_spend": self.has_exceeded_minimum_spend,
            "items": [item.to_dict() for item in self.items],
        }

    def __iter__(self) -> Iterator[tuple[str, Any]]:
        """Allow ``dict(basket)`` conversion."""
        return iter(self.to_dict().items())

is_empty property

Return True when the basket contains no items.

__iter__()

Allow dict(basket) conversion.

Source code in pysainsburys/models/basket/basket.py
173
174
175
def __iter__(self) -> Iterator[tuple[str, Any]]:
    """Allow ``dict(basket)`` conversion."""
    return iter(self.to_dict().items())

from_dict(data) classmethod

Parse a basket from grocery API JSON.

Source code in pysainsburys/models/basket/basket.py
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Basket:
    """Parse a basket from grocery API JSON."""
    return cls(
        basket_id=data.get("basket_id"),
        order_id=data.get("order_id"),
        subtotal_price=float(data.get("subtotal_price", 0)),
        total_price=float(data.get("total_price", 0)),
        slot_price=float(data.get("slot_price", 0)),
        savings=float(data.get("savings", 0)),
        nectar_savings=float(data.get("nectar_savings", 0)),
        item_count=int(data.get("item_count", 0)),
        minimum_spend=int(data.get("minimum_spend", 0)),
        delivery_instructions=data.get("delivery_instructions"),
        is_in_amend_mode=bool(data.get("is_in_amend_mode", False)),
        slot_type=data.get("slot_type"),
        has_exceeded_minimum_spend=bool(
            data.get("has_exceeded_minimum_spend", False)
        ),
        items=[BasketItem.from_dict(item) for item in data.get("items", [])],
    )

to_dict()

Serialise the basket to a plain dictionary.

Source code in pysainsburys/models/basket/basket.py
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
def to_dict(self) -> dict[str, Any]:
    """Serialise the basket to a plain dictionary."""
    return {
        "basket_id": self.basket_id,
        "order_id": self.order_id,
        "subtotal_price": self.subtotal_price,
        "total_price": self.total_price,
        "slot_price": self.slot_price,
        "savings": self.savings,
        "nectar_savings": self.nectar_savings,
        "item_count": self.item_count,
        "minimum_spend": self.minimum_spend,
        "delivery_instructions": self.delivery_instructions,
        "is_in_amend_mode": self.is_in_amend_mode,
        "slot_type": self.slot_type,
        "has_exceeded_minimum_spend": self.has_exceeded_minimum_spend,
        "items": [item.to_dict() for item in self.items],
    }

BasketItem dataclass

A single line item in the grocery basket.

Attributes:

Name Type Description
product_uid str

Catalogue identifier for the product.

quantity float

Number of units in the basket.

name str | None

Display name when returned by the basket endpoint.

item_uid str | None

Basket line identifier used for updates and removals.

subtotal float | None

Line total in pounds sterling.

unit_price Price | None

Price per unit when provided by the API.

product_data dict[str, Any] | None

Nested product JSON when included in the basket response. Use :meth:~pysainsburys.models.product.Product.from_basket_nested to parse this into a :class:~pysainsburys.models.product.Product.

Source code in pysainsburys/models/basket/basket.py
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
@dataclass(slots=True)
class BasketItem:
    """
    A single line item in the grocery basket.

    Attributes:
        product_uid: Catalogue identifier for the product.
        quantity: Number of units in the basket.
        name: Display name when returned by the basket endpoint.
        item_uid: Basket line identifier used for updates and removals.
        subtotal: Line total in pounds sterling.
        unit_price: Price per unit when provided by the API.
        product_data: Nested product JSON when included in the basket response.
            Use :meth:`~pysainsburys.models.product.Product.from_basket_nested`
            to parse this into a :class:`~pysainsburys.models.product.Product`.

    """

    product_uid: str
    quantity: float
    name: str | None = None
    item_uid: str | None = None
    subtotal: float | None = None
    unit_price: Price | None = None
    product_data: dict[str, Any] | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> BasketItem:
        """Parse a basket item from grocery API JSON."""
        product_data = data.get("product")
        nested = product_data if isinstance(product_data, dict) else None
        product_uid = str(data.get("product_uid") or data.get("uid") or "")
        name = data.get("name")
        if nested is not None:
            if not product_uid:
                product_uid = str(nested.get("sku") or nested.get("product_uid") or "")
            if name is None:
                name = nested.get("name")
        return cls(
            product_uid=product_uid,
            quantity=float(data.get("quantity", 0)),
            name=name,
            item_uid=data.get("item_uid") or data.get("itemId"),
            subtotal=(
                float(data["subtotal"])
                if data.get("subtotal") is not None
                else (
                    float(data["subtotal_price"])
                    if data.get("subtotal_price") is not None
                    else None
                )
            ),
            unit_price=Price.from_dict(data.get("unit_price")),
            product_data=nested,
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the basket item to a plain dictionary."""
        return {
            "product_uid": self.product_uid,
            "quantity": self.quantity,
            "name": self.name,
            "item_uid": self.item_uid,
            "subtotal": self.subtotal,
            "unit_price": self.unit_price.to_dict() if self.unit_price else None,
            "product": self.product_data,
        }

from_dict(data) classmethod

Parse a basket item from grocery API JSON.

Source code in pysainsburys/models/basket/basket.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
@classmethod
def from_dict(cls, data: dict[str, Any]) -> BasketItem:
    """Parse a basket item from grocery API JSON."""
    product_data = data.get("product")
    nested = product_data if isinstance(product_data, dict) else None
    product_uid = str(data.get("product_uid") or data.get("uid") or "")
    name = data.get("name")
    if nested is not None:
        if not product_uid:
            product_uid = str(nested.get("sku") or nested.get("product_uid") or "")
        if name is None:
            name = nested.get("name")
    return cls(
        product_uid=product_uid,
        quantity=float(data.get("quantity", 0)),
        name=name,
        item_uid=data.get("item_uid") or data.get("itemId"),
        subtotal=(
            float(data["subtotal"])
            if data.get("subtotal") is not None
            else (
                float(data["subtotal_price"])
                if data.get("subtotal_price") is not None
                else None
            )
        ),
        unit_price=Price.from_dict(data.get("unit_price")),
        product_data=nested,
    )

to_dict()

Serialise the basket item to a plain dictionary.

Source code in pysainsburys/models/basket/basket.py
76
77
78
79
80
81
82
83
84
85
86
def to_dict(self) -> dict[str, Any]:
    """Serialise the basket item to a plain dictionary."""
    return {
        "product_uid": self.product_uid,
        "quantity": self.quantity,
        "name": self.name,
        "item_uid": self.item_uid,
        "subtotal": self.subtotal,
        "unit_price": self.unit_price.to_dict() if self.unit_price else None,
        "product": self.product_data,
    }

basket_from_response(response)

Parse a basket API response into a :class:Basket.

Source code in pysainsburys/models/basket/basket.py
12
13
14
15
16
17
def basket_from_response(response: dict[str, Any] | list[Any] | None) -> Basket:
    """Parse a basket API response into a :class:`Basket`."""
    if not isinstance(response, dict):
        msg = "Basket response was not a JSON object."
        raise TypeError(msg)
    return Basket.from_dict(response)

pysainsburys.models.basket.basket.Basket dataclass

The authenticated customer's grocery basket.

Attributes:

Name Type Description
basket_id str | None

Basket identifier assigned by the commerce platform.

order_id str | None

Associated order id when amending an existing order.

subtotal_price float

Sum of item prices before delivery and savings.

total_price float

Basket total including fees where calculated.

slot_price float

Delivery or collection slot charge when applicable.

savings float

Promotional savings applied to the basket.

nectar_savings float

Nectar-specific savings when applicable.

item_count int

Number of distinct line items.

minimum_spend int

Minimum order value required for checkout.

delivery_instructions str | None

Customer delivery note when set.

is_in_amend_mode bool

Whether the basket is amending a placed order.

slot_type str | None

Reserved slot type string from the API.

has_exceeded_minimum_spend bool

Whether the minimum spend threshold is met.

items list[BasketItem]

Line items currently in the basket.

Source code in pysainsburys/models/basket/basket.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
@dataclass(slots=True)
class Basket:
    """
    The authenticated customer's grocery basket.

    Attributes:
        basket_id: Basket identifier assigned by the commerce platform.
        order_id: Associated order id when amending an existing order.
        subtotal_price: Sum of item prices before delivery and savings.
        total_price: Basket total including fees where calculated.
        slot_price: Delivery or collection slot charge when applicable.
        savings: Promotional savings applied to the basket.
        nectar_savings: Nectar-specific savings when applicable.
        item_count: Number of distinct line items.
        minimum_spend: Minimum order value required for checkout.
        delivery_instructions: Customer delivery note when set.
        is_in_amend_mode: Whether the basket is amending a placed order.
        slot_type: Reserved slot type string from the API.
        has_exceeded_minimum_spend: Whether the minimum spend threshold is met.
        items: Line items currently in the basket.

    """

    basket_id: str | None = None
    order_id: str | None = None
    subtotal_price: float = 0.0
    total_price: float = 0.0
    slot_price: float = 0.0
    savings: float = 0.0
    nectar_savings: float = 0.0
    item_count: int = 0
    minimum_spend: int = 0
    delivery_instructions: str | None = None
    is_in_amend_mode: bool = False
    slot_type: str | None = None
    has_exceeded_minimum_spend: bool = False
    items: list[BasketItem] = field(default_factory=list)

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> Basket:
        """Parse a basket from grocery API JSON."""
        return cls(
            basket_id=data.get("basket_id"),
            order_id=data.get("order_id"),
            subtotal_price=float(data.get("subtotal_price", 0)),
            total_price=float(data.get("total_price", 0)),
            slot_price=float(data.get("slot_price", 0)),
            savings=float(data.get("savings", 0)),
            nectar_savings=float(data.get("nectar_savings", 0)),
            item_count=int(data.get("item_count", 0)),
            minimum_spend=int(data.get("minimum_spend", 0)),
            delivery_instructions=data.get("delivery_instructions"),
            is_in_amend_mode=bool(data.get("is_in_amend_mode", False)),
            slot_type=data.get("slot_type"),
            has_exceeded_minimum_spend=bool(
                data.get("has_exceeded_minimum_spend", False)
            ),
            items=[BasketItem.from_dict(item) for item in data.get("items", [])],
        )

    @property
    def is_empty(self) -> bool:
        """Return ``True`` when the basket contains no items."""
        return self.item_count == 0 and not self.items

    def to_dict(self) -> dict[str, Any]:
        """Serialise the basket to a plain dictionary."""
        return {
            "basket_id": self.basket_id,
            "order_id": self.order_id,
            "subtotal_price": self.subtotal_price,
            "total_price": self.total_price,
            "slot_price": self.slot_price,
            "savings": self.savings,
            "nectar_savings": self.nectar_savings,
            "item_count": self.item_count,
            "minimum_spend": self.minimum_spend,
            "delivery_instructions": self.delivery_instructions,
            "is_in_amend_mode": self.is_in_amend_mode,
            "slot_type": self.slot_type,
            "has_exceeded_minimum_spend": self.has_exceeded_minimum_spend,
            "items": [item.to_dict() for item in self.items],
        }

    def __iter__(self) -> Iterator[tuple[str, Any]]:
        """Allow ``dict(basket)`` conversion."""
        return iter(self.to_dict().items())

is_empty property

Return True when the basket contains no items.

__iter__()

Allow dict(basket) conversion.

Source code in pysainsburys/models/basket/basket.py
173
174
175
def __iter__(self) -> Iterator[tuple[str, Any]]:
    """Allow ``dict(basket)`` conversion."""
    return iter(self.to_dict().items())

from_dict(data) classmethod

Parse a basket from grocery API JSON.

Source code in pysainsburys/models/basket/basket.py
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Basket:
    """Parse a basket from grocery API JSON."""
    return cls(
        basket_id=data.get("basket_id"),
        order_id=data.get("order_id"),
        subtotal_price=float(data.get("subtotal_price", 0)),
        total_price=float(data.get("total_price", 0)),
        slot_price=float(data.get("slot_price", 0)),
        savings=float(data.get("savings", 0)),
        nectar_savings=float(data.get("nectar_savings", 0)),
        item_count=int(data.get("item_count", 0)),
        minimum_spend=int(data.get("minimum_spend", 0)),
        delivery_instructions=data.get("delivery_instructions"),
        is_in_amend_mode=bool(data.get("is_in_amend_mode", False)),
        slot_type=data.get("slot_type"),
        has_exceeded_minimum_spend=bool(
            data.get("has_exceeded_minimum_spend", False)
        ),
        items=[BasketItem.from_dict(item) for item in data.get("items", [])],
    )

to_dict()

Serialise the basket to a plain dictionary.

Source code in pysainsburys/models/basket/basket.py
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
def to_dict(self) -> dict[str, Any]:
    """Serialise the basket to a plain dictionary."""
    return {
        "basket_id": self.basket_id,
        "order_id": self.order_id,
        "subtotal_price": self.subtotal_price,
        "total_price": self.total_price,
        "slot_price": self.slot_price,
        "savings": self.savings,
        "nectar_savings": self.nectar_savings,
        "item_count": self.item_count,
        "minimum_spend": self.minimum_spend,
        "delivery_instructions": self.delivery_instructions,
        "is_in_amend_mode": self.is_in_amend_mode,
        "slot_type": self.slot_type,
        "has_exceeded_minimum_spend": self.has_exceeded_minimum_spend,
        "items": [item.to_dict() for item in self.items],
    }

pysainsburys.basket.BasketAccess

Fetch and manipulate the authenticated customer's grocery basket.

Source code in pysainsburys/basket.py
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
class BasketAccess:
    """Fetch and manipulate the authenticated customer's grocery basket."""

    def __init__(self, api: API) -> None:
        self._api = api
        self._cached: Basket | None = None

    @property
    def cached(self) -> Basket | None:
        """Return the last fetched basket, if any."""
        return self._cached

    def _store(self, basket: Basket) -> Basket:
        self._cached = basket
        return basket

    async def fetch(self, *, calculate: bool = True) -> Basket:
        """Fetch the current basket."""
        response = await self._api.send_request(
            endpoint="get_basket",
            params={
                "calculate": str(calculate).lower(),
                "slot_booked": "false",
            },
        )
        return self._store(basket_from_response(response))

    async def add(
        self,
        product_uid: str,
        quantity: float = 1.0,
        *,
        uom: str = "ea",
        selected_catchweight: str | None = None,
    ) -> Basket:
        """Add a product to the basket (POST increment)."""
        if quantity <= 0:
            return await self.remove(product_uid)
        body: dict[str, Any] = {
            "product_uid": product_uid,
            "quantity": quantity,
            "uom": uom,
        }
        if selected_catchweight is not None:
            body["selected_catchweight"] = selected_catchweight
        response = await self._api.send_request(endpoint="add_basket_item", body=body)
        return self._store(basket_from_response(response))

    async def set_quantity(
        self,
        product_uid: str,
        quantity: float,
        *,
        item_uid: str | None = None,
        uom: str = "ea",
        selected_catchweight: str | None = None,
    ) -> Basket:
        """Set the absolute basket quantity for a product."""
        if quantity <= 0:
            return await self.remove(product_uid, item_uid=item_uid)
        resolved_item_uid = await resolve_basket_item_uid(
            self._api,
            product_uid,
            item_uid,
        )
        item: dict[str, Any] = {
            "product_uid": product_uid,
            "quantity": quantity,
            "uom": uom,
            "item_uid": resolved_item_uid,
        }
        if selected_catchweight is not None:
            item["selected_catchweight"] = selected_catchweight
        response = await self._api.send_request(
            endpoint="update_basket",
            body={"items": [item]},
        )
        return self._store(basket_from_response(response))

    async def remove(
        self,
        product_uid: str,
        *,
        item_uid: str | None = None,
        force_delete: bool = False,
    ) -> Basket:
        """Remove a product from the basket."""
        del force_delete  # DELETE /items is unreliable; updates always clear the line.
        resolved_item_uid = await resolve_basket_item_uid(
            self._api,
            product_uid,
            item_uid,
        )
        response = await self._api.send_request(
            endpoint="update_basket",
            body={
                "items": [
                    {
                        "product_uid": product_uid,
                        "quantity": 0,
                        "uom": "ea",
                        "item_uid": resolved_item_uid,
                    }
                ]
            },
        )
        return self._store(basket_from_response(response))

    async def clear(self) -> None:
        """Remove all items from the basket."""
        await self._api.send_request(endpoint="clear_basket")
        self._cached = None

cached property

Return the last fetched basket, if any.

add(product_uid, quantity=1.0, *, uom='ea', selected_catchweight=None) async

Add a product to the basket (POST increment).

Source code in pysainsburys/basket.py
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
async def add(
    self,
    product_uid: str,
    quantity: float = 1.0,
    *,
    uom: str = "ea",
    selected_catchweight: str | None = None,
) -> Basket:
    """Add a product to the basket (POST increment)."""
    if quantity <= 0:
        return await self.remove(product_uid)
    body: dict[str, Any] = {
        "product_uid": product_uid,
        "quantity": quantity,
        "uom": uom,
    }
    if selected_catchweight is not None:
        body["selected_catchweight"] = selected_catchweight
    response = await self._api.send_request(endpoint="add_basket_item", body=body)
    return self._store(basket_from_response(response))

clear() async

Remove all items from the basket.

Source code in pysainsburys/basket.py
157
158
159
160
async def clear(self) -> None:
    """Remove all items from the basket."""
    await self._api.send_request(endpoint="clear_basket")
    self._cached = None

fetch(*, calculate=True) async

Fetch the current basket.

Source code in pysainsburys/basket.py
65
66
67
68
69
70
71
72
73
74
async def fetch(self, *, calculate: bool = True) -> Basket:
    """Fetch the current basket."""
    response = await self._api.send_request(
        endpoint="get_basket",
        params={
            "calculate": str(calculate).lower(),
            "slot_booked": "false",
        },
    )
    return self._store(basket_from_response(response))

remove(product_uid, *, item_uid=None, force_delete=False) async

Remove a product from the basket.

Source code in pysainsburys/basket.py
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
async def remove(
    self,
    product_uid: str,
    *,
    item_uid: str | None = None,
    force_delete: bool = False,
) -> Basket:
    """Remove a product from the basket."""
    del force_delete  # DELETE /items is unreliable; updates always clear the line.
    resolved_item_uid = await resolve_basket_item_uid(
        self._api,
        product_uid,
        item_uid,
    )
    response = await self._api.send_request(
        endpoint="update_basket",
        body={
            "items": [
                {
                    "product_uid": product_uid,
                    "quantity": 0,
                    "uom": "ea",
                    "item_uid": resolved_item_uid,
                }
            ]
        },
    )
    return self._store(basket_from_response(response))

set_quantity(product_uid, quantity, *, item_uid=None, uom='ea', selected_catchweight=None) async

Set the absolute basket quantity for a product.

Source code in pysainsburys/basket.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
async def set_quantity(
    self,
    product_uid: str,
    quantity: float,
    *,
    item_uid: str | None = None,
    uom: str = "ea",
    selected_catchweight: str | None = None,
) -> Basket:
    """Set the absolute basket quantity for a product."""
    if quantity <= 0:
        return await self.remove(product_uid, item_uid=item_uid)
    resolved_item_uid = await resolve_basket_item_uid(
        self._api,
        product_uid,
        item_uid,
    )
    item: dict[str, Any] = {
        "product_uid": product_uid,
        "quantity": quantity,
        "uom": uom,
        "item_uid": resolved_item_uid,
    }
    if selected_catchweight is not None:
        item["selected_catchweight"] = selected_catchweight
    response = await self._api.send_request(
        endpoint="update_basket",
        body={"items": [item]},
    )
    return self._store(basket_from_response(response))

Customer

pysainsburys.models.customer

Customer profile models.

Customer dataclass

Authenticated Sainsbury's Groceries Online customer profile.

A customer is returned by :meth:~pysainsburys.Sainsburys.get_customer and exposes convenience accessors for basket, favourites, orders, and slot resources when bound to a client.

Attributes:

Name Type Description
user_id str

Commerce platform user identifier.

customer_id str | None

Customer record identifier when distinct from user_id.

identity_id str | None

Identity provider subject identifier.

email str | None

Account email address.

family_name str | None

Family name from the profile.

given_name str | None

Given name from the profile.

primary_phone str | None

Primary contact telephone number.

postcode str | None

Default delivery postcode when set.

title str | None

Salutation or title when provided.

is_very_important_customer bool

VIP flag from the API.

delivery_pass_expiry_date str | None

Delivery pass expiry when subscribed.

personalization_id str | None

Personalisation token for recommendations.

has_nectar_associated bool

Whether a Nectar card is associated.

has_nectar_linked bool

Whether Nectar is fully linked for rewards.

is_digital_nectar bool

Whether the account uses digital Nectar.

Source code in pysainsburys/models/customer/customer.py
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
@dataclass(slots=True)
class Customer:
    """
    Authenticated Sainsbury's Groceries Online customer profile.

    A customer is returned by :meth:`~pysainsburys.Sainsburys.get_customer` and
    exposes convenience accessors for basket, favourites, orders, and slot
    resources when bound to a client.

    Attributes:
        user_id: Commerce platform user identifier.
        customer_id: Customer record identifier when distinct from ``user_id``.
        identity_id: Identity provider subject identifier.
        email: Account email address.
        family_name: Family name from the profile.
        given_name: Given name from the profile.
        primary_phone: Primary contact telephone number.
        postcode: Default delivery postcode when set.
        title: Salutation or title when provided.
        is_very_important_customer: VIP flag from the API.
        delivery_pass_expiry_date: Delivery pass expiry when subscribed.
        personalization_id: Personalisation token for recommendations.
        has_nectar_associated: Whether a Nectar card is associated.
        has_nectar_linked: Whether Nectar is fully linked for rewards.
        is_digital_nectar: Whether the account uses digital Nectar.

    """

    user_id: str
    customer_id: str | None = None
    identity_id: str | None = None
    email: str | None = None
    family_name: str | None = None
    given_name: str | None = None
    primary_phone: str | None = None
    postcode: str | None = None
    title: str | None = None
    is_very_important_customer: bool = False
    delivery_pass_expiry_date: str | None = None
    personalization_id: str | None = None
    has_nectar_associated: bool = False
    has_nectar_linked: bool = False
    is_digital_nectar: bool = False
    _api: API | None = field(default=None, repr=False, compare=False, hash=False)
    _favourites: Favourites | None = field(
        default=None, init=False, repr=False, compare=False, hash=False
    )
    _basket_access: BasketAccess | None = field(
        default=None, init=False, repr=False, compare=False, hash=False
    )
    _orders: Orders | None = field(
        default=None, init=False, repr=False, compare=False, hash=False
    )
    _nectar: Nectar | None = field(
        default=None, init=False, repr=False, compare=False, hash=False
    )
    _slots: Slots | None = field(
        default=None, init=False, repr=False, compare=False, hash=False
    )

    @classmethod
    def from_dict(cls, data: dict[str, Any], *, api: API | None = None) -> Customer:
        """Parse a customer profile from grocery API JSON."""
        return cls(
            user_id=str(data.get("user_id", "")),
            customer_id=data.get("customer_id"),
            identity_id=data.get("identity_id"),
            email=data.get("email"),
            family_name=data.get("family_name"),
            given_name=data.get("given_name"),
            primary_phone=data.get("primary_phone"),
            postcode=data.get("postcode"),
            title=data.get("title"),
            is_very_important_customer=bool(
                data.get("is_very_important_customer", False)
            ),
            delivery_pass_expiry_date=data.get("delivery_pass_expiry_date"),
            personalization_id=data.get("personalization_id"),
            has_nectar_associated=bool(data.get("has_nectar_associated", False)),
            has_nectar_linked=bool(data.get("has_nectar_linked", False)),
            is_digital_nectar=bool(data.get("is_digital_nectar", False)),
            _api=api,
        )

    def _require_api(self) -> API:
        if self._api is None:
            msg = "Customer is not bound to a Sainsburys client."
            raise NotBoundError(msg)
        return self._api

    @property
    def favourites(self) -> Favourites:
        """Favourites list and add/remove helpers for this customer."""
        from ...favourites import Favourites

        if self._favourites is None:
            self._favourites = Favourites(self._require_api())
        return self._favourites

    @property
    def basket(self) -> BasketAccess:
        """Basket fetch and clear helpers for this customer."""
        from ...basket import BasketAccess

        if self._basket_access is None:
            self._basket_access = BasketAccess(self._require_api())
        return self._basket_access

    @property
    def orders(self) -> Orders:
        """Order history, latest order, and per-order status."""
        from ...orders import Orders

        if self._orders is None:
            self._orders = Orders(self._require_api())
        return self._orders

    @property
    def nectar(self) -> Nectar:
        """Nectar bonus offers and Your Nectar Price helpers."""
        from ...nectar import Nectar

        if self._nectar is None:
            self._nectar = Nectar(self._require_api())
        return self._nectar

    @property
    def slots(self) -> Slots:
        """Delivery and collection slot listing helpers."""
        from ...slots import Slots

        if self._slots is None:
            self._slots = Slots(self._require_api())
        return self._slots

    @property
    def display_name(self) -> str:
        """Return a human-friendly display name."""
        parts = [part for part in (self.given_name, self.family_name) if part]
        if parts:
            return " ".join(parts)
        return self.email or self.user_id

    def to_dict(self) -> dict[str, Any]:
        """Serialise the customer profile to a plain dictionary."""
        return {
            "user_id": self.user_id,
            "customer_id": self.customer_id,
            "identity_id": self.identity_id,
            "email": self.email,
            "family_name": self.family_name,
            "given_name": self.given_name,
            "primary_phone": self.primary_phone,
            "postcode": self.postcode,
            "title": self.title,
            "is_very_important_customer": self.is_very_important_customer,
            "delivery_pass_expiry_date": self.delivery_pass_expiry_date,
            "personalization_id": self.personalization_id,
            "has_nectar_associated": self.has_nectar_associated,
            "has_nectar_linked": self.has_nectar_linked,
            "is_digital_nectar": self.is_digital_nectar,
            "display_name": self.display_name,
        }

    def __iter__(self) -> Iterator[tuple[str, Any]]:
        """Allow ``dict(customer)`` conversion."""
        return iter(self.to_dict().items())

basket property

Basket fetch and clear helpers for this customer.

display_name property

Return a human-friendly display name.

favourites property

Favourites list and add/remove helpers for this customer.

nectar property

Nectar bonus offers and Your Nectar Price helpers.

orders property

Order history, latest order, and per-order status.

slots property

Delivery and collection slot listing helpers.

__iter__()

Allow dict(customer) conversion.

Source code in pysainsburys/models/customer/customer.py
184
185
186
def __iter__(self) -> Iterator[tuple[str, Any]]:
    """Allow ``dict(customer)`` conversion."""
    return iter(self.to_dict().items())

from_dict(data, *, api=None) classmethod

Parse a customer profile from grocery API JSON.

Source code in pysainsburys/models/customer/customer.py
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
@classmethod
def from_dict(cls, data: dict[str, Any], *, api: API | None = None) -> Customer:
    """Parse a customer profile from grocery API JSON."""
    return cls(
        user_id=str(data.get("user_id", "")),
        customer_id=data.get("customer_id"),
        identity_id=data.get("identity_id"),
        email=data.get("email"),
        family_name=data.get("family_name"),
        given_name=data.get("given_name"),
        primary_phone=data.get("primary_phone"),
        postcode=data.get("postcode"),
        title=data.get("title"),
        is_very_important_customer=bool(
            data.get("is_very_important_customer", False)
        ),
        delivery_pass_expiry_date=data.get("delivery_pass_expiry_date"),
        personalization_id=data.get("personalization_id"),
        has_nectar_associated=bool(data.get("has_nectar_associated", False)),
        has_nectar_linked=bool(data.get("has_nectar_linked", False)),
        is_digital_nectar=bool(data.get("is_digital_nectar", False)),
        _api=api,
    )

to_dict()

Serialise the customer profile to a plain dictionary.

Source code in pysainsburys/models/customer/customer.py
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
def to_dict(self) -> dict[str, Any]:
    """Serialise the customer profile to a plain dictionary."""
    return {
        "user_id": self.user_id,
        "customer_id": self.customer_id,
        "identity_id": self.identity_id,
        "email": self.email,
        "family_name": self.family_name,
        "given_name": self.given_name,
        "primary_phone": self.primary_phone,
        "postcode": self.postcode,
        "title": self.title,
        "is_very_important_customer": self.is_very_important_customer,
        "delivery_pass_expiry_date": self.delivery_pass_expiry_date,
        "personalization_id": self.personalization_id,
        "has_nectar_associated": self.has_nectar_associated,
        "has_nectar_linked": self.has_nectar_linked,
        "is_digital_nectar": self.is_digital_nectar,
        "display_name": self.display_name,
    }

pysainsburys.models.customer.customer.Customer dataclass

Authenticated Sainsbury's Groceries Online customer profile.

A customer is returned by :meth:~pysainsburys.Sainsburys.get_customer and exposes convenience accessors for basket, favourites, orders, and slot resources when bound to a client.

Attributes:

Name Type Description
user_id str

Commerce platform user identifier.

customer_id str | None

Customer record identifier when distinct from user_id.

identity_id str | None

Identity provider subject identifier.

email str | None

Account email address.

family_name str | None

Family name from the profile.

given_name str | None

Given name from the profile.

primary_phone str | None

Primary contact telephone number.

postcode str | None

Default delivery postcode when set.

title str | None

Salutation or title when provided.

is_very_important_customer bool

VIP flag from the API.

delivery_pass_expiry_date str | None

Delivery pass expiry when subscribed.

personalization_id str | None

Personalisation token for recommendations.

has_nectar_associated bool

Whether a Nectar card is associated.

has_nectar_linked bool

Whether Nectar is fully linked for rewards.

is_digital_nectar bool

Whether the account uses digital Nectar.

Source code in pysainsburys/models/customer/customer.py
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
@dataclass(slots=True)
class Customer:
    """
    Authenticated Sainsbury's Groceries Online customer profile.

    A customer is returned by :meth:`~pysainsburys.Sainsburys.get_customer` and
    exposes convenience accessors for basket, favourites, orders, and slot
    resources when bound to a client.

    Attributes:
        user_id: Commerce platform user identifier.
        customer_id: Customer record identifier when distinct from ``user_id``.
        identity_id: Identity provider subject identifier.
        email: Account email address.
        family_name: Family name from the profile.
        given_name: Given name from the profile.
        primary_phone: Primary contact telephone number.
        postcode: Default delivery postcode when set.
        title: Salutation or title when provided.
        is_very_important_customer: VIP flag from the API.
        delivery_pass_expiry_date: Delivery pass expiry when subscribed.
        personalization_id: Personalisation token for recommendations.
        has_nectar_associated: Whether a Nectar card is associated.
        has_nectar_linked: Whether Nectar is fully linked for rewards.
        is_digital_nectar: Whether the account uses digital Nectar.

    """

    user_id: str
    customer_id: str | None = None
    identity_id: str | None = None
    email: str | None = None
    family_name: str | None = None
    given_name: str | None = None
    primary_phone: str | None = None
    postcode: str | None = None
    title: str | None = None
    is_very_important_customer: bool = False
    delivery_pass_expiry_date: str | None = None
    personalization_id: str | None = None
    has_nectar_associated: bool = False
    has_nectar_linked: bool = False
    is_digital_nectar: bool = False
    _api: API | None = field(default=None, repr=False, compare=False, hash=False)
    _favourites: Favourites | None = field(
        default=None, init=False, repr=False, compare=False, hash=False
    )
    _basket_access: BasketAccess | None = field(
        default=None, init=False, repr=False, compare=False, hash=False
    )
    _orders: Orders | None = field(
        default=None, init=False, repr=False, compare=False, hash=False
    )
    _nectar: Nectar | None = field(
        default=None, init=False, repr=False, compare=False, hash=False
    )
    _slots: Slots | None = field(
        default=None, init=False, repr=False, compare=False, hash=False
    )

    @classmethod
    def from_dict(cls, data: dict[str, Any], *, api: API | None = None) -> Customer:
        """Parse a customer profile from grocery API JSON."""
        return cls(
            user_id=str(data.get("user_id", "")),
            customer_id=data.get("customer_id"),
            identity_id=data.get("identity_id"),
            email=data.get("email"),
            family_name=data.get("family_name"),
            given_name=data.get("given_name"),
            primary_phone=data.get("primary_phone"),
            postcode=data.get("postcode"),
            title=data.get("title"),
            is_very_important_customer=bool(
                data.get("is_very_important_customer", False)
            ),
            delivery_pass_expiry_date=data.get("delivery_pass_expiry_date"),
            personalization_id=data.get("personalization_id"),
            has_nectar_associated=bool(data.get("has_nectar_associated", False)),
            has_nectar_linked=bool(data.get("has_nectar_linked", False)),
            is_digital_nectar=bool(data.get("is_digital_nectar", False)),
            _api=api,
        )

    def _require_api(self) -> API:
        if self._api is None:
            msg = "Customer is not bound to a Sainsburys client."
            raise NotBoundError(msg)
        return self._api

    @property
    def favourites(self) -> Favourites:
        """Favourites list and add/remove helpers for this customer."""
        from ...favourites import Favourites

        if self._favourites is None:
            self._favourites = Favourites(self._require_api())
        return self._favourites

    @property
    def basket(self) -> BasketAccess:
        """Basket fetch and clear helpers for this customer."""
        from ...basket import BasketAccess

        if self._basket_access is None:
            self._basket_access = BasketAccess(self._require_api())
        return self._basket_access

    @property
    def orders(self) -> Orders:
        """Order history, latest order, and per-order status."""
        from ...orders import Orders

        if self._orders is None:
            self._orders = Orders(self._require_api())
        return self._orders

    @property
    def nectar(self) -> Nectar:
        """Nectar bonus offers and Your Nectar Price helpers."""
        from ...nectar import Nectar

        if self._nectar is None:
            self._nectar = Nectar(self._require_api())
        return self._nectar

    @property
    def slots(self) -> Slots:
        """Delivery and collection slot listing helpers."""
        from ...slots import Slots

        if self._slots is None:
            self._slots = Slots(self._require_api())
        return self._slots

    @property
    def display_name(self) -> str:
        """Return a human-friendly display name."""
        parts = [part for part in (self.given_name, self.family_name) if part]
        if parts:
            return " ".join(parts)
        return self.email or self.user_id

    def to_dict(self) -> dict[str, Any]:
        """Serialise the customer profile to a plain dictionary."""
        return {
            "user_id": self.user_id,
            "customer_id": self.customer_id,
            "identity_id": self.identity_id,
            "email": self.email,
            "family_name": self.family_name,
            "given_name": self.given_name,
            "primary_phone": self.primary_phone,
            "postcode": self.postcode,
            "title": self.title,
            "is_very_important_customer": self.is_very_important_customer,
            "delivery_pass_expiry_date": self.delivery_pass_expiry_date,
            "personalization_id": self.personalization_id,
            "has_nectar_associated": self.has_nectar_associated,
            "has_nectar_linked": self.has_nectar_linked,
            "is_digital_nectar": self.is_digital_nectar,
            "display_name": self.display_name,
        }

    def __iter__(self) -> Iterator[tuple[str, Any]]:
        """Allow ``dict(customer)`` conversion."""
        return iter(self.to_dict().items())

basket property

Basket fetch and clear helpers for this customer.

display_name property

Return a human-friendly display name.

favourites property

Favourites list and add/remove helpers for this customer.

nectar property

Nectar bonus offers and Your Nectar Price helpers.

orders property

Order history, latest order, and per-order status.

slots property

Delivery and collection slot listing helpers.

__iter__()

Allow dict(customer) conversion.

Source code in pysainsburys/models/customer/customer.py
184
185
186
def __iter__(self) -> Iterator[tuple[str, Any]]:
    """Allow ``dict(customer)`` conversion."""
    return iter(self.to_dict().items())

from_dict(data, *, api=None) classmethod

Parse a customer profile from grocery API JSON.

Source code in pysainsburys/models/customer/customer.py
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
@classmethod
def from_dict(cls, data: dict[str, Any], *, api: API | None = None) -> Customer:
    """Parse a customer profile from grocery API JSON."""
    return cls(
        user_id=str(data.get("user_id", "")),
        customer_id=data.get("customer_id"),
        identity_id=data.get("identity_id"),
        email=data.get("email"),
        family_name=data.get("family_name"),
        given_name=data.get("given_name"),
        primary_phone=data.get("primary_phone"),
        postcode=data.get("postcode"),
        title=data.get("title"),
        is_very_important_customer=bool(
            data.get("is_very_important_customer", False)
        ),
        delivery_pass_expiry_date=data.get("delivery_pass_expiry_date"),
        personalization_id=data.get("personalization_id"),
        has_nectar_associated=bool(data.get("has_nectar_associated", False)),
        has_nectar_linked=bool(data.get("has_nectar_linked", False)),
        is_digital_nectar=bool(data.get("is_digital_nectar", False)),
        _api=api,
    )

to_dict()

Serialise the customer profile to a plain dictionary.

Source code in pysainsburys/models/customer/customer.py
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
def to_dict(self) -> dict[str, Any]:
    """Serialise the customer profile to a plain dictionary."""
    return {
        "user_id": self.user_id,
        "customer_id": self.customer_id,
        "identity_id": self.identity_id,
        "email": self.email,
        "family_name": self.family_name,
        "given_name": self.given_name,
        "primary_phone": self.primary_phone,
        "postcode": self.postcode,
        "title": self.title,
        "is_very_important_customer": self.is_very_important_customer,
        "delivery_pass_expiry_date": self.delivery_pass_expiry_date,
        "personalization_id": self.personalization_id,
        "has_nectar_associated": self.has_nectar_associated,
        "has_nectar_linked": self.has_nectar_linked,
        "is_digital_nectar": self.is_digital_nectar,
        "display_name": self.display_name,
    }

Order

pysainsburys.models.order

Order domain models.

OrderList dataclass

A paginated list of customer orders.

Source code in pysainsburys/models/order/order.py
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
@dataclass(slots=True)
class OrderList:
    """A paginated list of customer orders."""

    orders: list[OrderSummary]
    controls: PageControls

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> OrderList:
        """Parse an order list from grocery API JSON."""
        return cls(
            orders=[OrderSummary.from_dict(item) for item in data.get("orders", [])],
            controls=PageControls.from_dict(data.get("controls")),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the order list to a plain dictionary."""
        return {
            "orders": [order.to_dict() for order in self.orders],
            "controls": self.controls.to_dict(),
        }

    def __iter__(self) -> Iterator[tuple[str, Any]]:
        """Allow ``dict(order_list)`` conversion."""
        return iter(self.to_dict().items())

__iter__()

Allow dict(order_list) conversion.

Source code in pysainsburys/models/order/order.py
84
85
86
def __iter__(self) -> Iterator[tuple[str, Any]]:
    """Allow ``dict(order_list)`` conversion."""
    return iter(self.to_dict().items())

from_dict(data) classmethod

Parse an order list from grocery API JSON.

Source code in pysainsburys/models/order/order.py
69
70
71
72
73
74
75
@classmethod
def from_dict(cls, data: dict[str, Any]) -> OrderList:
    """Parse an order list from grocery API JSON."""
    return cls(
        orders=[OrderSummary.from_dict(item) for item in data.get("orders", [])],
        controls=PageControls.from_dict(data.get("controls")),
    )

to_dict()

Serialise the order list to a plain dictionary.

Source code in pysainsburys/models/order/order.py
77
78
79
80
81
82
def to_dict(self) -> dict[str, Any]:
    """Serialise the order list to a plain dictionary."""
    return {
        "orders": [order.to_dict() for order in self.orders],
        "controls": self.controls.to_dict(),
    }

OrderStatus dataclass

Live status for the customer's active order slot.

Attributes:

Name Type Description
order_uid str | None

Identifier for the active order.

is_cutoff bool

Whether the amend cutoff has passed.

is_in_amend_mode bool

Whether the order can still be amended.

cutoff_time str | None

Amend cutoff timestamp when provided.

slot_end_time str | None

Reserved slot end timestamp.

slot_start_time str | None

Reserved slot start timestamp.

order_type str | None

Delivery or collection type string.

total float

Current order total in pounds sterling.

failed_payments list[dict[str, Any]]

Payment failure payloads from the API.

Source code in pysainsburys/models/order/order.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
@dataclass(slots=True)
class OrderStatus:
    """
    Live status for the customer's active order slot.

    Attributes:
        order_uid: Identifier for the active order.
        is_cutoff: Whether the amend cutoff has passed.
        is_in_amend_mode: Whether the order can still be amended.
        cutoff_time: Amend cutoff timestamp when provided.
        slot_end_time: Reserved slot end timestamp.
        slot_start_time: Reserved slot start timestamp.
        order_type: Delivery or collection type string.
        total: Current order total in pounds sterling.
        failed_payments: Payment failure payloads from the API.

    """

    order_uid: str | None = None
    is_cutoff: bool = False
    is_in_amend_mode: bool = False
    cutoff_time: str | None = None
    slot_end_time: str | None = None
    slot_start_time: str | None = None
    order_type: str | None = None
    total: float = 0.0
    failed_payments: list[dict[str, Any]] = field(default_factory=list)

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> OrderStatus:
        """Parse order status from grocery API JSON."""
        return cls(
            order_uid=data.get("order_uid"),
            is_cutoff=bool(data.get("is_cutoff", False)),
            is_in_amend_mode=bool(data.get("is_in_amend_mode", False)),
            cutoff_time=data.get("cutoff_time"),
            slot_end_time=data.get("slot_end_time"),
            slot_start_time=data.get("slot_start_time"),
            order_type=data.get("order_type"),
            total=float(data.get("total", 0)),
            failed_payments=list(data.get("failed_payments", [])),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise order status to a plain dictionary."""
        return {
            "order_uid": self.order_uid,
            "is_cutoff": self.is_cutoff,
            "is_in_amend_mode": self.is_in_amend_mode,
            "cutoff_time": self.cutoff_time,
            "slot_end_time": self.slot_end_time,
            "slot_start_time": self.slot_start_time,
            "order_type": self.order_type,
            "total": self.total,
            "failed_payments": self.failed_payments,
        }

    def __iter__(self) -> Iterator[tuple[str, Any]]:
        """Allow ``dict(order_status)`` conversion."""
        return iter(self.to_dict().items())

__iter__()

Allow dict(order_status) conversion.

Source code in pysainsburys/models/order/order.py
146
147
148
def __iter__(self) -> Iterator[tuple[str, Any]]:
    """Allow ``dict(order_status)`` conversion."""
    return iter(self.to_dict().items())

from_dict(data) classmethod

Parse order status from grocery API JSON.

Source code in pysainsburys/models/order/order.py
117
118
119
120
121
122
123
124
125
126
127
128
129
130
@classmethod
def from_dict(cls, data: dict[str, Any]) -> OrderStatus:
    """Parse order status from grocery API JSON."""
    return cls(
        order_uid=data.get("order_uid"),
        is_cutoff=bool(data.get("is_cutoff", False)),
        is_in_amend_mode=bool(data.get("is_in_amend_mode", False)),
        cutoff_time=data.get("cutoff_time"),
        slot_end_time=data.get("slot_end_time"),
        slot_start_time=data.get("slot_start_time"),
        order_type=data.get("order_type"),
        total=float(data.get("total", 0)),
        failed_payments=list(data.get("failed_payments", [])),
    )

to_dict()

Serialise order status to a plain dictionary.

Source code in pysainsburys/models/order/order.py
132
133
134
135
136
137
138
139
140
141
142
143
144
def to_dict(self) -> dict[str, Any]:
    """Serialise order status to a plain dictionary."""
    return {
        "order_uid": self.order_uid,
        "is_cutoff": self.is_cutoff,
        "is_in_amend_mode": self.is_in_amend_mode,
        "cutoff_time": self.cutoff_time,
        "slot_end_time": self.slot_end_time,
        "slot_start_time": self.slot_start_time,
        "order_type": self.order_type,
        "total": self.total,
        "failed_payments": self.failed_payments,
    }

OrderSummary dataclass

Summary information for a past or active order.

Attributes:

Name Type Description
order_id str

Primary order identifier used in URLs and APIs.

order_uid str | None

Alternate order uid when returned separately.

status str | None

Human-readable order status string.

total float | None

Order total in pounds sterling.

slot_start_time str | None

Reserved slot start timestamp.

slot_end_time str | None

Reserved slot end timestamp.

slot_type str | None

Delivery or collection slot type.

Source code in pysainsburys/models/order/order.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
@dataclass(slots=True)
class OrderSummary:
    """
    Summary information for a past or active order.

    Attributes:
        order_id: Primary order identifier used in URLs and APIs.
        order_uid: Alternate order uid when returned separately.
        status: Human-readable order status string.
        total: Order total in pounds sterling.
        slot_start_time: Reserved slot start timestamp.
        slot_end_time: Reserved slot end timestamp.
        slot_type: Delivery or collection slot type.

    """

    order_id: str
    order_uid: str | None = None
    status: str | None = None
    total: float | None = None
    slot_start_time: str | None = None
    slot_end_time: str | None = None
    slot_type: str | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> OrderSummary:
        """Parse an order summary from grocery API JSON."""
        return cls(
            order_id=str(data.get("order_id") or data.get("order_uid") or ""),
            order_uid=data.get("order_uid"),
            status=data.get("status"),
            total=float(data["total"]) if data.get("total") is not None else None,
            slot_start_time=data.get("slot_start_time"),
            slot_end_time=data.get("slot_end_time"),
            slot_type=data.get("slot_type") or data.get("order_type"),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the order summary to a plain dictionary."""
        return {
            "order_id": self.order_id,
            "order_uid": self.order_uid,
            "status": self.status,
            "total": self.total,
            "slot_start_time": self.slot_start_time,
            "slot_end_time": self.slot_end_time,
            "slot_type": self.slot_type,
        }

from_dict(data) classmethod

Parse an order summary from grocery API JSON.

Source code in pysainsburys/models/order/order.py
36
37
38
39
40
41
42
43
44
45
46
47
@classmethod
def from_dict(cls, data: dict[str, Any]) -> OrderSummary:
    """Parse an order summary from grocery API JSON."""
    return cls(
        order_id=str(data.get("order_id") or data.get("order_uid") or ""),
        order_uid=data.get("order_uid"),
        status=data.get("status"),
        total=float(data["total"]) if data.get("total") is not None else None,
        slot_start_time=data.get("slot_start_time"),
        slot_end_time=data.get("slot_end_time"),
        slot_type=data.get("slot_type") or data.get("order_type"),
    )

to_dict()

Serialise the order summary to a plain dictionary.

Source code in pysainsburys/models/order/order.py
49
50
51
52
53
54
55
56
57
58
59
def to_dict(self) -> dict[str, Any]:
    """Serialise the order summary to a plain dictionary."""
    return {
        "order_id": self.order_id,
        "order_uid": self.order_uid,
        "status": self.status,
        "total": self.total,
        "slot_start_time": self.slot_start_time,
        "slot_end_time": self.slot_end_time,
        "slot_type": self.slot_type,
    }

pysainsburys.orders.Orders

Order history and status for a customer.

Source code in pysainsburys/orders.py
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
class Orders:
    """Order history and status for a customer."""

    def __init__(self, api: API) -> None:
        self._api = api
        self._cached: OrderList | None = None

    @property
    def cached(self) -> OrderList | None:
        """Return the last fetched order list, if any."""
        return self._cached

    @property
    def latest(self) -> OrderHandle:
        """Return a handle for the most recent order."""
        if not self._cached or not self._cached.orders:
            msg = "No orders cached; call await orders.fetch() first."
            raise LookupError(msg)
        return OrderHandle(self._api, self._cached.orders[0].order_id)

    def __getitem__(self, order_id: str) -> OrderHandle:
        """Return a handle for an order by id."""
        return OrderHandle(self._api, order_id)

    async def fetch(
        self,
        *,
        page_number: int = 1,
        page_size: int = 20,
    ) -> OrderList:
        """Fetch a page of order history."""
        response = await self._api.send_request(
            endpoint="get_orders",
            params={
                "page_number": page_number,
                "page_size": page_size,
            },
        )
        if not isinstance(response, dict):
            msg = "Orders response was not a JSON object."
            raise TypeError(msg)
        self._cached = OrderList.from_dict(response)
        return self._cached

cached property

Return the last fetched order list, if any.

latest property

Return a handle for the most recent order.

__getitem__(order_id)

Return a handle for an order by id.

Source code in pysainsburys/orders.py
84
85
86
def __getitem__(self, order_id: str) -> OrderHandle:
    """Return a handle for an order by id."""
    return OrderHandle(self._api, order_id)

fetch(*, page_number=1, page_size=20) async

Fetch a page of order history.

Source code in pysainsburys/orders.py
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
async def fetch(
    self,
    *,
    page_number: int = 1,
    page_size: int = 20,
) -> OrderList:
    """Fetch a page of order history."""
    response = await self._api.send_request(
        endpoint="get_orders",
        params={
            "page_number": page_number,
            "page_size": page_size,
        },
    )
    if not isinstance(response, dict):
        msg = "Orders response was not a JSON object."
        raise TypeError(msg)
    self._cached = OrderList.from_dict(response)
    return self._cached

Slot

pysainsburys.models.slot

Slot domain models.

DeliverySlot dataclass

A single bookable delivery or collection time window.

Attributes:

Name Type Description
slot_uid str | None

Stable slot identifier from the API when provided.

start_time str | None

Slot start timestamp (ISO-8601).

end_time str | None

Slot end timestamp (ISO-8601).

price float | None

Customer-facing slot price in pounds sterling.

unqualified_price float | None

List price before delivery-pass or promotions.

is_available bool

Whether the slot can be booked.

status str | None

Raw availability status string from the API.

slot_type str | None

Delivery or collection type when returned per slot.

Source code in pysainsburys/models/slot/slot.py
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
@dataclass(slots=True)
class DeliverySlot:
    """
    A single bookable delivery or collection time window.

    Attributes:
        slot_uid: Stable slot identifier from the API when provided.
        start_time: Slot start timestamp (ISO-8601).
        end_time: Slot end timestamp (ISO-8601).
        price: Customer-facing slot price in pounds sterling.
        unqualified_price: List price before delivery-pass or promotions.
        is_available: Whether the slot can be booked.
        status: Raw availability status string from the API.
        slot_type: Delivery or collection type when returned per slot.

    """

    slot_uid: str | None = None
    start_time: str | None = None
    end_time: str | None = None
    price: float | None = None
    unqualified_price: float | None = None
    is_available: bool = True
    status: str | None = None
    slot_type: str | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> DeliverySlot:
        """Parse a slot entry from grocery API JSON."""
        return cls(
            slot_uid=(
                data.get("slot_uid")
                or data.get("slot_id")
                or data.get("uid")
                or data.get("id")
            ),
            start_time=data.get("start_time") or data.get("slot_start_time"),
            end_time=data.get("end_time") or data.get("slot_end_time"),
            price=_optional_float(data.get("price") or data.get("slot_price")),
            unqualified_price=_optional_float(
                data.get("unqualified_price") or data.get("list_price")
            ),
            is_available=_slot_available(data),
            status=data.get("status"),
            slot_type=data.get("slot_type") or data.get("order_type"),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the slot to a plain dictionary."""
        return {
            "slot_uid": self.slot_uid,
            "start_time": self.start_time,
            "end_time": self.end_time,
            "price": self.price,
            "unqualified_price": self.unqualified_price,
            "is_available": self.is_available,
            "status": self.status,
            "slot_type": self.slot_type,
        }

    def __iter__(self) -> Iterator[tuple[str, Any]]:
        """Allow ``dict(slot)`` conversion."""
        return iter(self.to_dict().items())

__iter__()

Allow dict(slot) conversion.

Source code in pysainsburys/models/slot/slot.py
94
95
96
def __iter__(self) -> Iterator[tuple[str, Any]]:
    """Allow ``dict(slot)`` conversion."""
    return iter(self.to_dict().items())

from_dict(data) classmethod

Parse a slot entry from grocery API JSON.

Source code in pysainsburys/models/slot/slot.py
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
@classmethod
def from_dict(cls, data: dict[str, Any]) -> DeliverySlot:
    """Parse a slot entry from grocery API JSON."""
    return cls(
        slot_uid=(
            data.get("slot_uid")
            or data.get("slot_id")
            or data.get("uid")
            or data.get("id")
        ),
        start_time=data.get("start_time") or data.get("slot_start_time"),
        end_time=data.get("end_time") or data.get("slot_end_time"),
        price=_optional_float(data.get("price") or data.get("slot_price")),
        unqualified_price=_optional_float(
            data.get("unqualified_price") or data.get("list_price")
        ),
        is_available=_slot_available(data),
        status=data.get("status"),
        slot_type=data.get("slot_type") or data.get("order_type"),
    )

to_dict()

Serialise the slot to a plain dictionary.

Source code in pysainsburys/models/slot/slot.py
81
82
83
84
85
86
87
88
89
90
91
92
def to_dict(self) -> dict[str, Any]:
    """Serialise the slot to a plain dictionary."""
    return {
        "slot_uid": self.slot_uid,
        "start_time": self.start_time,
        "end_time": self.end_time,
        "price": self.price,
        "unqualified_price": self.unqualified_price,
        "is_available": self.is_available,
        "status": self.status,
        "slot_type": self.slot_type,
    }

LocationContext dataclass

Location context used when listing slots.

Source code in pysainsburys/models/slot/slot.py
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
@dataclass(slots=True)
class LocationContext:
    """Location context used when listing slots."""

    slot_type: str | None = None
    postcode: str | None = None
    store_identifier: str | None = None
    location_uid: str | None = None
    region: str | None = None
    order_uid: str | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> LocationContext:
        """Parse location context JSON."""
        return cls(
            slot_type=data.get("slot_type") or data.get("reservation_type"),
            postcode=data.get("postcode"),
            store_identifier=data.get("store_identifier"),
            location_uid=data.get("location_uid"),
            region=data.get("region"),
            order_uid=data.get("order_uid"),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise location context to a plain dictionary."""
        return {
            "slot_type": self.slot_type,
            "postcode": self.postcode,
            "store_identifier": self.store_identifier,
            "location_uid": self.location_uid,
            "region": self.region,
            "order_uid": self.order_uid,
        }

    def __iter__(self) -> Iterator[tuple[str, Any]]:
        """Allow ``dict(location_context)`` conversion."""
        return iter(self.to_dict().items())

__iter__()

Allow dict(location_context) conversion.

Source code in pysainsburys/models/slot/slot.py
314
315
316
def __iter__(self) -> Iterator[tuple[str, Any]]:
    """Allow ``dict(location_context)`` conversion."""
    return iter(self.to_dict().items())

from_dict(data) classmethod

Parse location context JSON.

Source code in pysainsburys/models/slot/slot.py
291
292
293
294
295
296
297
298
299
300
301
@classmethod
def from_dict(cls, data: dict[str, Any]) -> LocationContext:
    """Parse location context JSON."""
    return cls(
        slot_type=data.get("slot_type") or data.get("reservation_type"),
        postcode=data.get("postcode"),
        store_identifier=data.get("store_identifier"),
        location_uid=data.get("location_uid"),
        region=data.get("region"),
        order_uid=data.get("order_uid"),
    )

to_dict()

Serialise location context to a plain dictionary.

Source code in pysainsburys/models/slot/slot.py
303
304
305
306
307
308
309
310
311
312
def to_dict(self) -> dict[str, Any]:
    """Serialise location context to a plain dictionary."""
    return {
        "slot_type": self.slot_type,
        "postcode": self.postcode,
        "store_identifier": self.store_identifier,
        "location_uid": self.location_uid,
        "region": self.region,
        "order_uid": self.order_uid,
    }

SlotDay dataclass

Slots grouped for a single calendar day.

Source code in pysainsburys/models/slot/slot.py
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
@dataclass(slots=True)
class SlotDay:
    """Slots grouped for a single calendar day."""

    date: str | None = None
    day_label: str | None = None
    slots: list[DeliverySlot] = field(default_factory=list)

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> SlotDay:
        """Parse a day entry from grocery API JSON."""
        raw_slots = data.get("slots") or data.get("available_slots") or []
        return cls(
            date=data.get("date") or data.get("day_date"),
            day_label=data.get("day_label")
            or data.get("label")
            or data.get("day_name"),
            slots=[
                DeliverySlot.from_dict(item)
                for item in raw_slots
                if isinstance(item, dict)
            ],
        )

    @property
    def available_slots(self) -> list[DeliverySlot]:
        """Return only slots marked as available."""
        return [slot for slot in self.slots if slot.is_available]

    def to_dict(self) -> dict[str, Any]:
        """Serialise the day to a plain dictionary."""
        return {
            "date": self.date,
            "day_label": self.day_label,
            "slots": [slot.to_dict() for slot in self.slots],
        }

    def __iter__(self) -> Iterator[tuple[str, Any]]:
        """Allow ``dict(day)`` conversion."""
        return iter(self.to_dict().items())

available_slots property

Return only slots marked as available.

__iter__()

Allow dict(day) conversion.

Source code in pysainsburys/models/slot/slot.py
136
137
138
def __iter__(self) -> Iterator[tuple[str, Any]]:
    """Allow ``dict(day)`` conversion."""
    return iter(self.to_dict().items())

from_dict(data) classmethod

Parse a day entry from grocery API JSON.

Source code in pysainsburys/models/slot/slot.py
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
@classmethod
def from_dict(cls, data: dict[str, Any]) -> SlotDay:
    """Parse a day entry from grocery API JSON."""
    raw_slots = data.get("slots") or data.get("available_slots") or []
    return cls(
        date=data.get("date") or data.get("day_date"),
        day_label=data.get("day_label")
        or data.get("label")
        or data.get("day_name"),
        slots=[
            DeliverySlot.from_dict(item)
            for item in raw_slots
            if isinstance(item, dict)
        ],
    )

to_dict()

Serialise the day to a plain dictionary.

Source code in pysainsburys/models/slot/slot.py
128
129
130
131
132
133
134
def to_dict(self) -> dict[str, Any]:
    """Serialise the day to a plain dictionary."""
    return {
        "date": self.date,
        "day_label": self.day_label,
        "slots": [slot.to_dict() for slot in self.slots],
    }

SlotReservation dataclass

Current slot reservation state for the customer.

Source code in pysainsburys/models/slot/slot.py
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
@dataclass(slots=True)
class SlotReservation:
    """Current slot reservation state for the customer."""

    reservation_type: str | None = None
    postcode: str | None = None
    region: str | None = None
    store_identifier: str | None = None
    location_uid: str | None = None
    is_expired: bool = False
    reserved_until: str | None = None
    is_alcohol_restricted_store: bool = False
    flexi_stores: list[str] = field(default_factory=list)
    slot: DeliverySlot | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> SlotReservation:
        """Parse slot reservation JSON."""
        slot_data = data.get("slot")
        slot = (
            DeliverySlot.from_dict(slot_data) if isinstance(slot_data, dict) else None
        )
        flexi = data.get("flexi_stores") or []
        return cls(
            reservation_type=data.get("reservation_type"),
            postcode=data.get("postcode"),
            region=data.get("region"),
            store_identifier=data.get("store_identifier"),
            location_uid=data.get("location_uid"),
            is_expired=bool(data.get("is_expired", False)),
            reserved_until=data.get("reserved_until"),
            is_alcohol_restricted_store=bool(
                data.get("is_alcohol_restricted_store", False)
            ),
            flexi_stores=[str(value) for value in flexi],
            slot=slot,
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the reservation to a plain dictionary."""
        return {
            "reservation_type": self.reservation_type,
            "postcode": self.postcode,
            "region": self.region,
            "store_identifier": self.store_identifier,
            "location_uid": self.location_uid,
            "is_expired": self.is_expired,
            "reserved_until": self.reserved_until,
            "is_alcohol_restricted_store": self.is_alcohol_restricted_store,
            "flexi_stores": list(self.flexi_stores),
            "slot": self.slot.to_dict() if self.slot else None,
        }

    def __iter__(self) -> Iterator[tuple[str, Any]]:
        """Allow ``dict(reservation)`` conversion."""
        return iter(self.to_dict().items())

__iter__()

Allow dict(reservation) conversion.

Source code in pysainsburys/models/slot/slot.py
275
276
277
def __iter__(self) -> Iterator[tuple[str, Any]]:
    """Allow ``dict(reservation)`` conversion."""
    return iter(self.to_dict().items())

from_dict(data) classmethod

Parse slot reservation JSON.

Source code in pysainsburys/models/slot/slot.py
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
@classmethod
def from_dict(cls, data: dict[str, Any]) -> SlotReservation:
    """Parse slot reservation JSON."""
    slot_data = data.get("slot")
    slot = (
        DeliverySlot.from_dict(slot_data) if isinstance(slot_data, dict) else None
    )
    flexi = data.get("flexi_stores") or []
    return cls(
        reservation_type=data.get("reservation_type"),
        postcode=data.get("postcode"),
        region=data.get("region"),
        store_identifier=data.get("store_identifier"),
        location_uid=data.get("location_uid"),
        is_expired=bool(data.get("is_expired", False)),
        reserved_until=data.get("reserved_until"),
        is_alcohol_restricted_store=bool(
            data.get("is_alcohol_restricted_store", False)
        ),
        flexi_stores=[str(value) for value in flexi],
        slot=slot,
    )

to_dict()

Serialise the reservation to a plain dictionary.

Source code in pysainsburys/models/slot/slot.py
260
261
262
263
264
265
266
267
268
269
270
271
272
273
def to_dict(self) -> dict[str, Any]:
    """Serialise the reservation to a plain dictionary."""
    return {
        "reservation_type": self.reservation_type,
        "postcode": self.postcode,
        "region": self.region,
        "store_identifier": self.store_identifier,
        "location_uid": self.location_uid,
        "is_expired": self.is_expired,
        "reserved_until": self.reserved_until,
        "is_alcohol_restricted_store": self.is_alcohol_restricted_store,
        "flexi_stores": list(self.flexi_stores),
        "slot": self.slot.to_dict() if self.slot else None,
    }

SlotWeek dataclass

Week view of delivery or collection slots.

Attributes:

Name Type Description
slot_type SlotType | None

Requested slot type (delivery or collection).

week_start_date str | None

First day of the returned week when provided.

store_identifier str | None

Fulfilment store number used for the query.

postcode str | None

Delivery postcode context when applicable.

location_uid str | None

Click-and-collect location uid when applicable.

days list[SlotDay]

Day groupings with nested slot windows.

Source code in pysainsburys/models/slot/slot.py
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
@dataclass(slots=True)
class SlotWeek:
    """
    Week view of delivery or collection slots.

    Attributes:
        slot_type: Requested slot type (``delivery`` or ``collection``).
        week_start_date: First day of the returned week when provided.
        store_identifier: Fulfilment store number used for the query.
        postcode: Delivery postcode context when applicable.
        location_uid: Click-and-collect location uid when applicable.
        days: Day groupings with nested slot windows.

    """

    slot_type: SlotType | None = None
    week_start_date: str | None = None
    store_identifier: str | None = None
    postcode: str | None = None
    location_uid: str | None = None
    days: list[SlotDay] = field(default_factory=list)

    @classmethod
    def from_dict(
        cls,
        data: dict[str, Any],
        *,
        slot_type: SlotType | None = None,
        store_identifier: str | None = None,
        postcode: str | None = None,
        location_uid: str | None = None,
    ) -> SlotWeek:
        """Parse a slot week from grocery API JSON."""
        days_data = data.get("days") or data.get("slot_days")
        if days_data is None:
            weeks = data.get("weeks") or data.get("slot_weeks")
            if isinstance(weeks, list):
                days_data = []
                for week in weeks:
                    if isinstance(week, dict):
                        days_data.extend(week.get("days", []))
        days = [
            SlotDay.from_dict(item)
            for item in (days_data or [])
            if isinstance(item, dict)
        ]
        return cls(
            slot_type=slot_type,
            week_start_date=data.get("week_start_date") or data.get("start_date"),
            store_identifier=store_identifier or data.get("store_identifier"),
            postcode=postcode or data.get("postcode"),
            location_uid=location_uid or data.get("location_uid"),
            days=days,
        )

    @property
    def slots(self) -> list[DeliverySlot]:
        """Flatten all slots across days."""
        return [slot for day in self.days for slot in day.slots]

    @property
    def available_slots(self) -> list[DeliverySlot]:
        """Flatten only available slots across days."""
        return [slot for slot in self.slots if slot.is_available]

    def to_dict(self) -> dict[str, Any]:
        """Serialise the slot week to a plain dictionary."""
        return {
            "slot_type": self.slot_type.value if self.slot_type else None,
            "week_start_date": self.week_start_date,
            "store_identifier": self.store_identifier,
            "postcode": self.postcode,
            "location_uid": self.location_uid,
            "days": [day.to_dict() for day in self.days],
        }

    def __iter__(self) -> Iterator[tuple[str, Any]]:
        """Allow ``dict(slot_week)`` conversion."""
        return iter(self.to_dict().items())

available_slots property

Flatten only available slots across days.

slots property

Flatten all slots across days.

__iter__()

Allow dict(slot_week) conversion.

Source code in pysainsburys/models/slot/slot.py
217
218
219
def __iter__(self) -> Iterator[tuple[str, Any]]:
    """Allow ``dict(slot_week)`` conversion."""
    return iter(self.to_dict().items())

from_dict(data, *, slot_type=None, store_identifier=None, postcode=None, location_uid=None) classmethod

Parse a slot week from grocery API JSON.

Source code in pysainsburys/models/slot/slot.py
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
@classmethod
def from_dict(
    cls,
    data: dict[str, Any],
    *,
    slot_type: SlotType | None = None,
    store_identifier: str | None = None,
    postcode: str | None = None,
    location_uid: str | None = None,
) -> SlotWeek:
    """Parse a slot week from grocery API JSON."""
    days_data = data.get("days") or data.get("slot_days")
    if days_data is None:
        weeks = data.get("weeks") or data.get("slot_weeks")
        if isinstance(weeks, list):
            days_data = []
            for week in weeks:
                if isinstance(week, dict):
                    days_data.extend(week.get("days", []))
    days = [
        SlotDay.from_dict(item)
        for item in (days_data or [])
        if isinstance(item, dict)
    ]
    return cls(
        slot_type=slot_type,
        week_start_date=data.get("week_start_date") or data.get("start_date"),
        store_identifier=store_identifier or data.get("store_identifier"),
        postcode=postcode or data.get("postcode"),
        location_uid=location_uid or data.get("location_uid"),
        days=days,
    )

to_dict()

Serialise the slot week to a plain dictionary.

Source code in pysainsburys/models/slot/slot.py
206
207
208
209
210
211
212
213
214
215
def to_dict(self) -> dict[str, Any]:
    """Serialise the slot week to a plain dictionary."""
    return {
        "slot_type": self.slot_type.value if self.slot_type else None,
        "week_start_date": self.week_start_date,
        "store_identifier": self.store_identifier,
        "postcode": self.postcode,
        "location_uid": self.location_uid,
        "days": [day.to_dict() for day in self.days],
    }

pysainsburys.slots.Slots

List delivery and collection slots for a customer.

Source code in pysainsburys/slots.py
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
class Slots:
    """List delivery and collection slots for a customer."""

    def __init__(self, api: API) -> None:
        self._api = api
        self._reservation_cache: SlotReservation | None = None
        self._location_context_cache: LocationContext | None = None
        self._week_cache: SlotWeek | None = None

    @property
    def cached(self) -> SlotWeek | None:
        """Return the last fetched slot week, if any."""
        return self._week_cache

    @property
    def cached_reservation(self) -> SlotReservation | None:
        """Return the last fetched slot reservation, if any."""
        return self._reservation_cache

    async def fetch_reservation(
        self, *, order_uid: str | None = None
    ) -> SlotReservation:
        """Fetch the customer's current slot reservation state."""
        params: dict[str, str | int | float | bool] | None = (
            {"order_uid": order_uid} if order_uid else None
        )
        response = await self._api.send_request(
            endpoint="get_slot_reservation",
            params=params,
        )
        if not isinstance(response, dict):
            msg = "Slot reservation response was not a JSON object."
            raise TypeError(msg)
        reservation = SlotReservation.from_dict(response)
        self._reservation_cache = reservation
        return reservation

    async def fetch_location_context(self) -> LocationContext:
        """Fetch location context used when choosing delivery or collection."""
        response = await self._api.send_request(endpoint="get_slot_location_context")
        if not isinstance(response, dict):
            msg = "Slot location context response was not a JSON object."
            raise TypeError(msg)
        context = LocationContext.from_dict(response)
        self._location_context_cache = context
        return context

    async def reserve(
        self,
        slot: DeliverySlot | str,
        *,
        slot_type: SlotType | None = None,
        start_time: str | None = None,
        end_time: str | None = None,
        store_identifier: str | None = None,
        postcode: str | None = None,
        location_uid: str | None = None,
        order_uid: str | None = None,
        use_location_context: bool = True,
    ) -> SlotReservation:
        """
        Reserve a slot, or replace the current reservation with another slot.

        This write path is inferred from static Android models and has not yet
        been validated against a live commerce session.
        """
        if isinstance(slot, DeliverySlot):
            slot_uid = slot.slot_uid
            start_time = start_time or slot.start_time
            end_time = end_time or slot.end_time
            slot_type = slot_type or _parse_slot_type(slot.slot_type)
        else:
            slot_uid = slot

        if not slot_uid:
            raise ValueError("The selected slot does not have a slot_uid.")

        if slot_type is None and self._week_cache is not None:
            slot_type = self._week_cache.slot_type

        if use_location_context:
            context = await self.fetch_location_context()
            slot_type = slot_type or _parse_slot_type(context.slot_type)
            store_identifier = store_identifier or context.store_identifier
            postcode = postcode or context.postcode
            location_uid = location_uid or context.location_uid
            order_uid = order_uid or context.order_uid

        if slot_type is None:
            msg = (
                "slot_type could not be inferred; pass slot_type, list slots first, "
                "or enable location context."
            )
            raise ValueError(msg)

        body = build_reserve_slot_payload(
            slot_type=slot_type,
            slot_uid=slot_uid,
            start_time=start_time,
            end_time=end_time,
            store_identifier=store_identifier,
            postcode=postcode,
            location_uid=location_uid,
            order_uid=order_uid,
        )
        response = await self._api.send_request(
            endpoint="create_slot_reservation",
            body=body,
        )
        if not isinstance(response, dict):
            msg = "Slot reservation response was not a JSON object."
            raise TypeError(msg)
        reservation = SlotReservation.from_dict(response)
        self._reservation_cache = reservation
        return reservation

    async def validate(self, *, order_uid: str | None = None) -> SlotReservation:
        """Validate the customer's current slot reservation."""
        params: dict[str, str | int | float | bool] | None = (
            {"order_uid": order_uid} if order_uid else None
        )
        response = await self._api.send_request(
            endpoint="validate_slot_reservation",
            params=params,
        )
        if not isinstance(response, dict):
            msg = "Slot reservation validation response was not a JSON object."
            raise TypeError(msg)
        reservation = SlotReservation.from_dict(response)
        self._reservation_cache = reservation
        return reservation

    async def list(
        self,
        *,
        slot_type: SlotType,
        store_identifier: str | None = None,
        postcode: str | None = None,
        location_uid: str | None = None,
        week_start_date: str | None = None,
        order_uid: str | None = None,
        use_location_context: bool = True,
    ) -> SlotWeek:
        """
        List available slots for delivery or click-and-collect.

        When ``use_location_context`` is true (default), missing
        ``store_identifier``, ``postcode``, or ``location_uid`` values are
        filled from :meth:`fetch_location_context` when available.
        """
        if use_location_context and (
            store_identifier is None or postcode is None or location_uid is None
        ):
            context = await self.fetch_location_context()
            store_identifier = store_identifier or context.store_identifier
            postcode = postcode or context.postcode
            location_uid = location_uid or context.location_uid
            order_uid = order_uid or context.order_uid

        if week_start_date is None:
            week_start_date = _default_week_start_date()

        body = build_list_slots_payload(
            slot_type=slot_type,
            store_identifier=store_identifier,
            postcode=postcode,
            location_uid=location_uid,
            week_start_date=week_start_date,
            order_uid=order_uid,
        )
        response = await self._api.send_request(endpoint="list_slots", body=body)
        if not isinstance(response, dict):
            msg = "Slot week response was not a JSON object."
            raise TypeError(msg)
        week = SlotWeek.from_dict(
            response,
            slot_type=slot_type,
            store_identifier=store_identifier,
            postcode=postcode,
            location_uid=location_uid,
        )
        self._week_cache = week
        return week

    async def list_delivery(
        self,
        *,
        postcode: str | None = None,
        store_identifier: str | None = None,
        week_start_date: str | None = None,
        order_uid: str | None = None,
        use_location_context: bool = True,
    ) -> SlotWeek:
        """List home-delivery slots."""
        return await self.list(
            slot_type=SlotType.DELIVERY,
            postcode=postcode,
            store_identifier=store_identifier,
            week_start_date=week_start_date,
            order_uid=order_uid,
            use_location_context=use_location_context,
        )

    async def list_collection(
        self,
        *,
        store_identifier: str | None = None,
        location_uid: str | None = None,
        week_start_date: str | None = None,
        order_uid: str | None = None,
        use_location_context: bool = True,
    ) -> SlotWeek:
        """List click-and-collect slots."""
        return await self.list(
            slot_type=SlotType.COLLECTION,
            store_identifier=store_identifier,
            location_uid=location_uid,
            week_start_date=week_start_date,
            order_uid=order_uid,
            use_location_context=use_location_context,
        )

cached property

Return the last fetched slot week, if any.

cached_reservation property

Return the last fetched slot reservation, if any.

fetch_location_context() async

Fetch location context used when choosing delivery or collection.

Source code in pysainsburys/slots.py
147
148
149
150
151
152
153
154
155
async def fetch_location_context(self) -> LocationContext:
    """Fetch location context used when choosing delivery or collection."""
    response = await self._api.send_request(endpoint="get_slot_location_context")
    if not isinstance(response, dict):
        msg = "Slot location context response was not a JSON object."
        raise TypeError(msg)
    context = LocationContext.from_dict(response)
    self._location_context_cache = context
    return context

fetch_reservation(*, order_uid=None) async

Fetch the customer's current slot reservation state.

Source code in pysainsburys/slots.py
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
async def fetch_reservation(
    self, *, order_uid: str | None = None
) -> SlotReservation:
    """Fetch the customer's current slot reservation state."""
    params: dict[str, str | int | float | bool] | None = (
        {"order_uid": order_uid} if order_uid else None
    )
    response = await self._api.send_request(
        endpoint="get_slot_reservation",
        params=params,
    )
    if not isinstance(response, dict):
        msg = "Slot reservation response was not a JSON object."
        raise TypeError(msg)
    reservation = SlotReservation.from_dict(response)
    self._reservation_cache = reservation
    return reservation

list(*, slot_type, store_identifier=None, postcode=None, location_uid=None, week_start_date=None, order_uid=None, use_location_context=True) async

List available slots for delivery or click-and-collect.

When use_location_context is true (default), missing store_identifier, postcode, or location_uid values are filled from :meth:fetch_location_context when available.

Source code in pysainsburys/slots.py
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
async def list(
    self,
    *,
    slot_type: SlotType,
    store_identifier: str | None = None,
    postcode: str | None = None,
    location_uid: str | None = None,
    week_start_date: str | None = None,
    order_uid: str | None = None,
    use_location_context: bool = True,
) -> SlotWeek:
    """
    List available slots for delivery or click-and-collect.

    When ``use_location_context`` is true (default), missing
    ``store_identifier``, ``postcode``, or ``location_uid`` values are
    filled from :meth:`fetch_location_context` when available.
    """
    if use_location_context and (
        store_identifier is None or postcode is None or location_uid is None
    ):
        context = await self.fetch_location_context()
        store_identifier = store_identifier or context.store_identifier
        postcode = postcode or context.postcode
        location_uid = location_uid or context.location_uid
        order_uid = order_uid or context.order_uid

    if week_start_date is None:
        week_start_date = _default_week_start_date()

    body = build_list_slots_payload(
        slot_type=slot_type,
        store_identifier=store_identifier,
        postcode=postcode,
        location_uid=location_uid,
        week_start_date=week_start_date,
        order_uid=order_uid,
    )
    response = await self._api.send_request(endpoint="list_slots", body=body)
    if not isinstance(response, dict):
        msg = "Slot week response was not a JSON object."
        raise TypeError(msg)
    week = SlotWeek.from_dict(
        response,
        slot_type=slot_type,
        store_identifier=store_identifier,
        postcode=postcode,
        location_uid=location_uid,
    )
    self._week_cache = week
    return week

list_collection(*, store_identifier=None, location_uid=None, week_start_date=None, order_uid=None, use_location_context=True) async

List click-and-collect slots.

Source code in pysainsburys/slots.py
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
async def list_collection(
    self,
    *,
    store_identifier: str | None = None,
    location_uid: str | None = None,
    week_start_date: str | None = None,
    order_uid: str | None = None,
    use_location_context: bool = True,
) -> SlotWeek:
    """List click-and-collect slots."""
    return await self.list(
        slot_type=SlotType.COLLECTION,
        store_identifier=store_identifier,
        location_uid=location_uid,
        week_start_date=week_start_date,
        order_uid=order_uid,
        use_location_context=use_location_context,
    )

list_delivery(*, postcode=None, store_identifier=None, week_start_date=None, order_uid=None, use_location_context=True) async

List home-delivery slots.

Source code in pysainsburys/slots.py
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
async def list_delivery(
    self,
    *,
    postcode: str | None = None,
    store_identifier: str | None = None,
    week_start_date: str | None = None,
    order_uid: str | None = None,
    use_location_context: bool = True,
) -> SlotWeek:
    """List home-delivery slots."""
    return await self.list(
        slot_type=SlotType.DELIVERY,
        postcode=postcode,
        store_identifier=store_identifier,
        week_start_date=week_start_date,
        order_uid=order_uid,
        use_location_context=use_location_context,
    )

reserve(slot, *, slot_type=None, start_time=None, end_time=None, store_identifier=None, postcode=None, location_uid=None, order_uid=None, use_location_context=True) async

Reserve a slot, or replace the current reservation with another slot.

This write path is inferred from static Android models and has not yet been validated against a live commerce session.

Source code in pysainsburys/slots.py
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
async def reserve(
    self,
    slot: DeliverySlot | str,
    *,
    slot_type: SlotType | None = None,
    start_time: str | None = None,
    end_time: str | None = None,
    store_identifier: str | None = None,
    postcode: str | None = None,
    location_uid: str | None = None,
    order_uid: str | None = None,
    use_location_context: bool = True,
) -> SlotReservation:
    """
    Reserve a slot, or replace the current reservation with another slot.

    This write path is inferred from static Android models and has not yet
    been validated against a live commerce session.
    """
    if isinstance(slot, DeliverySlot):
        slot_uid = slot.slot_uid
        start_time = start_time or slot.start_time
        end_time = end_time or slot.end_time
        slot_type = slot_type or _parse_slot_type(slot.slot_type)
    else:
        slot_uid = slot

    if not slot_uid:
        raise ValueError("The selected slot does not have a slot_uid.")

    if slot_type is None and self._week_cache is not None:
        slot_type = self._week_cache.slot_type

    if use_location_context:
        context = await self.fetch_location_context()
        slot_type = slot_type or _parse_slot_type(context.slot_type)
        store_identifier = store_identifier or context.store_identifier
        postcode = postcode or context.postcode
        location_uid = location_uid or context.location_uid
        order_uid = order_uid or context.order_uid

    if slot_type is None:
        msg = (
            "slot_type could not be inferred; pass slot_type, list slots first, "
            "or enable location context."
        )
        raise ValueError(msg)

    body = build_reserve_slot_payload(
        slot_type=slot_type,
        slot_uid=slot_uid,
        start_time=start_time,
        end_time=end_time,
        store_identifier=store_identifier,
        postcode=postcode,
        location_uid=location_uid,
        order_uid=order_uid,
    )
    response = await self._api.send_request(
        endpoint="create_slot_reservation",
        body=body,
    )
    if not isinstance(response, dict):
        msg = "Slot reservation response was not a JSON object."
        raise TypeError(msg)
    reservation = SlotReservation.from_dict(response)
    self._reservation_cache = reservation
    return reservation

validate(*, order_uid=None) async

Validate the customer's current slot reservation.

Source code in pysainsburys/slots.py
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
async def validate(self, *, order_uid: str | None = None) -> SlotReservation:
    """Validate the customer's current slot reservation."""
    params: dict[str, str | int | float | bool] | None = (
        {"order_uid": order_uid} if order_uid else None
    )
    response = await self._api.send_request(
        endpoint="validate_slot_reservation",
        params=params,
    )
    if not isinstance(response, dict):
        msg = "Slot reservation validation response was not a JSON object."
        raise TypeError(msg)
    reservation = SlotReservation.from_dict(response)
    self._reservation_cache = reservation
    return reservation

Store

pysainsburys.models.store

Store and in-store product models.

FinderPage dataclass

Pagination metadata from the Product Finder API.

Attributes:

Name Type Description
size int

Page size requested.

number int

Zero-based page index returned by Product Finder.

total_elements int

Total matching elements across all pages.

total_pages int

Total number of pages available.

Source code in pysainsburys/models/store/store.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
@dataclass(slots=True)
class FinderPage:
    """
    Pagination metadata from the Product Finder API.

    Attributes:
        size: Page size requested.
        number: Zero-based page index returned by Product Finder.
        total_elements: Total matching elements across all pages.
        total_pages: Total number of pages available.

    """

    size: int
    number: int
    total_elements: int
    total_pages: int

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> FinderPage:
        """Parse Product Finder pagination JSON."""
        data = data or {}
        return cls(
            size=int(data.get("size", 0)),
            number=int(data.get("number", 0)),
            total_elements=int(data.get("totalElements", 0)),
            total_pages=int(data.get("totalPages", 0)),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise pagination metadata to a plain dictionary."""
        return {
            "size": self.size,
            "number": self.number,
            "total_elements": self.total_elements,
            "total_pages": self.total_pages,
        }

from_dict(data) classmethod

Parse Product Finder pagination JSON.

Source code in pysainsburys/models/store/store.py
34
35
36
37
38
39
40
41
42
43
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> FinderPage:
    """Parse Product Finder pagination JSON."""
    data = data or {}
    return cls(
        size=int(data.get("size", 0)),
        number=int(data.get("number", 0)),
        total_elements=int(data.get("totalElements", 0)),
        total_pages=int(data.get("totalPages", 0)),
    )

to_dict()

Serialise pagination metadata to a plain dictionary.

Source code in pysainsburys/models/store/store.py
45
46
47
48
49
50
51
52
def to_dict(self) -> dict[str, Any]:
    """Serialise pagination metadata to a plain dictionary."""
    return {
        "size": self.size,
        "number": self.number,
        "total_elements": self.total_elements,
        "total_pages": self.total_pages,
    }

Store dataclass

A Sainsbury's store from Product Finder or click-and-collect.

When bound to a :class:~pysainsburys.Sainsburys client, a store can search in-store stock via :meth:search_products.

Attributes:

Name Type Description
name str

Store display name.

address1 str

Primary address line.

city str

Town or city.

post_code str

UK postcode.

is_available bool

Whether the store accepts online orders or collection.

store_id str

Product Finder store identifier.

store_number str | None

Internal store number for click-and-collect locations.

location_uid str | None

Click-and-collect location uid when applicable.

address2 str | None

Secondary address line.

county str | None

County or region.

opening_hours str | None

Opening hours text when provided.

distance float | None

Distance from the search origin in miles or kilometres.

telephone str | None

Store telephone number.

latitude float | None

WGS-84 latitude when available.

longitude float | None

WGS-84 longitude when available.

is_open bool | None

Whether the store is currently open when known.

click_and_collect_available bool

Whether click-and-collect is offered.

Source code in pysainsburys/models/store/store.py
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
@dataclass(slots=True)
class Store:
    """
    A Sainsbury's store from Product Finder or click-and-collect.

    When bound to a :class:`~pysainsburys.Sainsburys` client, a store can
    search in-store stock via :meth:`search_products`.

    Attributes:
        name: Store display name.
        address1: Primary address line.
        city: Town or city.
        post_code: UK postcode.
        is_available: Whether the store accepts online orders or collection.
        store_id: Product Finder store identifier.
        store_number: Internal store number for click-and-collect locations.
        location_uid: Click-and-collect location uid when applicable.
        address2: Secondary address line.
        county: County or region.
        opening_hours: Opening hours text when provided.
        distance: Distance from the search origin in miles or kilometres.
        telephone: Store telephone number.
        latitude: WGS-84 latitude when available.
        longitude: WGS-84 longitude when available.
        is_open: Whether the store is currently open when known.
        click_and_collect_available: Whether click-and-collect is offered.

    """

    name: str
    address1: str
    city: str
    post_code: str
    is_available: bool
    store_id: str = ""
    store_number: str | None = None
    location_uid: str | None = None
    address2: str | None = None
    county: str | None = None
    opening_hours: str | None = None
    distance: float | None = None
    telephone: str | None = None
    latitude: float | None = None
    longitude: float | None = None
    is_open: bool | None = None
    click_and_collect_available: bool = False
    _api: API | None = field(default=None, repr=False, compare=False, hash=False)

    @classmethod
    def from_dict(cls, data: dict[str, Any], *, api: API | None = None) -> Store:
        """Parse a store from Product Finder or click-and-collect JSON."""
        if "location_uid" in data or ("store_number" in data and "id" not in data):
            return cls.from_collect_dict(data, api=api)
        distance_raw = data.get("distance")
        distance = float(distance_raw) if distance_raw not in (None, "") else None
        lat_raw = data.get("latitude")
        lon_raw = data.get("longitude")
        return cls(
            store_id=str(data.get("id") or ""),
            name=str(data.get("name") or ""),
            address1=str(data.get("address1") or ""),
            address2=data.get("address2") or None,
            city=str(data.get("city") or ""),
            post_code=str(data.get("postCode") or data.get("postcode") or ""),
            opening_hours=data.get("openingHours"),
            distance=distance,
            telephone=data.get("telephone"),
            latitude=float(lat_raw) if lat_raw is not None else None,
            longitude=float(lon_raw) if lon_raw is not None else None,
            is_available=bool(data.get("isAvailable", True)),
            is_open=data.get("isOpen"),
            click_and_collect_available=bool(data.get("isAvailable", False)),
            _api=api,
        )

    @classmethod
    def from_collect_dict(
        cls,
        data: dict[str, Any],
        *,
        api: API | None = None,
    ) -> Store:
        """Parse a click-and-collect store location from grocery API JSON."""
        distance_raw = data.get("distance")
        return cls(
            name=str(data.get("name") or ""),
            address1=str(data.get("address1") or ""),
            city=str(data.get("city") or ""),
            post_code=str(data.get("postcode") or ""),
            is_available=bool(data.get("is_available", True)),
            location_uid=str(data.get("location_uid") or "") or None,
            store_number=str(data.get("store_number") or "") or None,
            address2=data.get("address2") or None,
            county=data.get("county") or None,
            distance=float(distance_raw) if distance_raw is not None else None,
            click_and_collect_available=bool(data.get("is_available", True)),
            _api=api,
        )

    def _require_api(self) -> API:
        if self._api is None:
            msg = (
                "Store is not bound to a Sainsburys client; "
                "fetch it via Sainsburys.find_stores(), find_stores_by_postcode(), "
                "or get_store()."
            )
            raise NotBoundError(msg)
        return self._api

    def bind_api(self, api: API) -> Store:
        """Attach a client for in-store product lookups."""
        self._api = api
        return self

    @property
    def product_finder_id(self) -> str:
        """Return the Product Finder store id used for in-store product search."""
        return self.store_id

    async def search_products(
        self,
        keyword: str,
        *,
        page: int = 1,
        page_size: int = 20,
    ) -> StoreProductList:
        """Search in-store products with aisle and stock for this store."""
        response = await self._require_api().send_product_finder_request(
            "/v2/products",
            params={
                "storeId": self.store_id,
                "keyword": keyword,
                "page": page,
                "size": page_size,
            },
        )
        if not isinstance(response, dict):
            msg = "Store product search response was not a JSON object."
            raise TypeError(msg)
        return StoreProductList.from_dict(response)

    def to_dict(self) -> dict[str, Any]:
        """Serialise the store to a plain dictionary."""
        return {
            "store_id": self.store_id,
            "store_number": self.store_number,
            "location_uid": self.location_uid,
            "name": self.name,
            "address1": self.address1,
            "address2": self.address2,
            "city": self.city,
            "county": self.county,
            "post_code": self.post_code,
            "opening_hours": self.opening_hours,
            "distance": self.distance,
            "telephone": self.telephone,
            "latitude": self.latitude,
            "longitude": self.longitude,
            "is_available": self.is_available,
            "is_open": self.is_open,
            "click_and_collect_available": self.click_and_collect_available,
        }

product_finder_id property

Return the Product Finder store id used for in-store product search.

bind_api(api)

Attach a client for in-store product lookups.

Source code in pysainsburys/models/store/store.py
164
165
166
167
def bind_api(self, api: API) -> Store:
    """Attach a client for in-store product lookups."""
    self._api = api
    return self

from_collect_dict(data, *, api=None) classmethod

Parse a click-and-collect store location from grocery API JSON.

Source code in pysainsburys/models/store/store.py
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
@classmethod
def from_collect_dict(
    cls,
    data: dict[str, Any],
    *,
    api: API | None = None,
) -> Store:
    """Parse a click-and-collect store location from grocery API JSON."""
    distance_raw = data.get("distance")
    return cls(
        name=str(data.get("name") or ""),
        address1=str(data.get("address1") or ""),
        city=str(data.get("city") or ""),
        post_code=str(data.get("postcode") or ""),
        is_available=bool(data.get("is_available", True)),
        location_uid=str(data.get("location_uid") or "") or None,
        store_number=str(data.get("store_number") or "") or None,
        address2=data.get("address2") or None,
        county=data.get("county") or None,
        distance=float(distance_raw) if distance_raw is not None else None,
        click_and_collect_available=bool(data.get("is_available", True)),
        _api=api,
    )

from_dict(data, *, api=None) classmethod

Parse a store from Product Finder or click-and-collect JSON.

Source code in pysainsburys/models/store/store.py
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
@classmethod
def from_dict(cls, data: dict[str, Any], *, api: API | None = None) -> Store:
    """Parse a store from Product Finder or click-and-collect JSON."""
    if "location_uid" in data or ("store_number" in data and "id" not in data):
        return cls.from_collect_dict(data, api=api)
    distance_raw = data.get("distance")
    distance = float(distance_raw) if distance_raw not in (None, "") else None
    lat_raw = data.get("latitude")
    lon_raw = data.get("longitude")
    return cls(
        store_id=str(data.get("id") or ""),
        name=str(data.get("name") or ""),
        address1=str(data.get("address1") or ""),
        address2=data.get("address2") or None,
        city=str(data.get("city") or ""),
        post_code=str(data.get("postCode") or data.get("postcode") or ""),
        opening_hours=data.get("openingHours"),
        distance=distance,
        telephone=data.get("telephone"),
        latitude=float(lat_raw) if lat_raw is not None else None,
        longitude=float(lon_raw) if lon_raw is not None else None,
        is_available=bool(data.get("isAvailable", True)),
        is_open=data.get("isOpen"),
        click_and_collect_available=bool(data.get("isAvailable", False)),
        _api=api,
    )

search_products(keyword, *, page=1, page_size=20) async

Search in-store products with aisle and stock for this store.

Source code in pysainsburys/models/store/store.py
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
async def search_products(
    self,
    keyword: str,
    *,
    page: int = 1,
    page_size: int = 20,
) -> StoreProductList:
    """Search in-store products with aisle and stock for this store."""
    response = await self._require_api().send_product_finder_request(
        "/v2/products",
        params={
            "storeId": self.store_id,
            "keyword": keyword,
            "page": page,
            "size": page_size,
        },
    )
    if not isinstance(response, dict):
        msg = "Store product search response was not a JSON object."
        raise TypeError(msg)
    return StoreProductList.from_dict(response)

to_dict()

Serialise the store to a plain dictionary.

Source code in pysainsburys/models/store/store.py
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
def to_dict(self) -> dict[str, Any]:
    """Serialise the store to a plain dictionary."""
    return {
        "store_id": self.store_id,
        "store_number": self.store_number,
        "location_uid": self.location_uid,
        "name": self.name,
        "address1": self.address1,
        "address2": self.address2,
        "city": self.city,
        "county": self.county,
        "post_code": self.post_code,
        "opening_hours": self.opening_hours,
        "distance": self.distance,
        "telephone": self.telephone,
        "latitude": self.latitude,
        "longitude": self.longitude,
        "is_available": self.is_available,
        "is_open": self.is_open,
        "click_and_collect_available": self.click_and_collect_available,
    }

StoreList dataclass

A paginated list of stores.

Source code in pysainsburys/models/store/store.py
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
@dataclass(slots=True)
class StoreList:
    """A paginated list of stores."""

    stores: list[Store]
    page: FinderPage | None = None
    controls: PageControls | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any], *, api: API | None = None) -> StoreList:
        """Parse stores from Product Finder or click-and-collect JSON."""
        if "locations" in data:
            stores = [
                Store.from_dict(item, api=api) for item in data.get("locations", [])
            ]
            return cls(
                stores=stores,
                controls=PageControls.from_dict(data.get("controls")),
            )
        stores = [Store.from_dict(item, api=api) for item in data.get("content", [])]
        return cls(stores=stores, page=FinderPage.from_dict(data.get("page")))

    def to_dict(self) -> dict[str, Any]:
        """Serialise the store list to a plain dictionary."""
        payload: dict[str, Any] = {
            "stores": [store.to_dict() for store in self.stores],
        }
        if self.page is not None:
            payload["page"] = self.page.to_dict()
        if self.controls is not None:
            payload["controls"] = self.controls.to_dict()
        return payload

from_dict(data, *, api=None) classmethod

Parse stores from Product Finder or click-and-collect JSON.

Source code in pysainsburys/models/store/store.py
227
228
229
230
231
232
233
234
235
236
237
238
239
@classmethod
def from_dict(cls, data: dict[str, Any], *, api: API | None = None) -> StoreList:
    """Parse stores from Product Finder or click-and-collect JSON."""
    if "locations" in data:
        stores = [
            Store.from_dict(item, api=api) for item in data.get("locations", [])
        ]
        return cls(
            stores=stores,
            controls=PageControls.from_dict(data.get("controls")),
        )
    stores = [Store.from_dict(item, api=api) for item in data.get("content", [])]
    return cls(stores=stores, page=FinderPage.from_dict(data.get("page")))

to_dict()

Serialise the store list to a plain dictionary.

Source code in pysainsburys/models/store/store.py
241
242
243
244
245
246
247
248
249
250
def to_dict(self) -> dict[str, Any]:
    """Serialise the store list to a plain dictionary."""
    payload: dict[str, Any] = {
        "stores": [store.to_dict() for store in self.stores],
    }
    if self.page is not None:
        payload["page"] = self.page.to_dict()
    if self.controls is not None:
        payload["controls"] = self.controls.to_dict()
    return payload

StoreProduct dataclass

A product with in-store aisle and stock information.

Attributes:

Name Type Description
product_code str

In-store product code used by Product Finder.

name str

Shelf label product name.

stock str

Stock status string (for example In Stock).

price float | None

Shelf price in pounds sterling.

price_per_unit float | None

Normalised unit price when provided.

unit_of_measure str | None

Unit label for price_per_unit.

aisle str | None

Aisle number or location hint in the store.

image_url str | None

Product image URL when available.

is_nectar_price bool

Whether the price is a Nectar offer.

promotions list[dict[str, Any]]

Raw promotion payloads from Product Finder.

Source code in pysainsburys/models/store/store.py
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
@dataclass(slots=True)
class StoreProduct:
    """
    A product with in-store aisle and stock information.

    Attributes:
        product_code: In-store product code used by Product Finder.
        name: Shelf label product name.
        stock: Stock status string (for example ``In Stock``).
        price: Shelf price in pounds sterling.
        price_per_unit: Normalised unit price when provided.
        unit_of_measure: Unit label for ``price_per_unit``.
        aisle: Aisle number or location hint in the store.
        image_url: Product image URL when available.
        is_nectar_price: Whether the price is a Nectar offer.
        promotions: Raw promotion payloads from Product Finder.

    """

    product_code: str
    name: str
    stock: str
    price: float | None = None
    price_per_unit: float | None = None
    unit_of_measure: str | None = None
    aisle: str | None = None
    image_url: str | None = None
    is_nectar_price: bool = False
    promotions: list[dict[str, Any]] = field(default_factory=list)

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> StoreProduct:
        """Parse an in-store product from Product Finder JSON."""
        retail = data.get("retail") or {}
        price_raw = retail.get("price")
        ppu_raw = retail.get("pricePerUnit")
        return cls(
            product_code=str(data.get("productCode") or ""),
            name=str(data.get("productName") or ""),
            stock=str(data.get("stock") or ""),
            price=float(price_raw) if price_raw not in (None, "") else None,
            price_per_unit=float(ppu_raw) if ppu_raw not in (None, "") else None,
            unit_of_measure=data.get("unitOfMeasure"),
            aisle=data.get("aisle"),
            image_url=data.get("image"),
            is_nectar_price=bool(data.get("isNectarPrice", False)),
            promotions=list(data.get("promotions") or []),
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise the in-store product to a plain dictionary."""
        return {
            "product_code": self.product_code,
            "name": self.name,
            "stock": self.stock,
            "price": self.price,
            "price_per_unit": self.price_per_unit,
            "unit_of_measure": self.unit_of_measure,
            "aisle": self.aisle,
            "image_url": self.image_url,
            "is_nectar_price": self.is_nectar_price,
            "promotions": self.promotions,
        }

from_dict(data) classmethod

Parse an in-store product from Product Finder JSON.

Source code in pysainsburys/models/store/store.py
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
@classmethod
def from_dict(cls, data: dict[str, Any]) -> StoreProduct:
    """Parse an in-store product from Product Finder JSON."""
    retail = data.get("retail") or {}
    price_raw = retail.get("price")
    ppu_raw = retail.get("pricePerUnit")
    return cls(
        product_code=str(data.get("productCode") or ""),
        name=str(data.get("productName") or ""),
        stock=str(data.get("stock") or ""),
        price=float(price_raw) if price_raw not in (None, "") else None,
        price_per_unit=float(ppu_raw) if ppu_raw not in (None, "") else None,
        unit_of_measure=data.get("unitOfMeasure"),
        aisle=data.get("aisle"),
        image_url=data.get("image"),
        is_nectar_price=bool(data.get("isNectarPrice", False)),
        promotions=list(data.get("promotions") or []),
    )

to_dict()

Serialise the in-store product to a plain dictionary.

Source code in pysainsburys/models/store/store.py
302
303
304
305
306
307
308
309
310
311
312
313
314
315
def to_dict(self) -> dict[str, Any]:
    """Serialise the in-store product to a plain dictionary."""
    return {
        "product_code": self.product_code,
        "name": self.name,
        "stock": self.stock,
        "price": self.price,
        "price_per_unit": self.price_per_unit,
        "unit_of_measure": self.unit_of_measure,
        "aisle": self.aisle,
        "image_url": self.image_url,
        "is_nectar_price": self.is_nectar_price,
        "promotions": self.promotions,
    }

StoreProductList dataclass

In-store product search results for a chosen store.

Source code in pysainsburys/models/store/store.py
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
@dataclass(slots=True)
class StoreProductList:
    """In-store product search results for a chosen store."""

    products: list[StoreProduct]
    page: FinderPage
    suggested_search_terms: list[str] = field(default_factory=list)

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> StoreProductList:
        """Parse in-store product results from Product Finder JSON."""
        products = [StoreProduct.from_dict(item) for item in data.get("content", [])]
        return cls(
            products=products,
            page=FinderPage.from_dict(data.get("page")),
            suggested_search_terms=[
                str(term) for term in data.get("suggestedSearchTerms", [])
            ],
        )

    def to_dict(self) -> dict[str, Any]:
        """Serialise in-store product results to a plain dictionary."""
        return {
            "products": [product.to_dict() for product in self.products],
            "page": self.page.to_dict(),
            "suggested_search_terms": self.suggested_search_terms,
        }

    def __iter__(self) -> Iterator[tuple[str, Any]]:
        """Allow ``dict(store_product_list)`` conversion."""
        return iter(self.to_dict().items())

__iter__()

Allow dict(store_product_list) conversion.

Source code in pysainsburys/models/store/store.py
346
347
348
def __iter__(self) -> Iterator[tuple[str, Any]]:
    """Allow ``dict(store_product_list)`` conversion."""
    return iter(self.to_dict().items())

from_dict(data) classmethod

Parse in-store product results from Product Finder JSON.

Source code in pysainsburys/models/store/store.py
326
327
328
329
330
331
332
333
334
335
336
@classmethod
def from_dict(cls, data: dict[str, Any]) -> StoreProductList:
    """Parse in-store product results from Product Finder JSON."""
    products = [StoreProduct.from_dict(item) for item in data.get("content", [])]
    return cls(
        products=products,
        page=FinderPage.from_dict(data.get("page")),
        suggested_search_terms=[
            str(term) for term in data.get("suggestedSearchTerms", [])
        ],
    )

to_dict()

Serialise in-store product results to a plain dictionary.

Source code in pysainsburys/models/store/store.py
338
339
340
341
342
343
344
def to_dict(self) -> dict[str, Any]:
    """Serialise in-store product results to a plain dictionary."""
    return {
        "products": [product.to_dict() for product in self.products],
        "page": self.page.to_dict(),
        "suggested_search_terms": self.suggested_search_terms,
    }

bind_store(api, store)

Attach an API client to a store for in-store product lookups.

Source code in pysainsburys/models/store/store.py
351
352
353
def bind_store(api: API, store: Store) -> Store:
    """Attach an API client to a store for in-store product lookups."""
    return store.bind_api(api)

bind_stores(api, stores)

Attach an API client to each store in a list.

Source code in pysainsburys/models/store/store.py
356
357
358
359
360
def bind_stores(api: API, stores: list[Store]) -> list[Store]:
    """Attach an API client to each store in a list."""
    for store in stores:
        bind_store(api, store)
    return stores

pysainsburys.models.store.store.Store dataclass

A Sainsbury's store from Product Finder or click-and-collect.

When bound to a :class:~pysainsburys.Sainsburys client, a store can search in-store stock via :meth:search_products.

Attributes:

Name Type Description
name str

Store display name.

address1 str

Primary address line.

city str

Town or city.

post_code str

UK postcode.

is_available bool

Whether the store accepts online orders or collection.

store_id str

Product Finder store identifier.

store_number str | None

Internal store number for click-and-collect locations.

location_uid str | None

Click-and-collect location uid when applicable.

address2 str | None

Secondary address line.

county str | None

County or region.

opening_hours str | None

Opening hours text when provided.

distance float | None

Distance from the search origin in miles or kilometres.

telephone str | None

Store telephone number.

latitude float | None

WGS-84 latitude when available.

longitude float | None

WGS-84 longitude when available.

is_open bool | None

Whether the store is currently open when known.

click_and_collect_available bool

Whether click-and-collect is offered.

Source code in pysainsburys/models/store/store.py
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
@dataclass(slots=True)
class Store:
    """
    A Sainsbury's store from Product Finder or click-and-collect.

    When bound to a :class:`~pysainsburys.Sainsburys` client, a store can
    search in-store stock via :meth:`search_products`.

    Attributes:
        name: Store display name.
        address1: Primary address line.
        city: Town or city.
        post_code: UK postcode.
        is_available: Whether the store accepts online orders or collection.
        store_id: Product Finder store identifier.
        store_number: Internal store number for click-and-collect locations.
        location_uid: Click-and-collect location uid when applicable.
        address2: Secondary address line.
        county: County or region.
        opening_hours: Opening hours text when provided.
        distance: Distance from the search origin in miles or kilometres.
        telephone: Store telephone number.
        latitude: WGS-84 latitude when available.
        longitude: WGS-84 longitude when available.
        is_open: Whether the store is currently open when known.
        click_and_collect_available: Whether click-and-collect is offered.

    """

    name: str
    address1: str
    city: str
    post_code: str
    is_available: bool
    store_id: str = ""
    store_number: str | None = None
    location_uid: str | None = None
    address2: str | None = None
    county: str | None = None
    opening_hours: str | None = None
    distance: float | None = None
    telephone: str | None = None
    latitude: float | None = None
    longitude: float | None = None
    is_open: bool | None = None
    click_and_collect_available: bool = False
    _api: API | None = field(default=None, repr=False, compare=False, hash=False)

    @classmethod
    def from_dict(cls, data: dict[str, Any], *, api: API | None = None) -> Store:
        """Parse a store from Product Finder or click-and-collect JSON."""
        if "location_uid" in data or ("store_number" in data and "id" not in data):
            return cls.from_collect_dict(data, api=api)
        distance_raw = data.get("distance")
        distance = float(distance_raw) if distance_raw not in (None, "") else None
        lat_raw = data.get("latitude")
        lon_raw = data.get("longitude")
        return cls(
            store_id=str(data.get("id") or ""),
            name=str(data.get("name") or ""),
            address1=str(data.get("address1") or ""),
            address2=data.get("address2") or None,
            city=str(data.get("city") or ""),
            post_code=str(data.get("postCode") or data.get("postcode") or ""),
            opening_hours=data.get("openingHours"),
            distance=distance,
            telephone=data.get("telephone"),
            latitude=float(lat_raw) if lat_raw is not None else None,
            longitude=float(lon_raw) if lon_raw is not None else None,
            is_available=bool(data.get("isAvailable", True)),
            is_open=data.get("isOpen"),
            click_and_collect_available=bool(data.get("isAvailable", False)),
            _api=api,
        )

    @classmethod
    def from_collect_dict(
        cls,
        data: dict[str, Any],
        *,
        api: API | None = None,
    ) -> Store:
        """Parse a click-and-collect store location from grocery API JSON."""
        distance_raw = data.get("distance")
        return cls(
            name=str(data.get("name") or ""),
            address1=str(data.get("address1") or ""),
            city=str(data.get("city") or ""),
            post_code=str(data.get("postcode") or ""),
            is_available=bool(data.get("is_available", True)),
            location_uid=str(data.get("location_uid") or "") or None,
            store_number=str(data.get("store_number") or "") or None,
            address2=data.get("address2") or None,
            county=data.get("county") or None,
            distance=float(distance_raw) if distance_raw is not None else None,
            click_and_collect_available=bool(data.get("is_available", True)),
            _api=api,
        )

    def _require_api(self) -> API:
        if self._api is None:
            msg = (
                "Store is not bound to a Sainsburys client; "
                "fetch it via Sainsburys.find_stores(), find_stores_by_postcode(), "
                "or get_store()."
            )
            raise NotBoundError(msg)
        return self._api

    def bind_api(self, api: API) -> Store:
        """Attach a client for in-store product lookups."""
        self._api = api
        return self

    @property
    def product_finder_id(self) -> str:
        """Return the Product Finder store id used for in-store product search."""
        return self.store_id

    async def search_products(
        self,
        keyword: str,
        *,
        page: int = 1,
        page_size: int = 20,
    ) -> StoreProductList:
        """Search in-store products with aisle and stock for this store."""
        response = await self._require_api().send_product_finder_request(
            "/v2/products",
            params={
                "storeId": self.store_id,
                "keyword": keyword,
                "page": page,
                "size": page_size,
            },
        )
        if not isinstance(response, dict):
            msg = "Store product search response was not a JSON object."
            raise TypeError(msg)
        return StoreProductList.from_dict(response)

    def to_dict(self) -> dict[str, Any]:
        """Serialise the store to a plain dictionary."""
        return {
            "store_id": self.store_id,
            "store_number": self.store_number,
            "location_uid": self.location_uid,
            "name": self.name,
            "address1": self.address1,
            "address2": self.address2,
            "city": self.city,
            "county": self.county,
            "post_code": self.post_code,
            "opening_hours": self.opening_hours,
            "distance": self.distance,
            "telephone": self.telephone,
            "latitude": self.latitude,
            "longitude": self.longitude,
            "is_available": self.is_available,
            "is_open": self.is_open,
            "click_and_collect_available": self.click_and_collect_available,
        }

product_finder_id property

Return the Product Finder store id used for in-store product search.

bind_api(api)

Attach a client for in-store product lookups.

Source code in pysainsburys/models/store/store.py
164
165
166
167
def bind_api(self, api: API) -> Store:
    """Attach a client for in-store product lookups."""
    self._api = api
    return self

from_collect_dict(data, *, api=None) classmethod

Parse a click-and-collect store location from grocery API JSON.

Source code in pysainsburys/models/store/store.py
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
@classmethod
def from_collect_dict(
    cls,
    data: dict[str, Any],
    *,
    api: API | None = None,
) -> Store:
    """Parse a click-and-collect store location from grocery API JSON."""
    distance_raw = data.get("distance")
    return cls(
        name=str(data.get("name") or ""),
        address1=str(data.get("address1") or ""),
        city=str(data.get("city") or ""),
        post_code=str(data.get("postcode") or ""),
        is_available=bool(data.get("is_available", True)),
        location_uid=str(data.get("location_uid") or "") or None,
        store_number=str(data.get("store_number") or "") or None,
        address2=data.get("address2") or None,
        county=data.get("county") or None,
        distance=float(distance_raw) if distance_raw is not None else None,
        click_and_collect_available=bool(data.get("is_available", True)),
        _api=api,
    )

from_dict(data, *, api=None) classmethod

Parse a store from Product Finder or click-and-collect JSON.

Source code in pysainsburys/models/store/store.py
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
@classmethod
def from_dict(cls, data: dict[str, Any], *, api: API | None = None) -> Store:
    """Parse a store from Product Finder or click-and-collect JSON."""
    if "location_uid" in data or ("store_number" in data and "id" not in data):
        return cls.from_collect_dict(data, api=api)
    distance_raw = data.get("distance")
    distance = float(distance_raw) if distance_raw not in (None, "") else None
    lat_raw = data.get("latitude")
    lon_raw = data.get("longitude")
    return cls(
        store_id=str(data.get("id") or ""),
        name=str(data.get("name") or ""),
        address1=str(data.get("address1") or ""),
        address2=data.get("address2") or None,
        city=str(data.get("city") or ""),
        post_code=str(data.get("postCode") or data.get("postcode") or ""),
        opening_hours=data.get("openingHours"),
        distance=distance,
        telephone=data.get("telephone"),
        latitude=float(lat_raw) if lat_raw is not None else None,
        longitude=float(lon_raw) if lon_raw is not None else None,
        is_available=bool(data.get("isAvailable", True)),
        is_open=data.get("isOpen"),
        click_and_collect_available=bool(data.get("isAvailable", False)),
        _api=api,
    )

search_products(keyword, *, page=1, page_size=20) async

Search in-store products with aisle and stock for this store.

Source code in pysainsburys/models/store/store.py
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
async def search_products(
    self,
    keyword: str,
    *,
    page: int = 1,
    page_size: int = 20,
) -> StoreProductList:
    """Search in-store products with aisle and stock for this store."""
    response = await self._require_api().send_product_finder_request(
        "/v2/products",
        params={
            "storeId": self.store_id,
            "keyword": keyword,
            "page": page,
            "size": page_size,
        },
    )
    if not isinstance(response, dict):
        msg = "Store product search response was not a JSON object."
        raise TypeError(msg)
    return StoreProductList.from_dict(response)

to_dict()

Serialise the store to a plain dictionary.

Source code in pysainsburys/models/store/store.py
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
def to_dict(self) -> dict[str, Any]:
    """Serialise the store to a plain dictionary."""
    return {
        "store_id": self.store_id,
        "store_number": self.store_number,
        "location_uid": self.location_uid,
        "name": self.name,
        "address1": self.address1,
        "address2": self.address2,
        "city": self.city,
        "county": self.county,
        "post_code": self.post_code,
        "opening_hours": self.opening_hours,
        "distance": self.distance,
        "telephone": self.telephone,
        "latitude": self.latitude,
        "longitude": self.longitude,
        "is_available": self.is_available,
        "is_open": self.is_open,
        "click_and_collect_available": self.click_and_collect_available,
    }

Exceptions

pysainsburys.exceptions

Exceptions for Sainsbury's GOL API.

AccessDeniedError

Bases: AuthError

Resource owner or policy denied the request.

Source code in pysainsburys/exceptions.py
117
118
class AccessDeniedError(AuthError):
    """Resource owner or policy denied the request."""

AuthError

Bases: Exception

General authentication error.

Source code in pysainsburys/exceptions.py
56
57
class AuthError(Exception):
    """General authentication error."""

BrowserLoginRequiredError

Bases: InteractionRequiredError

Interactive browser login is required to continue.

When raised, open :attr:authorization_url in a desktop browser, sign in, then call :meth:GOLAuth.finish_login with the redirect URL or code.

Source code in pysainsburys/exceptions.py
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
class BrowserLoginRequiredError(InteractionRequiredError):
    """
    Interactive browser login is required to continue.

    When raised, open :attr:`authorization_url` in a desktop browser, sign in,
    then call :meth:`GOLAuth.finish_login` with the redirect URL or code.
    """

    def __init__(
        self,
        message: str = "Browser login required",
        *,
        authorization_url: str | None = None,
    ) -> None:
        super().__init__(message)
        self.authorization_url = authorization_url

CommerceSessionError

Bases: AuthError

Error exchanging OAuth tokens for a commerce session.

Source code in pysainsburys/exceptions.py
137
138
class CommerceSessionError(AuthError):
    """Error exchanging OAuth tokens for a commerce session."""

ConfirmationRedirectError

Bases: AuthError

Error confirming login with redirect.

Source code in pysainsburys/exceptions.py
141
142
class ConfirmationRedirectError(AuthError):
    """Error confirming login with redirect."""

ExpiredAccessTokenError

Bases: AuthError

401 Unauthorized — access token or commerce session expired.

Source code in pysainsburys/exceptions.py
89
90
class ExpiredAccessTokenError(AuthError):
    """401 Unauthorized — access token or commerce session expired."""

HttpException

Bases: Exception

General HTTP error storing status and response.

Source code in pysainsburys/exceptions.py
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
class HttpException(Exception):
    """General HTTP error storing status and response."""

    def __init__(self, status: int, response: str) -> None:
        self.status = status
        self.response = response
        self.response_json = parse_error_response(response)
        super().__init__(
            format_http_error_message(
                status,
                response,
                parsed=self.response_json,
            )
        )

    @property
    def errors(self) -> list[dict[str, Any]]:
        """Return structured API errors when the body matches GOL error JSON."""
        if isinstance(self.response_json, dict):
            raw_errors = self.response_json.get("errors")
            if isinstance(raw_errors, list):
                return [error for error in raw_errors if isinstance(error, dict)]
        return []

errors property

Return structured API errors when the body matches GOL error JSON.

InteractionRequiredError

Bases: AuthError

Interactive user action is required to continue.

Source code in pysainsburys/exceptions.py
121
122
class InteractionRequiredError(AuthError):
    """Interactive user action is required to continue."""

InvalidClientError

Bases: AuthError

Client authentication failed.

Source code in pysainsburys/exceptions.py
101
102
class InvalidClientError(AuthError):
    """Client authentication failed."""

InvalidGrantError

Bases: AuthError

OAuth grant expired or invalid.

Source code in pysainsburys/exceptions.py
93
94
class InvalidGrantError(AuthError):
    """OAuth grant expired or invalid."""

InvalidRequestError

Bases: AuthError

OAuth request is malformed or missing required parameters.

Source code in pysainsburys/exceptions.py
97
98
class InvalidRequestError(AuthError):
    """OAuth request is malformed or missing required parameters."""

InvalidScopeError

Bases: AuthError

Requested OAuth scope is invalid, unknown, or malformed.

Source code in pysainsburys/exceptions.py
113
114
class InvalidScopeError(AuthError):
    """Requested OAuth scope is invalid, unknown, or malformed."""

LoginRequiredError

Bases: AuthError

User sign-in is required to continue.

Source code in pysainsburys/exceptions.py
125
126
class LoginRequiredError(AuthError):
    """User sign-in is required to continue."""

MFARequiredError

Bases: InteractionRequiredError

MFA is required to complete the login flow.

Raised after :meth:GOLAuth.request_mfa_code has triggered delivery of the verification code. Call :meth:GOLAuth.send_mfa_request with the code received by the user.

Source code in pysainsburys/exceptions.py
163
164
165
166
167
168
169
170
class MFARequiredError(InteractionRequiredError):
    """
    MFA is required to complete the login flow.

    Raised after :meth:`GOLAuth.request_mfa_code` has triggered delivery of
    the verification code. Call :meth:`GOLAuth.send_mfa_request` with the
    code received by the user.
    """

NotBoundError

Bases: Exception

Domain object is not bound to an authenticated client.

Source code in pysainsburys/exceptions.py
177
178
class NotBoundError(Exception):
    """Domain object is not bound to an authenticated client."""

ParseError

Bases: Exception

Error parsing an API response into a domain model.

Source code in pysainsburys/exceptions.py
173
174
class ParseError(Exception):
    """Error parsing an API response into a domain model."""

SessionRequiredError

Bases: AuthError

Commerce session headers or cookies are missing.

Source code in pysainsburys/exceptions.py
129
130
class SessionRequiredError(AuthError):
    """Commerce session headers or cookies are missing."""

TokenRequestError

Bases: AuthError

Error requesting a token from the token server.

Source code in pysainsburys/exceptions.py
133
134
class TokenRequestError(AuthError):
    """Error requesting a token from the token server."""

UnauthorizedClientError

Bases: AuthError

Client is not authorized for this grant type or flow.

Source code in pysainsburys/exceptions.py
105
106
class UnauthorizedClientError(AuthError):
    """Client is not authorized for this grant type or flow."""

UnknownEndpointError

Bases: HttpException

Unexpected HTTP status from a known endpoint.

Source code in pysainsburys/exceptions.py
85
86
class UnknownEndpointError(HttpException):
    """Unexpected HTTP status from a known endpoint."""

UnsupportedGrantTypeError

Bases: AuthError

OAuth grant type is not supported.

Source code in pysainsburys/exceptions.py
109
110
class UnsupportedGrantTypeError(AuthError):
    """OAuth grant type is not supported."""

format_http_error_message(status, response, *, parsed=None)

Format a human-readable HTTP error message.

Source code in pysainsburys/exceptions.py
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
def format_http_error_message(
    status: int,
    response: str,
    *,
    parsed: str | dict[str, Any] | list[Any] | None = None,
) -> str:
    """Format a human-readable HTTP error message."""
    body = parsed if parsed is not None else parse_error_response(response)
    if isinstance(body, dict):
        errors = body.get("errors")
        if isinstance(errors, list) and errors:
            parts: list[str] = []
            for error in errors:
                if not isinstance(error, dict):
                    continue
                code = str(error.get("code") or "").strip()
                detail = str(error.get("detail") or error.get("title") or "").strip()
                if code and detail:
                    parts.append(f"{code}: {detail}")
                elif code:
                    parts.append(code)
                elif detail:
                    parts.append(detail)
            if parts:
                return f"HTTP {status}: {'; '.join(parts)}"
        return f"HTTP {status}: {json.dumps(body)}"
    if isinstance(body, list):
        return f"HTTP {status}: {json.dumps(body)}"
    if body is None:
        return f"HTTP {status}"
    return f"HTTP {status}: {body}"

parse_error_response(response)

Parse an HTTP error body as JSON when possible.

Source code in pysainsburys/exceptions.py
 9
10
11
12
13
14
15
16
17
18
19
20
def parse_error_response(response: str) -> str | dict[str, Any] | list[Any] | None:
    """Parse an HTTP error body as JSON when possible."""
    text = response.strip()
    if not text:
        return None
    try:
        parsed = json.loads(text)
    except json.JSONDecodeError:
        return text
    if isinstance(parsed, (dict, list)):
        return parsed
    return text

Configuration

pysainsburys.config

Connection settings for Sainsbury's GOL API.

Config dataclass

Where and how to connect to the grocery API.

Source code in pysainsburys/config.py
12
13
14
15
16
17
@dataclass(slots=True)
class Config:
    """Where and how to connect to the grocery API."""

    base_url: str = GOL_BASE_URL
    app_version: str = GOL_APP_USER_AGENT.removeprefix("GOLAppAndroid/")