venice-cli 0.14.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.
venice/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.14.0"
venice/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
venice/audio_player.py ADDED
@@ -0,0 +1,59 @@
1
+ """Detect and invoke a local audio player. Synchronous; non-fatal on failure."""
2
+ import shutil
3
+ import subprocess
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Callable, List, Optional, Tuple
7
+
8
+ _PLAYERS: List[Tuple[str, Callable[[Path], List[str]]]] = [
9
+ ("paplay", lambda p: ["paplay", str(p)]),
10
+ ("aplay", lambda p: ["aplay", "-q", str(p)]),
11
+ ("ffplay", lambda p: ["ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet", str(p)]),
12
+ ("mpg123", lambda p: ["mpg123", "-q", str(p)]),
13
+ ("play", lambda p: ["play", "-q", str(p)]),
14
+ ("afplay", lambda p: ["afplay", str(p)]),
15
+ ]
16
+
17
+
18
+ def find_player() -> Optional[Tuple[str, Callable[[Path], List[str]]]]:
19
+ for name, builder in _PLAYERS:
20
+ if shutil.which(name):
21
+ return name, builder
22
+ return None
23
+
24
+
25
+ def has_player() -> bool:
26
+ return find_player() is not None
27
+
28
+
29
+ def play(path: Path) -> bool:
30
+ """Play synchronously. Returns True on exit-0, False otherwise.
31
+
32
+ Never raises. Prints a hint to stderr if no player exists.
33
+ """
34
+ found = find_player()
35
+ if not found:
36
+ print(
37
+ f"no audio player found (tried paplay/aplay/ffplay/mpg123/play/afplay)\n"
38
+ f"saved to {path}; play manually with your preferred tool.",
39
+ file=sys.stderr,
40
+ )
41
+ return False
42
+ name, build = found
43
+ try:
44
+ completed = subprocess.run(
45
+ build(path),
46
+ stdin=subprocess.DEVNULL,
47
+ stdout=subprocess.DEVNULL,
48
+ stderr=subprocess.DEVNULL,
49
+ )
50
+ if completed.returncode != 0:
51
+ print(
52
+ f"{name} exited {completed.returncode}; saved to {path}",
53
+ file=sys.stderr,
54
+ )
55
+ return False
56
+ return True
57
+ except (OSError, KeyboardInterrupt) as e:
58
+ print(f"playback aborted ({e}); file is at {path}", file=sys.stderr)
59
+ return False
venice/audio_post.py ADDED
@@ -0,0 +1,274 @@
1
+ """Local audio mastering via `ffmpeg`/`ffprobe`. Shells out; never touches the API.
2
+
3
+ Produces a 48k/24-bit WAV master with 2-pass `loudnorm` (LUFS + true-peak) and
4
+ an optional seamless loop (crossfade tail->head). ffmpeg/ffprobe are external
5
+ dependencies, detected at call time; missing tools fail cleanly before any work.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import List, Optional
15
+
16
+ _CODECS = {16: "pcm_s16le", 24: "pcm_s24le", 32: "pcm_s32le"}
17
+
18
+ # Stand-in measured values for --dry-run display (real ones come from pass 1).
19
+ _PLACEHOLDER_MEASURED = {
20
+ "input_i": "<I>", "input_tp": "<TP>", "input_lra": "<LRA>",
21
+ "input_thresh": "<thresh>", "target_offset": "<offset>",
22
+ }
23
+
24
+
25
+ def has_ffmpeg() -> bool:
26
+ return shutil.which("ffmpeg") is not None
27
+
28
+
29
+ def has_ffprobe() -> bool:
30
+ return shutil.which("ffprobe") is not None
31
+
32
+
33
+ def default_output(input_path: Path) -> Path:
34
+ """`foo.mp3` -> `foo.mastered.wav` next to the input."""
35
+ return input_path.with_name(input_path.stem + ".mastered.wav")
36
+
37
+
38
+ def add_master_flags(parser, *, include_toggle: bool) -> None:
39
+ """Shared mastering flags for `master`, `music`, and `sfx`.
40
+
41
+ `include_toggle` adds the `--master` on/off switch (music/sfx only; the
42
+ standalone `master` command always masters)."""
43
+ if include_toggle:
44
+ parser.add_argument(
45
+ "--master",
46
+ action="store_true",
47
+ help="After saving, master to WAV (48k/24-bit, LUFS/true-peak; needs ffmpeg).",
48
+ )
49
+ parser.add_argument("--lufs", type=float, default=-16.0, metavar="LUFS",
50
+ help="Integrated loudness target (default -16).")
51
+ parser.add_argument("--true-peak", type=float, default=-1.0, dest="true_peak",
52
+ metavar="DBTP", help="True-peak ceiling in dBTP (default -1.0).")
53
+ parser.add_argument("--sample-rate", type=int, default=48000, dest="sample_rate",
54
+ metavar="HZ", help="Output sample rate (default 48000).")
55
+ parser.add_argument("--bit-depth", type=int, choices=(16, 24, 32), default=24,
56
+ dest="bit_depth", help="Output PCM bit depth (default 24).")
57
+ parser.add_argument("--loop", action="store_true",
58
+ help="Make it seamlessly loopable (crossfade tail into head).")
59
+ parser.add_argument("--loop-crossfade", type=float, default=2.0, dest="loop_crossfade",
60
+ metavar="SEC", help="Loop crossfade length in seconds (default 2).")
61
+
62
+
63
+ def master_kwargs(args) -> dict:
64
+ """Pull the mastering knobs off a parsed namespace into master() kwargs."""
65
+ return dict(
66
+ sample_rate=args.sample_rate,
67
+ bit_depth=args.bit_depth,
68
+ lufs=args.lufs,
69
+ true_peak=args.true_peak,
70
+ loop=args.loop,
71
+ loop_crossfade=args.loop_crossfade,
72
+ )
73
+
74
+
75
+ def master_hook(args):
76
+ """Post-save callback (path -> exit code) for the music/sfx `--master` flag:
77
+ masters the just-written file to `<file>.mastered.wav` in place."""
78
+ def _run(path: Path) -> int:
79
+ return master(path, default_output(path), **master_kwargs(args))
80
+ return _run
81
+
82
+
83
+ def _n(v: float) -> str:
84
+ return f"{v:g}"
85
+
86
+
87
+ def _loudnorm(lufs: float, tp: float, measured: Optional[dict]) -> str:
88
+ """Build a loudnorm filter string. Pass 1 (measured=None) prints JSON stats;
89
+ pass 2 feeds the measured values back for an accurate linear normalization."""
90
+ base = f"loudnorm=I={_n(lufs)}:TP={_n(tp)}:LRA=11"
91
+ if measured is None:
92
+ return base + ":print_format=json"
93
+ return (
94
+ base
95
+ + f":measured_I={measured['input_i']}"
96
+ + f":measured_TP={measured['input_tp']}"
97
+ + f":measured_LRA={measured['input_lra']}"
98
+ + f":measured_thresh={measured['input_thresh']}"
99
+ + f":offset={measured['target_offset']}"
100
+ + ":linear=true:print_format=summary"
101
+ )
102
+
103
+
104
+ def _loop_filter(src: str, dur: Optional[float], cf: float) -> str:
105
+ """Filtergraph (from label `src` to `[out]`) that folds the last `cf`s of the
106
+ stream, faded out, over the first `cf`s, faded in -- a click-free loop of
107
+ length `dur - cf`. `dur=None` emits placeholders for dry-run display."""
108
+ c = _n(cf)
109
+ if dur is None:
110
+ d, tail, body = "<DUR>", "<DUR-CF>", "<DUR-CF>"
111
+ else:
112
+ d, tail, body = _n(dur), _n(dur - cf), _n(dur - cf)
113
+ return (
114
+ f"[{src}]asplit=3[la][lb][lc];"
115
+ f"[la]atrim=0:{c},asetpts=N/SR/TB,afade=t=in:st=0:d={c}[head];"
116
+ f"[lb]atrim={tail}:{d},asetpts=N/SR/TB,afade=t=out:st=0:d={c}[tail];"
117
+ f"[head][tail]amix=inputs=2:normalize=0[seam];"
118
+ f"[lc]atrim={c}:{body},asetpts=N/SR/TB[mid];"
119
+ f"[seam][mid]concat=n=2:v=0:a=1[out]"
120
+ )
121
+
122
+
123
+ def _pass1_cmd(input_path: Path, lufs: float, tp: float) -> List[str]:
124
+ return [
125
+ "ffmpeg", "-hide_banner", "-nostats", "-i", str(input_path),
126
+ "-af", _loudnorm(lufs, tp, None), "-f", "null", "-",
127
+ ]
128
+
129
+
130
+ def _pass2_cmd(input_path: Path, output_path: Path, *, lufs: float, tp: float,
131
+ measured: Optional[dict], codec: str, sample_rate: int,
132
+ loop: bool, dur: Optional[float], cf: float) -> List[str]:
133
+ cmd = ["ffmpeg", "-hide_banner", "-nostats", "-y", "-i", str(input_path)]
134
+ norm = _loudnorm(lufs, tp, measured)
135
+ if loop:
136
+ graph = f"[0:a]{norm}[m];" + _loop_filter("m", dur, cf)
137
+ cmd += ["-filter_complex", graph, "-map", "[out]"]
138
+ else:
139
+ cmd += ["-af", norm]
140
+ cmd += ["-ar", str(sample_rate), "-c:a", codec, str(output_path)]
141
+ return cmd
142
+
143
+
144
+ def _parse_loudnorm_json(stderr: str) -> dict:
145
+ """Extract the trailing JSON block ffmpeg's loudnorm prints on stderr."""
146
+ start = stderr.rfind("{")
147
+ end = stderr.rfind("}")
148
+ if start == -1 or end == -1 or end < start:
149
+ raise ValueError("no loudnorm JSON found in ffmpeg output")
150
+ return json.loads(stderr[start:end + 1])
151
+
152
+
153
+ def _run(cmd: List[str]):
154
+ try:
155
+ return subprocess.run(
156
+ cmd,
157
+ stdin=subprocess.DEVNULL,
158
+ stdout=subprocess.DEVNULL,
159
+ stderr=subprocess.PIPE,
160
+ text=True,
161
+ )
162
+ except OSError as e:
163
+ print(f"failed to run {cmd[0]}: {e}", file=sys.stderr)
164
+ return None
165
+
166
+
167
+ def _probe_duration(input_path: Path) -> Optional[float]:
168
+ cp = _run_capture([
169
+ "ffprobe", "-v", "error", "-show_entries", "format=duration",
170
+ "-of", "default=noprint_wrappers=1:nokey=1", str(input_path),
171
+ ])
172
+ if cp is None or cp.returncode != 0:
173
+ return None
174
+ try:
175
+ return float((cp.stdout or "").strip())
176
+ except (TypeError, ValueError):
177
+ return None
178
+
179
+
180
+ def _run_capture(cmd: List[str]):
181
+ try:
182
+ return subprocess.run(
183
+ cmd,
184
+ stdin=subprocess.DEVNULL,
185
+ stdout=subprocess.PIPE,
186
+ stderr=subprocess.PIPE,
187
+ text=True,
188
+ )
189
+ except OSError as e:
190
+ print(f"failed to run {cmd[0]}: {e}", file=sys.stderr)
191
+ return None
192
+
193
+
194
+ def master(
195
+ input_path: Path,
196
+ output_path: Path,
197
+ *,
198
+ sample_rate: int = 48000,
199
+ bit_depth: int = 24,
200
+ lufs: float = -16.0,
201
+ true_peak: float = -1.0,
202
+ loop: bool = False,
203
+ loop_crossfade: float = 2.0,
204
+ dry_run: bool = False,
205
+ ) -> int:
206
+ """Master `input_path` to a WAV at `output_path`. Returns an exit code
207
+ (0 ok, 2 bad arg / missing ffmpeg, 5 ffmpeg failure)."""
208
+ codec = _CODECS.get(bit_depth)
209
+ if codec is None:
210
+ print(f"master: unsupported --bit-depth {bit_depth}", file=sys.stderr)
211
+ return 2
212
+
213
+ if not dry_run and not has_ffmpeg():
214
+ print("master: ffmpeg not found on PATH; install it (e.g. apt install ffmpeg)",
215
+ file=sys.stderr)
216
+ return 2
217
+
218
+ dur: Optional[float] = None
219
+ if loop and not dry_run:
220
+ if not has_ffprobe():
221
+ print("master: --loop needs ffprobe (ships with ffmpeg)", file=sys.stderr)
222
+ return 2
223
+ dur = _probe_duration(input_path)
224
+ if dur is None:
225
+ print(f"master: could not read duration of {input_path}", file=sys.stderr)
226
+ return 5
227
+ if dur <= 2 * loop_crossfade:
228
+ print(
229
+ f"master: input is {dur:.2f}s; too short to loop with a "
230
+ f"{loop_crossfade:g}s crossfade (need > {2 * loop_crossfade:g}s)",
231
+ file=sys.stderr,
232
+ )
233
+ return 2
234
+
235
+ cmd1 = _pass1_cmd(input_path, lufs, true_peak)
236
+
237
+ if dry_run:
238
+ cmd2_template = _pass2_cmd(
239
+ input_path, output_path, lufs=lufs, tp=true_peak,
240
+ measured=_PLACEHOLDER_MEASURED, codec=codec, sample_rate=sample_rate,
241
+ loop=loop, dur=dur, cf=loop_crossfade,
242
+ )
243
+ print("master: dry run -- would run:", file=sys.stderr)
244
+ print(" pass 1 (measure): " + " ".join(cmd1), file=sys.stderr)
245
+ print(" pass 2 (apply): " + " ".join(cmd2_template), file=sys.stderr)
246
+ print(" (pass 2's measured_* / <DUR> values are resolved at run time)", file=sys.stderr)
247
+ return 0
248
+
249
+ r1 = _run_capture(cmd1)
250
+ if r1 is None or r1.returncode != 0:
251
+ print("master: loudness analysis (pass 1) failed", file=sys.stderr)
252
+ if r1 is not None and r1.stderr:
253
+ print(r1.stderr.strip()[-500:], file=sys.stderr)
254
+ return 5
255
+ try:
256
+ measured = _parse_loudnorm_json(r1.stderr or "")
257
+ except (ValueError, json.JSONDecodeError):
258
+ print("master: could not parse loudnorm stats from ffmpeg", file=sys.stderr)
259
+ return 5
260
+
261
+ cmd2 = _pass2_cmd(
262
+ input_path, output_path, lufs=lufs, tp=true_peak, measured=measured,
263
+ codec=codec, sample_rate=sample_rate, loop=loop, dur=dur, cf=loop_crossfade,
264
+ )
265
+ r2 = _run(cmd2)
266
+ if r2 is None or r2.returncode != 0:
267
+ print("master: encode (pass 2) failed", file=sys.stderr)
268
+ if r2 is not None and r2.stderr:
269
+ print(r2.stderr.strip()[-500:], file=sys.stderr)
270
+ return 5
271
+
272
+ print(str(output_path.resolve()))
273
+ print(f"mastered -> {output_path.resolve()}", file=sys.stderr)
274
+ return 0
venice/auth.py ADDED
@@ -0,0 +1,101 @@
1
+ """Credential read/write. Never logs the key. Never prints the key."""
2
+ import getpass
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from . import config
8
+
9
+
10
+ class AuthError(Exception):
11
+ """Credential not found or unusable. Message is safe to print."""
12
+
13
+
14
+ def load_key() -> str:
15
+ """Resolve API key with precedence: env var > credentials file > AuthError."""
16
+ env_val = os.environ.get(config.ENV_API_KEY, "").strip()
17
+ if env_val:
18
+ return env_val
19
+
20
+ p: Path = config.CREDS_FILE
21
+ if not p.exists():
22
+ raise AuthError(
23
+ f"No API key found. Set ${config.ENV_API_KEY} or run: venice login"
24
+ )
25
+
26
+ try:
27
+ mode = p.stat().st_mode & 0o777
28
+ if mode & 0o077:
29
+ print(
30
+ f"warning: {p} has loose permissions ({oct(mode)}); "
31
+ f"run `chmod 600 {p}`",
32
+ file=sys.stderr,
33
+ )
34
+ key = p.read_text(encoding="utf-8").strip()
35
+ except OSError as e:
36
+ raise AuthError(f"Cannot read {p}: {e}") from None
37
+
38
+ if not key:
39
+ raise AuthError(f"{p} is empty. Run: venice login")
40
+ return key
41
+
42
+
43
+ def save_key(key: str) -> Path:
44
+ """Atomically write the key with mode 0600. Returns the path written."""
45
+ key = (key or "").strip()
46
+ if not key:
47
+ raise AuthError("Refusing to save an empty key.")
48
+ if any(c.isspace() for c in key):
49
+ raise AuthError("Key contains whitespace; check what you pasted.")
50
+
51
+ config.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
52
+ try:
53
+ os.chmod(config.CONFIG_DIR, 0o700)
54
+ except OSError:
55
+ pass
56
+
57
+ tmp = config.CREDS_FILE.with_suffix(".tmp")
58
+ fd = os.open(
59
+ str(tmp),
60
+ os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
61
+ 0o600,
62
+ )
63
+ try:
64
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
65
+ f.write(key + "\n")
66
+ f.flush()
67
+ os.fsync(f.fileno())
68
+ except Exception:
69
+ try:
70
+ os.unlink(tmp)
71
+ except OSError:
72
+ pass
73
+ raise
74
+ os.replace(tmp, config.CREDS_FILE)
75
+ try:
76
+ os.chmod(config.CREDS_FILE, 0o600)
77
+ except OSError:
78
+ pass
79
+ return config.CREDS_FILE
80
+
81
+
82
+ def prompt_and_save() -> Path:
83
+ """Interactive prompt used by `venice login`. Uses getpass -- no echo.
84
+
85
+ Refuses non-TTY stdin so we don't fall back to cleartext input.
86
+ """
87
+ if not sys.stdin.isatty():
88
+ raise AuthError(
89
+ f"Interactive login requires a TTY. "
90
+ f"Set ${config.ENV_API_KEY} in your environment instead."
91
+ )
92
+
93
+ print(
94
+ "Paste your Venice API key (from https://venice.ai/settings/api).",
95
+ file=sys.stderr,
96
+ )
97
+ print("Input is hidden; it will not appear on screen.", file=sys.stderr)
98
+ key = getpass.getpass(prompt="API key: ")
99
+ path = save_key(key)
100
+ print(f"Saved {len(key)}-char key to {path} (mode 0600).", file=sys.stderr)
101
+ return path
venice/billing.py ADDED
@@ -0,0 +1,129 @@
1
+ """Small wrapper over /api_keys/rate_limits for balance + tier info.
2
+
3
+ Kept separate from client.py so commands can import a stable, narrow API
4
+ without depending on the raw HTTP shape.
5
+
6
+ ## Venice's billing model
7
+
8
+ Venice has up to four spendable buckets, drained in this order:
9
+
10
+ 1. DIEM allowance -- daily credit derived from staked DIEM (1 DIEM
11
+ staked = $1/day). Resets every 24 h at
12
+ `nextEpochBegins`. Per-epoch use-it-or-lose-it.
13
+ 2. Monthly credit -- BUNDLED_CREDITS, a recently-added bundle
14
+ granted with paid subscriptions. Drains BEFORE
15
+ cash. NOT exposed via the inference-key
16
+ endpoint -- only via /billing/balance (admin).
17
+ 3. VCU -- Venice Compute Units (per-tier inclusions).
18
+ Also not exposed via the inference key.
19
+ 4. USD cash -- one-and-done prepaid USD balance.
20
+
21
+ 1 unit of any of these == $1 of purchasing power: per-model pricing in
22
+ /models lists the same number in both `usd` and `diem` fields.
23
+
24
+ What we can read with an inference key (`VENICE-INFERENCE-KEY-...`):
25
+ - balances.USD (cash)
26
+ - balances.DIEM (epoch allowance, the remaining portion for the
27
+ current 24 h)
28
+ - nextEpochBegins (when DIEM resets)
29
+ - apiTier, keyExpiration, rateLimits
30
+
31
+ What we cannot read with this key:
32
+ - BUNDLED_CREDITS (monthly bundle balance)
33
+ - VCU (compute-unit balance)
34
+
35
+ Practical implication: when the user has monthly credit, the CLI's
36
+ "after charge" line will be slightly pessimistic about USD cash (since
37
+ the actual debit lands on monthly first). The combined spendable total
38
+ shown still reflects what we can see; if your real spendable is higher,
39
+ it means monthly credit / VCU is doing some of the lifting silently.
40
+ """
41
+ from __future__ import annotations
42
+
43
+ from typing import Optional
44
+
45
+ # Hard-coded because the spec doesn't expose it as a field on this endpoint.
46
+ SPEND_ORDER = ("DIEM allowance", "monthly credit", "USD cash")
47
+
48
+
49
+ def fetch_balance(client) -> Optional[dict]:
50
+ """Return {usd, diem, total, tier, next_epoch, key_expires} or None.
51
+
52
+ `total` is the combined spendable balance in USD-equivalent units
53
+ (USD + DIEM, since 1 DIEM = $1).
54
+ """
55
+ data = client.get_balance()
56
+ if not isinstance(data, dict):
57
+ return None
58
+ balances = data.get("balances") if isinstance(data.get("balances"), dict) else {}
59
+ tier_block = data.get("apiTier") if isinstance(data.get("apiTier"), dict) else {}
60
+ usd = balances.get("USD")
61
+ diem = balances.get("DIEM")
62
+ total = _safe_sum(usd, diem)
63
+ return {
64
+ "usd": usd,
65
+ "diem": diem,
66
+ "total": total,
67
+ "tier": tier_block.get("id"),
68
+ "is_charged": tier_block.get("isCharged"),
69
+ "next_epoch": data.get("nextEpochBegins"),
70
+ "key_expires": data.get("keyExpiration"),
71
+ }
72
+
73
+
74
+ def _safe_sum(*vals) -> Optional[float]:
75
+ """Sum that returns None if any input is non-numeric (and 0 if all None)."""
76
+ total = 0.0
77
+ any_seen = False
78
+ for v in vals:
79
+ if v is None:
80
+ continue
81
+ try:
82
+ total += float(v)
83
+ any_seen = True
84
+ except (TypeError, ValueError):
85
+ return None
86
+ return total if any_seen else None
87
+
88
+
89
+ def format_usd(amt) -> str:
90
+ """Pretty-print a USD amount. 4 decimals for sub-dollar, 2 otherwise."""
91
+ if amt is None:
92
+ return "$?.?? USD"
93
+ try:
94
+ v = float(amt)
95
+ except (TypeError, ValueError):
96
+ return f"{amt} USD"
97
+ if abs(v) < 1:
98
+ return f"${v:.4f} USD"
99
+ return f"${v:.2f} USD"
100
+
101
+
102
+ def format_balance_breakdown(info: dict) -> str:
103
+ """Render a one-line balance summary with the spendable total + breakdown.
104
+
105
+ Example: "$32.70 USD (6.56 DIEM allowance + 26.14 USD cash)"
106
+ Order mirrors the spend order (DIEM drains first), so the leftmost
107
+ bucket is what funds the next call. Falls back gracefully if a
108
+ component is missing.
109
+ """
110
+ if not isinstance(info, dict):
111
+ return "(no balance info)"
112
+ usd = info.get("usd")
113
+ diem = info.get("diem")
114
+ total = info.get("total")
115
+ head = format_usd(total)
116
+ parts = []
117
+ if diem is not None:
118
+ try:
119
+ parts.append(f"{float(diem):.2f} DIEM allowance")
120
+ except (TypeError, ValueError):
121
+ pass
122
+ if usd is not None:
123
+ try:
124
+ parts.append(f"{float(usd):.2f} USD cash")
125
+ except (TypeError, ValueError):
126
+ pass
127
+ if parts:
128
+ return f"{head} ({' + '.join(parts)})"
129
+ return head
venice/cli.py ADDED
@@ -0,0 +1,31 @@
1
+ """Top-level argparse dispatcher."""
2
+ import argparse
3
+ import sys
4
+
5
+ from . import __version__
6
+ from .commands import register_all
7
+
8
+
9
+ def build_parser() -> argparse.ArgumentParser:
10
+ p = argparse.ArgumentParser(
11
+ prog="venice",
12
+ description="Venice.ai CLI (stdlib-only). `venice <command> --help` for details.",
13
+ )
14
+ p.add_argument("--version", action="version", version=f"venice {__version__}")
15
+ sub = p.add_subparsers(dest="command", metavar="COMMAND")
16
+ register_all(sub)
17
+ return p
18
+
19
+
20
+ def main(argv=None) -> int:
21
+ parser = build_parser()
22
+ args = parser.parse_args(argv)
23
+ handler = getattr(args, "handler", None)
24
+ if handler is None:
25
+ parser.print_help(sys.stderr)
26
+ return 2
27
+ return int(handler(args) or 0)
28
+
29
+
30
+ if __name__ == "__main__":
31
+ raise SystemExit(main())