From b077e215d29f0e0301a3fc3f488309f82ae74d13 Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Fri, 15 May 2026 09:17:52 -0400 Subject: [PATCH 1/2] Add headed Playwright login + --from-chrome to `flight auth pp login` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solves the auth-rotation race that's been derailing PP work all session. Until now the only login path was --tokens-file pointing at a manually- captured JSON of Supabase cookies. Two new modes that share the same underlying cookie-reassembly logic: default (no flag) — Playwright headed Chromium. User logs in normally; CLI captures the resulting Supabase session. This is the recommended path: independent server-side session, so Chrome PP can stay logged in elsewhere and neither side races the other's refresh chain. Optional extra: `flight-cli[browser-login]`. --from-chrome — rookiepy reads cookies from a local Chrome profile. Quicker (no re-login) but the CLI inherits Chrome's refresh chain. Supabase rotates single-use, so a refresh on one side eventually invalidates the other. Convenience for one-shot use; the default is the right pick for stability. Mode shape modeled on krisp-cli (OAuth PKCE — CLI owns its token chain) and pplx (`auth import --browser X` via rookiepy). Both established precedents for CLI auth in the broader skill ecosystem. Shared `tokens_from_supabase_cookies()` helper in pp/auth.py funnels all three paths. Reassembles `sb--auth-token.0/.1` cookies, handles raw JSON and `base64-` prefix, ignores the `-code-verifier` PKCE cookie, falls back to JWT `exp` claim when payload lacks `expires_at`. Seven new unit tests pin the behavior with synthetic cookies + a fake rookiepy. Three modes are mutually exclusive; supplying more than one is a clean typer.BadParameter exit. Missing playwright import → clear "install with X" error pointing to the optional extra. Closes work-k93b. --- README.md | 34 +++++-- pyproject.toml | 9 ++ src/flight_cli/pp/auth.py | 160 ++++++++++++++++++++++++++++++--- src/flight_cli/pp/cli.py | 67 ++++++++++---- tests/pp/test_auth_login.py | 174 ++++++++++++++++++++++++++++++++++++ uv.lock | 106 ++++++++++++++++++++++ 6 files changed, 511 insertions(+), 39 deletions(-) create mode 100644 tests/pp/test_auth_login.py diff --git a/README.md b/README.md index 3b55039..5522bc8 100644 --- a/README.md +++ b/README.md @@ -80,19 +80,35 @@ flight fare JFK LHR --dep 2026-08-15 --pp --pp-only ### Setup -PointsPath requires a paid subscription (free tier is the browser extension only). Today the only login mode is to import tokens captured from a logged-in Chrome session: +PointsPath requires a paid subscription (free tier is the browser extension only). Three login modes: + +**1. Headed browser login (default, recommended).** Opens a Playwright Chromium so you can sign in normally; the CLI captures the resulting session into `~/.config/flight-cli/pp.json`. Independent of any Chrome PP session you have open elsewhere — different server-side Supabase session, so the refresh chains never race. ```sh -# 1. Capture your Supabase tokens from the browser. The extension is on -# pointspath.com under cookies named `sb-hxjqzkcirzhjvtubefie-auth-token.0/.1`; -# reassemble and save as JSON like: -# {"access_token": "...", "refresh_token": "...", "user": {"email": "..."}} -# 2. Import into the local token store: -flight auth pp login --tokens-file ~/Downloads/pp_tokens.json +# One-time setup: install the optional browser-login deps. +uv pip install -e '.[browser-login]' +uv run playwright install chromium + +# Then log in: +flight auth pp login flight auth pp whoami # confirm ``` -A browser-based `flight auth pp login` is planned but not yet shipped; once you have tokens cached, refresh is automatic for the lifetime of your refresh token. +**2. `--from-chrome` (cookie import).** Reads Supabase cookies from your local Chrome profile via `rookiepy`. Quicker than headed login since you don't sign in again — but the CLI then *shares* Chrome's refresh-token chain. Supabase rotates refresh tokens single-use, so a refresh on one side will eventually invalidate the other. Use this when you don't mind re-importing periodically. + +```sh +flight auth pp login --from-chrome +``` + +**3. `--tokens-file PATH` (JSON import).** Bring your own session JSON. Useful when you've captured tokens with another tool (CDP cookie sniff, browser DevTools, etc.). + +```sh +flight auth pp login --tokens-file ~/Downloads/pp_tokens.json +# Expected file shape: +# {"access_token": "...", "refresh_token": "...", "user": {"email": "..."}} +``` + +Once tokens are saved, refresh is automatic for the lifetime of the refresh-token chain (~indefinite, modulo the rotation race in mode 2). ### How airline selection works @@ -106,7 +122,7 @@ Pass `--pp-airlines United,Delta,...` to skip discovery and call only the named ### What it doesn't do -- Browser-based login (use `--tokens-file` for now) +- ~~Browser-based login~~ (now the default — see Setup above) - `calendar --pp` (lowest-fare-calendar overlay) — fan-out is N days × M airlines; deserves its own design - Match against airlines we don't yet support (the few in pricing-info but not enabled for your tier are silently skipped) diff --git a/pyproject.toml b/pyproject.toml index d10a4d9..c1ea7e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,8 +25,14 @@ dependencies = [ "rich>=13", "flights>=0.8", # fli — drives Google Flights for booking handoff "fast-flights>=2.2", # tfs= URL encoder (Google Flights deep-link) + "rookiepy>=0.5", # reads Chrome cookies for `auth pp login --from-chrome` ] +[project.optional-dependencies] +# Heavier interactive-login path. Optional because the package is small +# but `playwright install chromium` is ~150MB — non-PP users shouldn't pay. +browser-login = ["playwright>=1.40"] + [dependency-groups] dev = [ "basedpyright>=1.20", @@ -34,6 +40,9 @@ dev = [ "pytest>=8.3", "pytest-cov>=5", "hypothesis>=6.100", + # Pulled in only for type-checking; `playwright install chromium` is NOT + # run automatically — the dev Python package itself is small. + "playwright>=1.40", ] [project.scripts] diff --git a/src/flight_cli/pp/auth.py b/src/flight_cli/pp/auth.py index 1f86328..7ea1626 100644 --- a/src/flight_cli/pp/auth.py +++ b/src/flight_cli/pp/auth.py @@ -182,6 +182,72 @@ def get_valid_tokens() -> Tokens: # ──────────────────────────── login helpers ──────────────────────────────── +# Supabase splits the auth-token cookie across `sb--auth-token.0` and +# `.1` to dodge the 4KB cookie limit. The reassembled value is either raw +# JSON or "base64-" + base64 of JSON, depending on gotrue-js version. +SUPABASE_PROJECT_REF = "hxjqzkcirzhjvtubefie" +SUPABASE_AUTH_COOKIE_PREFIX = f"sb-{SUPABASE_PROJECT_REF}-auth-token" + + +def _tokens_from_supabase_payload(payload: _JsonDict) -> Tokens: + """Build Tokens from the JSON object Supabase stores in its session cookie + or returns from /auth/v1/token: {access_token, refresh_token, user, ...}. + Falls back to JWT exp claim if expires_at is absent.""" + access: str = payload["access_token"] + parts = access.split(".") + if len(parts) != _JWT_PARTS: + raise PPAuthError("access_token is not a JWT (expected 3 dot-separated parts)") + claims_payload = parts[1] + "=" * (-len(parts[1]) % 4) + claims: _JsonDict = json.loads(base64.urlsafe_b64decode(claims_payload)) + user: _JsonDict = payload.get("user") or {} + expires_at = int(payload.get("expires_at") or claims.get("exp") or 0) + return Tokens( + access_token=access, + refresh_token=payload.get("refresh_token") or "", + expires_at=expires_at, + user_email=user.get("email") or claims.get("email"), + ) + + +def tokens_from_supabase_cookies(cookies: list[dict[str, Any]]) -> Tokens: + """Reassemble Supabase auth tokens from a list of cookie dicts. + + Each dict needs `name` and `value` keys (the shape produced by rookiepy + and Playwright's BrowserContext.cookies()). Filters to `sb--auth- + token[.N]` cookies, concatenates by index, decodes optional `base64-` + prefix, parses the JSON, and returns Tokens. + """ + by_idx: dict[int, str] = {} + for c in cookies: + name = c.get("name", "") + if not name.startswith(SUPABASE_AUTH_COOKIE_PREFIX): + continue + if "verifier" in name: # PKCE-flow verifier cookie; not the session + continue + if name == SUPABASE_AUTH_COOKIE_PREFIX: + by_idx[0] = c.get("value", "") + else: + suffix = name.removeprefix(SUPABASE_AUTH_COOKIE_PREFIX + ".") + try: + by_idx[int(suffix)] = c.get("value", "") + except ValueError: + continue + if not by_idx: + raise PPAuthError( + f"No {SUPABASE_AUTH_COOKIE_PREFIX}.* cookies found " + "(is the browser logged into PointsPath?)", + ) + joined = "".join(by_idx[i] for i in sorted(by_idx)) + if joined.startswith("base64-"): + decoded = base64.b64decode(joined.removeprefix("base64-") + "==", validate=False) + raw_json = decoded.decode("utf-8") + else: + raw_json = joined + payload: _JsonDict = json.loads(raw_json) + t = _tokens_from_supabase_payload(payload) + save_tokens(t) + return t + def import_from_tokens_file(path: Path) -> Tokens: """Import tokens from a JSON file (e.g., one captured via CDP cookie sniffing). @@ -190,18 +256,86 @@ def import_from_tokens_file(path: Path) -> Tokens: {access_token, refresh_token, supabase_url?, user?} """ raw: _JsonDict = json.loads(Path(path).read_text()) - access: str = raw["access_token"] - parts = access.split(".") - if len(parts) != _JWT_PARTS: - raise PPAuthError("access_token is not a JWT (expected 3 dot-separated parts)") - payload = parts[1] + "=" * (-len(parts[1]) % 4) - claims: _JsonDict = json.loads(base64.urlsafe_b64decode(payload)) - user: _JsonDict = raw.get("user") or {} - t = Tokens( - access_token=access, - refresh_token=raw.get("refresh_token") or "", - expires_at=int(claims.get("exp") or 0), - user_email=user.get("email") or claims.get("email"), - ) + t = _tokens_from_supabase_payload(raw) save_tokens(t) return t + + +# Pointspath login URL — the homepage redirects unauthenticated users here. +_PP_HOME_URL = "https://pointspath.com/" + +# Convenience login mode default: leave the browser open long enough for the +# user to click through email confirmation / 2FA if needed. +_DEFAULT_BROWSER_LOGIN_TIMEOUT_SECS = 300 + + +def login_from_chrome() -> Tokens: + """Import a PointsPath session from a local Chrome profile via rookiepy. + + Convenience path: piggybacks on whatever Chrome session you already have + open. Inherits Chrome's refresh chain, so a CLI refresh here can race + against Chrome's gotrue-js. Prefer the headed Playwright path + (`login_via_browser`) for an independent session. + """ + try: + import rookiepy # noqa: PLC0415 # pyright: ignore[reportMissingTypeStubs] + except ImportError as e: # pragma: no cover — install-time concern + raise PPAuthError( + "rookiepy isn't installed (used for --from-chrome). " + "Install with: uv pip install rookiepy", + ) from e + + cookies: list[_JsonDict] = rookiepy.chrome(domains=["pointspath.com"]) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] + return tokens_from_supabase_cookies(cookies) + + +def login_via_browser( + *, + timeout_secs: int = _DEFAULT_BROWSER_LOGIN_TIMEOUT_SECS, + poll_interval_secs: float = 1.0, +) -> Tokens: + """Open a headed Playwright Chromium for the user to log into PointsPath. + + Independent of any user-facing Chrome session — this is the recommended + primary path. Polls for the Supabase auth-token cookie; returns as soon + as it appears, or raises PPAuthError on timeout / browser close. + """ + try: + from playwright.sync_api import Error as PlaywrightError # noqa: PLC0415 + from playwright.sync_api import sync_playwright # noqa: PLC0415 + except ImportError as e: # pragma: no cover — install-time concern + raise PPAuthError( + "playwright isn't installed (needed for headed browser login). " + "Install with: uv pip install 'flight-cli[browser-login]' " + "and then run: uv run playwright install chromium. " + "Alternatives: --from-chrome or --tokens-file PATH.", + ) from e + + import time # noqa: PLC0415 + + with sync_playwright() as p: + browser = p.chromium.launch(headless=False) + try: + context = browser.new_context() + page = context.new_page() + page.goto(_PP_HOME_URL) + + deadline = time.time() + timeout_secs + while time.time() < deadline: + try: + raw_cookies = context.cookies([_PP_HOME_URL]) + except PlaywrightError as e: + raise PPAuthError(f"Browser closed before login completed: {e}") from e + cookies: list[_JsonDict] = [dict(c) for c in raw_cookies] + try: + return tokens_from_supabase_cookies(cookies) + except PPAuthError: + # Cookies aren't ready yet; user is still authenticating. + pass + time.sleep(poll_interval_secs) + raise PPAuthError( + f"Timed out after {timeout_secs}s waiting for PointsPath login. " + "Re-run `flight auth pp login` and complete sign-in before the timeout.", + ) + finally: + browser.close() diff --git a/src/flight_cli/pp/cli.py b/src/flight_cli/pp/cli.py index 81beca5..5657e2e 100644 --- a/src/flight_cli/pp/cli.py +++ b/src/flight_cli/pp/cli.py @@ -22,10 +22,13 @@ from .auth import ( TOKENS_PATH, PPAuthError, + Tokens, clear_tokens, get_valid_tokens, import_from_tokens_file, load_tokens, + login_from_chrome, + login_via_browser, ) from .client import ( DEFAULT_AIRLINES, @@ -80,35 +83,65 @@ def pp_login( typer.Option( "--tokens-file", "-f", - help="Import tokens from a JSON file (e.g. one captured via CDP cookie sniff).", + help="Import tokens from a JSON file (e.g. previously captured Supabase session).", ), ] = None, + from_chrome: Annotated[ + bool, + typer.Option( + "--from-chrome", + help=( + "Read tokens from a local Chrome profile via rookiepy. " + "Inherits Chrome's session (and its rotation chain — Chrome and " + "the CLI may race on refresh). Convenience path; prefer the " + "default headed browser login for a clean, independent session." + ), + ), + ] = False, ) -> None: - """Save tokens to ~/.config/flight-cli/pp.json. + """Authenticate with PointsPath. Three modes (mutually exclusive). + + Default (no flag): open a headed Playwright Chromium so you can sign + in normally. Captures the resulting Supabase session into + ~/.config/flight-cli/pp.json. Independent of any user-facing Chrome + PP session. Requires the browser-login extra (see README PP Setup). + + --from-chrome: import the session from your local Chrome profile via + cookies. Quicker, but the CLI then shares Chrome's refresh-token + chain (Supabase rotates single-use, so a refresh on one side will + eventually invalidate the other). - Today only `--tokens-file` is wired. Browser-based login is planned but - not yet implemented; for now, capture tokens from your authenticated Chrome - session and pass the JSON file in. + --tokens-file PATH: import from a pre-captured JSON file. Expected + shape: {access_token, refresh_token, user.email}. """ - if tokens_file is None: - err.print("[yellow]Browser-based login isn't implemented yet.[/]") - err.print( - "Workaround: capture your Supabase tokens from your authenticated " - "Chrome session and pass them in:\n" - " flight-cli auth pp login --tokens-file /path/to/pp_tokens.json\n" - "Expected file shape: " - '{"access_token": "...", "refresh_token": "...", "user": {"email": "..."}}' - ) + modes = [bool(tokens_file), from_chrome] + if sum(modes) > 1: + err.print("[red]Pick at most one of --tokens-file or --from-chrome.[/]") raise typer.Exit(2) + try: - t = import_from_tokens_file(tokens_file) + t: Tokens + if tokens_file is not None: + t = import_from_tokens_file(tokens_file) + source = f"{tokens_file}" + elif from_chrome: + t = login_from_chrome() + source = "local Chrome cookies" + else: + console.print( + "[dim]Opening a browser to PointsPath. Sign in normally; " + "the CLI will capture your session and close the browser.[/]" + ) + t = login_via_browser() + source = "headed browser login" except (PPAuthError, OSError, json.JSONDecodeError, KeyError) as e: - err.print(f"[red]Login failed: {e}[/]") + err.print(f"[red]Login failed ({type(e).__name__}): {e}[/]") raise typer.Exit(1) from e + when = datetime.fromtimestamp(t.expires_at).isoformat() if t.expires_at else "?" console.print( f"[green]Saved[/] tokens for [bold]{t.user_email or '?'}[/] " - f"to {TOKENS_PATH}\n access_token expires: {when}" + f"to {TOKENS_PATH}\n source: {source}\n access_token expires: {when}" ) diff --git a/tests/pp/test_auth_login.py b/tests/pp/test_auth_login.py new file mode 100644 index 0000000..9d00222 --- /dev/null +++ b/tests/pp/test_auth_login.py @@ -0,0 +1,174 @@ +# pyright: reportPrivateUsage=false +"""Tests for the cookie-reassembly + login-helper code paths in pp/auth.py. + +The headed Playwright path can't be unit-tested (it requires a real +PointsPath login), but the shared cookie-reassembly logic — which both +`--from-chrome` and the Playwright capture funnel through — IS testable +with synthetic cookies.""" + +from __future__ import annotations + +import base64 +import json +from typing import TYPE_CHECKING, Any + +import pytest + +from flight_cli.pp import auth as auth_mod +from flight_cli.pp.auth import ( + SUPABASE_AUTH_COOKIE_PREFIX, + PPAuthError, + login_from_chrome, + tokens_from_supabase_cookies, +) + +if TYPE_CHECKING: + import pathlib + +_Cookie = dict[str, str] +_Payload = dict[str, Any] + +# Dummy test refresh token. Bandit's S105/S107 fire on string literals named +# *_token; this isn't a real credential. +_FAKE_REFRESH = "rt-abc" + + +# A minimal valid JWT — header.payload.signature, payload base64-encoding +# a JSON claims dict with `exp` and `email`. +def _jwt(exp: int = 2000000000, email: str = "test@example.com") -> str: + header = base64.urlsafe_b64encode(b'{"alg":"HS256","typ":"JWT"}').decode().rstrip("=") + claims = json.dumps({"exp": exp, "email": email, "sub": "fake-sub"}) + payload = base64.urlsafe_b64encode(claims.encode()).decode().rstrip("=") + return f"{header}.{payload}.sig" + + +def _session_payload( + access: str | None = None, + refresh: str = _FAKE_REFRESH, + email: str = "test@example.com", + expires_at: int = 2000000000, +) -> _Payload: + return { + "access_token": access or _jwt(exp=expires_at, email=email), + "refresh_token": refresh, + "expires_at": expires_at, + "user": {"email": email, "id": "fake-id"}, + } + + +def _split_cookie( + payload: _Payload, *, encode_base64: bool = True, chunks: int = 2 +) -> list[_Cookie]: + """Produce the `.0`+`.1` cookie pair Supabase writes to browsers.""" + raw = json.dumps(payload) + if encode_base64: + raw = "base64-" + base64.b64encode(raw.encode()).decode().rstrip("=") + if chunks == 1: + return [{"name": SUPABASE_AUTH_COOKIE_PREFIX, "value": raw}] + mid = len(raw) // chunks + return [ + {"name": f"{SUPABASE_AUTH_COOKIE_PREFIX}.0", "value": raw[:mid]}, + {"name": f"{SUPABASE_AUTH_COOKIE_PREFIX}.1", "value": raw[mid:]}, + ] + + +def _redirect_token_store(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Point the on-disk token store at tmp_path so tests don't touch real config.""" + monkeypatch.setattr(auth_mod, "TOKENS_PATH", tmp_path / "pp.json") + monkeypatch.setattr(auth_mod, "CONFIG_DIR", tmp_path) + + +# ─────────────────────── tokens_from_supabase_cookies ─────────────────────── + + +def test_reassembles_split_base64_cookies( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The standard shape: two cookies (.0 + .1), payload base64-prefixed.""" + _redirect_token_store(tmp_path, monkeypatch) + cookies = _split_cookie(_session_payload(email="adam@example.com")) + t = tokens_from_supabase_cookies(cookies) + assert t.user_email == "adam@example.com" + assert t.refresh_token == _FAKE_REFRESH + assert t.access_token.count(".") == 2 # round-trip preserved JWT shape + + +def test_handles_raw_json_payload_no_base64_prefix( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Older Supabase versions / different gotrue-js configs may write raw JSON.""" + _redirect_token_store(tmp_path, monkeypatch) + cookies = _split_cookie(_session_payload(), encode_base64=False) + t = tokens_from_supabase_cookies(cookies) + assert t.user_email == "test@example.com" + + +def test_handles_single_unsplit_cookie( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Some Supabase versions write a single cookie when the payload fits.""" + _redirect_token_store(tmp_path, monkeypatch) + cookies = _split_cookie(_session_payload(), chunks=1) + t = tokens_from_supabase_cookies(cookies) + assert t.refresh_token == _FAKE_REFRESH + + +def test_ignores_pkce_verifier_cookie( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The `-code-verifier` cookie shares our prefix but isn't part of the session.""" + _redirect_token_store(tmp_path, monkeypatch) + cookies = _split_cookie(_session_payload()) + cookies.append( + {"name": f"{SUPABASE_AUTH_COOKIE_PREFIX}-code-verifier", "value": "pkce-junk"}, + ) + t = tokens_from_supabase_cookies(cookies) + assert t.user_email == "test@example.com" + + +def test_raises_when_no_auth_cookies_found( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Browser isn't logged in / cookies expired and were cleared.""" + _redirect_token_store(tmp_path, monkeypatch) + cookies: list[_Cookie] = [{"name": "_ga", "value": "GA1.1.xxx"}] + with pytest.raises(PPAuthError, match=r"No sb-.*-auth-token"): + tokens_from_supabase_cookies(cookies) + + +def test_expiry_falls_back_to_jwt_exp_when_payload_missing_it( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Old payloads may lack `expires_at`; we should mine the JWT claim instead.""" + _redirect_token_store(tmp_path, monkeypatch) + payload = _session_payload(expires_at=1_900_000_000) + del payload["expires_at"] # force the JWT-claim fallback + cookies = _split_cookie(payload) + t = tokens_from_supabase_cookies(cookies) + assert t.expires_at == 1_900_000_000 + + +# ──────────────────────────── login_from_chrome ───────────────────────────── + + +def test_login_from_chrome_uses_rookiepy( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Verify the rookiepy plumbing: we ask only for pointspath.com, and the + returned cookies pass through tokens_from_supabase_cookies.""" + _redirect_token_store(tmp_path, monkeypatch) + seen_domains: list[list[str]] = [] + + class _FakeRookiepy: + @staticmethod + def chrome(domains: list[str]) -> list[_Cookie]: + seen_domains.append(domains) + return _split_cookie(_session_payload(email="chrome@example.com")) + + import sys + + monkeypatch.setitem(sys.modules, "rookiepy", _FakeRookiepy) + + t = login_from_chrome() + assert t.user_email == "chrome@example.com" + assert seen_domains == [["pointspath.com"]], "Should scope read to pointspath.com only" diff --git a/uv.lock b/uv.lock index 526cb3f..d405025 100644 --- a/uv.lock +++ b/uv.lock @@ -294,15 +294,22 @@ dependencies = [ { name = "httpx-curl-cffi" }, { name = "pydantic" }, { name = "rich" }, + { name = "rookiepy" }, { name = "stamina" }, { name = "structlog" }, { name = "typer" }, ] +[package.optional-dependencies] +browser-login = [ + { name = "playwright" }, +] + [package.dev-dependencies] dev = [ { name = "basedpyright" }, { name = "hypothesis" }, + { name = "playwright" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "ruff" }, @@ -316,17 +323,21 @@ requires-dist = [ { name = "flights", specifier = ">=0.8" }, { name = "httpx", specifier = ">=0.27" }, { name = "httpx-curl-cffi", specifier = ">=0.1.5" }, + { name = "playwright", marker = "extra == 'browser-login'", specifier = ">=1.40" }, { name = "pydantic", specifier = ">=2.7" }, { name = "rich", specifier = ">=13" }, + { name = "rookiepy", specifier = ">=0.5" }, { name = "stamina", specifier = ">=24.3" }, { name = "structlog", specifier = ">=24" }, { name = "typer", specifier = ">=0.12" }, ] +provides-extras = ["browser-login"] [package.metadata.requires-dev] dev = [ { name = "basedpyright", specifier = ">=1.20" }, { name = "hypothesis", specifier = ">=6.100" }, + { name = "playwright", specifier = ">=1.40" }, { name = "pytest", specifier = ">=8.3" }, { name = "pytest-cov", specifier = ">=5" }, { name = "ruff", specifier = ">=0.6" }, @@ -352,6 +363,53 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/47/71/d71f8b72497806b1481e50dda8e6fc1258da05effca52fd20b471b66625e/flights-0.8.5-py3-none-any.whl", hash = "sha256:4b459a8e6abccb6e4456f1979f9daf74bbec7bb8cabda0fb271b0c9af7e9e5a1", size = 148963, upload-time = "2026-05-12T17:22:41.025Z" }, ] +[[package]] +name = "greenlet" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3c/3f/dbf99fb14bfeb88c28f16729215478c0e265cacd6dc22270c8f31bb6892f/greenlet-3.5.0.tar.gz", hash = "sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4", size = 196995, upload-time = "2026-04-27T13:37:15.544Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/32/f2ce6d4cac3e55bc6173f92dbe627e782e1850f89d986c3606feb63aafa7/greenlet-3.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f", size = 286228, upload-time = "2026-04-27T12:20:34.421Z" }, + { url = "https://files.pythonhosted.org/packages/b7/aa/caed9e5adf742315fc7be2a84196373aab4816e540e38ba0d76cb7584d68/greenlet-3.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628", size = 601775, upload-time = "2026-04-27T12:52:41.045Z" }, + { url = "https://files.pythonhosted.org/packages/c7/af/90ae08497400a941595d12774447f752d3dfe0fbb012e35b76bc5c0ff37e/greenlet-3.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b", size = 614436, upload-time = "2026-04-27T12:59:41.595Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e9/4eeadf8cb3403ac274245ba75f07844abc7fa5f6787583fc9156ba741e0f/greenlet-3.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136", size = 620610, upload-time = "2026-04-27T13:02:39.194Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e0/2e13df68f367e2f9960616927d60857dd7e56aaadd59a47c644216b2f920/greenlet-3.5.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c", size = 611388, upload-time = "2026-04-27T12:25:28.008Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ef/f913b3c0eb7d26d86a2401c5e1546c9d46b657efee724b06f6f4ac5d8824/greenlet-3.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d", size = 422775, upload-time = "2026-04-27T13:05:14.261Z" }, + { url = "https://files.pythonhosted.org/packages/82/f7/393c64055132ac0d488ef6be549253b7e6274194863967ddc0bc8f5b87b8/greenlet-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588", size = 1570768, upload-time = "2026-04-27T12:53:28.099Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4b/eaf7735253522cf56d1b74d672a58f54fc114702ceaf05def59aae72f6e1/greenlet-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e", size = 1635983, upload-time = "2026-04-27T12:25:26.903Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/4fb3a0805bd5165da5ebf858da7cc01cce8061674106d2cf5bdab32cbfde/greenlet-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8", size = 238840, upload-time = "2026-04-27T12:23:54.806Z" }, + { url = "https://files.pythonhosted.org/packages/cb/cb/baa584cb00532126ffe12d9787db0a60c5a4f55c27bfe2666df5d4c30a32/greenlet-3.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2", size = 235615, upload-time = "2026-04-27T12:21:38.57Z" }, + { url = "https://files.pythonhosted.org/packages/0c/58/fc576f99037ce19c5aa16628e4c3226b6d1419f72a62c79f5f40576e6eb3/greenlet-3.5.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106", size = 285066, upload-time = "2026-04-27T12:23:05.033Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ba/b28ddbe6bfad6a8ac196ef0e8cff37bc65b79735995b9e410923fffeeb70/greenlet-3.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b", size = 604414, upload-time = "2026-04-27T12:52:42.358Z" }, + { url = "https://files.pythonhosted.org/packages/09/06/4b69f8f0b67603a8be2790e55107a190b376f2627fe0eaf5695d85ffb3cd/greenlet-3.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e", size = 617349, upload-time = "2026-04-27T12:59:43.32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/15/a643b4ecd09969e30b8a150d5919960caae0abe4f5af75ab040b1ab85e78/greenlet-3.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d", size = 623234, upload-time = "2026-04-27T13:02:40.611Z" }, + { url = "https://files.pythonhosted.org/packages/8a/17/a3918541fd0ddefe024a69de6d16aa7b46d36ac19562adaa63c7fa180eff/greenlet-3.5.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13", size = 613927, upload-time = "2026-04-27T12:25:30.28Z" }, + { url = "https://files.pythonhosted.org/packages/77/18/3b13d5ef1275b0ffaf933b05efa21408ac4ca95823c7411d79682e4fdcff/greenlet-3.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae", size = 425243, upload-time = "2026-04-27T13:05:15.689Z" }, + { url = "https://files.pythonhosted.org/packages/ee/e1/bd0af6213c7dd33175d8a462d4c1fe1175124ebed4855bc1475a5b5242c2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba", size = 1570893, upload-time = "2026-04-27T12:53:29.483Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2a/0789702f864f5382cb476b93d7a9c823c10472658102ccd65f415747d2e2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846", size = 1636060, upload-time = "2026-04-27T12:25:28.845Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8f/22bf9df92bbff0eb07842b60f7e63bf7675a9742df628437a9f02d09137f/greenlet-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5", size = 238740, upload-time = "2026-04-27T12:24:01.341Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b7/9c5c3d653bd4ff614277c049ac676422e2c557db47b4fe43e6313fc005dc/greenlet-3.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:47422135b1d308c14b2c6e758beedb1acd33bb91679f5670edf77bf46244722b", size = 235525, upload-time = "2026-04-27T12:23:12.308Z" }, + { url = "https://files.pythonhosted.org/packages/94/5e/a70f31e3e8d961c4ce589c15b28e4225d63704e431a23932a3808cbcc867/greenlet-3.5.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8", size = 285564, upload-time = "2026-04-27T12:23:08.555Z" }, + { url = "https://files.pythonhosted.org/packages/af/a6/046c0a28e21833e4086918218cfb3d8bed51c075a1b700f20b9d7861c0f4/greenlet-3.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1", size = 651166, upload-time = "2026-04-27T12:52:43.644Z" }, + { url = "https://files.pythonhosted.org/packages/47/f8/4af27f71c5ff32a7fbc516adb46370d9c4ae2bc7bd3dc7d066ac542b4b15/greenlet-3.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3", size = 663792, upload-time = "2026-04-27T12:59:44.93Z" }, + { url = "https://files.pythonhosted.org/packages/fb/89/2dadb89793c37ee8b4c237857188293e9060dc085f19845c292e00f8e091/greenlet-3.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37", size = 668086, upload-time = "2026-04-27T13:02:42.314Z" }, + { url = "https://files.pythonhosted.org/packages/a3/59/1bd6d7428d6ed9106efbb8c52310c60fd04f6672490f452aeaa3829aa436/greenlet-3.5.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7", size = 660933, upload-time = "2026-04-27T12:25:33.276Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/75722be7e26a2af4cbd2dc35b0ed382dacf9394b7e75551f76ed1abe87f2/greenlet-3.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2", size = 470799, upload-time = "2026-04-27T13:05:17.094Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/b903e5a5fae1e8a28cdd32a0cfbfd560b668c25b692f67768822ddc5f40f/greenlet-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf", size = 1618401, upload-time = "2026-04-27T12:53:31.062Z" }, + { url = "https://files.pythonhosted.org/packages/0e/e3/5ec408a329acb854fb607a122e1ee5fb3ff649f9a97952948a90803c0d8e/greenlet-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16", size = 1682038, upload-time = "2026-04-27T12:25:31.838Z" }, + { url = "https://files.pythonhosted.org/packages/91/20/6b165108058767ee643c55c5c4904d591a830ee2b3c7dbd359828fbc829f/greenlet-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033", size = 239835, upload-time = "2026-04-27T12:24:54.136Z" }, + { url = "https://files.pythonhosted.org/packages/4e/62/1c498375cee177b55d980c1db319f26470e5309e54698c8f8fc06c0fd539/greenlet-3.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:a96fcee45e03fe30a62669fd16ab5c9d3c172660d3085605cb1e2d1280d3c988", size = 236862, upload-time = "2026-04-27T12:23:24.957Z" }, + { url = "https://files.pythonhosted.org/packages/78/a8/4522939255bb5409af4e87132f915446bf3622c2c292d14d3c38d128ae82/greenlet-3.5.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853", size = 293614, upload-time = "2026-04-27T12:24:12.874Z" }, + { url = "https://files.pythonhosted.org/packages/15/5e/8744c52e2c027b5a8772a01561934c8835f869733e101f62075c60430340/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f", size = 650723, upload-time = "2026-04-27T12:52:45.412Z" }, + { url = "https://files.pythonhosted.org/packages/00/ef/7b4c39c03cf46ceca512c5d3f914afd85aa30b2cc9a93015b0dd73e4be6c/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7", size = 656529, upload-time = "2026-04-27T12:59:46.295Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5c/0602239503b124b70e39355cbdb39361ecfe65b87a5f2f63752c32f5286f/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce", size = 657015, upload-time = "2026-04-27T13:02:43.973Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/c7768f352f5c010f92064d0063f987e7dc0cd290a6d92a34109015ce4aa1/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112", size = 654364, upload-time = "2026-04-27T12:25:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/38/51/8699f865f125dc952384cb432b0f7138aa4d8f2969a7d12d0df5b94d054d/greenlet-3.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2", size = 488275, upload-time = "2026-04-27T13:05:18.28Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d0/079ebe12e4b1fc758857ce5be1a5e73f06870f2101e52611d1e71925ce54/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2", size = 1614204, upload-time = "2026-04-27T12:53:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/6d/89/6c2fb63df3596552d20e58fb4d96669243388cf680cff222758812c7bfaa/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2", size = 1675480, upload-time = "2026-04-27T12:25:34.168Z" }, + { url = "https://files.pythonhosted.org/packages/15/32/77ee8a6c1564fc345a491a4e85b3bf360e4cf26eac98c4532d2fdb96e01f/greenlet-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86", size = 245324, upload-time = "2026-04-27T12:24:40.295Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -479,6 +537,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "playwright" +version = "1.59.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/48/abab23f40643b4de8f2665816f0a1bf0994eeecda39d6d62f0f292b2ad01/playwright-1.59.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:bfc6940100b57423175c819ce2422ec5880d55fa2769987f62ab7a1f5fe6783e", size = 43156922, upload-time = "2026-04-29T08:11:08.921Z" }, + { url = "https://files.pythonhosted.org/packages/08/71/5e4d98b2ce3641b4343623c6450ff33b9de1c979d12a957505e392338b07/playwright-1.59.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:af068143a0c045ec11608b67d6c42e58db7e9cf65a742dd21fddedc1a9802c47", size = 41947177, upload-time = "2026-04-29T08:11:12.867Z" }, + { url = "https://files.pythonhosted.org/packages/80/91/fd219aa78ca03d37e93aaedaed4e224131e3090a9264f9bb773c8271d67e/playwright-1.59.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:4a4a2d4842b0e4120de3fa48636e4b69085a05b81d8a35ad4353f530ade72ed6", size = 43156922, upload-time = "2026-04-29T08:11:16.595Z" }, + { url = "https://files.pythonhosted.org/packages/73/0c/1e513d37c5be07d12829ebce93dbfe7baee230084cb66966c423432799c4/playwright-1.59.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c5792aad9e22b91a09264b9edbc18553cf05ea5a39404d65dc19a012c6b2e51d", size = 47151793, upload-time = "2026-04-29T08:11:19.979Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2d/15f72288cb65d690134e18fefb9483cc4976f7579b580648c45e494481a7/playwright-1.59.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c881a19377d2b900af855fb525b5f22a27bf3cfbecba6d1edb36766d56cb100", size = 46877615, upload-time = "2026-04-29T08:11:23.863Z" }, + { url = "https://files.pythonhosted.org/packages/72/a1/717ac5bc99f387c0f60def91271ea4262125c0815d764a5d1776a272275c/playwright-1.59.0-py3-none-win32.whl", hash = "sha256:6989c476be2b9cd3e24a18cc9dcf202e266fb3d91e3e5395cd668c54ea54b119", size = 37713698, upload-time = "2026-04-29T08:11:27.251Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a5/4e630ee05d8b46b840f943268e86d6063703e8dadb2d3eb405c7b9b2e48c/playwright-1.59.0-py3-none-win_amd64.whl", hash = "sha256:d5a5cc064b82ca92996080025710844e417f44df8fda9001102c28f44174171c", size = 37713704, upload-time = "2026-04-29T08:11:30.41Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0c/3ece41761ba13c8321009aefcaec7a016eb42799c42eef5e03ace7f2de5b/playwright-1.59.0-py3-none-win_arm64.whl", hash = "sha256:93581ad515728cadc8af39b288a5633ba6d36e7d72048e79d890ce01ea2156f9", size = 33956745, upload-time = "2026-04-29T08:11:34.738Z" }, +] + [[package]] name = "plotext" version = "5.3.2" @@ -649,6 +726,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, ] +[[package]] +name = "pyee" +version = "13.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -716,6 +805,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] +[[package]] +name = "rookiepy" +version = "0.5.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/b7/b75e3bb8cdd0210a0ffb106002b678170c9a3a50ae1dc1c9bc1f701b4452/rookiepy-0.5.6.tar.gz", hash = "sha256:efa6a93b119478a96b3d8c4454215c4f1af316a24b3b3b3015b73c1c1d887078", size = 45225, upload-time = "2024-11-01T19:28:37.537Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/f3/70737571ed2fb7cd9e360b0ec6ce637c8894d712632f7e13b442f5286eef/rookiepy-0.5.6-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:96737f44eab781d5a010b71f7cd70ef43c6a6977dc1c54d019158b8fcfe57167", size = 1404166, upload-time = "2024-11-01T19:26:29.356Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/9b69ca2821149cdb5061d6d3098a003affd7ca71b79166e98aed5d7beac8/rookiepy-0.5.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37a8fc437f49672387d4a512df5522a724c4440442e64cad84680f1718f68c50", size = 1311996, upload-time = "2024-11-01T19:26:31.659Z" }, + { url = "https://files.pythonhosted.org/packages/d7/29/3a7a44027d542c889f9605843d5c7f8df105eb0b79ad45750d7c0b18875b/rookiepy-0.5.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22d0ece1ef732045f0482f72bc199cad81878154ceb62780c1e2f297c62df7e2", size = 2678772, upload-time = "2024-11-01T19:26:33.995Z" }, + { url = "https://files.pythonhosted.org/packages/6b/62/fb0a8267604f11e7df565509834279b179eadb785e4d5e0b99cc096a27f4/rookiepy-0.5.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:307943d5780492c533bfeb6bc89d1a4b3e5b91b25af6fc1de280acc1e2ff16f6", size = 2583775, upload-time = "2024-11-01T19:26:36.665Z" }, + { url = "https://files.pythonhosted.org/packages/5b/0b/c1f85445a00107f97f488f5f14ac25f1994c8436c543263e43865380e553/rookiepy-0.5.6-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c607eef14f64ba284f3754449b6eb079aa8aaa66b443d0cdd2ec9aaa1814a8da", size = 2993272, upload-time = "2024-11-01T19:26:39.668Z" }, + { url = "https://files.pythonhosted.org/packages/4c/b4/7ca3ab1860f38e1d79df051aad6e8826732f3dee77fd694f226e20e8389c/rookiepy-0.5.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:19f4b95436705b38e4a55cfa9f3d999bb4edc6a7f91ba13b1b90bb14b1796fdb", size = 2871214, upload-time = "2024-11-01T19:26:42.24Z" }, + { url = "https://files.pythonhosted.org/packages/8a/36/f45c3a833595285d790d91de20e64dff5ae1780a407033a018f0efccf852/rookiepy-0.5.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:95b881f2f3d93bb56808eaaf8ac9e0e5e3245dda837edab194834a3caaadcbe7", size = 2971688, upload-time = "2024-11-01T19:26:44.341Z" }, + { url = "https://files.pythonhosted.org/packages/19/c4/59b5e0b6efcd919683578605bfac4bc112ac34de5a871094fe3efa58896b/rookiepy-0.5.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c49a0b59bc0329928a369a479f9b1c16cbe3a2548dd8273b13515c7ced73449", size = 2801469, upload-time = "2024-11-01T19:26:47.997Z" }, + { url = "https://files.pythonhosted.org/packages/e4/38/bba16936ece960db7ed10f8ebf05dc6228959511d3837020da1e9b7dcdf6/rookiepy-0.5.6-cp312-none-win_amd64.whl", hash = "sha256:6bb956c9f2dece5101aafc7a715bc672c5b30ddcaa55c9926d12440635d89cfc", size = 2441230, upload-time = "2024-11-01T19:26:50.486Z" }, +] + [[package]] name = "ruff" version = "0.15.13" From 2cf2156045689a03aacebc5e66e3ae2c0b228886 Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Fri, 15 May 2026 09:21:26 -0400 Subject: [PATCH 2/2] Document ephemeral `uv run --with playwright` instead of venv-mutating install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous setup section pushed users toward `uv pip install -e '.[browser-login]'` — which mutates flight-cli's project venv just to add an interactive-login dep. Most users would rather inject playwright at run-time and leave the venv alone. New pattern (recommended): # One-time, global (browser cached at ~/Library/Caches/ms-playwright): uvx --from playwright playwright install chromium # Per-invocation, ephemeral: uv run --with playwright flight auth pp login The optional extra is still defined for users who DO want playwright resident, but it's no longer the primary path. Updated `--help` docstring, README, and the missing-playwright ImportError message. --- README.md | 16 ++++++++++------ src/flight_cli/pp/auth.py | 5 +++-- src/flight_cli/pp/cli.py | 4 +++- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 5522bc8..10d26e3 100644 --- a/README.md +++ b/README.md @@ -85,15 +85,19 @@ PointsPath requires a paid subscription (free tier is the browser extension only **1. Headed browser login (default, recommended).** Opens a Playwright Chromium so you can sign in normally; the CLI captures the resulting session into `~/.config/flight-cli/pp.json`. Independent of any Chrome PP session you have open elsewhere — different server-side Supabase session, so the refresh chains never race. ```sh -# One-time setup: install the optional browser-login deps. -uv pip install -e '.[browser-login]' -uv run playwright install chromium - -# Then log in: -flight auth pp login +# One-time: download Chromium into Playwright's global cache +# (~/Library/Caches/ms-playwright on macOS). The browser install is +# disk-cached, not venv-resident. +uvx --from playwright playwright install chromium + +# Then log in. `--with playwright` adds the Python package ephemerally +# for this one invocation — no need to mutate flight-cli's venv. +uv run --with playwright flight auth pp login flight auth pp whoami # confirm ``` +If you'd rather make playwright a permanent venv resident (skip `--with` every time), there's an optional install extra: `uv pip install -e '.[browser-login]'`. Most users don't need this. + **2. `--from-chrome` (cookie import).** Reads Supabase cookies from your local Chrome profile via `rookiepy`. Quicker than headed login since you don't sign in again — but the CLI then *shares* Chrome's refresh-token chain. Supabase rotates refresh tokens single-use, so a refresh on one side will eventually invalidate the other. Use this when you don't mind re-importing periodically. ```sh diff --git a/src/flight_cli/pp/auth.py b/src/flight_cli/pp/auth.py index 7ea1626..273a423 100644 --- a/src/flight_cli/pp/auth.py +++ b/src/flight_cli/pp/auth.py @@ -306,8 +306,9 @@ def login_via_browser( except ImportError as e: # pragma: no cover — install-time concern raise PPAuthError( "playwright isn't installed (needed for headed browser login). " - "Install with: uv pip install 'flight-cli[browser-login]' " - "and then run: uv run playwright install chromium. " + "Quickest path: rerun this command via " + "`uv run --with playwright flight auth pp login` " + "(plus a one-time `uvx --from playwright playwright install chromium`). " "Alternatives: --from-chrome or --tokens-file PATH.", ) from e diff --git a/src/flight_cli/pp/cli.py b/src/flight_cli/pp/cli.py index 5657e2e..fdd387a 100644 --- a/src/flight_cli/pp/cli.py +++ b/src/flight_cli/pp/cli.py @@ -104,7 +104,9 @@ def pp_login( Default (no flag): open a headed Playwright Chromium so you can sign in normally. Captures the resulting Supabase session into ~/.config/flight-cli/pp.json. Independent of any user-facing Chrome - PP session. Requires the browser-login extra (see README PP Setup). + PP session. Needs playwright at runtime (ephemeral or installed): the + README PP Setup section covers `uv run --with playwright` + the + one-time `uvx --from playwright playwright install chromium`. --from-chrome: import the session from your local Chrome profile via cookies. Quicker, but the CLI then shares Chrome's refresh-token