Skip to content

Commit 881801b

Browse files
committed
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.
1 parent f3be9f7 commit 881801b

File tree

7 files changed

+120
-16
lines changed

7 files changed

+120
-16
lines changed

docs/config.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,9 @@ Callables or other Python objects have to be passed in `setup.py` (via the `use_
143143
: a ``os.pathsep`` separated list
144144
of directory names to ignore for root finding
145145

146+
`SETUPTOOLS_SCM_HG_COMMAND`
147+
: command used for running Mercurial (defaults to ``hg``)
148+
146149

147150

148151

src/setuptools_scm/_file_finders/hg.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@
1313

1414
log = logging.getLogger(__name__)
1515

16+
HG_COMMAND = os.environ.get("SETUPTOOLS_SCM_HG_COMMAND", "hg")
17+
1618

1719
def _hg_toplevel(path: str) -> str | None:
1820
try:
1921
return _run(
20-
["hg", "root"],
22+
[HG_COMMAND, "root"],
2123
cwd=(path or "."),
2224
check=True,
2325
).parse_success(norm_real)
@@ -32,7 +34,7 @@ def _hg_toplevel(path: str) -> str | None:
3234
def _hg_ls_files_and_dirs(toplevel: str) -> tuple[set[str], set[str]]:
3335
hg_files: set[str] = set()
3436
hg_dirs = {toplevel}
35-
res = _run(["hg", "files"], cwd=toplevel)
37+
res = _run([HG_COMMAND, "files"], cwd=toplevel)
3638
if res.returncode:
3739
return set(), set()
3840
for name in res.stdout.splitlines():

src/setuptools_scm/hg.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@
2323

2424
log = logging.getLogger(__name__)
2525

26+
HG_COMMAND = os.environ.get("SETUPTOOLS_SCM_HG_COMMAND", "hg")
27+
2628

2729
class HgWorkdir(Workdir):
2830
@classmethod
2931
def from_potential_worktree(cls, wd: _t.PathT) -> HgWorkdir | None:
30-
res = _run(["hg", "root"], wd)
32+
res = _run([HG_COMMAND, "root"], wd)
3133
if res.returncode:
3234
return None
3335
return cls(Path(res.stdout))
@@ -45,7 +47,7 @@ def get_meta(self, config: Configuration) -> ScmVersion | None:
4547
# the dedicated class GitWorkdirHgClient)
4648

4749
branch, dirty_str, dirty_date = _run(
48-
["hg", "id", "-T", "{branch}\n{if(dirty, 1, 0)}\n{date|shortdate}"],
50+
[HG_COMMAND, "id", "-T", "{branch}\n{if(dirty, 1, 0)}\n{date|shortdate}"],
4951
cwd=self.path,
5052
check=True,
5153
).stdout.split("\n")
@@ -108,7 +110,7 @@ def get_meta(self, config: Configuration) -> ScmVersion | None:
108110
return None
109111

110112
def hg_log(self, revset: str, template: str) -> str:
111-
cmd = ["hg", "log", "-r", revset, "-T", template]
113+
cmd = [HG_COMMAND, "log", "-r", revset, "-T", template]
112114

113115
return _run(cmd, cwd=self.path, check=True).stdout
114116

@@ -144,9 +146,9 @@ def check_changes_since_tag(self, tag: str | None) -> bool:
144146

145147

146148
def parse(root: _t.PathT, config: Configuration) -> ScmVersion | None:
147-
_require_command("hg")
149+
_require_command(HG_COMMAND)
148150
if os.path.exists(os.path.join(root, ".hg/git")):
149-
res = _run(["hg", "path"], root)
151+
res = _run([HG_COMMAND, "path"], root)
150152
if not res.returncode:
151153
for line in res.stdout.split("\n"):
152154
if line.startswith("default ="):

src/setuptools_scm/hg_git.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from ._run_cmd import CompletedProcess as _CompletedProcess
1212
from ._run_cmd import run as _run
1313
from .git import GitWorkdir
14+
from .hg import HG_COMMAND
1415
from .hg import HgWorkdir
1516

1617
log = logging.getLogger(__name__)
@@ -25,25 +26,25 @@
2526
class GitWorkdirHgClient(GitWorkdir, HgWorkdir):
2627
@classmethod
2728
def from_potential_worktree(cls, wd: _t.PathT) -> GitWorkdirHgClient | None:
28-
res = _run(["hg", "root"], cwd=wd).parse_success(parse=Path)
29+
res = _run([HG_COMMAND, "root"], cwd=wd).parse_success(parse=Path)
2930
if res is None:
3031
return None
3132
return cls(res)
3233

3334
def is_dirty(self) -> bool:
34-
res = _run(["hg", "id", "-T", "{dirty}"], cwd=self.path, check=True)
35+
res = _run([HG_COMMAND, "id", "-T", "{dirty}"], cwd=self.path, check=True)
3536
return bool(res.stdout)
3637

3738
def get_branch(self) -> str | None:
38-
res = _run(["hg", "id", "-T", "{bookmarks}"], cwd=self.path)
39+
res = _run([HG_COMMAND, "id", "-T", "{bookmarks}"], cwd=self.path)
3940
if res.returncode:
4041
log.info("branch err %s", res)
4142
return None
4243
return res.stdout
4344

4445
def get_head_date(self) -> date | None:
4546
return _run(
46-
["hg", "log", "-r", ".", "-T", "{shortdate(date)}"], cwd=self.path
47+
[HG_COMMAND, "log", "-r", ".", "-T", "{shortdate(date)}"], cwd=self.path
4748
).parse_success(parse=date.fromisoformat, error_msg="head date err")
4849

4950
def is_shallow(self) -> bool:
@@ -53,7 +54,7 @@ def fetch_shallow(self) -> None:
5354
pass
5455

5556
def get_hg_node(self) -> str | None:
56-
res = _run(["hg", "log", "-r", ".", "-T", "{node}"], cwd=self.path)
57+
res = _run([HG_COMMAND, "log", "-r", ".", "-T", "{node}"], cwd=self.path)
5758
if res.returncode:
5859
return None
5960
else:
@@ -77,7 +78,7 @@ def node(self) -> str | None:
7778

7879
if git_node is None:
7980
# trying again after hg -> git
80-
_run(["hg", "gexport"], cwd=self.path)
81+
_run([HG_COMMAND, "gexport"], cwd=self.path)
8182
git_node = self._hg2git(hg_node)
8283

8384
if git_node is None:
@@ -92,7 +93,7 @@ def node(self) -> str | None:
9293
return git_node[:7]
9394

9495
def count_all_nodes(self) -> int:
95-
res = _run(["hg", "log", "-r", "ancestors(.)", "-T", "."], cwd=self.path)
96+
res = _run([HG_COMMAND, "log", "-r", "ancestors(.)", "-T", "."], cwd=self.path)
9697
return len(res.stdout)
9798

9899
def default_describe(self) -> _CompletedProcess:
@@ -104,7 +105,7 @@ def default_describe(self) -> _CompletedProcess:
104105
"""
105106
res = _run(
106107
[
107-
"hg",
108+
HG_COMMAND,
108109
"log",
109110
"-r",
110111
"(reverse(ancestors(.)) and tag(r're:v?[0-9].*'))",
@@ -132,7 +133,7 @@ def default_describe(self) -> _CompletedProcess:
132133
logging.warning("tag not found hg=%s git=%s", hg_tags, git_tags)
133134
return _FAKE_GIT_DESCRIBE_ERROR
134135

135-
res = _run(["hg", "log", "-r", f"'{tag}'::.", "-T", "."], cwd=self.path)
136+
res = _run([HG_COMMAND, "log", "-r", f"'{tag}'::.", "-T", "."], cwd=self.path)
136137
if res.returncode:
137138
return _FAKE_GIT_DESCRIBE_ERROR
138139
distance = len(res.stdout) - 1

testing/test_file_finder.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
from __future__ import annotations
22

3+
import importlib
34
import os
5+
import shutil
46
import sys
57

68
from typing import Iterable
79

810
import pytest
911

1012
from setuptools_scm._file_finders import find_files
13+
from setuptools_scm._file_finders import hg
1114

1215
from .wd_wrapper import WorkDir
1316

@@ -245,3 +248,28 @@ def test_archive(
245248
os.link("data/datafile", datalink)
246249

247250
assert set(find_files()) == _sep({archive_file, "data/datafile", "data/datalink"})
251+
252+
253+
@pytest.fixture
254+
def hg_wd(wd: WorkDir, monkeypatch):
255+
wd("hg init")
256+
(wd.cwd / "file").touch()
257+
wd("hg add file")
258+
monkeypatch.chdir(wd.cwd)
259+
return wd
260+
261+
262+
def test_hg_gone(hg_wd: WorkDir, monkeypatch: pytest.MonkeyPatch) -> None:
263+
monkeypatch.setenv("PATH", str(hg_wd.cwd / "not-existing"))
264+
assert set(find_files()) == set()
265+
266+
267+
def test_hg_command_from_env(
268+
hg_wd: WorkDir, monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest
269+
) -> None:
270+
with monkeypatch.context() as m:
271+
m.setenv("SETUPTOOLS_SCM_HG_COMMAND", shutil.which("hg"))
272+
m.setenv("PATH", str(hg_wd.cwd / "not-existing"))
273+
request.addfinalizer(lambda: importlib.reload(hg))
274+
importlib.reload(hg)
275+
assert set(find_files()) == {"file"}

testing/test_hg_git.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
from __future__ import annotations
22

3+
import importlib
4+
import shutil
5+
36
import pytest
47

8+
from setuptools_scm import Configuration
9+
from setuptools_scm import hg
10+
from setuptools_scm import hg_git
11+
from setuptools_scm._run_cmd import CommandNotFoundError
512
from setuptools_scm._run_cmd import has_command
613
from setuptools_scm._run_cmd import run
14+
from setuptools_scm.hg import parse
715
from testing.wd_wrapper import WorkDir
816

917

@@ -81,3 +89,33 @@ def test_base(repositories_hg_git: tuple[WorkDir, WorkDir]) -> None:
8189
wd("hg pull -u")
8290
assert wd_git.get_version() == "17.33.0rc0"
8391
assert wd.get_version() == "17.33.0rc0"
92+
93+
94+
def test_hg_gone(
95+
repositories_hg_git: tuple[WorkDir, WorkDir], monkeypatch: pytest.MonkeyPatch
96+
) -> None:
97+
wd = repositories_hg_git[0]
98+
monkeypatch.setenv("PATH", str(wd.cwd / "not-existing"))
99+
config = Configuration()
100+
wd.write("pyproject.toml", "[tool.setuptools_scm]")
101+
with pytest.raises(CommandNotFoundError, match=r"hg"):
102+
parse(wd.cwd, config=config)
103+
104+
assert wd.get_version(fallback_version="1.0") == "1.0"
105+
106+
107+
def test_hg_command_from_env(
108+
repositories_hg_git: tuple[WorkDir, WorkDir],
109+
monkeypatch: pytest.MonkeyPatch,
110+
request: pytest.FixtureRequest,
111+
) -> None:
112+
wd = repositories_hg_git[0]
113+
with monkeypatch.context() as m:
114+
m.setenv("SETUPTOOLS_SCM_HG_COMMAND", shutil.which("hg"))
115+
m.setenv("PATH", str(wd.cwd / "not-existing"))
116+
request.addfinalizer(lambda: importlib.reload(hg))
117+
request.addfinalizer(lambda: importlib.reload(hg_git))
118+
importlib.reload(hg)
119+
importlib.reload(hg_git)
120+
wd.write("pyproject.toml", "[tool.setuptools_scm]")
121+
assert wd.get_version().startswith("0.1.dev0+")

testing/test_mercurial.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from __future__ import annotations
22

3+
import importlib
34
import os
5+
import shutil
46

57
from pathlib import Path
68

@@ -9,6 +11,7 @@
911
import setuptools_scm._file_finders
1012

1113
from setuptools_scm import Configuration
14+
from setuptools_scm import hg
1215
from setuptools_scm._run_cmd import CommandNotFoundError
1316
from setuptools_scm._run_cmd import has_command
1417
from setuptools_scm.hg import archival_to_version
@@ -67,6 +70,33 @@ def test_hg_gone(wd: WorkDir, monkeypatch: pytest.MonkeyPatch) -> None:
6770
assert wd.get_version(fallback_version="1.0") == "1.0"
6871

6972

73+
def test_hg_command_from_env(
74+
wd: WorkDir, monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest
75+
) -> None:
76+
with monkeypatch.context() as m:
77+
m.setenv("SETUPTOOLS_SCM_HG_COMMAND", shutil.which("hg"))
78+
m.setenv("PATH", str(wd.cwd / "not-existing"))
79+
request.addfinalizer(lambda: importlib.reload(hg))
80+
importlib.reload(hg)
81+
wd.write("pyproject.toml", "[tool.setuptools_scm]")
82+
assert wd.get_version() == "0.0"
83+
84+
85+
def test_hg_command_from_env_is_invalid(
86+
wd: WorkDir, monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest
87+
) -> None:
88+
with monkeypatch.context() as m:
89+
m.setenv("SETUPTOOLS_SCM_HG_COMMAND", str(wd.cwd / "not-existing"))
90+
request.addfinalizer(lambda: importlib.reload(hg))
91+
importlib.reload(hg)
92+
config = Configuration()
93+
wd.write("pyproject.toml", "[tool.setuptools_scm]")
94+
with pytest.raises(CommandNotFoundError, match=r"hg"):
95+
parse(wd.cwd, config=config)
96+
97+
assert wd.get_version(fallback_version="1.0") == "1.0"
98+
99+
70100
def test_find_files_stop_at_root_hg(
71101
wd: WorkDir, monkeypatch: pytest.MonkeyPatch
72102
) -> None:

0 commit comments

Comments
 (0)