diff --git a/hier_config/child.py b/hier_config/child.py index 1305745..a558b9c 100644 --- a/hier_config/child.py +++ b/hier_config/child.py @@ -114,7 +114,12 @@ def lines(self, *, sectional_exiting: bool = False) -> Iterable[str]: yield from child.lines(sectional_exiting=sectional_exiting) if sectional_exiting and (exit_text := self.sectional_exit): - yield " " * self.driver.rules.indentation * self.depth() + exit_text + depth = ( + self.depth() - 1 + if self.sectional_exit_text_parent_level + else self.depth() + ) + yield " " * self.driver.rules.indentation * depth + exit_text @property def sectional_exit(self) -> str | None: @@ -129,6 +134,14 @@ def sectional_exit(self) -> str | None: return "exit" + @property + def sectional_exit_text_parent_level(self) -> bool: + for rule in self.driver.rules.sectional_exiting: + if self.is_lineage_match(rule.match_rules): + return rule.exit_text_parent_level + + return False + def delete_sectional_exit(self) -> None: try: potential_exit = self.children[-1] diff --git a/hier_config/models.py b/hier_config/models.py index 3e61e81..927196a 100644 --- a/hier_config/models.py +++ b/hier_config/models.py @@ -34,6 +34,7 @@ class TagRule(BaseModel): class SectionalExitingRule(BaseModel): match_rules: tuple[MatchRule, ...] exit_text: str + exit_text_parent_level: bool = False class SectionalOverwriteRule(BaseModel): diff --git a/hier_config/platforms/cisco_xr/driver.py b/hier_config/platforms/cisco_xr/driver.py index a4e3698..fb6c752 100644 --- a/hier_config/platforms/cisco_xr/driver.py +++ b/hier_config/platforms/cisco_xr/driver.py @@ -39,30 +39,37 @@ def _instantiate_rules() -> HConfigDriverRules: SectionalExitingRule( match_rules=(MatchRule(startswith="route-policy"),), exit_text="end-policy", + exit_text_parent_level=True, ), SectionalExitingRule( match_rules=(MatchRule(startswith="prefix-set"),), exit_text="end-set", + exit_text_parent_level=True, ), SectionalExitingRule( match_rules=(MatchRule(startswith="policy-map"),), exit_text="end-policy-map", + exit_text_parent_level=True, ), SectionalExitingRule( match_rules=(MatchRule(startswith="class-map"),), exit_text="end-class-map", + exit_text_parent_level=True, ), SectionalExitingRule( match_rules=(MatchRule(startswith="community-set"),), exit_text="end-set", + exit_text_parent_level=True, ), SectionalExitingRule( match_rules=(MatchRule(startswith="extcommunity-set"),), exit_text="end-set", + exit_text_parent_level=True, ), SectionalExitingRule( match_rules=(MatchRule(startswith="template"),), exit_text="end-template", + exit_text_parent_level=True, ), SectionalExitingRule( match_rules=(MatchRule(startswith="interface"),), diff --git a/tests/test_driver_cisco_xr.py b/tests/test_driver_cisco_xr.py index 18d53a2..c1eeddd 100644 --- a/tests/test_driver_cisco_xr.py +++ b/tests/test_driver_cisco_xr.py @@ -128,3 +128,268 @@ def test_ipv4_acl_sequence_number_addition() -> None: "ipv4 access-list TEST_ACL", " 20 permit tcp any any eq 22", ) + + +def test_sectional_exit_text_parent_level_route_policy() -> None: + """Test that route-policy exit text appears at parent level (no indentation).""" + platform = Platform.CISCO_XR + config = get_hconfig_fast_load( + platform, + ( + "route-policy TEST", + " set local-preference 200", + " pass", + ), + ) + + route_policy = config.get_child(equals="route-policy TEST") + assert route_policy is not None + assert route_policy.sectional_exit_text_parent_level is True + + output = config.dump_simple(sectional_exiting=True) + assert output == ( + "route-policy TEST", + " set local-preference 200", + " pass", + "end-policy", + ) + + +def test_sectional_exit_text_parent_level_prefix_set() -> None: + """Test that prefix-set exit text appears at parent level (no indentation).""" + platform = Platform.CISCO_XR + config = get_hconfig_fast_load( + platform, + ( + "prefix-set TEST_PREFIX", + " 192.0.2.0/24", + " 198.51.100.0/24", + ), + ) + + prefix_set = config.get_child(equals="prefix-set TEST_PREFIX") + assert prefix_set is not None + assert prefix_set.sectional_exit_text_parent_level is True + + output = config.dump_simple(sectional_exiting=True) + assert output == ( + "prefix-set TEST_PREFIX", + " 192.0.2.0/24", + " 198.51.100.0/24", + "end-set", + ) + + +def test_sectional_exit_text_parent_level_policy_map() -> None: + """Test that policy-map exit text appears at parent level (no indentation).""" + platform = Platform.CISCO_XR + config = get_hconfig_fast_load( + platform, + ( + "policy-map TEST_POLICY", + " class TEST_CLASS", + " set precedence 5", + ), + ) + + policy_map = config.get_child(equals="policy-map TEST_POLICY") + assert policy_map is not None + assert policy_map.sectional_exit_text_parent_level is True + + output = config.dump_simple(sectional_exiting=True) + assert output == ( + "policy-map TEST_POLICY", + " class TEST_CLASS", + " set precedence 5", + " exit", + "end-policy-map", + ) + + +def test_sectional_exit_text_parent_level_class_map() -> None: + """Test that class-map exit text appears at parent level (no indentation).""" + platform = Platform.CISCO_XR + config = get_hconfig_fast_load( + platform, + ( + "class-map match-any TEST_CLASS", + " match access-group TEST_ACL", + ), + ) + + class_map = config.get_child(equals="class-map match-any TEST_CLASS") + assert class_map is not None + assert class_map.sectional_exit_text_parent_level is True + + output = config.dump_simple(sectional_exiting=True) + assert output == ( + "class-map match-any TEST_CLASS", + " match access-group TEST_ACL", + "end-class-map", + ) + + +def test_sectional_exit_text_parent_level_community_set() -> None: + """Test that community-set exit text appears at parent level (no indentation).""" + platform = Platform.CISCO_XR + config = get_hconfig_fast_load( + platform, + ( + "community-set TEST_COMM", + " 65001:100", + " 65001:200", + ), + ) + + community_set = config.get_child(equals="community-set TEST_COMM") + assert community_set is not None + assert community_set.sectional_exit_text_parent_level is True + + output = config.dump_simple(sectional_exiting=True) + assert output == ( + "community-set TEST_COMM", + " 65001:100", + " 65001:200", + "end-set", + ) + + +def test_sectional_exit_text_parent_level_extcommunity_set() -> None: + """Test that extcommunity-set exit text appears at parent level (no indentation).""" + platform = Platform.CISCO_XR + config = get_hconfig_fast_load( + platform, + ( + "extcommunity-set rt TEST_RT", + " 1:100", + " 2:200", + ), + ) + + extcommunity_set = config.get_child(equals="extcommunity-set rt TEST_RT") + assert extcommunity_set is not None + assert extcommunity_set.sectional_exit_text_parent_level is True + + output = config.dump_simple(sectional_exiting=True) + assert output == ( + "extcommunity-set rt TEST_RT", + " 1:100", + " 2:200", + "end-set", + ) + + +def test_sectional_exit_text_parent_level_template() -> None: + """Test that template exit text appears at parent level (no indentation).""" + platform = Platform.CISCO_XR + config = get_hconfig_fast_load( + platform, + ( + "template TEST_TEMPLATE", + " description test template", + ), + ) + + template = config.get_child(equals="template TEST_TEMPLATE") + assert template is not None + assert template.sectional_exit_text_parent_level is True + + output = config.dump_simple(sectional_exiting=True) + assert output == ( + "template TEST_TEMPLATE", + " description test template", + "end-template", + ) + + +def test_sectional_exit_text_current_level_interface() -> None: + """Test that interface exit text appears at current level (with indentation).""" + platform = Platform.CISCO_XR + config = get_hconfig_fast_load( + platform, + ( + "interface GigabitEthernet0/0/0/0", + " description test interface", + " ipv4 address 192.0.2.1 255.255.255.0", + ), + ) + + interface = config.get_child(equals="interface GigabitEthernet0/0/0/0") + assert interface is not None + assert interface.sectional_exit_text_parent_level is False + + output = config.dump_simple(sectional_exiting=True) + assert output == ( + "interface GigabitEthernet0/0/0/0", + " description test interface", + " ipv4 address 192.0.2.1 255.255.255.0", + " root", + ) + + +def test_sectional_exit_text_current_level_router_bgp() -> None: + """Test that router bgp exit text appears at current level (with indentation).""" + platform = Platform.CISCO_XR + config = get_hconfig_fast_load( + platform, + ( + "router bgp 65000", + " bgp router-id 192.0.2.1", + " address-family ipv4 unicast", + ), + ) + + router_bgp = config.get_child(equals="router bgp 65000") + assert router_bgp is not None + assert router_bgp.sectional_exit_text_parent_level is False + + output = config.dump_simple(sectional_exiting=True) + assert output == ( + "router bgp 65000", + " bgp router-id 192.0.2.1", + " address-family ipv4 unicast", + " root", + ) + + +def test_sectional_exit_text_multiple_sections() -> None: + """Test multiple sections with different exit text level behaviors.""" + platform = Platform.CISCO_XR + config = get_hconfig_fast_load( + platform, + ( + "route-policy TEST1", + " pass", + "!", + "interface GigabitEthernet0/0/0/0", + " description test", + "!", + "prefix-set TEST_PREFIX", + " 192.0.2.0/24", + ), + ) + + route_policy = config.get_child(equals="route-policy TEST1") + assert route_policy is not None + assert route_policy.sectional_exit_text_parent_level is True + + interface = config.get_child(equals="interface GigabitEthernet0/0/0/0") + assert interface is not None + assert interface.sectional_exit_text_parent_level is False + + prefix_set = config.get_child(equals="prefix-set TEST_PREFIX") + assert prefix_set is not None + assert prefix_set.sectional_exit_text_parent_level is True + + output = config.dump_simple(sectional_exiting=True) + assert output == ( + "route-policy TEST1", + " pass", + "end-policy", + "interface GigabitEthernet0/0/0/0", + " description test", + " root", + "prefix-set TEST_PREFIX", + " 192.0.2.0/24", + "end-set", + ) diff --git a/tests/test_hier_config.py b/tests/test_hier_config.py index 7ff3863..cb02957 100644 --- a/tests/test_hier_config.py +++ b/tests/test_hier_config.py @@ -1420,3 +1420,227 @@ def test_hconfig_deep_copy() -> None: assert original_interface is not None assert copied_interface is not None assert original_interface is not copied_interface + + +def test_sectional_exit_text_parent_level_cisco_xr() -> None: + """Test sectional_exit_text_parent_level returns True for Cisco XR configs with parent-level exit text.""" + platform = Platform.CISCO_XR + config = get_hconfig(platform) + + # Test route-policy which has exit_text_parent_level=True + route_policy = config.add_child("route-policy TEST") + assert route_policy.sectional_exit_text_parent_level is True + + # Test prefix-set which has exit_text_parent_level=True + prefix_set = config.add_child("prefix-set TEST") + assert prefix_set.sectional_exit_text_parent_level is True + + # Test policy-map which has exit_text_parent_level=True + policy_map = config.add_child("policy-map TEST") + assert policy_map.sectional_exit_text_parent_level is True + + # Test class-map which has exit_text_parent_level=True + class_map = config.add_child("class-map TEST") + assert class_map.sectional_exit_text_parent_level is True + + # Test community-set which has exit_text_parent_level=True + community_set = config.add_child("community-set TEST") + assert community_set.sectional_exit_text_parent_level is True + + # Test extcommunity-set which has exit_text_parent_level=True + extcommunity_set = config.add_child("extcommunity-set TEST") + assert extcommunity_set.sectional_exit_text_parent_level is True + + # Test template which has exit_text_parent_level=True + template = config.add_child("template TEST") + assert template.sectional_exit_text_parent_level is True + + +def test_sectional_exit_text_parent_level_cisco_xr_false() -> None: + """Test sectional_exit_text_parent_level returns False for Cisco XR configs without parent-level exit text.""" + platform = Platform.CISCO_XR + config = get_hconfig(platform) + + # Test interface which has exit_text_parent_level=False (default) + interface = config.add_child("interface GigabitEthernet0/0/0/0") + assert interface.sectional_exit_text_parent_level is False + + # Test router bgp which has exit_text_parent_level=False (default) + router_bgp = config.add_child("router bgp 65000") + assert router_bgp.sectional_exit_text_parent_level is False + + +def test_sectional_exit_text_parent_level_cisco_ios() -> None: + """Test sectional_exit_text_parent_level returns False for standard Cisco IOS configs.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + + # Cisco IOS interfaces don't have exit_text_parent_level=True + interface = config.add_child("interface GigabitEthernet0/0") + assert interface.sectional_exit_text_parent_level is False + + # Cisco IOS router configurations don't have exit_text_parent_level=True + router = config.add_child("router ospf 1") + assert router.sectional_exit_text_parent_level is False + + # Standard configuration sections + line = config.add_child("line vty 0 4") + assert line.sectional_exit_text_parent_level is False + + +def test_sectional_exit_text_parent_level_no_match() -> None: + """Test sectional_exit_text_parent_level returns False when no rules match.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + + # A child that doesn't match any sectional_exiting rules + hostname = config.add_child("hostname TEST") + assert hostname.sectional_exit_text_parent_level is False + + # A simple config line without children + ntp = config.add_child("ntp server 10.0.0.1") + assert ntp.sectional_exit_text_parent_level is False + + +def test_sectional_exit_text_parent_level_with_nested_children() -> None: + """Test sectional_exit_text_parent_level with nested child configurations.""" + platform = Platform.CISCO_XR + config = get_hconfig(platform) + + # Create a route-policy with nested children + route_policy = config.add_child("route-policy TEST") + if_statement = route_policy.add_child("if destination in (192.0.2.0/24) then") + + # Parent (route-policy) should have exit_text_parent_level=True + assert route_policy.sectional_exit_text_parent_level is True + + # Nested child should not match the sectional_exiting rule for route-policy + assert if_statement.sectional_exit_text_parent_level is False + + +def test_sectional_exit_text_parent_level_indentation_in_lines() -> None: + """Test that sectional_exit_text_parent_level affects indentation in lines output.""" + platform = Platform.CISCO_XR + config = get_hconfig(platform) + + # Create a route-policy with children - exit text should be at parent level (depth - 1) + route_policy = config.add_child("route-policy TEST") + route_policy.add_child("set local-preference 200") + route_policy.add_child("pass") + + # Get lines with sectional_exiting=True + lines = list(config.lines(sectional_exiting=True)) + + # The last line should be "end-policy" at depth 0 (parent level) + # route-policy is at depth 1, so exit text at depth 0 means no indentation + assert lines[-1] == "end-policy" + assert not lines[-1].startswith(" ") + + +def test_sectional_exit_text_parent_level_generic_platform() -> None: + """Test sectional_exit_text_parent_level with generic platform.""" + platform = Platform.GENERIC + config = get_hconfig(platform) + + # Generic platform has no specific sectional_exiting rules with parent_level=True + section = config.add_child("section test") + assert section.sectional_exit_text_parent_level is False + + +def test_children_eq_with_non_children_type() -> None: + """Test HConfigChildren.__eq__ with non-HConfigChildren object returns NotImplemented.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + interface = config.add_child("interface GigabitEthernet0/0") + + # Directly call __eq__ to verify it returns NotImplemented for non-HConfigChildren types + # We must use __eq__ directly here to test the NotImplemented return value + result = interface.children.__eq__("not a children object") # pylint: disable=unnecessary-dunder-call # noqa: PLC2801 + assert result is NotImplemented + + # This allows Python to try the reverse comparison, which results in False + assert interface.children != "not a children object" + + +def test_children_clear() -> None: + """Test HConfigChildren.clear() method.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + interface = config.add_child("interface GigabitEthernet0/0") + interface.add_child("description test") + interface.add_child("ip address 192.0.2.1 255.255.255.0") + + # Verify children exist + assert len(interface.children) == 2 + assert "description test" in interface.children + + # Clear all children + interface.children.clear() + + # Verify children are gone + assert len(interface.children) == 0 + assert "description test" not in interface.children + + +def test_children_delete_by_child_object() -> None: + """Test HConfigChildren.delete() with HConfigChild object.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + interface = config.add_child("interface GigabitEthernet0/0") + desc = interface.add_child("description test") + ip_addr = interface.add_child("ip address 192.0.2.1 255.255.255.0") + + # Verify both children exist + assert len(interface.children) == 2 + + # Delete by child object + interface.children.delete(desc) + + # Verify only one child remains + assert len(interface.children) == 1 + assert interface.children[0] is ip_addr + assert "description test" not in interface.children + + +def test_children_delete_by_child_object_not_present() -> None: + """Test HConfigChildren.delete() with HConfigChild object that's not in the collection.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + interface = config.add_child("interface GigabitEthernet0/0") + interface.add_child("description test") + + # Create a child that's not part of this interface + other_interface = config.add_child("interface GigabitEthernet0/1") + other_child = other_interface.add_child("description other") + + # Verify interface has 1 child + assert len(interface.children) == 1 + + # Try to delete a child that's not in the collection + interface.children.delete(other_child) + + # Verify child count hasn't changed + assert len(interface.children) == 1 + + +def test_children_extend() -> None: + """Test HConfigChildren.extend() method.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + interface1 = config.add_child("interface GigabitEthernet0/0") + interface2 = config.add_child("interface GigabitEthernet0/1") + + # Add children to interface2 + desc = interface2.add_child("description test") + ip_addr = interface2.add_child("ip address 192.0.2.1 255.255.255.0") + + # Verify interface1 has no children + assert len(interface1.children) == 0 + + # Extend interface1's children with interface2's children + interface1.children.extend([desc, ip_addr]) + + # Verify interface1 now has 2 children + assert len(interface1.children) == 2 + assert "description test" in interface1.children + assert "ip address 192.0.2.1 255.255.255.0" in interface1.children