From a659b0ba5f9876e735609caa680e524272b4652a Mon Sep 17 00:00:00 2001 From: Alexey Shalaev <75322386+AlexeyShalaev@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:32:48 +0300 Subject: [PATCH] fix: the generated ruff config runs the rules it selects `DTZ` and `ANN` sat in `select` and in `ignore` at once, so a generated project shipped a lint file that promised three timezone rules and the whole annotation family and ran neither. A naive `datetime.now()`, a `utcnow()` and an unannotated def passed `make check` without a word. `DTZ` is the family nothing else covers, and this org computes deadlines, retention windows and TTLs from timestamps, so it comes on rather than out of `select`. Annotations mypy already enforces for the package; keeping `ANN` on adds the coverage mypy has no view of, since it reads the package and never `tests/`. The entries that only ever meant "except in tests" now say so: `S101`, `S105` and `S106` move into a `per-file-ignores` block for `tests/**/*.py`, which leaves library code held to them. `S104` stays global -- library code does bind all interfaces. `ANN401` and `ANN204` stay and finally mean something. Measured against the org's libraries before changing anything: `DTZ` reports nothing in `deadline-budget`, `clientwright`, `servicewright` or `sqlalchemy-foundation-kit`, in library code or tests. The whole cost of the new set is five narrowing asserts in `clientwright` and fifteen missing test annotations across three repositories. CI gets the check that was missing: a rule selected and then ignored looks exactly like a rule that works, so the rendered project is now fed the mistakes those rules exist to catch and has to report them. Closes #7 --- .github/workflows/ci.yml | 29 +++++++++++++++++++++++++++++ template/CONTRIBUTING.md.jinja | 2 ++ template/pyproject.toml.jinja | 12 +++++++++++- 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96b2d7a..f8748ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,35 @@ jobs: make test-unit uv build + # A rule that is selected and then ignored looks exactly like a rule that + # works: the gate above stays green either way. Hand the generated project + # the mistakes the selected rules exist to catch and make it say so, and an + # assert in tests/ that has to stay accepted. + - name: The generated config runs the rules it selects + working-directory: ${{ runner.temp }}/demo-lib + run: | + cat > demo_lib/probe.py <<'PY' + import datetime + + + def stamp(offset): + return datetime.datetime.now() + offset + PY + cat > tests/unit/test_probe.py <<'PY' + def test__assert__still_reads_as_a_test() -> None: + assert True + PY + report=$(uv run ruff check --output-format concise demo_lib/probe.py || true) + echo "$report" + for rule in DTZ005 ANN201 ANN001; do + case "$report" in + *"$rule"*) ;; + *) echo "::error::$rule never fired -- the config selects the rule and then ignores it"; exit 1 ;; + esac + done + uv run ruff check tests/unit/test_probe.py + rm demo_lib/probe.py tests/unit/test_probe.py + all-checks-passed: name: All checks passed if: always() diff --git a/template/CONTRIBUTING.md.jinja b/template/CONTRIBUTING.md.jinja index 46d2f74..6d1e4e0 100644 --- a/template/CONTRIBUTING.md.jinja +++ b/template/CONTRIBUTING.md.jinja @@ -26,6 +26,8 @@ make test # full suite with 90% coverage threshold - **Docstrings** on public API only — Google style - **Line length** — 120 characters (ruff enforced) - **Quotes** — double quotes (ruff enforced) +- **Timezone-aware datetimes** — `datetime.now(tz=...)`, never `utcnow()` (ruff `DTZ`) +- **`assert` in tests only** — library code raises instead (ruff `S101`) - **No comments** unless the *why* is non-obvious ## Commit messages diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja index e7f2284..9c60bdf 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -86,8 +86,18 @@ target-version = "py{{ python_min_version | replace('.', '') }}" extend-exclude = ["*.md"] [tool.ruff.lint] +# Everything selected here runs. An entry below names one rule the house style +# disagrees with -- never a whole family that `select` has just asked for, which +# reads as a promise the linter does not keep. select = ["F", "E", "W", "I", "B", "N", "S", "C4", "DTZ", "SIM", "TRY", "PERF", "RUF", "UP", "ANN", "T20", "PTH", "PLC", "PLE", "PLW"] -ignore = ["TRY003", "S101", "ANN401", "RUF012", "S104", "S105", "S106", "ANN204", "DTZ", "ANN", "N802", "PERF401", "SIM105", "S607"] +ignore = ["TRY003", "ANN401", "RUF012", "S104", "ANN204", "N802", "PERF401", "SIM105", "S607"] + +# Tests are held to a different standard from the library they exercise: +# `assert` is what a test is made of, and a credential in a fixture is a literal +# rather than a leak. Annotations are not on this list -- mypy reads +# {{ package_name }}/ and never tests/, so ruff is the only thing holding that line here. +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = ["S101", "S105", "S106"] [tool.ruff.format] quote-style = "double"