pyhellofresh-uk 0.1.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,41 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ build:
9
+ name: Build distribution
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+
14
+ - name: Install uv
15
+ uses: astral-sh/setup-uv@v5
16
+
17
+ - name: Build package
18
+ run: uv build
19
+
20
+ - name: Upload dist artifacts
21
+ uses: actions/upload-artifact@v4
22
+ with:
23
+ name: dist
24
+ path: dist/
25
+
26
+ publish:
27
+ name: Publish to PyPI
28
+ needs: build
29
+ runs-on: ubuntu-latest
30
+ environment: release
31
+ permissions:
32
+ id-token: write
33
+ steps:
34
+ - name: Download dist artifacts
35
+ uses: actions/download-artifact@v4
36
+ with:
37
+ name: dist
38
+ path: dist/
39
+
40
+ - name: Publish to PyPI
41
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.egg-info/
5
+ dist/
6
+ .pytest_cache/
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyhellofresh-uk
3
+ Version: 0.1.5
4
+ Summary: Async Python library for the HelloFresh UK API
5
+ Project-URL: Homepage, https://github.com/caraar12345/pyhellofresh-uk
6
+ Project-URL: Repository, https://github.com/caraar12345/pyhellofresh-uk
7
+ License: MIT
8
+ Requires-Python: >=3.12
9
+ Requires-Dist: aiohttp>=3.9.0
File without changes
@@ -0,0 +1,28 @@
1
+ [project]
2
+ name = "pyhellofresh-uk"
3
+ version = "0.1.5"
4
+ description = "Async Python library for the HelloFresh UK API"
5
+ readme = "README.md"
6
+ license = { text = "MIT" }
7
+ requires-python = ">=3.12"
8
+ dependencies = [
9
+ "aiohttp>=3.9.0",
10
+ ]
11
+
12
+ [project.urls]
13
+ Homepage = "https://github.com/caraar12345/pyhellofresh-uk"
14
+ Repository = "https://github.com/caraar12345/pyhellofresh-uk"
15
+
16
+ [build-system]
17
+ requires = ["hatchling"]
18
+ build-backend = "hatchling.build"
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["src/pyhellofresh"]
22
+
23
+ [dependency-groups]
24
+ dev = [
25
+ "pytest>=8.0.0",
26
+ "pytest-asyncio>=0.23.0",
27
+ "aioresponses>=0.7.6",
28
+ ]
@@ -0,0 +1,18 @@
1
+ """pyhellofresh-uk: Async Python library for the HelloFresh UK API."""
2
+
3
+ from .client import HelloFreshClient
4
+ from .exceptions import ApiError, AuthenticationError, CloudflareBlockError, HelloFreshError
5
+ from .models import AuthToken, CustomerInfo, Meal, UpcomingDelivery, WeeklyDelivery
6
+
7
+ __all__ = [
8
+ "ApiError",
9
+ "AuthToken",
10
+ "AuthenticationError",
11
+ "CloudflareBlockError",
12
+ "CustomerInfo",
13
+ "HelloFreshClient",
14
+ "HelloFreshError",
15
+ "Meal",
16
+ "UpcomingDelivery",
17
+ "WeeklyDelivery",
18
+ ]
@@ -0,0 +1,436 @@
1
+ """Async client for the HelloFresh UK API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from datetime import UTC, datetime, timedelta
7
+
8
+ import aiohttp
9
+ from yarl import URL
10
+
11
+ from .exceptions import ApiError, AuthenticationError, CloudflareBlockError
12
+ from .models import AuthToken, CustomerInfo, Meal, UpcomingDelivery, WeeklyDelivery
13
+
14
+ _LOGGER = logging.getLogger(__name__)
15
+
16
+ _BASE_URL = "https://www.hellofresh.co.uk"
17
+ _AUTH0_URL = "https://hellofresh-live.eu.auth0.com"
18
+ _AUTH0_CLIENT_ID = "B1n0Q24hv7e4AHc7yG1WwQyuMvpCAIya"
19
+ _COUNTRY = "GB"
20
+ _LOCALE = "en-GB"
21
+ _TOKEN_EXPIRY_BUFFER = timedelta(seconds=60)
22
+ _PDF_URL_TEMPLATE = f"{_BASE_URL}/recipecards/card/{{recipe_id}}-en-GB.pdf"
23
+
24
+ _USER_AGENT = (
25
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
26
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
27
+ "Chrome/146.0.0.0 Safari/537.36"
28
+ )
29
+
30
+ _FETCH_HEADERS = {
31
+ "User-Agent": _USER_AGENT,
32
+ "Accept-Language": "en-GB,en;q=0.9",
33
+ "sec-ch-ua": '"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"',
34
+ "sec-ch-ua-mobile": "?0",
35
+ "sec-ch-ua-platform": '"macOS"',
36
+ "Origin": _BASE_URL,
37
+ "Sec-Fetch-Site": "same-origin",
38
+ "Sec-Fetch-Mode": "cors",
39
+ "Sec-Fetch-Dest": "empty",
40
+ }
41
+
42
+ _NAV_HEADERS = {
43
+ "User-Agent": _USER_AGENT,
44
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
45
+ "Accept-Language": "en-GB,en;q=0.9",
46
+ "sec-ch-ua": '"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"',
47
+ "sec-ch-ua-mobile": "?0",
48
+ "sec-ch-ua-platform": '"macOS"',
49
+ "Sec-Fetch-Site": "none",
50
+ "Sec-Fetch-Mode": "navigate",
51
+ "Sec-Fetch-Dest": "document",
52
+ "Upgrade-Insecure-Requests": "1",
53
+ }
54
+
55
+
56
+ class HelloFreshClient:
57
+ """Async client for the HelloFresh UK API.
58
+
59
+ The client owns its aiohttp session with a persistent cookie jar.
60
+
61
+ When ``flaresolverr_url`` is provided, it is used *only* for the initial
62
+ cookie pre-visit. All subsequent requests go directly to HelloFresh.
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ email: str,
68
+ password: str,
69
+ *,
70
+ flaresolverr_url: str | None = None,
71
+ ) -> None:
72
+ self._email = email
73
+ self._password = password
74
+ self._flaresolverr_url = flaresolverr_url.rstrip("/") if flaresolverr_url else None
75
+ self._session: aiohttp.ClientSession | None = None
76
+ self._token: AuthToken | None = None
77
+
78
+ def _ensure_session(self) -> aiohttp.ClientSession:
79
+ if self._session is None or self._session.closed:
80
+ self._session = aiohttp.ClientSession(cookie_jar=aiohttp.CookieJar())
81
+ return self._session
82
+
83
+ async def close(self) -> None:
84
+ """Close the underlying HTTP session."""
85
+ if self._session and not self._session.closed:
86
+ await self._session.close()
87
+ self._session = None
88
+
89
+ async def __aenter__(self) -> HelloFreshClient:
90
+ return self
91
+
92
+ async def __aexit__(self, *args: object) -> None:
93
+ await self.close()
94
+
95
+ # ------------------------------------------------------------------
96
+ # Cloudflare cookie acquisition
97
+ # ------------------------------------------------------------------
98
+
99
+ async def _acquire_cf_cookies(self) -> str:
100
+ """Visit the login page to obtain CF cookies. Returns the User-Agent to use."""
101
+ session = self._ensure_session()
102
+
103
+ if self._flaresolverr_url:
104
+ return await self._acquire_cf_cookies_via_flaresolverr(session)
105
+
106
+ try:
107
+ async with session.get(
108
+ f"{_BASE_URL}/login",
109
+ headers=_NAV_HEADERS,
110
+ allow_redirects=True,
111
+ ) as resp:
112
+ _LOGGER.debug("CF pre-visit status: %s", resp.status)
113
+ except Exception as err: # noqa: BLE001
114
+ _LOGGER.debug("CF pre-visit failed (continuing): %s", err)
115
+
116
+ return _USER_AGENT
117
+
118
+ async def _acquire_cf_cookies_via_flaresolverr(
119
+ self, session: aiohttp.ClientSession
120
+ ) -> str:
121
+ assert self._flaresolverr_url is not None
122
+ _LOGGER.debug("Acquiring CF cookies via FlareSolverr")
123
+
124
+ async with session.post(
125
+ f"{self._flaresolverr_url}/v1",
126
+ json={"cmd": "request.get", "url": f"{_BASE_URL}/login", "maxTimeout": 60000},
127
+ ) as resp:
128
+ fls = await resp.json()
129
+
130
+ if fls.get("status") != "ok":
131
+ raise ApiError(0, f"FlareSolverr: {fls.get('message', 'unknown error')}")
132
+
133
+ solution = fls["solution"]
134
+ cookies = {c["name"]: c["value"] for c in solution.get("cookies", [])}
135
+ if cookies:
136
+ session.cookie_jar.update_cookies(cookies, URL(f"{_BASE_URL}/"))
137
+ _LOGGER.debug("Imported %d CF cookies from FlareSolverr", len(cookies))
138
+
139
+ return solution.get("userAgent", _USER_AGENT)
140
+
141
+ # ------------------------------------------------------------------
142
+ # Authentication
143
+ # ------------------------------------------------------------------
144
+
145
+ async def authenticate(self) -> AuthToken:
146
+ """Authenticate with email and password, returning a fresh token."""
147
+ user_agent = await self._acquire_cf_cookies()
148
+ session = self._ensure_session()
149
+
150
+ async with session.post(
151
+ f"{_BASE_URL}/gw/login",
152
+ params={"country": _COUNTRY, "locale": _LOCALE},
153
+ json={"username": self._email, "password": self._password},
154
+ headers={
155
+ **_FETCH_HEADERS,
156
+ "User-Agent": user_agent,
157
+ "Accept": "application/json, text/plain, */*",
158
+ "Referer": f"{_BASE_URL}/login",
159
+ },
160
+ ) as resp:
161
+ if resp.status == 401:
162
+ raise AuthenticationError("Invalid email or password")
163
+ if resp.status == 403:
164
+ raise CloudflareBlockError(
165
+ "Cloudflare blocked the login request. "
166
+ "Provide a FlareSolverr URL to bypass this."
167
+ )
168
+ if resp.status != 200:
169
+ text = await resp.text()
170
+ raise ApiError(resp.status, text)
171
+ data = await resp.json()
172
+
173
+ issued_at = datetime.fromtimestamp(data["issued_at"], tz=UTC)
174
+ token = AuthToken(
175
+ access_token=data["access_token"],
176
+ refresh_token=data["refresh_token"],
177
+ token_type=data["token_type"],
178
+ expires_at=issued_at + timedelta(seconds=data["expires_in"]),
179
+ refresh_expires_at=issued_at + timedelta(seconds=data["refresh_expires_in"]),
180
+ customer_id=data["user_data"]["id"],
181
+ )
182
+ self._token = token
183
+ return token
184
+
185
+ async def _refresh_token(self, refresh_token: str) -> AuthToken:
186
+ session = self._ensure_session()
187
+ async with session.post(
188
+ f"{_AUTH0_URL}/oauth/token",
189
+ data={
190
+ "grant_type": "refresh_token",
191
+ "refresh_token": refresh_token,
192
+ "client_id": _AUTH0_CLIENT_ID,
193
+ },
194
+ ) as resp:
195
+ if resp.status != 200:
196
+ raise AuthenticationError("Refresh token is invalid or expired")
197
+ data = await resp.json()
198
+
199
+ now = datetime.now(tz=UTC)
200
+ token = AuthToken(
201
+ access_token=data["access_token"],
202
+ refresh_token=data.get("refresh_token", refresh_token),
203
+ token_type=data.get("token_type", "Bearer"),
204
+ expires_at=now + timedelta(seconds=data["expires_in"]),
205
+ refresh_expires_at=self._token.refresh_expires_at
206
+ if self._token
207
+ else now + timedelta(days=60),
208
+ customer_id=self._token.customer_id if self._token else "",
209
+ )
210
+ self._token = token
211
+ return token
212
+
213
+ async def _ensure_token(self) -> str:
214
+ if self._token is None:
215
+ await self.authenticate()
216
+ assert self._token is not None
217
+ return self._token.access_token
218
+
219
+ if datetime.now(tz=UTC) >= self._token.expires_at - _TOKEN_EXPIRY_BUFFER:
220
+ _LOGGER.debug("Access token near expiry, refreshing")
221
+ try:
222
+ await self._refresh_token(self._token.refresh_token)
223
+ except AuthenticationError:
224
+ _LOGGER.debug("Refresh failed, re-authenticating with credentials")
225
+ await self.authenticate()
226
+
227
+ assert self._token is not None
228
+ return self._token.access_token
229
+
230
+ def set_token(self, token: AuthToken) -> None:
231
+ """Inject a previously obtained token (e.g. from HA config entry)."""
232
+ self._token = token
233
+
234
+ # ------------------------------------------------------------------
235
+ # Core request helper
236
+ # ------------------------------------------------------------------
237
+
238
+ async def _request(self, path: str, params: dict) -> dict:
239
+ """Authenticated GET with one CF-cookie retry on HTML 403."""
240
+ session = self._ensure_session()
241
+ access_token = await self._ensure_token()
242
+
243
+ for attempt in range(2):
244
+ async with session.get(
245
+ f"{_BASE_URL}{path}",
246
+ params=params,
247
+ headers={
248
+ **_FETCH_HEADERS,
249
+ "Accept": "*/*",
250
+ "Authorization": f"Bearer {access_token}",
251
+ },
252
+ ) as resp:
253
+ if resp.status == 401:
254
+ raise AuthenticationError("Access token rejected by API")
255
+
256
+ if resp.status == 403:
257
+ content_type = resp.headers.get("Content-Type", "")
258
+ if "text/html" in content_type and self._flaresolverr_url and attempt == 0:
259
+ _LOGGER.debug(
260
+ "Cloudflare block on API call, refreshing cookies via FlareSolverr"
261
+ )
262
+ await self._acquire_cf_cookies()
263
+ continue
264
+ text = await resp.text()
265
+ if "text/html" in content_type:
266
+ raise CloudflareBlockError(f"Cloudflare blocked request to {path}")
267
+ raise ApiError(resp.status, text)
268
+
269
+ if resp.status != 200:
270
+ text = await resp.text()
271
+ raise ApiError(resp.status, text)
272
+
273
+ return await resp.json()
274
+
275
+ raise CloudflareBlockError(f"Cloudflare block persisted after cookie refresh for {path}")
276
+
277
+ def _std_params(self, **extra: str | int) -> dict:
278
+ """Standard params (country + locale) merged with extras."""
279
+ return {"country": _COUNTRY, "locale": _LOCALE, **extra}
280
+
281
+ # ------------------------------------------------------------------
282
+ # API methods
283
+ # ------------------------------------------------------------------
284
+
285
+ async def get_customer_info(self) -> CustomerInfo:
286
+ """Return customer account information."""
287
+ data = await self._request("/gw/api/customers/me/info", self._std_params())
288
+ return CustomerInfo(
289
+ id=data["id"],
290
+ uuid=data["uuid"],
291
+ first_name=data["firstName"],
292
+ last_name=data["lastName"],
293
+ email=data["email"],
294
+ active_subscription_id=data["activeSubscriptionId"],
295
+ subscription_ids=data["subscriptionIds"],
296
+ customer_plan_ids=data["customerPlanIds"],
297
+ )
298
+
299
+ async def get_current_week_meals(
300
+ self,
301
+ subscription_id: int,
302
+ *,
303
+ week: str | None = None,
304
+ ) -> WeeklyDelivery:
305
+ """Return meals for the specified week (defaults to the current ISO week)."""
306
+ if week is None:
307
+ iso = datetime.now().isocalendar()
308
+ week = f"{iso.year}-W{iso.week:02d}"
309
+
310
+ data = await self._request(
311
+ "/gw/my-deliveries/past-deliveries",
312
+ self._std_params(**{
313
+ "from": week,
314
+ "rating-scale": "5",
315
+ "subscription": str(subscription_id),
316
+ }),
317
+ )
318
+
319
+ for entry in data.get("weeks", []):
320
+ if entry.get("week") == week:
321
+ return WeeklyDelivery(
322
+ week=week,
323
+ meals=_parse_meals(entry.get("meals", [])),
324
+ )
325
+
326
+ return WeeklyDelivery(week=week, meals=[])
327
+
328
+ async def get_upcoming_delivery(
329
+ self,
330
+ subscription_id: int,
331
+ customer_plan_id: str,
332
+ ) -> UpcomingDelivery | None:
333
+ """Return the next scheduled delivery with currently selected meals.
334
+
335
+ Returns None if no upcoming (non-delivered) delivery is found.
336
+ """
337
+ now = datetime.now()
338
+ iso = now.isocalendar()
339
+ range_start = f"{iso.year}-W{iso.week:02d}"
340
+ # Look 8 weeks ahead to cover skipped/holiday weeks
341
+ range_end_dt = now + timedelta(weeks=8)
342
+ range_end_iso = range_end_dt.isocalendar()
343
+ range_end = f"{range_end_iso.year}-W{range_end_iso.week:02d}"
344
+
345
+ data = await self._request(
346
+ "/gw/api/customers/me/deliveries",
347
+ self._std_params(rangeStart=range_start, rangeEnd=range_end),
348
+ )
349
+
350
+ next_item = _find_next_delivery(data.get("items", []))
351
+ if next_item is None:
352
+ return None
353
+
354
+ week = next_item["id"]
355
+ cutoff_date = datetime.fromisoformat(next_item["cutoffDate"])
356
+ delivery_date = datetime.fromisoformat(next_item["deliveryDate"])
357
+ status = next_item.get("status", "")
358
+ product = next_item.get("product", {})
359
+ delivery_option = next_item.get("deliveryOption", {})
360
+
361
+ menu_data = await self._request(
362
+ "/gw/my-deliveries/menu",
363
+ {
364
+ "customerPlanId": customer_plan_id,
365
+ "delivery-option": delivery_option.get("handle", ""),
366
+ "exclude": "",
367
+ "exclude-feedback": "true",
368
+ "include-filters": "true",
369
+ "include-future-feedback": "false",
370
+ "locale": _LOCALE,
371
+ "preference": "chefschoice",
372
+ "product-sku": product.get("handle", ""),
373
+ "servings": str(product.get("specs", {}).get("size", 2)),
374
+ "subscription": str(subscription_id),
375
+ "week": week,
376
+ },
377
+ )
378
+
379
+ selected = [
380
+ _parse_meal_from_menu(m)
381
+ for m in menu_data.get("meals", [])
382
+ if m.get("selection") and m["selection"].get("quantity", 0) > 0
383
+ ]
384
+
385
+ return UpcomingDelivery(
386
+ week=week,
387
+ cutoff_date=cutoff_date,
388
+ delivery_date=delivery_date,
389
+ status=status,
390
+ meals=selected,
391
+ )
392
+
393
+
394
+ # ------------------------------------------------------------------
395
+ # Helpers
396
+ # ------------------------------------------------------------------
397
+
398
+ def _find_next_delivery(items: list[dict]) -> dict | None:
399
+ """Return the earliest non-delivered delivery, or None."""
400
+ for item in items:
401
+ if item.get("status") != "DELIVERED":
402
+ return item
403
+ return None
404
+
405
+
406
+ def _parse_meals(raw: list[dict]) -> list[Meal]:
407
+ return [
408
+ Meal(
409
+ id=m["id"],
410
+ name=m["name"],
411
+ headline=m.get("headline", ""),
412
+ image_url=m.get("image", ""),
413
+ website_url=m.get("websiteURL", ""),
414
+ pdf_url=_PDF_URL_TEMPLATE.format(recipe_id=m["id"]),
415
+ prep_time=m.get("prepTime"),
416
+ total_time=m.get("totalTime"),
417
+ category=m.get("category"),
418
+ )
419
+ for m in raw
420
+ if "id" in m and "name" in m
421
+ ]
422
+
423
+
424
+ def _parse_meal_from_menu(item: dict) -> Meal:
425
+ r = item.get("recipe", {})
426
+ return Meal(
427
+ id=r["id"],
428
+ name=r["name"],
429
+ headline=r.get("headline", ""),
430
+ image_url=r.get("image", ""),
431
+ website_url=r.get("websiteURL", ""),
432
+ pdf_url=_PDF_URL_TEMPLATE.format(recipe_id=r["id"]),
433
+ prep_time=r.get("prepTime"),
434
+ total_time=r.get("totalTime"),
435
+ category=r.get("category"),
436
+ )
@@ -0,0 +1,21 @@
1
+ """Exceptions for the HelloFresh UK API library."""
2
+
3
+
4
+ class HelloFreshError(Exception):
5
+ """Base exception for HelloFresh errors."""
6
+
7
+
8
+ class AuthenticationError(HelloFreshError):
9
+ """Raised when authentication fails."""
10
+
11
+
12
+ class CloudflareBlockError(HelloFreshError):
13
+ """Raised when Cloudflare blocks the request (403)."""
14
+
15
+
16
+ class ApiError(HelloFreshError):
17
+ """Raised when an API call fails."""
18
+
19
+ def __init__(self, status: int, message: str) -> None:
20
+ self.status = status
21
+ super().__init__(f"API error {status}: {message}")
@@ -0,0 +1,64 @@
1
+ """Data models for the HelloFresh UK API."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+
6
+
7
+ @dataclass
8
+ class AuthToken:
9
+ """Authentication token data."""
10
+
11
+ access_token: str
12
+ refresh_token: str
13
+ token_type: str
14
+ expires_at: datetime
15
+ refresh_expires_at: datetime
16
+ customer_id: str
17
+
18
+
19
+ @dataclass
20
+ class CustomerInfo:
21
+ """HelloFresh customer information."""
22
+
23
+ id: str
24
+ uuid: str
25
+ first_name: str
26
+ last_name: str
27
+ email: str
28
+ active_subscription_id: int
29
+ subscription_ids: list[int]
30
+ customer_plan_ids: list[str]
31
+
32
+
33
+ @dataclass
34
+ class Meal:
35
+ """A HelloFresh meal/recipe."""
36
+
37
+ id: str
38
+ name: str
39
+ headline: str
40
+ image_url: str
41
+ website_url: str
42
+ pdf_url: str
43
+ prep_time: str | None = None
44
+ total_time: str | None = None
45
+ category: str | None = None
46
+
47
+
48
+ @dataclass
49
+ class WeeklyDelivery:
50
+ """Meals for a delivered week (from past-deliveries endpoint)."""
51
+
52
+ week: str
53
+ meals: list[Meal] = field(default_factory=list)
54
+
55
+
56
+ @dataclass
57
+ class UpcomingDelivery:
58
+ """Details of the next scheduled delivery."""
59
+
60
+ week: str
61
+ cutoff_date: datetime
62
+ delivery_date: datetime
63
+ status: str
64
+ meals: list[Meal] = field(default_factory=list)