diff --git a/.github/workflows/checks-workflows.yml b/.github/workflows/checks-workflows.yml new file mode 100644 index 000000000..9b034dba4 --- /dev/null +++ b/.github/workflows/checks-workflows.yml @@ -0,0 +1,29 @@ +name: Checks for GitHub workflows + +on: + push: + branches: [main] + pull_request: + branches: + - main + +env: + PYTHONUNBUFFERED: 1 + FORCE_COLOR: 1 + PYTHON_VERSION: "3.11" + +jobs: + checks-workflows: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ env.PYTHON_VERSION }} + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Checks for GitHub workflows + run: | + python tools/scan_yaml_for_risky_text.py .github/workflows diff --git a/.github/workflows/circleci-trigger.yml b/.github/workflows/circleci-trigger.yml deleted file mode 100644 index 9b8713c48..000000000 --- a/.github/workflows/circleci-trigger.yml +++ /dev/null @@ -1,74 +0,0 @@ -name: CircleCI tests trigger - -on: - push: - branches: [main] - pull_request: - branches: - - main - -env: - PYTHONUNBUFFERED: 1 - FORCE_COLOR: 1 - -jobs: - circleci-trigger-fork: - if: ${{ github.event.pull_request.head.repo.fork }} - name: CircleCI tests trigger - runs-on: ubuntu-latest - steps: - - name: Passed fork step - run: echo "Success!" - - circleci-trigger: - if: ${{ ! github.event.pull_request.head.repo.fork }} - name: CircleCI tests trigger - runs-on: ubuntu-latest - steps: - - name: Start CircleCI pipeline - run: | - create_circleci_pipeline() { - local branch=$1 - - local json_data=$(jq -n --arg branch "$branch" --arg vizro_branch "${{ github.head_ref }}" '{branch: $branch, parameters: {branch: $branch, vizro_branch: $vizro_branch}}') - - curl --silent --request POST \ - --url "${{ secrets.QA_PIPELINE_URL }}" \ - --header "Circle-Token: ${{ secrets.CIRCLECI_API_KEY }}" \ - --header "content-type: application/json" \ - --data "$json_data" \ - | jq -r '.id' - } - - PIPELINE=$(create_circleci_pipeline "${{ github.head_ref }}") - - # If the above returns null then the QA repo doesn't contain current dev branch, so we use main branch. - if [[ "$PIPELINE" == "null" ]]; then - PIPELINE=$(create_circleci_pipeline "main") - fi - echo "Started pipeline with id $PIPELINE" - - echo "PIPELINE=$PIPELINE" >> $GITHUB_ENV - - - name: Wait for pipeline to run - run: sleep 60 - - - name: Check pipeline status - run: | - get_pipeline_status() { - curl --silent --request GET \ - --url "https://circleci.com/api/v2/pipeline/$PIPELINE/workflow" \ - --header "Circle-Token: ${{ secrets.CIRCLECI_API_KEY }}" \ - --header "content-type: application/json" \ - | jq -r '.items[0].status' - } - - while pipeline_status=$(get_pipeline_status); [[ "$pipeline_status" == "running" ]]; do - echo $pipeline_status - sleep 15 - done - - if [[ "$pipeline_status" != "success" ]]; then - echo "Pipeline not completed successfully - status was ${pipeline_status}" - exit 1 - fi diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index dc101a2d0..ecaeb9036 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -21,6 +21,6 @@ jobs: pull-requests: write steps: - - uses: actions/labeler@v4 + - uses: actions/labeler@v5 with: repo-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/test-integration-vizro-ai.yml b/.github/workflows/test-integration-vizro-ai.yml index 2dbaed8b3..039bd1268 100644 --- a/.github/workflows/test-integration-vizro-ai.yml +++ b/.github/workflows/test-integration-vizro-ai.yml @@ -93,3 +93,16 @@ jobs: cd ../vizro-ai hatch run ${{ matrix.hatch-env }}:pip install ../vizro-core/dist/vizro*.tar.gz hatch run ${{ matrix.hatch-env }}:test-integration + + - name: Send custom JSON data to Slack + id: slack + uses: slackapi/slack-github-action@v1.26.0 + if: failure() + with: + payload: | + { + "text": "Vizro-ai ${{ matrix.hatch-env }} integration tests build result: ${{ job.status }}\nBranch: ${{ github.head_ref }}\n${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK diff --git a/.github/workflows/vizro-qa-tests-trigger.yml b/.github/workflows/vizro-qa-tests-trigger.yml new file mode 100644 index 000000000..1d223a6de --- /dev/null +++ b/.github/workflows/vizro-qa-tests-trigger.yml @@ -0,0 +1,50 @@ +name: Vizro QA tests trigger + +on: + push: + branches: [main] + pull_request: + branches: + - main + +env: + PYTHONUNBUFFERED: 1 + FORCE_COLOR: 1 + +jobs: + vizro-qa-test-trigger-fork: + if: ${{ github.event.pull_request.head.repo.fork }} + name: Vizro QA ${{ matrix.label }} trigger + runs-on: ubuntu-latest + strategy: + matrix: + include: + - label: integration tests + - label: notebooks tests + steps: + - name: Passed fork step + run: echo "Success!" + + vizro-qa-tests-trigger: + if: ${{ ! github.event.pull_request.head.repo.fork }} + name: Vizro QA ${{ matrix.label }} trigger + runs-on: ubuntu-latest + strategy: + matrix: + include: + - label: integration tests + - label: notebooks test + steps: + - uses: actions/checkout@v4 + - name: Tests trigger + run: | + export INPUT_OWNER=${{ secrets.VIZRO_QA_ORG }} + export INPUT_REPO=${{ secrets.VIZRO_QA_REPO }} + if [ "${{ matrix.label }}" == "integration tests" ]; then + export INPUT_WORKFLOW_FILE_NAME=${{ secrets.VIZRO_QA_INTEGRATION_TESTS_WORKFLOW }} + elif [ "${{ matrix.label }}" == "notebooks test" ]; then + export INPUT_WORKFLOW_FILE_NAME=${{ secrets.VIZRO_QA_NOTEBOOKS_TESTS_WORKFLOW }} + fi + export INPUT_GITHUB_TOKEN=${{ secrets.VIZRO_SVC_PAT }} + export INPUT_REF=${{ github.head_ref }} + tools/trigger-workflow-and-wait.sh diff --git a/tools/scan_yaml_for_risky_text.py b/tools/scan_yaml_for_risky_text.py new file mode 100644 index 000000000..70240f872 --- /dev/null +++ b/tools/scan_yaml_for_risky_text.py @@ -0,0 +1,19 @@ +"""Check for security issues in workflows files.""" + +import sys +from pathlib import Path + +# according to this article: https://nathandavison.com/blog/github-actions-and-the-threat-of-malicious-pull-requests +# we should avoid using `pull_request_target` for security reasons +risky_text = "pull_request_target" + + +def find_risky_files(path: str): + """Searching for risky text in yml files for given path.""" + return {file for file in Path(path).rglob("*.yml") if risky_text in file.read_text()} + + +if __name__ == "__main__": + risky_files = find_risky_files(sys.argv[1]) + if risky_files: + sys.exit(f"{risky_text} found in files {risky_files}.") diff --git a/tools/trigger-workflow-and-wait.sh b/tools/trigger-workflow-and-wait.sh new file mode 100755 index 000000000..4447efb65 --- /dev/null +++ b/tools/trigger-workflow-and-wait.sh @@ -0,0 +1,244 @@ +#!/usr/bin/env bash + +#MIT License +# +#Copyright (c) 2020 Convictional, Inc. +# +#Permission is hereby granted, free of charge, to any person obtaining a copy +#of this software and associated documentation files (the "Software"), to deal +#in the Software without restriction, including without limitation the rights +#to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +#copies of the Software, and to permit persons to whom the Software is +#furnished to do so, subject to the following conditions: +# +#The above copyright notice and this permission notice shall be included in all +#copies or substantial portions of the Software. +# +#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +#IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +#FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +#AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +#LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +#OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +#SOFTWARE. + +set -e + +GITHUB_API_URL="${API_URL:-https://api.github.com}" +GITHUB_SERVER_URL="${SERVER_URL:-https://github.com}" + +validate_args() { + wait_interval=10 # Waits for 10 seconds + if [ "${INPUT_WAIT_INTERVAL}" ] + then + wait_interval=${INPUT_WAIT_INTERVAL} + fi + + propagate_failure=true + if [ -n "${INPUT_PROPAGATE_FAILURE}" ] + then + propagate_failure=${INPUT_PROPAGATE_FAILURE} + fi + + trigger_workflow=true + if [ -n "${INPUT_TRIGGER_WORKFLOW}" ] + then + trigger_workflow=${INPUT_TRIGGER_WORKFLOW} + fi + + wait_workflow=true + if [ -n "${INPUT_WAIT_WORKFLOW}" ] + then + wait_workflow=${INPUT_WAIT_WORKFLOW} + fi + + if [ -z "${INPUT_OWNER}" ] + then + echo "Error: Owner is a required argument." + exit 1 + fi + + if [ -z "${INPUT_REPO}" ] + then + echo "Error: Repo is a required argument." + exit 1 + fi + + if [ -z "${INPUT_GITHUB_TOKEN}" ] + then + echo "Error: Github token is required. You can head over settings and" + echo "under developer, you can create a personal access tokens. The" + echo "token requires repo access." + exit 1 + fi + + if [ -z "${INPUT_WORKFLOW_FILE_NAME}" ] + then + echo "Error: Workflow File Name is required" + exit 1 + fi + + client_payload=$(echo '{}' | jq -c) + if [ "${INPUT_CLIENT_PAYLOAD}" ] + then + client_payload=$(echo "${INPUT_CLIENT_PAYLOAD}" | jq -c) + fi + + ref="main" + if [ "$INPUT_REF" ] + then + ref="${INPUT_REF}" + fi +} + +lets_wait() { + echo "Sleeping for ${wait_interval} seconds" + sleep "$wait_interval" +} + +api() { + path=$1; shift + if response=$(curl --fail-with-body -sSL \ + "${GITHUB_API_URL}/repos/${INPUT_OWNER}/${INPUT_REPO}/actions/$path" \ + -H "Authorization: Bearer ${INPUT_GITHUB_TOKEN}" \ + -H 'Accept: application/vnd.github.v3+json' \ + -H 'Content-Type: application/json' \ + "$@") + then + echo "$response" + else + echo >&2 "api failed:" + echo >&2 "path: $path" + echo >&2 "response: $response" + if [[ "$response" == *'"Server Error"'* ]]; then + echo "Server error - trying again" + else + exit 1 + fi + fi +} + +lets_wait() { + local interval=${1:-$wait_interval} + echo >&2 "Sleeping for $interval seconds" + sleep "$interval" +} + +# Return the ids of the most recent workflow runs, optionally filtered by user +get_workflow_runs() { + since=${1:?} + + query="event=workflow_dispatch&created=>=$since${INPUT_GITHUB_USER+&actor=}${INPUT_GITHUB_USER}&per_page=100" + + echo "Getting workflow runs using query: ${query}" >&2 + + api "workflows/${INPUT_WORKFLOW_FILE_NAME}/runs?${query}" | + jq -r '.workflow_runs[].id' | + sort # Sort to ensure repeatable order, and lexicographically for compatibility with join +} + +trigger_workflow() { + START_TIME=$(date +%s) + SINCE=$(date -u -Iseconds -d "@$((START_TIME - 120))") # Two minutes ago, to overcome clock skew + + OLD_RUNS=$(get_workflow_runs "$SINCE") + + echo >&2 "Triggering workflow:" + echo >&2 " workflows/${INPUT_WORKFLOW_FILE_NAME}/dispatches" + echo >&2 " {\"ref\":\"${ref}\",\"inputs\":${client_payload}}" + + api "workflows/${INPUT_WORKFLOW_FILE_NAME}/dispatches" \ + --data "{\"ref\":\"${ref}\",\"inputs\":${client_payload}}" + + NEW_RUNS=$OLD_RUNS + while [ "$NEW_RUNS" = "$OLD_RUNS" ] + do + lets_wait + NEW_RUNS=$(get_workflow_runs "$SINCE") + done + + # Return new run ids + join -v2 <(echo "$OLD_RUNS") <(echo "$NEW_RUNS") +} + +comment_downstream_link() { + if response=$(curl --fail-with-body -sSL -X POST \ + "${INPUT_COMMENT_DOWNSTREAM_URL}" \ + -H "Authorization: Bearer ${INPUT_COMMENT_GITHUB_TOKEN}" \ + -H 'Accept: application/vnd.github.v3+json' \ + -d "{\"body\": \"Running downstream job at $1\"}") + then + echo "$response" + else + echo >&2 "failed to comment to ${INPUT_COMMENT_DOWNSTREAM_URL}:" + fi +} + +wait_for_workflow_to_finish() { + last_workflow_id=${1:?} + last_workflow_url="${GITHUB_SERVER_URL}/${INPUT_OWNER}/${INPUT_REPO}/actions/runs/${last_workflow_id}" + + echo "Waiting for workflow to finish:" + echo "The workflow id is [${last_workflow_id}]." + echo "The workflow logs can be found at ${last_workflow_url}" + echo "workflow_id=${last_workflow_id}" >> $GITHUB_OUTPUT + echo "workflow_url=${last_workflow_url}" >> $GITHUB_OUTPUT + echo "" + + if [ -n "${INPUT_COMMENT_DOWNSTREAM_URL}" ]; then + comment_downstream_link ${last_workflow_url} + fi + + conclusion=null + status= + + while [[ "${conclusion}" == "null" && "${status}" != "completed" ]] + do + lets_wait + + workflow=$(api "runs/$last_workflow_id") + conclusion=$(echo "${workflow}" | jq -r '.conclusion') + status=$(echo "${workflow}" | jq -r '.status') + + echo "Checking conclusion [${conclusion}]" + echo "Checking status [${status}]" + echo "conclusion=${conclusion}" >> $GITHUB_OUTPUT + done + + if [[ "${conclusion}" == "success" && "${status}" == "completed" ]] + then + echo "Yes, success" + else + # Alternative "failure" + echo "Conclusion is not success, it's [${conclusion}]." + + if [ "${propagate_failure}" = true ] + then + echo "Propagating failure to upstream job" + exit 1 + fi + fi +} + +main() { + validate_args + + if [ "${trigger_workflow}" = true ] + then + run_ids=$(trigger_workflow) + else + echo "Skipping triggering the workflow." + fi + + if [ "${wait_workflow}" = true ] + then + for run_id in $run_ids + do + wait_for_workflow_to_finish "$run_id" + done + else + echo "Skipping waiting for workflow." + fi +} + +main diff --git a/vizro-ai/src/vizro_ai/_vizro_ai.py b/vizro-ai/src/vizro_ai/_vizro_ai.py index ec2ffc1e1..61e2dc48b 100644 --- a/vizro-ai/src/vizro_ai/_vizro_ai.py +++ b/vizro-ai/src/vizro_ai/_vizro_ai.py @@ -9,7 +9,7 @@ from vizro_ai._llm_models import _get_llm_model, _get_model_name from vizro_ai.dashboard._graph.dashboard_creation import _create_and_compile_graph -from vizro_ai.dashboard.utils import DashboardOutputs, _dashboard_code, _register_data +from vizro_ai.dashboard.utils import DashboardOutputs, _register_data from vizro_ai.plot.components import GetCodeExplanation, GetDebugger from vizro_ai.plot.task_pipeline._pipeline_manager import PipelineManager from vizro_ai.utils.helper import ( @@ -195,8 +195,8 @@ def dashboard( _register_data(all_df_metadata=message_res["all_df_metadata"]) if return_elements: - code = _dashboard_code(dashboard) # TODO: `_dashboard_code` to be implemented - dashboard_output = DashboardOutputs(dashboard=dashboard, code=code) + # code = _dashboard_code(dashboard) # TODO: `_dashboard_code` to be implemented + dashboard_output = DashboardOutputs(dashboard=dashboard) return dashboard_output else: return dashboard diff --git a/vizro-ai/src/vizro_ai/dashboard/_graph/dashboard_creation.py b/vizro-ai/src/vizro_ai/dashboard/_graph/dashboard_creation.py index 1dc6efea1..e11caf5da 100644 --- a/vizro-ai/src/vizro_ai/dashboard/_graph/dashboard_creation.py +++ b/vizro-ai/src/vizro_ai/dashboard/_graph/dashboard_creation.py @@ -11,10 +11,10 @@ from langgraph.constants import END, Send from langgraph.graph import StateGraph from tqdm.auto import tqdm -from vizro_ai.dashboard._pydantic_output import _get_pydantic_output -from vizro_ai.dashboard._response_models.dashboard import DashboardPlanner +from vizro_ai.dashboard._pydantic_output import _get_pydantic_model +from vizro_ai.dashboard._response_models.dashboard import DashboardPlan from vizro_ai.dashboard._response_models.df_info import DfInfo, _create_df_info_content, _get_df_info -from vizro_ai.dashboard._response_models.page import PagePlanner +from vizro_ai.dashboard._response_models.page import PagePlan from vizro_ai.dashboard.utils import AllDfMetadata, DfMetadata, _execute_step from vizro_ai.utils.helper import DebugFailure @@ -47,7 +47,7 @@ class GraphState(BaseModel): messages: List[BaseMessage] dfs: List[pd.DataFrame] all_df_metadata: AllDfMetadata - dashboard_plan: Optional[DashboardPlanner] = None + dashboard_plan: Optional[DashboardPlan] = None pages: Annotated[List, operator.add] dashboard: Optional[vm.Dashboard] = None @@ -72,7 +72,7 @@ def _store_df_info(state: GraphState, config: RunnableConfig) -> Dict[str, AllDf llm = config["configurable"].get("model", None) try: - df_name = _get_pydantic_output( + df_name = _get_pydantic_model( query=query, llm_model=llm, response_model=DfInfo, @@ -91,7 +91,7 @@ def _store_df_info(state: GraphState, config: RunnableConfig) -> Dict[str, AllDf return {"all_df_metadata": all_df_metadata} -def _dashboard_plan(state: GraphState, config: RunnableConfig) -> Dict[str, DashboardPlanner]: +def _dashboard_plan(state: GraphState, config: RunnableConfig) -> Dict[str, DashboardPlan]: """Generate a dashboard plan.""" node_desc = "Generate dashboard plan" pbar = tqdm(total=2, desc=node_desc) @@ -106,10 +106,10 @@ def _dashboard_plan(state: GraphState, config: RunnableConfig) -> Dict[str, Dash None, ) try: - dashboard_plan = _get_pydantic_output( + dashboard_plan = _get_pydantic_model( query=query, llm_model=llm, - response_model=DashboardPlanner, + response_model=DashboardPlan, df_info=all_df_metadata.get_schemas_and_samples(), ) except (DebugFailure, ValidationError) as e: @@ -137,7 +137,7 @@ class BuildPageState(BaseModel): """ all_df_metadata: AllDfMetadata - page_plan: Optional[PagePlanner] = None + page_plan: Optional[PagePlan] = None def _build_page(state: BuildPageState, config: RunnableConfig) -> Dict[str, List[vm.Page]]: diff --git a/vizro-ai/src/vizro_ai/dashboard/_pydantic_output.py b/vizro-ai/src/vizro_ai/dashboard/_pydantic_output.py index 0e2f1a3dd..c1511b8e3 100644 --- a/vizro-ai/src/vizro_ai/dashboard/_pydantic_output.py +++ b/vizro-ai/src/vizro_ai/dashboard/_pydantic_output.py @@ -1,4 +1,4 @@ -"""Contains the _get_pydantic_output for the Vizro AI dashboard.""" +"""Contains the _get_pydantic_model for the Vizro AI dashboard.""" # ruff: noqa: F821 @@ -63,7 +63,7 @@ def _create_message_content( return message_content -def _get_pydantic_output( +def _get_pydantic_model( query: str, llm_model: BaseChatModel, response_model: BaseModel, @@ -94,5 +94,5 @@ def _get_pydantic_output( model = _get_llm_model() component_description = "Create a card with the following content: 'Hello, world!'" - res = _get_pydantic_output(query=component_description, llm_model=model, response_model=vm.Card) + res = _get_pydantic_model(query=component_description, llm_model=model, response_model=vm.Card) print(res) # noqa: T201 diff --git a/vizro-ai/src/vizro_ai/dashboard/_response_models/components.py b/vizro-ai/src/vizro_ai/dashboard/_response_models/components.py index 9f1202476..feb0dfde8 100644 --- a/vizro-ai/src/vizro_ai/dashboard/_response_models/components.py +++ b/vizro-ai/src/vizro_ai/dashboard/_response_models/components.py @@ -10,8 +10,8 @@ except ImportError: # pragma: no cov from pydantic import BaseModel, Field from vizro.tables import dash_ag_grid -from vizro_ai.dashboard._pydantic_output import _get_pydantic_output -from vizro_ai.dashboard._response_models.types import CompType +from vizro_ai.dashboard._pydantic_output import _get_pydantic_model +from vizro_ai.dashboard._response_models.types import ComponentType from vizro_ai.utils.helper import DebugFailure logger = logging.getLogger(__name__) @@ -20,7 +20,7 @@ class ComponentPlan(BaseModel): """Component plan model.""" - component_type: CompType + component_type: ComponentType component_description: str = Field( ..., description=""" @@ -62,7 +62,7 @@ def create(self, model, all_df_metadata) -> Union[vm.Card, vm.AgGrid, vm.Figure] The Card uses the dcc.Markdown component from Dash as its underlying text component. Create a card based on the card description: {self.component_description}. """ - result_proxy = _get_pydantic_output(query=card_prompt, llm_model=model, response_model=vm.Card) + result_proxy = _get_pydantic_model(query=card_prompt, llm_model=model, response_model=vm.Card) proxy_dict = result_proxy.dict() proxy_dict["id"] = self.component_id return vm.Card.parse_obj(proxy_dict) @@ -92,7 +92,6 @@ def create(self, model, all_df_metadata) -> Union[vm.Card, vm.AgGrid, vm.Figure] component_type="Card", component_description="Create a card says 'this is worldwide GDP'.", component_id="gdp_card", - page_id="page1", df_name="N/A", ) component = component_plan.create(model, all_df_metadata) diff --git a/vizro-ai/src/vizro_ai/dashboard/_response_models/controls.py b/vizro-ai/src/vizro_ai/dashboard/_response_models/controls.py index bf1b84102..43bdcf62f 100644 --- a/vizro-ai/src/vizro_ai/dashboard/_response_models/controls.py +++ b/vizro-ai/src/vizro_ai/dashboard/_response_models/controls.py @@ -1,7 +1,7 @@ """Controls plan model.""" import logging -from typing import List, Union +from typing import List, Optional import pandas as pd import vizro.models as vm @@ -10,8 +10,8 @@ from pydantic.v1 import BaseModel, Field, ValidationError, create_model, root_validator, validator except ImportError: # pragma: no cov from pydantic import BaseModel, Field, ValidationError, create_model, root_validator, validator -from vizro_ai.dashboard._pydantic_output import _get_pydantic_output -from vizro_ai.dashboard._response_models.types import CtrlType +from vizro_ai.dashboard._pydantic_output import _get_pydantic_model +from vizro_ai.dashboard._response_models.types import ControlType logger = logging.getLogger(__name__) @@ -84,16 +84,14 @@ def _create_filter(filter_prompt, model, df_cols, df_schema, controllable_compon result_proxy = _create_filter_proxy( df_cols=df_cols, df_schema=df_schema, controllable_components=controllable_components ) - proxy = _get_pydantic_output(query=filter_prompt, llm_model=model, response_model=result_proxy, df_info=df_schema) - return vm.Filter.parse_obj( - proxy.dict(exclude={"selector": {"id": True, "actions": True, "_add_key": True}, "id": True, "type": True}) - ) + proxy = _get_pydantic_model(query=filter_prompt, llm_model=model, response_model=result_proxy, df_info=df_schema) + return vm.Filter.parse_obj(proxy.dict(exclude_unset=True)) class ControlPlan(BaseModel): """Control plan model.""" - control_type: CtrlType + control_type: ControlType control_description: str = Field( ..., description=""" @@ -105,12 +103,12 @@ class ControlPlan(BaseModel): df_name: str = Field( ..., description=""" - The name of the dataframe that this component will use. + The name of the dataframe that the target component will use. If the dataframe is not used, please specify that. """, ) - def create(self, model, controllable_components, all_df_metadata) -> Union[vm.Filter, None]: + def create(self, model, controllable_components, all_df_metadata) -> Optional[vm.Filter]: """Create the control.""" filter_prompt = f""" Create a filter from the following instructions: <{self.control_description}>. Do not make up diff --git a/vizro-ai/src/vizro_ai/dashboard/_response_models/dashboard.py b/vizro-ai/src/vizro_ai/dashboard/_response_models/dashboard.py index 580a1d524..a96550b61 100644 --- a/vizro-ai/src/vizro_ai/dashboard/_response_models/dashboard.py +++ b/vizro-ai/src/vizro_ai/dashboard/_response_models/dashboard.py @@ -7,12 +7,12 @@ from pydantic.v1 import BaseModel, Field except ImportError: # pragma: no cov from pydantic import BaseModel, Field -from vizro_ai.dashboard._response_models.page import PagePlanner +from vizro_ai.dashboard._response_models.page import PagePlan logger = logging.getLogger(__name__) -class DashboardPlanner(BaseModel): +class DashboardPlan(BaseModel): """Dashboard plan model.""" title: str = Field( @@ -22,4 +22,4 @@ class DashboardPlanner(BaseModel): make a short and concise title from the content of the pages. """, ) - pages: List[PagePlanner] + pages: List[PagePlan] diff --git a/vizro-ai/src/vizro_ai/dashboard/_response_models/df_info.py b/vizro-ai/src/vizro_ai/dashboard/_response_models/df_info.py index ff6dc207d..0ea59395b 100644 --- a/vizro-ai/src/vizro_ai/dashboard/_response_models/df_info.py +++ b/vizro-ai/src/vizro_ai/dashboard/_response_models/df_info.py @@ -10,7 +10,7 @@ from pydantic import BaseModel, Field -DF_SUM_PROMPT = """ +DF_SUMMARY_PROMPT = """ Inspect the provided data and give a short unique name to the dataset. \n dataframe sample: \n ------- \n {df_sample} \n ------- \n Here is the data schema: \n ------- \n {df_schema} \n ------- \n @@ -27,7 +27,7 @@ class DfInfo(BaseModel): def _get_df_info(df: pd.DataFrame) -> Tuple[Dict[str, str], pd.DataFrame]: - """Get the dataframe schema and head info as strings.""" + """Get the dataframe schema and sample.""" formatted_pairs = dict(df.dtypes.astype(str)) df_sample = df.sample(5, replace=True, random_state=19) return formatted_pairs, df_sample @@ -35,7 +35,7 @@ def _get_df_info(df: pd.DataFrame) -> Tuple[Dict[str, str], pd.DataFrame]: def _create_df_info_content(df_schema: Dict[str, str], df_sample: pd.DataFrame, current_df_names: List[str]) -> dict: """Create the message content for the dataframe summarization.""" - return DF_SUM_PROMPT.format(df_sample=df_sample, df_schema=df_schema, current_df_names=current_df_names) + return DF_SUMMARY_PROMPT.format(df_sample=df_sample, df_schema=df_schema, current_df_names=current_df_names) if __name__ == "__main__": diff --git a/vizro-ai/src/vizro_ai/dashboard/_response_models/layout.py b/vizro-ai/src/vizro_ai/dashboard/_response_models/layout.py index 72081e8b1..dbec8b3d6 100644 --- a/vizro-ai/src/vizro_ai/dashboard/_response_models/layout.py +++ b/vizro-ai/src/vizro_ai/dashboard/_response_models/layout.py @@ -1,7 +1,7 @@ """Layout plan model.""" import logging -from typing import List +from typing import List, Optional import vizro.models as vm @@ -13,7 +13,7 @@ logger = logging.getLogger(__name__) -def _convert_to_grid(layout_grid_template_areas, component_ids) -> List[List[int]]: +def _convert_to_grid(layout_grid_template_areas: List[str], component_ids: List[str]) -> List[List[int]]: component_map = {component: index for index, component in enumerate(component_ids)} grid = [] @@ -26,8 +26,13 @@ def _convert_to_grid(layout_grid_template_areas, component_ids) -> List[List[int try: grid_row.append(component_map[cell]) except KeyError: - logger.warning(f"Component {cell} not found in component_ids: {component_ids}") - grid_row.append(-1) + logger.warning( + f""" +[FALLBACK] Component {cell} not found in component_ids: {component_ids}. +Returning default values. +""" + ) + return [] grid.append(grid_row) return grid @@ -36,13 +41,6 @@ def _convert_to_grid(layout_grid_template_areas, component_ids) -> List[List[int class LayoutPlan(BaseModel): """Layout plan model, which only applies to Vizro Components(Graph, AgGrid, Card).""" - layout_description: str = Field( - ..., - description=""" - Description of the layout of Vizro Components(Graph, AgGrid, Card). - Include everything that seems to relate to this layout. If layout not specified, describe layout as N/A. - """, - ) layout_grid_template_areas: List[str] = Field( [], description=""" @@ -57,9 +55,9 @@ class LayoutPlan(BaseModel): """, ) - def create(self, component_ids: List[str]): + def create(self, component_ids: List[str]) -> Optional[vm.Layout]: """Create the layout.""" - if self.layout_description == "N/A": + if not self.layout_grid_template_areas: return None try: @@ -72,7 +70,7 @@ def create(self, component_ids: List[str]): f""" [FALLBACK] Build failed for `Layout`, returning default values. Try rephrase the prompt or select a different model. Error details: {e} -Relevant prompt: {self.layout_description}, which was parsed as layout_grid_template_areas: +Relevant layout_grid_template_areas: {self.layout_grid_template_areas} """ ) @@ -88,8 +86,7 @@ def create(self, component_ids: List[str]): model = _get_llm_model() layout_plan = LayoutPlan( - layout_description="Create a layout with a graph on the left and a card on the right.", layout_grid_template_areas=["graph1 card2 card2", "graph1 . card1"], ) - layout = layout_plan.create(model, component_ids=["graph1", "card1", "card2"]) + layout = layout_plan.create(component_ids=["graph1", "card1", "card2"]) print(layout) # noqa: T201 diff --git a/vizro-ai/src/vizro_ai/dashboard/_response_models/page.py b/vizro-ai/src/vizro_ai/dashboard/_response_models/page.py index 051a24208..de37b7db1 100644 --- a/vizro-ai/src/vizro_ai/dashboard/_response_models/page.py +++ b/vizro-ai/src/vizro_ai/dashboard/_response_models/page.py @@ -18,7 +18,7 @@ logger = logging.getLogger(__name__) -class PagePlanner(BaseModel): +class PagePlan(BaseModel): """Page plan model.""" title: str = Field( @@ -28,7 +28,6 @@ class PagePlanner(BaseModel): make a concise and descriptive title from the components. """, ) - page_id: str = Field(..., description="Unique identifier for the page being planned.") components_plan: List[ComponentPlan] = Field( ..., description="List of components. Must contain at least one component." ) @@ -162,6 +161,7 @@ def create(self, model, all_df_metadata) -> Union[vm.Page, None]: try: page = vm.Page(title=title, components=components, controls=controls, layout=layout) except Exception as e: + # TODO: This Exception might be redundant. Check if it can be removed. if any("Number of page and grid components need to be the same" in error["msg"] for error in e.errors()): logger.warning( """ @@ -197,9 +197,8 @@ def create(self, model, all_df_metadata) -> Union[vm.Page, None]: ) } ) - page_plan = PagePlanner( + page_plan = PagePlan( title="Worldwide GDP", - page_id="page1", components_plan=[ ComponentPlan( component_type="Card", @@ -216,7 +215,6 @@ def create(self, model, all_df_metadata) -> Union[vm.Page, None]: ) ], layout_plan=LayoutPlan( - layout_description="N/A", layout_grid_template_areas=[], ), unsupported_specs=[], diff --git a/vizro-ai/src/vizro_ai/dashboard/_response_models/types.py b/vizro-ai/src/vizro_ai/dashboard/_response_models/types.py index 3235c683e..56cc2023f 100644 --- a/vizro-ai/src/vizro_ai/dashboard/_response_models/types.py +++ b/vizro-ai/src/vizro_ai/dashboard/_response_models/types.py @@ -5,9 +5,9 @@ # TODO make available in documentation # Complete list: ["AgGrid", "Button", "Card", "Container", "Graph", "Table", "Tabs"] -CompType = Literal["AgGrid", "Card", "Graph"] +ComponentType = Literal["AgGrid", "Card", "Graph"] """Component types currently supported by Vizro-AI.""" # Complete list: ["Filter", "Parameter"] -CtrlType = Literal["Filter"] +ControlType = Literal["Filter"] """Control types currently supported by Vizro-AI.""" diff --git a/vizro-ai/src/vizro_ai/dashboard/utils.py b/vizro-ai/src/vizro_ai/dashboard/utils.py index 5f7b52fa5..7276a57bb 100644 --- a/vizro-ai/src/vizro_ai/dashboard/utils.py +++ b/vizro-ai/src/vizro_ai/dashboard/utils.py @@ -46,7 +46,6 @@ def get_df_schema(self, name: str) -> Dict[str, str]: class DashboardOutputs: """Dataclass containing all possible `VizroAI.dashboard()` output.""" - code: str dashboard: vm.Dashboard @@ -62,11 +61,3 @@ def _register_data(all_df_metadata: AllDfMetadata) -> vm.Dashboard: for name, metadata in all_df_metadata.all_df_metadata.items(): data_manager[name] = metadata.df - - -def _dashboard_code(dashboard: vm.Dashboard) -> str: - """Generate dashboard code from dashboard object.""" - try: - return dashboard.to_python() - except AttributeError: - return "Dashboard code generation is coming soon!" diff --git a/vizro-core/changelog.d/20240725_105946_maximilian_schulz_improved_load_CSS.md b/vizro-core/changelog.d/20240725_105946_maximilian_schulz_improved_load_CSS.md new file mode 100644 index 000000000..56bee372f --- /dev/null +++ b/vizro-core/changelog.d/20240725_105946_maximilian_schulz_improved_load_CSS.md @@ -0,0 +1,47 @@ + + + + + +### Added + +- Add dark mode and loading spinner to the layout loading screen (before Vizro app is shown) ([#598](https://github.com/mckinsey/vizro/pull/598)) + + + + + diff --git a/vizro-core/changelog.d/20240801_113636_petar_pejovic_make_figures_subclassable.md b/vizro-core/changelog.d/20240801_113636_petar_pejovic_make_figures_subclassable.md new file mode 100644 index 000000000..74c27f31f --- /dev/null +++ b/vizro-core/changelog.d/20240801_113636_petar_pejovic_make_figures_subclassable.md @@ -0,0 +1,47 @@ + + + + + + + + +### Fixed + +- Fix subclassing of `vm.Graph`, `vm.Table`, `vm.AgGrid`, `vm.Figure` and `vm.Action` models. ([#606](https://github.com/mckinsey/vizro/pull/606)) + + diff --git a/vizro-core/changelog.d/20240801_152834_huong_li_nguyen_fix_slider_type.md b/vizro-core/changelog.d/20240801_152834_huong_li_nguyen_fix_slider_type.md new file mode 100644 index 000000000..07c5ee233 --- /dev/null +++ b/vizro-core/changelog.d/20240801_152834_huong_li_nguyen_fix_slider_type.md @@ -0,0 +1,47 @@ + + + + + + + + +### Fixed + +- Fix display of marks in `vm.Slider` and `vm.RangeSlider` by converting floats to integers when possible. ([#613](https://github.com/mckinsey/vizro/pull/613)) + + diff --git a/vizro-core/examples/scratch_dev/app.py b/vizro-core/examples/scratch_dev/app.py index b4bb910c1..8cebd9d33 100644 --- a/vizro-core/examples/scratch_dev/app.py +++ b/vizro-core/examples/scratch_dev/app.py @@ -1,66 +1,111 @@ """Dev app to try things out.""" -from typing import List - -import pandas as pd -import plotly.graph_objects as go import vizro.models as vm +import vizro.plotly.express as px from vizro import Vizro +from vizro.figures import kpi_card from vizro.models.types import capture +from vizro.tables import dash_ag_grid, dash_data_table -sankey_data = pd.DataFrame( - { - "Origin": [0, 1, 2, 1, 2, 4, 0], # indices inside labels - "Destination": [1, 2, 3, 4, 5, 5, 6], # indices inside labels - "Value": [10, 4, 8, 6, 4, 8, 8], - } -) +df = px.data.iris() +# Graph @capture("graph") -def sankey( - data_frame: pd.DataFrame, - source: str, - target: str, - value: str, - labels: List[str], -) -> go.Figure: - """Creates a sankey diagram based on a go.Figure.""" - fig = go.Figure( - data=[ - go.Sankey( - node={ - "pad": 16, - "thickness": 16, - "label": labels, - }, - link={ - "source": data_frame[source], - "target": data_frame[target], - "value": data_frame[value], - "label": labels, - "color": "rgba(205, 209, 228, 0.4)", - }, - ) - ] - ) - fig.update_layout(barmode="relative") - return fig +def my_graph_figure(data_frame, **kwargs): + """My custom figure.""" + return px.scatter(data_frame, **kwargs) + + +class MyGraph(vm.Graph): + """My custom class.""" + + def build(self): + """Custom build.""" + graph_build_obj = super().build() + # DO SOMETHING + return graph_build_obj + + +# Table +@capture("table") +def my_table_figure(data_frame, **kwargs): + """My custom figure.""" + return dash_data_table(data_frame, **kwargs)() + + +class MyTable(vm.Table): + """My custom class.""" + + pass + + +# AgGrid +@capture("ag_grid") +def my_ag_grid_figure(data_frame, **kwargs): + """My custom figure.""" + return dash_ag_grid(data_frame, **kwargs)() + + +class MyAgGrid(vm.AgGrid): + """My custom class.""" + + pass + + +# Figure +@capture("figure") +def my_kpi_card_figure(data_frame, **kwargs): + """My custom figure.""" + return kpi_card(data_frame, **kwargs)() + + +class MyFigure(vm.Figure): + """My custom class.""" + + pass + + +# Action +@capture("action") +def my_action_function(): + """My custom action.""" + pass + + +class MyAction(vm.Action): + """My custom class.""" + + pass page = vm.Page( - title="Sankey", + title="Test", + layout=vm.Layout( + grid=[[0, 1], [2, 3], [4, 5], [6, 7], [8, -1]], + col_gap="50px", + row_gap="50px", + ), components=[ - vm.Graph( - figure=sankey( - data_frame=sankey_data, - labels=["A1", "A2", "B1", "B2", "C1", "C2", "D1"], - source="Origin", - target="Destination", - value="Value", - ), + # Graph + MyGraph(figure=px.scatter(df, x="sepal_width", y="sepal_length", title="My Graph")), + MyGraph(figure=my_graph_figure(df, x="sepal_width", y="sepal_length", title="My Graph Custom Figure")), + # Table + MyTable(figure=dash_data_table(df), title="My Table"), + MyTable(figure=my_table_figure(df), title="My Table Custom Figure"), + # AgGrid + MyAgGrid(figure=dash_ag_grid(df), title="My AgGrid"), + MyAgGrid(figure=my_ag_grid_figure(df), title="My AgGrid Custom Figure"), + # Figure + MyFigure(figure=kpi_card(df, value_column="sepal_width", title="KPI Card")), + MyFigure(figure=my_kpi_card_figure(df, value_column="sepal_width", title="KPI Card Custom Figure")), + # Action + MyGraph( + figure=my_graph_figure(df, x="sepal_width", y="sepal_length", title="My Graph Custom Figure"), + actions=[MyAction(function=my_action_function())], ), ], + controls=[vm.Filter(column="species")], ) dashboard = vm.Dashboard(pages=[page]) diff --git a/vizro-core/schemas/0.1.20.dev0.json b/vizro-core/schemas/0.1.20.dev0.json index 99492cc9f..89b8fe8fc 100644 --- a/vizro-core/schemas/0.1.20.dev0.json +++ b/vizro-core/schemas/0.1.20.dev0.json @@ -857,14 +857,7 @@ "default": {}, "type": "object", "additionalProperties": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object" - } - ] + "type": "string" } }, "value": { @@ -932,14 +925,7 @@ "default": {}, "type": "object", "additionalProperties": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object" - } - ] + "type": "string" } }, "value": { diff --git a/vizro-core/src/vizro/models/_action/_action.py b/vizro-core/src/vizro/models/_action/_action.py index 3d9c1c665..0d0bffef4 100644 --- a/vizro-core/src/vizro/models/_action/_action.py +++ b/vizro-core/src/vizro/models/_action/_action.py @@ -11,7 +11,6 @@ except ImportError: # pragma: no cov from pydantic import Field, validator -import vizro.actions from vizro.managers._model_manager import ModelID from vizro.models import VizroBaseModel from vizro.models._models_utils import _log_call @@ -32,7 +31,7 @@ class Action(VizroBaseModel): """ - function: CapturedCallable = Field(..., import_path=vizro.actions, mode="action", description="Action function.") + function: CapturedCallable = Field(..., import_path="vizro.actions", mode="action", description="Action function.") inputs: List[str] = Field( [], description="Inputs in the form `.` passed to the action function.", diff --git a/vizro-core/src/vizro/models/_components/ag_grid.py b/vizro-core/src/vizro/models/_components/ag_grid.py index 8a7b5e721..80cbc02a4 100644 --- a/vizro-core/src/vizro/models/_components/ag_grid.py +++ b/vizro-core/src/vizro/models/_components/ag_grid.py @@ -10,7 +10,6 @@ from pydantic import Field, PrivateAttr, validator from dash import ClientsideFunction, Input, Output, clientside_callback -import vizro.tables as vt from vizro.actions._actions_utils import CallbackTriggerDict, _get_component_actions, _get_parent_vizro_model from vizro.managers import data_manager from vizro.models import Action, VizroBaseModel @@ -35,7 +34,7 @@ class AgGrid(VizroBaseModel): type: Literal["ag_grid"] = "ag_grid" figure: CapturedCallable = Field( - ..., import_path=vt, mode="ag_grid", description="Function that returns a Dash AgGrid." + ..., import_path="vizro.tables", mode="ag_grid", description="Function that returns a `Dash AG Grid`." ) title: str = Field("", description="Title of the AgGrid") actions: List[Action] = [] diff --git a/vizro-core/src/vizro/models/_components/figure.py b/vizro-core/src/vizro/models/_components/figure.py index cb5128918..f4cf0c52e 100644 --- a/vizro-core/src/vizro/models/_components/figure.py +++ b/vizro-core/src/vizro/models/_components/figure.py @@ -7,7 +7,6 @@ except ImportError: # pragma: no cov from pydantic import Field, PrivateAttr, validator -import vizro.figures as vf from vizro.managers import data_manager from vizro.models import VizroBaseModel from vizro.models._components._components_utils import _process_callable_data_frame @@ -26,7 +25,7 @@ class Figure(VizroBaseModel): type: Literal["figure"] = "figure" figure: CapturedCallable = Field( - import_path=vf, + import_path="vizro.figures", mode="figure", description="Function that returns a figure-like object.", ) 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 2b86cb341..39de02881 100644 --- a/vizro-core/src/vizro/models/_components/form/_form_utils.py +++ b/vizro-core/src/vizro/models/_components/form/_form_utils.py @@ -105,6 +105,12 @@ def validate_step(cls, step, values): def set_default_marks(cls, marks, values): if not marks and values.get("step") is None: marks = None + + # Dash has a bug where marks provided as floats that can be converted to integers are not displayed. + # So we need to convert the floats to integers if possible. + # https://github.com/plotly/dash-core-components/issues/159#issuecomment-380581043 + if marks: + marks = {int(k) if k.is_integer() else k: v for k, v in marks.items()} return marks 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 34c1f98f4..503ba87c8 100644 --- a/vizro-core/src/vizro/models/_components/form/range_slider.py +++ b/vizro-core/src/vizro/models/_components/form/range_slider.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, Literal, Optional, Union +from typing import Dict, List, Literal, Optional from dash import ClientsideFunction, Input, Output, State, clientside_callback, dcc, html @@ -43,7 +43,7 @@ class RangeSlider(VizroBaseModel): 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[int, Union[str, Dict[str, Any]]]] = Field({}, description="Marks to be displayed 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 ) diff --git a/vizro-core/src/vizro/models/_components/form/slider.py b/vizro-core/src/vizro/models/_components/form/slider.py index 33dcc4057..f3854e2b1 100644 --- a/vizro-core/src/vizro/models/_components/form/slider.py +++ b/vizro-core/src/vizro/models/_components/form/slider.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, Literal, Optional, Union +from typing import Dict, List, Literal, Optional from dash import ClientsideFunction, Input, Output, State, clientside_callback, dcc, html @@ -43,7 +43,7 @@ class Slider(VizroBaseModel): 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[int, Union[str, Dict[str, Any]]]] = Field({}, description="Marks to be displayed 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] = [] diff --git a/vizro-core/src/vizro/models/_components/graph.py b/vizro-core/src/vizro/models/_components/graph.py index 75670cd52..0f11a5d2c 100644 --- a/vizro-core/src/vizro/models/_components/graph.py +++ b/vizro-core/src/vizro/models/_components/graph.py @@ -12,7 +12,6 @@ import pandas as pd -import vizro.plotly.express as px from vizro import _themes as themes from vizro.actions._actions_utils import CallbackTriggerDict, _get_component_actions from vizro.managers import data_manager, model_manager @@ -38,7 +37,9 @@ class Graph(VizroBaseModel): """ type: Literal["graph"] = "graph" - figure: CapturedCallable = Field(..., import_path=px, mode="graph", description="Function that returns a graph.") + figure: CapturedCallable = Field( + ..., import_path="vizro.plotly.express", mode="graph", description="Function that returns a plotly `go.Figure`" + ) actions: List[Action] = [] # Component properties for actions and interactions diff --git a/vizro-core/src/vizro/models/_components/table.py b/vizro-core/src/vizro/models/_components/table.py index 6aefb7b9a..2018aedb8 100644 --- a/vizro-core/src/vizro/models/_components/table.py +++ b/vizro-core/src/vizro/models/_components/table.py @@ -9,7 +9,6 @@ except ImportError: # pragma: no cov from pydantic import Field, PrivateAttr, validator -import vizro.tables as vt from vizro.actions._actions_utils import CallbackTriggerDict, _get_component_actions, _get_parent_vizro_model from vizro.managers import data_manager from vizro.models import Action, VizroBaseModel @@ -34,7 +33,7 @@ class Table(VizroBaseModel): type: Literal["table"] = "table" figure: CapturedCallable = Field( - ..., import_path=vt, mode="table", description="Function that returns a Dash DataTable." + ..., import_path="vizro.tables", mode="table", description="Function that returns a `Dash DataTable`." ) title: str = Field("", description="Title of the table") actions: List[Action] = [] diff --git a/vizro-core/src/vizro/models/types.py b/vizro-core/src/vizro/models/types.py index 8dce74d74..bbe0209f7 100644 --- a/vizro-core/src/vizro/models/types.py +++ b/vizro-core/src/vizro/models/types.py @@ -4,6 +4,7 @@ from __future__ import annotations import functools +import importlib import inspect from datetime import date from typing import Any, Dict, List, Literal, Protocol, Union, runtime_checkable @@ -203,9 +204,9 @@ def _parse_json( import_path = field.field_info.extra["import_path"] try: - function = getattr(import_path, function_name) - except AttributeError as exc: - raise ValueError(f"_target_={function_name} cannot be imported from {import_path.__name__}.") from exc + function = getattr(importlib.import_module(import_path), function_name) + except (AttributeError, ModuleNotFoundError) as exc: + raise ValueError(f"_target_={function_name} cannot be imported from {import_path}.") from exc # All the other items in figure are the keyword arguments to pass into function. function_kwargs = captured_callable_config @@ -230,11 +231,11 @@ def _extract_from_attribute( def _check_type(cls, captured_callable: CapturedCallable, field: ModelField) -> CapturedCallable: """Checks captured_callable is right type and mode.""" expected_mode = field.field_info.extra["mode"] - import_path_name = field.field_info.extra["import_path"].__name__ + import_path = field.field_info.extra["import_path"] if not isinstance(captured_callable, CapturedCallable): raise ValueError( - f"Invalid CapturedCallable. Supply a function imported from {import_path_name} or defined with " + f"Invalid CapturedCallable. Supply a function imported from {import_path} or defined with " f"decorator @capture('{expected_mode}')." ) diff --git a/vizro-core/src/vizro/static/css/loading.css b/vizro-core/src/vizro/static/css/loading.css new file mode 100644 index 000000000..fd5eac3c8 --- /dev/null +++ b/vizro-core/src/vizro/static/css/loading.css @@ -0,0 +1,43 @@ +/* Inspired by https://github.com/facultyai/dash-bootstrap-components/blob/5c8f4b40f1100fc00bf2d5c1d671a7815a6b2910/docs/static/loading.css */ + +/* This creates a dark background in situations where neither dash-loading nor the Vizro app are displayed */ +html { + background: rgba(20, 23, 33, 1); + min-height: 100vh; +} + +/* The dash-loading Div is present when Dash is initially loading, before the layout is built */ + +/* The dash-loading-callback Div is present when Dash has loaded, but the layout is still building */ + +/* Note that the dash-loading-callback Div is present until all elements are loaded, but as soon as the initial page +elements (before on-page-load) are rendered, it gets pushed outside the viewable area, hence the spinner is not visible +which is good, as we have individual loading spinners for elements. At the moment, we are not using this class. +TODO: If we want to use this class, we need to evaluate if this is the best approach. + */ + +/* ._dash-loading-callback, */ +._dash-loading { + align-items: center; + background: rgba(20, 23, 33, 1); + color: transparent; + display: flex; + height: 100%; + justify-content: center; + position: fixed; + width: 100%; +} + +/* Loading spinner */ + +/* ._dash-loading-callback::after, */ +._dash-loading::after { + animation: spinner-border 0.75s linear infinite; + border: 0.5rem solid lightgrey; + border-radius: 50%; + border-right-color: transparent; + content: ""; + display: inline-block; + height: 8rem; + width: 8rem; +} diff --git a/vizro-core/tests/integration/test_examples.py b/vizro-core/tests/integration/test_examples.py index fe39c5a8b..8201f0c71 100644 --- a/vizro-core/tests/integration/test_examples.py +++ b/vizro-core/tests/integration/test_examples.py @@ -1,7 +1,6 @@ # ruff: noqa: F403, F405 import os import runpy -import sys from pathlib import Path import chromedriver_autoinstaller @@ -30,12 +29,11 @@ def dashboard(request, monkeypatch): example_directory = request.getfixturevalue("example_path") / request.getfixturevalue("version") monkeypatch.chdir(example_directory) monkeypatch.syspath_prepend(example_directory) - old_sys_modules = set(sys.modules) - yield runpy.run_path("app.py")["dashboard"] + return runpy.run_path("app.py")["dashboard"] # Both run_path and run_module contaminate sys.modules, so we need to undo this in order to avoid interference - # between tests. - for key in set(sys.modules) - old_sys_modules: - del sys.modules[key] + # between tests. However, if you do this then importlib.import_module seems to cause the problem due to mysterious + # reasons. The current system should work well so long as there's no sub-packages with clashing names in the + # examples. examples_path = Path(__file__).parents[2] / "examples" @@ -52,13 +50,13 @@ def dashboard(request, monkeypatch): @pytest.mark.parametrize( "example_path, version", [ + # KPI example is not included as it will be moved to HuggingFace over time. # Chart gallery is not included since it means installing black in the testing environment. # It will move to HuggingFace in due course anyway. (examples_path / "scratch_dev", ""), (examples_path / "scratch_dev", "yaml_version"), (examples_path / "dev", ""), (examples_path / "dev", "yaml_version"), - (examples_path / "kpi", ""), ], ids=str, ) 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 c145424f3..f42a63542 100644 --- a/vizro-core/tests/unit/vizro/models/_action/test_action.py +++ b/vizro-core/tests/unit/vizro/models/_action/test_action.py @@ -64,6 +64,18 @@ def test_create_action_mandatory_and_optional(self, identity_action_function): assert action.inputs == inputs assert action.outputs == outputs + def test_is_model_inheritable(self, identity_action_function): + class MyAction(vm.Action): + pass + + function = identity_action_function() + my_action = MyAction(function=function) + + assert hasattr(my_action, "id") + assert my_action.function is function + assert my_action.inputs == [] + assert my_action.outputs == [] + @pytest.mark.parametrize( "inputs, outputs", [ 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 80a23c9cc..e0c9a9f13 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 @@ -229,16 +229,22 @@ def test_validate_step_invalid(self): "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.0: "15", 25.0: "25"}), - ({"15": 15, "25": 25}, {15.0: "15", 25.0: "25"}), + ({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 (None, None), ], ) def test_valid_marks(self, marks, expected): range_slider = vm.RangeSlider(min=0, max=10, marks=marks) - assert range_slider.marks == expected + if marks: + assert [type(result_key) for result_key in range_slider.marks] == [ + type(expected_key) for expected_key in expected + ] + def test_invalid_marks(self): with pytest.raises(ValidationError, match="2 validation errors for RangeSlider"): vm.RangeSlider(min=1, max=10, marks={"start": 0, "end": 10}) 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 7ce15011d..56d2c5f24 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 @@ -120,16 +120,22 @@ def test_valid_marks_with_step(self): "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.0: "15", 25.0: "25"}), - ({"15": 15, "25": 25}, {15.0: "15", 25.0: "25"}), + ({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 (None, None), ], ) def test_valid_marks(self, marks, expected): slider = vm.Slider(min=0, max=10, marks=marks) - assert slider.marks == expected + if marks: + assert [type(result_key) for result_key in slider.marks] == [ + type(expected_key) for expected_key in expected + ] + def test_invalid_marks(self): with pytest.raises(ValidationError, match="2 validation errors for Slider"): vm.Slider(min=1, max=10, marks={"start": 0, "end": 10}) 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 906ccdb1b..dd2c1116f 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 @@ -69,6 +69,17 @@ def test_captured_callable_wrong_mode(self, standard_dash_table): ): vm.AgGrid(figure=standard_dash_table) + def test_is_model_inheritable(self, standard_ag_grid): + class MyAgGrid(vm.AgGrid): + pass + + my_ag_grid = MyAgGrid(figure=standard_ag_grid) + + assert hasattr(my_ag_grid, "id") + assert my_ag_grid.type == "ag_grid" + assert my_ag_grid.figure == standard_ag_grid + assert my_ag_grid.actions == [] + def test_set_action_via_validator(self, standard_ag_grid, identity_action_function): ag_grid = vm.AgGrid(figure=standard_ag_grid, actions=[Action(function=identity_action_function())]) actions_chain = ag_grid.actions[0] 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 1c1a5e9d5..20bbc3b87 100644 --- a/vizro-core/tests/unit/vizro/models/_components/test_figure.py +++ b/vizro-core/tests/unit/vizro/models/_components/test_figure.py @@ -54,6 +54,16 @@ def test_captured_callable_wrong_mode(self, standard_dash_table): ): vm.Figure(figure=standard_dash_table) + def test_is_model_inheritable(self, standard_kpi_card): + class MyFigure(vm.Figure): + pass + + my_figure = MyFigure(figure=standard_kpi_card) + + assert hasattr(my_figure, "id") + assert my_figure.type == "figure" + assert my_figure.figure == standard_kpi_card + class TestDunderMethodsFigure: def test_getitem_known_args(self, standard_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 ddc62fdc3..71e74480e 100644 --- a/vizro-core/tests/unit/vizro/models/_components/test_graph.py +++ b/vizro-core/tests/unit/vizro/models/_components/test_graph.py @@ -75,6 +75,17 @@ def test_captured_callable_wrong_mode(self, standard_ag_grid): ): vm.Graph(figure=standard_ag_grid) + def test_is_model_inheritable(self, standard_px_chart): + class MyGraph(vm.Graph): + pass + + my_graph = MyGraph(figure=standard_px_chart) + + assert hasattr(my_graph, "id") + assert my_graph.type == "graph" + assert my_graph.figure == standard_px_chart._captured_callable + assert my_graph.actions == [] + class TestDunderMethodsGraph: def test_getitem_known_args(self, standard_px_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 a44fab6b6..82e716fab 100644 --- a/vizro-core/tests/unit/vizro/models/_components/test_table.py +++ b/vizro-core/tests/unit/vizro/models/_components/test_table.py @@ -69,6 +69,17 @@ def test_captured_callable_wrong_mode(self, standard_ag_grid): ): vm.Table(figure=standard_ag_grid) + def test_is_model_inheritable(self, standard_dash_table): + class MyTable(vm.Table): + pass + + my_table = MyTable(figure=standard_dash_table) + + assert hasattr(my_table, "id") + assert my_table.type == "table" + assert my_table.figure == standard_dash_table + assert my_table.actions == [] + def test_set_action_via_validator(self, standard_dash_table, identity_action_function): table = vm.Table(figure=standard_dash_table, actions=[Action(function=identity_action_function())]) actions_chain = table.actions[0] diff --git a/vizro-core/tests/unit/vizro/models/test_types.py b/vizro-core/tests/unit/vizro/models/test_types.py index 0e51cf9bf..baa977c7f 100644 --- a/vizro-core/tests/unit/vizro/models/test_types.py +++ b/vizro-core/tests/unit/vizro/models/test_types.py @@ -1,4 +1,3 @@ -import importlib import re import plotly.graph_objects as go @@ -162,12 +161,12 @@ 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=importlib.import_module(__name__), mode="action") + function: CapturedCallable = Field(..., import_path=__name__, mode="action") 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=importlib.import_module(__name__), mode="graph") + function: CapturedCallable = Field(..., import_path=__name__, mode="graph") class TestModelFieldPython: @@ -270,3 +269,15 @@ def test_wrong_mode(self): ), ): ModelWithGraph(function=config) + + def test_invalid_import_path(self): + class ModelWithInvalidModule(VizroBaseModel): + # The import_path doesn't exist. + function: CapturedCallable = Field(..., import_path="invalid.module", mode="graph") + + config = {"_target_": "decorated_graph_function", "data_frame": None} + + with pytest.raises( + ValueError, match="_target_=decorated_graph_function cannot be imported from invalid.module." + ): + ModelWithInvalidModule(function=config)