diff --git a/.github/workflows/pixi.lock b/.github/workflows/pixi.lock index 6d9754a3a..ad6dbca9b 100644 --- a/.github/workflows/pixi.lock +++ b/.github/workflows/pixi.lock @@ -80,6 +80,7 @@ environments: - conda: https://prefix.dev/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda + - conda: https://prefix.dev/conda-forge/noarch/vcs_versioning-2.3.4-pyh5ded981_0.conda - conda: https://prefix.dev/conda-forge/noarch/wheel-0.48.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda packages: @@ -926,6 +927,19 @@ packages: run_exports: {} size: 118849 timestamp: 1784250406640 +- conda: https://prefix.dev/conda-forge/noarch/vcs_versioning-2.3.4-pyh5ded981_0.conda + sha256: 5ae074d3d15d9a7654b7d7709dd006fbc916c00eaf1081773d0da4c3fed1fa79 + md5: 935abb1ac9af6257ea25aab6b667353a + depends: + - python >=3.11 + - packaging >=26.2 + - typing_extensions >=4.1 + - python + license: MIT + license_family: MIT + run_exports: {} + size: 89186 + timestamp: 1788463774792 - conda: https://prefix.dev/conda-forge/noarch/wheel-0.48.0-pyhd8ed1ab_0.conda sha256: ba64b29b6d418024cb565081a1799262cb2780d2534ff4c14f76aa858c4286cd md5: 9a2124517bfd5d792e0f7b86eefdfeb8 diff --git a/.github/workflows/pixi.toml b/.github/workflows/pixi.toml index 29aa24f0b..caaba36b4 100644 --- a/.github/workflows/pixi.toml +++ b/.github/workflows/pixi.toml @@ -26,6 +26,7 @@ python-build = "*" cython = "*" pytest = "*" pytest-mock = "*" +vcs_versioning = "*" wheel = "*" [tasks] diff --git a/docs/how-to-guides/dynamic-versioning.rst b/docs/how-to-guides/dynamic-versioning.rst new file mode 100644 index 000000000..359bcb51a --- /dev/null +++ b/docs/how-to-guides/dynamic-versioning.rst @@ -0,0 +1,179 @@ +.. SPDX-FileCopyrightText: 2023 The meson-python developers +.. +.. SPDX-License-Identifier: MIT + +.. _how-to-guides-dynamic-versioning: + +****************** +Dynamic versioning +****************** + +The most common approach to versioning is to keep a static version number in +``pyproject.toml`` only, and update it before a new release in a regular commit. +This is simple and robust. However, sometimes a package author may want more +from versioning, and hence reach for dynamic versioning. E.g.: + +1. Use the package version in a ``meson.build`` file without duplicating the version string between ``pyproject.toml`` and ``meson.build``. +2. Use the hash of the current commit in the package version, or store it in a configuration file. +3. Derive the version from the most recent git tag rather than maintain it in the code. + +.. note:: + + Each of these things has a cost - keeping all metadata static and not + running ``git`` or introspecting the ``.git`` directory as part of the build + avoids running extra build steps in some cases, extra build dependencies or + custom scripts, and potential issues with shallow checkouts in CI where the + ``.git`` directory may not be present. Only use these dynamic features if + you have a good reason to do so! + +Single-sourcing the version string +---------------------------------- + +When you want to define your project's version string in a single place, +``meson-python`` knows how to extract the version number from the ``project()`` +call in the top-level ``meson.build``; in ``pyproject.toml`` it can be declared +as dynamic: + +.. code-block:: toml + + [project] + dynamic = ['version'] + +Then in ``meson.build``, define the version: + +.. code-block:: meson + + project('my-project', 'c', version: '1.2.3') + +It can also be done the other way around - this requires a bit more code, +however it has the advantage that all metadata remains static in +``pyproject.toml``, which can in some cases avoid triggering a build +when an installer needs to obtain the version. To implement this, +set the version in ``pyproject.toml``: + +.. code-block:: toml + + [project] + version = '1.2.3' + +And in ``meson.build``, run a helper script as part of the project call +(again, this is only needed if you actually use the version string inside a +``meson.build`` file): + +.. code-block:: meson + + project('my-project', + 'c', + version: run_command( + ['get_version.py'], + check: true + ).stdout().strip(), + ) + +With that ``get_version.py`` script retrieving the version from +``pyproject.toml``: + +.. code-block:: python + + #!/usr/bin/env python3 + import os + + def get_version(): + pyproject_toml = os.path.join(os.path.dirname(__file__), 'pyproject.toml') + with open(pyproject_toml) as f: + data = f.readlines() + + version_line = next( + line for line in data if line.startswith('version =') + ) + version = version_line.strip().split(' = ')[1] + return version.replace('"', '').replace("'", '') + + + if __name__ == "__main__": + print(get_version()) + + +Storing the git commit hash inside your package +----------------------------------------------- + +Capturing the git commit hash alongside the version can be useful for bug +reports and reproducibility: the user can print ``pkgname.__version__`` and +``pkgname.__git_hash__`` to identify exactly which commit they are running. The +commit hash is not part of ``pyproject.toml`` and cannot be derived from a +source distribution after the fact, so it has to be written into the package at +build time. + +A pattern to achieve this, which used by NumPy for example, is a single helper +script that does double duty: it prints the version when called from +``project()``, and it writes a generated ``_version.py`` file containing both +the version and the git hash when called from a build step. The +script is wired up via ``custom_target()`` for normal builds and via +``meson.add_dist_script()`` so that the generated file is also included +in source distributions. + +In ``meson.build``: + +.. literalinclude:: ../../tests/packages/dynamic-version-from-script/meson.build + :language: meson + :lines: 5- + +The ``build_always_stale: true`` flag ensures that the recorded hash is +refreshed every time the project is rebuilt, rather than being cached +from a previous build. + +The helper script reads the version from ``pyproject.toml`` (so the +version still has a single source of truth) and resolves the git hash +via ``git rev-parse``, falling back to ``'unknown'`` when called outside +a checkout — for example when building from an extracted source +distribution: + +.. literalinclude:: ../../tests/packages/dynamic-version-from-script/generate_version.py + :language: python + :lines: 1,5- + +The ``MESON_DIST_ROOT`` branch ensures that when the script is invoked +as a dist script, it writes the generated file into the staging +directory ``meson dist`` is preparing, so it is included in the source +distribution. See :ref:`sdist` for the surrounding context. + +The package's ``__init__.py`` re-exports the generated symbols: + +.. code-block:: python + + from pkgname._version import __git_hash__, __version__ + +A complete worked example lives at ``tests/packages/dynamic-version-from-script`` +in the meson-python source tree. + + +Derive version from latest git tag +---------------------------------- + +When the version is encoded in git tags rather than in source files, the +build system has to query git at configure time. There are a number of +packages that provide this functionality, including ``vcs-versioning``, +that can be used together with ``meson-python``. The +integration principle is the same as above: use a ``run_command()`` call inside +``project()`` (either directly or through a small wrapper script like +``get_version.py`` higher up) that prints the version and (optionally) writes +out a file to disk that can be included in the sdist. + +The example below uses ``vcs-versioning``; the same approach applies +to the other tools - only the wrapper script differs. Declare it as a build +requirement in ``pyproject.toml``: + + +.. literalinclude:: ../../tests/packages/version-vcs-versioning/pyproject.toml + :language: toml + :lines: 5-12 + +In ``meson.build``, invoke ``vcs-versioning`` to compute the version: + +.. literalinclude:: ../../tests/packages/version-vcs-versioning/meson.build + :language: meson + :lines: 5- + +That's it. You can use ``vcs-versioning`` config options as explained in its docs. +If you do want to store a generated file ``.py`` file with versioning metadata, +use ``meson.add_dist_script()`` as explained higher up. diff --git a/docs/index.rst b/docs/index.rst index c143fef87..007802164 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -85,6 +85,7 @@ the use of ``meson-python`` and Meson for Python packaging. how-to-guides/debug-builds how-to-guides/shared-libraries how-to-guides/limited-api + how-to-guides/dynamic-versioning reference/limitations projects-using-meson-python diff --git a/pyproject.toml b/pyproject.toml index 4dae4351a..bd109360f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,7 @@ test = [ 'pytest-cov', 'pytest-mock', 'cython >= 3.0.3', # required for Python 3.12 support + 'vcs-versioning', # used by the version-vcs-versioning test package 'wheel', ] docs = [ diff --git a/tests/conftest.py b/tests/conftest.py index be9b52b6a..0fc74cfdc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -93,6 +93,7 @@ def in_git_repo_context(path=os.path.curdir): subprocess.run(['git', 'config', 'user.name', 'A U Thor'], cwd=path, check=True) subprocess.run(['git', 'add', '*'], cwd=path, check=True) subprocess.run(['git', 'commit', '-q', '-m', 'Test'], cwd=path, check=True) + subprocess.run(['git', 'tag', 'v1.2.3'], cwd=path, check=True) yield finally: # PermissionError raised on Windows. diff --git a/tests/packages/dynamic-version-from-script/dynamic_version_from_script/__init__.py b/tests/packages/dynamic-version-from-script/dynamic_version_from_script/__init__.py new file mode 100644 index 000000000..184de77b1 --- /dev/null +++ b/tests/packages/dynamic-version-from-script/dynamic_version_from_script/__init__.py @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: 2026 The meson-python developers +# +# SPDX-License-Identifier: MIT + +from dynamic_version_from_script._version import __git_hash__, __version__ + + +__all__ = ['__git_hash__', '__version__'] diff --git a/tests/packages/dynamic-version-from-script/generate_version.py b/tests/packages/dynamic-version-from-script/generate_version.py new file mode 100755 index 000000000..6711b49c9 --- /dev/null +++ b/tests/packages/dynamic-version-from-script/generate_version.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 The meson-python developers +# +# SPDX-License-Identifier: MIT + +import argparse +import os +import subprocess + + +try: + import tomllib +except ModuleNotFoundError: + import tomli as tomllib + + +def get_version_from_pyproject(): + here = os.path.dirname(os.path.abspath(__file__)) + pyproject_toml = os.path.join(here, 'pyproject.toml') + with open(pyproject_toml, 'rb') as f: + return tomllib.load(f)['project']['version'] + + +def get_git_hash(): + here = os.path.dirname(os.path.abspath(__file__)) + try: + result = subprocess.run( + ['git', 'rev-parse', 'HEAD'], + cwd=here, + capture_output=True, + check=True, + text=True, + ) + except (subprocess.CalledProcessError, FileNotFoundError): + return 'unknown' + return result.stdout.strip() + + +def write_version_file(outfile, version, git_hash): + if 'MESON_DIST_ROOT' in os.environ: + outfile = os.path.join(os.environ['MESON_DIST_ROOT'], outfile) + with open(outfile, 'w') as f: + f.write(f"__version__ = '{version}'\n") + f.write(f"__git_hash__ = '{git_hash}'\n") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('outfile', nargs='?') + args = parser.parse_args() + + version = get_version_from_pyproject() + if args.outfile is None: + print(version) + else: + write_version_file(args.outfile, version, get_git_hash()) + + +if __name__ == '__main__': + main() diff --git a/tests/packages/dynamic-version-from-script/meson.build b/tests/packages/dynamic-version-from-script/meson.build new file mode 100644 index 000000000..003d92eef --- /dev/null +++ b/tests/packages/dynamic-version-from-script/meson.build @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: 2026 The meson-python developers +# +# SPDX-License-Identifier: MIT + +project( + 'dynamic-version-from-script', + version: run_command(['generate_version.py'], check: true).stdout().strip(), +) + +fs = import('fs') +py = import('python').find_installation() + +py.install_sources( + 'dynamic_version_from_script/__init__.py', + subdir: 'dynamic_version_from_script', +) + +meson.add_dist_script('generate_version.py', 'dynamic_version_from_script/_version.py') + +if not fs.exists('dynamic_version_from_script/_version.py') + custom_target( + 'write_version_file', + output: '_version.py', + command: ['generate_version.py', '@OUTPUT@'], + build_by_default: true, + build_always_stale: true, + install: true, + install_dir: py.get_install_dir() / 'dynamic_version_from_script', + ) +else + # When building from sdist, _version.py exists and should be included + py.install_sources( + ['dynamic_version_from_script/_version.py'], + subdir: 'dynamic_version_from_script', + ) +endif diff --git a/tests/packages/dynamic-version-from-script/pyproject.toml b/tests/packages/dynamic-version-from-script/pyproject.toml new file mode 100644 index 000000000..21e9ae128 --- /dev/null +++ b/tests/packages/dynamic-version-from-script/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 = 'dynamic-version-from-script' +version = '1.2.3' diff --git a/tests/packages/version-vcs-versioning/meson.build b/tests/packages/version-vcs-versioning/meson.build new file mode 100644 index 000000000..4a7c3fa20 --- /dev/null +++ b/tests/packages/version-vcs-versioning/meson.build @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: 2026 The meson-python developers +# +# SPDX-License-Identifier: MIT + +project( + 'version-vcs-versioning', + version: run_command( + [find_program('python3', 'python'), '-m', 'vcs_versioning'], + check: true, + ).stdout().strip(), +) diff --git a/tests/packages/version-vcs-versioning/pyproject.toml b/tests/packages/version-vcs-versioning/pyproject.toml new file mode 100644 index 000000000..888d70cdc --- /dev/null +++ b/tests/packages/version-vcs-versioning/pyproject.toml @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: 2026 The meson-python developers +# +# SPDX-License-Identifier: MIT + +[build-system] +build-backend = 'mesonpy' +requires = ['meson-python', 'vcs-versioning'] + +[project] +name = 'version-vcs-versioning' +dynamic = ['version'] + +[tool.vcs-versioning] +# Configured like this to have a reproducible sdist/wheel filename for tests +fallback_version = '1.2.3' +local_scheme = 'no-local-version' diff --git a/tests/test_sdist.py b/tests/test_sdist.py index 70a3e357e..a11f8a7cd 100644 --- a/tests/test_sdist.py +++ b/tests/test_sdist.py @@ -83,6 +83,31 @@ def test_dynamic_version(sdist_dynamic_version): ''')) +def test_dynamic_version_from_script(sdist_dynamic_version_from_script): + with tarfile.open(sdist_dynamic_version_from_script, 'r:gz') as sdist: + names = {member.name for member in sdist.getmembers()} + sdist_pkg_info = sdist.extractfile('dynamic_version_from_script-1.2.3/PKG-INFO').read() + + assert metadata(sdist_pkg_info) == metadata(textwrap.dedent('''\ + Metadata-Version: 2.1 + Name: dynamic-version-from-script + Version: 1.2.3 + ''')) + + assert 'dynamic_version_from_script-1.2.3/dynamic_version_from_script/_version.py' in names + + +def test_version_vcs_versioning(sdist_version_vcs_versioning): + with tarfile.open(sdist_version_vcs_versioning, 'r:gz') as sdist: + sdist_pkg_info = sdist.extractfile('version_vcs_versioning-1.2.3/PKG-INFO').read() + + assert metadata(sdist_pkg_info) == metadata(textwrap.dedent('''\ + Metadata-Version: 2.1 + Name: version-vcs-versioning + Version: 1.2.3 + ''')) + + def test_contents(sdist_library): with tarfile.open(sdist_library, 'r:gz') as sdist: names = {member.name for member in sdist.getmembers()}