Skip to content
Merged
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
8 changes: 8 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
coverage:
status:
project:
default:
target: 80%
patch:
default:
target: 80%
5 changes: 3 additions & 2 deletions collider/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from __future__ import annotations

import errno
import json
import shutil
import tempfile
Expand Down Expand Up @@ -237,7 +238,7 @@ def _ensure_archive(self, url: str, filename: str, expected_hash: str, offline:
f'Cached archive "{safe_name}" is corrupt: '
f'expected {expected_hash}, got {cached_hash}. Re-downloading.'
)
cached_path.unlink()
cached_path.unlink(missing_ok=True)

parsed = urllib.parse.urlparse(url)
if parsed.scheme == 'http':
Expand Down Expand Up @@ -290,7 +291,7 @@ def _ensure_archive(self, url: str, filename: str, expected_hash: str, offline:
try:
tmp_path.replace(cached_path)
except OSError as exc:
if exc.errno != 18:
if exc.errno != errno.EXDEV:
raise
# /tmp and cache may live on different filesystems, so fall back to a move.
shutil.move(tmp_path, cached_path)
Expand Down
17 changes: 4 additions & 13 deletions collider/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@

"""Application config paths and loading."""

import json
import os

from dataclasses import dataclass
Expand All @@ -12,6 +11,7 @@

from collider.cache import WrapCache
from collider.Context import Context
from collider.errors import ColliderUserError
from collider.file_model.configfile import ConfigFile
from collider.log import logger
from collider.repository.implementation.RepositoryInterface import RepositoryInterface
Expand Down Expand Up @@ -68,18 +68,9 @@ def load(*, offline: bool = False) -> Context:
else:
try:
config_file = ConfigFile.from_path(config_path)
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
logger.critical(
f'Invalid JSON in "{config_path.as_posix()}". '
'Fix or delete the file to regenerate defaults.'
)
raise SystemExit(os.EX_DATAERR) from exc
except TypeError as exc:
logger.critical(
f'Invalid config file "{config_path.as_posix()}". '
'Fix or delete the file to regenerate defaults.'
)
raise SystemExit(os.EX_DATAERR) from exc
except ColliderUserError as exc:
logger.critical('Fix or delete the config file to regenerate defaults.')
raise SystemExit(exc.exit_code) from exc
logger.debug(f'Loaded config file from "{config_path.as_posix()}".')

# Shared cache keeps wrap and archive data reusable across projects.
Expand Down
5 changes: 5 additions & 0 deletions collider/entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

from collider import config
from collider.Context import Context
from collider.errors import ColliderUserError
from collider.log import Level, configure_logging, logger
from collider.utils import core
from collider.utils.meson.meson import MesonUnavailableError
Expand Down Expand Up @@ -152,6 +153,10 @@ def main() -> int:
# A missing or outdated Meson is a user environment problem, not a Collider bug,
# so report a clean exit code instead of routing it through error_handler.
return os.EX_UNAVAILABLE
except ColliderUserError as e:
# Usage and user-environment errors are logged at the raise site; exit cleanly
# with the carried code instead of routing them through error_handler.
return e.exit_code


def entrypoint() -> None:
Expand Down
23 changes: 23 additions & 0 deletions collider/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 MOG Robotics OÜ

"""Shared exception types for user-facing error reporting."""


class ColliderUserError(Exception):
"""
Raised for usage or user-environment problems, not Collider bugs.
The entry point reports these cleanly and exits with the carried code instead of
routing them through the internal-bug handler.
"""

def __init__(self, message: str, exit_code: int) -> None:
"""
:param message: Human-readable description, already logged at the raise site.
:param exit_code: Process exit code (os.EX_*) the CLI should return.
"""
# A user error that exits successfully is a logic bug, not a recoverable state.
if exit_code == 0:
raise ValueError('ColliderUserError must use a non-zero exit code.')
super().__init__(message)
self.exit_code = exit_code
14 changes: 11 additions & 3 deletions collider/file_model/FileModelInterface.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"""Abstract file model interface."""

import json
import os
import tempfile

from abc import ABC
Expand All @@ -14,6 +15,7 @@

import jsonschema

from collider.errors import ColliderUserError
from collider.log import logger
from collider.utils.dataclass import prepare_ctor_kwargs, to_json_dict
from collider.utils.fs import atomic_write_text
Expand Down Expand Up @@ -97,12 +99,18 @@ def from_path(cls: Type[T], filepath: Path) -> T:
try:
with open(filepath, 'r', encoding='UTF-8') as f:
loaded_file = cls.from_stream(f)
except (json.JSONDecodeError, UnicodeDecodeError):
logger.error(f'Invalid JSON in "{filepath}".')
raise
except (ValueError, TypeError, KeyError) as exc:
# Malformed, schema-invalid, or undeserializable content (bad JSON, bad enum
# value, unknown registry key) is a user data problem, not a bug.
# json.JSONDecodeError and UnicodeDecodeError are ValueError subclasses.
logger.critical(msg := f'File "{filepath}" is invalid: {exc}')
raise ColliderUserError(msg, os.EX_DATAERR) from exc
except FileNotFoundError:
logger.warning(f'File not found: "{filepath}".')
raise
except OSError as exc:
logger.critical(msg := f'Cannot read file "{filepath}": {exc}')
raise ColliderUserError(msg, os.EX_IOERR) from exc

return loaded_file

Expand Down
63 changes: 52 additions & 11 deletions collider/repository/implementation/Wrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@

import hashlib
import json
import os
import time
import urllib.parse
import urllib.request

from pathlib import Path
from typing import Optional

from collider.errors import ColliderUserError
from collider.log import logger
from collider.Package import WrapPackage
from collider.repository.entries import RejectedEntry, RepoPackageEntry, packages_from_releases
Expand Down Expand Up @@ -56,6 +58,20 @@ def _releases_cache_path(cache_path: Path, url: urllib.parse.ParseResult) -> Pat
return Path(cache_path) / 'wrapdb' / f'{url.netloc}-{digest}' / _RELEASES_FILENAME


def _load_releases_cache(cache_file: Path) -> Optional[dict[str, WrapDbReleasesEntry]]:
"""
Load cached releases.json, or None when the file is missing or unreadable.
:param cache_file: Path to the cached releases.json.
:return: Parsed releases mapping, or None to signal a cache miss.
"""
try:
return json.loads(cache_file.read_text(encoding='utf-8'))
except (OSError, ValueError) as exc:
# ValueError covers both JSONDecodeError and UnicodeDecodeError.
logger.debug(f'Cached releases.json unusable: {exc}')
return None


def _ensure_v2_url(url: urllib.parse.ParseResult) -> urllib.parse.ParseResult:
path = url.path.rstrip('/')
if not path.endswith('/v2'):
Expand Down Expand Up @@ -107,32 +123,57 @@ def _from_url_impl(
if cache_path is not None:
cache_file = _releases_cache_path(cache_path, effective_url)

releases: dict[str, WrapDbReleasesEntry]
releases: Optional[dict[str, WrapDbReleasesEntry]] = None
if offline:
if cache_file is None or not cache_file.exists():
raise ValueError('Offline mode requires cached wrap releases.')
releases = json.loads(cache_file.read_text(encoding='utf-8'))
if cache_file is None or (releases := _load_releases_cache(cache_file)) is None:
raise ColliderUserError(
'Offline mode requires cached wrap releases.', os.EX_DATAERR
)
elif (
cache_file is not None
and cache_file.exists()
and (time.time() - cache_file.stat().st_mtime) < _RELEASES_TTL_SECONDS
# A corrupt within-TTL cache falls through to a network refresh.
and (releases := _load_releases_cache(cache_file)) is not None
):
logger.debug('Using cached releases.json (within TTL).')
releases = json.loads(cache_file.read_text(encoding='utf-8'))
else:
if releases is None and not offline:
try:
with urllib.request.urlopen(
releases_url, timeout=DEFAULT_NETWORK_TIMEOUT
) as response:
releases = json.load(response)
if not isinstance(releases, dict):
# A 200 body that is not a JSON object (e.g. `null`, a list, or
# an HTML error page parsed as a string) is a repository data
# problem, not a Collider bug.
logger.critical(
f'WrapDB at "{effective_url.geturl()}" returned non-object releases.json.'
)
raise ColliderUserError(
'WrapDB returned malformed releases.json.', os.EX_DATAERR
)
if cache_file is not None:
atomic_write_text(cache_file, json.dumps(releases), encoding='utf-8')
except Exception as e:
if cache_file is None or not cache_file.exists():
raise e
except ColliderUserError: # pylint: disable=try-except-raise
# Re-raise before the generic handler so a malformed-releases user
# error is not swallowed as a network failure and cache-fallback.
raise
except Exception:
cached = _load_releases_cache(cache_file) if cache_file is not None else None
if cached is None:
raise
logger.warning('Failed to refresh wrap releases; using cached data.')
releases = json.loads(cache_file.read_text(encoding='utf-8'))

releases = cached

if not isinstance(releases, dict):
# Defensive: covers a non-object cached releases.json (e.g. a list or
# `null`) that slipped past the network-fetch validation above.
logger.critical(
f'Cached releases for "{effective_url.geturl()}" are not a JSON object'
f'{f"; delete {cache_file}" if cache_file is not None else ""}.'
)
raise ColliderUserError('Cached wrap releases are malformed.', os.EX_DATAERR)
packages, rejected = _wrap_releases_to_packages(releases)
if rejected:
logger.warning(
Expand Down
5 changes: 4 additions & 1 deletion collider/subcommand/Install.py
Original file line number Diff line number Diff line change
Expand Up @@ -479,7 +479,7 @@ def _do_install(self, name: str, package: WrapPackage) -> bool:
Path.cwd() / SUBPROJECTS_DIR,
offline=self.offline,
)
except (FileNotFoundError, ValueError) as e:
except (OSError, ValueError) as e:
logger.critical(str(e))
return False

Expand All @@ -488,6 +488,9 @@ def _do_install(self, name: str, package: WrapPackage) -> bool:
except FileExistsError as exc:
logger.critical(str(exc))
return False
except OSError as exc:
logger.critical(f'Could not install wrap file: {exc}')
return False

logger.info(f'Installed "{name}" version "{package.version}".')
return True
9 changes: 3 additions & 6 deletions collider/subcommand/Repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from __future__ import annotations

import argparse
import json
import os
import urllib.parse

Expand Down Expand Up @@ -225,11 +224,9 @@ def _load_or_create_config(config_path: Path) -> Optional[ConfigFile]:
logger.critical(f'Failed to initialize config at "{config_path.as_posix()}": {exc}')
return None

try:
return ConfigFile.from_path(config_path)
except (TypeError, json.JSONDecodeError, UnicodeDecodeError) as exc:
logger.critical(f'Invalid config file "{config_path.as_posix()}": {exc}')
return None
# ConfigFile.from_path raises ColliderUserError on a corrupt config; let the
# carried exit code propagate to the entrypoint instead of degrading to EX_DATAERR.
return ConfigFile.from_path(config_path)

@staticmethod
def _normalize_repo_url(url: str) -> str:
Expand Down
16 changes: 7 additions & 9 deletions collider/subcommand/Setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from typing import Optional

from collider.Context import Context
from collider.errors import ColliderUserError
from collider.file_model.colliderfile import Colliderfile
from collider.log import logger
from collider.subcommand.SubcommandInterface import SubcommandInterface
Expand Down Expand Up @@ -102,7 +103,7 @@ def __init__(self, args: argparse.Namespace, context: Context):
else:
logger.critical(msg := 'Expected "--" separator before meson arguments.')
logger.critical(f'E.g. "collider setup -- {" ".join(self.meson_setup_args)}"')
raise ValueError(msg)
raise ColliderUserError(msg, os.EX_USAGE)

@override
def execute(self) -> int:
Expand Down Expand Up @@ -130,13 +131,10 @@ def execute(self) -> int:
)
return os.EX_NOINPUT

try:
if not self._verify_no_lock_drift():
return os.EX_DATAERR
fallback_args = self._force_fallback_args()
except ValueError as exc:
logger.critical(str(exc))
# A malformed or unreadable lock raises ColliderUserError, handled at the entrypoint.
if not self._verify_no_lock_drift():
return os.EX_DATAERR
fallback_args = self._force_fallback_args()

try:
meson.setup(
Expand All @@ -163,7 +161,7 @@ def _verify_no_lock_drift(self) -> bool:
default is to fail fast; --allow-drift downgrades this to a loud warning for the legitimate
local-patch workflow.
:return: True when setup may proceed, False when it must abort.
:raises ValueError: When collider.lock exists but is malformed.
:raises ColliderUserError: When collider.lock exists but cannot be read or parsed.
"""
drifted = detect_locked_wrap_drift(self.sourcedir)
if not drifted:
Expand Down Expand Up @@ -206,7 +204,7 @@ def _force_fallback_args(self) -> list[str]:
`--force-fallback-for`, so a user-supplied one is deferred to rather than overridden
with a flag Meson would discard alongside a misleading message.
:return: A single `--force-fallback-for` argument, or an empty list.
:raises ValueError: When collider.lock exists but is malformed.
:raises ColliderUserError: When collider.lock exists but cannot be read or parsed.
"""
managed = managed_package_names(self.sourcedir)

Expand Down
8 changes: 7 additions & 1 deletion collider/subcommand/Status.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,13 @@ def _resolve_versions(
exclude_optional=any(dep.exclude_optional for dep in tracked),
)
return {name: candidate.version for name, candidate in resolution.mapping.items()}
except Exception:
except Exception as exc:
# Status stays best-effort on resolution failures, but says so instead of
# silently listing transitive wraps as untracked.
logger.warning(
f'Version resolution failed ({type(exc).__name__}: {exc}); '
'transitive wraps may be listed as untracked.'
)
return {}

@staticmethod
Expand Down
5 changes: 4 additions & 1 deletion collider/subcommand/pkg/Add.py
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,7 @@ def _install_package(
offline=self.offline,
)
logger.debug('Package cache updated.')
except (FileNotFoundError, ValueError) as e:
except (OSError, ValueError) as e:
logger.critical(str(e))
return None

Expand All @@ -481,6 +481,9 @@ def _install_package(
f'{exc} Remove or rename the existing file/directory before installing this package.'
)
return None
except OSError as exc:
logger.critical(f'Could not install wrap file: {exc}')
return None
return package

@staticmethod
Expand Down
Loading
Loading