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 +6 -0
- avios/auth.py +121 -0
- avios/cli.py +231 -0
- avios/client.py +63 -0
- avios/config.py +51 -0
- avios/endpoints.py +18 -0
- avios/models.py +51 -0
- avios/session.py +120 -0
- avios/tui/__init__.py +5 -0
- avios/tui/app.py +97 -0
- avios/tui/art.py +31 -0
- avios/tui/styles.tcss +21 -0
- avios/tui/widgets.py +30 -0
- avios_cli-0.1.0.dist-info/METADATA +152 -0
- avios_cli-0.1.0.dist-info/RECORD +18 -0
- avios_cli-0.1.0.dist-info/WHEEL +4 -0
- avios_cli-0.1.0.dist-info/entry_points.txt +2 -0
- avios_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
avios/__init__.py
ADDED
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
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
|
+
[](https://github.com/alexechoi/avios-cli/actions/workflows/ci.yml)
|
|
39
|
+
[](LICENSE)
|
|
40
|
+
[](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
|
+

|
|
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,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.
|