From 87e76536094208a6916d6680f548cac5ea3aa216 Mon Sep 17 00:00:00 2001 From: Maximilian Schulz <83698606+maxschulz-COL@users.noreply.github.com> Date: Fri, 31 Jan 2025 08:54:39 +0100 Subject: [PATCH] [Tidy] Bump to Pydantic V2 (#917) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 7 +- pyproject.toml | 1 + ...30_145656_maximilian_schulz_pydantic_v2.md | 50 + .../pages/user-guides/custom-components.md | 28 +- vizro-core/examples/dev/app.py | 2 +- vizro-core/examples/scratch_dev/app.py | 26 +- .../visual-vocabulary/custom_components.py | 9 +- vizro-core/hatch.toml | 2 +- vizro-core/pyproject.toml | 2 +- .../{0.1.32.dev0.json => 0.2.0.dev0.json} | 2102 +++++++++-------- vizro-core/schemas/generate.py | 2 +- vizro-core/src/vizro/__init__.py | 2 +- .../src/vizro/actions/_actions_utils.py | 29 +- .../_callback_mapping_utils.py | 6 +- .../src/vizro/managers/_model_manager.py | 6 +- vizro-core/src/vizro/models/__init__.py | 24 +- .../src/vizro/models/_action/_action.py | 57 +- .../vizro/models/_action/_actions_chain.py | 15 +- vizro-core/src/vizro/models/_base.py | 225 +- .../src/vizro/models/_components/_form.py | 28 +- .../src/vizro/models/_components/ag_grid.py | 41 +- .../src/vizro/models/_components/button.py | 23 +- .../src/vizro/models/_components/card.py | 8 +- .../src/vizro/models/_components/container.py | 33 +- .../src/vizro/models/_components/figure.py | 27 +- .../vizro/models/_components/form/_alert.py | 10 +- .../models/_components/form/_form_utils.py | 55 +- .../models/_components/form/_text_area.py | 10 +- .../models/_components/form/_user_input.py | 26 +- .../models/_components/form/checklist.py | 31 +- .../models/_components/form/date_picker.py | 55 +- .../vizro/models/_components/form/dropdown.py | 57 +- .../models/_components/form/radio_items.py | 29 +- .../models/_components/form/range_slider.py | 60 +- .../vizro/models/_components/form/slider.py | 54 +- .../src/vizro/models/_components/graph.py | 45 +- .../src/vizro/models/_components/table.py | 40 +- .../src/vizro/models/_components/tabs.py | 13 +- .../src/vizro/models/_controls/filter.py | 65 +- .../src/vizro/models/_controls/parameter.py | 92 +- vizro-core/src/vizro/models/_dashboard.py | 49 +- vizro-core/src/vizro/models/_layout.py | 69 +- vizro-core/src/vizro/models/_models_utils.py | 8 +- .../models/_navigation/_navigation_utils.py | 3 +- .../src/vizro/models/_navigation/accordion.py | 35 +- .../src/vizro/models/_navigation/nav_bar.py | 31 +- .../src/vizro/models/_navigation/nav_link.py | 35 +- .../vizro/models/_navigation/navigation.py | 20 +- vizro-core/src/vizro/models/_page.py | 92 +- vizro-core/src/vizro/models/types.py | 93 +- .../unit/vizro/models/_action/test_action.py | 15 +- .../models/_action/test_actions_chain.py | 16 +- .../models/_components/form/test_checklist.py | 14 +- .../_components/form/test_date_picker.py | 10 +- .../models/_components/form/test_dropdown.py | 14 +- .../_components/form/test_radio_items.py | 12 +- .../_components/form/test_range_slider.py | 28 +- .../models/_components/form/test_slider.py | 18 +- .../vizro/models/_components/test_ag_grid.py | 8 +- .../vizro/models/_components/test_button.py | 3 - .../vizro/models/_components/test_card.py | 12 +- .../models/_components/test_container.py | 10 +- .../vizro/models/_components/test_figure.py | 6 +- .../vizro/models/_components/test_graph.py | 8 +- .../vizro/models/_components/test_table.py | 8 +- .../vizro/models/_components/test_tabs.py | 8 +- .../models/_navigation/test_accordion.py | 8 +- .../vizro/models/_navigation/test_nav_bar.py | 8 +- .../vizro/models/_navigation/test_nav_link.py | 12 +- .../models/_navigation/test_navigation.py | 8 +- .../tests/unit/vizro/models/test_base.py | 107 +- .../tests/unit/vizro/models/test_dashboard.py | 12 +- .../tests/unit/vizro/models/test_layout.py | 19 +- .../unit/vizro/models/test_models_utils.py | 13 +- .../tests/unit/vizro/models/test_page.py | 14 +- .../tests/unit/vizro/models/test_types.py | 21 +- 76 files changed, 2215 insertions(+), 2029 deletions(-) create mode 100644 vizro-core/changelog.d/20250130_145656_maximilian_schulz_pydantic_v2.md rename vizro-core/schemas/{0.1.32.dev0.json => 0.2.0.dev0.json} (70%) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ee2edec72..a4cdbbf40 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -60,10 +60,7 @@ repos: additional_dependencies: ["bandit[toml]"] - repo: https://github.com/pre-commit/mirrors-mypy - # Upgrade to v1.11.1 not possible as it doesn't seem compatible with pydantic<2 plugin. - # Similar issue with previous v.1.11.X versions: https://github.com/pydantic/pydantic/issues/10000 - # We need to revert the changes from the pre-commit autoupdate for now. - rev: v1.10.1 + rev: v1.14.1 hooks: - id: mypy files: ^vizro-core/src/ @@ -72,7 +69,7 @@ repos: # pydantic>=1.10.15 includes this fix which flags some genuine type problems. These will take a while to fix # or ignore so for now we just pin to 1.10.14 which doesn't flag the problems. # https://github.com/pydantic/pydantic/pull/8765 - - pydantic==1.10.14 + - pydantic==2.9.0 - repo: https://github.com/awebdeveloper/pre-commit-stylelint rev: "0.0.2" diff --git a/pyproject.toml b/pyproject.toml index 6424bb322..112ee0709 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ skip = "*.min.css.map,*.min.css,.vale/*, *assets/*,.github/*" [tool.mypy] # strict checks : strict = true check_untyped_defs = true +disable_error_code = "call-arg" # https://github.com/python/mypy/issues/14850 seems to not fix it disallow_any_generics = true disallow_incomplete_defs = false disallow_subclassing_any = false diff --git a/vizro-core/changelog.d/20250130_145656_maximilian_schulz_pydantic_v2.md b/vizro-core/changelog.d/20250130_145656_maximilian_schulz_pydantic_v2.md new file mode 100644 index 000000000..f177341fd --- /dev/null +++ b/vizro-core/changelog.d/20250130_145656_maximilian_schulz_pydantic_v2.md @@ -0,0 +1,50 @@ + + + +### Highlights ✨ + +- Vizro now uses pydantic V2 instead of V1 for its models. This is a big change, but it does not affect the API defined by the models. +However, when interacting with the VIzro models, e.g. in custom components or when calling pydantic V1 methods, code may break. +Please contact us in case you detect a bug or something breaks unexpectedly! ([#917](https://github.com/mckinsey/vizro/pull/917)) + + + + + + + + + + + diff --git a/vizro-core/docs/pages/user-guides/custom-components.md b/vizro-core/docs/pages/user-guides/custom-components.md index 1b958f5e1..1c6d14c36 100644 --- a/vizro-core/docs/pages/user-guides/custom-components.md +++ b/vizro-core/docs/pages/user-guides/custom-components.md @@ -363,6 +363,7 @@ Add the custom action `open_offcanvas` as a `function` argument inside the [`Act ], ), OffCanvas( + id="offcanvas", content="OffCanvas content", title="Offcanvas Title", ), @@ -402,18 +403,12 @@ As mentioned above, custom components can trigger action. To enable the custom c === "app.py" ```py - from typing import Literal + from typing import Annotated, Literal import dash_bootstrap_components as dbc import vizro.models as vm - from dash import html + from pydantic import AfterValidator, Field, PlainSerializer from vizro import Vizro - - try: - from pydantic.v1 import Field, PrivateAttr - except ImportError: - from pydantic import PrivateAttr - from vizro.models import Action from vizro.models._action._actions_chain import _action_validator_factory from vizro.models.types import capture @@ -423,9 +418,14 @@ As mentioned above, custom components can trigger action. To enable the custom c class Carousel(vm.VizroBaseModel): type: Literal["carousel"] = "carousel" items: list - actions: list[Action] = [] - # Here we set the action so a change in the active_index property of the custom component triggers the action - _set_actions = _action_validator_factory("active_index") + actions: Annotated[ + list[Action], + # Here we set the action so a change in the active_index property of the custom component triggers the action + AfterValidator(_action_validator_factory("active_index")), + # Here we tell the serializer to only serialize the actions field + PlainSerializer(lambda x: x[0].actions), + Field(default=[]), + ] def build(self): return dbc.Carousel( @@ -437,6 +437,7 @@ As mentioned above, custom components can trigger action. To enable the custom c # 2. Add new components to expected type - here the selector of the parent components vm.Page.add_type("components", Carousel) + # 3. Create custom action @capture("action") def slide_next_card(active_index): @@ -445,6 +446,7 @@ As mentioned above, custom components can trigger action. To enable the custom c return "First slide" + page = vm.Page( title="Custom Component", components=[ @@ -459,9 +461,9 @@ As mentioned above, custom components can trigger action. To enable the custom c vm.Action( function=slide_next_card(), inputs=["carousel.active_index"], - outputs=["carousel-card.children"] + outputs=["carousel-card.children"], ) - ] + ], ), ], ) diff --git a/vizro-core/examples/dev/app.py b/vizro-core/examples/dev/app.py index 926b289c1..e3f52b2cd 100644 --- a/vizro-core/examples/dev/app.py +++ b/vizro-core/examples/dev/app.py @@ -778,7 +778,7 @@ def multiple_cards(data_frame: pd.DataFrame, n_rows: Optional[int] = 1) -> html. components = [graphs, ag_grid, table, cards, figure, button, containers, tabs] controls = [filters, parameters, selectors] actions = [export_data_action, chart_interaction] -extensions = [custom_charts, custom_tables, custom_components, custom_actions, custom_figures] +extensions = [custom_charts, custom_tables, custom_actions, custom_figures, custom_components] dashboard = vm.Dashboard( title="Vizro Features", diff --git a/vizro-core/examples/scratch_dev/app.py b/vizro-core/examples/scratch_dev/app.py index b1d65c651..8915d8d9f 100644 --- a/vizro-core/examples/scratch_dev/app.py +++ b/vizro-core/examples/scratch_dev/app.py @@ -3,26 +3,24 @@ import vizro.models as vm import vizro.plotly.express as px from vizro import Vizro -from vizro.figures import kpi_card +from vizro.actions import export_data + +df = px.data.iris() -tips = px.data.tips # Create a layout with five rows and four columns. The KPI card is positioned in the first cell, while the remaining cells are empty. page = vm.Page( - title="KPI card", - layout=vm.Layout(grid=[[0, 0, -1, -1]] + [[-1, -1, -1, -1]] * 2), + title="Page 1", components=[ - vm.Figure( - figure=kpi_card( # For more information, refer to the API reference for kpi_card - data_frame=tips, - value_column="tip", - value_format="${value:.2f}", - icon="folder_check", - title="KPI card I", - ) - ) + vm.Graph(figure=px.bar(df, x="sepal_width", y="sepal_length")), + vm.Button( + text="Export data", + actions=[ + vm.Action(function=export_data()), + vm.Action(function=export_data()), + ], + ), ], - controls=[vm.Filter(column="day", selector=vm.RadioItems())], ) dashboard = vm.Dashboard(pages=[page]) diff --git a/vizro-core/examples/visual-vocabulary/custom_components.py b/vizro-core/examples/visual-vocabulary/custom_components.py index d88dcf543..2a653adbb 100644 --- a/vizro-core/examples/visual-vocabulary/custom_components.py +++ b/vizro-core/examples/visual-vocabulary/custom_components.py @@ -1,17 +1,12 @@ """Contains custom components used inside the dashboard.""" from typing import Literal +from urllib.parse import quote import dash_bootstrap_components as dbc import vizro.models as vm from dash import dcc, html - -try: - from pydantic.v1 import Field -except ImportError: # pragma: no cov - from pydantic import Field - -from urllib.parse import quote +from pydantic import Field class CodeClipboard(vm.VizroBaseModel): diff --git a/vizro-core/hatch.toml b/vizro-core/hatch.toml index 64cac9edb..3e863b4d3 100644 --- a/vizro-core/hatch.toml +++ b/vizro-core/hatch.toml @@ -118,7 +118,7 @@ VIZRO_LOG_LEVEL = "DEBUG" [envs.lower-bounds] extra-dependencies = [ - "pydantic==1.10.16", + "pydantic==2.9.0", "dash==2.18.0", "plotly==5.12.0", "pandas==2.0.0", diff --git a/vizro-core/pyproject.toml b/vizro-core/pyproject.toml index 367f12aff..153cdece5 100644 --- a/vizro-core/pyproject.toml +++ b/vizro-core/pyproject.toml @@ -20,7 +20,7 @@ dependencies = [ "dash-ag-grid>=31.0.0", "pandas>=2", "plotly>=5.12.0", - "pydantic>=1.10.16", # must be synced with pre-commit mypy hook manually + "pydantic>=2.9.0", # must be synced with pre-commit mypy hook manually "dash_mantine_components~=0.15.1", "flask_caching>=2", "wrapt>=1", diff --git a/vizro-core/schemas/0.1.32.dev0.json b/vizro-core/schemas/0.2.0.dev0.json similarity index 70% rename from vizro-core/schemas/0.1.32.dev0.json rename to vizro-core/schemas/0.2.0.dev0.json index d69755927..cfa0886da 100644 --- a/vizro-core/schemas/0.1.32.dev0.json +++ b/vizro-core/schemas/0.2.0.dev0.json @@ -1,839 +1,971 @@ { - "title": "Dashboard", - "description": "Vizro Dashboard to be used within [`Vizro`][vizro._vizro.Vizro.build].\n\nArgs:\n pages (list[Page]): See [`Page`][vizro.models.Page].\n theme (Literal[\"vizro_dark\", \"vizro_light\"]): Layout theme to be applied across dashboard.\n Defaults to `vizro_dark`.\n navigation (Navigation): See [`Navigation`][vizro.models.Navigation]. Defaults to `None`.\n title (str): Dashboard title to appear on every page on top left-side. Defaults to `\"\"`.", - "type": "object", - "properties": { - "id": { - "title": "Id", - "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", - "default": "", - "type": "string" - }, - "pages": { - "title": "Pages", - "type": "array", - "items": { - "$ref": "#/definitions/Page" - } - }, - "theme": { - "title": "Theme", - "description": "Layout theme to be applied across dashboard. Defaults to `vizro_dark`", - "default": "vizro_dark", - "enum": ["vizro_dark", "vizro_light"], - "type": "string" - }, - "navigation": { - "$ref": "#/definitions/Navigation" + "$defs": { + "Accordion": { + "additionalProperties": false, + "description": "Accordion to be used as nav_selector in [`Navigation`][vizro.models.Navigation].\n\nArgs:\n type (Literal[\"accordion\"]): Defaults to `\"accordion\"`.\n pages (dict[str, list[str]]): Mapping from name of a pages group to a list of page IDs. Defaults to `{}`.", + "properties": { + "id": { + "default": "", + "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", + "title": "Id", + "type": "string" + }, + "type": { + "const": "accordion", + "default": "accordion", + "title": "Type", + "type": "string" + }, + "pages": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "default": {}, + "description": "Mapping from name of a pages group to a list of page IDs.", + "title": "Pages", + "type": "object" + } + }, + "title": "Accordion", + "type": "object" }, - "title": { - "title": "Title", - "description": "Dashboard title to appear on every page on top left-side.", - "default": "", - "type": "string" - } - }, - "required": ["pages"], - "additionalProperties": false, - "definitions": { "Action": { - "title": "Action", + "additionalProperties": false, "description": "Action to be inserted into `actions` of relevant component.\n\nArgs:\n function (CapturedCallable): Action function. See [`vizro.actions`][vizro.actions].\n inputs (list[str]): Inputs in the form `.` passed to the action function.\n Defaults to `[]`.\n outputs (list[str]): Outputs in the form `.` changed by the action function.\n Defaults to `[]`.", - "type": "object", "properties": { "id": { - "title": "Id", - "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", "default": "", + "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", + "title": "Id", "type": "string" }, "inputs": { - "title": "Inputs", - "description": "Inputs in the form `.` passed to the action function.", "default": [], - "pattern": "^[^.]+[.][^.]+$", - "type": "array", + "description": "Inputs in the form `.` passed to the action function.", "items": { - "type": "string", - "pattern": "^[^.]+[.][^.]+$" - } + "pattern": "^[^.]+[.][^.]+$", + "type": "string" + }, + "title": "Inputs", + "type": "array" }, "outputs": { - "title": "Outputs", + "default": [], "description": "Outputs in the form `.` changed by the action function.", + "items": { + "pattern": "^[^.]+[.][^.]+$", + "type": "string" + }, + "title": "Outputs", + "type": "array" + } + }, + "title": "Action", + "type": "object" + }, + "ActionsChain": { + "additionalProperties": false, + "properties": { + "id": { + "default": "", + "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", + "title": "Id", + "type": "string" + }, + "trigger": { + "$ref": "#/$defs/Trigger" + }, + "actions": { "default": [], - "pattern": "^[^.]+[.][^.]+$", - "type": "array", "items": { - "type": "string", - "pattern": "^[^.]+[.][^.]+$" - } + "$ref": "#/$defs/Action" + }, + "title": "Actions", + "type": "array" } }, - "additionalProperties": false + "required": ["trigger"], + "title": "ActionsChain", + "type": "object" }, "AgGrid": { - "title": "AgGrid", + "additionalProperties": false, "description": "Wrapper for `dash-ag-grid.AgGrid` to visualize grids in dashboard.\n\nArgs:\n type (Literal[\"ag_grid\"]): Defaults to `\"ag_grid\"`.\n figure (CapturedCallable): Function that returns a Dash AgGrid. See [`vizro.tables`][vizro.tables].\n title (str): Title of the `AgGrid`. Defaults to `\"\"`.\n header (str): Markdown text positioned below the `AgGrid.title`. Follows the CommonMark specification.\n Ideal for adding supplementary information such as subtitles, descriptions, or additional context.\n Defaults to `\"\"`.\n footer (str): Markdown text positioned below the `AgGrid`. Follows the CommonMark specification.\n Ideal for providing further details such as sources, disclaimers, or additional notes. Defaults to `\"\"`.\n actions (list[Action]): See [`Action`][vizro.models.Action]. Defaults to `[]`.", - "type": "object", "properties": { "id": { - "title": "Id", - "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", "default": "", + "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", + "title": "Id", "type": "string" }, "type": { - "title": "Type", + "const": "ag_grid", "default": "ag_grid", - "enum": ["ag_grid"], + "title": "Type", "type": "string" }, "title": { - "title": "Title", - "description": "Title of the `AgGrid`", "default": "", + "description": "Title of the `AgGrid`.", + "title": "Title", "type": "string" }, "header": { - "title": "Header", - "description": "Markdown text positioned below the `AgGrid.title`. Follows the CommonMark specification. Ideal for adding supplementary information such as subtitles, descriptions, or additional context.", "default": "", + "description": "Markdown text positioned below the `AgGrid.title`. Follows the CommonMark specification. Ideal for adding supplementary information such as subtitles, descriptions, or additional context.", + "title": "Header", "type": "string" }, "footer": { - "title": "Footer", - "description": "Markdown text positioned below the `AgGrid`. Follows the CommonMark specification. Ideal for providing further details such as sources, disclaimers, or additional notes.", "default": "", + "description": "Markdown text positioned below the `AgGrid`. Follows the CommonMark specification. Ideal for providing further details such as sources, disclaimers, or additional notes.", + "title": "Footer", "type": "string" }, "actions": { - "title": "Actions", "default": [], - "type": "array", "items": { - "$ref": "#/definitions/Action" - } + "$ref": "#/$defs/Action" + }, + "title": "Actions", + "type": "array" } }, - "additionalProperties": false + "title": "AgGrid", + "type": "object" }, "Button": { - "title": "Button", + "additionalProperties": false, "description": "Component provided to `Page` to trigger any defined `action` in `Page`.\n\nArgs:\n type (Literal[\"button\"]): Defaults to `\"button\"`.\n text (str): Text to be displayed on button. Defaults to `\"Click me!\"`.\n actions (list[Action]): See [`Action`][vizro.models.Action]. Defaults to `[]`.", - "type": "object", "properties": { "id": { - "title": "Id", - "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", "default": "", + "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", + "title": "Id", "type": "string" }, "type": { - "title": "Type", + "const": "button", "default": "button", - "enum": ["button"], + "title": "Type", "type": "string" }, "text": { - "title": "Text", - "description": "Text to be displayed on button.", "default": "Click me!", + "description": "Text to be displayed on button.", + "title": "Text", "type": "string" }, "href": { - "title": "Href", - "description": "URL (relative or absolute) to navigate to.", "default": "", + "description": "URL (relative or absolute) to navigate to.", + "title": "Href", "type": "string" }, "actions": { - "title": "Actions", "default": [], - "type": "array", "items": { - "$ref": "#/definitions/Action" - } + "$ref": "#/$defs/Action" + }, + "title": "Actions", + "type": "array" } }, - "additionalProperties": false + "title": "Button", + "type": "object" }, "Card": { - "title": "Card", + "additionalProperties": false, "description": "Creates a card utilizing `dcc.Markdown` as title and text component.\n\nArgs:\n type (Literal[\"card\"]): Defaults to `\"card\"`.\n text (str): Markdown string to create card title/text that should adhere to the CommonMark Spec.\n href (str): URL (relative or absolute) to navigate to. If not provided the Card serves as a text card\n only. Defaults to `\"\"`.", - "type": "object", "properties": { "id": { - "title": "Id", - "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", "default": "", + "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", + "title": "Id", "type": "string" }, "type": { - "title": "Type", + "const": "card", "default": "card", - "enum": ["card"], + "title": "Type", "type": "string" }, "text": { - "title": "Text", "description": "Markdown string to create card title/text that should adhere to the CommonMark Spec.", + "title": "Text", "type": "string" }, "href": { - "title": "Href", - "description": "URL (relative or absolute) to navigate to. If not provided the Card serves as a text card only.", "default": "", + "description": "URL (relative or absolute) to navigate to. If not provided the Card serves as a text card only.", + "title": "Href", "type": "string" } }, "required": ["text"], - "additionalProperties": false + "title": "Card", + "type": "object" }, - "Figure": { - "title": "Figure", - "description": "Creates a figure-like object that can be displayed in the dashboard and is reactive to controls.\n\nArgs:\n type (Literal[\"figure\"]): Defaults to `\"figure\"`.\n figure (CapturedCallable): Function that returns a figure-like object. See [`vizro.figures`][vizro.figures].", - "type": "object", + "Checklist": { + "additionalProperties": false, + "description": "Categorical multi-option selector `Checklist`.\n\nCan be provided to [`Filter`][vizro.models.Filter] or\n[`Parameter`][vizro.models.Parameter]. Based on the underlying\n[`dcc.Checklist`](https://dash.plotly.com/dash-core-components/checklist).\n\nArgs:\n type (Literal[\"checklist\"]): Defaults to `\"checklist\"`.\n options (OptionsType): See [`OptionsType`][vizro.models.types.OptionsType]. Defaults to `[]`.\n value (Optional[MultiValueType]): See [`MultiValueType`][vizro.models.types.MultiValueType]. Defaults to `None`.\n title (str): Title to be displayed. Defaults to `\"\"`.\n actions (list[Action]): See [`Action`][vizro.models.Action]. Defaults to `[]`.", "properties": { "id": { - "title": "Id", - "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", "default": "", - "type": "string" - }, - "type": { - "title": "Type", - "default": "figure", - "enum": ["figure"], - "type": "string" - } - }, - "additionalProperties": false - }, - "Graph": { - "title": "Graph", - "description": "Wrapper for `dcc.Graph` to visualize charts in dashboard.\n\nArgs:\n type (Literal[\"graph\"]): Defaults to `\"graph\"`.\n figure (CapturedCallable): Function that returns a graph.\n See `CapturedCallable`][vizro.models.types.CapturedCallable].\n title (str): Title of the `Graph`. Defaults to `\"\"`.\n header (str): Markdown text positioned below the `Graph.title`. Follows the CommonMark specification.\n Ideal for adding supplementary information such as subtitles, descriptions, or additional context.\n Defaults to `\"\"`.\n footer (str): Markdown text positioned below the `Graph`. Follows the CommonMark specification.\n Ideal for providing further details such as sources, disclaimers, or additional notes. Defaults to `\"\"`.\n actions (list[Action]): See [`Action`][vizro.models.Action]. Defaults to `[]`.", - "type": "object", - "properties": { - "id": { - "title": "Id", "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", - "default": "", + "title": "Id", "type": "string" }, "type": { + "const": "checklist", + "default": "checklist", "title": "Type", - "default": "graph", - "enum": ["graph"], "type": "string" }, - "title": { - "title": "Title", - "description": "Title of the `Graph`", - "default": "", - "type": "string" + "options": { + "anyOf": [ + { + "items": { + "type": "boolean" + }, + "type": "array" + }, + { + "items": { + "type": "number" + }, + "type": "array" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "items": { + "format": "date", + "type": "string" + }, + "type": "array" + }, + { + "items": { + "$ref": "#/$defs/OptionsDictType" + }, + "type": "array" + } + ], + "default": [], + "title": "Options" }, - "header": { - "title": "Header", - "description": "Markdown text positioned below the `Graph.title`. Follows the CommonMark specification. Ideal for adding supplementary information such as subtitles, descriptions, or additional context.", - "default": "", - "type": "string" + "value": { + "anyOf": [ + { + "items": { + "type": "boolean" + }, + "type": "array" + }, + { + "items": { + "type": "number" + }, + "type": "array" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "items": { + "format": "date", + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Value" }, - "footer": { - "title": "Footer", - "description": "Markdown text positioned below the `Graph`. Follows the CommonMark specification. Ideal for providing further details such as sources, disclaimers, or additional notes.", + "title": { "default": "", + "description": "Title to be displayed", + "title": "Title", "type": "string" }, "actions": { - "title": "Actions", "default": [], - "type": "array", "items": { - "$ref": "#/definitions/Action" - } + "$ref": "#/$defs/Action" + }, + "title": "Actions", + "type": "array" } }, - "additionalProperties": false + "title": "Checklist", + "type": "object" }, - "Table": { - "title": "Table", - "description": "Wrapper for `dash_table.DataTable` to visualize tables in dashboard.\n\nArgs:\n type (Literal[\"table\"]): Defaults to `\"table\"`.\n figure (CapturedCallable): Function that returns a Dash DataTable. See [`vizro.tables`][vizro.tables].\n title (str): Title of the `Table`. Defaults to `\"\"`.\n header (str): Markdown text positioned below the `Table.title`. Follows the CommonMark specification.\n Ideal for adding supplementary information such as subtitles, descriptions, or additional context.\n Defaults to `\"\"`.\n footer (str): Markdown text positioned below the `Table`. Follows the CommonMark specification.\n Ideal for providing further details such as sources, disclaimers, or additional notes. Defaults to `\"\"`.\n actions (list[Action]): See [`Action`][vizro.models.Action]. Defaults to `[]`.", - "type": "object", + "Container": { + "additionalProperties": false, + "description": "Container to group together a set of components on a page.\n\nArgs:\n type (Literal[\"container\"]): Defaults to `\"container\"`.\n components (list[ComponentType]): See [ComponentType][vizro.models.types.ComponentType]. At least one component\n has to be provided.\n title (str): Title to be displayed.\n layout (Optional[Layout]): Layout to place components in. Defaults to `None`.", "properties": { "id": { - "title": "Id", - "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", "default": "", + "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", + "title": "Id", "type": "string" }, "type": { + "const": "container", + "default": "container", "title": "Type", - "default": "table", - "enum": ["table"], - "type": "string" - }, - "title": { - "title": "Title", - "description": "Title of the `Table`", - "default": "", - "type": "string" - }, - "header": { - "title": "Header", - "description": "Markdown text positioned below the `Table.title`. Follows the CommonMark specification. Ideal for adding supplementary information such as subtitles, descriptions, or additional context.", - "default": "", - "type": "string" - }, - "footer": { - "title": "Footer", - "description": "Markdown text positioned below the `Table`. Follows the CommonMark specification. Ideal for providing further details such as sources, disclaimers, or additional notes.", - "default": "", - "type": "string" - }, - "actions": { - "title": "Actions", - "default": [], - "type": "array", - "items": { - "$ref": "#/definitions/Action" - } - } - }, - "additionalProperties": false - }, - "Tabs": { - "title": "Tabs", - "description": "Tabs to group together a set of containers on a page.\n\nArgs:\n type (Literal[\"tabs\"]): Defaults to `\"tabs\"`.\n tabs (list[Container]): See [`Container`][vizro.models.Container].", - "type": "object", - "properties": { - "id": { - "title": "Id", - "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", - "default": "", - "type": "string" - }, - "type": { - "title": "Type", - "default": "tabs", - "enum": ["tabs"], - "type": "string" - }, - "tabs": { - "title": "Tabs", - "type": "array", - "items": { - "$ref": "#/definitions/Container" - } - } - }, - "required": ["tabs"], - "additionalProperties": false - }, - "Layout": { - "title": "Layout", - "description": "Grid specification to place chart/components on the [`Page`][vizro.models.Page].\n\nArgs:\n grid (list[list[int]]): Grid specification to arrange components on screen.\n row_gap (str): Gap between rows in px. Defaults to `\"12px\"`.\n col_gap (str): Gap between columns in px. Defaults to `\"12px\"`.\n row_min_height (str): Minimum row height in px. Defaults to `\"0px\"`.\n col_min_width (str): Minimum column width in px. Defaults to `\"0px\"`.", - "type": "object", - "properties": { - "id": { - "title": "Id", - "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", - "default": "", - "type": "string" - }, - "grid": { - "title": "Grid", - "description": "Grid specification to arrange components on screen.", - "type": "array", - "items": { - "type": "array", - "items": { - "type": "integer" - } - } - }, - "row_gap": { - "title": "Row Gap", - "description": "Gap between rows in px. Defaults to 12px.", - "default": "24px", - "pattern": "[0-9]+px", - "type": "string" - }, - "col_gap": { - "title": "Col Gap", - "description": "Gap between columns in px. Defaults to 12px.", - "default": "24px", - "pattern": "[0-9]+px", - "type": "string" - }, - "row_min_height": { - "title": "Row Min Height", - "description": "Minimum row height in px. Defaults to 0px.", - "default": "0px", - "pattern": "[0-9]+px", - "type": "string" - }, - "col_min_width": { - "title": "Col Min Width", - "description": "Minimum column width in px. Defaults to 0px.", - "default": "0px", - "pattern": "[0-9]+px", - "type": "string" - } - }, - "required": ["grid"], - "additionalProperties": false - }, - "Container": { - "title": "Container", - "description": "Container to group together a set of components on a page.\n\nArgs:\n type (Literal[\"container\"]): Defaults to `\"container\"`.\n components (list[ComponentType]): See [ComponentType][vizro.models.types.ComponentType]. At least one component\n has to be provided.\n title (str): Title to be displayed.\n layout (Layout): Layout to place components in. Defaults to `None`.", - "type": "object", - "properties": { - "id": { - "title": "Id", - "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", - "default": "", - "type": "string" - }, - "type": { - "title": "Type", - "default": "container", - "enum": ["container"], "type": "string" }, "components": { - "title": "Components", - "type": "array", "items": { + "description": "Component that makes up part of the layout on the page.", "discriminator": { - "propertyName": "type", "mapping": { - "ag_grid": "#/definitions/AgGrid", - "button": "#/definitions/Button", - "card": "#/definitions/Card", - "container": "#/definitions/Container", - "figure": "#/definitions/Figure", - "graph": "#/definitions/Graph", - "table": "#/definitions/Table", - "tabs": "#/definitions/Tabs" - } + "ag_grid": "#/$defs/AgGrid", + "button": "#/$defs/Button", + "card": "#/$defs/Card", + "container": "#/$defs/Container", + "figure": "#/$defs/Figure", + "graph": "#/$defs/Graph", + "table": "#/$defs/Table", + "tabs": "#/$defs/Tabs" + }, + "propertyName": "type" }, "oneOf": [ { - "$ref": "#/definitions/AgGrid" + "$ref": "#/$defs/AgGrid" }, { - "$ref": "#/definitions/Button" + "$ref": "#/$defs/Button" }, { - "$ref": "#/definitions/Card" + "$ref": "#/$defs/Card" }, { - "$ref": "#/definitions/Container" + "$ref": "#/$defs/Container" }, { - "$ref": "#/definitions/Figure" + "$ref": "#/$defs/Figure" }, { - "$ref": "#/definitions/Graph" + "$ref": "#/$defs/Graph" }, { - "$ref": "#/definitions/Table" + "$ref": "#/$defs/Table" }, { - "$ref": "#/definitions/Tabs" + "$ref": "#/$defs/Tabs" } ] - } + }, + "minItems": 1, + "title": "Components", + "type": "array" }, "title": { - "title": "Title", "description": "Title to be displayed.", + "title": "Title", "type": "string" }, "layout": { - "$ref": "#/definitions/Layout" + "anyOf": [ + { + "$ref": "#/$defs/Layout" + }, + { + "type": "null" + } + ], + "default": null } }, "required": ["components", "title"], - "additionalProperties": false + "title": "Container", + "type": "object" }, - "OptionsDictType": { - "title": "OptionsDictType", - "type": "object", + "DatePicker": { + "additionalProperties": false, + "description": "Temporal single/range option selector `DatePicker`.\n\nCan be provided to [`Filter`][vizro.models.Filter] or [`Parameter`][vizro.models.Parameter].\nBased on the underlying [`dmc.DatePicker`](https://www.dash-mantine-components.com/components/datepicker) or\n[`dmc.DateRangePicker`](https://www.dash-mantine-components.com/components/datepicker#daterangepicker).\n\nArgs:\n type (Literal[\"date_picker\"]): Defaults to `\"date_picker\"`.\n min (Optional[date]): Start date for date picker. Defaults to `None`.\n max (Optional[date]): End date for date picker. Defaults to `None`.\n value (Optional[Union[list[date], date]]): Default date/dates for date picker. Defaults to `None`.\n title (str): Title to be displayed. Defaults to `\"\"`.\n range (bool): Boolean flag for displaying range picker. Defaults to `True`.\n actions (list[Action]): See [`Action`][vizro.models.Action]. Defaults to `[]`.", "properties": { - "label": { - "title": "Label", + "id": { + "default": "", + "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", + "title": "Id", "type": "string" }, - "value": { - "title": "Value", + "type": { + "const": "date_picker", + "default": "date_picker", + "title": "Type", + "type": "string" + }, + "min": { "anyOf": [ { - "type": "boolean" + "format": "date", + "type": "string" }, { - "type": "number" + "type": "null" + } + ], + "default": null, + "description": "Start date for date picker.", + "title": "Min" + }, + "max": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "End date for date picker.", + "title": "Max" + }, + "value": { + "anyOf": [ + { + "items": { + "format": "date", + "type": "string" + }, + "type": "array" }, { + "format": "date", "type": "string" }, { - "type": "string", - "format": "date" + "type": "null" } - ] + ], + "default": null, + "description": "Default date/dates for date picker.", + "title": "Value" + }, + "title": { + "default": "", + "description": "Title to be displayed.", + "title": "Title", + "type": "string" + }, + "range": { + "default": true, + "description": "Boolean flag for displaying range picker.", + "title": "Range", + "type": "boolean" + }, + "actions": { + "default": [], + "items": { + "$ref": "#/$defs/Action" + }, + "title": "Actions", + "type": "array" } }, - "required": ["label", "value"], - "additionalProperties": false + "title": "DatePicker", + "type": "object" }, - "Checklist": { - "title": "Checklist", - "description": "Categorical multi-option selector `Checklist`.\n\nCan be provided to [`Filter`][vizro.models.Filter] or\n[`Parameter`][vizro.models.Parameter]. Based on the underlying\n[`dcc.Checklist`](https://dash.plotly.com/dash-core-components/checklist).\n\nArgs:\n type (Literal[\"checklist\"]): Defaults to `\"checklist\"`.\n options (OptionsType): See [`OptionsType`][vizro.models.types.OptionsType]. Defaults to `[]`.\n value (Optional[MultiValueType]): See [`MultiValueType`][vizro.models.types.MultiValueType]. Defaults to `None`.\n title (str): Title to be displayed. Defaults to `\"\"`.\n actions (list[Action]): See [`Action`][vizro.models.Action]. Defaults to `[]`.", - "type": "object", + "Dropdown": { + "additionalProperties": false, + "description": "Categorical single/multi-option selector `Dropdown`.\n\nCan be provided to [`Filter`][vizro.models.Filter] or\n[`Parameter`][vizro.models.Parameter]. Based on the underlying\n[`dcc.Dropdown`](https://dash.plotly.com/dash-core-components/dropdown).\n\nArgs:\n type (Literal[\"dropdown\"]): Defaults to `\"dropdown\"`.\n options (OptionsType): See [`OptionsType`][vizro.models.types.OptionsType]. Defaults to `[]`.\n value (Optional[Union[SingleValueType, MultiValueType]]): See\n [`SingleValueType`][vizro.models.types.SingleValueType] and\n [`MultiValueType`][vizro.models.types.MultiValueType]. Defaults to `None`.\n multi (bool): Whether to allow selection of multiple values. Defaults to `True`.\n title (str): Title to be displayed. Defaults to `\"\"`.\n actions (list[Action]): See [`Action`][vizro.models.Action]. Defaults to `[]`.", "properties": { "id": { - "title": "Id", - "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", "default": "", + "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", + "title": "Id", "type": "string" }, "type": { + "const": "dropdown", + "default": "dropdown", "title": "Type", - "default": "checklist", - "enum": ["checklist"], "type": "string" }, "options": { - "title": "Options", - "default": [], "anyOf": [ { - "type": "array", "items": { "type": "boolean" - } + }, + "type": "array" }, { - "type": "array", "items": { "type": "number" - } + }, + "type": "array" }, { - "type": "array", "items": { "type": "string" - } + }, + "type": "array" }, { - "type": "array", "items": { - "type": "string", - "format": "date" - } + "format": "date", + "type": "string" + }, + "type": "array" }, { - "type": "array", "items": { - "$ref": "#/definitions/OptionsDictType" - } + "$ref": "#/$defs/OptionsDictType" + }, + "type": "array" } - ] + ], + "default": [], + "title": "Options" }, "value": { - "title": "Value", "anyOf": [ { - "type": "array", + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "format": "date", + "type": "string" + }, + { "items": { "type": "boolean" - } + }, + "type": "array" }, { - "type": "array", "items": { "type": "number" - } + }, + "type": "array" }, { - "type": "array", "items": { "type": "string" - } + }, + "type": "array" }, { - "type": "array", "items": { - "type": "string", - "format": "date" - } + "format": "date", + "type": "string" + }, + "type": "array" + }, + { + "type": "null" } - ] + ], + "default": null, + "title": "Value" + }, + "multi": { + "default": true, + "description": "Whether to allow selection of multiple values", + "title": "Multi", + "type": "boolean" }, "title": { - "title": "Title", - "description": "Title to be displayed", "default": "", + "description": "Title to be displayed", + "title": "Title", "type": "string" }, "actions": { - "title": "Actions", "default": [], - "type": "array", "items": { - "$ref": "#/definitions/Action" - } + "$ref": "#/$defs/Action" + }, + "title": "Actions", + "type": "array" } }, - "additionalProperties": false + "title": "Dropdown", + "type": "object" }, - "DatePicker": { - "title": "DatePicker", - "description": "Temporal single/range option selector `DatePicker`.\n\nCan be provided to [`Filter`][vizro.models.Filter] or [`Parameter`][vizro.models.Parameter].\nBased on the underlying [`dmc.DatePicker`](https://www.dash-mantine-components.com/components/datepicker) or\n[`dmc.DateRangePicker`](https://www.dash-mantine-components.com/components/datepicker#daterangepicker).\n\nArgs:\n type (Literal[\"date_picker\"]): Defaults to `\"date_picker\"`.\n min (Optional[date]): Start date for date picker. Defaults to `None`.\n max (Optional[date]): End date for date picker. Defaults to `None`.\n value (Union[list[date], date]): Default date/dates for date picker. Defaults to `None`.\n title (str): Title to be displayed. Defaults to `\"\"`.\n range (bool): Boolean flag for displaying range picker. Default to `True`.\n actions (list[Action]): See [`Action`][vizro.models.Action]. Defaults to `[]`.", - "type": "object", + "Figure": { + "additionalProperties": false, + "description": "Creates a figure-like object that can be displayed in the dashboard and is reactive to controls.\n\nArgs:\n type (Literal[\"figure\"]): Defaults to `\"figure\"`.\n figure (CapturedCallable): Function that returns a figure-like object. See [`vizro.figures`][vizro.figures].", "properties": { "id": { - "title": "Id", - "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", "default": "", + "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", + "title": "Id", "type": "string" }, "type": { + "const": "figure", + "default": "figure", "title": "Type", - "default": "date_picker", - "enum": ["date_picker"], + "type": "string" + } + }, + "title": "Figure", + "type": "object" + }, + "Filter": { + "additionalProperties": false, + "description": "Filter the data supplied to `targets` on the [`Page`][vizro.models.Page].\n\nExamples:\n >>> print(repr(Filter(column=\"species\")))\n\nArgs:\n type (Literal[\"filter\"]): Defaults to `\"filter\"`.\n column (str): Column of `DataFrame` to filter.\n targets (list[ModelID]): Target component to be affected by filter. If none are given then target all components\n on the page that use `column`. Defaults to `[]`.\n selector (Optional[SelectorType]): See [SelectorType][vizro.models.types.SelectorType]. Defaults to `None`.", + "properties": { + "id": { + "default": "", + "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", + "title": "Id", "type": "string" }, - "min": { - "title": "Min", - "description": "Start date for date picker.", - "type": "string", - "format": "date" - }, - "max": { - "title": "Max", - "description": "End date for date picker.", - "type": "string", - "format": "date" - }, - "value": { - "title": "Value", - "description": "Default date for date picker", + "type": { + "const": "filter", + "default": "filter", + "title": "Type", + "type": "string" + }, + "column": { + "description": "Column of DataFrame to filter.", + "title": "Column", + "type": "string" + }, + "targets": { + "default": [], + "description": "Target component to be affected by filter. If none are given then target all components on the page that use `column`.", + "items": { + "type": "string" + }, + "title": "Targets", + "type": "array" + }, + "selector": { "anyOf": [ { - "type": "array", - "items": { - "type": "string", - "format": "date" - } + "description": "Selectors to be used inside a control.", + "discriminator": { + "mapping": { + "checklist": "#/$defs/Checklist", + "date_picker": "#/$defs/DatePicker", + "dropdown": "#/$defs/Dropdown", + "radio_items": "#/$defs/RadioItems", + "range_slider": "#/$defs/RangeSlider", + "slider": "#/$defs/Slider" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/$defs/Checklist" + }, + { + "$ref": "#/$defs/DatePicker" + }, + { + "$ref": "#/$defs/Dropdown" + }, + { + "$ref": "#/$defs/RadioItems" + }, + { + "$ref": "#/$defs/RangeSlider" + }, + { + "$ref": "#/$defs/Slider" + } + ] }, { - "type": "string", - "format": "date" + "type": "null" } - ] + ], + "default": null, + "title": "Selector" + } + }, + "required": ["column"], + "title": "Filter", + "type": "object" + }, + "Graph": { + "additionalProperties": false, + "description": "Wrapper for `dcc.Graph` to visualize charts in dashboard.\n\nArgs:\n type (Literal[\"graph\"]): Defaults to `\"graph\"`.\n figure (CapturedCallable): Function that returns a graph.\n See `CapturedCallable`][vizro.models.types.CapturedCallable].\n title (str): Title of the `Graph`. Defaults to `\"\"`.\n header (str): Markdown text positioned below the `Graph.title`. Follows the CommonMark specification.\n Ideal for adding supplementary information such as subtitles, descriptions, or additional context.\n Defaults to `\"\"`.\n footer (str): Markdown text positioned below the `Graph`. Follows the CommonMark specification.\n Ideal for providing further details such as sources, disclaimers, or additional notes. Defaults to `\"\"`.\n actions (list[Action]): See [`Action`][vizro.models.Action]. Defaults to `[]`.", + "properties": { + "id": { + "default": "", + "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", + "title": "Id", + "type": "string" + }, + "type": { + "const": "graph", + "default": "graph", + "title": "Type", + "type": "string" }, "title": { + "default": "", + "description": "Title of the `Graph`", "title": "Title", - "description": "Title to be displayed.", + "type": "string" + }, + "header": { "default": "", + "description": "Markdown text positioned below the `Graph.title`. Follows the CommonMark specification. Ideal for adding supplementary information such as subtitles, descriptions, or additional context.", + "title": "Header", "type": "string" }, - "range": { - "title": "Range", - "description": "Boolean flag for displaying range picker.", - "default": true, - "type": "boolean" + "footer": { + "default": "", + "description": "Markdown text positioned below the `Graph`. Follows the CommonMark specification. Ideal for providing further details such as sources, disclaimers, or additional notes.", + "title": "Footer", + "type": "string" }, "actions": { - "title": "Actions", "default": [], - "type": "array", "items": { - "$ref": "#/definitions/Action" - } + "$ref": "#/$defs/Action" + }, + "title": "Actions", + "type": "array" } }, - "additionalProperties": false + "title": "Graph", + "type": "object" }, - "Dropdown": { - "title": "Dropdown", - "description": "Categorical single/multi-option selector `Dropdown`.\n\nCan be provided to [`Filter`][vizro.models.Filter] or\n[`Parameter`][vizro.models.Parameter]. Based on the underlying\n[`dcc.Dropdown`](https://dash.plotly.com/dash-core-components/dropdown).\n\nArgs:\n type (Literal[\"dropdown\"]): Defaults to `\"dropdown\"`.\n options (OptionsType): See [`OptionsType`][vizro.models.types.OptionsType]. Defaults to `[]`.\n value (Optional[Union[SingleValueType, MultiValueType]]): See\n [`SingleValueType`][vizro.models.types.SingleValueType] and\n [`MultiValueType`][vizro.models.types.MultiValueType]. Defaults to `None`.\n multi (bool): Whether to allow selection of multiple values. Defaults to `True`.\n title (str): Title to be displayed. Defaults to `\"\"`.\n actions (list[Action]): See [`Action`][vizro.models.Action]. Defaults to `[]`.", - "type": "object", + "Layout": { + "additionalProperties": false, + "description": "Grid specification to place chart/components on the [`Page`][vizro.models.Page].\n\nArgs:\n grid (list[list[int]]): Grid specification to arrange components on screen.\n row_gap (str): Gap between rows in px. Defaults to `\"12px\"`.\n col_gap (str): Gap between columns in px. Defaults to `\"12px\"`.\n row_min_height (str): Minimum row height in px. Defaults to `\"0px\"`.\n col_min_width (str): Minimum column width in px. Defaults to `\"0px\"`.", "properties": { "id": { - "title": "Id", + "default": "", "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", + "title": "Id", + "type": "string" + }, + "grid": { + "description": "Grid specification to arrange components on screen.", + "items": { + "items": { + "type": "integer" + }, + "type": "array" + }, + "title": "Grid", + "type": "array" + }, + "row_gap": { + "default": "24px", + "description": "Gap between rows in px.", + "pattern": "[0-9]+px", + "title": "Row Gap", + "type": "string" + }, + "col_gap": { + "default": "24px", + "description": "Gap between columns in px.", + "pattern": "[0-9]+px", + "title": "Col Gap", + "type": "string" + }, + "row_min_height": { + "default": "0px", + "description": "Minimum row height in px.", + "pattern": "[0-9]+px", + "title": "Row Min Height", + "type": "string" + }, + "col_min_width": { + "default": "0px", + "description": "Minimum column width in px.", + "pattern": "[0-9]+px", + "title": "Col Min Width", + "type": "string" + } + }, + "required": ["grid"], + "title": "Layout", + "type": "object" + }, + "NavBar": { + "additionalProperties": false, + "description": "Navigation bar to be used as a nav_selector for `Navigation`.\n\nArgs:\n type (Literal[\"nav_bar\"]): Defaults to `\"nav_bar\"`.\n pages (dict[str, list[str]]): Mapping from name of a pages group to a list of page IDs. Defaults to `{}`.\n items (list[NavLink]): See [`NavLink`][vizro.models.NavLink]. Defaults to `[]`.", + "properties": { + "id": { "default": "", + "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", + "title": "Id", "type": "string" }, "type": { + "const": "nav_bar", + "default": "nav_bar", "title": "Type", - "default": "dropdown", - "enum": ["dropdown"], "type": "string" }, - "options": { - "title": "Options", - "default": [], - "anyOf": [ - { - "type": "array", - "items": { - "type": "boolean" - } - }, - { - "type": "array", - "items": { - "type": "number" - } - }, - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "array", - "items": { - "type": "string", - "format": "date" - } + "pages": { + "additionalProperties": { + "items": { + "type": "string" }, - { - "type": "array", - "items": { - "$ref": "#/definitions/OptionsDictType" - } - } - ] + "type": "array" + }, + "default": {}, + "description": "Mapping from name of a pages group to a list of page IDs.", + "title": "Pages", + "type": "object" }, - "value": { - "title": "Value", + "items": { + "default": [], + "items": { + "$ref": "#/$defs/NavLink" + }, + "title": "Items", + "type": "array" + } + }, + "title": "NavBar", + "type": "object" + }, + "NavLink": { + "additionalProperties": false, + "description": "Icon that serves as a navigation link to be used in navigation bar of Dashboard.\n\nArgs:\n pages (NavPagesType): See [`NavPagesType`][vizro.models.types.NavPagesType]. Defaults to `[]`.\n label (str): Text description of the icon for use in tooltip.\n icon (str): Icon name from [Google Material icons library](https://fonts.google.com/icons). Defaults to `\"\"`.", + "properties": { + "id": { + "default": "", + "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", + "title": "Id", + "type": "string" + }, + "pages": { "anyOf": [ { - "type": "boolean" - }, - { - "type": "number" - }, - { - "type": "string" - }, - { - "type": "string", - "format": "date" - }, - { - "type": "array", - "items": { - "type": "boolean" - } - }, - { - "type": "array", - "items": { - "type": "number" - } - }, - { - "type": "array", "items": { "type": "string" - } + }, + "type": "array" }, { - "type": "array", - "items": { - "type": "string", - "format": "date" - } + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "object" } - ] + ], + "default": [], + "title": "Pages" }, - "multi": { - "title": "Multi", - "description": "Whether to allow selection of multiple values", - "default": true, - "type": "boolean" + "label": { + "description": "Text description of the icon for use in tooltip.", + "title": "Label", + "type": "string" }, - "title": { - "title": "Title", - "description": "Title to be displayed", + "icon": { "default": "", + "description": "Icon name from Google Material icons library.", + "title": "Icon", "type": "string" - }, - "actions": { - "title": "Actions", - "default": [], - "type": "array", - "items": { - "$ref": "#/definitions/Action" - } } }, - "additionalProperties": false + "required": ["label"], + "title": "NavLink", + "type": "object" }, - "RadioItems": { - "title": "RadioItems", - "description": "Categorical single-option selector `RadioItems`.\n\nCan be provided to [`Filter`][vizro.models.Filter] or\n[`Parameter`][vizro.models.Parameter]. Based on the underlying\n[`dcc.RadioItems`](https://dash.plotly.com/dash-core-components/radioitems).\n\nArgs:\n type (Literal[\"radio_items\"]): Defaults to `\"radio_items\"`.\n options (OptionsType): See [`OptionsType`][vizro.models.types.OptionsType]. Defaults to `[]`.\n value (Optional[SingleValueType]): See [`SingleValueType`][vizro.models.types.SingleValueType].\n Defaults to `None`.\n title (str): Title to be displayed. Defaults to `\"\"`.\n actions (list[Action]): See [`Action`][vizro.models.Action]. Defaults to `[]`.", - "type": "object", + "Navigation": { + "additionalProperties": false, + "description": "Navigation in [`Dashboard`][vizro.models.Dashboard] to structure [`Pages`][vizro.models.Page].\n\nArgs:\n pages (NavPagesType): See [`NavPagesType`][vizro.models.types.NavPagesType]. Defaults to `[]`.\n nav_selector (Optional[NavSelectorType]): See [`NavSelectorType`][vizro.models.types.NavSelectorType].\n Defaults to `None`.", "properties": { "id": { - "title": "Id", - "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", "default": "", + "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", + "title": "Id", "type": "string" }, - "type": { - "title": "Type", - "default": "radio_items", - "enum": ["radio_items"], - "type": "string" - }, - "options": { - "title": "Options", - "default": [], + "pages": { "anyOf": [ { - "type": "array", - "items": { - "type": "boolean" - } - }, - { - "type": "array", - "items": { - "type": "number" - } - }, - { - "type": "array", "items": { "type": "string" - } + }, + "type": "array" }, { - "type": "array", - "items": { - "type": "string", - "format": "date" - } + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "object" + } + ], + "default": [], + "title": "Pages" + }, + "nav_selector": { + "anyOf": [ + { + "description": "Component for rendering navigation.", + "discriminator": { + "mapping": { + "accordion": "#/$defs/Accordion", + "nav_bar": "#/$defs/NavBar" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/$defs/Accordion" + }, + { + "$ref": "#/$defs/NavBar" + } + ] }, { - "type": "array", - "items": { - "$ref": "#/definitions/OptionsDictType" - } + "type": "null" } - ] + ], + "default": null, + "title": "Nav Selector" + } + }, + "title": "Navigation", + "type": "object" + }, + "OptionsDictType": { + "description": "Permissible sub-type for OptionsType. Needs to be in the format of {\"label\": XXX, \"value\": XXX}.", + "properties": { + "label": { + "title": "Label", + "type": "string" }, "value": { - "title": "Value", "anyOf": [ { "type": "boolean" @@ -845,606 +977,636 @@ "type": "string" }, { - "type": "string", - "format": "date" + "format": "date", + "type": "string" } - ] - }, - "title": { - "title": "Title", - "description": "Title to be displayed", - "default": "", - "type": "string" - }, - "actions": { - "title": "Actions", - "default": [], - "type": "array", - "items": { - "$ref": "#/definitions/Action" - } + ], + "title": "Value" } }, - "additionalProperties": false + "required": ["label", "value"], + "title": "OptionsDictType", + "type": "object" }, - "RangeSlider": { - "title": "RangeSlider", - "description": "Numeric multi-option selector `RangeSlider`.\n\nCan be provided to [`Filter`][vizro.models.Filter] or\n[`Parameter`][vizro.models.Parameter]. Based on the underlying\n[`dcc.RangeSlider`](https://dash.plotly.com/dash-core-components/rangeslider).\n\nArgs:\n type (Literal[\"range_slider\"]): Defaults to `\"range_slider\"`.\n min (Optional[float]): Start value for slider. Defaults to `None`.\n max (Optional[float]): End value for slider. Defaults to `None`.\n step (Optional[float]): Step-size for marks on slider. Defaults to `None`.\n marks (Optional[dict[int, Union[str, dict]]]): Marks to be displayed on slider. Defaults to `{}`.\n value (Optional[list[float]]): Default start and end value for slider. Must be 2 items. Defaults to `None`.\n title (str): Title to be displayed. Defaults to `\"\"`.\n actions (list[Action]): See [`Action`][vizro.models.Action]. Defaults to `[]`.", - "type": "object", + "Page": { + "additionalProperties": false, + "description": "A page in [`Dashboard`][vizro.models.Dashboard] with its own URL path and place in the `Navigation`.\n\nArgs:\n components (list[ComponentType]): See [ComponentType][vizro.models.types.ComponentType]. At least one component\n has to be provided.\n title (str): Title to be displayed.\n description (str): Description for meta tags.\n layout (Optional[Layout]): Layout to place components in. Defaults to `None`.\n controls (list[ControlType]): See [ControlType][vizro.models.types.ControlType]. Defaults to `[]`.\n path (str): Path to navigate to page. Defaults to `\"\"`.", "properties": { "id": { - "title": "Id", - "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", "default": "", + "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", + "title": "Id", "type": "string" }, - "type": { - "title": "Type", - "default": "range_slider", - "enum": ["range_slider"], - "type": "string" - }, - "min": { - "title": "Min", - "description": "Start value for slider.", - "type": "number" - }, - "max": { - "title": "Max", - "description": "End value for slider.", - "type": "number" - }, - "step": { - "title": "Step", - "description": "Step-size for marks on slider.", - "type": "number" - }, - "marks": { - "title": "Marks", - "description": "Marks to be displayed on slider.", - "default": {}, - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "value": { - "title": "Value", - "description": "Default start and end value for slider", - "minItems": 2, - "maxItems": 2, - "type": "array", + "components": { "items": { - "type": "number" - } + "description": "Component that makes up part of the layout on the page.", + "discriminator": { + "mapping": { + "ag_grid": "#/$defs/AgGrid", + "button": "#/$defs/Button", + "card": "#/$defs/Card", + "container": "#/$defs/Container", + "figure": "#/$defs/Figure", + "graph": "#/$defs/Graph", + "table": "#/$defs/Table", + "tabs": "#/$defs/Tabs" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/$defs/AgGrid" + }, + { + "$ref": "#/$defs/Button" + }, + { + "$ref": "#/$defs/Card" + }, + { + "$ref": "#/$defs/Container" + }, + { + "$ref": "#/$defs/Figure" + }, + { + "$ref": "#/$defs/Graph" + }, + { + "$ref": "#/$defs/Table" + }, + { + "$ref": "#/$defs/Tabs" + } + ] + }, + "minItems": 1, + "title": "Components", + "type": "array" }, "title": { - "title": "Title", "description": "Title to be displayed.", - "default": "", + "title": "Title", "type": "string" }, - "actions": { - "title": "Actions", - "default": [], - "type": "array", - "items": { - "$ref": "#/definitions/Action" - } - } - }, - "additionalProperties": false - }, - "Slider": { - "title": "Slider", - "description": "Numeric single-option selector `Slider`.\n\nCan be provided to [`Filter`][vizro.models.Filter] or\n[`Parameter`][vizro.models.Parameter]. Based on the underlying\n[`dcc.Slider`](https://dash.plotly.com/dash-core-components/slider).\n\nArgs:\n type (Literal[\"range_slider\"]): Defaults to `\"range_slider\"`.\n min (Optional[float]): Start value for slider. Defaults to `None`.\n max (Optional[float]): End value for slider. Defaults to `None`.\n step (Optional[float]): Step-size for marks on slider. Defaults to `None`.\n marks (Optional[dict[int, Union[str, dict]]]): Marks to be displayed on slider. Defaults to `{}`.\n value (Optional[float]): Default value for slider. Defaults to `None`.\n title (str): Title to be displayed. Defaults to `\"\"`.\n actions (list[Action]): See [`Action`][vizro.models.Action]. Defaults to `[]`.", - "type": "object", - "properties": { - "id": { - "title": "Id", - "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", + "description": { "default": "", + "description": "Description for meta tags.", + "title": "Description", "type": "string" }, - "type": { - "title": "Type", - "default": "slider", - "enum": ["slider"], - "type": "string" - }, - "min": { - "title": "Min", - "description": "Start value for slider.", - "type": "number" - }, - "max": { - "title": "Max", - "description": "End value for slider.", - "type": "number" - }, - "step": { - "title": "Step", - "description": "Step-size for marks on slider.", - "type": "number" - }, - "marks": { - "title": "Marks", - "description": "Marks to be displayed on slider.", - "default": {}, - "type": "object", - "additionalProperties": { - "type": "string" - } + "layout": { + "anyOf": [ + { + "$ref": "#/$defs/Layout" + }, + { + "type": "null" + } + ], + "default": null }, - "value": { - "title": "Value", - "description": "Default value for slider.", - "type": "number" + "controls": { + "default": [], + "items": { + "description": "Control that affects components on the page.", + "discriminator": { + "mapping": { + "filter": "#/$defs/Filter", + "parameter": "#/$defs/Parameter" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/$defs/Filter" + }, + { + "$ref": "#/$defs/Parameter" + } + ] + }, + "title": "Controls", + "type": "array" }, - "title": { - "title": "Title", - "description": "Title to be displayed.", + "path": { "default": "", + "description": "Path to navigate to page.", + "title": "Path", "type": "string" }, "actions": { - "title": "Actions", "default": [], - "type": "array", "items": { - "$ref": "#/definitions/Action" - } + "$ref": "#/$defs/ActionsChain" + }, + "title": "Actions", + "type": "array" } }, - "additionalProperties": false + "required": ["components", "title"], + "title": "Page", + "type": "object" }, - "Filter": { - "title": "Filter", - "description": "Filter the data supplied to `targets` on the [`Page`][vizro.models.Page].\n\nExamples:\n >>> print(repr(Filter(column=\"species\")))\n\nArgs:\n type (Literal[\"filter\"]): Defaults to `\"filter\"`.\n column (str): Column of `DataFrame` to filter.\n targets (list[ModelID]): Target component to be affected by filter. If none are given then target all components\n on the page that use `column`.\n selector (SelectorType): See [SelectorType][vizro.models.types.SelectorType]. Defaults to `None`.", - "type": "object", + "Parameter": { + "additionalProperties": false, + "description": "Alter the arguments supplied to any `targets` on the [`Page`][vizro.models.Page].\n\nExamples:\n >>> Parameter(targets=[\"scatter.x\"], selector=Slider(min=0, max=1, default=0.8, title=\"Bubble opacity\"))\n\nArgs:\n type (Literal[\"parameter\"]): Defaults to `\"parameter\"`.\n targets (list[str]): Targets in the form of `.`.\n selector (SelectorType): See [SelectorType][vizro.models.types.SelectorType]. Converts selector value\n `\"NONE\"` into `None` to allow optional parameters.", "properties": { "id": { - "title": "Id", - "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", "default": "", + "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", + "title": "Id", "type": "string" }, "type": { + "const": "parameter", + "default": "parameter", "title": "Type", - "default": "filter", - "enum": ["filter"], - "type": "string" - }, - "column": { - "title": "Column", - "description": "Column of DataFrame to filter.", "type": "string" }, "targets": { - "title": "Targets", - "description": "Target component to be affected by filter. If none are given then target all components on the page that use `column`.", - "default": [], - "type": "array", "items": { + "description": "Targets in the form of `.`.", "type": "string" - } + }, + "title": "Targets", + "type": "array" }, "selector": { - "title": "Selector", "description": "Selectors to be used inside a control.", "discriminator": { - "propertyName": "type", "mapping": { - "checklist": "#/definitions/Checklist", - "date_picker": "#/definitions/DatePicker", - "dropdown": "#/definitions/Dropdown", - "radio_items": "#/definitions/RadioItems", - "range_slider": "#/definitions/RangeSlider", - "slider": "#/definitions/Slider" - } + "checklist": "#/$defs/Checklist", + "date_picker": "#/$defs/DatePicker", + "dropdown": "#/$defs/Dropdown", + "radio_items": "#/$defs/RadioItems", + "range_slider": "#/$defs/RangeSlider", + "slider": "#/$defs/Slider" + }, + "propertyName": "type" }, "oneOf": [ { - "$ref": "#/definitions/Checklist" + "$ref": "#/$defs/Checklist" }, { - "$ref": "#/definitions/DatePicker" + "$ref": "#/$defs/DatePicker" }, { - "$ref": "#/definitions/Dropdown" + "$ref": "#/$defs/Dropdown" }, { - "$ref": "#/definitions/RadioItems" + "$ref": "#/$defs/RadioItems" }, { - "$ref": "#/definitions/RangeSlider" + "$ref": "#/$defs/RangeSlider" }, { - "$ref": "#/definitions/Slider" + "$ref": "#/$defs/Slider" } - ] + ], + "title": "Selector" } }, - "required": ["column"], - "additionalProperties": false - }, - "Parameter": { + "required": ["targets", "selector"], "title": "Parameter", - "description": "Alter the arguments supplied to any `targets` on the [`Page`][vizro.models.Page].\n\nExamples:\n >>> Parameter(targets=[\"scatter.x\"], selector=Slider(min=0, max=1, default=0.8, title=\"Bubble opacity\"))\n\nArgs:\n type (Literal[\"parameter\"]): Defaults to `\"parameter\"`.\n targets (list[str]): Targets in the form of `.`.\n selector (SelectorType): See [SelectorType][vizro.models.types.SelectorType]. Converts selector value\n `\"NONE\"` into `None` to allow optional parameters.", - "type": "object", + "type": "object" + }, + "RadioItems": { + "additionalProperties": false, + "description": "Categorical single-option selector `RadioItems`.\n\nCan be provided to [`Filter`][vizro.models.Filter] or\n[`Parameter`][vizro.models.Parameter]. Based on the underlying\n[`dcc.RadioItems`](https://dash.plotly.com/dash-core-components/radioitems).\n\nArgs:\n type (Literal[\"radio_items\"]): Defaults to `\"radio_items\"`.\n options (OptionsType): See [`OptionsType`][vizro.models.types.OptionsType]. Defaults to `[]`.\n value (Optional[SingleValueType]): See [`SingleValueType`][vizro.models.types.SingleValueType].\n Defaults to `None`.\n title (str): Title to be displayed. Defaults to `\"\"`.\n actions (list[Action]): See [`Action`][vizro.models.Action]. Defaults to `[]`.", "properties": { "id": { - "title": "Id", - "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", "default": "", + "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", + "title": "Id", "type": "string" }, "type": { + "const": "radio_items", + "default": "radio_items", "title": "Type", - "default": "parameter", - "enum": ["parameter"], "type": "string" }, - "targets": { - "title": "Targets", - "description": "Targets in the form of `.`.", - "type": "array", - "items": { - "type": "string" - } - }, - "selector": { - "title": "Selector", - "description": "Selectors to be used inside a control.", - "discriminator": { - "propertyName": "type", - "mapping": { - "checklist": "#/definitions/Checklist", - "date_picker": "#/definitions/DatePicker", - "dropdown": "#/definitions/Dropdown", - "radio_items": "#/definitions/RadioItems", - "range_slider": "#/definitions/RangeSlider", - "slider": "#/definitions/Slider" - } - }, - "oneOf": [ - { - "$ref": "#/definitions/Checklist" - }, + "options": { + "anyOf": [ { - "$ref": "#/definitions/DatePicker" + "items": { + "type": "boolean" + }, + "type": "array" }, { - "$ref": "#/definitions/Dropdown" + "items": { + "type": "number" + }, + "type": "array" }, { - "$ref": "#/definitions/RadioItems" + "items": { + "type": "string" + }, + "type": "array" }, { - "$ref": "#/definitions/RangeSlider" + "items": { + "format": "date", + "type": "string" + }, + "type": "array" }, { - "$ref": "#/definitions/Slider" + "items": { + "$ref": "#/$defs/OptionsDictType" + }, + "type": "array" } - ] - } - }, - "required": ["targets", "selector"], - "additionalProperties": false - }, - "ActionsChain": { - "title": "ActionsChain", - "description": "All models that are registered to the model manager should inherit from this class.\n\nArgs:\n id (str): ID to identify model. Must be unique throughout the whole dashboard. Defaults to `\"\"`.\n When no ID is chosen, ID will be automatically generated.", - "type": "object", - "properties": { - "id": { - "title": "Id", - "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", - "default": "", - "type": "string" + ], + "default": [], + "title": "Options" }, - "trigger": { - "title": "Trigger", - "type": "array", - "items": [ + "value": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "number" + }, { - "title": "Component Id", "type": "string" }, { - "title": "Component Property", + "format": "date", "type": "string" + }, + { + "type": "null" } ], - "minItems": 2, - "maxItems": 2 + "default": null, + "title": "Value" + }, + "title": { + "default": "", + "description": "Title to be displayed", + "title": "Title", + "type": "string" }, "actions": { - "title": "Actions", "default": [], - "type": "array", "items": { - "$ref": "#/definitions/Action" - } + "$ref": "#/$defs/Action" + }, + "title": "Actions", + "type": "array" } }, - "required": ["trigger"], - "additionalProperties": false + "title": "RadioItems", + "type": "object" }, - "Page": { - "title": "Page", - "description": "A page in [`Dashboard`][vizro.models.Dashboard] with its own URL path and place in the `Navigation`.\n\nArgs:\n components (list[ComponentType]): See [ComponentType][vizro.models.types.ComponentType]. At least one component\n has to be provided.\n title (str): Title to be displayed.\n description (str): Description for meta tags.\n layout (Layout): Layout to place components in. Defaults to `None`.\n controls (list[ControlType]): See [ControlType][vizro.models.types.ControlType]. Defaults to `[]`.\n path (str): Path to navigate to page. Defaults to `\"\"`.", - "type": "object", + "RangeSlider": { + "additionalProperties": false, + "description": "Numeric multi-option selector `RangeSlider`.\n\nCan be provided to [`Filter`][vizro.models.Filter] or\n[`Parameter`][vizro.models.Parameter]. Based on the underlying\n[`dcc.RangeSlider`](https://dash.plotly.com/dash-core-components/rangeslider).\n\nArgs:\n type (Literal[\"range_slider\"]): Defaults to `\"range_slider\"`.\n min (Optional[float]): Start value for slider. Defaults to `None`.\n max (Optional[float]): End value for slider. Defaults to `None`.\n step (Optional[float]): Step-size for marks on slider. Defaults to `None`.\n marks (Optional[dict[Union[float, int], str]]): Marks to be displayed on slider. Defaults to `{}`.\n value (Optional[list[float]]): Default start and end value for slider. Must be 2 items. Defaults to `None`.\n title (str): Title to be displayed. Defaults to `\"\"`.\n actions (list[Action]): See [`Action`][vizro.models.Action]. Defaults to `[]`.", "properties": { "id": { - "title": "Id", - "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", "default": "", + "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", + "title": "Id", "type": "string" }, - "components": { - "title": "Components", - "type": "array", - "items": { - "discriminator": { - "propertyName": "type", - "mapping": { - "ag_grid": "#/definitions/AgGrid", - "button": "#/definitions/Button", - "card": "#/definitions/Card", - "container": "#/definitions/Container", - "figure": "#/definitions/Figure", - "graph": "#/definitions/Graph", - "table": "#/definitions/Table", - "tabs": "#/definitions/Tabs" - } - }, - "oneOf": [ - { - "$ref": "#/definitions/AgGrid" - }, - { - "$ref": "#/definitions/Button" - }, - { - "$ref": "#/definitions/Card" - }, - { - "$ref": "#/definitions/Container" - }, - { - "$ref": "#/definitions/Figure" - }, - { - "$ref": "#/definitions/Graph" - }, - { - "$ref": "#/definitions/Table" - }, - { - "$ref": "#/definitions/Tabs" - } - ] - } - }, - "title": { - "title": "Title", - "description": "Title to be displayed.", + "type": { + "const": "range_slider", + "default": "range_slider", + "title": "Type", "type": "string" }, - "description": { - "title": "Description", - "description": "Description for meta tags.", - "default": "", - "type": "string" + "min": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Start value for slider.", + "title": "Min" }, - "layout": { - "$ref": "#/definitions/Layout" + "max": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "description": "End value for slider.", + "title": "Max" }, - "controls": { - "title": "Controls", - "default": [], - "type": "array", - "items": { - "discriminator": { - "propertyName": "type", - "mapping": { - "filter": "#/definitions/Filter", - "parameter": "#/definitions/Parameter" - } + "step": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Step-size for marks on slider.", + "title": "Step" + }, + "marks": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" }, - "oneOf": [ - { - "$ref": "#/definitions/Filter" + { + "type": "null" + } + ], + "default": {}, + "description": "Marks to be displayed on slider.", + "title": "Marks" + }, + "value": { + "anyOf": [ + { + "items": { + "type": "number" }, - { - "$ref": "#/definitions/Parameter" - } - ] - } + "maxItems": 2, + "minItems": 2, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Value" }, - "path": { - "title": "Path", - "description": "Path to navigate to page.", + "title": { "default": "", + "description": "Title to be displayed.", + "title": "Title", "type": "string" }, "actions": { - "title": "Actions", "default": [], - "type": "array", "items": { - "$ref": "#/definitions/ActionsChain" - } + "$ref": "#/$defs/Action" + }, + "title": "Actions", + "type": "array" } }, - "required": ["components", "title"], - "additionalProperties": false + "title": "RangeSlider", + "type": "object" }, - "Accordion": { - "title": "Accordion", - "description": "Accordion to be used as nav_selector in [`Navigation`][vizro.models.Navigation].\n\nArgs:\n type (Literal[\"accordion\"]): Defaults to `\"accordion\"`.\n pages (dict[str, list[str]]): Mapping from name of a pages group to a list of page IDs. Defaults to `{}`.", - "type": "object", + "Slider": { + "additionalProperties": false, + "description": "Numeric single-option selector `Slider`.\n\nCan be provided to [`Filter`][vizro.models.Filter] or\n[`Parameter`][vizro.models.Parameter]. Based on the underlying\n[`dcc.Slider`](https://dash.plotly.com/dash-core-components/slider).\n\nArgs:\n type (Literal[\"range_slider\"]): Defaults to `\"range_slider\"`.\n min (Optional[float]): Start value for slider. Defaults to `None`.\n max (Optional[float]): End value for slider. Defaults to `None`.\n step (Optional[float]): Step-size for marks on slider. Defaults to `None`.\n marks (Optional[dict[Union[float, int], str]]): Marks to be displayed on slider. Defaults to `{}`.\n value (Optional[float]): Default value for slider. Defaults to `None`.\n title (str): Title to be displayed. Defaults to `\"\"`.\n actions (list[Action]): See [`Action`][vizro.models.Action]. Defaults to `[]`.", "properties": { "id": { - "title": "Id", - "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", "default": "", + "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", + "title": "Id", "type": "string" }, "type": { + "const": "slider", + "default": "slider", "title": "Type", - "default": "accordion", - "enum": ["accordion"], "type": "string" }, - "pages": { - "title": "Pages", - "description": "Mapping from name of a pages group to a list of page IDs.", - "default": {}, - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "type": "string" + "min": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" } - } - } - }, - "additionalProperties": false - }, - "NavLink": { - "title": "NavLink", - "description": "Icon that serves as a navigation link to be used in navigation bar of Dashboard.\n\nArgs:\n pages (NavPagesType): See [`NavPagesType`][vizro.models.types.NavPagesType]. Defaults to `[]`.\n label (str): Text description of the icon for use in tooltip.\n icon (str): Icon name from [Google Material icons library](https://fonts.google.com/icons). Defaults to `\"\"`.", - "type": "object", - "properties": { - "id": { - "title": "Id", - "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", - "default": "", - "type": "string" + ], + "default": null, + "description": "Start value for slider.", + "title": "Min" }, - "pages": { - "title": "Pages", - "default": [], + "max": { "anyOf": [ { - "type": "array", - "items": { - "type": "string" - } + "type": "number" }, { - "type": "object", + "type": "null" + } + ], + "default": null, + "description": "End value for slider.", + "title": "Max" + }, + "step": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Step-size for marks on slider.", + "title": "Step" + }, + "marks": { + "anyOf": [ + { "additionalProperties": { - "type": "array", - "items": { - "type": "string" - } - } + "type": "string" + }, + "type": "object" + }, + { + "type": "null" } - ] + ], + "default": {}, + "description": "Marks to be displayed on slider.", + "title": "Marks" }, - "label": { - "title": "Label", - "description": "Text description of the icon for use in tooltip.", - "type": "string" + "value": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Default value for slider.", + "title": "Value" }, - "icon": { - "title": "Icon", - "description": "Icon name from Google Material icons library.", + "title": { "default": "", + "description": "Title to be displayed.", + "title": "Title", "type": "string" + }, + "actions": { + "default": [], + "items": { + "$ref": "#/$defs/Action" + }, + "title": "Actions", + "type": "array" } }, - "required": ["label"], - "additionalProperties": false + "title": "Slider", + "type": "object" }, - "NavBar": { - "title": "NavBar", - "description": "Navigation bar to be used as a nav_selector for `Navigation`.\n\nArgs:\n type (Literal[\"nav_bar\"]): Defaults to `\"nav_bar\"`.\n pages (dict[str, list[str]]): Mapping from name of a pages group to a list of page IDs. Defaults to `{}`.\n items (list[NavLink]): See [`NavLink`][vizro.models.NavLink]. Defaults to `[]`.", - "type": "object", + "Table": { + "additionalProperties": false, + "description": "Wrapper for `dash_table.DataTable` to visualize tables in dashboard.\n\nArgs:\n type (Literal[\"table\"]): Defaults to `\"table\"`.\n figure (CapturedCallable): Function that returns a Dash DataTable. See [`vizro.tables`][vizro.tables].\n title (str): Title of the `Table`. Defaults to `\"\"`.\n header (str): Markdown text positioned below the `Table.title`. Follows the CommonMark specification.\n Ideal for adding supplementary information such as subtitles, descriptions, or additional context.\n Defaults to `\"\"`.\n footer (str): Markdown text positioned below the `Table`. Follows the CommonMark specification.\n Ideal for providing further details such as sources, disclaimers, or additional notes. Defaults to `\"\"`.\n actions (list[Action]): See [`Action`][vizro.models.Action]. Defaults to `[]`.", "properties": { "id": { - "title": "Id", - "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", "default": "", + "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", + "title": "Id", "type": "string" }, "type": { + "const": "table", + "default": "table", "title": "Type", - "default": "nav_bar", - "enum": ["nav_bar"], "type": "string" }, - "pages": { - "title": "Pages", - "description": "Mapping from name of a pages group to a list of page IDs.", - "default": {}, - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "type": "string" - } - } + "title": { + "default": "", + "description": "Title of the `Table`", + "title": "Title", + "type": "string" }, - "items": { - "title": "Items", + "header": { + "default": "", + "description": "Markdown text positioned below the `Table.title`. Follows the CommonMark specification. Ideal for adding supplementary information such as subtitles, descriptions, or additional context.", + "title": "Header", + "type": "string" + }, + "footer": { + "default": "", + "description": "Markdown text positioned below the `Table`. Follows the CommonMark specification. Ideal for providing further details such as sources, disclaimers, or additional notes.", + "title": "Footer", + "type": "string" + }, + "actions": { "default": [], - "type": "array", "items": { - "$ref": "#/definitions/NavLink" - } + "$ref": "#/$defs/Action" + }, + "title": "Actions", + "type": "array" } }, - "additionalProperties": false + "title": "Table", + "type": "object" }, - "Navigation": { - "title": "Navigation", - "description": "Navigation in [`Dashboard`][vizro.models.Dashboard] to structure [`Pages`][vizro.models.Page].\n\nArgs:\n pages (NavPagesType): See [`NavPagesType`][vizro.models.types.NavPagesType]. Defaults to `[]`.\n nav_selector (NavSelectorType): See [`NavSelectorType`][vizro.models.types.NavSelectorType].\n Defaults to `None`.", - "type": "object", + "Tabs": { + "additionalProperties": false, + "description": "Tabs to group together a set of containers on a page.\n\nArgs:\n type (Literal[\"tabs\"]): Defaults to `\"tabs\"`.\n tabs (list[Container]): See [`Container`][vizro.models.Container].", "properties": { "id": { - "title": "Id", - "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", "default": "", + "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", + "title": "Id", "type": "string" }, - "pages": { - "title": "Pages", - "default": [], - "anyOf": [ - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "type": "string" - } - } - } - ] + "type": { + "const": "tabs", + "default": "tabs", + "title": "Type", + "type": "string" }, - "nav_selector": { - "title": "Nav Selector", - "anyOf": [ - { - "$ref": "#/definitions/Accordion" - }, - { - "$ref": "#/definitions/NavBar" - } - ] + "tabs": { + "items": { + "$ref": "#/$defs/Container" + }, + "minItems": 1, + "title": "Tabs", + "type": "array" + } + }, + "required": ["tabs"], + "title": "Tabs", + "type": "object" + }, + "Trigger": { + "maxItems": 2, + "minItems": 2, + "prefixItems": [ + { + "title": "Component Id", + "type": "string" + }, + { + "title": "Component Property", + "type": "string" } + ], + "type": "array" + } + }, + "additionalProperties": false, + "description": "Vizro Dashboard to be used within [`Vizro`][vizro._vizro.Vizro.build].\n\nArgs:\n pages (list[Page]): See [`Page`][vizro.models.Page].\n theme (Literal[\"vizro_dark\", \"vizro_light\"]): Layout theme to be applied across dashboard.\n Defaults to `vizro_dark`.\n navigation (Navigation): See [`Navigation`][vizro.models.Navigation]. Defaults to `None`.\n title (str): Dashboard title to appear on every page on top left-side. Defaults to `\"\"`.", + "properties": { + "id": { + "default": "", + "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", + "title": "Id", + "type": "string" + }, + "pages": { + "items": { + "$ref": "#/$defs/Page" }, - "additionalProperties": false + "title": "Pages", + "type": "array" + }, + "theme": { + "default": "vizro_dark", + "description": "Layout theme to be applied across dashboard. Defaults to `vizro_dark`.", + "enum": ["vizro_dark", "vizro_light"], + "title": "Theme", + "type": "string" + }, + "navigation": { + "anyOf": [ + { + "$ref": "#/$defs/Navigation" + }, + { + "type": "null" + } + ], + "default": null + }, + "title": { + "default": "", + "description": "Dashboard title to appear on every page on top left-side.", + "title": "Title", + "type": "string" } - } + }, + "required": ["pages"], + "title": "Dashboard", + "type": "object" } diff --git a/vizro-core/schemas/generate.py b/vizro-core/schemas/generate.py index a6cd91247..35b48b030 100644 --- a/vizro-core/schemas/generate.py +++ b/vizro-core/schemas/generate.py @@ -12,7 +12,7 @@ parser.add_argument("--check", help="check schema is up to date", action="store_true") args = parser.parse_args() -schema_json = Dashboard.schema_json(indent=4, by_alias=False) +schema_json = json.dumps(Dashboard.model_json_schema(by_alias=False), indent=4) schema_path = Path(__file__).with_name(f"{__version__}.json") if args.check: diff --git a/vizro-core/src/vizro/__init__.py b/vizro-core/src/vizro/__init__.py index 558b0f742..5f90bdc96 100644 --- a/vizro-core/src/vizro/__init__.py +++ b/vizro-core/src/vizro/__init__.py @@ -17,7 +17,7 @@ __all__ = ["Vizro"] -__version__ = "0.1.32.dev0" +__version__ = "0.2.0.dev0" # For the below _css_dist and _js_dist to be used by Dash, they must be retrieved by dash.resources.Css.get_all_css(). diff --git a/vizro-core/src/vizro/actions/_actions_utils.py b/vizro-core/src/vizro/actions/_actions_utils.py index 1873b6620..b21dfc0b7 100644 --- a/vizro-core/src/vizro/actions/_actions_utils.py +++ b/vizro-core/src/vizro/actions/_actions_utils.py @@ -12,12 +12,18 @@ from vizro.managers import data_manager, model_manager from vizro.managers._data_manager import DataSourceName from vizro.managers._model_manager import ModelID -from vizro.models.types import MultiValueType, SelectorType, SingleValueType +from vizro.models.types import ( + FigureType, + FigureWithFilterInteractionType, + MultiValueType, + SelectorType, + SingleValueType, +) if TYPE_CHECKING: from vizro.models import Action, VizroBaseModel -ValidatedNoneValueType = Union[SingleValueType, MultiValueType, None, list[None]] +ValidatedNoneValueType = Union[SingleValueType, MultiValueType, None, list[None], list[SingleValueType]] class CallbackTriggerDict(TypedDict): @@ -108,7 +114,7 @@ def _apply_filter_interaction( """ for ctd_filter_interaction in ctds_filter_interaction: triggered_model = model_manager[ctd_filter_interaction["modelID"]["id"]] - data_frame = triggered_model._filter_interaction( + data_frame = cast(FigureWithFilterInteractionType, triggered_model)._filter_interaction( data_frame=data_frame, target=target, ctd_filter_interaction=ctd_filter_interaction, @@ -121,7 +127,7 @@ def _validate_selector_value_none(value: Union[SingleValueType, MultiValueType]) if value == NONE_OPTION: return None elif isinstance(value, list): - return [i for i in value if i != NONE_OPTION] or [None] + return [i for i in value if i != NONE_OPTION] or [None] # type: ignore[list-item] return value @@ -182,20 +188,20 @@ def _get_parametrized_config( else: # TODO - avoid calling _captured_callable. Once we have done this we can remove _arguments from # CapturedCallable entirely. This might mean not being able to address nested parameters. - config = deepcopy(model_manager[target].figure._arguments) + config = deepcopy(cast(FigureType, model_manager[target]).figure._arguments) del config["data_frame"] for ctd in ctds_parameter: # TODO: needs to be refactored so that it is independent of implementation details parameter_value = ctd["value"] - selector: SelectorType = model_manager[ctd["id"]] + selector = cast(SelectorType, model_manager[ctd["id"]]) if hasattr(parameter_value, "__iter__") and ALL_OPTION in parameter_value: # type: ignore[operator] # Even if an option is provided as list[dict], the Dash component only returns a list of values. # So we need to ensure that we always return a list only as well to provide consistent types. parameter_value = [option["value"] if isinstance(option, dict) else option for option in selector.options] - parameter_value = _validate_selector_value_none(parameter_value) + parameter_value = _validate_selector_value_none(parameter_value) # type: ignore[arg-type] for action in _get_component_actions(selector): if action.function._function.__name__ != "_parameter": @@ -241,7 +247,7 @@ def _get_unfiltered_data( dynamic_data_load_params = _get_parametrized_config( ctds_parameter=ctds_parameter, target=target, data_frame=True ) - data_source_name = model_manager[target]["data_frame"] + data_source_name = cast(FigureType, model_manager[target])["data_frame"] multi_data_source_name_load_kwargs.append((data_source_name, dynamic_data_load_params["data_frame"])) return dict(zip(targets, data_manager._multi_load(multi_data_source_name_load_kwargs))) @@ -276,19 +282,20 @@ def _get_modified_page_figures( # Consider restructuring ctds to a more convenient form to make this possible. for target, unfiltered_data in target_to_data_frame.items(): filtered_data = _apply_filters(unfiltered_data, ctds_filter, ctds_filter_interaction, target) - outputs[target] = model_manager[target]( + outputs[target] = cast(FigureType, model_manager[target])( data_frame=filtered_data, **_get_parametrized_config(ctds_parameter=ctds_parameter, target=target, data_frame=False), ) for target in control_targets: - ctd_filter = [item for item in ctds_filter if item["id"] == model_manager[target].selector.id] + target_model = cast(Filter, model_manager[target]) + ctd_filter = [item for item in ctds_filter if item["id"] == cast(SelectorType, target_model.selector).id] # This only covers the case of cross-page actions when Filter in an output, but is not an input of the action. current_value = ctd_filter[0]["value"] if ctd_filter else None # target_to_data_frame contains all targets, including some which might not be relevant for the filter in # question. We filter to use just the relevant targets in Filter.__call__. - outputs[target] = model_manager[target](target_to_data_frame=target_to_data_frame, current_value=current_value) + outputs[target] = target_model(target_to_data_frame=target_to_data_frame, current_value=current_value) return 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 ba18e4fa5..ac57a1fbd 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 @@ -57,11 +57,11 @@ def _get_inputs_of_figure_interactions( for attribute in required_attributes: if not hasattr(triggered_model, attribute): raise ValueError(f"Model {triggered_model.id} does not have required attribute `{attribute}`.") - if "modelID" not in triggered_model._filter_interaction_input: + if "modelID" not in triggered_model._filter_interaction_input: # type: ignore[attr-defined] raise ValueError( f"Model {triggered_model.id} does not have required State `modelID` in `_filter_interaction_input`." ) - inputs.append(triggered_model._filter_interaction_input) + inputs.append(triggered_model._filter_interaction_input) # type: ignore[attr-defined] return inputs @@ -102,7 +102,7 @@ def _get_action_callback_outputs(action: Action) -> dict[str, Output]: return { target: Output( component_id=target, - component_property=model_manager[target]._output_component_property, + component_property=model_manager[target]._output_component_property, # type: ignore[attr-defined] allow_duplicate=True, ) for target in targets diff --git a/vizro-core/src/vizro/managers/_model_manager.py b/vizro-core/src/vizro/managers/_model_manager.py index 041e88ce4..b53497eb0 100644 --- a/vizro-core/src/vizro/managers/_model_manager.py +++ b/vizro-core/src/vizro/managers/_model_manager.py @@ -73,13 +73,13 @@ def _get_models( import vizro.models as vm if model_type is FIGURE_MODELS: - model_type = (vm.Graph, vm.AgGrid, vm.Table, vm.Figure) + model_type = (vm.Graph, vm.AgGrid, vm.Table, vm.Figure) # type: ignore[assignment] models = self.__get_model_children(page) if page is not None else self.__models.values() # Convert to list to avoid changing size when looping through at runtime. for model in list(models): if model_type is None or isinstance(model, model_type): - yield model + yield model # type: ignore[misc] def __get_model_children(self, model: Model) -> Generator[Model, None, None]: """Iterates through children of `model`. @@ -120,7 +120,7 @@ def _get_model_page(self, model: Model) -> Page: # type: ignore[return] return model for page in cast(Iterable[Page], self._get_models(Page)): - if model in self.__get_model_children(page): + if model in self.__get_model_children(page): # type: ignore[operator] return page @staticmethod diff --git a/vizro-core/src/vizro/models/__init__.py b/vizro-core/src/vizro/models/__init__.py index c19ff4515..ac47ffcb1 100644 --- a/vizro-core/src/vizro/models/__init__.py +++ b/vizro-core/src/vizro/models/__init__.py @@ -13,27 +13,9 @@ from ._layout import Layout from ._page import Page -Tabs.update_forward_refs(Container=Container) -Container.update_forward_refs( - AgGrid=AgGrid, Button=Button, Card=Card, Figure=Figure, Graph=Graph, Layout=Layout, Table=Table, Tabs=Tabs -) -Page.update_forward_refs( - Accordion=Accordion, - AgGrid=AgGrid, - Button=Button, - Card=Card, - Container=Container, - Figure=Figure, - Filter=Filter, - Graph=Graph, - Parameter=Parameter, - Table=Table, - Tabs=Tabs, -) -Navigation.update_forward_refs(Accordion=Accordion, NavBar=NavBar, NavLink=NavLink) -Dashboard.update_forward_refs(Page=Page, Navigation=Navigation) -NavBar.update_forward_refs(NavLink=NavLink) -NavLink.update_forward_refs(Accordion=Accordion) + +Dashboard.model_rebuild() + __all__ = [ "Accordion", diff --git a/vizro-core/src/vizro/models/_action/_action.py b/vizro-core/src/vizro/models/_action/_action.py index 0aef7303b..773d31dd7 100644 --- a/vizro-core/src/vizro/models/_action/_action.py +++ b/vizro-core/src/vizro/models/_action/_action.py @@ -2,22 +2,35 @@ import logging from collections.abc import Collection, Mapping from pprint import pformat -from typing import Any, Union +from typing import Annotated, Any, Union from dash import Input, Output, State, callback, html - -try: - from pydantic.v1 import Field, validator -except ImportError: # pragma: no cov - from pydantic import Field, validator +from pydantic import AfterValidator, Field, StringConstraints, field_validator +from pydantic.json_schema import SkipJsonSchema from vizro.models import VizroBaseModel from vizro.models._models_utils import _log_call -from vizro.models.types import CapturedCallable +from vizro.models.types import CapturedCallable, validate_captured_callable logger = logging.getLogger(__name__) +# TODO: Problem: generic Action model shouldn't depend on details of particular actions like export_data. +# Possible solutions: make a generic mapping of action functions to validation functions or the imports they +# require, and make the code here look up the appropriate validation using the function as key +# This could then also involve other validations currently only carried out at run-time in pre-defined actions, such +# as e.g. checking if the correct arguments have been provided to the file_format in export_data. +def validate_predefined_actions(function): + if function._function.__name__ == "export_data": + file_format = function._arguments.get("file_format") + if file_format not in [None, "csv", "xlsx"]: + raise ValueError(f'Unknown "file_format": {file_format}. Known file formats: "csv", "xlsx".') + if file_format == "xlsx": + if importlib.util.find_spec("openpyxl") is None and importlib.util.find_spec("xlsxwriter") is None: + raise ModuleNotFoundError("You must install either openpyxl or xlsxwriter to export to xlsx format.") + return function + + class Action(VizroBaseModel): """Action to be inserted into `actions` of relevant component. @@ -30,35 +43,21 @@ class Action(VizroBaseModel): """ - function: CapturedCallable = Field(..., import_path="vizro.actions", mode="action", description="Action function.") - inputs: list[str] = Field( + function: Annotated[ + SkipJsonSchema[CapturedCallable], + AfterValidator(validate_predefined_actions), + Field(json_schema_extra={"mode": "action", "import_path": "vizro.actions"}, description="Action function."), + ] + inputs: list[Annotated[str, StringConstraints(pattern="^[^.]+[.][^.]+$")]] = Field( [], description="Inputs in the form `.` passed to the action function.", - regex="^[^.]+[.][^.]+$", ) - outputs: list[str] = Field( + outputs: list[Annotated[str, StringConstraints(pattern="^[^.]+[.][^.]+$")]] = Field( [], description="Outputs in the form `.` changed by the action function.", - regex="^[^.]+[.][^.]+$", ) - # TODO: Problem: generic Action model shouldn't depend on details of particular actions like export_data. - # Possible solutions: make a generic mapping of action functions to validation functions or the imports they - # require, and make the code here look up the appropriate validation using the function as key - # This could then also involve other validations currently only carried out at run-time in pre-defined actions, such - # as e.g. checking if the correct arguments have been provided to the file_format in export_data. - @validator("function") - def validate_predefined_actions(cls, function): - if function._function.__name__ == "export_data": - file_format = function._arguments.get("file_format") - if file_format not in [None, "csv", "xlsx"]: - raise ValueError(f'Unknown "file_format": {file_format}.' f' Known file formats: "csv", "xlsx".') - if file_format == "xlsx": - if importlib.util.find_spec("openpyxl") is None and importlib.util.find_spec("xlsxwriter") is None: - raise ModuleNotFoundError( - "You must install either openpyxl or xlsxwriter to export to xlsx format." - ) - return function + _validate_function = field_validator("function", mode="before")(validate_captured_callable) def _get_callback_mapping(self): """Builds callback inputs and outputs for the Action model callback, and returns action required components. diff --git a/vizro-core/src/vizro/models/_action/_actions_chain.py b/vizro-core/src/vizro/models/_action/_actions_chain.py index 31049929c..9e19e2629 100644 --- a/vizro-core/src/vizro/models/_action/_actions_chain.py +++ b/vizro-core/src/vizro/models/_action/_actions_chain.py @@ -1,10 +1,7 @@ from functools import partial -from typing import Any, NamedTuple +from typing import NamedTuple -try: - from pydantic.v1 import validator -except ImportError: # pragma: no cov - from pydantic import validator +from pydantic import ValidationInfo from vizro.models import Action, VizroBaseModel @@ -20,15 +17,15 @@ class ActionsChain(VizroBaseModel): # Validators for reuse in other models to convert to ActionsChain -def _set_actions(actions: list[Action], values: dict[str, Any], trigger_property: str) -> list[ActionsChain]: +def _set_actions(value: list[Action], info: ValidationInfo, trigger_property: str) -> list[ActionsChain]: return [ ActionsChain( - trigger=Trigger(component_id=values["id"], component_property=trigger_property), - actions=actions, + trigger=Trigger(component_id=info.data["id"], component_property=trigger_property), + actions=value, ) ] def _action_validator_factory(trigger_property: str): set_actions = partial(_set_actions, trigger_property=trigger_property) - return validator("actions", allow_reuse=True)(set_actions) + return set_actions diff --git a/vizro-core/src/vizro/models/_base.py b/vizro-core/src/vizro/models/_base.py index d2f47470e..71b6d4d32 100644 --- a/vizro-core/src/vizro/models/_base.py +++ b/vizro-core/src/vizro/models/_base.py @@ -1,23 +1,20 @@ -from collections.abc import Mapping -from contextlib import contextmanager -from typing import Annotated, Any, Optional, Union - -try: - from pydantic.v1 import BaseModel, Field, validator - from pydantic.v1.fields import SHAPE_LIST, ModelField - from pydantic.v1.typing import get_args -except ImportError: # pragma: no cov - from pydantic import BaseModel, Field, validator - from pydantic.fields import SHAPE_LIST, ModelField - from pydantic.typing import get_args - - import inspect import logging import textwrap +from typing import Annotated, Any, Optional, Union, cast, get_args, get_origin import autoflake import black +from pydantic import ( + AfterValidator, + BaseModel, + ConfigDict, + Field, + SerializationInfo, + SerializerFunctionWrapHandler, + model_serializer, +) +from pydantic.fields import FieldInfo from vizro.managers import model_manager from vizro.models._models_utils import REPLACEMENT_STRINGS, _log_call @@ -55,21 +52,6 @@ {data_setting} """ -# Global variable to dictate whether VizroBaseModel.dict should be patched to work for _to_python. -# This is always False outside the _patch_vizro_base_model_dict context manager to ensure that, unless explicitly -# called for, dict behavior is unmodified from pydantic's default. -_PATCH_VIZRO_BASE_MODEL_DICT = False - - -@contextmanager -def _patch_vizro_base_model_dict(): - global _PATCH_VIZRO_BASE_MODEL_DICT # noqa - _PATCH_VIZRO_BASE_MODEL_DICT = True - try: - yield - finally: - _PATCH_VIZRO_BASE_MODEL_DICT = False - def _format_and_lint(code_string: str) -> str: # Tracking https://github.com/astral-sh/ruff/issues/659 for proper Python API @@ -152,6 +134,63 @@ def _extract_captured_callable_data_info() -> set[str]: } +def _add_type_to_union(union: type[Any], new_type: type[Any]): # TODO[mypy]: not sure how to type the return type + args = get_args(union) + return Union[args + (new_type,)] # noqa: RUF005 #as long as we support Python 3.9, we can't use the new syntax + + +def _add_type_to_annotated_union(union, new_type: type[Any]): # TODO[mypy]: not sure how to type the return type + args = get_args(union) + return Annotated[_add_type_to_union(args[0], new_type), args[1]] + + +def _is_discriminated_union_via_field_info(field: FieldInfo) -> bool: + if hasattr(field, "annotation") and field.annotation is None: + raise ValueError("Field annotation is None") + return hasattr(field, "discriminator") and field.discriminator is not None + + +def _is_discriminated_union_via_annotation(annotation) -> bool: + if get_origin(annotation) is not Annotated: + return False + metadata = get_args(annotation)[1:] + return hasattr(metadata[0], "discriminator") + + +def _is_not_annotated(field: type[Any]) -> bool: + return get_origin(field) is not None and get_origin(field) is not Annotated + + +def _add_type_to_annotated_union_if_found( + type_annotation: type[Any], additional_type: type[Any], field_name: str +) -> type[Any]: + def _split_types(type_annotation: type[Any]) -> type[Any]: + outer_type = get_origin(type_annotation) + inner_types = get_args(type_annotation) # TODO[MS]: what if multiple, or what if not first? + if outer_type is None or len(inner_types) < 1: + raise ValueError("Unsupported annotation type") + if len(inner_types) > 1: + return outer_type[ + _add_type_to_annotated_union_if_found(inner_types[0], additional_type, field_name), + inner_types[1], + ] + return outer_type[_add_type_to_annotated_union_if_found(inner_types[0], additional_type, field_name)] + + if _is_not_annotated(type_annotation): + return _split_types(type_annotation) + elif _is_discriminated_union_via_annotation(type_annotation): + return _add_type_to_annotated_union(type_annotation, additional_type) + else: + raise ValueError( + f"Field '{field_name}' must be a discriminated union or list of discriminated union type. " + "You probably do not need to call add_type to use your new type." + ) + + +def set_id(id: str) -> str: + return id or model_manager._generate_id() + + class VizroBaseModel(BaseModel): """All models that are registered to the model manager should inherit from this class. @@ -161,24 +200,46 @@ class VizroBaseModel(BaseModel): """ - id: str = Field( - "", - description="ID to identify model. Must be unique throughout the whole dashboard." - "When no ID is chosen, ID will be automatically generated.", - ) - - @validator("id", always=True) - def set_id(cls, id) -> str: - return id or model_manager._generate_id() + id: Annotated[ + str, + AfterValidator(set_id), + Field( + default="", + description="ID to identify model. Must be unique throughout the whole dashboard." + "When no ID is chosen, ID will be automatically generated.", + validate_default=True, + ), + ] @_log_call - def __init__(self, **data: Any): + def __init__(self, **data: Any): # TODO: model_post_init """Adds this model instance to the model manager.""" # Note this runs after the set_id validator, so self.id is available here. In pydantic v2 we should do this # using the new model_post_init method to avoid overriding __init__. super().__init__(**data) model_manager[self.id] = self + # Previously in V1, we used to have an overwritten `.dict` method, that would add __vizro_model__ to the dictionary + # if called in the correct context. + # In addition, it was possible to exclude fields specified in __vizro_exclude_fields__. + # This was like pydantic's own __exclude_fields__ but this is not possible in V2 due to the non-recursive nature of + # the model_dump method. Now this serializer allows to add the model name to the dictionary when serializing the + # model if called with context {"add_name": True}. + # Excluding specific fields is now done via overwriting this serializer (see e.g. Page model). + # Useful threads that were started: + # https://stackoverflow.com/questions/79272335/remove-field-from-all-nested-pydantic-models + # https://github.com/pydantic/pydantic/issues/11099 + @model_serializer(mode="wrap") + def serialize( + self, + handler: SerializerFunctionWrapHandler, + info: SerializationInfo, + ) -> dict[str, Any]: + result = handler(self) + if info.context is not None and info.context.get("add_name", False): + result["__vizro_model__"] = self.__class__.__name__ + return result + @classmethod def add_type(cls, field_name: str, new_type: type[Any]): """Adds a new type to an existing field based on a discriminated union. @@ -188,78 +249,22 @@ def add_type(cls, field_name: str, new_type: type[Any]): new_type: New type to add to discriminated union """ - - def _add_to_discriminated_union(union): - # Returning Annotated here isn't strictly necessary but feels safer because the new discriminated union - # will then be annotated the same way as the old one. - args = get_args(union) - # args[0] is the original union, e.g. Union[Filter, Parameter]. args[1] is the FieldInfo annotated metadata. - return Annotated[Union[args[0], new_type], args[1]] - - def _is_discriminated_union(field): - # Really this should be done as follows: - # return field.discriminator_key is not None - # However, this does not work with Optional[DiscriminatedUnion]. See also TestOptionalDiscriminatedUnion. - return hasattr(field.outer_type_, "__metadata__") and get_args(field.outer_type_)[1].discriminator - - field = cls.__fields__[field_name] - sub_field = field.sub_fields[0] if field.shape == SHAPE_LIST else None - - if _is_discriminated_union(field): - # Field itself is a non-optional discriminated union, e.g. selector: SelectorType or Optional[SelectorType]. - new_annotation = _add_to_discriminated_union(field.outer_type_) - elif sub_field is not None and _is_discriminated_union(sub_field): - # Field is a list of discriminated union e.g. components: list[ComponentType]. - new_annotation = list[_add_to_discriminated_union(sub_field.outer_type_)] # type: ignore[misc] - else: - raise ValueError( - f"Field '{field_name}' must be a discriminated union or list of discriminated union type. " - "You probably do not need to call add_type to use your new type." - ) - - cls.__fields__[field_name] = ModelField( - name=field.name, - type_=new_annotation, - class_validators=field.class_validators, - model_config=field.model_config, - default=field.default, - default_factory=field.default_factory, - required=field.required, - final=field.final, - alias=field.alias, - field_info=field.field_info, + field = cls.model_fields[field_name] + old_type = cast(type[Any], field.annotation) + new_annotation = ( + _add_type_to_union(old_type, new_type) + if _is_discriminated_union_via_field_info(field) + else _add_type_to_annotated_union_if_found(old_type, new_type, field_name) ) + field = cls.model_fields[field_name] = FieldInfo.merge_field_infos(field, annotation=new_annotation) # We need to resolve all ForwardRefs again e.g. in the case of Page, which requires update_forward_refs in # vizro.models. The vm.__dict__.copy() is inspired by pydantic's own implementation of update_forward_refs and # effectively replaces all ForwardRefs defined in vizro.models. import vizro.models as vm - cls.update_forward_refs(**vm.__dict__.copy()) - new_type.update_forward_refs(**vm.__dict__.copy()) - - def dict(self, **kwargs): - global _PATCH_VIZRO_BASE_MODEL_DICT # noqa - if not _PATCH_VIZRO_BASE_MODEL_DICT: - # Whenever dict is called outside _patch_vizro_base_model_dict, we don't modify the behavior of the dict. - return super().dict(**kwargs) - - # When used in _to_python, we overwrite pydantic's own `dict` method to add __vizro_model__ to the dictionary - # and to exclude fields specified dynamically in __vizro_exclude_fields__. - # To get exclude as an argument is a bit fiddly because this function is called recursively inside pydantic, - # which sets exclude=None by default. - if kwargs.get("exclude") is None: - kwargs["exclude"] = self.__vizro_exclude_fields__() - _dict = super().dict(**kwargs) - _dict["__vizro_model__"] = self.__class__.__name__ - return _dict - - # This is like pydantic's own __exclude_fields__ but safer to use (it looks like __exclude_fields__ no longer - # exists in pydantic v2). - # Root validators with pre=True are always included, even when exclude_default=True, and so this is needed - # to potentially exclude fields set this way, like Page.id. - def __vizro_exclude_fields__(self) -> Optional[Union[set[str], Mapping[str, Any]]]: - return None + cls.model_rebuild(force=True, _types_namespace=vm.__dict__.copy()) + new_type.model_rebuild(force=True, _types_namespace=vm.__dict__.copy()) def _to_python( self, extra_imports: Optional[set[str]] = None, extra_callable_defs: Optional[set[str]] = None @@ -304,8 +309,7 @@ def _to_python( data_defs_concat = "\n".join(data_defs_set) if data_defs_set else None # Model code - with _patch_vizro_base_model_dict(): - model_dict = self.dict(exclude_unset=True) + model_dict = self.model_dump(context={"add_name": True}, exclude_unset=True) model_code = "model = " + _dict_to_python(model_dict) @@ -326,8 +330,7 @@ def _to_python( logging.exception("Code formatting failed; returning unformatted code") return unformatted_code - class Config: - extra = "forbid" # Good for spotting user typos and being strict. - smart_union = True # Makes unions work without unexpected coercion (will be the default in pydantic v2). - validate_assignment = True # Run validators when a field is assigned after model instantiation. - copy_on_model_validation = "none" # Don't copy sub-models. Essential for the model_manager to work correctly. + model_config = ConfigDict( + extra="forbid", # Good for spotting user typos and being strict. + validate_assignment=True, # Run validators when a field is assigned after model instantiation. + ) diff --git a/vizro-core/src/vizro/models/_components/_form.py b/vizro-core/src/vizro/models/_components/_form.py index 3cbcd0918..97dc93a61 100644 --- a/vizro-core/src/vizro/models/_components/_form.py +++ b/vizro-core/src/vizro/models/_components/_form.py @@ -1,18 +1,14 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING, Annotated, Literal, Optional, cast from dash import html - -try: - from pydantic.v1 import validator -except ImportError: # pragma: no cov - from pydantic import validator +from pydantic import AfterValidator, BeforeValidator, Field, conlist from vizro.models import VizroBaseModel from vizro.models._components.form import Checklist, Dropdown, RadioItems, RangeSlider, Slider from vizro.models._layout import set_layout -from vizro.models._models_utils import _log_call, check_captured_callable, validate_min_length +from vizro.models._models_utils import _log_call, check_captured_callable_model from vizro.models.types import _FormComponentType if TYPE_CHECKING: @@ -25,20 +21,14 @@ class Form(VizroBaseModel): Args: type (Literal["form"]): Defaults to `"form"`. components (list[FormComponentType]): List of components used in the form. - layout (Layout): Defaults to `None`. + layout (Optional[Layout]): Defaults to `None`. """ type: Literal["form"] = "form" - components: list[_FormComponentType] - layout: Layout = None # type: ignore[assignment] - - # Re-used validators - _check_captured_callable = validator("components", allow_reuse=True, each_item=True, pre=True)( - check_captured_callable - ) - _validate_min_length = validator("components", allow_reuse=True, always=True)(validate_min_length) - _validate_layout = validator("layout", allow_reuse=True, always=True)(set_layout) + # TODO[mypy], see: https://github.com/pydantic/pydantic/issues/156 for components field + components: conlist(Annotated[_FormComponentType, BeforeValidator(check_captured_callable_model)], min_length=1) # type: ignore[valid-type] + layout: Annotated[Optional[Layout], AfterValidator(set_layout), Field(default=None, validate_default=True)] @_log_call def pre_build(self): @@ -52,6 +42,10 @@ def pre_build(self): @_log_call def build(self): + self.layout = cast( + Layout, # cannot actually be None if you check components and layout field together + self.layout, + ) components_container = self.layout.build() for component_idx, component in enumerate(self.components): components_container[f"{self.layout.id}_{component_idx}"].children = component.build() diff --git a/vizro-core/src/vizro/models/_components/ag_grid.py b/vizro-core/src/vizro/models/_components/ag_grid.py index 52b1e36a5..3af9fb74d 100644 --- a/vizro-core/src/vizro/models/_components/ag_grid.py +++ b/vizro-core/src/vizro/models/_components/ag_grid.py @@ -1,14 +1,11 @@ import logging -from typing import Literal +from typing import Annotated, Literal import pandas as pd -from dash import State, dcc, html - -try: - from pydantic.v1 import Field, PrivateAttr, validator -except ImportError: # pragma: no cov - from pydantic import Field, PrivateAttr, validator -from dash import ClientsideFunction, Input, Output, clientside_callback +from dash import ClientsideFunction, Input, Output, State, clientside_callback, dcc, html +from pydantic import AfterValidator, Field, PrivateAttr, field_validator +from pydantic.functional_serializers import PlainSerializer +from pydantic.json_schema import SkipJsonSchema from vizro.actions._actions_utils import CallbackTriggerDict, _get_component_actions, _get_parent_model from vizro.managers import data_manager @@ -16,7 +13,7 @@ from vizro.models._action._actions_chain import _action_validator_factory from vizro.models._components._components_utils import _process_callable_data_frame from vizro.models._models_utils import _log_call -from vizro.models.types import CapturedCallable +from vizro.models.types import CapturedCallable, validate_captured_callable logger = logging.getLogger(__name__) @@ -38,12 +35,17 @@ class AgGrid(VizroBaseModel): """ type: Literal["ag_grid"] = "ag_grid" - figure: CapturedCallable = Field( - ..., import_path="vizro.tables", mode="ag_grid", description="Function that returns a `Dash AG Grid`." - ) - title: str = Field("", description="Title of the `AgGrid`") + figure: Annotated[ + SkipJsonSchema[CapturedCallable], + AfterValidator(_process_callable_data_frame), + Field( + json_schema_extra={"mode": "ag_grid", "import_path": "vizro.tables"}, + description="Function that returns a `Dash AG Grid`.", + ), + ] + title: str = Field(default="", description="Title of the `AgGrid`.") header: str = Field( - "", + default="", description="Markdown text positioned below the `AgGrid.title`. Follows the CommonMark specification. Ideal " "for adding supplementary information such as subtitles, descriptions, or additional context.", ) @@ -52,16 +54,19 @@ class AgGrid(VizroBaseModel): description="Markdown text positioned below the `AgGrid`. Follows the CommonMark specification. Ideal for " "providing further details such as sources, disclaimers, or additional notes.", ) - actions: list[Action] = [] + actions: Annotated[ + list[Action], + AfterValidator(_action_validator_factory("cellClicked")), + PlainSerializer(lambda x: x[0].actions), + Field(default=[]), # TODO[MS]: here and elsewhere: do we need to validate default here? + ] _input_component_id: str = PrivateAttr() # Component properties for actions and interactions _output_component_property: str = PrivateAttr("children") - # Validators - set_actions = _action_validator_factory("cellClicked") - _validate_callable = validator("figure", allow_reuse=True, always=True)(_process_callable_data_frame) + _validate_figure = field_validator("figure", mode="before")(validate_captured_callable) # Convenience wrapper/syntactic sugar. def __call__(self, **kwargs): diff --git a/vizro-core/src/vizro/models/_components/button.py b/vizro-core/src/vizro/models/_components/button.py index 6c95617a2..1899f9cb0 100644 --- a/vizro-core/src/vizro/models/_components/button.py +++ b/vizro-core/src/vizro/models/_components/button.py @@ -1,12 +1,9 @@ -from typing import Literal +from typing import Annotated, Literal import dash_bootstrap_components as dbc from dash import get_relative_path - -try: - from pydantic.v1 import Field -except ImportError: # pragma: no cov - from pydantic import Field +from pydantic import AfterValidator, Field +from pydantic.functional_serializers import PlainSerializer from vizro.models import Action, VizroBaseModel from vizro.models._action._actions_chain import _action_validator_factory @@ -24,12 +21,14 @@ class Button(VizroBaseModel): """ type: Literal["button"] = "button" - text: str = Field("Click me!", description="Text to be displayed on button.") - href: str = Field("", description="URL (relative or absolute) to navigate to.") - actions: list[Action] = [] - - # Re-used validators - _set_actions = _action_validator_factory("n_clicks") + text: str = Field(default="Click me!", description="Text to be displayed on button.") + href: str = Field(default="", description="URL (relative or absolute) to navigate to.") + actions: Annotated[ + list[Action], + AfterValidator(_action_validator_factory("n_clicks")), + PlainSerializer(lambda x: x[0].actions), + Field(default=[]), + ] @_log_call def build(self): diff --git a/vizro-core/src/vizro/models/_components/card.py b/vizro-core/src/vizro/models/_components/card.py index 2638b63ac..538b7668c 100644 --- a/vizro-core/src/vizro/models/_components/card.py +++ b/vizro-core/src/vizro/models/_components/card.py @@ -2,11 +2,7 @@ import dash_bootstrap_components as dbc from dash import dcc, get_relative_path - -try: - from pydantic.v1 import Field -except ImportError: # pragma: no cov - from pydantic import Field +from pydantic import Field from vizro.models import VizroBaseModel from vizro.models._models_utils import _log_call @@ -25,7 +21,7 @@ class Card(VizroBaseModel): type: Literal["card"] = "card" text: str = Field( - ..., description="Markdown string to create card title/text that should adhere to the CommonMark Spec." + description="Markdown string to create card title/text that should adhere to the CommonMark Spec." ) href: str = Field( "", diff --git a/vizro-core/src/vizro/models/_components/container.py b/vizro-core/src/vizro/models/_components/container.py index 6b56b5769..627323c82 100644 --- a/vizro-core/src/vizro/models/_components/container.py +++ b/vizro-core/src/vizro/models/_components/container.py @@ -1,17 +1,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING, Annotated, Literal, Optional, cast from dash import html - -try: - from pydantic.v1 import Field, validator -except ImportError: # pragma: no cov - from pydantic import Field, validator +from pydantic import AfterValidator, BeforeValidator, Field, conlist from vizro.models import VizroBaseModel from vizro.models._layout import set_layout -from vizro.models._models_utils import _log_call, check_captured_callable, validate_min_length +from vizro.models._models_utils import _log_call, check_captured_callable_model from vizro.models.types import ComponentType if TYPE_CHECKING: @@ -26,21 +22,18 @@ class Container(VizroBaseModel): components (list[ComponentType]): See [ComponentType][vizro.models.types.ComponentType]. At least one component has to be provided. title (str): Title to be displayed. - layout (Layout): Layout to place components in. Defaults to `None`. + layout (Optional[Layout]): Layout to place components in. Defaults to `None`. """ type: Literal["container"] = "container" - components: list[ComponentType] - title: str = Field(..., description="Title to be displayed.") - layout: Layout = None # type: ignore[assignment] - - # Re-used validators - _check_captured_callable = validator("components", allow_reuse=True, each_item=True, pre=True)( - check_captured_callable + # TODO[mypy], see: https://github.com/pydantic/pydantic/issues/156 for components field + components: conlist( # type: ignore[valid-type] + Annotated[ComponentType, BeforeValidator(check_captured_callable_model)], + min_length=1, ) - _validate_min_length = validator("components", allow_reuse=True, always=True)(validate_min_length) - _validate_layout = validator("layout", allow_reuse=True, always=True)(set_layout) + title: str = Field(description="Title to be displayed.") + layout: Annotated[Optional[Layout], AfterValidator(set_layout), Field(default=None, validate_default=True)] @_log_call def build(self): @@ -51,6 +44,12 @@ def build(self): # 2) Logic inside Tabs.build that sets hidden=True for the heading or uses del to remove the heading via # providing an ID to the heading and accessing it in the component tree # 3) New field in Container like short_title to allow tab label to be set independently + from vizro.models import Layout + + self.layout = cast( + Layout, # cannot actually be None if you check components and layout field together + self.layout, + ) components_container = self.layout.build() for component_idx, component in enumerate(self.components): components_container[f"{self.layout.id}_{component_idx}"].children = component.build() diff --git a/vizro-core/src/vizro/models/_components/figure.py b/vizro-core/src/vizro/models/_components/figure.py index 7d0a54492..4be63e694 100644 --- a/vizro-core/src/vizro/models/_components/figure.py +++ b/vizro-core/src/vizro/models/_components/figure.py @@ -1,17 +1,14 @@ -from typing import Literal +from typing import Annotated, Literal from dash import dcc, html - -try: - from pydantic.v1 import Field, PrivateAttr, validator -except ImportError: # pragma: no cov - from pydantic import Field, PrivateAttr, validator +from pydantic import AfterValidator, Field, PrivateAttr, field_validator +from pydantic.json_schema import SkipJsonSchema from vizro.managers import data_manager from vizro.models import VizroBaseModel from vizro.models._components._components_utils import _process_callable_data_frame from vizro.models._models_utils import _log_call -from vizro.models.types import CapturedCallable +from vizro.models.types import CapturedCallable, validate_captured_callable class Figure(VizroBaseModel): @@ -24,17 +21,19 @@ class Figure(VizroBaseModel): """ type: Literal["figure"] = "figure" - figure: CapturedCallable = Field( - import_path="vizro.figures", - mode="figure", - description="Function that returns a figure-like object.", - ) + figure: Annotated[ + SkipJsonSchema[CapturedCallable], + AfterValidator(_process_callable_data_frame), + Field( + json_schema_extra={"mode": "figure", "import_path": "vizro.figures"}, + description="Function that returns a figure-like object.", + ), + ] # Component properties for actions and interactions _output_component_property: str = PrivateAttr("children") - # Validators - _validate_callable = validator("figure", allow_reuse=True, always=True)(_process_callable_data_frame) + _validate_figure = field_validator("figure", mode="before")(validate_captured_callable) def __call__(self, **kwargs): # This default value is not actually used anywhere at the moment since __call__ is always used with data_frame diff --git a/vizro-core/src/vizro/models/_components/form/_alert.py b/vizro-core/src/vizro/models/_components/form/_alert.py index 9cc2cfaee..cc8fe25fa 100644 --- a/vizro-core/src/vizro/models/_components/form/_alert.py +++ b/vizro-core/src/vizro/models/_components/form/_alert.py @@ -2,11 +2,7 @@ import dash_bootstrap_components as dbc from dash import html - -try: - from pydantic.v1 import Field -except ImportError: # pragma: no cov - from pydantic import Field +from pydantic import Field from vizro.models import Action, VizroBaseModel from vizro.models._models_utils import _log_call @@ -25,9 +21,9 @@ class Alert(VizroBaseModel): """ type: Literal["alert"] = "alert" - text: str = Field(..., description="Text to be displayed in the alert.") + text: str = Field(description="Text to be displayed in the alert.") is_open: bool = Field(True, description="Flag indicating whether alert should be open by default.") - duration: Optional[int] = Field(None, description="Duration in milliseconds for the alert to appear.", ge=0) + duration: Optional[int] = Field(default=None, description="Duration in milliseconds for the alert to appear.", ge=0) actions: list[Action] = [] @_log_call diff --git a/vizro-core/src/vizro/models/_components/form/_form_utils.py b/vizro-core/src/vizro/models/_components/form/_form_utils.py index c1387588c..dc369aa71 100644 --- a/vizro-core/src/vizro/models/_components/form/_form_utils.py +++ b/vizro-core/src/vizro/models/_components/form/_form_utils.py @@ -1,7 +1,9 @@ """Helper functions for models inside form folder.""" from datetime import date -from typing import Union +from typing import Any, Optional, Union + +from pydantic import ValidationInfo from vizro._constants import ALL_OPTION from vizro.models.types import MultiValueType, OptionsType, SingleValueType @@ -34,24 +36,26 @@ def is_value_contained(value: Union[SingleValueType, MultiValueType], options: O # Validators for reuse -def validate_options_dict(cls, values): +def validate_options_dict(cls, data: Any) -> Any: """Reusable validator for the "options" argument of categorical selectors.""" - if "options" not in values or not isinstance(values["options"], list): - return values + if "options" not in data or not isinstance(data["options"], list): + return data - for entry in values["options"]: + for entry in data["options"]: if isinstance(entry, dict) and not set(entry.keys()) == {"label", "value"}: raise ValueError("Invalid argument `options` passed. Expected a dict with keys `label` and `value`.") - return values + return data -def validate_value(cls, value, values): +def validate_value(value, info: ValidationInfo): """Reusable validator for the "value" argument of categorical selectors.""" - if "options" not in values or not values["options"]: + if "options" not in info.data or not info.data["options"]: return value possible_values = ( - [entry["value"] for entry in values["options"]] if isinstance(values["options"][0], dict) else values["options"] + [entry["value"] for entry in info.data["options"]] + if isinstance(info.data["options"][0], dict) + else info.data["options"] ) if hasattr(value, "__iter__") and ALL_OPTION in value: @@ -63,17 +67,17 @@ def validate_value(cls, value, values): return value -def validate_max(cls, max, values): +def validate_max(max, info: ValidationInfo): """Validates that the `max` is not below the `min` for a range-based input.""" if max is None: return max - if values["min"] is not None and max < values["min"]: + if info.data["min"] is not None and max < info.data["min"]: raise ValueError("Maximum value of selector is required to be larger than minimum value.") return max -def validate_range_value(cls, value, values): +def validate_range_value(value, info: ValidationInfo): """Validates a value or range of values to ensure they lie within specified bounds (min/max).""" EXPECTED_VALUE_LENGTH = 2 if value is None: @@ -82,33 +86,38 @@ def validate_range_value(cls, value, values): lvalue, hvalue = ( (value[0], value[1]) if isinstance(value, list) and len(value) == EXPECTED_VALUE_LENGTH + # TODO: I am not sure the below makes sense. + # The field constraint on value means that it should always be a list of length 2. + # The unit tests even check for the case where value is a list of length 1 (and it should raise an error). else (value[0], value[0]) if isinstance(value, list) and len(value) == 1 else (value, value) ) - if (values["min"] is not None and not lvalue >= values["min"]) or ( - values["max"] is not None and not hvalue <= values["max"] + if (info.data["min"] is not None and not lvalue >= info.data["min"]) or ( + info.data["max"] is not None and not hvalue <= info.data["max"] ): raise ValueError("Please provide a valid value between the min and max value.") return value -def validate_step(cls, step, values): +def validate_step(step, info: ValidationInfo): """Reusable validator for the "step" argument for sliders.""" if step is None: return step - if values["max"] is not None and step > (values["max"] - values["min"]): + if info.data["max"] is not None and step > (info.data["max"] - info.data["min"]): raise ValueError( "The step value of the slider must be less than or equal to the difference between max and min." ) return step -def set_default_marks(cls, marks, values): - if not marks and values.get("step") is None: +def set_default_marks( + marks: Optional[dict[float, str]], info: ValidationInfo +) -> Optional[dict[Union[float, int], str]]: + if not marks and info.data.get("step") is None: marks = None # Dash has a bug where marks provided as floats that can be converted to integers are not displayed. @@ -119,11 +128,15 @@ def set_default_marks(cls, marks, values): return marks -def validate_date_picker_range(cls, range, values): - if range and values.get("value") and (isinstance(values["value"], (date, str)) or len(values["value"]) == 1): +def validate_date_picker_range(range, info: ValidationInfo): + if ( + range + and info.data.get("value") + and (isinstance(info.data["value"], (date, str)) or len(info.data["value"]) == 1) + ): raise ValueError("Please set range=False if providing single date value.") - if not range and isinstance(values.get("value"), list): + if not range and isinstance(info.data.get("value"), list): raise ValueError("Please set range=True if providing list of date values.") return range diff --git a/vizro-core/src/vizro/models/_components/form/_text_area.py b/vizro-core/src/vizro/models/_components/form/_text_area.py index bb1ea7fa1..c44e6c688 100644 --- a/vizro-core/src/vizro/models/_components/form/_text_area.py +++ b/vizro-core/src/vizro/models/_components/form/_text_area.py @@ -2,11 +2,7 @@ import dash_bootstrap_components as dbc from dash import html - -try: - from pydantic.v1 import Field, PrivateAttr -except ImportError: # pragma: no cov - from pydantic import Field, PrivateAttr +from pydantic import Field, PrivateAttr from vizro.models import Action, VizroBaseModel from vizro.models._action._actions_chain import _action_validator_factory @@ -28,8 +24,8 @@ class TextArea(VizroBaseModel): type: Literal["text_area"] = "text_area" # TODO: before making public consider naming this field (or giving an alias) label instead of title - title: str = Field("", description="Title to be displayed") - placeholder: str = Field("", description="Default text to display in input field") + title: str = Field(default="", description="Title to be displayed") + placeholder: str = Field(default="", description="Default text to display in input field") actions: list[Action] = [] # Component properties for actions and interactions diff --git a/vizro-core/src/vizro/models/_components/form/_user_input.py b/vizro-core/src/vizro/models/_components/form/_user_input.py index 7ca821f65..af4a48645 100644 --- a/vizro-core/src/vizro/models/_components/form/_user_input.py +++ b/vizro-core/src/vizro/models/_components/form/_user_input.py @@ -1,12 +1,8 @@ -from typing import Literal +from typing import Annotated, Literal import dash_bootstrap_components as dbc from dash import html - -try: - from pydantic.v1 import Field, PrivateAttr -except ImportError: # pragma: no cov - from pydantic import Field, PrivateAttr +from pydantic import AfterValidator, Field, PlainSerializer, PrivateAttr from vizro.models import Action, VizroBaseModel from vizro.models._action._actions_chain import _action_validator_factory @@ -28,18 +24,20 @@ class UserInput(VizroBaseModel): type: Literal["user_input"] = "user_input" # TODO: before making public consider naming this field (or giving an alias) label instead of title - title: str = Field("", description="Title to be displayed") - placeholder: str = Field("", description="Default text to display in input field") - actions: list[Action] = [] + title: str = Field(default="", description="Title to be displayed") + placeholder: str = Field(default="", description="Default text to display in input field") + # TODO: Before making public, consider how actions should be triggered and what the default property should be + # See comment thread: https://github.com/mckinsey/vizro/pull/298#discussion_r1478137654 + actions: Annotated[ + list[Action], + AfterValidator(_action_validator_factory("value")), + PlainSerializer(lambda x: x[0].actions), + Field(default=[]), + ] # Component properties for actions and interactions _input_property: str = PrivateAttr("value") - # Re-used validators - # TODO: Before making public, consider how actions should be triggered and what the default property should be - # See comment thread: https://github.com/mckinsey/vizro/pull/298#discussion_r1478137654 - _set_actions = _action_validator_factory("value") - @_log_call def build(self): return html.Div( diff --git a/vizro-core/src/vizro/models/_components/form/checklist.py b/vizro-core/src/vizro/models/_components/form/checklist.py index d69e725cc..957ada8ff 100644 --- a/vizro-core/src/vizro/models/_components/form/checklist.py +++ b/vizro-core/src/vizro/models/_components/form/checklist.py @@ -1,13 +1,9 @@ -from typing import Literal, Optional - -from dash import html - -try: - from pydantic.v1 import Field, PrivateAttr, root_validator, validator -except ImportError: # pragma: no cov - from pydantic import Field, PrivateAttr, root_validator, validator +from typing import Annotated, Literal, Optional import dash_bootstrap_components as dbc +from dash import html +from pydantic import AfterValidator, Field, PrivateAttr, model_validator +from pydantic.functional_serializers import PlainSerializer from vizro.models import Action, VizroBaseModel from vizro.models._action._actions_chain import _action_validator_factory @@ -34,9 +30,16 @@ class Checklist(VizroBaseModel): type: Literal["checklist"] = "checklist" options: OptionsType = [] - value: Optional[MultiValueType] = None - title: str = Field("", description="Title to be displayed") - actions: list[Action] = [] + value: Annotated[ + Optional[MultiValueType], AfterValidator(validate_value), Field(default=None, validate_default=True) + ] + title: str = Field(default="", description="Title to be displayed") + actions: Annotated[ + list[Action], + AfterValidator(_action_validator_factory("value")), + PlainSerializer(lambda x: x[0].actions), + Field(default=[]), + ] _dynamic: bool = PrivateAttr(False) @@ -44,9 +47,7 @@ class Checklist(VizroBaseModel): _input_property: str = PrivateAttr("value") # Re-used validators - _set_actions = _action_validator_factory("value") - _validate_options = root_validator(allow_reuse=True, pre=True)(validate_options_dict) - _validate_value = validator("value", allow_reuse=True, always=True)(validate_value) + _validate_options = model_validator(mode="before")(validate_options_dict) def __call__(self, options): full_options, default_value = get_options_and_default(options=options, multi=True) @@ -67,7 +68,7 @@ def __call__(self, options): def _build_dynamic_placeholder(self): if self.value is None: _, default_value = get_options_and_default(self.options, multi=True) - self.value = [default_value] + self.value = [default_value] # type: ignore[assignment] return self.__call__(self.options) diff --git a/vizro-core/src/vizro/models/_components/form/date_picker.py b/vizro-core/src/vizro/models/_components/form/date_picker.py index 9fc5170f6..e124e7bde 100644 --- a/vizro-core/src/vizro/models/_components/form/date_picker.py +++ b/vizro-core/src/vizro/models/_components/form/date_picker.py @@ -1,17 +1,11 @@ -from typing import Literal, Optional, Union - -import dash_mantine_components as dmc -from dash import html - -try: - from pydantic.v1 import Field, PrivateAttr, validator -except ImportError: # pragma: no cov - from pydantic import Field, PrivateAttr, validator - - from datetime import date +from typing import Annotated, Literal, Optional, Union import dash_bootstrap_components as dbc +import dash_mantine_components as dmc +from dash import html +from pydantic import AfterValidator, Field, PrivateAttr +from pydantic.functional_serializers import PlainSerializer from vizro.models import Action, VizroBaseModel from vizro.models._action._actions_chain import _action_validator_factory @@ -29,28 +23,39 @@ class DatePicker(VizroBaseModel): type (Literal["date_picker"]): Defaults to `"date_picker"`. min (Optional[date]): Start date for date picker. Defaults to `None`. max (Optional[date]): End date for date picker. Defaults to `None`. - value (Union[list[date], date]): Default date/dates for date picker. Defaults to `None`. + value (Optional[Union[list[date], date]]): Default date/dates for date picker. Defaults to `None`. title (str): Title to be displayed. Defaults to `""`. - range (bool): Boolean flag for displaying range picker. Default to `True`. + range (bool): Boolean flag for displaying range picker. Defaults to `True`. actions (list[Action]): See [`Action`][vizro.models.Action]. Defaults to `[]`. """ type: Literal["date_picker"] = "date_picker" - min: Optional[date] = Field(None, description="Start date for date picker.") - max: Optional[date] = Field(None, description="End date for date picker.") - value: Optional[Union[list[date], date]] = Field(None, description="Default date for date picker") - title: str = Field("", description="Title to be displayed.") - range: bool = Field(True, description="Boolean flag for displaying range picker.") - actions: list[Action] = [] + min: Optional[date] = Field(default=None, description="Start date for date picker.") + max: Annotated[ + Optional[date], AfterValidator(validate_max), Field(default=None, description="End date for date picker.") + ] + value: Annotated[ + Optional[Union[list[date], date]], + # TODO[MS]: check here and similar if the early exit clause in below validator or similar is + # necessary given we don't validate on default + AfterValidator(validate_range_value), + Field(default=None, description="Default date/dates for date picker."), + ] + title: str = Field(default="", description="Title to be displayed.") + range: Annotated[ + bool, + AfterValidator(validate_date_picker_range), + Field(default=True, description="Boolean flag for displaying range picker.", validate_default=True), + ] + actions: Annotated[ + list[Action], + AfterValidator(_action_validator_factory("value")), + PlainSerializer(lambda x: x[0].actions), + Field(default=[]), + ] _input_property: str = PrivateAttr("value") - _set_actions = _action_validator_factory("value") - - # Re-used validators - _validate_value = validator("value", allow_reuse=True)(validate_range_value) - _validate_max = validator("max", allow_reuse=True)(validate_max) - _validate_range = validator("range", allow_reuse=True, always=True)(validate_date_picker_range) def build(self): init_value = self.value or ([self.min, self.max] if self.range else self.min) # type: ignore[list-item] diff --git a/vizro-core/src/vizro/models/_components/form/dropdown.py b/vizro-core/src/vizro/models/_components/form/dropdown.py index e5dc8c4a1..069ceab68 100755 --- a/vizro-core/src/vizro/models/_components/form/dropdown.py +++ b/vizro-core/src/vizro/models/_components/form/dropdown.py @@ -1,15 +1,11 @@ import math from datetime import date -from typing import Literal, Optional, Union, cast - -from dash import dcc, html - -try: - from pydantic.v1 import Field, PrivateAttr, StrictBool, root_validator, validator -except ImportError: # pragma: no cov - from pydantic import Field, PrivateAttr, StrictBool, root_validator, validator +from typing import Annotated, Literal, Optional, Union, cast import dash_bootstrap_components as dbc +from dash import dcc, html +from pydantic import AfterValidator, Field, PrivateAttr, StrictBool, ValidationInfo, model_validator +from pydantic.functional_serializers import PlainSerializer from vizro.models import Action, VizroBaseModel from vizro.models._action._actions_chain import _action_validator_factory @@ -23,7 +19,7 @@ def _get_list_of_labels(full_options: OptionsType) -> Union[list[StrictBool], li if all(isinstance(option, dict) for option in full_options): return [option["label"] for option in full_options] # type: ignore[index] else: - return full_options + return cast(Union[list[StrictBool], list[float], list[str], list[date]], full_options) def _calculate_option_height(full_options: OptionsType) -> int: @@ -39,6 +35,15 @@ def _calculate_option_height(full_options: OptionsType) -> int: return 8 + 24 * number_of_lines +def validate_multi(multi, info: ValidationInfo): + if "value" not in info.data: + return multi + + if info.data["value"] and multi is False and isinstance(info.data["value"], list): + raise ValueError("Please set multi=True if providing a list of default values.") + return multi + + def _add_select_all_option(full_options: OptionsType) -> OptionsType: """Adds a 'Select All' option to the list of options.""" # TODO: Move option to dictionary conversion within `get_options_and_default` function as here: https://github.com/mckinsey/vizro/pull/961#discussion_r1923356781 @@ -72,10 +77,23 @@ class Dropdown(VizroBaseModel): type: Literal["dropdown"] = "dropdown" options: OptionsType = [] - value: Optional[Union[SingleValueType, MultiValueType]] = None - multi: bool = Field(True, description="Whether to allow selection of multiple values") - title: str = Field("", description="Title to be displayed") - actions: list[Action] = [] + value: Annotated[ + Optional[Union[SingleValueType, MultiValueType]], + AfterValidator(validate_value), + Field(default=None, validate_default=True), + ] + multi: Annotated[ + bool, + AfterValidator(validate_multi), + Field(default=True, description="Whether to allow selection of multiple values", validate_default=True), + ] + title: str = Field(default="", description="Title to be displayed") + actions: Annotated[ + list[Action], + AfterValidator(_action_validator_factory("value")), + PlainSerializer(lambda x: x[0].actions), + Field(default=[]), + ] # Consider making the _dynamic public later. The same property could also be used for all other components. # For example: vm.Graph could have a dynamic that is by default set on True. @@ -85,18 +103,7 @@ class Dropdown(VizroBaseModel): _input_property: str = PrivateAttr("value") # Re-used validators - _set_actions = _action_validator_factory("value") - _validate_options = root_validator(allow_reuse=True, pre=True)(validate_options_dict) - _validate_value = validator("value", allow_reuse=True, always=True)(validate_value) - - @validator("multi", always=True) - def validate_multi(cls, multi, values): - if "value" not in values: - return multi - - if values["value"] and multi is False and isinstance(values["value"], list): - raise ValueError("Please set multi=True if providing a list of default values.") - return multi + _validate_options = model_validator(mode="before")(validate_options_dict) def __call__(self, options): full_options, default_value = get_options_and_default(options=options, multi=self.multi) diff --git a/vizro-core/src/vizro/models/_components/form/radio_items.py b/vizro-core/src/vizro/models/_components/form/radio_items.py index 25b67beef..ff03056f6 100644 --- a/vizro-core/src/vizro/models/_components/form/radio_items.py +++ b/vizro-core/src/vizro/models/_components/form/radio_items.py @@ -1,13 +1,9 @@ -from typing import Literal, Optional - -from dash import html - -try: - from pydantic.v1 import Field, PrivateAttr, root_validator, validator -except ImportError: # pragma: no cov - from pydantic import Field, PrivateAttr, root_validator, validator +from typing import Annotated, Literal, Optional import dash_bootstrap_components as dbc +from dash import html +from pydantic import AfterValidator, Field, PrivateAttr, model_validator +from pydantic.functional_serializers import PlainSerializer from vizro.models import Action, VizroBaseModel from vizro.models._action._actions_chain import _action_validator_factory @@ -35,9 +31,16 @@ class RadioItems(VizroBaseModel): type: Literal["radio_items"] = "radio_items" options: OptionsType = [] - value: Optional[SingleValueType] = None - title: str = Field("", description="Title to be displayed") - actions: list[Action] = [] + value: Annotated[ + Optional[SingleValueType], AfterValidator(validate_value), Field(default=None, validate_default=True) + ] + title: str = Field(default="", description="Title to be displayed") + actions: Annotated[ + list[Action], + AfterValidator(_action_validator_factory("value")), + PlainSerializer(lambda x: x[0].actions), + Field(default=[]), + ] _dynamic: bool = PrivateAttr(False) @@ -45,9 +48,7 @@ class RadioItems(VizroBaseModel): _input_property: str = PrivateAttr("value") # Re-used validators - _set_actions = _action_validator_factory("value") - _validate_options = root_validator(allow_reuse=True, pre=True)(validate_options_dict) - _validate_value = validator("value", allow_reuse=True, always=True)(validate_value) + _validate_options = model_validator(mode="before")(validate_options_dict) def __call__(self, options): full_options, default_value = get_options_and_default(options=options, multi=False) diff --git a/vizro-core/src/vizro/models/_components/form/range_slider.py b/vizro-core/src/vizro/models/_components/form/range_slider.py index a96521708..5bef03879 100644 --- a/vizro-core/src/vizro/models/_components/form/range_slider.py +++ b/vizro-core/src/vizro/models/_components/form/range_slider.py @@ -1,13 +1,9 @@ -from typing import Literal, Optional - -from dash import ClientsideFunction, Input, Output, State, clientside_callback, dcc, html - -try: - from pydantic.v1 import Field, PrivateAttr, validator -except ImportError: # pragma: no cov - from pydantic import Field, PrivateAttr, validator +from typing import Annotated, Literal, Optional import dash_bootstrap_components as dbc +from dash import ClientsideFunction, Input, Output, State, clientside_callback, dcc, html +from pydantic import AfterValidator, Field, PrivateAttr, conlist +from pydantic.functional_serializers import PlainSerializer from vizro.models import Action, VizroBaseModel from vizro.models._action._actions_chain import _action_validator_factory @@ -32,7 +28,7 @@ class RangeSlider(VizroBaseModel): min (Optional[float]): Start value for slider. Defaults to `None`. max (Optional[float]): End value for slider. Defaults to `None`. step (Optional[float]): Step-size for marks on slider. Defaults to `None`. - marks (Optional[dict[int, Union[str, dict]]]): Marks to be displayed on slider. Defaults to `{}`. + marks (Optional[dict[Union[float, int], str]]): Marks to be displayed on slider. Defaults to `{}`. value (Optional[list[float]]): Default start and end value for slider. Must be 2 items. Defaults to `None`. title (str): Title to be displayed. Defaults to `""`. actions (list[Action]): See [`Action`][vizro.models.Action]. Defaults to `[]`. @@ -40,28 +36,40 @@ class RangeSlider(VizroBaseModel): """ type: Literal["range_slider"] = "range_slider" - min: Optional[float] = Field(None, description="Start value for slider.") - max: Optional[float] = Field(None, description="End value for slider.") - step: Optional[float] = Field(None, description="Step-size for marks on slider.") - marks: Optional[dict[float, str]] = Field({}, description="Marks to be displayed on slider.") - value: Optional[list[float]] = Field( - None, description="Default start and end value for slider", min_items=2, max_items=2 - ) - title: str = Field("", description="Title to be displayed.") - actions: list[Action] = [] + min: Optional[float] = Field(default=None, description="Start value for slider.") + max: Annotated[ + Optional[float], AfterValidator(validate_max), Field(default=None, description="End value for slider.") + ] + step: Annotated[ + Optional[float], + AfterValidator(validate_step), + Field(default=None, description="Step-size for marks on slider."), + ] + marks: Annotated[ + Optional[dict[float, str]], + AfterValidator(set_default_marks), + Field(default={}, description="Marks to be displayed on slider.", validate_default=True), + ] + # TODO[mypy], see: https://github.com/pydantic/pydantic/issues/156 for value field + value: Optional[ # type: ignore[valid-type] + Annotated[ + conlist(float, min_length=2, max_length=2), + AfterValidator(validate_range_value), + ] + ] = Field(default=None) + title: str = Field(default="", description="Title to be displayed.") + actions: Annotated[ + list[Action], + AfterValidator(_action_validator_factory("value")), + PlainSerializer(lambda x: x[0].actions), + Field(default=[]), + ] _dynamic: bool = PrivateAttr(False) # Component properties for actions and interactions _input_property: str = PrivateAttr("value") - # Re-used validators - _validate_max = validator("max", allow_reuse=True)(validate_max) - _validate_value = validator("value", allow_reuse=True)(validate_range_value) - _validate_step = validator("step", allow_reuse=True)(validate_step) - _set_default_marks = validator("marks", allow_reuse=True, always=True)(set_default_marks) - _set_actions = _action_validator_factory("value") - def __call__(self, min, max, current_value): output = [ Output(f"{self.id}_start_value", "value"), @@ -142,7 +150,7 @@ def _build_dynamic_placeholder(self, current_value): @_log_call def build(self): - current_value = self.value or [self.min, self.max] # type: ignore[list-item] + current_value = self.value or [self.min, self.max] return ( self._build_dynamic_placeholder(current_value) if self._dynamic diff --git a/vizro-core/src/vizro/models/_components/form/slider.py b/vizro-core/src/vizro/models/_components/form/slider.py index 2ffdb9f6a..a5c786f17 100644 --- a/vizro-core/src/vizro/models/_components/form/slider.py +++ b/vizro-core/src/vizro/models/_components/form/slider.py @@ -1,13 +1,9 @@ -from typing import Literal, Optional - -from dash import ClientsideFunction, Input, Output, State, clientside_callback, dcc, html - -try: - from pydantic.v1 import Field, PrivateAttr, validator -except ImportError: # pragma: no cov - from pydantic import Field, PrivateAttr, validator +from typing import Annotated, Literal, Optional import dash_bootstrap_components as dbc +from dash import ClientsideFunction, Input, Output, State, clientside_callback, dcc, html +from pydantic import AfterValidator, Field, PrivateAttr +from pydantic.functional_serializers import PlainSerializer from vizro.models import Action, VizroBaseModel from vizro.models._action._actions_chain import _action_validator_factory @@ -32,7 +28,7 @@ class Slider(VizroBaseModel): min (Optional[float]): Start value for slider. Defaults to `None`. max (Optional[float]): End value for slider. Defaults to `None`. step (Optional[float]): Step-size for marks on slider. Defaults to `None`. - marks (Optional[dict[int, Union[str, dict]]]): Marks to be displayed on slider. Defaults to `{}`. + marks (Optional[dict[Union[float, int], str]]): Marks to be displayed on slider. Defaults to `{}`. value (Optional[float]): Default value for slider. Defaults to `None`. title (str): Title to be displayed. Defaults to `""`. actions (list[Action]): See [`Action`][vizro.models.Action]. Defaults to `[]`. @@ -40,26 +36,38 @@ class Slider(VizroBaseModel): """ type: Literal["slider"] = "slider" - min: Optional[float] = Field(None, description="Start value for slider.") - max: Optional[float] = Field(None, description="End value for slider.") - step: Optional[float] = Field(None, description="Step-size for marks on slider.") - marks: Optional[dict[float, str]] = Field({}, description="Marks to be displayed on slider.") - value: Optional[float] = Field(None, description="Default value for slider.") - title: str = Field("", description="Title to be displayed.") - actions: list[Action] = [] + min: Optional[float] = Field(default=None, description="Start value for slider.") + max: Annotated[ + Optional[float], AfterValidator(validate_max), Field(default=None, description="End value for slider.") + ] + step: Annotated[ + Optional[float], + AfterValidator(validate_step), + Field(default=None, description="Step-size for marks on slider."), + ] + marks: Annotated[ + Optional[dict[float, str]], + AfterValidator(set_default_marks), + Field(default={}, description="Marks to be displayed on slider.", validate_default=True), + ] + value: Annotated[ + Optional[float], + AfterValidator(validate_range_value), + Field(default=None, description="Default value for slider."), + ] + title: str = Field(default="", description="Title to be displayed.") + actions: Annotated[ + list[Action], + AfterValidator(_action_validator_factory("value")), + PlainSerializer(lambda x: x[0].actions), + Field(default=[]), + ] _dynamic: bool = PrivateAttr(False) # Component properties for actions and interactions _input_property: str = PrivateAttr("value") - # Re-used validators - _validate_max = validator("max", allow_reuse=True)(validate_max) - _validate_value = validator("value", allow_reuse=True)(validate_range_value) - _validate_step = validator("step", allow_reuse=True)(validate_step) - _set_default_marks = validator("marks", allow_reuse=True, always=True)(set_default_marks) - _set_actions = _action_validator_factory("value") - def __call__(self, min, max, current_value): output = [ Output(f"{self.id}_end_value", "value"), diff --git a/vizro-core/src/vizro/models/_components/graph.py b/vizro-core/src/vizro/models/_components/graph.py index d69790819..095719a06 100644 --- a/vizro-core/src/vizro/models/_components/graph.py +++ b/vizro-core/src/vizro/models/_components/graph.py @@ -1,18 +1,15 @@ import logging import warnings from contextlib import suppress -from typing import Literal +from typing import Annotated, Literal, cast +import pandas as pd from dash import ClientsideFunction, Input, Output, State, clientside_callback, dcc, html, set_props from dash.exceptions import MissingCallbackContextException from plotly import graph_objects as go - -try: - from pydantic.v1 import Field, PrivateAttr, validator -except ImportError: # pragma: no cov - from pydantic import Field, PrivateAttr, validator - -import pandas as pd +from pydantic import AfterValidator, Field, PrivateAttr, field_validator +from pydantic.functional_serializers import PlainSerializer +from pydantic.json_schema import SkipJsonSchema from vizro.actions._actions_utils import CallbackTriggerDict, _get_component_actions from vizro.managers import data_manager, model_manager @@ -21,7 +18,7 @@ from vizro.models._action._actions_chain import _action_validator_factory from vizro.models._components._components_utils import _process_callable_data_frame from vizro.models._models_utils import _log_call -from vizro.models.types import CapturedCallable +from vizro.models.types import CapturedCallable, validate_captured_callable logger = logging.getLogger(__name__) @@ -44,28 +41,36 @@ class Graph(VizroBaseModel): """ type: Literal["graph"] = "graph" - figure: CapturedCallable = Field( - ..., import_path="vizro.plotly.express", mode="graph", description="Function that returns a plotly `go.Figure`" - ) - title: str = Field("", description="Title of the `Graph`") + figure: Annotated[ + SkipJsonSchema[CapturedCallable], + AfterValidator(_process_callable_data_frame), + Field( + json_schema_extra={"mode": "graph", "import_path": "vizro.plotly.express"}, + description="Function that returns a plotly `go.Figure`", + ), + ] + title: str = Field(default="", description="Title of the `Graph`") header: str = Field( - "", + default="", description="Markdown text positioned below the `Graph.title`. Follows the CommonMark specification. Ideal for " "adding supplementary information such as subtitles, descriptions, or additional context.", ) footer: str = Field( - "", + default="", description="Markdown text positioned below the `Graph`. Follows the CommonMark specification. Ideal for " "providing further details such as sources, disclaimers, or additional notes.", ) - actions: list[Action] = [] + actions: Annotated[ + list[Action], + AfterValidator(_action_validator_factory("clickData")), + PlainSerializer(lambda x: x[0].actions), + Field(default=[]), + ] # Component properties for actions and interactions _output_component_property: str = PrivateAttr("figure") - # Validators - _set_actions = _action_validator_factory("clickData") - _validate_callable = validator("figure", allow_reuse=True)(_process_callable_data_frame) + _validate_figure = field_validator("figure", mode="before")(validate_captured_callable) # Convenience wrapper/syntactic sugar. def __call__(self, **kwargs): @@ -117,7 +122,7 @@ def _filter_interaction( source_graph_id: ModelID = ctd_click_data["id"] source_graph_actions = _get_component_actions(model_manager[source_graph_id]) try: - custom_data_columns = model_manager[source_graph_id]["custom_data"] + custom_data_columns = cast(Graph, model_manager[source_graph_id])["custom_data"] except KeyError as exc: raise KeyError( f"Missing 'custom_data' for the source graph with id {source_graph_id}. " diff --git a/vizro-core/src/vizro/models/_components/table.py b/vizro-core/src/vizro/models/_components/table.py index edfea4c5c..602fe397b 100644 --- a/vizro-core/src/vizro/models/_components/table.py +++ b/vizro-core/src/vizro/models/_components/table.py @@ -1,13 +1,11 @@ import logging -from typing import Literal +from typing import Annotated, Literal import pandas as pd from dash import State, dcc, html - -try: - from pydantic.v1 import Field, PrivateAttr, validator -except ImportError: # pragma: no cov - from pydantic import Field, PrivateAttr, validator +from pydantic import AfterValidator, Field, PrivateAttr, field_validator +from pydantic.functional_serializers import PlainSerializer +from pydantic.json_schema import SkipJsonSchema from vizro.actions._actions_utils import CallbackTriggerDict, _get_component_actions, _get_parent_model from vizro.managers import data_manager @@ -15,7 +13,7 @@ from vizro.models._action._actions_chain import _action_validator_factory from vizro.models._components._components_utils import _process_callable_data_frame from vizro.models._models_utils import _log_call -from vizro.models.types import CapturedCallable +from vizro.models.types import CapturedCallable, validate_captured_callable logger = logging.getLogger(__name__) @@ -37,30 +35,38 @@ class Table(VizroBaseModel): """ type: Literal["table"] = "table" - figure: CapturedCallable = Field( - ..., import_path="vizro.tables", mode="table", description="Function that returns a `Dash DataTable`." - ) - title: str = Field("", description="Title of the `Table`") + figure: Annotated[ + SkipJsonSchema[CapturedCallable], + AfterValidator(_process_callable_data_frame), + Field( + json_schema_extra={"mode": "table", "import_path": "vizro.tables"}, + description="Function that returns a `Dash DataTable`.", + ), + ] + title: str = Field(default="", description="Title of the `Table`") header: str = Field( - "", + default="", description="Markdown text positioned below the `Table.title`. Follows the CommonMark specification. Ideal for " "adding supplementary information such as subtitles, descriptions, or additional context.", ) footer: str = Field( - "", + default="", description="Markdown text positioned below the `Table`. Follows the CommonMark specification. Ideal for " "providing further details such as sources, disclaimers, or additional notes.", ) - actions: list[Action] = [] + actions: Annotated[ + list[Action], + AfterValidator(_action_validator_factory("active_cell")), + PlainSerializer(lambda x: x[0].actions), + Field(default=[]), + ] _input_component_id: str = PrivateAttr() # Component properties for actions and interactions _output_component_property: str = PrivateAttr("children") - # Validators - set_actions = _action_validator_factory("active_cell") - _validate_callable = validator("figure", allow_reuse=True, always=True)(_process_callable_data_frame) + _validate_figure = field_validator("figure", mode="before")(validate_captured_callable) # Convenience wrapper/syntactic sugar. def __call__(self, **kwargs): diff --git a/vizro-core/src/vizro/models/_components/tabs.py b/vizro-core/src/vizro/models/_components/tabs.py index 099d2f1d9..87a139582 100644 --- a/vizro-core/src/vizro/models/_components/tabs.py +++ b/vizro-core/src/vizro/models/_components/tabs.py @@ -3,14 +3,10 @@ from typing import TYPE_CHECKING, Literal import dash_bootstrap_components as dbc - -try: - from pydantic.v1 import validator -except ImportError: # pragma: no cov - from pydantic import validator +from pydantic import conlist from vizro.models import VizroBaseModel -from vizro.models._models_utils import _log_call, validate_min_length +from vizro.models._models_utils import _log_call if TYPE_CHECKING: from vizro.models._components import Container @@ -26,9 +22,8 @@ class Tabs(VizroBaseModel): """ type: Literal["tabs"] = "tabs" - tabs: list[Container] - - _validate_tabs = validator("tabs", allow_reuse=True, always=True)(validate_min_length) + # TODO[mypy], see: https://github.com/pydantic/pydantic/issues/156 for tabs field + tabs: conlist(Container, min_length=1) # type: ignore[valid-type] @_log_call def build(self): diff --git a/vizro-core/src/vizro/models/_controls/filter.py b/vizro-core/src/vizro/models/_controls/filter.py index 8a18add8e..f45459cd9 100644 --- a/vizro-core/src/vizro/models/_controls/filter.py +++ b/vizro-core/src/vizro/models/_controls/filter.py @@ -1,23 +1,17 @@ from __future__ import annotations from collections.abc import Iterable -from typing import Any, Literal, Union, cast +from typing import Annotated, Any, Literal, Optional, Union, cast import pandas as pd from dash import dcc from pandas.api.types import is_datetime64_any_dtype, is_numeric_dtype - -from vizro.managers._data_manager import DataSourceName - -try: - from pydantic.v1 import Field, PrivateAttr, validator -except ImportError: # pragma: no cov - from pydantic import Field, PrivateAttr, validator +from pydantic import AfterValidator, Field, PrivateAttr from vizro._constants import ALL_OPTION, FILTER_ACTION_PREFIX from vizro.actions import _filter from vizro.managers import data_manager, model_manager -from vizro.managers._data_manager import _DynamicData +from vizro.managers._data_manager import DataSourceName, _DynamicData from vizro.managers._model_manager import FIGURE_MODELS, ModelID from vizro.models import Action, VizroBaseModel from vizro.models._components.form import ( @@ -29,16 +23,20 @@ Slider, ) from vizro.models._models_utils import _log_call -from vizro.models.types import MultiValueType, SelectorType +from vizro.models.types import FigureType, MultiValueType, SelectorType # Ideally we might define these as NumericalSelectorType = Union[RangeSlider, Slider] etc., but that will not work # with isinstance checks. # First entry in each tuple is the default selector for that column type. +# MS: For mypy we need to do this anyway, see below - I have tried to make a function that takes the tuples and +# converts them in order to reuse code, but I think it does not work SELECTORS = { "numerical": (RangeSlider, Slider), "categorical": (Dropdown, Checklist, RadioItems), "temporal": (DatePicker,), } +CategoricalSelectorType = Union[Dropdown, Checklist, RadioItems] +NumericalTemporalSelectorType = Union[RangeSlider, Slider, DatePicker] # This disallowed selectors for each column type map is based on the discussion at the following link: # See https://github.com/mckinsey/vizro/pull/319#discussion_r1524888171 @@ -51,6 +49,7 @@ # TODO: Remove DYNAMIC_SELECTORS along with its validation check when support dynamic mode for the DatePicker selector. # Tuple of filter selectors that support dynamic mode DYNAMIC_SELECTORS = (Dropdown, Checklist, RadioItems, Slider, RangeSlider) +DynamicNonCategoricalSelectorType = Union[Slider, RangeSlider] def _filter_between(series: pd.Series, value: Union[list[float], list[str]]) -> pd.Series: @@ -71,6 +70,12 @@ def _filter_isin(series: pd.Series, value: MultiValueType) -> pd.Series: return series.isin(value) +def check_target_present(target): + if target not in model_manager: + raise ValueError(f"Target {target} not found in model_manager.") + return target + + class Filter(VizroBaseModel): """Filter the data supplied to `targets` on the [`Page`][vizro.models.Page]. @@ -81,19 +86,19 @@ class Filter(VizroBaseModel): type (Literal["filter"]): Defaults to `"filter"`. column (str): Column of `DataFrame` to filter. targets (list[ModelID]): Target component to be affected by filter. If none are given then target all components - on the page that use `column`. - selector (SelectorType): See [SelectorType][vizro.models.types.SelectorType]. Defaults to `None`. + on the page that use `column`. Defaults to `[]`. + selector (Optional[SelectorType]): See [SelectorType][vizro.models.types.SelectorType]. Defaults to `None`. """ type: Literal["filter"] = "filter" - column: str = Field(..., description="Column of DataFrame to filter.") - targets: list[ModelID] = Field( - [], + column: str = Field(description="Column of DataFrame to filter.") + targets: list[Annotated[ModelID, AfterValidator(check_target_present)]] = Field( + default=[], description="Target component to be affected by filter. " "If none are given then target all components on the page that use `column`.", ) - selector: SelectorType = None + selector: Optional[SelectorType] = None _dynamic: bool = PrivateAttr(False) @@ -102,12 +107,6 @@ class Filter(VizroBaseModel): _column_type: Literal["numerical", "categorical", "temporal"] = PrivateAttr() - @validator("targets", each_item=True) - def check_target_present(cls, target): - if target not in model_manager: - raise ValueError(f"Target {target} not found in model_manager.") - return target - def __call__(self, target_to_data_frame: dict[ModelID, pd.DataFrame], current_value: Any): # Only relevant for a dynamic filter. # Although targets are fixed at build time, the validation logic is repeated during runtime, so if a column @@ -124,8 +123,10 @@ def __call__(self, target_to_data_frame: dict[ModelID, pd.DataFrame], current_va ) if isinstance(self.selector, SELECTORS["categorical"]): + self.selector = cast(CategoricalSelectorType, self.selector) return self.selector(options=self._get_options(targeted_data, current_value)) else: + self.selector = cast(DynamicNonCategoricalSelectorType, self.selector) _min, _max = self._get_min_max(targeted_data, current_value) # "current_value" is propagated only to support dcc.Input and dcc.Store components in numerical selectors # to work with a dynamic selector. This can be removed when dash persistence bug is fixed. @@ -152,7 +153,7 @@ def pre_build(self): # Find more about the mentioned limitation at: https://github.com/mckinsey/vizro/pull/879/files#r1846609956 # Even if the solution changes for dynamic data, static data should still use {} as the arguments here. multi_data_source_name_load_kwargs: list[tuple[DataSourceName, dict[str, Any]]] = [ - (model_manager[target]["data_frame"], {}) for target in proposed_targets + (cast(FigureType, model_manager[target])["data_frame"], {}) for target in proposed_targets ] target_to_data_frame = dict(zip(proposed_targets, data_manager._multi_load(multi_data_source_name_load_kwargs))) @@ -183,7 +184,7 @@ def pre_build(self): and getattr(self.selector, "max", None) is None ): for target_id in self.targets: - data_source_name = model_manager[target_id]["data_frame"] + data_source_name = cast(FigureType, model_manager[target_id])["data_frame"] if isinstance(data_manager[data_source_name], _DynamicData): self._dynamic = True self.selector._dynamic = True @@ -191,6 +192,7 @@ def pre_build(self): # Set appropriate properties for the selector. if isinstance(self.selector, SELECTORS["numerical"] + SELECTORS["temporal"]): + self.selector = cast(NumericalTemporalSelectorType, self.selector) _min, _max = self._get_min_max(targeted_data) # Note that manually set self.selector.min/max = 0 are Falsey but should not be overwritten. if self.selector.min is None: @@ -199,6 +201,7 @@ def pre_build(self): self.selector.max = _max else: # Categorical selector. + self.selector = cast(CategoricalSelectorType, self.selector) self.selector.options = self.selector.options or self._get_options(targeted_data) if not self.selector.actions: @@ -218,7 +221,9 @@ def pre_build(self): @_log_call def build(self): - selector_build_obj = self.selector.build() + # Cast is justified as the selector is set in pre_build and is not None. + selector = cast(SelectorType, self.selector) + selector_build_obj = selector.build() # TODO: Align the (dynamic) object's return structure with the figure's components when the Dash bug is fixed. # This means returning an empty "html.Div(id=self.id, className=...)" as a placeholder from Filter.build(). # Also, make selector.title visible when the filter is reloading. @@ -231,11 +236,11 @@ def build(self): # Note: dcc.Slider and dcc.RangeSlider do not support the "style" property directly, # so the "className" attribute is used to apply custom CSS for visibility control. # Reference for Dash class names: https://dashcheatsheet.pythonanywhere.com/ - selector_build_obj[self.selector.id].className = "invisible" - if f"{self.selector.id}_start_value" in selector_build_obj: - selector_build_obj[f"{self.selector.id}_start_value"].className = "d-none" - if f"{self.selector.id}_end_value" in selector_build_obj: - selector_build_obj[f"{self.selector.id}_end_value"].className = "d-none" + selector_build_obj[selector.id].className = "invisible" + if f"{selector.id}_start_value" in selector_build_obj: + selector_build_obj[f"{selector.id}_start_value"].className = "d-none" + if f"{selector.id}_end_value" in selector_build_obj: + selector_build_obj[f"{selector.id}_end_value"].className = "d-none" return dcc.Loading( id=self.id, diff --git a/vizro-core/src/vizro/models/_controls/parameter.py b/vizro-core/src/vizro/models/_controls/parameter.py index cdc76936c..575a913fb 100644 --- a/vizro-core/src/vizro/models/_controls/parameter.py +++ b/vizro-core/src/vizro/models/_controls/parameter.py @@ -1,10 +1,7 @@ from collections.abc import Iterable -from typing import Literal, cast +from typing import Annotated, Literal, cast -try: - from pydantic.v1 import Field, validator -except ImportError: # pragma: no cov - from pydantic import Field, validator +from pydantic import AfterValidator, Field from vizro._constants import PARAMETER_ACTION_PREFIX from vizro.actions import _parameter @@ -15,6 +12,42 @@ from vizro.models.types import SelectorType +def check_dot_notation(target): + if "." not in target: + raise ValueError( + f"Invalid target {target}. Targets must be supplied in the form ." + ) + return target + + +def check_target_present(target): + target_id = target.split(".")[0] + if target_id not in model_manager: + raise ValueError(f"Target {target_id} not found in model_manager.") + return target + + +def check_data_frame_as_target_argument(target): + targeted_argument = target.split(".", 1)[1] + if targeted_argument.startswith("data_frame") and targeted_argument.count(".") != 1: + raise ValueError( + f"Invalid target {target}. 'data_frame' target must be supplied in the form " + ".data_frame." + ) + # TODO: Add validation: Make sure the target data_frame is _DynamicData. + return target + + +def check_duplicate_parameter_target(targets): + all_targets = targets.copy() + for param in cast(Iterable[Parameter], model_manager._get_models(Parameter)): + all_targets.extend(param.targets) + duplicate_targets = {item for item in all_targets if all_targets.count(item) > 1} + if duplicate_targets: + raise ValueError(f"Duplicate parameter targets {duplicate_targets} found.") + return targets + + class Parameter(VizroBaseModel): """Alter the arguments supplied to any `targets` on the [`Page`][vizro.models.Page]. @@ -30,45 +63,20 @@ class Parameter(VizroBaseModel): """ type: Literal["parameter"] = "parameter" - targets: list[str] = Field(..., description="Targets in the form of `.`.") + targets: Annotated[ # TODO[MS]: check if the double annotation is the best way to do this + list[ + Annotated[ + str, + AfterValidator(check_dot_notation), + AfterValidator(check_target_present), + AfterValidator(check_data_frame_as_target_argument), + Field(description="Targets in the form of `.`."), + ] + ], + AfterValidator(check_duplicate_parameter_target), + ] selector: SelectorType - @validator("targets", each_item=True) - def check_dot_notation(cls, target): - if "." not in target: - raise ValueError( - f"Invalid target {target}. Targets must be supplied in the form ." - ) - return target - - @validator("targets", each_item=True) - def check_target_present(cls, target): - target_id = target.split(".")[0] - if target_id not in model_manager: - raise ValueError(f"Target {target_id} not found in model_manager.") - return target - - @validator("targets", each_item=True) - def check_data_frame_as_target_argument(cls, target): - targeted_argument = target.split(".", 1)[1] - if targeted_argument.startswith("data_frame") and targeted_argument.count(".") != 1: - raise ValueError( - f"Invalid target {target}. 'data_frame' target must be supplied in the form " - ".data_frame." - ) - # TODO: Add validation: Make sure the target data_frame is _DynamicData. - return target - - @validator("targets") - def check_duplicate_parameter_target(cls, targets): - all_targets = targets.copy() - for param in cast(Iterable[Parameter], model_manager._get_models(Parameter)): - all_targets.extend(param.targets) - duplicate_targets = {item for item in all_targets if all_targets.count(item) > 1} - if duplicate_targets: - raise ValueError(f"Duplicate parameter targets {duplicate_targets} found.") - return targets - @_log_call def pre_build(self): self._check_numerical_and_temporal_selectors_values() diff --git a/vizro-core/src/vizro/models/_dashboard.py b/vizro-core/src/vizro/models/_dashboard.py index 3c1dea11e..6ea28c4be 100644 --- a/vizro-core/src/vizro/models/_dashboard.py +++ b/vizro-core/src/vizro/models/_dashboard.py @@ -4,7 +4,7 @@ import logging from functools import partial from pathlib import Path -from typing import TYPE_CHECKING, Literal, TypedDict +from typing import TYPE_CHECKING, Annotated, Literal, Optional, TypedDict, cast import dash import dash_bootstrap_components as dbc @@ -21,18 +21,12 @@ get_relative_path, html, ) - -import vizro -from vizro._themes.template_dashboard_overrides import dashboard_overrides - -try: - from pydantic.v1 import Field, validator -except ImportError: # pragma: no cov - from pydantic import Field, validator - from dash.development.base_component import Component +from pydantic import AfterValidator, Field, ValidationInfo +import vizro from vizro._constants import MODULE_PAGE_404, VIZRO_ASSETS_PATH +from vizro._themes.template_dashboard_overrides import dashboard_overrides from vizro.actions._action_loop._action_loop import ActionLoop from vizro.models import Navigation, VizroBaseModel from vizro.models._models_utils import _log_call @@ -76,6 +70,15 @@ def _all_hidden(components: list[Component]): ) +def set_navigation_pages(navigation: Optional[Navigation], info: ValidationInfo) -> Optional[Navigation]: + if "pages" not in info.data: + return navigation + + navigation = navigation or Navigation() + navigation.pages = navigation.pages or [page.id for page in info.data["pages"]] + return navigation + + class Dashboard(VizroBaseModel): """Vizro Dashboard to be used within [`Vizro`][vizro._vizro.Vizro.build]. @@ -90,25 +93,12 @@ class Dashboard(VizroBaseModel): pages: list[Page] theme: Literal["vizro_dark", "vizro_light"] = Field( - "vizro_dark", description="Layout theme to be applied across dashboard. Defaults to `vizro_dark`" + default="vizro_dark", description="Layout theme to be applied across dashboard. Defaults to `vizro_dark`." ) - navigation: Navigation = None # type: ignore[assignment] - title: str = Field("", description="Dashboard title to appear on every page on top left-side.") - - @validator("pages", always=True) - def validate_pages(cls, pages): - if not pages: - raise ValueError("Ensure this value has at least 1 item.") - return pages - - @validator("navigation", always=True) - def set_navigation_pages(cls, navigation, values): - if "pages" not in values: - return navigation - - navigation = navigation or Navigation() - navigation.pages = navigation.pages or [page.id for page in values["pages"]] - return navigation + navigation: Annotated[ + Optional[Navigation], AfterValidator(set_navigation_pages), Field(default=None, validate_default=True) + ] + title: str = Field(default="", description="Dashboard title to appear on every page on top left-side.") @_log_call def pre_build(self): @@ -230,7 +220,8 @@ def _get_page_divs(self, page: Page) -> _PageDivsType: # Shared across pages but slightly differ in content. These could possibly be done by a clientside # callback instead. page_title = html.H2(id="page-title", children=page.title) - navigation: _NavBuildType = self.navigation.build(active_page_id=page.id) + # cannot actually be None if you check pages and layout field together + navigation: _NavBuildType = cast(Navigation, self.navigation).build(active_page_id=page.id) nav_bar = navigation["nav-bar"] nav_panel = navigation["nav-panel"] diff --git a/vizro-core/src/vizro/models/_layout.py b/vizro-core/src/vizro/models/_layout.py index 201f7f9c2..bb21e68de 100644 --- a/vizro-core/src/vizro/models/_layout.py +++ b/vizro-core/src/vizro/models/_layout.py @@ -1,13 +1,9 @@ -from typing import NamedTuple, Optional +from typing import Annotated, NamedTuple, Optional import numpy as np from dash import html from numpy import ma - -try: - from pydantic.v1 import Field, PrivateAttr, validator -except ImportError: # pragma: no cov - from pydantic import Field, PrivateAttr, validator +from pydantic import AfterValidator, Field, PrivateAttr, ValidationInfo from vizro._constants import EMPTY_SPACE_CONST from vizro.models import VizroBaseModel @@ -33,22 +29,40 @@ def _get_unique_grid_component_ids(grid: list[list[int]]): # Validators for reuse -def set_layout(cls, layout, values): +def set_layout(layout, info: ValidationInfo): from vizro.models import Layout - if "components" not in values: + # This exists only to eagerly raise the error, otherwise obscure error message on eg Page() + # Same for similar code in other places + # TODO[MS]: find another solution that clashes less with typing + if "components" not in info.data: return layout if layout is None: - grid = [[i] for i in range(len(values["components"]))] + grid = [[i] for i in range(len(info.data["components"]))] return Layout(grid=grid) unique_grid_idx = _get_unique_grid_component_ids(layout.grid) - if len(unique_grid_idx) != len(values["components"]): + if len(unique_grid_idx) != len(info.data["components"]): raise ValueError("Number of page and grid components need to be the same.") return layout +def validate_grid(grid): + if len({len(row) for row in grid}) > 1: + raise ValueError("All rows must be of same length.") + + # Validate grid type and values + unique_grid_idx = _get_unique_grid_component_ids(grid) + if 0 not in unique_grid_idx or not np.array_equal(unique_grid_idx, np.arange(unique_grid_idx.max() + 1)): + raise ValueError("Grid must contain consecutive integers starting from 0.") + + # Validates grid areas spanned by components and spaces + component_grid_lines, space_grid_lines = _get_grid_lines(grid) + _validate_grid_areas(component_grid_lines + space_grid_lines) + return grid + + def _convert_to_combined_grid_coord(matrix: ma.MaskedArray) -> ColRowGridLines: """Converts `matrix` coordinates from user `grid` to one combined grid area spanned by component i. @@ -156,30 +170,17 @@ class Layout(VizroBaseModel): """ - grid: list[list[int]] = Field(..., description="Grid specification to arrange components on screen.") - row_gap: str = Field(GAP_DEFAULT, description="Gap between rows in px. Defaults to 12px.", regex="[0-9]+px") - col_gap: str = Field(GAP_DEFAULT, description="Gap between columns in px. Defaults to 12px.", regex="[0-9]+px") - row_min_height: str = Field(MIN_DEFAULT, description="Minimum row height in px. Defaults to 0px.", regex="[0-9]+px") - col_min_width: str = Field( - MIN_DEFAULT, description="Minimum column width in px. Defaults to 0px.", regex="[0-9]+px" - ) + grid: Annotated[ + list[list[int]], + AfterValidator(validate_grid), + Field(description="Grid specification to arrange components on screen."), + ] + row_gap: str = Field(default=GAP_DEFAULT, description="Gap between rows in px.", pattern="[0-9]+px") + col_gap: str = Field(default=GAP_DEFAULT, description="Gap between columns in px.", pattern="[0-9]+px") + row_min_height: str = Field(default=MIN_DEFAULT, description="Minimum row height in px.", pattern="[0-9]+px") + col_min_width: str = Field(default=MIN_DEFAULT, description="Minimum column width in px.", pattern="[0-9]+px") _component_grid_lines: Optional[list[ColRowGridLines]] = PrivateAttr() - @validator("grid") - def validate_grid(cls, grid): - if len({len(row) for row in grid}) > 1: - raise ValueError("All rows must be of same length.") - - # Validate grid type and values - unique_grid_idx = _get_unique_grid_component_ids(grid) - if 0 not in unique_grid_idx or not np.array_equal(unique_grid_idx, np.arange(unique_grid_idx.max() + 1)): - raise ValueError("Grid must contain consecutive integers starting from 0.") - - # Validates grid areas spanned by components and spaces - component_grid_lines, space_grid_lines = _get_grid_lines(grid) - _validate_grid_areas(component_grid_lines + space_grid_lines) - return grid - def __init__(self, **data): super().__init__(**data) self._component_grid_lines = _get_grid_lines(self.grid)[0] @@ -216,8 +217,8 @@ def build(self): style={ "gridRowGap": self.row_gap, "gridColumnGap": self.col_gap, - "gridTemplateColumns": f"repeat({len(self.grid[0])}," f"minmax({self.col_min_width}, 1fr))", - "gridTemplateRows": f"repeat({len(self.grid)}," f"minmax({self.row_min_height}, 1fr))", + "gridTemplateColumns": f"repeat({len(self.grid[0])},minmax({self.col_min_width}, 1fr))", + "gridTemplateRows": f"repeat({len(self.grid)},minmax({self.row_min_height}, 1fr))", }, className="grid-layout", id=self.id, diff --git a/vizro-core/src/vizro/models/_models_utils.py b/vizro-core/src/vizro/models/_models_utils.py index ca6d08685..436b13ee4 100644 --- a/vizro-core/src/vizro/models/_models_utils.py +++ b/vizro-core/src/vizro/models/_models_utils.py @@ -18,13 +18,7 @@ def _wrapper(self, *args, **kwargs): # Validators for reuse -def validate_min_length(cls, value): - if not value: - raise ValueError("Ensure this value has at least 1 item.") - return value - - -def check_captured_callable(cls, value): +def check_captured_callable_model(value): if isinstance(value, CapturedCallable): captured_callable = value elif isinstance(value, _SupportsCapturedCallable): diff --git a/vizro-core/src/vizro/models/_navigation/_navigation_utils.py b/vizro-core/src/vizro/models/_navigation/_navigation_utils.py index 387cf8b9a..924813e00 100644 --- a/vizro-core/src/vizro/models/_navigation/_navigation_utils.py +++ b/vizro-core/src/vizro/models/_navigation/_navigation_utils.py @@ -7,9 +7,10 @@ import dash_bootstrap_components as dbc from vizro.managers import model_manager +from vizro.models.types import NavPagesType -def _validate_pages(pages): +def _validate_pages(pages: NavPagesType) -> NavPagesType: """Reusable validator to check if provided Page IDs exist as registered pages.""" from vizro.models import Page diff --git a/vizro-core/src/vizro/models/_navigation/accordion.py b/vizro-core/src/vizro/models/_navigation/accordion.py index e8f27c1ff..d7c23a95f 100644 --- a/vizro-core/src/vizro/models/_navigation/accordion.py +++ b/vizro-core/src/vizro/models/_navigation/accordion.py @@ -1,14 +1,10 @@ import itertools from collections.abc import Mapping -from typing import Literal +from typing import Annotated, Literal, cast import dash_bootstrap_components as dbc from dash import get_relative_path - -try: - from pydantic.v1 import Field, validator -except ImportError: # pragma: no cov - from pydantic import Field, validator +from pydantic import AfterValidator, BeforeValidator, Field from vizro._constants import ACCORDION_DEFAULT_TITLE from vizro.managers._model_manager import ModelID, model_manager @@ -17,6 +13,12 @@ from vizro.models._navigation._navigation_utils import _validate_pages +def coerce_pages_type(pages): + if isinstance(pages, Mapping): + return pages + return {ACCORDION_DEFAULT_TITLE: pages} + + class Accordion(VizroBaseModel): """Accordion to be used as nav_selector in [`Navigation`][vizro.models.Navigation]. @@ -27,15 +29,14 @@ class Accordion(VizroBaseModel): """ type: Literal["accordion"] = "accordion" - pages: dict[str, list[str]] = Field({}, description="Mapping from name of a pages group to a list of page IDs.") - - _validate_pages = validator("pages", allow_reuse=True)(_validate_pages) - - @validator("pages", pre=True) - def coerce_pages_type(cls, pages): - if isinstance(pages, Mapping): - return pages - return {ACCORDION_DEFAULT_TITLE: pages} + pages: Annotated[ + dict[ + str, list[str] # TODO[MS]:this is the type after validation, but the type before validation is NavPagesType + ], + AfterValidator(_validate_pages), + BeforeValidator(coerce_pages_type), + Field(default={}, description="Mapping from name of a pages group to a list of page IDs."), + ] @_log_call def build(self, *, active_page_id=None): @@ -79,10 +80,12 @@ def build(self, *, active_page_id=None): def _create_nav_links(self, pages: list[str]): """Creates a `NavLink` for each provided page.""" + from vizro.models import Page + nav_links = [] for page_id in pages: - page = model_manager[ModelID(str(page_id))] + page = cast(Page, model_manager[ModelID(str(page_id))]) nav_links.append( dbc.NavLink( children=page.title, diff --git a/vizro-core/src/vizro/models/_navigation/nav_bar.py b/vizro-core/src/vizro/models/_navigation/nav_bar.py index 325573542..9f015394b 100644 --- a/vizro-core/src/vizro/models/_navigation/nav_bar.py +++ b/vizro-core/src/vizro/models/_navigation/nav_bar.py @@ -1,16 +1,11 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Literal +from typing import Annotated, Literal, Union import dash_bootstrap_components as dbc from dash import html - -try: - from pydantic.v1 import Field, validator -except ImportError: # pragma: no cov - from pydantic import Field, validator - +from pydantic import AfterValidator, BeforeValidator, Field from vizro.models import VizroBaseModel from vizro.models._models_utils import _log_call @@ -18,6 +13,12 @@ from vizro.models._navigation.nav_link import NavLink +def coerce_pages_type(pages: Union[list[str], dict[str, list[str]]]) -> dict[str, list[str]]: + if isinstance(pages, Mapping): + return pages + return {page: [page] for page in pages} + + class NavBar(VizroBaseModel): """Navigation bar to be used as a nav_selector for `Navigation`. @@ -29,18 +30,14 @@ class NavBar(VizroBaseModel): """ type: Literal["nav_bar"] = "nav_bar" - pages: dict[str, list[str]] = Field({}, description="Mapping from name of a pages group to a list of page IDs.") + pages: Annotated[ + dict[str, list[str]], + AfterValidator(_validate_pages), + BeforeValidator(coerce_pages_type), + Field(default={}, description="Mapping from name of a pages group to a list of page IDs."), + ] items: list[NavLink] = [] - # validators - _validate_pages = validator("pages", allow_reuse=True)(_validate_pages) - - @validator("pages", pre=True) - def coerce_pages_type(cls, pages): - if isinstance(pages, Mapping): - return pages - return {page: [page] for page in pages} - @_log_call def pre_build(self): self.items = self.items or [ diff --git a/vizro-core/src/vizro/models/_navigation/nav_link.py b/vizro-core/src/vizro/models/_navigation/nav_link.py index a288181ac..f13e69ecb 100644 --- a/vizro-core/src/vizro/models/_navigation/nav_link.py +++ b/vizro-core/src/vizro/models/_navigation/nav_link.py @@ -1,15 +1,11 @@ from __future__ import annotations import itertools +from typing import Annotated, cast import dash_bootstrap_components as dbc from dash import get_relative_path, html - -try: - from pydantic.v1 import Field, PrivateAttr, validator -except ImportError: # pragma: no cov - from pydantic import Field, PrivateAttr, validator - +from pydantic import AfterValidator, Field, PrivateAttr from vizro.managers._model_manager import ModelID, model_manager from vizro.models import VizroBaseModel @@ -19,6 +15,10 @@ from vizro.models.types import NavPagesType +def validate_icon(icon) -> str: + return icon.strip().lower().replace(" ", "_") + + class NavLink(VizroBaseModel): """Icon that serves as a navigation link to be used in navigation bar of Dashboard. @@ -29,33 +29,32 @@ class NavLink(VizroBaseModel): """ - pages: NavPagesType = [] - label: str = Field(..., description="Text description of the icon for use in tooltip.") - icon: str = Field("", description="Icon name from Google Material icons library.") + pages: Annotated[NavPagesType, AfterValidator(_validate_pages), Field(default=[])] + label: str = Field(description="Text description of the icon for use in tooltip.") + icon: Annotated[ + str, + AfterValidator(validate_icon), + Field(default="", description="Icon name from Google Material icons library."), + ] _nav_selector: Accordion = PrivateAttr() - # Re-used validators - _validate_pages = validator("pages", allow_reuse=True)(_validate_pages) - - @validator("icon") - def validate_icon(cls, icon) -> str: - return icon.strip().lower().replace(" ", "_") - @_log_call def pre_build(self): from vizro.models._navigation.accordion import Accordion - self._nav_selector = Accordion(pages=self.pages) + self._nav_selector = Accordion(pages=self.pages) # type: ignore[arg-type] @_log_call def build(self, *, active_page_id=None): # _nav_selector is an Accordion, so _nav_selector._pages is guaranteed to be dict[str, list[str]]. # `active_page_id` is still required here for the automatic opening of the Accordion when navigating # from homepage to a page within the Accordion and there are several Accordions within the page. + from vizro.models import Page + all_page_ids = list(itertools.chain(*self._nav_selector.pages.values())) first_page_id = all_page_ids[0] item_active = active_page_id in all_page_ids - first_page = model_manager[ModelID(str(first_page_id))] + first_page = cast(Page, model_manager[ModelID(str(first_page_id))]) nav_link = dbc.NavLink( [ diff --git a/vizro-core/src/vizro/models/_navigation/navigation.py b/vizro-core/src/vizro/models/_navigation/navigation.py index 2bfd2b549..e2266e3e8 100644 --- a/vizro-core/src/vizro/models/_navigation/navigation.py +++ b/vizro-core/src/vizro/models/_navigation/navigation.py @@ -1,13 +1,10 @@ from __future__ import annotations -from dash import html - -try: - from pydantic.v1 import validator -except ImportError: # pragma: no cov - from pydantic import validator +from typing import Annotated, Optional, cast import dash_bootstrap_components as dbc +from dash import html +from pydantic import AfterValidator, Field from vizro.models import VizroBaseModel from vizro.models._models_utils import _log_call @@ -21,16 +18,13 @@ class Navigation(VizroBaseModel): Args: pages (NavPagesType): See [`NavPagesType`][vizro.models.types.NavPagesType]. Defaults to `[]`. - nav_selector (NavSelectorType): See [`NavSelectorType`][vizro.models.types.NavSelectorType]. + nav_selector (Optional[NavSelectorType]): See [`NavSelectorType`][vizro.models.types.NavSelectorType]. Defaults to `None`. """ - pages: NavPagesType = [] - nav_selector: NavSelectorType = None - - # validators - _validate_pages = validator("pages", allow_reuse=True)(_validate_pages) + pages: Annotated[NavPagesType, AfterValidator(_validate_pages), Field(default=[])] + nav_selector: Optional[NavSelectorType] = None @_log_call def pre_build(self): @@ -43,7 +37,7 @@ def pre_build(self): @_log_call def build(self, *, active_page_id=None) -> _NavBuildType: - nav_selector = self.nav_selector.build(active_page_id=active_page_id) + nav_selector = cast(NavSelectorType, self.nav_selector).build(active_page_id=active_page_id) if "nav-bar" not in nav_selector: # e.g. nav_selector is Accordion and nav_selector.build returns single html.Div with id="nav-panel". diff --git a/vizro-core/src/vizro/models/_page.py b/vizro-core/src/vizro/models/_page.py index e137987bb..aea39246d 100644 --- a/vizro-core/src/vizro/models/_page.py +++ b/vizro-core/src/vizro/models/_page.py @@ -1,14 +1,20 @@ from __future__ import annotations -from collections.abc import Iterable, Mapping -from typing import Any, Optional, TypedDict, Union, cast +from collections.abc import Iterable +from typing import Annotated, Optional, TypedDict, cast from dash import dcc, html - -try: - from pydantic.v1 import Field, root_validator, validator -except ImportError: # pragma: no cov - from pydantic import Field, root_validator, validator +from pydantic import ( + AfterValidator, + BeforeValidator, + Field, + FieldSerializationInfo, + SerializerFunctionWrapHandler, + ValidationInfo, + conlist, + model_serializer, + model_validator, +) from vizro._constants import ON_PAGE_LOAD_ACTION_PREFIX from vizro.actions import _on_page_load @@ -17,7 +23,7 @@ from vizro.models import Action, Filter, Layout, VizroBaseModel from vizro.models._action._actions_chain import ActionsChain, Trigger from vizro.models._layout import set_layout -from vizro.models._models_utils import _log_call, check_captured_callable, validate_min_length +from vizro.models._models_utils import _log_call, check_captured_callable_model from .types import ComponentType, ControlType @@ -28,6 +34,20 @@ _PageBuildType = TypedDict("_PageBuildType", {"control-panel": html.Div, "page-components": html.Div}) +def set_path(path: str, info: ValidationInfo) -> str: + # Based on how Github generates anchor links - see: + # https://stackoverflow.com/questions/72536973/how-are-github-markdown-anchor-links-constructed. + def clean_path(path: str, allowed_characters: str) -> str: + path = path.strip().lower().replace(" ", "-") + path = "".join(character for character in path if character.isalnum() or character in allowed_characters) + return path if path.startswith("/") else "/" + path + + # Allow "/" in path if provided by user, otherwise turn page id into suitable URL path (not allowing "/") + if path: + return clean_path(path, "-_/") + return clean_path(info.data["id"], "-_") + + class Page(VizroBaseModel): """A page in [`Dashboard`][vizro.models.Dashboard] with its own URL path and place in the `Navigation`. @@ -36,30 +56,27 @@ class Page(VizroBaseModel): has to be provided. title (str): Title to be displayed. description (str): Description for meta tags. - layout (Layout): Layout to place components in. Defaults to `None`. + layout (Optional[Layout]): Layout to place components in. Defaults to `None`. controls (list[ControlType]): See [ControlType][vizro.models.types.ControlType]. Defaults to `[]`. path (str): Path to navigate to page. Defaults to `""`. """ - components: list[ComponentType] - title: str = Field(..., description="Title to be displayed.") - description: str = Field("", description="Description for meta tags.") - layout: Layout = None # type: ignore[assignment] + # TODO[mypy], see: https://github.com/pydantic/pydantic/issues/156 for components field + components: conlist(Annotated[ComponentType, BeforeValidator(check_captured_callable_model)], min_length=1) # type: ignore[valid-type] + title: str = Field(description="Title to be displayed.") + description: str = Field(default="", description="Description for meta tags.") + layout: Annotated[Optional[Layout], AfterValidator(set_layout), Field(default=None, validate_default=True)] controls: list[ControlType] = [] - path: str = Field("", description="Path to navigate to page.") + path: Annotated[ + str, AfterValidator(set_path), Field(default="", description="Path to navigate to page.", validate_default=True) + ] # TODO: Remove default on page load action if possible actions: list[ActionsChain] = [] - # Re-used validators - _check_captured_callable = validator("components", allow_reuse=True, each_item=True, pre=True)( - check_captured_callable - ) - _validate_min_length = validator("components", allow_reuse=True, always=True)(validate_min_length) - _validate_layout = validator("layout", allow_reuse=True, always=True)(set_layout) - - @root_validator(pre=True) + @model_validator(mode="before") + @classmethod def set_id(cls, values): if "title" not in values: return values @@ -67,20 +84,6 @@ def set_id(cls, values): values.setdefault("id", values["title"]) return values - @validator("path", always=True) - def set_path(cls, path, values) -> str: - # Based on how Github generates anchor links - see: - # https://stackoverflow.com/questions/72536973/how-are-github-markdown-anchor-links-constructed. - def clean_path(path: str, allowed_characters: str) -> str: - path = path.strip().lower().replace(" ", "-") - path = "".join(character for character in path if character.isalnum() or character in allowed_characters) - return path if path.startswith("/") else "/" + path - - # Allow "/" in path if provided by user, otherwise turn page id into suitable URL path (not allowing "/") - if path: - return clean_path(path, "-_/") - return clean_path(values["id"], "-_") - def __init__(self, **data): """Adds the model instance to the model manager.""" try: @@ -91,8 +94,17 @@ def __init__(self, **data): f"as the page title. If you have multiple pages with the same title then you must assign a unique id." ) from exc - def __vizro_exclude_fields__(self) -> Optional[Union[set[str], Mapping[str, Any]]]: - return {"id"} if self.id == self.title else None + # This is a modification of the original `model_serializer` decorator that allows for the `context` to be passed + # It allows skipping the `id` serialization if it is the same as the `title` + @model_serializer(mode="wrap") # type: ignore[type-var] + def _serialize_id(self, handler: SerializerFunctionWrapHandler, info: FieldSerializationInfo): + result = handler(self) + if info.context is not None and info.context.get("add_name", False): + result["__vizro_model__"] = self.__class__.__name__ + if self.title == self.id: + result.pop("id", None) + return result + return result @_log_call def pre_build(self): @@ -126,6 +138,10 @@ def build(self) -> _PageBuildType: controls_content = [control.build() for control in self.controls] control_panel = html.Div(id="control-panel", children=controls_content, hidden=not controls_content) + self.layout = cast( + Layout, + self.layout, # cannot actually be None if you check components and layout field together + ) components_container = self.layout.build() for component_idx, component in enumerate(self.components): components_container[f"{self.layout.id}_{component_idx}"].children = component.build() diff --git a/vizro-core/src/vizro/models/types.py b/vizro-core/src/vizro/models/types.py index 4f28bc92a..1c8d1f409 100644 --- a/vizro-core/src/vizro/models/types.py +++ b/vizro-core/src/vizro/models/types.py @@ -8,22 +8,11 @@ import inspect from contextlib import contextmanager from datetime import date -from typing import Any, Literal, Protocol, Union, runtime_checkable +from typing import Annotated, Any, Literal, Protocol, Union, runtime_checkable import plotly.io as pio - -try: - from pydantic.v1 import Field, StrictBool - from pydantic.v1.fields import ModelField - from pydantic.v1.schema import SkipField -except ImportError: # pragma: no cov - from pydantic import Field, StrictBool - from pydantic.fields import ModelField - from pydantic.schema import SkipField - - -from typing import Annotated - +import pydantic_core as cs +from pydantic import Field, StrictBool, ValidationInfo from typing_extensions import TypedDict from vizro.charts._charts_utils import _DashboardReadyFigure @@ -45,6 +34,23 @@ class _SupportsCapturedCallable(Protocol): _captured_callable: CapturedCallable +class JsonSchemaExtraType(TypedDict): + """Type that specifies the extra information needed to parse a CapturedCallable from JSON/YAML.""" + + import_path: str + mode: str + + +def validate_captured_callable(cls, value, info: ValidationInfo): + """Reusable validator for the `figure` argument of Figure like models.""" + # TODO[MS]: We may want to double check on the mechanism of how field info is brought to. This seems + # to get deprecated in V3 + json_schema_extra: JsonSchemaExtraType = cls.model_fields[info.field_name].json_schema_extra + return CapturedCallable._validate_captured_callable( + captured_callable_config=value, json_schema_extra=json_schema_extra + ) + + class CapturedCallable: """Stores a captured function call to use in a dashboard. @@ -172,29 +178,39 @@ def _function(self): return self.__function @classmethod - def __modify_schema__(cls, field_schema: dict[str, Any], field: ModelField): - """Generates schema for field of this type.""" - raise SkipField(f"{cls.__name__} {field.name} is excluded from the schema.") - + def _validate_captured_callable( + cls, + captured_callable_config: Union[dict[str, Any], _SupportsCapturedCallable, CapturedCallable], + json_schema_extra: JsonSchemaExtraType, + ): + value = cls._parse_json(captured_callable_config=captured_callable_config, json_schema_extra=json_schema_extra) + value = cls._extract_from_attribute(value) + value = cls._check_type(captured_callable=value, json_schema_extra=json_schema_extra) + return value + + # TODO: The below could be transferred to a custom type similar to this example: + # https://docs.pydantic.dev/2.9/concepts/types/#handling-third-party-types + # TODO: Ultimately we are calling this, but it is always true, as the before validator catches things anyway + # In future: we should really get rid of this and make a custom type annotation that does the job of validation + # and schema generation. + # Once we have a custom schema for captured callables, we can bypass the core schema and return a custom schema. @classmethod - def __get_validators__(cls): - """Makes type compatible with pydantic model without needing `arbitrary_types_allowed`.""" - # Each validator receives as an input the value returned from the previous validator. - # captured_callable could be _SupportsCapturedCallable, CapturedCallable, dictionary from JSON/YAML or - # invalid type at this point. Begin by parsing it from JSON/YAML if applicable: - yield cls._parse_json - # At this point captured_callable is _SupportsCapturedCallable, CapturedCallable or invalid type. Next extract - # it from _SupportsCapturedCallable if applicable: - yield cls._extract_from_attribute - # At this point captured_callable is CapturedCallable or invalid type. Check it is in fact CapturedCallable - # and do final checks: - yield cls._check_type + def __get_pydantic_core_schema__(cls, source: Any, handler: Any) -> cs.core_schema.CoreSchema: + """Core validation, which boils down to checking if it is a custom type.""" + return cs.core_schema.no_info_plain_validator_function(cls.core_validation) + + @staticmethod + def core_validation(value: Any): + """Core validation logic.""" + if not isinstance(value, CapturedCallable): + raise ValueError(f"Expected CapturedCallable, got {type(value)}") + return value @classmethod def _parse_json( cls, captured_callable_config: Union[_SupportsCapturedCallable, CapturedCallable, dict[str, Any]], - field: ModelField, + json_schema_extra: JsonSchemaExtraType, ) -> Union[CapturedCallable, _SupportsCapturedCallable]: """Parses captured_callable_config specification from JSON/YAML. @@ -217,7 +233,7 @@ def _parse_json( "CapturedCallable object must contain the key '_target_' that gives the target function." ) from exc - import_path = field.field_info.extra["import_path"] + import_path = json_schema_extra["import_path"] try: function = getattr(importlib.import_module(import_path), function_name) except (AttributeError, ModuleNotFoundError) as exc: @@ -243,10 +259,12 @@ def _extract_from_attribute( return captured_callable._captured_callable @classmethod - def _check_type(cls, captured_callable: CapturedCallable, field: ModelField) -> CapturedCallable: + def _check_type( + cls, captured_callable: CapturedCallable, json_schema_extra: JsonSchemaExtraType + ) -> CapturedCallable: """Checks captured_callable is right type and mode.""" - expected_mode = field.field_info.extra["mode"] - import_path = field.field_info.extra["import_path"] + expected_mode = json_schema_extra["mode"] + import_path = json_schema_extra["import_path"] if not isinstance(captured_callable, CapturedCallable): raise ValueError( @@ -508,3 +526,8 @@ class OptionsDictType(TypedDict): ] """Discriminated union. Type of component for rendering navigation: [`Accordion`][vizro.models.Accordion] or [`NavBar`][vizro.models.NavBar].""" + + +# Extra type groups used for mypy casting +FigureWithFilterInteractionType = Union["Graph", "Table", "AgGrid"] +FigureType = Union["Graph", "Table", "AgGrid", "Figure"] 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 48ed759d8..c3807728c 100644 --- a/vizro-core/tests/unit/vizro/models/_action/test_action.py +++ b/vizro-core/tests/unit/vizro/models/_action/test_action.py @@ -4,14 +4,9 @@ import pandas as pd import pytest -from dash import Output, State, html - -try: - from pydantic.v1 import ValidationError -except ImportError: # pragma: no cov - from pydantic import ValidationError - from asserts import assert_component_equal +from dash import Output, State, html +from pydantic import ValidationError import vizro.models as vm import vizro.plotly.express as px @@ -105,7 +100,7 @@ def test_inputs_outputs_valid(self, inputs, outputs, identity_action_function): ], ) def test_inputs_invalid(self, inputs, identity_action_function): - with pytest.raises(ValidationError, match="string does not match regex"): + with pytest.raises(ValidationError, match="String should match pattern"): Action(function=identity_action_function(), inputs=inputs, outputs=[]) @pytest.mark.parametrize( @@ -118,7 +113,7 @@ def test_inputs_invalid(self, inputs, identity_action_function): ], ) def test_outputs_invalid(self, outputs, identity_action_function): - with pytest.raises(ValidationError, match="string does not match regex"): + with pytest.raises(ValidationError, match="String should match pattern"): Action(function=identity_action_function(), inputs=[], outputs=outputs) @pytest.mark.parametrize("file_format", [None, "csv", "xlsx"]) @@ -130,7 +125,7 @@ def test_export_data_file_format_valid(self, file_format): def test_export_data_file_format_invalid(self): with pytest.raises( - ValueError, match='Unknown "file_format": invalid_file_format.' ' Known file formats: "csv", "xlsx".' + ValueError, match='Unknown "file_format": invalid_file_format. Known file formats: "csv", "xlsx".' ): Action(function=export_data(file_format="invalid_file_format")) diff --git a/vizro-core/tests/unit/vizro/models/_action/test_actions_chain.py b/vizro-core/tests/unit/vizro/models/_action/test_actions_chain.py index a29d5672a..a96215281 100644 --- a/vizro-core/tests/unit/vizro/models/_action/test_actions_chain.py +++ b/vizro-core/tests/unit/vizro/models/_action/test_actions_chain.py @@ -1,5 +1,7 @@ """Unit tests for vizro.models.ActionChain.""" +from dataclasses import dataclass + import pytest from vizro.models._action._action import Action @@ -17,6 +19,16 @@ def test_action(identity_action_function): return Action(function=identity_action_function()) +@pytest.fixture +def validation_info(): + @dataclass + class MockValidationInfo: + data: dict + + validation_info = MockValidationInfo(data={"id": "component_id"}) + return validation_info + + class TestActionsChainInstantiation: """Tests model instantiation.""" @@ -36,8 +48,8 @@ def test_create_action_chains_mandatory_and_optional(self, test_trigger, test_ac assert actions_chain.actions[0] == test_action -def test_set_actions(test_action): - result = _set_actions(actions=[test_action], values={"id": "component_id"}, trigger_property="value") +def test_set_actions(test_action, validation_info): + result = _set_actions(value=[test_action], info=validation_info, trigger_property="value") actions_chain = result[0] action = actions_chain.actions[0] diff --git a/vizro-core/tests/unit/vizro/models/_components/form/test_checklist.py b/vizro-core/tests/unit/vizro/models/_components/form/test_checklist.py index a78c8b7b8..6c1b78a7b 100755 --- a/vizro-core/tests/unit/vizro/models/_components/form/test_checklist.py +++ b/vizro-core/tests/unit/vizro/models/_components/form/test_checklist.py @@ -4,11 +4,7 @@ import pytest from asserts import assert_component_equal from dash import html - -try: - from pydantic.v1 import ValidationError -except ImportError: # pragma: no cov - from pydantic import ValidationError +from pydantic import ValidationError from vizro.models._action._action import Action from vizro.models._components.form import Checklist @@ -56,7 +52,6 @@ def test_create_checklist_mandatory_and_optional(self): [{"label": "True", "value": True}, {"label": "False", "value": False}], [{"label": "True", "value": True}, {"label": "False", "value": False}], ), - ([True, 2.0, 1.0, "A", "B"], ["True", "2.0", "1.0", "A", "B"]), ], ) def test_create_checklist_valid_options(self, test_options, expected): @@ -69,9 +64,9 @@ def test_create_checklist_valid_options(self, test_options, expected): assert checklist.title == "" assert checklist.actions == [] - @pytest.mark.parametrize("test_options", [1, "A", True, 1.0]) + @pytest.mark.parametrize("test_options", [1, "A", True, 1.0, [True, 2.0, 1.0, "A", "B"]]) def test_create_checklist_invalid_options_type(self, test_options): - with pytest.raises(ValidationError, match="value is not a valid list"): + with pytest.raises(ValidationError, match="Input should be a valid"): Checklist(options=test_options) def test_create_checklist_invalid_options_dict(self): @@ -88,7 +83,6 @@ def test_create_checklist_invalid_options_dict(self): ([1.0, 2.0], [1.0, 2.0, 3.0]), ([False, True], [True, False]), (["A", "B"], [{"label": "A", "value": "A"}, {"label": "B", "value": "B"}]), - (["True", "A"], [True, 2.0, 1.0, "A", "B"]), ], ) def test_create_checklist_valid_value(self, test_value, options): @@ -116,7 +110,7 @@ def test_create_checklist_invalid_value_non_existing(self, test_value, options): Checklist(value=test_value, options=options) def test_create_checklist_invalid_value_format(self): - with pytest.raises(ValidationError, match="value is not a valid list"): + with pytest.raises(ValidationError, match="Input should be a valid list"): Checklist(value="A", options=["A", "B", "C"]) def test_set_action_via_validator(self, identity_action_function): diff --git a/vizro-core/tests/unit/vizro/models/_components/form/test_date_picker.py b/vizro-core/tests/unit/vizro/models/_components/form/test_date_picker.py index 6fa0872b4..da55d0008 100644 --- a/vizro-core/tests/unit/vizro/models/_components/form/test_date_picker.py +++ b/vizro-core/tests/unit/vizro/models/_components/form/test_date_picker.py @@ -7,11 +7,7 @@ import pytest from asserts import assert_component_equal from dash import html - -try: - from pydantic.v1 import ValidationError -except ImportError: # pragma: no cov - from pydantic import ValidationError +from pydantic import ValidationError import vizro.models as vm @@ -45,7 +41,7 @@ def test_create_datepicker_mandatory_and_optional(self): assert date_picker.actions == [] assert date_picker.range is True - @pytest.mark.parametrize("title", ["test", 1, 1.0, """## Test header""", ""]) + @pytest.mark.parametrize("title", ["test", """## Test header""", ""]) def test_valid_title(self, title): date_picker = vm.DatePicker(title=title) @@ -71,7 +67,7 @@ def test_validate_max_invalid_min_greater_than_max(self): vm.DatePicker(min="2024-02-01", max="2024-01-01") def test_validate_max_invalid_date_format(self): - with pytest.raises(ValidationError, match="invalid date format"): + with pytest.raises(ValidationError, match="Input should be a valid date or datetime"): vm.DatePicker(min="50-50-50", max="50-50-50") def test_validate_range_true_datepicker_value_valid(self): diff --git a/vizro-core/tests/unit/vizro/models/_components/form/test_dropdown.py b/vizro-core/tests/unit/vizro/models/_components/form/test_dropdown.py index 49f73c55e..f600b6389 100755 --- a/vizro-core/tests/unit/vizro/models/_components/form/test_dropdown.py +++ b/vizro-core/tests/unit/vizro/models/_components/form/test_dropdown.py @@ -4,11 +4,7 @@ import pytest from asserts import assert_component_equal from dash import dcc, html - -try: - from pydantic.v1 import ValidationError -except ImportError: # pragma: no cov - from pydantic import ValidationError +from pydantic import ValidationError from vizro.models._action._action import Action from vizro.models._components.form import Dropdown @@ -58,7 +54,6 @@ def test_create_dropdown_mandatory_and_optional(self): [{"label": "True", "value": True}, {"label": "False", "value": False}], [{"label": "True", "value": True}, {"label": "False", "value": False}], ), - ([True, 2.0, 1.0, "A", "B"], ["True", "2.0", "1.0", "A", "B"]), ], ) def test_create_dropdown_valid_options(self, test_options, expected): @@ -72,9 +67,9 @@ def test_create_dropdown_valid_options(self, test_options, expected): assert dropdown.title == "" assert dropdown.actions == [] - @pytest.mark.parametrize("test_options", [1, "A", True, 1.0]) + @pytest.mark.parametrize("test_options", [1, "A", True, 1.0, [True, 2.0, 1.0, "A", "B"]]) def test_create_dropdown_invalid_options_type(self, test_options): - with pytest.raises(ValidationError, match="value is not a valid list"): + with pytest.raises(ValidationError, match="Input should be a valid"): Dropdown(options=test_options) def test_create_dropdown_invalid_options_dict(self): @@ -92,21 +87,18 @@ def test_create_dropdown_invalid_options_dict(self): (1.0, [1.0, 2.0, 3.0], False), (False, [True, False], False), ("A", [{"label": "A", "value": "A"}, {"label": "B", "value": "B"}], False), - ("True", [True, 2.0, 1.0, "A", "B"], False), # Single default value with multi=True ("A", ["A", "B", "C"], True), (1, [1, 2, 3], True), (1.0, [1.0, 2.0, 3.0], True), (False, [True, False], True), ("A", [{"label": "A", "value": "A"}, {"label": "B", "value": "B"}], True), - ("True", [True, 2.0, 1.0, "A", "B"], True), # List of default values with multi=True (["A", "B"], ["A", "B", "C"], True), ([1, 2], [1, 2, 3], True), ([1.0, 2.0], [1.0, 2.0, 3.0], True), ([False, True], [True, False], True), (["A", "B"], [{"label": "A", "value": "A"}, {"label": "B", "value": "B"}], True), - (["True", "A"], [True, 2.0, 1.0, "A", "B"], True), ], ) def test_create_dropdown_valid_value(self, test_value, options, multi): diff --git a/vizro-core/tests/unit/vizro/models/_components/form/test_radio_items.py b/vizro-core/tests/unit/vizro/models/_components/form/test_radio_items.py index c48d7c7a9..5d6b0e755 100755 --- a/vizro-core/tests/unit/vizro/models/_components/form/test_radio_items.py +++ b/vizro-core/tests/unit/vizro/models/_components/form/test_radio_items.py @@ -4,11 +4,7 @@ import pytest from asserts import assert_component_equal from dash import html - -try: - from pydantic.v1 import ValidationError -except ImportError: # pragma: no cov - from pydantic import ValidationError +from pydantic import ValidationError from vizro.models._action._action import Action from vizro.models._components.form import RadioItems @@ -56,7 +52,6 @@ def test_create_radio_items_mandatory_and_optional(self): [{"label": "True", "value": True}, {"label": "False", "value": False}], [{"label": "True", "value": True}, {"label": "False", "value": False}], ), - ([True, 2.0, 1.0, "A", "B"], ["True", "2.0", "1.0", "A", "B"]), ], ) def test_create_radio_items_valid_options(self, test_options, expected): @@ -69,9 +64,9 @@ def test_create_radio_items_valid_options(self, test_options, expected): assert radio_items.title == "" assert radio_items.actions == [] - @pytest.mark.parametrize("test_options", [1, "A", True, 1.0]) + @pytest.mark.parametrize("test_options", [1, "A", True, 1.0, [True, 2.0, 1.0, "A", "B"]]) def test_create_radio_items_invalid_options_type(self, test_options): - with pytest.raises(ValidationError, match="value is not a valid list"): + with pytest.raises(ValidationError, match="Input should be a valid"): RadioItems(options=test_options) def test_create_radio_items_invalid_options_dict(self): @@ -88,7 +83,6 @@ def test_create_radio_items_invalid_options_dict(self): (2.0, [1.0, 2.0, 3.0]), (True, [True, False]), ("B", [{"label": "A", "value": "A"}, {"label": "B", "value": "B"}]), - ("True", [True, 2.0, 1.0, "A", "B"]), ], ) def test_create_radio_items_valid_value(self, test_value, options): diff --git a/vizro-core/tests/unit/vizro/models/_components/form/test_range_slider.py b/vizro-core/tests/unit/vizro/models/_components/form/test_range_slider.py index 4879aafc3..99e28fe30 100644 --- a/vizro-core/tests/unit/vizro/models/_components/form/test_range_slider.py +++ b/vizro-core/tests/unit/vizro/models/_components/form/test_range_slider.py @@ -4,11 +4,7 @@ import pytest from asserts import assert_component_equal from dash import dcc, html - -try: - from pydantic.v1 import ValidationError -except ImportError: # pragma: no cov - from pydantic import ValidationError +from pydantic import ValidationError import vizro.models as vm @@ -199,13 +195,13 @@ def test_validate_slider_value_valid(self, value, expected): @pytest.mark.parametrize( "value, match", [ - ([0], "ensure this value has at least 2 items"), - ([], "ensure this value has at least 2 items"), - (2, "value is not a valid list"), - ([0, None], "1 validation error for RangeSlider"), + ([0], "List should have at least 2 items after validation"), + ([], "List should have at least 2 items after validation"), + (2, "Input should be a valid list"), + ([0, None], "Input should be a valid number"), ([None, None], "2 validation errors for RangeSlider"), ([-1, 11], "Please provide a valid value between the min and max value."), - ([1, 2, 3], "ensure this value has at most 2 items"), + ([1, 2, 3], "List should have at most 2 items after validation, not 3"), ], ) def test_validate_slider_value_invalid(self, value, match): @@ -228,11 +224,9 @@ def test_validate_step_invalid(self): @pytest.mark.parametrize( "marks, expected", [ - ({i: str(i) for i in range(0, 10, 5)}, {i: str(i) for i in range(0, 10, 5)}), - ({15: 15, 25: 25}, {15: "15", 25: "25"}), # all int - ({15.5: 15.5, 25.5: 25.5}, {15.5: "15.5", 25.5: "25.5"}), # all floats - ({15.0: 15, 25.5: 25.5}, {15: "15", 25.5: "25.5"}), # mixed floats - ({"15": 15, "25": 25}, {15: "15", 25: "25"}), # all string + # TODO[MS]: why is this not failing, should it not be converted to float? + ({i: str(i) for i in range(0, 10, 5)}, {i: str(i) for i in range(0, 10, 5)}), # int - str + ({1.0: "1", 1.5: "1.5"}, {1: "1", 1.5: "1.5"}), # float - str (but see validator) (None, None), ], ) @@ -246,7 +240,7 @@ def test_valid_marks(self, marks, expected): ] def test_invalid_marks(self): - with pytest.raises(ValidationError, match="2 validation errors for RangeSlider"): + with pytest.raises(ValidationError, match="4 validation errors for RangeSlider"): vm.RangeSlider(min=1, max=10, marks={"start": 0, "end": 10}) @pytest.mark.parametrize("step, expected", [(1, {}), (None, None)]) @@ -272,7 +266,7 @@ def test_set_step_and_marks(self, step, marks, expected_marks, expected_class): assert slider["slider-id"].marks == expected_marks assert slider["slider-id"].className == expected_class - @pytest.mark.parametrize("title", ["test", 1, 1.0, """## Test header""", ""]) + @pytest.mark.parametrize("title", ["test", """## Test header""", ""]) def test_valid_title(self, title): slider = vm.RangeSlider(title=title) diff --git a/vizro-core/tests/unit/vizro/models/_components/form/test_slider.py b/vizro-core/tests/unit/vizro/models/_components/form/test_slider.py index 92b34429e..ebb7a7681 100755 --- a/vizro-core/tests/unit/vizro/models/_components/form/test_slider.py +++ b/vizro-core/tests/unit/vizro/models/_components/form/test_slider.py @@ -4,11 +4,7 @@ import pytest from asserts import assert_component_equal from dash import dcc, html - -try: - from pydantic.v1 import ValidationError -except ImportError: # pragma: no cov - from pydantic import ValidationError +from pydantic import ValidationError import vizro.models as vm @@ -119,11 +115,9 @@ def test_valid_marks_with_step(self): @pytest.mark.parametrize( "marks, expected", [ - ({i: str(i) for i in range(0, 10, 5)}, {i: str(i) for i in range(0, 10, 5)}), - ({15: 15, 25: 25}, {15: "15", 25: "25"}), # all int - ({15.5: 15.5, 25.5: 25.5}, {15.5: "15.5", 25.5: "25.5"}), # all floats - ({15.0: 15, 25.5: 25.5}, {15: "15", 25.5: "25.5"}), # mixed floats - ({"15": 15, "25": 25}, {15: "15", 25: "25"}), # all string + # TODO[MS]: why is this not failing, should it not be converted to float? + ({i: str(i) for i in range(0, 10, 5)}, {i: str(i) for i in range(0, 10, 5)}), # int - str + ({1.0: "1", 1.5: "1.5"}, {1: "1", 1.5: "1.5"}), # float - str (but see validator) (None, None), ], ) @@ -137,7 +131,7 @@ def test_valid_marks(self, marks, expected): ] def test_invalid_marks(self): - with pytest.raises(ValidationError, match="2 validation errors for Slider"): + with pytest.raises(ValidationError, match="4 validation errors for Slider"): vm.Slider(min=1, max=10, marks={"start": 0, "end": 10}) @pytest.mark.parametrize("step, expected", [(1, {}), (None, None)]) @@ -163,7 +157,7 @@ def test_set_step_and_marks(self, step, marks, expected_marks, expected_class): assert slider["slider-id"].marks == expected_marks assert slider["slider-id"].className == expected_class - @pytest.mark.parametrize("title", ["test", 1, 1.0, """## Test header""", ""]) + @pytest.mark.parametrize("title", ["test", """## Test header""", ""]) def test_valid_title(self, title): slider = vm.Slider(title=title) diff --git a/vizro-core/tests/unit/vizro/models/_components/test_ag_grid.py b/vizro-core/tests/unit/vizro/models/_components/test_ag_grid.py index 213b76006..715632568 100644 --- a/vizro-core/tests/unit/vizro/models/_components/test_ag_grid.py +++ b/vizro-core/tests/unit/vizro/models/_components/test_ag_grid.py @@ -5,11 +5,7 @@ import pytest from asserts import assert_component_equal from dash import dcc, html - -try: - from pydantic.v1 import ValidationError -except ImportError: # pragma: no cov - from pydantic import ValidationError +from pydantic import ValidationError import vizro.models as vm import vizro.plotly.express as px @@ -51,7 +47,7 @@ def test_create_ag_grid_mandatory_and_optional(self, standard_ag_grid, id): assert ag_grid.figure == standard_ag_grid def test_mandatory_figure_missing(self): - with pytest.raises(ValidationError, match="field required"): + with pytest.raises(ValidationError, match="Field required"): vm.AgGrid() def test_captured_callable_invalid(self, standard_go_chart): diff --git a/vizro-core/tests/unit/vizro/models/_components/test_button.py b/vizro-core/tests/unit/vizro/models/_components/test_button.py index 1118c8de6..5b87589e4 100644 --- a/vizro-core/tests/unit/vizro/models/_components/test_button.py +++ b/vizro-core/tests/unit/vizro/models/_components/test_button.py @@ -24,11 +24,8 @@ def test_create_default_button(self): [ ("Test", "/page_1_reference"), ("Test", "https://www.google.de/"), - (123, "/"), ("""# Header""", "/"), - (1.23, "/"), ("""

Hello

""", "/"), - (True, "/"), ], ) def test_create_button_with_optional(self, text, href): diff --git a/vizro-core/tests/unit/vizro/models/_components/test_card.py b/vizro-core/tests/unit/vizro/models/_components/test_card.py index 9b4274653..3fa125117 100755 --- a/vizro-core/tests/unit/vizro/models/_components/test_card.py +++ b/vizro-core/tests/unit/vizro/models/_components/test_card.py @@ -4,11 +4,7 @@ import pytest from asserts import assert_component_equal from dash import dcc - -try: - from pydantic.v1 import ValidationError -except ImportError: # pragma: no cov - from pydantic import ValidationError +from pydantic import ValidationError import vizro.models as vm @@ -34,11 +30,11 @@ def test_create_card_mandatory_and_optional(self, id, href): assert card.href == href def test_mandatory_text_missing(self): - with pytest.raises(ValidationError, match="field required"): + with pytest.raises(ValidationError, match="Field required"): vm.Card() def test_none_as_text(self): - with pytest.raises(ValidationError, match="none is not an allowed value"): + with pytest.raises(ValidationError, match="Input should be a valid string"): vm.Card(text=None) @@ -97,7 +93,7 @@ def test_markdown_setting(self, test_text, expected): "test_text, expected", [ ("""

Hello

""", "

Hello

"), # html will not be evaluated but converted to string - (12345, "12345"), + ("12345", "12345"), ("""$$ \\frac{1}{(\\sqrt{\\phi \\sqrt{5}}-\\phi)}$$""", "$$ \\frac{1}{(\\sqrt{\\phi \\sqrt{5}}-\\phi)}$$"), ], ) diff --git a/vizro-core/tests/unit/vizro/models/_components/test_container.py b/vizro-core/tests/unit/vizro/models/_components/test_container.py index 3b80accb4..8c8a6d185 100644 --- a/vizro-core/tests/unit/vizro/models/_components/test_container.py +++ b/vizro-core/tests/unit/vizro/models/_components/test_container.py @@ -4,11 +4,7 @@ import pytest from asserts import STRIP_ALL, assert_component_equal from dash import html - -try: - from pydantic.v1 import ValidationError -except ImportError: # pragma: no cov - from pydantic import ValidationError +from pydantic import ValidationError import vizro.models as vm @@ -32,11 +28,11 @@ def test_create_container_mandatory_and_optional(self): assert container.title == "Title" def test_mandatory_title_missing(self): - with pytest.raises(ValidationError, match="field required"): + with pytest.raises(ValidationError, match="Field required"): vm.Container(components=[vm.Button()]) def test_mandatory_components_missing(self): - with pytest.raises(ValidationError, match="field required"): + with pytest.raises(ValidationError, match="Field required"): vm.Container(title="Title") diff --git a/vizro-core/tests/unit/vizro/models/_components/test_figure.py b/vizro-core/tests/unit/vizro/models/_components/test_figure.py index 08adc456e..7f35c5a8a 100644 --- a/vizro-core/tests/unit/vizro/models/_components/test_figure.py +++ b/vizro-core/tests/unit/vizro/models/_components/test_figure.py @@ -5,11 +5,7 @@ import pytest from asserts import assert_component_equal from dash import dcc, html - -try: - from pydantic.v1 import ValidationError -except ImportError: # pragma: no cov - from pydantic import ValidationError +from pydantic import ValidationError import vizro.models as vm from vizro.figures import kpi_card diff --git a/vizro-core/tests/unit/vizro/models/_components/test_graph.py b/vizro-core/tests/unit/vizro/models/_components/test_graph.py index 0712941f2..054f12379 100644 --- a/vizro-core/tests/unit/vizro/models/_components/test_graph.py +++ b/vizro-core/tests/unit/vizro/models/_components/test_graph.py @@ -7,11 +7,7 @@ from asserts import assert_component_equal from dash import dcc, html from dash.exceptions import MissingCallbackContextException - -try: - from pydantic.v1 import ValidationError -except ImportError: # pragma: no cov - from pydantic import ValidationError +from pydantic import ValidationError import vizro.models as vm import vizro.plotly.express as px @@ -55,7 +51,7 @@ def test_create_graph_mandatory_and_optional(self, standard_px_chart, id): assert graph.figure == standard_px_chart._captured_callable def test_mandatory_figure_missing(self): - with pytest.raises(ValidationError, match="field required"): + with pytest.raises(ValidationError, match="Field required"): vm.Graph() def test_captured_callable_invalid(self, standard_go_chart): diff --git a/vizro-core/tests/unit/vizro/models/_components/test_table.py b/vizro-core/tests/unit/vizro/models/_components/test_table.py index 46f7c8590..69d002d4c 100644 --- a/vizro-core/tests/unit/vizro/models/_components/test_table.py +++ b/vizro-core/tests/unit/vizro/models/_components/test_table.py @@ -5,11 +5,7 @@ import pytest from asserts import assert_component_equal from dash import dcc, html - -try: - from pydantic.v1 import ValidationError -except ImportError: # pragma: no cov - from pydantic import ValidationError +from pydantic import ValidationError import vizro.models as vm import vizro.plotly.express as px @@ -46,7 +42,7 @@ def test_create_table_mandatory_and_optional(self, standard_dash_table, id): assert table.figure == standard_dash_table def test_mandatory_figure_missing(self): - with pytest.raises(ValidationError, match="field required"): + with pytest.raises(ValidationError, match="Field required"): vm.Table() def test_captured_callable_invalid(self, standard_go_chart): diff --git a/vizro-core/tests/unit/vizro/models/_components/test_tabs.py b/vizro-core/tests/unit/vizro/models/_components/test_tabs.py index 7a83dcfc0..a64a1b994 100644 --- a/vizro-core/tests/unit/vizro/models/_components/test_tabs.py +++ b/vizro-core/tests/unit/vizro/models/_components/test_tabs.py @@ -4,11 +4,7 @@ import pytest from asserts import assert_component_equal from dash import html - -try: - from pydantic.v1 import ValidationError -except ImportError: # pragma: no cov - from pydantic import ValidationError +from pydantic import ValidationError import vizro.models as vm @@ -31,7 +27,7 @@ def test_create_tabs_mandatory_only(self, containers): assert tabs.type == "tabs" def test_mandatory_tabs_missing(self): - with pytest.raises(ValidationError, match="field required"): + with pytest.raises(ValidationError, match="Field required"): vm.Tabs(id="tabs-id") diff --git a/vizro-core/tests/unit/vizro/models/_navigation/test_accordion.py b/vizro-core/tests/unit/vizro/models/_navigation/test_accordion.py index f1ae89000..c6f4f982d 100644 --- a/vizro-core/tests/unit/vizro/models/_navigation/test_accordion.py +++ b/vizro-core/tests/unit/vizro/models/_navigation/test_accordion.py @@ -5,11 +5,7 @@ import dash_bootstrap_components as dbc import pytest from asserts import assert_component_equal - -try: - from pydantic.v1 import ValidationError -except ImportError: # pragma: no cov - from pydantic import ValidationError +from pydantic import ValidationError import vizro.models as vm from vizro._constants import ACCORDION_DEFAULT_TITLE @@ -41,7 +37,7 @@ def test_invalid_field_pages_no_ids_provided(self, pages): vm.Accordion(pages=pages) def test_invalid_field_pages_wrong_input_type(self): - with pytest.raises(ValidationError, match="str type expected"): + with pytest.raises(ValidationError, match="Input should be a valid string"): vm.Accordion(pages=[vm.Page(title="Page 3", components=[vm.Button()])]) @pytest.mark.parametrize("pages", [["non existent page"], {"Group": ["non existent page"]}]) diff --git a/vizro-core/tests/unit/vizro/models/_navigation/test_nav_bar.py b/vizro-core/tests/unit/vizro/models/_navigation/test_nav_bar.py index 601e15e39..380b72b6d 100644 --- a/vizro-core/tests/unit/vizro/models/_navigation/test_nav_bar.py +++ b/vizro-core/tests/unit/vizro/models/_navigation/test_nav_bar.py @@ -6,11 +6,7 @@ import pytest from asserts import STRIP_ALL, assert_component_equal from dash import html - -try: - from pydantic.v1 import ValidationError -except ImportError: # pragma: no cov - from pydantic import ValidationError +from pydantic import ValidationError import vizro.models as vm @@ -45,7 +41,7 @@ def test_invalid_field_pages_no_ids_provided(self, pages): vm.NavBar(pages=pages) def test_invalid_field_pages_wrong_input_type(self): - with pytest.raises(ValidationError, match="unhashable type: 'Page'"): + with pytest.raises(TypeError, match="unhashable type: 'Page'"): vm.NavBar(pages=[vm.Page(title="Page 3", components=[vm.Button()])]) @pytest.mark.parametrize("pages", [["non existent page"], {"Group": ["non existent page"]}]) diff --git a/vizro-core/tests/unit/vizro/models/_navigation/test_nav_link.py b/vizro-core/tests/unit/vizro/models/_navigation/test_nav_link.py index 262c102ee..4f2ee8cc7 100644 --- a/vizro-core/tests/unit/vizro/models/_navigation/test_nav_link.py +++ b/vizro-core/tests/unit/vizro/models/_navigation/test_nav_link.py @@ -6,11 +6,7 @@ import pytest from asserts import STRIP_ALL, assert_component_equal from dash import html - -try: - from pydantic.v1 import ValidationError -except ImportError: # pragma: no cov - from pydantic import ValidationError +from pydantic import ValidationError import vizro.models as vm @@ -46,7 +42,7 @@ def test_nav_link_valid_pages_as_dict(self, pages_as_dict): assert nav_link.pages == pages_as_dict def test_mandatory_label_missing(self): - with pytest.raises(ValidationError, match="field required"): + with pytest.raises(ValidationError, match="Field required"): vm.NavLink() @pytest.mark.parametrize("pages", [{"Group": []}, []]) @@ -55,8 +51,8 @@ def test_invalid_field_pages_no_ids_provided(self, pages): vm.NavLink(pages=pages) def test_invalid_field_pages_wrong_input_type(self): - with pytest.raises(ValidationError, match="str type expected"): - vm.NavLink(pages=[vm.Page(title="Page 3", components=[vm.Button()])]) + with pytest.raises(ValidationError, match="Input should be a valid"): + vm.NavLink(pages=[vm.Page(title="Page 3", components=[vm.Button()])], label="Foo") @pytest.mark.parametrize("pages", [["non existent page"], {"Group": ["non existent page"]}]) def test_invalid_page(self, pages): diff --git a/vizro-core/tests/unit/vizro/models/_navigation/test_navigation.py b/vizro-core/tests/unit/vizro/models/_navigation/test_navigation.py index a774bbe5c..7dd6eb9fb 100644 --- a/vizro-core/tests/unit/vizro/models/_navigation/test_navigation.py +++ b/vizro-core/tests/unit/vizro/models/_navigation/test_navigation.py @@ -5,11 +5,7 @@ import dash_bootstrap_components as dbc import pytest from asserts import STRIP_ALL, assert_component_equal - -try: - from pydantic.v1 import ValidationError -except ImportError: # pragma: no cov - from pydantic import ValidationError +from pydantic import ValidationError import vizro.models as vm @@ -38,7 +34,7 @@ def test_invalid_field_pages_no_ids_provided(self, pages): vm.Navigation(pages=pages) def test_invalid_field_pages_wrong_input_type(self): - with pytest.raises(ValidationError, match="str type expected"): + with pytest.raises(ValidationError, match="Input should be a valid"): vm.Navigation(pages=[vm.Page(title="Page 3", components=[vm.Button()])]) @pytest.mark.parametrize("pages", [["non existent page"], {"Group": ["non existent page"]}]) diff --git a/vizro-core/tests/unit/vizro/models/test_base.py b/vizro-core/tests/unit/vizro/models/test_base.py index 763bfe5e6..45bc60a56 100644 --- a/vizro-core/tests/unit/vizro/models/test_base.py +++ b/vizro-core/tests/unit/vizro/models/test_base.py @@ -1,19 +1,21 @@ -from typing import Literal, Optional, Union - -import pytest - -try: - from pydantic.v1 import Field, ValidationError, root_validator, validator -except ImportError: # pragma: no cov - from pydantic import Field, ValidationError, root_validator, validator import logging import textwrap -from typing import Annotated +from typing import Annotated, Literal, Optional, Union + +import pytest +from pydantic import ( + Field, + FieldSerializationInfo, + SerializerFunctionWrapHandler, + ValidationError, + field_validator, + model_serializer, + model_validator, +) import vizro.models as vm import vizro.plotly.express as px from vizro.actions import export_data -from vizro.models._base import _patch_vizro_base_model_dict from vizro.models.types import capture from vizro.tables import dash_ag_grid @@ -70,9 +72,12 @@ class _ParentWithList(vm.VizroBaseModel): @pytest.fixture() def ParentWithForwardRef(): class _ParentWithForwardRef(vm.VizroBaseModel): - child: Annotated[Union["ChildXForwardRef", "ChildYForwardRef"], Field(discriminator="type")] # noqa: F821 + child: Annotated[Union["ChildXForwardRef", "ChildYForwardRef"], Field(discriminator="type")] - _ParentWithForwardRef.update_forward_refs(ChildXForwardRef=ChildX, ChildYForwardRef=ChildY) + # TODO: [MS] This is how I would update the forward refs, but we should double check + ChildXForwardRef = ChildX + ChildYForwardRef = ChildY + _ParentWithForwardRef.model_rebuild() return _ParentWithForwardRef @@ -87,7 +92,9 @@ class _ParentWithNonDiscriminatedUnion(vm.VizroBaseModel): class TestDiscriminatedUnion: def test_no_type_match(self, Parent): child = ChildZ() - with pytest.raises(ValidationError, match="No match for discriminator 'type' and value 'child_Z'"): + with pytest.raises( + ValidationError, match="'type' does not match any of the expected tags: 'child_x', 'child_y'" + ): Parent(child=child) def test_add_type_model_instantiation(self, Parent): @@ -115,7 +122,9 @@ def test_no_type_match(self, ParentWithOptional): # The current error message is the non-discriminated union one. def test_no_type_match_current_behaviour(self, ParentWithOptional): child = ChildZ() - with pytest.raises(ValidationError, match="unexpected value; permitted: 'child_x'"): + with pytest.raises( + ValidationError, match="'type' does not match any of the expected tags: 'child_x', 'child_y'" + ): ParentWithOptional(child=child) def test_add_type_model_instantiation(self, ParentWithOptional): @@ -132,7 +141,9 @@ def test_add_type_dict_instantiation(self, ParentWithOptional): class TestListDiscriminatedUnion: def test_no_type_match(self, ParentWithList): child = ChildZ() - with pytest.raises(ValidationError, match="No match for discriminator 'type' and value 'child_Z'"): + with pytest.raises( + ValidationError, match="'type' does not match any of the expected tags: 'child_x', 'child_y'" + ): ParentWithList(child=[child]) def test_add_type_model_instantiation(self, ParentWithList): @@ -149,7 +160,9 @@ def test_add_type_dict_instantiation(self, ParentWithList): class TestParentForwardRefDiscriminatedUnion: def test_no_type_match(self, ParentWithForwardRef): child = ChildZ() - with pytest.raises(ValidationError, match="No match for discriminator 'type' and value 'child_Z'"): + with pytest.raises( + ValidationError, match="'type' does not match any of the expected tags: 'child_x', 'child_y'" + ): ParentWithForwardRef(child=child) def test_add_type_model_instantiation(self, ParentWithForwardRef, mocker): @@ -169,9 +182,13 @@ def test_add_type_dict_instantiation(self, ParentWithForwardRef, mocker): class TestChildWithForwardRef: def test_no_type_match(self, Parent): + # TODO: [MS] I am not sure why this worked before, but in my understanding, + # we need to define the forward ref before rebuilding the model that contains it. + ChildXForwardRef = ChildX # noqa: F841 + ChildWithForwardRef.model_rebuild() child = ChildWithForwardRef() with pytest.raises( - ValidationError, match="No match for discriminator 'type' and value 'child_with_forward_ref'" + ValidationError, match="'type' does not match any of the expected tags: 'child_x', 'child_y'" ): Parent(child=child) @@ -201,17 +218,19 @@ class Model(vm.VizroBaseModel): class ModelWithFieldSetting(vm.VizroBaseModel): type: Literal["exclude_model"] = "exclude_model" - title: str = Field(..., description="Title to be displayed.") - foo: str = "" + title: str = Field(description="Title to be displayed.") + foo: Optional[str] = Field(default=None, description="Foo field.", validate_default=True) # Set a field with regular validator - @validator("foo", always=True) - def set_foo(cls, foo) -> str: + @field_validator("foo") + @classmethod + def set_foo(cls, foo: Optional[str]) -> str: return foo or "long-random-thing" # Set a field with a pre=True root-validator --> # # this will not be caught by exclude_unset=True - @root_validator(pre=True) + @model_validator(mode="before") + @classmethod def set_id(cls, values): if "title" not in values: return values @@ -219,46 +238,50 @@ def set_id(cls, values): values.setdefault("id", values["title"]) return values - # Exclude field even if missed by exclude_unset=True - def __vizro_exclude_fields__(self): - """Exclude id field if it is the same as the title.""" - return {"id"} + # Exclude field when id is the same as title + @model_serializer(mode="wrap") + def _serialize_id(self, nxt: SerializerFunctionWrapHandler, info: FieldSerializationInfo): + result = nxt(self) + if info.context is not None and info.context.get("add_name", False): + result["__vizro_model__"] = self.__class__.__name__ + if self.title == self.id: + result.pop("id", None) + return result + return result class TestDict: def test_dict_no_args(self): model = Model(id="model_id") - assert model.dict() == {"id": "model_id", "type": "model"} + assert model.model_dump() == {"id": "model_id", "type": "model"} def test_dict_exclude_unset(self): model = Model(id="model_id") - assert model.dict(exclude_unset=True) == {"id": "model_id"} + assert model.model_dump(exclude_unset=True) == {"id": "model_id"} def test_dict_exclude_id(self): model = Model() - assert model.dict(exclude={"id"}) == {"type": "model"} + assert model.model_dump(exclude={"id"}) == {"type": "model"} def test_dict_exclude_type(self): - # __vizro_exclude_fields__ should have no effect here. model = Model(id="model_id") - assert model.dict(exclude={"type"}) == {"id": "model_id"} + assert model.model_dump(exclude={"type"}) == {"id": "model_id"} def test_dict_exclude_in_model_unset_with_and_without_context(self): model = ModelWithFieldSetting(title="foo") - with _patch_vizro_base_model_dict(): - assert model.dict(exclude_unset=True) == {"title": "foo", "__vizro_model__": "ModelWithFieldSetting"} - assert model.dict(exclude_unset=True) == {"id": "foo", "title": "foo"} + assert model.model_dump(context={"add_name": True}, exclude_unset=True) == { + "title": "foo", + "__vizro_model__": "ModelWithFieldSetting", + } def test_dict_exclude_in_model_no_args_with_and_without_context(self): model = ModelWithFieldSetting(title="foo") - with _patch_vizro_base_model_dict(): - assert model.dict() == { - "title": "foo", - "type": "exclude_model", - "__vizro_model__": "ModelWithFieldSetting", - "foo": "long-random-thing", - } - assert model.dict() == {"id": "foo", "type": "exclude_model", "title": "foo", "foo": "long-random-thing"} + assert model.model_dump(context={"add_name": True}) == { + "title": "foo", + "type": "exclude_model", + "__vizro_model__": "ModelWithFieldSetting", + "foo": "long-random-thing", + } @pytest.fixture diff --git a/vizro-core/tests/unit/vizro/models/test_dashboard.py b/vizro-core/tests/unit/vizro/models/test_dashboard.py index c14a09307..582b6e4fd 100644 --- a/vizro-core/tests/unit/vizro/models/test_dashboard.py +++ b/vizro-core/tests/unit/vizro/models/test_dashboard.py @@ -7,11 +7,7 @@ import pytest from asserts import assert_component_equal from dash import dcc, html - -try: - from pydantic.v1 import ValidationError -except ImportError: # pragma: no cov - from pydantic import ValidationError +from pydantic import ValidationError import vizro import vizro.models as vm @@ -50,7 +46,7 @@ def test_navigation_with_pages(self, page_1, page_2): assert dashboard.navigation.pages == ["Page 1"] def test_mandatory_pages_missing(self): - with pytest.raises(ValidationError, match="field required"): + with pytest.raises(ValidationError, match="Field required"): vm.Dashboard() def test_field_invalid_pages_empty_list(self): @@ -58,11 +54,11 @@ def test_field_invalid_pages_empty_list(self): vm.Dashboard(pages=[]) def test_field_invalid_pages_input_type(self): - with pytest.raises(ValidationError, match="5 validation errors for Dashboard"): + with pytest.raises(ValidationError, match="Input should be a valid dictionary or instance of Page"): vm.Dashboard(pages=[vm.Button()]) def test_field_invalid_theme_input_type(self, page_1): - with pytest.raises(ValidationError, match="unexpected value; permitted: 'vizro_dark', 'vizro_light'"): + with pytest.raises(ValidationError, match="Input should be 'vizro_dark' or 'vizro_light'"): vm.Dashboard(pages=[page_1], theme="not_existing") diff --git a/vizro-core/tests/unit/vizro/models/test_layout.py b/vizro-core/tests/unit/vizro/models/test_layout.py index f45373228..36623ea3b 100755 --- a/vizro-core/tests/unit/vizro/models/test_layout.py +++ b/vizro-core/tests/unit/vizro/models/test_layout.py @@ -1,13 +1,8 @@ -import pytest - -try: - from pydantic.v1 import ValidationError -except ImportError: # pragma: no cov - from pydantic import ValidationError - import numpy as np +import pytest from asserts import assert_component_equal from dash import html +from pydantic import ValidationError import vizro.models as vm from vizro.models._layout import GAP_DEFAULT, MIN_DEFAULT, ColRowGridLines, _get_unique_grid_component_ids @@ -49,7 +44,7 @@ def test_create_layout_mandatory_and_optional(self, test_gap): ] def test_mandatory_grid_missing(self): - with pytest.raises(ValidationError, match="field required"): + with pytest.raises(ValidationError, match="Field required"): vm.Layout() @@ -68,7 +63,7 @@ class TestMalformedGrid: ], ) def test_invalid_input_type(self, grid): - with pytest.raises(ValidationError, match="value is not a valid list"): + with pytest.raises(ValidationError, match="Input should be a valid list"): vm.Layout(grid=grid) @pytest.mark.parametrize( @@ -79,7 +74,7 @@ def test_invalid_input_type(self, grid): ], ) def test_invalid_input_value(self, grid): - with pytest.raises(ValidationError, match="value is not a valid integer"): + with pytest.raises(ValidationError, match="Input should be a valid integer"): vm.Layout(grid=grid) @pytest.mark.parametrize( @@ -192,8 +187,8 @@ def test_layout_build(self): style={ "gridRowGap": "24px", "gridColumnGap": "24px", - "gridTemplateColumns": f"repeat(2," f"minmax({'0px'}, 1fr))", - "gridTemplateRows": f"repeat(2," f"minmax({'0px'}, 1fr))", + "gridTemplateColumns": f"repeat(2,minmax({'0px'}, 1fr))", + "gridTemplateRows": f"repeat(2,minmax({'0px'}, 1fr))", }, className="grid-layout", ) diff --git a/vizro-core/tests/unit/vizro/models/test_models_utils.py b/vizro-core/tests/unit/vizro/models/test_models_utils.py index 24e6808be..27d956eb1 100644 --- a/vizro-core/tests/unit/vizro/models/test_models_utils.py +++ b/vizro-core/tests/unit/vizro/models/test_models_utils.py @@ -1,20 +1,12 @@ import re import pytest - -try: - from pydantic.v1 import ValidationError -except ImportError: # pragma: no cov - from pydantic import ValidationError +from pydantic import ValidationError import vizro.models as vm class TestSharedValidators: - def test_validate_min_length(self, model_with_layout): - with pytest.raises(ValidationError, match="Ensure this value has at least 1 item."): - model_with_layout(title="Title", components=[]) - @pytest.mark.parametrize( "captured_callable, error_message", [ @@ -44,7 +36,8 @@ def test_check_for_valid_component_types(self, model_with_layout): with pytest.raises( ValidationError, match=re.escape( - "(allowed values: 'ag_grid', 'button', 'card', 'container', 'figure', 'graph', 'table', 'tabs')" + "'type' does not match any of the expected tags: 'ag_grid', 'button', 'card', 'container', 'figure', " + "'graph', 'table', 'tabs'" ), ): model_with_layout(title="Page Title", components=[vm.Checklist()]) diff --git a/vizro-core/tests/unit/vizro/models/test_page.py b/vizro-core/tests/unit/vizro/models/test_page.py index 62bce4966..f73c74a4c 100644 --- a/vizro-core/tests/unit/vizro/models/test_page.py +++ b/vizro-core/tests/unit/vizro/models/test_page.py @@ -1,11 +1,7 @@ import re import pytest - -try: - from pydantic.v1 import ValidationError -except ImportError: # pragma: no cov - from pydantic import ValidationError +from pydantic import ValidationError import vizro.models as vm from vizro._constants import ON_PAGE_LOAD_ACTION_PREFIX @@ -42,11 +38,11 @@ def test_create_page_mandatory_and_optional(self): assert page.actions == [] def test_mandatory_title_missing(self): - with pytest.raises(ValidationError, match="field required"): + with pytest.raises(ValidationError, match="Field required"): vm.Page(id="my-id", components=[vm.Button()]) def test_mandatory_components_missing(self): - with pytest.raises(ValidationError, match="field required"): + with pytest.raises(ValidationError, match="Field required"): vm.Page(title="Page 1") def test_set_id_duplicate_title_valid(self): @@ -85,7 +81,9 @@ def test_set_path_invalid(self, test_path): assert page.path == "/this-needs-fixing" def test_check_for_valid_control_types(self): - with pytest.raises(ValidationError, match=re.escape("(allowed values: 'filter', 'parameter')")): + with pytest.raises( + ValidationError, match=re.escape("'type' does not match any of the expected tags: 'filter', 'parameter'") + ): vm.Page(title="Page Title", components=[vm.Button()], controls=[vm.Button()]) diff --git a/vizro-core/tests/unit/vizro/models/test_types.py b/vizro-core/tests/unit/vizro/models/test_types.py index 8fcdcf1b9..805b300a5 100644 --- a/vizro-core/tests/unit/vizro/models/test_types.py +++ b/vizro-core/tests/unit/vizro/models/test_types.py @@ -4,14 +4,11 @@ import plotly.graph_objects as go import plotly.io as pio import pytest - -try: - from pydantic.v1 import Field, ValidationError -except ImportError: # pragma: no cov - from pydantic import Field, ValidationError +from pydantic import Field, ValidationError, field_validator +from pydantic.json_schema import SkipJsonSchema from vizro.models import VizroBaseModel -from vizro.models.types import CapturedCallable, capture +from vizro.models.types import CapturedCallable, capture, validate_captured_callable def positional_only_function(a, /): @@ -163,12 +160,14 @@ def invalid_decorated_graph_function(): class ModelWithAction(VizroBaseModel): # The import_path here makes it possible to import the above function using getattr(import_path, _target_). - function: CapturedCallable = Field(..., import_path=__name__, mode="action") + function: SkipJsonSchema[CapturedCallable] = Field(json_schema_extra={"mode": "action", "import_path": __name__}) + _validate_figure = field_validator("function", mode="before")(validate_captured_callable) class ModelWithGraph(VizroBaseModel): # The import_path here makes it possible to import the above function using getattr(import_path, _target_). - function: CapturedCallable = Field(..., import_path=__name__, mode="graph") + function: SkipJsonSchema[CapturedCallable] = Field(json_schema_extra={"mode": "graph", "import_path": __name__}) + _validate_figure = field_validator("function", mode="before")(validate_captured_callable) class TestModelFieldPython: @@ -247,7 +246,7 @@ def test_invalid_import(self): def test_invalid_arguments(self): config = {"_target_": "decorated_action_function", "e": 5} - with pytest.raises(ValidationError, match="got an unexpected keyword argument"): + with pytest.raises(TypeError, match="got an unexpected keyword argument"): ModelWithGraph(function=config) def test_undecorated_function(self): @@ -275,7 +274,9 @@ def test_wrong_mode(self): def test_invalid_import_path(self): class ModelWithInvalidModule(VizroBaseModel): # The import_path doesn't exist. - function: CapturedCallable = Field(..., import_path="invalid.module", mode="graph") + function: CapturedCallable = Field(json_schema_extra={"mode": "graph", "import_path": "invalid.module"}) + + _validate_figure = field_validator("function", mode="before")(validate_captured_callable) config = {"_target_": "decorated_graph_function", "data_frame": "data_source_name"}