Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ensure config is passed to hatch-vcs and update README #4

Merged
merged 4 commits into from
Jan 1, 2025
Merged
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
153 changes: 107 additions & 46 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,119 +4,180 @@
[![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

__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.

Using `setuptools_scm` as follows only succeeds in particular cases:
## 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
```

* ### `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
# __init__.py
import myproject.initialize
from myproject.version import __version__
```

```python
from setuptools_scm import get_version
# initialize.py
from myproject import __version__
print(f"{__version__=}")
```

__version__ = get_version(root="..", relative_to=__file__)
while the following is not:

```python
# __init__.py
import myproject.initialize
from myproject.version import __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.
```python
# initialize.py
from myproject.version import __version__ # Always import from version.py!
print(f"{__version__=}")
```

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.
* ### `ImportError: attempted relative import with no known parent package`

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!
Ensure that the project is properly installed, e.g. by running `pip install -editable .`.

## Conclusion
* ### `ModuleNotFoundError: No module named 'importlib.metadata'`

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.
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`.

## Why "Footgun"?
## 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:

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'.
```
27 changes: 8 additions & 19 deletions hatch_vcs_footgun_example/__init__.py
Original file line number Diff line number Diff line change
@@ -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__"]
2 changes: 1 addition & 1 deletion hatch_vcs_footgun_example/main.py
100644 → 100755
Original file line number Diff line number Diff line change
@@ -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__}'.")
45 changes: 45 additions & 0 deletions hatch_vcs_footgun_example/version.py
Original file line number Diff line number Diff line change
@@ -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 <https://github.com/maresb/hatch-vcs-footgun-example/>.
"""
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()
3 changes: 0 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"