Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 47 additions & 15 deletions apparun/gui/modules.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
from __future__ import annotations

import io
import re
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 requests
import streamlit as st
from pydantic import BaseModel, Field
from PIL import Image
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")
Expand All @@ -19,19 +29,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 = (
Expand Down Expand Up @@ -77,14 +97,26 @@ 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):
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(
"""
<style>
Expand Down
64 changes: 62 additions & 2 deletions apparun/gui/panels/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = {}
Expand Down Expand Up @@ -60,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


Expand All @@ -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):
Expand Down
8 changes: 7 additions & 1 deletion apparun/gui/panels/output_dynamic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions apparun/gui/panels/output_static.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
from typing import Literal

import pandas as pd
import streamlit as st

from apparun.gui.panels.base import StaticOutputPanel
from apparun.gui.panels.base import StaticOutputPanel, register_panel
from apparun.impact_model import ImpactModel
from apparun.results import ImpactModelResult


@register_panel("markdown")
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}")
21 changes: 21 additions & 0 deletions samples/conf/custom_gui.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: "GUI with custom panels"
favicon_path: "https://www.cea.fr/PublishingImages/cea.jpg"
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"
3 changes: 3 additions & 0 deletions samples/conf/parameters_sets/scenario_A.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
lifespan: 3
architecture: Maxwell
cuda_core: 2048
3 changes: 3 additions & 0 deletions samples/conf/parameters_sets/scenario_B.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
lifespan: 3
architecture: Pascal
cuda_core: 1024
1 change: 1 addition & 0 deletions samples/conf/sample_gui.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
name: "Preconfigured tool for NVIDIA AI GPU"
favicon_path: "https://www.cea.fr/PublishingImages/cea.jpg"
modules:
- impact_model_path: "samples/impact_models/nvidia_ai_gpu_chip.yaml"
name: "Scenario comparison by phase"
Expand Down
74 changes: 74 additions & 0 deletions samples/scripts/custom_gui.py
Original file line number Diff line number Diff line change
@@ -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, impact_model: ImpactModel = None, lca_data: pd.DataFrame = None):
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")
Binary file added tests/data/cea.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions tests/data/conf/functional_gui.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
name: "Preconfigured tool for NVIDIA AI GPU"
favicon_path: "https://www.cea.fr/PublishingImages/cea.jpg"
modules:
- name: "Random explanation"
output_panels:
Expand Down
7 changes: 7 additions & 0 deletions tests/data/conf/functional_gui_local_logo.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
name: "Preconfigured tool for NVIDIA AI GPU"
favicon_path: "tests/data/cea.jpg"
modules:
- name: "Random explanation"
output_panels:
- type: markdown
message: "**Lorem ipsum** dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
28 changes: 28 additions & 0 deletions tests/end_to_end/test_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@
This module contains the tests related to the gui command.
"""

import pytest
from PIL import UnidentifiedImageError
from streamlit.testing.v1 import AppTest

from apparun.cli.main import load_yaml
from apparun.gui.modules import GUI


def run_test_gui():
from apparun.cli.main import generate_gui
Expand All @@ -20,3 +25,26 @@ def test_streamlit_app_is_deploying():
at.run()
# This app should generate three md widgets: one title, one header, one text block.
assert len(at.markdown) == 3


def test_favicons():
"""
Check that local and remote favicon can be loaded.
"""
remote_logo_gui_config = load_yaml("tests/data/conf/functional_gui.yaml", "r")
gui = GUI(**remote_logo_gui_config)
try:
gui.setup_layout()
except UnidentifiedImageError:
msg = f"Cannot load remote favicon {gui.favicon_path}"
pytest.fail(msg)

local_logo_gui_config = load_yaml(
"tests/data/conf/functional_gui_local_logo.yaml", "r"
)
gui = GUI(**local_logo_gui_config)
try:
gui.setup_layout()
except UnidentifiedImageError:
msg = f"Cannot load local favicon {gui.favicon_path}"
pytest.fail(msg)
Loading