diff --git a/CLAUDE.md b/CLAUDE.md index aa29540..6979c6b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,6 +62,18 @@ Platform drivers: `CISCO_IOS`, `CISCO_XR`, `CISCO_NXOS`, `ARISTA_EOS`, `FORTINET Pattern: add frozen Pydantic model in `models.py` → add default factory + field in `HConfigDriverRules` (driver_base.py) → consume in `child.py` or `root.py` → populate in platform driver's `_instantiate_rules()`. +## Development Approach + +This project follows **Test-Driven Development (TDD)**: + +1. **Write a failing test first** that validates the expected behavior. +2. **Run the test to confirm it fails** for the right reason. +3. **Implement the minimal code** to make the test pass. +4. **Run the full test suite** to ensure no regressions. +5. **Refactor** if needed, keeping tests green. + +All new features and bug fixes must have corresponding tests. Write tests before or alongside implementation, not after. + ## Code Style - Strict type checking: pyright strict mode + mypy strict + pylint with pydantic plugin. diff --git a/docs/drivers.md b/docs/drivers.md index 9ac71ce..0d78d1d 100644 --- a/docs/drivers.md +++ b/docs/drivers.md @@ -601,6 +601,92 @@ Both approaches allow you to extend the functionality of the Cisco IOS driver: 1. **Subclassing:** Recommended for reusable, modular extensions where the driver logic can be encapsulated in a new class. 2. **Dynamic Modification:** Useful when the driver is instantiated dynamically, and you need to modify the rules at runtime. +### Example 3: Adding Unused Object Detection + +Unused object detection is not enabled in any driver by default — it must be explicitly configured. This ensures no unintended side-effects for users who are not expecting it. + +You can add unused object rules dynamically or via `load_hconfig_v2_options`: + +#### Dynamic Extension + +```python +from hier_config import get_hconfig, get_hconfig_driver, Platform +from hier_config.models import MatchRule, ReferenceLocation, UnusedObjectRule + +driver = get_hconfig_driver(Platform.CISCO_XR) + +# Detect unused IPv4 ACLs +driver.rules.unused_objects.append( + UnusedObjectRule( + match_rules=(MatchRule(startswith="ipv4 access-list "),), + name_re=r"^ipv4 access-list (?P\S+)", + reference_locations=( + ReferenceLocation( + match_rules=(MatchRule(startswith="interface "),), + reference_re=r"\bipv4 access-group {name}\b", + ), + ), + ) +) + +config = get_hconfig(driver, running_config_text) +for unused in config.unused_objects(): + print(f"Unused: {unused.text}") +``` + +#### Via `load_hconfig_v2_options` + +```python +from hier_config import get_hconfig, Platform +from hier_config.utils import load_hconfig_v2_options + +options = { + "unused_objects": [ + { + "lineage": [{"startswith": "ipv4 access-list "}], + "name_re": r"^ipv4 access-list (?P\S+)", + "reference_locations": [ + { + "lineage": [{"startswith": "interface "}], + "reference_re": r"\bipv4 access-group {name}\b", + }, + ], + }, + ], +} +driver = load_hconfig_v2_options(options, Platform.CISCO_XR) +config = get_hconfig(driver, running_config_text) + +for unused in config.unused_objects(): + print(f"Unused: {unused.text}") +``` + +Each `UnusedObjectRule` requires: + +- `match_rules` — locates the object definition (e.g., `startswith="ipv4 access-list "`) +- `name_re` — regex with a `(?P...)` capture group to extract the object name +- `reference_locations` — a tuple of `ReferenceLocation` entries, each specifying where to search and what regex pattern (with `{name}` placeholder) to match + +### Example 4: Adding Negation Substitution + +Some platforms require negation commands to be truncated or transformed. Use `NegationSubRule` for regex-based negation transformations: + +```python +from hier_config import get_hconfig_driver, Platform +from hier_config.models import MatchRule, NegationSubRule + +driver = get_hconfig_driver(Platform.CISCO_NXOS) + +# Truncate SNMP user negation after the username +driver.rules.negation_sub.append( + NegationSubRule( + match_rules=(MatchRule(startswith="snmp-server user "),), + search=r"(no snmp-server user \S+).*", + replace=r"\1", + ) +) +``` + ## Creating a Custom Driver This guide walks you through the process of creating a custom driver using the `HConfigDriverBase` class from the `hier_config.platforms.driver_base` module. Custom drivers allow you to define operating system-specific rules and behaviors for managing device configurations. diff --git a/hier_config/child.py b/hier_config/child.py index 823a14d..2d50ae4 100644 --- a/hier_config/child.py +++ b/hier_config/child.py @@ -2,7 +2,7 @@ from itertools import chain from logging import getLogger -from re import search +from re import search, sub from typing import TYPE_CHECKING, Any from .base import HConfigBase @@ -257,7 +257,9 @@ def negate(self) -> HConfigChild: negation string defined in the driver (e.g. ``no ip route``). 2. ``negation_default_when`` rule — rewrites the command to its ``default`` form (e.g. ``no shutdown`` → ``default shutdown``). - 3. ``swap_negation`` — toggles the negation prefix/declaration + 3. ``negation_sub`` rule — applies a regex substitution to the + negated text (e.g. truncating after a specific token). + 4. ``swap_negation`` — toggles the negation prefix/declaration prefix (e.g. ``shutdown`` ↔ ``no shutdown``). Returns self so that callers can chain further operations. @@ -269,6 +271,15 @@ def negate(self) -> HConfigChild: if self.use_default_for_negation(self): return self._default() + for rule in self.driver.rules.negation_sub: + if self.is_lineage_match(rule.match_rules): + self.text = sub( + rule.search, + rule.replace, + f"{self.driver.negation_prefix}{self.text}", + ) + return self + return self.driver.swap_negation(self) def use_default_for_negation(self, config: HConfigChild) -> bool: diff --git a/hier_config/constructors.py b/hier_config/constructors.py index 3328957..3755a53 100644 --- a/hier_config/constructors.py +++ b/hier_config/constructors.py @@ -165,6 +165,9 @@ def get_hconfig_fast_load( for child in tuple(config.all_children()): child.delete_sectional_exit() + for callback in driver.rules.post_load_callbacks: + callback(config) + return config diff --git a/hier_config/models.py b/hier_config/models.py index 45d15c4..23d4a93 100644 --- a/hier_config/models.py +++ b/hier_config/models.py @@ -136,6 +136,48 @@ class NegationDefaultWithRule(BaseModel): use: str +class NegationSubRule(BaseModel): + r"""Regex substitution applied to a command during negation. + + When a negated command matches ``match_rules``, ``re.sub(search, replace, text)`` + is applied to transform the negation line. Useful when a platform requires + truncated or reformatted negation commands — e.g. NX-OS SNMP user removal + must drop everything after the username. + + The regex is applied to the **already-negated** text (with ``no `` prepended). + ``replace`` supports back-references such as ``\1``. + """ + + match_rules: tuple[MatchRule, ...] + search: str + replace: str + + +class ReferenceLocation(BaseModel): + """A location in the config tree where object name references are searched. + + ``match_rules`` defines a lineage prefix that narrows the search scope. + ``reference_re`` is a regex pattern containing ``{name}`` which is + interpolated with the extracted object name before matching. + """ + + match_rules: tuple[MatchRule, ...] + reference_re: str + + +class UnusedObjectRule(BaseModel): + r"""Rule for detecting unused named objects in a configuration. + + Identifies object definitions via ``match_rules``, extracts the object + name using ``name_re`` (a regex with a ``(?P...)`` capture group), + and searches for references across all ``reference_locations``. + """ + + match_rules: tuple[MatchRule, ...] + name_re: str + reference_locations: tuple[ReferenceLocation, ...] + + SetLikeOfStr = frozenset[str] | set[str] diff --git a/hier_config/platforms/cisco_xr/driver.py b/hier_config/platforms/cisco_xr/driver.py index 57d6781..af7dcd2 100644 --- a/hier_config/platforms/cisco_xr/driver.py +++ b/hier_config/platforms/cisco_xr/driver.py @@ -1,4 +1,6 @@ -from collections.abc import Iterable +from __future__ import annotations + +from typing import TYPE_CHECKING from hier_config.child import HConfigChild from hier_config.models import ( @@ -14,6 +16,28 @@ ) from hier_config.platforms.driver_base import HConfigDriverBase, HConfigDriverRules +if TYPE_CHECKING: + from collections.abc import Iterable + + from hier_config.root import HConfig + + +def _fixup_xr_comments(config: HConfig) -> None: + """Move ``!`` comment lines into the next sibling's comments set.""" + for parent in (config, *config.all_children()): + siblings = list(parent.children) + comment_buffer: list[str] = [] + for sibling in siblings: + if sibling.text.startswith("!"): + comment_text = sibling.text.removeprefix("!").lstrip(" ") + if comment_text: + comment_buffer.append(comment_text) + sibling.delete() + elif comment_buffer: + for comment in comment_buffer: + sibling.comments.add(comment) + comment_buffer.clear() + class HConfigDriverCiscoIOSXR(HConfigDriverBase): # pylint: disable=too-many-instance-attributes """Driver for Cisco IOS XR. @@ -154,8 +178,10 @@ def _instantiate_rules() -> HConfigDriverRules: PerLineSubRule(search="^end-set$", replace=" end-set"), PerLineSubRule(search="^end-group$", replace=" end-group"), PerLineSubRule(search="^end$", replace=""), - PerLineSubRule(search="^\\s*[#!].*", replace=""), + PerLineSubRule(search="^\\s*#.*", replace=""), + PerLineSubRule(search="^!\\s*$", replace=""), ], + post_load_callbacks=[_fixup_xr_comments], idempotent_commands=[ IdempotentCommandsRule( match_rules=( diff --git a/hier_config/platforms/driver_base.py b/hier_config/platforms/driver_base.py index c0218e3..1824856 100644 --- a/hier_config/platforms/driver_base.py +++ b/hier_config/platforms/driver_base.py @@ -14,12 +14,14 @@ MatchRule, NegationDefaultWhenRule, NegationDefaultWithRule, + NegationSubRule, OrderingRule, ParentAllowsDuplicateChildRule, PerLineSubRule, SectionalExitingRule, SectionalOverwriteNoNegateRule, SectionalOverwriteRule, + UnusedObjectRule, ) from hier_config.root import HConfig @@ -80,6 +82,14 @@ def _sectional_overwrite_no_negate_rules_default() -> list[ return [] +def _negation_sub_rules_default() -> list[NegationSubRule]: + return [] + + +def _unused_object_rules_default() -> list[UnusedObjectRule]: + return [] + + class HConfigDriverRules(BaseModel): # pylint: disable=too-many-instance-attributes """Pydantic model holding all rule collections for a platform driver. @@ -126,6 +136,12 @@ class HConfigDriverRules(BaseModel): # pylint: disable=too-many-instance-attrib sectional_overwrite_no_negate: list[SectionalOverwriteNoNegateRule] = Field( default_factory=_sectional_overwrite_no_negate_rules_default ) + negation_sub: list[NegationSubRule] = Field( + default_factory=_negation_sub_rules_default + ) + unused_objects: list[UnusedObjectRule] = Field( + default_factory=_unused_object_rules_default + ) class HConfigDriverBase(ABC): diff --git a/hier_config/root.py b/hier_config/root.py index 1b62328..b7d4d18 100644 --- a/hier_config/root.py +++ b/hier_config/root.py @@ -5,7 +5,7 @@ from .base import HConfigBase from .child import HConfigChild -from .models import Dump, DumpLine +from .models import Dump, DumpLine, ReferenceLocation if TYPE_CHECKING: from collections.abc import Iterable, Iterator @@ -208,6 +208,45 @@ def deep_copy(self) -> HConfig: new_instance.add_deep_copy_of(child) return new_instance + def unused_objects(self) -> Iterator[HConfigChild]: + """Yield top-level children that are defined objects with no references. + + Uses ``self.driver.rules.unused_objects`` to identify object definitions, + extract their names, and search for references across the config tree. + Objects with zero references are yielded. + """ + from re import search as _re_search # noqa: PLC0415 + + for rule in self.driver.rules.unused_objects: + seen_names: set[str] = set() + for definition in self.get_children_deep(rule.match_rules): + match = _re_search(rule.name_re, definition.text) + if not match: + continue + name = match.group("name") + if name in seen_names: + continue + seen_names.add(name) + + if not self._is_object_referenced(name, rule.reference_locations): + yield definition + + def _is_object_referenced( + self, + name: str, + reference_locations: tuple[ReferenceLocation, ...], + ) -> bool: + """Return True if *name* is found in any reference location.""" + from re import escape as _re_escape # noqa: PLC0415 + from re import search as _re_search # noqa: PLC0415 + + for ref_location in reference_locations: + pattern = ref_location.reference_re.format(name=_re_escape(name)) + for section in self.get_children_deep(ref_location.match_rules): + if any(_re_search(pattern, d.text) for d in section.all_children()): + return True + return False + def _is_duplicate_child_allowed(self) -> bool: # noqa: PLR6301 """Determine if duplicate(identical text) children are allowed under the parent.""" return False diff --git a/hier_config/utils.py b/hier_config/utils.py index 37627b0..a8ef0c6 100644 --- a/hier_config/utils.py +++ b/hier_config/utils.py @@ -14,13 +14,16 @@ MatchRule, NegationDefaultWhenRule, NegationDefaultWithRule, + NegationSubRule, OrderingRule, ParentAllowsDuplicateChildRule, PerLineSubRule, + ReferenceLocation, SectionalExitingRule, SectionalOverwriteNoNegateRule, SectionalOverwriteRule, TagRule, + UnusedObjectRule, ) from hier_config.platforms.driver_base import HConfigDriverBase @@ -185,6 +188,33 @@ def _process_custom_rules( NegationDefaultWithRule(match_rules=match_rules, use=rule.get("use", "")), ) + for rule in v2_options.get("negation_sub", ()): + match_rules = _collect_match_rules(rule.get("lineage", [])) + driver.rules.negation_sub.append( + NegationSubRule( + match_rules=match_rules, + search=rule.get("search", ""), + replace=rule.get("replace", ""), + ), + ) + + for rule in v2_options.get("unused_objects", ()): + match_rules = _collect_match_rules(rule.get("lineage", [])) + ref_locations = tuple( + ReferenceLocation( + match_rules=_collect_match_rules(ref.get("lineage", [])), + reference_re=ref.get("reference_re", ""), + ) + for ref in rule.get("reference_locations", []) + ) + driver.rules.unused_objects.append( + UnusedObjectRule( + match_rules=match_rules, + name_re=rule.get("name_re", ""), + reference_locations=ref_locations, + ), + ) + def load_hconfig_v2_options( v2_options: dict[str, Any] | str, platform: Platform diff --git a/pyproject.toml b/pyproject.toml index 8c93959..789e970 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,14 +1,14 @@ [tool.poetry] name = "hier-config" -version = "3.4.3" +version = "3.5.0" description = "A network configuration query and comparison library, used to build remediation configurations." packages = [ { include="hier_config", from="."}, ] authors = [ "Andrew Edwards ", - "James Williams ", + "Jan Brooks " ] license = "MIT" classifiers = [ @@ -73,7 +73,6 @@ load-plugins = [ "pylint.extensions.eq_without_hash", "pylint.extensions.for_any_all", "pylint.extensions.overlapping_exceptions", - "pylint.extensions.overlapping_exceptions", "pylint.extensions.redefined_loop_name", "pylint.extensions.set_membership", "pylint.extensions.typing", @@ -91,8 +90,8 @@ disable = [ "redefined-loop-name", # Covered by ruff PLW2901 "too-many-arguments", # Covered by ruff PLR0913 "too-many-return-statements", # Covered by ruff PLR0911 - "too-many-locals", # Coverred by ruff PLR0914 - "too-many-public-methods", # Coverred by ruff PLR0904 + "too-many-locals", # Covered by ruff PLR0914 + "too-many-public-methods", # Covered by ruff PLR0904 ] diff --git a/tests/test_negation_sub.py b/tests/test_negation_sub.py new file mode 100644 index 0000000..c5033ed --- /dev/null +++ b/tests/test_negation_sub.py @@ -0,0 +1,125 @@ +from hier_config import get_hconfig_fast_load +from hier_config.models import ( + MatchRule, + NegationSubRule, + Platform, +) +from hier_config.platforms.driver_base import HConfigDriverRules +from hier_config.platforms.generic.driver import HConfigDriverGeneric +from hier_config.utils import load_hconfig_v2_options + + +def _make_driver( + rules: list[NegationSubRule], +) -> HConfigDriverGeneric: + """Create a generic driver with custom negation_sub rules.""" + driver = HConfigDriverGeneric() + driver.rules = HConfigDriverRules(negation_sub=rules) + return driver + + +def test_negation_sub_truncates_snmp_user() -> None: + """SNMP user negation is truncated after the username.""" + driver = _make_driver( + [ + NegationSubRule( + match_rules=(MatchRule(startswith="snmp-server user "),), + search=r"(no snmp-server user \S+).*", + replace=r"\1", + ), + ], + ) + running = get_hconfig_fast_load( + driver, + ("snmp-server user admin auth sha secret",), + ) + generated = get_hconfig_fast_load(driver, ()) + remediation = running.config_to_get_to(generated) + assert remediation.dump_simple() == ("no snmp-server user admin",) + + +def test_negation_sub_truncates_prefix_list() -> None: + """Prefix-list negation is truncated after the sequence number.""" + driver = _make_driver( + [ + NegationSubRule( + match_rules=(MatchRule(startswith="ipv6 prefix-list "),), + search=r"(no ipv6 prefix-list \S+ seq \d+).*", + replace=r"\1", + ), + ], + ) + running = get_hconfig_fast_load( + driver, + ("ipv6 prefix-list PL seq 1 permit 2801::/64 ge 65",), + ) + generated = get_hconfig_fast_load(driver, ()) + remediation = running.config_to_get_to(generated) + assert remediation.dump_simple() == ("no ipv6 prefix-list PL seq 1",) + + +def test_negation_sub_no_match_uses_normal_negation() -> None: + """Commands not matching any negation_sub rule get normal swap_negation.""" + driver = _make_driver( + [ + NegationSubRule( + match_rules=(MatchRule(startswith="snmp-server user "),), + search=r"(no snmp-server user \S+).*", + replace=r"\1", + ), + ], + ) + running = get_hconfig_fast_load( + driver, + ("hostname router1",), + ) + generated = get_hconfig_fast_load(driver, ()) + remediation = running.config_to_get_to(generated) + assert remediation.dump_simple() == ("no hostname router1",) + + +def test_negation_sub_full_remediation() -> None: + """Full remediation: removed entry uses truncated negation, kept entry unchanged.""" + driver = _make_driver( + [ + NegationSubRule( + match_rules=(MatchRule(startswith="snmp-server user "),), + search=r"(no snmp-server user \S+).*", + replace=r"\1", + ), + ], + ) + running = get_hconfig_fast_load( + driver, + ( + "snmp-server user admin auth sha secret", + "snmp-server user monitor auth sha secret2", + ), + ) + generated = get_hconfig_fast_load( + driver, + ("snmp-server user monitor auth sha secret2",), + ) + remediation = running.config_to_get_to(generated) + assert remediation.dump_simple() == ("no snmp-server user admin",) + + +def test_negation_sub_via_v2_options() -> None: + """Negation sub rules loaded via load_hconfig_v2_options work correctly.""" + v2_options: dict[str, object] = { + "negation_sub": [ + { + "lineage": [{"startswith": "snmp-server user "}], + "search": r"(no snmp-server user \S+).*", + "replace": r"\1", + }, + ], + } + driver = load_hconfig_v2_options(v2_options, Platform.GENERIC) + running = get_hconfig_fast_load( + driver, + ("snmp-server user admin auth sha secret",), + ) + generated = get_hconfig_fast_load(driver, ()) + remediation = running.config_to_get_to(generated) + assert remediation.dump_simple() == ("no snmp-server user admin",) diff --git a/tests/test_unused_objects.py b/tests/test_unused_objects.py new file mode 100644 index 0000000..f784bdb --- /dev/null +++ b/tests/test_unused_objects.py @@ -0,0 +1,185 @@ +from hier_config import get_hconfig_fast_load +from hier_config.models import ( + MatchRule, + Platform, + ReferenceLocation, + UnusedObjectRule, +) +from hier_config.platforms.driver_base import HConfigDriverRules +from hier_config.platforms.generic.driver import HConfigDriverGeneric +from hier_config.utils import load_hconfig_v2_options + + +def _make_driver( + rules: list[UnusedObjectRule], +) -> HConfigDriverGeneric: + """Create a generic driver with custom unused object rules.""" + driver = HConfigDriverGeneric() + driver.rules = HConfigDriverRules(unused_objects=rules) + return driver + + +def test_unused_acl_detected() -> None: + """An ACL defined but not referenced is yielded as unused.""" + driver = _make_driver( + [ + UnusedObjectRule( + match_rules=(MatchRule(startswith="ipv4 access-list "),), + name_re=r"^ipv4 access-list (?P\S+)", + reference_locations=( + ReferenceLocation( + match_rules=(MatchRule(startswith="interface "),), + reference_re=r"\bipv4 access-group {name}\b", + ), + ), + ), + ], + ) + config = get_hconfig_fast_load( + driver, + ( + "ipv4 access-list USED_ACL", + " 10 permit tcp any any", + "ipv4 access-list UNUSED_ACL", + " 10 deny ipv4 any any", + "interface GigabitEthernet0/0/0/0", + " ipv4 access-group USED_ACL ingress", + ), + ) + unused = [child.text for child in config.unused_objects()] + assert unused == ["ipv4 access-list UNUSED_ACL"] + + +def test_used_acl_not_detected() -> None: + """An ACL that is referenced should not be yielded.""" + driver = _make_driver( + [ + UnusedObjectRule( + match_rules=(MatchRule(startswith="ipv4 access-list "),), + name_re=r"^ipv4 access-list (?P\S+)", + reference_locations=( + ReferenceLocation( + match_rules=(MatchRule(startswith="interface "),), + reference_re=r"\bipv4 access-group {name}\b", + ), + ), + ), + ], + ) + config = get_hconfig_fast_load( + driver, + ( + "ipv4 access-list MY_ACL", + " 10 permit tcp any any", + "interface GigabitEthernet0/0/0/0", + " ipv4 access-group MY_ACL ingress", + ), + ) + unused = list(config.unused_objects()) + assert not unused + + +def test_no_unused_object_rules_yields_nothing() -> None: + """A driver with no unused_objects rules yields nothing.""" + config = get_hconfig_fast_load( + Platform.GENERIC, + ("hostname router1",), + ) + unused = list(config.unused_objects()) + assert not unused + + +def test_multiple_reference_locations() -> None: + """An object referenced via the second reference location is not unused.""" + driver = _make_driver( + [ + UnusedObjectRule( + match_rules=(MatchRule(startswith="route-policy "),), + name_re=r"^route-policy (?P\S+)", + reference_locations=( + ReferenceLocation( + match_rules=(MatchRule(startswith="interface "),), + reference_re=r"\broute-policy {name}\b", + ), + ReferenceLocation( + match_rules=(MatchRule(startswith="router bgp"),), + reference_re=r"\broute-policy {name}\b", + ), + ), + ), + ], + ) + config = get_hconfig_fast_load( + driver, + ( + "route-policy USED_IN_BGP", + " pass", + "route-policy UNUSED_POLICY", + " drop", + "router bgp 65000", + " neighbor 10.0.0.1", + " route-policy USED_IN_BGP in", + ), + ) + unused = [child.text for child in config.unused_objects()] + assert unused == ["route-policy UNUSED_POLICY"] + + +def test_unused_objects_via_v2_options() -> None: + """Test unused object detection loaded via load_hconfig_v2_options.""" + v2_options: dict[str, object] = { + "unused_objects": [ + { + "lineage": [{"startswith": "ipv4 access-list "}], + "name_re": r"^ipv4 access-list (?P\S+)", + "reference_locations": [ + { + "lineage": [{"startswith": "interface "}], + "reference_re": r"\bipv4 access-group {name}\b", + }, + ], + }, + ], + } + driver = load_hconfig_v2_options(v2_options, Platform.CISCO_XR) + config = get_hconfig_fast_load( + driver, + ( + "ipv4 access-list APPLIED_ACL", + " 10 permit tcp any any", + "ipv4 access-list ORPHAN_ACL", + " 10 deny ipv4 any any", + "interface GigabitEthernet0/0/0/0", + " ipv4 access-group APPLIED_ACL ingress", + ), + ) + unused = [child.text for child in config.unused_objects()] + assert "ipv4 access-list ORPHAN_ACL" in unused + assert "ipv4 access-list APPLIED_ACL" not in unused + + +def test_name_re_without_match_skips_definition() -> None: + """A name_re that doesn't match the definition text is safely skipped.""" + driver = _make_driver( + [ + UnusedObjectRule( + match_rules=(MatchRule(startswith="ipv4 access-list "),), + name_re=r"^WILL_NOT_MATCH (?P\S+)", + reference_locations=( + ReferenceLocation( + match_rules=(MatchRule(startswith="interface "),), + reference_re=r"\bipv4 access-group {name}\b", + ), + ), + ), + ], + ) + config = get_hconfig_fast_load( + driver, + ( + "ipv4 access-list MY_ACL", + " 10 permit tcp any any", + ), + ) + unused = list(config.unused_objects()) + assert not unused diff --git a/tests/test_xr_comments.py b/tests/test_xr_comments.py new file mode 100644 index 0000000..7631260 --- /dev/null +++ b/tests/test_xr_comments.py @@ -0,0 +1,125 @@ +from hier_config import get_hconfig, get_hconfig_fast_load +from hier_config.models import Platform + + +def test_xr_comment_attached_to_next_sibling() -> None: + """IOS-XR inline comments are attached to the next sibling's comments set.""" + config = get_hconfig( + Platform.CISCO_XR, + """\ +router isis backbone + ! ISIS network number should be encoded with 0-padded loopback IP + net 49.0001.1921.2022.0222.00 +""", + ) + 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 ( + "ISIS network number should be encoded with 0-padded loopback IP" + in net_child.comments + ) + + +def test_xr_multiple_comments_before_line() -> None: + """Multiple consecutive comment lines are all attached to the next sibling.""" + config = get_hconfig( + Platform.CISCO_XR, + """\ +router isis backbone + ! first comment + ! second comment + net 49.0001.1921.2022.0222.00 +""", + ) + 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 "first comment" in net_child.comments + assert "second comment" in net_child.comments + + +def test_xr_comment_lines_not_parsed_as_children() -> None: + """Comment lines starting with ! should not appear as config children.""" + config = get_hconfig( + Platform.CISCO_XR, + """\ +router isis backbone + ! this is a comment + net 49.0001.1921.2022.0222.00 +""", + ) + router_isis = config.get_child(equals="router isis backbone") + assert router_isis is not None + for child in router_isis.all_children(): + assert not child.text.startswith("!") + + +def test_xr_top_level_bang_delimiters_stripped() -> None: + """Top-level ! delimiters (with no comment text) are stripped.""" + config = get_hconfig( + Platform.CISCO_XR, + """\ +hostname router1 +! +interface GigabitEthernet0/0/0/0 + description test +! +""", + ) + children = [child.text for child in config.children] + assert "hostname router1" in children + assert "interface GigabitEthernet0/0/0/0" in children + assert "!" not in children + + +def test_xr_comment_preservation_with_fast_load() -> None: + """Comments are also preserved when using get_hconfig_fast_load.""" + config = get_hconfig_fast_load( + Platform.CISCO_XR, + ( + "router isis backbone", + " ! loopback comment", + " net 49.0001.0000.0000.0001.00", + ), + ) + 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 "loopback comment" in net_child.comments + + +def test_xr_hash_comments_still_stripped() -> None: + """Lines starting with # are still stripped (not preserved).""" + config = get_hconfig( + Platform.CISCO_XR, + """\ +hostname router1 +# this should be stripped +interface GigabitEthernet0/0/0/0 +""", + ) + for child in config.all_children(): + assert not child.text.startswith("#") + + +def test_xr_comment_with_leading_bang_preserved() -> None: + """A comment containing ! in its body is preserved correctly.""" + config = get_hconfig( + Platform.CISCO_XR, + """\ +router isis backbone + ! !important note about ISIS + net 49.0001.1921.2022.0222.00 +""", + ) + 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 "!important note about ISIS" in net_child.comments