avios-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
avios/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """avios — a CLI and TUI for avios.com.
2
+
3
+ View your Avios balance, transactions and account details from the terminal.
4
+ """
5
+
6
+ __version__ = "0.1.0"
avios/auth.py ADDED
@@ -0,0 +1,121 @@
1
+ """Browser-assisted login for avios.
2
+
3
+ avios.com has no plain credential API: login is Auth0 "Universal Login" guarded by
4
+ hCaptcha, Akamai Bot Manager and SMS/passkey MFA, so there is no way to POST a
5
+ username/password over HTTP and obtain a session. Instead we let a real browser
6
+ handle the login once and capture the resulting session cookie.
7
+
8
+ Two strategies, both requiring the optional ``login`` extra
9
+ (``pip install "avios-cli[login]"``):
10
+
11
+ - :func:`login_via_browser` — open a Playwright browser, the user logs in
12
+ (password + captcha + MFA), and we grab the cookies. Also needs
13
+ ``playwright install chromium``.
14
+ - :func:`import_from_browser` — read the avios.com cookie straight out of a
15
+ running browser via ``browser_cookie3`` (no popup) if already logged in there.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from collections.abc import Callable, Iterable
21
+ from typing import Any, Protocol
22
+
23
+ from avios.session import Session
24
+
25
+ DASHBOARD_PATH = "/manage-avios/dashboard"
26
+ LOGIN_TIMEOUT_MS = 300_000 # 5 minutes to complete password + captcha + MFA
27
+ SUPPORTED_BROWSERS = ("chrome", "firefox", "edge", "brave", "safari", "chromium")
28
+
29
+
30
+ class LoginError(RuntimeError):
31
+ """Login could not be completed."""
32
+
33
+
34
+ class _RawCookie(Protocol):
35
+ name: str
36
+ value: str
37
+ domain: str
38
+
39
+
40
+ def _to_cookie_dicts(raw: Iterable[_RawCookie]) -> list[dict[str, Any]]:
41
+ """Convert browser_cookie3 / cookiejar entries to our stored cookie format."""
42
+ return [{"name": c.name, "value": c.value, "domain": c.domain} for c in raw]
43
+
44
+
45
+ def _only_avios(cookies: list[dict[str, Any]]) -> list[dict[str, Any]]:
46
+ return [c for c in cookies if "avios.com" in c.get("domain", "")]
47
+
48
+
49
+ def login_via_browser(
50
+ session: Session | None = None,
51
+ *,
52
+ headless: bool = False,
53
+ timeout_ms: int = LOGIN_TIMEOUT_MS,
54
+ ) -> int:
55
+ """Open a browser, wait for the user to log in, and save the session cookie.
56
+
57
+ Returns the number of avios.com cookies captured.
58
+ """
59
+ session = session or Session()
60
+ try:
61
+ from playwright.sync_api import sync_playwright
62
+ except ImportError as exc:
63
+ raise LoginError(
64
+ 'Playwright is required. Install with: pip install "avios-cli[login]" '
65
+ "then: playwright install chromium"
66
+ ) from exc
67
+
68
+ base_url = session.settings.base_url
69
+ with sync_playwright() as pw: # pragma: no cover - requires a real browser
70
+ browser = pw.chromium.launch(headless=headless)
71
+ ctx = browser.new_context(user_agent=session.settings.user_agent)
72
+ page = ctx.new_page()
73
+ page.goto(f"{base_url}{DASHBOARD_PATH}")
74
+ try:
75
+ page.wait_for_url("**/manage-avios/**", timeout=timeout_ms)
76
+ page.wait_for_timeout(1500) # let the session cookie settle
77
+ except Exception as exc:
78
+ browser.close()
79
+ raise LoginError("Did not reach the dashboard in time. Please try again.") from exc
80
+ cookies = list(ctx.cookies())
81
+ browser.close()
82
+
83
+ session.save_cookies(cookies)
84
+ return len(_only_avios(cookies))
85
+
86
+
87
+ def _default_loader(browser: str) -> Callable[[], Iterable[_RawCookie]]:
88
+ """Return a callable that reads avios.com cookies from ``browser``."""
89
+ try:
90
+ import browser_cookie3 as bc3
91
+ except ImportError as exc:
92
+ raise LoginError(
93
+ 'browser-cookie3 is required. Install with: pip install "avios-cli[login]"'
94
+ ) from exc
95
+ fn = getattr(bc3, browser, None)
96
+ if fn is None:
97
+ raise LoginError(
98
+ f"Unknown browser '{browser}'. Choose from: {', '.join(SUPPORTED_BROWSERS)}"
99
+ )
100
+ return lambda: fn(domain_name="avios.com")
101
+
102
+
103
+ def import_from_browser(
104
+ session: Session | None = None,
105
+ browser: str = "chrome",
106
+ *,
107
+ loader: Callable[[], Iterable[_RawCookie]] | None = None,
108
+ ) -> int:
109
+ """Import the avios.com session cookie from a running browser.
110
+
111
+ Returns the number of avios.com cookies imported.
112
+ """
113
+ session = session or Session()
114
+ load = loader or _default_loader(browser)
115
+ cookies = _only_avios(_to_cookie_dicts(load()))
116
+ if not cookies:
117
+ raise LoginError(
118
+ f"No avios.com cookies found in {browser}. Log into avios.com there first."
119
+ )
120
+ session.save_cookies(cookies)
121
+ return len(cookies)
avios/cli.py ADDED
@@ -0,0 +1,231 @@
1
+ """Command-line interface for avios.
2
+
3
+ Thin presentation layer: every command builds an :class:`~avios.client.AviosClient`,
4
+ calls one method, and renders it with Rich (or raw JSON via ``--json``). Auth
5
+ errors from the session layer are turned into friendly messages in one place.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Iterator
11
+ from contextlib import contextmanager
12
+ from typing import Any
13
+
14
+ import httpx
15
+ import typer
16
+ from rich.console import Console
17
+ from rich.table import Table
18
+
19
+ from avios import __version__
20
+ from avios.auth import LoginError, import_from_browser, login_via_browser
21
+ from avios.client import AviosClient
22
+ from avios.models import Balance
23
+ from avios.session import NotAuthenticated, Session, SessionExpired
24
+
25
+ app = typer.Typer(
26
+ add_completion=False,
27
+ no_args_is_help=True,
28
+ help="avios — view your Avios balance and transactions from the terminal.",
29
+ )
30
+ console = Console()
31
+
32
+ JSON_OPTION = typer.Option(False, "--json", help="Output raw JSON instead of a table.")
33
+
34
+
35
+ def _version_callback(value: bool) -> None:
36
+ if value:
37
+ typer.echo(f"avios {__version__}")
38
+ raise typer.Exit
39
+
40
+
41
+ @app.callback()
42
+ def main(
43
+ version: bool = typer.Option(
44
+ False,
45
+ "--version",
46
+ "-V",
47
+ help="Show the version and exit.",
48
+ callback=_version_callback,
49
+ is_eager=True,
50
+ ),
51
+ ) -> None:
52
+ """avios — a CLI and TUI for avios.com."""
53
+
54
+
55
+ # -- helpers -----------------------------------------------------------------
56
+ def _client() -> AviosClient:
57
+ return AviosClient(Session())
58
+
59
+
60
+ @contextmanager
61
+ def _handle_errors() -> Iterator[None]:
62
+ """Turn session/HTTP errors into friendly messages + exit codes."""
63
+ try:
64
+ yield
65
+ except NotAuthenticated as exc:
66
+ console.print("[red]Not logged in.[/] Run [bold]avios login[/].")
67
+ raise typer.Exit(1) from exc
68
+ except SessionExpired as exc:
69
+ console.print("[red]Session expired.[/] Run [bold]avios login[/] again.")
70
+ raise typer.Exit(2) from exc
71
+ except httpx.HTTPError as exc:
72
+ console.print(f"[red]Request failed:[/] {exc}")
73
+ raise typer.Exit(1) from exc
74
+
75
+
76
+ def _print_json(data: Any) -> None:
77
+ console.print_json(data=data)
78
+
79
+
80
+ def _render_records(records: list[dict[str, Any]], title: str) -> None:
81
+ if not records:
82
+ console.print(f"[dim]No {title.lower()}.[/]")
83
+ return
84
+ keys = [k for k, v in records[0].items() if not isinstance(v, dict | list)][:6]
85
+ table = Table(title=title)
86
+ for key in keys:
87
+ table.add_column(key)
88
+ for record in records:
89
+ table.add_row(*[str(record.get(key, "")) for key in keys])
90
+ console.print(table)
91
+
92
+
93
+ # -- auth commands -----------------------------------------------------------
94
+ @app.command()
95
+ def login(
96
+ from_browser: bool = typer.Option(
97
+ False, "--from-browser", help="Import the cookie from a running browser (no popup)."
98
+ ),
99
+ browser: str = typer.Option("chrome", help="Browser to import from (with --from-browser)."),
100
+ headless: bool = typer.Option(
101
+ False, help="Run the login browser headless (only works without captcha/MFA)."
102
+ ),
103
+ ) -> None:
104
+ """Log in to avios.com (opens a browser once; captures your session cookie)."""
105
+ session = Session()
106
+ try:
107
+ if from_browser:
108
+ count = import_from_browser(session, browser)
109
+ else:
110
+ console.print(
111
+ "Opening a browser — log in normally (password, captcha, SMS code). "
112
+ "I'll capture the session once you reach the dashboard."
113
+ )
114
+ count = login_via_browser(session, headless=headless)
115
+ except LoginError as exc:
116
+ console.print(f"[red]{exc}[/]")
117
+ raise typer.Exit(1) from exc
118
+
119
+ console.print(f"[green]Logged in.[/] Saved {count} avios cookie(s).")
120
+ try:
121
+ balance = AviosClient(session).get_balance()
122
+ console.print(
123
+ f"[green]✓ Session works.[/] Balance: [bold cyan]{balance.balance:,}[/] Avios"
124
+ )
125
+ except (NotAuthenticated, SessionExpired, httpx.HTTPError) as exc:
126
+ console.print(f"[yellow]Saved, but a test call failed:[/] {exc}")
127
+
128
+
129
+ @app.command()
130
+ def logout() -> None:
131
+ """Remove the stored session."""
132
+ Session().clear()
133
+ console.print("[green]Logged out.[/]")
134
+
135
+
136
+ # -- data commands -----------------------------------------------------------
137
+ @app.command()
138
+ def balance(json_out: bool = JSON_OPTION) -> None:
139
+ """Show your Avios balance."""
140
+ with _handle_errors():
141
+ result = _client().get_balance()
142
+ if json_out:
143
+ _print_json(result.as_dict())
144
+ return
145
+ _render_balance(result)
146
+
147
+
148
+ def _render_balance(result: Balance) -> None:
149
+ table = Table(show_header=False, box=None)
150
+ table.add_row("[bold]Avios[/]", f"[bold cyan]{result.balance:,}[/]")
151
+ if result.household_avios_balance is not None:
152
+ table.add_row("Household", f"{result.household_avios_balance:,}")
153
+ console.print(table)
154
+
155
+
156
+ @app.command()
157
+ def transactions(
158
+ limit: int = typer.Option(20, help="Number of transactions to show."),
159
+ json_out: bool = JSON_OPTION,
160
+ ) -> None:
161
+ """List recent Avios transactions."""
162
+ with _handle_errors():
163
+ items = _client().get_transactions(limit=limit)
164
+ records = [item.as_dict() for item in items]
165
+ if json_out:
166
+ _print_json(records)
167
+ return
168
+ _render_records(records, "Transactions")
169
+
170
+
171
+ @app.command()
172
+ def pending(json_out: bool = JSON_OPTION) -> None:
173
+ """List pending Avios transactions."""
174
+ with _handle_errors():
175
+ items = _client().get_pending_transactions()
176
+ records = [item.as_dict() for item in items]
177
+ if json_out:
178
+ _print_json(records)
179
+ return
180
+ _render_records(records, "Pending")
181
+
182
+
183
+ @app.command()
184
+ def accounts(json_out: bool = JSON_OPTION) -> None:
185
+ """List linked loyalty accounts."""
186
+ with _handle_errors():
187
+ items = _client().get_accounts()
188
+ records = [item.as_dict() for item in items]
189
+ if json_out:
190
+ _print_json(records)
191
+ return
192
+ _render_records(records, "Accounts")
193
+
194
+
195
+ @app.command()
196
+ def overview() -> None:
197
+ """Show the dashboard overview (raw JSON; shape not yet finalised)."""
198
+ with _handle_errors():
199
+ result = _client().get_overview()
200
+ _print_json(result.as_dict())
201
+
202
+
203
+ @app.command()
204
+ def whoami() -> None:
205
+ """Show your profile (raw JSON; shape not yet finalised)."""
206
+ with _handle_errors():
207
+ result = _client().get_profile()
208
+ _print_json(result.as_dict())
209
+
210
+
211
+ @app.command()
212
+ def raw(path: str) -> None:
213
+ """Fetch any endpoint directly and print the response."""
214
+ with _handle_errors():
215
+ data = _client().raw(path)
216
+ if isinstance(data, str):
217
+ console.print(data)
218
+ else:
219
+ _print_json(data)
220
+
221
+
222
+ @app.command()
223
+ def tui() -> None:
224
+ """Launch the full-screen dashboard."""
225
+ from avios.tui.app import run
226
+
227
+ run()
228
+
229
+
230
+ if __name__ == "__main__":
231
+ app()
avios/client.py ADDED
@@ -0,0 +1,63 @@
1
+ """High-level, typed client for the avios.com internal API.
2
+
3
+ :class:`AviosClient` wraps a :class:`~avios.session.Session` and returns pydantic
4
+ models. Session/expiry errors from the session layer propagate unchanged so the
5
+ CLI/TUI can render a single, friendly "please log in again" message.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from avios import endpoints
13
+ from avios.models import Account, Balance, Overview, Profile, Transaction
14
+ from avios.session import Session
15
+
16
+
17
+ def _extract_list(payload: Any) -> list[dict[str, Any]]:
18
+ """Return the list of items from a payload.
19
+
20
+ Handles both a bare JSON array and an object that wraps the array under some
21
+ key (e.g. ``{"transactions": [...]}``). Returns ``[]`` if none is found.
22
+ """
23
+ if isinstance(payload, list):
24
+ return payload
25
+ if isinstance(payload, dict):
26
+ for value in payload.values():
27
+ if isinstance(value, list):
28
+ return value
29
+ return []
30
+
31
+
32
+ class AviosClient:
33
+ """Typed access to a user's Avios account."""
34
+
35
+ def __init__(self, session: Session | None = None) -> None:
36
+ self.session = session or Session()
37
+
38
+ def get_balance(self) -> Balance:
39
+ return Balance.model_validate(self.session.get_json(endpoints.BALANCE))
40
+
41
+ def get_profile(self) -> Profile:
42
+ return Profile.model_validate(self.session.get_json(endpoints.PROFILE))
43
+
44
+ def get_overview(self) -> Overview:
45
+ return Overview.model_validate(self.session.get_json(endpoints.OVERVIEW))
46
+
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)
54
+ transactions = [Transaction.model_validate(item) for item in items]
55
+ return transactions[:limit] if limit is not None else transactions
56
+
57
+ def get_pending_transactions(self) -> list[Transaction]:
58
+ payload = self.session.get_json(endpoints.TRANSACTIONS_PENDING)
59
+ return [Transaction.model_validate(item) for item in _extract_list(payload)]
60
+
61
+ def raw(self, path: str) -> Any:
62
+ """Fetch an arbitrary endpoint (escape hatch), returning parsed JSON."""
63
+ return self.session.get_json(path if path.startswith("/") else f"/{path}")
avios/config.py ADDED
@@ -0,0 +1,51 @@
1
+ """Runtime configuration for avios.
2
+
3
+ Settings are resolved from defaults and ``AVIOS_``-prefixed environment variables
4
+ (e.g. ``AVIOS_BASE_URL``, ``AVIOS_CONFIG_DIR``). The session cookie itself is not
5
+ stored here — see :mod:`avios.session`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+
13
+ from pydantic import Field
14
+ from pydantic_settings import BaseSettings, SettingsConfigDict
15
+
16
+ DEFAULT_BASE_URL = "https://www.avios.com"
17
+
18
+ # A realistic desktop-Chrome UA. The internal endpoints are cookie-authenticated
19
+ # and don't sign requests, but sending a browser-like UA avoids trivial filtering.
20
+ DEFAULT_USER_AGENT = (
21
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
22
+ "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
23
+ )
24
+
25
+
26
+ def _default_config_dir() -> Path:
27
+ """Return the XDG config directory for avios (``~/.config/avios`` by default)."""
28
+ xdg = os.environ.get("XDG_CONFIG_HOME")
29
+ base = Path(xdg) if xdg else Path.home() / ".config"
30
+ return base / "avios"
31
+
32
+
33
+ class Settings(BaseSettings):
34
+ """Resolved runtime settings."""
35
+
36
+ model_config = SettingsConfigDict(env_prefix="AVIOS_", extra="ignore")
37
+
38
+ base_url: str = DEFAULT_BASE_URL
39
+ user_agent: str = DEFAULT_USER_AGENT
40
+ config_dir: Path = Field(default_factory=_default_config_dir)
41
+ request_timeout: float = 20.0
42
+
43
+ @property
44
+ def state_path(self) -> Path:
45
+ """Path to the saved session file (cookie jar)."""
46
+ return self.config_dir / "state.json"
47
+
48
+
49
+ def get_settings() -> Settings:
50
+ """Build a fresh :class:`Settings` (reads the environment each call)."""
51
+ return Settings()
avios/endpoints.py ADDED
@@ -0,0 +1,18 @@
1
+ """avios.com internal API endpoint paths (relative to ``Settings.base_url``).
2
+
3
+ These are the JSON endpoints the avios.com web app calls. They are cookie-
4
+ authenticated and unofficial — see the README disclaimer.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ # Confirmed lightweight balance endpoint used by the "spend Avios" area.
10
+ BALANCE = "/en-GB/spend-avios/api/avios-balance"
11
+ # Balance under the manage-avios API — kept as a fallback.
12
+ BALANCE_ALT = "/manage-avios/api/user/current/balance"
13
+
14
+ PROFILE = "/manage-avios/api/user/current"
15
+ OVERVIEW = "/manage-avios/api/user/current/dashboard-overview"
16
+ TRANSACTIONS = "/manage-avios/api/user/current/transactions"
17
+ TRANSACTIONS_PENDING = "/manage-avios/api/user/current/transactions/pending"
18
+ ACCOUNTS = "/shell/api/users/current/accounts"
avios/models.py ADDED
@@ -0,0 +1,51 @@
1
+ """Pydantic models for avios.com API payloads.
2
+
3
+ Only :class:`Balance` has a fully-confirmed shape (observed in captured traffic).
4
+ The remaining payloads were redacted in the capture, so their models keep
5
+ ``extra='allow'`` to preserve every field until concrete shapes are pinned from
6
+ live responses. This means nothing is silently dropped and the client can already
7
+ return typed objects today.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+ from pydantic import BaseModel, ConfigDict, Field
15
+
16
+
17
+ class AviosModel(BaseModel):
18
+ """Base model: camelCase-JSON aware, forward-compatible with unknown fields."""
19
+
20
+ model_config = ConfigDict(populate_by_name=True, extra="allow")
21
+
22
+ def as_dict(self) -> dict[str, Any]:
23
+ """Serialise back to the API's camelCase JSON shape."""
24
+ return self.model_dump(by_alias=True)
25
+
26
+
27
+ class Balance(AviosModel):
28
+ """Avios balance (from ``/en-GB/spend-avios/api/avios-balance``)."""
29
+
30
+ balance: int
31
+ household_avios_balance: int | None = Field(default=None, alias="householdAviosBalance")
32
+
33
+
34
+ class Transaction(AviosModel):
35
+ """A single Avios statement entry.
36
+
37
+ Concrete fields are pinned once live transaction payloads are observed; until
38
+ then every key is preserved via ``extra='allow'``.
39
+ """
40
+
41
+
42
+ class Account(AviosModel):
43
+ """A linked loyalty account."""
44
+
45
+
46
+ class Profile(AviosModel):
47
+ """The current user's profile."""
48
+
49
+
50
+ class Overview(AviosModel):
51
+ """Dashboard overview summary."""
avios/session.py ADDED
@@ -0,0 +1,120 @@
1
+ """Session and cookie handling for avios.
2
+
3
+ avios.com authenticates its internal JSON endpoints with a browser session cookie
4
+ (no bearer token, no request signing). This module stores that cookie jar and
5
+ builds pre-authenticated :class:`httpx.Client` instances. Acquiring the cookie in
6
+ the first place (browser-assisted login) lives in :mod:`avios.auth`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import httpx
17
+
18
+ from avios.config import Settings, get_settings
19
+
20
+
21
+ class SessionError(RuntimeError):
22
+ """Base class for session problems."""
23
+
24
+
25
+ class NotAuthenticated(SessionError):
26
+ """No stored session is available; the user must log in."""
27
+
28
+
29
+ class SessionExpired(SessionError):
30
+ """The stored session is no longer valid; the user must log in again."""
31
+
32
+
33
+ def cookie_header_from(cookies: list[dict[str, Any]]) -> str:
34
+ """Build a ``Cookie`` header from stored cookies, scoped to avios.com."""
35
+ return "; ".join(
36
+ f"{c['name']}={c['value']}" for c in cookies if "avios.com" in c.get("domain", "")
37
+ )
38
+
39
+
40
+ class Session:
41
+ """Persisted avios session backed by a cookie jar on disk.
42
+
43
+ A ``transport`` may be injected for testing (e.g. ``httpx.MockTransport``).
44
+ The ``AVIOS_COOKIE`` environment variable, if set, overrides the stored jar
45
+ with a raw ``Cookie`` header string.
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ settings: Settings | None = None,
51
+ *,
52
+ transport: httpx.BaseTransport | None = None,
53
+ ) -> None:
54
+ self.settings = settings or get_settings()
55
+ self._transport = transport
56
+
57
+ @property
58
+ def state_path(self) -> Path:
59
+ return self.settings.state_path
60
+
61
+ # -- cookie storage -------------------------------------------------------
62
+ def save_cookies(self, cookies: list[dict[str, Any]]) -> None:
63
+ """Persist a Playwright/browser-style cookie list to ``state.json`` (0600)."""
64
+ self.state_path.parent.mkdir(parents=True, exist_ok=True)
65
+ self.state_path.write_text(json.dumps({"cookies": cookies}))
66
+ self.state_path.chmod(0o600)
67
+
68
+ def load_cookies(self) -> list[dict[str, Any]]:
69
+ if not self.state_path.exists():
70
+ return []
71
+ data = json.loads(self.state_path.read_text())
72
+ cookies = data.get("cookies", [])
73
+ return cookies if isinstance(cookies, list) else []
74
+
75
+ def cookie_header(self) -> str:
76
+ env = os.environ.get("AVIOS_COOKIE")
77
+ if env:
78
+ return env.strip()
79
+ return cookie_header_from(self.load_cookies())
80
+
81
+ def is_authenticated(self) -> bool:
82
+ return bool(self.cookie_header())
83
+
84
+ def clear(self) -> None:
85
+ """Remove the stored session (logout)."""
86
+ self.state_path.unlink(missing_ok=True)
87
+
88
+ # -- HTTP -----------------------------------------------------------------
89
+ def client(self) -> httpx.Client:
90
+ """Return a pre-authenticated client. Raises :class:`NotAuthenticated`."""
91
+ cookie = self.cookie_header()
92
+ if not cookie:
93
+ raise NotAuthenticated("No saved session. Run `avios login` first.")
94
+ headers = {
95
+ "accept": "application/json, text/plain, */*",
96
+ "user-agent": self.settings.user_agent,
97
+ "referer": f"{self.settings.base_url}/manage-avios/dashboard",
98
+ "cookie": cookie,
99
+ }
100
+ return httpx.Client(
101
+ base_url=self.settings.base_url,
102
+ headers=headers,
103
+ timeout=self.settings.request_timeout,
104
+ follow_redirects=False,
105
+ transport=self._transport,
106
+ )
107
+
108
+ def get_json(self, path: str) -> Any:
109
+ """GET ``path`` and return parsed JSON.
110
+
111
+ A redirect to the auth gateway (301/302) or a 401 means the session is no
112
+ longer valid, surfaced as :class:`SessionExpired`.
113
+ """
114
+ with self.client() as client:
115
+ response = client.get(path)
116
+ if response.status_code in (301, 302, 401):
117
+ raise SessionExpired("Session expired. Run `avios login` again.")
118
+ response.raise_for_status()
119
+ ctype = response.headers.get("content-type", "")
120
+ return response.json() if "json" in ctype else response.text
avios/tui/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Textual TUI for avios."""
2
+
3
+ from avios.tui.app import AviosApp, run
4
+
5
+ __all__ = ["AviosApp", "run"]
avios/tui/app.py ADDED
@@ -0,0 +1,97 @@
1
+ """The avios Textual dashboard.
2
+
3
+ A single screen: a balance header and a scrollable transactions table. Data is
4
+ fetched off the event loop (the API client is synchronous httpx) via
5
+ ``asyncio.to_thread`` inside an exclusive worker, so the UI never blocks.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+
12
+ import httpx
13
+ from textual import work
14
+ from textual.app import App, ComposeResult
15
+ from textual.widgets import DataTable, Footer, Header, Static
16
+
17
+ from avios.client import AviosClient
18
+ from avios.models import Transaction
19
+ from avios.session import NotAuthenticated, Session, SessionExpired
20
+ from avios.tui.art import banner_text
21
+ from avios.tui.widgets import BalanceDisplay
22
+
23
+ TRANSACTIONS_TO_SHOW = 50
24
+ MAX_COLUMNS = 6
25
+
26
+
27
+ class AviosApp(App[None]):
28
+ """Full-screen Avios dashboard."""
29
+
30
+ CSS_PATH = "styles.tcss"
31
+ TITLE = "avios"
32
+ SUB_TITLE = "your Avios, in the terminal"
33
+ BINDINGS = [
34
+ ("r", "refresh", "Refresh"),
35
+ ("q", "quit", "Quit"),
36
+ ]
37
+
38
+ def __init__(self, client: AviosClient | None = None) -> None:
39
+ super().__init__()
40
+ self._client = client or AviosClient(Session())
41
+
42
+ def compose(self) -> ComposeResult:
43
+ yield Header(show_clock=True)
44
+ yield Static(banner_text(), id="banner")
45
+ yield BalanceDisplay("Loading…", id="balance")
46
+ yield DataTable(id="transactions", zebra_stripes=True, cursor_type="row")
47
+ yield Footer()
48
+
49
+ def on_mount(self) -> None:
50
+ self.query_one("#transactions", DataTable).loading = True
51
+ self.refresh_data()
52
+
53
+ def action_refresh(self) -> None:
54
+ self.query_one("#transactions", DataTable).loading = True
55
+ self.refresh_data()
56
+
57
+ @work(exclusive=True)
58
+ async def refresh_data(self) -> None:
59
+ await self._load()
60
+
61
+ async def _load(self) -> None:
62
+ try:
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
+ except (NotAuthenticated, SessionExpired) as exc:
68
+ self._show_error(f"{exc} — run `avios login`")
69
+ return
70
+ except httpx.HTTPError as exc:
71
+ self._show_error(f"Request failed: {exc}")
72
+ return
73
+ self.query_one("#balance", BalanceDisplay).update_balance(balance)
74
+ self._populate_transactions(transactions)
75
+
76
+ def _show_error(self, message: str) -> None:
77
+ self.query_one("#balance", BalanceDisplay).update_message(message, error=True)
78
+ self.query_one("#transactions", DataTable).loading = False
79
+
80
+ def _populate_transactions(self, transactions: list[Transaction]) -> None:
81
+ table = self.query_one("#transactions", DataTable)
82
+ table.loading = False
83
+ table.clear(columns=True)
84
+ records = [txn.as_dict() for txn in transactions]
85
+ if not records:
86
+ table.add_column("info")
87
+ table.add_row("No transactions")
88
+ 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
+
94
+
95
+ def run(client: AviosClient | None = None) -> None:
96
+ """Launch the dashboard."""
97
+ AviosApp(client=client).run()
avios/tui/art.py ADDED
@@ -0,0 +1,31 @@
1
+ """ASCII banner art for the TUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.text import Text
6
+
7
+ # "AVIOS" in the figlet "ANSI Shadow" style.
8
+ BANNER = r"""
9
+ █████╗ ██╗ ██╗██╗ ██████╗ ███████╗
10
+ ██╔══██╗██║ ██║██║██╔═══██╗██╔════╝
11
+ ███████║██║ ██║██║██║ ██║███████╗
12
+ ██╔══██║╚██╗ ██╔╝██║██║ ██║╚════██║
13
+ ██║ ██║ ╚████╔╝ ██║╚██████╔╝███████║
14
+ ╚═╝ ╚═╝ ╚═══╝ ╚═╝ ╚═════╝ ╚══════╝
15
+ """
16
+
17
+ TAGLINE = "✈ your Avios, in the terminal"
18
+
19
+ # Teal → blue gradient applied line by line (evokes the Avios/BA palette).
20
+ _GRADIENT = ["#00d7af", "#00d7d7", "#00afd7", "#0087d7", "#005fd7", "#5f5fd7"]
21
+
22
+
23
+ def banner_text() -> Text:
24
+ """Return the AVIOS banner as a colour-gradient Rich ``Text``."""
25
+ text = Text(justify="center")
26
+ lines = BANNER.strip("\n").splitlines()
27
+ for index, line in enumerate(lines):
28
+ color = _GRADIENT[min(index, len(_GRADIENT) - 1)]
29
+ text.append(line + "\n", style=f"bold {color}")
30
+ text.append(TAGLINE, style="italic #9e9e9e")
31
+ return text
avios/tui/styles.tcss ADDED
@@ -0,0 +1,21 @@
1
+ Screen {
2
+ background: $surface;
3
+ }
4
+
5
+ #banner {
6
+ height: auto;
7
+ padding: 1 0 0 0;
8
+ content-align: center middle;
9
+ text-align: center;
10
+ }
11
+
12
+ #balance {
13
+ height: 3;
14
+ padding: 0 2;
15
+ content-align: left middle;
16
+ border-bottom: solid $primary;
17
+ }
18
+
19
+ #transactions {
20
+ height: 1fr;
21
+ }
avios/tui/widgets.py ADDED
@@ -0,0 +1,30 @@
1
+ """Reusable TUI widgets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.text import Text
6
+ from textual.widgets import Static
7
+
8
+ from avios.models import Balance
9
+
10
+
11
+ class BalanceDisplay(Static):
12
+ """A one-line header showing the Avios balance (or a status message).
13
+
14
+ The rendered plain text is mirrored on :attr:`last_text` for easy assertions.
15
+ """
16
+
17
+ last_text: str = ""
18
+
19
+ def update_balance(self, balance: Balance) -> None:
20
+ text = Text()
21
+ text.append("Avios ", style="bold")
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")
25
+ self.last_text = text.plain
26
+ self.update(text)
27
+
28
+ def update_message(self, message: str, *, error: bool = False) -> None:
29
+ self.last_text = message
30
+ self.update(Text(message, style="bold red" if error else "dim"))
@@ -0,0 +1,152 @@
1
+ Metadata-Version: 2.4
2
+ Name: avios-cli
3
+ Version: 0.1.0
4
+ Summary: A CLI and TUI for avios.com — view your Avios balance and transactions from the terminal.
5
+ Project-URL: Homepage, https://github.com/alexechoi/avios-cli
6
+ Project-URL: Repository, https://github.com/alexechoi/avios-cli
7
+ Project-URL: Issues, https://github.com/alexechoi/avios-cli/issues
8
+ Author: Alex Choi
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: avios,british-airways,cli,loyalty,terminal,tui
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Utilities
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: httpx>=0.27
25
+ Requires-Dist: pydantic-settings>=2.3
26
+ Requires-Dist: pydantic>=2.7
27
+ Requires-Dist: pyyaml>=6.0
28
+ Requires-Dist: rich>=13.7
29
+ Requires-Dist: textual>=0.80
30
+ Requires-Dist: typer>=0.12
31
+ Provides-Extra: login
32
+ Requires-Dist: browser-cookie3>=0.19; extra == 'login'
33
+ Requires-Dist: playwright>=1.44; extra == 'login'
34
+ Description-Content-Type: text/markdown
35
+
36
+ # avios-cli
37
+
38
+ [![CI](https://github.com/alexechoi/avios-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/alexechoi/avios-cli/actions/workflows/ci.yml)
39
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
40
+ [![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/)
41
+
42
+ A **CLI and TUI for [avios.com](https://www.avios.com)** — check your Avios balance,
43
+ browse your transactions and manage your account without leaving the terminal.
44
+
45
+ > ⚠️ **Unofficial.** This project is not affiliated with, authorised by, or endorsed by
46
+ > Avios, British Airways or IAG Loyalty. It drives the same private endpoints the
47
+ > avios.com website uses, with your own logged-in session. Use at your own risk; it may
48
+ > break at any time and may be against the provider's terms of service.
49
+
50
+ ![avios TUI dashboard](docs/dashboard.svg)
51
+
52
+ <sub>The <code>avios tui</code> dashboard (demo data). Regenerate with <code>uv run python scripts/screenshot.py</code>.</sub>
53
+
54
+ ## Status
55
+
56
+ Early alpha, built in the open. See the [roadmap](#roadmap).
57
+
58
+ ## Install
59
+
60
+ Requires Python 3.10+.
61
+
62
+ ```bash
63
+ uvx avios-cli --help # try it without installing (recommended)
64
+ # or install it:
65
+ pip install avios-cli
66
+ ```
67
+
68
+ Or from source, for development:
69
+
70
+ ```bash
71
+ git clone https://github.com/alexechoi/avios-cli
72
+ cd avios-cli
73
+ uv sync
74
+ uv run avios --help
75
+ ```
76
+
77
+ ## Log in
78
+
79
+ avios.com has no credential API — login is Auth0 Universal Login behind hCaptcha and
80
+ SMS/passkey MFA — so `avios login` rides a real browser session (like `gh`/`aws`
81
+ login). Install the login extra once:
82
+
83
+ ```bash
84
+ uv sync --extra login # or: pip install "avios-cli[login]"
85
+ uv run playwright install chromium
86
+ ```
87
+
88
+ Then either open a browser to log in, or import the cookie from a browser you're
89
+ already logged into:
90
+
91
+ ```bash
92
+ avios login # opens a browser; log in once, cookie is captured
93
+ avios login --from-browser # instead, read the avios.com cookie from Chrome
94
+ ```
95
+
96
+ Your session cookie is stored at `~/.config/avios/state.json` (mode `600`). It
97
+ expires after ~a day; just run `avios login` again. Run `avios logout` to remove it.
98
+
99
+ ## Usage
100
+
101
+ ```bash
102
+ avios balance # your Avios balance
103
+ avios transactions --limit 20 # recent transactions
104
+ avios pending # pending Avios
105
+ avios accounts # linked loyalty accounts
106
+ avios overview # dashboard summary (raw JSON)
107
+ avios whoami # your profile (raw JSON)
108
+ avios raw /manage-avios/api/user/current # hit any endpoint directly
109
+ ```
110
+
111
+ Add `--json` to `balance`, `transactions`, `pending` or `accounts` for scriptable
112
+ output.
113
+
114
+ ## TUI
115
+
116
+ ```bash
117
+ avios tui
118
+ ```
119
+
120
+ A full-screen dashboard: your balance in the header and a scrollable transactions
121
+ table. Press `r` to refresh, `q` to quit.
122
+
123
+ ## Roadmap
124
+
125
+ - [x] Project scaffolding, packaging and CI
126
+ - [x] Session + cookie storage layer
127
+ - [x] Typed API client (balance, transactions, accounts, profile)
128
+ - [x] Browser-assisted `avios login`
129
+ - [x] CLI commands
130
+ - [x] Textual TUI dashboard
131
+ - [ ] Reward-flight **availability** search _(coming soon — needs a British Airways capture)_
132
+
133
+ ## Development
134
+
135
+ ```bash
136
+ uv sync # installs the project + the `dev` dependency-group
137
+ uv run ruff check .
138
+ uv run mypy src
139
+ uv run pytest
140
+ ```
141
+
142
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow and
143
+ [CHANGELOG.md](CHANGELOG.md) for release notes.
144
+
145
+ ## Security
146
+
147
+ No passwords are handled or stored — only your session cookie, kept locally at
148
+ `~/.config/avios/state.json` (mode `600`). See [SECURITY.md](SECURITY.md).
149
+
150
+ ## License
151
+
152
+ [MIT](LICENSE)
@@ -0,0 +1,18 @@
1
+ avios/__init__.py,sha256=5McxIEsfgmtlnfQbcf-2iItj31mtplu5xiS9Z0fPNFc,147
2
+ avios/auth.py,sha256=GF3Cz0gJ_CNYUL30p4lcsp4vCkS0kY5IC2-_C1jXSLQ,4274
3
+ avios/cli.py,sha256=ktjZIAw2pEsNCobQCsdDvRNfc2IHWMUbab0V0hMvGvU,6913
4
+ avios/client.py,sha256=dtG8cBDF0vv41GlB8kadxloRj_M292g46Kq2d-ptyig,2463
5
+ avios/config.py,sha256=ZADQ4Exfyvkr0n63u1eXbjNMjMNFSVb7qKTVouCnYgc,1636
6
+ avios/endpoints.py,sha256=YHt2b5gKBRUTaMIcMhbf-e2L292AStd3iWL2bAw6f5Y,785
7
+ avios/models.py,sha256=608z7Sh6GD-zo1LL9mvOhKihniW0dGB7WTsnBzljdtE,1476
8
+ avios/session.py,sha256=18xwyJoC8fnJAnx-paiNlGkTEYormjwIh-relqqe4TM,4227
9
+ avios/tui/__init__.py,sha256=6Ab42V-TpFQmW_8IVYnCr_ByREwgE-PXuT4Y5Upbdf0,101
10
+ avios/tui/app.py,sha256=n3zWEemNzV_8cdGqLQlH9aI7EPkH_Ld1BwQP9w1eB1g,3352
11
+ avios/tui/art.py,sha256=Ke5IIsb-i1FLYjE1s0Lx3ENqLY2Fd_zL4PnEGBGO1Rc,1372
12
+ avios/tui/styles.tcss,sha256=cc-xF2zqA9995wBLATsCrjdPRUWQyRe5EsQFIktfzzY,298
13
+ avios/tui/widgets.py,sha256=ge-uJwPnlydPBgxzxnpiUwycy04a_8Z0WIWMUNU7Pus,964
14
+ avios_cli-0.1.0.dist-info/METADATA,sha256=9RihSFAWR_aKQf3FqR0LyhegxO8WbzQ2y9NXl8IN2ts,5003
15
+ avios_cli-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
16
+ avios_cli-0.1.0.dist-info/entry_points.txt,sha256=Bf7Oc85-qvh6S7aIsLWZCHThuXFL3ix8Plzj_s4VUZE,40
17
+ avios_cli-0.1.0.dist-info/licenses/LICENSE,sha256=vxtn-VeiQV1SGhHDY2UgDPLO6zjZPHcd2UdgVcDtzwU,1066
18
+ avios_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ avios = avios.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alex Choi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.