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.
cortexgrid/__init__.py ADDED
@@ -0,0 +1,195 @@
1
+ """cortexgrid — connect your code to the RoboLab compute cluster.
2
+
3
+ import cortexgrid
4
+
5
+ # Fire-and-forget training on the DGX; returns a job id immediately.
6
+ # The jobs control plane picks up the submission and dispatches it to Ray.
7
+ job_id = cortexgrid.remote(my_train, config, num_gpus=1, retry=True)
8
+ print(f"Submitted: {job_id}")
9
+
10
+ # Check on it later
11
+ for job in cortexgrid.list_experiment_run_jobs(run_id):
12
+ status = cortexgrid.get_ray_job_status(job.get_ray_job_id())
13
+
14
+ # Inside the training function — checkpoint after each epoch
15
+ with cortexgrid.checkpoint() as ckpt:
16
+ ckpt.epoch = epoch
17
+ ckpt.save_training_state(model, optimizer, scheduler)
18
+
19
+ # On resume — load checkpoint if it exists
20
+ ckpt = cortexgrid.resume()
21
+ if ckpt:
22
+ ckpt.restore_training_state(model, optimizer, scheduler)
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from pathlib import Path
28
+ from typing import Any, Callable
29
+
30
+ from cortexgrid.checkpoint import checkpoint, resume
31
+ from cortexgrid.experiment import (
32
+ Experiment,
33
+ delete_experiment,
34
+ delete_run,
35
+ list_experiments,
36
+ )
37
+ from cortexgrid.infra import get_ray_job_server_uri
38
+ from cortexgrid.jobs import (
39
+ schedule_remote_job,
40
+ list_experiment_run_jobs,
41
+ stop_experiment_run_jobs,
42
+ JobLifecycle,
43
+ LifecycleEvent,
44
+ Payload,
45
+ )
46
+ from cortexgrid.secrets import (
47
+ delete_secret,
48
+ get_secret,
49
+ list_secrets,
50
+ set_secret,
51
+ )
52
+ from cortexgrid.mlflow_util import (
53
+ log_metric,
54
+ log_metrics,
55
+ log_params,
56
+ log_artifact,
57
+ get_mlflow_client,
58
+ list_run_metrics,
59
+ get_metric_history,
60
+ list_run_params,
61
+ list_run_artifacts,
62
+ )
63
+ from cortexgrid.ray_util import (
64
+ get_ray_status,
65
+ get_ray_logs,
66
+ get_ray_job_url,
67
+ stop_ray_job,
68
+ submit_ray_job,
69
+ list_ray_jobs_with_submission_id,
70
+ get_ray_job_status,
71
+ ray_submission_id,
72
+ get_ray_job_attempt,
73
+ JobStatus,
74
+ )
75
+ from cortexgrid.s3_util import delete_prefix, download, get_s3_client, upload, upload_dir
76
+ from cortexgrid.model_storage import (
77
+ SavedModel,
78
+ delete_model,
79
+ list_models,
80
+ load_model,
81
+ model_registry_status,
82
+ )
83
+ from cortexgrid.model_storage import save_model as _save_model_storage
84
+ from cortexgrid.model_serving import (
85
+ Deployment,
86
+ ServingStatus,
87
+ deploy_model,
88
+ list_deployed_models,
89
+ model_serving_status,
90
+ undeploy_model,
91
+ )
92
+
93
+
94
+ def remote(
95
+ fn: Callable[..., Any],
96
+ *args: Any,
97
+ num_gpus: int = 0,
98
+ num_cpus: int = 1,
99
+ retry: bool = False,
100
+ **kwargs: Any,
101
+ ) -> str:
102
+ """Submit a function to the control plane. Returns a job ID."""
103
+ experiment = Experiment.get_instance()
104
+ return schedule_remote_job(
105
+ experiment.experiment_name,
106
+ experiment.run_id,
107
+ fn,
108
+ *args,
109
+ num_gpus=num_gpus,
110
+ num_cpus=num_cpus,
111
+ retry=retry,
112
+ **kwargs,
113
+ )
114
+
115
+
116
+ def save_model(
117
+ weights_dir: str | Path, serve_app: type, family: str, suffix: str
118
+ ) -> SavedModel:
119
+ """Persist a weights directory under the current Experiment's run, paired
120
+ with the serve-app class that will front it at deploy time."""
121
+ experiment = Experiment.get_instance()
122
+ return _save_model_storage(
123
+ weights_dir,
124
+ serve_app,
125
+ suffix,
126
+ family,
127
+ run_id=experiment.run_id,
128
+ run_name=experiment.run_name(),
129
+ )
130
+
131
+
132
+ __all__ = [
133
+ "Experiment",
134
+ "delete_experiment",
135
+ "delete_run",
136
+ # Ray / jobs
137
+ "remote",
138
+ "get_ray_job_status",
139
+ "list_experiment_run_jobs",
140
+ "stop_experiment_run_jobs",
141
+ "JobStatus",
142
+ "JobLifecycle",
143
+ "LifecycleEvent",
144
+ "Payload",
145
+ "get_ray_status",
146
+ "get_ray_logs",
147
+ "get_ray_job_url",
148
+ "stop_ray_job",
149
+ "submit_ray_job",
150
+ "get_ray_job_server_uri",
151
+ "list_ray_jobs_with_submission_id",
152
+ "ray_submission_id",
153
+ "get_ray_job_attempt",
154
+ # MLflow
155
+ "log_metric",
156
+ "log_metrics",
157
+ "log_params",
158
+ "log_artifact",
159
+ "get_mlflow_client",
160
+ "list_run_metrics",
161
+ "get_metric_history",
162
+ "list_run_params",
163
+ "list_run_artifacts",
164
+ "list_experiments",
165
+ # Checkpointing
166
+ "checkpoint",
167
+ "resume",
168
+ # S3
169
+ "upload",
170
+ "upload_dir",
171
+ "download",
172
+ "get_s3_client",
173
+ "delete_prefix",
174
+ # Secrets
175
+ "get_secret",
176
+ "set_secret",
177
+ "list_secrets",
178
+ "delete_secret",
179
+ # Model registry
180
+ "SavedModel",
181
+ "save_model",
182
+ "load_model",
183
+ "list_models",
184
+ "model_registry_status",
185
+ "delete_model",
186
+ # Model serving
187
+ "Deployment",
188
+ "ServingStatus",
189
+ "deploy_model",
190
+ "model_serving_status",
191
+ "undeploy_model",
192
+ "list_deployed_models",
193
+ ]
194
+
195
+ # trigger: 315-trigger-tests-2026-05-16
cortexgrid/_bundle.py ADDED
@@ -0,0 +1,207 @@
1
+ """Code bundler: the files needed to run a piece of Python on another machine.
2
+
3
+ `bundle(seed)` describes what is needed to run the module at `seed`: the local
4
+ files it reaches -- following its import graph and each package's __init__
5
+ chain, resolving imports the way the interpreter does -- and the third-party
6
+ dependencies those files import. A file is local unless it lives in an installed
7
+ package location (site-packages / dist-packages); imports that resolve into one
8
+ are not followed. The standard library is excluded (it ships with the
9
+ interpreter). Bundles of several seeds combine with `BundleDesc.merge`.
10
+
11
+ `stage(files, dest)` lays a bundle out under `dest` at each file's import path,
12
+ so `dest` on sys.path (e.g. a Ray working_dir) makes every module importable.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import ast
18
+ from collections.abc import Iterator
19
+ from dataclasses import dataclass
20
+ import functools
21
+ import importlib.machinery
22
+ import importlib.util
23
+ from pathlib import Path
24
+ import shutil
25
+ import sys
26
+
27
+
28
+ ThirdPartyDependencyName = str
29
+ ThirdPartyDependencyVersion = str
30
+
31
+ @dataclass
32
+ class BundleDesc:
33
+ local_files: set[Path]
34
+ tp_deps: dict[ThirdPartyDependencyName, ThirdPartyDependencyVersion]
35
+
36
+ def merge(self, other: BundleDesc) -> BundleDesc:
37
+ return BundleDesc(
38
+ local_files=self.local_files.union(other.local_files),
39
+ tp_deps={**self.tp_deps, **other.tp_deps},
40
+ )
41
+
42
+
43
+ def bundle(seed: Path) -> BundleDesc:
44
+ """What is needed to run the module at `seed`: its local files, each at its
45
+ real path. Third-party dependency detection is not implemented yet, so
46
+ `tp_deps` is always empty."""
47
+ seed = seed.resolve()
48
+ files: set[Path] = set()
49
+ queue: list[Path] = [seed]
50
+ while queue:
51
+ file = queue.pop()
52
+ if file in files or not _is_local(file):
53
+ continue
54
+ files.add(file)
55
+ queue.extend(_init_chain(file)) # importing a module runs its __init__ chain
56
+ if file.suffix == ".py":
57
+ for name in _imports(file):
58
+ dep = _module_file(name)
59
+ if dep is not None:
60
+ queue.append(dep)
61
+ return BundleDesc(local_files=files, tp_deps={})
62
+
63
+
64
+ def stage(files: set[Path], dest: Path) -> None:
65
+ """Copy `files` under `dest`, each at its import path (relative to the
66
+ sys.path entry it lives under), so `dest` on sys.path imports them all."""
67
+ for file in files:
68
+ target = dest / file.relative_to(_sys_path_root(file))
69
+ target.parent.mkdir(parents=True, exist_ok=True)
70
+ shutil.copy2(file, target)
71
+
72
+
73
+ # Distributions already present in the Ray worker image (k8s/docker/ray/Dockerfile).
74
+ # They and their whole dependency trees -- torch's CUDA stack, sympy, numpy, ray,
75
+ # mlflow, ... -- are on the worker already, so a caller subtracts them from a
76
+ # bundle rather than shipping them again.
77
+ _WORKER_BAKED = ("ray", "mlflow", "torch", "smart_open", "dotenv", "psutil")
78
+
79
+
80
+ @functools.lru_cache(maxsize=1)
81
+ def worker_provides() -> frozenset[Path]:
82
+ """Every file the Ray worker image already provides. Subtract from a bundle
83
+ before shipping: `bundle(entry).local_files - worker_provides()`."""
84
+ provided: set[Path] = set()
85
+ for name in _WORKER_BAKED:
86
+ origin = _module_file(name)
87
+ if origin is not None:
88
+ provided |= bundle(origin).local_files
89
+ return frozenset(provided)
90
+
91
+
92
+ def _is_local(file: Path) -> bool:
93
+ """True unless `file` lives in an installed-package location."""
94
+ return not any(part in ("site-packages", "dist-packages") for part in file.parts)
95
+
96
+
97
+ def _module_file(name: str) -> Path | None:
98
+ """The file the interpreter would load for import `name`; None for the
99
+ standard library, builtins, namespace packages, and names that are not
100
+ modules (e.g. a function in `from pkg import function`)."""
101
+ if name.split(".")[0] in sys.stdlib_module_names:
102
+ return None
103
+ spec = _find_spec(name)
104
+ if spec is None or spec.origin in (None, "built-in", "frozen"):
105
+ return None
106
+ return Path(spec.origin).resolve()
107
+
108
+
109
+ def _find_spec(name: str) -> importlib.machinery.ModuleSpec | None:
110
+ """The spec the import system would find for `name`, without importing any
111
+ of its parent packages (importlib.util.find_spec imports them).
112
+
113
+ Mirrors the import system's own lookup: each meta path finder is asked for
114
+ `name`, with the parent's submodule_search_locations as the import path for
115
+ a submodule. https://docs.python.org/3.11/reference/import.html#the-meta-path
116
+ """
117
+ parent, _, child = name.rpartition(".")
118
+ path: list[str] | None = None
119
+ if parent:
120
+ parent_spec = _find_spec(parent)
121
+ if parent_spec is None or parent_spec.submodule_search_locations is None:
122
+ return None
123
+ path = list(parent_spec.submodule_search_locations)
124
+ portions = _namespace_portions(child, path)
125
+ if portions:
126
+ # The finders cannot build a nested namespace package's spec without
127
+ # its parent in sys.modules, so build it here.
128
+ spec = importlib.machinery.ModuleSpec(name, None, is_package=True)
129
+ spec.submodule_search_locations = portions
130
+ return spec
131
+ for finder in sys.meta_path:
132
+ spec = finder.find_spec(name, path)
133
+ if spec is not None:
134
+ return spec
135
+ return None
136
+
137
+
138
+ def _namespace_portions(child: str, path: list[str]) -> list[str]:
139
+ """The directories that make `child` a namespace package under `path`: each
140
+ `<entry>/<child>` directory, provided no entry holds `child` as a module or
141
+ regular package. Empty if `child` is not a namespace package.
142
+ https://peps.python.org/pep-0420/#specification
143
+ """
144
+ suffixes = importlib.machinery.all_suffixes()
145
+ portions: list[str] = []
146
+ for entry in map(Path, path):
147
+ directory = entry / child
148
+ if any(
149
+ (entry / f"{child}{suffix}").is_file()
150
+ or (directory / f"__init__{suffix}").is_file()
151
+ for suffix in suffixes
152
+ ):
153
+ return []
154
+ if directory.is_dir():
155
+ portions.append(str(directory))
156
+ return portions
157
+
158
+
159
+ def _imports(file: Path) -> Iterator[str]:
160
+ """Absolute module names imported by a .py file, relative imports resolved
161
+ against the file's own package."""
162
+ package = _package(file)
163
+ for node in ast.walk(ast.parse(file.read_text())):
164
+ if isinstance(node, ast.Import):
165
+ for alias in node.names:
166
+ yield alias.name
167
+ elif isinstance(node, ast.ImportFrom):
168
+ if node.level:
169
+ try:
170
+ base = importlib.util.resolve_name(
171
+ "." * node.level + (node.module or ""), package
172
+ )
173
+ except (ImportError, ValueError):
174
+ continue
175
+ else:
176
+ base = node.module
177
+ if base:
178
+ yield base
179
+ for alias in node.names:
180
+ yield f"{base}.{alias.name}"
181
+
182
+
183
+ def _init_chain(file: Path) -> Iterator[Path]:
184
+ """The package __init__.py files above `file`, up to its sys.path root."""
185
+ directory = file.parent
186
+ while (directory / "__init__.py").exists():
187
+ yield (directory / "__init__.py").resolve()
188
+ directory = directory.parent
189
+
190
+
191
+ def _package(file: Path) -> str:
192
+ """Dotted name of the package `file` lives in ('' at the sys.path root)."""
193
+ parts: list[str] = []
194
+ directory = file.parent
195
+ while (directory / "__init__.py").exists():
196
+ parts.insert(0, directory.name)
197
+ directory = directory.parent
198
+ return ".".join(parts)
199
+
200
+
201
+ def _sys_path_root(file: Path) -> Path:
202
+ """The sys.path entry `file` is imported from: its first ancestor directory
203
+ without an __init__.py."""
204
+ directory = file.parent
205
+ while (directory / "__init__.py").exists():
206
+ directory = directory.parent
207
+ return directory
@@ -0,0 +1,48 @@
1
+ """Cluster-side entrypoint. Run by Ray as: python -m cortexgrid._ray_job_driver payload.pkl"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import cloudpickle # type: ignore
6
+ import logging
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from cortexgrid.checkpoint import set_cortexgrid_job_id
11
+ from cortexgrid.experiment import Experiment
12
+ from cortexgrid.jobs import Payload
13
+
14
+ log = logging.getLogger("ray-job-driver")
15
+
16
+
17
+ def main(payload_path: str) -> None:
18
+ logging.basicConfig(
19
+ level=logging.INFO,
20
+ format="%(asctime)s.%(msecs)03d %(levelname)s %(name)s: %(message)s",
21
+ datefmt="%H:%M:%S",
22
+ )
23
+
24
+ log.info("Loading payload: %s", payload_path)
25
+
26
+ if not Path(payload_path).exists():
27
+ raise FileNotFoundError(f"Payload not found: {payload_path}")
28
+
29
+ payload: Payload = cloudpickle.loads(Path(payload_path).read_bytes())
30
+ log.info("Payload loaded: %s", payload_path)
31
+ log.info("Loading experiment: %s/%s", payload.experiment_name, payload.run_id)
32
+
33
+ set_cortexgrid_job_id(payload.job_id)
34
+ Experiment.from_experiment(payload.experiment_name, payload.run_id)
35
+ log.info("Experiment loaded: %s/%s", payload.experiment_name, payload.run_id)
36
+
37
+ log.info(
38
+ "Starting job in experiment: %s/%s; args: %r; kwargs: %r",
39
+ payload.experiment_name,
40
+ payload.run_id,
41
+ payload.args,
42
+ payload.kwargs,
43
+ )
44
+ payload.fn(*payload.args, **payload.kwargs)
45
+
46
+
47
+ if __name__ == "__main__":
48
+ main(sys.argv[1])
@@ -0,0 +1,39 @@
1
+ """Generic Ray Serve application builder used by every cortexgrid.deploy_model.
2
+
3
+ Ray Serve's REST `import_path` resolves to `cortexgrid._serve_entry:build`.
4
+ On the cluster replica, `build` imports the serve-app class bundled at
5
+ `save_model` time (its import path was stored as an MLflow tag), reads its
6
+ `num_gpus`/`num_replicas` class attributes for actor placement, wraps it as a
7
+ Ray Serve deployment, and binds it with the (family, suffix, run_name)
8
+ identifiers.
9
+
10
+ The serve-app owns everything about traffic: its own routes, request schemas,
11
+ streaming, and timeouts. cortexgrid does not interpose a request/response
12
+ contract - it only schedules the app and hands it the identifiers it needs to
13
+ fetch its own weights via `cortexgrid.load_model`.
14
+
15
+ Design note: resource needs (`num_gpus`/`num_replicas`) are read from plain
16
+ class attributes rather than a cortexgrid decorator or base class. This is a
17
+ deliberate, provisional choice - kept minimal until we see how serve-apps
18
+ declare resources in practice; revisit if plain class attributes prove too
19
+ limited.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import importlib
25
+ from typing import Any
26
+
27
+ from ray import serve
28
+ from ray.serve.deployment import Application
29
+
30
+
31
+ def build(args: dict[str, Any]) -> Application:
32
+ module_name, class_name = args["class_import_path"].split(":")
33
+ serve_app = getattr(importlib.import_module(module_name), class_name)
34
+ num_gpus = getattr(serve_app, "num_gpus", 0)
35
+ num_replicas = getattr(serve_app, "num_replicas", 1)
36
+ return serve.deployment(serve_app).options(
37
+ num_replicas=num_replicas,
38
+ ray_actor_options={"num_gpus": num_gpus},
39
+ ).bind(args["family"], args["suffix"], args["run_name"])
@@ -0,0 +1,217 @@
1
+ """Durable checkpointing for cortexgrid jobs.
2
+
3
+ Save arbitrary state (primitives, torch tensors, state_dicts) via MLflow
4
+ artifacts and resume from the latest checkpoint on retry.
5
+
6
+ Usage (save)::
7
+
8
+ with cortexgrid.checkpoint() as ckpt:
9
+ ckpt.epoch = epoch
10
+ ckpt.global_step = step
11
+ ckpt.save_training_state(model, optimizer, scheduler)
12
+
13
+ Usage (resume)::
14
+
15
+ ckpt = cortexgrid.resume()
16
+ if ckpt:
17
+ ckpt.restore_training_state(model, optimizer, scheduler)
18
+ start_epoch = ckpt.epoch + 1
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import logging
25
+ import tempfile
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ import cloudpickle # type: ignore
30
+ from mlflow.tracking import MlflowClient
31
+
32
+ from cortexgrid import s3_util
33
+ from cortexgrid.experiment import Experiment, get_mlflow_tracking_uri
34
+
35
+ log = logging.getLogger(__name__)
36
+ _CORTEXGRID_JOB_ID: str | None = None
37
+
38
+
39
+ class Checkpoint:
40
+ """Attribute-based checkpoint persisted via MLflow artifacts.
41
+
42
+ Assign any cloudpickle-compatible value to an attribute and it will be
43
+ saved when the context manager exits::
44
+
45
+ with cortexgrid.checkpoint() as ckpt:
46
+ ckpt.epoch = 5
47
+ ckpt.model_state = model.state_dict()
48
+
49
+ Read values back after calling ``cortexgrid.resume()``::
50
+
51
+ ckpt = cortexgrid.resume()
52
+ if ckpt:
53
+ model.load_state_dict(ckpt.model_state)
54
+ """
55
+
56
+ _INTERNAL = frozenset(("_prefix", "_data"))
57
+
58
+ def __init__(self, prefix: str, *, _data: dict[str, Any] | None = None) -> None:
59
+ object.__setattr__(self, "_prefix", prefix)
60
+ object.__setattr__(self, "_data", _data if _data is not None else {})
61
+
62
+ def __setattr__(self, name: str, value: Any) -> None:
63
+ if name in self._INTERNAL:
64
+ object.__setattr__(self, name, value)
65
+ else:
66
+ self._data[name] = value
67
+
68
+ def __getattr__(self, name: str) -> Any:
69
+ if name in ("_prefix", "_data", "_INTERNAL"):
70
+ return object.__getattribute__(self, name)
71
+ try:
72
+ return self._data[name]
73
+ except KeyError:
74
+ raise AttributeError(f"Checkpoint has no attribute {name!r}")
75
+
76
+ def __bool__(self) -> bool:
77
+ return bool(self._data)
78
+
79
+ def __repr__(self) -> str:
80
+ keys = ", ".join(sorted(self._data))
81
+ return f"Checkpoint(prefix={self._prefix!r}, attrs=[{keys}])"
82
+
83
+ def save_training_state(
84
+ self,
85
+ model: Any,
86
+ optimizer: Any,
87
+ scheduler: Any | None = None,
88
+ ) -> None:
89
+ """Save model, optimizer, and optionally scheduler state_dicts."""
90
+ self._data["_model_state"] = model.state_dict()
91
+ self._data["_optimizer_state"] = optimizer.state_dict()
92
+ if scheduler is not None:
93
+ self._data["_scheduler_state"] = scheduler.state_dict()
94
+
95
+ def restore_training_state(
96
+ self,
97
+ model: Any,
98
+ optimizer: Any,
99
+ scheduler: Any | None = None,
100
+ ) -> None:
101
+ """Load state_dicts back into existing model/optimizer/scheduler."""
102
+ model.load_state_dict(self._data["_model_state"])
103
+ optimizer.load_state_dict(self._data["_optimizer_state"])
104
+ if scheduler is not None and "_scheduler_state" in self._data:
105
+ scheduler.load_state_dict(self._data["_scheduler_state"])
106
+
107
+ def __enter__(self) -> Checkpoint:
108
+ return self
109
+
110
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
111
+ if exc_type is None:
112
+ self._persist()
113
+
114
+ def _persist(self) -> None:
115
+ """Upload attr blobs to MinIO; log manifest.json via MLflow."""
116
+ exp = Experiment.get_instance()
117
+ client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
118
+ tmpdir = Path(tempfile.mkdtemp())
119
+
120
+ manifest: dict[str, Any] = {"attrs": {}}
121
+
122
+ for name, value in self._data.items():
123
+ filename = f"{name}.pkl"
124
+ (tmpdir / filename).write_bytes(cloudpickle.dumps(value))
125
+ uri = s3_util.upload(
126
+ str(tmpdir / filename), dest_path=f"{self._prefix}/{filename}"
127
+ )
128
+ manifest["attrs"][name] = {"uri": uri}
129
+
130
+ (tmpdir / "manifest.json").write_text(json.dumps(manifest))
131
+ client.log_artifact(
132
+ exp.run_id, str(tmpdir / "manifest.json"), artifact_path=self._prefix
133
+ )
134
+ log.info("Checkpoint saved: %s (%d attrs)", self._prefix, len(self._data))
135
+
136
+ @classmethod
137
+ def _load(cls, prefix: str) -> Checkpoint | None:
138
+ """Download manifest via MLflow; download attr blobs from MinIO."""
139
+ exp = Experiment.get_instance()
140
+ client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
141
+
142
+ manifest_rel = f"{prefix}/manifest.json"
143
+ if not any(
144
+ a.path == manifest_rel for a in client.list_artifacts(exp.run_id, prefix)
145
+ ):
146
+ return None
147
+
148
+ try:
149
+ manifest_path = client.download_artifacts(exp.run_id, manifest_rel)
150
+ manifest = json.loads(Path(manifest_path).read_text())
151
+ except Exception:
152
+ return None
153
+
154
+ data: dict[str, Any] = {}
155
+ tmpdir = Path(tempfile.mkdtemp())
156
+ for name, info in manifest["attrs"].items():
157
+ try:
158
+ _, _, src_path = info["uri"].removeprefix("s3://").partition("/")
159
+ file_path = s3_util.download(
160
+ src_path, local_path=str(tmpdir / Path(src_path).name)
161
+ )
162
+ data[name] = cloudpickle.loads(Path(file_path).read_bytes())
163
+ except Exception:
164
+ log.warning("Failed to load checkpoint attribute %r", name)
165
+ return None
166
+
167
+ log.info("Checkpoint loaded: %s (%d attrs)", prefix, len(data))
168
+ return cls(prefix, _data=data)
169
+
170
+
171
+ def set_cortexgrid_job_id(job_id: str) -> None:
172
+ global _CORTEXGRID_JOB_ID
173
+ _CORTEXGRID_JOB_ID = job_id
174
+
175
+
176
+ def get_cortexgrid_job_id() -> str | None:
177
+ """Return the current job ID, or None if not running inside a job."""
178
+ global _CORTEXGRID_JOB_ID
179
+ return _CORTEXGRID_JOB_ID
180
+
181
+
182
+ def _checkpoint_prefix() -> str:
183
+ job_id = get_cortexgrid_job_id() or "global"
184
+ return f"checkpoint/{job_id}"
185
+
186
+
187
+ def checkpoint() -> Checkpoint:
188
+ """Create a checkpoint for the current job. Use as a context manager.
189
+
190
+ Returns a no-op checkpoint if not running inside a cortexgrid job,
191
+ so callers don't need to guard with ``if`` checks.
192
+
193
+ Example::
194
+
195
+ with cortexgrid.checkpoint() as ckpt:
196
+ ckpt.epoch = epoch
197
+ ckpt.save_training_state(model, optimizer, scheduler)
198
+ """
199
+ prefix = _checkpoint_prefix()
200
+ return Checkpoint(prefix)
201
+
202
+
203
+ def resume() -> Checkpoint | None:
204
+ """Load the latest checkpoint for the current job, or None.
205
+
206
+ Returns None if not running inside a cortexgrid job or if no
207
+ checkpoint exists.
208
+
209
+ Example::
210
+
211
+ ckpt = cortexgrid.resume()
212
+ if ckpt:
213
+ ckpt.restore_training_state(model, optimizer, scheduler)
214
+ start_epoch = ckpt.epoch + 1
215
+ """
216
+ prefix = _checkpoint_prefix()
217
+ return Checkpoint._load(prefix)