ilidl 0.1.0__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,24 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ contents: read
12
+ id-token: write
13
+ environment: pypi
14
+ steps:
15
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
16
+ with:
17
+ persist-credentials: false
18
+ - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
19
+ with:
20
+ enable-cache: false
21
+ - run: uv build
22
+ - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
23
+ with:
24
+ packages-dir: dist/
ilidl-0.1.0/.gitignore ADDED
@@ -0,0 +1,35 @@
1
+ # Python
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ *.egg-info/
6
+ dist/
7
+ build/
8
+
9
+ # Virtual environment
10
+ .venv/
11
+
12
+ # Testing / linting caches
13
+ .pytest_cache/
14
+ .ruff_cache/
15
+ .coverage
16
+
17
+ # Playwright
18
+ .playwright-mcp/
19
+
20
+ # IDE
21
+ .idea/
22
+ .vscode/
23
+ *.swp
24
+
25
+ # Debug artifacts
26
+ *.png
27
+
28
+ # Claude
29
+ .claude/
30
+
31
+ # OS
32
+ .DS_Store
33
+
34
+ # Local plans (not for remote)
35
+ docs/superpowers/
@@ -0,0 +1,43 @@
1
+ # Contributing
2
+
3
+ Thanks for your interest in contributing to iLidl!
4
+
5
+ ## Setup
6
+
7
+ ```bash
8
+ git clone https://github.com/RoryDotGG/iLidl.git
9
+ cd iLidl
10
+ uv venv && source .venv/bin/activate
11
+ uv pip install -e ".[dev,auth]"
12
+ playwright install chromium
13
+ ```
14
+
15
+ ## Development
16
+
17
+ Run tests:
18
+
19
+ ```bash
20
+ uv run pytest tests/ -q
21
+ ```
22
+
23
+ Lint and format:
24
+
25
+ ```bash
26
+ uv run ruff check .
27
+ uv run ruff format .
28
+ ```
29
+
30
+ ## Submitting changes
31
+
32
+ 1. Fork the repository
33
+ 2. Create a feature branch (`git checkout -b feat/my-feature`)
34
+ 3. Make your changes with tests
35
+ 4. Ensure `ruff check .` and `ruff format --check .` pass
36
+ 5. Commit using [conventional commits](https://www.conventionalcommits.org/) (e.g. `feat:`, `fix:`, `chore:`)
37
+ 6. Open a pull request against `main`
38
+
39
+ ## Notes
40
+
41
+ - UK receipts are the primary focus. Other countries may work but are untested.
42
+ - The Lidl Plus API is undocumented and may change without notice.
43
+ - Keep the `App-Version` header realistic. The API rejects fake versions.
ilidl-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 RoryDotGG
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
ilidl-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.4
2
+ Name: ilidl
3
+ Version: 0.1.0
4
+ Summary: Unofficial Lidl Plus API client
5
+ Project-URL: Homepage, https://github.com/RoryDotGG/iLidl
6
+ Project-URL: Repository, https://github.com/RoryDotGG/iLidl
7
+ Project-URL: Issues, https://github.com/RoryDotGG/iLidl/issues
8
+ Author: RoryDotGG
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: api-client,coupons,lidl,lidl-plus,receipts
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Internet
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.12
22
+ Requires-Dist: click
23
+ Requires-Dist: httpx
24
+ Requires-Dist: tomli-w
25
+ Provides-Extra: auth
26
+ Requires-Dist: playwright; extra == 'auth'
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest; extra == 'dev'
29
+ Requires-Dist: ruff; extra == 'dev'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # iLidl
33
+
34
+ Unofficial Python client and CLI for the Lidl Plus API. Fetch receipts, parse item details, and manage coupons.
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ pip install ilidl
40
+
41
+ # Or with uv
42
+ uv add ilidl
43
+
44
+ # With auth support (requires Playwright for login)
45
+ pip install "ilidl[auth]"
46
+ playwright install chromium
47
+ ```
48
+
49
+ ## Authentication
50
+
51
+ ```bash
52
+ ilidl login
53
+ ```
54
+
55
+ Launches a headless browser to complete the Lidl Plus OAuth flow. You'll be prompted for your phone number and a verification code. A refresh token is saved to `~/.config/ilidl/config.toml` for future use.
56
+
57
+ ## CLI Usage
58
+
59
+ ```bash
60
+ # Receipts
61
+ ilidl receipt latest # Most recent receipt
62
+ ilidl receipt latest --json # As JSON
63
+ ilidl receipt <id> # Specific receipt
64
+ ilidl receipts # List all receipts
65
+ ilidl receipts --from 2026-03-01 # Filter by date
66
+ ilidl receipts --to 2026-03-18
67
+
68
+ # Coupons
69
+ ilidl coupons list # List available coupons
70
+ ilidl coupons activate <id> # Activate a coupon
71
+ ilidl coupons activate --all # Activate all coupons
72
+ ilidl coupons deactivate <id> # Deactivate a coupon
73
+ ```
74
+
75
+ All commands support `--json` for machine-readable output.
76
+
77
+ ## Library Usage
78
+
79
+ ```python
80
+ from ilidl import LidlClient
81
+
82
+ client = LidlClient(refresh_token="...", country="GB", language="en")
83
+
84
+ # Get latest receipt with parsed items
85
+ receipt = client.latest_receipt()
86
+ for item in receipt.items:
87
+ print(f"{item.name}: {item.price}")
88
+
89
+ # List coupons
90
+ for coupon in client.coupons():
91
+ print(f"{coupon.title} (active: {coupon.is_activated})")
92
+ ```
93
+
94
+ ## Configuration
95
+
96
+ Stored at `~/.config/ilidl/config.toml`:
97
+
98
+ ```toml
99
+ [auth]
100
+ refresh_token = "..."
101
+
102
+ [account]
103
+ language = "en"
104
+ country = "GB"
105
+ ```
106
+
107
+ ## Notes
108
+
109
+ - UK receipts are returned as HTML by the API and parsed into structured items using `data-*` attributes.
110
+ - The `App-Version` header must be a realistic value (currently `16.45.5`). The API rejects fake versions.
111
+ - API v2 is used for receipt lists, v3 for receipt detail.
112
+ - This project replaces the upstream `lidl-plus` library which is broken on modern Python.
ilidl-0.1.0/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # iLidl
2
+
3
+ Unofficial Python client and CLI for the Lidl Plus API. Fetch receipts, parse item details, and manage coupons.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install ilidl
9
+
10
+ # Or with uv
11
+ uv add ilidl
12
+
13
+ # With auth support (requires Playwright for login)
14
+ pip install "ilidl[auth]"
15
+ playwright install chromium
16
+ ```
17
+
18
+ ## Authentication
19
+
20
+ ```bash
21
+ ilidl login
22
+ ```
23
+
24
+ Launches a headless browser to complete the Lidl Plus OAuth flow. You'll be prompted for your phone number and a verification code. A refresh token is saved to `~/.config/ilidl/config.toml` for future use.
25
+
26
+ ## CLI Usage
27
+
28
+ ```bash
29
+ # Receipts
30
+ ilidl receipt latest # Most recent receipt
31
+ ilidl receipt latest --json # As JSON
32
+ ilidl receipt <id> # Specific receipt
33
+ ilidl receipts # List all receipts
34
+ ilidl receipts --from 2026-03-01 # Filter by date
35
+ ilidl receipts --to 2026-03-18
36
+
37
+ # Coupons
38
+ ilidl coupons list # List available coupons
39
+ ilidl coupons activate <id> # Activate a coupon
40
+ ilidl coupons activate --all # Activate all coupons
41
+ ilidl coupons deactivate <id> # Deactivate a coupon
42
+ ```
43
+
44
+ All commands support `--json` for machine-readable output.
45
+
46
+ ## Library Usage
47
+
48
+ ```python
49
+ from ilidl import LidlClient
50
+
51
+ client = LidlClient(refresh_token="...", country="GB", language="en")
52
+
53
+ # Get latest receipt with parsed items
54
+ receipt = client.latest_receipt()
55
+ for item in receipt.items:
56
+ print(f"{item.name}: {item.price}")
57
+
58
+ # List coupons
59
+ for coupon in client.coupons():
60
+ print(f"{coupon.title} (active: {coupon.is_activated})")
61
+ ```
62
+
63
+ ## Configuration
64
+
65
+ Stored at `~/.config/ilidl/config.toml`:
66
+
67
+ ```toml
68
+ [auth]
69
+ refresh_token = "..."
70
+
71
+ [account]
72
+ language = "en"
73
+ country = "GB"
74
+ ```
75
+
76
+ ## Notes
77
+
78
+ - UK receipts are returned as HTML by the API and parsed into structured items using `data-*` attributes.
79
+ - The `App-Version` header must be a realistic value (currently `16.45.5`). The API rejects fake versions.
80
+ - API v2 is used for receipt lists, v3 for receipt detail.
81
+ - This project replaces the upstream `lidl-plus` library which is broken on modern Python.
@@ -0,0 +1,9 @@
1
+ """Unofficial Lidl Plus API client."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ from ilidl.client import LidlClient
6
+ from ilidl.models import Coupon, Discount, Item, Receipt, Store
7
+
8
+ __all__ = ["LidlClient", "Coupon", "Discount", "Item", "Receipt", "Store"]
9
+ __version__ = version("ilidl")
@@ -0,0 +1,219 @@
1
+ """Playwright-based OAuth2 PKCE authentication for Lidl Plus."""
2
+
3
+ import base64
4
+ import contextlib
5
+ import getpass
6
+ import hashlib
7
+ import re
8
+ import secrets
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+ from ilidl.config import Config
14
+ from ilidl.exceptions import AuthError
15
+
16
+ AUTH_API = "https://accounts.lidl.com"
17
+ CLIENT_ID = "LidlPlusNativeClient"
18
+ REDIRECT_URI = "com.lidlplus.app://callback"
19
+ SCOPES = "openid profile offline_access lpprofile lpapis"
20
+
21
+
22
+ def _generate_pkce() -> tuple[str, str]:
23
+ """Generate PKCE code_verifier and code_challenge."""
24
+ verifier = secrets.token_urlsafe(64)
25
+ digest = hashlib.sha256(verifier.encode()).digest()
26
+ challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
27
+ return verifier, challenge
28
+
29
+
30
+ def _build_auth_url(challenge: str, country: str, language: str) -> str:
31
+ """Build the OAuth authorization URL."""
32
+ params = (
33
+ f"client_id={CLIENT_ID}"
34
+ f"&response_type=code"
35
+ f"&scope={SCOPES.replace(' ', '+')}"
36
+ f"&redirect_uri={REDIRECT_URI.replace(':', '%3A').replace('/', '%2F')}"
37
+ f"&code_challenge={challenge}"
38
+ f"&code_challenge_method=S256"
39
+ f"&Country={country.upper()}"
40
+ f"&language={language.lower()}-{country.upper()}"
41
+ )
42
+ return f"{AUTH_API}/connect/authorize?{params}"
43
+
44
+
45
+ def _exchange_code(code: str, verifier: str) -> dict[str, Any]:
46
+ """Exchange authorization code for tokens."""
47
+ secret = base64.b64encode(f"{CLIENT_ID}:secret".encode()).decode()
48
+ resp = httpx.post(
49
+ f"{AUTH_API}/connect/token",
50
+ headers={
51
+ "Authorization": f"Basic {secret}",
52
+ "Content-Type": "application/x-www-form-urlencoded",
53
+ },
54
+ data={
55
+ "grant_type": "authorization_code",
56
+ "code": code,
57
+ "redirect_uri": REDIRECT_URI,
58
+ "code_verifier": verifier,
59
+ },
60
+ timeout=30,
61
+ )
62
+ try:
63
+ resp.raise_for_status()
64
+ except httpx.HTTPStatusError as exc:
65
+ msg = f"Token exchange failed: {resp.status_code} {resp.text}"
66
+ raise AuthError(msg) from exc
67
+ return resp.json()
68
+
69
+
70
+ def _dbg(debug: bool, page: Any, name: str, msg: str = "") -> None:
71
+ """Save a debug screenshot and optionally print a message."""
72
+ if not debug:
73
+ return
74
+ page.screenshot(path=f"/tmp/ilidl_{name}.png")
75
+ if msg:
76
+ print(f"DEBUG {msg}")
77
+
78
+
79
+ def login(config: Config, *, debug: bool = False) -> str:
80
+ """Run interactive login via phone number. Returns refresh token."""
81
+ try:
82
+ from playwright.sync_api import sync_playwright
83
+ except ImportError as e:
84
+ msg = "Playwright is required for login. Install with: uv pip install 'ilidl[auth]'"
85
+ raise AuthError(msg) from e
86
+
87
+ verifier, challenge = _generate_pkce()
88
+ url = _build_auth_url(challenge, config.country, config.language)
89
+
90
+ phone = input("Phone number (local, e.g. 7415148717): ").strip()
91
+ password = getpass.getpass("Password: ")
92
+
93
+ with sync_playwright() as p:
94
+ browser = p.chromium.launch(headless=True)
95
+ page = browser.new_page()
96
+
97
+ code: str = ""
98
+
99
+ def handle_request(request):
100
+ nonlocal code
101
+ req_url = request.url
102
+ if debug:
103
+ print(f"DEBUG request: {req_url[:120]}")
104
+ if req_url.startswith("com.lidlplus.app://"):
105
+ match = re.search(r"code=([^&]+)", req_url)
106
+ if match:
107
+ code = match.group(1)
108
+ if debug:
109
+ print(f"DEBUG got code: {code}")
110
+
111
+ def handle_response(response):
112
+ nonlocal code
113
+ # Check for 302 redirects to the app callback
114
+ if code:
115
+ return
116
+ location = response.headers.get("location", "")
117
+ if location.startswith("com.lidlplus.app://"):
118
+ match = re.search(r"code=([^&]+)", location)
119
+ if match:
120
+ code = match.group(1)
121
+ if debug:
122
+ print(f"DEBUG got code from redirect: {code}")
123
+
124
+ page.on("request", handle_request)
125
+ page.on("response", handle_response)
126
+
127
+ page.goto(url)
128
+ page.wait_for_load_state("networkidle")
129
+ _dbg(debug, page, "01_welcome", f"url: {page.url}")
130
+
131
+ # Click "Log in" on the welcome page
132
+ page.locator("button:has-text('Log in'), a:has-text('Log in')").first.click(timeout=5000)
133
+ page.wait_for_load_state("networkidle")
134
+ _dbg(debug, page, "02_login_form", "on login form")
135
+
136
+ # Switch to phone number login
137
+ page.locator("button:has-text('phone number'), a:has-text('phone number')").first.click(
138
+ timeout=5000
139
+ )
140
+ page.wait_for_load_state("networkidle")
141
+ _dbg(debug, page, "03_phone_form", f"url: {page.url}")
142
+
143
+ # Fill local phone number (country code is pre-selected)
144
+ phone_field = page.locator(
145
+ "input[type='tel'], input[name='PhoneNumber'], input[name='phoneNumber']"
146
+ ).first
147
+ phone_field.fill(phone)
148
+
149
+ # Fill password
150
+ page.locator("input[type='password']").first.fill(password)
151
+ _dbg(debug, page, "04_filled")
152
+
153
+ # Submit and wait for navigation or page change
154
+ login_url = page.url
155
+ page.locator("button:has-text('Log in'), button[type='submit']").first.click()
156
+
157
+ # Wait for URL to change or for an error/verification
158
+ # element to appear
159
+ with contextlib.suppress(TimeoutError):
160
+ page.wait_for_function(
161
+ f"window.location.href !== '{login_url}' || "
162
+ "document.querySelector("
163
+ ' \'[name="VerificationCode"],'
164
+ " .alert-danger,"
165
+ " .error-message'"
166
+ ")",
167
+ timeout=15000,
168
+ )
169
+ page.wait_for_load_state("networkidle")
170
+ _dbg(debug, page, "05_after_submit", f"url: {page.url}")
171
+
172
+ # Check for error message
173
+ if not code:
174
+ error_el = page.locator(
175
+ ".alert-danger, .error-message, [class*='error'], [class*='Error']"
176
+ )
177
+ if error_el.first.is_visible(timeout=2000):
178
+ error_text = error_el.first.text_content()
179
+ _dbg(debug, page, "05b_error")
180
+ browser.close()
181
+ msg = f"Login failed: {error_text}"
182
+ raise AuthError(msg)
183
+
184
+ # Check for verification code prompt
185
+ if not code:
186
+ verify_field = page.locator(
187
+ "[name='VerificationCode'], input[name='code'], input[inputmode='numeric']"
188
+ )
189
+ try:
190
+ if verify_field.first.is_visible(timeout=10000):
191
+ _dbg(debug, page, "06_verify_prompt")
192
+ otp = input("Enter verification code: ").strip()
193
+ verify_field.first.fill(otp)
194
+ page.locator(
195
+ "button[type='submit'], "
196
+ "button:has-text('Submit'), "
197
+ "button:has-text('Verify')"
198
+ ).first.click()
199
+
200
+ # Wait for redirect after verify
201
+ with contextlib.suppress(TimeoutError):
202
+ page.wait_for_url("**/callback*", timeout=15000)
203
+ page.wait_for_timeout(3000)
204
+ _dbg(debug, page, "07_after_verify", f"url: {page.url}")
205
+ except Exception as exc:
206
+ if debug:
207
+ print(f"DEBUG verify error: {exc}")
208
+ _dbg(debug, page, "08_error")
209
+
210
+ browser.close()
211
+
212
+ if not code:
213
+ msg = "Login failed: could not extract authorization code"
214
+ raise AuthError(msg)
215
+
216
+ tokens = _exchange_code(code, verifier)
217
+ config.refresh_token = tokens["refresh_token"]
218
+ config.save()
219
+ return tokens["refresh_token"]