From a9224699623c84d96751527275de97bc0fb01bbd Mon Sep 17 00:00:00 2001 From: jtdub Date: Wed, 25 Mar 2026 22:40:16 -0500 Subject: [PATCH] Add custom exception hierarchy (#219) Add HierConfigError as the base exception with DriverNotFoundError, InvalidConfigError, IncompatibleDriverError, and reparent DuplicateChildError under it. Replace generic ValueError/TypeError raises in constructors and workflows with specific exception types. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 3 ++ hier_config/__init__.py | 12 ++++++++ hier_config/constructors.py | 7 +++-- hier_config/exceptions.py | 18 +++++++++++- hier_config/workflows.py | 3 +- tests/unit/test_constructors.py | 15 ++++++---- tests/unit/test_exceptions.py | 52 +++++++++++++++++++++++++++++++++ tests/unit/test_workflows.py | 4 ++- 8 files changed, 103 insertions(+), 11 deletions(-) create mode 100644 tests/unit/test_exceptions.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c730542..488c8e72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Skipped by default; run with `poetry run pytest -m benchmark -v -s`. - Added support for Huawei VRP with a new driver and test suite (#238). +- Custom exception hierarchy: `HierConfigError` base, `DriverNotFoundError`, + `InvalidConfigError`, `IncompatibleDriverError` (#219). `DuplicateChildError` + reparented under `HierConfigError`. ### Changed diff --git a/hier_config/__init__.py b/hier_config/__init__.py index 9f78fe2b..c56ec3ea 100644 --- a/hier_config/__init__.py +++ b/hier_config/__init__.py @@ -6,6 +6,13 @@ get_hconfig_from_dump, get_hconfig_view, ) +from .exceptions import ( + DriverNotFoundError, + DuplicateChildError, + HierConfigError, + IncompatibleDriverError, + InvalidConfigError, +) from .models import ChangeDetail, MatchRule, Platform, ReportSummary, TagRule from .reporting import RemediationReporter from .root import HConfig @@ -13,8 +20,13 @@ __all__ = ( "ChangeDetail", + "DriverNotFoundError", + "DuplicateChildError", "HConfig", "HConfigChild", + "HierConfigError", + "IncompatibleDriverError", + "InvalidConfigError", "MatchRule", "Platform", "RemediationReporter", diff --git a/hier_config/constructors.py b/hier_config/constructors.py index bdef14e3..4b9d38e0 100644 --- a/hier_config/constructors.py +++ b/hier_config/constructors.py @@ -7,6 +7,7 @@ from hier_config.platforms.driver_base import HConfigDriverBase from .child import HConfigChild +from .exceptions import DriverNotFoundError, InvalidConfigError from .models import Dump, Platform from .platforms.arista_eos.driver import HConfigDriverAristaEOS from .platforms.arista_eos.view import HConfigViewAristaEOS @@ -49,7 +50,7 @@ def get_hconfig_driver(platform: Platform) -> HConfigDriverBase: if driver_cls is None: message = f"Unsupported platform: {platform}" - raise ValueError(message) + raise DriverNotFoundError(message) return driver_cls() @@ -72,7 +73,7 @@ def get_hconfig_view(config: HConfig) -> HConfigViewBase: return HConfigViewHPProcurve(config) message = f"Unsupported platform: {config.driver.__class__.__name__}" - raise ValueError(message) + raise DriverNotFoundError(message) def get_hconfig( @@ -307,4 +308,4 @@ def _load_from_string_lines(config: HConfig, config_text: str) -> None: # noqa: end_indent_adjust.pop(0) if in_banner: message = "we are still in a banner for some reason" - raise ValueError(message) + raise InvalidConfigError(message) diff --git a/hier_config/exceptions.py b/hier_config/exceptions.py index 82d93df2..e4b4ccf2 100644 --- a/hier_config/exceptions.py +++ b/hier_config/exceptions.py @@ -1,2 +1,18 @@ -class DuplicateChildError(Exception): +class HierConfigError(Exception): + """Base exception for all hier_config errors.""" + + +class DuplicateChildError(HierConfigError): """Raised when attempting to add a duplicate child.""" + + +class DriverNotFoundError(HierConfigError): + """Raised when a platform driver cannot be found.""" + + +class InvalidConfigError(HierConfigError): + """Raised for malformed configuration text.""" + + +class IncompatibleDriverError(HierConfigError): + """Raised when configs with mismatched drivers are used together.""" diff --git a/hier_config/workflows.py b/hier_config/workflows.py index a34756f5..7af8a9d1 100644 --- a/hier_config/workflows.py +++ b/hier_config/workflows.py @@ -1,6 +1,7 @@ from collections.abc import Iterable from logging import getLogger +from .exceptions import IncompatibleDriverError from .models import TagRule from .root import HConfig @@ -59,7 +60,7 @@ def __init__( if running_config.driver.__class__ is not generated_config.driver.__class__: message = "The running and generated configs must use the same driver." - raise ValueError(message) + raise IncompatibleDriverError(message) self._remediation_config: HConfig | None = None self._rollback_config: HConfig | None = None diff --git a/tests/unit/test_constructors.py b/tests/unit/test_constructors.py index 47019ae3..4b236d31 100644 --- a/tests/unit/test_constructors.py +++ b/tests/unit/test_constructors.py @@ -18,22 +18,25 @@ _load_from_string_lines, # pyright: ignore[reportPrivateUsage] get_hconfig_fast_generic_load, ) +from hier_config.exceptions import DriverNotFoundError, InvalidConfigError from hier_config.models import Platform from hier_config.root import HConfig def test_get_hconfig_driver_unsupported_platform() -> None: - """Test ValueError when platform is not supported (lines 49-50).""" - with pytest.raises(ValueError, match="Unsupported platform: invalid_platform"): + """Test DriverNotFoundError when platform is not supported (lines 49-50).""" + with pytest.raises( + DriverNotFoundError, match="Unsupported platform: invalid_platform" + ): get_hconfig_driver("invalid_platform") # type: ignore[arg-type] def test_get_hconfig_view_unsupported_platform() -> None: - """Test ValueError when platform is not supported (lines 72-73).""" + """Test DriverNotFoundError when platform is not supported (lines 72-73).""" driver = get_hconfig_driver(Platform.FORTINET_FORTIOS) config = HConfig(driver=driver) with pytest.raises( - ValueError, match="Unsupported platform: HConfigDriverFortinetFortiOS" + DriverNotFoundError, match="Unsupported platform: HConfigDriverFortinetFortiOS" ): get_hconfig_view(config) @@ -237,7 +240,9 @@ def test_load_from_string_lines_with_incomplete_banner() -> None: This is line 1 This is line 2 """ - with pytest.raises(ValueError, match="we are still in a banner for some reason"): + with pytest.raises( + InvalidConfigError, match="we are still in a banner for some reason" + ): _load_from_string_lines(config, config_text) diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py new file mode 100644 index 00000000..cfc3d98e --- /dev/null +++ b/tests/unit/test_exceptions.py @@ -0,0 +1,52 @@ +"""Tests for the custom exception hierarchy (#219).""" + +import pytest + +from hier_config import Platform, WorkflowRemediation, get_hconfig, get_hconfig_driver +from hier_config.exceptions import ( + DriverNotFoundError, + DuplicateChildError, + HierConfigError, + IncompatibleDriverError, + InvalidConfigError, +) + + +def test_hier_config_error_is_base_exception() -> None: + """All custom exceptions inherit from HierConfigError.""" + assert issubclass(DuplicateChildError, HierConfigError) + assert issubclass(DriverNotFoundError, HierConfigError) + assert issubclass(InvalidConfigError, HierConfigError) + assert issubclass(IncompatibleDriverError, HierConfigError) + + +def test_hier_config_error_is_catchable_as_exception() -> None: + """HierConfigError itself inherits from Exception.""" + assert issubclass(HierConfigError, Exception) + + +def test_duplicate_child_error_on_duplicate_section() -> None: + config = get_hconfig(Platform.CISCO_IOS, "interface Loopback0") + with pytest.raises(DuplicateChildError, match="Found a duplicate section"): + config.add_child("interface Loopback0") + + +def test_driver_not_found_error_invalid_platform() -> None: + """get_hconfig_driver raises DriverNotFoundError for unsupported platforms.""" + with pytest.raises(DriverNotFoundError, match="Unsupported platform"): + get_hconfig_driver("bogus_platform") # type: ignore[arg-type] + + +def test_incompatible_driver_error_mismatched_drivers() -> None: + """WorkflowRemediation raises IncompatibleDriverError for mismatched drivers.""" + running = get_hconfig(Platform.CISCO_IOS) + generated = get_hconfig(Platform.ARISTA_EOS) + with pytest.raises(IncompatibleDriverError, match="same driver"): + WorkflowRemediation(running, generated) + + +def test_invalid_config_error_banner_parsing() -> None: + """Malformed banner config raises InvalidConfigError.""" + config_text = "banner motd ^C\nthis banner never ends" + with pytest.raises(InvalidConfigError, match="banner"): + get_hconfig(Platform.CISCO_IOS, config_text) diff --git a/tests/unit/test_workflows.py b/tests/unit/test_workflows.py index 6f5f1e67..eb63fb84 100644 --- a/tests/unit/test_workflows.py +++ b/tests/unit/test_workflows.py @@ -1,6 +1,7 @@ import pytest from hier_config import WorkflowRemediation, get_hconfig +from hier_config.exceptions import IncompatibleDriverError from hier_config.models import Platform, TagRule @@ -50,7 +51,8 @@ def test_remediation_config_driver_mismatch() -> None: generated_config = get_hconfig(Platform.JUNIPER_JUNOS, "dummy_config") with pytest.raises( - ValueError, match=r"The running and generated configs must use the same driver." + IncompatibleDriverError, + match=r"The running and generated configs must use the same driver.", ): WorkflowRemediation(running_config, generated_config)