test: 100% coverage on src/ via real tests + fail_under gate (#37)#38
Conversation
…il_under gate (closes #37) Closes the priority-low coverage gap surfaced during the independent code-review pass (issue #37). Five new tests, no `# pragma: no cover`, no `coverage_exclude_lines` — every previously-uncovered defensive line is now exercised by a real test. Per-line coverage delta on src/ (post-PR #36 baseline → here): __init__.py 60% → 100% (lines 7-8) __main__.py 97% → 100% (line 52) badge.py 100% (unchanged) collector.py 99% → 100% (line 190) config.py 96% → 100% (lines 42, 86) TOTAL 99% → 100% New tests (5 total): 1. tests/test_init.py (new file) :: test_init_falls_back_to_dev_version_when_package_not_installed — monkeypatches importlib.metadata.version to raise PackageNotFoundError, importlib.reload(pypi_winnow_downloads), asserts __version__ == "0.0.0+dev". Restores the real version() and reloads in finally to keep other tests' view of __version__ correct. 2. tests/test_main.py :: test_main_module_invokes_main_when_run_as_script — uses runpy.run_module("pypi_winnow_downloads", run_name="__main__") to trigger the guard at __main__.py:52 in-process, with collector.collect stubbed to return an empty CollectorResult and sys.argv set to a valid --config path so main() falls through cleanly. Patches collector.collect BEFORE runpy fires so __main__.py's `from .collector import collect` reads the stub. 3. tests/test_collector.py :: test_run_pypinfo_raises_on_non_integer_download_count — feeds run_pypinfo a row with download_count: "not-an-int" (string), asserts CollectorError("non-integer download_count") raised. Locks in collector.py:190. 4. tests/test_config.py :: test_load_config_rejects_non_mapping_service_value — `service: just-a-string` YAML, asserts ConfigError matching "'service' must be a mapping, got str". Locks in config.py:42 (the _require_field isinstance check). 5. tests/test_config.py :: test_load_config_rejects_non_list_packages_value — `packages: 42` YAML (non-list, non-null), asserts ConfigError matching "'packages' must be a list, got int". Distinct from the existing null-packages test which exercises a different branch. pyproject.toml gains: [tool.coverage.run] source = ["src/pypi_winnow_downloads"] [tool.coverage.report] fail_under = 100 show_missing = true Now `uv run pytest --cov` exits non-zero if any line in src/ is uncovered. CI's `test` job will fail fast on a coverage regression rather than silently letting it land — same shape as the strict-markers / strict-config pytest gates already in place. Per the user's standing rule, no exclusion mechanism is used to game the metric; if a future line is "hard to test", the answer is to write the test (as done here for the __main__ guard via runpy) or to delete the line as dead code. Local sweep: 71/71 pytest pass (was 66, 5 new), ruff/format/ mypy clean, coverage gate green at 100.00%.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
cmeans
left a comment
There was a problem hiding this comment.
QA round 1 — clean, no follow-ups
Issue #37 fully delivered. Reproduced locally:
| Pre-PR | Post-PR | File | New test |
|---|---|---|---|
| 60% (lines 7-8) | 100% | __init__.py |
test_init.py::test_init_falls_back_to_dev_version_when_package_not_installed — monkeypatch importlib.metadata.version to raise PackageNotFoundError, importlib.reload, assert __version__ == "0.0.0+dev". finally block restores real version + reloads so the rest of the suite isn't poisoned. |
| 97% (line 52) | 100% | __main__.py |
test_main.py::test_main_module_invokes_main_when_run_as_script — runpy.run_module(..., run_name="__main__") exercises the guard in-process. The stub-collect-then-runpy ordering (with the comment about why) is the correct fix to the runpy-vs-prior-import gotcha I called out in our earlier conversation. |
| 99% (line 190) | 100% | collector.py |
test_collector.py::test_run_pypinfo_raises_on_non_integer_download_count — feeds a row with "download_count": "not-an-int", asserts the exact CollectorError("non-integer download_count") message. |
| 96% (lines 42, 86) | 100% | config.py |
test_config.py::test_load_config_rejects_non_mapping_service_value — YAML with service: just-a-string, asserts ConfigError("'service' must be a mapping, got str"). test_load_config_rejects_non_list_packages_value — YAML with packages: 42, asserts ConfigError("'packages' must be a list, got int"). Distinct from the existing null-packages test (separate branch). |
| 99% | 100% | TOTAL (258 stmts, 0 miss) | 5 new tests |
fail_under = 100 gate verification:
Reproduced your local check from a different angle. Ran the suite with --deselect tests/test_init.py to drop two lines off the bottom; pytest reported:
TOTAL 258 2 99%
FAIL Required test coverage of 100.0% not reached. Total coverage: 99.22%
Gate fires at the right boundary — a coverage regression in CI will exit non-zero.
Standing-rule compliance:
| Standing rule | State |
|---|---|
feedback_use_intentions_for_scheduled_followups (no # pragma: no cover cheats) |
confirmed — git grep -nE "pragma:.*no.cover|coverage_exclude" returns only the two prose mentions in CHANGELOG and pyproject.toml comments explicitly disclaiming any annotation use. No actual exclusion mechanism in the codebase. |
Test count in PR body matches pytest output |
matches — PR body says 71/71, I observed 71 passed locally. |
| Coverage config focused on production source, not tests | [tool.coverage.run] source = ["src/pypi_winnow_downloads"] — correct shape. PR body's reasoning ("tests don't have defensive branches") is accurate. |
Local verification on head 0c3d2dc:
| Check | Result |
|---|---|
uv run pytest |
71 passed, 0 deselected, 0.23s |
| All 5 new tests run individually | 5 passed |
uv run pytest --cov --cov-report=term-missing |
0 missing on every src/ file; Required test coverage of 100.0% reached. Total coverage: 100.00% |
uv run ruff check, ruff format --check, mypy src |
all clean |
| CI on PR head | all SUCCESS (test 3.11/3.12/3.13, lint, typecheck, deploy-smoke, qa-approved, on-push) |
Note (not a finding): the runpy test emits one RuntimeWarning: 'pypi_winnow_downloads.__main__' found in sys.modules after import of package …. This is a known runpy interaction with test_main.py's top-level from pypi_winnow_downloads.__main__ import main import — sys.modules is already populated, then runpy re-executes. The warning is harmless (the test passes deterministically across the CI matrix, and coverage observes line 52 firing) and is the standard cost of testing __main__ guards via runpy. Could be filtered with a one-line filterwarnings = ['ignore:.*found in sys.modules.*:RuntimeWarning'] in pyproject.toml if the noise stays bothersome, but leaving it visible preserves signal for any other RuntimeWarning that might surface later. Operator's call.
No findings. Transitioning label to Ready for QA Signoff.
|
Applying |
Three mechanical edits: - pyproject.toml: version "0.1.0" -> "0.1.1" - CHANGELOG.md: insert `## [0.1.1] - 2026-04-26` directly under the (still empty) `## [Unreleased]` header so all 12 PRs' worth of bullets that have been accumulating since v0.1.0 ship are now categorized under the 0.1.1 release. Updated the link refs at the bottom: [Unreleased] now compares from v0.1.1, and a new [0.1.1] entry compares v0.1.0...v0.1.1. - uv.lock: refreshed by `uv lock` so the locked pypi-winnow-downloads version (0.1.1) matches pyproject.toml. What ships in v0.1.1 (highlights — full changelog under ## [0.1.1]): Library fixes (operator-visible): - collector: _write_health OSError no longer escapes per-package isolation. Disk-full / perm errors now produce structured `winnow-collect: ...; health file write failed: [Errno 28] No space left on device` exit instead of a raw traceback. Closes #32. - collector: stale_threshold_days is now actually consulted — the "warn if previous run is older than N days" feature documented in config.example.yaml since v0.1.0 finally fires. Log-only per the documented v1 contract; degrades silently on first-run / unreadable / malformed / future- timestamped previous _health.json. Closes #33. Documentation: - README acknowledgments / license / BigQuery dataset link refresh (PR #15) - README shields.io URL canonicalization (PR #27, closes #16) - deploy/README.md Tailscale Funnel as alternative HTTPS exposure (PR #22) - deploy/README.md "Pick an approach" table updated to reflect the new Caddy logging shape (in PR #30) CI / project infrastructure (no PyPI consumer impact, but hardens future releases): - Community health files: CONTRIBUTING / CoC / SECURITY / issue templates (PR #20) - .github/dependabot.yml across pip + github-actions + docker ecosystems (PR #21) - Dependabot PR hygiene cascade from cmeans/mcp-synology: PULL_REQUEST_TEMPLATE.md + auto-CHANGELOG workflow (App- token authenticated so required CI re-fires on the bot's HEAD SHA) + dependabot.yml prefix fix (PR #25). Validated end-to-end via the first two real Dependabot bumps PR #23 (codecov-action 5->6) and PR #24 (python 3.13-slim -> 3.14-slim). - deploy-smoke CI job that builds the Dockerfile, smokes the entrypoint, validates compose+Caddyfile against caddy:2 (PR #29, closes #7). Promoted to required status check on the main-protection ruleset 2026-04-26 22:43 (issue #31 closed via operator action). - deploy/caddy/Caddyfile.example gains global error logger + per-site access logger with built-in lumberjack rotation, documents the validate-as-root gotcha (PR #30). Live CT 112 deployment fixed in the same change. - 100% coverage on src/ via real tests (no `# pragma: no cover`), with `fail_under = 100` gate in pyproject.toml so future regressions trip CI (PR #38, closes #37). Verified locally: 71/71 pytest pass, ruff/format/mypy clean, coverage gate green at 100.00%. After this merges: 1. Tag the squash-merge commit as v0.1.1 and push the tag — publish.yml fires and uploads to PyPI via the existing trusted-publisher OIDC flow. 2. Update the live CT 112 deployment to install pypi-winnow-downloads==0.1.1 from PyPI (currently runs a wheel built from main, but pinning to the released version keeps deploy reproducible). 3. Close any post-release follow-ups Chris wants tracked. Co-authored-by: cmeans-claude-dev[bot] <272174644+cmeans-claude-dev[bot]@users.noreply.github.com>
Summary
Closes #37. Pushes statement coverage on
src/pypi_winnow_downloads/from 99% → 100%, then locks the gain withfail_under = 100inpyproject.tomlso future regressions trip CI immediately. No# pragma: no coverannotations, nocoverage_exclude_linespatterns — every previously-uncovered defensive line is exercised by a real test.Per-line coverage delta
__init__.pyPackageNotFoundErrorfallback)__main__.pyif __name__ == "__main__":guard)badge.pycollector.pydownload_countraise)config.pyNew tests
test_init_falls_back_to_dev_version_when_package_not_installedtests/test_init.py(new file)monkeypatch importlib.metadata.versionto raisePackageNotFoundError,importlib.reload, assert__version__ == "0.0.0+dev". Restores infinallyso other tests see the real version.test_main_module_invokes_main_when_run_as_scripttests/test_main.pyrunpy.run_module("pypi_winnow_downloads", run_name="__main__")triggers the guard at__main__.py:52in-process. Stubscollector.collectto return an emptyCollectorResultso the package runs without shelling out to pypinfo. Patches the stub BEFORErunpyso__main__.py'sfrom .collector import collectreads the stub.test_run_pypinfo_raises_on_non_integer_download_counttests/test_collector.pydownload_count: "not-an-int"; assertsCollectorError("non-integer download_count").test_load_config_rejects_non_mapping_service_valuetests/test_config.pyservice: just-a-string; assertsConfigError("'service' must be a mapping, got str").test_load_config_rejects_non_list_packages_valuetests/test_config.pypackages: 42; assertsConfigError("'packages' must be a list, got int"). Distinct from the existing null-packages test (separate branches).Coverage config added to
pyproject.toml```toml
[tool.coverage.run]
source = ["src/pypi_winnow_downloads"]
[tool.coverage.report]
fail_under = 100
show_missing = true
```
uv run pytest --covnow exits non-zero if any line insrc/is uncovered. CI'stestjob will fail fast on a coverage regression rather than silently letting it land — same shape as the existing--strict-markers/--strict-configpytest gates.Per the standing rule (
feedback_no_pragma_no_cover.md): no exclusion mechanism is used to game the metric. If a future line is "hard to test", the answer is to write the test (as done here for__main__:52viarunpy) or to delete the line as dead code.Test plan
uv run pytest --cov— 71/71 passed (5 new tests, was 66), coverage gate green at 100.00%uv run ruff check src/ tests/— cleanuv run ruff format --check src/ tests/— cleanuv run mypy src/pypi_winnow_downloads/— clean--cov-report=term-missingshows 0 missing on every src/ filefail_under = 100enforcement verified: temporarily commented one new test locally, pytest exited non-zero with "Required test coverage of 100.0% reached. Total coverage: <100" — gate worksOut of scope
--cov-branch) — current setup is statement coverage only. Branch coverage would catch the implicitelsearms of the newisinstancechecks; reasonable follow-up if the noise stays manageable.tests/themselves —[tool.coverage.run] sourcedeliberately excludes the test files, which is the standard practice. The previous report (without thesourcesetting) was including them.Closes #37