Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion hier_config/platforms/cisco_xr/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=[
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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="."},
Expand Down
132 changes: 132 additions & 0 deletions tests/test_driver_cisco_xr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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() == ()
19 changes: 19 additions & 0 deletions tests/test_xr_comments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("!")
Loading