From e5a72a6a0974cb643e611e724aa425df2ba77e81 Mon Sep 17 00:00:00 2001 From: Maxime PERALTA Date: Fri, 20 Feb 2026 17:42:36 +0100 Subject: [PATCH 1/4] (feature) Panel classes can be decleared in external scripts --- apparun/gui/modules.py | 44 ++++++++---- apparun/gui/panels/base.py | 62 +++++++++++++++- apparun/gui/panels/output_dynamic.py | 8 ++- apparun/gui/panels/output_static.py | 3 +- samples/conf/custom_gui.yaml | 20 ++++++ samples/conf/parameters_sets/scenario_A.yaml | 3 + samples/conf/parameters_sets/scenario_B.yaml | 3 + samples/scripts/custom_gui.py | 74 ++++++++++++++++++++ 8 files changed, 200 insertions(+), 17 deletions(-) create mode 100644 samples/conf/custom_gui.yaml create mode 100644 samples/conf/parameters_sets/scenario_A.yaml create mode 100644 samples/conf/parameters_sets/scenario_B.yaml create mode 100644 samples/scripts/custom_gui.py diff --git a/apparun/gui/modules.py b/apparun/gui/modules.py index 74f1130..cd21706 100644 --- a/apparun/gui/modules.py +++ b/apparun/gui/modules.py @@ -1,15 +1,21 @@ from __future__ import annotations import subprocess -from typing import Annotated, Callable, List, Optional, TypeVar, Union +from typing import Annotated, Any, Callable, Dict, List, Optional, TypeVar, Union import pandas as pd import streamlit as st -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator -from apparun.gui.panels.base import InputScenarioFormPanel -from apparun.gui.panels.output_dynamic import ScenarioComparisonDynamicOutputPanel -from apparun.gui.panels.output_static import Markdown +from apparun.gui.panels.base import * +from apparun.gui.panels.base import ( + InputPanel, + OutputPanel, + get_input_panel, + get_output_panel, +) +from apparun.gui.panels.output_dynamic import * +from apparun.gui.panels.output_static import * from apparun.impact_model import ImpactModel PandasDataFrame = TypeVar("pd.core.frame.DataFrame") @@ -19,19 +25,29 @@ class Module(BaseModel): name: Optional[str] = None lca_data_path: Optional[str] = None impact_model_path: Optional[str] = None - input_panel: Optional[ - Annotated[InputScenarioFormPanel, Field(discriminator="type")] - ] = None - output_panels: List[ - Annotated[ - Union[ScenarioComparisonDynamicOutputPanel, Markdown], - Field(discriminator="type"), - ] - ] + input_panel: Optional[InputPanel] = None + output_panels: List[OutputPanel] cols: Optional[Callable] = None lca_data: Optional[PandasDataFrame] = None impact_model: Optional[ImpactModel] = None + @field_validator("input_panel", mode="before") + @classmethod + def build_input_panel( + cls, panel: Optional[Dict[Any]] = None + ) -> Optional[InputPanel]: + if panel is None: + return None + return get_input_panel(panel["type"])(**panel) + + @field_validator("output_panels", mode="before") + @classmethod + def build_output_panels(cls, panels: List[Dict[Any]]) -> List[OutputPanel]: + return [ + get_output_panel(output_panel["type"])(**output_panel) + for output_panel in panels + ] + def __init__(self, **args): super().__init__(**args) self.lca_data = ( diff --git a/apparun/gui/panels/base.py b/apparun/gui/panels/base.py index 9fe7627..e101b0a 100644 --- a/apparun/gui/panels/base.py +++ b/apparun/gui/panels/base.py @@ -11,6 +11,65 @@ ACTION_ADD = "add" ACTION_CLEAR = "clear" +INPUT_PANELS = {} +OUTPUT_PANELS = {} + + +def register_panel(panel_type: str): + """ + This decorator registers a new Panel class in PANELS registry. + :param panel_type: new Panel's type name + :return: new Panel class + """ + + def decorator(decorated_class): + if issubclass(decorated_class, OutputPanel): + if panel_type not in OUTPUT_PANELS: + OUTPUT_PANELS[panel_type] = decorated_class + return decorated_class + if issubclass(decorated_class, InputPanel): + if panel_type not in INPUT_PANELS: + INPUT_PANELS[panel_type] = decorated_class + return decorated_class + msg = f"Custom panel {panel_type} must inherit from InputPanel or OutputPanel." + raise TypeError(msg) + + return decorator + + +def get_input_panel(panel_type: str): + """ + Get a registered InputPanel class by type. + :param panel_type: type of the desired input panel. + :return: registered InputPanel class corresponding to the type. + """ + return INPUT_PANELS[panel_type] + + +def get_output_panel(panel_type: str): + """ + Get a registered OutputPanel class by type. + :param panel_type: type of the desired output panel. + :return: registered OutputPanel class corresponding to the type. + """ + return OUTPUT_PANELS[panel_type] + + +def registered_input_panels() -> List[str]: + """ + Get a list of registered InputPanel types. + :return: list of registered InputPanel types. + """ + return list(INPUT_PANELS.keys()) + + +def registered_output_panels() -> List[str]: + """ + Get a list of registered OutputPanel types. + :return: list of registered OutputPanel types. + """ + return list(OUTPUT_PANELS.keys()) + class Panel(BaseModel): _state: Dict[Any, Any] = {} @@ -76,8 +135,9 @@ def submit(self): return +@register_panel("input_scenario_form_panel") class InputScenarioFormPanel(InputPanel): - fields: List[Dict[str, Any]] + fields: Optional[List[Dict[str, Any]]] = [] type: Literal["input_scenario_form_panel"] def __init__(self, **args): diff --git a/apparun/gui/panels/output_dynamic.py b/apparun/gui/panels/output_dynamic.py index 1bc1428..339db36 100644 --- a/apparun/gui/panels/output_dynamic.py +++ b/apparun/gui/panels/output_dynamic.py @@ -3,11 +3,17 @@ import pandas as pd import streamlit as st -from apparun.gui.panels.base import ACTION_ADD, ACTION_CLEAR, DynamicOutputPanel +from apparun.gui.panels.base import ( + ACTION_ADD, + ACTION_CLEAR, + DynamicOutputPanel, + register_panel, +) from apparun.impact_model import ImpactModel from apparun.results import ImpactModelResult, ScenarioComparisonResult +@register_panel("scenario_comparison_dynamic_output_panel") class ScenarioComparisonDynamicOutputPanel(DynamicOutputPanel): type: Literal["scenario_comparison_dynamic_output_panel"] result: Optional[ImpactModelResult] = None diff --git a/apparun/gui/panels/output_static.py b/apparun/gui/panels/output_static.py index 212a181..1c7d2c0 100644 --- a/apparun/gui/panels/output_static.py +++ b/apparun/gui/panels/output_static.py @@ -2,10 +2,11 @@ import streamlit as st -from apparun.gui.panels.base import StaticOutputPanel +from apparun.gui.panels.base import StaticOutputPanel, register_panel from apparun.results import ImpactModelResult +@register_panel("markdown") class Markdown(StaticOutputPanel): type: Literal["markdown"] message: str diff --git a/samples/conf/custom_gui.yaml b/samples/conf/custom_gui.yaml new file mode 100644 index 0000000..a4e3226 --- /dev/null +++ b/samples/conf/custom_gui.yaml @@ -0,0 +1,20 @@ +name: "GUI with custom panels" +modules: + - name: "Rude markdown" + output_panels: + - type: markdown + message: "This is using a panel built in Appa Run." + - name: "Polite markdown" + output_panels: + - type: polite_markdown + message: "This is using a panel built in an external script." + - name: "Scenario comparison by phase" + impact_model_path: "samples/impact_models/nvidia_ai_gpu_chip.yaml" + input_panel: + type: input_scenario_from_file_panel + name: "GPU parameters" + output_panels: + - type: batch_scenario_comparison_dynamic_output_panel + y: EFV3_CLIMATE_CHANGE + hue: phase + by_property: "phase" \ No newline at end of file diff --git a/samples/conf/parameters_sets/scenario_A.yaml b/samples/conf/parameters_sets/scenario_A.yaml new file mode 100644 index 0000000..23dab9d --- /dev/null +++ b/samples/conf/parameters_sets/scenario_A.yaml @@ -0,0 +1,3 @@ +lifespan: 3 +architecture: Maxwell +cuda_core: 2048 \ No newline at end of file diff --git a/samples/conf/parameters_sets/scenario_B.yaml b/samples/conf/parameters_sets/scenario_B.yaml new file mode 100644 index 0000000..3c90075 --- /dev/null +++ b/samples/conf/parameters_sets/scenario_B.yaml @@ -0,0 +1,3 @@ +lifespan: 3 +architecture: Pascal +cuda_core: 1024 \ No newline at end of file diff --git a/samples/scripts/custom_gui.py b/samples/scripts/custom_gui.py new file mode 100644 index 0000000..a0afbc6 --- /dev/null +++ b/samples/scripts/custom_gui.py @@ -0,0 +1,74 @@ +from typing import Literal + +import pandas as pd +import streamlit as st +import yaml + +from apparun.cli.main import generate_gui +from apparun.gui.panels.base import ( + ACTION_ADD, + InputScenarioFormPanel, + StaticOutputPanel, + register_panel, +) +from apparun.gui.panels.output_dynamic import ScenarioComparisonDynamicOutputPanel +from apparun.impact_model import ImpactModel + + +@register_panel("polite_markdown") +class PoliteMarkdown(StaticOutputPanel): + type: Literal["polite_markdown"] + message: str + + def run(self): + st.markdown(f"{self.message}\n\nKind regards.") + + +@register_panel("batch_scenario_comparison_dynamic_output_panel") +class BatchScenarioComparisonDynamicOutputPanel(ScenarioComparisonDynamicOutputPanel): + type: Literal["batch_scenario_comparison_dynamic_output_panel"] + + def __init__(self, **args): + super().__init__(**args) + self._state["scenarios"] = {} + + def run( + self, + entry_data, + impact_model: ImpactModel = None, + lca_data: pd.DataFrame = None, + ): + if entry_data["action"] == ACTION_ADD: + scenario_scores = self.get_results( + entry_data["parameters"], impact_model, lca_data + ) + fig = self.result.get_figure(scenario_scores) + st.plotly_chart(fig) + + +@register_panel("input_scenario_from_file_panel") +class ScenarioFromFilePanel(InputScenarioFormPanel): + type: Literal["input_scenario_from_file_panel"] + + def run(self): + self.st_component = st.form(self._uuid) + + if self.name is not None: + self.st_component.markdown(f"### {self.name}") + + uploaded_files = self.st_component.file_uploader( + "Upload parameters", accept_multiple_files="directory", type="yaml" + ) + for uploaded_file in uploaded_files: + scenario_name = uploaded_file.name.split("/")[-1].split(".")[:-1][0] + self._state["parameters"][scenario_name] = yaml.safe_load( + uploaded_file.getvalue() + ) + + scenarios_add = self.st_component.form_submit_button("Compute") + + if scenarios_add and len(uploaded_files) > 0: + self._state["action"] = ACTION_ADD + + +generate_gui("samples/conf/custom_gui.yaml") From e271bcc6e9e51f66ea391dfc60201f60e31028fa Mon Sep 17 00:00:00 2001 From: Maxime PERALTA Date: Mon, 2 Mar 2026 13:55:09 +0100 Subject: [PATCH 2/4] (update) LCA data and impact model can be accessed by static panels --- apparun/gui/modules.py | 4 +++- apparun/gui/panels/base.py | 2 +- apparun/gui/panels/output_static.py | 4 +++- samples/scripts/custom_gui.py | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apparun/gui/modules.py b/apparun/gui/modules.py index cd21706..15c6298 100644 --- a/apparun/gui/modules.py +++ b/apparun/gui/modules.py @@ -93,7 +93,9 @@ def run(self): else: with self.output_col: for output_panel in self.output_panels: - output_panel.run() + output_panel.run( + impact_model=self.impact_model, lca_data=self.lca_data + ) class GUI(BaseModel): diff --git a/apparun/gui/panels/base.py b/apparun/gui/panels/base.py index e101b0a..79bf1d1 100644 --- a/apparun/gui/panels/base.py +++ b/apparun/gui/panels/base.py @@ -119,7 +119,7 @@ def get_results( class StaticOutputPanel(OutputPanel): type: Literal["static_output_panel"] - def run(self): + def run(self, impact_model: ImpactModel = None, lca_data: pd.DataFrame = None): return diff --git a/apparun/gui/panels/output_static.py b/apparun/gui/panels/output_static.py index 1c7d2c0..9e7b48c 100644 --- a/apparun/gui/panels/output_static.py +++ b/apparun/gui/panels/output_static.py @@ -1,8 +1,10 @@ from typing import Literal +import pandas as pd import streamlit as st from apparun.gui.panels.base import StaticOutputPanel, register_panel +from apparun.impact_model import ImpactModel from apparun.results import ImpactModelResult @@ -11,5 +13,5 @@ class Markdown(StaticOutputPanel): type: Literal["markdown"] message: str - def run(self): + def run(self, impact_model: ImpactModel = None, lca_data: pd.DataFrame = None): st.markdown(f"{self.message}") diff --git a/samples/scripts/custom_gui.py b/samples/scripts/custom_gui.py index a0afbc6..ec48a6c 100644 --- a/samples/scripts/custom_gui.py +++ b/samples/scripts/custom_gui.py @@ -20,7 +20,7 @@ class PoliteMarkdown(StaticOutputPanel): type: Literal["polite_markdown"] message: str - def run(self): + def run(self, impact_model: ImpactModel = None, lca_data: pd.DataFrame = None): st.markdown(f"{self.message}\n\nKind regards.") From cdd2dd350980809f2583b40f9aa775277cb6d90d Mon Sep 17 00:00:00 2001 From: Maxime PERALTA Date: Tue, 3 Mar 2026 11:26:09 +0100 Subject: [PATCH 3/4] (feature) Add GUI title page and favicon support --- apparun/gui/modules.py | 14 ++++++++++ samples/conf/custom_gui.yaml | 1 + samples/conf/sample_gui.yaml | 1 + tests/data/conf/functional_gui.yaml | 1 + .../data/conf/functional_gui_local_logo.yaml | 7 +++++ tests/end_to_end/test_gui.py | 28 +++++++++++++++++++ 6 files changed, 52 insertions(+) create mode 100644 tests/data/conf/functional_gui_local_logo.yaml diff --git a/apparun/gui/modules.py b/apparun/gui/modules.py index 15c6298..c340904 100644 --- a/apparun/gui/modules.py +++ b/apparun/gui/modules.py @@ -1,10 +1,14 @@ from __future__ import annotations +import io +import re import subprocess from typing import Annotated, Any, Callable, Dict, List, Optional, TypeVar, Union import pandas as pd +import requests import streamlit as st +from PIL import Image from pydantic import BaseModel, Field, field_validator from apparun.gui.panels.base import * @@ -100,9 +104,19 @@ def run(self): class GUI(BaseModel): name: Optional[str] = None + favicon_path: Optional[str] = None modules: List[Module] def setup_layout(self): + favicon = None + if self.favicon_path is not None: + if re.match(r"https?://.*", self.favicon_path): + favicon = Image.open( + io.BytesIO(requests.get(self.favicon_path, stream=True).content) + ) + else: + favicon = Image.open(self.favicon_path) + st.set_page_config(page_title=self.name, page_icon=favicon) st.html( """