From 45d743ac886857f19f4376a30c95911934fae462 Mon Sep 17 00:00:00 2001 From: Ben Mares Date: Wed, 1 Jan 2025 20:04:04 +0200 Subject: [PATCH 1/4] Remove unnecessary version-file build hook --- pyproject.toml | 3 --- 1 file changed, 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 165bc58..fa9bcef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,3 @@ Homepage = "https://github.com/maresb/hatch-vcs-footgun-example" [tool.hatch.version] source = "vcs" - -[tool.hatch.build.hooks.vcs] -version-file = "hatch_vcs_footgun_example/_version.py" From 3bda28ce097ead57dcf29520fc652c605ccbf686 Mon Sep 17 00:00:00 2001 From: Ben Mares Date: Wed, 1 Jan 2025 20:07:15 +0200 Subject: [PATCH 2/4] Fix #3 --- hatch_vcs_footgun_example/__init__.py | 27 +++++----------- hatch_vcs_footgun_example/main.py | 2 +- hatch_vcs_footgun_example/version.py | 45 +++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 20 deletions(-) mode change 100644 => 100755 hatch_vcs_footgun_example/main.py create mode 100644 hatch_vcs_footgun_example/version.py diff --git a/hatch_vcs_footgun_example/__init__.py b/hatch_vcs_footgun_example/__init__.py index 0ce84a2..eb030f0 100644 --- a/hatch_vcs_footgun_example/__init__.py +++ b/hatch_vcs_footgun_example/__init__.py @@ -1,20 +1,9 @@ -# Define the variable '__version__': -try: - # If setuptools_scm is installed (e.g. in a development environment with - # an editable install), then use it to determine the version dynamically. - from setuptools_scm import get_version +"""Project initialization. - # This will fail with LookupError if the package is not installed in - # editable mode or if Git is not installed. - __version__ = get_version(root="..", relative_to=__file__) -except (ImportError, LookupError): - # As a fallback, use the version that is hard-coded in the file. - try: - from hatch_vcs_footgun_example._version import __version__ # noqa: F401 - except ModuleNotFoundError: - # The user is probably trying to run this without having installed - # the package, so complain. - raise RuntimeError( - "Hatch VCS Footgun Example is not correctly installed. " - "Please install it with pip." - ) +It is a popular convention to define `__version__` in the top-level `__init__.py`. +""" + +from hatch_vcs_footgun_example.version import __version__ + + +__all__ = ["__version__"] diff --git a/hatch_vcs_footgun_example/main.py b/hatch_vcs_footgun_example/main.py old mode 100644 new mode 100755 index 4fde653..f2d0842 --- a/hatch_vcs_footgun_example/main.py +++ b/hatch_vcs_footgun_example/main.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -from hatch_vcs_footgun_example import __version__ +from hatch_vcs_footgun_example.version import __version__ if __name__ == "__main__": print(f"My version is '{__version__}'.") diff --git a/hatch_vcs_footgun_example/version.py b/hatch_vcs_footgun_example/version.py new file mode 100644 index 0000000..a92717f --- /dev/null +++ b/hatch_vcs_footgun_example/version.py @@ -0,0 +1,45 @@ +"""Compute the version number and store it in the `__version__` variable.""" + + +def _get_hatch_version(): + """Compute the most up-to-date version number in a development environment. + + Returns `None` if Hatchling is not installed, e.g. in a production environment. + + For more details, see . + """ + import os + + try: + from hatchling.metadata.core import ProjectMetadata + from hatchling.plugin.manager import PluginManager + from hatchling.utils.fs import locate_file + except ImportError: + # Hatchling is not installed, so probably we are not in + # a development environment. + return None + + pyproject_toml = locate_file(__file__, "pyproject.toml") + if pyproject_toml is None: + raise RuntimeError("pyproject.toml not found although hatchling is installed") + root = os.path.dirname(pyproject_toml) + metadata = ProjectMetadata(root=root, plugin_manager=PluginManager()) + # Version can be either statically set in pyproject.toml or computed dynamically: + return metadata.core.version or metadata.hatch.version.cached + + +def _get_importlib_metadata_version(): + """Compute the version number using importlib.metadata. + + This is the official Pythonic way to get the version number of an installed + package. However, it is only updated when a package is installed. Thus, if a + package is installed in editable mode, and a different version is checked out, + then the version number will not be updated. + """ + from importlib.metadata import version + + __version__ = version(__package__) + return __version__ + + +__version__ = _get_hatch_version() or _get_importlib_metadata_version() From 59764840cebdbaa77eae332c7c8adbf39ff3e6ea Mon Sep 17 00:00:00 2001 From: Ben Mares Date: Wed, 1 Jan 2025 20:54:39 +0200 Subject: [PATCH 3/4] Update README for 2025 --- README.md | 148 +++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 102 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index f02a327..1e00c4e 100644 --- a/README.md +++ b/README.md @@ -4,39 +4,30 @@ [![Hatch project](https://img.shields.io/badge/%F0%9F%A5%9A-Hatch-4051b5.svg)](https://github.com/pypa/hatch) [![License: Unlicense](https://img.shields.io/github/license/maresb/hatch-vcs-footgun-example)](LICENSE) -A somewhat hacky usage of the [Hatch VCS build hook](https://github.com/ofek/hatch-vcs#build-hook) which ensures that the `__version__` variable stays up-to-date, even when the project is installed in editable mode. +A somewhat hacky usage of the [Hatch VCS](https://github.com/ofek/hatch-vcs) plugin to ensure that the `__version__` variable stays up-to-date, even when the project is installed in editable mode. ## Quick summary 1. Ensure that [Hatch VCS](https://pypi.org/project/hatch-vcs/) is configured in [`pyproject.toml`](pyproject.toml). -1. Copy the contents of [`__init__.py`](hatch_vcs_footgun_example/__init__.py) and adjust to your project. -1. Add `_version.py` to your [`.gitignore`](.gitignore) file. -1. Install `setuptools-scm` as a development dependency. +1. Copy the contents of [`version.py`](hatch_vcs_footgun_example/version.py) and adjust to your project. +1. Install `hatch-vcs` as a development-only dependency. 1. Enjoy up-to-date version numbers, even in editable mode. ## Background -For consistency's sake, it's good to have a single source of truth for the version number of your project. However, there are four common places where the version number appears in most modern Python projects: +For consistency's sake, it's good to have a single source of truth for the version number of your project. However, there are at least four common places where the version number commonly appears in modern Python projects: 1. The `version` field of the `[project]` section of `pyproject.toml`. 1. The dist-info `METADATA` file from when the project was installed. 1. The `__version__` variable in the `__init__.py` file of the project's main package. 1. The Git tag of the release commit. -With Hatch VCS, the definitive source of truth is the Git tag. One often still needs a technique to access this version number programmatically. The [quasi-standard](https://stackoverflow.com/a/459185) is to store it in the `__version__` variable, so that the following works: - -```python -import myproject - -print(myproject.__version__) -``` +With Hatch VCS, the definitive source of truth is the Git tag. One often still needs a technique to access this version number programmatically. For example, a CLI tool might print its version. ## Standard solutions 1. ### Dynamically read the version number from the package metadata with `importlib.metadata` - For Python 3.8 and higher, one can do: - ```python # __init__.py from importlib.metadata import version @@ -44,79 +35,144 @@ print(myproject.__version__) __version__ = version("myproject") ``` - This works well in most cases, and does *not* require the Hatch VCS build hook. + This works well in most cases, and does *not* require the Hatch VCS plugin. If your project is properly installed, you can even replace `"myproject"` with `__package__`. There are two important caveats to this approach. - 1. The version number comes from the last time the project was installed. In case you are developing your project in editable mode, the reported version may be outdated unless you remember to reinstall each time the version number changes. + 1. The version number comes from the last time the project was installed. In case you are developing your project in editable mode, the reported version may be outdated unless you remember to reinstall each time the version number changes. - 2. Parsing the `METADATA` file can be relatively slow. If performance is crucial and every millisecond counts (e.g. if one is writing a CLI tool), then this is not an ideal solution. - - (For compatibility with Python 3.7 and lower, see [the examples here](https://packaging.python.org/en/latest/guides/single-sourcing-package-version/) regarding `importlib_metadata`.) + 2. Parsing the `METADATA` file can be relatively slow. If performance is crucial and every millisecond of startup time counts (e.g. if one is writing a CLI tool), then this is not an ideal solution. 1. ### Use a static `_version.py` file - Using the [Hatch VCS build hook](https://github.com/ofek/hatch-vcs#build-hook), a `_version.py` file is generated when either building a distribution or installing the project from source. - - ```python - # __init__.py - from myproject._version import __version__ - ``` + If using the [Hatch VCS build hook](https://github.com/ofek/hatch-vcs#build-hook) option of the `hatch-vcs` plugin, a `_version.py` file will be generated when either building a distribution or installing the project from source. Since `_version.py` is generated dynamically, it should be added to `.gitignore`. As with the `importlib.metadata` approach, if the project is installed in editable mode then the `_version.py` file will not be updated unless the package is reinstalled (or locally rebuilt). -1. ### Compute the version number at runtime with `setuptools_scm` +1. ### Use `hatch-vcs` to dynamically compute the version number at runtime + + This strategy has several requirements: + + 1. The `pyproject.toml` file must be present. (This is usually _not_ a viable option because this file is typically absent when a project is installed from a wheel!) + 2. The `hatch-vcs` plugin must be installed. (This is usually only true in the build environment.) + 3. `git` must be available, and the tags must be accessible and up-to-date. + + This is very fragile, but has the advantage that when it works, the version number is always up-to-date, even for an editable installation. + + This method should always be used with a fallback to one of the other two methods to avoid failure when the requirements are not met. For example, a production deployment will typically not have `git`, `hatchling`, or `hatch-vcs` installed. + +## Troubleshooting + +There are many potential pitfalls to this approach. Please open an issue if you encounter one not covered here, or if the solution is insufficient. + +* ### The version number computed by `hatch-vcs` is incorrect + + Ensure that your clone of the repository has the latest tags. You may need to run + + ```bash + git pull --tags + ``` - Using `setuptools_scm` as follows only succeeds in particular cases: +* ### `Unknown version source: vcs` + + Install `hatch-vcs` in your development environment. + + If you see this in your production environment, then uninstall `hatchling`. + +* ### `ValueError: A distribution name is required.` + + This occurs when the `__package__` variable is not set. Always ensure that you invoke your package as a module. + + Correct: + + ```bash + python -m mypackage.main + ``` + + Incorrect: + + ```bash + python mypackage/main.py + ``` + + (The latter should only be used for running scripts that are not part of a package!) + +* ### `LookupError: Error getting the version from source `vcs`: setuptools-scm was unable to detect version` + + This can occur if `git` is not correctly installed. + +* ### `ImportError: cannot import name '__version__' from partially initialized module '...' (most likely due to a circular import)` + + This can occur when importing `__version__` from the top-level `__init__.py` file. + + Instead, import `__version__` from `version.py`. + + For example, the following is a classical circular import: ```python - from setuptools_scm import get_version + # __init__.py + import myproject.initialize + from myproject.version import __version__ + ``` - __version__ = get_version(root="..", relative_to=__file__) + ```python + # initialize.py + from myproject import __version__ + print(f"{__version__=}") ``` - It requires that `setuptools_scm` is installed in the runtime environment alongside the VCS tool (`git` or `hg`), and in order to read the tags, the project must be installed from a source repository. + while the following is not: - This is very fragile, but has the advantage that when it works, the version number is always up-to-date, even for an editable installation. + ```python + # __init__.py + import myproject.initialize + from myproject.version import __version__ + ``` + + ```python + # initialize.py + from myproject.version import __version__ # Always import from version.py! + print(f"{__version__=}") + ``` -Note that parsing the version from `pyproject.toml` is usually _not_ a viable option because this file is typically absent when a project is installed from a wheel! +* ### `ImportError: attempted relative import with no known parent package` -## Conclusion + Ensure that the project is properly installed, e.g. by running `pip install -editable .`. -In most cases, using `importlib.metadata.version` or a `_version.py` are the best solutions. In the second case, the Hatch VCS build hook is a good way to generate the `_version.py` file. However, this data can become outdated during development with an editable install. If reporting the correct version during development is important, then the hybrid approach as follows may be desirable. +## Conclusion -## Why "Footgun"? +In most cases, using `importlib.metadata.version` is the best solution. However, this data can become outdated during development with an editable install. If reporting the correct version during development is important, then the hybrid approach implemented in [`version.py`](hatch_vcs_footgun_example/version.py) may be desirable: -In case you are developing in editable mode, and it is important that the version number be kept up-to-date automatically, then it is possible to use a solution similar to that illustrated in this example. Namely: +- Default to using `hatch-vcs` to compute the version number at runtime. +- Fall back to using `importlib.metadata.version` if `hatchling` is not installed. -- Default to using `setuptools_scm` to set `__version__`. -- When that fails, fall back to `_version.py`. +## Why "Footgun"? -However, it is somewhat of a [footgun](https://en.wiktionary.org/wiki/footgun): it involves distinct version detection mechanisms between development and deployment. Furthermore, this technique is unsupported, so it must be used at your own risk. +This hybrid approach is somewhat of a [footgun](https://en.wiktionary.org/wiki/footgun): it involves distinct version detection mechanisms between development and deployment. Ideally you should always remember to reinstall the package whenever checking out a new commit so that you can simply use the standard `importlib.metadata.version` mechanism. In constrast, the hybrid approach is unsupported, so it must be used at your own risk. ## Usage After cloning this repository, ```bash -python -m hatch_vcs_footgun_example.main # Prints an error because it's not installed -pip install --editable . # Installs and creates the "_version.py" file -python -m hatch_vcs_footgun_example.main # Prints "My version is '1.0.2'." +python -m hatch_vcs_footgun_example.main # PackageNotFoundError because it's not installed +pip install --editable . +python -m hatch_vcs_footgun_example.main # Prints "My version is '1.0.3'." ``` -Without `setuptools-scm` installed, the version number is reported incorrectly after a new tag. +Without `hatch-vcs` installed, the version number is reported incorrectly after a new tag. ```bash git commit --allow-empty -m "For v1.2.3" git tag v1.2.3 -python -m hatch_vcs_footgun_example.main # My version is '1.0.2'. +python -m hatch_vcs_footgun_example.main # My version is '1.0.3'. ``` -With `setuptools-scm` installed the version is correctly reported: +With `hatch-vcs` installed the version is correctly reported: ```bash -pip install setuptools-scm +pip install hatch-vcs python -m hatch_vcs_footgun_example.main # My version is '1.2.3'. ``` From dd7fa9d4d5ea656d2ac16b2fbb7d7a499ae8ce3b Mon Sep 17 00:00:00 2001 From: Ben Mares Date: Wed, 1 Jan 2025 21:07:46 +0200 Subject: [PATCH 4/4] Add Python 3.7 note --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 1e00c4e..f252fb0 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,11 @@ There are many potential pitfalls to this approach. Please open an issue if you Ensure that the project is properly installed, e.g. by running `pip install -editable .`. +* ### `ModuleNotFoundError: No module named 'importlib.metadata'` + + For end-of-life versions of Python below 3.8, the `importlib.metadata` module is not available. In this case, you need to install the `importlib-metadata` backport and + fall back to `importlib_metadata` in place of `importlib.metadata`. + ## Conclusion In most cases, using `importlib.metadata.version` is the best solution. However, this data can become outdated during development with an editable install. If reporting the correct version during development is important, then the hybrid approach implemented in [`version.py`](hatch_vcs_footgun_example/version.py) may be desirable: