Skip to content

Commit 5bd4e38

Browse files
committed
fix(signal): convert legacy curve styles when loading DataLab <= 1.3 files
Since SigimaX always restores stored curve styles, the automatic styles that DataLab 1.3 and earlier stored in signal metadata are now removed when loading native HDF5 files (workspaces, history files) saved by these versions, so that their curves get the current automatic styles. Styles stored by later versions are always kept, including user styles identical to an automatic style of earlier versions (e.g. #1f77b4 with a solid line), which were lost when reloading a workspace. - `NativeH5Reader.saved_version` (decoded once) and `NativeH5Reader.has_legacy_curve_styles`, based on `is_legacy_style_version` (release < 1.4; missing or invalid versions keep stored styles); `H5InputOutput` uses `saved_version` - `SignalPanel.deserialize_object_from_hdf5` removes `color` and `style` when the file is legacy and the pair is a legacy automatic style - Tests: legacy workspace and history file converted, current files kept, user style from the legacy cycle kept over two round trips, version parsing; headless reader test double updated to the reader contract - Documentation, release notes and French translations Assisted-by: Claude Opus 5.5
1 parent f848eb4 commit 5bd4e38

10 files changed

Lines changed: 257 additions & 34 deletions

File tree

‎datalab/gui/h5io.py‎

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -111,14 +111,6 @@ def __read_provenance(
111111
_("The provenance data of this workspace are invalid: %s") % exc
112112
) from exc
113113

114-
@staticmethod
115-
def __saved_version(reader: NativeH5Reader) -> str | None:
116-
"""Return the DataLab version that saved the file, as stored."""
117-
value = reader.version[()]
118-
if isinstance(value, bytes):
119-
value = value.decode("utf-8", "replace")
120-
return value if isinstance(value, str) else None
121-
122114
def __data_objects(self) -> list[SignalObj | ImageObj]:
123115
"""Return the objects of the signal and image panels."""
124116
return [
@@ -176,7 +168,7 @@ def open_file_headless(self, filename: str, reset_all: bool) -> bool:
176168
try:
177169
reader = NativeH5Reader(filename)
178170
block = self.__read_provenance(reader)
179-
saved_version = self.__saved_version(reader)
171+
saved_version = reader.saved_version
180172
if reset_all:
181173
self.mainwindow.reset_all()
182174
before = {id(obj) for obj in self.__data_objects()}
@@ -233,7 +225,7 @@ def __open_native(self, filename: str, reset_all: bool) -> bool:
233225
try:
234226
reader = NativeH5Reader(filename)
235227
block = self.__read_provenance(reader)
236-
saved_version = self.__saved_version(reader)
228+
saved_version = reader.saved_version
237229
if block is not None and not reset_all and not self.__confirm_append():
238230
self.cancelled_files.append(filename)
239231
return True

‎datalab/gui/panel/signal.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from qtpy import QtWidgets as QW
3939

4040
from datalab.gui.docks import DockablePlotWidget
41+
from datalab.h5.native import NativeH5Reader
4142

4243

4344
class SignalPanel(BaseDataPanel[SignalObj, SignalROI, roieditor.SignalROIEditor]):
@@ -88,6 +89,32 @@ def __init__(
8889
view_toolbar = dockableplotwidget.toolbar
8990
self.acthandler = SignalActionHandler(self, panel_toolbar, view_toolbar)
9091

92+
def deserialize_object_from_hdf5(
93+
self, reader: NativeH5Reader, name: str, reset_all: bool = False
94+
) -> SignalObj:
95+
"""Deserialize object from a HDF5 file
96+
97+
DataLab 1.3 and earlier stored automatic curve styles in signal metadata:
98+
when loading a file saved by these versions, the colors and line styles of
99+
their automatic style cycle are removed, so that curves are given the
100+
current automatic styles. Other stored styles are kept.
101+
102+
Args:
103+
reader: HDF5 reader
104+
name: Object name in HDF5 file
105+
reset_all: If True, preserve original UUIDs (workspace reload).
106+
If False, regenerate only UUIDs that conflict with existing
107+
objects (object import).
108+
"""
109+
obj = super().deserialize_object_from_hdf5(reader, name, reset_all)
110+
metadata = obj.metadata
111+
if reader.has_legacy_curve_styles and CURVESTYLES.is_legacy_automatic(
112+
*(metadata.get(key) for key in LINE_STYLE_KEYS)
113+
):
114+
for key in LINE_STYLE_KEYS:
115+
metadata.pop(key, None)
116+
return obj
117+
91118
# ------Creating, adding, removing objects------------------------------------------
92119
def get_newparam_from_current(
93120
self, newparam: NewSignalParam | None = None, title: str | None = None

‎datalab/h5/native.py‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
from guidata.io import HDF5Reader, HDF5Writer
2121
from guidata.io.h5fmt import NoDefault
22+
from packaging.version import InvalidVersion, Version
2223

2324
import datalab
2425

@@ -27,6 +28,30 @@
2728

2829
H5_CALLABLE_PREFIX = "#callable#"
2930

31+
#: First DataLab version which no longer stores automatic curve styles in signal
32+
#: metadata (earlier versions stored them, see :func:`is_legacy_style_version`)
33+
NON_PERSISTENT_CURVE_STYLES_VERSION = (1, 4)
34+
35+
36+
def is_legacy_style_version(version: str | None) -> bool:
37+
"""Return True if a file saved by the given DataLab version may store automatic
38+
curve styles in signal metadata (DataLab 1.3 or earlier)
39+
40+
Args:
41+
version: DataLab version that saved the file, as stored in the file
42+
43+
Returns:
44+
True if the version is earlier than 1.4 (pre-releases of 1.4 included in
45+
1.4); False if it is 1.4 or later, missing or invalid
46+
"""
47+
if not isinstance(version, str):
48+
return False
49+
try:
50+
release = Version(version).release
51+
except InvalidVersion:
52+
return False
53+
return release < NON_PERSISTENT_CURVE_STYLES_VERSION
54+
3055

3156
class NativeH5Writer(HDF5Writer):
3257
"""DataLab signal/image objects HDF5 guidata Dataset Writer class
@@ -140,6 +165,25 @@ class NativeH5Reader(HDF5Reader):
140165
def __init__(self, filename: str) -> None:
141166
super().__init__(filename)
142167
self.version = self.h5[DATALAB_VERSION_NAME]
168+
self.__saved_version: str | None = None
169+
self.__saved_version_read = False
170+
171+
@property
172+
def saved_version(self) -> str | None:
173+
"""DataLab version that saved the file, or None if it is not readable"""
174+
if not self.__saved_version_read:
175+
value = self.version[()]
176+
if isinstance(value, bytes):
177+
value = value.decode("utf-8", "replace")
178+
self.__saved_version = value if isinstance(value, str) else None
179+
self.__saved_version_read = True
180+
return self.__saved_version
181+
182+
@property
183+
def has_legacy_curve_styles(self) -> bool:
184+
"""True if the file was saved by a DataLab version which stored automatic
185+
curve styles in signal metadata (DataLab 1.3 or earlier)"""
186+
return is_legacy_style_version(self.saved_version)
143187

144188
@staticmethod
145189
def deserialize_func_or_class(obj: str) -> Callable | type:

‎datalab/tests/features/hdf5/h5workspace_unit_test.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ def test_open_file_headless_refreshes_reference_displays(monkeypatch) -> None:
8989
provenance=SimpleNamespace(load=Mock()),
9090
)
9191
# A workspace file without provenance block, saved by DataLab 1.4
92-
reader = SimpleNamespace(close=Mock(), h5={}, version={(): "1.4.0"})
92+
reader = SimpleNamespace(close=Mock(), h5={}, saved_version="1.4.0")
9393
monkeypatch.setattr("datalab.gui.h5io.NativeH5Reader", lambda _filename: reader)
9494

9595
assert H5InputOutput(mainwindow).open_file_headless("workspace.h5", False)
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
2+
3+
"""
4+
Native HDF5 reader unit tests
5+
-----------------------------
6+
7+
The DataLab version that saved a native HDF5 file is read once, whatever the way
8+
it is stored, and tells whether the file stores automatic curve styles.
9+
"""
10+
11+
# guitest: skip
12+
13+
from __future__ import annotations
14+
15+
import h5py
16+
import numpy as np
17+
import pytest
18+
19+
from datalab.h5.native import DATALAB_VERSION_NAME, NativeH5Reader
20+
21+
22+
@pytest.mark.parametrize(
23+
"stored, saved_version, legacy",
24+
[
25+
("1.4.0", "1.4.0", False),
26+
("1.3.2", "1.3.2", True),
27+
(np.bytes_(b"1.2.1"), "1.2.1", True), # Stored as bytes
28+
("not a version", "not a version", False),
29+
(42, None, False), # Not a string
30+
],
31+
)
32+
def test_saved_version(tmp_path, stored, saved_version, legacy: bool) -> None:
33+
"""The saved version is decoded, and legacy curve styles are detected"""
34+
filename = str(tmp_path / "workspace.h5")
35+
with h5py.File(filename, "w") as h5file:
36+
h5file[DATALAB_VERSION_NAME] = stored
37+
reader = NativeH5Reader(filename)
38+
try:
39+
assert reader.saved_version == saved_version
40+
assert reader.has_legacy_curve_styles is legacy
41+
finally:
42+
reader.close()

‎datalab/tests/features/signal/curve_styles_unit_test.py‎

Lines changed: 129 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
77
Automatic curve styles depend on the position of the curves on the plot, are not
88
stored in signal metadata, and are not shifted by dialogs showing signals. Curve
9-
styles chosen by the user are kept.
9+
styles chosen by the user are kept. Automatic styles stored by DataLab 1.3 or
10+
earlier are removed when loading files saved by these versions.
1011
"""
1112

1213
# guitest: show
@@ -15,7 +16,9 @@
1516

1617
import os.path as osp
1718

19+
import h5py
1820
import numpy as np
21+
import pytest
1922
from plotpy.items import CurveItem
2023
from qtpy import QtGui as QG
2124
from sigima.objects import SignalObj, create_signal
@@ -26,10 +29,15 @@
2629
CurveStyles,
2730
create_adapter_from_object,
2831
)
32+
from datalab.gui.main import DLMainWindow
2933
from datalab.gui.panel.signal import SignalPanel
34+
from datalab.h5.native import DATALAB_VERSION_NAME, is_legacy_style_version
3035
from datalab.objectmodel import get_uuid
3136
from datalab.tests import datalab_test_app_context, helpers
3237

38+
#: Style of the automatic style cycle of DataLab 1.3 or earlier
39+
LEGACY_STYLE = (CurveStyles.LEGACY_COLORS[2], "DashDotDotLine")
40+
3341

3442
def _create_signals(count: int) -> list[SignalObj]:
3543
"""Create test signals"""
@@ -47,13 +55,60 @@ def _style(item: CurveItem) -> tuple[str, str]:
4755
return QG.QColor(item.param.line.color).name(), item.param.line.style
4856

4957

50-
def _set_user_style(panel: SignalPanel, item: CurveItem, color: str) -> None:
51-
"""Set a curve color as the user does with the curve parameters dialog"""
58+
def _stored_style(obj: SignalObj) -> tuple[str | None, str | None]:
59+
"""Return the color and the line style stored in signal metadata"""
60+
return obj.metadata.get("color"), obj.metadata.get("style")
61+
62+
63+
def _set_user_style(
64+
panel: SignalPanel, item: CurveItem, color: str, linestyle: str | None = None
65+
) -> None:
66+
"""Set a curve style as the user does with the curve parameters dialog"""
5267
item.param.line.color = color
68+
if linestyle is not None:
69+
item.param.line.style = linestyle
5370
item.param.update_item(item)
5471
panel.plot_item_parameters_changed(item)
5572

5673

74+
def _set_saved_version(filename: str, version: str) -> None:
75+
"""Set the DataLab version that saved a native HDF5 file"""
76+
with h5py.File(filename, "r+") as h5file:
77+
del h5file[DATALAB_VERSION_NAME]
78+
h5file[DATALAB_VERSION_NAME] = version
79+
80+
81+
def _reload_workspace(win: DLMainWindow, filename: str) -> list[SignalObj]:
82+
"""Reload a workspace and show all its signals"""
83+
panel = win.signalpanel
84+
panel.remove_all_objects()
85+
win.load_h5_workspace([filename], reset_all=True)
86+
loaded = panel.objmodel.get_all_objects()
87+
panel.objview.select_objects(loaded)
88+
return loaded
89+
90+
91+
@pytest.mark.parametrize(
92+
"version, expected",
93+
[
94+
("1.3.0", True),
95+
("1.3.10", True),
96+
("0.20.1", True),
97+
("1.4.0", False),
98+
("1.4.0rc1", False), # Pre-releases of 1.4 do not store automatic styles
99+
("1.4.0.dev0", False),
100+
("1.10.0", False),
101+
("2.0.0", False),
102+
("invalid", False),
103+
("", False),
104+
(None, False),
105+
],
106+
)
107+
def test_legacy_style_version(version: str | None, expected: bool) -> None:
108+
"""Versions which stored automatic curve styles are DataLab 1.3 or earlier"""
109+
assert is_legacy_style_version(version) is expected
110+
111+
57112
def test_styles_follow_curve_positions() -> None:
58113
"""Visible curves are given the automatic styles of their positions"""
59114
with datalab_test_app_context(console=False, exec_loop=False) as win:
@@ -123,23 +178,80 @@ def test_user_styles_are_kept() -> None:
123178
assert _style(_item(panel, loaded[2])) == CurveStyles.style_at(2)
124179

125180

126-
def test_legacy_styles_are_automatic() -> None:
127-
"""Styles stored by earlier versions from their style cycle are automatic"""
181+
def test_user_style_from_legacy_cycle_is_kept() -> None:
182+
"""A style chosen by the user is kept over several workspace round trips, even
183+
if it matches a style of the automatic style cycle of DataLab 1.3 or earlier"""
128184
with helpers.WorkdirRestoringTempDir() as tmpdir:
129185
with datalab_test_app_context(console=False, exec_loop=False) as win:
130186
panel = win.signalpanel
131187
signals = _create_signals(2)
132-
for obj in signals:
133-
obj.metadata["color"] = CurveStyles.LEGACY_COLORS[2]
134-
obj.metadata["style"] = "DashDotDotLine"
135188
panel.add_objects(signals, set_current=False)
136189
panel.objview.select_objects(signals)
137-
for index, obj in enumerate(signals):
138-
assert _style(_item(panel, obj)) == CurveStyles.style_at(index)
139-
win.save_h5_workspace(osp.join(tmpdir, "legacy_styles.h5"))
140-
for obj in signals:
141-
for key in LINE_STYLE_KEYS:
142-
assert key not in obj.metadata
190+
_set_user_style(panel, _item(panel, signals[1]), "#1f77b4", "SolidLine")
191+
assert CurveStyles.is_legacy_automatic("#1f77b4", "SolidLine")
192+
fname = osp.join(tmpdir, "user_styles.h5")
193+
for _cycle in range(2):
194+
win.save_h5_workspace(fname)
195+
loaded = _reload_workspace(win, fname)
196+
assert _stored_style(loaded[1]) == ("#1f77b4", "SolidLine")
197+
assert _style(_item(panel, loaded[1])) == ("#1f77b4", "SolidLine")
198+
assert _stored_style(loaded[0]) == (None, None)
199+
assert _style(_item(panel, loaded[0])) == CurveStyles.style_at(0)
200+
201+
202+
def test_legacy_styles_converted_on_load() -> None:
203+
"""Automatic styles stored by DataLab 1.3 or earlier are removed on load"""
204+
with helpers.WorkdirRestoringTempDir() as tmpdir:
205+
with datalab_test_app_context(console=False, exec_loop=False) as win:
206+
panel = win.signalpanel
207+
signals = _create_signals(3)
208+
signals[0].metadata["color"], signals[0].metadata["style"] = LEGACY_STYLE
209+
signals[1].metadata["color"], signals[1].metadata["style"] = LEGACY_STYLE
210+
# A legacy color with a line style which is not a legacy automatic one:
211+
signals[2].metadata["color"] = LEGACY_STYLE[0]
212+
signals[2].metadata["style"] = "DotLine"
213+
panel.add_objects(signals, set_current=False)
214+
panel.objview.select_objects(signals)
215+
fname = osp.join(tmpdir, "legacy_styles.h5")
216+
win.save_h5_workspace(fname)
217+
218+
# Saved by the current version: stored styles are kept
219+
loaded = _reload_workspace(win, fname)
220+
assert _stored_style(loaded[0]) == LEGACY_STYLE
221+
assert _style(_item(panel, loaded[0])) == LEGACY_STYLE
222+
223+
# Saved by DataLab 1.3: legacy automatic styles are removed
224+
_set_saved_version(fname, "1.3.0")
225+
loaded = _reload_workspace(win, fname)
226+
for index in (0, 1):
227+
assert _stored_style(loaded[index]) == (None, None)
228+
assert _style(_item(panel, loaded[index])) == CurveStyles.style_at(
229+
index
230+
)
231+
assert _stored_style(loaded[2]) == (LEGACY_STYLE[0], "DotLine")
232+
assert _style(_item(panel, loaded[2])) == (LEGACY_STYLE[0], "DotLine")
233+
234+
235+
def test_legacy_styles_converted_on_dlhist_import() -> None:
236+
"""Automatic styles stored by DataLab 1.3 or earlier are removed when importing
237+
a history file in a workspace in use"""
238+
with helpers.WorkdirRestoringTempDir() as tmpdir:
239+
with datalab_test_app_context(console=False, exec_loop=False) as win:
240+
panel = win.signalpanel
241+
signals = _create_signals(1)
242+
signals[0].metadata["color"], signals[0].metadata["style"] = LEGACY_STYLE
243+
panel.add_objects(signals, set_current=False)
244+
fname = osp.join(tmpdir, "legacy_styles.dlhist")
245+
assert win.historypanel.save_to_dlhist_file(fname)
246+
_set_saved_version(fname, "1.3.0")
247+
existing = panel.objmodel.get_all_objects()
248+
assert win.historypanel.open_dlhist_file(fname)
249+
imported = [
250+
obj for obj in panel.objmodel.get_all_objects() if obj not in existing
251+
]
252+
assert len(imported) == 1
253+
assert _stored_style(imported[0]) == (None, None)
254+
assert _stored_style(existing[0]) == LEGACY_STYLE
143255

144256

145257
def test_reset_curve_styles() -> None:
@@ -168,5 +280,7 @@ def test_reset_curve_styles() -> None:
168280
test_styles_follow_curve_positions()
169281
test_style_cycle_not_used_by_panel_and_dialogs()
170282
test_user_styles_are_kept()
171-
test_legacy_styles_are_automatic()
283+
test_user_style_from_legacy_cycle_is_kept()
284+
test_legacy_styles_converted_on_load()
285+
test_legacy_styles_converted_on_dlhist_import()
172286
test_reset_curve_styles()

0 commit comments

Comments
 (0)