From f3be9f7211baf534a19fca354a0e384f25a4f77b Mon Sep 17 00:00:00 2001 From: Manuel Jacob Date: Wed, 11 Jun 2025 21:20:54 +0200 Subject: [PATCH 1/2] chore: pass list instead of string to _run() This makes it consistent with the other invocations in this file and makes it easier to parametrize the Mercurial command in the future. --- src/setuptools_scm/hg_git.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/setuptools_scm/hg_git.py b/src/setuptools_scm/hg_git.py index 6dc098fe..af669036 100644 --- a/src/setuptools_scm/hg_git.py +++ b/src/setuptools_scm/hg_git.py @@ -42,9 +42,9 @@ def get_branch(self) -> str | None: return res.stdout def get_head_date(self) -> date | None: - return _run('hg log -r . -T "{shortdate(date)}"', cwd=self.path).parse_success( - parse=date.fromisoformat, error_msg="head date err" - ) + 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 @@ -53,7 +53,7 @@ def fetch_shallow(self) -> None: pass def get_hg_node(self) -> str | None: - res = _run('hg log -r . -T "{node}"', cwd=self.path) + res = _run(["hg", "log", "-r", ".", "-T", "{node}"], cwd=self.path) if res.returncode: return None else: From 2ac718c9f3b91a2058470d95e953c863e1b37702 Mon Sep 17 00:00:00 2001 From: Manuel Jacob Date: Wed, 11 Jun 2025 21:50:03 +0200 Subject: [PATCH 2/2] Make Mercurial command configurable by an environment variable. This is useful when e.g. developing Mercurial or Mercurial extensions. Previously, the first ``hg`` binary in PATH was used. If the Mercurial in the current virtual environment was broken, it was impossible to install anything that uses setuptools-scm to determine a version from Mercurial. With this change, it is possible to set the SETUPTOOLS_SCM_HG_COMMAND environment variable to the standard system-wide Mercurial executable. Also, it makes it possible to make setuptools-scm use chg, a variant of Mercurial that uses a daemon to save start-up overhead. Using it, the time of running ``uv pip install`` of a small-to-medium-size package decreased from 8.826s to 2.965s (a 3x reduction). If the environment variable is not set, the behavior remains unchanged. --- changelog.d/20250612_144312_me_hg_command.md | 4 +++ docs/config.md | 5 +++ src/setuptools_scm/_file_finders/hg.py | 6 ++-- src/setuptools_scm/hg.py | 12 ++++--- src/setuptools_scm/hg_git.py | 19 +++++----- testing/conftest.py | 9 +++++ testing/test_file_finder.py | 33 +++++++++++++++++ testing/test_hg_git.py | 38 ++++++++++++++++++++ testing/test_mercurial.py | 32 +++++++++++++++++ 9 files changed, 142 insertions(+), 16 deletions(-) create mode 100644 changelog.d/20250612_144312_me_hg_command.md diff --git a/changelog.d/20250612_144312_me_hg_command.md b/changelog.d/20250612_144312_me_hg_command.md new file mode 100644 index 00000000..ff109f13 --- /dev/null +++ b/changelog.d/20250612_144312_me_hg_command.md @@ -0,0 +1,4 @@ +### Added + +- make Mercurial command configurable by environment variable `SETUPTOOLS_SCM_HG_COMMAND` + diff --git a/docs/config.md b/docs/config.md index b30fce86..fbeef5c4 100644 --- a/docs/config.md +++ b/docs/config.md @@ -143,6 +143,11 @@ Callables or other Python objects have to be passed in `setup.py` (via the `use_ : a ``os.pathsep`` separated list of directory names to ignore for root finding +`SETUPTOOLS_SCM_HG_COMMAND` +: command used for running Mercurial (defaults to ``hg``) + + for example, set this to ``chg`` to reduce start-up overhead of Mercurial + diff --git a/src/setuptools_scm/_file_finders/hg.py b/src/setuptools_scm/_file_finders/hg.py index 9115a5fa..4fc3a1ee 100644 --- a/src/setuptools_scm/_file_finders/hg.py +++ b/src/setuptools_scm/_file_finders/hg.py @@ -13,11 +13,13 @@ log = logging.getLogger(__name__) +HG_COMMAND = os.environ.get("SETUPTOOLS_SCM_HG_COMMAND", "hg") + def _hg_toplevel(path: str) -> str | None: try: return _run( - ["hg", "root"], + [HG_COMMAND, "root"], cwd=(path or "."), check=True, ).parse_success(norm_real) @@ -32,7 +34,7 @@ def _hg_toplevel(path: str) -> str | None: def _hg_ls_files_and_dirs(toplevel: str) -> tuple[set[str], set[str]]: hg_files: set[str] = set() hg_dirs = {toplevel} - res = _run(["hg", "files"], cwd=toplevel) + res = _run([HG_COMMAND, "files"], cwd=toplevel) if res.returncode: return set(), set() for name in res.stdout.splitlines(): diff --git a/src/setuptools_scm/hg.py b/src/setuptools_scm/hg.py index d8307c78..f1c1560d 100644 --- a/src/setuptools_scm/hg.py +++ b/src/setuptools_scm/hg.py @@ -23,11 +23,13 @@ log = logging.getLogger(__name__) +HG_COMMAND = os.environ.get("SETUPTOOLS_SCM_HG_COMMAND", "hg") + class HgWorkdir(Workdir): @classmethod def from_potential_worktree(cls, wd: _t.PathT) -> HgWorkdir | None: - res = _run(["hg", "root"], wd) + res = _run([HG_COMMAND, "root"], wd) if res.returncode: return None return cls(Path(res.stdout)) @@ -45,7 +47,7 @@ def get_meta(self, config: Configuration) -> ScmVersion | None: # the dedicated class GitWorkdirHgClient) branch, dirty_str, dirty_date = _run( - ["hg", "id", "-T", "{branch}\n{if(dirty, 1, 0)}\n{date|shortdate}"], + [HG_COMMAND, "id", "-T", "{branch}\n{if(dirty, 1, 0)}\n{date|shortdate}"], cwd=self.path, check=True, ).stdout.split("\n") @@ -108,7 +110,7 @@ def get_meta(self, config: Configuration) -> ScmVersion | None: return None def hg_log(self, revset: str, template: str) -> str: - cmd = ["hg", "log", "-r", revset, "-T", template] + cmd = [HG_COMMAND, "log", "-r", revset, "-T", template] return _run(cmd, cwd=self.path, check=True).stdout @@ -144,9 +146,9 @@ def check_changes_since_tag(self, tag: str | None) -> bool: def parse(root: _t.PathT, config: Configuration) -> ScmVersion | None: - _require_command("hg") + _require_command(HG_COMMAND) if os.path.exists(os.path.join(root, ".hg/git")): - res = _run(["hg", "path"], root) + res = _run([HG_COMMAND, "path"], root) if not res.returncode: for line in res.stdout.split("\n"): if line.startswith("default ="): diff --git a/src/setuptools_scm/hg_git.py b/src/setuptools_scm/hg_git.py index af669036..f2336e3c 100644 --- a/src/setuptools_scm/hg_git.py +++ b/src/setuptools_scm/hg_git.py @@ -11,6 +11,7 @@ from ._run_cmd import CompletedProcess as _CompletedProcess from ._run_cmd import run as _run from .git import GitWorkdir +from .hg import HG_COMMAND from .hg import HgWorkdir log = logging.getLogger(__name__) @@ -25,17 +26,17 @@ class GitWorkdirHgClient(GitWorkdir, HgWorkdir): @classmethod def from_potential_worktree(cls, wd: _t.PathT) -> GitWorkdirHgClient | None: - res = _run(["hg", "root"], cwd=wd).parse_success(parse=Path) + res = _run([HG_COMMAND, "root"], cwd=wd).parse_success(parse=Path) if res is None: return None return cls(res) def is_dirty(self) -> bool: - res = _run(["hg", "id", "-T", "{dirty}"], cwd=self.path, check=True) + res = _run([HG_COMMAND, "id", "-T", "{dirty}"], cwd=self.path, check=True) return bool(res.stdout) def get_branch(self) -> str | None: - res = _run(["hg", "id", "-T", "{bookmarks}"], cwd=self.path) + res = _run([HG_COMMAND, "id", "-T", "{bookmarks}"], cwd=self.path) if res.returncode: log.info("branch err %s", res) return None @@ -43,7 +44,7 @@ def get_branch(self) -> str | None: def get_head_date(self) -> date | None: return _run( - ["hg", "log", "-r", ".", "-T", "{shortdate(date)}"], cwd=self.path + [HG_COMMAND, "log", "-r", ".", "-T", "{shortdate(date)}"], cwd=self.path ).parse_success(parse=date.fromisoformat, error_msg="head date err") def is_shallow(self) -> bool: @@ -53,7 +54,7 @@ def fetch_shallow(self) -> None: pass def get_hg_node(self) -> str | None: - res = _run(["hg", "log", "-r", ".", "-T", "{node}"], cwd=self.path) + res = _run([HG_COMMAND, "log", "-r", ".", "-T", "{node}"], cwd=self.path) if res.returncode: return None else: @@ -77,7 +78,7 @@ def node(self) -> str | None: if git_node is None: # trying again after hg -> git - _run(["hg", "gexport"], cwd=self.path) + _run([HG_COMMAND, "gexport"], cwd=self.path) git_node = self._hg2git(hg_node) if git_node is None: @@ -92,7 +93,7 @@ def node(self) -> str | None: return git_node[:7] def count_all_nodes(self) -> int: - res = _run(["hg", "log", "-r", "ancestors(.)", "-T", "."], cwd=self.path) + res = _run([HG_COMMAND, "log", "-r", "ancestors(.)", "-T", "."], cwd=self.path) return len(res.stdout) def default_describe(self) -> _CompletedProcess: @@ -104,7 +105,7 @@ def default_describe(self) -> _CompletedProcess: """ res = _run( [ - "hg", + HG_COMMAND, "log", "-r", "(reverse(ancestors(.)) and tag(r're:v?[0-9].*'))", @@ -132,7 +133,7 @@ def default_describe(self) -> _CompletedProcess: logging.warning("tag not found hg=%s git=%s", hg_tags, git_tags) return _FAKE_GIT_DESCRIBE_ERROR - res = _run(["hg", "log", "-r", f"'{tag}'::.", "-T", "."], cwd=self.path) + res = _run([HG_COMMAND, "log", "-r", f"'{tag}'::.", "-T", "."], cwd=self.path) if res.returncode: return _FAKE_GIT_DESCRIBE_ERROR distance = len(res.stdout) - 1 diff --git a/testing/conftest.py b/testing/conftest.py index 09b69c1a..ec936f7c 100644 --- a/testing/conftest.py +++ b/testing/conftest.py @@ -2,6 +2,7 @@ import contextlib import os +import shutil import sys from pathlib import Path @@ -86,6 +87,14 @@ def wd(tmp_path: Path) -> WorkDir: return WorkDir(target_wd) +@pytest.fixture(scope="session") +def hg_exe() -> str: + hg = shutil.which("hg") + if hg is None: + pytest.skip("hg executable not found") + return hg + + @pytest.fixture def repositories_hg_git(tmp_path: Path) -> tuple[WorkDir, WorkDir]: tmp_path = tmp_path.resolve() diff --git a/testing/test_file_finder.py b/testing/test_file_finder.py index 5902d8e5..9daf9b04 100644 --- a/testing/test_file_finder.py +++ b/testing/test_file_finder.py @@ -1,5 +1,6 @@ from __future__ import annotations +import importlib import os import sys @@ -8,6 +9,7 @@ import pytest from setuptools_scm._file_finders import find_files +from setuptools_scm._file_finders import hg from .wd_wrapper import WorkDir @@ -245,3 +247,34 @@ def test_archive( os.link("data/datafile", datalink) assert set(find_files()) == _sep({archive_file, "data/datafile", "data/datalink"}) + + +@pytest.fixture +def hg_wd(wd: WorkDir, monkeypatch: pytest.MonkeyPatch) -> WorkDir: + try: + wd("hg init") + except OSError: + pytest.skip("hg executable not found") + (wd.cwd / "file").touch() + wd("hg add file") + monkeypatch.chdir(wd.cwd) + return wd + + +def test_hg_gone(hg_wd: WorkDir, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PATH", str(hg_wd.cwd / "not-existing")) + assert set(find_files()) == set() + + +def test_hg_command_from_env( + hg_wd: WorkDir, + monkeypatch: pytest.MonkeyPatch, + request: pytest.FixtureRequest, + hg_exe: str, +) -> None: + with monkeypatch.context() as m: + m.setenv("SETUPTOOLS_SCM_HG_COMMAND", hg_exe) + m.setenv("PATH", str(hg_wd.cwd / "not-existing")) + request.addfinalizer(lambda: importlib.reload(hg)) + importlib.reload(hg) + assert set(find_files()) == {"file"} diff --git a/testing/test_hg_git.py b/testing/test_hg_git.py index 9527cb02..f2a9539f 100644 --- a/testing/test_hg_git.py +++ b/testing/test_hg_git.py @@ -1,9 +1,16 @@ from __future__ import annotations +import importlib + import pytest +from setuptools_scm import Configuration +from setuptools_scm import hg +from setuptools_scm import hg_git +from setuptools_scm._run_cmd import CommandNotFoundError from setuptools_scm._run_cmd import has_command from setuptools_scm._run_cmd import run +from setuptools_scm.hg import parse from testing.wd_wrapper import WorkDir @@ -81,3 +88,34 @@ def test_base(repositories_hg_git: tuple[WorkDir, WorkDir]) -> None: wd("hg pull -u") assert wd_git.get_version() == "17.33.0rc0" assert wd.get_version() == "17.33.0rc0" + + +def test_hg_gone( + repositories_hg_git: tuple[WorkDir, WorkDir], monkeypatch: pytest.MonkeyPatch +) -> None: + wd = repositories_hg_git[0] + monkeypatch.setenv("PATH", str(wd.cwd / "not-existing")) + config = Configuration() + wd.write("pyproject.toml", "[tool.setuptools_scm]") + with pytest.raises(CommandNotFoundError, match=r"hg"): + parse(wd.cwd, config=config) + + assert wd.get_version(fallback_version="1.0") == "1.0" + + +def test_hg_command_from_env( + repositories_hg_git: tuple[WorkDir, WorkDir], + monkeypatch: pytest.MonkeyPatch, + request: pytest.FixtureRequest, + hg_exe: str, +) -> None: + wd = repositories_hg_git[0] + with monkeypatch.context() as m: + m.setenv("SETUPTOOLS_SCM_HG_COMMAND", hg_exe) + m.setenv("PATH", str(wd.cwd / "not-existing")) + request.addfinalizer(lambda: importlib.reload(hg)) + request.addfinalizer(lambda: importlib.reload(hg_git)) + importlib.reload(hg) + importlib.reload(hg_git) + wd.write("pyproject.toml", "[tool.setuptools_scm]") + assert wd.get_version().startswith("0.1.dev0+") diff --git a/testing/test_mercurial.py b/testing/test_mercurial.py index 57073716..6c2a137c 100644 --- a/testing/test_mercurial.py +++ b/testing/test_mercurial.py @@ -1,5 +1,6 @@ from __future__ import annotations +import importlib import os from pathlib import Path @@ -9,6 +10,7 @@ import setuptools_scm._file_finders from setuptools_scm import Configuration +from setuptools_scm import hg from setuptools_scm._run_cmd import CommandNotFoundError from setuptools_scm._run_cmd import has_command from setuptools_scm.hg import archival_to_version @@ -67,6 +69,36 @@ def test_hg_gone(wd: WorkDir, monkeypatch: pytest.MonkeyPatch) -> None: assert wd.get_version(fallback_version="1.0") == "1.0" +def test_hg_command_from_env( + wd: WorkDir, + monkeypatch: pytest.MonkeyPatch, + request: pytest.FixtureRequest, + hg_exe: str, +) -> None: + with monkeypatch.context() as m: + m.setenv("SETUPTOOLS_SCM_HG_COMMAND", hg_exe) + m.setenv("PATH", str(wd.cwd / "not-existing")) + request.addfinalizer(lambda: importlib.reload(hg)) + importlib.reload(hg) + wd.write("pyproject.toml", "[tool.setuptools_scm]") + assert wd.get_version() == "0.0" + + +def test_hg_command_from_env_is_invalid( + wd: WorkDir, monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest +) -> None: + with monkeypatch.context() as m: + m.setenv("SETUPTOOLS_SCM_HG_COMMAND", str(wd.cwd / "not-existing")) + request.addfinalizer(lambda: importlib.reload(hg)) + importlib.reload(hg) + config = Configuration() + wd.write("pyproject.toml", "[tool.setuptools_scm]") + with pytest.raises(CommandNotFoundError, match=r"hg"): + parse(wd.cwd, config=config) + + assert wd.get_version(fallback_version="1.0") == "1.0" + + def test_find_files_stop_at_root_hg( wd: WorkDir, monkeypatch: pytest.MonkeyPatch ) -> None: