diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08871404..850af7aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -195,6 +195,36 @@ jobs: if: ${{ matrix.task.run-if }} + pre-commit: + name: Check pre-commit integration + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up python + uses: actions/setup-python@v5 + with: + python-version: 3.12 + + - name: Install dependencies + run: python -m pip install pre-commit + + - name: Install pre-commit + run: | + pre-commit install + + - name: Update pre-commit + run: | + pre-commit autoupdate + + - name: Run pre-commit + run: | + pre-commit run -a + + pypi-publish: name: Check tag and publish # Only trigger this for tag changes. @@ -292,6 +322,7 @@ jobs: - test-windows - coverage - check + - pre-commit steps: - name: Require all successes uses: re-actors/alls-green@3a2de129f0713010a71314c74e33c0e3ef90e696 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5e6a6b43..a96ca41a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -33,3 +33,8 @@ repos: - id: debug-statements - id: check-toml - id: check-yaml + + - repo: https://github.com/twisted/towncrier + rev: 23.11.0 + hooks: + - id: towncrier-check diff --git a/RELEASE.rst b/RELEASE.rst index eaeca382..8b457aa7 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -102,7 +102,14 @@ In ``src/towncrier/_version.py`` the version is set using ``incremental`` such a Commit and push the changes. -Merge the commit in the main branch. +Merge the commit in the main branch, **without using squash**. + +We tag the release based on a commit from the release branch. +If we merge with squash, +the release tag commit will no longer be found in the main branch history. +With a squash merge, the whole branch history is lost. +This causes the `pre-commit autoupdate` to fail. +See `PR590 `_ for more details. You can announce the release over IRC or Gitter. diff --git a/docs/configuration.rst b/docs/configuration.rst index f0eaeda4..e7c8a99d 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -43,7 +43,7 @@ Top level keys ``version`` The version of your project. - Python projects that provide the ``package`` key can have the version to be automatically determined from a ``__version__`` variable in the package's module. + Python projects that provide the ``package`` key, if left empty then the version will be automatically determined from the installed package's version metadata or a ``__version__`` variable in the package's module. If not provided or able to be determined, the version must be passed explicitly by the command line argument ``--version``. diff --git a/docs/pre-commit.rst b/docs/pre-commit.rst index 452bd155..ebf04b54 100644 --- a/docs/pre-commit.rst +++ b/docs/pre-commit.rst @@ -16,7 +16,7 @@ Usage with the default configuration repos: - repo: https://github.com/twisted/towncrier - rev: 22.13.0 # run 'pre-commit autoupdate' to update + rev: 23.11.0 # run 'pre-commit autoupdate' to update hooks: - id: towncrier-check @@ -30,7 +30,7 @@ News fragments are stored in ``changelog.d/`` in the root of the repository and repos: - repo: https://github.com/twisted/towncrier - rev: 22.13.0 # run 'pre-commit autoupdate' to update + rev: 23.11.0 # run 'pre-commit autoupdate' to update hooks: - id: towncrier-update files: $changelog\.d/ diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 7138cd9e..258a1323 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -48,19 +48,27 @@ Create this folder:: The ``.gitignore`` will remain and keep Git from not tracking the directory. -Detecting Dates & Versions --------------------------- +Detecting Version +----------------- -``towncrier`` needs to know what version your project is, and there are two ways you can give it: +``towncrier`` needs to know what version your project is when generating news files. +These are the ways you can provide it, in order of precedence (with the first taking precedence over the second, and so on): -- For Python 2/3-compatible projects, a ``__version__`` in the top level package. - This can be either a string literal, a tuple, or an `Incremental `_ version. -- Manually passing ``--version=`` when interacting with ``towncrier``. +1. Manually pass ``--version=`` when interacting with ``towncrier``. +2. Set a value for the ``version`` option in your configuration file. +3. For Python projects with a ``package`` key in the configuration file: -As an example, if your package doesn't have a ``__version__``, you can manually specify it when calling ``towncrier`` on the command line with the ``--version`` flag:: + - install the package to use its metadata version information + - add a ``__version__`` in the top level package that is either a string literal, a tuple, or an `Incremental `_ version + +As an example, you can manually specify the version when calling ``towncrier`` on the command line with the ``--version`` flag:: $ towncrier build --version=1.2.3post4 + +Setting Date +------------ + ``towncrier`` will also include the current date (in ``YYYY-MM-DD`` format) when generating news files. You can change this with the ``--date`` flag:: 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/_settings/load.py b/src/towncrier/_settings/load.py index 66a6c546..724a7768 100644 --- a/src/towncrier/_settings/load.py +++ b/src/towncrier/_settings/load.py @@ -65,26 +65,53 @@ def __init__(self, *args: str, **kwargs: str): def load_config_from_options( directory: str | None, config_path: str | None ) -> tuple[str, Config]: + """ + Load the configuration from a given directory or specific configuration file. + + Unless an explicit configuration file is given, traverse back from the given + directory looking for a configuration file. + + Returns a tuple of the base directory and the parsed Config instance. + """ if config_path is None: - if directory is None: - directory = os.getcwd() + return traverse_for_config(directory) + + config_path = os.path.abspath(config_path) + # When a directory is provided (in addition to the config file), use it as the base + # directory. Otherwise use the directory containing the config file. + if directory is not None: base_directory = os.path.abspath(directory) - config = load_config(base_directory) else: - config_path = os.path.abspath(config_path) - if directory is None: - base_directory = os.path.dirname(config_path) - else: - base_directory = os.path.abspath(directory) - config = load_config_from_file(os.path.dirname(config_path), config_path) + base_directory = os.path.dirname(config_path) - if config is None: - raise ConfigError(f"No configuration file found.\nLooked in: {base_directory}") + if not os.path.isfile(config_path): + raise ConfigError(f"Configuration file '{config_path}' not found.") + config = load_config_from_file(base_directory, config_path) return base_directory, config +def traverse_for_config(path: str | None) -> tuple[str, Config]: + """ + Search for a configuration file in the current directory and all parent directories. + + Returns the directory containing the configuration file and the parsed configuration. + """ + start_directory = directory = os.path.abspath(path or os.getcwd()) + while True: + config = load_config(directory) + if config is not None: + return directory, config + + parent = os.path.dirname(directory) + if parent == directory: + raise ConfigError( + f"No configuration file found.\nLooked back from: {start_directory}" + ) + directory = parent + + def load_config(directory: str) -> Config | None: towncrier_toml = os.path.join(directory, "towncrier.toml") pyproject_toml = os.path.join(directory, "pyproject.toml") diff --git a/src/towncrier/newsfragments/432.doc.rst b/src/towncrier/newsfragments/432.doc.rst new file mode 100644 index 00000000..92e5247f --- /dev/null +++ b/src/towncrier/newsfragments/432.doc.rst @@ -0,0 +1 @@ +Clarify version discovery behavior. 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/newsfragments/571.misc b/src/towncrier/newsfragments/571.misc new file mode 100644 index 00000000..e69de29b diff --git a/src/towncrier/newsfragments/601.feature.rst b/src/towncrier/newsfragments/601.feature.rst new file mode 100644 index 00000000..ca209ca5 --- /dev/null +++ b/src/towncrier/newsfragments/601.feature.rst @@ -0,0 +1 @@ +Running ``towncrier`` will now traverse back up directories looking for the configuration file. diff --git a/src/towncrier/newsfragments/602.doc.rst b/src/towncrier/newsfragments/602.doc.rst new file mode 100644 index 00000000..92e5247f --- /dev/null +++ b/src/towncrier/newsfragments/602.doc.rst @@ -0,0 +1 @@ +Clarify version discovery behavior. diff --git a/src/towncrier/test/test_build.py b/src/towncrier/test/test_build.py index 241425e9..17ffad49 100644 --- a/src/towncrier/test/test_build.py +++ b/src/towncrier/test/test_build.py @@ -121,11 +121,21 @@ def test_in_different_dir_dir_option(self, runner): self.assertEqual(0, result.exit_code) self.assertTrue((project_dir / "NEWS.rst").exists()) + @with_project() + def test_traverse_up_to_find_config(self, runner): + """ + When the current directory doesn't contain the configuration file, Towncrier + will traverse up the directory tree until it finds it. + """ + os.chdir("foo") + result = runner.invoke(_main, ["--draft", "--date", "01-01-2001"]) + self.assertEqual(0, result.exit_code, result.output) + @with_project() def test_in_different_dir_config_option(self, runner): """ The current working directory and the location of the configuration - don't matter as long as we pass corrct paths to the directory and the + don't matter as long as we pass correct paths to the directory and the config file. """ project_dir = Path(".").resolve() 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): diff --git a/src/towncrier/test/test_settings.py b/src/towncrier/test/test_settings.py index 6d1f6041..b08384a5 100644 --- a/src/towncrier/test/test_settings.py +++ b/src/towncrier/test/test_settings.py @@ -207,10 +207,25 @@ def test_load_no_config(self, runner: CliRunner): self.assertEqual( result.output, - f"No configuration file found.\nLooked in: {os.path.abspath(temp)}\n", + f"No configuration file found.\nLooked back from: {os.path.abspath(temp)}\n", ) self.assertEqual(result.exit_code, 1) + @with_isolated_runner + def test_load_explicit_missing_config(self, runner: CliRunner): + """ + Calling the CLI with an incorrect explicit configuration file will exit with + code 1 and an informative message is sent to standard output. + """ + config = "not-there.toml" + result = runner.invoke(cli, ("--config", config)) + + self.assertEqual(result.exit_code, 1) + self.assertEqual( + result.output, + f"Configuration file '{os.path.abspath(config)}' not found.\n", + ) + def test_missing_template(self): """ Towncrier will raise an exception saying when it can't find a template.