feat: infer status url and check updates
This commit is contained in:
@@ -9,25 +9,23 @@ 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
|
||||
--api-url https://codex.server2.shujk.top/1232131231313123/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
|
||||
shusub2 --api-url https://codex.server2.shujk.top/1232131231313123/api/tui/accounts
|
||||
```
|
||||
|
||||
Configure default API and optional status URLs for `shusub2`:
|
||||
Configure the default public 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
|
||||
printf '%s\n' 'https://example.com/api/status' > ~/.config/shusub2/status-url
|
||||
printf '%s\n' 'https://codex.server2.shujk.top/1232131231313123/api/tui/accounts' > ~/.config/shusub2/api-url
|
||||
chmod 600 ~/.config/shusub2/api-url
|
||||
chmod 600 ~/.config/shusub2/status-url
|
||||
shusub2
|
||||
```
|
||||
|
||||
@@ -38,6 +36,9 @@ Environment variables override the config file:
|
||||
- `SHUSUB2_API_URL_FILE`
|
||||
- `SHUSUB2_STATUS_URL`
|
||||
- `SHUSUB2_STATUS_URL_FILE`
|
||||
- `SHUSUB2_VERSION_CHECK_URL`
|
||||
- `SHUSUB2_VERSION_CHECK_TIMEOUT`
|
||||
- `SHUSUB2_NO_VERSION_CHECK`
|
||||
|
||||
## Local Development
|
||||
|
||||
@@ -64,6 +65,12 @@ The TUI reads `/api/tui/accounts` and can optionally read `sub2api-status`
|
||||
`/api/status` for channel monitor health. It does not need SSH, database
|
||||
access, API keys, OAuth credentials, or plaintext env files.
|
||||
|
||||
When `--status-url` is omitted, `shusub2` infers a sibling `/api/status` URL
|
||||
from account URLs ending in `/api/tui/accounts`, so the public Codex endpoint
|
||||
automatically enables monitor availability. On startup it also checks the
|
||||
public Gitea repo for a newer package version and prints a short upgrade hint
|
||||
when one is available.
|
||||
|
||||
Columns are ordered for scanning inside zellij:
|
||||
|
||||
```text
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "shusub2"
|
||||
version = "0.1.5"
|
||||
version = "0.1.6"
|
||||
description = "Terminal UI for Sub2API account quota and daily usage"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
+91
-4
@@ -4,8 +4,10 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import importlib.metadata
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
@@ -13,14 +15,19 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
APP_NAME = "shusub2"
|
||||
FALLBACK_VERSION = "0.1.6"
|
||||
DEFAULT_API_URL = "http://127.0.0.1:18318/api/tui/accounts"
|
||||
DEFAULT_CONFIG_FILE = "~/.config/shusub2/api-url"
|
||||
DEFAULT_STATUS_CONFIG_FILE = "~/.config/shusub2/status-url"
|
||||
DEFAULT_VERSION_CHECK_URL = "https://gitea.shujk.top/shujakuin/shusub2/raw/branch/main/pyproject.toml"
|
||||
DEFAULT_REFRESH_SECONDS = 60
|
||||
DEFAULT_TIMEOUT_SECONDS = 10
|
||||
DEFAULT_VERSION_CHECK_TIMEOUT_SECONDS = 2
|
||||
MONITOR_OK_STATUSES = {"operational", "ok", "success"}
|
||||
MONITOR_FAILED_STATUSES = {"error", "failed", "failure"}
|
||||
MONITOR_STOPWORDS = {"response", "responses", "monitor"}
|
||||
INSTALL_COMMAND = "uv tool install --force git+https://gitea.shujk.top/shujakuin/shusub2.git"
|
||||
|
||||
|
||||
def env_int(name: str, default: int, *, minimum: int = 1) -> int:
|
||||
@@ -61,6 +68,71 @@ def default_status_url() -> str:
|
||||
)
|
||||
|
||||
|
||||
def inferred_status_url(api_url: str) -> str:
|
||||
parsed = urllib.parse.urlparse(str(api_url or "").strip())
|
||||
if not parsed.scheme or not parsed.netloc:
|
||||
return ""
|
||||
path = parsed.path.rstrip("/")
|
||||
if path.endswith("/api/tui/accounts"):
|
||||
status_path = path[: -len("/api/tui/accounts")] + "/api/status"
|
||||
elif path.endswith("/api/accounts"):
|
||||
status_path = path[: -len("/api/accounts")] + "/api/status"
|
||||
else:
|
||||
return ""
|
||||
return urllib.parse.urlunparse(parsed._replace(path=status_path, params="", query="", fragment=""))
|
||||
|
||||
|
||||
def current_version() -> str:
|
||||
try:
|
||||
return importlib.metadata.version(APP_NAME)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
return FALLBACK_VERSION
|
||||
except Exception:
|
||||
return FALLBACK_VERSION
|
||||
|
||||
|
||||
def parse_version(value: Any) -> tuple[int, ...]:
|
||||
parts = []
|
||||
for part in re.split(r"[^0-9]+", str(value or "")):
|
||||
if part:
|
||||
parts.append(int(part))
|
||||
return tuple(parts)
|
||||
|
||||
|
||||
def version_is_newer(latest: str, current: str) -> bool:
|
||||
latest_parts = parse_version(latest)
|
||||
current_parts = parse_version(current)
|
||||
width = max(len(latest_parts), len(current_parts), 1)
|
||||
return latest_parts + (0,) * (width - len(latest_parts)) > current_parts + (0,) * (width - len(current_parts))
|
||||
|
||||
|
||||
def latest_version_from_text(text: str) -> str:
|
||||
match = re.search(r'(?m)^version\s*=\s*"([^"]+)"', text)
|
||||
return "" if not match else match.group(1).strip()
|
||||
|
||||
|
||||
def fetch_latest_version(url: str, timeout: int) -> str:
|
||||
req = urllib.request.Request(url, headers={"Accept": "text/plain"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as response:
|
||||
return latest_version_from_text(response.read(65536).decode("utf-8", errors="replace"))
|
||||
|
||||
|
||||
def version_update_message(latest: str, current: str | None = None) -> str:
|
||||
current = current or current_version()
|
||||
if not latest or not version_is_newer(latest, current):
|
||||
return ""
|
||||
return f"update available: {APP_NAME} {current} -> {latest}; run `{INSTALL_COMMAND}`"
|
||||
|
||||
|
||||
def check_version_update(url: str, timeout: int, *, disabled: bool = False) -> str:
|
||||
if disabled or os.environ.get("SHUSUB2_NO_VERSION_CHECK", "").strip().lower() in {"1", "true", "yes", "on"}:
|
||||
return ""
|
||||
try:
|
||||
return version_update_message(fetch_latest_version(url, timeout))
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def as_float(value: Any) -> float:
|
||||
try:
|
||||
if value is None or str(value).strip() == "":
|
||||
@@ -422,7 +494,7 @@ def print_once(payload: dict[str, Any], filter_text: str = "", status_payload: d
|
||||
)
|
||||
|
||||
|
||||
def run_textual(api_url: str, status_url: str, refresh_seconds: int, timeout: int) -> int:
|
||||
def run_textual(api_url: str, status_url: str, refresh_seconds: int, timeout: int, version_message: str = "") -> int:
|
||||
try:
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import DataTable, Footer, Header, Input, Static
|
||||
@@ -486,7 +558,7 @@ def run_textual(api_url: str, status_url: str, refresh_seconds: int, timeout: in
|
||||
self.payload = fetch_payload(api_url, timeout, refresh=refresh)
|
||||
self.status_payload, self.status_error = fetch_optional_payload(status_url, timeout)
|
||||
self.render_payload()
|
||||
status_bits = [monitor_summary(self.status_payload, self.status_error), f"source {self.payload.get('source_name') or '-'}"]
|
||||
status_bits = [version_message, monitor_summary(self.status_payload, self.status_error), f"source {self.payload.get('source_name') or '-'}"]
|
||||
status.update(" | ".join(bit for bit in status_bits if bit) + f" | {api_url}")
|
||||
except Exception as exc:
|
||||
status.update(f"error: {exc}")
|
||||
@@ -552,6 +624,13 @@ 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("--status-url", default=default_status_url(), help="optional sub2api-status /api/status URL for channel monitor health")
|
||||
parser.add_argument("--version-check-url", default=os.environ.get("SHUSUB2_VERSION_CHECK_URL", DEFAULT_VERSION_CHECK_URL))
|
||||
parser.add_argument(
|
||||
"--version-check-timeout",
|
||||
type=int,
|
||||
default=env_int("SHUSUB2_VERSION_CHECK_TIMEOUT", DEFAULT_VERSION_CHECK_TIMEOUT_SECONDS),
|
||||
)
|
||||
parser.add_argument("--no-version-check", action="store_true", default=os.environ.get("SHUSUB2_NO_VERSION_CHECK", "").strip().lower() in {"1", "true", "yes", "on"})
|
||||
parser.add_argument(
|
||||
"--refresh-seconds",
|
||||
type=int,
|
||||
@@ -565,11 +644,19 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
status_url = str(args.status_url or "").strip() or inferred_status_url(args.api_url)
|
||||
version_message = check_version_update(
|
||||
args.version_check_url,
|
||||
max(1, args.version_check_timeout),
|
||||
disabled=bool(args.no_version_check),
|
||||
)
|
||||
if args.once:
|
||||
status_payload, status_error = fetch_optional_payload(args.status_url, args.timeout)
|
||||
if version_message:
|
||||
print(version_message)
|
||||
status_payload, status_error = fetch_optional_payload(status_url, args.timeout)
|
||||
print_once(fetch_payload(args.api_url, args.timeout, refresh=True), args.filter, status_payload, status_error)
|
||||
return 0
|
||||
return run_textual(args.api_url, args.status_url, max(1, args.refresh_seconds), max(1, args.timeout))
|
||||
return run_textual(args.api_url, status_url, max(1, args.refresh_seconds), max(1, args.timeout), version_message)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -86,6 +86,23 @@ class Sub2APIQuotaTUITests(unittest.TestCase):
|
||||
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_infers_public_status_url_from_accounts_url(self) -> None:
|
||||
mod = load_module()
|
||||
|
||||
self.assertEqual(
|
||||
mod.inferred_status_url("https://codex.server2.shujk.top/1232131231313123/api/tui/accounts"),
|
||||
"https://codex.server2.shujk.top/1232131231313123/api/status",
|
||||
)
|
||||
self.assertEqual(mod.inferred_status_url("https://example.com/nope"), "")
|
||||
|
||||
def test_version_update_message_only_for_newer_versions(self) -> None:
|
||||
mod = load_module()
|
||||
|
||||
self.assertEqual(mod.latest_version_from_text('name = "shusub2"\nversion = "0.1.7"\n'), "0.1.7")
|
||||
self.assertIn("0.1.6 -> 0.1.7", mod.version_update_message("0.1.7", "0.1.6"))
|
||||
self.assertEqual(mod.version_update_message("0.1.6", "0.1.6"), "")
|
||||
self.assertEqual(mod.version_update_message("0.1.5", "0.1.6"), "")
|
||||
|
||||
def test_once_output_is_name_first_and_includes_daily_quota(self) -> None:
|
||||
mod = load_module()
|
||||
payload = {
|
||||
|
||||
Reference in New Issue
Block a user