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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sonilo-cli
3
- Version: 0.1.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.6,>=0.5
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 every endpoint — no card
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. `sonilo account`
87
- shows the services available to your key.
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 every endpoint — no card
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. `sonilo account`
71
- shows the services available to your key.
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.1.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.5,<0.6"]
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]
@@ -1,3 +1,3 @@
1
- __version__ = "0.1.0"
1
+ __version__ = "0.3.0"
2
2
 
3
3
  __all__ = ["__version__"]
@@ -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
- return Sonilo(api_key=key)
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
- _print_json(client.account.services())
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