feat: publish shusub2 tui

This commit is contained in:
2026-06-09 15:06:26 +08:00
commit ffde69aee6
7 changed files with 838 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.venv/
__pycache__/
tests/__pycache__/
build/
dist/
*.egg-info/
+21
View File
@@ -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.
+75
View File
@@ -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.
+29
View File
@@ -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"]
+438
View File
@@ -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())
+139
View File
@@ -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()
Generated
+130
View File
@@ -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" },
]