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
@@ -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
+
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from .context import RunContext
4
+
5
+ __all__ = [
6
+ "RunContext",
7
+ ]
8
+
9
+
@@ -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
+