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
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .context import ChartContext
|
|
6
|
+
from .registry import chart
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def run_chart_entrypoint(*args: Any, **kwargs: Any) -> int:
|
|
10
|
+
from .entrypoint import run_chart_entrypoint as _impl
|
|
11
|
+
|
|
12
|
+
return _impl(*args, **kwargs)
|
|
13
|
+
|
|
14
|
+
__all__ = ["chart", "ChartContext", "run_chart_entrypoint"]
|
|
15
|
+
|
|
16
|
+
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ChartContext:
|
|
9
|
+
"""Runtime context passed to chart functions.
|
|
10
|
+
|
|
11
|
+
The platform supports two chart styles:
|
|
12
|
+
- **static**: chart function receives resolved `metrics` (dict)
|
|
13
|
+
- **dynamic**: chart function receives `probe_paths` and may subscribe to updates
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
output_dir: Path,
|
|
19
|
+
project_id: str,
|
|
20
|
+
run_id: str,
|
|
21
|
+
kv_path: str,
|
|
22
|
+
probe_id: Optional[str],
|
|
23
|
+
theme: str,
|
|
24
|
+
chart_name: str,
|
|
25
|
+
metrics: Optional[dict[str, Any]] = None,
|
|
26
|
+
probe_ids: Optional[dict[str, str]] = None,
|
|
27
|
+
chart_type: str = "static",
|
|
28
|
+
):
|
|
29
|
+
self.output_dir = output_dir
|
|
30
|
+
self.project_id = project_id
|
|
31
|
+
self.run_id = run_id
|
|
32
|
+
self.kv_path = kv_path
|
|
33
|
+
self.probe_id = probe_id
|
|
34
|
+
self.theme = theme
|
|
35
|
+
self.chart_name = chart_name
|
|
36
|
+
self.metrics = metrics or {}
|
|
37
|
+
self.probe_ids = probe_ids or {}
|
|
38
|
+
self.chart_type = chart_type
|
|
39
|
+
|
|
40
|
+
def _firestore_client(self) -> Optional[Any]:
|
|
41
|
+
try:
|
|
42
|
+
from google.cloud import firestore # type: ignore
|
|
43
|
+
except Exception:
|
|
44
|
+
return None
|
|
45
|
+
try:
|
|
46
|
+
return firestore.Client()
|
|
47
|
+
except Exception:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
def load_payload(self) -> Optional[dict[str, Any]]:
|
|
51
|
+
"""Deprecated helper: load a document at `self.kv_path` from Firestore (if available)."""
|
|
52
|
+
client = self._firestore_client()
|
|
53
|
+
if client is None:
|
|
54
|
+
return None
|
|
55
|
+
try:
|
|
56
|
+
root = client.collection("mlops_projects").document(self.project_id)
|
|
57
|
+
rel_path = (self.kv_path or "").strip("/")
|
|
58
|
+
parts = rel_path.split("/") if rel_path else []
|
|
59
|
+
if not parts or len(parts) % 2 != 0:
|
|
60
|
+
return None
|
|
61
|
+
ref = root
|
|
62
|
+
for i in range(0, len(parts), 2):
|
|
63
|
+
ref = ref.collection(parts[i]).document(parts[i + 1])
|
|
64
|
+
snap = ref.get()
|
|
65
|
+
return snap.to_dict() if getattr(snap, "exists", False) else None
|
|
66
|
+
except Exception:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
def get_firestore_client(self) -> Optional[Any]:
|
|
70
|
+
"""Deprecated: prefer `_firestore_client()` or KV APIs."""
|
|
71
|
+
return self._firestore_client()
|
|
72
|
+
|
|
73
|
+
# Deprecated: id-based refs no longer used
|
|
74
|
+
def get_probe_ref(self, probe_id: str) -> Optional[Any]:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
def get_probe_metrics_ref(self, probe_id: str) -> Optional[Any]:
|
|
78
|
+
# Deprecated
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
def get_probe_metrics_ref_by_path(self, probe_path: str) -> Optional[Any]:
|
|
82
|
+
"""Resolve a probe_path to its metrics document reference.
|
|
83
|
+
|
|
84
|
+
This allows dynamic charts to subscribe directly using the configured
|
|
85
|
+
probe path without dealing with probe IDs.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
probe_path: The logical probe path as specified in chart config.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Firestore document reference for the metrics document, or None if
|
|
92
|
+
it cannot be resolved or Firestore is unavailable.
|
|
93
|
+
"""
|
|
94
|
+
client = self._firestore_client()
|
|
95
|
+
if client is None:
|
|
96
|
+
return None
|
|
97
|
+
try:
|
|
98
|
+
from mlops.storage.path_utils import encode_probe_path # type: ignore
|
|
99
|
+
except Exception:
|
|
100
|
+
return None
|
|
101
|
+
try:
|
|
102
|
+
enc = encode_probe_path(probe_path)
|
|
103
|
+
return (
|
|
104
|
+
client.collection("mlops_projects")
|
|
105
|
+
.document(self.project_id)
|
|
106
|
+
.collection("metric")
|
|
107
|
+
.document(self.run_id)
|
|
108
|
+
.collection("probes_by_path")
|
|
109
|
+
.document(enc)
|
|
110
|
+
)
|
|
111
|
+
except Exception:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
def savefig(self, filename: "os.PathLike[str] | str", fig: Optional[Any] = None, **savefig_kwargs: Any) -> Path:
|
|
115
|
+
"""Save a matplotlib figure under this context's output directory.
|
|
116
|
+
|
|
117
|
+
- Ensures parent directories exist
|
|
118
|
+
- If `filename` is relative, it is resolved under `self.output_dir`
|
|
119
|
+
- If `fig` is provided, calls `fig.savefig(...)`; otherwise uses pyplot.savefig on current figure
|
|
120
|
+
|
|
121
|
+
Returns the resolved output path.
|
|
122
|
+
"""
|
|
123
|
+
out_path = Path(filename)
|
|
124
|
+
if not out_path.is_absolute():
|
|
125
|
+
out_path = self.output_dir / out_path
|
|
126
|
+
try:
|
|
127
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
except Exception:
|
|
129
|
+
# Best-effort; downstream save will raise if it truly cannot write
|
|
130
|
+
pass
|
|
131
|
+
if fig is not None:
|
|
132
|
+
fig.savefig(out_path, **savefig_kwargs)
|
|
133
|
+
else:
|
|
134
|
+
# Import locally to avoid hard dependency at module import time
|
|
135
|
+
try:
|
|
136
|
+
import matplotlib.pyplot as _plt # type: ignore
|
|
137
|
+
_plt.savefig(out_path, **savefig_kwargs)
|
|
138
|
+
except Exception:
|
|
139
|
+
# Re-raise for caller context
|
|
140
|
+
raise
|
|
141
|
+
return out_path
|
|
142
|
+
|
|
143
|
+
def get_run_status(self) -> Optional[str]:
|
|
144
|
+
"""Return lowercased run status (best-effort)."""
|
|
145
|
+
try:
|
|
146
|
+
from .kv_utils import create_kv_store
|
|
147
|
+
kv = create_kv_store(self.project_id)
|
|
148
|
+
if kv and hasattr(kv, "get_run_status"):
|
|
149
|
+
status = kv.get_run_status(self.run_id)
|
|
150
|
+
if status:
|
|
151
|
+
return str(status).lower()
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
client = self._firestore_client()
|
|
155
|
+
if client is None:
|
|
156
|
+
return None
|
|
157
|
+
try:
|
|
158
|
+
snap = (
|
|
159
|
+
client.collection("mlops_projects")
|
|
160
|
+
.document(self.project_id)
|
|
161
|
+
.collection("runs")
|
|
162
|
+
.document(self.run_id)
|
|
163
|
+
.get()
|
|
164
|
+
)
|
|
165
|
+
if not getattr(snap, "exists", False):
|
|
166
|
+
return None
|
|
167
|
+
data = snap.to_dict() or {}
|
|
168
|
+
status = data.get("status")
|
|
169
|
+
return str(status).lower() if status else None
|
|
170
|
+
except Exception:
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
def is_run_finished(self) -> bool:
|
|
174
|
+
"""Return True if the run status indicates completion/failure/cancellation.
|
|
175
|
+
|
|
176
|
+
This is a convenience predicate for chart scripts to decide when to
|
|
177
|
+
unsubscribe their listeners and exit.
|
|
178
|
+
"""
|
|
179
|
+
status = self.get_run_status()
|
|
180
|
+
return status in {"completed", "failed", "cancelled"}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
__all__ = [
|
|
184
|
+
"ChartContext",
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import inspect
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Callable, Optional
|
|
10
|
+
|
|
11
|
+
from .context import ChartContext
|
|
12
|
+
from .kv_utils import create_kv_store
|
|
13
|
+
from .registry import CHART_FUNCS
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _write_text(path: Path, content: str) -> None:
|
|
17
|
+
try:
|
|
18
|
+
path.write_text(content, encoding="utf-8")
|
|
19
|
+
except Exception:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _append_text(path: Path, content: str) -> None:
|
|
24
|
+
try:
|
|
25
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
with path.open("a", encoding="utf-8") as f:
|
|
27
|
+
f.write(content)
|
|
28
|
+
except Exception:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _is_falsey_env(name: str, default: str = "1") -> bool:
|
|
33
|
+
return str(os.environ.get(name, default)).strip().lower() in {"0", "false", "no"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _maybe_seed_chart_subprocess() -> None:
|
|
37
|
+
"""Best-effort deterministic seeding for chart subprocesses."""
|
|
38
|
+
if _is_falsey_env("MLOPS_TASK_LEVEL_SEEDING", default="1"):
|
|
39
|
+
return
|
|
40
|
+
try:
|
|
41
|
+
base_seed = int(os.environ.get("MLOPS_RANDOM_SEED") or 42)
|
|
42
|
+
except Exception:
|
|
43
|
+
base_seed = 42
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
import random
|
|
47
|
+
|
|
48
|
+
random.seed(base_seed)
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
# Avoid importing heavyweight optional deps just for seeding. If a chart script
|
|
53
|
+
# imports these libraries, they'll be present in sys.modules and we can seed.
|
|
54
|
+
try:
|
|
55
|
+
np = sys.modules.get("numpy")
|
|
56
|
+
if np is None:
|
|
57
|
+
import numpy as np # type: ignore
|
|
58
|
+
np.random.seed(base_seed) # type: ignore[attr-defined]
|
|
59
|
+
except Exception:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
torch = sys.modules.get("torch")
|
|
63
|
+
if torch is not None:
|
|
64
|
+
try:
|
|
65
|
+
torch.manual_seed(base_seed) # type: ignore[attr-defined]
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
try:
|
|
69
|
+
if getattr(torch, "cuda", None) and torch.cuda.is_available(): # type: ignore[attr-defined]
|
|
70
|
+
torch.cuda.manual_seed_all(base_seed) # type: ignore[attr-defined]
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
try:
|
|
74
|
+
torch.use_deterministic_algorithms(True) # type: ignore[attr-defined]
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
tf = sys.modules.get("tensorflow")
|
|
79
|
+
if tf is not None:
|
|
80
|
+
try:
|
|
81
|
+
tf.random.set_seed(base_seed) # type: ignore[attr-defined]
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _import_chart_modules(output_dir: Path) -> None:
|
|
87
|
+
"""Import user chart modules (best-effort) so @chart() registrations run."""
|
|
88
|
+
files_csv = os.environ.get("MLOPS_CHART_IMPORT_FILES", "").strip()
|
|
89
|
+
if files_csv:
|
|
90
|
+
for fpath in [p.strip() for p in files_csv.split(",") if p.strip()]:
|
|
91
|
+
try:
|
|
92
|
+
import hashlib
|
|
93
|
+
import importlib.util
|
|
94
|
+
|
|
95
|
+
f_abs = str(Path(fpath).resolve())
|
|
96
|
+
mod_name = f"mlops_chart_{hashlib.sha256(f_abs.encode()).hexdigest()[:12]}"
|
|
97
|
+
spec = importlib.util.spec_from_file_location(mod_name, f_abs)
|
|
98
|
+
if spec and spec.loader:
|
|
99
|
+
mod = importlib.util.module_from_spec(spec)
|
|
100
|
+
spec.loader.exec_module(mod) # type: ignore[attr-defined]
|
|
101
|
+
except Exception as exc:
|
|
102
|
+
_append_text(output_dir / "import_error.txt", f"Failed to import chart file {fpath}: {exc}\n")
|
|
103
|
+
|
|
104
|
+
def _normalize_probe_mappings(probe_paths_config: Any) -> dict[str, str]:
|
|
105
|
+
out: dict[str, str] = {}
|
|
106
|
+
if isinstance(probe_paths_config, list):
|
|
107
|
+
for item in probe_paths_config:
|
|
108
|
+
if isinstance(item, dict):
|
|
109
|
+
for key, path in item.items():
|
|
110
|
+
out[str(key)] = str(path)
|
|
111
|
+
return out
|
|
112
|
+
if isinstance(probe_paths_config, dict):
|
|
113
|
+
for key, path in probe_paths_config.items():
|
|
114
|
+
out[str(key)] = str(path)
|
|
115
|
+
return out
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _resolve_chart_fn(chart_name: str) -> Optional[Callable[..., Any]]:
|
|
119
|
+
if chart_name and chart_name in CHART_FUNCS:
|
|
120
|
+
return CHART_FUNCS[chart_name]
|
|
121
|
+
|
|
122
|
+
if not chart_name:
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
# Allow chart scripts to call `mlops.reporting.run_chart_entrypoint()` directly
|
|
126
|
+
# without @chart() by searching parent frames for a global callable.
|
|
127
|
+
frame = inspect.currentframe()
|
|
128
|
+
try:
|
|
129
|
+
cur = frame.f_back if frame else None
|
|
130
|
+
for _ in range(12):
|
|
131
|
+
if cur is None:
|
|
132
|
+
break
|
|
133
|
+
mod_name = cur.f_globals.get("__name__")
|
|
134
|
+
if isinstance(mod_name, str) and mod_name.startswith("mlops.reporting"):
|
|
135
|
+
cur = cur.f_back
|
|
136
|
+
continue
|
|
137
|
+
maybe = cur.f_globals.get(chart_name)
|
|
138
|
+
if callable(maybe):
|
|
139
|
+
return maybe
|
|
140
|
+
cur = cur.f_back
|
|
141
|
+
except Exception:
|
|
142
|
+
return None
|
|
143
|
+
finally:
|
|
144
|
+
# Avoid reference cycles through frames.
|
|
145
|
+
del frame
|
|
146
|
+
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def run_chart_entrypoint(argv: Optional[list[str]] = None, require_function: bool = True) -> int:
|
|
151
|
+
"""Standard entrypoint for user chart scripts.
|
|
152
|
+
|
|
153
|
+
- Resolves project/run from env/args
|
|
154
|
+
- For static charts: Fetches metrics from multiple probe paths
|
|
155
|
+
- For dynamic charts: Passes probe_paths directly to the user function
|
|
156
|
+
- Invokes a registered or in-module function named by MLOPS_CHART_NAME
|
|
157
|
+
with signature (metrics, ctx) for static or (probe_paths, ctx) for dynamic
|
|
158
|
+
"""
|
|
159
|
+
parser = argparse.ArgumentParser()
|
|
160
|
+
parser.add_argument("--theme", default=os.environ.get("MLOPS_CHART_THEME", "light"))
|
|
161
|
+
parser.add_argument("--project-id", default=os.environ.get("MLOPS_PROJECT_ID", ""))
|
|
162
|
+
parser.add_argument("--run-id", default=os.environ.get("MLOPS_RUN_ID", ""))
|
|
163
|
+
parser.add_argument("--oneshot", action="store_true")
|
|
164
|
+
args = parser.parse_args(argv)
|
|
165
|
+
|
|
166
|
+
output_dir = Path(os.environ.get("MLOPS_OUTPUT_DIR", "./out"))
|
|
167
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
168
|
+
|
|
169
|
+
chart_name = os.environ.get("MLOPS_CHART_NAME", "").strip()
|
|
170
|
+
chart_type = os.environ.get("MLOPS_CHART_TYPE", "static").strip().lower()
|
|
171
|
+
if chart_type not in {"static", "dynamic"}:
|
|
172
|
+
chart_type = "static"
|
|
173
|
+
|
|
174
|
+
# Best-effort: import chart modules so @chart() registration runs.
|
|
175
|
+
_import_chart_modules(output_dir)
|
|
176
|
+
_maybe_seed_chart_subprocess()
|
|
177
|
+
|
|
178
|
+
if not args.project_id:
|
|
179
|
+
_write_text(output_dir / "error.txt", "Missing --project-id or MLOPS_PROJECT_ID")
|
|
180
|
+
return 1
|
|
181
|
+
|
|
182
|
+
run_id = (args.run_id or os.environ.get("MLOPS_RUN_ID", "") or "").strip()
|
|
183
|
+
if not run_id:
|
|
184
|
+
_write_text(output_dir / "error.txt", "Missing --run-id or MLOPS_RUN_ID")
|
|
185
|
+
return 1
|
|
186
|
+
|
|
187
|
+
probe_paths_json = os.environ.get("MLOPS_PROBE_PATHS", "").strip()
|
|
188
|
+
if not probe_paths_json:
|
|
189
|
+
_write_text(
|
|
190
|
+
output_dir / "error.txt",
|
|
191
|
+
"Missing MLOPS_PROBE_PATHS. All charts must define probe_paths in config.",
|
|
192
|
+
)
|
|
193
|
+
return 1
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
probe_paths_config = json.loads(probe_paths_json)
|
|
197
|
+
except Exception as e:
|
|
198
|
+
_write_text(output_dir / "error.txt", f"Invalid MLOPS_PROBE_PATHS JSON: {e}")
|
|
199
|
+
return 1
|
|
200
|
+
|
|
201
|
+
probe_mappings = _normalize_probe_mappings(probe_paths_config)
|
|
202
|
+
|
|
203
|
+
metrics: dict[str, Any] = {}
|
|
204
|
+
kv_store = None
|
|
205
|
+
try:
|
|
206
|
+
kv_store = create_kv_store(args.project_id)
|
|
207
|
+
if kv_store and hasattr(kv_store, "get_probe_metrics_by_path"):
|
|
208
|
+
for user_key, probe_path in probe_mappings.items():
|
|
209
|
+
if chart_type != "static":
|
|
210
|
+
metrics[user_key] = {}
|
|
211
|
+
continue
|
|
212
|
+
try:
|
|
213
|
+
probe_metrics = kv_store.get_probe_metrics_by_path(run_id, probe_path) or {}
|
|
214
|
+
metrics[user_key] = probe_metrics
|
|
215
|
+
except Exception:
|
|
216
|
+
metrics[user_key] = {}
|
|
217
|
+
except Exception:
|
|
218
|
+
kv_store = None
|
|
219
|
+
|
|
220
|
+
ctx = ChartContext(
|
|
221
|
+
output_dir=output_dir,
|
|
222
|
+
project_id=args.project_id,
|
|
223
|
+
run_id=run_id,
|
|
224
|
+
kv_path="", # legacy; not used by the current runner
|
|
225
|
+
probe_id=None, # legacy; probe IDs are not used by path-based charts
|
|
226
|
+
theme=args.theme,
|
|
227
|
+
chart_name=chart_name,
|
|
228
|
+
metrics=metrics,
|
|
229
|
+
probe_ids={},
|
|
230
|
+
chart_type=chart_type,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
fn = _resolve_chart_fn(chart_name)
|
|
234
|
+
if fn is None:
|
|
235
|
+
if require_function:
|
|
236
|
+
_write_text(
|
|
237
|
+
output_dir / "error.txt",
|
|
238
|
+
f"No chart function found for '{chart_name}'. Define def {chart_name}(metrics, ctx): ... or use @chart().",
|
|
239
|
+
)
|
|
240
|
+
return 1
|
|
241
|
+
return 0
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
sig = inspect.signature(fn)
|
|
245
|
+
params = list(sig.parameters.keys())
|
|
246
|
+
num_params = len(params)
|
|
247
|
+
|
|
248
|
+
if chart_type == "dynamic":
|
|
249
|
+
first_name = params[0] if num_params >= 1 else None
|
|
250
|
+
payload = probe_mappings
|
|
251
|
+
if num_params >= 2:
|
|
252
|
+
fn(payload, ctx)
|
|
253
|
+
elif num_params == 1:
|
|
254
|
+
if first_name in ("probe_paths", "paths"):
|
|
255
|
+
fn(payload)
|
|
256
|
+
else:
|
|
257
|
+
fn(ctx)
|
|
258
|
+
else:
|
|
259
|
+
_write_text(
|
|
260
|
+
output_dir / "error.txt",
|
|
261
|
+
f"Dynamic chart function '{chart_name}' must accept at least one parameter (probe_paths or ctx).",
|
|
262
|
+
)
|
|
263
|
+
return 1
|
|
264
|
+
else:
|
|
265
|
+
if num_params >= 2:
|
|
266
|
+
fn(metrics, ctx)
|
|
267
|
+
elif num_params == 1:
|
|
268
|
+
if params[0] == "metrics":
|
|
269
|
+
fn(metrics)
|
|
270
|
+
else:
|
|
271
|
+
fn(ctx)
|
|
272
|
+
else:
|
|
273
|
+
_write_text(
|
|
274
|
+
output_dir / "error.txt",
|
|
275
|
+
f"Chart function '{chart_name}' must accept at least one parameter (metrics or ctx).",
|
|
276
|
+
)
|
|
277
|
+
return 1
|
|
278
|
+
except Exception as _fe:
|
|
279
|
+
import traceback
|
|
280
|
+
|
|
281
|
+
error_msg = f"Chart function failed: {chart_name} -> {_fe}\n{traceback.format_exc()}"
|
|
282
|
+
_write_text(output_dir / "error.txt", error_msg)
|
|
283
|
+
return 1
|
|
284
|
+
|
|
285
|
+
return 0
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
__all__ = ["run_chart_entrypoint"]
|
|
289
|
+
|
|
290
|
+
if __name__ == "__main__":
|
|
291
|
+
import sys as _sys
|
|
292
|
+
_sys.exit(run_chart_entrypoint())
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
from mlops.core.workspace import get_projects_root, get_workspace_root
|
|
8
|
+
from mlops.storage.factory import create_kv_store as _create_kv_store
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _load_backend_cfg_from_project_config(project_id: str) -> dict[str, Any]:
|
|
12
|
+
root = get_workspace_root()
|
|
13
|
+
cfg_path = get_projects_root(root) / project_id / "configs" / "project_config.yaml"
|
|
14
|
+
if not cfg_path.exists():
|
|
15
|
+
return {}
|
|
16
|
+
try:
|
|
17
|
+
import yaml # type: ignore
|
|
18
|
+
except Exception:
|
|
19
|
+
return {}
|
|
20
|
+
try:
|
|
21
|
+
cfg = yaml.safe_load(cfg_path.read_text()) or {}
|
|
22
|
+
cache_cfg = (((cfg.get("model") or {}).get("parameters") or {}).get("cache") or {})
|
|
23
|
+
backend_cfg = (cache_cfg.get("backend") or {}) if isinstance(cache_cfg, dict) else {}
|
|
24
|
+
return backend_cfg if isinstance(backend_cfg, dict) else {}
|
|
25
|
+
except Exception:
|
|
26
|
+
return {}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _as_int(val: Any) -> Optional[int]:
|
|
30
|
+
if val is None or val == "":
|
|
31
|
+
return None
|
|
32
|
+
try:
|
|
33
|
+
return int(val)
|
|
34
|
+
except Exception:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def create_kv_store(project_id: str) -> Optional[Any]:
|
|
39
|
+
"""Create a KV store instance for chart subprocesses.
|
|
40
|
+
|
|
41
|
+
Priority:
|
|
42
|
+
- `MLOPS_KV_BACKEND` (if set)
|
|
43
|
+
- project config `projects/<id>/configs/project_config.yaml` (cache.backend)
|
|
44
|
+
- environment-driven heuristics
|
|
45
|
+
"""
|
|
46
|
+
backend_cfg = _load_backend_cfg_from_project_config(project_id)
|
|
47
|
+
root = get_workspace_root()
|
|
48
|
+
project_root = get_projects_root(root) / project_id
|
|
49
|
+
return _create_kv_store(
|
|
50
|
+
project_id,
|
|
51
|
+
backend_cfg,
|
|
52
|
+
env=os.environ,
|
|
53
|
+
workspace_root=root,
|
|
54
|
+
project_root=project_root,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def resolve_kv_path_from_env_or_firestore(project_id: str, run_id: str, probe_path: str) -> Tuple[str, Optional[str]]:
|
|
59
|
+
"""Deprecated: kept for older chart scripts (probe IDs removed)."""
|
|
60
|
+
try:
|
|
61
|
+
from mlops.storage.path_utils import encode_probe_path # type: ignore
|
|
62
|
+
except Exception:
|
|
63
|
+
encode_probe_path = lambda s: s # type: ignore
|
|
64
|
+
if run_id and probe_path:
|
|
65
|
+
enc = encode_probe_path(probe_path)
|
|
66
|
+
return f"metric/{run_id}/probes_by_path/{enc}", None
|
|
67
|
+
if run_id:
|
|
68
|
+
return f"runs/{run_id}", None
|
|
69
|
+
return "", None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
__all__ = [
|
|
73
|
+
"create_kv_store",
|
|
74
|
+
"resolve_kv_path_from_env_or_firestore",
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any, TypeVar, overload
|
|
5
|
+
|
|
6
|
+
ChartFn = Callable[..., Any]
|
|
7
|
+
CHART_FUNCS: dict[str, ChartFn] = {}
|
|
8
|
+
|
|
9
|
+
_F = TypeVar("_F", bound=ChartFn)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@overload
|
|
13
|
+
def chart(fn: _F) -> _F: ...
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@overload
|
|
17
|
+
def chart(name: str | None = None) -> Callable[[_F], _F]: ...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def chart(name: str | None | _F = None):
|
|
21
|
+
"""Register a chart function by name.
|
|
22
|
+
|
|
23
|
+
The registration key is either:
|
|
24
|
+
- the function name, or
|
|
25
|
+
- the explicit `name=...`
|
|
26
|
+
|
|
27
|
+
The runner (`mlops.reporting.entrypoint`) dispatches based on `MLOPS_CHART_NAME`.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def _decorator(fn: _F) -> _F:
|
|
31
|
+
key = (name if isinstance(name, str) else getattr(fn, "__name__", "")).strip()
|
|
32
|
+
if key:
|
|
33
|
+
CHART_FUNCS[key] = fn
|
|
34
|
+
return fn
|
|
35
|
+
|
|
36
|
+
# Allow `@chart` without parentheses.
|
|
37
|
+
if callable(name) and not isinstance(name, str):
|
|
38
|
+
fn2 = name
|
|
39
|
+
name = None
|
|
40
|
+
return _decorator(fn2) # type: ignore[arg-type]
|
|
41
|
+
|
|
42
|
+
return _decorator
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"chart",
|
|
47
|
+
"CHART_FUNCS",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
mlops/runtime/context.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class RunContext:
|
|
10
|
+
"""Typed, run-scoped context passed through orchestration/execution boundaries.
|
|
11
|
+
|
|
12
|
+
This is intentionally small in Phase 2: it centralizes the identity (project/run)
|
|
13
|
+
and key resolved paths/config snapshots so components don't rely on implicit
|
|
14
|
+
global state or environment variables.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
workspace_root: Path
|
|
18
|
+
project_id: str
|
|
19
|
+
project_root: Path
|
|
20
|
+
run_id: str
|
|
21
|
+
|
|
22
|
+
runtime_python: Optional[str] = None
|
|
23
|
+
reporting_python: Optional[str] = None
|
|
24
|
+
|
|
25
|
+
cache_backend: Dict[str, Any] = field(default_factory=dict)
|
|
26
|
+
cache_config: Dict[str, Any] = field(default_factory=dict)
|
|
27
|
+
reporting_config: Dict[str, Any] = field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"RunContext",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|