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.
Files changed (43) hide show
  1. {avios_cli-0.1.3 → avios_cli-0.1.4}/CHANGELOG.md +13 -1
  2. {avios_cli-0.1.3 → avios_cli-0.1.4}/PKG-INFO +6 -1
  3. {avios_cli-0.1.3 → avios_cli-0.1.4}/README.md +5 -0
  4. {avios_cli-0.1.3 → avios_cli-0.1.4}/src/avios/__init__.py +1 -1
  5. {avios_cli-0.1.3 → avios_cli-0.1.4}/src/avios/auth.py +23 -7
  6. {avios_cli-0.1.3 → avios_cli-0.1.4}/tests/test_auth.py +34 -54
  7. {avios_cli-0.1.3 → avios_cli-0.1.4}/.editorconfig +0 -0
  8. {avios_cli-0.1.3 → avios_cli-0.1.4}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  9. {avios_cli-0.1.3 → avios_cli-0.1.4}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  10. {avios_cli-0.1.3 → avios_cli-0.1.4}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  11. {avios_cli-0.1.3 → avios_cli-0.1.4}/.github/dependabot.yml +0 -0
  12. {avios_cli-0.1.3 → avios_cli-0.1.4}/.github/workflows/ci.yml +0 -0
  13. {avios_cli-0.1.3 → avios_cli-0.1.4}/.github/workflows/publish.yml +0 -0
  14. {avios_cli-0.1.3 → avios_cli-0.1.4}/.gitignore +0 -0
  15. {avios_cli-0.1.3 → avios_cli-0.1.4}/.pre-commit-config.yaml +0 -0
  16. {avios_cli-0.1.3 → avios_cli-0.1.4}/.python-version +0 -0
  17. {avios_cli-0.1.3 → avios_cli-0.1.4}/CONTRIBUTING.md +0 -0
  18. {avios_cli-0.1.3 → avios_cli-0.1.4}/LICENSE +0 -0
  19. {avios_cli-0.1.3 → avios_cli-0.1.4}/SECURITY.md +0 -0
  20. {avios_cli-0.1.3 → avios_cli-0.1.4}/docs/dashboard.svg +0 -0
  21. {avios_cli-0.1.3 → avios_cli-0.1.4}/pyproject.toml +0 -0
  22. {avios_cli-0.1.3 → avios_cli-0.1.4}/scripts/screenshot.py +0 -0
  23. {avios_cli-0.1.3 → avios_cli-0.1.4}/src/avios/cli.py +0 -0
  24. {avios_cli-0.1.3 → avios_cli-0.1.4}/src/avios/client.py +0 -0
  25. {avios_cli-0.1.3 → avios_cli-0.1.4}/src/avios/config.py +0 -0
  26. {avios_cli-0.1.3 → avios_cli-0.1.4}/src/avios/endpoints.py +0 -0
  27. {avios_cli-0.1.3 → avios_cli-0.1.4}/src/avios/models.py +0 -0
  28. {avios_cli-0.1.3 → avios_cli-0.1.4}/src/avios/session.py +0 -0
  29. {avios_cli-0.1.3 → avios_cli-0.1.4}/src/avios/tui/__init__.py +0 -0
  30. {avios_cli-0.1.3 → avios_cli-0.1.4}/src/avios/tui/app.py +0 -0
  31. {avios_cli-0.1.3 → avios_cli-0.1.4}/src/avios/tui/art.py +0 -0
  32. {avios_cli-0.1.3 → avios_cli-0.1.4}/src/avios/tui/styles.tcss +0 -0
  33. {avios_cli-0.1.3 → avios_cli-0.1.4}/src/avios/tui/widgets.py +0 -0
  34. {avios_cli-0.1.3 → avios_cli-0.1.4}/tests/conftest.py +0 -0
  35. {avios_cli-0.1.3 → avios_cli-0.1.4}/tests/fixtures/accounts.json +0 -0
  36. {avios_cli-0.1.3 → avios_cli-0.1.4}/tests/fixtures/balance.json +0 -0
  37. {avios_cli-0.1.3 → avios_cli-0.1.4}/tests/fixtures/transactions.json +0 -0
  38. {avios_cli-0.1.3 → avios_cli-0.1.4}/tests/test_cli.py +0 -0
  39. {avios_cli-0.1.3 → avios_cli-0.1.4}/tests/test_client.py +0 -0
  40. {avios_cli-0.1.3 → avios_cli-0.1.4}/tests/test_models.py +0 -0
  41. {avios_cli-0.1.3 → avios_cli-0.1.4}/tests/test_session.py +0 -0
  42. {avios_cli-0.1.3 → avios_cli-0.1.4}/tests/test_tui.py +0 -0
  43. {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.3...HEAD
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
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
 
@@ -3,4 +3,4 @@
3
3
  View your Avios balance, transactions and account details from the terminal.
4
4
  """
5
5
 
6
- __version__ = "0.1.3"
6
+ __version__ = "0.1.4"
@@ -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
- browser = _launch_browser(pw, headless=headless)
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
- browser.close()
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 _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"}, {}):
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.launch(headless=headless, **kwargs)
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 Playwright browser flow can't be automated (Auth0 + hCaptcha + MFA need a
4
- human), so we test the cookie-conversion/import helpers and the missing-dependency
5
- paths. ``browser_cookie3`` is faked via ``sys.modules`` so tests don't depend on
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, to exercise the poll-until-authenticated flow --------------
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, browser: _FakeBrowser) -> None:
168
- self._browser = browser
169
- self.launches: list[str | None] = []
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
- def launch(self, **kwargs: object) -> _FakeBrowser:
172
- self.launches.append(kwargs.get("channel")) # type: ignore[arg-type]
173
- return self._browser
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 that returns a context manager yielding a fake Playwright."""
174
+ """Callable returning a context manager that yields the fake Playwright."""
178
175
 
179
- def __init__(self, browser: _FakeBrowser) -> None:
180
- self._pw = _FakePlaywright(browser)
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(browser))
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 browser.closed
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(browser))
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 browser.closed
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
- pw = PW()
242
- assert _launch_browser(pw, headless=True) == "browser"
243
- assert pw.chromium.channels == ["chrome", None]
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