Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,13 @@ conflicts = [
[dependency-groups]
# version pins are in uv.lock
dev = [
"coverage==7.15.3",
"coverage==7.15.4",
"pyright==1.1.411",
"mypy==2.3.0",
"respx",
"pytest",
"pytest-asyncio",
"ruff==0.16.1",
"ruff==0.16.2",
"time-machine",
"dirty-equals>=0.6.0",
"importlib-metadata>=6.7.0",
Expand Down
76 changes: 9 additions & 67 deletions requirements-dev.lock
Original file line number Diff line number Diff line change
@@ -1,101 +1,43 @@
# This file was autogenerated by uv via the following command:
# uv export --frozen --no-hashes -o requirements-dev.lock
-e .
annotated-types==0.7.0
# via pydantic
annotated-types==0.8.0
anyio==4.14.2
# via
# httpx
# x-twitter-scraper
ast-serialize==0.6.0
# via mypy
ast-serialize==0.8.0
backports-asyncio-runner==1.2.0 ; python_full_version < '3.11'
# via pytest-asyncio
certifi==2026.6.17
# via
# httpcore
# httpx
certifi==2026.7.22
colorama==0.4.6 ; sys_platform == 'win32'
# via pytest
coverage==7.15.3
coverage==7.15.4
dirty-equals==0.11
distro==1.9.0
# via x-twitter-scraper
exceptiongroup==1.3.1 ; python_full_version < '3.11'
# via
# anyio
# pytest
execnet==2.1.2
# via pytest-xdist
h11==0.16.0
# via httpcore
httpcore==1.0.9
# via httpx
httpx==0.28.1
# via
# respx
# x-twitter-scraper
idna==3.18
# via
# anyio
# httpx
importlib-metadata==9.0.0
iniconfig==2.3.0
# via pytest
librt==0.13.0 ; platform_python_implementation != 'PyPy'
# via mypy
librt==0.15.0 ; platform_python_implementation != 'PyPy'
markdown-it-py==4.2.0
# via rich
mdurl==0.1.2
# via markdown-it-py
mypy==2.3.0
mypy-extensions==1.1.0
# via mypy
nodeenv==1.10.0
# via pyright
packaging==26.2
# via pytest
packaging==26.3
pathspec==1.1.1
# via mypy
pluggy==1.6.0
# via pytest
pydantic==2.13.4
# via x-twitter-scraper
pydantic-core==2.46.4
# via pydantic
pygments==2.20.0
# via
# pytest
# rich
pyright==1.1.411
pytest==9.1.1
# via
# pytest-asyncio
# pytest-xdist
pytest-asyncio==1.4.0
pytest-xdist==3.8.0
respx==0.23.1
rich==15.0.0
ruff==0.16.1
ruff==0.16.2
sniffio==1.3.1
# via x-twitter-scraper
time-machine==3.2.0
time-machine==3.4.0
tomli==2.4.1 ; python_full_version < '3.11'
# via
# mypy
# pytest
typing-extensions==4.16.0
# via
# anyio
# exceptiongroup
# mypy
# pydantic
# pydantic-core
# pyright
# pytest-asyncio
# typing-inspection
# x-twitter-scraper
typing-inspection==0.4.2
# via pydantic
typing-inspection==0.4.3
zipp==4.1.0
# via importlib-metadata
2 changes: 1 addition & 1 deletion scripts/bootstrap
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,4 @@ uv sync --locked --all-extras

echo "==> Exporting Python dependencies…"
# note: `--no-hashes` is required because of https://github.com/pypa/pip/issues/4995
uv export --frozen --no-hashes -o requirements-dev.lock
uv export --frozen --no-annotate --no-header --no-hashes -o requirements-dev.lock
63 changes: 20 additions & 43 deletions scripts/verify_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@
import json
from typing import cast
from pathlib import Path
from collections.abc import Mapping, Sequence
from collections.abc import Mapping

STATEMENT_MINIMUM = 90
BRANCH_MINIMUM = 80
THRESHOLDS = (
("Statement", "covered_lines", "num_statements", 90),
("Branch", "covered_branches", "num_branches", 80),
)


def _integer(mapping: Mapping[str, object], key: str) -> int:
Expand All @@ -23,55 +25,30 @@ def _integer(mapping: Mapping[str, object], key: str) -> int:
return value


def coverage_percent(covered: int, total: int) -> float:
if total <= 0:
raise ValueError("Coverage report must contain measurable items")
return covered * 100 / total


def coverage_passes(covered: int, total: int, minimum: int) -> bool:
return covered * 100 >= total * minimum


def verify_coverage(report_path: Path) -> bool:
payload = cast(object, json.loads(report_path.read_text(encoding="utf-8")))
if not isinstance(payload, dict):
if not isinstance(payload, Mapping):
raise ValueError("Coverage report root must be an object")
report = cast(dict[str, object], payload)
report = cast(Mapping[str, object], payload)
totals_value = report.get("totals")
if not isinstance(totals_value, dict):
if not isinstance(totals_value, Mapping):
raise ValueError("Coverage report must contain totals")
totals = cast(dict[str, object], totals_value)

metrics = (
(
"Statement",
_integer(totals, "covered_lines"),
_integer(totals, "num_statements"),
STATEMENT_MINIMUM,
),
(
"Branch",
_integer(totals, "covered_branches"),
_integer(totals, "num_branches"),
BRANCH_MINIMUM,
),
)
totals = cast(Mapping[str, object], totals_value)

passed = True
for label, covered, total, minimum in metrics:
percent = coverage_percent(covered, total)
for label, covered_key, total_key, minimum in THRESHOLDS:
covered = _integer(totals, covered_key)
total = _integer(totals, total_key)
if total <= 0:
raise ValueError("Coverage report must contain measurable items")
percent = covered * 100 / total
print(f"{label} coverage: {covered}/{total} ({percent:.2f}%); minimum {minimum:.2f}%")
passed = coverage_passes(covered, total, minimum) and passed
passed = covered * 100 >= total * minimum and passed
return passed


def main(argv: Sequence[str]) -> int:
if len(argv) != 2:
print("usage: verify_coverage.py COVERAGE_JSON", file=sys.stderr)
return 2
return 0 if verify_coverage(Path(argv[1])) else 1


if __name__ == "__main__":
raise SystemExit(main(sys.argv))
if len(sys.argv) != 2:
print("usage: verify_coverage.py COVERAGE_JSON", file=sys.stderr)
raise SystemExit(2)
raise SystemExit(0 if verify_coverage(Path(sys.argv[1])) else 1)
22 changes: 5 additions & 17 deletions tests/test_coverage_verifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

import pytest

from scripts.verify_coverage import coverage_passes, verify_coverage, coverage_percent
from scripts.verify_coverage import verify_coverage


def write_report(path: Path, *, lines: tuple[int, int], branches: tuple[int, int]) -> None:
Expand All @@ -28,23 +28,11 @@ def write_report(path: Path, *, lines: tuple[int, int], branches: tuple[int, int
)


def test_coverage_thresholds_are_inclusive() -> None:
assert coverage_percent(9, 10) == 90
assert coverage_passes(9, 10, 90)
assert coverage_passes(8, 10, 80)
assert not coverage_passes(7, 10, 80)


def test_verify_coverage_accepts_gold_thresholds(tmp_path: Path) -> None:
report_path = tmp_path / "coverage.json"
write_report(report_path, lines=(90, 100), branches=(80, 100))
assert verify_coverage(report_path)


def test_verify_coverage_rejects_low_branch_coverage(tmp_path: Path) -> None:
@pytest.mark.parametrize(("branches", "expected"), [((80, 100), True), ((79, 100), False)])
def test_verify_coverage_enforces_thresholds(tmp_path: Path, branches: tuple[int, int], expected: bool) -> None:
report_path = tmp_path / "coverage.json"
write_report(report_path, lines=(95, 100), branches=(79, 100))
assert not verify_coverage(report_path)
write_report(report_path, lines=(90, 100), branches=branches)
assert verify_coverage(report_path) is expected


def test_verify_coverage_rejects_invalid_reports(tmp_path: Path) -> None:
Expand Down
Loading