diff --git a/CHANGELOG.md b/CHANGELOG.md index c38f0ba..afb32f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Performance benchmarks for parsing, remediation, and iteration (#202). Skipped by default; run with `poetry run pytest -m benchmark -v -s`. +### Fixed + +- `DuplicateChildError` raised when parsing IOS-XR configs with indented `!` section + separators (e.g., ` !`, ` !`). The `per_line_sub` regex was changed from `^!\s*$` + to `^\s*!\s*$` so bare `!` lines at any indentation level are stripped, restoring + v3.4.2 behavior (#231). + --- ## [3.5.0] - 2026-03-19 diff --git a/hier_config/platforms/cisco_xr/driver.py b/hier_config/platforms/cisco_xr/driver.py index af7dcd2..535aaa6 100644 --- a/hier_config/platforms/cisco_xr/driver.py +++ b/hier_config/platforms/cisco_xr/driver.py @@ -179,7 +179,7 @@ def _instantiate_rules() -> HConfigDriverRules: PerLineSubRule(search="^end-group$", replace=" end-group"), PerLineSubRule(search="^end$", replace=""), PerLineSubRule(search="^\\s*#.*", replace=""), - PerLineSubRule(search="^!\\s*$", replace=""), + PerLineSubRule(search="^\\s*!\\s*$", replace=""), ], post_load_callbacks=[_fixup_xr_comments], idempotent_commands=[ diff --git a/pyproject.toml b/pyproject.toml index ee2b35a..a461921 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "hier-config" -version = "3.5.0" +version = "3.5.1" description = "A network configuration query and comparison library, used to build remediation configurations." packages = [ { include="hier_config", from="."}, diff --git a/tests/test_driver_cisco_xr.py b/tests/test_driver_cisco_xr.py index cfc808f..effb3be 100644 --- a/tests/test_driver_cisco_xr.py +++ b/tests/test_driver_cisco_xr.py @@ -639,3 +639,135 @@ def test_sectional_exit_text_multiple_sections() -> None: " 192.0.2.0/24", "end-set", ) + + +def test_indented_bang_section_separators_no_duplicate_child_error() -> None: + """Test that indented ! section separators don't raise DuplicateChildError (issue #231).""" + platform = Platform.CISCO_XR + config_text = """\ +telemetry model-driven + destination-group DEST-GROUP-1 + address-family ipv4 10.0.0.1 port 57000 + encoding self-describing-gpb + protocol tcp + ! + ! + destination-group DEST-GROUP-2 + address-family ipv4 10.0.0.2 port 57000 + encoding self-describing-gpb + protocol tcp + ! + ! + sensor-group SENSOR-1 + sensor-path openconfig-platform:components/component/cpu + sensor-path openconfig-platform:components/component/memory + ! + sensor-group SENSOR-2 + sensor-path openconfig-interfaces:interfaces/interface/state/counters + ! +! +""" + hconfig = get_hconfig(platform, config_text) + telemetry = hconfig.get_child(equals="telemetry model-driven") + assert telemetry is not None + child_texts = [child.text for child in telemetry.children] + assert "destination-group DEST-GROUP-1" in child_texts + assert "destination-group DEST-GROUP-2" in child_texts + assert "sensor-group SENSOR-1" in child_texts + assert "sensor-group SENSOR-2" in child_texts + + +def test_running_with_bang_separators_intended_without_no_remediation() -> None: + """Running config with indented ! separators and intended without produces no remediation.""" + platform = Platform.CISCO_XR + running = get_hconfig( + platform, + """\ +telemetry model-driven + destination-group DEST-GROUP-1 + address-family ipv4 10.0.0.1 port 57000 + encoding self-describing-gpb + protocol tcp + ! + ! + destination-group DEST-GROUP-2 + address-family ipv4 10.0.0.2 port 57000 + encoding self-describing-gpb + protocol tcp + ! + ! +""", + ) + intended = get_hconfig( + platform, + """\ +telemetry model-driven + destination-group DEST-GROUP-1 + address-family ipv4 10.0.0.1 port 57000 + encoding self-describing-gpb + protocol tcp + destination-group DEST-GROUP-2 + address-family ipv4 10.0.0.2 port 57000 + encoding self-describing-gpb + protocol tcp +""", + ) + assert running.config_to_get_to(intended).dump_simple() == () + + +def test_intended_with_bang_comments_running_without_no_remediation() -> None: + """Intended config with ! comment lines and running without produces no remediation.""" + platform = Platform.CISCO_XR + running = get_hconfig( + platform, + """\ +telemetry model-driven + destination-group DEST-GROUP-1 + address-family ipv4 10.0.0.1 port 57000 + encoding self-describing-gpb + protocol tcp + destination-group DEST-GROUP-2 + address-family ipv4 10.0.0.2 port 57000 + encoding self-describing-gpb + protocol tcp +""", + ) + intended = get_hconfig( + platform, + """\ +telemetry model-driven + ! This is a test comment + ! This is a second test comment + destination-group DEST-GROUP-1 + address-family ipv4 10.0.0.1 port 57000 + encoding self-describing-gpb + protocol tcp + destination-group DEST-GROUP-2 + address-family ipv4 10.0.0.2 port 57000 + encoding self-describing-gpb + protocol tcp +""", + ) + assert running.config_to_get_to(intended).dump_simple() == () + + +def test_differing_bang_comment_text_produces_no_remediation() -> None: + """Differing ! comment text between running and intended produces no remediation.""" + platform = Platform.CISCO_XR + running = get_hconfig( + platform, + """\ +router isis backbone + ! original comment + net 49.0001.1921.2022.0222.00 +""", + ) + intended = get_hconfig( + platform, + """\ +router isis backbone + ! completely different comment + net 49.0001.1921.2022.0222.00 +""", + ) + assert running.config_to_get_to(intended).dump_simple() == () diff --git a/tests/test_xr_comments.py b/tests/test_xr_comments.py index 7631260..d16db67 100644 --- a/tests/test_xr_comments.py +++ b/tests/test_xr_comments.py @@ -123,3 +123,22 @@ def test_xr_comment_with_leading_bang_preserved() -> None: net_child = router_isis.get_child(startswith="net ") assert net_child is not None assert "!important note about ISIS" in net_child.comments + + +def test_xr_trailing_comment_with_no_following_sibling_is_dropped() -> None: + """A trailing ! comment at the end of a section with no following sibling is silently dropped.""" + config = get_hconfig( + Platform.CISCO_XR, + """\ +router isis backbone + net 49.0001.1921.2022.0222.00 + ! trailing comment with no following sibling +""", + ) + router_isis = config.get_child(equals="router isis backbone") + assert router_isis is not None + net_child = router_isis.get_child(startswith="net ") + assert net_child is not None + assert len(net_child.comments) == 0 + for child in router_isis.all_children(): + assert not child.text.startswith("!")