From d146cc3feccc4e18f7aaadd0684eafc705189aad Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:32:18 +0100 Subject: [PATCH] fix(webapi): give uploaded objects their own identity An object downloaded from the Web API keeps its UUID in its metadata. Uploading it again under another name (PUT /objects/{name}/data, e.g. DataLab-Kernel's rename) created an object with the UUID of the original one: the panel refused it, the error was shown in a modal dialog on the Qt main thread, and the request hung until the 30-second main-thread timeout. PUT /objects/{name}/data creates (or replaces) an object: the adapter now gives it a new UUID, whatever the archive carries, and adds it with the panel method that raises errors instead of showing them, so that the API returns an error response. Updating an object in place (PUT /objects/{name}) still keeps its identity. Closes #434 Assisted-by: Claude Opus 5.5 --- datalab/tests/webapi_extra_test.py | 36 ++++++++++++++++++++++++++++++ datalab/webapi/adapter.py | 15 +++++++++---- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/datalab/tests/webapi_extra_test.py b/datalab/tests/webapi_extra_test.py index b8e7093ab..9fd8e96f8 100644 --- a/datalab/tests/webapi_extra_test.py +++ b/datalab/tests/webapi_extra_test.py @@ -145,6 +145,42 @@ def test_workspace_adapter_resolves_uuid_and_rejects_ambiguous_titles() -> None: image_panel.SIG_OBJECT_REMOVED.emit.assert_called_once_with() +def test_workspace_adapter_uploaded_copy_gets_a_new_identity() -> None: + """An object downloaded then uploaded under another name is a new object. + + Regression: the copy carried the UUID of the original object, the panel + refused it and showed the error in a modal dialog, so that the request hung + until the main-thread timeout (DataLab-Kernel ``rename``). + """ + signal = _make_signal("Original", n=10) + signal_panel = make_panel([signal]) + + def add_object(obj: DataObject) -> None: + if get_uuid(obj) in [get_uuid(other) for other in signal_panel.objmodel]: + raise ValueError("Object already in panel") + signal_panel.objmodel.append(obj) + + signal_panel._add_object = add_object # pylint: disable=protected-access + adapter = WorkspaceAdapter( + SimpleNamespace(signalpanel=signal_panel, imagepanel=make_panel([])) + ) + copy = adapter.get_object(get_uuid(signal)) + assert get_uuid(copy) == get_uuid(signal) + copy.title = "Renamed" + adapter.add_object(copy) + assert len(signal_panel.objmodel) == 2 + assert get_uuid(signal_panel.objmodel[1]) != get_uuid(signal) + assert get_uuid(signal) == get_uuid(signal_panel.objmodel[0]) + + # Errors of the panel are raised to the caller, not shown in a dialog + def failing_add(_obj: DataObject) -> None: + raise ValueError("refused") + + signal_panel._add_object = failing_add # pylint: disable=protected-access + with pytest.raises(ValueError, match="refused"): + adapter.add_object(_make_signal("Other")) + + # ============================================================================= # Serialization helpers — _to_python_scalar # ============================================================================= diff --git a/datalab/webapi/adapter.py b/datalab/webapi/adapter.py index de3a156d7..16431c0f6 100644 --- a/datalab/webapi/adapter.py +++ b/datalab/webapi/adapter.py @@ -34,7 +34,7 @@ from qtpy.QtCore import QCoreApplication, QObject, QThread, Signal, Slot -from datalab.objectmodel import UUID_REGEX, get_uuid +from datalab.objectmodel import UUID_REGEX, get_uuid, set_uuid if TYPE_CHECKING: from sigima.objects import ImageObj, SignalObj @@ -342,7 +342,13 @@ def add_object(self, obj: DataObject, overwrite: bool = False) -> None: self._add_object_sync(obj) def _add_object_sync(self, obj: DataObject) -> None: - """Add object (called from main thread or marshaled via executor).""" + """Add object (called from main thread or marshaled via executor). + + The object is a new one: it gets its own identity, whatever UUID its + metadata carry (e.g. an object downloaded from the API and uploaded + again under another name). Errors are raised to the caller, never shown + in a dialog. + """ obj_type = type(obj).__name__ if obj_type == "SignalObj": @@ -352,8 +358,9 @@ def _add_object_sync(self, obj: DataObject) -> None: else: raise TypeError(f"Unsupported object type: {obj_type}") - # Use executor to run on main thread if necessary - self._executor.run_on_main_thread(lambda: panel.add_object(obj)) + set_uuid(obj) + # pylint: disable-next=protected-access + self._executor.run_on_main_thread(lambda: panel._add_object(obj)) def remove_object(self, name: str) -> None: """Remove an object from the workspace.