Loading src/nova/mvvm/_internal/utils.py +11 −0 Original line number Diff line number Diff line Loading @@ -3,6 +3,9 @@ import re from typing import Any, Dict from nova.mvvm import bindings_map from nova.mvvm.interface import LinkedObjectType def normalize_field_name(field: str) -> str: return field.replace(".", "_").replace("[", "_").replace("]", "") Loading Loading @@ -87,3 +90,11 @@ def rgetdictvalue(obj: Dict[str, Any], field: str) -> Any: for index in indices: obj = obj[index] return obj def check_binding(linked_object: LinkedObjectType, name: str) -> None: if name in bindings_map: raise ValueError(f"cannot connect to binding {name}: name already used") for communicator in bindings_map.values(): if communicator.viewmodel_linked_object and communicator.viewmodel_linked_object == linked_object: raise ValueError(f"cannot connect to binding {name}: object already connected") src/nova/mvvm/interface.py +1 −1 Original line number Diff line number Diff line Loading @@ -18,7 +18,7 @@ class Communicator(ABC): """ @abstractmethod def connect(self, connector: Any = None) -> ConnectCallbackType: def connect(self, *args: Any) -> ConnectCallbackType: """ Connect a GUI element to a linked object. Loading src/nova/mvvm/pyqt_binding/binding.py +20 −10 Original line number Diff line number Diff line """Binding module for PyQt framework.""" import os from typing import Any, Callable, Optional from typing import Any, Optional from pydantic import BaseModel, ValidationError from typing_extensions import override from .._internal.pydantic_utils import get_errored_fields_from_validation_error, get_updated_fields from .._internal.utils import rsetattr from .._internal.utils import check_binding, rsetattr from ..bindings_map import bindings_map if os.environ.get("QT_API", None) == "pyqt5": Loading @@ -24,18 +25,22 @@ else: import inspect from ..interface import BindingInterface from ..interface import BindingInterface, Communicator, ConnectCallbackType def is_callable(var: Any) -> bool: return inspect.isfunction(var) or inspect.ismethod(var) class PyQtCommunicator(QObject): """Communicator class, that provides methods required for binding to communicate between ViewModel and View.""" class PyQtObject(QObject): """PyQt object class.""" signal = pyqtSignal(object) class PyQtCommunicator(Communicator): """Communicator class, that provides methods required for binding to communicate between ViewModel and View.""" def __init__( self, viewmodel_linked_object: Any = None, Loading @@ -43,6 +48,7 @@ class PyQtCommunicator(QObject): callback_after_update: Any = None, ) -> None: super().__init__() self.pyqtobject = PyQtObject() self.viewmodel_linked_object = viewmodel_linked_object self.linked_object_attributes = linked_object_attributes self.callback_after_update = callback_after_update Loading Loading @@ -81,23 +87,27 @@ class PyQtCommunicator(QObject): if updated and self.callback_after_update: self.callback_after_update({"updated": updates, "errored": errors, "error": error}) def connect(self, name: str, callback: Callable) -> Any: @override def connect(self, name: str, connector: Any) -> ConnectCallbackType: # connect should be called from the View side to connect a # GUI element (via a function to change GUI element that is passed to the connect call) # and a linked_object (passed during bind creation from ViewModel side) if name in bindings_map: raise Exception(f"cannot connect to binding {name}: already connected") if not is_callable(connector): raise ValueError("connector should be a callable type") check_binding(self.viewmodel_linked_object, name) bindings_map[name] = self self.prefix = name self.signal.connect(callback) self.pyqtobject.signal.connect(connector) if self.viewmodel_linked_object: return self._update_viewmodel_callback else: return None @override def update_in_view(self, value: Any) -> Any: """Update a View (GUI) when called by a ViewModel.""" return self.signal.emit(value) return self.pyqtobject.signal.emit(value) class PyQtBinding(BindingInterface): Loading src/nova/mvvm/trame_binding/binding.py +3 −3 Original line number Diff line number Diff line Loading @@ -10,7 +10,7 @@ from trame_server.state import State from typing_extensions import override from .._internal.pydantic_utils import get_errored_fields_from_validation_error, get_updated_fields from .._internal.utils import normalize_field_name, rget_list_of_fields, rgetattr, rsetattr from .._internal.utils import check_binding, normalize_field_name, rget_list_of_fields, rgetattr, rsetattr from ..bindings_map import bindings_map from ..interface import ( BindingInterface, Loading Loading @@ -72,12 +72,12 @@ class TrameCommunicator(Communicator): else: connector = str(connector) if connector else None if connector: if connector in bindings_map: raise ValueError(f"cannot connect to binding {connector}: already connected") check_binding(self.viewmodel_linked_object, connector) bindings_map[connector] = self self.connection = StateConnection(self, connector) return self.connection.get_callback() @override def update_in_view(self, value: Any) -> None: self.connection.update_in_view(value) Loading tests/test_pyqt_bindings.py +34 −5 Original line number Diff line number Diff line Loading @@ -5,6 +5,7 @@ from typing import Any, Dict, List import pytest from PyQt6.QtWidgets import QLabel, QLineEdit, QMainWindow, QVBoxLayout, QWidget from pytestqt.qtbot import QtBot from typing_extensions import Generator from nova.mvvm import bindings_map from nova.mvvm.pydantic_utils import get_field_info Loading @@ -14,6 +15,12 @@ from nova.mvvm.pyqt_binding.binding import PyQtCommunicator from .model import User @pytest.fixture(scope="function") # Default scope def function_scoped_fixture() -> Generator[str, None]: yield "function" bindings_map.clear() class MainWindow(QMainWindow): """Test application class.""" Loading @@ -27,8 +34,7 @@ class MainWindow(QMainWindow): self.username_edit_box.setText(config.username) def process_config_change(self, key: str, value: Any) -> None: print(key, value) self.callback_config_object(key, value) self.callback_config_object(key, value) # type: ignore def get_description(self, field: str, default: str = "") -> str: try: Loading @@ -50,7 +56,7 @@ class MainWindow(QMainWindow): self.setCentralWidget(container) def test_binding_model_to_pyqt(qtbot: QtBot) -> None: def test_binding_model_to_pyqt(qtbot: QtBot, function_scoped_fixture: str) -> None: # Creates pyqt binding for a Pydantic object, updates model and validates that the PyQt element was updated. test_object = User() Loading Loading @@ -85,7 +91,9 @@ test_cases: List[Dict[str, Any]] = [ [(case["input"], case["result"]) for case in test_cases], ids=[case["test_name"] for case in test_cases], ) def test_binding_pyqt_to_model(qtbot: QtBot, input: str, expected_result: Dict[str, Any]) -> None: def test_binding_pyqt_to_model( qtbot: QtBot, input: str, expected_result: Dict[str, Any], function_scoped_fixture: str ) -> None: # Creates pyqt binding for a Pydantic object, updates user interface state and validates that the model was updated # or validation error occurred. after_update_results = {} Loading @@ -105,4 +113,25 @@ def test_binding_pyqt_to_model(qtbot: QtBot, input: str, expected_result: Dict[s else: assert "username" in after_update_results["updated"] assert test_object.username == expected_result["value"] bindings_map.clear() def test_pyqt_binding_same_name(function_scoped_fixture: str) -> None: # Creates pyqt binding for with same name, expect error test_object = User() test_object2 = User() binding = PyQtBinding().new_bind(test_object) binding2 = PyQtBinding().new_bind(test_object2) binding.connect("test_object", lambda: print("hello")) with pytest.raises(ValueError): binding2.connect("test_object", lambda: print("hello")) def test_binding_same_object(function_scoped_fixture: str) -> None: # Creates pyqt binding for the same Pydantic object twice, expect error test_object = User() binding = PyQtBinding().new_bind(test_object) binding.connect("test_object", lambda: print("hello")) with pytest.raises(ValueError): binding.connect("test_object1", lambda: print("hello")) Loading
src/nova/mvvm/_internal/utils.py +11 −0 Original line number Diff line number Diff line Loading @@ -3,6 +3,9 @@ import re from typing import Any, Dict from nova.mvvm import bindings_map from nova.mvvm.interface import LinkedObjectType def normalize_field_name(field: str) -> str: return field.replace(".", "_").replace("[", "_").replace("]", "") Loading Loading @@ -87,3 +90,11 @@ def rgetdictvalue(obj: Dict[str, Any], field: str) -> Any: for index in indices: obj = obj[index] return obj def check_binding(linked_object: LinkedObjectType, name: str) -> None: if name in bindings_map: raise ValueError(f"cannot connect to binding {name}: name already used") for communicator in bindings_map.values(): if communicator.viewmodel_linked_object and communicator.viewmodel_linked_object == linked_object: raise ValueError(f"cannot connect to binding {name}: object already connected")
src/nova/mvvm/interface.py +1 −1 Original line number Diff line number Diff line Loading @@ -18,7 +18,7 @@ class Communicator(ABC): """ @abstractmethod def connect(self, connector: Any = None) -> ConnectCallbackType: def connect(self, *args: Any) -> ConnectCallbackType: """ Connect a GUI element to a linked object. Loading
src/nova/mvvm/pyqt_binding/binding.py +20 −10 Original line number Diff line number Diff line """Binding module for PyQt framework.""" import os from typing import Any, Callable, Optional from typing import Any, Optional from pydantic import BaseModel, ValidationError from typing_extensions import override from .._internal.pydantic_utils import get_errored_fields_from_validation_error, get_updated_fields from .._internal.utils import rsetattr from .._internal.utils import check_binding, rsetattr from ..bindings_map import bindings_map if os.environ.get("QT_API", None) == "pyqt5": Loading @@ -24,18 +25,22 @@ else: import inspect from ..interface import BindingInterface from ..interface import BindingInterface, Communicator, ConnectCallbackType def is_callable(var: Any) -> bool: return inspect.isfunction(var) or inspect.ismethod(var) class PyQtCommunicator(QObject): """Communicator class, that provides methods required for binding to communicate between ViewModel and View.""" class PyQtObject(QObject): """PyQt object class.""" signal = pyqtSignal(object) class PyQtCommunicator(Communicator): """Communicator class, that provides methods required for binding to communicate between ViewModel and View.""" def __init__( self, viewmodel_linked_object: Any = None, Loading @@ -43,6 +48,7 @@ class PyQtCommunicator(QObject): callback_after_update: Any = None, ) -> None: super().__init__() self.pyqtobject = PyQtObject() self.viewmodel_linked_object = viewmodel_linked_object self.linked_object_attributes = linked_object_attributes self.callback_after_update = callback_after_update Loading Loading @@ -81,23 +87,27 @@ class PyQtCommunicator(QObject): if updated and self.callback_after_update: self.callback_after_update({"updated": updates, "errored": errors, "error": error}) def connect(self, name: str, callback: Callable) -> Any: @override def connect(self, name: str, connector: Any) -> ConnectCallbackType: # connect should be called from the View side to connect a # GUI element (via a function to change GUI element that is passed to the connect call) # and a linked_object (passed during bind creation from ViewModel side) if name in bindings_map: raise Exception(f"cannot connect to binding {name}: already connected") if not is_callable(connector): raise ValueError("connector should be a callable type") check_binding(self.viewmodel_linked_object, name) bindings_map[name] = self self.prefix = name self.signal.connect(callback) self.pyqtobject.signal.connect(connector) if self.viewmodel_linked_object: return self._update_viewmodel_callback else: return None @override def update_in_view(self, value: Any) -> Any: """Update a View (GUI) when called by a ViewModel.""" return self.signal.emit(value) return self.pyqtobject.signal.emit(value) class PyQtBinding(BindingInterface): Loading
src/nova/mvvm/trame_binding/binding.py +3 −3 Original line number Diff line number Diff line Loading @@ -10,7 +10,7 @@ from trame_server.state import State from typing_extensions import override from .._internal.pydantic_utils import get_errored_fields_from_validation_error, get_updated_fields from .._internal.utils import normalize_field_name, rget_list_of_fields, rgetattr, rsetattr from .._internal.utils import check_binding, normalize_field_name, rget_list_of_fields, rgetattr, rsetattr from ..bindings_map import bindings_map from ..interface import ( BindingInterface, Loading Loading @@ -72,12 +72,12 @@ class TrameCommunicator(Communicator): else: connector = str(connector) if connector else None if connector: if connector in bindings_map: raise ValueError(f"cannot connect to binding {connector}: already connected") check_binding(self.viewmodel_linked_object, connector) bindings_map[connector] = self self.connection = StateConnection(self, connector) return self.connection.get_callback() @override def update_in_view(self, value: Any) -> None: self.connection.update_in_view(value) Loading
tests/test_pyqt_bindings.py +34 −5 Original line number Diff line number Diff line Loading @@ -5,6 +5,7 @@ from typing import Any, Dict, List import pytest from PyQt6.QtWidgets import QLabel, QLineEdit, QMainWindow, QVBoxLayout, QWidget from pytestqt.qtbot import QtBot from typing_extensions import Generator from nova.mvvm import bindings_map from nova.mvvm.pydantic_utils import get_field_info Loading @@ -14,6 +15,12 @@ from nova.mvvm.pyqt_binding.binding import PyQtCommunicator from .model import User @pytest.fixture(scope="function") # Default scope def function_scoped_fixture() -> Generator[str, None]: yield "function" bindings_map.clear() class MainWindow(QMainWindow): """Test application class.""" Loading @@ -27,8 +34,7 @@ class MainWindow(QMainWindow): self.username_edit_box.setText(config.username) def process_config_change(self, key: str, value: Any) -> None: print(key, value) self.callback_config_object(key, value) self.callback_config_object(key, value) # type: ignore def get_description(self, field: str, default: str = "") -> str: try: Loading @@ -50,7 +56,7 @@ class MainWindow(QMainWindow): self.setCentralWidget(container) def test_binding_model_to_pyqt(qtbot: QtBot) -> None: def test_binding_model_to_pyqt(qtbot: QtBot, function_scoped_fixture: str) -> None: # Creates pyqt binding for a Pydantic object, updates model and validates that the PyQt element was updated. test_object = User() Loading Loading @@ -85,7 +91,9 @@ test_cases: List[Dict[str, Any]] = [ [(case["input"], case["result"]) for case in test_cases], ids=[case["test_name"] for case in test_cases], ) def test_binding_pyqt_to_model(qtbot: QtBot, input: str, expected_result: Dict[str, Any]) -> None: def test_binding_pyqt_to_model( qtbot: QtBot, input: str, expected_result: Dict[str, Any], function_scoped_fixture: str ) -> None: # Creates pyqt binding for a Pydantic object, updates user interface state and validates that the model was updated # or validation error occurred. after_update_results = {} Loading @@ -105,4 +113,25 @@ def test_binding_pyqt_to_model(qtbot: QtBot, input: str, expected_result: Dict[s else: assert "username" in after_update_results["updated"] assert test_object.username == expected_result["value"] bindings_map.clear() def test_pyqt_binding_same_name(function_scoped_fixture: str) -> None: # Creates pyqt binding for with same name, expect error test_object = User() test_object2 = User() binding = PyQtBinding().new_bind(test_object) binding2 = PyQtBinding().new_bind(test_object2) binding.connect("test_object", lambda: print("hello")) with pytest.raises(ValueError): binding2.connect("test_object", lambda: print("hello")) def test_binding_same_object(function_scoped_fixture: str) -> None: # Creates pyqt binding for the same Pydantic object twice, expect error test_object = User() binding = PyQtBinding().new_bind(test_object) binding.connect("test_object", lambda: print("hello")) with pytest.raises(ValueError): binding.connect("test_object1", lambda: print("hello"))