Skip to content

Fix: structured error types#36

Merged
wpak-ai merged 5 commits into
developfrom
fix/structured-error-types
May 6, 2026
Merged

Fix: structured error types#36
wpak-ai merged 5 commits into
developfrom
fix/structured-error-types

Conversation

@bradjin8
Copy link
Copy Markdown
Collaborator

@bradjin8 bradjin8 commented May 6, 2026

Closes #28

Summary by CodeRabbit

  • Bug Fixes

    • CLI now reports clearer, specific errors for config loading, workflow parsing/missing workflows, Docker availability, and result/log loading; status/logs follow mode supports JSON streaming and improved JSON/file error handling.
    • Job selection treats numeric indexes vs name matches more reliably.
  • Tests

    • Added comprehensive tests covering structured error types and CLI error-paths for config, workflow, executor, and results/log handling.

@bradjin8 bradjin8 self-assigned this May 6, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 6, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 27e9348f-6535-406a-8eb6-f78025586f2d

📥 Commits

Reviewing files that changed from the base of the PR and between 84d5526 and 9d6cd0d.

📒 Files selected for processing (2)
  • cli/localci/errors.py
  • cli/tests/test_workflow.py

📝 Walkthrough

Walkthrough

Centralizes LocalCI error types in a new cli/localci/errors.py, migrates prior in-module exception classes to it, and updates core, utils, and CLI modules to import and handle these structured exceptions; adds/updates tests and tightens many broad exception handlers. (≈30 words)

Changes

Error Hierarchy & CLI Integration

Layer / File(s) Summary
Error Definition
cli/localci/errors.py
Add LocalCIError hierarchy and many specialized subclasses (ConfigError, ConfigFileNotFoundError, ConfigIOError, ConfigValidationError, WorkflowError, WorkflowNotFoundError, WorkflowParseError, MissingFieldError, UnsupportedMatrixError, CyclicDependencyError, ExecutionError, ActNotFoundError, DockerNotAvailableError, YqError, YqNotFoundError) with contextual metadata and messages.
Core Re-exports
cli/localci/core/__init__.py
Re-export error types from localci.errors; narrow executor exports and add queue re-exports (DependencyResolver, PriorityConfig, PriorityJobQueue, PriorityRule).
Error Migration
cli/localci/core/executor.py, cli/localci/core/queue.py
Remove local exception class definitions (ActNotFoundError, DockerNotAvailableError, CyclicDependencyError) and import them from localci.errors.
Config Loading
cli/localci/core/config.py
load_config now raises ConfigFileNotFoundError when a provided path is missing, ConfigIOError on I/O failures, and ConfigValidationError on parsing/validation errors (uses LocalCIConfig.model_validate).
Workflow Analysis
cli/localci/core/workflow.py
Import workflow error types; wrap FileNotFoundError as WorkflowNotFoundError; convert parsing failures into WorkflowParseError (now captures cause); analyze_multiple now catches only WorkflowError.
Utilities
cli/localci/utils/docker.py, cli/localci/utils/yq.py
Docker availability checks now raise DockerNotAvailableError; yq utilities import YqError/YqNotFoundError and move logger init after imports.
CLI Handlers
cli/localci/cli/main.py, cli/localci/cli/run.py, cli/localci/cli/list.py, cli/localci/cli/images.py, cli/localci/cli/logs.py, cli/localci/cli/status.py
Replace broad exception catches with targeted handlers for config/workflow/docker/json errors (e.g., ConfigFileNotFoundError, ConfigIOError, ConfigValidationError, WorkflowError, DockerNotAvailableError, json.JSONDecodeError, OSError, TypeError, ValueError); adjust messages/exits and small control-flow clarifications.
Tests
cli/tests/test_errors.py, cli/tests/test_executor.py, cli/tests/test_workflow.py
Add/modify tests validating exception hierarchy, attributes (e.g., WorkflowParseError.cause), backward-compatibility with built-ins, load_config and WorkflowAnalyzer error mapping, CLI exit codes/messages; executor tests expect DockerNotAvailableError instead of RuntimeError.

Sequence Diagram(s)

sequenceDiagram
  participant CLI as CLI
  participant Config as ConfigLoader
  participant Workflow as WorkflowAnalyzer
  participant Docker as DockerUtils
  participant FS as Filesystem

  CLI->>Config: load_config(path)
  alt config file missing
    Config-->>CLI: ConfigFileNotFoundError
    CLI->>CLI: print error & exit
  else config IO error
    Config-->>CLI: ConfigIOError
    CLI->>CLI: print error & exit
  else config invalid
    Config-->>CLI: ConfigValidationError
    CLI->>CLI: print error & exit
  else config ok
    CLI->>Workflow: analyze(workflow_path)
    alt workflow missing
      Workflow-->>CLI: WorkflowNotFoundError
      CLI->>CLI: print error & exit
    else parse error
      Workflow-->>CLI: WorkflowParseError
      CLI->>CLI: print error & exit
    else workflow ok
      CLI->>Docker: check_docker()
      alt docker unavailable
        Docker-->>CLI: DockerNotAvailableError
        CLI->>CLI: print error & exit
      else docker available
        CLI->>FS: run jobs / read/write results
        FS-->>CLI: results
      end
    end
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • AuraMindNest
  • whisper67265

Poem

🐰 In burrows of code the errors align,

Paths and causes now labelled and fine.
Configs speak clearly, workflows confess,
Docker and yq give helpful progress.
Hop on, run tests — the rabbit says "yes!"

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 26.76% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Fix: structured error types' accurately summarizes the main objective of the PR, which is to introduce and use structured error types throughout the codebase to replace generic exception handling.
Linked Issues check ✅ Passed The PR comprehensively addresses all acceptance criteria from issue #28: defines structured error types via a new errors.py module with a hierarchical exception system, replaces bare except blocks throughout multiple files with specific exception handling, includes contextual metadata (paths, causes, messages) in each error type, and adds comprehensive test coverage in test_errors.py.
Out of Scope Changes check ✅ Passed All code changes are directly aligned with the PR objective of introducing structured error types. Changes span error definition (errors.py), error imports/usage across modules (cli, core, utils), test coverage updates, and minor documentation improvements. No unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/structured-error-types

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
cli/localci/utils/docker.py (1)

52-57: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Normalize docker version execution failures to DockerNotAvailableError

subprocess.run(...) here can raise TimeoutExpired/OSError, which currently escape as raw exceptions instead of the new structured type.

Suggested fix
-        result = subprocess.run(
-            [self._docker_path, "version", "--format", "{{.Server.Version}}"],
-            capture_output=True,
-            text=True,
-            timeout=10,
-        )
+        try:
+            result = subprocess.run(
+                [self._docker_path, "version", "--format", "{{.Server.Version}}"],
+                capture_output=True,
+                text=True,
+                timeout=10,
+            )
+        except (OSError, subprocess.TimeoutExpired) as exc:
+            raise DockerNotAvailableError(f"Docker daemon check failed: {exc}") from exc
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/localci/utils/docker.py` around lines 52 - 57, The subprocess.run call
that executes the Docker CLI ([self._docker_path, "version", ...]) can raise
TimeoutExpired or OSError which should be normalized to the new
DockerNotAvailableError; wrap the subprocess.run invocation in a try/except that
catches subprocess.TimeoutExpired and OSError and then raise
DockerNotAvailableError (preserving the original exception as the cause or
including its message) so callers get the structured error instead of raw
exceptions; update the block around the subprocess.run call (the code
referencing self._docker_path and result) to perform this conversion.
cli/localci/core/workflow.py (1)

389-397: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

FileNotFoundError can still leak unwrapped from analyze()

The Line 391 wrapper covers only workflow_name(). Subsequent yq reads can still raise raw FileNotFoundError, which breaks the structured-error flow.

Suggested direction

Wrap the full parsing block (all yq reads + job parsing) so any FileNotFoundError is converted to WorkflowNotFoundError consistently, while preserving existing WorkflowError passthrough.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/localci/core/workflow.py` around lines 389 - 397, The analyze() flow
currently only wraps self.yq.workflow_name(...) in a FileNotFoundError ->
WorkflowNotFoundError conversion, so subsequent yq calls (e.g.,
self.yq.events(...)) or job parsing can still raise raw FileNotFoundError; wrap
the entire parsing block that calls self.yq.workflow_name, self.yq.events and
any job-parsing logic inside a single try/except that catches FileNotFoundError
and re-raises WorkflowNotFoundError(workflow_path, exc), let existing
WorkflowError (and WorkflowParseError) pass through unchanged, and for other
exceptions continue to raise WorkflowParseError(workflow_path, str(exc)) so all
file-not-found cases are consistently converted while preserving current error
semantics.
🧹 Nitpick comments (2)
cli/tests/test_errors.py (1)

291-291: 💤 Low value

Unused constant _FIXTURES.

The _FIXTURES path constant is defined but not used in any of the tests in this file. Consider removing it if not needed, or verify if it's intended for future tests.

♻️ Remove unused constant
-_FIXTURES = Path(__file__).parent / "fixtures"
-
-
 class TestCliWorkflowErrorPaths:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/tests/test_errors.py` at line 291, Remove the unused module-level
constant _FIXTURES defined in test_errors.py (the Path(...) assignment) since it
is not referenced by any tests; either delete the _FIXTURES declaration or
replace its use where intended, ensuring no other tests import or rely on that
symbol (search for _FIXTURES in the file to confirm).
cli/tests/test_executor.py (1)

23-30: 💤 Low value

Consider importing error types from their canonical location.

The test imports ActNotFoundError and DockerNotAvailableError from localci.core.executor, but these are now defined in localci.errors. While this works (since executor imports them), importing from the canonical module improves clarity and future maintainability.

♻️ Suggested import path update
 from localci.core.executor import (
     ActCommand,
-    ActNotFoundError,
-    DockerNotAvailableError,
     JobExecutor,
     JobResult,
     JobStatus,
 )
+from localci.errors import ActNotFoundError, DockerNotAvailableError
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/tests/test_executor.py` around lines 23 - 30, Update the test imports to
use the canonical error module: replace importing ActNotFoundError and
DockerNotAvailableError from localci.core.executor with imports from
localci.errors; modify the import statement in cli/tests/test_executor.py so
JobExecutor, JobResult, JobStatus, and ActCommand still come from
localci.core.executor but ActNotFoundError and DockerNotAvailableError are
imported from localci.errors to reflect their true definitions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cli/localci/cli/run.py`:
- Around line 162-168: The code currently catches only WorkflowNotFoundError and
WorkflowParseError; add a final except block for the base WorkflowError to
prevent other structured workflow exceptions from bubbling out. After the two
existing excepts, add "except WorkflowError as exc:" that calls
print_error(str(exc)) and ctx.exit(1) (matching the other handlers) so all
WorkflowError subclasses are caught and handled; reference the existing
exception classes WorkflowNotFoundError, WorkflowParseError and WorkflowError
and the functions print_error and ctx.exit to locate where to insert this
fallback.

In `@cli/localci/core/config.py`:
- Around line 386-395: The YAML parsing call yaml.safe_load can raise
yaml.YAMLError which is not currently caught; update the try/except around
opening/reading/parsing (the block using config_path and yaml.safe_load) to also
catch yaml.YAMLError (either by adding a separate except or by combining
exceptions like except (OSError, yaml.YAMLError) as exc) and re-raise it wrapped
in the same structured error type (raise ConfigIOError(config_path, exc) from
exc) so malformed YAML is reported consistently; leave the subsequent
LocalCIConfig.model_validate and ConfigValidationError handling unchanged.

In `@cli/localci/errors.py`:
- Around line 149-152: The constructor for WorkflowParseError is currently
storing the cause as a string; change WorkflowParseError.__init__ to accept the
original exception object (e.g., parameter name cause: Exception or detail:
Exception) and assign self.cause = cause (the exception) while still using
str(cause) or a supplied message in the super().__init__ call; ensure self.path
remains Path(path) and the exception message remains "Failed to parse workflow
{self.path}: {detail_or_str(cause)}" so callers retain structured access to the
original exception via the self.cause attribute.

---

Outside diff comments:
In `@cli/localci/core/workflow.py`:
- Around line 389-397: The analyze() flow currently only wraps
self.yq.workflow_name(...) in a FileNotFoundError -> WorkflowNotFoundError
conversion, so subsequent yq calls (e.g., self.yq.events(...)) or job parsing
can still raise raw FileNotFoundError; wrap the entire parsing block that calls
self.yq.workflow_name, self.yq.events and any job-parsing logic inside a single
try/except that catches FileNotFoundError and re-raises
WorkflowNotFoundError(workflow_path, exc), let existing WorkflowError (and
WorkflowParseError) pass through unchanged, and for other exceptions continue to
raise WorkflowParseError(workflow_path, str(exc)) so all file-not-found cases
are consistently converted while preserving current error semantics.

In `@cli/localci/utils/docker.py`:
- Around line 52-57: The subprocess.run call that executes the Docker CLI
([self._docker_path, "version", ...]) can raise TimeoutExpired or OSError which
should be normalized to the new DockerNotAvailableError; wrap the subprocess.run
invocation in a try/except that catches subprocess.TimeoutExpired and OSError
and then raise DockerNotAvailableError (preserving the original exception as the
cause or including its message) so callers get the structured error instead of
raw exceptions; update the block around the subprocess.run call (the code
referencing self._docker_path and result) to perform this conversion.

---

Nitpick comments:
In `@cli/tests/test_errors.py`:
- Line 291: Remove the unused module-level constant _FIXTURES defined in
test_errors.py (the Path(...) assignment) since it is not referenced by any
tests; either delete the _FIXTURES declaration or replace its use where
intended, ensuring no other tests import or rely on that symbol (search for
_FIXTURES in the file to confirm).

In `@cli/tests/test_executor.py`:
- Around line 23-30: Update the test imports to use the canonical error module:
replace importing ActNotFoundError and DockerNotAvailableError from
localci.core.executor with imports from localci.errors; modify the import
statement in cli/tests/test_executor.py so JobExecutor, JobResult, JobStatus,
and ActCommand still come from localci.core.executor but ActNotFoundError and
DockerNotAvailableError are imported from localci.errors to reflect their true
definitions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9415c8bb-3fda-47cc-ba0c-0c86f5d0e53b

📥 Commits

Reviewing files that changed from the base of the PR and between d2c2738 and b6a51f9.

📒 Files selected for processing (14)
  • cli/localci/cli/images.py
  • cli/localci/cli/list.py
  • cli/localci/cli/main.py
  • cli/localci/cli/run.py
  • cli/localci/core/__init__.py
  • cli/localci/core/config.py
  • cli/localci/core/executor.py
  • cli/localci/core/queue.py
  • cli/localci/core/workflow.py
  • cli/localci/errors.py
  • cli/localci/utils/docker.py
  • cli/localci/utils/yq.py
  • cli/tests/test_errors.py
  • cli/tests/test_executor.py

Comment thread cli/localci/cli/run.py Outdated
Comment thread cli/localci/core/config.py
Comment thread cli/localci/errors.py Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
cli/localci/core/workflow.py (1)

389-426: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve existing structured errors and wrap all yq reads consistently.

analyze() only wraps workflow_name(). Failures from events(), global_env(), concurrency(), or job_names() still escape raw, and any YqError/YqNotFoundError raised in these paths gets flattened into WorkflowParseError instead of keeping its original structured type.

Suggested change
         try:
             name = self.yq.workflow_name(workflow_path)
+            events = self.yq.events(workflow_path)
+            env = self.yq.global_env(workflow_path)
+            concurrency = self.yq.concurrency(workflow_path)
+            job_ids = self.yq.job_names(workflow_path)
+        except LocalCIError:
+            raise
         except FileNotFoundError as exc:
             raise WorkflowNotFoundError(workflow_path, exc) from exc
         except Exception as exc:
             raise WorkflowParseError(workflow_path, exc) from exc
-
-        events = self.yq.events(workflow_path)
-        env = self.yq.global_env(workflow_path)
-        concurrency = self.yq.concurrency(workflow_path)
-
-        job_ids = self.yq.job_names(workflow_path)
 
         jobs: dict[str, Job] = {}
@@
             try:
                 jobs[job_id] = self._parse_job(workflow_path, job_id)
             except WorkflowError:
                 raise
+            except LocalCIError:
+                raise
             except Exception as exc:
                 raise WorkflowParseError(
                     workflow_path,
                     exc,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/localci/core/workflow.py` around lines 389 - 426, The code only wraps
self.yq.workflow_name in structured error handling; wrap the other yq reads
(self.yq.events, self.yq.global_env, self.yq.concurrency, self.yq.job_names) the
same way inside analyze(): for each call, catch FileNotFoundError and raise
WorkflowNotFoundError(workflow_path, exc) from exc, let YqError and
YqNotFoundError (or other existing yq-specific exceptions) propagate (or
re-raise) so their structured types are preserved, and only convert unexpected
exceptions into WorkflowParseError(workflow_path, exc) (including a helpful
message). Apply this same pattern before iterating jobs and keep the existing
job-parsing try/except around self._parse_job as-is.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cli/localci/errors.py`:
- Around line 167-179: Modify the two error classes so they preserve structured
metadata as attributes instead of just interpolating into the message: in
MissingFieldError.__init__ store self.field_name and self.context (and keep the
super().__init__ call with the message), and in UnsupportedMatrixError.__init__
store self.entry, self.detail and maybe self.name = entry.get("name", "unknown")
(again still call super().__init__ with the formatted message). This ensures
downstream code can access the original values from the MissingFieldError and
UnsupportedMatrixError instances without parsing the error string.

---

Outside diff comments:
In `@cli/localci/core/workflow.py`:
- Around line 389-426: The code only wraps self.yq.workflow_name in structured
error handling; wrap the other yq reads (self.yq.events, self.yq.global_env,
self.yq.concurrency, self.yq.job_names) the same way inside analyze(): for each
call, catch FileNotFoundError and raise WorkflowNotFoundError(workflow_path,
exc) from exc, let YqError and YqNotFoundError (or other existing yq-specific
exceptions) propagate (or re-raise) so their structured types are preserved, and
only convert unexpected exceptions into WorkflowParseError(workflow_path, exc)
(including a helpful message). Apply this same pattern before iterating jobs and
keep the existing job-parsing try/except around self._parse_job as-is.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d10bf786-003a-4387-b868-9c35f5c75bb0

📥 Commits

Reviewing files that changed from the base of the PR and between 4096591 and 84d5526.

📒 Files selected for processing (5)
  • cli/localci/core/config.py
  • cli/localci/core/workflow.py
  • cli/localci/errors.py
  • cli/tests/test_errors.py
  • cli/tests/test_workflow.py

Comment thread cli/localci/errors.py Outdated
@bradjin8 bradjin8 requested review from jonathanMLDev and wpak-ai May 6, 2026 13:31
@wpak-ai wpak-ai merged commit 2bfe042 into develop May 6, 2026
4 checks passed
@wpak-ai wpak-ai deleted the fix/structured-error-types branch May 6, 2026 16:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Silent failure chain: structured error types for main.py

2 participants