From 330fc5e7af44546c40a0abff43af9a5f69438cf6 Mon Sep 17 00:00:00 2001 From: James Williams Date: Wed, 28 Jan 2026 12:29:41 -0600 Subject: [PATCH] add negation_negate_with support to load_hconfig_v2_options --- docs/utilities.md | 9 +++ hier_config/utils.py | 175 +++++++++++++++++++++---------------------- tests/conftest.py | 9 +++ tests/test_utils.py | 9 +++ 4 files changed, 114 insertions(+), 88 deletions(-) diff --git a/docs/utilities.md b/docs/utilities.md index 1515b3d..7087fe5 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -122,6 +122,15 @@ v2_options = { {"lineage": [{"startswith": "router bgp"}], "exit_text": "exit"} ], "idempotent_commands": [{"lineage": [{"startswith": "interface"}]}], + "negation_negate_with": [ + { + "lineage": [ + {"startswith": "interface Ethernet"}, + {"startswith": "spanning-tree port type"}, + ], + "use": "no spanning-tree port type", + } + ], } platform = Platform.CISCO_IOS driver = load_hconfig_v2_options(v2_options, platform) diff --git a/hier_config/utils.py b/hier_config/utils.py index 42cbcfb..37627b0 100644 --- a/hier_config/utils.py +++ b/hier_config/utils.py @@ -13,6 +13,7 @@ IndentAdjustRule, MatchRule, NegationDefaultWhenRule, + NegationDefaultWithRule, OrderingRule, ParentAllowsDuplicateChildRule, PerLineSubRule, @@ -125,94 +126,45 @@ def hconfig_v3_platform_v2_os_mapper(platform: Platform) -> str: return "generic" -def load_hconfig_v2_options( - v2_options: dict[str, Any] | str, platform: Platform -) -> HConfigDriverBase: - """Load Hier Config v2 options to v3 driver format from either a dictionary or a file. +def _process_simple_rules( + v2_options: dict[str, Any], + key: str, + rule_class: type[Any], + append_to: Callable[[Any], None], +) -> None: + """Process v2 rules that only need match_rules.""" + for rule in v2_options.get(key, ()): + match_rules = _collect_match_rules(rule.get("lineage", [])) + append_to(rule_class(match_rules=match_rules)) - Args: - v2_options (Union[dict, str]): Either a dictionary containing v2 options or - a file path to a YAML file containing the v2 options. - platform (Platform): The Hier Config v3 Platform enum for the target platform. - - Returns: - HConfigDriverBase: A v3 driver instance with the migrated rules. - - """ - # Load options from a file if a string is provided - if isinstance(v2_options, str): - v2_options = yaml.safe_load(read_text_from_file(file_path=v2_options)) - - # Ensure v2_options is a dictionary - if not isinstance(v2_options, dict): - msg = "v2_options must be a dictionary or a valid file path." - raise TypeError(msg) - - driver = get_hconfig_driver(platform) - - def process_rules( - key: str, - rule_class: type[Any], - append_to: Callable[[Any], None], - lineage_key: str = "lineage", - ) -> None: - """Helper to process rules.""" - for rule in v2_options.get(key, ()): - match_rules = _collect_match_rules(rule.get(lineage_key, [])) - append_to(rule_class(match_rules=match_rules)) - - # sectional_overwrite - process_rules( - "sectional_overwrite", - SectionalOverwriteRule, - driver.rules.sectional_overwrite.append, - ) - - # sectional_overwrite_no_negate - process_rules( - "sectional_overwrite_no_negate", - SectionalOverwriteNoNegateRule, - driver.rules.sectional_overwrite_no_negate.append, - ) +def _process_custom_rules( + v2_options: dict[str, Any], driver: HConfigDriverBase +) -> None: + """Process v2 rules that require custom handling.""" for rule in v2_options.get("ordering", ()): - lineage_rules = rule.get("lineage", []) - match_rules = _collect_match_rules(lineage_rules) + match_rules = _collect_match_rules(rule.get("lineage", [])) weight = rule.get("order", 500) - 500 - driver.rules.ordering.append( OrderingRule(match_rules=match_rules, weight=weight), ) - # indent_adjust for rule in v2_options.get("indent_adjust", ()): - start_expression = rule.get("start_expression") - end_expression = rule.get("end_expression") - driver.rules.indent_adjust.append( IndentAdjustRule( - start_expression=start_expression, end_expression=end_expression + start_expression=rule.get("start_expression"), + end_expression=rule.get("end_expression"), ) ) - # parent_allows_duplicate_child - process_rules( - "parent_allows_duplicate_child", - ParentAllowsDuplicateChildRule, - driver.rules.parent_allows_duplicate_child.append, - ) - - # sectional_exiting for rule in v2_options.get("sectional_exiting", ()): - lineage_rules = rule.get("lineage", []) - match_rules = _collect_match_rules(lineage_rules) - exit_text = rule.get("exit_text", "") - + match_rules = _collect_match_rules(rule.get("lineage", [])) driver.rules.sectional_exiting.append( - SectionalExitingRule(match_rules=match_rules, exit_text=exit_text), + SectionalExitingRule( + match_rules=match_rules, exit_text=rule.get("exit_text", "") + ), ) - # full_text_sub for rule in v2_options.get("full_text_sub", ()): driver.rules.full_text_sub.append( FullTextSubRule( @@ -220,7 +172,6 @@ def process_rules( ) ) - # per_line_sub for rule in v2_options.get("per_line_sub", ()): driver.rules.per_line_sub.append( PerLineSubRule( @@ -228,26 +179,74 @@ def process_rules( ) ) - # idempotent_commands_blacklist -> idempotent_commands_avoid - process_rules( - "idempotent_commands_blacklist", - IdempotentCommandsAvoidRule, - driver.rules.idempotent_commands_avoid.append, - ) + for rule in v2_options.get("negation_negate_with", ()): + match_rules = _collect_match_rules(rule.get("lineage", [])) + driver.rules.negate_with.append( + NegationDefaultWithRule(match_rules=match_rules, use=rule.get("use", "")), + ) - # idempotent_commands - process_rules( - "idempotent_commands", - IdempotentCommandsRule, - driver.rules.idempotent_commands.append, - ) - # negation_default_when - process_rules( - "negation_default_when", - NegationDefaultWhenRule, - driver.rules.negation_default_when.append, +def load_hconfig_v2_options( + v2_options: dict[str, Any] | str, platform: Platform +) -> HConfigDriverBase: + """Load Hier Config v2 options to v3 driver format from either a dictionary or a file. + + Args: + v2_options (Union[dict, str]): Either a dictionary containing v2 options or + a file path to a YAML file containing the v2 options. + platform (Platform): The Hier Config v3 Platform enum for the target platform. + + Returns: + HConfigDriverBase: A v3 driver instance with the migrated rules. + + """ + if isinstance(v2_options, str): + v2_options = yaml.safe_load(read_text_from_file(file_path=v2_options)) + + if not isinstance(v2_options, dict): + msg = "v2_options must be a dictionary or a valid file path." + raise TypeError(msg) + + driver = get_hconfig_driver(platform) + + # Process simple rules that only need match_rules + simple_rules: tuple[tuple[str, type[Any], Callable[[Any], None]], ...] = ( + ( + "sectional_overwrite", + SectionalOverwriteRule, + driver.rules.sectional_overwrite.append, + ), + ( + "sectional_overwrite_no_negate", + SectionalOverwriteNoNegateRule, + driver.rules.sectional_overwrite_no_negate.append, + ), + ( + "parent_allows_duplicate_child", + ParentAllowsDuplicateChildRule, + driver.rules.parent_allows_duplicate_child.append, + ), + ( + "idempotent_commands_blacklist", + IdempotentCommandsAvoidRule, + driver.rules.idempotent_commands_avoid.append, + ), + ( + "idempotent_commands", + IdempotentCommandsRule, + driver.rules.idempotent_commands.append, + ), + ( + "negation_default_when", + NegationDefaultWhenRule, + driver.rules.negation_default_when.append, + ), ) + for key, rule_class, append_to in simple_rules: + _process_simple_rules(v2_options, key, rule_class, append_to) + + # Process rules that require custom handling + _process_custom_rules(v2_options, driver) return driver diff --git a/tests/conftest.py b/tests/conftest.py index c53c308..27ddc70 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -107,6 +107,15 @@ def v2_options() -> dict[str, Any]: } ], "idempotent_commands": [{"lineage": [{"startswith": "interface"}]}], + "negation_negate_with": [ + { + "lineage": [ + {"startswith": "interface Ethernet"}, + {"startswith": "spanning-tree port type"}, + ], + "use": "no spanning-tree port type", + } + ], } diff --git a/tests/test_utils.py b/tests/test_utils.py index dff6dac..63ee3bf 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -173,6 +173,15 @@ def test_load_hconfig_v2_options( assert len(driver.rules.idempotent_commands) == 1 assert driver.rules.idempotent_commands[0].match_rules[0].startswith == "interface" + # Assert negation_negate_with -> negate_with + assert len(driver.rules.negate_with) == 1 + assert driver.rules.negate_with[0].match_rules[0].startswith == "interface Ethernet" + assert ( + driver.rules.negate_with[0].match_rules[1].startswith + == "spanning-tree port type" + ) + assert driver.rules.negate_with[0].use == "no spanning-tree port type" + def test_load_hconfig_v2_tags_valid_input() -> None: v2_tags = [