Source code for versioningit.onbuild

from __future__ import annotations
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
from .util import bool_guard, ensure_terminated, optional_str_guard, str_guard

if TYPE_CHECKING:
    from typing_extensions import Literal, TypeAlias

    TextMode: TypeAlias = Literal["r", "w", "a"]
    BinaryMode: TypeAlias = Literal["rb", "br", "wb", "bw", "ab", "ba"]


[docs] class OnbuildFileProvider(ABC): """ .. versionadded:: 3.0.0 An abstract base class for accessing files that are about to be included in an sdist or wheel currently being built """
[docs] @abstractmethod def get_file( 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 """ ...
[docs] class OnbuildFile(ABC): """ .. versionadded:: 3.0.0 An abstract base class for opening a file in a project currently being built """ @overload def open( self, mode: TextMode = "r", encoding: str | None = None, errors: str | None = None, newline: str | None = None, ) -> TextIO: ... @overload def open( self, mode: BinaryMode, encoding: None = None, errors: None = None, newline: None = None, ) -> IO[bytes]: ...
[docs] @abstractmethod def open( self, mode: TextMode | BinaryMode = "r", encoding: str | None = None, 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(OnbuildFileProvider): """ .. versionadded:: 3.0.0 `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, install_path: str | PurePath, is_source: bool ) -> SetuptoolsOnbuildFile: return SetuptoolsOnbuildFile( provider=self, source_path=PurePath(source_path), install_path=PurePath(install_path), is_source=is_source, ) @dataclass class SetuptoolsOnbuildFile(OnbuildFile): provider: SetuptoolsFileProvider source_path: PurePath install_path: PurePath is_source: bool @overload def open( self, mode: TextMode = "r", encoding: str | None = None, errors: str | None = None, newline: str | None = None, ) -> TextIO: ... @overload def open( self, mode: BinaryMode, encoding: None = None, errors: None = None, newline: None = None, ) -> IO[bytes]: ... def open( self, mode: TextMode | BinaryMode = "r", encoding: str | None = None, errors: str | None = None, newline: str | None = None, ) -> IO: 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: 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(OnbuildFileProvider): """ .. versionadded:: 3.0.0 `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, install_path: str | PurePath, is_source: bool ) -> HatchOnbuildFile: return HatchOnbuildFile( provider=self, source_path=PurePath(source_path), install_path=PurePath(install_path), is_source=is_source, ) def get_force_include(self) -> dict[str, str]: return {str(self.tmp_dir / p): str(p) for p in self.modified} @dataclass class HatchOnbuildFile(OnbuildFile): provider: HatchFileProvider source_path: PurePath install_path: PurePath is_source: bool @overload def open( self, mode: TextMode = "r", encoding: str | None = None, errors: str | None = None, newline: str | None = None, ) -> TextIO: ... @overload def open( self, mode: BinaryMode, encoding: None = None, errors: None = None, newline: None = None, ) -> IO[bytes]: ... def open( self, mode: TextMode | BinaryMode = "r", encoding: str | None = None, errors: str | None = None, newline: str | None = None, ) -> IO: 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 ) else: p = self.provider.tmp_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 not p.exists() and "a" in mode: with suppress(FileNotFoundError): shutil.copy2(self.provider.src_dir / self.source_path, p) return p.open(mode=mode, encoding=encoding, errors=errors, newline=newline) def replace_version_onbuild( *, file_provider: OnbuildFileProvider, is_source: bool, template_fields: dict[str, Any], params: dict[str, Any], ) -> None: """Implements the ``"replace-version"`` ``onbuild`` method""" DEFAULT_REGEX = r"^\s*__version__\s*=\s*(?P<version>.*)" DEFAULT_REPLACEMENT = '"{version}"' params = params.copy() source_file = str_guard(params.pop("source-file", None), "onbuild.source-file") build_file = str_guard(params.pop("build-file", None), "onbuild.build-file") encoding = str_guard(params.pop("encoding", "utf-8"), "onbuild.encoding") regex = str_guard(params.pop("regex", DEFAULT_REGEX), "onbuild.regex") try: rgx = re.compile(regex) except re.error as e: raise ConfigError(f"versioningit: onbuild.regex: Invalid regex: {e}") require_match = bool_guard( params.pop("require-match", False), "onbuild.require-match" ) replacement = str_guard( params.pop("replacement", DEFAULT_REPLACEMENT), "onbuild.replacement" ) append_line = optional_str_guard( params.pop("append-line", None), "onbuild.append-line" ) warn_extra_fields( params, "onbuild", [ "source-file", "build-file", "encoding", "regex", "require-match", "replacement", "append-line", ], ) path = source_file if is_source else build_file log.info("Updating version in file %s", path) file = file_provider.get_file( source_path=source_file, install_path=build_file, is_source=is_source, ) with file.open(encoding=encoding) as fp: # Don't use readlines(), as that doesn't split on everything that # splitlines() uses lines = fp.read().splitlines(keepends=True) for i, ln in enumerate(lines): m = rgx.search(ln) if m: log.debug("onbuild.regex matched file on line %d", i + 1) vgroup: str | int if "version" in m.groupdict(): vgroup = "version" else: vgroup = 0 if m[vgroup] is None: raise RuntimeError( "'version' group in versioningit's onbuild.regex did" " not participate in match" ) newline = ensure_terminated( ln[: m.start(vgroup)] + m.expand(replacement.format_map(template_fields)) + ln[m.end(vgroup) :] ) log.debug("Replacing line %r with %r", ln, newline) lines[i] = newline break else: if require_match: raise RuntimeError(f"onbuild.regex did not match any lines in {path}") elif append_line is not None: log.info( "onbuild.regex did not match any lines in the file; appending line" ) if lines: lines[-1] = ensure_terminated(lines[-1]) lines.append(ensure_terminated(append_line.format_map(template_fields))) else: log.info( "onbuild.regex did not match any lines in the file; leaving unmodified" ) return with file.open("w", encoding=encoding) as fp: fp.writelines(lines)