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:
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.
Scattered dispatch tables - Each module maintained its own
WIDGET_UPDATE_DISPATCHandWIDGET_GET_DISPATCHdictionaries mapping widget types to handler functions. These tables were duplicated, inconsistent, and hard to maintain.Inconsistent Qt APIs - Qt widgets have inconsistent APIs:
QLineEdit.text()vsQSpinBox.value()vsQComboBox.currentData(). Each place that read widget values had to know about these differences.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
TypeErrorimmediatelyDiscoverable 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:
Module |
Purpose |
|---|---|
|
ABC definitions (ValueGettable, ValueSettable, PlaceholderCapable, etc.) |
|
WidgetMeta metaclass for auto-registration |
|
Qt widget adapters implementing ABCs |
|
ABC-based dispatch with explicit isinstance checks |
|
Centralized operations API |
|
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 IDget_widget_capabilities(widget_class)- Get ABCs a class implementslist_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 wrapperSpinBoxAdapter- QSpinBox wrapper (handles None via special value text)DoubleSpinBoxAdapter- QDoubleSpinBox wrapperComboBoxAdapter- QComboBox wrapper with enum supportCheckBoxAdapter- QCheckBox wrapperGroupBoxCheckboxAdapter- 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
Field Change Dispatcher Architecture - Event-driven field change handling
UI Services Architecture - Service layer using these protocols
AbstractManagerWidget Architecture - ABC that uses widget protocols
Parametric Widget Creation - Widget creation configuration