From d2fe36fa5bb5bb13bfbeb3461154fadd5d709993 Mon Sep 17 00:00:00 2001 From: Tom Most Date: Sat, 27 Jul 2024 16:51:24 -0700 Subject: [PATCH] Defense in depth It seems we must rely on heuristics, so let's go all the way. --- src/incremental/__init__.py | 31 +++++---- src/incremental/_hatch.py | 2 +- src/incremental/newsfragments/106.bugfix | 2 +- src/incremental/update.py | 14 ++-- tests/test_examples.py | 81 ++++++++++++++++++++++++ 5 files changed, 110 insertions(+), 20 deletions(-) diff --git a/src/incremental/__init__.py b/src/incremental/__init__.py index 7da0359..9ded25c 100644 --- a/src/incremental/__init__.py +++ b/src/incremental/__init__.py @@ -374,14 +374,13 @@ def _findPath(path, package): # type: (str, str) -> str ) -def _existing_version(path): # type: (str) -> Version +def _existing_version(version_path): # type: (str) -> Version """ - Load the current version from {path}/_version.py. + Load the current version from a ``_version.py`` file. """ version_info = {} # type: Dict[str, Version] - versionpath = os.path.join(path, "_version.py") - with open(versionpath, "r") as f: + with open(version_path, "r") as f: exec(f.read(), version_info) return version_info["__version__"] @@ -393,8 +392,7 @@ def _get_setuptools_version(dist): # type: (_Distribution) -> None This function is registered as a setuptools.finalize_distribution_options entry point [1]. Consequently, it is called in all sorts of weird - contexts, so it strives to not raise unless there is a pyproject.toml - containing a [tool.incremental] section. + contexts. In setuptools, silent failure is the law. [1]: https://setuptools.pypa.io/en/latest/userguide/extension.html#customizing-distribution-options @@ -411,10 +409,16 @@ def _get_setuptools_version(dist): # type: (_Distribution) -> None config = _load_pyproject_toml("./pyproject.toml") except Exception: return - if not config or not config.opt_in: + + if not config.opt_in: return - dist.metadata.version = _existing_version(config.path).public() + try: + version = _existing_version(config.version_path) + except Exception: + return + + dist.metadata.version = version.public() def _get_distutils_version(dist, keyword, value): # type: (_Distribution, object, object) -> None @@ -435,8 +439,8 @@ def _get_distutils_version(dist, keyword, value): # type: (_Distribution, objec for item in sp_command.find_all_modules(): if item[1] == "_version": - package_path = os.path.dirname(item[2]) - dist.metadata.version = _existing_version(package_path).public() + version_path = os.path.join(os.path.dirname(item[2]), "_version.py") + dist.metadata.version = _existing_version(version_path).public() return raise Exception("No _version.py found.") # pragma: no cover @@ -475,8 +479,13 @@ class _IncrementalConfig: path: str """Path to the package root""" + @property + def version_path(self): # type: () -> str + """Path of the ``_version.py`` file. May not exist.""" + return os.path.join(self.path, "_version.py") + -def _load_pyproject_toml(toml_path): # type: (str) -> Optional[_IncrementalConfig] +def _load_pyproject_toml(toml_path): # type: (str) -> _IncrementalConfig """ Load Incremental configuration from a ``pyproject.toml`` diff --git a/src/incremental/_hatch.py b/src/incremental/_hatch.py index 7777adc..39ddcb7 100644 --- a/src/incremental/_hatch.py +++ b/src/incremental/_hatch.py @@ -23,7 +23,7 @@ def get_version_data(self) -> _VersionData: # type: ignore[override] # If the Hatch plugin is running at all we've already opted in. config = _load_pyproject_toml(path) assert config is not None, "Failed to read {}".format(path) - return {"version": _existing_version(config.path).public()} + return {"version": _existing_version(config.version_path).public()} def set_version(self, version: str, version_data: Dict[Any, Any]) -> None: raise NotImplementedError( diff --git a/src/incremental/newsfragments/106.bugfix b/src/incremental/newsfragments/106.bugfix index 63749ba..4697af2 100644 --- a/src/incremental/newsfragments/106.bugfix +++ b/src/incremental/newsfragments/106.bugfix @@ -1,3 +1,3 @@ Incremental could mis-identify that a project had opted in to version management. -If a ``pyproject.toml`` in the current directory contained a ``[project]`` table with a ``name`` key, but did not contain the opt-in ``[tool.incremental]`` table, Incremental would still treat the file as if the opt-in were present and attempt to validate the configuration. This could happen in contexts outside of packaging, such as when creating a virtualenv. When operating as a setuptools plugin Incremental now always requires the ``[tool.incremental]`` opt-in. Additionally, it suppresses any exceptions that occur while attempting to read ``pyproject.toml`` until it finds a valid ``[tool.incremental]`` table. +If a ``pyproject.toml`` in the current directory contained a ``[project]`` table with a ``name`` key, but did not contain the opt-in ``[tool.incremental]`` table, Incremental would still treat the file as if the opt-in were present and attempt to validate the configuration. This could happen in contexts outside of packaging, such as when creating a virtualenv. When operating as a setuptools plugin Incremental now always ignores invalid configuration, such as configuration that doesn't match the content of the working directory. diff --git a/src/incremental/update.py b/src/incremental/update.py index f3e33ee..0c92e77 100644 --- a/src/incremental/update.py +++ b/src/incremental/update.py @@ -77,10 +77,11 @@ def _run( ): raise ValueError("Only give --create") + versionpath = os.path.join(path, "_version.py") if newversion: from pkg_resources import parse_version - existing = _existing_version(path) + existing = _existing_version(versionpath) st_version = parse_version(newversion)._version # type: ignore[attr-defined] release = list(st_version.release) @@ -109,7 +110,7 @@ def _run( existing = v elif rc and not patch: - existing = _existing_version(path) + existing = _existing_version(versionpath) if existing.release_candidate: v = Version( @@ -123,7 +124,7 @@ def _run( v = Version(package, _date.year - _YEAR_START, _date.month, 0, 1) elif patch: - existing = _existing_version(path) + existing = _existing_version(versionpath) v = Version( package, existing.major, @@ -133,7 +134,7 @@ def _run( ) elif post: - existing = _existing_version(path) + existing = _existing_version(versionpath) if existing.post is None: _post = 0 @@ -143,7 +144,7 @@ def _run( v = Version(package, existing.major, existing.minor, existing.micro, post=_post) elif dev: - existing = _existing_version(path) + existing = _existing_version(versionpath) if existing.dev is None: _dev = 0 @@ -160,7 +161,7 @@ def _run( ) else: - existing = _existing_version(path) + existing = _existing_version(versionpath) if existing.release_candidate: v = Version(package, existing.major, existing.minor, existing.micro) @@ -212,7 +213,6 @@ def _run( with open(filepath, "wb") as f: f.write(content) - versionpath = os.path.join(path, "_version.py") _print("Updating %s" % (versionpath,)) with open(versionpath, "wb") as f: f.write( diff --git a/tests/test_examples.py b/tests/test_examples.py index efe8be0..879e2c5 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -69,6 +69,87 @@ def test_setuptools_version(self): self.assertEqual(example_setuptools.__version__.base(), "2.3.4") self.assertEqual(metadata.version("example_setuptools"), "2.3.4") + def test_setuptools_no_optin(self): + """ + The setuptools plugin is a no-op when there isn't a + [tool.incremental] table in pyproject.toml. + """ + src = FilePath(self.mktemp()) + src.makedirs() + src.child("pyproject.toml").setContent( + b"""\ +[project] +name = "example_no_optin" +version = "0.0.0" +""" + ) + package_dir = src.child("example_no_optin") + package_dir.makedirs() + package_dir.child("__init__.py").setContent(b"") + package_dir.child("_version.py").setContent( + b"from incremental import Version\n" + b'__version__ = Version("example_no_optin", 24, 7, 0)\n' + ) + + build_and_install(src) + + self.assertEqual(metadata.version("example_no_optin"), "0.0.0") + + def test_setuptools_no_package(self): + """ + The setuptools plugin is a no-op when there isn't a + package directory that matches the project name. + """ + src = FilePath(self.mktemp()) + src.makedirs() + src.child("pyproject.toml").setContent( + b"""\ +[project] +name = "example_no_package" +version = "0.0.0" + +[tool.incremental] +""" + ) + + build_and_install(src) + + self.assertEqual(metadata.version("example_no_package"), "0.0.0") + + def test_setuptools_bad_versionpy(self): + """ + The setuptools plugin is a no-op when reading the version + from ``_version.py`` fails. + """ + src = FilePath(self.mktemp()) + src.makedirs() + src.child("setup.py").setContent( + b"""\ +from setuptools import setup + +setup( + name="example_bad_versionpy", + version="0.1.2", + packages=["example_bad_versionpy"], + zip_safe=False, +) +""" + ) + src.child("pyproject.toml").setContent( + b"""\ +[tool.incremental] +name = "example_bad_versionpy" +""" + ) + package_dir = src.child("example_bad_versionpy") + package_dir.makedirs() + package_dir.child("_version.py").setContent(b"bad version.py") + + build_and_install(src) + + # The version from setup.py wins. + self.assertEqual(metadata.version("example_bad_versionpy"), "0.1.2") + def test_hatchling_get_version(self): """ example_hatchling has a version of 24.7.0.