bladekit 4.5.0__py3-none-any.whl

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.
bladekit/config.py ADDED
@@ -0,0 +1,181 @@
1
+ """Client configuration resolution.
2
+
3
+ Precedence (task requirement 5): ``FABRIC_API_URL`` / ``FABRIC_TOKEN`` environment
4
+ variables (or their legacy ``FORGE_*`` spelling) win over ``~/.blade/config.toml``.
5
+
6
+ The TOML file is written by ``blade token set`` and read by ``blade token show``
7
+ and every client. Kept dependency-light: stdlib ``tomllib`` on 3.11+, ``tomli``
8
+ fallback on 3.10, and a tiny hand-rolled writer (we never need to serialize
9
+ arbitrary TOML — only ``api_url`` and ``token``).
10
+
11
+ Migration (PAAS-771): the file used to live at ``~/.forge/config.toml``. When the
12
+ new file is absent and the legacy one exists, the legacy file is copied to the new
13
+ location on first read and left in place, so an older ``forge`` CLI installed side
14
+ by side keeps working.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import shutil
21
+ import sys
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+ from typing import Optional
25
+
26
+ from . import _env
27
+ from ._constants import DEFAULT_API_URL
28
+
29
+ if sys.version_info >= (3, 11):
30
+ import tomllib as _toml
31
+ else: # pragma: no cover - exercised only on 3.10
32
+ import tomli as _toml # type: ignore[no-redef]
33
+
34
+
35
+ def default_config_path() -> Path:
36
+ """``~/.blade/config.toml`` — where the CLI keeps its token."""
37
+ return Path.home() / ".blade" / "config.toml"
38
+
39
+
40
+ def legacy_config_path() -> Path:
41
+ """``~/.forge/config.toml`` — where the ``forge`` CLI kept it (pre-PAAS-771)."""
42
+ return Path.home() / ".forge" / "config.toml"
43
+
44
+
45
+ def config_path() -> Path:
46
+ """Location of the config file (override with ``FABRIC_CONFIG`` for tests)."""
47
+ override = _env.get("CONFIG")
48
+ if override:
49
+ return Path(override)
50
+ return default_config_path()
51
+
52
+
53
+ def migrate_legacy_config() -> Optional[Path]:
54
+ """Copy ``~/.forge/config.toml`` to ``~/.blade/config.toml`` if only the former exists.
55
+
56
+ Returns the path that now holds the configuration (the new one after a
57
+ successful copy, the legacy one if the copy failed — e.g. a read-only home —
58
+ so the caller can still read it), or None when there is nothing to migrate.
59
+ The legacy file is never modified or removed. Skipped when ``FABRIC_CONFIG``
60
+ / ``FORGE_CONFIG`` points somewhere explicit.
61
+ """
62
+ if _env.get("CONFIG"):
63
+ return None
64
+ new, old = default_config_path(), legacy_config_path()
65
+ if new.exists() or not old.is_file():
66
+ return None
67
+ try:
68
+ # Same private permissions write_config() uses: the file holds a token.
69
+ new.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
70
+ shutil.copyfile(old, new)
71
+ new.chmod(0o600)
72
+ except OSError:
73
+ return old
74
+ return new
75
+
76
+ @dataclass
77
+ class Config:
78
+ api_url: str
79
+ token: Optional[str]
80
+ # Where each value came from, for ``blade token show``.
81
+ api_url_source: str = "default"
82
+ token_source: str = "none"
83
+
84
+
85
+ def _read_file() -> dict:
86
+ path = config_path()
87
+ if not path.exists():
88
+ migrated = migrate_legacy_config()
89
+ if migrated is not None:
90
+ path = migrated
91
+ if not path.exists():
92
+ return {}
93
+ try:
94
+ with path.open("rb") as fh:
95
+ return _toml.load(fh)
96
+ except (OSError, _toml.TOMLDecodeError):
97
+ return {}
98
+
99
+
100
+ def load_config() -> Config:
101
+ """Resolve effective config from env then file then defaults."""
102
+ file_data = _read_file()
103
+
104
+ env_url, env_url_var = _env.lookup("API_URL")
105
+ if env_url:
106
+ api_url, url_src = env_url, f"env ({env_url_var})"
107
+ elif file_data.get("api_url"):
108
+ api_url, url_src = str(file_data["api_url"]), f"file ({config_path()})"
109
+ else:
110
+ api_url, url_src = DEFAULT_API_URL, "default"
111
+
112
+ env_token, env_token_var = _env.lookup("TOKEN")
113
+ if env_token:
114
+ token, tok_src = env_token, f"env ({env_token_var})"
115
+ elif file_data.get("token"):
116
+ token, tok_src = str(file_data["token"]), f"file ({config_path()})"
117
+ else:
118
+ # In-container fallback: a function calling the AI Fabric API (Sandbox.create
119
+ # without secrets, Queue/Dict items) authenticates with the run's
120
+ # ephemeral runner token, obtained from the runner's in-process context —
121
+ # NOT from os.environ, which the runner scrubs before importing user code
122
+ # (PAAS-250). The server accepts that token as the run's owner only on
123
+ # the runner-token allowlist (control plane runnerTokenRoutes: no
124
+ # secrets in sandboxes, no volumes, no org-wide listings) and only while
125
+ # the run is alive. The env form is honoured for pre-1.4 runners.
126
+ from ._runner_api import runner_credentials
127
+
128
+ creds = runner_credentials()
129
+ if creds is not None:
130
+ token, tok_src = creds.token, creds.source
131
+ else:
132
+ token, tok_src = None, "none"
133
+
134
+ return Config(
135
+ api_url=api_url.rstrip("/"),
136
+ token=token,
137
+ api_url_source=url_src,
138
+ token_source=tok_src,
139
+ )
140
+
141
+
142
+ def _toml_escape(value: str) -> str:
143
+ return value.replace("\\", "\\\\").replace('"', '\\"')
144
+
145
+
146
+ def write_config(*, token: Optional[str] = None, api_url: Optional[str] = None) -> Path:
147
+ """Persist token and/or api_url to the config file (merging with existing)."""
148
+ data = _read_file()
149
+ if token is not None:
150
+ data["token"] = token
151
+ if api_url is not None:
152
+ data["api_url"] = api_url.rstrip("/")
153
+
154
+ path = config_path()
155
+ # The file holds a secret token: create both the dir and the file with private
156
+ # perms *from the start* (no world/group-readable window between create and
157
+ # chmod). mkdir(mode=0o700) + os.open(O_CREAT, 0o600) — the mode only applies on
158
+ # creation, so chmod afterwards keeps perms tight if the file already existed.
159
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
160
+ lines = ["# Blade AI Fabric CLI configuration — written by `blade token set`.", ""]
161
+ if "api_url" in data:
162
+ lines.append(f'api_url = "{_toml_escape(str(data["api_url"]))}"')
163
+ if "token" in data:
164
+ lines.append(f'token = "{_toml_escape(str(data["token"]))}"')
165
+ lines.append("")
166
+ payload = "\n".join(lines).encode("utf-8")
167
+
168
+ try:
169
+ fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
170
+ except OSError: # pragma: no cover - non-POSIX fallback
171
+ path.write_text("\n".join(lines), encoding="utf-8")
172
+ else:
173
+ with os.fdopen(fd, "wb") as fh:
174
+ fh.write(payload)
175
+ # Re-assert 0600 in case the file pre-existed with looser perms (O_CREAT mode
176
+ # is ignored for an existing file).
177
+ try:
178
+ path.chmod(0o600)
179
+ except OSError: # pragma: no cover - non-POSIX
180
+ pass
181
+ return path