Skip to content

Commit fe07f58

Browse files
committed
feat(cmd[git,hg,svn]) Surface timeout= on public Cmd.run signatures
why: ``timeout=`` was reachable only by spelunking through ``**kwargs`` into ``libvcs._internal.run.run()``; it did not appear in any public signature or docstring. That made the new deadline feature undiscoverable for downstream callers (e.g. vcspull) and easy to miss in IDE autocomplete. what: - Add explicit ``timeout: float | None = None`` keyword to ``Git.run``, ``Hg.run``, and ``Svn.run`` with NumPy "Parameters" entries that describe the SIGTERM/SIGKILL escalation and ``CommandTimeoutError`` contract. - Forward ``timeout`` to ``run()`` at each call site. - Cover the propagation in ``tests/cmd/test_{git,hg,svn}.py`` by mocking ``run`` and asserting the kwarg lands.
1 parent 1eaf889 commit fe07f58

6 files changed

Lines changed: 83 additions & 1 deletion

File tree

src/libvcs/cmd/git.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ def run(
146146
config_env: str | None = None,
147147
# Pass-through to run()
148148
log_in_real_time: bool = False,
149+
timeout: float | None = None,
149150
**kwargs: t.Any,
150151
) -> str:
151152
"""Run a command for this git repository.
@@ -205,6 +206,13 @@ def run(
205206
``--config=<name>=<value>``
206207
config_env :
207208
``--config-env=<name>=<envvar>``
209+
timeout : float, optional
210+
Wall-clock seconds to wait before terminating the subprocess.
211+
``None`` (default) preserves the legacy behaviour of blocking
212+
until the process exits. When the deadline is exceeded the
213+
process is sent ``SIGTERM`` (then ``SIGKILL`` after a grace
214+
period) and :class:`libvcs.exc.CommandTimeoutError` is raised
215+
with any output collected so far.
208216
209217
Examples
210218
--------
@@ -281,7 +289,7 @@ def stringify(v: t.Any) -> str:
281289
if self.progress_callback is not None:
282290
kwargs["callback"] = self.progress_callback
283291

284-
return run(args=cli_args, **kwargs)
292+
return run(args=cli_args, timeout=timeout, **kwargs)
285293

286294
def clone(
287295
self,

src/libvcs/cmd/hg.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ def run(
9898
pager: HgPagerType | None = None,
9999
color: HgColorType | None = None,
100100
check_returncode: bool | None = None,
101+
timeout: float | None = None,
101102
**kwargs: t.Any,
102103
) -> str:
103104
"""Run a command for this Mercurial repository.
@@ -149,6 +150,13 @@ def run(
149150
``--config CONFIG [+]``, ``section.name=value``
150151
check_returncode : bool, default: ``True``
151152
Passthrough to :func:`libvcs._internal.run.run()`
153+
timeout : float, optional
154+
Wall-clock seconds to wait before terminating the subprocess.
155+
``None`` (default) preserves the legacy behaviour of blocking
156+
until the process exits. When the deadline is exceeded the
157+
process is sent ``SIGTERM`` (then ``SIGKILL`` after a grace
158+
period) and :class:`libvcs.exc.CommandTimeoutError` is raised
159+
with any output collected so far.
152160
153161
Examples
154162
--------
@@ -194,6 +202,7 @@ def run(
194202
return run(
195203
args=cli_args,
196204
check_returncode=True if check_returncode is None else check_returncode,
205+
timeout=timeout,
197206
**kwargs,
198207
)
199208

src/libvcs/cmd/svn.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ def run(
8383
# Special behavior
8484
make_parents: bool | None = True,
8585
check_returncode: bool | None = None,
86+
timeout: float | None = None,
8687
**kwargs: t.Any,
8788
) -> str:
8889
"""Run a command for this SVN working copy.
@@ -119,6 +120,13 @@ def run(
119120
Creates checkout directory (`:attr:`self.path`) if it doesn't already exist.
120121
check_returncode : bool, default: ``None``
121122
Passthrough to :meth:`Svn.run`
123+
timeout : float, optional
124+
Wall-clock seconds to wait before terminating the subprocess.
125+
``None`` (default) preserves the legacy behaviour of blocking
126+
until the process exits. When the deadline is exceeded the
127+
process is sent ``SIGTERM`` (then ``SIGKILL`` after a grace
128+
period) and :class:`libvcs.exc.CommandTimeoutError` is raised
129+
with any output collected so far.
122130
123131
Examples
124132
--------
@@ -152,6 +160,7 @@ def run(
152160
return run(
153161
args=cli_args,
154162
check_returncode=True if check_returncode is None else check_returncode,
163+
timeout=timeout,
155164
**kwargs,
156165
)
157166

tests/cmd/test_git.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
from libvcs.cmd import git
1313

1414
if t.TYPE_CHECKING:
15+
from pytest_mock import MockerFixture
16+
1517
from libvcs.pytest_plugin import CreateRepoFn, GitCommitEnvVars
1618
from libvcs.sync.git import GitSync
1719

@@ -44,6 +46,24 @@ def test_git_run_accepts_scalar_string(tmp_path: pathlib.Path) -> None:
4446
assert result.startswith("git version ")
4547

4648

49+
def test_git_run_timeout_propagates_to_runner(
50+
tmp_path: pathlib.Path,
51+
mocker: MockerFixture,
52+
) -> None:
53+
"""``Git.run(timeout=X)`` forwards X to the underlying ``run()`` call.
54+
55+
Regression guard for the discoverability fix: ``timeout=`` is part of the
56+
public ``Git.run`` signature rather than reachable only via ``**kwargs``.
57+
"""
58+
repo = git.Git(path=tmp_path)
59+
mock_run = mocker.patch("libvcs.cmd.git.run", return_value="")
60+
61+
repo.run(["--version"], timeout=2.5)
62+
63+
_args, kwargs = mock_run.call_args
64+
assert kwargs.get("timeout") == 2.5
65+
66+
4767
def test_git_init_bare(tmp_path: pathlib.Path) -> None:
4868
"""Test git init with bare repository."""
4969
repo = git.Git(path=tmp_path)

tests/cmd/test_hg.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,15 @@
44

55
import pathlib
66
import shutil
7+
import typing as t
78

89
import pytest
910

1011
from libvcs.cmd.hg import Hg
1112

13+
if t.TYPE_CHECKING:
14+
from pytest_mock import MockerFixture
15+
1216
if not shutil.which("hg"):
1317
pytestmark = pytest.mark.skip(reason="hg is not available")
1418

@@ -20,3 +24,17 @@ def test_hg_run_accepts_scalar_string(tmp_path: pathlib.Path) -> None:
2024
result = repo.run("help")
2125

2226
assert "Mercurial Distributed SCM" in result
27+
28+
29+
def test_hg_run_timeout_propagates_to_runner(
30+
tmp_path: pathlib.Path,
31+
mocker: MockerFixture,
32+
) -> None:
33+
"""``Hg.run(timeout=X)`` forwards X to the underlying ``run()``."""
34+
repo = Hg(path=tmp_path)
35+
mock_run = mocker.patch("libvcs.cmd.hg.run", return_value="")
36+
37+
repo.run(["help"], timeout=2.5)
38+
39+
_args, kwargs = mock_run.call_args
40+
assert kwargs.get("timeout") == 2.5

tests/cmd/test_svn.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,15 @@
44

55
import pathlib
66
import shutil
7+
import typing as t
78

89
import pytest
910

1011
from libvcs.cmd.svn import Svn
1112

13+
if t.TYPE_CHECKING:
14+
from pytest_mock import MockerFixture
15+
1216
if not shutil.which("svn"):
1317
pytestmark = pytest.mark.skip(reason="svn is not available")
1418

@@ -20,3 +24,17 @@ def test_svn_run_accepts_scalar_string(tmp_path: pathlib.Path) -> None:
2024
result = repo.run("help")
2125

2226
assert "usage: svn <subcommand> [options] [args]" in result
27+
28+
29+
def test_svn_run_timeout_propagates_to_runner(
30+
tmp_path: pathlib.Path,
31+
mocker: MockerFixture,
32+
) -> None:
33+
"""``Svn.run(timeout=X)`` forwards X to the underlying ``run()``."""
34+
repo = Svn(path=tmp_path)
35+
mock_run = mocker.patch("libvcs.cmd.svn.run", return_value="")
36+
37+
repo.run(["help"], timeout=2.5)
38+
39+
_args, kwargs = mock_run.call_args
40+
assert kwargs.get("timeout") == 2.5

0 commit comments

Comments
 (0)