diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0716937..336f98b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,11 +45,14 @@ jobs: - name: Install uv run: pip install uv - - name: Install ruff - run: uv pip install --system ruff + - name: Install package and dev dependencies + run: uv pip install --system -e ".[dev]" + + - name: Run ruff check + run: ruff check plsdo/ tests/ scripts/ - - name: Run ruff - run: ruff check plsdo/ tests/ + - name: Run ruff format check + run: ruff format --check plsdo/ tests/ scripts/ - name: Check version consistency run: python scripts/check_version.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7b52f61..fdf04d9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,8 +46,11 @@ jobs: uv venv uv pip install -e ".[dev]" - - name: Run ruff - run: uv run ruff check plsdo/ tests/ + - name: Run ruff check + run: uv run ruff check plsdo/ tests/ scripts/ + + - name: Run ruff format check + run: uv run ruff format --check plsdo/ tests/ scripts/ - name: Check version consistency run: python scripts/check_version.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..fefc60f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,9 @@ +# Run `pre-commit install` once to enable these hooks locally. +# Keep this rev in step with the ruff pin in pyproject.toml; bump both together. +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.11 + hooks: + - id: ruff-check + args: [--fix] + - id: ruff-format diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 772149c..a515028 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,8 +16,15 @@ uv venv .venv && source .venv/bin/activate uv pip install -e ".[dev]" ``` -The `dev` extra includes the test runner, coverage, and the linter. Cross- -validation additionally requires the `cv` extra (`uv pip install -e ".[dev,cv]"`). +The `dev` extra includes the test runner, coverage, the linter, and +`pre-commit`. Cross-validation additionally requires the `cv` extra +(`uv pip install -e ".[dev,cv]"`). + +Install the git hooks once so ruff runs automatically on every commit: + +```bash +pre-commit install +``` ## Running tests diff --git a/plsdo/core.py b/plsdo/core.py index 480d1c3..83f21d8 100644 --- a/plsdo/core.py +++ b/plsdo/core.py @@ -109,9 +109,7 @@ def permutation_test(self, n_perms: int = 10000) -> None: perm_s_list.append(perm_s) self.permuted_singular_values = np.stack(perm_s_list, axis=1) - self.p_values = corrected_pvalue( - self.s, self.permuted_singular_values, axis=1 - ) + self.p_values = corrected_pvalue(self.s, self.permuted_singular_values, axis=1) self.significant_lvs = self.p_values < 0.05 self._permuted = True diff --git a/plsdo/cross_validate.py b/plsdo/cross_validate.py index 8d6e53c..155e477 100644 --- a/plsdo/cross_validate.py +++ b/plsdo/cross_validate.py @@ -2,6 +2,7 @@ import numpy as np import pandas as pd + try: from sklearn.cross_decomposition import PLSRegression from sklearn.metrics import ( diff --git a/plsdo/io.py b/plsdo/io.py index 78c3b6b..48a7b0b 100644 --- a/plsdo/io.py +++ b/plsdo/io.py @@ -167,9 +167,7 @@ def align_subjects( col = sid[0] id_sets = [set(df[col]) for df in dfs] else: - id_sets = [ - set(df[sid].itertuples(index=False, name=None)) for df in dfs - ] + id_sets = [set(df[sid].itertuples(index=False, name=None)) for df in dfs] shared_ids = id_sets[0] for s in id_sets[1:]: @@ -203,8 +201,7 @@ def align_subjects( ordered_df = pd.DataFrame(ordered_tuples, columns=sid) aligned = [ - ordered_df.merge(df, on=sid, how="left").reset_index(drop=True) - for df in dfs + ordered_df.merge(df, on=sid, how="left").reset_index(drop=True) for df in dfs ] return aligned @@ -345,9 +342,7 @@ class GroupConfig: groups: list[GroupSpec] = field(default_factory=list) @classmethod - def from_group_col( - cls, group_col: str, subject_id: SubjectID | None = None - ): + def from_group_col(cls, group_col: str, subject_id: SubjectID | None = None): """Create a config from a single --group-col string.""" return cls( subject_id=subject_id, @@ -368,9 +363,7 @@ def x_axis_group(self) -> Optional[GroupSpec]: def hue_column(self) -> Optional[str]: """Column name of the group with role 'hue', or None if none.""" - return next( - (g.column for g in self.active_groups() if g.role == "hue"), None - ) + return next((g.column for g in self.active_groups() if g.role == "hue"), None) def facet_rows_column(self) -> Optional[str]: """Column name of the group with role 'facet_rows', or None if none.""" diff --git a/plsdo/pipeline.py b/plsdo/pipeline.py index aec07cd..bb99f7d 100644 --- a/plsdo/pipeline.py +++ b/plsdo/pipeline.py @@ -200,7 +200,7 @@ def run_pipeline( if len(active) == 1: prefix = active[0].column + "_" x_display_names = [ - name[len(prefix):] if name.startswith(prefix) else name + name[len(prefix) :] if name.startswith(prefix) else name for name in x_feature_names ] else: diff --git a/plsdo/plotting.py b/plsdo/plotting.py index 908a6e6..a9f5467 100644 --- a/plsdo/plotting.py +++ b/plsdo/plotting.py @@ -133,11 +133,15 @@ def plot_heatmap( fmt=".2f" if annotate else "", ) ax.set_xticklabels( - ax.get_xticklabels(), rotation=45, ha="right", + ax.get_xticklabels(), + rotation=45, + ha="right", fontsize=tick_fontsize, ) ax.set_yticklabels( - ax.get_yticklabels(), rotation=0, fontsize=tick_fontsize, + ax.get_yticklabels(), + rotation=0, + fontsize=tick_fontsize, ) if subtitle: fig.suptitle(subtitle) @@ -195,9 +199,7 @@ def _heatmap_with_colour_bars( ax.set_xticklabels( ax.get_xticklabels(), rotation=45, ha="right", fontsize=tick_fontsize ) - ax.set_yticklabels( - ax.get_yticklabels(), rotation=0, fontsize=tick_fontsize - ) + ax.set_yticklabels(ax.get_yticklabels(), rotation=0, fontsize=tick_fontsize) if subtitle: g.figure.suptitle(subtitle) g.savefig(out_path, transparent=False, dpi=dpi) @@ -352,16 +354,24 @@ def _box_strip_facet( g = sns.FacetGrid(data=data, col=col, sharex=False, **grid_kwargs) g.map_dataframe( sns.boxplot, - x=x, y=y, hue=hue, - order=order, hue_order=hue_order, - palette=palette, dodge=box_dodge, + x=x, + y=y, + hue=hue, + order=order, + hue_order=hue_order, + palette=palette, + dodge=box_dodge, **_BOXPLOT_STYLE, ) g.map_dataframe( sns.stripplot, - x=x, y=y, hue=hue, - order=order, hue_order=hue_order, - palette=palette, dodge=strip_dodge, + x=x, + y=y, + hue=hue, + order=order, + hue_order=hue_order, + palette=palette, + dodge=strip_dodge, **_STRIPPLOT_STYLE, ) if rotate_xticklabels: diff --git a/pyproject.toml b/pyproject.toml index 4ef17f2..06338dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,8 @@ cv = [ dev = [ "pytest", "pytest-cov", - "ruff", + "ruff==0.15.11", + "pre-commit", "scikit-learn", ] diff --git a/scripts/check_version.py b/scripts/check_version.py index 0117219..e61439e 100644 --- a/scripts/check_version.py +++ b/scripts/check_version.py @@ -38,7 +38,8 @@ def main() -> None: ROOT / "plsdo" / "__init__.py", r'__version__\s*=\s*"([^"]+)"' ) citation_version = _extract( - ROOT / "CITATION.cff", r'^version:\s*"?([^"\n]+)"?', + ROOT / "CITATION.cff", + r'^version:\s*"?([^"\n]+)"?', ) error = compare(package_version, citation_version) diff --git a/tests/test_core.py b/tests/test_core.py index b931944..364da5a 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -74,6 +74,7 @@ def test_sign_convention_largest_loading_positive(self, x_array, y_array): col = model.u_loadings[:, i] assert col[np.argmax(np.abs(col))] > 0 + class TestPermutationTest: def _fitted_model(self, x_array, y_array): from plsdo.io import zscore_columns @@ -198,6 +199,7 @@ def test_seed_reproducibility(self, x_array, y_array): np.testing.assert_array_equal(m1.u_bootstrap_ratios, m2.u_bootstrap_ratios) + class TestBootstrapZscoreX: def test_zscore_x_false_does_not_alter_dummy_x(self): """Bootstrap with zscore_x=False must leave integer dummy X unchanged.""" diff --git a/tests/test_cross_validate.py b/tests/test_cross_validate.py index 29da171..45b1a13 100644 --- a/tests/test_cross_validate.py +++ b/tests/test_cross_validate.py @@ -177,7 +177,11 @@ def test_missing_sklearn_raises_helpful_error(self, monkeypatch): import sys for mod in list(sys.modules): - if mod == "sklearn" or mod.startswith("sklearn.") or mod == "plsdo.cross_validate": + if ( + mod == "sklearn" + or mod.startswith("sklearn.") + or mod == "plsdo.cross_validate" + ): monkeypatch.delitem(sys.modules, mod, raising=False) real_import = builtins.__import__ diff --git a/tests/test_io.py b/tests/test_io.py index cc86cdc..81dd523 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -211,12 +211,8 @@ def test_multi_column_drops_non_shared(self, caplog): assert "not present in all files" in caplog.text def test_multi_column_empty_intersection_raises(self): - df1 = pd.DataFrame( - {"sid": ["a", "a"], "run": [1, 2], "v1": [10, 20]} - ) - df2 = pd.DataFrame( - {"sid": ["b", "b"], "run": [1, 2], "v2": [30, 40]} - ) + df1 = pd.DataFrame({"sid": ["a", "a"], "run": [1, 2], "v1": [10, 20]}) + df2 = pd.DataFrame({"sid": ["b", "b"], "run": [1, 2], "v2": [30, 40]}) with pytest.raises(ValueError, match="No subjects shared"): align_subjects([df1, df2], subject_id=["sid", "run"]) @@ -224,9 +220,7 @@ def test_single_element_list_matches_string(self): df1 = pd.DataFrame({"id": ["a", "b", "c"], "v1": [1, 2, 3]}) df2 = pd.DataFrame({"id": ["c", "a", "b"], "v2": [30, 10, 20]}) aligned_str = align_subjects([df1.copy(), df2.copy()], subject_id="id") - aligned_list = align_subjects( - [df1.copy(), df2.copy()], subject_id=["id"] - ) + aligned_list = align_subjects([df1.copy(), df2.copy()], subject_id=["id"]) for a, b in zip(aligned_str, aligned_list): pd.testing.assert_frame_equal(a, b) diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 69f30a9..b013cef 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -111,6 +111,7 @@ def test_explicit_override_bypasses_guard(self, figures_dir, caplog): assert len(produced) > 1 assert "scree.svg" in produced + class TestMultiIndexSubjectScores: """Integration: compound subject ID produces a two-level index in CSV.""" @@ -471,9 +472,7 @@ def test_facet_cols_moves_lv_to_rows(self, monkeypatch, tmp_path): def test_default_layout_threads_col_wrap(self, monkeypatch, tmp_path): """In the default layout (LV on columns, no facet) facet_col_wrap is passed through to control column wrapping.""" - config = GroupConfig( - groups=[GroupSpec("group", "x_axis", facet_col_wrap=3)] - ) + config = GroupConfig(groups=[GroupSpec("group", "x_axis", facet_col_wrap=3)]) calls = self._capture_boxstrip_calls(config, monkeypatch, tmp_path) assert calls[0]["col_col"] == "LV" assert calls[0]["row_col"] is None diff --git a/tests/test_plotting.py b/tests/test_plotting.py index 6850fe6..455868c 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -209,17 +209,21 @@ def test_box_and_strip_receive_same_palette_dict(self, tmp_output, monkeypatch): real_stripplot = sns.stripplot def spy_boxplot(*args, **kwargs): - captured["boxplot"].append({ - "palette": kwargs.get("palette"), - "hue_order": kwargs.get("hue_order"), - }) + captured["boxplot"].append( + { + "palette": kwargs.get("palette"), + "hue_order": kwargs.get("hue_order"), + } + ) return real_boxplot(*args, **kwargs) def spy_stripplot(*args, **kwargs): - captured["stripplot"].append({ - "palette": kwargs.get("palette"), - "hue_order": kwargs.get("hue_order"), - }) + captured["stripplot"].append( + { + "palette": kwargs.get("palette"), + "hue_order": kwargs.get("hue_order"), + } + ) return real_stripplot(*args, **kwargs) monkeypatch.setattr(plotting_mod.sns, "boxplot", spy_boxplot) @@ -272,7 +276,9 @@ def test_non_alphabetical_order_colours_match(self, tmp_output, monkeypatch): rows.append({"group": g, "score": rng.standard_normal(), "LV": "LV1"}) scores_df = pd.DataFrame(rows) scores_df["group"] = pd.Categorical( - scores_df["group"], categories=cat_order, ordered=True, + scores_df["group"], + categories=cat_order, + ordered=True, ) # Prevent plt.close so we can inspect the rendered figure @@ -306,9 +312,7 @@ def test_non_alphabetical_order_colours_match(self, tmp_output, monkeypatch): continue for offset, fc in zip(offsets, fcs): x_pos = round(offset[0]) - strip_colours.setdefault(x_pos, set()).add( - tuple(fc[:3].round(4)) - ) + strip_colours.setdefault(x_pos, set()).add(tuple(fc[:3].round(4))) # Each box patch colour should match the strip colour at the # same position. @@ -341,7 +345,9 @@ def _facet_df(self): } ) df = pd.DataFrame(rows) - df["group"] = pd.Categorical(df["group"], categories=["A", "B", "C"], ordered=True) + df["group"] = pd.Categorical( + df["group"], categories=["A", "B", "C"], ordered=True + ) return df def test_row_col_produces_grid_rows(self, tmp_output, monkeypatch):