cortexgrid 0.2.85__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.
@@ -0,0 +1,223 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from cortexgrid import s3_util
8
+ from cortexgrid.infra import get_mlflow_tracking_uri
9
+ from cortexgrid.jobs import stop_experiment_run_jobs
10
+ from cortexgrid.ray_util import list_ray_jobs_with_submission_id, stop_ray_job
11
+ from cortexgrid.model_storage import delete_models_for_run
12
+ from haikunator import Haikunator # type: ignore
13
+ from mlflow.tracking import MlflowClient
14
+
15
+
16
+ log = logging.getLogger(__name__)
17
+ _SINGLETON_EXPERIMENT: Experiment | None = None
18
+
19
+
20
+ @dataclass
21
+ class Experiment:
22
+ experiment_name: str
23
+ run_id: str
24
+
25
+ def run_name(self) -> str:
26
+ client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
27
+ return client.get_run(self.run_id).info.run_name or self.run_id
28
+
29
+ @classmethod
30
+ def init(cls, name: str | None = None) -> "Experiment":
31
+ """Create a new MLflow experiment+run. Once per process."""
32
+ logging.basicConfig(
33
+ level=logging.INFO,
34
+ format="%(asctime)s.%(msecs)03d %(levelname)s %(name)s: %(message)s",
35
+ datefmt="%H:%M:%S",
36
+ )
37
+
38
+ if _SINGLETON_EXPERIMENT is not None:
39
+ if name is not None and _SINGLETON_EXPERIMENT.experiment_name != name:
40
+ raise ValueError(
41
+ f"Active experiment has a different name {name} != "
42
+ f"{_SINGLETON_EXPERIMENT.experiment_name}"
43
+ )
44
+ return _SINGLETON_EXPERIMENT
45
+
46
+ experiment_name, run_id = _try_create_experiment_and_run(
47
+ experiment=name,
48
+ mlflow_tracking_uri=get_mlflow_tracking_uri(),
49
+ )
50
+ instance = cls(
51
+ experiment_name=experiment_name,
52
+ run_id=run_id,
53
+ )
54
+ set_instance(instance)
55
+ return instance
56
+
57
+ @classmethod
58
+ def from_experiment(cls, experiment_name: str, run_id: str) -> "Experiment":
59
+ """Bind to an existing MLflow experiment+run. Once per process."""
60
+ logging.basicConfig(
61
+ level=logging.INFO,
62
+ format="%(asctime)s.%(msecs)03d %(levelname)s %(name)s: %(message)s",
63
+ datefmt="%H:%M:%S",
64
+ )
65
+
66
+ if _SINGLETON_EXPERIMENT is not None:
67
+ if (
68
+ _SINGLETON_EXPERIMENT.experiment_name != experiment_name
69
+ or _SINGLETON_EXPERIMENT.run_id != run_id
70
+ ):
71
+ raise ValueError(
72
+ f"Active experiment is different to the requested one: "
73
+ f"({experiment_name}, {run_id}) != "
74
+ f"({_SINGLETON_EXPERIMENT.experiment_name}, {_SINGLETON_EXPERIMENT.run_id})"
75
+ )
76
+ return _SINGLETON_EXPERIMENT
77
+
78
+ instance = cls(
79
+ experiment_name=experiment_name,
80
+ run_id=run_id,
81
+ )
82
+ set_instance(instance)
83
+ return instance
84
+
85
+ def get_jobs(self) -> list[str]:
86
+ """Return cortexgrid job IDs submitted against this experiment+run."""
87
+ client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
88
+ return [
89
+ Path(f.path).name
90
+ for f in client.list_artifacts(self.run_id, path="job")
91
+ if f.is_dir
92
+ ]
93
+
94
+ @classmethod
95
+ def get_instance(cls) -> "Experiment":
96
+ if _SINGLETON_EXPERIMENT is None:
97
+ raise ValueError("Call Experiment.init or Experiment.from_experiment first")
98
+ return _SINGLETON_EXPERIMENT
99
+
100
+ @classmethod
101
+ def close(cls) -> None:
102
+ """Detach the active experiment so a different one can be init'd in this process."""
103
+ set_instance(None)
104
+
105
+
106
+ def set_instance(instance: Experiment | None) -> None:
107
+ global _SINGLETON_EXPERIMENT
108
+ _SINGLETON_EXPERIMENT = instance
109
+
110
+
111
+ def clear_instance() -> None:
112
+ # Use only in tests to clean between tests
113
+ global _SINGLETON_EXPERIMENT
114
+ _SINGLETON_EXPERIMENT = None
115
+
116
+
117
+ def _try_create_experiment_and_run(
118
+ experiment: str | None, mlflow_tracking_uri: str
119
+ ) -> tuple[str, str]:
120
+ name_gen = Haikunator()
121
+ if experiment is None:
122
+ experiment = name_gen.haikunate(token_length=2, token_chars="0123456789")
123
+
124
+ client = MlflowClient(tracking_uri=mlflow_tracking_uri)
125
+ experiment_obj = client.get_experiment_by_name(name=experiment)
126
+ if experiment_obj:
127
+ experiment_id = experiment_obj.experiment_id
128
+ else:
129
+ experiment_id = client.create_experiment(name=experiment)
130
+
131
+ run_name = name_gen.haikunate(token_length=2, token_chars="0123456789")
132
+ run = client.create_run(experiment_id=experiment_id, run_name=run_name)
133
+
134
+ return (experiment, run.info.run_id)
135
+
136
+
137
+ def delete_run(run_id: str) -> None:
138
+ """Soft-delete a run in MLflow, cancel its Ray attempts, and wipe its
139
+ S3 job packages so it cannot be relaunched or re-read.
140
+
141
+ stop_experiment_run_jobs runs first so the control plane stops spawning
142
+ fresh Ray attempts for retry=True jobs before we tear the run down."""
143
+ log.info("delete_run(%s): start", run_id)
144
+ stop_experiment_run_jobs(run_id)
145
+ log.info("delete_run(%s): stop_experiment_run_jobs done", run_id)
146
+ client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
147
+ job_ids = [
148
+ Path(f.path).name for f in client.list_artifacts(run_id, path="job") if f.is_dir
149
+ ]
150
+ log.info("delete_run(%s): %d job artifact(s) to clean", run_id, len(job_ids))
151
+ all_submissions = list_ray_jobs_with_submission_id()
152
+ for job_id in job_ids:
153
+ prefix = f"{run_id}-{job_id}-"
154
+ for sid in all_submissions:
155
+ if sid.startswith(prefix):
156
+ stop_ray_job(sid)
157
+ s3_util.delete_prefix(f"job/{job_id}/")
158
+ delete_models_for_run(run_id)
159
+ client.delete_run(run_id)
160
+ log.info("delete_run(%s): done", run_id)
161
+
162
+
163
+ def list_run_ids_in_experiment(name: str) -> list[str]:
164
+ """Return the run IDs of every active run in the named experiment."""
165
+ client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
166
+ exp = client.get_experiment_by_name(name)
167
+ if exp is None:
168
+ return []
169
+ return [
170
+ r.info.run_id for r in client.search_runs(experiment_ids=[exp.experiment_id])
171
+ ]
172
+
173
+
174
+ def delete_experiment(name: str) -> None:
175
+ """Soft-delete every run in the experiment, then the experiment itself.
176
+
177
+ Idempotent: already-deleted experiments are treated as success."""
178
+ log.info("delete_experiment(%r): start", name)
179
+ client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
180
+ exp = client.get_experiment_by_name(name)
181
+ if exp is None:
182
+ log.info("delete_experiment(%r): early-exit, experiment not found", name)
183
+ return
184
+ if exp.lifecycle_stage != "active":
185
+ log.info(
186
+ "delete_experiment(%r): early-exit, lifecycle=%s", name, exp.lifecycle_stage
187
+ )
188
+ return
189
+ runs = list(client.search_runs(experiment_ids=[exp.experiment_id]))
190
+ log.info("delete_experiment(%r): %d active run(s) to delete", name, len(runs))
191
+ for run in runs:
192
+ delete_run(run.info.run_id)
193
+ client.delete_experiment(exp.experiment_id)
194
+ log.info("delete_experiment(%r): done", name)
195
+
196
+
197
+ def list_experiments() -> list[Experiment]:
198
+ """Map MLflow experiment names to their run IDs."""
199
+ client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
200
+ result: list[Experiment] = []
201
+ for exp in client.search_experiments():
202
+ runs = client.search_runs(experiment_ids=[exp.experiment_id])
203
+ for run in runs:
204
+ result.append(Experiment(exp.name, run_id=run.info.run_id))
205
+ return result
206
+
207
+
208
+ def get_experiment_by_run_name(run_name: str) -> Experiment:
209
+ """Resolve a run by its haikunator name back to its (experiment_name, run_id) pair."""
210
+ client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
211
+ experiment_ids = [e.experiment_id for e in client.search_experiments()]
212
+ if not experiment_ids:
213
+ raise ValueError(f"No run named {run_name!r}")
214
+ runs = client.search_runs(
215
+ experiment_ids=experiment_ids,
216
+ filter_string=f"attributes.run_name = '{run_name}'",
217
+ max_results=1,
218
+ )
219
+ if not runs:
220
+ raise ValueError(f"No run named {run_name!r}")
221
+ run = runs[0]
222
+ exp = client.get_experiment(run.info.experiment_id)
223
+ return Experiment(experiment_name=exp.name, run_id=run.info.run_id)
cortexgrid/infra.py ADDED
@@ -0,0 +1,39 @@
1
+ from cortexgrid.secrets import get_secret
2
+ from mlflow.tracking import MlflowClient
3
+
4
+
5
+ def get_mlflow_tracking_uri() -> str:
6
+ return get_secret("MLFLOW_TRACKING_URI")
7
+
8
+
9
+ def get_ray_job_server_uri() -> str:
10
+ return get_secret("RAY_JOB_SERVER_URI")
11
+
12
+
13
+ def get_ray_serve_uri() -> str:
14
+ """HTTP base URL where Ray Serve apps are reachable (data plane, port 8000)."""
15
+ return get_secret("RAY_SERVE_URI")
16
+
17
+
18
+ def get_ray_serve_applications_uri() -> str:
19
+ """Dashboard REST endpoint for declarative Serve app management."""
20
+ return f"{get_ray_job_server_uri()}/api/serve/applications/"
21
+
22
+
23
+ def get_s3_endpoint_url() -> str:
24
+ # AWS profile: regional s3.amazonaws.com URL (stored as "" = no override).
25
+ # On-prem: tailnet-reachable MinIO NodePort URL.
26
+ return get_secret("S3_ENDPOINT_URL")
27
+
28
+
29
+ def get_s3_bucket() -> str:
30
+ # Provisioned by terraform/platform/s3 on AWS; created lazily on first
31
+ # upload against on-prem MinIO.
32
+ return get_secret("S3_BUCKET_NAME")
33
+
34
+
35
+ def get_mlflow_run_url(run_id: str) -> str:
36
+ base = get_mlflow_tracking_uri()
37
+ client = MlflowClient(tracking_uri=base)
38
+ run = client.get_run(run_id)
39
+ return f"{base}/#/experiments/{run.info.experiment_id}/runs/{run_id}"
cortexgrid/jobs.py ADDED
@@ -0,0 +1,292 @@
1
+ """Submit tasks to the cortexgrid control plane."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import cloudpickle # type: ignore
6
+ from dataclasses import asdict, dataclass, field
7
+ import inspect
8
+ import io
9
+ import json
10
+ import logging
11
+ from pathlib import Path
12
+ import sys
13
+ import tarfile
14
+ import tempfile
15
+ from typing import Any, Callable
16
+
17
+ from cortexgrid import s3_util
18
+ from cortexgrid._bundle import bundle, stage, worker_provides
19
+ from cortexgrid.infra import get_mlflow_tracking_uri
20
+ from cortexgrid.ray_util import get_ray_job_id_for_cortexgrid_job
21
+ from haikunator import Haikunator # type: ignore
22
+ from mlflow.tracking import MlflowClient
23
+ from pydantic import BaseModel, ConfigDict
24
+
25
+ log = logging.getLogger(__name__)
26
+
27
+
28
+ @dataclass
29
+ class LifecycleEvent:
30
+ """A single observed state on a given attempt of a job.
31
+
32
+ ``start`` and ``end`` are ISO 8601 timestamps. ``end`` is ``None``
33
+ while the state is still current; it is set when a subsequent
34
+ observation shows the (attempt, state) pair has changed.
35
+ ``ray_job_id`` is the Ray submission id observed at record time and
36
+ is ``None`` for the pre-submission PENDING entry of a given attempt.
37
+ ``error`` carries a worker-submission exception message and is
38
+ attached to the event that was current when the worker failed.
39
+ """
40
+
41
+ attempt: int
42
+ state: str
43
+ start: str
44
+ end: str | None = None
45
+ ray_job_id: str | None = None
46
+ error: str | None = None
47
+
48
+
49
+ @dataclass
50
+ class JobLifecycle:
51
+ """Static identity and latches for a job.
52
+
53
+ JobLifecycle is the source of truth for *job identity* and for a
54
+ handful of fields that are either immutable or can only change once
55
+ over the lifetime of a job. Live execution status is never stored
56
+ here — it is derived on demand from Ray by :func:`get_ray_job_status`.
57
+ """
58
+
59
+ experiment_name: str
60
+ run_id: str
61
+ job_id: str
62
+ stop_requested: bool = False # latch: False -> True, never cleared
63
+ retry: bool = False # static flag set at job creation
64
+ num_gpus: int = 0
65
+ num_cpus: int = 1
66
+ history: list[LifecycleEvent] = field(default_factory=list)
67
+
68
+ def to_json(self) -> str:
69
+ return json.dumps(asdict(self))
70
+
71
+ @classmethod
72
+ def from_json(cls, text: str) -> "JobLifecycle":
73
+ data = json.loads(text)
74
+ data.pop("error", None)
75
+ data["history"] = [LifecycleEvent(**e) for e in data.get("history", [])]
76
+ return cls(**data)
77
+
78
+ def get_ray_job_id(
79
+ self, all_ray_submission_ids: list[str] | None = None
80
+ ) -> str | None:
81
+ return get_ray_job_id_for_cortexgrid_job(
82
+ self.run_id, self.job_id, all_ray_submission_ids
83
+ )
84
+
85
+ def download_project_code_root(self) -> str:
86
+ client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
87
+ manifest_rel = f"job/{self.job_id}/manifest.json"
88
+ if not any(
89
+ a.path == manifest_rel
90
+ for a in client.list_artifacts(self.run_id, f"job/{self.job_id}")
91
+ ):
92
+ raise FileNotFoundError(
93
+ f"artifact {manifest_rel} not found in run {self.run_id}"
94
+ )
95
+ manifest_path = client.download_artifacts(self.run_id, manifest_rel)
96
+ manifest = json.loads(Path(manifest_path).read_text())
97
+ _, _, src_path = (
98
+ manifest["code_tarball_uri"].removeprefix("s3://").partition("/")
99
+ )
100
+ extract_dir = Path(tempfile.mkdtemp())
101
+ tarball_local = s3_util.download(
102
+ src_path, local_path=str(extract_dir / "project_code_root.tar.gz")
103
+ )
104
+ with tarfile.open(tarball_local, "r:gz") as tar:
105
+ tar.extractall(extract_dir)
106
+ return str(extract_dir / "project_code_root")
107
+
108
+ def save_to_mlflow(self) -> None:
109
+ artifact_path = f"job/{self.job_id}"
110
+ client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
111
+ log.info(
112
+ "Saving lifecycle for job %s (stop_requested=%s)",
113
+ self.job_id,
114
+ self.stop_requested,
115
+ )
116
+
117
+ with tempfile.TemporaryDirectory() as tmp_dir:
118
+ local_path = Path(tmp_dir, "lifecycle.json")
119
+ local_path.write_text(self.to_json())
120
+ client.log_artifact(
121
+ self.run_id, str(local_path), artifact_path=artifact_path
122
+ )
123
+
124
+ @classmethod
125
+ def load_from_mlflow(cls, run_id: str, job_id: str) -> "JobLifecycle":
126
+ client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
127
+ lifecycle_rel = f"job/{job_id}/lifecycle.json"
128
+ if not any(
129
+ a.path == lifecycle_rel
130
+ for a in client.list_artifacts(run_id, f"job/{job_id}")
131
+ ):
132
+ raise FileNotFoundError(
133
+ f"artifact {lifecycle_rel} not found in run {run_id}"
134
+ )
135
+ local_path = client.download_artifacts(run_id, lifecycle_rel)
136
+ return cls.from_json(Path(local_path).read_text())
137
+
138
+
139
+ class Payload(BaseModel):
140
+ model_config = ConfigDict(arbitrary_types_allowed=True)
141
+
142
+ experiment_name: str
143
+ run_id: str
144
+ job_id: str
145
+ fn: Callable[..., Any]
146
+ args: tuple[Any, ...]
147
+ kwargs: dict[str, Any]
148
+ project_code_root: str
149
+ num_gpus: int = 0
150
+ num_cpus: int = 1
151
+
152
+ def save_to_mlflow(self) -> None:
153
+ artifact_path = f"job/{self.job_id}"
154
+ client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
155
+ log.info(
156
+ "Uploading payload for job %s from %s", self.job_id, self.project_code_root
157
+ )
158
+ with tempfile.TemporaryDirectory() as tmp_dir:
159
+ tarball_path = Path(tmp_dir, "project_code_root.tar.gz")
160
+ payload_bytes = cloudpickle.dumps(self)
161
+ with tarfile.open(tarball_path, "w:gz") as tar:
162
+ tar.add(self.project_code_root, arcname="project_code_root")
163
+ info = tarfile.TarInfo("project_code_root/payload.pkl")
164
+ info.size = len(payload_bytes)
165
+ tar.addfile(info, io.BytesIO(payload_bytes))
166
+ tarball_uri = s3_util.upload(
167
+ str(tarball_path),
168
+ dest_path=f"{artifact_path}/project_code_root.tar.gz",
169
+ )
170
+ manifest_path = Path(tmp_dir, "manifest.json")
171
+ manifest_path.write_text(json.dumps({"code_tarball_uri": tarball_uri}))
172
+ client.log_artifact(
173
+ self.run_id, str(manifest_path), artifact_path=artifact_path
174
+ )
175
+ log.info("Payload upload complete for job %s", self.job_id)
176
+
177
+ @classmethod
178
+ def load_from_mlflow(cls, run_id: str, job_id: str) -> "Payload":
179
+ client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
180
+ log.info("Downloading payload for job %s", job_id)
181
+ manifest_rel = f"job/{job_id}/manifest.json"
182
+ if not any(
183
+ a.path == manifest_rel
184
+ for a in client.list_artifacts(run_id, f"job/{job_id}")
185
+ ):
186
+ raise FileNotFoundError(
187
+ f"artifact {manifest_rel} not found in run {run_id}"
188
+ )
189
+ manifest_path = client.download_artifacts(run_id, manifest_rel)
190
+ manifest = json.loads(Path(manifest_path).read_text())
191
+ _, _, src_path = (
192
+ manifest["code_tarball_uri"].removeprefix("s3://").partition("/")
193
+ )
194
+ extract_dir = Path(tempfile.mkdtemp())
195
+ tarball_local = s3_util.download(
196
+ src_path, local_path=str(extract_dir / "project_code_root.tar.gz")
197
+ )
198
+ with tarfile.open(tarball_local, "r:gz") as tar:
199
+ tar.extractall(extract_dir)
200
+ project_code_root = str(extract_dir / "project_code_root")
201
+ sys.path.insert(0, project_code_root)
202
+ try:
203
+ payload = cloudpickle.loads(
204
+ Path(project_code_root, "payload.pkl").read_bytes()
205
+ )
206
+ finally:
207
+ sys.path.remove(project_code_root)
208
+ payload.project_code_root = project_code_root
209
+ log.info(
210
+ "Payload downloaded for job %s, project_code_root=%s",
211
+ job_id,
212
+ payload.project_code_root,
213
+ )
214
+
215
+ return payload
216
+
217
+
218
+ def schedule_remote_job(
219
+ experiment_name: str,
220
+ run_id: str,
221
+ fn: Callable[..., Any],
222
+ *args: Any,
223
+ num_gpus: int = 0,
224
+ num_cpus: int = 1,
225
+ retry: bool = False,
226
+ **kwargs: Any,
227
+ ) -> str:
228
+ """Submit a function to the control plane. Returns a job ID."""
229
+ job_id = Haikunator().haikunate(token_length=2, token_chars="0123456789")
230
+ entry_file = Path(inspect.getfile(fn)).resolve()
231
+ driver_file = Path(__file__).with_name("_ray_job_driver.py")
232
+ files = bundle(entry_file).merge(bundle(driver_file)).local_files - worker_provides()
233
+ with tempfile.TemporaryDirectory() as tmp:
234
+ code_root = Path(tmp, "project_code_root")
235
+ stage(files, code_root)
236
+ log.info("Submitting job %s (%d files)", job_id, len(files))
237
+ Payload(
238
+ experiment_name=experiment_name,
239
+ run_id=run_id,
240
+ job_id=job_id,
241
+ fn=fn,
242
+ args=args,
243
+ kwargs=kwargs,
244
+ project_code_root=str(code_root),
245
+ num_gpus=num_gpus,
246
+ num_cpus=num_cpus,
247
+ ).save_to_mlflow()
248
+ JobLifecycle(
249
+ experiment_name=experiment_name,
250
+ run_id=run_id,
251
+ job_id=job_id,
252
+ retry=retry,
253
+ num_gpus=num_gpus,
254
+ num_cpus=num_cpus,
255
+ ).save_to_mlflow()
256
+ return job_id
257
+
258
+
259
+ def list_experiment_run_jobs(run_id: str) -> list[JobLifecycle]:
260
+ """Return all jobs and their lifecycle states for this experiment+run."""
261
+ client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
262
+ entries = client.list_artifacts(run_id, path="job")
263
+ result: list[JobLifecycle] = []
264
+ for entry in entries:
265
+ if not entry.is_dir:
266
+ continue
267
+ job_id = Path(entry.path).name
268
+ try:
269
+ result.append(JobLifecycle.load_from_mlflow(run_id, job_id))
270
+ except Exception:
271
+ logging.getLogger(__name__).warning(
272
+ "Skipping job %s: missing lifecycle", job_id
273
+ )
274
+ return result
275
+
276
+
277
+ def stop_experiment_run_jobs(run_id: str) -> None:
278
+ """Request all jobs in the run to stop by flipping the stop_requested latch.
279
+
280
+ This function never touches Ray. The control plane observes the
281
+ latch on its next poll and calls `ray.stop_job` for any job that
282
+ has reached Ray. For jobs that have not yet been submitted, the
283
+ latch short-circuits the submission path in the worker.
284
+
285
+ Idempotent: already-requested jobs are skipped, and the flag has
286
+ no effect on jobs that Ray already reports as terminal.
287
+ """
288
+ for job in list_experiment_run_jobs(run_id):
289
+ if job.stop_requested:
290
+ continue
291
+ job.stop_requested = True
292
+ job.save_to_mlflow()
@@ -0,0 +1,110 @@
1
+ """MLflow wrappers.
2
+
3
+ Thin layer that configures MLflow tracking URI and S3 credentials,
4
+ then exposes convenience functions for common operations.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from typing import Any
11
+
12
+ import requests
13
+
14
+ from cortexgrid.experiment import Experiment
15
+ from cortexgrid.infra import get_mlflow_tracking_uri
16
+ from mlflow.entities import Metric
17
+ from mlflow.tracking import MlflowClient
18
+
19
+
20
+ def log_metric(key: str, value: float, step: int | None = None) -> None:
21
+ """Log a metric to the current active MLflow run."""
22
+ experiment = Experiment.get_instance()
23
+
24
+ client = get_mlflow_client()
25
+ client.log_metric(experiment.run_id, key, value, step=step)
26
+
27
+
28
+ def log_metrics(metrics: dict[str, float], step: int | None = None) -> None:
29
+ """Log multiple metrics to the current active MLflow run."""
30
+ experiment = Experiment.get_instance()
31
+
32
+ client = get_mlflow_client()
33
+ timestamp = int(time.time() * 1000)
34
+ metric_entities = [
35
+ Metric(key=k, value=v, timestamp=timestamp, step=step or 0)
36
+ for k, v in metrics.items()
37
+ ]
38
+ client.log_batch(experiment.run_id, metrics=metric_entities)
39
+
40
+
41
+ def log_params(params: dict[str, Any]) -> None:
42
+ """Log parameters to the current active MLflow run."""
43
+ experiment = Experiment.get_instance()
44
+
45
+ client = get_mlflow_client()
46
+ for key, value in params.items():
47
+ client.log_param(experiment.run_id, key, value)
48
+
49
+
50
+ def log_artifact(local_path: str, artifact_path: str | None = None) -> None:
51
+ """Log a file as an artifact to the current active MLflow run."""
52
+ experiment = Experiment.get_instance()
53
+
54
+ client = get_mlflow_client()
55
+ client.log_artifact(experiment.run_id, local_path, artifact_path=artifact_path)
56
+
57
+
58
+ def get_mlflow_client() -> MlflowClient:
59
+ """Return a configured MlflowClient."""
60
+ return MlflowClient(tracking_uri=get_mlflow_tracking_uri())
61
+
62
+
63
+ def list_run_metrics(run_id: str) -> list[str]:
64
+ """Return the metric key names logged for a run."""
65
+ client = get_mlflow_client()
66
+ run = client.get_run(run_id)
67
+ return list(run.data.metrics.keys())
68
+
69
+
70
+ def get_metric_history(
71
+ run_id: str, key: str, max_points: int | None = None
72
+ ) -> list[dict[str, Any]]:
73
+ """Return the history of a metric as [{step, value, timestamp}, ...].
74
+
75
+ When `max_points` is given, MLflow samples server-side and returns at
76
+ most that many points (saves DB work and bytes-on-the-wire). Without
77
+ `max_points`, the full history is returned.
78
+ """
79
+ if max_points is not None:
80
+ url = (
81
+ f"{get_mlflow_tracking_uri().rstrip('/')}"
82
+ "/ajax-api/2.0/mlflow/metrics/get-history-bulk-interval"
83
+ )
84
+ response = requests.get(
85
+ url,
86
+ params={"run_ids": run_id, "metric_key": key, "max_results": max_points},
87
+ )
88
+ response.raise_for_status()
89
+ return [
90
+ {"step": int(m["step"]), "value": float(m["value"]), "timestamp": int(m["timestamp"])}
91
+ for m in response.json().get("metrics", [])
92
+ ]
93
+ client = get_mlflow_client()
94
+ history = client.get_metric_history(run_id, key)
95
+ return [
96
+ {"step": m.step, "value": m.value, "timestamp": m.timestamp} for m in history
97
+ ]
98
+
99
+
100
+ def list_run_params(run_id: str) -> dict[str, str]:
101
+ """Return all parameter key/value pairs for a run."""
102
+ client = get_mlflow_client()
103
+ run = client.get_run(run_id)
104
+ return dict(run.data.params)
105
+
106
+
107
+ def list_run_artifacts(run_id: str, path: str = "") -> list[str]:
108
+ """Return artifact paths for a run."""
109
+ client = get_mlflow_client()
110
+ return [a.path for a in client.list_artifacts(run_id, path=path)]