From a5e5c1a30a27f8f66e510e11562d957848680a9e Mon Sep 17 00:00:00 2001 From: Monkey Dev Date: Tue, 5 May 2026 15:01:53 -0400 Subject: [PATCH 1/5] feat: implement structured error types --- cli/localci/cli/list.py | 2 +- cli/localci/cli/main.py | 21 ++- cli/localci/cli/run.py | 9 +- cli/localci/core/config.py | 20 ++- cli/localci/core/workflow.py | 25 ++- cli/localci/errors.py | 144 ++++++++++++++++ cli/tests/test_errors.py | 307 +++++++++++++++++++++++++++++++++++ 7 files changed, 501 insertions(+), 27 deletions(-) create mode 100644 cli/localci/errors.py create mode 100644 cli/tests/test_errors.py 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/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..58958f3 100644 --- a/cli/localci/cli/run.py +++ b/cli/localci/cli/run.py @@ -18,6 +18,7 @@ DockerNotAvailableError, JobExecutor, ) +from localci.errors import WorkflowNotFoundError, WorkflowParseError from localci.core.models import JobEvent, JobEventType from localci.core.orchestrator import ( OrchestratorConfig, @@ -158,8 +159,12 @@ def run( try: analyzer = WorkflowAnalyzer() wf = analyzer.analyze(workflow_path) - except Exception as exc: - print_error(f"Failed to parse workflow: {exc}") + except WorkflowNotFoundError as exc: + print_error(str(exc)) + ctx.exit(1) + return + except WorkflowParseError as exc: + print_error(str(exc)) ctx.exit(1) return diff --git a/cli/localci/core/config.py b/cli/localci/core/config.py index 3b97d89..2616f52 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 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/workflow.py b/cli/localci/core/workflow.py index 99d5d4f..d96f9d6 100644 --- a/cli/localci/core/workflow.py +++ b/cli/localci/core/workflow.py @@ -15,6 +15,13 @@ from pathlib import Path from typing import Any, Optional +from localci.errors import ( + LocalCIError, + WorkflowError, + WorkflowNotFoundError, + WorkflowParseError, +) + logger = logging.getLogger(__name__) @@ -324,18 +331,6 @@ def visit(job_id: str) -> None: # ===================================================================== -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.""" @@ -411,8 +406,8 @@ 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 @@ -474,7 +469,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..a995bc3 --- /dev/null +++ b/cli/localci/errors.py @@ -0,0 +1,144 @@ +"""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 +""" + +from __future__ import annotations + +from pathlib import Path +from typing import 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`. + """ + + 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.). + + Attributes + ---------- + path: + Path to the workflow file. + cause: + The underlying parse exception. + """ + + def __init__(self, path: Path, detail: str) -> None: + self.path = Path(path) + self.cause = detail + super().__init__(f"Failed to parse workflow {self.path}: {detail}") diff --git a/cli/tests/test_errors.py b/cli/tests/test_errors.py new file mode 100644 index 0000000..51dc9a3 --- /dev/null +++ b/cli/tests/test_errors.py @@ -0,0 +1,307 @@ +"""Tests for structured error types (Issue #28). + +Covers: +- Exception hierarchy and attribute contracts for every type +- Backward-compatibility: structured errors are still caught by built-in base classes +- load_config raises structured errors for every failure mode +- CLI exits with code 1 and an actionable message for each config error path +- 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 ( + ConfigError, + ConfigFileNotFoundError, + ConfigIOError, + ConfigValidationError, + LocalCIError, + WorkflowError, + WorkflowNotFoundError, + WorkflowParseError, +) + + +runner = CliRunner() + + +# --------------------------------------------------------------------------- +# Exception hierarchy +# --------------------------------------------------------------------------- + + +class TestExceptionHierarchy: + """All custom errors are subclasses of 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) + + +# --------------------------------------------------------------------------- +# 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" + exc = WorkflowParseError(p, "unexpected key") + assert exc.path == p + 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) + + +# --------------------------------------------------------------------------- +# 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 From b6a51f931b5b67137695413cc666b4d933c8a231 Mon Sep 17 00:00:00 2001 From: bradjin8 Date: Wed, 6 May 2026 06:20:18 -0400 Subject: [PATCH 2/5] added more error handlings to use typed error --- cli/localci/cli/images.py | 7 +-- cli/localci/core/__init__.py | 8 +-- cli/localci/core/executor.py | 28 +---------- cli/localci/core/queue.py | 9 +--- cli/localci/core/workflow.py | 22 +------- cli/localci/errors.py | 98 ++++++++++++++++++++++++++++++++++-- cli/localci/utils/docker.py | 6 ++- cli/localci/utils/yq.py | 31 +----------- cli/tests/test_executor.py | 4 +- 9 files changed, 115 insertions(+), 98 deletions(-) diff --git a/cli/localci/cli/images.py b/cli/localci/cli/images.py index 11235f5..04648db 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, @@ -212,7 +213,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 +259,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 +286,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/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/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 d96f9d6..42fd99c 100644 --- a/cli/localci/core/workflow.py +++ b/cli/localci/core/workflow.py @@ -17,6 +17,8 @@ from localci.errors import ( LocalCIError, + MissingFieldError, + UnsupportedMatrixError, WorkflowError, WorkflowNotFoundError, WorkflowParseError, @@ -326,26 +328,6 @@ def visit(job_id: str) -> None: return order -# ===================================================================== -# Errors -# ===================================================================== - - -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 # ===================================================================== diff --git a/cli/localci/errors.py b/cli/localci/errors.py index a995bc3..3dc92d2 100644 --- a/cli/localci/errors.py +++ b/cli/localci/errors.py @@ -11,15 +11,23 @@ │ ├── ConfigFileNotFoundError (also FileNotFoundError) │ ├── ConfigIOError (also OSError) │ └── ConfigValidationError - └── WorkflowError - ├── WorkflowNotFoundError (also FileNotFoundError) - └── WorkflowParseError + ├── 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 Optional +from typing import Any, Optional # --------------------------------------------------------------------------- @@ -142,3 +150,85 @@ def __init__(self, path: Path, detail: str) -> None: self.path = Path(path) self.cause = detail super().__init__(f"Failed to parse workflow {self.path}: {detail}") + + +class MissingFieldError(WorkflowError): + """Required field missing from workflow.""" + + def __init__(self, field_name: str, context: str) -> None: + 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: + name = entry.get("name", "unknown") + super().__init__(f"Unsupported matrix entry '{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" + "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" + ) 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_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") From 40965914260e13532b0a67578b72697e2e2bd480 Mon Sep 17 00:00:00 2001 From: bradjin8 Date: Wed, 6 May 2026 06:39:17 -0400 Subject: [PATCH 3/5] fix: gaps/inconsistencies found during self-review --- cli/localci/cli/config.py | 4 ++-- cli/localci/cli/images.py | 12 ++++++++++-- cli/localci/cli/logs.py | 3 ++- cli/localci/cli/run.py | 14 ++++++-------- cli/localci/cli/status.py | 4 ++-- cli/localci/errors.py | 14 ++++++++++---- cli/tests/test_errors.py | 34 ++++++++++++++++++++++++++++++---- cli/tests/test_workflow.py | 1 + 8 files changed, 63 insertions(+), 23 deletions(-) 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 04648db..eba781b 100644 --- a/cli/localci/cli/images.py +++ b/cli/localci/cli/images.py @@ -81,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)) @@ -123,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: 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/run.py b/cli/localci/cli/run.py index 58958f3..bbcc1b3 100644 --- a/cli/localci/cli/run.py +++ b/cli/localci/cli/run.py @@ -18,7 +18,7 @@ DockerNotAvailableError, JobExecutor, ) -from localci.errors import WorkflowNotFoundError, WorkflowParseError +from localci.errors import WorkflowError from localci.core.models import JobEvent, JobEventType from localci.core.orchestrator import ( OrchestratorConfig, @@ -159,11 +159,7 @@ def run( try: analyzer = WorkflowAnalyzer() wf = analyzer.analyze(workflow_path) - except WorkflowNotFoundError as exc: - print_error(str(exc)) - ctx.exit(1) - return - except WorkflowParseError as exc: + except WorkflowError as exc: print_error(str(exc)) ctx.exit(1) return @@ -215,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: @@ -392,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/errors.py b/cli/localci/errors.py index 3dc92d2..e9813e1 100644 --- a/cli/localci/errors.py +++ b/cli/localci/errors.py @@ -138,17 +138,20 @@ def __init__(self, path: Path, cause: Optional[Exception] = None) -> None: class WorkflowParseError(WorkflowError): """The workflow file could not be parsed (invalid YAML, unexpected structure, etc.). + Use ``raise WorkflowParseError(path, detail) from exc`` to retain the original + exception as :attr:`__cause__`. + Attributes ---------- path: Path to the workflow file. - cause: - The underlying parse exception. + detail: + Human-readable parse failure (typically ``str(exc)`` from the underlying error). """ def __init__(self, path: Path, detail: str) -> None: self.path = Path(path) - self.cause = detail + self.detail = detail super().__init__(f"Failed to parse workflow {self.path}: {detail}") @@ -227,8 +230,11 @@ def __init__(self) -> None: "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: sudo snap install yq OR install from GitHub releases\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/tests/test_errors.py b/cli/tests/test_errors.py index 51dc9a3..75a0099 100644 --- a/cli/tests/test_errors.py +++ b/cli/tests/test_errors.py @@ -1,10 +1,11 @@ """Tests for structured error types (Issue #28). Covers: -- Exception hierarchy and attribute contracts for every type +- 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 every failure mode -- CLI exits with code 1 and an actionable message for each config error path +- 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 """ @@ -22,14 +23,22 @@ 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, ) @@ -42,7 +51,7 @@ class TestExceptionHierarchy: - """All custom errors are subclasses of LocalCIError.""" + """Structured errors used by the CLI and core are under LocalCIError.""" def test_config_error_is_local_ci_error(self): assert issubclass(ConfigError, LocalCIError) @@ -77,6 +86,22 @@ def test_workflow_not_found_is_file_not_found_error(self): 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 @@ -138,6 +163,7 @@ def test_workflow_parse_error_attributes(self, tmp_path): p = tmp_path / "ci.yml" exc = WorkflowParseError(p, "unexpected key") assert exc.path == p + assert exc.detail == "unexpected key" assert "unexpected key" in str(exc) assert str(p) in str(exc) diff --git a/cli/tests/test_workflow.py b/cli/tests/test_workflow.py index 4b5f67e..40ba48f 100644 --- a/cli/tests/test_workflow.py +++ b/cli/tests/test_workflow.py @@ -573,6 +573,7 @@ class TestErrorClasses: def test_workflow_parse_error(self): err = WorkflowParseError(Path("ci.yml"), "bad yaml") assert "ci.yml" in str(err) + assert err.detail == "bad yaml" assert isinstance(err, WorkflowError) def test_unsupported_matrix_error(self): From 84d5526d04e2ba9393adbd03e079788ce97e6d4e Mon Sep 17 00:00:00 2001 From: bradjin8 Date: Wed, 6 May 2026 08:17:15 -0400 Subject: [PATCH 4/5] fix: YAML parsing error may not be caught; WorkflowParseError is dropping structured cause data --- cli/localci/core/config.py | 2 +- cli/localci/core/workflow.py | 6 ++++-- cli/localci/errors.py | 25 +++++++++++++++++-------- cli/tests/test_errors.py | 15 +++++++++++++-- cli/tests/test_workflow.py | 5 +++-- 5 files changed, 38 insertions(+), 15 deletions(-) diff --git a/cli/localci/core/config.py b/cli/localci/core/config.py index 2616f52..7fd0070 100644 --- a/cli/localci/core/config.py +++ b/cli/localci/core/config.py @@ -386,7 +386,7 @@ def load_config(path: Path | str | None = None) -> LocalCIConfig: try: with open(config_path, "r", encoding="utf-8") as fh: raw: dict[str, Any] = yaml.safe_load(fh) or {} - except OSError as exc: + except (OSError, yaml.YAMLError) as exc: raise ConfigIOError(config_path, exc) from exc try: diff --git a/cli/localci/core/workflow.py b/cli/localci/core/workflow.py index 42fd99c..df5198d 100644 --- a/cli/localci/core/workflow.py +++ b/cli/localci/core/workflow.py @@ -391,7 +391,7 @@ def analyze(self, workflow_path: Path, event: Optional[str] = None) -> Workflow: 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) @@ -420,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( diff --git a/cli/localci/errors.py b/cli/localci/errors.py index e9813e1..fb9f7f4 100644 --- a/cli/localci/errors.py +++ b/cli/localci/errors.py @@ -79,7 +79,8 @@ class ConfigIOError(ConfigError, OSError): path: The config file path that could not be read. cause: - The underlying :exc:`OSError`. + The underlying :exc:`OSError` (read failure) or ``yaml.YAMLError`` + from PyYAML (malformed YAML during load). """ def __init__(self, path: Path, cause: Exception) -> None: @@ -138,21 +139,29 @@ def __init__(self, path: Path, cause: Optional[Exception] = None) -> None: class WorkflowParseError(WorkflowError): """The workflow file could not be parsed (invalid YAML, unexpected structure, etc.). - Use ``raise WorkflowParseError(path, detail) from exc`` to retain the original - exception as :attr:`__cause__`. + 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. - detail: - Human-readable parse failure (typically ``str(exc)`` from the underlying error). + cause: + The underlying exception from parsing or analysis. """ - def __init__(self, path: Path, detail: str) -> None: + def __init__( + self, + path: Path, + cause: Exception, + *, + message: Optional[str] = None, + ) -> None: self.path = Path(path) - self.detail = detail - super().__init__(f"Failed to parse workflow {self.path}: {detail}") + 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): diff --git a/cli/tests/test_errors.py b/cli/tests/test_errors.py index 75a0099..ab51954 100644 --- a/cli/tests/test_errors.py +++ b/cli/tests/test_errors.py @@ -161,9 +161,10 @@ def test_workflow_not_found_with_cause(self, tmp_path): def test_workflow_parse_error_attributes(self, tmp_path): p = tmp_path / "ci.yml" - exc = WorkflowParseError(p, "unexpected key") + original = ValueError("unexpected key") + exc = WorkflowParseError(p, original) assert exc.path == p - assert exc.detail == "unexpected key" + assert exc.cause is original assert "unexpected key" in str(exc) assert str(p) in str(exc) @@ -257,6 +258,16 @@ def broken_open(path, *args, **kwargs): 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 diff --git a/cli/tests/test_workflow.py b/cli/tests/test_workflow.py index 40ba48f..25f6bed 100644 --- a/cli/tests/test_workflow.py +++ b/cli/tests/test_workflow.py @@ -571,9 +571,10 @@ 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.detail == "bad yaml" + assert err.cause is cause assert isinstance(err, WorkflowError) def test_unsupported_matrix_error(self): From 9d6cd0d0b89a696a0d038b30cb0f5c3a4f9aa98d Mon Sep 17 00:00:00 2001 From: bradjin8 Date: Wed, 6 May 2026 09:09:28 -0400 Subject: [PATCH 5/5] fix: MIssingFieldError and UnsupportedMatrixError interpolate their inputs --- cli/localci/errors.py | 14 +++++++++++--- cli/tests/test_workflow.py | 8 +++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/cli/localci/errors.py b/cli/localci/errors.py index fb9f7f4..c0b0b05 100644 --- a/cli/localci/errors.py +++ b/cli/localci/errors.py @@ -168,15 +168,23 @@ class MissingFieldError(WorkflowError): """Required field missing from workflow.""" def __init__(self, field_name: str, context: str) -> None: - super().__init__(f"Missing required field '{field_name}' in {context}") + 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: - name = entry.get("name", "unknown") - super().__init__(f"Unsupported matrix entry '{name}': {detail}") + self.entry = entry + self.detail = detail + self.name = entry.get("name", "unknown") + super().__init__( + f"Unsupported matrix entry '{self.name}': {detail}" + ) class CyclicDependencyError(WorkflowError): diff --git a/cli/tests/test_workflow.py b/cli/tests/test_workflow.py index 25f6bed..4ed9de2 100644 --- a/cli/tests/test_workflow.py +++ b/cli/tests/test_workflow.py @@ -578,13 +578,19 @@ def test_workflow_parse_error(self): 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 # =====================================================================