datalab-platform 1.0.4__py3-none-any.whl → 1.1.0__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 (45) hide show
  1. datalab/__init__.py +1 -1
  2. datalab/config.py +4 -0
  3. datalab/control/baseproxy.py +160 -0
  4. datalab/control/remote.py +175 -1
  5. datalab/data/doc/DataLab_en.pdf +0 -0
  6. datalab/data/doc/DataLab_fr.pdf +0 -0
  7. datalab/data/icons/control/copy_connection_info.svg +11 -0
  8. datalab/data/icons/control/start_webapi_server.svg +19 -0
  9. datalab/data/icons/control/stop_webapi_server.svg +7 -0
  10. datalab/gui/main.py +221 -2
  11. datalab/gui/settings.py +10 -0
  12. datalab/gui/tour.py +2 -3
  13. datalab/locale/fr/LC_MESSAGES/datalab.mo +0 -0
  14. datalab/locale/fr/LC_MESSAGES/datalab.po +87 -1
  15. datalab/tests/__init__.py +32 -1
  16. datalab/tests/backbone/config_unit_test.py +1 -1
  17. datalab/tests/backbone/main_app_test.py +4 -0
  18. datalab/tests/backbone/memory_leak.py +1 -1
  19. datalab/tests/features/common/createobject_unit_test.py +1 -1
  20. datalab/tests/features/common/misc_app_test.py +5 -0
  21. datalab/tests/features/control/call_method_unit_test.py +104 -0
  22. datalab/tests/features/control/embedded1_unit_test.py +8 -0
  23. datalab/tests/features/control/remoteclient_app_test.py +39 -35
  24. datalab/tests/features/control/simpleclient_unit_test.py +7 -3
  25. datalab/tests/features/hdf5/h5browser2_unit.py +1 -1
  26. datalab/tests/features/image/background_dialog_test.py +2 -2
  27. datalab/tests/features/image/imagetools_unit_test.py +1 -1
  28. datalab/tests/features/signal/baseline_dialog_test.py +1 -1
  29. datalab/tests/webapi_test.py +395 -0
  30. datalab/webapi/__init__.py +95 -0
  31. datalab/webapi/actions.py +318 -0
  32. datalab/webapi/adapter.py +642 -0
  33. datalab/webapi/controller.py +379 -0
  34. datalab/webapi/routes.py +576 -0
  35. datalab/webapi/schema.py +198 -0
  36. datalab/webapi/serialization.py +388 -0
  37. datalab/widgets/status.py +61 -0
  38. {datalab_platform-1.0.4.dist-info → datalab_platform-1.1.0.dist-info}/METADATA +6 -2
  39. {datalab_platform-1.0.4.dist-info → datalab_platform-1.1.0.dist-info}/RECORD +45 -33
  40. /datalab/data/icons/{libre-gui-link.svg → control/libre-gui-link.svg} +0 -0
  41. /datalab/data/icons/{libre-gui-unlink.svg → control/libre-gui-unlink.svg} +0 -0
  42. {datalab_platform-1.0.4.dist-info → datalab_platform-1.1.0.dist-info}/WHEEL +0 -0
  43. {datalab_platform-1.0.4.dist-info → datalab_platform-1.1.0.dist-info}/entry_points.txt +0 -0
  44. {datalab_platform-1.0.4.dist-info → datalab_platform-1.1.0.dist-info}/licenses/LICENSE +0 -0
  45. {datalab_platform-1.0.4.dist-info → datalab_platform-1.1.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,642 @@
1
+ # Copyright (c) DataLab Platform Developers, BSD 3-Clause License
2
+ # See LICENSE file for details
3
+
4
+ """
5
+ Web API Adapter
6
+ ===============
7
+
8
+ Thread-safe adapter for accessing the DataLab workspace from Web API handlers.
9
+
10
+ The Web API server runs in a separate thread from the Qt GUI. This adapter
11
+ provides safe access to workspace operations by using Qt's signal/slot mechanism
12
+ to marshal calls to the main thread when necessary.
13
+
14
+ Design
15
+ ------
16
+
17
+ The adapter provides the same interface as the workspace but ensures:
18
+
19
+ 1. Read operations are safe (DataLab's data model is mostly immutable)
20
+ 2. Write operations are marshaled to the Qt main thread
21
+ 3. Errors are properly propagated back to the calling thread
22
+
23
+ Usage
24
+ -----
25
+
26
+ The adapter is instantiated once when the Web API starts and passed to route
27
+ handlers via FastAPI dependency injection.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import threading
33
+ from typing import TYPE_CHECKING, Any, Union
34
+
35
+ from qtpy.QtCore import QCoreApplication, QObject, QThread, Signal, Slot
36
+
37
+ from datalab.objectmodel import get_uuid
38
+
39
+ if TYPE_CHECKING:
40
+ from sigima.objects import ImageObj, SignalObj
41
+
42
+ DataObject = Union[SignalObj, ImageObj]
43
+
44
+
45
+ class MainThreadExecutor(QObject):
46
+ """Helper to execute functions on the main thread using Qt signals.
47
+
48
+ This class uses Qt's signal/slot mechanism to safely marshal function calls
49
+ from worker threads (like the Uvicorn server thread) to the Qt main thread.
50
+ This is essential because Qt GUI operations must be performed on the main
51
+ thread.
52
+
53
+ The implementation uses a signal connected with Qt.QueuedConnection to post
54
+ work to the main thread's event loop, and a threading.Event to synchronize
55
+ the calling thread with the result.
56
+
57
+ Important: This class must be instantiated on the main thread!
58
+ """
59
+
60
+ # Signal to request execution on main thread
61
+ _execute_signal = Signal(object, object) # (func, result_holder)
62
+
63
+ def __init__(self) -> None:
64
+ super().__init__()
65
+ # Connect signal to slot - this connection will queue calls to main thread
66
+ self._execute_signal.connect(self._execute_on_main_thread)
67
+ # Store the main thread for comparison
68
+ self._main_thread = QThread.currentThread()
69
+
70
+ @Slot(object, object)
71
+ def _execute_on_main_thread(self, func, result_holder: dict) -> None:
72
+ """Slot that executes the function on the main thread.
73
+
74
+ Args:
75
+ func: Zero-argument callable to execute.
76
+ result_holder: Dict to store result/exception and signal completion.
77
+ """
78
+ try:
79
+ result_holder["result"] = func()
80
+ result_holder["exception"] = None
81
+ except Exception as e: # pylint: disable=broad-exception-caught
82
+ result_holder["result"] = None
83
+ result_holder["exception"] = e
84
+ finally:
85
+ # Signal that execution is complete
86
+ result_holder["event"].set()
87
+
88
+ def run_on_main_thread(self, func) -> Any:
89
+ """Run a function on the Qt main thread and wait for the result.
90
+
91
+ If already on the main thread, executes directly. Otherwise, uses
92
+ Qt's signal/slot mechanism to marshal the call to the main thread.
93
+
94
+ Args:
95
+ func: Zero-argument callable to execute.
96
+
97
+ Returns:
98
+ The result of the function.
99
+
100
+ Raises:
101
+ Any exception raised by the function.
102
+ """
103
+ # Check if we're already on the main thread
104
+ if QThread.currentThread() == self._main_thread:
105
+ return func()
106
+
107
+ # Create result holder with synchronization event
108
+ result_holder = {
109
+ "result": None,
110
+ "exception": None,
111
+ "event": threading.Event(),
112
+ }
113
+
114
+ # Emit signal to queue execution on main thread
115
+ self._execute_signal.emit(func, result_holder)
116
+
117
+ # Wait for execution to complete
118
+ # Use a timeout to avoid hanging forever if something goes wrong
119
+ if not result_holder["event"].wait(timeout=30.0):
120
+ raise TimeoutError("Main thread execution timed out after 30 seconds")
121
+
122
+ # Process pending events to ensure UI updates are applied
123
+ QCoreApplication.processEvents()
124
+
125
+ # Re-raise any exception from the main thread
126
+ if result_holder["exception"] is not None:
127
+ raise result_holder["exception"] # pylint: disable=raising-bad-type
128
+
129
+ return result_holder["result"]
130
+
131
+
132
+ class WorkspaceAdapter(QObject):
133
+ """Thread-safe adapter for workspace access.
134
+
135
+ This class wraps access to DataLab's workspace, ensuring that all
136
+ modifying operations are executed on the Qt main thread.
137
+
138
+ Attributes:
139
+ main_window: Reference to the DataLab main window.
140
+ """
141
+
142
+ def __init__(self, main_window=None) -> None:
143
+ """Initialize the adapter.
144
+
145
+ This should be called from the main thread to ensure the executor
146
+ is properly initialized with working Qt signals.
147
+
148
+ Args:
149
+ main_window: The DataLab main window. If None, operations will fail.
150
+ """
151
+ super().__init__()
152
+ self._main_window = main_window
153
+ # Create executor on main thread to ensure proper Qt signal connection
154
+ self._executor = MainThreadExecutor()
155
+
156
+ def set_main_window(self, main_window) -> None:
157
+ """Set the main window reference.
158
+
159
+ Args:
160
+ main_window: The DataLab main window.
161
+ """
162
+ self._main_window = main_window
163
+
164
+ def _ensure_main_window(self) -> None:
165
+ """Ensure main window is available."""
166
+ if self._main_window is None:
167
+ raise RuntimeError("DataLab main window not available")
168
+
169
+ # =========================================================================
170
+ # Read operations (marshaled to Qt main thread for thread safety)
171
+ # =========================================================================
172
+
173
+ def list_objects(self) -> list[tuple[str, str]]:
174
+ """List all objects in the workspace.
175
+
176
+ This operation is marshaled to the Qt main thread for thread safety.
177
+
178
+ Returns:
179
+ List of (name, panel) tuples for all objects.
180
+ """
181
+ self._ensure_main_window()
182
+
183
+ def do_list():
184
+ result = []
185
+ # Access signal panel
186
+ sig_panel = self._main_window.signalpanel
187
+ if sig_panel is not None:
188
+ for obj in sig_panel.objmodel:
189
+ result.append((obj.title, "signal"))
190
+
191
+ # Access image panel
192
+ img_panel = self._main_window.imagepanel
193
+ if img_panel is not None:
194
+ for obj in img_panel.objmodel:
195
+ result.append((obj.title, "image"))
196
+ return result
197
+
198
+ return self._executor.run_on_main_thread(do_list)
199
+
200
+ def get_object(self, name: str) -> DataObject:
201
+ """Get an object by name.
202
+
203
+ This operation is marshaled to the Qt main thread for thread safety.
204
+
205
+ Args:
206
+ name: Object name/title.
207
+
208
+ Returns:
209
+ The requested object.
210
+
211
+ Raises:
212
+ KeyError: If object not found.
213
+ """
214
+ self._ensure_main_window()
215
+
216
+ def do_get():
217
+ # Search in signal panel
218
+ sig_panel = self._main_window.signalpanel
219
+ if sig_panel is not None:
220
+ for obj in sig_panel.objmodel:
221
+ if obj.title == name:
222
+ return obj.copy()
223
+
224
+ # Search in image panel
225
+ img_panel = self._main_window.imagepanel
226
+ if img_panel is not None:
227
+ for obj in img_panel.objmodel:
228
+ if obj.title == name:
229
+ return obj.copy()
230
+
231
+ raise KeyError(f"Object '{name}' not found")
232
+
233
+ return self._executor.run_on_main_thread(do_get)
234
+
235
+ def object_exists(self, name: str) -> bool:
236
+ """Check if an object exists.
237
+
238
+ Args:
239
+ name: Object name/title.
240
+
241
+ Returns:
242
+ True if object exists.
243
+ """
244
+ try:
245
+ self.get_object(name)
246
+ return True
247
+ except KeyError:
248
+ return False
249
+
250
+ def get_object_panel(self, name: str) -> str | None:
251
+ """Get the panel containing an object.
252
+
253
+ This operation is marshaled to the Qt main thread for thread safety.
254
+
255
+ Args:
256
+ name: Object name/title.
257
+
258
+ Returns:
259
+ "signal" or "image", or None if not found.
260
+ """
261
+ self._ensure_main_window()
262
+
263
+ def do_lookup():
264
+ sig_panel = self._main_window.signalpanel
265
+ if sig_panel is not None:
266
+ for obj in sig_panel.objmodel:
267
+ if obj.title == name:
268
+ return "signal"
269
+
270
+ img_panel = self._main_window.imagepanel
271
+ if img_panel is not None:
272
+ for obj in img_panel.objmodel:
273
+ if obj.title == name:
274
+ return "image"
275
+
276
+ return None
277
+
278
+ return self._executor.run_on_main_thread(do_lookup)
279
+
280
+ # =========================================================================
281
+ # Write operations (must be marshaled to Qt main thread)
282
+ # =========================================================================
283
+
284
+ def add_object(self, obj: DataObject, overwrite: bool = False) -> None:
285
+ """Add an object to the workspace.
286
+
287
+ This operation is marshaled to the Qt main thread as a single atomic
288
+ operation to ensure thread safety.
289
+
290
+ Args:
291
+ obj: Object to add.
292
+ overwrite: If True, replace existing object with same name.
293
+
294
+ Raises:
295
+ ValueError: If object exists and overwrite is False.
296
+ """
297
+ self._ensure_main_window()
298
+ name = obj.title
299
+ obj_type = type(obj).__name__
300
+
301
+ if obj_type not in ("SignalObj", "ImageObj"):
302
+ raise TypeError(f"Unsupported object type: {obj_type}")
303
+
304
+ # Check if object exists
305
+ if self.object_exists(name):
306
+ if not overwrite:
307
+ raise ValueError(f"Object '{name}' already exists")
308
+ # Remove existing object first using the working remove method
309
+ self._remove_object_sync(name)
310
+
311
+ # Add the new object
312
+ self._add_object_sync(obj)
313
+
314
+ def _add_object_sync(self, obj: DataObject) -> None:
315
+ """Add object (called from main thread or marshaled via executor)."""
316
+ obj_type = type(obj).__name__
317
+
318
+ if obj_type == "SignalObj":
319
+ panel = self._main_window.signalpanel
320
+ elif obj_type == "ImageObj":
321
+ panel = self._main_window.imagepanel
322
+ else:
323
+ raise TypeError(f"Unsupported object type: {obj_type}")
324
+
325
+ # Use executor to run on main thread if necessary
326
+ self._executor.run_on_main_thread(lambda: panel.add_object(obj))
327
+
328
+ def remove_object(self, name: str) -> None:
329
+ """Remove an object from the workspace.
330
+
331
+ This operation is marshaled to the Qt main thread.
332
+
333
+ Args:
334
+ name: Object name/title.
335
+
336
+ Raises:
337
+ KeyError: If object not found.
338
+ """
339
+ self._ensure_main_window()
340
+
341
+ if not self.object_exists(name):
342
+ raise KeyError(f"Object '{name}' not found")
343
+
344
+ self._remove_object_sync(name)
345
+
346
+ def _remove_object_sync(self, name: str) -> None:
347
+ """Remove object (called from main thread or marshaled via executor)."""
348
+ panel_name = self.get_object_panel(name)
349
+ if panel_name is None:
350
+ return
351
+
352
+ main_window = self._main_window
353
+
354
+ # Use executor to run on main thread, including all panel access
355
+ def do_remove():
356
+ # All panel access happens inside the executor
357
+ if panel_name == "signal":
358
+ panel = main_window.signalpanel
359
+ else:
360
+ panel = main_window.imagepanel
361
+
362
+ # Find the object
363
+ target_obj = None
364
+ for obj in panel.objmodel:
365
+ if obj.title == name:
366
+ target_obj = obj
367
+ break
368
+
369
+ if target_obj is None:
370
+ return
371
+
372
+ obj_uuid = get_uuid(target_obj)
373
+
374
+ # Remove using the same approach as remove_all_objects but for single object
375
+ # Remove from plot handler
376
+ panel.plothandler.remove_item(obj_uuid)
377
+ # Remove from tree view
378
+ panel.objview.remove_item(obj_uuid, refresh=False)
379
+ # Remove from object model
380
+ panel.objmodel.remove_object(target_obj)
381
+ # Update tree
382
+ panel.objview.update_tree()
383
+ # Emit signal
384
+ panel.SIG_OBJECT_REMOVED.emit()
385
+
386
+ self._executor.run_on_main_thread(do_remove)
387
+
388
+ def update_metadata(self, name: str, metadata: dict) -> None:
389
+ """Update object metadata.
390
+
391
+ This operation modifies Qt objects and should be marshaled to the
392
+ Qt main thread for thread safety.
393
+
394
+ Args:
395
+ name: Object name/title.
396
+ metadata: Dictionary of metadata fields to update.
397
+
398
+ Raises:
399
+ KeyError: If object not found.
400
+ """
401
+ self._ensure_main_window()
402
+
403
+ panel_name = self.get_object_panel(name)
404
+ if panel_name is None:
405
+ raise KeyError(f"Object '{name}' not found")
406
+
407
+ if panel_name == "signal":
408
+ panel = self._main_window.signalpanel
409
+ else:
410
+ panel = self._main_window.imagepanel
411
+
412
+ def do_update():
413
+ # Find and update object
414
+ for obj in panel.objmodel:
415
+ if obj.title == name:
416
+ for key, value in metadata.items():
417
+ if value is not None and hasattr(obj, key):
418
+ setattr(obj, key, value)
419
+ # Refresh display
420
+ panel.SIG_REFRESH_PLOT.emit("selected", True)
421
+ break
422
+
423
+ self._executor.run_on_main_thread(do_update)
424
+
425
+ def clear(self) -> None:
426
+ """Clear all objects from the workspace.
427
+
428
+ This operation is marshaled to the Qt main thread.
429
+ """
430
+ self._ensure_main_window()
431
+
432
+ def do_clear():
433
+ # Clear both panels using remove_all_objects (no confirmation dialog)
434
+ for panel in [self._main_window.signalpanel, self._main_window.imagepanel]:
435
+ if panel is not None:
436
+ panel.remove_all_objects()
437
+
438
+ self._executor.run_on_main_thread(do_clear)
439
+
440
+ # =========================================================================
441
+ # Computation operations (for calc API)
442
+ # =========================================================================
443
+
444
+ def select_objects(
445
+ self, names: list[str], panel: str | None = None
446
+ ) -> tuple[list[str], str]:
447
+ """Select objects by name in a panel.
448
+
449
+ This operation is marshaled to the Qt main thread for thread safety.
450
+
451
+ Args:
452
+ names: List of object names/titles to select.
453
+ panel: Panel name ("signal" or "image"). None = auto-detect or current.
454
+
455
+ Returns:
456
+ Tuple of (list of selected names, panel name).
457
+
458
+ Raises:
459
+ KeyError: If any object not found.
460
+ ValueError: If objects span multiple panels.
461
+ """
462
+ self._ensure_main_window()
463
+
464
+ def do_select():
465
+ # Determine panel for each object
466
+ panels_found = set()
467
+ obj_indices = []
468
+
469
+ for name in names:
470
+ obj_panel = self.get_object_panel(name)
471
+ if obj_panel is None:
472
+ raise KeyError(f"Object '{name}' not found")
473
+ panels_found.add(obj_panel)
474
+
475
+ if len(panels_found) > 1:
476
+ raise ValueError(
477
+ "Cannot select objects from multiple panels. "
478
+ f"Found objects in: {panels_found}"
479
+ )
480
+
481
+ if panel is not None:
482
+ target_panel = panel
483
+ elif panels_found:
484
+ target_panel = panels_found.pop()
485
+ else:
486
+ target_panel = "signal"
487
+
488
+ # Get the panel widget
489
+ if target_panel == "signal":
490
+ panel_widget = self._main_window.signalpanel
491
+ else:
492
+ panel_widget = self._main_window.imagepanel
493
+
494
+ # Find object indices (1-based) by name
495
+ for name in names:
496
+ for idx, obj in enumerate(panel_widget.objmodel):
497
+ if obj.title == name:
498
+ obj_indices.append(idx + 1) # 1-based indexing
499
+ break
500
+
501
+ # Select the objects using the panel's method
502
+ if obj_indices:
503
+ panel_widget.objview.select_objects(obj_indices)
504
+
505
+ return names, target_panel
506
+
507
+ return self._executor.run_on_main_thread(do_select)
508
+
509
+ def get_selected_objects(self, panel: str | None = None) -> list[str]:
510
+ """Get names of currently selected objects.
511
+
512
+ Args:
513
+ panel: Panel name. None = current panel.
514
+
515
+ Returns:
516
+ List of selected object names.
517
+ """
518
+ self._ensure_main_window()
519
+
520
+ def do_get_selected():
521
+ if panel == "signal":
522
+ panel_widget = self._main_window.signalpanel
523
+ elif panel == "image":
524
+ panel_widget = self._main_window.imagepanel
525
+ else:
526
+ # Use current panel
527
+ panel_widget = self._main_window.tabwidget.currentWidget()
528
+ if not hasattr(panel_widget, "objmodel"):
529
+ return []
530
+
531
+ return [obj.title for obj in panel_widget.objview.get_sel_objects()]
532
+
533
+ return self._executor.run_on_main_thread(do_get_selected)
534
+
535
+ def calc(self, name: str, param: dict | None = None) -> tuple[bool, list[str]]:
536
+ """Call a computation function on currently selected objects.
537
+
538
+ This operation is marshaled to the Qt main thread for thread safety.
539
+
540
+ Args:
541
+ name: Computation function name (e.g., "normalize", "fft").
542
+ param: Optional parameters as a dictionary.
543
+
544
+ Returns:
545
+ Tuple of (success, list of new object names created).
546
+
547
+ Raises:
548
+ ValueError: If computation function not found.
549
+ """
550
+ self._ensure_main_window()
551
+
552
+ def do_calc():
553
+ # Get objects before calc to track new ones
554
+ before_names = set(self._get_all_object_names())
555
+
556
+ # Convert param dict to DataSet if provided
557
+ param_dataset = None
558
+ if param is not None:
559
+ param_dataset = self._dict_to_dataset(name, param)
560
+
561
+ # Call the main window's calc method with edit=False to prevent
562
+ # blocking modal dialogs when called from the API
563
+ try:
564
+ self._main_window.calc(name, param_dataset, edit=False)
565
+ success = True
566
+ except ValueError:
567
+ raise
568
+ except Exception as e: # pylint: disable=broad-exception-caught
569
+ raise RuntimeError(f"Computation '{name}' failed: {e}") from e
570
+
571
+ # Get objects after calc to find new ones
572
+ after_names = set(self._get_all_object_names())
573
+ new_names = list(after_names - before_names)
574
+
575
+ return success, new_names
576
+
577
+ return self._executor.run_on_main_thread(do_calc)
578
+
579
+ def _get_all_object_names(self) -> list[str]:
580
+ """Get all object names from all panels."""
581
+ names = []
582
+ for panel in [self._main_window.signalpanel, self._main_window.imagepanel]:
583
+ if panel is not None:
584
+ for obj in panel.objmodel:
585
+ names.append(obj.title)
586
+ return names
587
+
588
+ def _dict_to_dataset(self, func_name: str, param_dict: dict):
589
+ """Convert a parameter dictionary to a DataSet object.
590
+
591
+ This looks up the parameter class for the given function and
592
+ creates an instance with the provided values.
593
+
594
+ Args:
595
+ func_name: Computation function name.
596
+ param_dict: Dictionary of parameter values.
597
+
598
+ Returns:
599
+ DataSet instance, or None if no parameters needed.
600
+ """
601
+ import guidata.dataset as gds # pylint: disable=import-outside-toplevel
602
+
603
+ # Try to find the parameter class from the processor
604
+ # First, look in the current panel's processor
605
+ panel = self._main_window.tabwidget.currentWidget()
606
+ if hasattr(panel, "processor"):
607
+ try:
608
+ feature = panel.processor.get_feature(func_name)
609
+ if feature.paramclass is not None:
610
+ # Create instance and set values
611
+ param_obj = feature.paramclass()
612
+ for key, value in param_dict.items():
613
+ if hasattr(param_obj, key):
614
+ setattr(param_obj, key, value)
615
+ return param_obj
616
+ except ValueError:
617
+ pass
618
+
619
+ # Fallback: try to import common parameter classes from sigima
620
+ try:
621
+ import sigima.params # pylint: disable=import-outside-toplevel
622
+
623
+ # Try to find matching param class (e.g., "normalize" -> NormalizeParam)
624
+ param_class_name = func_name.title().replace("_", "") + "Param"
625
+ if hasattr(sigima.params, param_class_name):
626
+ param_class = getattr(sigima.params, param_class_name)
627
+ return param_class.create(**param_dict)
628
+ except ImportError:
629
+ pass
630
+
631
+ # If we can't find a param class, create a simple DataSet
632
+ if param_dict:
633
+
634
+ class DynamicParam(gds.DataSet):
635
+ """Dynamic parameter class created at runtime."""
636
+
637
+ param_obj = DynamicParam()
638
+ for key, value in param_dict.items():
639
+ setattr(param_obj, key, value)
640
+ return param_obj
641
+
642
+ return None