duhub-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.
- du_cli/__init__.py +1 -0
- du_cli/client.py +65 -0
- du_cli/config.py +67 -0
- du_cli/main.py +269 -0
- du_cli/output.py +104 -0
- duhub_cli-0.1.0.dist-info/METADATA +193 -0
- duhub_cli-0.1.0.dist-info/RECORD +11 -0
- duhub_cli-0.1.0.dist-info/WHEEL +5 -0
- duhub_cli-0.1.0.dist-info/entry_points.txt +2 -0
- duhub_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- duhub_cli-0.1.0.dist-info/top_level.txt +1 -0
du_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""duhub CLI — external developer client for DUhub."""
|
du_cli/client.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Single httpx wrapper: auth headers, error hints, timeout."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import contextlib
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
TIMEOUT = 300.0
|
|
9
|
+
CONNECT_TIMEOUT = 10.0
|
|
10
|
+
MAX_UPLOAD_BYTES = 30 * 1024 * 1024
|
|
11
|
+
|
|
12
|
+
class CliError(Exception):
|
|
13
|
+
def __init__(self, status: int, detail: str, hint: str):
|
|
14
|
+
super().__init__(detail)
|
|
15
|
+
self.status = status
|
|
16
|
+
self.detail = detail
|
|
17
|
+
self.hint = hint
|
|
18
|
+
|
|
19
|
+
def make_client() -> httpx.Client:
|
|
20
|
+
# Long read timeout for audio jobs, short connect timeout for bad hosts.
|
|
21
|
+
return httpx.Client(timeout=httpx.Timeout(TIMEOUT, connect=CONNECT_TIMEOUT))
|
|
22
|
+
|
|
23
|
+
@contextlib.contextmanager
|
|
24
|
+
def api_client():
|
|
25
|
+
"""Yield an httpx client; transport failures become CliError (no tracebacks)."""
|
|
26
|
+
try:
|
|
27
|
+
with make_client() as c:
|
|
28
|
+
yield c
|
|
29
|
+
except httpx.RequestError as e:
|
|
30
|
+
raise CliError(
|
|
31
|
+
502,
|
|
32
|
+
f"cannot reach server: {e}",
|
|
33
|
+
"check base URL and try `duhub health --detailed`",
|
|
34
|
+
) from e
|
|
35
|
+
|
|
36
|
+
def auth_headers(api_key: str, idempotency_key: str | None = None) -> dict[str, str]:
|
|
37
|
+
h = {"X-API-Key": api_key}
|
|
38
|
+
if idempotency_key:
|
|
39
|
+
h["Idempotency-Key"] = idempotency_key
|
|
40
|
+
return h
|
|
41
|
+
|
|
42
|
+
def error_hint(status: int, detail: str, path: str = "") -> str:
|
|
43
|
+
d = (detail or "").lower()
|
|
44
|
+
if status == 401:
|
|
45
|
+
return "invalid key — run `duhub configure`, copy from dashboard API Keys"
|
|
46
|
+
if status == 402 or "insufficient_balance" in d:
|
|
47
|
+
return "insufficient balance — top-up via dashboard Wallet"
|
|
48
|
+
if status == 403:
|
|
49
|
+
return "organization pending review — contact org admin"
|
|
50
|
+
if status == 413:
|
|
51
|
+
return "file exceeds 30 MB limit — compress or split audio"
|
|
52
|
+
if status == 503:
|
|
53
|
+
return "server busy (saturated) — retry after Retry-After seconds"
|
|
54
|
+
if status == 502:
|
|
55
|
+
return "backend unreachable — try `duhub health --detailed`"
|
|
56
|
+
if status == 404:
|
|
57
|
+
return "not found — check base URL and gateway version"
|
|
58
|
+
return detail or f"HTTP {status}"
|
|
59
|
+
|
|
60
|
+
def raise_for_api(status: int, body: str, path: str = "") -> None:
|
|
61
|
+
if 200 <= status < 300:
|
|
62
|
+
return
|
|
63
|
+
detail = body[:500] if body else f"HTTP {status}"
|
|
64
|
+
# try to keep gateway JSON detail readable
|
|
65
|
+
raise CliError(status, detail, error_hint(status, detail, path))
|
du_cli/config.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Config load/save: env > ~/.config/du/config.toml > defaults."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import tomllib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
DEFAULT_BASE_URL = "https://du-api.dev.umuganda.digital"
|
|
9
|
+
CONFIG_PATH = Path.home() / ".config" / "du" / "config.toml"
|
|
10
|
+
|
|
11
|
+
def _read_file(path: Path) -> dict[str, str]:
|
|
12
|
+
try:
|
|
13
|
+
with path.open("rb") as fh:
|
|
14
|
+
data = tomllib.load(fh)
|
|
15
|
+
except (FileNotFoundError, tomllib.TOMLDecodeError, UnicodeDecodeError, OSError):
|
|
16
|
+
return {}
|
|
17
|
+
out: dict[str, str] = {}
|
|
18
|
+
if isinstance(data.get("base_url"), str):
|
|
19
|
+
out["base_url"] = data["base_url"]
|
|
20
|
+
if isinstance(data.get("api_key"), str):
|
|
21
|
+
out["api_key"] = data["api_key"]
|
|
22
|
+
return out
|
|
23
|
+
|
|
24
|
+
def load_config(base_url_override: str | None = None, api_key_override: str | None = None) -> dict[str, str]:
|
|
25
|
+
filed = _read_file(CONFIG_PATH)
|
|
26
|
+
base = base_url_override if base_url_override is not None else os.environ.get("DU_BASE_URL", filed.get("base_url", DEFAULT_BASE_URL))
|
|
27
|
+
key = api_key_override if api_key_override is not None else os.environ.get("DU_API_KEY", filed.get("api_key", ""))
|
|
28
|
+
return {"base_url": base.strip().rstrip("/"), "api_key": key.strip()}
|
|
29
|
+
|
|
30
|
+
def _q(v: str) -> str:
|
|
31
|
+
escaped = (
|
|
32
|
+
v.replace("\\", "\\\\")
|
|
33
|
+
.replace('"', '\\"')
|
|
34
|
+
.replace("\n", "\\n")
|
|
35
|
+
.replace("\r", "\\r")
|
|
36
|
+
.replace("\t", "\\t")
|
|
37
|
+
)
|
|
38
|
+
return '"' + escaped + '"'
|
|
39
|
+
|
|
40
|
+
def save_config(base_url: str | None, api_key: str | None) -> Path:
|
|
41
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
prev = _read_file(CONFIG_PATH)
|
|
43
|
+
if base_url is not None:
|
|
44
|
+
prev["base_url"] = base_url.strip().rstrip("/")
|
|
45
|
+
if api_key is not None:
|
|
46
|
+
prev["api_key"] = api_key.strip()
|
|
47
|
+
lines = []
|
|
48
|
+
if "base_url" in prev:
|
|
49
|
+
lines.append(f"base_url = {_q(prev['base_url'])}")
|
|
50
|
+
if "api_key" in prev:
|
|
51
|
+
lines.append(f"api_key = {_q(prev['api_key'])}")
|
|
52
|
+
text = "\n".join(lines) + ("\n" if lines else "")
|
|
53
|
+
fd = os.open(CONFIG_PATH, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
54
|
+
if hasattr(os, "fchmod"):
|
|
55
|
+
try:
|
|
56
|
+
os.fchmod(fd, 0o600) # tighten before the key is written
|
|
57
|
+
except OSError:
|
|
58
|
+
pass
|
|
59
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
60
|
+
fh.write(text)
|
|
61
|
+
return CONFIG_PATH
|
|
62
|
+
|
|
63
|
+
def clear_config() -> None:
|
|
64
|
+
try:
|
|
65
|
+
CONFIG_PATH.unlink()
|
|
66
|
+
except FileNotFoundError:
|
|
67
|
+
pass
|
du_cli/main.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""duhub CLI entrypoint: configure, health, stt, tts, mt, diarize."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import contextlib
|
|
5
|
+
from pathlib import Path as _Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from du_cli import config, output
|
|
10
|
+
from du_cli.client import (
|
|
11
|
+
MAX_UPLOAD_BYTES,
|
|
12
|
+
CliError,
|
|
13
|
+
api_client,
|
|
14
|
+
auth_headers,
|
|
15
|
+
raise_for_api,
|
|
16
|
+
)
|
|
17
|
+
from du_cli.output import render_segments as _render_segments
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
|
|
21
|
+
def _version_callback(value: bool) -> None:
|
|
22
|
+
if value:
|
|
23
|
+
typer.echo(f"duhub {__version__}")
|
|
24
|
+
raise typer.Exit()
|
|
25
|
+
|
|
26
|
+
app = typer.Typer(
|
|
27
|
+
no_args_is_help=True,
|
|
28
|
+
add_completion=False,
|
|
29
|
+
help="DUhub CLI — STT, TTS, MT and diarization over your API key.",
|
|
30
|
+
epilog="Keys live on the dashboard under API Keys; billing per second (audio) or word (text).",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
@app.callback()
|
|
34
|
+
def _root(
|
|
35
|
+
version: bool = typer.Option(
|
|
36
|
+
False, "--version", callback=_version_callback, is_eager=True,
|
|
37
|
+
help="Show version and exit.",
|
|
38
|
+
),
|
|
39
|
+
) -> None:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
def _cfg(base_url: str | None, api_key: str | None) -> dict[str, str]:
|
|
43
|
+
return config.load_config(base_url, api_key)
|
|
44
|
+
|
|
45
|
+
@contextlib.contextmanager
|
|
46
|
+
def _api(cfg: dict[str, str], json_out: bool):
|
|
47
|
+
"""Authed client; missing key, transport, or HTTP errors become exit(1)."""
|
|
48
|
+
try:
|
|
49
|
+
if not cfg["api_key"]:
|
|
50
|
+
raise CliError(401, "missing API key", "run `duhub configure --api-key KEY`")
|
|
51
|
+
with api_client() as c:
|
|
52
|
+
yield c
|
|
53
|
+
except CliError as e:
|
|
54
|
+
output.print_err(e.detail, e.hint, json_out)
|
|
55
|
+
raise typer.Exit(1)
|
|
56
|
+
|
|
57
|
+
def _audio_path(file: str, json_out: bool) -> _Path:
|
|
58
|
+
"""Validate an upload path; map filesystem errors to a clean CLI error."""
|
|
59
|
+
p = _Path(file)
|
|
60
|
+
try:
|
|
61
|
+
if not p.exists():
|
|
62
|
+
output.print_err(f"file not found: {file}", "check the path", json_out)
|
|
63
|
+
raise typer.Exit(1)
|
|
64
|
+
if not p.is_file():
|
|
65
|
+
output.print_err(f"not a file: {file}", "pass an audio file path", json_out)
|
|
66
|
+
raise typer.Exit(1)
|
|
67
|
+
if p.stat().st_size > MAX_UPLOAD_BYTES:
|
|
68
|
+
output.print_err("file exceeds 30 MB", "compress or split audio", json_out)
|
|
69
|
+
raise typer.Exit(1)
|
|
70
|
+
except OSError as e:
|
|
71
|
+
output.print_err(f"cannot read {file}: {e}", "check the path and permissions", json_out)
|
|
72
|
+
raise typer.Exit(1)
|
|
73
|
+
return p
|
|
74
|
+
|
|
75
|
+
def _open_audio(p: _Path, json_out: bool):
|
|
76
|
+
"""Open an upload file, turning permission errors into a clean CLI error."""
|
|
77
|
+
try:
|
|
78
|
+
return p.open("rb")
|
|
79
|
+
except OSError as e:
|
|
80
|
+
output.print_err(f"cannot read {p}: {e}", "check the path and permissions", json_out)
|
|
81
|
+
raise typer.Exit(1)
|
|
82
|
+
|
|
83
|
+
def _check_output(o: str | None, json_out: bool) -> None:
|
|
84
|
+
if o:
|
|
85
|
+
output.check_output_path(o, json_out)
|
|
86
|
+
|
|
87
|
+
@app.command()
|
|
88
|
+
def configure(
|
|
89
|
+
base_url: str | None = typer.Option(None, "--base-url"),
|
|
90
|
+
api_key: str | None = typer.Option(None, "--api-key"),
|
|
91
|
+
reset: bool = typer.Option(False, "--reset"),
|
|
92
|
+
) -> None:
|
|
93
|
+
if reset:
|
|
94
|
+
try:
|
|
95
|
+
config.clear_config()
|
|
96
|
+
except OSError as e:
|
|
97
|
+
output.print_err(f"cannot remove {config.CONFIG_PATH}: {e}", "check permissions", False)
|
|
98
|
+
raise typer.Exit(1)
|
|
99
|
+
typer.echo("cleared")
|
|
100
|
+
return
|
|
101
|
+
if not base_url and not api_key:
|
|
102
|
+
cur = config.load_config()
|
|
103
|
+
typer.echo(f"base_url={cur['base_url']}")
|
|
104
|
+
typer.echo("api_key=" + ("set" if cur["api_key"] else "missing"))
|
|
105
|
+
return
|
|
106
|
+
try:
|
|
107
|
+
p = config.save_config(base_url, api_key)
|
|
108
|
+
except OSError as e:
|
|
109
|
+
output.print_err(f"cannot write {config.CONFIG_PATH}: {e}", "check permissions", False)
|
|
110
|
+
raise typer.Exit(1)
|
|
111
|
+
typer.echo(f"saved {p}")
|
|
112
|
+
|
|
113
|
+
@app.command()
|
|
114
|
+
def health(
|
|
115
|
+
detailed: bool = typer.Option(False, "--detailed"),
|
|
116
|
+
json_out: bool = typer.Option(False, "--json", help="Machine-readable single-JSON output."),
|
|
117
|
+
base_url: str | None = typer.Option(None, "--base-url"),
|
|
118
|
+
api_key: str | None = typer.Option(None, "--api-key"),
|
|
119
|
+
) -> None:
|
|
120
|
+
cfg = _cfg(base_url, api_key)
|
|
121
|
+
path = "/health/detailed" if detailed else "/health"
|
|
122
|
+
try:
|
|
123
|
+
headers = auth_headers(cfg["api_key"]) if cfg["api_key"] else {}
|
|
124
|
+
with api_client() as c:
|
|
125
|
+
r = c.get(cfg["base_url"] + path, headers=headers)
|
|
126
|
+
raise_for_api(r.status_code, r.text, path)
|
|
127
|
+
output.present(
|
|
128
|
+
r.text,
|
|
129
|
+
{"units": "", "cache": "", "request_id": r.headers.get("x-request-id", "")},
|
|
130
|
+
json_out, None,
|
|
131
|
+
)
|
|
132
|
+
except CliError as e:
|
|
133
|
+
output.print_err(e.detail, e.hint, json_out)
|
|
134
|
+
raise typer.Exit(1)
|
|
135
|
+
|
|
136
|
+
@app.command()
|
|
137
|
+
def stt(
|
|
138
|
+
file: str = typer.Argument(..., help="Audio file (wav/flac/ogg, ≤30MB)"),
|
|
139
|
+
o: str | None = typer.Option(None, "-o", "--output"),
|
|
140
|
+
idempotency_key: str | None = typer.Option(None, "--idempotency-key"),
|
|
141
|
+
json_out: bool = typer.Option(False, "--json", help="Machine-readable single-JSON output."),
|
|
142
|
+
) -> None:
|
|
143
|
+
cfg = _cfg(None, None)
|
|
144
|
+
p = _audio_path(file, json_out)
|
|
145
|
+
_check_output(o, json_out)
|
|
146
|
+
with _api(cfg, json_out) as c, _open_audio(p, json_out) as fh:
|
|
147
|
+
r = c.post(
|
|
148
|
+
cfg["base_url"] + "/stt",
|
|
149
|
+
headers=auth_headers(cfg["api_key"], idempotency_key),
|
|
150
|
+
files={"file": (p.name, fh)},
|
|
151
|
+
)
|
|
152
|
+
raise_for_api(r.status_code, r.text, "/stt")
|
|
153
|
+
output.present(
|
|
154
|
+
r.text,
|
|
155
|
+
{
|
|
156
|
+
"units": r.headers.get("x-units", ""),
|
|
157
|
+
"cache": r.headers.get("x-cache-status", ""),
|
|
158
|
+
"request_id": r.headers.get("x-request-id", ""),
|
|
159
|
+
},
|
|
160
|
+
json_out, o,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
@app.command()
|
|
164
|
+
def tts(
|
|
165
|
+
text: str = typer.Argument("", help="Text to synthesize"),
|
|
166
|
+
o: str = typer.Option(..., "-o", "--output", help="Output wav path (required)"),
|
|
167
|
+
text_file: str | None = typer.Option(None, "--text-file"),
|
|
168
|
+
idempotency_key: str | None = typer.Option(None, "--idempotency-key"),
|
|
169
|
+
json_out: bool = typer.Option(False, "--json", help="Machine-readable single-JSON output."),
|
|
170
|
+
) -> None:
|
|
171
|
+
cfg = _cfg(None, None)
|
|
172
|
+
if text_file:
|
|
173
|
+
try:
|
|
174
|
+
text = _Path(text_file).read_text(encoding="utf-8").strip()
|
|
175
|
+
except (FileNotFoundError, IsADirectoryError, OSError, ValueError) as e:
|
|
176
|
+
output.print_err(f"cannot read --text-file: {e}", "check the path", json_out)
|
|
177
|
+
raise typer.Exit(1)
|
|
178
|
+
if not text.strip():
|
|
179
|
+
output.print_err("empty text", "provide TEXT or --text-file", json_out)
|
|
180
|
+
raise typer.Exit(1)
|
|
181
|
+
_check_output(o, json_out)
|
|
182
|
+
if not json_out:
|
|
183
|
+
typer.echo(f"words={len(text.split())}")
|
|
184
|
+
with _api(cfg, json_out) as c:
|
|
185
|
+
r = c.post(
|
|
186
|
+
cfg["base_url"] + "/tts",
|
|
187
|
+
headers={**auth_headers(cfg["api_key"], idempotency_key), "Content-Type": "application/json"},
|
|
188
|
+
json={"text": text},
|
|
189
|
+
)
|
|
190
|
+
raise_for_api(r.status_code, r.text, "/tts")
|
|
191
|
+
output.save_bytes(o, r.content, json_out)
|
|
192
|
+
output.print_ok({"units": r.headers.get("x-units", ""), "cache": r.headers.get("x-cache-status", ""), "request_id": r.headers.get("x-request-id", "")}, json_out)
|
|
193
|
+
|
|
194
|
+
@app.command()
|
|
195
|
+
def mt(
|
|
196
|
+
text: str = typer.Argument(..., help="Text to translate"),
|
|
197
|
+
src: str = typer.Option("rw", "--src"),
|
|
198
|
+
tgt: str = typer.Option("en", "--tgt"),
|
|
199
|
+
o: str | None = typer.Option(None, "-o", "--output"),
|
|
200
|
+
idempotency_key: str | None = typer.Option(None, "--idempotency-key"),
|
|
201
|
+
json_out: bool = typer.Option(False, "--json", help="Machine-readable single-JSON output."),
|
|
202
|
+
) -> None:
|
|
203
|
+
cfg = _cfg(None, None)
|
|
204
|
+
if src not in ("rw", "en") or tgt not in ("rw", "en"):
|
|
205
|
+
output.print_err(f"unsupported lang src={src} tgt={tgt}", "use --src rw|en --tgt rw|en", json_out)
|
|
206
|
+
raise typer.Exit(1)
|
|
207
|
+
if src == tgt:
|
|
208
|
+
output.print_err("src==tgt", "source and target must differ", json_out)
|
|
209
|
+
raise typer.Exit(1)
|
|
210
|
+
if not text.strip():
|
|
211
|
+
output.print_err("empty text", "provide TEXT", json_out)
|
|
212
|
+
raise typer.Exit(1)
|
|
213
|
+
_check_output(o, json_out)
|
|
214
|
+
with _api(cfg, json_out) as c:
|
|
215
|
+
r = c.post(
|
|
216
|
+
cfg["base_url"] + "/mt",
|
|
217
|
+
headers={**auth_headers(cfg["api_key"], idempotency_key), "Content-Type": "application/json"},
|
|
218
|
+
json={"text": text, "source_lang": src, "target_lang": tgt},
|
|
219
|
+
)
|
|
220
|
+
raise_for_api(r.status_code, r.text, "/mt")
|
|
221
|
+
output.present(
|
|
222
|
+
r.text,
|
|
223
|
+
{
|
|
224
|
+
"units": r.headers.get("x-units", ""),
|
|
225
|
+
"cache": r.headers.get("x-cache-status", ""),
|
|
226
|
+
"request_id": r.headers.get("x-request-id", ""),
|
|
227
|
+
},
|
|
228
|
+
json_out, o,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
@app.command()
|
|
232
|
+
def diarize(
|
|
233
|
+
file: str = typer.Argument(..., help="Audio file (≤30MB)"),
|
|
234
|
+
o: str | None = typer.Option(None, "-o", "--output"),
|
|
235
|
+
fmt: str = typer.Option("json", "--format", help="json|txt|srt"),
|
|
236
|
+
idempotency_key: str | None = typer.Option(None, "--idempotency-key"),
|
|
237
|
+
json_out: bool = typer.Option(False, "--json", help="Machine-readable single-JSON output."),
|
|
238
|
+
) -> None:
|
|
239
|
+
cfg = _cfg(None, None)
|
|
240
|
+
if fmt not in ("json", "txt", "srt"):
|
|
241
|
+
output.print_err(f"bad format {fmt}", "use --format json|txt|srt", json_out)
|
|
242
|
+
raise typer.Exit(1)
|
|
243
|
+
p = _audio_path(file, json_out)
|
|
244
|
+
_check_output(o, json_out)
|
|
245
|
+
with _api(cfg, json_out) as c, _open_audio(p, json_out) as fh:
|
|
246
|
+
r = c.post(
|
|
247
|
+
cfg["base_url"] + "/diarize",
|
|
248
|
+
headers=auth_headers(cfg["api_key"], idempotency_key),
|
|
249
|
+
files={"file": (p.name, fh)},
|
|
250
|
+
)
|
|
251
|
+
raise_for_api(r.status_code, r.text, "/diarize")
|
|
252
|
+
try:
|
|
253
|
+
data = r.json()
|
|
254
|
+
segs = data.get("segments", []) if isinstance(data, dict) else None
|
|
255
|
+
if not isinstance(segs, list) or not all(isinstance(s, dict) for s in segs):
|
|
256
|
+
raise ValueError("segments must be a list of objects")
|
|
257
|
+
rendered = _render_segments(segs, fmt)
|
|
258
|
+
except (TypeError, ValueError):
|
|
259
|
+
output.print_err("invalid diarize response", "try `duhub health --detailed`", json_out)
|
|
260
|
+
raise typer.Exit(1)
|
|
261
|
+
output.present(
|
|
262
|
+
rendered,
|
|
263
|
+
{"units": r.headers.get("x-units", ""), "cache": "", "request_id": r.headers.get("x-request-id", "")},
|
|
264
|
+
json_out, o,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
if __name__ == "__main__":
|
|
269
|
+
app()
|
du_cli/output.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Human (Rich) vs machine (--json) output. No binary to stdout."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.markup import escape
|
|
11
|
+
|
|
12
|
+
_console = Console()
|
|
13
|
+
_console_err = Console(stderr=True)
|
|
14
|
+
|
|
15
|
+
def summary_line(units: str | None, cache: str | None, request_id: str | None) -> str:
|
|
16
|
+
parts = []
|
|
17
|
+
if units is not None:
|
|
18
|
+
parts.append(f"units={units}")
|
|
19
|
+
if cache is not None:
|
|
20
|
+
parts.append(f"cache={cache}")
|
|
21
|
+
if request_id is not None:
|
|
22
|
+
parts.append(f"request-id={request_id}")
|
|
23
|
+
return " ".join(parts) if parts else "ok"
|
|
24
|
+
|
|
25
|
+
def render_segments(segments: list[dict], fmt: str) -> str:
|
|
26
|
+
if fmt == "json":
|
|
27
|
+
return json.dumps(segments, ensure_ascii=False, indent=2)
|
|
28
|
+
if fmt == "txt":
|
|
29
|
+
return "\n".join(f"[Speaker {s.get('speaker')}] {s.get('text', '')}" for s in segments)
|
|
30
|
+
if fmt == "srt":
|
|
31
|
+
def ts(x: float) -> str:
|
|
32
|
+
total_ms = round(float(x) * 1000)
|
|
33
|
+
h, rem = divmod(total_ms, 3_600_000)
|
|
34
|
+
m, rem = divmod(rem, 60_000)
|
|
35
|
+
sec, ms = divmod(rem, 1000)
|
|
36
|
+
return f"{h:02d}:{m:02d}:{sec:02d},{ms:03d}"
|
|
37
|
+
lines = []
|
|
38
|
+
for i, s in enumerate(segments, 1):
|
|
39
|
+
lines.append(str(i))
|
|
40
|
+
lines.append(f"{ts(float(s.get('start', 0)))} --> {ts(float(s.get('end', 0)))}")
|
|
41
|
+
lines.append(f"[Speaker {s.get('speaker')}] {s.get('text', '')}")
|
|
42
|
+
lines.append("")
|
|
43
|
+
return "\n".join(lines)
|
|
44
|
+
raise ValueError(f"unknown format: {fmt}")
|
|
45
|
+
|
|
46
|
+
def print_ok(summary: dict[str, str], as_json: bool) -> None:
|
|
47
|
+
if as_json:
|
|
48
|
+
print(json.dumps(summary, ensure_ascii=False))
|
|
49
|
+
else:
|
|
50
|
+
_console.print(f"[green]ok[/green] {summary_line(summary.get('units'), summary.get('cache'), summary.get('request_id'))}")
|
|
51
|
+
|
|
52
|
+
def print_err(error: str, hint: str, as_json: bool) -> None:
|
|
53
|
+
if as_json:
|
|
54
|
+
print(json.dumps({"error": error, "hint": hint}, ensure_ascii=False), file=sys.stderr)
|
|
55
|
+
else:
|
|
56
|
+
_console_err.print(f"[red]error:[/red] {escape(error)}\nhint: {escape(hint)}")
|
|
57
|
+
|
|
58
|
+
def check_output_path(path: str, as_json: bool) -> None:
|
|
59
|
+
"""Fail early (before a billed API call) when the output path is unusable."""
|
|
60
|
+
p = Path(path)
|
|
61
|
+
try:
|
|
62
|
+
if p.exists() and p.is_dir():
|
|
63
|
+
raise IsADirectoryError(f"is a directory: {path}")
|
|
64
|
+
parent = p.parent
|
|
65
|
+
if not parent.exists():
|
|
66
|
+
raise FileNotFoundError(f"parent directory does not exist: {parent}")
|
|
67
|
+
if not parent.is_dir():
|
|
68
|
+
raise NotADirectoryError(f"not a directory: {parent}")
|
|
69
|
+
except OSError as e:
|
|
70
|
+
print_err(f"cannot write {path}: {e}", "check the path and permissions", as_json)
|
|
71
|
+
raise typer.Exit(1)
|
|
72
|
+
|
|
73
|
+
def _write(path: str, data: str | bytes, as_json: bool) -> None:
|
|
74
|
+
check_output_path(path, as_json)
|
|
75
|
+
try:
|
|
76
|
+
p = Path(path)
|
|
77
|
+
if isinstance(data, bytes):
|
|
78
|
+
p.write_bytes(data)
|
|
79
|
+
else:
|
|
80
|
+
p.write_text(data, encoding="utf-8")
|
|
81
|
+
except OSError as e:
|
|
82
|
+
print_err(f"cannot write {path}: {e}", "check the path and permissions", as_json)
|
|
83
|
+
raise typer.Exit(1)
|
|
84
|
+
|
|
85
|
+
def save_bytes(path: str, data: bytes, as_json: bool) -> None:
|
|
86
|
+
"""Write a binary payload (e.g. TTS wav) with a clean CLI error on failure."""
|
|
87
|
+
_write(path, data, as_json)
|
|
88
|
+
|
|
89
|
+
def present(text: str, summary: dict[str, str], as_json: bool, o: str | None) -> None:
|
|
90
|
+
"""One payload outlet: -o writes the raw file; stdout is a single JSON
|
|
91
|
+
object under --json, else raw text plus the human ok-line."""
|
|
92
|
+
if o:
|
|
93
|
+
_write(o, text, as_json)
|
|
94
|
+
print_ok(summary, as_json)
|
|
95
|
+
return
|
|
96
|
+
if as_json:
|
|
97
|
+
try:
|
|
98
|
+
data = json.loads(text)
|
|
99
|
+
except ValueError:
|
|
100
|
+
data = text
|
|
101
|
+
print(json.dumps({"data": data, **summary}, ensure_ascii=False))
|
|
102
|
+
else:
|
|
103
|
+
print(text)
|
|
104
|
+
print_ok(summary, False)
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: duhub-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: DUhub CLI — STT, TTS, MT and diarization over your API key.
|
|
5
|
+
Author: Digital Umuganda
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://beta.duhub.ai
|
|
8
|
+
Project-URL: Repository, https://github.com/Digital-Umuganda/duhub-cli
|
|
9
|
+
Project-URL: Issues, https://github.com/Digital-Umuganda/duhub-cli/issues
|
|
10
|
+
Keywords: kinyarwanda,stt,tts,translation,diarization,speech
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: typer>=0.9
|
|
23
|
+
Requires-Dist: rich>=13
|
|
24
|
+
Requires-Dist: httpx>=0.27
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# duhub-cli
|
|
28
|
+
|
|
29
|
+
Command-line client for [DUhub](https://beta.duhub.ai):
|
|
30
|
+
transcribe audio, synthesize speech, translate text, and diarize meetings —
|
|
31
|
+
all billed to your API key.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
duhub stt rec.wav -o transcript.json
|
|
35
|
+
duhub tts "Muraho neza nshuti yanjye." -o out.wav
|
|
36
|
+
duhub mt "Muraho" --src rw --tgt en
|
|
37
|
+
duhub diarize meeting.wav --format srt -o speakers.srt
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
Requires Python 3.11+.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pipx install duhub-cli
|
|
46
|
+
duhub --version
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Alternatives:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pipx install git+https://github.com/Digital-Umuganda/duhub-cli.git
|
|
53
|
+
pip install git+https://github.com/Digital-Umuganda/duhub-cli.git # plain pip
|
|
54
|
+
python -m du_cli.main --help # without installing
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
> The installed command is `duhub` only, so it does not shadow the Unix
|
|
58
|
+
> `du` (disk usage). You can also invoke `python -m du_cli.main` without
|
|
59
|
+
> installing.
|
|
60
|
+
|
|
61
|
+
## Quickstart
|
|
62
|
+
|
|
63
|
+
1. Create an account on the
|
|
64
|
+
[Dashboard](https://beta.duhub.ai) and copy an
|
|
65
|
+
API key from **API Keys** (shown once — treat it like a password).
|
|
66
|
+
2. Save it:
|
|
67
|
+
```bash
|
|
68
|
+
duhub configure --api-key du_live_xxxxxxxxxxxxxxxx
|
|
69
|
+
```
|
|
70
|
+
3. Check connectivity:
|
|
71
|
+
```bash
|
|
72
|
+
duhub health
|
|
73
|
+
duhub health --detailed
|
|
74
|
+
```
|
|
75
|
+
4. Transcribe something:
|
|
76
|
+
```bash
|
|
77
|
+
duhub stt rec.wav
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Configuration
|
|
81
|
+
|
|
82
|
+
`duhub configure` stores settings in `~/.config/du/config.toml`:
|
|
83
|
+
|
|
84
|
+
| Source | Priority | Example |
|
|
85
|
+
|--------|----------|---------|
|
|
86
|
+
| Environment | Highest (always wins) | `DU_API_KEY`, `DU_BASE_URL` |
|
|
87
|
+
| Config file | Middle | `duhub configure --api-key … --base-url …` |
|
|
88
|
+
| Built-in default | Lowest | base `https://du-api.dev.umuganda.digital` |
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
duhub configure # show current state
|
|
92
|
+
duhub configure --api-key du_live_xxx # set key
|
|
93
|
+
duhub configure --base-url http://localhost:8080 # point at local gateway
|
|
94
|
+
duhub configure --reset # delete the file
|
|
95
|
+
export DU_API_KEY=du_live_xxx DU_BASE_URL=https://du-api.dev.umuganda.digital
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
> Prefer `DU_API_KEY` env var over `--api-key` — flags land in shell history.
|
|
99
|
+
|
|
100
|
+
## Commands
|
|
101
|
+
|
|
102
|
+
Every command exits `0` on success, `1` on failure (`2` on CLI usage
|
|
103
|
+
errors) and prints a summary line (`units`, `cache`, `request-id`). The
|
|
104
|
+
API commands (`health`, `stt`, `tts`, `mt`, `diarize`) accept `--json`
|
|
105
|
+
for a single machine-readable object on stdout
|
|
106
|
+
(`{"data", "units", "cache", "request_id"}`). `-o` writes the raw
|
|
107
|
+
payload to a file instead of stdout.
|
|
108
|
+
|
|
109
|
+
Every billed command (`stt`, `tts`, `mt`, `diarize`) accepts
|
|
110
|
+
`--idempotency-key`, so a retry after a timeout or connection drop is
|
|
111
|
+
deduplicated by the gateway instead of being billed twice.
|
|
112
|
+
|
|
113
|
+
### `duhub health`
|
|
114
|
+
|
|
115
|
+
Liveness (`/health`) or full readiness (`--detailed`, checks STT/TTS/MT
|
|
116
|
+
backends — the one to try first when calls fail).
|
|
117
|
+
|
|
118
|
+
### `duhub stt` — speech to text
|
|
119
|
+
|
|
120
|
+
Upload audio (WAV/FLAC/OGG, ≤ 30 MB), get `{"text": "…"}`.
|
|
121
|
+
**Billed per audio second.**
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
duhub stt rec.wav -o transcript.json
|
|
125
|
+
duhub stt rec.wav --idempotency-key run-42 -o transcript.json
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### `duhub tts` — text to speech
|
|
129
|
+
|
|
130
|
+
Synthesize Kinyarwanda speech to a 24 kHz mono WAV (`-o` is required —
|
|
131
|
+
audio never goes to stdout). **Billed per word.** `--text-file`
|
|
132
|
+
overrides `TEXT` when both are given.
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
duhub tts "Muraho neza nshuti yanjye." -o out.wav
|
|
136
|
+
duhub tts "Muraho neza nshuti yanjye." -o out.wav --idempotency-key run-42
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### `duhub mt` — translation
|
|
140
|
+
|
|
141
|
+
Translate between Kinyarwanda (`rw`) and English (`en`).
|
|
142
|
+
**Billed per input word.**
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
duhub mt "Muraho" --src rw --tgt en
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### `duhub diarize` — who spoke when
|
|
149
|
+
|
|
150
|
+
Upload audio, get speaker-labelled segments. `--format` renders the same
|
|
151
|
+
result as `json` (default), `txt`, or `srt`. **Billed per audio second.**
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
duhub diarize meeting.wav --format srt -o speakers.srt
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
> A `404` here usually means the configured gateway predates the
|
|
158
|
+
> `/diarize` route. Check `duhub health --detailed` and your `--base-url`.
|
|
159
|
+
|
|
160
|
+
## Scripting
|
|
161
|
+
|
|
162
|
+
`--json` prints exactly one JSON object — pipe-safe:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
TEXT=$(duhub stt rec.wav --json | jq -r .data.text)
|
|
166
|
+
|
|
167
|
+
for f in clips/*.wav; do
|
|
168
|
+
duhub stt "$f" -o "${f%.wav}.json" || echo "FAILED: $f" >&2
|
|
169
|
+
done
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Errors
|
|
173
|
+
|
|
174
|
+
API, input-file, and output-file failures are reported as `error` +
|
|
175
|
+
`hint` — no tracebacks:
|
|
176
|
+
|
|
177
|
+
| You see | Cause | Fix |
|
|
178
|
+
|---------|-------|-----|
|
|
179
|
+
| `invalid key` (401) | Missing/unknown/revoked key | Copy a fresh key from dashboard API Keys |
|
|
180
|
+
| `insufficient balance` (402) | Wallet empty | Top-up via dashboard Wallet |
|
|
181
|
+
| `organization pending review` (403) | Org not approved yet | Contact your org admin |
|
|
182
|
+
| `file exceeds 30 MB` | Upload guard | Compress or split the audio |
|
|
183
|
+
| `server busy (saturated)` (503) | GPU at concurrency cap | Wait `Retry-After` seconds, retry |
|
|
184
|
+
| `backend unreachable` (502) | Model container down | `duhub health --detailed` to see which |
|
|
185
|
+
| `cannot reach server` | Wrong `--base-url`, offline, timeout | Check URL and connectivity |
|
|
186
|
+
|
|
187
|
+
## Development
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
uv sync # or: python -m venv .venv && .venv/bin/pip install -e .
|
|
191
|
+
uv run pytest
|
|
192
|
+
uv run duhub --help
|
|
193
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
du_cli/__init__.py,sha256=ZdroH7KM49mKbJMV4Dnl_FVAlDjRHaRWgn3sjWnkE2s,57
|
|
2
|
+
du_cli/client.py,sha256=JvPZfXmNKg5KA4U-vJQEahNpZCErWlHWwJE2KyblEhY,2308
|
|
3
|
+
du_cli/config.py,sha256=yF29tGA-1uy_kk9xO1PfL75f8CDwVTGZQQYThdlmzuw,2423
|
|
4
|
+
du_cli/main.py,sha256=zwA_aalTJtynMepxpe7xH5IBFzuYdXNr8Bpary_bR_0,10227
|
|
5
|
+
du_cli/output.py,sha256=H5luZKqtmQ4OC6xoBDj-dsC0zKFYFiBq6JLZlLyujRQ,3933
|
|
6
|
+
duhub_cli-0.1.0.dist-info/licenses/LICENSE,sha256=wxI7NZ1OvtXusPXaEmhy8efO9XmMa2kKaP93sNm4ZZ8,1073
|
|
7
|
+
duhub_cli-0.1.0.dist-info/METADATA,sha256=_MSkIXA-WzUcPxVGvnv1VPC8yMIgwbfEJzezdTosAp8,6081
|
|
8
|
+
duhub_cli-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
9
|
+
duhub_cli-0.1.0.dist-info/entry_points.txt,sha256=p4hxWWISD3zKwahc6VfelZZLtmoIctfb1fkDko1xzXQ,42
|
|
10
|
+
duhub_cli-0.1.0.dist-info/top_level.txt,sha256=RLpZ8i7eI0S9TUmL0jPcQLB9lG3er8Lr-IcNYm8Zlxc,7
|
|
11
|
+
duhub_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Digital Umuganda
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
du_cli
|