python-appie 0.1.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.
appie/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """Public package exports and CLI entrypoints for python-appie."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ from appie.auth import AHAuthClient
8
+ from appie.client import AHClient
9
+ from appie.mock import MockAHClient
10
+
11
+ __all__ = ["AHAuthClient", "AHClient", "MockAHClient", "login_cli"]
12
+
13
+
14
+ def login_cli() -> None:
15
+ """Run the interactive login CLI."""
16
+ asyncio.run(_login_cli_async())
17
+
18
+
19
+ async def _login_cli_async() -> None: # pragma: no cover - exercised via manual browser login
20
+ async with AHClient() as client:
21
+ try:
22
+ code = await client.capture_login_code()
23
+ except RuntimeError as exc:
24
+ print(f"Automatic login capture failed: {exc}")
25
+ print(f"Open this URL in your browser:\n{client.authorize_url}\n")
26
+ redirect_or_code = input("Paste the redirect URL or raw code: ").strip()
27
+ code = client._extract_code(redirect_or_code)
28
+ await client.auth.login_with_code(code)
29
+ print("✓ Logged in successfully")
appie/auth.py ADDED
@@ -0,0 +1,159 @@
1
+ """Authentication helpers for the Albert Heijn API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import UTC, datetime, timedelta
7
+ from pathlib import Path
8
+
9
+ import httpx
10
+
11
+ from appie.models import StoredToken, TokenResponse
12
+
13
+ BASE_URL = "https://api.ah.nl"
14
+ DEFAULT_CLIENT_ID = "appie-ios"
15
+ DEFAULT_CLIENT_VERSION = "9.28"
16
+ DEFAULT_USER_AGENT = "Appie/9.28 (iPhone17,3; iPhone; CPU OS 26_1 like Mac OS X)"
17
+ DEFAULT_TOKEN_PATH = Path.home() / ".config" / "appie" / "tokens.json"
18
+ REFRESH_SKEW_SECONDS = 60
19
+
20
+
21
+ class AHAuthClient:
22
+ """Manage AH access tokens, refresh tokens, and token persistence."""
23
+
24
+ def __init__(
25
+ self,
26
+ http_client: httpx.AsyncClient | None = None,
27
+ token_path: Path | None = None,
28
+ ) -> None:
29
+ self._client = http_client or httpx.AsyncClient(base_url=BASE_URL)
30
+ self._owns_client = http_client is None
31
+ self.token_path = token_path or DEFAULT_TOKEN_PATH
32
+ self._stored_token: StoredToken | None = self.load_tokens()
33
+
34
+ async def __aenter__(self) -> AHAuthClient:
35
+ """Return the auth client in async context-manager usage."""
36
+ return self
37
+
38
+ async def __aexit__(self, *_: object) -> None:
39
+ """Close owned resources at context-manager exit."""
40
+ await self.aclose()
41
+
42
+ async def aclose(self) -> None:
43
+ """Close the underlying HTTP client when this instance owns it."""
44
+ if self._owns_client:
45
+ await self._client.aclose()
46
+
47
+ @property
48
+ def access_token(self) -> str | None:
49
+ """Return the current access token if one is loaded."""
50
+ if self._stored_token is None:
51
+ return None
52
+ return self._stored_token.access_token
53
+
54
+ @property
55
+ def refresh_token_value(self) -> str | None:
56
+ """Return the current refresh token if one is loaded."""
57
+ if self._stored_token is None:
58
+ return None
59
+ return self._stored_token.refresh_token
60
+
61
+ @property
62
+ def token(self) -> TokenResponse | None:
63
+ """Return the current stored token as a public token model."""
64
+ if self._stored_token is None:
65
+ return None
66
+ return self._stored_token.to_token_response()
67
+
68
+ def load_tokens(self) -> StoredToken | None:
69
+ """Load persisted tokens from disk when available."""
70
+ if not self.token_path.exists():
71
+ return None
72
+ payload = json.loads(self.token_path.read_text())
73
+ return StoredToken.model_validate(payload)
74
+
75
+ def save_tokens(self, token: TokenResponse) -> StoredToken:
76
+ """Persist tokens to disk and cache the stored token in memory."""
77
+ expires_at = datetime.now(UTC) + timedelta(seconds=token.expires_in)
78
+ stored = StoredToken.from_token_response(token, expires_at=expires_at)
79
+ self.token_path.parent.mkdir(parents=True, exist_ok=True)
80
+ self.token_path.write_text(stored.model_dump_json(indent=2))
81
+ self._stored_token = stored
82
+ return stored
83
+
84
+ def token_is_expiring(self) -> bool:
85
+ """Return whether the current access token should be refreshed soon."""
86
+ if self._stored_token is None:
87
+ return True
88
+ refresh_deadline = datetime.now(UTC) + timedelta(seconds=REFRESH_SKEW_SECONDS)
89
+ return self._stored_token.expires_at <= refresh_deadline
90
+
91
+ async def ensure_valid_token(self) -> TokenResponse:
92
+ """Return a usable token, refreshing it when necessary."""
93
+ if self._stored_token is None:
94
+ raise RuntimeError("No stored tokens found. Run login() or login_cli() first.")
95
+ if self.token_is_expiring():
96
+ return await self.refresh_token(self._stored_token.refresh_token)
97
+ return self._stored_token.to_token_response()
98
+
99
+ async def get_anonymous_token(self) -> TokenResponse:
100
+ """Fetch an anonymous bootstrap token."""
101
+ response = await self._post_json(
102
+ "/mobile-auth/v1/auth/token/anonymous",
103
+ {"clientId": DEFAULT_CLIENT_ID},
104
+ )
105
+ self._raise_for_status(response, "Failed to get anonymous token")
106
+ return TokenResponse.model_validate(response.json())
107
+
108
+ async def login_with_code(self, code: str) -> TokenResponse:
109
+ """Exchange an authorization code for persisted user tokens."""
110
+ response = await self._post_json(
111
+ "/mobile-auth/v1/auth/token",
112
+ {
113
+ "clientId": DEFAULT_CLIENT_ID,
114
+ "code": code,
115
+ },
116
+ )
117
+ self._raise_for_status(response, "Failed to exchange authorization code")
118
+ token = TokenResponse.model_validate(response.json())
119
+ self.save_tokens(token)
120
+ return token
121
+
122
+ async def refresh_token(self, refresh_token: str) -> TokenResponse:
123
+ """Refresh a persisted token set using the refresh token."""
124
+ response = await self._post_json(
125
+ "/mobile-auth/v1/auth/token/refresh",
126
+ {
127
+ "clientId": DEFAULT_CLIENT_ID,
128
+ "refreshToken": refresh_token,
129
+ },
130
+ )
131
+ self._raise_for_status(response, "Failed to refresh token")
132
+ token = TokenResponse.model_validate(response.json())
133
+ self.save_tokens(token)
134
+ return token
135
+
136
+ async def _post_json(self, url: str, payload: dict[str, str]) -> httpx.Response:
137
+ return await self._client.post(
138
+ url,
139
+ headers={
140
+ "User-Agent": DEFAULT_USER_AGENT,
141
+ "x-client-name": DEFAULT_CLIENT_ID,
142
+ "x-client-version": DEFAULT_CLIENT_VERSION,
143
+ "x-application": "AHWEBSHOP",
144
+ "Accept": "application/json",
145
+ "Content-Type": "application/json",
146
+ },
147
+ json=payload,
148
+ )
149
+
150
+ @staticmethod
151
+ def _raise_for_status(response: httpx.Response, context: str) -> None:
152
+ try:
153
+ response.raise_for_status()
154
+ except httpx.HTTPStatusError as exc:
155
+ body = response.text.strip()
156
+ detail = f"{context}: {exc}"
157
+ if body:
158
+ detail = f"{detail}\nResponse body: {body}"
159
+ raise RuntimeError(detail) from exc
appie/client.py ADDED
@@ -0,0 +1,248 @@
1
+ """Main async client for interacting with Albert Heijn endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Mapping
7
+ from typing import Any
8
+ from urllib.parse import parse_qs, urlparse
9
+
10
+ import httpx
11
+
12
+ from appie.auth import (
13
+ BASE_URL,
14
+ DEFAULT_CLIENT_ID,
15
+ DEFAULT_CLIENT_VERSION,
16
+ DEFAULT_USER_AGENT,
17
+ AHAuthClient,
18
+ )
19
+
20
+
21
+ class AHClient:
22
+ """High-level async client that exposes products, receipts, and lists APIs."""
23
+
24
+ user_agent = DEFAULT_USER_AGENT
25
+ graphql_url = f"{BASE_URL}/graphql"
26
+ authorize_url = (
27
+ "https://login.ah.nl/login"
28
+ f"?client_id={DEFAULT_CLIENT_ID}&redirect_uri=appie://login-exit&response_type=code"
29
+ )
30
+
31
+ def __init__(
32
+ self,
33
+ *,
34
+ http_client: httpx.AsyncClient | None = None,
35
+ auth_client: AHAuthClient | None = None,
36
+ ) -> None:
37
+ default_headers = {
38
+ "User-Agent": self.user_agent,
39
+ "x-client-name": DEFAULT_CLIENT_ID,
40
+ "x-client-version": DEFAULT_CLIENT_VERSION,
41
+ "x-application": "AHWEBSHOP",
42
+ "Content-Type": "application/json",
43
+ }
44
+ self._client = http_client or httpx.AsyncClient(base_url=BASE_URL, headers=default_headers)
45
+ if http_client is not None:
46
+ self._client.headers.update(default_headers)
47
+ self._owns_client = http_client is None
48
+ self.auth = auth_client or AHAuthClient(http_client=self._client)
49
+
50
+ from appie.lists import ListsAPI
51
+ from appie.products import ProductsAPI
52
+ from appie.receipts import ReceiptsAPI
53
+
54
+ self.receipts = ReceiptsAPI(self)
55
+ self.products = ProductsAPI(self)
56
+ self.lists = ListsAPI(self)
57
+
58
+ async def __aenter__(self) -> AHClient:
59
+ """Return the client in async context-manager usage."""
60
+ return self
61
+
62
+ async def __aexit__(self, *_: object) -> None:
63
+ """Close owned resources at context-manager exit."""
64
+ await self.aclose()
65
+
66
+ async def aclose(self) -> None:
67
+ """Close the auth and HTTP clients when owned by this instance."""
68
+ await self.auth.aclose()
69
+ if self._owns_client:
70
+ await self._client.aclose()
71
+
72
+ async def login(self) -> None:
73
+ """Run the browser-assisted login flow and persist the resulting tokens."""
74
+ code = await self.capture_login_code()
75
+ await self.auth.login_with_code(code)
76
+
77
+ async def capture_login_code(
78
+ self,
79
+ timeout_seconds: float = 300,
80
+ ) -> str: # pragma: no cover - exercised via manual browser login
81
+ """Open a browser, capture the AH redirect code, and return it."""
82
+ async_playwright, playwright_error = self._load_playwright()
83
+ try:
84
+ return await self._capture_login_code_in_browser(async_playwright, timeout_seconds)
85
+ except playwright_error as exc:
86
+ raise RuntimeError(
87
+ "Automatic browser login could not start. Ensure Google Chrome is installed, "
88
+ "or fall back to entering the raw code manually."
89
+ ) from exc
90
+
91
+ @staticmethod
92
+ def _load_playwright() -> tuple[Any, type[Exception]]: # pragma: no cover
93
+ try:
94
+ from playwright.async_api import Error as PlaywrightError
95
+ from playwright.async_api import async_playwright
96
+ except ImportError as exc:
97
+ raise RuntimeError(
98
+ "Playwright is not installed. Install project dependencies again and ensure the "
99
+ "Playwright browser runtime is available."
100
+ ) from exc
101
+ return async_playwright, PlaywrightError
102
+
103
+ async def _capture_login_code_in_browser(
104
+ self,
105
+ async_playwright: Any,
106
+ timeout_seconds: float,
107
+ ) -> str: # pragma: no cover - browser integration helper
108
+ """Capture the login code from the browser redirect target."""
109
+ async with async_playwright() as playwright:
110
+ browser = await playwright.chromium.launch(channel="chrome", headless=False)
111
+ context = await browser.new_context()
112
+ page = await context.new_page()
113
+
114
+ code_future: asyncio.Future[str] = asyncio.get_running_loop().create_future()
115
+ self._register_login_capture_handlers(page, code_future)
116
+ await page.goto(self.authorize_url, wait_until="domcontentloaded")
117
+ print(
118
+ "A Chrome window was opened for AH login. Complete the login there; "
119
+ "the auth code will be captured automatically."
120
+ )
121
+
122
+ try:
123
+ return await asyncio.wait_for(code_future, timeout=timeout_seconds)
124
+ finally:
125
+ await context.close()
126
+ await browser.close()
127
+
128
+ def _register_login_capture_handlers(
129
+ self,
130
+ page: Any,
131
+ code_future: asyncio.Future[str],
132
+ ) -> None: # pragma: no cover - browser integration helper
133
+ """Attach event handlers that resolve when the app redirect is seen."""
134
+ page.on(
135
+ "framenavigated",
136
+ lambda frame: self._resolve_code_future(
137
+ code_future,
138
+ self._extract_code_from_redirect_target(frame.url),
139
+ ),
140
+ )
141
+ page.on(
142
+ "request",
143
+ lambda request: self._resolve_code_future(
144
+ code_future,
145
+ self._extract_code_from_redirect_target(request.url),
146
+ ),
147
+ )
148
+ page.on(
149
+ "requestfailed",
150
+ lambda request: self._resolve_code_future(
151
+ code_future,
152
+ self._extract_code_from_redirect_target(request.url),
153
+ ),
154
+ )
155
+ page.on(
156
+ "response",
157
+ lambda response: asyncio.create_task(
158
+ self._handle_login_response(response, code_future)
159
+ ),
160
+ )
161
+
162
+ @staticmethod
163
+ def _resolve_code_future(
164
+ code_future: asyncio.Future[str],
165
+ candidate: str | None,
166
+ ) -> None: # pragma: no cover - browser integration helper
167
+ """Resolve the login-code future once a valid code is seen."""
168
+ if candidate and not code_future.done():
169
+ code_future.set_result(candidate)
170
+
171
+ async def _handle_login_response(
172
+ self,
173
+ response: Any,
174
+ code_future: asyncio.Future[str],
175
+ ) -> None: # pragma: no cover - browser integration helper
176
+ """Inspect response headers for the redirect target."""
177
+ try:
178
+ headers = await response.all_headers()
179
+ except Exception:
180
+ return
181
+ self._resolve_code_future(
182
+ code_future,
183
+ self._extract_code_from_redirect_target(headers.get("location")),
184
+ )
185
+
186
+ async def request(
187
+ self,
188
+ method: str,
189
+ url: str,
190
+ *,
191
+ auth_required: bool = True,
192
+ headers: Mapping[str, str] | None = None,
193
+ **kwargs: Any,
194
+ ) -> httpx.Response:
195
+ """Send an HTTP request with default headers and optional bearer auth."""
196
+ merged_headers = dict(headers or {})
197
+ if auth_required:
198
+ token = await self.auth.ensure_valid_token()
199
+ merged_headers["Authorization"] = f"{token.token_type} {token.access_token}"
200
+ response = await self._client.request(method, url, headers=merged_headers, **kwargs)
201
+ response.raise_for_status()
202
+ return response
203
+
204
+ async def graphql(
205
+ self,
206
+ query: str,
207
+ variables: Mapping[str, object] | None = None,
208
+ ) -> dict:
209
+ """Send a GraphQL request and return the `data` payload."""
210
+ response = await self.request(
211
+ "POST",
212
+ self.graphql_url,
213
+ json={"query": query, "variables": dict(variables or {})},
214
+ )
215
+ payload = response.json()
216
+ if "errors" in payload:
217
+ raise RuntimeError(f"GraphQL request failed: {payload['errors']}")
218
+ return payload["data"]
219
+
220
+ @staticmethod
221
+ def _extract_code(value: str) -> str:
222
+ """Extract an auth code from a redirect URL or raw code string."""
223
+ code = AHClient._extract_code_from_text(value)
224
+ if not code:
225
+ raise ValueError("Input did not contain an authorization code.")
226
+ return code
227
+
228
+ @staticmethod
229
+ def _extract_code_from_text(value: str) -> str | None:
230
+ """Extract a code from a raw string or URL query string."""
231
+ stripped = value.strip()
232
+ if stripped and "://" not in stripped and "?" not in stripped and "=" not in stripped:
233
+ return stripped
234
+ parsed = urlparse(stripped)
235
+ code = parse_qs(parsed.query).get("code", [None])[0]
236
+ return code
237
+
238
+ @staticmethod
239
+ def _extract_code_from_redirect_target(value: str | None) -> str | None:
240
+ """Extract a code only from the expected `appie://login-exit` redirect target."""
241
+ if not value:
242
+ return None
243
+ parsed = urlparse(value.strip())
244
+ if parsed.scheme != "appie":
245
+ return None
246
+ if parsed.netloc != "login-exit" and parsed.path != "/login-exit":
247
+ return None
248
+ return parse_qs(parsed.query).get("code", [None])[0]
appie/lists.py ADDED
@@ -0,0 +1,78 @@
1
+ """Shopping-list operations for the Albert Heijn API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Protocol
6
+
7
+ from appie.models import ShoppingListItem
8
+
9
+ ADD_ITEM_MUTATION = """
10
+ mutation AddToShoppingList($input: ShoppingListItemInput!) {
11
+ addShoppingListItem(input: $input) {
12
+ id
13
+ description
14
+ quantity
15
+ productId
16
+ }
17
+ }
18
+ """
19
+
20
+
21
+ class GraphQLClient(Protocol):
22
+ """Protocol for clients that can execute GraphQL requests."""
23
+
24
+ async def graphql(self, query: str, variables: dict[str, Any] | None = None) -> dict:
25
+ """Execute a GraphQL request."""
26
+ ...
27
+
28
+
29
+ class ListsAPI:
30
+ """High-level shopping-list operations."""
31
+
32
+ def __init__(self, client: GraphQLClient) -> None:
33
+ self._client = client
34
+
35
+ async def get_list(self) -> list[ShoppingListItem]:
36
+ """Return the current shopping list when the query shape is confirmed."""
37
+ raise NotImplementedError(
38
+ "Shopping-list query shape is not confirmed yet. "
39
+ "The public method is reserved for a future verified GraphQL query."
40
+ )
41
+
42
+ async def add_item(
43
+ self,
44
+ description: str,
45
+ quantity: int = 1,
46
+ product_id: int | None = None,
47
+ ) -> ShoppingListItem:
48
+ """Add an item to the shopping list."""
49
+ data = await self._client.graphql(
50
+ ADD_ITEM_MUTATION,
51
+ {
52
+ "input": {
53
+ "description": description,
54
+ "quantity": quantity,
55
+ "productId": product_id,
56
+ }
57
+ },
58
+ )
59
+ return self._map_item(data["addShoppingListItem"])
60
+
61
+ async def remove_item(self, item_id: str) -> None:
62
+ """Remove an item from the shopping list when the mutation is confirmed."""
63
+ raise NotImplementedError(
64
+ f"Shopping-list remove mutation is not confirmed yet for item {item_id}."
65
+ )
66
+
67
+ async def clear(self) -> None:
68
+ """Clear the shopping list when the mutation is confirmed."""
69
+ raise NotImplementedError("Shopping-list clear mutation is not confirmed yet.")
70
+
71
+ @staticmethod
72
+ def _map_item(payload: dict) -> ShoppingListItem:
73
+ return ShoppingListItem(
74
+ id=str(payload["id"]),
75
+ description=payload["description"],
76
+ quantity=int(payload.get("quantity", 1)),
77
+ product_id=payload.get("productId"),
78
+ )
appie/mock.py ADDED
@@ -0,0 +1,211 @@
1
+ """Mock client implementations for local development and testing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from copy import deepcopy
6
+ from datetime import UTC, datetime
7
+
8
+ from appie.models import Product, Receipt, ReceiptProduct, ShoppingListItem
9
+
10
+
11
+ def _default_products() -> list[Product]:
12
+ return [
13
+ Product(
14
+ id=1525,
15
+ title="AH Halfvolle melk",
16
+ brand="AH",
17
+ price=1.29,
18
+ unit_size="1 l",
19
+ image_url="https://example.test/ah-halfvolle-melk.jpg",
20
+ ),
21
+ Product(
22
+ id=441199,
23
+ title="Campina Halfvolle melk voordeelverpakking",
24
+ brand="Campina",
25
+ price=1.99,
26
+ unit_size="1,5 l",
27
+ image_url="https://example.test/campina-halfvolle-melk.jpg",
28
+ ),
29
+ Product(
30
+ id=987654,
31
+ title="AH Pindakaas",
32
+ brand="AH",
33
+ price=2.49,
34
+ unit_size="600 g",
35
+ image_url="https://example.test/ah-pindakaas.jpg",
36
+ ),
37
+ ]
38
+
39
+
40
+ def _default_receipts() -> list[Receipt]:
41
+ return [
42
+ Receipt(
43
+ id="mock-receipt-2",
44
+ datetime=datetime(2026, 3, 21, 14, 16, tzinfo=UTC),
45
+ store_name="Albert Heijn Mockstraat",
46
+ total=17.78,
47
+ products=[
48
+ ReceiptProduct(
49
+ id=1525,
50
+ name="AH Halfvolle melk",
51
+ quantity=2,
52
+ price_per_unit=1.29,
53
+ total_price=2.58,
54
+ ),
55
+ ReceiptProduct(
56
+ id=987654,
57
+ name="AH Pindakaas",
58
+ quantity=1,
59
+ price_per_unit=2.49,
60
+ total_price=2.49,
61
+ ),
62
+ ],
63
+ ),
64
+ Receipt(
65
+ id="mock-receipt-1",
66
+ datetime=datetime(2026, 3, 7, 10, 20, tzinfo=UTC),
67
+ store_name="Albert Heijn Mockstraat",
68
+ total=24.82,
69
+ products=[
70
+ ReceiptProduct(
71
+ id=441199,
72
+ name="Campina Halfvolle melk voordeelverpakking",
73
+ quantity=2,
74
+ price_per_unit=1.99,
75
+ total_price=3.98,
76
+ ),
77
+ ReceiptProduct(
78
+ id=987654,
79
+ name="AH Pindakaas",
80
+ quantity=3,
81
+ price_per_unit=2.49,
82
+ total_price=7.47,
83
+ ),
84
+ ],
85
+ ),
86
+ ]
87
+
88
+
89
+ class MockProductsAPI:
90
+ """In-memory mock implementation of the products API."""
91
+
92
+ def __init__(self, products: list[Product]) -> None:
93
+ """Store the in-memory product catalog."""
94
+ self._products = products
95
+
96
+ async def search(self, query: str, limit: int = 10) -> list[Product]:
97
+ """Return products whose title or brand contains the query."""
98
+ lowered = query.lower()
99
+ matches = [
100
+ product
101
+ for product in self._products
102
+ if lowered in product.title.lower() or lowered in (product.brand or "").lower()
103
+ ]
104
+ return matches[:limit]
105
+
106
+ async def get(self, product_id: int) -> Product:
107
+ """Return a product by ID."""
108
+ for product in self._products:
109
+ if product.id == product_id:
110
+ return product
111
+ raise LookupError(f"Mock product {product_id} was not found.")
112
+
113
+
114
+ class MockReceiptsAPI:
115
+ """In-memory mock implementation of the receipts API."""
116
+
117
+ def __init__(self, receipts: list[Receipt]) -> None:
118
+ """Store the in-memory receipt list."""
119
+ self._receipts = receipts
120
+
121
+ async def list_pos_receipts(self, limit: int = 50) -> list[Receipt]:
122
+ """Return receipt summaries without line items."""
123
+ return [self._to_summary(receipt) for receipt in self._receipts[:limit]]
124
+
125
+ async def get_pos_receipt(self, receipt_id: str) -> Receipt:
126
+ """Return a detailed receipt by ID."""
127
+ for receipt in self._receipts:
128
+ if receipt.id == receipt_id:
129
+ return receipt.model_copy(deep=True)
130
+ raise LookupError(f"Mock receipt {receipt_id} was not found.")
131
+
132
+ async def list_all(self, limit: int = 50) -> list[Receipt]:
133
+ """Return receipt summaries sorted by datetime descending."""
134
+ summaries = await self.list_pos_receipts(limit=limit)
135
+ return sorted(summaries, key=lambda receipt: receipt.datetime, reverse=True)
136
+
137
+ @staticmethod
138
+ def _to_summary(receipt: Receipt) -> Receipt:
139
+ return Receipt(
140
+ id=receipt.id,
141
+ datetime=receipt.datetime,
142
+ store_name=receipt.store_name,
143
+ total=receipt.total,
144
+ products=[],
145
+ )
146
+
147
+
148
+ class MockListsAPI:
149
+ """In-memory mock implementation of the shopping-list API."""
150
+
151
+ def __init__(self) -> None:
152
+ """Initialize an empty in-memory shopping list."""
153
+ self._items: list[ShoppingListItem] = []
154
+
155
+ async def get_list(self) -> list[ShoppingListItem]:
156
+ """Return the current in-memory shopping list."""
157
+ return [item.model_copy(deep=True) for item in self._items]
158
+
159
+ async def add_item(
160
+ self,
161
+ description: str,
162
+ quantity: int = 1,
163
+ product_id: int | None = None,
164
+ ) -> ShoppingListItem:
165
+ """Add an item to the in-memory shopping list."""
166
+ item = ShoppingListItem(
167
+ id=f"mock-item-{len(self._items) + 1}",
168
+ description=description,
169
+ quantity=quantity,
170
+ product_id=product_id,
171
+ )
172
+ self._items.append(item)
173
+ return item.model_copy(deep=True)
174
+
175
+ async def remove_item(self, item_id: str) -> None:
176
+ """Remove an item from the in-memory shopping list."""
177
+ self._items = [item for item in self._items if item.id != item_id]
178
+
179
+ async def clear(self) -> None:
180
+ """Clear the in-memory shopping list."""
181
+ self._items.clear()
182
+
183
+
184
+ class MockAHClient:
185
+ """Drop-in async mock client for local development and tests."""
186
+
187
+ def __init__(
188
+ self,
189
+ *,
190
+ products: list[Product] | None = None,
191
+ receipts: list[Receipt] | None = None,
192
+ ) -> None:
193
+ """Initialize the mock APIs with optional custom fixture data."""
194
+ product_fixtures = deepcopy(products) if products is not None else _default_products()
195
+ receipt_fixtures = deepcopy(receipts) if receipts is not None else _default_receipts()
196
+ self.products = MockProductsAPI(product_fixtures)
197
+ self.receipts = MockReceiptsAPI(receipt_fixtures)
198
+ self.lists = MockListsAPI()
199
+
200
+ async def __aenter__(self) -> MockAHClient:
201
+ """Return the mock client in async context-manager usage."""
202
+ return self
203
+
204
+ async def __aexit__(self, *_: object) -> None:
205
+ """Allow use in async context-manager blocks."""
206
+
207
+ async def aclose(self) -> None:
208
+ """Match the real client interface without external cleanup."""
209
+
210
+ async def login(self) -> None:
211
+ """Match the real client interface without external auth."""
appie/models.py ADDED
@@ -0,0 +1,82 @@
1
+ """Pydantic models used by the appie client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+
7
+ from pydantic import BaseModel, ConfigDict
8
+
9
+
10
+ class TokenResponse(BaseModel):
11
+ """Public token payload returned by AH auth endpoints."""
12
+
13
+ access_token: str
14
+ refresh_token: str
15
+ expires_in: int
16
+ token_type: str = "Bearer"
17
+
18
+
19
+ class StoredToken(BaseModel):
20
+ """Persisted token payload with an absolute expiry timestamp."""
21
+
22
+ access_token: str
23
+ refresh_token: str
24
+ expires_in: int
25
+ token_type: str = "Bearer"
26
+ expires_at: datetime
27
+
28
+ model_config = ConfigDict(extra="ignore")
29
+
30
+ @classmethod
31
+ def from_token_response(cls, token: TokenResponse, expires_at: datetime) -> StoredToken:
32
+ """Create a stored token from a public token payload."""
33
+ return cls(**token.model_dump(), expires_at=expires_at)
34
+
35
+ def to_token_response(self) -> TokenResponse:
36
+ """Convert a stored token back to the public token model."""
37
+ return TokenResponse(
38
+ access_token=self.access_token,
39
+ refresh_token=self.refresh_token,
40
+ expires_in=self.expires_in,
41
+ token_type=self.token_type,
42
+ )
43
+
44
+
45
+ class Product(BaseModel):
46
+ """Normalized product information."""
47
+
48
+ id: int
49
+ title: str
50
+ brand: str | None = None
51
+ price: float | None = None
52
+ unit_size: str | None = None
53
+ image_url: str | None = None
54
+
55
+
56
+ class ReceiptProduct(BaseModel):
57
+ """A single line item on a receipt."""
58
+
59
+ id: int
60
+ name: str
61
+ quantity: float
62
+ price_per_unit: float
63
+ total_price: float
64
+
65
+
66
+ class Receipt(BaseModel):
67
+ """A normalized receipt summary or detailed receipt."""
68
+
69
+ id: str
70
+ datetime: datetime
71
+ store_name: str | None = None
72
+ total: float
73
+ products: list[ReceiptProduct]
74
+
75
+
76
+ class ShoppingListItem(BaseModel):
77
+ """A shopping-list item."""
78
+
79
+ id: str
80
+ description: str
81
+ quantity: int = 1
82
+ product_id: int | None = None
appie/products.py ADDED
@@ -0,0 +1,99 @@
1
+ """Product search and lookup operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Protocol
6
+
7
+ import httpx
8
+
9
+ from appie.models import Product
10
+
11
+
12
+ class RequestingClient(Protocol):
13
+ """Protocol for clients that can make authenticated HTTP requests."""
14
+
15
+ async def request(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
16
+ """Send an HTTP request."""
17
+ ...
18
+
19
+
20
+ class ProductsAPI:
21
+ """High-level product operations."""
22
+
23
+ def __init__(self, client: RequestingClient) -> None:
24
+ self._client = client
25
+
26
+ async def search(self, query: str, limit: int = 10) -> list[Product]:
27
+ """Search products by free-text query."""
28
+ response = await self._client.request(
29
+ "GET",
30
+ "/mobile-services/product/search/v2",
31
+ params={"query": query, "sortOn": "RELEVANCE", "size": limit, "page": 0},
32
+ )
33
+ payload = response.json()
34
+ products = payload.get("products") or payload.get("data") or []
35
+ return [self._map_product(item) for item in products]
36
+
37
+ async def get(self, product_id: int) -> Product:
38
+ """Fetch a single product by its webshop identifier."""
39
+ response = await self._client.request(
40
+ "GET",
41
+ f"/mobile-services/product/detail/v4/fir/{product_id}",
42
+ )
43
+ payload = response.json()
44
+ product_card = payload.get("productCard")
45
+ if not isinstance(product_card, dict):
46
+ raise LookupError(f"Product {product_id} detail payload did not contain productCard.")
47
+ return self._map_product(product_card)
48
+
49
+ @staticmethod
50
+ def _map_product(payload: dict) -> Product:
51
+ return Product(
52
+ id=int(ProductsAPI._extract_product_id(payload)),
53
+ title=ProductsAPI._extract_title(payload),
54
+ brand=payload.get("brand"),
55
+ price=ProductsAPI._extract_price(payload),
56
+ unit_size=ProductsAPI._extract_unit_size(payload),
57
+ image_url=ProductsAPI._extract_image_url(payload),
58
+ )
59
+
60
+ @staticmethod
61
+ def _extract_product_id(payload: dict) -> int:
62
+ product_id = payload.get("id", payload.get("webshopId"))
63
+ if product_id is None:
64
+ raise KeyError("Product payload did not contain id or webshopId")
65
+ return int(product_id)
66
+
67
+ @staticmethod
68
+ def _extract_title(payload: dict) -> str:
69
+ title = payload.get("title") or payload.get("description")
70
+ if not title:
71
+ raise KeyError("Product payload did not contain title or description")
72
+ return title
73
+
74
+ @staticmethod
75
+ def _extract_price(payload: dict) -> float | None:
76
+ price = (
77
+ payload.get("currentPrice") or payload.get("priceBeforeBonus") or payload.get("price")
78
+ )
79
+ if isinstance(price, dict):
80
+ price = price.get("amount")
81
+ return float(price) if price is not None else None
82
+
83
+ @staticmethod
84
+ def _extract_unit_size(payload: dict) -> str | None:
85
+ return (
86
+ payload.get("unitSize")
87
+ or payload.get("salesUnitSize")
88
+ or payload.get("unitPriceDescription")
89
+ )
90
+
91
+ @staticmethod
92
+ def _extract_image_url(payload: dict) -> str | None:
93
+ image_url = payload.get("imageUrl")
94
+ images = payload.get("images")
95
+ if image_url is None and isinstance(images, list) and images:
96
+ first_image = images[0]
97
+ if isinstance(first_image, dict):
98
+ image_url = first_image.get("url")
99
+ return image_url
appie/receipts.py ADDED
@@ -0,0 +1,106 @@
1
+ """Receipt listing and detail operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Any, Protocol
7
+
8
+ from appie.models import Receipt, ReceiptProduct
9
+
10
+ POS_RECEIPTS_QUERY = """
11
+ query FetchPosReceipts($offset: Int!, $limit: Int!) {
12
+ posReceiptsPage(pagination: {offset: $offset, limit: $limit}) {
13
+ posReceipts {
14
+ id
15
+ dateTime
16
+ totalAmount { amount }
17
+ }
18
+ }
19
+ }
20
+ """
21
+
22
+ POS_RECEIPT_DETAILS_QUERY = """
23
+ query FetchReceipt($id: String!) {
24
+ posReceiptDetails(id: $id) {
25
+ id
26
+ memberId
27
+ products {
28
+ id
29
+ quantity
30
+ name
31
+ price { amount }
32
+ amount { amount }
33
+ }
34
+ }
35
+ }
36
+ """
37
+
38
+
39
+ class GraphQLClient(Protocol):
40
+ """Protocol for clients that can execute GraphQL requests."""
41
+
42
+ async def graphql(self, query: str, variables: dict[str, Any] | None = None) -> dict:
43
+ """Execute a GraphQL request."""
44
+ ...
45
+
46
+
47
+ class ReceiptsAPI:
48
+ """High-level receipt operations."""
49
+
50
+ def __init__(self, client: GraphQLClient) -> None:
51
+ self._client = client
52
+
53
+ async def list_pos_receipts(self, limit: int = 50) -> list[Receipt]:
54
+ """Return POS receipt summaries without line items."""
55
+ data = await self._client.graphql(POS_RECEIPTS_QUERY, {"offset": 0, "limit": limit})
56
+ receipts = (data.get("posReceiptsPage") or {}).get("posReceipts", [])
57
+ return [self._map_receipt_summary(item) for item in receipts]
58
+
59
+ async def get_pos_receipt(self, receipt_id: str) -> Receipt:
60
+ """Return a single POS receipt including product line items."""
61
+ data = await self._client.graphql(POS_RECEIPT_DETAILS_QUERY, {"id": receipt_id})
62
+ receipt = data["posReceiptDetails"]
63
+ summary = await self._get_receipt_summary(receipt_id)
64
+ return self._map_receipt_detail(receipt, summary=summary)
65
+
66
+ async def list_all(self, limit: int = 50) -> list[Receipt]:
67
+ """Return receipt summaries sorted by datetime descending."""
68
+ receipts = await self.list_pos_receipts(limit=limit)
69
+ return sorted(receipts, key=lambda receipt: receipt.datetime, reverse=True)
70
+
71
+ @staticmethod
72
+ def _map_receipt_summary(payload: dict) -> Receipt:
73
+ return Receipt(
74
+ id=str(payload["id"]),
75
+ datetime=datetime.fromisoformat(payload["dateTime"]),
76
+ store_name=None,
77
+ total=float(payload["totalAmount"]["amount"]),
78
+ products=[],
79
+ )
80
+
81
+ @classmethod
82
+ def _map_receipt_detail(cls, payload: dict, summary: Receipt | None = None) -> Receipt:
83
+ products = [
84
+ ReceiptProduct(
85
+ id=int(line["id"]),
86
+ name=line["name"],
87
+ quantity=float(line["quantity"]),
88
+ price_per_unit=float((line.get("price") or {}).get("amount") or 0.0),
89
+ total_price=float(line["amount"]["amount"]),
90
+ )
91
+ for line in payload.get("products", [])
92
+ ]
93
+ return Receipt(
94
+ id=str(payload["id"]),
95
+ datetime=summary.datetime if summary else datetime.min,
96
+ store_name=summary.store_name if summary else None,
97
+ total=summary.total if summary else sum(product.total_price for product in products),
98
+ products=products,
99
+ )
100
+
101
+ async def _get_receipt_summary(self, receipt_id: str) -> Receipt | None:
102
+ receipts = await self.list_pos_receipts(limit=100)
103
+ for receipt in receipts:
104
+ if receipt.id == receipt_id:
105
+ return receipt
106
+ return None
@@ -0,0 +1,211 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-appie
3
+ Version: 0.1.0
4
+ Summary: Unofficial Python client for the Albert Heijn API.
5
+ Project-URL: Homepage, https://github.com/tijnschouten/appie
6
+ Project-URL: Documentation, https://tijnschouten.github.io/appie/
7
+ Project-URL: Repository, https://github.com/tijnschouten/appie
8
+ Project-URL: Issues, https://github.com/tijnschouten/appie/issues
9
+ Author: Codex
10
+ Requires-Python: >=3.11
11
+ Requires-Dist: httpx
12
+ Requires-Dist: playwright>=1.52.0
13
+ Requires-Dist: pydantic>=2
14
+ Provides-Extra: dev
15
+ Requires-Dist: mkdocs-material>=9.5; extra == 'dev'
16
+ Requires-Dist: mkdocs>=1.6; extra == 'dev'
17
+ Requires-Dist: pre-commit>=4.0; extra == 'dev'
18
+ Requires-Dist: pyright>=1.1.390; extra == 'dev'
19
+ Requires-Dist: pytest; extra == 'dev'
20
+ Requires-Dist: pytest-asyncio; extra == 'dev'
21
+ Requires-Dist: pytest-cov; extra == 'dev'
22
+ Requires-Dist: respx; extra == 'dev'
23
+ Requires-Dist: ruff; extra == 'dev'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # python-appie
27
+
28
+ `python-appie` is an unofficial async Python client for the Albert Heijn API.
29
+
30
+ Full documentation lives in [`docs/`](/Users/tijnschouten/repos/personal/appie/docs), is built from [`mkdocs.yml`](/Users/tijnschouten/repos/personal/appie/mkdocs.yml), and is intended to be published at [tijnschouten.github.io/appie](https://tijnschouten.github.io/appie/).
31
+
32
+ Releases are intended to publish to PyPI as `python-appie` from version tags via GitHub Actions.
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ uv add python-appie
38
+ ```
39
+
40
+ Or:
41
+
42
+ ```bash
43
+ pip install python-appie
44
+ ```
45
+
46
+ For local development in this repository:
47
+
48
+ ```bash
49
+ uv sync --extra dev
50
+ pre-commit install
51
+ ```
52
+
53
+ ## Quick start
54
+
55
+ Authenticate once:
56
+
57
+ ```bash
58
+ uv run appie-login
59
+ ```
60
+
61
+ This opens Chrome for an interactive AH login and captures the OAuth redirect code automatically. If automatic capture cannot start, the CLI falls back to asking for the redirect URL or raw code manually.
62
+
63
+ Then use the client:
64
+
65
+ ```python
66
+ import asyncio
67
+
68
+ from appie import AHClient
69
+
70
+
71
+ async def main() -> None:
72
+ async with AHClient() as client:
73
+ products = await client.products.search("melk", limit=3)
74
+ for product in products:
75
+ print(product)
76
+
77
+
78
+ asyncio.run(main())
79
+ ```
80
+
81
+ Tokens are stored in `~/.config/appie/tokens.json` and refreshed automatically when they are close to expiring.
82
+
83
+ ## Features
84
+
85
+ ### Authentication
86
+
87
+ - `appie-login` CLI for browser-based login
88
+ - automatic code capture from the AH redirect flow
89
+ - token persistence in `~/.config/appie/tokens.json`
90
+ - automatic token refresh using the stored refresh token
91
+
92
+ ### Products
93
+
94
+ - search products via `client.products.search(query, limit=10)`
95
+ - fetch a single product via `client.products.get(product_id)`
96
+
97
+ Example:
98
+
99
+ ```python
100
+ import asyncio
101
+
102
+ from appie import AHClient
103
+
104
+
105
+ async def main() -> None:
106
+ async with AHClient() as client:
107
+ product = await client.products.get(1525)
108
+ print(product)
109
+
110
+
111
+ asyncio.run(main())
112
+ ```
113
+
114
+ ### Receipts
115
+
116
+ - list in-store POS receipt summaries via `client.receipts.list_all(limit=50)`
117
+ - fetch a receipt with line items via `client.receipts.get_pos_receipt(receipt_id)`
118
+
119
+ Important:
120
+ `list_all()` and `list_pos_receipts()` return receipt summaries. In those results, `products` is intentionally empty.
121
+ To retrieve line items, call `get_pos_receipt()` with a receipt ID from the summary list.
122
+
123
+ Example:
124
+
125
+ ```python
126
+ import asyncio
127
+
128
+ from appie import AHClient
129
+
130
+
131
+ async def main() -> None:
132
+ async with AHClient() as client:
133
+ receipts = await client.receipts.list_all(limit=5)
134
+ detailed = await client.receipts.get_pos_receipt(receipts[0].id)
135
+ print(detailed)
136
+
137
+
138
+ asyncio.run(main())
139
+ ```
140
+
141
+ ### Shopping lists
142
+
143
+ - add an item via `client.lists.add_item(description, quantity=1, product_id=None)`
144
+ - use `MockAHClient` for local development and tests without touching AH
145
+
146
+ Example:
147
+
148
+ ```python
149
+ import asyncio
150
+
151
+ from appie import AHClient
152
+
153
+
154
+ async def main() -> None:
155
+ async with AHClient() as client:
156
+ item = await client.lists.add_item("Halfvolle melk", quantity=2)
157
+ print(item)
158
+
159
+
160
+ asyncio.run(main())
161
+ ```
162
+
163
+ Current limitation:
164
+ shopping-list add is implemented, but `get_list()`, `remove_item()`, and `clear()` still raise `NotImplementedError` until their live API shape is confirmed.
165
+
166
+ ## API overview
167
+
168
+ ### Main client
169
+
170
+ - `AHClient()`
171
+ - `MockAHClient()`
172
+ - `await client.login()`
173
+ - `await client.graphql(query, variables=None)`
174
+
175
+ ### Auth client
176
+
177
+ - `AHAuthClient.get_anonymous_token()`
178
+ - `AHAuthClient.login_with_code(code)`
179
+ - `AHAuthClient.refresh_token(refresh_token)`
180
+
181
+ ### Sub-APIs
182
+
183
+ - `client.products.search(query, limit=10)`
184
+ - `client.products.get(product_id)`
185
+ - `client.receipts.list_pos_receipts(limit=50)`
186
+ - `client.receipts.list_all(limit=50)`
187
+ - `client.receipts.get_pos_receipt(receipt_id)`
188
+ - `client.lists.add_item(description, quantity=1, product_id=None)`
189
+ - `client.lists.get_list()`
190
+ - `client.lists.remove_item(item_id)`
191
+ - `client.lists.clear()`
192
+
193
+ ## Development
194
+
195
+ Run checks locally:
196
+
197
+ ```bash
198
+ uv run ruff format .
199
+ uv run --extra dev ruff check .
200
+ uv run --extra dev pyright
201
+ uv run --extra dev pytest
202
+ uv run --extra dev mkdocs build --strict
203
+ ```
204
+
205
+ ## Notes
206
+
207
+ - This client is unofficial and may break when Albert Heijn changes its backend.
208
+ - Receipt support currently covers in-store POS receipts.
209
+ - Shopping-list support only implements the verified add-item mutation; other operations raise explicit `NotImplementedError` until their GraphQL shape is confirmed.
210
+ - Receipt summaries do not include line items; call `get_pos_receipt()` for a detailed receipt.
211
+ - Endpoint discovery for this package is inspired by [gwillem/appie-go](https://github.com/gwillem/appie-go).
@@ -0,0 +1,12 @@
1
+ appie/__init__.py,sha256=9BOqxGRrvx5Vyb5Y3bKziVGP3iDKiwfkVTwAyq-KfMI,1009
2
+ appie/auth.py,sha256=l_yxwH9bvjyOKXxW10qbW2pkbwX38CtohJDdfgHibv4,6132
3
+ appie/client.py,sha256=5EQFYfQgTpU1dr6qY7rFN9KeVz6ZcjuqFSCKjefI3fc,9140
4
+ appie/lists.py,sha256=XaoNkcE9OLHJKahYmxwE5lhdkIoBksQNTWI8QScGiQU,2409
5
+ appie/mock.py,sha256=ZxN7BZYA-odwGWUkf-mXb5Ky_hXJq-gVYPkOFSHPF7I,7078
6
+ appie/models.py,sha256=AprEQVIA8wSZuoCeL9--wQlp_cFtvyvtFncBkZtAgAY,1959
7
+ appie/products.py,sha256=Hk9loD7L-SPx7tZVzghBx6Q9pMtrEfZhZjHxX7POI4s,3530
8
+ appie/receipts.py,sha256=KeLqJI1D3pIbV9qX1uiPKRLLBOjN4BvPrBxZe6_Naso,3526
9
+ python_appie-0.1.0.dist-info/METADATA,sha256=KKx0B0eB28L_tEg581mffteZt0OWL6a0h94v04zhW40,5587
10
+ python_appie-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
11
+ python_appie-0.1.0.dist-info/entry_points.txt,sha256=A8yGyh6r6ciUAoKrHDbCB2hMlim0P3RDdnaMgbDclCc,48
12
+ python_appie-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ appie-login = appie:login_cli