Skip to content

Commit 02fd15e

Browse files
committed
refactor(cli[add]) Extract duplicated save-config dispatch helpers
why: master's add flow repeats the same save-with-error-handling block at six sites (three merge-path via save_config, three no-merge-path via _save_ordered_items) and derives the "current URL" from an existing entry with two identical isinstance ladders. The AddAction SKIP_PINNED/ SKIP_EXISTING split roughly doubled these over time. what: - Add _save_config_or_log_error(save_fn, *, config_file_path) wrapping the try/save/except-log-traceback pattern; each call site keeps its own success message via the returned bool - Add _extract_current_url(existing_config) for the string/dict/other URL derivation - Route all six save sites and both URL derivations through the helpers, preserving the merge vs no-merge dispatcher (save_config vs _save_ordered_items) via a passed closure - Cover both helpers with doctests and parametrized functional tests
1 parent e6a62d9 commit 02fd15e

2 files changed

Lines changed: 211 additions & 70 deletions

File tree

src/vcspull/cli/add.py

Lines changed: 119 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,99 @@ def _save_ordered_items(
382382
save_config_yaml_with_items(config_file_path, items)
383383

384384

385+
def _save_config_or_log_error(
386+
save_fn: t.Callable[[], None],
387+
*,
388+
config_file_path: pathlib.Path,
389+
) -> bool:
390+
"""Execute a config-save callable with standardised error handling.
391+
392+
Wraps the save call in a try/except block, logging an exception message on
393+
failure and optionally printing the traceback when DEBUG logging is active.
394+
The success message is left to the caller, since each save site phrases it
395+
differently (e.g. "added" vs "label adjustments saved").
396+
397+
Parameters
398+
----------
399+
save_fn : Callable[[], None]
400+
Zero-argument callable that performs the actual file write (e.g. a
401+
lambda wrapping ``save_config`` or ``_save_ordered_items``).
402+
config_file_path : pathlib.Path
403+
Path to the config file, used only for error-log messages.
404+
405+
Returns
406+
-------
407+
bool
408+
``True`` if the save succeeded, ``False`` otherwise.
409+
410+
Examples
411+
--------
412+
>>> import pathlib
413+
>>> p = pathlib.Path("/tmp/x.yaml")
414+
>>> def _ok_save() -> None:
415+
... pass
416+
>>> _save_config_or_log_error(_ok_save, config_file_path=p)
417+
True
418+
419+
>>> def _bad_save() -> None:
420+
... raise RuntimeError("disk full")
421+
>>> _save_config_or_log_error(_bad_save, config_file_path=p)
422+
False
423+
"""
424+
try:
425+
save_fn()
426+
except Exception:
427+
log.exception(
428+
"Error saving config to %s",
429+
PrivatePath(config_file_path),
430+
)
431+
if log.isEnabledFor(logging.DEBUG):
432+
traceback.print_exc()
433+
return False
434+
return True
435+
436+
437+
def _extract_current_url(existing_config: t.Any) -> str:
438+
"""Derive a display URL from an existing repository config entry.
439+
440+
Parameters
441+
----------
442+
existing_config : Any
443+
The value stored in the workspace section for a given repo name.
444+
May be a plain URL string, a dict with ``repo`` / ``url`` keys,
445+
or any other object.
446+
447+
Returns
448+
-------
449+
str
450+
A human-readable URL string suitable for log messages.
451+
452+
Examples
453+
--------
454+
>>> _extract_current_url("git+https://github.com/user/repo.git")
455+
'git+https://github.com/user/repo.git'
456+
457+
>>> _extract_current_url({"repo": "git+https://github.com/user/repo.git"})
458+
'git+https://github.com/user/repo.git'
459+
460+
>>> _extract_current_url({"url": "https://example.com/repo.git"})
461+
'https://example.com/repo.git'
462+
463+
>>> _extract_current_url({"repo": None, "url": None})
464+
'unknown'
465+
466+
>>> _extract_current_url(42)
467+
'42'
468+
"""
469+
if isinstance(existing_config, str):
470+
return existing_config
471+
if isinstance(existing_config, dict):
472+
repo_value = existing_config.get("repo")
473+
url_value = existing_config.get("url")
474+
return repo_value or url_value or "unknown"
475+
return str(existing_config)
476+
477+
385478
def handle_add_command(args: argparse.Namespace) -> None:
386479
"""Entry point for the ``vcspull add`` CLI command."""
387480
repo_input = getattr(args, "repo_path", None)
@@ -790,8 +883,10 @@ def _prepare_no_merge_items(
790883
f" ({reason})" if reason else "",
791884
)
792885
if (duplicate_merge_changes > 0 or config_was_relabelled) and not dry_run:
793-
try:
794-
save_config(config_file_path, raw_config)
886+
if _save_config_or_log_error(
887+
lambda: save_config(config_file_path, raw_config),
888+
config_file_path=config_file_path,
889+
):
795890
log.info(
796891
"%s✓%s Workspace label adjustments saved to %s%s%s.",
797892
Fore.GREEN,
@@ -800,13 +895,6 @@ def _prepare_no_merge_items(
800895
display_config_path,
801896
Style.RESET_ALL,
802897
)
803-
except Exception:
804-
log.exception(
805-
"Error saving config to %s",
806-
PrivatePath(config_file_path),
807-
)
808-
if log.isEnabledFor(logging.DEBUG):
809-
traceback.print_exc()
810898
elif (duplicate_merge_changes > 0 or config_was_relabelled) and dry_run:
811899
log.info(
812900
"%s→%s Would save workspace label adjustments to %s%s%s.",
@@ -818,14 +906,7 @@ def _prepare_no_merge_items(
818906
)
819907
return
820908
elif add_action == AddAction.SKIP_EXISTING:
821-
if isinstance(existing_config, str):
822-
current_url = existing_config
823-
elif isinstance(existing_config, dict):
824-
repo_value = existing_config.get("repo")
825-
url_value = existing_config.get("url")
826-
current_url = repo_value or url_value or "unknown"
827-
else:
828-
current_url = str(existing_config)
909+
current_url = _extract_current_url(existing_config)
829910

830911
log.warning(
831912
"Repository '%s' already exists under '%s'. Current URL: %s. "
@@ -836,8 +917,10 @@ def _prepare_no_merge_items(
836917
)
837918

838919
if (duplicate_merge_changes > 0 or config_was_relabelled) and not dry_run:
839-
try:
840-
save_config(config_file_path, raw_config)
920+
if _save_config_or_log_error(
921+
lambda: save_config(config_file_path, raw_config),
922+
config_file_path=config_file_path,
923+
):
841924
log.info(
842925
"%s✓%s Workspace label adjustments saved to %s%s%s.",
843926
Fore.GREEN,
@@ -846,13 +929,6 @@ def _prepare_no_merge_items(
846929
display_config_path,
847930
Style.RESET_ALL,
848931
)
849-
except Exception:
850-
log.exception(
851-
"Error saving config to %s",
852-
PrivatePath(config_file_path),
853-
)
854-
if log.isEnabledFor(logging.DEBUG):
855-
traceback.print_exc()
856932
elif (duplicate_merge_changes > 0 or config_was_relabelled) and dry_run:
857933
log.info(
858934
"%s→%s Would save workspace label adjustments to %s%s%s.",
@@ -886,8 +962,10 @@ def _prepare_no_merge_items(
886962
)
887963
return
888964

889-
try:
890-
save_config(config_file_path, raw_config)
965+
if _save_config_or_log_error(
966+
lambda: save_config(config_file_path, raw_config),
967+
config_file_path=config_file_path,
968+
):
891969
log.info(
892970
"%s✓%s Successfully added %s'%s'%s (%s%s%s) to %s%s%s under '%s%s%s'.",
893971
Fore.GREEN,
@@ -905,13 +983,6 @@ def _prepare_no_merge_items(
905983
workspace_label,
906984
Style.RESET_ALL,
907985
)
908-
except Exception:
909-
log.exception(
910-
"Error saving config to %s",
911-
PrivatePath(config_file_path),
912-
)
913-
if log.isEnabledFor(logging.DEBUG):
914-
traceback.print_exc()
915986
return
916987

917988
ordered_items = _build_ordered_items(top_level_items, raw_config)
@@ -970,8 +1041,10 @@ def _prepare_no_merge_items(
9701041
Style.RESET_ALL,
9711042
)
9721043
else:
973-
try:
974-
_save_ordered_items(config_file_path, ordered_items)
1044+
if _save_config_or_log_error(
1045+
lambda: _save_ordered_items(config_file_path, ordered_items),
1046+
config_file_path=config_file_path,
1047+
):
9751048
log.info(
9761049
"%s✓%s Workspace label adjustments saved to %s%s%s.",
9771050
Fore.GREEN,
@@ -980,23 +1053,9 @@ def _prepare_no_merge_items(
9801053
display_config_path,
9811054
Style.RESET_ALL,
9821055
)
983-
except Exception:
984-
log.exception(
985-
"Error saving config to %s",
986-
PrivatePath(config_file_path),
987-
)
988-
if log.isEnabledFor(logging.DEBUG):
989-
traceback.print_exc()
9901056
return
9911057
elif no_merge_add_action == AddAction.SKIP_EXISTING:
992-
if isinstance(existing_config, str):
993-
current_url = existing_config
994-
elif isinstance(existing_config, dict):
995-
repo_value = existing_config.get("repo")
996-
url_value = existing_config.get("url")
997-
current_url = repo_value or url_value or "unknown"
998-
else:
999-
current_url = str(existing_config)
1058+
current_url = _extract_current_url(existing_config)
10001059

10011060
log.warning(
10021061
"Repository '%s' already exists under '%s'. Current URL: %s. "
@@ -1017,8 +1076,10 @@ def _prepare_no_merge_items(
10171076
Style.RESET_ALL,
10181077
)
10191078
else:
1020-
try:
1021-
_save_ordered_items(config_file_path, ordered_items)
1079+
if _save_config_or_log_error(
1080+
lambda: _save_ordered_items(config_file_path, ordered_items),
1081+
config_file_path=config_file_path,
1082+
):
10221083
log.info(
10231084
"%s✓%s Workspace label adjustments saved to %s%s%s.",
10241085
Fore.GREEN,
@@ -1027,13 +1088,6 @@ def _prepare_no_merge_items(
10271088
display_config_path,
10281089
Style.RESET_ALL,
10291090
)
1030-
except Exception:
1031-
log.exception(
1032-
"Error saving config to %s",
1033-
PrivatePath(config_file_path),
1034-
)
1035-
if log.isEnabledFor(logging.DEBUG):
1036-
traceback.print_exc()
10371091
return
10381092

10391093
target_section = ordered_items[target_index]["section"]
@@ -1067,8 +1121,10 @@ def _prepare_no_merge_items(
10671121
)
10681122
return
10691123

1070-
try:
1071-
_save_ordered_items(config_file_path, ordered_items)
1124+
if _save_config_or_log_error(
1125+
lambda: _save_ordered_items(config_file_path, ordered_items),
1126+
config_file_path=config_file_path,
1127+
):
10721128
log.info(
10731129
"%s✓%s Successfully added %s'%s'%s (%s%s%s) to %s%s%s under '%s%s%s'.",
10741130
Fore.GREEN,
@@ -1086,10 +1142,3 @@ def _prepare_no_merge_items(
10861142
workspace_label,
10871143
Style.RESET_ALL,
10881144
)
1089-
except Exception:
1090-
log.exception(
1091-
"Error saving config to %s",
1092-
PrivatePath(config_file_path),
1093-
)
1094-
if log.isEnabledFor(logging.DEBUG):
1095-
traceback.print_exc()

tests/cli/test_add.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
AddAction,
2121
_classify_add_action,
2222
_collapse_ordered_items_to_dict,
23+
_extract_current_url,
24+
_save_config_or_log_error,
2325
add_repo,
2426
create_add_subparser,
2527
handle_add_command,
@@ -1714,3 +1716,93 @@ def test_collapse_ordered_items_to_dict(
17141716
for label, expected_keys in expected_repo_keys.items():
17151717
assert label in result
17161718
assert set(result[label].keys()) == expected_keys
1719+
1720+
1721+
class ExtractCurrentUrlFixture(t.NamedTuple):
1722+
"""Fixture for _extract_current_url derivation cases."""
1723+
1724+
test_id: str
1725+
existing_config: object
1726+
expected: str
1727+
1728+
1729+
EXTRACT_CURRENT_URL_FIXTURES: list[ExtractCurrentUrlFixture] = [
1730+
ExtractCurrentUrlFixture(
1731+
test_id="plain-string",
1732+
existing_config="git+https://github.com/user/repo.git",
1733+
expected="git+https://github.com/user/repo.git",
1734+
),
1735+
ExtractCurrentUrlFixture(
1736+
test_id="dict-repo-key",
1737+
existing_config={"repo": "git+https://github.com/user/repo.git"},
1738+
expected="git+https://github.com/user/repo.git",
1739+
),
1740+
ExtractCurrentUrlFixture(
1741+
test_id="dict-url-key",
1742+
existing_config={"url": "https://example.com/repo.git"},
1743+
expected="https://example.com/repo.git",
1744+
),
1745+
ExtractCurrentUrlFixture(
1746+
test_id="dict-repo-wins-over-url",
1747+
existing_config={"repo": "git+ssh://a", "url": "https://b"},
1748+
expected="git+ssh://a",
1749+
),
1750+
ExtractCurrentUrlFixture(
1751+
test_id="dict-empty-values",
1752+
existing_config={"repo": None, "url": None},
1753+
expected="unknown",
1754+
),
1755+
ExtractCurrentUrlFixture(
1756+
test_id="non-str-non-dict",
1757+
existing_config=42,
1758+
expected="42",
1759+
),
1760+
]
1761+
1762+
1763+
@pytest.mark.parametrize(
1764+
list(ExtractCurrentUrlFixture._fields),
1765+
EXTRACT_CURRENT_URL_FIXTURES,
1766+
ids=[fixture.test_id for fixture in EXTRACT_CURRENT_URL_FIXTURES],
1767+
)
1768+
def test_extract_current_url(
1769+
test_id: str,
1770+
existing_config: object,
1771+
expected: str,
1772+
) -> None:
1773+
"""_extract_current_url derives a display URL from any entry shape."""
1774+
assert _extract_current_url(existing_config) == expected
1775+
1776+
1777+
def test_save_config_or_log_error_success(tmp_path: pathlib.Path) -> None:
1778+
"""A successful save callable returns True and logs no error."""
1779+
calls: list[int] = []
1780+
1781+
assert (
1782+
_save_config_or_log_error(
1783+
lambda: calls.append(1),
1784+
config_file_path=tmp_path / "cfg.yaml",
1785+
)
1786+
is True
1787+
)
1788+
assert calls == [1]
1789+
1790+
1791+
def test_save_config_or_log_error_failure(
1792+
tmp_path: pathlib.Path,
1793+
caplog: pytest.LogCaptureFixture,
1794+
) -> None:
1795+
"""A raising save callable returns False and logs the failure."""
1796+
1797+
def _boom() -> None:
1798+
error_message = "disk full"
1799+
raise RuntimeError(error_message)
1800+
1801+
with caplog.at_level(logging.ERROR, logger="vcspull.cli.add"):
1802+
result = _save_config_or_log_error(
1803+
_boom,
1804+
config_file_path=tmp_path / "cfg.yaml",
1805+
)
1806+
1807+
assert result is False
1808+
assert any(record.levelno == logging.ERROR for record in caplog.records)

0 commit comments

Comments
 (0)