sonilo-cli 0.10.0__tar.gz → 0.12.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
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: sonilo-cli
3
- Version: 0.10.0
3
+ Version: 0.12.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.13,>=0.12.0
11
+ Requires-Dist: sonilo<0.14,>=0.13.0
12
12
  Provides-Extra: dev
13
13
  Requires-Dist: pytest>=8; extra == 'dev'
14
14
  Requires-Dist: respx>=0.21; extra == 'dev'
@@ -22,16 +22,67 @@ Command-line interface for the [Sonilo API](https://github.com/sonilo-ai/sonilo-
22
22
 
23
23
  pip install sonilo-cli
24
24
 
25
- ## Auth
25
+ ## Signing in
26
26
 
27
- Set your API key once:
27
+ Run this once per machine — there is no key to create, paste, or export:
28
+
29
+ sonilo login
30
+
31
+ It prints a one-time code and opens your browser to platform.sonilo.com. Sign
32
+ in, confirm the code matches what the terminal printed, and approve; the CLI is
33
+ waiting on that page and continues by itself. Every command works from then on.
34
+
35
+ Approving mints an ordinary Sonilo API key on your account, named
36
+ `cli: <hostname>` and valid for **90 days**, stored in
37
+ `~/.config/sonilo/credentials.json` (`$XDG_CONFIG_HOME/sonilo/` when that is
38
+ set), owner-readable only. It is visible and revocable at
39
+ [the dashboard](https://platform.sonilo.com/dashboard/api-keys) like any other
40
+ key.
41
+
42
+ sonilo whoami # which account, key prefix, expiry, and which source is active
43
+ sonilo logout # revoke the key server-side, then forget it locally
44
+
45
+ `logout` revokes before forgetting, so a machine that loses the file never
46
+ leaves a live key behind. If the revoke cannot reach the API the credential is
47
+ deliberately kept and you are pointed at the dashboard, rather than being left
48
+ holding a key nothing can revoke.
49
+
50
+ Signing in again while already signed in reports the existing session; use
51
+ `--force` to replace it (that mints a fresh key and revokes the one it
52
+ replaces). When a credential has expired you do not need the flag — `sonilo
53
+ login` treats an expired sign-in as no sign-in. On a machine with no browser,
54
+ add `--no-browser` and approve the printed URL from another device.
55
+
56
+ The credential file is shared with the JS CLI (`npm install -g sonilo-cli`) and
57
+ read by [sonilo-mcp](https://github.com/sonilo-ai/sonilo-mcp) 0.16.0+, so one
58
+ sign-in covers all three — an MCP host config then needs no `env` block at all.
59
+
60
+ ## Auth with an API key
61
+
62
+ Signing in is optional. A key from
63
+ [the dashboard](https://platform.sonilo.com/dashboard/api-keys) works exactly as
64
+ it always has, which is what you want for CI, containers, and anything
65
+ non-interactive — there is no browser there to approve with, and a 90-day expiry
66
+ is not something a pipeline should depend on:
28
67
 
29
68
  export SONILO_API_KEY=sk-...
30
69
 
31
70
  or pass `--api-key sk-...` on any command.
32
71
 
72
+ Credentials resolve most-explicit-first: `--api-key`, then `SONILO_API_KEY`,
73
+ then the stored sign-in. That order is a guarantee, not an accident — an
74
+ exported `SONILO_API_KEY` keeps winning after you upgrade, so adding a `sonilo
75
+ login` on the same machine cannot quietly move your calls to another account.
76
+
77
+ Set `SONILO_API_URL` (or pass `--api-base` to `login`) to point at another
78
+ environment. Credentials are stored per host, so a staging sign-in and a
79
+ production sign-in coexist without overwriting each other.
80
+
33
81
  ## Commands
34
82
 
83
+ sonilo login # sign in with your browser, no key needed
84
+ sonilo whoami # show the active account and credential source
85
+ sonilo logout # revoke the stored key and forget it
35
86
  sonilo account # plan limits and available services
36
87
  sonilo usage --days 7 # usage summary
37
88
  sonilo text-to-music --prompt "warm lo-fi piano, rain" --duration 30
@@ -44,6 +95,9 @@ or pass `--api-key sk-...` on any command.
44
95
  sonilo video-to-video-music --video clip.mp4 --prompt "tense synths" --output scored.mp4
45
96
  sonilo video-to-video-sfx --video clip.mp4 --segments @segments.json --output scored.mp4
46
97
  sonilo video-to-video-sound --video clip.mp4 --music-prompt "tense synths"
98
+ sonilo audio-ducking --voice interview.mp4 --music-url https://example.com/bed.wav
99
+ # ducks the existing music bed under the voice; a video voice comes back
100
+ # as a new .mp4 with the ducked mix muxed in
47
101
  sonilo dubbing --video-url https://example.com/clip.mp4 --languages es,fr --output dubbed.mp4
48
102
  # writes dubbed.es.mp4 and dubbed.fr.mp4
49
103
  sonilo tasks get <task-id>
@@ -169,6 +223,24 @@ they differ only in what comes back: `video-to-sound` writes the mixed **audio**
169
223
  or ducking altered the music bed.
170
224
  - Both also take `--variants` — see [Variants](#variants) above.
171
225
 
226
+ ### Audio ducking
227
+
228
+ `audio-ducking` mixes an **existing** music bed under an **existing** voice track — nothing is
229
+ generated, so reach for it when the music is fixed or external. (When the music is being generated
230
+ for the same clip anyway, `video-to-sound` or `video-to-music` duck internally as part of that one
231
+ call instead.)
232
+
233
+ sonilo audio-ducking --voice interview.mp4 --music-url https://example.com/bed.wav
234
+
235
+ - Exactly one of `--voice` / `--voice-url` and one of `--music` / `--music-url`; a local file and a
236
+ URL mix freely across the two inputs.
237
+ - The **voice** may be audio or video: a video's own audio track becomes the voice, and the ducked
238
+ mix is muxed back into a new video, so the result is a `.mp4` instead of a `.wav`. The default
239
+ `--output` name follows what came back (`output.wav` or `output.mp4`).
240
+ - The **music** must be audio (`wav, mp3, m4a, aac, ogg, flac`). The API does not detect a video
241
+ there, so the CLI rejects a local video file up front rather than let it be mishandled silently.
242
+ - Each input is capped at 360 seconds server-side.
243
+
172
244
  ### Dubbing
173
245
 
174
246
  `dubbing` dubs a video into one or more target languages in a single async call:
@@ -6,16 +6,67 @@ Command-line interface for the [Sonilo API](https://github.com/sonilo-ai/sonilo-
6
6
 
7
7
  pip install sonilo-cli
8
8
 
9
- ## Auth
9
+ ## Signing in
10
10
 
11
- Set your API key once:
11
+ Run this once per machine — there is no key to create, paste, or export:
12
+
13
+ sonilo login
14
+
15
+ It prints a one-time code and opens your browser to platform.sonilo.com. Sign
16
+ in, confirm the code matches what the terminal printed, and approve; the CLI is
17
+ waiting on that page and continues by itself. Every command works from then on.
18
+
19
+ Approving mints an ordinary Sonilo API key on your account, named
20
+ `cli: <hostname>` and valid for **90 days**, stored in
21
+ `~/.config/sonilo/credentials.json` (`$XDG_CONFIG_HOME/sonilo/` when that is
22
+ set), owner-readable only. It is visible and revocable at
23
+ [the dashboard](https://platform.sonilo.com/dashboard/api-keys) like any other
24
+ key.
25
+
26
+ sonilo whoami # which account, key prefix, expiry, and which source is active
27
+ sonilo logout # revoke the key server-side, then forget it locally
28
+
29
+ `logout` revokes before forgetting, so a machine that loses the file never
30
+ leaves a live key behind. If the revoke cannot reach the API the credential is
31
+ deliberately kept and you are pointed at the dashboard, rather than being left
32
+ holding a key nothing can revoke.
33
+
34
+ Signing in again while already signed in reports the existing session; use
35
+ `--force` to replace it (that mints a fresh key and revokes the one it
36
+ replaces). When a credential has expired you do not need the flag — `sonilo
37
+ login` treats an expired sign-in as no sign-in. On a machine with no browser,
38
+ add `--no-browser` and approve the printed URL from another device.
39
+
40
+ The credential file is shared with the JS CLI (`npm install -g sonilo-cli`) and
41
+ read by [sonilo-mcp](https://github.com/sonilo-ai/sonilo-mcp) 0.16.0+, so one
42
+ sign-in covers all three — an MCP host config then needs no `env` block at all.
43
+
44
+ ## Auth with an API key
45
+
46
+ Signing in is optional. A key from
47
+ [the dashboard](https://platform.sonilo.com/dashboard/api-keys) works exactly as
48
+ it always has, which is what you want for CI, containers, and anything
49
+ non-interactive — there is no browser there to approve with, and a 90-day expiry
50
+ is not something a pipeline should depend on:
12
51
 
13
52
  export SONILO_API_KEY=sk-...
14
53
 
15
54
  or pass `--api-key sk-...` on any command.
16
55
 
56
+ Credentials resolve most-explicit-first: `--api-key`, then `SONILO_API_KEY`,
57
+ then the stored sign-in. That order is a guarantee, not an accident — an
58
+ exported `SONILO_API_KEY` keeps winning after you upgrade, so adding a `sonilo
59
+ login` on the same machine cannot quietly move your calls to another account.
60
+
61
+ Set `SONILO_API_URL` (or pass `--api-base` to `login`) to point at another
62
+ environment. Credentials are stored per host, so a staging sign-in and a
63
+ production sign-in coexist without overwriting each other.
64
+
17
65
  ## Commands
18
66
 
67
+ sonilo login # sign in with your browser, no key needed
68
+ sonilo whoami # show the active account and credential source
69
+ sonilo logout # revoke the stored key and forget it
19
70
  sonilo account # plan limits and available services
20
71
  sonilo usage --days 7 # usage summary
21
72
  sonilo text-to-music --prompt "warm lo-fi piano, rain" --duration 30
@@ -28,6 +79,9 @@ or pass `--api-key sk-...` on any command.
28
79
  sonilo video-to-video-music --video clip.mp4 --prompt "tense synths" --output scored.mp4
29
80
  sonilo video-to-video-sfx --video clip.mp4 --segments @segments.json --output scored.mp4
30
81
  sonilo video-to-video-sound --video clip.mp4 --music-prompt "tense synths"
82
+ sonilo audio-ducking --voice interview.mp4 --music-url https://example.com/bed.wav
83
+ # ducks the existing music bed under the voice; a video voice comes back
84
+ # as a new .mp4 with the ducked mix muxed in
31
85
  sonilo dubbing --video-url https://example.com/clip.mp4 --languages es,fr --output dubbed.mp4
32
86
  # writes dubbed.es.mp4 and dubbed.fr.mp4
33
87
  sonilo tasks get <task-id>
@@ -153,6 +207,24 @@ they differ only in what comes back: `video-to-sound` writes the mixed **audio**
153
207
  or ducking altered the music bed.
154
208
  - Both also take `--variants` — see [Variants](#variants) above.
155
209
 
210
+ ### Audio ducking
211
+
212
+ `audio-ducking` mixes an **existing** music bed under an **existing** voice track — nothing is
213
+ generated, so reach for it when the music is fixed or external. (When the music is being generated
214
+ for the same clip anyway, `video-to-sound` or `video-to-music` duck internally as part of that one
215
+ call instead.)
216
+
217
+ sonilo audio-ducking --voice interview.mp4 --music-url https://example.com/bed.wav
218
+
219
+ - Exactly one of `--voice` / `--voice-url` and one of `--music` / `--music-url`; a local file and a
220
+ URL mix freely across the two inputs.
221
+ - The **voice** may be audio or video: a video's own audio track becomes the voice, and the ducked
222
+ mix is muxed back into a new video, so the result is a `.mp4` instead of a `.wav`. The default
223
+ `--output` name follows what came back (`output.wav` or `output.mp4`).
224
+ - The **music** must be audio (`wav, mp3, m4a, aac, ogg, flac`). The API does not detect a video
225
+ there, so the CLI rejects a local video file up front rather than let it be mishandled silently.
226
+ - Each input is capped at 360 seconds server-side.
227
+
156
228
  ### Dubbing
157
229
 
158
230
  `dubbing` dubs a video into one or more target languages in a single async call:
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sonilo-cli"
7
- version = "0.10.0"
7
+ version = "0.12.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.12.0,<0.13"]
13
+ dependencies = ["sonilo>=0.13.0,<0.14"]
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.10.0"
1
+ __version__ = "0.12.0"
2
2
 
3
3
  __all__ = ["__version__"]
@@ -12,7 +12,8 @@ from urllib.parse import urlparse
12
12
  from sonilo import Sonilo
13
13
  from sonilo.errors import APIError, SoniloError
14
14
 
15
- from sonilo_cli import __version__
15
+ from sonilo_cli import __version__, credentials
16
+ from sonilo_cli.login import LoginError, cmd_login, cmd_logout, cmd_whoami
16
17
 
17
18
 
18
19
  class _Parser(argparse.ArgumentParser):
@@ -196,12 +197,34 @@ def _segments(args: argparse.Namespace) -> Optional[List[Dict[str, Any]]]:
196
197
  return parse_segments(args.segments, args.segments_shape, args.command)
197
198
 
198
199
 
199
- def build_client(api_key: Optional[str]) -> Sonilo:
200
+ def _expired(iso: str) -> bool:
201
+ """Whether a stored credential's expiry has passed.
202
+
203
+ Reuses login's parser so both paths agree on what "expired" means, and on
204
+ treating an unparseable timestamp as *not* expired — the API is the
205
+ authority on whether a key works, and refusing to run over a formatting
206
+ quirk would be worse than letting a dead key earn its own 401.
207
+ """
208
+ from sonilo_cli.login import _is_expired
209
+
210
+ return _is_expired(iso)
211
+
212
+
213
+ def build_client(api_key: Optional[str], path: Optional[Path] = None) -> Sonilo:
214
+ api_base = os.environ.get("SONILO_API_URL", credentials.DEFAULT_API_BASE).rstrip("/")
215
+ # Order is a compatibility promise: an exported SONILO_API_KEY keeps winning
216
+ # over a stored credential, so upgrading never moves someone's account.
200
217
  key = api_key or os.environ.get("SONILO_API_KEY")
218
+ if not key:
219
+ stored = credentials.read_credential(api_base, path)
220
+ if stored is not None:
221
+ if _expired(stored.get("expires_at", "")):
222
+ _fail('your sonilo login expired — run "sonilo login" again')
223
+ key = stored["api_key"]
201
224
  if not key:
202
225
  _fail(
203
- "no API key — pass --api-key <key> or set the "
204
- "SONILO_API_KEY environment variable"
226
+ 'no API key — run "sonilo login", or pass --api-key <key>, or set '
227
+ "the SONILO_API_KEY environment variable"
205
228
  )
206
229
  # Identify as the CLI rather than inheriting the SDK's own name, so CLI
207
230
  # traffic stays separable from direct SDK use in server-side analytics.
@@ -451,6 +474,42 @@ def cmd_video_to_video_sfx(client: Sonilo, args: argparse.Namespace) -> None:
451
474
  )
452
475
 
453
476
 
477
+ # The music-bed extensions audio-ducking accepts for a local --music file —
478
+ # the MCP server's _AUDIO_EXTS, kept identical so the two surfaces accept and
479
+ # reject the same files. A whitelist (not a video-extension blacklist) because
480
+ # the failure it guards against is silent: the API never probes the music
481
+ # input for a video stream, so a video sent there is mishandled without an
482
+ # error. The voice input needs no such guard — it may legitimately be audio
483
+ # or video, and the API probes it.
484
+ _MUSIC_AUDIO_EXTS = (".wav", ".mp3", ".m4a", ".aac", ".ogg", ".flac")
485
+
486
+
487
+ def cmd_audio_ducking(client: Sonilo, args: argparse.Namespace) -> None:
488
+ if args.music is not None and Path(args.music).suffix.lower() not in _MUSIC_AUDIO_EXTS:
489
+ _fail(
490
+ f"--music must be an audio file ({', '.join(_MUSIC_AUDIO_EXTS)}) — "
491
+ "the API does not detect a video here and would mishandle it. "
492
+ "The voice input is the one that may be a video."
493
+ )
494
+ result = client.audio_ducking.generate(
495
+ voice=args.voice,
496
+ voice_url=args.voice_url,
497
+ music=args.music,
498
+ music_url=args.music_url,
499
+ )
500
+ # Default output name follows what actually came back: a .wav, or a .mp4
501
+ # (ducked mix re-muxed in) when the voice input was a video. An explicit
502
+ # --output is used verbatim, same as the video-out commands.
503
+ out = args.output
504
+ if out is None:
505
+ ext = Path(urlparse(result.output_url or "").path).suffix
506
+ if not ext:
507
+ ext = ".mp4" if result.output_type == "video" else ".wav"
508
+ out = f"output{ext}"
509
+ path = result.save(out)
510
+ _wrote(path, path.stat().st_size)
511
+
512
+
454
513
  # Matched to the dubbing backend's own ceiling: it polls its pipeline for up
455
514
  # to 7200s (2 hours), so anything shorter abandons a job the user has already
456
515
  # been charged for. The SDK's generic DEFAULT_WAIT_TIMEOUT of 600s is far too
@@ -577,6 +636,33 @@ def build_parser() -> argparse.ArgumentParser:
577
636
  _add_global(parser)
578
637
  sub = parser.add_subparsers(dest="command", metavar="<command>")
579
638
 
639
+ # login/logout/whoami run *before* a client exists — build_client exits when
640
+ # no key is available, which is the exact situation login is for. The
641
+ # needs_client=False flag is what main() checks to skip that.
642
+ p_login = sub.add_parser("login", help="Sign in and store an API key for future commands")
643
+ _add_global(p_login)
644
+ p_login.add_argument("--force", action="store_true",
645
+ help="Sign in again even if already signed in, replacing the stored key.")
646
+ p_login.add_argument("--no-browser", action="store_true", dest="no_browser",
647
+ help="Print the URL instead of opening a browser.")
648
+ p_login.add_argument("--api-base", dest="api_base", default=None,
649
+ help="Sign in against a non-default API base URL.")
650
+ p_login.set_defaults(func=lambda client, args: cmd_login(args), needs_client=False)
651
+
652
+ p_logout = sub.add_parser("logout", help="Revoke the stored key and forget it locally")
653
+ _add_global(p_logout)
654
+ p_logout.add_argument("--local-only", action="store_true", dest="local_only",
655
+ help="Forget the credential without revoking the key server-side.")
656
+ p_logout.add_argument("--api-base", dest="api_base", default=None,
657
+ help="Act on the credential for a non-default API base URL.")
658
+ p_logout.set_defaults(func=lambda client, args: cmd_logout(args), needs_client=False)
659
+
660
+ p_whoami = sub.add_parser("whoami", help="Show which account and key are currently active")
661
+ _add_global(p_whoami)
662
+ p_whoami.add_argument("--api-base", dest="api_base", default=None,
663
+ help="Inspect the credential for a non-default API base URL.")
664
+ p_whoami.set_defaults(func=lambda client, args: cmd_whoami(args), needs_client=False)
665
+
580
666
  p_account = sub.add_parser("account", help="Show plan limits and available services")
581
667
  _add_global(p_account)
582
668
  p_account.set_defaults(func=cmd_account)
@@ -740,6 +826,38 @@ def build_parser() -> argparse.ArgumentParser:
740
826
  _add_variants(p_v2vsd)
741
827
  p_v2vsd.set_defaults(func=cmd_video_to_video_sound)
742
828
 
829
+ p_duck = sub.add_parser(
830
+ "audio-ducking", help="Duck an existing music bed under a voice track"
831
+ )
832
+ _add_global(p_duck)
833
+ voice_group = p_duck.add_mutually_exclusive_group(required=True)
834
+ voice_group.add_argument(
835
+ "--voice", default=None,
836
+ help="Local voice track. May be audio or video: a video's own audio "
837
+ "track becomes the voice, and the ducked mix is muxed back into "
838
+ "a new video.",
839
+ )
840
+ voice_group.add_argument(
841
+ "--voice-url", dest="voice_url", default=None,
842
+ help="Remote voice audio/video URL.",
843
+ )
844
+ music_group = p_duck.add_mutually_exclusive_group(required=True)
845
+ music_group.add_argument(
846
+ "--music", default=None,
847
+ help="Local music bed. Audio only (wav, mp3, m4a, aac, ogg, flac) — "
848
+ "the API does not detect a video here, so the CLI rejects one.",
849
+ )
850
+ music_group.add_argument(
851
+ "--music-url", dest="music_url", default=None,
852
+ help="Remote music audio URL.",
853
+ )
854
+ p_duck.add_argument(
855
+ "--output", default=None,
856
+ help="Where to save the result. Default: output.wav, or output.mp4 "
857
+ "when the voice input was a video.",
858
+ )
859
+ p_duck.set_defaults(func=cmd_audio_ducking)
860
+
743
861
  p_dub = sub.add_parser("dubbing", help="Dub a video into other languages")
744
862
  _add_global(p_dub)
745
863
  _add_video_source(p_dub)
@@ -789,6 +907,14 @@ def main(argv: Optional[List[str]] = None) -> None:
789
907
  func = getattr(args, "func", None)
790
908
  if func is None:
791
909
  parser.error("missing command (try `sonilo --help`)")
910
+ if getattr(args, "needs_client", True) is False:
911
+ try:
912
+ func(None, args) # type: ignore[arg-type]
913
+ except LoginError as exc:
914
+ # Expected outcomes — denied, expired, throttled, offline — read as
915
+ # `sonilo: <message>`, never as a traceback.
916
+ _fail(str(exc))
917
+ return
792
918
  client = build_client(getattr(args, "api_key", None))
793
919
  try:
794
920
  func(client, args)
@@ -0,0 +1,105 @@
1
+ """The credential file written by `sonilo login`.
2
+
3
+ Format and location are shared with the JS CLI (`packages/cli/src/credentials.ts`
4
+ in sonilo-js) and read by sonilo-mcp, so the path, the JSON shape and the
5
+ 0600/0700 modes are a cross-repo contract — change them in all three or not at
6
+ all.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from pathlib import Path
13
+ from typing import Any, Dict, Optional
14
+
15
+ DEFAULT_API_BASE = "https://api.sonilo.com"
16
+ FORMAT_VERSION = 1
17
+
18
+
19
+ class CredentialFormatError(Exception):
20
+ """The file was written by a newer client than this one understands."""
21
+
22
+
23
+ def credentials_path() -> Path:
24
+ base = os.environ.get("XDG_CONFIG_HOME")
25
+ root = Path(base) if base else Path(os.environ.get("HOME", str(Path.home()))) / ".config"
26
+ return root / "sonilo" / "credentials.json"
27
+
28
+
29
+ def _empty() -> Dict[str, Any]:
30
+ return {"version": FORMAT_VERSION, "credentials": {}}
31
+
32
+
33
+ def _load(path: Path) -> Dict[str, Any]:
34
+ try:
35
+ raw = path.read_text(encoding="utf-8")
36
+ except OSError:
37
+ return _empty()
38
+ try:
39
+ parsed = json.loads(raw)
40
+ except ValueError:
41
+ # Corrupt file reads as "no credential"; `login` will replace it.
42
+ return _empty()
43
+ if not isinstance(parsed, dict):
44
+ return _empty()
45
+ version = parsed.get("version")
46
+ if isinstance(version, int) and version > FORMAT_VERSION:
47
+ raise CredentialFormatError(
48
+ f"{path} was written by a newer sonilo CLI (format {version}). "
49
+ "Upgrade the CLI or delete the file."
50
+ )
51
+ creds = parsed.get("credentials")
52
+ return {
53
+ "version": FORMAT_VERSION,
54
+ "credentials": creds if isinstance(creds, dict) else {},
55
+ }
56
+
57
+
58
+ def _save(path: Path, data: Dict[str, Any]) -> None:
59
+ path.parent.mkdir(parents=True, exist_ok=True)
60
+ os.chmod(path.parent, 0o700)
61
+ tmp = path.with_name(path.name + ".{}.tmp".format(os.getpid()))
62
+ tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
63
+ os.chmod(tmp, 0o600)
64
+ try:
65
+ # Rename last: an interrupted write can never replace a whole
66
+ # credential with half of one.
67
+ os.replace(tmp, path)
68
+ except OSError:
69
+ # Never leave the temp file behind holding a live key.
70
+ try:
71
+ tmp.unlink()
72
+ except OSError:
73
+ pass
74
+ raise
75
+
76
+
77
+ def read_credential(api_base: str, path: Optional[Path] = None) -> Optional[Dict[str, Any]]:
78
+ entry = _load(path or credentials_path())["credentials"].get(api_base)
79
+ if isinstance(entry, dict) and isinstance(entry.get("api_key"), str) and entry["api_key"]:
80
+ return entry
81
+ return None
82
+
83
+
84
+ def write_credential(
85
+ api_base: str, cred: Dict[str, Any], path: Optional[Path] = None
86
+ ) -> None:
87
+ target = path or credentials_path()
88
+ data = _load(target)
89
+ data["credentials"][api_base] = cred
90
+ _save(target, data)
91
+
92
+
93
+ def remove_credential(api_base: str, path: Optional[Path] = None) -> None:
94
+ target = path or credentials_path()
95
+ data = _load(target)
96
+ if api_base not in data["credentials"]:
97
+ return
98
+ del data["credentials"][api_base]
99
+ if not data["credentials"]:
100
+ try:
101
+ target.unlink()
102
+ except OSError:
103
+ pass
104
+ return
105
+ _save(target, data)