datalab-platform 1.0.1__py3-none-any.whl → 1.0.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. datalab/__init__.py +1 -1
  2. datalab/adapters_metadata/common.py +2 -2
  3. datalab/adapters_plotpy/converters.py +3 -1
  4. datalab/adapters_plotpy/coordutils.py +157 -0
  5. datalab/adapters_plotpy/roi/image.py +35 -6
  6. datalab/adapters_plotpy/roi/signal.py +8 -1
  7. datalab/config.py +88 -26
  8. datalab/control/baseproxy.py +70 -0
  9. datalab/control/proxy.py +33 -0
  10. datalab/control/remote.py +35 -0
  11. datalab/data/doc/DataLab_en.pdf +0 -0
  12. datalab/data/doc/DataLab_fr.pdf +0 -0
  13. datalab/data/icons/create/linear_chirp.svg +1 -1
  14. datalab/data/icons/create/logistic.svg +1 -1
  15. datalab/gui/actionhandler.py +16 -2
  16. datalab/gui/h5io.py +25 -0
  17. datalab/gui/macroeditor.py +37 -6
  18. datalab/gui/main.py +62 -5
  19. datalab/gui/newobject.py +7 -0
  20. datalab/gui/objectview.py +18 -3
  21. datalab/gui/panel/base.py +89 -16
  22. datalab/gui/panel/macro.py +26 -0
  23. datalab/gui/plothandler.py +20 -2
  24. datalab/gui/processor/base.py +72 -26
  25. datalab/gui/processor/image.py +6 -2
  26. datalab/gui/processor/signal.py +10 -0
  27. datalab/gui/roieditor.py +2 -2
  28. datalab/locale/fr/LC_MESSAGES/datalab.mo +0 -0
  29. datalab/locale/fr/LC_MESSAGES/datalab.po +3288 -0
  30. datalab/objectmodel.py +1 -1
  31. datalab/tests/features/common/auto_analysis_recompute_unit_test.py +81 -0
  32. datalab/tests/features/common/coordutils_unit_test.py +212 -0
  33. datalab/tests/features/common/result_deletion_unit_test.py +121 -1
  34. datalab/tests/features/common/roi_plotitem_unit_test.py +4 -2
  35. datalab/tests/features/common/update_tree_robustness_test.py +65 -0
  36. datalab/tests/features/control/remoteclient_unit.py +10 -0
  37. datalab/tests/features/hdf5/h5workspace_unit_test.py +133 -0
  38. datalab/tests/features/image/roigrid_unit_test.py +75 -0
  39. datalab/tests/features/macro/macroeditor_unit_test.py +104 -3
  40. datalab/tests/features/signal/custom_signal_bug_unit_test.py +96 -0
  41. datalab/widgets/imagebackground.py +13 -4
  42. datalab/widgets/instconfviewer.py +2 -2
  43. datalab/widgets/signalcursor.py +7 -2
  44. datalab/widgets/signaldeltax.py +4 -1
  45. {datalab_platform-1.0.1.dist-info → datalab_platform-1.0.3.dist-info}/METADATA +3 -3
  46. {datalab_platform-1.0.1.dist-info → datalab_platform-1.0.3.dist-info}/RECORD +50 -43
  47. {datalab_platform-1.0.1.dist-info → datalab_platform-1.0.3.dist-info}/WHEEL +0 -0
  48. {datalab_platform-1.0.1.dist-info → datalab_platform-1.0.3.dist-info}/entry_points.txt +0 -0
  49. {datalab_platform-1.0.1.dist-info → datalab_platform-1.0.3.dist-info}/licenses/LICENSE +0 -0
  50. {datalab_platform-1.0.1.dist-info → datalab_platform-1.0.3.dist-info}/top_level.txt +0 -0
datalab/objectmodel.py CHANGED
@@ -189,7 +189,7 @@ class ObjectGroup:
189
189
 
190
190
  def get_object_ids(self) -> list[str]:
191
191
  """Return object ids in group"""
192
- return self.__objects
192
+ return self.__objects.copy()
193
193
 
194
194
 
195
195
  class ObjectModel:
@@ -235,6 +235,87 @@ def test_analysis_recompute_after_recompute_1_to_1():
235
235
  print("\n✓ Recompute_1_to_1 auto-analysis test passed!")
236
236
 
237
237
 
238
+ def test_analysis_recompute_avoids_redundant_calculations():
239
+ """Test that auto-recompute doesn't cause O(n²) redundant calculations.
240
+
241
+ This test verifies that when multiple objects have ROIs modified simultaneously,
242
+ the analysis is recomputed only once per object, not once per object × number
243
+ of selected objects.
244
+
245
+ Regression test for bug: When N images were selected with statistics computed,
246
+ adding a ROI would trigger N × N = N² calculations instead of N.
247
+ """
248
+ with datalab_test_app_context(console=False) as win:
249
+ panel = win.imagepanel
250
+
251
+ # Create multiple images (N = 5 for this test)
252
+ n_images = 5
253
+ size = 100
254
+ images = []
255
+ for i in range(n_images):
256
+ param = Gauss2DParam.create(height=size, width=size, sigma=15)
257
+ img = create_image_from_param(param)
258
+ img.title = f"Test image {i + 1}"
259
+ panel.add_object(img)
260
+ images.append(img)
261
+
262
+ # Select all images
263
+ panel.objview.select_objects(images)
264
+ selected = panel.objview.get_sel_objects()
265
+ assert len(selected) == n_images, f"Should have {n_images} selected objects"
266
+
267
+ # Compute statistics on all selected images
268
+ with Conf.proc.show_result_dialog.temp(False):
269
+ panel.processor.run_feature("centroid")
270
+
271
+ # Verify all images have centroid results
272
+ for img in images:
273
+ centroid = get_centroid_coords(img)
274
+ assert centroid is not None, f"Image '{img.title}' should have centroid"
275
+
276
+ print(f"\nInitial statistics computed for {n_images} images")
277
+
278
+ # Track how many times compute_1_to_0 is called during auto-recompute
279
+ # by counting the calls via a wrapper
280
+ call_count = [0] # Use list to allow modification in closure
281
+ original_compute_1_to_0 = panel.processor.compute_1_to_0
282
+
283
+ def counting_compute_1_to_0(*args, **kwargs):
284
+ call_count[0] += 1
285
+ return original_compute_1_to_0(*args, **kwargs)
286
+
287
+ panel.processor.compute_1_to_0 = counting_compute_1_to_0
288
+
289
+ try:
290
+ # Add ROI to all selected images via edit_roi_graphically flow
291
+ # Simulating what happens when user adds ROI in the editor
292
+ roi = create_image_roi("rectangle", [25, 25, 50, 50])
293
+ for img in images:
294
+ img.roi = roi
295
+ # Simulate the auto-recompute that happens after ROI modification
296
+ panel.processor.auto_recompute_analysis(img)
297
+
298
+ # With the fix, compute_1_to_0 should be called exactly N times
299
+ # (once per object), not N² times
300
+ print(f"compute_1_to_0 was called {call_count[0]} times")
301
+ assert call_count[0] == n_images, (
302
+ f"compute_1_to_0 should be called exactly {n_images} times "
303
+ f"(once per object), but was called {call_count[0]} times. "
304
+ f"This suggests O(n²) redundant calculations."
305
+ )
306
+
307
+ # Verify the target_objs parameter is being used correctly:
308
+ # Each call should process only 1 object, not all selected objects
309
+ # This is verified by checking that the call count matches n_images
310
+
311
+ finally:
312
+ # Restore the original method
313
+ panel.processor.compute_1_to_0 = original_compute_1_to_0
314
+
315
+ print(f"\n✓ Auto-recompute correctly called {n_images} times (no O(n²) issue)")
316
+
317
+
238
318
  if __name__ == "__main__":
239
319
  test_analysis_recompute_after_roi_change()
240
320
  test_analysis_recompute_after_recompute_1_to_1()
321
+ test_analysis_recompute_avoids_redundant_calculations()
@@ -0,0 +1,212 @@
1
+ # Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
2
+
3
+ """
4
+ Coordinate utilities unit tests
5
+ """
6
+
7
+ import numpy as np
8
+ import pytest
9
+ from sigima.objects import (
10
+ CircularROI,
11
+ PolygonalROI,
12
+ RectangularROI,
13
+ SegmentROI,
14
+ create_image,
15
+ create_signal,
16
+ )
17
+
18
+ from datalab.adapters_plotpy.coordutils import (
19
+ round_image_coords,
20
+ round_image_roi_param,
21
+ round_signal_coords,
22
+ round_signal_roi_param,
23
+ )
24
+
25
+
26
+ def test_round_signal_coords():
27
+ """Test signal coordinate rounding"""
28
+ # Create a signal with sampling period of 0.1
29
+ x = np.arange(0, 10, 0.1)
30
+ y = np.sin(x)
31
+ sig = create_signal("test", x, y)
32
+
33
+ # Test basic rounding
34
+ coords = [1.23456789, 5.87654321]
35
+ rounded = round_signal_coords(sig, coords)
36
+ # With sampling period 0.1 and precision_factor 0.1, precision = 0.01
37
+ # Should round to 2 decimal places
38
+ assert rounded == [1.23, 5.88]
39
+
40
+ # Test with custom precision factor
41
+ rounded = round_signal_coords(sig, coords, precision_factor=1.0)
42
+ # precision = 0.1, should round to 1 decimal place
43
+ assert rounded == [1.2, 5.9]
44
+
45
+ # Test with signal that has too few points
46
+ sig_short = create_signal("test", np.array([1.0]), np.array([2.0]))
47
+ coords = [1.23456789]
48
+ rounded = round_signal_coords(sig_short, coords)
49
+ # Should return coords as-is
50
+ assert rounded == coords
51
+
52
+ # Test with constant x (zero sampling period)
53
+ sig_const = create_signal("test", np.ones(10), np.ones(10))
54
+ rounded = round_signal_coords(sig_const, coords)
55
+ # Should return coords as-is
56
+ assert rounded == coords
57
+
58
+
59
+ def test_round_image_coords():
60
+ """Test image coordinate rounding"""
61
+ # Create an image with dx=dy=1.0 (uniform)
62
+ data = np.ones((100, 100))
63
+ img = create_image("test", data)
64
+
65
+ # Test basic rounding
66
+ coords = [10.123456, 20.987654, 30.555555, 40.444444]
67
+ rounded = round_image_coords(img, coords)
68
+ # With pixel spacing 1.0 and precision_factor 0.1, precision = 0.1
69
+ # Should round to 1 decimal place
70
+ assert rounded == [10.1, 21.0, 30.6, 40.4]
71
+
72
+ # Test with custom precision factor
73
+ rounded = round_image_coords(img, coords, precision_factor=1.0)
74
+ # precision = 1.0, should round to 0 decimal places
75
+ assert rounded == [10.0, 21.0, 31.0, 40.0]
76
+
77
+ # Test with empty coords
78
+ assert not round_image_coords(img, [])
79
+
80
+ # Test error for odd number of coordinates
81
+ with pytest.raises(ValueError, match="even number of elements"):
82
+ round_image_coords(img, [1.0, 2.0, 3.0])
83
+
84
+
85
+ def test_round_signal_roi_param():
86
+ """Test signal ROI parameter rounding"""
87
+ # Create a signal with sampling period of 0.1
88
+ x = np.arange(0, 10, 0.1)
89
+ y = np.sin(x)
90
+ sig = create_signal("test", x, y)
91
+
92
+ # Create a segment ROI
93
+ roi = SegmentROI([1.23456789, 5.87654321], False)
94
+ param = roi.to_param(sig, 0)
95
+
96
+ # Round the parameter
97
+ round_signal_roi_param(sig, param)
98
+
99
+ # Check that coordinates are rounded
100
+ assert param.xmin == 1.23
101
+ assert param.xmax == 5.88
102
+
103
+
104
+ def test_round_image_roi_param_rectangle():
105
+ """Test image ROI parameter rounding for rectangular ROI"""
106
+ # Create an image with dx=dy=1.0
107
+ data = np.ones((100, 100))
108
+ img = create_image("test", data)
109
+
110
+ # Create a rectangular ROI with floating-point errors
111
+ roi = RectangularROI([10.0, 20.0, 50.29999999999995, 75.19999999999999], False)
112
+ param = roi.to_param(img, 0)
113
+
114
+ # Verify we have the floating-point errors before rounding
115
+ assert param.dx == 50.29999999999995
116
+ assert param.dy == 75.19999999999999
117
+
118
+ # Round the parameter
119
+ round_image_roi_param(img, param)
120
+
121
+ # Check that coordinates are rounded
122
+ assert param.x0 == 10.0
123
+ assert param.y0 == 20.0
124
+ assert param.dx == 50.3
125
+ assert param.dy == 75.2
126
+
127
+
128
+ def test_round_image_roi_param_circle():
129
+ """Test image ROI parameter rounding for circular ROI"""
130
+ # Create an image with dx=dy=1.0
131
+ data = np.ones((100, 100))
132
+ img = create_image("test", data)
133
+
134
+ # Create a circular ROI with floating-point errors
135
+ roi = CircularROI([50.123456, 50.987654, 25.555555], False)
136
+ param = roi.to_param(img, 0)
137
+
138
+ # Round the parameter
139
+ round_image_roi_param(img, param)
140
+
141
+ # Check that coordinates are rounded
142
+ assert param.xc == 50.1
143
+ assert param.yc == 51.0
144
+ assert param.r == 25.6
145
+
146
+
147
+ def test_round_image_roi_param_polygon():
148
+ """Test image ROI parameter rounding for polygonal ROI"""
149
+ # Create an image with dx=dy=1.0
150
+ data = np.ones((100, 100))
151
+ img = create_image("test", data)
152
+
153
+ # Create a polygonal ROI with floating-point errors
154
+ coords = [10.123456, 20.987654, 30.555555, 40.444444, 50.111111, 60.999999]
155
+ roi = PolygonalROI(coords, False)
156
+ param = roi.to_param(img, 0)
157
+
158
+ # Round the parameter
159
+ round_image_roi_param(img, param)
160
+
161
+ # Check that coordinates are rounded
162
+ expected = np.array([10.1, 21.0, 30.6, 40.4, 50.1, 61.0])
163
+ np.testing.assert_array_equal(param.points, expected)
164
+
165
+
166
+ def test_round_coords_non_uniform_image():
167
+ """Test coordinate rounding for non-uniform image coordinates"""
168
+ # Create an image with non-uniform coordinates
169
+ data = np.ones((10, 10))
170
+ img = create_image("test", data)
171
+ # Set non-uniform coordinates
172
+ img.xcoords = np.array([0, 1, 3, 6, 10, 15, 21, 28, 36, 45]) # varying spacing
173
+ img.ycoords = np.array([0, 2, 4, 6, 8, 10, 12, 14, 16, 18]) # uniform spacing of 2
174
+
175
+ # Test rounding - should use average spacing
176
+ coords = [5.123456, 7.987654, 25.555555, 13.444444]
177
+ rounded = round_image_coords(img, coords)
178
+
179
+ # Average dx ≈ 5.0, average dy = 2.0
180
+ # With precision_factor=0.1: precision_x=0.5, precision_y=0.2
181
+ # Should round to 1 decimal place for both
182
+ assert rounded == [5.1, 8.0, 25.6, 13.4]
183
+
184
+
185
+ def test_round_coords_preserves_structure():
186
+ """Test that coordinate rounding preserves the structure of coordinates"""
187
+ # Create an image
188
+ data = np.ones((100, 100))
189
+ img = create_image("test", data)
190
+
191
+ # Test with multiple coordinate pairs
192
+ coords = [
193
+ 10.111,
194
+ 20.222,
195
+ 30.333,
196
+ 40.444,
197
+ 50.555,
198
+ 60.666,
199
+ 70.777,
200
+ 80.888,
201
+ ]
202
+ rounded = round_image_coords(img, coords)
203
+
204
+ # Should have same length
205
+ assert len(rounded) == len(coords)
206
+
207
+ # Each coordinate should be rounded independently
208
+ assert all(isinstance(c, (int, float)) for c in rounded)
209
+
210
+
211
+ if __name__ == "__main__":
212
+ pytest.main([__file__, "-v"])
@@ -12,12 +12,13 @@ Test the deletion of analysis results from objects.
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
- from sigima.objects import Gauss2DParam, create_image_from_param
15
+ from sigima.objects import Gauss2DParam, create_image_from_param, create_image_roi
16
16
  from sigima.tests.data import create_paracetamol_signal
17
17
 
18
18
  from datalab.adapters_metadata import GeometryAdapter, TableAdapter
19
19
  from datalab.config import Conf
20
20
  from datalab.env import execenv
21
+ from datalab.gui.processor.base import extract_analysis_parameters
21
22
  from datalab.objectmodel import get_uuid
22
23
  from datalab.tests import datalab_test_app_context
23
24
 
@@ -91,6 +92,125 @@ def test_delete_results_signal():
91
92
  execenv.print(" ✓ Stats result deleted")
92
93
 
93
94
 
95
+ def test_delete_results_clears_analysis_parameters():
96
+ """Test that deleting results also clears analysis parameters.
97
+
98
+ This prevents auto_recompute_analysis from attempting to recompute
99
+ deleted analyses when ROI changes.
100
+ """
101
+ with datalab_test_app_context(console=False) as win:
102
+ execenv.print("Test delete_results clears analysis parameters:")
103
+ panel = win.imagepanel
104
+
105
+ # Create a test image
106
+ param = Gauss2DParam.create(height=200, width=200, sigma=20)
107
+ img = create_image_from_param(param)
108
+ panel.add_object(img)
109
+
110
+ # Run centroid analysis to create results and store analysis parameters
111
+ execenv.print(" Running centroid analysis...")
112
+ with Conf.proc.show_result_dialog.temp(False):
113
+ panel.processor.run_feature("centroid")
114
+
115
+ # Verify that analysis parameters exist
116
+ img_refreshed = panel.objmodel[get_uuid(img)]
117
+ analysis_params = extract_analysis_parameters(img_refreshed)
118
+ assert analysis_params is not None, (
119
+ "Analysis parameters should exist after running centroid"
120
+ )
121
+ assert analysis_params.func_name == "centroid", (
122
+ "Analysis parameters should store the centroid function name"
123
+ )
124
+ execenv.print(" ✓ Analysis parameters stored")
125
+
126
+ # Delete all results
127
+ execenv.print(" Deleting all results...")
128
+ panel.objview.select_objects([get_uuid(img)])
129
+ panel.delete_results()
130
+
131
+ # Verify that analysis parameters were also cleared
132
+ img_after = panel.objmodel[get_uuid(img)]
133
+ analysis_params_after = extract_analysis_parameters(img_after)
134
+ assert analysis_params_after is None, (
135
+ "Analysis parameters should be cleared after deleting results"
136
+ )
137
+ execenv.print(" ✓ Analysis parameters cleared")
138
+
139
+ # Now add a ROI and verify no auto-recompute happens (no new results)
140
+ execenv.print(" Adding ROI to verify no auto-recompute...")
141
+ roi = create_image_roi("rectangle", [25, 25, 100, 100])
142
+ img_after.roi = roi
143
+ panel.processor.auto_recompute_analysis(img_after)
144
+
145
+ # Verify that no new results were created
146
+ adapter_after_roi = GeometryAdapter.from_obj(img_after, "centroid")
147
+ assert adapter_after_roi is None, (
148
+ "No centroid result should be created after ROI change "
149
+ "because analysis parameters were cleared"
150
+ )
151
+ execenv.print(
152
+ " ✓ No auto-recompute after ROI change (analysis params cleared)"
153
+ )
154
+ execenv.print("\n✓ All tests passed!")
155
+
156
+
157
+ def test_delete_results_after_roi_removed():
158
+ """Test that deleting results works when ROI was removed from object.
159
+
160
+ This tests the fix for the bug where deleting results fails with
161
+ AttributeError when results contain ROI information but the ROI
162
+ was subsequently removed from the object.
163
+ """
164
+ with datalab_test_app_context(console=False) as win:
165
+ execenv.print("Test delete_results after ROI removed:")
166
+ panel = win.imagepanel
167
+
168
+ # Create a test image with ROI
169
+ param = Gauss2DParam.create(height=200, width=200, sigma=20)
170
+ img = create_image_from_param(param)
171
+ roi = create_image_roi("rectangle", [25, 25, 100, 100])
172
+ img.roi = roi
173
+ panel.add_object(img)
174
+ execenv.print(" ✓ Created image with ROI")
175
+
176
+ # Run centroid analysis - this stores ROI index in results
177
+ execenv.print(" Running centroid analysis with ROI...")
178
+ with Conf.proc.show_result_dialog.temp(False):
179
+ panel.processor.run_feature("centroid")
180
+
181
+ # Verify that results exist and contain ROI information
182
+ img_refreshed = panel.objmodel[get_uuid(img)]
183
+ adapter_before = GeometryAdapter.from_obj(img_refreshed, "centroid")
184
+ assert adapter_before is not None, "Centroid result should exist"
185
+ df = adapter_before.to_dataframe()
186
+ assert "roi_index" in df.columns, "Results should contain roi_index"
187
+ execenv.print(" ✓ Centroid result created with ROI information")
188
+
189
+ # Now remove the ROI from the object (simulating user action)
190
+ img_refreshed.roi = None
191
+ execenv.print(" ✓ Removed ROI from object")
192
+
193
+ # Try to delete all results - this should NOT raise an error
194
+ execenv.print(" Deleting all results (with ROI removed)...")
195
+ panel.objview.select_objects([get_uuid(img)])
196
+ try:
197
+ panel.delete_results()
198
+ execenv.print(" ✓ Delete results succeeded (no AttributeError)")
199
+ except AttributeError as e:
200
+ raise AssertionError(
201
+ f"delete_results should not raise AttributeError when ROI is None: {e}"
202
+ ) from e
203
+
204
+ # Verify that results were deleted
205
+ img_after = panel.objmodel[get_uuid(img)]
206
+ adapter_after = GeometryAdapter.from_obj(img_after, "centroid")
207
+ assert adapter_after is None, "Centroid result should be deleted"
208
+ execenv.print(" ✓ Centroid result deleted successfully")
209
+ execenv.print("\n✓ Test passed!")
210
+
211
+
94
212
  if __name__ == "__main__":
95
213
  test_delete_results_image()
96
214
  test_delete_results_signal()
215
+ test_delete_results_clears_analysis_parameters()
216
+ test_delete_results_after_roi_removed()
@@ -32,11 +32,13 @@ def __conversion_methods(
32
32
  single_roi = roi.get_single_roi(0)
33
33
  with qt_app_context(exec_loop=False):
34
34
  plot_item = create_adapter_from_object(single_roi).to_plot_item(obj)
35
- sroi_new = plotitem_to_singleroi(plot_item)
35
+ sroi_new = plotitem_to_singleroi(plot_item, obj)
36
36
  orig_coords = [float(val) for val in single_roi.get_physical_coords(obj)]
37
37
  new_coords = [float(val) for val in sroi_new.get_physical_coords(obj)]
38
38
  execenv.print(f"{orig_coords} --> {new_coords}")
39
- assert np.array_equal(orig_coords, new_coords)
39
+ # Check that coordinates are close
40
+ # (allowing for rounding applied during conversion)
41
+ assert np.allclose(orig_coords, new_coords, rtol=1e-5, atol=1e-10)
40
42
 
41
43
 
42
44
  def test_signal_roi_plotitem_conversion() -> None:
@@ -0,0 +1,65 @@
1
+ # Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
2
+
3
+ """
4
+ Update tree robustness test
5
+ ---------------------------
6
+
7
+ This test verifies that the object tree view handles edge cases gracefully,
8
+ specifically when the tree becomes out of sync with the model.
9
+
10
+ This is a regression test for a bug where update_tree() crashed with
11
+ AttributeError: 'NoneType' object has no attribute 'setText' when an
12
+ object existed in the model but had no corresponding tree item.
13
+
14
+ The fix makes update_tree() defensive by calling populate_tree() if
15
+ an item is not found.
16
+ """
17
+
18
+ # pylint: disable=invalid-name # Allows short reference names like x, y, ...
19
+ # guitest: show
20
+
21
+ import numpy as np
22
+ import sigima.objects
23
+
24
+ from datalab.tests import datalab_test_app_context
25
+
26
+
27
+ def test_update_tree_with_model_tree_desync():
28
+ """Test that update_tree handles model/tree desynchronization gracefully.
29
+
30
+ This test simulates a scenario where an object exists in the model
31
+ but doesn't have a corresponding tree item, which previously caused
32
+ an AttributeError in update_tree().
33
+
34
+ The fix ensures update_tree() detects this and calls populate_tree()
35
+ to restore consistency.
36
+ """
37
+ with datalab_test_app_context(console=False) as win:
38
+ win.set_current_panel("image")
39
+
40
+ # Create and add a base image
41
+ data = np.random.rand(100, 100).astype(np.float64)
42
+ img1 = sigima.objects.create_image("Base Image", data)
43
+ win.imagepanel.add_object(img1)
44
+
45
+ # Create a second image and add it to the MODEL only (not to the tree)
46
+ # This simulates a desync between model and tree
47
+ data2 = np.random.rand(100, 100).astype(np.float64)
48
+ img2 = sigima.objects.create_image("Second Image", data2)
49
+
50
+ # Get current group ID
51
+ group_id = win.imagepanel.objview.get_current_group_id()
52
+
53
+ # Add to model ONLY (bypassing add_object_item which adds to tree)
54
+ win.imagepanel.objmodel.add_object(img2, group_id)
55
+
56
+ # Now the model has 2 objects but the tree only shows 1
57
+ # Calling update_tree() should NOT crash
58
+ # With the fix, it should call populate_tree() to resync
59
+
60
+ # This is the call that previously caused:
61
+ # AttributeError: 'NoneType' object has no attribute 'setText'
62
+ win.imagepanel.objview.update_tree()
63
+
64
+ # Verify objects are now in sync
65
+ assert len(win.imagepanel) == 2
@@ -48,6 +48,16 @@ def multiple_commands(remote: RemoteProxy):
48
48
  remote.reset_all()
49
49
  remote.open_h5_files([fname], True, False)
50
50
  remote.import_h5_file(fname, True)
51
+
52
+ # Test new headless workspace API methods (Issue #275)
53
+ fname_workspace = osp.join(tmpdir, "workspace_test.h5")
54
+ remote.save_h5_workspace(fname_workspace)
55
+ assert osp.exists(fname_workspace), "Workspace file was not created"
56
+ remote.reset_all()
57
+ remote.load_h5_workspace([fname_workspace], reset_all=True)
58
+ # Verify objects were restored
59
+ assert len(remote.get_object_titles()) > 0, "No objects after load_h5_workspace"
60
+
51
61
  remote.set_current_panel("signal")
52
62
  assert remote.get_current_panel() == "signal"
53
63
  remote.calc("log10")
@@ -0,0 +1,133 @@
1
+ # Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
2
+
3
+ """
4
+ HDF5 workspace API unit tests
5
+ -----------------------------
6
+
7
+ Tests for the headless HDF5 workspace API methods:
8
+ - load_h5_workspace: Load native DataLab HDF5 files without GUI elements
9
+ - save_h5_workspace: Save workspace to native DataLab HDF5 file without GUI elements
10
+
11
+ These methods are designed for use from the internal console where Qt GUI elements
12
+ would cause thread-safety issues.
13
+ """
14
+
15
+ # guitest: show
16
+
17
+ import os.path as osp
18
+
19
+ import h5py
20
+ import pytest
21
+ from sigima.tests.data import create_noisy_gaussian_image, create_paracetamol_signal
22
+
23
+ from datalab.tests import datalab_test_app_context, helpers
24
+
25
+
26
+ def test_save_and_load_h5_workspace():
27
+ """Test save_h5_workspace and load_h5_workspace methods"""
28
+ with helpers.WorkdirRestoringTempDir() as tmpdir:
29
+ with datalab_test_app_context(console=False) as win:
30
+ # === Create test objects
31
+ sig1 = create_paracetamol_signal()
32
+ win.signalpanel.add_object(sig1)
33
+
34
+ ima1 = create_noisy_gaussian_image()
35
+ win.imagepanel.add_object(ima1)
36
+
37
+ # Store object counts and titles for verification
38
+ sig_count_before = len(win.signalpanel.objmodel)
39
+ ima_count_before = len(win.imagepanel.objmodel)
40
+ sig_title = sig1.title
41
+ ima_title = ima1.title
42
+
43
+ # === Test save_h5_workspace
44
+ fname = osp.join(tmpdir, "test_workspace.h5")
45
+ win.save_h5_workspace(fname)
46
+ assert osp.exists(fname), "HDF5 file was not created"
47
+
48
+ # === Clear workspace
49
+ for panel in win.panels:
50
+ panel.remove_all_objects()
51
+
52
+ assert len(win.signalpanel.objmodel) == 0
53
+ assert len(win.imagepanel.objmodel) == 0
54
+
55
+ # === Test load_h5_workspace
56
+ win.load_h5_workspace([fname], reset_all=True)
57
+
58
+ # Verify objects were restored
59
+ assert len(win.signalpanel.objmodel) == sig_count_before
60
+ assert len(win.imagepanel.objmodel) == ima_count_before
61
+
62
+ # Verify titles (get objects in order from groups)
63
+ loaded_sig = win.signalpanel.objmodel.get_all_objects()[0]
64
+ loaded_ima = win.imagepanel.objmodel.get_all_objects()[0]
65
+ assert loaded_sig.title == sig_title
66
+ assert loaded_ima.title == ima_title
67
+
68
+
69
+ def test_load_h5_workspace_invalid_file():
70
+ """Test load_h5_workspace raises ValueError for non-native HDF5 files"""
71
+ with helpers.WorkdirRestoringTempDir() as tmpdir:
72
+ with datalab_test_app_context(console=False) as win:
73
+ # Create a non-native HDF5 file (just an empty HDF5)
74
+ fname = osp.join(tmpdir, "not_native.h5")
75
+ with h5py.File(fname, "w") as f:
76
+ f.create_dataset("dummy", data=[1, 2, 3])
77
+
78
+ # Should raise ValueError for non-native file
79
+ with pytest.raises(ValueError, match="not a native DataLab HDF5 file"):
80
+ win.load_h5_workspace([fname])
81
+
82
+
83
+ def test_load_h5_workspace_append():
84
+ """Test load_h5_workspace with reset_all=False (append mode)"""
85
+ with helpers.WorkdirRestoringTempDir() as tmpdir:
86
+ with datalab_test_app_context(console=False) as win:
87
+ # Create and save first signal
88
+ sig1 = create_paracetamol_signal()
89
+ sig1.title = "Signal 1"
90
+ win.signalpanel.add_object(sig1)
91
+
92
+ fname = osp.join(tmpdir, "workspace1.h5")
93
+ win.save_h5_workspace(fname)
94
+
95
+ # Clear and create second signal
96
+ win.signalpanel.remove_all_objects()
97
+ sig2 = create_paracetamol_signal()
98
+ sig2.title = "Signal 2"
99
+ win.signalpanel.add_object(sig2)
100
+
101
+ assert len(win.signalpanel.objmodel) == 1
102
+
103
+ # Load first workspace with reset_all=False (append)
104
+ win.load_h5_workspace([fname], reset_all=False)
105
+
106
+ # Should now have both signals (appended)
107
+ assert len(win.signalpanel.objmodel) == 2
108
+
109
+
110
+ def test_save_h5_workspace_modified_flag():
111
+ """Test that save_h5_workspace clears the modified flag"""
112
+ with helpers.WorkdirRestoringTempDir() as tmpdir:
113
+ with datalab_test_app_context(console=False) as win:
114
+ # Create an object (this sets modified flag)
115
+ sig1 = create_paracetamol_signal()
116
+ win.signalpanel.add_object(sig1)
117
+
118
+ # Workspace should be modified
119
+ assert win.is_modified()
120
+
121
+ # Save workspace
122
+ fname = osp.join(tmpdir, "test.h5")
123
+ win.save_h5_workspace(fname)
124
+
125
+ # Modified flag should be cleared
126
+ assert not win.is_modified()
127
+
128
+
129
+ if __name__ == "__main__":
130
+ test_save_and_load_h5_workspace()
131
+ test_load_h5_workspace_invalid_file()
132
+ test_load_h5_workspace_append()
133
+ test_save_h5_workspace_modified_flag()