diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33454ae..9d15054 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,9 @@ jobs: ssh-private-key: ${{ secrets.STRIDE_DATA_DEPLOY_KEY }} - name: Download test data run: | - git clone git@github.com:dsgrid/stride-data.git /tmp/stride-data + STRIDE_DATA_REF=$(cat .stride-data-ref | tr -d '[:space:]') + echo "Cloning stride-data at ref: $STRIDE_DATA_REF" + git clone --branch "$STRIDE_DATA_REF" --single-branch git@github.com:dsgrid/stride-data.git /tmp/stride-data mkdir -p ~/.stride/data cp -r /tmp/stride-data/global ~/.stride/data/ cp -r /tmp/stride-data/global-test ~/.stride/data/ diff --git a/.stride-data-ref b/.stride-data-ref new file mode 100644 index 0000000..46b105a --- /dev/null +++ b/.stride-data-ref @@ -0,0 +1 @@ +v2.0.0 diff --git a/pyproject.toml b/pyproject.toml index afe9541..e07a6fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ "dash-bootstrap-components>=2.0.3", "dbt-core >= 1.10.5, < 2", "dbt-duckdb", - "dsgrid-toolkit >= 0.3.3, < 0.4.0", + "dsgrid-toolkit >= 0.4.0, < 0.5.0", "duckdb >= 1.1, < 2", "loguru", "pandas>=2.2,<3", diff --git a/src/stride/ui/app.py b/src/stride/ui/app.py index 680bf0c..2e2e380 100644 --- a/src/stride/ui/app.py +++ b/src/stride/ui/app.py @@ -31,7 +31,11 @@ get_temp_edits_for_category, parse_temp_edit_key, ) -from stride.config import CACHED_PROJECTS_UPPER_BOUND, DEFAULT_MAX_CACHED_PROJECTS, get_max_cached_projects as _get_config_max_cached +from stride.config import ( + CACHED_PROJECTS_UPPER_BOUND, + DEFAULT_MAX_CACHED_PROJECTS, + get_max_cached_projects as _get_config_max_cached, +) from stride.ui.palette_utils import get_default_user_palette, list_user_palettes assets_path = Path(__file__).parent.absolute() / "assets" @@ -434,7 +438,11 @@ def create_app( # noqa: C901 value=current_project_path, placeholder="Switch project...", className="mb-2", - style={"fontSize": "0.85rem", "width": "calc(100% - 35px)", "display": "inline-block"}, + style={ + "fontSize": "0.85rem", + "width": "calc(100% - 35px)", + "display": "inline-block", + }, clearable=False, ), html.Button( @@ -1094,7 +1102,7 @@ def update_navigation_tabs(project_path: str) -> tuple[list[dict[str, str]], str ] return options, "compare" # Reset to home view - # Refresh dropdown options when refresh button is clicked + # Refresh dropdown options when refresh button is clicked @callback( Output("project-switcher-dropdown", "options", allow_duplicate=True), Input("refresh-projects-btn", "n_clicks"), @@ -1376,7 +1384,11 @@ def create_app_no_project( value=None, placeholder="Select a recent project...", className="mb-2", - style={"fontSize": "0.85rem", "width": "calc(100% - 35px)", "display": "inline-block"}, + style={ + "fontSize": "0.85rem", + "width": "calc(100% - 35px)", + "display": "inline-block", + }, clearable=False, ), html.Button( @@ -1721,9 +1733,7 @@ def _register_refresh_projects_callback() -> None: State("current-project-path", "data"), prevent_initial_call=True, ) - def refresh_dropdown_options( - n_clicks: int | None, current_path: str - ) -> list[dict[str, str]]: + def refresh_dropdown_options(n_clicks: int | None, current_path: str) -> list[dict[str, str]]: """Refresh the project switcher dropdown options with latest recent projects.""" if not n_clicks: raise PreventUpdate diff --git a/src/stride/ui/settings/layout.py b/src/stride/ui/settings/layout.py index 43e4fc2..9c41952 100644 --- a/src/stride/ui/settings/layout.py +++ b/src/stride/ui/settings/layout.py @@ -133,7 +133,12 @@ def create_settings_layout( step=1, value=max_cached_value, className="form-control form-control-sm", - style={"width": "100px", "display": "inline-block", "height": "31px", "fontSize": "0.85rem"}, + style={ + "width": "100px", + "display": "inline-block", + "height": "31px", + "fontSize": "0.85rem", + }, readOnly=is_overridden, disabled=is_overridden, ), @@ -212,8 +217,7 @@ def create_settings_layout( value=current_palette_name, placeholder="Select a user palette...", disabled=( - current_palette_type - != "user" + current_palette_type != "user" ), ), dbc.Button( @@ -224,8 +228,7 @@ def create_settings_layout( size="sm", className="ms-2 mt-2", disabled=( - current_palette_type - != "user" + current_palette_type != "user" or not current_palette_name ), ), @@ -253,8 +256,7 @@ def create_settings_layout( size="sm", className="ms-2 mt-2 theme-text", disabled=( - current_palette_type - != "user" + current_palette_type != "user" or not current_palette_name ), ), @@ -320,7 +322,10 @@ def create_settings_layout( html.Div( [ _create_color_item( - ColorCategory.SCENARIO.value, label, color, temp_edits + ColorCategory.SCENARIO.value, + label, + color, + temp_edits, ) for label, color in scenario_colors.items() ], @@ -340,7 +345,10 @@ def create_settings_layout( html.Div( [ _create_color_item( - ColorCategory.MODEL_YEAR.value, label, color, temp_edits + ColorCategory.MODEL_YEAR.value, + label, + color, + temp_edits, ) for label, color in model_year_colors.items() ], @@ -360,7 +368,10 @@ def create_settings_layout( html.Div( [ _create_color_item( - ColorCategory.SECTOR.value, label, color, temp_edits + ColorCategory.SECTOR.value, + label, + color, + temp_edits, ) for label, color in sector_colors.items() ], @@ -380,7 +391,10 @@ def create_settings_layout( html.Div( [ _create_color_item( - ColorCategory.END_USE.value, label, color, temp_edits + ColorCategory.END_USE.value, + label, + color, + temp_edits, ) for label, color in end_use_colors.items() ], @@ -801,7 +815,7 @@ def get_temp_edits_for_category(category_value: str) -> dict[str, str]: """ prefix = f"{category_value}:" return { - key[len(prefix):]: color + key[len(prefix) :]: color for key, color in _temp_color_edits.items() if key.startswith(prefix) } @@ -861,7 +875,9 @@ def create_color_preview_content(color_manager: ColorManager) -> list[html.Div]: ), html.Div( [ - _create_color_item(ColorCategory.SCENARIO.value, label, color, temp_edits) + _create_color_item( + ColorCategory.SCENARIO.value, label, color, temp_edits + ) for label, color in scenario_colors.items() ], className="d-flex flex-wrap gap-2 mb-3", @@ -881,7 +897,9 @@ def create_color_preview_content(color_manager: ColorManager) -> list[html.Div]: ), html.Div( [ - _create_color_item(ColorCategory.MODEL_YEAR.value, label, color, temp_edits) + _create_color_item( + ColorCategory.MODEL_YEAR.value, label, color, temp_edits + ) for label, color in model_year_colors.items() ], className="d-flex flex-wrap gap-2 mb-3", @@ -901,7 +919,9 @@ def create_color_preview_content(color_manager: ColorManager) -> list[html.Div]: ), html.Div( [ - _create_color_item(ColorCategory.SECTOR.value, label, color, temp_edits) + _create_color_item( + ColorCategory.SECTOR.value, label, color, temp_edits + ) for label, color in sector_colors.items() ], className="d-flex flex-wrap gap-2 mb-3", @@ -921,7 +941,9 @@ def create_color_preview_content(color_manager: ColorManager) -> list[html.Div]: ), html.Div( [ - _create_color_item(ColorCategory.END_USE.value, label, color, temp_edits) + _create_color_item( + ColorCategory.END_USE.value, label, color, temp_edits + ) for label, color in end_use_colors.items() ], className="d-flex flex-wrap gap-2", diff --git a/tests/palette/test_color_manager_update.py b/tests/palette/test_color_manager_update.py index be8c838..c70622b 100644 --- a/tests/palette/test_color_manager_update.py +++ b/tests/palette/test_color_manager_update.py @@ -48,10 +48,14 @@ def test_color_manager_updates_with_new_palette() -> None: def test_color_manager_singleton_behavior() -> None: """Test that ColorManager maintains singleton behavior.""" - palette1 = ColorPalette.from_dict({"scenarios": {}, "model_years": {}, "metrics": {"A": "#111111"}}) + palette1 = ColorPalette.from_dict( + {"scenarios": {}, "model_years": {}, "metrics": {"A": "#111111"}} + ) cm1 = ColorManager(palette1) - palette2 = ColorPalette.from_dict({"scenarios": {}, "model_years": {}, "metrics": {"A": "#222222"}}) + palette2 = ColorPalette.from_dict( + {"scenarios": {}, "model_years": {}, "metrics": {"A": "#222222"}} + ) cm2 = ColorManager(palette2) # Should be the same instance @@ -169,7 +173,9 @@ def test_color_manager_scenario_styling_updates() -> None: def test_color_manager_preserves_palette_reference() -> None: """Test that ColorManager properly references the provided palette.""" - palette = ColorPalette.from_dict({"scenarios": {}, "model_years": {}, "metrics": {"Label": "#123456"}}) + palette = ColorPalette.from_dict( + {"scenarios": {}, "model_years": {}, "metrics": {"Label": "#123456"}} + ) cm = ColorManager(palette) # Get the palette back diff --git a/tests/palette/test_palette.py b/tests/palette/test_palette.py index 5dce0e7..9d95f02 100644 --- a/tests/palette/test_palette.py +++ b/tests/palette/test_palette.py @@ -562,7 +562,12 @@ class TestMergeWithProjectDimensions: def test_matched_names_keep_colors(self) -> None: """Entries in both palette and project keep their stored color.""" palette = ColorPalette.from_dict( - {"scenarios": {"baseline": "#AA0000", "high": "#BB0000"}, "model_years": {}, "sectors": {}, "end_uses": {}} + { + "scenarios": {"baseline": "#AA0000", "high": "#BB0000"}, + "model_years": {}, + "sectors": {}, + "end_uses": {}, + } ) palette.merge_with_project_dimensions(scenarios=["baseline", "high"]) assert palette.scenarios["baseline"] == "#AA0000" @@ -571,7 +576,12 @@ def test_matched_names_keep_colors(self) -> None: def test_new_project_names_get_colors(self) -> None: """Names in the project but not in the palette get auto-assigned colors.""" palette = ColorPalette.from_dict( - {"scenarios": {"baseline": "#AA0000"}, "model_years": {}, "sectors": {}, "end_uses": {}} + { + "scenarios": {"baseline": "#AA0000"}, + "model_years": {}, + "sectors": {}, + "end_uses": {}, + } ) palette.merge_with_project_dimensions(scenarios=["baseline", "new_scenario"]) assert palette.scenarios["baseline"] == "#AA0000" @@ -681,7 +691,12 @@ def test_none_categories_skipped(self) -> None: def test_case_insensitive_matching(self) -> None: """Merge normalizes names to lowercase for matching.""" palette = ColorPalette.from_dict( - {"scenarios": {"baseline": "#AA0000"}, "model_years": {}, "sectors": {}, "end_uses": {}} + { + "scenarios": {"baseline": "#AA0000"}, + "model_years": {}, + "sectors": {}, + "end_uses": {}, + } ) palette.merge_with_project_dimensions(scenarios=["Baseline"]) assert palette.scenarios["baseline"] == "#AA0000" diff --git a/tests/palette/test_palette_merge.py b/tests/palette/test_palette_merge.py index 0d2abba..c76f56d 100644 --- a/tests/palette/test_palette_merge.py +++ b/tests/palette/test_palette_merge.py @@ -1,6 +1,6 @@ """Tests for ColorPalette.merge_with_project_dimensions.""" -from stride.ui.palette import ColorCategory, ColorPalette, TOL_BRIGHT, TOL_METRICS_LIGHT +from stride.ui.palette import ColorPalette, TOL_BRIGHT class TestMergeMatchedNames: diff --git a/tests/test_app_cache.py b/tests/test_app_cache.py index a3b7b33..7664451 100644 --- a/tests/test_app_cache.py +++ b/tests/test_app_cache.py @@ -27,6 +27,7 @@ # helpers # --------------------------------------------------------------------------- + def _make_mock_project(name: str = "proj") -> MagicMock: """Return a lightweight mock that behaves like a Project.""" proj = MagicMock() @@ -364,11 +365,7 @@ def _build_dropdown_options_no_project( for proj in recent: project_id = proj.get("project_id", "") proj_path = proj.get("path", "") - if ( - project_id - and project_id not in seen_project_ids - and Path(proj_path).exists() - ): + if project_id and project_id not in seen_project_ids and Path(proj_path).exists(): dropdown_options.append( {"label": proj.get("name", project_id), "value": proj_path} ) @@ -387,11 +384,7 @@ def _build_dropdown_options_with_project( for proj in recent: project_id = proj.get("project_id", "") proj_path = proj.get("path", "") - if ( - project_id - and project_id not in seen_project_ids - and Path(proj_path).exists() - ): + if project_id and project_id not in seen_project_ids and Path(proj_path).exists(): dropdown_options.append( {"label": proj.get("name", project_id), "value": proj_path} ) @@ -443,9 +436,7 @@ def test_with_project_includes_current_first(self, tmp_path: Path) -> None: p = tmp_path / "other" p.mkdir() recent = [{"project_id": "other", "path": str(p), "name": "Other"}] - result = self._build_dropdown_options_with_project( - "Current", "/current/path", recent - ) + result = self._build_dropdown_options_with_project("Current", "/current/path", recent) assert result[0] == {"label": "Current", "value": "/current/path"} assert len(result) == 2 @@ -454,9 +445,7 @@ def test_with_project_does_not_duplicate_current(self, tmp_path: Path) -> None: p = tmp_path / "cur" p.mkdir() recent = [{"project_id": "Current", "path": str(p), "name": "Current"}] - result = self._build_dropdown_options_with_project( - "Current", str(p), recent - ) + result = self._build_dropdown_options_with_project("Current", str(p), recent) # Only one entry because deduplication by project_id assert len(result) == 1 @@ -472,9 +461,7 @@ def test_with_project_multiple_recent(self, tmp_path: Path) -> None: {"project_id": f"P{i}", "path": str(d), "name": f"Project {i}"} for i, d in enumerate(dirs) ] - result = self._build_dropdown_options_with_project( - "Current", "/current", recent - ) + result = self._build_dropdown_options_with_project("Current", "/current", recent) # Current + 3 recent assert len(result) == 4 assert result[0]["label"] == "Current"