feat: publish shusub2 tui
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
tests/__pycache__/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.egg-info/
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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"]
|
||||||
@@ -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())
|
||||||
@@ -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()
|
||||||
@@ -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" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user