fix(exceptions): replace Any with str | os.PathLike | None for FileSystemError.path #3312

Merged
freemo merged 1 commits from fix/type-safety-filesystem-error-path-hint into master 2026-04-05 21:08:32 +00:00
3 changed files with 163 additions and 2 deletions
@@ -0,0 +1,29 @@
Feature: FileSystemError path parameter type hint
As a developer using the CleverAgents exception hierarchy
I want FileSystemError to accept str, pathlib.Path, and None for the path parameter
So that static analysis tools can catch callers passing incompatible types
Background:
Given the FileSystemError class is imported from cleveragents.core.exceptions
Scenario: Construct FileSystemError with a str path
When I construct a FileSystemError with message "file not found" and path "/tmp/foo.txt"
Then the FileSystemError is created successfully
And the path attribute equals "/tmp/foo.txt"
Scenario: Construct FileSystemError with a pathlib.Path path
When I construct a FileSystemError with message "permission denied" and a pathlib.Path path
Then the FileSystemError is created successfully
And the path attribute is the pathlib.Path instance
Scenario: Construct FileSystemError with None path
When I construct a FileSystemError with message "unknown fs error" and no path
Then the FileSystemError is created successfully
And the path attribute is None
Scenario: FileSystemError path type annotation is str or os.PathLike or None
When I inspect the type annotation of the path parameter in FileSystemError.__init__
Then the annotation is not Any
And the annotation accepts str values
And the annotation accepts os.PathLike values
And the annotation accepts None
@@ -0,0 +1,128 @@
"""Step definitions for FileSystemError path parameter type hint tests."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any, get_args, get_origin, get_type_hints
from behave import given, then, when
from behave.runner import Context
from cleveragents.core.exceptions import FileSystemError
@given("the FileSystemError class is imported from cleveragents.core.exceptions")
def step_import_filesystem_error(context: Context) -> None:
"""Ensure FileSystemError is available."""
context.FileSystemError = FileSystemError
@when('I construct a FileSystemError with message "{message}" and path "{path}"')
def step_construct_with_str_path(context: Context, message: str, path: str) -> None:
"""Construct FileSystemError with a str path."""
context.error = FileSystemError(message=message, path=path)
context.path_value = path
@when('I construct a FileSystemError with message "{message}" and a pathlib.Path path')
def step_construct_with_pathlib_path(context: Context, message: str) -> None:
"""Construct FileSystemError with a pathlib.Path path."""
path = Path("/tmp/test_dir/file.txt")
context.error = FileSystemError(message=message, path=path)
context.path_value = path
@when('I construct a FileSystemError with message "{message}" and no path')
def step_construct_with_none_path(context: Context, message: str) -> None:
"""Construct FileSystemError with no path (defaults to None)."""
context.error = FileSystemError(message=message)
context.path_value = None
@when("I inspect the type annotation of the path parameter in FileSystemError.__init__")
def step_inspect_annotation(context: Context) -> None:
"""Inspect the type annotation of the path parameter."""
hints = get_type_hints(FileSystemError.__init__)
context.path_annotation = hints.get("path")
@then("the FileSystemError is created successfully")
def step_error_created(context: Context) -> None:
"""Verify the FileSystemError was created without error."""
assert isinstance(context.error, FileSystemError), (
f"Expected FileSystemError instance, got {type(context.error)}"
)
@then('the path attribute equals "{expected_path}"')
def step_path_equals_str(context: Context, expected_path: str) -> None:
"""Verify the path attribute equals the expected string."""
assert context.error.path == expected_path, (
f"Expected path '{expected_path}', got '{context.error.path}'"
)
@then("the path attribute is the pathlib.Path instance")
def step_path_is_pathlib(context: Context) -> None:
"""Verify the path attribute is the pathlib.Path instance."""
assert isinstance(context.error.path, Path), (
f"Expected pathlib.Path instance, got {type(context.error.path)}"
)
assert context.error.path == context.path_value, (
f"Expected path '{context.path_value}', got '{context.error.path}'"
)
@then("the path attribute is None")
def step_path_is_none(context: Context) -> None:
"""Verify the path attribute is None."""
Outdated
Review

[READABILITY, Non-blocking] This triple-check pattern is defensive and correct, but a brief inline comment explaining why three checks are needed would help future maintainers. Something like:

# Generic alias representation varies across Python versions:
# - get_origin() works for parameterized generics (os.PathLike[str])
# - Direct identity check handles the bare os.PathLike case
# - __origin__ attribute is a fallback for older typing internals

Automated by CleverAgents Bot
Reviewer: Code Quality | Agent: ca-pr-self-reviewer

**[READABILITY, Non-blocking]** This triple-check pattern is defensive and correct, but a brief inline comment explaining *why* three checks are needed would help future maintainers. Something like: ```python # Generic alias representation varies across Python versions: # - get_origin() works for parameterized generics (os.PathLike[str]) # - Direct identity check handles the bare os.PathLike case # - __origin__ attribute is a fallback for older typing internals ``` --- **Automated by CleverAgents Bot** Reviewer: Code Quality | Agent: ca-pr-self-reviewer
assert context.error.path is None, (
f"Expected path to be None, got '{context.error.path}'"
)
@then("the annotation is not Any")
def step_annotation_not_any(context: Context) -> None:
"""Verify the path annotation is not Any."""
assert context.path_annotation is not Any, (
"path parameter should not be typed as Any"
)
@then("the annotation accepts str values")
def step_annotation_accepts_str(context: Context) -> None:
"""Verify the annotation includes str."""
annotation = context.path_annotation
args = get_args(annotation)
assert str in args, f"Expected str in annotation args, got {args}"
@then("the annotation accepts os.PathLike values")
def step_annotation_accepts_pathlike(context: Context) -> None:
"""Verify the annotation includes os.PathLike."""
annotation = context.path_annotation
args = get_args(annotation)
# Check for os.PathLike or os.PathLike[str] in the union args.
# Three checks are needed because generic alias representation varies across
# Python versions:
# - get_origin() works for parameterized generics (os.PathLike[str])
# - Direct identity check handles the bare os.PathLike case
# - __origin__ attribute is a fallback for older typing internals
pathlike_present = any(
(
get_origin(a) is os.PathLike
or a is os.PathLike
or (hasattr(a, "__origin__") and a.__origin__ is os.PathLike)
)
for a in args
)
assert pathlike_present, f"Expected os.PathLike in annotation args, got {args}"
@then("the annotation accepts None")
def step_annotation_accepts_none(context: Context) -> None:
"""Verify the annotation includes None (NoneType)."""
annotation = context.path_annotation
args = get_args(annotation)
assert type(None) in args, f"Expected NoneType in annotation args, got {args}"
+6 -2
View File
@@ -4,6 +4,7 @@ Based on ADR-005: Error Handling Hierarchy and Exception Strategy.
All exceptions inherit from CleverAgentsError and follow fail-fast principles.
"""
import os
from typing import Any
Review

[CONSISTENCY, Non-blocking] Consider adding -> None return type annotation here for consistency with other __init__ methods in this file (LockConflictError, LockExpiredError, DecisionPhaseViolationError all include -> None). Since this signature is already being reformatted, it would be a low-effort consistency improvement:

def __init__(
    self,
    message: str,
    path: str | os.PathLike[str] | None = None,
    details: dict[str, Any] | None = None,
) -> None:

Automated by CleverAgents Bot
Reviewer: Code Quality | Agent: ca-pr-self-reviewer

**[CONSISTENCY, Non-blocking]** Consider adding `-> None` return type annotation here for consistency with other `__init__` methods in this file (`LockConflictError`, `LockExpiredError`, `DecisionPhaseViolationError` all include `-> None`). Since this signature is already being reformatted, it would be a low-effort consistency improvement: ```python def __init__( self, message: str, path: str | os.PathLike[str] | None = None, details: dict[str, Any] | None = None, ) -> None: ``` --- **Automated by CleverAgents Bot** Reviewer: Code Quality | Agent: ca-pr-self-reviewer
@@ -234,8 +235,11 @@ class FileSystemError(CleverAgentsError):
"""File system operation failures."""
def __init__(
self, message: str, path: Any = None, details: dict[str, Any] | None = None
):
self,
message: str,
path: str | os.PathLike[str] | None = None,
details: dict[str, Any] | None = None,
) -> None:
"""Initialize with path information.
Args: