Skip to content

Commit 20bce70

Browse files
Merge pull request #22 from datalogics-kam/pdfcloud-5564-coverage
PDFCLOUD-5564 Better coverage support; fix coverage gaps
2 parents 73a3bcf + 3bc1513 commit 20bce70

21 files changed

Lines changed: 1200 additions & 233 deletions

.github/workflows/test-and-publish.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,37 @@ jobs:
3838
run: uvx nox --python ${{ matrix.python-version }} --session tests -- --no-parallel
3939
env:
4040
PDFREST_API_KEY: ${{ secrets.PDFREST_API_KEY }}
41+
- name: Fetch base branch for diff-cover
42+
if: github.event_name == 'pull_request'
43+
run: |
44+
if git rev-parse --is-shallow-repository | grep -q true; then
45+
git fetch --no-tags --prune origin ${{ github.base_ref }} --unshallow
46+
else
47+
git fetch --no-tags --prune origin ${{ github.base_ref }}
48+
fi
49+
- name: Run diff-cover (new code must be >= 90%)
50+
if: github.event_name == 'pull_request'
51+
run: >
52+
uv run diff-cover coverage/py${{ matrix.python-version }}/coverage.xml
53+
--compare-branch origin/${{ github.base_ref }}
54+
--fail-under 90
55+
--format markdown:coverage/py${{ matrix.python-version }}/diff-cover.md
56+
- name: Check client class function coverage
57+
run: >
58+
uv run python scripts/check_class_function_coverage.py
59+
coverage/py${{ matrix.python-version }}/coverage.json
60+
--class PdfRestClient
61+
--class AsyncPdfRestClient
62+
--class _FilesClient
63+
--class _AsyncFilesClient
64+
--fail-under 90
65+
--markdown-report coverage/py${{ matrix.python-version }}/class-function-coverage.md
66+
- name: Upload coverage reports
67+
if: always()
68+
uses: actions/upload-artifact@v4
69+
with:
70+
name: coverage-${{ matrix.python-version }}
71+
path: coverage/py${{ matrix.python-version }}
4172

4273
examples:
4374
name: Examples (Python ${{ matrix.python-version }})

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,3 +453,4 @@ $RECYCLE.BIN/
453453
*.speedscope.json
454454

455455
!pyrightconfig.json
456+
/coverage/

AGENTS.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,16 @@
1919
- `uv run pre-commit run --all-files` — enforce formatting and lint rules before
2020
pushing.
2121
- `uv run pytest` — execute the suite with the active interpreter.
22-
- `scripts/check_test_parity.sh` — run changed tests and report sync/async
23-
parity gaps (accepts optional base/head refs, defaults to
24-
`upstream/main..HEAD`).
2522
- `uv build` — produce wheels and sdists identical to the release workflow.
2623
- `uvx nox -s tests` — create matrix virtualenvs via nox and execute the pytest
2724
session.
2825
- `nox` executes pytest sessions with built-in parallelism; when invoking pytest
2926
directly use `pytest -n 8 --maxschedchunk 2` to mirror the parallel test
3027
scheduling and keep runtimes predictable.
28+
- Coverage reports (XML/Markdown/HTML) are produced by the nox `tests` session
29+
and stored under `coverage/py<version>/` (for example,
30+
`coverage/py3.12/coverage.xml`, `coverage/py3.12/coverage.md`,
31+
`coverage/py3.12/html/`).
3132

3233
## Coding Style & Naming Conventions
3334

@@ -138,6 +139,19 @@
138139
description with the reason and the follow-up plan; otherwise reviewers should
139140
block the change. Treat this as a release gate on par with unit tests.
140141

142+
- **Client coverage criteria:** `PdfRestClient` and `AsyncPdfRestClient` are
143+
customer-facing entry points and must retain high coverage. Every public
144+
client method must have at least one unit test that exercises the REST call
145+
path (MockTransport + request assertions), with distinct sync and async tests.
146+
Optional payload branches (`pages`, `output`, `rgb_color`, etc.) need explicit
147+
coverage so serialization regressions are caught.
148+
149+
- **Class function coverage scope:** The class coverage gate targets the main
150+
client-facing classes (`PdfRestClient`, `AsyncPdfRestClient`, `_FilesClient`,
151+
`_AsyncFilesClient`). For these classes, underscore-prefixed methods are
152+
intentionally in scope and should be covered as part of the interface
153+
contract.
154+
141155
- Write pytest tests: files named `test_*.py`, test functions `test_*`, fixtures
142156
in `conftest.py` where shared.
143157

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,11 @@ Run the test suite with:
3737
uv run pytest
3838
```
3939

40-
Check sync/async parity for changed tests (defaults to `upstream/main..HEAD`):
40+
Check per-function coverage for the client classes:
4141

4242
```bash
43-
scripts/check_test_parity.sh
43+
uvx nox -s class-coverage
4444
```
45+
46+
To reuse an existing `coverage/py<version>/coverage.json` without rerunning
47+
tests, add `-- --no-tests` (and optional `--coverage-json path`).

TESTING_GUIDELINES.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,23 @@ iteration required.
1313
request customization, validation failures, file helpers, and live calls. Do
1414
not hide the transport behind a parameter; the test name itself should reveal
1515
which client is under test.
16-
- **Check parity regularly.** Run `scripts/check_test_parity.sh` (defaults to
17-
`upstream/main..HEAD`) to spot missing sync/async counterparts, keeping
18-
parameterized test IDs aligned between transports.
16+
- **Maintain high client coverage.** `PdfRestClient` and `AsyncPdfRestClient`
17+
are the primary customer-facing entry points. Every public client method must
18+
have at least one unit test that exercises the REST call path (MockTransport
19+
asserting method/path/headers/body). Optional payload branches (for example,
20+
`pages`, `output`, `rgb_color`, and output-prefix fields) require explicit
21+
tests so serialization differences are caught early.
22+
- **Check client coverage regularly.** Run `uvx nox -s class-coverage` to
23+
enforce minimum function-level coverage for `PdfRestClient` and
24+
`AsyncPdfRestClient`.
1925
- **Exercise both sides of the contract.** Hermetic tests (via
2026
`httpx.MockTransport`) validate serialization and local validation. Live
2127
suites prove the server behaves the same way, including invalid literal
2228
handling.
29+
- **Know where coverage lands.** The nox `tests` session writes coverage reports
30+
to `coverage/py<version>/` (XML, Markdown, and HTML). Example:
31+
`coverage/py3.12/coverage.xml`, `coverage/py3.12/coverage.md`,
32+
`coverage/py3.12/html/`.
2333
- **Reset global state per test.** Use
2434
`monkeypatch.delenv("PDFREST_API_KEY", raising=False)` (or `setenv`) so
2535
clients never inherit accidental API keys. Patch `importlib.metadata.version`

noxfile.py

Lines changed: 118 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,79 @@
1717
PROJECT_ROOT = Path(__file__).resolve().parent
1818
DEFAULT_EXAMPLE_PYTHON = "3.11"
1919
EXAMPLES_DIR = PROJECT_ROOT / "examples"
20+
DEFAULT_COVERAGE_CLASSES = (
21+
"PdfRestClient",
22+
"AsyncPdfRestClient",
23+
"_FilesClient",
24+
"_AsyncFilesClient",
25+
)
26+
27+
28+
def _install_test_dependencies(session: nox.Session) -> None:
29+
_ = session.run_install(
30+
"uv",
31+
"sync",
32+
"--no-default-groups",
33+
"--group=dev",
34+
"--reinstall-package=pdfrest",
35+
f"--python={session.virtualenv.location}",
36+
env={"UV_PROJECT_ENVIRONMENT": session.virtualenv.location},
37+
)
38+
39+
40+
def _coverage_dir_for_session(session: nox.Session) -> Path:
41+
coverage_dir = PROJECT_ROOT / "coverage" / f"py{session.python}"
42+
coverage_dir.mkdir(parents=True, exist_ok=True)
43+
return coverage_dir
44+
45+
46+
def _pytest_args_from_session(session: nox.Session) -> list[str]:
47+
parser = argparse.ArgumentParser(add_help=False)
48+
_ = parser.add_argument("--no-parallel", action="store_true")
49+
_ = parser.add_argument("-n", "--workers", "--numprocesses")
50+
custom, remaining = parser.parse_known_args(session.posargs)
51+
52+
pytest_args = list(remaining)
53+
54+
if custom.no_parallel:
55+
return pytest_args
56+
if custom.workers:
57+
pytest_args[:0] = ["-n", custom.workers, "--maxschedchunk", "2"]
58+
else:
59+
pytest_args[:0] = ["-n", "8", "--maxschedchunk", "2"]
60+
61+
return pytest_args
62+
63+
64+
def _run_pytest_with_coverage(session: nox.Session, pytest_args: Iterable[str]) -> Path:
65+
coverage_dir = _coverage_dir_for_session(session)
66+
htmlcov_dir = coverage_dir / "html"
67+
xml_report = coverage_dir / "coverage.xml"
68+
md_report = coverage_dir / "coverage.md"
69+
json_report = coverage_dir / "coverage.json"
70+
_ = session.run(
71+
"pytest",
72+
"--cov=pdfrest",
73+
"--cov-report=term-missing",
74+
f"--cov-report=html:{htmlcov_dir}",
75+
f"--cov-report=xml:{xml_report}",
76+
f"--cov-report=markdown:{md_report}",
77+
f"--cov-report=json:{json_report}",
78+
*pytest_args,
79+
)
80+
return coverage_dir
81+
82+
83+
def _parse_class_values(values: Iterable[str]) -> list[str]:
84+
classes: list[str] = []
85+
for value in values:
86+
if not value:
87+
continue
88+
for item in value.split(","):
89+
item = item.strip()
90+
if item:
91+
classes.append(item)
92+
return classes
2093

2194

2295
@dataclass(frozen=True)
@@ -162,40 +235,61 @@ def _infer_python_version_from_path(script: Path) -> str | None:
162235

163236
@nox.session(name="tests", python=python_versions, reuse_venv=True)
164237
def tests(session: nox.Session) -> None:
165-
# Define only custom flags
238+
pytest_args = _pytest_args_from_session(session)
239+
240+
_install_test_dependencies(session)
241+
_ = _run_pytest_with_coverage(session, pytest_args)
242+
243+
244+
@nox.session(name="class-coverage", python=python_versions, reuse_venv=True)
245+
def class_coverage(session: nox.Session) -> None:
166246
parser = argparse.ArgumentParser(add_help=False)
167247
_ = parser.add_argument("--no-parallel", action="store_true")
168-
_ = parser.add_argument(
169-
"-n", "--workers", "--numprocesses"
170-
) # e.g., -n 4 to set workers
248+
_ = parser.add_argument("-n", "--workers", "--numprocesses")
249+
_ = parser.add_argument("--no-tests", action="store_true")
250+
_ = parser.add_argument("--coverage-json", type=Path, default=None)
251+
_ = parser.add_argument("--markdown-report", type=Path, default=None)
252+
_ = parser.add_argument("--fail-under", type=float, default=90.0)
253+
_ = parser.add_argument("--class", dest="classes", action="append", default=[])
254+
_ = parser.add_argument("--classes", dest="classes_csv", default="")
171255
custom, remaining = parser.parse_known_args(session.posargs)
172256

173257
pytest_args = list(remaining)
258+
if not custom.no_parallel:
259+
if custom.workers:
260+
pytest_args[:0] = ["-n", custom.workers, "--maxschedchunk", "2"]
261+
else:
262+
pytest_args[:0] = ["-n", "8", "--maxschedchunk", "2"]
174263

175-
# Default to parallel unless disabled or overridden
176-
if custom.no_parallel:
177-
pass
178-
elif custom.workers:
179-
pytest_args[:0] = ["-n", custom.workers, "--maxschedchunk", "2"]
264+
if custom.no_tests:
265+
coverage_dir = _coverage_dir_for_session(session)
180266
else:
181-
pytest_args[:0] = ["-n", "8", "--maxschedchunk", "2"]
267+
_install_test_dependencies(session)
268+
coverage_dir = _run_pytest_with_coverage(session, pytest_args)
182269

183-
_ = session.run_install(
184-
"uv",
185-
"sync",
186-
"--no-default-groups",
187-
"--group=dev",
188-
"--reinstall-package=pdfrest",
189-
f"--python={session.virtualenv.location}",
190-
env={"UV_PROJECT_ENVIRONMENT": session.virtualenv.location},
191-
)
192-
_ = session.run(
193-
"pytest",
194-
"--cov=pdfrest",
195-
"--cov-report=term-missing",
196-
*pytest_args,
270+
coverage_json = custom.coverage_json or (coverage_dir / "coverage.json")
271+
markdown_report = custom.markdown_report or (
272+
coverage_dir / "class-function-coverage.md"
197273
)
198274

275+
classes = _parse_class_values([*custom.classes, custom.classes_csv])
276+
if not classes:
277+
classes = list(DEFAULT_COVERAGE_CLASSES)
278+
279+
script_args = [
280+
"python",
281+
str(PROJECT_ROOT / "scripts" / "check_class_function_coverage.py"),
282+
str(coverage_json),
283+
"--fail-under",
284+
f"{custom.fail_under}",
285+
"--markdown-report",
286+
str(markdown_report),
287+
]
288+
for class_name in classes:
289+
script_args.extend(["--class", class_name])
290+
291+
_ = session.run(*script_args)
292+
199293

200294
@nox.session(name="examples", python=python_versions, reuse_venv=True)
201295
def run_examples(session: nox.Session) -> None:

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ dev = [
3434
"nox>=2025.5.1",
3535
"basedpyright>=1.34.0",
3636
"python-dotenv>=1.0.1",
37+
"diff-cover>=10.2.0",
3738
]
3839

3940
[tool.pytest.ini_options]

pyrightconfig.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
{
2323
"root": "src"
2424
},
25+
{
26+
"root": "scripts"
27+
},
2528
{
2629
"root": "examples",
2730
"pythonVersion": "3.11"

0 commit comments

Comments
 (0)