Skip to content
Merged
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from abc import ABC, abstractmethod
from functools import cached_property

from nicegui import ui
from nicegui.element import Element


class ParentMixin:
def __init__(self) -> None:
self._parent: Element | None = None

@staticmethod
def _get_parent() -> Element:
"""overwrite to use a different parent element"""
return ui.element()

@cached_property
def parent(self) -> Element:
if self._parent is None:
self._parent = self._get_parent()
return self._parent

def remove_parent(self) -> None:
if self._parent is not None:
self._parent.delete()
self._parent = None


class DisplayaMixin(ABC):
@abstractmethod
def display(self) -> None:
"""creates ui elements inside the parent container"""
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from abc import abstractmethod
from typing import Generic, TypeVar

from ._mixins import DisplayaMixin, ParentMixin
from .base_display_model import BaseUpdatableDisplayModel

M = TypeVar("M", bound=BaseUpdatableDisplayModel)


class BaseUpdatableComponent(DisplayaMixin, ParentMixin, Generic[M]):
def __init__(self, display_model: M):
super().__init__()

self.display_model = display_model
self.display_model.on_remove_from_ui(self.remove_parent)

def display(self) -> None:
with self.parent:
self._draw_ui()

@abstractmethod
def _draw_ui(self) -> None:
"""creates ui elements inside the parent container"""
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
from collections.abc import Callable
from typing import Any, Self, TypeAlias

from pydantic import BaseModel, NonNegativeInt, PrivateAttr

CompleteModelDict: TypeAlias = dict[str, Any]


class BaseUpdatableDisplayModel(BaseModel):
_on_type_change_subscribers: dict[str, Callable] = PrivateAttr(default_factory=dict)
_on_value_change_subscribers: dict[str, Callable] = PrivateAttr(
default_factory=dict
)
_on_remove_from_ui_callback: Callable | None = PrivateAttr(default=None)

def _get_on_change_callbacks_to_run(self, update_obj: Self) -> list[Callable]:
callbaks_to_run: list[Callable] = []

for attribute_name, callback in self._on_value_change_subscribers.items():
if getattr(self, attribute_name) != getattr(update_obj, attribute_name):
callbaks_to_run.append(callback)

for attribute_name, callback in self._on_type_change_subscribers.items():
if type(getattr(self, attribute_name)) is not type(
getattr(update_obj, attribute_name)
):
callbaks_to_run.append(callback)

return callbaks_to_run

def update(self, update_obj: Self) -> NonNegativeInt:
"""
updates the model with the values from update_obj
returns the number of callbacks that were run
"""
callbacks_to_run = self._get_on_change_callbacks_to_run(update_obj)

for attribute_name, update_value in update_obj.__dict__.items():
current_value = getattr(self, attribute_name)
if current_value != update_value:
if isinstance(update_value, BaseUpdatableDisplayModel):
if type(current_value) is type(update_value):
current_value.update(update_value)
else:
setattr(self, attribute_name, update_value)
else:
setattr(self, attribute_name, update_value)

for callback in callbacks_to_run:
callback()

return len(callbacks_to_run)

def remove_from_ui(self) -> None:
"""the UI will remove the component associated with this model"""
if self._on_remove_from_ui_callback:
self._on_remove_from_ui_callback()

def _raise_if_attribute_not_declared_in_model(self, attribute: str) -> None:
if attribute not in self.__class__.model_fields:
msg = f"Attribute '{attribute}' is not part of the model fields"
raise ValueError(msg)

def on_type_change(self, attribute: str, callback: Callable) -> None:
"""subscribe callback to an attribute TYPE change"""
self._raise_if_attribute_not_declared_in_model(attribute)

self._on_type_change_subscribers[attribute] = callback

def on_value_change(self, attribute: str, callback: Callable) -> None:
"""subscribe callback to an attribute VALUE change"""
self._raise_if_attribute_not_declared_in_model(attribute)

self._on_value_change_subscribers[attribute] = callback

def on_remove_from_ui(self, callback: Callable) -> None:
"""
invokes callback when object is no longer required,
allows the UI to have a clear hook to remove the component
"""
self._on_remove_from_ui_callback = callback
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
from typing import Generic, TypeAlias, TypeVar

from ._mixins import DisplayaMixin, ParentMixin
from .base_component import BaseUpdatableComponent
from .base_display_model import BaseUpdatableDisplayModel

M = TypeVar("M", bound=BaseUpdatableDisplayModel)

Reference: TypeAlias = str


class UpdatableComponentStack(DisplayaMixin, ParentMixin, Generic[M]):
"""
Renders `BaseUpdatableComponent` models via the provided `BaseUpdatableDisplayModel`
Appends new elements to the parent container.
"""

def __init__(self, component: type[BaseUpdatableComponent]) -> None:
super().__init__()
self.component = component

self._added_models: dict[Reference, M] = {}
self._rendered_models: set[Reference] = set()

def _render_to_parent(self) -> None:
with self.parent:
for reference in self._added_models:
if reference not in self._rendered_models:
self._render_component(reference)

def _render_component(self, reference: Reference) -> None:
with self.parent:
model = self._added_models[reference]
component = self.component(model)
component.display()
self._rendered_models.add(reference)

def display(self) -> None:
self._render_to_parent()

def add_or_update_model(self, reference: Reference, display_model: M) -> None:
"""adds or updates and existing ui element form a given model"""
if reference not in self._added_models:
self._added_models[reference] = display_model
self._render_component(reference)
else:
self._added_models[reference].update(display_model)

def remove_model(self, reference: Reference) -> None:
"""removes a model from the ui via it's given reference"""
if reference in self._added_models:
self._added_models[reference].remove_from_ui()
del self._added_models[reference]
self._rendered_models.remove(reference)

def update_from_dict(self, models: dict[Reference, M]) -> None:
"""updates UI given a new input"""
# remove models that are not in the new list
for reference in tuple(self._added_models.keys()):
if reference not in models:
self.remove_model(reference)

# add or update existing models
for reference, model in models.items():
self.add_or_update_model(reference, model)
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
from unittest.mock import Mock

import pytest
from pydantic import NonNegativeInt, TypeAdapter
from simcore_service_dynamic_scheduler.api.frontend._common.base_display_model import (
BaseUpdatableDisplayModel,
CompleteModelDict,
)


class Pet(BaseUpdatableDisplayModel):
name: str
species: str


class Friend(BaseUpdatableDisplayModel):
name: str
age: int


class RemderOnPropertyValueChange(BaseUpdatableDisplayModel):
name: str
age: int
companion: Pet | Friend


class RenderOnPropertyTypeChange(BaseUpdatableDisplayModel):
name: str
age: int
companion: Pet | Friend


@pytest.mark.parametrize(
"class_, initial_dict, update_dict, expected_dict, on_type_change, on_value_change",
[
pytest.param(
Pet,
{"name": "Fluffy", "species": "cat"},
{"name": "Fido", "species": "dog"},
{"name": "Fido", "species": "dog"},
{},
{},
id="does-not-require-rerender-without-any-render-on-declared",
),
pytest.param(
RemderOnPropertyValueChange,
{
"name": "Alice",
"age": 30,
"companion": {"name": "Fluffy", "species": "cat"},
},
{
"name": "Alice",
"age": 30,
"companion": {"name": "Fido", "species": "dog"},
},
{
"name": "Alice",
"age": 30,
"companion": {"name": "Fido", "species": "dog"},
},
{},
{"companion": 1},
id="requires-rerender-on-property-change",
),
pytest.param(
RemderOnPropertyValueChange,
{
"name": "Alice",
"age": 30,
"companion": {"name": "Fluffy", "species": "cat"},
},
{
"name": "Alice",
"age": 30,
"companion": {"name": "Fluffy", "species": "cat"},
},
{
"name": "Alice",
"age": 30,
"companion": {"name": "Fluffy", "species": "cat"},
},
{},
{"companion": 0},
id="do-not-require-rerender-if-same-value",
),
pytest.param(
RenderOnPropertyTypeChange,
{
"name": "Alice",
"age": 30,
"companion": {"name": "Fluffy", "species": "cat"},
},
{
"name": "Alice",
"age": 31,
"companion": {"name": "Fido", "species": "dog"},
},
{
"name": "Alice",
"age": 31,
"companion": {"name": "Fido", "species": "dog"},
},
{"companion": 0},
{},
id="does-not-require-rerender-if-same-type-with-value-changes",
),
pytest.param(
RenderOnPropertyTypeChange,
{
"name": "Alice",
"age": 30,
"companion": {"name": "Fluffy", "species": "cat"},
},
{
"name": "Alice",
"age": 30,
"companion": {"name": "Fluffy", "species": "cat"},
},
{
"name": "Alice",
"age": 30,
"companion": {"name": "Fluffy", "species": "cat"},
},
{"companion": 0},
{},
id="does-not-require-rerender-if-same-type-with-NO-value-changes",
),
pytest.param(
RenderOnPropertyTypeChange,
{
"name": "Alice",
"age": 30,
"companion": {"name": "Fluffy", "species": "cat"},
},
{"name": "Alice", "age": 31, "companion": {"name": "Charlie", "age": 25}},
{"name": "Alice", "age": 31, "companion": {"name": "Charlie", "age": 25}},
{"companion": 1},
{},
id="requires-rerender-when-type-changes",
),
],
)
def test_base_updatable_display_model(
class_: type[BaseUpdatableDisplayModel],
initial_dict: CompleteModelDict,
update_dict: CompleteModelDict,
expected_dict: CompleteModelDict,
on_type_change: dict[str, NonNegativeInt],
on_value_change: dict[str, NonNegativeInt],
):
person = TypeAdapter(class_).validate_python(initial_dict)
assert person.model_dump() == initial_dict

subscribed_on_type_changed: dict[str, Mock] = {}
for attribute in on_type_change:
mock = Mock()
person.on_type_change(attribute, mock)
subscribed_on_type_changed[attribute] = mock

subscribed_on_value_change: dict[str, Mock] = {}
for attribute in on_value_change:
mock = Mock()
person.on_value_change(attribute, mock)
subscribed_on_value_change[attribute] = mock

person.update(TypeAdapter(class_).validate_python(update_dict))
assert person.model_dump() == expected_dict

for attribute, mock in subscribed_on_type_changed.items():
assert (
mock.call_count == on_type_change[attribute]
), f"wrong on_type_change count for '{attribute}'"

for attribute, mock in subscribed_on_value_change.items():
assert (
mock.call_count == on_value_change[attribute]
), f"wrong on_value_change count for '{attribute}'"
Loading
Loading