Skip to content

Commit 2ac718c

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 2ac718c

File tree

9 files changed

+142
-16
lines changed

9 files changed

+142
-16
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
### Added
2+
3+
- make Mercurial command configurable by environment variable `SETUPTOOLS_SCM_HG_COMMAND`
4+

docs/config.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,11 @@ 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+
149+
for example, set this to ``chg`` to reduce start-up overhead of Mercurial
150+
146151

147152

148153

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/conftest.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import contextlib
44
import os
5+
import shutil
56
import sys
67

78
from pathlib import Path
@@ -86,6 +87,14 @@ def wd(tmp_path: Path) -> WorkDir:
8687
return WorkDir(target_wd)
8788

8889

90+
@pytest.fixture(scope="session")
91+
def hg_exe() -> str:
92+
hg = shutil.which("hg")
93+
if hg is None:
94+
pytest.skip("hg executable not found")
95+
return hg
96+
97+
8998
@pytest.fixture
9099
def repositories_hg_git(tmp_path: Path) -> tuple[WorkDir, WorkDir]:
91100
tmp_path = tmp_path.resolve()

testing/test_file_finder.py

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

3+
import importlib
34
import os
45
import sys
56

@@ -8,6 +9,7 @@
89
import pytest
910

1011
from setuptools_scm._file_finders import find_files
12+
from setuptools_scm._file_finders import hg
1113

1214
from .wd_wrapper import WorkDir
1315

@@ -245,3 +247,34 @@ def test_archive(
245247
os.link("data/datafile", datalink)
246248

247249
assert set(find_files()) == _sep({archive_file, "data/datafile", "data/datalink"})
250+
251+
252+
@pytest.fixture
253+
def hg_wd(wd: WorkDir, monkeypatch: pytest.MonkeyPatch) -> WorkDir:
254+
try:
255+
wd("hg init")
256+
except OSError:
257+
pytest.skip("hg executable not found")
258+
(wd.cwd / "file").touch()
259+
wd("hg add file")
260+
monkeypatch.chdir(wd.cwd)
261+
return wd
262+
263+
264+
def test_hg_gone(hg_wd: WorkDir, monkeypatch: pytest.MonkeyPatch) -> None:
265+
monkeypatch.setenv("PATH", str(hg_wd.cwd / "not-existing"))
266+
assert set(find_files()) == set()
267+
268+
269+
def test_hg_command_from_env(
270+
hg_wd: WorkDir,
271+
monkeypatch: pytest.MonkeyPatch,
272+
request: pytest.FixtureRequest,
273+
hg_exe: str,
274+
) -> None:
275+
with monkeypatch.context() as m:
276+
m.setenv("SETUPTOOLS_SCM_HG_COMMAND", hg_exe)
277+
m.setenv("PATH", str(hg_wd.cwd / "not-existing"))
278+
request.addfinalizer(lambda: importlib.reload(hg))
279+
importlib.reload(hg)
280+
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,16 @@
11
from __future__ import annotations
22

3+
import importlib
4+
35
import pytest
46

7+
from setuptools_scm import Configuration
8+
from setuptools_scm import hg
9+
from setuptools_scm import hg_git
10+
from setuptools_scm._run_cmd import CommandNotFoundError
511
from setuptools_scm._run_cmd import has_command
612
from setuptools_scm._run_cmd import run
13+
from setuptools_scm.hg import parse
714
from testing.wd_wrapper import WorkDir
815

916

@@ -81,3 +88,34 @@ def test_base(repositories_hg_git: tuple[WorkDir, WorkDir]) -> None:
8188
wd("hg pull -u")
8289
assert wd_git.get_version() == "17.33.0rc0"
8390
assert wd.get_version() == "17.33.0rc0"
91+
92+
93+
def test_hg_gone(
94+
repositories_hg_git: tuple[WorkDir, WorkDir], monkeypatch: pytest.MonkeyPatch
95+
) -> None:
96+
wd = repositories_hg_git[0]
97+
monkeypatch.setenv("PATH", str(wd.cwd / "not-existing"))
98+
config = Configuration()
99+
wd.write("pyproject.toml", "[tool.setuptools_scm]")
100+
with pytest.raises(CommandNotFoundError, match=r"hg"):
101+
parse(wd.cwd, config=config)
102+
103+
assert wd.get_version(fallback_version="1.0") == "1.0"
104+
105+
106+
def test_hg_command_from_env(
107+
repositories_hg_git: tuple[WorkDir, WorkDir],
108+
monkeypatch: pytest.MonkeyPatch,
109+
request: pytest.FixtureRequest,
110+
hg_exe: str,
111+
) -> None:
112+
wd = repositories_hg_git[0]
113+
with monkeypatch.context() as m:
114+
m.setenv("SETUPTOOLS_SCM_HG_COMMAND", hg_exe)
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: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import importlib
34
import os
45

56
from pathlib import Path
@@ -9,6 +10,7 @@
910
import setuptools_scm._file_finders
1011

1112
from setuptools_scm import Configuration
13+
from setuptools_scm import hg
1214
from setuptools_scm._run_cmd import CommandNotFoundError
1315
from setuptools_scm._run_cmd import has_command
1416
from setuptools_scm.hg import archival_to_version
@@ -67,6 +69,36 @@ def test_hg_gone(wd: WorkDir, monkeypatch: pytest.MonkeyPatch) -> None:
6769
assert wd.get_version(fallback_version="1.0") == "1.0"
6870

6971

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

0 commit comments

Comments
 (0)