Skip to content

Commit b0f8003

Browse files
authored
Add run_in_pyodide_coverage (#183)
Add `@run_in_pyodide_coverage` which is a variant of `@run_in_pyodide` that includes coverage reports.
1 parent d0576ce commit b0f8003

8 files changed

Lines changed: 289 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
## [Unreleased]
2+
3+
- Add `run_in_pyodide_coverage()` function for coverage testing.
4+
[#183](https://github.com/pyodide/pytest-pyodide/pull/183)
5+
16
## [0.59.0] - 2026-02-27
27

38
### Changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ test = [
3232
"build",
3333
"requests",
3434
"selenium<4.21.0",
35+
"coverage",
3536
]
3637

3738
# pytest will look up `pytest11` entrypoints to find plugins

pytest_pyodide/_decorator_in_pyodide.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from base64 import b64decode, b64encode
2121
from inspect import isclass
2222
from io import BytesIO
23+
from pathlib import Path
2324
from typing import Any
2425

2526
import pyodide_js
@@ -144,4 +145,21 @@ def get_locals(frame):
144145
return (1, encode(e), repr(e))
145146

146147

148+
def start_coverage(coverage_args64):
149+
coverage_args = decode(coverage_args64)
150+
151+
import coverage
152+
153+
coverage = coverage.Coverage(**coverage_args)
154+
coverage.start()
155+
return coverage
156+
157+
158+
def end_coverage(coverage):
159+
coverage.stop()
160+
coverage.save()
161+
coverage_bytes = Path(".coverage").read_bytes()
162+
return b64encode(coverage_bytes).decode()
163+
164+
147165
__all__ = ["PyodideHandle", "encode"]

pytest_pyodide/decorator.py

Lines changed: 89 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
from collections.abc import Callable, Collection
77
from copy import deepcopy
88
from io import BytesIO
9+
from pathlib import Path
10+
from secrets import token_hex
11+
from textwrap import dedent, indent
912
from typing import Any, Protocol
1013

1114
from .copy_files_to_pyodide import copy_files_to_emscripten_fs
@@ -371,7 +374,7 @@ def __new__(cls, function: Callable[..., Any] | None = None, /, **kwargs):
371374
# @run_in_pyodide
372375
# def f():
373376
# pass
374-
return run_in_pyodide(**kwargs)(function)
377+
return cls(**kwargs)(function)
375378
# Just do normal __new__ behavior
376379
return object.__new__(cls)
377380

@@ -449,7 +452,8 @@ def _run(self, selenium: SeleniumType, args: tuple[Any, ...]):
449452
selenium.load_package(self._pkgs)
450453

451454
r = selenium.run_async(code)
452-
[status, result, repr] = r
455+
[status, result, repr, *extra] = r
456+
self._process_extra(*extra)
453457

454458
result = _decode(selenium, result, status, repr)
455459
if status:
@@ -462,18 +466,25 @@ def _code_template(self, args: tuple[Any, ...]) -> str:
462466
if the function is async await the result. Last, if there was an
463467
exception, pickle it and send it back.
464468
"""
469+
# Indent by 12 to match the indentation level of the body of __tmp()
470+
prelude = indent(self._get_code_prelude(), " " * 12)
471+
epilogue = indent(self._get_code_epilogue(), " " * 12)
472+
465473
return f"""
466474
async def __tmp():
467475
__tracebackhide__ = True
468476
469477
from pytest_pyodide.decorator import run_in_pyodide_main
470-
return run_in_pyodide_main(
478+
\n{prelude}
479+
result = await run_in_pyodide_main(
471480
{_encode(self._mod)!r},
472481
{_encode(args)!r},
473482
{self._module_filename!r},
474483
{self._func_name!r},
475484
{self._async_func!r},
476485
)
486+
\n{epilogue}
487+
return result
477488
478489
try:
479490
result = await __tmp()
@@ -482,6 +493,81 @@ async def __tmp():
482493
result
483494
"""
484495

496+
# Hooks for run_in_pyodide_coverage
497+
498+
def _get_code_prelude(self):
499+
return ""
500+
501+
def _get_code_epilogue(self):
502+
return ""
503+
504+
def _process_extra(self):
505+
"""Hook to handle any extra data computed.
506+
507+
_get_code_epilogue can add extra data to "result". That extra data will
508+
be passed to this function. See the overload in run_in_pyodide_coverage
509+
for how this is used.
510+
"""
511+
pass
512+
513+
514+
# * coverage_count ensures that multiple files from one test run don't overwrite
515+
# each other.
516+
# * coverage_hex prevents us from overwriting files generated by other test
517+
# runs.
518+
_COVERAGE_COUNT = 1
519+
_COVERAGE_HEX = token_hex(20)
520+
521+
522+
def _get_coverage_path() -> Path:
523+
global _COVERAGE_COUNT
524+
count = _COVERAGE_COUNT
525+
_COVERAGE_COUNT += 1
526+
return Path(f".coverage.emscripten.{count}.{_COVERAGE_HEX}")
527+
528+
529+
class run_in_pyodide_coverage(run_in_pyodide):
530+
def __init__(
531+
self,
532+
packages: Collection[str] = (),
533+
pytest_assert_rewrites: bool = True,
534+
*,
535+
_force_assert_rewrites: bool = False,
536+
coverage_args: dict[str, Any] | None = None,
537+
):
538+
super().__init__(
539+
packages,
540+
pytest_assert_rewrites,
541+
_force_assert_rewrites=_force_assert_rewrites,
542+
)
543+
self._pkgs.append("coverage")
544+
if coverage_args is None:
545+
coverage_args = {}
546+
self._coverage_args = coverage_args
547+
548+
def _get_code_prelude(self):
549+
"""Start coverage with the coverage_args passed from the host"""
550+
return dedent(
551+
f"""
552+
from pytest_pyodide.decorator import start_coverage
553+
coverage = start_coverage({_encode(self._coverage_args)!r})
554+
"""
555+
)
556+
557+
def _get_code_epilogue(self):
558+
"""Stop coverage and append the data to the result"""
559+
return dedent(
560+
"""
561+
from pytest_pyodide.decorator import end_coverage
562+
coverage_outdata = end_coverage(coverage)
563+
result = (*result, coverage_outdata)
564+
"""
565+
)
566+
567+
def _process_extra(self, coverage_out_binary): # type:ignore[override]
568+
"""Write coverage data to the file system"""
569+
_get_coverage_path().write_bytes(b64decode(coverage_out_binary))
570+
485571

486572
def copy_files_to_pyodide(file_list, install_wheels=True, recurse_directories=True):
487573
"""A decorator that copies files across to pyodide"""
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""Dummy math utilities for exercising run_in_pyodide_coverage."""
2+
3+
4+
def add(a, b):
5+
return a + b
6+
7+
8+
def multiply(a, b):
9+
return a * b
10+
11+
12+
def factorial(n):
13+
if n < 0:
14+
raise ValueError("factorial is not defined for negative numbers")
15+
result = 1
16+
for i in range(2, n + 1):
17+
result *= i
18+
return result
19+
20+
21+
def never_called():
22+
# This function is intentionally never called by the tests so that the
23+
# coverage report will flag it as uncovered.
24+
return "this line should be reported as not covered"
25+
26+
27+
def unreachable(): # pragma: no cover
28+
# This function should never be called by the tests and is excluded
29+
# from coverage reporting via the pragma above.
30+
raise RuntimeError("this function should never run")

tests/coverage-test/pyproject.toml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
[build-system]
2+
requires = ["hatchling"]
3+
build-backend = "hatchling.build"
4+
5+
[project]
6+
name = "dummy-pkg"
7+
version = "0.1.0"
8+
description = "Dummy package for exercising run_in_pyodide_coverage"
9+
requires-python = ">=3.10"
10+
11+
[tool.hatch.build.targets.wheel]
12+
packages = ["dummy_pkg"]
13+
14+
15+
[tool.coverage.paths]
16+
source = [
17+
"{COVERAGE_TEST_PATH}",
18+
"/lib/python*/site-packages",
19+
]

tests/test_decorator.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -167,22 +167,19 @@ def test_selenium4(selenium_standalone):
167167
]
168168

169169

170-
def test_local_fail_load_package(selenium_standalone):
170+
def test_local_fail_load_package(selenium_standalone, monkeypatch):
171171
selenium = selenium_standalone
172172

173-
def _load_package_error(*args, **kwargs):
173+
def load_package_error(*args, **kwargs):
174174
raise OSError("STOP!")
175175

176-
_load_package_original = selenium.load_package
177-
selenium.load_package = _load_package_error
176+
monkeypatch.setattr(selenium, "load_package", load_package_error)
178177

179178
exc = None
180179
try:
181180
example_func(selenium)
182181
except OSError:
183182
exc = pytest.ExceptionInfo.from_current()
184-
finally:
185-
selenium.load_package = _load_package_original
186183

187184
assert exc
188185
try:
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""End-to-end test for ``run_in_pyodide_coverage``.
2+
3+
Builds the dummy-pkg wheel from ``tests/coverage-test``, installs it into the
4+
selenium pyodide environment, then calls ``add``, ``multiply`` and
5+
``factorial`` each inside their own ``run_in_pyodide_coverage`` block. Finally,
6+
the emitted ``.coverage.emscripten.*`` files are combined and a coverage
7+
report is produced, which we assert contains the expected covered /
8+
not-covered lines (and that the ``# pragma: no cover`` line is excluded).
9+
"""
10+
11+
import json
12+
import subprocess
13+
import sys
14+
from pathlib import Path
15+
from pprint import pprint
16+
17+
from pytest_pyodide.decorator import run_in_pyodide_coverage
18+
from pytest_pyodide.server import spawn_web_server
19+
20+
COVERAGE_TEST_DIR = Path(__file__).parent / "coverage-test"
21+
DUMMY_PKG_FILE = COVERAGE_TEST_DIR / "dummy_pkg/__init__.py"
22+
COVERAGE_TEST_PYPROJECT_TOML = COVERAGE_TEST_DIR / "pyproject.toml"
23+
24+
25+
def _build_wheel(outdir: Path) -> Path:
26+
"""Build the dummy-pkg wheel into ``outdir`` and return its path."""
27+
subprocess.run(
28+
[
29+
sys.executable,
30+
"-m",
31+
"build",
32+
"--wheel",
33+
"--outdir",
34+
str(outdir),
35+
str(COVERAGE_TEST_DIR),
36+
],
37+
check=True,
38+
)
39+
wheels = list(outdir.glob("dummy_pkg-*.whl"))
40+
assert len(wheels) == 1, f"expected one wheel, got {wheels}"
41+
return wheels[0]
42+
43+
44+
def test_run_in_pyodide_coverage(selenium, tmp_path, monkeypatch):
45+
# tmp_path = Path("/home/hood/Documents/programming/pytest-pyodide/tests/tmp_path")
46+
# Build the dummy-pkg wheel and install it into pyodide.
47+
wheel = _build_wheel(tmp_path)
48+
with spawn_web_server(tmp_path) as server:
49+
hostname, port, _ = server
50+
selenium.load_package(f"http://{hostname}:{port}/{wheel.name}")
51+
52+
# Run everything from tmp_path so the `.coverage.emscripten.*` files
53+
# created by run_in_pyodide_coverage land in an isolated directory.
54+
monkeypatch.chdir(tmp_path)
55+
56+
@run_in_pyodide_coverage(coverage_args={"source_pkgs": ["dummy_pkg"]})
57+
def exercise_add(selenium):
58+
from dummy_pkg import add
59+
60+
assert add(2, 3) == 5
61+
62+
@run_in_pyodide_coverage(coverage_args={"source_pkgs": ["dummy_pkg"]})
63+
def exercise_multiply(selenium):
64+
from dummy_pkg import multiply
65+
66+
assert multiply(4, 5) == 20
67+
68+
@run_in_pyodide_coverage(coverage_args={"source_pkgs": ["dummy_pkg"]})
69+
def exercise_factorial(selenium):
70+
from dummy_pkg import factorial
71+
72+
assert factorial(5) == 120
73+
74+
exercise_add(selenium)
75+
exercise_multiply(selenium)
76+
exercise_factorial(selenium)
77+
78+
# Make sure coverage data files were actually written.
79+
coverage_files = list(tmp_path.glob(".coverage.emscripten.*"))
80+
assert len(coverage_files) == 3
81+
pyproject_toml = COVERAGE_TEST_PYPROJECT_TOML.read_text()
82+
pyproject_toml = pyproject_toml.format(
83+
COVERAGE_TEST_PATH=str(COVERAGE_TEST_DIR.absolute())
84+
)
85+
(tmp_path / "pyproject.toml").write_text(pyproject_toml)
86+
87+
# Combine the per-run files into a single .coverage database.
88+
subprocess.run(
89+
[sys.executable, "-m", "coverage", "combine"],
90+
check=True,
91+
cwd=tmp_path,
92+
)
93+
94+
# Produce a report restricted to dummy_pkg.
95+
result = subprocess.run(
96+
[
97+
sys.executable,
98+
"-m",
99+
"coverage",
100+
"json",
101+
],
102+
cwd=tmp_path,
103+
capture_output=True,
104+
text=True,
105+
check=True,
106+
)
107+
assert result.returncode == 0
108+
assert result.stdout == "Wrote JSON report to coverage.json\n"
109+
110+
coverage_json = json.loads((tmp_path / "coverage.json").read_text())
111+
file = coverage_json["files"][str(DUMMY_PKG_FILE.absolute())]
112+
functions = file["functions"]
113+
pprint(functions)
114+
115+
assert functions["add"]["missing_lines"] == []
116+
assert functions["add"]["excluded_lines"] == []
117+
assert functions["multiply"]["missing_lines"] == []
118+
assert functions["multiply"]["excluded_lines"] == []
119+
assert functions["factorial"]["missing_lines"] == [14]
120+
assert functions["factorial"]["excluded_lines"] == []
121+
assert functions["never_called"]["missing_lines"] == [24]
122+
assert functions["never_called"]["excluded_lines"] == []
123+
assert functions["unreachable"]["missing_lines"] == []
124+
assert functions["unreachable"]["excluded_lines"] == [30]

0 commit comments

Comments
 (0)