diff --git a/src/derzug/utils/misc.py b/src/derzug/utils/misc.py index eec765a..cbf9341 100644 --- a/src/derzug/utils/misc.py +++ b/src/derzug/utils/misc.py @@ -27,8 +27,12 @@ def load_widget_entrypoints(): """ return tuple( sorted( - entry_points(group=constants.WIDGETS_ENTRY), - key=lambda ep: 0 if ep.dist.name.lower() == constants.PKG_NAME else 1, + ( + ep + for ep in entry_points(group=constants.WIDGETS_ENTRY) + if ep.dist.name.lower() == constants.PKG_NAME + ), + key=lambda ep: ep.name, ) ) diff --git a/src/derzug/utils/spool.py b/src/derzug/utils/spool.py index 8ed8f7c..6a5c61a 100644 --- a/src/derzug/utils/spool.py +++ b/src/derzug/utils/spool.py @@ -37,6 +37,24 @@ def normalize_dims_value(value: Any) -> list[str]: def extract_single_patch(spool: dc.BaseSpool) -> dc.Patch | None: """Return the only patch in spool, or None when length is not exactly one.""" + get_contents = getattr(spool, "get_contents", None) + if callable(get_contents): + try: + contents = get_contents() + except Exception: + pass + else: + try: + row_count = len(contents) + except Exception: + row_count = 1 + if row_count != 1: + return None + try: + return spool[0] + except Exception: + pass + iterator = iter(spool) try: first = next(iterator) diff --git a/src/derzug/views/orange_registry.py b/src/derzug/views/orange_registry.py index ac1658f..81b5afd 100644 --- a/src/derzug/views/orange_registry.py +++ b/src/derzug/views/orange_registry.py @@ -39,7 +39,9 @@ def filter_registry_for_das(registry: WidgetRegistry) -> WidgetRegistry: for widget in registry.widgets(): package = (getattr(widget, "package", "") or "").lower() name = widget.name - is_derzug_widget = package.startswith(constants.PKG_NAME) + is_derzug_widget = package == constants.PKG_NAME or package.startswith( + f"{constants.PKG_NAME}." + ) if not is_derzug_widget and name not in constants.ORANGE_WIDGETS_TO_LOAD: continue if "obsolete" in widget.id: diff --git a/src/derzug/widgets/spool.py b/src/derzug/widgets/spool.py index 4c9019f..edccc1b 100644 --- a/src/derzug/widgets/spool.py +++ b/src/derzug/widgets/spool.py @@ -1642,10 +1642,15 @@ def set_patch(self, patch: dc.Patch | None) -> None: @Inputs.spool def set_spool(self, spool: dc.BaseSpool | None) -> None: - """Append an incoming spool to the current spool.""" + """Use an incoming spool as the source for select/chunk transforms.""" if spool is None: return - self._ingest_input(spool) + self.Error.general.clear() + self._clear_other_inputs("snapshot") + self._source_mode = "snapshot" + self._set_source_controls_enabled(False) + self._set_source_spool(spool) + self.run() def _ingest_input(self, incoming: dc.BaseSpool) -> None: """Merge incoming spool data into the current spool and emit it.""" diff --git a/tests/test_utils/test_spool_utils.py b/tests/test_utils/test_spool_utils.py index da77b07..fbef98f 100644 --- a/tests/test_utils/test_spool_utils.py +++ b/tests/test_utils/test_spool_utils.py @@ -11,7 +11,11 @@ PointGeometry, SpanGeometry, ) -from derzug.utils.spool import annotation_overlap_mask, filter_contents_by_annotations +from derzug.utils.spool import ( + annotation_overlap_mask, + extract_single_patch, + filter_contents_by_annotations, +) def _contents_df() -> pd.DataFrame: @@ -28,6 +32,25 @@ def _contents_df() -> pd.DataFrame: ) +def test_extract_single_patch_uses_metadata_for_multi_row_spool(): + """Multi-row spools should not be iterated just to reject patch unpacking.""" + + class _LazyMultiRowSpool: + iterated = False + + def get_contents(self): + return pd.DataFrame({"tag": ["first", "second"]}) + + def __iter__(self): + self.iterated = True + raise AssertionError("spool payload was materialized") + + spool = _LazyMultiRowSpool() + + assert extract_single_patch(spool) is None + assert spool.iterated is False + + def test_point_annotations_filter_contents_on_shared_dims(): """Points should keep rows whose extents contain the point.""" df = _contents_df() diff --git a/tests/test_widgets/test_select.py b/tests/test_widgets/test_select.py index 4cb89d1..82fc759 100644 --- a/tests/test_widgets/test_select.py +++ b/tests/test_widgets/test_select.py @@ -4,6 +4,7 @@ import dascore as dc import numpy as np +import pandas as pd import pytest from derzug.models.annotations import Annotation, AnnotationSet, PointGeometry from derzug.models.selection import SelectParams @@ -732,6 +733,36 @@ def test_multi_patch_spool_emits_no_patch(self, select_widget, monkeypatch): assert patch_received[-1] is None + def test_multi_row_lazy_spool_unpack_does_not_iterate( + self, select_widget, monkeypatch + ): + """ + Unpack should reject multi-row spools from metadata without loading + patches. + """ + + class _LazyMultiRowSpool: + iterated = False + + def get_contents(self): + return pd.DataFrame({"tag": ["first", "second"]}) + + def __iter__(self): + self.iterated = True + raise AssertionError("spool payload was materialized") + + spool = _LazyMultiRowSpool() + spool_received = capture_output(select_widget.Outputs.spool, monkeypatch) + patch_received = capture_output(select_widget.Outputs.patch, monkeypatch) + select_widget.unpack_single_patch = True + + select_widget.set_spool(spool) + wait_for_widget_idle(select_widget, timeout=5.0) + + assert spool_received[-1] is spool + assert patch_received[-1] is None + assert spool.iterated is False + def test_clearing_spool_input_clears_patch_output(self, select_widget, monkeypatch): """Clearing spool input should clear both spool and patch outputs.""" capture_output(select_widget.Outputs.spool, monkeypatch) diff --git a/tests/test_widgets/test_spool.py b/tests/test_widgets/test_spool.py index d852160..40b5931 100644 --- a/tests/test_widgets/test_spool.py +++ b/tests/test_widgets/test_spool.py @@ -26,7 +26,7 @@ wait_for_widget_idle, widget_context, ) -from derzug.widgets.spool import Spool, _spool_rows_to_patches +from derzug.widgets.spool import Spool, SpoolTransformTask, _spool_rows_to_patches def _default_example_name(spool_widget: Spool) -> str | None: @@ -1984,21 +1984,37 @@ def test_set_patch_appends_in_memory_spool(self, spool_widget, monkeypatch, qtbo assert len(list(updated)) == 2 assert len(list(spool_widget._current_spool)) == 2 - def test_set_spool_appends_in_memory_spool(self, spool_widget, monkeypatch, qtbot): - """Spool input appends all incoming patches to an in-memory spool.""" + def test_set_spool_uses_input_as_transform_source( + self, spool_widget, monkeypatch, qtbot + ): + """Spool input fronts the incoming spool for select/chunk transforms.""" first = _patch_with_tag("first") second = _patch_with_tag("second") - third = _patch_with_tag("third") - spool_widget._current_spool = dc.spool([first]) + incoming = dc.spool([first, second]) received = capture_output(spool_widget.Outputs.spool, monkeypatch) + monkeypatch.setattr( + spool_widget, + "_write_spool_to_directory", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("spool input should not be persisted") + ), + ) - spool_widget.set_spool(dc.spool([second, third])) + spool_widget.set_spool(incoming) + wait_for_widget_idle(spool_widget, timeout=5.0) + first_combo, first_edit, _remove = _select_row(spool_widget, 0) + first_combo.setCurrentText("tag") + first_edit.setText("'second'") + first_edit.editingFinished.emit() wait_for_widget_idle(spool_widget, timeout=5.0) + task = spool_widget.get_task() + assert isinstance(task, SpoolTransformTask) + assert spool_widget._source_spool is incoming + assert spool_widget._source_mode == "snapshot" assert received - updated = received[-1] - assert len(list(updated)) == 3 - assert len(list(spool_widget._current_spool)) == 3 + assert _spool_tags(received[-1]) == ["second"] + assert _spool_tags(spool_widget._display_spool) == ["second"] def test_none_input_is_noop(self, spool_widget, monkeypatch): """None on the new inputs leaves the current spool unchanged.""" @@ -2013,8 +2029,10 @@ def test_none_input_is_noop(self, spool_widget, monkeypatch): assert received == [] assert spool_widget._current_spool is current - def test_input_uses_put_when_available(self, spool_widget, monkeypatch, qtbot): - """Input ingestion prefers a spool's put method when it exists.""" + def test_patch_input_uses_put_when_available( + self, spool_widget, monkeypatch, qtbot + ): + """Patch input ingestion prefers a spool's put method when it exists.""" called: list[object] = [] updated = dc.spool([_patch_with_tag("put-result")]) @@ -2026,11 +2044,11 @@ def put(self, value): spool_widget._current_spool = _PutSpool() received = capture_output(spool_widget.Outputs.spool, monkeypatch) - incoming = dc.spool([_patch_with_tag("incoming")]) - spool_widget.set_spool(incoming) + spool_widget.set_patch(_patch_with_tag("incoming")) wait_for_widget_idle(spool_widget, timeout=5.0) - assert called == [incoming] + assert len(called) == 1 + assert _spool_tags(called[0]) == ["incoming"] assert received[-1] is updated assert spool_widget._current_spool is updated