Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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() / <name>-<version>.dist-info / <subdir>`` are
routed into the wheel's ``.dist-info/<subdir>/`` at pack time.


0.19.0
======

Expand Down
111 changes: 111 additions & 0 deletions docs/how-to-guides/sboms.rst
Original file line number Diff line number Diff line change
@@ -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() / <name>-<version>.dist-info / <subdir>`` into
the wheel's own ``.dist-info/<subdir>/`` 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 ``<name>-<version>.dist-info/<subdir>/`` 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 ``<name>-<version>.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.
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
86 changes: 67 additions & 19 deletions mesonpy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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 <distinfo>/... 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', [])}
Expand Down Expand Up @@ -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)
Expand All @@ -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:

Expand All @@ -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)

Expand Down Expand Up @@ -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'))
Expand Down Expand Up @@ -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}/<name>-<version>.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:
Expand Down
1 change: 1 addition & 0 deletions tests/packages/dist-info-sboms-collision/LICENSE.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Top-level LICENSE.txt referenced by project.license-files in pyproject.toml.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Second LICENSE.txt routed via py.dist_info_install_dir('licenses').
Colliding basename should trigger a BuildError.
16 changes: 16 additions & 0 deletions tests/packages/dist-info-sboms-collision/meson.build
Original file line number Diff line number Diff line change
@@ -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')
13 changes: 13 additions & 0 deletions tests/packages/dist-info-sboms-collision/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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']
15 changes: 15 additions & 0 deletions tests/packages/dist-info-sboms-platlib/meson.build
Original file line number Diff line number Diff line change
@@ -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')
11 changes: 11 additions & 0 deletions tests/packages/dist-info-sboms-platlib/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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'
7 changes: 7 additions & 0 deletions tests/packages/dist-info-sboms-platlib/sboms/static.cdx.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"$schema": "https://cyclonedx.org/schema/bom-1.6.schema.json",
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"version": 1,
"components": []
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2026 The meson-python developers
#
# SPDX-License-Identifier: MIT
17 changes: 17 additions & 0 deletions tests/packages/dist-info-sboms/generate.py
Original file line number Diff line number Diff line change
@@ -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)
Loading