Widget Protocol System ====================== **ABC-based widget contracts replacing duck typing with fail-loud type checking.** *Module: pyqt_reactive.forms* The Problem: Duck Typing in UI Code ----------------------------------- Before this system, the pyqt-reactive UI layer relied heavily on duck typing to interact with widgets. Code would check ``hasattr(widget, 'get_value')`` or try to call methods and catch exceptions. This created several problems: 1. **Silent failures** - If a widget didn't have a method, the code would silently skip it or use a fallback, masking bugs that should have been caught during development. 2. **Scattered dispatch tables** - Each module maintained its own ``WIDGET_UPDATE_DISPATCH`` and ``WIDGET_GET_DISPATCH`` dictionaries mapping widget types to handler functions. These tables were duplicated, inconsistent, and hard to maintain. 3. **Inconsistent Qt APIs** - Qt widgets have inconsistent APIs: ``QLineEdit.text()`` vs ``QSpinBox.value()`` vs ``QComboBox.currentData()``. Each place that read widget values had to know about these differences. 4. **No discoverability** - There was no central registry of what widgets existed or what capabilities they had. Finding all widgets that support placeholders required grepping the codebase. The Solution: ABC-Based Contracts --------------------------------- The Widget Protocol System solves these problems by defining explicit Abstract Base Class (ABC) contracts. Instead of asking "does this widget have a get_value method?", we ask "is this widget a ValueGettable?". This is a fundamental shift from structural typing (duck typing) to nominal typing (explicit inheritance). The key insight is that widget capabilities are composable. A text field can get and set values, show placeholders, and emit change signals. A checkbox can get and set values and emit signals, but doesn't need placeholders. By defining each capability as a separate ABC, widgets can mix and match exactly the capabilities they need. This follows established pyqt-reactive patterns: - **StorageBackendMeta** - Metaclass auto-registration for storage backends - **MemoryTypeConverter** - Adapter pattern for normalizing inconsistent memory APIs Design Philosophy ~~~~~~~~~~~~~~~~~ - **Explicit inheritance over duck typing** - Widgets declare capabilities via ABC inheritance - **Fail-loud over fail-silent** - Missing implementations raise ``TypeError`` immediately - **Discoverable over scattered** - All capabilities tracked in a central registry - **Multiple inheritance for composable capabilities** - Mix and match ABCs as needed Architecture ------------ The system consists of 6 modules that work together: .. list-table:: Widget Protocol Modules :header-rows: 1 :widths: 25 75 * - Module - Purpose * - ``widget_protocols.py`` - ABC definitions (ValueGettable, ValueSettable, PlaceholderCapable, etc.) * - ``widget_registry.py`` - WidgetMeta metaclass for auto-registration * - ``widget_adapters.py`` - Qt widget adapters implementing ABCs * - ``widget_dispatcher.py`` - ABC-based dispatch with explicit isinstance checks * - ``widget_operations.py`` - Centralized operations API * - ``widget_factory.py`` - Type-based widget creation Widget ABCs ----------- The foundation of the system is six Abstract Base Classes, each representing a single widget capability. These ABCs are intentionally minimal—each defines exactly one responsibility, allowing widgets to compose capabilities through multiple inheritance. Think of these like interfaces in Java or protocols in Swift. A widget that inherits from ``ValueGettable`` is making a contract: "I promise to implement ``get_value()``". If the widget fails to implement the method, Python raises ``TypeError`` at class definition time, not at runtime when you try to use it. .. code-block:: python from pyqt_reactive.protocols.widget_protocols import ( ValueGettable, # get_value() -> Any ValueSettable, # set_value(value: Any) -> None PlaceholderCapable, # set_placeholder(text: str) -> None RangeConfigurable, # configure_range(min, max) -> None EnumSelectable, # set_enum_options(enum_type) / get_selected_enum() ChangeSignalEmitter # connect_change_signal() / disconnect_change_signal() ) Here's what the simplest ABC looks like. The ``@abstractmethod`` decorator ensures that any concrete class must implement this method: .. code-block:: python class ValueGettable(ABC): """ABC for widgets that can return a value.""" @abstractmethod def get_value(self) -> Any: """Get the current value from the widget.""" pass Metaclass Auto-Registration --------------------------- One of the pain points with widget systems is keeping a registry of available widgets in sync with the actual widget classes. Add a new widget class, forget to register it, and you get mysterious "widget not found" errors at runtime. The ``WidgetMeta`` metaclass solves this by automatically registering widgets when their classes are defined. This mirrors the ``StorageBackendMeta`` pattern used elsewhere in pyqt-reactive. When Python processes a class definition with this metaclass, it automatically adds the class to the global registry: .. code-block:: python from pyqt_reactive.forms.widget_registry import WidgetMeta, WIDGET_IMPLEMENTATIONS class LineEditAdapter(QLineEdit, ValueGettable, ValueSettable, metaclass=WidgetMeta): _widget_id = "line_edit" def get_value(self) -> Any: return self.text() def set_value(self, value: Any) -> None: self.setText(str(value) if value else "") # Auto-registered: assert WIDGET_IMPLEMENTATIONS["line_edit"] is LineEditAdapter Registry functions: - ``get_widget_class(widget_id)`` - Get class by ID - ``get_widget_capabilities(widget_class)`` - Get ABCs a class implements - ``list_widgets_with_capability(abc)`` - Find all widgets implementing an ABC Qt Widget Adapters ------------------ Here's where theory meets practice. Qt widgets have notoriously inconsistent APIs. To get a value, you call ``text()`` on a QLineEdit, ``value()`` on a QSpinBox, and ``currentData()`` on a QComboBox. Setting values is similarly inconsistent. And placeholders? QLineEdit has ``setPlaceholderText()``, but QSpinBox uses a completely different mechanism called "special value text" that only shows when the value equals the minimum. The adapter layer normalizes these inconsistencies. Each adapter wraps a Qt widget and implements the appropriate ABCs, translating the uniform interface to Qt-specific calls: .. code-block:: python # The problem - Qt inconsistency: line_edit.text() # vs spinbox.value() line_edit.setText(v) # vs spinbox.setValue(v) line_edit.setPlaceholderText(t) # vs spinbox.setSpecialValueText(t) # The solution - ABC-normalized interface: adapter.get_value() # Uniform for all widgets adapter.set_value(v) # Uniform for all widgets adapter.set_placeholder(t) # Uniform for all widgets The adapter implementations handle edge cases that would otherwise be scattered throughout the codebase. For example, ``SpinBoxAdapter`` treats the minimum value with special value text as "None", allowing spinboxes to represent optional integers. Available adapters: - ``LineEditAdapter`` - QLineEdit wrapper - ``SpinBoxAdapter`` - QSpinBox wrapper (handles None via special value text) - ``DoubleSpinBoxAdapter`` - QDoubleSpinBox wrapper - ``ComboBoxAdapter`` - QComboBox wrapper with enum support - ``CheckBoxAdapter`` - QCheckBox wrapper - ``GroupBoxCheckboxAdapter`` - QGroupBox with checkbox title ABC-Based Dispatch ------------------ With ABCs and adapters in place, we need a dispatch layer that routes operations to the right methods. The key difference from duck typing is that we use ``isinstance`` checks against ABCs rather than ``hasattr`` checks for methods. This might seem like a minor distinction, but it fundamentally changes error handling. With duck typing, missing a method might silently fall through to a default case. With ABC dispatch, attempting an operation on a widget that doesn't support it raises an immediate, descriptive ``TypeError``: .. code-block:: python from pyqt_reactive.forms.widget_dispatcher import WidgetDispatcher # BEFORE (duck typing): if hasattr(widget, 'get_value'): value = widget.get_value() # AFTER (ABC-based, fails loud): value = WidgetDispatcher.get_value(widget) # TypeError if not ValueGettable Error message on failure: .. code-block:: text TypeError: Widget QLabel does not implement ValueGettable ABC. Add ValueGettable to widget's base classes and implement get_value() method. Centralized Operations ---------------------- While ``WidgetDispatcher`` handles the low-level dispatch, ``WidgetOperations`` provides the API that most code should use. It wraps the dispatcher with additional conveniences like finding all value-capable widgets in a container and "try-style" operations for optional capabilities. The distinction between fail-loud and try-style operations is important. Use fail-loud operations when the widget *must* support the capability (a bug if it doesn't). Use try-style when the capability is genuinely optional (e.g., setting placeholders on widgets that may or may not support them): .. code-block:: python from pyqt_reactive.forms.widget_operations import WidgetOperations ops = WidgetOperations() # Fail-loud operations (raise TypeError if ABC not implemented) value = ops.get_value(widget) ops.set_value(widget, 42) ops.set_placeholder(widget, "Pipeline default: 100") ops.configure_range(widget, 0, 100) ops.connect_change_signal(widget, on_change_callback) # Try-style operations (return False if unsupported) if ops.try_set_placeholder(widget, text): print("Placeholder set") else: print("Widget doesn't support placeholders") # Find all value-capable widgets in a container value_widgets = ops.get_all_value_widgets(form_container) Widget Factory -------------- The final piece is the factory that creates widgets based on Python types. When building forms from dataclass definitions, we need to create appropriate widgets for each field type. The factory maps Python types to widget constructors: .. code-block:: python from pyqt_reactive.forms.widget_factory import WidgetFactory factory = WidgetFactory() # Type-based creation widget = factory.create_widget_for_type(str) # -> LineEditAdapter widget = factory.create_widget_for_type(int) # -> SpinBoxAdapter widget = factory.create_widget_for_type(bool) # -> CheckBoxAdapter widget = factory.create_widget_for_type(MyEnum) # -> ComboBoxAdapter with enum The factory handles type resolution including ``Optional[T]`` unwrapping, enum detection, and ``List[Enum]`` for multi-select widgets. See Also -------- - :doc:`field_change_dispatcher` - Event-driven field change handling - :doc:`ui_services_architecture` - Service layer using these protocols - :doc:`abstract_manager_widget` - ABC that uses widget protocols - :doc:`parametric_widget_creation` - Widget creation configuration