diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 3ad9a70c..75ace44b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -11,6 +11,15 @@ Changelog +++++++++ +Unreleased +========== + +- Add support for including PEP 770 SBOMs and other dist-info-bound metadata + files in the wheel. Files installed to + ``py.get_install_dir() / -.dist-info / `` are + routed into the wheel's ``.dist-info//`` at pack time. + + 0.19.0 ====== diff --git a/docs/how-to-guides/sboms.rst b/docs/how-to-guides/sboms.rst new file mode 100644 index 00000000..bb7147c9 --- /dev/null +++ b/docs/how-to-guides/sboms.rst @@ -0,0 +1,111 @@ +.. SPDX-FileCopyrightText: 2026 The meson-python developers +.. +.. SPDX-License-Identifier: MIT + +.. _how-to-guides-sboms: + +*********************************** +Including SBOMs in wheels (PEP 770) +*********************************** + +`PEP 770`_ defines a location for Software Bill of Materials (SBOM) +files inside the wheel's ``.dist-info/sboms/`` directory. +``meson-python`` routes any file installed to +``py.get_install_dir() / -.dist-info / `` into +the wheel's own ``.dist-info//`` at pack time, giving projects +a way to ship SBOMs and other dist-info-bound metadata files without +post-build wheel surgery. + +.. _PEP 770: https://peps.python.org/pep-0770/ + +Static SBOM files +================= + +For SBOMs that are checked into the source tree, typically describing +source-vendored components: + +.. code-block:: meson + + project('my-project', 'c', version: '1.0.0') + + py = import('python').find_installation(pure: false) + distinfo = meson.project_name() + '-' + meson.project_version() + '.dist-info' + + install_data( + 'sboms/component1.cdx.json', + 'sboms/component2.cdx.json', + install_dir: py.get_install_dir() / distinfo / 'sboms', + ) + +The files end up in the wheel at +``my_project-1.0.0.dist-info/sboms/component1.cdx.json`` and +``component2.cdx.json``. + +Dynamically generated SBOMs +=========================== + +When the SBOM is generated at build time, use a ``custom_target`` that +writes the file and installs it to the same location: + +.. code-block:: meson + + custom_target('vendored-sbom', + output: 'vendored.cdx.json', + command: [py, files('scripts/generate_sbom.py'), '@OUTPUT@', + '--version', meson.project_version()], + install: true, + install_dir: py.get_install_dir() / distinfo / 'sboms', + ) + +The generator runs during the build, and the output is routed into +``my_project-1.0.0.dist-info/sboms/vendored.cdx.json``. + +The generator is provided by the project; ``meson-python`` does not +ship one. It can be a script checked into the source tree +(``scripts/`` is a common convention) or a third-party generator +installed via ``[build-system] requires``. For guidance on generator +implementations and the SBOM format itself, see the PSF +`SBOMs for Python packages`_ proposal. + +Other ``.dist-info`` subdirectories +=================================== + +Any subdirectory works the same way. Additional PEP 639 license files, +for example, can go under ``licenses``: + +.. code-block:: meson + + install_data('LICENSES/extra.txt', + install_dir: py.get_install_dir() / distinfo / 'licenses') + +License files declared via ``project.license-files`` in +``pyproject.toml`` are already placed in ``.dist-info/licenses/`` +automatically and do not need a ``meson.build`` entry. Use the pattern +above only for additional files outside the standard +``project.license-files`` list. + +File naming and validation +========================== + +* Files installed under ``-.dist-info//`` must + have unique basenames within their subdirectory. ``meson-python`` + raises a ``BuildError`` at build time if two files would write to + the same path, including collisions between files routed through + this mechanism and files written from ``project.license-files``. +* The ``-.dist-info`` directory name is matched + canonically, so hyphens and underscores in the user's project name + do not break routing. +* Recommended file extensions are ``.cdx.json`` for CycloneDX and + ``.spdx.json`` for SPDX, per the PSF + `SBOMs for Python packages`_ proposal. + +.. _SBOMs for Python packages: https://github.com/psf/sboms-for-python-packages + +Editable installs +================= + +Files staged via this mechanism are only placed in non-editable wheels +(``pip install .`` or ``python -m build``). Editable wheels +(``pip install -e .``) redirect imports to the build directory and do +not carry dist-info-bound payloads. Since SBOMs are distribution +artifacts, this limitation does not affect development workflows. diff --git a/docs/index.rst b/docs/index.rst index f56b7ebc..0ec38fce 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -83,6 +83,7 @@ the use of ``meson-python`` and Meson for Python packaging. how-to-guides/meson-args how-to-guides/debug-builds how-to-guides/shared-libraries + how-to-guides/sboms reference/limitations projects-using-meson-python diff --git a/mesonpy/__init__.py b/mesonpy/__init__.py index e0328d1a..2a1eb7e8 100644 --- a/mesonpy/__init__.py +++ b/mesonpy/__init__.py @@ -126,15 +126,22 @@ class _Entry(typing.NamedTuple): src: str +def _canonicalize_distinfo(dir_name: str) -> str: + """Canonical form for .dist-info directory equality comparison.""" + return re.sub(r'[-_.]+', '-', dir_name).lower() + + def _map_to_wheel( sources: Dict[str, Dict[str, Any]], - exclude: List[str], include: List[str] + exclude: List[str], include: List[str], + distinfo_dir: str, ) -> DefaultDict[str, List[_Entry]]: """Map files to the wheel, organized by wheel installation directory.""" wheel_files: DefaultDict[str, List[_Entry]] = collections.defaultdict(list) packages: Dict[str, str] = {} excluded = _compile_patterns(exclude) included = _compile_patterns(include) + canonical_distinfo = _canonicalize_distinfo(distinfo_dir) for key, group in sources.items(): for src, target in group.items(): @@ -151,17 +158,22 @@ def _map_to_wheel( if path is None: raise BuildError(f'Could not map installation path to an equivalent wheel directory: {str(destination)!r}') - if path == 'purelib' or path == 'platlib': - package = destination.parts[1] - other = packages.setdefault(package, path) - if other != path: - this = os.fspath(pathlib.Path(path, *destination.parts[1:])) - module = next(entry.dst for entry in wheel_files[other] if entry.dst.parts[0] == destination.parts[1]) - that = os.fspath(other / module) - raise BuildError( - f'The {package} package is split between {path} and {other}: ' - f'{this!r} and {that!r}, a "pure: false" argument may be missing in meson.build. ' - f'It is recommended to set it in "import(\'python\').find_installation()"') + if path in ('purelib', 'platlib'): + if dst.parts and _canonicalize_distinfo(dst.parts[0]) == canonical_distinfo: + # Route /... into the wheel's .dist-info/. + path = 'distinfo' + dst = pathlib.Path(*dst.parts[1:]) + else: + package = destination.parts[1] + other = packages.setdefault(package, path) + if other != path: + this = os.fspath(pathlib.Path(path, *destination.parts[1:])) + module = next(entry.dst for entry in wheel_files[other] if entry.dst.parts[0] == destination.parts[1]) + that = os.fspath(other / module) + raise BuildError( + f'The {package} package is split between {path} and {other}: ' + f'{this!r} and {that!r}, a "pure: false" argument may be missing in meson.build. ' + f'It is recommended to set it in "import(\'python\').find_installation()"') if key == 'install_subdirs' or key == 'targets' and os.path.isdir(src): exclude_files = {os.path.normpath(x) for x in target.get('exclude_files', [])} @@ -468,7 +480,11 @@ def _install_path(self, wheel_file: mesonpy._wheelfile.WheelFile, origin: Path, if not os.fspath(origin).endswith('.pdb'): raise - def _wheel_write_metadata(self, whl: mesonpy._wheelfile.WheelFile) -> None: + def _wheel_write_metadata( + self, + whl: mesonpy._wheelfile.WheelFile, + distinfo_seen: Dict[str, str], + ) -> None: # add metadata whl.writestr(f'{self._distinfo_dir}/METADATA', bytes(self._metadata.as_rfc822())) whl.writestr(f'{self._distinfo_dir}/WHEEL', self.wheel) @@ -479,18 +495,34 @@ def _wheel_write_metadata(self, whl: mesonpy._wheelfile.WheelFile) -> None: if isinstance(self._metadata.license, pyproject_metadata.License): license_file = self._metadata.license.file if license_file: - whl.write(license_file, f'{self._distinfo_dir}/{os.path.basename(license_file)}') + target = f'{self._distinfo_dir}/{os.path.basename(license_file)}' + if target in distinfo_seen: + raise BuildError( + f'Two files would be installed to {target!r} in the wheel: ' + f'{distinfo_seen[target]!r} and {str(license_file)!r}. ' + f'Files placed in .dist-info/ must have unique paths.') + distinfo_seen[target] = str(license_file) + whl.write(license_file, target) # Add PEP-639 license-files. Use ``getattr()`` for compatibility with pyproject-metadata < 0.9.0. license_files = getattr(self._metadata, 'license_files', None) if license_files: for f in license_files: - whl.write(f, f'{self._distinfo_dir}/licenses/{pathlib.Path(f).as_posix()}') + target = f'{self._distinfo_dir}/licenses/{pathlib.Path(f).as_posix()}' + if target in distinfo_seen: + raise BuildError( + f'Two files would be installed to {target!r} in the wheel: ' + f'{distinfo_seen[target]!r} and {str(f)!r}. ' + f'Files placed in .dist-info/ must have unique paths.') + distinfo_seen[target] = str(f) + whl.write(f, target) def build(self, directory: Path) -> pathlib.Path: wheel_file = pathlib.Path(directory, f'{self.name}.whl') with mesonpy._wheelfile.WheelFile(wheel_file, 'w') as whl: - self._wheel_write_metadata(whl) + # Collision tracker for files written under .dist-info/. + distinfo_seen: Dict[str, str] = {} + self._wheel_write_metadata(whl, distinfo_seen) with _clicounter(sum(len(x) for x in self._manifest.values())) as counter: @@ -505,6 +537,15 @@ def build(self, directory: Path) -> pathlib.Path: elif path == 'mesonpy-libs': # custom installation path for bundled libraries dst = pathlib.Path(self._libs_dir, dst) + elif path == 'distinfo': + target = pathlib.Path(self._distinfo_dir, dst).as_posix() + if target in distinfo_seen: + raise BuildError( + f'Two files would be installed to {target!r} in the wheel: ' + f'{distinfo_seen[target]!r} and {str(src)!r}. ' + f'Files placed in .dist-info/ must have unique paths.') + distinfo_seen[target] = str(src) + dst = pathlib.Path(self._distinfo_dir, dst) else: dst = pathlib.Path(self._data_dir, path, dst) @@ -536,7 +577,10 @@ def build(self, directory: Path, source_dir: pathlib.Path, build_dir: pathlib.Pa wheel_file = pathlib.Path(directory, f'{self.name}.whl') with mesonpy._wheelfile.WheelFile(wheel_file, 'w') as whl: - self._wheel_write_metadata(whl) + # Pass a registry so duplicate license-files in pyproject + # still surface clearly even though editable wheels have no + # manifest loop and no dist_info_install_dir() routing. + self._wheel_write_metadata(whl, distinfo_seen={}) whl.writestr( f'{self._distinfo_dir}/direct_url.json', source_dir.as_uri().encode('utf-8')) @@ -972,8 +1016,12 @@ def _manifest(self) -> DefaultDict[str, List[_Entry]]: continue sources[key][target] = details - # Map Meson installation locations to wheel paths. - return _map_to_wheel(sources, self._excluded_files, self._included_files) + # Map Meson installation locations to wheel paths. Pass the + # wheel's dist-info directory name so files staged under + # {purelib}/-.dist-info/... get rerouted to the + # wheel's own .dist-info/ at pack time. + distinfo_dir = f'{self._metadata.distribution_name}-{self._metadata.version}.dist-info' + return _map_to_wheel(sources, self._excluded_files, self._included_files, distinfo_dir) @property def _meson_name(self) -> str: diff --git a/tests/packages/dist-info-sboms-collision/LICENSE.txt b/tests/packages/dist-info-sboms-collision/LICENSE.txt new file mode 100644 index 00000000..7bc53ae7 --- /dev/null +++ b/tests/packages/dist-info-sboms-collision/LICENSE.txt @@ -0,0 +1 @@ +Top-level LICENSE.txt referenced by project.license-files in pyproject.toml. diff --git a/tests/packages/dist-info-sboms-collision/duplicate/LICENSE.txt b/tests/packages/dist-info-sboms-collision/duplicate/LICENSE.txt new file mode 100644 index 00000000..36ce06ec --- /dev/null +++ b/tests/packages/dist-info-sboms-collision/duplicate/LICENSE.txt @@ -0,0 +1,2 @@ +Second LICENSE.txt routed via py.dist_info_install_dir('licenses'). +Colliding basename should trigger a BuildError. diff --git a/tests/packages/dist-info-sboms-collision/meson.build b/tests/packages/dist-info-sboms-collision/meson.build new file mode 100644 index 00000000..33017fd8 --- /dev/null +++ b/tests/packages/dist-info-sboms-collision/meson.build @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: 2026 The meson-python developers +# +# SPDX-License-Identifier: MIT + +project('dist-info-sboms-collision', version: '1.0.0') + +py = import('python').find_installation() + +distinfo = meson.project_name() + '-' + meson.project_version() + '.dist-info' + +# This install_data targets the same wheel path as the PEP 639 +# license-files entry in pyproject.toml (.dist-info/licenses/LICENSE.txt). +# meson-python should surface the collision as a BuildError rather than +# silently clobber. +install_data('duplicate/LICENSE.txt', + install_dir: py.get_install_dir() / distinfo / 'licenses') diff --git a/tests/packages/dist-info-sboms-collision/pyproject.toml b/tests/packages/dist-info-sboms-collision/pyproject.toml new file mode 100644 index 00000000..704c461e --- /dev/null +++ b/tests/packages/dist-info-sboms-collision/pyproject.toml @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: 2026 The meson-python developers +# +# SPDX-License-Identifier: MIT + +[build-system] +build-backend = 'mesonpy' +requires = ['meson-python'] + +[project] +name = 'dist-info-sboms-collision' +version = '1.0.0' +license = 'MIT' +license-files = ['LICENSE.txt'] diff --git a/tests/packages/dist-info-sboms-platlib/meson.build b/tests/packages/dist-info-sboms-platlib/meson.build new file mode 100644 index 00000000..d5a140d4 --- /dev/null +++ b/tests/packages/dist-info-sboms-platlib/meson.build @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: 2026 The meson-python developers +# +# SPDX-License-Identifier: MIT + +project('dist-info-sboms-platlib', version: '1.0.0') + +# pure: false exercises the platlib routing path. Projects shipping +# C extensions (pandas, numpy, scipy) all use this; the distinfo +# prefix detection must work for both purelib and platlib roots. +py = import('python').find_installation(pure: false) + +distinfo = meson.project_name() + '-' + meson.project_version() + '.dist-info' + +install_data('sboms/static.cdx.json', + install_dir: py.get_install_dir() / distinfo / 'sboms') diff --git a/tests/packages/dist-info-sboms-platlib/pyproject.toml b/tests/packages/dist-info-sboms-platlib/pyproject.toml new file mode 100644 index 00000000..1525f4e4 --- /dev/null +++ b/tests/packages/dist-info-sboms-platlib/pyproject.toml @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: 2026 The meson-python developers +# +# SPDX-License-Identifier: MIT + +[build-system] +build-backend = 'mesonpy' +requires = ['meson-python'] + +[project] +name = 'dist-info-sboms-platlib' +version = '1.0.0' diff --git a/tests/packages/dist-info-sboms-platlib/sboms/static.cdx.json b/tests/packages/dist-info-sboms-platlib/sboms/static.cdx.json new file mode 100644 index 00000000..8a8fd07c --- /dev/null +++ b/tests/packages/dist-info-sboms-platlib/sboms/static.cdx.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "version": 1, + "components": [] +} diff --git a/tests/packages/dist-info-sboms-platlib/sboms/static.cdx.json.license b/tests/packages/dist-info-sboms-platlib/sboms/static.cdx.json.license new file mode 100644 index 00000000..775d000c --- /dev/null +++ b/tests/packages/dist-info-sboms-platlib/sboms/static.cdx.json.license @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2026 The meson-python developers +# +# SPDX-License-Identifier: MIT diff --git a/tests/packages/dist-info-sboms/generate.py b/tests/packages/dist-info-sboms/generate.py new file mode 100644 index 00000000..ffea484c --- /dev/null +++ b/tests/packages/dist-info-sboms/generate.py @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: 2026 The meson-python developers +# +# SPDX-License-Identifier: MIT + +"""Trivial SBOM generator for the dist-info-sboms test package.""" + +import json +import sys + + +with open(sys.argv[1], 'w') as f: + json.dump({ + 'bomFormat': 'CycloneDX', + 'specVersion': '1.6', + 'version': 1, + 'components': [], + }, f) diff --git a/tests/packages/dist-info-sboms/meson.build b/tests/packages/dist-info-sboms/meson.build new file mode 100644 index 00000000..36567a4f --- /dev/null +++ b/tests/packages/dist-info-sboms/meson.build @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: 2026 The meson-python developers +# +# SPDX-License-Identifier: MIT + +project('dist-info-sboms', version: '1.0.0') + +py = import('python').find_installation() + +# Compose the wheel's .dist-info/ directory name. meson-python detects +# this prefix under {py_purelib}/... in the install plan and routes the +# file into the wheel's own .dist-info/. +distinfo = meson.project_name() + '-' + meson.project_version() + '.dist-info' + +# Static SBOM file. +install_data('sboms/static.cdx.json', + install_dir: py.get_install_dir() / distinfo / 'sboms') + +# Dynamic SBOM produced by a custom_target. This is the pattern +# scientific-Python projects (pandas, NumPy, SciPy) use to inject +# build-time-generated SBOMs into the wheel. +custom_target('generated-sbom', + output: 'generated.cdx.json', + command: [py, files('generate.py'), '@OUTPUT@'], + install: true, + install_dir: py.get_install_dir() / distinfo / 'sboms') + +# Different subdirectory — proves the routing handles arbitrary +# subdir names, not just 'sboms'. 'extras' (rather than 'licenses') +# avoids suggesting this is a PEP 639 alternative; PEP 639 +# license-files still flow through pyproject_metadata + the existing +# _wheel_write_metadata path. +install_data('sboms/extra.txt', + install_dir: py.get_install_dir() / distinfo / 'extras') diff --git a/tests/packages/dist-info-sboms/pyproject.toml b/tests/packages/dist-info-sboms/pyproject.toml new file mode 100644 index 00000000..e9421b72 --- /dev/null +++ b/tests/packages/dist-info-sboms/pyproject.toml @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: 2026 The meson-python developers +# +# SPDX-License-Identifier: MIT + +[build-system] +build-backend = 'mesonpy' +requires = ['meson-python'] + +[project] +name = 'dist-info-sboms' +version = '1.0.0' diff --git a/tests/packages/dist-info-sboms/sboms/extra.txt b/tests/packages/dist-info-sboms/sboms/extra.txt new file mode 100644 index 00000000..6720832b --- /dev/null +++ b/tests/packages/dist-info-sboms/sboms/extra.txt @@ -0,0 +1 @@ +Sample extras file routed via py.dist_info_install_dir('extras'). diff --git a/tests/packages/dist-info-sboms/sboms/extra.txt.license b/tests/packages/dist-info-sboms/sboms/extra.txt.license new file mode 100644 index 00000000..775d000c --- /dev/null +++ b/tests/packages/dist-info-sboms/sboms/extra.txt.license @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2026 The meson-python developers +# +# SPDX-License-Identifier: MIT diff --git a/tests/packages/dist-info-sboms/sboms/static.cdx.json b/tests/packages/dist-info-sboms/sboms/static.cdx.json new file mode 100644 index 00000000..8a8fd07c --- /dev/null +++ b/tests/packages/dist-info-sboms/sboms/static.cdx.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "version": 1, + "components": [] +} diff --git a/tests/packages/dist-info-sboms/sboms/static.cdx.json.license b/tests/packages/dist-info-sboms/sboms/static.cdx.json.license new file mode 100644 index 00000000..775d000c --- /dev/null +++ b/tests/packages/dist-info-sboms/sboms/static.cdx.json.license @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2026 The meson-python developers +# +# SPDX-License-Identifier: MIT diff --git a/tests/test_wheel.py b/tests/test_wheel.py index e3c93559..fce268f0 100644 --- a/tests/test_wheel.py +++ b/tests/test_wheel.py @@ -158,6 +158,52 @@ def test_license_pep639(wheel_license_pep639): ''')) +def test_dist_info_sboms(wheel_dist_info_sboms): + artifact = wheel.wheelfile.WheelFile(wheel_dist_info_sboms) + + assert wheel_contents(artifact) == { + 'dist_info_sboms-1.0.0.dist-info/METADATA', + 'dist_info_sboms-1.0.0.dist-info/RECORD', + 'dist_info_sboms-1.0.0.dist-info/WHEEL', + 'dist_info_sboms-1.0.0.dist-info/sboms/static.cdx.json', + 'dist_info_sboms-1.0.0.dist-info/sboms/generated.cdx.json', + 'dist_info_sboms-1.0.0.dist-info/extras/extra.txt', + } + + # Static SBOM file content preserved verbatim. + static_sbom = artifact.read('dist_info_sboms-1.0.0.dist-info/sboms/static.cdx.json') + assert b'CycloneDX' in static_sbom + + # Dynamically-generated SBOM produced by the custom_target ran. + generated_sbom = artifact.read('dist_info_sboms-1.0.0.dist-info/sboms/generated.cdx.json') + assert b'CycloneDX' in generated_sbom + + +def test_dist_info_sboms_platlib(wheel_dist_info_sboms_platlib): + # Covers `pure: false` projects (pandas, numpy, scipy): meson's + # install plan emits {py_platlib}//... rather than + # {py_purelib}/..., and the distinfo-prefix detection has to fire + # for both roots. Without that coverage the file can land at the + # right wheel path by accident but bypass the collision check. + artifact = wheel.wheelfile.WheelFile(wheel_dist_info_sboms_platlib) + names = wheel_contents(artifact) + # The wheel is a platlib wheel (cpNN-abi-plat tag); its .dist-info + # directory name may vary with python interpreter tag, so check + # the payload by suffix. + assert any(n.endswith('.dist-info/sboms/static.cdx.json') for n in names), names + + +@pytest.mark.filterwarnings('ignore:canonicalization and validation of license expression') +def test_dist_info_sboms_collision(package_dist_info_sboms_collision, tmp_path): + # Verifies the collision check catches the case where a file routed + # via python.dist_info_install_dir('licenses') targets the same wheel + # path as a PEP 639 license-files entry. Before the shared seen-set + # fix, this silently produced a wheel with duplicate zip entries. + with pytest.raises(mesonpy.BuildError, match='Two files would be installed to'): + with mesonpy._project() as project: + project.wheel(tmp_path) + + @pytest.mark.skipif(sys.platform in {'win32', 'cygwin'}, reason='requires RPATH support') def test_contents(package_library, wheel_library): artifact = wheel.wheelfile.WheelFile(wheel_library)