diff --git a/README.md b/README.md index 97268a2..65ecc0c 100644 --- a/README.md +++ b/README.md @@ -20,26 +20,32 @@ Hier Config is compatible with any NOS that utilizes a structured CLI syntax sim The code documentation can be found at: [Hier Config documentation](https://hier-config.readthedocs.io/en/latest/). -Installation -============ +## Highlights + +- Predict the device state before deploying (`future()`) and generate accurate rollbacks that now preserve distinct structural commands—BGP neighbor descriptions, for example, no longer collapse when multiple peers share a common prefix. +- Build remediation workflows with deterministic diffs across Cisco-style and Junos-style configuration syntaxes. + +## Installation ### PIP + Install from PyPi: ```shell pip install hier-config ``` -Quick Start -=========== +## Quick Start ### Step 1: Import Required Classes + ```python from hier_config import WorkflowRemediation, get_hconfig, Platform from hier_config.utils import read_text_from_file ``` ### Step 2: Load Configurations + Load the running and intended configurations as strings: ```python @@ -48,6 +54,7 @@ generated_config_text = read_text_from_file("./tests/fixtures/generated_config.c ``` ### Step 3: Create HConfig Objects + Specify the device platform (e.g., `Platform.CISCO_IOS`): ```python @@ -56,6 +63,7 @@ generated_config = get_hconfig(Platform.CISCO_IOS, generated_config_text) ``` ### Step 4: Initialize WorkflowRemediation + Compare configurations and generate remediation steps: ```python diff --git a/docs/drivers.md b/docs/drivers.md index 4d26c19..c806ddc 100644 --- a/docs/drivers.md +++ b/docs/drivers.md @@ -82,6 +82,7 @@ In Hier Config, the rules within a driver are organized into sections, each targ --- ### 1. Negation Rules + **Purpose**: Define how to negate commands or reset them to a default state. - **Models**: @@ -95,6 +96,7 @@ In Hier Config, the rules within a driver are organized into sections, each targ --- ### 2. Sectional Exiting + **Purpose**: Manage hierarchical configuration sections by defining commands for properly exiting each section. - **Models**: @@ -105,6 +107,7 @@ In Hier Config, the rules within a driver are organized into sections, each targ --- ### 3. Ordering + **Purpose**: Assign weights to commands to control the order of operations during configuration application. - **Models**: @@ -115,6 +118,7 @@ In Hier Config, the rules within a driver are organized into sections, each targ --- ### 4. Per-Line Substitutions + **Purpose**: Modify or clean up specific lines in the configuration. - **Models**: @@ -128,6 +132,7 @@ In Hier Config, the rules within a driver are organized into sections, each targ --- ### 5. Idempotent Commands + **Purpose**: Ensure commands are not repeated unnecessarily in the configuration. - **Models**: @@ -140,6 +145,7 @@ In Hier Config, the rules within a driver are organized into sections, each targ --- ### 6. Post-Processing Callbacks + **Purpose**: Apply additional transformations after initial configuration processing. - **Implementation**: @@ -148,6 +154,7 @@ In Hier Config, the rules within a driver are organized into sections, each targ --- ### 7. Tagging and Overwriting + **Purpose**: Apply tags to configuration lines or define overwriting behavior for specific sections. - **Models**: @@ -164,6 +171,7 @@ In Hier Config, the rules within a driver are organized into sections, each targ --- ### 8. Indentation Adjustments + **Purpose**: Define start and end points for adjusting indentation within configurations. - **Models**: @@ -174,6 +182,7 @@ In Hier Config, the rules within a driver are organized into sections, each targ --- ### 9. Match Rules + **Purpose**: Provide a flexible way to define conditions for matching configuration lines. - **Models**: @@ -187,6 +196,7 @@ In Hier Config, the rules within a driver are organized into sections, each targ --- ### 10. Instance Metadata + **Purpose**: Manage metadata for configuration instances, such as tags and comments. - **Models**: @@ -198,6 +208,7 @@ In Hier Config, the rules within a driver are organized into sections, each targ --- ### 11. Dumping Configuration + **Purpose**: Represent and handle the output of processed configuration lines. - **Models**: @@ -387,8 +398,8 @@ driver.rules.idempotent_commands.append( #### Explanation -* **Dynamic Rule Extension:** You directly modify the driver.rules attributes to append new rules dynamically. -* **Flexibility:** This approach is useful when the driver is instantiated by external code, and subclassing is not feasible. +- **Dynamic Rule Extension:** You directly modify the driver.rules attributes to append new rules dynamically. +- **Flexibility:** This approach is useful when the driver is instantiated by external code, and subclassing is not feasible. Both approaches allow you to extend the functionality of the Cisco IOS driver: @@ -406,15 +417,17 @@ This guide walks you through the process of creating a custom driver using the ` The `HConfigDriverBase` class provides a foundation for defining driver-specific rules and behaviors. It encapsulates configuration rules and methods for handling idempotency, negation, and more. You will extend this class to create a new driver. Key Components: + 1. **`HConfigDriverRules`**: A collection of rules for handling configuration logic. -2. **Methods to Override**: Define custom behavior by overriding the `_instantiate_rules` method. -3. **Properties**: Adjust behavior for negation and declaration prefixes. +1. **Methods to Override**: Define custom behavior by overriding the `_instantiate_rules` method. +1. **Properties**: Adjust behavior for negation and declaration prefixes. --- ### Steps to Create a Custom Driver #### Step 1: Subclass `HConfigDriverBase` + Begin by subclassing `HConfigDriverBase` to define a new driver. ```python @@ -472,6 +485,7 @@ class CustomHConfigDriver(HConfigDriverBase): ``` #### Step 2: Customize Negation or Declaration Prefixes (Optional) + Override the `negation_prefix` or `declaration_prefix` properties to customize their behavior. ```python @@ -547,7 +561,7 @@ workflow = WorkflowRemediation(running_config, generated_config) ### Key Methods in HConfigDriverBase 1. `idempotent_for`: - * Matches configurations against idempotent rules to prevent duplication. + - Matches configurations against idempotent rules to prevent duplication. ```python def idempotent_for( @@ -558,29 +572,30 @@ def idempotent_for( ... ``` -2. `negate_with`: - * Provides a negation command based on rules. +1. `negate_with`: + - Provides a negation command based on rules. ```python def negate_with(self, config: HConfigChild) -> Optional[str]: ... ``` -3. `swap_negation`: - * Toggles the negation of a command. +1. `swap_negation`: + - Toggles the negation of a command. ```python def swap_negation(self, child: HConfigChild) -> HConfigChild: ... ``` -4. Properties: - * `negation_prefix`: Default is `"no "`. - * `declaration_prefix`: Default is `""`. +1. Properties: + - `negation_prefix`: Default is `"no "`. + - `declaration_prefix`: Default is `""`. ### Example Rule Definitions #### Negation Rules + Define commands that require specific negation handling: ```python @@ -593,6 +608,7 @@ negate_with=[ ``` #### Sectional Exiting + Define how to exit specific configuration sections: ```python @@ -608,6 +624,7 @@ sectional_exiting=[ ``` #### Command Ordering + Set the execution order of specific commands: ```python @@ -620,6 +637,7 @@ ordering=[ ``` #### Per-Line Substitution + Clean up unwanted lines in the configuration: ```python diff --git a/docs/future-config.md b/docs/future-config.md index 0408dc9..26cb6d8 100644 --- a/docs/future-config.md +++ b/docs/future-config.md @@ -26,6 +26,7 @@ post_change_2_config = post_change_1_config.future(change_2_config) change_2_rollback_config = post_change_2_config.config_to_get_to(post_change_1_config) ``` + Currently, this algorithm does not account for: - negate a numbered ACL when removing an item @@ -35,6 +36,22 @@ Currently, this algorithm does not account for: - idempotent_acl_check - and likely others +## Structural Idempotency Matching + +Starting in version 3.3.1, Hier Config derives a structural "idempotency key" from +each command’s lineage when evaluating [`IdempotentCommandsRule`](drivers.md). +This prevents unrelated lines that happen to share a prefix from being treated as +duplicates during `future()` predictions. For example, distinct BGP neighbor +descriptions such as `neighbor 2.2.2.2 description neighbor2` and +`neighbor 3.3.3.3 description neighbor3` now remain in the predicted future +configuration because their identities differ within the `neighbor` hierarchy. + +> **Tip:** When creating driver rules, ensure your `MatchRule` definitions capture +> the structural parts of the command that should be unique (for instance, via +> regex capture groups or specific `startswith` clauses). This allows the +> idempotency engine to distinguish between commands that only vary by attributes +> such as IP address or description text. + ```bash >>> from hier_config import get_hconfig, Platform >>> from hier_config.utils import read_text_from_file diff --git a/hier_config/platforms/driver_base.py b/hier_config/platforms/driver_base.py index 74d7f6a..5fd3320 100644 --- a/hier_config/platforms/driver_base.py +++ b/hier_config/platforms/driver_base.py @@ -1,5 +1,6 @@ from abc import ABC, abstractmethod from collections.abc import Callable, Iterable +from re import Match, search from pydantic import Field, PositiveInt @@ -10,6 +11,7 @@ IdempotentCommandsAvoidRule, IdempotentCommandsRule, IndentAdjustRule, + MatchRule, NegationDefaultWhenRule, NegationDefaultWithRule, OrderingRule, @@ -133,10 +135,18 @@ def idempotent_for( other_children: Iterable[HConfigChild], ) -> HConfigChild | None: for rule in self.rules.idempotent_commands: - if config.is_lineage_match(rule.match_rules): - for other_child in other_children: - if other_child.is_lineage_match(rule.match_rules): - return other_child + if not config.is_lineage_match(rule.match_rules): + continue + + config_key = self._idempotency_key(config, rule.match_rules) + + for other_child in other_children: + if not other_child.is_lineage_match(rule.match_rules): + continue + + if self._idempotency_key(other_child, rule.match_rules) == config_key: + return other_child + return None def negate_with(self, config: HConfigChild) -> str | None: @@ -154,6 +164,242 @@ def swap_negation(self, child: HConfigChild) -> HConfigChild: return child + def _idempotency_key( + self, + config: HConfigChild, + match_rules: tuple[MatchRule, ...], + ) -> tuple[str, ...]: + """Build a structural identity for `config` that respects driver rules. + + Args: + config: The child being evaluated for idempotency. + match_rules: The match rules describing the lineage signature. + + Returns: + A tuple of string fragments representing the idempotency key. + + """ + lineage = tuple(config.lineage()) + if len(lineage) != len(match_rules): + return () + + components: list[str] = [] + for child, rule in zip(lineage, match_rules, strict=False): + components.append(self._idempotency_component_key(child, rule)) + return tuple(components) + + def _idempotency_component_key( + self, + child: HConfigChild, + rule: MatchRule, + ) -> str: + """Derive the structural key for a single lineage component. + + Args: + child: The lineage child contributing to the key. + rule: The rule governing how to match the child. + + Returns: + A string fragment representing the component key. + + """ + text = child.text + normalized_text = text.removeprefix(self.negation_prefix) + + parts: list[str] = [] + parts.extend(self._key_from_equals(rule.equals, text)) + parts.extend(self._key_from_prefix(rule.startswith, normalized_text)) + parts.extend(self._key_from_suffix(rule.endswith, normalized_text)) + parts.extend(self._key_from_contains(rule.contains, normalized_text)) + parts.extend(self._key_from_regex(rule.re_search, normalized_text, text)) + + if not parts: + parts.append(f"text|{normalized_text}") + + return ";".join(parts) + + @staticmethod + def _key_from_equals(equals: str | frozenset[str] | None, text: str) -> list[str]: + """Return key fragments constrained by `equals` match rules. + + Args: + equals: The equals constraint specified by the rule. + text: The original command text to fall back on for sets. + + Returns: + A list containing zero or one key fragments. + + """ + if equals is None: + return [] + if isinstance(equals, str): + return [f"equals|{equals}"] + return [f"equals|{text}"] + + def _key_from_prefix( + self, + prefix: str | tuple[str, ...] | None, + normalized_text: str, + ) -> list[str]: + """Return key fragments for `startswith` match rules. + + Args: + prefix: The `startswith` constraint(s) to evaluate. + normalized_text: The command text without the negation prefix. + + Returns: + A list containing zero or one key fragments. + + """ + if prefix is None: + return [] + matched = self._match_prefix(normalized_text, prefix) + if matched is None: + return [] + return [f"startswith|{matched}"] + + def _key_from_suffix( + self, + suffix: str | tuple[str, ...] | None, + normalized_text: str, + ) -> list[str]: + """Return key fragments for `endswith` match rules. + + Args: + suffix: The `endswith` constraint(s) to evaluate. + normalized_text: The command text without the negation prefix. + + Returns: + A list containing zero or one key fragments. + + """ + if suffix is None: + return [] + matched = self._match_suffix(normalized_text, suffix) + if matched is None: + return [] + return [f"endswith|{matched}"] + + def _key_from_contains( + self, + contains: str | tuple[str, ...] | None, + normalized_text: str, + ) -> list[str]: + """Return key fragments for `contains` match rules. + + Args: + contains: The `contains` constraint(s) to evaluate. + normalized_text: The command text without the negation prefix. + + Returns: + A list containing zero or one key fragments. + + """ + if contains is None: + return [] + matched = self._match_contains(normalized_text, contains) + if matched is None: + return [] + return [f"contains|{matched}"] + + def _key_from_regex( + self, + pattern: str | None, + normalized_text: str, + original_text: str, + ) -> list[str]: + """Return key fragments derived from regex match rules. + + Args: + pattern: The regex pattern to match. + normalized_text: The command text without the negation prefix. + original_text: The command text including any negation. + + Returns: + A list containing zero or one key fragments. + + """ + if pattern is None: + return [] + + match = search(pattern, normalized_text) + match_source = normalized_text + if match is None: + match = search(pattern, original_text) + match_source = original_text + + if match is None: + return [] + + regex_key = self._normalize_regex_key(pattern, match_source, match) + return [f"re|{regex_key}"] + + @staticmethod + def _match_prefix(value: str, prefix: str | tuple[str, ...]) -> str | None: + if isinstance(prefix, tuple): + matches = [candidate for candidate in prefix if value.startswith(candidate)] + if matches: + return max(matches, key=len) + return None + + if value.startswith(prefix): + return prefix + + return None + + @staticmethod + def _match_suffix(value: str, suffix: str | tuple[str, ...]) -> str | None: + if isinstance(suffix, tuple): + matches = [candidate for candidate in suffix if value.endswith(candidate)] + if matches: + return max(matches, key=len) + return None + + if value.endswith(suffix): + return suffix + + return None + + @staticmethod + def _match_contains(value: str, contains: str | tuple[str, ...]) -> str | None: + if isinstance(contains, tuple): + matches = [candidate for candidate in contains if candidate in value] + if matches: + return max(matches, key=len) + return None + + if contains in value: + return contains + + return None + + @staticmethod + def _normalize_regex_key(pattern: str, value: str, match: Match[str]) -> str: + """Normalize regex matches so equivalent commands hash the same.""" + result = match.group(0) + + if match.re.groups: + groups = tuple(g or "" for g in match.groups()) + if any(groups): + normalized_groups = tuple(group.strip() for group in groups) + if any(normalized_groups): + return "|".join(normalized_groups) + + trimmed_pattern = pattern.rstrip("$") + for suffix in (".*", ".+"): + if trimmed_pattern.endswith(suffix): + candidate_pattern = trimmed_pattern[: -len(suffix)] + if not candidate_pattern: + break + trimmed_match = search(candidate_pattern, value) + if trimmed_match is not None: + candidate = trimmed_match.group(0).strip() + if candidate: + return candidate + break + + return result.strip() + @property def declaration_prefix(self) -> str: return "" diff --git a/tests/test_hier_config.py b/tests/test_hier_config.py index 7ff3863..f664cd7 100644 --- a/tests/test_hier_config.py +++ b/tests/test_hier_config.py @@ -1,4 +1,4 @@ -"""Tests for hier_config core functionality.""" +"""Tests for hier_config functionality.""" # pylint: disable=too-many-lines import tempfile @@ -16,7 +16,8 @@ get_hconfig_from_dump, ) from hier_config.exceptions import DuplicateChildError -from hier_config.models import Instance, MatchRule, Platform +from hier_config.models import IdempotentCommandsRule, Instance, MatchRule, Platform +from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS def test_bool(platform_a: Platform) -> None: @@ -488,6 +489,438 @@ def test_future_config(platform_a: Platform) -> None: ) +def test_future_preserves_bgp_neighbor_description() -> None: + """Validate Arista BGP neighbors keep untouched descriptions across future/rollback. + + This regression asserts that applying a candidate config via ``future()`` retains + existing neighbor descriptions and the subsequent ``config_to_get_to`` rollback only + negates the new commands. + """ + platform = Platform.ARISTA_EOS + running_raw = """router bgp 1 + neighbor 2.2.2.2 description neighbor2 + neighbor 2.2.2.2 remote-as 2 + ! +""" + change_raw = """router bgp 1 + neighbor 3.3.3.3 description neighbor3 + neighbor 3.3.3.3 remote-as 3 +""" + + running_config = get_hconfig(platform, running_raw) + change_config = get_hconfig(platform, change_raw) + + future_config = running_config.future(change_config) + expected_future = ( + "router bgp 1", + " neighbor 3.3.3.3 description neighbor3", + " neighbor 3.3.3.3 remote-as 3", + " neighbor 2.2.2.2 description neighbor2", + " neighbor 2.2.2.2 remote-as 2", + " exit", + ) + assert future_config.dump_simple(sectional_exiting=True) == expected_future + + rollback_config = future_config.config_to_get_to(running_config) + expected_rollback = ( + "router bgp 1", + " no neighbor 3.3.3.3 description neighbor3", + " no neighbor 3.3.3.3 remote-as 3", + " exit", + ) + assert rollback_config.dump_simple(sectional_exiting=True) == expected_rollback + + +def test_idempotency_key_with_equals_string() -> None: + """Test idempotency key generation with equals constraint as string.""" + driver = HConfigDriverCiscoIOS() + # Add a rule with equals as string + driver.rules.idempotent_commands.append( + IdempotentCommandsRule( + match_rules=(MatchRule(equals="logging console"),), + ) + ) + + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = next(iter(config.children)) + + # Test the idempotency with equals string + key = driver._idempotency_key(child, (MatchRule(equals="logging console"),)) # noqa: SLF001 # pyright: ignore[reportPrivateUsage] + assert key == ("equals|logging console",) + + +def test_idempotency_key_with_equals_frozenset() -> None: + """Test idempotency key generation with equals constraint as frozenset.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = next(iter(config.children)) + + # Test the idempotency with equals frozenset (should fall back to text) + key = driver._idempotency_key( # noqa: SLF001 # pyright: ignore[reportPrivateUsage] + child, (MatchRule(equals=frozenset(["logging console", "other"])),) + ) + assert key == ("equals|logging console",) + + +def test_idempotency_key_no_match_rules() -> None: + """Test idempotency key falls back to text when no match rules apply.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """some command +""" + config = get_hconfig(driver, config_raw) + child = next(iter(config.children)) + + # Empty MatchRule should fall back to text + key = driver._idempotency_key(child, (MatchRule(),)) # noqa: SLF001 # pyright: ignore[reportPrivateUsage] + assert key == ("text|some command",) + + +def test_idempotency_key_prefix_no_match() -> None: + """Test idempotency key when prefix doesn't match.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = next(iter(config.children)) + + # Prefix that doesn't match should fall back to text + key = driver._idempotency_key(child, (MatchRule(startswith="interface"),)) # noqa: SLF001 # pyright: ignore[reportPrivateUsage] + assert key == ("text|logging console",) + + +def test_idempotency_key_suffix_no_match() -> None: + """Test idempotency key when suffix doesn't match.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = next(iter(config.children)) + + # Suffix that doesn't match should fall back to text + key = driver._idempotency_key(child, (MatchRule(endswith="emergency"),)) # noqa: SLF001 # pyright: ignore[reportPrivateUsage] + assert key == ("text|logging console",) + + +def test_idempotency_key_contains_no_match() -> None: + """Test idempotency key when contains doesn't match.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = next(iter(config.children)) + + # Contains that doesn't match should fall back to text + key = driver._idempotency_key(child, (MatchRule(contains="interface"),)) # noqa: SLF001 # pyright: ignore[reportPrivateUsage] + assert key == ("text|logging console",) + + +def test_idempotency_key_regex_no_match() -> None: + """Test idempotency key when regex doesn't match.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = next(iter(config.children)) + + # Regex that doesn't match should fall back to text + key = driver._idempotency_key(child, (MatchRule(re_search="^interface"),)) # noqa: SLF001 # pyright: ignore[reportPrivateUsage] + assert key == ("text|logging console",) + + +def test_idempotency_key_prefix_tuple_no_match() -> None: + """Test idempotency key with tuple of prefixes that don't match.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = next(iter(config.children)) + + # Tuple of prefixes that don't match should fall back to text + key = driver._idempotency_key( # noqa: SLF001 # pyright: ignore[reportPrivateUsage] + child, (MatchRule(startswith=("interface", "router", "vlan")),) + ) + assert key == ("text|logging console",) + + +def test_idempotency_key_prefix_tuple_match() -> None: + """Test idempotency key with tuple of prefixes that match.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = next(iter(config.children)) + + # Tuple of prefixes with one matching - should return longest match + key = driver._idempotency_key( # noqa: SLF001 # pyright: ignore[reportPrivateUsage] + child, (MatchRule(startswith=("log", "logging", "logging console")),) + ) + assert key == ("startswith|logging console",) + + +def test_idempotency_key_suffix_tuple_no_match() -> None: + """Test idempotency key with tuple of suffixes that don't match.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = next(iter(config.children)) + + # Tuple of suffixes that don't match should fall back to text + key = driver._idempotency_key( # noqa: SLF001 # pyright: ignore[reportPrivateUsage] + child, (MatchRule(endswith=("emergency", "alert", "critical")),) + ) + assert key == ("text|logging console",) + + +def test_idempotency_key_suffix_tuple_match() -> None: + """Test idempotency key with tuple of suffixes that match.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = next(iter(config.children)) + + # Tuple of suffixes with one matching - should return longest match + key = driver._idempotency_key( # noqa: SLF001 # pyright: ignore[reportPrivateUsage] + child, (MatchRule(endswith=("ole", "sole", "console")),) + ) + assert key == ("endswith|console",) + + +def test_idempotency_key_contains_tuple_no_match() -> None: + """Test idempotency key with tuple of contains that don't match.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = next(iter(config.children)) + + # Tuple of contains that don't match should fall back to text + key = driver._idempotency_key( # noqa: SLF001 # pyright: ignore[reportPrivateUsage] + child, (MatchRule(contains=("interface", "router", "vlan")),) + ) + assert key == ("text|logging console",) + + +def test_idempotency_key_contains_tuple_match() -> None: + """Test idempotency key with tuple of contains that match.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = next(iter(config.children)) + + # Tuple of contains with matches - should return longest match + key = driver._idempotency_key( # noqa: SLF001 # pyright: ignore[reportPrivateUsage] + child, (MatchRule(contains=("log", "console", "logging console")),) + ) + assert key == ("contains|logging console",) + + +def test_idempotency_key_regex_with_groups() -> None: + """Test idempotency key with regex capture groups.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """router bgp 1 + neighbor 10.1.1.1 description peer1 +""" + config = get_hconfig(driver, config_raw) + bgp_child = next(iter(config.children)) + neighbor_child = next(iter(bgp_child.children)) + + # Regex with capture groups should use groups + key = driver._idempotency_key( # noqa: SLF001 # pyright: ignore[reportPrivateUsage] + neighbor_child, + ( + MatchRule(startswith="router bgp"), + MatchRule(re_search=r"neighbor (\S+) description"), + ), + ) + assert key == ("startswith|router bgp", "re|10.1.1.1") + + +def test_idempotency_key_regex_with_empty_groups() -> None: + """Test idempotency key with regex that has empty capture groups.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = next(iter(config.children)) + + # Regex with empty/None groups should fall back to match result + key = driver._idempotency_key( # noqa: SLF001 # pyright: ignore[reportPrivateUsage] + child, (MatchRule(re_search=r"logging ()?(console)"),) + ) + # Group 1 is empty, group 2 has "console", so should use groups + assert "re|" in key[0] + + +def test_idempotency_key_regex_greedy_pattern() -> None: + """Test idempotency key with greedy regex pattern (.* or .+).""" + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console emergency +""" + config = get_hconfig(driver, config_raw) + child = next(iter(config.children)) + + # Regex with .* should be trimmed + key = driver._idempotency_key(child, (MatchRule(re_search=r"logging console.*"),)) # noqa: SLF001 # pyright: ignore[reportPrivateUsage] + assert key == ("re|logging console",) + + +def test_idempotency_key_regex_greedy_pattern_with_dollar() -> None: + """Test idempotency key with greedy regex pattern with $ anchor.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console emergency +""" + config = get_hconfig(driver, config_raw) + child = next(iter(config.children)) + + # Regex with .*$ should be trimmed + key = driver._idempotency_key(child, (MatchRule(re_search=r"logging console.*$"),)) # noqa: SLF001 # pyright: ignore[reportPrivateUsage] + assert key == ("re|logging console",) + + +def test_idempotency_key_regex_only_greedy() -> None: + """Test idempotency key with regex that is only greedy pattern.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = next(iter(config.children)) + + # Regex that is only .* should not trim to empty + key = driver._idempotency_key(child, (MatchRule(re_search=r".*"),)) # noqa: SLF001 # pyright: ignore[reportPrivateUsage] + # Should use the full match result + assert key == ("re|logging console",) + + +def test_idempotency_key_lineage_mismatch() -> None: + """Test idempotency key when lineage length doesn't match rules length.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """interface GigabitEthernet1/1 + description test +""" + config = get_hconfig(driver, config_raw) + interface_child = next(iter(config.children)) + desc_child = next(iter(interface_child.children)) + + # Try to match with wrong number of rules (desc has 2 lineage levels, only 1 rule) + key = driver._idempotency_key(desc_child, (MatchRule(startswith="description"),)) # noqa: SLF001 # pyright: ignore[reportPrivateUsage] + # Should return empty tuple when lineage length != match_rules length + assert not key + + +def test_idempotency_key_negated_command() -> None: + """Test idempotency key with negated command.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """no logging console +""" + config = get_hconfig(driver, config_raw) + child = next(iter(config.children)) + + # Negated command should strip 'no ' prefix for matching + key = driver._idempotency_key(child, (MatchRule(startswith="logging"),)) # noqa: SLF001 # pyright: ignore[reportPrivateUsage] + assert key == ("startswith|logging",) + + +def test_idempotency_key_regex_fallback_to_original() -> None: + """Test idempotency key regex matching fallback to original text.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """no logging console +""" + config = get_hconfig(driver, config_raw) + child = next(iter(config.children)) + + # Regex that matches original but not normalized (tests lines 328-329) + key = driver._idempotency_key(child, (MatchRule(re_search=r"^no logging"),)) # noqa: SLF001 # pyright: ignore[reportPrivateUsage] + assert "re|no logging" in key[0] + + +def test_idempotency_key_suffix_single_match() -> None: + """Test idempotency key with single suffix that matches (not tuple).""" + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = next(iter(config.children)) + + # Single suffix that matches (tests line 359) + key = driver._idempotency_key(child, (MatchRule(endswith="console"),)) # noqa: SLF001 # pyright: ignore[reportPrivateUsage] + assert key == ("endswith|console",) + + +def test_idempotency_key_contains_single_match() -> None: + """Test idempotency key with single contains that matches (not tuple).""" + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console emergency +""" + config = get_hconfig(driver, config_raw) + child = next(iter(config.children)) + + # Single contains that matches (tests line 372) + key = driver._idempotency_key(child, (MatchRule(contains="console"),)) # noqa: SLF001 # pyright: ignore[reportPrivateUsage] + assert key == ("contains|console",) + + +def test_idempotency_key_regex_greedy_with_plus() -> None: + """Test idempotency key with greedy regex using .+ suffix.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """interface GigabitEthernet1 +""" + config = get_hconfig(driver, config_raw) + child = next(iter(config.children)) + + # Regex with .+ should be trimmed similar to .* + # Tests the .+ branch in line 389 + key = driver._idempotency_key(child, (MatchRule(re_search=r"interface .+"),)) # noqa: SLF001 # pyright: ignore[reportPrivateUsage] + # Should trim to just "interface " and use that + assert key == ("re|interface",) + + +def test_idempotency_key_regex_trimmed_to_no_match() -> None: + """Test idempotency key when trimmed regex doesn't match.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = next(iter(config.children)) + + # Regex "interface.*" matches nothing, but after trimming .* we get "interface" + # which also doesn't match "logging console", so we fall back to full match result + # This should hit the break at line 399 because trimmed_match is None + key = driver._idempotency_key(child, (MatchRule(re_search=r"interface.*"),)) # noqa: SLF001 # pyright: ignore[reportPrivateUsage] + # Since "interface.*" doesn't match "logging console", should fall back to text + assert key == ("text|logging console",) + + def test_difference1(platform_a: Platform) -> None: rc = ("a", " a1", " a2", " a3", "b") step = ("a", " a1", " a2", " a3", " a4", " a5", "b", "c", "d", " d1")