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/setup.py ADDED
@@ -0,0 +1,353 @@
1
+ """Project-local M3 SDK setup.
2
+
3
+ The standalone CLI lives in an isolated tool environment. This module is the
4
+ explicit bridge into a project's own Python environment; it never installs
5
+ the CLI or application package into that environment.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib.metadata
11
+ import json
12
+ import os
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ from collections.abc import Mapping
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+ from typing import Any, cast
20
+
21
+ _VALIDATE_SCRIPT = r"""
22
+ import importlib
23
+ import importlib.metadata
24
+ import json
25
+
26
+ checks = {}
27
+ for name in ("pytest", "m3", "m3.pytest_plugin", "openai"):
28
+ try:
29
+ importlib.import_module(name)
30
+ except Exception:
31
+ checks[name] = False
32
+ else:
33
+ checks[name] = True
34
+ try:
35
+ from m3.storage import SQLiteExecutionStore
36
+ except Exception:
37
+ checks["SQLiteExecutionStore"] = False
38
+ else:
39
+ checks["SQLiteExecutionStore"] = True
40
+ try:
41
+ version = importlib.metadata.version("sf-m3")
42
+ except Exception:
43
+ version = None
44
+ print(json.dumps({"checks": checks, "version": version}, sort_keys=True))
45
+ """
46
+
47
+
48
+ class SetupError(RuntimeError):
49
+ """A safe, user-facing setup failure."""
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class EnvironmentTarget:
54
+ path: Path
55
+ python: Path
56
+ source: str
57
+ created: bool = False
58
+
59
+
60
+ def _python_path(base: Path) -> Path:
61
+ candidate = base / ("Scripts" if os.name == "nt" else "bin") / "python"
62
+ if os.name == "nt" and not candidate.is_file():
63
+ candidate = candidate.with_suffix(".exe")
64
+ return candidate
65
+
66
+
67
+ def _environment_root(python: Path) -> Path:
68
+ return (
69
+ python.parent.parent
70
+ if python.parent.name in {"bin", "Scripts"}
71
+ else python.parent
72
+ )
73
+
74
+
75
+ def _absolute(value: str | os.PathLike[str]) -> Path:
76
+ return Path(value).expanduser().absolute()
77
+
78
+
79
+ def _resolve_explicit(
80
+ value: str | os.PathLike[str], environment: Mapping[str, str]
81
+ ) -> Path:
82
+ text = os.fspath(value)
83
+ if os.sep not in text and (os.altsep is None or os.altsep not in text):
84
+ found = shutil.which(text, path=environment.get("PATH"))
85
+ if found:
86
+ return _absolute(found)
87
+ return _absolute(text)
88
+
89
+
90
+ def _environment_python(name: str, environment: Mapping[str, str]) -> Path | None:
91
+ raw = environment.get(name)
92
+ if not raw:
93
+ return None
94
+ base = _absolute(raw)
95
+ candidates = [_python_path(base)]
96
+ if name == "CONDA_PREFIX":
97
+ # Conda uses ``<prefix>/python.exe`` on Windows and
98
+ # ``<prefix>/bin/python`` on POSIX; accept either layout so a copied
99
+ # environment remains discoverable across platforms.
100
+ candidates = [base / "python.exe", base / "bin" / "python", *candidates]
101
+ for python in candidates:
102
+ if python.is_file():
103
+ return python
104
+ raise SetupError(f"active {name} environment is unavailable")
105
+
106
+
107
+ def _probe_python(
108
+ python: Path,
109
+ *,
110
+ allow_conda: bool = False,
111
+ conda_prefix: Path | None = None,
112
+ ) -> dict[str, Any]:
113
+ if not python.is_file():
114
+ raise SetupError("selected Python executable is unavailable")
115
+ try:
116
+ result = subprocess.run(
117
+ [
118
+ str(python),
119
+ "-c",
120
+ "import json,sys; print(json.dumps({'version': sys.version_info[:2], 'prefix': sys.prefix, 'base_prefix': sys.base_prefix}))",
121
+ ],
122
+ capture_output=True,
123
+ text=True,
124
+ check=False,
125
+ timeout=10,
126
+ )
127
+ except (OSError, subprocess.TimeoutExpired):
128
+ raise SetupError("selected Python executable could not be started") from None
129
+ if result.returncode != 0:
130
+ raise SetupError("selected Python executable could not be started")
131
+ try:
132
+ payload = json.loads(result.stdout.strip().splitlines()[-1])
133
+ version = payload["version"]
134
+ if tuple(version) < (3, 10):
135
+ raise SetupError("Python 3.10 or newer is required")
136
+ prefix = Path(str(payload["prefix"])).absolute()
137
+ base_prefix = Path(str(payload["base_prefix"])).absolute()
138
+ conda_root = conda_prefix.absolute() if conda_prefix is not None else None
139
+ # An arbitrary interpreter containing conda-meta is not enough to
140
+ # distinguish a project env from the global Conda base.
141
+ is_conda = bool(allow_conda and conda_root is not None and prefix == conda_root)
142
+ if prefix == base_prefix and not is_conda:
143
+ raise SetupError(
144
+ "refusing to install into a system or global Python; use an isolated environment"
145
+ )
146
+ return cast(dict[str, Any], payload)
147
+ except SetupError:
148
+ raise
149
+ except (IndexError, KeyError, TypeError, ValueError, json.JSONDecodeError):
150
+ raise SetupError(
151
+ "selected Python executable returned an invalid check result"
152
+ ) from None
153
+
154
+
155
+ def resolve_target(
156
+ project_root: Path,
157
+ explicit: str | os.PathLike[str] | None = None,
158
+ *,
159
+ environment: Mapping[str, str] | None = None,
160
+ ) -> EnvironmentTarget:
161
+ """Select an existing isolated environment, or create project ``.venv``."""
162
+
163
+ env = os.environ if environment is None else environment
164
+ root = project_root.resolve()
165
+ if not root.is_dir():
166
+ raise SetupError("project root is unavailable")
167
+ if explicit is not None:
168
+ python = _resolve_explicit(explicit, env)
169
+ _probe_python(python)
170
+ return EnvironmentTarget(_environment_root(python), python, "--python")
171
+
172
+ for variable in ("VIRTUAL_ENV", "CONDA_PREFIX"):
173
+ if (
174
+ variable == "CONDA_PREFIX"
175
+ and env.get(variable)
176
+ and env.get("CONDA_DEFAULT_ENV", "").casefold() == "base"
177
+ ):
178
+ raise SetupError(
179
+ "active Conda base is not a project environment; create or activate a project environment"
180
+ )
181
+ candidate = _environment_python(variable, env)
182
+ if candidate is not None:
183
+ python = candidate
184
+ _probe_python(
185
+ python,
186
+ allow_conda=variable == "CONDA_PREFIX",
187
+ conda_prefix=Path(raw).absolute()
188
+ if (raw := env.get(variable))
189
+ else None,
190
+ )
191
+ return EnvironmentTarget(_environment_root(python), python, variable)
192
+
193
+ venv = root / ".venv"
194
+ python = _python_path(venv)
195
+ if python.is_file():
196
+ _probe_python(python)
197
+ return EnvironmentTarget(venv, python, "project .venv")
198
+ if venv.exists():
199
+ raise SetupError(
200
+ "project .venv exists but its Python executable is unavailable"
201
+ )
202
+
203
+ # The caller creates this exact target, and records that fact for rollback.
204
+ return EnvironmentTarget(venv, python, "new project .venv", created=True)
205
+
206
+
207
+ def _create_environment(target: EnvironmentTarget) -> EnvironmentTarget:
208
+ try:
209
+ target.path.parent.mkdir(parents=True, exist_ok=True)
210
+ except OSError:
211
+ raise SetupError("could not create the project environment") from None
212
+ uv = shutil.which("uv")
213
+ try:
214
+ if uv:
215
+ result = subprocess.run(
216
+ [uv, "venv", str(target.path)],
217
+ capture_output=True,
218
+ text=True,
219
+ check=False,
220
+ timeout=120,
221
+ )
222
+ else:
223
+ result = subprocess.run(
224
+ [sys.executable, "-m", "venv", str(target.path)],
225
+ capture_output=True,
226
+ text=True,
227
+ check=False,
228
+ timeout=120,
229
+ )
230
+ except (OSError, subprocess.TimeoutExpired):
231
+ raise SetupError("could not create the project environment") from None
232
+ if result.returncode != 0:
233
+ raise SetupError("could not create the project environment")
234
+ python = _python_path(target.path)
235
+ _probe_python(python)
236
+ return EnvironmentTarget(target.path, python, target.source, created=True)
237
+
238
+
239
+ def _cli_version() -> str:
240
+ try:
241
+ sdk = importlib.metadata.version("sf-m3")
242
+ cli = importlib.metadata.version("sf-m3-cli")
243
+ except importlib.metadata.PackageNotFoundError:
244
+ raise SetupError(
245
+ "the bundled M3 SDK version is unavailable; reinstall sf-m3-cli"
246
+ ) from None
247
+ if sdk != cli:
248
+ raise SetupError(
249
+ "the bundled CLI and SDK versions do not match; reinstall sf-m3-cli"
250
+ )
251
+ return sdk
252
+
253
+
254
+ def _ready(python: Path, version: str, project_root: Path) -> bool:
255
+ try:
256
+ result = subprocess.run(
257
+ [str(python), "-c", _VALIDATE_SCRIPT],
258
+ cwd=str(project_root),
259
+ capture_output=True,
260
+ text=True,
261
+ check=False,
262
+ timeout=15,
263
+ )
264
+ payload = json.loads(result.stdout.strip().splitlines()[-1])
265
+ checks = payload.get("checks", {})
266
+ return (
267
+ result.returncode == 0
268
+ and payload.get("version") == version
269
+ and all(
270
+ checks.get(name)
271
+ for name in (
272
+ "pytest",
273
+ "m3",
274
+ "m3.pytest_plugin",
275
+ "openai",
276
+ "SQLiteExecutionStore",
277
+ )
278
+ )
279
+ )
280
+ except (
281
+ OSError,
282
+ subprocess.TimeoutExpired,
283
+ IndexError,
284
+ TypeError,
285
+ ValueError,
286
+ json.JSONDecodeError,
287
+ ):
288
+ return False
289
+
290
+
291
+ def _install_sdk(target: EnvironmentTarget, version: str) -> str:
292
+ requirement = f"sf-m3[pytest,storage,judge]=={version}"
293
+ uv = shutil.which("uv")
294
+ command = (
295
+ [uv, "pip", "install", "--python", str(target.python), requirement]
296
+ if uv
297
+ else [
298
+ str(target.python),
299
+ "-m",
300
+ "pip",
301
+ "install",
302
+ "--disable-pip-version-check",
303
+ requirement,
304
+ ]
305
+ )
306
+ try:
307
+ result = subprocess.run(
308
+ command, capture_output=True, text=True, check=False, timeout=300
309
+ )
310
+ except (OSError, subprocess.TimeoutExpired):
311
+ raise SetupError("could not install the matching M3 SDK from PyPI") from None
312
+ if result.returncode != 0:
313
+ raise SetupError("could not install the matching M3 SDK from PyPI")
314
+ return "uv" if uv else "venv/pip"
315
+
316
+
317
+ def run(args: Any) -> int:
318
+ root = (args.project_root or Path.cwd()).resolve()
319
+ version = _cli_version()
320
+ target = resolve_target(root, args.python)
321
+ print(f"Installing M3 SDK {version}")
322
+ print(f"Project environment: {target.path} ({target.source})")
323
+ if not target.created and _ready(target.python, version, root):
324
+ print(f"Project environment ready: {target.path}")
325
+ print(f"M3 SDK: {version}")
326
+ print("Installer: skipped; environment is already ready")
327
+ print("Next:\n m3 doctor\n m3 test --ui -- -q")
328
+ return 0
329
+
330
+ created = target.created
331
+ try:
332
+ if created:
333
+ print("[1/3] Creating project environment")
334
+ target = _create_environment(target)
335
+ else:
336
+ print("[1/3] Selecting project environment")
337
+ print("[2/3] Installing matching SDK from PyPI")
338
+ installer = _install_sdk(target, version)
339
+ print("[3/3] Verifying project environment")
340
+ if not _ready(target.python, version, root):
341
+ raise SetupError("project environment did not pass the M3 SDK checks")
342
+ except SetupError:
343
+ if created and target.path.exists():
344
+ shutil.rmtree(target.path, ignore_errors=True)
345
+ raise
346
+ print(f"Project environment ready: {target.path}")
347
+ print(f"M3 SDK: {version}")
348
+ print(f"Installer: {installer}")
349
+ print("Next:\n m3 doctor\n m3 test --ui -- -q")
350
+ return 0
351
+
352
+
353
+ __all__ = ["EnvironmentTarget", "SetupError", "resolve_target", "run"]