Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to the [Nucleus Python Client](https://github.com/scaleapi/n
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.19.1](https://github.com/scaleapi/nucleus-python-client/releases/tag/v0.19.1) - 2026-07-27

### Added
- **Model weights.** Attach a raw weights artifact (any binary, no format constraints) to a model and fetch it back: `NucleusClient.upload_model_weights(model, path)`, `download_model_weights(model, path)`, `get_model_weights(model)`, and `delete_model_weights(model)`, plus `Model.upload_weights()` / `download_weights()` / `weights()` / `delete_weights()` and the new `ModelWeights` metadata type (`present`, `status`, `size_bytes`, `original_filename`, `content_type`, `download_url`). Artifacts up to 10 GB are supported; uploading requires edit access on the model, downloading is available to anyone who can see it.
- Bytes transfer **directly to and from storage** via presigned URLs and never pass through the Nucleus API, so large artifacts aren't subject to API request-size limits. Uploads over 5 GB are split into multipart parts automatically, uploaded a few at a time; both `upload_model_weights` and `download_model_weights` accept an `on_progress` callback receiving `(bytes_transferred, total_bytes)`.

## [0.19.0](https://github.com/scaleapi/nucleus-python-client/releases/tag/v0.19.0) - 2026-07-10

### Added
Expand Down
166 changes: 166 additions & 0 deletions nucleus/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"LinePrediction",
"Model",
"ModelCreationError",
"ModelWeights",
# "MultiCategoryAnnotation", # coming soon!
"NotFoundError",
"NucleusAPIError",
Expand Down Expand Up @@ -115,6 +116,7 @@
DATASET_IS_SCENE_KEY,
DATASET_PRIVACY_MODE_KEY,
DEFAULT_NETWORK_TIMEOUT_SEC,
DELETED_KEY,
EMBEDDING_DIMENSION_KEY,
EMBEDDINGS_URL_KEY,
ERROR_ITEMS,
Expand Down Expand Up @@ -146,6 +148,8 @@
SLICE_TAGS_KEY,
STATUS_CODE_KEY,
UPDATE_KEY,
UPLOAD_ID_KEY,
URL_KEY,
)
from .data_transfer_object.dataset_details import DatasetDetails
from .data_transfer_object.dataset_info import DatasetInfo
Expand Down Expand Up @@ -196,6 +200,15 @@
)
from .model import Model
from .model_run import ModelRun
from .model_weights import (
MODEL_WEIGHTS_MAX_BYTES,
ModelWeights,
ProgressCallback,
finalize_payload,
presign_payload,
stream_weights_to_file,
transfer_weights_to_storage,
)
from .payload_constructor import (
construct_annotation_payload,
construct_append_payload,
Expand Down Expand Up @@ -1638,6 +1651,159 @@ def delete_model(self, model_id: str) -> dict:
)
return response

def upload_model_weights(
self,
model: Union[Model, str],
path: str,
*,
content_type: Optional[str] = None,
original_filename: Optional[str] = None,
checksum_sha256: Optional[str] = None,
on_progress: Optional[ProgressCallback] = None,
) -> ModelWeights:
"""Attach a weights artifact to a model.

Any binary is accepted — there are no format constraints — up to 10 GB.
Requires edit access on the model (its owner, or an ``edit`` grant under
Admin Plane RBAC). The bytes go straight to storage via a presigned URL
and never transit the Nucleus API, so large artifacts aren't subject to
API request-size limits.

::

import nucleus

client = nucleus.NucleusClient(YOUR_SCALE_API_KEY)
model = client.get_model(reference_id="My-CNN")
client.upload_model_weights(model, "/path/to/weights.bin")

Parameters:
model: A :class:`Model` or a model id (``prj_*``).
path: Local path of the artifact to upload.
content_type: Opaque content type. Defaults to
``application/octet-stream`` server-side.
original_filename: Filename stored for display. Defaults to the
basename of ``path``.
checksum_sha256: Optional client-declared SHA-256 of the artifact.
on_progress: Called with ``(bytes_sent, total_bytes)`` as the
upload proceeds.

Returns:
:class:`ModelWeights`: Metadata for the stored artifact.
"""
model_id = model.id if isinstance(model, Model) else model
total_bytes = os.path.getsize(path)
if total_bytes > MODEL_WEIGHTS_MAX_BYTES:
raise ValueError(
f"{path} is {total_bytes} bytes, which exceeds the "
f"{MODEL_WEIGHTS_MAX_BYTES // 1024 ** 3} GB model weights limit"
)

presign = self.make_request(
presign_payload(
total_bytes,
content_type,
original_filename
if original_filename is not None
else os.path.basename(path),
checksum_sha256,
),
f"model/{model_id}/weights/presign",
)
parts = transfer_weights_to_storage(
path, presign, total_bytes, on_progress
)
finalized = self.make_request(
finalize_payload(presign[UPLOAD_ID_KEY], parts),
f"model/{model_id}/weights/finalize",
)
return ModelWeights.from_json(finalized, self)

def download_model_weights(
self,
model: Union[Model, str],
path: str,
*,
on_progress: Optional[ProgressCallback] = None,
) -> str:
"""Download a model's weights artifact to a local path.

Available to anyone who can see the model.

::

import nucleus

client = nucleus.NucleusClient(YOUR_SCALE_API_KEY)
model = client.get_model(reference_id="My-CNN")
client.download_model_weights(model, "/path/to/save/weights.bin")

Parameters:
model: A :class:`Model` or a model id (``prj_*``).
path: Local path to write the artifact to. Parent directories are
created if needed.
on_progress: Called with ``(bytes_written, total_bytes)`` as the
download proceeds. ``total_bytes`` is ``0`` if storage doesn't
report a content length.

Returns:
str: The path written.

Raises:
ValueError: If the model has no weights artifact ready.
"""
model_id = model.id if isinstance(model, Model) else model
# `?json=1` returns the signed URL instead of a 302 to it, so the
# streaming GET below isn't carrying the API's auth headers to storage.
signed = self.make_request(
{},
f"model/{model_id}/weights/download?json=1",
requests_command=requests.get,
)
url = signed.get(URL_KEY)
if not url:
raise ValueError(
f"Model {model_id} has no downloadable weights artifact"
)
return stream_weights_to_file(url, path, on_progress)

def get_model_weights(self, model: Union[Model, str]) -> ModelWeights:
"""Fetch metadata for a model's weights artifact.

Parameters:
model: A :class:`Model` or a model id (``prj_*``).

Returns:
:class:`ModelWeights`: Metadata. ``present`` is ``False`` when no
ready artifact exists.
"""
model_id = model.id if isinstance(model, Model) else model
return ModelWeights.from_json(
self.make_request(
{}, f"model/{model_id}/weights", requests_command=requests.get
),
self,
)

def delete_model_weights(self, model: Union[Model, str]) -> bool:
"""Delete a model's weights artifact.

Requires edit access on the model.

Parameters:
model: A :class:`Model` or a model id (``prj_*``).

Returns:
bool: Whether an artifact was deleted.
"""
model_id = model.id if isinstance(model, Model) else model
response = self.make_request(
{},
f"model/{model_id}/weights",
requests_command=requests.delete,
)
return bool(response.get(DELETED_KEY, False))

def download_pointcloud_task(
self, task_id: str, frame_num: int
) -> List[Union[Point3D, LidarPoint]]:
Expand Down
20 changes: 20 additions & 0 deletions nucleus/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,23 @@
Y_KEY = "y"
Z_KEY = "z"
GLOB_SIZE_THRESHOLD_CHECK = 500

# Model weights payload keys. Unlike most of the keys above these are camelCase:
# the weights routes serialize their DTOs straight through rather than
# snake_casing them, so the wire format is camelCase in both directions.
CHECKSUM_SHA256_KEY = "checksumSha256"
CONTENT_TYPE_KEY = "contentType"
DECLARED_SIZE_BYTES_KEY = "declaredSizeBytes"
DELETED_KEY = "deleted"
DOWNLOAD_URL_KEY = "downloadUrl"
ETAG_KEY = "eTag"
MODEL_PROJECT_ID_KEY = "modelProjectId"
ORIGINAL_FILENAME_KEY = "originalFilename"
PART_NUMBER_KEY = "partNumber"
PART_SIZE_BYTES_KEY = "partSizeBytes"
PARTS_KEY = "parts"
PRESENT_KEY = "present"
REQUIRED_HEADERS_KEY = "requiredHeaders"
SIZE_BYTES_KEY = "sizeBytes"
UPLOAD_ID_KEY = "uploadId"
UPLOAD_URL_KEY = "uploadUrl"
36 changes: 36 additions & 0 deletions nucleus/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
)
from .dataset import Dataset
from .model_run import ModelRun
from .model_weights import ModelWeights
from .prediction import (
BoxPrediction,
CuboidPrediction,
Expand Down Expand Up @@ -343,3 +344,38 @@ def remove_trained_slice_ids(self, slide_ids: List[str]):
)

return response.json()

def upload_weights(self, path: str, **kwargs) -> "ModelWeights":
"""Attach a weights artifact to this model. ::

import nucleus
client = nucleus.NucleusClient("YOUR_SCALE_API_KEY")
model = client.get_model(reference_id="My-CNN")

model.upload_weights("/path/to/weights.bin")

See :meth:`NucleusClient.upload_model_weights` for the accepted keyword
arguments.
"""
return self._client.upload_model_weights(self, path, **kwargs)

def download_weights(self, path: str, **kwargs) -> str:
"""Download this model's weights artifact to ``path``.

See :meth:`NucleusClient.download_model_weights`.
"""
return self._client.download_model_weights(self, path, **kwargs)

def weights(self) -> "ModelWeights":
"""Fetch metadata for this model's weights artifact.

See :meth:`NucleusClient.get_model_weights`.
"""
return self._client.get_model_weights(self)

def delete_weights(self) -> bool:
"""Delete this model's weights artifact.

See :meth:`NucleusClient.delete_model_weights`.
"""
return self._client.delete_model_weights(self)
Loading