base-cli 0.1.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.
- base_cli/__init__.py +86 -0
- base_cli/_runtime.py +89 -0
- base_cli/app.py +483 -0
- base_cli/command_filters.py +37 -0
- base_cli/command_protocol.py +263 -0
- base_cli/config.py +270 -0
- base_cli/context.py +103 -0
- base_cli/exit_codes.py +9 -0
- base_cli/history.py +426 -0
- base_cli/ide_schema.py +74 -0
- base_cli/inspection.py +45 -0
- base_cli/logging.py +182 -0
- base_cli/output.py +202 -0
- base_cli/paths.py +136 -0
- base_cli/py.typed +0 -0
- base_cli/redaction.py +50 -0
- base_cli/testing.py +59 -0
- base_cli-0.1.0.dist-info/LICENSE +183 -0
- base_cli-0.1.0.dist-info/METADATA +543 -0
- base_cli-0.1.0.dist-info/RECORD +22 -0
- base_cli-0.1.0.dist-info/WHEEL +5 -0
- base_cli-0.1.0.dist-info/top_level.txt +1 -0
base_cli/history.py
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import fcntl as _fcntl
|
|
12
|
+
except ImportError: # pragma: no cover - fcntl is unavailable on Windows.
|
|
13
|
+
_fcntl = None # type: ignore[assignment]
|
|
14
|
+
|
|
15
|
+
from .config import load_yaml_file
|
|
16
|
+
from .context import Context
|
|
17
|
+
from .paths import base_cache_root
|
|
18
|
+
from .redaction import REDACTED, is_secret_key, option_name_to_parameter, redact_argv, redact_text_value
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"HISTORY_PATH",
|
|
23
|
+
"HISTORY_SCOPE_INTERNAL",
|
|
24
|
+
"HISTORY_SCOPE_PRIMARY",
|
|
25
|
+
"SCHEMA_VERSION",
|
|
26
|
+
"base_setup_action",
|
|
27
|
+
"base_version",
|
|
28
|
+
"build_finished_record",
|
|
29
|
+
"compact_home_text",
|
|
30
|
+
"compact_optional_path",
|
|
31
|
+
"compact_path",
|
|
32
|
+
"display_command",
|
|
33
|
+
"duration_ms",
|
|
34
|
+
"format_timestamp",
|
|
35
|
+
"optional_int",
|
|
36
|
+
"optional_string",
|
|
37
|
+
"parse_finished_history_record_line",
|
|
38
|
+
"parse_positive_int",
|
|
39
|
+
"project_name",
|
|
40
|
+
"redact_history_argv",
|
|
41
|
+
"redact_history_text",
|
|
42
|
+
"runtime_bundle_path",
|
|
43
|
+
"utc_now",
|
|
44
|
+
"write_finished_record",
|
|
45
|
+
"write_history_record",
|
|
46
|
+
"write_primary_record",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
SCHEMA_VERSION = 1
|
|
51
|
+
HISTORY_PATH = Path("base") / "history" / "runs.jsonl"
|
|
52
|
+
HISTORY_SCOPE_PRIMARY = "primary"
|
|
53
|
+
HISTORY_SCOPE_INTERNAL = "internal"
|
|
54
|
+
|
|
55
|
+
# Only these names are Base-owned Python entry points. A standalone caller
|
|
56
|
+
# may legitimately choose a name beginning with ``base_`` and should not have
|
|
57
|
+
# that name rewritten by the shared framework.
|
|
58
|
+
_BASE_DISPLAY_COMMANDS = frozenset(
|
|
59
|
+
{
|
|
60
|
+
"base_activate",
|
|
61
|
+
"base_build",
|
|
62
|
+
"base_check",
|
|
63
|
+
"base_ci",
|
|
64
|
+
"base_clean",
|
|
65
|
+
"base_config",
|
|
66
|
+
"base_demo",
|
|
67
|
+
"base_dev",
|
|
68
|
+
"base_devcontainer",
|
|
69
|
+
"base_devenv",
|
|
70
|
+
"base_devenv_report",
|
|
71
|
+
"base_docs",
|
|
72
|
+
"base_export_context",
|
|
73
|
+
"base_gh",
|
|
74
|
+
"base_github_projects",
|
|
75
|
+
"base_history",
|
|
76
|
+
"base_logs",
|
|
77
|
+
"base_onboard",
|
|
78
|
+
"base_pr_policy",
|
|
79
|
+
"base_projects",
|
|
80
|
+
"base_prompt",
|
|
81
|
+
"base_release",
|
|
82
|
+
"base_repo",
|
|
83
|
+
"base_run",
|
|
84
|
+
"base_setup",
|
|
85
|
+
"base_test",
|
|
86
|
+
"base_trust",
|
|
87
|
+
"base_update",
|
|
88
|
+
"base_update_profile",
|
|
89
|
+
"base_workspace",
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def utc_now() -> datetime:
|
|
95
|
+
return datetime.now(timezone.utc)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def write_finished_record(
|
|
99
|
+
context: Context,
|
|
100
|
+
argv: list[str],
|
|
101
|
+
sensitive_options: set[str],
|
|
102
|
+
started_at: datetime,
|
|
103
|
+
exit_code: int,
|
|
104
|
+
) -> None:
|
|
105
|
+
# Base-dispatched child commands share the parent's run bundle and
|
|
106
|
+
# diagnostic stream. Their completion is an implementation detail, so
|
|
107
|
+
# keep history at the public-invocation level as well.
|
|
108
|
+
if context.dry_run or context.log_file is None or context.history_scope == HISTORY_SCOPE_INTERNAL:
|
|
109
|
+
return
|
|
110
|
+
try:
|
|
111
|
+
record = build_finished_record(context, argv, sensitive_options, started_at, exit_code)
|
|
112
|
+
write_history_record(record)
|
|
113
|
+
if context.run_root is not None:
|
|
114
|
+
update_run_metadata(context.run_root, record)
|
|
115
|
+
except Exception as exc: # pylint: disable=broad-exception-caught
|
|
116
|
+
context.log.debug("Unable to write command history record: %s", exc)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def build_finished_record(
|
|
120
|
+
context: Context,
|
|
121
|
+
argv: list[str],
|
|
122
|
+
sensitive_options: set[str],
|
|
123
|
+
started_at: datetime,
|
|
124
|
+
exit_code: int,
|
|
125
|
+
) -> dict[str, Any]:
|
|
126
|
+
ended_at = utc_now()
|
|
127
|
+
record: dict[str, Any] = {
|
|
128
|
+
"schema_version": SCHEMA_VERSION,
|
|
129
|
+
"run_id": context.run_id,
|
|
130
|
+
"event": "finished",
|
|
131
|
+
"command": display_command(context.cli_name, argv),
|
|
132
|
+
"raw_command": context.cli_name,
|
|
133
|
+
"argv": redact_history_argv(argv, sensitive_options),
|
|
134
|
+
"started_at": format_timestamp(started_at),
|
|
135
|
+
"ended_at": format_timestamp(ended_at),
|
|
136
|
+
"duration_ms": duration_ms(started_at, ended_at),
|
|
137
|
+
"exit_code": exit_code,
|
|
138
|
+
"status": "ok" if exit_code == 0 else "error",
|
|
139
|
+
"log_path": compact_path(context.log_file),
|
|
140
|
+
"owner": context.runtime_owner,
|
|
141
|
+
"bundle_path": compact_path(context.run_root or context.state_dir),
|
|
142
|
+
"os": normalized_os(),
|
|
143
|
+
}
|
|
144
|
+
optional_fields = {
|
|
145
|
+
"project": project_name(context),
|
|
146
|
+
"project_root": compact_optional_path(context.project_root),
|
|
147
|
+
"manifest": compact_optional_path(context.manifest_path),
|
|
148
|
+
"workspace_root": compact_optional_path(context.workspace_root),
|
|
149
|
+
"base_version": base_version(context.base_home),
|
|
150
|
+
"shell": os.environ.get("SHELL"),
|
|
151
|
+
"scope": context.history_scope,
|
|
152
|
+
"parent_run_id": context.history_parent_run_id,
|
|
153
|
+
}
|
|
154
|
+
record.update({key: value for key, value in optional_fields.items() if value})
|
|
155
|
+
return record
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
159
|
+
def write_primary_record(
|
|
160
|
+
command: str,
|
|
161
|
+
argv: list[str],
|
|
162
|
+
started_at: datetime,
|
|
163
|
+
exit_code: int,
|
|
164
|
+
run_id: str,
|
|
165
|
+
scope: str = HISTORY_SCOPE_PRIMARY,
|
|
166
|
+
project: str | None = None,
|
|
167
|
+
project_root: str | None = None,
|
|
168
|
+
manifest: str | None = None,
|
|
169
|
+
log_path: str | None = None,
|
|
170
|
+
owner: str = "base",
|
|
171
|
+
bundle_path: str | None = None,
|
|
172
|
+
*,
|
|
173
|
+
raw_command: str = "basectl",
|
|
174
|
+
) -> None:
|
|
175
|
+
"""Write the user-facing record for a Bash-dispatched command."""
|
|
176
|
+
ended_at = utc_now()
|
|
177
|
+
record: dict[str, Any] = {
|
|
178
|
+
"schema_version": SCHEMA_VERSION,
|
|
179
|
+
"run_id": run_id,
|
|
180
|
+
"event": "finished",
|
|
181
|
+
"command": command,
|
|
182
|
+
"raw_command": raw_command,
|
|
183
|
+
"argv": redact_history_argv(argv, sensitive_options=set()),
|
|
184
|
+
"started_at": format_timestamp(started_at),
|
|
185
|
+
"ended_at": format_timestamp(ended_at),
|
|
186
|
+
"duration_ms": duration_ms(started_at, ended_at),
|
|
187
|
+
"exit_code": exit_code,
|
|
188
|
+
"status": "ok" if exit_code == 0 else "error",
|
|
189
|
+
"os": normalized_os(),
|
|
190
|
+
"scope": scope,
|
|
191
|
+
}
|
|
192
|
+
resolved_bundle = Path(bundle_path).expanduser() if bundle_path else runtime_bundle_path()
|
|
193
|
+
resolved_log = Path(log_path).expanduser() if log_path else (
|
|
194
|
+
resolved_bundle / "logs" / "primary.log" if resolved_bundle is not None else None
|
|
195
|
+
)
|
|
196
|
+
optional_fields = {
|
|
197
|
+
"project": project,
|
|
198
|
+
"project_root": compact_optional_path(Path(project_root)) if project_root else None,
|
|
199
|
+
"manifest": compact_optional_path(Path(manifest)) if manifest else None,
|
|
200
|
+
"log_path": compact_optional_path(resolved_log),
|
|
201
|
+
"owner": owner,
|
|
202
|
+
"bundle_path": compact_optional_path(resolved_bundle),
|
|
203
|
+
}
|
|
204
|
+
record.update({key: value for key, value in optional_fields.items() if value})
|
|
205
|
+
write_history_record(record)
|
|
206
|
+
if resolved_bundle is not None:
|
|
207
|
+
update_run_metadata(resolved_bundle, record)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def write_history_record(record: dict[str, Any]) -> None:
|
|
211
|
+
path = base_cache_root() / HISTORY_PATH
|
|
212
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
213
|
+
append_history_line(path, f"{json.dumps(record, sort_keys=True)}\n")
|
|
214
|
+
path.chmod(0o600)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def runtime_bundle_path() -> Path | None:
|
|
218
|
+
value = os.environ.get("BASE_CLI_RUN_ROOT")
|
|
219
|
+
if not value:
|
|
220
|
+
return None
|
|
221
|
+
return Path(value).expanduser().resolve(strict=False)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def update_run_metadata(run_root: Path, record: dict[str, Any]) -> None:
|
|
225
|
+
metadata_path = run_root / "run.json"
|
|
226
|
+
metadata: dict[str, Any] = {}
|
|
227
|
+
try:
|
|
228
|
+
if metadata_path.is_file():
|
|
229
|
+
loaded = json.loads(metadata_path.read_text(encoding="utf-8"))
|
|
230
|
+
if isinstance(loaded, dict):
|
|
231
|
+
metadata = loaded
|
|
232
|
+
metadata.update(
|
|
233
|
+
{
|
|
234
|
+
"run_id": record.get("run_id"),
|
|
235
|
+
"owner": record.get("owner", metadata.get("owner", "base")),
|
|
236
|
+
"status": record.get("status"),
|
|
237
|
+
"exit_code": record.get("exit_code"),
|
|
238
|
+
"ended_at": record.get("ended_at"),
|
|
239
|
+
"command": record.get("command"),
|
|
240
|
+
}
|
|
241
|
+
)
|
|
242
|
+
for key in (
|
|
243
|
+
"argv",
|
|
244
|
+
"manifest",
|
|
245
|
+
"parent_run_id",
|
|
246
|
+
"project",
|
|
247
|
+
"project_root",
|
|
248
|
+
"raw_command",
|
|
249
|
+
"scope",
|
|
250
|
+
"workspace_root",
|
|
251
|
+
):
|
|
252
|
+
if key in record and record[key] is not None:
|
|
253
|
+
metadata[key] = record[key]
|
|
254
|
+
metadata_path.parent.mkdir(parents=True, exist_ok=True)
|
|
255
|
+
metadata_path.write_text(json.dumps(metadata, sort_keys=True) + "\n", encoding="utf-8")
|
|
256
|
+
metadata_path.chmod(0o600)
|
|
257
|
+
except (OSError, TypeError, ValueError):
|
|
258
|
+
pass
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def append_history_line(path: Path, line: str) -> None:
|
|
262
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
263
|
+
try:
|
|
264
|
+
lock_history_file(fd)
|
|
265
|
+
try:
|
|
266
|
+
write_all(fd, line.encode("utf-8"))
|
|
267
|
+
finally:
|
|
268
|
+
unlock_history_file(fd)
|
|
269
|
+
finally:
|
|
270
|
+
os.close(fd)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def lock_history_file(fd: int) -> None:
|
|
274
|
+
if _fcntl is not None:
|
|
275
|
+
_fcntl.flock(fd, _fcntl.LOCK_EX)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def unlock_history_file(fd: int) -> None:
|
|
279
|
+
if _fcntl is not None:
|
|
280
|
+
_fcntl.flock(fd, _fcntl.LOCK_UN)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def write_all(fd: int, data: bytes) -> None:
|
|
284
|
+
remaining = data
|
|
285
|
+
while remaining:
|
|
286
|
+
written = os.write(fd, remaining)
|
|
287
|
+
if written == 0:
|
|
288
|
+
raise OSError("history append wrote zero bytes")
|
|
289
|
+
remaining = remaining[written:]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def format_timestamp(value: datetime) -> str:
|
|
293
|
+
normalized = value.astimezone(timezone.utc)
|
|
294
|
+
return normalized.isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def duration_ms(started_at: datetime, ended_at: datetime) -> int:
|
|
298
|
+
return max(0, round((ended_at - started_at).total_seconds() * 1000))
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def display_command(cli_name: str, argv: list[str]) -> str:
|
|
302
|
+
if cli_name == "base_setup":
|
|
303
|
+
return base_setup_action(argv) or "setup"
|
|
304
|
+
if cli_name in _BASE_DISPLAY_COMMANDS:
|
|
305
|
+
return cli_name.removeprefix("base_").replace("_", "-")
|
|
306
|
+
return cli_name.replace("_", "-")
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def parse_positive_int(option: str, value: str) -> int:
|
|
310
|
+
if not value.isdigit():
|
|
311
|
+
raise ValueError(f"Option '{option}' must be a positive integer.")
|
|
312
|
+
amount = int(value)
|
|
313
|
+
if amount <= 0:
|
|
314
|
+
raise ValueError(f"Option '{option}' must be greater than zero.")
|
|
315
|
+
return amount
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def parse_finished_history_record_line(line: str) -> dict[str, Any] | None:
|
|
319
|
+
try:
|
|
320
|
+
payload = json.loads(line)
|
|
321
|
+
except json.JSONDecodeError:
|
|
322
|
+
return None
|
|
323
|
+
if not isinstance(payload, dict) or payload.get("schema_version") != SCHEMA_VERSION:
|
|
324
|
+
return None
|
|
325
|
+
if payload.get("event") != "finished":
|
|
326
|
+
return None
|
|
327
|
+
return payload
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def optional_string(value: Any) -> str | None:
|
|
331
|
+
return value if isinstance(value, str) and value else None
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def optional_int(value: Any) -> int | None:
|
|
335
|
+
return value if isinstance(value, int) else None
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def base_setup_action(argv: list[str]) -> str | None:
|
|
339
|
+
for index, arg in enumerate(argv):
|
|
340
|
+
if arg == "--action" and index + 1 < len(argv):
|
|
341
|
+
return argv[index + 1]
|
|
342
|
+
if arg.startswith("--action="):
|
|
343
|
+
return arg.partition("=")[2]
|
|
344
|
+
return None
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def project_name(context: Context) -> str | None:
|
|
348
|
+
if context.project_name:
|
|
349
|
+
return context.project_name
|
|
350
|
+
if context.manifest_path is None:
|
|
351
|
+
return None
|
|
352
|
+
try:
|
|
353
|
+
data = load_yaml_file(context.manifest_path)
|
|
354
|
+
except (OSError, RuntimeError, ValueError):
|
|
355
|
+
return None
|
|
356
|
+
project_data = data.get("project")
|
|
357
|
+
if not isinstance(project_data, dict):
|
|
358
|
+
return None
|
|
359
|
+
value = project_data.get("name")
|
|
360
|
+
return value if isinstance(value, str) and value else None
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def base_version(base_home: Path | None) -> str | None:
|
|
364
|
+
if base_home is None:
|
|
365
|
+
return None
|
|
366
|
+
try:
|
|
367
|
+
version = (base_home / "VERSION").read_text(encoding="utf-8").splitlines()[0].strip()
|
|
368
|
+
except (IndexError, OSError):
|
|
369
|
+
return None
|
|
370
|
+
return version or None
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def normalized_os() -> str:
|
|
374
|
+
system = platform.system().lower()
|
|
375
|
+
if system == "darwin":
|
|
376
|
+
return "macos"
|
|
377
|
+
return system or platform.platform()
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def redact_history_argv(argv: list[str], sensitive_options: set[str]) -> list[str]:
|
|
381
|
+
redacted = redact_argv(argv, sensitive_options)
|
|
382
|
+
result: list[str] = []
|
|
383
|
+
redact_next = False
|
|
384
|
+
for arg in redacted:
|
|
385
|
+
if redact_next:
|
|
386
|
+
result.append(REDACTED)
|
|
387
|
+
redact_next = False
|
|
388
|
+
continue
|
|
389
|
+
|
|
390
|
+
option, separator, _value = arg.partition("=")
|
|
391
|
+
normalized = option_name_to_parameter(option) if option.startswith("--") else option
|
|
392
|
+
if option.startswith("--") and is_secret_key(normalized):
|
|
393
|
+
if separator:
|
|
394
|
+
result.append(f"{option}={REDACTED}")
|
|
395
|
+
else:
|
|
396
|
+
result.append(option)
|
|
397
|
+
redact_next = True
|
|
398
|
+
continue
|
|
399
|
+
result.append(redact_history_text(arg))
|
|
400
|
+
return result
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def redact_history_text(value: str) -> str:
|
|
404
|
+
key, separator, _value = value.partition("=")
|
|
405
|
+
if separator and is_secret_key(key):
|
|
406
|
+
return f"{key}={REDACTED}"
|
|
407
|
+
return compact_home_text(redact_text_value(value))
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def compact_optional_path(path: Path | None) -> str | None:
|
|
411
|
+
if path is None:
|
|
412
|
+
return None
|
|
413
|
+
return compact_path(path)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def compact_path(path: Path) -> str:
|
|
417
|
+
return compact_home_text(str(path.expanduser().resolve(strict=False)))
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def compact_home_text(value: str) -> str:
|
|
421
|
+
home = str(Path.home().expanduser().resolve(strict=False))
|
|
422
|
+
if value == home:
|
|
423
|
+
return "~"
|
|
424
|
+
if value.startswith(f"{home}/"):
|
|
425
|
+
return f"~/{value[len(home) + 1:]}"
|
|
426
|
+
return value
|
base_cli/ide_schema.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class IdeDefinition:
|
|
10
|
+
name: str
|
|
11
|
+
label: str
|
|
12
|
+
cli: str
|
|
13
|
+
cask: str
|
|
14
|
+
settings_app_dir: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
IDE_DEFINITIONS = {
|
|
18
|
+
"vscode": IdeDefinition(
|
|
19
|
+
name="vscode",
|
|
20
|
+
label="VS Code",
|
|
21
|
+
cli="code",
|
|
22
|
+
cask="visual-studio-code",
|
|
23
|
+
settings_app_dir="Code",
|
|
24
|
+
),
|
|
25
|
+
"cursor": IdeDefinition(
|
|
26
|
+
name="cursor",
|
|
27
|
+
label="Cursor",
|
|
28
|
+
cli="cursor",
|
|
29
|
+
cask="cursor",
|
|
30
|
+
settings_app_dir="Cursor",
|
|
31
|
+
),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
SUPPORTED_IDES = frozenset(IDE_DEFINITIONS)
|
|
35
|
+
PROJECT_AUTO_SETTING_KEYS = frozenset({"python.defaultInterpreterPath"})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_ide_extensions(context: str, extensions_data: Any) -> tuple[str, ...]:
|
|
39
|
+
if extensions_data is None:
|
|
40
|
+
return ()
|
|
41
|
+
if not isinstance(extensions_data, list):
|
|
42
|
+
raise ValueError(f"{context} must be a list when provided.")
|
|
43
|
+
|
|
44
|
+
extensions: list[str] = []
|
|
45
|
+
for index, extension in enumerate(extensions_data, start=1):
|
|
46
|
+
if not isinstance(extension, str) or not extension.strip():
|
|
47
|
+
raise ValueError(f"{context}[{index}] must be a non-empty string.")
|
|
48
|
+
extensions.append(extension.strip())
|
|
49
|
+
return tuple(extensions)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_ide_settings(
|
|
53
|
+
context: str,
|
|
54
|
+
settings_data: Any,
|
|
55
|
+
*,
|
|
56
|
+
auto_setting_keys: frozenset[str] | None = None,
|
|
57
|
+
) -> dict[str, Any]:
|
|
58
|
+
if settings_data is None:
|
|
59
|
+
return {}
|
|
60
|
+
if not isinstance(settings_data, dict):
|
|
61
|
+
raise ValueError(f"{context} must be a mapping when provided.")
|
|
62
|
+
|
|
63
|
+
settings: dict[str, Any] = {}
|
|
64
|
+
for key, value in settings_data.items():
|
|
65
|
+
if not isinstance(key, str) or not key:
|
|
66
|
+
raise ValueError(f"{context} keys must be non-empty strings.")
|
|
67
|
+
if auto_setting_keys is not None and value == "auto" and key not in auto_setting_keys:
|
|
68
|
+
raise ValueError(f"{context}.{key} does not support the special value 'auto'.")
|
|
69
|
+
try:
|
|
70
|
+
json.dumps(value)
|
|
71
|
+
except TypeError as exc:
|
|
72
|
+
raise ValueError(f"{context}.{key} must be JSON-serializable.") from exc
|
|
73
|
+
settings[key] = value
|
|
74
|
+
return settings
|
base_cli/inspection.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from typing import Any
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
InspectionStatus = Literal["ok", "warn", "error"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def inspection_envelope(
|
|
12
|
+
*,
|
|
13
|
+
command: str,
|
|
14
|
+
status: InspectionStatus,
|
|
15
|
+
data: Mapping[str, Any],
|
|
16
|
+
error: Mapping[str, Any] | None = None,
|
|
17
|
+
) -> dict[str, Any]:
|
|
18
|
+
"""Build the stable v1 envelope for read-only inspection commands."""
|
|
19
|
+
if status not in ("ok", "warn", "error"):
|
|
20
|
+
raise ValueError(f"Unsupported inspection status: {status}")
|
|
21
|
+
return {
|
|
22
|
+
"schema_version": 1,
|
|
23
|
+
"command": command,
|
|
24
|
+
"status": status,
|
|
25
|
+
"data": dict(data),
|
|
26
|
+
"error": None if error is None else dict(error),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def render_inspection_json(
|
|
31
|
+
*,
|
|
32
|
+
command: str,
|
|
33
|
+
status: InspectionStatus,
|
|
34
|
+
data: Mapping[str, Any],
|
|
35
|
+
error: Mapping[str, Any] | None = None,
|
|
36
|
+
) -> str:
|
|
37
|
+
"""Serialize the stable inspection envelope with Python's JSON encoder."""
|
|
38
|
+
return (
|
|
39
|
+
json.dumps(
|
|
40
|
+
inspection_envelope(command=command, status=status, data=data, error=error),
|
|
41
|
+
ensure_ascii=False,
|
|
42
|
+
indent=2,
|
|
43
|
+
)
|
|
44
|
+
+ "\n"
|
|
45
|
+
)
|