sonilo-cli 0.1.0__tar.gz → 0.3.0__tar.gz
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.
- {sonilo_cli-0.1.0 → sonilo_cli-0.3.0}/PKG-INFO +38 -5
- {sonilo_cli-0.1.0 → sonilo_cli-0.3.0}/README.md +36 -3
- {sonilo_cli-0.1.0 → sonilo_cli-0.3.0}/pyproject.toml +2 -2
- {sonilo_cli-0.1.0 → sonilo_cli-0.3.0}/src/sonilo_cli/__init__.py +1 -1
- {sonilo_cli-0.1.0 → sonilo_cli-0.3.0}/src/sonilo_cli/__main__.py +94 -4
- {sonilo_cli-0.1.0 → sonilo_cli-0.3.0}/tests/test_cli.py +167 -0
- sonilo_cli-0.3.0/tests/test_context7.py +51 -0
- sonilo_cli-0.1.0/tests/test_context7.py +0 -12
- {sonilo_cli-0.1.0 → sonilo_cli-0.3.0}/.gitignore +0 -0
- {sonilo_cli-0.1.0 → sonilo_cli-0.3.0}/LICENSE +0 -0
- {sonilo_cli-0.1.0 → sonilo_cli-0.3.0}/tests/__init__.py +0 -0
- {sonilo_cli-0.1.0 → sonilo_cli-0.3.0}/tests/test_smoke.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sonilo-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Command-line interface for the Sonilo API: generate music and sound effects from text or video
|
|
5
5
|
Project-URL: Repository, https://github.com/sonilo-ai/sonilo-python
|
|
6
6
|
Author: Sonilo AI
|
|
@@ -8,7 +8,7 @@ License-Expression: MIT
|
|
|
8
8
|
License-File: LICENSE
|
|
9
9
|
Keywords: ai,cli,music,sfx,sonilo,text-to-music,video-to-music
|
|
10
10
|
Requires-Python: >=3.9
|
|
11
|
-
Requires-Dist: sonilo<0.
|
|
11
|
+
Requires-Dist: sonilo<0.8,>=0.7.0
|
|
12
12
|
Provides-Extra: dev
|
|
13
13
|
Requires-Dist: pytest>=8; extra == 'dev'
|
|
14
14
|
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
@@ -41,6 +41,8 @@ or pass `--api-key sk-...` on any command.
|
|
|
41
41
|
sonilo video-to-sound --video clip.mp4 \
|
|
42
42
|
--music-prompt "uplifting orchestral score" --sfx-prompt "match the on-screen action"
|
|
43
43
|
sonilo video-to-video-sound --video clip.mp4 --music-prompt "tense synths"
|
|
44
|
+
sonilo dubbing --video-url https://example.com/clip.mp4 --languages es,fr --output dubbed.mp4
|
|
45
|
+
# writes dubbed.es.mp4 and dubbed.fr.mp4
|
|
44
46
|
sonilo tasks get <task-id>
|
|
45
47
|
sonilo tasks wait <task-id> --poll-interval 2 --timeout 600
|
|
46
48
|
|
|
@@ -73,15 +75,46 @@ they differ only in what comes back: `video-to-sound` writes the mixed **audio**
|
|
|
73
75
|
music stem lands at `soundtrack.music.m4a`. `music_processed` exists only when `--preserve-speech`
|
|
74
76
|
or ducking altered the music bed.
|
|
75
77
|
|
|
78
|
+
### Dubbing
|
|
79
|
+
|
|
80
|
+
`dubbing` dubs a video into one or more target languages in a single async call:
|
|
81
|
+
|
|
82
|
+
sonilo dubbing --video-url https://example.com/clip.mp4 --languages es,fr --output dubbed.mp4
|
|
83
|
+
# writes dubbed.es.mp4 and dubbed.fr.mp4
|
|
84
|
+
|
|
85
|
+
- `--languages` is comma-separated; omit it to use the server default `zh_cn,es,fr`. Supported
|
|
86
|
+
codes: `en, zh_cn, ja, ko, pt, es, de, fr, it, ru`.
|
|
87
|
+
- Source videos may be at most 180 seconds long.
|
|
88
|
+
- `--output` is a filename template, not a single destination: a dubbing task returns one video
|
|
89
|
+
per language, so `--output clip.mp4` writes `clip.es.mp4`, `clip.fr.mp4`, etc.
|
|
90
|
+
- Billing is per language, and dubbing has **no free trial runs** — see [Free trial](#free-trial)
|
|
91
|
+
below.
|
|
92
|
+
- `--timeout` defaults to 7200 seconds, matching the backend's own ceiling for a dubbing job
|
|
93
|
+
(far longer than other commands' default, since dubbing can run well past the usual
|
|
94
|
+
`tasks wait --timeout 600`). If the wait still times out, the task keeps running
|
|
95
|
+
server-side — resume watching it with `sonilo tasks wait <task-id>`.
|
|
96
|
+
|
|
76
97
|
## Free trial
|
|
77
98
|
|
|
78
|
-
Accounts created through self-serve signup start with free runs on
|
|
99
|
+
Accounts created through self-serve signup start with free runs on most endpoints — no card
|
|
79
100
|
required:
|
|
80
101
|
|
|
81
102
|
| Free runs | Endpoints |
|
|
82
103
|
| --- | --- |
|
|
83
104
|
| 2 each | text-to-music, text-to-sfx, audio-ducking |
|
|
84
105
|
| 1 each | video-to-music, video-to-sfx, video-to-video-music, video-to-video-sfx, video-to-sound, video-to-video-sound |
|
|
106
|
+
| 0 | dubbing |
|
|
107
|
+
|
|
108
|
+
Dubbing bills `video duration × number of languages`, so a free run on it would be worth far more
|
|
109
|
+
than a free run on any other endpoint — it has no free allowance and bills from the first call.
|
|
110
|
+
|
|
111
|
+
The table above is the current default. `sonilo account` prints the live numbers: the account JSON
|
|
112
|
+
goes to stdout, and when the account has a free-trial allowance one summary line goes to stderr:
|
|
113
|
+
|
|
114
|
+
Free trial: text-to-music 1/2 left, video-to-music 0/1 left
|
|
115
|
+
|
|
116
|
+
Because the summary is on stderr, `sonilo account | jq .trial` still sees clean JSON.
|
|
85
117
|
|
|
86
|
-
Once an endpoint's free runs are used up, calls to it bill at the normal rate
|
|
87
|
-
|
|
118
|
+
Once an endpoint's free runs are used up, calls to it bill at the normal rate — or, if the account
|
|
119
|
+
has never been funded, fail with `HTTP 402: ... (trial_exhausted)` until a payment method is added.
|
|
120
|
+
That is the one 402 a retry can never fix.
|
|
@@ -25,6 +25,8 @@ or pass `--api-key sk-...` on any command.
|
|
|
25
25
|
sonilo video-to-sound --video clip.mp4 \
|
|
26
26
|
--music-prompt "uplifting orchestral score" --sfx-prompt "match the on-screen action"
|
|
27
27
|
sonilo video-to-video-sound --video clip.mp4 --music-prompt "tense synths"
|
|
28
|
+
sonilo dubbing --video-url https://example.com/clip.mp4 --languages es,fr --output dubbed.mp4
|
|
29
|
+
# writes dubbed.es.mp4 and dubbed.fr.mp4
|
|
28
30
|
sonilo tasks get <task-id>
|
|
29
31
|
sonilo tasks wait <task-id> --poll-interval 2 --timeout 600
|
|
30
32
|
|
|
@@ -57,15 +59,46 @@ they differ only in what comes back: `video-to-sound` writes the mixed **audio**
|
|
|
57
59
|
music stem lands at `soundtrack.music.m4a`. `music_processed` exists only when `--preserve-speech`
|
|
58
60
|
or ducking altered the music bed.
|
|
59
61
|
|
|
62
|
+
### Dubbing
|
|
63
|
+
|
|
64
|
+
`dubbing` dubs a video into one or more target languages in a single async call:
|
|
65
|
+
|
|
66
|
+
sonilo dubbing --video-url https://example.com/clip.mp4 --languages es,fr --output dubbed.mp4
|
|
67
|
+
# writes dubbed.es.mp4 and dubbed.fr.mp4
|
|
68
|
+
|
|
69
|
+
- `--languages` is comma-separated; omit it to use the server default `zh_cn,es,fr`. Supported
|
|
70
|
+
codes: `en, zh_cn, ja, ko, pt, es, de, fr, it, ru`.
|
|
71
|
+
- Source videos may be at most 180 seconds long.
|
|
72
|
+
- `--output` is a filename template, not a single destination: a dubbing task returns one video
|
|
73
|
+
per language, so `--output clip.mp4` writes `clip.es.mp4`, `clip.fr.mp4`, etc.
|
|
74
|
+
- Billing is per language, and dubbing has **no free trial runs** — see [Free trial](#free-trial)
|
|
75
|
+
below.
|
|
76
|
+
- `--timeout` defaults to 7200 seconds, matching the backend's own ceiling for a dubbing job
|
|
77
|
+
(far longer than other commands' default, since dubbing can run well past the usual
|
|
78
|
+
`tasks wait --timeout 600`). If the wait still times out, the task keeps running
|
|
79
|
+
server-side — resume watching it with `sonilo tasks wait <task-id>`.
|
|
80
|
+
|
|
60
81
|
## Free trial
|
|
61
82
|
|
|
62
|
-
Accounts created through self-serve signup start with free runs on
|
|
83
|
+
Accounts created through self-serve signup start with free runs on most endpoints — no card
|
|
63
84
|
required:
|
|
64
85
|
|
|
65
86
|
| Free runs | Endpoints |
|
|
66
87
|
| --- | --- |
|
|
67
88
|
| 2 each | text-to-music, text-to-sfx, audio-ducking |
|
|
68
89
|
| 1 each | video-to-music, video-to-sfx, video-to-video-music, video-to-video-sfx, video-to-sound, video-to-video-sound |
|
|
90
|
+
| 0 | dubbing |
|
|
91
|
+
|
|
92
|
+
Dubbing bills `video duration × number of languages`, so a free run on it would be worth far more
|
|
93
|
+
than a free run on any other endpoint — it has no free allowance and bills from the first call.
|
|
94
|
+
|
|
95
|
+
The table above is the current default. `sonilo account` prints the live numbers: the account JSON
|
|
96
|
+
goes to stdout, and when the account has a free-trial allowance one summary line goes to stderr:
|
|
97
|
+
|
|
98
|
+
Free trial: text-to-music 1/2 left, video-to-music 0/1 left
|
|
99
|
+
|
|
100
|
+
Because the summary is on stderr, `sonilo account | jq .trial` still sees clean JSON.
|
|
69
101
|
|
|
70
|
-
Once an endpoint's free runs are used up, calls to it bill at the normal rate
|
|
71
|
-
|
|
102
|
+
Once an endpoint's free runs are used up, calls to it bill at the normal rate — or, if the account
|
|
103
|
+
has never been funded, fail with `HTTP 402: ... (trial_exhausted)` until a payment method is added.
|
|
104
|
+
That is the one 402 a retry can never fix.
|
|
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "sonilo-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Command-line interface for the Sonilo API: generate music and sound effects from text or video"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
11
11
|
requires-python = ">=3.9"
|
|
12
12
|
authors = [{ name = "Sonilo AI" }]
|
|
13
|
-
dependencies = ["sonilo>=0.
|
|
13
|
+
dependencies = ["sonilo>=0.7.0,<0.8"]
|
|
14
14
|
keywords = ["sonilo", "cli", "music", "sfx", "text-to-music", "video-to-music", "ai"]
|
|
15
15
|
|
|
16
16
|
[project.urls]
|
|
@@ -6,11 +6,11 @@ import os
|
|
|
6
6
|
import sys
|
|
7
7
|
import time
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
from typing import Any, List, NoReturn, Optional
|
|
9
|
+
from typing import Any, Dict, List, NoReturn, Optional
|
|
10
10
|
from urllib.parse import urlparse
|
|
11
11
|
|
|
12
12
|
from sonilo import Sonilo
|
|
13
|
-
from sonilo.errors import SoniloError
|
|
13
|
+
from sonilo.errors import APIError, SoniloError
|
|
14
14
|
|
|
15
15
|
from sonilo_cli import __version__
|
|
16
16
|
|
|
@@ -43,11 +43,38 @@ def build_client(api_key: Optional[str]) -> Sonilo:
|
|
|
43
43
|
"no API key — pass --api-key <key> or set the "
|
|
44
44
|
"SONILO_API_KEY environment variable"
|
|
45
45
|
)
|
|
46
|
-
|
|
46
|
+
# Identify as the CLI rather than inheriting the SDK's own name, so CLI
|
|
47
|
+
# traffic stays separable from direct SDK use in server-side analytics.
|
|
48
|
+
return Sonilo(api_key=key, client_name="cli-python", client_version=__version__)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def format_trial_summary(trial: Optional[Dict[str, Any]]) -> Optional[str]:
|
|
52
|
+
"""One-line human summary of the free-trial allowance, e.g.
|
|
53
|
+
"Free trial: text-to-music 1/2 left, video-to-music 0/1 left".
|
|
54
|
+
|
|
55
|
+
Returns None when there is nothing to report — the `trial` field is
|
|
56
|
+
present only for self-serve accounts, and printing an empty "Free trial:"
|
|
57
|
+
label would read as a bug.
|
|
58
|
+
"""
|
|
59
|
+
if not trial:
|
|
60
|
+
return None
|
|
61
|
+
parts = [
|
|
62
|
+
# Service keys are task_types (text_to_music); show them the way the
|
|
63
|
+
# endpoints and the error messages spell them (text-to-music).
|
|
64
|
+
f"{service.replace('_', '-')} {quota['remaining']}/{quota['granted']} left"
|
|
65
|
+
for service, quota in trial.items()
|
|
66
|
+
]
|
|
67
|
+
return f"Free trial: {', '.join(parts)}"
|
|
47
68
|
|
|
48
69
|
|
|
49
70
|
def cmd_account(client: Sonilo, args: argparse.Namespace) -> None:
|
|
50
|
-
|
|
71
|
+
services = client.account.services()
|
|
72
|
+
_print_json(services)
|
|
73
|
+
# stdout stays pure JSON so `sonilo account | jq` keeps working; the
|
|
74
|
+
# human-readable summary goes to stderr.
|
|
75
|
+
summary = format_trial_summary(services.get("trial"))
|
|
76
|
+
if summary is not None:
|
|
77
|
+
sys.stderr.write(f"{summary}\n")
|
|
51
78
|
|
|
52
79
|
|
|
53
80
|
def cmd_usage(client: Sonilo, args: argparse.Namespace) -> None:
|
|
@@ -158,6 +185,42 @@ def cmd_video_to_video_sound(client: Sonilo, args: argparse.Namespace) -> None:
|
|
|
158
185
|
_run_sound(client, args, client.video_to_video_sound, "mp4")
|
|
159
186
|
|
|
160
187
|
|
|
188
|
+
# Matched to the dubbing backend's own ceiling: it polls its pipeline for up
|
|
189
|
+
# to 7200s (2 hours), so anything shorter abandons a job the user has already
|
|
190
|
+
# been charged for. The SDK's generic DEFAULT_WAIT_TIMEOUT of 600s is far too
|
|
191
|
+
# short for this endpoint. --timeout overrides.
|
|
192
|
+
DUBBING_WAIT_TIMEOUT = 7200.0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _language_path(out: str, language: str) -> str:
|
|
196
|
+
"""Turn one --output value into a per-language path: `clip.mp4` + `es`
|
|
197
|
+
becomes `clip.es.mp4`. A dubbing task returns one video per language, so a
|
|
198
|
+
single literal destination cannot express the result. This is the same
|
|
199
|
+
transform _stem_path applies for --stem, so both flags read the same way."""
|
|
200
|
+
base = Path(out)
|
|
201
|
+
return str(base.with_name(f"{base.stem}.{language}{base.suffix or '.mp4'}"))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def cmd_dubbing(client: Sonilo, args: argparse.Namespace) -> None:
|
|
205
|
+
out = args.output if args.output is not None else "output.mp4"
|
|
206
|
+
languages = None
|
|
207
|
+
if args.languages is not None:
|
|
208
|
+
languages = [code.strip() for code in args.languages.split(",") if code.strip()]
|
|
209
|
+
if not languages:
|
|
210
|
+
_fail("--languages needs at least one language code, e.g. --languages es,fr")
|
|
211
|
+
result = client.dubbing.generate(
|
|
212
|
+
video=args.video,
|
|
213
|
+
video_url=args.video_url,
|
|
214
|
+
languages=languages,
|
|
215
|
+
timeout=args.timeout,
|
|
216
|
+
)
|
|
217
|
+
if not result.outputs:
|
|
218
|
+
_fail("task succeeded but returned no dubbed videos")
|
|
219
|
+
for language in sorted(result.outputs):
|
|
220
|
+
path = result.save(language, _language_path(out, language))
|
|
221
|
+
_wrote(path, path.stat().st_size)
|
|
222
|
+
|
|
223
|
+
|
|
161
224
|
def _identity(body: Any) -> Any:
|
|
162
225
|
return body
|
|
163
226
|
|
|
@@ -301,6 +364,28 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
301
364
|
p_v2vsd.add_argument("--output", default=None, help="Where to save the combined video.")
|
|
302
365
|
p_v2vsd.set_defaults(func=cmd_video_to_video_sound)
|
|
303
366
|
|
|
367
|
+
p_dub = sub.add_parser("dubbing", help="Dub a video into other languages")
|
|
368
|
+
_add_global(p_dub)
|
|
369
|
+
_add_video_source(p_dub)
|
|
370
|
+
p_dub.add_argument(
|
|
371
|
+
"--languages", default=None,
|
|
372
|
+
help="Comma-separated target languages. Default: zh_cn,es,fr. "
|
|
373
|
+
"Supported: en, zh_cn, ja, ko, pt, es, de, fr, it, ru",
|
|
374
|
+
)
|
|
375
|
+
p_dub.add_argument(
|
|
376
|
+
"--output", default=None,
|
|
377
|
+
help="Filename template; one file is written per language with the code "
|
|
378
|
+
"inserted before the extension (clip.mp4 -> clip.es.mp4). "
|
|
379
|
+
"Default: output.mp4",
|
|
380
|
+
)
|
|
381
|
+
p_dub.add_argument(
|
|
382
|
+
"--timeout", type=float, default=DUBBING_WAIT_TIMEOUT,
|
|
383
|
+
help="Give up waiting after this many seconds. Default: 7200, matching the "
|
|
384
|
+
"backend's own ceiling for a dubbing job. A timed-out "
|
|
385
|
+
"task is still running — resume it with `sonilo tasks wait <task-id>`.",
|
|
386
|
+
)
|
|
387
|
+
p_dub.set_defaults(func=cmd_dubbing)
|
|
388
|
+
|
|
304
389
|
p_tasks = sub.add_parser("tasks", help="Inspect async tasks")
|
|
305
390
|
_add_global(p_tasks)
|
|
306
391
|
tsub = p_tasks.add_subparsers(dest="tasks_command", metavar="<get|wait>")
|
|
@@ -331,6 +416,11 @@ def main(argv: Optional[List[str]] = None) -> None:
|
|
|
331
416
|
client = build_client(getattr(args, "api_key", None))
|
|
332
417
|
try:
|
|
333
418
|
func(client, args)
|
|
419
|
+
except APIError as exc:
|
|
420
|
+
# Show the API's error code alongside the message: it is what the
|
|
421
|
+
# docs tell people to branch on, and "(trial_exhausted)" is the
|
|
422
|
+
# difference between "add a payment method" and "retry later".
|
|
423
|
+
_fail(f"{exc}{f' ({exc.code})' if exc.code else ''}")
|
|
334
424
|
except SoniloError as exc:
|
|
335
425
|
_fail(str(exc))
|
|
336
426
|
finally:
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import json
|
|
2
|
+
from urllib.parse import unquote_plus
|
|
2
3
|
|
|
3
4
|
import httpx
|
|
4
5
|
import pytest
|
|
@@ -24,6 +25,59 @@ def test_account_prints_json(capsys):
|
|
|
24
25
|
assert out == {"plan": "pro"}
|
|
25
26
|
|
|
26
27
|
|
|
28
|
+
@respx.mock
|
|
29
|
+
def test_account_prints_trial_summary_on_stderr(capsys):
|
|
30
|
+
services = {
|
|
31
|
+
"available_services": ["text_to_music", "video_to_music"],
|
|
32
|
+
"rpm_limit": 60,
|
|
33
|
+
"concurrency_limit": 5,
|
|
34
|
+
"discount_factor": 1.0,
|
|
35
|
+
"max_upload_size_mb": 300,
|
|
36
|
+
"trial": {
|
|
37
|
+
"text_to_music": {"granted": 2, "used": 1, "remaining": 1},
|
|
38
|
+
"video_to_music": {"granted": 1, "used": 1, "remaining": 0},
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
respx.get(f"{BASE}/v1/account/services").mock(
|
|
42
|
+
return_value=httpx.Response(200, json=services)
|
|
43
|
+
)
|
|
44
|
+
run(["account"])
|
|
45
|
+
captured = capsys.readouterr()
|
|
46
|
+
# stdout stays parseable JSON; the summary is on stderr.
|
|
47
|
+
assert json.loads(captured.out) == services
|
|
48
|
+
assert captured.err.strip() == (
|
|
49
|
+
"Free trial: text-to-music 1/2 left, video-to-music 0/1 left"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@respx.mock
|
|
54
|
+
def test_account_prints_no_summary_without_trial(capsys):
|
|
55
|
+
respx.get(f"{BASE}/v1/account/services").mock(
|
|
56
|
+
return_value=httpx.Response(200, json={"available_services": []})
|
|
57
|
+
)
|
|
58
|
+
run(["account"])
|
|
59
|
+
assert capsys.readouterr().err == ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@respx.mock
|
|
63
|
+
def test_trial_exhausted_error_shows_the_code(capsys):
|
|
64
|
+
respx.get(f"{BASE}/v1/account/services").mock(
|
|
65
|
+
return_value=httpx.Response(
|
|
66
|
+
402,
|
|
67
|
+
json={
|
|
68
|
+
"code": "trial_exhausted",
|
|
69
|
+
"message": "You've used your 2 free trial calls for text-to-music.",
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
with pytest.raises(SystemExit) as exc:
|
|
74
|
+
run(["account"])
|
|
75
|
+
assert exc.value.code == 1
|
|
76
|
+
err = capsys.readouterr().err
|
|
77
|
+
assert "free trial calls for text-to-music" in err
|
|
78
|
+
assert "(trial_exhausted)" in err
|
|
79
|
+
|
|
80
|
+
|
|
27
81
|
@respx.mock
|
|
28
82
|
def test_usage_passes_days(capsys):
|
|
29
83
|
route = respx.get(f"{BASE}/v1/account/usage").mock(
|
|
@@ -369,3 +423,116 @@ def test_video_to_video_sound_requires_a_video_source():
|
|
|
369
423
|
with pytest.raises(SystemExit) as exc:
|
|
370
424
|
run(["video-to-video-sound"])
|
|
371
425
|
assert exc.value.code == 1
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def test_cli_identifies_itself_not_the_sdk():
|
|
429
|
+
"""CLI traffic must be separable from direct SDK use in analytics."""
|
|
430
|
+
import sonilo_cli
|
|
431
|
+
from sonilo_cli.__main__ import build_client
|
|
432
|
+
|
|
433
|
+
client = build_client("sk-test")
|
|
434
|
+
try:
|
|
435
|
+
assert client._http.headers["x-sonilo-client"] == "cli-python"
|
|
436
|
+
assert client._http.headers["x-sonilo-client-version"] == sonilo_cli.__version__
|
|
437
|
+
finally:
|
|
438
|
+
client.close()
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
DUBBING_BODY = {
|
|
442
|
+
"task_id": "db1",
|
|
443
|
+
"type": "dubbing",
|
|
444
|
+
"status": "succeeded",
|
|
445
|
+
"outputs": {"es": "https://r2/es.mp4", "fr": "https://r2/fr.mp4"},
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
@respx.mock
|
|
450
|
+
def test_dubbing_writes_one_file_per_language(tmp_path):
|
|
451
|
+
respx.post(f"{BASE}/v1/dubbing").mock(
|
|
452
|
+
return_value=httpx.Response(202, json={"task_id": "db1", "status": "processing"})
|
|
453
|
+
)
|
|
454
|
+
respx.get(f"{BASE}/v1/tasks/db1").mock(
|
|
455
|
+
return_value=httpx.Response(200, json=DUBBING_BODY)
|
|
456
|
+
)
|
|
457
|
+
respx.get("https://r2/es.mp4").mock(
|
|
458
|
+
return_value=httpx.Response(200, content=b"es-bytes")
|
|
459
|
+
)
|
|
460
|
+
respx.get("https://r2/fr.mp4").mock(
|
|
461
|
+
return_value=httpx.Response(200, content=b"fr-bytes")
|
|
462
|
+
)
|
|
463
|
+
out = tmp_path / "clip.mp4"
|
|
464
|
+
run([
|
|
465
|
+
"dubbing",
|
|
466
|
+
"--video-url", "https://x/v.mp4",
|
|
467
|
+
"--languages", "es,fr",
|
|
468
|
+
"--output", str(out),
|
|
469
|
+
])
|
|
470
|
+
assert (tmp_path / "clip.es.mp4").read_bytes() == b"es-bytes"
|
|
471
|
+
assert (tmp_path / "clip.fr.mp4").read_bytes() == b"fr-bytes"
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
@respx.mock
|
|
475
|
+
def test_dubbing_sends_languages_as_a_json_array(tmp_path):
|
|
476
|
+
route = respx.post(f"{BASE}/v1/dubbing").mock(
|
|
477
|
+
return_value=httpx.Response(202, json={"task_id": "db1", "status": "processing"})
|
|
478
|
+
)
|
|
479
|
+
respx.get(f"{BASE}/v1/tasks/db1").mock(
|
|
480
|
+
return_value=httpx.Response(200, json={
|
|
481
|
+
"task_id": "db1", "status": "succeeded",
|
|
482
|
+
"outputs": {"es": "https://r2/es.mp4"},
|
|
483
|
+
})
|
|
484
|
+
)
|
|
485
|
+
respx.get("https://r2/es.mp4").mock(
|
|
486
|
+
return_value=httpx.Response(200, content=b"es-bytes")
|
|
487
|
+
)
|
|
488
|
+
run([
|
|
489
|
+
"dubbing",
|
|
490
|
+
"--video-url", "https://x/v.mp4",
|
|
491
|
+
"--languages", " es , fr ",
|
|
492
|
+
"--output", str(tmp_path / "clip.mp4"),
|
|
493
|
+
])
|
|
494
|
+
# With no file part the request body is form-urlencoded, so the JSON
|
|
495
|
+
# array arrives percent-encoded (spaces as `+`, quotes as `%22`, etc.).
|
|
496
|
+
# Decode before checking for the literal JSON array string.
|
|
497
|
+
body = unquote_plus(route.calls.last.request.content.decode())
|
|
498
|
+
assert '["es", "fr"]' in body
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def test_dubbing_requires_a_video_source():
|
|
502
|
+
with pytest.raises(SystemExit) as exc:
|
|
503
|
+
main(["--api-key", "sk-test", "dubbing"])
|
|
504
|
+
assert exc.value.code == 1
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
@respx.mock
|
|
508
|
+
def test_dubbing_non_https_url_exits_1(capsys):
|
|
509
|
+
with pytest.raises(SystemExit) as exc:
|
|
510
|
+
main(["--api-key", "sk-test", "dubbing", "--video-url", "http://x/v.mp4"])
|
|
511
|
+
assert exc.value.code == 1
|
|
512
|
+
assert "https" in capsys.readouterr().err
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
@respx.mock
|
|
516
|
+
def test_dubbing_without_languages_omits_the_field(tmp_path):
|
|
517
|
+
route = respx.post(f"{BASE}/v1/dubbing").mock(
|
|
518
|
+
return_value=httpx.Response(202, json={"task_id": "db1", "status": "processing"})
|
|
519
|
+
)
|
|
520
|
+
respx.get(f"{BASE}/v1/tasks/db1").mock(
|
|
521
|
+
return_value=httpx.Response(200, json={
|
|
522
|
+
"task_id": "db1", "status": "succeeded",
|
|
523
|
+
"outputs": {"es": "https://r2/es.mp4"},
|
|
524
|
+
})
|
|
525
|
+
)
|
|
526
|
+
respx.get("https://r2/es.mp4").mock(
|
|
527
|
+
return_value=httpx.Response(200, content=b"es-bytes")
|
|
528
|
+
)
|
|
529
|
+
run([
|
|
530
|
+
"dubbing",
|
|
531
|
+
"--video-url", "https://x/v.mp4",
|
|
532
|
+
"--output", str(tmp_path / "clip.mp4"),
|
|
533
|
+
])
|
|
534
|
+
# Omitting --languages must not send a `languages` field at all — the
|
|
535
|
+
# server default (["zh_cn", "es", "fr"]) only applies when the field is
|
|
536
|
+
# absent. Sending `languages=[]` or the string "None" would silently
|
|
537
|
+
# override that default with something else.
|
|
538
|
+
assert b"languages" not in route.calls.last.request.content
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
ROOT = Path(__file__).resolve().parents[2]
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_context7_json_is_valid_and_complete():
|
|
8
|
+
data = json.loads((ROOT / "context7.json").read_text())
|
|
9
|
+
# Names the language: sonilo-js publishes to Context7 too, and a title of
|
|
10
|
+
# just "Sonilo" on both left the two indistinguishable in search.
|
|
11
|
+
assert data["projectTitle"] == "Sonilo Python SDK"
|
|
12
|
+
assert data["$schema"] == "https://context7.com/schema/context7.json"
|
|
13
|
+
assert "**/__pycache__/**" in data["excludeFolders"]
|
|
14
|
+
assert isinstance(data["rules"], list) and len(data["rules"]) >= 5
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Limits from https://context7.com/schema/context7.json. The parser tolerates
|
|
18
|
+
# oversized values, so nothing complains at index time — but the ownership
|
|
19
|
+
# claim validates strictly and rejects the whole file, which is how five
|
|
20
|
+
# over-long rules went unnoticed until claiming failed.
|
|
21
|
+
MAX_RULE_LEN = 255
|
|
22
|
+
MAX_RULES = 50
|
|
23
|
+
MAX_DESCRIPTION_LEN = 200
|
|
24
|
+
MAX_PROJECT_TITLE_LEN = 100
|
|
25
|
+
|
|
26
|
+
ALLOWED_KEYS = {
|
|
27
|
+
"$schema", "projectTitle", "description", "branch", "folders",
|
|
28
|
+
"excludeFolders", "excludeFiles", "rules", "disallow", "redirect",
|
|
29
|
+
"previousVersions", "url", "public_key",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_context7_json_respects_schema_limits():
|
|
34
|
+
data = json.loads((ROOT / "context7.json").read_text())
|
|
35
|
+
|
|
36
|
+
unknown = set(data) - ALLOWED_KEYS
|
|
37
|
+
assert not unknown, f"unknown top-level keys reject the claim: {unknown}"
|
|
38
|
+
|
|
39
|
+
assert len(data["rules"]) <= MAX_RULES
|
|
40
|
+
too_long = {i: len(r) for i, r in enumerate(data["rules"]) if len(r) > MAX_RULE_LEN}
|
|
41
|
+
assert not too_long, f"rules over {MAX_RULE_LEN} chars: {too_long}"
|
|
42
|
+
|
|
43
|
+
assert len(data["description"]) <= MAX_DESCRIPTION_LEN
|
|
44
|
+
assert len(data["projectTitle"]) <= MAX_PROJECT_TITLE_LEN
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_context7_json_carries_ownership_keys():
|
|
48
|
+
"""The claim flow requires both, and the schema makes them interdependent."""
|
|
49
|
+
data = json.loads((ROOT / "context7.json").read_text())
|
|
50
|
+
assert data["url"] == "https://context7.com/sonilo-ai/sonilo-python"
|
|
51
|
+
assert data["public_key"].startswith("pk_")
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
from pathlib import Path
|
|
3
|
-
|
|
4
|
-
ROOT = Path(__file__).resolve().parents[2]
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
def test_context7_json_is_valid_and_complete():
|
|
8
|
-
data = json.loads((ROOT / "context7.json").read_text())
|
|
9
|
-
assert data["projectTitle"] == "Sonilo"
|
|
10
|
-
assert data["$schema"] == "https://context7.com/schema/context7.json"
|
|
11
|
-
assert "**/__pycache__/**" in data["excludeFolders"]
|
|
12
|
-
assert isinstance(data["rules"], list) and len(data["rules"]) >= 5
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|