diff --git a/src/versioningit/__init__.py b/src/versioningit/__init__.py index 7e3534b..efba498 100644 --- a/src/versioningit/__init__.py +++ b/src/versioningit/__init__.py @@ -72,13 +72,12 @@ NotVersioningitError, ) from .get_cmdclasses import get_cmdclasses -from .onbuild import FileProvider, OnbuildFile +from .onbuild import OnbuildFile, OnbuildFileProvider __all__ = [ "ConfigError", "Error", "FallbackReport", - "FileProvider", "InvalidTagError", "InvalidVersionError", "MethodError", @@ -87,6 +86,7 @@ "NotVCSError", "NotVersioningitError", "OnbuildFile", + "OnbuildFileProvider", "Report", "VCSDescription", "Versioningit", diff --git a/src/versioningit/core.py b/src/versioningit/core.py index 11584eb..fac733b 100644 --- a/src/versioningit/core.py +++ b/src/versioningit/core.py @@ -7,7 +7,7 @@ from .errors import Error, MethodError, NotSdistError, NotVCSError, NotVersioningitError from .logging import log, warn_bad_version from .methods import VersioningitMethod -from .onbuild import FileProvider, SetuptoolsFileProvider +from .onbuild import OnbuildFileProvider, SetuptoolsFileProvider from .util import is_sdist, parse_version_from_metadata if TYPE_CHECKING: @@ -427,7 +427,7 @@ def do_write(self, template_fields: dict[str, Any]) -> None: def do_onbuild( self, - file_provider: FileProvider, + file_provider: OnbuildFileProvider, is_source: bool, template_fields: dict[str, Any], ) -> None: @@ -599,9 +599,7 @@ def run_onbuild( """ vgit = Versioningit.from_project_dir(project_dir, config) vgit.do_onbuild( - file_provider=SetuptoolsFileProvider( - build_dir=Path(build_dir), src_dir=Path(project_dir) - ), + file_provider=SetuptoolsFileProvider(build_dir=Path(build_dir)), is_source=is_source, template_fields=template_fields, ) diff --git a/src/versioningit/onbuild.py b/src/versioningit/onbuild.py index 496193b..cb9a870 100644 --- a/src/versioningit/onbuild.py +++ b/src/versioningit/onbuild.py @@ -2,9 +2,11 @@ from abc import ABC, abstractmethod from contextlib import suppress from dataclasses import dataclass, field +import os from pathlib import Path, PurePath import re import shutil +import tempfile from typing import IO, TYPE_CHECKING, Any, TextIO, overload from .errors import ConfigError from .logging import log, warn_extra_fields @@ -17,15 +19,39 @@ BinaryMode: TypeAlias = Literal["rb", "br", "wb", "bw", "ab", "ba"] -class FileProvider(ABC): +class OnbuildFileProvider(ABC): + """ + An abstract base class for accessing files that are about to be included in + an sdist or wheel currently being built + """ + @abstractmethod def get_file( - self, source_path: str | PurePath, build_path: str | PurePath, is_source: bool + self, source_path: str | PurePath, install_path: str | PurePath, is_source: bool ) -> OnbuildFile: + """ + Get an object for reading & writing a file in the project being built. + + :param source_path: + the path to the file relative to the root of the project's source + :param install_path: + the path to the same file when it's in a wheel, relative to the + root of the wheel (or, equivalently, the path to the file when it's + installed in a site-packages directory, relative to that directory) + :param is_source: + `True` if building an sdist or other artifact that preserves source + paths, `False` if building a wheel or other artifact that uses + install paths + """ ... class OnbuildFile(ABC): + """ + An abstract base class for opening a file in a project currently being + built + """ + @overload def open( self, @@ -54,22 +80,44 @@ def open( errors: str | None = None, newline: str | None = None, ) -> IO: + """ + Open the associated file. ``mode`` must be ``"r"``, ``"w"``, ``"a"``, + ``"rb"``, ``"br"``, ``"wb"``, ``"bw"``, ``"ab"``, or ``"ba"``. + + When opening a file for writing or appending, if the file does not + already exist, any parent directories are created automatically. + """ ... @dataclass -class SetuptoolsFileProvider(FileProvider): - src_dir: Path +class SetuptoolsFileProvider(OnbuildFileProvider): + """ + `OnbuildFileProvider` implementation for use when building sdists or wheels + under setuptools. + + Setuptools builds its artifacts by creating a temporary directory + containing all of the files (sometimes hardlinked) that will go into them + and then building an archive from that directory. "onbuild" runs just + before the archive step, so this provider simply operates directly on the + temporary directory without ever looking at the project source. + """ + + #: The setuptools-managed temporary directory containing the files for the + #: archive currently being built build_dir: Path + + #: The set of file paths in `build_dir` (relative to `build_dir`) that have + #: been opened for writing or appending modified: set[PurePath] = field(init=False, default_factory=set) def get_file( - self, source_path: str | PurePath, build_path: str | PurePath, is_source: bool + self, source_path: str | PurePath, install_path: str | PurePath, is_source: bool ) -> SetuptoolsOnbuildFile: return SetuptoolsOnbuildFile( provider=self, source_path=PurePath(source_path), - build_path=PurePath(build_path), + install_path=PurePath(install_path), is_source=is_source, ) @@ -78,7 +126,7 @@ def get_file( class SetuptoolsOnbuildFile(OnbuildFile): provider: SetuptoolsFileProvider source_path: PurePath - build_path: PurePath + install_path: PurePath is_source: bool @overload @@ -108,37 +156,57 @@ def open( errors: str | None = None, newline: str | None = None, ) -> IO: - path = self.source_path if self.is_source else self.build_path + path = self.source_path if self.is_source else self.install_path p = self.provider.build_dir / path if ("w" in mode or "a" in mode) and path not in self.provider.modified: self.provider.modified.add(path) p.parent.mkdir(parents=True, exist_ok=True) # If setuptools is using hard links for the build files, undo that # for this file: - try: - p.unlink() - except FileNotFoundError: - pass - else: - if "a" in mode: - with suppress(FileNotFoundError): - shutil.copy2(self.provider.src_dir / self.source_path, p) + if "w" in mode: + with suppress(FileNotFoundError): + p.unlink() + elif p.exists(): + # We've been asked to append to the file, so replace it with a + # non-hardlinked copy of its contents: + fd, tmp = tempfile.mkstemp(dir=self.provider.build_dir) + os.close(fd) + shutil.copy2(p, tmp) + os.replace(tmp, p) return p.open(mode=mode, encoding=encoding, errors=errors, newline=newline) @dataclass -class HatchFileProvider(FileProvider): +class HatchFileProvider(OnbuildFileProvider): + """ + `OnbuildFileProvider` implementation for use when building sdists or wheels + under Hatch. + + Hatch builds its artifacts by reading the contents of the files in the + project directory directly into an in-memory archive. In order to modify + what goes into that archive without altering anything in the project + directory, we need to write all modifications to a temporary directory and + register the resulting files as "forced inclusion paths." + """ + + #: The root of the project directory src_dir: Path + + #: A temporary directory (managed outside the provider) in which to create + #: modified files tmp_dir: Path + + #: The set of file paths created under the temporary directory, relative to + #: the temporary directory modified: set[PurePath] = field(init=False, default_factory=set) def get_file( - self, source_path: str | PurePath, build_path: str | PurePath, is_source: bool + self, source_path: str | PurePath, install_path: str | PurePath, is_source: bool ) -> HatchOnbuildFile: return HatchOnbuildFile( provider=self, source_path=PurePath(source_path), - build_path=PurePath(build_path), + install_path=PurePath(install_path), is_source=is_source, ) @@ -150,7 +218,7 @@ def get_force_include(self) -> dict[str, str]: class HatchOnbuildFile(OnbuildFile): provider: HatchFileProvider source_path: PurePath - build_path: PurePath + install_path: PurePath is_source: bool @overload @@ -180,7 +248,7 @@ def open( errors: str | None = None, newline: str | None = None, ) -> IO: - path = self.source_path if self.is_source else self.build_path + path = self.source_path if self.is_source else self.install_path if "r" in mode and path not in self.provider.modified: return (self.provider.src_dir / self.source_path).open( mode=mode, encoding=encoding, errors=errors @@ -198,7 +266,7 @@ def open( def replace_version_onbuild( *, - file_provider: FileProvider, + file_provider: OnbuildFileProvider, is_source: bool, template_fields: dict[str, Any], params: dict[str, Any], @@ -244,7 +312,7 @@ def replace_version_onbuild( log.info("Updating version in file %s", path) file = file_provider.get_file( source_path=source_file, - build_path=build_file, + install_path=build_file, is_source=is_source, ) with file.open(encoding=encoding) as fp: diff --git a/test/test_methods/test_onbuild.py b/test/test_methods/test_onbuild.py index 1211ba1..5d46ad0 100644 --- a/test/test_methods/test_onbuild.py +++ b/test/test_methods/test_onbuild.py @@ -7,8 +7,8 @@ import pytest from versioningit.errors import ConfigError from versioningit.onbuild import ( - FileProvider, HatchFileProvider, + OnbuildFileProvider, SetuptoolsFileProvider, replace_version_onbuild, ) @@ -176,7 +176,7 @@ def test_replace_version_onbuild( tmp_path /= "tmp" # copytree() can't copy to a dir that already exists copytree(src_dir, tmp_path) replace_version_onbuild( - file_provider=SetuptoolsFileProvider(src_dir=src_dir, build_dir=tmp_path), + file_provider=SetuptoolsFileProvider(build_dir=tmp_path), is_source=is_source, template_fields={ "version": "1.2.3", @@ -201,7 +201,7 @@ def test_replace_version_onbuild_require_match(tmp_path: Path) -> None: copytree(src_dir, tmp_path) with pytest.raises(RuntimeError) as excinfo: replace_version_onbuild( - file_provider=SetuptoolsFileProvider(src_dir=src_dir, build_dir=tmp_path), + file_provider=SetuptoolsFileProvider(build_dir=tmp_path), is_source=True, template_fields={"version": "1.2.3"}, params={ @@ -226,7 +226,7 @@ def test_replace_version_onbuild_bad_regex(tmp_path: Path) -> None: ConfigError, match=r"^versioningit: onbuild\.regex: Invalid regex: .+" ): replace_version_onbuild( - file_provider=SetuptoolsFileProvider(src_dir=src_dir, build_dir=tmp_path), + file_provider=SetuptoolsFileProvider(build_dir=tmp_path), is_source=True, template_fields={"version": "1.2.3"}, params={ @@ -245,7 +245,7 @@ def test_replace_version_onbuild_version_not_captured(tmp_path: Path) -> None: copytree(src_dir, tmp_path) with pytest.raises(RuntimeError) as excinfo: replace_version_onbuild( - file_provider=SetuptoolsFileProvider(src_dir=src_dir, build_dir=tmp_path), + file_provider=SetuptoolsFileProvider(build_dir=tmp_path), is_source=True, template_fields={"version": "1.2.3"}, params={ @@ -267,41 +267,35 @@ def test_replace_version_onbuild_version_not_captured(tmp_path: Path) -> None: "mode,after", [ ("w", "Coconut\n"), - ("a", "Apple\nCoconut\n"), + ("a", "{contents}Coconut\n"), ], ) def test_setuptools_file_provider_read_write_read( is_source: bool, mode: Literal["w", "a"], after: str, tmp_path: Path ) -> None: - src_dir = tmp_path / "src" - src_dir.mkdir() - build_dir = tmp_path / "build" - build_dir.mkdir() - (src_dir / "apple.txt").write_text("Apple\n") - (build_dir / "apple.txt").write_text("Macintosh\n") - (build_dir / "banana.txt").write_text("Banana\n") - provider = SetuptoolsFileProvider(src_dir=src_dir, build_dir=build_dir) + (tmp_path / "apple.txt").write_text("Apple\n") + (tmp_path / "banana.txt").write_text("Banana\n") + provider = SetuptoolsFileProvider(build_dir=tmp_path) file = provider.get_file( - source_path="apple.txt", build_path="banana.txt", is_source=is_source + source_path="apple.txt", install_path="banana.txt", is_source=is_source ) with file.open() as fp: contents = fp.read() if is_source: - assert contents == "Macintosh\n" + assert contents == "Apple\n" else: assert contents == "Banana\n" with file.open(mode) as fp: fp.write("Coconut\n") with file.open() as fp: - contents = fp.read() - assert contents == after - assert (src_dir / "apple.txt").read_text() == "Apple\n" + contents2 = fp.read() + assert contents2 == after.format(contents=contents) if is_source: - assert (build_dir / "apple.txt").read_text() == after - assert (build_dir / "banana.txt").read_text() == "Banana\n" + assert (tmp_path / "apple.txt").read_text() == contents2 + assert (tmp_path / "banana.txt").read_text() == "Banana\n" else: - assert (build_dir / "apple.txt").read_text() == "Macintosh\n" - assert (build_dir / "banana.txt").read_text() == after + assert (tmp_path / "apple.txt").read_text() == "Apple\n" + assert (tmp_path / "banana.txt").read_text() == contents2 @pytest.mark.parametrize("is_source", [False, True]) @@ -324,9 +318,9 @@ def test_setuptools_file_provider_read_write_read_hard_link( os.link(src_dir / "apple.txt", build_dir / "apple.txt") else: os.link(src_dir / "apple.txt", build_dir / "banana.txt") - provider = SetuptoolsFileProvider(src_dir=src_dir, build_dir=build_dir) + provider = SetuptoolsFileProvider(build_dir=build_dir) file = provider.get_file( - source_path="apple.txt", build_path="banana.txt", is_source=is_source + source_path="apple.txt", install_path="banana.txt", is_source=is_source ) with file.open() as fp: contents = fp.read() @@ -363,7 +357,7 @@ def test_hatch_file_provider_read_write_read( (src_dir / "apple.txt").write_text("Apple\n") provider = HatchFileProvider(src_dir=src_dir, tmp_dir=tmp_dir) file = provider.get_file( - source_path="apple.txt", build_path="banana.txt", is_source=is_source + source_path="apple.txt", install_path="banana.txt", is_source=is_source ) with file.open() as fp: contents = fp.read() @@ -392,15 +386,15 @@ def test_file_provider_write_new_file( src_dir.mkdir() build_dir = tmp_path / "build" build_dir.mkdir() - provider: FileProvider + provider: OnbuildFileProvider if backend == "setuptools": - provider = SetuptoolsFileProvider(src_dir=src_dir, build_dir=build_dir) + provider = SetuptoolsFileProvider(build_dir=build_dir) else: assert backend == "hatch" provider = HatchFileProvider(src_dir=src_dir, tmp_dir=build_dir) file = provider.get_file( source_path="green/apple.txt", - build_path="yellow/banana.txt", + install_path="yellow/banana.txt", is_source=is_source, ) with file.open(mode) as fp: