diff --git a/docs/how-to-guides/dynamic-versioning.rst b/docs/how-to-guides/dynamic-versioning.rst new file mode 100644 index 00000000..f5f28e7c --- /dev/null +++ b/docs/how-to-guides/dynamic-versioning.rst @@ -0,0 +1,180 @@ +.. 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 - all popular ones (e.g., +``setuptools-scm``, ``versioneer``, ``versioningit``) 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 ``setuptools-scm``; 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-setuptools-scm/pyproject.toml + :language: toml + :lines: 5-12 + +In ``meson.build``, invoke ``setuptools-scm`` to compute the version: + +.. literalinclude:: ../../tests/packages/version-setuptools-scm/meson.build + :language: meson + :lines: 5- + +That's it. You can use ``setuptools-scm`` 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 f56b7ebc..0ca520b6 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/dynamic-versioning reference/limitations projects-using-meson-python diff --git a/pyproject.toml b/pyproject.toml index f424455c..a43c3ff2 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 + 'setuptools-scm', # used by the version-setuptools-scm test package 'wheel', 'typing-extensions >= 3.7.4; python_version < "3.11"', ] diff --git a/tests/conftest.py b/tests/conftest.py index 919a57c7..d5c2846f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -84,6 +84,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 00000000..184de77b --- /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 00000000..674173e1 --- /dev/null +++ b/tests/packages/dynamic-version-from-script/generate_version.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 The meson-python developers +# +# SPDX-License-Identifier: MIT + +import argparse +import os +import subprocess + + +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) as f: + for line in f: + if line.startswith('version ='): + return line.split('=', 1)[1].strip().strip('\'"') + raise RuntimeError('version not found in pyproject.toml') + + +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() + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument('--print-version', action='store_true') + group.add_argument('-o', '--outfile') + args = parser.parse_args() + + version = get_version_from_pyproject() + if args.print_version: + print(version) + return + 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 00000000..e192eb3b --- /dev/null +++ b/tests/packages/dynamic-version-from-script/meson.build @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: 2026 The meson-python developers +# +# SPDX-License-Identifier: MIT + +project( + 'dynamic-version-from-script', + version: run_command( + ['generate_version.py', '--print-version'], + 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', '-o', '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', '-o', '@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 00000000..21e9ae12 --- /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-setuptools-scm/meson.build b/tests/packages/version-setuptools-scm/meson.build new file mode 100644 index 00000000..09109aa0 --- /dev/null +++ b/tests/packages/version-setuptools-scm/meson.build @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: 2026 The meson-python developers +# +# SPDX-License-Identifier: MIT + +project( + 'version-setuptools-scm', + version: run_command( + ['python3', '-m', 'setuptools_scm'], + check: true, + ).stdout().strip(), +) diff --git a/tests/packages/version-setuptools-scm/pyproject.toml b/tests/packages/version-setuptools-scm/pyproject.toml new file mode 100644 index 00000000..d1749b31 --- /dev/null +++ b/tests/packages/version-setuptools-scm/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', 'setuptools-scm[simple]'] + +[project] +name = 'version-setuptools-scm' +dynamic = ['version'] + +[tool.setuptools_scm] +# 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 70a3e357..e8d8d1c6 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_setuptools_scm(sdist_version_setuptools_scm): + with tarfile.open(sdist_version_setuptools_scm, 'r:gz') as sdist: + sdist_pkg_info = sdist.extractfile('version_setuptools_scm-1.2.3/PKG-INFO').read() + + assert metadata(sdist_pkg_info) == metadata(textwrap.dedent('''\ + Metadata-Version: 2.1 + Name: version-setuptools-scm + 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()}