cometapi-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cometapi_cli/__init__.py +3 -0
- cometapi_cli/app.py +85 -0
- cometapi_cli/client.py +270 -0
- cometapi_cli/commands/__init__.py +1 -0
- cometapi_cli/commands/account.py +39 -0
- cometapi_cli/commands/balance.py +56 -0
- cometapi_cli/commands/chat.py +104 -0
- cometapi_cli/commands/chat_repl.py +229 -0
- cometapi_cli/commands/config_cmd.py +174 -0
- cometapi_cli/commands/doctor.py +144 -0
- cometapi_cli/commands/logs.py +326 -0
- cometapi_cli/commands/models.py +44 -0
- cometapi_cli/commands/repl.py +134 -0
- cometapi_cli/commands/stats.py +39 -0
- cometapi_cli/commands/tasks.py +130 -0
- cometapi_cli/commands/tokens.py +87 -0
- cometapi_cli/config.py +102 -0
- cometapi_cli/console.py +8 -0
- cometapi_cli/constants.py +55 -0
- cometapi_cli/errors.py +113 -0
- cometapi_cli/formatters.py +156 -0
- cometapi_cli/main.py +8 -0
- cometapi_cli-0.1.0.dist-info/METADATA +228 -0
- cometapi_cli-0.1.0.dist-info/RECORD +27 -0
- cometapi_cli-0.1.0.dist-info/WHEEL +4 -0
- cometapi_cli-0.1.0.dist-info/entry_points.txt +2 -0
- cometapi_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Tokens (API keys) management command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from ..config import get_client, mask_secret
|
|
10
|
+
from ..constants import extract_items, format_ts, quota_to_usd
|
|
11
|
+
from ..errors import handle_errors
|
|
12
|
+
from ..formatters import OutputFormat, output, resolve_format
|
|
13
|
+
|
|
14
|
+
_STATUS_MAP = {1: "active", 2: "disabled", 3: "expired", 4: "exhausted"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _quota_to_usd(quota: int | float) -> str:
|
|
18
|
+
if quota < 0:
|
|
19
|
+
return "Unlimited"
|
|
20
|
+
return quota_to_usd(quota, precision=2)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _format_ts_short(ts: int) -> str:
|
|
24
|
+
return format_ts(ts, fmt="%Y-%m-%d %H:%M")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _format_token_row(t: dict) -> dict:
|
|
28
|
+
key = t.get("key", "")
|
|
29
|
+
unlimited = t.get("unlimited_quota", False)
|
|
30
|
+
return {
|
|
31
|
+
"id": t.get("id", ""),
|
|
32
|
+
"name": t.get("name", "") or "—",
|
|
33
|
+
"key": mask_secret(key) if key else "—",
|
|
34
|
+
"status": _STATUS_MAP.get(t.get("status", 0), "unknown"),
|
|
35
|
+
"balance": "Unlimited" if unlimited else _quota_to_usd(t.get("remain_quota", 0)),
|
|
36
|
+
"used": _quota_to_usd(t.get("used_quota", 0)),
|
|
37
|
+
"created": _format_ts_short(t.get("created_time", 0)),
|
|
38
|
+
"accessed": _format_ts_short(t.get("accessed_time", 0)),
|
|
39
|
+
"expires": "Never" if t.get("expired_time", 0) == -1 else _format_ts_short(t.get("expired_time", 0)),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@handle_errors
|
|
44
|
+
def tokens(
|
|
45
|
+
ctx: typer.Context,
|
|
46
|
+
search: Annotated[str | None, typer.Option("--search", "-s", help="Search tokens by name or key.")] = None,
|
|
47
|
+
page: Annotated[int, typer.Option("--page", "-p", help="Page number.")] = 1,
|
|
48
|
+
limit: Annotated[int, typer.Option("--limit", "-l", help="Results per page.")] = 20,
|
|
49
|
+
output_format: Annotated[OutputFormat | None, typer.Option("--format", "-f", help="Output format.")] = None,
|
|
50
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""List and search your API keys (requires access token)."""
|
|
53
|
+
fmt = resolve_format(ctx, json_output, output_format)
|
|
54
|
+
client = get_client(require_access_token=True)
|
|
55
|
+
|
|
56
|
+
if search:
|
|
57
|
+
resp = client.search_tokens(keyword=search)
|
|
58
|
+
# Search returns a flat list — apply client-side limit if specified
|
|
59
|
+
data = extract_items(resp)
|
|
60
|
+
if limit and len(data) > limit:
|
|
61
|
+
data = data[:limit]
|
|
62
|
+
else:
|
|
63
|
+
resp = client.list_tokens(page=page, page_size=limit)
|
|
64
|
+
data = extract_items(resp)
|
|
65
|
+
|
|
66
|
+
if fmt == OutputFormat.JSON:
|
|
67
|
+
output(data, fmt)
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
if not data:
|
|
71
|
+
from ..console import console
|
|
72
|
+
console.print("[dim]No tokens found.[/]")
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
rows = [_format_token_row(t) for t in data]
|
|
76
|
+
columns = {
|
|
77
|
+
"id": "cyan",
|
|
78
|
+
"name": "green",
|
|
79
|
+
"key": "yellow",
|
|
80
|
+
"status": "green",
|
|
81
|
+
"balance": "green",
|
|
82
|
+
"used": "red",
|
|
83
|
+
"created": "dim",
|
|
84
|
+
"accessed": "dim",
|
|
85
|
+
"expires": "dim",
|
|
86
|
+
}
|
|
87
|
+
output(rows, fmt, title="API Keys (Tokens)", columns=columns)
|
cometapi_cli/config.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Configuration file management for CometAPI CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
if sys.version_info >= (3, 11):
|
|
11
|
+
import tomllib
|
|
12
|
+
else:
|
|
13
|
+
import tomli as tomllib
|
|
14
|
+
|
|
15
|
+
import tomli_w
|
|
16
|
+
|
|
17
|
+
from .errors import ConfigError, ConfigMissing
|
|
18
|
+
|
|
19
|
+
CONFIG_DIR = Path.home() / ".config" / "cometapi"
|
|
20
|
+
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
|
21
|
+
|
|
22
|
+
VALID_KEYS = {"api_key", "access_token", "base_url", "default_model", "output_format"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_config() -> dict[str, Any]:
|
|
26
|
+
"""Load config from TOML file. Returns empty dict if file doesn't exist."""
|
|
27
|
+
if not CONFIG_FILE.exists():
|
|
28
|
+
return {}
|
|
29
|
+
try:
|
|
30
|
+
return tomllib.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
|
31
|
+
except Exception as e:
|
|
32
|
+
raise ConfigError(
|
|
33
|
+
f"Config file is corrupt or unreadable: {CONFIG_FILE}\n{e}\n"
|
|
34
|
+
"Fix the file or delete it and run [bold]cometapi init[/bold]."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def save_config(config: dict[str, Any]) -> None:
|
|
39
|
+
"""Save config to TOML file with restrictive permissions."""
|
|
40
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
CONFIG_FILE.write_bytes(tomli_w.dumps(config).encode("utf-8"))
|
|
42
|
+
CONFIG_FILE.chmod(0o600)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_value(key: str, env_var: str | None = None, default: Any = None) -> Any:
|
|
46
|
+
"""Get a config value with priority: config file > env var > default."""
|
|
47
|
+
cfg = load_config()
|
|
48
|
+
val = cfg.get(key)
|
|
49
|
+
if val:
|
|
50
|
+
return val
|
|
51
|
+
if env_var:
|
|
52
|
+
env_val = os.environ.get(env_var)
|
|
53
|
+
if env_val:
|
|
54
|
+
return env_val
|
|
55
|
+
return default
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def mask_secret(value: str, show_last: int = 4) -> str:
|
|
59
|
+
"""Mask a secret value, showing only the last N characters."""
|
|
60
|
+
if not value:
|
|
61
|
+
return ""
|
|
62
|
+
if len(value) <= show_last:
|
|
63
|
+
return "****"
|
|
64
|
+
return "****" + value[-show_last:]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_client(require_access_token: bool = False, **overrides: Any) -> Any:
|
|
68
|
+
"""Create a CometClient with config file fallback.
|
|
69
|
+
|
|
70
|
+
Priority: override > config file > env var > default.
|
|
71
|
+
"""
|
|
72
|
+
from cometapi_cli.client import CometClient
|
|
73
|
+
|
|
74
|
+
cfg = load_config()
|
|
75
|
+
|
|
76
|
+
api_key = overrides.get("api_key") or cfg.get("api_key") or os.environ.get("COMETAPI_KEY")
|
|
77
|
+
access_token = overrides.get("access_token") or cfg.get("access_token") or os.environ.get("COMETAPI_ACCESS_TOKEN")
|
|
78
|
+
base_url = overrides.get("base_url") or cfg.get("base_url") or os.environ.get("COMETAPI_BASE_URL")
|
|
79
|
+
|
|
80
|
+
if not api_key:
|
|
81
|
+
raise ConfigMissing(
|
|
82
|
+
"API key not configured. Set COMETAPI_KEY or run [bold]cometapi init[/bold].\n"
|
|
83
|
+
"Create one at: https://www.cometapi.com/console/token"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if require_access_token and not access_token:
|
|
87
|
+
raise ConfigMissing(
|
|
88
|
+
"Access token not configured. This command requires COMETAPI_ACCESS_TOKEN.\n"
|
|
89
|
+
"Run [bold]cometapi init[/bold] to configure, or set the environment variable.\n"
|
|
90
|
+
"Get one at: https://www.cometapi.com/console/personal"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
return CometClient(
|
|
94
|
+
api_key=api_key,
|
|
95
|
+
access_token=access_token or None,
|
|
96
|
+
base_url=base_url or None,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_default_model() -> str:
|
|
101
|
+
"""Get the default model from config, env, or fallback."""
|
|
102
|
+
return get_value("default_model", env_var="COMETAPI_DEFAULT_MODEL", default="gpt-5.4")
|
cometapi_cli/console.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Shared constants and helpers for CometAPI CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
# $1.00 USD = 500,000 quota units
|
|
10
|
+
QUOTA_PER_UNIT = 500_000.0
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def quota_to_usd(quota: int | float, precision: int = 4) -> str:
|
|
14
|
+
"""Convert backend quota units to a formatted USD string."""
|
|
15
|
+
if not quota:
|
|
16
|
+
return "$0.00"
|
|
17
|
+
return f"${quota / QUOTA_PER_UNIT:,.{precision}f}"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def format_ts(ts: int, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
|
|
21
|
+
"""Format a Unix timestamp as UTC string."""
|
|
22
|
+
if not ts:
|
|
23
|
+
return "—"
|
|
24
|
+
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime(fmt)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse_date(value: str, label: str) -> int:
|
|
28
|
+
"""Parse a date string into a Unix timestamp (UTC).
|
|
29
|
+
|
|
30
|
+
Accepts YYYY-MM-DD, ISO 8601, or a raw Unix timestamp.
|
|
31
|
+
Exits with code 2 on invalid input.
|
|
32
|
+
"""
|
|
33
|
+
if value.isdigit():
|
|
34
|
+
return int(value)
|
|
35
|
+
for date_fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"):
|
|
36
|
+
try:
|
|
37
|
+
dt = datetime.strptime(value, date_fmt).replace(tzinfo=timezone.utc)
|
|
38
|
+
return int(dt.timestamp())
|
|
39
|
+
except ValueError:
|
|
40
|
+
continue
|
|
41
|
+
from .console import err_console
|
|
42
|
+
|
|
43
|
+
err_console.print(
|
|
44
|
+
f"[red]Invalid {label} date:[/] {value}. "
|
|
45
|
+
"Use YYYY-MM-DD, ISO 8601, or a Unix timestamp."
|
|
46
|
+
)
|
|
47
|
+
raise typer.Exit(code=2)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def extract_items(resp: dict) -> list:
|
|
51
|
+
"""Extract items from paginated or flat API response."""
|
|
52
|
+
data = resp.get("data", [])
|
|
53
|
+
if isinstance(data, dict):
|
|
54
|
+
return data.get("items", data.get("data", []))
|
|
55
|
+
return data
|
cometapi_cli/errors.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Error handling and exit codes for CometAPI CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from enum import IntEnum
|
|
8
|
+
from typing import Any, TypeVar
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ExitCode(IntEnum):
|
|
16
|
+
"""Unix sysexits.h-compatible exit codes."""
|
|
17
|
+
|
|
18
|
+
SUCCESS = 0
|
|
19
|
+
ERROR = 1
|
|
20
|
+
USAGE = 2
|
|
21
|
+
CONFIG_MISSING = 64
|
|
22
|
+
SERVICE_UNAVAILABLE = 69
|
|
23
|
+
AUTH_ERROR = 77
|
|
24
|
+
CONFIG_ERROR = 78
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CometCLIError(Exception):
|
|
28
|
+
"""Base exception for CLI errors with structured exit codes."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, message: str, exit_code: ExitCode = ExitCode.ERROR):
|
|
31
|
+
self.message = message
|
|
32
|
+
self.exit_code = exit_code
|
|
33
|
+
super().__init__(message)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ConfigMissing(CometCLIError):
|
|
37
|
+
"""Required configuration value is missing."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, message: str):
|
|
40
|
+
super().__init__(message, ExitCode.CONFIG_MISSING)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ConfigError(CometCLIError):
|
|
44
|
+
"""Configuration file is corrupt or unreadable."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, message: str):
|
|
47
|
+
super().__init__(message, ExitCode.CONFIG_ERROR)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AuthError(CometCLIError):
|
|
51
|
+
"""Authentication failed."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, message: str):
|
|
54
|
+
super().__init__(message, ExitCode.AUTH_ERROR)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ServiceUnavailable(CometCLIError):
|
|
58
|
+
"""API service is unreachable."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, message: str):
|
|
61
|
+
super().__init__(message, ExitCode.SERVICE_UNAVAILABLE)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def handle_errors(func: F) -> F:
|
|
65
|
+
"""Decorator that catches exceptions and produces user-friendly error output."""
|
|
66
|
+
|
|
67
|
+
@functools.wraps(func)
|
|
68
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
69
|
+
from .console import err_console
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
return func(*args, **kwargs)
|
|
73
|
+
except CometCLIError as e:
|
|
74
|
+
err_console.print(f"[red bold]Error:[/] {e.message}")
|
|
75
|
+
raise typer.Exit(code=e.exit_code)
|
|
76
|
+
except KeyboardInterrupt:
|
|
77
|
+
err_console.print("\n[dim]Interrupted.[/]")
|
|
78
|
+
raise typer.Exit(code=130)
|
|
79
|
+
except (typer.Exit, typer.Abort, SystemExit):
|
|
80
|
+
raise
|
|
81
|
+
except Exception as e:
|
|
82
|
+
exc_type = type(e).__name__
|
|
83
|
+
msg = str(e)
|
|
84
|
+
|
|
85
|
+
if "AuthenticationError" in exc_type:
|
|
86
|
+
err_console.print(f"[red bold]Authentication failed:[/] {msg}")
|
|
87
|
+
err_console.print(
|
|
88
|
+
"[dim]Check your COMETAPI_KEY or run [bold]cometapi init[/bold] to configure.[/]"
|
|
89
|
+
)
|
|
90
|
+
err_console.print(
|
|
91
|
+
"[dim]Create a new key at: https://www.cometapi.com/console/token[/]"
|
|
92
|
+
)
|
|
93
|
+
raise typer.Exit(code=ExitCode.AUTH_ERROR)
|
|
94
|
+
|
|
95
|
+
if "APIConnectionError" in exc_type or "ConnectError" in exc_type:
|
|
96
|
+
err_console.print(f"[red bold]Connection failed:[/] {msg}")
|
|
97
|
+
err_console.print(
|
|
98
|
+
"[dim]Check your network or run [bold]cometapi doctor[/bold] to diagnose.[/]"
|
|
99
|
+
)
|
|
100
|
+
raise typer.Exit(code=ExitCode.SERVICE_UNAVAILABLE)
|
|
101
|
+
|
|
102
|
+
if "api_key" in msg.lower() or "COMETAPI_KEY" in msg or "OPENAI_API_KEY" in msg:
|
|
103
|
+
err_console.print("[red bold]API key not configured.[/]")
|
|
104
|
+
err_console.print("[dim]Set COMETAPI_KEY or run [bold]cometapi init[/bold].[/]")
|
|
105
|
+
err_console.print(
|
|
106
|
+
"[dim]Create one at: https://www.cometapi.com/console/token[/]"
|
|
107
|
+
)
|
|
108
|
+
raise typer.Exit(code=ExitCode.CONFIG_MISSING)
|
|
109
|
+
|
|
110
|
+
err_console.print(f"[red bold]Error:[/] {msg}")
|
|
111
|
+
raise typer.Exit(code=ExitCode.ERROR)
|
|
112
|
+
|
|
113
|
+
return wrapper # type: ignore[return-value]
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Multi-format output rendering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
from .console import console
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class OutputFormat(str, Enum):
|
|
19
|
+
"""Supported output formats."""
|
|
20
|
+
|
|
21
|
+
TABLE = "table"
|
|
22
|
+
JSON = "json"
|
|
23
|
+
YAML = "yaml"
|
|
24
|
+
CSV = "csv"
|
|
25
|
+
MARKDOWN = "markdown"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def resolve_format(
|
|
29
|
+
ctx: typer.Context,
|
|
30
|
+
json_output: bool = False,
|
|
31
|
+
output_format: OutputFormat | None = None,
|
|
32
|
+
) -> OutputFormat:
|
|
33
|
+
"""Resolve output format: per-command --json > per-command --format > global --format > config file."""
|
|
34
|
+
if json_output:
|
|
35
|
+
return OutputFormat.JSON
|
|
36
|
+
if output_format is not None:
|
|
37
|
+
return output_format
|
|
38
|
+
global_fmt = (ctx.obj or {}).get("format", None)
|
|
39
|
+
if global_fmt is not None:
|
|
40
|
+
return global_fmt
|
|
41
|
+
# Fall back to config file output_format
|
|
42
|
+
from .config import get_value
|
|
43
|
+
|
|
44
|
+
cfg_fmt = get_value("output_format")
|
|
45
|
+
if cfg_fmt:
|
|
46
|
+
try:
|
|
47
|
+
return OutputFormat(cfg_fmt)
|
|
48
|
+
except ValueError:
|
|
49
|
+
pass
|
|
50
|
+
return OutputFormat.TABLE
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _render_json(data: Any) -> None:
|
|
54
|
+
print(json.dumps(data, ensure_ascii=False, default=str, indent=2))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _render_yaml(data: Any) -> None:
|
|
58
|
+
import yaml
|
|
59
|
+
|
|
60
|
+
print(yaml.dump(data, allow_unicode=True, default_flow_style=False).rstrip())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _render_csv(rows: Sequence[dict[str, Any]]) -> None:
|
|
64
|
+
if not rows:
|
|
65
|
+
return
|
|
66
|
+
buf = io.StringIO()
|
|
67
|
+
writer = csv.DictWriter(buf, fieldnames=list(rows[0].keys()))
|
|
68
|
+
writer.writeheader()
|
|
69
|
+
writer.writerows(rows)
|
|
70
|
+
print(buf.getvalue().rstrip())
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _render_kv_table(data: dict[str, Any], title: str = "") -> None:
|
|
74
|
+
"""Render a single dict as a Field/Value table."""
|
|
75
|
+
table = Table(title=title or None)
|
|
76
|
+
table.add_column("Field", style="cyan", no_wrap=True)
|
|
77
|
+
table.add_column("Value", style="green")
|
|
78
|
+
for key, value in data.items():
|
|
79
|
+
display_key = key.replace("_", " ").title()
|
|
80
|
+
table.add_row(display_key, str(value))
|
|
81
|
+
console.print(table)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _render_list_table(
|
|
85
|
+
rows: Sequence[dict[str, Any]],
|
|
86
|
+
title: str = "",
|
|
87
|
+
columns: dict[str, str] | None = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Render a list of dicts as a columnar table."""
|
|
90
|
+
if not rows:
|
|
91
|
+
console.print("[dim]No data.[/]")
|
|
92
|
+
return
|
|
93
|
+
if columns is None:
|
|
94
|
+
columns = {k: ("cyan" if i == 0 else "green") for i, k in enumerate(rows[0].keys())}
|
|
95
|
+
table = Table(title=title or None)
|
|
96
|
+
for col_name, style in columns.items():
|
|
97
|
+
table.add_column(col_name.replace("_", " ").title(), style=style, no_wrap=True)
|
|
98
|
+
for row in rows:
|
|
99
|
+
table.add_row(*(str(row.get(k, "")) for k in columns))
|
|
100
|
+
console.print(table)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _render_kv_markdown(data: dict[str, Any], title: str = "") -> None:
|
|
104
|
+
lines = []
|
|
105
|
+
if title:
|
|
106
|
+
lines.append(f"## {title}\n")
|
|
107
|
+
lines.append("| Field | Value |")
|
|
108
|
+
lines.append("| --- | --- |")
|
|
109
|
+
for key, value in data.items():
|
|
110
|
+
display_key = key.replace("_", " ").title()
|
|
111
|
+
lines.append(f"| {display_key} | {value} |")
|
|
112
|
+
print("\n".join(lines))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _render_list_markdown(rows: Sequence[dict[str, Any]], title: str = "") -> None:
|
|
116
|
+
if not rows:
|
|
117
|
+
return
|
|
118
|
+
headers = list(rows[0].keys())
|
|
119
|
+
lines = []
|
|
120
|
+
if title:
|
|
121
|
+
lines.append(f"## {title}\n")
|
|
122
|
+
lines.append("| " + " | ".join(h.replace("_", " ").title() for h in headers) + " |")
|
|
123
|
+
lines.append("| " + " | ".join("---" for _ in headers) + " |")
|
|
124
|
+
for row in rows:
|
|
125
|
+
lines.append("| " + " | ".join(str(row.get(h, "")) for h in headers) + " |")
|
|
126
|
+
print("\n".join(lines))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def output(
|
|
130
|
+
data: Any,
|
|
131
|
+
fmt: OutputFormat,
|
|
132
|
+
title: str = "",
|
|
133
|
+
columns: dict[str, str] | None = None,
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Dispatch data to the appropriate formatter.
|
|
136
|
+
|
|
137
|
+
- dict → rendered as key-value table (TABLE/MARKDOWN) or as-is (JSON/YAML/CSV)
|
|
138
|
+
- list[dict] → rendered as columnar table
|
|
139
|
+
"""
|
|
140
|
+
if fmt == OutputFormat.JSON:
|
|
141
|
+
_render_json(data)
|
|
142
|
+
elif fmt == OutputFormat.YAML:
|
|
143
|
+
_render_yaml(data)
|
|
144
|
+
elif fmt == OutputFormat.CSV:
|
|
145
|
+
rows = [data] if isinstance(data, dict) else data
|
|
146
|
+
_render_csv(rows)
|
|
147
|
+
elif fmt == OutputFormat.MARKDOWN:
|
|
148
|
+
if isinstance(data, dict):
|
|
149
|
+
_render_kv_markdown(data, title)
|
|
150
|
+
else:
|
|
151
|
+
_render_list_markdown(data, title)
|
|
152
|
+
else: # TABLE
|
|
153
|
+
if isinstance(data, dict):
|
|
154
|
+
_render_kv_table(data, title)
|
|
155
|
+
else:
|
|
156
|
+
_render_list_table(data, title, columns)
|