avios-cli 0.1.3__tar.gz → 0.1.4__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.4}/CHANGELOG.md +13 -1
- {avios_cli-0.1.3 → avios_cli-0.1.4}/PKG-INFO +6 -1
- {avios_cli-0.1.3 → avios_cli-0.1.4}/README.md +5 -0
- {avios_cli-0.1.3 → avios_cli-0.1.4}/src/avios/__init__.py +1 -1
- {avios_cli-0.1.3 → avios_cli-0.1.4}/src/avios/auth.py +23 -7
- {avios_cli-0.1.3 → avios_cli-0.1.4}/tests/test_auth.py +34 -54
- {avios_cli-0.1.3 → avios_cli-0.1.4}/.editorconfig +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.4}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.4}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.4}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.4}/.github/dependabot.yml +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.4}/.github/workflows/ci.yml +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.4}/.github/workflows/publish.yml +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.4}/.gitignore +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.4}/.pre-commit-config.yaml +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.4}/.python-version +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.4}/CONTRIBUTING.md +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.4}/LICENSE +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.4}/SECURITY.md +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.4}/docs/dashboard.svg +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.4}/pyproject.toml +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.4}/scripts/screenshot.py +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.4}/src/avios/cli.py +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.4}/src/avios/client.py +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.4}/src/avios/config.py +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.4}/src/avios/endpoints.py +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.4}/src/avios/models.py +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.4}/src/avios/session.py +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.4}/src/avios/tui/__init__.py +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.4}/src/avios/tui/app.py +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.4}/src/avios/tui/art.py +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.4}/src/avios/tui/styles.tcss +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.4}/src/avios/tui/widgets.py +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.4}/tests/conftest.py +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.4}/tests/fixtures/accounts.json +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.4}/tests/fixtures/balance.json +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.4}/tests/fixtures/transactions.json +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.4}/tests/test_cli.py +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.4}/tests/test_client.py +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.4}/tests/test_models.py +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.4}/tests/test_session.py +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.4}/tests/test_tui.py +0 -0
- {avios_cli-0.1.3 → avios_cli-0.1.4}/uv.lock +0 -0
|
@@ -6,6 +6,17 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.1.4] - 2026-07-24
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- **Endless hCaptcha loop during `avios login`.** The login browser opened a fresh,
|
|
13
|
+
empty context with automation flags on, which hCaptcha/Akamai flag as a bot and
|
|
14
|
+
answer with infinite challenges. Login now opens a **persistent Chrome profile**
|
|
15
|
+
(cookies/reputation persist between attempts) with the automation fingerprint
|
|
16
|
+
disabled (`--disable-blink-features=AutomationControlled`, no `--enable-automation`).
|
|
17
|
+
If you still hit captchas, use `avios login --from-browser` to import the cookie
|
|
18
|
+
from your normal Chrome instead.
|
|
19
|
+
|
|
9
20
|
## [0.1.3] - 2026-07-24
|
|
10
21
|
|
|
11
22
|
### Fixed
|
|
@@ -63,7 +74,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
63
74
|
- Reward-flight **availability** search is not yet implemented (needs a British
|
|
64
75
|
Airways capture).
|
|
65
76
|
|
|
66
|
-
[Unreleased]: https://github.com/alexechoi/avios-cli/compare/v0.1.
|
|
77
|
+
[Unreleased]: https://github.com/alexechoi/avios-cli/compare/v0.1.4...HEAD
|
|
78
|
+
[0.1.4]: https://github.com/alexechoi/avios-cli/compare/v0.1.3...v0.1.4
|
|
67
79
|
[0.1.3]: https://github.com/alexechoi/avios-cli/compare/v0.1.2...v0.1.3
|
|
68
80
|
[0.1.2]: https://github.com/alexechoi/avios-cli/compare/v0.1.1...v0.1.2
|
|
69
81
|
[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.4
|
|
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
|
|
@@ -100,6 +100,11 @@ uvx --from 'avios-cli[login]' avios login --from-browser
|
|
|
100
100
|
Plain `uvx avios-cli login` won't work — the isolated environment doesn't include
|
|
101
101
|
the `login` extra.
|
|
102
102
|
|
|
103
|
+
**Stuck in an endless captcha loop?** That's bot detection on the automated
|
|
104
|
+
browser. Use `--from-browser` instead: log into avios.com in your normal Chrome
|
|
105
|
+
(you'll get one normal captcha), then run
|
|
106
|
+
`uvx --from 'avios-cli[login]' avios login --from-browser`.
|
|
107
|
+
|
|
103
108
|
Your session cookie is stored at `~/.config/avios/state.json` (mode `600`). It
|
|
104
109
|
expires after ~a day; just run `avios login` again. Run `avios logout` to remove it.
|
|
105
110
|
|
|
@@ -65,6 +65,11 @@ uvx --from 'avios-cli[login]' avios login --from-browser
|
|
|
65
65
|
Plain `uvx avios-cli login` won't work — the isolated environment doesn't include
|
|
66
66
|
the `login` extra.
|
|
67
67
|
|
|
68
|
+
**Stuck in an endless captcha loop?** That's bot detection on the automated
|
|
69
|
+
browser. Use `--from-browser` instead: log into avios.com in your normal Chrome
|
|
70
|
+
(you'll get one normal captcha), then run
|
|
71
|
+
`uvx --from 'avios-cli[login]' avios login --from-browser`.
|
|
72
|
+
|
|
68
73
|
Your session cookie is stored at `~/.config/avios/state.json` (mode `600`). It
|
|
69
74
|
expires after ~a day; just run `avios login` again. Run `avios logout` to remove it.
|
|
70
75
|
|
|
@@ -63,15 +63,15 @@ def login_via_browser(
|
|
|
63
63
|
session = session or Session()
|
|
64
64
|
factory = playwright_factory or _import_sync_playwright()
|
|
65
65
|
base_url = session.settings.base_url
|
|
66
|
+
profile_dir = str(session.settings.config_dir / "chrome-profile")
|
|
66
67
|
|
|
67
68
|
with factory() as pw:
|
|
68
|
-
|
|
69
|
-
ctx = browser.new_context(user_agent=session.settings.user_agent)
|
|
69
|
+
ctx = _open_login_context(pw, headless=headless, user_data_dir=profile_dir)
|
|
70
70
|
page = ctx.new_page()
|
|
71
71
|
page.goto(f"{base_url}{DASHBOARD_PATH}")
|
|
72
72
|
authed = _wait_for_auth(ctx, page, base_url, timeout_ms)
|
|
73
73
|
cookies = list(ctx.cookies())
|
|
74
|
-
|
|
74
|
+
ctx.close()
|
|
75
75
|
|
|
76
76
|
if not authed:
|
|
77
77
|
raise LoginError("Timed out waiting for login. Run `avios login` again.")
|
|
@@ -114,11 +114,27 @@ def _is_authenticated(ctx: Any, base_url: str) -> bool:
|
|
|
114
114
|
return False
|
|
115
115
|
|
|
116
116
|
|
|
117
|
-
def
|
|
118
|
-
"""
|
|
119
|
-
|
|
117
|
+
def _open_login_context(pw: Any, *, headless: bool, user_data_dir: str) -> Any:
|
|
118
|
+
"""Open a browser context for login, tuned to avoid the hCaptcha bot loop.
|
|
119
|
+
|
|
120
|
+
hCaptcha/Akamai serve endless challenges to obviously-automated browsers, so:
|
|
121
|
+
- prefer the user's real Chrome (``channel="chrome"``) over bundled Chromium;
|
|
122
|
+
- disable the automation fingerprint (``navigator.webdriver`` /
|
|
123
|
+
``--enable-automation``);
|
|
124
|
+
- use a **persistent profile** so cookies and captcha reputation carry over
|
|
125
|
+
between attempts (a brand-new, empty profile looks high-risk).
|
|
126
|
+
"""
|
|
127
|
+
args = ["--disable-blink-features=AutomationControlled"]
|
|
128
|
+
ignore_default_args = ["--enable-automation"]
|
|
129
|
+
for extra in ({"channel": "chrome"}, {}):
|
|
120
130
|
try:
|
|
121
|
-
return pw.chromium.
|
|
131
|
+
return pw.chromium.launch_persistent_context(
|
|
132
|
+
user_data_dir,
|
|
133
|
+
headless=headless,
|
|
134
|
+
args=args,
|
|
135
|
+
ignore_default_args=ignore_default_args,
|
|
136
|
+
**extra,
|
|
137
|
+
)
|
|
122
138
|
except Exception:
|
|
123
139
|
continue
|
|
124
140
|
raise LoginError(
|
|
@@ -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
|
|
|
@@ -17,6 +17,7 @@ from avios.auth import (
|
|
|
17
17
|
LoginError,
|
|
18
18
|
_default_loader,
|
|
19
19
|
_only_avios,
|
|
20
|
+
_open_login_context,
|
|
20
21
|
_to_cookie_dicts,
|
|
21
22
|
import_from_browser,
|
|
22
23
|
login_via_browser,
|
|
@@ -108,7 +109,7 @@ def test_login_via_browser_without_playwright(
|
|
|
108
109
|
login_via_browser(_session(settings))
|
|
109
110
|
|
|
110
111
|
|
|
111
|
-
# --- fake Playwright
|
|
112
|
+
# --- fake Playwright (persistent context) to exercise the login flow ------------
|
|
112
113
|
class _FakeResponse:
|
|
113
114
|
def __init__(self, ok: bool) -> None:
|
|
114
115
|
self.ok = ok
|
|
@@ -138,6 +139,7 @@ class _FakeContext:
|
|
|
138
139
|
def __init__(self, ok_after: int, cookies: list[dict[str, str]]) -> None:
|
|
139
140
|
self.request = _FakeRequest(ok_after)
|
|
140
141
|
self._cookies = cookies
|
|
142
|
+
self.closed = False
|
|
141
143
|
|
|
142
144
|
def new_page(self) -> _FakePage:
|
|
143
145
|
return _FakePage()
|
|
@@ -145,39 +147,34 @@ class _FakeContext:
|
|
|
145
147
|
def cookies(self) -> list[dict[str, str]]:
|
|
146
148
|
return self._cookies
|
|
147
149
|
|
|
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
150
|
def close(self) -> None:
|
|
158
151
|
self.closed = True
|
|
159
152
|
|
|
160
153
|
|
|
161
|
-
class _FakePlaywright:
|
|
162
|
-
def __init__(self, browser: _FakeBrowser) -> None:
|
|
163
|
-
self.chromium = _FakeChromium(browser)
|
|
164
|
-
|
|
165
|
-
|
|
166
154
|
class _FakeChromium:
|
|
167
|
-
def __init__(self,
|
|
168
|
-
self.
|
|
169
|
-
self.
|
|
155
|
+
def __init__(self, ctx: _FakeContext, fail_channel: str | None = None) -> None:
|
|
156
|
+
self._ctx = ctx
|
|
157
|
+
self._fail_channel = fail_channel
|
|
158
|
+
self.channels: list[str | None] = []
|
|
159
|
+
|
|
160
|
+
def launch_persistent_context(self, user_data_dir: str, **kwargs: object) -> _FakeContext:
|
|
161
|
+
channel = kwargs.get("channel")
|
|
162
|
+
self.channels.append(channel) # type: ignore[arg-type]
|
|
163
|
+
if channel == self._fail_channel:
|
|
164
|
+
raise RuntimeError("channel unavailable")
|
|
165
|
+
return self._ctx
|
|
170
166
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
167
|
+
|
|
168
|
+
class _FakePlaywright:
|
|
169
|
+
def __init__(self, ctx: _FakeContext, fail_channel: str | None = None) -> None:
|
|
170
|
+
self.chromium = _FakeChromium(ctx, fail_channel)
|
|
174
171
|
|
|
175
172
|
|
|
176
173
|
class _FakeFactory:
|
|
177
|
-
"""Callable
|
|
174
|
+
"""Callable returning a context manager that yields the fake Playwright."""
|
|
178
175
|
|
|
179
|
-
def __init__(self,
|
|
180
|
-
self._pw = _FakePlaywright(
|
|
176
|
+
def __init__(self, ctx: _FakeContext, fail_channel: str | None = None) -> None:
|
|
177
|
+
self._pw = _FakePlaywright(ctx, fail_channel)
|
|
181
178
|
|
|
182
179
|
def __call__(self) -> _FakeFactory:
|
|
183
180
|
return self
|
|
@@ -195,14 +192,13 @@ def test_login_captures_only_after_authenticated(settings: Settings) -> None:
|
|
|
195
192
|
{"name": "_ga", "value": "junk", "domain": ".google.com"},
|
|
196
193
|
]
|
|
197
194
|
ctx = _FakeContext(ok_after=3, cookies=cookies)
|
|
198
|
-
browser = _FakeBrowser(ctx)
|
|
199
195
|
session = _session(settings)
|
|
200
196
|
|
|
201
|
-
count = login_via_browser(session, timeout_ms=100_000, playwright_factory=_FakeFactory(
|
|
197
|
+
count = login_via_browser(session, timeout_ms=100_000, playwright_factory=_FakeFactory(ctx))
|
|
202
198
|
|
|
203
199
|
assert count == 1 # only the avios cookie
|
|
204
200
|
assert session.load_cookies() == cookies
|
|
205
|
-
assert
|
|
201
|
+
assert ctx.closed
|
|
206
202
|
assert ctx.request.calls >= 3 # polled until authenticated, not immediately
|
|
207
203
|
|
|
208
204
|
|
|
@@ -210,34 +206,18 @@ def test_login_times_out_without_saving(settings: Settings) -> None:
|
|
|
210
206
|
ctx = _FakeContext(
|
|
211
207
|
ok_after=999, cookies=[{"name": "appSession", "value": "x", "domain": "www.avios.com"}]
|
|
212
208
|
)
|
|
213
|
-
browser = _FakeBrowser(ctx)
|
|
214
209
|
session = _session(settings)
|
|
215
210
|
|
|
216
211
|
with pytest.raises(LoginError, match="Timed out"):
|
|
217
|
-
login_via_browser(session, timeout_ms=6_000, playwright_factory=_FakeFactory(
|
|
212
|
+
login_via_browser(session, timeout_ms=6_000, playwright_factory=_FakeFactory(ctx))
|
|
218
213
|
|
|
219
214
|
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"
|
|
215
|
+
assert ctx.closed
|
|
236
216
|
|
|
237
|
-
class PW:
|
|
238
|
-
def __init__(self) -> None:
|
|
239
|
-
self.chromium = Chromium()
|
|
240
217
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
218
|
+
def test_open_login_context_prefers_chrome_then_falls_back(tmp_path: object) -> None:
|
|
219
|
+
ctx = _FakeContext(ok_after=1, cookies=[])
|
|
220
|
+
pw = _FakePlaywright(ctx, fail_channel="chrome")
|
|
221
|
+
result = _open_login_context(pw, headless=True, user_data_dir=str(tmp_path))
|
|
222
|
+
assert result is ctx
|
|
223
|
+
assert pw.chromium.channels == ["chrome", None] # tried Chrome, fell back to Chromium
|
|
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
|
|
File without changes
|
|
File without changes
|