Replace 46 blind `except Exception` handlers with the exception types the guarded code can actually raise, so an unforeseen failure surfaces instead of being silently swallowed. The types were derived per site from the try block: file reads get (OSError, UnicodeDecodeError), TOML and YAML parsing add their decode errors, int() guards get ValueError, imports get ImportError, subprocess calls get (OSError, subprocess.SubprocessError), git queries get GitRunError, and importlib.metadata lookups get PackageNotFoundError. Three handlers stay blind, now with a stated reason: the per-repository loops in install and update are batch boundaries where one failing repository must never abort the run. Three imports come along because the narrowing needs them: GitRunError in the changelog and version commands, and http.client in token validation -- urllib raises HTTPException, which is not an OSError subclass and would otherwise have escaped. Add tests/integration/test_error_path_degradation.py, which drives 44 of the 46 handlers into their except branch and asserts the documented degradation. Faults are injected for real rather than mocked wherever possible: undecodable bytes, malformed YAML, digit strings past sys.get_int_max_str_digits(), a real HTTPError with an unreadable body. That approach paid for itself immediately -- tomllib.load() decodes internally and raises UnicodeDecodeError, which was missing from the pyproject handlers and would have turned a malformed pyproject.toml into a traceback. The two handlers left uncovered are the branch-close path in _release_impl, reachable only through a full release, and the jinja2 import guard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
163 lines
5.5 KiB
Python
163 lines
5.5 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
import yaml
|
|
|
|
from pkgmgr.core.config.save import save_user_config
|
|
|
|
from .context import build_context
|
|
from .io import write_mirrors_file
|
|
from .types import MirrorMap, Repository
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Helpers
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
def _repo_key(repo: Repository) -> tuple[str, str, str]:
|
|
"""
|
|
Normalised key for identifying a repository in config files.
|
|
"""
|
|
return (
|
|
str(repo.get("provider", "")),
|
|
str(repo.get("account", "")),
|
|
str(repo.get("repository", "")),
|
|
)
|
|
|
|
|
|
def _load_user_config(path: str) -> dict[str, object]:
|
|
"""
|
|
Load a user config YAML file as dict.
|
|
Non-dicts yield {}.
|
|
"""
|
|
if not os.path.exists(path):
|
|
return {}
|
|
|
|
try:
|
|
with open(path, encoding="utf-8") as f:
|
|
data = yaml.safe_load(f) or {}
|
|
return data if isinstance(data, dict) else {}
|
|
except (OSError, UnicodeDecodeError, yaml.YAMLError):
|
|
return {}
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Main merge command
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
def merge_mirrors(
|
|
selected_repos: list[Repository],
|
|
repositories_base_dir: str,
|
|
all_repos: list[Repository],
|
|
source: str,
|
|
target: str,
|
|
preview: bool = False,
|
|
user_config_path: str | None = None,
|
|
) -> None:
|
|
"""
|
|
Merge mirrors between config and MIRRORS file.
|
|
|
|
Rules:
|
|
- source, target ∈ {"config", "file"}.
|
|
- merged = (target_mirrors overridden by source_mirrors)
|
|
- If target == "file" → write MIRRORS file.
|
|
- If target == "config":
|
|
* update the user config YAML directly
|
|
* write it using save_user_config()
|
|
|
|
The merge strategy is:
|
|
dst + src (src wins on same name)
|
|
"""
|
|
|
|
# Load user config once if we intend to write to it.
|
|
user_cfg: dict[str, object] | None = None
|
|
user_cfg_path_expanded: str | None = None
|
|
|
|
if target == "config" and user_config_path and not preview:
|
|
user_cfg_path_expanded = os.path.expanduser(user_config_path)
|
|
user_cfg = _load_user_config(user_cfg_path_expanded)
|
|
if not isinstance(user_cfg.get("repositories"), list):
|
|
user_cfg["repositories"] = []
|
|
|
|
for repo in selected_repos:
|
|
ctx = build_context(repo, repositories_base_dir, all_repos)
|
|
|
|
print("============================================================")
|
|
print(f"[MIRROR MERGE] Repository: {ctx.identifier}")
|
|
print(f"[MIRROR MERGE] Directory: {ctx.repo_dir}")
|
|
print(f"[MIRROR MERGE] {source} → {target}")
|
|
print("============================================================")
|
|
|
|
# Pick the correct source/target maps
|
|
if source == "config":
|
|
src = ctx.config_mirrors
|
|
dst = ctx.file_mirrors
|
|
else: # source == "file"
|
|
src = ctx.file_mirrors
|
|
dst = ctx.config_mirrors
|
|
|
|
# Merge (src overrides dst)
|
|
merged: MirrorMap = dict(dst)
|
|
merged.update(src)
|
|
|
|
# ---------------------------------------------------------
|
|
# WRITE TO FILE
|
|
# ---------------------------------------------------------
|
|
if target == "file":
|
|
write_mirrors_file(ctx.repo_dir, merged, preview=preview)
|
|
print()
|
|
continue
|
|
|
|
# ---------------------------------------------------------
|
|
# WRITE TO CONFIG
|
|
# ---------------------------------------------------------
|
|
if target == "config":
|
|
# If preview or no config path → show intended output
|
|
if preview or not user_cfg:
|
|
print("[INFO] The following mirrors would be written to config:")
|
|
if not merged:
|
|
print(" (no mirrors)")
|
|
else:
|
|
for name, url in sorted(merged.items()):
|
|
print(f" - {name}: {url}")
|
|
print(" (Config not modified due to preview or missing path.)")
|
|
print()
|
|
continue
|
|
|
|
repos = user_cfg.get("repositories")
|
|
target_key = _repo_key(repo)
|
|
existing_repo: Repository | None = None
|
|
|
|
# Find existing repo entry
|
|
for entry in repos:
|
|
if isinstance(entry, dict) and _repo_key(entry) == target_key:
|
|
existing_repo = entry
|
|
break
|
|
|
|
# Create entry if missing
|
|
if existing_repo is None:
|
|
existing_repo = {
|
|
"provider": repo.get("provider"),
|
|
"account": repo.get("account"),
|
|
"repository": repo.get("repository"),
|
|
}
|
|
repos.append(existing_repo)
|
|
|
|
# Write or delete mirrors
|
|
if merged:
|
|
existing_repo["mirrors"] = dict(merged)
|
|
else:
|
|
existing_repo.pop("mirrors", None)
|
|
|
|
print(" [OK] Updated repo['mirrors'] in user config.")
|
|
print()
|
|
|
|
# -------------------------------------------------------------
|
|
# SAVE CONFIG (once at the end)
|
|
# -------------------------------------------------------------
|
|
if user_cfg is not None and user_cfg_path_expanded is not None and not preview:
|
|
save_user_config(user_cfg, user_cfg_path_expanded)
|
|
print(f"[OK] Saved updated config: {user_cfg_path_expanded}")
|