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 ADDED
@@ -0,0 +1,5 @@
1
+ """The standalone M3 command line interface."""
2
+
3
+ from .main import main
4
+
5
+ __all__ = ["main"]
m3_cli/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Module entry point for ``python -m m3_cli``."""
2
+
3
+ from .main import main
4
+
5
+ raise SystemExit(main())
m3_cli/branding.py ADDED
@@ -0,0 +1,10 @@
1
+ """Shared M3 CLI branding."""
2
+
3
+ M3_ASCII_ART = """\
4
+ ███╗ ███╗ ███████╗░
5
+ ████╗ ████║ ██║░
6
+ ██╔████╔██║ ███████║░
7
+ ██║╚██╔╝██║ ██║░
8
+ ██║ ╚═╝ ██║ ███████║░
9
+ ╚═╝ ╚═╝ ╚══════╝░
10
+ ░░░░░░░░░░░░░░░░░░░░"""
@@ -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
+ ]
m3_cli/errors.py ADDED
@@ -0,0 +1,10 @@
1
+ """Safe errors shared by command-line subcommands."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class CLIError(ValueError):
7
+ """A user-facing CLI error whose message contains no untrusted values."""
8
+
9
+
10
+ __all__ = ["CLIError"]