Skip to content

Commit feacc78

Browse files
committed
[CI] Add Python code coverage to test-other and modernize tooling
Revive and modernize Python code coverage collection, integrating it into the CircleCI `test-other` suite. 1. In `tools/emcoverage.py`, strip incoming Python interpreter flags (such as `-E` passed by compiler entry point wrappers) and resolve bare command names to their `.py` equivalents in the root directory. 2. In `tools/config.py`, normalize `EMSDK_PYTHON` to an absolute path rather than clobbering it with `sys.executable`, which prevented child processes from executing under custom wrappers. 3. In `pyproject.toml`, enable parallel coverage measurement and multiprocessing concurrency, and configure omit patterns and report settings. 4. Add `diff-cover` to `requirements-dev.txt`. 5. In `.circleci/config.yml`, add an optional `coverage` parameter to `run-tests` and `run-tests-linux`. Enable coverage in `test-other`, upload HTML and XML reports as artifacts, and run `diff-cover` on PRs.
1 parent 2b6e96a commit feacc78

5 files changed

Lines changed: 97 additions & 24 deletions

File tree

‎.circleci/config.yml‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,15 +313,45 @@ commands:
313313
description: "Extra EMCC_CFLAGS"
314314
type: string
315315
default: ""
316+
coverage:
317+
description: "Whether to collect python code coverage"
318+
type: boolean
319+
default: false
316320
steps:
321+
- when:
322+
condition: << parameters.coverage >>
323+
steps:
324+
- run:
325+
name: reset coverage
326+
command: ./tools/emcoverage.py reset
317327
- run:
318328
name: run tests (<< parameters.title >>)
319329
environment:
320330
EMCC_CFLAGS: << parameters.extra-cflags >>
321331
command: |
322332
env
323-
./test/runner << parameters.test_targets >>
333+
if [ "<< parameters.coverage >>" = "true" ]; then
334+
./tools/emcoverage.py ./test/runner << parameters.test_targets >>
335+
else
336+
./test/runner << parameters.test_targets >>
337+
fi
324338
$EMSDK_PYTHON ./test/check_clean.py
339+
- when:
340+
condition: << parameters.coverage >>
341+
steps:
342+
- run:
343+
name: generate coverage report
344+
command: |
345+
./tools/emcoverage.py report
346+
./tools/emcoverage.py xml
347+
./tools/emcoverage.py html
348+
if [ -n "$CIRCLE_PULL_REQUEST" ]; then
349+
diff-cover coverage.xml --compare-branch=origin/main || true
350+
fi
351+
- store_artifacts:
352+
path: coverage.xml
353+
- store_artifacts:
354+
path: htmlcov
325355
freeze-cache:
326356
description: "Freeze emscripten cache"
327357
steps:
@@ -348,6 +378,10 @@ commands:
348378
description: "Name of given test suite"
349379
type: string
350380
default: ""
381+
coverage:
382+
description: "Whether to collect python code coverage"
383+
type: boolean
384+
default: false
351385
steps:
352386
- prepare-for-tests
353387
- when:
@@ -357,6 +391,7 @@ commands:
357391
- run-tests:
358392
test_targets: << parameters.test_targets >>
359393
title: << parameters.title >>
394+
coverage: << parameters.coverage >>
360395
- upload-test-results
361396
run-tests-chrome:
362397
description: "Runs browser tests under chrome"
@@ -1043,6 +1078,7 @@ jobs:
10431078
# some native-dependent tests fail because of the lack of native
10441079
# headers on emsdk-bundled clang
10451080
test_targets: "other jslib skip:other.test_native_link_error_message"
1081+
coverage: true
10461082
- run-tests-linux:
10471083
# Include a single test from test_benchmark.py to ensure it's always
10481084
# kept functional. Must be run separately from above because we cannot

‎pyproject.toml‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,13 +91,26 @@ lint.pylint.max-statements = 142 # Recommended: 50
9191

9292
[tool.coverage.run]
9393
source = [ "." ]
94+
parallel = true
95+
concurrency = [ "multiprocessing" ]
9496
omit = [
97+
"test/*",
9598
"./test/*",
99+
"third_party/*",
96100
"./third_party/*",
101+
"tools/emcoverage.py",
97102
"./tools/emcoverage.py",
98103
"test.py",
99104
]
100105

106+
[tool.coverage.report]
107+
show_missing = true
108+
exclude_lines = [
109+
"# pragma: no cover",
110+
"if __name__ == '__main__':",
111+
"raise NotImplementedError",
112+
]
113+
101114
[tool.mypy]
102115
mypy_path = "third_party/,third_party/ply,third_party/websockify"
103116
files = [ "." ]

‎requirements-dev.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
# Install with `pip3 install -r requirements-dev.txt`
66

77
coverage[toml]==7.15.0
8+
diff-cover==10.6.0
89
mypy==2.3.0
910
psutil==7.0.0
1011
ruff==0.16.6

‎tools/config.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,9 @@ def normalize_relative_python_path():
7070
# libraries to cache. These sub-emcc tasks will run in a different CWD, so
7171
# reinitialize EMSDK_PYTHON here so that sub-tool spawns will use the same
7272
# Python interpreter as the parent.
73-
if os.environ.get('EMSDK_PYTHON'):
74-
os.environ['EMSDK_PYTHON'] = sys.executable
73+
python = os.environ.get('EMSDK_PYTHON')
74+
if python:
75+
os.environ['EMSDK_PYTHON'] = os.path.abspath(python)
7576

7677

7778
def set_config_from_tool_location(config_key, tool_binary, f):

‎tools/emcoverage.py‎

Lines changed: 43 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@
2929
"""
3030

3131
import contextlib
32-
import errno
3332
import os
3433
import shutil
3534
import sys
@@ -39,44 +38,67 @@
3938
import coverage.cmdline # type: ignore
4039

4140
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
41+
ROOT_DIR = os.path.dirname(SCRIPT_DIR)
4242

4343

4444
def main():
4545
# We set EMSDK_PYTHON to point to this file, which is executable via #! line.
4646
# Emscripten uses EMSDK_PYTHON to invoke all python subprocesses. By making this
4747
# script run all python subprocesses, all of them will execute under the
48-
# watchful eye of emcoverage.py, and resulting in their code coverage being
48+
# watchful eye of emcoverage.py, resulting in their code coverage being
4949
# tracked.
5050
os.environ['EMSDK_PYTHON'] = os.path.abspath(__file__)
5151

5252
store = os.path.join(SCRIPT_DIR, 'coverage')
5353

54-
if len(sys.argv) < 2 or sys.argv[1] == 'help':
54+
if len(sys.argv) < 2 or sys.argv[1] in {'help', '-h', '--help'}:
5555
print(__doc__.replace('emcoverage.py', sys.argv[0]).strip())
56-
return
56+
return 0
5757

5858
if sys.argv[1] == 'reset':
59-
shutil.rmtree(store)
60-
return
61-
62-
if sys.argv[1] in {'html', 'report', 'xml'}:
59+
shutil.rmtree(store, ignore_errors=True)
60+
with contextlib.suppress(FileNotFoundError):
61+
os.remove('.coverage')
62+
for f in glob('.coverage.*'):
63+
with contextlib.suppress(FileNotFoundError):
64+
os.remove(f)
65+
return 0
66+
67+
if sys.argv[1] in {'html', 'report', 'xml', 'annotate', 'json', 'lcov'}:
6368
old_argv = sys.argv
64-
sys.argv = ['coverage', 'combine', *glob(os.path.join(store, '*'))]
65-
with contextlib.suppress(SystemExit):
66-
coverage.cmdline.main()
67-
sys.argv = [*old_argv, '-i']
69+
files = glob(os.path.join(store, '*'))
70+
if files:
71+
sys.argv = ['coverage', 'combine', *files]
72+
with contextlib.suppress(SystemExit):
73+
coverage.cmdline.main()
74+
extra_args = []
75+
if '-i' not in old_argv and '--ignore-errors' not in old_argv:
76+
extra_args.append('-i')
77+
sys.argv = ['coverage', *old_argv[1:], *extra_args]
6878
return coverage.cmdline.main()
6979

70-
if not os.path.exists(sys.argv[1]):
71-
# If argv[1] is not a file path, instead try to interpret it as an emscripten command.
72-
# This allows `emcoverage.py emcc` or `emcoverage.py embuilder` to work.
73-
sys.argv[1] = os.path.join(os.path.dirname(sys.executable), '..', sys.argv[1] + '.py')
80+
# Strip python interpreter flags passed by launcher scripts (e.g. -E in emcc wrapper)
81+
while len(sys.argv) > 1 and sys.argv[1] in {'-E', '-B', '-O', '-s', '-S', '-u'}:
82+
sys.argv.pop(1)
83+
84+
if len(sys.argv) > 1 and sys.argv[1] in {'python', 'python3'}:
85+
sys.argv.pop(1)
86+
87+
if len(sys.argv) < 2:
88+
print('emcoverage.py: no command specified', file=sys.stderr)
89+
return 1
90+
91+
# If argv[1] is an emscripten command without .py extension, resolve it to root script.
92+
# This allows `emcoverage.py emcc` or `emcoverage.py embuilder` to work even though
93+
# executable shell wrappers exist in the repository root.
94+
if not sys.argv[1].endswith('.py'):
95+
candidate = os.path.join(ROOT_DIR, sys.argv[1] + '.py')
96+
if os.path.isfile(candidate):
97+
sys.argv[1] = candidate
98+
elif not os.path.exists(sys.argv[1]):
99+
sys.argv[1] = candidate
74100

75-
try:
76-
os.mkdir(store)
77-
except OSError as e:
78-
if e.errno != errno.EEXIST:
79-
raise
101+
os.makedirs(store, exist_ok=True)
80102
os.environ['COVERAGE_FILE'] = os.path.join(store, str(uuid.uuid4()))
81103
sys.argv[0:1] = ['coverage', 'run', '--parallel-mode', '--']
82104

0 commit comments

Comments
 (0)