From 6f501a1942487734f2b4c05affa8833fb056031c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:44:36 +0000 Subject: [PATCH 1/3] Initial plan From 55667da6632d4dcea6125069b9d3d0604065348f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:51:48 +0000 Subject: [PATCH 2/3] Add Cell Annotation scaffolding Co-authored-by: yulewu <38241047+yulewu@users.noreply.github.com> --- .github/workflows/cell-annotation.yml | 36 +++++ .gitignore | 5 +- tests/test_cell_annotation_integration.py | 32 ++++ tests/test_cell_annotation_plugin.py | 145 ++++++++++++++++++ ueler/_compat.py | 5 +- ueler/viewer/interfaces.py | 53 +++++++ ueler/viewer/main_viewer.py | 24 ++- .../viewer/plugin/cell_annotation/__init__.py | 17 ++ .../viewer/plugin/cell_annotation/manifest.py | 42 +++++ ueler/viewer/plugin/cell_annotation/plugin.py | 63 ++++++++ .../plugin/cell_annotation/selection_spec.py | 44 ++++++ ueler/viewer/plugin/cell_annotation/store.py | 101 ++++++++++++ ueler/viewer/plugin/heatmap.py | 34 +++- ueler/viewer/plugin/run_flowsom.py | 50 +++++- viewer/interfaces.py | 9 ++ viewer/plugin/cell_annotation/__init__.py | 29 ++++ 16 files changed, 684 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/cell-annotation.yml create mode 100644 tests/test_cell_annotation_integration.py create mode 100644 tests/test_cell_annotation_plugin.py create mode 100644 ueler/viewer/interfaces.py create mode 100644 ueler/viewer/plugin/cell_annotation/__init__.py create mode 100644 ueler/viewer/plugin/cell_annotation/manifest.py create mode 100644 ueler/viewer/plugin/cell_annotation/plugin.py create mode 100644 ueler/viewer/plugin/cell_annotation/selection_spec.py create mode 100644 ueler/viewer/plugin/cell_annotation/store.py create mode 100644 viewer/interfaces.py create mode 100644 viewer/plugin/cell_annotation/__init__.py diff --git a/.github/workflows/cell-annotation.yml b/.github/workflows/cell-annotation.yml new file mode 100644 index 0000000..7feb476 --- /dev/null +++ b/.github/workflows/cell-annotation.yml @@ -0,0 +1,36 @@ +name: Cell Annotation + +on: + pull_request: + push: + branches: + - main + - cell_annotation + - copilot/** + +jobs: + unit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install package + run: python -m pip install --upgrade pip && pip install -e ".[dev]" + - name: Run Cell Annotation unit tests + run: python -m unittest tests.test_cell_annotation_plugin + + integration: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install package + run: python -m pip install --upgrade pip && pip install -e ".[dev]" + - name: Run Cell Annotation integration tests + env: + ENABLE_CELL_ANNOTATION: "1" + run: python -m unittest tests.test_cell_annotation_integration diff --git a/.gitignore b/.gitignore index 9e89e6c..35a1c60 100644 --- a/.gitignore +++ b/.gitignore @@ -28,9 +28,12 @@ Thumbs.db dev_note/github_issues.md .binder/ .github/ +!.github/ +!.github/workflows/ +!.github/workflows/*.yml .specify/ specs/ script/*.ipynb -!script/run_ueler.ipynb \ No newline at end of file +!script/run_ueler.ipynb diff --git a/tests/test_cell_annotation_integration.py b/tests/test_cell_annotation_integration.py new file mode 100644 index 0000000..2f80df0 --- /dev/null +++ b/tests/test_cell_annotation_integration.py @@ -0,0 +1,32 @@ +"""Focused integration tests for Cell Annotation wiring.""" + +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from ueler.viewer.plugin.cell_annotation import CellAnnotationPlugin + + +class TestCellAnnotationCompatibility(unittest.TestCase): + def test_legacy_viewer_namespace_resolves_to_packaged_plugin(self): + from viewer.plugin.cell_annotation import CellAnnotationPlugin as LegacyPlugin + from viewer.plugin.cell_annotation import DatasetStore as LegacyStore + + self.assertIs(LegacyPlugin, CellAnnotationPlugin) + self.assertEqual(LegacyStore.__name__, "DatasetStore") + + def test_plugin_opened_dataset_creates_manifest_location(self): + plugin = CellAnnotationPlugin(object()) + with tempfile.TemporaryDirectory() as dataset_root: + plugin.on_dataset_opened(dataset_root) + + self.assertTrue((plugin.store.store_path / "checkpoints").is_dir()) + self.assertTrue((plugin.store.store_path / "thumbnails").is_dir()) + self.assertTrue((plugin.store.store_path / "selections").is_dir()) + self.assertEqual(plugin.manifest.path, Path(plugin.store.store_path) / "manifest.json") + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/tests/test_cell_annotation_plugin.py b/tests/test_cell_annotation_plugin.py new file mode 100644 index 0000000..1f031fa --- /dev/null +++ b/tests/test_cell_annotation_plugin.py @@ -0,0 +1,145 @@ +"""Unit tests for Cell Annotation scaffolding.""" + +from __future__ import annotations + +import json +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +from ueler.viewer.plugin.cell_annotation.manifest import Manifest +from ueler.viewer.plugin.cell_annotation.plugin import CellAnnotationPlugin, _flag_enabled +from ueler.viewer.plugin.cell_annotation.selection_spec import MaterializedSelectionSpec +from ueler.viewer.plugin.cell_annotation.store import ( + STORE_SUBDIRS, + DatasetStore, + _dataset_id, + atomic_replace, + atomic_write_json, +) + + +class TestDatasetStore(unittest.TestCase): + def test_dataset_id_is_stable_and_short(self): + with tempfile.TemporaryDirectory() as dataset_root: + value = _dataset_id(dataset_root) + self.assertEqual(value, _dataset_id(dataset_root)) + self.assertEqual(len(value), 16) + self.assertTrue(all(char in "0123456789abcdef" for char in value)) + + def test_ensure_dirs_creates_expected_tree(self): + with tempfile.TemporaryDirectory() as dataset_root: + store = DatasetStore(dataset_root) + store.ensure_dirs() + self.assertTrue(store.store_path.is_dir()) + for subdir in STORE_SUBDIRS: + self.assertTrue((store.store_path / subdir).is_dir()) + + def test_subdir_returns_child_path(self): + with tempfile.TemporaryDirectory() as dataset_root: + store = DatasetStore(dataset_root) + self.assertEqual(store.subdir("checkpoints"), store.store_path / "checkpoints") + + +class TestAtomicHelpers(unittest.TestCase): + def test_atomic_write_json_overwrites_existing_file(self): + with tempfile.TemporaryDirectory() as root: + target = Path(root) / "manifest.json" + atomic_write_json(target, {"version": 1}) + atomic_write_json(target, {"version": 2}) + self.assertEqual(json.loads(target.read_text()), {"version": 2}) + + def test_atomic_write_json_removes_partial_file_on_failure(self): + class Unserializable: + pass + + with tempfile.TemporaryDirectory() as root: + target = Path(root) / "manifest.json" + atomic_write_json(target, {"version": 1}) + with self.assertRaises(TypeError): + atomic_write_json(target, {"bad": Unserializable()}) + self.assertEqual(json.loads(target.read_text()), {"version": 1}) + self.assertEqual(list(Path(root).glob(".*.tmp*")), []) + + def test_atomic_replace_moves_file_into_place(self): + with tempfile.TemporaryDirectory() as root: + src = Path(root) / "src.json" + dst = Path(root) / "deep" / "dst.json" + src.write_text('{"ok": true}') + atomic_replace(src, dst) + self.assertFalse(src.exists()) + self.assertEqual(json.loads(dst.read_text()), {"ok": True}) + + +class TestManifest(unittest.TestCase): + def test_manifest_load_and_save_round_trip(self): + with tempfile.TemporaryDirectory() as root: + manifest = Manifest(root) + manifest.data["checkpoints"] = [] + manifest.save_atomic() + self.assertEqual(Manifest(root).load(), {"checkpoints": []}) + + def test_manifest_rebuild_stub_resets_to_empty_dict(self): + with tempfile.TemporaryDirectory() as root: + manifest = Manifest(root) + manifest.data["checkpoints"] = ["stale"] + self.assertEqual(manifest.rebuild_from_disk(), {}) + self.assertEqual(manifest.data, {}) + + +class TestSelectionSpec(unittest.TestCase): + def test_subset_and_union(self): + parent = MaterializedSelectionSpec.from_cells("dataset_a", [("fov1", 1), ("fov1", 2)]) + child = MaterializedSelectionSpec.from_cells("dataset_a", [("fov1", 2)]) + sibling = MaterializedSelectionSpec.from_cells("dataset_a", [("fov2", 9)]) + + self.assertTrue(child.subset_of(parent)) + self.assertEqual(child.union(sibling).cardinality(), 2) + + def test_union_requires_matching_dataset(self): + left = MaterializedSelectionSpec.from_cells("dataset_a", [("fov1", 1)]) + right = MaterializedSelectionSpec.from_cells("dataset_b", [("fov1", 1)]) + + with self.assertRaises(ValueError): + left.union(right) + + +class TestFeatureFlagAndPluginLifecycle(unittest.TestCase): + def test_flag_defaults_to_disabled(self): + with patch.dict(os.environ, {}, clear=True): + self.assertFalse(_flag_enabled()) + + def test_truthy_feature_flag_values_enable_plugin(self): + for value in ("1", "true", "TRUE", "yes"): + with self.subTest(value=value), patch.dict(os.environ, {"ENABLE_CELL_ANNOTATION": value}, clear=True): + self.assertTrue(_flag_enabled()) + + def test_plugin_lifecycle_initializes_store_manifest_and_providers(self): + viewer = MagicMock() + plugin = CellAnnotationPlugin(viewer) + self.assertIsNone(plugin.store) + self.assertIsNone(plugin.manifest) + + heatmap = MagicMock() + flowsom = MagicMock() + plugin.register_heatmap(heatmap) + plugin.register_flowsom(flowsom) + self.assertIs(plugin.heatmap_provider, heatmap) + self.assertIs(plugin.flowsom_provider, flowsom) + + with tempfile.TemporaryDirectory() as dataset_root: + plugin.on_dataset_opened(dataset_root) + self.assertIsNotNone(plugin.store) + self.assertIsNotNone(plugin.manifest) + for subdir in STORE_SUBDIRS: + self.assertTrue((plugin.store.store_path / subdir).is_dir()) + plugin.on_dataset_closed() + + self.assertIsNone(plugin.store) + self.assertIsNone(plugin.manifest) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/ueler/_compat.py b/ueler/_compat.py index 5a75805..ded882c 100644 --- a/ueler/_compat.py +++ b/ueler/_compat.py @@ -23,12 +23,14 @@ "ueler.viewer.annotation_display": "viewer.annotation_display", "ueler.viewer.roi_manager": "viewer.roi_manager", "ueler.viewer.color_palettes": "viewer.color_palettes", + "ueler.viewer.interfaces": "viewer.interfaces", } VIEWER_PLUGIN_ALIASES: Dict[str, str] = { "ueler.viewer.plugin.plugin_base": "viewer.plugin.plugin_base", "ueler.viewer.plugin.cell_gallery": "viewer.plugin.cell_gallery", "ueler.viewer.plugin.region_annotation": "viewer.plugin.region_annotation", + "ueler.viewer.plugin.cell_annotation": "viewer.plugin.cell_annotation", } LEGACY_VIEWER_ALIASES: Dict[str, str] = { @@ -38,6 +40,7 @@ "viewer.observable": "ueler.viewer.observable", "viewer.annotation_palette_editor": "ueler.viewer.annotation_palette_editor", "viewer.annotation_display": "ueler.viewer.annotation_display", + "viewer.interfaces": "ueler.viewer.interfaces", "viewer.roi_manager": "ueler.viewer.roi_manager", "viewer.main_viewer": "ueler.viewer.main_viewer", } @@ -179,4 +182,4 @@ def ensure_aliases_loaded() -> None: """Ensure all compatibility aliases are registered.""" for group in COMPAT_ALIAS_GROUPS: - register_module_aliases(group) \ No newline at end of file + register_module_aliases(group) diff --git a/ueler/viewer/interfaces.py b/ueler/viewer/interfaces.py new file mode 100644 index 0000000..649ac23 --- /dev/null +++ b/ueler/viewer/interfaces.py @@ -0,0 +1,53 @@ +"""Cross-plugin protocols for the Cell Annotation workflow.""" + +from __future__ import annotations + +from typing import Any, Mapping, Protocol, runtime_checkable + + +@runtime_checkable +class SelectionSpec(Protocol): + """Opaque handle describing a saved cell selection.""" + + dataset_id: str + + def cardinality(self) -> int: + """Return the number of selected cells represented by this spec.""" + + def subset_of(self, other: "SelectionSpec") -> bool: + """Return ``True`` when the current selection is contained in *other*.""" + + def union(self, other: "SelectionSpec") -> "SelectionSpec": + """Return a selection representing the union with *other*.""" + + +@runtime_checkable +class HeatmapStateProvider(Protocol): + """Protocol implemented by the Heatmap plugin for checkpoint export/import.""" + + def export_heatmap_state( + self, + *, + include_embeddings: bool, + include_raw_medians: bool, + extra_obs_cols: list[str] | None, + ) -> dict[str, Any]: + """Export the current Heatmap view state.""" + + def import_heatmap_state(self, adata_path: str) -> None: + """Restore Heatmap state from a checkpoint artifact.""" + + +@runtime_checkable +class FlowsomParamsProvider(Protocol): + """Protocol implemented by the FlowSOM plugin for orchestration hooks.""" + + def export_flowsom_params(self) -> dict[str, Any]: + """Return the current FlowSOM configuration.""" + + def import_flowsom_params(self, params: Mapping[str, Any]) -> None: + """Restore FlowSOM configuration from a checkpoint snapshot.""" + + def set_selection_context(self, selection: SelectionSpec) -> None: + """Constrain the FlowSOM plugin to a Cell Annotation selection.""" + diff --git a/ueler/viewer/main_viewer.py b/ueler/viewer/main_viewer.py index 54cf3d5..9f916ff 100644 --- a/ueler/viewer/main_viewer.py +++ b/ueler/viewer/main_viewer.py @@ -1277,6 +1277,28 @@ def dynamically_load_plugins(self, allow_plugins: Optional[Iterable[str]] = None self.SidePlots.__dict__[f"{module_name}_output"] = instance if module_name == 'roi_manager_plugin': self.roi_plugin = instance + self._register_cell_annotation_plugin() + + def _register_cell_annotation_plugin(self) -> None: + """Register the Cell Annotation plugin when the feature flag is enabled.""" + try: + from ueler.viewer.plugin.cell_annotation.plugin import ( + CellAnnotationPlugin, + _flag_enabled, + ) + except Exception as exc: # pragma: no cover - optional plugin guard + if self._debug: + print(f"[CellAnnotation] failed to import plugin: {exc}") + return + + if not _flag_enabled(): + return + + plugin = CellAnnotationPlugin(self) + setattr(self, CellAnnotationPlugin.REGISTRY_KEY, plugin) + plugin.on_dataset_opened(self.base_folder) + if self._debug: + print(f"[CellAnnotation] plugin registered: {plugin.store and plugin.store.store_path}") def setup_event_connections(self): @@ -4233,4 +4255,4 @@ def worker(): for item_id, result in status.results.items(): legacy_results[item_id] = True if result.ok else (result.error or "Export failed") - return legacy_results \ No newline at end of file + return legacy_results diff --git a/ueler/viewer/plugin/cell_annotation/__init__.py b/ueler/viewer/plugin/cell_annotation/__init__.py new file mode 100644 index 0000000..8009597 --- /dev/null +++ b/ueler/viewer/plugin/cell_annotation/__init__.py @@ -0,0 +1,17 @@ +"""Cell Annotation plugin package.""" + +from __future__ import annotations + +from .manifest import Manifest +from .plugin import CellAnnotationPlugin +from .selection_spec import MaterializedSelectionSpec +from .store import DatasetStore, atomic_replace, atomic_write_json + +__all__ = [ + "CellAnnotationPlugin", + "DatasetStore", + "Manifest", + "MaterializedSelectionSpec", + "atomic_replace", + "atomic_write_json", +] diff --git a/ueler/viewer/plugin/cell_annotation/manifest.py b/ueler/viewer/plugin/cell_annotation/manifest.py new file mode 100644 index 0000000..862e2e4 --- /dev/null +++ b/ueler/viewer/plugin/cell_annotation/manifest.py @@ -0,0 +1,42 @@ +"""Manifest helpers for Cell Annotation checkpoint storage.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from .store import atomic_write_json + + +class Manifest: + """Thin wrapper around ``manifest.json`` within a dataset store.""" + + def __init__(self, store_path: str | Path) -> None: + self._store_path = Path(store_path) + self._path = self._store_path / "manifest.json" + self._data: dict[str, Any] = {} + + @property + def path(self) -> Path: + return self._path + + @property + def data(self) -> dict[str, Any]: + return self._data + + def load(self) -> dict[str, Any] | None: + if not self._path.exists(): + return None + with open(self._path, "r", encoding="utf-8") as handle: + self._data = json.load(handle) + return self._data + + def save_atomic(self) -> None: + atomic_write_json(self._path, self._data) + + def rebuild_from_disk(self) -> dict[str, Any]: + """Stub manifest rebuild used until checkpoint scanning lands.""" + + self._data = {} + return self._data diff --git a/ueler/viewer/plugin/cell_annotation/plugin.py b/ueler/viewer/plugin/cell_annotation/plugin.py new file mode 100644 index 0000000..983b90f --- /dev/null +++ b/ueler/viewer/plugin/cell_annotation/plugin.py @@ -0,0 +1,63 @@ +"""Cell Annotation plugin scaffolding and lifecycle hooks.""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Optional + +from ueler.viewer.interfaces import FlowsomParamsProvider, HeatmapStateProvider + +from .manifest import Manifest +from .store import DatasetStore + + +def _flag_enabled() -> bool: + """Return whether the Cell Annotation feature flag is enabled.""" + + return os.environ.get("ENABLE_CELL_ANNOTATION", "").strip().lower() in {"1", "true", "yes"} + + +class CellAnnotationPlugin: + """Own the non-UI Cell Annotation store and provider registrations.""" + + REGISTRY_KEY = "cell_annotation_plugin" + + def __init__(self, viewer: object) -> None: + self._viewer = viewer + self._store: Optional[DatasetStore] = None + self._manifest: Optional[Manifest] = None + self._heatmap_provider: Optional[HeatmapStateProvider] = None + self._flowsom_provider: Optional[FlowsomParamsProvider] = None + + @property + def store(self) -> Optional[DatasetStore]: + return self._store + + @property + def manifest(self) -> Optional[Manifest]: + return self._manifest + + @property + def heatmap_provider(self) -> Optional[HeatmapStateProvider]: + return self._heatmap_provider + + @property + def flowsom_provider(self) -> Optional[FlowsomParamsProvider]: + return self._flowsom_provider + + def on_dataset_opened(self, base_folder: str | Path) -> None: + self._store = DatasetStore(base_folder) + self._store.ensure_dirs() + self._manifest = Manifest(self._store.store_path) + self._manifest.load() + + def on_dataset_closed(self) -> None: + self._store = None + self._manifest = None + + def register_heatmap(self, provider: HeatmapStateProvider) -> None: + self._heatmap_provider = provider + + def register_flowsom(self, provider: FlowsomParamsProvider) -> None: + self._flowsom_provider = provider diff --git a/ueler/viewer/plugin/cell_annotation/selection_spec.py b/ueler/viewer/plugin/cell_annotation/selection_spec.py new file mode 100644 index 0000000..3df790c --- /dev/null +++ b/ueler/viewer/plugin/cell_annotation/selection_spec.py @@ -0,0 +1,44 @@ +"""Selection specification helpers for Cell Annotation.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import FrozenSet, Iterable, Tuple + +CellRef = Tuple[str, int] + + +@dataclass(frozen=True) +class MaterializedSelectionSpec: + """Concrete selection handle backed by explicit ``(fov_id, cell_id)`` pairs.""" + + dataset_id: str + cells: FrozenSet[CellRef] + + @classmethod + def from_cells(cls, dataset_id: str, cells: Iterable[CellRef]) -> "MaterializedSelectionSpec": + return cls(dataset_id=dataset_id, cells=frozenset(cells)) + + def cardinality(self) -> int: + return len(self.cells) + + def subset_of(self, other: "MaterializedSelectionSpec") -> bool: + self._validate_same_dataset(other) + return self.cells.issubset(other.cells) + + def union(self, other: "MaterializedSelectionSpec") -> "MaterializedSelectionSpec": + self._validate_same_dataset(other) + return MaterializedSelectionSpec(self.dataset_id, self.cells | other.cells) + + def to_payload(self) -> dict[str, object]: + ordered = sorted(self.cells) + return { + "type": "materialized", + "dataset_id": self.dataset_id, + "n_cells": len(ordered), + "cells": [[fov_id, cell_id] for fov_id, cell_id in ordered], + } + + def _validate_same_dataset(self, other: "MaterializedSelectionSpec") -> None: + if self.dataset_id != other.dataset_id: + raise ValueError("Selection specifications must belong to the same dataset") diff --git a/ueler/viewer/plugin/cell_annotation/store.py b/ueler/viewer/plugin/cell_annotation/store.py new file mode 100644 index 0000000..b9e7d4f --- /dev/null +++ b/ueler/viewer/plugin/cell_annotation/store.py @@ -0,0 +1,101 @@ +"""Storage helpers for the Cell Annotation plugin.""" + +from __future__ import annotations + +import hashlib +import json +import os +import tempfile +from pathlib import Path + +STORE_SUBDIRS = ("checkpoints", "thumbnails", "selections") + + +def _dataset_id(dataset_root: str | Path) -> str: + """Return a short, stable identifier for *dataset_root*.""" + + resolved = str(Path(dataset_root).resolve()) + return hashlib.sha256(resolved.encode("utf-8")).hexdigest()[:16] + + +class DatasetStore: + """Resolve and create the per-dataset Cell Annotation store tree.""" + + def __init__(self, dataset_root: str | Path) -> None: + self._root = Path(dataset_root).resolve() + self._dataset_id = _dataset_id(self._root) + self._store_path = self._root / ".UELer" / f"dataset_{self._dataset_id}" + + @property + def dataset_id(self) -> str: + return self._dataset_id + + @property + def store_path(self) -> Path: + return self._store_path + + def ensure_dirs(self) -> None: + self._store_path.mkdir(parents=True, exist_ok=True) + for subdir in STORE_SUBDIRS: + (self._store_path / subdir).mkdir(exist_ok=True) + + def subdir(self, name: str) -> Path: + return self._store_path / name + + +def _fsync_dir(directory: Path) -> None: + try: + fd = os.open(str(directory), os.O_RDONLY) + except OSError: + return + try: + os.fsync(fd) + except OSError: + pass + finally: + os.close(fd) + + +def atomic_replace(src_tmp: str | Path, dst_final: str | Path) -> None: + """Atomically replace *dst_final* with the already-written temp file.""" + + src = Path(src_tmp) + dst = Path(dst_final) + dst.parent.mkdir(parents=True, exist_ok=True) + + with open(src, "rb") as handle: + try: + os.fsync(handle.fileno()) + except OSError: + pass + + _fsync_dir(src.parent) + os.replace(src, dst) + _fsync_dir(dst.parent) + + +def atomic_write_json(path: str | Path, obj: object) -> None: + """Write JSON atomically so readers never observe a partial manifest.""" + + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_path = tempfile.mkstemp( + dir=str(target.parent), + prefix=f".{target.name}.tmp", + suffix=".json", + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(obj, handle, indent=2) + handle.flush() + try: + os.fsync(handle.fileno()) + except OSError: + pass + atomic_replace(tmp_path, target) + except Exception: + try: + os.unlink(tmp_path) + except OSError: + pass + raise diff --git a/ueler/viewer/plugin/heatmap.py b/ueler/viewer/plugin/heatmap.py index 64eedee..d62f868 100644 --- a/ueler/viewer/plugin/heatmap.py +++ b/ueler/viewer/plugin/heatmap.py @@ -5,6 +5,7 @@ from scipy.cluster.hierarchy import dendrogram import pandas as pd from ueler.viewer.observable import Observable +from ueler.viewer.interfaces import HeatmapStateProvider from ueler.viewer.plugin.plugin_base import PluginBase from ueler.viewer.plugin.heatmap_adapter import HeatmapModeAdapter from ueler.viewer.plugin.heatmap_layers import DataLayer, InteractionLayer, DisplayLayer @@ -52,6 +53,7 @@ def __init__(self, main_viewer, width, height): self.initialized = True # Ensure layout reflects the starting orientation before observers fire. self._sync_panel_location() + self._register_cell_annotation_provider() def _on_lock_cutoff_change(self, change): if self._suppress_lock_observer: @@ -89,6 +91,36 @@ def _request_lock_override(self, *_): print("Unlock request accepted. You may adjust the dendrogram until it relocks.") self.ui_component.lock_cutoff_button.value = False + def _register_cell_annotation_provider(self) -> None: + plugin = getattr(self.main_viewer, "cell_annotation_plugin", None) + register = getattr(plugin, "register_heatmap", None) + if callable(register): + register(self) + + def export_heatmap_state( + self, + *, + include_embeddings: bool, + include_raw_medians: bool, + extra_obs_cols: list[str] | None, + ) -> dict: + channels = self.ui_component.channel_selector.value + if isinstance(channels, str): + selected_channels = [channels] + else: + selected_channels = list(channels or []) + + return { + "selected_channels": selected_channels, + "orientation": dict(self.orientation_state), + "include_embeddings": include_embeddings, + "include_raw_medians": include_raw_medians, + "extra_obs_cols": list(extra_obs_cols or []), + } + + def import_heatmap_state(self, adata_path: str) -> None: + self._last_imported_heatmap_state_path = adata_path + class UiComponent: def __init__(self, parent): self.channel_selector_text = HTML( @@ -269,4 +301,4 @@ def __init__(self): class linked_controls: def __init__(self): # Legacy widget linkage placeholder kept for interface parity. - pass \ No newline at end of file + pass diff --git a/ueler/viewer/plugin/run_flowsom.py b/ueler/viewer/plugin/run_flowsom.py index e0ad081..c6fc373 100644 --- a/ueler/viewer/plugin/run_flowsom.py +++ b/ueler/viewer/plugin/run_flowsom.py @@ -52,6 +52,7 @@ def _missing_pyflowsom(*_args, **_kwargs): ) from ueler.viewer.decorators import update_status_bar from ueler.viewer.observable import Observable +from ueler.viewer.interfaces import SelectionSpec from ueler.viewer.plugin.plugin_base import PluginBase class RunFlowsom(PluginBase): @@ -76,6 +77,8 @@ def __init__(self, main_viewer, width, height): # Always run this at the end of __init__ # self.load_widget_states(os.path.join(self.main_viewer.base_folder, ".UELer", f'{self.displayed_name}_widget_states.json')) self.initialized = True + self._selection_context = None + self._register_cell_annotation_provider() # def after_all_plugins_loaded(self): # # Add observer to monitor changes to selected_indices @@ -130,6 +133,51 @@ def run_flowsom(self, b): self.main_viewer.inform_plugins("on_cell_table_change") print(f"FlowSOM clustering completed. The labels are saved in the column {column_name_text}") + + def _register_cell_annotation_provider(self) -> None: + plugin = getattr(self.main_viewer, "cell_annotation_plugin", None) + register = getattr(plugin, "register_flowsom", None) + if callable(register): + register(self) + + def export_flowsom_params(self) -> dict: + channels = self.ui_component.channel_selector.value + if isinstance(channels, str): + selected_channels = [channels] + else: + selected_channels = list(channels or []) + + return { + "channels": selected_channels, + "subset_on": self.ui_component.subset_on_dropdown.value, + "subset": list(self.ui_component.subset_selector.value or ()), + "column_name": self.ui_component.column_name_text.value, + "xdim": self.ui_component.xdim_input.value, + "ydim": self.ui_component.ydim_input.value, + "rlen": self.ui_component.rlen_input.value, + "seed": self.ui_component.seed_input.value, + } + + def import_flowsom_params(self, params): + mapping = { + "subset_on": self.ui_component.subset_on_dropdown, + "column_name": self.ui_component.column_name_text, + "xdim": self.ui_component.xdim_input, + "ydim": self.ui_component.ydim_input, + "rlen": self.ui_component.rlen_input, + "seed": self.ui_component.seed_input, + } + for key, widget in mapping.items(): + if key in params: + widget.value = params[key] + + if "channels" in params: + self.ui_component.channel_selector.value = tuple(params["channels"]) + if "subset" in params: + self.ui_component.subset_selector.value = tuple(params["subset"]) + + def set_selection_context(self, selection: SelectionSpec) -> None: + self._selection_context = selection def on_subset_on_dropdown_change(self, change): selected_clusters = change['new'] # Get the selected clusters @@ -273,4 +321,4 @@ def __init__(self): class linked_controls: def __init__(self): # Placeholder to maintain plugin interface parity with legacy widgets. - pass \ No newline at end of file + pass diff --git a/viewer/interfaces.py b/viewer/interfaces.py new file mode 100644 index 0000000..db0eaea --- /dev/null +++ b/viewer/interfaces.py @@ -0,0 +1,9 @@ +"""Compatibility wrapper for ``ueler.viewer.interfaces``.""" + +from __future__ import annotations + +import importlib +import sys + +_target = importlib.import_module("ueler.viewer.interfaces") +sys.modules[__name__] = _target diff --git a/viewer/plugin/cell_annotation/__init__.py b/viewer/plugin/cell_annotation/__init__.py new file mode 100644 index 0000000..a76e286 --- /dev/null +++ b/viewer/plugin/cell_annotation/__init__.py @@ -0,0 +1,29 @@ +"""Compatibility wrapper for the relocated ``cell_annotation`` package.""" + +from __future__ import annotations + +import importlib +import sys + +_target = importlib.import_module("ueler.viewer.plugin.cell_annotation") +sys.modules[__name__] = _target + +CellAnnotationPlugin = _target.CellAnnotationPlugin +DatasetStore = _target.DatasetStore +Manifest = _target.Manifest +MaterializedSelectionSpec = _target.MaterializedSelectionSpec +atomic_replace = _target.atomic_replace +atomic_write_json = _target.atomic_write_json + +__all__ = getattr( + _target, + "__all__", + [ + "CellAnnotationPlugin", + "DatasetStore", + "Manifest", + "MaterializedSelectionSpec", + "atomic_replace", + "atomic_write_json", + ], +) From 60215b2ac7d2965485c1548931b9f75f0cfd829f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:58:23 +0000 Subject: [PATCH 3/3] Finalize Cell Annotation scaffolding and CI Co-authored-by: yulewu <38241047+yulewu@users.noreply.github.com> --- .github/workflows/cell-annotation.yml | 4 + tests/test_cell_annotation_plugin.py | 139 +++++++++++++++++- .../viewer/plugin/cell_annotation/manifest.py | 7 +- .../plugin/cell_annotation/selection_spec.py | 1 + ueler/viewer/plugin/cell_annotation/store.py | 4 +- ueler/viewer/plugin/heatmap.py | 3 + ueler/viewer/plugin/run_flowsom.py | 2 + 7 files changed, 156 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cell-annotation.yml b/.github/workflows/cell-annotation.yml index 7feb476..e36a93b 100644 --- a/.github/workflows/cell-annotation.yml +++ b/.github/workflows/cell-annotation.yml @@ -11,6 +11,8 @@ on: jobs: unit: runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 @@ -23,6 +25,8 @@ jobs: integration: runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 diff --git a/tests/test_cell_annotation_plugin.py b/tests/test_cell_annotation_plugin.py index 1f031fa..63c37fa 100644 --- a/tests/test_cell_annotation_plugin.py +++ b/tests/test_cell_annotation_plugin.py @@ -3,8 +3,11 @@ from __future__ import annotations import json +import importlib.util import os +import sys import tempfile +import types import unittest from pathlib import Path from unittest.mock import MagicMock, patch @@ -20,13 +23,73 @@ atomic_write_json, ) +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _load_module_from_file(module_name: str, file_path: Path, stubs: dict[str, types.ModuleType]): + original_modules = {name: sys.modules.get(name) for name in stubs} + try: + sys.modules.update(stubs) + spec = importlib.util.spec_from_file_location(module_name, file_path) + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + assert spec and spec.loader + spec.loader.exec_module(module) + return module + finally: + sys.modules.pop(module_name, None) + for name, original in original_modules.items(): + if original is None: + sys.modules.pop(name, None) + else: + sys.modules[name] = original + + +def _widget_module() -> types.ModuleType: + module = types.ModuleType("ipywidgets") + + class _Widget: + def __init__(self, *args, **kwargs): + self.value = kwargs.get("value") + self.options = kwargs.get("options", []) + self.allowed_tags = kwargs.get("allowed_tags", []) + self.children = tuple(kwargs.get("children", ())) + + def observe(self, *_args, **_kwargs): + return None + + def on_click(self, *_args, **_kwargs): + return None + + for name in ( + "SelectMultiple", + "FloatSlider", + "Dropdown", + "VBox", + "Output", + "TagsInput", + "Checkbox", + "IntText", + "Text", + "Button", + "HBox", + "Layout", + "IntSlider", + "Tab", + "RadioButtons", + "HTML", + ): + setattr(module, name, _Widget) + module.Widget = _Widget + return module + class TestDatasetStore(unittest.TestCase): def test_dataset_id_is_stable_and_short(self): with tempfile.TemporaryDirectory() as dataset_root: value = _dataset_id(dataset_root) self.assertEqual(value, _dataset_id(dataset_root)) - self.assertEqual(len(value), 16) + self.assertEqual(len(value), 32) self.assertTrue(all(char in "0123456789abcdef" for char in value)) def test_ensure_dirs_creates_expected_tree(self): @@ -141,5 +204,79 @@ def test_plugin_lifecycle_initializes_store_manifest_and_providers(self): self.assertIsNone(plugin.manifest) +class TestProviderStubMethods(unittest.TestCase): + def test_heatmap_import_stub_records_last_path(self): + heatmap_stubs = { + "ipywidgets": _widget_module(), + "pandas": types.ModuleType("pandas"), + "scipy.cluster.hierarchy": types.SimpleNamespace(dendrogram=lambda *_a, **_k: None), + "ueler.viewer.observable": types.SimpleNamespace(Observable=object), + "ueler.viewer.plugin.plugin_base": types.SimpleNamespace( + PluginBase=type("PluginBase", (), {"__init__": lambda self, *_args, **_kwargs: None}) + ), + "ueler.viewer.plugin.heatmap_adapter": types.SimpleNamespace( + HeatmapModeAdapter=type("HeatmapModeAdapter", (), {"__init__": lambda self, *_args, **_kwargs: None}) + ), + "ueler.viewer.plugin.heatmap_layers": types.SimpleNamespace( + DataLayer=type("DataLayer", (), {}), + InteractionLayer=type("InteractionLayer", (), {}), + DisplayLayer=type("DisplayLayer", (), {}), + ), + } + module = _load_module_from_file( + "test_heatmap_module", + REPO_ROOT / "ueler/viewer/plugin/heatmap.py", + heatmap_stubs, + ) + heatmap = module.HeatmapDisplay.__new__(module.HeatmapDisplay) + + module.HeatmapDisplay.import_heatmap_state(heatmap, "/tmp/checkpoint.h5ad") + + self.assertEqual(heatmap._last_imported_heatmap_state_path, "/tmp/checkpoint.h5ad") + + def test_flowsom_selection_context_stub_is_stored(self): + numpy_stub = types.ModuleType("numpy") + numpy_stub.inf = float("inf") + flowsom_stubs = { + "numpy": numpy_stub, + "pandas": types.ModuleType("pandas"), + "seaborn": types.ModuleType("seaborn"), + "ipywidgets": _widget_module(), + "matplotlib.font_manager": types.ModuleType("matplotlib.font_manager"), + "matplotlib.pyplot": types.ModuleType("matplotlib.pyplot"), + "matplotlib.backend_bases": types.SimpleNamespace(MouseButton=object), + "matplotlib.text": types.SimpleNamespace(Annotation=object), + "IPython.display": types.SimpleNamespace(display=lambda *_a, **_k: None), + "mpl_toolkits.axes_grid1": types.SimpleNamespace(make_axes_locatable=lambda *_a, **_k: None), + "mpl_toolkits.axes_grid1.anchored_artists": types.SimpleNamespace(AnchoredSizeBar=object), + "scipy.cluster.hierarchy": types.SimpleNamespace( + cut_tree=lambda *_a, **_k: None, + dendrogram=lambda *_a, **_k: None, + linkage=lambda *_a, **_k: None, + ), + "ueler.image_utils": types.SimpleNamespace( + color_one_image=lambda *_a, **_k: None, + estimate_color_range=lambda *_a, **_k: None, + process_single_crop=lambda *_a, **_k: None, + ), + "ueler.viewer.decorators": types.SimpleNamespace(update_status_bar=lambda func: func), + "ueler.viewer.observable": types.SimpleNamespace(Observable=object), + "ueler.viewer.plugin.plugin_base": types.SimpleNamespace( + PluginBase=type("PluginBase", (), {"__init__": lambda self, *_args, **_kwargs: None}) + ), + } + module = _load_module_from_file( + "test_flowsom_module", + REPO_ROOT / "ueler/viewer/plugin/run_flowsom.py", + flowsom_stubs, + ) + flowsom = module.RunFlowsom.__new__(module.RunFlowsom) + selection = MaterializedSelectionSpec.from_cells("dataset_a", [("fov1", 1)]) + + module.RunFlowsom.set_selection_context(flowsom, selection) + + self.assertIs(flowsom._selection_context, selection) + + if __name__ == "__main__": # pragma: no cover unittest.main() diff --git a/ueler/viewer/plugin/cell_annotation/manifest.py b/ueler/viewer/plugin/cell_annotation/manifest.py index 862e2e4..a873aad 100644 --- a/ueler/viewer/plugin/cell_annotation/manifest.py +++ b/ueler/viewer/plugin/cell_annotation/manifest.py @@ -36,7 +36,12 @@ def save_atomic(self) -> None: atomic_write_json(self._path, self._data) def rebuild_from_disk(self) -> dict[str, Any]: - """Stub manifest rebuild used until checkpoint scanning lands.""" + """Stub manifest rebuild used until checkpoint scanning lands. + + TODO: replace this with a directory walk that scans checkpoint, thumbnail, + and selection artifacts, ignores ``*.partial`` files, and rebuilds the + persisted DAG metadata in ``manifest.json``. + """ self._data = {} return self._data diff --git a/ueler/viewer/plugin/cell_annotation/selection_spec.py b/ueler/viewer/plugin/cell_annotation/selection_spec.py index 3df790c..0327d8b 100644 --- a/ueler/viewer/plugin/cell_annotation/selection_spec.py +++ b/ueler/viewer/plugin/cell_annotation/selection_spec.py @@ -31,6 +31,7 @@ def union(self, other: "MaterializedSelectionSpec") -> "MaterializedSelectionSpe return MaterializedSelectionSpec(self.dataset_id, self.cells | other.cells) def to_payload(self) -> dict[str, object]: + """Serialize this materialized selection for manifest/checkpoint metadata.""" ordered = sorted(self.cells) return { "type": "materialized", diff --git a/ueler/viewer/plugin/cell_annotation/store.py b/ueler/viewer/plugin/cell_annotation/store.py index b9e7d4f..4562713 100644 --- a/ueler/viewer/plugin/cell_annotation/store.py +++ b/ueler/viewer/plugin/cell_annotation/store.py @@ -12,10 +12,10 @@ def _dataset_id(dataset_root: str | Path) -> str: - """Return a short, stable identifier for *dataset_root*.""" + """Return a stable identifier derived from the dataset root path.""" resolved = str(Path(dataset_root).resolve()) - return hashlib.sha256(resolved.encode("utf-8")).hexdigest()[:16] + return hashlib.sha256(resolved.encode("utf-8")).hexdigest()[:32] class DatasetStore: diff --git a/ueler/viewer/plugin/heatmap.py b/ueler/viewer/plugin/heatmap.py index d62f868..44b433b 100644 --- a/ueler/viewer/plugin/heatmap.py +++ b/ueler/viewer/plugin/heatmap.py @@ -26,6 +26,7 @@ def __init__(self, main_viewer, width, height): self._cutoff_lock_reason = None self._lock_override_requested = False self._suppress_lock_observer = False + self._last_imported_heatmap_state_path = None # Keep Assign tab controls in sync with current cluster selection state. self.data.current_clusters["index"].add_observer(self.update_ui_components) self.ui_component.lock_cutoff_button.observe(self._on_lock_cutoff_change, names='value') @@ -119,6 +120,8 @@ def export_heatmap_state( } def import_heatmap_state(self, adata_path: str) -> None: + """Record the requested checkpoint path until checkpoint restore lands.""" + # TODO: replace this bookkeeping-only stub with full checkpoint restoration. self._last_imported_heatmap_state_path = adata_path class UiComponent: diff --git a/ueler/viewer/plugin/run_flowsom.py b/ueler/viewer/plugin/run_flowsom.py index c6fc373..8c09d1e 100644 --- a/ueler/viewer/plugin/run_flowsom.py +++ b/ueler/viewer/plugin/run_flowsom.py @@ -77,6 +77,7 @@ def __init__(self, main_viewer, width, height): # Always run this at the end of __init__ # self.load_widget_states(os.path.join(self.main_viewer.base_folder, ".UELer", f'{self.displayed_name}_widget_states.json')) self.initialized = True + # Stores the active Cell Annotation selection for future subset-aware runs. self._selection_context = None self._register_cell_annotation_provider() @@ -177,6 +178,7 @@ def import_flowsom_params(self, params): self.ui_component.subset_selector.value = tuple(params["subset"]) def set_selection_context(self, selection: SelectionSpec) -> None: + """Persist the active Cell Annotation selection for future subset-aware runs.""" self._selection_context = selection def on_subset_on_dropdown_change(self, change):