From 83ea0ef4d8271eba3ee077811fa3b2c0ba122991 Mon Sep 17 00:00:00 2001 From: James Williams Date: Thu, 6 Nov 2025 08:59:22 -0600 Subject: [PATCH 01/11] addresses #156 --- hier_config/platforms/driver_base.py | 183 ++++++++++++++++++++++++++- tests/test_hier_config.py | 36 ++++++ 2 files changed, 215 insertions(+), 4 deletions(-) diff --git a/hier_config/platforms/driver_base.py b/hier_config/platforms/driver_base.py index 74d7f6a..b58c0e7 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,171 @@ def swap_negation(self, child: HConfigChild) -> HConfigChild: return child + def _idempotency_key( + self, + config: HConfigChild, + match_rules: tuple[MatchRule, ...], + ) -> tuple[str, ...]: + lineage = tuple(config.lineage()) + if len(lineage) != len(match_rules): + return () + + return tuple(map(self._idempotency_component_key, lineage, match_rules)) + + def _idempotency_component_key( + self, + child: HConfigChild, + rule: MatchRule, + ) -> str: + 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]: + 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]: + 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]: + 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]: + 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]: + 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: + 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 f96d222..ed293ec 100644 --- a/tests/test_hier_config.py +++ b/tests/test_hier_config.py @@ -485,6 +485,42 @@ def test_future_config(platform_a: Platform) -> None: ) +def test_future_preserves_bgp_neighbor_description() -> None: + 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_difference1(platform_a: Platform) -> None: rc = ("a", " a1", " a2", " a3", "b") step = ("a", " a1", " a2", " a3", " a4", " a5", "b", "c", "d", " d1") From d062da557b99644fa6fbf04fa541a1efe3c3ea11 Mon Sep 17 00:00:00 2001 From: James Williams Date: Thu, 6 Nov 2025 15:55:17 -0600 Subject: [PATCH 02/11] resolve pylint issue --- hier_config/platforms/driver_base.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/hier_config/platforms/driver_base.py b/hier_config/platforms/driver_base.py index b58c0e7..7908533 100644 --- a/hier_config/platforms/driver_base.py +++ b/hier_config/platforms/driver_base.py @@ -173,7 +173,10 @@ def _idempotency_key( if len(lineage) != len(match_rules): return () - return tuple(map(self._idempotency_component_key, lineage, match_rules)) + return tuple( + self._idempotency_component_key(child, rule) + for child, rule in zip(lineage, match_rules) + ) def _idempotency_component_key( self, From a7b31eb759030d4147afaf0fba51392192e41fc9 Mon Sep 17 00:00:00 2001 From: James Williams Date: Thu, 6 Nov 2025 16:10:03 -0600 Subject: [PATCH 03/11] resolve linting issues --- hier_config/platforms/driver_base.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/hier_config/platforms/driver_base.py b/hier_config/platforms/driver_base.py index 7908533..72af5cf 100644 --- a/hier_config/platforms/driver_base.py +++ b/hier_config/platforms/driver_base.py @@ -173,10 +173,10 @@ def _idempotency_key( if len(lineage) != len(match_rules): return () - return tuple( - self._idempotency_component_key(child, rule) - for child, rule in zip(lineage, match_rules) - ) + components: list[str] = [] + for child, rule in zip(lineage, match_rules): + components.append(self._idempotency_component_key(child, rule)) + return tuple(components) def _idempotency_component_key( self, From 8fee8ba199d56df892ba6737ce2709ad033d86f1 Mon Sep 17 00:00:00 2001 From: James Williams Date: Thu, 6 Nov 2025 16:21:13 -0600 Subject: [PATCH 04/11] add docstrings --- hier_config/platforms/driver_base.py | 72 ++++++++++++++++++++++++++++ tests/test_hier_config.py | 6 +++ 2 files changed, 78 insertions(+) diff --git a/hier_config/platforms/driver_base.py b/hier_config/platforms/driver_base.py index 72af5cf..3acaa0b 100644 --- a/hier_config/platforms/driver_base.py +++ b/hier_config/platforms/driver_base.py @@ -169,6 +169,16 @@ def _idempotency_key( 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 () @@ -183,6 +193,16 @@ def _idempotency_component_key( 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) @@ -202,6 +222,16 @@ def _idempotency_component_key( 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): @@ -213,6 +243,16 @@ def _key_from_prefix( 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) @@ -225,6 +265,16 @@ def _key_from_suffix( 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) @@ -237,6 +287,16 @@ def _key_from_contains( 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) @@ -250,6 +310,17 @@ def _key_from_regex( 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 [] @@ -308,6 +379,7 @@ def _match_contains( @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: diff --git a/tests/test_hier_config.py b/tests/test_hier_config.py index ed293ec..6d076cf 100644 --- a/tests/test_hier_config.py +++ b/tests/test_hier_config.py @@ -486,6 +486,12 @@ 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 From 51930f76c7383757620f497b20187fdefad9c199 Mon Sep 17 00:00:00 2001 From: James Williams Date: Thu, 6 Nov 2025 16:48:59 -0600 Subject: [PATCH 05/11] update docs --- README.md | 16 ++++++++++++---- docs/drivers.md | 44 ++++++++++++++++++++++++++++++------------- docs/future-config.md | 17 +++++++++++++++++ 3 files changed, 60 insertions(+), 17 deletions(-) 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..b0e88a0 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**: @@ -122,12 +126,13 @@ In Hier Config, the rules within a driver are organized into sections, each targ - `search`: A string or regex to search for. - `replace`: The replacement text. - - **`FullTextSubRule`**: + - **`FullTextSubRule`**: - Similar to `PerLineSubRule`, but applies to the entire text rather than individual lines. --- ### 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 From faa0beecfd8dac588e4b03d41ad6dda065a35c2d Mon Sep 17 00:00:00 2001 From: James Williams Date: Thu, 6 Nov 2025 17:42:46 -0600 Subject: [PATCH 06/11] Update docs/drivers.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/drivers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/drivers.md b/docs/drivers.md index b0e88a0..c806ddc 100644 --- a/docs/drivers.md +++ b/docs/drivers.md @@ -126,7 +126,7 @@ In Hier Config, the rules within a driver are organized into sections, each targ - `search`: A string or regex to search for. - `replace`: The replacement text. - - **`FullTextSubRule`**: + - **`FullTextSubRule`**: - Similar to `PerLineSubRule`, but applies to the entire text rather than individual lines. --- From ef7533f66574eb5982fc2386b9ecd389d0353733 Mon Sep 17 00:00:00 2001 From: jtdub Date: Sun, 11 Jan 2026 18:53:09 -0600 Subject: [PATCH 07/11] ruff --- hier_config/platforms/driver_base.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/hier_config/platforms/driver_base.py b/hier_config/platforms/driver_base.py index 3acaa0b..5fd3320 100644 --- a/hier_config/platforms/driver_base.py +++ b/hier_config/platforms/driver_base.py @@ -184,7 +184,7 @@ def _idempotency_key( return () components: list[str] = [] - for child, rule in zip(lineage, match_rules): + for child, rule in zip(lineage, match_rules, strict=False): components.append(self._idempotency_component_key(child, rule)) return tuple(components) @@ -219,9 +219,7 @@ def _idempotency_component_key( return ";".join(parts) @staticmethod - def _key_from_equals( - equals: str | frozenset[str] | None, text: str - ) -> list[str]: + def _key_from_equals(equals: str | frozenset[str] | None, text: str) -> list[str]: """Return key fragments constrained by `equals` match rules. Args: @@ -363,9 +361,7 @@ def _match_suffix(value: str, suffix: str | tuple[str, ...]) -> str | None: return None @staticmethod - def _match_contains( - value: str, contains: str | tuple[str, ...] - ) -> str | None: + 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: From a289fdfe6a06411b7271b2508d11b627101d2475 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Jan 2026 04:03:25 +0000 Subject: [PATCH 08/11] Add comprehensive test coverage for idempotency key implementation - Increase driver_base.py coverage from 78% to 98% - Add 25 new tests covering all idempotency key code paths - Test all match rule types: equals, startswith, endswith, contains, regex - Test edge cases: negated commands, lineage mismatch, tuple matching - Test regex features: capture groups, greedy patterns, normalization - Verify issue #157 fix with comprehensive test scenarios All tests pass (74 in test_hier_config.py, 118 total) --- tests/test_hier_config.py | 439 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 439 insertions(+) diff --git a/tests/test_hier_config.py b/tests/test_hier_config.py index 6d076cf..4726a08 100644 --- a/tests/test_hier_config.py +++ b/tests/test_hier_config.py @@ -527,6 +527,445 @@ def test_future_preserves_bgp_neighbor_description() -> None: 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.""" + from hier_config.models import IdempotentCommandsRule + from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS + + 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 = list(config.children)[0] + + # Test the idempotency with equals string + key = driver._idempotency_key(child, (MatchRule(equals="logging console"),)) + assert key == ("equals|logging console",) + + +def test_idempotency_key_with_equals_frozenset() -> None: + """Test idempotency key generation with equals constraint as frozenset.""" + from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS + + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = list(config.children)[0] + + # Test the idempotency with equals frozenset (should fall back to text) + key = driver._idempotency_key( + 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.""" + from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS + + driver = HConfigDriverCiscoIOS() + + config_raw = """some command +""" + config = get_hconfig(driver, config_raw) + child = list(config.children)[0] + + # Empty MatchRule should fall back to text + key = driver._idempotency_key(child, (MatchRule(),)) + assert key == ("text|some command",) + + +def test_idempotency_key_prefix_no_match() -> None: + """Test idempotency key when prefix doesn't match.""" + from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS + + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = list(config.children)[0] + + # Prefix that doesn't match should fall back to text + key = driver._idempotency_key(child, (MatchRule(startswith="interface"),)) + assert key == ("text|logging console",) + + +def test_idempotency_key_suffix_no_match() -> None: + """Test idempotency key when suffix doesn't match.""" + from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS + + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = list(config.children)[0] + + # Suffix that doesn't match should fall back to text + key = driver._idempotency_key(child, (MatchRule(endswith="emergency"),)) + assert key == ("text|logging console",) + + +def test_idempotency_key_contains_no_match() -> None: + """Test idempotency key when contains doesn't match.""" + from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS + + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = list(config.children)[0] + + # Contains that doesn't match should fall back to text + key = driver._idempotency_key(child, (MatchRule(contains="interface"),)) + assert key == ("text|logging console",) + + +def test_idempotency_key_regex_no_match() -> None: + """Test idempotency key when regex doesn't match.""" + from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS + + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = list(config.children)[0] + + # Regex that doesn't match should fall back to text + key = driver._idempotency_key(child, (MatchRule(re_search="^interface"),)) + 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.""" + from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS + + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = list(config.children)[0] + + # Tuple of prefixes that don't match should fall back to text + key = driver._idempotency_key( + 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.""" + from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS + + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = list(config.children)[0] + + # Tuple of prefixes with one matching - should return longest match + key = driver._idempotency_key( + 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.""" + from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS + + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = list(config.children)[0] + + # Tuple of suffixes that don't match should fall back to text + key = driver._idempotency_key( + 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.""" + from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS + + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = list(config.children)[0] + + # Tuple of suffixes with one matching - should return longest match + key = driver._idempotency_key( + 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.""" + from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS + + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = list(config.children)[0] + + # Tuple of contains that don't match should fall back to text + key = driver._idempotency_key( + 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.""" + from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS + + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = list(config.children)[0] + + # Tuple of contains with matches - should return longest match + key = driver._idempotency_key( + 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.""" + from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS + + driver = HConfigDriverCiscoIOS() + + config_raw = """router bgp 1 + neighbor 10.1.1.1 description peer1 +""" + config = get_hconfig(driver, config_raw) + bgp_child = list(config.children)[0] + neighbor_child = list(bgp_child.children)[0] + + # Regex with capture groups should use groups + key = driver._idempotency_key( + 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.""" + from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS + + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = list(config.children)[0] + + # Regex with empty/None groups should fall back to match result + key = driver._idempotency_key(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 .+).""" + from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS + + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console emergency +""" + config = get_hconfig(driver, config_raw) + child = list(config.children)[0] + + # Regex with .* should be trimmed + key = driver._idempotency_key(child, (MatchRule(re_search=r"logging console.*"),)) + assert key == ("re|logging console",) + + +def test_idempotency_key_regex_greedy_pattern_with_dollar() -> None: + """Test idempotency key with greedy regex pattern with $ anchor.""" + from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS + + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console emergency +""" + config = get_hconfig(driver, config_raw) + child = list(config.children)[0] + + # Regex with .*$ should be trimmed + key = driver._idempotency_key(child, (MatchRule(re_search=r"logging console.*$"),)) + assert key == ("re|logging console",) + + +def test_idempotency_key_regex_only_greedy() -> None: + """Test idempotency key with regex that is only greedy pattern.""" + from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS + + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = list(config.children)[0] + + # Regex that is only .* should not trim to empty + key = driver._idempotency_key(child, (MatchRule(re_search=r".*"),)) + # 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.""" + from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS + + driver = HConfigDriverCiscoIOS() + + config_raw = """interface GigabitEthernet1/1 + description test +""" + config = get_hconfig(driver, config_raw) + interface_child = list(config.children)[0] + desc_child = list(interface_child.children)[0] + + # 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"),)) + # Should return empty tuple when lineage length != match_rules length + assert key == () + + +def test_idempotency_key_negated_command() -> None: + """Test idempotency key with negated command.""" + from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS + + driver = HConfigDriverCiscoIOS() + + config_raw = """no logging console +""" + config = get_hconfig(driver, config_raw) + child = list(config.children)[0] + + # Negated command should strip 'no ' prefix for matching + key = driver._idempotency_key(child, (MatchRule(startswith="logging"),)) + assert key == ("startswith|logging",) + + +def test_idempotency_key_regex_fallback_to_original() -> None: + """Test idempotency key regex matching fallback to original text.""" + from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS + + driver = HConfigDriverCiscoIOS() + + config_raw = """no logging console +""" + config = get_hconfig(driver, config_raw) + child = list(config.children)[0] + + # Regex that matches original but not normalized (tests lines 328-329) + key = driver._idempotency_key(child, (MatchRule(re_search=r"^no logging"),)) + 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).""" + from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS + + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = list(config.children)[0] + + # Single suffix that matches (tests line 359) + key = driver._idempotency_key(child, (MatchRule(endswith="console"),)) + assert key == ("endswith|console",) + + +def test_idempotency_key_contains_single_match() -> None: + """Test idempotency key with single contains that matches (not tuple).""" + from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS + + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console emergency +""" + config = get_hconfig(driver, config_raw) + child = list(config.children)[0] + + # Single contains that matches (tests line 372) + key = driver._idempotency_key(child, (MatchRule(contains="console"),)) + assert key == ("contains|console",) + + +def test_idempotency_key_regex_greedy_with_plus() -> None: + """Test idempotency key with greedy regex using .+ suffix.""" + from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS + + driver = HConfigDriverCiscoIOS() + + config_raw = """interface GigabitEthernet1 +""" + config = get_hconfig(driver, config_raw) + child = list(config.children)[0] + + # Regex with .+ should be trimmed similar to .* + # Tests the .+ branch in line 389 + key = driver._idempotency_key(child, (MatchRule(re_search=r"interface .+"),)) + # 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.""" + from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS + + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = list(config.children)[0] + + # 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.*"),)) + # 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") From 1d1243395300d975947239c90f649bab4da13938 Mon Sep 17 00:00:00 2001 From: jtdub Date: Fri, 16 Jan 2026 22:07:39 -0600 Subject: [PATCH 09/11] lint testing --- tests/test_hier_config.py | 58 ++++++++++++++++++++------------------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/tests/test_hier_config.py b/tests/test_hier_config.py index 4726a08..e7e9475 100644 --- a/tests/test_hier_config.py +++ b/tests/test_hier_config.py @@ -543,7 +543,7 @@ def test_idempotency_key_with_equals_string() -> None: config_raw = """logging console """ config = get_hconfig(driver, config_raw) - child = list(config.children)[0] + child = next(iter(config.children)) # Test the idempotency with equals string key = driver._idempotency_key(child, (MatchRule(equals="logging console"),)) @@ -559,7 +559,7 @@ def test_idempotency_key_with_equals_frozenset() -> None: config_raw = """logging console """ config = get_hconfig(driver, config_raw) - child = list(config.children)[0] + child = next(iter(config.children)) # Test the idempotency with equals frozenset (should fall back to text) key = driver._idempotency_key( @@ -577,7 +577,7 @@ def test_idempotency_key_no_match_rules() -> None: config_raw = """some command """ config = get_hconfig(driver, config_raw) - child = list(config.children)[0] + child = next(iter(config.children)) # Empty MatchRule should fall back to text key = driver._idempotency_key(child, (MatchRule(),)) @@ -593,7 +593,7 @@ def test_idempotency_key_prefix_no_match() -> None: config_raw = """logging console """ config = get_hconfig(driver, config_raw) - child = list(config.children)[0] + child = next(iter(config.children)) # Prefix that doesn't match should fall back to text key = driver._idempotency_key(child, (MatchRule(startswith="interface"),)) @@ -609,7 +609,7 @@ def test_idempotency_key_suffix_no_match() -> None: config_raw = """logging console """ config = get_hconfig(driver, config_raw) - child = list(config.children)[0] + child = next(iter(config.children)) # Suffix that doesn't match should fall back to text key = driver._idempotency_key(child, (MatchRule(endswith="emergency"),)) @@ -625,7 +625,7 @@ def test_idempotency_key_contains_no_match() -> None: config_raw = """logging console """ config = get_hconfig(driver, config_raw) - child = list(config.children)[0] + child = next(iter(config.children)) # Contains that doesn't match should fall back to text key = driver._idempotency_key(child, (MatchRule(contains="interface"),)) @@ -641,7 +641,7 @@ def test_idempotency_key_regex_no_match() -> None: config_raw = """logging console """ config = get_hconfig(driver, config_raw) - child = list(config.children)[0] + child = next(iter(config.children)) # Regex that doesn't match should fall back to text key = driver._idempotency_key(child, (MatchRule(re_search="^interface"),)) @@ -657,7 +657,7 @@ def test_idempotency_key_prefix_tuple_no_match() -> None: config_raw = """logging console """ config = get_hconfig(driver, config_raw) - child = list(config.children)[0] + child = next(iter(config.children)) # Tuple of prefixes that don't match should fall back to text key = driver._idempotency_key( @@ -675,7 +675,7 @@ def test_idempotency_key_prefix_tuple_match() -> None: config_raw = """logging console """ config = get_hconfig(driver, config_raw) - child = list(config.children)[0] + child = next(iter(config.children)) # Tuple of prefixes with one matching - should return longest match key = driver._idempotency_key( @@ -693,7 +693,7 @@ def test_idempotency_key_suffix_tuple_no_match() -> None: config_raw = """logging console """ config = get_hconfig(driver, config_raw) - child = list(config.children)[0] + child = next(iter(config.children)) # Tuple of suffixes that don't match should fall back to text key = driver._idempotency_key( @@ -711,7 +711,7 @@ def test_idempotency_key_suffix_tuple_match() -> None: config_raw = """logging console """ config = get_hconfig(driver, config_raw) - child = list(config.children)[0] + child = next(iter(config.children)) # Tuple of suffixes with one matching - should return longest match key = driver._idempotency_key( @@ -729,7 +729,7 @@ def test_idempotency_key_contains_tuple_no_match() -> None: config_raw = """logging console """ config = get_hconfig(driver, config_raw) - child = list(config.children)[0] + child = next(iter(config.children)) # Tuple of contains that don't match should fall back to text key = driver._idempotency_key( @@ -747,7 +747,7 @@ def test_idempotency_key_contains_tuple_match() -> None: config_raw = """logging console """ config = get_hconfig(driver, config_raw) - child = list(config.children)[0] + child = next(iter(config.children)) # Tuple of contains with matches - should return longest match key = driver._idempotency_key( @@ -766,8 +766,8 @@ def test_idempotency_key_regex_with_groups() -> None: neighbor 10.1.1.1 description peer1 """ config = get_hconfig(driver, config_raw) - bgp_child = list(config.children)[0] - neighbor_child = list(bgp_child.children)[0] + bgp_child = next(iter(config.children)) + neighbor_child = next(iter(bgp_child.children)) # Regex with capture groups should use groups key = driver._idempotency_key( @@ -789,10 +789,12 @@ def test_idempotency_key_regex_with_empty_groups() -> None: config_raw = """logging console """ config = get_hconfig(driver, config_raw) - child = list(config.children)[0] + child = next(iter(config.children)) # Regex with empty/None groups should fall back to match result - key = driver._idempotency_key(child, (MatchRule(re_search=r"logging ()?(console)"),)) + key = driver._idempotency_key( + child, (MatchRule(re_search=r"logging ()?(console)"),) + ) # Group 1 is empty, group 2 has "console", so should use groups assert "re|" in key[0] @@ -806,7 +808,7 @@ def test_idempotency_key_regex_greedy_pattern() -> None: config_raw = """logging console emergency """ config = get_hconfig(driver, config_raw) - child = list(config.children)[0] + child = next(iter(config.children)) # Regex with .* should be trimmed key = driver._idempotency_key(child, (MatchRule(re_search=r"logging console.*"),)) @@ -822,7 +824,7 @@ def test_idempotency_key_regex_greedy_pattern_with_dollar() -> None: config_raw = """logging console emergency """ config = get_hconfig(driver, config_raw) - child = list(config.children)[0] + child = next(iter(config.children)) # Regex with .*$ should be trimmed key = driver._idempotency_key(child, (MatchRule(re_search=r"logging console.*$"),)) @@ -838,7 +840,7 @@ def test_idempotency_key_regex_only_greedy() -> None: config_raw = """logging console """ config = get_hconfig(driver, config_raw) - child = list(config.children)[0] + child = next(iter(config.children)) # Regex that is only .* should not trim to empty key = driver._idempotency_key(child, (MatchRule(re_search=r".*"),)) @@ -856,8 +858,8 @@ def test_idempotency_key_lineage_mismatch() -> None: description test """ config = get_hconfig(driver, config_raw) - interface_child = list(config.children)[0] - desc_child = list(interface_child.children)[0] + 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"),)) @@ -874,7 +876,7 @@ def test_idempotency_key_negated_command() -> None: config_raw = """no logging console """ config = get_hconfig(driver, config_raw) - child = list(config.children)[0] + child = next(iter(config.children)) # Negated command should strip 'no ' prefix for matching key = driver._idempotency_key(child, (MatchRule(startswith="logging"),)) @@ -890,7 +892,7 @@ def test_idempotency_key_regex_fallback_to_original() -> None: config_raw = """no logging console """ config = get_hconfig(driver, config_raw) - child = list(config.children)[0] + 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"),)) @@ -906,7 +908,7 @@ def test_idempotency_key_suffix_single_match() -> None: config_raw = """logging console """ config = get_hconfig(driver, config_raw) - child = list(config.children)[0] + child = next(iter(config.children)) # Single suffix that matches (tests line 359) key = driver._idempotency_key(child, (MatchRule(endswith="console"),)) @@ -922,7 +924,7 @@ def test_idempotency_key_contains_single_match() -> None: config_raw = """logging console emergency """ config = get_hconfig(driver, config_raw) - child = list(config.children)[0] + child = next(iter(config.children)) # Single contains that matches (tests line 372) key = driver._idempotency_key(child, (MatchRule(contains="console"),)) @@ -938,7 +940,7 @@ def test_idempotency_key_regex_greedy_with_plus() -> None: config_raw = """interface GigabitEthernet1 """ config = get_hconfig(driver, config_raw) - child = list(config.children)[0] + child = next(iter(config.children)) # Regex with .+ should be trimmed similar to .* # Tests the .+ branch in line 389 @@ -956,7 +958,7 @@ def test_idempotency_key_regex_trimmed_to_no_match() -> None: config_raw = """logging console """ config = get_hconfig(driver, config_raw) - child = list(config.children)[0] + 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 From 36fca3830a50dd3b5b70605134018334a2f8acc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Jan 2026 04:14:09 +0000 Subject: [PATCH 10/11] Complete ruff linting fixes - Move all imports to top-level (fix PLC0415) - Add noqa: SLF001 for intentional private member access - Sort imports properly (fix I001) All tests passing, ruff checks clean. --- tests/test_hier_config.py | 104 ++++++++++---------------------------- 1 file changed, 27 insertions(+), 77 deletions(-) diff --git a/tests/test_hier_config.py b/tests/test_hier_config.py index e7e9475..823b3e1 100644 --- a/tests/test_hier_config.py +++ b/tests/test_hier_config.py @@ -13,7 +13,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: @@ -529,9 +530,6 @@ def test_future_preserves_bgp_neighbor_description() -> None: def test_idempotency_key_with_equals_string() -> None: """Test idempotency key generation with equals constraint as string.""" - from hier_config.models import IdempotentCommandsRule - from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS - driver = HConfigDriverCiscoIOS() # Add a rule with equals as string driver.rules.idempotent_commands.append( @@ -546,14 +544,12 @@ def test_idempotency_key_with_equals_string() -> None: child = next(iter(config.children)) # Test the idempotency with equals string - key = driver._idempotency_key(child, (MatchRule(equals="logging console"),)) + key = driver._idempotency_key(child, (MatchRule(equals="logging console"),)) # noqa: SLF001 assert key == ("equals|logging console",) def test_idempotency_key_with_equals_frozenset() -> None: """Test idempotency key generation with equals constraint as frozenset.""" - from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS - driver = HConfigDriverCiscoIOS() config_raw = """logging console @@ -562,7 +558,7 @@ def test_idempotency_key_with_equals_frozenset() -> None: child = next(iter(config.children)) # Test the idempotency with equals frozenset (should fall back to text) - key = driver._idempotency_key( + key = driver._idempotency_key( # noqa: SLF001 child, (MatchRule(equals=frozenset(["logging console", "other"])),) ) assert key == ("equals|logging console",) @@ -570,8 +566,6 @@ def test_idempotency_key_with_equals_frozenset() -> None: def test_idempotency_key_no_match_rules() -> None: """Test idempotency key falls back to text when no match rules apply.""" - from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS - driver = HConfigDriverCiscoIOS() config_raw = """some command @@ -580,14 +574,12 @@ def test_idempotency_key_no_match_rules() -> None: child = next(iter(config.children)) # Empty MatchRule should fall back to text - key = driver._idempotency_key(child, (MatchRule(),)) + key = driver._idempotency_key(child, (MatchRule(),)) # noqa: SLF001 assert key == ("text|some command",) def test_idempotency_key_prefix_no_match() -> None: """Test idempotency key when prefix doesn't match.""" - from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS - driver = HConfigDriverCiscoIOS() config_raw = """logging console @@ -596,14 +588,12 @@ def test_idempotency_key_prefix_no_match() -> None: child = next(iter(config.children)) # Prefix that doesn't match should fall back to text - key = driver._idempotency_key(child, (MatchRule(startswith="interface"),)) + key = driver._idempotency_key(child, (MatchRule(startswith="interface"),)) # noqa: SLF001 assert key == ("text|logging console",) def test_idempotency_key_suffix_no_match() -> None: """Test idempotency key when suffix doesn't match.""" - from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS - driver = HConfigDriverCiscoIOS() config_raw = """logging console @@ -612,14 +602,12 @@ def test_idempotency_key_suffix_no_match() -> None: child = next(iter(config.children)) # Suffix that doesn't match should fall back to text - key = driver._idempotency_key(child, (MatchRule(endswith="emergency"),)) + key = driver._idempotency_key(child, (MatchRule(endswith="emergency"),)) # noqa: SLF001 assert key == ("text|logging console",) def test_idempotency_key_contains_no_match() -> None: """Test idempotency key when contains doesn't match.""" - from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS - driver = HConfigDriverCiscoIOS() config_raw = """logging console @@ -628,14 +616,12 @@ def test_idempotency_key_contains_no_match() -> None: child = next(iter(config.children)) # Contains that doesn't match should fall back to text - key = driver._idempotency_key(child, (MatchRule(contains="interface"),)) + key = driver._idempotency_key(child, (MatchRule(contains="interface"),)) # noqa: SLF001 assert key == ("text|logging console",) def test_idempotency_key_regex_no_match() -> None: """Test idempotency key when regex doesn't match.""" - from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS - driver = HConfigDriverCiscoIOS() config_raw = """logging console @@ -644,14 +630,12 @@ def test_idempotency_key_regex_no_match() -> None: child = next(iter(config.children)) # Regex that doesn't match should fall back to text - key = driver._idempotency_key(child, (MatchRule(re_search="^interface"),)) + key = driver._idempotency_key(child, (MatchRule(re_search="^interface"),)) # noqa: SLF001 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.""" - from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS - driver = HConfigDriverCiscoIOS() config_raw = """logging console @@ -660,7 +644,7 @@ def test_idempotency_key_prefix_tuple_no_match() -> None: child = next(iter(config.children)) # Tuple of prefixes that don't match should fall back to text - key = driver._idempotency_key( + key = driver._idempotency_key( # noqa: SLF001 child, (MatchRule(startswith=("interface", "router", "vlan")),) ) assert key == ("text|logging console",) @@ -668,8 +652,6 @@ def test_idempotency_key_prefix_tuple_no_match() -> None: def test_idempotency_key_prefix_tuple_match() -> None: """Test idempotency key with tuple of prefixes that match.""" - from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS - driver = HConfigDriverCiscoIOS() config_raw = """logging console @@ -678,7 +660,7 @@ def test_idempotency_key_prefix_tuple_match() -> None: child = next(iter(config.children)) # Tuple of prefixes with one matching - should return longest match - key = driver._idempotency_key( + key = driver._idempotency_key( # noqa: SLF001 child, (MatchRule(startswith=("log", "logging", "logging console")),) ) assert key == ("startswith|logging console",) @@ -686,8 +668,6 @@ def test_idempotency_key_prefix_tuple_match() -> None: def test_idempotency_key_suffix_tuple_no_match() -> None: """Test idempotency key with tuple of suffixes that don't match.""" - from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS - driver = HConfigDriverCiscoIOS() config_raw = """logging console @@ -696,7 +676,7 @@ def test_idempotency_key_suffix_tuple_no_match() -> None: child = next(iter(config.children)) # Tuple of suffixes that don't match should fall back to text - key = driver._idempotency_key( + key = driver._idempotency_key( # noqa: SLF001 child, (MatchRule(endswith=("emergency", "alert", "critical")),) ) assert key == ("text|logging console",) @@ -704,8 +684,6 @@ def test_idempotency_key_suffix_tuple_no_match() -> None: def test_idempotency_key_suffix_tuple_match() -> None: """Test idempotency key with tuple of suffixes that match.""" - from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS - driver = HConfigDriverCiscoIOS() config_raw = """logging console @@ -714,7 +692,7 @@ def test_idempotency_key_suffix_tuple_match() -> None: child = next(iter(config.children)) # Tuple of suffixes with one matching - should return longest match - key = driver._idempotency_key( + key = driver._idempotency_key( # noqa: SLF001 child, (MatchRule(endswith=("ole", "sole", "console")),) ) assert key == ("endswith|console",) @@ -722,8 +700,6 @@ def test_idempotency_key_suffix_tuple_match() -> None: def test_idempotency_key_contains_tuple_no_match() -> None: """Test idempotency key with tuple of contains that don't match.""" - from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS - driver = HConfigDriverCiscoIOS() config_raw = """logging console @@ -732,7 +708,7 @@ def test_idempotency_key_contains_tuple_no_match() -> None: child = next(iter(config.children)) # Tuple of contains that don't match should fall back to text - key = driver._idempotency_key( + key = driver._idempotency_key( # noqa: SLF001 child, (MatchRule(contains=("interface", "router", "vlan")),) ) assert key == ("text|logging console",) @@ -740,8 +716,6 @@ def test_idempotency_key_contains_tuple_no_match() -> None: def test_idempotency_key_contains_tuple_match() -> None: """Test idempotency key with tuple of contains that match.""" - from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS - driver = HConfigDriverCiscoIOS() config_raw = """logging console @@ -750,7 +724,7 @@ def test_idempotency_key_contains_tuple_match() -> None: child = next(iter(config.children)) # Tuple of contains with matches - should return longest match - key = driver._idempotency_key( + key = driver._idempotency_key( # noqa: SLF001 child, (MatchRule(contains=("log", "console", "logging console")),) ) assert key == ("contains|logging console",) @@ -758,8 +732,6 @@ def test_idempotency_key_contains_tuple_match() -> None: def test_idempotency_key_regex_with_groups() -> None: """Test idempotency key with regex capture groups.""" - from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS - driver = HConfigDriverCiscoIOS() config_raw = """router bgp 1 @@ -770,7 +742,7 @@ def test_idempotency_key_regex_with_groups() -> None: neighbor_child = next(iter(bgp_child.children)) # Regex with capture groups should use groups - key = driver._idempotency_key( + key = driver._idempotency_key( # noqa: SLF001 neighbor_child, ( MatchRule(startswith="router bgp"), @@ -782,8 +754,6 @@ def test_idempotency_key_regex_with_groups() -> None: def test_idempotency_key_regex_with_empty_groups() -> None: """Test idempotency key with regex that has empty capture groups.""" - from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS - driver = HConfigDriverCiscoIOS() config_raw = """logging console @@ -792,7 +762,7 @@ def test_idempotency_key_regex_with_empty_groups() -> None: child = next(iter(config.children)) # Regex with empty/None groups should fall back to match result - key = driver._idempotency_key( + key = driver._idempotency_key( # noqa: SLF001 child, (MatchRule(re_search=r"logging ()?(console)"),) ) # Group 1 is empty, group 2 has "console", so should use groups @@ -801,8 +771,6 @@ def test_idempotency_key_regex_with_empty_groups() -> None: def test_idempotency_key_regex_greedy_pattern() -> None: """Test idempotency key with greedy regex pattern (.* or .+).""" - from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS - driver = HConfigDriverCiscoIOS() config_raw = """logging console emergency @@ -811,14 +779,12 @@ def test_idempotency_key_regex_greedy_pattern() -> None: child = next(iter(config.children)) # Regex with .* should be trimmed - key = driver._idempotency_key(child, (MatchRule(re_search=r"logging console.*"),)) + key = driver._idempotency_key(child, (MatchRule(re_search=r"logging console.*"),)) # noqa: SLF001 assert key == ("re|logging console",) def test_idempotency_key_regex_greedy_pattern_with_dollar() -> None: """Test idempotency key with greedy regex pattern with $ anchor.""" - from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS - driver = HConfigDriverCiscoIOS() config_raw = """logging console emergency @@ -827,14 +793,12 @@ def test_idempotency_key_regex_greedy_pattern_with_dollar() -> None: child = next(iter(config.children)) # Regex with .*$ should be trimmed - key = driver._idempotency_key(child, (MatchRule(re_search=r"logging console.*$"),)) + key = driver._idempotency_key(child, (MatchRule(re_search=r"logging console.*$"),)) # noqa: SLF001 assert key == ("re|logging console",) def test_idempotency_key_regex_only_greedy() -> None: """Test idempotency key with regex that is only greedy pattern.""" - from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS - driver = HConfigDriverCiscoIOS() config_raw = """logging console @@ -843,15 +807,13 @@ def test_idempotency_key_regex_only_greedy() -> None: child = next(iter(config.children)) # Regex that is only .* should not trim to empty - key = driver._idempotency_key(child, (MatchRule(re_search=r".*"),)) + key = driver._idempotency_key(child, (MatchRule(re_search=r".*"),)) # noqa: SLF001 # 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.""" - from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS - driver = HConfigDriverCiscoIOS() config_raw = """interface GigabitEthernet1/1 @@ -862,15 +824,13 @@ def test_idempotency_key_lineage_mismatch() -> None: 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"),)) + key = driver._idempotency_key(desc_child, (MatchRule(startswith="description"),)) # noqa: SLF001 # Should return empty tuple when lineage length != match_rules length assert key == () def test_idempotency_key_negated_command() -> None: """Test idempotency key with negated command.""" - from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS - driver = HConfigDriverCiscoIOS() config_raw = """no logging console @@ -879,14 +839,12 @@ def test_idempotency_key_negated_command() -> None: child = next(iter(config.children)) # Negated command should strip 'no ' prefix for matching - key = driver._idempotency_key(child, (MatchRule(startswith="logging"),)) + key = driver._idempotency_key(child, (MatchRule(startswith="logging"),)) # noqa: SLF001 assert key == ("startswith|logging",) def test_idempotency_key_regex_fallback_to_original() -> None: """Test idempotency key regex matching fallback to original text.""" - from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS - driver = HConfigDriverCiscoIOS() config_raw = """no logging console @@ -895,14 +853,12 @@ def test_idempotency_key_regex_fallback_to_original() -> None: 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"),)) + key = driver._idempotency_key(child, (MatchRule(re_search=r"^no logging"),)) # noqa: SLF001 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).""" - from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS - driver = HConfigDriverCiscoIOS() config_raw = """logging console @@ -911,14 +867,12 @@ def test_idempotency_key_suffix_single_match() -> None: child = next(iter(config.children)) # Single suffix that matches (tests line 359) - key = driver._idempotency_key(child, (MatchRule(endswith="console"),)) + key = driver._idempotency_key(child, (MatchRule(endswith="console"),)) # noqa: SLF001 assert key == ("endswith|console",) def test_idempotency_key_contains_single_match() -> None: """Test idempotency key with single contains that matches (not tuple).""" - from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS - driver = HConfigDriverCiscoIOS() config_raw = """logging console emergency @@ -927,14 +881,12 @@ def test_idempotency_key_contains_single_match() -> None: child = next(iter(config.children)) # Single contains that matches (tests line 372) - key = driver._idempotency_key(child, (MatchRule(contains="console"),)) + key = driver._idempotency_key(child, (MatchRule(contains="console"),)) # noqa: SLF001 assert key == ("contains|console",) def test_idempotency_key_regex_greedy_with_plus() -> None: """Test idempotency key with greedy regex using .+ suffix.""" - from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS - driver = HConfigDriverCiscoIOS() config_raw = """interface GigabitEthernet1 @@ -944,15 +896,13 @@ def test_idempotency_key_regex_greedy_with_plus() -> None: # Regex with .+ should be trimmed similar to .* # Tests the .+ branch in line 389 - key = driver._idempotency_key(child, (MatchRule(re_search=r"interface .+"),)) + key = driver._idempotency_key(child, (MatchRule(re_search=r"interface .+"),)) # noqa: SLF001 # 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.""" - from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS - driver = HConfigDriverCiscoIOS() config_raw = """logging console @@ -963,7 +913,7 @@ def test_idempotency_key_regex_trimmed_to_no_match() -> None: # 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.*"),)) + key = driver._idempotency_key(child, (MatchRule(re_search=r"interface.*"),)) # noqa: SLF001 # Since "interface.*" doesn't match "logging console", should fall back to text assert key == ("text|logging console",) From c7eab5dd97d412720c3709f7d1efaa3fd288c7a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Jan 2026 04:19:59 +0000 Subject: [PATCH 11/11] Fix pyright and pylint linting errors - Add module docstring and pylint disable for too-many-lines - Add pyright ignore comments for intentional private member access (25 instances) - Fix pylint C1803: Change 'key == ()' to 'not key' for implicit boolean check All tests passing, pyright 0 errors, ruff checks clean. --- tests/test_hier_config.py | 55 +++++++++++++++++++++------------------ 1 file changed, 29 insertions(+), 26 deletions(-) diff --git a/tests/test_hier_config.py b/tests/test_hier_config.py index 823b3e1..47501e0 100644 --- a/tests/test_hier_config.py +++ b/tests/test_hier_config.py @@ -1,3 +1,6 @@ +"""Tests for hier_config functionality.""" +# pylint: disable=too-many-lines + import tempfile import types from pathlib import Path @@ -544,7 +547,7 @@ def test_idempotency_key_with_equals_string() -> None: child = next(iter(config.children)) # Test the idempotency with equals string - key = driver._idempotency_key(child, (MatchRule(equals="logging console"),)) # noqa: SLF001 + key = driver._idempotency_key(child, (MatchRule(equals="logging console"),)) # noqa: SLF001 # pyright: ignore[reportPrivateUsage] assert key == ("equals|logging console",) @@ -558,7 +561,7 @@ def test_idempotency_key_with_equals_frozenset() -> None: child = next(iter(config.children)) # Test the idempotency with equals frozenset (should fall back to text) - key = driver._idempotency_key( # noqa: SLF001 + key = driver._idempotency_key( # noqa: SLF001 # pyright: ignore[reportPrivateUsage] child, (MatchRule(equals=frozenset(["logging console", "other"])),) ) assert key == ("equals|logging console",) @@ -574,7 +577,7 @@ def test_idempotency_key_no_match_rules() -> None: child = next(iter(config.children)) # Empty MatchRule should fall back to text - key = driver._idempotency_key(child, (MatchRule(),)) # noqa: SLF001 + key = driver._idempotency_key(child, (MatchRule(),)) # noqa: SLF001 # pyright: ignore[reportPrivateUsage] assert key == ("text|some command",) @@ -588,7 +591,7 @@ def test_idempotency_key_prefix_no_match() -> None: 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 + key = driver._idempotency_key(child, (MatchRule(startswith="interface"),)) # noqa: SLF001 # pyright: ignore[reportPrivateUsage] assert key == ("text|logging console",) @@ -602,7 +605,7 @@ def test_idempotency_key_suffix_no_match() -> None: 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 + key = driver._idempotency_key(child, (MatchRule(endswith="emergency"),)) # noqa: SLF001 # pyright: ignore[reportPrivateUsage] assert key == ("text|logging console",) @@ -616,7 +619,7 @@ def test_idempotency_key_contains_no_match() -> None: 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 + key = driver._idempotency_key(child, (MatchRule(contains="interface"),)) # noqa: SLF001 # pyright: ignore[reportPrivateUsage] assert key == ("text|logging console",) @@ -630,7 +633,7 @@ def test_idempotency_key_regex_no_match() -> None: 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 + key = driver._idempotency_key(child, (MatchRule(re_search="^interface"),)) # noqa: SLF001 # pyright: ignore[reportPrivateUsage] assert key == ("text|logging console",) @@ -644,7 +647,7 @@ def test_idempotency_key_prefix_tuple_no_match() -> None: child = next(iter(config.children)) # Tuple of prefixes that don't match should fall back to text - key = driver._idempotency_key( # noqa: SLF001 + key = driver._idempotency_key( # noqa: SLF001 # pyright: ignore[reportPrivateUsage] child, (MatchRule(startswith=("interface", "router", "vlan")),) ) assert key == ("text|logging console",) @@ -660,7 +663,7 @@ def test_idempotency_key_prefix_tuple_match() -> None: child = next(iter(config.children)) # Tuple of prefixes with one matching - should return longest match - key = driver._idempotency_key( # noqa: SLF001 + key = driver._idempotency_key( # noqa: SLF001 # pyright: ignore[reportPrivateUsage] child, (MatchRule(startswith=("log", "logging", "logging console")),) ) assert key == ("startswith|logging console",) @@ -676,7 +679,7 @@ def test_idempotency_key_suffix_tuple_no_match() -> None: child = next(iter(config.children)) # Tuple of suffixes that don't match should fall back to text - key = driver._idempotency_key( # noqa: SLF001 + key = driver._idempotency_key( # noqa: SLF001 # pyright: ignore[reportPrivateUsage] child, (MatchRule(endswith=("emergency", "alert", "critical")),) ) assert key == ("text|logging console",) @@ -692,7 +695,7 @@ def test_idempotency_key_suffix_tuple_match() -> None: child = next(iter(config.children)) # Tuple of suffixes with one matching - should return longest match - key = driver._idempotency_key( # noqa: SLF001 + key = driver._idempotency_key( # noqa: SLF001 # pyright: ignore[reportPrivateUsage] child, (MatchRule(endswith=("ole", "sole", "console")),) ) assert key == ("endswith|console",) @@ -708,7 +711,7 @@ def test_idempotency_key_contains_tuple_no_match() -> None: child = next(iter(config.children)) # Tuple of contains that don't match should fall back to text - key = driver._idempotency_key( # noqa: SLF001 + key = driver._idempotency_key( # noqa: SLF001 # pyright: ignore[reportPrivateUsage] child, (MatchRule(contains=("interface", "router", "vlan")),) ) assert key == ("text|logging console",) @@ -724,7 +727,7 @@ def test_idempotency_key_contains_tuple_match() -> None: child = next(iter(config.children)) # Tuple of contains with matches - should return longest match - key = driver._idempotency_key( # noqa: SLF001 + key = driver._idempotency_key( # noqa: SLF001 # pyright: ignore[reportPrivateUsage] child, (MatchRule(contains=("log", "console", "logging console")),) ) assert key == ("contains|logging console",) @@ -742,7 +745,7 @@ def test_idempotency_key_regex_with_groups() -> None: neighbor_child = next(iter(bgp_child.children)) # Regex with capture groups should use groups - key = driver._idempotency_key( # noqa: SLF001 + key = driver._idempotency_key( # noqa: SLF001 # pyright: ignore[reportPrivateUsage] neighbor_child, ( MatchRule(startswith="router bgp"), @@ -762,7 +765,7 @@ def test_idempotency_key_regex_with_empty_groups() -> None: child = next(iter(config.children)) # Regex with empty/None groups should fall back to match result - key = driver._idempotency_key( # noqa: SLF001 + 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 @@ -779,7 +782,7 @@ def test_idempotency_key_regex_greedy_pattern() -> None: child = next(iter(config.children)) # Regex with .* should be trimmed - key = driver._idempotency_key(child, (MatchRule(re_search=r"logging console.*"),)) # noqa: SLF001 + key = driver._idempotency_key(child, (MatchRule(re_search=r"logging console.*"),)) # noqa: SLF001 # pyright: ignore[reportPrivateUsage] assert key == ("re|logging console",) @@ -793,7 +796,7 @@ def test_idempotency_key_regex_greedy_pattern_with_dollar() -> None: child = next(iter(config.children)) # Regex with .*$ should be trimmed - key = driver._idempotency_key(child, (MatchRule(re_search=r"logging console.*$"),)) # noqa: SLF001 + key = driver._idempotency_key(child, (MatchRule(re_search=r"logging console.*$"),)) # noqa: SLF001 # pyright: ignore[reportPrivateUsage] assert key == ("re|logging console",) @@ -807,7 +810,7 @@ def test_idempotency_key_regex_only_greedy() -> None: 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 + 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",) @@ -824,9 +827,9 @@ def test_idempotency_key_lineage_mismatch() -> None: 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 + 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 key == () + assert not key def test_idempotency_key_negated_command() -> None: @@ -839,7 +842,7 @@ def test_idempotency_key_negated_command() -> None: child = next(iter(config.children)) # Negated command should strip 'no ' prefix for matching - key = driver._idempotency_key(child, (MatchRule(startswith="logging"),)) # noqa: SLF001 + key = driver._idempotency_key(child, (MatchRule(startswith="logging"),)) # noqa: SLF001 # pyright: ignore[reportPrivateUsage] assert key == ("startswith|logging",) @@ -853,7 +856,7 @@ def test_idempotency_key_regex_fallback_to_original() -> None: 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 + key = driver._idempotency_key(child, (MatchRule(re_search=r"^no logging"),)) # noqa: SLF001 # pyright: ignore[reportPrivateUsage] assert "re|no logging" in key[0] @@ -867,7 +870,7 @@ def test_idempotency_key_suffix_single_match() -> None: child = next(iter(config.children)) # Single suffix that matches (tests line 359) - key = driver._idempotency_key(child, (MatchRule(endswith="console"),)) # noqa: SLF001 + key = driver._idempotency_key(child, (MatchRule(endswith="console"),)) # noqa: SLF001 # pyright: ignore[reportPrivateUsage] assert key == ("endswith|console",) @@ -881,7 +884,7 @@ def test_idempotency_key_contains_single_match() -> None: child = next(iter(config.children)) # Single contains that matches (tests line 372) - key = driver._idempotency_key(child, (MatchRule(contains="console"),)) # noqa: SLF001 + key = driver._idempotency_key(child, (MatchRule(contains="console"),)) # noqa: SLF001 # pyright: ignore[reportPrivateUsage] assert key == ("contains|console",) @@ -896,7 +899,7 @@ def test_idempotency_key_regex_greedy_with_plus() -> None: # 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 + 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",) @@ -913,7 +916,7 @@ def test_idempotency_key_regex_trimmed_to_no_match() -> None: # 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 + 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",)