Source code for ClearMap.gui.params_interfaces

import ast
import functools
import traceback
import warnings
from abc import ABC, abstractmethod
from contextlib import contextmanager
from typing import List, Any, Callable, Mapping, Optional, Dict, Type, Tuple, Sequence, Union

from PyQt5.QtCore import QEvent, QObject
from importlib_metadata import version
from packaging.version import Version

from PyQt5 import sip
from PyQt5.QtWidgets import (QCheckBox, QLabel, QLineEdit, QSpinBox, QFrame,
                             QComboBox, QPlainTextEdit, QTextEdit, QGroupBox,
                             QWidget, QListWidget, QDoubleSpinBox)

from ClearMap.IO.assets_constants import CONTENT_TYPE_TO_PIPELINE
from ClearMap.Utils.event_bus import BusSubscriberMixin, EventBus, Publishes
from ClearMap.Utils.events import ChannelsChanged
from ClearMap.Utils.utilities import set_item_recursive, get_item_recursive, DELETE
from ClearMap.Utils.exceptions import ConfigNotFoundError, ClearMapValueError
from ClearMap.config.config_handler import ALTERNATIVES_REG
from ClearMap.gui.gui_utils_base import disconnect_widget_signal
from ClearMap.gui.pipeline_model import LinearPipeline, PipelineStep, BINARIZATION_STEPS
from ClearMap.gui.pipeline_widgets import LinearPipelineWidget
from ClearMap.gui.widget_monkeypatch_callbacks import _get_sorted_spin_boxes, ensure_compound_box_patched
from ClearMap.gui.widgets import ExtendableTabWidget, FileDropListWidget, LandmarksWeightsPanel, GroupsWidgetAdapter, \
    NProcessesWidget

CLEARMAP_VERSION = Version(version('ClearMap'))
DEBUG_PAINT_GUARD = True   # FIXME: base on machine params log_level

_UNSET = object()  # sentinel singleton


[docs] def identity_op(x): return x
[docs] def invert(x): return not bool(x)
def _is_alive(widget): if widget is None: return False try: if sip.isdeleted(widget): return False except Exception: # Not a sip-wrapped object (e.g. mock in tests). # Can't determine deletion state; assume alive. return True if widget.property('_cm_replaced'): return False return True # REFACTOR: merge with VectorLink version def _ensure_sentinel_min_for_scalar(widget: QWidget, sentinel, label: Optional[str]): if isinstance(widget, QDoubleSpinBox) or isinstance(widget, QSpinBox): if widget.minimum() > sentinel: widget.setMinimum(sentinel) if sentinel == -1 and label is not None: try: widget.setSpecialValueText(label) except ValueError: warnings.warn(f"Sentinel label could not be applied to {widget.objectName()}")
[docs] def param_setter(func): """ For UiParameter setters that touch widgets. Brackets the setter with self._painting = True/False. """ @functools.wraps(func) def wrapper(self, *args, **kwargs): old = getattr(self, "_painting", False) self._painting = True try: return func(self, *args, **kwargs) finally: self._painting = old return wrapper
[docs] def param_handler(func): """ For handlers/slots that write back to config. If we're painting, warn (in debug) and skip. """ @functools.wraps(func) def wrapper(self, *args, **kwargs): if getattr(self, "_painting", False): if DEBUG_PAINT_GUARD: warnings.warn( f"{self.__class__.__name__}.{func.__name__} fired while painting. " f"This will be ignored, but it's probably a bug.", RuntimeWarning, stacklevel=2, ) return return func(self, *args, **kwargs) return wrapper
# ################ For compound widgets wrapped in a QFrame #####################
[docs] def frame_getter(widget: QFrame): """ For compound objects wrapped in a frame like singlets, doublets and triplets, with or without a checkbox or for text edits with an enable checkbox """ if hasattr(widget, 'getValue'): # singlets, doublets and triplets return widget.getValue() elif hasattr(widget, 'text') and callable(widget.text): # TextEdit with checkbox return widget.text() else: raise ValueError(f'Unrecognised frame with name "{widget.objectName()}"')
[docs] def frame_setter(widget: QFrame, value): """ For compound objects wrapped in a frame like singlets, doublets and triplets, with or without a checkbox or for text edits with an enable checkbox """ if hasattr(widget, 'setValue'): # singlets, doublets and triplets widget.setValue(value) elif hasattr(widget, 'setText') and callable(widget.setText): # TextEdit with checkbox widget.setText(value) else: raise ValueError(f'Unrecognised frame with name "{widget.objectName()}"')
[docs] def connect_frame(widget, callback): name = widget.objectName() if hasattr(widget, 'valueChangedConnect'): # singlets, doublets and triplets widget.valueChangedConnect(lambda *_: callback()) elif hasattr(widget, 'textChangedConnect'): # WARNING: plainTextEdit.textChanged is argument less widget.textChangedConnect(lambda *_: callback()) else: raise ValueError(f'Unrecognised frame with "{name=}"')
def _ensure_list(v: Any, n: int) -> List[Any]: if v is None: return [None] * n if isinstance(v, (list, tuple)): L = list(v) if len(L) > n: return L[:n] # silently truncate if too long # raise ValueError(f"Expected at most {n} elements, got {len(L)}") else: return L + [None] * (n - len(L)) # pad if too short return [v] def _ensure_sentinel_min(widget: QFrame, sentinel: Union[int, float], label: Optional[str] = "auto"): # ensure each child spinbox can display the sentinel; decorate with specialValueText when sentinel == -1 for sb in list(widget.findChildren(QSpinBox)) + list(widget.findChildren(QDoubleSpinBox)): if sb.minimum() > sentinel: sb.setMinimum(sentinel) if sentinel == -1 and label is not None: try: sb.setSpecialValueText(label) except ValueError: warnings.warn(f'Sentinel display label could not be enforced for {widget.objectName()}') pass def _equals_disabled_value(val, token): return (val is None or val == 'auto') if (token is None) else (val == token) # def _disabled_to_sentinel(x: Any, token: Any, sentinel: Union[int, float]) -> Any: # # treat both the exact token and None as “disabled element” # return sentinel if (x is None or _equals_disabled_value(x, token)) else x # # # def _sentinel_to_disabled(x: Any, token: Any, sentinel: Union[int, float]) -> Any: # return token if x == sentinel else x
[docs] def combobox_getter(widget: QComboBox): txt = widget.currentText() if txt in ('None', ''): return None return txt
[docs] def combobox_setter(widget: QComboBox, value): if value is None: value = 'None' # TODO: decide 'None' or '' found = widget.findText(value) if found == -1 and widget.count() > 0: warnings.warn(f'Value "{value}" not found in combobox, possible values: ' f'[{", ".join([widget.itemText(i) for i in range(widget.count())])}]') widget.setCurrentText(value)
[docs] def line_edit_getter(widget: QLineEdit): txt = widget.text() if txt in ('None', ''): return None count = sum([txt.count(symbol) for symbol in '[]{}']) if count: # List or dict if count % 2 == 0: # (not perfect but should work for most cases) txt = ast.literal_eval(txt) else: warnings.warn(f'Unbalanced brackets in {txt}') return txt
[docs] def list_widget_getter(widget: QListWidget): widgets = [widget.item(i) for i in range(widget.count())] return [itm.text() for itm in widgets]
[docs] def list_widget_setter(widget: QListWidget, itm_list: List[str]): try: widget.clear() widget.addItems(itm_list) except RuntimeError: # Widget has been deleted print(f'Widget {widget} has been deleted') pass
def _linear_pipeline_getter(w: LinearPipelineWidget) -> dict: """Translate widget state to existing config schema.""" return { 'step_order': [s.spec_name for s in w.pipeline.steps], **{s.spec_name: {'run': s.enabled} for s in w.pipeline.steps} } def _linear_pipeline_setter(w: LinearPipelineWidget, value: dict | None) -> None: """Reconstruct pipeline from existing config schema.""" if not value: return order = value.get('step_order') or list(BINARIZATION_STEPS.keys()) w._pipeline = LinearPipeline(steps=[ PipelineStep( spec_name=name, enabled=value.get(name, {}).get('run', True), keep_intermediate=value.get(name, {}).get('keep_intermediate', True), locked=(name == 'binarize'), ) for name in order if name in BINARIZATION_STEPS ]) w._populate() def _linear_pipeline_connector(w: 'LinearPipelineWidget', cb: Callable) -> Callable: """ Connect all pipeline-mutation signals to cb. Returns a single disconnector that tears down every connection. """ slot_pipeline = lambda _steps: cb() slot_keep = lambda _spec, _keep: cb() w.pipeline_changed.connect(slot_pipeline) w.keep_intermediate_changed.connect(slot_keep) def _disconnect(): disconnect_widget_signal(w.pipeline_changed, slot=slot_pipeline) disconnect_widget_signal(w.keep_intermediate_changed, slot=slot_keep) return _disconnect
[docs] class EditingFinishedFilter(QObject): """Emits a callback when a QPlainTextEdit loses focus (≈ editingFinished).""" def __init__(self, callback, parent=None): super().__init__(parent) self._cb = callback
[docs] def eventFilter(self, obj, event) -> bool: if event.type() == QEvent.FocusOut: self._cb() return False
Getter = Callable[[QWidget], Any] Setter = Callable[[QWidget, Any], None] Connector = Callable[[QWidget, Callable[[], None]], None] OpsTuple = Tuple[Optional[Getter], Optional[Setter], Optional[Connector]]
[docs] class WidgetOps: """Registry + cache for widget getters/setters/connectors.""" def __init__(self) -> None: self._getters: Dict[Type[QWidget], Getter] = {} self._setters: Dict[Type[QWidget], Setter] = {} self._connectors: Dict[Type[QWidget], Connector] = {} self._cache: Dict[Type[QWidget], OpsTuple] = {} self._install_defaults()
[docs] def register(self, cls: Type[QWidget], *, getter: Optional[Getter] = None, setter: Optional[Setter] = None, connector: Optional[Connector] = None) -> None: """ Register operations for a given widget class. If an operation is None, it will not be registered. This will clear the cache which will be rebuilt on demand. Parameters ---------- cls: Type[QWidget] The widget class to register getter: Getter The getter function for the widget class setter: Setter The setter function for the widget class connector: Connector The connector function for the widget class """ if getter: self._getters[cls] = getter if setter: self._setters[cls] = setter if connector: self._connectors[cls]= connector self._cache.clear()
def _resolve_ops(self, cls: Type[QWidget]) -> OpsTuple: """ Resolve the operations for a given widget class, using MRO to find the most specific registered operations. + Cache the results for faster lookup next time. Parameters ---------- cls: Type[QWidget] The widget class to resolve the operations for Returns ------- OpsTuple A tuple of (getter, setter, connector) functions or None if not found """ if cls in self._cache: return self._cache[cls] g = s = k = None # Walk MRO: most specific class wins for c in cls.mro(): if g is None and c in self._getters: g = self._getters[c] if s is None and c in self._setters: s = self._setters[c] if k is None and c in self._connectors: k = self._connectors[c] if g and s and k: break ops = (g, s, k) self._cache[cls] = ops return ops
[docs] def resolve(self, widget: QWidget) -> OpsTuple: """ Resolve the operations for a given widget instance. Uses the class of the widget to lookup the operations. This will also use MRO to find the most specific registered operations. Caches the results for faster lookup next time. Parameters ---------- widget: QWidget The widget instance to resolve the operations for Returns ------- OpsTuple A tuple of (getter, setter, connector) functions or None if not found """ return self._resolve_ops(widget.__class__)
[docs] def get(self, widget: QWidget) -> Any: getter_fn, _, _ = self.resolve(widget) if getter_fn is None: raise NotImplementedError(f"No getter for {type(widget).__name__}") return getter_fn(widget)
[docs] def set(self, widget: QWidget, value: Any, *, silent: bool = False) -> None: _, setter_fn, _ = self.resolve(widget) if setter_fn is None: raise NotImplementedError(f"No setter for {type(widget).__name__}") if silent and hasattr(widget, "blockSignals"): old = widget.blockSignals(True) # type: ignore[attr-defined] try: setter_fn(widget, value) except: print(f"Error setting {value=} on widget {widget.objectName()}:") raise finally: widget.blockSignals(old) # type: ignore[attr-defined] else: setter_fn(widget, value)
[docs] def connect(self, widget: QWidget, cb: Callable[[], None]) -> Optional[Callable[[], None]]: _, _, connector = self.resolve(widget) if connector is None: # For e.g. QLabel warnings.warn(f'No connector for {type(widget).__name__}, skipping') return None else: return connector(widget, cb)
def _install_defaults(self) -> None: def check_box_connector(w: QCheckBox, cb: Callable[[], None]): w.stateChanged.connect(cb) return lambda: disconnect_widget_signal(w.stateChanged, slot=cb) self.register(QCheckBox, getter=lambda w: w.isChecked(), setter=lambda w, v: w.setChecked(bool(v)), connector=check_box_connector, # TODO: Check if not setChecked ) self.register(QLabel, getter=lambda w: w.text(), setter=lambda w, v: w.setText(v), connector=None) # FIXME: QLabel does not have textChanged signal def text_changed_connector(w: QLineEdit, cb: Callable[[], None]): w.textChanged.connect(cb) return lambda: disconnect_widget_signal(w.textChanged, slot=cb) def editing_finished_connector(w: QLineEdit, cb: Callable[[], None]): w.editingFinished.connect(cb) return lambda: disconnect_widget_signal(w.editingFinished, slot=cb) self.register(QLineEdit, getter=line_edit_getter, setter=lambda w, v: w.setText(str(v)), connector=editing_finished_connector, ) def value_changed_connector(w: QSpinBox, cb: Callable[[], None]): w.setKeyboardTracking(False) w.valueChanged.connect(cb) return lambda: disconnect_widget_signal(w.valueChanged, slot=cb) def plain_text_editing_finished_connector(w: QPlainTextEdit, cb: Callable[[], None]): f = EditingFinishedFilter(cb, parent=w) w.installEventFilter(f) return lambda: w.removeEventFilter(f) self.register(QSpinBox, getter=lambda w: w.value(), setter=lambda w, v: w.setValue(int(v)), connector=value_changed_connector, ) self.register(QDoubleSpinBox, getter=lambda w: w.value(), setter=lambda w, v: w.setValue(float(v)), connector=value_changed_connector, ) self.register(QPlainTextEdit, getter=lambda w: w.toPlainText(), setter=lambda w, v: w.setPlainText("" if v is None else str(v)), connector=plain_text_editing_finished_connector, ) self.register(QTextEdit, # FIXME: check if HTML for both getter and setter is what we want getter=lambda w: w.toHtml(), # TODO: check if this always what we want setter=lambda w, v: w.setPlainText("" if v is None else str(v)), connector=editing_finished_connector, ) def current_text_changed_connector(w: QComboBox, cb: Callable[[], None]): w.currentTextChanged.connect(cb) return lambda: disconnect_widget_signal(w.currentTextChanged, slot=cb) self.register(QComboBox, getter=combobox_getter, setter=combobox_setter, connector=current_text_changed_connector, ) def toggled_connector(w: QGroupBox, cb: Callable[[], None]): w.toggled.connect(cb) return lambda: disconnect_widget_signal(w.toggled, slot=cb) self.register(QGroupBox, getter=lambda w: w.isChecked(), setter=lambda w, v: w.setChecked(bool(v)), connector=toggled_connector, ) def item_changed_connector(w: QListWidget, cb: Callable[[], None]): w.itemChanged.connect(cb) return lambda: disconnect_widget_signal(w.itemChanged, slot=cb) self.register(QListWidget, # FIXME: not the right signal (should be for any change). + also rows inserted/removed getter=list_widget_getter, setter=list_widget_setter, connector=item_changed_connector, ) def items_changed_connector(w: FileDropListWidget, cb: Callable[[], None]): w.itemsChanged.connect(cb) return lambda: disconnect_widget_signal(w.itemsChanged, slot=cb) self.register(FileDropListWidget, getter=list_widget_getter, setter=list_widget_setter, connector=items_changed_connector, ) self.register(QFrame, getter=frame_getter, setter=frame_setter, connector=connect_frame )
WIDGET_OPS = WidgetOps() WIDGET_OPS.register( # Not strictly necessary, but more explicit LandmarksWeightsPanel, getter=lambda w: w.get_weights(), setter=lambda w, v: w.set_weights(v or []), connector=lambda w, cb: w.valueChangedConnect(cb), ) WIDGET_OPS.register( GroupsWidgetAdapter, getter=lambda w: w.get_value(), setter=lambda w, v: w.set_value(v or {}), connector=lambda w, cb: w.connect(cb), ) WIDGET_OPS.register( LinearPipelineWidget, getter=_linear_pipeline_getter, setter=_linear_pipeline_setter, connector=_linear_pipeline_connector, )
[docs] def n_proc_getter(widget: NProcessesWidget): val = widget.value() return val
[docs] def n_proc_setter(widget: NProcessesWidget, value): widget.setValue(int(value))
[docs] def n_proc_connector(widget: NProcessesWidget, cb): def _slot(_val: int): cb() widget.valueChanged.connect(_slot) return lambda: disconnect_widget_signal(widget.valueChanged, slot=_slot)
WIDGET_OPS.register(NProcessesWidget, getter=n_proc_getter, setter=n_proc_setter, connector=n_proc_connector)
[docs] class UiParameter(BusSubscriberMixin): """ This is a class to link the GUI widgets to the config file. This is done automatically from parsing the ``params_dict`` attribute. The ``params_dict`` attribute is a dictionary of the form:: {'attr_name': ParamLink(keys, widget, attr_name=, default=, connect=)} or {'attr_name': keys,} where: - ``attr_name`` is the name of the attribute in the class - ``keys`` is a list of keys (chain) to access the value in the config file. If ``None``, the attribute is not connected widget is the GUI widget ``connect`` is a boolean to indicate whether the widget should be connected to the config file. Set to ``False`` to not connect or connect manually. If the value is not a ``ParamLink``, it is assumed that the keys point to the value and the widget connection is done through accessors and mutators. Attributes ---------- tab: QWidget The tab where the widget is located params_dict: Dict[str, ParamLink | List[str] The dictionary of attributes and their corresponding ParamLink _cfg_subtree: List[str] The list of keys to access the config file. This allows a relative path to be used from the whole config file. """ def __init__(self, tab: QWidget, *, event_bus: EventBus, get_view: Optional[Callable[[], Mapping[str, Any]]] = None, apply_patch: Optional[Callable[[dict], None]] = None): super().__init__(event_bus) if tab is None: raise ValueError('Tab widget cannot be None') self.tab: QWidget = tab self.advanced_controls: List[QWidget] = [] self._get_view: Callable[[], Mapping[str, Any]] | None = get_view self._apply_patch: Callable[[dict], None] | None = apply_patch self._cfg_subtree = getattr(self, "_cfg_subtree", []) # FIXME: pass as arg to ctor? self._painting = False # True when updating the UI from the config file self.widget_ops = WIDGET_OPS self._connected_param_keys: set[str] = set() self.params_dict = self.build_params_dict() self._validate_params_dict() self.connect() # User overridable hook self.connect_simple_widgets() self.post_connect()
[docs] def build_params_dict(self) -> dict: raise NotImplementedError
def connect(self): pass
[docs] def post_connect(self): pass
@contextmanager def _suppress_handlers(self): old = self._painting self._painting = True try: yield finally: self._painting = old def _validate_params_dict(self): for k, v in self.params_dict.items(): if isinstance(v, ParamLink) and v.widget is None: raise RuntimeError(f"{self.__class__.__name__}: ParamLink {k} has no widget")
[docs] def teardown(self): for link in self.params_dict.values(): if isinstance(link, ParamLink): link.disconnect_all() # Drop widget refs try: link.widget = None except Exception: pass
def _update_value(self, keys, value): if self._apply_patch is None: raise ValueError('Patch sink not set. Please call bind_apply_patch first.') if self._painting and DEBUG_PAINT_GUARD: warnings.warn( f"{self.__class__.__name__}._update_value called while painting " f"for keys={keys!r}. This is probably a cfg→UI→cfg loop.\n" f"Stack:\n{''.join(traceback.format_stack(limit=10))}", RuntimeWarning, ) patch = {} set_item_recursive(patch, self.cfg_subtree + keys, value) self._apply_patch(patch) @property def cfg_subtree(self) -> List[str]: # WARNING: needs to be dynamic since channels can change return self._cfg_subtree @cfg_subtree.setter def cfg_subtree(self, value: List[str]): self._cfg_subtree = value
[docs] def is_simple_attr(self, key): if key == 'params_dict' or not hasattr(self, 'params_dict') or self.params_dict is None: return False if not key in self.params_dict.keys(): # Not set through GUI return False attr = self.params_dict[key] is_simple = isinstance(attr, ParamLink) return is_simple
[docs] def bind_apply_patch(self, fn: Callable[[dict], None]) -> None: """Provide a sink to apply a patch (already scoped to the section).""" self._apply_patch = fn
[docs] def bind_view_provider(self, fn: Callable[[], Mapping[str, Any]]) -> None: """Provide a readonly view of the current section (fresh each call).""" self._get_view = fn
@property def view(self): if self._get_view is None: raise ValueError('View provider not set. Please call bind_view_provider first.') base = self._get_view() return get_item_recursive(base, self.cfg_subtree) if self.cfg_subtree else base def _emit_patch(self, relative_path, value) -> None: full_path = list(self.cfg_subtree) + list(relative_path) patch = {} set_item_recursive(patch, full_path, value) if self._apply_patch is not None: self._apply_patch(patch) def __getattr__(self, item): # FiXME: use binder.default if self.is_simple_attr(item): binder = self.params_dict[item] widget = binder.widget val = self.widget_ops.get(widget) return binder.cast_from_ui(val) else: raise AttributeError(f'{self.__class__.__name__}, unknown attribute "{item}"') def __setattr__(self, key, value): if self.is_simple_attr(key): p_link = self.params_dict[key] widget = p_link.widget value = p_link.cast_to_ui(value) self.widget_ops.set(widget, value, silent=self._painting) else: object.__setattr__(self, key, value)
[docs] def get(self, item, default_value=None): try: return self.__getattribute__(item) except AttributeError: return default_value
[docs] def connect_simple_widgets(self): for k in self.params_dict.keys(): link = self.params_dict[k] if (isinstance(link, ParamLink) and link.has_connect_function()) or \ (self.is_simple_attr(k) and link.connect and not hasattr(self, f'handle_{k}_changed')): self.__connect_widget(k)
def __connect_widget(self, key): if key in self._connected_param_keys: return # ensure idempotency param_link = self.params_dict[key] widget = param_link.widget callback = param_link.connect if not callable(callback): callback = functools.partial(self.handle_widget_changed, attr_name=key) disconnector = self.widget_ops.connect(widget, callback) param_link.add_disconnector(disconnector) if callable(getattr(param_link, 'extra_connect', None)): extra_disconnector = param_link.extra_connect(widget, callback) param_link.add_disconnector(extra_disconnector) self._connected_param_keys.add(key)
[docs] def extend_params_dict(self, extra_param_links: dict[str, ParamLink | list], *, connect_new: bool = True): """ Extend the params_dict with new entries after creation (and potenitally after initaial connexion) Parameters ---------- extra_param_links: dict[str, ParamLink | list] The new entries to add to the params_dict connect_new: Returns ------- """ self.params_dict.update(extra_param_links) if connect_new: self.connect_simple_widgets() # (idempotent)
[docs] def handle_widget_changed(self, *_, attr_name='', **__): """ Generic handler for simple widgets that binds them to the config file. Calls _emit_patch with the keys obtained from the ParamLink with **attr_name** and the current value of the attribute. Discards the values of the signal passed by Qt. Parameters ---------- attr_name: str The name of the attribute in the class """ if self._painting: return param_link = self.params_dict[attr_name] keys = param_link.keys if keys is None: # For display only widgets (e.g. plotting temp settings) return if not param_link.is_relevant(self.view): return property_value = getattr(self, attr_name) # Cast happens here, don't do it twice self._emit_patch(keys, property_value) if callable(param_link.notify_apply): param_link.notify_apply()
[docs] def connect(self): """Connect GUI slots here""" pass
[docs] def cfg_to_ui(self): if not self.params_dict: raise NotImplementedError('params_dict not set') self._painting = True view = attr = keys_list = _UNSET # Pre-initialize to avoid referencing before initialisation in err try: view = self.view for attr, p_link in self.params_dict.items(): keys_list = p_link.keys if isinstance(p_link, ParamLink) else p_link if keys_list is None: # For params without cfg continue if isinstance(p_link, ParamLink): widget = p_link.widget if widget is None: continue # TODO: add warning? relevant = p_link.is_relevant(view if isinstance(view, dict) else view.config) # hide when not applicable if not relevant and hasattr(widget, "setVisible"): widget.setVisible(False) if not relevant: continue try: val = get_item_recursive(view, keys_list) # check if we want try_get_item here w/ default except (KeyError, ConfigNotFoundError): if isinstance(p_link, ParamLink) and p_link.missing_ok: val = p_link.default else: raise # Update the UI setattr(self, attr, val) # comes after the cfg otherwise, key will be missing in the callback except Exception as e: parts = [f"Error in cfg_to_ui for {self.__class__.__name__}"] # Build context defensively — each piece may or may not exist for label, obj in [("attr", attr), ("keys", keys_list), ("view", view)]: if obj is _UNSET: parts.append(f"{label}=<not yet assigned>") else: try: parts.append(f"{label}={obj!r}") except Exception: parts.append(f"{label}=<repr failed: {type(obj).__name__}>") print(" | ".join(parts + [f'exception={e!r}'])) raise finally: self._painting = False
[docs] def sanitize_nones(self, val): """ In python, the maximum item (last item) in a list can be accessed with -1. In ClearMap, the maximum is often represented by None. This function is used to do the conversion. Parameters ---------- val: int or None The value to be converted Returns ------- int The converted value """ return val if val is not None else -1
[docs] def sanitize_neg_one(self, val): """ In python, the maximum item (last item) in a list can be accessed with -1. In ClearMap, the maximum is often represented by None. This function is used to do the conversion. Parameters ---------- val: int The value to be converted Returns ------- int or None The converted value """ return val if val != -1 else None
[docs] def str_to_lower(self, val): return "" if val is None else str(val).lower()
[docs] def str_to_capitalize(self, val): return "" if val is None else str(val).capitalize()
[docs] def sanitize_path_read(self, val): return "" if val in (None, 'None') else str(val)
[docs] def sanitize_path_write(self, val): return None if val in (None, '', 'None') else str(val)
[docs] class ChannelUiParameter(UiParameter, ABC): """ Base class for channel parameters. Each channel parameter is associated with a channel name and a widget in the tab. """ def __init__(self, tab: QWidget, channel_name: str, *, event_bus: EventBus, name_widget_name: Optional[str] = None, get_view: Callable[[], Mapping[str, Any]] | None = None, apply_patch: Callable[[dict], None] | None = None): tab_widget = tab.channelsParamsTabWidget page, self.page_index = tab_widget.get_channel_widget(channel_name, return_idx=True) if page is None: raise ClearMapValueError(f'Channel {channel_name} not found in tab for {self.__class__.__name__}. ' f'Please add it first.') if name_widget_name is not None: self.nameWidget = getattr(page, name_widget_name) self.nameWidget.setText(channel_name) # WARNING: needs to come first since used by all the rest # self.name = channel_name # WARNING: needs to come first since used by all the rest else: self.nameWidget = tab_widget super().__init__(page, event_bus=event_bus, get_view=get_view, apply_patch=apply_patch) @property @abstractmethod def cfg_subtree(self) -> List[str]: pass @cfg_subtree.setter def cfg_subtree(self, value: List[str]): warnings.warn(f'cfg_subtree should not be set for ChannelParameters. ' f'Trying to set it to {value}') @property def name(self): if isinstance(self.nameWidget, QLineEdit): return self.nameWidget.text() elif isinstance(self.nameWidget, ExtendableTabWidget): return self.nameWidget.tabText(self.page_index) else: raise ValueError(f'Unsupported nameWidget type: {type(self.nameWidget)}') @name.setter def name(self, value): if value == self.name: return if isinstance(self.nameWidget, QLineEdit): self.nameWidget.setText(value) elif isinstance(self.nameWidget, ExtendableTabWidget): self.nameWidget.setTabText(self.page_index, value)
[docs] class UiParameterCollection(BusSubscriberMixin, ABC): """ For multi-section UiParameters that share the same config file. This ensures the file remains consistent. Although the attributes of contained `UiParameters` are accessible, it is recommended to first dereference them to avoid clashes. """ def __init__(self, tab: QWidget, *, pipeline_name: str, event_bus: EventBus, get_view: Callable[[], Mapping[str, Any]] | None = None, apply_patch: Callable[[dict], None] | None = None): super().__init__(event_bus) self.tab = tab self.pipeline_name = pipeline_name self._get_view: Callable[[], Mapping[str, Any]] | None = get_view self._apply_patch: Callable[[dict], None] | None = apply_patch # cfg_subtree is just the section name for single-pipeline collections self.cfg_subtree: List[str] = [ALTERNATIVES_REG.pipeline_to_section_name(self.pipeline_name)] self.advanced_controls: List[QWidget] = []
[docs] def handle_advanced_state_changed(self, state): for ctrl in self.advanced_controls: ctrl.setVisible(state)
[docs] def bind_apply_patch(self, fn: Callable[[dict], None]) -> None: """Provide a sink to apply a patch (already scoped to the section).""" self._apply_patch = fn
def _update_value(self, keys, value): if self._apply_patch is None: raise ValueError('Patch sink not set. Please call bind_apply_patch first.') patch = {} set_item_recursive(patch, self.cfg_subtree + keys, value) self._apply_patch(patch)
[docs] def bind_view_provider(self, fn: Callable[[], Mapping[str, Any]]) -> None: """Provide a readonly view of the current section (fresh each call).""" self._get_view = fn
@property def view(self): if self._get_view is None: raise ValueError('View provider not set. Please call bind_view_provider first.') base = self._get_view() if self.cfg_subtree: return get_item_recursive(base, self.cfg_subtree) else: return base def _emit_patch(self, relative_path, value) -> None: full_path = list(self.cfg_subtree) + list(relative_path) patch = {} set_item_recursive(patch, full_path, value) if self._apply_patch is not None: self._apply_patch(patch)
[docs] def pop(self, channel_name): # FIXME: should be in ChannelsUiParameterCollection. Check calls if self._apply_patch is None: raise ValueError('Patch sink not set. Please call bind_apply_patch first.') if not self.channels: warnings.warn(f'Could not pop "{channel_name}" from empty channels') if channel_name not in self.channels: warnings.warn(f'Could not remove "{channel_name=}" not found in {self.channels=}') return None channels_before = self.channels[:] # 1. Remove from in-memory dict FIRST (prevents re-entrant KeyError) popped_channel = self.channel_params.pop(channel_name) # 2. Remove UI widget BEFORE firing events (so reconcile_channel_pages # won't see it as obsolete and try to pop again) tab_widget = getattr(self.tab, 'channelsParamsTabWidget', None) if tab_widget is not None: tab_widget.remove_channel_widget(channel_name) # 3. Notify config (may fire synchronous ChannelsChanged — now harmless) try: self._emit_patch(['channels', channel_name], DELETE) except Exception as e: warnings.warn(f'Could not remove "{channel_name=}" from config: {e}') return None # popped_channel = self.channel_params.pop(channel_name) # 4. Ensure all signals get disconnected and resources freed try: popped_channel.teardown() except Exception as e: warnings.warn(f'Error during teardown of channel "{channel_name}": {e}') tab = self.tab.channelsParamsTabWidget tab.remove_channel_widget(channel_name) # 5. Bus notification channels_after = self.channels self.publish(ChannelsChanged(before=channels_before, after=channels_after)) return popped_channel
@property def version(self): return Version(self.view['clearmap_version']) @property @abstractmethod def params(self) -> list[UiParameter]: raise NotImplementedError('Please subclass UiParameterCollection and implement params property')
[docs] def cfg_to_ui(self): for param in self.params: if param: param.cfg_to_ui() else: print(f'Skipping None param in {self.__class__.__name__}')
[docs] def set_painting(self, painting: bool): if hasattr(self, "_painting"): self._painting = painting for param in self.params: param._painting = painting
[docs] class ChannelsUiParameterCollection(UiParameterCollection): publishes = Publishes(ChannelsChanged) def __init__(self, tab: QWidget, *, pipeline_name: str, event_bus: EventBus, get_view: Callable[[], Mapping[str, Any]] | None = None, apply_patch: Callable[[dict], None] | None = None): super().__init__(tab, pipeline_name=pipeline_name, event_bus=event_bus, get_view=get_view, apply_patch=apply_patch) self._channels = {} @property def relevant_channels(self): # Same as sm.get_channels_by_pipeline whithout going through SampleManager instance return [c for c, v in self._get_view()['sample']['channels'].items() if v['data_type'] and CONTENT_TYPE_TO_PIPELINE[v['data_type']] == self.pipeline_name] @property def channels(self): return list(self._channels.keys()) @property def channel_params(self): # WARNING: this is mutable return self._channels
[docs] def get(self, channel, default_value=None): return self._channels.get(channel, default_value)
def __getitem__(self, item): return self._channels[item] def __setitem__(self, key, value): if not isinstance(value, (ChannelUiParameter, UiParameterCollection)): # WARNING: collection of collections raise ClearMapValueError(f'Value must be a ChannelUiParameter. Got "{type(value)}" instead.') self._channels[key] = value def __contains__(self, item): return item in self._channels def __iter__(self): return iter(self._channels)
[docs] def keys(self): return self._channels.keys()
[docs] def values(self): return self._channels.values()
[docs] def items(self): return self._channels.items()