sonilo-cli 0.9.0__tar.gz → 0.11.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.9.0
3
+ Version: 0.11.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.12,>=0.11.0
11
+ Requires-Dist: sonilo<0.13,>=0.12.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
@@ -106,6 +157,17 @@ request — and values above 1 are never covered by the free trial.
106
157
  - On `video-to-sound` / `video-to-video-sound`, `--stem` is applied per variant too, e.g.
107
158
  `take.0.music.m4a`.
108
159
 
160
+ ### Prompt influence
161
+
162
+ `--prompt-influence` (0-1, API default 0.5) sets how strongly the generated music follows the
163
+ prompt, on `video-to-music` and `video-to-video-music` only. Lower values let the video lead;
164
+ higher values follow the prompt more literally. It is free of charge, and unlike `--format wav` it
165
+ does not force the async path — it works on the streaming default too. Left unset, the field is
166
+ not sent at all and the API's own 0.5 default applies; `--prompt-influence 0` is a real value
167
+ ("let the video lead entirely") and is sent. Out-of-range values earn a `422` from the API.
168
+
169
+ sonilo video-to-music --video clip.mp4 --prompt "tense synths" --prompt-influence 0.8
170
+
109
171
  ### Scored video
110
172
 
111
173
  `video-to-video-music` and `video-to-video-sfx` are the video-out counterparts of `video-to-music`
@@ -128,6 +190,8 @@ file (default `output.mp4`):
128
190
  - For music *and* effects in one call, use `video-to-video-sound` below.
129
191
  - `video-to-video-music` also takes `--variants` — see [Variants](#variants) above.
130
192
  `video-to-video-sfx` does not.
193
+ - `video-to-video-music` also takes `--prompt-influence` — see
194
+ [Prompt influence](#prompt-influence) above. `video-to-video-sfx` does not.
131
195
 
132
196
  ### Combined soundtracks
133
197
 
@@ -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
@@ -90,6 +141,17 @@ request — and values above 1 are never covered by the free trial.
90
141
  - On `video-to-sound` / `video-to-video-sound`, `--stem` is applied per variant too, e.g.
91
142
  `take.0.music.m4a`.
92
143
 
144
+ ### Prompt influence
145
+
146
+ `--prompt-influence` (0-1, API default 0.5) sets how strongly the generated music follows the
147
+ prompt, on `video-to-music` and `video-to-video-music` only. Lower values let the video lead;
148
+ higher values follow the prompt more literally. It is free of charge, and unlike `--format wav` it
149
+ does not force the async path — it works on the streaming default too. Left unset, the field is
150
+ not sent at all and the API's own 0.5 default applies; `--prompt-influence 0` is a real value
151
+ ("let the video lead entirely") and is sent. Out-of-range values earn a `422` from the API.
152
+
153
+ sonilo video-to-music --video clip.mp4 --prompt "tense synths" --prompt-influence 0.8
154
+
93
155
  ### Scored video
94
156
 
95
157
  `video-to-video-music` and `video-to-video-sfx` are the video-out counterparts of `video-to-music`
@@ -112,6 +174,8 @@ file (default `output.mp4`):
112
174
  - For music *and* effects in one call, use `video-to-video-sound` below.
113
175
  - `video-to-video-music` also takes `--variants` — see [Variants](#variants) above.
114
176
  `video-to-video-sfx` does not.
177
+ - `video-to-video-music` also takes `--prompt-influence` — see
178
+ [Prompt influence](#prompt-influence) above. `video-to-video-sfx` does not.
115
179
 
116
180
  ### Combined soundtracks
117
181
 
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sonilo-cli"
7
- version = "0.9.0"
7
+ version = "0.11.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.11.0,<0.12"]
13
+ dependencies = ["sonilo>=0.12.0,<0.13"]
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.9.0"
1
+ __version__ = "0.11.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.
@@ -309,12 +332,15 @@ def cmd_video_to_music(client: Sonilo, args: argparse.Namespace) -> None:
309
332
  preserve_speech=args.preserve_speech or None,
310
333
  output_format=fmt if fmt != "m4a" else None,
311
334
  variants_num=args.variants,
335
+ prompt_influence=args.prompt_influence,
312
336
  )
313
337
  _save_music_variants(result, out)
314
338
  else:
339
+ # prompt_influence rides the streaming path too — it is a generation
340
+ # parameter, not a finalize-time one, so it never forces async.
315
341
  track = client.video_to_music.generate(
316
342
  video=args.video, video_url=args.video_url, prompt=args.prompt,
317
- segments=segments,
343
+ segments=segments, prompt_influence=args.prompt_influence,
318
344
  )
319
345
  path = track.save(out)
320
346
  _wrote(path, len(track.audio))
@@ -435,6 +461,7 @@ def cmd_video_to_video_music(client: Sonilo, args: argparse.Namespace) -> None:
435
461
  preserve_speech=True if args.preserve_speech else None,
436
462
  isolate_vocals=True if args.isolate_vocals else None,
437
463
  variants_num=args.variants,
464
+ prompt_influence=args.prompt_influence,
438
465
  )
439
466
 
440
467
 
@@ -543,6 +570,19 @@ def _add_segments(parser: argparse.ArgumentParser, shape: _SegmentShape) -> None
543
570
  parser.set_defaults(segments_shape=shape)
544
571
 
545
572
 
573
+ def _add_prompt_influence(parser: argparse.ArgumentParser) -> None:
574
+ # Only the two music-from-video commands take this — the API accepts it
575
+ # nowhere else. type=float so 0 arrives as 0.0, a real value ("let the
576
+ # video lead entirely"), distinct from the unset None that keeps the
577
+ # field off the wire and leaves the API its own 0.5 default.
578
+ parser.add_argument(
579
+ "--prompt-influence", dest="prompt_influence", type=float, default=None,
580
+ help="How strongly the generated music follows the prompt, 0-1 "
581
+ "(API default 0.5). Lower values let the video lead; higher "
582
+ "values follow the prompt more literally. Free of charge.",
583
+ )
584
+
585
+
546
586
  def _add_variants(parser: argparse.ArgumentParser) -> None:
547
587
  parser.add_argument(
548
588
  "--variants", type=int, default=None,
@@ -560,6 +600,33 @@ def build_parser() -> argparse.ArgumentParser:
560
600
  _add_global(parser)
561
601
  sub = parser.add_subparsers(dest="command", metavar="<command>")
562
602
 
603
+ # login/logout/whoami run *before* a client exists — build_client exits when
604
+ # no key is available, which is the exact situation login is for. The
605
+ # needs_client=False flag is what main() checks to skip that.
606
+ p_login = sub.add_parser("login", help="Sign in and store an API key for future commands")
607
+ _add_global(p_login)
608
+ p_login.add_argument("--force", action="store_true",
609
+ help="Sign in again even if already signed in, replacing the stored key.")
610
+ p_login.add_argument("--no-browser", action="store_true", dest="no_browser",
611
+ help="Print the URL instead of opening a browser.")
612
+ p_login.add_argument("--api-base", dest="api_base", default=None,
613
+ help="Sign in against a non-default API base URL.")
614
+ p_login.set_defaults(func=lambda client, args: cmd_login(args), needs_client=False)
615
+
616
+ p_logout = sub.add_parser("logout", help="Revoke the stored key and forget it locally")
617
+ _add_global(p_logout)
618
+ p_logout.add_argument("--local-only", action="store_true", dest="local_only",
619
+ help="Forget the credential without revoking the key server-side.")
620
+ p_logout.add_argument("--api-base", dest="api_base", default=None,
621
+ help="Act on the credential for a non-default API base URL.")
622
+ p_logout.set_defaults(func=lambda client, args: cmd_logout(args), needs_client=False)
623
+
624
+ p_whoami = sub.add_parser("whoami", help="Show which account and key are currently active")
625
+ _add_global(p_whoami)
626
+ p_whoami.add_argument("--api-base", dest="api_base", default=None,
627
+ help="Inspect the credential for a non-default API base URL.")
628
+ p_whoami.set_defaults(func=lambda client, args: cmd_whoami(args), needs_client=False)
629
+
563
630
  p_account = sub.add_parser("account", help="Show plan limits and available services")
564
631
  _add_global(p_account)
565
632
  p_account.set_defaults(func=cmd_account)
@@ -601,6 +668,7 @@ def build_parser() -> argparse.ArgumentParser:
601
668
  p_v2m.add_argument("--async", dest="use_async", action="store_true",
602
669
  help="Submit and poll instead of streaming.")
603
670
  _add_variants(p_v2m)
671
+ _add_prompt_influence(p_v2m)
604
672
  p_v2m.set_defaults(func=cmd_video_to_music)
605
673
 
606
674
  p_t2s = sub.add_parser("text-to-sfx", help="Generate a sound effect from a text prompt")
@@ -677,6 +745,7 @@ def build_parser() -> argparse.ArgumentParser:
677
745
  help="Legacy alias for --preserve-speech; no separate stem.")
678
746
  p_v2vm.add_argument("--output", default=None, help="Where to save the scored video.")
679
747
  _add_variants(p_v2vm)
748
+ _add_prompt_influence(p_v2vm)
680
749
  p_v2vm.set_defaults(func=cmd_video_to_video_music)
681
750
 
682
751
  p_v2vfx = sub.add_parser(
@@ -770,6 +839,14 @@ def main(argv: Optional[List[str]] = None) -> None:
770
839
  func = getattr(args, "func", None)
771
840
  if func is None:
772
841
  parser.error("missing command (try `sonilo --help`)")
842
+ if getattr(args, "needs_client", True) is False:
843
+ try:
844
+ func(None, args) # type: ignore[arg-type]
845
+ except LoginError as exc:
846
+ # Expected outcomes — denied, expired, throttled, offline — read as
847
+ # `sonilo: <message>`, never as a traceback.
848
+ _fail(str(exc))
849
+ return
773
850
  client = build_client(getattr(args, "api_key", None))
774
851
  try:
775
852
  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)