avios-cli 0.1.4__tar.gz → 0.1.6__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 (44) hide show
  1. {avios_cli-0.1.4 → avios_cli-0.1.6}/CHANGELOG.md +36 -1
  2. {avios_cli-0.1.4 → avios_cli-0.1.6}/PKG-INFO +22 -10
  3. {avios_cli-0.1.4 → avios_cli-0.1.6}/README.md +21 -9
  4. {avios_cli-0.1.4 → avios_cli-0.1.6}/src/avios/__init__.py +1 -1
  5. {avios_cli-0.1.4 → avios_cli-0.1.6}/src/avios/auth.py +142 -13
  6. {avios_cli-0.1.4 → avios_cli-0.1.6}/src/avios/cli.py +49 -23
  7. {avios_cli-0.1.4 → avios_cli-0.1.6}/src/avios/client.py +6 -7
  8. {avios_cli-0.1.4 → avios_cli-0.1.6}/src/avios/models.py +7 -3
  9. {avios_cli-0.1.4 → avios_cli-0.1.6}/src/avios/tui/app.py +9 -3
  10. {avios_cli-0.1.4 → avios_cli-0.1.6}/src/avios/tui/widgets.py +2 -2
  11. avios_cli-0.1.6/tests/fixtures/balance.json +1 -0
  12. {avios_cli-0.1.4 → avios_cli-0.1.6}/tests/test_auth.py +28 -30
  13. {avios_cli-0.1.4 → avios_cli-0.1.6}/tests/test_cli.py +36 -13
  14. {avios_cli-0.1.4 → avios_cli-0.1.6}/tests/test_client.py +8 -8
  15. {avios_cli-0.1.4 → avios_cli-0.1.6}/tests/test_models.py +9 -7
  16. {avios_cli-0.1.4 → avios_cli-0.1.6}/tests/test_tui.py +1 -1
  17. avios_cli-0.1.4/tests/fixtures/accounts.json +0 -3
  18. avios_cli-0.1.4/tests/fixtures/balance.json +0 -1
  19. {avios_cli-0.1.4 → avios_cli-0.1.6}/.editorconfig +0 -0
  20. {avios_cli-0.1.4 → avios_cli-0.1.6}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  21. {avios_cli-0.1.4 → avios_cli-0.1.6}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  22. {avios_cli-0.1.4 → avios_cli-0.1.6}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  23. {avios_cli-0.1.4 → avios_cli-0.1.6}/.github/dependabot.yml +0 -0
  24. {avios_cli-0.1.4 → avios_cli-0.1.6}/.github/workflows/ci.yml +0 -0
  25. {avios_cli-0.1.4 → avios_cli-0.1.6}/.github/workflows/publish.yml +0 -0
  26. {avios_cli-0.1.4 → avios_cli-0.1.6}/.gitignore +0 -0
  27. {avios_cli-0.1.4 → avios_cli-0.1.6}/.pre-commit-config.yaml +0 -0
  28. {avios_cli-0.1.4 → avios_cli-0.1.6}/.python-version +0 -0
  29. {avios_cli-0.1.4 → avios_cli-0.1.6}/CONTRIBUTING.md +0 -0
  30. {avios_cli-0.1.4 → avios_cli-0.1.6}/LICENSE +0 -0
  31. {avios_cli-0.1.4 → avios_cli-0.1.6}/SECURITY.md +0 -0
  32. {avios_cli-0.1.4 → avios_cli-0.1.6}/docs/dashboard.svg +0 -0
  33. {avios_cli-0.1.4 → avios_cli-0.1.6}/pyproject.toml +0 -0
  34. {avios_cli-0.1.4 → avios_cli-0.1.6}/scripts/screenshot.py +0 -0
  35. {avios_cli-0.1.4 → avios_cli-0.1.6}/src/avios/config.py +0 -0
  36. {avios_cli-0.1.4 → avios_cli-0.1.6}/src/avios/endpoints.py +0 -0
  37. {avios_cli-0.1.4 → avios_cli-0.1.6}/src/avios/session.py +0 -0
  38. {avios_cli-0.1.4 → avios_cli-0.1.6}/src/avios/tui/__init__.py +0 -0
  39. {avios_cli-0.1.4 → avios_cli-0.1.6}/src/avios/tui/art.py +0 -0
  40. {avios_cli-0.1.4 → avios_cli-0.1.6}/src/avios/tui/styles.tcss +0 -0
  41. {avios_cli-0.1.4 → avios_cli-0.1.6}/tests/conftest.py +0 -0
  42. {avios_cli-0.1.4 → avios_cli-0.1.6}/tests/fixtures/transactions.json +0 -0
  43. {avios_cli-0.1.4 → avios_cli-0.1.6}/tests/test_session.py +0 -0
  44. {avios_cli-0.1.4 → avios_cli-0.1.6}/uv.lock +0 -0
@@ -6,6 +6,39 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.6] - 2026-07-24
10
+
11
+ ### Fixed
12
+ - **`balance` finally works after login.** It (and `whoami`) were querying the
13
+ `manage-avios`/`spend-avios` micro-app APIs, which need a separate per-app
14
+ session and returned 401 ("Session expired") even when you were logged in.
15
+ Probing a real session showed the balance is served by
16
+ `/shell/api/users/current/accounts` (`{balance, individual, household}`) and the
17
+ profile by `/auth-gateway/user` — both of which authenticate with the browser
18
+ session. `balance`/`whoami` now use those.
19
+
20
+ ### Changed
21
+ - `balance` shows total / individual / household; `whoami` shows name, tier,
22
+ membership and email.
23
+ - The TUI shows your balance even if transactions are unavailable.
24
+ - `transactions`, `pending` and `overview` are marked **experimental** — they need
25
+ the `manage-avios` app session, which the cookie/SSO login can't yet establish.
26
+ - Removed the `accounts` command (its endpoint is the balance source, now shown by
27
+ `balance`).
28
+
29
+ ## [0.1.5] - 2026-07-24
30
+
31
+ ### Fixed
32
+ - **`--from-browser` only read Chrome's `Default` profile.** If you were logged
33
+ into avios.com in another profile (Profile 1, a work profile, ...), it grabbed
34
+ the wrong profile's cookies and every call failed with "Session expired". It now
35
+ **scans all Chrome profiles, verifies which one actually authenticates**
36
+ (against `/auth-gateway/user`), and uses that — and reports which profile it used.
37
+
38
+ ### Added
39
+ - `avios login --from-browser --profile "Profile 1"` to target a specific browser
40
+ profile, and a clear warning when the imported cookies aren't a logged-in session.
41
+
9
42
  ## [0.1.4] - 2026-07-24
10
43
 
11
44
  ### Fixed
@@ -74,7 +107,9 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
74
107
  - Reward-flight **availability** search is not yet implemented (needs a British
75
108
  Airways capture).
76
109
 
77
- [Unreleased]: https://github.com/alexechoi/avios-cli/compare/v0.1.4...HEAD
110
+ [Unreleased]: https://github.com/alexechoi/avios-cli/compare/v0.1.6...HEAD
111
+ [0.1.6]: https://github.com/alexechoi/avios-cli/compare/v0.1.5...v0.1.6
112
+ [0.1.5]: https://github.com/alexechoi/avios-cli/compare/v0.1.4...v0.1.5
78
113
  [0.1.4]: https://github.com/alexechoi/avios-cli/compare/v0.1.3...v0.1.4
79
114
  [0.1.3]: https://github.com/alexechoi/avios-cli/compare/v0.1.2...v0.1.3
80
115
  [0.1.2]: https://github.com/alexechoi/avios-cli/compare/v0.1.1...v0.1.2
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: avios-cli
3
- Version: 0.1.4
3
+ Version: 0.1.6
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,6 +97,14 @@ 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,17 +119,21 @@ expires after ~a day; just run `avios login` again. Run `avios logout` to remove
111
119
  ## Usage
112
120
 
113
121
  ```bash
114
- avios balance # your Avios balance
115
- avios transactions --limit 20 # recent transactions
116
- avios pending # pending Avios
117
- avios accounts # linked loyalty accounts
118
- avios overview # dashboard summary (raw JSON)
119
- avios whoami # your profile (raw JSON)
120
- avios raw /manage-avios/api/user/current # hit any endpoint directly
122
+ avios balance # total / individual / household Avios
123
+ avios whoami # name, tier, membership, email
124
+ avios raw /shell/api/users/current/accounts # hit any endpoint directly
121
125
  ```
122
126
 
123
- Add `--json` to `balance`, `transactions`, `pending` or `accounts` for scriptable
124
- output.
127
+ Add `--json` to `balance` or `whoami` for scriptable output.
128
+
129
+ **Experimental** (need the `manage-avios` app session, which the cookie/SSO login
130
+ can't yet establish — these currently return "session expired"):
131
+
132
+ ```bash
133
+ avios transactions --limit 20
134
+ avios pending
135
+ avios overview
136
+ ```
125
137
 
126
138
  ## TUI
127
139
 
@@ -62,6 +62,14 @@ 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,17 +84,21 @@ expires after ~a day; just run `avios login` again. Run `avios logout` to remove
76
84
  ## Usage
77
85
 
78
86
  ```bash
79
- avios balance # your Avios balance
80
- avios transactions --limit 20 # recent transactions
81
- avios pending # pending Avios
82
- avios accounts # linked loyalty accounts
83
- avios overview # dashboard summary (raw JSON)
84
- avios whoami # your profile (raw JSON)
85
- avios raw /manage-avios/api/user/current # hit any endpoint directly
87
+ avios balance # total / individual / household Avios
88
+ avios whoami # name, tier, membership, email
89
+ avios raw /shell/api/users/current/accounts # hit any endpoint directly
86
90
  ```
87
91
 
88
- Add `--json` to `balance`, `transactions`, `pending` or `accounts` for scriptable
89
- output.
92
+ Add `--json` to `balance` or `whoami` for scriptable output.
93
+
94
+ **Experimental** (need the `manage-avios` app session, which the cookie/SSO login
95
+ can't yet establish — these currently return "session expired"):
96
+
97
+ ```bash
98
+ avios transactions --limit 20
99
+ avios pending
100
+ avios overview
101
+ ```
90
102
 
91
103
  ## TUI
92
104
 
@@ -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.4"
6
+ __version__ = "0.1.6"
@@ -17,22 +17,62 @@ Two strategies, both requiring the optional ``login`` extra
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
+ import contextlib
20
21
  from collections.abc import Callable, Iterable
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
21
24
  from typing import Any, Protocol
22
25
 
26
+ import httpx
27
+
23
28
  from avios import endpoints
24
- from avios.session import Session
29
+ from avios.config import Settings
30
+ from avios.session import Session, cookie_header_from
25
31
 
26
32
  DASHBOARD_PATH = "/manage-avios/dashboard"
27
33
  LOGIN_TIMEOUT_MS = 300_000 # 5 minutes to complete password + captcha + MFA
28
34
  LOGIN_POLL_MS = 2_000 # how often to check whether the session is authenticated
29
35
  SUPPORTED_BROWSERS = ("chrome", "firefox", "edge", "brave", "safari", "chromium")
30
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
+
31
62
 
32
63
  class LoginError(RuntimeError):
33
64
  """Login could not be completed."""
34
65
 
35
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
+
36
76
  class _RawCookie(Protocol):
37
77
  name: str
38
78
  value: str
@@ -109,7 +149,9 @@ def _wait_for_auth(ctx: Any, page: Any, base_url: str, timeout_ms: int) -> bool:
109
149
 
110
150
  def _is_authenticated(ctx: Any, base_url: str) -> bool:
111
151
  try:
112
- return bool(ctx.request.get(f"{base_url}{endpoints.AUTH_USER}").ok)
152
+ # Use the same endpoint `balance` reads, so a successful login guarantees
153
+ # the balance command will work.
154
+ return bool(ctx.request.get(f"{base_url}{endpoints.ACCOUNTS}").ok)
113
155
  except Exception:
114
156
  return False
115
157
 
@@ -143,8 +185,7 @@ def _open_login_context(pw: Any, *, headless: bool, user_data_dir: str) -> Any:
143
185
  )
144
186
 
145
187
 
146
- def _default_loader(browser: str) -> Callable[[], Iterable[_RawCookie]]:
147
- """Return a callable that reads avios.com cookies from ``browser``."""
188
+ def _bc3() -> Any:
148
189
  try:
149
190
  import browser_cookie3 as bc3
150
191
  except ImportError as exc:
@@ -152,30 +193,118 @@ def _default_loader(browser: str) -> Callable[[], Iterable[_RawCookie]]:
152
193
  "browser-cookie3 isn't installed. Run with the 'login' extra:\n"
153
194
  " uvx --from 'avios-cli[login]' avios login --from-browser"
154
195
  ) from exc
196
+ return bc3
197
+
198
+
199
+ def _browser_fn(bc3: Any, browser: str) -> Any:
155
200
  fn = getattr(bc3, browser, None)
156
201
  if fn is None:
157
202
  raise LoginError(
158
203
  f"Unknown browser '{browser}'. Choose from: {', '.join(SUPPORTED_BROWSERS)}"
159
204
  )
160
- return lambda: fn(domain_name="avios.com")
205
+ return fn
206
+
207
+
208
+ def _profile_cookie_files(browser: str, profile: str | None) -> list[tuple[str, Path]]:
209
+ """Discover (profile_name, Cookies-db) pairs for a chromium-family browser."""
210
+ found: list[tuple[str, Path]] = []
211
+ for rel in _CHROMIUM_ROOTS.get(browser, []):
212
+ root = Path.home() / rel
213
+ if not root.exists():
214
+ continue
215
+ for prof in sorted(p for p in root.iterdir() if p.is_dir()):
216
+ if profile and prof.name != profile:
217
+ continue
218
+ for db in (prof / "Cookies", prof / "Network" / "Cookies"):
219
+ if db.exists():
220
+ found.append((prof.name, db))
221
+ break
222
+ return found
223
+
224
+
225
+ def _candidate_cookie_sets(
226
+ browser: str, profile: str | None
227
+ ) -> list[tuple[str, list[dict[str, Any]]]]:
228
+ """Return [(profile_name, avios_cookies)] to try, most specific first."""
229
+ bc3 = _bc3()
230
+ fn = _browser_fn(bc3, browser)
231
+ candidates: list[tuple[str, list[dict[str, Any]]]] = []
232
+ for name, db in _profile_cookie_files(browser, profile):
233
+ try:
234
+ raw = fn(domain_name="avios.com", cookie_file=str(db))
235
+ except Exception:
236
+ continue
237
+ candidates.append((name, _only_avios(_to_cookie_dicts(raw))))
238
+ if not profile:
239
+ # Fallback to browser_cookie3's own default lookup.
240
+ with contextlib.suppress(Exception):
241
+ raw = fn(domain_name="avios.com")
242
+ candidates.append(("default", _only_avios(_to_cookie_dicts(raw))))
243
+ return candidates
244
+
245
+
246
+ def _cookies_authenticate(settings: Settings, cookies: list[dict[str, Any]]) -> bool:
247
+ """True if these cookies yield a 200 from the auth endpoint."""
248
+ header = cookie_header_from(cookies)
249
+ if not header:
250
+ return False
251
+ try:
252
+ with httpx.Client(
253
+ base_url=settings.base_url,
254
+ timeout=15.0,
255
+ follow_redirects=False,
256
+ headers={
257
+ "accept": "application/json",
258
+ "user-agent": settings.user_agent,
259
+ "cookie": header,
260
+ },
261
+ ) as client:
262
+ return client.get(endpoints.ACCOUNTS).status_code == 200
263
+ except httpx.HTTPError:
264
+ return False
161
265
 
162
266
 
163
267
  def import_from_browser(
164
268
  session: Session | None = None,
165
269
  browser: str = "chrome",
166
270
  *,
271
+ profile: str | None = None,
167
272
  loader: Callable[[], Iterable[_RawCookie]] | None = None,
168
- ) -> int:
169
- """Import the avios.com session cookie from a running browser.
273
+ authenticator: Callable[[list[dict[str, Any]]], bool] | None = None,
274
+ ) -> ImportResult:
275
+ """Import the avios.com session cookie from a browser profile.
170
276
 
171
- Returns the number of avios.com cookies imported.
277
+ Scans the browser's profiles, prefers the one whose cookies actually
278
+ authenticate, and saves them. ``loader``/``authenticator`` are test seams.
172
279
  """
173
280
  session = session or Session()
174
- load = loader or _default_loader(browser)
175
- cookies = _only_avios(_to_cookie_dicts(load()))
176
- if not cookies:
281
+ authenticate = authenticator or (lambda c: _cookies_authenticate(session.settings, c))
282
+
283
+ if loader is not None:
284
+ candidates: list[tuple[str, list[dict[str, Any]]]] = [
285
+ ("browser", _only_avios(_to_cookie_dicts(loader())))
286
+ ]
287
+ else:
288
+ candidates = _candidate_cookie_sets(browser, profile)
289
+
290
+ working: tuple[str, list[dict[str, Any]]] | None = None
291
+ fallback: tuple[str, list[dict[str, Any]]] | None = None
292
+ for name, cookies in candidates:
293
+ if not cookies:
294
+ continue
295
+ if fallback is None:
296
+ fallback = (name, cookies)
297
+ if authenticate(cookies):
298
+ working = (name, cookies)
299
+ break
300
+
301
+ chosen = working or fallback
302
+ if chosen is None:
303
+ where = f"profile '{profile}'" if profile else browser
177
304
  raise LoginError(
178
- f"No avios.com cookies found in {browser}. Log into avios.com there first."
305
+ f"No avios.com cookies found in {where}. Log into avios.com in that "
306
+ "browser first (and check the profile with --profile)."
179
307
  )
308
+ name, cookies = chosen
180
309
  session.save_cookies(cookies)
181
- return len(cookies)
310
+ 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
- count = import_from_browser(session, browser)
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(
@@ -150,8 +165,10 @@ def balance(json_out: bool = JSON_OPTION) -> None:
150
165
  def _render_balance(result: Balance) -> None:
151
166
  table = Table(show_header=False, box=None)
152
167
  table.add_row("[bold]Avios[/]", f"[bold cyan]{result.balance:,}[/]")
153
- if result.household_avios_balance is not None:
154
- table.add_row("Household", f"{result.household_avios_balance:,}")
168
+ if result.individual is not None:
169
+ table.add_row("Individual", f"{result.individual:,}")
170
+ if result.household is not None:
171
+ table.add_row("Household", f"{result.household:,}")
155
172
  console.print(table)
156
173
 
157
174
 
@@ -160,7 +177,7 @@ def transactions(
160
177
  limit: int = typer.Option(20, help="Number of transactions to show."),
161
178
  json_out: bool = JSON_OPTION,
162
179
  ) -> None:
163
- """List recent Avios transactions."""
180
+ """List recent Avios transactions (experimental; needs the manage-avios session)."""
164
181
  with _handle_errors():
165
182
  items = _client().get_transactions(limit=limit)
166
183
  records = [item.as_dict() for item in items]
@@ -172,7 +189,7 @@ def transactions(
172
189
 
173
190
  @app.command()
174
191
  def pending(json_out: bool = JSON_OPTION) -> None:
175
- """List pending Avios transactions."""
192
+ """List pending Avios transactions (experimental; needs the manage-avios session)."""
176
193
  with _handle_errors():
177
194
  items = _client().get_pending_transactions()
178
195
  records = [item.as_dict() for item in items]
@@ -182,32 +199,41 @@ def pending(json_out: bool = JSON_OPTION) -> None:
182
199
  _render_records(records, "Pending")
183
200
 
184
201
 
185
- @app.command()
186
- def accounts(json_out: bool = JSON_OPTION) -> None:
187
- """List linked loyalty accounts."""
188
- with _handle_errors():
189
- items = _client().get_accounts()
190
- records = [item.as_dict() for item in items]
191
- if json_out:
192
- _print_json(records)
193
- return
194
- _render_records(records, "Accounts")
195
-
196
-
197
202
  @app.command()
198
203
  def overview() -> None:
199
- """Show the dashboard overview (raw JSON; shape not yet finalised)."""
204
+ """Show the dashboard overview (experimental; needs the manage-avios session)."""
200
205
  with _handle_errors():
201
206
  result = _client().get_overview()
202
207
  _print_json(result.as_dict())
203
208
 
204
209
 
205
210
  @app.command()
206
- def whoami() -> None:
207
- """Show your profile (raw JSON; shape not yet finalised)."""
211
+ def whoami(json_out: bool = JSON_OPTION) -> None:
212
+ """Show your profile (name, tier, membership)."""
208
213
  with _handle_errors():
209
- result = _client().get_profile()
210
- _print_json(result.as_dict())
214
+ data = _client().get_profile().as_dict()
215
+ if json_out:
216
+ _print_json(data)
217
+ return
218
+ claims = data.get("tokenContent", {})
219
+
220
+ def claim(key: str) -> str:
221
+ return str(claims.get(f"https://avios.com/{key}", "") or "")
222
+
223
+ table = Table(show_header=False, box=None)
224
+ name = claims.get("name") or f"{claims.get('given_name', '')} {claims.get('family_name', '')}"
225
+ for label, value in (
226
+ ("Name", str(name).strip()),
227
+ ("Tier", claim("customer_tier_name")),
228
+ ("Membership", claim("membership_id")),
229
+ ("Email", str(claims.get("email", "") or "")),
230
+ ):
231
+ if value:
232
+ table.add_row(f"[bold]{label}[/]", value)
233
+ if table.row_count:
234
+ console.print(table)
235
+ else:
236
+ _print_json(data)
211
237
 
212
238
 
213
239
  @app.command()
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
  from typing import Any
11
11
 
12
12
  from avios import endpoints
13
- from avios.models import Account, Balance, Overview, Profile, Transaction
13
+ from avios.models import Balance, Overview, Profile, Transaction
14
14
  from avios.session import Session
15
15
 
16
16
 
@@ -36,18 +36,17 @@ class AviosClient:
36
36
  self.session = session or Session()
37
37
 
38
38
  def get_balance(self) -> Balance:
39
- return Balance.model_validate(self.session.get_json(endpoints.BALANCE))
39
+ # /shell/api/users/current/accounts authenticates with the SSO session and
40
+ # returns {balance, individual, household}.
41
+ return Balance.model_validate(self.session.get_json(endpoints.ACCOUNTS))
40
42
 
41
43
  def get_profile(self) -> Profile:
42
- return Profile.model_validate(self.session.get_json(endpoints.PROFILE))
44
+ # /auth-gateway/user returns the SSO user (idToken + tokenContent claims).
45
+ return Profile.model_validate(self.session.get_json(endpoints.AUTH_USER))
43
46
 
44
47
  def get_overview(self) -> Overview:
45
48
  return Overview.model_validate(self.session.get_json(endpoints.OVERVIEW))
46
49
 
47
- def get_accounts(self) -> list[Account]:
48
- payload = self.session.get_json(endpoints.ACCOUNTS)
49
- return [Account.model_validate(item) for item in _extract_list(payload)]
50
-
51
50
  def get_transactions(self, limit: int | None = None) -> list[Transaction]:
52
51
  payload = self.session.get_json(endpoints.TRANSACTIONS)
53
52
  items = _extract_list(payload)
@@ -11,7 +11,7 @@ from __future__ import annotations
11
11
 
12
12
  from typing import Any
13
13
 
14
- from pydantic import BaseModel, ConfigDict, Field
14
+ from pydantic import BaseModel, ConfigDict
15
15
 
16
16
 
17
17
  class AviosModel(BaseModel):
@@ -25,10 +25,14 @@ class AviosModel(BaseModel):
25
25
 
26
26
 
27
27
  class Balance(AviosModel):
28
- """Avios balance (from ``/en-GB/spend-avios/api/avios-balance``)."""
28
+ """Avios balance (from ``/shell/api/users/current/accounts``).
29
+
30
+ Shape: ``{"balance": N, "individual": N, "household": N}``.
31
+ """
29
32
 
30
33
  balance: int
31
- household_avios_balance: int | None = Field(default=None, alias="householdAviosBalance")
34
+ individual: int | None = None
35
+ household: int | None = None
32
36
 
33
37
 
34
38
  class Transaction(AviosModel):
@@ -61,9 +61,6 @@ class AviosApp(App[None]):
61
61
  async def _load(self) -> None:
62
62
  try:
63
63
  balance = await asyncio.to_thread(self._client.get_balance)
64
- transactions = await asyncio.to_thread(
65
- self._client.get_transactions, TRANSACTIONS_TO_SHOW
66
- )
67
64
  except (NotAuthenticated, SessionExpired) as exc:
68
65
  self._show_error(f"{exc} — run `avios login`")
69
66
  return
@@ -71,6 +68,15 @@ class AviosApp(App[None]):
71
68
  self._show_error(f"Request failed: {exc}")
72
69
  return
73
70
  self.query_one("#balance", BalanceDisplay).update_balance(balance)
71
+
72
+ # Transactions are experimental (manage-avios session); degrade gracefully
73
+ # so the balance still shows if they're unavailable.
74
+ try:
75
+ transactions = await asyncio.to_thread(
76
+ self._client.get_transactions, TRANSACTIONS_TO_SHOW
77
+ )
78
+ except (NotAuthenticated, SessionExpired, httpx.HTTPError):
79
+ transactions = []
74
80
  self._populate_transactions(transactions)
75
81
 
76
82
  def _show_error(self, message: str) -> None:
@@ -20,8 +20,8 @@ class BalanceDisplay(Static):
20
20
  text = Text()
21
21
  text.append("Avios ", style="bold")
22
22
  text.append(f"{balance.balance:,}", style="bold cyan")
23
- if balance.household_avios_balance is not None:
24
- text.append(f" Household {balance.household_avios_balance:,}", style="dim")
23
+ if balance.household is not None:
24
+ text.append(f" Household {balance.household:,}", style="dim")
25
25
  self.last_text = text.plain
26
26
  self.update(text)
27
27
 
@@ -0,0 +1 @@
1
+ {"balance": 75751, "individual": 75751, "household": 112430}
@@ -14,8 +14,9 @@ import types
14
14
  import pytest
15
15
 
16
16
  from avios.auth import (
17
+ ImportResult,
17
18
  LoginError,
18
- _default_loader,
19
+ _browser_fn,
19
20
  _only_avios,
20
21
  _open_login_context,
21
22
  _to_cookie_dicts,
@@ -52,52 +53,49 @@ def test_only_avios_filters_foreign_domains() -> None:
52
53
  assert _only_avios(cookies) == [cookies[0]]
53
54
 
54
55
 
55
- def test_import_from_browser_saves_only_avios(settings: Settings) -> None:
56
+ def test_import_from_browser_uses_authenticated_cookies(settings: Settings) -> None:
56
57
  session = _session(settings)
57
- count = import_from_browser(
58
+ result = import_from_browser(
58
59
  session,
59
60
  loader=lambda: [
60
61
  FakeCookie("appSession", "abc", "www.avios.com"),
61
62
  FakeCookie("_ga", "junk", ".google.com"),
62
63
  ],
64
+ authenticator=lambda cookies: True,
63
65
  )
64
- assert count == 1
65
- assert session.is_authenticated()
66
+ assert isinstance(result, ImportResult)
67
+ assert result.count == 1 # only the avios cookie
68
+ assert result.authenticated is True
66
69
  assert session.load_cookies() == [
67
70
  {"name": "appSession", "value": "abc", "domain": "www.avios.com"}
68
71
  ]
69
72
 
70
73
 
71
- def test_import_from_browser_no_cookies_raises(settings: Settings) -> None:
72
- with pytest.raises(LoginError, match="No avios.com cookies"):
73
- import_from_browser(_session(settings), loader=lambda: [FakeCookie("_ga", "j", ".x.com")])
74
-
75
-
76
- def _fake_bc3(monkeypatch: pytest.MonkeyPatch, **funcs: object) -> None:
77
- module = types.ModuleType("browser_cookie3")
78
- for name, fn in funcs.items():
79
- setattr(module, name, fn)
80
- monkeypatch.setitem(sys.modules, "browser_cookie3", module)
81
-
82
-
83
- def test_default_loader_passes_domain(monkeypatch: pytest.MonkeyPatch) -> None:
84
- 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
85
83
 
86
- def chrome(**kwargs: str) -> list[FakeCookie]:
87
- seen.update(kwargs)
88
- return [FakeCookie("appSession", "x", "www.avios.com")]
89
84
 
90
- _fake_bc3(monkeypatch, chrome=chrome)
91
- loader = _default_loader("chrome")
92
- cookies = list(loader())
93
- assert seen["domain_name"] == "avios.com"
94
- assert cookies[0].name == "appSession"
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
+ )
95
92
 
96
93
 
97
- def test_default_loader_unknown_browser(monkeypatch: pytest.MonkeyPatch) -> None:
98
- _fake_bc3(monkeypatch, chrome=lambda **k: [])
94
+ def test_browser_fn_unknown_browser() -> None:
95
+ bc3 = types.ModuleType("browser_cookie3")
96
+ bc3.chrome = lambda **k: [] # type: ignore[attr-defined]
99
97
  with pytest.raises(LoginError, match="Unknown browser"):
100
- _default_loader("nope")
98
+ _browser_fn(bc3, "nope")
101
99
 
102
100
 
103
101
  def test_login_via_browser_without_playwright(
@@ -13,9 +13,9 @@ 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
- from avios.models import Account, Balance, Overview, Profile, Transaction
18
+ from avios.models import Balance, Overview, Profile, Transaction
19
19
  from avios.session import NotAuthenticated, SessionExpired
20
20
 
21
21
  runner = CliRunner()
@@ -28,7 +28,7 @@ class FakeClient:
28
28
  pass
29
29
 
30
30
  def get_balance(self) -> Balance:
31
- return Balance(balance=100, household_avios_balance=200)
31
+ return Balance(balance=100, individual=100, household=200)
32
32
 
33
33
  def get_transactions(self, limit: int | None = None) -> list[Transaction]:
34
34
  items = [
@@ -40,14 +40,20 @@ class FakeClient:
40
40
  def get_pending_transactions(self) -> list[Transaction]:
41
41
  return []
42
42
 
43
- def get_accounts(self) -> list[Account]:
44
- return [Account.model_validate({"programme": "BAEC", "balance": 100})]
45
-
46
43
  def get_overview(self) -> Overview:
47
44
  return Overview.model_validate({"tier": "Blue"})
48
45
 
49
46
  def get_profile(self) -> Profile:
50
- return Profile.model_validate({"firstName": "Alex"})
47
+ return Profile.model_validate(
48
+ {
49
+ "idToken": "jwt",
50
+ "tokenContent": {
51
+ "name": "Alex Choi",
52
+ "https://avios.com/customer_tier_name": "Gold",
53
+ "https://avios.com/membership_id": "BA123",
54
+ },
55
+ }
56
+ )
51
57
 
52
58
  def raw(self, path: str) -> dict[str, str]:
53
59
  return {"path": path}
@@ -81,7 +87,7 @@ def test_balance_json(fake_client: None) -> None:
81
87
  result = runner.invoke(app, ["balance", "--json"])
82
88
  assert result.exit_code == 0
83
89
  assert '"balance"' in result.stdout
84
- assert '"householdAviosBalance"' in result.stdout
90
+ assert '"household"' in result.stdout
85
91
 
86
92
 
87
93
  def test_transactions_respects_limit(fake_client: None) -> None:
@@ -97,10 +103,12 @@ def test_transactions_json(fake_client: None) -> None:
97
103
  assert '"avios"' in result.stdout
98
104
 
99
105
 
100
- def test_accounts(fake_client: None) -> None:
101
- result = runner.invoke(app, ["accounts"])
106
+ def test_whoami(fake_client: None) -> None:
107
+ result = runner.invoke(app, ["whoami"])
102
108
  assert result.exit_code == 0
103
- assert "BAEC" in result.stdout
109
+ out = " ".join(result.stdout.split())
110
+ assert "Alex Choi" in out
111
+ assert "Gold" in out
104
112
 
105
113
 
106
114
  def test_raw(fake_client: None) -> None:
@@ -134,16 +142,31 @@ def test_session_expired(monkeypatch: pytest.MonkeyPatch) -> None:
134
142
 
135
143
  # -- login / logout ----------------------------------------------------------
136
144
  def test_login_from_browser(monkeypatch: pytest.MonkeyPatch) -> None:
137
- monkeypatch.setattr("avios.cli.import_from_browser", lambda session, browser: 2)
145
+ monkeypatch.setattr(
146
+ "avios.cli.import_from_browser",
147
+ lambda session, browser, profile=None: ImportResult(2, "Default", True),
148
+ )
138
149
  monkeypatch.setattr("avios.cli.AviosClient", FakeClient)
139
150
  result = runner.invoke(app, ["login", "--from-browser"])
140
151
  assert result.exit_code == 0
141
152
  assert "Logged in" in result.stdout
153
+ assert "Default" in result.stdout
142
154
  assert "Balance" in result.stdout
143
155
 
144
156
 
157
+ def test_login_from_browser_unauthenticated(monkeypatch: pytest.MonkeyPatch) -> None:
158
+ monkeypatch.setattr(
159
+ "avios.cli.import_from_browser",
160
+ lambda session, browser, profile=None: ImportResult(20, "Default", False),
161
+ )
162
+ result = runner.invoke(app, ["login", "--from-browser"])
163
+ assert result.exit_code == 2
164
+ normalized = " ".join(result.stdout.split()) # Rich wraps lines
165
+ assert "not a logged-in avios session" in normalized
166
+
167
+
145
168
  def test_login_error(monkeypatch: pytest.MonkeyPatch) -> None:
146
- def boom(session: Any, browser: str) -> int:
169
+ def boom(session: Any, browser: str, profile: str | None = None) -> ImportResult:
147
170
  raise LoginError("nope")
148
171
 
149
172
  monkeypatch.setattr("avios.cli.import_from_browser", boom)
@@ -34,10 +34,10 @@ def _client(settings: Settings, routes: dict[str, Any]) -> AviosClient:
34
34
 
35
35
 
36
36
  def test_get_balance(settings: Settings, load_fixture: Callable[[str], Any]) -> None:
37
- client = _client(settings, {endpoints.BALANCE: load_fixture("balance.json")})
37
+ client = _client(settings, {endpoints.ACCOUNTS: load_fixture("balance.json")})
38
38
  balance = client.get_balance()
39
39
  assert balance.balance == 75751
40
- assert balance.household_avios_balance == 75752
40
+ assert balance.household == 112430
41
41
 
42
42
 
43
43
  def test_get_transactions_from_bare_list(
@@ -64,11 +64,11 @@ def test_get_transactions_from_wrapped_object(settings: Settings) -> None:
64
64
  assert txns[0].as_dict()["avios"] == 100
65
65
 
66
66
 
67
- def test_get_accounts(settings: Settings, load_fixture: Callable[[str], Any]) -> None:
68
- client = _client(settings, {endpoints.ACCOUNTS: load_fixture("accounts.json")})
69
- accounts = client.get_accounts()
70
- assert len(accounts) == 1
71
- assert accounts[0].as_dict()["programme"] == "British Airways Executive Club"
67
+ def test_get_profile(settings: Settings) -> None:
68
+ payload = {"idToken": "jwt", "tokenContent": {"name": "Alex"}}
69
+ client = _client(settings, {endpoints.AUTH_USER: payload})
70
+ profile = client.get_profile()
71
+ assert profile.as_dict()["tokenContent"]["name"] == "Alex"
72
72
 
73
73
 
74
74
  def test_raw_normalises_leading_slash(settings: Settings) -> None:
@@ -77,7 +77,7 @@ def test_raw_normalises_leading_slash(settings: Settings) -> None:
77
77
 
78
78
 
79
79
  def test_expired_session_propagates(settings: Settings) -> None:
80
- client = _client(settings, {endpoints.BALANCE: 302})
80
+ client = _client(settings, {endpoints.ACCOUNTS: 302})
81
81
  with pytest.raises(SessionExpired):
82
82
  client.get_balance()
83
83
 
@@ -8,20 +8,22 @@ from typing import Any
8
8
  from avios.models import Balance, Transaction
9
9
 
10
10
 
11
- def test_balance_parses_confirmed_shape(load_fixture: Callable[[str], Any]) -> None:
11
+ def test_balance_parses_shell_shape(load_fixture: Callable[[str], Any]) -> None:
12
12
  balance = Balance.model_validate(load_fixture("balance.json"))
13
13
  assert balance.balance == 75751
14
- assert balance.household_avios_balance == 75752
14
+ assert balance.individual == 75751
15
+ assert balance.household == 112430
15
16
 
16
17
 
17
- def test_balance_alias_round_trips() -> None:
18
- balance = Balance(balance=100, household_avios_balance=200)
19
- assert balance.as_dict() == {"balance": 100, "householdAviosBalance": 200}
18
+ def test_balance_round_trips() -> None:
19
+ balance = Balance(balance=100, individual=100, household=200)
20
+ assert balance.as_dict() == {"balance": 100, "individual": 100, "household": 200}
20
21
 
21
22
 
22
- def test_balance_household_is_optional() -> None:
23
+ def test_balance_extras_are_optional() -> None:
23
24
  balance = Balance.model_validate({"balance": 42})
24
- assert balance.household_avios_balance is None
25
+ assert balance.individual is None
26
+ assert balance.household is None
25
27
 
26
28
 
27
29
  def test_extra_fields_are_preserved() -> None:
@@ -15,7 +15,7 @@ from avios.tui.widgets import BalanceDisplay
15
15
 
16
16
  class FakeClient:
17
17
  def get_balance(self) -> Balance:
18
- return Balance(balance=100, household_avios_balance=200)
18
+ return Balance(balance=100, individual=100, household=200)
19
19
 
20
20
  def get_transactions(self, limit: int | None = None) -> list[Transaction]:
21
21
  return [
@@ -1,3 +0,0 @@
1
- [
2
- {"programme": "British Airways Executive Club", "balance": 75751}
3
- ]
@@ -1 +0,0 @@
1
- {"balance": 75751, "householdAviosBalance": 75752}
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