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:

Widget Protocol Modules

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.

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:

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:

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:

# 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:

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:

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):

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:

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