sonilo-cli 0.4.0__tar.gz → 0.5.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.4.0
3
+ Version: 0.5.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
@@ -41,6 +41,8 @@ or pass `--api-key sk-...` on any command.
41
41
  sonilo video-to-sfx --video clip.mp4 --segments @segments.json
42
42
  sonilo video-to-sound --video clip.mp4 \
43
43
  --music-prompt "uplifting orchestral score" --sfx-prompt "match the on-screen action"
44
+ sonilo video-to-video-music --video clip.mp4 --prompt "tense synths" --output scored.mp4
45
+ sonilo video-to-video-sfx --video clip.mp4 --segments @segments.json --output scored.mp4
44
46
  sonilo video-to-video-sound --video clip.mp4 --music-prompt "tense synths"
45
47
  sonilo dubbing --video-url https://example.com/clip.mp4 --languages es,fr --output dubbed.mp4
46
48
  # writes dubbed.es.mp4 and dubbed.fr.mp4
@@ -50,7 +52,8 @@ or pass `--api-key sk-...` on any command.
50
52
  ### Notes
51
53
 
52
54
  - `text-to-music` / `video-to-music` stream a short `.m4a` by default. `--format wav`,
53
- `--isolate-vocals`, and `--preserve-speech` each switch to the async submit-and-poll path.
55
+ `--preserve-speech`, and its legacy alias `--isolate-vocals` each switch to the async
56
+ submit-and-poll path.
54
57
  - `text-to-sfx` / `video-to-sfx` are always async; `--format` accepts `wav|mp3|aac|flac`.
55
58
  - Output defaults to `./output.<ext>`; override with `--output`.
56
59
 
@@ -72,7 +75,7 @@ The two segment shapes are **not** interchangeable:
72
75
  | Shape | Commands | Fields |
73
76
  | --- | --- | --- |
74
77
  | Music | `text-to-music`, `video-to-music` | `{start, prompt, label?}` |
75
- | SFX | `video-to-sfx`, `video-to-sound`, `video-to-video-sound` | `{start, end, prompt}` |
78
+ | SFX | `video-to-sfx`, `video-to-video-sfx`, `video-to-sound`, `video-to-video-sound` | `{start, end, prompt}` |
76
79
 
77
80
  - `start` / `end` are seconds from the start of the track or clip.
78
81
  - Passing one shape to a command that takes the other is rejected before any request is made, with
@@ -83,6 +86,28 @@ The two segment shapes are **not** interchangeable:
83
86
  - Keys the CLI does not recognise are forwarded as-is, so a newly added API field works without
84
87
  upgrading the CLI.
85
88
  - `text-to-sfx` takes no segments (its output is a single effect, not a timeline).
89
+ - `video-to-video-music` takes no segments either — the API scores the whole clip in one pass.
90
+
91
+ ### Scored video
92
+
93
+ `video-to-video-music` and `video-to-video-sfx` are the video-out counterparts of `video-to-music`
94
+ and `video-to-sfx`: same generation, but what comes back is the source picture with the new audio
95
+ already muxed in, so there is nothing to line up afterwards. Both are async-only and write a single
96
+ file (default `output.mp4`):
97
+
98
+ sonilo video-to-video-music --video clip.mp4 --prompt "tense synths" --output scored.mp4
99
+ sonilo video-to-video-sfx --video clip.mp4 \
100
+ --segments '[{"start":0,"end":5,"prompt":"footsteps on gravel"}]' --output scored.mp4
101
+
102
+ - `--prompt` is optional on both; without it the model scores from the picture alone.
103
+ - `video-to-video-music` also takes `--preserve-speech`, which keeps source speech in the mix;
104
+ omitting it leaves the server default untouched. `--isolate-vocals` is a legacy alias for the
105
+ same flag — the API ORs the two together, and this endpoint returns one muxed video with no
106
+ separate vocals stem.
107
+ - `video-to-video-sfx` takes `--segments` in the SFX shape `{start, end, prompt}` — see
108
+ [Segments](#segments).
109
+ - Neither command exposes `--format`: the output is a video, not an audio file.
110
+ - For music *and* effects in one call, use `video-to-video-sound` below.
86
111
 
87
112
  ### Combined soundtracks
88
113
 
@@ -25,6 +25,8 @@ or pass `--api-key sk-...` on any command.
25
25
  sonilo video-to-sfx --video clip.mp4 --segments @segments.json
26
26
  sonilo video-to-sound --video clip.mp4 \
27
27
  --music-prompt "uplifting orchestral score" --sfx-prompt "match the on-screen action"
28
+ sonilo video-to-video-music --video clip.mp4 --prompt "tense synths" --output scored.mp4
29
+ sonilo video-to-video-sfx --video clip.mp4 --segments @segments.json --output scored.mp4
28
30
  sonilo video-to-video-sound --video clip.mp4 --music-prompt "tense synths"
29
31
  sonilo dubbing --video-url https://example.com/clip.mp4 --languages es,fr --output dubbed.mp4
30
32
  # writes dubbed.es.mp4 and dubbed.fr.mp4
@@ -34,7 +36,8 @@ or pass `--api-key sk-...` on any command.
34
36
  ### Notes
35
37
 
36
38
  - `text-to-music` / `video-to-music` stream a short `.m4a` by default. `--format wav`,
37
- `--isolate-vocals`, and `--preserve-speech` each switch to the async submit-and-poll path.
39
+ `--preserve-speech`, and its legacy alias `--isolate-vocals` each switch to the async
40
+ submit-and-poll path.
38
41
  - `text-to-sfx` / `video-to-sfx` are always async; `--format` accepts `wav|mp3|aac|flac`.
39
42
  - Output defaults to `./output.<ext>`; override with `--output`.
40
43
 
@@ -56,7 +59,7 @@ The two segment shapes are **not** interchangeable:
56
59
  | Shape | Commands | Fields |
57
60
  | --- | --- | --- |
58
61
  | Music | `text-to-music`, `video-to-music` | `{start, prompt, label?}` |
59
- | SFX | `video-to-sfx`, `video-to-sound`, `video-to-video-sound` | `{start, end, prompt}` |
62
+ | SFX | `video-to-sfx`, `video-to-video-sfx`, `video-to-sound`, `video-to-video-sound` | `{start, end, prompt}` |
60
63
 
61
64
  - `start` / `end` are seconds from the start of the track or clip.
62
65
  - Passing one shape to a command that takes the other is rejected before any request is made, with
@@ -67,6 +70,28 @@ The two segment shapes are **not** interchangeable:
67
70
  - Keys the CLI does not recognise are forwarded as-is, so a newly added API field works without
68
71
  upgrading the CLI.
69
72
  - `text-to-sfx` takes no segments (its output is a single effect, not a timeline).
73
+ - `video-to-video-music` takes no segments either — the API scores the whole clip in one pass.
74
+
75
+ ### Scored video
76
+
77
+ `video-to-video-music` and `video-to-video-sfx` are the video-out counterparts of `video-to-music`
78
+ and `video-to-sfx`: same generation, but what comes back is the source picture with the new audio
79
+ already muxed in, so there is nothing to line up afterwards. Both are async-only and write a single
80
+ file (default `output.mp4`):
81
+
82
+ sonilo video-to-video-music --video clip.mp4 --prompt "tense synths" --output scored.mp4
83
+ sonilo video-to-video-sfx --video clip.mp4 \
84
+ --segments '[{"start":0,"end":5,"prompt":"footsteps on gravel"}]' --output scored.mp4
85
+
86
+ - `--prompt` is optional on both; without it the model scores from the picture alone.
87
+ - `video-to-video-music` also takes `--preserve-speech`, which keeps source speech in the mix;
88
+ omitting it leaves the server default untouched. `--isolate-vocals` is a legacy alias for the
89
+ same flag — the API ORs the two together, and this endpoint returns one muxed video with no
90
+ separate vocals stem.
91
+ - `video-to-video-sfx` takes `--segments` in the SFX shape `{start, end, prompt}` — see
92
+ [Segments](#segments).
93
+ - Neither command exposes `--format`: the output is a video, not an audio file.
94
+ - For music *and* effects in one call, use `video-to-video-sound` below.
70
95
 
71
96
  ### Combined soundtracks
72
97
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sonilo-cli"
7
- version = "0.4.0"
7
+ version = "0.5.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"
@@ -1,3 +1,3 @@
1
- __version__ = "0.4.0"
1
+ __version__ = "0.5.0"
2
2
 
3
3
  __all__ = ["__version__"]
@@ -329,6 +329,40 @@ def cmd_video_to_video_sound(client: Sonilo, args: argparse.Namespace) -> None:
329
329
  _run_sound(client, args, client.video_to_video_sound, "mp4")
330
330
 
331
331
 
332
+ def _run_video(args: argparse.Namespace, resource: Any, **params: Any) -> None:
333
+ """Run one of the video-returning endpoints and save the single result.
334
+
335
+ These return the source picture with the generated audio muxed in — one
336
+ file, no stems — so the default destination is an `.mp4`, matching
337
+ video-to-video-sound.
338
+ """
339
+ out = args.output if args.output is not None else "output.mp4"
340
+ result = resource.generate(video=args.video, video_url=args.video_url, **params)
341
+ path = result.save(out)
342
+ _wrote(path, path.stat().st_size)
343
+
344
+
345
+ def cmd_video_to_video_music(client: Sonilo, args: argparse.Namespace) -> None:
346
+ _run_video(
347
+ args,
348
+ client.video_to_video_music,
349
+ prompt=args.prompt,
350
+ # Unset flags forward None, not False, so the server default stands —
351
+ # same reasoning as --no-ducking on the sound commands.
352
+ preserve_speech=True if args.preserve_speech else None,
353
+ isolate_vocals=True if args.isolate_vocals else None,
354
+ )
355
+
356
+
357
+ def cmd_video_to_video_sfx(client: Sonilo, args: argparse.Namespace) -> None:
358
+ _run_video(
359
+ args,
360
+ client.video_to_video_sfx,
361
+ prompt=args.prompt,
362
+ segments=_segments(args),
363
+ )
364
+
365
+
332
366
  # Matched to the dubbing backend's own ceiling: it polls its pipeline for up
333
367
  # to 7200s (2 hours), so anything shorter abandons a job the user has already
334
368
  # been charged for. The SDK's generic DEFAULT_WAIT_TIMEOUT of 600s is far too
@@ -460,10 +494,14 @@ def build_parser() -> argparse.ArgumentParser:
460
494
  p_v2m.add_argument("--output", default=None, help="Where to save the audio.")
461
495
  p_v2m.add_argument("--format", choices=["m4a", "wav"], default="m4a",
462
496
  help="Output container. wav forces async.")
463
- p_v2m.add_argument("--isolate-vocals", dest="isolate_vocals", action="store_true",
464
- help="Split out a vocals-only stem. Forces async.")
465
497
  p_v2m.add_argument("--preserve-speech", dest="preserve_speech", action="store_true",
466
498
  help="Keep source speech in the mix. Forces async.")
499
+ # The API ORs isolate_vocals into preserve_speech (video_to_music.py:
500
+ # `isolate_vocals = bool(preserve_speech) or bool(isolate_vocals)`), so
501
+ # the two flags are one feature under two names, not two behaviours.
502
+ # isolate_vocals is the legacy name kept for existing callers.
503
+ p_v2m.add_argument("--isolate-vocals", dest="isolate_vocals", action="store_true",
504
+ help="Legacy alias for --preserve-speech. Forces async.")
467
505
  p_v2m.add_argument("--async", dest="use_async", action="store_true",
468
506
  help="Submit and poll instead of streaming.")
469
507
  p_v2m.set_defaults(func=cmd_video_to_music)
@@ -506,6 +544,33 @@ def build_parser() -> argparse.ArgumentParser:
506
544
  p_v2sd.add_argument("--output", default=None, help="Where to save the combined audio.")
507
545
  p_v2sd.set_defaults(func=cmd_video_to_sound)
508
546
 
547
+ p_v2vm = sub.add_parser(
548
+ "video-to-video-music", help="Generate music muxed into the source video"
549
+ )
550
+ _add_global(p_v2vm)
551
+ _add_video_source(p_v2vm)
552
+ p_v2vm.add_argument("--prompt", default=None, help="Optional creative direction.")
553
+ p_v2vm.add_argument("--preserve-speech", dest="preserve_speech", action="store_true",
554
+ help="Keep source speech in the mix.")
555
+ # Same aliasing as video-to-music, and here the endpoint collapses the two
556
+ # into a single boolean before it reaches the model (video_to_video.py:
557
+ # `keep_speech = bool(preserve_speech) or bool(isolate_vocals)`), with no
558
+ # vocals stem in the result — the output is one muxed video.
559
+ p_v2vm.add_argument("--isolate-vocals", dest="isolate_vocals", action="store_true",
560
+ help="Legacy alias for --preserve-speech; no separate stem.")
561
+ p_v2vm.add_argument("--output", default=None, help="Where to save the scored video.")
562
+ p_v2vm.set_defaults(func=cmd_video_to_video_music)
563
+
564
+ p_v2vfx = sub.add_parser(
565
+ "video-to-video-sfx", help="Generate sound effects muxed into the source video"
566
+ )
567
+ _add_global(p_v2vfx)
568
+ _add_video_source(p_v2vfx)
569
+ p_v2vfx.add_argument("--prompt", default=None, help="Optional creative direction.")
570
+ _add_segments(p_v2vfx, SFX_SHAPE)
571
+ p_v2vfx.add_argument("--output", default=None, help="Where to save the scored video.")
572
+ p_v2vfx.set_defaults(func=cmd_video_to_video_sfx)
573
+
509
574
  p_v2vsd = sub.add_parser(
510
575
  "video-to-video-sound", help="Generate matched music+sfx muxed into the source video"
511
576
  )
@@ -764,7 +764,7 @@ def test_segments_semantic_rules_are_left_to_the_server():
764
764
 
765
765
  @pytest.mark.parametrize(
766
766
  "command",
767
- ["text-to-music", "video-to-music", "video-to-sfx",
767
+ ["text-to-music", "video-to-music", "video-to-sfx", "video-to-video-sfx",
768
768
  "video-to-sound", "video-to-video-sound"],
769
769
  )
770
770
  def test_segments_help_shows_all_three_value_forms(command, capsys):
@@ -778,10 +778,192 @@ def test_segments_help_shows_all_three_value_forms(command, capsys):
778
778
 
779
779
  @pytest.mark.parametrize("command", ["text-to-sfx"])
780
780
  def test_commands_without_segments_reject_the_flag(command, capsys):
781
- """text-to-sfx takes no segments in the SDK, so the CLI must not offer it
782
- (video-to-video-music, the other segment-less endpoint, has no CLI
783
- command at all)."""
781
+ """text-to-sfx takes no segments in the SDK, so the CLI must not offer it.
782
+ The other segment-less endpoint, video-to-video-music, is covered by
783
+ test_video_to_video_music_rejects_segments (it takes no --duration)."""
784
784
  with pytest.raises(SystemExit) as exc:
785
785
  run([command, "--prompt", "x", "--duration", "3", "--segments", "[]"])
786
786
  assert exc.value.code == 1
787
787
  assert "unrecognized arguments" in capsys.readouterr().err
788
+
789
+
790
+ # --- video-to-video-music / video-to-video-sfx ---------------------------
791
+ #
792
+ # Both return the source picture with the generated audio muxed in, so the
793
+ # task body carries a single `video` object rather than `audio`/`output_url`
794
+ # (shape confirmed against tests/test_video_to_video.py in the SDK repo).
795
+
796
+
797
+ def _video_body(task_id, task_type):
798
+ return {
799
+ "task_id": task_id,
800
+ "type": task_type,
801
+ "status": "succeeded",
802
+ "video": {"url": f"https://r2.example.com/{task_id}.mp4",
803
+ "content_type": "video/mp4", "file_size": 7},
804
+ "duration_seconds": 4.0,
805
+ }
806
+
807
+
808
+ def _mock_video_task(endpoint, task_id, task_type, content=b"MP4DATA"):
809
+ """Wire up submit + poll + download for one video-returning endpoint."""
810
+ route = respx.post(f"{BASE}/v1/{endpoint}").mock(
811
+ return_value=httpx.Response(202, json={"task_id": task_id, "status": "processing"})
812
+ )
813
+ respx.get(f"{BASE}/v1/tasks/{task_id}").mock(
814
+ return_value=httpx.Response(200, json=_video_body(task_id, task_type))
815
+ )
816
+ respx.get(f"https://r2.example.com/{task_id}.mp4").mock(
817
+ return_value=httpx.Response(200, content=content)
818
+ )
819
+ return route
820
+
821
+
822
+ @respx.mock
823
+ def test_video_to_video_music_saves_the_scored_video(tmp_path):
824
+ route = _mock_video_task("video-to-video-music", "vm1", "video_to_video_music")
825
+ out = tmp_path / "scored.mp4"
826
+ run(["video-to-video-music", "--video-url", "http://x/y.mp4",
827
+ "--prompt", "tense synths", "--output", str(out)])
828
+ assert route.called
829
+ assert out.read_bytes() == b"MP4DATA"
830
+ body = unquote_plus(route.calls.last.request.content.decode())
831
+ assert "video_url=http://x/y.mp4" in body
832
+ assert "prompt=tense synths" in body
833
+
834
+
835
+ @respx.mock
836
+ def test_video_to_video_music_defaults_to_mp4(tmp_path, monkeypatch):
837
+ monkeypatch.chdir(tmp_path)
838
+ _mock_video_task("video-to-video-music", "vm2", "video_to_video_music")
839
+ run(["video-to-video-music", "--video-url", "http://x/y.mp4"])
840
+ assert (tmp_path / "output.mp4").read_bytes() == b"MP4DATA"
841
+
842
+
843
+ @respx.mock
844
+ def test_video_to_video_music_flags_reach_the_request_body(tmp_path):
845
+ route = _mock_video_task("video-to-video-music", "vm3", "video_to_video_music")
846
+ run(["video-to-video-music", "--video-url", "http://x/y.mp4",
847
+ "--preserve-speech", "--isolate-vocals", "--output", str(tmp_path / "s.mp4")])
848
+ body = route.calls.last.request.content.decode()
849
+ assert "preserve_speech=true" in body
850
+ assert "isolate_vocals=true" in body
851
+
852
+
853
+ @respx.mock
854
+ def test_video_to_video_music_unset_flags_are_omitted(tmp_path):
855
+ route = _mock_video_task("video-to-video-music", "vm4", "video_to_video_music")
856
+ run(["video-to-video-music", "--video-url", "http://x/y.mp4",
857
+ "--output", str(tmp_path / "s.mp4")])
858
+ # Unset switches must forward None, not False, so the server default
859
+ # stands — same rule as --no-ducking on the sound commands.
860
+ body = route.calls.last.request.content.decode()
861
+ assert "preserve_speech=" not in body
862
+ assert "isolate_vocals=" not in body
863
+ assert "prompt=" not in body
864
+
865
+
866
+ def test_video_to_video_music_requires_a_video_source():
867
+ with pytest.raises(SystemExit) as exc:
868
+ run(["video-to-video-music", "--prompt", "x"])
869
+ assert exc.value.code == 1
870
+
871
+
872
+ def test_video_to_video_music_rejects_both_sources():
873
+ with pytest.raises(SystemExit) as exc:
874
+ run(["video-to-video-music", "--video", "a.mp4", "--video-url", "http://x/y.mp4"])
875
+ assert exc.value.code == 1
876
+
877
+
878
+ def test_video_to_video_music_rejects_segments(capsys):
879
+ """The endpoint scores the whole clip in one pass — the SDK resource takes
880
+ no `segments`, so the CLI must not offer the flag."""
881
+ with pytest.raises(SystemExit) as exc:
882
+ run(["video-to-video-music", "--video-url", "http://x/y.mp4", "--segments", "[]"])
883
+ assert exc.value.code == 1
884
+ assert "unrecognized arguments" in capsys.readouterr().err
885
+
886
+
887
+ @respx.mock
888
+ def test_video_to_video_sfx_saves_the_scored_video(tmp_path):
889
+ route = _mock_video_task("video-to-video-sfx", "vf1", "video_to_video_sfx")
890
+ out = tmp_path / "scored.mp4"
891
+ run(["video-to-video-sfx", "--video-url", "http://x/y.mp4",
892
+ "--prompt", "footsteps", "--output", str(out)])
893
+ assert route.called
894
+ assert out.read_bytes() == b"MP4DATA"
895
+ body = unquote_plus(route.calls.last.request.content.decode())
896
+ assert "video_url=http://x/y.mp4" in body
897
+ assert "prompt=footsteps" in body
898
+
899
+
900
+ @respx.mock
901
+ def test_video_to_video_sfx_defaults_to_mp4(tmp_path, monkeypatch):
902
+ monkeypatch.chdir(tmp_path)
903
+ _mock_video_task("video-to-video-sfx", "vf2", "video_to_video_sfx")
904
+ run(["video-to-video-sfx", "--video-url", "http://x/y.mp4"])
905
+ assert (tmp_path / "output.mp4").read_bytes() == b"MP4DATA"
906
+
907
+
908
+ @respx.mock
909
+ def test_video_to_video_sfx_segments_reach_the_request_body(tmp_path):
910
+ route = _mock_video_task("video-to-video-sfx", "vf3", "video_to_video_sfx")
911
+ run(["video-to-video-sfx", "--video-url", "http://x/y.mp4",
912
+ "--segments", json.dumps(SFX_SEGMENTS), "--output", str(tmp_path / "s.mp4")])
913
+ assert _sent_segments(route) == SFX_SEGMENTS
914
+
915
+
916
+ @respx.mock
917
+ def test_video_to_video_sfx_omitting_segments_sends_no_field(tmp_path):
918
+ route = _mock_video_task("video-to-video-sfx", "vf4", "video_to_video_sfx")
919
+ run(["video-to-video-sfx", "--video-url", "http://x/y.mp4",
920
+ "--output", str(tmp_path / "s.mp4")])
921
+ assert b"segments" not in route.calls.last.request.content
922
+
923
+
924
+ def test_video_to_video_sfx_rejects_music_shaped_segments(capsys):
925
+ with pytest.raises(SystemExit) as exc:
926
+ run(["video-to-video-sfx", "--video-url", "http://x/y.mp4",
927
+ "--segments", json.dumps(MUSIC_SEGMENTS)])
928
+ assert exc.value.code == 1
929
+ err = capsys.readouterr().err
930
+ assert "video-to-video-sfx segments take {start, end, prompt}" in err
931
+ assert "got an object with keys start, label, prompt" in err
932
+
933
+
934
+ def test_video_to_video_sfx_requires_a_video_source():
935
+ with pytest.raises(SystemExit) as exc:
936
+ run(["video-to-video-sfx", "--prompt", "x"])
937
+ assert exc.value.code == 1
938
+
939
+
940
+ def test_video_to_video_sfx_rejects_both_sources():
941
+ with pytest.raises(SystemExit) as exc:
942
+ run(["video-to-video-sfx", "--video", "a.mp4", "--video-url", "http://x/y.mp4"])
943
+ assert exc.value.code == 1
944
+
945
+
946
+ @pytest.mark.parametrize("command", ["video-to-music", "video-to-video-music"])
947
+ def test_isolate_vocals_is_documented_as_an_alias(command, capsys):
948
+ """Both endpoints OR the two fields into one behaviour server-side
949
+ (video_to_music.py: `isolate_vocals = bool(preserve_speech) or
950
+ bool(isolate_vocals)`; video_to_video.py: `keep_speech = bool(...) or
951
+ bool(...)`), so the help must not present --isolate-vocals as a separate
952
+ feature. On video-to-video-music there is no stem at all — the result is
953
+ a single muxed video."""
954
+ with pytest.raises(SystemExit):
955
+ main([command, "--help"])
956
+ help_text = capsys.readouterr().out
957
+ assert "Legacy alias for --preserve-speech" in help_text
958
+ assert "vocals-only stem" not in help_text
959
+
960
+
961
+ @pytest.mark.parametrize(
962
+ "command", ["video-to-video-music", "video-to-video-sfx"]
963
+ )
964
+ def test_video_to_video_commands_are_listed_in_top_level_help(command, capsys):
965
+ """Both are documented publicly as `sonilo <command>`, so they have to be
966
+ discoverable from `sonilo --help`, not just by knowing the name."""
967
+ with pytest.raises(SystemExit):
968
+ main(["--help"])
969
+ assert command in capsys.readouterr().out
File without changes
File without changes
File without changes