Skip to content

test: 100% coverage on src/ via real tests + fail_under gate (#37)#38

Merged
cmeans-claude-dev[bot] merged 1 commit into
mainfrom
chore/coverage-100
Apr 27, 2026
Merged

test: 100% coverage on src/ via real tests + fail_under gate (#37)#38
cmeans-claude-dev[bot] merged 1 commit into
mainfrom
chore/coverage-100

Conversation

@cmeans-claude-dev

@cmeans-claude-dev cmeans-claude-dev Bot commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #37. Pushes statement coverage on src/pypi_winnow_downloads/ from 99% → 100%, then locks the gain with fail_under = 100 in pyproject.toml so future regressions trip CI immediately. No # pragma: no cover annotations, no coverage_exclude_lines patterns — every previously-uncovered defensive line is exercised by a real test.

Per-line coverage delta

File Pre-PR Post-PR Lines added
__init__.py 60% 100% covers 7-8 (PackageNotFoundError fallback)
__main__.py 97% 100% covers 52 (if __name__ == "__main__": guard)
badge.py 100% 100% (unchanged)
collector.py 99% 100% covers 190 (non-int download_count raise)
config.py 96% 100% covers 42 + 86 (defensive isinstance checks)
TOTAL 99% 100% 5 lines covered

New tests

Test File Mechanism
test_init_falls_back_to_dev_version_when_package_not_installed tests/test_init.py (new file) monkeypatch importlib.metadata.version to raise PackageNotFoundError, importlib.reload, assert __version__ == "0.0.0+dev". Restores in finally so other tests see the real version.
test_main_module_invokes_main_when_run_as_script tests/test_main.py runpy.run_module("pypi_winnow_downloads", run_name="__main__") triggers the guard at __main__.py:52 in-process. Stubs collector.collect to return an empty CollectorResult so the package runs without shelling out to pypinfo. Patches the stub BEFORE runpy so __main__.py's from .collector import collect reads the stub.
test_run_pypinfo_raises_on_non_integer_download_count tests/test_collector.py Feeds a row with download_count: "not-an-int"; asserts CollectorError("non-integer download_count").
test_load_config_rejects_non_mapping_service_value tests/test_config.py YAML with service: just-a-string; asserts ConfigError("'service' must be a mapping, got str").
test_load_config_rejects_non_list_packages_value tests/test_config.py YAML with packages: 42; asserts ConfigError("'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 --cov now 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 existing --strict-markers / --strict-config pytest 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__:52 via runpy) or to delete the line as dead code.

Test plan

  • uv run pytest --cov71/71 passed (5 new tests, was 66), coverage gate green at 100.00%
  • uv run ruff check src/ tests/ — clean
  • uv run ruff format --check src/ tests/ — clean
  • uv run mypy src/pypi_winnow_downloads/ — clean
  • Per-line verification: --cov-report=term-missing shows 0 missing on every src/ file
  • fail_under = 100 enforcement verified: temporarily commented one new test locally, pytest exited non-zero with "Required test coverage of 100.0% reached. Total coverage: <100" — gate works
  • CI green on all jobs (lint, typecheck, test 3.11/3.12/3.13, deploy-smoke, codecov)

Out of scope

  • Branch coverage (--cov-branch) — current setup is statement coverage only. Branch coverage would catch the implicit else arms of the new isinstance checks; reasonable follow-up if the noise stays manageable.
  • Coverage of tests/ themselves — [tool.coverage.run] source deliberately excludes the test files, which is the standard practice. The previous report (without the source setting) was including them.

Closes #37

…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%.
@cmeans-claude-dev cmeans-claude-dev Bot added the Ready for QA Dev work complete — QA can begin review label Apr 27, 2026
@github-actions github-actions Bot added Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA and removed Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA labels Apr 27, 2026
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@cmeans cmeans added QA Active QA is actively reviewing; Dev should not push changes and removed Ready for QA Dev work complete — QA can begin review labels Apr 27, 2026

@cmeans cmeans left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_installedmonkeypatch 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_scriptrunpy.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.

@cmeans

cmeans commented Apr 27, 2026

Copy link
Copy Markdown
Owner

Applying Ready for QA Signoff — see review above. Issue #37 fully delivered: 100% coverage on src/ via real tests (zero pragma annotations confirmed via repo-wide grep), fail_under=100 gate verified to fire correctly when coverage regresses. All 5 previously-uncovered defensive lines exercised by tests targeting the exact code path (not incidental coverage). 71/71 tests, ruff/format/mypy clean, CI green. One harmless runpy RuntimeWarning noted as informational.

@cmeans cmeans added Ready for QA Signoff QA passed — ready for maintainer final review and merge and removed QA Active QA is actively reviewing; Dev should not push changes labels Apr 27, 2026

@cmeans cmeans left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@cmeans cmeans added QA Approved Manual QA testing completed and passed and removed Ready for QA Signoff QA passed — ready for maintainer final review and merge labels Apr 27, 2026
@cmeans-claude-dev cmeans-claude-dev Bot merged commit 8c75ddd into main Apr 27, 2026
40 checks passed
@cmeans-claude-dev cmeans-claude-dev Bot deleted the chore/coverage-100 branch April 27, 2026 03:59
cmeans-claude-dev Bot added a commit that referenced this pull request Apr 27, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

QA Approved Manual QA testing completed and passed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Push coverage from 99% → 100% on src/

2 participants