Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit fe453c1

Browse filesBrowse 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
Copy full SHA for fe453c1

File tree

Expand file treeCollapse file tree

7 files changed

+241
-11
lines changed
Filter options
Expand file treeCollapse file tree

7 files changed

+241
-11
lines changed

‎CHANGES

Copy file name to clipboardExpand all lines: CHANGES
+21Lines changed: 21 additions & 0 deletions
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

Copy file name to clipboardExpand all lines: docs/cli/sync.md
+16Lines changed: 16 additions & 0 deletions
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

Copy file name to clipboardExpand all lines: src/vcspull/cli/__init__.py
+1-1Lines changed: 1 addition & 1 deletion
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

Copy file name to clipboardExpand all lines: src/vcspull/cli/sync.py
+25-2Lines changed: 25 additions & 2 deletions
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

Copy file name to clipboardExpand all lines: src/vcspull/log.py
+1-1Lines changed: 1 addition & 1 deletion
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

Copy file name to clipboardExpand all lines: tests/conftest.py
+1-1Lines changed: 1 addition & 1 deletion
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

Copy file name to clipboard
+176-6Lines changed: 176 additions & 6 deletions
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 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.