Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,34 @@ $ uvx --from 'vcspull' --prerelease allow vcspull
_Notes on upcoming releases will be added here_
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->

### Breaking changes

#### Exceptions renamed with an `Error` suffix (#420)

Two public exceptions were renamed to match the `Error`-suffix naming
convention (pep8-naming `N818`), with no aliases kept:

- {exc}`~vcspull.exc.VCSPullError` — the base every vcspull error derives
from (was `VCSPullException`).
- {exc}`~vcspull.exc.MultipleConfigError` — raised when the home directory
holds more than one eligible config file (was `MultipleConfigWarning`;
it was always raised as a hard error, never a warning).

Update `except` clauses accordingly:

```python
# Before
except vcspull.exc.VCSPullException:
...

# After
except vcspull.exc.VCSPullError:
...
```

Other subclasses ({exc}`~vcspull.exc.WorktreeError` and friends) keep their
names.

## vcspull v1.65.0 (2026-07-05)

vcspull v1.65.0 sharpens the feedback you get when a sync cannot check out
Expand Down
23 changes: 8 additions & 15 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,17 +69,12 @@ def xdg_config_path(
@pytest.fixture
def config_path(
xdg_config_path: pathlib.Path,
request: pytest.FixtureRequest,
) -> pathlib.Path:
) -> t.Generator[pathlib.Path, None, None]:
"""Ensure and return vcspull configuration path."""
conf_path = xdg_config_path / "vcspull"
conf_path.mkdir(exist_ok=True)

def clean() -> None:
shutil.rmtree(conf_path)

request.addfinalizer(clean)
return conf_path
yield conf_path
shutil.rmtree(conf_path)


@pytest.fixture(autouse=True)
Expand All @@ -92,16 +87,14 @@ def set_xdg_config_path(


@pytest.fixture
def repos_path(user_path: pathlib.Path, request: pytest.FixtureRequest) -> pathlib.Path:
def repos_path(
user_path: pathlib.Path,
) -> t.Generator[pathlib.Path, None, None]:
"""Return temporary directory for repository checkout guaranteed unique."""
path = user_path / "repos"
path.mkdir(exist_ok=True)

def clean() -> None:
shutil.rmtree(path)

request.addfinalizer(clean)
return path
yield path
shutil.rmtree(path)


def pytest_collection_modifyitems(
Expand Down
2 changes: 1 addition & 1 deletion docs/api/exc.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

When configuration loading or a worktree operation fails, vcspull raises an
exception from {mod}`vcspull.exc`. Catch
{exc}`~vcspull.exc.VCSPullException` to handle any of them; the generated
{exc}`~vcspull.exc.VCSPullError` to handle any of them; the generated
reference below lists the specific subclasses.

```{eval-rst}
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -214,16 +214,19 @@ select = [
"F", # pyflakes
"I", # isort
"UP", # pyupgrade
"N", # pep8-naming
"A", # flake8-builtins
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"ICN", # flake8-import-conventions
"COM", # flake8-commas
"EM", # flake8-errmsg
"Q", # flake8-quotes
"PTH", # flake8-use-pathlib
"SIM", # flake8-simplify
"TRY", # Trycertatops
"PERF", # Perflint
"PT", # flake8-pytest-style
"RUF", # Ruff-specific rules
"D", # pydocstyle
"FA100", # future annotations
Expand Down
4 changes: 2 additions & 2 deletions src/vcspull/cli/import_cmd/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
save_config,
workspace_root_label,
)
from vcspull.exc import MultipleConfigWarning
from vcspull.exc import MultipleConfigError

from .._colors import Colors, get_color_mode
from .._output import OutputFormatter, get_output_mode
Expand Down Expand Up @@ -774,7 +774,7 @@ def _run_import(
# Resolve config file
try:
config_file_path = _resolve_config_file(config_path_str)
except (ValueError, MultipleConfigWarning) as exc_:
except (ValueError, MultipleConfigError) as exc_:
log.error("%s %s", colors.error("✗"), exc_) # noqa: TRY400
return 1
display_config_path = str(PrivatePath(config_file_path))
Expand Down
4 changes: 2 additions & 2 deletions src/vcspull/cli/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -2026,14 +2026,14 @@ def guess_vcs(url: str) -> VCSLiteral | None:
return t.cast("VCSLiteral", vcs_matches[0].vcs)


class CouldNotGuessVCSFromURL(exc.VCSPullException):
class CouldNotGuessVCSFromURL(exc.VCSPullError):
"""Raised when no VCS could be guessed from a URL."""

def __init__(self, repo_url: str, *args: object, **kwargs: object) -> None:
return super().__init__(f"Could not automatically determine VCS for {repo_url}")


class SyncFailedError(exc.VCSPullException):
class SyncFailedError(exc.VCSPullError):
"""Raised when a sync operation completes but with errors."""

def __init__(
Expand Down
34 changes: 17 additions & 17 deletions src/vcspull/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ def _validate_worktrees_config(

Raises
------
VCSPullException
VCSPullError
If the worktrees configuration is invalid.

Examples
Expand Down Expand Up @@ -157,35 +157,35 @@ def _validate_worktrees_config(
>>> _validate_worktrees_config("not-a-list", "myrepo")
Traceback (most recent call last):
...
vcspull.exc.VCSPullException: ...worktrees must be a list, got str
vcspull.exc.VCSPullError: ...worktrees must be a list, got str

Error: worktree entry must be a dict:

>>> _validate_worktrees_config(["not-a-dict"], "myrepo")
Traceback (most recent call last):
...
vcspull.exc.VCSPullException: ...must be a dict, got str
vcspull.exc.VCSPullError: ...must be a dict, got str

Error: missing required 'dir' field:

>>> _validate_worktrees_config([{"tag": "v1.0.0"}], "myrepo")
Traceback (most recent call last):
...
vcspull.exc.VCSPullException: ...missing required 'dir' field
vcspull.exc.VCSPullError: ...missing required 'dir' field

Error: no ref type specified:

>>> _validate_worktrees_config([{"dir": "../wt"}], "myrepo")
Traceback (most recent call last):
...
vcspull.exc.VCSPullException: ...must specify one of: tag, branch, or commit
vcspull.exc.VCSPullError: ...must specify one of: tag, branch, or commit

Error: empty ref value:

>>> _validate_worktrees_config([{"dir": "../wt", "tag": ""}], "myrepo")
Traceback (most recent call last):
...
vcspull.exc.VCSPullException: ...empty ref value...
vcspull.exc.VCSPullError: ...empty ref value...

Error: multiple refs specified:

Expand All @@ -194,14 +194,14 @@ def _validate_worktrees_config(
... )
Traceback (most recent call last):
...
vcspull.exc.VCSPullException: ...cannot specify multiple refs...
vcspull.exc.VCSPullError: ...cannot specify multiple refs...
"""
if not isinstance(worktrees_raw, list):
msg = (
f"Repository '{repo_name}': worktrees must be a list, "
f"got {type(worktrees_raw).__name__}"
)
raise exc.VCSPullException(msg)
raise exc.VCSPullError(msg)

validated: list[WorktreeConfigDict] = []

Expand All @@ -211,22 +211,22 @@ def _validate_worktrees_config(
f"Repository '{repo_name}': worktree entry {idx} must be a dict, "
f"got {type(wt).__name__}"
)
raise exc.VCSPullException(msg)
raise exc.VCSPullError(msg)

# Validate required 'dir' field
if "dir" not in wt or not wt["dir"]:
msg = (
f"Repository '{repo_name}': worktree entry {idx} "
"missing required 'dir' field"
)
raise exc.VCSPullException(msg)
raise exc.VCSPullError(msg)

if not isinstance(wt["dir"], str):
msg = (
f"Repository '{repo_name}': worktree entry {idx} "
f"'dir' must be a string, got {type(wt['dir']).__name__}"
)
raise exc.VCSPullException(msg)
raise exc.VCSPullError(msg)

# Validate exactly one ref type
tag = wt.get("tag")
Expand All @@ -243,19 +243,19 @@ def _validate_worktrees_config(
f"Repository '{repo_name}': worktree entry {idx} "
"must specify one of: tag, branch, or commit"
)
raise exc.VCSPullException(msg)
raise exc.VCSPullError(msg)
if refs_specified == 0 and empty_refs > 0:
msg = (
f"Repository '{repo_name}': worktree entry {idx} "
"has empty ref value (tag, branch, or commit)"
)
raise exc.VCSPullException(msg)
raise exc.VCSPullError(msg)
if refs_specified > 1:
msg = (
f"Repository '{repo_name}': worktree entry {idx} "
"cannot specify multiple refs (tag, branch, commit)"
)
raise exc.VCSPullException(msg)
raise exc.VCSPullError(msg)

# Validate ref types are strings
for ref_name, ref_val in [("tag", tag), ("branch", branch), ("commit", commit)]:
Expand All @@ -264,7 +264,7 @@ def _validate_worktrees_config(
f"Repository '{repo_name}': worktree entry {idx} "
f"'{ref_name}' must be a string, got {type(ref_val).__name__}"
)
raise exc.VCSPullException(msg)
raise exc.VCSPullError(msg)

# Build validated worktree config
wt_config: WorktreeConfigDict = {"dir": wt["dir"]}
Expand Down Expand Up @@ -447,7 +447,7 @@ def find_home_config_files(
)
else:
if sum(filter(None, [has_json_config, has_yaml_config])) > 1:
raise exc.MultipleConfigWarning
raise exc.MultipleConfigError
if has_yaml_config:
configs.append(yaml_config)
if has_json_config:
Expand Down Expand Up @@ -616,7 +616,7 @@ def load_configs(

if len(dupes) > 0:
msg = ("repos with same path + different VCS detected!", dupes)
raise exc.VCSPullException(msg)
raise exc.VCSPullError(msg)
repos.extend(newrepos)

return repos
Expand Down
6 changes: 3 additions & 3 deletions src/vcspull/exc.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,17 @@
from __future__ import annotations


class VCSPullException(Exception):
class VCSPullError(Exception):
"""Standard exception raised by vcspull."""


class MultipleConfigWarning(VCSPullException):
class MultipleConfigError(VCSPullError):
"""Multiple eligible config files found at the same time."""

message = "Multiple configs found in home directory use only one. .yaml, .json."


class WorktreeError(VCSPullException):
class WorktreeError(VCSPullError):
"""Base exception for worktree operations."""


Expand Down
4 changes: 2 additions & 2 deletions src/vcspull/log.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ def template(self, record: logging.LogRecord) -> str:
Style.RESET_ALL,
" ",
]
module_funcName = [Fore.GREEN, Style.BRIGHT, "%(module)s.%(funcName)s()"]
module_func_name = [Fore.GREEN, Style.BRIGHT, "%(module)s.%(funcName)s()"]
lineno = [
Fore.BLACK,
Style.DIM,
Expand All @@ -307,7 +307,7 @@ def template(self, record: logging.LogRecord) -> str:
]

return "".join(
reset + levelname + asctime + name + module_funcName + lineno + reset,
reset + levelname + asctime + name + module_func_name + lineno + reset,
)


Expand Down
8 changes: 4 additions & 4 deletions tests/cli/test_import_repos.py
Original file line number Diff line number Diff line change
Expand Up @@ -1419,8 +1419,8 @@ def test_import_repos_catches_multiple_config_warning(
monkeypatch: MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test _run_import logs error instead of crashing on MultipleConfigWarning."""
from vcspull.exc import MultipleConfigWarning
"""Test _run_import logs error instead of crashing on MultipleConfigError."""
from vcspull.exc import MultipleConfigError

caplog.set_level(logging.ERROR)

Expand All @@ -1430,9 +1430,9 @@ def test_import_repos_catches_multiple_config_warning(

importer = MockImporter(repos=[_make_repo("repo1")])

# Mock _resolve_config_file: raise MultipleConfigWarning to test error handling
# Mock _resolve_config_file: raise MultipleConfigError to test error handling
def raise_multiple_config(_: str | None) -> pathlib.Path:
raise MultipleConfigWarning(MultipleConfigWarning.message)
raise MultipleConfigError(MultipleConfigError.message)

monkeypatch.setattr(
import_common_mod,
Expand Down
3 changes: 2 additions & 1 deletion tests/cli/test_sync_sigint.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ def test_exit_on_sigint_produces_wifsignaled_sigint() -> None:
# vcspull. Prepending the parent's resolved package dir keeps the child
# importable regardless of the surrounding install style.
vcspull_spec = importlib.util.find_spec("vcspull")
assert vcspull_spec is not None and vcspull_spec.origin is not None
assert vcspull_spec is not None
assert vcspull_spec.origin is not None
vcspull_parent = str(pathlib.Path(vcspull_spec.origin).resolve().parent.parent)
env = {
**os.environ,
Expand Down
Loading