diff --git a/CHANGELOG.md b/CHANGELOG.md index 021de916..980a7547 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ 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.21.3](https://github.com/scaleapi/nucleus-python-client/releases/tag/v0.21.3) - 2026-08-27 + +### Added +- **Full model create/edit parity with the dashboard.** `create_model()` now accepts every field the UI exposes: `description`, `architecture`, `num_parameters`, `training_data`, `input_schema`, `output_schema`, structured `training_data_fields` ({key: value}, stored in metadata + indexed for search), and versioning (`parent_model_project_id`, `bump_type`, `version_major`, `version_minor`, `version_label`). +- **`NucleusClient.update_model()` / `Model.update()`.** Edit an existing model's descriptive fields (name, reference id, metadata, description, architecture, num parameters, training data, input/output schema, training-data fields). Only fields you pass change; passing `None` clears a nullable field. Backed by the new `POST /nucleus/model/:modelId/update` endpoint. +- **`Model.set_parent()`.** Set or clear a model's parent and version (pass `None` to make it a root). +- `Model` now exposes `description`, `architecture`, `num_parameters`, `training_data`, `input_schema`, `output_schema`, `parent_model_project_id`, `version_major`, `version_minor`, and `version_label`, populated by `Model.from_json`. + ## [0.21.2](https://github.com/scaleapi/nucleus-python-client/releases/tag/v0.21.2) - 2026-08-17 ### Added diff --git a/nucleus/__init__.py b/nucleus/__init__.py index e5a94c5a..b7943be1 100644 --- a/nucleus/__init__.py +++ b/nucleus/__init__.py @@ -236,10 +236,13 @@ _transfer_weights_to_storage, ) from .payload_constructor import ( + NO_UPDATE, construct_annotation_payload, construct_box_predictions_payload, construct_model_creation_payload, + construct_model_update_payload, construct_segmentation_payload, + merge_training_data_fields_into_metadata, ) from .prediction import ( BoxPrediction, @@ -764,6 +767,18 @@ def create_model( bundle_name: Optional[str] = None, tags: Optional[List[str]] = None, trained_slice_ids: Optional[List[str]] = None, + description: Optional[str] = None, + architecture: Optional[str] = None, + num_parameters: Optional[str] = None, + training_data: Optional[str] = None, + input_schema: Optional[Dict] = None, + output_schema: Optional[Dict] = None, + training_data_fields: Optional[Dict[str, str]] = None, + parent_model_project_id: Optional[str] = None, + bump_type: Optional[str] = None, + version_major: Optional[int] = None, + version_minor: Optional[int] = None, + version_label: Optional[str] = None, ) -> Model: """Adds a :class:`Model` to Nucleus. @@ -777,10 +792,32 @@ def create_model( about the hyperparameters used in training this model. bundle_name: Optional name of bundle attached to this model tags: Optional list of tags to attach to this model + description: Optional free-text description of the model. + architecture: Optional model architecture (e.g. ``"ResNet-50"``). + num_parameters: Optional parameter count, as a string so values like + ``"7B"`` are allowed. + training_data: Optional free-text description of the training data. + input_schema: Optional JSON schema describing the model's input. + output_schema: Optional JSON schema describing the model's output. + training_data_fields: Optional ``{key: value}`` mapping of structured + training-data fields (e.g. ``{"biome": "desert"}``), matching the + dashboard's training-data editor. Stored inside ``metadata`` and + indexed for search; merged on top of any ``metadata`` you pass. + parent_model_project_id: Optional id of a model to branch a new version + from. When set, this model becomes a version of that parent. + bump_type: ``"major"`` or ``"minor"``; how to bump the version relative + to the parent. Required when ``parent_model_project_id`` is set unless + ``version_major``/``version_minor`` are given explicitly. + version_major: Optional explicit major version. + version_minor: Optional explicit minor version. + version_label: Optional display label (e.g. a semver string). Returns: :class:`Model`: The newly created model as an object. """ + metadata = merge_training_data_fields_into_metadata( + metadata, training_data_fields + ) response = self.make_request( construct_model_creation_payload( name, @@ -789,6 +826,17 @@ def create_model( bundle_name, tags, trained_slice_ids, + description=description, + architecture=architecture, + num_parameters=num_parameters, + training_data=training_data, + input_schema=input_schema, + output_schema=output_schema, + parent_model_project_id=parent_model_project_id, + bump_type=bump_type, + version_major=version_major, + version_minor=version_minor, + version_label=version_label, ), "models/add", ) @@ -805,7 +853,80 @@ def create_model( client=self, tags=tags, trained_slice_ids=trained_slice_ids, + description=description, + architecture=architecture, + num_parameters=num_parameters, + training_data=training_data, + input_schema=input_schema, + output_schema=output_schema, + parent_model_project_id=parent_model_project_id, + version_major=response.get("version_major", version_major), + version_minor=response.get("version_minor", version_minor), + version_label=version_label, + ) + + def update_model( + self, + model_id: str, + name=NO_UPDATE, + reference_id=NO_UPDATE, + metadata=NO_UPDATE, + description=NO_UPDATE, + architecture=NO_UPDATE, + num_parameters=NO_UPDATE, + training_data=NO_UPDATE, + input_schema=NO_UPDATE, + output_schema=NO_UPDATE, + training_data_fields=NO_UPDATE, + ) -> Model: + """Edit an existing model's descriptive fields. + + Only the fields you pass are changed; anything left at its default is untouched + server-side. For nullable fields, passing ``None`` deliberately clears the value + (distinct from not passing it at all). Versioning/parent changes go through + :meth:`Model.set_parent` instead. + + Parameters: + model_id: Id of the model to edit. + name: New name. + reference_id: New reference id (``None`` clears it). + metadata: Replacement metadata dict (``None`` clears it). If + ``training_data_fields`` is also given it is merged on top of this. + description: New description (``None`` clears it). + architecture: New architecture (``None`` clears it). + num_parameters: New parameter count string (``None`` clears it). + training_data: New training-data description (``None`` clears it). + input_schema: New input JSON schema (``None`` clears it). + output_schema: New output JSON schema (``None`` clears it). + training_data_fields: ``{key: value}`` structured training-data fields to + write into ``metadata`` under the reserved key. Merged on top of + ``metadata`` when both are supplied; on its own it starts from an empty + metadata object, so pass ``metadata`` too if you need to preserve + existing keys. + + Returns: + :class:`Model`: The updated model, refreshed from the server. + """ + if training_data_fields is not NO_UPDATE: + base = ( + {} if metadata is NO_UPDATE or metadata is None else metadata + ) + metadata = merge_training_data_fields_into_metadata( + base, training_data_fields + ) + payload = construct_model_update_payload( + name=name, + reference_id=reference_id, + metadata=metadata, + description=description, + architecture=architecture, + num_parameters=num_parameters, + training_data=training_data, + input_schema=input_schema, + output_schema=output_schema, ) + response = self.make_request(payload, f"model/{model_id}/update") + return Model.from_json(response, self) def create_launch_model( self, @@ -1166,9 +1287,11 @@ def update_evaluation_v2_preset( None if exclusion_rules is None else [ - rule.to_api_dict() - if hasattr(rule, "to_api_dict") - else rule + ( + rule.to_api_dict() + if hasattr(rule, "to_api_dict") + else rule + ) for rule in exclusion_rules ] ) diff --git a/nucleus/constants.py b/nucleus/constants.py index fdc7e8d6..54b0b025 100644 --- a/nucleus/constants.py +++ b/nucleus/constants.py @@ -110,6 +110,21 @@ MODEL_BUNDLE_NAME_KEY = "bundle_name" MODEL_TAGS_KEY = "tags" MODEL_TRAINED_SLICE_IDS_KEY = "trained_slice_ids" +MODEL_DESCRIPTION_KEY = "description" +MODEL_ARCHITECTURE_KEY = "architecture" +MODEL_NUM_PARAMETERS_KEY = "num_parameters" +MODEL_TRAINING_DATA_KEY = "training_data" +MODEL_INPUT_SCHEMA_KEY = "input_schema" +MODEL_OUTPUT_SCHEMA_KEY = "output_schema" +MODEL_PARENT_MODEL_PROJECT_ID_KEY = "parent_model_project_id" +MODEL_BUMP_TYPE_KEY = "bump_type" +MODEL_VERSION_MAJOR_KEY = "version_major" +MODEL_VERSION_MINOR_KEY = "version_minor" +MODEL_VERSION_LABEL_KEY = "version_label" +# Reserved key, inside a model's `metadata` blob, holding the structured +# training-data fields (a list of {"key", "value"}). Backend + dashboard agree on +# this exact camelCase key; see scaleapi packages/shared/src/nucleus/modelTrainingDataFields.ts. +MODEL_TRAINING_DATA_FIELDS_METADATA_KEY = "trainingDataFields" MODEL_ID_KEY = "model_id" MODEL_RUN_ID_KEY = "model_run_id" MODEL_RUN_IDS_KEY = "model_run_ids" diff --git a/nucleus/model.py b/nucleus/model.py index 18340571..ee860ae9 100644 --- a/nucleus/model.py +++ b/nucleus/model.py @@ -4,15 +4,29 @@ from .async_job import AsyncJob from .constants import ( + BACKEND_REFERENCE_ID_KEY, METADATA_KEY, + MODEL_ARCHITECTURE_KEY, + MODEL_BUMP_TYPE_KEY, + MODEL_BUNDLE_NAME_KEY, + MODEL_DESCRIPTION_KEY, + MODEL_INPUT_SCHEMA_KEY, + MODEL_NUM_PARAMETERS_KEY, + MODEL_OUTPUT_SCHEMA_KEY, + MODEL_PARENT_MODEL_PROJECT_ID_KEY, MODEL_TAGS_KEY, MODEL_TRAINED_SLICE_IDS_KEY, + MODEL_TRAINING_DATA_KEY, + MODEL_VERSION_LABEL_KEY, + MODEL_VERSION_MAJOR_KEY, + MODEL_VERSION_MINOR_KEY, NAME_KEY, REFERENCE_ID_KEY, ) from .dataset import Dataset from .model_run import ModelRun from .model_weights import ModelWeights +from .payload_constructor import NO_UPDATE from .prediction import ( BoxPrediction, CuboidPrediction, @@ -109,6 +123,16 @@ def __init__( bundle_name=None, tags=None, trained_slice_ids=None, + description=None, + architecture=None, + num_parameters=None, + training_data=None, + input_schema=None, + output_schema=None, + parent_model_project_id=None, + version_major=None, + version_minor=None, + version_label=None, ): self.id = model_id self.name = name @@ -118,9 +142,30 @@ def __init__( self.tags = tags if tags else [] self._client = client self.trained_slice_ids = trained_slice_ids if trained_slice_ids else [] + self.description = description + self.architecture = architecture + self.num_parameters = num_parameters + self.training_data = training_data + self.input_schema = input_schema + self.output_schema = output_schema + self.parent_model_project_id = parent_model_project_id + self.version_major = version_major + self.version_minor = version_minor + self.version_label = version_label def __repr__(self): - return f"Model(model_id='{self.id}', name='{self.name}', reference_id='{self.reference_id}', metadata={self.metadata}, bundle_name={self.bundle_name}, tags={self.tags}, client={self._client}, trained_slice_ids={self.trained_slice_ids})" + return ( + f"Model(model_id='{self.id}', name='{self.name}', " + f"reference_id='{self.reference_id}', metadata={self.metadata}, " + f"bundle_name={self.bundle_name}, tags={self.tags}, " + f"trained_slice_ids={self.trained_slice_ids}, " + f"description={self.description}, architecture={self.architecture}, " + f"num_parameters={self.num_parameters}, training_data={self.training_data}, " + f"input_schema={self.input_schema}, output_schema={self.output_schema}, " + f"parent_model_project_id={self.parent_model_project_id}, " + f"version_major={self.version_major}, version_minor={self.version_minor}, " + f"version_label={self.version_label}, client={self._client})" + ) def __eq__(self, other): return ( @@ -141,12 +186,110 @@ def from_json(cls, payload: dict, client): return cls( model_id=payload["id"], name=payload["name"], - reference_id=payload["ref_id"], + # The backend returns the reference id as `ref_id`; fall back to the + # canonical key for any endpoint that returns it that way. + reference_id=payload.get( + BACKEND_REFERENCE_ID_KEY, payload.get(REFERENCE_ID_KEY) + ), metadata=payload["metadata"] or None, client=client, + bundle_name=payload.get(MODEL_BUNDLE_NAME_KEY, None), tags=payload.get(MODEL_TAGS_KEY, None), trained_slice_ids=payload.get(MODEL_TRAINED_SLICE_IDS_KEY, None), + description=payload.get(MODEL_DESCRIPTION_KEY, None), + architecture=payload.get(MODEL_ARCHITECTURE_KEY, None), + num_parameters=payload.get(MODEL_NUM_PARAMETERS_KEY, None), + training_data=payload.get(MODEL_TRAINING_DATA_KEY, None), + input_schema=payload.get(MODEL_INPUT_SCHEMA_KEY, None), + output_schema=payload.get(MODEL_OUTPUT_SCHEMA_KEY, None), + parent_model_project_id=payload.get( + MODEL_PARENT_MODEL_PROJECT_ID_KEY, None + ), + version_major=payload.get(MODEL_VERSION_MAJOR_KEY, None), + version_minor=payload.get(MODEL_VERSION_MINOR_KEY, None), + version_label=payload.get(MODEL_VERSION_LABEL_KEY, None), + ) + + def update( + self, + name=NO_UPDATE, + reference_id=NO_UPDATE, + metadata=NO_UPDATE, + description=NO_UPDATE, + architecture=NO_UPDATE, + num_parameters=NO_UPDATE, + training_data=NO_UPDATE, + input_schema=NO_UPDATE, + output_schema=NO_UPDATE, + training_data_fields=NO_UPDATE, + ) -> "Model": + """Edit this model's descriptive fields in place. + + Thin wrapper over :meth:`NucleusClient.update_model` — only the fields you pass + are changed, and passing ``None`` clears a nullable field. Refreshes this + object's attributes from the server response and returns ``self``. + """ + updated = self._client.update_model( + self.id, + name=name, + reference_id=reference_id, + metadata=metadata, + description=description, + architecture=architecture, + num_parameters=num_parameters, + training_data=training_data, + input_schema=input_schema, + output_schema=output_schema, + training_data_fields=training_data_fields, + ) + # Copy refreshed state back onto this instance (both share the same client). + self.__dict__.update(updated.__dict__) + return self + + def set_parent( + self, + parent_model_project_id: Optional[str], + bump_type: Optional[str] = None, + version_major: Optional[int] = None, + version_minor: Optional[int] = None, + version_label: Optional[str] = None, + ) -> "Model": + """Set or clear this model's parent and version. + + Pass a model id to make this model a version branched from that parent, or + ``None`` to clear the parent and turn it back into a root. ``bump_type`` + (``"major"``/``"minor"``) selects the version bump relative to the parent unless + ``version_major``/``version_minor`` are given explicitly. Updates this object's + version fields from the server response and returns ``self``. + """ + payload: Dict[str, Union[str, int, None]] = { + MODEL_PARENT_MODEL_PROJECT_ID_KEY: parent_model_project_id, + } + if bump_type is not None: + payload[MODEL_BUMP_TYPE_KEY] = bump_type + if version_major is not None: + payload[MODEL_VERSION_MAJOR_KEY] = version_major + if version_minor is not None: + payload[MODEL_VERSION_MINOR_KEY] = version_minor + if version_label is not None: + payload[MODEL_VERSION_LABEL_KEY] = version_label + + response = self._client.make_request( + payload, f"model/{self.id}/parent" + ) + self.parent_model_project_id = response.get( + MODEL_PARENT_MODEL_PROJECT_ID_KEY, self.parent_model_project_id + ) + self.version_major = response.get( + MODEL_VERSION_MAJOR_KEY, self.version_major + ) + self.version_minor = response.get( + MODEL_VERSION_MINOR_KEY, self.version_minor + ) + self.version_label = response.get( + MODEL_VERSION_LABEL_KEY, self.version_label ) + return self def create_run( self, diff --git a/nucleus/payload_constructor.py b/nucleus/payload_constructor.py index 15fd9169..fa13f950 100644 --- a/nucleus/payload_constructor.py +++ b/nucleus/payload_constructor.py @@ -15,10 +15,22 @@ ANNOTATIONS_KEY, LABELS_KEY, METADATA_KEY, + MODEL_ARCHITECTURE_KEY, + MODEL_BUMP_TYPE_KEY, MODEL_BUNDLE_NAME_KEY, + MODEL_DESCRIPTION_KEY, MODEL_ID_KEY, + MODEL_INPUT_SCHEMA_KEY, + MODEL_NUM_PARAMETERS_KEY, + MODEL_OUTPUT_SCHEMA_KEY, + MODEL_PARENT_MODEL_PROJECT_ID_KEY, MODEL_TAGS_KEY, MODEL_TRAINED_SLICE_IDS_KEY, + MODEL_TRAINING_DATA_FIELDS_METADATA_KEY, + MODEL_TRAINING_DATA_KEY, + MODEL_VERSION_LABEL_KEY, + MODEL_VERSION_MAJOR_KEY, + MODEL_VERSION_MINOR_KEY, NAME_KEY, REFERENCE_ID_KEY, SEGMENTATIONS_KEY, @@ -104,6 +116,32 @@ def construct_box_predictions_payload( return {ANNOTATIONS_KEY: predictions, ANNOTATION_UPDATE_KEY: update} +# Sentinel distinguishing "leave unchanged" from an explicit ``None`` ("clear this +# field") in model update calls, where both are meaningful. +NO_UPDATE = object() + + +def merge_training_data_fields_into_metadata( + metadata: Optional[Dict], training_data_fields: Optional[Dict[str, str]] +) -> Optional[Dict]: + """Return a copy of ``metadata`` with structured training-data fields written under + the reserved key the dashboard and backend agree on. + + ``training_data_fields`` is a ``{key: value}`` mapping; it is stored as a list of + ``{"key", "value"}`` objects (the shape the model-registry UI reads/writes and the + server denormalizes into ``nucleus.model_training_data_field`` for search). Returns + ``metadata`` unchanged when ``training_data_fields`` is ``None``. + """ + if training_data_fields is None: + return metadata + merged = dict(metadata) if metadata else {} + merged[MODEL_TRAINING_DATA_FIELDS_METADATA_KEY] = [ + {"key": str(k), "value": str(v)} + for k, v in training_data_fields.items() + ] + return merged + + def construct_model_creation_payload( name: str, reference_id: str, @@ -111,6 +149,17 @@ def construct_model_creation_payload( bundle_name: Optional[str], tags: Optional[List[str]], trained_slice_ids: Optional[List[str]], + description: Optional[str] = None, + architecture: Optional[str] = None, + num_parameters: Optional[str] = None, + training_data: Optional[str] = None, + input_schema: Optional[Dict] = None, + output_schema: Optional[Dict] = None, + parent_model_project_id: Optional[str] = None, + bump_type: Optional[str] = None, + version_major: Optional[int] = None, + version_minor: Optional[int] = None, + version_label: Optional[str] = None, ) -> dict: payload = { NAME_KEY: name, @@ -125,9 +174,63 @@ def construct_model_creation_payload( if tags: payload[MODEL_TAGS_KEY] = tags + # Optional descriptive + versioning fields: only sent when provided so the server + # keeps its defaults for anything omitted. + optional_fields = { + MODEL_DESCRIPTION_KEY: description, + MODEL_ARCHITECTURE_KEY: architecture, + MODEL_NUM_PARAMETERS_KEY: num_parameters, + MODEL_TRAINING_DATA_KEY: training_data, + MODEL_INPUT_SCHEMA_KEY: input_schema, + MODEL_OUTPUT_SCHEMA_KEY: output_schema, + MODEL_PARENT_MODEL_PROJECT_ID_KEY: parent_model_project_id, + MODEL_BUMP_TYPE_KEY: bump_type, + MODEL_VERSION_MAJOR_KEY: version_major, + MODEL_VERSION_MINOR_KEY: version_minor, + MODEL_VERSION_LABEL_KEY: version_label, + } + for key, value in optional_fields.items(): + if value is not None: + payload[key] = value + return payload +def construct_model_update_payload( + name=NO_UPDATE, + reference_id=NO_UPDATE, + metadata=NO_UPDATE, + description=NO_UPDATE, + architecture=NO_UPDATE, + num_parameters=NO_UPDATE, + training_data=NO_UPDATE, + input_schema=NO_UPDATE, + output_schema=NO_UPDATE, +) -> dict: + """Build the body for ``POST model/{id}/update``. + + Only fields the caller actually passed (value is not :data:`NO_UPDATE`) are included, + so unspecified fields are left untouched server-side. Passing ``None`` for a nullable + field is a deliberate "clear it" and is forwarded as ``null``. + """ + candidates = { + NAME_KEY: name, + REFERENCE_ID_KEY: reference_id, + METADATA_KEY: metadata, + MODEL_DESCRIPTION_KEY: description, + MODEL_ARCHITECTURE_KEY: architecture, + MODEL_NUM_PARAMETERS_KEY: num_parameters, + MODEL_TRAINING_DATA_KEY: training_data, + MODEL_INPUT_SCHEMA_KEY: input_schema, + MODEL_OUTPUT_SCHEMA_KEY: output_schema, + } + return { + key: value + for key, value in candidates.items() + if value is not NO_UPDATE + } + + def construct_model_run_creation_payload( name: str, reference_id: Optional[str], diff --git a/pyproject.toml b/pyproject.toml index 4901f914..68651aab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ ignore = ["E501", "E741", "E731", "F401"] # Easy ignore for getting it running [tool.poetry] name = "scale-nucleus" -version = "0.21.2" +version = "0.21.3" description = "The official Python client library for Nucleus, the Data Platform for AI" license = "MIT" authors = ["Scale AI Nucleus Team "]