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)