diff --git a/vizro-core/changelog.d/20231122_143116_petar_pejovic_enable_custom_actions.md b/vizro-core/changelog.d/20231122_143116_petar_pejovic_enable_custom_actions.md new file mode 100644 index 000000000..eeba5b1a1 --- /dev/null +++ b/vizro-core/changelog.d/20231122_143116_petar_pejovic_enable_custom_actions.md @@ -0,0 +1,47 @@ + + +### Highlights ✨ + +- Release of the `custom actions`. Visit the [user guide on actions](https://vizro.readthedocs.io/en/stable/pages/user_guides/actions/) to learn more. +- TODO: Add PR link above. + + + + + + + diff --git a/vizro-core/docs/pages/user_guides/actions.md b/vizro-core/docs/pages/user_guides/actions.md index d76b3ea77..66aa60e89 100644 --- a/vizro-core/docs/pages/user_guides/actions.md +++ b/vizro-core/docs/pages/user_guides/actions.md @@ -285,8 +285,8 @@ Here is an example of how to configure a chart interaction when the source is a [Table]: ../../assets/user_guides/actions/actions_table_filter_interaction.png -## Predefined actions customization -Many predefined actions are customizable which helps to achieve more specific desired goal. For specific options, please +## Pre-defined actions customization +Many pre-defined actions are customizable which helps to achieve more specific desired goal. For specific options, please refer to the [API reference][vizro.actions] on this topic. ## Actions chaining @@ -384,9 +384,152 @@ The order of action execution is guaranteed, and the next action in the list wil [Graph3]: ../../assets/user_guides/actions/actions_chaining.png + ## Custom actions -!!! success "Coming soon!" +In case you want to create complex functionality with the [`Action`][vizro.models.Action] model, and if there is no already pre-defined `action function` available, you can create your own `custom action`. +Like other actions, custom actions could also be added as element inside the actions chain, and it can be triggered with one of many dashboard components. + +### Simple custom action example (without UI inputs and outputs) + +Custom actions feature enables you to implement your own `action function`, and for this, simply do the following: + +- define a function +- decorate it with the `@capture("action")` decorator +- add it as a `function` argument inside the [`Action`][vizro.models.Action] model + +The following example shows how to create a custom action that postpone execution of the next action in the chain for `N` seconds. + +??? example "Custom Dash DataTable" + === "app.py" + ```py + import vizro.models as vm + import vizro.plotly.express as px + from vizro import Vizro + from vizro.actions import export_data + from vizro.models.types import capture + from time import sleep + + + @capture("action") + def my_custom_action(sleep_n_seconds: int = 0): + """Custom action.""" + sleep(sleep_n_seconds) + + + df = px.data.iris() + + page = vm.Page( + title="Example of a simple custom action", + components=[ + vm.Graph( + id="scatter_chart", + figure=px.scatter(df, x="sepal_length", y="petal_width", color="species") + ), + vm.Button( + text="Export data", + actions=[ + vm.Action(function=export_data()), + vm.Action( + function=my_custom_action( + sleep_n_seconds=2 + ) + ), + vm.Action(function=export_data(file_format="xlsx")), + ] + ) + ], + controls=[vm.Filter(column="species", selector=vm.Dropdown(title="Species"))], + ) + + dashboard = vm.Dashboard(pages=[page]) + + Vizro().build(dashboard).run() + ``` + === "app.yaml" + ```yaml + # Custom action are currently only possible via python configuration + ``` + + +### Custom action with UI inputs and outputs +In case the custom action needs to interact with the dashboard, it is possible to define `inputs` and `outputs` for the custom action. + +- `inputs` represents dashboard components properties which values are passed to the custom action function as function arguments. It can be defined as a list of strings in the following format: `"."`, which enables propagation of the visible values from the app into the function as its arguments in the following format: `"_"`. +- `outputs` represents dashboard components properties that are affected with custom action function return value. Similar to `inputs`, it can be defined aa a list of strings in the following format: `"."`. + +The following example shows how to create a custom action that prints the data of the clicked point in the graph to the console. + +??? example "Custom Dash DataTable" + === "app.py" + ```py + import vizro.models as vm + import vizro.plotly.express as px + from vizro import Vizro + from vizro.actions import filter_interaction + from vizro.models.types import capture + + + @capture("action") + def my_custom_action(scatter_chart_clickData: dict = None): + """Custom action.""" + if scatter_chart_clickData: + return f'Scatter chart clicked data:\n### Species: "{scatter_chart_clickData["points"][0]["customdata"][0]}"' + return "### No data clicked." + + + df = px.data.iris() + + page = vm.Page( + title="Example of a custom action with UI inputs and outputs", + layout=vm.Layout( + grid=[ + [0, 0, 0, 1], + [0, 0, 0, -1], + [0, 0, 0, -1], + [0, 0, 0, -1], + [2, 2, 2, -1], + [2, 2, 2, -1], + [2, 2, 2, -1], + [2, 2, 2, -1], + ], + row_gap="25px", + ), + components=[ + vm.Graph( + id="scatter_chart", + figure=px.scatter(df, x="sepal_length", y="petal_width", color="species", custom_data=["species"]), + actions=[ + vm.Action(function=filter_interaction(targets=["scatter_chart_2"])), + vm.Action( + function=my_custom_action(), + inputs=["scatter_chart.clickData"], + outputs=["card_id.children"] + ), + ], + ), + vm.Card(id="card_id", text="### No data clicked."), + vm.Graph( + id="scatter_chart_2", + figure=px.scatter(df, x="sepal_length", y="petal_width", color="species"), + ), + ], + controls=[vm.Filter(column="species", selector=vm.Dropdown(title="Species"))], + ) + + dashboard = vm.Dashboard(pages=[page]) + + Vizro().build(dashboard).run() + ``` + === "app.yaml" + ```yaml + # Custom action are currently only possible via python configuration + ``` + +#### Custom action return value +TO BE ADDED! + +--- !!! warning diff --git a/vizro-core/src/vizro/actions/_actions_utils.py b/vizro-core/src/vizro/actions/_actions_utils.py index e82756e03..5e6f39daf 100644 --- a/vizro-core/src/vizro/actions/_actions_utils.py +++ b/vizro-core/src/vizro/actions/_actions_utils.py @@ -2,9 +2,9 @@ from __future__ import annotations -from collections import defaultdict +from collections import defaultdict, namedtuple from copy import deepcopy -from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, TypedDict, Union +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, TypedDict, Union import pandas as pd @@ -84,8 +84,9 @@ def _apply_graph_filter_interaction( customdata = ctd_click_data["value"]["points"][0]["customdata"] + # TODO: BUG: fix if there is filter_interaction and other actions assigned to the same 'vm.Graph.actions' . for action in source_graph_actions: - if target not in action.function["targets"]: + if action.function._function.__name__ != "filter_interaction" or target not in action.function["targets"]: continue for custom_data_idx, column in enumerate(custom_data_columns): data_frame = data_frame[data_frame[column].isin([customdata[custom_data_idx]])] @@ -248,7 +249,7 @@ def _get_modified_page_figures( ctds_parameters: List[CallbackTriggerDict], ctd_theme: CallbackTriggerDict, targets: Optional[List[ModelID]] = None, -) -> Dict[ModelID, Any]: +) -> Tuple[Any, ...]: if not targets: targets = [] filtered_data = _get_filtered_data( @@ -262,7 +263,7 @@ def _get_modified_page_figures( parameters=ctds_parameters, ) - outputs = {} + outputs: Dict[ModelID, Any] = {} for target in targets: outputs[target] = model_manager[target]( # type: ignore[operator] data_frame=filtered_data[target], **parameterized_config[target] @@ -270,4 +271,4 @@ def _get_modified_page_figures( if hasattr(outputs[target], "update_layout"): outputs[target].update_layout(template="vizro_dark" if ctd_theme["value"] else "vizro_light") - return outputs + return namedtuple("outputs", outputs.keys())(**outputs) diff --git a/vizro-core/src/vizro/actions/_callback_mapping/_callback_mapping_utils.py b/vizro-core/src/vizro/actions/_callback_mapping/_callback_mapping_utils.py index da6620214..ee10ec67d 100644 --- a/vizro-core/src/vizro/actions/_callback_mapping/_callback_mapping_utils.py +++ b/vizro-core/src/vizro/actions/_callback_mapping/_callback_mapping_utils.py @@ -203,9 +203,9 @@ def _get_export_data_callback_outputs(action_id: ModelID) -> Dict[str, List[Stat targets = _get_components_with_data(action_id=action_id) return { - f"download-dataframe_{target}": Output( + f"download_dataframe_{target}": Output( component_id={ - "type": "download-dataframe", + "type": "download_dataframe", "action_id": action_id, "target_id": target, }, @@ -231,7 +231,7 @@ def _get_export_data_callback_components(action_id: ModelID) -> List[dcc.Downloa return [ dcc.Download( id={ - "type": "download-dataframe", + "type": "download_dataframe", "action_id": action_id, "target_id": target, }, diff --git a/vizro-core/src/vizro/actions/_filter_action.py b/vizro-core/src/vizro/actions/_filter_action.py index f35daa7b4..445cb4ff0 100644 --- a/vizro-core/src/vizro/actions/_filter_action.py +++ b/vizro-core/src/vizro/actions/_filter_action.py @@ -1,6 +1,6 @@ """Pre-defined action function "_filter" to be reused in `action` parameter of VizroBaseModels.""" -from typing import Any, Callable, Dict, List +from typing import Any, Callable, Dict, List, Tuple import pandas as pd from dash import ctx @@ -18,7 +18,7 @@ def _filter( targets: List[ModelID], filter_function: Callable[[pd.Series, Any], pd.Series], **inputs: Dict[str, Any], -) -> Dict[ModelID, Any]: +) -> Tuple[Any, ...]: """Filters targeted charts/components on page by interaction with `Filter` control. Args: diff --git a/vizro-core/src/vizro/actions/_on_page_load_action.py b/vizro-core/src/vizro/actions/_on_page_load_action.py index 16c6279f5..7f5ac9f33 100644 --- a/vizro-core/src/vizro/actions/_on_page_load_action.py +++ b/vizro-core/src/vizro/actions/_on_page_load_action.py @@ -1,6 +1,6 @@ """Pre-defined action function "_on_page_load" to be reused in `action` parameter of VizroBaseModels.""" -from typing import Any, Dict +from typing import Any, Dict, Tuple from dash import ctx @@ -13,7 +13,7 @@ @capture("action") -def _on_page_load(page_id: ModelID, **inputs: Dict[str, Any]) -> Dict[ModelID, Any]: +def _on_page_load(page_id: ModelID, **inputs: Dict[str, Any]) -> Tuple[Any, ...]: """Applies controls to charts on page once the page is opened (or refreshed). Args: diff --git a/vizro-core/src/vizro/actions/_parameter_action.py b/vizro-core/src/vizro/actions/_parameter_action.py index ba77a7762..53998be77 100644 --- a/vizro-core/src/vizro/actions/_parameter_action.py +++ b/vizro-core/src/vizro/actions/_parameter_action.py @@ -1,6 +1,6 @@ """Pre-defined action function "_parameter" to be reused in `action` parameter of VizroBaseModels.""" -from typing import Any, Dict, List +from typing import Any, Dict, List, Tuple from dash import ctx @@ -12,7 +12,7 @@ @capture("action") -def _parameter(targets: List[str], **inputs: Dict[str, Any]) -> Dict[ModelID, Any]: +def _parameter(targets: List[str], **inputs: Dict[str, Any]) -> Tuple[Any, ...]: """Modifies parameters of targeted charts/components on page. Args: diff --git a/vizro-core/src/vizro/actions/export_data_action.py b/vizro-core/src/vizro/actions/export_data_action.py index afaded8cf..627c5c80d 100644 --- a/vizro-core/src/vizro/actions/export_data_action.py +++ b/vizro-core/src/vizro/actions/export_data_action.py @@ -1,6 +1,7 @@ """Pre-defined action function "export_data" to be reused in `action` parameter of VizroBaseModels.""" -from typing import Any, Dict, List, Optional +from collections import namedtuple +from typing import Any, Dict, List, Optional, Tuple from dash import ctx, dcc from typing_extensions import Literal @@ -18,7 +19,7 @@ def export_data( targets: Optional[List[ModelID]] = None, file_format: Literal["csv", "xlsx"] = "csv", **inputs: Dict[str, Any], -) -> Dict[str, Any]: +) -> Tuple[Any, ...]: """Exports visible data of target charts/components on page after being triggered. Args: @@ -38,7 +39,7 @@ def export_data( targets = [ output["id"]["target_id"] for output in ctx.outputs_list - if isinstance(output["id"], dict) and output["id"]["type"] == "download-dataframe" + if isinstance(output["id"], dict) and output["id"]["type"] == "download_dataframe" ] for target in targets: if target not in model_manager: @@ -50,7 +51,7 @@ def export_data( ctds_filter_interaction=ctx.args_grouping["filter_interaction"], ) - callback_outputs = {} + outputs = {} for target_id in targets: if file_format == "csv": writer = data_frames[target_id].to_csv @@ -58,8 +59,8 @@ def export_data( writer = data_frames[target_id].to_excel # Invalid file_format should be caught by Action validation - callback_outputs[f"download-dataframe_{target_id}"] = dcc.send_data_frame( + outputs[f"download_dataframe_{target_id}"] = dcc.send_data_frame( writer=writer, filename=f"{target_id}.{file_format}", index=False ) - return callback_outputs + return namedtuple("outputs", outputs.keys())(**outputs) diff --git a/vizro-core/src/vizro/actions/filter_interaction_action.py b/vizro-core/src/vizro/actions/filter_interaction_action.py index d3a04f3a8..95d1f1ee3 100644 --- a/vizro-core/src/vizro/actions/filter_interaction_action.py +++ b/vizro-core/src/vizro/actions/filter_interaction_action.py @@ -1,6 +1,6 @@ """Pre-defined action function "filter_interaction" to be reused in `action` parameter of VizroBaseModels.""" -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple from dash import ctx @@ -15,7 +15,7 @@ def filter_interaction( targets: Optional[List[ModelID]] = None, **inputs: Dict[str, Any], -) -> Dict[ModelID, Any]: +) -> Tuple[Any, ...]: """Filters targeted charts/components on page by clicking on data points or table cells of the source chart. To set up filtering on specific columns of the target graph(s), include these columns in the 'custom_data' diff --git a/vizro-core/src/vizro/models/_action/_action.py b/vizro-core/src/vizro/models/_action/_action.py index 6f9800a1e..d68edca89 100644 --- a/vizro-core/src/vizro/models/_action/_action.py +++ b/vizro-core/src/vizro/models/_action/_action.py @@ -1,5 +1,6 @@ import importlib.util import logging +from collections.abc import Collection from typing import Any, Dict, List from dash import Input, Output, State, callback, ctx, html @@ -55,19 +56,6 @@ def validate_predefined_actions(cls, function): ) return function - @staticmethod - def _validate_output_number(outputs, return_value): - return_value_len = ( - 1 if not hasattr(return_value, "__len__") or isinstance(return_value, str) else len(return_value) - ) - - # Raising the custom exception if the callback return value length doesn't match the number of defined outputs. - if len(outputs) != return_value_len: - raise ValueError( - f"Number of action's returned elements ({return_value_len}) does not match the number" - f" of action's defined outputs ({len(outputs)})." - ) - def _get_callback_mapping(self): """Builds callback inputs and outputs for the Action model callback, and returns action required components. @@ -113,25 +101,32 @@ def _action_callback_function(self, **inputs: Dict[str, Any]) -> Dict[str, Any]: logger.debug(f"Action inputs: {inputs}") # Invoking the action's function - return_value = self.function(**inputs) or {} + return_value = self.function(**inputs) or [] # Action callback outputs outputs = list(ctx.outputs_grouping.keys()) outputs.remove("action_finished") - # Validate number of outputs - self._validate_output_number( - outputs=outputs, - return_value=return_value, - ) - - # If return_value is a single element, ensure return_value is a list - if not isinstance(return_value, (list, tuple, dict)): - return_value = [return_value] - if isinstance(return_value, dict): - return {"action_finished": None, **return_value} + if hasattr(return_value, "_asdict") and hasattr(return_value, "_fields"): + # return_value is a namedtuple. + if set(return_value._fields) != set(outputs): + raise ValueError( + f"Action's returned fields {set(return_value._fields)} does not match the action's defined " + f"outputs {set(outputs) if outputs else {}}." + ) + return_dict = return_value._asdict() + else: + if isinstance(return_value, (str, dict)) or not isinstance(return_value, Collection): + return_value = [return_value] + + if len(return_value) != len(outputs): + raise ValueError( + f"Number of action's returned elements ({len(return_value)}) does not match the number" + f" of action's defined outputs ({len(outputs)})." + ) + return_dict = dict(zip(outputs, return_value)) - return {"action_finished": None, **dict(zip(outputs, return_value))} + return {"action_finished": None, **return_dict} @_log_call def build(self): diff --git a/vizro-core/src/vizro/models/types.py b/vizro-core/src/vizro/models/types.py index 140d5d040..ebb63d56d 100644 --- a/vizro-core/src/vizro/models/types.py +++ b/vizro-core/src/vizro/models/types.py @@ -188,15 +188,16 @@ class capture: >>> @capture("table") >>> def table_function(): >>> ... - >>> @capture("table") - >>> def plot_function(): - >>> ... >>> @capture("action") >>> def action_function(): >>> ... For further help on the use of `@capture("graph")`, you can refer to the guide on - [custom charts](../user_guides/custom_charts.md). + [custom graphs](../user_guides/custom_charts.md). + For further help on the use of `@capture("table")`, you can refer to the guide on + [custom tables](../user_guides/table#custom-table). + For further help on the use of `@capture("action")`, you can refer to the guide on + [custom actions](../user_guides/actions/#custom-actions). """ def __init__(self, mode: Literal["graph", "action", "table"]): diff --git a/vizro-core/tests/unit/vizro/actions/_callback_mapping/test_get_action_callback_mapping.py b/vizro-core/tests/unit/vizro/actions/_callback_mapping/test_get_action_callback_mapping.py index 49836398d..36f29957c 100644 --- a/vizro-core/tests/unit/vizro/actions/_callback_mapping/test_get_action_callback_mapping.py +++ b/vizro-core/tests/unit/vizro/actions/_callback_mapping/test_get_action_callback_mapping.py @@ -163,8 +163,8 @@ def export_data_inputs_expected(): @pytest.fixture def export_data_outputs_expected(request): return { - f"download-dataframe_{target}": dash.Output( - {"action_id": "export_data_action", "target_id": target, "type": "download-dataframe"}, "data" + f"download_dataframe_{target}": dash.Output( + {"action_id": "export_data_action", "target_id": target, "type": "download_dataframe"}, "data" ) for target in request.param } @@ -173,7 +173,7 @@ def export_data_outputs_expected(request): @pytest.fixture def export_data_components_expected(request): return [ - dash.dcc.Download(id={"type": "download-dataframe", "action_id": "export_data_action", "target_id": target}) + dash.dcc.Download(id={"type": "download_dataframe", "action_id": "export_data_action", "target_id": target}) for target in request.param ] diff --git a/vizro-core/tests/unit/vizro/actions/test_export_data_action.py b/vizro-core/tests/unit/vizro/actions/test_export_data_action.py index 7f9440976..fc7f5c9d8 100644 --- a/vizro-core/tests/unit/vizro/actions/test_export_data_action.py +++ b/vizro-core/tests/unit/vizro/actions/test_export_data_action.py @@ -113,7 +113,7 @@ def callback_context_export_data(request): "filter_interaction": args_grouping_filter_interaction, }, "outputs_list": [ - {"id": {"action_id": "test_action", "target_id": target, "type": "download-dataframe"}, "property": "data"} + {"id": {"action_id": "test_action", "target_id": target, "type": "download_dataframe"}, "property": "data"} for target in targets ], } @@ -131,7 +131,7 @@ def test_no_graphs_no_targets(self, callback_context_export_data): # Run action by picking the above added action function and executing it with () result = model_manager["test_action"].function() - assert result == {} + assert len(result) == 0 @pytest.mark.usefixtures("managers_one_page_two_graphs_one_button") @pytest.mark.parametrize( @@ -144,11 +144,11 @@ def test_graphs_no_targets(self, callback_context_export_data, gapminder_2007): # Run action by picking the above added action function and executing it with () result = model_manager["test_action"].function() - assert result["download-dataframe_scatter_chart"]["filename"] == "scatter_chart.csv" - assert result["download-dataframe_scatter_chart"]["content"] == gapminder_2007.to_csv(index=False) + assert result.download_dataframe_scatter_chart["filename"] == "scatter_chart.csv" + assert result.download_dataframe_scatter_chart["content"] == gapminder_2007.to_csv(index=False) - assert result["download-dataframe_box_chart"]["filename"] == "box_chart.csv" - assert result["download-dataframe_box_chart"]["content"] == gapminder_2007.to_csv(index=False) + assert result.download_dataframe_box_chart["filename"] == "box_chart.csv" + assert result.download_dataframe_box_chart["content"] == gapminder_2007.to_csv(index=False) @pytest.mark.usefixtures("managers_one_page_two_graphs_one_button") @pytest.mark.parametrize( @@ -166,11 +166,11 @@ def test_graphs_false_targets(self, callback_context_export_data, targets, gapmi # Run action by picking the above added action function and executing it with () result = model_manager["test_action"].function() - assert result["download-dataframe_scatter_chart"]["filename"] == "scatter_chart.csv" - assert result["download-dataframe_scatter_chart"]["content"] == gapminder_2007.to_csv(index=False) + assert result.download_dataframe_scatter_chart["filename"] == "scatter_chart.csv" + assert result.download_dataframe_scatter_chart["content"] == gapminder_2007.to_csv(index=False) - assert result["download-dataframe_box_chart"]["filename"] == "box_chart.csv" - assert result["download-dataframe_box_chart"]["content"] == gapminder_2007.to_csv(index=False) + assert result.download_dataframe_box_chart["filename"] == "box_chart.csv" + assert result.download_dataframe_box_chart["content"] == gapminder_2007.to_csv(index=False) @pytest.mark.usefixtures("managers_one_page_two_graphs_one_button") @pytest.mark.parametrize("callback_context_export_data", [(["scatter_chart"], None, None, None)], indirect=True) @@ -181,10 +181,10 @@ def test_one_target(self, callback_context_export_data, gapminder_2007): # Run action by picking the above added action function and executing it with () result = model_manager["test_action"].function() - assert result["download-dataframe_scatter_chart"]["filename"] == "scatter_chart.csv" - assert result["download-dataframe_scatter_chart"]["content"] == gapminder_2007.to_csv(index=False) + assert result.download_dataframe_scatter_chart["filename"] == "scatter_chart.csv" + assert result.download_dataframe_scatter_chart["content"] == gapminder_2007.to_csv(index=False) - assert "download-dataframe_box_chart" not in result + assert not hasattr(result, "download_dataframe_box_chart") @pytest.mark.usefixtures("managers_one_page_two_graphs_one_button") @pytest.mark.parametrize( @@ -199,11 +199,11 @@ def test_multiple_targets(self, callback_context_export_data, gapminder_2007): # Run action by picking the above added action function and executing it with () result = model_manager["test_action"].function() - assert result["download-dataframe_scatter_chart"]["filename"] == "scatter_chart.csv" - assert result["download-dataframe_scatter_chart"]["content"] == gapminder_2007.to_csv(index=False) + assert result.download_dataframe_scatter_chart["filename"] == "scatter_chart.csv" + assert result.download_dataframe_scatter_chart["content"] == gapminder_2007.to_csv(index=False) - assert result["download-dataframe_box_chart"]["filename"] == "box_chart.csv" - assert result["download-dataframe_box_chart"]["content"] == gapminder_2007.to_csv(index=False) + assert result.download_dataframe_box_chart["filename"] == "box_chart.csv" + assert result.download_dataframe_box_chart["content"] == gapminder_2007.to_csv(index=False) @pytest.mark.usefixtures("managers_one_page_two_graphs_one_button") @pytest.mark.parametrize("callback_context_export_data", [(["invalid_target_id"], None, None, None)], indirect=True) @@ -263,13 +263,13 @@ def test_multiple_targets_with_filter_and_filter_interaction( # Run action by picking the above added export_data action function and executing it with () result = model_manager["test_action"].function() - assert result["download-dataframe_scatter_chart"]["filename"] == "scatter_chart.csv" - assert result["download-dataframe_scatter_chart"][ + assert result.download_dataframe_scatter_chart["filename"] == "scatter_chart.csv" + assert result.download_dataframe_scatter_chart[ "content" ] == target_scatter_filter_and_filter_interaction.to_csv(index=False) - assert result["download-dataframe_box_chart"]["filename"] == "box_chart.csv" - assert result["download-dataframe_box_chart"]["content"] == target_box_filtered_pop.to_csv(index=False) + assert result.download_dataframe_box_chart["filename"] == "box_chart.csv" + assert result.download_dataframe_box_chart["content"] == target_box_filtered_pop.to_csv(index=False) @pytest.mark.usefixtures("managers_one_page_two_graphs_one_table_one_button") @pytest.mark.parametrize( @@ -318,10 +318,10 @@ def test_multiple_targets_with_filter_and_filter_interaction_and_table( # Run action by picking the above added export_data action function and executing it with () result = model_manager["test_action"].function() - assert result["download-dataframe_scatter_chart"]["filename"] == "scatter_chart.csv" - assert result["download-dataframe_scatter_chart"][ + assert result.download_dataframe_scatter_chart["filename"] == "scatter_chart.csv" + assert result.download_dataframe_scatter_chart[ "content" ] == target_scatter_filter_and_filter_interaction.to_csv(index=False) - assert result["download-dataframe_box_chart"]["filename"] == "box_chart.csv" - assert result["download-dataframe_box_chart"]["content"] == target_box_filtered_pop.to_csv(index=False) + assert result.download_dataframe_box_chart["filename"] == "box_chart.csv" + assert result.download_dataframe_box_chart["content"] == target_box_filtered_pop.to_csv(index=False) diff --git a/vizro-core/tests/unit/vizro/actions/test_filter_action.py b/vizro-core/tests/unit/vizro/actions/test_filter_action.py index ccb84a66b..32ee2b6ed 100644 --- a/vizro-core/tests/unit/vizro/actions/test_filter_action.py +++ b/vizro-core/tests/unit/vizro/actions/test_filter_action.py @@ -119,8 +119,8 @@ def test_one_filter_no_targets( # Run action by picking the above added action function and executing it with () result = model_manager[f"{FILTER_ACTION_PREFIX}_test_filter"].function() - assert result["scatter_chart"] == target_scatter_filtered_continent - assert result["box_chart"] == target_box_filtered_continent + assert result.scatter_chart == target_scatter_filtered_continent + assert result.box_chart == target_box_filtered_continent @pytest.mark.parametrize( "callback_context_filter_continent,target_scatter_filtered_continent", @@ -144,8 +144,8 @@ def test_one_filter_one_target( # Run action by picking the above added action function and executing it with () result = model_manager[f"{FILTER_ACTION_PREFIX}_test_filter"].function() - assert result["scatter_chart"] == target_scatter_filtered_continent - assert "box_chart" not in result + assert result.scatter_chart == target_scatter_filtered_continent + assert not hasattr(result, "box_chart") @pytest.mark.parametrize( "callback_context_filter_continent,target_scatter_filtered_continent,target_box_filtered_continent", @@ -173,8 +173,8 @@ def test_one_filter_multiple_targets( # Run action by picking the above added action function and executing it with () result = model_manager[f"{FILTER_ACTION_PREFIX}_test_filter"].function() - assert result["scatter_chart"] == target_scatter_filtered_continent - assert result["box_chart"] == target_box_filtered_continent + assert result.scatter_chart == target_scatter_filtered_continent + assert result.box_chart == target_box_filtered_continent @pytest.mark.parametrize( "callback_context_filter_continent_and_pop,target_scatter_filtered_continent_and_pop,target_box_filtered_continent_and_pop", @@ -208,8 +208,8 @@ def test_multiple_filters_no_targets( # Run action by picking the above added action function and executing it with () result = model_manager[f"{FILTER_ACTION_PREFIX}_test_filter_continent"].function() - assert result["scatter_chart"] == target_scatter_filtered_continent_and_pop - assert result["box_chart"] == target_box_filtered_continent_and_pop + assert result.scatter_chart == target_scatter_filtered_continent_and_pop + assert result.box_chart == target_box_filtered_continent_and_pop @pytest.mark.parametrize( "callback_context_filter_continent_and_pop,target_scatter_filtered_continent_and_pop", @@ -241,8 +241,8 @@ def test_multiple_filters_one_target( # Run action by picking the above added action function and executing it with () result = model_manager[f"{FILTER_ACTION_PREFIX}_test_filter_continent"].function() - assert result["scatter_chart"] == target_scatter_filtered_continent_and_pop - assert "box_chart" not in result + assert result.scatter_chart == target_scatter_filtered_continent_and_pop + assert not hasattr(result, "box_chart") @pytest.mark.parametrize( "callback_context_filter_continent_and_pop,target_scatter_filtered_continent_and_pop,target_box_filtered_continent_and_pop", @@ -284,5 +284,5 @@ def test_multiple_filters_multiple_targets( # Run action by picking the above added action function and executing it with () result = model_manager[f"{FILTER_ACTION_PREFIX}_test_filter_continent"].function() - assert result["scatter_chart"] == target_scatter_filtered_continent_and_pop - assert result["box_chart"] == target_box_filtered_continent_and_pop + assert result.scatter_chart == target_scatter_filtered_continent_and_pop + assert result.box_chart == target_box_filtered_continent_and_pop diff --git a/vizro-core/tests/unit/vizro/actions/test_filter_interaction_action.py b/vizro-core/tests/unit/vizro/actions/test_filter_interaction_action.py index 8e3fa5462..c90bb51cb 100644 --- a/vizro-core/tests/unit/vizro/actions/test_filter_interaction_action.py +++ b/vizro-core/tests/unit/vizro/actions/test_filter_interaction_action.py @@ -123,7 +123,7 @@ def test_filter_interaction_without_targets_temporary_behavior( # temporary fix # Run action by picking the above added action function and executing it with () result = model_manager["test_action"].function() - assert result == {} + assert len(result) == 0 @pytest.mark.xfail # This is the desired behavior, ie when no target is provided, then all charts filtered @pytest.mark.parametrize( @@ -147,8 +147,8 @@ def test_filter_interaction_without_targets_desired_behavior( # Run action by picking the above added action function and executing it with () result = model_manager["test_action"].function() - assert result["scatter_chart"] == target_scatter_filtered_continent - assert result["box_chart"] == target_box_filtered_continent + assert result.scatter_chart == target_scatter_filtered_continent + assert result.box_chart == target_box_filtered_continent @pytest.mark.parametrize( "callback_context_filter_interaction,target_scatter_filtered_continent", @@ -172,7 +172,7 @@ def test_filter_interaction_with_one_target( # Run action by picking the above added action function and executing it with () result = model_manager["test_action"].function() - assert result["scatter_chart"] == target_scatter_filtered_continent + assert result.scatter_chart == target_scatter_filtered_continent @pytest.mark.parametrize( "callback_context_filter_interaction,target_scatter_filtered_continent,target_box_filtered_continent", @@ -197,8 +197,8 @@ def test_filter_interaction_with_two_target( # Run action by picking the above added action function and executing it with () result = model_manager["test_action"].function() - assert result["scatter_chart"] == target_scatter_filtered_continent - assert result["box_chart"] == target_box_filtered_continent + assert result.scatter_chart == target_scatter_filtered_continent + assert result.box_chart == target_box_filtered_continent @pytest.mark.xfail # This (or similar code) should raise a Value/Validation error explaining next steps @pytest.mark.parametrize("target", ["scatter_chart", ["scatter_chart"]]) @@ -238,7 +238,7 @@ def test_table_filter_interaction_with_one_target( # Run action by picking the above added action function and executing it with () result = model_manager["test_action"].function() - assert result["scatter_chart"] == target_scatter_filtered_continent + assert result.scatter_chart == target_scatter_filtered_continent @pytest.mark.parametrize( "callback_context_filter_interaction, target_scatter_filtered_continent, target_box_filtered_continent", @@ -267,8 +267,8 @@ def test_table_filter_interaction_with_two_targets( # Run action by picking the above added action function and executing it with () result = model_manager["test_action"].function() - assert result["scatter_chart"] == target_scatter_filtered_continent - assert result["box_chart"] == target_box_filtered_continent + assert result.scatter_chart == target_scatter_filtered_continent + assert result.box_chart == target_box_filtered_continent @pytest.mark.parametrize( "callback_context_filter_interaction, target_scatter_filtered_continent, target_box_filtered_continent", @@ -297,8 +297,8 @@ def test_mixed_chart_and_table_filter_interaction_with_two_targets( # Run action by picking the above added action function and executing it with () result = model_manager["test_action"].function() - assert result["scatter_chart"] == target_scatter_filtered_continent - assert result["box_chart"] == target_box_filtered_continent + assert result.scatter_chart == target_scatter_filtered_continent + assert result.box_chart == target_box_filtered_continent # TODO: Simplify parametrization, such that we have less repetitive code # TODO: Eliminate above xfails diff --git a/vizro-core/tests/unit/vizro/actions/test_on_page_load_action.py b/vizro-core/tests/unit/vizro/actions/test_on_page_load_action.py index 6e402a095..b23956dae 100644 --- a/vizro-core/tests/unit/vizro/actions/test_on_page_load_action.py +++ b/vizro-core/tests/unit/vizro/actions/test_on_page_load_action.py @@ -148,7 +148,7 @@ def test_multiple_controls_one_target( # Run action by picking 'on_page_load' default Page action function and executing it with () result = model_manager[f"{ON_PAGE_LOAD_ACTION_PREFIX}_action_test_page"].function() - assert result["scatter_chart"] == target_scatter_filtered_continent_and_pop_parameter_y_and_x + assert result.scatter_chart == target_scatter_filtered_continent_and_pop_parameter_y_and_x @pytest.mark.usefixtures("managers_one_page_two_graphs_one_button") @pytest.mark.parametrize( @@ -206,5 +206,5 @@ def test_multiple_controls_multiple_targets( # Run action by picking 'on_page_load' default Page action function and executing it with () result = model_manager[f"{ON_PAGE_LOAD_ACTION_PREFIX}_action_test_page"].function() - assert result["scatter_chart"] == target_scatter_filtered_continent_and_pop_parameter_y_and_x - assert result["box_chart"] == target_box_filtered_continent_and_pop_parameter_y_and_x + assert result.scatter_chart == target_scatter_filtered_continent_and_pop_parameter_y_and_x + assert result.box_chart == target_box_filtered_continent_and_pop_parameter_y_and_x diff --git a/vizro-core/tests/unit/vizro/actions/test_parameter_action.py b/vizro-core/tests/unit/vizro/actions/test_parameter_action.py index c112cce60..bf0e96b36 100644 --- a/vizro-core/tests/unit/vizro/actions/test_parameter_action.py +++ b/vizro-core/tests/unit/vizro/actions/test_parameter_action.py @@ -171,8 +171,8 @@ def test_one_parameter_one_target( # Run action by picking the above added action function and executing it with () result = model_manager[f"{PARAMETER_ACTION_PREFIX}_test_parameter"].function() - assert result["scatter_chart"] == target_scatter_parameter_y - assert "box_chart" not in result + assert result.scatter_chart == target_scatter_parameter_y + assert not hasattr(result, "box_chart") @pytest.mark.parametrize( "callback_context_parameter_hover_data, target_scatter_parameter_hover_data", @@ -207,8 +207,8 @@ def test_one_parameter_one_target_NONE_list( # Run action by picking the above added action function and executing it with () result = model_manager[f"{PARAMETER_ACTION_PREFIX}_test_parameter"].function() - assert result["scatter_chart"] == target_scatter_parameter_hover_data - assert "box_chart" not in result + assert result.scatter_chart == target_scatter_parameter_hover_data + assert not hasattr(result, "box_chart") @pytest.mark.parametrize( "callback_context_parameter_y, target_scatter_parameter_y, target_box_parameter_y", @@ -235,8 +235,8 @@ def test_one_parameter_multiple_targets( # Run action by picking the above added action function and executing it with () result = model_manager[f"{PARAMETER_ACTION_PREFIX}_test_parameter"].function() - assert result["scatter_chart"] == target_scatter_parameter_y - assert result["box_chart"] == target_box_parameter_y + assert result.scatter_chart == target_scatter_parameter_y + assert result.box_chart == target_box_parameter_y @pytest.mark.parametrize( "callback_context_parameter_y_and_x, target_scatter_parameter_y_and_x", @@ -268,8 +268,8 @@ def test_multiple_parameters_one_target( # Run action by picking the above added action function and executing it with () result = model_manager[f"{PARAMETER_ACTION_PREFIX}_test_parameter_x"].function() - assert result["scatter_chart"] == target_scatter_parameter_y_and_x - assert "box_chart" not in result + assert result.scatter_chart == target_scatter_parameter_y_and_x + assert not hasattr(result, "box_chart") @pytest.mark.parametrize( "callback_context_parameter_y_and_x, target_scatter_parameter_y_and_x, target_box_parameter_y_and_x", @@ -305,8 +305,8 @@ def test_multiple_parameters_multiple_targets( # Run action by picking the above added action function and executing it with () result = model_manager[f"{PARAMETER_ACTION_PREFIX}_test_parameter_x"].function() - assert result["scatter_chart"] == target_scatter_parameter_y_and_x - assert result["box_chart"] == target_box_parameter_y_and_x + assert result.scatter_chart == target_scatter_parameter_y_and_x + assert result.box_chart == target_box_parameter_y_and_x @pytest.mark.parametrize( "callback_context_parameter_y_and_x, target_scatter_parameter_y_and_x, target_box_parameter_y_and_x", @@ -341,9 +341,9 @@ def test_one_parameter_per_target_multiple_attributes( # Run action by picking the above added action function and executing it with () result = model_manager[f"{PARAMETER_ACTION_PREFIX}_test_parameter_scatter"].function() - assert result["scatter_chart"] == target_scatter_parameter_y_and_x - assert "box_chart" not in target_scatter_parameter_y_and_x + assert result.scatter_chart == target_scatter_parameter_y_and_x + assert not hasattr(target_scatter_parameter_y_and_x, "box_chart") result = model_manager[f"{PARAMETER_ACTION_PREFIX}_test_parameter_box"].function() - assert result["box_chart"] == target_box_parameter_y_and_x - assert "scatter_chart" not in target_scatter_parameter_y_and_x + assert result.box_chart == target_box_parameter_y_and_x + assert not hasattr(target_scatter_parameter_y_and_x, "scatter_chart") diff --git a/vizro-core/tests/unit/vizro/models/_action/test_action.py b/vizro-core/tests/unit/vizro/models/_action/test_action.py index 088f6b495..74164b642 100644 --- a/vizro-core/tests/unit/vizro/models/_action/test_action.py +++ b/vizro-core/tests/unit/vizro/models/_action/test_action.py @@ -2,6 +2,7 @@ import json import sys +from collections import namedtuple import dash import plotly @@ -244,7 +245,27 @@ def test_get_callback_mapping_with_inputs_and_outputs( # pylint: disable=too-ma ( {"component_1_property": "new_value"}, ["component_1_property"], - {"action_finished": None, "component_1_property": "new_value"}, + {"action_finished": None, "component_1_property": {"component_1_property": "new_value"}}, + ), + ( + ({"component_1_property": "new_value", "component_2_property": "new_value_2"}), + ["component_1_property"], + { + "action_finished": None, + "component_1_property": { + "component_1_property": "new_value", + "component_2_property": "new_value_2", + }, + }, + ), + ( + ({"component_1_property": "new_value"}, {"component_2_property": "new_value_2"}), + ["component_1_property", "component_2_property"], + { + "action_finished": None, + "component_1_property": {"component_1_property": "new_value"}, + "component_2_property": {"component_2_property": "new_value_2"}, + }, ), ], indirect=["custom_action_function_mock_return", "callback_context_set_outputs_grouping"], @@ -256,6 +277,30 @@ def test_action_callback_function_return_value_valid( result = action._action_callback_function() assert result == expected_function_return_value + @pytest.mark.parametrize( + "custom_action_function_mock_return, callback_context_set_outputs_grouping, expected_function_return_value", + [ + # custom action function return value - namedtuple + ( + (namedtuple("outputs", ["component_1_property"])("new_value")), + ["component_1_property"], + {"action_finished": None, "component_1_property": "new_value"}, + ), + ( + (namedtuple("outputs", ["component_1_property", "component_2_property"])("new_value", "new_value_2")), + ["component_1_property", "component_2_property"], + {"action_finished": None, "component_1_property": "new_value", "component_2_property": "new_value_2"}, + ), + ], + indirect=["custom_action_function_mock_return", "callback_context_set_outputs_grouping"], + ) + def test_action_callback_function_return_value_valid_namedtuple( + self, custom_action_function_mock_return, callback_context_set_outputs_grouping, expected_function_return_value + ): + action = Action(function=custom_action_function_mock_return()) + result = action._action_callback_function() + assert result == expected_function_return_value + @pytest.mark.parametrize( "custom_action_function_mock_return, callback_context_set_outputs_grouping", [ @@ -267,7 +312,10 @@ def test_action_callback_function_return_value_valid( (["new_value", "new_value_2"], ["component_1_property"]), ({"component_1_property": "new_value"}, []), ({"component_1_property": "new_value"}, ["component_1_property", "component_2_property"]), - ({"component_1_property": "new_value", "component_2_property": "new_value_2"}, ["component_1_property"]), + ( + {"component_1_property": "new_value", "component_2_property": "new_value_2"}, + ["component_1_property", "component_2_property"], + ), ], indirect=True, ) @@ -277,7 +325,31 @@ def test_action_callback_function_return_value_invalid( action = Action(function=custom_action_function_mock_return()) with pytest.raises( ValueError, - match="Number of action's returned elements .(.?.)" - " does not match the number of action's defined outputs .(.?.).", + match="Number of action's returned elements \\(.?\\)" + " does not match the number of action's defined outputs \\(.?\\).", + ): + action._action_callback_function() + + @pytest.mark.parametrize( + "custom_action_function_mock_return, callback_context_set_outputs_grouping", + [ + ( + (namedtuple("outputs", ["component_1_property"])("new_value")), + [], + ), + ( + (namedtuple("outputs", ["component_1_property", "component_2_property"])("new_value", "new_value_2")), + ["component_1_property", "component_2_property", "component_3_property"], + ), + ], + indirect=True, + ) + def test_action_callback_function_return_value_invalid_namedtuple( + self, custom_action_function_mock_return, callback_context_set_outputs_grouping + ): + action = Action(function=custom_action_function_mock_return()) + with pytest.raises( + ValueError, + match="Action's returned fields \\{.*\\}" " does not match the action's defined outputs \\{.*\\}.", ): action._action_callback_function()