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.
Files changed (86) hide show
  1. expops-0.1.3.dist-info/METADATA +826 -0
  2. expops-0.1.3.dist-info/RECORD +86 -0
  3. expops-0.1.3.dist-info/WHEEL +5 -0
  4. expops-0.1.3.dist-info/entry_points.txt +3 -0
  5. expops-0.1.3.dist-info/licenses/LICENSE +674 -0
  6. expops-0.1.3.dist-info/top_level.txt +1 -0
  7. mlops/__init__.py +0 -0
  8. mlops/__main__.py +11 -0
  9. mlops/_version.py +34 -0
  10. mlops/adapters/__init__.py +12 -0
  11. mlops/adapters/base.py +86 -0
  12. mlops/adapters/config_schema.py +89 -0
  13. mlops/adapters/custom/__init__.py +3 -0
  14. mlops/adapters/custom/custom_adapter.py +447 -0
  15. mlops/adapters/plugin_manager.py +113 -0
  16. mlops/adapters/sklearn/__init__.py +3 -0
  17. mlops/adapters/sklearn/adapter.py +94 -0
  18. mlops/cluster/__init__.py +3 -0
  19. mlops/cluster/controller.py +496 -0
  20. mlops/cluster/process_runner.py +91 -0
  21. mlops/cluster/providers.py +258 -0
  22. mlops/core/__init__.py +95 -0
  23. mlops/core/custom_model_base.py +38 -0
  24. mlops/core/dask_networkx_executor.py +1265 -0
  25. mlops/core/executor_worker.py +1239 -0
  26. mlops/core/experiment_tracker.py +81 -0
  27. mlops/core/graph_types.py +64 -0
  28. mlops/core/networkx_parser.py +135 -0
  29. mlops/core/payload_spill.py +278 -0
  30. mlops/core/pipeline_utils.py +162 -0
  31. mlops/core/process_hashing.py +216 -0
  32. mlops/core/step_state_manager.py +1298 -0
  33. mlops/core/step_system.py +956 -0
  34. mlops/core/workspace.py +99 -0
  35. mlops/environment/__init__.py +10 -0
  36. mlops/environment/base.py +43 -0
  37. mlops/environment/conda_manager.py +307 -0
  38. mlops/environment/factory.py +70 -0
  39. mlops/environment/pyenv_manager.py +146 -0
  40. mlops/environment/setup_env.py +31 -0
  41. mlops/environment/system_manager.py +66 -0
  42. mlops/environment/utils.py +105 -0
  43. mlops/environment/venv_manager.py +134 -0
  44. mlops/main.py +527 -0
  45. mlops/managers/project_manager.py +400 -0
  46. mlops/managers/reproducibility_manager.py +575 -0
  47. mlops/platform.py +996 -0
  48. mlops/reporting/__init__.py +16 -0
  49. mlops/reporting/context.py +187 -0
  50. mlops/reporting/entrypoint.py +292 -0
  51. mlops/reporting/kv_utils.py +77 -0
  52. mlops/reporting/registry.py +50 -0
  53. mlops/runtime/__init__.py +9 -0
  54. mlops/runtime/context.py +34 -0
  55. mlops/runtime/env_export.py +113 -0
  56. mlops/storage/__init__.py +12 -0
  57. mlops/storage/adapters/__init__.py +9 -0
  58. mlops/storage/adapters/gcp_kv_store.py +778 -0
  59. mlops/storage/adapters/gcs_object_store.py +96 -0
  60. mlops/storage/adapters/memory_store.py +240 -0
  61. mlops/storage/adapters/redis_store.py +438 -0
  62. mlops/storage/factory.py +199 -0
  63. mlops/storage/interfaces/__init__.py +6 -0
  64. mlops/storage/interfaces/kv_store.py +118 -0
  65. mlops/storage/path_utils.py +38 -0
  66. mlops/templates/premier-league/charts/plot_metrics.js +70 -0
  67. mlops/templates/premier-league/charts/plot_metrics.py +145 -0
  68. mlops/templates/premier-league/charts/requirements.txt +6 -0
  69. mlops/templates/premier-league/configs/cluster_config.yaml +13 -0
  70. mlops/templates/premier-league/configs/project_config.yaml +207 -0
  71. mlops/templates/premier-league/data/England CSV.csv +12154 -0
  72. mlops/templates/premier-league/models/premier_league_model.py +638 -0
  73. mlops/templates/premier-league/requirements.txt +8 -0
  74. mlops/templates/sklearn-basic/README.md +22 -0
  75. mlops/templates/sklearn-basic/charts/plot_metrics.py +85 -0
  76. mlops/templates/sklearn-basic/charts/requirements.txt +3 -0
  77. mlops/templates/sklearn-basic/configs/project_config.yaml +64 -0
  78. mlops/templates/sklearn-basic/data/train.csv +14 -0
  79. mlops/templates/sklearn-basic/models/model.py +62 -0
  80. mlops/templates/sklearn-basic/requirements.txt +10 -0
  81. mlops/web/__init__.py +3 -0
  82. mlops/web/server.py +585 -0
  83. mlops/web/ui/index.html +52 -0
  84. mlops/web/ui/mlops-charts.js +357 -0
  85. mlops/web/ui/script.js +1244 -0
  86. 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()