bilkatogo 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bilkatogo/__init__.py ADDED
@@ -0,0 +1,58 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ from .client import BilkaToGoClient
4
+ from .const import CURRENT_ORDER_SCAN_LIMIT
5
+ from .exceptions import (
6
+ BilkaToGoError,
7
+ BilkaUnreachableError,
8
+ InvalidCredentialsError,
9
+ UnexpectedResponseError,
10
+ UnknownPickupPointError,
11
+ )
12
+ from .models import (
13
+ FINISHED_STATUSES,
14
+ MissingItem,
15
+ Offer,
16
+ Order,
17
+ OrderDetails,
18
+ OrderLine,
19
+ OrderStatus,
20
+ PickupCode,
21
+ PickupPoint,
22
+ PickupSlot,
23
+ Product,
24
+ Substitution,
25
+ current_order,
26
+ )
27
+ from .token_storage import FileTokenStorage, TokenStorage
28
+
29
+ try:
30
+ __version__ = version("bilkatogo")
31
+ except PackageNotFoundError:
32
+ __version__ = "0.0.0"
33
+
34
+ __all__ = [
35
+ "CURRENT_ORDER_SCAN_LIMIT",
36
+ "FINISHED_STATUSES",
37
+ "BilkaToGoClient",
38
+ "BilkaToGoError",
39
+ "BilkaUnreachableError",
40
+ "FileTokenStorage",
41
+ "InvalidCredentialsError",
42
+ "MissingItem",
43
+ "Offer",
44
+ "Order",
45
+ "OrderDetails",
46
+ "OrderLine",
47
+ "OrderStatus",
48
+ "PickupCode",
49
+ "PickupPoint",
50
+ "PickupSlot",
51
+ "Product",
52
+ "Substitution",
53
+ "TokenStorage",
54
+ "UnexpectedResponseError",
55
+ "UnknownPickupPointError",
56
+ "__version__",
57
+ "current_order",
58
+ ]
bilkatogo/__main__.py ADDED
@@ -0,0 +1,21 @@
1
+ import sys
2
+
3
+ MISSING_CLI_EXTRA = (
4
+ "The bilkatogo command needs the CLI extra: pip install 'bilkatogo[cli]' "
5
+ "or uvx --from 'bilkatogo[cli]' bilkatogo"
6
+ )
7
+
8
+
9
+ def main() -> None:
10
+ try:
11
+ from .cli import main as cli_main
12
+ except ModuleNotFoundError as err:
13
+ if err.name != "typer":
14
+ raise
15
+ print(MISSING_CLI_EXTRA, file=sys.stderr)
16
+ sys.exit(1)
17
+ cli_main()
18
+
19
+
20
+ if __name__ == "__main__":
21
+ main()
bilkatogo/cli.py ADDED
@@ -0,0 +1,398 @@
1
+ import asyncio
2
+ import functools
3
+ import json
4
+ import sys
5
+ from collections.abc import Awaitable, Callable, Coroutine
6
+ from datetime import date, datetime, time
7
+ from decimal import Decimal
8
+ from typing import Annotated, Any
9
+
10
+ import httpx
11
+ import typer
12
+
13
+ from . import config
14
+ from .client import BilkaToGoClient
15
+ from .const import SEARCH_LIMIT
16
+ from .exceptions import (
17
+ BilkaToGoError,
18
+ BilkaUnreachableError,
19
+ InvalidCredentialsError,
20
+ UnexpectedResponseError,
21
+ )
22
+ from .models import BilkaDataClass, Offer, Order, OrderStatus, PickupPoint, PickupSlot, Product
23
+ from .token_storage import FileTokenStorage
24
+
25
+ app = typer.Typer(no_args_is_help=True, help="Track your BilkaToGo orders.")
26
+
27
+ EmailOption = Annotated[
28
+ str | None, typer.Option("--email", envvar="BILKATOGO_EMAIL", help="BilkaToGo account email.")
29
+ ]
30
+ PasswordOption = Annotated[
31
+ str | None,
32
+ typer.Option("--password", envvar="BILKATOGO_PASSWORD", help="BilkaToGo account password."),
33
+ ]
34
+ JsonOption = Annotated[bool, typer.Option("--json", help="Output JSON.")]
35
+
36
+
37
+ def run_async[**P, R](func: Callable[P, Coroutine[Any, Any, R]]) -> Callable[P, R]:
38
+ @functools.wraps(func)
39
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
40
+ loop_factory = asyncio.SelectorEventLoop if sys.platform == "win32" else None
41
+ return asyncio.run(func(*args, **kwargs), loop_factory=loop_factory)
42
+
43
+ return wrapper
44
+
45
+
46
+ def known_credentials(email: str | None, password: str | None) -> tuple[str | None, str | None]:
47
+ saved = config.load_config()
48
+ return email or saved.get("email"), password or saved.get("password")
49
+
50
+
51
+ def resolve_credentials(email: str | None, password: str | None) -> tuple[str, str]:
52
+ email, password = known_credentials(email, password)
53
+ return email or typer.prompt("Email"), password or prompt_password()
54
+
55
+
56
+ def prompt_password() -> str:
57
+ return typer.prompt("Password", hide_input=True)
58
+
59
+
60
+ def to_json(data: Any) -> str:
61
+ def default(obj: Any) -> Any:
62
+ if isinstance(obj, BilkaDataClass):
63
+ return dict(obj)
64
+ if isinstance(obj, datetime | date | time):
65
+ return obj.isoformat()
66
+ if isinstance(obj, Decimal):
67
+ return str(obj)
68
+ raise TypeError(f"Cannot serialise {type(obj).__name__}")
69
+
70
+ return json.dumps(data, default=default, ensure_ascii=False)
71
+
72
+
73
+ def print_table(headers: list[str], rows: list[list[str]]) -> None:
74
+ widths = [max(len(cell) for cell in column) for column in zip(headers, *rows, strict=True)]
75
+ for row in [headers, *rows]:
76
+ typer.echo(" ".join(cell.ljust(width) for cell, width in zip(row, widths, strict=True)))
77
+
78
+
79
+ def http_client(ctx: typer.Context) -> httpx.AsyncClient | None:
80
+ factory = ctx.obj
81
+ return factory() if callable(factory) else None
82
+
83
+
84
+ def fail(message: str) -> typer.Exit:
85
+ typer.echo(message, err=True)
86
+ return typer.Exit(code=1)
87
+
88
+
89
+ def failure(action: str, err: BilkaToGoError) -> typer.Exit:
90
+ if isinstance(err, InvalidCredentialsError):
91
+ return fail("Login failed: Wrong email or password.")
92
+ if isinstance(err, BilkaUnreachableError):
93
+ return fail(f"{action}: Could not reach Bilka. Check your connection and try again.")
94
+ if isinstance(err, UnexpectedResponseError):
95
+ return fail(f"{action}: Bilka answered unexpectedly ({err}). Try again later.")
96
+ return fail(f"{action}: {err}")
97
+
98
+
99
+ @app.callback()
100
+ def main_callback() -> None:
101
+ pass
102
+
103
+
104
+ @app.command()
105
+ @run_async
106
+ async def login(
107
+ ctx: typer.Context,
108
+ email: EmailOption = None,
109
+ password: PasswordOption = None,
110
+ as_json: JsonOption = False,
111
+ ) -> None:
112
+ """Log in and store the session for later commands."""
113
+ email, password = resolve_credentials(email, password)
114
+ storage = FileTokenStorage(config.session_file())
115
+ http = http_client(ctx)
116
+ try:
117
+ async with BilkaToGoClient(
118
+ email, password, token_storage=storage, httpx_client=http
119
+ ) as client:
120
+ await client.login()
121
+ except BilkaToGoError as err:
122
+ raise failure("Login failed", err) from None
123
+ finally:
124
+ if http is not None:
125
+ await http.aclose()
126
+
127
+ if as_json:
128
+ typer.echo(json.dumps({"email": email, "logged_in": True}))
129
+ else:
130
+ typer.echo(f"Logged in as {email}.")
131
+
132
+
133
+ async def with_session[T](
134
+ ctx: typer.Context,
135
+ email: str | None,
136
+ password: str | None,
137
+ fetch: Callable[[BilkaToGoClient], Awaitable[T]],
138
+ what: str,
139
+ ) -> T:
140
+ storage = FileTokenStorage(config.session_file())
141
+ email, password = known_credentials(email, password)
142
+ if email is None:
143
+ session = await storage.load()
144
+ email = (session or {}).get("email") or typer.prompt("Email")
145
+ http = http_client(ctx)
146
+ try:
147
+ try:
148
+ async with BilkaToGoClient(
149
+ email, password, token_storage=storage, httpx_client=http
150
+ ) as client:
151
+ return await fetch(client)
152
+ except InvalidCredentialsError:
153
+ if password is not None:
154
+ raise
155
+ async with BilkaToGoClient(
156
+ email, prompt_password(), token_storage=storage, httpx_client=http
157
+ ) as client:
158
+ return await fetch(client)
159
+ except BilkaToGoError as err:
160
+ raise failure(f"Could not fetch {what}", err) from None
161
+ finally:
162
+ if http is not None:
163
+ await http.aclose()
164
+
165
+
166
+ LimitOption = Annotated[int, typer.Option("--limit", min=1, help="Number of orders to show.")]
167
+ WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
168
+
169
+
170
+ @app.command()
171
+ @run_async
172
+ async def orders(
173
+ ctx: typer.Context,
174
+ limit: LimitOption = 5,
175
+ email: EmailOption = None,
176
+ password: PasswordOption = None,
177
+ as_json: JsonOption = False,
178
+ ) -> None:
179
+ """Show your Orders with their Pickup Slot and Order Status."""
180
+ found = await with_session(
181
+ ctx, email, password, lambda client: client.get_orders(limit=limit), "orders"
182
+ )
183
+ if as_json:
184
+ typer.echo(to_json(found))
185
+ elif not found:
186
+ typer.echo("No orders.")
187
+ else:
188
+ print_table(
189
+ ["Order", "Ordered", "Pickup Slot", "Pickup Point", "Status"],
190
+ [order_row(order) for order in found],
191
+ )
192
+
193
+
194
+ def order_row(order: Order) -> list[str]:
195
+ return [
196
+ order.order_number,
197
+ order.ordered_at.strftime("%Y-%m-%d %H:%M") if order.ordered_at else "",
198
+ format_slot(order.pickup_slot),
199
+ format_point(order.pickup_point, order.home_delivery),
200
+ format_status(order.status),
201
+ ]
202
+
203
+
204
+ def format_slot(slot: PickupSlot | None) -> str:
205
+ if slot is None:
206
+ return ""
207
+ return f"{WEEKDAYS[slot.date.weekday()]} {slot.date:%d/%m} {slot.start:%H:%M}-{slot.end:%H:%M}"
208
+
209
+
210
+ def format_point(point: PickupPoint | None, home_delivery: bool) -> str:
211
+ return "Home Delivery" if home_delivery else (point.name if point else "")
212
+
213
+
214
+ def format_amount(amount: Decimal | None) -> str:
215
+ return "" if amount is None else f"{amount:.2f} kr"
216
+
217
+
218
+ def format_status(status: OrderStatus) -> str:
219
+ return status.name.replace("_", " ").title()
220
+
221
+
222
+ OrderNumberArgument = Annotated[
223
+ str | None,
224
+ typer.Argument(help="Order Number, e.g. 900000000001. Defaults to the Current Order."),
225
+ ]
226
+
227
+
228
+ @app.command()
229
+ @run_async
230
+ async def order(
231
+ ctx: typer.Context,
232
+ order_number: OrderNumberArgument = None,
233
+ email: EmailOption = None,
234
+ password: PasswordOption = None,
235
+ as_json: JsonOption = False,
236
+ ) -> None:
237
+ """Show an Order (default: the Current Order), its Order Total and what packing changed."""
238
+ if order_number is None:
239
+ details = await with_session(
240
+ ctx, email, password, lambda client: client.get_current_order(), "the current order"
241
+ )
242
+ else:
243
+ details = await with_session(
244
+ ctx,
245
+ email,
246
+ password,
247
+ lambda client: client.get_order(order_number),
248
+ f"order {order_number}",
249
+ )
250
+ if as_json:
251
+ typer.echo(to_json(details))
252
+ return
253
+ if details is None:
254
+ typer.echo("No orders.")
255
+ return
256
+ fields = [
257
+ ("Order", details.order_number),
258
+ ("Status", format_status(details.status)),
259
+ ("Pickup Slot", format_slot(details.pickup_slot)),
260
+ ("Pickup Point", format_point(details.pickup_point, details.home_delivery)),
261
+ ("Pickup Code", details.pickup_code.code if details.pickup_code else ""),
262
+ ("Ordered Total", format_amount(details.ordered_total)),
263
+ ("Order Total", format_amount(details.order_total)),
264
+ ("Deposit", format_amount(details.deposit)),
265
+ ("Service Fee", format_amount(details.service_fee)),
266
+ ("Packaging", format_amount(details.packaging)),
267
+ ("Delivery Fee", format_amount(details.delivery_fee)),
268
+ ("Discount", format_amount(details.discount)),
269
+ ]
270
+ for label, value in fields:
271
+ typer.echo(f"{label:<13} {value}")
272
+ print_list(
273
+ "Substitutions",
274
+ [
275
+ f"{substitution.original_product} -> "
276
+ f"{substitution.quantity} x {substitution.replacement_product}"
277
+ for substitution in details.substitutions
278
+ ],
279
+ )
280
+ print_list(
281
+ "Missing Items",
282
+ [
283
+ f"{item.product}: {item.missing_quantity} of {item.ordered_quantity} missing"
284
+ for item in details.missing_items
285
+ ],
286
+ )
287
+
288
+
289
+ def print_list(label: str, entries: list[str]) -> None:
290
+ if not entries:
291
+ typer.echo(f"{label} none")
292
+ return
293
+ typer.echo(label)
294
+ for entry in entries:
295
+ typer.echo(f" {entry}")
296
+
297
+
298
+ @app.command("pickup-points")
299
+ @run_async
300
+ async def pickup_points(
301
+ ctx: typer.Context,
302
+ email: EmailOption = None,
303
+ password: PasswordOption = None,
304
+ as_json: JsonOption = False,
305
+ ) -> None:
306
+ """Show every Pickup Point with its Store, marking the one chosen in your Cart."""
307
+
308
+ async def fetch(client: BilkaToGoClient) -> tuple[list[PickupPoint], PickupPoint | None]:
309
+ return await client.get_pickup_points(), await client.get_cart_pickup_point()
310
+
311
+ points, chosen = await with_session(ctx, email, password, fetch, "pickup points")
312
+ chosen_name = chosen.name if chosen else None
313
+ if as_json:
314
+ typer.echo(
315
+ to_json([{**dict(point), "chosen": point.name == chosen_name} for point in points])
316
+ )
317
+ return
318
+ print_table(
319
+ ["", "Pickup Point", "Address", "Store"],
320
+ [
321
+ [
322
+ "*" if point.name == chosen_name else "",
323
+ point.name,
324
+ f"{point.address}, {point.zip_code} {point.city}",
325
+ point.store_id or "",
326
+ ]
327
+ for point in points
328
+ ],
329
+ )
330
+
331
+
332
+ SearchTextArgument = Annotated[str, typer.Argument(help="What to search for, e.g. skyr.")]
333
+ PickupPointOption = Annotated[
334
+ str | None,
335
+ typer.Option(
336
+ "--pickup-point",
337
+ help="Search at this Pickup Point's Store. Defaults to the Pickup Point in your Cart.",
338
+ ),
339
+ ]
340
+ SearchLimitOption = Annotated[
341
+ int, typer.Option("--limit", min=1, help="Number of Products to show.")
342
+ ]
343
+
344
+
345
+ @app.command()
346
+ @run_async
347
+ async def search(
348
+ ctx: typer.Context,
349
+ text: SearchTextArgument,
350
+ pickup_point: PickupPointOption = None,
351
+ limit: SearchLimitOption = SEARCH_LIMIT,
352
+ email: EmailOption = None,
353
+ password: PasswordOption = None,
354
+ as_json: JsonOption = False,
355
+ ) -> None:
356
+ """Search Products with their Price, stock and Offer at a Store."""
357
+ found = await with_session(
358
+ ctx,
359
+ email,
360
+ password,
361
+ lambda client: client.search(text, pickup_point=pickup_point, limit=limit),
362
+ "products",
363
+ )
364
+ if as_json:
365
+ typer.echo(to_json(found))
366
+ elif not found:
367
+ typer.echo("No products.")
368
+ else:
369
+ print_table(
370
+ ["Article", "Product", "Brand", "Price", "Unit Price", "Stock", "Offer"],
371
+ [product_row(product) for product in found],
372
+ )
373
+
374
+
375
+ def product_row(product: Product) -> list[str]:
376
+ unit_price = f"{format_amount(product.unit_price)}/{product.unit}" if product.unit else ""
377
+ return [
378
+ product.article_number,
379
+ product.name,
380
+ product.brand,
381
+ format_amount(product.price),
382
+ unit_price,
383
+ "In stock" if product.in_stock else "Out of stock",
384
+ format_offer(product.offer) if product.offer else product.multibuy_offer or "",
385
+ ]
386
+
387
+
388
+ def format_offer(offer: Offer) -> str:
389
+ text = f"{offer.label}: {format_amount(offer.price)}"
390
+ if offer.ends:
391
+ text += f" until {offer.ends.isoformat()}"
392
+ if offer.limit_per_customer:
393
+ text += f", max {offer.limit_per_customer} per customer"
394
+ return text
395
+
396
+
397
+ def main() -> None:
398
+ app()