commit ffde69aee68eac433f658d5909b4cf362adbcf7b Author: yunyaozhou Date: Tue Jun 9 15:06:26 2026 +0800 feat: publish shusub2 tui diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8e38998 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.venv/ +__pycache__/ +tests/__pycache__/ +build/ +dist/ +*.egg-info/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..729c91b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Shujakuin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3756878 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# shusub2 + +Terminal UI for the token-safe Sub2API account feed exposed by +`cliproxy-codex-quota`. + +## Quick Start + +Run once from a public Git repo with `uvx`: + +```bash +uvx --from git+https://gitea.shujk.top/shujakuin/shusub2.git shusub2 \ + --api-url https://example.com/api/tui/accounts +``` + +Install as a user command: + +```bash +uv tool install git+https://gitea.shujk.top/shujakuin/shusub2.git +shusub2 --api-url https://example.com/api/tui/accounts +``` + +Configure a default API URL for `shusub2`: + +```bash +mkdir -p ~/.config/shusub2 +chmod 700 ~/.config/shusub2 +printf '%s\n' 'https://example.com/api/tui/accounts' > ~/.config/shusub2/api-url +chmod 600 ~/.config/shusub2/api-url +shusub2 +``` + +Environment variables override the config file: + +- `SHUSUB2_API_URL` +- `SUB2API_QUOTA_TUI_API_URL` +- `SHUSUB2_API_URL_FILE` + +## Local Development + +```bash +cd apps/sub2api-quota-tui +uv run shusub2 +``` + +Run inside zellij: + +```bash +zellij action new-pane --name sub2api-quota -- \ + bash -lc 'cd /home/shujakuin/infra/apps/sub2api-quota-tui && uv run shusub2' +``` + +Configuration: + +- `--api-url` / `SUB2API_QUOTA_TUI_API_URL` +- `--refresh-seconds` / `SUB2API_QUOTA_TUI_REFRESH_SECONDS` +- `--timeout` / `SUB2API_QUOTA_TUI_TIMEOUT` + +The TUI reads only `/api/tui/accounts`; it does not need SSH, database access, +API keys, OAuth credentials, or plaintext env files. + +Columns are ordered for scanning inside zellij: + +```text +Name | Provider | Group | Daily | Today | Tokens | Req | Kind | 5h | 7d | Reset | Status +``` + +`Provider` distinguishes `openai` and `anthropic` accounts from the public +`platform` field returned by the API. + +`Daily` is shown as `used/limit` when Sub2API has `quota_daily_*` fields in +`accounts.extra`; otherwise it is `-`. + +`Group` is the derived Sub2API tier alias group. Higher tiers win when multiple +aliases exist: `id < slow < fast < sfast`. The table shows `sfast` first, then +`fast`, `slow`, `id`, and ungrouped accounts. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..04b66d2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = "shusub2" +version = "0.1.1" +description = "Terminal UI for Sub2API account quota and daily usage" +readme = "README.md" +requires-python = ">=3.11" +license = { text = "MIT" } +authors = [ + { name = "Shujakuin" }, +] +keywords = ["sub2api", "quota", "tui", "textual", "openai", "anthropic"] +dependencies = [ + "textual>=0.89.1", +] + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project.scripts] +shusub2 = "sub2api_quota_tui:main" +sub2api-quota-tui = "sub2api_quota_tui:main" + +[project.urls] +Homepage = "https://gitea.shujk.top/shujakuin/shusub2" +Repository = "https://gitea.shujk.top/shujakuin/shusub2" + +[tool.setuptools] +py-modules = ["sub2api_quota_tui"] diff --git a/sub2api_quota_tui.py b/sub2api_quota_tui.py new file mode 100644 index 0000000..d60be34 --- /dev/null +++ b/sub2api_quota_tui.py @@ -0,0 +1,438 @@ +#!/usr/bin/env python3 +"""Textual TUI for Sub2API quota and daily account usage.""" +from __future__ import annotations + +import argparse +import datetime as dt +import json +import os +import sys +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any + + +DEFAULT_API_URL = "http://127.0.0.1:18318/api/tui/accounts" +DEFAULT_CONFIG_FILE = "~/.config/shusub2/api-url" +DEFAULT_REFRESH_SECONDS = 60 +DEFAULT_TIMEOUT_SECONDS = 10 + + +def env_int(name: str, default: int, *, minimum: int = 1) -> int: + try: + return max(minimum, int(os.environ.get(name, default))) + except Exception: + return default + + +def default_api_url() -> str: + for name in ("SHUSUB2_API_URL", "SUB2API_QUOTA_TUI_API_URL"): + value = os.environ.get(name, "").strip() + if value: + return value + config_file = Path(os.environ.get("SHUSUB2_API_URL_FILE", DEFAULT_CONFIG_FILE)).expanduser() + try: + for line in config_file.read_text(encoding="utf-8").splitlines(): + value = line.strip() + if value and not value.startswith("#"): + return value + except OSError: + pass + return DEFAULT_API_URL + + +def as_float(value: Any) -> float: + try: + if value is None or str(value).strip() == "": + return 0.0 + return float(value) + except Exception: + return 0.0 + + +def as_int(value: Any) -> int: + try: + if value is None or str(value).strip() == "": + return 0 + return int(float(value)) + except Exception: + return 0 + + +def format_cost(value: Any) -> str: + amount = as_float(value) + if amount == 0: + return "$0" + if abs(amount) < 0.01: + return f"${amount:.6f}".rstrip("0").rstrip(".") + if abs(amount) < 10: + return f"${amount:.3f}".rstrip("0").rstrip(".") + return f"${amount:.2f}".rstrip("0").rstrip(".") + + +def format_count(value: Any) -> str: + number = as_int(value) + if abs(number) >= 1_000_000: + return f"{number / 1_000_000:.1f}M".rstrip("0").rstrip(".") + if abs(number) >= 1_000: + return f"{number / 1_000:.1f}K".rstrip("0").rstrip(".") + return str(number) + + +def format_percent(value: Any) -> str: + if value is None or str(value).strip() == "": + return "-" + percent = as_float(value) + rounded = round(percent, 1) + if rounded.is_integer(): + return f"{int(rounded)}%" + return f"{rounded}%" + + +def parse_time(value: Any) -> dt.datetime | None: + text = str(value or "").strip() + if not text: + return None + try: + normalized = text[:-1] + "+00:00" if text.endswith("Z") else text + return dt.datetime.fromisoformat(normalized) + except Exception: + return None + + +def short_time(value: Any) -> str: + parsed = parse_time(value) + if not parsed: + return "-" + return parsed.astimezone().strftime("%m-%d %H:%M") + + +def add_refresh_param(api_url: str, refresh: bool) -> str: + if not refresh: + return api_url + parsed = urllib.parse.urlparse(api_url) + query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True) + query = [(key, value) for key, value in query if key != "refresh"] + query.append(("refresh", "1")) + return urllib.parse.urlunparse(parsed._replace(query=urllib.parse.urlencode(query))) + + +def fetch_payload(api_url: str, timeout: int, *, refresh: bool = False) -> dict[str, Any]: + req = urllib.request.Request(add_refresh_param(api_url, refresh), headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=timeout) as response: + data = json.loads(response.read().decode("utf-8")) + if not isinstance(data, dict): + raise RuntimeError("API did not return a JSON object") + return data + + +def kind_label(kind: Any) -> str: + return { + "quota_limited": "quota", + "pending_quota": "pending", + "daily_limited": "daily", + "pay_as_you_go": "usage", + "disabled": "disabled", + }.get(str(kind or ""), str(kind or "unknown")) + + +def provider_label(account: dict[str, Any]) -> str: + text = str(account.get("provider") or account.get("platform") or "").strip().lower() + if not text: + return "-" + if "anthropic" in text or "claude" in text: + return "anthropic" + if "openai" in text or "chatgpt" in text: + return "openai" + return text + + +def routing_group_sort_rank(value: Any) -> int: + return {"sfast": 0, "fast": 1, "slow": 2, "id": 3, "": 4, "-": 4}.get(str(value or "").strip().lower(), 4) + + +def window_for(account: dict[str, Any], window_id: str) -> dict[str, Any]: + for window in account.get("windows") or []: + if isinstance(window, dict) and window.get("id") == window_id: + return window + return {} + + +def window_cell(account: dict[str, Any], window_id: str) -> str: + window = window_for(account, window_id) + if not window or window.get("used_percent") is None: + return "-" + return f"{format_percent(window.get('used_percent'))}/{format_percent(window.get('remaining_percent'))}" + + +def daily_quota_cell(account: dict[str, Any]) -> str: + limit = account.get("daily_quota_limit") + used = account.get("daily_quota_used") + if limit is None and used is None: + return "-" + if limit is None: + return str(used) + return f"{used or 0}/{limit}" + + +def reset_cell(account: dict[str, Any]) -> str: + daily_reset = short_time(account.get("daily_quota_reset_at")) + if daily_reset != "-": + return daily_reset + five_hour = window_for(account, "five-hour") + weekly = window_for(account, "weekly") + for window in (five_hour, weekly): + reset = short_time(window.get("reset")) + if reset != "-": + return reset + return "-" + + +def normalize_account_rows(payload: dict[str, Any], filter_text: str = "") -> list[dict[str, Any]]: + needle = filter_text.strip().lower() + rows = [] + for account in payload.get("accounts") or []: + if not isinstance(account, dict): + continue + haystack = " ".join( + str(account.get(key) or "") + for key in ( + "id", + "name", + "routing_group", + "provider", + "kind", + "status", + "account_type", + "platform", + "plan", + "daily_quota_timezone", + ) + ).lower() + if needle and needle not in haystack: + continue + routing_group = str(account.get("routing_group") or "-") + rows.append( + { + "id": as_int(account.get("id")), + "name": str(account.get("name") or ""), + "routing_group": routing_group, + "provider": provider_label(account), + "kind": str(account.get("kind") or "unknown"), + "kind_label": kind_label(account.get("kind")), + "status": str(account.get("status") or ""), + "account_type": str(account.get("account_type") or ""), + "plan": str(account.get("plan") or ""), + "priority": account.get("priority"), + "today_cost_usd": as_float(account.get("today_cost_usd")), + "today_actual_cost_usd": as_float(account.get("today_actual_cost_usd")), + "today_tokens": as_int(account.get("today_tokens")), + "today_requests": as_int(account.get("today_requests")), + "daily_quota": account.get("daily_quota") if isinstance(account.get("daily_quota"), dict) else {}, + "daily_quota_limit": account.get("daily_quota_limit"), + "daily_quota_used": account.get("daily_quota_used"), + "daily_quota_remaining": account.get("daily_quota_remaining"), + "daily_quota_used_percent": account.get("daily_quota_used_percent"), + "daily_quota_reset_at": account.get("daily_quota_reset_at"), + "daily_quota_cell": daily_quota_cell(account), + "quota_used_percent_max": account.get("quota_used_percent_max"), + "five_hour": window_cell(account, "five-hour"), + "weekly": window_cell(account, "weekly"), + "reset": reset_cell(account), + "latest_usage_at": short_time(account.get("latest_usage_at") or account.get("last_used_at")), + "error": str(account.get("error") or ""), + "raw": account, + } + ) + rows.sort( + key=lambda item: ( + routing_group_sort_rank(item["routing_group"]), + 1 if item["kind"] == "disabled" else 0, + -as_float(item["today_cost_usd"]), + -as_int(item["today_tokens"]), + -as_float(item["daily_quota_used_percent"]), + -as_float(item["quota_used_percent_max"]), + str(item["name"]).lower(), + ) + ) + return rows + + +def summary_line(payload: dict[str, Any]) -> str: + totals = payload.get("totals") if isinstance(payload.get("totals"), dict) else {} + generated = short_time(payload.get("generated_local") or payload.get("generated_at")) + cached = "cached" if payload.get("cached") else "live" + return ( + f"{generated} {cached} | " + f"{as_int(totals.get('usable_accounts'))}/{as_int(totals.get('total_accounts'))} usable | " + f"today {format_cost(totals.get('today_cost_usd'))} | " + f"{format_count(totals.get('today_tokens'))} tokens | " + f"{format_count(totals.get('today_requests'))} req" + ) + + +def print_once(payload: dict[str, Any], filter_text: str = "") -> None: + print(summary_line(payload)) + print("name provider group daily today tokens req kind 5h 7d reset status") + for row in normalize_account_rows(payload, filter_text): + print( + f"{row['name'][:30]:<30} " + f"{row['provider']:<9} " + f"{row['routing_group']:<6} " + f"{row['daily_quota_cell']:<10} " + f"{format_cost(row['today_cost_usd']):<10} " + f"{format_count(row['today_tokens']):<8} " + f"{format_count(row['today_requests']):<5} " + f"{row['kind_label']:<9} " + f"{row['five_hour']:<9} " + f"{row['weekly']:<9} " + f"{row['reset']:<11} " + f"{row['status']}" + ) + + +def run_textual(api_url: str, refresh_seconds: int, timeout: int) -> int: + try: + from textual.app import App, ComposeResult + from textual.widgets import DataTable, Footer, Header, Input, Static + except ImportError: + print("Textual is required. Run with: uv run --with textual python sub2api_quota_tui.py", file=sys.stderr) + return 2 + + class Sub2APIQuotaApp(App[None]): + CSS = """ + #summary { height: 1; padding: 0 1; color: $accent; } + #filter { height: 3; } + #accounts { height: 1fr; } + #detail { height: 3; padding: 0 1; border-top: solid $panel; } + #status { height: 1; padding: 0 1; color: $text-muted; } + """ + BINDINGS = [ + ("q", "quit", "Quit"), + ("r", "refresh", "Refresh"), + ("/", "focus_filter", "Filter"), + ] + + def __init__(self) -> None: + super().__init__() + self.payload: dict[str, Any] = {} + self.rows: list[dict[str, Any]] = [] + self.row_by_key: dict[str, dict[str, Any]] = {} + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + yield Static("", id="summary") + yield Input(placeholder="filter", id="filter") + yield DataTable(id="accounts") + yield Static("", id="detail") + yield Static("", id="status") + yield Footer() + + def on_mount(self) -> None: + table = self.query_one("#accounts", DataTable) + table.cursor_type = "row" + table.zebra_stripes = True + table.add_columns("Name", "Provider", "Group", "Daily", "Today", "Tokens", "Req", "Kind", "5h", "7d", "Reset", "Status") + self.refresh_data(refresh=True) + self.set_interval(refresh_seconds, self.refresh_data) + + def action_refresh(self) -> None: + self.refresh_data(refresh=True) + + def action_focus_filter(self) -> None: + self.query_one("#filter", Input).focus() + + def on_input_changed(self, event: Input.Changed) -> None: + if event.input.id == "filter": + self.render_payload() + + def refresh_data(self, refresh: bool = False) -> None: + status = self.query_one("#status", Static) + status.update("refreshing...") + try: + self.payload = fetch_payload(api_url, timeout, refresh=refresh) + self.render_payload() + status.update(f"source {self.payload.get('source_name') or '-'} | {api_url}") + except Exception as exc: + status.update(f"error: {exc}") + + def render_payload(self) -> None: + filter_text = self.query_one("#filter", Input).value + self.rows = normalize_account_rows(self.payload, filter_text) + table = self.query_one("#accounts", DataTable) + table.clear() + self.row_by_key = {} + for row in self.rows: + key = str(row["id"]) + self.row_by_key[key] = row + table.add_row( + row["name"], + row["provider"], + row["routing_group"], + row["daily_quota_cell"], + format_cost(row["today_cost_usd"]), + format_count(row["today_tokens"]), + format_count(row["today_requests"]), + row["kind_label"], + row["five_hour"], + row["weekly"], + row["reset"], + row["status"], + key=key, + ) + self.query_one("#summary", Static).update(summary_line(self.payload)) + if self.rows: + self.render_detail(self.rows[0]) + else: + self.query_one("#detail", Static).update("no accounts") + + def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: + key = str(event.row_key.value) + row = self.row_by_key.get(key) + if row: + self.render_detail(row) + + def render_detail(self, row: dict[str, Any]) -> None: + raw = row.get("raw") if isinstance(row.get("raw"), dict) else {} + detail = ( + f"{row['name']} | {row['provider']} | group {row['routing_group']} | {row['kind_label']} | {row['account_type']} | " + f"daily {row['daily_quota_cell']} ({format_percent(row['daily_quota_used_percent'])} used, " + f"{row['daily_quota_remaining'] if row['daily_quota_remaining'] is not None else '-'} left) | " + f"today {format_cost(row['today_cost_usd'])}, {format_count(row['today_tokens'])} tokens, " + f"{format_count(row['today_requests'])} req | latest {row['latest_usage_at']} | " + f"priority {row['priority'] if row['priority'] is not None else '-'}" + ) + error = str(raw.get("error") or "").strip() + if error: + detail += f" | {error}" + self.query_one("#detail", Static).update(detail) + + Sub2APIQuotaApp().run() + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Sub2API quota and daily usage TUI") + parser.add_argument("--api-url", default=default_api_url()) + parser.add_argument( + "--refresh-seconds", + type=int, + default=env_int("SUB2API_QUOTA_TUI_REFRESH_SECONDS", DEFAULT_REFRESH_SECONDS), + ) + parser.add_argument("--timeout", type=int, default=env_int("SUB2API_QUOTA_TUI_TIMEOUT", DEFAULT_TIMEOUT_SECONDS)) + parser.add_argument("--once", action="store_true", help="print one snapshot and exit") + parser.add_argument("--filter", default="", help="initial filter for --once output") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + if args.once: + print_once(fetch_payload(args.api_url, args.timeout, refresh=True), args.filter) + return 0 + return run_textual(args.api_url, max(1, args.refresh_seconds), max(1, args.timeout)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_payload.py b/tests/test_payload.py new file mode 100644 index 0000000..22418d0 --- /dev/null +++ b/tests/test_payload.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from pathlib import Path +import importlib.util +import sys +import unittest + + +def load_module(): + module_path = Path(__file__).resolve().parents[1] / "sub2api_quota_tui.py" + spec = importlib.util.spec_from_file_location("sub2api_quota_tui", module_path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +class Sub2APIQuotaTUITests(unittest.TestCase): + def test_normalize_rows_sorts_by_usage_and_formats_windows(self) -> None: + mod = load_module() + payload = { + "generated_at": "2026-06-09T12:00:00+08:00", + "totals": {"total_accounts": 2, "usable_accounts": 2, "today_cost_usd": 0.75, "today_tokens": 3000, "today_requests": 9}, + "accounts": [ + { + "id": 1, + "name": "quota-low", + "routing_group": "slow", + "provider": "anthropic", + "kind": "quota_limited", + "status": "ok", + "account_type": "oauth", + "today_cost_usd": 0.25, + "today_tokens": 2000, + "today_requests": 4, + "windows": [ + {"id": "five-hour", "used_percent": 80, "remaining_percent": 20, "reset": "2026-06-09T15:00:00+08:00"}, + {"id": "weekly", "used_percent": 10, "remaining_percent": 90, "reset": "2026-06-10T15:00:00+08:00"}, + ], + }, + { + "id": 2, + "name": "payg-high", + "routing_group": "fast", + "provider": "openai", + "kind": "pay_as_you_go", + "status": "ok", + "account_type": "apikey", + "daily_quota_limit": 100, + "daily_quota_used": 91.2, + "daily_quota_remaining": 8.8, + "daily_quota_used_percent": 91.2, + "today_cost_usd": 0.5, + "today_tokens": 1000, + "today_requests": 5, + "windows": [], + }, + ], + } + + rows = mod.normalize_account_rows(payload) + + self.assertEqual([row["name"] for row in rows], ["payg-high", "quota-low"]) + self.assertEqual(rows[0]["routing_group"], "fast") + self.assertEqual(rows[0]["provider"], "openai") + self.assertEqual(rows[0]["daily_quota_cell"], "91.2/100") + self.assertEqual(rows[0]["kind_label"], "usage") + self.assertEqual(rows[1]["five_hour"], "80%/20%") + self.assertEqual(rows[1]["weekly"], "10%/90%") + self.assertIn("today $0.75", mod.summary_line(payload)) + + def test_filter_matches_kind_and_name(self) -> None: + mod = load_module() + payload = { + "accounts": [ + {"id": 1, "name": "alpha", "routing_group": "slow", "provider": "anthropic", "kind": "quota_limited", "today_cost_usd": 0}, + {"id": 2, "name": "beta", "routing_group": "sfast", "provider": "openai", "kind": "pay_as_you_go", "today_cost_usd": 0}, + ] + } + + self.assertEqual([row["name"] for row in mod.normalize_account_rows(payload, "pay")], ["beta"]) + self.assertEqual([row["name"] for row in mod.normalize_account_rows(payload, "alp")], ["alpha"]) + self.assertEqual([row["name"] for row in mod.normalize_account_rows(payload, "sfast")], ["beta"]) + self.assertEqual([row["name"] for row in mod.normalize_account_rows(payload, "anthropic")], ["alpha"]) + + def test_once_output_is_name_first_and_includes_daily_quota(self) -> None: + mod = load_module() + payload = { + "generated_at": "2026-06-09T12:00:00+08:00", + "totals": {"total_accounts": 1, "usable_accounts": 1, "today_cost_usd": 1, "today_tokens": 100, "today_requests": 2}, + "accounts": [ + { + "id": 2, + "name": "input 300", + "routing_group": "fast", + "provider": "openai", + "kind": "daily_limited", + "status": "ok", + "account_type": "apikey", + "daily_quota_limit": 300, + "daily_quota_used": 96.6, + "daily_quota_remaining": 203.4, + "daily_quota_used_percent": 32.2, + "daily_quota_reset_at": "2026-06-09T16:00:00Z", + "today_cost_usd": 1, + "today_tokens": 100, + "today_requests": 2, + "windows": [], + } + ], + } + + rows = mod.normalize_account_rows(payload) + + self.assertEqual(rows[0]["kind_label"], "daily") + self.assertEqual(rows[0]["routing_group"], "fast") + self.assertEqual(rows[0]["provider"], "openai") + self.assertEqual(rows[0]["daily_quota_cell"], "96.6/300") + self.assertTrue(rows[0]["reset"].endswith("00:00") or rows[0]["reset"] != "-") + + def test_routing_group_sort_shows_sfast_first(self) -> None: + mod = load_module() + payload = { + "accounts": [ + {"id": 1, "name": "manual", "routing_group": "id", "kind": "pay_as_you_go", "today_cost_usd": 10}, + {"id": 2, "name": "slow", "routing_group": "slow", "kind": "pay_as_you_go", "today_cost_usd": 1}, + {"id": 3, "name": "fast", "routing_group": "fast", "kind": "pay_as_you_go", "today_cost_usd": 1}, + {"id": 4, "name": "sfast", "routing_group": "sfast", "kind": "pay_as_you_go", "today_cost_usd": 1}, + ] + } + + rows = mod.normalize_account_rows(payload) + + self.assertEqual([row["name"] for row in rows], ["sfast", "fast", "slow", "manual"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..4ce7448 --- /dev/null +++ b/uv.lock @@ -0,0 +1,130 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "linkify-it-py" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +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 = "shusub2" +version = "0.1.1" +source = { editable = "." } +dependencies = [ + { name = "textual" }, +] + +[package.metadata] +requires-dist = [{ name = "textual", specifier = ">=0.89.1" }] + +[[package]] +name = "textual" +version = "8.2.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/7a/c519db0aba5024f86e71e9631810bfdd6866ed2c8695bd7fa34b90e7ef59/textual-8.2.7.tar.gz", hash = "sha256:658f568ff81e30ed43890c3e07520390e5cf1b4763822006e060656b0a88f105", size = 1859249, upload-time = "2026-05-19T10:52:49.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/f5/c1e18bc0707300a0e90204343abbf7d7acd6fb7ebe03a6d4893b99a234b8/textual-8.2.7-py3-none-any.whl", hash = "sha256:4caaa13a90bc4cf9c6c862c067ccd34fe84e9c161710a2a907a8026313b6bd73", size = 731129, upload-time = "2026-05-19T10:52:51.773Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "uc-micro-py" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, +]