sf-m3-cli 0.2.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.
- m3_cli/__init__.py +5 -0
- m3_cli/__main__.py +5 -0
- m3_cli/branding.py +10 -0
- m3_cli/control_plane.py +278 -0
- m3_cli/doctor.py +376 -0
- m3_cli/errors.py +10 -0
- m3_cli/init.py +236 -0
- m3_cli/main.py +271 -0
- m3_cli/runtime.py +123 -0
- m3_cli/setup.py +353 -0
- m3_cli/supervisor.py +1336 -0
- m3_cli/ui/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
- m3_cli/ui/assets/index-BB1JIVWj.css +1 -0
- m3_cli/ui/assets/index-DRJt176-.js +95 -0
- m3_cli/ui/assets/inter-latin-standard-normal-BwkfbSeq.woff2 +0 -0
- m3_cli/ui/index.html +34 -0
- m3_cli/web.py +166 -0
- sf_m3_cli-0.2.0.dist-info/METADATA +395 -0
- sf_m3_cli-0.2.0.dist-info/RECORD +22 -0
- sf_m3_cli-0.2.0.dist-info/WHEEL +4 -0
- sf_m3_cli-0.2.0.dist-info/entry_points.txt +2 -0
- sf_m3_cli-0.2.0.dist-info/licenses/LICENSE +197 -0
m3_cli/__init__.py
ADDED
m3_cli/__main__.py
ADDED
m3_cli/branding.py
ADDED
m3_cli/control_plane.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Report uploader library for a future explicit CLI command.
|
|
2
|
+
|
|
3
|
+
No CLI command or pytest hook imports this module in normal operation.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import tempfile
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import asdict
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
from urllib import error, request
|
|
17
|
+
from urllib.parse import quote, urlparse
|
|
18
|
+
|
|
19
|
+
from m3.feedback import Feedback, project_test_attempts
|
|
20
|
+
from m3.storage import SQLiteExecutionStore
|
|
21
|
+
from m3_app.api.report_payloads import (
|
|
22
|
+
build_execution_envelope,
|
|
23
|
+
build_report_envelope,
|
|
24
|
+
)
|
|
25
|
+
from m3_app.api.wire import neutralize_response
|
|
26
|
+
from m3_app.services.execution_service import project_test_results
|
|
27
|
+
|
|
28
|
+
_RETRIES = 3
|
|
29
|
+
_MAX_UPLOAD_BYTES = 16 << 20
|
|
30
|
+
_SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _id_value(value: Any) -> str:
|
|
34
|
+
return str(getattr(value, "root", value))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _NoRedirect(request.HTTPRedirectHandler):
|
|
38
|
+
def redirect_request(
|
|
39
|
+
self,
|
|
40
|
+
req: request.Request,
|
|
41
|
+
fp: Any,
|
|
42
|
+
code: int,
|
|
43
|
+
msg: str,
|
|
44
|
+
headers: Any,
|
|
45
|
+
newurl: str,
|
|
46
|
+
) -> None:
|
|
47
|
+
raise RuntimeError("control-plane redirect rejected")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _json_bytes(value: Any) -> bytes:
|
|
51
|
+
return json.dumps(
|
|
52
|
+
value,
|
|
53
|
+
ensure_ascii=False,
|
|
54
|
+
allow_nan=False,
|
|
55
|
+
separators=(",", ":"),
|
|
56
|
+
sort_keys=True,
|
|
57
|
+
).encode("utf-8")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _post(url: str, token: str, body: bytes) -> None:
|
|
61
|
+
_validate_body(body, token)
|
|
62
|
+
last: Exception | None = None
|
|
63
|
+
for attempt in range(_RETRIES):
|
|
64
|
+
try:
|
|
65
|
+
req = request.Request(
|
|
66
|
+
url,
|
|
67
|
+
data=body,
|
|
68
|
+
method="POST",
|
|
69
|
+
headers={
|
|
70
|
+
"Authorization": "Bearer " + token,
|
|
71
|
+
"Content-Type": "application/json",
|
|
72
|
+
"Content-Length": str(len(body)),
|
|
73
|
+
},
|
|
74
|
+
)
|
|
75
|
+
with _OPENER.open(req, timeout=30) as response:
|
|
76
|
+
if 200 <= response.status < 300:
|
|
77
|
+
return
|
|
78
|
+
if response.status < 500 and response.status != 429:
|
|
79
|
+
raise RuntimeError("control-plane upload rejected")
|
|
80
|
+
except (
|
|
81
|
+
error.HTTPError,
|
|
82
|
+
error.URLError,
|
|
83
|
+
TimeoutError,
|
|
84
|
+
OSError,
|
|
85
|
+
RuntimeError,
|
|
86
|
+
) as exc:
|
|
87
|
+
last = exc
|
|
88
|
+
if isinstance(exc, error.HTTPError) and exc.code < 500 and exc.code != 429:
|
|
89
|
+
break
|
|
90
|
+
if attempt + 1 < _RETRIES:
|
|
91
|
+
time.sleep(0.25 * (2**attempt))
|
|
92
|
+
raise RuntimeError("control-plane upload failed") from last
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _validate_body(body: bytes, token: str) -> None:
|
|
96
|
+
if len(body) > _MAX_UPLOAD_BYTES:
|
|
97
|
+
raise RuntimeError("control-plane upload is too large")
|
|
98
|
+
if token and token.encode("utf-8") in body:
|
|
99
|
+
raise RuntimeError("control-plane upload contains credential material")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _execution_payload(store: SQLiteExecutionStore, snapshot: Any) -> dict[str, Any]:
|
|
103
|
+
execution_id = snapshot.execution_id.root
|
|
104
|
+
report = store.get_report(execution_id, event_limit=None, artifact_limit=None)
|
|
105
|
+
if report is None:
|
|
106
|
+
raise RuntimeError("execution data unavailable")
|
|
107
|
+
if snapshot.lifecycle.value != "finished" or snapshot.outcome is None:
|
|
108
|
+
raise RuntimeError("execution is not terminal")
|
|
109
|
+
if (
|
|
110
|
+
report.snapshot.execution_id != snapshot.execution_id
|
|
111
|
+
or report.snapshot.lifecycle.value != "finished"
|
|
112
|
+
or report.snapshot.outcome != snapshot.outcome
|
|
113
|
+
):
|
|
114
|
+
raise RuntimeError("execution report identity is invalid")
|
|
115
|
+
if report.events_truncated or report.artifacts_truncated:
|
|
116
|
+
raise RuntimeError("execution report is truncated")
|
|
117
|
+
if report.event_count != len(report.events) or report.artifact_count != len(
|
|
118
|
+
report.artifacts
|
|
119
|
+
):
|
|
120
|
+
raise RuntimeError("execution report is incomplete")
|
|
121
|
+
trace = store.get_trace_view(execution_id)
|
|
122
|
+
if trace is None:
|
|
123
|
+
raise RuntimeError("trace data unavailable")
|
|
124
|
+
spec = store.get_execution_spec(execution_id)
|
|
125
|
+
project_name = None
|
|
126
|
+
project_id = snapshot.project_id.root if snapshot.project_id is not None else None
|
|
127
|
+
get_project = getattr(store, "get_project", None)
|
|
128
|
+
if project_id is not None and callable(get_project):
|
|
129
|
+
project = get_project(project_id)
|
|
130
|
+
project_name = project[1] if project is not None else None
|
|
131
|
+
test_results: tuple[dict[str, Any], ...] = ()
|
|
132
|
+
if snapshot.run_id is not None:
|
|
133
|
+
test_results = tuple(
|
|
134
|
+
asdict(item)
|
|
135
|
+
for item in project_test_results(
|
|
136
|
+
project_test_attempts(store, snapshot.run_id.root), execution_id
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
public = build_report_envelope(execution_id, spec, report, trace, test_results)
|
|
140
|
+
public_execution = build_execution_envelope(snapshot, spec, project_name)
|
|
141
|
+
public_snapshot = public_execution["snapshot"]
|
|
142
|
+
return {
|
|
143
|
+
"transport_version": 1,
|
|
144
|
+
"snapshot": public_snapshot,
|
|
145
|
+
"execution": public_execution,
|
|
146
|
+
"report": public,
|
|
147
|
+
"sort_at": snapshot.created_at.isoformat(),
|
|
148
|
+
"lifecycle": snapshot.lifecycle.value,
|
|
149
|
+
"outcome": snapshot.outcome.value if snapshot.outcome is not None else None,
|
|
150
|
+
"project_id": project_id,
|
|
151
|
+
"project_name": project_name,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def upload_current_run(
|
|
156
|
+
feedback: Feedback,
|
|
157
|
+
store: SQLiteExecutionStore,
|
|
158
|
+
directory: str | os.PathLike[str],
|
|
159
|
+
*,
|
|
160
|
+
base_url: str,
|
|
161
|
+
token: str,
|
|
162
|
+
) -> None:
|
|
163
|
+
"""Upload summary, complete current-run executions, then publish.
|
|
164
|
+
|
|
165
|
+
Every body is cached before its first request, making retries byte-identical.
|
|
166
|
+
"""
|
|
167
|
+
parsed = urlparse(base_url)
|
|
168
|
+
if (
|
|
169
|
+
parsed.scheme != "https"
|
|
170
|
+
or not parsed.netloc
|
|
171
|
+
or parsed.path not in ("", "/")
|
|
172
|
+
or parsed.query
|
|
173
|
+
or parsed.fragment
|
|
174
|
+
):
|
|
175
|
+
raise RuntimeError("control-plane URL must be HTTPS")
|
|
176
|
+
if parsed.username is not None or parsed.password is not None:
|
|
177
|
+
raise RuntimeError("control-plane URL must not contain credentials")
|
|
178
|
+
if not token.startswith("m3pat_"):
|
|
179
|
+
raise RuntimeError("control-plane token must be a personal access token")
|
|
180
|
+
root = Path(directory) / "control-plane"
|
|
181
|
+
if root.exists() and root.is_symlink():
|
|
182
|
+
raise RuntimeError("upload cache path is a symlink")
|
|
183
|
+
root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
184
|
+
os.chmod(root, 0o700)
|
|
185
|
+
execution_cache = root / "executions"
|
|
186
|
+
if execution_cache.is_symlink():
|
|
187
|
+
raise RuntimeError("upload cache path is a symlink")
|
|
188
|
+
execution_cache.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
189
|
+
os.chmod(execution_cache, 0o700)
|
|
190
|
+
run_id = _id_value(feedback.run_id)
|
|
191
|
+
if not _SAFE_ID.fullmatch(run_id) or ".." in run_id:
|
|
192
|
+
raise RuntimeError("invalid run ID")
|
|
193
|
+
snapshots: list[Any] = []
|
|
194
|
+
offset = 0
|
|
195
|
+
while True:
|
|
196
|
+
page = store.list_executions(run_id=run_id, limit=100, offset=offset)
|
|
197
|
+
snapshots.extend(page.items)
|
|
198
|
+
offset += len(page.items)
|
|
199
|
+
if not page.items or offset >= page.total:
|
|
200
|
+
break
|
|
201
|
+
snapshots = [
|
|
202
|
+
item
|
|
203
|
+
for item in snapshots
|
|
204
|
+
if item.run_id is not None and item.run_id.root == run_id
|
|
205
|
+
]
|
|
206
|
+
execution_ids = [item.execution_id.root for item in snapshots]
|
|
207
|
+
if any(not _SAFE_ID.fullmatch(item) or ".." in item for item in execution_ids):
|
|
208
|
+
raise RuntimeError("invalid execution ID")
|
|
209
|
+
exported = json.loads(
|
|
210
|
+
(Path(directory) / "feedback.json").read_text(encoding="utf-8")
|
|
211
|
+
)
|
|
212
|
+
if not isinstance(exported, dict):
|
|
213
|
+
raise RuntimeError("feedback export is invalid")
|
|
214
|
+
summary = {
|
|
215
|
+
"transport_version": 1,
|
|
216
|
+
"execution_ids": execution_ids,
|
|
217
|
+
"baseline_run_id": _id_value(feedback.comparison.baseline_run_id)
|
|
218
|
+
if feedback.comparison is not None
|
|
219
|
+
else None,
|
|
220
|
+
"feedback": {
|
|
221
|
+
"version": "v2",
|
|
222
|
+
"feedback": neutralize_response("/api/v2/feedback/{run_id}", exported),
|
|
223
|
+
},
|
|
224
|
+
}
|
|
225
|
+
summary_path = root / "summary.json"
|
|
226
|
+
if summary_path.is_symlink():
|
|
227
|
+
raise RuntimeError("upload cache file is a symlink")
|
|
228
|
+
summary_bytes = (
|
|
229
|
+
summary_path.read_bytes() if summary_path.is_file() else _json_bytes(summary)
|
|
230
|
+
)
|
|
231
|
+
_validate_body(summary_bytes, token)
|
|
232
|
+
if len(summary_bytes) > 1 << 20:
|
|
233
|
+
raise RuntimeError("control-plane summary is too large")
|
|
234
|
+
if not summary_path.is_file():
|
|
235
|
+
_cache(summary_path, summary_bytes)
|
|
236
|
+
base = base_url.rstrip("/") + "/v1/runs/" + quote(run_id, safe="")
|
|
237
|
+
_post(base + "/report", token, summary_bytes)
|
|
238
|
+
for snapshot in snapshots:
|
|
239
|
+
execution_id = snapshot.execution_id.root
|
|
240
|
+
path = execution_cache / (execution_id + ".json")
|
|
241
|
+
if path.is_symlink():
|
|
242
|
+
raise RuntimeError("upload cache file is a symlink")
|
|
243
|
+
body = (
|
|
244
|
+
path.read_bytes()
|
|
245
|
+
if path.is_file()
|
|
246
|
+
else _json_bytes(_execution_payload(store, snapshot))
|
|
247
|
+
)
|
|
248
|
+
_validate_body(body, token)
|
|
249
|
+
if not path.is_file():
|
|
250
|
+
_cache(path, body)
|
|
251
|
+
_post(
|
|
252
|
+
base + "/executions/" + quote(execution_id, safe="") + "/report",
|
|
253
|
+
token,
|
|
254
|
+
body,
|
|
255
|
+
)
|
|
256
|
+
_post(base + "/publish", token, _json_bytes({"transport_version": 1}))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _cache(path: Path, data: bytes) -> None:
|
|
260
|
+
fd, temp = tempfile.mkstemp(prefix=".upload-", dir=path.parent)
|
|
261
|
+
try:
|
|
262
|
+
os.fchmod(fd, 0o600)
|
|
263
|
+
with os.fdopen(fd, "wb") as stream:
|
|
264
|
+
stream.write(data)
|
|
265
|
+
stream.flush()
|
|
266
|
+
os.fsync(stream.fileno())
|
|
267
|
+
os.replace(temp, path)
|
|
268
|
+
os.chmod(path, 0o600)
|
|
269
|
+
finally:
|
|
270
|
+
try:
|
|
271
|
+
os.unlink(temp)
|
|
272
|
+
except FileNotFoundError:
|
|
273
|
+
pass
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
__all__ = ["upload_current_run"]
|
|
277
|
+
|
|
278
|
+
_OPENER = request.build_opener(_NoRedirect())
|
m3_cli/doctor.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""Implementation of the safe ``m3 doctor`` command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.metadata
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from dotenv import dotenv_values
|
|
12
|
+
|
|
13
|
+
from m3.configuration import ConfigError, load_config
|
|
14
|
+
from m3.services.probes import ProbeKind, ProbeRequest, Probes
|
|
15
|
+
|
|
16
|
+
from .errors import CLIError
|
|
17
|
+
|
|
18
|
+
_TRANSPORT_MODULES = {
|
|
19
|
+
"stdio": "mcp.client.stdio",
|
|
20
|
+
"streamable_http": "mcp.client.streamable_http",
|
|
21
|
+
}
|
|
22
|
+
_STORAGE_MODULES = {"sqlite": "sqlalchemy"}
|
|
23
|
+
_ENV_PREFIX = "M3_"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DoctorCLIError(CLIError):
|
|
27
|
+
"""A user-facing doctor argument or configuration error."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DoctorArgumentError(DoctorCLIError):
|
|
31
|
+
"""A safe diagnostic for an invalid doctor argument combination."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DoctorProjectPythonError(DoctorCLIError):
|
|
35
|
+
"""A safe diagnostic for an unavailable or mismatched project Python."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DoctorConfigurationError(DoctorCLIError):
|
|
39
|
+
"""A value-free configuration diagnostic at the CLI boundary."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, error: ConfigError) -> None:
|
|
42
|
+
self.configuration_error = error
|
|
43
|
+
super().__init__("invalid configuration")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _configuration_error_payload(error: ConfigError) -> dict[str, str]:
|
|
47
|
+
return {
|
|
48
|
+
"code": str(error.code),
|
|
49
|
+
"field": str(error.field),
|
|
50
|
+
"origin": str(error.origin),
|
|
51
|
+
"reason": str(error.reason),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _read_selected_environment(path: Path | None) -> dict[str, str] | None:
|
|
56
|
+
if path is None:
|
|
57
|
+
return None
|
|
58
|
+
try:
|
|
59
|
+
if not path.is_file():
|
|
60
|
+
raise DoctorCLIError("environment file unavailable")
|
|
61
|
+
# No interpolation through ambient variables: the selected file is an
|
|
62
|
+
# explicit source, while ambient M3_* values override it below.
|
|
63
|
+
values = dotenv_values(path, interpolate=False)
|
|
64
|
+
selected = {
|
|
65
|
+
key: value
|
|
66
|
+
for key, value in values.items()
|
|
67
|
+
if key.startswith(_ENV_PREFIX) and isinstance(value, str)
|
|
68
|
+
}
|
|
69
|
+
selected.update(
|
|
70
|
+
{
|
|
71
|
+
key: value
|
|
72
|
+
for key, value in os.environ.items()
|
|
73
|
+
if key.startswith(_ENV_PREFIX)
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
return selected
|
|
77
|
+
except (OSError, TypeError, ValueError):
|
|
78
|
+
raise DoctorCLIError("environment file unavailable") from None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _parse_requirement(value: str) -> tuple[str, str | None]:
|
|
82
|
+
if value == "config":
|
|
83
|
+
return "config", None
|
|
84
|
+
if value.startswith("config:"):
|
|
85
|
+
field = value.partition(":")[2]
|
|
86
|
+
if field in {"artifact_policy", "protocol_revision", "telemetry_enabled"}:
|
|
87
|
+
return "config", field
|
|
88
|
+
raise DoctorCLIError("invalid requirement")
|
|
89
|
+
try:
|
|
90
|
+
kind, target = value.split(":", 1)
|
|
91
|
+
except ValueError:
|
|
92
|
+
raise DoctorCLIError("invalid requirement") from None
|
|
93
|
+
if not target or kind not in {
|
|
94
|
+
"binary",
|
|
95
|
+
"harness",
|
|
96
|
+
"protocol",
|
|
97
|
+
"transport",
|
|
98
|
+
"storage",
|
|
99
|
+
}:
|
|
100
|
+
raise DoctorCLIError("invalid requirement")
|
|
101
|
+
if kind == "transport" and target not in _TRANSPORT_MODULES:
|
|
102
|
+
raise DoctorCLIError("invalid requirement")
|
|
103
|
+
if kind == "storage" and target not in {"memory", "sqlite"}:
|
|
104
|
+
raise DoctorCLIError("invalid requirement")
|
|
105
|
+
return kind, target
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _probe_label(kind: str, target: str) -> str:
|
|
109
|
+
"""Use a useful fixed/allowlisted result name without arbitrary targets."""
|
|
110
|
+
|
|
111
|
+
if kind in {"binary", "harness", "protocol"}:
|
|
112
|
+
return kind
|
|
113
|
+
return target
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _requests(requirements: list[tuple[str, str | None]]) -> list[ProbeRequest]:
|
|
117
|
+
requests: list[ProbeRequest] = []
|
|
118
|
+
for kind, target in requirements:
|
|
119
|
+
if kind == "config":
|
|
120
|
+
continue
|
|
121
|
+
assert target is not None
|
|
122
|
+
if kind == "transport":
|
|
123
|
+
requests.append(
|
|
124
|
+
ProbeRequest(
|
|
125
|
+
ProbeKind.TRANSPORT,
|
|
126
|
+
_probe_label(kind, target),
|
|
127
|
+
transport=target,
|
|
128
|
+
module=_TRANSPORT_MODULES[target],
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
elif kind == "storage":
|
|
132
|
+
requests.append(
|
|
133
|
+
ProbeRequest(
|
|
134
|
+
ProbeKind.STORAGE,
|
|
135
|
+
_probe_label(kind, target),
|
|
136
|
+
module=_STORAGE_MODULES.get(target),
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
else:
|
|
140
|
+
requests.append(
|
|
141
|
+
ProbeRequest(
|
|
142
|
+
getattr(ProbeKind, kind.upper()),
|
|
143
|
+
_probe_label(kind, target),
|
|
144
|
+
executable=target,
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
return requests
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _requirement_label(kind: str, target: str | None) -> str:
|
|
151
|
+
"""Return a fixed or allowlisted label that never contains executable targets."""
|
|
152
|
+
|
|
153
|
+
if target is None:
|
|
154
|
+
return kind
|
|
155
|
+
return kind if kind in {"binary", "harness", "protocol"} else f"{kind}:{target}"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _json_value(value: Any) -> Any:
|
|
159
|
+
if hasattr(value, "model_dump"):
|
|
160
|
+
return value.model_dump(mode="json")
|
|
161
|
+
return value
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _redact_requirement_targets(value: Any, targets: set[str]) -> Any:
|
|
165
|
+
if isinstance(value, str):
|
|
166
|
+
for target in targets:
|
|
167
|
+
if target:
|
|
168
|
+
value = value.replace(target, "[REDACTED]")
|
|
169
|
+
return value
|
|
170
|
+
if isinstance(value, list):
|
|
171
|
+
return [_redact_requirement_targets(item, targets) for item in value]
|
|
172
|
+
if isinstance(value, dict):
|
|
173
|
+
return {
|
|
174
|
+
key: _redact_requirement_targets(item, targets)
|
|
175
|
+
for key, item in value.items()
|
|
176
|
+
}
|
|
177
|
+
return value
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _safe_probe_results(
|
|
181
|
+
results: list[Any], requirements: list[tuple[str, str | None]]
|
|
182
|
+
) -> list[dict[str, Any]]:
|
|
183
|
+
"""Project probe results without exposing executable paths or arguments."""
|
|
184
|
+
|
|
185
|
+
targets = {
|
|
186
|
+
target
|
|
187
|
+
for kind, target in requirements
|
|
188
|
+
if kind in {"binary", "harness", "protocol"} and target
|
|
189
|
+
}
|
|
190
|
+
safe_results: list[dict[str, Any]] = []
|
|
191
|
+
for result in results:
|
|
192
|
+
safe = _redact_requirement_targets(_json_value(result), targets)
|
|
193
|
+
if not isinstance(safe, dict):
|
|
194
|
+
safe_results.append(safe)
|
|
195
|
+
continue
|
|
196
|
+
evidence = safe.get("evidence")
|
|
197
|
+
if isinstance(evidence, dict):
|
|
198
|
+
evidence["resolved_executable"] = None
|
|
199
|
+
details = evidence.get("details")
|
|
200
|
+
if isinstance(details, dict):
|
|
201
|
+
details.pop("resolved_executable", None)
|
|
202
|
+
safe_results.append(safe)
|
|
203
|
+
return safe_results
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def run(args: Any) -> tuple[int, dict[str, Any]]:
|
|
207
|
+
requirements = [_parse_requirement(value) for value in args.require]
|
|
208
|
+
if not requirements:
|
|
209
|
+
requirements = [("config", None), ("storage", "memory")]
|
|
210
|
+
if args.env_file is not None and not any(
|
|
211
|
+
kind == "config" for kind, _ in requirements
|
|
212
|
+
):
|
|
213
|
+
raise DoctorArgumentError("--env-file requires a config requirement")
|
|
214
|
+
environment = _read_selected_environment(args.env_file)
|
|
215
|
+
if args.project_root is not None:
|
|
216
|
+
try:
|
|
217
|
+
if not args.project_root.is_dir():
|
|
218
|
+
raise DoctorCLIError("project root unavailable")
|
|
219
|
+
except OSError:
|
|
220
|
+
raise DoctorCLIError("project root unavailable") from None
|
|
221
|
+
configuration: dict[str, Any] | None = None
|
|
222
|
+
project_python: dict[str, Any] | None = None
|
|
223
|
+
from .supervisor import (
|
|
224
|
+
ProjectPythonError,
|
|
225
|
+
resolve_project_python,
|
|
226
|
+
validate_project_python,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
root = (args.project_root or Path.cwd()).resolve()
|
|
230
|
+
try:
|
|
231
|
+
cli_version = importlib.metadata.version("sf-m3-cli")
|
|
232
|
+
bundled_sdk_version = importlib.metadata.version("sf-m3")
|
|
233
|
+
except importlib.metadata.PackageNotFoundError:
|
|
234
|
+
raise DoctorCLIError(
|
|
235
|
+
"the CLI installation is incomplete; reinstall sf-m3-cli"
|
|
236
|
+
) from None
|
|
237
|
+
cli = {
|
|
238
|
+
"status": "ready" if cli_version == bundled_sdk_version else "not ready",
|
|
239
|
+
"version": cli_version,
|
|
240
|
+
"sdk_version": bundled_sdk_version,
|
|
241
|
+
}
|
|
242
|
+
cli_ready = cli["status"] == "ready"
|
|
243
|
+
source = None
|
|
244
|
+
selected_path = None
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
selected = resolve_project_python(
|
|
248
|
+
getattr(args, "python", None),
|
|
249
|
+
project_root=root,
|
|
250
|
+
fallback_to_system=False,
|
|
251
|
+
)
|
|
252
|
+
selected_path = selected
|
|
253
|
+
if getattr(args, "python", None) is not None:
|
|
254
|
+
source = "--python"
|
|
255
|
+
elif os.environ.get("VIRTUAL_ENV"):
|
|
256
|
+
source = "VIRTUAL_ENV"
|
|
257
|
+
elif os.environ.get("CONDA_PREFIX"):
|
|
258
|
+
source = "CONDA_PREFIX"
|
|
259
|
+
else:
|
|
260
|
+
source = "project .venv"
|
|
261
|
+
version = validate_project_python(
|
|
262
|
+
selected,
|
|
263
|
+
cli_sdk_version=bundled_sdk_version,
|
|
264
|
+
project_root=root,
|
|
265
|
+
)
|
|
266
|
+
except ProjectPythonError as error:
|
|
267
|
+
reason = str(error)
|
|
268
|
+
if reason.startswith("no project environment is configured"):
|
|
269
|
+
project_python = {"status": "not ready", "reason": reason}
|
|
270
|
+
elif (
|
|
271
|
+
"missing required M3 packages" in reason
|
|
272
|
+
or "does not match CLI SDK" in reason
|
|
273
|
+
or "distribution version could not be determined" in reason
|
|
274
|
+
):
|
|
275
|
+
project_python = {"status": "not ready", "reason": reason, "source": source}
|
|
276
|
+
else:
|
|
277
|
+
raise DoctorProjectPythonError(reason) from None
|
|
278
|
+
else:
|
|
279
|
+
project_python = {
|
|
280
|
+
"status": "ready",
|
|
281
|
+
"version": version,
|
|
282
|
+
"source": source,
|
|
283
|
+
"executable": str(selected_path) if selected_path is not None else None,
|
|
284
|
+
}
|
|
285
|
+
include_config = any(kind == "config" for kind, _ in requirements)
|
|
286
|
+
if include_config:
|
|
287
|
+
try:
|
|
288
|
+
config = load_config(env=environment, cwd=args.project_root)
|
|
289
|
+
except ConfigError as error:
|
|
290
|
+
raise DoctorConfigurationError(error) from None
|
|
291
|
+
except (OSError, TypeError, ValueError):
|
|
292
|
+
raise DoctorCLIError("invalid configuration") from None
|
|
293
|
+
configuration = {"status": "ready", "settings": _json_value(config)}
|
|
294
|
+
probe_report = Probes().probe_requested(_requests(requirements))
|
|
295
|
+
results = _safe_probe_results(list(probe_report.results), requirements)
|
|
296
|
+
ready = (
|
|
297
|
+
cli_ready
|
|
298
|
+
and project_python["status"] == "ready"
|
|
299
|
+
and (configuration is None or configuration["status"] == "ready")
|
|
300
|
+
and probe_report.readiness.ready
|
|
301
|
+
)
|
|
302
|
+
report = {
|
|
303
|
+
"ready": ready,
|
|
304
|
+
"cli": cli,
|
|
305
|
+
"project_root": str(root),
|
|
306
|
+
"requirements": [
|
|
307
|
+
_requirement_label(kind, target) for kind, target in requirements
|
|
308
|
+
],
|
|
309
|
+
"configuration": configuration,
|
|
310
|
+
"project_python": project_python,
|
|
311
|
+
"results": results,
|
|
312
|
+
}
|
|
313
|
+
return (0 if ready else 1), report
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def print_human(report: dict[str, Any]) -> None:
|
|
317
|
+
print(f"m3 doctor: {'ready' if report['ready'] else 'not ready'}")
|
|
318
|
+
cli = report.get("cli")
|
|
319
|
+
if cli is not None:
|
|
320
|
+
print(f"M3 CLI {cli.get('version')}: {cli.get('status')}")
|
|
321
|
+
if cli.get("sdk_version") != cli.get("version"):
|
|
322
|
+
print(f"bundled SDK: {cli.get('sdk_version')}")
|
|
323
|
+
project_python = report.get("project_python")
|
|
324
|
+
if project_python is not None:
|
|
325
|
+
detail = (
|
|
326
|
+
f" (m3 {project_python['version']})"
|
|
327
|
+
if project_python.get("version")
|
|
328
|
+
else ""
|
|
329
|
+
)
|
|
330
|
+
print(f"project environment: {project_python['status']}{detail}")
|
|
331
|
+
if project_python.get("source"):
|
|
332
|
+
print(f"project environment source: {project_python['source']}")
|
|
333
|
+
if project_python.get("executable"):
|
|
334
|
+
print(f"project Python: {project_python['executable']}")
|
|
335
|
+
if project_python.get("reason"):
|
|
336
|
+
print(f"reason: {project_python['reason']}")
|
|
337
|
+
if report.get("configuration") is not None:
|
|
338
|
+
print(f"config: {report['configuration']['status']}")
|
|
339
|
+
for result in report["results"]:
|
|
340
|
+
capability = result["capability"]
|
|
341
|
+
reason = capability.get("reason")
|
|
342
|
+
print(
|
|
343
|
+
f"{capability['name']}: {capability['status']}"
|
|
344
|
+
+ (f" ({reason})" if reason else "")
|
|
345
|
+
)
|
|
346
|
+
if not report.get("ready"):
|
|
347
|
+
if cli := report.get("cli"):
|
|
348
|
+
if cli.get("status") != "ready":
|
|
349
|
+
print("Next: reinstall sf-m3-cli")
|
|
350
|
+
return
|
|
351
|
+
if project_python is not None and project_python.get("status") != "ready":
|
|
352
|
+
print("Next: m3 setup")
|
|
353
|
+
else:
|
|
354
|
+
print("Next: m3 doctor")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def print_configuration_error(error: ConfigError) -> None:
|
|
358
|
+
details = _configuration_error_payload(error)
|
|
359
|
+
print(
|
|
360
|
+
"m3 doctor: configuration error "
|
|
361
|
+
f"(code={details['code']} field={details['field']} "
|
|
362
|
+
f"origin={details['origin']} reason={details['reason']})",
|
|
363
|
+
file=sys.stderr,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
__all__ = [
|
|
368
|
+
"DoctorArgumentError",
|
|
369
|
+
"DoctorCLIError",
|
|
370
|
+
"DoctorConfigurationError",
|
|
371
|
+
"DoctorProjectPythonError",
|
|
372
|
+
"_read_selected_environment",
|
|
373
|
+
"print_configuration_error",
|
|
374
|
+
"print_human",
|
|
375
|
+
"run",
|
|
376
|
+
]
|