diff --git a/pyproject.toml b/pyproject.toml index 245e7dd4..d6f8fa6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ requires-python = ">=3.8" dependencies = [ "click", "importlib-resources>=5; python_version<'3.10'", + "importlib-metadata>=4.6; python_version<'3.10'", "incremental", "jinja2", "tomli; python_version<'3.11'", diff --git a/src/towncrier/_project.py b/src/towncrier/_project.py index cfaa11fa..53e72e95 100644 --- a/src/towncrier/_project.py +++ b/src/towncrier/_project.py @@ -5,17 +5,24 @@ Responsible for getting the version and name from a project. """ - from __future__ import annotations import sys from importlib import import_module +from importlib.metadata import version as metadata_version from types import ModuleType +from typing import Any from incremental import Version as IncrementalVersion +if sys.version_info >= (3, 10): + from importlib.metadata import packages_distributions +else: + from importlib_metadata import packages_distributions # type: ignore + + def _get_package(package_dir: str, package: str) -> ModuleType: try: module = import_module(package) @@ -37,13 +44,40 @@ def _get_package(package_dir: str, package: str) -> ModuleType: return module +def _get_metadata_version(package: str) -> str | None: + """ + Try to get the version from the package metadata. + """ + distributions = packages_distributions() + distribution_names = distributions.get(package) + if not distribution_names or len(distribution_names) != 1: + # We can only determine the version if there is exactly one matching distribution. + return None + return metadata_version(distribution_names[0]) + + def get_version(package_dir: str, package: str) -> str: + """ + Get the version of a package. + + Try to extract the version from the distribution version metadata that matches + `package`, then fall back to looking for the package in `package_dir`. + """ + version: Any + + # First try to get the version from the package metadata. + if version := _get_metadata_version(package): + return version + + # When no version if found, fall back to looking for the package in `package_dir`. module = _get_package(package_dir, package) version = getattr(module, "__version__", None) if not version: - raise Exception("No __version__, I don't know how else to look") + raise Exception( + f"No __version__ or metadata version info for the '{package}' package." + ) if isinstance(version, str): return version.strip() diff --git a/src/towncrier/newsfragments/432.feature.rst b/src/towncrier/newsfragments/432.feature.rst new file mode 100644 index 00000000..94f19ea4 --- /dev/null +++ b/src/towncrier/newsfragments/432.feature.rst @@ -0,0 +1 @@ +Inferring the version of a Python package now tries to use the metadata of the installed package before importing the package explicitly (which only looks for ``[package].__version__``). diff --git a/src/towncrier/test/test_project.py b/src/towncrier/test/test_project.py index 45875683..62ec3d77 100644 --- a/src/towncrier/test/test_project.py +++ b/src/towncrier/test/test_project.py @@ -4,7 +4,8 @@ import os import sys -from unittest import skipIf +from importlib.metadata import version as metadata_version +from unittest import mock from click.testing import CliRunner from twisted.trial.unittest import TestCase @@ -14,12 +15,6 @@ from .helpers import write -try: - from importlib.metadata import version as metadata_version -except ImportError: - metadata_version = None - - towncrier_cli.name = "towncrier" @@ -50,7 +45,6 @@ def test_tuple(self): version = get_version(temp, "mytestproja") self.assertEqual(version, "1.3.12") - @skipIf(metadata_version is None, "Needs importlib.metadata.") def test_incremental(self): """ An incremental Version __version__ is picked up. @@ -60,6 +54,14 @@ def test_incremental(self): with self.assertWarnsRegex( DeprecationWarning, "Accessing towncrier.__version__ is deprecated.*" ): + # Previously this triggered towncrier.__version__ but now the first version + # check is from the package metadata. Let's mock out that part to ensure we + # can get incremental versions from __version__ still. + with mock.patch( + "towncrier._project._get_metadata_version", return_value=None + ): + version = get_version(pkg, "towncrier") + version = get_version(pkg, "towncrier") with self.assertWarnsRegex( @@ -70,6 +72,13 @@ def test_incremental(self): self.assertEqual(metadata_version("towncrier"), version) self.assertEqual("towncrier", name) + def test_version_from_metadata(self): + """ + A version from package metadata is picked up. + """ + version = get_version(".", "towncrier") + self.assertEqual(metadata_version("towncrier"), version) + def _setup_missing(self): """ Create a minimalistic project with missing metadata in a temporary @@ -91,10 +100,12 @@ def test_missing_version(self): tmp_dir = self._setup_missing() with self.assertRaises(Exception) as e: + # The 'missing' package has no __version__ string. get_version(tmp_dir, "missing") self.assertEqual( - ("No __version__, I don't know how else to look",), e.exception.args + ("No __version__ or metadata version info for the 'missing' package.",), + e.exception.args, ) def test_missing_version_project_name(self):