diff --git a/cli/localci/cli/config.py b/cli/localci/cli/config.py index 852519f..2fb4081 100644 --- a/cli/localci/cli/config.py +++ b/cli/localci/cli/config.py @@ -163,9 +163,9 @@ def _coerce_value(raw: str) -> str | int | float | bool: try: return int(raw) except ValueError: - pass + pass # fall through to float / plain string try: return float(raw) except ValueError: - pass + pass # not numeric; return raw string below return raw diff --git a/cli/localci/cli/images.py b/cli/localci/cli/images.py index 11235f5..eba781b 100644 --- a/cli/localci/cli/images.py +++ b/cli/localci/cli/images.py @@ -14,6 +14,7 @@ import yaml from localci.core.registry import ImageRegistry +from localci.errors import DockerNotAvailableError from localci.utils.docker import DockerManager from localci.utils.output import ( console, @@ -80,10 +81,14 @@ def images_list(ctx: click.Context, output_format: str, registry_path: Path | No """List available images.""" try: registry = _get_registry(registry_path) - except Exception as exc: # noqa: BLE001 + except FileNotFoundError as exc: print_error(str(exc)) ctx.exit(1) return + except (OSError, yaml.YAMLError, TypeError, ValueError) as exc: + print_error(f"Could not load image registry: {exc}") + ctx.exit(1) + return if output_format == "json": click.echo(json.dumps([e.to_dict() for e in registry.entries], indent=2)) @@ -122,10 +127,14 @@ def images_info(ctx: click.Context, image: str, registry_path: Path | None) -> N """Show detailed information about an image.""" try: registry = _get_registry(registry_path) - except Exception as exc: # noqa: BLE001 + except FileNotFoundError as exc: print_error(str(exc)) ctx.exit(1) return + except (OSError, yaml.YAMLError, TypeError, ValueError) as exc: + print_error(f"Could not load image registry: {exc}") + ctx.exit(1) + return match = registry.find_by_name(image) if not match: @@ -212,7 +221,7 @@ def images_clean( try: dm = DockerManager() - except RuntimeError as exc: + except DockerNotAvailableError as exc: print_error(str(exc)) ctx.exit(1) return @@ -258,7 +267,7 @@ def images_import(ctx: click.Context, tar_file: str) -> None: """Import a Docker image from a tar file.""" try: dm = DockerManager() - except RuntimeError as exc: + except DockerNotAvailableError as exc: print_error(str(exc)) ctx.exit(1) return @@ -285,7 +294,7 @@ def images_export(ctx: click.Context, image: str, output_path: str) -> None: """Export a Docker image to a tar file.""" try: dm = DockerManager() - except RuntimeError as exc: + except DockerNotAvailableError as exc: print_error(str(exc)) ctx.exit(1) return diff --git a/cli/localci/cli/list.py b/cli/localci/cli/list.py index e0ece2c..c6a69ac 100644 --- a/cli/localci/cli/list.py +++ b/cli/localci/cli/list.py @@ -157,7 +157,7 @@ def list_cmd( try: analyzer = WorkflowAnalyzer() wf = analyzer.analyze(wf_path) - except (WorkflowError, FileNotFoundError) as exc: + except WorkflowError as exc: print_error(str(exc)) ctx.exit(1) return diff --git a/cli/localci/cli/logs.py b/cli/localci/cli/logs.py index 15a927b..f77eb7c 100644 --- a/cli/localci/cli/logs.py +++ b/cli/localci/cli/logs.py @@ -5,6 +5,7 @@ from __future__ import annotations +import json from pathlib import Path import click @@ -95,7 +96,7 @@ def logs( try: summary = ExecutionSummary.load(results_file) - except Exception as exc: + except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as exc: print_error(f"Failed to load results: {exc}") ctx.exit(1) return diff --git a/cli/localci/cli/main.py b/cli/localci/cli/main.py index 48ccd2d..3f59d82 100644 --- a/cli/localci/cli/main.py +++ b/cli/localci/cli/main.py @@ -14,6 +14,12 @@ from localci import __version__ from localci.core.config import load_config +from localci.errors import ( + ConfigError, + ConfigFileNotFoundError, + ConfigIOError, + ConfigValidationError, +) from localci.utils.output import configure_console, console, print_error # --------------------------------------------------------------------------- @@ -58,12 +64,21 @@ def cli( # Load configuration – surface any validation errors immediately. try: cfg = load_config(config_path) - except FileNotFoundError as exc: + except ConfigFileNotFoundError as exc: print_error(str(exc)) ctx.exit(1) return - except Exception as exc: # noqa: BLE001 - print_error(f"Failed to load config: {exc}") + except ConfigIOError as exc: + print_error(f"Cannot read config file {exc.path}: {exc.cause}") + ctx.exit(1) + return + except ConfigValidationError as exc: + location = f" {exc.path}" if exc.path else "" + print_error(f"Invalid config{location}: {exc.cause}") + ctx.exit(1) + return + except ConfigError as exc: + print_error(str(exc)) ctx.exit(1) return diff --git a/cli/localci/cli/run.py b/cli/localci/cli/run.py index 0248db3..bbcc1b3 100644 --- a/cli/localci/cli/run.py +++ b/cli/localci/cli/run.py @@ -18,6 +18,7 @@ DockerNotAvailableError, JobExecutor, ) +from localci.errors import WorkflowError from localci.core.models import JobEvent, JobEventType from localci.core.orchestrator import ( OrchestratorConfig, @@ -158,8 +159,8 @@ def run( try: analyzer = WorkflowAnalyzer() wf = analyzer.analyze(workflow_path) - except Exception as exc: - print_error(f"Failed to parse workflow: {exc}") + except WorkflowError as exc: + print_error(str(exc)) ctx.exit(1) return @@ -210,7 +211,7 @@ def run( seen.add((jid, e.index)) continue except ValueError: - pass + pass # not a numeric job index; treat *j* as a name substring below j_lower = j.lower() for jid, e in selected: if j_lower in e.name.lower() and (jid, e.index) not in seen: @@ -387,8 +388,10 @@ def run( summary.save(execution_file) print_info(f"Results saved to {last_run_file}") print_info(f"Execution ID: {summary.execution_id} (use with status -e or logs -e)") - except Exception as exc: + except OSError as exc: print_warning(f"Could not save results: {exc}") + except (TypeError, ValueError) as exc: + print_warning(f"Could not serialize results for save: {exc}") if not summary.all_passed: ctx.exit(1) diff --git a/cli/localci/cli/status.py b/cli/localci/cli/status.py index 9dc2e69..efcc3b5 100644 --- a/cli/localci/cli/status.py +++ b/cli/localci/cli/status.py @@ -112,7 +112,7 @@ def status( try: summary = ExecutionSummary.load(results_file) - except Exception as exc: + except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as exc: print_error(f"Failed to load results: {exc}") ctx.exit(1) return @@ -270,4 +270,4 @@ def _follow_status(status_file: Path, output_format: str) -> None: else: _print_status_table(last_data) except KeyboardInterrupt: - pass + pass # intentional: quiet exit after follow mode diff --git a/cli/localci/core/__init__.py b/cli/localci/core/__init__.py index 88131aa..22fd25f 100644 --- a/cli/localci/core/__init__.py +++ b/cli/localci/core/__init__.py @@ -1,6 +1,9 @@ """Core modules for Local CI.""" -from localci.core.workflow import ( +from localci.errors import ( + ActNotFoundError, + CyclicDependencyError, + DockerNotAvailableError, MissingFieldError, UnsupportedMatrixError, WorkflowError, @@ -8,8 +11,6 @@ ) from localci.core.executor import ( # noqa: F401 ActCommand, - ActNotFoundError, - DockerNotAvailableError, JobExecutor, JobResult, JobStatus, @@ -23,7 +24,6 @@ QueuedJobStatus, ) from localci.core.queue import ( # noqa: F401 - CyclicDependencyError, DependencyResolver, PriorityConfig, PriorityJobQueue, diff --git a/cli/localci/core/config.py b/cli/localci/core/config.py index 3b97d89..7fd0070 100644 --- a/cli/localci/core/config.py +++ b/cli/localci/core/config.py @@ -12,7 +12,9 @@ from typing import Any, Optional import yaml -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, ValidationError, field_validator + +from localci.errors import ConfigFileNotFoundError, ConfigIOError, ConfigValidationError logger = logging.getLogger(__name__) @@ -372,7 +374,7 @@ def load_config(path: Path | str | None = None) -> LocalCIConfig: if path is not None: config_path = Path(path) if not config_path.is_file(): - raise FileNotFoundError(f"Config file not found: {config_path}") + raise ConfigFileNotFoundError(config_path) else: config_path = find_config_file() @@ -381,10 +383,16 @@ def load_config(path: Path | str | None = None) -> LocalCIConfig: return LocalCIConfig() logger.debug("Loading config from %s", config_path) - with open(config_path, "r", encoding="utf-8") as fh: - raw: dict[str, Any] = yaml.safe_load(fh) or {} - - return LocalCIConfig.model_validate(raw) + try: + with open(config_path, "r", encoding="utf-8") as fh: + raw: dict[str, Any] = yaml.safe_load(fh) or {} + except (OSError, yaml.YAMLError) as exc: + raise ConfigIOError(config_path, exc) from exc + + try: + return LocalCIConfig.model_validate(raw) + except ValidationError as exc: + raise ConfigValidationError(config_path, exc) from exc def default_config_yaml() -> str: diff --git a/cli/localci/core/executor.py b/cli/localci/core/executor.py index 4671027..20d246c 100644 --- a/cli/localci/core/executor.py +++ b/cli/localci/core/executor.py @@ -19,6 +19,8 @@ from pathlib import Path from typing import IO, Callable, Optional +from localci.errors import ActNotFoundError, DockerNotAvailableError + logger = logging.getLogger(__name__) @@ -268,32 +270,6 @@ def __str__(self) -> str: return self.display() -# ===================================================================== -# Errors -# ===================================================================== - - -class ActNotFoundError(RuntimeError): - """``act`` is not installed.""" - - def __init__(self) -> None: - super().__init__( - "act is not installed.\n" - "Install with:\n" - " Windows: choco install act-cli\n" - " Linux: curl -s https://raw.githubusercontent.com/nektos/act/" - "master/install.sh | sudo bash\n" - " macOS: brew install act" - ) - - -class DockerNotAvailableError(RuntimeError): - """Docker daemon is not running or not installed.""" - - def __init__(self, detail: str = "Docker daemon is not running") -> None: - super().__init__(detail) - - # ===================================================================== # JobExecutor # ===================================================================== diff --git a/cli/localci/core/queue.py b/cli/localci/core/queue.py index 2c49ffa..ba39b6f 100644 --- a/cli/localci/core/queue.py +++ b/cli/localci/core/queue.py @@ -19,6 +19,7 @@ QueuedJob, QueuedJobStatus, ) +from localci.errors import CyclicDependencyError if TYPE_CHECKING: from localci.core.config import LocalCIConfig @@ -84,14 +85,6 @@ def from_config(cls, config: "LocalCIConfig") -> PriorityConfig: # --------------------------------------------------------------------------- -class CyclicDependencyError(Exception): - """Circular dependency detected in job graph.""" - - def __init__(self, job_id: str): - super().__init__(f"Cyclic dependency detected involving job: {job_id}") - self.job_id = job_id - - class DependencyResolver: """Resolve job dependencies using topological sort. diff --git a/cli/localci/core/workflow.py b/cli/localci/core/workflow.py index 99d5d4f..df5198d 100644 --- a/cli/localci/core/workflow.py +++ b/cli/localci/core/workflow.py @@ -15,6 +15,15 @@ from pathlib import Path from typing import Any, Optional +from localci.errors import ( + LocalCIError, + MissingFieldError, + UnsupportedMatrixError, + WorkflowError, + WorkflowNotFoundError, + WorkflowParseError, +) + logger = logging.getLogger(__name__) @@ -319,38 +328,6 @@ def visit(job_id: str) -> None: return order -# ===================================================================== -# Errors -# ===================================================================== - - -class WorkflowError(Exception): - """Base error for workflow analysis.""" - - -class WorkflowParseError(WorkflowError): - """Failed to parse workflow YAML.""" - - def __init__(self, file: Path, detail: str): - self.file = file - super().__init__(f"Failed to parse {file}: {detail}") - - -class UnsupportedMatrixError(WorkflowError): - """Matrix configuration not supported.""" - - def __init__(self, entry: dict, detail: str): - name = entry.get("name", "unknown") - super().__init__(f"Unsupported matrix entry '{name}': {detail}") - - -class MissingFieldError(WorkflowError): - """Required field missing from workflow.""" - - def __init__(self, field_name: str, context: str): - super().__init__(f"Missing required field '{field_name}' in {context}") - - # ===================================================================== # WorkflowAnalyzer # ===================================================================== @@ -411,10 +388,10 @@ def analyze(self, workflow_path: Path, event: Optional[str] = None) -> Workflow: try: name = self.yq.workflow_name(workflow_path) - except FileNotFoundError: - raise + except FileNotFoundError as exc: + raise WorkflowNotFoundError(workflow_path, exc) from exc except Exception as exc: - raise WorkflowParseError(workflow_path, str(exc)) from exc + raise WorkflowParseError(workflow_path, exc) from exc events = self.yq.events(workflow_path) @@ -443,7 +420,9 @@ def analyze(self, workflow_path: Path, event: Optional[str] = None) -> Workflow: raise except Exception as exc: raise WorkflowParseError( - workflow_path, f"Error parsing job '{job_id}': {exc}" + workflow_path, + exc, + message=f"Error parsing job '{job_id}': {exc}", ) from exc workflow = Workflow( @@ -474,7 +453,7 @@ def analyze_multiple(self, workflow_dir: Path) -> list[Workflow]: for yml in sorted(workflow_dir.glob("*.yml")): try: workflows.append(self.analyze(yml)) - except (WorkflowError, FileNotFoundError) as exc: + except WorkflowError as exc: logger.warning("Failed to parse %s: %s", yml, exc) except Exception as exc: logger.error( diff --git a/cli/localci/errors.py b/cli/localci/errors.py new file mode 100644 index 0000000..c0b0b05 --- /dev/null +++ b/cli/localci/errors.py @@ -0,0 +1,257 @@ +"""Structured exception types for LocalCI. + +All public errors inherit from :class:`LocalCIError` so callers can catch the +full family with a single ``except LocalCIError`` clause while still being able +to distinguish failure modes precisely. + +Hierarchy:: + + LocalCIError + ├── ConfigError + │ ├── ConfigFileNotFoundError (also FileNotFoundError) + │ ├── ConfigIOError (also OSError) + │ └── ConfigValidationError + ├── WorkflowError + │ ├── WorkflowNotFoundError (also FileNotFoundError) + │ ├── WorkflowParseError + │ ├── MissingFieldError + │ ├── UnsupportedMatrixError + │ └── CyclicDependencyError + ├── ExecutionError (act / Docker prerequisites) + │ ├── ActNotFoundError + │ └── DockerNotAvailableError + ├── YqError + └── YqNotFoundError +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Optional + + +# --------------------------------------------------------------------------- +# Base +# --------------------------------------------------------------------------- + + +class LocalCIError(Exception): + """Base exception for all LocalCI errors.""" + + +# --------------------------------------------------------------------------- +# Configuration errors +# --------------------------------------------------------------------------- + + +class ConfigError(LocalCIError): + """Base class for configuration-related errors.""" + + +class ConfigFileNotFoundError(ConfigError, FileNotFoundError): + """A config file path was given but the file does not exist. + + Inherits from :exc:`FileNotFoundError` for backward-compatibility with + existing ``except FileNotFoundError`` handlers. + + Attributes + ---------- + path: + The config file path that was not found. + cause: + The original exception, if any. + """ + + def __init__(self, path: Path, cause: Optional[Exception] = None) -> None: + self.path = Path(path) + self.cause = cause + super().__init__(f"Config file not found: {self.path}") + + +class ConfigIOError(ConfigError, OSError): + """The config file exists but could not be read (permission denied, I/O error, etc.). + + Inherits from :exc:`OSError` so existing ``except OSError`` handlers still + intercept it. + + Attributes + ---------- + path: + The config file path that could not be read. + cause: + The underlying :exc:`OSError` (read failure) or ``yaml.YAMLError`` + from PyYAML (malformed YAML during load). + """ + + def __init__(self, path: Path, cause: Exception) -> None: + self.path = Path(path) + self.cause = cause + super().__init__(f"Cannot read config file {self.path}: {cause}") + + +class ConfigValidationError(ConfigError): + """The config file content failed schema/Pydantic validation. + + Attributes + ---------- + path: + The config file path, or ``None`` when no file was involved. + cause: + The underlying :exc:`pydantic.ValidationError` or other validation + exception carrying the field-level details. + """ + + def __init__(self, path: Optional[Path], cause: Exception) -> None: + self.path = Path(path) if path is not None else None + self.cause = cause + location = f" in {self.path}" if self.path else "" + super().__init__(f"Invalid config{location}: {cause}") + + +# --------------------------------------------------------------------------- +# Workflow errors +# --------------------------------------------------------------------------- + + +class WorkflowError(LocalCIError): + """Base class for workflow-related errors.""" + + +class WorkflowNotFoundError(WorkflowError, FileNotFoundError): + """The workflow file does not exist. + + Inherits from :exc:`FileNotFoundError` for backward-compatibility. + + Attributes + ---------- + path: + The workflow file path that was not found. + cause: + The original exception, if any. + """ + + def __init__(self, path: Path, cause: Optional[Exception] = None) -> None: + self.path = Path(path) + self.cause = cause + super().__init__(f"Workflow file not found: {self.path}") + + +class WorkflowParseError(WorkflowError): + """The workflow file could not be parsed (invalid YAML, unexpected structure, etc.). + + Prefer ``raise WorkflowParseError(path, exc) from exc`` so :attr:`cause` and + :attr:`__cause__` both reference the original exception. Use *message* when + the user-facing text should add context beyond ``str(exc)``. + + Attributes + ---------- + path: + Path to the workflow file. + cause: + The underlying exception from parsing or analysis. + """ + + def __init__( + self, + path: Path, + cause: Exception, + *, + message: Optional[str] = None, + ) -> None: + self.path = Path(path) + self.cause = cause + part = message if message is not None else str(cause) + super().__init__(f"Failed to parse workflow {self.path}: {part}") + + +class MissingFieldError(WorkflowError): + """Required field missing from workflow.""" + + def __init__(self, field_name: str, context: str) -> None: + self.field_name = field_name + self.context = context + super().__init__( + f"Missing required field '{field_name}' in {context}" + ) + + +class UnsupportedMatrixError(WorkflowError): + """Matrix configuration not supported.""" + + def __init__(self, entry: dict[str, Any], detail: str) -> None: + self.entry = entry + self.detail = detail + self.name = entry.get("name", "unknown") + super().__init__( + f"Unsupported matrix entry '{self.name}': {detail}" + ) + + +class CyclicDependencyError(WorkflowError): + """Circular dependency detected in job graph.""" + + def __init__(self, job_id: str) -> None: + super().__init__(f"Cyclic dependency detected involving job: {job_id}") + self.job_id = job_id + + +# --------------------------------------------------------------------------- +# Execution prerequisites (act, Docker) +# --------------------------------------------------------------------------- + + +class ExecutionError(LocalCIError): + """Base class for missing ``act`` or unavailable Docker.""" + + +class ActNotFoundError(ExecutionError): + """``act`` is not installed.""" + + def __init__(self) -> None: + super().__init__( + "act is not installed.\n" + "Install with:\n" + " Windows: choco install act-cli\n" + " Linux: curl -s https://raw.githubusercontent.com/nektos/act/" + "master/install.sh | sudo bash\n" + " macOS: brew install act" + ) + + +class DockerNotAvailableError(ExecutionError): + """Docker daemon is not running or not installed.""" + + def __init__(self, detail: str = "Docker daemon is not running") -> None: + super().__init__(detail) + + +# --------------------------------------------------------------------------- +# YAML query (yq) +# --------------------------------------------------------------------------- + + +class YqError(LocalCIError): + """Error from yq execution.""" + + def __init__(self, expression: str, stderr: str) -> None: + self.expression = expression + self.stderr = stderr + super().__init__(f"yq error for '{expression}': {stderr}") + + +class YqNotFoundError(LocalCIError): + """yq is not installed.""" + + def __init__(self) -> None: + super().__init__( + "mikefarah/yq is not installed.\n" + "Install v4+ (not pip's `yq` / kislyuk/yq): " + "https://github.com/mikefarah/yq#install\n" + "Prefer a normal binary on PATH (GitHub releases, distro packages). " + "Snap-installed yq is often confined and cannot read arbitrary paths (e.g. /tmp).\n" + "Examples:\n" + " Windows: winget install MikeFarah.yq OR choco install yq\n" + " Linux: install from https://github.com/mikefarah/yq/releases " + "OR your distro's `yq` package (ensure `yq --version` shows mikefarah)\n" + " macOS: brew install yq" + ) diff --git a/cli/localci/utils/docker.py b/cli/localci/utils/docker.py index fde5c5e..2a2a9ba 100644 --- a/cli/localci/utils/docker.py +++ b/cli/localci/utils/docker.py @@ -13,6 +13,8 @@ from pathlib import Path from typing import Optional +from localci.errors import DockerNotAvailableError + logger = logging.getLogger(__name__) @@ -45,7 +47,7 @@ def has_docker(self) -> bool: def _check_docker(self) -> None: """Verify Docker is available.""" if not self._docker_path: - raise RuntimeError("Docker is not installed") + raise DockerNotAvailableError("Docker is not installed") result = subprocess.run( [self._docker_path, "version", "--format", "{{.Server.Version}}"], @@ -56,7 +58,7 @@ def _check_docker(self) -> None: if result.returncode == 0: logger.info("Docker version: %s", result.stdout.strip()) else: - raise RuntimeError("Docker daemon not responding") + raise DockerNotAvailableError("Docker daemon not responding") # ----------------------------------------------------------------- # Image operations diff --git a/cli/localci/utils/yq.py b/cli/localci/utils/yq.py index f9f930a..5bd1563 100644 --- a/cli/localci/utils/yq.py +++ b/cli/localci/utils/yq.py @@ -23,36 +23,9 @@ import yaml -logger = logging.getLogger(__name__) - - -# ===================================================================== -# Errors -# ===================================================================== - - -class YqError(Exception): - """Error from yq execution.""" +from localci.errors import YqError, YqNotFoundError - def __init__(self, expression: str, stderr: str): - self.expression = expression - self.stderr = stderr - super().__init__(f"yq error for '{expression}': {stderr}") - - -class YqNotFoundError(Exception): - """yq is not installed.""" - - def __init__(self) -> None: - super().__init__( - "mikefarah/yq is not installed.\n" - "Install v4+ (not pip's `yq` / kislyuk/yq): " - "https://github.com/mikefarah/yq#install\n" - "Examples:\n" - " Windows: winget install MikeFarah.yq OR choco install yq\n" - " Linux: sudo snap install yq OR install from GitHub releases\n" - " macOS: brew install yq" - ) +logger = logging.getLogger(__name__) class YqFallbackWarning(UserWarning): diff --git a/cli/tests/test_errors.py b/cli/tests/test_errors.py new file mode 100644 index 0000000..ab51954 --- /dev/null +++ b/cli/tests/test_errors.py @@ -0,0 +1,344 @@ +"""Tests for structured error types (Issue #28). + +Covers: +- Exception hierarchy under :class:`~localci.errors.LocalCIError` (config, workflow, + execution, yq families) and attribute contracts for config/workflow parse types +- Backward-compatibility: structured errors are still caught by built-in base classes +- load_config raises structured errors for each failure mode +- CLI exits with code 1 and an actionable message for config error paths +- WorkflowAnalyzer raises WorkflowNotFoundError / WorkflowParseError +- CLI run/list exit cleanly on workflow errors +""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +import pytest +import yaml +from click.testing import CliRunner + +from localci.cli.main import cli +from localci.core.config import load_config +from localci.core.workflow import WorkflowAnalyzer +from localci.errors import ( + ActNotFoundError, + ConfigError, + ConfigFileNotFoundError, + ConfigIOError, + ConfigValidationError, + CyclicDependencyError, + DockerNotAvailableError, + ExecutionError, + LocalCIError, + MissingFieldError, + UnsupportedMatrixError, + WorkflowError, + WorkflowNotFoundError, + WorkflowParseError, + YqError, + YqNotFoundError, +) + + +runner = CliRunner() + + +# --------------------------------------------------------------------------- +# Exception hierarchy +# --------------------------------------------------------------------------- + + +class TestExceptionHierarchy: + """Structured errors used by the CLI and core are under LocalCIError.""" + + def test_config_error_is_local_ci_error(self): + assert issubclass(ConfigError, LocalCIError) + + def test_config_file_not_found_is_config_error(self): + assert issubclass(ConfigFileNotFoundError, ConfigError) + + def test_config_file_not_found_is_file_not_found_error(self): + """Backward-compat: caught by except FileNotFoundError.""" + assert issubclass(ConfigFileNotFoundError, FileNotFoundError) + + def test_config_io_error_is_config_error(self): + assert issubclass(ConfigIOError, ConfigError) + + def test_config_io_error_is_os_error(self): + """Backward-compat: caught by except OSError.""" + assert issubclass(ConfigIOError, OSError) + + def test_config_validation_error_is_config_error(self): + assert issubclass(ConfigValidationError, ConfigError) + + def test_workflow_error_is_local_ci_error(self): + assert issubclass(WorkflowError, LocalCIError) + + def test_workflow_not_found_is_workflow_error(self): + assert issubclass(WorkflowNotFoundError, WorkflowError) + + def test_workflow_not_found_is_file_not_found_error(self): + """Backward-compat: caught by except FileNotFoundError.""" + assert issubclass(WorkflowNotFoundError, FileNotFoundError) + + def test_workflow_parse_error_is_workflow_error(self): + assert issubclass(WorkflowParseError, WorkflowError) + + def test_missing_field_and_matrix_errors_are_workflow_error(self): + assert issubclass(MissingFieldError, WorkflowError) + assert issubclass(UnsupportedMatrixError, WorkflowError) + + def test_cyclic_dependency_is_workflow_error(self): + assert issubclass(CyclicDependencyError, WorkflowError) + + def test_execution_family_under_local_ci_error(self): + assert issubclass(ExecutionError, LocalCIError) + assert issubclass(ActNotFoundError, ExecutionError) + assert issubclass(DockerNotAvailableError, ExecutionError) + + def test_yq_errors_under_local_ci_error(self): + assert issubclass(YqError, LocalCIError) + assert issubclass(YqNotFoundError, LocalCIError) + + +# --------------------------------------------------------------------------- +# Error attribute contracts +# --------------------------------------------------------------------------- + + +class TestErrorAttributes: + """Each error type carries expected metadata attributes.""" + + def test_config_file_not_found_attributes(self, tmp_path): + p = tmp_path / "missing.yml" + exc = ConfigFileNotFoundError(p) + assert exc.path == p + assert exc.cause is None + assert str(p) in str(exc) + + def test_config_file_not_found_with_cause(self, tmp_path): + p = tmp_path / "missing.yml" + original = FileNotFoundError("original") + exc = ConfigFileNotFoundError(p, cause=original) + assert exc.cause is original + + def test_config_io_error_attributes(self, tmp_path): + p = tmp_path / "locked.yml" + original = PermissionError("permission denied") + exc = ConfigIOError(p, original) + assert exc.path == p + assert exc.cause is original + assert str(p) in str(exc) + + def test_config_validation_error_attributes_with_path(self, tmp_path): + p = tmp_path / ".localci.yml" + original = ValueError("bad value") + exc = ConfigValidationError(p, original) + assert exc.path == p + assert exc.cause is original + assert str(p) in str(exc) + + def test_config_validation_error_attributes_no_path(self): + original = ValueError("bad value") + exc = ConfigValidationError(None, original) + assert exc.path is None + assert exc.cause is original + + def test_workflow_not_found_attributes(self, tmp_path): + p = tmp_path / "ci.yml" + exc = WorkflowNotFoundError(p) + assert exc.path == p + assert exc.cause is None + assert str(p) in str(exc) + + def test_workflow_not_found_with_cause(self, tmp_path): + p = tmp_path / "ci.yml" + original = FileNotFoundError("no file") + exc = WorkflowNotFoundError(p, cause=original) + assert exc.cause is original + + def test_workflow_parse_error_attributes(self, tmp_path): + p = tmp_path / "ci.yml" + original = ValueError("unexpected key") + exc = WorkflowParseError(p, original) + assert exc.path == p + assert exc.cause is original + assert "unexpected key" in str(exc) + assert str(p) in str(exc) + + +# --------------------------------------------------------------------------- +# Backward-compatibility: caught by built-in types +# --------------------------------------------------------------------------- + + +class TestBackwardCompatibility: + """Structured errors are still intercepted by legacy except clauses.""" + + def test_config_file_not_found_caught_as_file_not_found(self, tmp_path): + p = tmp_path / "missing.yml" + with pytest.raises(FileNotFoundError): + raise ConfigFileNotFoundError(p) + + def test_config_io_error_caught_as_os_error(self, tmp_path): + p = tmp_path / "locked.yml" + with pytest.raises(OSError): + raise ConfigIOError(p, PermissionError("denied")) + + def test_workflow_not_found_caught_as_file_not_found(self, tmp_path): + p = tmp_path / "ci.yml" + with pytest.raises(FileNotFoundError): + raise WorkflowNotFoundError(p) + + +# --------------------------------------------------------------------------- +# load_config raises structured errors +# --------------------------------------------------------------------------- + + +class TestLoadConfigStructuredErrors: + """load_config raises the correct structured exception for each failure mode.""" + + def test_file_not_found_raises_config_file_not_found(self): + with pytest.raises(ConfigFileNotFoundError) as exc_info: + load_config("/nonexistent/path/.localci.yml") + assert ".localci.yml" in str(exc_info.value) + + def test_file_not_found_still_caught_as_file_not_found_error(self): + """Backward-compat with existing except FileNotFoundError handlers.""" + with pytest.raises(FileNotFoundError): + load_config("/nonexistent/path/.localci.yml") + + def test_file_not_found_carries_path_attribute(self): + with pytest.raises(ConfigFileNotFoundError) as exc_info: + load_config("/nonexistent/path/.localci.yml") + assert exc_info.value.path == Path("/nonexistent/path/.localci.yml") + + def test_validation_error_raises_config_validation_error(self, tmp_path): + cfg_file = tmp_path / ".localci.yml" + cfg_file.write_text("parallel:\n max_jobs: 999\n") # exceeds max of 64 + with pytest.raises(ConfigValidationError) as exc_info: + load_config(cfg_file) + assert exc_info.value.path == cfg_file + assert exc_info.value.cause is not None + + def test_validation_error_carries_cause(self, tmp_path): + cfg_file = tmp_path / ".localci.yml" + cfg_file.write_text("parallel:\n max_jobs: 0\n") # below min of 1 + with pytest.raises(ConfigValidationError) as exc_info: + load_config(cfg_file) + from pydantic import ValidationError + assert isinstance(exc_info.value.cause, ValidationError) + + def test_valid_config_still_loads(self, tmp_path): + cfg_file = tmp_path / ".localci.yml" + cfg_file.write_text("version: 1\nevent: push\n") + cfg = load_config(cfg_file) + assert cfg.version == 1 + + def test_io_error_raises_config_io_error(self, tmp_path, monkeypatch): + """ConfigIOError is raised when the file cannot be read.""" + cfg_file = tmp_path / ".localci.yml" + cfg_file.write_text("version: 1\n") + + import builtins + original_open = builtins.open + + def broken_open(path, *args, **kwargs): + if Path(str(path)) == cfg_file: + raise PermissionError("permission denied") + return original_open(path, *args, **kwargs) + + monkeypatch.setattr(builtins, "open", broken_open) + + with pytest.raises(ConfigIOError) as exc_info: + load_config(cfg_file) + assert exc_info.value.path == cfg_file + assert isinstance(exc_info.value.cause, PermissionError) + + def test_malformed_yaml_raises_config_io_error(self, tmp_path): + """Invalid YAML is surfaced as ConfigIOError (same as read/parse failures).""" + cfg_file = tmp_path / ".localci.yml" + cfg_file.write_text("version: 1\n bad_indent: x\n") + + with pytest.raises(ConfigIOError) as exc_info: + load_config(cfg_file) + assert exc_info.value.path == cfg_file + assert isinstance(exc_info.value.cause, yaml.YAMLError) + + +# --------------------------------------------------------------------------- +# WorkflowAnalyzer raises structured errors +# --------------------------------------------------------------------------- + + +class TestWorkflowAnalyzerStructuredErrors: + """WorkflowAnalyzer.analyze raises WorkflowNotFoundError / WorkflowParseError.""" + + def test_missing_workflow_raises_workflow_not_found(self, tmp_path): + p = tmp_path / "nonexistent.yml" + analyzer = WorkflowAnalyzer() + with pytest.raises(WorkflowNotFoundError) as exc_info: + analyzer.analyze(p) + assert exc_info.value.path == p + + def test_missing_workflow_caught_as_file_not_found(self, tmp_path): + """Backward-compat.""" + p = tmp_path / "nonexistent.yml" + analyzer = WorkflowAnalyzer() + with pytest.raises(FileNotFoundError): + analyzer.analyze(p) + + def test_missing_workflow_is_workflow_error(self, tmp_path): + p = tmp_path / "nonexistent.yml" + analyzer = WorkflowAnalyzer() + with pytest.raises(WorkflowError): + analyzer.analyze(p) + + +# --------------------------------------------------------------------------- +# CLI: config error paths surface actionable messages and exit 1 +# --------------------------------------------------------------------------- + + +class TestCliConfigErrorPaths: + """CLI exits with code 1 and prints an actionable message for config errors.""" + + def test_explicit_config_not_found(self, tmp_path): + result = runner.invoke(cli, ["--config", str(tmp_path / "missing.yml"), "list"]) + assert result.exit_code != 0 + assert "not found" in result.output.lower() or "missing" in result.output.lower() + + def test_invalid_config_surfaces_validation_detail(self, tmp_path): + cfg = tmp_path / ".localci.yml" + cfg.write_text("parallel:\n max_jobs: 999\n") + result = runner.invoke(cli, ["--config", str(cfg), "list"]) + assert result.exit_code != 0 + # Should include something actionable — not just "Failed to load config" + assert "invalid config" in result.output.lower() or "999" in result.output or "max_jobs" in result.output.lower() + + +# --------------------------------------------------------------------------- +# CLI: workflow error paths surface actionable messages and exit 1 +# --------------------------------------------------------------------------- + + +_FIXTURES = Path(__file__).parent / "fixtures" + + +class TestCliWorkflowErrorPaths: + """CLI run/list exits cleanly when the workflow file is missing or unparseable.""" + + def test_run_missing_workflow(self, tmp_path): + result = runner.invoke( + cli, ["run", "--workflow", str(tmp_path / "nonexistent.yml"), "--dry-run"] + ) + assert result.exit_code != 0 + + def test_list_missing_workflow(self, tmp_path): + result = runner.invoke( + cli, ["list", "--workflow", str(tmp_path / "nonexistent.yml")] + ) + assert result.exit_code != 0 diff --git a/cli/tests/test_executor.py b/cli/tests/test_executor.py index 0087c01..fea1c6f 100644 --- a/cli/tests/test_executor.py +++ b/cli/tests/test_executor.py @@ -991,7 +991,7 @@ def test_docker_not_installed(self, mock_which): from localci.utils.docker import DockerManager - with pytest.raises(RuntimeError, match="not installed"): + with pytest.raises(DockerNotAvailableError, match="not installed"): DockerManager() @patch("subprocess.run") @@ -1002,7 +1002,7 @@ def test_docker_not_responding(self, mock_which, mock_run): from localci.utils.docker import DockerManager - with pytest.raises(RuntimeError, match="not responding"): + with pytest.raises(DockerNotAvailableError, match="not responding"): DockerManager() @patch("subprocess.run") diff --git a/cli/tests/test_workflow.py b/cli/tests/test_workflow.py index 4b5f67e..4ed9de2 100644 --- a/cli/tests/test_workflow.py +++ b/cli/tests/test_workflow.py @@ -571,18 +571,26 @@ class TestErrorClasses: """Error hierarchy and messages.""" def test_workflow_parse_error(self): - err = WorkflowParseError(Path("ci.yml"), "bad yaml") + cause = ValueError("bad yaml") + err = WorkflowParseError(Path("ci.yml"), cause) assert "ci.yml" in str(err) + assert err.cause is cause assert isinstance(err, WorkflowError) def test_unsupported_matrix_error(self): - err = UnsupportedMatrixError({"name": "test"}, "missing field") + entry = {"name": "test"} + err = UnsupportedMatrixError(entry, "missing field") assert "test" in str(err) + assert err.entry is entry + assert err.detail == "missing field" + assert err.name == "test" assert isinstance(err, WorkflowError) def test_missing_field_error(self): err = MissingFieldError("compiler", "matrix entry 3") assert "compiler" in str(err) + assert err.field_name == "compiler" + assert err.context == "matrix entry 3" assert isinstance(err, WorkflowError) # Event filtering # =====================================================================