Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
5678919
🧪 Instrument C-extensions to collect coverage
webknjaz Jun 28, 2025
b27ede9
fixup! pathlib + cleanup
webknjaz Jun 29, 2025
1ce564b
fixup! state integration
webknjaz Jun 29, 2025
22dc02e
Add a gcovr config
webknjaz Jun 29, 2025
fa496c0
debug! ccache @ many(musl)linux
webknjaz Jun 29, 2025
5488bda
debug! PWD hack note
webknjaz Jun 29, 2025
fc79cc7
Skip copyping gcno if not debug
webknjaz Jun 29, 2025
33fd170
Enable debug builds in cibuildwheel
webknjaz Jun 29, 2025
b0173dd
Copy gcno into the build lib dir
webknjaz Jun 29, 2025
0551382
fixup! refactor
webknjaz Jun 30, 2025
20bf2d1
fixup! Drop LDFLAGS
webknjaz Jun 30, 2025
4d5e4a9
fixup! debug first
webknjaz Jun 30, 2025
7b8dccf
fixup! drop notes
webknjaz Jun 30, 2025
0ea9dfe
fixup! LDFLAGS
webknjaz Jul 1, 2025
7d5aac8
fixup! conditional CC
webknjaz Jul 1, 2025
28faa08
fixup! flags
webknjaz Jul 1, 2025
7e647fc
fixup! notes cleanup
webknjaz Jul 1, 2025
ccfb231
debug! gcovr @ cibuldwheel
webknjaz Jul 1, 2025
46fe558
experiment! move gcovr into the test job
webknjaz Jul 1, 2025
8f80c6a
experiment! only provision tracing env if ext has data
webknjaz Jul 1, 2025
17ef432
fixup! change debug build var order back
webknjaz Jul 1, 2025
c8b7e76
fixup! path typing
webknjaz Jul 1, 2025
0568bd5
debug! tracing files in CI
webknjaz Jul 1, 2025
f5bc5cb
debug! show multidict files in CI
webknjaz Jul 1, 2025
b5e647f
experiment! Copy tracing data in-tree while testing
webknjaz Jul 2, 2025
5432316
fixup! pip install gcovr
webknjaz Jul 2, 2025
014dcad
debug! Install ccache in debug runs
webknjaz Jul 2, 2025
cedeae0
fixup! privileges
webknjaz Jul 2, 2025
55f932b
Include gcovr config into sdist
webknjaz Jul 2, 2025
46f86bb
experiment! coverage dir
webknjaz Jul 2, 2025
60cefdc
experiment! explicitly pass gcda/gcno files to gcovr
webknjaz Jul 2, 2025
b9c6799
debug! Make gcovr verbose in CI
webknjaz Jul 2, 2025
bea0a92
debug! Log gcovr leftovers
webknjaz Jul 3, 2025
8bea87b
debug! log leftovers around workdir
webknjaz Jul 7, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,12 @@
run: >-
make clean
shell: bash
- name: Pre-install ccache for gcov [FIXME]
if: steps.build_type.outputs.build_type == 'source'
run: >-
sudo apt update -y
&& sudo apt install -y ccache
shell: bash
- name: Self-install (from ${{ steps.build_type.outputs.build_type }})
env:
MULTIDICT_NO_EXTENSIONS: ${{ matrix.no-extensions }}
Expand All @@ -365,12 +371,56 @@
&& '.'
|| steps.wheel-file.outputs.path
}}'
- name: Experimental display of multidict files [FIXME]
run: pip show multidict --files
- name: Run unittests
run: >-
python -Im pytest tests -v
--cov-report xml
--junitxml=.test-results/pytest/test.xml
--${{ matrix.no-extensions == 'Y' && 'no-' || '' }}c-extensions
- name: Experimentally find gcda/gcno [FIXME]
if: >-
!cancelled()
&& runner.os == 'Linux'
run: |
find . -name '*.gc*'
- name: Experimentally find gcda/gcno in site-packages [FIXME]
if: >-
!cancelled()
&& runner.os == 'Linux'
run: |
find "$(python -Ic 'import importlib.resources; print(str(importlib.resources.files("multidict")))')" -name '*.gc*'

Check failure on line 393 in .github/workflows/ci-cd.yml

View workflow job for this annotation

GitHub Actions / lint / Linter

393:81 [line-length] line too long (123 > 80 characters)
- name: Experimentally install Gcovr [FIXME]
if: >-
!cancelled()
&& runner.os == 'Linux'
run: |
pip install gcovr
- name: Experimentally make a coverage dir [FIXME]
if: >-
!cancelled()
&& runner.os == 'Linux'
run: |
mkdir -pv coverage
- name: Experimentally run Gcovr [FIXME]
if: >-
!cancelled()
&& runner.os == 'Linux'
run: |
gcovr --verbose
- name: See Gcovr leftovers at home [FIXME]
if: >-
!cancelled()
&& runner.os == 'Linux'
run: |
find ~/ -type f -name '*.gcov.json.gz'
- name: See Gcovr leftovers in workdir [FIXME]
if: >-
!cancelled()
&& runner.os == 'Linux'
run: |
find ../.. -type f -name '*.gcov.json.gz'
- name: Produce markdown test summary from JUnit
if: >-
!cancelled()
Expand Down
1 change: 1 addition & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
include .coveragerc
include gcovr.cfg
include pyproject.toml
include pytest.ini
include LICENSE
Expand Down
10 changes: 10 additions & 0 deletions gcovr.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
filter = multidict/

html-details = coverage/index.html

print-summary = yes

search-path = multidict/__tracing-data__/multidict/_multidict.gcda
search-path = multidict/__tracing-data__/multidict/_multidict.gcno

xml = coverage/coverage.xml
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,7 @@ enable = ["cpython-freethreading"]
[tool.cibuildwheel.linux]
# Re-enable 32-bit builds (disabled by default in cibuildwheel 3.0)
archs = ["auto", "auto32"]
before-all = "yum install -y libffi-devel || apk add --upgrade libffi-dev || apt-get install libffi-dev"
before-all = "yum install -y ccache libffi-devel || apk add --upgrade ccache libffi-dev || apt-get install ccache libffi-dev"

[tool.cibuildwheel.linux.environment]
MULTIDICT_DEBUG_BUILD = "1"
208 changes: 206 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import functools
import json
import os
import pathlib
import platform
import sys

from distutils import log
from setuptools import Extension, setup

NO_EXTENSIONS = bool(os.environ.get("MULTIDICT_NO_EXTENSIONS"))
Expand All @@ -11,8 +15,27 @@
NO_EXTENSIONS = True

CFLAGS = ["-O0", "-g3", "-UNDEBUG"] if DEBUG_BUILD else ["-O3", "-DNDEBUG"]
# https://gcc.gnu.org/onlinedocs/gcc/Gcov-Data-Files.html
# `-ftest-coverage` -> `.gcno` is in the same place as `.o` -> `{self.build_temp}/multidict`
# `-fprofile-dir` -> change the location of the `.gcda` file
#
# https://gcc.gnu.org/onlinedocs/gcc/Invoking-Gcov.html
# `-fkeep-inline-functions`
# `-fkeep-static-functions`
# `-fprofile-prefix-map=old=new`
# `--coverage` is an alias for `-fprofile-arcs -ftest-coverage` when compiling and `-lgcov` when linking; `-coverage` seems to be its synonym.
# `-fprofile-abs-path` makes `gcovr` more robust — https://gcovr.com/en/stable/guide/compiling.html#compiler-options
if DEBUG_BUILD:
CFLAGS.extend([
'--coverage',
'-fkeep-inline-functions',
'-fkeep-static-functions',
'-fprofile-abs-path',
])

if platform.system() != "Windows":
LDFLAGS = ['--coverage'] if DEBUG_BUILD else []

if platform.system() != "Windows" and False:
CFLAGS.extend(
[
"-std=c11",
Expand All @@ -25,20 +48,201 @@
]
)

if DEBUG_BUILD:
# https://gcovr.com/en/stable/cookbook.html#how-to-collect-coverage-for-c-extensions-in-python
os.environ['CC'] = 'ccache gcc' # no distutils/setuptools equivalent

extensions = [
Extension(
"multidict._multidict",
["multidict/_multidict.c"],
extra_compile_args=CFLAGS,
extra_link_args=LDFLAGS,
),
]


from setuptools.command.build_ext import build_ext


class TraceableBinaryExtensionCmd(build_ext):
def _ext_mod_path_for(self, ext) -> list[str]:
if not DEBUG_BUILD:
raise LookupError

fullname = self.get_ext_fullname(ext.name)
return fullname.split('.')

def _ext_tracing_file_for(self, ext) -> pathlib.Path:
if not DEBUG_BUILD:
raise LookupError

modpath = self._ext_mod_path_for(ext)

# NOTE: `.o` file is unnecessary for gcovr to function
return pathlib.Path(*modpath).with_suffix('.gcno')

def _ext_tracing_data_dir_for(self, ext) -> tuple[str]:
if not DEBUG_BUILD:
raise LookupError

modpath = self._ext_mod_path_for(ext)
package = '.'.join(modpath[:-1])
build_py = self.get_finalized_command('build_py') # ??
package_dir = pathlib.Path(build_py.get_package_dir(package))
return package_dir / '__tracing-data__'

@functools.cached_property
def _ext_tracing_data_dir_map(self) -> dict[str, tuple[str]]:
extensions_with_tracing_data = self.extensions if DEBUG_BUILD else ()

return {
ext.name: self._ext_tracing_data_dir_for(ext)
for ext in extensions_with_tracing_data
}

def _extra_ext_data_files_for(self, ext) -> tuple[str]:
if not DEBUG_BUILD:
return ()

tracing_data_in_package_dir = self._ext_tracing_data_dir_map[ext.name]
tracing_data_file_in_tmp_dir = self._ext_tracing_file_for(ext)
tracing_data_file_in_package_dir = tracing_data_in_package_dir / tracing_data_file_in_tmp_dir
build_meta_json_path = tracing_data_in_package_dir / 'build-metadata.json'

return (build_meta_json_path, tracing_data_file_in_package_dir)

@functools.cached_property
def _extra_ext_data_files_map(self) -> dict[str, tuple[str]]:
extensions_with_tracing_data = self.extensions if DEBUG_BUILD else ()

return {
ext.name: self._extra_ext_data_files_for(ext)
for ext in extensions_with_tracing_data
}

@functools.cached_property
def _extra_wheel_data_files(self) -> tuple[pathlib.Path]:
extensions_with_tracing_data = self.extensions if DEBUG_BUILD else ()

return tuple(
relative_path
for ext in extensions_with_tracing_data
for relative_path in self._extra_ext_data_files_map[ext.name]
)

def get_outputs(self) -> list[str]:
"""Return absolute file paths to be included in the wheel."""
base_outputs = super().get_outputs()

build_dir_path = pathlib.Path(self.build_lib)
tracing_outputs = (
build_dir_path / relative_path
for relative_path in self._extra_wheel_data_files
)

# NOTE: Files returned here end up in wheels and then `site-packages/`.
# NOTE: Editable installs rely on the tracing files to be copied into
# NOTE: the source checkout, which is happening in `run()`.
return [*base_outputs, *tracing_outputs]

def build_extension(self, ext):
super().build_extension(ext)

if not DEBUG_BUILD:
log.info('Not a debug build. Skipping tracing data...')
return

log.info('Copying tracing data into the build directory')
tracing_data_file_in_tmp_dir = self._ext_tracing_file_for(ext)
tracing_data_in_package_dir = self._ext_tracing_data_dir_map[ext.name]
tracing_data_file_in_package_dir = tracing_data_in_package_dir / tracing_data_file_in_tmp_dir

Check notice

Code scanning / CodeQL

Unused local variable Note

Variable tracing_data_file_in_package_dir is not used.

tracing_data_in_build_dir = pathlib.Path(self.build_lib) / tracing_data_in_package_dir

tracing_data_file_in_build_dir_absolute = tracing_data_in_build_dir / tracing_data_file_in_tmp_dir

tracing_data_file_in_build_dir_absolute.parent.mkdir(exist_ok=True, parents=True)
# NOTE: `gcc` writes `.gcno` files next to `.o` which is the temporary
# NOTE: directory for us (`build_temp`). This copies it over into the
# NOTE: regular build directory (`build_lib`) producing the layout we
# NOTE: expect in wheels / site-packages / source checkout. It's later
# NOTE: copied over to those places along with the shared libraries.
self.copy_file(
pathlib.Path(self.build_temp) / tracing_data_file_in_tmp_dir,
tracing_data_file_in_build_dir_absolute,
level=self.verbose,
)

# NOTE: `gcc` writes an absolute path to `.gcno` files into the shared
# NOTE: library at build time. It may point to an arbitrary temporary
# NOTE: directory that we don't care for. When pytest imports this
# NOTE: file, the C-extension attempts writing a `.gcda` file in the
# NOTE: same directory. We want it to be written in the source checkout
# NOTE: next to the tracing file. For this, we record information about
# NOTE: the desired location relative to the project root, bundle it
# NOTE: in the wheel and the tests will read from it, and set the
# NOTE: respective environment variables so the coverage data ends up
# NOTE: where we expect it to. `gcovr` will also work better with
# NOTE: predictable data locations.
# NOTE:
# NOTE: The contributors won't have to set the env vars manually
# NOTE: $ GCOV_PREFIX=multidict/__tracing-data__/ \
# NOTE: GCOV_PREFIX_STRIP=2 \
# NOTE: python -Im pytest
build_meta_path = tracing_data_in_build_dir / 'build-metadata.json'
tmp_path_length = len(pathlib.Path(self.build_temp).resolve().parents)
build_meta_path.write_text(
json.dumps(
{
'GCOV_PREFIX': str(tracing_data_in_package_dir),
'GCOV_PREFIX_STRIP': str(tmp_path_length),
},
),
encoding='utf-8',
)

def run(self):
super().run()

if not self.inplace:
log.info('Not an editable install. Skipping tracing data...')

if not DEBUG_BUILD:
log.info('Not a debug build. Skipping tracing data...')
return

log.info(
'Editable install in debug mode. Copying tracing data in-tree...',
)

# NOTE: Editable installs usually expect data in-tree. This handles
# NOTE: the cases of `python setup.py build_ext --inplace` and
# NOTE: `pip install -e .`
# NOTE: Normal wheel builds include files returned by `get_outputs()`.

build_dir_path = pathlib.Path(self.build_lib)
for relative_path in self._extra_wheel_data_files:
relative_path.parent.mkdir(exist_ok=True, parents=True)
self.copy_file(
build_dir_path / relative_path,
relative_path,
level=self.verbose,
)

for ext in self.extensions:
tracing_data_in_pkg_dir = self._ext_tracing_data_dir_map[ext.name]
(tracing_data_in_pkg_dir / '.gitignore').write_text('*\n')


if not NO_EXTENSIONS:
print("*********************")
print("* Accelerated build *")
print("*********************")
setup(ext_modules=extensions)
setup(
cmdclass={'build_ext': TraceableBinaryExtensionCmd},
ext_modules=extensions,
)
else:
print("*********************")
print("* Pure Python build *")
Expand Down
Loading
Loading