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..bff209d30 --- /dev/null +++ b/vizro-core/changelog.d/20231122_143116_petar_pejovic_enable_custom_actions.md @@ -0,0 +1,46 @@ + + +### Highlights ✨ + +- Release of custom actions. Visit the [user guide on custom actions](https://vizro.readthedocs.io/en/stable/pages/user_guides/custom_actions/) to learn more. ([#178](https://github.com/mckinsey/vizro/pull/178)) + + + + + + + diff --git a/vizro-core/docs/assets/user_guides/custom_actions/custom_action_inputs_outputs.png b/vizro-core/docs/assets/user_guides/custom_actions/custom_action_inputs_outputs.png new file mode 100644 index 000000000..c3580904c Binary files /dev/null and b/vizro-core/docs/assets/user_guides/custom_actions/custom_action_inputs_outputs.png differ diff --git a/vizro-core/docs/assets/user_guides/custom_actions/custom_action_multiple_return_values.png b/vizro-core/docs/assets/user_guides/custom_actions/custom_action_multiple_return_values.png new file mode 100644 index 000000000..624f56c67 Binary files /dev/null and b/vizro-core/docs/assets/user_guides/custom_actions/custom_action_multiple_return_values.png differ diff --git a/vizro-core/docs/pages/user_guides/actions.md b/vizro-core/docs/pages/user_guides/actions.md index d76b3ea77..cb52bca81 100644 --- a/vizro-core/docs/pages/user_guides/actions.md +++ b/vizro-core/docs/pages/user_guides/actions.md @@ -1,12 +1,13 @@ # How to use Actions -This guide shows you how to use `Actions`, a new concept in Vizro that is similar, but not identical to [`Callbacks`](https://dash.plotly.com/basic-callbacks) in `Dash`. Many components of a dashboard (eg. [`Graph`][vizro.models.Graph] or [`Button`][vizro.models.Button]) have an optional +This guide shows you how to use actions, a concept that is similar, but not identical, to [ +callbacks](https://dash.plotly.com/basic-callbacks) in `Dash`. Many components of a dashboard (e.g. [`Graph`][vizro.models.Graph] or [`Button`][vizro.models.Button]) have an optional `actions` argument, where you can enter the [`Action`][vizro.models.Action] model. -In a nutshell, using the [`Action`][vizro.models.Action] model together with an `action function` allows you to create complex functionality on a variety of triggers in your dashboard. -There is already a range of reusable `action functions` available. +In a nutshell, using the [`Action`][vizro.models.Action] model together with an action function allows you to create complex functionality on a variety of triggers in your dashboard. +There is already a range of reusable action functions available. -???+ info "Overview of currently available pre-defined `action functions`" +???+ info "Overview of currently available pre-definedaction functions" - [`export_data`][vizro.actions.export_data] - [`filter_interaction`][vizro.actions.filter_interaction] @@ -14,12 +15,12 @@ There is already a range of reusable `action functions` available. ## Pre-defined actions To attach an action to a component, you must enter the [`Action`][vizro.models.Action] model into the component's `action` argument. You can then -add a desired pre-defined `action function` into the `function` argument of the [`Action`][vizro.models.Action]. +add a desired pre-defined action function into the `function` argument of the [`Action`][vizro.models.Action]. ??? note "Note on `Trigger`" Currently each component has one pre-defined trigger property. A trigger property is an attribute of the component that triggers a configured action (e.g. for the `Button` it is `n_click`). -The below sections are guides on how to leverage pre-defined action functions +The below sections are guides on how to leverage pre-defined action functions. ### Export data @@ -104,7 +105,7 @@ a result, when a dashboard user now clicks the button, all data on the page will ### Filter data by clicking on chart -To enable filtering when clicking on data in a (source) chart, you can add the [`filter_interaction`][vizro.actions.filter_interaction] action function to the [`Graph`][vizro.models.Graph] or [`Table`][vizro.models.Table] component. The [`filter_interaction`][vizro.actions.filter_interaction] is currently configured to be triggered on click only. +To enable filtering when clicking on data in a source chart, you can add the [`filter_interaction`][vizro.actions.filter_interaction] action function to the [`Graph`][vizro.models.Graph] or [`Table`][vizro.models.Table] component. The [`filter_interaction`][vizro.actions.filter_interaction] is currently configured to be triggered on click only. To configure this chart interaction follow the steps below: @@ -124,7 +125,7 @@ Selecting a data point with a corresponding value of "Africa" in the continent c Here is an example of how to configure a chart interaction when the source is a [`Graph`][vizro.models.Graph] component. -!!! example "`filter_interaction`" +!!! example "Graph `filter_interaction`" === "app.py" ```py import vizro.models as vm @@ -210,7 +211,7 @@ Here is an example of how to configure a chart interaction when the source is a Here is an example of how to configure a chart interaction when the source is a [`Table`][vizro.models.Table] component. -!!! example "`filter_interaction`" +!!! example "Table `filter_interaction`" === "app.py" ```py import vizro.models as vm @@ -285,8 +286,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,11 +385,4 @@ 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!" - -!!! warning - - When creating your own custom action functions (as this is already possible without official support), you are responsible for the security of your creation. Vizro cannot guarantee - the security of custom created action functions, so make sure you keep this in mind when publicly deploying your dashboard. +To enhance existing actions, please see our How-to-guide on creating [custom actions](custom_actions.md). diff --git a/vizro-core/docs/pages/user_guides/custom_actions.md b/vizro-core/docs/pages/user_guides/custom_actions.md new file mode 100644 index 000000000..2a5fa20cd --- /dev/null +++ b/vizro-core/docs/pages/user_guides/custom_actions.md @@ -0,0 +1,236 @@ +# How to create custom actions + +If you want to use the [`Action`][vizro.models.Action] model to perform functions that are not available in the [pre-defined action functions][vizro.actions], you can create your own custom action. +Like other [actions](actions.md), custom actions could also be added as an element inside the [actions chain](actions.md#actions-chaining), and it can be triggered with one of many dashboard components. + +### Simple custom actions + +Custom actions enable you to implement your own action function. Simply do the following: + +1. define a function +2. decorate it with the `@capture("action")` decorator +3. add it as a `function` argument inside the [`Action`][vizro.models.Action] model + +The following example shows how to create a custom action that postpones execution of the next action in the chain for `t` seconds. + +!!! example "Simple custom action" + === "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(t: int): + """Custom action.""" + sleep(t) + + + 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(t=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 + ``` + + +### Interacting with dashboard inputs and outputs +When a custom action needs to interact with the dashboard, it is possible to define `inputs` and `outputs` for the custom action. + +- `inputs` represents dashboard component properties whose values are passed to the custom action function as arguments. It is a list of strings in the format `"."` (e.g. `"scatter_chart.clickData`"). These correspond to function arguments in the format `_` (e.g. `scatter_chart_clickData`). +- `outputs` represents dashboard component properties corresponding to the custom action function return value(s). Similar to `inputs`, it is a list of strings in the format `"."` (e.g. `"my_card.children"`). + +The following example shows how to create a custom action that shows the clicked chart data in a [`Card`][vizro.models.Card] component. For further information on the structure and content of the `clickData` property, refer to the Dash documentation on [interactive visualizations](https://dash.plotly.com/interactive-graphing). + +!!! example "Custom action with dashboard inputs and outputs" + === "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(show_species: bool, scatter_chart_clickData: dict): + """Custom action.""" + clicked_point = scatter_chart_clickData["points"][0] + x, y = clicked_point["x"], clicked_point["y"] + text = f"Clicked point has sepal length {x}, petal width {y}" + + if show_species: + species = clicked_point["customdata"][0] + text += f" and species {species}" + return text + + + df = px.data.iris() + + page = vm.Page( + title="Example of a custom action with UI inputs and outputs", + layout=vm.Layout( + grid=[ + [0, 2], + [0, 2], + [0, 2], + [1, -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(show_species=True), + inputs=["scatter_chart.clickData"], + outputs=["my_card.children"], + ), + ], + ), + vm.Card(id="my_card", text="Click on a point on the above graph."), + 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 + ``` + === "Result" + [![CustomAction]][CustomAction] + + [CustomAction]: ../../assets/user_guides/custom_actions/custom_action_inputs_outputs.png + +### Multiple return values +The return value of the custom action function is propagated to the dashboard components that are defined in the `outputs` argument of the [`Action`][vizro.models.Action] model. +If there is a single `output` defined, the function return value is directly assigned to the component property. +If there are multiple `outputs` defined, the return value is iterated and assigned to the respective component properties, in line with Python's flexibility in managing multiple return values. + +!!! example "Custom action with multiple return values" + === "app.py" + ```py + import vizro.models as vm + import vizro.plotly.express as px + from vizro import Vizro + from vizro.models.types import capture + + + @capture("action") + def my_custom_action(scatter_chart_clickData: dict): + """Custom action.""" + clicked_point = scatter_chart_clickData["points"][0] + x, y = clicked_point["x"], clicked_point["y"] + species = clicked_point["customdata"][0] + card_1_text = f"Clicked point has sepal length {x}, petal width {y}" + card_2_text = f"Clicked point has species {species}" + return card_1_text, card_2_text # (1)! + + + 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, 0], + [0, 0], + [1, 2], + ], + 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=my_custom_action(), + inputs=["scatter_chart.clickData"], + outputs=["my_card_1.children", "my_card_2.children"], # (2)! + ), + ], + ), + vm.Card(id="my_card_1", text="Click on a point on the above graph."), + vm.Card(id="my_card_2", text="Click on a point on the above graph."), + ], + controls=[vm.Filter(column="species", selector=vm.Dropdown(title="Species"))], + ) + + dashboard = vm.Dashboard(pages=[page]) + + Vizro().build(dashboard).run() + ``` + + 1. `my_custom_action` returns two values (which will be in Python tuple). + 2. These values are assigned to the `outputs` in the same order. + === "app.yaml" + ```yaml + # Custom action are currently only possible via python configuration + ``` + === "Result" + [![CustomAction2]][CustomAction2] + + [CustomAction2]: ../../assets/user_guides/custom_actions/custom_action_multiple_return_values.png + +If your action has many outputs, it can be fragile to rely on their ordering. To refer to outputs by name instead, you can return a [`collections.abc.namedtuple`](https://docs.python.org/3/library/collections.html#namedtuple-factory-function-for-tuples-with-named-fields) in which the fields are named in the format `_`. Here is what the custom action function from the previous example would look like: +```py hl_lines="11-13" +from collections import namedtuple + +@capture("action") +def my_custom_action(scatter_chart_clickData: dict): + """Custom action.""" + clicked_point = scatter_chart_clickData["points"][0] + x, y = clicked_point["x"], clicked_point["y"] + species = clicked_point["customdata"][0] + card_1_text = f"Clicked point has sepal length {x}, petal width {y}" + card_2_text = f"Clicked point has species {species}" + return namedtuple("CardsText", "my_card_1_children, my_card_2_children")( + my_card_1_children=card_1_text, my_card_2_children=card_2_text + ) +``` + +!!! warning + + Please note that users of this package are responsible for the content of any custom action function that they write - especially with regard to leaking any sensitive information or exposing to any security threat during implementation. You should always [treat the content of user input as untrusted](https://community.plotly.com/t/writing-secure-dash-apps-community-thread/54619). diff --git a/vizro-core/docs/pages/user_guides/custom_tables.md b/vizro-core/docs/pages/user_guides/custom_tables.md new file mode 100644 index 000000000..6578c5f5c --- /dev/null +++ b/vizro-core/docs/pages/user_guides/custom_tables.md @@ -0,0 +1,75 @@ +# How to create custom tables + +If you want to use the [`Table`][vizro.models.Table] model to and to create a custom [table](table.md) you can create your own custom table, e.g. when requiring computations that can be controlled by parameters. + +For this, similar to how one would create a [custom chart](../user_guides/custom_charts.md), simply do the following: + +- define a function that returns a `dash_table.DataTable` object +- decorate it with the `@capture("table")` decorator +- the function must accept a `data_frame` argument (of type `pandas.DataFrame`) +- the table should be derived from and require only one `pandas.DataFrame` (e.g. any further dataframes added through other arguments will not react to dashboard components such as `Filter`) + + +The following example shows a possible version of a custom table. In this case the argument `chosen_columns` was added, which you can control with a parameter: + +??? example "Custom Dash DataTable" + === "app.py" + ```py + from typing import List + + from dash import dash_table + + import vizro.models as vm + import vizro.plotly.express as px + from vizro import Vizro + from vizro.models.types import capture + + df = px.data.gapminder().query("year == 2007") + + + @capture("table") + def my_custom_table(data_frame=None, chosen_columns: List[str] = None): + """Custom table.""" + columns = [{"name": i, "id": i} for i in chosen_columns] + defaults = { + "style_as_list_view": True, + "style_data": {"border_bottom": "1px solid var(--border-subtle-alpha-01)", "height": "40px"}, + "style_header": { + "border_bottom": "1px solid var(--state-overlays-selected-hover)", + "border_top": "1px solid var(--main-container-bg-color)", + "height": "32px", + }, + } + return dash_table.DataTable(data=data_frame.to_dict("records"), columns=columns, **defaults) + + + page = vm.Page( + title="Example of a custom Dash DataTable", + components=[ + vm.Table( + id="custom_table", + title="Custom Dash DataTable", + figure=my_custom_table( + data_frame=df, chosen_columns=["country", "continent", "lifeExp", "pop", "gdpPercap"] + ), + ), + ], + controls=[ + vm.Parameter( + targets=["custom_table.chosen_columns"], + selector=vm.Dropdown(title="Choose columns", options=df.columns.to_list(), multi=True), + ) + ], + ) + dashboard = vm.Dashboard(pages=[page]) + + Vizro().build(dashboard).run() + ``` + === "app.yaml" + ```yaml + # Custom tables are currently only possible via python configuration + ``` + === "Result" + [![Table3]][Table3] + + [Table3]: ../../assets/user_guides/table/custom_table.png diff --git a/vizro-core/docs/pages/user_guides/table.md b/vizro-core/docs/pages/user_guides/table.md index 81304b2b7..68e64f28e 100755 --- a/vizro-core/docs/pages/user_guides/table.md +++ b/vizro-core/docs/pages/user_guides/table.md @@ -209,79 +209,4 @@ an example of a styled table where some conditional formatting is applied. There [Table2]: ../../assets/user_guides/table/styled_table.png -#### Custom Table - -In case you want to add custom logic to a Dash DataTable, e.g. when requiring computations that can be controlled by parameters, it is possible to -create a custom Dash DataTable in Vizro. - -For this, similar to how one would create a [custom chart](../user_guides/custom_charts.md), simply do the following: - -- define a function that returns a `dash_table.DataTable` object -- decorate it with the `@capture("table")` decorator -- the function must accept a `data_frame` argument (of type `pandas.DataFrame`) -- the table should be derived from and require only one `pandas.DataFrame` (e.g. any further dataframes added through other arguments will not react to dashboard components such as `Filter`) - - -The following example shows a possible version of a custom table. In this case the argument `chosen_columns` was added, which you can control with a parameter: - -??? example "Custom Dash DataTable" - === "app.py" - ```py - from typing import List - - from dash import dash_table - - import vizro.models as vm - import vizro.plotly.express as px - from vizro import Vizro - from vizro.models.types import capture - - df = px.data.gapminder().query("year == 2007") - - - @capture("table") - def my_custom_table(data_frame=None, chosen_columns: List[str] = None): - """Custom table.""" - columns = [{"name": i, "id": i} for i in chosen_columns] - defaults = { - "style_as_list_view": True, - "style_data": {"border_bottom": "1px solid var(--border-subtle-alpha-01)", "height": "40px"}, - "style_header": { - "border_bottom": "1px solid var(--state-overlays-selected-hover)", - "border_top": "1px solid var(--main-container-bg-color)", - "height": "32px", - }, - } - return dash_table.DataTable(data=data_frame.to_dict("records"), columns=columns, **defaults) - - - page = vm.Page( - title="Example of a custom Dash DataTable", - components=[ - vm.Table( - id="custom_table", - title="Custom Dash DataTable", - figure=my_custom_table( - data_frame=df, chosen_columns=["country", "continent", "lifeExp", "pop", "gdpPercap"] - ), - ), - ], - controls=[ - vm.Parameter( - targets=["custom_table.chosen_columns"], - selector=vm.Dropdown(title="Choose columns", options=df.columns.to_list(), multi=True), - ) - ], - ) - dashboard = vm.Dashboard(pages=[page]) - - Vizro().build(dashboard).run() - ``` - === "app.yaml" - ```yaml - # Custom tables are currently only possible via python configuration - ``` - === "Result" - [![Table3]][Table3] - - [Table3]: ../../assets/user_guides/table/custom_table.png +To enhance existing tables, please see our How-to-guide on creating [custom tables](custom_tables.md). diff --git a/vizro-core/mkdocs.yml b/vizro-core/mkdocs.yml index affa09d2c..bff2f8dc2 100644 --- a/vizro-core/mkdocs.yml +++ b/vizro-core/mkdocs.yml @@ -33,7 +33,9 @@ nav: - Integrations: pages/user_guides/integration.md - Extensions: - Custom Charts: pages/user_guides/custom_charts.md + - Custom Tables: pages/user_guides/custom_tables.md - Custom Components: pages/user_guides/custom_components.md + - Custom Actions: pages/user_guides/custom_actions.md - API reference: - Vizro: pages/API_reference/vizro.md - Models: pages/API_reference/models.md diff --git a/vizro-core/src/vizro/actions/_actions_utils.py b/vizro-core/src/vizro/actions/_actions_utils.py index 4f4363203..b3d153d6a 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 @@ -57,7 +57,11 @@ def _apply_filters( selector_actions = _get_component_actions(model_manager[ctd["id"]]) for action in selector_actions: - if target not in action.function["targets"] or ALL_OPTION in selector_value: + if ( + action.function._function.__name__ != "_filter" + or target not in action.function["targets"] + or ALL_OPTION in selector_value + ): continue _filter_function = action.function["filter_function"] @@ -85,7 +89,7 @@ def _apply_graph_filter_interaction( customdata = ctd_click_data["value"]["points"][0]["customdata"] 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]])] @@ -119,7 +123,7 @@ def _apply_table_filter_interaction( source_table_actions = _get_component_actions(_get_parent_vizro_model(ctd_active_cell["id"])) for action in source_table_actions: - if target not in action.function["targets"]: + if action.function._function.__name__ != "filter_interaction" or target not in action.function["targets"]: continue column = ctd_active_cell["value"]["column_id"] derived_viewport_data_row = ctd_active_cell["value"]["row"] @@ -203,7 +207,7 @@ def _get_parametrized_config( for action in selector_actions: action_targets = _create_target_arg_mapping(action.function["targets"]) - if target not in action_targets: + if action.function._function.__name__ != "_parameter" or target not in action_targets: continue for action_targets_arg in action_targets[target]: @@ -247,7 +251,7 @@ def _get_modified_page_figures( ctds_filter_interaction: List[Dict[str, CallbackTriggerDict]], ctds_parameters: List[CallbackTriggerDict], targets: Optional[List[ModelID]] = None, -) -> Dict[ModelID, Any]: +) -> Tuple[Any, ...]: if not targets: targets = [] filtered_data = _get_filtered_data( @@ -261,10 +265,10 @@ 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] ) - 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 2e55f12da..8ceb74742 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 bd3a2b596..8acf26fa4 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 ca492907a..31b66092b 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 defc5d5aa..b236b0f12 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..9a8614fc3 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 fd2392617..778173e9c 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..e0df862c8 100644 --- a/vizro-core/src/vizro/models/_action/_action.py +++ b/vizro-core/src/vizro/models/_action/_action.py @@ -1,8 +1,9 @@ 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 +from dash import Input, Output, State, callback, html from pydantic import Field, validator import vizro.actions @@ -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. @@ -106,32 +94,46 @@ def _get_callback_mapping(self): return callback_inputs, callback_outputs, action_components - def _action_callback_function(self, **inputs: Dict[str, Any]) -> Dict[str, Any]: + def _action_callback_function(self, inputs: Dict[str, Any], outputs: List[str]) -> Dict[str, Any]: logger.debug("=============== ACTION ===============") logger.debug(f'Action ID: "{self.id}"') logger.debug(f'Action name: "{self.function._function.__name__}"') logger.debug(f"Action inputs: {inputs}") # Invoking the action's function - 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} + return_value = self.function(**inputs) + + if return_value is None and len(outputs) == 0: + # Action has no outputs and the custom action function returns None. + # Special case results with no exception. + return_dict = {} + elif 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) or {}}." + ) + return_dict = return_value._asdict() + elif len(outputs) == 1: + # If the action has only one output, so assign the entire return_value to the output. + # This ensures consistent handling regardless of the type or structure of the return_value. + return_dict = {outputs[0]: return_value} + else: + if not isinstance(return_value, Collection) or len(return_value) == 0: + # If return_value is not a collection or is an empty collection, + # create a new collection from it. This ensures handling of return values like None, True, 1 etc. + # and treats an empty collection as a 1-length 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 return_dict @_log_call def build(self): @@ -156,7 +158,10 @@ def build(self): @callback(output=callback_outputs, inputs=callback_inputs, prevent_initial_call=True) def callback_wrapper(trigger: None, **inputs: Dict[str, Any]) -> Dict[str, Any]: - return self._action_callback_function(**inputs) + outputs = list(callback_outputs.keys()) + outputs.remove("action_finished") + return_dict = self._action_callback_function(inputs=inputs, outputs=outputs) + return {"action_finished": None, **return_dict} return html.Div( children=action_components, diff --git a/vizro-core/src/vizro/models/types.py b/vizro-core/src/vizro/models/types.py index f33684057..eb6e887af 100644 --- a/vizro-core/src/vizro/models/types.py +++ b/vizro-core/src/vizro/models/types.py @@ -178,7 +178,7 @@ class capture: """Captures a function call to create a [`CapturedCallable`][vizro.models.types.CapturedCallable]. This is used to add the functionality required to make graphs and actions work in a dashboard. - Typically it should be used as a function decorator. There are three possible modes: `"graph"`, `"table"` and + Typically, it should be used as a function decorator. There are three possible modes: `"graph"`, `"table"` and `"action"`. Examples: @@ -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 ead84198a..a2bacb397 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 @@ -1,3 +1,5 @@ +from collections import namedtuple + import pytest from dash._callback_context import context_value from dash._utils import AttributeDict @@ -113,7 +115,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 ], } @@ -130,8 +132,10 @@ 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() + # TODO: DO WE NEED TO USED ELLIPSIS INSTEAD OF OUTPUTS HERE? + expected = namedtuple("Outputs", [])() - assert result == {} + assert result == expected @pytest.mark.usefixtures("managers_one_page_two_graphs_one_button") @pytest.mark.parametrize( @@ -143,12 +147,24 @@ 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() + expected = namedtuple("Outputs", ["download_dataframe_box_chart", "download_dataframe_scatter_chart"])( + **{ + "download_dataframe_scatter_chart": { + "filename": "scatter_chart.csv", + "content": gapminder_2007.to_csv(index=False), + "type": None, + "base64": False, + }, + "download_dataframe_box_chart": { + "filename": "box_chart.csv", + "content": gapminder_2007.to_csv(index=False), + "type": None, + "base64": 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._asdict() == expected._asdict() @pytest.mark.usefixtures("managers_one_page_two_graphs_one_button") @pytest.mark.parametrize( @@ -165,12 +181,24 @@ 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() + expected = namedtuple("Outputs", ["download_dataframe_box_chart", "download_dataframe_scatter_chart"])( + **{ + "download_dataframe_scatter_chart": { + "filename": "scatter_chart.csv", + "content": gapminder_2007.to_csv(index=False), + "type": None, + "base64": False, + }, + "download_dataframe_box_chart": { + "filename": "box_chart.csv", + "content": gapminder_2007.to_csv(index=False), + "type": None, + "base64": 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._asdict() == expected._asdict() @pytest.mark.usefixtures("managers_one_page_two_graphs_one_button") @pytest.mark.parametrize("callback_context_export_data", [(["scatter_chart"], None, None, None)], indirect=True) @@ -180,11 +208,18 @@ 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() + expected = namedtuple("Outputs", ["download_dataframe_scatter_chart"])( + **{ + "download_dataframe_scatter_chart": { + "filename": "scatter_chart.csv", + "content": gapminder_2007.to_csv(index=False), + "type": None, + "base64": 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 result._asdict() == expected._asdict() @pytest.mark.usefixtures("managers_one_page_two_graphs_one_button") @pytest.mark.parametrize( @@ -198,12 +233,24 @@ 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() + expected = namedtuple("Outputs", ["download_dataframe_box_chart", "download_dataframe_scatter_chart"])( + **{ + "download_dataframe_scatter_chart": { + "filename": "scatter_chart.csv", + "content": gapminder_2007.to_csv(index=False), + "type": None, + "base64": False, + }, + "download_dataframe_box_chart": { + "filename": "box_chart.csv", + "content": gapminder_2007.to_csv(index=False), + "type": None, + "base64": 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._asdict() == expected._asdict() @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) @@ -262,14 +309,24 @@ 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() + expected = namedtuple("Outputs", ["download_dataframe_box_chart", "download_dataframe_scatter_chart"])( + **{ + "download_dataframe_scatter_chart": { + "filename": "scatter_chart.csv", + "content": target_scatter_filter_and_filter_interaction.to_csv(index=False), + "type": None, + "base64": False, + }, + "download_dataframe_box_chart": { + "filename": "box_chart.csv", + "content": target_box_filtered_pop.to_csv(index=False), + "type": None, + "base64": False, + }, + } + ) - 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._asdict() == expected._asdict() @pytest.mark.usefixtures("managers_one_page_two_graphs_one_table_one_button") @pytest.mark.parametrize( @@ -317,11 +374,21 @@ 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() + expected = namedtuple("Outputs", ["download_dataframe_box_chart", "download_dataframe_scatter_chart"])( + **{ + "download_dataframe_scatter_chart": { + "filename": "scatter_chart.csv", + "content": target_scatter_filter_and_filter_interaction.to_csv(index=False), + "type": None, + "base64": False, + }, + "download_dataframe_box_chart": { + "filename": "box_chart.csv", + "content": target_box_filtered_pop.to_csv(index=False), + "type": None, + "base64": False, + }, + } + ) - 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._asdict() == expected._asdict() 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..863fbd1ec 100644 --- a/vizro-core/tests/unit/vizro/actions/test_filter_action.py +++ b/vizro-core/tests/unit/vizro/actions/test_filter_action.py @@ -1,3 +1,5 @@ +from collections import namedtuple + import pytest from dash._callback_context import context_value from dash._utils import AttributeDict @@ -118,9 +120,14 @@ 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() + expected = namedtuple("Outputs", ["box_chart", "scatter_chart"])( + **{ + "scatter_chart": target_scatter_filtered_continent, + "box_chart": target_box_filtered_continent, + } + ) - assert result["scatter_chart"] == target_scatter_filtered_continent - assert result["box_chart"] == target_box_filtered_continent + assert result._asdict() == expected._asdict() @pytest.mark.parametrize( "callback_context_filter_continent,target_scatter_filtered_continent", @@ -143,9 +150,13 @@ 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() + expected = namedtuple("Outputs", ["scatter_chart"])( + **{ + "scatter_chart": target_scatter_filtered_continent, + } + ) - assert result["scatter_chart"] == target_scatter_filtered_continent - assert "box_chart" not in result + assert result._asdict() == expected._asdict() @pytest.mark.parametrize( "callback_context_filter_continent,target_scatter_filtered_continent,target_box_filtered_continent", @@ -172,9 +183,14 @@ 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() + expected = namedtuple("Outputs", ["box_chart", "scatter_chart"])( + **{ + "scatter_chart": target_scatter_filtered_continent, + "box_chart": target_box_filtered_continent, + } + ) - assert result["scatter_chart"] == target_scatter_filtered_continent - assert result["box_chart"] == target_box_filtered_continent + assert result._asdict() == expected._asdict() @pytest.mark.parametrize( "callback_context_filter_continent_and_pop,target_scatter_filtered_continent_and_pop,target_box_filtered_continent_and_pop", @@ -207,9 +223,14 @@ 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() + expected = namedtuple("Outputs", ["box_chart", "scatter_chart"])( + **{ + "scatter_chart": target_scatter_filtered_continent_and_pop, + "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 + assert result._asdict() == expected._asdict() @pytest.mark.parametrize( "callback_context_filter_continent_and_pop,target_scatter_filtered_continent_and_pop", @@ -240,9 +261,13 @@ 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() + expected = namedtuple("Outputs", ["scatter_chart"])( + **{ + "scatter_chart": target_scatter_filtered_continent_and_pop, + } + ) - assert result["scatter_chart"] == target_scatter_filtered_continent_and_pop - assert "box_chart" not in result + assert result._asdict() == expected._asdict() @pytest.mark.parametrize( "callback_context_filter_continent_and_pop,target_scatter_filtered_continent_and_pop,target_box_filtered_continent_and_pop", @@ -283,6 +308,11 @@ 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() + expected = namedtuple("Outputs", ["box_chart", "scatter_chart"])( + **{ + "scatter_chart": target_scatter_filtered_continent_and_pop, + "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 + assert result._asdict() == expected._asdict() 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 c16950bfa..c9a6ed74e 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 @@ -1,3 +1,5 @@ +from collections import namedtuple + import plotly.express as px import pytest from dash._callback_context import context_value @@ -122,8 +124,9 @@ 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() + expected = namedtuple("Outputs", [])() - assert result == {} + assert result == expected @pytest.mark.xfail # This is the desired behavior, ie when no target is provided, then all charts filtered @pytest.mark.parametrize( @@ -146,9 +149,14 @@ 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() + expected = namedtuple("Outputs", ["box_chart", "scatter_chart"])( + **{ + "scatter_chart": target_scatter_filtered_continent, + "box_chart": target_box_filtered_continent, + } + ) - assert result["scatter_chart"] == target_scatter_filtered_continent - assert result["box_chart"] == target_box_filtered_continent + assert result._asdict() == expected._asdict() @pytest.mark.parametrize( "callback_context_filter_interaction,target_scatter_filtered_continent", @@ -171,8 +179,13 @@ 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() + expected = namedtuple("Outputs", ["scatter_chart"])( + **{ + "scatter_chart": target_scatter_filtered_continent, + } + ) - assert result["scatter_chart"] == target_scatter_filtered_continent + assert result._asdict() == expected._asdict() @pytest.mark.parametrize( "callback_context_filter_interaction,target_scatter_filtered_continent,target_box_filtered_continent", @@ -196,9 +209,14 @@ 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() + expected = namedtuple("Outputs", ["box_chart", "scatter_chart"])( + **{ + "scatter_chart": target_scatter_filtered_continent, + "box_chart": target_box_filtered_continent, + } + ) - assert result["scatter_chart"] == target_scatter_filtered_continent - assert result["box_chart"] == target_box_filtered_continent + assert result._asdict() == expected._asdict() @pytest.mark.xfail # This (or similar code) should raise a Value/Validation error explaining next steps @pytest.mark.parametrize("target", ["scatter_chart", ["scatter_chart"]]) @@ -237,8 +255,13 @@ 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() + expected = namedtuple("Outputs", ["scatter_chart"])( + **{ + "scatter_chart": target_scatter_filtered_continent, + } + ) - assert result["scatter_chart"] == target_scatter_filtered_continent + assert result._asdict() == expected._asdict() @pytest.mark.parametrize( "callback_context_filter_interaction, target_scatter_filtered_continent, target_box_filtered_continent", @@ -266,9 +289,14 @@ 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() + expected = namedtuple("Outputs", ["box_chart", "scatter_chart"])( + **{ + "scatter_chart": target_scatter_filtered_continent, + "box_chart": target_box_filtered_continent, + } + ) - assert result["scatter_chart"] == target_scatter_filtered_continent - assert result["box_chart"] == target_box_filtered_continent + assert result._asdict() == expected._asdict() @pytest.mark.parametrize( "callback_context_filter_interaction, target_scatter_filtered_continent, target_box_filtered_continent", @@ -296,9 +324,14 @@ 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() + expected = namedtuple("Outputs", ["box_chart", "scatter_chart"])( + **{ + "scatter_chart": target_scatter_filtered_continent, + "box_chart": target_box_filtered_continent, + } + ) - assert result["scatter_chart"] == target_scatter_filtered_continent - assert result["box_chart"] == target_box_filtered_continent + assert result._asdict() == expected._asdict() # 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 f9e3b70d1..24c1f0e31 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 @@ -1,3 +1,5 @@ +from collections import namedtuple + import pytest from dash._callback_context import context_value from dash._utils import AttributeDict @@ -93,23 +95,26 @@ def callback_context_on_page_load(request): class TestOnPageLoad: @pytest.mark.usefixtures("managers_one_page_two_graphs_one_button") @pytest.mark.parametrize( - "callback_context_on_page_load, target_scatter_filtered_continent_and_pop_parameter_y_and_x", + "callback_context_on_page_load, target_scatter_filtered_continent_and_pop_parameter_y_and_x, template", [ ( ["Africa", [10**6, 10**7], "pop", "continent", "vizro_dark"], ["Africa", [10**6, 10**7], "pop", "continent", "vizro_dark"], + "vizro_dark", ), ( ["Africa", [10**6, 10**7], "pop", "continent", "vizro_light"], ["Africa", [10**6, 10**7], "pop", "continent", "vizro_light"], + "vizro_light", ), ], - indirect=True, + indirect=["callback_context_on_page_load", "target_scatter_filtered_continent_and_pop_parameter_y_and_x"], ) def test_multiple_controls_one_target( self, callback_context_on_page_load, target_scatter_filtered_continent_and_pop_parameter_y_and_x, + template, box_chart, ): # Creating and adding a Filter objects to the existing Page @@ -148,7 +153,15 @@ 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 + box_chart.layout.template = template + expected = namedtuple("Outputs", ["box_chart", "scatter_chart"])( + **{ + "scatter_chart": target_scatter_filtered_continent_and_pop_parameter_y_and_x, + "box_chart": box_chart, + } + ) + + assert result._asdict() == expected._asdict() @pytest.mark.usefixtures("managers_one_page_two_graphs_one_button") @pytest.mark.parametrize( @@ -205,6 +218,11 @@ 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() + expected = namedtuple("Outputs", ["box_chart", "scatter_chart"])( + **{ + "scatter_chart": target_scatter_filtered_continent_and_pop_parameter_y_and_x, + "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 + assert result._asdict() == expected._asdict() 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..f1af5333c 100644 --- a/vizro-core/tests/unit/vizro/actions/test_parameter_action.py +++ b/vizro-core/tests/unit/vizro/actions/test_parameter_action.py @@ -1,3 +1,5 @@ +from collections import namedtuple + import pytest from dash._callback_context import context_value from dash._utils import AttributeDict @@ -170,9 +172,13 @@ 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() + expected = namedtuple("Outputs", ["scatter_chart"])( + **{ + "scatter_chart": target_scatter_parameter_y, + } + ) - assert result["scatter_chart"] == target_scatter_parameter_y - assert "box_chart" not in result + assert result._asdict() == expected._asdict() @pytest.mark.parametrize( "callback_context_parameter_hover_data, target_scatter_parameter_hover_data", @@ -206,9 +212,13 @@ 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() + expected = namedtuple("Outputs", ["scatter_chart"])( + **{ + "scatter_chart": target_scatter_parameter_hover_data, + } + ) - assert result["scatter_chart"] == target_scatter_parameter_hover_data - assert "box_chart" not in result + assert result._asdict() == expected._asdict() @pytest.mark.parametrize( "callback_context_parameter_y, target_scatter_parameter_y, target_box_parameter_y", @@ -234,9 +244,14 @@ 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() + expected = namedtuple("Outputs", ["box_chart", "scatter_chart"])( + **{ + "scatter_chart": target_scatter_parameter_y, + "box_chart": target_box_parameter_y, + } + ) - assert result["scatter_chart"] == target_scatter_parameter_y - assert result["box_chart"] == target_box_parameter_y + assert result._asdict() == expected._asdict() @pytest.mark.parametrize( "callback_context_parameter_y_and_x, target_scatter_parameter_y_and_x", @@ -267,9 +282,13 @@ 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() + expected = namedtuple("Outputs", ["scatter_chart"])( + **{ + "scatter_chart": target_scatter_parameter_y_and_x, + } + ) - assert result["scatter_chart"] == target_scatter_parameter_y_and_x - assert "box_chart" not in result + assert result._asdict() == expected._asdict() @pytest.mark.parametrize( "callback_context_parameter_y_and_x, target_scatter_parameter_y_and_x, target_box_parameter_y_and_x", @@ -304,9 +323,14 @@ 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() + expected = namedtuple("Outputs", ["box_chart", "scatter_chart"])( + **{ + "scatter_chart": target_scatter_parameter_y_and_x, + "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 + assert result._asdict() == expected._asdict() @pytest.mark.parametrize( "callback_context_parameter_y_and_x, target_scatter_parameter_y_and_x, target_box_parameter_y_and_x", @@ -341,9 +365,19 @@ 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 + expected = namedtuple("Outputs", ["scatter_chart"])( + **{ + "scatter_chart": target_scatter_parameter_y_and_x, + } + ) + + assert result._asdict() == expected._asdict() 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 + expected = namedtuple("Outputs", ["box_chart"])( + **{ + "box_chart": target_box_parameter_y_and_x, + } + ) + + assert result._asdict() == expected._asdict() 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..89ab74b84 100644 --- a/vizro-core/tests/unit/vizro/models/_action/test_action.py +++ b/vizro-core/tests/unit/vizro/models/_action/test_action.py @@ -2,13 +2,12 @@ import json import sys +from collections import namedtuple import dash import plotly import pytest from dash import html -from dash._callback_context import context_value -from dash._utils import AttributeDict from pydantic import ValidationError from vizro.actions import export_data @@ -45,17 +44,6 @@ def expected_get_callback_mapping_outputs(request): } -@pytest.fixture -def callback_context_set_outputs_grouping(request): - """Mock dash.callback_context that represents outputs grouping for custom action.""" - outputs = request.param - - outputs_grouping = dict.fromkeys(outputs) - mock_callback_context = {"outputs_grouping": {"action_finished": None, **outputs_grouping}} - context_value.set(AttributeDict(**mock_callback_context)) - return context_value - - @pytest.fixture def custom_action_build_expected(): return html.Div( @@ -222,62 +210,133 @@ def test_get_callback_mapping_with_inputs_and_outputs( # pylint: disable=too-ma assert action_components == [] @pytest.mark.parametrize( - "custom_action_function_mock_return, callback_context_set_outputs_grouping, expected_function_return_value", + "custom_action_function_mock_return, callback_outputs, expected_function_return_value", [ - # custom action function return value - None - (None, [], {"action_finished": None}), - # custom action function return value - single value - ("new_value", ["component_1_property"], {"action_finished": None, "component_1_property": "new_value"}), - # custom action function return value - list of values + # no outputs + (None, [], {}), + # single output + (None, ["component_1_property"], {"component_1_property": None}), + (False, ["component_1_property"], {"component_1_property": False}), + (0, ["component_1_property"], {"component_1_property": 0}), + (123, ["component_1_property"], {"component_1_property": 123}), + ("value", ["component_1_property"], {"component_1_property": "value"}), + ((), ["component_1_property"], {"component_1_property": ()}), + (("value"), ["component_1_property"], {"component_1_property": ("value")}), + (("value_1", "value_2"), ["component_1_property"], {"component_1_property": ("value_1", "value_2")}), + ([], ["component_1_property"], {"component_1_property": []}), + (["value"], ["component_1_property"], {"component_1_property": ["value"]}), + (["value_1", "value_2"], ["component_1_property"], {"component_1_property": ["value_1", "value_2"]}), + ({}, ["component_1_property"], {"component_1_property": {}}), + ({"key_1": "value_1"}, ["component_1_property"], {"component_1_property": {"key_1": "value_1"}}), ( - ["new_value", "new_value_2"], + {"key_1": "value_1", "key_2": "value_2"}, + ["component_1_property"], + {"component_1_property": {"key_1": "value_1", "key_2": "value_2"}}, + ), + # multiple outputs + ( + "ab", ["component_1_property", "component_2_property"], - {"action_finished": None, "component_1_property": "new_value", "component_2_property": "new_value_2"}, + {"component_1_property": "a", "component_2_property": "b"}, ), - # custom action function return value - tuple ( - ("new_value", "new_value_2"), + ("value_1", "value_2"), ["component_1_property", "component_2_property"], - {"action_finished": None, "component_1_property": "new_value", "component_2_property": "new_value_2"}, + {"component_1_property": "value_1", "component_2_property": "value_2"}, ), - # custom action function return value - dictionary ( - {"component_1_property": "new_value"}, + ["value_1", "value_2"], + ["component_1_property", "component_2_property"], + {"component_1_property": "value_1", "component_2_property": "value_2"}, + ), + ( + {"key_1": "value_1", "key_2": "value_2"}, + ["component_1_property", "component_2_property"], + {"component_1_property": "key_1", "component_2_property": "key_2"}, + ), + # single outputs + ( + (namedtuple("Outputs", ["component_1_property"])("new_value")), ["component_1_property"], - {"action_finished": None, "component_1_property": "new_value"}, + {"component_1_property": "new_value"}, + ), + # multiple outputs + ( + (namedtuple("Outputs", ["component_1_property", "component_2_property"])("new_value", "new_value_2")), + ["component_1_property", "component_2_property"], + {"component_1_property": "new_value", "component_2_property": "new_value_2"}, ), ], - indirect=["custom_action_function_mock_return", "callback_context_set_outputs_grouping"], + indirect=["custom_action_function_mock_return"], ) def test_action_callback_function_return_value_valid( - self, custom_action_function_mock_return, callback_context_set_outputs_grouping, expected_function_return_value + self, custom_action_function_mock_return, callback_outputs, expected_function_return_value ): action = Action(function=custom_action_function_mock_return()) - result = action._action_callback_function() + result = action._action_callback_function(inputs={}, outputs=callback_outputs) assert result == expected_function_return_value @pytest.mark.parametrize( - "custom_action_function_mock_return, callback_context_set_outputs_grouping", + "custom_action_function_mock_return, callback_outputs", [ - (None, ["component_1_property"]), - ("new_value", []), - ("new_value", ["component_1_property", "component_2_property"]), + (None, ["component_1_property", "component_2_property"]), + (False, []), + (0, []), + (123, []), + (123, ["component_1_property", "component_2_property"]), + ("", []), + ("ab", []), + ("ab", ["component_1_property", "component_2_property", "component_3_property"]), + ((), []), + (("new_value"), []), + (("new_value"), ["component_1_property", "component_2_property"]), + (("new_value", "new_value_2"), []), + (("new_value", "new_value_2"), ["component_1_property", "component_2_property", "component_3_property"]), + ([], []), (["new_value"], []), (["new_value"], ["component_1_property", "component_2_property"]), - (["new_value", "new_value_2"], ["component_1_property"]), + (["new_value", "new_value_2"], []), + (["new_value", "new_value_2"], ["component_1_property", "component_2_property", "component_3_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": "new_value", "component_2_property": "new_value_2"}, + ["component_1_property", "component_2_property", "component_3_property"], + ), + ], + indirect=["custom_action_function_mock_return"], + ) + def test_action_callback_function_return_value_invalid(self, custom_action_function_mock_return, callback_outputs): + 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 \\(.?\\).", + ): + action._action_callback_function(inputs={}, outputs=callback_outputs) + + @pytest.mark.parametrize( + "custom_action_function_mock_return, callback_outputs", + [ + ( + (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, + indirect=["custom_action_function_mock_return"], ) - def test_action_callback_function_return_value_invalid( - self, custom_action_function_mock_return, callback_context_set_outputs_grouping + def test_action_callback_function_return_value_invalid_namedtuple( + self, custom_action_function_mock_return, callback_outputs ): 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="Action's returned fields \\{.*\\}" " does not match the action's defined outputs \\{.*\\}.", ): - action._action_callback_function() + action._action_callback_function(inputs={}, outputs=callback_outputs)