Skip to content

Commit

Permalink
Defense in depth
Browse files Browse the repository at this point in the history
It seems we must rely on heuristics, so let's go all the way.
  • Loading branch information
twm committed Jul 28, 2024
1 parent d659ea0 commit d2fe36f
Show file tree
Hide file tree
Showing 5 changed files with 110 additions and 20 deletions.
31 changes: 20 additions & 11 deletions src/incremental/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__"]
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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``
Expand Down
2 changes: 1 addition & 1 deletion src/incremental/_hatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion src/incremental/newsfragments/106.bugfix
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 7 additions & 7 deletions src/incremental/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand All @@ -133,7 +134,7 @@ def _run(
)

elif post:
existing = _existing_version(path)
existing = _existing_version(versionpath)

if existing.post is None:
_post = 0
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
81 changes: 81 additions & 0 deletions tests/test_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down

0 comments on commit d2fe36f

Please sign in to comment.