diff options
author | Ronny Pfannschmidt <opensource@ronnypfannschmidt.de> | 2023-04-26 22:34:18 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-04-26 22:34:18 +0200 |
commit | 126a49c3f9e6b4f3f077f83595af08a74895b4a2 (patch) | |
tree | 69fc9699ac2987e935ffa6c9c9fa55fb214d5bc3 | |
parent | 51b3566170be25582b5c3216a54b024caf3d431f (diff) | |
parent | 9e9836224b02a9db1a668db8652cb08657a8a9a3 (diff) | |
download | setuptools-scm-126a49c3f9e6b4f3f077f83595af08a74895b4a2.tar.gz |
Merge pull request #823 from RonnyPfannschmidt/cleanups
assorted cleanups
36 files changed, 739 insertions, 662 deletions
diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index a6fc97f..f0029c9 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -90,21 +90,10 @@ jobs: - run: | $(hg debuginstall --template "{pythonexe}") -m pip install hg-git --user if: matrix.os == 'ubuntu-latest' + # this hopefull helps with os caches, hg init sometimes gets 20s timeouts + - run: hg version - run: pytest - test_legacy_setuptools: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Setup python - uses: actions/setup-python@v4 - with: - python-version: "3.8" - architecture: x64 - - run: pip install -e .[toml,test] pytest virtualenv - - run: pytest --test-legacy testing/test_setuptools_support.py || true # ignore fail flaky on ci - - dist_upload: runs-on: ubuntu-latest diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6541a0c..6a36020 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -44,3 +44,4 @@ repos: - pytest == 7.1 - importlib_metadata - typing-extensions>=4.5 + - rich diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 71e528a..72efc1c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -8,7 +8,12 @@ breaking * turn Configuration into a dataclass * require configuration to always pass into helpers * hide file-finders implementation in private module -* define own build backend to have setuptools work +* migrate to hatchling +* renamed setuptools_scm.hacks to setuptools_scm.fallbacks and drop support for pip-egg-info +* remove trace function and use logging instead +* unify distance=None and distance=0 they should mean the same + and where hiding dirty states that are now explicitly dirty +* depend on later importlib for the full selectable api features -------- @@ -27,6 +32,9 @@ features * pre-compiled regex * move helpers to private modules +* support passing log levels to SETUPTOOLS_SCM_DEBUG +* support using rich.logging as console log handler if installed + v7.1.0 ====== diff --git a/_own_version_helper.py b/_own_version_helper.py index b7069ba..e40cb63 100644 --- a/_own_version_helper.py +++ b/_own_version_helper.py @@ -4,21 +4,41 @@ to use the attribute for the versions """ from __future__ import annotations +import logging +from typing import Callable + +from setuptools_scm import _types as _t from setuptools_scm import Configuration from setuptools_scm import get_version from setuptools_scm import git from setuptools_scm import hg -from setuptools_scm.hacks import parse_pkginfo +from setuptools_scm.fallbacks import parse_pkginfo from setuptools_scm.version import get_local_node_and_date from setuptools_scm.version import guess_next_dev_version from setuptools_scm.version import ScmVersion +log = logging.getLogger("setuptools_scm") +# todo: take fake entrypoints from pyproject.toml +try_parse: list[Callable[[_t.PathT, Configuration], ScmVersion | None]] = [ + parse_pkginfo, + git.parse, + hg.parse, + git.parse_archival, + hg.parse_archival, +] + def parse(root: str, config: Configuration) -> ScmVersion | None: - try: - return parse_pkginfo(root, config) - except OSError: - return git.parse(root, config=config) or hg.parse(root, config=config) + for maybe_parse in try_parse: + try: + parsed = maybe_parse(root, config) + except OSError as e: + log.warning("parse with %s failed with: %s", maybe_parse, e) + else: + if parsed is not None: + return parsed + else: + return None def scm_version() -> str: diff --git a/pyproject.toml b/pyproject.toml index 1ae670b..86a7402 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,7 @@ build-backend = "hatchling.build" requires = [ "hatchling>=1.10", + 'importlib-metadata>=4.6; python_version < "3.10"', "packaging>=20", "setuptools>=55", 'tomli; python_version < "3.11"', @@ -40,15 +41,19 @@ dynamic = [ "version", ] dependencies = [ - 'importlib-metadata; python_version < "3.8"', + 'importlib-metadata>=4.6; python_version < "3.10"', "packaging>=20", "setuptools", 'tomli>=1; python_version < "3.11"', "typing-extensions", ] [project.optional-dependencies] +rich = [ + "rich", +] test = [ "pytest", + "rich", "virtualenv>20", ] toml = [ @@ -78,10 +83,9 @@ node-and-timestamp = "setuptools_scm.version:get_local_node_and_timestamp" [project.entry-points."setuptools_scm.parse_scm_fallback"] ".git_archival.txt" = "setuptools_scm.git:parse_archival" ".hg_archival.txt" = "setuptools_scm.hg:parse_archival" -PKG-INFO = "setuptools_scm.hacks:parse_pkginfo" -pip-egg-info = "setuptools_scm.hacks:parse_pip_egg_info" -"pyproject.toml" = "setuptools_scm.hacks:fallback_version" -"setup.py" = "setuptools_scm.hacks:fallback_version" +PKG-INFO = "setuptools_scm.fallbacks:parse_pkginfo" +"pyproject.toml" = "setuptools_scm.fallbacks:fallback_version" +"setup.py" = "setuptools_scm.fallbacks:fallback_version" [project.entry-points."setuptools_scm.version_scheme"] "calver-by-date" = "setuptools_scm.version:calver_by_date" "guess-next-dev" = "setuptools_scm.version:guess_next_dev_version" diff --git a/src/setuptools_scm/__init__.py b/src/setuptools_scm/__init__.py index eccc68f..b29273c 100644 --- a/src/setuptools_scm/__init__.py +++ b/src/setuptools_scm/__init__.py @@ -28,6 +28,7 @@ if TYPE_CHECKING: from typing import NoReturn from . import _types as _t + TEMPLATES = { ".py": """\ # file generated by setuptools_scm @@ -49,9 +50,9 @@ def dump_version( target = os.path.normpath(os.path.join(root, write_to)) ext = os.path.splitext(target)[1] template = template or TEMPLATES.get(ext) - from ._trace import trace + from ._log import log - trace("dump", write_to, version) + log.debug("dump %s into %s", version, write_to) if template is None: raise ValueError( "bad file format: '{}' (of {}) \nonly *.txt and *.py are supported".format( diff --git a/src/setuptools_scm/_config.py b/src/setuptools_scm/_config.py index 34745ae..108c86e 100644 --- a/src/setuptools_scm/_config.py +++ b/src/setuptools_scm/_config.py @@ -9,17 +9,19 @@ from typing import Any from typing import Callable from typing import Pattern +from . import _log from . import _types as _t from ._integration.pyproject_reading import ( get_args_for_pyproject as _get_args_for_pyproject, ) from ._integration.pyproject_reading import read_pyproject as _read_pyproject from ._overrides import read_toml_overrides -from ._trace import trace from ._version_cls import _validate_version_cls from ._version_cls import _VersionT from ._version_cls import Version as _Version +log = _log.log.getChild("config") + DEFAULT_TAG_REGEX = re.compile( r"^(?:[\w-]+-)?(?P<version>[vV]?\d+(?:\.\d+){0,2}[^\+]*)(?:\+.*)?$" ) @@ -44,7 +46,7 @@ def _check_tag_regex(value: str | Pattern[str] | None) -> Pattern[str]: def _check_absolute_root(root: _t.PathT, relative_to: _t.PathT | None) -> str: - trace("abs root", repr(locals())) + log.debug("check absolute root=%s relative_to=%s", root, relative_to) if relative_to: if ( os.path.isabs(root) @@ -61,10 +63,10 @@ def _check_absolute_root(root: _t.PathT, relative_to: _t.PathT | None) -> str: " its the directory %r\n" "assuming the parent directory was passed" % (relative_to,) ) - trace("dir", relative_to) + log.debug("dir %s", relative_to) root = os.path.join(relative_to, root) else: - trace("file", relative_to) + log.debug("file %s", relative_to) root = os.path.join(os.path.dirname(relative_to), root) return os.path.abspath(root) diff --git a/src/setuptools_scm/_entrypoints.py b/src/setuptools_scm/_entrypoints.py index 9b5b093..01b48b1 100644 --- a/src/setuptools_scm/_entrypoints.py +++ b/src/setuptools_scm/_entrypoints.py @@ -1,6 +1,5 @@ from __future__ import annotations -import warnings from typing import Any from typing import Callable from typing import cast @@ -8,17 +7,23 @@ from typing import Iterator from typing import overload from typing import TYPE_CHECKING +from typing_extensions import Protocol + +from . import _log from . import version -from ._trace import trace if TYPE_CHECKING: from ._config import Configuration - from typing_extensions import Protocol from . import _types as _t -else: - Configuration = Any - class Protocol: + +log = _log.log.getChild("entrypoints") + + +class EntrypointProtocol(Protocol): + name: str + + def load(self) -> Any: pass @@ -34,64 +39,52 @@ def _version_from_entrypoints( from .discover import iter_matching_entrypoints - trace("version_from_ep", entrypoint, root) + log.debug("version_from_ep %s in %s", entrypoint, root) for ep in iter_matching_entrypoints(root, entrypoint, config): fn = ep.load() maybe_version: version.ScmVersion | None = fn(root, config=config) - trace(ep, version) + log.debug("%s found %r", ep, maybe_version) if maybe_version is not None: return maybe_version return None try: - from importlib.metadata import entry_points # type: ignore - from importlib.metadata import EntryPoint + from importlib_metadata import entry_points + from importlib_metadata import EntryPoint except ImportError: - try: - from importlib_metadata import entry_points - from importlib_metadata import EntryPoint - except ImportError: - from collections import defaultdict - - def entry_points() -> dict[str, list[_t.EntrypointProtocol]]: - warnings.warn( - "importlib metadata missing, " - "this may happen at build time for python3.7" - ) - return defaultdict(list) - - class EntryPoint: # type: ignore - def __init__(self, *args: Any, **kwargs: Any): - pass # entry_points() already provides the warning + from importlib.metadata import entry_points # type: ignore [no-redef, import] + from importlib.metadata import EntryPoint # type: ignore [no-redef] def iter_entry_points( group: str, name: str | None = None -) -> Iterator[_t.EntrypointProtocol]: - all_eps = entry_points() - if hasattr(all_eps, "select"): - eps = all_eps.select(group=group) - else: - eps = all_eps[group] - if name is None: - return iter(eps) - return (ep for ep in eps if ep.name == name) +) -> Iterator[EntrypointProtocol]: + eps = entry_points(group=group) + res = ( + eps + if name is None + else eps.select( # type: ignore [no-untyped-call] + name=name, + ) + ) + return cast(Iterator[EntrypointProtocol], iter(res)) def _get_ep(group: str, name: str) -> Any | None: - from ._entrypoints import iter_entry_points - for ep in iter_entry_points(group, name): - trace("ep found:", ep.name) + log.debug("ep found: %s", ep.name) return ep.load() else: return None def _get_from_object_reference_str(path: str) -> Any | None: + ep: EntrypointProtocol = EntryPoint( + path, path, None + ) # type: ignore [no-untyped-call] try: - return EntryPoint(path, path, None).load() + return ep.load() except (AttributeError, ModuleNotFoundError): return None @@ -121,22 +114,22 @@ def _iter_version_schemes( @overload def _call_version_scheme( - version: version.ScmVersion, entypoint: str, given_value: str, default: str + version: version.ScmVersion, entrypoint: str, given_value: str, default: str ) -> str: ... @overload def _call_version_scheme( - version: version.ScmVersion, entypoint: str, given_value: str, default: None + version: version.ScmVersion, entrypoint: str, given_value: str, default: None ) -> str | None: ... def _call_version_scheme( - version: version.ScmVersion, entypoint: str, given_value: str, default: str | None + version: version.ScmVersion, entrypoint: str, given_value: str, default: str | None ) -> str | None: - for scheme in _iter_version_schemes(entypoint, given_value): + for scheme in _iter_version_schemes(entrypoint, given_value): result = scheme(version) if result is not None: return result diff --git a/src/setuptools_scm/_file_finders/__init__.py b/src/setuptools_scm/_file_finders/__init__.py index 5a7a1c0..5e3304a 100644 --- a/src/setuptools_scm/_file_finders/__init__.py +++ b/src/setuptools_scm/_file_finders/__init__.py @@ -6,9 +6,11 @@ from typing import Callable from typing_extensions import TypeGuard -from setuptools_scm import _types as _t -from setuptools_scm._entrypoints import iter_entry_points -from setuptools_scm._trace import trace +from .. import _log +from .. import _types as _t +from .._entrypoints import iter_entry_points + +log = _log.log.getChild("file_finder") def scm_find_files( @@ -84,7 +86,7 @@ def is_toplevel_acceptable(toplevel: str | None) -> TypeGuard[str]: ) ignored = [os.path.normcase(p) for p in ignored] - trace(toplevel, ignored) + log.debug("toplevel: %r\n ignored %s", toplevel, ignored) return toplevel not in ignored diff --git a/src/setuptools_scm/_file_finders/git.py b/src/setuptools_scm/_file_finders/git.py index f007d5b..873b4ba 100644 --- a/src/setuptools_scm/_file_finders/git.py +++ b/src/setuptools_scm/_file_finders/git.py @@ -10,8 +10,7 @@ from . import is_toplevel_acceptable from . import scm_find_files from .. import _types as _t from .._run_cmd import run as _run -from .._trace import trace -from ..utils import data_from_mime +from ..integration import data_from_mime log = logging.getLogger(__name__) @@ -43,7 +42,7 @@ def _git_toplevel(path: str) -> str | None: # for this assertion to work. Length of string isn't changed by replace # ``\\`` is just and escape for `\` out = cwd[: -len(out)] - trace("find files toplevel", out) + log.debug("find files toplevel %s", out) return os.path.normcase(os.path.realpath(out.strip())) except subprocess.CalledProcessError: # git returned error, we are not in a git repo @@ -94,7 +93,7 @@ def git_find_files(path: _t.PathT = "") -> list[str]: return [] fullpath = os.path.abspath(os.path.normpath(path)) if not fullpath.startswith(toplevel): - trace("toplevel mismatch", toplevel, fullpath) + log.warning("toplevel mismatch computed %s vs resolved %s ", toplevel, fullpath) git_files, git_dirs = _git_ls_files_and_dirs(toplevel) return scm_find_files(path, git_files, git_dirs) @@ -112,5 +111,5 @@ def git_archive_find_files(path: _t.PathT = "") -> list[str]: # Substitutions have not been performed, so not a reliable archive return [] - trace("git archive detected - fallback to listing all files") + log.warning("git archive detected - fallback to listing all files") return scm_find_files(path, set(), set(), force_all_files=True) diff --git a/src/setuptools_scm/_file_finders/hg.py b/src/setuptools_scm/_file_finders/hg.py index 8f777ea..ec8604a 100644 --- a/src/setuptools_scm/_file_finders/hg.py +++ b/src/setuptools_scm/_file_finders/hg.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging import os import subprocess @@ -7,8 +8,9 @@ from .. import _types as _t from .._file_finders import is_toplevel_acceptable from .._file_finders import scm_find_files from .._run_cmd import run as _run -from .._trace import trace -from ..utils import data_from_mime +from ..integration import data_from_mime + +log = logging.getLogger(__name__) def _hg_toplevel(path: str) -> str | None: @@ -66,5 +68,5 @@ def hg_archive_find_files(path: _t.PathT = "") -> list[str]: # Ensure file is valid return [] - trace("hg archive detected - fallback to listing all files") + log.warning("hg archive detected - fallback to listing all files") return scm_find_files(path, set(), set(), force_all_files=True) diff --git a/src/setuptools_scm/_log.py b/src/setuptools_scm/_log.py new file mode 100644 index 0000000..35c050d --- /dev/null +++ b/src/setuptools_scm/_log.py @@ -0,0 +1,94 @@ +""" +logging helpers, supports vendoring +""" +from __future__ import annotations + +import contextlib +import logging +import os +import sys +from typing import IO +from typing import Iterator + +log = logging.getLogger(__name__.rsplit(".", 1)[0]) +log.propagate = False + + +class AlwaysStdErrHandler(logging.StreamHandler): # type: ignore[type-arg] + def __init___(self) -> None: + super().__init__(sys.stderr) + + @property # type: ignore [override] + def stream(self) -> IO[str]: + return sys.stderr + + @stream.setter + def stream(self, value: IO[str]) -> None: + assert value is sys.stderr + + +def make_default_handler() -> logging.Handler: + try: + from rich.console import Console + + console = Console(stderr=True) + from rich.logging import RichHandler + + return RichHandler(console=console) + except ImportError: + handler = AlwaysStdErrHandler() + handler.setFormatter(logging.Formatter("%(levelname)s %(name)s %(message)s")) + return handler + + +_default_handler = make_default_handler() + +log.addHandler(_default_handler) + + +def _default_log_level() -> str | int: + val: str = os.environ.get("SETUPTOOLS_SCM_DEBUG", "") + level: str | int + if val: + level = logging.DEBUG + levelname: str | int = logging.getLevelName(val) + if isinstance(levelname, int): + level = val + else: + level = logging.WARNING + else: + level = logging.WARNING + return level + + +log.setLevel(_default_log_level()) + + +@contextlib.contextmanager +def defer_to_pytest() -> Iterator[None]: + log.propagate = True + old_level = log.level + log.setLevel(logging.NOTSET) + log.removeHandler(_default_handler) + try: + yield + finally: + log.addHandler(_default_handler) + log.propagate = False + log.setLevel(old_level) + + +@contextlib.contextmanager +def enable_debug(handler: logging.Handler = _default_handler) -> Iterator[None]: + log.addHandler(handler) + old_level = log.level + log.setLevel(logging.DEBUG) + old_handler_level = handler.level + handler.setLevel(logging.DEBUG) + try: + yield + finally: + log.setLevel(old_level) + handler.setLevel(old_handler_level) + if handler is not _default_handler: + log.removeHandler(handler) diff --git a/src/setuptools_scm/_overrides.py b/src/setuptools_scm/_overrides.py index f08f170..6279377 100644 --- a/src/setuptools_scm/_overrides.py +++ b/src/setuptools_scm/_overrides.py @@ -4,9 +4,11 @@ import os from typing import Any from . import _config +from . import _log from . import version from ._integration.pyproject_reading import lazy_toml_load -from ._trace import trace + +log = _log.log.getChild("overrides") PRETEND_KEY = "SETUPTOOLS_SCM_PRETEND_VERSION" PRETEND_KEY_NAMED = PRETEND_KEY + "_FOR_{name}" @@ -30,7 +32,7 @@ def _read_pretended_version_for( tries ``SETUPTOOLS_SCM_PRETEND_VERSION`` and ``SETUPTOOLS_SCM_PRETEND_VERSION_FOR_$UPPERCASE_DIST_NAME`` """ - trace("dist name:", config.dist_name) + log.debug("dist name: %s", config.dist_name) pretended = read_named_env(name="PRETEND_VERSION", dist_name=config.dist_name) diff --git a/src/setuptools_scm/_run_cmd.py b/src/setuptools_scm/_run_cmd.py index b528504..b1e5f1a 100644 --- a/src/setuptools_scm/_run_cmd.py +++ b/src/setuptools_scm/_run_cmd.py @@ -3,11 +3,73 @@ from __future__ import annotations import os import shlex import subprocess +import textwrap +import warnings +from typing import Callable from typing import Mapping +from typing import overload +from typing import Sequence +from typing import TYPE_CHECKING +from typing import TypeVar -from . import _trace +from . import _log from . import _types as _t +if TYPE_CHECKING: + BaseCompletedProcess = subprocess.CompletedProcess[str] +else: + BaseCompletedProcess = subprocess.CompletedProcess + + +log = _log.log.getChild("run_cmd") + +PARSE_RESULT = TypeVar("PARSE_RESULT") +T = TypeVar("T") + + +class CompletedProcess(BaseCompletedProcess): + @classmethod + def from_raw( + cls, input: BaseCompletedProcess, strip: bool = True + ) -> CompletedProcess: + return cls( + args=input.args, + returncode=input.returncode, + stdout=input.stdout.strip() if strip and input.stdout else input.stdout, + stderr=input.stderr.strip() if strip and input.stderr else input.stderr, + ) + + @overload + def parse_success( + self, + parse: Callable[[str], PARSE_RESULT], + default: None = None, + error_msg: str | None = None, + ) -> PARSE_RESULT | None: + ... + + @overload + def parse_success( + self, + parse: Callable[[str], PARSE_RESULT], + default: T, + error_msg: str | None = None, + ) -> PARSE_RESULT | T: + ... + + def parse_success( + self, + parse: Callable[[str], PARSE_RESULT], + default: T | None = None, + error_msg: str | None = None, + ) -> PARSE_RESULT | T | None: + if self.returncode: + if error_msg: + log.warning("%s %s", error_msg, self) + return default + else: + return parse(self.stdout) + def no_git_env(env: Mapping[str, str]) -> dict[str, str]: # adapted from pre-commit @@ -21,7 +83,7 @@ def no_git_env(env: Mapping[str, str]) -> dict[str, str]: # GIT_INDEX_FILE: Causes 'error invalid object ...' during commit for k, v in env.items(): if k.startswith("GIT_"): - _trace.trace(k, v) + log.debug("%s: %s", k, v) return { k: v for k, v in env.items() @@ -66,17 +128,17 @@ def run( trace: bool = True, timeout: int = 20, check: bool = False, -) -> subprocess.CompletedProcess[str]: +) -> CompletedProcess: if isinstance(cmd, str): cmd = shlex.split(cmd) else: cmd = [os.fspath(x) for x in cmd] - if trace: - _trace.trace_command(cmd, cwd) + cmd_4_trace = " ".join(map(_unsafe_quote_for_display, cmd)) + log.debug("at %s\n $ %s ", cwd, cmd_4_trace) res = subprocess.run( cmd, capture_output=True, - cwd=str(cwd), + cwd=os.fspath(cwd), env=dict( avoid_pip_isolation(no_git_env(os.environ)), # os.environ, @@ -88,17 +150,45 @@ def run( text=True, timeout=timeout, ) - if strip: - if res.stdout: - res.stdout = ensure_stripped_str(res.stdout) - res.stderr = ensure_stripped_str(res.stderr) + + res = CompletedProcess.from_raw(res, strip=strip) if trace: if res.stdout: - _trace.trace("out:\n", res.stdout, indent=True) + log.debug("out:\n%s", textwrap.indent(res.stdout, " ")) if res.stderr: - _trace.trace("err:\n", res.stderr, indent=True) + log.debug("err:\n%s", textwrap.indent(res.stderr, " ")) if res.returncode: - _trace.trace("ret:", res.returncode) + log.debug("ret: %s", res.returncode) if check: res.check_returncode() return res + + +def _unsafe_quote_for_display(item: _t.PathT) -> str: + # give better results than shlex.join in our cases + text = os.fspath(item) + return text if all(c not in text for c in " {[:") else f'"{text}"' + + +def has_command( + name: str, args: Sequence[str] = ["version"], warn: bool = True +) -> bool: + try: + p = run([name, *args], cwd=".", timeout=5) + except OSError as e: + log.warning("command %s missing: %s", name, e) + res = False + except subprocess.TimeoutExpired as e: + log.warning("command %s timed out %s", name, e) + res = False + + else: + res = not p.returncode + if not res and warn: + warnings.warn("%r was not found" % name, category=RuntimeWarning) + return res + + +def require_command(name: str) -> None: + if not has_command(name, warn=False): + raise OSError(f"{name!r} was not found") diff --git a/src/setuptools_scm/_trace.py b/src/setuptools_scm/_trace.py deleted file mode 100644 index cf5fcfa..0000000 --- a/src/setuptools_scm/_trace.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import annotations - -import os -import sys -import textwrap -from typing import Sequence - -from . import _types as _t - -DEBUG: bool = bool(os.environ.get("SETUPTOOLS_SCM_DEBUG")) - - -def trace(*k: object, indent: bool = False) -> None: - if not DEBUG: - if indent and len(k) > 1: - k = (k[0],) + tuple(textwrap.indent(str(s), " ") for s in k[1:]) - print(*k, file=sys.stderr, flush=True) - - -def _unsafe_quote_for_display(item: _t.PathT) -> str: - # give better results than shlex.join in our cases - text = os.fspath(item) - return text if all(c not in text for c in " {[:") else f'"{text}"' - - -def trace_command(cmd: Sequence[_t.PathT], cwd: _t.PathT) -> None: - if not DEBUG: - return - cmd_4_trace = " ".join(map(_unsafe_quote_for_display, cmd)) - trace(f"---\n > {cwd}\\$ ", cmd_4_trace, indent=True) diff --git a/src/setuptools_scm/_types.py b/src/setuptools_scm/_types.py index 7cd57a1..fdcd2dd 100644 --- a/src/setuptools_scm/_types.py +++ b/src/setuptools_scm/_types.py @@ -1,19 +1,17 @@ from __future__ import annotations import os -from typing import Any from typing import Callable from typing import List from typing import Sequence from typing import Tuple -from typing import TypeVar +from typing import TYPE_CHECKING from typing import Union -from typing_extensions import ParamSpec -from typing_extensions import Protocol from typing_extensions import TypeAlias -from . import version +if TYPE_CHECKING: + from . import version PathT: TypeAlias = Union["os.PathLike[str]", str] @@ -22,24 +20,3 @@ CMD_TYPE: TypeAlias = Union[Sequence[PathT], str] VERSION_SCHEME: TypeAlias = Union[str, Callable[["version.ScmVersion"], str]] VERSION_SCHEMES: TypeAlias = Union[List[str], Tuple[str, ...], VERSION_SCHEME] SCMVERSION: TypeAlias = "version.ScmVersion" - - -class EntrypointProtocol(Protocol): - name: str - - def load(self) -> Any: - pass - - -T = TypeVar("T") -T2 = TypeVar("T2") -P = ParamSpec("P") - - -def transfer_input_args( - template: Callable[P, T], -) -> Callable[[Callable[..., T]], Callable[P, T]]: - def decorate(func: Callable[..., T2]) -> Callable[P, T2]: - return func - - return decorate diff --git a/src/setuptools_scm/_version_cls.py b/src/setuptools_scm/_version_cls.py index f6e87a8..e62c9fa 100644 --- a/src/setuptools_scm/_version_cls.py +++ b/src/setuptools_scm/_version_cls.py @@ -38,8 +38,9 @@ def _version_as_tuple(version_str: str) -> tuple[int | str, ...]: try: parsed_version = Version(version_str) except InvalidVersion: - log = getLogger("setuptools_scm") - log.exception("failed to parse version %s", version_str) + log = getLogger(__name__).parent + assert log is not None + log.error("failed to parse version %s", version_str) return (version_str,) else: version_fields: tuple[int | str, ...] = parsed_version.release diff --git a/src/setuptools_scm/discover.py b/src/setuptools_scm/discover.py index 2bd01e9..85dd31f 100644 --- a/src/setuptools_scm/discover.py +++ b/src/setuptools_scm/discover.py @@ -1,32 +1,28 @@ from __future__ import annotations import os +from pathlib import Path from typing import Iterable from typing import Iterator +from . import _entrypoints +from . import _log from . import _types as _t from ._config import Configuration -from ._trace import trace +log = _log.log.getChild("discover") -def walk_potential_roots( - root: _t.PathT, search_parents: bool = True -) -> Iterator[_t.PathT]: + +def walk_potential_roots(root: _t.PathT, search_parents: bool = True) -> Iterator[Path]: """ Iterate though a path and each of its parents. :param root: File path. :param search_parents: If ``False`` the parents are not considered. """ - - if not search_parents: - yield root - return - - tail = root - - while tail: - yield root - root, tail = os.path.split(root) + root = Path(root) + yield root + if search_parents: + yield from root.parents def match_entrypoint(root: _t.PathT, name: str) -> bool: @@ -40,14 +36,14 @@ def match_entrypoint(root: _t.PathT, name: str) -> bool: if os.path.exists(os.path.join(root, name)): if not os.path.isabs(name): return True - trace("ignoring bad ep", name) + log.debug("ignoring bad ep %s", name) return False def iter_matching_entrypoints( root: _t.PathT, entrypoint: str, config: Configuration -) -> Iterable[_t.EntrypointProtocol]: +) -> Iterable[_entrypoints.EntrypointProtocol]: """ Consider different entry-points in ``root`` and optionally its parents. :param root: File path. @@ -56,12 +52,12 @@ def iter_matching_entrypoints( read ``search_parent_directories``, write found parent to ``parent``. """ - trace("looking for ep", entrypoint, root) + log.debug("looking for ep %s in %s", entrypoint, root) from ._entrypoints import iter_entry_points for wd in walk_potential_roots(root, config.search_parent_directories): for ep in iter_entry_points(entrypoint): if match_entrypoint(wd, ep.name): - trace("found ep", ep, "in", wd) + log.debug("found ep %s in %s", ep, wd) config.parent = wd yield ep diff --git a/src/setuptools_scm/hacks.py b/src/setuptools_scm/fallbacks.py index 5aaa0db..e1ea60c 100644 --- a/src/setuptools_scm/hacks.py +++ b/src/setuptools_scm/fallbacks.py @@ -1,23 +1,26 @@ from __future__ import annotations +import logging import os +from pathlib import Path from typing import TYPE_CHECKING if TYPE_CHECKING: from . import _types as _t from . import Configuration -from .utils import data_from_mime -from ._trace import trace +from .integration import data_from_mime from .version import meta from .version import ScmVersion from .version import tag_to_version +log = logging.getLogger(__name__) + _UNKNOWN = "UNKNOWN" def parse_pkginfo(root: _t.PathT, config: Configuration) -> ScmVersion | None: - pkginfo = os.path.join(root, "PKG-INFO") - trace("pkginfo", pkginfo) + pkginfo = Path(root) / "PKG-INFO" + log.debug("pkginfo %s", pkginfo) data = data_from_mime(pkginfo) version = data.get("Version", _UNKNOWN) if version != _UNKNOWN: @@ -26,17 +29,6 @@ def parse_pkginfo(root: _t.PathT, config: Configuration) -> ScmVersion | None: return None -def parse_pip_egg_info(root: _t.PathT, config: Configuration) -> ScmVersion | None: - pipdir = os.path.join(root, "pip-egg-info") - if not os.path.isdir(pipdir): - return None - items = os.listdir(pipdir) - trace("pip-egg-info", pipdir, items) - if not items: - return None - return parse_pkginfo(os.path.join(pipdir, items[0]), config=config) - - def fallback_version(root: _t.PathT, config: Configuration) -> ScmVersion | None: if config.parentdir_prefix_version is not None: _, parent_name = os.path.split(os.path.abspath(root)) @@ -47,6 +39,6 @@ def fallback_version(root: _t.PathT, config: Configuration) -> ScmVersion | None if version is not None: return meta(str(version), preformatted=True, config=config) if config.fallback_version is not None: - trace("FALLBACK") + log.debug("FALLBACK %s", config.fallback_version) return meta(config.fallback_version, preformatted=True, config=config) return None diff --git a/src/setuptools_scm/git.py b/src/setuptools_scm/git.py index 484bd4d..d1da7f8 100644 --- a/src/setuptools_scm/git.py +++ b/src/setuptools_scm/git.py @@ -1,31 +1,34 @@ from __future__ import annotations +import dataclasses +import logging import os import re +import shlex import warnings from datetime import date from datetime import datetime -from os.path import isfile -from os.path import join from os.path import samefile +from pathlib import Path from typing import Callable +from typing import Sequence from typing import TYPE_CHECKING from . import _types as _t from . import Configuration -from ._trace import trace +from . import discover +from ._run_cmd import CompletedProcess as _CompletedProcess +from ._run_cmd import require_command as _require_command +from ._run_cmd import run as _run +from .integration import data_from_mime from .scm_workdir import Workdir -from .utils import _CmdResult -from .utils import data_from_mime -from .utils import do_ex -from .utils import require_command from .version import meta from .version import ScmVersion from .version import tag_to_version if TYPE_CHECKING: from . import hg_git - +log = logging.getLogger(__name__) REF_TAG_RE = re.compile(r"(?<=\btag: )([^,]+)\b") DESCRIBE_UNSUPPORTED = "%(describe" @@ -44,89 +47,105 @@ DEFAULT_DESCRIBE = [ ] +def run_git( + args: Sequence[str | os.PathLike[str]], repo: Path, *, check: bool = False +) -> _CompletedProcess: + return _run(["git", "--git-dir", repo / ".git", *args], cwd=repo, check=check) + + class GitWorkdir(Workdir): """experimental, may change at any time""" - COMMAND = "git" - @classmethod def from_potential_worktree(cls, wd: _t.PathT) -> GitWorkdir | None: - require_command(cls.COMMAND) - wd = os.path.abspath(wd) - git_dir = join(wd, ".git") - real_wd, _, ret = do_ex( - ["git", "--git-dir", git_dir, "rev-parse", "--show-prefix"], wd - ) - real_wd = real_wd[:-1] # remove the trailing pathsep - if ret: + wd = Path(wd).resolve() + real_wd = run_git(["rev-parse", "--show-prefix"], wd).parse_success(parse=str) + if real_wd is None: return None + else: + real_wd = real_wd[:-1] # remove the trailing pathsep + if not real_wd: - real_wd = wd + real_wd = os.fspath(wd) else: - assert wd.replace("\\", "/").endswith(real_wd) + str_wd = os.fspath(wd) + assert str_wd.replace("\\", "/").endswith(real_wd) # In windows wd contains ``\`` which should be replaced by ``/`` # for this assertion to work. Length of string isn't changed by replace # ``\\`` is just and escape for `\` - real_wd = wd[: -len(real_wd)] - trace("real root", real_wd) + real_wd = str_wd[: -len(real_wd)] + log.debug("real root %s", real_wd) if not samefile(real_wd, wd): return None - return cls(real_wd) - - def do_ex_git(self, cmd: list[str]) -> _CmdResult: - return self.do_ex(["git", "--git-dir", join(self.path, ".git")] + cmd) + return cls(Path(real_wd)) def is_dirty(self) -> bool: - out, _, _ = self.do_ex_git(["status", "--porcelain", "--untracked-files=no"]) - return bool(out) + return run_git( + ["status", "--porcelain", "--untracked-files=no"], self.path + ).parse_success( + parse=bool, + default=False, + ) def get_branch(self) -> str | None: - branch, err, ret = self.do_ex_git(["rev-parse", "--abbrev-ref", "HEAD"]) - if ret: - trace("branch err", branch, err, ret) - branch, err, ret = self.do_ex_git(["symbolic-ref", "--short", "HEAD"]) - if ret: - trace("branch err (symbolic-ref)", branch, err, ret) - return None - return branch + return run_git( + ["rev-parse", "--abbrev-ref", "HEAD"], + self.path, + ).parse_success( + parse=str, + error_msg="branch err (abbrev-err)", + ) or run_git( + ["symbolic-ref", "--short", "HEAD"], + self.path, + ).parse_success( + parse=str, + error_msg="branch err (symbolic-ref)", + ) def get_head_date(self) -> date | None: - timestamp, err, ret = self.do_ex_git( - ["-c", "log.showSignature=false", "log", "-n", "1", "HEAD", "--format=%cI"] + def parse_timestamp(timestamp_text: str) -> date | None: + if "%c" in timestamp_text: + log.warning("git too old -> timestamp is %r", timestamp_text) + return None + return datetime.fromisoformat(timestamp_text).date() + + res = run_git( + [ + *("-c", "log.showSignature=false"), + *("log", "-n", "1", "HEAD"), + "--format=%cI", + ], + self.path, + ) + return res.parse_success( + parse=parse_timestamp, + error_msg="logging the iso date for head failed", + default=None, ) - if ret: - trace("timestamp err", timestamp, err, ret) - return None - # TODO, when dropping python3.6 use fromiso - date_part = timestamp.split("T")[0] - if "%c" in date_part: - trace("git too old -> timestamp is ", timestamp) - return None - return datetime.strptime(date_part, r"%Y-%m-%d").date() def is_shallow(self) -> bool: - return isfile(join(self.path, ".git/shallow")) + return self.path.joinpath(".git/shallow").is_file() def fetch_shallow(self) -> None: - self.do_ex_git(["fetch", "--unshallow"]) + run_git(["fetch", "--unshallow"], self.path, check=True) def node(self) -> str | None: - node, _, ret = self.do_ex_git(["rev-parse", "--verify", "--quiet", "HEAD"]) - if not ret: + def _unsafe_short_node(node: str) -> str: return node[:7] - else: - return None + + return run_git( + ["rev-parse", "--verify", "--quiet", "HEAD"], self.path + ).parse_success( + parse=_unsafe_short_node, + ) def count_all_nodes(self) -> int: - revs, _, _ = self.do_ex_git(["rev-list", "HEAD"]) - return revs.count("\n") + 1 + res = run_git(["rev-list", "HEAD"], self.path) + return res.stdout.count("\n") + 1 - def default_describe(self) -> _CmdResult: - git_dir = join(self.path, ".git") - return self.do_ex( - DEFAULT_DESCRIBE[:1] + ["--git-dir", git_dir] + DEFAULT_DESCRIBE[1:] - ) + def default_describe(self) -> _CompletedProcess: + return run_git(DEFAULT_DESCRIBE[1:], self.path) def warn_on_shallow(wd: GitWorkdir) -> None: @@ -150,7 +169,7 @@ def fail_on_shallow(wd: GitWorkdir) -> None: ) -def get_working_directory(config: Configuration, root: str) -> GitWorkdir | None: +def get_working_directory(config: Configuration, root: _t.PathT) -> GitWorkdir | None: """ Return the working directory (``GitWorkdir``). """ @@ -158,14 +177,18 @@ def get_working_directory(config: Configuration, root: str) -> GitWorkdir | None if config.parent: # todo broken return GitWorkdir.from_potential_worktree(config.parent) - if config.search_parent_directories: - return search_parent(root) + for potential_root in discover.walk_potential_roots( + root, search_parents=config.search_parent_directories + ): + potential_wd = GitWorkdir.from_potential_worktree(potential_root) + if potential_wd is not None: + return potential_wd return GitWorkdir.from_potential_worktree(root) def parse( - root: str, + root: _t.PathT, config: Configuration, describe_command: str | list[str] | None = None, pre_parse: Callable[[GitWorkdir], None] = warn_on_shallow, @@ -173,6 +196,7 @@ def parse( """ :param pre_parse: experimental pre_parse action, may change at any time """ + _require_command("git") wd = get_working_directory(config, root) if wd: return _git_parse_inner( @@ -182,6 +206,34 @@ def parse( return None +def version_from_describe( + wd: GitWorkdir | hg_git.GitWorkdirHgClient, + config: Configuration, + describe_command: _t.CMD_TYPE | None, +) -> ScmVersion | None: + pass + + if config.git_describe_command is not None: + describe_command = config.git_describe_command + + if describe_command is not None: + if isinstance(describe_command, str): + describe_command = shlex.split(describe_command) + # todo: figure how ot ensure git with gitdir gets correctly invoked + if describe_command[0] == "git": + describe_res = run_git(describe_command[1:], wd.path) + else: + describe_res = _run(describe_command, wd.path) + else: + describe_res = wd.default_describe() + + def parse_describe(output: str) -> ScmVersion: + tag, distance, node, dirty = _git_parse_describe(output) + return meta(tag=tag, distance=distance, dirty=dirty, node=node, config=config) + + return describe_res.parse_success(parse=parse_describe) + + def _git_parse_inner( config: Configuration, wd: GitWorkdir | hg_git.GitWorkdirHgClient, @@ -191,47 +243,30 @@ def _git_parse_inner( if pre_parse: pre_parse(wd) - if config.git_describe_command is not None: - describe_command = config.git_describe_command + version = version_from_describe(wd, config, describe_command) - if describe_command is not None: - out, _, ret = wd.do_ex(describe_command) - else: - out, _, ret = wd.default_describe() - distance: int | None - node: str | None - if ret == 0: - tag, distance, node, dirty = _git_parse_describe(out) - if distance == 0 and not dirty: - distance = None - else: + if version is None: # If 'git git_describe_command' failed, try to get the information otherwise. - tag = "0.0" + tag = config.version_cls("0.0") node = wd.node() if node is None: distance = 0 + dirty = True else: distance = wd.count_all_nodes() node = "g" + node - dirty = wd.is_dirty() - + dirty = wd.is_dirty() + version = meta( + tag=tag, distance=distance, dirty=dirty, node=node, config=config + ) branch = wd.get_branch() node_date = wd.get_head_date() or date.today() - - return meta( - tag, - branch=branch, - node=node, - node_date=node_date, - distance=distance, - dirty=dirty, - config=config, - ) + return dataclasses.replace(version, branch=branch, node_date=node_date) def _git_parse_describe( describe_output: str, -) -> tuple[str, int | None, str | None, bool]: +) -> tuple[str, int, str | None, bool]: # 'describe_output' looks e.g. like 'v1.5.0-0-g4060507' or # 'v1.15.1rc1-37-g9bd1298-dirty'. # It may also just be a bare tag name if this is a tagged commit and we are @@ -246,7 +281,7 @@ def _git_parse_describe( split = describe_output.rsplit("-", 2) if len(split) < 3: # probably a tagged commit tag = describe_output - number = None + number = 0 node = None else: tag, number_, node = split @@ -254,38 +289,11 @@ def _git_parse_describe( return tag, number, node, dirty -def search_parent(dirname: _t.PathT) -> GitWorkdir | None: - """ - Walk up the path to find the `.git` directory. - :param dirname: Directory from which to start searching. - """ - - # Code based on: - # https://github.com/gitpython-developers/GitPython/blob/main/git/repo/base.py - - curpath = os.path.abspath(dirname) - - while curpath: - try: - wd = GitWorkdir.from_potential_worktree(curpath) - except Exception: - wd = None - - if wd is not None: - return wd - - curpath, tail = os.path.split(curpath) - - if not tail: - return None - return None - - def archival_to_version( data: dict[str, str], config: Configuration ) -> ScmVersion | None: node: str | None - trace("data", data) + log.debug("data %s", data) archival_describe = data.get("describe-name", DESCRIBE_UNSUPPORTED) if DESCRIBE_UNSUPPORTED in archival_describe: warnings.warn("git archive did not support describe output") @@ -294,7 +302,7 @@ def archival_to_version( return meta( tag, config=config, - distance=None if number == 0 else number, + distance=number, node=node, ) @@ -307,7 +315,7 @@ def archival_to_version( if node is None: return None elif "$FORMAT" in node.upper(): - warnings.warn("unexported git archival found") + warnings.warn("unprocessed git archival found (no export subst applied)") return None else: return meta("0.0", node=node, config=config) diff --git a/src/setuptools_scm/hg.py b/src/setuptools_scm/hg.py index e7295bc..522dfb6 100644 --- a/src/setuptools_scm/hg.py +++ b/src/setuptools_scm/hg.py @@ -1,16 +1,15 @@ from __future__ import annotations import datetime +import logging import os from pathlib import Path from typing import TYPE_CHECKING from . import Configuration -from ._trace import trace from ._version_cls import Version +from .integration import data_from_mime from .scm_workdir import Workdir -from .utils import data_from_mime -from .utils import require_command from .version import meta from .version import ScmVersion from .version import tag_to_version @@ -18,19 +17,18 @@ from .version import tag_to_version if TYPE_CHECKING: from . import _types as _t -from ._run_cmd import run as _run +from ._run_cmd import run as _run, require_command as _require_command +log = logging.getLogger(__name__) -class HgWorkdir(Workdir): - COMMAND = "hg" +class HgWorkdir(Workdir): @classmethod def from_potential_worktree(cls, wd: _t.PathT) -> HgWorkdir | None: - require_command(cls.COMMAND) - res = _run("hg root", wd) + res = _run(["hg", "root"], wd) if res.returncode: return None - return cls(res.stdout) + return cls(Path(res.stdout)) def get_meta(self, config: Configuration) -> ScmVersion | None: node: str @@ -45,16 +43,22 @@ class HgWorkdir(Workdir): # mainly used to emulate Git branches, which is already supported with # the dedicated class GitWorkdirHgClient) - branch, dirty_str, dirty_date = self.do( - ["hg", "id", "-T", "{branch}\n{if(dirty, 1, 0)}\n{date|shortdate}"] - ).split("\n") + branch, dirty_str, dirty_date = _run( + ["hg", "id", "-T", "{branch}\n{if(dirty, 1, 0)}\n{date|shortdate}"], + cwd=self.path, + check=True, + ).stdout.split("\n") dirty = bool(int(dirty_str)) node_date = datetime.date.fromisoformat(dirty_date if dirty else node_date_str) - if node.count("0") == len(node): - trace("initial node", self.path) + if node == "0" * len(node): + log.debug("initial node %s", self.path) return meta( - "0.0", config=config, dirty=dirty, branch=branch, node_date=node_date + Version("0.0"), + config=config, + dirty=dirty, + branch=branch, + node_date=node_date, ) node = "h" + node[:7] @@ -97,7 +101,7 @@ class HgWorkdir(Workdir): return meta(tag, config=config, node_date=node_date) except ValueError as e: - trace("error", e) + log.exception("error %s", e) pass # unpacking failed, old hg return None @@ -139,6 +143,7 @@ class HgWorkdir(Workdir): def parse(root: _t.PathT, config: Configuration) -> ScmVersion | None: + _require_command("hg") if os.path.exists(os.path.join(root, ".hg/git")): res = _run(["hg", "path"], root) if not res.returncode: @@ -162,7 +167,7 @@ def parse(root: _t.PathT, config: Configuration) -> ScmVersion | None: def archival_to_version(data: dict[str, str], config: Configuration) -> ScmVersion: - trace("data", data) + log.debug("data %s", data) node = data.get("node", "")[:12] if node: node = "h" + node @@ -176,7 +181,7 @@ def archival_to_version(data: dict[str, str], config: Configuration) -> ScmVersi config=config, ) else: - return meta("0.0", node=node, config=config) + return meta(config.version_cls("0.0"), node=node, config=config) def parse_archival(root: _t.PathT, config: Configuration) -> ScmVersion: diff --git a/src/setuptools_scm/hg_git.py b/src/setuptools_scm/hg_git.py index f7ea883..b6c3036 100644 --- a/src/setuptools_scm/hg_git.py +++ b/src/setuptools_scm/hg_git.py @@ -1,19 +1,25 @@ from __future__ import annotations +import logging import os from contextlib import suppress from datetime import date -from datetime import datetime +from pathlib import Path from . import _types as _t -from ._trace import trace +from ._run_cmd import CompletedProcess as _CompletedProcess +from ._run_cmd import require_command +from ._run_cmd import run as _run from .git import GitWorkdir from .hg import HgWorkdir -from .utils import _CmdResult -from .utils import do_ex -from .utils import require_command -_FAKE_GIT_DESCRIBE_ERROR = _CmdResult("<>hg git failed", "", 1) +log = logging.getLogger(__name__) + +_FAKE_GIT_DESCRIBE_ERROR = _CompletedProcess( + "fake git describe output for hg", + 1, + "<>hg git failed to describe", +) class GitWorkdirHgClient(GitWorkdir, HgWorkdir): @@ -21,29 +27,27 @@ class GitWorkdirHgClient(GitWorkdir, HgWorkdir): @classmethod def from_potential_worktree(cls, wd: _t.PathT) -> GitWorkdirHgClient | None: - require_command(cls.COMMAND) - root, _, ret = do_ex(["hg", "root"], wd) - if ret: + require_command("hg") + res = _run(["hg", "root"], cwd=wd).parse_success(parse=Path) + if res is None: return None - return cls(root) + return cls(res) def is_dirty(self) -> bool: - out, _, _ = self.do_ex('hg id -T "{dirty}"') - return bool(out) + res = _run(["hg", "id", "-T", "{dirty}"], cwd=self.path, check=True) + return bool(res.stdout) def get_branch(self) -> str | None: - res = self.do_ex('hg id -T "{bookmarks}"') + res = _run(["hg", "id", "-T", "{bookmarks}"], cwd=self.path) if res.returncode: - trace("branch err", res) + log.info("branch err %s", res) return None - return res.out + return res.stdout def get_head_date(self) -> date | None: - date_part, err, ret = self.do_ex('hg log -r . -T "{shortdate(date)}"') - if ret: - trace("head date err", date_part, err, ret) - return None - return datetime.strptime(date_part, r"%Y-%m-%d").date() + return _run('hg log -r . -T "{shortdate(date)}"', cwd=self.path).parse_success( + parse=date.fromisoformat, error_msg="head date err" + ) def is_shallow(self) -> bool: return False @@ -52,11 +56,11 @@ class GitWorkdirHgClient(GitWorkdir, HgWorkdir): pass def get_hg_node(self) -> str | None: - node, _, ret = self.do_ex('hg log -r . -T "{node}"') - if not ret: - return node - else: + res = _run('hg log -r . -T "{node}"', cwd=self.path) + if res.returncode: return None + else: + return res.stdout def _hg2git(self, hg_node: str) -> str | None: with suppress(FileNotFoundError): @@ -76,11 +80,11 @@ class GitWorkdirHgClient(GitWorkdir, HgWorkdir): if git_node is None: # trying again after hg -> git - self.do_ex("hg gexport") + _run(["hg", "gexport"], cwd=self.path) git_node = self._hg2git(hg_node) if git_node is None: - trace("Cannot get git node so we use hg node", hg_node) + log.debug("Cannot get git node so we use hg node %s", hg_node) if hg_node == "0" * len(hg_node): # mimic Git behavior @@ -91,17 +95,17 @@ class GitWorkdirHgClient(GitWorkdir, HgWorkdir): return git_node[:7] def count_all_nodes(self) -> int: - revs, _, _ = self.do_ex(["hg", "log", "-r", "ancestors(.)", "-T", "."]) - return len(revs) + res = _run(["hg", "log", "-r", "ancestors(.)", "-T", "."], cwd=self.path) + return len(res.stdout) - def default_describe(self) -> _CmdResult: + def default_describe(self) -> _CompletedProcess: """ Tentative to reproduce the output of `git describe --dirty --tags --long --match *[0-9]*` """ - hg_tags_str, _, ret = self.do_ex( + res = _run( [ "hg", "log", @@ -109,16 +113,17 @@ class GitWorkdirHgClient(GitWorkdir, HgWorkdir): "(reverse(ancestors(.)) and tag(r're:v?[0-9].*'))", "-T", "{tags}{if(tags, ' ', '')}", - ] + ], + cwd=self.path, ) - if ret: + if res.returncode: return _FAKE_GIT_DESCRIBE_ERROR - hg_tags: list[str] = hg_tags_str.split() + hg_tags: list[str] = res.stdout.split() if not hg_tags: return _FAKE_GIT_DESCRIBE_ERROR - with open(os.path.join(self.path, ".hg/git-tags")) as fp: + with self.path.joinpath(".hg/git-tags").open() as fp: git_tags: dict[str, str] = dict(line.split()[::-1] for line in fp) tag: str @@ -127,13 +132,13 @@ class GitWorkdirHgClient(GitWorkdir, HgWorkdir): tag = hg_tag break else: - trace("tag not found", hg_tags, git_tags) + logging.warning("tag not found hg=%s git=%s", hg_tags, git_tags) return _FAKE_GIT_DESCRIBE_ERROR - out, _, ret = self.do_ex(["hg", "log", "-r", f"'{tag}'::.", "-T", "."]) - if ret: + res = _run(["hg", "log", "-r", f"'{tag}'::.", "-T", "."], cwd=self.path) + if res.returncode: return _FAKE_GIT_DESCRIBE_ERROR - distance = len(out) - 1 + distance = len(res.stdout) - 1 node = self.node() assert node is not None @@ -141,5 +146,10 @@ class GitWorkdirHgClient(GitWorkdir, HgWorkdir): if self.is_dirty(): desc += "-dirty" - trace("desc", desc) - return _CmdResult(desc, "", 0) + log.debug("faked describe %r", desc) + return _CompletedProcess( + ["setuptools-scm", "faked", "describe"], + returncode=0, + stdout=desc, + stderr="", + ) diff --git a/src/setuptools_scm/integration.py b/src/setuptools_scm/integration.py index 1d600e2..9df4f3e 100644 --- a/src/setuptools_scm/integration.py +++ b/src/setuptools_scm/integration.py @@ -1,7 +1,10 @@ from __future__ import annotations +import logging import os +import textwrap import warnings +from pathlib import Path from typing import Any from typing import Callable from typing import TYPE_CHECKING @@ -9,14 +12,15 @@ from typing import TYPE_CHECKING import setuptools from . import _get_version +from . import _types as _t from . import _version_missing from . import Configuration from ._integration.setuptools import ( read_dist_name_from_setup_cfg as _read_dist_name_from_setup_cfg, ) -from ._trace import trace from ._version_cls import _validate_version_cls +log = logging.getLogger(__name__) if TYPE_CHECKING: pass @@ -82,11 +86,11 @@ def version_keyword( if dist.metadata.version is not None: warnings.warn(f"version of {dist_name} already set") return - trace( - "version keyword", + log.debug( + "version keyword %r", vars(dist.metadata), ) - trace("dist", id(dist), id(dist.metadata)) + log.debug("dist %s %s", id(dist), id(dist.metadata)) if dist_name is None: dist_name = _read_dist_name_from_setup_cfg() @@ -98,11 +102,11 @@ def version_keyword( def infer_version(dist: setuptools.Distribution) -> None: - trace( - "finalize hook", + log.debug( + "finalize hook %r", vars(dist.metadata), ) - trace("dist", id(dist), id(dist.metadata)) + log.debug("dist %s %s", id(dist), id(dist.metadata)) if dist.metadata.version is not None: return # metadata already added by hook dist_name = dist.metadata.name @@ -115,6 +119,16 @@ def infer_version(dist: setuptools.Distribution) -> None: try: config = Configuration.from_file(dist_name=dist_name) except LookupError as e: - trace(e) + log.exception(e) else: _assign_version(dist, config) + + +def data_from_mime(path: _t.PathT) -> dict[str, str]: + content = Path(path).read_text(encoding="utf-8") + log.debug("mime %s content:\n%s", path, textwrap.indent(content, " ")) + # the complex conditions come from reading pseudo-mime-messages + data = dict(x.split(": ", 1) for x in content.splitlines() if ": " in x) + + log.debug("mime %s data:\n%s", path, data) + return data diff --git a/src/setuptools_scm/scm_workdir.py b/src/setuptools_scm/scm_workdir.py index 113f68a..9879549 100644 --- a/src/setuptools_scm/scm_workdir.py +++ b/src/setuptools_scm/scm_workdir.py @@ -1,26 +1,15 @@ from __future__ import annotations -from typing import ClassVar -from typing import TYPE_CHECKING +from dataclasses import dataclass +from pathlib import Path -from .utils import _CmdResult -from .utils import do -from .utils import do_ex -from .utils import require_command - -if TYPE_CHECKING: - from . import _types as _t +from ._config import Configuration +from .version import ScmVersion +@dataclass() class Workdir: - COMMAND: ClassVar[str] - - def __init__(self, path: _t.PathT): - require_command(self.COMMAND) - self.path = path - - def do_ex(self, cmd: _t.CMD_TYPE) -> _CmdResult: - return do_ex(cmd, cwd=self.path) + path: Path - def do(self, cmd: _t.CMD_TYPE) -> str: - return do(cmd, cwd=self.path) + def run_describe(self, config: Configuration) -> ScmVersion: + raise NotImplementedError(self.run_describe) diff --git a/src/setuptools_scm/utils.py b/src/setuptools_scm/utils.py deleted file mode 100644 index 6788f23..0000000 --- a/src/setuptools_scm/utils.py +++ /dev/null @@ -1,79 +0,0 @@ -""" -utils -""" -from __future__ import annotations - -import logging -import subprocess -import sys -import warnings -from types import CodeType -from types import FunctionType -from typing import NamedTuple -from typing import TYPE_CHECKING - -from . import _run_cmd -from . import _trace - -if TYPE_CHECKING: - from . import _types as _t - -log = logging.getLogger(__name__) - - -class _CmdResult(NamedTuple): - out: str - err: str - returncode: int - - -def do_ex(cmd: _t.CMD_TYPE, cwd: _t.PathT = ".") -> _CmdResult: - res = _run_cmd.run(cmd, cwd) - return _CmdResult(res.stdout, res.stderr, res.returncode) - - -def do(cmd: _t.CMD_TYPE, cwd: _t.PathT = ".") -> str: - out, err, ret = do_ex(cmd, cwd) - if ret and not _trace.DEBUG: - print(err) - return out - - -def data_from_mime(path: _t.PathT) -> dict[str, str]: - with open(path, encoding="utf-8") as fp: - content = fp.read() - _trace.trace("content", repr(content)) - # the complex conditions come from reading pseudo-mime-messages - data = dict(x.split(": ", 1) for x in content.splitlines() if ": " in x) - _trace.trace("data", data) - return data - - -def function_has_arg(fn: object | FunctionType, argname: str) -> bool: - assert isinstance(fn, FunctionType) - code: CodeType = fn.__code__ - return argname in code.co_varnames - - -def has_command(name: str, args: list[str] | None = None, warn: bool = True) -> bool: - try: - cmd = [name, "help"] if args is None else [name, *args] - p = _run_cmd.run(cmd, cwd=".", timeout=5) - except OSError: - _trace.trace(*sys.exc_info()) - res = False - except subprocess.TimeoutExpired as e: - log.info(e) - _trace.trace(e) - res = False - - else: - res = not p.returncode - if not res and warn: - warnings.warn("%r was not found" % name, category=RuntimeWarning) - return res - - -def require_command(name: str) -> None: - if not has_command(name, warn=False): - raise OSError(f"{name!r} was not found") diff --git a/src/setuptools_scm/version.py b/src/setuptools_scm/version.py index 36bc7df..128533c 100644 --- a/src/setuptools_scm/version.py +++ b/src/setuptools_scm/version.py @@ -1,6 +1,7 @@ from __future__ import annotations import dataclasses +import logging import os import re import warnings @@ -13,22 +14,21 @@ from typing import Match from typing import TYPE_CHECKING from . import _entrypoints -from ._modify_version import _bump_dev -from ._modify_version import _bump_regex -from ._modify_version import _dont_guess_next_version -from ._modify_version import _format_local_with_time -from ._modify_version import _strip_local +from . import _modify_version if TYPE_CHECKING: from typing_extensions import Concatenate + from typing_extensions import ParamSpec - from . import _types as _t + _P = ParamSpec("_P") from ._version_cls import Version as PkgVersion, _VersionT from . import _version_cls as _v from . import _config -from ._trace import trace + +log = logging.getLogger(__name__) + SEMVER_MINOR = 2 SEMVER_PATCH = 3 @@ -55,19 +55,19 @@ def _parse_version_tag( "suffix": match.group(0)[match.end(key) :], } - trace(f"tag '{tag}' parsed to {result}") + log.debug(f"tag '{tag}' parsed to {result}") return result def callable_or_entrypoint(group: str, callable_or_name: str | Any) -> Any: - trace("ep", (group, callable_or_name)) + log.debug("ep %r %r", group, callable_or_name) if callable(callable_or_name): return callable_or_name from ._entrypoints import iter_entry_points for ep in iter_entry_points(group, callable_or_name): - trace("ep found:", ep.name) + log.debug("ep found: %s", ep.name) return ep.load() @@ -77,7 +77,7 @@ def tag_to_version( """ take a tag that might be prefixed with a keyword and return only the version part """ - trace("tag", tag) + log.debug("tag %s", tag) tagdict = _parse_version_tag(tag, config) if not isinstance(tagdict, dict) or not tagdict.get("version", None): @@ -85,7 +85,7 @@ def tag_to_version( return None version_str = tagdict["version"] - trace("version pre parse", version_str) + log.debug("version pre parse %s", version_str) if tagdict.get("suffix", ""): warnings.warn( @@ -95,7 +95,7 @@ def tag_to_version( ) version: _VersionT = config.version_cls(version_str) - trace("version", repr(version)) + log.debug("version=%r", version) return version @@ -112,7 +112,7 @@ def _source_epoch_or_utc_now() -> datetime: class ScmVersion: tag: _v.Version | _v.NonNormalizedVersion | str config: _config.Configuration - distance: int | None = None + distance: int = 0 node: str | None = None dirty: bool = False preformatted: bool = False @@ -122,13 +122,9 @@ class ScmVersion: init=False, default_factory=_source_epoch_or_utc_now ) - def __post_init__(self) -> None: - if self.dirty and self.distance is None: - self.distance = 0 - @property def exact(self) -> bool: - return self.distance is None + return self.distance == 0 and not self.dirty def __repr__(self) -> str: return self.format_with( @@ -153,10 +149,10 @@ class ScmVersion: def format_next_version( self, - guess_next: Callable[Concatenate[ScmVersion, _t.P], str], + guess_next: Callable[Concatenate[ScmVersion, _P], str], fmt: str = "{guessed}.dev{distance}", - *k: _t.P.args, - **kw: _t.P.kwargs, + *k: _P.args, + **kw: _P.kwargs, ) -> str: guessed = guess_next(self, *k, **kw) return self.format_with(fmt, guessed=guessed) @@ -178,7 +174,7 @@ def _parse_tag( def meta( tag: str | _VersionT, *, - distance: int | None = None, + distance: int = 0, dirty: bool = False, node: str | None = None, preformatted: bool = False, @@ -187,7 +183,7 @@ def meta( node_date: date | None = None, ) -> ScmVersion: parsed_version = _parse_tag(tag, preformatted, config) - trace("version", tag, "->", parsed_version) + log.info("version %s -> %s", tag, parsed_version) assert parsed_version is not None, "Can't parse version %s" % tag return ScmVersion( parsed_version, @@ -202,8 +198,8 @@ def meta( def guess_next_version(tag_version: ScmVersion) -> str: - version = _strip_local(str(tag_version.tag)) - return _bump_dev(version) or _bump_regex(version) + version = _modify_version._strip_local(str(tag_version.tag)) + return _modify_version._bump_dev(version) or _modify_version._bump_regex(version) def guess_next_dev_version(version: ScmVersion) -> str: @@ -282,7 +278,7 @@ def no_guess_dev_version(version: ScmVersion) -> str: if version.exact: return version.format_with("{tag}") else: - return version.format_next_version(_dont_guess_next_version) + return version.format_next_version(_modify_version._dont_guess_next_version) _DATE_REGEX = re.compile( @@ -366,11 +362,11 @@ def calver_by_date(version: ScmVersion) -> str: def get_local_node_and_date(version: ScmVersion) -> str: - return _format_local_with_time(version, time_format="%Y%m%d") + return _modify_version._format_local_with_time(version, time_format="%Y%m%d") def get_local_node_and_timestamp(version: ScmVersion, fmt: str = "%Y%m%d%H%M%S") -> str: - return _format_local_with_time(version, time_format=fmt) + return _modify_version._format_local_with_time(version, time_format=fmt) def get_local_dirty_tag(version: ScmVersion) -> str: @@ -389,18 +385,18 @@ def postrelease_version(version: ScmVersion) -> str: def format_version(version: ScmVersion, **config: Any) -> str: - trace("scm version", version) - trace("config", config) + log.debug("scm version %s", version) + log.debug("config %s", config) if version.preformatted: assert isinstance(version.tag, str) return version.tag main_version = _entrypoints._call_version_scheme( version, "setuptools_scm.version_scheme", config["version_scheme"], None ) - trace("version", main_version) + log.debug("version %s", main_version) assert main_version is not None local_version = _entrypoints._call_version_scheme( version, "setuptools_scm.local_scheme", config["local_scheme"], "+unknown" ) - trace("local_version", local_version) + log.debug("local_version %s", local_version) return main_version + local_version diff --git a/testing/conftest.py b/testing/conftest.py index e1c8160..ef5883c 100644 --- a/testing/conftest.py +++ b/testing/conftest.py @@ -1,12 +1,14 @@ from __future__ import annotations +import contextlib import os from pathlib import Path +from types import TracebackType from typing import Any +from typing import Iterator import pytest -import setuptools_scm.utils from .wd_wrapper import WorkDir from setuptools_scm._run_cmd import run @@ -40,25 +42,35 @@ def pytest_addoption(parser: Any) -> None: ) -class DebugMode: - def __init__(self, monkeypatch: pytest.MonkeyPatch): - self.__monkeypatch = monkeypatch - self.__module = setuptools_scm._trace +class DebugMode(contextlib.AbstractContextManager): # type: ignore[type-arg] + from setuptools_scm import _log as __module - __monkeypatch: pytest.MonkeyPatch + def __init__(self) -> None: + self.__stack = contextlib.ExitStack() + + def __enter__(self) -> DebugMode: + self.enable() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + self.disable() def enable(self) -> None: - self.__monkeypatch.setattr(self.__module, "DEBUG", True) + self.__stack.enter_context(self.__module.defer_to_pytest()) def disable(self) -> None: - self.__monkeypatch.setattr(self.__module, "DEBUG", False) + self.__stack.close() @pytest.fixture(autouse=True) -def debug_mode(monkeypatch: pytest.MonkeyPatch) -> DebugMode: - debug_mode = DebugMode(monkeypatch) - debug_mode.enable() - return debug_mode +def debug_mode() -> Iterator[DebugMode]: + with DebugMode() as debug_mode: + yield debug_mode @pytest.fixture diff --git a/testing/test_basic_api.py b/testing/test_basic_api.py index bf21a47..f1b4c4f 100644 --- a/testing/test_basic_api.py +++ b/testing/test_basic_api.py @@ -1,7 +1,6 @@ from __future__ import annotations import os -import shutil import sys from pathlib import Path @@ -11,17 +10,13 @@ import setuptools_scm from setuptools_scm import Configuration from setuptools_scm import dump_version from setuptools_scm._run_cmd import run -from setuptools_scm.utils import data_from_mime -from setuptools_scm.utils import do +from setuptools_scm.integration import data_from_mime from setuptools_scm.version import ScmVersion from testing.wd_wrapper import WorkDir -@pytest.mark.parametrize("cmd", ["ls", "dir"]) -def test_do(cmd: str, tmp_path: Path) -> None: - if not shutil.which(cmd): - pytest.skip(f"{cmd} not found") - do(cmd, cwd=tmp_path) +def test_run_plain(tmp_path: Path) -> None: + run([sys.executable, "-c", "print(1)"], cwd=tmp_path) def test_data_from_mime(tmp_path: Path) -> None: @@ -35,7 +30,7 @@ def test_data_from_mime(tmp_path: Path) -> None: def test_version_from_pkginfo(wd: WorkDir) -> None: wd.write("PKG-INFO", "Version: 0.1") - assert wd.version == "0.1" + assert wd.get_version() == "0.1" # replicate issue 167 assert wd.get_version(version_scheme="1.{0.distance}.0".format) == "0.1" diff --git a/testing/test_functions.py b/testing/test_functions.py index ceb6cd3..6b0bd39 100644 --- a/testing/test_functions.py +++ b/testing/test_functions.py @@ -8,7 +8,7 @@ from setuptools_scm import Configuration from setuptools_scm import dump_version from setuptools_scm import get_version from setuptools_scm import PRETEND_KEY -from setuptools_scm.utils import has_command +from setuptools_scm._run_cmd import has_command from setuptools_scm.version import format_version from setuptools_scm.version import guess_next_version from setuptools_scm.version import meta @@ -38,36 +38,37 @@ def test_next_tag(tag: str, expected: str) -> None: VERSIONS = { - "exact": meta("1.1", distance=None, dirty=False, config=c), - "zerodistance": meta("1.1", distance=0, dirty=False, config=c), - "dirty": meta("1.1", distance=None, dirty=True, config=c), - "distance": meta("1.1", distance=3, dirty=False, config=c), - "distancedirty": meta("1.1", distance=3, dirty=True, config=c), + "exact": meta("1.1", distance=0, dirty=False, config=c), + "dirty": meta("1.1", distance=0, dirty=True, config=c), + "distance-clean": meta("1.1", distance=3, dirty=False, config=c), + "distance-dirty": meta("1.1", distance=3, dirty=True, config=c), } @pytest.mark.parametrize( - "version,scheme,expected", + "version,version_scheme, local_scheme,expected", [ - ("exact", "guess-next-dev node-and-date", "1.1"), - ("zerodistance", "guess-next-dev node-and-date", "1.2.dev0"), - ("zerodistance", "guess-next-dev no-local-version", "1.2.dev0"), - ("dirty", "guess-next-dev node-and-date", "1.2.dev0+d20090213"), - ("dirty", "guess-next-dev no-local-version", "1.2.dev0"), - ("distance", "guess-next-dev node-and-date", "1.2.dev3"), - ("distancedirty", "guess-next-dev node-and-date", "1.2.dev3+d20090213"), - ("distancedirty", "guess-next-dev no-local-version", "1.2.dev3"), - ("exact", "post-release node-and-date", "1.1"), - ("zerodistance", "post-release node-and-date", "1.1.post0"), - ("dirty", "post-release node-and-date", "1.1.post0+d20090213"), - ("distance", "post-release node-and-date", "1.1.post3"), - ("distancedirty", "post-release node-and-date", "1.1.post3+d20090213"), + ("exact", "guess-next-dev", "node-and-date", "1.1"), + ("dirty", "guess-next-dev", "node-and-date", "1.2.dev0+d20090213"), + ("dirty", "guess-next-dev", "no-local-version", "1.2.dev0"), + ("distance-clean", "guess-next-dev", "node-and-date", "1.2.dev3"), + ("distance-dirty", "guess-next-dev", "node-and-date", "1.2.dev3+d20090213"), + ("exact", "post-release", "node-and-date", "1.1"), + ("dirty", "post-release", "node-and-date", "1.1.post0+d20090213"), + ("distance-clean", "post-release", "node-and-date", "1.1.post3"), + ("distance-dirty", "post-release", "node-and-date", "1.1.post3+d20090213"), ], ) -def test_format_version(version: str, scheme: str, expected: str) -> None: +def test_format_version( + version: str, version_scheme: str, local_scheme: str, expected: str +) -> None: scm_version = VERSIONS[version] - vs, ls = scheme.split() - assert format_version(scm_version, version_scheme=vs, local_scheme=ls) == expected + assert ( + format_version( + scm_version, version_scheme=version_scheme, local_scheme=local_scheme + ) + == expected + ) def test_dump_version_doesnt_bail_on_value_error(tmp_path: Path) -> None: diff --git a/testing/test_git.py b/testing/test_git.py index 3e1ee67..9e2e9b5 100644 --- a/testing/test_git.py +++ b/testing/test_git.py @@ -22,9 +22,10 @@ from setuptools_scm import Configuration from setuptools_scm import git from setuptools_scm import NonNormalizedVersion from setuptools_scm._file_finders.git import git_find_files +from setuptools_scm._run_cmd import CompletedProcess +from setuptools_scm._run_cmd import has_command from setuptools_scm._run_cmd import run from setuptools_scm.git import archival_to_version -from setuptools_scm.utils import has_command from setuptools_scm.version import format_version pytestmark = pytest.mark.skipif( @@ -70,7 +71,7 @@ setup(use_scm_version={"root": "../..", """ ) res = run([sys.executable, "setup.py", "--version"], p) - assert res.stdout == "0.1.dev0" + assert res.stdout == "0.1.dev0+d20090213" def test_root_search_parent_directories( @@ -85,7 +86,7 @@ setup(use_scm_version={"search_parent_directories": True}) """ ) res = run([sys.executable, "setup.py", "--version"], p) - assert res.stdout == "0.1.dev0" + assert res.stdout == "0.1.dev0+d20090213" def test_git_gone(wd: WorkDir, monkeypatch: pytest.MonkeyPatch) -> None: @@ -146,37 +147,37 @@ def test_not_owner(wd: WorkDir) -> None: def test_version_from_git(wd: WorkDir) -> None: - assert wd.version == "0.1.dev0" + assert wd.get_version() == "0.1.dev0+d20090213" parsed = git.parse(str(wd.cwd), Configuration(), git.DEFAULT_DESCRIBE) assert parsed is not None and parsed.branch in ("master", "main") wd.commit_testfile() - assert wd.version.startswith("0.1.dev1+g") - assert not wd.version.endswith("1-") + assert wd.get_version().startswith("0.1.dev1+g") + assert not wd.get_version().endswith("1-") wd("git tag v0.1") - assert wd.version == "0.1" + assert wd.get_version() == "0.1" wd.write("test.txt", "test2") - assert wd.version.startswith("0.2.dev0+g") + assert wd.get_version().startswith("0.2.dev0+g") wd.commit_testfile() - assert wd.version.startswith("0.2.dev1+g") + assert wd.get_version().startswith("0.2.dev1+g") wd("git tag version-0.2") - assert wd.version.startswith("0.2") + assert wd.get_version().startswith("0.2") wd.commit_testfile() wd("git tag version-0.2.post210+gbe48adfpost3+g0cc25f2") with pytest.warns( UserWarning, match="tag '.*' will be stripped of its suffix '.*'" ): - assert wd.version.startswith("0.2") + assert wd.get_version().startswith("0.2") wd.commit_testfile() wd("git tag 17.33.0-rc") - assert wd.version == "17.33.0rc0" + assert wd.get_version() == "17.33.0rc0" # custom normalization assert wd.get_version(normalize=False) == "17.33.0-rc" @@ -253,9 +254,10 @@ def test_unicode_version_scheme(wd: WorkDir) -> None: def test_git_worktree(wd: WorkDir) -> None: wd.write("test.txt", "test2") # untracked files dont change the state - assert wd.version == "0.1.dev0" + assert wd.get_version() == "0.1.dev0+d20090213" + wd("git add test.txt") - assert wd.version.startswith("0.1.dev0+d") + assert wd.get_version().startswith("0.1.dev0+d") @pytest.mark.issue(86) @@ -268,14 +270,14 @@ def test_git_dirty_notag( wd.commit_testfile() wd.write("test.txt", "test2") wd("git add test.txt") - assert wd.version.startswith("0.1.dev1") + version = wd.get_version() + if today: # the date on the tag is in UTC tag = datetime.now(timezone.utc).date().strftime(".d%Y%m%d") else: tag = ".d20090213" - # we are dirty, check for the tag - assert tag in wd.version + assert version.startswith("0.1.dev1+g") and version.endswith(tag) @pytest.mark.issue(193) @@ -303,7 +305,8 @@ def shallow_wd(wd: WorkDir, tmp_path: Path) -> Path: def test_git_parse_shallow_warns( shallow_wd: Path, recwarn: pytest.WarningsRecorder ) -> None: - git.parse(str(shallow_wd), Configuration()) + git.parse(shallow_wd, Configuration()) + print(list(recwarn)) msg = recwarn.pop() assert "is shallow and may cause errors" in str(msg.message) @@ -339,7 +342,7 @@ def test_parse_no_worktree(tmp_path: Path) -> None: def test_alphanumeric_tags_match(wd: WorkDir) -> None: wd.commit_testfile() wd("git tag newstyle-development-started") - assert wd.version.startswith("0.1.dev1+g") + assert wd.get_version().startswith("0.1.dev1+g") def test_git_archive_export_ignore( @@ -387,7 +390,7 @@ def test_git_archive_run_from_subdirectory( def test_git_branch_names_correct(wd: WorkDir) -> None: wd.commit_testfile() wd("git checkout -b test/fun") - wd_git = git.GitWorkdir(os.fspath(wd.cwd)) + wd_git = git.GitWorkdir(wd.cwd) assert wd_git.get_branch() == "test/fun" @@ -443,10 +446,10 @@ def test_non_dotted_tag_no_version_match(wd: WorkDir) -> None: def test_gitdir(monkeypatch: pytest.MonkeyPatch, wd: WorkDir) -> None: """ """ wd.commit_testfile() - normal = wd.version + normal = wd.get_version() # git hooks set this and break subsequent setuptools_scm unless we clean monkeypatch.setenv("GIT_DIR", __file__) - assert wd.version == normal + assert wd.get_version() == normal def test_git_getdate(wd: WorkDir) -> None: @@ -459,7 +462,7 @@ def test_git_getdate(wd: WorkDir) -> None: assert parsed.node_date is not None return parsed.node_date - git_wd = git.GitWorkdir(os.fspath(wd.cwd)) + git_wd = git.GitWorkdir(wd.cwd) assert git_wd.get_head_date() is None assert parse_date() == today @@ -468,10 +471,17 @@ def test_git_getdate(wd: WorkDir) -> None: assert parse_date() == today -def test_git_getdate_badgit(wd: WorkDir) -> None: +def test_git_getdate_badgit( + wd: WorkDir, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch +) -> None: wd.commit_testfile() - git_wd = git.GitWorkdir(os.fspath(wd.cwd)) - with patch.object(git_wd, "do_ex", Mock(return_value=("%cI", "", 0))): + git_wd = git.GitWorkdir(wd.cwd) + fake_date_result = CompletedProcess(args=[], stdout="%cI", stderr="", returncode=0) + with patch.object( + git, + "run_git", + Mock(return_value=fake_date_result), + ): assert git_wd.get_head_date() is None @@ -504,7 +514,7 @@ Expire-Date: 0 def test_git_getdate_signed_commit(signed_commit_wd: WorkDir) -> None: today = date.today() signed_commit_wd.commit_testfile(signed=True) - git_wd = git.GitWorkdir(os.fspath(signed_commit_wd.cwd)) + git_wd = git.GitWorkdir(signed_commit_wd.cwd) assert git_wd.get_head_date() == today @@ -551,6 +561,8 @@ def test_git_archival_node_missing_no_version() -> None: def test_git_archival_from_unfiltered() -> None: config = Configuration() - with pytest.warns(UserWarning, match="unexported git archival found"): + with pytest.warns( + UserWarning, match=r"unprocessed git archival found \(no export subst applied\)" + ): version = archival_to_version({"node": "$Format:%H$"}, config=config) assert version is None diff --git a/testing/test_hg_git.py b/testing/test_hg_git.py index 476016f..9527cb0 100644 --- a/testing/test_hg_git.py +++ b/testing/test_hg_git.py @@ -2,8 +2,8 @@ from __future__ import annotations import pytest -from setuptools_scm.utils import do_ex -from setuptools_scm.utils import has_command +from setuptools_scm._run_cmd import has_command +from setuptools_scm._run_cmd import run from testing.wd_wrapper import WorkDir @@ -12,13 +12,13 @@ def _check_hg_git() -> None: if not has_command("hg", warn=False): pytest.skip("hg executable not found") - python_hg, err, ret = do_ex("hg debuginstall --template {pythonexe}") + res = run("hg debuginstall --template {pythonexe}", cwd=".") - if ret: + if res.returncode: skip_no_hggit = True else: - out, err, ret = do_ex([python_hg.strip(), "-c", "import hggit"]) - skip_no_hggit = bool(ret) + res = run([res.stdout, "-c", "import hggit"], cwd=".") + skip_no_hggit = bool(res.returncode) if skip_no_hggit: pytest.skip("hg-git not installed") @@ -26,15 +26,15 @@ def _check_hg_git() -> None: def test_base(repositories_hg_git: tuple[WorkDir, WorkDir]) -> None: wd, wd_git = repositories_hg_git - assert wd_git.version == "0.1.dev0" - assert wd.version == "0.1.dev0" + assert wd_git.get_version() == "0.1.dev0+d20090213" + assert wd.get_version() == "0.1.dev0+d20090213" wd_git.commit_testfile() - version_git = wd_git.version + version_git = wd_git.get_version() wd("hg pull -u") - version = wd.version + version = wd.get_version() assert version_git.startswith("0.1.dev1+g") assert version.startswith("0.1.dev1+g") @@ -44,24 +44,24 @@ def test_base(repositories_hg_git: tuple[WorkDir, WorkDir]) -> None: wd_git("git tag v0.1") wd("hg pull -u") - assert wd_git.version == "0.1" - assert wd.version == "0.1" + assert wd_git.get_version() == "0.1" + assert wd.get_version() == "0.1" wd_git.write("test.txt", "test2") wd.write("test.txt", "test2") - assert wd_git.version.startswith("0.2.dev0+g") - assert wd.version.startswith("0.2.dev0+g") + assert wd_git.get_version().startswith("0.2.dev0+g") + assert wd.get_version().startswith("0.2.dev0+g") wd_git.commit_testfile() wd("hg pull") wd("hg up -C") - assert wd_git.version.startswith("0.2.dev1+g") - assert wd.version.startswith("0.2.dev1+g") + assert wd_git.get_version().startswith("0.2.dev1+g") + assert wd.get_version().startswith("0.2.dev1+g") wd_git("git tag version-0.2") wd("hg pull -u") - assert wd_git.version.startswith("0.2") - assert wd.version.startswith("0.2") + assert wd_git.get_version().startswith("0.2") + assert wd.get_version().startswith("0.2") wd_git.commit_testfile() wd_git("git tag version-0.2.post210+gbe48adfpost3+g0cc25f2") @@ -69,15 +69,15 @@ def test_base(repositories_hg_git: tuple[WorkDir, WorkDir]) -> None: with pytest.warns( UserWarning, match="tag '.*' will be stripped of its suffix '.*'" ): - assert wd_git.version.startswith("0.2") + assert wd_git.get_version().startswith("0.2") with pytest.warns( UserWarning, match="tag '.*' will be stripped of its suffix '.*'" ): - assert wd.version.startswith("0.2") + assert wd.get_version().startswith("0.2") wd_git.commit_testfile() wd_git("git tag 17.33.0-rc") wd("hg pull -u") - assert wd_git.version == "17.33.0rc0" - assert wd.version == "17.33.0rc0" + assert wd_git.get_version() == "17.33.0rc0" + assert wd.get_version() == "17.33.0rc0" diff --git a/testing/test_integration.py b/testing/test_integration.py index cba0f98..c0594ad 100644 --- a/testing/test_integration.py +++ b/testing/test_integration.py @@ -91,7 +91,7 @@ def test_pyproject_support_with_git(wd: WorkDir, metadata_in: str) -> None: wd.write("setup.py", SETUP_PY_FILES[metadata_in]) wd.write("setup.cfg", SETUP_CFG_FILES[metadata_in]) res = wd([sys.executable, "setup.py", "--version"]) - assert res.endswith("0.1.dev0") + assert res.endswith("0.1.dev0+d20090213") def test_pretend_version(monkeypatch: pytest.MonkeyPatch, wd: WorkDir) -> None: @@ -152,7 +152,7 @@ def test_distribution_provides_extras() -> None: from importlib_metadata import distribution dist = distribution("setuptools_scm") - assert sorted(dist.metadata.get_all("Provides-Extra")) == ["test", "toml"] + assert sorted(dist.metadata.get_all("Provides-Extra")) == ["rich", "test", "toml"] @pytest.mark.issue(760) diff --git a/testing/test_mercurial.py b/testing/test_mercurial.py index 7de7ca2..1b35d11 100644 --- a/testing/test_mercurial.py +++ b/testing/test_mercurial.py @@ -7,9 +7,9 @@ import pytest import setuptools_scm._file_finders from setuptools_scm import Configuration +from setuptools_scm._run_cmd import has_command from setuptools_scm.hg import archival_to_version from setuptools_scm.hg import parse -from setuptools_scm.utils import has_command from setuptools_scm.version import format_version from testing.wd_wrapper import WorkDir @@ -76,31 +76,31 @@ def test_find_files_stop_at_root_hg( # XXX: better tests for tag prefixes def test_version_from_hg_id(wd: WorkDir) -> None: - assert wd.version == "0.0" + assert wd.get_version() == "0.0" wd.commit_testfile() - assert wd.version.startswith("0.1.dev1+") + assert wd.get_version().startswith("0.1.dev1+") # tagging commit is considered the tag wd('hg tag v0.1 -u test -d "0 0"') - assert wd.version == "0.1" + assert wd.get_version() == "0.1" wd.commit_testfile() - assert wd.version.startswith("0.2.dev2") + assert wd.get_version().startswith("0.2.dev2") wd("hg up v0.1") - assert wd.version == "0.1" + assert wd.get_version() == "0.1" # commit originating from the tagged revision - # that is not a actual tag + # that is not an actual tag wd.commit_testfile() - assert wd.version.startswith("0.2.dev1+") + assert wd.get_version().startswith("0.2.dev1+") # several tags wd("hg up") wd('hg tag v0.2 -u test -d "0 0"') wd('hg tag v0.3 -u test -d "0 0" -r v0.2') - assert wd.version == "0.3" + assert wd.get_version() == "0.3" def test_version_from_archival(wd: WorkDir) -> None: @@ -108,14 +108,14 @@ def test_version_from_archival(wd: WorkDir) -> None: # cleaning the wd ensure this test won't break randomly wd.cwd.joinpath(".hg").rename(wd.cwd / ".nothg") wd.write(".hg_archival.txt", "node: 000000000000\n" "tag: 0.1\n") - assert wd.version == "0.1" + assert wd.get_version() == "0.1" wd.write( ".hg_archival.txt", "node: 000000000000\n" "latesttag: 0.1\n" "latesttagdistance: 3\n", ) - assert wd.version == "0.2.dev3+h000000000000" + assert wd.get_version() == "0.2.dev3+h000000000000" @pytest.mark.issue("#72") @@ -125,7 +125,7 @@ def test_version_in_merge(wd: WorkDir) -> None: wd("hg up 0") wd.commit_testfile() wd("hg merge --tool :merge") - assert wd.version is not None + assert wd.get_version() is not None @pytest.mark.issue(128) @@ -157,14 +157,14 @@ def pre_merge_commit_after_tag(version_1_0: WorkDir) -> WorkDir: @pytest.mark.usefixtures("pre_merge_commit_after_tag") def test_version_bump_before_merge_commit(wd: WorkDir) -> None: - assert wd.version.startswith("1.0.1.dev1+") + assert wd.get_version().startswith("1.0.1.dev1+") @pytest.mark.issue(219) @pytest.mark.usefixtures("pre_merge_commit_after_tag") def test_version_bump_from_merge_commit(wd: WorkDir) -> None: wd.commit() - assert wd.version.startswith("1.0.1.dev3+") # issue 219 + assert wd.get_version().startswith("1.0.1.dev3+") # issue 219 @pytest.mark.usefixtures("version_1_0") @@ -174,9 +174,9 @@ def test_version_bump_from_commit_including_hgtag_mods(wd: WorkDir) -> None: tagfile.write(b"0 0\n") wd.write("branchfile", "branchtext") wd(wd.add_command) - assert wd.version.startswith("1.0.1.dev1+") # bump from dirty version + assert wd.get_version().startswith("1.0.1.dev1+") # bump from dirty version wd.commit() # commits both the testfile _and_ .hgtags - assert wd.version.startswith("1.0.1.dev2+") + assert wd.get_version().startswith("1.0.1.dev2+") @pytest.mark.issue(229) @@ -186,7 +186,7 @@ def test_latest_tag_detection(wd: WorkDir) -> None: Note that will be superseded by the fix for pypa/setuptools_scm/issues/235 """ wd('hg tag some-random-tag -u test -d "0 0"') - assert wd.version == "1.0.0" + assert wd.get_version() == "1.0.0" @pytest.mark.usefixtures("version_1_0") diff --git a/testing/test_regressions.py b/testing/test_regressions.py index f7b4b17..018f37e 100644 --- a/testing/test_regressions.py +++ b/testing/test_regressions.py @@ -1,6 +1,5 @@ from __future__ import annotations -import os import pprint import subprocess import sys @@ -16,7 +15,6 @@ from pathlib import Path import pytest from setuptools_scm import Configuration -from setuptools_scm import get_version from setuptools_scm.git import parse from setuptools_scm._run_cmd import run @@ -48,28 +46,7 @@ def test_pkginfo_noscmroot(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> N pass else: res = run([sys.executable, "setup.py", "--version"], p) - assert res.stdout == "0.1.dev0" - - -def test_pip_egg_info(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """if we are indeed a sdist, the root does not apply""" - - # we should get the version from pkg-info if git is broken - p = tmp_path.joinpath("sub/package") - p.mkdir(parents=True) - tmp_path.joinpath(".git").mkdir() - p.joinpath("setup.py").write_text( - "from setuptools import setup;" 'setup(use_scm_version={"root": ".."})' - ) - - with pytest.raises(LookupError): - get_version(root=os.fspath(p), fallback_root=os.fspath(p)) - - bad_egg_info = p.joinpath("pip-egg-info/random.egg-info/") - bad_egg_info.mkdir(parents=True) - - bad_egg_info.joinpath("PKG-INFO").write_text("Version: 1.0") - assert get_version(root=os.fspath(p), fallback_root=os.fspath(p)) == "1.0" + assert res.stdout == "0.1.dev0+d20090213" @pytest.mark.issue(164) diff --git a/testing/wd_wrapper.py b/testing/wd_wrapper.py index 1587f82..dd504ee 100644 --- a/testing/wd_wrapper.py +++ b/testing/wd_wrapper.py @@ -23,15 +23,12 @@ class WorkDir: if kw: assert isinstance(cmd, str), "formatting the command requires text input" cmd = cmd.format(**kw) - from setuptools_scm.utils import do + from setuptools_scm._run_cmd import run - return do(cmd, self.cwd) + return run(cmd, cwd=self.cwd).stdout - def write(self, name: str, content: str | bytes, **kw: object) -> Path: + def write(self, name: str, content: str | bytes) -> Path: path = self.cwd / name - if kw: - assert isinstance(content, str) - content = content.format(**kw) if isinstance(content, bytes): path.write_bytes(content) else: @@ -59,7 +56,7 @@ class WorkDir: def commit_testfile(self, reason: str | None = None, signed: bool = False) -> None: reason = self._reason(reason) - self.write("test.txt", "test {reason}", reason=reason) + self.write("test.txt", f"test {reason}") self(self.add_command) self.commit(reason=reason, signed=signed) @@ -68,10 +65,5 @@ class WorkDir: from setuptools_scm import get_version version = get_version(root=self.cwd, fallback_root=self.cwd, **kw) - print(version) + print(self.cwd.name, version, sep=": ") return version - - @property - def version(self) -> str: - __tracebackhide__ = True - return self.get_version() @@ -7,6 +7,8 @@ filterwarnings= error ignore:.*tool\.setuptools_scm.* ignore:.*git archive did not support describe output.*:UserWarning +log_level = debug +log_cli_level = info markers= issue(id): reference to github issue skip_commit: allows to skip committing in the helpers @@ -37,8 +39,8 @@ deps= check-manifest docutils pygments - setuptools>45 typing_extensions + hatchling commands= rst2html.py README.rst {envlogdir}/README.html --strict [] check-manifest --no-build-isolation |