avios-cli 0.1.3__tar.gz → 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.
- {avios_cli-0.1.3 → avios_cli-0.1.5}/CHANGELOG.md +27 -1
- {avios_cli-0.1.3 → avios_cli-0.1.5}/PKG-INFO +14 -1
- {avios_cli-0.1.3 → avios_cli-0.1.5}/README.md +13 -0
- {avios_cli-0.1.3 → avios_cli-0.1.5}/src/avios/__init__.py +1 -1
- avios_cli-0.1.5/src/avios/auth.py +308 -0
- {avios_cli-0.1.3 → avios_cli-0.1.5}/src/avios/cli.py +17 -2
- {avios_cli-0.1.3 → avios_cli-0.1.5}/tests/test_auth.py +62 -84
- {avios_cli-0.1.3 → avios_cli-0.1.5}/tests/test_cli.py +18 -3
- avios_cli-0.1.3/src/avios/auth.py +0 -165
- {avios_cli-0.1.3 → avios_cli-0.1.5}/.editorconfig +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.5}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.5}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.5}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.5}/.github/dependabot.yml +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.5}/.github/workflows/ci.yml +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.5}/.github/workflows/publish.yml +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.5}/.gitignore +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.5}/.pre-commit-config.yaml +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.5}/.python-version +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.5}/CONTRIBUTING.md +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.5}/LICENSE +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.5}/SECURITY.md +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.5}/docs/dashboard.svg +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.5}/pyproject.toml +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.5}/scripts/screenshot.py +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.5}/src/avios/client.py +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.5}/src/avios/config.py +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.5}/src/avios/endpoints.py +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.5}/src/avios/models.py +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.5}/src/avios/session.py +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.5}/src/avios/tui/__init__.py +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.5}/src/avios/tui/app.py +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.5}/src/avios/tui/art.py +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.5}/src/avios/tui/styles.tcss +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.5}/src/avios/tui/widgets.py +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.5}/tests/conftest.py +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.5}/tests/fixtures/accounts.json +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.5}/tests/fixtures/balance.json +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.5}/tests/fixtures/transactions.json +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.5}/tests/test_client.py +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.5}/tests/test_models.py +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.5}/tests/test_session.py +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.5}/tests/test_tui.py +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.5}/uv.lock +0 -0
|
@@ -6,6 +6,30 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.1.5] - 2026-07-24
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- **`--from-browser` only read Chrome's `Default` profile.** If you were logged
|
|
13
|
+
into avios.com in another profile (Profile 1, a work profile, ...), it grabbed
|
|
14
|
+
the wrong profile's cookies and every call failed with "Session expired". It now
|
|
15
|
+
**scans all Chrome profiles, verifies which one actually authenticates**
|
|
16
|
+
(against `/auth-gateway/user`), and uses that — and reports which profile it used.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- `avios login --from-browser --profile "Profile 1"` to target a specific browser
|
|
20
|
+
profile, and a clear warning when the imported cookies aren't a logged-in session.
|
|
21
|
+
|
|
22
|
+
## [0.1.4] - 2026-07-24
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- **Endless hCaptcha loop during `avios login`.** The login browser opened a fresh,
|
|
26
|
+
empty context with automation flags on, which hCaptcha/Akamai flag as a bot and
|
|
27
|
+
answer with infinite challenges. Login now opens a **persistent Chrome profile**
|
|
28
|
+
(cookies/reputation persist between attempts) with the automation fingerprint
|
|
29
|
+
disabled (`--disable-blink-features=AutomationControlled`, no `--enable-automation`).
|
|
30
|
+
If you still hit captchas, use `avios login --from-browser` to import the cookie
|
|
31
|
+
from your normal Chrome instead.
|
|
32
|
+
|
|
9
33
|
## [0.1.3] - 2026-07-24
|
|
10
34
|
|
|
11
35
|
### Fixed
|
|
@@ -63,7 +87,9 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
63
87
|
- Reward-flight **availability** search is not yet implemented (needs a British
|
|
64
88
|
Airways capture).
|
|
65
89
|
|
|
66
|
-
[Unreleased]: https://github.com/alexechoi/avios-cli/compare/v0.1.
|
|
90
|
+
[Unreleased]: https://github.com/alexechoi/avios-cli/compare/v0.1.5...HEAD
|
|
91
|
+
[0.1.5]: https://github.com/alexechoi/avios-cli/compare/v0.1.4...v0.1.5
|
|
92
|
+
[0.1.4]: https://github.com/alexechoi/avios-cli/compare/v0.1.3...v0.1.4
|
|
67
93
|
[0.1.3]: https://github.com/alexechoi/avios-cli/compare/v0.1.2...v0.1.3
|
|
68
94
|
[0.1.2]: https://github.com/alexechoi/avios-cli/compare/v0.1.1...v0.1.2
|
|
69
95
|
[0.1.1]: https://github.com/alexechoi/avios-cli/compare/v0.1.0...v0.1.1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: avios-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: A CLI and TUI for avios.com — view your Avios balance and transactions from the terminal.
|
|
5
5
|
Project-URL: Homepage, https://github.com/alexechoi/avios-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/alexechoi/avios-cli
|
|
@@ -97,9 +97,22 @@ into avios.com with:
|
|
|
97
97
|
uvx --from 'avios-cli[login]' avios login --from-browser
|
|
98
98
|
```
|
|
99
99
|
|
|
100
|
+
This scans **all your Chrome profiles** and uses whichever one is actually logged
|
|
101
|
+
into avios.com (it tells you which). If you have several profiles, you can target
|
|
102
|
+
one directly:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
uvx --from 'avios-cli[login]' avios login --from-browser --profile "Profile 1"
|
|
106
|
+
```
|
|
107
|
+
|
|
100
108
|
Plain `uvx avios-cli login` won't work — the isolated environment doesn't include
|
|
101
109
|
the `login` extra.
|
|
102
110
|
|
|
111
|
+
**Stuck in an endless captcha loop?** That's bot detection on the automated
|
|
112
|
+
browser. Use `--from-browser` instead: log into avios.com in your normal Chrome
|
|
113
|
+
(you'll get one normal captcha), then run
|
|
114
|
+
`uvx --from 'avios-cli[login]' avios login --from-browser`.
|
|
115
|
+
|
|
103
116
|
Your session cookie is stored at `~/.config/avios/state.json` (mode `600`). It
|
|
104
117
|
expires after ~a day; just run `avios login` again. Run `avios logout` to remove it.
|
|
105
118
|
|
|
@@ -62,9 +62,22 @@ into avios.com with:
|
|
|
62
62
|
uvx --from 'avios-cli[login]' avios login --from-browser
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
+
This scans **all your Chrome profiles** and uses whichever one is actually logged
|
|
66
|
+
into avios.com (it tells you which). If you have several profiles, you can target
|
|
67
|
+
one directly:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
uvx --from 'avios-cli[login]' avios login --from-browser --profile "Profile 1"
|
|
71
|
+
```
|
|
72
|
+
|
|
65
73
|
Plain `uvx avios-cli login` won't work — the isolated environment doesn't include
|
|
66
74
|
the `login` extra.
|
|
67
75
|
|
|
76
|
+
**Stuck in an endless captcha loop?** That's bot detection on the automated
|
|
77
|
+
browser. Use `--from-browser` instead: log into avios.com in your normal Chrome
|
|
78
|
+
(you'll get one normal captcha), then run
|
|
79
|
+
`uvx --from 'avios-cli[login]' avios login --from-browser`.
|
|
80
|
+
|
|
68
81
|
Your session cookie is stored at `~/.config/avios/state.json` (mode `600`). It
|
|
69
82
|
expires after ~a day; just run `avios login` again. Run `avios logout` to remove it.
|
|
70
83
|
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Browser-assisted login for avios.
|
|
2
|
+
|
|
3
|
+
avios.com has no plain credential API: login is Auth0 "Universal Login" guarded by
|
|
4
|
+
hCaptcha, Akamai Bot Manager and SMS/passkey MFA, so there is no way to POST a
|
|
5
|
+
username/password over HTTP and obtain a session. Instead we let a real browser
|
|
6
|
+
handle the login once and capture the resulting session cookie.
|
|
7
|
+
|
|
8
|
+
Two strategies, both requiring the optional ``login`` extra
|
|
9
|
+
(``pip install "avios-cli[login]"``):
|
|
10
|
+
|
|
11
|
+
- :func:`login_via_browser` — open a Playwright browser, the user logs in
|
|
12
|
+
(password + captcha + MFA), and we grab the cookies. Also needs
|
|
13
|
+
``playwright install chromium``.
|
|
14
|
+
- :func:`import_from_browser` — read the avios.com cookie straight out of a
|
|
15
|
+
running browser via ``browser_cookie3`` (no popup) if already logged in there.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import contextlib
|
|
21
|
+
from collections.abc import Callable, Iterable
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Protocol
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
|
|
28
|
+
from avios import endpoints
|
|
29
|
+
from avios.config import Settings
|
|
30
|
+
from avios.session import Session, cookie_header_from
|
|
31
|
+
|
|
32
|
+
DASHBOARD_PATH = "/manage-avios/dashboard"
|
|
33
|
+
LOGIN_TIMEOUT_MS = 300_000 # 5 minutes to complete password + captcha + MFA
|
|
34
|
+
LOGIN_POLL_MS = 2_000 # how often to check whether the session is authenticated
|
|
35
|
+
SUPPORTED_BROWSERS = ("chrome", "firefox", "edge", "brave", "safari", "chromium")
|
|
36
|
+
|
|
37
|
+
# Chromium-family user-data roots per browser (relative to home), used to discover
|
|
38
|
+
# per-profile cookie databases (Default, "Profile 1", ...).
|
|
39
|
+
_CHROMIUM_ROOTS = {
|
|
40
|
+
"chrome": [
|
|
41
|
+
"Library/Application Support/Google/Chrome",
|
|
42
|
+
".config/google-chrome",
|
|
43
|
+
"AppData/Local/Google/Chrome/User Data",
|
|
44
|
+
],
|
|
45
|
+
"brave": [
|
|
46
|
+
"Library/Application Support/BraveSoftware/Brave-Browser",
|
|
47
|
+
".config/BraveSoftware/Brave-Browser",
|
|
48
|
+
"AppData/Local/BraveSoftware/Brave-Browser/User Data",
|
|
49
|
+
],
|
|
50
|
+
"edge": [
|
|
51
|
+
"Library/Application Support/Microsoft Edge",
|
|
52
|
+
".config/microsoft-edge",
|
|
53
|
+
"AppData/Local/Microsoft/Edge/User Data",
|
|
54
|
+
],
|
|
55
|
+
"chromium": [
|
|
56
|
+
"Library/Application Support/Chromium",
|
|
57
|
+
".config/chromium",
|
|
58
|
+
"AppData/Local/Chromium/User Data",
|
|
59
|
+
],
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class LoginError(RuntimeError):
|
|
64
|
+
"""Login could not be completed."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class ImportResult:
|
|
69
|
+
"""Outcome of importing cookies from a browser."""
|
|
70
|
+
|
|
71
|
+
count: int
|
|
72
|
+
profile: str | None
|
|
73
|
+
authenticated: bool
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class _RawCookie(Protocol):
|
|
77
|
+
name: str
|
|
78
|
+
value: str
|
|
79
|
+
domain: str
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _to_cookie_dicts(raw: Iterable[_RawCookie]) -> list[dict[str, Any]]:
|
|
83
|
+
"""Convert browser_cookie3 / cookiejar entries to our stored cookie format."""
|
|
84
|
+
return [{"name": c.name, "value": c.value, "domain": c.domain} for c in raw]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _only_avios(cookies: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
88
|
+
return [c for c in cookies if "avios.com" in c.get("domain", "")]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def login_via_browser(
|
|
92
|
+
session: Session | None = None,
|
|
93
|
+
*,
|
|
94
|
+
headless: bool = False,
|
|
95
|
+
timeout_ms: int = LOGIN_TIMEOUT_MS,
|
|
96
|
+
playwright_factory: Any = None,
|
|
97
|
+
) -> int:
|
|
98
|
+
"""Open a browser, wait until the user is actually logged in, save the cookie.
|
|
99
|
+
|
|
100
|
+
Returns the number of avios.com cookies captured. ``playwright_factory`` is an
|
|
101
|
+
injection point for tests; production uses the real ``sync_playwright``.
|
|
102
|
+
"""
|
|
103
|
+
session = session or Session()
|
|
104
|
+
factory = playwright_factory or _import_sync_playwright()
|
|
105
|
+
base_url = session.settings.base_url
|
|
106
|
+
profile_dir = str(session.settings.config_dir / "chrome-profile")
|
|
107
|
+
|
|
108
|
+
with factory() as pw:
|
|
109
|
+
ctx = _open_login_context(pw, headless=headless, user_data_dir=profile_dir)
|
|
110
|
+
page = ctx.new_page()
|
|
111
|
+
page.goto(f"{base_url}{DASHBOARD_PATH}")
|
|
112
|
+
authed = _wait_for_auth(ctx, page, base_url, timeout_ms)
|
|
113
|
+
cookies = list(ctx.cookies())
|
|
114
|
+
ctx.close()
|
|
115
|
+
|
|
116
|
+
if not authed:
|
|
117
|
+
raise LoginError("Timed out waiting for login. Run `avios login` again.")
|
|
118
|
+
session.save_cookies(cookies)
|
|
119
|
+
return len(_only_avios(cookies))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _import_sync_playwright() -> Any:
|
|
123
|
+
try:
|
|
124
|
+
from playwright.sync_api import sync_playwright
|
|
125
|
+
except ImportError as exc:
|
|
126
|
+
raise LoginError(
|
|
127
|
+
"Playwright isn't installed. Log in using the 'login' extra:\n"
|
|
128
|
+
" uvx --from 'avios-cli[login]' avios login\n"
|
|
129
|
+
" (first time only: uvx --from playwright playwright install chromium)\n"
|
|
130
|
+
"Or import the cookie from Chrome instead (no browser download):\n"
|
|
131
|
+
" uvx --from 'avios-cli[login]' avios login --from-browser"
|
|
132
|
+
) from exc
|
|
133
|
+
return sync_playwright
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _wait_for_auth(ctx: Any, page: Any, base_url: str, timeout_ms: int) -> bool:
|
|
137
|
+
"""Poll the auth endpoint until the session is authenticated (or time out).
|
|
138
|
+
|
|
139
|
+
The dashboard is a client-side SPA, so its URL is NOT a reliable signal — it
|
|
140
|
+
matches the moment we navigate, before login. ``/auth-gateway/user`` returns
|
|
141
|
+
401 until logged in, then 200.
|
|
142
|
+
"""
|
|
143
|
+
for _ in range(max(1, timeout_ms // LOGIN_POLL_MS)):
|
|
144
|
+
if _is_authenticated(ctx, base_url):
|
|
145
|
+
return True
|
|
146
|
+
page.wait_for_timeout(LOGIN_POLL_MS)
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _is_authenticated(ctx: Any, base_url: str) -> bool:
|
|
151
|
+
try:
|
|
152
|
+
return bool(ctx.request.get(f"{base_url}{endpoints.AUTH_USER}").ok)
|
|
153
|
+
except Exception:
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _open_login_context(pw: Any, *, headless: bool, user_data_dir: str) -> Any:
|
|
158
|
+
"""Open a browser context for login, tuned to avoid the hCaptcha bot loop.
|
|
159
|
+
|
|
160
|
+
hCaptcha/Akamai serve endless challenges to obviously-automated browsers, so:
|
|
161
|
+
- prefer the user's real Chrome (``channel="chrome"``) over bundled Chromium;
|
|
162
|
+
- disable the automation fingerprint (``navigator.webdriver`` /
|
|
163
|
+
``--enable-automation``);
|
|
164
|
+
- use a **persistent profile** so cookies and captcha reputation carry over
|
|
165
|
+
between attempts (a brand-new, empty profile looks high-risk).
|
|
166
|
+
"""
|
|
167
|
+
args = ["--disable-blink-features=AutomationControlled"]
|
|
168
|
+
ignore_default_args = ["--enable-automation"]
|
|
169
|
+
for extra in ({"channel": "chrome"}, {}):
|
|
170
|
+
try:
|
|
171
|
+
return pw.chromium.launch_persistent_context(
|
|
172
|
+
user_data_dir,
|
|
173
|
+
headless=headless,
|
|
174
|
+
args=args,
|
|
175
|
+
ignore_default_args=ignore_default_args,
|
|
176
|
+
**extra,
|
|
177
|
+
)
|
|
178
|
+
except Exception:
|
|
179
|
+
continue
|
|
180
|
+
raise LoginError(
|
|
181
|
+
"Couldn't launch a browser. Install Chromium once with:\n"
|
|
182
|
+
" uvx --from playwright playwright install chromium"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _bc3() -> Any:
|
|
187
|
+
try:
|
|
188
|
+
import browser_cookie3 as bc3
|
|
189
|
+
except ImportError as exc:
|
|
190
|
+
raise LoginError(
|
|
191
|
+
"browser-cookie3 isn't installed. Run with the 'login' extra:\n"
|
|
192
|
+
" uvx --from 'avios-cli[login]' avios login --from-browser"
|
|
193
|
+
) from exc
|
|
194
|
+
return bc3
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _browser_fn(bc3: Any, browser: str) -> Any:
|
|
198
|
+
fn = getattr(bc3, browser, None)
|
|
199
|
+
if fn is None:
|
|
200
|
+
raise LoginError(
|
|
201
|
+
f"Unknown browser '{browser}'. Choose from: {', '.join(SUPPORTED_BROWSERS)}"
|
|
202
|
+
)
|
|
203
|
+
return fn
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _profile_cookie_files(browser: str, profile: str | None) -> list[tuple[str, Path]]:
|
|
207
|
+
"""Discover (profile_name, Cookies-db) pairs for a chromium-family browser."""
|
|
208
|
+
found: list[tuple[str, Path]] = []
|
|
209
|
+
for rel in _CHROMIUM_ROOTS.get(browser, []):
|
|
210
|
+
root = Path.home() / rel
|
|
211
|
+
if not root.exists():
|
|
212
|
+
continue
|
|
213
|
+
for prof in sorted(p for p in root.iterdir() if p.is_dir()):
|
|
214
|
+
if profile and prof.name != profile:
|
|
215
|
+
continue
|
|
216
|
+
for db in (prof / "Cookies", prof / "Network" / "Cookies"):
|
|
217
|
+
if db.exists():
|
|
218
|
+
found.append((prof.name, db))
|
|
219
|
+
break
|
|
220
|
+
return found
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _candidate_cookie_sets(
|
|
224
|
+
browser: str, profile: str | None
|
|
225
|
+
) -> list[tuple[str, list[dict[str, Any]]]]:
|
|
226
|
+
"""Return [(profile_name, avios_cookies)] to try, most specific first."""
|
|
227
|
+
bc3 = _bc3()
|
|
228
|
+
fn = _browser_fn(bc3, browser)
|
|
229
|
+
candidates: list[tuple[str, list[dict[str, Any]]]] = []
|
|
230
|
+
for name, db in _profile_cookie_files(browser, profile):
|
|
231
|
+
try:
|
|
232
|
+
raw = fn(domain_name="avios.com", cookie_file=str(db))
|
|
233
|
+
except Exception:
|
|
234
|
+
continue
|
|
235
|
+
candidates.append((name, _only_avios(_to_cookie_dicts(raw))))
|
|
236
|
+
if not profile:
|
|
237
|
+
# Fallback to browser_cookie3's own default lookup.
|
|
238
|
+
with contextlib.suppress(Exception):
|
|
239
|
+
raw = fn(domain_name="avios.com")
|
|
240
|
+
candidates.append(("default", _only_avios(_to_cookie_dicts(raw))))
|
|
241
|
+
return candidates
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _cookies_authenticate(settings: Settings, cookies: list[dict[str, Any]]) -> bool:
|
|
245
|
+
"""True if these cookies yield a 200 from the auth endpoint."""
|
|
246
|
+
header = cookie_header_from(cookies)
|
|
247
|
+
if not header:
|
|
248
|
+
return False
|
|
249
|
+
try:
|
|
250
|
+
with httpx.Client(
|
|
251
|
+
base_url=settings.base_url,
|
|
252
|
+
timeout=15.0,
|
|
253
|
+
follow_redirects=False,
|
|
254
|
+
headers={
|
|
255
|
+
"accept": "application/json",
|
|
256
|
+
"user-agent": settings.user_agent,
|
|
257
|
+
"cookie": header,
|
|
258
|
+
},
|
|
259
|
+
) as client:
|
|
260
|
+
return client.get(endpoints.AUTH_USER).status_code == 200
|
|
261
|
+
except httpx.HTTPError:
|
|
262
|
+
return False
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def import_from_browser(
|
|
266
|
+
session: Session | None = None,
|
|
267
|
+
browser: str = "chrome",
|
|
268
|
+
*,
|
|
269
|
+
profile: str | None = None,
|
|
270
|
+
loader: Callable[[], Iterable[_RawCookie]] | None = None,
|
|
271
|
+
authenticator: Callable[[list[dict[str, Any]]], bool] | None = None,
|
|
272
|
+
) -> ImportResult:
|
|
273
|
+
"""Import the avios.com session cookie from a browser profile.
|
|
274
|
+
|
|
275
|
+
Scans the browser's profiles, prefers the one whose cookies actually
|
|
276
|
+
authenticate, and saves them. ``loader``/``authenticator`` are test seams.
|
|
277
|
+
"""
|
|
278
|
+
session = session or Session()
|
|
279
|
+
authenticate = authenticator or (lambda c: _cookies_authenticate(session.settings, c))
|
|
280
|
+
|
|
281
|
+
if loader is not None:
|
|
282
|
+
candidates: list[tuple[str, list[dict[str, Any]]]] = [
|
|
283
|
+
("browser", _only_avios(_to_cookie_dicts(loader())))
|
|
284
|
+
]
|
|
285
|
+
else:
|
|
286
|
+
candidates = _candidate_cookie_sets(browser, profile)
|
|
287
|
+
|
|
288
|
+
working: tuple[str, list[dict[str, Any]]] | None = None
|
|
289
|
+
fallback: tuple[str, list[dict[str, Any]]] | None = None
|
|
290
|
+
for name, cookies in candidates:
|
|
291
|
+
if not cookies:
|
|
292
|
+
continue
|
|
293
|
+
if fallback is None:
|
|
294
|
+
fallback = (name, cookies)
|
|
295
|
+
if authenticate(cookies):
|
|
296
|
+
working = (name, cookies)
|
|
297
|
+
break
|
|
298
|
+
|
|
299
|
+
chosen = working or fallback
|
|
300
|
+
if chosen is None:
|
|
301
|
+
where = f"profile '{profile}'" if profile else browser
|
|
302
|
+
raise LoginError(
|
|
303
|
+
f"No avios.com cookies found in {where}. Log into avios.com in that "
|
|
304
|
+
"browser first (and check the profile with --profile)."
|
|
305
|
+
)
|
|
306
|
+
name, cookies = chosen
|
|
307
|
+
session.save_cookies(cookies)
|
|
308
|
+
return ImportResult(count=len(cookies), profile=name, authenticated=working is not None)
|
|
@@ -98,6 +98,9 @@ def login(
|
|
|
98
98
|
False, "--from-browser", help="Import the cookie from a running browser (no popup)."
|
|
99
99
|
),
|
|
100
100
|
browser: str = typer.Option("chrome", help="Browser to import from (with --from-browser)."),
|
|
101
|
+
profile: str | None = typer.Option(
|
|
102
|
+
None, help="Browser profile name for --from-browser (e.g. 'Default', 'Profile 1')."
|
|
103
|
+
),
|
|
101
104
|
headless: bool = typer.Option(
|
|
102
105
|
False, help="Run the login browser headless (only works without captcha/MFA)."
|
|
103
106
|
),
|
|
@@ -106,7 +109,19 @@ def login(
|
|
|
106
109
|
session = Session()
|
|
107
110
|
try:
|
|
108
111
|
if from_browser:
|
|
109
|
-
|
|
112
|
+
result = import_from_browser(session, browser, profile=profile)
|
|
113
|
+
where = f" from {browser} profile '{result.profile}'" if result.profile else ""
|
|
114
|
+
if not result.authenticated:
|
|
115
|
+
console.print(
|
|
116
|
+
f"[yellow]Imported {result.count} cookie(s){where}, but they're not a "
|
|
117
|
+
"logged-in avios session.[/]"
|
|
118
|
+
)
|
|
119
|
+
console.print(
|
|
120
|
+
"Log into avios.com in that profile, pick another with "
|
|
121
|
+
"[bold]--profile[/], or use [bold]avios login[/] (browser)."
|
|
122
|
+
)
|
|
123
|
+
raise typer.Exit(2)
|
|
124
|
+
console.print(f"[green]Logged in{where}.[/] Saved {result.count} cookie(s).")
|
|
110
125
|
else:
|
|
111
126
|
console.print(
|
|
112
127
|
"Opening a browser — log in normally (password, captcha, SMS code). "
|
|
@@ -114,11 +129,11 @@ def login(
|
|
|
114
129
|
"captures your session automatically."
|
|
115
130
|
)
|
|
116
131
|
count = login_via_browser(session, headless=headless)
|
|
132
|
+
console.print(f"[green]Logged in.[/] Saved {count} avios cookie(s).")
|
|
117
133
|
except LoginError as exc:
|
|
118
134
|
console.print(f"[red]{escape(str(exc))}[/]")
|
|
119
135
|
raise typer.Exit(1) from exc
|
|
120
136
|
|
|
121
|
-
console.print(f"[green]Logged in.[/] Saved {count} avios cookie(s).")
|
|
122
137
|
try:
|
|
123
138
|
balance = AviosClient(session).get_balance()
|
|
124
139
|
console.print(
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""Tests for the login helpers.
|
|
2
2
|
|
|
3
|
-
The
|
|
4
|
-
human), so we
|
|
5
|
-
|
|
3
|
+
The interactive browser flow can't be automated (Auth0 + hCaptcha + MFA need a
|
|
4
|
+
human), so we inject a fake Playwright to exercise the poll-until-authenticated
|
|
5
|
+
logic, and fake ``browser_cookie3`` via ``sys.modules`` so tests don't depend on
|
|
6
6
|
the optional ``login`` extra or a real browser profile.
|
|
7
7
|
"""
|
|
8
8
|
|
|
@@ -14,9 +14,11 @@ import types
|
|
|
14
14
|
import pytest
|
|
15
15
|
|
|
16
16
|
from avios.auth import (
|
|
17
|
+
ImportResult,
|
|
17
18
|
LoginError,
|
|
18
|
-
|
|
19
|
+
_browser_fn,
|
|
19
20
|
_only_avios,
|
|
21
|
+
_open_login_context,
|
|
20
22
|
_to_cookie_dicts,
|
|
21
23
|
import_from_browser,
|
|
22
24
|
login_via_browser,
|
|
@@ -51,52 +53,49 @@ def test_only_avios_filters_foreign_domains() -> None:
|
|
|
51
53
|
assert _only_avios(cookies) == [cookies[0]]
|
|
52
54
|
|
|
53
55
|
|
|
54
|
-
def
|
|
56
|
+
def test_import_from_browser_uses_authenticated_cookies(settings: Settings) -> None:
|
|
55
57
|
session = _session(settings)
|
|
56
|
-
|
|
58
|
+
result = import_from_browser(
|
|
57
59
|
session,
|
|
58
60
|
loader=lambda: [
|
|
59
61
|
FakeCookie("appSession", "abc", "www.avios.com"),
|
|
60
62
|
FakeCookie("_ga", "junk", ".google.com"),
|
|
61
63
|
],
|
|
64
|
+
authenticator=lambda cookies: True,
|
|
62
65
|
)
|
|
63
|
-
assert
|
|
64
|
-
assert
|
|
66
|
+
assert isinstance(result, ImportResult)
|
|
67
|
+
assert result.count == 1 # only the avios cookie
|
|
68
|
+
assert result.authenticated is True
|
|
65
69
|
assert session.load_cookies() == [
|
|
66
70
|
{"name": "appSession", "value": "abc", "domain": "www.avios.com"}
|
|
67
71
|
]
|
|
68
72
|
|
|
69
73
|
|
|
70
|
-
def
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
monkeypatch.setitem(sys.modules, "browser_cookie3", module)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def test_default_loader_passes_domain(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
83
|
-
seen: dict[str, str] = {}
|
|
74
|
+
def test_import_from_browser_reports_unauthenticated(settings: Settings) -> None:
|
|
75
|
+
session = _session(settings)
|
|
76
|
+
result = import_from_browser(
|
|
77
|
+
session,
|
|
78
|
+
loader=lambda: [FakeCookie("appSession", "stale", "www.avios.com")],
|
|
79
|
+
authenticator=lambda cookies: False, # cookies exist but don't authenticate
|
|
80
|
+
)
|
|
81
|
+
assert result.count == 1
|
|
82
|
+
assert result.authenticated is False # saved as best-effort, flagged as not working
|
|
84
83
|
|
|
85
|
-
def chrome(**kwargs: str) -> list[FakeCookie]:
|
|
86
|
-
seen.update(kwargs)
|
|
87
|
-
return [FakeCookie("appSession", "x", "www.avios.com")]
|
|
88
84
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
85
|
+
def test_import_from_browser_no_cookies_raises(settings: Settings) -> None:
|
|
86
|
+
with pytest.raises(LoginError, match="No avios.com cookies"):
|
|
87
|
+
import_from_browser(
|
|
88
|
+
_session(settings),
|
|
89
|
+
loader=lambda: [FakeCookie("_ga", "j", ".x.com")],
|
|
90
|
+
authenticator=lambda cookies: True,
|
|
91
|
+
)
|
|
94
92
|
|
|
95
93
|
|
|
96
|
-
def
|
|
97
|
-
|
|
94
|
+
def test_browser_fn_unknown_browser() -> None:
|
|
95
|
+
bc3 = types.ModuleType("browser_cookie3")
|
|
96
|
+
bc3.chrome = lambda **k: [] # type: ignore[attr-defined]
|
|
98
97
|
with pytest.raises(LoginError, match="Unknown browser"):
|
|
99
|
-
|
|
98
|
+
_browser_fn(bc3, "nope")
|
|
100
99
|
|
|
101
100
|
|
|
102
101
|
def test_login_via_browser_without_playwright(
|
|
@@ -108,7 +107,7 @@ def test_login_via_browser_without_playwright(
|
|
|
108
107
|
login_via_browser(_session(settings))
|
|
109
108
|
|
|
110
109
|
|
|
111
|
-
# --- fake Playwright
|
|
110
|
+
# --- fake Playwright (persistent context) to exercise the login flow ------------
|
|
112
111
|
class _FakeResponse:
|
|
113
112
|
def __init__(self, ok: bool) -> None:
|
|
114
113
|
self.ok = ok
|
|
@@ -138,6 +137,7 @@ class _FakeContext:
|
|
|
138
137
|
def __init__(self, ok_after: int, cookies: list[dict[str, str]]) -> None:
|
|
139
138
|
self.request = _FakeRequest(ok_after)
|
|
140
139
|
self._cookies = cookies
|
|
140
|
+
self.closed = False
|
|
141
141
|
|
|
142
142
|
def new_page(self) -> _FakePage:
|
|
143
143
|
return _FakePage()
|
|
@@ -145,39 +145,34 @@ class _FakeContext:
|
|
|
145
145
|
def cookies(self) -> list[dict[str, str]]:
|
|
146
146
|
return self._cookies
|
|
147
147
|
|
|
148
|
-
|
|
149
|
-
class _FakeBrowser:
|
|
150
|
-
def __init__(self, ctx: _FakeContext) -> None:
|
|
151
|
-
self._ctx = ctx
|
|
152
|
-
self.closed = False
|
|
153
|
-
|
|
154
|
-
def new_context(self, **kwargs: object) -> _FakeContext:
|
|
155
|
-
return self._ctx
|
|
156
|
-
|
|
157
148
|
def close(self) -> None:
|
|
158
149
|
self.closed = True
|
|
159
150
|
|
|
160
151
|
|
|
161
|
-
class _FakePlaywright:
|
|
162
|
-
def __init__(self, browser: _FakeBrowser) -> None:
|
|
163
|
-
self.chromium = _FakeChromium(browser)
|
|
164
|
-
|
|
165
|
-
|
|
166
152
|
class _FakeChromium:
|
|
167
|
-
def __init__(self,
|
|
168
|
-
self.
|
|
169
|
-
self.
|
|
153
|
+
def __init__(self, ctx: _FakeContext, fail_channel: str | None = None) -> None:
|
|
154
|
+
self._ctx = ctx
|
|
155
|
+
self._fail_channel = fail_channel
|
|
156
|
+
self.channels: list[str | None] = []
|
|
157
|
+
|
|
158
|
+
def launch_persistent_context(self, user_data_dir: str, **kwargs: object) -> _FakeContext:
|
|
159
|
+
channel = kwargs.get("channel")
|
|
160
|
+
self.channels.append(channel) # type: ignore[arg-type]
|
|
161
|
+
if channel == self._fail_channel:
|
|
162
|
+
raise RuntimeError("channel unavailable")
|
|
163
|
+
return self._ctx
|
|
164
|
+
|
|
170
165
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
166
|
+
class _FakePlaywright:
|
|
167
|
+
def __init__(self, ctx: _FakeContext, fail_channel: str | None = None) -> None:
|
|
168
|
+
self.chromium = _FakeChromium(ctx, fail_channel)
|
|
174
169
|
|
|
175
170
|
|
|
176
171
|
class _FakeFactory:
|
|
177
|
-
"""Callable
|
|
172
|
+
"""Callable returning a context manager that yields the fake Playwright."""
|
|
178
173
|
|
|
179
|
-
def __init__(self,
|
|
180
|
-
self._pw = _FakePlaywright(
|
|
174
|
+
def __init__(self, ctx: _FakeContext, fail_channel: str | None = None) -> None:
|
|
175
|
+
self._pw = _FakePlaywright(ctx, fail_channel)
|
|
181
176
|
|
|
182
177
|
def __call__(self) -> _FakeFactory:
|
|
183
178
|
return self
|
|
@@ -195,14 +190,13 @@ def test_login_captures_only_after_authenticated(settings: Settings) -> None:
|
|
|
195
190
|
{"name": "_ga", "value": "junk", "domain": ".google.com"},
|
|
196
191
|
]
|
|
197
192
|
ctx = _FakeContext(ok_after=3, cookies=cookies)
|
|
198
|
-
browser = _FakeBrowser(ctx)
|
|
199
193
|
session = _session(settings)
|
|
200
194
|
|
|
201
|
-
count = login_via_browser(session, timeout_ms=100_000, playwright_factory=_FakeFactory(
|
|
195
|
+
count = login_via_browser(session, timeout_ms=100_000, playwright_factory=_FakeFactory(ctx))
|
|
202
196
|
|
|
203
197
|
assert count == 1 # only the avios cookie
|
|
204
198
|
assert session.load_cookies() == cookies
|
|
205
|
-
assert
|
|
199
|
+
assert ctx.closed
|
|
206
200
|
assert ctx.request.calls >= 3 # polled until authenticated, not immediately
|
|
207
201
|
|
|
208
202
|
|
|
@@ -210,34 +204,18 @@ def test_login_times_out_without_saving(settings: Settings) -> None:
|
|
|
210
204
|
ctx = _FakeContext(
|
|
211
205
|
ok_after=999, cookies=[{"name": "appSession", "value": "x", "domain": "www.avios.com"}]
|
|
212
206
|
)
|
|
213
|
-
browser = _FakeBrowser(ctx)
|
|
214
207
|
session = _session(settings)
|
|
215
208
|
|
|
216
209
|
with pytest.raises(LoginError, match="Timed out"):
|
|
217
|
-
login_via_browser(session, timeout_ms=6_000, playwright_factory=_FakeFactory(
|
|
210
|
+
login_via_browser(session, timeout_ms=6_000, playwright_factory=_FakeFactory(ctx))
|
|
218
211
|
|
|
219
212
|
assert session.is_authenticated() is False # nothing saved on timeout
|
|
220
|
-
assert
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
def test_launch_browser_prefers_chrome_then_falls_back() -> None:
|
|
224
|
-
from avios.auth import _launch_browser
|
|
225
|
-
|
|
226
|
-
class Chromium:
|
|
227
|
-
def __init__(self) -> None:
|
|
228
|
-
self.channels: list[str | None] = []
|
|
229
|
-
|
|
230
|
-
def launch(self, **kwargs: object) -> str:
|
|
231
|
-
channel = kwargs.get("channel")
|
|
232
|
-
self.channels.append(channel) # type: ignore[arg-type]
|
|
233
|
-
if channel == "chrome":
|
|
234
|
-
raise RuntimeError("no system chrome")
|
|
235
|
-
return "browser"
|
|
213
|
+
assert ctx.closed
|
|
236
214
|
|
|
237
|
-
class PW:
|
|
238
|
-
def __init__(self) -> None:
|
|
239
|
-
self.chromium = Chromium()
|
|
240
215
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
216
|
+
def test_open_login_context_prefers_chrome_then_falls_back(tmp_path: object) -> None:
|
|
217
|
+
ctx = _FakeContext(ok_after=1, cookies=[])
|
|
218
|
+
pw = _FakePlaywright(ctx, fail_channel="chrome")
|
|
219
|
+
result = _open_login_context(pw, headless=True, user_data_dir=str(tmp_path))
|
|
220
|
+
assert result is ctx
|
|
221
|
+
assert pw.chromium.channels == ["chrome", None] # tried Chrome, fell back to Chromium
|
|
@@ -13,7 +13,7 @@ import pytest
|
|
|
13
13
|
from typer.testing import CliRunner
|
|
14
14
|
|
|
15
15
|
from avios import __version__
|
|
16
|
-
from avios.auth import LoginError
|
|
16
|
+
from avios.auth import ImportResult, LoginError
|
|
17
17
|
from avios.cli import app
|
|
18
18
|
from avios.models import Account, Balance, Overview, Profile, Transaction
|
|
19
19
|
from avios.session import NotAuthenticated, SessionExpired
|
|
@@ -134,16 +134,31 @@ def test_session_expired(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
134
134
|
|
|
135
135
|
# -- login / logout ----------------------------------------------------------
|
|
136
136
|
def test_login_from_browser(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
137
|
-
monkeypatch.setattr(
|
|
137
|
+
monkeypatch.setattr(
|
|
138
|
+
"avios.cli.import_from_browser",
|
|
139
|
+
lambda session, browser, profile=None: ImportResult(2, "Default", True),
|
|
140
|
+
)
|
|
138
141
|
monkeypatch.setattr("avios.cli.AviosClient", FakeClient)
|
|
139
142
|
result = runner.invoke(app, ["login", "--from-browser"])
|
|
140
143
|
assert result.exit_code == 0
|
|
141
144
|
assert "Logged in" in result.stdout
|
|
145
|
+
assert "Default" in result.stdout
|
|
142
146
|
assert "Balance" in result.stdout
|
|
143
147
|
|
|
144
148
|
|
|
149
|
+
def test_login_from_browser_unauthenticated(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
150
|
+
monkeypatch.setattr(
|
|
151
|
+
"avios.cli.import_from_browser",
|
|
152
|
+
lambda session, browser, profile=None: ImportResult(20, "Default", False),
|
|
153
|
+
)
|
|
154
|
+
result = runner.invoke(app, ["login", "--from-browser"])
|
|
155
|
+
assert result.exit_code == 2
|
|
156
|
+
normalized = " ".join(result.stdout.split()) # Rich wraps lines
|
|
157
|
+
assert "not a logged-in avios session" in normalized
|
|
158
|
+
|
|
159
|
+
|
|
145
160
|
def test_login_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
146
|
-
def boom(session: Any, browser: str) ->
|
|
161
|
+
def boom(session: Any, browser: str, profile: str | None = None) -> ImportResult:
|
|
147
162
|
raise LoginError("nope")
|
|
148
163
|
|
|
149
164
|
monkeypatch.setattr("avios.cli.import_from_browser", boom)
|
|
@@ -1,165 +0,0 @@
|
|
|
1
|
-
"""Browser-assisted login for avios.
|
|
2
|
-
|
|
3
|
-
avios.com has no plain credential API: login is Auth0 "Universal Login" guarded by
|
|
4
|
-
hCaptcha, Akamai Bot Manager and SMS/passkey MFA, so there is no way to POST a
|
|
5
|
-
username/password over HTTP and obtain a session. Instead we let a real browser
|
|
6
|
-
handle the login once and capture the resulting session cookie.
|
|
7
|
-
|
|
8
|
-
Two strategies, both requiring the optional ``login`` extra
|
|
9
|
-
(``pip install "avios-cli[login]"``):
|
|
10
|
-
|
|
11
|
-
- :func:`login_via_browser` — open a Playwright browser, the user logs in
|
|
12
|
-
(password + captcha + MFA), and we grab the cookies. Also needs
|
|
13
|
-
``playwright install chromium``.
|
|
14
|
-
- :func:`import_from_browser` — read the avios.com cookie straight out of a
|
|
15
|
-
running browser via ``browser_cookie3`` (no popup) if already logged in there.
|
|
16
|
-
"""
|
|
17
|
-
|
|
18
|
-
from __future__ import annotations
|
|
19
|
-
|
|
20
|
-
from collections.abc import Callable, Iterable
|
|
21
|
-
from typing import Any, Protocol
|
|
22
|
-
|
|
23
|
-
from avios import endpoints
|
|
24
|
-
from avios.session import Session
|
|
25
|
-
|
|
26
|
-
DASHBOARD_PATH = "/manage-avios/dashboard"
|
|
27
|
-
LOGIN_TIMEOUT_MS = 300_000 # 5 minutes to complete password + captcha + MFA
|
|
28
|
-
LOGIN_POLL_MS = 2_000 # how often to check whether the session is authenticated
|
|
29
|
-
SUPPORTED_BROWSERS = ("chrome", "firefox", "edge", "brave", "safari", "chromium")
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
class LoginError(RuntimeError):
|
|
33
|
-
"""Login could not be completed."""
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
class _RawCookie(Protocol):
|
|
37
|
-
name: str
|
|
38
|
-
value: str
|
|
39
|
-
domain: str
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def _to_cookie_dicts(raw: Iterable[_RawCookie]) -> list[dict[str, Any]]:
|
|
43
|
-
"""Convert browser_cookie3 / cookiejar entries to our stored cookie format."""
|
|
44
|
-
return [{"name": c.name, "value": c.value, "domain": c.domain} for c in raw]
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def _only_avios(cookies: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
48
|
-
return [c for c in cookies if "avios.com" in c.get("domain", "")]
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def login_via_browser(
|
|
52
|
-
session: Session | None = None,
|
|
53
|
-
*,
|
|
54
|
-
headless: bool = False,
|
|
55
|
-
timeout_ms: int = LOGIN_TIMEOUT_MS,
|
|
56
|
-
playwright_factory: Any = None,
|
|
57
|
-
) -> int:
|
|
58
|
-
"""Open a browser, wait until the user is actually logged in, save the cookie.
|
|
59
|
-
|
|
60
|
-
Returns the number of avios.com cookies captured. ``playwright_factory`` is an
|
|
61
|
-
injection point for tests; production uses the real ``sync_playwright``.
|
|
62
|
-
"""
|
|
63
|
-
session = session or Session()
|
|
64
|
-
factory = playwright_factory or _import_sync_playwright()
|
|
65
|
-
base_url = session.settings.base_url
|
|
66
|
-
|
|
67
|
-
with factory() as pw:
|
|
68
|
-
browser = _launch_browser(pw, headless=headless)
|
|
69
|
-
ctx = browser.new_context(user_agent=session.settings.user_agent)
|
|
70
|
-
page = ctx.new_page()
|
|
71
|
-
page.goto(f"{base_url}{DASHBOARD_PATH}")
|
|
72
|
-
authed = _wait_for_auth(ctx, page, base_url, timeout_ms)
|
|
73
|
-
cookies = list(ctx.cookies())
|
|
74
|
-
browser.close()
|
|
75
|
-
|
|
76
|
-
if not authed:
|
|
77
|
-
raise LoginError("Timed out waiting for login. Run `avios login` again.")
|
|
78
|
-
session.save_cookies(cookies)
|
|
79
|
-
return len(_only_avios(cookies))
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def _import_sync_playwright() -> Any:
|
|
83
|
-
try:
|
|
84
|
-
from playwright.sync_api import sync_playwright
|
|
85
|
-
except ImportError as exc:
|
|
86
|
-
raise LoginError(
|
|
87
|
-
"Playwright isn't installed. Log in using the 'login' extra:\n"
|
|
88
|
-
" uvx --from 'avios-cli[login]' avios login\n"
|
|
89
|
-
" (first time only: uvx --from playwright playwright install chromium)\n"
|
|
90
|
-
"Or import the cookie from Chrome instead (no browser download):\n"
|
|
91
|
-
" uvx --from 'avios-cli[login]' avios login --from-browser"
|
|
92
|
-
) from exc
|
|
93
|
-
return sync_playwright
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def _wait_for_auth(ctx: Any, page: Any, base_url: str, timeout_ms: int) -> bool:
|
|
97
|
-
"""Poll the auth endpoint until the session is authenticated (or time out).
|
|
98
|
-
|
|
99
|
-
The dashboard is a client-side SPA, so its URL is NOT a reliable signal — it
|
|
100
|
-
matches the moment we navigate, before login. ``/auth-gateway/user`` returns
|
|
101
|
-
401 until logged in, then 200.
|
|
102
|
-
"""
|
|
103
|
-
for _ in range(max(1, timeout_ms // LOGIN_POLL_MS)):
|
|
104
|
-
if _is_authenticated(ctx, base_url):
|
|
105
|
-
return True
|
|
106
|
-
page.wait_for_timeout(LOGIN_POLL_MS)
|
|
107
|
-
return False
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
def _is_authenticated(ctx: Any, base_url: str) -> bool:
|
|
111
|
-
try:
|
|
112
|
-
return bool(ctx.request.get(f"{base_url}{endpoints.AUTH_USER}").ok)
|
|
113
|
-
except Exception:
|
|
114
|
-
return False
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def _launch_browser(pw: Any, *, headless: bool) -> Any:
|
|
118
|
-
"""Launch the user's system Chrome if available, else bundled Chromium."""
|
|
119
|
-
for kwargs in ({"channel": "chrome"}, {}):
|
|
120
|
-
try:
|
|
121
|
-
return pw.chromium.launch(headless=headless, **kwargs)
|
|
122
|
-
except Exception:
|
|
123
|
-
continue
|
|
124
|
-
raise LoginError(
|
|
125
|
-
"Couldn't launch a browser. Install Chromium once with:\n"
|
|
126
|
-
" uvx --from playwright playwright install chromium"
|
|
127
|
-
)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
def _default_loader(browser: str) -> Callable[[], Iterable[_RawCookie]]:
|
|
131
|
-
"""Return a callable that reads avios.com cookies from ``browser``."""
|
|
132
|
-
try:
|
|
133
|
-
import browser_cookie3 as bc3
|
|
134
|
-
except ImportError as exc:
|
|
135
|
-
raise LoginError(
|
|
136
|
-
"browser-cookie3 isn't installed. Run with the 'login' extra:\n"
|
|
137
|
-
" uvx --from 'avios-cli[login]' avios login --from-browser"
|
|
138
|
-
) from exc
|
|
139
|
-
fn = getattr(bc3, browser, None)
|
|
140
|
-
if fn is None:
|
|
141
|
-
raise LoginError(
|
|
142
|
-
f"Unknown browser '{browser}'. Choose from: {', '.join(SUPPORTED_BROWSERS)}"
|
|
143
|
-
)
|
|
144
|
-
return lambda: fn(domain_name="avios.com")
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
def import_from_browser(
|
|
148
|
-
session: Session | None = None,
|
|
149
|
-
browser: str = "chrome",
|
|
150
|
-
*,
|
|
151
|
-
loader: Callable[[], Iterable[_RawCookie]] | None = None,
|
|
152
|
-
) -> int:
|
|
153
|
-
"""Import the avios.com session cookie from a running browser.
|
|
154
|
-
|
|
155
|
-
Returns the number of avios.com cookies imported.
|
|
156
|
-
"""
|
|
157
|
-
session = session or Session()
|
|
158
|
-
load = loader or _default_loader(browser)
|
|
159
|
-
cookies = _only_avios(_to_cookie_dicts(load()))
|
|
160
|
-
if not cookies:
|
|
161
|
-
raise LoginError(
|
|
162
|
-
f"No avios.com cookies found in {browser}. Log into avios.com there first."
|
|
163
|
-
)
|
|
164
|
-
session.save_cookies(cookies)
|
|
165
|
-
return len(cookies)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|