-
Notifications
You must be signed in to change notification settings - Fork 32
✨ nicegui rendering aids #8427
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
GitHK
merged 35 commits into
ITISFoundation:master
from
GitHK:pr-osparc-migrate-dy-scheduler-part4
Sep 29, 2025
Merged
✨ nicegui rendering aids #8427
Changes from 31 commits
Commits
Show all changes
35 commits
Select commit
Hold shift + click to select a range
69d528b
fixed directory structure
92880f1
Merge remote-tracking branch 'upstream/master' into pr-osparc-migrate…
060f553
added base display model
9953126
fixed interface
ccfc0e1
refactor
8029182
fixed types
3528daf
enhanced testing utils
3b8056b
refactor subscribers
27ffd9c
fixed test
2f29be2
baseline test
488661f
refactor
5398166
revert change
8e94831
refactor
11fbf6a
fixed test
2f05c0c
speedup tests
a5beb8a
fixed tests
8607c2e
Merge remote-tracking branch 'upstream/master' into pr-osparc-migrate…
b7fca98
added signal to remove an UI element form the model
24da1d0
Merge remote-tracking branch 'upstream/master' into pr-osparc-migrate…
d86d292
fixed tests
ed3bec1
refactor test
5cb54ff
refactor
74874ef
renamed
64aa3af
made tests more redable
751a3b1
added basic rendering logic
77ca02e
enhanced helpers
e88a8d8
refactor
27466ab
rename
02eaab2
docstring
62c4deb
refactor
832c17b
Merge remote-tracking branch 'upstream/master' into pr-osparc-migrate…
8e5b3d6
typo
48e84c1
typo
741e0f3
added note
c45096b
feedback
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Empty file.
32 changes: 32 additions & 0 deletions
32
...s/dynamic-scheduler/src/simcore_service_dynamic_scheduler/api/frontend/_common/_mixins.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() | ||
GitHK marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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""" | ||
23 changes: 23 additions & 0 deletions
23
...ic-scheduler/src/simcore_service_dynamic_scheduler/api/frontend/_common/base_component.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
GitHK marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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""" | ||
81 changes: 81 additions & 0 deletions
81
...cheduler/src/simcore_service_dynamic_scheduler/api/frontend/_common/base_display_model.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
GitHK marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| _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) | ||
| ): | ||
GitHK marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| callbaks_to_run.append(callback) | ||
|
|
||
| return callbaks_to_run | ||
GitHK marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| 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) | ||
|
|
||
GitHK marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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 | ||
65 changes: 65 additions & 0 deletions
65
...ces/dynamic-scheduler/src/simcore_service_dynamic_scheduler/api/frontend/_common/stack.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) |
178 changes: 178 additions & 0 deletions
178
services/dynamic-scheduler/tests/unit/api_frontend/_common/test_base_display_model.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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): | ||
GitHK marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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}'" | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.