Commit 47dfdb34 authored by Yakubov, Sergey's avatar Yakubov, Sergey
Browse files

raise exception on duplicate binding, refactor

parent 6ec21036
Loading
Loading
Loading
Loading
Loading
+11 −0
Original line number Diff line number Diff line
@@ -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("]", "")
@@ -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")
+1 −1
Original line number Diff line number Diff line
@@ -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.

+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":
@@ -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,
@@ -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
@@ -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):
+3 −3
Original line number Diff line number Diff line
@@ -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,
@@ -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)

+34 −5
Original line number Diff line number Diff line
@@ -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
@@ -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."""

@@ -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:
@@ -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()
@@ -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 = {}
@@ -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