expops 0.1.3__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.
- expops-0.1.3.dist-info/METADATA +826 -0
- expops-0.1.3.dist-info/RECORD +86 -0
- expops-0.1.3.dist-info/WHEEL +5 -0
- expops-0.1.3.dist-info/entry_points.txt +3 -0
- expops-0.1.3.dist-info/licenses/LICENSE +674 -0
- expops-0.1.3.dist-info/top_level.txt +1 -0
- mlops/__init__.py +0 -0
- mlops/__main__.py +11 -0
- mlops/_version.py +34 -0
- mlops/adapters/__init__.py +12 -0
- mlops/adapters/base.py +86 -0
- mlops/adapters/config_schema.py +89 -0
- mlops/adapters/custom/__init__.py +3 -0
- mlops/adapters/custom/custom_adapter.py +447 -0
- mlops/adapters/plugin_manager.py +113 -0
- mlops/adapters/sklearn/__init__.py +3 -0
- mlops/adapters/sklearn/adapter.py +94 -0
- mlops/cluster/__init__.py +3 -0
- mlops/cluster/controller.py +496 -0
- mlops/cluster/process_runner.py +91 -0
- mlops/cluster/providers.py +258 -0
- mlops/core/__init__.py +95 -0
- mlops/core/custom_model_base.py +38 -0
- mlops/core/dask_networkx_executor.py +1265 -0
- mlops/core/executor_worker.py +1239 -0
- mlops/core/experiment_tracker.py +81 -0
- mlops/core/graph_types.py +64 -0
- mlops/core/networkx_parser.py +135 -0
- mlops/core/payload_spill.py +278 -0
- mlops/core/pipeline_utils.py +162 -0
- mlops/core/process_hashing.py +216 -0
- mlops/core/step_state_manager.py +1298 -0
- mlops/core/step_system.py +956 -0
- mlops/core/workspace.py +99 -0
- mlops/environment/__init__.py +10 -0
- mlops/environment/base.py +43 -0
- mlops/environment/conda_manager.py +307 -0
- mlops/environment/factory.py +70 -0
- mlops/environment/pyenv_manager.py +146 -0
- mlops/environment/setup_env.py +31 -0
- mlops/environment/system_manager.py +66 -0
- mlops/environment/utils.py +105 -0
- mlops/environment/venv_manager.py +134 -0
- mlops/main.py +527 -0
- mlops/managers/project_manager.py +400 -0
- mlops/managers/reproducibility_manager.py +575 -0
- mlops/platform.py +996 -0
- mlops/reporting/__init__.py +16 -0
- mlops/reporting/context.py +187 -0
- mlops/reporting/entrypoint.py +292 -0
- mlops/reporting/kv_utils.py +77 -0
- mlops/reporting/registry.py +50 -0
- mlops/runtime/__init__.py +9 -0
- mlops/runtime/context.py +34 -0
- mlops/runtime/env_export.py +113 -0
- mlops/storage/__init__.py +12 -0
- mlops/storage/adapters/__init__.py +9 -0
- mlops/storage/adapters/gcp_kv_store.py +778 -0
- mlops/storage/adapters/gcs_object_store.py +96 -0
- mlops/storage/adapters/memory_store.py +240 -0
- mlops/storage/adapters/redis_store.py +438 -0
- mlops/storage/factory.py +199 -0
- mlops/storage/interfaces/__init__.py +6 -0
- mlops/storage/interfaces/kv_store.py +118 -0
- mlops/storage/path_utils.py +38 -0
- mlops/templates/premier-league/charts/plot_metrics.js +70 -0
- mlops/templates/premier-league/charts/plot_metrics.py +145 -0
- mlops/templates/premier-league/charts/requirements.txt +6 -0
- mlops/templates/premier-league/configs/cluster_config.yaml +13 -0
- mlops/templates/premier-league/configs/project_config.yaml +207 -0
- mlops/templates/premier-league/data/England CSV.csv +12154 -0
- mlops/templates/premier-league/models/premier_league_model.py +638 -0
- mlops/templates/premier-league/requirements.txt +8 -0
- mlops/templates/sklearn-basic/README.md +22 -0
- mlops/templates/sklearn-basic/charts/plot_metrics.py +85 -0
- mlops/templates/sklearn-basic/charts/requirements.txt +3 -0
- mlops/templates/sklearn-basic/configs/project_config.yaml +64 -0
- mlops/templates/sklearn-basic/data/train.csv +14 -0
- mlops/templates/sklearn-basic/models/model.py +62 -0
- mlops/templates/sklearn-basic/requirements.txt +10 -0
- mlops/web/__init__.py +3 -0
- mlops/web/server.py +585 -0
- mlops/web/ui/index.html +52 -0
- mlops/web/ui/mlops-charts.js +357 -0
- mlops/web/ui/script.js +1244 -0
- mlops/web/ui/styles.css +248 -0
mlops/main.py
ADDED
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
MLOps Platform CLI
|
|
4
|
+
|
|
5
|
+
Main entry point for the MLOps platform with project-based workflows.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
|
|
22
|
+
import yaml
|
|
23
|
+
|
|
24
|
+
from .managers.project_manager import ProjectManager
|
|
25
|
+
from .core.pipeline_utils import setup_environment_and_write_interpreter
|
|
26
|
+
from .core.workspace import get_workspace_root, infer_source_root
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
ENV_ENV_READY = "MLOPS_ENV_READY"
|
|
30
|
+
ENV_FORCE_LOCAL = "MLOPS_FORCE_LOCAL"
|
|
31
|
+
ENV_LOG_POINTERS_PRINTED = "MLOPS_LOG_POINTERS_PRINTED"
|
|
32
|
+
ENV_RUN_LOG_FILE = "MLOPS_RUN_LOG_FILE"
|
|
33
|
+
ENV_WORKSPACE_DIR = "MLOPS_WORKSPACE_DIR"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _env_truthy(name: str) -> bool:
|
|
37
|
+
try:
|
|
38
|
+
return str(os.environ.get(name, "")).strip().lower() in {"1", "true", "yes"}
|
|
39
|
+
except Exception:
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _is_timestamped_log_path(project_id: str, path_obj: Path) -> bool:
|
|
44
|
+
try:
|
|
45
|
+
pattern = rf"^{re.escape(project_id)}_\d{{8}}_\d{{6}}\.log$"
|
|
46
|
+
return bool(re.match(pattern, path_obj.name))
|
|
47
|
+
except Exception:
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _select_run_log_file(project_path: Path, project_id: str) -> Path:
|
|
52
|
+
logs_dir = project_path / "logs"
|
|
53
|
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
|
|
55
|
+
run_log_env = os.environ.get(ENV_RUN_LOG_FILE)
|
|
56
|
+
if run_log_env:
|
|
57
|
+
try:
|
|
58
|
+
candidate = Path(run_log_env)
|
|
59
|
+
if _is_timestamped_log_path(project_id, candidate):
|
|
60
|
+
candidate.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
return candidate
|
|
62
|
+
except Exception:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
ts = time.strftime("%Y%m%d_%H%M%S")
|
|
66
|
+
log_file = logs_dir / f"{project_id}_{ts}.log"
|
|
67
|
+
os.environ[ENV_RUN_LOG_FILE] = str(log_file)
|
|
68
|
+
return log_file
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _print_log_pointers_once(project_id: str, log_path: Path) -> None:
|
|
72
|
+
try:
|
|
73
|
+
if not os.environ.get(ENV_LOG_POINTERS_PRINTED):
|
|
74
|
+
print(f"Project: {project_id}", flush=True)
|
|
75
|
+
print(f"Project log: {log_path}", flush=True)
|
|
76
|
+
os.environ[ENV_LOG_POINTERS_PRINTED] = "1"
|
|
77
|
+
except Exception:
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _append_to_log_file(log_file: Path, text: str) -> None:
|
|
82
|
+
try:
|
|
83
|
+
with open(log_file, "a", encoding="utf-8") as lf:
|
|
84
|
+
lf.write(text)
|
|
85
|
+
except Exception:
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class _StreamToLogger:
|
|
90
|
+
def __init__(self, logger: logging.Logger, level: int):
|
|
91
|
+
self.logger = logger
|
|
92
|
+
self.level = level
|
|
93
|
+
self._buffer = ""
|
|
94
|
+
|
|
95
|
+
def write(self, message: str) -> None:
|
|
96
|
+
if not message:
|
|
97
|
+
return
|
|
98
|
+
self._buffer += message
|
|
99
|
+
while "\n" in self._buffer:
|
|
100
|
+
line, self._buffer = self._buffer.split("\n", 1)
|
|
101
|
+
if line.strip():
|
|
102
|
+
self.logger.log(self.level, line)
|
|
103
|
+
|
|
104
|
+
def flush(self) -> None:
|
|
105
|
+
if self._buffer.strip():
|
|
106
|
+
self.logger.log(self.level, self._buffer.strip())
|
|
107
|
+
self._buffer = ""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _configure_file_logging_and_redirect(log_file: Path) -> None:
|
|
111
|
+
logging.basicConfig(
|
|
112
|
+
level=logging.INFO,
|
|
113
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
114
|
+
handlers=[logging.FileHandler(str(log_file), encoding="utf-8")],
|
|
115
|
+
force=True,
|
|
116
|
+
)
|
|
117
|
+
root_logger = logging.getLogger()
|
|
118
|
+
sys.stdout = _StreamToLogger(root_logger, logging.INFO)
|
|
119
|
+
sys.stderr = _StreamToLogger(root_logger, logging.ERROR)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _get_cluster_provider(cluster_config_path: Path) -> str | None:
|
|
123
|
+
try:
|
|
124
|
+
if not cluster_config_path.exists():
|
|
125
|
+
return None
|
|
126
|
+
with open(cluster_config_path) as f:
|
|
127
|
+
cluster_cfg = yaml.safe_load(f) or {}
|
|
128
|
+
provider = cluster_cfg.get("provider")
|
|
129
|
+
if isinstance(provider, str) and provider.strip():
|
|
130
|
+
return provider.strip()
|
|
131
|
+
except Exception:
|
|
132
|
+
pass
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _maybe_delegate_to_cluster_controller(project_id: str, project_path: Path, local_flag: bool, workspace_root: Path) -> None:
|
|
137
|
+
if local_flag or _env_truthy(ENV_FORCE_LOCAL):
|
|
138
|
+
return
|
|
139
|
+
cluster_config_path = project_path / "configs" / "cluster_config.yaml"
|
|
140
|
+
if not _get_cluster_provider(cluster_config_path):
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
controller_path = Path(__file__).resolve().parent / "cluster" / "controller.py"
|
|
144
|
+
# Pass workspace root explicitly so the controller does not rely on a source checkout layout.
|
|
145
|
+
cmd = [sys.executable, str(controller_path), "--project-dir", str(workspace_root), "--project-id", project_id]
|
|
146
|
+
result = subprocess.run(cmd)
|
|
147
|
+
raise SystemExit(result.returncode)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _prepare_and_reexec_under_project_interpreter_if_needed(
|
|
151
|
+
workspace_root: Path, project_id: str, log_file: Path, local_flag: bool
|
|
152
|
+
) -> None:
|
|
153
|
+
if os.environ.get(ENV_ENV_READY):
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
cache_base = Path.home() / ".cache" / "mlops-platform" / project_id
|
|
158
|
+
env_file = cache_base / "python_interpreter.txt"
|
|
159
|
+
env_file.parent.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
|
|
161
|
+
os.environ.setdefault(ENV_WORKSPACE_DIR, str(workspace_root))
|
|
162
|
+
|
|
163
|
+
project_python = setup_environment_and_write_interpreter(workspace_root, project_id, env_file)
|
|
164
|
+
|
|
165
|
+
source_root = infer_source_root()
|
|
166
|
+
if source_root and (source_root / "src").exists():
|
|
167
|
+
os.environ["PYTHONPATH"] = f"{source_root / 'src'}:{os.environ.get('PYTHONPATH', '')}".rstrip(":")
|
|
168
|
+
|
|
169
|
+
_append_to_log_file(
|
|
170
|
+
log_file,
|
|
171
|
+
f"\n=== Preparing project interpreter at {project_python} ({datetime.now()}) ===\n"
|
|
172
|
+
+ f"workspace_root={workspace_root}\n",
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
def _current_dist_info() -> tuple[str, str] | None:
|
|
176
|
+
"""Return (distribution_name, version) for the installed platform package (if available)."""
|
|
177
|
+
try:
|
|
178
|
+
from importlib.metadata import PackageNotFoundError, version # type: ignore
|
|
179
|
+
except Exception:
|
|
180
|
+
return None
|
|
181
|
+
for dist in ("expops", "mlops-platform", "mlops_platform"):
|
|
182
|
+
try:
|
|
183
|
+
v = version(dist)
|
|
184
|
+
if v:
|
|
185
|
+
return (str(dist), str(v))
|
|
186
|
+
except PackageNotFoundError:
|
|
187
|
+
continue
|
|
188
|
+
except Exception:
|
|
189
|
+
continue
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
dist_info = _current_dist_info()
|
|
193
|
+
dist_name = dist_info[0] if dist_info else None
|
|
194
|
+
dist_version = dist_info[1] if dist_info else None
|
|
195
|
+
|
|
196
|
+
with open(log_file, "a", encoding="utf-8") as lf:
|
|
197
|
+
# Upgrade pip tooling
|
|
198
|
+
subprocess.run(
|
|
199
|
+
[project_python, "-m", "pip", "install", "--upgrade", "pip", "setuptools", "wheel"],
|
|
200
|
+
stdout=lf,
|
|
201
|
+
stderr=lf,
|
|
202
|
+
text=True,
|
|
203
|
+
check=False,
|
|
204
|
+
)
|
|
205
|
+
# Ensure the platform package is importable in the project interpreter.
|
|
206
|
+
#
|
|
207
|
+
# - Installed package workflow: install the SAME version into the project env from PyPI.
|
|
208
|
+
# - Dev workflow (source checkout): fall back to editable install.
|
|
209
|
+
installed_ok = False
|
|
210
|
+
if dist_name and dist_version:
|
|
211
|
+
lf.write(f"Installing {dist_name}=={dist_version} into project env...\n")
|
|
212
|
+
result = subprocess.run(
|
|
213
|
+
[project_python, "-m", "pip", "install", f"{dist_name}=={dist_version}"],
|
|
214
|
+
stdout=lf,
|
|
215
|
+
stderr=lf,
|
|
216
|
+
text=True,
|
|
217
|
+
check=False,
|
|
218
|
+
)
|
|
219
|
+
lf.write(f"{dist_name} install exit code: {result.returncode}\n")
|
|
220
|
+
installed_ok = (result.returncode == 0)
|
|
221
|
+
elif source_root and (source_root / "setup.py").exists():
|
|
222
|
+
lf.write("Installing platform from local source checkout (editable)...\n")
|
|
223
|
+
result = subprocess.run(
|
|
224
|
+
[project_python, "-m", "pip", "install", "-e", str(source_root)],
|
|
225
|
+
stdout=lf,
|
|
226
|
+
stderr=lf,
|
|
227
|
+
text=True,
|
|
228
|
+
check=False,
|
|
229
|
+
)
|
|
230
|
+
lf.write(f"platform editable install exit code: {result.returncode}\n")
|
|
231
|
+
installed_ok = (result.returncode == 0)
|
|
232
|
+
else:
|
|
233
|
+
lf.write("WARNING: could not determine current platform distribution/version; skipping self-install.\n")
|
|
234
|
+
# Ensure pydantic is available in the project env even if editable install failed
|
|
235
|
+
need_pyd = subprocess.run([project_python, "-c", "import pydantic"], capture_output=True, text=True)
|
|
236
|
+
if need_pyd.returncode != 0:
|
|
237
|
+
lf.write("Installing pydantic>=2 in project env...\n")
|
|
238
|
+
subprocess.run(
|
|
239
|
+
[project_python, "-m", "pip", "install", "pydantic>=2"],
|
|
240
|
+
stdout=lf,
|
|
241
|
+
stderr=lf,
|
|
242
|
+
text=True,
|
|
243
|
+
check=False,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# If the project interpreter still can't import mlops, do not re-exec.
|
|
247
|
+
# This keeps the CLI functional even when self-install is not available yet.
|
|
248
|
+
import_check = subprocess.run([project_python, "-c", "import mlops"], stdout=lf, stderr=lf, text=True)
|
|
249
|
+
if import_check.returncode != 0:
|
|
250
|
+
lf.write(
|
|
251
|
+
"WARNING: project interpreter cannot import 'mlops'; "
|
|
252
|
+
+ ("self-install succeeded but import failed.\n" if installed_ok else "self-install was not successful.\n")
|
|
253
|
+
+ "Skipping re-exec under project interpreter.\n"
|
|
254
|
+
)
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
cmd = [project_python, "-m", "mlops.main", "run", project_id]
|
|
258
|
+
if local_flag:
|
|
259
|
+
cmd.append("--local")
|
|
260
|
+
os.environ[ENV_ENV_READY] = "1"
|
|
261
|
+
os.execv(cmd[0], cmd)
|
|
262
|
+
except Exception as e:
|
|
263
|
+
_append_to_log_file(log_file, f"Environment setup failed: {e}\n")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def create_project_command(args: argparse.Namespace) -> None:
|
|
267
|
+
project_manager = ProjectManager()
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
project_info = project_manager.create_project(
|
|
271
|
+
project_id=args.project_id,
|
|
272
|
+
base_config_path=args.config,
|
|
273
|
+
description=args.description or "",
|
|
274
|
+
template=getattr(args, "template", None),
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
print("\nProject Details:")
|
|
278
|
+
print(f" ID: {project_info['project_id']}")
|
|
279
|
+
print(f" Path: {project_info['project_path']}")
|
|
280
|
+
print(f" Created: {project_info['created_at']}")
|
|
281
|
+
|
|
282
|
+
if args.config or getattr(args, "template", None):
|
|
283
|
+
print(f" Config: {project_info.get('active_config', 'No config copied')}")
|
|
284
|
+
|
|
285
|
+
print("\nNext steps:")
|
|
286
|
+
print(f" 1. Run your project: mlops run {args.project_id}")
|
|
287
|
+
print(f" 2. Update config: mlops config {args.project_id} --set key=value")
|
|
288
|
+
print(f" 3. List projects: mlops list")
|
|
289
|
+
|
|
290
|
+
except ValueError as e:
|
|
291
|
+
raise SystemExit(f"Error: {e}")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def delete_project_command(args: argparse.Namespace) -> None:
|
|
295
|
+
project_manager = ProjectManager()
|
|
296
|
+
|
|
297
|
+
success = project_manager.delete_project(args.project_id, confirm=args.force)
|
|
298
|
+
if not success:
|
|
299
|
+
raise SystemExit(1)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def list_projects_command(args: argparse.Namespace) -> None:
|
|
303
|
+
project_manager = ProjectManager()
|
|
304
|
+
projects = project_manager.list_projects()
|
|
305
|
+
|
|
306
|
+
if not projects:
|
|
307
|
+
print("No projects found. Create your first project with:")
|
|
308
|
+
print(" mlops create my-project")
|
|
309
|
+
return
|
|
310
|
+
|
|
311
|
+
print(f"Found {len(projects)} project(s):\n")
|
|
312
|
+
|
|
313
|
+
for project in projects:
|
|
314
|
+
print(f"- {project['project_id']}")
|
|
315
|
+
print(f" Description: {project.get('description', 'No description')}")
|
|
316
|
+
print(f" Created: {project.get('created_at', 'Unknown')}")
|
|
317
|
+
print(f" Path: {project.get('project_path', 'Unknown')}")
|
|
318
|
+
|
|
319
|
+
runs = project.get('runs', [])
|
|
320
|
+
if runs:
|
|
321
|
+
print(f" Runs: {len(runs)} completed")
|
|
322
|
+
print()
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def run_project_command(args: argparse.Namespace) -> None:
|
|
326
|
+
project_manager = ProjectManager()
|
|
327
|
+
|
|
328
|
+
if not project_manager.project_exists(args.project_id):
|
|
329
|
+
raise SystemExit(
|
|
330
|
+
f"Error: Project '{args.project_id}' does not exist\n"
|
|
331
|
+
f"Create it first with: mlops create {args.project_id}"
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
project_path = project_manager.get_project_path(args.project_id)
|
|
335
|
+
workspace_root = get_workspace_root()
|
|
336
|
+
_maybe_delegate_to_cluster_controller(args.project_id, project_path, bool(args.local), workspace_root)
|
|
337
|
+
|
|
338
|
+
config_path = project_manager.get_project_config_path(args.project_id)
|
|
339
|
+
|
|
340
|
+
if not config_path.exists():
|
|
341
|
+
raise SystemExit(
|
|
342
|
+
f"Error: No configuration found for project '{args.project_id}'\n"
|
|
343
|
+
f"Expected config at: {config_path}\n"
|
|
344
|
+
f"You can set a config with: python -m mlops.main config {args.project_id} --file path/to/config.yaml"
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
log_file = _select_run_log_file(project_path, args.project_id)
|
|
348
|
+
_print_log_pointers_once(args.project_id, log_file)
|
|
349
|
+
|
|
350
|
+
_prepare_and_reexec_under_project_interpreter_if_needed(workspace_root, args.project_id, log_file, bool(args.local))
|
|
351
|
+
_configure_file_logging_and_redirect(log_file)
|
|
352
|
+
|
|
353
|
+
print(f"Running project '{args.project_id}'...")
|
|
354
|
+
print(f"Project path: {project_path}")
|
|
355
|
+
print(f"Config: {config_path}")
|
|
356
|
+
|
|
357
|
+
try:
|
|
358
|
+
# Initialize MLPlatform and run pipeline (lazy import after ensuring deps)
|
|
359
|
+
from .platform import MLPlatform
|
|
360
|
+
platform = MLPlatform()
|
|
361
|
+
results = platform.run_pipeline_for_project(args.project_id, str(config_path))
|
|
362
|
+
|
|
363
|
+
print("\nPipeline completed successfully.")
|
|
364
|
+
if isinstance(results, dict):
|
|
365
|
+
print(f"Run ID: {results.get('run_id', 'N/A')}")
|
|
366
|
+
else:
|
|
367
|
+
print(f"Results: {results}")
|
|
368
|
+
|
|
369
|
+
except Exception as e:
|
|
370
|
+
raise SystemExit(f"Error running pipeline: {e}")
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _parse_config_value(raw: str) -> object:
|
|
374
|
+
"""Parse a simple scalar value from CLI input (best-effort)."""
|
|
375
|
+
s = str(raw)
|
|
376
|
+
low = s.lower()
|
|
377
|
+
if low in {"true", "false"}:
|
|
378
|
+
return low == "true"
|
|
379
|
+
if s.isdigit():
|
|
380
|
+
return int(s)
|
|
381
|
+
try:
|
|
382
|
+
if "." in s and s.replace(".", "", 1).isdigit():
|
|
383
|
+
return float(s)
|
|
384
|
+
except Exception:
|
|
385
|
+
pass
|
|
386
|
+
return s
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def config_project_command(args: argparse.Namespace) -> None:
|
|
390
|
+
project_manager = ProjectManager()
|
|
391
|
+
|
|
392
|
+
if not project_manager.project_exists(args.project_id):
|
|
393
|
+
raise SystemExit(f"Error: Project '{args.project_id}' does not exist")
|
|
394
|
+
|
|
395
|
+
# Handle setting config file
|
|
396
|
+
if args.file:
|
|
397
|
+
config_file_path = Path(args.file)
|
|
398
|
+
if not config_file_path.exists():
|
|
399
|
+
raise SystemExit(f"Error: Config file '{args.file}' does not exist")
|
|
400
|
+
|
|
401
|
+
project_path = project_manager.get_project_path(args.project_id)
|
|
402
|
+
dest_config = project_path / "configs" / "project_config.yaml"
|
|
403
|
+
|
|
404
|
+
shutil.copy2(config_file_path, dest_config)
|
|
405
|
+
|
|
406
|
+
# Update project info
|
|
407
|
+
project_info = project_manager.get_project_info(args.project_id)
|
|
408
|
+
project_info["active_config"] = str(dest_config)
|
|
409
|
+
|
|
410
|
+
project_info_file = project_path / "project_info.json"
|
|
411
|
+
with open(project_info_file, 'w') as f:
|
|
412
|
+
json.dump(project_info, f, indent=2)
|
|
413
|
+
|
|
414
|
+
print(f"Configuration updated for project '{args.project_id}'")
|
|
415
|
+
print(f"Config file: {dest_config}")
|
|
416
|
+
return
|
|
417
|
+
|
|
418
|
+
if args.set:
|
|
419
|
+
config_updates = {}
|
|
420
|
+
for setting in args.set:
|
|
421
|
+
try:
|
|
422
|
+
key, value = setting.split('=', 1)
|
|
423
|
+
value_obj = _parse_config_value(value)
|
|
424
|
+
|
|
425
|
+
# Handle nested keys (e.g., model.parameters.n_estimators=100)
|
|
426
|
+
keys = key.split('.')
|
|
427
|
+
current = config_updates
|
|
428
|
+
for k in keys[:-1]:
|
|
429
|
+
if k not in current:
|
|
430
|
+
current[k] = {}
|
|
431
|
+
current = current[k]
|
|
432
|
+
current[keys[-1]] = value_obj
|
|
433
|
+
|
|
434
|
+
except ValueError:
|
|
435
|
+
raise SystemExit(f"Error: Invalid setting format '{setting}'. Use key=value")
|
|
436
|
+
|
|
437
|
+
project_manager.update_project_config(args.project_id, config_updates)
|
|
438
|
+
print(f"Configuration updated for project '{args.project_id}'")
|
|
439
|
+
return
|
|
440
|
+
|
|
441
|
+
# Show current config
|
|
442
|
+
config_path = project_manager.get_project_config_path(args.project_id)
|
|
443
|
+
if config_path.exists():
|
|
444
|
+
print(f"Current configuration for project '{args.project_id}':")
|
|
445
|
+
print(f"File: {config_path}")
|
|
446
|
+
print("\n" + "="*50)
|
|
447
|
+
with open(config_path, 'r') as f:
|
|
448
|
+
print(f.read())
|
|
449
|
+
else:
|
|
450
|
+
print(f"No configuration file found for project '{args.project_id}'")
|
|
451
|
+
print(f"Create one with: python -m mlops.main config {args.project_id} --file path/to/config.yaml")
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def main():
|
|
455
|
+
"""Main CLI entry point."""
|
|
456
|
+
parser = argparse.ArgumentParser(
|
|
457
|
+
description="MLOps Platform with Project-based Workflows",
|
|
458
|
+
prog="python -m mlops.main"
|
|
459
|
+
)
|
|
460
|
+
parser.add_argument(
|
|
461
|
+
"--workspace",
|
|
462
|
+
"-w",
|
|
463
|
+
help="Workspace root directory (contains projects/). Defaults to MLOPS_WORKSPACE_DIR or current directory.",
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
subparsers = parser.add_subparsers(dest='command', help='Available commands')
|
|
467
|
+
|
|
468
|
+
# Create project command
|
|
469
|
+
create_parser = subparsers.add_parser('create', help='Create a new project')
|
|
470
|
+
create_parser.add_argument('project_id', help='Project identifier (e.g., my-project)')
|
|
471
|
+
create_group = create_parser.add_mutually_exclusive_group()
|
|
472
|
+
create_group.add_argument('--config', '-c', help='Base configuration file to copy')
|
|
473
|
+
create_group.add_argument(
|
|
474
|
+
'--template',
|
|
475
|
+
'-t',
|
|
476
|
+
help="Create from a built-in template (e.g., 'sklearn-basic')",
|
|
477
|
+
)
|
|
478
|
+
create_parser.add_argument('--description', '-d', help='Project description')
|
|
479
|
+
create_parser.set_defaults(func=create_project_command)
|
|
480
|
+
|
|
481
|
+
# Delete project command
|
|
482
|
+
delete_parser = subparsers.add_parser('delete', help='Delete a project')
|
|
483
|
+
delete_parser.add_argument('project_id', help='Project to delete')
|
|
484
|
+
delete_parser.add_argument('--force', '-f', action='store_true', help='Skip confirmation prompt')
|
|
485
|
+
delete_parser.set_defaults(func=delete_project_command)
|
|
486
|
+
|
|
487
|
+
# List projects command
|
|
488
|
+
list_parser = subparsers.add_parser('list', help='List all projects')
|
|
489
|
+
list_parser.set_defaults(func=list_projects_command)
|
|
490
|
+
|
|
491
|
+
# Run project command
|
|
492
|
+
run_parser = subparsers.add_parser('run', help='Run a project pipeline')
|
|
493
|
+
run_parser.add_argument('project_id', help='Project to run')
|
|
494
|
+
run_parser.add_argument('--local', '-l', action='store_true', help='Force local run even if a cluster_config.yaml exists')
|
|
495
|
+
run_parser.set_defaults(func=run_project_command)
|
|
496
|
+
|
|
497
|
+
# Config project command
|
|
498
|
+
config_parser = subparsers.add_parser('config', help='Manage project configuration')
|
|
499
|
+
config_parser.add_argument('project_id', help='Project to configure')
|
|
500
|
+
config_parser.add_argument('--file', '-f', help='Set configuration from file')
|
|
501
|
+
config_parser.add_argument('--set', '-s', action='append', help='Set configuration value (key=value)')
|
|
502
|
+
config_parser.set_defaults(func=config_project_command)
|
|
503
|
+
|
|
504
|
+
# Parse and execute
|
|
505
|
+
args = parser.parse_args()
|
|
506
|
+
|
|
507
|
+
# Apply workspace override early so all downstream components resolve paths consistently.
|
|
508
|
+
try:
|
|
509
|
+
if getattr(args, "workspace", None):
|
|
510
|
+
os.environ[ENV_WORKSPACE_DIR] = str(Path(args.workspace).expanduser().resolve())
|
|
511
|
+
except Exception:
|
|
512
|
+
pass
|
|
513
|
+
# Ensure relative paths in configs (e.g., "projects/<id>/...") resolve against the workspace.
|
|
514
|
+
try:
|
|
515
|
+
os.chdir(get_workspace_root())
|
|
516
|
+
except Exception:
|
|
517
|
+
pass
|
|
518
|
+
|
|
519
|
+
if not args.command:
|
|
520
|
+
parser.print_help()
|
|
521
|
+
raise SystemExit(1)
|
|
522
|
+
|
|
523
|
+
args.func(args)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
if __name__ == '__main__':
|
|
527
|
+
main()
|