Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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: 3 additions & 3 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:
docker:
# Important: Don't change this otherwise we will stop testing the earliest
# python version we have to support.
- image: python:3.7-bullseye
- image: python:3.10-bullseye
resource_class: medium
parallelism: 6
steps:
Expand Down Expand Up @@ -77,7 +77,7 @@ jobs:

pypi_publish:
docker:
- image: cimg/python:3.7
- image: cimg/python:3.10
steps:
- checkout # checkout source code to working directory
- run:
Expand Down Expand Up @@ -156,7 +156,7 @@ workflows:
- test_client_installation:
matrix:
parameters:
python_version: ['3.7', '3.8', '3.9', '3.10', '3.11']
python_version: ['3.10', '3.11', '3.12', '3.13', '3.14']
context: Nucleus
build_test_publish:
jobs:
Expand Down
2 changes: 1 addition & 1 deletion .pylintrc
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ disable=
W0511,
R0914,
R0913,
R0917,
C0114,
C0111,
C0103,
R0904,
R0201,
wrong-import-position

[tool.pylint.REPORTS]
Expand Down
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,35 @@ 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.18.0](https://github.com/scaleapi/nucleus-python-client/releases/tag/v0.18.0) - 2026-04-29

### Removed
- Dropped support for Python 3.7, 3.8, and 3.9. The minimum supported Python version is now **3.10**, and the SDK now supports Python **3.10, 3.11, 3.12, 3.13, and 3.14**.

### Changed
- `DatasetItem.reference_id` is now typed `Optional[str]` (defaulting to `None`) instead of `str` with a `"DUMMY_VALUE"` sentinel. The field is still required at runtime: `__post_init__` now asserts `reference_id is not None`. This matches the existing docstring (already documented as `Optional[str]`) and removes the magic sentinel.
- `nucleus/async_utils.py` now passes `aiohttp.ClientTimeout(total=DEFAULT_NETWORK_TIMEOUT_SEC)` to `session.post`/`session.get` instead of a bare integer (no behavioral change; aligns with the typed `aiohttp` API).
- `NucleusClient.list_autotags` now always returns a `list` (`List[dict]`) regardless of the response shape, matching its declared return type.

### Fixed
- All `mypy --ignore-missing-imports nucleus` errors and notes resolved (zero issues across all source files):
- `nucleus/evaluation_match.py`: widen `infer_confusion_category` parameters to `Optional[str]`.
- `nucleus/annotation.py`: default `TYPE_KEY` lookup to `""`; make `Segment.index` `Optional[int]`; type `Segment.to_payload`'s `payload` as `Dict[str, Any]`.
- `nucleus/prediction.py`: default `TYPE_KEY` lookup to `""`.
- `nucleus/camera_params.py`: make `camera_model`, `k1`–`k4`, `p1`, `p2` `Optional[...]` to match `from_json`.
- `nucleus/metrics/segmentation_utils.py` & `segmentation_metrics.py`: replace `np.float_` (removed in NumPy 2.x) with `np.float64`; use `shape[-1]` to satisfy NumPy stub typing.
- `nucleus/test_launch_integration.py`: use `Image.Image` (the class) instead of `Image` (the module) in return annotations.
- `nucleus/dataset.py`: default `dataset_item_jsons` to `[]` so the comprehension always iterates.
- `nucleus/scene.py`: annotate `Frame.__init__` and `VideoScene.info` so their bodies are type-checked.

### Tooling / CI
- Expanded CircleCI installation matrix from `[3.10, 3.11]` to `[3.10, 3.11, 3.12, 3.13, 3.14]`, so every supported Python version is exercised on every PR (build sdist, install with each extras combination, smoke-test `import nucleus`).
- Fixed pytest 9 fixture-mark errors across the test suite (`tests/cli/conftest.py`, `tests/validate/conftest.py`, `tests/test_scene.py`, `tests/test_video_scene.py`); pytest 9 turns `@pytest.mark.*` on a fixture into a hard error.
- Cleaned up several pylint findings across the codebase (`E0606`, `W3101`, `R1737`, `R1728`, `C3001`, `C3002`, `W0719`).
- Updated pylint disables (`+R0913`, `-R0201`).
- Re-applied `black` formatting after the lint pass.
- Replaced removed NumPy alias `np.float` with `np.float64` in `nucleus/metrics/segmentation_utils.py` (in addition to the previously fixed `np.float_`).

## [0.17.14](https://github.com/scaleapi/nucleus-python-client/releases/tag/v0.17.14) - 2026-04-14

### Changed
Expand Down
4 changes: 3 additions & 1 deletion cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ def init_client():
"Please set only one."
)
if api_key or limited_access_key:
client = nucleus.NucleusClient(api_key=api_key, limited_access_key=limited_access_key)
client = nucleus.NucleusClient(
api_key=api_key, limited_access_key=limited_access_key
)
else:
raise RuntimeError(
"Set NUCLEUS_API_KEY or NUCLEUS_LIMITED_ACCESS_KEY"
Expand Down
17 changes: 14 additions & 3 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,24 @@
if TYPE_CHECKING:
from nucleus import NucleusClient

assert "NUCLEUS_PYTEST_API_KEY" in os.environ or "NUCLEUS_PYTEST_LIMITED_ACCESS_KEY" in os.environ, (
assert (
"NUCLEUS_PYTEST_API_KEY" in os.environ
or "NUCLEUS_PYTEST_LIMITED_ACCESS_KEY" in os.environ
), (
"You must set at least one of 'NUCLEUS_PYTEST_API_KEY' or "
"'NUCLEUS_PYTEST_LIMITED_ACCESS_KEY' environment variables to run the test suite"
)

API_KEY = os.environ.get("NUCLEUS_PYTEST_API_KEY") if "NUCLEUS_PYTEST_API_KEY" in os.environ else None
LIMITED_ACCESS_KEY = os.environ.get("NUCLEUS_PYTEST_LIMITED_ACCESS_KEY") if "NUCLEUS_PYTEST_LIMITED_ACCESS_KEY" in os.environ else None
API_KEY = (
os.environ.get("NUCLEUS_PYTEST_API_KEY")
if "NUCLEUS_PYTEST_API_KEY" in os.environ
else None
)
LIMITED_ACCESS_KEY = (
os.environ.get("NUCLEUS_PYTEST_LIMITED_ACCESS_KEY")
if "NUCLEUS_PYTEST_LIMITED_ACCESS_KEY" in os.environ
else None
)


@pytest.fixture(scope="session")
Expand Down
22 changes: 14 additions & 8 deletions nucleus/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,8 +244,12 @@ def __init__(
self.extra_headers: Dict[str, str] = {}
if limited_access_key:
self.extra_headers["x-limited-access-key"] = limited_access_key
self.connection = Connection(self.api_key, self.endpoint, extra_headers=self.extra_headers)
self.validate = Validate(self.api_key, self.endpoint, extra_headers=self.extra_headers)
self.connection = Connection(
self.api_key, self.endpoint, extra_headers=self.extra_headers
)
self.validate = Validate(
self.api_key, self.endpoint, extra_headers=self.extra_headers
)

def __repr__(self):
return f"NucleusClient(api_key='{self.api_key}', use_notebook={self._use_notebook}, endpoint='{self.endpoint}')"
Expand Down Expand Up @@ -1019,7 +1023,11 @@ def list_autotags(self, dataset_id: str) -> List[dict]:
f"{dataset_id}/list_autotags",
requests_command=requests.get,
)
return response[AUTOTAGS_KEY] if AUTOTAGS_KEY in response else response
if isinstance(response, dict) and AUTOTAGS_KEY in response:
return list(response[AUTOTAGS_KEY])
if isinstance(response, list):
return list(response)
return []

def delete_autotag(self, autotag_id: str) -> dict:
# TODO: migrate to Dataset method (use autotag name, not id) and deprecate
Expand Down Expand Up @@ -1093,7 +1101,7 @@ def download_pointcloud_task(
)
points = response.get(POINTS_KEY, None)
if points is None or len(points) == 0:
raise Exception("Response has invalid payload")
raise RuntimeError("Response has invalid payload")

sample_point = points[0]
if I_KEY in sample_point.keys():
Expand Down Expand Up @@ -1135,7 +1143,7 @@ def download_pointcloud_tasks(
task_id = req.split("/")[1] # task/<task id>/frame/1 => task_id
points = data.get(POINTS_KEY, None)
if points is None or len(points) == 0:
raise Exception("Response has invalid payload")
raise RuntimeError("Response has invalid payload")

sample_point = points[0]
if I_KEY in sample_point.keys():
Expand Down Expand Up @@ -1252,9 +1260,7 @@ def make_request(

def _set_api_key(self, api_key):
"""Fetch API key from environment variable NUCLEUS_API_KEY if not set"""
api_key = (
api_key if api_key else os.environ.get("NUCLEUS_API_KEY")
)
api_key = api_key if api_key else os.environ.get("NUCLEUS_API_KEY")
if api_key is None:
raise NoAPIKey()

Expand Down
21 changes: 13 additions & 8 deletions nucleus/annotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import os
from dataclasses import dataclass, field
from enum import Enum
from typing import Dict, List, Optional, Sequence, Type, Union
from typing import Any, Dict, List, Optional, Sequence, Type, Union
from urllib.parse import urlparse

import numpy as np
Expand Down Expand Up @@ -71,7 +71,7 @@ def from_json(cls, payload: dict):
CATEGORY_TYPE: CategoryAnnotation,
MULTICATEGORY_TYPE: MultiCategoryAnnotation,
}
type_key = payload.get(TYPE_KEY, None)
type_key = payload.get(TYPE_KEY, "")
AnnotationCls = type_key_to_type.get(type_key, SegmentationAnnotation)
return AnnotationCls.from_json(payload)

Expand Down Expand Up @@ -749,15 +749,16 @@ class index and the string label.
Parameters:
label (str): The label name of the class for the class or instance
represented by index in the associated mask.
index (int): The integer pixel value in the mask this mapping refers to.
index (Optional[int]): The integer pixel value in the mask this mapping
refers to, if present in the payload.
metadata (Optional[Dict]): Arbitrary key/value dictionary of info to attach to this segment.
Strings, floats and ints are supported best by querying and insights
features within Nucleus. For more details see our `metadata guide
<https://nucleus.scale.com/docs/upload-metadata>`_.
"""

label: str
index: int
index: Optional[int] = None
metadata: Optional[dict] = None

@classmethod
Expand All @@ -769,7 +770,7 @@ def from_json(cls, payload: dict):
)

def to_payload(self) -> dict:
payload = {
payload: Dict[str, Any] = {
LABEL_KEY: self.label,
INDEX_KEY: self.index,
}
Expand Down Expand Up @@ -848,7 +849,7 @@ class SegmentationAnnotation(Annotation):

def __post_init__(self):
if not self.mask_url:
raise Exception("You must specify a mask_url.")
raise ValueError("You must specify a mask_url.")

@classmethod
def from_json(cls, payload: dict):
Expand Down Expand Up @@ -881,7 +882,9 @@ def has_local_files_to_upload(self) -> bool:
"""Check if the mask url is local and needs to be uploaded."""
if is_local_path(self.mask_url):
if not os.path.isfile(self.mask_url):
raise Exception(f"Mask file {self.mask_url} does not exist.")
raise FileNotFoundError(
f"Mask file {self.mask_url} does not exist."
)
return True
return False

Expand Down Expand Up @@ -1083,7 +1086,9 @@ class AnnotationList:
default_factory=list
)
cuboid_annotations: List[CuboidAnnotation] = field(default_factory=list)
category_annotations: List[CategoryAnnotation] = field(default_factory=list)
category_annotations: List[CategoryAnnotation] = field(
default_factory=list
)
multi_category_annotations: List[MultiCategoryAnnotation] = field(
default_factory=list
)
Expand Down
4 changes: 3 additions & 1 deletion nucleus/annotation_uploader.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,9 @@ def get_form_data_and_file_pointers_fn(
"""

def fn():
request_json = construct_segmentation_payload(segmentations, update)
request_json = construct_segmentation_payload(
segmentations, update
)
form_data = [
FileFormField(
name=SERIALIZED_REQUEST_KEY,
Expand Down
22 changes: 12 additions & 10 deletions nucleus/async_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,18 +182,19 @@ async def _post_form_data(
async with UPLOAD_SEMAPHORE:
for sleep_time in RetryStrategy.sleep_times() + [-1]:
with request as form:
api_key = getattr(client, "api_key", None)
async with session.post(
endpoint,
data=form,
auth=(
(lambda k: aiohttp.BasicAuth(str(k), ""))(
getattr(client, "api_key", None)
)
if getattr(client, "api_key", None) is not None
aiohttp.BasicAuth(str(api_key), "")
if api_key is not None
else None
),
headers=getattr(client, "extra_headers", None),
timeout=DEFAULT_NETWORK_TIMEOUT_SEC,
timeout=aiohttp.ClientTimeout(
total=DEFAULT_NETWORK_TIMEOUT_SEC
),
) as response:
data = await _parse_async_response(
endpoint, session, response, sleep_time
Expand Down Expand Up @@ -228,17 +229,18 @@ async def _make_request(

async with UPLOAD_SEMAPHORE:
for sleep_time in RetryStrategy.sleep_times() + [-1]:
api_key = getattr(client, "api_key", None)
async with session.get(
endpoint,
auth=(
(lambda k: aiohttp.BasicAuth(str(k), ""))(
getattr(client, "api_key", None)
)
if getattr(client, "api_key", None) is not None
aiohttp.BasicAuth(str(api_key), "")
if api_key is not None
else None
),
headers=getattr(client, "extra_headers", None),
timeout=DEFAULT_NETWORK_TIMEOUT_SEC,
timeout=aiohttp.ClientTimeout(
total=DEFAULT_NETWORK_TIMEOUT_SEC
),
) as response:
data = await _parse_async_response(
endpoint, session, response, sleep_time
Expand Down
18 changes: 10 additions & 8 deletions nucleus/camera_params.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from dataclasses import dataclass
from enum import Enum
from typing import Any, Dict
from typing import Any, Dict, Optional

from .annotation import Point3D
from .constants import (
Expand Down Expand Up @@ -56,6 +56,8 @@ class CameraParams:
fy (float): Focal length in y direction (in pixels).
cx (float): Principal point x value.
cy (float): Principal point y value.
camera_model (Optional[str]): Distortion model name when present.
k1, k2, k3, k4, p1, p2 (Optional[float]): Distortion coefficients when present.
"""

position: Point3D
Expand All @@ -64,13 +66,13 @@ class CameraParams:
fy: float
cx: float
cy: float
camera_model: str
k1: float
k2: float
k3: float
k4: float
p1: float
p2: float
camera_model: Optional[str] = None
k1: Optional[float] = None
k2: Optional[float] = None
k3: Optional[float] = None
k4: Optional[float] = None
p1: Optional[float] = None
p2: Optional[float] = None

def __post_init__(self):
if self.camera_model is not None:
Expand Down
15 changes: 12 additions & 3 deletions nucleus/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@
class Connection:
"""Wrapper of HTTP requests to the Nucleus endpoint."""

def __init__(self, api_key: Optional[str] = None, endpoint: Optional[str] = None, extra_headers: Optional[dict] = None):
def __init__(
self,
api_key: Optional[str] = None,
endpoint: Optional[str] = None,
extra_headers: Optional[dict] = None,
):
self.api_key = api_key
self.endpoint = endpoint
self.extra_headers = extra_headers or {}
Expand All @@ -24,7 +29,9 @@ def __init__(self, api_key: Optional[str] = None, endpoint: Optional[str] = None
"Cannot use both api key and limited access key simultaneously."
)
# Require at least one auth mechanism: Basic (api_key) or limited access header
if self.api_key is None and not self.extra_headers.get("x-limited-access-key"):
if self.api_key is None and not self.extra_headers.get(
"x-limited-access-key"
):
raise NoAPIKey()

def __repr__(self):
Expand Down Expand Up @@ -74,7 +81,9 @@ def make_request(

for retry_wait_time in RetryStrategy.sleep_times():
auth_kwargs = (
{"auth": (self.api_key, "")} if self.api_key is not None else {}
{"auth": (self.api_key, "")}
if self.api_key is not None
else {}
)
response = requests_command(
endpoint,
Expand Down
Loading