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/ray_util.py ADDED
@@ -0,0 +1,171 @@
1
+ """Talk to the Ray cluster's dashboard: job submission + Serve applications.
2
+
3
+ Job submission goes through ray's `JobSubmissionClient`. Serve applications use
4
+ the dashboard's declarative `/api/serve/applications/` REST endpoint directly -
5
+ there's no public Python client for it that doesn't drag in `ray.init`, and the
6
+ REST surface is tiny."""
7
+
8
+ from __future__ import annotations
9
+
10
+ from enum import Enum
11
+ from typing import Any
12
+
13
+ import requests # type: ignore
14
+
15
+ from cortexgrid.infra import get_ray_job_server_uri, get_ray_serve_applications_uri
16
+ from ray.job_submission import JobSubmissionClient
17
+
18
+
19
+ class JobStatus(str, Enum):
20
+ PENDING = "pending" # Pending scheduling
21
+ RUNNING = "running"
22
+ FINISHED = "finished"
23
+ FAILED = "failed"
24
+ STOPPED = "stopped"
25
+
26
+
27
+ _ray_job_submission_client: JobSubmissionClient | None = None
28
+
29
+
30
+ def get_ray_job_submission_client() -> JobSubmissionClient:
31
+ """Return a cached JobSubmissionClient. Each constructor call does a version-check RTT, so we share one."""
32
+ global _ray_job_submission_client
33
+ if _ray_job_submission_client is None:
34
+ _ray_job_submission_client = JobSubmissionClient(get_ray_job_server_uri())
35
+ return _ray_job_submission_client
36
+
37
+
38
+ def get_ray_job_id_for_cortexgrid_job(
39
+ run_id: str, job_id: str, all_ray_submission_ids: list[str] | None = None
40
+ ) -> str | None:
41
+ if all_ray_submission_ids is None:
42
+ all_ray_submission_ids = list_ray_jobs_with_submission_id()
43
+ prefix = ray_submission_id(run_id, job_id, None) + "-"
44
+ attempts = [sid for sid in all_ray_submission_ids if sid.startswith(prefix)]
45
+ return max(attempts, key=get_ray_job_attempt) if attempts else None
46
+
47
+
48
+ def get_ray_status(ray_job_id: str | None) -> str | None:
49
+ """Return the current status of a previously submitted ray job."""
50
+ if ray_job_id is None:
51
+ return None
52
+
53
+ client = get_ray_job_submission_client()
54
+ return client.get_job_status(ray_job_id).value
55
+
56
+
57
+ def get_ray_job_status(ray_job_id: str | None) -> JobStatus:
58
+ """Derive a job's observable status from a live Ray query.
59
+
60
+ Returns ``PENDING`` both when ``ray_job_id is None`` (never submitted)
61
+ and when Ray itself reports ``PENDING`` (queued). Callers that need
62
+ to distinguish those two must check ``ray_job_id is None`` first.
63
+ """
64
+ ray_status = get_ray_status(ray_job_id)
65
+ if ray_job_id is None:
66
+ return JobStatus.PENDING
67
+ if ray_status == "SUCCEEDED":
68
+ return JobStatus.FINISHED
69
+ if ray_status == "FAILED":
70
+ return JobStatus.FAILED
71
+ if ray_status == "STOPPED":
72
+ return JobStatus.STOPPED
73
+ return JobStatus.RUNNING
74
+
75
+
76
+ def get_ray_logs(ray_job_id: str | None) -> str | None:
77
+ """Return the stdout/stderr of a previously submitted ray job."""
78
+ if ray_job_id is None:
79
+ return None
80
+
81
+ client = get_ray_job_submission_client()
82
+ return client.get_job_logs(ray_job_id)
83
+
84
+
85
+ def get_ray_job_url(ray_job_id: str | None) -> str | None:
86
+ """Build the browser-facing URL to view a job in the Ray dashboard."""
87
+ if ray_job_id is None:
88
+ return None
89
+
90
+ base = get_ray_job_server_uri()
91
+ return f"{base}/#/jobs/{ray_job_id}"
92
+
93
+
94
+ def stop_ray_job(ray_job_id: str) -> None:
95
+ """Stop a running ray job."""
96
+ client = get_ray_job_submission_client()
97
+ client.stop_job(ray_job_id)
98
+
99
+
100
+ def list_ray_jobs_with_submission_id() -> list[str]:
101
+ """List all ray jobs, the ones that received submission id."""
102
+ client = get_ray_job_submission_client()
103
+ return [
104
+ job.submission_id for job in client.list_jobs() if job.submission_id is not None
105
+ ]
106
+
107
+
108
+ def ray_submission_id(run_id: str, job_id: str, attempt: int | None) -> str:
109
+ """Deterministic Ray submission id derived from a job's identity."""
110
+ return (
111
+ f"{run_id}-{job_id}-{attempt}" if attempt is not None else f"{run_id}-{job_id}"
112
+ )
113
+
114
+
115
+ def get_ray_job_attempt(ray_job_id: str | None) -> int:
116
+ if ray_job_id is None:
117
+ return 0
118
+ _, sep, suffix = ray_job_id.rpartition("-")
119
+ if not sep or not suffix.isdigit():
120
+ raise ValueError(f"Ray submission_id has no attempt suffix: {ray_job_id!r}")
121
+ return int(suffix)
122
+
123
+
124
+ def submit_ray_job(
125
+ submission_id: str,
126
+ entrypoint: str,
127
+ runtime_env: dict,
128
+ num_gpus: int = 0,
129
+ num_cpus: int = 1,
130
+ ) -> None:
131
+ """Submit a job to Ray with a caller-supplied deterministic submission_id.
132
+
133
+ Raises whatever the Ray SDK raises on a duplicate submission_id; the
134
+ control plane relies on that exception to short-circuit re-submission
135
+ on retry paths.
136
+ """
137
+ client = get_ray_job_submission_client()
138
+ client.submit_job(
139
+ submission_id=submission_id,
140
+ entrypoint=entrypoint,
141
+ runtime_env=runtime_env,
142
+ entrypoint_num_gpus=num_gpus,
143
+ entrypoint_num_cpus=num_cpus,
144
+ )
145
+
146
+
147
+ def get_serve_details() -> dict[str, Any]:
148
+ """GET the Serve controller's view of currently-running applications.
149
+
150
+ Returns the full ServeInstanceDetails JSON; callers project the parts they
151
+ care about. Raises for any non-2xx response (HTTPError carries the body).
152
+ """
153
+ response = requests.get(get_ray_serve_applications_uri(), timeout=30)
154
+ response.raise_for_status()
155
+ return response.json()
156
+
157
+
158
+ def put_serve_applications(applications: list[dict[str, Any]]) -> None:
159
+ """PUT the full desired set of Serve applications.
160
+
161
+ The endpoint is declarative: any application not in `applications` is
162
+ deleted, any new application is created, any updated application is
163
+ rolled. Callers that want to mutate one app should GET first, splice,
164
+ and PUT the result.
165
+ """
166
+ response = requests.put(
167
+ get_ray_serve_applications_uri(),
168
+ json={"applications": applications},
169
+ timeout=60,
170
+ )
171
+ response.raise_for_status()
cortexgrid/s3_util.py ADDED
@@ -0,0 +1,135 @@
1
+ """S3/MinIO wrappers.
2
+
3
+ Builds an S3 boto3 client from the S3_* entries in the head secrets store:
4
+ S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_REGION, S3_ENDPOINT_URL. They are
5
+ fetched at runtime via cortexgrid.secrets.get_secret, so no consumer has to
6
+ export them as environment variables.
7
+
8
+ On the AWS profile S3_ENDPOINT_URL is the regional s3.amazonaws.com URL and
9
+ S3_* are the real AWS keys; on the on-prem profile S3_ENDPOINT_URL is the
10
+ tailnet-reachable MinIO URL and S3_* are the MinIO admin creds.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ from typing import Any
17
+
18
+ import boto3 # type: ignore
19
+ from tqdm import tqdm # type: ignore
20
+
21
+ from cortexgrid.infra import get_s3_bucket, get_s3_endpoint_url
22
+ from cortexgrid.secrets import get_secret
23
+
24
+
25
+ def get_s3_client() -> Any:
26
+ """Return a boto3 S3 client configured for the cluster's object store."""
27
+ return boto3.client(
28
+ "s3",
29
+ aws_access_key_id=get_secret("S3_ACCESS_KEY_ID"),
30
+ aws_secret_access_key=get_secret("S3_SECRET_ACCESS_KEY"),
31
+ endpoint_url=get_s3_endpoint_url(),
32
+ # Required: without it boto3 picks the local default region for SigV4,
33
+ # which mismatches the AWS endpoint and produces 301 Moved Permanently
34
+ # against real AWS. MinIO ignores the value.
35
+ region_name=get_secret("S3_REGION"),
36
+ )
37
+
38
+
39
+ def _ensure_bucket(client: Any, bucket: str) -> None:
40
+ """Create the bucket if and only if head_bucket returns 404. Other errors
41
+ (region mismatch, perms) propagate so they aren't silently masked by an
42
+ unrelated create_bucket failure."""
43
+ try:
44
+ client.head_bucket(Bucket=bucket)
45
+ except client.exceptions.ClientError as e:
46
+ if e.response["Error"]["Code"] != "404":
47
+ raise
48
+ # LocationConstraint is required for any AWS region other than us-east-1.
49
+ # MinIO accepts it too.
50
+ client.create_bucket(
51
+ Bucket=bucket,
52
+ CreateBucketConfiguration={"LocationConstraint": get_secret("S3_REGION")},
53
+ )
54
+
55
+
56
+ def upload(local_path: str, dest_path: str | None = None) -> str:
57
+ """Upload a local file to the canonical S3/MinIO bucket.
58
+
59
+ Args:
60
+ local_path: Path to the local file.
61
+ dest_path: Full object key in the bucket (folder/filename). Defaults
62
+ to the local file's basename at the bucket root.
63
+
64
+ Returns:
65
+ The s3://bucket/<dest_path> URI of the uploaded object.
66
+ """
67
+ bucket = get_s3_bucket()
68
+ dest_path = dest_path or os.path.basename(local_path)
69
+
70
+ client = get_s3_client()
71
+ _ensure_bucket(client, bucket)
72
+ file_size = os.path.getsize(local_path)
73
+ with tqdm(
74
+ total=file_size,
75
+ unit="B",
76
+ unit_scale=True,
77
+ desc=f"Uploading {os.path.basename(local_path)}",
78
+ ) as pbar:
79
+ client.upload_file(local_path, bucket, dest_path, Callback=pbar.update)
80
+ return f"s3://{bucket}/{dest_path}"
81
+
82
+
83
+ def upload_dir(local_dir: str, dest_path: str = "") -> list[str]:
84
+ """Upload all files in a directory tree to the canonical S3/MinIO bucket.
85
+
86
+ Args:
87
+ local_dir: Path to the local directory.
88
+ dest_path: Folder inside the bucket where the tree lands. Each file's
89
+ key is dest_path/<path relative to local_dir>. Defaults to bucket root.
90
+
91
+ Returns:
92
+ List of s3://bucket/<key> URIs for uploaded objects.
93
+ """
94
+ bucket = get_s3_bucket()
95
+
96
+ client = get_s3_client()
97
+ _ensure_bucket(client, bucket)
98
+
99
+ uploaded: list[str] = []
100
+ for root, _dirs, files in os.walk(local_dir):
101
+ for filename in files:
102
+ local_path = os.path.join(root, filename)
103
+ rel_path = os.path.relpath(local_path, local_dir).replace(os.sep, "/")
104
+ key = f"{dest_path}/{rel_path}" if dest_path else rel_path
105
+ client.upload_file(local_path, bucket, key)
106
+ uploaded.append(f"s3://{bucket}/{key}")
107
+
108
+ return uploaded
109
+
110
+
111
+ def delete_prefix(prefix: str) -> None:
112
+ """Delete every object under `prefix` in the canonical bucket."""
113
+ bucket = get_s3_bucket()
114
+ client = get_s3_client()
115
+ paginator = client.get_paginator("list_objects_v2")
116
+ for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
117
+ keys = [{"Key": obj["Key"]} for obj in page.get("Contents", [])]
118
+ if keys:
119
+ client.delete_objects(Bucket=bucket, Delete={"Objects": keys})
120
+
121
+
122
+ def download(src_path: str, local_path: str | None = None) -> str:
123
+ """Download a file from the canonical S3/MinIO bucket.
124
+
125
+ Args:
126
+ src_path: Full object key in the bucket (folder/filename).
127
+ local_path: Where to save locally. Defaults to the src_path basename.
128
+
129
+ Returns:
130
+ The local file path.
131
+ """
132
+ local_path = local_path or os.path.basename(src_path)
133
+ client = get_s3_client()
134
+ client.download_file(get_s3_bucket(), src_path, local_path)
135
+ return local_path
cortexgrid/secrets.py ADDED
@@ -0,0 +1,56 @@
1
+ """Secrets API - client for the head's secrets server.
2
+
3
+ The head runs a small HTTP server (k8s/seed/scripts/cortexgrid_head.py) that
4
+ keeps every secret in a .env file on the head host. All calls go to
5
+ $CORTEXGRID_HEAD_URL: http://robolab-head:7700 from a laptop on the tailnet,
6
+ http://cortexgrid-head.default.svc.cluster.local:7700 from pods.
7
+
8
+ There is no authentication: the head is only reachable over the tailnet.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from urllib.parse import quote
15
+
16
+ import requests # type: ignore
17
+
18
+
19
+ _TIMEOUT_S = 10.0
20
+
21
+
22
+ def _head_url() -> str:
23
+ url = os.environ.get("CORTEXGRID_HEAD_URL")
24
+ if not url:
25
+ raise RuntimeError(
26
+ "CORTEXGRID_HEAD_URL is not set; point it at the head's secrets "
27
+ "server, e.g. http://robolab-head:7700"
28
+ )
29
+ return url.rstrip("/")
30
+
31
+
32
+ def _secret_url(id: str) -> str:
33
+ return f"{_head_url()}/secrets/{quote(id, safe='')}"
34
+
35
+
36
+ def get_secret(id: str) -> str:
37
+ response = requests.get(_secret_url(id), timeout=_TIMEOUT_S)
38
+ response.raise_for_status()
39
+ return response.json()["value"]
40
+
41
+
42
+ def list_secrets() -> list[str]:
43
+ response = requests.get(f"{_head_url()}/secrets", timeout=_TIMEOUT_S)
44
+ response.raise_for_status()
45
+ return response.json()
46
+
47
+
48
+ def set_secret(id: str, value: str) -> None:
49
+ response = requests.put(_secret_url(id), json={"value": value}, timeout=_TIMEOUT_S)
50
+ response.raise_for_status()
51
+
52
+
53
+ def delete_secret(id: str) -> None:
54
+ """Idempotent: already-gone is treated as success, matching HTTP DELETE semantics."""
55
+ response = requests.delete(_secret_url(id), timeout=_TIMEOUT_S)
56
+ response.raise_for_status()
@@ -0,0 +1,199 @@
1
+ Metadata-Version: 2.5
2
+ Name: cortexgrid
3
+ Version: 0.2.85
4
+ Summary: Connect your ML code to the RoboLab compute cluster — Ray, MLflow, and S3
5
+ Project-URL: Homepage, https://github.com/robodatalab/cortexgrid
6
+ Project-URL: Repository, https://github.com/robodatalab/cortexgrid
7
+ Project-URL: Documentation, https://github.com/robodatalab/cortexgrid/blob/main/docs/cortexgrid/README.md
8
+ License-Expression: Apache-2.0
9
+ License-File: LICENSE
10
+ Requires-Python: >=3.11
11
+ Requires-Dist: boto3>=1.34
12
+ Requires-Dist: cloudpickle>=3.0
13
+ Requires-Dist: fabric>=3.2.3
14
+ Requires-Dist: haikunator>=2.1.0
15
+ Requires-Dist: mlflow<4,>=3.11
16
+ Requires-Dist: pip>=23.0
17
+ Requires-Dist: pydantic-settings>=2.13.1
18
+ Requires-Dist: pydantic>=2.13.3
19
+ Requires-Dist: pydotenv>=0.0.7
20
+ Requires-Dist: python-dotenv>=1.2.2
21
+ Requires-Dist: pyyaml>=6.0.3
22
+ Requires-Dist: ray[default]<3,>=2.9
23
+ Requires-Dist: requests>=2.31
24
+ Requires-Dist: setuptools>=82.0.1
25
+ Requires-Dist: tqdm>=4.60
26
+ Description-Content-Type: text/markdown
27
+
28
+ # cortexgrid
29
+
30
+ `cortexgrid` is a Python library that connects your ML code to the deployed infrastructure. It wraps Ray, MLflow, and S3/MinIO so your training scripts don't need to know about URLs, credentials, or service endpoints.
31
+
32
+ ### Installation
33
+
34
+ Add `cortexgrid` as a dependency in your project's `pyproject.toml`:
35
+
36
+ ```toml
37
+ [project]
38
+ dependencies = [
39
+ "cortexgrid",
40
+ ]
41
+ ```
42
+
43
+ Then `uv sync` to install it, and point it at the head's secrets server (reachable over the tailnet):
44
+
45
+ ```bash
46
+ export CORTEXGRID_HEAD_URL=http://robolab-head:7700
47
+ ```
48
+
49
+ ### Usage
50
+
51
+ ```python
52
+ import cortexgrid
53
+
54
+ cortexgrid.init(experiment="weather-forecast")
55
+ ```
56
+
57
+ That single call reads the service URLs from the head's secrets server at `$CORTEXGRID_HEAD_URL` and connects to all services through them. It also creates (or finds) the named MLflow experiment and starts a new run inside it. Omit `experiment=` to auto-generate a unique name like `funky-koval-12`.
58
+
59
+ **One experiment per binary run.** `cortexgrid.init()` may only be called once per process. Every subsequent `cortexgrid.log_metric`, `cortexgrid.log_artifact`, checkpoint, and `cortexgrid.remote()` submission is scoped to that experiment+run. Remote jobs dispatched by the control plane inherit the experiment+run via the pickled payload, so their logging flows into the same MLflow run as the parent binary.
60
+
61
+ #### Experiment tracking (MLflow)
62
+
63
+ ```python
64
+ cortexgrid.init(experiment="weather-forecast")
65
+
66
+ cortexgrid.log_params({"lr": 1e-3, "epochs": 20, "batch_size": 64})
67
+
68
+ for epoch in range(20):
69
+ loss = train_one_epoch(model, dataloader)
70
+ cortexgrid.log_metric("loss", loss, step=epoch)
71
+
72
+ if epoch % 5 == 0:
73
+ with cortexgrid.checkpoint() as ckpt:
74
+ ckpt.epoch = epoch
75
+ ckpt.save_training_state(model, optimizer)
76
+ ```
77
+
78
+ No run-scoping context manager — `init()` starts the run, and every subsequent logging call flows into it. Metrics and artifacts are logged to the MLflow server on the DGX. View them at `http://<DGX_IP>:5000`.
79
+
80
+ #### Checkpointing and resuming
81
+
82
+ Inside a cortexgrid job, `cortexgrid.checkpoint()` returns an attribute-based checkpoint object that persists to MLflow artifacts when its `with` block exits. On job restart (either manual retry or `retry=True`), `cortexgrid.resume()` returns the last checkpoint for the same job ID, or `None` if there isn't one.
83
+
84
+ ```python
85
+ ckpt = cortexgrid.resume()
86
+ if ckpt:
87
+ ckpt.restore_training_state(model, optimizer)
88
+ start_epoch = ckpt.epoch + 1
89
+ else:
90
+ start_epoch = 0
91
+
92
+ for epoch in range(start_epoch, 20):
93
+ train_one_epoch(model, dataloader)
94
+ with cortexgrid.checkpoint() as ckpt:
95
+ ckpt.epoch = epoch
96
+ ckpt.save_training_state(model, optimizer)
97
+ ```
98
+
99
+ You can assign any cloudpickle-compatible or torch-serializable value as an attribute on the checkpoint (`ckpt.metric = 0.93`, `ckpt.weights = model.state_dict()`); the `save_training_state`/`restore_training_state` helpers are a shortcut for the common model+optimizer pair.
100
+
101
+ #### Distributed compute (jobs control plane)
102
+
103
+ ```python
104
+ def train_step(batch):
105
+ # runs on the DGX GPU
106
+ # MLflow and S3 env vars are injected automatically
107
+ return loss
108
+
109
+ job_id = cortexgrid.remote(train_step, batch, num_gpus=1, retry=True)
110
+ print(f"Submitted: {job_id}")
111
+ ```
112
+
113
+ `cortexgrid.remote` submits a job *request* (a pickled payload plus a `JobLifecycle` record) to MLflow and returns a job ID string immediately. It does not wait for the job to run or finish — use the UI at `http://<DGX_IP>:8000`, or poll `cortexgrid.list_experiment_run_jobs(run_id)`, to observe status.
114
+
115
+ A separate service — the **jobs control plane** — polls MLflow for pending job requests, matches them against the set of Ray submissions the cluster already has, and submits anything missing. It is also responsible for retrying failed jobs and honouring user-requested stops.
116
+
117
+ Each submission captures the code and dependencies the entry function needs automatically ([_bundle.py](https://github.com/robodatalab/cortexgrid/blob/main/cortexgrid/_bundle.py)):
118
+ - `bundle(entry)` traces the import graph from the function's source file, resolving each import the way the interpreter does (via `sys.path`), and returns every file needed to run it -- your own modules and third-party packages alike, wherever they live. The standard library is excluded (it ships with the interpreter)
119
+ - Everything ships **as source**: the bundle is staged at each file's import path and tarred into the Ray `working_dir`. Nothing is `pip`-installed on the worker
120
+ - Dependencies the worker image already has are subtracted rather than shipped: `bundle(entry) - worker_provides()`, where `worker_provides()` is the bundle of the packages baked into the ray image (torch and its CUDA stack, ray, mlflow, ...). See [k8s/docker/ray/Dockerfile](https://github.com/robodatalab/cortexgrid/blob/main/k8s/docker/ray/Dockerfile)
121
+ - Injects MLflow/S3 credentials so task code running on the DGX can reach all services
122
+
123
+ ##### Retries
124
+
125
+ Pass `retry=True` and the control plane will resubmit the job whenever Ray reports the most recent attempt as `FAILED`. Retries are **unbounded by design**: the intended way to end a retry loop is to stop the job manually from the UI (which flips the `stop_requested` latch on the lifecycle, and the control plane stops the current Ray attempt on its next poll). This keeps the retry policy simple — you don't have to predict a good `max_retries` up front — and puts the human in the loop for anything that's failing persistently.
126
+
127
+ ##### Stopping a job
128
+
129
+ ```python
130
+ cortexgrid.stop_experiment_run_jobs(run_id) # stops every job in the run
131
+ ```
132
+
133
+ `stop_experiment_run_jobs` never touches Ray directly. It only flips `stop_requested` on each job's lifecycle record in MLflow. The control plane observes the flag on its next poll and calls `ray.stop_job` for any attempt that has reached Ray. For jobs that have not yet been submitted, the same flag short-circuits the submission path inside the worker.
134
+
135
+ #### Object storage (S3/MinIO)
136
+
137
+ ```python
138
+ cortexgrid.upload("data/output.parquet", bucket="ray-checkpoints", key="run-42/output.parquet")
139
+ cortexgrid.download("ray-checkpoints", "run-42/output.parquet", local_path="./output.parquet")
140
+
141
+ # or get the raw boto3 client
142
+ s3 = cortexgrid.get_s3_client()
143
+ ```
144
+
145
+ Works with MinIO on the DGX today, real S3 on AWS tomorrow — same code.
146
+
147
+ #### Getting raw clients
148
+
149
+ ```python
150
+ mlflow_client = cortexgrid.get_mlflow_client() # mlflow.tracking.MlflowClient
151
+ s3_client = cortexgrid.get_s3_client() # boto3 S3 client
152
+ ```
153
+
154
+ #### Model registry and serving
155
+
156
+ Save a trained model's weights together with the serve-app that fronts it, then deploy it as a Ray Serve application:
157
+
158
+ ```python
159
+ saved = cortexgrid.save_model(weights_dir, MyServeApp, family="qwen", suffix="instruct")
160
+ deployed = cortexgrid.deploy_model("qwen", "instruct", saved.run_name, wait=True)
161
+ print(deployed.url)
162
+ ```
163
+
164
+ `save_model` is synchronous (registry lifecycle: `uploading` -> `ready`); `deploy_model` schedules the serving lifecycle (`deploying` -> `running`). See [model-serving.md](https://github.com/robodatalab/cortexgrid/blob/main/docs/cortexgrid/model-serving.md) for both lifecycles end to end - upload/deploy/undeploy/delete, status queries (`model_registry_status`, `model_serving_status`), and error handling.
165
+
166
+ ### API reference
167
+
168
+ | Function | Description |
169
+ |----------|-------------|
170
+ | `cortexgrid.init(experiment=None)` | Configure connections + start a new MLflow run inside the named experiment. One call per binary. |
171
+ | `cortexgrid.log_metric(key, value, step)` | Log a metric |
172
+ | `cortexgrid.log_metrics(metrics, step)` | Log multiple metrics |
173
+ | `cortexgrid.log_params(params)` | Log parameters |
174
+ | `cortexgrid.log_artifact(path, artifact_path)` | Log a file as an artifact |
175
+ | `cortexgrid.checkpoint()` | Context manager returning an attribute-based checkpoint saved to MLflow on exit |
176
+ | `cortexgrid.resume()` | Load the latest checkpoint for the current job, or `None` |
177
+ | `cortexgrid.remote(fn, *args, num_gpus=0, num_cpus=1, retry=False, **kwargs)` | Submit a function to the jobs control plane; returns a job ID |
178
+ | `cortexgrid.list_experiment_run_jobs(run_id)` | List `JobLifecycle` records for every cortexgrid job in a run |
179
+ | `cortexgrid.stop_experiment_run_jobs(run_id)` | Request every job in a run to stop (flips the `stop_requested` latch) |
180
+ | `cortexgrid.get_ray_job_status(ray_job_id)` | Live Ray status for a submission id |
181
+ | `cortexgrid.get_ray_logs(ray_job_id)` | Tail the stdout/stderr of a Ray submission |
182
+ | `cortexgrid.upload(path, bucket, key)` | Upload a file to S3/MinIO |
183
+ | `cortexgrid.download(bucket, key, path)` | Download a file from S3/MinIO |
184
+ | `cortexgrid.get_mlflow_client()` | Raw configured MLflow client |
185
+ | `cortexgrid.get_s3_client()` | Raw configured boto3 S3 client |
186
+
187
+ ## ML compute stack
188
+
189
+ The DGX Spark runs the following services as k8s workloads managed by Argo CD (see [../k8s/argo_deployments/](https://github.com/robodatalab/cortexgrid/tree/main/k8s/argo_deployments/)):
190
+
191
+ | Service | Port | Purpose |
192
+ |---------|------|---------|
193
+ | Ray | 8265 | Dashboard + job submission (NodePort 30265) |
194
+ | MLflow | 5000 | Experiment tracking, model registry |
195
+ | MinIO | 9000/9001 | S3-compatible artifact storage |
196
+ | PostgreSQL | 5432 | MLflow metadata backend |
197
+ | Prometheus | 9090 | Metrics collection |
198
+ | Grafana | 3000 | Dashboards (GPU, jobs, system) |
199
+
@@ -0,0 +1,19 @@
1
+ cortexgrid/__init__.py,sha256=GBz4Y1QXNQL2sbcqBXK_A-xxyOgMx8_zHIjuRpwpa9s,4619
2
+ cortexgrid/_bundle.py,sha256=udXCvPr9l_Uf4iLGJRVTG-SKfcMPS_jBiHUG2tnx3pM,7923
3
+ cortexgrid/_ray_job_driver.py,sha256=kl1mDUb8PL5zX7f9YO1s4ufK8J8Gu6RrZjoFKB_1qxU,1459
4
+ cortexgrid/_serve_entry.py,sha256=FdU0D9VJnjAOktuQbtqT6lOhLsxKh26IElVq5XHlQBM,1699
5
+ cortexgrid/checkpoint.py,sha256=WPhngYX_Xu-eHwfxcS7r0fQw65FgagosbqmeaXqlzN8,7087
6
+ cortexgrid/experiment.py,sha256=0kcIwylQJAv2j6pM2g7WlVvZb953nih89ri-bSmk_D0,8260
7
+ cortexgrid/infra.py,sha256=EJwyQvhL_dtjE2Xh5U0S-d0aGYylbL2WSXhB70a0MxM,1217
8
+ cortexgrid/jobs.py,sha256=E1BJhCLJe8BfuIuITTQfcQ_SvMM-TMvoMyrGvoLyC30,10732
9
+ cortexgrid/mlflow_util.py,sha256=jA3JtURiB8SwXbGZ32yZbgnPIYRZPLko9ewFW-GvYI4,3650
10
+ cortexgrid/model_serving.py,sha256=0EuCHnLHP99EpOomjPrItLIFywmvMiFLji5eSBi7S_M,13908
11
+ cortexgrid/model_storage.py,sha256=KmXfmvEZgYl9iQwE3WLoLfHdBuMrcveKn64GGv632ng,10326
12
+ cortexgrid/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ cortexgrid/ray_util.py,sha256=e3pvDL7LOHhKUGVHBvWhWH5p0kq0_qg9uN7ecUptnBY,5757
14
+ cortexgrid/s3_util.py,sha256=ZftE8eGAO8M-GmKhX0BLVu9zYFEeulYrH1nmIdX0aUE,4850
15
+ cortexgrid/secrets.py,sha256=DL0A884HKivrTNd7R0RX2ncMUddkdIbHWlu-9osXtHM,1659
16
+ cortexgrid-0.2.85.dist-info/METADATA,sha256=GYqrUaN-lVpxxlLMqcZJJmjIslQ7JMd2JxEYUcxreks,10380
17
+ cortexgrid-0.2.85.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
18
+ cortexgrid-0.2.85.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
19
+ cortexgrid-0.2.85.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any