Skip to content

Commit 72a0f9e

Browse files
TLATERbradb423
andcommitted
rules/python: Rework built-in coveragepy support
[Coveragepy recently gained support for lcov output][nedbat/coveragepy#1289], which allows implementing full support for python coverage without relying on a downstream fork of that project Coveragepy actually must be invoked twice; One generating a `.coverage` database file, the other exporting the data. This means that it is incompatible with the old implementation. The fork of coveragepy previously intended for use with bazel circumvented this by just changing how coveragepy works, and never outputting that database - just outputting the lcov directly instead. If we'd like to use upstream coveragepy, this is of course not possible. The stub_template seems to be written with the idea of supporting other coverage tooling in mind, however it still hard-codes arguments specific to coveragepy. Instead, we think it makes sense to properly support one of them for now, and to rethink a more generic interface later - it will probably take specific scripting for each implementation of coverage in python anyway. As such, this patch rewrites the python stub template to fully support upstream coveragepy as a coverage tool, and reworks some of the logic around invoking python to do so more cleanly. Additional notes: - Python coverage will only work with Python 3.7+ with upstream coveragepy, since the first release with lcov support does not support earlier Python versions - this is unfortunate, but there is not much we can do downstream short of forking to resolve that. The stub template itself should still work with Python 2.4+. - Comments in the code claim to use `os.execv` for performance reasons. There may be a small overhead to `subprocess.call`, but it shouldn't be too impactful, especially considering the overhead in logic (written in Python) this involves - if this is indeed for performance reasons, this is probably a somewhat premature optimization. A colleauge helped dig through some history, finding 3bed4af as the source of this - if that commit is to believed, this is actually to resolve issues with signal handling, however that seems odd as well, since this calls arbitrary python applications, which in turn may use subprocesses again as well, and therefore break what that commit seems to attempt to fix. It's completely opaque to me why we put so much effort into trying to ensure we use `os.execv`. I've replicated the behavior and comments assuming it was correct previously, but the patch probably shouldn't land as-is - the comment explaining the use of `os.execv` is most likely misleading. --- [nedbat/coveragepy#1289]: nedbat/coveragepy#1289 Co-authored-by: Bradley Burns <[email protected]>
1 parent 464bac3 commit 72a0f9e

File tree

1 file changed

+96
-34
lines changed

1 file changed

+96
-34
lines changed

src/main/java/com/google/devtools/build/lib/bazel/rules/python/python_stub_template.txt

Lines changed: 96 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,71 @@ def Deduplicate(items):
281281
seen.add(it)
282282
yield it
283283

284+
def ExecuteFile(python_program, main_filename, args, env, module_space,
285+
coverage_tool=None, workspace=None):
286+
"""Executes the given python file using the various environment settings.
287+
288+
This will not return, and acts much like os.execv, except is much
289+
more restricted, and handles bazel-related edge cases.
290+
291+
Args:
292+
python_program: Path to the python binary to use for execution
293+
main_filename: The python file to execute
294+
args: Additional args to pass to the python file
295+
env: A dict of environment variables to set for the execution
296+
module_space: The module space/runfiles tree
297+
coverage_tool: The coverage tool to execute with
298+
workspace: The workspace to execute in. This is expected to be a
299+
directory under the runfiles tree, and will recursively
300+
delete the runfiles directory if set.
301+
"""
302+
# We want to use os.execv instead of subprocess.call, which causes
303+
# problems with signal passing (making it difficult to kill
304+
# bazel). However, these conditions force us to run via
305+
# subprocess.call instead:
306+
#
307+
# - On Windows, os.execv doesn't handle arguments with spaces
308+
# correctly, and it actually starts a subprocess just like
309+
# subprocess.call.
310+
# - When running in a workspace (i.e., if we're running from a zip),
311+
# we need to clean up the workspace after the process finishes so
312+
# control must return here.
313+
# - If we may need to emit a host config warning after execution, we
314+
# can't execv because we need control to return here. This only
315+
# happens for targets built in the host config.
316+
# - For coverage targets, at least coveragepy requires running in
317+
# two invocations, which also requires control to return here.
318+
#
319+
if not (IsWindows() or workspace or %enable_host_version_warning% or coverage_tool):
320+
os.environ.update(env)
321+
os.execv(python_program, [python_program, main_filename] + args)
322+
323+
if coverage_tool is not None:
324+
# Coveragepy wants to frst create a .coverage database file, from
325+
# which we can then export lcov.
326+
subprocess.call(
327+
[python_program, coverage_tool, "run", "--append", "--branch", main_filename] + args,
328+
env=env,
329+
cwd=workspace
330+
)
331+
output_filename = os.environ.get('COVERAGE_DIR') + '/pylcov.dat'
332+
ret_code = subprocess.call(
333+
[python_program, coverage_tool, "lcov", "-o", output_filename] + args,
334+
env=env,
335+
cwd=workspace
336+
)
337+
else:
338+
ret_code = subprocess.call(
339+
[python_program, main_filename] + args,
340+
env=env,
341+
cwd=workspace
342+
)
343+
344+
if workspace:
345+
shutil.rmtree(os.path.dirname(module_space), True)
346+
MaybeEmitHostVersionWarning(ret_code)
347+
sys.exit(ret_code)
348+
284349
def Main():
285350
args = sys.argv[1:]
286351

@@ -332,54 +397,51 @@ def Main():
332397
if python_program is None:
333398
raise AssertionError('Could not find python binary: ' + PYTHON_BINARY)
334399

335-
cov_tool = os.environ.get('PYTHON_COVERAGE')
336-
if cov_tool:
337-
# Inhibit infinite recursion:
338-
del os.environ['PYTHON_COVERAGE']
400+
# COVERAGE_DIR is set iff the instrumentation is configured for the
401+
# file and coverage is enabled.
402+
if os.environ.get('COVERAGE_DIR'):
403+
if 'PYTHON_COVERAGE' in os.environ:
404+
cov_tool = os.environ.get('PYTHON_COVERAGE')
405+
else:
406+
raise EnvironmentError(
407+
'No python coverage tool set, '
408+
'set PYTHON_COVERAGE '
409+
'to configure the coverage tool'
410+
)
411+
339412
if not os.path.exists(cov_tool):
340413
raise EnvironmentError('Python coverage tool %s not found.' % cov_tool)
341-
args = [python_program, cov_tool, 'run', '-a', '--branch', main_filename] + args
414+
342415
# coverage library expects sys.path[0] to contain the library, and replaces
343416
# it with the directory of the program it starts. Our actual sys.path[0] is
344417
# the runfiles directory, which must not be replaced.
345418
# CoverageScript.do_execute() undoes this sys.path[0] setting.
346419
#
347420
# Update sys.path such that python finds the coverage package. The coverage
348421
# entry point is coverage.coverage_main, so we need to do twice the dirname.
349-
new_env['PYTHONPATH'] = \
350-
new_env['PYTHONPATH'] + ':' + os.path.dirname(os.path.dirname(cov_tool))
351-
new_env['PYTHON_LCOV_FILE'] = os.environ.get('COVERAGE_DIR') + '/pylcov.dat'
422+
new_env['PYTHONPATH'] = (
423+
new_env['PYTHONPATH'] + ':' + os.path.dirname(os.path.dirname(cov_tool))
424+
)
352425
else:
353-
args = [python_program, main_filename] + args
426+
cov_tool = None
354427

355-
os.environ.update(new_env)
428+
new_env.update((key, val) for key, val in os.environ.items() if key not in new_env)
429+
430+
workspace = None
431+
if IsRunningFromZip():
432+
# If RUN_UNDER_RUNFILES equals 1, it means we need to
433+
# change directory to the right runfiles directory.
434+
# (So that the data files are accessible)
435+
if os.environ.get('RUN_UNDER_RUNFILES') == '1':
436+
workspace = os.path.join(module_space, '%workspace_name%')
356437

357438
try:
358439
sys.stdout.flush()
359-
if IsRunningFromZip():
360-
# If RUN_UNDER_RUNFILES equals 1, it means we need to
361-
# change directory to the right runfiles directory.
362-
# (So that the data files are accessible)
363-
if os.environ.get('RUN_UNDER_RUNFILES') == '1':
364-
os.chdir(os.path.join(module_space, '%workspace_name%'))
365-
ret_code = subprocess.call(args)
366-
shutil.rmtree(os.path.dirname(module_space), True)
367-
MaybeEmitHostVersionWarning(ret_code)
368-
sys.exit(ret_code)
369-
else:
370-
# On Windows, os.execv doesn't handle arguments with spaces correctly,
371-
# and it actually starts a subprocess just like subprocess.call.
372-
#
373-
# If we may need to emit a host config warning after execution, don't
374-
# execv because we need control to return here. This only happens for
375-
# targets built in the host config, so other targets still get to take
376-
# advantage of the performance benefits of execv.
377-
if IsWindows() or %enable_host_version_warning%:
378-
ret_code = subprocess.call(args)
379-
MaybeEmitHostVersionWarning(ret_code)
380-
sys.exit(ret_code)
381-
else:
382-
os.execv(args[0], args)
440+
ExecuteFile(
441+
python_program, main_filename, args, new_env, module_space,
442+
cov_tool, workspace
443+
)
444+
383445
except EnvironmentError:
384446
# This works from Python 2.4 all the way to 3.x.
385447
e = sys.exc_info()[1]

0 commit comments

Comments
 (0)