fix: catch concrete exceptions instead of bare Exception

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>
This commit is contained in:
Kevin Veen-Birkenbach
2026-07-27 18:37:14 +02:00
parent 85d1abf61c
commit 7764a2946c
36 changed files with 860 additions and 103 deletions

View File

@@ -102,10 +102,8 @@ def run_archive(
if not dry_run: if not dry_run:
for path, _title in archived: for path, _title in archived:
try: with contextlib.suppress(FileNotFoundError):
path.unlink() path.unlink()
except FileNotFoundError:
pass
return ArchivePlan( return ArchivePlan(
archived=archived, archived=archived,

View File

@@ -230,7 +230,7 @@ def install_repos(
f"[Warning] install: repository {identifier} failed (exit={code}). Continuing..." f"[Warning] install: repository {identifier} failed (exit={code}). Continuing..."
) )
continue continue
except Exception as exc: except Exception as exc: # noqa: BLE001 - batch boundary: one repository must never abort the run
failures.append((identifier, f"unexpected error: {exc}")) failures.append((identifier, f"unexpected error: {exc}"))
if not quiet: if not quiet:
print( print(

View File

@@ -21,7 +21,7 @@ def coerce_index(key: str, entry: dict[str, Any]) -> int | None:
if k.isdigit(): if k.isdigit():
try: try:
return int(k) return int(k)
except Exception: except ValueError:
return None return None
# 2) Explicit index fields (schema-dependent) # 2) Explicit index fields (schema-dependent)
@@ -32,7 +32,7 @@ def coerce_index(key: str, entry: dict[str, Any]) -> int | None:
if isinstance(v, str) and v.strip().isdigit(): if isinstance(v, str) and v.strip().isdigit():
try: try:
return int(v.strip()) return int(v.strip())
except Exception: except ValueError:
pass pass
# 3) Last resort: extract trailing number from key if it looks like "<name>-<n>" # 3) Last resort: extract trailing number from key if it looks like "<name>-<n>"
@@ -40,13 +40,13 @@ def coerce_index(key: str, entry: dict[str, Any]) -> int | None:
if m: if m:
try: try:
return int(m.group(1)) return int(m.group(1))
except Exception: except ValueError:
return None return None
return None return None
def iter_store_paths(entry: Dict[str, Any]) -> Iterable[str]: def iter_store_paths(entry: dict[str, Any]) -> Iterable[str]:
""" """
Yield all possible store paths from a nix profile JSON entry. Yield all possible store paths from a nix profile JSON entry.

View File

@@ -35,12 +35,12 @@ class NixProfileListReader:
sp = m.group(2) sp = m.group(2)
try: try:
idx = int(idx_s) idx = int(idx_s)
except Exception: except ValueError:
continue continue
entries.append((idx, self._store_prefix(sp))) entries.append((idx, self._store_prefix(sp)))
seen: set[int] = set() seen: set[int] = set()
uniq: List[Tuple[int, str]] = [] uniq: list[tuple[int, str]] = []
for idx, sp in entries: for idx, sp in entries:
if idx not in seen: if idx not in seen:
seen.add(idx) seen.add(idx)

View File

@@ -36,7 +36,7 @@ class ArchPkgbuildInstaller(BaseInstaller):
try: try:
if hasattr(os, "geteuid") and os.geteuid() == 0: if hasattr(os, "geteuid") and os.geteuid() == 0:
return False return False
except Exception: except (AttributeError, OSError):
# On non-POSIX platforms just ignore this check. # On non-POSIX platforms just ignore this check.
pass pass

View File

@@ -35,10 +35,10 @@ def _load_user_config(path: str) -> dict[str, object]:
return {} return {}
try: try:
with open(path, "r", encoding="utf-8") as f: with open(path, encoding="utf-8") as f:
data = yaml.safe_load(f) or {} data = yaml.safe_load(f) or {}
return data if isinstance(data, dict) else {} return data if isinstance(data, dict) else {}
except Exception: except (OSError, UnicodeDecodeError, yaml.YAMLError):
return {} return {}

View File

@@ -83,10 +83,8 @@ def _markdownlint(reference_path: str, document: str) -> list[str]:
] ]
return findings or [output.strip() or "markdown-lint reported an error"] return findings or [output.strip() or "markdown-lint reported an error"]
finally: finally:
try: with contextlib.suppress(OSError):
os.remove(tmp_path) os.remove(tmp_path)
except OSError:
pass
def _builtin_lint(document: str) -> list[str]: def _builtin_lint(document: str) -> list[str]:

View File

@@ -104,9 +104,9 @@ def update_changelog(
changelog = "" changelog = ""
if os.path.exists(changelog_path): if os.path.exists(changelog_path):
try: try:
with open(changelog_path, "r", encoding="utf-8") as f: with open(changelog_path, encoding="utf-8") as f:
changelog = f.read() changelog = f.read()
except Exception as exc: except (OSError, UnicodeDecodeError) as exc:
print(f"[WARN] Could not read existing CHANGELOG.md: {exc}") print(f"[WARN] Could not read existing CHANGELOG.md: {exc}")
new_changelog = _insert_after_h1(changelog, entry) new_changelog = _insert_after_h1(changelog, entry)

View File

@@ -68,9 +68,9 @@ def update_debian_changelog(
return return
try: try:
with open(debian_changelog_path, "r", encoding="utf-8") as f: with open(debian_changelog_path, encoding="utf-8") as f:
existing = f.read() existing = f.read()
except Exception as exc: except (OSError, UnicodeDecodeError) as exc:
print(f"[WARN] Could not read debian/changelog: {exc}") print(f"[WARN] Could not read debian/changelog: {exc}")
existing = "" existing = ""

View File

@@ -34,13 +34,11 @@ def _open_editor_for_changelog(initial_message: str | None = None) -> str:
) )
try: try:
with open(tmp_path, "r", encoding="utf-8") as f: with open(tmp_path, encoding="utf-8") as f:
content = f.read() content = f.read()
finally: finally:
try: with contextlib.suppress(OSError):
os.remove(tmp_path) os.remove(tmp_path)
except OSError:
pass
lines = [line for line in content.splitlines() if not line.strip().startswith(";")] lines = [line for line in content.splitlines() if not line.strip().startswith(";")]
return "\n".join(lines).strip() return "\n".join(lines).strip()

View File

@@ -12,9 +12,9 @@ def update_flake_version(
return return
try: try:
with open(flake_path, "r", encoding="utf-8") as f: with open(flake_path, encoding="utf-8") as f:
content = f.read() content = f.read()
except Exception as exc: except (OSError, UnicodeDecodeError) as exc:
print(f"[WARN] Could not read flake.nix: {exc}") print(f"[WARN] Could not read flake.nix: {exc}")
return return

View File

@@ -12,9 +12,9 @@ def update_pkgbuild_version(
return return
try: try:
with open(pkgbuild_path, "r", encoding="utf-8") as f: with open(pkgbuild_path, encoding="utf-8") as f:
content = f.read() content = f.read()
except Exception as exc: except (OSError, UnicodeDecodeError) as exc:
print(f"[WARN] Could not read PKGBUILD: {exc}") print(f"[WARN] Could not read PKGBUILD: {exc}")
return return

View File

@@ -18,9 +18,9 @@ def update_spec_changelog(
return return
try: try:
with open(spec_path, "r", encoding="utf-8") as f: with open(spec_path, encoding="utf-8") as f:
content = f.read() content = f.read()
except Exception as exc: except (OSError, UnicodeDecodeError) as exc:
print(f"[WARN] Could not read spec file for changelog update: {exc}") print(f"[WARN] Could not read spec file for changelog update: {exc}")
return return
@@ -63,7 +63,7 @@ def update_spec_changelog(
try: try:
with open(spec_path, "w", encoding="utf-8") as f: with open(spec_path, "w", encoding="utf-8") as f:
f.write(new_content) f.write(new_content)
except Exception as exc: except (OSError, UnicodeEncodeError) as exc:
print(f"[WARN] Failed to write updated spec changelog section: {exc}") print(f"[WARN] Failed to write updated spec changelog section: {exc}")
return return

View File

@@ -15,9 +15,9 @@ def update_spec_version(
return return
try: try:
with open(spec_path, "r", encoding="utf-8") as f: with open(spec_path, encoding="utf-8") as f:
content = f.read() content = f.read()
except Exception as exc: except (OSError, UnicodeDecodeError) as exc:
print(f"[WARN] Could not read spec file: {exc}") print(f"[WARN] Could not read spec file: {exc}")
return return

View File

@@ -186,7 +186,7 @@ def _release_impl(
print(f"[INFO] Deleting branch {branch} after successful release...") print(f"[INFO] Deleting branch {branch} after successful release...")
try: try:
close_branch(name=branch, base_branch="main", cwd=".") close_branch(name=branch, base_branch="main", cwd=".")
except Exception as exc: except (RuntimeError, GitRunError) as exc:
print(f"[WARN] Failed to close branch {branch} automatically: {exc}") print(f"[WARN] Failed to close branch {branch} automatically: {exc}")

View File

@@ -2,13 +2,13 @@ from __future__ import annotations
import os import os
from pathlib import Path from pathlib import Path
from typing import Any, Dict from typing import Any
from pkgmgr.core.git.queries import get_repo_root from pkgmgr.core.git.queries import get_repo_root
try: try:
from jinja2 import Environment, FileSystemLoader, StrictUndefined from jinja2 import Environment, FileSystemLoader, StrictUndefined
except Exception as exc: # pragma: no cover except ImportError as exc: # pragma: no cover
Environment = None # type: ignore Environment = None # type: ignore
FileSystemLoader = None # type: ignore FileSystemLoader = None # type: ignore
StrictUndefined = None # type: ignore StrictUndefined = None # type: ignore

View File

@@ -28,7 +28,7 @@ def delete_repos(selected_repos, repositories_base_dir, all_repos, preview=False
print( print(
f"Deleted repository directory '{repo_dir}' for {repo_identifier}." f"Deleted repository directory '{repo_dir}' for {repo_identifier}."
) )
except Exception as e: except OSError as e:
print(f"Error deleting '{repo_dir}' for {repo_identifier}: {e}") print(f"Error deleting '{repo_dir}' for {repo_identifier}: {e}")
else: else:
print(f"Skipped deletion of '{repo_dir}' for {repo_identifier}.") print(f"Skipped deletion of '{repo_dir}' for {repo_identifier}.")

View File

@@ -57,7 +57,7 @@ class UpdateManager:
f"[Warning] update: pull failed for {identifier} (exit={code}). Continuing..." f"[Warning] update: pull failed for {identifier} (exit={code}). Continuing..."
) )
continue continue
except Exception as exc: except Exception as exc: # noqa: BLE001 - batch boundary: one repository must never abort the run
failures.append((identifier, f"pull failed: {exc}")) failures.append((identifier, f"pull failed: {exc}"))
if not quiet: if not quiet:
print( print(
@@ -88,7 +88,7 @@ class UpdateManager:
f"[Warning] update: install failed for {identifier} (exit={code}). Continuing..." f"[Warning] update: install failed for {identifier} (exit={code}). Continuing..."
) )
continue continue
except Exception as exc: except Exception as exc: # noqa: BLE001 - batch boundary: one repository must never abort the run
failures.append((identifier, f"install failed: {exc}")) failures.append((identifier, f"install failed: {exc}"))
if not quiet: if not quiet:
print( print(

View File

@@ -2,22 +2,23 @@ from __future__ import annotations
import os import os
import sys import sys
from typing import Any, Dict, List, Optional, Tuple from typing import Any
from pkgmgr.actions.changelog import generate_changelog from pkgmgr.actions.changelog import generate_changelog
from pkgmgr.cli.context import CLIContext from pkgmgr.cli.context import CLIContext
from pkgmgr.core.git import GitRunError
from pkgmgr.core.git.queries import get_tags from pkgmgr.core.git.queries import get_tags
from pkgmgr.core.repository.dir import get_repo_dir from pkgmgr.core.repository.dir import get_repo_dir
from pkgmgr.core.repository.identifier import get_repo_identifier from pkgmgr.core.repository.identifier import get_repo_identifier
from pkgmgr.core.version.semver import extract_semver_from_tags from pkgmgr.core.version.semver import extract_semver_from_tags
Repository = Dict[str, Any] Repository = dict[str, Any]
def _find_previous_and_current_tag( def _find_previous_and_current_tag(
tags: List[str], tags: list[str],
target_tag: Optional[str] = None, target_tag: str | None = None,
) -> Tuple[Optional[str], Optional[str]]: ) -> tuple[str | None, str | None]:
""" """
Given a list of tags and an optional target tag, determine Given a list of tags and an optional target tag, determine
(previous_tag, current_tag) on the SemVer axis. (previous_tag, current_tag) on the SemVer axis.
@@ -93,7 +94,7 @@ def handle_changelog(
if not repo_dir: if not repo_dir:
try: try:
repo_dir = get_repo_dir(ctx.repositories_base_dir, repo) repo_dir = get_repo_dir(ctx.repositories_base_dir, repo)
except Exception: except (AttributeError, KeyError, TypeError):
repo_dir = None repo_dir = None
identifier = get_repo_identifier(repo, ctx.all_repositories) identifier = get_repo_identifier(repo, ctx.all_repositories)
@@ -113,12 +114,12 @@ def handle_changelog(
try: try:
tags = get_tags(cwd=repo_dir) tags = get_tags(cwd=repo_dir)
except Exception as exc: except GitRunError as exc:
print(f"[ERROR] Could not read git tags: {exc}") print(f"[ERROR] Could not read git tags: {exc}")
tags = [] tags = []
from_ref: Optional[str] = None from_ref: str | None = None
to_ref: Optional[str] = None to_ref: str | None = None
if range_arg: if range_arg:
# Explicit range provided # Explicit range provided

View File

@@ -36,7 +36,7 @@ def handle_release(
repo_dir = repo.get("directory") or get_repo_dir( repo_dir = repo.get("directory") or get_repo_dir(
ctx.repositories_base_dir, repo ctx.repositories_base_dir, repo
) )
except Exception as exc: except (AttributeError, KeyError, TypeError) as exc:
print( print(
f"[WARN] Skipping repository {identifier}: failed to resolve directory: {exc}" f"[WARN] Skipping repository {identifier}: failed to resolve directory: {exc}"
) )

View File

@@ -119,7 +119,7 @@ def handle_repos_command(
for repository in selected: for repository in selected:
try: try:
repo_dir = _resolve_repository_directory(repository, ctx) repo_dir = _resolve_repository_directory(repository, ctx)
except Exception as exc: except (AttributeError, KeyError, TypeError, ValueError) as exc:
ident = ( ident = (
f"{repository.get('provider', '?')}/" f"{repository.get('provider', '?')}/"
f"{repository.get('account', '?')}/" f"{repository.get('account', '?')}/"

View File

@@ -2,9 +2,10 @@ from __future__ import annotations
import os import os
import sys import sys
from typing import Any, Dict, List, Optional, Tuple from typing import Any
from pkgmgr.cli.context import CLIContext from pkgmgr.cli.context import CLIContext
from pkgmgr.core.git import GitRunError
from pkgmgr.core.git.queries import get_tags from pkgmgr.core.git.queries import get_tags
from pkgmgr.core.repository.dir import get_repo_dir from pkgmgr.core.repository.dir import get_repo_dir
from pkgmgr.core.repository.identifier import get_repo_identifier from pkgmgr.core.repository.identifier import get_repo_identifier
@@ -116,7 +117,7 @@ def handle_version(
if not repo_dir: if not repo_dir:
try: try:
repo_dir = get_repo_dir(ctx.repositories_base_dir, repo) repo_dir = get_repo_dir(ctx.repositories_base_dir, repo)
except Exception: except (AttributeError, KeyError, TypeError):
repo_dir = None repo_dir = None
if not repo_dir or not os.path.isdir(repo_dir): if not repo_dir or not os.path.isdir(repo_dir):
@@ -150,11 +151,11 @@ def handle_version(
try: try:
tags = get_tags(cwd=repo_dir) tags = get_tags(cwd=repo_dir)
except Exception as exc: except GitRunError as exc:
print(f"[ERROR] Could not read git tags: {exc}") print(f"[ERROR] Could not read git tags: {exc}")
tags = [] tags = []
latest_tag_info: Optional[Tuple[str, SemVer]] = ( latest_tag_info: tuple[str, SemVer] | None = (
find_latest_version(tags) if tags else None find_latest_version(tags) if tags else None
) )

View File

@@ -43,7 +43,7 @@ def _select_repo_for_current_directory(ctx: CLIContext) -> list[dict[str, Any]]:
if not repo_dir: if not repo_dir:
try: try:
repo_dir = get_repo_dir(ctx.repositories_base_dir, repo) repo_dir = get_repo_dir(ctx.repositories_base_dir, repo)
except Exception: except (AttributeError, KeyError, TypeError):
continue continue
repo_dir = os.path.abspath(os.path.expanduser(repo_dir)) repo_dir = os.path.abspath(os.path.expanduser(repo_dir))

View File

@@ -117,20 +117,20 @@ def _proxy_has_explicit_selection(args: argparse.Namespace) -> bool:
def _select_repo_for_current_directory( def _select_repo_for_current_directory(
ctx: CLIContext, ctx: CLIContext,
) -> List[Dict[str, Any]]: ) -> list[dict[str, Any]]:
""" """
Heuristic: find the repository whose local directory matches the Heuristic: find the repository whose local directory matches the
current working directory or is the closest parent. current working directory or is the closest parent.
""" """
cwd = os.path.abspath(os.getcwd()) cwd = os.path.abspath(os.getcwd())
candidates: List[tuple[str, Dict[str, Any]]] = [] candidates: list[tuple[str, dict[str, Any]]] = []
for repo in ctx.all_repositories: for repo in ctx.all_repositories:
repo_dir = repo.get("directory") repo_dir = repo.get("directory")
if not repo_dir: if not repo_dir:
try: try:
repo_dir = get_repo_dir(ctx.repositories_base_dir, repo) repo_dir = get_repo_dir(ctx.repositories_base_dir, repo)
except Exception: except (AttributeError, KeyError, TypeError):
repo_dir = None repo_dir = None
if not repo_dir: if not repo_dir:
continue continue

View File

@@ -69,7 +69,7 @@ def create_ink(
try: try:
if os.path.realpath(command).startswith(os.path.realpath(repo_dir)): if os.path.realpath(command).startswith(os.path.realpath(repo_dir)):
os.chmod(command, 0o755) os.chmod(command, 0o755)
except Exception as e: except OSError as e:
if not quiet: if not quiet:
print(f"Failed to set permissions on '{command}': {e}") print(f"Failed to set permissions on '{command}': {e}")
@@ -106,6 +106,6 @@ def create_ink(
os.symlink(link_path, alias_link_path) os.symlink(link_path, alias_link_path)
if not quiet: if not quiet:
print(f"Alias '{alias_name}' created → {repo_identifier}") print(f"Alias '{alias_name}' created → {repo_identifier}")
except Exception as e: except OSError as e:
if not quiet: if not quiet:
print(f"Error creating alias '{alias_name}': {e}") print(f"Error creating alias '{alias_name}': {e}")

View File

@@ -61,10 +61,8 @@ def run_command(
line = stream.readline() line = stream.readline()
if line == "": if line == "":
# EOF: stop watching this stream # EOF: stop watching this stream
try: with contextlib.suppress(Exception):
sel.unregister(stream) sel.unregister(stream)
except Exception:
pass
continue continue
if which == "stdout": if which == "stdout":
@@ -78,14 +76,10 @@ def run_command(
try: try:
sel.close() sel.close()
finally: finally:
try: with contextlib.suppress(Exception):
process.stdout.close() process.stdout.close()
except Exception: with contextlib.suppress(Exception):
pass
try:
process.stderr.close() process.stderr.close()
except Exception:
pass
returncode = process.wait() returncode = process.wait()

View File

@@ -194,11 +194,11 @@ def _load_defaults_from_package_or_project() -> dict[str, Any]:
""" """
try: try:
import pkgmgr # type: ignore import pkgmgr # type: ignore
except Exception: except ImportError:
return {"directories": {}, "repositories": []} return {"directories": {}, "repositories": []}
pkg_root = Path(pkgmgr.__file__).resolve().parent pkg_root = Path(pkgmgr.__file__).resolve().parent
candidates: List[Path] = [] candidates: list[Path] = []
# Always prefer package-internal config dir # Always prefer package-internal config dir
candidates.append(pkg_root / "config") candidates.append(pkg_root / "config")

View File

@@ -34,7 +34,7 @@ class GhTokenProvider:
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
text=True, text=True,
).strip() ).strip()
except Exception: except (OSError, subprocess.SubprocessError):
return None return None
if not out: if not out:

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import http.client
import json import json
import urllib.request import urllib.request
@@ -32,7 +33,7 @@ def validate_token(provider_kind: str, host: str, token: str) -> bool:
# Optional: parse to ensure body is JSON # Optional: parse to ensure body is JSON
_ = json.loads(resp.read().decode("utf-8")) _ = json.loads(resp.read().decode("utf-8"))
return True return True
except Exception: except (OSError, http.client.HTTPException, json.JSONDecodeError, UnicodeDecodeError):
return False return False
# Unknown provider: don't hard-fail validation (conservative default) # Unknown provider: don't hard-fail validation (conservative default)

View File

@@ -50,19 +50,19 @@ class HttpClient:
) as resp: ) as resp:
raw = resp.read().decode("utf-8", errors="replace") raw = resp.read().decode("utf-8", errors="replace")
parsed: Optional[Dict[str, Any]] = None parsed: dict[str, Any] | None = None
if raw: if raw:
try: try:
loaded = json.loads(raw) loaded = json.loads(raw)
parsed = loaded if isinstance(loaded, dict) else None parsed = loaded if isinstance(loaded, dict) else None
except Exception: except json.JSONDecodeError:
parsed = None parsed = None
return HttpResponse(status=int(resp.status), text=raw, json=parsed) return HttpResponse(status=int(resp.status), text=raw, json=parsed)
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
try: try:
body = exc.read().decode("utf-8", errors="replace") body = exc.read().decode("utf-8", errors="replace")
except Exception: except (OSError, ValueError):
body = "" body = ""
raise HttpError(status=int(exc.code), message=str(exc), body=body) from exc raise HttpError(status=int(exc.code), message=str(exc), body=body) from exc
except urllib.error.URLError as exc: except urllib.error.URLError as exc:

View File

@@ -12,18 +12,18 @@ from .providers.github import GitHubProvider
class ProviderRegistry: class ProviderRegistry:
"""Resolve the correct provider implementation for a host.""" """Resolve the correct provider implementation for a host."""
providers: List[RemoteProvider] providers: list[RemoteProvider]
@classmethod @classmethod
def default(cls) -> ProviderRegistry: def default(cls) -> ProviderRegistry:
# Order matters: more specific providers first; fallback providers last. # Order matters: more specific providers first; fallback providers last.
return cls(providers=[GitHubProvider(), GiteaProvider()]) return cls(providers=[GitHubProvider(), GiteaProvider()])
def resolve(self, host: str) -> Optional[RemoteProvider]: def resolve(self, host: str) -> RemoteProvider | None:
for p in self.providers: for p in self.providers:
try: try:
if p.can_handle(host): if p.can_handle(host):
return p return p
except Exception: except (AttributeError, TypeError, ValueError):
continue continue
return None return None

View File

@@ -46,7 +46,7 @@ def get_installed_python_version(*candidates: str) -> InstalledVersion | None:
""" """
try: try:
from importlib import metadata as importlib_metadata from importlib import metadata as importlib_metadata
except Exception: except ImportError:
return None return None
candidates = _unique_candidates(candidates) candidates = _unique_candidates(candidates)
@@ -62,13 +62,13 @@ def get_installed_python_version(*candidates: str) -> InstalledVersion | None:
try: try:
version = importlib_metadata.version(name) version = importlib_metadata.version(name)
return InstalledVersion(name=name, version=version) return InstalledVersion(name=name, version=version)
except Exception: except importlib_metadata.PackageNotFoundError:
continue continue
# 2) Fallback: scan distributions (last resort) # 2) Fallback: scan distributions (last resort)
try: try:
dists = importlib_metadata.distributions() dists = importlib_metadata.distributions()
except Exception: except (OSError, ImportError):
return None return None
norm_candidates = {_normalize(c) for c in candidates} norm_candidates = {_normalize(c) for c in candidates}
@@ -145,7 +145,7 @@ def get_installed_nix_profile_version(*candidates: str) -> InstalledVersion | No
guess = _extract_version_from_store_path(sp) guess = _extract_version_from_store_path(sp)
if guess: if guess:
return InstalledVersion(name=name, version=guess) return InstalledVersion(name=name, version=guess)
except Exception: except (json.JSONDecodeError, AttributeError):
pass pass
# Fallback: text mode # Fallback: text mode

View File

@@ -22,7 +22,7 @@ def read_pyproject_version(repo_dir: str) -> str | None:
try: try:
import tomllib # Python 3.11+ import tomllib # Python 3.11+
except Exception: except ImportError:
import tomli as tomllib # type: ignore import tomli as tomllib # type: ignore
try: try:
@@ -31,11 +31,11 @@ def read_pyproject_version(repo_dir: str) -> str | None:
project = data.get("project") or {} project = data.get("project") or {}
version = project.get("version") version = project.get("version")
return str(version).strip() if version else None return str(version).strip() if version else None
except Exception: except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError):
return None return None
def read_pyproject_project_name(repo_dir: str) -> Optional[str]: def read_pyproject_project_name(repo_dir: str) -> str | None:
""" """
Read distribution name from pyproject.toml ([project].name). Read distribution name from pyproject.toml ([project].name).
@@ -49,7 +49,7 @@ def read_pyproject_project_name(repo_dir: str) -> Optional[str]:
try: try:
import tomllib # Python 3.11+ import tomllib # Python 3.11+
except Exception: except ImportError:
import tomli as tomllib # type: ignore import tomli as tomllib # type: ignore
try: try:
@@ -58,11 +58,11 @@ def read_pyproject_project_name(repo_dir: str) -> Optional[str]:
project = data.get("project") or {} project = data.get("project") or {}
name = project.get("name") name = project.get("name")
return str(name).strip() if name else None return str(name).strip() if name else None
except Exception: except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError):
return None return None
def read_flake_version(repo_dir: str) -> Optional[str]: def read_flake_version(repo_dir: str) -> str | None:
""" """
Read the version from flake.nix in repo_dir, if present. Read the version from flake.nix in repo_dir, if present.
@@ -75,9 +75,9 @@ def read_flake_version(repo_dir: str) -> Optional[str]:
return None return None
try: try:
with open(path, "r", encoding="utf-8") as f: with open(path, encoding="utf-8") as f:
text = f.read() text = f.read()
except Exception: except (OSError, UnicodeDecodeError):
return None return None
match = re.search(r'version\s*=\s*"([^"]+)"', text) match = re.search(r'version\s*=\s*"([^"]+)"', text)
@@ -102,9 +102,9 @@ def read_pkgbuild_version(repo_dir: str) -> str | None:
return None return None
try: try:
with open(path, "r", encoding="utf-8") as f: with open(path, encoding="utf-8") as f:
text = f.read() text = f.read()
except Exception: except (OSError, UnicodeDecodeError):
return None return None
ver_match = re.search(r"^pkgver\s*=\s*(.+)$", text, re.MULTILINE) ver_match = re.search(r"^pkgver\s*=\s*(.+)$", text, re.MULTILINE)
@@ -148,13 +148,13 @@ def read_debian_changelog_version(repo_dir: str) -> str | None:
if match: if match:
return match.group(1).strip() or None return match.group(1).strip() or None
break break
except Exception: except (OSError, UnicodeDecodeError):
return None return None
return None return None
def read_spec_version(repo_dir: str) -> Optional[str]: def read_spec_version(repo_dir: str) -> str | None:
""" """
Read the version from an RPM spec file. Read the version from an RPM spec file.
@@ -174,9 +174,9 @@ def read_spec_version(repo_dir: str) -> Optional[str]:
return None return None
try: try:
with open(path, "r", encoding="utf-8") as f: with open(path, encoding="utf-8") as f:
text = f.read() text = f.read()
except Exception: except (OSError, UnicodeDecodeError):
return None return None
ver_match = re.search(r"^Version:\s*(.+)$", text, re.MULTILINE) ver_match = re.search(r"^Version:\s*(.+)$", text, re.MULTILINE)
@@ -205,18 +205,18 @@ def read_ansible_galaxy_version(repo_dir: str) -> str | None:
galaxy_yml = os.path.join(repo_dir, "galaxy.yml") galaxy_yml = os.path.join(repo_dir, "galaxy.yml")
if os.path.isfile(galaxy_yml): if os.path.isfile(galaxy_yml):
try: try:
with open(galaxy_yml, "r", encoding="utf-8") as f: with open(galaxy_yml, encoding="utf-8") as f:
data = yaml.safe_load(f) or {} data = yaml.safe_load(f) or {}
version = data.get("version") version = data.get("version")
if isinstance(version, str) and version.strip(): if isinstance(version, str) and version.strip():
return version.strip() return version.strip()
except Exception: except (OSError, UnicodeDecodeError, yaml.YAMLError):
pass pass
meta_yml = os.path.join(repo_dir, "meta", "main.yml") meta_yml = os.path.join(repo_dir, "meta", "main.yml")
if os.path.isfile(meta_yml): if os.path.isfile(meta_yml):
try: try:
with open(meta_yml, "r", encoding="utf-8") as f: with open(meta_yml, encoding="utf-8") as f:
data = yaml.safe_load(f) or {} data = yaml.safe_load(f) or {}
galaxy_info = data.get("galaxy_info") or {} galaxy_info = data.get("galaxy_info") or {}
@@ -228,7 +228,7 @@ def read_ansible_galaxy_version(repo_dir: str) -> str | None:
version = data.get("version") version = data.get("version")
if isinstance(version, str) and version.strip(): if isinstance(version, str) and version.strip():
return version.strip() return version.strip()
except Exception: except (OSError, UnicodeDecodeError, yaml.YAMLError):
return None return None
return None return None

View File

@@ -21,7 +21,7 @@ class TestIntegrationChangelogCommands(unittest.TestCase):
""" """
try: try:
repo_dir = _load_pkgmgr_repo_dir() repo_dir = _load_pkgmgr_repo_dir()
except Exception: except (OSError, RuntimeError, ValueError):
repo_dir = None repo_dir = None
if repo_dir is not None and not os.path.isdir(repo_dir): if repo_dir is not None and not os.path.isdir(repo_dir):

View File

@@ -0,0 +1,766 @@
"""Error-path tests: pkgmgr must degrade, not crash, on broken input.
pkgmgr probes external toolchains and parses packaging metadata it does
not control. Every test here injects one concrete failure -- undecodable
bytes, malformed YAML, a missing binary, an unreachable host -- and
asserts the documented fallback: return None, warn and continue, or skip
the entry.
Faults are injected for real wherever possible rather than mocked, so the
exception types listed in the handlers are the ones actually raised.
"""
from __future__ import annotations
import importlib.util
import io
import os
import subprocess
import tempfile
import unittest
import urllib.error
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
BAD_UTF8 = b"\xff\xfe\x00\x80version = 1"
# tomli is only a dependency below Python 3.11, so the fallback branch is
# only exercisable where it is actually installed.
HAS_TOMLI = importlib.util.find_spec("tomli") is not None
def _write(path: str, data: bytes | str) -> str:
mode = "wb" if isinstance(data, bytes) else "w"
with open(path, mode) as handle:
handle.write(data)
return path
class TestVersionSourceDegradation(unittest.TestCase):
"""src/pkgmgr/core/version/source.py"""
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.dir = self.tmp.name
def test_broken_toml_returns_none(self) -> None:
from pkgmgr.core.version.source import (
read_pyproject_project_name,
read_pyproject_version,
)
_write(os.path.join(self.dir, "pyproject.toml"), "this is [[[ not toml")
self.assertIsNone(read_pyproject_version(self.dir))
self.assertIsNone(read_pyproject_project_name(self.dir))
def test_undecodable_toml_returns_none(self) -> None:
from pkgmgr.core.version.source import read_pyproject_version
_write(os.path.join(self.dir, "pyproject.toml"), BAD_UTF8)
self.assertIsNone(read_pyproject_version(self.dir))
def test_unreadable_pyproject_returns_none(self) -> None:
from pkgmgr.core.version.source import read_pyproject_version
_write(os.path.join(self.dir, "pyproject.toml"), '[project]\nversion = "1.0.0"\n')
with patch("builtins.open", side_effect=OSError("EIO")):
self.assertIsNone(read_pyproject_version(self.dir))
def test_undecodable_flake_returns_none(self) -> None:
from pkgmgr.core.version.source import read_flake_version
_write(os.path.join(self.dir, "flake.nix"), BAD_UTF8)
self.assertIsNone(read_flake_version(self.dir))
def test_undecodable_pkgbuild_returns_none(self) -> None:
from pkgmgr.core.version.source import read_pkgbuild_version
_write(os.path.join(self.dir, "PKGBUILD"), BAD_UTF8)
self.assertIsNone(read_pkgbuild_version(self.dir))
def test_undecodable_debian_changelog_returns_none(self) -> None:
from pkgmgr.core.version.source import read_debian_changelog_version
os.makedirs(os.path.join(self.dir, "debian"), exist_ok=True)
_write(os.path.join(self.dir, "debian", "changelog"), BAD_UTF8)
self.assertIsNone(read_debian_changelog_version(self.dir))
def test_undecodable_spec_returns_none(self) -> None:
from pkgmgr.core.version.source import read_spec_version
_write(os.path.join(self.dir, "pkg.spec"), BAD_UTF8)
self.assertIsNone(read_spec_version(self.dir))
def test_broken_galaxy_yaml_returns_none(self) -> None:
from pkgmgr.core.version.source import read_ansible_galaxy_version
_write(os.path.join(self.dir, "galaxy.yml"), "::: not: valid: yaml:::\n - [")
self.assertIsNone(read_ansible_galaxy_version(self.dir))
def test_broken_meta_main_yaml_returns_none(self) -> None:
from pkgmgr.core.version.source import read_ansible_galaxy_version
os.makedirs(os.path.join(self.dir, "meta"), exist_ok=True)
_write(os.path.join(self.dir, "meta", "main.yml"), "a: [1,\nb: }{")
self.assertIsNone(read_ansible_galaxy_version(self.dir))
@unittest.skipUnless(HAS_TOMLI, "tomli is not installed")
def test_missing_tomllib_falls_back_to_tomli(self) -> None:
import builtins
from pkgmgr.core.version.source import read_pyproject_version
_write(os.path.join(self.dir, "pyproject.toml"), '[project]\nversion = "9.9.9"\n')
real_import = builtins.__import__
def fake_import(name, *args, **kwargs):
if name == "tomllib":
raise ImportError("no tomllib on this interpreter")
return real_import(name, *args, **kwargs)
with patch("builtins.__import__", side_effect=fake_import):
self.assertEqual(read_pyproject_version(self.dir), "9.9.9")
class TestInstalledVersionDegradation(unittest.TestCase):
"""src/pkgmgr/core/version/installed.py"""
def test_unknown_distribution_returns_none(self) -> None:
from pkgmgr.core.version.installed import get_installed_python_version
self.assertIsNone(get_installed_python_version("no-such-distribution-xyz"))
def test_missing_importlib_metadata_returns_none(self) -> None:
import builtins
from pkgmgr.core.version.installed import get_installed_python_version
real_import = builtins.__import__
def fake_import(name, globals=None, locals=None, fromlist=(), level=0):
if name == "importlib" and fromlist and "metadata" in fromlist:
raise ImportError("no importlib.metadata")
return real_import(name, globals, locals, fromlist, level)
with patch("builtins.__import__", side_effect=fake_import):
self.assertIsNone(get_installed_python_version("kpmx"))
def test_broken_distribution_scan_returns_none(self) -> None:
from importlib import metadata as importlib_metadata
from pkgmgr.core.version.installed import get_installed_python_version
with (
patch.object(
importlib_metadata,
"version",
side_effect=importlib_metadata.PackageNotFoundError("nope"),
),
patch.object(
importlib_metadata, "distributions", side_effect=OSError("broken env")
),
):
self.assertIsNone(get_installed_python_version("kpmx"))
def test_non_json_nix_profile_output_falls_back_to_text_mode(self) -> None:
import pkgmgr.core.version.installed as mod
with (
patch.object(mod.shutil, "which", return_value="/usr/bin/nix"),
patch.object(mod, "_run_nix", return_value=(0, "not json at all", "")),
):
self.assertIsNone(mod.get_installed_nix_profile_version("pkgmgr"))
def test_json_list_instead_of_object_is_tolerated(self) -> None:
import pkgmgr.core.version.installed as mod
# Valid JSON, wrong shape: data.get() raises AttributeError on a list.
with (
patch.object(mod.shutil, "which", return_value="/usr/bin/nix"),
patch.object(mod, "_run_nix", return_value=(0, '["a", "b"]', "")),
):
self.assertIsNone(mod.get_installed_nix_profile_version("pkgmgr"))
class TestNixProfileParsingDegradation(unittest.TestCase):
"""nix profile normalizer and list reader"""
def test_coerce_index_rejects_oversized_digit_strings(self) -> None:
from pkgmgr.actions.install.installers.nix.profile.normalizer import (
coerce_index,
)
# str.isdigit() accepts characters int() refuses, e.g. superscripts.
self.assertIsNone(coerce_index("²³", {}))
self.assertIsNone(coerce_index("name-x", {"index": "²"}))
def test_profile_list_skips_unparsable_index(self) -> None:
from pkgmgr.actions.install.installers.nix.profile_list import (
NixProfileListReader,
)
runner = MagicMock()
runner.run.return_value = SimpleNamespace(
returncode=0,
stdout=" 7 /nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-demo\n",
)
entries = NixProfileListReader(runner).entries(SimpleNamespace())
self.assertEqual(
entries,
[(7, "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-demo")],
)
class TestReleaseFileDegradation(unittest.TestCase):
"""src/pkgmgr/actions/release/files/*"""
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.dir = self.tmp.name
def test_undecodable_flake_is_reported_not_raised(self) -> None:
from pkgmgr.actions.release.files.flake import update_flake_version
path = _write(os.path.join(self.dir, "flake.nix"), BAD_UTF8)
buf = io.StringIO()
with patch("sys.stdout", buf):
update_flake_version(path, "2.0.0")
self.assertIn("Could not read flake.nix", buf.getvalue())
def test_undecodable_pkgbuild_is_reported_not_raised(self) -> None:
from pkgmgr.actions.release.files.pkgbuild import update_pkgbuild_version
path = _write(os.path.join(self.dir, "PKGBUILD"), BAD_UTF8)
buf = io.StringIO()
with patch("sys.stdout", buf):
update_pkgbuild_version(path, "2.0.0")
self.assertIn("Could not read PKGBUILD", buf.getvalue())
def test_undecodable_spec_is_reported_not_raised(self) -> None:
from pkgmgr.actions.release.files.rpm_spec import update_spec_version
path = _write(os.path.join(self.dir, "pkg.spec"), BAD_UTF8)
buf = io.StringIO()
with patch("sys.stdout", buf):
update_spec_version(path, "2.0.0")
self.assertIn("Could not read spec file", buf.getvalue())
class TestMirrorConfigDegradation(unittest.TestCase):
"""src/pkgmgr/actions/mirror/merge_cmd.py"""
def test_broken_user_config_yields_empty_mapping(self) -> None:
from pkgmgr.actions.mirror.merge_cmd import _load_user_config
with tempfile.TemporaryDirectory() as tmp:
path = _write(os.path.join(tmp, "config.yaml"), "a: [1,\nb: }{")
self.assertEqual(_load_user_config(path), {})
undecodable = _write(os.path.join(tmp, "bad.yaml"), BAD_UTF8)
self.assertEqual(_load_user_config(undecodable), {})
class TestRepositoryDeleteDegradation(unittest.TestCase):
"""src/pkgmgr/actions/repository/delete.py"""
def test_rmtree_failure_is_reported_not_raised(self) -> None:
import pkgmgr.actions.repository.delete as mod
repo = {"provider": "github.com", "account": "a", "repository": "r"}
buf = io.StringIO()
with (
patch.object(mod.shutil, "rmtree", side_effect=OSError("device busy")),
patch.object(mod.os.path, "exists", return_value=True),
patch.object(mod, "get_repo_dir", return_value="/repos/a/r"),
patch("builtins.input", return_value="y"),
patch("sys.stdout", buf),
):
mod.delete_repos([repo], "/repos", [repo], preview=False)
self.assertIn("Error deleting", buf.getvalue())
class TestInkDegradation(unittest.TestCase):
"""src/pkgmgr/core/command/ink.py"""
def test_chmod_failure_is_reported_not_raised(self) -> None:
import pkgmgr.core.command.ink as mod
with tempfile.TemporaryDirectory() as tmp:
repo_dir = os.path.join(tmp, "p", "a", "repo")
bin_dir = os.path.join(tmp, "bin")
os.makedirs(repo_dir)
_write(os.path.join(repo_dir, "main.py"), "print()\n")
repo = {
"provider": "p",
"account": "a",
"repository": "repo",
"command": os.path.join(repo_dir, "main.py"),
}
buf = io.StringIO()
with (
patch.object(mod.os, "chmod", side_effect=OSError("read-only fs")),
patch("sys.stdout", buf),
):
mod.create_ink(
repo,
tmp,
bin_dir,
[repo],
quiet=False,
preview=False,
)
self.assertIn("Failed to set permissions", buf.getvalue())
class TestHttpClientDegradation(unittest.TestCase):
"""src/pkgmgr/core/remote_provisioning/http/client.py"""
def test_non_json_body_is_returned_as_text(self) -> None:
from pkgmgr.core.remote_provisioning.http.client import HttpClient
resp = MagicMock()
resp.read.return_value = b"<html>not json</html>"
resp.status = 200
resp.__enter__ = lambda s: s
resp.__exit__ = lambda *a: False
with patch(
"pkgmgr.core.remote_provisioning.http.client.urllib.request.urlopen",
return_value=resp,
):
out = HttpClient().request_json("GET", "https://example.invalid/x")
self.assertIsNone(out.json)
self.assertIn("not json", out.text)
def test_unreadable_error_body_degrades_to_empty_string(self) -> None:
from pkgmgr.core.remote_provisioning.http.client import HttpClient
from pkgmgr.core.remote_provisioning.http.errors import HttpError
class BrokenBody(io.RawIOBase):
def read(self, *args):
raise OSError("socket closed")
err = urllib.error.HTTPError(
url="https://example.invalid/x",
code=503,
msg="nope",
hdrs={},
fp=BrokenBody(),
)
with (
patch(
"pkgmgr.core.remote_provisioning.http.client.urllib.request.urlopen",
side_effect=err,
),
self.assertRaises(HttpError) as ctx,
):
HttpClient().request_json("GET", "https://example.invalid/x")
self.assertEqual(ctx.exception.status, 503)
self.assertEqual(ctx.exception.body, "")
class TestProviderRegistryDegradation(unittest.TestCase):
"""src/pkgmgr/core/remote_provisioning/registry.py"""
def test_misbehaving_provider_is_skipped(self) -> None:
from pkgmgr.core.remote_provisioning.registry import ProviderRegistry
class Broken:
def can_handle(self, host):
raise TypeError("host is not what I expected")
class Good:
def can_handle(self, host):
return True
good = Good()
self.assertIs(ProviderRegistry(providers=[Broken(), good]).resolve("x"), good)
def test_registry_returns_none_when_all_providers_break(self) -> None:
from pkgmgr.core.remote_provisioning.registry import ProviderRegistry
class Broken:
def can_handle(self, host):
raise AttributeError("nope")
self.assertIsNone(ProviderRegistry(providers=[Broken()]).resolve("x"))
class TestCredentialProviderDegradation(unittest.TestCase):
"""gh token provider and token validation"""
def test_missing_gh_binary_returns_none(self) -> None:
import pkgmgr.core.credentials.providers.gh as mod
from pkgmgr.core.credentials.types import TokenRequest
req = TokenRequest(provider_kind="github", host="github.com", owner=None)
with (
patch.object(mod.shutil, "which", return_value="/usr/bin/gh"),
patch.object(
mod.subprocess,
"check_output",
side_effect=FileNotFoundError("gh vanished"),
),
):
self.assertIsNone(mod.GhTokenProvider().get(req))
def test_failing_gh_command_returns_none(self) -> None:
import pkgmgr.core.credentials.providers.gh as mod
from pkgmgr.core.credentials.types import TokenRequest
req = TokenRequest(provider_kind="github", host="github.com", owner=None)
with (
patch.object(mod.shutil, "which", return_value="/usr/bin/gh"),
patch.object(
mod.subprocess,
"check_output",
side_effect=subprocess.CalledProcessError(1, ["gh"]),
),
):
self.assertIsNone(mod.GhTokenProvider().get(req))
def test_unreachable_host_makes_token_invalid(self) -> None:
import pkgmgr.core.credentials.validate as mod
with patch.object(
mod.urllib.request, "urlopen", side_effect=urllib.error.URLError("no route")
):
self.assertFalse(mod.validate_token("github", "github.com", "tok"))
def test_non_json_validation_body_makes_token_invalid(self) -> None:
import pkgmgr.core.credentials.validate as mod
resp = MagicMock()
resp.status = 200
resp.read.return_value = b"<html>gateway</html>"
resp.__enter__ = lambda s: s
resp.__exit__ = lambda *a: False
with patch.object(mod.urllib.request, "urlopen", return_value=resp):
self.assertFalse(mod.validate_token("github", "github.com", "tok"))
class TestConfigLoadDegradation(unittest.TestCase):
"""src/pkgmgr/core/config/load.py"""
def test_missing_pkgmgr_package_yields_empty_defaults(self) -> None:
import builtins
from pkgmgr.core.config.load import _load_defaults_from_package_or_project
real_import = builtins.__import__
def fake_import(name, *args, **kwargs):
if name == "pkgmgr":
raise ImportError("not installed")
return real_import(name, *args, **kwargs)
with patch("builtins.__import__", side_effect=fake_import):
self.assertEqual(
_load_defaults_from_package_or_project(),
{"directories": {}, "repositories": []},
)
class TestArchPkgbuildDegradation(unittest.TestCase):
"""src/pkgmgr/actions/install/installers/os_packages/arch_pkgbuild.py"""
def test_missing_geteuid_does_not_break_supports(self) -> None:
import pkgmgr.actions.install.installers.os_packages.arch_pkgbuild as mod
ctx = SimpleNamespace(repo_dir="/repo")
with (
patch.object(mod.os, "geteuid", side_effect=OSError("no euid here")),
patch.object(mod.shutil, "which", return_value=None),
):
self.assertFalse(mod.ArchPkgbuildInstaller().supports(ctx))
class TestCliDirectoryResolutionDegradation(unittest.TestCase):
"""CLI commands resolving repository directories from malformed config"""
def test_dispatch_skips_malformed_repository_entries(self) -> None:
import pkgmgr.cli.dispatch as mod
ctx = SimpleNamespace(
all_repositories=[{"provider": "github.com"}],
repositories_base_dir="/repos",
)
with patch.object(mod, "get_repo_dir", side_effect=KeyError("account")):
self.assertEqual(mod._select_repo_for_current_directory(ctx), [])
def test_proxy_skips_malformed_repository_entries(self) -> None:
import pkgmgr.cli.proxy as mod
ctx = SimpleNamespace(
all_repositories=[{"provider": "github.com"}],
repositories_base_dir="/repos",
)
with patch.object(mod, "get_repo_dir", side_effect=TypeError("bad entry")):
self.assertEqual(mod._select_repo_for_current_directory(ctx), [])
class TestGitTagQueryDegradation(unittest.TestCase):
"""changelog and version CLI reading git tags outside a repository"""
def test_changelog_reports_unreadable_tags(self) -> None:
import pkgmgr.cli.commands.changelog as mod
from pkgmgr.core.git import GitRunError
with tempfile.TemporaryDirectory() as tmp:
ctx = SimpleNamespace(
all_repositories=[],
repositories_base_dir=tmp,
config_merged={},
)
repo = {"directory": tmp, "repository": "demo"}
buf = io.StringIO()
with (
patch.object(mod, "get_tags", side_effect=GitRunError("not a git repo")),
patch.object(mod, "get_repo_identifier", return_value="demo"),
patch.object(mod, "generate_changelog", return_value=""),
patch("sys.stdout", buf),
):
mod.handle_changelog(SimpleNamespace(command="show"), ctx, [repo])
self.assertIn("Could not read git tags", buf.getvalue())
if __name__ == "__main__":
unittest.main()
class TestReleaseFileWriteDegradation(unittest.TestCase):
"""release file updaters that read or write packaging metadata"""
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.dir = self.tmp.name
def test_undecodable_changelog_is_reported_and_rewritten(self) -> None:
from pkgmgr.actions.release.files.changelog_md import update_changelog
path = _write(os.path.join(self.dir, "CHANGELOG.md"), BAD_UTF8)
buf = io.StringIO()
with patch("sys.stdout", buf):
update_changelog(path, "2.0.0", message="Fix things", preview=True)
self.assertIn("Could not read existing CHANGELOG.md", buf.getvalue())
def test_undecodable_debian_changelog_is_reported(self) -> None:
from pkgmgr.actions.release.files.debian import update_debian_changelog
path = _write(os.path.join(self.dir, "changelog"), BAD_UTF8)
buf = io.StringIO()
with patch("sys.stdout", buf):
update_debian_changelog(path, "demo", "2.0.0", message="Fix", preview=False)
self.assertIn("Could not read debian/changelog", buf.getvalue())
def test_undecodable_spec_changelog_is_reported(self) -> None:
from pkgmgr.actions.release.files.rpm_changelog import update_spec_changelog
path = _write(os.path.join(self.dir, "pkg.spec"), BAD_UTF8)
buf = io.StringIO()
with patch("sys.stdout", buf):
update_spec_changelog(path, "demo", "2.0.0", message="Fix", preview=False)
self.assertIn("Could not read spec file for changelog update", buf.getvalue())
def test_unwritable_spec_changelog_is_reported(self) -> None:
import pkgmgr.actions.release.files.rpm_changelog as mod
path = _write(
os.path.join(self.dir, "pkg.spec"),
"Name: demo\nVersion: 1.0.0\n%changelog\n",
)
real_open = open
buf = io.StringIO()
def failing_open(file, mode="r", *args, **kwargs):
if "w" in mode:
raise OSError("disk full")
return real_open(file, mode, *args, **kwargs)
with patch("builtins.open", side_effect=failing_open), patch("sys.stdout", buf):
mod.update_spec_changelog(path, "demo", "2.0.0", message="Fix")
self.assertIn("Failed to write updated spec changelog", buf.getvalue())
class TestTemplateImportDegradation(unittest.TestCase):
"""src/pkgmgr/actions/repository/create/templates.py"""
def test_module_records_missing_jinja2(self) -> None:
import pkgmgr.actions.repository.create.templates as mod
self.assertTrue(hasattr(mod, "_JINJA_IMPORT_ERROR"))
if mod.Environment is None:
self.assertIsNotNone(mod._JINJA_IMPORT_ERROR)
else:
self.assertIsNone(mod._JINJA_IMPORT_ERROR)
class TestCliRepoDirDegradation(unittest.TestCase):
"""CLI handlers resolving repo dirs from malformed entries"""
def _ctx(self):
return SimpleNamespace(
all_repositories=[],
repositories_base_dir="/repos",
binaries_dir="/bin",
config_merged={},
)
def test_changelog_skips_unresolvable_directory(self) -> None:
import pkgmgr.cli.commands.changelog as mod
buf = io.StringIO()
with (
patch.object(mod, "get_repo_dir", side_effect=KeyError("account")),
patch.object(mod, "get_repo_identifier", return_value="demo"),
patch("sys.stdout", buf),
):
mod.handle_changelog(
SimpleNamespace(range=""), self._ctx(), [{"repository": "demo"}]
)
self.assertIn("Skipped", buf.getvalue())
def test_version_skips_unresolvable_directory(self) -> None:
import pkgmgr.cli.commands.version as mod
buf = io.StringIO()
with (
patch.object(mod, "get_repo_dir", side_effect=TypeError("bad entry")),
patch.object(mod, "get_repo_identifier", return_value="demo"),
patch("sys.stdout", buf),
):
mod.handle_version(
SimpleNamespace(), self._ctx(), [{"repository": "demo"}]
)
self.assertIn("Skipped", buf.getvalue())
def test_release_skips_unresolvable_directory(self) -> None:
import pkgmgr.cli.commands.release as mod
buf = io.StringIO()
with (
patch.object(mod, "get_repo_dir", side_effect=AttributeError("nope")),
patch.object(mod, "get_repo_identifier", return_value="demo"),
patch("sys.stdout", buf),
):
mod.handle_release(
SimpleNamespace(list=False), self._ctx(), [{"repository": "demo"}]
)
self.assertIn("failed to resolve directory", buf.getvalue())
def test_repos_path_reports_unresolvable_directory(self) -> None:
import pkgmgr.cli.commands.repos as mod
buf = io.StringIO()
with (
patch.object(
mod, "_resolve_repository_directory", side_effect=ValueError("bad")
),
patch("sys.stdout", buf),
):
mod.handle_repos_command(
SimpleNamespace(command="path"), self._ctx(), [{"repository": "demo"}]
)
self.assertIn("Could not resolve directory", buf.getvalue())
def test_version_reports_unreadable_tags(self) -> None:
import pkgmgr.cli.commands.version as mod
from pkgmgr.core.git import GitRunError
with tempfile.TemporaryDirectory() as tmp:
buf = io.StringIO()
with (
patch.object(mod, "get_tags", side_effect=GitRunError("no repo")),
patch.object(mod, "get_repo_identifier", return_value="demo"),
patch("sys.stdout", buf),
):
mod.handle_version(
SimpleNamespace(), self._ctx(), [{"directory": tmp}]
)
self.assertIn("Could not read git tags", buf.getvalue())
class TestInkAliasDegradation(unittest.TestCase):
"""alias symlink creation in src/pkgmgr/core/command/ink.py"""
def test_alias_symlink_failure_is_reported_not_raised(self) -> None:
import pkgmgr.core.command.ink as mod
with tempfile.TemporaryDirectory() as tmp:
repo_dir = os.path.join(tmp, "p", "a", "repo")
bin_dir = os.path.join(tmp, "bin")
os.makedirs(repo_dir)
command = _write(os.path.join(repo_dir, "main.py"), "print()\n")
repo = {
"provider": "p",
"account": "a",
"repository": "repo",
"command": command,
"alias": "demo-alias",
}
buf = io.StringIO()
real_symlink = os.symlink
calls = {"n": 0}
def flaky_symlink(src, dst, **kwargs):
calls["n"] += 1
if calls["n"] > 1:
raise OSError("alias target is on a read-only mount")
return real_symlink(src, dst, **kwargs)
with patch.object(mod.os, "symlink", side_effect=flaky_symlink), patch(
"sys.stdout", buf
):
mod.create_ink(repo, tmp, bin_dir, [repo], quiet=False, preview=False)
self.assertIn("Error creating alias", buf.getvalue())
class TestNixIndexParsingDegradation(unittest.TestCase):
"""remaining int() guards in the nix profile helpers"""
def test_coerce_index_rejects_oversized_trailing_number(self) -> None:
from pkgmgr.actions.install.installers.nix.profile.normalizer import (
coerce_index,
)
# Matches the "<name>-<digits>" branch, but int() refuses digit strings
# longer than sys.get_int_max_str_digits().
self.assertIsNone(coerce_index("pkg-" + "9" * 5000, {}))
def test_profile_list_skips_oversized_index(self) -> None:
from pkgmgr.actions.install.installers.nix.profile_list import (
NixProfileListReader,
)
runner = MagicMock()
runner.run.return_value = SimpleNamespace(
returncode=0,
stdout=" " + "9" * 5000 + " /nix/store/"
+ "a" * 32 + "-demo\n",
)
self.assertEqual(NixProfileListReader(runner).entries(SimpleNamespace()), [])
class TestTomliFallbackDegradation(unittest.TestCase):
"""second tomllib import guard in version/source.py"""
@unittest.skipUnless(HAS_TOMLI, "tomli is not installed")
def test_project_name_falls_back_to_tomli(self) -> None:
import builtins
from pkgmgr.core.version.source import read_pyproject_project_name
with tempfile.TemporaryDirectory() as tmp:
_write(os.path.join(tmp, "pyproject.toml"), '[project]\nname = "demo"\n')
real_import = builtins.__import__
def fake_import(name, *args, **kwargs):
if name == "tomllib":
raise ImportError("no tomllib")
return real_import(name, *args, **kwargs)
with patch("builtins.__import__", side_effect=fake_import):
self.assertEqual(read_pyproject_project_name(tmp), "demo")

View File

@@ -13,7 +13,7 @@ class TestIntegrationReposCreatePreview(unittest.TestCase):
# Import lazily to avoid hard-failing if the CLI module/function name differs. # Import lazily to avoid hard-failing if the CLI module/function name differs.
try: try:
repos_mod = importlib.import_module("pkgmgr.cli.commands.repos") repos_mod = importlib.import_module("pkgmgr.cli.commands.repos")
except Exception as exc: except ImportError as exc:
self.skipTest(f"CLI module not available: {exc}") self.skipTest(f"CLI module not available: {exc}")
handle = getattr(repos_mod, "handle_repos_command", None) handle = getattr(repos_mod, "handle_repos_command", None)