11# SPDX-License-Identifier: MIT
22# Copyright (c) 2023-2024 Kilian Lackhove
33import json
4+ import os
45import re
56import subprocess
67import sys
78import threading
89from collections import defaultdict
10+ from importlib .metadata import version
911from pathlib import Path
1012from socket import gethostname
1113from time import sleep
1214from typing import cast
1315
1416import coverage
1517import pytest
18+ from coverage .config import CoverageConfig
19+ from packaging .version import Version
1620
1721from coverage_sh .plugin import (
1822 CoverageParserThread ,
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 ()
132141def 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+
191257class 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
386452class TestMonitorThread :
@@ -404,10 +470,6 @@ def test_run_should_wait_for_main_thread_join(self, dummy_project_dir):
404470
405471
406472class 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