diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index c56bdf39..6230850f 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -27,6 +27,46 @@ jobs: ruff check . --output-format=github ruff format --check . + complexity: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + # wily needs history to compare against the base; the gate itself + # only needs the working tree. + fetch-depth: 0 + - name: Set up Python 3.14 + uses: actions/setup-python@v6 + with: + python-version: "3.14" + cache: "pip" + - name: Install metrics tooling + # Versions pinned in the [metrics] extra so CI and the local + # pre-commit hook grade identically. + run: pip install -e .[metrics] + - name: Complexity gate + # Ratchet, not an aspiration: these are the tightest thresholds the + # package currently passes. A change that regresses complexity fails + # here with the offending block named. Mirrors the xenon pre-commit + # hook, so a contributor sees the same verdict before pushing. + run: xenon --max-absolute C --max-modules B --max-average A dataretrieval + - name: Complexity trend vs base + # Advisory: reports which files moved and by how much, so a reviewer + # can see direction rather than a pass/fail. Never fails the build -- + # the gate above is what blocks. + if: github.event_name == 'pull_request' + continue-on-error: true + run: | + base="${{ github.event.pull_request.base.sha }}" + git fetch --quiet origin "$base" || true + wily build dataretrieval --max-revisions 40 >/dev/null 2>&1 || true + echo '### Complexity trend' >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + wily diff dataretrieval --revision "$base" 2>&1 | tail -40 \ + >> "$GITHUB_STEP_SUMMARY" || echo 'no comparable revision' \ + >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + package-artifact: runs-on: ubuntu-latest steps: @@ -47,7 +87,6 @@ jobs: "$venv/bin/python" -m pip install "$GITHUB_WORKSPACE"/wheelhouse/*.whl cd "$RUNNER_TEMP" "$venv/bin/python" -I - <<'PY' - import importlib.util import os from importlib.resources import files from pathlib import Path @@ -60,8 +99,6 @@ jobs: checkout = Path(os.environ["GITHUB_WORKSPACE"]).resolve() installed = Path(dataretrieval.__file__).resolve() assert not installed.is_relative_to(checkout), (installed, checkout) - assert importlib.util.find_spec("dataretrieval.waterdata.api") is not None - assert importlib.util.find_spec("dataretrieval.ogc.engine") is not None assert files("dataretrieval").joinpath("py.typed").is_file() assert waterdata.get_daily assert ngwmn.get_sites diff --git a/.gitignore b/.gitignore index 182189e4..0b08fada 100644 --- a/.gitignore +++ b/.gitignore @@ -112,3 +112,6 @@ ENV/ # macOS *.DS_Store + +# wily metrics cache (rebuildable: `wily build dataretrieval`) +.wily/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8dd44167..9305a68d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -38,6 +38,31 @@ repos: pass_filenames: false additional_dependencies: [httpx, anyio] + # Complexity ratchet. Thresholds are the tightest the package currently + # passes, so this holds the line rather than demanding a refactor: no block + # above rank C (cyclomatic complexity 20), no module below B, average at A. + # Tightening a letter is a deliberate commit, and the failure names the + # offending block, so the fix is local. Applied to the package only -- + # tests legitimately contain long, branchy fixtures. + - repo: https://github.com/rubik/xenon + rev: v0.9.3 + hooks: + - id: xenon + # The package path is in args, not passed by pre-commit: --max-average + # and --max-modules are whole-package judgements, and computing them + # over only the changed files would let the average drift upward one + # commit at a time. ``files`` decides whether to run, not what to scan. + args: + - "--max-absolute" + - "C" + - "--max-modules" + - "B" + - "--max-average" + - "A" + - "dataretrieval" + files: ^dataretrieval/.*\.py$ + pass_filenames: false + # Strip cell outputs + execution_count from notebooks on commit so the # diff is the source, not the rendered run. Demos still execute fine # locally; clean commits keep PRs reviewable and avoid quota/timestamp diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 024d6192..573654c2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -103,8 +103,30 @@ ruff format --check . mypy coverage run -m pytest tests/ coverage report -m +xenon --max-absolute C --max-modules B --max-average A dataretrieval ``` +The last one is a complexity ratchet: those thresholds are the tightest the +package passes today, so it fails only when a change makes complexity worse. It +names the offending block, so the fix is local -- usually extracting a branch +rather than restructuring. Install it with `pip install -e .[metrics]`; the +`xenon` pre-commit hook runs the identical check, so a clean pre-commit run means +CI agrees. + +To see the *trend* rather than a pass/fail, that extra also installs +[`wily`](https://github.com/tonybaloney/wily), which indexes metrics across git +history: + +```bash +wily build dataretrieval --max-revisions 50 # index recent commits (slow, once) +wily report dataretrieval # how the package moved over time +wily diff dataretrieval --revision main # what your branch changed +wily rank dataretrieval maintainability.mi # worst-maintained files today +``` + +`wily` is advisory and is never a merge gate -- rising complexity in a file that +gained a genuinely complex feature is information, not a failure. + For documentation changes, install `.[doc,nldi]` and run `make html` from `docs/`. The broader `make docs` target also runs doctests and network-dependent link checking. diff --git a/pyproject.toml b/pyproject.toml index cec99697..efdbd6c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,14 @@ dataretrieval = ["py.typed"] type-check = [ "mypy", ] +# Complexity gates and history. ``xenon`` fails a build when complexity +# regresses; ``wily`` tracks the trend across commits so a review can say +# whether a change moved the codebase, not just whether it passed. Kept out of +# ``test`` so the test job stays lean -- the metrics job installs this instead. +metrics = [ + "xenon==0.9.3", + "wily==1.25.0", +] test = [ "pytest > 5.0.0", "pytest-cov[all]",