avios-cli 0.1.5__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. {avios_cli-0.1.5 → avios_cli-0.2.0}/CHANGELOG.md +36 -1
  2. {avios_cli-0.1.5 → avios_cli-0.2.0}/PKG-INFO +8 -9
  3. {avios_cli-0.1.5 → avios_cli-0.2.0}/README.md +7 -8
  4. {avios_cli-0.1.5 → avios_cli-0.2.0}/src/avios/__init__.py +1 -1
  5. {avios_cli-0.1.5 → avios_cli-0.2.0}/src/avios/auth.py +4 -2
  6. {avios_cli-0.1.5 → avios_cli-0.2.0}/src/avios/cli.py +54 -39
  7. {avios_cli-0.1.5 → avios_cli-0.2.0}/src/avios/client.py +15 -11
  8. {avios_cli-0.1.5 → avios_cli-0.2.0}/src/avios/config.py +3 -0
  9. {avios_cli-0.1.5 → avios_cli-0.2.0}/src/avios/models.py +23 -7
  10. {avios_cli-0.1.5 → avios_cli-0.2.0}/src/avios/session.py +2 -0
  11. {avios_cli-0.1.5 → avios_cli-0.2.0}/src/avios/tui/app.py +17 -10
  12. {avios_cli-0.1.5 → avios_cli-0.2.0}/src/avios/tui/widgets.py +2 -2
  13. avios_cli-0.2.0/tests/fixtures/balance.json +1 -0
  14. avios_cli-0.2.0/tests/fixtures/transactions.json +25 -0
  15. {avios_cli-0.1.5 → avios_cli-0.2.0}/tests/test_cli.py +38 -15
  16. avios_cli-0.2.0/tests/test_client.py +81 -0
  17. avios_cli-0.2.0/tests/test_models.py +53 -0
  18. {avios_cli-0.1.5 → avios_cli-0.2.0}/tests/test_session.py +1 -0
  19. {avios_cli-0.1.5 → avios_cli-0.2.0}/tests/test_tui.py +8 -4
  20. avios_cli-0.1.5/tests/fixtures/accounts.json +0 -3
  21. avios_cli-0.1.5/tests/fixtures/balance.json +0 -1
  22. avios_cli-0.1.5/tests/fixtures/transactions.json +0 -5
  23. avios_cli-0.1.5/tests/test_client.py +0 -89
  24. avios_cli-0.1.5/tests/test_models.py +0 -32
  25. {avios_cli-0.1.5 → avios_cli-0.2.0}/.editorconfig +0 -0
  26. {avios_cli-0.1.5 → avios_cli-0.2.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  27. {avios_cli-0.1.5 → avios_cli-0.2.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  28. {avios_cli-0.1.5 → avios_cli-0.2.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  29. {avios_cli-0.1.5 → avios_cli-0.2.0}/.github/dependabot.yml +0 -0
  30. {avios_cli-0.1.5 → avios_cli-0.2.0}/.github/workflows/ci.yml +0 -0
  31. {avios_cli-0.1.5 → avios_cli-0.2.0}/.github/workflows/publish.yml +0 -0
  32. {avios_cli-0.1.5 → avios_cli-0.2.0}/.gitignore +0 -0
  33. {avios_cli-0.1.5 → avios_cli-0.2.0}/.pre-commit-config.yaml +0 -0
  34. {avios_cli-0.1.5 → avios_cli-0.2.0}/.python-version +0 -0
  35. {avios_cli-0.1.5 → avios_cli-0.2.0}/CONTRIBUTING.md +0 -0
  36. {avios_cli-0.1.5 → avios_cli-0.2.0}/LICENSE +0 -0
  37. {avios_cli-0.1.5 → avios_cli-0.2.0}/SECURITY.md +0 -0
  38. {avios_cli-0.1.5 → avios_cli-0.2.0}/docs/dashboard.svg +0 -0
  39. {avios_cli-0.1.5 → avios_cli-0.2.0}/pyproject.toml +0 -0
  40. {avios_cli-0.1.5 → avios_cli-0.2.0}/scripts/screenshot.py +0 -0
  41. {avios_cli-0.1.5 → avios_cli-0.2.0}/src/avios/endpoints.py +0 -0
  42. {avios_cli-0.1.5 → avios_cli-0.2.0}/src/avios/tui/__init__.py +0 -0
  43. {avios_cli-0.1.5 → avios_cli-0.2.0}/src/avios/tui/art.py +0 -0
  44. {avios_cli-0.1.5 → avios_cli-0.2.0}/src/avios/tui/styles.tcss +0 -0
  45. {avios_cli-0.1.5 → avios_cli-0.2.0}/tests/conftest.py +0 -0
  46. {avios_cli-0.1.5 → avios_cli-0.2.0}/tests/test_auth.py +0 -0
  47. {avios_cli-0.1.5 → avios_cli-0.2.0}/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.2.0] - 2026-07-24
10
+
11
+ ### Added
12
+ - **`transactions`, `pending` and `overview` now work.** The manage-avios API
13
+ only needed an `x-avios-opco` header (not a separate session, as previously
14
+ thought) — it's now sent automatically. `transactions` renders date /
15
+ description / Avios (coloured +/-) / type and respects `--limit`; the TUI shows
16
+ real transactions too.
17
+ - Typed `Transaction` model (identifier, dateProcessed, description, type,
18
+ partner, amount, categories) and a `NamedRef` helper.
19
+ - `AVIOS_OPCO` setting (default `BAEC` = British Airways Executive Club) for other
20
+ Avios programmes.
21
+
22
+ ## [0.1.6] - 2026-07-24
23
+
24
+ ### Fixed
25
+ - **`balance` finally works after login.** It (and `whoami`) were querying the
26
+ `manage-avios`/`spend-avios` micro-app APIs, which need a separate per-app
27
+ session and returned 401 ("Session expired") even when you were logged in.
28
+ Probing a real session showed the balance is served by
29
+ `/shell/api/users/current/accounts` (`{balance, individual, household}`) and the
30
+ profile by `/auth-gateway/user` — both of which authenticate with the browser
31
+ session. `balance`/`whoami` now use those.
32
+
33
+ ### Changed
34
+ - `balance` shows total / individual / household; `whoami` shows name, tier,
35
+ membership and email.
36
+ - The TUI shows your balance even if transactions are unavailable.
37
+ - `transactions`, `pending` and `overview` are marked **experimental** — they need
38
+ the `manage-avios` app session, which the cookie/SSO login can't yet establish.
39
+ - Removed the `accounts` command (its endpoint is the balance source, now shown by
40
+ `balance`).
41
+
9
42
  ## [0.1.5] - 2026-07-24
10
43
 
11
44
  ### Fixed
@@ -87,7 +120,9 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
87
120
  - Reward-flight **availability** search is not yet implemented (needs a British
88
121
  Airways capture).
89
122
 
90
- [Unreleased]: https://github.com/alexechoi/avios-cli/compare/v0.1.5...HEAD
123
+ [Unreleased]: https://github.com/alexechoi/avios-cli/compare/v0.2.0...HEAD
124
+ [0.2.0]: https://github.com/alexechoi/avios-cli/compare/v0.1.6...v0.2.0
125
+ [0.1.6]: https://github.com/alexechoi/avios-cli/compare/v0.1.5...v0.1.6
91
126
  [0.1.5]: https://github.com/alexechoi/avios-cli/compare/v0.1.4...v0.1.5
92
127
  [0.1.4]: https://github.com/alexechoi/avios-cli/compare/v0.1.3...v0.1.4
93
128
  [0.1.3]: https://github.com/alexechoi/avios-cli/compare/v0.1.2...v0.1.3
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: avios-cli
3
- Version: 0.1.5
3
+ Version: 0.2.0
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
@@ -119,17 +119,16 @@ expires after ~a day; just run `avios login` again. Run `avios logout` to remove
119
119
  ## Usage
120
120
 
121
121
  ```bash
122
- avios balance # your Avios balance
123
- avios transactions --limit 20 # recent transactions
122
+ avios balance # total / individual / household Avios
123
+ avios transactions --limit 20 # recent transactions (date, description, Avios, type)
124
124
  avios pending # pending Avios
125
- avios accounts # linked loyalty accounts
126
- avios overview # dashboard summary (raw JSON)
127
- avios whoami # your profile (raw JSON)
128
- avios raw /manage-avios/api/user/current # hit any endpoint directly
125
+ avios overview # dashboard summary
126
+ avios whoami # name, tier, membership, email
127
+ avios raw /shell/api/users/current/accounts # hit any endpoint directly
129
128
  ```
130
129
 
131
- Add `--json` to `balance`, `transactions`, `pending` or `accounts` for scriptable
132
- output.
130
+ Add `--json` to `balance`, `transactions`, `pending` or `whoami` for scriptable
131
+ output. Non-BA Avios programmes can set `AVIOS_OPCO` (default `BAEC`).
133
132
 
134
133
  ## TUI
135
134
 
@@ -84,17 +84,16 @@ expires after ~a day; just run `avios login` again. Run `avios logout` to remove
84
84
  ## Usage
85
85
 
86
86
  ```bash
87
- avios balance # your Avios balance
88
- avios transactions --limit 20 # recent transactions
87
+ avios balance # total / individual / household Avios
88
+ avios transactions --limit 20 # recent transactions (date, description, Avios, type)
89
89
  avios pending # pending Avios
90
- avios accounts # linked loyalty accounts
91
- avios overview # dashboard summary (raw JSON)
92
- avios whoami # your profile (raw JSON)
93
- avios raw /manage-avios/api/user/current # hit any endpoint directly
90
+ avios overview # dashboard summary
91
+ avios whoami # name, tier, membership, email
92
+ avios raw /shell/api/users/current/accounts # hit any endpoint directly
94
93
  ```
95
94
 
96
- Add `--json` to `balance`, `transactions`, `pending` or `accounts` for scriptable
97
- output.
95
+ Add `--json` to `balance`, `transactions`, `pending` or `whoami` for scriptable
96
+ output. Non-BA Avios programmes can set `AVIOS_OPCO` (default `BAEC`).
98
97
 
99
98
  ## TUI
100
99
 
@@ -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.5"
6
+ __version__ = "0.2.0"
@@ -149,7 +149,9 @@ def _wait_for_auth(ctx: Any, page: Any, base_url: str, timeout_ms: int) -> bool:
149
149
 
150
150
  def _is_authenticated(ctx: Any, base_url: str) -> bool:
151
151
  try:
152
- 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)
153
155
  except Exception:
154
156
  return False
155
157
 
@@ -257,7 +259,7 @@ def _cookies_authenticate(settings: Settings, cookies: list[dict[str, Any]]) ->
257
259
  "cookie": header,
258
260
  },
259
261
  ) as client:
260
- return client.get(endpoints.AUTH_USER).status_code == 200
262
+ return client.get(endpoints.ACCOUNTS).status_code == 200
261
263
  except httpx.HTTPError:
262
264
  return False
263
265
 
@@ -20,7 +20,7 @@ from rich.table import Table
20
20
  from avios import __version__
21
21
  from avios.auth import LoginError, import_from_browser, login_via_browser
22
22
  from avios.client import AviosClient
23
- from avios.models import Balance
23
+ from avios.models import Balance, Transaction
24
24
  from avios.session import NotAuthenticated, Session, SessionExpired
25
25
 
26
26
  app = typer.Typer(
@@ -78,19 +78,6 @@ def _print_json(data: Any) -> None:
78
78
  console.print_json(data=data)
79
79
 
80
80
 
81
- def _render_records(records: list[dict[str, Any]], title: str) -> None:
82
- if not records:
83
- console.print(f"[dim]No {title.lower()}.[/]")
84
- return
85
- keys = [k for k, v in records[0].items() if not isinstance(v, dict | list)][:6]
86
- table = Table(title=title)
87
- for key in keys:
88
- table.add_column(key)
89
- for record in records:
90
- table.add_row(*[str(record.get(key, "")) for key in keys])
91
- console.print(table)
92
-
93
-
94
81
  # -- auth commands -----------------------------------------------------------
95
82
  @app.command()
96
83
  def login(
@@ -165,8 +152,29 @@ def balance(json_out: bool = JSON_OPTION) -> None:
165
152
  def _render_balance(result: Balance) -> None:
166
153
  table = Table(show_header=False, box=None)
167
154
  table.add_row("[bold]Avios[/]", f"[bold cyan]{result.balance:,}[/]")
168
- if result.household_avios_balance is not None:
169
- table.add_row("Household", f"{result.household_avios_balance:,}")
155
+ if result.individual is not None:
156
+ table.add_row("Individual", f"{result.individual:,}")
157
+ if result.household is not None:
158
+ table.add_row("Household", f"{result.household:,}")
159
+ console.print(table)
160
+
161
+
162
+ def _render_transactions(items: list[Transaction], title: str) -> None:
163
+ if not items:
164
+ console.print(f"[dim]No {title.lower()}.[/]")
165
+ return
166
+ table = Table(title=title)
167
+ table.add_column("Date")
168
+ table.add_column("Description")
169
+ table.add_column("Avios", justify="right")
170
+ table.add_column("Type")
171
+ for txn in items:
172
+ date = (txn.date_processed or "")[:10]
173
+ desc = (txn.description or "").splitlines()[0][:44] if txn.description else ""
174
+ amount = f"{txn.amount:+,}" if txn.amount is not None else ""
175
+ colour = "green" if (txn.amount or 0) >= 0 else "red"
176
+ kind = txn.type.value if txn.type else ""
177
+ table.add_row(date, desc, f"[{colour}]{amount}[/]", kind or "")
170
178
  console.print(table)
171
179
 
172
180
 
@@ -178,11 +186,10 @@ def transactions(
178
186
  """List recent Avios transactions."""
179
187
  with _handle_errors():
180
188
  items = _client().get_transactions(limit=limit)
181
- records = [item.as_dict() for item in items]
182
189
  if json_out:
183
- _print_json(records)
190
+ _print_json([item.as_dict() for item in items])
184
191
  return
185
- _render_records(records, "Transactions")
192
+ _render_transactions(items, "Transactions")
186
193
 
187
194
 
188
195
  @app.command()
@@ -190,39 +197,47 @@ def pending(json_out: bool = JSON_OPTION) -> None:
190
197
  """List pending Avios transactions."""
191
198
  with _handle_errors():
192
199
  items = _client().get_pending_transactions()
193
- records = [item.as_dict() for item in items]
194
200
  if json_out:
195
- _print_json(records)
201
+ _print_json([item.as_dict() for item in items])
196
202
  return
197
- _render_records(records, "Pending")
198
-
199
-
200
- @app.command()
201
- def accounts(json_out: bool = JSON_OPTION) -> None:
202
- """List linked loyalty accounts."""
203
- with _handle_errors():
204
- items = _client().get_accounts()
205
- records = [item.as_dict() for item in items]
206
- if json_out:
207
- _print_json(records)
208
- return
209
- _render_records(records, "Accounts")
203
+ _render_transactions(items, "Pending")
210
204
 
211
205
 
212
206
  @app.command()
213
207
  def overview() -> None:
214
- """Show the dashboard overview (raw JSON; shape not yet finalised)."""
208
+ """Show the dashboard overview (raw JSON)."""
215
209
  with _handle_errors():
216
210
  result = _client().get_overview()
217
211
  _print_json(result.as_dict())
218
212
 
219
213
 
220
214
  @app.command()
221
- def whoami() -> None:
222
- """Show your profile (raw JSON; shape not yet finalised)."""
215
+ def whoami(json_out: bool = JSON_OPTION) -> None:
216
+ """Show your profile (name, tier, membership)."""
223
217
  with _handle_errors():
224
- result = _client().get_profile()
225
- _print_json(result.as_dict())
218
+ data = _client().get_profile().as_dict()
219
+ if json_out:
220
+ _print_json(data)
221
+ return
222
+ claims = data.get("tokenContent", {})
223
+
224
+ def claim(key: str) -> str:
225
+ return str(claims.get(f"https://avios.com/{key}", "") or "")
226
+
227
+ table = Table(show_header=False, box=None)
228
+ name = claims.get("name") or f"{claims.get('given_name', '')} {claims.get('family_name', '')}"
229
+ for label, value in (
230
+ ("Name", str(name).strip()),
231
+ ("Tier", claim("customer_tier_name")),
232
+ ("Membership", claim("membership_id")),
233
+ ("Email", str(claims.get("email", "") or "")),
234
+ ):
235
+ if value:
236
+ table.add_row(f"[bold]{label}[/]", value)
237
+ if table.row_count:
238
+ console.print(table)
239
+ else:
240
+ _print_json(data)
226
241
 
227
242
 
228
243
  @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,23 +36,27 @@ 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
- def get_transactions(self, limit: int | None = None) -> list[Transaction]:
52
- payload = self.session.get_json(endpoints.TRANSACTIONS)
53
- items = _extract_list(payload)
50
+ def get_transactions(self, limit: int = 50) -> list[Transaction]:
51
+ # `offset` is a server-side page hint; slice to `limit` for an exact count.
52
+ offset = limit if limit and limit > 0 else 1000
53
+ path = f"{endpoints.TRANSACTIONS}?startRecord=1&offset={offset}&status=completed"
54
+ payload = self.session.get_json(path)
55
+ items = (
56
+ payload.get("transactions", []) if isinstance(payload, dict) else _extract_list(payload)
57
+ )
54
58
  transactions = [Transaction.model_validate(item) for item in items]
55
- return transactions[:limit] if limit is not None else transactions
59
+ return transactions[:limit] if limit and limit > 0 else transactions
56
60
 
57
61
  def get_pending_transactions(self) -> list[Transaction]:
58
62
  payload = self.session.get_json(endpoints.TRANSACTIONS_PENDING)
@@ -39,6 +39,9 @@ class Settings(BaseSettings):
39
39
  user_agent: str = DEFAULT_USER_AGENT
40
40
  config_dir: Path = Field(default_factory=_default_config_dir)
41
41
  request_timeout: float = 20.0
42
+ # Operating company code sent as `x-avios-opco`; required by the manage-avios
43
+ # API. "BAEC" = British Airways Executive Club (override with AVIOS_OPCO).
44
+ opco: str = "BAEC"
42
45
 
43
46
  @property
44
47
  def state_path(self) -> Path:
@@ -25,18 +25,34 @@ 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
- class Transaction(AviosModel):
35
- """A single Avios statement entry.
38
+ class NamedRef(AviosModel):
39
+ """A ``{value, id}`` reference (transaction type, partner, ...)."""
36
40
 
37
- Concrete fields are pinned once live transaction payloads are observed; until
38
- then every key is preserved via ``extra='allow'``.
39
- """
41
+ value: str | None = None
42
+ id: str | None = None
43
+
44
+
45
+ class Transaction(AviosModel):
46
+ """A single Avios statement entry (from the manage-avios transactions API)."""
47
+
48
+ identifier: str | None = None
49
+ date_processed: str | None = Field(default=None, alias="dateProcessed")
50
+ description: str | None = None
51
+ type: NamedRef | None = None
52
+ partner: NamedRef | None = None
53
+ amount: int | None = None
54
+ categories: list[str] = Field(default_factory=list)
55
+ record_index: int | None = Field(default=None, alias="recordIndex")
40
56
 
41
57
 
42
58
  class Account(AviosModel):
@@ -95,6 +95,8 @@ class Session:
95
95
  "accept": "application/json, text/plain, */*",
96
96
  "user-agent": self.settings.user_agent,
97
97
  "referer": f"{self.settings.base_url}/manage-avios/dashboard",
98
+ # Required by the manage-avios API (401 without it).
99
+ "x-avios-opco": self.settings.opco,
98
100
  "cookie": cookie,
99
101
  }
100
102
  return httpx.Client(
@@ -21,7 +21,6 @@ from avios.tui.art import banner_text
21
21
  from avios.tui.widgets import BalanceDisplay
22
22
 
23
23
  TRANSACTIONS_TO_SHOW = 50
24
- MAX_COLUMNS = 6
25
24
 
26
25
 
27
26
  class AviosApp(App[None]):
@@ -61,9 +60,6 @@ class AviosApp(App[None]):
61
60
  async def _load(self) -> None:
62
61
  try:
63
62
  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
63
  except (NotAuthenticated, SessionExpired) as exc:
68
64
  self._show_error(f"{exc} — run `avios login`")
69
65
  return
@@ -71,6 +67,15 @@ class AviosApp(App[None]):
71
67
  self._show_error(f"Request failed: {exc}")
72
68
  return
73
69
  self.query_one("#balance", BalanceDisplay).update_balance(balance)
70
+
71
+ # Transactions are experimental (manage-avios session); degrade gracefully
72
+ # so the balance still shows if they're unavailable.
73
+ try:
74
+ transactions = await asyncio.to_thread(
75
+ self._client.get_transactions, TRANSACTIONS_TO_SHOW
76
+ )
77
+ except (NotAuthenticated, SessionExpired, httpx.HTTPError):
78
+ transactions = []
74
79
  self._populate_transactions(transactions)
75
80
 
76
81
  def _show_error(self, message: str) -> None:
@@ -81,15 +86,17 @@ class AviosApp(App[None]):
81
86
  table = self.query_one("#transactions", DataTable)
82
87
  table.loading = False
83
88
  table.clear(columns=True)
84
- records = [txn.as_dict() for txn in transactions]
85
- if not records:
89
+ if not transactions:
86
90
  table.add_column("info")
87
91
  table.add_row("No transactions")
88
92
  return
89
- keys = [k for k, v in records[0].items() if not isinstance(v, dict | list)][:MAX_COLUMNS]
90
- table.add_columns(*keys)
91
- for record in records:
92
- table.add_row(*[str(record.get(key, "")) for key in keys])
93
+ table.add_columns("Date", "Description", "Avios", "Type")
94
+ for txn in transactions:
95
+ date = (txn.date_processed or "")[:10]
96
+ desc = (txn.description or "").splitlines()[0][:44] if txn.description else ""
97
+ amount = f"{txn.amount:+,}" if txn.amount is not None else ""
98
+ kind = txn.type.value if txn.type else ""
99
+ table.add_row(date, desc, amount, kind)
93
100
 
94
101
 
95
102
  def run(client: AviosClient | None = None) -> 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}
@@ -0,0 +1,25 @@
1
+ {
2
+ "totalRecords": 2,
3
+ "transactions": [
4
+ {
5
+ "identifier": "2139440378",
6
+ "dateProcessed": "2026-07-21T11:37:35.000Z",
7
+ "description": "UBER",
8
+ "type": {"value": "Collection", "id": "DV_COL"},
9
+ "partner": {"value": "UBER", "id": "UBR"},
10
+ "amount": 50,
11
+ "categories": ["collectingAvios"],
12
+ "recordIndex": 1
13
+ },
14
+ {
15
+ "identifier": "2023965591",
16
+ "dateProcessed": "2025-12-10T21:37:04.000Z",
17
+ "description": "Tokyo Haneda to London Heathrow\nClass: Club World\nBA0008",
18
+ "type": {"value": "Reward Flight", "id": "FLT_RWD"},
19
+ "partner": {"value": "British Airways", "id": "BA"},
20
+ "amount": -52000,
21
+ "categories": ["flights"],
22
+ "recordIndex": 2
23
+ }
24
+ ]
25
+ }
@@ -15,7 +15,7 @@ from typer.testing import CliRunner
15
15
  from avios import __version__
16
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,26 +28,46 @@ 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
- def get_transactions(self, limit: int | None = None) -> list[Transaction]:
33
+ def get_transactions(self, limit: int = 50) -> list[Transaction]:
34
34
  items = [
35
- Transaction.model_validate({"date": "2026-07-01", "avios": 100}),
36
- Transaction.model_validate({"date": "2026-06-01", "avios": 50}),
35
+ Transaction.model_validate(
36
+ {
37
+ "dateProcessed": "2026-07-01T09:00:00.000Z",
38
+ "description": "UBER",
39
+ "type": {"value": "Collection", "id": "DV_COL"},
40
+ "amount": 100,
41
+ }
42
+ ),
43
+ Transaction.model_validate(
44
+ {
45
+ "dateProcessed": "2026-06-01T09:00:00.000Z",
46
+ "description": "Deliveroo UK",
47
+ "type": {"value": "BA Avios eStore", "id": "BA_AV_ESTORE"},
48
+ "amount": 50,
49
+ }
50
+ ),
37
51
  ]
38
- return items[:limit] if limit is not None else items
52
+ return items[:limit]
39
53
 
40
54
  def get_pending_transactions(self) -> list[Transaction]:
41
55
  return []
42
56
 
43
- def get_accounts(self) -> list[Account]:
44
- return [Account.model_validate({"programme": "BAEC", "balance": 100})]
45
-
46
57
  def get_overview(self) -> Overview:
47
58
  return Overview.model_validate({"tier": "Blue"})
48
59
 
49
60
  def get_profile(self) -> Profile:
50
- return Profile.model_validate({"firstName": "Alex"})
61
+ return Profile.model_validate(
62
+ {
63
+ "idToken": "jwt",
64
+ "tokenContent": {
65
+ "name": "Alex Choi",
66
+ "https://avios.com/customer_tier_name": "Gold",
67
+ "https://avios.com/membership_id": "BA123",
68
+ },
69
+ }
70
+ )
51
71
 
52
72
  def raw(self, path: str) -> dict[str, str]:
53
73
  return {"path": path}
@@ -81,7 +101,7 @@ def test_balance_json(fake_client: None) -> None:
81
101
  result = runner.invoke(app, ["balance", "--json"])
82
102
  assert result.exit_code == 0
83
103
  assert '"balance"' in result.stdout
84
- assert '"householdAviosBalance"' in result.stdout
104
+ assert '"household"' in result.stdout
85
105
 
86
106
 
87
107
  def test_transactions_respects_limit(fake_client: None) -> None:
@@ -94,13 +114,16 @@ def test_transactions_respects_limit(fake_client: None) -> None:
94
114
  def test_transactions_json(fake_client: None) -> None:
95
115
  result = runner.invoke(app, ["transactions", "--json"])
96
116
  assert result.exit_code == 0
97
- assert '"avios"' in result.stdout
117
+ assert '"amount"' in result.stdout
118
+ assert '"dateProcessed"' in result.stdout
98
119
 
99
120
 
100
- def test_accounts(fake_client: None) -> None:
101
- result = runner.invoke(app, ["accounts"])
121
+ def test_whoami(fake_client: None) -> None:
122
+ result = runner.invoke(app, ["whoami"])
102
123
  assert result.exit_code == 0
103
- assert "BAEC" in result.stdout
124
+ out = " ".join(result.stdout.split())
125
+ assert "Alex Choi" in out
126
+ assert "Gold" in out
104
127
 
105
128
 
106
129
  def test_raw(fake_client: None) -> None:
@@ -0,0 +1,81 @@
1
+ """Tests for the typed API client (balance, transactions, profile, ...)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ import httpx
9
+ import pytest
10
+
11
+ from avios import endpoints
12
+ from avios.client import AviosClient, _extract_list
13
+ from avios.config import Settings
14
+ from avios.session import Session, SessionExpired
15
+
16
+
17
+ def _client(
18
+ settings: Settings, routes: dict[str, Any], captured: list[httpx.Request] | None = None
19
+ ) -> AviosClient:
20
+ def handler(request: httpx.Request) -> httpx.Response:
21
+ if captured is not None:
22
+ captured.append(request)
23
+ if request.url.path not in routes:
24
+ return httpx.Response(404, json={})
25
+ body = routes[request.url.path]
26
+ if isinstance(body, int): # treat an int as a status code (e.g. 302)
27
+ return httpx.Response(body, headers={"location": "/auth-gateway/login"})
28
+ return httpx.Response(200, json=body)
29
+
30
+ session = Session(settings, transport=httpx.MockTransport(handler))
31
+ session.save_cookies([{"name": "appSession", "value": "x", "domain": "www.avios.com"}])
32
+ return AviosClient(session)
33
+
34
+
35
+ def test_get_balance(settings: Settings, load_fixture: Callable[[str], Any]) -> None:
36
+ client = _client(settings, {endpoints.ACCOUNTS: load_fixture("balance.json")})
37
+ balance = client.get_balance()
38
+ assert balance.balance == 75751
39
+ assert balance.household == 112430
40
+
41
+
42
+ def test_get_transactions(settings: Settings, load_fixture: Callable[[str], Any]) -> None:
43
+ captured: list[httpx.Request] = []
44
+ client = _client(
45
+ settings, {endpoints.TRANSACTIONS: load_fixture("transactions.json")}, captured
46
+ )
47
+ txns = client.get_transactions(limit=2)
48
+ assert len(txns) == 2
49
+ assert txns[0].type is not None and txns[0].type.value == "Collection"
50
+ assert txns[0].amount == 50
51
+ assert txns[1].amount == -52000
52
+ # Sends the required opco header and the limit as offset.
53
+ req = captured[-1]
54
+ assert req.headers["x-avios-opco"] == "BAEC"
55
+ assert "offset=2" in str(req.url)
56
+ assert "status=completed" in str(req.url)
57
+
58
+
59
+ def test_get_profile(settings: Settings) -> None:
60
+ payload = {"idToken": "jwt", "tokenContent": {"name": "Alex"}}
61
+ client = _client(settings, {endpoints.AUTH_USER: payload})
62
+ profile = client.get_profile()
63
+ assert profile.as_dict()["tokenContent"]["name"] == "Alex"
64
+
65
+
66
+ def test_raw_normalises_leading_slash(settings: Settings) -> None:
67
+ client = _client(settings, {"/manage-avios/api/user/current": {"id": 1}})
68
+ assert client.raw("manage-avios/api/user/current") == {"id": 1}
69
+
70
+
71
+ def test_expired_session_propagates(settings: Settings) -> None:
72
+ client = _client(settings, {endpoints.ACCOUNTS: 302})
73
+ with pytest.raises(SessionExpired):
74
+ client.get_balance()
75
+
76
+
77
+ def test_extract_list_variants() -> None:
78
+ assert _extract_list([1, 2]) == [1, 2]
79
+ assert _extract_list({"items": [1]}) == [1]
80
+ assert _extract_list({"nope": 1}) == []
81
+ assert _extract_list("string") == []
@@ -0,0 +1,53 @@
1
+ """Tests for the pydantic models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ from avios.models import Balance, Transaction
9
+
10
+
11
+ def test_balance_parses_shell_shape(load_fixture: Callable[[str], Any]) -> None:
12
+ balance = Balance.model_validate(load_fixture("balance.json"))
13
+ assert balance.balance == 75751
14
+ assert balance.individual == 75751
15
+ assert balance.household == 112430
16
+
17
+
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}
21
+
22
+
23
+ def test_balance_extras_are_optional() -> None:
24
+ balance = Balance.model_validate({"balance": 42})
25
+ assert balance.individual is None
26
+ assert balance.household is None
27
+
28
+
29
+ def test_extra_fields_are_preserved() -> None:
30
+ txn = Transaction.model_validate({"date": "2026-07-01", "avios": 500, "unknown": "x"})
31
+ dumped = txn.as_dict()
32
+ assert dumped["unknown"] == "x"
33
+
34
+
35
+ def test_transaction_parses_real_shape() -> None:
36
+ txn = Transaction.model_validate(
37
+ {
38
+ "identifier": "2139440378",
39
+ "dateProcessed": "2026-07-21T11:37:35.000Z",
40
+ "description": "UBER",
41
+ "type": {"value": "Collection", "id": "DV_COL"},
42
+ "partner": {"value": "UBER", "id": "UBR"},
43
+ "amount": 50,
44
+ "categories": ["collectingAvios"],
45
+ "recordIndex": 1,
46
+ }
47
+ )
48
+ assert txn.identifier == "2139440378"
49
+ assert txn.date_processed == "2026-07-21T11:37:35.000Z"
50
+ assert txn.type is not None and txn.type.value == "Collection"
51
+ assert txn.partner is not None and txn.partner.id == "UBR"
52
+ assert txn.amount == 50
53
+ assert txn.categories == ["collectingAvios"]
@@ -67,6 +67,7 @@ def test_client_requires_authentication(settings: Settings) -> None:
67
67
  def test_get_json_returns_parsed_body(settings: Settings) -> None:
68
68
  def handler(request: httpx.Request) -> httpx.Response:
69
69
  assert request.headers["cookie"] == "appSession=abc"
70
+ assert request.headers["x-avios-opco"] == "BAEC"
70
71
  return httpx.Response(200, json={"balance": 75751})
71
72
 
72
73
  session = _session(settings, httpx.MockTransport(handler))
@@ -15,12 +15,16 @@ 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
- def get_transactions(self, limit: int | None = None) -> list[Transaction]:
20
+ def get_transactions(self, limit: int = 50) -> list[Transaction]:
21
21
  return [
22
- Transaction.model_validate({"date": "2026-07-01", "avios": 100}),
23
- Transaction.model_validate({"date": "2026-06-01", "avios": 50}),
22
+ Transaction.model_validate(
23
+ {"dateProcessed": "2026-07-01T09:00:00Z", "description": "UBER", "amount": 100}
24
+ ),
25
+ Transaction.model_validate(
26
+ {"dateProcessed": "2026-06-01T09:00:00Z", "description": "Flight", "amount": -5000}
27
+ ),
24
28
  ]
25
29
 
26
30
 
@@ -1,3 +0,0 @@
1
- [
2
- {"programme": "British Airways Executive Club", "balance": 75751}
3
- ]
@@ -1 +0,0 @@
1
- {"balance": 75751, "householdAviosBalance": 75752}
@@ -1,5 +0,0 @@
1
- [
2
- {"date": "2026-07-01", "description": "Flight BA286 SFO-LHR", "avios": 4200},
3
- {"date": "2026-06-15", "description": "Tesco groceries", "avios": 150},
4
- {"date": "2026-06-02", "description": "Redemption: LHR-JFK", "avios": -50000}
5
- ]
@@ -1,89 +0,0 @@
1
- """Tests for the typed API client.
2
-
3
- The transactions/accounts fixtures are representative, not confirmed shapes, so
4
- these tests assert on counts and field pass-through (via ``extra='allow'``) rather
5
- than a rigid schema. The balance shape is confirmed.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- from collections.abc import Callable
11
- from typing import Any
12
-
13
- import httpx
14
- import pytest
15
-
16
- from avios import endpoints
17
- from avios.client import AviosClient, _extract_list
18
- from avios.config import Settings
19
- from avios.session import Session, SessionExpired
20
-
21
-
22
- def _client(settings: Settings, routes: dict[str, Any]) -> AviosClient:
23
- def handler(request: httpx.Request) -> httpx.Response:
24
- if request.url.path not in routes:
25
- return httpx.Response(404, json={})
26
- body = routes[request.url.path]
27
- if isinstance(body, int): # treat an int as a status code (e.g. 302)
28
- return httpx.Response(body, headers={"location": "/auth-gateway/login"})
29
- return httpx.Response(200, json=body)
30
-
31
- session = Session(settings, transport=httpx.MockTransport(handler))
32
- session.save_cookies([{"name": "appSession", "value": "x", "domain": "www.avios.com"}])
33
- return AviosClient(session)
34
-
35
-
36
- def test_get_balance(settings: Settings, load_fixture: Callable[[str], Any]) -> None:
37
- client = _client(settings, {endpoints.BALANCE: load_fixture("balance.json")})
38
- balance = client.get_balance()
39
- assert balance.balance == 75751
40
- assert balance.household_avios_balance == 75752
41
-
42
-
43
- def test_get_transactions_from_bare_list(
44
- settings: Settings, load_fixture: Callable[[str], Any]
45
- ) -> None:
46
- client = _client(settings, {endpoints.TRANSACTIONS: load_fixture("transactions.json")})
47
- txns = client.get_transactions()
48
- assert len(txns) == 3
49
- assert txns[0].as_dict()["description"] == "Flight BA286 SFO-LHR"
50
-
51
-
52
- def test_get_transactions_respects_limit(
53
- settings: Settings, load_fixture: Callable[[str], Any]
54
- ) -> None:
55
- client = _client(settings, {endpoints.TRANSACTIONS: load_fixture("transactions.json")})
56
- assert len(client.get_transactions(limit=2)) == 2
57
-
58
-
59
- def test_get_transactions_from_wrapped_object(settings: Settings) -> None:
60
- payload = {"total": 1, "transactions": [{"date": "2026-07-01", "avios": 100}]}
61
- client = _client(settings, {endpoints.TRANSACTIONS: payload})
62
- txns = client.get_transactions()
63
- assert len(txns) == 1
64
- assert txns[0].as_dict()["avios"] == 100
65
-
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"
72
-
73
-
74
- def test_raw_normalises_leading_slash(settings: Settings) -> None:
75
- client = _client(settings, {"/manage-avios/api/user/current": {"id": 1}})
76
- assert client.raw("manage-avios/api/user/current") == {"id": 1}
77
-
78
-
79
- def test_expired_session_propagates(settings: Settings) -> None:
80
- client = _client(settings, {endpoints.BALANCE: 302})
81
- with pytest.raises(SessionExpired):
82
- client.get_balance()
83
-
84
-
85
- def test_extract_list_variants() -> None:
86
- assert _extract_list([1, 2]) == [1, 2]
87
- assert _extract_list({"items": [1]}) == [1]
88
- assert _extract_list({"nope": 1}) == []
89
- assert _extract_list("string") == []
@@ -1,32 +0,0 @@
1
- """Tests for the pydantic models."""
2
-
3
- from __future__ import annotations
4
-
5
- from collections.abc import Callable
6
- from typing import Any
7
-
8
- from avios.models import Balance, Transaction
9
-
10
-
11
- def test_balance_parses_confirmed_shape(load_fixture: Callable[[str], Any]) -> None:
12
- balance = Balance.model_validate(load_fixture("balance.json"))
13
- assert balance.balance == 75751
14
- assert balance.household_avios_balance == 75752
15
-
16
-
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}
20
-
21
-
22
- def test_balance_household_is_optional() -> None:
23
- balance = Balance.model_validate({"balance": 42})
24
- assert balance.household_avios_balance is None
25
-
26
-
27
- def test_extra_fields_are_preserved() -> None:
28
- txn = Transaction.model_validate({"date": "2026-07-01", "avios": 500, "unknown": "x"})
29
- dumped = txn.as_dict()
30
- assert dumped["date"] == "2026-07-01"
31
- assert dumped["avios"] == 500
32
- assert dumped["unknown"] == "x"
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