Skip to content

Commit 28e4310

Browse files
cdleonardlackhove
andauthored
plugin: override CoveragePlugin.configure to determine data_file (#23)
This is the right way to handle configuration, as supported by the coveragepy plugin API. The previous approach of creating a dummy Coverage object breaks enabling coveragepy via pth. The Coverage.__init__ functions calls _prevent_sub_process_measurement which sets _auto_save to False on the Coverage object created from pth. Fixes #22 --------- Co-authored-by: Kilian Lackhove <kilian@lackhove.de>
1 parent fae85a5 commit 28e4310

File tree

4 files changed

+107
-11
lines changed

4 files changed

+107
-11
lines changed

coverage_sh/plugin.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from random import Random
1717
from socket import gethostname
1818
from time import sleep
19-
from typing import TYPE_CHECKING, Any
19+
from typing import TYPE_CHECKING, Any, cast
2020

2121
import coverage
2222
import magic
@@ -27,7 +27,7 @@
2727
if TYPE_CHECKING:
2828
from collections.abc import Iterable, Iterator
2929

30-
from coverage.types import TLineNo
30+
from coverage.types import TConfigurable, TLineNo
3131
from tree_sitter import Node
3232

3333
LineData = dict[str, set[int]]
@@ -307,9 +307,11 @@ def _iterdir(path: Path) -> Iterator[Path]:
307307
class ShellPlugin(CoveragePlugin):
308308
def __init__(self, options: dict[str, Any]):
309309
self.options = options
310-
self._helper_path = None
310+
self._helper_path: None | Path = None
311311

312-
coverage_data_path = Path(coverage.Coverage().config.data_file).absolute()
312+
def configure(self, config: TConfigurable) -> None:
313+
data_file_option = config.get_option("run:data_file")
314+
coverage_data_path = Path(cast("str", data_file_option)).absolute()
313315

314316
if self.options.get("cover_always", False):
315317
parser_thread = CoverageParserThread(
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
#! /usr/bin/env python3
2+
print("Hello from inner python")

tests/resources/testproject/test.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@
77

88
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
99
"${SCRIPT_DIR}"/syntax_example.sh
10+
"${SCRIPT_DIR}"/inner.py

tests/test_plugin.py

Lines changed: 98 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
11
# SPDX-License-Identifier: MIT
22
# Copyright (c) 2023-2024 Kilian Lackhove
33
import json
4+
import os
45
import re
56
import subprocess
67
import sys
78
import threading
89
from collections import defaultdict
10+
from importlib.metadata import version
911
from pathlib import Path
1012
from socket import gethostname
1113
from time import sleep
1214
from typing import cast
1315

1416
import coverage
1517
import pytest
18+
from coverage.config import CoverageConfig
19+
from packaging.version import Version
1620

1721
from coverage_sh.plugin import (
1822
CoverageParserThread,
@@ -127,6 +131,11 @@
127131
"/home/dummy_user/dummy_dir_b": {10},
128132
}
129133

134+
#: expected output of testproject/test.sh
135+
END2END_STDOUT = SYNTAX_EXAMPLE_STDOUT + "Hello from inner python\n"
136+
#: lines executed inside inner.py
137+
INNER_PY_EXECUTED_LINES = [2]
138+
130139

131140
@pytest.fixture()
132141
def examples_dir(resources_dir):
@@ -165,7 +174,8 @@ def test_end2end(
165174
timeout=2,
166175
)
167176
assert proc.stderr == ""
168-
assert proc.stdout == SYNTAX_EXAMPLE_STDOUT
177+
assert proc.stdout == END2END_STDOUT
178+
assert proc.returncode == 0
169179

170180
assert Path(".coverage").is_file()
171181
assert len(list(Path().glob(f".coverage.sh.{gethostname()}.*"))) == 1
@@ -176,7 +186,7 @@ def test_end2end(
176186
subprocess.check_call([sys.executable, "-m", "coverage", "json"])
177187

178188
coverage_json = json.loads(Path("coverage.json").read_text())
179-
assert coverage_json["files"]["test.sh"]["executed_lines"] == [8, 9]
189+
assert coverage_json["files"]["test.sh"]["executed_lines"] == [8, 9, 10]
180190
assert coverage_json["files"]["syntax_example.sh"]["excluded_lines"] == []
181191
assert (
182192
coverage_json["files"]["syntax_example.sh"]["executed_lines"]
@@ -188,6 +198,62 @@ def test_end2end(
188198
)
189199

190200

201+
@pytest.fixture(scope="session")
202+
def covpy_installs_pth_at_install_time() -> None: # noqa: PT004
203+
"""Skip if coveragepy does not install a .pth file into site-packages at install time.
204+
205+
- >= 7.13.0: writes the .pth file during pip install — reliable in all environments.
206+
- >= 7.9.0: installs it dynamically at runtime — fails if site-packages is read-only.
207+
- < 7.9.0: no ``patch`` config option at all.
208+
209+
We skip below 7.13.0 to ensure reliability in all environments, even though the
210+
underlying fix would also work on 7.9.x-7.12.x given a writable site-packages.
211+
"""
212+
if Version(version("coverage")) < Version("7.13.0"):
213+
pytest.skip(
214+
"coverage < 7.13.0 does not install .pth file at install time"
215+
) # pragma: no cover
216+
217+
218+
@pytest.mark.usefixtures("covpy_installs_pth_at_install_time")
219+
def test_end2end_python_subprocess_via_shell(
220+
dummy_project_dir: Path,
221+
monkeypatch: pytest.MonkeyPatch,
222+
) -> None:
223+
"""Test that a Python script invoked by a shell script is measured correctly.
224+
225+
Call chain: ``coverage run main.py`` -> ``test.sh`` -> ``inner.py``.
226+
coverage-sh handles shell script measurement; ``inner.py`` is measured via
227+
coveragepy's subprocess mechanism, which requires:
228+
- ``patch = ["subprocess"]``: patches the ``subprocess`` module so spawned Python
229+
processes inherit coverage measurement.
230+
- A ``.pth`` file in site-packages: activates coverage in child processes via
231+
``coverage.process_startup()``.
232+
"""
233+
monkeypatch.chdir(dummy_project_dir)
234+
pyproject_text = """\
235+
[tool.coverage.run]
236+
plugins = ["coverage_sh"]
237+
patch = ["subprocess"]
238+
"""
239+
Path("pyproject.toml").write_text(pyproject_text)
240+
proc = subprocess.run(
241+
[sys.executable, "-m", "coverage", "run", "main.py"],
242+
capture_output=True,
243+
text=True,
244+
check=True,
245+
timeout=2,
246+
)
247+
assert proc.stderr == ""
248+
assert proc.stdout == END2END_STDOUT
249+
subprocess.check_call([sys.executable, "-m", "coverage", "combine"])
250+
subprocess.check_call([sys.executable, "-m", "coverage", "json"])
251+
coverage_json = json.loads(Path("coverage.json").read_text())
252+
assert (
253+
coverage_json["files"]["inner.py"]["executed_lines"] == INNER_PY_EXECUTED_LINES
254+
)
255+
256+
191257
class TestShellFileReporter:
192258
@pytest.fixture()
193259
def reporter(self, syntax_example_path):
@@ -380,7 +446,7 @@ def test_call_should_execute_example(
380446
assert proc.stderr is not None
381447
assert proc.stderr.read() == ""
382448
assert proc.stdout is not None
383-
assert proc.stdout.read() == SYNTAX_EXAMPLE_STDOUT
449+
assert proc.stdout.read() == END2END_STDOUT
384450

385451

386452
class TestMonitorThread:
@@ -404,10 +470,6 @@ def test_run_should_wait_for_main_thread_join(self, dummy_project_dir):
404470

405471

406472
class TestShellPlugin:
407-
def test_init_cover_always(self):
408-
plugin = ShellPlugin({"cover_always": True})
409-
del plugin
410-
411473
def test_file_tracer_should_return_None(self):
412474
plugin = ShellPlugin({})
413475
assert plugin.file_tracer("foobar") is None
@@ -443,3 +505,32 @@ def test_find_executable_files_should_find_symlinks(self, tmp_path):
443505
executable_files = plugin.find_executable_files(str(tmp_path))
444506

445507
assert set(executable_files) == {str(f) for f in (foo_file_path, foo_file_link)}
508+
509+
def test_configure_should_update_data_file_path(self) -> None:
510+
"""configure() should set PatchedPopen.data_file_path from the coverage config.
511+
512+
Verifies that calling configure() updates the shared data_file_path used by
513+
PatchedPopen to locate the coverage data file, replacing any previously set value.
514+
"""
515+
plugin = ShellPlugin({})
516+
old_data_file_path = Path("/old/value")
517+
PatchedPopen.data_file_path = old_data_file_path
518+
config = CoverageConfig()
519+
plugin.configure(config)
520+
assert PatchedPopen.data_file_path != old_data_file_path
521+
assert PatchedPopen.data_file_path.name == ".coverage"
522+
523+
def test_configure_should_set_bash_env_when_cover_always(
524+
self, monkeypatch: pytest.MonkeyPatch
525+
) -> None:
526+
"""configure() should set BASH_ENV when cover_always is enabled.
527+
528+
When cover_always=True, configure() must export BASH_ENV so that bash sources
529+
the coverage helper script automatically for every shell invocation, including
530+
those not spawned via subprocess.
531+
"""
532+
monkeypatch.delenv("BASH_ENV", raising=False)
533+
plugin = ShellPlugin({"cover_always": True})
534+
config = CoverageConfig()
535+
plugin.configure(config)
536+
assert os.getenv("BASH_ENV")

0 commit comments

Comments
 (0)