From 412ef0ebd3fc5a029b87ff5bc04e2470142bdd08 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Wed, 17 Dec 2025 09:22:21 +0100 Subject: [PATCH 01/60] First steps --- example/src/components/data_viewer.py | 6 +- example/src/components/login_dialog.py | 13 ++- example/src/components/metric.py | 8 +- example/src/components/sidebar.py | 3 +- example/src/flows/refresh.py | 1 + example/src/layouts/dashboard.py | 5 +- example/src/layouts/login.py | 4 +- example/src/layouts/manage_data.py | 2 +- src/ststeroids/component.py | 144 ++++++++++--------------- src/ststeroids/store.py | 79 +++++++------- 10 files changed, 114 insertions(+), 151 deletions(-) diff --git a/example/src/components/data_viewer.py b/example/src/components/data_viewer.py index b0f7cfe..7f1fbe4 100644 --- a/example/src/components/data_viewer.py +++ b/example/src/components/data_viewer.py @@ -6,12 +6,10 @@ class DataViewerComponent(Component): def __init__( self, - component_id: str, header: str, column_config: dict = {}, column_order: list = [], ): - super().__init__(component_id, {"data": None, "dek": uuid.uuid4()}) self.header = header self.column_config = column_config self.column_order = column_order @@ -19,11 +17,11 @@ def __init__( def render(self): st.subheader(self.header) st.dataframe( - self.state.data, + self.data, hide_index=True, column_config=self.column_config, column_order=self.column_order, ) def set_data(self, data): - self.state.data = data + self.data = data diff --git a/example/src/components/login_dialog.py b/example/src/components/login_dialog.py index 6f48ab0..6e4d674 100644 --- a/example/src/components/login_dialog.py +++ b/example/src/components/login_dialog.py @@ -5,18 +5,17 @@ class LoginDialogComponent(Component): def __init__( self, - component_id: str, login_flow: Flow, login_success_flow: Flow, header: str = "Enter username/password", ): - super().__init__(component_id, {"visible": False}) self.header = header self.login_flow = login_flow self.login_success_flow = login_success_flow + self.visible = False def render(self): - if self.state.visible: + if self.visible: username = st.text_input("Username") password = st.text_input("Password", type="password") if st.button("Login", use_container_width=True): @@ -27,11 +26,11 @@ def render(self): st.error("Login failed, please check your username and password.") def show(self): - if self.state.visible is False: - self.state.visible = True + if self.visible is False: + self.visible = True st.rerun() def hide(self): - if self.state.visible is True: - self.state.visible = False + if self.visible is True: + self.visible = False st.rerun() diff --git a/example/src/components/metric.py b/example/src/components/metric.py index 87b992f..3cf9c27 100644 --- a/example/src/components/metric.py +++ b/example/src/components/metric.py @@ -5,14 +5,14 @@ class MetricComponent(Component): def __init__( self, - component_id: str, header: str, ): - super().__init__(component_id, {"value": None}) self.header = header + self.value = 0 + print("running") def render(self): - st.metric(self.header, self.state.value) + st.metric(self.header, self.value) def set_value(self, value: int): - self.state.value = value + self.value = value diff --git a/example/src/components/sidebar.py b/example/src/components/sidebar.py index 51b0291..e54a2aa 100644 --- a/example/src/components/sidebar.py +++ b/example/src/components/sidebar.py @@ -4,8 +4,7 @@ class SidebarComponent(Component): - def __init__(self, component_id: str, router: Router): - super().__init__(component_id) + def __init__(self, str, router: Router): self.router = router def render(self): diff --git a/example/src/flows/refresh.py b/example/src/flows/refresh.py index 4c93e95..323cb42 100644 --- a/example/src/flows/refresh.py +++ b/example/src/flows/refresh.py @@ -18,6 +18,7 @@ def run(self): cp_avg_rating: MetricComponent = self.component_store.get_component( ComponentIDs.avg_rating ) + print(cp_avg_rating.value) response = self.backend_service.get_movies() if response.ok: data = response.json() diff --git a/example/src/layouts/dashboard.py b/example/src/layouts/dashboard.py index 0991354..cd13c10 100644 --- a/example/src/layouts/dashboard.py +++ b/example/src/layouts/dashboard.py @@ -7,8 +7,9 @@ class DashboardLayout(Layout): def __init__(self, refresh_flow: Flow): self.refresh_flow = refresh_flow - self.total_movies = MetricComponent(ComponentIDs.total_movies, "Total movies") - self.avg_rating = MetricComponent(ComponentIDs.avg_rating, "Avg. Rating") + self.total_movies = MetricComponent.create(ComponentIDs.total_movies, "Total movies") + self.avg_rating = MetricComponent.create(ComponentIDs.avg_rating, "Avg. Rating") + def render(self): left, right = st.columns([1, 1]) diff --git a/example/src/layouts/login.py b/example/src/layouts/login.py index 8f61ffa..79f6f29 100644 --- a/example/src/layouts/login.py +++ b/example/src/layouts/login.py @@ -12,9 +12,7 @@ def __init__( login_success_flow: Flow, ): self.login_header = login_header - self.login_dialog = LoginDialogComponent( - ComponentIDs.dialog_login, login_flow, login_success_flow - ) + self.login_dialog = LoginDialogComponent.create(ComponentIDs.dialog_login, login_flow, login_success_flow) def render(self): self.login_dialog.execute_render("dialog", {"title": self.login_header}) diff --git a/example/src/layouts/manage_data.py b/example/src/layouts/manage_data.py index b62dbd5..0fec15e 100644 --- a/example/src/layouts/manage_data.py +++ b/example/src/layouts/manage_data.py @@ -5,7 +5,7 @@ class ManageDataLayout(Layout): def __init__(self): - self.data_viewer = DataViewerComponent(ComponentIDs.data_viewer, "Movies") + self.data_viewer = DataViewerComponent.create(ComponentIDs.data_viewer, "Movies") def render(self): self.data_viewer.render() diff --git a/src/ststeroids/component.py b/src/ststeroids/component.py index dda12d5..30779fe 100644 --- a/src/ststeroids/component.py +++ b/src/ststeroids/component.py @@ -15,45 +15,60 @@ class Component: state (State): The state associated with the component. """ - def __new__(cls, *args, **kwargs): - """Creates an new instance of the component or returns it from the session.""" - component_id = kwargs.get("component_id") or (args[0] if args else None) - if component_id is None: - raise KeyError("component_id is required") - - cls.__store = ComponentStore() - component_instance_exists = cls.__store.has_property(component_id) - if component_instance_exists: - return cls.__store.get_component(component_id) - return super().__new__(cls) - - def __init_subclass__(cls, **kwargs): - """Wrap subclass __init__ so it only runs once.""" - super().__init_subclass__(**kwargs) - orig_init = cls.__init__ - - @wraps(orig_init) - def wrapped_init(self, *args, **kwargs): - if getattr(self, "_sub_initialized", False): - return - orig_init(self, *args, **kwargs) - self._sub_initialized = True - - cls.__init__ = wrapped_init - - def __init__(self, component_id: str, initial_state: dict = None): - """ - Initializes the component with a unique ID and initial state. - - :param component_id: The unique identifier for the component. - :param initial_state: Initial state for the component. Defaults to an empty dictionary. - """ - self.id = component_id - self.state = State( - self.id, self.__store, initial_state if initial_state else {} - ) - self.__store.init_component(self) - + # def __new__(cls, *args, **kwargs): + # """Creates an new instance of the component or returns it from the session.""" + # component_id = kwargs.get("component_id") or (args[0] if args else None) + # if component_id is None: + # raise KeyError("component_id is required") + + # cls.__store = ComponentStore() + # component_instance_exists = cls.__store.has_property(component_id) + # if component_instance_exists: + # return cls.__store.get_component(component_id) + # return super().__new__(cls) + + # def __init_subclass__(cls, **kwargs): + # """Wrap subclass __init__ so it only runs once.""" + # super().__init_subclass__(**kwargs) + # orig_init = cls.__init__ + + # @wraps(orig_init) + # def wrapped_init(self, *args, **kwargs): + # if getattr(self, "_sub_initialized", False): + # return + # orig_init(self, *args, **kwargs) + # self._sub_initialized = True + + # cls.__init__ = wrapped_init + + # def __init__(self, component_id: str): + # """ + # Initializes the component with a unique ID and initial state. + + # :param component_id: The unique identifier for the component. + # :param initial_state: Initial state for the component. Defaults to an empty dictionary. + # """ + # self.id = component_id + # # self.state = State( + # # self.id, self.__store, initial_state if initial_state else {} + # # ) + # self.__store.init_component(self) + + @classmethod + def create(cls, component_id:str, *args, **kwargs): + """ + Create a new component instance or return it from the store. + """ + cls._store = ComponentStore() + + if cls._store.has_property(component_id): + return cls._store.get_component(component_id) + + instance = cls(*args, **kwargs) + instance.id = component_id + cls._store.init_component(instance) + return instance + def register_element(self, element_name: str): """ Generates a unique key for an element based on the instance ID. @@ -163,54 +178,3 @@ def render(self) -> None: :raises NotImplementedError: If called directly without being implemented in a subclass. """ raise NotImplementedError("Subclasses should implement this method.") - - -class State: - """ - Manages the state of a component, storing and retrieving properties - through the associated store. - - Attributes: - __id (str): The unique identifier for the component. - __store (ComponentStore): The store instance that holds the component's state. - """ - - def __init__(self, component_id: str, store: ComponentStore, initial_state: dict): - """ - Initializes the state for a component, setting up the store and component ID. - - :param component_id: The unique identifier for the component. - :param store: The store instance where the state is stored. - :param initial_state: Initial state data for the component. - """ - super().__setattr__( - "_State__id", component_id - ) # Directly set private attributes - super().__setattr__("_State__store", store) # Avoid recursion - store.init_component_state(component_id, initial_state) - - def __getattr__(self, name) -> Any: - """ - Retrieves a property of the component from the store. - - :param name: The name of the property to retrieve. - :return: The value of the property from the store. - - :raises AttributeError: If the requested property is not found. - """ - if not name.startswith("__"): - return self.__store.get_property(self.__id, name) - - def __setattr__(self, name, value): - """ - Sets a property of the component in the store. - - :param name: The name of the property to set. - :param value: The value to set for the property. - - This method avoids recursion for special attributes and handles normal properties. - """ - if not name.startswith("__"): - self.__store.set_property(self.__id, name, value) - else: - super().__setattr__(name, value) # Avoid recursion for special attributes diff --git a/src/ststeroids/store.py b/src/ststeroids/store.py index 817b746..dec9d88 100644 --- a/src/ststeroids/store.py +++ b/src/ststeroids/store.py @@ -90,49 +90,49 @@ def init_component(self, component: object) -> None: """ Initializes a component in the session store with its ID - :param component_id: The unique identifier for the component. + :param component: The component instance. :return: None """ if not self.has_property(component.id): super().set_property(component.id, component) - def init_component_state(self, component_id: str, initial_state: dict) -> None: - """ - Initializes a component state in the session store with its ID and initial state. - - :param component_id: The unique identifier for the component. - :param initial_state: The initial state to set for the component. - :return: None - """ - if not self.has_property(f"{component_id}_state"): - super().set_property(f"{component_id}_state", initial_state) - - def get_property( # pylint: disable=arguments-differ - self, component_id: str, property_name: str - ) -> Any: - """ - Retrieves the value of a property from a component's state. - - :param component_id: The unique identifier for the component. - :param property_name: The name of the property to retrieve. - :return: The value of the property from the component's state. - """ - return super().get_property(f"{component_id}_state")[property_name] - - def set_property( # pylint: disable=arguments-differ - self, component_id: str, property_name: str, property_value: Any - ) -> None: - """ - Sets the value of a property in a component's state. - - :param component_id: The unique identifier for the component. - :param property_name: The name of the property to set. - :param property_value: The value to set for the property. - :return: None - """ - component_state = super().get_property(f"{component_id}_state") - component_state[property_name] = property_value - super().set_property(f"{component_id}_state", component_state) + # def init_component_state(self, component_id: str, initial_state: dict) -> None: + # """ + # Initializes a component state in the session store with its ID and initial state. + + # :param component_id: The unique identifier for the component. + # :param initial_state: The initial state to set for the component. + # :return: None + # """ + # if not self.has_property(f"{component_id}_state"): + # super().set_property(f"{component_id}_state", initial_state) + + # def get_property( # pylint: disable=arguments-differ + # self, component_id: str, property_name: str + # ) -> Any: + # """ + # Retrieves the value of a property from a component's state. + + # :param component_id: The unique identifier for the component. + # :param property_name: The name of the property to retrieve. + # :return: The value of the property from the component's state. + # """ + # return super().get_property(f"{component_id}_state")[property_name] + + # def set_property( # pylint: disable=arguments-differ + # self, component_id: str, property_name: str, property_value: Any + # ) -> None: + # """ + # Sets the value of a property in a component's state. + + # :param component_id: The unique identifier for the component. + # :param property_name: The name of the property to set. + # :param property_value: The value to set for the property. + # :return: None + # """ + # component_state = super().get_property(f"{component_id}_state") + # component_state[property_name] = property_value + # super().set_property(f"{component_id}_state", component_state) def get_component(self, component_id: str): """ @@ -142,3 +142,6 @@ def get_component(self, component_id: str): :return: The component's state or properties. """ return super().get_property(component_id) + + # def set_component(self, component): + # super().set_property(component.id, component) From 40a7bf5bec7615c964f6ff9274f6ed17ed2a5c5e Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Sat, 20 Dec 2025 07:14:35 +0100 Subject: [PATCH 02/60] Version bump --- README.md | 8 ++++++++ pyproject.toml | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index afe672d..9ab59ab 100644 --- a/README.md +++ b/README.md @@ -287,6 +287,14 @@ app_style.apply_style() ### Release notes +1.0.0 + +A partial rewrite of the framework so that it has a smaller footprint and creation of objects feels more natural and is better supported by editors and debuggers. + +- + +**Note** this version is considered to be a breaking change. Make sure to adapt your code base so that it works with this new version. + 0.1.17 - Improved execute_render function by adding an error handler diff --git a/pyproject.toml b/pyproject.toml index 502c3e5..ccbf457 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ststeroids" -version = "0.1.17" +version = "1.0.0" description = "A framework supercharging Streamlit for building advanced multi-page applications" readme = "README.md" authors = [{ name = "ponsoc"}] From ce15cf5b36c44452b39d3d24acf8303e2197e042 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Sat, 20 Dec 2025 07:16:06 +0100 Subject: [PATCH 03/60] Flow and layout are now abstract --- src/ststeroids/flow.py | 10 +++++----- src/ststeroids/layout.py | 7 +++++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/ststeroids/flow.py b/src/ststeroids/flow.py index 29d3e05..d03454a 100644 --- a/src/ststeroids/flow.py +++ b/src/ststeroids/flow.py @@ -1,8 +1,9 @@ from .store import ComponentStore +from abc import ABC, abstractmethod # pylint: disable=too-few-public-methods -class Flow: +class Flow(ABC): """ Base class for a flow that can interact with the component store """ @@ -19,15 +20,14 @@ def execute_run(self, *args, **kwargs): """ return self.run(*args, **kwargs) + @abstractmethod def run(self, *args, **kwargs): """ - Executes the flow logic. + Abstract methods that executes the flow logic. Each derived class should implement its own `run` method. :param args: Positional arguments for the run method. :param kwargs: Keyword arguments for the run method. - :return: None - :raises NotImplementedError: If the method is not implemented in a subclass. """ - raise NotImplementedError("Subclasses must implement the run method.") + pass diff --git a/src/ststeroids/layout.py b/src/ststeroids/layout.py index fb8d380..611dd12 100644 --- a/src/ststeroids/layout.py +++ b/src/ststeroids/layout.py @@ -1,4 +1,6 @@ -class Layout: +from abc import ABC, abstractmethod + +class Layout(ABC): """ Base class for a layout """ @@ -12,9 +14,10 @@ def execute_render(self): """ self.render() + @abstractmethod def render(self) -> None: """ - Placeholder method for rendering the layout. + Abstract method for rendering the layout. This method should be implemented by subclasses to define how the layout is rendered. From 58f077f3d3a3dffdfba53a92cf2813ecc424a477 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Sat, 20 Dec 2025 07:16:32 +0100 Subject: [PATCH 04/60] Component absract --- src/ststeroids/component.py | 56 ++++++------------------------------- 1 file changed, 8 insertions(+), 48 deletions(-) diff --git a/src/ststeroids/component.py b/src/ststeroids/component.py index 30779fe..1e9e185 100644 --- a/src/ststeroids/component.py +++ b/src/ststeroids/component.py @@ -1,4 +1,5 @@ -from typing import Any, Literal +from typing import Literal +from abc import ABC, abstractmethod import streamlit as st from .store import ComponentStore from .flow import Flow @@ -6,54 +7,14 @@ # pylint: disable=too-few-public-methods -class Component: +class Component(ABC): """ - Base class for a component that interacts with the state and the store. + Base class for a component that interacts with the the store. Attributes: id (str): The unique identifier for the component. - state (State): The state associated with the component. """ - # def __new__(cls, *args, **kwargs): - # """Creates an new instance of the component or returns it from the session.""" - # component_id = kwargs.get("component_id") or (args[0] if args else None) - # if component_id is None: - # raise KeyError("component_id is required") - - # cls.__store = ComponentStore() - # component_instance_exists = cls.__store.has_property(component_id) - # if component_instance_exists: - # return cls.__store.get_component(component_id) - # return super().__new__(cls) - - # def __init_subclass__(cls, **kwargs): - # """Wrap subclass __init__ so it only runs once.""" - # super().__init_subclass__(**kwargs) - # orig_init = cls.__init__ - - # @wraps(orig_init) - # def wrapped_init(self, *args, **kwargs): - # if getattr(self, "_sub_initialized", False): - # return - # orig_init(self, *args, **kwargs) - # self._sub_initialized = True - - # cls.__init__ = wrapped_init - - # def __init__(self, component_id: str): - # """ - # Initializes the component with a unique ID and initial state. - - # :param component_id: The unique identifier for the component. - # :param initial_state: Initial state for the component. Defaults to an empty dictionary. - # """ - # self.id = component_id - # # self.state = State( - # # self.id, self.__store, initial_state if initial_state else {} - # # ) - # self.__store.init_component(self) - @classmethod def create(cls, component_id:str, *args, **kwargs): """ @@ -168,13 +129,12 @@ def execute_render( case "fragment": return self._render_fragment(**options) raise ValueError(f"Unexpected render_as value: {render_as}") - + + @abstractmethod def render(self) -> None: """ - Placeholder method for rendering the component. + Abstract method for rendering the component. This method should be implemented by subclasses to define how the component is rendered. - - :raises NotImplementedError: If called directly without being implemented in a subclass. """ - raise NotImplementedError("Subclasses should implement this method.") + pass From 8b158ea8a4d1729164b97e7a40ef17220648eb68 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Sat, 20 Dec 2025 07:16:52 +0100 Subject: [PATCH 05/60] Cleaning the store --- src/ststeroids/store.py | 43 +---------------------------------------- 1 file changed, 1 insertion(+), 42 deletions(-) diff --git a/src/ststeroids/store.py b/src/ststeroids/store.py index dec9d88..f01069e 100644 --- a/src/ststeroids/store.py +++ b/src/ststeroids/store.py @@ -96,44 +96,6 @@ def init_component(self, component: object) -> None: if not self.has_property(component.id): super().set_property(component.id, component) - # def init_component_state(self, component_id: str, initial_state: dict) -> None: - # """ - # Initializes a component state in the session store with its ID and initial state. - - # :param component_id: The unique identifier for the component. - # :param initial_state: The initial state to set for the component. - # :return: None - # """ - # if not self.has_property(f"{component_id}_state"): - # super().set_property(f"{component_id}_state", initial_state) - - # def get_property( # pylint: disable=arguments-differ - # self, component_id: str, property_name: str - # ) -> Any: - # """ - # Retrieves the value of a property from a component's state. - - # :param component_id: The unique identifier for the component. - # :param property_name: The name of the property to retrieve. - # :return: The value of the property from the component's state. - # """ - # return super().get_property(f"{component_id}_state")[property_name] - - # def set_property( # pylint: disable=arguments-differ - # self, component_id: str, property_name: str, property_value: Any - # ) -> None: - # """ - # Sets the value of a property in a component's state. - - # :param component_id: The unique identifier for the component. - # :param property_name: The name of the property to set. - # :param property_value: The value to set for the property. - # :return: None - # """ - # component_state = super().get_property(f"{component_id}_state") - # component_state[property_name] = property_value - # super().set_property(f"{component_id}_state", component_state) - def get_component(self, component_id: str): """ Retrieves the current state or properties of a component. @@ -141,7 +103,4 @@ def get_component(self, component_id: str): :param component_id: The unique identifier for the component. :return: The component's state or properties. """ - return super().get_property(component_id) - - # def set_component(self, component): - # super().set_property(component.id, component) + return super().get_property(component_id) \ No newline at end of file From 489b63e17aa6d67204c9babab77e8a38be50cf35 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Sat, 20 Dec 2025 07:58:24 +0100 Subject: [PATCH 06/60] Sidebar is a component and should be in a layout, not in main app --- README.md | 4 ++++ example/src/components/sidebar.py | 5 +++-- example/src/layouts/dashboard.py | 4 +++- example/src/layouts/login.py | 4 +++- example/src/layouts/manage_data.py | 4 +++- example/src/main.py | 4 ---- 6 files changed, 16 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index afe672d..3e3f57b 100644 --- a/README.md +++ b/README.md @@ -287,6 +287,10 @@ app_style.apply_style() ### Release notes +0.1.18 + +- Updated example app so that sidebar is actually defined and rendered a layout and not in the main app + 0.1.17 - Improved execute_render function by adding an error handler diff --git a/example/src/components/sidebar.py b/example/src/components/sidebar.py index 51b0291..06a7c74 100644 --- a/example/src/components/sidebar.py +++ b/example/src/components/sidebar.py @@ -4,11 +4,12 @@ class SidebarComponent(Component): - def __init__(self, component_id: str, router: Router): + def __init__(self, component_id: str): super().__init__(component_id) - self.router = router + # self.router = router def render(self): + # print(self.router.get_current_route()) with st.sidebar: st.page_link("pages/dashboard.py", icon=":material/search:", label="Dashboard") st.page_link("pages/manage.py", icon=":material/bar_chart:", label="Manage data") diff --git a/example/src/layouts/dashboard.py b/example/src/layouts/dashboard.py index 0991354..5ab8f52 100644 --- a/example/src/layouts/dashboard.py +++ b/example/src/layouts/dashboard.py @@ -1,5 +1,5 @@ import streamlit as st -from components import MetricComponent +from components import MetricComponent, SidebarComponent from shared import ComponentIDs from ststeroids import Layout, Flow @@ -7,10 +7,12 @@ class DashboardLayout(Layout): def __init__(self, refresh_flow: Flow): self.refresh_flow = refresh_flow + self.sidebar = SidebarComponent("sidebar") self.total_movies = MetricComponent(ComponentIDs.total_movies, "Total movies") self.avg_rating = MetricComponent(ComponentIDs.avg_rating, "Avg. Rating") def render(self): + self.sidebar.execute_render() left, right = st.columns([1, 1]) with left: self.total_movies.execute_render() diff --git a/example/src/layouts/login.py b/example/src/layouts/login.py index 8f61ffa..1198991 100644 --- a/example/src/layouts/login.py +++ b/example/src/layouts/login.py @@ -1,5 +1,5 @@ import streamlit as st -from components import LoginDialogComponent +from components import LoginDialogComponent, SidebarComponent from shared import ComponentIDs from ststeroids import Flow, Layout @@ -11,12 +11,14 @@ def __init__( login_flow: Flow, login_success_flow: Flow, ): + self.sidebar = SidebarComponent("sidebar") self.login_header = login_header self.login_dialog = LoginDialogComponent( ComponentIDs.dialog_login, login_flow, login_success_flow ) def render(self): + self.sidebar.execute_render() self.login_dialog.execute_render("dialog", {"title": self.login_header}) st.write("Not logged in. Please refresh or use the menu on the left.") self.login_dialog.show() diff --git a/example/src/layouts/manage_data.py b/example/src/layouts/manage_data.py index b62dbd5..24ff0b6 100644 --- a/example/src/layouts/manage_data.py +++ b/example/src/layouts/manage_data.py @@ -1,4 +1,4 @@ -from components import DataViewerComponent +from components import DataViewerComponent, SidebarComponent from shared import ComponentIDs from ststeroids import Layout @@ -6,6 +6,8 @@ class ManageDataLayout(Layout): def __init__(self): self.data_viewer = DataViewerComponent(ComponentIDs.data_viewer, "Movies") + self.sidebar = SidebarComponent("sidebar") def render(self): + self.sidebar.execute_render() self.data_viewer.render() diff --git a/example/src/main.py b/example/src/main.py index 7e6efa7..ceec642 100644 --- a/example/src/main.py +++ b/example/src/main.py @@ -1,6 +1,5 @@ from collections import defaultdict import streamlit as st -from components import SidebarComponent from flows import LoginFlow, LoginSuccessFlow, RefreshFlow from layouts import LoginLayout, DashboardLayout, ManageDataLayout from service import MockBackendService @@ -25,11 +24,8 @@ def __init__(self): self.login_layout = LoginLayout("App login", self.login_flow, self.login_success_flow) self.dashboard_layout = DashboardLayout(self.refresh_flow) self.manage_data_layout = ManageDataLayout() - - self.sidebar = SidebarComponent("sidebar", self.router) def run(self, entry_route:str = None): - self.sidebar.render() def get_routes(): routes = defaultdict(lambda: self.login_layout) From ae2d619fcead30ae4e9b155654a19a372a42de03 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Tue, 23 Dec 2025 16:10:22 +0100 Subject: [PATCH 07/60] Second attempt --- example/src/app.py | 2 +- example/src/components/sidebar.py | 4 +- example/src/flows/login_succes.py | 8 ++-- example/src/main.py | 35 ++++++----------- example/src/pages/dashboard.py | 2 +- example/src/pages/manage.py | 2 +- src/ststeroids/__init__.py | 5 ++- src/ststeroids/main.py | 39 +++++++++++++++++++ src/ststeroids/route.py | 5 +++ src/ststeroids/route_builder.py | 19 ++++++++++ src/ststeroids/router.py | 62 +++++++++++++++---------------- 11 files changed, 115 insertions(+), 68 deletions(-) create mode 100644 src/ststeroids/main.py create mode 100644 src/ststeroids/route.py create mode 100644 src/ststeroids/route_builder.py diff --git a/example/src/app.py b/example/src/app.py index 208ed99..f0424c0 100644 --- a/example/src/app.py +++ b/example/src/app.py @@ -2,4 +2,4 @@ app = MainApp() -app.run() +app.app.run() diff --git a/example/src/components/sidebar.py b/example/src/components/sidebar.py index 06a7c74..67be210 100644 --- a/example/src/components/sidebar.py +++ b/example/src/components/sidebar.py @@ -1,15 +1,13 @@ import streamlit as st -from ststeroids import Component, Router +from ststeroids import Component class SidebarComponent(Component): def __init__(self, component_id: str): super().__init__(component_id) - # self.router = router def render(self): - # print(self.router.get_current_route()) with st.sidebar: st.page_link("pages/dashboard.py", icon=":material/search:", label="Dashboard") st.page_link("pages/manage.py", icon=":material/bar_chart:", label="Manage data") diff --git a/example/src/flows/login_succes.py b/example/src/flows/login_succes.py index c192528..55f7525 100644 --- a/example/src/flows/login_succes.py +++ b/example/src/flows/login_succes.py @@ -1,17 +1,17 @@ from service import MockBackendService from shared import ComponentIDs -from ststeroids import Flow, Router, Store +from ststeroids import Flow, Store from components import LoginDialogComponent, DataViewerComponent, MetricComponent +import streamlit as st class LoginSuccessFlow(Flow): def __init__( - self, router: Router, session_store: Store, backend_service: MockBackendService + self, session_store: Store, backend_service: MockBackendService ): super().__init__() self.session_store = session_store self.backend_service = backend_service - self.router = router def run(self): cp_login_dialog: LoginDialogComponent = self.component_store.get_component( @@ -31,5 +31,5 @@ def run(self): ) # Store the data in the session_store for later use in more complex applications cp_total_movies.set_value(len(data)) cp_data_viewer.set_data(data) - self.router.route("dashboard") + st.switch_page("pages/dashboard.py") cp_login_dialog.hide() diff --git a/example/src/main.py b/example/src/main.py index ceec642..5dd5bcb 100644 --- a/example/src/main.py +++ b/example/src/main.py @@ -3,17 +3,17 @@ from flows import LoginFlow, LoginSuccessFlow, RefreshFlow from layouts import LoginLayout, DashboardLayout, ManageDataLayout from service import MockBackendService -from ststeroids import Router, Store, Style +from ststeroids import Store, Style, StSteroids class MainApp: def __init__(self): self.session_store = Store("store") - self.router = Router("login") + # self.router = Router("login") self.backend_service = MockBackendService("./example/test_data.json") self.login_flow = LoginFlow(self.session_store, self.backend_service) - self.login_success_flow = LoginSuccessFlow(self.router, self.session_store, self.backend_service) + self.login_success_flow = LoginSuccessFlow(self.session_store, self.backend_service) self.refresh_flow = RefreshFlow(self.session_store, self.backend_service) st.set_page_config(page_title="StSteroids Example app", layout="wide") @@ -24,24 +24,11 @@ def __init__(self): self.login_layout = LoginLayout("App login", self.login_flow, self.login_success_flow) self.dashboard_layout = DashboardLayout(self.refresh_flow) self.manage_data_layout = ManageDataLayout() - - def run(self, entry_route:str = None): - - def get_routes(): - routes = defaultdict(lambda: self.login_layout) - routes["login"] = self.login_layout - - if self.session_store.has_property("access_token"): - routes.update( - { - "dashboard": self.dashboard_layout, - "manage_data": self.manage_data_layout, - }, - ) - - return routes - - self.router.register_routes(get_routes()) - if entry_route: - self.router.route(entry_route) - self.router.run() + + self.app = StSteroids() + + self.app.default(self.login_layout) + + self.app.route("login").to(self.login_layout).register() + self.app.route("dashboard").to(self.dashboard_layout).when(lambda: self.session_store.has_property("access_token")).register() + self.app.route("manage_data").to(self.manage_data_layout).when(lambda: self.session_store.has_property("access_token")).register() diff --git a/example/src/pages/dashboard.py b/example/src/pages/dashboard.py index 6d68026..ac7a751 100644 --- a/example/src/pages/dashboard.py +++ b/example/src/pages/dashboard.py @@ -2,4 +2,4 @@ app = MainApp() -app.run("dashboard") +app.app.run("dashboard") diff --git a/example/src/pages/manage.py b/example/src/pages/manage.py index 7ddadba..858c941 100644 --- a/example/src/pages/manage.py +++ b/example/src/pages/manage.py @@ -2,4 +2,4 @@ app = MainApp() -app.run("manage_data") +app.app.run("manage_data") diff --git a/src/ststeroids/__init__.py b/src/ststeroids/__init__.py index 10e7a93..0625723 100644 --- a/src/ststeroids/__init__.py +++ b/src/ststeroids/__init__.py @@ -3,6 +3,7 @@ from .style import Style from .store import Store from .layout import Layout -from .router import Router +from .main import StSteroids -__all__ = ["Component", "Layout", "Flow", "Style", "Store", "Router"] + +__all__ = ["Component", "Layout", "Flow", "Style", "Store", "StSteroids"] diff --git a/src/ststeroids/main.py b/src/ststeroids/main.py new file mode 100644 index 0000000..7afe796 --- /dev/null +++ b/src/ststeroids/main.py @@ -0,0 +1,39 @@ +from .route import Route +from .route_builder import RouteBuilder +from .router import Router + +class StSteroids: + + def __init__(self): + self._router = Router() + self._routes: dict[str, Route] = {} + self._default: Route | None = None + + def route(self, name: str) -> "RouteBuilder": + return RouteBuilder(self, name) + + def default(self, target) -> None: + self._default = Route("__default__", target) + + def register(self, route: "Route"): + self._routes[route.name] = route + + def run(self, entry_route: str | None = None): + routes = {} + + if self._default: + routes["__default__"] = self._default + + for route in self._routes.values(): + if route.condition: + if route.condition(): + routes[route.name] = route + else: + routes[route.name] = route + + self._router.register_routes(routes) + + if entry_route: + self._router.route(entry_route) + + self._router.run() \ No newline at end of file diff --git a/src/ststeroids/route.py b/src/ststeroids/route.py new file mode 100644 index 0000000..dbf1cb1 --- /dev/null +++ b/src/ststeroids/route.py @@ -0,0 +1,5 @@ +class Route: + def __init__(self, name: str, target: callable, condition: callable = None): + self.name = name + self.target = target + self.condition = condition \ No newline at end of file diff --git a/src/ststeroids/route_builder.py b/src/ststeroids/route_builder.py new file mode 100644 index 0000000..853dd8f --- /dev/null +++ b/src/ststeroids/route_builder.py @@ -0,0 +1,19 @@ +from .route import Route + +class RouteBuilder: + def __init__(self, app, name: str): + self.app = app + self._name = name + self._target = None + self._condition = None + + def to(self, target): + self._target = target + return self + + def when(self, condition: callable): + self._condition = condition + return self + + def register(self): + self.app.register(Route(self._name, self._target, self._condition)) diff --git a/src/ststeroids/router.py b/src/ststeroids/router.py index e94e78b..810f5ce 100644 --- a/src/ststeroids/router.py +++ b/src/ststeroids/router.py @@ -1,55 +1,53 @@ import streamlit as st -from .layout import Layout +from .route import Route class Router: """ - A routing system for Streamlit applications, allowing navigation between different pages. + A routing system for the framework, allowing navigation between different layouts. """ - def __init__(self, default: str = "home"): + def __init__(self, default: str = "__default__"): """ - Initializes the Router instance with a default page. + Initializes the Router instance with a default layout. - :param default: The default page to load when the app starts. Defaults to "home". + :param default: The default layout to use when no current route is selected. """ - self.routes = {} - if "ststeroids_current_route" not in st.session_state: - st.session_state["ststeroids_current_route"] = default + self._routes: dict[str, Route] = {} + self._current: str | None = None + self._default: str = default - def run(self): + def register_routes(self, routes: dict[str, Route]): """ - Executes the function associated with the currently active route. - - :return: None + Registers a dictionary of routes where keys are route names and values are layout callables. """ - try: - route = self.routes[st.session_state["ststeroids_current_route"]] - except KeyError as exc: - raise KeyError( - f"The current route '{st.session_state['ststeroids_current_route']}' is not a registered route." - ) from exc - route() + self._routes = routes def route(self, route_name: str): """ - Updates the current page in the session state. + Sets the current route to execute. :param route_name: The name of the route to navigate to. - :return: None """ - st.session_state["ststeroids_current_route"] = route_name + self._current = route_name - def register_routes(self, routes: dict[str, Layout]): + def get_current_route(self) -> str | None: """ - Registers a dictionary of routes where keys are route names and values are layouts. - - :param routes: A dictionary mapping route names to layouts. - :return: None + Returns the name of the currently selected layout. """ - self.routes = routes + return self._current - def get_current_route(self): - if "ststeroids_current_route" in st.session_state: - return st.session_state["ststeroids_current_route"] - return None + def run(self): + """ + Executes the callable associated with the current layout. + Falls back to the default route if none is selected. + """ + if self._current in self._routes: + route = self._routes[self._current] + elif self._default in self._routes: + route = self._routes[self._default] + else: + raise RuntimeError( + "No current route selected and no default route registered." + ) + route.target() \ No newline at end of file From e91d374f761e0a7e9ce0d6a7d0ea05dcb1a52c35 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Tue, 23 Dec 2025 16:30:00 +0100 Subject: [PATCH 08/60] Review improvements --- example/src/main.py | 2 +- src/ststeroids/main.py | 5 ++++- src/ststeroids/router.py | 4 ++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/example/src/main.py b/example/src/main.py index 5dd5bcb..a42bb93 100644 --- a/example/src/main.py +++ b/example/src/main.py @@ -27,7 +27,7 @@ def __init__(self): self.app = StSteroids() - self.app.default(self.login_layout) + self.app.default_route(self.login_layout) self.app.route("login").to(self.login_layout).register() self.app.route("dashboard").to(self.dashboard_layout).when(lambda: self.session_store.has_property("access_token")).register() diff --git a/src/ststeroids/main.py b/src/ststeroids/main.py index 7afe796..f413594 100644 --- a/src/ststeroids/main.py +++ b/src/ststeroids/main.py @@ -12,11 +12,14 @@ def __init__(self): def route(self, name: str) -> "RouteBuilder": return RouteBuilder(self, name) - def default(self, target) -> None: + def default_route(self, target) -> None: self._default = Route("__default__", target) def register(self, route: "Route"): self._routes[route.name] = route + + def current_route(self): + return self._router.current_route() def run(self, entry_route: str | None = None): routes = {} diff --git a/src/ststeroids/router.py b/src/ststeroids/router.py index 810f5ce..12c4e80 100644 --- a/src/ststeroids/router.py +++ b/src/ststeroids/router.py @@ -11,7 +11,7 @@ def __init__(self, default: str = "__default__"): """ Initializes the Router instance with a default layout. - :param default: The default layout to use when no current route is selected. + :param default: The default route to use when no current route is selected. """ self._routes: dict[str, Route] = {} self._current: str | None = None @@ -31,7 +31,7 @@ def route(self, route_name: str): """ self._current = route_name - def get_current_route(self) -> str | None: + def current_route(self) -> str | None: """ Returns the name of the currently selected layout. """ From b5731156673d88ea94d1fc197ec86ab76c3f97e5 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Tue, 23 Dec 2025 16:32:40 +0100 Subject: [PATCH 09/60] Update README.md --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3e3f57b..41a8cc4 100644 --- a/README.md +++ b/README.md @@ -287,9 +287,10 @@ app_style.apply_style() ### Release notes -0.1.18 +1.0.0 - Updated example app so that sidebar is actually defined and rendered a layout and not in the main app +- Rewrite of the whole router concept. Making it easier to work with routes and conditional routes. Also moves application routing logic to the framework. 0.1.17 From 2296175541416f66f70e486ab52c2936799ee240 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Tue, 23 Dec 2025 16:45:43 +0100 Subject: [PATCH 10/60] Update refresh.py --- example/src/flows/refresh.py | 1 - 1 file changed, 1 deletion(-) diff --git a/example/src/flows/refresh.py b/example/src/flows/refresh.py index 323cb42..4c93e95 100644 --- a/example/src/flows/refresh.py +++ b/example/src/flows/refresh.py @@ -18,7 +18,6 @@ def run(self): cp_avg_rating: MetricComponent = self.component_store.get_component( ComponentIDs.avg_rating ) - print(cp_avg_rating.value) response = self.backend_service.get_movies() if response.ok: data = response.json() From 39da56ef6edf5eb9f01065857413fbef6b3778d7 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Tue, 23 Dec 2025 16:46:10 +0100 Subject: [PATCH 11/60] Update metric.py --- example/src/components/metric.py | 1 - 1 file changed, 1 deletion(-) diff --git a/example/src/components/metric.py b/example/src/components/metric.py index 3cf9c27..903facf 100644 --- a/example/src/components/metric.py +++ b/example/src/components/metric.py @@ -9,7 +9,6 @@ def __init__( ): self.header = header self.value = 0 - print("running") def render(self): st.metric(self.header, self.value) From 5fef8ab9d123f4012f9966ed4a9122d9b6c424d6 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Wed, 24 Dec 2025 10:31:57 +0100 Subject: [PATCH 12/60] No value in current route when properly using the router the layout is the active route --- example/src/components/sidebar.py | 10 ++++++++++ src/ststeroids/main.py | 3 --- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/example/src/components/sidebar.py b/example/src/components/sidebar.py index 67be210..2175e26 100644 --- a/example/src/components/sidebar.py +++ b/example/src/components/sidebar.py @@ -7,8 +7,18 @@ class SidebarComponent(Component): def __init__(self, component_id: str): super().__init__(component_id) + def _test(self): + if "test" not in st.session_state: + st.session_state["test"] = True + if st.session_state["test"] == True: + st.session_state["test"] = False + else: + st.session_state["test"] = True + print(st.session_state["test"]) + def render(self): with st.sidebar: st.page_link("pages/dashboard.py", icon=":material/search:", label="Dashboard") st.page_link("pages/manage.py", icon=":material/bar_chart:", label="Manage data") + st.button("test",key="testkey",on_click=self._test) \ No newline at end of file diff --git a/src/ststeroids/main.py b/src/ststeroids/main.py index f413594..46a143a 100644 --- a/src/ststeroids/main.py +++ b/src/ststeroids/main.py @@ -17,9 +17,6 @@ def default_route(self, target) -> None: def register(self, route: "Route"): self._routes[route.name] = route - - def current_route(self): - return self._router.current_route() def run(self, entry_route: str | None = None): routes = {} From 595912dd7021c223d31910049e7e9adab86bcf67 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Wed, 24 Dec 2025 17:03:34 +0100 Subject: [PATCH 13/60] Update layout.py --- src/ststeroids/layout.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/ststeroids/layout.py b/src/ststeroids/layout.py index 611dd12..9fbed73 100644 --- a/src/ststeroids/layout.py +++ b/src/ststeroids/layout.py @@ -20,7 +20,5 @@ def render(self) -> None: Abstract method for rendering the layout. This method should be implemented by subclasses to define how the layout is rendered. - - :raises NotImplementedError: If called directly without being implemented in a subclass. """ - raise NotImplementedError("Subclasses should implement this method.") + pass From fba86fbeccc91e82b0e7da30a523c7b43cfcf0a2 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Wed, 24 Dec 2025 17:03:45 +0100 Subject: [PATCH 14/60] Update refresh.py --- example/src/flows/refresh.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/src/flows/refresh.py b/example/src/flows/refresh.py index 4c93e95..65882a3 100644 --- a/example/src/flows/refresh.py +++ b/example/src/flows/refresh.py @@ -15,7 +15,7 @@ def __init__( self.backend_service = backend_service def run(self): - cp_avg_rating: MetricComponent = self.component_store.get_component( + cp_avg_rating= MetricComponent.get( ComponentIDs.avg_rating ) response = self.backend_service.get_movies() From 2b606fff85e3fcde1da66fc6d0310d9ffad716db Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Wed, 24 Dec 2025 17:03:54 +0100 Subject: [PATCH 15/60] Update login_succes.py --- example/src/flows/login_succes.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/example/src/flows/login_succes.py b/example/src/flows/login_succes.py index c192528..8c1d70a 100644 --- a/example/src/flows/login_succes.py +++ b/example/src/flows/login_succes.py @@ -14,13 +14,11 @@ def __init__( self.router = router def run(self): - cp_login_dialog: LoginDialogComponent = self.component_store.get_component( - ComponentIDs.dialog_login - ) - cp_data_viewer: DataViewerComponent = self.component_store.get_component( + cp_login_dialog = LoginDialogComponent.get(ComponentIDs.dialog_login) + cp_data_viewer = DataViewerComponent.get( ComponentIDs.data_viewer ) - cp_total_movies: MetricComponent = self.component_store.get_component( + cp_total_movies = MetricComponent.get ( ComponentIDs.total_movies ) response = self.backend_service.get_movies() From fca3bfe69bf0c4d423281b6058725a59015cae6d Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Wed, 24 Dec 2025 17:04:03 +0100 Subject: [PATCH 16/60] Update component.py --- src/ststeroids/component.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/ststeroids/component.py b/src/ststeroids/component.py index 1e9e185..9609530 100644 --- a/src/ststeroids/component.py +++ b/src/ststeroids/component.py @@ -3,7 +3,7 @@ import streamlit as st from .store import ComponentStore from .flow import Flow -from functools import wraps + # pylint: disable=too-few-public-methods @@ -24,11 +24,21 @@ def create(cls, component_id:str, *args, **kwargs): if cls._store.has_property(component_id): return cls._store.get_component(component_id) - - instance = cls(*args, **kwargs) - instance.id = component_id - cls._store.init_component(instance) - return instance + try: + instance = cls(*args, **kwargs) + instance.id = component_id + cls._store.init_component(instance) + return instance + except TypeError as e: + raise TypeError(f"{str(e)}. This usually happens when you are trying to get a component without creating it first.") + + + @classmethod + def get(cls, component_id: str) : + """ + Alias for create() — creation is implicit. + """ + return cls.create(component_id) def register_element(self, element_name: str): """ From 9833822de17228f2b7fb42fb800772e4a22444c4 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Wed, 24 Dec 2025 20:43:06 +0100 Subject: [PATCH 17/60] formatting --- example/src/components/sidebar.py | 9 ++++++--- example/src/flows/login_succes.py | 8 ++------ example/src/flows/refresh.py | 4 +--- example/src/layouts/dashboard.py | 5 +++-- example/src/layouts/login.py | 4 +++- example/src/layouts/manage_data.py | 4 +++- example/src/main.py | 13 +++++++++---- src/ststeroids/component.py | 16 ++++++++-------- src/ststeroids/flow.py | 9 +-------- src/ststeroids/layout.py | 1 + src/ststeroids/store.py | 2 +- tests/test_component.py | 18 +++++++++++++----- tests/test_store.py | 2 ++ 13 files changed, 53 insertions(+), 42 deletions(-) diff --git a/example/src/components/sidebar.py b/example/src/components/sidebar.py index e54a2aa..db246c4 100644 --- a/example/src/components/sidebar.py +++ b/example/src/components/sidebar.py @@ -9,6 +9,9 @@ def __init__(self, str, router: Router): def render(self): with st.sidebar: - st.page_link("pages/dashboard.py", icon=":material/search:", label="Dashboard") - st.page_link("pages/manage.py", icon=":material/bar_chart:", label="Manage data") - + st.page_link( + "pages/dashboard.py", icon=":material/search:", label="Dashboard" + ) + st.page_link( + "pages/manage.py", icon=":material/bar_chart:", label="Manage data" + ) diff --git a/example/src/flows/login_succes.py b/example/src/flows/login_succes.py index 8c1d70a..95ce499 100644 --- a/example/src/flows/login_succes.py +++ b/example/src/flows/login_succes.py @@ -15,12 +15,8 @@ def __init__( def run(self): cp_login_dialog = LoginDialogComponent.get(ComponentIDs.dialog_login) - cp_data_viewer = DataViewerComponent.get( - ComponentIDs.data_viewer - ) - cp_total_movies = MetricComponent.get ( - ComponentIDs.total_movies - ) + cp_data_viewer = DataViewerComponent.get(ComponentIDs.data_viewer) + cp_total_movies = MetricComponent.get(ComponentIDs.total_movies) response = self.backend_service.get_movies() if response.ok: data = response.json() diff --git a/example/src/flows/refresh.py b/example/src/flows/refresh.py index 65882a3..94e410c 100644 --- a/example/src/flows/refresh.py +++ b/example/src/flows/refresh.py @@ -15,9 +15,7 @@ def __init__( self.backend_service = backend_service def run(self): - cp_avg_rating= MetricComponent.get( - ComponentIDs.avg_rating - ) + cp_avg_rating = MetricComponent.get(ComponentIDs.avg_rating) response = self.backend_service.get_movies() if response.ok: data = response.json() diff --git a/example/src/layouts/dashboard.py b/example/src/layouts/dashboard.py index cd13c10..01ae848 100644 --- a/example/src/layouts/dashboard.py +++ b/example/src/layouts/dashboard.py @@ -7,9 +7,10 @@ class DashboardLayout(Layout): def __init__(self, refresh_flow: Flow): self.refresh_flow = refresh_flow - self.total_movies = MetricComponent.create(ComponentIDs.total_movies, "Total movies") + self.total_movies = MetricComponent.create( + ComponentIDs.total_movies, "Total movies" + ) self.avg_rating = MetricComponent.create(ComponentIDs.avg_rating, "Avg. Rating") - def render(self): left, right = st.columns([1, 1]) diff --git a/example/src/layouts/login.py b/example/src/layouts/login.py index 79f6f29..cb27b11 100644 --- a/example/src/layouts/login.py +++ b/example/src/layouts/login.py @@ -12,7 +12,9 @@ def __init__( login_success_flow: Flow, ): self.login_header = login_header - self.login_dialog = LoginDialogComponent.create(ComponentIDs.dialog_login, login_flow, login_success_flow) + self.login_dialog = LoginDialogComponent.create( + ComponentIDs.dialog_login, login_flow, login_success_flow + ) def render(self): self.login_dialog.execute_render("dialog", {"title": self.login_header}) diff --git a/example/src/layouts/manage_data.py b/example/src/layouts/manage_data.py index 0fec15e..1e3042d 100644 --- a/example/src/layouts/manage_data.py +++ b/example/src/layouts/manage_data.py @@ -5,7 +5,9 @@ class ManageDataLayout(Layout): def __init__(self): - self.data_viewer = DataViewerComponent.create(ComponentIDs.data_viewer, "Movies") + self.data_viewer = DataViewerComponent.create( + ComponentIDs.data_viewer, "Movies" + ) def render(self): self.data_viewer.render() diff --git a/example/src/main.py b/example/src/main.py index 7e6efa7..bb6ad08 100644 --- a/example/src/main.py +++ b/example/src/main.py @@ -6,6 +6,7 @@ from service import MockBackendService from ststeroids import Router, Store, Style + class MainApp: def __init__(self): @@ -14,7 +15,9 @@ def __init__(self): self.backend_service = MockBackendService("./example/test_data.json") self.login_flow = LoginFlow(self.session_store, self.backend_service) - self.login_success_flow = LoginSuccessFlow(self.router, self.session_store, self.backend_service) + self.login_success_flow = LoginSuccessFlow( + self.router, self.session_store, self.backend_service + ) self.refresh_flow = RefreshFlow(self.session_store, self.backend_service) st.set_page_config(page_title="StSteroids Example app", layout="wide") @@ -22,13 +25,15 @@ def __init__(self): app_style = Style("./example/src/assets/style.css") app_style.apply_style() - self.login_layout = LoginLayout("App login", self.login_flow, self.login_success_flow) + self.login_layout = LoginLayout( + "App login", self.login_flow, self.login_success_flow + ) self.dashboard_layout = DashboardLayout(self.refresh_flow) self.manage_data_layout = ManageDataLayout() self.sidebar = SidebarComponent("sidebar", self.router) - - def run(self, entry_route:str = None): + + def run(self, entry_route: str = None): self.sidebar.render() def get_routes(): diff --git a/src/ststeroids/component.py b/src/ststeroids/component.py index 9609530..be70ea7 100644 --- a/src/ststeroids/component.py +++ b/src/ststeroids/component.py @@ -5,7 +5,6 @@ from .flow import Flow - # pylint: disable=too-few-public-methods class Component(ABC): """ @@ -16,7 +15,7 @@ class Component(ABC): """ @classmethod - def create(cls, component_id:str, *args, **kwargs): + def create(cls, component_id: str, *args, **kwargs): """ Create a new component instance or return it from the store. """ @@ -30,16 +29,17 @@ def create(cls, component_id:str, *args, **kwargs): cls._store.init_component(instance) return instance except TypeError as e: - raise TypeError(f"{str(e)}. This usually happens when you are trying to get a component without creating it first.") - - + raise TypeError( + f"{str(e)}. This usually happens when you are trying to get a component without creating it first." + ) + @classmethod - def get(cls, component_id: str) : + def get(cls, component_id: str): """ Alias for create() — creation is implicit. """ return cls.create(component_id) - + def register_element(self, element_name: str): """ Generates a unique key for an element based on the instance ID. @@ -139,7 +139,7 @@ def execute_render( case "fragment": return self._render_fragment(**options) raise ValueError(f"Unexpected render_as value: {render_as}") - + @abstractmethod def render(self) -> None: """ diff --git a/src/ststeroids/flow.py b/src/ststeroids/flow.py index d03454a..38f7074 100644 --- a/src/ststeroids/flow.py +++ b/src/ststeroids/flow.py @@ -1,19 +1,12 @@ -from .store import ComponentStore from abc import ABC, abstractmethod # pylint: disable=too-few-public-methods class Flow(ABC): """ - Base class for a flow that can interact with the component store + Base class for a flow """ - def __init__(self): - """ - Initializes the Flow class and creates a ComponentStore instance. - """ - self.component_store = ComponentStore() - def execute_run(self, *args, **kwargs): """ Executes the run method implemented in the subclasses. diff --git a/src/ststeroids/layout.py b/src/ststeroids/layout.py index 9fbed73..dbf94c1 100644 --- a/src/ststeroids/layout.py +++ b/src/ststeroids/layout.py @@ -1,5 +1,6 @@ from abc import ABC, abstractmethod + class Layout(ABC): """ Base class for a layout diff --git a/src/ststeroids/store.py b/src/ststeroids/store.py index f01069e..26afd68 100644 --- a/src/ststeroids/store.py +++ b/src/ststeroids/store.py @@ -103,4 +103,4 @@ def get_component(self, component_id: str): :param component_id: The unique identifier for the component. :return: The component's state or properties. """ - return super().get_property(component_id) \ No newline at end of file + return super().get_property(component_id) diff --git a/tests/test_component.py b/tests/test_component.py index df0afef..08150c5 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -26,15 +26,22 @@ def component(mock_store): ) return component + def test_component_creation_without_id(): with pytest.raises(KeyError): component = Component(initial_state={"key": "value"}) + def test_component_singleton(): - first_instance = Component(component_id="test_component", initial_state={"key": "value"}) - second_instance = Component(component_id="test_component", initial_state={"key": "value"}) + first_instance = Component( + component_id="test_component", initial_state={"key": "value"} + ) + second_instance = Component( + component_id="test_component", initial_state={"key": "value"} + ) assert first_instance is second_instance + def test_subclass_init_runs_only_once(): calls = {"count": 0} @@ -49,8 +56,8 @@ def __init__(self, value): # Call __init__ again explicitly obj.__init__(99) - assert obj.value == 42 # value didn't change - assert calls["count"] == 1 # __init__ not called again + assert obj.value == 42 # value didn't change + assert calls["count"] == 1 # __init__ not called again def test_component_initialization(component): @@ -144,6 +151,7 @@ def test_execute_render_fragment(component): component._render_fragment.assert_called_once_with(x=1) assert result == "fragment_rendered" + def test_execute_render_raises_an_error_with_an_invalid_render_as(component): with pytest.raises(ValueError): - component.execute_render(render_as="something", options={"x": 1}) \ No newline at end of file + component.execute_render(render_as="something", options={"x": 1}) diff --git a/tests/test_store.py b/tests/test_store.py index 5eac546..1ec138d 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -50,11 +50,13 @@ def test_store_has_property(mock_session_state): assert store.has_property("key") is True assert store.has_property("missing_key") is False + def test_component_store_singleton(): first_instance = ComponentStore() second_instance = ComponentStore() assert first_instance is second_instance + def test_component_store_initialization(mock_session_state): ComponentStore() assert "components" in mock_session_state From fd7676ed1d4c4dd3edad353bcb8a84ccfcec8ee2 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Wed, 24 Dec 2025 20:47:50 +0100 Subject: [PATCH 18/60] formatting --- example/src/components/data_viewer.py | 1 - 1 file changed, 1 deletion(-) diff --git a/example/src/components/data_viewer.py b/example/src/components/data_viewer.py index 7f1fbe4..6b195f8 100644 --- a/example/src/components/data_viewer.py +++ b/example/src/components/data_viewer.py @@ -1,4 +1,3 @@ -import uuid import streamlit as st from ststeroids import Component From 4e71ea9dd3b382e1ad5f5e1e9740b1e078ddb5ac Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Wed, 24 Dec 2025 20:51:35 +0100 Subject: [PATCH 19/60] Resolving first comments --- example/src/components/data_viewer.py | 1 + example/src/components/sidebar.py | 3 --- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/example/src/components/data_viewer.py b/example/src/components/data_viewer.py index 6b195f8..15a1e80 100644 --- a/example/src/components/data_viewer.py +++ b/example/src/components/data_viewer.py @@ -12,6 +12,7 @@ def __init__( self.header = header self.column_config = column_config self.column_order = column_order + self.data = None def render(self): st.subheader(self.header) diff --git a/example/src/components/sidebar.py b/example/src/components/sidebar.py index db246c4..3c92b56 100644 --- a/example/src/components/sidebar.py +++ b/example/src/components/sidebar.py @@ -4,9 +4,6 @@ class SidebarComponent(Component): - def __init__(self, str, router: Router): - self.router = router - def render(self): with st.sidebar: st.page_link( From 78e2b7f4f884625c959f3a8b0ff48df87f03d271 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Wed, 24 Dec 2025 20:53:17 +0100 Subject: [PATCH 20/60] Formatting and removing unused stuff --- example/src/components/sidebar.py | 12 ++++++++---- example/src/flows/login_succes.py | 4 +--- example/src/main.py | 18 +++++++++++++----- src/ststeroids/main.py | 9 +++++---- src/ststeroids/route.py | 2 +- src/ststeroids/route_builder.py | 5 +++-- src/ststeroids/router.py | 3 +-- tests/test_component.py | 18 +++++++++++++----- tests/test_store.py | 2 ++ 9 files changed, 47 insertions(+), 26 deletions(-) diff --git a/example/src/components/sidebar.py b/example/src/components/sidebar.py index 2175e26..0cb8274 100644 --- a/example/src/components/sidebar.py +++ b/example/src/components/sidebar.py @@ -18,7 +18,11 @@ def _test(self): def render(self): with st.sidebar: - st.page_link("pages/dashboard.py", icon=":material/search:", label="Dashboard") - st.page_link("pages/manage.py", icon=":material/bar_chart:", label="Manage data") - - st.button("test",key="testkey",on_click=self._test) \ No newline at end of file + st.page_link( + "pages/dashboard.py", icon=":material/search:", label="Dashboard" + ) + st.page_link( + "pages/manage.py", icon=":material/bar_chart:", label="Manage data" + ) + + st.button("test", key="testkey", on_click=self._test) diff --git a/example/src/flows/login_succes.py b/example/src/flows/login_succes.py index 55f7525..610ba92 100644 --- a/example/src/flows/login_succes.py +++ b/example/src/flows/login_succes.py @@ -6,9 +6,7 @@ class LoginSuccessFlow(Flow): - def __init__( - self, session_store: Store, backend_service: MockBackendService - ): + def __init__(self, session_store: Store, backend_service: MockBackendService): super().__init__() self.session_store = session_store self.backend_service = backend_service diff --git a/example/src/main.py b/example/src/main.py index a42bb93..7305e74 100644 --- a/example/src/main.py +++ b/example/src/main.py @@ -1,10 +1,10 @@ -from collections import defaultdict import streamlit as st from flows import LoginFlow, LoginSuccessFlow, RefreshFlow from layouts import LoginLayout, DashboardLayout, ManageDataLayout from service import MockBackendService from ststeroids import Store, Style, StSteroids + class MainApp: def __init__(self): @@ -13,7 +13,9 @@ def __init__(self): self.backend_service = MockBackendService("./example/test_data.json") self.login_flow = LoginFlow(self.session_store, self.backend_service) - self.login_success_flow = LoginSuccessFlow(self.session_store, self.backend_service) + self.login_success_flow = LoginSuccessFlow( + self.session_store, self.backend_service + ) self.refresh_flow = RefreshFlow(self.session_store, self.backend_service) st.set_page_config(page_title="StSteroids Example app", layout="wide") @@ -21,7 +23,9 @@ def __init__(self): app_style = Style("./example/src/assets/style.css") app_style.apply_style() - self.login_layout = LoginLayout("App login", self.login_flow, self.login_success_flow) + self.login_layout = LoginLayout( + "App login", self.login_flow, self.login_success_flow + ) self.dashboard_layout = DashboardLayout(self.refresh_flow) self.manage_data_layout = ManageDataLayout() @@ -30,5 +34,9 @@ def __init__(self): self.app.default_route(self.login_layout) self.app.route("login").to(self.login_layout).register() - self.app.route("dashboard").to(self.dashboard_layout).when(lambda: self.session_store.has_property("access_token")).register() - self.app.route("manage_data").to(self.manage_data_layout).when(lambda: self.session_store.has_property("access_token")).register() + self.app.route("dashboard").to(self.dashboard_layout).when( + lambda: self.session_store.has_property("access_token") + ).register() + self.app.route("manage_data").to(self.manage_data_layout).when( + lambda: self.session_store.has_property("access_token") + ).register() diff --git a/src/ststeroids/main.py b/src/ststeroids/main.py index 46a143a..8829465 100644 --- a/src/ststeroids/main.py +++ b/src/ststeroids/main.py @@ -2,8 +2,9 @@ from .route_builder import RouteBuilder from .router import Router + class StSteroids: - + def __init__(self): self._router = Router() self._routes: dict[str, Route] = {} @@ -17,13 +18,13 @@ def default_route(self, target) -> None: def register(self, route: "Route"): self._routes[route.name] = route - + def run(self, entry_route: str | None = None): routes = {} if self._default: routes["__default__"] = self._default - + for route in self._routes.values(): if route.condition: if route.condition(): @@ -36,4 +37,4 @@ def run(self, entry_route: str | None = None): if entry_route: self._router.route(entry_route) - self._router.run() \ No newline at end of file + self._router.run() diff --git a/src/ststeroids/route.py b/src/ststeroids/route.py index dbf1cb1..85b85ed 100644 --- a/src/ststeroids/route.py +++ b/src/ststeroids/route.py @@ -2,4 +2,4 @@ class Route: def __init__(self, name: str, target: callable, condition: callable = None): self.name = name self.target = target - self.condition = condition \ No newline at end of file + self.condition = condition diff --git a/src/ststeroids/route_builder.py b/src/ststeroids/route_builder.py index 853dd8f..e2ac286 100644 --- a/src/ststeroids/route_builder.py +++ b/src/ststeroids/route_builder.py @@ -1,5 +1,6 @@ from .route import Route + class RouteBuilder: def __init__(self, app, name: str): self.app = app @@ -10,10 +11,10 @@ def __init__(self, app, name: str): def to(self, target): self._target = target return self - + def when(self, condition: callable): self._condition = condition return self def register(self): - self.app.register(Route(self._name, self._target, self._condition)) + self.app.register(Route(self._name, self._target, self._condition)) diff --git a/src/ststeroids/router.py b/src/ststeroids/router.py index 12c4e80..49dfc86 100644 --- a/src/ststeroids/router.py +++ b/src/ststeroids/router.py @@ -1,4 +1,3 @@ -import streamlit as st from .route import Route @@ -50,4 +49,4 @@ def run(self): raise RuntimeError( "No current route selected and no default route registered." ) - route.target() \ No newline at end of file + route.target() diff --git a/tests/test_component.py b/tests/test_component.py index df0afef..08150c5 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -26,15 +26,22 @@ def component(mock_store): ) return component + def test_component_creation_without_id(): with pytest.raises(KeyError): component = Component(initial_state={"key": "value"}) + def test_component_singleton(): - first_instance = Component(component_id="test_component", initial_state={"key": "value"}) - second_instance = Component(component_id="test_component", initial_state={"key": "value"}) + first_instance = Component( + component_id="test_component", initial_state={"key": "value"} + ) + second_instance = Component( + component_id="test_component", initial_state={"key": "value"} + ) assert first_instance is second_instance + def test_subclass_init_runs_only_once(): calls = {"count": 0} @@ -49,8 +56,8 @@ def __init__(self, value): # Call __init__ again explicitly obj.__init__(99) - assert obj.value == 42 # value didn't change - assert calls["count"] == 1 # __init__ not called again + assert obj.value == 42 # value didn't change + assert calls["count"] == 1 # __init__ not called again def test_component_initialization(component): @@ -144,6 +151,7 @@ def test_execute_render_fragment(component): component._render_fragment.assert_called_once_with(x=1) assert result == "fragment_rendered" + def test_execute_render_raises_an_error_with_an_invalid_render_as(component): with pytest.raises(ValueError): - component.execute_render(render_as="something", options={"x": 1}) \ No newline at end of file + component.execute_render(render_as="something", options={"x": 1}) diff --git a/tests/test_store.py b/tests/test_store.py index 5eac546..1ec138d 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -50,11 +50,13 @@ def test_store_has_property(mock_session_state): assert store.has_property("key") is True assert store.has_property("missing_key") is False + def test_component_store_singleton(): first_instance = ComponentStore() second_instance = ComponentStore() assert first_instance is second_instance + def test_component_store_initialization(mock_session_state): ComponentStore() assert "components" in mock_session_state From 65af69f3ca1dc4980055e0fa4226e66c8b72c0ee Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Sun, 28 Dec 2025 09:00:03 +0100 Subject: [PATCH 21/60] Cleaning --- example/src/components/sidebar.py | 11 ----------- example/src/main.py | 1 - 2 files changed, 12 deletions(-) diff --git a/example/src/components/sidebar.py b/example/src/components/sidebar.py index 0cb8274..5965c4e 100644 --- a/example/src/components/sidebar.py +++ b/example/src/components/sidebar.py @@ -7,15 +7,6 @@ class SidebarComponent(Component): def __init__(self, component_id: str): super().__init__(component_id) - def _test(self): - if "test" not in st.session_state: - st.session_state["test"] = True - if st.session_state["test"] == True: - st.session_state["test"] = False - else: - st.session_state["test"] = True - print(st.session_state["test"]) - def render(self): with st.sidebar: st.page_link( @@ -24,5 +15,3 @@ def render(self): st.page_link( "pages/manage.py", icon=":material/bar_chart:", label="Manage data" ) - - st.button("test", key="testkey", on_click=self._test) diff --git a/example/src/main.py b/example/src/main.py index 7305e74..03d7780 100644 --- a/example/src/main.py +++ b/example/src/main.py @@ -9,7 +9,6 @@ class MainApp: def __init__(self): self.session_store = Store("store") - # self.router = Router("login") self.backend_service = MockBackendService("./example/test_data.json") self.login_flow = LoginFlow(self.session_store, self.backend_service) From 883a2f00322ad216ec6b29575bf1f90132f78545 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Sun, 28 Dec 2025 09:44:12 +0100 Subject: [PATCH 22/60] Added create methods for flow and layout --- example/src/components/sidebar.py | 2 +- example/src/main.py | 14 +++++++------- src/ststeroids/flow.py | 7 +++++++ src/ststeroids/layout.py | 8 ++++++-- src/ststeroids/router.py | 2 +- 5 files changed, 22 insertions(+), 11 deletions(-) diff --git a/example/src/components/sidebar.py b/example/src/components/sidebar.py index 3c92b56..dae8b31 100644 --- a/example/src/components/sidebar.py +++ b/example/src/components/sidebar.py @@ -1,5 +1,5 @@ import streamlit as st -from ststeroids import Component, Router +from ststeroids import Component class SidebarComponent(Component): diff --git a/example/src/main.py b/example/src/main.py index bb6ad08..2b727f3 100644 --- a/example/src/main.py +++ b/example/src/main.py @@ -14,24 +14,24 @@ def __init__(self): self.router = Router("login") self.backend_service = MockBackendService("./example/test_data.json") - self.login_flow = LoginFlow(self.session_store, self.backend_service) - self.login_success_flow = LoginSuccessFlow( + self.login_flow = LoginFlow.create(self.session_store, self.backend_service) + self.login_success_flow = LoginSuccessFlow.create( self.router, self.session_store, self.backend_service ) - self.refresh_flow = RefreshFlow(self.session_store, self.backend_service) + self.refresh_flow = RefreshFlow.create(self.session_store, self.backend_service) st.set_page_config(page_title="StSteroids Example app", layout="wide") app_style = Style("./example/src/assets/style.css") app_style.apply_style() - self.login_layout = LoginLayout( + self.login_layout = LoginLayout.create( "App login", self.login_flow, self.login_success_flow ) - self.dashboard_layout = DashboardLayout(self.refresh_flow) - self.manage_data_layout = ManageDataLayout() + self.dashboard_layout = DashboardLayout.create(self.refresh_flow) + self.manage_data_layout = ManageDataLayout.create() - self.sidebar = SidebarComponent("sidebar", self.router) + self.sidebar = SidebarComponent.create("sidebar") def run(self, entry_route: str = None): self.sidebar.render() diff --git a/src/ststeroids/flow.py b/src/ststeroids/flow.py index 38f7074..90e41e7 100644 --- a/src/ststeroids/flow.py +++ b/src/ststeroids/flow.py @@ -7,6 +7,13 @@ class Flow(ABC): Base class for a flow """ + @classmethod + def create(cls, *args, **kwargs): + """ + Create a new flow instance. + """ + return cls(*args, **kwargs) + def execute_run(self, *args, **kwargs): """ Executes the run method implemented in the subclasses. diff --git a/src/ststeroids/layout.py b/src/ststeroids/layout.py index dbf94c1..eb640a0 100644 --- a/src/ststeroids/layout.py +++ b/src/ststeroids/layout.py @@ -6,8 +6,12 @@ class Layout(ABC): Base class for a layout """ - def __call__(self): - self.render() + @classmethod + def create(cls, *args, **kwargs): + """ + Creates a new layout instance. + """ + return cls(*args, **kwargs) def execute_render(self): """ diff --git a/src/ststeroids/router.py b/src/ststeroids/router.py index e94e78b..52b8c05 100644 --- a/src/ststeroids/router.py +++ b/src/ststeroids/router.py @@ -29,7 +29,7 @@ def run(self): raise KeyError( f"The current route '{st.session_state['ststeroids_current_route']}' is not a registered route." ) from exc - route() + route.execute_render() def route(self, route_name: str): """ From 535b8c3458d1f7c6f35c82c890685170403bcd2b Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Sun, 28 Dec 2025 20:32:37 +0100 Subject: [PATCH 23/60] Introducing toast --- example/src/components/__init__.py | 3 ++- example/src/components/toast.py | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 example/src/components/toast.py diff --git a/example/src/components/__init__.py b/example/src/components/__init__.py index 28c4079..533e702 100644 --- a/example/src/components/__init__.py +++ b/example/src/components/__init__.py @@ -2,6 +2,7 @@ from .sidebar import SidebarComponent from .data_viewer import DataViewerComponent from .metric import MetricComponent +from .toast import ToastComponent -__all__ = [LoginDialogComponent, SidebarComponent, DataViewerComponent, MetricComponent] +__all__ = [LoginDialogComponent, SidebarComponent, DataViewerComponent, MetricComponent, ToastComponent] diff --git a/example/src/components/toast.py b/example/src/components/toast.py new file mode 100644 index 0000000..22f8600 --- /dev/null +++ b/example/src/components/toast.py @@ -0,0 +1,20 @@ +import streamlit as st +from ststeroids import Component + + +class ToastComponent(Component): + def __init__( + self, + ): + self.visible = False + self.message = None + + def render(self): + if self.visible: + st.toast(self.message) + self.visible = False + + def set_message(self, message: str): + print("set called") + self.message = message + self.visible = True \ No newline at end of file From 3ead59f8b97df01b2e5ce21f7d1895292966b946 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Sun, 28 Dec 2025 20:39:02 +0100 Subject: [PATCH 24/60] Better naming --- example/src/components/data_viewer.py | 2 +- example/src/components/login_dialog.py | 39 +++++++++++++++----------- example/src/components/metric.py | 2 +- example/src/components/sidebar.py | 2 +- example/src/components/toast.py | 2 +- example/src/layouts/dashboard.py | 8 ++++-- src/ststeroids/component.py | 14 ++++----- src/ststeroids/flow.py | 8 +++--- src/ststeroids/layout.py | 6 ---- src/ststeroids/router.py | 2 +- 10 files changed, 44 insertions(+), 41 deletions(-) diff --git a/example/src/components/data_viewer.py b/example/src/components/data_viewer.py index 15a1e80..06d122a 100644 --- a/example/src/components/data_viewer.py +++ b/example/src/components/data_viewer.py @@ -14,7 +14,7 @@ def __init__( self.column_order = column_order self.data = None - def render(self): + def display(self): st.subheader(self.header) st.dataframe( self.data, diff --git a/example/src/components/login_dialog.py b/example/src/components/login_dialog.py index 6e4d674..5179898 100644 --- a/example/src/components/login_dialog.py +++ b/example/src/components/login_dialog.py @@ -6,31 +6,38 @@ class LoginDialogComponent(Component): def __init__( self, login_flow: Flow, - login_success_flow: Flow, + # login_success_flow: Flow, header: str = "Enter username/password", ): self.header = header self.login_flow = login_flow - self.login_success_flow = login_success_flow + # self.login_success_flow = login_success_flow self.visible = False + self.error_message = None - def render(self): + def display(self): if self.visible: - username = st.text_input("Username") - password = st.text_input("Password", type="password") + print(self.visible) + self.username = st.text_input("Username") + self.password = st.text_input("Password", type="password") if st.button("Login", use_container_width=True): - login_succes = self.login_flow.execute_run(username, password) - if login_succes: - self.login_success_flow.execute_run() - else: - st.error("Login failed, please check your username and password.") - + self.login_flow.dispatch() + if self.error_message: + st.error(self.error_message) def show(self): - if self.visible is False: + # if self.visible is False: self.visible = True - st.rerun() + # st.rerun() def hide(self): - if self.visible is True: - self.visible = False - st.rerun() + print("hide") + # if self.visible is True: + self.visible = False + # st.rerun() + + def set_error(self, message: str): + self.error_message = message + + # def clear_error9 + + diff --git a/example/src/components/metric.py b/example/src/components/metric.py index 903facf..a262af5 100644 --- a/example/src/components/metric.py +++ b/example/src/components/metric.py @@ -10,7 +10,7 @@ def __init__( self.header = header self.value = 0 - def render(self): + def display(self): st.metric(self.header, self.value) def set_value(self, value: int): diff --git a/example/src/components/sidebar.py b/example/src/components/sidebar.py index dae8b31..0a186a9 100644 --- a/example/src/components/sidebar.py +++ b/example/src/components/sidebar.py @@ -4,7 +4,7 @@ class SidebarComponent(Component): - def render(self): + def display(self): with st.sidebar: st.page_link( "pages/dashboard.py", icon=":material/search:", label="Dashboard" diff --git a/example/src/components/toast.py b/example/src/components/toast.py index 22f8600..4a0a862 100644 --- a/example/src/components/toast.py +++ b/example/src/components/toast.py @@ -9,7 +9,7 @@ def __init__( self.visible = False self.message = None - def render(self): + def display(self): if self.visible: st.toast(self.message) self.visible = False diff --git a/example/src/layouts/dashboard.py b/example/src/layouts/dashboard.py index 01ae848..059d77f 100644 --- a/example/src/layouts/dashboard.py +++ b/example/src/layouts/dashboard.py @@ -1,5 +1,5 @@ import streamlit as st -from components import MetricComponent +from components import MetricComponent, ToastComponent from shared import ComponentIDs from ststeroids import Layout, Flow @@ -7,17 +7,19 @@ class DashboardLayout(Layout): def __init__(self, refresh_flow: Flow): self.refresh_flow = refresh_flow + self.toast = ToastComponent.create("toast") self.total_movies = MetricComponent.create( ComponentIDs.total_movies, "Total movies" ) self.avg_rating = MetricComponent.create(ComponentIDs.avg_rating, "Avg. Rating") def render(self): + self.toast.render() left, right = st.columns([1, 1]) with left: - self.total_movies.execute_render() + self.total_movies.render() with right: - self.avg_rating.execute_render( + self.avg_rating.render( "fragment", {"refresh_flow": self.refresh_flow, "refresh_interval": "2s"}, ) diff --git a/src/ststeroids/component.py b/src/ststeroids/component.py index be70ea7..9401e1e 100644 --- a/src/ststeroids/component.py +++ b/src/ststeroids/component.py @@ -94,7 +94,7 @@ def _render_dialog(self, title: str): @st.dialog(title) def _render(): - self.render() + self.display() _render() @@ -119,11 +119,11 @@ def _render(): _render() def __render_fragment(self, refresh_flow: Flow = None): - self.render() + self.display() if refresh_flow: - refresh_flow.execute_run() + refresh_flow.dispatch() - def execute_render( + def render( self, render_as: Literal["normal", "dialog", "fragment"] = "normal", options: dict = {}, @@ -133,7 +133,7 @@ def execute_render( """ match render_as: case "normal": - return self.render() + return self.display() case "dialog": return self._render_dialog(**options) case "fragment": @@ -141,9 +141,9 @@ def execute_render( raise ValueError(f"Unexpected render_as value: {render_as}") @abstractmethod - def render(self) -> None: + def display(self) -> None: """ - Abstract method for rendering the component. + Abstract method for displaying the component. This method should be implemented by subclasses to define how the component is rendered. """ diff --git a/src/ststeroids/flow.py b/src/ststeroids/flow.py index 90e41e7..6c4de92 100644 --- a/src/ststeroids/flow.py +++ b/src/ststeroids/flow.py @@ -10,18 +10,18 @@ class Flow(ABC): @classmethod def create(cls, *args, **kwargs): """ - Create a new flow instance. + Creates a new flow instance. """ return cls(*args, **kwargs) - def execute_run(self, *args, **kwargs): + def dispatch(self): """ Executes the run method implemented in the subclasses. """ - return self.run(*args, **kwargs) + return self.run() @abstractmethod - def run(self, *args, **kwargs): + def run(self): """ Abstract methods that executes the flow logic. diff --git a/src/ststeroids/layout.py b/src/ststeroids/layout.py index eb640a0..afac9b1 100644 --- a/src/ststeroids/layout.py +++ b/src/ststeroids/layout.py @@ -13,12 +13,6 @@ def create(cls, *args, **kwargs): """ return cls(*args, **kwargs) - def execute_render(self): - """ - Executes the render method implemented in the subclasses. - """ - self.render() - @abstractmethod def render(self) -> None: """ diff --git a/src/ststeroids/router.py b/src/ststeroids/router.py index 52b8c05..5f1327b 100644 --- a/src/ststeroids/router.py +++ b/src/ststeroids/router.py @@ -29,7 +29,7 @@ def run(self): raise KeyError( f"The current route '{st.session_state['ststeroids_current_route']}' is not a registered route." ) from exc - route.execute_render() + route.render() def route(self, route_name: str): """ From fb75cd18d179f7e327bc154d1498b5addace1af2 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Sun, 28 Dec 2025 21:09:25 +0100 Subject: [PATCH 25/60] Some cleaning considered the new framework rules and started with updating readme --- README.md | 137 +++++++++++++++++------------- example/src/flows/login.py | 55 ++++++++++-- example/src/flows/login_succes.py | 1 - example/src/flows/refresh.py | 1 - example/src/layouts/login.py | 5 +- 5 files changed, 130 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 9ab59ab..102f4fb 100644 --- a/README.md +++ b/README.md @@ -10,24 +10,54 @@ Ststeroids was designed to supercharge the development of complex multi-page app The main concepts of Ststeroids are: - Reusable Components -- Logics Flows +- Logic Flows - Declarative Layouts -- Routers +- A Router +- A Store In addition, StSteroids provides an easy way to load style sheets into your Streamlit application and offers a wrapper around `st.session_state` to separate states into stores. This wrapper is also used within components to store the component and its state in the session state. #### Components Components are at the core of StSteroids. A component represents a specific visual element of your application along with its rendering logic. Examples include a login dialog or a person details component. -Each component contains only the logic necessary for its functionality, such as basic input validation or button interactions that trigger a [flow](#flows). Components and their state are stored in the ComponentStore. +Each component contains only the logic necessary for its functionality, such as basic input validation or button interactions that trigger a [flow](#flows). Components and their attributes are stored in the ComponentStore which is a special instance of a Store. + +Component concepts: + +- components never decide on domain logic, so no domain error handeling for example +- a component contains interaction elements, unless + - the component is still meaningful and usable without a the interaction element → split the element out +- a component doesn't navigate pages +- should have functions for updating it's attributes (explicit state changes) (so that the flow doesn't need to all the attributes) + +For example, a metric component that can be reused for multiple purposes. #### Flows -Flows contain the business logic of the application, handling its core functionality and, in some cases, linking components to backend services. -For example, a login flow might call an authentication service, validate the response, extract the access token, and store it in the session store. +Flows encapsulate the application’s interaction and orchestration logic. +They handle user-initiated actions, coordinate state changes across components, and invoke domain services to perform business operations. + +Flow concepts: + +- Flows act as handlers for user and system interactions (e.g. button clicks, page entry, form submission) +- Flows orchestrate application behavior, calling services and updating component state +- Flows coordinate multiple components and stores as part of a single interaction +- Flows determine navigation and control flow between layouts or pages +- Flows own error handling and recovery logic for the interactions they manage +- Flows may contain light business rules, but core domain logic should live in services + +For example, a login flow might call an authentication service, evaluate the result, store relevant session data, and update one or more components to reflect the outcome. + +When multiple flows share orchestration resources—such as access to the same components, stores, or helper logic—it is recommended to introduce a shared base flow to centralize this responsibility and avoid duplication. #### Layouts -Layouts bring components together to create a multi-page application. Each layout functions as a page, rendering one or more components and defining their arrangement. +Layouts bring components together to create a multi-page application. Each layout functions as a page, rendering one or more components and defining their arrangement and rendering. + +Layout concepts: + +- layouts are responsible for initializing and wiring components +- layouts are responsible for the visual arrangement of components +- layouts are responsible for conditional rendering based on application state or context (for example, authorisation) For example, a layout might define multiple Streamlit columns and place components within them. @@ -61,49 +91,52 @@ pytest #### Components -Defining a new component. +Example of defining a new component. + ```python from ststeroids import Component -class YourXComponent(Component): - def __init__(self, component_id: str): - super().__init__(component_id) # This line is important to initialize the base class. +class MetricComponent(Component): + def __init__( + self, + header: str, + ): + self.header = header + self.value = 0 - def render(self): - # Your render logic + def display(self): + st.metric(self.header, self.value) + + def set_value(self, value: int): + self.value = value ``` -Additionaly an initial state (dict) can be passed as a second paramters while initing the base class. +The header attribute and set_value method are specific to this example. They illustrate how components can have instance-bound attributes and provide an explicit API for updating their state. Components should own their state and expose such functions rather than allowing external code to directly mutate their attributes. ##### API Reference `id` -Holds the component id +Holds the component id, is automaticly added from the base component. -`state` +`create(cls, component_id: str, *args, **kwargs)` -Manages the component state. Although technically an instance of the StSteroids `State` class, it functions like a dictionary, allowing properties to be accessed using getters and setters. +Creates a new component instance with the given `component_id` and stores it in the `ComponentStore`. +This is typically called in layouts to initialize components. Additional arguments are passed to the component's constructor. -When outside the component: -```python -myvalue = yourcomponent.state.yourproperty -yourcomponent.state.yourproperty = "yourvalue" -``` +`get(cls, component_id: str)` -When inside the component: -```python -myvalue = self.state.yourproperty -self.state.yourproperty = "yourvalue" -``` +Retrieves an existing component instance from the `ComponentStore` by its `component_id`. +`create()` must have been called first; otherwise, an error will be raised. +This is typically used in flows that needs to interact with a component after it has been initialized. -`render()` +`display()` -This method needs to be implemented by the subclass. To call it in a layout, use `execute_render()` +This method needs to be implemented by the subclass. To call it in a layout, use `render()` -`execute_render(render_as: Literal["normal", "dialog", "fragment"]="normal", options:dict={})` +`render(render_as: Literal["normal", "dialog", "fragment"]="normal", options:dict={})` -Executes the render method of an instance of a component. Additionaly provide the `render_as` parameter with the `options` parameter. +Executes the display method of an instance of a component. Additionaly provide the `render_as` parameter with the `options` parameter. Dialog options: @@ -158,65 +191,55 @@ from ststeroids import Flow class YourXFlow(Flow): def __init__(self): - super().__init__() # This line is important to initialize the base class. + def run(self): # Your flow logic ``` + + ##### API Reference `run()` -This method needs to be implemented by the subclass. To call it, use `execute_run()` +This method needs to be implemented by the subclass. To call it, use `dispatch()` -`execute_run()` +`dispatch()` Executes the run method implemented in the subclass. -`component_store` - -The component store containing the instances of components and their states. - -Use `component_store.get_component(component_id: str)` to retrieve an instance of a component. - -```python -from components import YourXComponent - -your_x_component_instance: YourXComponent = self.component_store.get_component("your_x_component_id") -``` - -Notice the `: YourXComponent` this tells your IDE what type of component you are getting and helps the autocomplete. - #### Layouts -Defining a new layout. +Example of defining a new layout. + ```python from ststeroids import Layout -class YourXLayout(Layout): +class ManageDataLayout(Layout): def __init__(self): + self.data_viewer = DataViewerComponent.create( + ComponentIDs.data_viewer, "Movies" + ) def render(self): - # Your layout render logic + self.data_viewer.render() ``` -An instance of a layout can be rendered by calling either the `render()` function or by calling the instance of the layout. +An instance of a layout can be rendered by calling either the `render()` function. Calling the instance ```python my_x_layout = YourXLayout() -my_x_layout() +my_x_layout.render() ``` + + ##### API Reference `render()` -This method needs to be implemented by the subclass. To call it in the application, use `execute_render()` - -`execute_render()` - -Executes the render method of an instance of a layout. +This method needs to be implemented by the subclass. #### Routers Intializing a router diff --git a/example/src/flows/login.py b/example/src/flows/login.py index 85af513..938a4df 100644 --- a/example/src/flows/login.py +++ b/example/src/flows/login.py @@ -1,17 +1,56 @@ from service import MockBackendService from ststeroids import Flow, Store - +from components import LoginDialogComponent, DataViewerComponent, MetricComponent, ToastComponent +from shared import ComponentIDs +import streamlit as st class LoginFlow(Flow): def __init__(self, session_store: Store, backend_service: MockBackendService): - super().__init__() self.session_store = session_store self.backend_service = backend_service - def run(self, username: str, password: str): - response = self.backend_service.authenticate(username, password) + @property + def cp_login_dialog(self): + return LoginDialogComponent.get(ComponentIDs.dialog_login) + + @property + def cp_data_viewer(self): + return DataViewerComponent.get(ComponentIDs.data_viewer) + + @property + def cp_total_movies(self): + return MetricComponent.get(ComponentIDs.total_movies) + + @property + def cp_toast(self): + return ToastComponent.get("toast") + + def run(self): + response = self.backend_service.authenticate(self.cp_login_dialog.username, self.cp_login_dialog.password) + if response.ok: + self.login_succes(response) + else: + self.login_failed() + + def login_succes(self, response): + token_data = response.json() + self.session_store.set_property("access_token", token_data["access_token"]) + self.cp_login_dialog.hide() + response = self.backend_service.get_movies() + response.ok = False if response.ok: - token_data = response.json() - self.session_store.set_property("access_token", token_data["access_token"]) - return True - return False + data = response.json() + self.session_store.set_property( + "data", data + ) # Store the data in the session_store for later use in more complex applications + self.cp_total_movies.set_value(len(data)) + self.cp_data_viewer.set_data(data) + else: + self.cp_toast.set_message("error") + st.switch_page("pages/dashboard.py") + + + + + def login_failed(self): + self.cp_login_dialog.set_error("Login failed, check your username and password") \ No newline at end of file diff --git a/example/src/flows/login_succes.py b/example/src/flows/login_succes.py index 95ce499..112654a 100644 --- a/example/src/flows/login_succes.py +++ b/example/src/flows/login_succes.py @@ -8,7 +8,6 @@ class LoginSuccessFlow(Flow): def __init__( self, router: Router, session_store: Store, backend_service: MockBackendService ): - super().__init__() self.session_store = session_store self.backend_service = backend_service self.router = router diff --git a/example/src/flows/refresh.py b/example/src/flows/refresh.py index 94e410c..3216b69 100644 --- a/example/src/flows/refresh.py +++ b/example/src/flows/refresh.py @@ -10,7 +10,6 @@ def __init__( session_store: Store, backend_service: MockBackendService, ): - super().__init__() self.session_store = session_store self.backend_service = backend_service diff --git a/example/src/layouts/login.py b/example/src/layouts/login.py index cb27b11..ee7be9e 100644 --- a/example/src/layouts/login.py +++ b/example/src/layouts/login.py @@ -17,6 +17,7 @@ def __init__( ) def render(self): - self.login_dialog.execute_render("dialog", {"title": self.login_header}) - st.write("Not logged in. Please refresh or use the menu on the left.") self.login_dialog.show() + self.login_dialog.render("dialog", {"title": self.login_header}) + st.write("Not logged in. Please refresh or use the menu on the left.") + From c89136101bed6dfbe9a57d73ee59bd6d8987e644 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Sun, 28 Dec 2025 21:19:39 +0100 Subject: [PATCH 26/60] Update login.py --- example/src/flows/login.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/example/src/flows/login.py b/example/src/flows/login.py index 938a4df..5da4e84 100644 --- a/example/src/flows/login.py +++ b/example/src/flows/login.py @@ -28,11 +28,11 @@ def cp_toast(self): def run(self): response = self.backend_service.authenticate(self.cp_login_dialog.username, self.cp_login_dialog.password) if response.ok: - self.login_succes(response) + self._login_success(response) else: - self.login_failed() + self._login_failed() - def login_succes(self, response): + def _login_success(self, response): token_data = response.json() self.session_store.set_property("access_token", token_data["access_token"]) self.cp_login_dialog.hide() @@ -52,5 +52,5 @@ def login_succes(self, response): - def login_failed(self): + def _login_failed(self): self.cp_login_dialog.set_error("Login failed, check your username and password") \ No newline at end of file From 6ed71cea1b30722a6866ee9386dc6b2e9f95f6e4 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Fri, 2 Jan 2026 10:02:23 +0100 Subject: [PATCH 27/60] Cleaning and docs --- README.md | 67 ++++++++++++++++++++++---- example/src/components/login_dialog.py | 14 ++---- example/src/components/toast.py | 1 - example/src/flows/login.py | 5 +- example/src/flows/refresh.py | 4 +- example/src/layouts/dashboard.py | 2 +- example/src/shared.py | 1 + 7 files changed, 65 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 102f4fb..d3d6af7 100644 --- a/README.md +++ b/README.md @@ -185,19 +185,50 @@ Sets the value of a registered element. #### Flows -Defining a new flow. +Example of defining a new flow: + ```python from ststeroids import Flow -class YourXFlow(Flow): - def __init__(self): - +class AddDocumentFlow(Flow): + def __init__(self, session_store: Store): + self.session_store = session_store + + @property + def cp_document_table(self): + return TableComponent.get(ComponentIDs.documents) def run(self): - # Your flow logic + # Flow logic for adding a document ``` - +Now imagine your application supports multiple document-related actions (for example: add, delete, or update documents). +These actions often share the same orchestration context, such as access to the session store or a document table component. + +To avoid duplicating this setup in every action flow, it is recommended to introduce a base flow that provides shared orchestration resources. + +First, rename the flow above to a base flow: + +```python +class DocumentActionBaseFlow(Flow): + def __init__(self, session_store: Store): + self.session_store = session_store + + @property + def cp_document_table(self): + return TableComponent.get(ComponentIDs.documents) +``` + +Then, create a dedicated flow for each document action: + +```python +class AddDocumentFlow(DocumentActionBaseFlow): + def run(self): + # Flow logic for adding a document +``` + +In this example, AddDocumentFlow represents a single user action, while DocumentActionBaseFlow provides shared orchestration context. +This keeps flows focused, avoids duplication, and clearly separates reusable setup from action-specific logic.R ##### API Reference @@ -226,14 +257,30 @@ class ManageDataLayout(Layout): self.data_viewer.render() ``` -An instance of a layout can be rendered by calling either the `render()` function. +Layouts are responsible for creating and rendering components. +They must not contain business logic, checks, or flow control. + +Component creation should always happen in the layout constructor using +`Component.create(...)`. + +##### Rendering a layout + +A layout instance can be rendered by calling its `render()` method. -Calling the instance ```python -my_x_layout = YourXLayout() +my_x_layout = YourXLayout.create() my_x_layout.render() ``` - + +Calling `render()` on a layout is restricted to the router. + +This ensures: +- a single, predictable render entry point +- consistent routing behavior +- a clear separation of concerns + +Layouts describe what is rendered. +The router decides when it is rendered. ##### API Reference diff --git a/example/src/components/login_dialog.py b/example/src/components/login_dialog.py index 5179898..305d4b3 100644 --- a/example/src/components/login_dialog.py +++ b/example/src/components/login_dialog.py @@ -6,38 +6,30 @@ class LoginDialogComponent(Component): def __init__( self, login_flow: Flow, - # login_success_flow: Flow, header: str = "Enter username/password", ): self.header = header self.login_flow = login_flow - # self.login_success_flow = login_success_flow self.visible = False self.error_message = None def display(self): if self.visible: - print(self.visible) self.username = st.text_input("Username") self.password = st.text_input("Password", type="password") if st.button("Login", use_container_width=True): self.login_flow.dispatch() if self.error_message: st.error(self.error_message) + # clearing the error message? + def show(self): - # if self.visible is False: - self.visible = True - # st.rerun() + self.visible = True def hide(self): - print("hide") - # if self.visible is True: self.visible = False - # st.rerun() def set_error(self, message: str): self.error_message = message - # def clear_error9 - diff --git a/example/src/components/toast.py b/example/src/components/toast.py index 4a0a862..4f29ae0 100644 --- a/example/src/components/toast.py +++ b/example/src/components/toast.py @@ -15,6 +15,5 @@ def display(self): self.visible = False def set_message(self, message: str): - print("set called") self.message = message self.visible = True \ No newline at end of file diff --git a/example/src/flows/login.py b/example/src/flows/login.py index 5da4e84..2ba6949 100644 --- a/example/src/flows/login.py +++ b/example/src/flows/login.py @@ -23,7 +23,7 @@ def cp_total_movies(self): @property def cp_toast(self): - return ToastComponent.get("toast") + return ToastComponent.get(ComponentIDs.toast) def run(self): response = self.backend_service.authenticate(self.cp_login_dialog.username, self.cp_login_dialog.password) @@ -49,8 +49,5 @@ def _login_success(self, response): self.cp_toast.set_message("error") st.switch_page("pages/dashboard.py") - - - def _login_failed(self): self.cp_login_dialog.set_error("Login failed, check your username and password") \ No newline at end of file diff --git a/example/src/flows/refresh.py b/example/src/flows/refresh.py index 3216b69..dc0207e 100644 --- a/example/src/flows/refresh.py +++ b/example/src/flows/refresh.py @@ -21,9 +21,9 @@ def run(self): self.session_store.set_property( "data", data ) # Store the data in the session_store for later use in more complex applications - avg_rating = self.avg_rating(data, "rating") + avg_rating = self._avg_rating(data, "rating") cp_avg_rating.set_value(avg_rating) - def avg_rating(self, data, key): + def _avg_rating(self, data, key): values = [d[key] for d in data if key in d and isinstance(d[key], (int, float))] return round(sum(values) / len(values)) if values else 0 diff --git a/example/src/layouts/dashboard.py b/example/src/layouts/dashboard.py index 059d77f..d99decd 100644 --- a/example/src/layouts/dashboard.py +++ b/example/src/layouts/dashboard.py @@ -7,7 +7,7 @@ class DashboardLayout(Layout): def __init__(self, refresh_flow: Flow): self.refresh_flow = refresh_flow - self.toast = ToastComponent.create("toast") + self.toast = ToastComponent.create(ComponentIDs.toast) self.total_movies = MetricComponent.create( ComponentIDs.total_movies, "Total movies" ) diff --git a/example/src/shared.py b/example/src/shared.py index 3340653..9f9c951 100644 --- a/example/src/shared.py +++ b/example/src/shared.py @@ -4,3 +4,4 @@ class ComponentIDs: data_viewer = "data_view" total_movies = "total_movies" avg_rating = "avg_rating" + toast = "toast" \ No newline at end of file From 1df2ab5707f19023ca8f86d40f7931fe7a295014 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Fri, 2 Jan 2026 10:04:06 +0100 Subject: [PATCH 28/60] Update login.py --- example/src/flows/login.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/example/src/flows/login.py b/example/src/flows/login.py index 2ba6949..5857cee 100644 --- a/example/src/flows/login.py +++ b/example/src/flows/login.py @@ -37,7 +37,8 @@ def _login_success(self, response): self.session_store.set_property("access_token", token_data["access_token"]) self.cp_login_dialog.hide() response = self.backend_service.get_movies() - response.ok = False + # enable the line below for example of an error scenario + # response.ok = False if response.ok: data = response.json() self.session_store.set_property( From 23ea5b275621c01bad4032a2dffcfcd5205fc4b8 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Fri, 2 Jan 2026 10:54:26 +0100 Subject: [PATCH 29/60] improved visibility functionality --- README.md | 24 ++++++++++++++++++------ example/src/components/login_dialog.py | 23 ++++++++--------------- example/src/components/toast.py | 9 +++++---- example/src/layouts/login.py | 7 +++++-- example/src/main.py | 2 +- src/ststeroids/component.py | 14 +++++++++++++- 6 files changed, 50 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index d3d6af7..6b7bff1 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Component concepts: - a component contains interaction elements, unless - the component is still meaningful and usable without a the interaction element → split the element out - a component doesn't navigate pages -- should have functions for updating it's attributes (explicit state changes) (so that the flow doesn't need to all the attributes) +- should have methods for updating it's attributes (explicit state changes) (so that the flow doesn't need to all the attributes) For example, a metric component that can be reused for multiple purposes. @@ -111,7 +111,7 @@ class MetricComponent(Component): self.value = value ``` -The header attribute and set_value method are specific to this example. They illustrate how components can have instance-bound attributes and provide an explicit API for updating their state. Components should own their state and expose such functions rather than allowing external code to directly mutate their attributes. +The header attribute and set_value method are specific to this example. They illustrate how components can have instance-bound attributes and provide an explicit API for updating their state. Components should own their state and expose such methods rather than allowing external code to directly mutate their attributes. ##### API Reference @@ -119,6 +119,18 @@ The header attribute and set_value method are specific to this example. They ill Holds the component id, is automaticly added from the base component. +`visible` + +Controls if the component is visible. Defaults to `True` Control using the `show` and `hide` methods. + +`show()` + +Sets the `visible` property of the component to `True` + +`hide()` + +Sets the `visible` property of the component to `False` + `create(cls, component_id: str, *args, **kwargs)` Creates a new component instance with the given `component_id` and stores it in the `ComponentStore`. @@ -157,7 +169,7 @@ The refresh interval, for example: `2s`. `register_element(element_name: str)` -Registers a Streamlit element onto the component by generating component bound key. Use this function when setting a key for an element within the component. +Registers a Streamlit element onto the component by generating component bound key. Use this method when setting a key for an element within the component. Usage: @@ -367,7 +379,7 @@ A partial rewrite of the framework so that it has a smaller footprint and creati 0.1.17 -- Improved execute_render function by adding an error handler +- Improved execute_render method by adding an error handler - Default refresh_interval for a fragment is now `None` to avoid unintended refreshes 0.1.16 @@ -385,8 +397,8 @@ A partial rewrite of the framework so that it has a smaller footprint and creati 0.1.13 -- Adds a function to set a registered element's value. -- Adds a function for rendering a component as a fragment. +- Adds a method to set a registered element's value. +- Adds a method for rendering a component as a fragment. 0.1.12 diff --git a/example/src/components/login_dialog.py b/example/src/components/login_dialog.py index 305d4b3..c358f98 100644 --- a/example/src/components/login_dialog.py +++ b/example/src/components/login_dialog.py @@ -10,24 +10,17 @@ def __init__( ): self.header = header self.login_flow = login_flow - self.visible = False self.error_message = None + self.hide() def display(self): - if self.visible: - self.username = st.text_input("Username") - self.password = st.text_input("Password", type="password") - if st.button("Login", use_container_width=True): - self.login_flow.dispatch() - if self.error_message: - st.error(self.error_message) - # clearing the error message? - - def show(self): - self.visible = True - - def hide(self): - self.visible = False + self.username = st.text_input("Username") + self.password = st.text_input("Password", type="password") + if st.button("Login", use_container_width=True): + self.login_flow.dispatch() + if self.error_message: + st.error(self.error_message) + # clearing the error message? def set_error(self, message: str): self.error_message = message diff --git a/example/src/components/toast.py b/example/src/components/toast.py index 4f29ae0..7ea3622 100644 --- a/example/src/components/toast.py +++ b/example/src/components/toast.py @@ -6,14 +6,15 @@ class ToastComponent(Component): def __init__( self, ): - self.visible = False self.message = None + self.hide() def display(self): - if self.visible: + # if self.visible: st.toast(self.message) - self.visible = False + self.hide() def set_message(self, message: str): self.message = message - self.visible = True \ No newline at end of file + self.show() + # self.visible = True \ No newline at end of file diff --git a/example/src/layouts/login.py b/example/src/layouts/login.py index ee7be9e..1610d81 100644 --- a/example/src/layouts/login.py +++ b/example/src/layouts/login.py @@ -1,23 +1,26 @@ import streamlit as st from components import LoginDialogComponent from shared import ComponentIDs -from ststeroids import Flow, Layout +from ststeroids import Flow, Layout, Store class LoginLayout(Layout): def __init__( self, + session_store: Store, login_header: str, login_flow: Flow, login_success_flow: Flow, ): + self.session_store = session_store self.login_header = login_header self.login_dialog = LoginDialogComponent.create( ComponentIDs.dialog_login, login_flow, login_success_flow ) def render(self): - self.login_dialog.show() + if not self.session_store.has_property("access_token"): + self.login_dialog.show() self.login_dialog.render("dialog", {"title": self.login_header}) st.write("Not logged in. Please refresh or use the menu on the left.") diff --git a/example/src/main.py b/example/src/main.py index 2b727f3..a0ed641 100644 --- a/example/src/main.py +++ b/example/src/main.py @@ -26,7 +26,7 @@ def __init__(self): app_style.apply_style() self.login_layout = LoginLayout.create( - "App login", self.login_flow, self.login_success_flow + self.session_store, "App login", self.login_flow, self.login_success_flow ) self.dashboard_layout = DashboardLayout.create(self.refresh_flow) self.manage_data_layout = ManageDataLayout.create() diff --git a/src/ststeroids/component.py b/src/ststeroids/component.py index 9401e1e..253c825 100644 --- a/src/ststeroids/component.py +++ b/src/ststeroids/component.py @@ -26,6 +26,8 @@ def create(cls, component_id: str, *args, **kwargs): try: instance = cls(*args, **kwargs) instance.id = component_id + if not hasattr(instance, "visible"): + instance.visible = True cls._store.init_component(instance) return instance except TypeError as e: @@ -127,10 +129,14 @@ def render( self, render_as: Literal["normal", "dialog", "fragment"] = "normal", options: dict = {}, - ): + ): """ Executes the render method implemented in the subclasses, additionaly providing extra configuration based on the `render_as` parameter """ + + if not self.visible: + return + match render_as: case "normal": return self.display() @@ -139,6 +145,12 @@ def render( case "fragment": return self._render_fragment(**options) raise ValueError(f"Unexpected render_as value: {render_as}") + + def show(self): + self.visible = True + + def hide(self): + self.visible = False @abstractmethod def display(self) -> None: From c468adb19ace897cfa01a690db7a2aa283b6589e Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Fri, 2 Jan 2026 11:08:41 +0100 Subject: [PATCH 30/60] doc strings and type hints --- src/ststeroids/component.py | 33 ++++++++++++++++----------------- src/ststeroids/flow.py | 9 +++------ src/ststeroids/style.py | 2 +- 3 files changed, 20 insertions(+), 24 deletions(-) diff --git a/src/ststeroids/component.py b/src/ststeroids/component.py index 253c825..066da2d 100644 --- a/src/ststeroids/component.py +++ b/src/ststeroids/component.py @@ -12,12 +12,16 @@ class Component(ABC): Attributes: id (str): The unique identifier for the component. + visible (bool) Controls if the component is visible or not. """ @classmethod def create(cls, component_id: str, *args, **kwargs): """ Create a new component instance or return it from the store. + + :param component_id: A unique identifier for the instance of the component + """ cls._store = ComponentStore() @@ -38,19 +42,20 @@ def create(cls, component_id: str, *args, **kwargs): @classmethod def get(cls, component_id: str): """ - Alias for create() — creation is implicit. + Alias for create() — note that create has to be called first. + + :param component_id: The unique identifier for the instance of the component to return. """ + return cls.create(component_id) def register_element(self, element_name: str): - """ + """ Generates a unique key for an element based on the instance ID. - Args: - element_name (str): The name of the element to register. + param: element_name: The name of the element to register. - Returns: - str: A unique key for the element. + return: A unique key for the element. """ key = f"{self.id}_{element_name}" return key @@ -59,11 +64,8 @@ def get_element(self, element_name: str): """ Retrieves the value of a registered element from the session state. - Args: - element_name (str): The name of the element to retrieve. - - Returns: - Any: The value of the element if it exists in the session state, otherwise None. + param: element_name: The name of the element to retrieve. + return: The value of the element if it exists in the session state, otherwise None. """ key = f"{self.id}_{element_name}" if key not in st.session_state: @@ -74,12 +76,9 @@ def set_element(self, element_name: str, element_value): """ Sets the value of a registered element in the session state. - Args: - element_name (str): The name of the element to set. - element_value (Any): The value to assign to the element. - - Returns: - None + param: element_name: The name of the element to set. + param: element_value: The value to assign to the element. + return: None """ key = f"{self.id}_{element_name}" diff --git a/src/ststeroids/flow.py b/src/ststeroids/flow.py index 6c4de92..4c85fb3 100644 --- a/src/ststeroids/flow.py +++ b/src/ststeroids/flow.py @@ -8,26 +8,23 @@ class Flow(ABC): """ @classmethod - def create(cls, *args, **kwargs): + def create(cls, *args, **kwargs): """ Creates a new flow instance. """ return cls(*args, **kwargs) - def dispatch(self): + def dispatch(self) -> None: """ Executes the run method implemented in the subclasses. """ return self.run() @abstractmethod - def run(self): + def run(self) -> None: """ Abstract methods that executes the flow logic. Each derived class should implement its own `run` method. - - :param args: Positional arguments for the run method. - :param kwargs: Keyword arguments for the run method. """ pass diff --git a/src/ststeroids/style.py b/src/ststeroids/style.py index 935acf5..e48efa1 100644 --- a/src/ststeroids/style.py +++ b/src/ststeroids/style.py @@ -15,7 +15,7 @@ def __init__(self, style_file: str): """ self.style_file = style_file - def apply_style(self): + def apply_style(self) -> None: """ Reads the CSS file and applies its styles to the Streamlit app. From 5a21bedf0a07c9d839ff08ba5073c15186154d4b Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Fri, 2 Jan 2026 11:42:09 +0100 Subject: [PATCH 31/60] final cleaning --- example/src/main.py | 2 +- src/ststeroids/component.py | 2 +- src/ststeroids/store.py | 34 ++++++++++++---------------------- 3 files changed, 14 insertions(+), 24 deletions(-) diff --git a/example/src/main.py b/example/src/main.py index a0ed641..8639340 100644 --- a/example/src/main.py +++ b/example/src/main.py @@ -10,7 +10,7 @@ class MainApp: def __init__(self): - self.session_store = Store("store") + self.session_store = Store.create("session") self.router = Router("login") self.backend_service = MockBackendService("./example/test_data.json") diff --git a/src/ststeroids/component.py b/src/ststeroids/component.py index 066da2d..59bfcfe 100644 --- a/src/ststeroids/component.py +++ b/src/ststeroids/component.py @@ -23,7 +23,7 @@ def create(cls, component_id: str, *args, **kwargs): :param component_id: A unique identifier for the instance of the component """ - cls._store = ComponentStore() + cls._store = ComponentStore.create("components") if cls._store.has_property(component_id): return cls._store.get_component(component_id) diff --git a/src/ststeroids/store.py b/src/ststeroids/store.py index 26afd68..2b75b14 100644 --- a/src/ststeroids/store.py +++ b/src/ststeroids/store.py @@ -13,15 +13,14 @@ class Store: """ def __init__(self, store_name: str): - """ - Initializes the session store with the given name. - - :param store_name: The name of the store to create in session state. - """ self.name = store_name if store_name not in st.session_state: - st.session_state[self.name] = {} + st.session_state[store_name] = {} + @classmethod + def create(cls, store_name: str): + return cls(store_name) + def has_property(self, property_name: str) -> bool: """ Checks if a property exists in the store. @@ -65,26 +64,17 @@ def del_property(self, property_name: str) -> None: class ComponentStore(Store): """ - Class that creates a component session store. This can be passed to component instances. + Class that creates a component session store. - :param component_id: The unique identifier for the component. - :param initial_state: The initial state of the component. """ - _instance = None - - def __new__(cls, *args, **kwargs): - if cls._instance is None: - cls._instance = super().__new__(cls) - return cls._instance + # def __init__(self): + # """ + # Initializes the component store with the name 'components'. - def __init__(self): - """ - Initializes the component store with the name 'components'. - - This store is used specifically for storing component-related state in the session. - """ - super().__init__("components") + # This store is used specifically for storing component-related state in the session. + # """ + # super().__init__("components") def init_component(self, component: object) -> None: """ From 7d2c14c3e6c84e3e437b9f3acbd00d8ecc992554 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Fri, 2 Jan 2026 11:57:56 +0100 Subject: [PATCH 32/60] docs strings --- src/ststeroids/main.py | 57 +++++++++++++++++++++++++++++---- src/ststeroids/route.py | 27 ++++++++++++++-- src/ststeroids/route_builder.py | 47 ++++++++++++++++++++++++--- src/ststeroids/router.py | 6 ++-- 4 files changed, 121 insertions(+), 16 deletions(-) diff --git a/src/ststeroids/main.py b/src/ststeroids/main.py index 8829465..9d6ca2d 100644 --- a/src/ststeroids/main.py +++ b/src/ststeroids/main.py @@ -1,25 +1,70 @@ from .route import Route from .route_builder import RouteBuilder from .router import Router - +from .layout import Layout class StSteroids: + """ + The main application class for managing routes and navigation. + + StSteroids handles registration of routes, setting a default route, + and running the router to navigate to the appropriate page or layout. + + Attributes: + _router (Router): The router instance responsible for handling navigation. + _routes (dict[str, Route]): Dictionary of registered routes keyed by name. + _default (Route | None): Optional default route to use if no route is specified. + """ def __init__(self): + """ + Initializes the StSteroids application instance. + """ self._router = Router() self._routes: dict[str, Route] = {} self._default: Route | None = None - def route(self, name: str) -> "RouteBuilder": + def route(self, name: str) -> RouteBuilder: + """ + Creates a RouteBuilder for defining a new route. + + Example usage: + app.route("home").to(HomeLayout).when(user_is_logged_in).register() + + :param name: The unique name of the route. + :return: RouteBuilder instance to define target and condition before registering. + """ return RouteBuilder(self, name) - def default_route(self, target) -> None: + def default_route(self, target: Layout) -> None: + """ + Sets the default route for the application. + + The default route is used if no other route is specified when running the app. + + :param target: The target layout for the default route. + """ self._default = Route("__default__", target) - def register(self, route: "Route"): + def register(self, route: Route) -> None: + """ + Registers a route in the application. + + :param route: The Route instance to register. + """ self._routes[route.name] = route - def run(self, entry_route: str | None = None): + def run(self, entry_route: str | None = None) -> None: + """ + Runs the application router. + + Filters routes based on their conditions, registers active routes + with the router, navigates to the specified entry route if provided, + and starts the router. + + :param entry_route: Optional name of the route to navigate to immediately. + :return: None + """ routes = {} if self._default: @@ -37,4 +82,4 @@ def run(self, entry_route: str | None = None): if entry_route: self._router.route(entry_route) - self._router.run() + self._router.run() \ No newline at end of file diff --git a/src/ststeroids/route.py b/src/ststeroids/route.py index 85b85ed..95e7b3b 100644 --- a/src/ststeroids/route.py +++ b/src/ststeroids/route.py @@ -1,5 +1,28 @@ +from .layout import Layout + class Route: - def __init__(self, name: str, target: callable, condition: callable = None): + """ + Represents a single route in the application. + + A route defines: + - a name (unique identifier), + - a target (the layout or callable to navigate to), + - an optional condition that determines if the route is active. + + Attributes: + name (str): Unique name of the route. + target (layout): The target layout or callable to execute. + condition (callable, optional): If provided, the route is active only when this callable returns True. + """ + + def __init__(self, name: str, target: Layout , condition: callable = None): + """ + Initializes a Route instance. + + :param name: Unique name of the route. + :param target: Layout to execute when the route is triggered. + :param condition: Optional callable returning a boolean. If provided, determines if the route is active. + """ self.name = name self.target = target - self.condition = condition + self.condition = condition \ No newline at end of file diff --git a/src/ststeroids/route_builder.py b/src/ststeroids/route_builder.py index e2ac286..77ee20f 100644 --- a/src/ststeroids/route_builder.py +++ b/src/ststeroids/route_builder.py @@ -1,20 +1,57 @@ +from .layout import Layout from .route import Route - class RouteBuilder: + """ + A builder class for defining and registering routes in the application. + + Allows chaining of target and condition definitions before registering the route. + + Example usage: + RouteBuilder(app, "home").to(HomeLayout).when(user_is_logged_in).register() + """ + def __init__(self, app, name: str): + """ + Initializes the RouteBuilder. + + :param app: The application instance where the route will be registered. + :param name: Unique name of the route. + """ self.app = app self._name = name self._target = None self._condition = None - def to(self, target): + def to(self, target: Layout) -> "RouteBuilder": + """ + Sets the target for this route. + + :param target: Layout class or callable to execute when the route is triggered. + :return: Self, to allow method chaining. + """ self._target = target return self - def when(self, condition: callable): + def when(self, condition: callable) -> "RouteBuilder": + """ + Sets a condition for this route. + + The route will only be active if the condition evaluates to True. + + :param condition: Callable returning a boolean. + :return: Self, to allow method chaining. + """ self._condition = condition return self - def register(self): - self.app.register(Route(self._name, self._target, self._condition)) + def register(self) -> None: + """ + Registers the route in the application with the specified target and condition. + + Raises: + ValueError: If no target has been set for the route. + """ + if self._target is None: + raise ValueError(f"Route '{self._name}' cannot be registered without a target.") + self.app.register(Route(self._name, self._target, self._condition)) \ No newline at end of file diff --git a/src/ststeroids/router.py b/src/ststeroids/router.py index 49dfc86..5ce8541 100644 --- a/src/ststeroids/router.py +++ b/src/ststeroids/router.py @@ -16,13 +16,13 @@ def __init__(self, default: str = "__default__"): self._current: str | None = None self._default: str = default - def register_routes(self, routes: dict[str, Route]): + def register_routes(self, routes: dict[str, Route]) -> None: """ Registers a dictionary of routes where keys are route names and values are layout callables. """ self._routes = routes - def route(self, route_name: str): + def route(self, route_name: str) -> None: """ Sets the current route to execute. @@ -36,7 +36,7 @@ def current_route(self) -> str | None: """ return self._current - def run(self): + def run(self) -> None: """ Executes the callable associated with the current layout. Falls back to the default route if none is selected. From 790ea1bcfeb589b622fd2cb11f477f7ab531baf2 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Fri, 2 Jan 2026 11:59:32 +0100 Subject: [PATCH 33/60] Update store.py --- src/ststeroids/store.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/ststeroids/store.py b/src/ststeroids/store.py index 2b75b14..61bbbd4 100644 --- a/src/ststeroids/store.py +++ b/src/ststeroids/store.py @@ -68,14 +68,6 @@ class ComponentStore(Store): """ - # def __init__(self): - # """ - # Initializes the component store with the name 'components'. - - # This store is used specifically for storing component-related state in the session. - # """ - # super().__init__("components") - def init_component(self, component: object) -> None: """ Initializes a component in the session store with its ID From 789541f854d3a1555c1b9bbecabcf61252878b25 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Fri, 2 Jan 2026 12:41:28 +0100 Subject: [PATCH 34/60] Update README.md --- README.md | 65 ++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 48 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 4511aa2..5822e08 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,6 @@ The main concepts of Ststeroids are: - Reusable Components - Logic Flows - Declarative Layouts -- A Router - A Store In addition, StSteroids provides an easy way to load style sheets into your Streamlit application and offers a wrapper around `st.session_state` to separate states into stores. This wrapper is also used within components to store the component and its state in the session state. @@ -61,18 +60,15 @@ Layout concepts: For example, a layout might define multiple Streamlit columns and place components within them. -#### Routers -Routers enable multi-page applications by defining routes and linking them to layouts. These routes are internal, meaning they cannot be accessed directly via a URL (due to current Streamlit limitations) and should be triggered through user interactions. - ### Installation ``` pip install ststeroids ``` -### Usage +### Getting started -StSteroids allows you to define components, layouts, and flows, then connect everything in `app.py` using a router. See the `example` folder in this repository. +StSteroids allows you to define components, layouts, and flows, then connect everything in a `main.py` by creating a StSteroids app. See the `example` folder in this repository. To run the example app, execute the following commands from the project root: @@ -89,6 +85,49 @@ pip install -r requirements-dev.txt pytest ``` +#### The basics + +To create an application using StSteroids, follow these steps: + +1. Create components – Define the individual UI elements of your application, such as dialogs, tables, or metrics, using the Component base class. +2. Create flows – Implement the business or orchestration logic that interacts with components, services, and session state. +3. Create layouts – Group and initialize components, arrange them visually, and pass the necessary flows to the components. Layouts define how your pages are structured. +4. Create the StSteroids app – Instantiate the app, register routes for each layout, and define a default route if needed. + +This sequence ensures a clear separation of concerns and keeps your app modular, testable, and easy to maintain. + +#### Routes + +Example of creatinga a StSteroids application. + +```python +app = StSteroids() + +# Register a layout as a route +app.route("dashboard").to(DashboardLayout).register() + +# Set a default route (optional) +app.default_route(DashboardLayout) + +# Run the app (optionally specify an entry route) +app.run() +``` + +`app.route(name).to(layout).register()` + +Registers a layout or page as a route in your app. +- name is the route identifier. +- layout is the layout class or callable to render when this route is selected. + +`app.default_route(layout)` + +Sets a default layout to display if no route is specified. + +`app.run(entry_route)` + +Starts the app and navigates to entry_route if provided; otherwise, uses the default route. + + #### Components Example of defining a new component. @@ -300,13 +339,6 @@ The router decides when it is rendered. This method needs to be implemented by the subclass. -#### Routers -Intializing a router - -```python -from ststeroids import Router -router = Router() -``` ##### API Reference @@ -333,7 +365,7 @@ A wrapper around `st.session_state` to separate states into stores. Usage: ```python -session_store = Store("yourstore") +session_store = Store.create("yourstore") ``` ##### API reference @@ -371,11 +403,10 @@ app_style.apply_style() 1.0.0 -A partial rewrite of the framework so that it has a smaller footprint and creation of objects feels more natural and is better supported by editors and debuggers. +We’ve partially rewritten the framework to reduce its footprint and make object creation more intuitive. Editor and debugger support has been improved, making development smoother and more productive. The router system has also been greatly enhanced, now supporting conditional routes directly within the framework, giving you more control over navigation and layout rendering. -- +**Note** this version is considered to be a breaking change. Make sure to adapt your code base so that it works with this new version. A small migration guide: -**Note** this version is considered to be a breaking change. Make sure to adapt your code base so that it works with this new version. - Updated example app so that sidebar is actually defined and rendered a layout and not in the main app - Rewrite of the whole router concept. Making it easier to work with routes and conditional routes. Also moves application routing logic to the framework. From 93b99ec8d79266f9748dd46e7436c5cc73d9fd4e Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Fri, 2 Jan 2026 12:58:38 +0100 Subject: [PATCH 35/60] Added migration guide --- README.md | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5822e08..1b816de 100644 --- a/README.md +++ b/README.md @@ -116,8 +116,8 @@ app.run() `app.route(name).to(layout).register()` Registers a layout or page as a route in your app. -- name is the route identifier. -- layout is the layout class or callable to render when this route is selected. +- name is the route identifier +- layout is the layout class or callable to render when this route is selected `app.default_route(layout)` @@ -407,8 +407,17 @@ We’ve partially rewritten the framework to reduce its footprint and make objec **Note** this version is considered to be a breaking change. Make sure to adapt your code base so that it works with this new version. A small migration guide: -- Updated example app so that sidebar is actually defined and rendered a layout and not in the main app -- Rewrite of the whole router concept. Making it easier to work with routes and conditional routes. Also moves application routing logic to the framework. +- Update the `__init__` of your components to match the new style +- Rename `render` in your components to `display` +- Remove any `show` and `hide` methods from your components as well as the `visible` property. They are now controlled by the framework +- In Flows use `YourComponent.get(component_id)` instead of `self.component_store.get_component(component_id) +- Remove `Router` from your Flows, use `st.switch_page` instead if you didn't already +- Move the initialization of the sidebar to layouts instead of the `main` of the app +- When rendering a component call `render` instead of `execute_render` +- When creating instances of StSteroids classes use `create` instead of calling `ClassName()`. This does not apply to the `Style` class +- The flow's `run` method can no longer take parameters. Access a components state instead to aquire the require parameters +- When calling a flow, use `dispatch()` instead of `run()` +- If you previously implemented your own logic for using the `router` class. Please consider using the new Steroids app style. 0.1.17 From 180e935cd8f82403d1bcab077d756e93d016bbe4 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Fri, 2 Jan 2026 13:01:50 +0100 Subject: [PATCH 36/60] Formatting --- example/src/components/__init__.py | 8 +++++++- example/src/components/login_dialog.py | 2 -- example/src/components/toast.py | 6 +++--- example/src/flows/login.py | 26 +++++++++++++++++--------- example/src/layouts/login.py | 1 - example/src/main.py | 4 +--- example/src/shared.py | 2 +- src/ststeroids/component.py | 8 ++++---- src/ststeroids/flow.py | 2 +- src/ststeroids/main.py | 3 ++- src/ststeroids/route.py | 5 +++-- src/ststeroids/route_builder.py | 7 +++++-- src/ststeroids/store.py | 2 +- 13 files changed, 45 insertions(+), 31 deletions(-) diff --git a/example/src/components/__init__.py b/example/src/components/__init__.py index 533e702..4e216f2 100644 --- a/example/src/components/__init__.py +++ b/example/src/components/__init__.py @@ -5,4 +5,10 @@ from .toast import ToastComponent -__all__ = [LoginDialogComponent, SidebarComponent, DataViewerComponent, MetricComponent, ToastComponent] +__all__ = [ + LoginDialogComponent, + SidebarComponent, + DataViewerComponent, + MetricComponent, + ToastComponent, +] diff --git a/example/src/components/login_dialog.py b/example/src/components/login_dialog.py index c358f98..a23ed6b 100644 --- a/example/src/components/login_dialog.py +++ b/example/src/components/login_dialog.py @@ -24,5 +24,3 @@ def display(self): def set_error(self, message: str): self.error_message = message - - diff --git a/example/src/components/toast.py b/example/src/components/toast.py index 7ea3622..84e7793 100644 --- a/example/src/components/toast.py +++ b/example/src/components/toast.py @@ -11,10 +11,10 @@ def __init__( def display(self): # if self.visible: - st.toast(self.message) - self.hide() + st.toast(self.message) + self.hide() def set_message(self, message: str): self.message = message self.show() - # self.visible = True \ No newline at end of file + # self.visible = True diff --git a/example/src/flows/login.py b/example/src/flows/login.py index 5857cee..cc8c7f9 100644 --- a/example/src/flows/login.py +++ b/example/src/flows/login.py @@ -1,9 +1,15 @@ from service import MockBackendService from ststeroids import Flow, Store -from components import LoginDialogComponent, DataViewerComponent, MetricComponent, ToastComponent +from components import ( + LoginDialogComponent, + DataViewerComponent, + MetricComponent, + ToastComponent, +) from shared import ComponentIDs import streamlit as st + class LoginFlow(Flow): def __init__(self, session_store: Store, backend_service: MockBackendService): self.session_store = session_store @@ -11,22 +17,24 @@ def __init__(self, session_store: Store, backend_service: MockBackendService): @property def cp_login_dialog(self): - return LoginDialogComponent.get(ComponentIDs.dialog_login) - + return LoginDialogComponent.get(ComponentIDs.dialog_login) + @property def cp_data_viewer(self): - return DataViewerComponent.get(ComponentIDs.data_viewer) - + return DataViewerComponent.get(ComponentIDs.data_viewer) + @property def cp_total_movies(self): return MetricComponent.get(ComponentIDs.total_movies) - + @property def cp_toast(self): return ToastComponent.get(ComponentIDs.toast) - + def run(self): - response = self.backend_service.authenticate(self.cp_login_dialog.username, self.cp_login_dialog.password) + response = self.backend_service.authenticate( + self.cp_login_dialog.username, self.cp_login_dialog.password + ) if response.ok: self._login_success(response) else: @@ -51,4 +59,4 @@ def _login_success(self, response): st.switch_page("pages/dashboard.py") def _login_failed(self): - self.cp_login_dialog.set_error("Login failed, check your username and password") \ No newline at end of file + self.cp_login_dialog.set_error("Login failed, check your username and password") diff --git a/example/src/layouts/login.py b/example/src/layouts/login.py index a7fe246..514b273 100644 --- a/example/src/layouts/login.py +++ b/example/src/layouts/login.py @@ -25,4 +25,3 @@ def render(self): self.login_dialog.show() self.login_dialog.render("dialog", {"title": self.login_header}) st.write("Not logged in. Please refresh or use the menu on the left.") - diff --git a/example/src/main.py b/example/src/main.py index 2494de5..c4626c3 100644 --- a/example/src/main.py +++ b/example/src/main.py @@ -5,7 +5,6 @@ from ststeroids import Store, Style, StSteroids - class MainApp: def __init__(self): @@ -24,8 +23,7 @@ def __init__(self): app_style.apply_style() self.login_layout = LoginLayout.create( - self.session_store, - "App login", self.login_flow, self.login_success_flow + self.session_store, "App login", self.login_flow, self.login_success_flow ) self.dashboard_layout = DashboardLayout.create(self.refresh_flow) self.manage_data_layout = ManageDataLayout.create() diff --git a/example/src/shared.py b/example/src/shared.py index 9f9c951..a262e07 100644 --- a/example/src/shared.py +++ b/example/src/shared.py @@ -4,4 +4,4 @@ class ComponentIDs: data_viewer = "data_view" total_movies = "total_movies" avg_rating = "avg_rating" - toast = "toast" \ No newline at end of file + toast = "toast" diff --git a/src/ststeroids/component.py b/src/ststeroids/component.py index 59bfcfe..f71a58d 100644 --- a/src/ststeroids/component.py +++ b/src/ststeroids/component.py @@ -50,7 +50,7 @@ def get(cls, component_id: str): return cls.create(component_id) def register_element(self, element_name: str): - """ + """ Generates a unique key for an element based on the instance ID. param: element_name: The name of the element to register. @@ -128,7 +128,7 @@ def render( self, render_as: Literal["normal", "dialog", "fragment"] = "normal", options: dict = {}, - ): + ): """ Executes the render method implemented in the subclasses, additionaly providing extra configuration based on the `render_as` parameter """ @@ -144,10 +144,10 @@ def render( case "fragment": return self._render_fragment(**options) raise ValueError(f"Unexpected render_as value: {render_as}") - + def show(self): self.visible = True - + def hide(self): self.visible = False diff --git a/src/ststeroids/flow.py b/src/ststeroids/flow.py index 4c85fb3..31724e3 100644 --- a/src/ststeroids/flow.py +++ b/src/ststeroids/flow.py @@ -8,7 +8,7 @@ class Flow(ABC): """ @classmethod - def create(cls, *args, **kwargs): + def create(cls, *args, **kwargs): """ Creates a new flow instance. """ diff --git a/src/ststeroids/main.py b/src/ststeroids/main.py index 9d6ca2d..f6dca31 100644 --- a/src/ststeroids/main.py +++ b/src/ststeroids/main.py @@ -3,6 +3,7 @@ from .router import Router from .layout import Layout + class StSteroids: """ The main application class for managing routes and navigation. @@ -82,4 +83,4 @@ def run(self, entry_route: str | None = None) -> None: if entry_route: self._router.route(entry_route) - self._router.run() \ No newline at end of file + self._router.run() diff --git a/src/ststeroids/route.py b/src/ststeroids/route.py index 95e7b3b..dde31ef 100644 --- a/src/ststeroids/route.py +++ b/src/ststeroids/route.py @@ -1,5 +1,6 @@ from .layout import Layout + class Route: """ Represents a single route in the application. @@ -15,7 +16,7 @@ class Route: condition (callable, optional): If provided, the route is active only when this callable returns True. """ - def __init__(self, name: str, target: Layout , condition: callable = None): + def __init__(self, name: str, target: Layout, condition: callable = None): """ Initializes a Route instance. @@ -25,4 +26,4 @@ def __init__(self, name: str, target: Layout , condition: callable = None): """ self.name = name self.target = target - self.condition = condition \ No newline at end of file + self.condition = condition diff --git a/src/ststeroids/route_builder.py b/src/ststeroids/route_builder.py index 77ee20f..7c0ec56 100644 --- a/src/ststeroids/route_builder.py +++ b/src/ststeroids/route_builder.py @@ -1,6 +1,7 @@ from .layout import Layout from .route import Route + class RouteBuilder: """ A builder class for defining and registering routes in the application. @@ -53,5 +54,7 @@ def register(self) -> None: ValueError: If no target has been set for the route. """ if self._target is None: - raise ValueError(f"Route '{self._name}' cannot be registered without a target.") - self.app.register(Route(self._name, self._target, self._condition)) \ No newline at end of file + raise ValueError( + f"Route '{self._name}' cannot be registered without a target." + ) + self.app.register(Route(self._name, self._target, self._condition)) diff --git a/src/ststeroids/store.py b/src/ststeroids/store.py index 61bbbd4..7ab85fa 100644 --- a/src/ststeroids/store.py +++ b/src/ststeroids/store.py @@ -20,7 +20,7 @@ def __init__(self, store_name: str): @classmethod def create(cls, store_name: str): return cls(store_name) - + def has_property(self, property_name: str) -> bool: """ Checks if a property exists in the store. From 5d2fc986524cf358a110cc7afffd613785b866f8 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Fri, 2 Jan 2026 13:04:36 +0100 Subject: [PATCH 37/60] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1b816de..d886fa4 100644 --- a/README.md +++ b/README.md @@ -403,7 +403,7 @@ app_style.apply_style() 1.0.0 -We’ve partially rewritten the framework to reduce its footprint and make object creation more intuitive. Editor and debugger support has been improved, making development smoother and more productive. The router system has also been greatly enhanced, now supporting conditional routes directly within the framework, giving you more control over navigation and layout rendering. +Partially rewritten the framework to reduce its footprint and make object creation more intuitive. Editor and debugger support has been improved, making development smoother and more productive. The router system has also been greatly enhanced, now supporting conditional routes directly within the framework, giving you more control over navigation and layout rendering. **Note** this version is considered to be a breaking change. Make sure to adapt your code base so that it works with this new version. A small migration guide: From f5356ad7957052a6dc9a25060a95f7ac3f4c1bf2 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Fri, 2 Jan 2026 13:07:28 +0100 Subject: [PATCH 38/60] Update README.md --- README.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/README.md b/README.md index d886fa4..10edaf9 100644 --- a/README.md +++ b/README.md @@ -456,13 +456,6 @@ Considered first stable release. Beta releases -### Todo - -- Improve IDE/autocomplete for state managed variables -- Ambition: directly link element values to component states -- Describe component store -- Layout and flow class singletons - ## Ideas - Something for RBAC From 3f6261c32211e2b3f7f32c9909f7d15dfab98c6b Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Fri, 2 Jan 2026 14:19:12 +0100 Subject: [PATCH 39/60] Update README.md --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 10edaf9..339c6a1 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ Layout concepts: - layouts are responsible for initializing and wiring components - layouts are responsible for the visual arrangement of components - layouts are responsible for conditional rendering based on application state or context (for example, authorisation) +- layout shouldn't handle domain errors For example, a layout might define multiple Streamlit columns and place components within them. @@ -408,6 +409,9 @@ Partially rewritten the framework to reduce its footprint and make object creati **Note** this version is considered to be a breaking change. Make sure to adapt your code base so that it works with this new version. A small migration guide: - Update the `__init__` of your components to match the new style + - component_id is no longer needed + - the `super().__init__()` no longer needs to be called +- accessing `.state` is no longer possible, you can directly access the attributes on a component instead - Rename `render` in your components to `display` - Remove any `show` and `hide` methods from your components as well as the `visible` property. They are now controlled by the framework - In Flows use `YourComponent.get(component_id)` instead of `self.component_store.get_component(component_id) @@ -417,7 +421,7 @@ Partially rewritten the framework to reduce its footprint and make object creati - When creating instances of StSteroids classes use `create` instead of calling `ClassName()`. This does not apply to the `Style` class - The flow's `run` method can no longer take parameters. Access a components state instead to aquire the require parameters - When calling a flow, use `dispatch()` instead of `run()` -- If you previously implemented your own logic for using the `router` class. Please consider using the new Steroids app style. +- If you previously implemented your own logic for using the `router` class. Please consider using the new Steroids app style 0.1.17 From 4f9782b9b0aaeb36db637a4cf4cf2aef348f1c11 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Tue, 6 Jan 2026 17:01:51 +0100 Subject: [PATCH 40/60] Comments Jamie and event handlers for app and routes --- README.md | 30 ++++++++++++++++++++------ example/src/components/login_dialog.py | 4 ++-- example/src/components/toast.py | 2 -- example/src/flows/__init__.py | 4 ++-- example/src/flows/app_setup.py | 6 ++++++ example/src/layouts/dashboard.py | 3 +++ example/src/layouts/login.py | 3 +-- example/src/main.py | 12 +++++------ src/ststeroids/component.py | 2 +- src/ststeroids/main.py | 14 +++++++++++- src/ststeroids/route.py | 5 +++-- src/ststeroids/route_builder.py | 9 ++++++-- src/ststeroids/router.py | 2 ++ 13 files changed, 69 insertions(+), 27 deletions(-) create mode 100644 example/src/flows/app_setup.py diff --git a/README.md b/README.md index 339c6a1..2b61ba6 100644 --- a/README.md +++ b/README.md @@ -97,9 +97,9 @@ To create an application using StSteroids, follow these steps: This sequence ensures a clear separation of concerns and keeps your app modular, testable, and easy to maintain. -#### Routes +#### StSteroids App and routes -Example of creatinga a StSteroids application. +Example of creating a StSteroids application. ```python app = StSteroids() @@ -114,11 +114,23 @@ app.default_route(DashboardLayout) app.run() ``` +##### API reference + `app.route(name).to(layout).register()` -Registers a layout or page as a route in your app. -- name is the route identifier -- layout is the layout class or callable to render when this route is selected +Registers a route that maps a route name to a layout class . +The layout is rendered when the route becomes active. + +The full route builder API is as follows. + +`app.route(name).to(layout).when(callable).on_enter(flow).register()` + +- `when` sets up a condition by specififying a callable. The route is only registered if the callable evaluates to True +- `on_enter` registers a flow for the on enter event. The flow is dispatched once when the route becomes active, before the layout is rendered + +`app.on_app_run_once(flow)` + +Registers an on app run once event handler flow. You can use this to have an initial flow that runs once at the start of the application. `app.default_route(layout)` @@ -129,6 +141,7 @@ Sets a default layout to display if no route is specified. Starts the app and navigates to entry_route if provided; otherwise, uses the default route. + #### Components Example of defining a new component. @@ -280,7 +293,7 @@ class AddDocumentFlow(DocumentActionBaseFlow): ``` In this example, AddDocumentFlow represents a single user action, while DocumentActionBaseFlow provides shared orchestration context. -This keeps flows focused, avoids duplication, and clearly separates reusable setup from action-specific logic.R +This keeps flows focused, avoids duplication, and clearly separates reusable setup from action-specific logic. ##### API Reference @@ -421,7 +434,9 @@ Partially rewritten the framework to reduce its footprint and make object creati - When creating instances of StSteroids classes use `create` instead of calling `ClassName()`. This does not apply to the `Style` class - The flow's `run` method can no longer take parameters. Access a components state instead to aquire the require parameters - When calling a flow, use `dispatch()` instead of `run()` -- If you previously implemented your own logic for using the `router` class. Please consider using the new Steroids app style +- If you previously implemented your own logic for using the `router` class. Please consider using the new Steroids app style, by doing so you can also utilize + - The on app run once event, for initial set up + - The router on enter event, for initial route setup. For example refresh data before rendering the page 0.1.17 @@ -462,5 +477,6 @@ Beta releases ## Ideas +- Improve event examples - Something for RBAC - Something for running longtime requests \ No newline at end of file diff --git a/example/src/components/login_dialog.py b/example/src/components/login_dialog.py index a23ed6b..3b25613 100644 --- a/example/src/components/login_dialog.py +++ b/example/src/components/login_dialog.py @@ -17,10 +17,10 @@ def display(self): self.username = st.text_input("Username") self.password = st.text_input("Password", type="password") if st.button("Login", use_container_width=True): - self.login_flow.dispatch() + self.login_flow.dispatch() # via on click, doe hij dan wel een rerun/ if self.error_message: st.error(self.error_message) - # clearing the error message? + self.error_message = None def set_error(self, message: str): self.error_message = message diff --git a/example/src/components/toast.py b/example/src/components/toast.py index 84e7793..1910d85 100644 --- a/example/src/components/toast.py +++ b/example/src/components/toast.py @@ -10,11 +10,9 @@ def __init__( self.hide() def display(self): - # if self.visible: st.toast(self.message) self.hide() def set_message(self, message: str): self.message = message self.show() - # self.visible = True diff --git a/example/src/flows/__init__.py b/example/src/flows/__init__.py index e2b5e51..303142e 100644 --- a/example/src/flows/__init__.py +++ b/example/src/flows/__init__.py @@ -1,5 +1,5 @@ from .login import LoginFlow -from .login_succes import LoginSuccessFlow from .refresh import RefreshFlow +from .app_setup import SetupFlow -__all__ = [LoginFlow, LoginSuccessFlow, RefreshFlow] +__all__ = [LoginFlow, RefreshFlow, SetupFlow] diff --git a/example/src/flows/app_setup.py b/example/src/flows/app_setup.py new file mode 100644 index 0000000..a21344c --- /dev/null +++ b/example/src/flows/app_setup.py @@ -0,0 +1,6 @@ +from ststeroids import Flow + + +class SetupFlow(Flow): + def run(self): + print("Im a flow setting up the app per user") diff --git a/example/src/layouts/dashboard.py b/example/src/layouts/dashboard.py index 1f2f57b..9322ea5 100644 --- a/example/src/layouts/dashboard.py +++ b/example/src/layouts/dashboard.py @@ -25,3 +25,6 @@ def render(self): "fragment", {"refresh_flow": self.refresh_flow, "refresh_interval": "2s"}, ) + + if st.button("logout"): + del st.session_state["store"]["access_token"] diff --git a/example/src/layouts/login.py b/example/src/layouts/login.py index 514b273..afb4702 100644 --- a/example/src/layouts/login.py +++ b/example/src/layouts/login.py @@ -10,13 +10,12 @@ def __init__( session_store: Store, login_header: str, login_flow: Flow, - login_success_flow: Flow, ): self.session_store = session_store self.login_header = login_header self.sidebar = SidebarComponent.create(ComponentIDs.sidebar) self.login_dialog = LoginDialogComponent.create( - ComponentIDs.dialog_login, login_flow, login_success_flow + ComponentIDs.dialog_login, login_flow ) def render(self): diff --git a/example/src/main.py b/example/src/main.py index c4626c3..18a57a2 100644 --- a/example/src/main.py +++ b/example/src/main.py @@ -1,5 +1,5 @@ import streamlit as st -from flows import LoginFlow, LoginSuccessFlow, RefreshFlow +from flows import LoginFlow, RefreshFlow, SetupFlow from layouts import LoginLayout, DashboardLayout, ManageDataLayout from service import MockBackendService from ststeroids import Store, Style, StSteroids @@ -11,10 +11,8 @@ def __init__(self): self.session_store = Store.create("store") self.backend_service = MockBackendService("./example/test_data.json") + self.setup_flow = SetupFlow.create() self.login_flow = LoginFlow.create(self.session_store, self.backend_service) - self.login_success_flow = LoginSuccessFlow.create( - self.session_store, self.backend_service - ) self.refresh_flow = RefreshFlow.create(self.session_store, self.backend_service) st.set_page_config(page_title="StSteroids Example app", layout="wide") @@ -23,13 +21,15 @@ def __init__(self): app_style.apply_style() self.login_layout = LoginLayout.create( - self.session_store, "App login", self.login_flow, self.login_success_flow + self.session_store, "App login", self.login_flow ) self.dashboard_layout = DashboardLayout.create(self.refresh_flow) self.manage_data_layout = ManageDataLayout.create() self.app = StSteroids() + # self.app.on_app_run_once(self.setup_flow) + self.app.default_route(self.login_layout) self.app.route("login").to(self.login_layout).register() @@ -38,4 +38,4 @@ def __init__(self): ).register() self.app.route("manage_data").to(self.manage_data_layout).when( lambda: self.session_store.has_property("access_token") - ).register() + ).on_enter(self.setup_flow).register() diff --git a/src/ststeroids/component.py b/src/ststeroids/component.py index f71a58d..955e95e 100644 --- a/src/ststeroids/component.py +++ b/src/ststeroids/component.py @@ -143,7 +143,7 @@ def render( return self._render_dialog(**options) case "fragment": return self._render_fragment(**options) - raise ValueError(f"Unexpected render_as value: {render_as}") + raise ValueError(f"Unexpected render_as value: {render_as}.") def show(self): self.visible = True diff --git a/src/ststeroids/main.py b/src/ststeroids/main.py index f6dca31..bc6f431 100644 --- a/src/ststeroids/main.py +++ b/src/ststeroids/main.py @@ -2,7 +2,8 @@ from .route_builder import RouteBuilder from .router import Router from .layout import Layout - +from .flow import Flow +import streamlit as st class StSteroids: """ @@ -24,6 +25,8 @@ def __init__(self): self._router = Router() self._routes: dict[str, Route] = {} self._default: Route | None = None + self._on_app_run_once = None + self._on_app_run = None def route(self, name: str) -> RouteBuilder: """ @@ -55,6 +58,11 @@ def register(self, route: Route) -> None: """ self._routes[route.name] = route + def on_app_run_once(self, callback: Flow): + if self._on_app_run_once: + raise RuntimeError("on_app_run_once already registered.") + self._on_app_run_once = callback + def run(self, entry_route: str | None = None) -> None: """ Runs the application router. @@ -66,6 +74,10 @@ def run(self, entry_route: str | None = None) -> None: :param entry_route: Optional name of the route to navigate to immediately. :return: None """ + if not "_on_app_run_once_done" in st.session_state and self._on_app_run_once: + self._on_app_run_once.dispatch() + st.session_state["_on_app_run_once_done"] = True + routes = {} if self._default: diff --git a/src/ststeroids/route.py b/src/ststeroids/route.py index dde31ef..7a503b3 100644 --- a/src/ststeroids/route.py +++ b/src/ststeroids/route.py @@ -1,5 +1,5 @@ from .layout import Layout - +from .flow import Flow class Route: """ @@ -16,7 +16,7 @@ class Route: condition (callable, optional): If provided, the route is active only when this callable returns True. """ - def __init__(self, name: str, target: Layout, condition: callable = None): + def __init__(self, name: str, target: Layout, on_enter: Flow = None, condition: callable = None): """ Initializes a Route instance. @@ -26,4 +26,5 @@ def __init__(self, name: str, target: Layout, condition: callable = None): """ self.name = name self.target = target + self.on_enter = on_enter self.condition = condition diff --git a/src/ststeroids/route_builder.py b/src/ststeroids/route_builder.py index 7c0ec56..04f927c 100644 --- a/src/ststeroids/route_builder.py +++ b/src/ststeroids/route_builder.py @@ -1,6 +1,6 @@ from .layout import Layout from .route import Route - +from .flow import Flow class RouteBuilder: """ @@ -23,6 +23,7 @@ def __init__(self, app, name: str): self._name = name self._target = None self._condition = None + self._on_enter = None def to(self, target: Layout) -> "RouteBuilder": """ @@ -45,6 +46,10 @@ def when(self, condition: callable) -> "RouteBuilder": """ self._condition = condition return self + + def on_enter(self, callback: Flow): + self._on_enter = callback + return self def register(self) -> None: """ @@ -57,4 +62,4 @@ def register(self) -> None: raise ValueError( f"Route '{self._name}' cannot be registered without a target." ) - self.app.register(Route(self._name, self._target, self._condition)) + self.app.register(Route(self._name, self._target, self._on_enter, self._condition)) diff --git a/src/ststeroids/router.py b/src/ststeroids/router.py index a72691a..33125c5 100644 --- a/src/ststeroids/router.py +++ b/src/ststeroids/router.py @@ -49,4 +49,6 @@ def run(self) -> None: raise RuntimeError( "No current route selected and no default route registered." ) + if route.on_enter: + route.on_enter.dispatch() route.target.render() From 82d1ea215ed75389637fa4abdc0fdac61e3c7d5c Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Tue, 6 Jan 2026 21:01:33 +0100 Subject: [PATCH 41/60] Final changes, component events work now through events --- README.md | 23 ++++++++++++---- example/src/components/__init__.py | 3 +- example/src/components/button.py | 13 +++++++++ example/src/components/login_dialog.py | 6 ++-- example/src/flows/__init__.py | 3 +- example/src/flows/app_setup.py | 4 +-- example/src/flows/login.py | 2 +- example/src/flows/login_succes.py | 27 ------------------ example/src/flows/logout.py | 9 ++++++ example/src/flows/refresh.py | 2 +- example/src/layouts/dashboard.py | 17 ++++++++---- example/src/layouts/login.py | 6 ++-- example/src/main.py | 11 +++++--- example/src/shared.py | 1 + src/ststeroids/component.py | 38 ++++++++++++++++++++++---- src/ststeroids/flow.py | 25 +++++++++++++---- src/ststeroids/main.py | 15 ++++++++-- src/ststeroids/route.py | 9 +++++- src/ststeroids/route_builder.py | 7 +++-- 19 files changed, 150 insertions(+), 71 deletions(-) create mode 100644 example/src/components/button.py delete mode 100644 example/src/flows/login_succes.py create mode 100644 example/src/flows/logout.py diff --git a/README.md b/README.md index 2b61ba6..2aa5a1c 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ For example, a login flow might call an authentication service, evaluate the res When multiple flows share orchestration resources—such as access to the same components, stores, or helper logic—it is recommended to introduce a shared base flow to centralize this responsibility and avoid duplication. #### Layouts + Layouts bring components together to create a multi-page application. Each layout functions as a page, rendering one or more components and defining their arrangement and rendering. Layout concepts: @@ -118,7 +119,7 @@ app.run() `app.route(name).to(layout).register()` -Registers a route that maps a route name to a layout class . +Registers a route that maps the route name to a layout class. The layout is rendered when the route becomes active. The full route builder API is as follows. @@ -126,11 +127,11 @@ The full route builder API is as follows. `app.route(name).to(layout).when(callable).on_enter(flow).register()` - `when` sets up a condition by specififying a callable. The route is only registered if the callable evaluates to True -- `on_enter` registers a flow for the on enter event. The flow is dispatched once when the route becomes active, before the layout is rendered +- `on_enter` registers a flow for the on enter event. The flow is dispatched once when the route becomes active, before the layout is rendered. Note! that an on enter event flow should not switch page as it will break the routing concept `app.on_app_run_once(flow)` -Registers an on app run once event handler flow. You can use this to have an initial flow that runs once at the start of the application. +Registers an on app run once event handler flow. You can use this to have an initial flow that runs once at the start of the application. Note! that an on app run once event flow should not switch page as it will break the app run concept `app.default_route(layout)` @@ -222,7 +223,7 @@ The refresh interval, for example: `2s`. `register_element(element_name: str)` -Registers a Streamlit element onto the component by generating component bound key. Use this method when setting a key for an element within the component. +Registers a Streamlit element onto the component by generating component bound key. Use this method when setting a key for an element within the component. For more information about using keys, please refer to the official Streamlit documentation. Usage: @@ -248,6 +249,14 @@ Usage: Sets the value of a registered element. +`on(event_name: str, callback: Flow)` + +Registers a flow as an event handler for the given event name on the component. The flow will be dispatched when the event is triggered. + +`trigger(event_name: str)` + +Triggers the specified event and dispatches the flow registered for it. Raises an error if no flow has been registered for that event. + #### Flows Example of defining a new flow: @@ -263,7 +272,7 @@ class AddDocumentFlow(Flow): def cp_document_table(self): return TableComponent.get(ComponentIDs.documents) - def run(self): + def run(self, component_id: str | None = None) -> None: # Flow logic for adding a document ``` @@ -427,6 +436,7 @@ Partially rewritten the framework to reduce its footprint and make object creati - accessing `.state` is no longer possible, you can directly access the attributes on a component instead - Rename `render` in your components to `display` - Remove any `show` and `hide` methods from your components as well as the `visible` property. They are now controlled by the framework +- Use a component’s on method to register flows as event handlers for specific component events, typically during layout initialization. Call trigger on the component to emit events and dispatch the registered flows. - In Flows use `YourComponent.get(component_id)` instead of `self.component_store.get_component(component_id) - Remove `Router` from your Flows, use `st.switch_page` instead if you didn't already - Move the initialization of the sidebar to layouts instead of the `main` of the app @@ -477,6 +487,7 @@ Beta releases ## Ideas -- Improve event examples +- Improve event examples in the example app +- Move logout to sidebar in the example app and show another example for a separate button - Something for RBAC - Something for running longtime requests \ No newline at end of file diff --git a/example/src/components/__init__.py b/example/src/components/__init__.py index 4e216f2..0e4f778 100644 --- a/example/src/components/__init__.py +++ b/example/src/components/__init__.py @@ -3,7 +3,7 @@ from .data_viewer import DataViewerComponent from .metric import MetricComponent from .toast import ToastComponent - +from .button import ButtonComponent __all__ = [ LoginDialogComponent, @@ -11,4 +11,5 @@ DataViewerComponent, MetricComponent, ToastComponent, + ButtonComponent, ] diff --git a/example/src/components/button.py b/example/src/components/button.py new file mode 100644 index 0000000..3ec1a0d --- /dev/null +++ b/example/src/components/button.py @@ -0,0 +1,13 @@ +import streamlit as st +from ststeroids import Component + + +class ButtonComponent(Component): + def __init__(self, button_text: str): + self.button_text = button_text + + def _handle_click(self): + self.trigger("button_click") + + def display(self): + st.button(self.button_text, on_click=self._handle_click) diff --git a/example/src/components/login_dialog.py b/example/src/components/login_dialog.py index 3b25613..1594ae9 100644 --- a/example/src/components/login_dialog.py +++ b/example/src/components/login_dialog.py @@ -1,15 +1,13 @@ import streamlit as st -from ststeroids import Component, Flow +from ststeroids import Component class LoginDialogComponent(Component): def __init__( self, - login_flow: Flow, header: str = "Enter username/password", ): self.header = header - self.login_flow = login_flow self.error_message = None self.hide() @@ -17,7 +15,7 @@ def display(self): self.username = st.text_input("Username") self.password = st.text_input("Password", type="password") if st.button("Login", use_container_width=True): - self.login_flow.dispatch() # via on click, doe hij dan wel een rerun/ + self.trigger("login_click") if self.error_message: st.error(self.error_message) self.error_message = None diff --git a/example/src/flows/__init__.py b/example/src/flows/__init__.py index 303142e..7685419 100644 --- a/example/src/flows/__init__.py +++ b/example/src/flows/__init__.py @@ -1,5 +1,6 @@ from .login import LoginFlow from .refresh import RefreshFlow from .app_setup import SetupFlow +from .logout import LogoutFlow -__all__ = [LoginFlow, RefreshFlow, SetupFlow] +__all__ = [LoginFlow, RefreshFlow, SetupFlow, LogoutFlow] diff --git a/example/src/flows/app_setup.py b/example/src/flows/app_setup.py index a21344c..214fcb6 100644 --- a/example/src/flows/app_setup.py +++ b/example/src/flows/app_setup.py @@ -2,5 +2,5 @@ class SetupFlow(Flow): - def run(self): - print("Im a flow setting up the app per user") + def run(self, component_id: str | None = None): + print("I'm a flow setting up the app per user") diff --git a/example/src/flows/login.py b/example/src/flows/login.py index cc8c7f9..c1c14b2 100644 --- a/example/src/flows/login.py +++ b/example/src/flows/login.py @@ -31,7 +31,7 @@ def cp_total_movies(self): def cp_toast(self): return ToastComponent.get(ComponentIDs.toast) - def run(self): + def run(self, component_id: str | None = None): response = self.backend_service.authenticate( self.cp_login_dialog.username, self.cp_login_dialog.password ) diff --git a/example/src/flows/login_succes.py b/example/src/flows/login_succes.py deleted file mode 100644 index f6c6d6a..0000000 --- a/example/src/flows/login_succes.py +++ /dev/null @@ -1,27 +0,0 @@ -from service import MockBackendService -from shared import ComponentIDs -from ststeroids import Flow, Store -from components import LoginDialogComponent, DataViewerComponent, MetricComponent -import streamlit as st - - -class LoginSuccessFlow(Flow): - def __init__(self, session_store: Store, backend_service: MockBackendService): - super().__init__() - self.session_store = session_store - self.backend_service = backend_service - - def run(self): - cp_login_dialog = LoginDialogComponent.get(ComponentIDs.dialog_login) - cp_data_viewer = DataViewerComponent.get(ComponentIDs.data_viewer) - cp_total_movies = MetricComponent.get(ComponentIDs.total_movies) - response = self.backend_service.get_movies() - if response.ok: - data = response.json() - self.session_store.set_property( - "data", data - ) # Store the data in the session_store for later use in more complex applications - cp_total_movies.set_value(len(data)) - cp_data_viewer.set_data(data) - st.switch_page("pages/dashboard.py") - cp_login_dialog.hide() diff --git a/example/src/flows/logout.py b/example/src/flows/logout.py new file mode 100644 index 0000000..d003546 --- /dev/null +++ b/example/src/flows/logout.py @@ -0,0 +1,9 @@ +from ststeroids import Flow, Store + + +class LogoutFlow(Flow): + def __init__(self, session_store: Store): + self.session_store = session_store + + def run(self, component_id: str | None = None): + self.session_store.del_property("access_token") diff --git a/example/src/flows/refresh.py b/example/src/flows/refresh.py index dc0207e..cee9f6d 100644 --- a/example/src/flows/refresh.py +++ b/example/src/flows/refresh.py @@ -13,7 +13,7 @@ def __init__( self.session_store = session_store self.backend_service = backend_service - def run(self): + def run(self, component_id: str | None = None): cp_avg_rating = MetricComponent.get(ComponentIDs.avg_rating) response = self.backend_service.get_movies() if response.ok: diff --git a/example/src/layouts/dashboard.py b/example/src/layouts/dashboard.py index 9322ea5..efa7c3d 100644 --- a/example/src/layouts/dashboard.py +++ b/example/src/layouts/dashboard.py @@ -1,11 +1,16 @@ import streamlit as st -from components import MetricComponent, SidebarComponent, ToastComponent +from components import ( + MetricComponent, + SidebarComponent, + ToastComponent, + ButtonComponent, +) from shared import ComponentIDs from ststeroids import Layout, Flow class DashboardLayout(Layout): - def __init__(self, refresh_flow: Flow): + def __init__(self, refresh_flow: Flow, logout_flow: Flow): self.refresh_flow = refresh_flow self.sidebar = SidebarComponent.create(ComponentIDs.sidebar) self.toast = ToastComponent.create(ComponentIDs.toast) @@ -13,6 +18,9 @@ def __init__(self, refresh_flow: Flow): ComponentIDs.total_movies, "Total movies" ) self.avg_rating = MetricComponent.create(ComponentIDs.avg_rating, "Avg. Rating") + self.logout_button = ButtonComponent.create(ComponentIDs.logout, "Logout") + + self.logout_button.on("button_click", logout_flow) def render(self): self.sidebar.render() @@ -25,6 +33,5 @@ def render(self): "fragment", {"refresh_flow": self.refresh_flow, "refresh_interval": "2s"}, ) - - if st.button("logout"): - del st.session_state["store"]["access_token"] + st.divider() + self.logout_button.render() diff --git a/example/src/layouts/login.py b/example/src/layouts/login.py index afb4702..303e983 100644 --- a/example/src/layouts/login.py +++ b/example/src/layouts/login.py @@ -14,9 +14,9 @@ def __init__( self.session_store = session_store self.login_header = login_header self.sidebar = SidebarComponent.create(ComponentIDs.sidebar) - self.login_dialog = LoginDialogComponent.create( - ComponentIDs.dialog_login, login_flow - ) + self.login_dialog = LoginDialogComponent.create(ComponentIDs.dialog_login) + + self.login_dialog.on("login_click", login_flow) def render(self): self.sidebar.render() diff --git a/example/src/main.py b/example/src/main.py index 18a57a2..7959e18 100644 --- a/example/src/main.py +++ b/example/src/main.py @@ -1,5 +1,5 @@ import streamlit as st -from flows import LoginFlow, RefreshFlow, SetupFlow +from flows import LoginFlow, RefreshFlow, SetupFlow, LogoutFlow from layouts import LoginLayout, DashboardLayout, ManageDataLayout from service import MockBackendService from ststeroids import Store, Style, StSteroids @@ -13,6 +13,7 @@ def __init__(self): self.backend_service = MockBackendService("./example/test_data.json") self.setup_flow = SetupFlow.create() self.login_flow = LoginFlow.create(self.session_store, self.backend_service) + self.logout_flow = LogoutFlow.create(self.session_store) self.refresh_flow = RefreshFlow.create(self.session_store, self.backend_service) st.set_page_config(page_title="StSteroids Example app", layout="wide") @@ -23,12 +24,14 @@ def __init__(self): self.login_layout = LoginLayout.create( self.session_store, "App login", self.login_flow ) - self.dashboard_layout = DashboardLayout.create(self.refresh_flow) + self.dashboard_layout = DashboardLayout.create( + self.refresh_flow, self.logout_flow + ) self.manage_data_layout = ManageDataLayout.create() self.app = StSteroids() - # self.app.on_app_run_once(self.setup_flow) + self.app.on_app_run_once(self.setup_flow) self.app.default_route(self.login_layout) @@ -38,4 +41,4 @@ def __init__(self): ).register() self.app.route("manage_data").to(self.manage_data_layout).when( lambda: self.session_store.has_property("access_token") - ).on_enter(self.setup_flow).register() + ).register() diff --git a/example/src/shared.py b/example/src/shared.py index a262e07..24571b1 100644 --- a/example/src/shared.py +++ b/example/src/shared.py @@ -5,3 +5,4 @@ class ComponentIDs: total_movies = "total_movies" avg_rating = "avg_rating" toast = "toast" + logout = "logout" diff --git a/src/ststeroids/component.py b/src/ststeroids/component.py index 955e95e..d376f20 100644 --- a/src/ststeroids/component.py +++ b/src/ststeroids/component.py @@ -1,4 +1,4 @@ -from typing import Literal +from typing import Literal, Any from abc import ABC, abstractmethod import streamlit as st from .store import ComponentStore @@ -15,6 +15,8 @@ class Component(ABC): visible (bool) Controls if the component is visible or not. """ + id: str + @classmethod def create(cls, component_id: str, *args, **kwargs): """ @@ -32,6 +34,8 @@ def create(cls, component_id: str, *args, **kwargs): instance.id = component_id if not hasattr(instance, "visible"): instance.visible = True + instance._events = {} + cls._store.init_component(instance) return instance except TypeError as e: @@ -49,7 +53,7 @@ def get(cls, component_id: str): return cls.create(component_id) - def register_element(self, element_name: str): + def register_element(self, element_name: str) -> str: """ Generates a unique key for an element based on the instance ID. @@ -60,7 +64,7 @@ def register_element(self, element_name: str): key = f"{self.id}_{element_name}" return key - def get_element(self, element_name: str): + def get_element(self, element_name: str) -> Any: """ Retrieves the value of a registered element from the session state. @@ -72,7 +76,7 @@ def get_element(self, element_name: str): return None return st.session_state[key] - def set_element(self, element_name: str, element_value): + def set_element(self, element_name: str, element_value) -> None: """ Sets the value of a registered element in the session state. @@ -84,6 +88,30 @@ def set_element(self, element_name: str, element_value): st.session_state[key] = element_value + def on(self, event_name: str, callback: Flow) -> None: + """ + Register a Flow callback for a named event on this component. + + :param event_name: The unique name of the event to bind the callback to. + Should ideally be a class-level constant to enable autocomplete. + :param callback: The Flow instance to execute when this event is triggered. + :return: None + """ + self._events[event_name] = callback + + def trigger(self, event_name: str) -> None: + """ + Trigger a previously registered event callback. + + :param event_name: The name of the event to trigger. + :raises RuntimeError: If no callback has been registered for this event. + :return: None + """ + callback = self._events.get(event_name, None) + if not callback: + raise RuntimeError(f"{event_name} has not been registered.") + callback.dispatch(self.id) + def _render_dialog(self, title: str): """ Internal method for rendering the component as a dialog. @@ -128,7 +156,7 @@ def render( self, render_as: Literal["normal", "dialog", "fragment"] = "normal", options: dict = {}, - ): + ) -> None: """ Executes the render method implemented in the subclasses, additionaly providing extra configuration based on the `render_as` parameter """ diff --git a/src/ststeroids/flow.py b/src/ststeroids/flow.py index 31724e3..db10fc5 100644 --- a/src/ststeroids/flow.py +++ b/src/ststeroids/flow.py @@ -14,17 +14,30 @@ def create(cls, *args, **kwargs): """ return cls(*args, **kwargs) - def dispatch(self) -> None: + def dispatch(self, component_id: str | None = None) -> None: """ - Executes the run method implemented in the subclasses. + Dispatches the flow execution. + + This method triggers the flow and forwards the identifier of the + source that caused the execution. + + :param component_id: Optional identifier of the source component that triggered the flow. Ignore for other sources. + :return: None """ - return self.run() + return self.run(component_id) @abstractmethod - def run(self) -> None: + def run(self, component_id: str | None = None) -> None: """ - Abstract methods that executes the flow logic. + Executes the flow logic. + + This method must be implemented by subclasses and contains the + orchestration and business logic for the flow. + + The `component_id` provides contextual information about which component triggered + the flow. - Each derived class should implement its own `run` method. + :param component_id: Optional identifier of the source component that triggered the flow. Can be useful when you want to reuse a flow for different instances of the same component. + :return: None """ pass diff --git a/src/ststeroids/main.py b/src/ststeroids/main.py index bc6f431..d83c8c1 100644 --- a/src/ststeroids/main.py +++ b/src/ststeroids/main.py @@ -5,6 +5,7 @@ from .flow import Flow import streamlit as st + class StSteroids: """ The main application class for managing routes and navigation. @@ -58,7 +59,17 @@ def register(self, route: Route) -> None: """ self._routes[route.name] = route - def on_app_run_once(self, callback: Flow): + def on_app_run_once(self, callback: Flow) -> None: + """ + Registers a flow to be executed once when the application starts. + + This flow will be triggered only the first time the app runs. + Subsequent reruns of the app will not re-execute this flow. + + :param callback: The Flow instance to execute on the first app run. + :raises RuntimeError: If an on_app_run_once flow has already been registered. + :return: None + """ if self._on_app_run_once: raise RuntimeError("on_app_run_once already registered.") self._on_app_run_once = callback @@ -74,7 +85,7 @@ def run(self, entry_route: str | None = None) -> None: :param entry_route: Optional name of the route to navigate to immediately. :return: None """ - if not "_on_app_run_once_done" in st.session_state and self._on_app_run_once: + if "_on_app_run_once_done" not in st.session_state and self._on_app_run_once: self._on_app_run_once.dispatch() st.session_state["_on_app_run_once_done"] = True diff --git a/src/ststeroids/route.py b/src/ststeroids/route.py index 7a503b3..aabfdc4 100644 --- a/src/ststeroids/route.py +++ b/src/ststeroids/route.py @@ -1,6 +1,7 @@ from .layout import Layout from .flow import Flow + class Route: """ Represents a single route in the application. @@ -16,7 +17,13 @@ class Route: condition (callable, optional): If provided, the route is active only when this callable returns True. """ - def __init__(self, name: str, target: Layout, on_enter: Flow = None, condition: callable = None): + def __init__( + self, + name: str, + target: Layout, + on_enter: Flow = None, + condition: callable = None, + ): """ Initializes a Route instance. diff --git a/src/ststeroids/route_builder.py b/src/ststeroids/route_builder.py index 04f927c..96fa9e9 100644 --- a/src/ststeroids/route_builder.py +++ b/src/ststeroids/route_builder.py @@ -2,6 +2,7 @@ from .route import Route from .flow import Flow + class RouteBuilder: """ A builder class for defining and registering routes in the application. @@ -46,7 +47,7 @@ def when(self, condition: callable) -> "RouteBuilder": """ self._condition = condition return self - + def on_enter(self, callback: Flow): self._on_enter = callback return self @@ -62,4 +63,6 @@ def register(self) -> None: raise ValueError( f"Route '{self._name}' cannot be registered without a target." ) - self.app.register(Route(self._name, self._target, self._on_enter, self._condition)) + self.app.register( + Route(self._name, self._target, self._on_enter, self._condition) + ) From c330813897c3716a3ad4c61430552cd6a12040f6 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Thu, 8 Jan 2026 09:07:17 +0100 Subject: [PATCH 42/60] Comments Jamie --- README.md | 19 +++++++++---------- src/ststeroids/main.py | 5 ----- src/ststeroids/route.py | 7 +++++-- src/ststeroids/store.py | 16 +++++++++++++--- src/ststeroids/style.py | 3 +++ 5 files changed, 30 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 2aa5a1c..3ba8372 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,9 @@ Each component contains only the logic necessary for its functionality, such as Component concepts: - components never decide on domain logic, so no domain error handeling for example -- a component contains interaction elements, unless - - the component is still meaningful and usable without a the interaction element → split the element out -- a component doesn't navigate pages -- should have methods for updating it's attributes (explicit state changes) (so that the flow doesn't need to all the attributes) +- a component contains interaction elements, unless the component is still meaningful and usable without the interaction element → split the element out +- components don't navigate pages +- should have methods for updating its attributes (explicit state changes so that the flow doesn't need to all the attributes) For example, a metric component that can be reused for multiple purposes. @@ -38,12 +37,12 @@ They handle user-initiated actions, coordinate state changes across components, Flow concepts: -- Flows act as handlers for user and system interactions (e.g. button clicks, page entry, form submission) -- Flows orchestrate application behavior, calling services and updating component state -- Flows coordinate multiple components and stores as part of a single interaction -- Flows determine navigation and control flow between layouts or pages -- Flows own error handling and recovery logic for the interactions they manage -- Flows may contain light business rules, but core domain logic should live in services +- flows act as handlers for user and system interactions (e.g. button clicks, page entry, form submission) +- flows orchestrate application behavior, calling services and updating component state +- flows coordinate multiple components and stores as part of a single interaction +- flows determine navigation and control flow between layouts or pages +- flows own error handling and recovery logic for the interactions they manage +- flows may contain light business rules, but core domain logic should live in services For example, a login flow might call an authentication service, evaluate the result, store relevant session data, and update one or more components to reflect the outcome. diff --git a/src/ststeroids/main.py b/src/ststeroids/main.py index d83c8c1..ed2d76c 100644 --- a/src/ststeroids/main.py +++ b/src/ststeroids/main.py @@ -12,11 +12,6 @@ class StSteroids: StSteroids handles registration of routes, setting a default route, and running the router to navigate to the appropriate page or layout. - - Attributes: - _router (Router): The router instance responsible for handling navigation. - _routes (dict[str, Route]): Dictionary of registered routes keyed by name. - _default (Route | None): Optional default route to use if no route is specified. """ def __init__(self): diff --git a/src/ststeroids/route.py b/src/ststeroids/route.py index aabfdc4..40b9870 100644 --- a/src/ststeroids/route.py +++ b/src/ststeroids/route.py @@ -9,11 +9,13 @@ class Route: A route defines: - a name (unique identifier), - a target (the layout or callable to navigate to), + - an optional flow to dispatch when the route is entered, - an optional condition that determines if the route is active. Attributes: name (str): Unique name of the route. - target (layout): The target layout or callable to execute. + target (layout): The target layout to render. + on_enter (flow): The flow to dispatch when the route is entered. condition (callable, optional): If provided, the route is active only when this callable returns True. """ @@ -28,7 +30,8 @@ def __init__( Initializes a Route instance. :param name: Unique name of the route. - :param target: Layout to execute when the route is triggered. + :param target: Layout to render when the route is triggered. + :param on_enter: Flow to dispatch when the route is entered. :param condition: Optional callable returning a boolean. If provided, determines if the route is active. """ self.name = name diff --git a/src/ststeroids/store.py b/src/ststeroids/store.py index 7ab85fa..edf7d51 100644 --- a/src/ststeroids/store.py +++ b/src/ststeroids/store.py @@ -7,18 +7,28 @@ class Store: Class for creating a session store. This class manages storing and retrieving properties in Streamlit's session state. - It initializes a store with a unique name and allows properties to be set and retrieved. - - :param store_name: The name of the store to create in session state. + + Attributes: + name (str): Unique name of the store. """ def __init__(self, store_name: str): + """ + Initializes a store with a unique name and allows properties to be set and retrieved. Do not use directly, use create instead. + + :param store_name: The name of the store to create in session state. + """ self.name = store_name if store_name not in st.session_state: st.session_state[store_name] = {} @classmethod def create(cls, store_name: str): + """ + Creates a new instance of the store with a unique name and allows properties to be set and retrieved. + + :param store_name: The name of the store to create in session state. + """ return cls(store_name) def has_property(self, property_name: str) -> bool: diff --git a/src/ststeroids/style.py b/src/ststeroids/style.py index e48efa1..e794dc7 100644 --- a/src/ststeroids/style.py +++ b/src/ststeroids/style.py @@ -5,6 +5,9 @@ class Style: """ A class for applying custom CSS styles to a Streamlit app. + + Attributes: + style_file (str): Path to CSS file for this instance. """ def __init__(self, style_file: str): From b9c88d043d22caf58a65aebb32944c20e228a705 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Thu, 8 Jan 2026 12:16:33 +0100 Subject: [PATCH 43/60] Better flow context --- README.md | 7 ++++--- example/src/components/button.py | 14 ++++++++++++-- example/src/components/login_dialog.py | 13 +++++++++++-- example/src/flows/app_setup.py | 4 ++-- example/src/flows/login.py | 4 ++-- example/src/flows/logout.py | 4 ++-- example/src/flows/refresh.py | 4 ++-- example/src/layouts/dashboard.py | 4 +--- example/src/layouts/login.py | 3 --- example/src/main.py | 8 ++++++-- src/ststeroids/__init__.py | 3 ++- src/ststeroids/component.py | 4 +++- src/ststeroids/flow.py | 16 +++++++--------- src/ststeroids/flow_context.py | 13 +++++++++++++ src/ststeroids/main.py | 3 ++- src/ststeroids/router.py | 4 ++-- 16 files changed, 71 insertions(+), 37 deletions(-) create mode 100644 src/ststeroids/flow_context.py diff --git a/README.md b/README.md index 3ba8372..6332336 100644 --- a/README.md +++ b/README.md @@ -271,7 +271,7 @@ class AddDocumentFlow(Flow): def cp_document_table(self): return TableComponent.get(ComponentIDs.documents) - def run(self, component_id: str | None = None) -> None: + def run(self, ctx: FlowContext) -> None: # Flow logic for adding a document ``` @@ -296,7 +296,7 @@ Then, create a dedicated flow for each document action: ```python class AddDocumentFlow(DocumentActionBaseFlow): - def run(self): + def run(self, ctx: FlowContext): # Flow logic for adding a document ``` @@ -435,7 +435,7 @@ Partially rewritten the framework to reduce its footprint and make object creati - accessing `.state` is no longer possible, you can directly access the attributes on a component instead - Rename `render` in your components to `display` - Remove any `show` and `hide` methods from your components as well as the `visible` property. They are now controlled by the framework -- Use a component’s on method to register flows as event handlers for specific component events, typically during layout initialization. Call trigger on the component to emit events and dispatch the registered flows. +- Use a component’s on method to register flows as event handlers for specific component events, typically when setting up the app. Call trigger on the component to emit events and dispatch the registered flows. - In Flows use `YourComponent.get(component_id)` instead of `self.component_store.get_component(component_id) - Remove `Router` from your Flows, use `st.switch_page` instead if you didn't already - Move the initialization of the sidebar to layouts instead of the `main` of the app @@ -488,5 +488,6 @@ Beta releases - Improve event examples in the example app - Move logout to sidebar in the example app and show another example for a separate button +- Fragement through event - Something for RBAC - Something for running longtime requests \ No newline at end of file diff --git a/example/src/components/button.py b/example/src/components/button.py index 3ec1a0d..01e4f98 100644 --- a/example/src/components/button.py +++ b/example/src/components/button.py @@ -1,13 +1,23 @@ import streamlit as st -from ststeroids import Component +from ststeroids import Component, Flow class ButtonComponent(Component): + + EVENT_ClICK = "click" + def __init__(self, button_text: str): self.button_text = button_text def _handle_click(self): - self.trigger("button_click") + self.trigger(self.EVENT_ClICK) def display(self): st.button(self.button_text, on_click=self._handle_click) + + def on_click(self, flow: Flow) -> None: + """ + Register a flow to be executed when the user clicks the button. + """ + self.on(self.EVENT_ClICK, flow) + diff --git a/example/src/components/login_dialog.py b/example/src/components/login_dialog.py index 1594ae9..2911b13 100644 --- a/example/src/components/login_dialog.py +++ b/example/src/components/login_dialog.py @@ -1,8 +1,11 @@ import streamlit as st -from ststeroids import Component +from ststeroids import Component, Flow class LoginDialogComponent(Component): + + EVENT_LOGIN = "login" + def __init__( self, header: str = "Enter username/password", @@ -15,10 +18,16 @@ def display(self): self.username = st.text_input("Username") self.password = st.text_input("Password", type="password") if st.button("Login", use_container_width=True): - self.trigger("login_click") + self.trigger(self.EVENT_LOGIN) if self.error_message: st.error(self.error_message) self.error_message = None + def on_login(self, flow: Flow) -> None: + """ + Register a flow to be executed when the user clicks the login button. + """ + self.on(self.EVENT_LOGIN, flow) + def set_error(self, message: str): self.error_message = message diff --git a/example/src/flows/app_setup.py b/example/src/flows/app_setup.py index 214fcb6..adc440e 100644 --- a/example/src/flows/app_setup.py +++ b/example/src/flows/app_setup.py @@ -1,6 +1,6 @@ -from ststeroids import Flow +from ststeroids import Flow, FlowContext class SetupFlow(Flow): - def run(self, component_id: str | None = None): + def run(self, _ctx: FlowContext): print("I'm a flow setting up the app per user") diff --git a/example/src/flows/login.py b/example/src/flows/login.py index c1c14b2..599a693 100644 --- a/example/src/flows/login.py +++ b/example/src/flows/login.py @@ -1,5 +1,5 @@ from service import MockBackendService -from ststeroids import Flow, Store +from ststeroids import Flow, Store, FlowContext from components import ( LoginDialogComponent, DataViewerComponent, @@ -31,7 +31,7 @@ def cp_total_movies(self): def cp_toast(self): return ToastComponent.get(ComponentIDs.toast) - def run(self, component_id: str | None = None): + def run(self, _ctx: FlowContext): response = self.backend_service.authenticate( self.cp_login_dialog.username, self.cp_login_dialog.password ) diff --git a/example/src/flows/logout.py b/example/src/flows/logout.py index d003546..17eefbc 100644 --- a/example/src/flows/logout.py +++ b/example/src/flows/logout.py @@ -1,9 +1,9 @@ -from ststeroids import Flow, Store +from ststeroids import Flow, Store, FlowContext class LogoutFlow(Flow): def __init__(self, session_store: Store): self.session_store = session_store - def run(self, component_id: str | None = None): + def run(self, _ctx: FlowContext ): self.session_store.del_property("access_token") diff --git a/example/src/flows/refresh.py b/example/src/flows/refresh.py index cee9f6d..23f3e32 100644 --- a/example/src/flows/refresh.py +++ b/example/src/flows/refresh.py @@ -1,6 +1,6 @@ from service import MockBackendService from shared import ComponentIDs -from ststeroids import Flow, Store +from ststeroids import Flow, Store, FlowContext from components import MetricComponent @@ -13,7 +13,7 @@ def __init__( self.session_store = session_store self.backend_service = backend_service - def run(self, component_id: str | None = None): + def run(self, _ctx: FlowContext): cp_avg_rating = MetricComponent.get(ComponentIDs.avg_rating) response = self.backend_service.get_movies() if response.ok: diff --git a/example/src/layouts/dashboard.py b/example/src/layouts/dashboard.py index efa7c3d..e7c66de 100644 --- a/example/src/layouts/dashboard.py +++ b/example/src/layouts/dashboard.py @@ -10,7 +10,7 @@ class DashboardLayout(Layout): - def __init__(self, refresh_flow: Flow, logout_flow: Flow): + def __init__(self, refresh_flow: Flow): self.refresh_flow = refresh_flow self.sidebar = SidebarComponent.create(ComponentIDs.sidebar) self.toast = ToastComponent.create(ComponentIDs.toast) @@ -20,8 +20,6 @@ def __init__(self, refresh_flow: Flow, logout_flow: Flow): self.avg_rating = MetricComponent.create(ComponentIDs.avg_rating, "Avg. Rating") self.logout_button = ButtonComponent.create(ComponentIDs.logout, "Logout") - self.logout_button.on("button_click", logout_flow) - def render(self): self.sidebar.render() self.toast.render() diff --git a/example/src/layouts/login.py b/example/src/layouts/login.py index 303e983..b845777 100644 --- a/example/src/layouts/login.py +++ b/example/src/layouts/login.py @@ -9,15 +9,12 @@ def __init__( self, session_store: Store, login_header: str, - login_flow: Flow, ): self.session_store = session_store self.login_header = login_header self.sidebar = SidebarComponent.create(ComponentIDs.sidebar) self.login_dialog = LoginDialogComponent.create(ComponentIDs.dialog_login) - self.login_dialog.on("login_click", login_flow) - def render(self): self.sidebar.render() if not self.session_store.has_property("access_token"): diff --git a/example/src/main.py b/example/src/main.py index 7959e18..8a06c0d 100644 --- a/example/src/main.py +++ b/example/src/main.py @@ -22,13 +22,17 @@ def __init__(self): app_style.apply_style() self.login_layout = LoginLayout.create( - self.session_store, "App login", self.login_flow + self.session_store, "App login" ) self.dashboard_layout = DashboardLayout.create( - self.refresh_flow, self.logout_flow + self.refresh_flow ) self.manage_data_layout = ManageDataLayout.create() + # register event handlers + self.login_layout.login_dialog.on_login(self.login_flow) + self.dashboard_layout.logout_button.on_click(self.logout_flow) + self.app = StSteroids() self.app.on_app_run_once(self.setup_flow) diff --git a/src/ststeroids/__init__.py b/src/ststeroids/__init__.py index 0625723..e332657 100644 --- a/src/ststeroids/__init__.py +++ b/src/ststeroids/__init__.py @@ -4,6 +4,7 @@ from .store import Store from .layout import Layout from .main import StSteroids +from .flow_context import FlowContext -__all__ = ["Component", "Layout", "Flow", "Style", "Store", "StSteroids"] +__all__ = ["Component", "Layout", "Flow", "Style", "Store", "StSteroids", "FlowContext"] diff --git a/src/ststeroids/component.py b/src/ststeroids/component.py index d376f20..c2e6d5e 100644 --- a/src/ststeroids/component.py +++ b/src/ststeroids/component.py @@ -3,6 +3,7 @@ import streamlit as st from .store import ComponentStore from .flow import Flow +from .flow_context import FlowContext # pylint: disable=too-few-public-methods @@ -16,6 +17,7 @@ class Component(ABC): """ id: str + _events: dict[str, Flow] @classmethod def create(cls, component_id: str, *args, **kwargs): @@ -110,7 +112,7 @@ def trigger(self, event_name: str) -> None: callback = self._events.get(event_name, None) if not callback: raise RuntimeError(f"{event_name} has not been registered.") - callback.dispatch(self.id) + callback.dispatch(FlowContext("component", self.id)) def _render_dialog(self, title: str): """ diff --git a/src/ststeroids/flow.py b/src/ststeroids/flow.py index db10fc5..096771b 100644 --- a/src/ststeroids/flow.py +++ b/src/ststeroids/flow.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from.flow_context import FlowContext # pylint: disable=too-few-public-methods @@ -14,30 +15,27 @@ def create(cls, *args, **kwargs): """ return cls(*args, **kwargs) - def dispatch(self, component_id: str | None = None) -> None: + def dispatch(self, ctx: FlowContext) -> None: """ Dispatches the flow execution. - This method triggers the flow and forwards the identifier of the + This method triggers the flow and forwards the context of the source that caused the execution. - :param component_id: Optional identifier of the source component that triggered the flow. Ignore for other sources. + :param ctx: The `context` provides contextual information about what triggered the flow. :return: None """ - return self.run(component_id) + self.run(ctx) @abstractmethod - def run(self, component_id: str | None = None) -> None: + def run(self, ctx: FlowContext) -> None: """ Executes the flow logic. This method must be implemented by subclasses and contains the orchestration and business logic for the flow. - The `component_id` provides contextual information about which component triggered - the flow. - - :param component_id: Optional identifier of the source component that triggered the flow. Can be useful when you want to reuse a flow for different instances of the same component. + :param ctx: The `context` provides contextual information about what triggered the flow. Can be useful when you want to reuse a flow for different instances of the same component. :return: None """ pass diff --git a/src/ststeroids/flow_context.py b/src/ststeroids/flow_context.py new file mode 100644 index 0000000..4ea1ec4 --- /dev/null +++ b/src/ststeroids/flow_context.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass + +@dataclass +class FlowContext: + """ + Encapsulates the context of why a flow is being executed. + + Attributes: + type: The type of the trigger ("component", "route", "app"). + identifier: Optional identifier, e.g., component id, route name. + """ + type: str + identifier: str = None \ No newline at end of file diff --git a/src/ststeroids/main.py b/src/ststeroids/main.py index ed2d76c..c379104 100644 --- a/src/ststeroids/main.py +++ b/src/ststeroids/main.py @@ -4,6 +4,7 @@ from .layout import Layout from .flow import Flow import streamlit as st +from .flow_context import FlowContext class StSteroids: @@ -81,7 +82,7 @@ def run(self, entry_route: str | None = None) -> None: :return: None """ if "_on_app_run_once_done" not in st.session_state and self._on_app_run_once: - self._on_app_run_once.dispatch() + self._on_app_run_once.dispatch(FlowContext("app","run_once")) st.session_state["_on_app_run_once_done"] = True routes = {} diff --git a/src/ststeroids/router.py b/src/ststeroids/router.py index 33125c5..12cf3a4 100644 --- a/src/ststeroids/router.py +++ b/src/ststeroids/router.py @@ -1,5 +1,5 @@ from .route import Route - +from .flow_context import FlowContext class Router: """ @@ -50,5 +50,5 @@ def run(self) -> None: "No current route selected and no default route registered." ) if route.on_enter: - route.on_enter.dispatch() + route.on_enter.dispatch(FlowContext("route", route.name)) route.target.render() From c4c6ee2a592b687bba3564dcdc7d0f703d512b28 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Thu, 8 Jan 2026 16:24:21 +0100 Subject: [PATCH 44/60] New Fragment and Dialog component types --- README.md | 35 +++++++---------- example/src/components/login_dialog.py | 6 +-- example/src/components/metric.py | 4 +- example/src/layouts/dashboard.py | 9 ++--- example/src/layouts/login.py | 6 +-- example/src/main.py | 1 + src/ststeroids/__init__.py | 5 ++- src/ststeroids/component.py | 54 +------------------------- src/ststeroids/dialog.py | 37 ++++++++++++++++++ src/ststeroids/fragment.py | 48 +++++++++++++++++++++++ 10 files changed, 114 insertions(+), 91 deletions(-) create mode 100644 src/ststeroids/dialog.py create mode 100644 src/ststeroids/fragment.py diff --git a/README.md b/README.md index 6332336..2a87744 100644 --- a/README.md +++ b/README.md @@ -92,8 +92,9 @@ To create an application using StSteroids, follow these steps: 1. Create components – Define the individual UI elements of your application, such as dialogs, tables, or metrics, using the Component base class. 2. Create flows – Implement the business or orchestration logic that interacts with components, services, and session state. -3. Create layouts – Group and initialize components, arrange them visually, and pass the necessary flows to the components. Layouts define how your pages are structured. -4. Create the StSteroids app – Instantiate the app, register routes for each layout, and define a default route if needed. +3. Create layouts – Group and initialize components, arrange them visually. Layouts define how your pages are structured. +4. Register event handlers. +5. Create the StSteroids app – Instantiate the app, register routes for each layout, and define a default route if needed. This sequence ensures a clear separation of concerns and keeps your app modular, testable, and easy to maintain. @@ -185,10 +186,13 @@ Sets the `visible` property of the component to `True` Sets the `visible` property of the component to `False` `create(cls, component_id: str, *args, **kwargs)` +`create(cls, component_id: str, title:str ,*args, **kwargs)` (Dialog only) +`create(cls, component_id: str, refresh_interval:str ,*args, **kwargs)` (Fragment only) Creates a new component instance with the given `component_id` and stores it in the `ComponentStore`. This is typically called in layouts to initialize components. Additional arguments are passed to the component's constructor. + `get(cls, component_id: str)` Retrieves an existing component instance from the `ComponentStore` by its `component_id`. @@ -199,26 +203,9 @@ This is typically used in flows that needs to interact with a component after it This method needs to be implemented by the subclass. To call it in a layout, use `render()` -`render(render_as: Literal["normal", "dialog", "fragment"]="normal", options:dict={})` - -Executes the display method of an instance of a component. Additionaly provide the `render_as` parameter with the `options` parameter. - -Dialog options: - -**title** - -The dialog title. - -Fragment options: - -**refresh_flow** - -A refresh flow that should be called post rendering the component, you can use this to refresh the applications state for the next view. - -**refresh_interval** - -The refresh interval, for example: `2s`. +`render()` +Executes the display method of an instance of a component. `register_element(element_name: str)` @@ -252,6 +239,10 @@ Sets the value of a registered element. Registers a flow as an event handler for the given event name on the component. The flow will be dispatched when the event is triggered. +`on_refresh(self, flow: Flow)` + +Registers a flow as an event handler for the refresh event of a Fragment (Fragment only) + `trigger(event_name: str)` Triggers the specified event and dispatches the flow registered for it. Raises an error if no flow has been registered for that event. @@ -446,6 +437,7 @@ Partially rewritten the framework to reduce its footprint and make object creati - If you previously implemented your own logic for using the `router` class. Please consider using the new Steroids app style, by doing so you can also utilize - The on app run once event, for initial set up - The router on enter event, for initial route setup. For example refresh data before rendering the page +- There are two new component types, `Fragement` and `Dialog`, they replace the `render_as` parameter. Please update your components and render calls accordingly 0.1.17 @@ -488,6 +480,5 @@ Beta releases - Improve event examples in the example app - Move logout to sidebar in the example app and show another example for a separate button -- Fragement through event - Something for RBAC - Something for running longtime requests \ No newline at end of file diff --git a/example/src/components/login_dialog.py b/example/src/components/login_dialog.py index 2911b13..4a28825 100644 --- a/example/src/components/login_dialog.py +++ b/example/src/components/login_dialog.py @@ -1,16 +1,14 @@ import streamlit as st -from ststeroids import Component, Flow +from ststeroids import Dialog, Flow -class LoginDialogComponent(Component): +class LoginDialogComponent(Dialog): EVENT_LOGIN = "login" def __init__( self, - header: str = "Enter username/password", ): - self.header = header self.error_message = None self.hide() diff --git a/example/src/components/metric.py b/example/src/components/metric.py index a262af5..0dcf5b0 100644 --- a/example/src/components/metric.py +++ b/example/src/components/metric.py @@ -1,8 +1,8 @@ import streamlit as st -from ststeroids import Component +from ststeroids import Fragment -class MetricComponent(Component): +class MetricComponent(Fragment): def __init__( self, header: str, diff --git a/example/src/layouts/dashboard.py b/example/src/layouts/dashboard.py index e7c66de..02a8f37 100644 --- a/example/src/layouts/dashboard.py +++ b/example/src/layouts/dashboard.py @@ -15,9 +15,9 @@ def __init__(self, refresh_flow: Flow): self.sidebar = SidebarComponent.create(ComponentIDs.sidebar) self.toast = ToastComponent.create(ComponentIDs.toast) self.total_movies = MetricComponent.create( - ComponentIDs.total_movies, "Total movies" + ComponentIDs.total_movies, None, "Total movies" ) - self.avg_rating = MetricComponent.create(ComponentIDs.avg_rating, "Avg. Rating") + self.avg_rating = MetricComponent.create(ComponentIDs.avg_rating, "2s", "Avg. Rating") self.logout_button = ButtonComponent.create(ComponentIDs.logout, "Logout") def render(self): @@ -27,9 +27,6 @@ def render(self): with left: self.total_movies.render() with right: - self.avg_rating.render( - "fragment", - {"refresh_flow": self.refresh_flow, "refresh_interval": "2s"}, - ) + self.avg_rating.render() st.divider() self.logout_button.render() diff --git a/example/src/layouts/login.py b/example/src/layouts/login.py index b845777..85045e1 100644 --- a/example/src/layouts/login.py +++ b/example/src/layouts/login.py @@ -1,7 +1,7 @@ import streamlit as st from components import LoginDialogComponent, SidebarComponent from shared import ComponentIDs -from ststeroids import Flow, Layout, Store +from ststeroids import Layout, Store class LoginLayout(Layout): @@ -13,11 +13,11 @@ def __init__( self.session_store = session_store self.login_header = login_header self.sidebar = SidebarComponent.create(ComponentIDs.sidebar) - self.login_dialog = LoginDialogComponent.create(ComponentIDs.dialog_login) + self.login_dialog = LoginDialogComponent.create(ComponentIDs.dialog_login, self.login_header) def render(self): self.sidebar.render() if not self.session_store.has_property("access_token"): self.login_dialog.show() - self.login_dialog.render("dialog", {"title": self.login_header}) + self.login_dialog.render() st.write("Not logged in. Please refresh or use the menu on the left.") diff --git a/example/src/main.py b/example/src/main.py index 8a06c0d..73d503d 100644 --- a/example/src/main.py +++ b/example/src/main.py @@ -32,6 +32,7 @@ def __init__(self): # register event handlers self.login_layout.login_dialog.on_login(self.login_flow) self.dashboard_layout.logout_button.on_click(self.logout_flow) + self.dashboard_layout.avg_rating.on_refresh(self.refresh_flow) self.app = StSteroids() diff --git a/src/ststeroids/__init__.py b/src/ststeroids/__init__.py index e332657..d63fccd 100644 --- a/src/ststeroids/__init__.py +++ b/src/ststeroids/__init__.py @@ -5,6 +5,7 @@ from .layout import Layout from .main import StSteroids from .flow_context import FlowContext +from .fragment import Fragment +from .dialog import Dialog - -__all__ = ["Component", "Layout", "Flow", "Style", "Store", "StSteroids", "FlowContext"] +__all__ = ["Component", "Layout", "Flow", "Style", "Store", "StSteroids", "FlowContext", "Fragment", "Dialog"] diff --git a/src/ststeroids/component.py b/src/ststeroids/component.py index c2e6d5e..f00e30a 100644 --- a/src/ststeroids/component.py +++ b/src/ststeroids/component.py @@ -111,53 +111,11 @@ def trigger(self, event_name: str) -> None: """ callback = self._events.get(event_name, None) if not callback: - raise RuntimeError(f"{event_name} has not been registered.") + raise RuntimeError(f"{event_name} has not been registered for component with id {self.id}") callback.dispatch(FlowContext("component", self.id)) - def _render_dialog(self, title: str): - """ - Internal method for rendering the component as a dialog. - - This wraps the component's core render logic in a Streamlit dialog with the given title. - - :param title: The title to display at the top of the dialog. - """ - - @st.dialog(title) - def _render(): - self.display() - - _render() - - def _render_fragment(self, refresh_interval: str = None, refresh_flow: Flow = None): - """ - Internal method for rendering the component as a fragment. - - This sets up a Streamlit fragment that automatically re-runs at the given interval. - It internally calls the __render_fragment method. - - This method is not meant to be overridden. Subclasses should implement the render() - method to define the rendering behavior. - - :param refresh_interval: The interval at which the fragment should refresh (e.g., "5s"). - :param refresh_flow: Optional flow object to pass into the rendering logic. - """ - - @st.fragment(run_every=refresh_interval) - def _render(): - self.__render_fragment(refresh_flow) - - _render() - - def __render_fragment(self, refresh_flow: Flow = None): - self.display() - if refresh_flow: - refresh_flow.dispatch() - def render( self, - render_as: Literal["normal", "dialog", "fragment"] = "normal", - options: dict = {}, ) -> None: """ Executes the render method implemented in the subclasses, additionaly providing extra configuration based on the `render_as` parameter @@ -165,15 +123,7 @@ def render( if not self.visible: return - - match render_as: - case "normal": - return self.display() - case "dialog": - return self._render_dialog(**options) - case "fragment": - return self._render_fragment(**options) - raise ValueError(f"Unexpected render_as value: {render_as}.") + self.display() def show(self): self.visible = True diff --git a/src/ststeroids/dialog.py b/src/ststeroids/dialog.py new file mode 100644 index 0000000..8204ae2 --- /dev/null +++ b/src/ststeroids/dialog.py @@ -0,0 +1,37 @@ +import streamlit as st +from .component import Component + +class Dialog(Component): + """ + Base class for dialog components. + + Dialog components wrap their content inside a Streamlit dialog and provide + a dedicated method for rendering as a dialog. + """ + + @classmethod + def create(cls, component_id: str, title: str = "title", *args, **kwargs): + """ + Create a new Dialog instance or return it from the store. + + :param component_id: Unique identifier for this dialog component. + :param title: Dialog title. + :return: Dialog instance + """ + instance = super().create(component_id, *args, **kwargs) + instance.title = title + return instance + + def render(self): + """ + Renders the component inside a Streamlit dialog context. + Calls the `display` method to render the contents. + """ + if not self.visible: + return + + @st.dialog(self.title) + def _dialog(): + self.display() + + _dialog() \ No newline at end of file diff --git a/src/ststeroids/fragment.py b/src/ststeroids/fragment.py new file mode 100644 index 0000000..41d1128 --- /dev/null +++ b/src/ststeroids/fragment.py @@ -0,0 +1,48 @@ +from .component import Component +from .flow import Flow +import streamlit as st + +class Fragment(Component): + """ + Base class for components that render as Streamlit fragments and provide + a built-in `refresh` event for decoupled flows. + """ + + refresh_interval: str + EVENT_REFRESH = "_refresh" # class-level constant for autocomplete + + @classmethod + def create(cls, component_id: str, refresh_interval: str = "5s", *args, **kwargs): + """ + Create a new Fragment instance or return it from the store. + + :param component_id: Unique identifier for this dialog component. + :param refresh_interval: The interval for the on_refresh event. + :return: Fragment instance + """ + instance = super().create(component_id, *args, **kwargs) + instance.refresh_interval = refresh_interval + return instance + + def render(self): + """ + Render the component as a fragment and trigger the `on_refresh` event + on each rerun/refresh. + """ + if not self.visible: + return + + @st.fragment(run_every=self.refresh_interval) + def _fragment(): + if self.EVENT_REFRESH in self._events: + self.trigger(self.EVENT_REFRESH) + self.display() + + _fragment() + + def on_refresh(self, flow: Flow) -> None: + """ + Register a flow to be executed when the the fragment refreshes + """ + self.on(self.EVENT_REFRESH, flow) + \ No newline at end of file From ded77d12511f441de953639e8414ab65fed42dd7 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Fri, 9 Jan 2026 08:57:41 +0100 Subject: [PATCH 45/60] Comments Jamie done --- README.md | 35 ++++++-------------------- pyproject.toml | 2 +- src/ststeroids/dialog.py | 12 ++++++--- src/ststeroids/fragment.py | 15 +++++++++--- src/ststeroids/main.py | 2 ++ src/ststeroids/router.py | 50 ++++++++++++++++++++++++++------------ 6 files changed, 65 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 2a87744..21fe213 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Each component contains only the logic necessary for its functionality, such as Component concepts: -- components never decide on domain logic, so no domain error handeling for example +- components never decide on domain logic, so there is no domain error handling for example - a component contains interaction elements, unless the component is still meaningful and usable without the interaction element → split the element out - components don't navigate pages - should have methods for updating its attributes (explicit state changes so that the flow doesn't need to all the attributes) @@ -126,12 +126,12 @@ The full route builder API is as follows. `app.route(name).to(layout).when(callable).on_enter(flow).register()` -- `when` sets up a condition by specififying a callable. The route is only registered if the callable evaluates to True -- `on_enter` registers a flow for the on enter event. The flow is dispatched once when the route becomes active, before the layout is rendered. Note! that an on enter event flow should not switch page as it will break the routing concept +- `when` sets up a condition by specifying a callable. The route is only registered if the callable evaluates to True +- `on_enter` registers a flow for the on enter event. The flow is dispatched once when the route becomes active, before the layout is rendered. Note! that an `on_enter` event flow should not switch page as it will break the routing concept `app.on_app_run_once(flow)` -Registers an on app run once event handler flow. You can use this to have an initial flow that runs once at the start of the application. Note! that an on app run once event flow should not switch page as it will break the app run concept +Registers an `on_app_run_once` event handler flow. You can use this to have an initial flow that runs once at the start of the application. Note! that an `on_app_run_once` event flow should not switch page as it will break the app run concept `app.default_route(layout)` @@ -171,7 +171,7 @@ The header attribute and set_value method are specific to this example. They ill `id` -Holds the component id, is automaticly added from the base component. +Holds the component id, is automatically added from the base component. `visible` @@ -197,7 +197,7 @@ This is typically called in layouts to initialize components. Additional argumen Retrieves an existing component instance from the `ComponentStore` by its `component_id`. `create()` must have been called first; otherwise, an error will be raised. -This is typically used in flows that needs to interact with a component after it has been initialized. +This is typically used in flows that need to interact with a component after it has been initialized. `display()` @@ -209,7 +209,7 @@ Executes the display method of an instance of a component. `register_element(element_name: str)` -Registers a Streamlit element onto the component by generating component bound key. Use this method when setting a key for an element within the component. For more information about using keys, please refer to the official Streamlit documentation. +Registers a Streamlit element onto the component by generating component-bound key. Use this method when setting a key for an element within the component. For more information about using keys, please refer to the official Streamlit documentation. Usage: @@ -352,25 +352,6 @@ The router decides when it is rendered. This method needs to be implemented by the subclass. - -##### API Reference - -`run` - -Runs the currently active route - -`route(route_name: str)` - -Changes the currently active to the given route name - -`register_routes(routes: dict[str, Layout])` - -Registers a dictionary of routes where keys are route names and values are layouts. - -`get_current_route` - -Returns the currently active route. Useful for creating a navigation breadcrumbs. - #### Store A wrapper around `st.session_state` to separate states into stores. @@ -426,7 +407,7 @@ Partially rewritten the framework to reduce its footprint and make object creati - accessing `.state` is no longer possible, you can directly access the attributes on a component instead - Rename `render` in your components to `display` - Remove any `show` and `hide` methods from your components as well as the `visible` property. They are now controlled by the framework -- Use a component’s on method to register flows as event handlers for specific component events, typically when setting up the app. Call trigger on the component to emit events and dispatch the registered flows. +- Use a component’s `on` method to register flows as event handlers for specific component events, typically when setting up the app. Call trigger on the component to emit events and dispatch the registered flows. - In Flows use `YourComponent.get(component_id)` instead of `self.component_store.get_component(component_id) - Remove `Router` from your Flows, use `st.switch_page` instead if you didn't already - Move the initialization of the sidebar to layouts instead of the `main` of the app diff --git a/pyproject.toml b/pyproject.toml index ccbf457..469c992 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ststeroids" -version = "1.0.0" +version = "1.0.0rc1" description = "A framework supercharging Streamlit for building advanced multi-page applications" readme = "README.md" authors = [{ name = "ponsoc"}] diff --git a/src/ststeroids/dialog.py b/src/ststeroids/dialog.py index 8204ae2..184584a 100644 --- a/src/ststeroids/dialog.py +++ b/src/ststeroids/dialog.py @@ -7,8 +7,13 @@ class Dialog(Component): Dialog components wrap their content inside a Streamlit dialog and provide a dedicated method for rendering as a dialog. + + Attributes: + title (str): The title of the dialog. """ + title: str + @classmethod def create(cls, component_id: str, title: str = "title", *args, **kwargs): """ @@ -16,16 +21,17 @@ def create(cls, component_id: str, title: str = "title", *args, **kwargs): :param component_id: Unique identifier for this dialog component. :param title: Dialog title. - :return: Dialog instance """ instance = super().create(component_id, *args, **kwargs) instance.title = title return instance - def render(self): - """ + def render(self) -> None: + """ Renders the component inside a Streamlit dialog context. Calls the `display` method to render the contents. + + :return: None """ if not self.visible: return diff --git a/src/ststeroids/fragment.py b/src/ststeroids/fragment.py index 41d1128..a202040 100644 --- a/src/ststeroids/fragment.py +++ b/src/ststeroids/fragment.py @@ -6,28 +6,32 @@ class Fragment(Component): """ Base class for components that render as Streamlit fragments and provide a built-in `refresh` event for decoupled flows. + + Attributes: + refresh_interval (str|None): The refresh interval """ - refresh_interval: str + refresh_interval: str | None EVENT_REFRESH = "_refresh" # class-level constant for autocomplete @classmethod - def create(cls, component_id: str, refresh_interval: str = "5s", *args, **kwargs): + def create(cls, component_id: str, refresh_interval: str | None = None, *args, **kwargs): """ Create a new Fragment instance or return it from the store. :param component_id: Unique identifier for this dialog component. :param refresh_interval: The interval for the on_refresh event. - :return: Fragment instance """ instance = super().create(component_id, *args, **kwargs) instance.refresh_interval = refresh_interval return instance - def render(self): + def render(self) -> None: """ Render the component as a fragment and trigger the `on_refresh` event on each rerun/refresh. + + :return: None """ if not self.visible: return @@ -43,6 +47,9 @@ def _fragment(): def on_refresh(self, flow: Flow) -> None: """ Register a flow to be executed when the the fragment refreshes + + :param: Flow the flow to dispatch on the refresh event. + :return: None """ self.on(self.EVENT_REFRESH, flow) \ No newline at end of file diff --git a/src/ststeroids/main.py b/src/ststeroids/main.py index c379104..87cc0f2 100644 --- a/src/ststeroids/main.py +++ b/src/ststeroids/main.py @@ -44,6 +44,7 @@ def default_route(self, target: Layout) -> None: The default route is used if no other route is specified when running the app. :param target: The target layout for the default route. + :return: None """ self._default = Route("__default__", target) @@ -52,6 +53,7 @@ def register(self, route: Route) -> None: Registers a route in the application. :param route: The Route instance to register. + :return: None """ self._routes[route.name] = route diff --git a/src/ststeroids/router.py b/src/ststeroids/router.py index 12cf3a4..737c101 100644 --- a/src/ststeroids/router.py +++ b/src/ststeroids/router.py @@ -1,16 +1,22 @@ from .route import Route from .flow_context import FlowContext + class Router: """ - A routing system for the framework, allowing navigation between different layouts. + Central routing system responsible for selecting and rendering layouts. + + The Router maintains a set of registered routes, determines which route + is currently active, optionally dispatches route lifecycle flows, and + renders the corresponding layout. """ def __init__(self, default: str = "__default__"): """ - Initializes the Router instance with a default layout. + Initialize the Router. - :param default: The default route to use when no current route is selected. + :param default: The name of the default route to use when no explicit + route has been selected. """ self._routes: dict[str, Route] = {} self._current: str | None = None @@ -18,28 +24,36 @@ def __init__(self, default: str = "__default__"): def register_routes(self, routes: dict[str, Route]) -> None: """ - Registers a dictionary of routes where keys are route names and values are layout callables. + Register the available routes. + + This replaces any previously registered routes. + + :param routes: A mapping of route names to Route instances. + :return: None """ self._routes = routes def route(self, route_name: str) -> None: """ - Sets the current route to execute. + Set the current route to navigate to. - :param route_name: The name of the route to navigate to. - """ - self._current = route_name + The route will be resolved and rendered on the next call to `run()`. - def current_route(self) -> str | None: - """ - Returns the name of the currently selected layout. + :param route_name: The name of the route to activate. + :return: None """ - return self._current + self._current = route_name def run(self) -> None: """ - Executes the callable associated with the current layout. - Falls back to the default route if none is selected. + Resolve and render the active route. + + The router selects the current route if set, otherwise falls back + to the default route. If the route defines an `on_enter` flow, it + will be dispatched before rendering the target layout. + + :raises RuntimeError: If no valid route can be resolved. + :return: None """ if self._current in self._routes: route = self._routes[self._current] @@ -49,6 +63,10 @@ def run(self) -> None: raise RuntimeError( "No current route selected and no default route registered." ) + if route.on_enter: - route.on_enter.dispatch(FlowContext("route", route.name)) - route.target.render() + route.on_enter.dispatch( + FlowContext(source="route", identifier=route.name) + ) + + route.target.render() \ No newline at end of file From 02efdc1fee56369810bcffdd36f7d7318708669c Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Fri, 9 Jan 2026 12:24:36 +0100 Subject: [PATCH 46/60] formatting --- README.md | 4 ++-- example/src/components/button.py | 1 - example/src/flows/logout.py | 2 +- example/src/layouts/dashboard.py | 4 +++- example/src/layouts/login.py | 4 +++- example/src/main.py | 8 ++------ src/ststeroids/__init__.py | 12 +++++++++++- src/ststeroids/component.py | 6 ++++-- src/ststeroids/dialog.py | 7 ++++--- src/ststeroids/flow.py | 2 +- src/ststeroids/flow_context.py | 6 ++++-- src/ststeroids/fragment.py | 8 +++++--- src/ststeroids/main.py | 2 +- src/ststeroids/router.py | 6 ++---- src/ststeroids/store.py | 2 +- 15 files changed, 44 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 21fe213..f414129 100644 --- a/README.md +++ b/README.md @@ -407,7 +407,7 @@ Partially rewritten the framework to reduce its footprint and make object creati - accessing `.state` is no longer possible, you can directly access the attributes on a component instead - Rename `render` in your components to `display` - Remove any `show` and `hide` methods from your components as well as the `visible` property. They are now controlled by the framework -- Use a component’s `on` method to register flows as event handlers for specific component events, typically when setting up the app. Call trigger on the component to emit events and dispatch the registered flows. +- Use a component’s `on` method to register flows as event handlers for specific component events, typically when setting up the app. Call `trigger` on the component to emit events and dispatch the registered flows. - In Flows use `YourComponent.get(component_id)` instead of `self.component_store.get_component(component_id) - Remove `Router` from your Flows, use `st.switch_page` instead if you didn't already - Move the initialization of the sidebar to layouts instead of the `main` of the app @@ -418,7 +418,7 @@ Partially rewritten the framework to reduce its footprint and make object creati - If you previously implemented your own logic for using the `router` class. Please consider using the new Steroids app style, by doing so you can also utilize - The on app run once event, for initial set up - The router on enter event, for initial route setup. For example refresh data before rendering the page -- There are two new component types, `Fragement` and `Dialog`, they replace the `render_as` parameter. Please update your components and render calls accordingly +- There are two new component types, `Fragment` and `Dialog`, they replace the `render_as` parameter. Please update your components and render calls accordingly 0.1.17 diff --git a/example/src/components/button.py b/example/src/components/button.py index 01e4f98..f385a30 100644 --- a/example/src/components/button.py +++ b/example/src/components/button.py @@ -20,4 +20,3 @@ def on_click(self, flow: Flow) -> None: Register a flow to be executed when the user clicks the button. """ self.on(self.EVENT_ClICK, flow) - diff --git a/example/src/flows/logout.py b/example/src/flows/logout.py index 17eefbc..4e8964a 100644 --- a/example/src/flows/logout.py +++ b/example/src/flows/logout.py @@ -5,5 +5,5 @@ class LogoutFlow(Flow): def __init__(self, session_store: Store): self.session_store = session_store - def run(self, _ctx: FlowContext ): + def run(self, _ctx: FlowContext): self.session_store.del_property("access_token") diff --git a/example/src/layouts/dashboard.py b/example/src/layouts/dashboard.py index 02a8f37..1641e62 100644 --- a/example/src/layouts/dashboard.py +++ b/example/src/layouts/dashboard.py @@ -17,7 +17,9 @@ def __init__(self, refresh_flow: Flow): self.total_movies = MetricComponent.create( ComponentIDs.total_movies, None, "Total movies" ) - self.avg_rating = MetricComponent.create(ComponentIDs.avg_rating, "2s", "Avg. Rating") + self.avg_rating = MetricComponent.create( + ComponentIDs.avg_rating, "2s", "Avg. Rating" + ) self.logout_button = ButtonComponent.create(ComponentIDs.logout, "Logout") def render(self): diff --git a/example/src/layouts/login.py b/example/src/layouts/login.py index 85045e1..02dea24 100644 --- a/example/src/layouts/login.py +++ b/example/src/layouts/login.py @@ -13,7 +13,9 @@ def __init__( self.session_store = session_store self.login_header = login_header self.sidebar = SidebarComponent.create(ComponentIDs.sidebar) - self.login_dialog = LoginDialogComponent.create(ComponentIDs.dialog_login, self.login_header) + self.login_dialog = LoginDialogComponent.create( + ComponentIDs.dialog_login, self.login_header + ) def render(self): self.sidebar.render() diff --git a/example/src/main.py b/example/src/main.py index 73d503d..0321ae0 100644 --- a/example/src/main.py +++ b/example/src/main.py @@ -21,12 +21,8 @@ def __init__(self): app_style = Style("./example/src/assets/style.css") app_style.apply_style() - self.login_layout = LoginLayout.create( - self.session_store, "App login" - ) - self.dashboard_layout = DashboardLayout.create( - self.refresh_flow - ) + self.login_layout = LoginLayout.create(self.session_store, "App login") + self.dashboard_layout = DashboardLayout.create(self.refresh_flow) self.manage_data_layout = ManageDataLayout.create() # register event handlers diff --git a/src/ststeroids/__init__.py b/src/ststeroids/__init__.py index d63fccd..edc8193 100644 --- a/src/ststeroids/__init__.py +++ b/src/ststeroids/__init__.py @@ -8,4 +8,14 @@ from .fragment import Fragment from .dialog import Dialog -__all__ = ["Component", "Layout", "Flow", "Style", "Store", "StSteroids", "FlowContext", "Fragment", "Dialog"] +__all__ = [ + "Component", + "Layout", + "Flow", + "Style", + "Store", + "StSteroids", + "FlowContext", + "Fragment", + "Dialog", +] diff --git a/src/ststeroids/component.py b/src/ststeroids/component.py index f00e30a..5305adf 100644 --- a/src/ststeroids/component.py +++ b/src/ststeroids/component.py @@ -1,4 +1,4 @@ -from typing import Literal, Any +from typing import Any from abc import ABC, abstractmethod import streamlit as st from .store import ComponentStore @@ -111,7 +111,9 @@ def trigger(self, event_name: str) -> None: """ callback = self._events.get(event_name, None) if not callback: - raise RuntimeError(f"{event_name} has not been registered for component with id {self.id}") + raise RuntimeError( + f"{event_name} has not been registered for component with id {self.id}" + ) callback.dispatch(FlowContext("component", self.id)) def render( diff --git a/src/ststeroids/dialog.py b/src/ststeroids/dialog.py index 184584a..b26de35 100644 --- a/src/ststeroids/dialog.py +++ b/src/ststeroids/dialog.py @@ -1,6 +1,7 @@ import streamlit as st from .component import Component + class Dialog(Component): """ Base class for dialog components. @@ -27,7 +28,7 @@ def create(cls, component_id: str, title: str = "title", *args, **kwargs): return instance def render(self) -> None: - """ + """ Renders the component inside a Streamlit dialog context. Calls the `display` method to render the contents. @@ -35,9 +36,9 @@ def render(self) -> None: """ if not self.visible: return - + @st.dialog(self.title) def _dialog(): self.display() - _dialog() \ No newline at end of file + _dialog() diff --git a/src/ststeroids/flow.py b/src/ststeroids/flow.py index 096771b..5716900 100644 --- a/src/ststeroids/flow.py +++ b/src/ststeroids/flow.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from.flow_context import FlowContext +from .flow_context import FlowContext # pylint: disable=too-few-public-methods diff --git a/src/ststeroids/flow_context.py b/src/ststeroids/flow_context.py index 4ea1ec4..043fa45 100644 --- a/src/ststeroids/flow_context.py +++ b/src/ststeroids/flow_context.py @@ -1,13 +1,15 @@ from dataclasses import dataclass + @dataclass class FlowContext: """ Encapsulates the context of why a flow is being executed. - + Attributes: type: The type of the trigger ("component", "route", "app"). identifier: Optional identifier, e.g., component id, route name. """ + type: str - identifier: str = None \ No newline at end of file + identifier: str = None diff --git a/src/ststeroids/fragment.py b/src/ststeroids/fragment.py index a202040..19eb508 100644 --- a/src/ststeroids/fragment.py +++ b/src/ststeroids/fragment.py @@ -2,6 +2,7 @@ from .flow import Flow import streamlit as st + class Fragment(Component): """ Base class for components that render as Streamlit fragments and provide @@ -15,7 +16,9 @@ class Fragment(Component): EVENT_REFRESH = "_refresh" # class-level constant for autocomplete @classmethod - def create(cls, component_id: str, refresh_interval: str | None = None, *args, **kwargs): + def create( + cls, component_id: str, refresh_interval: str | None = None, *args, **kwargs + ): """ Create a new Fragment instance or return it from the store. @@ -47,9 +50,8 @@ def _fragment(): def on_refresh(self, flow: Flow) -> None: """ Register a flow to be executed when the the fragment refreshes - + :param: Flow the flow to dispatch on the refresh event. :return: None """ self.on(self.EVENT_REFRESH, flow) - \ No newline at end of file diff --git a/src/ststeroids/main.py b/src/ststeroids/main.py index 87cc0f2..c513347 100644 --- a/src/ststeroids/main.py +++ b/src/ststeroids/main.py @@ -84,7 +84,7 @@ def run(self, entry_route: str | None = None) -> None: :return: None """ if "_on_app_run_once_done" not in st.session_state and self._on_app_run_once: - self._on_app_run_once.dispatch(FlowContext("app","run_once")) + self._on_app_run_once.dispatch(FlowContext("app", "run_once")) st.session_state["_on_app_run_once_done"] = True routes = {} diff --git a/src/ststeroids/router.py b/src/ststeroids/router.py index 737c101..f50098e 100644 --- a/src/ststeroids/router.py +++ b/src/ststeroids/router.py @@ -65,8 +65,6 @@ def run(self) -> None: ) if route.on_enter: - route.on_enter.dispatch( - FlowContext(source="route", identifier=route.name) - ) + route.on_enter.dispatch(FlowContext(source="route", identifier=route.name)) - route.target.render() \ No newline at end of file + route.target.render() diff --git a/src/ststeroids/store.py b/src/ststeroids/store.py index edf7d51..f974c46 100644 --- a/src/ststeroids/store.py +++ b/src/ststeroids/store.py @@ -7,7 +7,7 @@ class Store: Class for creating a session store. This class manages storing and retrieving properties in Streamlit's session state. - + Attributes: name (str): Unique name of the store. """ From 7cfc0ebaeabaa11cd3c27591b832db13c529816a Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Sat, 7 Feb 2026 10:11:39 +0100 Subject: [PATCH 47/60] minor naming --- README.md | 2 +- src/ststeroids/component.py | 2 +- src/ststeroids/fragment.py | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index f414129..50547db 100644 --- a/README.md +++ b/README.md @@ -239,7 +239,7 @@ Sets the value of a registered element. Registers a flow as an event handler for the given event name on the component. The flow will be dispatched when the event is triggered. -`on_refresh(self, flow: Flow)` +`on_refresh(self, callback: Flow)` Registers a flow as an event handler for the refresh event of a Fragment (Fragment only) diff --git a/src/ststeroids/component.py b/src/ststeroids/component.py index 5305adf..0f4fbe1 100644 --- a/src/ststeroids/component.py +++ b/src/ststeroids/component.py @@ -120,7 +120,7 @@ def render( self, ) -> None: """ - Executes the render method implemented in the subclasses, additionaly providing extra configuration based on the `render_as` parameter + Executes the render method implemented in the subclasses. """ if not self.visible: diff --git a/src/ststeroids/fragment.py b/src/ststeroids/fragment.py index 19eb508..e4a0cc5 100644 --- a/src/ststeroids/fragment.py +++ b/src/ststeroids/fragment.py @@ -47,11 +47,11 @@ def _fragment(): _fragment() - def on_refresh(self, flow: Flow) -> None: + def on_refresh(self, callback: Flow) -> None: """ Register a flow to be executed when the the fragment refreshes - :param: Flow the flow to dispatch on the refresh event. + :param: callback the flow to dispatch on the refresh event. :return: None """ - self.on(self.EVENT_REFRESH, flow) + self.on(self.EVENT_REFRESH, callback) From 1b7df3a5a930492862a3bed546614a19d89539fd Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Sat, 7 Feb 2026 10:27:44 +0100 Subject: [PATCH 48/60] update store tests --- tests/test_store.py | 81 ++++++++++++++++++++++++++++++--------------- 1 file changed, 55 insertions(+), 26 deletions(-) diff --git a/tests/test_store.py b/tests/test_store.py index 1ec138d..8441a28 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -1,43 +1,57 @@ import pytest from unittest.mock import patch + from ststeroids import Store from ststeroids.store import ComponentStore @pytest.fixture def mock_session_state(): - with patch("streamlit.session_state", new={}) as mock_state: - yield mock_state + # Patch Streamlit session_state and return the dict for inspection + with patch("streamlit.session_state", new={}) as state: + yield state + +# ========================= +# Store tests +# ========================= def test_store_initialization(mock_session_state): - Store("test_store") + store = Store.create("test_store") + assert "test_store" in mock_session_state assert mock_session_state["test_store"] == {} + assert store.name == "test_store" def test_store_set_property(mock_session_state): - store = Store("test_store") + store = Store.create("test_store") store.set_property("key", "value") + assert mock_session_state["test_store"]["key"] == "value" def test_store_get_property(mock_session_state): - store = Store("test_store") + store = Store.create("test_store") store.set_property("key", "value") + assert store.get_property("key") == "value" def test_store_del_property(mock_session_state): - store = Store("test_store") + store = Store.create("test_store") store.set_property("key", "value") store.del_property("key") - with pytest.raises(KeyError, match="'key' doesn't"): + + with pytest.raises( + KeyError, match="'key' doesn't exist in store 'test_store'." + ): store.get_property("key") def test_store_get_property_key_error(mock_session_state): - store = Store("test_store") + store = Store.create("test_store") + with pytest.raises( KeyError, match="'missing_key' doesn't exist in store 'test_store'." ): @@ -45,42 +59,57 @@ def test_store_get_property_key_error(mock_session_state): def test_store_has_property(mock_session_state): - store = Store("test_store") + store = Store.create("test_store") store.set_property("key", "value") + assert store.has_property("key") is True assert store.has_property("missing_key") is False -def test_component_store_singleton(): - first_instance = ComponentStore() - second_instance = ComponentStore() - assert first_instance is second_instance - +# ========================= +# ComponentStore tests +# ========================= def test_component_store_initialization(mock_session_state): - ComponentStore() + component_store = ComponentStore.create("components") + assert "components" in mock_session_state assert mock_session_state["components"] == {} + assert component_store.name == "components" def test_component_store_init_component(mock_session_state): class MockComponent: id = "comp1" - component_store = ComponentStore() + component_store = ComponentStore.create("components") component = MockComponent() + component_store.init_component(component) - assert component_store.get_component("comp1") == component + assert component_store.get_component("comp1") is component + + +def test_component_store_does_not_override_existing_component(mock_session_state): + class MockComponent: + id = "comp1" + + component_store = ComponentStore.create("components") -def test_component_store_init_component_state(mock_session_state): - component_store = ComponentStore() - component_store.init_component_state("comp1", {"state_key": "state_value"}) - assert component_store.get_property("comp1", "state_key") == "state_value" + first = MockComponent() + second = MockComponent() + component_store.init_component(first) + component_store.init_component(second) -def test_component_store_set_get_property(mock_session_state): - component_store = ComponentStore() - component_store.init_component_state("comp1", {}) - component_store.set_property("comp1", "prop", "value") - assert component_store.get_property("comp1", "prop") == "value" + # Should keep the first component + assert component_store.get_component("comp1") is first + + +def test_component_store_get_missing_component_raises(mock_session_state): + component_store = ComponentStore.create("components") + + with pytest.raises( + KeyError, match="'missing' doesn't exist in store 'components'." + ): + component_store.get_component("missing") \ No newline at end of file From 086bf9d858a2225f2ee262bfc02edd6526244774 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Mon, 9 Feb 2026 08:27:44 +0100 Subject: [PATCH 49/60] update router tests --- src/ststeroids/router.py | 2 +- tests/test_router.py | 125 ++++++++++++++++++++++----------------- 2 files changed, 72 insertions(+), 55 deletions(-) diff --git a/src/ststeroids/router.py b/src/ststeroids/router.py index f50098e..e50269a 100644 --- a/src/ststeroids/router.py +++ b/src/ststeroids/router.py @@ -65,6 +65,6 @@ def run(self) -> None: ) if route.on_enter: - route.on_enter.dispatch(FlowContext(source="route", identifier=route.name)) + route.on_enter.dispatch(FlowContext("route", route.name)) route.target.render() diff --git a/tests/test_router.py b/tests/test_router.py index 38e7dbe..727f852 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -1,8 +1,9 @@ -from collections import defaultdict import pytest -import streamlit as st from unittest.mock import MagicMock -from ststeroids import Router + +from ststeroids.router import Router +from ststeroids.route import Route +from ststeroids.flow_context import FlowContext @pytest.fixture @@ -10,79 +11,95 @@ def router(): return Router() -@pytest.fixture -def mock_session_state(mocker): - mocker.patch.object(st, "session_state", {}, create=True) +def make_route(name="home", on_enter=None): + target = MagicMock() + target.render = MagicMock() + return Route( + name=name, + target=target, + on_enter=on_enter, + ) -def test_router_initialization(mock_session_state, router): - assert "ststeroids_current_route" in st.session_state - assert st.session_state["ststeroids_current_route"] == "home" +def test_router_initialization(router): + assert router._current is None + assert router._default == "__default__" + assert router._routes == {} -def test_router_initialization_with_custom_default(mock_session_state): - Router(default="dashboard") - assert st.session_state["ststeroids_current_route"] == "dashboard" +def test_router_initialization_with_custom_default(): + router = Router(default="dashboard") + assert router._default == "dashboard" -def test_register_routes(mock_session_state, router): - mock_layout = MagicMock() - routes = {"home": mock_layout, "dashboard": mock_layout} +def test_register_routes(router): + route = make_route("home") + routes = {"home": route} + router.register_routes(routes) - assert router.routes == routes + + assert router._routes == routes -def test_route_changes_current_route(mock_session_state, router): +def test_route_sets_current_route(router): router.route("dashboard") - assert st.session_state["ststeroids_current_route"] == "dashboard" + assert router._current == "dashboard" + +def test_run_calls_current_route(router): + route = make_route("home") -def test_run_calls_current_route(mock_session_state, router): - mock_function = MagicMock() - router.register_routes({"home": mock_function}) + router.register_routes({"home": route}) + router.route("home") router.run() - mock_function.assert_called_once() + route.target.render.assert_called_once() -def test_run_calls_current_route_that_raises_an_exception(mock_session_state, router): - mock_function = MagicMock(side_effect=KeyError("Missing key")) - router.register_routes({"home": mock_function}) - with pytest.raises(KeyError, match="Missing key"): - router.route("home") - router.run() +def test_run_calls_on_enter_if_present(router): + on_enter = MagicMock() + on_enter.dispatch = MagicMock() -def test_run_calls_invalid_current_route(mock_session_state, router): - mock_function = MagicMock() - router.register_routes({"home": mock_function}) - with pytest.raises( - KeyError, match="The current route 'invalid' is not a registered route." - ): - router.route("invalid") - router.run() + route = make_route("home", on_enter=on_enter) + router.register_routes({"home": route}) + router.route("home") + router.run() -def test_run_calls_with_defaultdict(mock_session_state, router): - mock_function = MagicMock() - default_function = MagicMock() + on_enter.dispatch.assert_called_once() + args, kwargs = on_enter.dispatch.call_args + flow_context = args[0] + assert isinstance(flow_context, FlowContext) + assert flow_context.identifier == "home" + assert flow_context.type == "route" - # Use defaultdict to return default_function for any missing keys - router.register_routes( - defaultdict(lambda: default_function, {"home": mock_function}) - ) + route.target.render.assert_called_once() - router.route( - "invalid" - ) # This will now return default_function instead of raising KeyError + +def test_run_falls_back_to_default_route(router): + default_route = make_route("__default__") + + router.register_routes({"__default__": default_route}) router.run() - # Ensure the default function is called - default_function.assert_called_once() - # Ensure the "home" function is not called - mock_function.assert_not_called() + default_route.target.render.assert_called_once() -def test_get_current_route(mock_session_state, router): - assert router.get_current_route() == "home" - router.route("dashboard") - assert router.get_current_route() == "dashboard" +def test_run_raises_if_no_current_and_no_default(router): + router.register_routes({}) + + with pytest.raises( + RuntimeError, + match="No current route selected and no default route registered.", + ): + router.run() + + +def test_run_uses_default_when_current_is_invalid(router): + default_route = make_route("__default__") + + router.register_routes({"__default__": default_route}) + router.route("invalid") + router.run() + + default_route.target.render.assert_called_once() \ No newline at end of file From a7a995c6eae068bbdbe7fd3e76d1b7d6cefa4d06 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Mon, 9 Feb 2026 13:44:06 +0100 Subject: [PATCH 50/60] add and update all tests --- tests/test_component.py | 190 +++++++++++++----------------------- tests/test_dialog.py | 50 ++++++++++ tests/test_flow.py | 35 ++++--- tests/test_fragement.py | 62 ++++++++++++ tests/test_layout.py | 27 +++-- tests/test_main.py | 135 +++++++++++++++++++++++++ tests/test_route.py | 28 ++++++ tests/test_route_builder.py | 55 +++++++++++ 8 files changed, 438 insertions(+), 144 deletions(-) create mode 100644 tests/test_dialog.py create mode 100644 tests/test_fragement.py create mode 100644 tests/test_main.py create mode 100644 tests/test_route.py create mode 100644 tests/test_route_builder.py diff --git a/tests/test_component.py b/tests/test_component.py index 08150c5..9ee520f 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -1,157 +1,103 @@ -from unittest.mock import MagicMock, patch import pytest +from unittest.mock import MagicMock, patch +import streamlit as st + +from ststeroids.component import Component +from ststeroids.flow import Flow from ststeroids.store import ComponentStore -from ststeroids.component import Component, State +from ststeroids.flow_context import FlowContext @pytest.fixture def mock_session_state(): - with patch("streamlit.session_state", new={}) as mock_state: - yield mock_state + with patch("streamlit.session_state", new={}) as state: + yield state -@pytest.fixture(scope="session") +@pytest.fixture def mock_store(): - # Mocking the ComponentStore for testing purposes store = MagicMock(spec=ComponentStore) + store.has_property.return_value = False return store -@pytest.fixture(scope="session") +@pytest.fixture def component(mock_store): - # Creating a sample component for testing - component = Component(component_id="test_component", initial_state={"key": "value"}) - component._Component__store = ( - mock_store # Injecting the mock store into the component - ) - return component - + with patch("ststeroids.store.ComponentStore.create", return_value=mock_store): + class MyComponent(Component): + def display(self): + pass -def test_component_creation_without_id(): - with pytest.raises(KeyError): - component = Component(initial_state={"key": "value"}) + return MyComponent.create("test_component") -def test_component_singleton(): - first_instance = Component( - component_id="test_component", initial_state={"key": "value"} - ) - second_instance = Component( - component_id="test_component", initial_state={"key": "value"} - ) - assert first_instance is second_instance +def test_component_create_returns_same_instance(mock_store): + with patch("ststeroids.store.ComponentStore.create", return_value=mock_store): + class MyComponent(Component): + def display(self): + pass + comp1 = MyComponent.create("comp") + comp2 = MyComponent.create("comp") + # Same store call, simulating singleton + mock_store.has_property.return_value = True + mock_store.get_component.return_value = comp1 + comp3 = MyComponent.create("comp") + assert comp1 is comp3 -def test_subclass_init_runs_only_once(): - calls = {"count": 0} - class Sub(Component): - def __init__(self, value): - calls["count"] += 1 - self.value = value - - obj = Sub(42) - assert obj.value == 42 - assert calls["count"] == 1 # __init__ ran once - - # Call __init__ again explicitly - obj.__init__(99) - assert obj.value == 42 # value didn't change - assert calls["count"] == 1 # __init__ not called again - - -def test_component_initialization(component): - # Test that the component is initialized correctly +def test_component_attributes(component): assert component.id == "test_component" - assert isinstance(component.state, State) - - -def test_state_initialization(mock_store): - # Test that the state is initialized with the component ID and store - state = State( - component_id="test_component", store=mock_store, initial_state={"key": "value"} - ) - mock_store.init_component_state.assert_called_once_with( - "test_component", {"key": "value"} - ) - assert state._State__id == "test_component" - assert state._State__store == mock_store - - -def test_getattr(component): - # Test that attributes are retrieved correctly from the store - assert component.state.key == "value" - - -def test_setattr(component): - # Test that attributes are set correctly in the store - component.state.key = "new_value" - assert component.state.key == "new_value" - - -def test_render_not_implemented(component): - # Test that calling render raises NotImplementedError - with pytest.raises(NotImplementedError): - component.render() - - -def test_register_element(component): - element_name = "button" - expected_key = "test_component_button" - assert component.register_element(element_name) == expected_key - - -def test_get_element_not_set(component): - element_name = "non_existent" - assert component.get_element(element_name) is None - - -def test_get_element_set(component, mock_session_state): - element_name = "input" - key = component.register_element(element_name) - mock_session_state[key] = "Test Value" - assert component.get_element(element_name) == "Test Value" + assert hasattr(component, "_events") + assert component.visible is True -def test_set_element(component, mock_session_state): - element_name = "input" - key = component.register_element(element_name) - mock_session_state[key] = "nothing" - component.set_element(element_name, "something") - assert component.get_element(element_name) == "something" +def test_register_element_returns_key(component): + key = component.register_element("button") + assert key == "test_component_button" -def test__render_fragment_with_flow(component): - mock_flow = MagicMock() - component.render = MagicMock() +def test_get_element_and_set_element(mock_session_state, component): + key = component.register_element("input") + # Initially None + assert component.get_element("input") is None + component.set_element("input", "value") + assert mock_session_state[key] == "value" + assert component.get_element("input") == "value" - component._Component__render_fragment(refresh_flow=mock_flow) - mock_flow.execute_run.assert_called_once() - component.render.assert_called_once() +def test_on_and_trigger_calls_flow(component): + flow = MagicMock(spec=Flow) + component.on("click", flow) + component.trigger("click") + flow.dispatch.assert_called_once() + args, _ = flow.dispatch.call_args + ctx = args[0] + assert isinstance(ctx, FlowContext) + assert ctx.identifier == component.id + assert ctx.type == "component" -def test_execute_render_normal(component): - component.render = MagicMock(return_value="normal_rendered") - result = component.execute_render(render_as="normal") - component.render.assert_called_once() - assert result == "normal_rendered" +def test_trigger_raises_if_event_not_registered(component): + with pytest.raises(RuntimeError, match="has not been registered"): + component.trigger("non_existent") -def test_execute_render_dialog(component): - component._render_dialog = MagicMock(return_value="dialog_rendered") - result = component.execute_render(render_as="dialog", options={"title": "bar"}) - component._render_dialog.assert_called_once_with(title="bar") - assert result == "dialog_rendered" +def test_render_calls_display(component): + component.display = MagicMock() + component.render() + component.display.assert_called_once() -def test_execute_render_fragment(component): - component._render_fragment = MagicMock(return_value="fragment_rendered") - result = component.execute_render(render_as="fragment", options={"x": 1}) - component._render_fragment.assert_called_once_with(x=1) - assert result == "fragment_rendered" +def test_render_skips_if_not_visible(component): + component.display = MagicMock() + component.hide() + component.render() + component.display.assert_not_called() -def test_execute_render_raises_an_error_with_an_invalid_render_as(component): - with pytest.raises(ValueError): - component.execute_render(render_as="something", options={"x": 1}) +def test_show_and_hide(component): + component.hide() + assert component.visible is False + component.show() + assert component.visible is True \ No newline at end of file diff --git a/tests/test_dialog.py b/tests/test_dialog.py new file mode 100644 index 0000000..cabd3ab --- /dev/null +++ b/tests/test_dialog.py @@ -0,0 +1,50 @@ +import pytest +from unittest.mock import MagicMock, patch +import streamlit as st + +from ststeroids.dialog import Dialog + + +@pytest.fixture +def mock_dialog(): + with patch("streamlit.dialog") as mock: + yield mock + + +@pytest.fixture +def dialog_instance(): + class MyDialog(Dialog): + def display(self): + pass + + return MyDialog.create("my_dialog", title="My Title") + + +def test_create_sets_title(dialog_instance): + assert dialog_instance.title == "My Title" + + +def test_render_calls_display_inside_dialog(dialog_instance, mock_dialog): + # Mock display + dialog_instance.display = MagicMock() + + # st.dialog returns a decorator that immediately calls the wrapped function + def fake_decorator(func): + def wrapper(): + func() + return wrapper + + mock_dialog.side_effect = lambda title: fake_decorator + + dialog_instance.render() + dialog_instance.display.assert_called_once() + mock_dialog.assert_called_once_with("My Title") + + +def test_render_skips_if_not_visible(dialog_instance, mock_dialog): + dialog_instance.display = MagicMock() + dialog_instance.hide() + dialog_instance.render() + # display should not be called + dialog_instance.display.assert_not_called() + mock_dialog.assert_not_called() \ No newline at end of file diff --git a/tests/test_flow.py b/tests/test_flow.py index 170df45..f72e883 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -1,24 +1,31 @@ import pytest +from unittest.mock import MagicMock + from ststeroids.flow import Flow -from ststeroids.store import ComponentStore -def test_flow_initializes_component_store(): - flow = Flow() - assert isinstance(flow.component_store, ComponentStore) +def test_flow_cannot_instantiate_directly(): + with pytest.raises(TypeError): + Flow() + +def test_subclass_run_called_by_dispatch(): + class MyFlow(Flow): + def run(self, ctx): + pass -def test_flow_run_raises_not_implemented_error(): - flow = Flow() - with pytest.raises(NotImplementedError): - flow.execute_run() + flow = MyFlow.create() + flow.run = MagicMock() + flow.dispatch(None) + flow.run.assert_called_once_with(None) -def test_subclass_run_called_by__run(): +def test_flow_create_classmethod(): class MyFlow(Flow): - def run(self, x): - return x * 2 + def run(self, ctx): + pass - flow = MyFlow() - result = flow.execute_run(3) - assert result == 6 + flow = MyFlow.create() + assert isinstance(flow, MyFlow) + result = flow.run(None) + assert result is None \ No newline at end of file diff --git a/tests/test_fragement.py b/tests/test_fragement.py new file mode 100644 index 0000000..7269172 --- /dev/null +++ b/tests/test_fragement.py @@ -0,0 +1,62 @@ +import pytest +from unittest.mock import MagicMock, patch +import streamlit as st + +from ststeroids.fragment import Fragment +from ststeroids.flow import Flow +from ststeroids.flow_context import FlowContext + + +@pytest.fixture +def mock_fragment(): + with patch("streamlit.fragment") as mock: + yield mock + + +@pytest.fixture +def fragment_instance(): + class MyFragment(Fragment): + def display(self): + pass + + return MyFragment.create("frag1", refresh_interval="5s") + + +def test_create_sets_refresh_interval(fragment_instance): + assert fragment_instance.refresh_interval == "5s" + + +def test_on_refresh_registers_flow(fragment_instance): + flow = MagicMock(spec=Flow) + fragment_instance.on_refresh(flow) + assert fragment_instance._events[fragment_instance.EVENT_REFRESH] == flow + + +def test_render_calls_display_inside_fragment(fragment_instance, mock_fragment): + # Mock display and trigger + fragment_instance.display = MagicMock() + fragment_instance.trigger = MagicMock() + + # st.fragment returns a decorator that calls the wrapped function immediately + def fake_decorator(func): + def wrapper(): + func() + return wrapper + + mock_fragment.side_effect = lambda run_every=None: fake_decorator + + fragment_instance.render() + + mock_fragment.assert_called_once_with(run_every="5s") + fragment_instance.trigger.assert_called_once_with(fragment_instance.EVENT_REFRESH) + fragment_instance.display.assert_called_once() + + +def test_render_skips_if_not_visible(fragment_instance, mock_fragment): + fragment_instance.display = MagicMock() + fragment_instance.trigger = MagicMock() + fragment_instance.hide() + fragment_instance.render() + fragment_instance.display.assert_not_called() + fragment_instance.trigger.assert_not_called() + mock_fragment.assert_not_called() \ No newline at end of file diff --git a/tests/test_layout.py b/tests/test_layout.py index 94795ab..1af9006 100644 --- a/tests/test_layout.py +++ b/tests/test_layout.py @@ -3,18 +3,29 @@ from unittest.mock import MagicMock -def test_layout_render_raises_not_implemented_error(): - layout = Layout() - with pytest.raises(NotImplementedError): - layout.render() +def test_layout_cannot_instantiate_directly(): + # Abstract classes cannot be instantiated + with pytest.raises(TypeError): + Layout() -def test_subclass_run_called_by__run(): +def test_subclass_render_called(): class MyLayout(Layout): def render(self): - return "" + return "rendered" layout = MyLayout() - layout.render = MagicMock() - layout.execute_render() + layout.render = MagicMock(return_value="rendered") + result = layout.render() layout.render.assert_called_once() + assert result == "rendered" + + +def test_layout_create_classmethod(): + class MyLayout(Layout): + def render(self): + return "ok" + + layout = MyLayout.create() + assert isinstance(layout, MyLayout) + assert layout.render() == "ok" \ No newline at end of file diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..4bf7b49 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,135 @@ +import pytest +from unittest.mock import MagicMock, patch + +from ststeroids import StSteroids +from ststeroids.route import Route +from ststeroids.layout import Layout +from ststeroids.flow import Flow + + +@pytest.fixture +def mock_session_state(): + # Patch Streamlit session_state and return the dict for inspection + with patch("streamlit.session_state", new={}) as state: + yield state + + +@pytest.fixture +def app(): + return StSteroids() + + +def test_route_returns_routebuilder(app): + rb = app.route("home") + from ststeroids.route_builder import RouteBuilder + + assert isinstance(rb, RouteBuilder) + + +def test_default_route_sets_default(app): + class MyLayout(Layout): + def render(self): + pass + + layout = MyLayout() + app.default_route(layout) + + assert app._default.name == "__default__" + assert app._default.target == layout + + +def test_register_adds_route(app): + class MyLayout(Layout): + def render(self): + pass + + layout = MyLayout() + route = Route("home", layout) + + app.register(route) + + assert "home" in app._routes + assert app._routes["home"] == route + + +def test_on_app_run_once_registers_flow(app): + flow = MagicMock(spec=Flow) + + app.on_app_run_once(flow) + assert app._on_app_run_once == flow + + with pytest.raises(RuntimeError): + app.on_app_run_once(flow) + + +def test_run_triggers_on_app_run_once_only_once(app, mock_session_state): + flow = MagicMock(spec=Flow) + app.on_app_run_once(flow) + + app._router.register_routes = MagicMock() + app._router.route = MagicMock() + app._router.run = MagicMock() + + # First run + app.run() + + flow.dispatch.assert_called_once() + assert "_on_app_run_once_done" in mock_session_state + assert mock_session_state["_on_app_run_once_done"] is True + + # Second run + flow.reset_mock() + app.run() + + flow.dispatch.assert_not_called() + + +def test_run_filters_routes_by_condition(app): + class MyLayout(Layout): + def render(self): + pass + + route_true = Route( + "true_route", + MyLayout(), + condition=lambda: True, + ) + + route_false = Route( + "false_route", + MyLayout(), + condition=lambda: False, + ) + + app.register(route_true) + app.register(route_false) + + app._router.register_routes = MagicMock() + app._router.route = MagicMock() + app._router.run = MagicMock() + + app.run() + + routes_passed = app._router.register_routes.call_args[0][0] + + assert "true_route" in routes_passed + assert "false_route" not in routes_passed + + +def test_run_calls_router_methods(app): + class MyLayout(Layout): + def render(self): + pass + + route = Route("home", MyLayout()) + app.register(route) + + app._router.register_routes = MagicMock() + app._router.route = MagicMock() + app._router.run = MagicMock() + + app.run(entry_route="home") + + app._router.register_routes.assert_called_once() + app._router.route.assert_called_once_with("home") + app._router.run.assert_called_once() \ No newline at end of file diff --git a/tests/test_route.py b/tests/test_route.py new file mode 100644 index 0000000..facd774 --- /dev/null +++ b/tests/test_route.py @@ -0,0 +1,28 @@ +import pytest +from ststeroids.route import Route +from ststeroids.layout import Layout +from ststeroids.flow import Flow +from unittest.mock import MagicMock + + +def test_route_initialization_defaults(): + layout = MagicMock(spec=Layout) + route = Route(name="home", target=layout) + + assert route.name == "home" + assert route.target == layout + assert route.on_enter is None + assert route.condition is None + + +def test_route_initialization_with_all_arguments(): + layout = MagicMock(spec=Layout) + flow = MagicMock(spec=Flow) + condition = lambda: True + + route = Route(name="dashboard", target=layout, on_enter=flow, condition=condition) + + assert route.name == "dashboard" + assert route.target == layout + assert route.on_enter == flow + assert route.condition == condition \ No newline at end of file diff --git a/tests/test_route_builder.py b/tests/test_route_builder.py new file mode 100644 index 0000000..1cde7f2 --- /dev/null +++ b/tests/test_route_builder.py @@ -0,0 +1,55 @@ +import pytest +from unittest.mock import MagicMock + +from ststeroids.route_builder import RouteBuilder +from ststeroids.route import Route +from ststeroids.layout import Layout +from ststeroids.flow import Flow + + +def test_to_when_on_enter_chain_returns_self(): + app = MagicMock() + builder = RouteBuilder(app, "home") + + class DummyLayout(Layout): + def render(self): + pass + + flow = MagicMock(spec=Flow) + condition = lambda: True + + # Each method should return self for chaining + assert builder.to(DummyLayout) is builder + assert builder.when(condition) is builder + assert builder.on_enter(flow) is builder + + +def test_register_without_target_raises(): + app = MagicMock() + builder = RouteBuilder(app, "home") + + with pytest.raises(ValueError, match="cannot be registered without a target"): + builder.register() + + +def test_register_calls_app_register_with_route(): + app = MagicMock() + builder = RouteBuilder(app, "home") + + class DummyLayout(Layout): + def render(self): + pass + + flow = MagicMock(spec=Flow) + condition = lambda: True + + builder.to(DummyLayout).when(condition).on_enter(flow).register() + + # Ensure app.register was called once with a Route instance + assert app.register.call_count == 1 + route_arg = app.register.call_args[0][0] + assert isinstance(route_arg, Route) + assert route_arg.name == "home" + assert route_arg.target == DummyLayout + assert route_arg.on_enter == flow + assert route_arg.condition == condition \ No newline at end of file From 3534b76e79301e3d12ae3b9811a26cdba4d6a91c Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Mon, 9 Feb 2026 13:44:34 +0100 Subject: [PATCH 51/60] formatting --- tests/test_component.py | 4 +++- tests/test_dialog.py | 3 ++- tests/test_flow.py | 2 +- tests/test_fragement.py | 3 ++- tests/test_layout.py | 2 +- tests/test_main.py | 2 +- tests/test_route.py | 8 ++++---- tests/test_route_builder.py | 2 +- tests/test_router.py | 2 +- tests/test_store.py | 8 ++++---- 10 files changed, 20 insertions(+), 16 deletions(-) diff --git a/tests/test_component.py b/tests/test_component.py index 9ee520f..f401f91 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -24,6 +24,7 @@ def mock_store(): @pytest.fixture def component(mock_store): with patch("ststeroids.store.ComponentStore.create", return_value=mock_store): + class MyComponent(Component): def display(self): pass @@ -33,6 +34,7 @@ def display(self): def test_component_create_returns_same_instance(mock_store): with patch("ststeroids.store.ComponentStore.create", return_value=mock_store): + class MyComponent(Component): def display(self): pass @@ -100,4 +102,4 @@ def test_show_and_hide(component): component.hide() assert component.visible is False component.show() - assert component.visible is True \ No newline at end of file + assert component.visible is True diff --git a/tests/test_dialog.py b/tests/test_dialog.py index cabd3ab..5dada04 100644 --- a/tests/test_dialog.py +++ b/tests/test_dialog.py @@ -32,6 +32,7 @@ def test_render_calls_display_inside_dialog(dialog_instance, mock_dialog): def fake_decorator(func): def wrapper(): func() + return wrapper mock_dialog.side_effect = lambda title: fake_decorator @@ -47,4 +48,4 @@ def test_render_skips_if_not_visible(dialog_instance, mock_dialog): dialog_instance.render() # display should not be called dialog_instance.display.assert_not_called() - mock_dialog.assert_not_called() \ No newline at end of file + mock_dialog.assert_not_called() diff --git a/tests/test_flow.py b/tests/test_flow.py index f72e883..af252cf 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -28,4 +28,4 @@ def run(self, ctx): flow = MyFlow.create() assert isinstance(flow, MyFlow) result = flow.run(None) - assert result is None \ No newline at end of file + assert result is None diff --git a/tests/test_fragement.py b/tests/test_fragement.py index 7269172..0236b17 100644 --- a/tests/test_fragement.py +++ b/tests/test_fragement.py @@ -41,6 +41,7 @@ def test_render_calls_display_inside_fragment(fragment_instance, mock_fragment): def fake_decorator(func): def wrapper(): func() + return wrapper mock_fragment.side_effect = lambda run_every=None: fake_decorator @@ -59,4 +60,4 @@ def test_render_skips_if_not_visible(fragment_instance, mock_fragment): fragment_instance.render() fragment_instance.display.assert_not_called() fragment_instance.trigger.assert_not_called() - mock_fragment.assert_not_called() \ No newline at end of file + mock_fragment.assert_not_called() diff --git a/tests/test_layout.py b/tests/test_layout.py index 1af9006..b73143e 100644 --- a/tests/test_layout.py +++ b/tests/test_layout.py @@ -28,4 +28,4 @@ def render(self): layout = MyLayout.create() assert isinstance(layout, MyLayout) - assert layout.render() == "ok" \ No newline at end of file + assert layout.render() == "ok" diff --git a/tests/test_main.py b/tests/test_main.py index 4bf7b49..d20209e 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -132,4 +132,4 @@ def render(self): app._router.register_routes.assert_called_once() app._router.route.assert_called_once_with("home") - app._router.run.assert_called_once() \ No newline at end of file + app._router.run.assert_called_once() diff --git a/tests/test_route.py b/tests/test_route.py index facd774..1b37d5a 100644 --- a/tests/test_route.py +++ b/tests/test_route.py @@ -8,7 +8,7 @@ def test_route_initialization_defaults(): layout = MagicMock(spec=Layout) route = Route(name="home", target=layout) - + assert route.name == "home" assert route.target == layout assert route.on_enter is None @@ -19,10 +19,10 @@ def test_route_initialization_with_all_arguments(): layout = MagicMock(spec=Layout) flow = MagicMock(spec=Flow) condition = lambda: True - + route = Route(name="dashboard", target=layout, on_enter=flow, condition=condition) - + assert route.name == "dashboard" assert route.target == layout assert route.on_enter == flow - assert route.condition == condition \ No newline at end of file + assert route.condition == condition diff --git a/tests/test_route_builder.py b/tests/test_route_builder.py index 1cde7f2..c94b3ef 100644 --- a/tests/test_route_builder.py +++ b/tests/test_route_builder.py @@ -52,4 +52,4 @@ def render(self): assert route_arg.name == "home" assert route_arg.target == DummyLayout assert route_arg.on_enter == flow - assert route_arg.condition == condition \ No newline at end of file + assert route_arg.condition == condition diff --git a/tests/test_router.py b/tests/test_router.py index 727f852..f133b0a 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -102,4 +102,4 @@ def test_run_uses_default_when_current_is_invalid(router): router.route("invalid") router.run() - default_route.target.render.assert_called_once() \ No newline at end of file + default_route.target.render.assert_called_once() diff --git a/tests/test_store.py b/tests/test_store.py index 8441a28..8fd0ddf 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -16,6 +16,7 @@ def mock_session_state(): # Store tests # ========================= + def test_store_initialization(mock_session_state): store = Store.create("test_store") @@ -43,9 +44,7 @@ def test_store_del_property(mock_session_state): store.set_property("key", "value") store.del_property("key") - with pytest.raises( - KeyError, match="'key' doesn't exist in store 'test_store'." - ): + with pytest.raises(KeyError, match="'key' doesn't exist in store 'test_store'."): store.get_property("key") @@ -70,6 +69,7 @@ def test_store_has_property(mock_session_state): # ComponentStore tests # ========================= + def test_component_store_initialization(mock_session_state): component_store = ComponentStore.create("components") @@ -112,4 +112,4 @@ def test_component_store_get_missing_component_raises(mock_session_state): with pytest.raises( KeyError, match="'missing' doesn't exist in store 'components'." ): - component_store.get_component("missing") \ No newline at end of file + component_store.get_component("missing") From 2460e7f4f82f90bf8dbb8be86b3834457c1aa708 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Mon, 9 Feb 2026 13:50:19 +0100 Subject: [PATCH 52/60] more formatting --- tests/test_component.py | 3 +-- tests/test_dialog.py | 1 - tests/test_fragement.py | 2 -- tests/test_route.py | 1 - 4 files changed, 1 insertion(+), 6 deletions(-) diff --git a/tests/test_component.py b/tests/test_component.py index f401f91..4d64af4 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -1,6 +1,5 @@ import pytest from unittest.mock import MagicMock, patch -import streamlit as st from ststeroids.component import Component from ststeroids.flow import Flow @@ -40,7 +39,7 @@ def display(self): pass comp1 = MyComponent.create("comp") - comp2 = MyComponent.create("comp") + MyComponent.create("comp") # Same store call, simulating singleton mock_store.has_property.return_value = True mock_store.get_component.return_value = comp1 diff --git a/tests/test_dialog.py b/tests/test_dialog.py index 5dada04..1269885 100644 --- a/tests/test_dialog.py +++ b/tests/test_dialog.py @@ -1,6 +1,5 @@ import pytest from unittest.mock import MagicMock, patch -import streamlit as st from ststeroids.dialog import Dialog diff --git a/tests/test_fragement.py b/tests/test_fragement.py index 0236b17..0a015c9 100644 --- a/tests/test_fragement.py +++ b/tests/test_fragement.py @@ -1,10 +1,8 @@ import pytest from unittest.mock import MagicMock, patch -import streamlit as st from ststeroids.fragment import Fragment from ststeroids.flow import Flow -from ststeroids.flow_context import FlowContext @pytest.fixture diff --git a/tests/test_route.py b/tests/test_route.py index 1b37d5a..dc47caa 100644 --- a/tests/test_route.py +++ b/tests/test_route.py @@ -1,4 +1,3 @@ -import pytest from ststeroids.route import Route from ststeroids.layout import Layout from ststeroids.flow import Flow From b547c39b40b93fb8a435e5a2795878b29ff37834 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Mon, 9 Feb 2026 13:50:32 +0100 Subject: [PATCH 53/60] missed a file --- .flake8 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.flake8 b/.flake8 index 715f6c8..4ee027a 100644 --- a/.flake8 +++ b/.flake8 @@ -1,4 +1,4 @@ [flake8] -extend-ignore = E203, E501 +extend-ignore = E203, E501, E731 exclude = .github,__pycache__,docs/source/conf.py,old,build,dist,venv, max-complexity = 10 \ No newline at end of file From ce7a54b7fde981f6bbefa244f636934b55f64394 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Mon, 9 Feb 2026 16:45:10 +0100 Subject: [PATCH 54/60] fix minor bug and update test --- pyproject.toml | 2 +- src/ststeroids/dialog.py | 3 ++- src/ststeroids/fragment.py | 3 ++- tests/test_dialog.py | 13 +++++++++---- tests/test_fragement.py | 11 +++++++---- 5 files changed, 21 insertions(+), 11 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 469c992..aa08566 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ststeroids" -version = "1.0.0rc1" +version = "1.0.0rc3" description = "A framework supercharging Streamlit for building advanced multi-page applications" readme = "README.md" authors = [{ name = "ponsoc"}] diff --git a/src/ststeroids/dialog.py b/src/ststeroids/dialog.py index b26de35..e6fecab 100644 --- a/src/ststeroids/dialog.py +++ b/src/ststeroids/dialog.py @@ -24,7 +24,8 @@ def create(cls, component_id: str, title: str = "title", *args, **kwargs): :param title: Dialog title. """ instance = super().create(component_id, *args, **kwargs) - instance.title = title + if not hasattr(instance, "title"): + instance.title = title return instance def render(self) -> None: diff --git a/src/ststeroids/fragment.py b/src/ststeroids/fragment.py index e4a0cc5..acd9fd2 100644 --- a/src/ststeroids/fragment.py +++ b/src/ststeroids/fragment.py @@ -26,7 +26,8 @@ def create( :param refresh_interval: The interval for the on_refresh event. """ instance = super().create(component_id, *args, **kwargs) - instance.refresh_interval = refresh_interval + if not hasattr(instance, "refresh_interval"): + instance.refresh_interval = refresh_interval return instance def render(self) -> None: diff --git a/tests/test_dialog.py b/tests/test_dialog.py index 1269885..8a0a21b 100644 --- a/tests/test_dialog.py +++ b/tests/test_dialog.py @@ -4,6 +4,11 @@ from ststeroids.dialog import Dialog +class MyDialog(Dialog): + def display(self): + pass + + @pytest.fixture def mock_dialog(): with patch("streamlit.dialog") as mock: @@ -12,10 +17,6 @@ def mock_dialog(): @pytest.fixture def dialog_instance(): - class MyDialog(Dialog): - def display(self): - pass - return MyDialog.create("my_dialog", title="My Title") @@ -23,6 +24,10 @@ def test_create_sets_title(dialog_instance): assert dialog_instance.title == "My Title" +def test_get_does_not_set_title(dialog_instance): + MyDialog.get("my_dialog") + assert dialog_instance.title == "My Title" + def test_render_calls_display_inside_dialog(dialog_instance, mock_dialog): # Mock display dialog_instance.display = MagicMock() diff --git a/tests/test_fragement.py b/tests/test_fragement.py index 0a015c9..e7dcff1 100644 --- a/tests/test_fragement.py +++ b/tests/test_fragement.py @@ -4,6 +4,9 @@ from ststeroids.fragment import Fragment from ststeroids.flow import Flow +class MyFragment(Fragment): + def display(self): + pass @pytest.fixture def mock_fragment(): @@ -13,16 +16,16 @@ def mock_fragment(): @pytest.fixture def fragment_instance(): - class MyFragment(Fragment): - def display(self): - pass - return MyFragment.create("frag1", refresh_interval="5s") def test_create_sets_refresh_interval(fragment_instance): assert fragment_instance.refresh_interval == "5s" +def test_get_does_not_set_refresh_interval(fragment_instance): + MyFragment.get("my_dialog") + assert fragment_instance.refresh_interval == "5s" + def test_on_refresh_registers_flow(fragment_instance): flow = MagicMock(spec=Flow) From 922f68819a3d7d4c41051e2d92ce3d68c270d7a6 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:07:21 +0100 Subject: [PATCH 55/60] fix order --- README.md | 2 +- src/ststeroids/main.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 50547db..3ee8964 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ The full route builder API is as follows. `app.on_app_run_once(flow)` -Registers an `on_app_run_once` event handler flow. You can use this to have an initial flow that runs once at the start of the application. Note! that an `on_app_run_once` event flow should not switch page as it will break the app run concept +Registers an `on_app_run_once` event handler flow. You can use this to have an initial flow that runs once at the start of the application. Note! that an `on_app_run_once`. `app.default_route(layout)` diff --git a/src/ststeroids/main.py b/src/ststeroids/main.py index c513347..a8c9a33 100644 --- a/src/ststeroids/main.py +++ b/src/ststeroids/main.py @@ -84,8 +84,8 @@ def run(self, entry_route: str | None = None) -> None: :return: None """ if "_on_app_run_once_done" not in st.session_state and self._on_app_run_once: - self._on_app_run_once.dispatch(FlowContext("app", "run_once")) st.session_state["_on_app_run_once_done"] = True + self._on_app_run_once.dispatch(FlowContext("app", "run_once")) routes = {} From 872ca97cc44f4586d2b3af29cf4dc92b84401f61 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Fri, 13 Feb 2026 15:46:52 +0100 Subject: [PATCH 56/60] schedule and rerun --- src/ststeroids/flow_context.py | 21 ++++++++++++++++++--- src/ststeroids/main.py | 6 ++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/ststeroids/flow_context.py b/src/ststeroids/flow_context.py index 043fa45..32d5c28 100644 --- a/src/ststeroids/flow_context.py +++ b/src/ststeroids/flow_context.py @@ -1,7 +1,5 @@ -from dataclasses import dataclass +import streamlit as st - -@dataclass class FlowContext: """ Encapsulates the context of why a flow is being executed. @@ -13,3 +11,20 @@ class FlowContext: type: str identifier: str = None + + def __init__(self, type: str, identifier: str): + self.type = type + self.identifier = identifier + + def experimental_schedule_and_rerun(self, fn, *args, **kwargs): + self.experimental_schedule(fn, *args, **kwargs) + st.rerun() + + def experimental_schedule(self, fn, *args, **kwargs): + st.session_state["_schedule_rerun"] = { + "fn": fn, + "args": args, + "kwargs": kwargs, + "type": self.type, + "identifier": self.identifier, + } \ No newline at end of file diff --git a/src/ststeroids/main.py b/src/ststeroids/main.py index a8c9a33..363004e 100644 --- a/src/ststeroids/main.py +++ b/src/ststeroids/main.py @@ -105,3 +105,9 @@ def run(self, entry_route: str | None = None) -> None: self._router.route(entry_route) self._router.run() + + # experimental experimental_schedule_and_rerun + schedule_and_rerun = st.session_state.pop("_schedule_rerun", None) + if schedule_and_rerun: + schedule_and_rerun["fn"](*schedule_and_rerun["args"], **schedule_and_rerun["kwargs"]) + st.rerun() From 51034f2cabd4ae9bb3ee445a3e3ddff76573e39f Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Fri, 6 Mar 2026 08:16:12 +0100 Subject: [PATCH 57/60] Better way of working with the events --- README.md | 11 ++--------- pyproject.toml | 2 +- src/ststeroids/component.py | 8 ++------ 3 files changed, 5 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 3ee8964..334cf74 100644 --- a/README.md +++ b/README.md @@ -245,7 +245,7 @@ Registers a flow as an event handler for the refresh event of a Fragment (Fragme `trigger(event_name: str)` -Triggers the specified event and dispatches the flow registered for it. Raises an error if no flow has been registered for that event. +Triggers the specified event and dispatches the flow registered for it. #### Flows @@ -455,11 +455,4 @@ Considered first stable release. < 0.1.11 -Beta releases - -## Ideas - -- Improve event examples in the example app -- Move logout to sidebar in the example app and show another example for a separate button -- Something for RBAC -- Something for running longtime requests \ No newline at end of file +Beta releases \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index aa08566..03ad6a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ststeroids" -version = "1.0.0rc3" +version = "1.0.0rc5" description = "A framework supercharging Streamlit for building advanced multi-page applications" readme = "README.md" authors = [{ name = "ponsoc"}] diff --git a/src/ststeroids/component.py b/src/ststeroids/component.py index 0f4fbe1..2a4ce23 100644 --- a/src/ststeroids/component.py +++ b/src/ststeroids/component.py @@ -106,15 +106,11 @@ def trigger(self, event_name: str) -> None: Trigger a previously registered event callback. :param event_name: The name of the event to trigger. - :raises RuntimeError: If no callback has been registered for this event. :return: None """ callback = self._events.get(event_name, None) - if not callback: - raise RuntimeError( - f"{event_name} has not been registered for component with id {self.id}" - ) - callback.dispatch(FlowContext("component", self.id)) + if callback: + callback.dispatch(FlowContext("component", self.id)) def render( self, From 2699b224b8d15ba463304c31843a21a5f76726e8 Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Fri, 6 Mar 2026 09:10:51 +0100 Subject: [PATCH 58/60] Update test_component.py --- tests/test_component.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/test_component.py b/tests/test_component.py index 4d64af4..28113ef 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -77,12 +77,11 @@ def test_on_and_trigger_calls_flow(component): assert isinstance(ctx, FlowContext) assert ctx.identifier == component.id assert ctx.type == "component" - - -def test_trigger_raises_if_event_not_registered(component): - with pytest.raises(RuntimeError, match="has not been registered"): - component.trigger("non_existent") - + +def test_on_and_trigger_does_not_call_flow_when_not_registered(component): + flow = MagicMock(spec=Flow) + component.trigger("click") + flow.dispatch.assert_not_called() def test_render_calls_display(component): component.display = MagicMock() From 6f7bd9d8e6da2072f2444d7953500ef16618b2ae Mon Sep 17 00:00:00 2001 From: ponsoc <36486184+ponsoc@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:04:26 +0100 Subject: [PATCH 59/60] Improved tests and example app --- README.md | 47 ++++++++- example/src/assets/style.css | 46 ++++++++- example/src/components/__init__.py | 2 + example/src/components/status.py | 32 ++++++ example/src/flows/__init__.py | 3 +- example/src/flows/long_running.py | 18 ++++ example/src/layouts/dashboard.py | 5 + example/src/main.py | 12 +-- example/src/shared.py | 2 + pyproject.toml | 2 +- src/ststeroids/flow_context.py | 6 +- src/ststeroids/main.py | 66 ++++++------ tests/test_component.py | 6 +- tests/test_fragement.py | 2 +- tests/test_main.py | 157 +++++++++++++++++++---------- tests/test_route.py | 6 +- tests/test_route_builder.py | 4 +- 17 files changed, 302 insertions(+), 114 deletions(-) create mode 100644 example/src/components/status.py create mode 100644 example/src/flows/long_running.py diff --git a/README.md b/README.md index 334cf74..375b2aa 100644 --- a/README.md +++ b/README.md @@ -288,7 +288,7 @@ Then, create a dedicated flow for each document action: ```python class AddDocumentFlow(DocumentActionBaseFlow): def run(self, ctx: FlowContext): - # Flow logic for adding a document + # Flow logic for adding a documentd ``` In this example, AddDocumentFlow represents a single user action, while DocumentActionBaseFlow provides shared orchestration context. @@ -296,9 +296,44 @@ This keeps flows focused, avoids duplication, and clearly separates reusable set ##### API Reference -`run()` +`run(ctx: FlowContext)` + +This method must be implemented by subclasses. It contains the logic that should run when the flow is triggered. + +To execute a flow, register it with an event handler. When the event occurs, the framework calls `run()`. + +The `FlowContext` object provides information about the event that triggered the flow and utilities for scheduling follow-up actions. + +**Attributes** + +`identifier` + Identifier of the event that triggered the flow. + +`type` + Type of event that triggered the flow. + +**Methods** + +`schedule(function_to_schedule, args=None, kwargs=None)` +Schedules a function to run **after the next rerun**. +This is typically used when component state must be updated before executing additional logic. -This method needs to be implemented by the subclass. To call it, use `dispatch()` +`schedule_and_rerun(function_to_schedule, args=None, kwargs=None)` +Schedules a function to run **after the next rerun** and immediately triggers a rerun. Use schedule in combination with user interactions to avoid the `calling st.rerun() within a callback is a no-op` warning. + +```python +class ApproveLabelsFlow(Flow): + + def run(self, ctx: FlowContext): + # mark selected rows as approved + self.table.update_rows(approved=True) + + # perform backend call after rerun + ctx.schedule_and_rerun(self.store_labels) + + def store_labels(self): + self.backend.store(self.table.selected_rows) +``` `dispatch()` @@ -455,4 +490,8 @@ Considered first stable release. < 0.1.11 -Beta releases \ No newline at end of file +Beta releases + +### Todo + +* the default route can only be one of the registered routes \ No newline at end of file diff --git a/example/src/assets/style.css b/example/src/assets/style.css index bdf13fa..6b79dbf 100644 --- a/example/src/assets/style.css +++ b/example/src/assets/style.css @@ -1,3 +1,47 @@ html, body, * { font-style: italic -} \ No newline at end of file +} + +.status-icon { + width: 20px; + height: 20px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: bold; + color: white; +} + +/* RUNNING (spinner) */ +.running { + border: 3px solid rgba(0,0,0,0.1); + border-left-color: #4CAF50; + animation: spin 1s linear infinite; +} + +/* STATIC STATES */ +.success { + background-color: #4CAF50; +} + +.error { + background-color: #F44336; +} + +.info { + background-color: #2196F3; +} + +/* Spin animation */ +@keyframes spin { + 0% { transform: rotate(0deg);} + 100% { transform: rotate(360deg);} +} + +.status-row { + display: flex; + align-items: center; + gap: 10px; +} diff --git a/example/src/components/__init__.py b/example/src/components/__init__.py index 0e4f778..f7923f7 100644 --- a/example/src/components/__init__.py +++ b/example/src/components/__init__.py @@ -4,6 +4,7 @@ from .metric import MetricComponent from .toast import ToastComponent from .button import ButtonComponent +from .status import StatusComponent __all__ = [ LoginDialogComponent, @@ -12,4 +13,5 @@ MetricComponent, ToastComponent, ButtonComponent, + StatusComponent ] diff --git a/example/src/components/status.py b/example/src/components/status.py new file mode 100644 index 0000000..2e32703 --- /dev/null +++ b/example/src/components/status.py @@ -0,0 +1,32 @@ +from typing import Literal + +import streamlit as st +from ststeroids import Component + + +class StatusComponent(Component): + + def __init__( + self, + message: str = None, + type: Literal["running", "info", "error", "success"] = "info", + ): + self.message = message + self.type = type + + def display(self): + if self.message: + st.markdown(f"