diff --git a/docs/cmd/index.md b/docs/cmd/index.md index 4e495cb08..32fad657d 100644 --- a/docs/cmd/index.md +++ b/docs/cmd/index.md @@ -20,3 +20,58 @@ git hg svn ``` + +## Controlling commands + +### Override `run()` + +You want to control `stdout`, `stderr`, terminal output, tee'ing or logging, introspect and modify +the commands themselves. libvcs is designed to make this trivial to control. + +- Git -> `Git.` -> `Git.run` -> `run` + +You override `Git.run` method, and all `Git` commands can be intercepted. + +```python + +class MyGit(Git): + def run(self, *args, **kwargs): + return ... +``` + +You can also pass-through using `super()` + +```python + +class MyGit(Git): + def run(self, *args, **kwargs): + return super().run(*args, **kwargs) +``` + +Two possibilities: + +1. Modify args / kwargs before running them +2. Replace `run()` with a different subprocess runner + +### `LazySubprocessMixin` + +```python + +class MyGit(Git, LazySubprocessMixin): + def run(self, *args, **kwargs): + return ... +``` + +You can introspect it here. + +Instead of `git.run(...)` you'd do `git.run(...).run()`. + +Also, you can introspect and modify the output before execution + +```python +>>> mycmd = git.run(...) +>>> mycmd.flags +... +>>> mycmd.flags = '--help' +>>> mycmd.run() +``` diff --git a/libvcs/projects/git.py b/libvcs/projects/git.py index 775ed3f86..edf843226 100644 --- a/libvcs/projects/git.py +++ b/libvcs/projects/git.py @@ -22,6 +22,7 @@ from typing import Dict, Literal, Optional, TypedDict, Union from urllib import parse as urlparse +from libvcs.cmd.git import Git from libvcs.types import StrPath from .. import exc @@ -313,24 +314,26 @@ def set_remotes(self, overwrite: bool = False): def obtain(self, *args, **kwargs): """Retrieve the repository, clone if doesn't exist.""" - self.ensure_dir() - - url = self.url + clone_kwargs = {} - cmd = ["clone", "--progress"] if self.git_shallow: - cmd.extend(["--depth", "1"]) + clone_kwargs["depth"] = 1 if self.tls_verify: - cmd.extend(["-c", "http.sslVerify=false"]) - cmd.extend([url, self.dir]) + clone_kwargs["c"] = "http.sslVerify=false" self.log.info("Cloning.") - self.run(cmd, log_in_real_time=True) + + git = Git(dir=self.dir) + + # Needs to log to std out, e.g. log_in_real_time + git.clone(url=self.url, progress=True, make_parents=True, **clone_kwargs) self.log.info("Initializing submodules.") + self.run(["submodule", "init"], log_in_real_time=True) - cmd = ["submodule", "update", "--recursive", "--init"] - self.run(cmd, log_in_real_time=True) + self.run( + ["submodule", "update", "--recursive", "--init"], log_in_real_time=True + ) self.set_remotes(overwrite=True) diff --git a/tests/_internal/subprocess/test_SubprocessCommand.py b/tests/_internal/subprocess/test_SubprocessCommand.py index 493f67efa..bf2a13483 100644 --- a/tests/_internal/subprocess/test_SubprocessCommand.py +++ b/tests/_internal/subprocess/test_SubprocessCommand.py @@ -1,6 +1,9 @@ import pathlib import subprocess +import sys +import textwrap from typing import Any +from unittest import mock import pytest @@ -137,3 +140,161 @@ def test_run(tmp_path: pathlib.Path, args: list, kwargs: dict, run_kwargs: dict) response = cmd.run(**run_kwargs) assert response.returncode == 0 + + +@pytest.mark.parametrize( + "args,kwargs,run_kwargs", + [ + [ + ["ls"], + {}, + {}, + ], + [[["ls", "-l"]], {}, {}], + [[["ls", "-al"]], {}, {"stdout": subprocess.DEVNULL}], + ], + ids=idfn, +) +@mock.patch("subprocess.Popen") +def test_Popen_mock( + mock_subprocess_popen, + tmp_path: pathlib.Path, + args: list, + kwargs: dict, + run_kwargs: dict, + capsys: pytest.LogCaptureFixture, +): + process_mock = mock.Mock() + attrs = {"communicate.return_value": ("output", "error"), "returncode": 1} + process_mock.configure_mock(**attrs) + mock_subprocess_popen.return_value = process_mock + kwargs["cwd"] = tmp_path + cmd = SubprocessCommand(*args, **kwargs) + response = cmd.Popen(**run_kwargs) + + assert response.returncode == 1 + + +@pytest.mark.parametrize( + "args,kwargs,run_kwargs", + [ + [[["git", "pull", "--progress"]], {}, {}], + ], + ids=idfn, +) +@mock.patch("subprocess.Popen") +def test_Popen_git_mock( + mock_subprocess_popen, + tmp_path: pathlib.Path, + args: list, + kwargs: dict, + run_kwargs: dict, + capsys: pytest.LogCaptureFixture, +): + process_mock = mock.Mock() + attrs = {"communicate.return_value": ("output", "error"), "returncode": 1} + process_mock.configure_mock(**attrs) + mock_subprocess_popen.return_value = process_mock + kwargs["cwd"] = tmp_path + cmd = SubprocessCommand(*args, **kwargs) + response = cmd.Popen(**run_kwargs) + + stdout, stderr = response.communicate() + + assert response.returncode == 1 + assert stdout == "output" + assert stderr == "error" + + +CODE = ( + textwrap.dedent( + r""" + import sys + import time + size = 10 + for i in range(10): + time.sleep(.01) + sys.stderr.write( + '[' + "#" * i + "." * (size-i) + ']' + f' {i}/{size}' + '\n' + ) + sys.stderr.flush() +""" + ) + .strip("\n") + .lstrip() +) + + +def test_Popen_stderr( + tmp_path: pathlib.Path, + capsys: pytest.LogCaptureFixture, +): + cmd = SubprocessCommand( + [ + sys.executable, + "-c", + CODE, + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=tmp_path, + ) + response = cmd.Popen() + while response.poll() is None: + stdout, stderr = response.communicate() + + assert stdout != "output" + assert stderr != "1" + assert response.returncode == 0 + + +def test_CaptureStderrMixin( + tmp_path: pathlib.Path, + capsys: pytest.LogCaptureFixture, +): + cmd = SubprocessCommand( + [ + sys.executable, + "-c", + CODE, + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=tmp_path, + ) + response = cmd.Popen() + while response.poll() is None: + if response.stderr is not None: + line = response.stderr.readline().decode("utf-8").strip() + if line: + assert line.startswith("[") + assert response.returncode == 0 + + +def test_CaptureStderrMixin_error( + tmp_path: pathlib.Path, + capsys: pytest.LogCaptureFixture, +): + cmd = SubprocessCommand( + [ + sys.executable, + "-c", + CODE + + textwrap.dedent( + """ + sys.exit("FATAL") + """ + ), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=tmp_path, + ) + response = cmd.Popen() + while response.poll() is None: + if response.stderr is not None: + line = response.stderr.readline().decode("utf-8").strip() + if line: + assert line.startswith("[") or line == "FATAL" + + assert response.returncode == 1 diff --git a/tests/cmd/test_git.py b/tests/cmd/test_git.py index 5eb271399..c522b970c 100644 --- a/tests/cmd/test_git.py +++ b/tests/cmd/test_git.py @@ -3,6 +3,7 @@ import pytest +from libvcs._internal.subprocess import SubprocessCommand from libvcs.cmd import git @@ -11,3 +12,20 @@ def test_run(dir_type: Callable, tmp_path: pathlib.Path): repo = git.Git(dir=dir_type(tmp_path)) assert repo.dir == tmp_path + + +@pytest.mark.parametrize("dir_type", [str, pathlib.Path]) +def test_run_deferred(dir_type: Callable, tmp_path: pathlib.Path): + class GitDeferred(git.Git): + def run(self, args, **kwargs): + return SubprocessCommand(["git", *args], **kwargs) + + g = GitDeferred(dir=dir_type(tmp_path)) + cmd = g.run(["help"]) + + assert g.dir == tmp_path + assert cmd.args == ["git", "help"] + + assert cmd.run(capture_output=True, text=True).stdout.startswith( + "usage: git [--version] [--help] [-C ]" + )