diff --git a/MIGRATION_PLAN.md b/MIGRATION_PLAN.md index bef2b4e..7534803 100644 --- a/MIGRATION_PLAN.md +++ b/MIGRATION_PLAN.md @@ -365,29 +365,42 @@ cpu_features_t cfd_get_cpu_features(void); **Actual effort:** < 1 day -### Phase 4: Add Error Handling API (Important) +### Phase 4: Add Error Handling API (Important) ✅ COMPLETED **Priority:** P1 - Better debugging -**Tasks:** - -- [ ] **4.1 Expose error functions** - - `get_last_error()` → Python string - - `get_last_status()` → Python enum - - `clear_error()` - -- [ ] **4.2 Create Python exceptions** - - `CFDError` base exception - - `CFDMemoryError` - - `CFDInvalidError` - - `CFDUnsupportedError` +**Status:** Completed on 2026-01-02 -- [ ] **4.3 Integrate with all functions** - - Check return codes - - Raise appropriate exceptions - - Include error messages +**Tasks:** -**Estimated effort:** 1 day +- [x] **4.1 Expose error functions** + - `get_last_error()` → Python string (already in C extension) + - `get_last_status()` → Python enum (already in C extension) + - `get_error_string(code)` → Python string (already in C extension) + - `clear_error()` (already in C extension) + +- [x] **4.2 Create Python exceptions** + - Created `cfd_python/_exceptions.py` with exception hierarchy + - `CFDError` base exception with `status_code` and `message` attributes + - `CFDMemoryError(CFDError, MemoryError)` - for CFD_ERROR_NOMEM (-2) + - `CFDInvalidError(CFDError, ValueError)` - for CFD_ERROR_INVALID (-3) + - `CFDIOError(CFDError, IOError)` - for CFD_ERROR_IO (-4) + - `CFDUnsupportedError(CFDError, NotImplementedError)` - for CFD_ERROR_UNSUPPORTED (-5) + - `CFDDivergedError(CFDError)` - for CFD_ERROR_DIVERGED (-6) + - `CFDMaxIterError(CFDError)` - for CFD_ERROR_MAX_ITER (-7) + +- [x] **4.3 Implement raise_for_status helper** + - `raise_for_status(status_code, context="")` - Raises appropriate exception based on status code + - Maps status codes to exception classes + - Includes error message from C library when available + +- [x] **4.4 Add tests** + - Added tests to `tests/test_errors.py` + - Tests for exception class hierarchy and inheritance + - Tests for `raise_for_status` function with all error codes + - Tests for export verification + +**Actual effort:** < 0.5 days ### Phase 5: Add Backend Availability API (v0.1.6 Feature) ✅ COMPLETED @@ -547,12 +560,12 @@ find_library(CFD_LIBRARY cfd_library) # Unified library name | Phase 2: Boundary Conditions | ~~3-4 days~~ ✅ 1 day | ~~5-7 days~~ 2 days | | Phase 2.5: CI/Build System (v0.1.6) | ✅ 1 day | 3 days | | Phase 3: Derived Fields | ~~1-2 days~~ ✅ < 1 day | 3.5 days | -| Phase 4: Error Handling | 1 day | 4.5 days | -| Phase 5: Backend Availability (v0.1.6) | ✅ 0.5 days | 4 days | -| Phase 6: CPU Features | 1 day | 5 days | -| Phase 7: Docs & Tests | 2 days | 7 days | +| Phase 4: Error Handling | ~~1 day~~ ✅ < 0.5 days | 4 days | +| Phase 5: Backend Availability (v0.1.6) | ✅ 0.5 days | 4.5 days | +| Phase 6: CPU Features | 1 day | 5.5 days | +| Phase 7: Docs & Tests | 2 days | 7.5 days | -**Total estimated effort:** ~~9-10 days~~ ~7 days (4 days completed) +**Total estimated effort:** ~~9-10 days~~ ~7.5 days (4.5 days completed) --- diff --git a/cfd_python/__init__.py b/cfd_python/__init__.py index 1ca7d9c..52b18a4 100644 --- a/cfd_python/__init__.py +++ b/cfd_python/__init__.py @@ -81,6 +81,16 @@ - get_available_backends(): Get list of all available backends """ +from ._exceptions import ( + CFDDivergedError, + CFDError, + CFDInvalidError, + CFDIOError, + CFDMaxIterError, + CFDMemoryError, + CFDUnsupportedError, + raise_for_status, +) from ._version import get_version __version__ = get_version() @@ -166,6 +176,15 @@ "backend_get_name", "list_solvers_by_backend", "get_available_backends", + # Exception classes (Phase 4) + "CFDError", + "CFDMemoryError", + "CFDInvalidError", + "CFDIOError", + "CFDUnsupportedError", + "CFDDivergedError", + "CFDMaxIterError", + "raise_for_status", ] # Load C extension and populate module namespace diff --git a/cfd_python/_exceptions.py b/cfd_python/_exceptions.py new file mode 100644 index 0000000..1d1b52e --- /dev/null +++ b/cfd_python/_exceptions.py @@ -0,0 +1,129 @@ +"""CFD Python exception classes for structured error handling.""" + +__all__ = [ + "CFDError", + "CFDMemoryError", + "CFDInvalidError", + "CFDIOError", + "CFDUnsupportedError", + "CFDDivergedError", + "CFDMaxIterError", + "raise_for_status", +] + + +class CFDError(Exception): + """Base exception for CFD library errors. + + Attributes: + status_code: The CFD status code that triggered the error. + message: Human-readable error message. + """ + + def __init__(self, message: str, status_code: int = -1): + self.status_code = status_code + self.message = message + super().__init__(f"{message} (status={status_code})") + + +class CFDMemoryError(CFDError, MemoryError): + """Raised when CFD library fails to allocate memory. + + Corresponds to CFD_ERROR_NOMEM (-2). + """ + + pass + + +class CFDInvalidError(CFDError, ValueError): + """Raised when an invalid argument is passed to a CFD function. + + Corresponds to CFD_ERROR_INVALID (-3). + """ + + pass + + +class CFDIOError(CFDError, IOError): + """Raised when a file I/O operation fails. + + Corresponds to CFD_ERROR_IO (-4). + """ + + pass + + +class CFDUnsupportedError(CFDError, NotImplementedError): + """Raised when an unsupported operation is requested. + + Corresponds to CFD_ERROR_UNSUPPORTED (-5). + This typically occurs when requesting a backend that is not available. + """ + + pass + + +class CFDDivergedError(CFDError): + """Raised when the solver diverges during computation. + + Corresponds to CFD_ERROR_DIVERGED (-6). + This indicates numerical instability, often due to time step being too large. + """ + + pass + + +class CFDMaxIterError(CFDError): + """Raised when solver reaches maximum iteration limit without converging. + + Corresponds to CFD_ERROR_MAX_ITER (-7). + Consider increasing max_iter or adjusting solver parameters. + """ + + pass + + +# Mapping from status codes to exception classes +_STATUS_TO_EXCEPTION = { + -1: CFDError, # CFD_ERROR (generic) + -2: CFDMemoryError, # CFD_ERROR_NOMEM + -3: CFDInvalidError, # CFD_ERROR_INVALID + -4: CFDIOError, # CFD_ERROR_IO + -5: CFDUnsupportedError, # CFD_ERROR_UNSUPPORTED + -6: CFDDivergedError, # CFD_ERROR_DIVERGED + -7: CFDMaxIterError, # CFD_ERROR_MAX_ITER +} + + +def raise_for_status(status_code: int, context: str = "") -> None: + """Raise an appropriate exception if status_code indicates an error. + + Args: + status_code: The CFD status code to check. + context: Optional context string to include in the error message. + + Raises: + CFDError: Or an appropriate subclass based on the status code. + + Example: + >>> status = some_cfd_operation() + >>> raise_for_status(status, "during simulation step") + """ + if status_code >= 0: + return # Success + + # Try to get error message from CFD library + try: + from . import get_error_string, get_last_error + + error_msg = get_last_error() + if not error_msg: + error_msg = get_error_string(status_code) + except (ImportError, AttributeError): + error_msg = f"CFD error code {status_code}" + + if context: + error_msg = f"{context}: {error_msg}" + + exception_class = _STATUS_TO_EXCEPTION.get(status_code, CFDError) + raise exception_class(error_msg, status_code) diff --git a/tests/test_errors.py b/tests/test_errors.py index 80adea6..e5e277a 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -5,6 +5,16 @@ import pytest import cfd_python +from cfd_python import ( + CFDDivergedError, + CFDError, + CFDInvalidError, + CFDIOError, + CFDMaxIterError, + CFDMemoryError, + CFDUnsupportedError, + raise_for_status, +) class TestErrorHandling: @@ -59,3 +69,143 @@ def test_run_simulation_zero_steps(self): result = cfd_python.run_simulation(5, 5, steps=0) assert isinstance(result, list) assert len(result) == 25 # 5x5 grid + + +class TestExceptionClasses: + """Test exception class hierarchy and attributes""" + + def test_cfd_error_base(self): + """Test CFDError base class""" + err = CFDError("test error", -1) + assert err.message == "test error" + assert err.status_code == -1 + assert "test error" in str(err) + assert "(status=-1)" in str(err) + + def test_cfd_error_default_status(self): + """Test CFDError with default status code""" + err = CFDError("test error") + assert err.status_code == -1 + + def test_cfd_memory_error_inheritance(self): + """Test CFDMemoryError inherits from both CFDError and MemoryError""" + err = CFDMemoryError("out of memory", -2) + assert isinstance(err, CFDError) + assert isinstance(err, MemoryError) + assert err.status_code == -2 + + def test_cfd_invalid_error_inheritance(self): + """Test CFDInvalidError inherits from both CFDError and ValueError""" + err = CFDInvalidError("invalid argument", -3) + assert isinstance(err, CFDError) + assert isinstance(err, ValueError) + assert err.status_code == -3 + + def test_cfd_io_error_inheritance(self): + """Test CFDIOError inherits from both CFDError and IOError""" + err = CFDIOError("file not found", -4) + assert isinstance(err, CFDError) + assert isinstance(err, IOError) + assert err.status_code == -4 + + def test_cfd_unsupported_error_inheritance(self): + """Test CFDUnsupportedError inherits from both CFDError and NotImplementedError""" + err = CFDUnsupportedError("not supported", -5) + assert isinstance(err, CFDError) + assert isinstance(err, NotImplementedError) + assert err.status_code == -5 + + def test_cfd_diverged_error(self): + """Test CFDDivergedError""" + err = CFDDivergedError("solver diverged", -6) + assert isinstance(err, CFDError) + assert err.status_code == -6 + + def test_cfd_max_iter_error(self): + """Test CFDMaxIterError""" + err = CFDMaxIterError("max iterations reached", -7) + assert isinstance(err, CFDError) + assert err.status_code == -7 + + +class TestRaiseForStatus: + """Test raise_for_status function""" + + def test_success_does_not_raise(self): + """Test that success status codes do not raise""" + raise_for_status(0) # CFD_SUCCESS + raise_for_status(1) # Any positive value + + def test_generic_error_raises_cfd_error(self): + """Test that -1 raises CFDError""" + with pytest.raises(CFDError) as exc_info: + raise_for_status(-1) + assert exc_info.value.status_code == -1 + + def test_nomem_raises_cfd_memory_error(self): + """Test that -2 raises CFDMemoryError""" + with pytest.raises(CFDMemoryError) as exc_info: + raise_for_status(-2) + assert exc_info.value.status_code == -2 + + def test_invalid_raises_cfd_invalid_error(self): + """Test that -3 raises CFDInvalidError""" + with pytest.raises(CFDInvalidError) as exc_info: + raise_for_status(-3) + assert exc_info.value.status_code == -3 + + def test_io_raises_cfd_io_error(self): + """Test that -4 raises CFDIOError""" + with pytest.raises(CFDIOError) as exc_info: + raise_for_status(-4) + assert exc_info.value.status_code == -4 + + def test_unsupported_raises_cfd_unsupported_error(self): + """Test that -5 raises CFDUnsupportedError""" + with pytest.raises(CFDUnsupportedError) as exc_info: + raise_for_status(-5) + assert exc_info.value.status_code == -5 + + def test_diverged_raises_cfd_diverged_error(self): + """Test that -6 raises CFDDivergedError""" + with pytest.raises(CFDDivergedError) as exc_info: + raise_for_status(-6) + assert exc_info.value.status_code == -6 + + def test_max_iter_raises_cfd_max_iter_error(self): + """Test that -7 raises CFDMaxIterError""" + with pytest.raises(CFDMaxIterError) as exc_info: + raise_for_status(-7) + assert exc_info.value.status_code == -7 + + def test_unknown_error_raises_cfd_error(self): + """Test that unknown negative codes raise CFDError""" + with pytest.raises(CFDError) as exc_info: + raise_for_status(-99) + assert exc_info.value.status_code == -99 + + def test_context_included_in_message(self): + """Test that context is included in error message""" + with pytest.raises(CFDError) as exc_info: + raise_for_status(-1, "during simulation") + assert "during simulation" in str(exc_info.value) + + +class TestExceptionExports: + """Test that exception classes are properly exported""" + + def test_exceptions_in_all(self): + """Test all exception classes are in __all__""" + exceptions = [ + "CFDError", + "CFDMemoryError", + "CFDInvalidError", + "CFDIOError", + "CFDUnsupportedError", + "CFDDivergedError", + "CFDMaxIterError", + "raise_for_status", + ] + for name in exceptions: + assert name in cfd_python.__all__, f"{name} should be in __all__" + assert hasattr(cfd_python, name), f"{name} should be accessible"