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
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"CommandProtocolError",
|
|
10
|
+
"dumps_record",
|
|
11
|
+
"dumps_records",
|
|
12
|
+
"loads_records",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
PROTOCOL_HEADER = "BASE_COMMAND_PROTOCOL_V1"
|
|
17
|
+
MAX_RECORD_COUNT = 1_000_000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CommandProtocolError(ValueError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class FieldSpec:
|
|
26
|
+
value_type: str
|
|
27
|
+
nullable: bool = False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
STRING = FieldSpec("string")
|
|
31
|
+
NULLABLE_STRING = FieldSpec("string", nullable=True)
|
|
32
|
+
BOOLEAN = FieldSpec("boolean")
|
|
33
|
+
|
|
34
|
+
PROJECT_REFERENCE_FIELDS = {
|
|
35
|
+
"project_name": STRING,
|
|
36
|
+
"project_root": STRING,
|
|
37
|
+
"manifest_path": STRING,
|
|
38
|
+
}
|
|
39
|
+
PROJECT_ROUTE_FIELDS = {
|
|
40
|
+
**PROJECT_REFERENCE_FIELDS,
|
|
41
|
+
"project_venv_dir": STRING,
|
|
42
|
+
"uses_uv_manager": BOOLEAN,
|
|
43
|
+
"manifest_command_trust_required": BOOLEAN,
|
|
44
|
+
}
|
|
45
|
+
PROJECT_SETUP_ROUTE_FIELDS = {
|
|
46
|
+
**PROJECT_ROUTE_FIELDS,
|
|
47
|
+
"requires_project_python": BOOLEAN,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
RECORD_SCHEMAS: dict[str, dict[str, FieldSpec]] = {
|
|
51
|
+
"project-list-entry": {
|
|
52
|
+
"project_name": STRING,
|
|
53
|
+
"project_root": STRING,
|
|
54
|
+
},
|
|
55
|
+
"project-reference": PROJECT_REFERENCE_FIELDS,
|
|
56
|
+
"project-route": PROJECT_ROUTE_FIELDS,
|
|
57
|
+
"project-setup-route": PROJECT_SETUP_ROUTE_FIELDS,
|
|
58
|
+
"project-command": {
|
|
59
|
+
**PROJECT_ROUTE_FIELDS,
|
|
60
|
+
"command": STRING,
|
|
61
|
+
"runner": NULLABLE_STRING,
|
|
62
|
+
},
|
|
63
|
+
"named-command": {
|
|
64
|
+
**PROJECT_REFERENCE_FIELDS,
|
|
65
|
+
"command_name": STRING,
|
|
66
|
+
"command": STRING,
|
|
67
|
+
"runner": NULLABLE_STRING,
|
|
68
|
+
},
|
|
69
|
+
"build-target": {
|
|
70
|
+
**PROJECT_ROUTE_FIELDS,
|
|
71
|
+
"target_name": STRING,
|
|
72
|
+
"working_dir": STRING,
|
|
73
|
+
"command": STRING,
|
|
74
|
+
"description": NULLABLE_STRING,
|
|
75
|
+
"runner": NULLABLE_STRING,
|
|
76
|
+
},
|
|
77
|
+
"demo": {
|
|
78
|
+
**PROJECT_ROUTE_FIELDS,
|
|
79
|
+
"demo_script": STRING,
|
|
80
|
+
"runner": NULLABLE_STRING,
|
|
81
|
+
},
|
|
82
|
+
"activation-source": {
|
|
83
|
+
"source_path": STRING,
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
RecordValue = str | bool | None
|
|
88
|
+
Record = Mapping[str, RecordValue]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def dumps_record(record_type: str, record: Record) -> str:
|
|
92
|
+
return dumps_records(record_type, (record,))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def dumps_records(record_type: str, records: tuple[Record, ...] | list[Record]) -> str:
|
|
96
|
+
schema = _schema(record_type)
|
|
97
|
+
if len(records) > MAX_RECORD_COUNT:
|
|
98
|
+
raise CommandProtocolError(f"record_count exceeds protocol maximum of {MAX_RECORD_COUNT}")
|
|
99
|
+
lines = [
|
|
100
|
+
PROTOCOL_HEADER,
|
|
101
|
+
f"record_type={record_type}",
|
|
102
|
+
f"record_count={len(records)}",
|
|
103
|
+
]
|
|
104
|
+
for index, record in enumerate(records):
|
|
105
|
+
_validate_record(schema, record_type, record)
|
|
106
|
+
lines.append(f"record={index}")
|
|
107
|
+
for field_name, spec in schema.items():
|
|
108
|
+
wire_type, payload = _encode_value(field_name, spec, record[field_name])
|
|
109
|
+
lines.append(f"field.{field_name}:{wire_type}={payload}")
|
|
110
|
+
lines.append(f"end_record={index}")
|
|
111
|
+
lines.append("end_protocol=")
|
|
112
|
+
return "\n".join(lines)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def loads_records(
|
|
116
|
+
payload: str,
|
|
117
|
+
expected_record_type: str | None = None,
|
|
118
|
+
) -> tuple[str, tuple[dict[str, RecordValue], ...]]:
|
|
119
|
+
# The wire framing is LF-delimited. `str.splitlines()` also accepts CR,
|
|
120
|
+
# vertical tab, form feed, and Unicode separators, which would make the
|
|
121
|
+
# Python decoder more permissive than the Bash and Zsh readers.
|
|
122
|
+
# A CLI `print()` adds one terminal LF; accept that conventional text-file
|
|
123
|
+
# terminator without accepting an extra blank line or other separators.
|
|
124
|
+
framed_payload = payload.removesuffix("\n")
|
|
125
|
+
lines = framed_payload.split("\n")
|
|
126
|
+
cursor = 0
|
|
127
|
+
|
|
128
|
+
def take(label: str) -> str:
|
|
129
|
+
nonlocal cursor
|
|
130
|
+
if cursor >= len(lines):
|
|
131
|
+
raise CommandProtocolError(f"missing {label}")
|
|
132
|
+
line = lines[cursor]
|
|
133
|
+
cursor += 1
|
|
134
|
+
return line
|
|
135
|
+
|
|
136
|
+
if take("protocol header") != PROTOCOL_HEADER:
|
|
137
|
+
raise CommandProtocolError(f"unsupported protocol header; expected {PROTOCOL_HEADER}")
|
|
138
|
+
|
|
139
|
+
record_type = _metadata_value(take("record_type"), "record_type")
|
|
140
|
+
schema = _schema(record_type)
|
|
141
|
+
if expected_record_type is not None and record_type != expected_record_type:
|
|
142
|
+
raise CommandProtocolError(f"expected record_type '{expected_record_type}', got '{record_type}'")
|
|
143
|
+
|
|
144
|
+
record_count_text = _metadata_value(take("record_count"), "record_count")
|
|
145
|
+
record_count = _parse_record_count(record_count_text)
|
|
146
|
+
|
|
147
|
+
records: list[dict[str, RecordValue]] = []
|
|
148
|
+
for index in range(record_count):
|
|
149
|
+
if take(f"record {index}") != f"record={index}":
|
|
150
|
+
raise CommandProtocolError(f"expected record={index}")
|
|
151
|
+
|
|
152
|
+
record: dict[str, RecordValue] = {}
|
|
153
|
+
for _field_index in range(len(schema)):
|
|
154
|
+
field_name, wire_type, encoded_value = _parse_field_line(take(f"field in record {index}"))
|
|
155
|
+
if field_name in record:
|
|
156
|
+
raise CommandProtocolError(f"record {index} duplicates field '{field_name}'")
|
|
157
|
+
try:
|
|
158
|
+
spec = schema[field_name]
|
|
159
|
+
except KeyError as exc:
|
|
160
|
+
raise CommandProtocolError(
|
|
161
|
+
f"record {index} has unknown field '{field_name}' for '{record_type}'"
|
|
162
|
+
) from exc
|
|
163
|
+
record[field_name] = _decode_value(field_name, spec, wire_type, encoded_value)
|
|
164
|
+
|
|
165
|
+
missing = sorted(set(schema) - set(record))
|
|
166
|
+
if missing:
|
|
167
|
+
raise CommandProtocolError(f"record {index} is missing fields: {', '.join(missing)}")
|
|
168
|
+
if take(f"end_record {index}") != f"end_record={index}":
|
|
169
|
+
raise CommandProtocolError(f"expected end_record={index}")
|
|
170
|
+
records.append(record)
|
|
171
|
+
|
|
172
|
+
if take("end_protocol") != "end_protocol=":
|
|
173
|
+
raise CommandProtocolError("expected end_protocol marker")
|
|
174
|
+
if cursor != len(lines):
|
|
175
|
+
raise CommandProtocolError("unexpected data after end_protocol marker")
|
|
176
|
+
return record_type, tuple(records)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _schema(record_type: str) -> dict[str, FieldSpec]:
|
|
180
|
+
try:
|
|
181
|
+
return RECORD_SCHEMAS[record_type]
|
|
182
|
+
except KeyError as exc:
|
|
183
|
+
supported = ", ".join(sorted(RECORD_SCHEMAS))
|
|
184
|
+
raise CommandProtocolError(f"unsupported record_type '{record_type}'; expected one of: {supported}") from exc
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _validate_record(schema: Mapping[str, FieldSpec], record_type: str, record: Record) -> None:
|
|
188
|
+
missing = sorted(set(schema) - set(record))
|
|
189
|
+
unknown = sorted(set(record) - set(schema))
|
|
190
|
+
if missing:
|
|
191
|
+
raise CommandProtocolError(f"record for '{record_type}' is missing fields: {', '.join(missing)}")
|
|
192
|
+
if unknown:
|
|
193
|
+
raise CommandProtocolError(f"record for '{record_type}' has unknown fields: {', '.join(unknown)}")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _encode_value(field_name: str, spec: FieldSpec, value: RecordValue) -> tuple[str, str]:
|
|
197
|
+
if value is None:
|
|
198
|
+
if not spec.nullable:
|
|
199
|
+
raise CommandProtocolError(f"field '{field_name}' cannot be null")
|
|
200
|
+
return "null", ""
|
|
201
|
+
if spec.value_type == "boolean":
|
|
202
|
+
if not isinstance(value, bool):
|
|
203
|
+
raise CommandProtocolError(f"field '{field_name}' must be a boolean")
|
|
204
|
+
return "boolean", "true" if value else "false"
|
|
205
|
+
if not isinstance(value, str):
|
|
206
|
+
raise CommandProtocolError(f"field '{field_name}' must be a string")
|
|
207
|
+
if "\0" in value:
|
|
208
|
+
raise CommandProtocolError(f"field '{field_name}' cannot contain NUL")
|
|
209
|
+
return "string", value.encode("utf-8").hex()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _decode_value(field_name: str, spec: FieldSpec, wire_type: str, payload: str) -> RecordValue:
|
|
213
|
+
if wire_type == "null":
|
|
214
|
+
if not spec.nullable:
|
|
215
|
+
raise CommandProtocolError(f"field '{field_name}' cannot be null")
|
|
216
|
+
if payload:
|
|
217
|
+
raise CommandProtocolError(f"null field '{field_name}' must have an empty payload")
|
|
218
|
+
return None
|
|
219
|
+
if spec.value_type == "boolean":
|
|
220
|
+
if wire_type != "boolean" or payload not in ("true", "false"):
|
|
221
|
+
raise CommandProtocolError(f"field '{field_name}' must use boolean:true or boolean:false")
|
|
222
|
+
return payload == "true"
|
|
223
|
+
if wire_type != "string":
|
|
224
|
+
expected = "string or null" if spec.nullable else "string"
|
|
225
|
+
raise CommandProtocolError(f"field '{field_name}' must use {expected} encoding")
|
|
226
|
+
if len(payload) % 2 != 0 or re.fullmatch(r"[0-9a-f]*", payload) is None:
|
|
227
|
+
raise CommandProtocolError(f"field '{field_name}' has invalid lowercase hexadecimal data")
|
|
228
|
+
try:
|
|
229
|
+
value = bytes.fromhex(payload).decode("utf-8")
|
|
230
|
+
except UnicodeDecodeError as exc:
|
|
231
|
+
raise CommandProtocolError(f"field '{field_name}' has invalid UTF-8 data") from exc
|
|
232
|
+
if "\0" in value:
|
|
233
|
+
raise CommandProtocolError(f"field '{field_name}' cannot contain NUL")
|
|
234
|
+
return value
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _metadata_value(line: str, name: str) -> str:
|
|
238
|
+
prefix = f"{name}="
|
|
239
|
+
if not line.startswith(prefix):
|
|
240
|
+
raise CommandProtocolError(f"expected {name} metadata")
|
|
241
|
+
return line[len(prefix) :]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _parse_record_count(record_count_text: str) -> int:
|
|
245
|
+
if re.fullmatch(r"0|[1-9][0-9]*", record_count_text) is None:
|
|
246
|
+
raise CommandProtocolError("record_count must be a canonical non-negative integer")
|
|
247
|
+
if len(record_count_text) > len(str(MAX_RECORD_COUNT)):
|
|
248
|
+
raise CommandProtocolError(f"record_count exceeds protocol maximum of {MAX_RECORD_COUNT}")
|
|
249
|
+
record_count = int(record_count_text)
|
|
250
|
+
if record_count > MAX_RECORD_COUNT:
|
|
251
|
+
raise CommandProtocolError(f"record_count exceeds protocol maximum of {MAX_RECORD_COUNT}")
|
|
252
|
+
return record_count
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _parse_field_line(line: str) -> tuple[str, str, str]:
|
|
256
|
+
key, separator, payload = line.partition("=")
|
|
257
|
+
if not separator or not key.startswith("field."):
|
|
258
|
+
raise CommandProtocolError("expected field.<name>:<type>=<payload>")
|
|
259
|
+
descriptor = key.removeprefix("field.")
|
|
260
|
+
field_name, separator, wire_type = descriptor.partition(":")
|
|
261
|
+
if not separator or not field_name or not wire_type:
|
|
262
|
+
raise CommandProtocolError("expected field.<name>:<type>=<payload>")
|
|
263
|
+
return field_name, wire_type, payload
|
base_cli/config.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .ide_schema import SUPPORTED_IDES
|
|
10
|
+
from .ide_schema import parse_ide_extensions
|
|
11
|
+
from .ide_schema import parse_ide_settings
|
|
12
|
+
from .paths import base_state_root
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"UserConfig",
|
|
17
|
+
"UserGithubConfig",
|
|
18
|
+
"UserIdeConfig",
|
|
19
|
+
"UserIdePreference",
|
|
20
|
+
"UserWorkspaceConfig",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class UserIdePreference:
|
|
26
|
+
enabled: bool | None
|
|
27
|
+
install: bool | None
|
|
28
|
+
extra_extensions: tuple[str, ...]
|
|
29
|
+
settings: dict[str, Any]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class UserIdeConfig:
|
|
34
|
+
enabled: bool | None
|
|
35
|
+
preferences: dict[str, UserIdePreference]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class UserWorkspaceConfig:
|
|
40
|
+
root: Path | None
|
|
41
|
+
manifest: Path | None = None
|
|
42
|
+
manifest_source: str | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class UserGithubConfig:
|
|
47
|
+
default_owner: str | None
|
|
48
|
+
clone_protocol: str | None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class UserConfig:
|
|
53
|
+
raw: dict[str, Any]
|
|
54
|
+
ide: UserIdeConfig
|
|
55
|
+
workspace: UserWorkspaceConfig = UserWorkspaceConfig(root=None)
|
|
56
|
+
github: UserGithubConfig = UserGithubConfig(default_owner=None, clone_protocol=None)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def user_config_path(home: Path | None = None) -> Path:
|
|
60
|
+
return base_state_root(home) / "config.yaml"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load_yaml_file(path: Path) -> dict[str, Any]:
|
|
64
|
+
if not path.is_file():
|
|
65
|
+
return {}
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
import yaml
|
|
69
|
+
except ImportError as exc:
|
|
70
|
+
raise RuntimeError("PyYAML is required to load base_cli configuration.") from exc
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
74
|
+
except yaml.YAMLError as exc:
|
|
75
|
+
raise ValueError(f"Config file '{path}' contains invalid YAML: {exc}") from exc
|
|
76
|
+
if data is None:
|
|
77
|
+
return {}
|
|
78
|
+
if not isinstance(data, dict):
|
|
79
|
+
raise ValueError(f"Config file '{path}' must contain a YAML mapping.")
|
|
80
|
+
return data
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def load_user_config(home: Path | None = None) -> dict[str, Any]:
|
|
84
|
+
return load_yaml_file(user_config_path(home))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def read_user_config(home: Path | None = None) -> UserConfig:
|
|
88
|
+
raw = load_user_config(home)
|
|
89
|
+
path = user_config_path(home)
|
|
90
|
+
return UserConfig(
|
|
91
|
+
raw=raw,
|
|
92
|
+
workspace=_read_user_workspace_config(path, raw.get("workspace")),
|
|
93
|
+
github=_read_user_github_config(path, raw.get("github")),
|
|
94
|
+
ide=_read_user_ide_config(path, raw.get("ide")),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _read_user_workspace_config(path: Path, workspace_data: Any) -> UserWorkspaceConfig:
|
|
99
|
+
if workspace_data is None:
|
|
100
|
+
return UserWorkspaceConfig(root=None)
|
|
101
|
+
if not isinstance(workspace_data, dict):
|
|
102
|
+
raise ValueError(f"{path}: workspace must be a mapping when provided.")
|
|
103
|
+
|
|
104
|
+
allowed_keys = {"root", "manifest", "manifest_source"}
|
|
105
|
+
unknown_keys = sorted(set(workspace_data) - allowed_keys)
|
|
106
|
+
if unknown_keys:
|
|
107
|
+
raise ValueError(f"{path}: workspace has unsupported keys: {', '.join(unknown_keys)}.")
|
|
108
|
+
|
|
109
|
+
return UserWorkspaceConfig(
|
|
110
|
+
root=_optional_path(path, "workspace.root", workspace_data.get("root")),
|
|
111
|
+
manifest=_optional_path(path, "workspace.manifest", workspace_data.get("manifest")),
|
|
112
|
+
manifest_source=_optional_non_empty_string(
|
|
113
|
+
path,
|
|
114
|
+
"workspace.manifest_source",
|
|
115
|
+
workspace_data.get("manifest_source"),
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _read_user_github_config(path: Path, github_data: Any) -> UserGithubConfig:
|
|
121
|
+
if github_data is None:
|
|
122
|
+
return UserGithubConfig(default_owner=None, clone_protocol=None)
|
|
123
|
+
if not isinstance(github_data, dict):
|
|
124
|
+
raise ValueError(f"{path}: github must be a mapping when provided.")
|
|
125
|
+
|
|
126
|
+
allowed_keys = {"default_owner", "clone_protocol"}
|
|
127
|
+
unknown_keys = sorted(set(github_data) - allowed_keys)
|
|
128
|
+
if unknown_keys:
|
|
129
|
+
raise ValueError(f"{path}: github has unsupported keys: {', '.join(unknown_keys)}.")
|
|
130
|
+
|
|
131
|
+
return UserGithubConfig(
|
|
132
|
+
default_owner=_optional_github_owner(path, github_data.get("default_owner")),
|
|
133
|
+
clone_protocol=_optional_github_clone_protocol(path, github_data.get("clone_protocol")),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _optional_github_owner(path: Path, value: Any) -> str | None:
|
|
138
|
+
owner = _optional_non_empty_string(path, "github.default_owner", value)
|
|
139
|
+
if owner is None:
|
|
140
|
+
return None
|
|
141
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9-]*", owner):
|
|
142
|
+
raise ValueError(
|
|
143
|
+
f"{path}: github.default_owner must start with a letter or digit and contain only "
|
|
144
|
+
"letters, digits, and dash."
|
|
145
|
+
)
|
|
146
|
+
return owner
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _optional_github_clone_protocol(path: Path, value: Any) -> str | None:
|
|
150
|
+
protocol = _optional_non_empty_string(path, "github.clone_protocol", value)
|
|
151
|
+
if protocol is None:
|
|
152
|
+
return None
|
|
153
|
+
if protocol not in {"ssh", "https"}:
|
|
154
|
+
raise ValueError(f"{path}: github.clone_protocol must be 'ssh' or 'https'.")
|
|
155
|
+
return protocol
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _optional_path(path: Path, key: str, value: Any) -> Path | None:
|
|
159
|
+
if value is None:
|
|
160
|
+
return None
|
|
161
|
+
candidate = _optional_non_empty_string(path, key, value)
|
|
162
|
+
if candidate is None:
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
candidate_path = Path(candidate).expanduser()
|
|
166
|
+
if not candidate_path.is_absolute():
|
|
167
|
+
raise ValueError(f"{path}: {key} must be an absolute path or start with '~'.")
|
|
168
|
+
return candidate_path.resolve(strict=False)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _optional_non_empty_string(path: Path, key: str, value: Any) -> str | None:
|
|
172
|
+
if value is None:
|
|
173
|
+
return None
|
|
174
|
+
if not isinstance(value, str) or not value.strip():
|
|
175
|
+
raise ValueError(f"{path}: {key} must be a non-empty string when provided.")
|
|
176
|
+
return value.strip()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _read_user_ide_config(path: Path, ide_data: Any) -> UserIdeConfig:
|
|
180
|
+
if ide_data is None:
|
|
181
|
+
return UserIdeConfig(enabled=None, preferences={})
|
|
182
|
+
if not isinstance(ide_data, dict):
|
|
183
|
+
raise ValueError(f"{path}: ide must be a mapping when provided.")
|
|
184
|
+
|
|
185
|
+
allowed_keys = SUPPORTED_IDES | {"enabled"}
|
|
186
|
+
unknown_keys = sorted(set(ide_data) - allowed_keys)
|
|
187
|
+
if unknown_keys:
|
|
188
|
+
raise ValueError(f"{path}: unsupported ide keys: {', '.join(unknown_keys)}.")
|
|
189
|
+
|
|
190
|
+
enabled = _optional_bool(path, "ide.enabled", ide_data.get("enabled"))
|
|
191
|
+
preferences = {
|
|
192
|
+
ide_name: _read_user_ide_preference(path, ide_name, ide_data.get(ide_name))
|
|
193
|
+
for ide_name in sorted(SUPPORTED_IDES)
|
|
194
|
+
if ide_name in ide_data
|
|
195
|
+
}
|
|
196
|
+
return UserIdeConfig(enabled=enabled, preferences=preferences)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _read_user_ide_preference(path: Path, ide_name: str, preference_data: Any) -> UserIdePreference:
|
|
200
|
+
if preference_data is None:
|
|
201
|
+
preference_data = {}
|
|
202
|
+
if not isinstance(preference_data, dict):
|
|
203
|
+
raise ValueError(f"{path}: ide.{ide_name} must be a mapping when provided.")
|
|
204
|
+
|
|
205
|
+
allowed_keys = {"enabled", "install", "extra_extensions", "settings"}
|
|
206
|
+
unknown_keys = sorted(set(preference_data) - allowed_keys)
|
|
207
|
+
if unknown_keys:
|
|
208
|
+
raise ValueError(f"{path}: ide.{ide_name} has unsupported keys: {', '.join(unknown_keys)}.")
|
|
209
|
+
|
|
210
|
+
enabled = _optional_bool(path, f"ide.{ide_name}.enabled", preference_data.get("enabled"))
|
|
211
|
+
install = _optional_bool(path, f"ide.{ide_name}.install", preference_data.get("install"))
|
|
212
|
+
extra_extensions = _read_extra_extensions(path, ide_name, preference_data.get("extra_extensions", []))
|
|
213
|
+
settings = _read_user_ide_settings(path, ide_name, preference_data.get("settings", {}))
|
|
214
|
+
return UserIdePreference(
|
|
215
|
+
enabled=enabled,
|
|
216
|
+
install=install,
|
|
217
|
+
extra_extensions=extra_extensions,
|
|
218
|
+
settings=settings,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _optional_bool(path: Path, key: str, value: Any) -> bool | None:
|
|
223
|
+
if value is None:
|
|
224
|
+
return None
|
|
225
|
+
if not isinstance(value, bool):
|
|
226
|
+
raise ValueError(f"{path}: {key} must be a boolean when provided.")
|
|
227
|
+
return value
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _read_extra_extensions(path: Path, ide_name: str, extensions_data: Any) -> tuple[str, ...]:
|
|
231
|
+
return parse_ide_extensions(f"{path}: ide.{ide_name}.extra_extensions", extensions_data)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _read_user_ide_settings(path: Path, ide_name: str, settings_data: Any) -> dict[str, Any]:
|
|
235
|
+
return parse_ide_settings(f"{path}: ide.{ide_name}.settings", settings_data)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def merge_dicts(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
|
239
|
+
merged = dict(base)
|
|
240
|
+
for key, value in override.items():
|
|
241
|
+
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
|
242
|
+
merged[key] = merge_dicts(merged[key], value)
|
|
243
|
+
else:
|
|
244
|
+
merged[key] = value
|
|
245
|
+
return merged
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def load_config(
|
|
249
|
+
project_root: Path | None,
|
|
250
|
+
explicit_config: Path | None,
|
|
251
|
+
home: Path | None = None,
|
|
252
|
+
) -> dict[str, Any]:
|
|
253
|
+
root = home or Path.home()
|
|
254
|
+
config: dict[str, Any] = {}
|
|
255
|
+
config = merge_dicts(config, load_user_config(root))
|
|
256
|
+
if project_root is not None:
|
|
257
|
+
config = merge_dicts(config, load_yaml_file(project_root / ".base" / "config.yaml"))
|
|
258
|
+
if explicit_config is not None:
|
|
259
|
+
config = merge_dicts(config, load_yaml_file(explicit_config))
|
|
260
|
+
|
|
261
|
+
env_config: dict[str, Any] = {}
|
|
262
|
+
if "BASE_CLI_ENVIRONMENT" in os.environ:
|
|
263
|
+
env_config["environment"] = os.environ["BASE_CLI_ENVIRONMENT"]
|
|
264
|
+
if "BASE_CLI_LOG_LEVEL" in os.environ:
|
|
265
|
+
env_config["log_level"] = os.environ["BASE_CLI_LOG_LEVEL"]
|
|
266
|
+
elif os.environ.get("LOG_DEBUG", "").lower() in ("1", "true"):
|
|
267
|
+
env_config["log_level"] = "debug"
|
|
268
|
+
if "BASE_CLI_KEEP_TEMP" in os.environ:
|
|
269
|
+
env_config["keep_temp"] = os.environ["BASE_CLI_KEEP_TEMP"].lower() == "true"
|
|
270
|
+
return merge_dicts(config, env_config)
|
base_cli/context.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextvars
|
|
4
|
+
import logging
|
|
5
|
+
import shutil
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Callable
|
|
9
|
+
|
|
10
|
+
from .config import UserConfig, UserIdeConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_current_context: contextvars.ContextVar[Context | None] = contextvars.ContextVar(
|
|
14
|
+
"base_cli_current_context",
|
|
15
|
+
default=None,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _default_user_config() -> UserConfig:
|
|
20
|
+
return UserConfig(raw={}, ide=UserIdeConfig(enabled=None, preferences={}))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Context:
|
|
25
|
+
"""Runtime state and cleanup hooks available to an active Base CLI command."""
|
|
26
|
+
|
|
27
|
+
cli_name: str
|
|
28
|
+
run_id: str
|
|
29
|
+
state_dir: Path
|
|
30
|
+
log_dir: Path
|
|
31
|
+
cache_dir: Path
|
|
32
|
+
temp_dir: Path
|
|
33
|
+
log_file: Path | None
|
|
34
|
+
config: dict[str, Any]
|
|
35
|
+
environment: str
|
|
36
|
+
debug: bool
|
|
37
|
+
keep_temp: bool
|
|
38
|
+
log: logging.Logger
|
|
39
|
+
dry_run: bool = False
|
|
40
|
+
base_home: Path | None = None
|
|
41
|
+
project_root: Path | None = None
|
|
42
|
+
manifest_path: Path | None = None
|
|
43
|
+
project_name: str | None = None
|
|
44
|
+
history_scope: str = "primary"
|
|
45
|
+
history_parent_run_id: str | None = None
|
|
46
|
+
user_config: UserConfig = field(default_factory=_default_user_config)
|
|
47
|
+
cleanup_hooks: list[Callable[[], None]] = field(default_factory=list)
|
|
48
|
+
workspace_root: Path | None = None
|
|
49
|
+
quiet: bool = False
|
|
50
|
+
runtime_owner: str = "base"
|
|
51
|
+
owner_root: Path | None = None
|
|
52
|
+
run_root: Path | None = None
|
|
53
|
+
|
|
54
|
+
def on_cleanup(self, hook: Callable[[], None]) -> None:
|
|
55
|
+
self.cleanup_hooks.append(hook)
|
|
56
|
+
|
|
57
|
+
def bind_project(self, project_name: str | None, project_root: Path, manifest_path: Path | None = None) -> None:
|
|
58
|
+
"""Bind the selected project to this invocation's history context."""
|
|
59
|
+
self.project_name = project_name
|
|
60
|
+
self.project_root = project_root.resolve()
|
|
61
|
+
self.manifest_path = manifest_path.resolve() if manifest_path is not None else None
|
|
62
|
+
|
|
63
|
+
def cleanup(self) -> None:
|
|
64
|
+
for hook in self.cleanup_hooks:
|
|
65
|
+
try:
|
|
66
|
+
hook()
|
|
67
|
+
except Exception as exc: # pylint: disable=broad-exception-caught
|
|
68
|
+
self.log.warning("Cleanup hook failed: %s", exc)
|
|
69
|
+
if not self.keep_temp and self.temp_dir.exists():
|
|
70
|
+
try:
|
|
71
|
+
shutil.rmtree(self.temp_dir)
|
|
72
|
+
for parent in (self.temp_dir.parent, self.temp_dir.parent.parent):
|
|
73
|
+
try:
|
|
74
|
+
parent.rmdir()
|
|
75
|
+
except OSError:
|
|
76
|
+
break
|
|
77
|
+
except OSError as exc:
|
|
78
|
+
self.log.warning("Temp directory cleanup failed for '%s': %s", self.temp_dir, exc)
|
|
79
|
+
for handler in list(self.log.handlers):
|
|
80
|
+
try:
|
|
81
|
+
handler.flush()
|
|
82
|
+
except Exception as exc: # pylint: disable=broad-exception-caught
|
|
83
|
+
self.log.warning("Log handler flush failed: %s", exc)
|
|
84
|
+
try:
|
|
85
|
+
handler.close()
|
|
86
|
+
except Exception as exc: # pylint: disable=broad-exception-caught
|
|
87
|
+
self.log.warning("Log handler close failed: %s", exc)
|
|
88
|
+
self.log.removeHandler(handler)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def set_current_context(context: Context | None) -> contextvars.Token[Context | None]:
|
|
92
|
+
return _current_context.set(context)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def reset_current_context(token: contextvars.Token[Context | None]) -> None:
|
|
96
|
+
_current_context.reset(token)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_current_context() -> Context:
|
|
100
|
+
context = _current_context.get()
|
|
101
|
+
if context is None:
|
|
102
|
+
raise RuntimeError("base_cli context is not active. Run inside a base_cli.App command.")
|
|
103
|
+
return context
|