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
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Project scaffolding for the standalone M3 CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import typing
|
|
9
|
+
import uuid
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
if typing.TYPE_CHECKING:
|
|
13
|
+
import tomli as _tomllib
|
|
14
|
+
elif sys.version_info >= (3, 11):
|
|
15
|
+
import tomllib as _tomllib
|
|
16
|
+
else: # pragma: no cover - exercised by Python 3.10
|
|
17
|
+
import tomli as _tomllib
|
|
18
|
+
|
|
19
|
+
DEFAULT_SUITE = "mcp-behavior"
|
|
20
|
+
_ENV_EXAMPLE = """# Copy to .env if it does not exist; otherwise add the keys you need.
|
|
21
|
+
# Keep real keys out of .env.example and ensure .env is gitignored.
|
|
22
|
+
|
|
23
|
+
# Agent model providers
|
|
24
|
+
OPENCODE_API_KEY=
|
|
25
|
+
OPENAI_API_KEY=
|
|
26
|
+
ANTHROPIC_API_KEY=
|
|
27
|
+
|
|
28
|
+
# LLMJudge(model=...) reads this key by default.
|
|
29
|
+
M3_JUDGE_API_KEY=
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _project_root(value: Path | None) -> Path:
|
|
34
|
+
if value is not None:
|
|
35
|
+
return value.expanduser().resolve()
|
|
36
|
+
try:
|
|
37
|
+
result = subprocess.run(
|
|
38
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
39
|
+
check=True,
|
|
40
|
+
capture_output=True,
|
|
41
|
+
text=True,
|
|
42
|
+
)
|
|
43
|
+
except (OSError, subprocess.CalledProcessError):
|
|
44
|
+
return Path.cwd().resolve()
|
|
45
|
+
return Path(result.stdout.strip()).resolve()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _validate(value: str, label: str) -> str:
|
|
49
|
+
value = value.strip()
|
|
50
|
+
if not value:
|
|
51
|
+
raise ValueError(f"{label} must not be blank")
|
|
52
|
+
if len(value) > 256:
|
|
53
|
+
raise ValueError(f"{label} must be at most 256 characters")
|
|
54
|
+
if any(ord(char) < 32 or ord(char) == 127 for char in value):
|
|
55
|
+
raise ValueError(f"{label} must not contain control characters")
|
|
56
|
+
return value
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _toml_string(value: str) -> str:
|
|
60
|
+
return (
|
|
61
|
+
'"' + value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") + '"'
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _starter(suite: str) -> str:
|
|
66
|
+
return f'''import pytest
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@pytest.mark.m3(suite_name={_toml_string(suite)})
|
|
70
|
+
@pytest.mark.skip(reason="TODO: implement this test for your MCP project")
|
|
71
|
+
def test_mcp_behavior() -> None:
|
|
72
|
+
"""Verify the MCP behavior this suite is meant to protect."""
|
|
73
|
+
# TODO: connect to your MCP server with MCPTestKit, or add the
|
|
74
|
+
# agent fixture to test how an agent uses the server.
|
|
75
|
+
# TODO: assert the expected result or captured tool call.
|
|
76
|
+
pytest.fail("Replace this starter with a real M3 assertion")
|
|
77
|
+
'''
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _identity(config: Path) -> dict[str, object] | None:
|
|
81
|
+
try:
|
|
82
|
+
data = _tomllib.loads(config.read_text(encoding="utf-8"))
|
|
83
|
+
project_id = data.get("project_id")
|
|
84
|
+
project_name = data.get("project_name")
|
|
85
|
+
if (
|
|
86
|
+
type(data.get("schema_version")) is not int
|
|
87
|
+
or data["schema_version"] != 1
|
|
88
|
+
or not isinstance(project_id, str)
|
|
89
|
+
or not isinstance(project_name, str)
|
|
90
|
+
or _validate(project_name, "project name") != project_name
|
|
91
|
+
or str(uuid.UUID(project_id)) != project_id
|
|
92
|
+
):
|
|
93
|
+
return None
|
|
94
|
+
return data
|
|
95
|
+
except (OSError, UnicodeError, ValueError, TypeError):
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _style(value: str, code: str) -> str:
|
|
100
|
+
if sys.stdout.isatty() and "NO_COLOR" not in os.environ:
|
|
101
|
+
return f"\x1b[{code}m{value}\x1b[0m"
|
|
102
|
+
return value
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _create_env_example(path: Path) -> bool:
|
|
106
|
+
if path.exists() or path.is_symlink():
|
|
107
|
+
return False
|
|
108
|
+
with path.open("x", encoding="utf-8", newline="\n") as stream:
|
|
109
|
+
try:
|
|
110
|
+
stream.write(_ENV_EXAMPLE)
|
|
111
|
+
except OSError:
|
|
112
|
+
path.unlink(missing_ok=True)
|
|
113
|
+
raise
|
|
114
|
+
return True
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def run(args: object) -> int:
|
|
118
|
+
root = _project_root(getattr(args, "project_root", None))
|
|
119
|
+
if not root.is_dir():
|
|
120
|
+
print("m3 init: project root is unavailable", file=sys.stderr)
|
|
121
|
+
return 2
|
|
122
|
+
config = root / "m3.toml"
|
|
123
|
+
starter = root / "tests" / "test_m3_starter.py"
|
|
124
|
+
env_example = root / ".env.example"
|
|
125
|
+
config_present = config.exists() or config.is_symlink()
|
|
126
|
+
starter_present = starter.exists() or starter.is_symlink()
|
|
127
|
+
data = (
|
|
128
|
+
_identity(config)
|
|
129
|
+
if config_present and config.is_file() and not config.is_symlink()
|
|
130
|
+
else None
|
|
131
|
+
)
|
|
132
|
+
if (
|
|
133
|
+
data is not None
|
|
134
|
+
and starter_present
|
|
135
|
+
and starter.is_file()
|
|
136
|
+
and not starter.is_symlink()
|
|
137
|
+
):
|
|
138
|
+
try:
|
|
139
|
+
created_example = _create_env_example(env_example)
|
|
140
|
+
except OSError:
|
|
141
|
+
print("m3 init: could not create .env.example", file=sys.stderr)
|
|
142
|
+
return 2
|
|
143
|
+
print(f"m3 is already initialized at {root}")
|
|
144
|
+
print(f"Project: {data['project_name']}")
|
|
145
|
+
print(f"Files: {config}, {starter}")
|
|
146
|
+
if created_example:
|
|
147
|
+
print(f"Created {env_example}")
|
|
148
|
+
print("Existing project files unchanged; supplied names were not applied.")
|
|
149
|
+
else:
|
|
150
|
+
print("Nothing changed; supplied names were not applied.")
|
|
151
|
+
print("Edit project_name in m3.toml to rename the project; keep project_id.")
|
|
152
|
+
return 0
|
|
153
|
+
if config_present or starter_present:
|
|
154
|
+
print(
|
|
155
|
+
f"m3 init: partial initialization found at {root}; "
|
|
156
|
+
"repair m3.toml and tests/test_m3_starter.py, then retry.",
|
|
157
|
+
file=sys.stderr,
|
|
158
|
+
)
|
|
159
|
+
return 2
|
|
160
|
+
|
|
161
|
+
print(_style("M3 init", "1;36"))
|
|
162
|
+
project_arg = getattr(args, "project_name", None)
|
|
163
|
+
suite_arg = getattr(args, "suite", None)
|
|
164
|
+
interactive = sys.stdin.isatty()
|
|
165
|
+
try:
|
|
166
|
+
if project_arg is None:
|
|
167
|
+
if not interactive:
|
|
168
|
+
raise ValueError(
|
|
169
|
+
"--project-name is required when stdin is not interactive"
|
|
170
|
+
)
|
|
171
|
+
default = root.name or "project"
|
|
172
|
+
answer = input(f"Project name [{default}]: ").strip()
|
|
173
|
+
project_arg = answer or default
|
|
174
|
+
if suite_arg is None:
|
|
175
|
+
if not interactive:
|
|
176
|
+
raise ValueError("--suite is required when stdin is not interactive")
|
|
177
|
+
answer = input(f"Suite name [{DEFAULT_SUITE}]: ").strip()
|
|
178
|
+
suite_arg = answer or DEFAULT_SUITE
|
|
179
|
+
project_name = _validate(str(project_arg), "project name")
|
|
180
|
+
suite_name = _validate(str(suite_arg), "suite name")
|
|
181
|
+
except (EOFError, KeyboardInterrupt):
|
|
182
|
+
print("m3 init: cancelled", file=sys.stderr)
|
|
183
|
+
return 130
|
|
184
|
+
except ValueError as exc:
|
|
185
|
+
print(f"m3 init: {exc}", file=sys.stderr)
|
|
186
|
+
return 2
|
|
187
|
+
|
|
188
|
+
config_text = (
|
|
189
|
+
"# M3 project identity. Keep project_id stable when renaming.\n"
|
|
190
|
+
"schema_version = 1\n"
|
|
191
|
+
f"project_id = {_toml_string(str(uuid.uuid4()))}\n"
|
|
192
|
+
f"project_name = {_toml_string(project_name)}\n"
|
|
193
|
+
)
|
|
194
|
+
created: list[Path] = []
|
|
195
|
+
tests_dir = starter.parent
|
|
196
|
+
created_tests_dir = False
|
|
197
|
+
try:
|
|
198
|
+
if tests_dir.is_symlink():
|
|
199
|
+
raise OSError("tests path is a symlink")
|
|
200
|
+
if not tests_dir.exists():
|
|
201
|
+
tests_dir.mkdir()
|
|
202
|
+
created_tests_dir = True
|
|
203
|
+
with config.open("x", encoding="utf-8", newline="\n") as stream:
|
|
204
|
+
created.append(config)
|
|
205
|
+
stream.write(config_text)
|
|
206
|
+
with starter.open("x", encoding="utf-8", newline="\n") as stream:
|
|
207
|
+
created.append(starter)
|
|
208
|
+
stream.write(_starter(suite_name))
|
|
209
|
+
if _create_env_example(env_example):
|
|
210
|
+
created.append(env_example)
|
|
211
|
+
except OSError:
|
|
212
|
+
for path in reversed(created):
|
|
213
|
+
try:
|
|
214
|
+
path.unlink()
|
|
215
|
+
except OSError:
|
|
216
|
+
pass
|
|
217
|
+
if created_tests_dir:
|
|
218
|
+
try:
|
|
219
|
+
tests_dir.rmdir()
|
|
220
|
+
except OSError:
|
|
221
|
+
pass
|
|
222
|
+
print("m3 init: could not create project files", file=sys.stderr)
|
|
223
|
+
return 2
|
|
224
|
+
print(_style(f"Initialized M3 project {project_name!r} at {root}", "32"))
|
|
225
|
+
print(f"Created {config}")
|
|
226
|
+
print(f"Created {starter}")
|
|
227
|
+
if env_example in created:
|
|
228
|
+
print(f"Created {env_example}")
|
|
229
|
+
print("Next: m3 setup")
|
|
230
|
+
print(
|
|
231
|
+
"For agent or judge tests, add the keys you use to .env (copy .env.example if needed)."
|
|
232
|
+
)
|
|
233
|
+
print("Pass --env-file .env to m3 test to load those keys.")
|
|
234
|
+
print("Then implement and unskip the test, and run:")
|
|
235
|
+
print(f" m3 test --suite {suite_name} -- tests/test_m3_starter.py")
|
|
236
|
+
return 0
|
m3_cli/main.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Argument parsing and command dispatch for the standalone CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import NoReturn
|
|
10
|
+
|
|
11
|
+
from . import doctor, init, runtime, setup
|
|
12
|
+
from .branding import M3_ASCII_ART
|
|
13
|
+
from .errors import CLIError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _RedactingArgumentParser(argparse.ArgumentParser):
|
|
17
|
+
def error(self, _message: str) -> NoReturn:
|
|
18
|
+
raise CLIError("invalid command or configuration")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parser() -> argparse.ArgumentParser:
|
|
22
|
+
parser = _RedactingArgumentParser(
|
|
23
|
+
prog="m3",
|
|
24
|
+
description=M3_ASCII_ART,
|
|
25
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
26
|
+
)
|
|
27
|
+
subparsers = parser.add_subparsers(
|
|
28
|
+
dest="command", required=True, parser_class=_RedactingArgumentParser
|
|
29
|
+
)
|
|
30
|
+
doctor_parser = subparsers.add_parser(
|
|
31
|
+
"doctor", help="check explicitly requested SDK capabilities"
|
|
32
|
+
)
|
|
33
|
+
doctor_parser.add_argument(
|
|
34
|
+
"--require",
|
|
35
|
+
action="append",
|
|
36
|
+
default=[],
|
|
37
|
+
metavar="KIND:TARGET",
|
|
38
|
+
help=(
|
|
39
|
+
"require config, binary:<exe>, harness:<exe>, protocol:<exe>, "
|
|
40
|
+
"transport:<stdio|streamable_http>, or storage:<memory|sqlite>"
|
|
41
|
+
),
|
|
42
|
+
)
|
|
43
|
+
doctor_parser.add_argument(
|
|
44
|
+
"--project-root", type=Path, help="project root used for pyproject discovery"
|
|
45
|
+
)
|
|
46
|
+
doctor_parser.add_argument(
|
|
47
|
+
"--python", type=Path, metavar="PATH", help="Python used for project checks"
|
|
48
|
+
)
|
|
49
|
+
doctor_parser.add_argument(
|
|
50
|
+
"--env-file", type=Path, help="explicit dotenv file; cwd .env is never searched"
|
|
51
|
+
)
|
|
52
|
+
doctor_parser.add_argument(
|
|
53
|
+
"--json", action="store_true", help="emit a machine-readable report"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
setup_parser = subparsers.add_parser(
|
|
57
|
+
"setup", help="install the SDK into a project environment"
|
|
58
|
+
)
|
|
59
|
+
setup_parser.add_argument(
|
|
60
|
+
"--project-root", type=Path, help="project root used for environment setup"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
init_parser = subparsers.add_parser(
|
|
64
|
+
"init", help="create a project identity and pytest starter test"
|
|
65
|
+
)
|
|
66
|
+
init_parser.add_argument("--project-root", type=Path, help="project root")
|
|
67
|
+
init_parser.add_argument(
|
|
68
|
+
"--project-name", help="project name (prompts when omitted)"
|
|
69
|
+
)
|
|
70
|
+
init_parser.add_argument(
|
|
71
|
+
"--suite", help="starter suite name (prompts when omitted)"
|
|
72
|
+
)
|
|
73
|
+
setup_parser.add_argument(
|
|
74
|
+
"--python",
|
|
75
|
+
type=Path,
|
|
76
|
+
metavar="PATH",
|
|
77
|
+
help="isolated Python environment to update",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
test = subparsers.add_parser("test", help="run pytest")
|
|
81
|
+
test.add_argument(
|
|
82
|
+
"--python", type=Path, metavar="PATH", help="Python used to run pytest"
|
|
83
|
+
)
|
|
84
|
+
test.add_argument(
|
|
85
|
+
"--project-root", type=Path, help="project root used for pytest and M3 state"
|
|
86
|
+
)
|
|
87
|
+
test.add_argument(
|
|
88
|
+
"--results-db", type=Path, metavar="PATH", help="SQLite history database"
|
|
89
|
+
)
|
|
90
|
+
test.add_argument(
|
|
91
|
+
"--baseline", metavar="RUN_ID", help="compare feedback with a previous run"
|
|
92
|
+
)
|
|
93
|
+
test.add_argument("--runtime", choices=("system", "managed"), default="system")
|
|
94
|
+
test.add_argument(
|
|
95
|
+
"--harness",
|
|
96
|
+
action="append",
|
|
97
|
+
default=[],
|
|
98
|
+
metavar="KIND[@VERSION]=MODEL[,MODEL...]",
|
|
99
|
+
)
|
|
100
|
+
test.add_argument("--harness-cache-dir", type=Path, default=None, metavar="PATH")
|
|
101
|
+
test.add_argument("--trials", type=int, default=None, metavar="N")
|
|
102
|
+
test.add_argument("--suite", type=str, default=None, metavar="NAME")
|
|
103
|
+
test.add_argument(
|
|
104
|
+
"--execution-timeout",
|
|
105
|
+
type=float,
|
|
106
|
+
default=None,
|
|
107
|
+
metavar="SECONDS",
|
|
108
|
+
help="deadline for each selected agent execution",
|
|
109
|
+
)
|
|
110
|
+
test.add_argument("--judge-max-requests", type=int, default=None, metavar="N")
|
|
111
|
+
test.add_argument(
|
|
112
|
+
"--credential-env",
|
|
113
|
+
action="append",
|
|
114
|
+
default=[],
|
|
115
|
+
metavar="[KIND:]TARGET=SOURCE",
|
|
116
|
+
)
|
|
117
|
+
test.add_argument("--env-file", type=Path, default=None, metavar="PATH")
|
|
118
|
+
test.add_argument(
|
|
119
|
+
"--ui", action="store_true", help="serve the bundled UI after pytest"
|
|
120
|
+
)
|
|
121
|
+
test.add_argument("--port", type=int, default=8000, metavar="PORT", help="UI port")
|
|
122
|
+
|
|
123
|
+
runtime_parser = subparsers.add_parser(
|
|
124
|
+
"runtime", help="manage managed runtime caches"
|
|
125
|
+
)
|
|
126
|
+
runtime_sub = runtime_parser.add_subparsers(
|
|
127
|
+
dest="runtime_command", required=True, parser_class=_RedactingArgumentParser
|
|
128
|
+
)
|
|
129
|
+
cache_parser = runtime_sub.add_parser("cache", help="manage managed harness cache")
|
|
130
|
+
cache_sub = cache_parser.add_subparsers(
|
|
131
|
+
dest="cache_command", required=True, parser_class=_RedactingArgumentParser
|
|
132
|
+
)
|
|
133
|
+
for action in ("list", "prune"):
|
|
134
|
+
command = cache_sub.add_parser(action, help=f"{action} managed harness cache")
|
|
135
|
+
command.add_argument(
|
|
136
|
+
"--cache-dir",
|
|
137
|
+
"--harness-cache-dir",
|
|
138
|
+
type=Path,
|
|
139
|
+
default=None,
|
|
140
|
+
metavar="PATH",
|
|
141
|
+
)
|
|
142
|
+
command.add_argument("--project-root", type=Path, default=None, metavar="PATH")
|
|
143
|
+
return parser
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _command_error_message(command: str) -> str:
|
|
147
|
+
return f"m3 {command}: invalid command or configuration"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def main(argv: list[str] | None = None) -> int:
|
|
151
|
+
effective_argv = list(sys.argv[1:] if argv is None else argv)
|
|
152
|
+
command_name = (
|
|
153
|
+
effective_argv[0]
|
|
154
|
+
if effective_argv
|
|
155
|
+
and effective_argv[0] in {"doctor", "setup", "test", "init", "runtime"}
|
|
156
|
+
else "doctor"
|
|
157
|
+
)
|
|
158
|
+
pytest_args: list[str] = []
|
|
159
|
+
try:
|
|
160
|
+
if effective_argv and effective_argv[0] == "test" and "--" in effective_argv:
|
|
161
|
+
separator = effective_argv.index("--")
|
|
162
|
+
pytest_args = effective_argv[separator + 1 :]
|
|
163
|
+
effective_argv = effective_argv[:separator]
|
|
164
|
+
try:
|
|
165
|
+
args = _parser().parse_args(effective_argv)
|
|
166
|
+
except SystemExit as exc:
|
|
167
|
+
return exc.code if isinstance(exc.code, int) else 2
|
|
168
|
+
if args.command == "test":
|
|
169
|
+
from .supervisor import run_test
|
|
170
|
+
|
|
171
|
+
return run_test(
|
|
172
|
+
python=args.python,
|
|
173
|
+
project_root=args.project_root,
|
|
174
|
+
pytest_args=pytest_args,
|
|
175
|
+
database=args.results_db,
|
|
176
|
+
ui=args.ui,
|
|
177
|
+
port=args.port,
|
|
178
|
+
baseline=args.baseline,
|
|
179
|
+
harnesses=args.harness,
|
|
180
|
+
trials=args.trials,
|
|
181
|
+
suite=args.suite,
|
|
182
|
+
credential_env=args.credential_env,
|
|
183
|
+
env_file=args.env_file,
|
|
184
|
+
execution_timeout=args.execution_timeout,
|
|
185
|
+
judge_max_requests=args.judge_max_requests,
|
|
186
|
+
runtime=args.runtime,
|
|
187
|
+
harness_cache_dir=args.harness_cache_dir,
|
|
188
|
+
)
|
|
189
|
+
if args.command == "runtime":
|
|
190
|
+
return runtime.cache_command(
|
|
191
|
+
args.cache_command,
|
|
192
|
+
args.cache_dir,
|
|
193
|
+
project_root=args.project_root,
|
|
194
|
+
)
|
|
195
|
+
if args.command == "setup":
|
|
196
|
+
try:
|
|
197
|
+
return setup.run(args)
|
|
198
|
+
except setup.SetupError as exc:
|
|
199
|
+
print(f"m3 setup: {exc}", file=sys.stderr)
|
|
200
|
+
return 2
|
|
201
|
+
if args.command == "init":
|
|
202
|
+
return init.run(args)
|
|
203
|
+
code, report = doctor.run(args)
|
|
204
|
+
if args.json:
|
|
205
|
+
print(json.dumps(report, indent=2, sort_keys=True))
|
|
206
|
+
else:
|
|
207
|
+
doctor.print_human(report)
|
|
208
|
+
return code
|
|
209
|
+
except doctor.DoctorConfigurationError as exc:
|
|
210
|
+
if "--json" in effective_argv:
|
|
211
|
+
print(
|
|
212
|
+
json.dumps(
|
|
213
|
+
{
|
|
214
|
+
"ready": False,
|
|
215
|
+
"error": doctor._configuration_error_payload(
|
|
216
|
+
exc.configuration_error
|
|
217
|
+
),
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
else:
|
|
222
|
+
doctor.print_configuration_error(exc.configuration_error)
|
|
223
|
+
return 2
|
|
224
|
+
except doctor.DoctorArgumentError as exc:
|
|
225
|
+
if "--json" in effective_argv:
|
|
226
|
+
print(json.dumps({"ready": False, "error": str(exc)}))
|
|
227
|
+
else:
|
|
228
|
+
print(f"m3 doctor: {exc}", file=sys.stderr)
|
|
229
|
+
return 2
|
|
230
|
+
except doctor.DoctorProjectPythonError as exc:
|
|
231
|
+
if "--json" in effective_argv:
|
|
232
|
+
print(
|
|
233
|
+
json.dumps(
|
|
234
|
+
{
|
|
235
|
+
"ready": False,
|
|
236
|
+
"error": {
|
|
237
|
+
"code": "project_python_unavailable",
|
|
238
|
+
"reason": str(exc),
|
|
239
|
+
},
|
|
240
|
+
}
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
else:
|
|
244
|
+
print(f"m3 doctor: {exc}", file=sys.stderr)
|
|
245
|
+
return 2
|
|
246
|
+
except doctor.DoctorCLIError as exc:
|
|
247
|
+
print(f"m3 doctor: {exc}", file=sys.stderr)
|
|
248
|
+
return 2
|
|
249
|
+
except CLIError:
|
|
250
|
+
if "--json" in effective_argv:
|
|
251
|
+
print(
|
|
252
|
+
json.dumps(
|
|
253
|
+
{"ready": False, "error": "invalid command or configuration"}
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
else:
|
|
257
|
+
print(_command_error_message(command_name), file=sys.stderr)
|
|
258
|
+
return 2
|
|
259
|
+
except Exception:
|
|
260
|
+
if "--json" in effective_argv:
|
|
261
|
+
print(
|
|
262
|
+
json.dumps(
|
|
263
|
+
{"ready": False, "error": "invalid command or configuration"}
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
else:
|
|
267
|
+
print(_command_error_message(command_name), file=sys.stderr)
|
|
268
|
+
return 2
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
__all__ = ["main"]
|
m3_cli/runtime.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""CLI access to the managed runtime cache service."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from m3.runtime import list_cache as _sdk_list_cache
|
|
14
|
+
from m3.runtime import prune_cache as _sdk_prune_cache
|
|
15
|
+
from m3.runtime import resolve_cache_root as _sdk_resolve_cache_root
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def resolve_cache_root(
|
|
19
|
+
override: str | os.PathLike[str] | None = None, *, project_root: Path | None = None
|
|
20
|
+
) -> Path:
|
|
21
|
+
return (
|
|
22
|
+
_sdk_resolve_cache_root(
|
|
23
|
+
project_root or Path.cwd(),
|
|
24
|
+
cli_override=Path(override).expanduser() if override is not None else None,
|
|
25
|
+
)
|
|
26
|
+
.expanduser()
|
|
27
|
+
.absolute()
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def list_cache(root: Path) -> list[dict[str, Any]]:
|
|
32
|
+
entries = list(_sdk_list_cache(root))
|
|
33
|
+
|
|
34
|
+
def project(item: Mapping[str, Any]) -> dict[str, str]:
|
|
35
|
+
provenance = item.get("provenance")
|
|
36
|
+
values = provenance if isinstance(provenance, dict) else item
|
|
37
|
+
path = Path(str(item.get("path", "")))
|
|
38
|
+
return {
|
|
39
|
+
"kind": str(values.get("kind", "")),
|
|
40
|
+
"version": str(values.get("version", "")),
|
|
41
|
+
"target": str(values.get("target", "")),
|
|
42
|
+
"digest": str(values.get("sha256", "")),
|
|
43
|
+
"status": "in_use"
|
|
44
|
+
if any(path.glob("lease-*"))
|
|
45
|
+
else str(item.get("status", "ready")),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
projected = [project(item) for item in entries]
|
|
49
|
+
return sorted(
|
|
50
|
+
projected,
|
|
51
|
+
key=lambda item: tuple(
|
|
52
|
+
item[field] for field in ("kind", "version", "target", "digest")
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _entry_paths(root: Path) -> list[Path]:
|
|
58
|
+
return [
|
|
59
|
+
Path(str(item["path"]))
|
|
60
|
+
for item in _sdk_list_cache(root)
|
|
61
|
+
if isinstance(item.get("path"), str)
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _entry_size(path: Path) -> int:
|
|
66
|
+
"""Return bytes occupied by regular files in a cache entry.
|
|
67
|
+
|
|
68
|
+
Cache entries are trusted only after the SDK has validated them. Still,
|
|
69
|
+
avoid following links here so reporting a prune cannot traverse outside
|
|
70
|
+
the selected cache if an entry is concurrently modified.
|
|
71
|
+
"""
|
|
72
|
+
total = 0
|
|
73
|
+
try:
|
|
74
|
+
for current, directories, files in os.walk(path, followlinks=False):
|
|
75
|
+
directories[:] = [
|
|
76
|
+
name for name in directories if not (Path(current) / name).is_symlink()
|
|
77
|
+
]
|
|
78
|
+
for name in files:
|
|
79
|
+
candidate = Path(current) / name
|
|
80
|
+
try:
|
|
81
|
+
if not candidate.is_symlink():
|
|
82
|
+
total += candidate.stat().st_size
|
|
83
|
+
except OSError:
|
|
84
|
+
continue
|
|
85
|
+
except OSError:
|
|
86
|
+
return 0
|
|
87
|
+
return total
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def prune_cache(root: Path) -> list[Path]:
|
|
91
|
+
deadline = time.monotonic() + 10.0
|
|
92
|
+
removed: list[Path] = []
|
|
93
|
+
while True:
|
|
94
|
+
removed.extend(_sdk_prune_cache(root))
|
|
95
|
+
if not _entry_paths(root):
|
|
96
|
+
return removed
|
|
97
|
+
if time.monotonic() >= deadline:
|
|
98
|
+
raise RuntimeError("runtime cache entries are still in use")
|
|
99
|
+
time.sleep(0.1)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def cache_command(
|
|
103
|
+
action: str,
|
|
104
|
+
override: str | os.PathLike[str] | None = None,
|
|
105
|
+
*,
|
|
106
|
+
project_root: Path | None = None,
|
|
107
|
+
) -> int:
|
|
108
|
+
try:
|
|
109
|
+
root = resolve_cache_root(override, project_root=project_root)
|
|
110
|
+
if action == "list":
|
|
111
|
+
print(json.dumps(list_cache(root), sort_keys=True))
|
|
112
|
+
else:
|
|
113
|
+
sizes = {path: _entry_size(path) for path in _entry_paths(root)}
|
|
114
|
+
removed = prune_cache(root)
|
|
115
|
+
bytes_removed = sum(sizes.get(path, 0) for path in removed)
|
|
116
|
+
print(
|
|
117
|
+
f"pruned runtime cache: {root} "
|
|
118
|
+
f"({len(removed)} entries, {bytes_removed} bytes removed)"
|
|
119
|
+
)
|
|
120
|
+
return 0
|
|
121
|
+
except (OSError, ValueError, RuntimeError) as exc:
|
|
122
|
+
print(f"m3 runtime: {exc}", file=sys.stderr)
|
|
123
|
+
return 2
|