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 +29 -0
- appie/auth.py +159 -0
- appie/client.py +248 -0
- appie/lists.py +78 -0
- appie/mock.py +211 -0
- appie/models.py +82 -0
- appie/products.py +99 -0
- appie/receipts.py +106 -0
- python_appie-0.1.0.dist-info/METADATA +211 -0
- python_appie-0.1.0.dist-info/RECORD +12 -0
- python_appie-0.1.0.dist-info/WHEEL +4 -0
- python_appie-0.1.0.dist-info/entry_points.txt +2 -0
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,,
|