diff --git a/.github/workflows/test_pyqt5.yml b/.github/workflows/test_pyqt5.yml index 6133074..8b3f06f 100644 --- a/.github/workflows/test_pyqt5.yml +++ b/.github/workflows/test_pyqt5.yml @@ -53,13 +53,14 @@ jobs: python -m pip install --upgrade pip python -m pip install ruff pytest pip install PyQt5 - if [ "${{ github.ref_name }}" = "develop" ]; then + if [ "${{ github.base_ref || github.ref_name }}" = "develop" ]; then # Clone and install development versions of key dependencies with editable install cd .. git clone --depth 1 https://github.com/PlotPyStack/PythonQwt.git git clone --depth 1 --branch develop https://github.com/PlotPyStack/guidata.git git clone --depth 1 --branch develop https://github.com/PlotPyStack/plotpy.git - git clone --depth 1 --branch develop https://github.com/DataLab-Platform/sigima.git + DEPENDENCY_BRANCH="${{ github.head_ref || github.ref_name }}" + git clone --depth 1 --branch "$DEPENDENCY_BRANCH" https://github.com/DataLab-Platform/sigima.git || git clone --depth 1 --branch develop https://github.com/DataLab-Platform/sigima.git cd SigimaX pip install -e ../guidata pip install -e ../PythonQwt @@ -72,7 +73,7 @@ jobs: pip install -r deps.txt # Install SigimaX without dependencies pip install --no-deps . - elif [ "${{ github.ref_name }}" = "release" ]; then + elif [ "${{ github.base_ref || github.ref_name }}" = "release" ]; then # Clone dependencies from release branches (with fallback to main/master) cd .. # Try cloning PythonQwt from main or master diff --git a/.github/workflows/test_pyqt6.yml b/.github/workflows/test_pyqt6.yml index 3e8fc01..ecec5d6 100644 --- a/.github/workflows/test_pyqt6.yml +++ b/.github/workflows/test_pyqt6.yml @@ -53,13 +53,14 @@ jobs: python -m pip install --upgrade pip python -m pip install ruff pytest pip install PyQt6 - if [ "${{ github.ref_name }}" = "develop" ]; then + if [ "${{ github.base_ref || github.ref_name }}" = "develop" ]; then # Clone and install development versions of key dependencies with editable install cd .. git clone --depth 1 https://github.com/PlotPyStack/PythonQwt.git git clone --depth 1 --branch develop https://github.com/PlotPyStack/guidata.git git clone --depth 1 --branch develop https://github.com/PlotPyStack/plotpy.git - git clone --depth 1 --branch develop https://github.com/DataLab-Platform/sigima.git + DEPENDENCY_BRANCH="${{ github.head_ref || github.ref_name }}" + git clone --depth 1 --branch "$DEPENDENCY_BRANCH" https://github.com/DataLab-Platform/sigima.git || git clone --depth 1 --branch develop https://github.com/DataLab-Platform/sigima.git cd SigimaX pip install -e ../guidata pip install -e ../PythonQwt @@ -72,7 +73,7 @@ jobs: pip install -r deps.txt # Install SigimaX without dependencies pip install --no-deps . - elif [ "${{ github.ref_name }}" = "release" ]; then + elif [ "${{ github.base_ref || github.ref_name }}" = "release" ]; then # Clone dependencies from release branches (with fallback to main/master) cd .. # Try cloning PythonQwt from main or master diff --git a/doc/release_notes/release_1.01.md b/doc/release_notes/release_1.01.md new file mode 100644 index 0000000..73f33b8 --- /dev/null +++ b/doc/release_notes/release_1.01.md @@ -0,0 +1,21 @@ +# Version 1.1.0 # + +## SigimaX Version 1.1.0 ## + +### PlotPy adapters ### + +* PlotPy annotations created or edited through SigimaX are now stored with + Sigima's renderer-independent annotation model, so they may be displayed by + other supported visualization backends without carrying PlotPy-specific + serialization data. +* Existing PlotPy annotations remain readable without modifying the source + object. Supported annotations are migrated when an edit is accepted, while + malformed, unknown, or partially supported payloads are preserved unchanged. +* Canonical annotation identifiers, metadata, extensions, and persistent lock + state are preserved across PlotPy editing round trips. Application-specific + opaque annotation entries continue to coexist with graphical annotations. + +### Requirements ### + +* Sigima 1.3.0 or later is required for the portable annotation model and + PlotPy conversion helpers. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 7c42da0..219cd3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ dependencies = [ "guidata >= 3.15.0", "PlotPy >= 2.11.0", "psutil >= 5.7", - "Sigima >= 1.2.0", + "Sigima >= 1.3.0", ] dynamic = ["version"] diff --git a/requirements.txt b/requirements.txt index 7d16f02..afe330d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ Coverage PlotPy >= 2.11.0 PyQt5 >= 5.15.6 -Sigima >= 1.2.0 +Sigima >= 1.3.0 guidata >= 3.15.0 myst-nb myst_parser diff --git a/sigimax/_metadata.py b/sigimax/_metadata.py index 44f18a7..040e917 100644 --- a/sigimax/_metadata.py +++ b/sigimax/_metadata.py @@ -24,7 +24,7 @@ alongside internal access to version information. """ -__version__ = "1.0.1" +__version__ = "1.1.0" __docurl__ = "https://sigimax.readthedocs.io/" __homeurl__ = "https://github.com/DataLab-Platform/SigimaX" __supporturl__ = "https://github.com/DataLab-Platform/SigimaX/issues/new/choose" diff --git a/sigimax/adapters_plotpy/annotations.py b/sigimax/adapters_plotpy/annotations.py index c9eab92..ef610ae 100644 --- a/sigimax/adapters_plotpy/annotations.py +++ b/sigimax/adapters_plotpy/annotations.py @@ -12,17 +12,57 @@ from __future__ import annotations -import json -from typing import TYPE_CHECKING +from dataclasses import dataclass, fields, replace +from typing import TYPE_CHECKING, Any -from guidata.io import JSONReader, JSONWriter -from plotpy.io import load_items, save_items +from guidata.io import JSONWriter +from plotpy.io import save_items +from sigima.objects.annotations import ( + GraphicalAnnotation, + annotation_from_dict, + annotation_to_dict, + is_graphical_annotation_dict, +) +from sigima.objects.annotations.legacy_plotpy import ( + legacy_plotpy_payload_to_annotations, +) +from sigima.viz.annotation_plotpy import ( + annotation_to_plotpy_item, + load_legacy_plotpy_items, + plotpy_item_to_annotation, +) if TYPE_CHECKING: from plotpy.items import AnnotatedShape from sigima.objects.base import BaseObj +_SOURCE_ATTRIBUTE = "_sigimax_annotation_source" +_PRESERVED_FIELDS = {"id", "metadata", "extensions"} + + +@dataclass +class _AnnotationItemSource: + """Stored annotation represented by one PlotPy item.""" + + object_id: int + storage_index: int + canonical: bool + original: GraphicalAnnotation | None + baseline: GraphicalAnnotation | None = None + + +class _LegacyPayloadObject: + """Minimal object view used to load one historical PlotPy payload.""" + + def __init__(self, payload: dict[str, Any]) -> None: + self.payload = payload + + def get_annotations(self) -> list[dict[str, Any]]: + """Return the single payload expected by Sigima's legacy loader.""" + return [self.payload] + + class PlotPyAnnotationAdapter: """Adapter for converting between Sigima annotations and PlotPy items. @@ -68,19 +108,123 @@ def get_items(self) -> list[AnnotatedShape]: return [] items = [] - for ann_dict in annotations: - # Each annotation dict should contain PlotPy's JSON serialization - if "plotpy_json" in ann_dict: - try: - json_str = ann_dict["plotpy_json"] - for item in load_items(JSONReader(json_str)): - items.append(item) - except (json.JSONDecodeError, ValueError, KeyError): - # Skip invalid items - continue + for index, ann_dict in enumerate(annotations): + if is_graphical_annotation_dict(ann_dict): + annotation = annotation_from_dict(ann_dict) + item = annotation_to_plotpy_item(annotation) + self._set_item_source(item, index, True, annotation) + items.append(item) + elif isinstance(ann_dict, dict) and "plotpy_json" in ann_dict: + for item in self._load_legacy_payload(ann_dict): + self._set_item_source( + item, index, False, self._item_to_annotation(item) + ) + items.append(item) return items + @staticmethod + def _serialize_plotpy_item(item: AnnotatedShape) -> dict[str, Any]: + """Serialize an unsupported PlotPy item as an opaque legacy payload.""" + writer = JSONWriter(None) + save_items(writer, [item]) + return { + "type": "plotpy_item", + "item_class": item.__class__.__name__, + "plotpy_json": writer.get_json(), + } + + @classmethod + def _item_to_annotation(cls, item: AnnotatedShape) -> GraphicalAnnotation | None: + """Convert a PlotPy item, including not-yet-initialized shapes.""" + try: + annotation = plotpy_item_to_annotation(item) + except (AttributeError, TypeError, ValueError): + annotation = None + if annotation is None: + try: + legacy_payload = cls._serialize_plotpy_item(item) + [annotation] = legacy_plotpy_payload_to_annotations(legacy_payload) + except (AttributeError, TypeError, ValueError): + return None + return annotation + + @classmethod + def _item_to_payload(cls, item: AnnotatedShape) -> dict[str, Any]: + """Convert a PlotPy item to canonical data when supported.""" + annotation = cls._item_to_annotation(item) + if annotation is None: + return cls._serialize_plotpy_item(item) + return annotation_to_dict(annotation) + + @staticmethod + def _load_legacy_payload(payload: dict[str, Any]) -> list[AnnotatedShape]: + """Load one historical payload without modifying the source object.""" + return load_legacy_plotpy_items(_LegacyPayloadObject(payload)) + + def _set_item_source( + self, + item: AnnotatedShape, + storage_index: int, + canonical: bool, + original: GraphicalAnnotation | None, + ) -> None: + """Attach storage provenance and an initial PlotPy projection.""" + source = _AnnotationItemSource( + object_id=id(self.obj), + storage_index=storage_index, + canonical=canonical, + original=original, + ) + setattr(item, _SOURCE_ATTRIBUTE, source) + self.capture_item_reference(item) + + def _get_item_source(self, item: AnnotatedShape) -> _AnnotationItemSource | None: + """Return provenance when *item* belongs to this object.""" + source = getattr(item, _SOURCE_ATTRIBUTE, None) + if isinstance(source, _AnnotationItemSource) and source.object_id == id( + self.obj + ): + return source + return None + + def capture_item_reference(self, item: AnnotatedShape) -> None: + """Capture the PlotPy state used as the edit comparison baseline.""" + source = self._get_item_source(item) + if source is not None: + source.baseline = self._item_to_annotation(item) + + def is_item_locked(self, item: AnnotatedShape) -> bool: + """Return the persistent lock state represented by *item*.""" + source = self._get_item_source(item) + if source is not None and source.original is not None: + return source.original.locked + return bool(item.is_readonly()) + + def is_annotation_item(self, item: AnnotatedShape) -> bool: + """Return whether *item* is a stored or newly created annotation.""" + if self._get_item_source(item) is not None: + return True + return not item.is_readonly() and self._item_to_annotation(item) is not None + + @staticmethod + def _merge_edited_annotation( + original: GraphicalAnnotation, + baseline: GraphicalAnnotation | None, + edited: GraphicalAnnotation, + ) -> GraphicalAnnotation: + """Apply only PlotPy-visible edits to a canonical annotation.""" + if baseline is None or type(original) is not type(edited): + return original + updates = {} + for annotation_field in fields(original): + name = annotation_field.name + if name in _PRESERVED_FIELDS: + continue + if getattr(edited, name) != getattr(baseline, name): + updates[name] = getattr(edited, name) + return replace(original, **updates) if updates else original + def set_items(self, items: list[AnnotatedShape]) -> None: """Set annotations from PlotPy items. @@ -91,23 +235,63 @@ def set_items(self, items: list[AnnotatedShape]) -> None: This method serializes PlotPy items to JSON using PlotPy's save_items() function and stores them in the Sigima format. """ - if not items: - self.obj.clear_annotations() - return - - # Convert PlotPy items to our annotation format - annotations = [] + stored = self.obj.get_annotations() + sourced_items: dict[ + int, list[tuple[AnnotatedShape, _AnnotationItemSource]] + ] = {} + new_items = [] for item in items: - writer = JSONWriter(None) - save_items(writer, [item]) - ann_dict = { - "type": "plotpy_item", - "item_class": item.__class__.__name__, - "plotpy_json": writer.get_json(), - } - annotations.append(ann_dict) + source = self._get_item_source(item) + if source is None or source.storage_index >= len(stored): + new_items.append(item) + else: + sourced_items.setdefault(source.storage_index, []).append( + (item, source) + ) - self.obj.set_annotations(annotations) + output = [] + for index, payload in enumerate(stored): + group = sourced_items.pop(index, []) + if is_graphical_annotation_dict(payload): + if not group: + continue + item, source = group.pop(0) + edited = self._item_to_annotation(item) + if source.original is None or edited is None: + output.append(payload) + else: + merged = self._merge_edited_annotation( + source.original, source.baseline, edited + ) + output.append( + payload + if merged is source.original + else annotation_to_dict(merged) + ) + new_items.extend(item for item, _source in group) + elif isinstance(payload, dict) and "plotpy_json" in payload: + if not self._load_legacy_payload(payload): + output.append(payload) + continue + converted = [self._item_to_annotation(item) for item, _source in group] + if any(annotation is None for annotation in converted): + output.append(payload) + else: + output.extend( + annotation_to_dict(annotation) + for annotation in converted + if annotation is not None + ) + else: + output.append(payload) + + for group in sourced_items.values(): + new_items.extend(item for item, _source in group) + output.extend(self._item_to_payload(item) for item in new_items) + if output: + self.obj.set_annotations(output) + else: + self.obj.clear_annotations() def add_items(self, items: list[AnnotatedShape]) -> None: """Add PlotPy items to existing annotations. @@ -115,9 +299,9 @@ def add_items(self, items: list[AnnotatedShape]) -> None: Args: items: List of PlotPy annotation items to add """ - current_items = self.get_items() - current_items.extend(items) - self.set_items(current_items) + annotations = self.obj.get_annotations() + annotations.extend(self._item_to_payload(item) for item in items) + self.obj.set_annotations(annotations) def clear(self) -> None: """Clear all annotations.""" diff --git a/sigimax/adapters_plotpy/objects/base.py b/sigimax/adapters_plotpy/objects/base.py index 765639e..ecc0a5d 100644 --- a/sigimax/adapters_plotpy/objects/base.py +++ b/sigimax/adapters_plotpy/objects/base.py @@ -176,7 +176,11 @@ def iterate_shape_items(self, editable: bool = False): for item in self.annotation_adapter.get_items(): if isinstance(item, AnnotatedShape): config_annotated_shape(item, fmt, lbl) - set_plot_item_editable(item, editable) + item_editable = editable and not self.annotation_adapter.is_item_locked( + item + ) + set_plot_item_editable(item, item_editable) + self.annotation_adapter.capture_item_reference(item) yield item def update_plot_item_parameters(self, item: TypePlotItem) -> None: diff --git a/sigimax/mainwindow.py b/sigimax/mainwindow.py index 97d82ad..9d2c7a1 100644 --- a/sigimax/mainwindow.py +++ b/sigimax/mainwindow.py @@ -103,8 +103,8 @@ def get_instance(cls, console=None, hide_on_close=False): def __init__(self, console=None, hide_on_close=False): """Initialize main window""" - SGMXMainWindow.__instance = self super().__init__() + SGMXMainWindow.__instance = self conf = get_conf() self.setObjectName(conf.app_name.get()) self.setWindowIcon(get_icon(conf.app_logo_path.get())) diff --git a/sigimax/tests/adapters_plotpy/test_iterate_shape_items.py b/sigimax/tests/adapters_plotpy/test_iterate_shape_items.py index 5863a2c..4cc104c 100644 --- a/sigimax/tests/adapters_plotpy/test_iterate_shape_items.py +++ b/sigimax/tests/adapters_plotpy/test_iterate_shape_items.py @@ -13,11 +13,32 @@ from __future__ import annotations +import math + import numpy as np import pytest +from guidata.io import JSONWriter from guidata.qthelpers import qt_app_context +from plotpy.builder import make +from plotpy.io import save_items from plotpy.items import AnnotatedRectangle, AnnotatedXRange -from sigima.objects import create_image_roi, create_signal_roi +from sigima.objects import ( + Axis, + CircleAnnotation, + CursorAnnotation, + CursorOrientation, + EllipseAnnotation, + PointAnnotation, + PolygonAnnotation, + PolylineAnnotation, + RangeAnnotation, + RectangleAnnotation, + SegmentAnnotation, + TextAnnotation, + annotation_to_dict, + create_image_roi, + create_signal_roi, +) from sigima.tests.data import create_multigaussian_image, create_paracetamol_signal from sigimax.adapters_plotpy.converters import create_adapter_from_object @@ -50,6 +71,9 @@ def test_annotations_roundtrip(): # Store via adapter adapter.add_annotations_from_items([rect]) assert sig.has_annotations() + [stored] = sig.get_annotations() + assert stored["format"] == "sigima.annotation" + assert "plotpy_json" not in stored # Retrieve via annotation adapter recovered = adapter.annotation_adapter.get_items() @@ -62,6 +86,188 @@ def test_annotations_roundtrip(): np.testing.assert_allclose([r_x0, r_y0, r_x1, r_y1], [x0, y0, x1, y1]) +@pytest.mark.parametrize( + "annotation", + [ + PointAnnotation(x=1.0, y=2.0), + SegmentAnnotation(x0=0.0, y0=0.0, x1=1.0, y1=1.0), + RectangleAnnotation( + x=1.0, + y=2.0, + width=3.0, + height=4.0, + angle=math.pi / 4, + ), + CircleAnnotation(cx=1.0, cy=2.0, radius=3.0), + EllipseAnnotation( + cx=1.0, + cy=2.0, + radius_x=3.0, + radius_y=4.0, + angle=math.pi / 6, + ), + PolylineAnnotation(points=((0.0, 0.0), (1.0, 1.0))), + PolygonAnnotation(points=((0.0, 0.0), (1.0, 0.0), (0.0, 1.0))), + TextAnnotation(text="Data", x=1.0, y=2.0), + TextAnnotation(text="Axes", x=0.1, y=0.9, coordinate_space="axes"), + CursorAnnotation( + orientation=CursorOrientation.CROSSHAIR, + position=(1.0, 2.0), + ), + RangeAnnotation(axis=Axis.X, start=1.0, end=2.0), + ], + ids=[ + "point", + "segment", + "rectangle", + "circle", + "ellipse", + "polyline", + "polygon", + "text-data", + "text-axes", + "cursor", + "range", + ], +) +def test_all_canonical_primitives_roundtrip_without_rewrite(annotation): + """Every canonical primitive survives a PlotPy no-op byte-for-byte.""" + with qt_app_context(exec_loop=False): + sig = create_paracetamol_signal() + original = annotation_to_dict(annotation) + sig.set_annotations([original]) + adapter = create_adapter_from_object(sig).annotation_adapter + + items = adapter.get_items() + adapter.set_items(items) + + assert sig.get_annotations() == [original] + + +def test_canonical_annotation_edit_preserves_identity_and_opaque_data(): + """Canonical no-ops stay exact and edits retain non-PlotPy fields.""" + with qt_app_context(exec_loop=False): + sig = create_paracetamol_signal() + opaque = {"consumer": "custom", "payload": {"keep": True}} + annotation = RectangleAnnotation( + x=3.0, + y=5.0, + width=4.0, + height=6.0, + locked=True, + title="Reference", + metadata={"owner": "test"}, + extensions={"vendor": {"keep": True}}, + ) + original = annotation_to_dict(annotation) + sig.set_annotations([opaque, original]) + adapter = create_adapter_from_object(sig).annotation_adapter + + [item] = adapter.get_items() + adapter.set_items([item]) + assert sig.get_annotations() == [opaque, original] + + item.set_rect(2.0, 3.0, 8.0, 11.0) + adapter.set_items([item]) + + preserved_opaque, edited = sig.get_annotations() + assert preserved_opaque == opaque + assert edited["id"] == original["id"] + assert edited["metadata"] == original["metadata"] + assert edited["extensions"] == original["extensions"] + assert edited["locked"] is True + assert edited["x"] == pytest.approx(5.0) + assert edited["y"] == pytest.approx(7.0) + assert edited["width"] == pytest.approx(6.0) + assert edited["height"] == pytest.approx(8.0) + + +def test_legacy_annotations_migrate_only_on_write(): + """Reading is non-mutating while accepting the items migrates them.""" + with qt_app_context(exec_loop=False): + sig = create_paracetamol_signal() + item = make.annotated_rectangle(1.0, 2.0, 5.0, 8.0, title="Legacy") + writer = JSONWriter(None) + save_items(writer, [item]) + legacy = { + "type": "plotpy_item", + "item_class": type(item).__name__, + "plotpy_json": writer.get_json(), + } + opaque = {"consumer": "custom", "payload": {"keep": True}} + sig.set_annotations([legacy, opaque]) + adapter = create_adapter_from_object(sig).annotation_adapter + + items = adapter.get_items() + assert sig.get_annotations() == [legacy, opaque] + + adapter.set_items(items) + migrated, preserved_opaque = sig.get_annotations() + assert migrated["format"] == "sigima.annotation" + assert "plotpy_json" not in migrated + assert preserved_opaque == opaque + + +def test_unreadable_annotations_survive_replacement(): + """Replacing visible annotations preserves unreadable and opaque data.""" + with qt_app_context(exec_loop=False): + sig = create_paracetamol_signal() + canonical = annotation_to_dict( + RectangleAnnotation(x=3.0, y=5.0, width=4.0, height=6.0) + ) + malformed = {"type": "plotpy_item", "plotpy_json": "{"} + opaque = {"consumer": "custom", "payload": {"keep": True}} + sig.set_annotations([canonical, malformed, opaque]) + adapter = create_adapter_from_object(sig).annotation_adapter + + assert len(adapter.get_items()) == 1 + adapter.set_items([]) + + assert sig.get_annotations() == [malformed, opaque] + + +def test_partially_supported_legacy_group_is_preserved_atomically(): + """A legacy payload is not partly migrated when one item is unsupported.""" + with qt_app_context(exec_loop=False): + sig = create_paracetamol_signal() + rectangle = make.annotated_rectangle(1.0, 2.0, 5.0, 8.0) + curve = make.curve([0.0, 1.0], [1.0, 2.0]) + writer = JSONWriter(None) + save_items(writer, [rectangle, curve]) + legacy = { + "type": "plotpy_item", + "plotpy_json": writer.get_json(), + } + sig.set_annotations([legacy]) + adapter = create_adapter_from_object(sig).annotation_adapter + + items = adapter.get_items() + assert len(items) == 2 + adapter.set_items(items) + + assert sig.get_annotations() == [legacy] + + +def test_locked_annotation_remains_readonly_in_edit_mode(): + """Persistent annotation locks override the dialog edit mode.""" + with qt_app_context(exec_loop=False): + sig = create_paracetamol_signal() + sig.set_graphical_annotations( + [ + RectangleAnnotation(width=1.0, height=1.0, locked=True), + RectangleAnnotation(x=2.0, width=1.0, height=1.0), + ] + ) + adapter = create_adapter_from_object(sig) + + locked, editable = list(adapter.iterate_shape_items(editable=True)) + + assert locked.is_readonly() + assert not editable.is_readonly() + assert adapter.annotation_adapter.is_annotation_item(locked) + assert adapter.annotation_adapter.is_annotation_item(editable) + + # --------------------------------------------------------------------------- # iterate_shape_items — with ROI # ---------------------------------------------------------------------------