From 9efc504747b674f02cea1a9da2857e8ba1c3b2d4 Mon Sep 17 00:00:00 2001 From: Gregory Mulholland Date: Wed, 25 Mar 2026 12:12:18 -0700 Subject: [PATCH] Enhance validation, batch, and deserialization error messages - Batcher collision error now explains what a collision means and suggests ensuring unique UIDs - Batcher dependency error now shows count and first 10 dependency names, suggests increasing batch_size - Collection uid=None error now explains the likely cause (unregistered object) and suggests calling .register() - Deserialization errors truncate data dicts to 200 chars and value reprs to 100 chars to avoid overwhelming output Co-Authored-By: Claude Opus 4.6 (1M context) --- src/citrine/_rest/collection.py | 7 +++++- src/citrine/_serialization/properties.py | 29 ++++++++++++++++++------ src/citrine/_utils/batcher.py | 17 ++++++++++++-- 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/src/citrine/_rest/collection.py b/src/citrine/_rest/collection.py index 6f83805ed..efe2242a6 100644 --- a/src/citrine/_rest/collection.py +++ b/src/citrine/_rest/collection.py @@ -52,7 +52,12 @@ def build(self, data: dict): def get(self, uid: Union[UUID, str]) -> ResourceType: """Get a particular element of the collection.""" if uid is None: - raise ValueError("Cannot get when uid=None. Are you using a registered resource?") + raise ValueError( + "Cannot retrieve a resource with uid=None. " + "This usually means the object has not been " + "registered with the platform yet. Call " + ".register() first to obtain a server-assigned " + "UID.") path = self._get_path(uid) data = self.session.get_resource(path, version=self._api_version) data = data[self._individual_key] if self._individual_key else data diff --git a/src/citrine/_serialization/properties.py b/src/citrine/_serialization/properties.py index 95cceef28..5dba37e35 100644 --- a/src/citrine/_serialization/properties.py +++ b/src/citrine/_serialization/properties.py @@ -95,9 +95,13 @@ def serialize(self, value: DeserializedType, base_class: typing.Optional[type] = None) -> SerializedType: if not isinstance(value, self.underlying_types): base_name = self._error_source(base_class) + value_repr = repr(value) + if len(value_repr) > 100: + value_repr = value_repr[:100] + "..." raise ValueError( - f'{type(value)} {value} is not one of valid types: ' - f'{self.underlying_types}{base_name}' + f'{type(value).__name__} is not one of valid ' + f'types: {self.underlying_types}{base_name}. ' + f'Value: {value_repr}' ) return self._serialize(value) @@ -107,9 +111,13 @@ def deserialize(self, value: SerializedType, if isinstance(value, self.underlying_types): return value # Don't worry if it was already deserialized base_name = self._error_source(base_class) + value_repr = repr(value) + if len(value_repr) > 100: + value_repr = value_repr[:100] + "..." raise ValueError( - f'{type(value)} {value} is not one of valid types: ' - f'{self.serialized_types}{base_name}' + f'{type(value).__name__} is not one of valid ' + f'types: {self.serialized_types}{base_name}. ' + f'Value: {value_repr}' ) return self._deserialize(value) @@ -129,9 +137,16 @@ def deserialize_from_dict(self, data: dict) -> DeserializedType: next_value = value.get(field) if next_value is None: if self.default is None and not self.optional: - msg = "Unable to deserialize {} into {}, missing a required field: {}".format( - data, self.underlying_types, field) - raise ValueError(msg) + data_preview = str(data) + if len(data_preview) > 200: + data_preview = data_preview[:200] + "..." + raise ValueError( + "Unable to deserialize into {}: " + "missing required field '{}'. " + "Data: {}".format( + self.underlying_types, + field, data_preview) + ) # This occurs if a `field` is unexpectedly not present in the data dictionary # or if its value is null. # Use the default value and stop traversing, even if we have not yet reached diff --git a/src/citrine/_utils/batcher.py b/src/citrine/_utils/batcher.py index dcc171807..1bf23b5b7 100644 --- a/src/citrine/_utils/batcher.py +++ b/src/citrine/_utils/batcher.py @@ -36,7 +36,11 @@ def batch(self, objects: Iterable[DataConcepts], batch_size) -> List[List[DataCo for obj in objects: if obj.to_link() in seen: # Repeat in the iterable; don't add it to the batch if seen[obj.to_link()] != obj: # verify that it's a replicate - raise ValueError(f"Colliding objects for {obj.to_link()}") + raise ValueError( + "Colliding objects for {}: two different " + "objects share the same identifier. Ensure " + "each object has a unique UID.".format( + obj.to_link())) else: by_type[obj.typ].append(obj) for scope in obj.uids: @@ -80,7 +84,16 @@ def batch(self, objects: Iterable[DataConcepts], batch_size) -> List[List[DataCo local_set = {index.get(x, x) for x in depends[obj] if index.get(x, x) in obj_set} full_set = set(local_set) if len(full_set) > batch_size: - raise ValueError(f"Object {obj.name} has more than {batch_size} dependencies.") + sample = [getattr(d, 'name', str(d)) + for d in list(full_set)[:10]] + raise ValueError( + "Object '{}' has {} dependencies, " + "exceeding batch_size={}. First {}: {}. " + "Increase batch_size or simplify the " + "dependency graph.".format( + obj.name, len(full_set), + batch_size, len(sample), sample) + ) for subobj in local_set: full_set.update(depends[subobj])