Skip to content

Commit f9200ae

Browse files
committed
Merge branch 'master' into 1090-implement-sampling-tracing-strategy
2 parents 604c818 + b24fbf5 commit f9200ae

File tree

11 files changed

+783
-9
lines changed

11 files changed

+783
-9
lines changed

services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/api/frontend/_common/__init__.py

Whitespace-only changes.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from abc import ABC, abstractmethod
2+
from functools import cached_property
3+
4+
from nicegui import ui
5+
from nicegui.element import Element
6+
7+
8+
class ParentMixin:
9+
def __init__(self) -> None:
10+
self._parent: Element | None = None
11+
12+
@staticmethod
13+
def _get_parent() -> Element:
14+
"""overwrite to use a different parent element"""
15+
return ui.element()
16+
17+
@cached_property
18+
def parent(self) -> Element:
19+
if self._parent is None:
20+
self._parent = self._get_parent()
21+
return self._parent
22+
23+
def remove_parent(self) -> None:
24+
if self._parent is not None:
25+
self._parent.delete()
26+
self._parent = None
27+
28+
29+
class DisplayaMixin(ABC):
30+
@abstractmethod
31+
def display(self) -> None:
32+
"""create an ui element ad attach it to the current NiceGUI context"""
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from abc import abstractmethod
2+
from typing import Generic, TypeVar
3+
4+
from ._mixins import DisplayaMixin, ParentMixin
5+
from .base_display_model import BaseUpdatableDisplayModel
6+
7+
M = TypeVar("M", bound=BaseUpdatableDisplayModel)
8+
9+
10+
class BaseUpdatableComponent(DisplayaMixin, ParentMixin, Generic[M]):
11+
def __init__(self, display_model: M):
12+
super().__init__()
13+
14+
self.display_model = display_model
15+
self.display_model.on_remove_from_ui(self.remove_parent)
16+
17+
def display(self) -> None:
18+
with self.parent:
19+
self._draw_ui()
20+
21+
@abstractmethod
22+
def _draw_ui(self) -> None:
23+
"""creates ui elements inside the parent container"""
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
from collections.abc import Callable
2+
from typing import Annotated, Any, Self, TypeAlias
3+
4+
from pydantic import BaseModel, NonNegativeInt, PrivateAttr
5+
6+
CompleteModelDict: TypeAlias = dict[str, Any]
7+
8+
9+
class BaseUpdatableDisplayModel(BaseModel):
10+
_on_type_change_subscribers: Annotated[
11+
dict[str, Callable], PrivateAttr(default_factory=dict)
12+
]
13+
_on_value_change_subscribers: Annotated[
14+
dict[str, Callable], PrivateAttr(default_factory=dict)
15+
]
16+
_on_remove_from_ui_callback: Annotated[Callable | None, PrivateAttr(default=None)]
17+
18+
def _get_on_change_callbacks_to_run(self, update_obj: Self) -> list[Callable]:
19+
callbacks_to_run: list[Callable] = []
20+
21+
for attribute_name, callback in self._on_value_change_subscribers.items():
22+
if getattr(self, attribute_name) != getattr(update_obj, attribute_name):
23+
callbacks_to_run.append(callback)
24+
25+
for attribute_name, callback in self._on_type_change_subscribers.items():
26+
if type(getattr(self, attribute_name)) is not type(
27+
getattr(update_obj, attribute_name)
28+
):
29+
callbacks_to_run.append(callback)
30+
31+
return callbacks_to_run
32+
33+
def update(self, update_obj: Self) -> NonNegativeInt:
34+
"""
35+
updates the model with the values from update_obj
36+
returns the number of callbacks that were run
37+
"""
38+
callbacks_to_run = self._get_on_change_callbacks_to_run(update_obj)
39+
40+
for attribute_name, update_value in update_obj.__dict__.items():
41+
current_value = getattr(self, attribute_name)
42+
if current_value != update_value:
43+
if isinstance(update_value, BaseUpdatableDisplayModel):
44+
if type(current_value) is type(update_value):
45+
current_value.update(update_value)
46+
else:
47+
setattr(self, attribute_name, update_value)
48+
else:
49+
setattr(self, attribute_name, update_value)
50+
51+
for callback in callbacks_to_run:
52+
callback()
53+
54+
return len(callbacks_to_run)
55+
56+
def remove_from_ui(self) -> None:
57+
"""the UI will remove the component associated with this model"""
58+
if self._on_remove_from_ui_callback:
59+
self._on_remove_from_ui_callback()
60+
61+
def _raise_if_attribute_not_declared_in_model(self, attribute: str) -> None:
62+
if attribute not in self.__class__.model_fields:
63+
msg = f"Attribute '{attribute}' is not part of the model fields"
64+
raise ValueError(msg)
65+
66+
def on_type_change(self, attribute: str, callback: Callable) -> None:
67+
"""subscribe callback to an attribute TYPE change"""
68+
self._raise_if_attribute_not_declared_in_model(attribute)
69+
70+
self._on_type_change_subscribers[attribute] = callback
71+
72+
def on_value_change(self, attribute: str, callback: Callable) -> None:
73+
"""subscribe callback to an attribute VALUE change"""
74+
self._raise_if_attribute_not_declared_in_model(attribute)
75+
76+
self._on_value_change_subscribers[attribute] = callback
77+
78+
def on_remove_from_ui(self, callback: Callable) -> None:
79+
"""
80+
invokes callback when object is no longer required,
81+
allows the UI to have a clear hook to remove the component
82+
"""
83+
self._on_remove_from_ui_callback = callback
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
from typing import Generic, TypeAlias, TypeVar
2+
3+
from ._mixins import DisplayaMixin, ParentMixin
4+
from .base_component import BaseUpdatableComponent
5+
from .base_display_model import BaseUpdatableDisplayModel
6+
7+
M = TypeVar("M", bound=BaseUpdatableDisplayModel)
8+
9+
Reference: TypeAlias = str
10+
11+
12+
class UpdatableComponentStack(DisplayaMixin, ParentMixin, Generic[M]):
13+
"""
14+
Renders `BaseUpdatableComponent` models via the provided `BaseUpdatableDisplayModel`
15+
Appends new elements to the parent container.
16+
"""
17+
18+
def __init__(self, component: type[BaseUpdatableComponent]) -> None:
19+
super().__init__()
20+
self.component = component
21+
22+
self._added_models: dict[Reference, M] = {}
23+
self._rendered_models: set[Reference] = set()
24+
25+
def _render_to_parent(self) -> None:
26+
with self.parent:
27+
for reference in self._added_models:
28+
if reference not in self._rendered_models:
29+
self._render_component(reference)
30+
31+
def _render_component(self, reference: Reference) -> None:
32+
with self.parent:
33+
model = self._added_models[reference]
34+
component = self.component(model)
35+
component.display()
36+
self._rendered_models.add(reference)
37+
38+
def display(self) -> None:
39+
self._render_to_parent()
40+
41+
def add_or_update_model(self, reference: Reference, display_model: M) -> None:
42+
"""adds or updates and existing ui element form a given model"""
43+
if reference not in self._added_models:
44+
self._added_models[reference] = display_model
45+
self._render_component(reference)
46+
else:
47+
self._added_models[reference].update(display_model)
48+
49+
def remove_model(self, reference: Reference) -> None:
50+
"""removes a model from the ui via it's given reference"""
51+
if reference in self._added_models:
52+
self._added_models[reference].remove_from_ui()
53+
del self._added_models[reference]
54+
self._rendered_models.remove(reference)
55+
56+
def update_from_dict(self, models: dict[Reference, M]) -> None:
57+
"""updates UI given a new input"""
58+
# remove models that are not in the new list
59+
for reference in tuple(self._added_models.keys()):
60+
if reference not in models:
61+
self.remove_model(reference)
62+
63+
# add or update existing models
64+
for reference, model in models.items():
65+
self.add_or_update_model(reference, model)
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
from unittest.mock import Mock
2+
3+
import pytest
4+
from pydantic import NonNegativeInt, TypeAdapter
5+
from simcore_service_dynamic_scheduler.api.frontend._common.base_display_model import (
6+
BaseUpdatableDisplayModel,
7+
CompleteModelDict,
8+
)
9+
10+
11+
class Pet(BaseUpdatableDisplayModel):
12+
name: str
13+
species: str
14+
15+
16+
class Friend(BaseUpdatableDisplayModel):
17+
name: str
18+
age: int
19+
20+
21+
class RenderOnPropertyValueChange(BaseUpdatableDisplayModel):
22+
name: str
23+
age: int
24+
companion: Pet | Friend
25+
26+
27+
class RenderOnPropertyTypeChange(BaseUpdatableDisplayModel):
28+
name: str
29+
age: int
30+
companion: Pet | Friend
31+
32+
33+
@pytest.mark.parametrize(
34+
"class_, initial_dict, update_dict, expected_dict, on_type_change, on_value_change",
35+
[
36+
pytest.param(
37+
Pet,
38+
{"name": "Fluffy", "species": "cat"},
39+
{"name": "Fido", "species": "dog"},
40+
{"name": "Fido", "species": "dog"},
41+
{},
42+
{},
43+
id="does-not-require-rerender-without-any-render-on-declared",
44+
),
45+
pytest.param(
46+
RenderOnPropertyValueChange,
47+
{
48+
"name": "Alice",
49+
"age": 30,
50+
"companion": {"name": "Fluffy", "species": "cat"},
51+
},
52+
{
53+
"name": "Alice",
54+
"age": 30,
55+
"companion": {"name": "Fido", "species": "dog"},
56+
},
57+
{
58+
"name": "Alice",
59+
"age": 30,
60+
"companion": {"name": "Fido", "species": "dog"},
61+
},
62+
{},
63+
{"companion": 1},
64+
id="requires-rerender-on-property-change",
65+
),
66+
pytest.param(
67+
RenderOnPropertyValueChange,
68+
{
69+
"name": "Alice",
70+
"age": 30,
71+
"companion": {"name": "Fluffy", "species": "cat"},
72+
},
73+
{
74+
"name": "Alice",
75+
"age": 30,
76+
"companion": {"name": "Fluffy", "species": "cat"},
77+
},
78+
{
79+
"name": "Alice",
80+
"age": 30,
81+
"companion": {"name": "Fluffy", "species": "cat"},
82+
},
83+
{},
84+
{"companion": 0},
85+
id="do-not-require-rerender-if-same-value",
86+
),
87+
pytest.param(
88+
RenderOnPropertyTypeChange,
89+
{
90+
"name": "Alice",
91+
"age": 30,
92+
"companion": {"name": "Fluffy", "species": "cat"},
93+
},
94+
{
95+
"name": "Alice",
96+
"age": 31,
97+
"companion": {"name": "Fido", "species": "dog"},
98+
},
99+
{
100+
"name": "Alice",
101+
"age": 31,
102+
"companion": {"name": "Fido", "species": "dog"},
103+
},
104+
{"companion": 0},
105+
{},
106+
id="does-not-require-rerender-if-same-type-with-value-changes",
107+
),
108+
pytest.param(
109+
RenderOnPropertyTypeChange,
110+
{
111+
"name": "Alice",
112+
"age": 30,
113+
"companion": {"name": "Fluffy", "species": "cat"},
114+
},
115+
{
116+
"name": "Alice",
117+
"age": 30,
118+
"companion": {"name": "Fluffy", "species": "cat"},
119+
},
120+
{
121+
"name": "Alice",
122+
"age": 30,
123+
"companion": {"name": "Fluffy", "species": "cat"},
124+
},
125+
{"companion": 0},
126+
{},
127+
id="does-not-require-rerender-if-same-type-with-NO-value-changes",
128+
),
129+
pytest.param(
130+
RenderOnPropertyTypeChange,
131+
{
132+
"name": "Alice",
133+
"age": 30,
134+
"companion": {"name": "Fluffy", "species": "cat"},
135+
},
136+
{"name": "Alice", "age": 31, "companion": {"name": "Charlie", "age": 25}},
137+
{"name": "Alice", "age": 31, "companion": {"name": "Charlie", "age": 25}},
138+
{"companion": 1},
139+
{},
140+
id="requires-rerender-when-type-changes",
141+
),
142+
],
143+
)
144+
def test_base_updatable_display_model(
145+
class_: type[BaseUpdatableDisplayModel],
146+
initial_dict: CompleteModelDict,
147+
update_dict: CompleteModelDict,
148+
expected_dict: CompleteModelDict,
149+
on_type_change: dict[str, NonNegativeInt],
150+
on_value_change: dict[str, NonNegativeInt],
151+
):
152+
person = TypeAdapter(class_).validate_python(initial_dict)
153+
assert person.model_dump() == initial_dict
154+
155+
subscribed_on_type_changed: dict[str, Mock] = {}
156+
for attribute in on_type_change:
157+
mock = Mock()
158+
person.on_type_change(attribute, mock)
159+
subscribed_on_type_changed[attribute] = mock
160+
161+
subscribed_on_value_change: dict[str, Mock] = {}
162+
for attribute in on_value_change:
163+
mock = Mock()
164+
person.on_value_change(attribute, mock)
165+
subscribed_on_value_change[attribute] = mock
166+
167+
person.update(TypeAdapter(class_).validate_python(update_dict))
168+
assert person.model_dump() == expected_dict
169+
170+
for attribute, mock in subscribed_on_type_changed.items():
171+
assert (
172+
mock.call_count == on_type_change[attribute]
173+
), f"wrong on_type_change count for '{attribute}'"
174+
175+
for attribute, mock in subscribed_on_value_change.items():
176+
assert (
177+
mock.call_count == on_value_change[attribute]
178+
), f"wrong on_value_change count for '{attribute}'"

0 commit comments

Comments
 (0)