Skip to content

Commit fe453c1

Browse files
authored
feat(sync): Continue on error by default, CLI test infra (#387)
Fixes #363, related to #366 CLI sync: - Fix arguments on sub commands - Continue to next repo if encountering error when syncing - New flag: `--exit-on-error` / `-x` - Also stubs out basic CLI tests
2 parents ea44ca6 + cf7d195 commit fe453c1

File tree

7 files changed

+241
-11
lines changed

7 files changed

+241
-11
lines changed

CHANGES

+21
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,23 @@ $ pipx install --suffix=@next 'vcspull' --pip-args '\--pre' --force
2222
### What's new
2323

2424
- Refreshed logo
25+
- `vcspull sync`:
26+
27+
- Syncing will now skip to the next repos if an error is encountered
28+
29+
- Learned `--exit-on-error` / `-x`
30+
31+
Usage:
32+
33+
```console
34+
$ vcspull sync --exit-on-error grako django
35+
```
36+
37+
Print traceback for errored repos:
38+
39+
```console
40+
$ vcspull --log-level DEBUG sync --exit-on-error grako django
41+
```
2542

2643
### Development
2744

@@ -33,6 +50,10 @@ $ pipx install --suffix=@next 'vcspull' --pip-args '\--pre' --force
3350
- Add [flake8-bugbear](https://github.com/PyCQA/flake8-bugbear) (#379)
3451
- Add [flake8-comprehensions](https://github.com/adamchainz/flake8-comprehensions) (#380)
3552

53+
### Testing
54+
55+
- Add CLI tests (#387)
56+
3657
### Documentation
3758

3859
- Render changelog in sphinx-autoissues (#378)

docs/cli/sync.md

+16
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,22 @@
44

55
# vcspull sync
66

7+
## Error handling
8+
9+
As of 1.13.x, vcspull will continue to the next repo if an error is encountered when syncing multiple repos.
10+
11+
To imitate the old behavior, use `--exit-on-error` / `-x`:
12+
13+
```console
14+
$ vcspull sync --exit-on-error grako django
15+
```
16+
17+
Print traceback for errored repos:
18+
19+
```console
20+
$ vcspull --log-level DEBUG sync --exit-on-error grako django
21+
```
22+
723
```{eval-rst}
824
.. click:: vcspull.cli.sync:sync
925
:prog: vcspull sync

src/vcspull/cli/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@
1919

2020
@click.group(
2121
context_settings={
22+
"obj": {},
2223
"help_option_names": ["-h", "--help"],
23-
"allow_interspersed_args": True,
2424
}
2525
)
2626
@click.option(

src/vcspull/cli/sync.py

+25-2
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ def clamp(n, _min, _max):
5959
return max(_min, min(n, _max))
6060

6161

62+
EXIT_ON_ERROR_MSG = "Exiting via error (--exit-on-error passed)"
63+
64+
6265
@click.command(name="sync")
6366
@click.argument(
6467
"repo_terms", type=click.STRING, nargs=-1, shell_complete=get_repo_completions
@@ -71,7 +74,15 @@ def clamp(n, _min, _max):
7174
help="Specify config",
7275
shell_complete=get_config_file_completions,
7376
)
74-
def sync(repo_terms, config):
77+
@click.option(
78+
"exit_on_error",
79+
"--exit-on-error",
80+
"-x",
81+
is_flag=True,
82+
default=False,
83+
help="Exit immediately when encountering an error syncing multiple repos",
84+
)
85+
def sync(repo_terms, config, exit_on_error: bool) -> None:
7586
if config:
7687
configs = load_configs([config])
7788
else:
@@ -95,7 +106,19 @@ def sync(repo_terms, config):
95106
else:
96107
found_repos = configs
97108

98-
list(map(update_repo, found_repos))
109+
for repo in found_repos:
110+
try:
111+
update_repo(repo)
112+
except Exception:
113+
click.echo(
114+
f'Failed syncing {repo.get("name")}',
115+
)
116+
if log.isEnabledFor(logging.DEBUG):
117+
import traceback
118+
119+
traceback.print_exc()
120+
if exit_on_error:
121+
raise click.ClickException(EXIT_ON_ERROR_MSG)
99122

100123

101124
def progress_cb(output, timestamp):

src/vcspull/log.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ def setup_logger(log=None, level="INFO"):
2929
3030
Parameters
3131
----------
32-
log : :py:class:`Logger`
32+
log : :py:class:`logging.Logger`
3333
instance of logger
3434
"""
3535
if not log:

tests/conftest.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ def git_repo_kwargs(repos_path: pathlib.Path, git_dummy_repo_dir):
7171
"""Return kwargs for :func:`create_project`."""
7272
return {
7373
"url": "git+file://" + git_dummy_repo_dir,
74-
"parent_dir": str(repos_path),
74+
"dir": str(repos_path / "repo_name"),
7575
"name": "repo_name",
7676
}
7777

tests/test_cli.py

+176-6
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,184 @@
1+
import pathlib
2+
import typing as t
3+
14
import pytest
25

6+
import yaml
37
from click.testing import CliRunner
48

9+
from libvcs.sync.git import GitSync
510
from vcspull.cli import cli
11+
from vcspull.cli.sync import EXIT_ON_ERROR_MSG
12+
13+
14+
def test_sync_cli_non_existent(tmp_path: pathlib.Path) -> None:
15+
runner = CliRunner()
16+
with runner.isolated_filesystem(temp_dir=tmp_path):
17+
result = runner.invoke(cli, ["sync", "hi"])
18+
assert result.exit_code == 0
19+
assert "" in result.output
20+
21+
22+
def test_sync(
23+
home_path: pathlib.Path,
24+
config_path: pathlib.Path,
25+
tmp_path: pathlib.Path,
26+
git_repo: GitSync,
27+
) -> None:
28+
runner = CliRunner()
29+
with runner.isolated_filesystem(temp_dir=tmp_path):
30+
config = {
31+
"~/github_projects/": {
32+
"my_git_repo": {
33+
"url": f"git+file://{git_repo.dir}",
34+
"remotes": {"test_remote": f"git+file://{git_repo.dir}"},
35+
},
36+
"broken_repo": {
37+
"url": f"git+file://{git_repo.dir}",
38+
"remotes": {"test_remote": "git+file://non-existent-remote"},
39+
},
40+
}
41+
}
42+
yaml_config = config_path / ".vcspull.yaml"
43+
yaml_config_data = yaml.dump(config, default_flow_style=False)
44+
yaml_config.write_text(yaml_config_data, encoding="utf-8")
45+
46+
# CLI can sync
47+
result = runner.invoke(cli, ["sync", "my_git_repo"])
48+
assert result.exit_code == 0
49+
output = "".join(list(result.output))
50+
assert "my_git_repo" in output
51+
52+
53+
if t.TYPE_CHECKING:
54+
from typing_extensions import TypeAlias
655

56+
ExpectedOutput: TypeAlias = t.Optional[t.Union[str, t.List[str]]]
757

8-
@pytest.mark.skip(reason="todo")
9-
def test_command_line(self):
58+
59+
class SyncBrokenFixture(t.NamedTuple):
60+
test_id: str
61+
sync_args: list[str]
62+
expected_exit_code: int
63+
expected_in_output: "ExpectedOutput" = None
64+
expected_not_in_output: "ExpectedOutput" = None
65+
66+
67+
SYNC_BROKEN_REPO_FIXTURES = [
68+
SyncBrokenFixture(
69+
test_id="normal-checkout",
70+
sync_args=["my_git_repo"],
71+
expected_exit_code=0,
72+
expected_in_output="Already on 'master'",
73+
),
74+
SyncBrokenFixture(
75+
test_id="normal-checkout--exit-on-error",
76+
sync_args=["my_git_repo", "--exit-on-error"],
77+
expected_exit_code=0,
78+
expected_in_output="Already on 'master'",
79+
),
80+
SyncBrokenFixture(
81+
test_id="normal-checkout--x",
82+
sync_args=["my_git_repo", "-x"],
83+
expected_exit_code=0,
84+
expected_in_output="Already on 'master'",
85+
),
86+
SyncBrokenFixture(
87+
test_id="normal-first-broken",
88+
sync_args=["non_existent_repo", "my_git_repo"],
89+
expected_exit_code=0,
90+
expected_not_in_output=EXIT_ON_ERROR_MSG,
91+
),
92+
SyncBrokenFixture(
93+
test_id="normal-last-broken",
94+
sync_args=["my_git_repo", "non_existent_repo"],
95+
expected_exit_code=0,
96+
expected_not_in_output=EXIT_ON_ERROR_MSG,
97+
),
98+
SyncBrokenFixture(
99+
test_id="exit-on-error--exit-on-error-first-broken",
100+
sync_args=["non_existent_repo", "my_git_repo", "--exit-on-error"],
101+
expected_exit_code=1,
102+
expected_in_output=EXIT_ON_ERROR_MSG,
103+
),
104+
SyncBrokenFixture(
105+
test_id="exit-on-error--x-first-broken",
106+
sync_args=["non_existent_repo", "my_git_repo", "-x"],
107+
expected_exit_code=1,
108+
expected_in_output=EXIT_ON_ERROR_MSG,
109+
expected_not_in_output="master",
110+
),
111+
#
112+
# Verify ordering
113+
#
114+
SyncBrokenFixture(
115+
test_id="exit-on-error--exit-on-error-last-broken",
116+
sync_args=["my_git_repo", "non_existent_repo", "-x"],
117+
expected_exit_code=1,
118+
expected_in_output=[EXIT_ON_ERROR_MSG, "Already on 'master'"],
119+
),
120+
SyncBrokenFixture(
121+
test_id="exit-on-error--x-last-item",
122+
sync_args=["my_git_repo", "non_existent_repo", "--exit-on-error"],
123+
expected_exit_code=1,
124+
expected_in_output=[EXIT_ON_ERROR_MSG, "Already on 'master'"],
125+
),
126+
]
127+
128+
129+
@pytest.mark.parametrize(
130+
list(SyncBrokenFixture._fields),
131+
SYNC_BROKEN_REPO_FIXTURES,
132+
ids=[test.test_id for test in SYNC_BROKEN_REPO_FIXTURES],
133+
)
134+
def test_sync_broken(
135+
home_path: pathlib.Path,
136+
config_path: pathlib.Path,
137+
tmp_path: pathlib.Path,
138+
git_repo: GitSync,
139+
test_id: str,
140+
sync_args: list[str],
141+
expected_exit_code: int,
142+
expected_in_output: "ExpectedOutput",
143+
expected_not_in_output: "ExpectedOutput",
144+
) -> None:
10145
runner = CliRunner()
11-
result = runner.invoke(cli, ["sync", "hi"])
12-
assert result.exit_code == 0
13-
assert "Debug mode is on" in result.output
14-
assert "Syncing" in result.output
146+
147+
github_projects = home_path / "github_projects"
148+
my_git_repo = github_projects / "my_git_repo"
149+
if my_git_repo.is_dir():
150+
my_git_repo.rmdir()
151+
152+
with runner.isolated_filesystem(temp_dir=tmp_path):
153+
config = {
154+
"~/github_projects/": {
155+
"my_git_repo": {
156+
"url": f"git+file://{git_repo.dir}",
157+
"remotes": {"test_remote": f"git+file://{git_repo.dir}"},
158+
},
159+
"non_existent_repo": {
160+
"url": "git+file:///dev/null",
161+
},
162+
}
163+
}
164+
yaml_config = config_path / ".vcspull.yaml"
165+
yaml_config_data = yaml.dump(config, default_flow_style=False)
166+
yaml_config.write_text(yaml_config_data, encoding="utf-8")
167+
168+
# CLI can sync
169+
assert isinstance(sync_args, list)
170+
result = runner.invoke(cli, ["sync", *sync_args])
171+
assert result.exit_code == expected_exit_code
172+
output = "".join(list(result.output))
173+
174+
if expected_in_output is not None:
175+
if isinstance(expected_in_output, str):
176+
expected_in_output = [expected_in_output]
177+
for needle in expected_in_output:
178+
assert needle in output
179+
180+
if expected_not_in_output is not None:
181+
if isinstance(expected_not_in_output, str):
182+
expected_not_in_output = [expected_not_in_output]
183+
for needle in expected_not_in_output:
184+
assert needle not in output

0 commit comments

Comments
 (0)