diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..dca2c592 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @jtdub @aedwardstx diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 59df6789..be694321 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -2,9 +2,9 @@ name: hier_config build and test on: push: - branches: [master] + branches: [master, next] pull_request: - branches: [master] + branches: [master, next] jobs: build: diff --git a/CHANGELOG.md b/CHANGELOG.md index 901d62ae..df5d907a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `TextStyle` type alias (`Literal["without_comments", "merged", "with_comments"]`) for + the `style` parameter on `HConfigChild.cisco_style_text()` and + `RemediationReporter.to_text()`, replacing the unconstrained `str` type (#189). + - Performance benchmarks for parsing, remediation, and iteration (#202). Skipped by default; run with `poetry run pytest -m benchmark -v -s`. - Added support for Huawei VRP with a new driver and test suite (#238). +- Custom exception hierarchy: `HierConfigError` base, `DriverNotFoundError`, + `InvalidConfigError`, `IncompatibleDriverError` (#219). `DuplicateChildError` + reparented under `HierConfigError`. + +### Changed + +- Changed `style` parameter on `indented_text()` and `RemediationReporter.to_text()` from `str` to `Literal["without_comments", "merged", "with_comments"]` via new `TextStyle` type alias (#189). +- Renamed `load_hconfig_v2_options` to `load_driver_rules` (#221). +- Renamed `load_hconfig_v2_tags` to `load_tag_rules` (#221). +- Renamed `tags_add()`/`tags_remove()` to `add_tags()`/`remove_tags()` (#216). +- Renamed `cisco_style_text()` to `indented_text()` (#216). +- Renamed `dump_simple()` to `to_lines()` (#216). +- Renamed `config_to_get_to()` to `remediation()` (#216). +- Converted `depth()` method to `depth` property (#216). + +### Removed + +- Removed `HCONFIG_PLATFORM_V2_TO_V3_MAPPING` constant (#221). +- Removed `hconfig_v2_os_v3_platform_mapper()` function (#221). +- Removed `hconfig_v3_platform_v2_os_mapper()` function (#221). +- Removed `load_hconfig_v2_options_from_file()` function (#221). ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index c16ea29f..11a430e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co hier_config is a Python library that compares network device configurations (running vs intended) and generates minimal remediation commands. It parses config text into hierarchical trees and computes diffs respecting vendor-specific syntax rules. +## Branching Strategy + +- `master` — stable branch for v3.x releases and maintenance. +- `next` — long-lived development branch for v4 work. All v4 features and breaking changes target this branch. PRs for v4 work should be opened against `next`. + ## Build & Test Commands All commands use **poetry** (not pip): @@ -21,10 +26,16 @@ poetry run ./scripts/build.py lint poetry run ./scripts/build.py pytest --coverage # Run a single test -poetry run pytest tests/test_driver_cisco_xr.py::test_multiple_groups_no_duplicate_child_error -v +poetry run pytest tests/unit/platforms/test_cisco_xr.py::test_multiple_groups_no_duplicate_child_error -v # Run a single test file -poetry run pytest tests/test_driver_cisco_xr.py -v +poetry run pytest tests/unit/platforms/test_cisco_xr.py -v + +# Run only unit tests +poetry run pytest tests/unit/ -v + +# Run only integration tests +poetry run pytest tests/integration/ -v # Auto-fix formatting poetry run ruff format hier_config tests scripts @@ -52,9 +63,9 @@ Three-layer design: **Tree** (parse/represent config), **Driver** (platform-spec ### Tree Layer -- `HConfig` (root.py) — root node, owns the driver reference. Key methods: `config_to_get_to()`, `future()`, `difference()`, `dump_simple()`. +- `HConfig` (root.py) — root node, owns the driver reference. Key methods: `remediation()`, `future()`, `difference()`, `to_lines()`. - `HConfigChild` (child.py) — tree node with `text`, `parent`, `children`, `tags`, `comments`. Provides `is_lineage_match()` for rule evaluation and `negate()` for negation logic. -- `HConfigBase` (base.py) — abstract base shared by both. Provides `add_child()`, `get_children_deep()`, `_config_to_get_to()` (left pass = negate missing, right pass = add new). +- `HConfigBase` (base.py) — abstract base shared by both. Provides `add_child()`, `get_children_deep()`, `_remediation()` (left pass = negate missing, right pass = add new). - `HConfigChildren` (children.py) — ordered collection with O(1) dict lookup by text. ### Driver Layer (`platforms/`) @@ -90,6 +101,21 @@ This project follows **Test-Driven Development (TDD)**: All new features and bug fixes must have corresponding tests. Write tests before or alongside implementation, not after. +### Test Organization + +Tests mirror the source code structure and are split into categories: + +- **`tests/unit/`** — Unit tests for individual classes and functions. + - `test_root.py`, `test_child.py`, `test_children.py` — Tree layer tests. + - `test_constructors.py`, `test_utils.py`, `test_workflows.py`, `test_reporting.py` — Core module tests. + - `platforms/` — Driver unit tests (post-load callbacks, swap_negation, etc.). + - `platforms/views/` — Config view tests. +- **`tests/integration/`** — Tests that exercise driver remediation scenarios (running config → generated config → remediation). + - Per-platform files (`test_cisco_ios.py`, `test_cisco_xr.py`, etc.). + - `test_remediation.py` — Cross-platform remediation, future, and difference tests. + - `test_circular_workflows.py` — Roundtrip workflow validation across all platforms. +- **`tests/benchmarks/`** — Performance benchmarks (skipped by default). + ## Changelog When creating a PR, always update `CHANGELOG.md` with a summary of the changes under the `## [Unreleased]` section. Use the [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format with categories: `Added`, `Changed`, `Fixed`, `Removed`. Reference the GitHub issue number when applicable (e.g., `(#209)`). When a version is released, move unreleased entries under a new version heading with the release date. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 017a6cda..ffc3fe04 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -115,12 +115,11 @@ ruff format . Example: ``` -Add negation_negate_with support to load_hconfig_v2_options +Add negation_negate_with support to load_driver_rules -When migrating from v2 to v3, users may need to express custom negation -strings via the v2 option dict format. This change forwards that value -into the NegationDefaultWithRule model so that the behaviour is preserved -during migration. +When loading driver rules from a dict, users may need to express custom +negation strings. This change forwards that value into the +NegationDefaultWithRule model so that the behaviour is preserved. ``` --- diff --git a/docs/architecture.md b/docs/architecture.md index bc46d3c4..1a50db42 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,7 +26,7 @@ The tree layer lives in `hier_config/base.py`, `hier_config/root.py`, `hier_conf - A reference to the **driver** for the platform. - An `HConfigChildren` collection of top-level `HConfigChild` nodes. -- High-level operations: `future()`, `config_to_get_to()`, `merge()`, `difference()`, `dump()`. +- High-level operations: `future()`, `remediation()`, `merge()`, `difference()`, `dump()`. Create an `HConfig` object via the constructor function: @@ -62,7 +62,7 @@ Both `HConfig` and `HConfigChild` inherit from `HConfigBase`, which provides: - Child manipulation: `add_child`, `add_children`, `add_deep_copy_of`, `add_shallow_copy_of`. - Searching: `get_child`, `get_children`, `get_child_deep`, `get_children_deep`. -- Diffing: `unified_diff`, `_config_to_get_to`, `_difference`. +- Diffing: `unified_diff`, `_remediation`, `_difference`. - Future prediction: `_future`, `_future_pre`. --- @@ -129,9 +129,9 @@ remediation = workflow.remediation_config # what to apply rollback = workflow.rollback_config # how to revert ``` -Internally it calls `running_config.config_to_get_to(generated_config)` which traverses the tree and calls `_config_to_get_to_left` (what to negate) and `_config_to_get_to_right` (what to add). +Internally it calls `running_config.remediation(generated_config)` which traverses the tree and calls `_remediation_left` (what to negate) and `_remediation_right` (what to add). -### `config_to_get_to()` +### `remediation()` This method computes the **minimal delta** between two configs: @@ -204,7 +204,7 @@ HConfig tree (HConfigBase / HConfigChild nodes) │ ├──► HConfig.future() → predicted post-change HConfig │ - ├──► HConfig.config_to_get_to() + ├──► HConfig.remediation() │ │ │ ▼ │ delta HConfig (remediation commands) diff --git a/docs/drivers.md b/docs/drivers.md index 0d78d1df..c23b537c 100644 --- a/docs/drivers.md +++ b/docs/drivers.md @@ -210,7 +210,7 @@ intended = get_hconfig(Platform.HP_PROCURVE, intended_text) workflow = WorkflowRemediation(running, intended) for line in workflow.remediation_config.all_children_sorted(): - print(line.cisco_style_text()) + print(line.indented_text()) ``` --- @@ -239,7 +239,7 @@ intended = get_hconfig(Platform.HP_COMWARE5, intended_text) workflow = WorkflowRemediation(running, intended) for line in workflow.remediation_config.all_children_sorted(): - print(line.cisco_style_text()) + print(line.indented_text()) ``` --- @@ -605,7 +605,7 @@ Both approaches allow you to extend the functionality of the Cisco IOS driver: Unused object detection is not enabled in any driver by default — it must be explicitly configured. This ensures no unintended side-effects for users who are not expecting it. -You can add unused object rules dynamically or via `load_hconfig_v2_options`: +You can add unused object rules dynamically or via `load_driver_rules`: #### Dynamic Extension @@ -634,11 +634,11 @@ for unused in config.unused_objects(): print(f"Unused: {unused.text}") ``` -#### Via `load_hconfig_v2_options` +#### Via `load_driver_rules` ```python from hier_config import get_hconfig, Platform -from hier_config.utils import load_hconfig_v2_options +from hier_config.utils import load_driver_rules options = { "unused_objects": [ @@ -654,7 +654,7 @@ options = { }, ], } -driver = load_hconfig_v2_options(options, Platform.CISCO_XR) +driver = load_driver_rules(options, Platform.CISCO_XR) config = get_hconfig(driver, running_config_text) for unused in config.unused_objects(): diff --git a/docs/future-config.md b/docs/future-config.md index 22fe85e0..1a65e66d 100644 --- a/docs/future-config.md +++ b/docs/future-config.md @@ -7,7 +7,7 @@ This feature is useful in scenarios where you need to determine the anticipated - Verifying that a configuration change was successfully applied to a device - For example, checking if the post-change configuration matches the predicted future configuration - Generating a future-state configuration that can be analyzed by tools like Batfish to assess the potential impact of a change -- Building rollback configurations: once the future configuration state is known, a rollback configuration can be generated by simply creating the remediation in reverse `(rollback = future.config_to_get_to(running))`. +- Building rollback configurations: once the future configuration state is known, a rollback configuration can be generated by simply creating the remediation in reverse `(rollback = future.remediation(running))`. - When building rollbacks for a series of configuration changes, you can use the future configuration from each change as input for the subsequent change. For example, use the future configuration after Change 1 as the input for determining the future configuration after Change 2, and so on. ## `merge()` versus `future()` @@ -21,9 +21,9 @@ If you receive a `DuplicateChildError` while calling `merge()`, consider whether ```python post_change_1_config = running_config.future(change_1_config) -change_1_rollback_config = post_change_1_config.config_to_get_to(running_config) +change_1_rollback_config = post_change_1_config.remediation(running_config) 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) +change_2_rollback_config = post_change_2_config.remediation(post_change_1_config) ``` @@ -53,7 +53,7 @@ The `future()` algorithm is a best-effort simulation. The following cases are n 5. **ACL idempotency check.** The extended ACL idempotency logic (sequence-number based matching) that drives - `config_to_get_to()` is not replicated in `future()`. + `remediation()` is not replicated in `future()`. 6. **And likely others.** Complex platform-specific interactions (e.g. VRF-aware BGP sections, route-policy @@ -93,7 +93,7 @@ configuration because their identities differ within the `neighbor` hierarchy. >>> print("Running Config") Running Config >>> for line in running_config.all_children(): -... print(line.cisco_style_text()) +... print(line.indented_text()) ... hostname aggr-example.rtr ip access-list extended TEST @@ -116,7 +116,7 @@ interface Vlan3 >>> print("Remediation Config") Remediation Config >>> for line in remediation_config.all_children(): -... print(line.cisco_style_text()) +... print(line.indented_text()) ... vlan 3 name switch_mgmt_10.0.3.0/24 @@ -139,7 +139,7 @@ interface Vlan4 >>> print("Future Config") Future Config >>> for line in running_config.future(remediation_config).all_children(): -... print(line.cisco_style_text()) +... print(line.indented_text()) ... vlan 3 name switch_mgmt_10.0.3.0/24 diff --git a/docs/getting-started.md b/docs/getting-started.md index 70443e8b..298d60ae 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -48,7 +48,7 @@ The `remediation_config` attribute generates the configuration needed to apply t >>> print("Remediation configuration:") Remediation configuration: >>> for line in workflow.remediation_config.all_children_sorted(): -... print(line.cisco_style_text()) +... print(line.indented_text()) ... vlan 3 name switch_mgmt_10.0.3.0/24 @@ -79,7 +79,7 @@ Similarly, the `rollback_config` attribute generates a configuration that can re >>> print("Rollback configuration:") Rollback configuration: >>> for line in workflow.rollback_config.all_children_sorted(): -... print(line.cisco_style_text()) +... print(line.indented_text()) ... no vlan 4 no interface Vlan4 diff --git a/docs/glossary.md b/docs/glossary.md index 80e3822a..3b5ddf5a 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -24,7 +24,7 @@ A Python class that encodes all operating-system-specific behaviour for one netw A configuration command where only the *last* value applied takes effect — applying the same command twice with different values results in only the second value being active. Typical examples: `hostname`, `ip address`, `description`. -hier_config uses `IdempotentCommandsRule` to identify these commands. During `config_to_get_to()`, when both the running and intended configs contain a command that matches an idempotency rule, the running value is **not** negated before the new value is applied (the new value simply overwrites it). +hier_config uses `IdempotentCommandsRule` to identify these commands. During `remediation()`, when both the running and intended configs contain a command that matches an idempotency rule, the running value is **not** negated before the new value is applied (the new value simply overwrites it). **Example rule:** @@ -126,7 +126,7 @@ A `SectionalExitingRule` that instructs hier_config to emit a closing token at t ## Sectional overwrite -A `SectionalOverwriteRule` that tells `config_to_get_to()` to **negate the entire section** and then re-create it from the intended config rather than performing a line-by-line diff. Appropriate for configuration blocks where the order of entries matters globally or where partial changes are not supported by the OS. +A `SectionalOverwriteRule` that tells `remediation()` to **negate the entire section** and then re-create it from the intended config rather than performing a line-by-line diff. Appropriate for configuration blocks where the order of entries matters globally or where partial changes are not supported by the OS. --- diff --git a/docs/index.md b/docs/index.md index 9c5ab990..b0656d89 100644 --- a/docs/index.md +++ b/docs/index.md @@ -44,7 +44,7 @@ intended = get_hconfig(Platform.CISCO_IOS, intended_config_text) workflow = WorkflowRemediation(running, intended) for line in workflow.remediation_config.all_children_sorted(): - print(line.cisco_style_text()) + print(line.indented_text()) ``` --- diff --git a/docs/utilities.md b/docs/utilities.md index 7087fe53..57f5c955 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -43,79 +43,26 @@ tag_rules = load_hier_config_tags("path/to/tag_rules.yml") print(tag_rules) ``` -## Hier Config V2 to V3 Migration Utilities +## load_driver_rules -Hier Config version 3 introduces breaking changes compared to version 2. These utilities are designed to help you transition seamlessly by enabling the continued use of version 2 configurations while you update your tooling to support the new version. - -### hconfig_v2_os_v3_platform_mapper **Description**: -Maps a Hier Config v2 OS name to a v3 Platform enumeration. +Loads driver rules from a dictionary or YAML file into a platform driver. **Arguments**: - `os_name (str)`: The name of the OS as defined in Hier Config v2. + `options (Dict[str, Any] | str)`: A dictionary of rule options, or a file path to a YAML file. + `platform (Platform)`: The Platform enum for the target platform. **Returns**: - `Platform`: The corresponding Platform enumeration for Hier Config v3. - -**Raises**: - - `ValueError`: If the provided OS name is not supported in v2. + `HConfigDriverBase`: A driver instance with the loaded rules. -**Example**: -```python -from hier_config.utils import hconfig_v2_os_v3_platform_mapper - -platform = hconfig_v2_os_v3_platform_mapper("ios") - -print(platform) # Output: -``` - -### hconfig_v3_platform_v2_os_mapper -**Description**: -Maps a Hier Config v3 Platform enumeration to a v2 OS name. - -**Arguments**: - - `platform (Platform)`: A Platform enumeration from Hier Config v3. - -**Returns**: - - `str`: The corresponding OS name for Hier Config v2. - -**Raises**: - - `ValueError`: If the provided Platform is not supported in v3. - -**Example**: -```python -from hier_config.utils import hconfig_v3_platform_v2_os_mapper - -os_name = hconfig_v3_platform_v2_os_mapper(Platform.CISCO_IOS) -print(os_name) # Output: "ios" -``` - -### load_hconfig_v2_options -**Description**: -Loads v2-style configuration options into a v3-compatible driver. - -**Arguments**: - - `v2_options (Dict[str, Any])`: A dictionary of v2-style options. - `platform (Platform)`: A Platform enumeration from Hier Config v3. - -**Returns**: - - `HConfigDriverBase`: Hier Config Platform Driver. - -**Example loading options from a dictionary**: +**Example loading rules from a dictionary**: ```python from hier_config import Platform -from hier_config.utils import load_hconfig_v2_options +from hier_config.utils import load_driver_rules -v2_options = { - "negation": "no", +options = { "ordering": [{"lineage": [{"startswith": "ntp"}], "order": 700}], "per_line_sub": [{"search": "^!.*Generated.*$", "replace": ""}], "sectional_exiting": [ @@ -133,57 +80,50 @@ v2_options = { ], } platform = Platform.CISCO_IOS -driver = load_hconfig_v2_options(v2_options, platform) - -print(driver) +driver = load_driver_rules(options, platform) ``` -*Output*: -``` -print(driver.rules) -full_text_sub=[] idempotent_commands=[IdempotentCommandsRule(match_rules=(MatchRule(equals=None, startswith='vlan', endswith=None, contains=None, re_search=None), MatchRule(equals=None, startswith='name', endswith=None, contains=None, re_search=None))), IdempotentCommandsRule(match_rules=(MatchRule(equals=None, startswith='interface ', endswith=None, contains=None, re_search=None), MatchRule(equals=None, startswith='description ', endswith=None, contains=None, re_search=None))), IdempotentCommandsRule(match_rules=(MatchRule(equals=None, startswith='interface ', endswith=None, contains=None, re_search=None), MatchRule(equals=None, startswith='ip address ', endswith=None, contains=None, re_search=None))), IdempotentCommandsRule(match_rules=(MatchRule(equals=None, startswith='interface ', endswith=None, contains=None, re_search=None), MatchRule(equals=None, startswith='switchport mode ', endswith=None, contains=None, re_search=None))), IdempotentCommandsRule(match_rules=(MatchRule(equals=None, startswith='interface ', endswith=None, contains=None, re_search=None), MatchRule(equals=None, startswith='authentication host-mode ', endswith=None, contains=None, re_search=None))), IdempotentCommandsRule(match_rules=(MatchRule(equals=None, startswith='interface ', endswith=None, contains=None, re_search=None), MatchRule(equals=None, startswith='authentication event server dead action authorize vlan ', endswith=None, contains=None, re_search=None))), IdempotentCommandsRule(match_rules=(MatchRule(equals=None, startswith='errdisable recovery interval ', endswith=None, contains=None, re_search=None),)), IdempotentCommandsRule(match_rules=(MatchRule(equals=None, startswith=None, endswith=None, contains=None, re_search='^(no )?logging console.*'),)), IdempotentCommandsRule(match_rules=(MatchRule(equals=None, startswith='interface', endswith=None, contains=None, re_search=None),))] idempotent_commands_avoid=[] indent_adjust=[] indentation=2 negation_default_when=[] negate_with=[NegationDefaultWithRule(match_rules=(MatchRule(equals=None, startswith='logging console ', endswith=None, contains=None, re_search=None),), use='logging console debugging'), NegationDefaultWithRule(match_rules=(MatchRule(equals=None, startswith='', endswith=None, contains=None, re_search=None),), use='no')] ordering=[OrderingRule(match_rules=(MatchRule(equals=None, startswith='interface', endswith=None, contains=None, re_search=None), MatchRule(equals=None, startswith='switchport mode ', endswith=None, contains=None, re_search=None)), weight=-10), OrderingRule(match_rules=(MatchRule(equals=None, startswith='no vlan filter', endswith=None, contains=None, re_search=None),), weight=200), OrderingRule(match_rules=(MatchRule(equals=None, startswith='interface', endswith=None, contains=None, re_search=None), MatchRule(equals=None, startswith='no shutdown', endswith=None, contains=None, re_search=None)), weight=200), OrderingRule(match_rules=(MatchRule(equals=None, startswith='aaa group server tacacs+ ', endswith=None, contains=None, re_search=None), MatchRule(equals=None, startswith='no server ', endswith=None, contains=None, re_search=None)), weight=10), OrderingRule(match_rules=(MatchRule(equals=None, startswith='no tacacs-server ', endswith=None, contains=None, re_search=None),), weight=10), OrderingRule(match_rules=(MatchRule(equals=None, startswith='ntp', endswith=None, contains=None, re_search=None),), weight=700)] parent_allows_duplicate_child=[] per_line_sub=[PerLineSubRule(search='^Building configuration.*', replace=''), PerLineSubRule(search='^Current configuration.*', replace=''), PerLineSubRule(search='^! Last configuration change.*', replace=''), PerLineSubRule(search='^! NVRAM config last updated.*', replace=''), PerLineSubRule(search='^ntp clock-period .*', replace=''), PerLineSubRule(search='^version.*', replace=''), PerLineSubRule(search='^ logging event link-status$', replace=''), PerLineSubRule(search='^ logging event subif-link-status$', replace=''), PerLineSubRule(search='^\\s*ipv6 unreachables disable$', replace=''), PerLineSubRule(search='^end$', replace=''), PerLineSubRule(search='^\\s*[#!].*', replace=''), PerLineSubRule(search='^ no ip address', replace=''), PerLineSubRule(search='^ exit-peer-policy', replace=''), PerLineSubRule(search='^ exit-peer-session', replace=''), PerLineSubRule(search='^ exit-address-family', replace=''), PerLineSubRule(search='^crypto key generate rsa general-keys.*$', replace=''), PerLineSubRule(search='^!.*Generated.*$', replace='')] post_load_callbacks=[, , ] sectional_exiting=[SectionalExitingRule(match_rules=(MatchRule(equals=None, startswith='router bgp', endswith=None, contains=None, re_search=None), MatchRule(equals=None, startswith='template peer-policy', endswith=None, contains=None, re_search=None)), exit_text='exit-peer-policy'), SectionalExitingRule(match_rules=(MatchRule(equals=None, startswith='router bgp', endswith=None, contains=None, re_search=None), MatchRule(equals=None, startswith='template peer-session', endswith=None, contains=None, re_search=None)), exit_text='exit-peer-session'), SectionalExitingRule(match_rules=(MatchRule(equals=None, startswith='router bgp', endswith=None, contains=None, re_search=None), MatchRule(equals=None, startswith='address-family', endswith=None, contains=None, re_search=None)), exit_text='exit-address-family'), SectionalExitingRule(match_rules=(MatchRule(equals=None, startswith='router bgp', endswith=None, contains=None, re_search=None),), exit_text='exit')] sectional_overwrite=[] sectional_overwrite_no_negate=[] -``` - -**Example loading options from a file**: +**Example loading rules from a file**: ```python from hier_config import Platform -from hier_config.utils import load_hconfig_v2_options_from_file +from hier_config.utils import load_driver_rules platform = Platform.CISCO_IOS -driver = load_hconfig_v2_options("/path/to/options.yml", platform) +driver = load_driver_rules("/path/to/options.yml", platform) ``` -### load_hconfig_v2_tags +## load_tag_rules + **Description**: -Converts v2-style tags into a tuple of TagRule Pydantic objects compatible with Hier Config v3. +Loads tag rules from a list of dictionaries or a YAML file into TagRule objects. **Arguments**: - `v2_tags (List[Dict[str, Any]])`: A list of dictionaries representing v2-style tags. + `tags (List[Dict[str, Any]] | str)`: A list of dictionaries representing tag rules, or a file path to a YAML file. **Returns**: - `Tuple[TagRule, ...]`: A tuple of TagRule Pydantic objects. + `Tuple[TagRule, ...]`: A tuple of TagRule objects. **Example loading tags from a dictionary**: ```python -from hier_config.utils import load_hconfig_v2_tags +from hier_config.utils import load_tag_rules -v3_tags = load_hconfig_v2_tags([ +tags = load_tag_rules([ { "lineage": [{"startswith": ["ip name-server", "ntp"]}], "add_tags": "ntp" } ]) -print(v3_tags) # Output: (TagRule(match_rules=(MatchRule(equals=None, startswith=('ip name-server', 'ntp'), endswith=None, contains=None, re_search=None),), apply_tags=frozenset({'ntp'})),) +print(tags) ``` **Example loading tags from a file**: ```python -from hier_config.utils import load_hconfig_v2_tags_from_file +from hier_config.utils import load_tag_rules -v3_tags = load_hconfig_v2_tags("path/to/v2_tags.yml") +tags = load_tag_rules("path/to/tags.yml") -print(v3_tags) +print(tags) ``` \ No newline at end of file diff --git a/hier_config/__init__.py b/hier_config/__init__.py index 9f78fe2b..ec8d8e1c 100644 --- a/hier_config/__init__.py +++ b/hier_config/__init__.py @@ -6,20 +6,33 @@ get_hconfig_from_dump, get_hconfig_view, ) -from .models import ChangeDetail, MatchRule, Platform, ReportSummary, TagRule +from .exceptions import ( + DriverNotFoundError, + DuplicateChildError, + HierConfigError, + IncompatibleDriverError, + InvalidConfigError, +) +from .models import ChangeDetail, MatchRule, Platform, ReportSummary, TagRule, TextStyle from .reporting import RemediationReporter from .root import HConfig from .workflows import WorkflowRemediation __all__ = ( "ChangeDetail", + "DriverNotFoundError", + "DuplicateChildError", "HConfig", "HConfigChild", + "HierConfigError", + "IncompatibleDriverError", + "InvalidConfigError", "MatchRule", "Platform", "RemediationReporter", "ReportSummary", "TagRule", + "TextStyle", "WorkflowRemediation", "get_hconfig", "get_hconfig_driver", diff --git a/hier_config/base.py b/hier_config/base.py index 42ae20db..9ecb1257 100644 --- a/hier_config/base.py +++ b/hier_config/base.py @@ -26,7 +26,7 @@ class HConfigBase(ABC): # noqa: PLR0904 Both `HConfig` (the root) and `HConfigChild` (individual nodes) inherit from this class. It provides the shared tree-manipulation API: adding, searching, - and diffing children, as well as the `_future` / `_config_to_get_to` algorithms + and diffing children, as well as the `_future` / `_remediation` algorithms that power `WorkflowRemediation`. """ @@ -62,6 +62,7 @@ def driver(self) -> HConfigDriverBase: def lineage(self) -> Iterator[HConfigChild]: pass + @property @abstractmethod def depth(self) -> int: pass @@ -231,7 +232,7 @@ def add_shallow_copy_of( new_child.comments.update(child_to_add.comments) new_child.order_weight = child_to_add.order_weight if child_to_add.is_leaf: - new_child.tags_add(child_to_add.tags) + new_child.add_tags(child_to_add.tags) return new_child @@ -372,7 +373,7 @@ def _with_tags( return new_instance - def _config_to_get_to( + def _remediation( self, target: _HConfigRootOrChildT, delta: _HConfigRootOrChildT, @@ -382,8 +383,8 @@ def _config_to_get_to( target is the destination(i.e. generated_config). """ - self._config_to_get_to_left(target, delta) - self._config_to_get_to_right(target, delta) + self._remediation_left(target, delta) + self._remediation_right(target, delta) return delta @@ -441,7 +442,7 @@ def _difference( return delta - def _config_to_get_to_left( + def _remediation_left( self, target: HConfig | HConfigChild, delta: HConfig | HConfigChild, @@ -462,7 +463,7 @@ def _config_to_get_to_left( if self_child.children: negated.comments.add(f"removes {len(self_child.children) + 1} lines") - def _config_to_get_to_right( + def _remediation_right( self, target: HConfig | HConfigChild, delta: HConfig | HConfigChild, @@ -481,7 +482,7 @@ def _config_to_get_to_right( # This creates a new HConfigChild object just in case there are some delta children. # This is not very efficient, think of a way to not do this. subtree = delta.instantiate_child(target_child.text) - self_child._config_to_get_to(target_child, subtree) # noqa: SLF001 + self_child._remediation(target_child, subtree) # noqa: SLF001 if subtree.children: delta.children.append(subtree) # The child is absent, add it. diff --git a/hier_config/child.py b/hier_config/child.py index 33080fdd..513bd6ab 100644 --- a/hier_config/child.py +++ b/hier_config/child.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Any from .base import HConfigBase -from .models import Instance, MatchRule, SetLikeOfStr +from .models import Instance, MatchRule, SetLikeOfStr, TextStyle if TYPE_CHECKING: from collections.abc import Iterable, Iterator @@ -114,15 +114,13 @@ def root(self) -> HConfig: return self.parent.root def lines(self, *, sectional_exiting: bool = False) -> Iterable[str]: - yield self.cisco_style_text() + yield self.indented_text() for child in sorted(self.children): yield from child.lines(sectional_exiting=sectional_exiting) if sectional_exiting and (exit_text := self.sectional_exit): depth = ( - self.depth() - 1 - if self.sectional_exit_text_parent_level - else self.depth() + self.depth - 1 if self.sectional_exit_text_parent_level else self.depth ) yield " " * self.driver.rules.indentation * depth + exit_text @@ -147,9 +145,10 @@ def delete_sectional_exit(self) -> None: if (exit_text := self.sectional_exit) and exit_text == potential_exit.text: potential_exit.delete() + @property def depth(self) -> int: """Returns the distance to the root HConfig object i.e. indent level.""" - return self.parent.depth() + 1 + return self.parent.depth + 1 def move(self, new_parent: HConfig | HConfigChild) -> None: """Move one HConfigChild object to different HConfig parent object. @@ -179,12 +178,12 @@ def path(self) -> Iterator[str]: for child in self.lineage(): yield child.text - def cisco_style_text( + def indented_text( self, - style: str = "without_comments", + style: TextStyle = "without_comments", tag: str | None = None, ) -> str: - """Return a Cisco style formated line i.e. indentation_level + text ! comments.""" + """Return an indented text line i.e. indentation_level + text ! comments.""" comments: list[str] = [] if style == "without_comments": pass @@ -210,27 +209,27 @@ def cisco_style_text( @property def indentation(self) -> str: - return " " * self.driver.rules.indentation * (self.depth() - 1) + return " " * self.driver.rules.indentation * (self.depth - 1) def delete(self) -> None: """Delete the current object from its parent.""" self.parent.children.delete(self) - def tags_add(self, tag: str | Iterable[str]) -> None: + def add_tags(self, tag: str | Iterable[str]) -> None: """Add a tag to self._tags on all leaf nodes.""" if self.is_branch: for child in self.children: - child.tags_add(tag) + child.add_tags(tag) elif isinstance(tag, str): self._tags.add(tag) else: self._tags.update(tag) - def tags_remove(self, tag: str | Iterable[str]) -> None: + def remove_tags(self, tag: str | Iterable[str]) -> None: """Remove a tag from self._tags on all leaf nodes.""" if self.is_branch: for child in self.children: - child.tags_remove(tag) + child.remove_tags(tag) elif isinstance(tag, str): self._tags.remove(tag) else: @@ -348,7 +347,7 @@ def overwrite_with( comment is attached to the new entry, and a ``"dropping section"`` comment is added to the negated entry when applicable. - Used by :meth:`_config_to_get_to_right` when a sectional-overwrite + Used by :meth:`_remediation_right` when a sectional-overwrite rule is active for ``self.text``. """ if self.children != target.children: diff --git a/hier_config/constructors.py b/hier_config/constructors.py index 5c178fc0..4b9d38e0 100644 --- a/hier_config/constructors.py +++ b/hier_config/constructors.py @@ -7,6 +7,7 @@ from hier_config.platforms.driver_base import HConfigDriverBase from .child import HConfigChild +from .exceptions import DriverNotFoundError, InvalidConfigError from .models import Dump, Platform from .platforms.arista_eos.driver import HConfigDriverAristaEOS from .platforms.arista_eos.view import HConfigViewAristaEOS @@ -49,7 +50,7 @@ def get_hconfig_driver(platform: Platform) -> HConfigDriverBase: if driver_cls is None: message = f"Unsupported platform: {platform}" - raise ValueError(message) + raise DriverNotFoundError(message) return driver_cls() @@ -72,7 +73,7 @@ def get_hconfig_view(config: HConfig) -> HConfigViewBase: return HConfigViewHPProcurve(config) message = f"Unsupported platform: {config.driver.__class__.__name__}" - raise ValueError(message) + raise DriverNotFoundError(message) def get_hconfig( @@ -108,10 +109,10 @@ def get_hconfig_from_dump( if item.depth == 1: parent: HConfig | HConfigChild = config # has the same parent - elif last_item.depth() == item.depth: + elif last_item.depth == item.depth: parent = last_item.parent # is a child object - elif last_item.depth() + 1 == item.depth: + elif last_item.depth + 1 == item.depth: parent = last_item # has a parent somewhere closer to the root but not the root else: @@ -307,4 +308,4 @@ def _load_from_string_lines(config: HConfig, config_text: str) -> None: # noqa: end_indent_adjust.pop(0) if in_banner: message = "we are still in a banner for some reason" - raise ValueError(message) + raise InvalidConfigError(message) diff --git a/hier_config/exceptions.py b/hier_config/exceptions.py index 82d93df2..e4b4ccf2 100644 --- a/hier_config/exceptions.py +++ b/hier_config/exceptions.py @@ -1,2 +1,18 @@ -class DuplicateChildError(Exception): +class HierConfigError(Exception): + """Base exception for all hier_config errors.""" + + +class DuplicateChildError(HierConfigError): """Raised when attempting to add a duplicate child.""" + + +class DriverNotFoundError(HierConfigError): + """Raised when a platform driver cannot be found.""" + + +class InvalidConfigError(HierConfigError): + """Raised for malformed configuration text.""" + + +class IncompatibleDriverError(HierConfigError): + """Raised when configs with mismatched drivers are used together.""" diff --git a/hier_config/models.py b/hier_config/models.py index c69f08a7..cb5da59c 100644 --- a/hier_config/models.py +++ b/hier_config/models.py @@ -1,8 +1,11 @@ from enum import Enum, auto +from typing import Literal from pydantic import BaseModel as PydanticBaseModel from pydantic import ConfigDict, NonNegativeInt, PositiveInt +TextStyle = Literal["without_comments", "merged", "with_comments"] + class BaseModel(PydanticBaseModel): """Pydantic.BaseModel with a safe config applied.""" diff --git a/hier_config/reporting.py b/hier_config/reporting.py index f2036a9d..b9949a06 100644 --- a/hier_config/reporting.py +++ b/hier_config/reporting.py @@ -14,7 +14,7 @@ from typing import Any from hier_config.child import HConfigChild -from hier_config.models import ChangeDetail, ReportSummary, TagRule +from hier_config.models import ChangeDetail, ReportSummary, TagRule, TextStyle from hier_config.root import HConfig @@ -173,7 +173,7 @@ def apply_tag_rules(self, tag_rules: Sequence[TagRule]) -> None: """ for tag_rule in tag_rules: for child in self.merged_config.get_children_deep(tag_rule.match_rules): - child.tags_add(tag_rule.apply_tags) + child.add_tags(tag_rule.apply_tags) def get_all_changes( self, @@ -637,7 +637,7 @@ def to_text( self, file_path: str | Path, *, - style: str = "merged", + style: TextStyle = "merged", include_tags: Iterable[str] = (), exclude_tags: Iterable[str] = (), ) -> None: @@ -660,7 +660,7 @@ def to_text( exclude_tags=exclude_tags, ) - lines = [child.cisco_style_text(style=style) for child in changes] + lines = [child.indented_text(style=style) for child in changes] output_path = Path(file_path) output_path.write_text("\n".join(lines), encoding="utf-8") diff --git a/hier_config/root.py b/hier_config/root.py index b7d4d185..c9258282 100644 --- a/hier_config/root.py +++ b/hier_config/root.py @@ -33,7 +33,9 @@ def __str__(self) -> str: return "\n".join(str(c) for c in sorted(self.children)) def __repr__(self) -> str: - return f"HConfig(driver={self.driver.__class__.__name__}, lines={self.dump_simple()})" + return ( + f"HConfig(driver={self.driver.__class__.__name__}, lines={self.to_lines()})" + ) def __hash__(self) -> int: return hash(*self.children) @@ -116,7 +118,7 @@ def lines(self, *, sectional_exiting: bool = False) -> Iterable[str]: for child in sorted(self.children): yield from child.lines(sectional_exiting=sectional_exiting) - def dump_simple(self, *, sectional_exiting: bool = False) -> tuple[str, ...]: + def to_lines(self, *, sectional_exiting: bool = False) -> tuple[str, ...]: return tuple(self.lines(sectional_exiting=sectional_exiting)) def dump(self) -> Dump: @@ -124,7 +126,7 @@ def dump(self) -> Dump: return Dump( lines=tuple( DumpLine( - depth=c.depth(), + depth=c.depth, text=c.text, tags=frozenset(c.tags), comments=frozenset(c.comments), @@ -134,7 +136,8 @@ def dump(self) -> Dump: ), ) - def depth(self) -> int: # noqa: PLR6301 + @property + def depth(self) -> int: """Returns the distance to the root HConfig object i.e. indent level.""" return 0 @@ -142,7 +145,7 @@ def difference(self, target: HConfig) -> HConfig: """Creates a new HConfig object with the config from self that is not in target.""" return self._difference(target, HConfig(self.driver)) - def config_to_get_to( + def remediation( self, target: HConfig, delta: HConfig | None = None, @@ -154,7 +157,7 @@ def config_to_get_to( if delta is None: delta = HConfig(self.driver) - return self._config_to_get_to(target, delta) + return self._remediation(target, delta) def add_ancestor_copy_of( self, diff --git a/hier_config/utils.py b/hier_config/utils.py index e87bedff..76a8aff3 100644 --- a/hier_config/utils.py +++ b/hier_config/utils.py @@ -27,17 +27,6 @@ ) from hier_config.platforms.driver_base import HConfigDriverBase -HCONFIG_PLATFORM_V2_TO_V3_MAPPING = { - "ios": Platform.CISCO_IOS, - "iosxe": Platform.CISCO_IOS, - "iosxr": Platform.CISCO_XR, - "nxos": Platform.CISCO_NXOS, - "eos": Platform.ARISTA_EOS, - "junos": Platform.JUNIPER_JUNOS, - "vyos": Platform.VYOS, - "huawei_vrp": Platform.HUAWEI_VRP, -} - def _set_match_rule(lineage: dict[str, Any]) -> MatchRule | None: if startswith := lineage.get("startswith"): @@ -92,68 +81,28 @@ def load_hier_config_tags(tags_file: str) -> tuple[TagRule, ...]: return TypeAdapter(tuple[TagRule, ...]).validate_python(tags_data) -def hconfig_v2_os_v3_platform_mapper(os_name: str) -> Platform: - """Map a Hier Config v2 operating system name to a v3 Platform enumeration. - - Args: - os_name (str): The name of the OS as defined in Hier Config v2. - - Returns: - Platform: The corresponding Platform enumeration for Hier Config v3. - - Example: - >>> hconfig_v2_os_v3_platform_mapper("CISCO_IOS") - - - """ - return HCONFIG_PLATFORM_V2_TO_V3_MAPPING.get(os_name, Platform.GENERIC) - - -def hconfig_v3_platform_v2_os_mapper(platform: Platform) -> str: - """Map a Hier Config v3 Platform enumeration to a v2 operating system name. - - Args: - platform (Platform): A Platform enumeration from Hier Config v3. - - Returns: - str: The corresponding OS name for Hier Config v2. - - Example: - >>> hconfig_v3_platform_v2_os_mapper(Platform.CISCO_IOS) - "ios" - - """ - for os_name, plat in HCONFIG_PLATFORM_V2_TO_V3_MAPPING.items(): - if plat == platform: - return os_name - - return "generic" - - def _process_simple_rules( - v2_options: dict[str, Any], + 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, ()): + """Process rules that only need match_rules.""" + for rule in options.get(key, ()): match_rules = _collect_match_rules(rule.get("lineage", [])) append_to(rule_class(match_rules=match_rules)) -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", ()): +def _process_custom_rules(options: dict[str, Any], driver: HConfigDriverBase) -> None: + """Process rules that require custom handling.""" + for rule in options.get("ordering", ()): 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), ) - for rule in v2_options.get("indent_adjust", ()): + for rule in options.get("indent_adjust", ()): driver.rules.indent_adjust.append( IndentAdjustRule( start_expression=rule.get("start_expression"), @@ -161,7 +110,7 @@ def _process_custom_rules( ) ) - for rule in v2_options.get("sectional_exiting", ()): + for rule in options.get("sectional_exiting", ()): match_rules = _collect_match_rules(rule.get("lineage", [])) driver.rules.sectional_exiting.append( SectionalExitingRule( @@ -169,27 +118,27 @@ def _process_custom_rules( ), ) - for rule in v2_options.get("full_text_sub", ()): + for rule in options.get("full_text_sub", ()): driver.rules.full_text_sub.append( FullTextSubRule( search=rule.get("search", ""), replace=rule.get("replace", "") ) ) - for rule in v2_options.get("per_line_sub", ()): + for rule in options.get("per_line_sub", ()): driver.rules.per_line_sub.append( PerLineSubRule( search=rule.get("search", ""), replace=rule.get("replace", "") ) ) - for rule in v2_options.get("negation_negate_with", ()): + for rule in 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", "")), ) - for rule in v2_options.get("negation_sub", ()): + for rule in options.get("negation_sub", ()): match_rules = _collect_match_rules(rule.get("lineage", [])) driver.rules.negation_sub.append( NegationSubRule( @@ -199,7 +148,7 @@ def _process_custom_rules( ), ) - for rule in v2_options.get("unused_objects", ()): + for rule in options.get("unused_objects", ()): match_rules = _collect_match_rules(rule.get("lineage", [])) ref_locations = tuple( ReferenceLocation( @@ -217,25 +166,25 @@ def _process_custom_rules( ) -def load_hconfig_v2_options( - v2_options: dict[str, Any] | str, platform: Platform +def load_driver_rules( + options: dict[str, Any] | str, platform: Platform ) -> HConfigDriverBase: - """Load Hier Config v2 options to v3 driver format from either a dictionary or a file. + """Load driver rules from a dictionary or YAML 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. + options: Either a dictionary containing driver rule options or + a file path to a YAML file containing the options. + platform: The Platform enum for the target platform. Returns: - HConfigDriverBase: A v3 driver instance with the migrated rules. + HConfigDriverBase: A driver instance with the loaded rules. """ - if isinstance(v2_options, str): - v2_options = yaml.safe_load(read_text_from_file(file_path=v2_options)) + if isinstance(options, str): + options = yaml.safe_load(read_text_from_file(file_path=options)) - if not isinstance(v2_options, dict): - msg = "v2_options must be a dictionary or a valid file path." + if not isinstance(options, dict): + msg = "options must be a dictionary or a valid file path." raise TypeError(msg) driver = get_hconfig_driver(platform) @@ -274,75 +223,47 @@ def load_hconfig_v2_options( ), ) for key, rule_class, append_to in simple_rules: - _process_simple_rules(v2_options, key, rule_class, append_to) + _process_simple_rules(options, key, rule_class, append_to) # Process rules that require custom handling - _process_custom_rules(v2_options, driver) + _process_custom_rules(options, driver) return driver -def load_hconfig_v2_options_from_file( - options_file: str, platform: Platform -) -> HConfigDriverBase: - """Load Hier Config v2 options file to v3 driver format. - - Args: - options_file (str): The v2 options file. - platform (Platform): The Hier Config v3 Platform enum for the target platform. - - Returns: - HConfigDriverBase: A v3 driver instance with the migrated rules. - - """ - hconfig_options = yaml.safe_load(read_text_from_file(file_path=options_file)) - return load_hconfig_v2_options(v2_options=hconfig_options, platform=platform) - - -def load_hconfig_v2_tags( - v2_tags: list[dict[str, Any]] | str, -) -> tuple["TagRule"] | tuple["TagRule", ...]: - """Convert v2-style tags into v3-style TagRule Pydantic objects for Hier Config. +def load_tag_rules( + tags: list[dict[str, Any]] | str, +) -> tuple[TagRule, ...]: + """Load tag rules from a list of dictionaries or a YAML file. Args: - v2_tags (Union[list[dict[str, Any]], str]): - Either a list of dictionaries representing v2-style tags or a file path - to a YAML file containing the v2-style tags. - - If a list is provided, each dictionary should contain: + tags: Either a list of dictionaries or a file path to a YAML file. + Each dictionary should contain: - `lineage`: A list of dictionaries with rules (e.g., `startswith`, `endswith`). - `add_tags`: A string representing the tag to add. - - If a file path is provided, it will be read and parsed as YAML. Returns: - Tuple[TagRule]: A tuple of TagRule Pydantic objects representing v3-style tags. + A tuple of TagRule objects. """ - # Load tags from a file if a string is provided - if isinstance(v2_tags, str): - v2_tags = yaml.safe_load(read_text_from_file(file_path=v2_tags)) + if isinstance(tags, str): + tags = yaml.safe_load(read_text_from_file(file_path=tags)) - # Ensure v2_tags is a list - if not isinstance(v2_tags, list): - msg = "v2_tags must be a list of dictionaries or a valid file path." + if not isinstance(tags, list): + msg = "tags must be a list of dictionaries or a valid file path." raise TypeError(msg) - v3_tags: list[TagRule] = [] + result: list[TagRule] = [] - for v2_tag in v2_tags: - if "lineage" in v2_tag and "add_tags" in v2_tag: - # Extract the v2 fields - lineage_rules = v2_tag["lineage"] - tags = v2_tag["add_tags"] + for tag in tags: + if "lineage" in tag and "add_tags" in tag: + lineage_rules = tag["lineage"] + tag_name = tag["add_tags"] - # Convert to MatchRule objects - match_rules = tuple( - match_rule - for lineage in lineage_rules - if (match_rule := _set_match_rule(lineage)) is not None - ) + match_rules = _collect_match_rules(lineage_rules) - # Create the TagRule object - v3_tag = TagRule(match_rules=match_rules, apply_tags=frozenset([tags])) - v3_tags.append(v3_tag) + result.append( + TagRule(match_rules=match_rules, apply_tags=frozenset([tag_name])) + ) - return tuple(v3_tags) + return tuple(result) diff --git a/hier_config/workflows.py b/hier_config/workflows.py index fe24130b..7af8a9d1 100644 --- a/hier_config/workflows.py +++ b/hier_config/workflows.py @@ -1,6 +1,7 @@ from collections.abc import Iterable from logging import getLogger +from .exceptions import IncompatibleDriverError from .models import TagRule from .root import HConfig @@ -38,13 +39,13 @@ class WorkflowRemediation: remediation_config = workflow.remediation_config print("Remediation configuration:") for line in remediation_config.all_children_sorted(): - print(line.cisco_style_text()) + print(line.indented_text()) # Generate the rollback configuration to revert back to the running configuration rollback_config = workflow.rollback_config print("Rollback configuration:") for line in rollback_config.all_children_sorted(): - print(line.cisco_style_text()) + print(line.indented_text()) ``` """ @@ -59,7 +60,7 @@ def __init__( if running_config.driver.__class__ is not generated_config.driver.__class__: message = "The running and generated configs must use the same driver." - raise ValueError(message) + raise IncompatibleDriverError(message) self._remediation_config: HConfig | None = None self._rollback_config: HConfig | None = None @@ -79,7 +80,7 @@ def remediation_config(self) -> HConfig: if self._remediation_config: return self._remediation_config - remediation_config = self.running_config.config_to_get_to( + remediation_config = self.running_config.remediation( self.generated_config ).set_order_weight() @@ -102,7 +103,7 @@ def rollback_config(self) -> HConfig: if self._rollback_config: return self._rollback_config - rollback_config = self.generated_config.config_to_get_to( + rollback_config = self.generated_config.remediation( self.running_config, HConfig(self.running_config.driver) ).set_order_weight() @@ -125,7 +126,7 @@ def apply_remediation_tag_rules(self, tag_rules: tuple[TagRule, ...]) -> None: for child in self.remediation_config.get_children_deep( tag_rule.match_rules ): - child.tags_add(tag_rule.apply_tags) + child.add_tags(tag_rule.apply_tags) def remediation_config_filtered_text( self, @@ -153,4 +154,4 @@ def remediation_config_filtered_text( if include_tags or exclude_tags else self.remediation_config.all_children_sorted() ) - return "\n".join(c.cisco_style_text() for c in children) + return "\n".join(c.indented_text() for c in children) diff --git a/tests/config_view/__init__.py b/tests/benchmarks/__init__.py similarity index 100% rename from tests/config_view/__init__.py rename to tests/benchmarks/__init__.py diff --git a/tests/test_benchmarks.py b/tests/benchmarks/test_benchmarks.py similarity index 82% rename from tests/test_benchmarks.py rename to tests/benchmarks/test_benchmarks.py index 076b9657..480f562e 100644 --- a/tests/test_benchmarks.py +++ b/tests/benchmarks/test_benchmarks.py @@ -54,8 +54,10 @@ def _generate_large_ios_config(num_interfaces: int = 1000) -> str: " auto-cost reference-bandwidth 100000", ] ) - for i in range(num_interfaces): - lines.append(f" network 10.{i // 256}.{i % 256}.0 0.0.0.3 area 0") + lines.extend( + f" network 10.{i // 256}.{i % 256}.0 0.0.0.3 area 0" + for i in range(num_interfaces) + ) lines.extend( [ "!", @@ -125,8 +127,10 @@ def _generate_large_xr_config(num_interfaces: int = 1000) -> str: " router-id 10.0.0.1", ] ) - for i in range(num_interfaces): - lines.append(f" area 0 interface GigabitEthernet0/0/0/{i} cost 100") + lines.extend( + f" area 0 interface GigabitEthernet0/0/0/{i} cost 100" + for i in range(num_interfaces) + ) lines.append("!") return "\n".join(lines) @@ -145,33 +149,37 @@ def _time_fn(fn: Callable[[], object], iterations: int = 3) -> float: class TestParsingBenchmarks: """Benchmarks for config parsing.""" - def test_parse_large_ios_config(self) -> None: + @staticmethod + def test_parse_large_ios_config() -> None: """Parse a ~10k line IOS config via get_hconfig.""" config_text = _generate_large_ios_config() elapsed = _time_fn(lambda: get_hconfig(Platform.CISCO_IOS, config_text)) line_count = config_text.count("\n") - print(f"\nget_hconfig: {line_count} lines in {elapsed:.4f}s") + print(f"\nget_hconfig: {line_count} lines in {elapsed:.4f}s") # noqa: T201 assert elapsed < 5.0, f"Parsing took {elapsed:.2f}s, expected < 5s" - def test_parse_large_xr_config(self) -> None: + @staticmethod + def test_parse_large_xr_config() -> None: """Parse a ~10k line XR config via get_hconfig.""" config_text = _generate_large_xr_config() elapsed = _time_fn(lambda: get_hconfig(Platform.CISCO_XR, config_text)) line_count = config_text.count("\n") - print(f"\nget_hconfig (XR): {line_count} lines in {elapsed:.4f}s") + print(f"\nget_hconfig (XR): {line_count} lines in {elapsed:.4f}s") # noqa: T201 assert elapsed < 5.0, f"Parsing took {elapsed:.2f}s, expected < 5s" - def test_fast_load_large_ios_config(self) -> None: + @staticmethod + def test_fast_load_large_ios_config() -> None: """Parse a ~10k line IOS config via get_hconfig_fast_load.""" config_text = _generate_large_ios_config() config_lines = tuple(config_text.splitlines()) elapsed = _time_fn( lambda: get_hconfig_fast_load(Platform.CISCO_IOS, config_lines), ) - print(f"\nget_hconfig_fast_load: {len(config_lines)} lines in {elapsed:.4f}s") + print(f"\nget_hconfig_fast_load: {len(config_lines)} lines in {elapsed:.4f}s") # noqa: T201 assert elapsed < 5.0, f"Fast load took {elapsed:.2f}s, expected < 5s" - def test_fast_load_vs_get_hconfig(self) -> None: + @staticmethod + def test_fast_load_vs_get_hconfig() -> None: """get_hconfig_fast_load should be faster than get_hconfig.""" config_text = _generate_large_ios_config() config_lines = tuple(config_text.splitlines()) @@ -181,7 +189,7 @@ def test_fast_load_vs_get_hconfig(self) -> None: lambda: get_hconfig_fast_load(Platform.CISCO_IOS, config_lines), ) ratio = time_full / time_fast if time_fast > 0 else float("inf") - print( + print( # noqa: T201 f"\nget_hconfig: {time_full:.4f}s, " f"fast_load: {time_fast:.4f}s, " f"ratio: {ratio:.1f}x" @@ -193,9 +201,10 @@ def test_fast_load_vs_get_hconfig(self) -> None: class TestRemediationBenchmarks: - """Benchmarks for config_to_get_to remediation.""" + """Benchmarks for remediation remediation.""" - def test_remediation_small_diff(self) -> None: + @staticmethod + def test_remediation_small_diff() -> None: """Remediation with ~5% of interfaces changed.""" running_text = _generate_large_ios_config() running = get_hconfig(Platform.CISCO_IOS, running_text) @@ -206,11 +215,12 @@ def test_remediation_small_diff(self) -> None: ) generated = get_hconfig(Platform.CISCO_IOS, generated_text) - elapsed = _time_fn(lambda: running.config_to_get_to(generated)) - print(f"\nRemediation (10% diff): {elapsed:.4f}s") + elapsed = _time_fn(lambda: running.remediation(generated)) + print(f"\nRemediation (10% diff): {elapsed:.4f}s") # noqa: T201 assert elapsed < 5.0, f"Remediation took {elapsed:.2f}s, expected < 5s" - def test_remediation_large_diff(self) -> None: + @staticmethod + def test_remediation_large_diff() -> None: """Remediation with ~100% of interfaces changed.""" running = get_hconfig(Platform.CISCO_IOS, _generate_large_ios_config()) generated_text = _generate_large_ios_config().replace( @@ -218,11 +228,12 @@ def test_remediation_large_diff(self) -> None: ) generated = get_hconfig(Platform.CISCO_IOS, generated_text) - elapsed = _time_fn(lambda: running.config_to_get_to(generated)) - print(f"\nRemediation (100% diff): {elapsed:.4f}s") + elapsed = _time_fn(lambda: running.remediation(generated)) + print(f"\nRemediation (100% diff): {elapsed:.4f}s") # noqa: T201 assert elapsed < 10.0, f"Remediation took {elapsed:.2f}s, expected < 10s" - def test_remediation_completely_different(self) -> None: + @staticmethod + def test_remediation_completely_different() -> None: """Remediation between two entirely different configs.""" running = get_hconfig(Platform.CISCO_IOS, _generate_large_ios_config(500)) # Generate a completely different config @@ -237,36 +248,39 @@ def test_remediation_completely_different(self) -> None: ) generated = get_hconfig(Platform.CISCO_IOS, "\n".join(lines)) - elapsed = _time_fn(lambda: running.config_to_get_to(generated)) - print(f"\nRemediation (completely different): {elapsed:.4f}s") + elapsed = _time_fn(lambda: running.remediation(generated)) + print(f"\nRemediation (completely different): {elapsed:.4f}s") # noqa: T201 assert elapsed < 10.0, f"Remediation took {elapsed:.2f}s, expected < 10s" class TestIterationBenchmarks: """Benchmarks for tree traversal and iteration.""" - def test_all_children_sorted(self) -> None: + @staticmethod + def test_all_children_sorted() -> None: """Iterate all_children_sorted on a large config.""" config = get_hconfig(Platform.CISCO_IOS, _generate_large_ios_config()) elapsed = _time_fn(lambda: list(config.all_children_sorted())) child_count = len(list(config.all_children())) - print(f"\nall_children_sorted: {child_count} nodes in {elapsed:.4f}s") + print(f"\nall_children_sorted: {child_count} nodes in {elapsed:.4f}s") # noqa: T201 assert elapsed < 2.0, f"Iteration took {elapsed:.2f}s, expected < 2s" - def test_dump_simple(self) -> None: + @staticmethod + def test_to_lines() -> None: """Dump a large config to simple text.""" config = get_hconfig(Platform.CISCO_IOS, _generate_large_ios_config()) - elapsed = _time_fn(config.dump_simple) - line_count = len(config.dump_simple()) - print(f"\ndump_simple: {line_count} lines in {elapsed:.4f}s") - assert elapsed < 2.0, f"dump_simple took {elapsed:.2f}s, expected < 2s" + elapsed = _time_fn(config.to_lines) + line_count = len(config.to_lines()) + print(f"\nto_lines: {line_count} lines in {elapsed:.4f}s") # noqa: T201 + assert elapsed < 2.0, f"to_lines took {elapsed:.2f}s, expected < 2s" - def test_deep_copy(self) -> None: + @staticmethod + def test_deep_copy() -> None: """Deep copy a large config tree.""" config = get_hconfig(Platform.CISCO_IOS, _generate_large_ios_config()) elapsed = _time_fn(config.deep_copy) - print(f"\ndeep_copy: {elapsed:.4f}s") + print(f"\ndeep_copy: {elapsed:.4f}s") # noqa: T201 assert elapsed < 5.0, f"deep_copy took {elapsed:.2f}s, expected < 5s" diff --git a/tests/circular/__init__.py b/tests/circular/__init__.py deleted file mode 100644 index 8162f58a..00000000 --- a/tests/circular/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Circular config workflow tests.""" diff --git a/tests/conftest.py b/tests/conftest.py index 27ddc70c..b71ad1e0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,4 @@ from pathlib import Path -from typing import Any import pytest import yaml @@ -80,45 +79,6 @@ def tags_file_path() -> str: return "./tests/fixtures/tag_rules_ios.yml" -@pytest.fixture(scope="module") -def v2_options() -> dict[str, Any]: - return { - "negation": "no", - "sectional_overwrite": [{"lineage": [{"startswith": "template"}]}], - "sectional_overwrite_no_negate": [{"lineage": [{"startswith": "as-path-set"}]}], - "ordering": [{"lineage": [{"startswith": "ntp"}], "order": 700}], - "indent_adjust": [ - {"start_expression": "^\\s*template", "end_expression": "^\\s*end-template"} - ], - "parent_allows_duplicate_child": [ - {"lineage": [{"startswith": "route-policy"}]} - ], - "sectional_exiting": [ - {"lineage": [{"startswith": "router bgp"}], "exit_text": "exit"} - ], - "full_text_sub": [{"search": "banner motd # replace me #", "replace": ""}], - "per_line_sub": [{"search": "^!.*Generated.*$", "replace": ""}], - "idempotent_commands_blacklist": [ - { - "lineage": [ - {"startswith": "interface"}, - {"re_search": "ip address.*secondary"}, - ] - } - ], - "idempotent_commands": [{"lineage": [{"startswith": "interface"}]}], - "negation_negate_with": [ - { - "lineage": [ - {"startswith": "interface Ethernet"}, - {"startswith": "spanning-tree port type"}, - ], - "use": "no spanning-tree port type", - } - ], - } - - def _fixture_file_read(filename: str) -> str: return str( Path(__file__) diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/circular/conftest.py b/tests/integration/conftest.py similarity index 100% rename from tests/circular/conftest.py rename to tests/integration/conftest.py diff --git a/tests/circular/fixtures/comware5_generated.conf b/tests/integration/fixtures/comware5_generated.conf similarity index 100% rename from tests/circular/fixtures/comware5_generated.conf rename to tests/integration/fixtures/comware5_generated.conf diff --git a/tests/circular/fixtures/comware5_remediation.conf b/tests/integration/fixtures/comware5_remediation.conf similarity index 100% rename from tests/circular/fixtures/comware5_remediation.conf rename to tests/integration/fixtures/comware5_remediation.conf diff --git a/tests/circular/fixtures/comware5_rollback.conf b/tests/integration/fixtures/comware5_rollback.conf similarity index 100% rename from tests/circular/fixtures/comware5_rollback.conf rename to tests/integration/fixtures/comware5_rollback.conf diff --git a/tests/circular/fixtures/comware5_running.conf b/tests/integration/fixtures/comware5_running.conf similarity index 100% rename from tests/circular/fixtures/comware5_running.conf rename to tests/integration/fixtures/comware5_running.conf diff --git a/tests/circular/fixtures/eos_generated.conf b/tests/integration/fixtures/eos_generated.conf similarity index 100% rename from tests/circular/fixtures/eos_generated.conf rename to tests/integration/fixtures/eos_generated.conf diff --git a/tests/circular/fixtures/eos_remediation.conf b/tests/integration/fixtures/eos_remediation.conf similarity index 100% rename from tests/circular/fixtures/eos_remediation.conf rename to tests/integration/fixtures/eos_remediation.conf diff --git a/tests/circular/fixtures/eos_rollback.conf b/tests/integration/fixtures/eos_rollback.conf similarity index 100% rename from tests/circular/fixtures/eos_rollback.conf rename to tests/integration/fixtures/eos_rollback.conf diff --git a/tests/circular/fixtures/eos_running.conf b/tests/integration/fixtures/eos_running.conf similarity index 100% rename from tests/circular/fixtures/eos_running.conf rename to tests/integration/fixtures/eos_running.conf diff --git a/tests/circular/fixtures/fortios_generated.conf b/tests/integration/fixtures/fortios_generated.conf similarity index 100% rename from tests/circular/fixtures/fortios_generated.conf rename to tests/integration/fixtures/fortios_generated.conf diff --git a/tests/circular/fixtures/fortios_remediation.conf b/tests/integration/fixtures/fortios_remediation.conf similarity index 100% rename from tests/circular/fixtures/fortios_remediation.conf rename to tests/integration/fixtures/fortios_remediation.conf diff --git a/tests/circular/fixtures/fortios_rollback.conf b/tests/integration/fixtures/fortios_rollback.conf similarity index 100% rename from tests/circular/fixtures/fortios_rollback.conf rename to tests/integration/fixtures/fortios_rollback.conf diff --git a/tests/circular/fixtures/fortios_running.conf b/tests/integration/fixtures/fortios_running.conf similarity index 100% rename from tests/circular/fixtures/fortios_running.conf rename to tests/integration/fixtures/fortios_running.conf diff --git a/tests/circular/fixtures/ios_generated.conf b/tests/integration/fixtures/ios_generated.conf similarity index 100% rename from tests/circular/fixtures/ios_generated.conf rename to tests/integration/fixtures/ios_generated.conf diff --git a/tests/circular/fixtures/ios_remediation.conf b/tests/integration/fixtures/ios_remediation.conf similarity index 100% rename from tests/circular/fixtures/ios_remediation.conf rename to tests/integration/fixtures/ios_remediation.conf diff --git a/tests/circular/fixtures/ios_rollback.conf b/tests/integration/fixtures/ios_rollback.conf similarity index 100% rename from tests/circular/fixtures/ios_rollback.conf rename to tests/integration/fixtures/ios_rollback.conf diff --git a/tests/circular/fixtures/ios_running.conf b/tests/integration/fixtures/ios_running.conf similarity index 100% rename from tests/circular/fixtures/ios_running.conf rename to tests/integration/fixtures/ios_running.conf diff --git a/tests/circular/fixtures/iosxr_generated.conf b/tests/integration/fixtures/iosxr_generated.conf similarity index 100% rename from tests/circular/fixtures/iosxr_generated.conf rename to tests/integration/fixtures/iosxr_generated.conf diff --git a/tests/circular/fixtures/iosxr_remediation.conf b/tests/integration/fixtures/iosxr_remediation.conf similarity index 100% rename from tests/circular/fixtures/iosxr_remediation.conf rename to tests/integration/fixtures/iosxr_remediation.conf diff --git a/tests/circular/fixtures/iosxr_rollback.conf b/tests/integration/fixtures/iosxr_rollback.conf similarity index 100% rename from tests/circular/fixtures/iosxr_rollback.conf rename to tests/integration/fixtures/iosxr_rollback.conf diff --git a/tests/circular/fixtures/iosxr_running.conf b/tests/integration/fixtures/iosxr_running.conf similarity index 100% rename from tests/circular/fixtures/iosxr_running.conf rename to tests/integration/fixtures/iosxr_running.conf diff --git a/tests/circular/fixtures/junos_generated.conf b/tests/integration/fixtures/junos_generated.conf similarity index 100% rename from tests/circular/fixtures/junos_generated.conf rename to tests/integration/fixtures/junos_generated.conf diff --git a/tests/circular/fixtures/junos_remediation.conf b/tests/integration/fixtures/junos_remediation.conf similarity index 100% rename from tests/circular/fixtures/junos_remediation.conf rename to tests/integration/fixtures/junos_remediation.conf diff --git a/tests/circular/fixtures/junos_rollback.conf b/tests/integration/fixtures/junos_rollback.conf similarity index 100% rename from tests/circular/fixtures/junos_rollback.conf rename to tests/integration/fixtures/junos_rollback.conf diff --git a/tests/circular/fixtures/junos_running.conf b/tests/integration/fixtures/junos_running.conf similarity index 100% rename from tests/circular/fixtures/junos_running.conf rename to tests/integration/fixtures/junos_running.conf diff --git a/tests/circular/fixtures/nxos_generated.conf b/tests/integration/fixtures/nxos_generated.conf similarity index 100% rename from tests/circular/fixtures/nxos_generated.conf rename to tests/integration/fixtures/nxos_generated.conf diff --git a/tests/circular/fixtures/nxos_remediation.conf b/tests/integration/fixtures/nxos_remediation.conf similarity index 100% rename from tests/circular/fixtures/nxos_remediation.conf rename to tests/integration/fixtures/nxos_remediation.conf diff --git a/tests/circular/fixtures/nxos_rollback.conf b/tests/integration/fixtures/nxos_rollback.conf similarity index 100% rename from tests/circular/fixtures/nxos_rollback.conf rename to tests/integration/fixtures/nxos_rollback.conf diff --git a/tests/circular/fixtures/nxos_running.conf b/tests/integration/fixtures/nxos_running.conf similarity index 100% rename from tests/circular/fixtures/nxos_running.conf rename to tests/integration/fixtures/nxos_running.conf diff --git a/tests/circular/fixtures/procurve_generated.conf b/tests/integration/fixtures/procurve_generated.conf similarity index 100% rename from tests/circular/fixtures/procurve_generated.conf rename to tests/integration/fixtures/procurve_generated.conf diff --git a/tests/circular/fixtures/procurve_remediation.conf b/tests/integration/fixtures/procurve_remediation.conf similarity index 100% rename from tests/circular/fixtures/procurve_remediation.conf rename to tests/integration/fixtures/procurve_remediation.conf diff --git a/tests/circular/fixtures/procurve_rollback.conf b/tests/integration/fixtures/procurve_rollback.conf similarity index 100% rename from tests/circular/fixtures/procurve_rollback.conf rename to tests/integration/fixtures/procurve_rollback.conf diff --git a/tests/circular/fixtures/procurve_running.conf b/tests/integration/fixtures/procurve_running.conf similarity index 100% rename from tests/circular/fixtures/procurve_running.conf rename to tests/integration/fixtures/procurve_running.conf diff --git a/tests/circular/fixtures/vyos_generated.conf b/tests/integration/fixtures/vyos_generated.conf similarity index 100% rename from tests/circular/fixtures/vyos_generated.conf rename to tests/integration/fixtures/vyos_generated.conf diff --git a/tests/circular/fixtures/vyos_remediation.conf b/tests/integration/fixtures/vyos_remediation.conf similarity index 100% rename from tests/circular/fixtures/vyos_remediation.conf rename to tests/integration/fixtures/vyos_remediation.conf diff --git a/tests/circular/fixtures/vyos_rollback.conf b/tests/integration/fixtures/vyos_rollback.conf similarity index 100% rename from tests/circular/fixtures/vyos_rollback.conf rename to tests/integration/fixtures/vyos_rollback.conf diff --git a/tests/circular/fixtures/vyos_running.conf b/tests/integration/fixtures/vyos_running.conf similarity index 100% rename from tests/circular/fixtures/vyos_running.conf rename to tests/integration/fixtures/vyos_running.conf diff --git a/tests/circular/test_config_workflows.py b/tests/integration/test_circular_workflows.py similarity index 89% rename from tests/circular/test_config_workflows.py rename to tests/integration/test_circular_workflows.py index 1a1a9ab1..644dc616 100644 --- a/tests/circular/test_config_workflows.py +++ b/tests/integration/test_circular_workflows.py @@ -70,7 +70,7 @@ def test_circular_workflow( # pylint: disable=too-many-locals # noqa: PLR0914, assert running_config is not None assert running_config.children loaded_running_text = "\n".join( - line.cisco_style_text() for line in running_config.all_children_sorted() + line.indented_text() for line in running_config.all_children_sorted() ) assert loaded_running_text.strip() == running_config_text.strip(), ( "Loaded running config does not match the file" @@ -81,7 +81,7 @@ def test_circular_workflow( # pylint: disable=too-many-locals # noqa: PLR0914, assert generated_config is not None assert generated_config.children loaded_generated_text = "\n".join( - line.cisco_style_text() for line in generated_config.all_children_sorted() + line.indented_text() for line in generated_config.all_children_sorted() ) assert loaded_generated_text.strip() == generated_config_text.strip(), ( "Loaded generated config does not match the file" @@ -94,7 +94,7 @@ def test_circular_workflow( # pylint: disable=too-many-locals # noqa: PLR0914, remediation_config = workflow.remediation_config assert remediation_config is not None remediation_text = "\n".join( - line.cisco_style_text() for line in remediation_config.all_children_sorted() + line.indented_text() for line in remediation_config.all_children_sorted() ) assert remediation_text.strip() == expected_remediation_text.strip(), ( "Generated remediation config does not match expected" @@ -110,14 +110,14 @@ def test_circular_workflow( # pylint: disable=too-many-locals # noqa: PLR0914, # remove deleted sections, so we verify that all generated lines are present (subset check) # rather than exact equality future_lines = { - line.cisco_style_text() + line.indented_text() for line in future_config.all_children_sorted() - if not line.cisco_style_text().strip().startswith(("no ", "delete ")) + if not line.indented_text().strip().startswith(("no ", "delete ")) } generated_lines = { - line.cisco_style_text() + line.indented_text() for line in generated_config.all_children_sorted() - if not line.cisco_style_text().strip().startswith(("no ", "delete ")) + if not line.indented_text().strip().startswith(("no ", "delete ")) } # Check that all generated lines are present in future (subset check) missing_lines = generated_lines - future_lines @@ -130,7 +130,7 @@ def test_circular_workflow( # pylint: disable=too-many-locals # noqa: PLR0914, rollback_config = workflow.rollback_config assert rollback_config is not None rollback_text = "\n".join( - line.cisco_style_text() for line in rollback_config.all_children_sorted() + line.indented_text() for line in rollback_config.all_children_sorted() ) assert rollback_text.strip() == expected_rollback_text.strip(), ( "Generated rollback config does not match expected" @@ -146,14 +146,14 @@ def test_circular_workflow( # pylint: disable=too-many-locals # noqa: PLR0914, # remove deleted sections, so we verify that all running lines are present (subset check) # rather than exact equality rollback_future_lines = { - line.cisco_style_text() + line.indented_text() for line in rollback_future_config.all_children_sorted() - if not line.cisco_style_text().strip().startswith(("no ", "delete ")) + if not line.indented_text().strip().startswith(("no ", "delete ")) } running_lines = { - line.cisco_style_text() + line.indented_text() for line in running_config.all_children_sorted() - if not line.cisco_style_text().strip().startswith(("no ", "delete ")) + if not line.indented_text().strip().startswith(("no ", "delete ")) } # Check that all running lines are present in rollback_future (subset check) missing_lines = running_lines - rollback_future_lines diff --git a/tests/test_driver_cisco_ios.py b/tests/integration/test_cisco_ios.py similarity index 51% rename from tests/test_driver_cisco_ios.py rename to tests/integration/test_cisco_ios.py index aba54622..55b8560b 100644 --- a/tests/test_driver_cisco_ios.py +++ b/tests/integration/test_cisco_ios.py @@ -7,12 +7,12 @@ def test_logging_console_emergencies_scenario_1() -> None: platform = Platform.CISCO_IOS running_config = get_hconfig_fast_load(platform, ("no logging console",)) generated_config = get_hconfig_fast_load(platform, ("logging console emergencies",)) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ("logging console emergencies",) + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ("logging console emergencies",) future_config = running_config.future(remediation_config) - assert future_config.dump_simple() == ("logging console emergencies",) - rollback = future_config.config_to_get_to(running_config) - assert rollback.dump_simple() == ("no logging console",) + assert future_config.to_lines() == ("logging console emergencies",) + rollback = future_config.remediation(running_config) + assert rollback.to_lines() == ("no logging console",) running_after_rollback = future_config.future(rollback) assert not tuple(running_config.unified_diff(running_after_rollback)) @@ -22,12 +22,12 @@ def test_logging_console_emergencies_scenario_2() -> None: platform = Platform.CISCO_IOS running_config = get_hconfig_fast_load(platform, ("logging console",)) generated_config = get_hconfig_fast_load(platform, ("logging console emergencies",)) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ("logging console emergencies",) + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ("logging console emergencies",) future_config = running_config.future(remediation_config) - assert future_config.dump_simple() == ("logging console emergencies",) - rollback = future_config.config_to_get_to(running_config) - assert rollback.dump_simple() == ("logging console",) + assert future_config.to_lines() == ("logging console emergencies",) + rollback = future_config.remediation(running_config) + assert rollback.to_lines() == ("logging console",) running_after_rollback = future_config.future(rollback) assert not tuple(running_config.unified_diff(running_after_rollback)) @@ -37,12 +37,12 @@ def test_logging_console_emergencies_scenario_3() -> None: platform = Platform.CISCO_IOS running_config = get_hconfig(platform) generated_config = get_hconfig_fast_load(platform, ("logging console emergencies",)) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ("logging console emergencies",) + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ("logging console emergencies",) future_config = running_config.future(remediation_config) - assert future_config.dump_simple() == ("logging console emergencies",) - rollback = future_config.config_to_get_to(running_config) - assert rollback.dump_simple() == ("logging console debugging",) + assert future_config.to_lines() == ("logging console emergencies",) + rollback = future_config.remediation(running_config) + assert rollback.to_lines() == ("logging console debugging",) running_after_rollback = future_config.future(rollback) assert not tuple(running_config.unified_diff(running_after_rollback)) @@ -94,50 +94,11 @@ def test_duplicate_child_router() -> None: " exit-address-family", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "router eigrp EIGRP_INSTANCE", " address-family ipv4 unicast autonomous-system 10000", " topology base", " no redistribute bgp 65001", " redistribute bgp 65001 route-map ROUTE_MAP_IN", ) - - -def test_rm_ipv6_acl_sequence_numbers() -> None: - """Test post-load callback that removes IPv6 ACL sequence numbers (covers lines 21-23).""" - platform = Platform.CISCO_IOS - config_text = "ipv6 access-list TEST_IPV6_ACL\n sequence 10 permit tcp any any eq 443\n sequence 20 deny ipv6 any any" - config = get_hconfig(platform, config_text) - acl = config.get_child(equals="ipv6 access-list TEST_IPV6_ACL") - - assert acl is not None - assert acl.get_child(equals="permit tcp any any eq 443") is not None - assert acl.get_child(equals="deny ipv6 any any") is not None - assert acl.get_child(startswith="sequence") is None - - -def test_remove_ipv4_acl_remarks() -> None: - """Test post-load callback that removes IPv4 ACL remarks (covers line 30).""" - platform = Platform.CISCO_IOS - config_text = "ip access-list extended TEST_ACL\n remark Allow HTTPS traffic\n permit tcp any any eq 443\n remark Block all other traffic\n deny ip any any" - config = get_hconfig(platform, config_text) - acl = config.get_child(equals="ip access-list extended TEST_ACL") - - assert acl is not None - assert acl.get_child(equals="10 permit tcp any any eq 443") is not None - assert acl.get_child(equals="20 deny ip any any") is not None - assert acl.get_child(startswith="remark") is None - - -def test_add_acl_sequence_numbers() -> None: - """Test post-load callback that adds sequence numbers to IPv4 ACLs (covers lines 42-43).""" - platform = Platform.CISCO_IOS - config_text = "ip access-list extended TEST_ACL\n permit tcp any any eq 443\n permit tcp any any eq 80\n deny ip any any" - config = get_hconfig(platform, config_text) - acl = config.get_child(equals="ip access-list extended TEST_ACL") - - assert acl is not None - assert acl.get_child(equals="10 permit tcp any any eq 443") is not None - assert acl.get_child(equals="20 permit tcp any any eq 80") is not None - assert acl.get_child(equals="30 deny ip any any") is not None diff --git a/tests/test_driver_cisco_nxos.py b/tests/integration/test_cisco_nxos.py similarity index 88% rename from tests/test_driver_cisco_nxos.py rename to tests/integration/test_cisco_nxos.py index 44c5080f..8f6e009e 100644 --- a/tests/test_driver_cisco_nxos.py +++ b/tests/integration/test_cisco_nxos.py @@ -1,6 +1,6 @@ from hier_config import get_hconfig_fast_load from hier_config.models import Platform -from hier_config.utils import load_hconfig_v2_options +from hier_config.utils import load_driver_rules def test_line_console_terminal_settings_negation_negate_with() -> None: @@ -9,7 +9,7 @@ def test_line_console_terminal_settings_negation_negate_with() -> None: NX-OS does not accept 'no terminal length ' or 'no terminal width '. The correct remediation is to reset to platform defaults via negation_negate_with. """ - driver = load_hconfig_v2_options( + driver = load_driver_rules( { "negation_negate_with": [ { @@ -48,9 +48,9 @@ def test_line_console_terminal_settings_negation_negate_with() -> None: ), ) - remediation = running_config.config_to_get_to(generated_config) + remediation = running_config.remediation(generated_config) - assert remediation.dump_simple() == ( + assert remediation.to_lines() == ( "line console", " terminal length 24", " terminal width 80", diff --git a/tests/test_driver_cisco_xr.py b/tests/integration/test_cisco_xr.py similarity index 51% rename from tests/test_driver_cisco_xr.py rename to tests/integration/test_cisco_xr.py index effb3be3..3907b4dc 100644 --- a/tests/test_driver_cisco_xr.py +++ b/tests/integration/test_cisco_xr.py @@ -2,29 +2,6 @@ from hier_config.models import Platform -def test_multiple_groups_no_duplicate_child_error() -> None: - """Test that multiple group blocks don't raise DuplicateChildError (issue #209).""" - platform = Platform.CISCO_XR - config_text = """\ -hostname router1 -group core - interface 'Bundle-Ether.*' - mtu 9188 - ! -end-group -group edge - interface 'Bundle-Ether.*' - mtu 9092 - ! -end-group -""" - hconfig = get_hconfig(platform, config_text) - children = [child.text for child in hconfig.children] - assert "hostname router1" in children - assert "group core" in children - assert "group edge" in children - - def test_multiple_groups_remediation() -> None: """Test remediation between configs with multiple group blocks.""" platform = Platform.CISCO_XR @@ -55,8 +32,8 @@ def test_multiple_groups_remediation() -> None: "end-group", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple(sectional_exiting=True) == ("no group edge",) + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines(sectional_exiting=True) == ("no group edge",) def test_duplicate_child_route_policy() -> None: @@ -97,8 +74,8 @@ def test_duplicate_child_route_policy() -> None: "", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple(sectional_exiting=True) == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines(sectional_exiting=True) == ( "no route-policy SET_LOCAL_PREF_AND_PASS", ) @@ -140,8 +117,8 @@ def test_nested_if_endif_route_policy() -> None: "end-policy", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple(sectional_exiting=True) == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines(sectional_exiting=True) == ( "route-policy EXAMPLE-POLICY", " if (community matches-any COMM-SET-A) then", " if (community matches-any COMM-SET-B) then", @@ -203,8 +180,8 @@ def test_flow_exporter_template_indent_adjust() -> None: "end-policy", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple(sectional_exiting=True) == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines(sectional_exiting=True) == ( "route-policy POLICY1", " if (destination in PREFIX-SET1) then", " drop", @@ -274,8 +251,8 @@ def test_template_block_indent_adjust() -> None: "!", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple(sectional_exiting=True) == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines(sectional_exiting=True) == ( "no template UPLINK-PORT", "template UPLINK-PORT", " description Uplink - Core Facing", @@ -313,9 +290,9 @@ def test_ipv4_acl_sequence_number_idempotent() -> None: " 30 deny ipv4 any any", ), ) - remediation_config = running_config.config_to_get_to(generated_config) + remediation_config = running_config.remediation(generated_config) - assert remediation_config.dump_simple() == ( + assert remediation_config.to_lines() == ( "ipv4 access-list TEST_ACL", " 20 permit tcp any any eq 22", ) @@ -340,9 +317,9 @@ def test_ipv6_acl_sequence_number_idempotent() -> None: " 20 deny ipv6 any any", ), ) - remediation_config = running_config.config_to_get_to(generated_config) + remediation_config = running_config.remediation(generated_config) - assert remediation_config.dump_simple() == ( + assert remediation_config.to_lines() == ( "ipv6 access-list TEST_IPV6_ACL", " 10 permit tcp any any eq 22", ) @@ -368,315 +345,14 @@ def test_ipv4_acl_sequence_number_addition() -> None: " 30 deny ipv4 any any", ), ) - remediation_config = running_config.config_to_get_to(generated_config) + remediation_config = running_config.remediation(generated_config) - assert remediation_config.dump_simple() == ( + assert remediation_config.to_lines() == ( "ipv4 access-list TEST_ACL", " 20 permit tcp any any eq 22", ) -def test_sectional_exit_text_parent_level_route_policy() -> None: - """Test that route-policy exit text appears at parent level (no indentation).""" - platform = Platform.CISCO_XR - config = get_hconfig_fast_load( - platform, - ( - "route-policy TEST", - " set local-preference 200", - " pass", - ), - ) - - route_policy = config.get_child(equals="route-policy TEST") - assert route_policy is not None - assert route_policy.sectional_exit_text_parent_level is True - - output = config.dump_simple(sectional_exiting=True) - assert output == ( - "route-policy TEST", - " set local-preference 200", - " pass", - "end-policy", - ) - - -def test_sectional_exit_text_parent_level_prefix_set() -> None: - """Test that prefix-set exit text appears at parent level (no indentation).""" - platform = Platform.CISCO_XR - config = get_hconfig_fast_load( - platform, - ( - "prefix-set TEST_PREFIX", - " 192.0.2.0/24", - " 198.51.100.0/24", - ), - ) - - prefix_set = config.get_child(equals="prefix-set TEST_PREFIX") - assert prefix_set is not None - assert prefix_set.sectional_exit_text_parent_level is True - - output = config.dump_simple(sectional_exiting=True) - assert output == ( - "prefix-set TEST_PREFIX", - " 192.0.2.0/24", - " 198.51.100.0/24", - "end-set", - ) - - -def test_sectional_exit_text_parent_level_policy_map() -> None: - """Test that policy-map exit text appears at parent level (no indentation).""" - platform = Platform.CISCO_XR - config = get_hconfig_fast_load( - platform, - ( - "policy-map TEST_POLICY", - " class TEST_CLASS", - " set precedence 5", - ), - ) - - policy_map = config.get_child(equals="policy-map TEST_POLICY") - assert policy_map is not None - assert policy_map.sectional_exit_text_parent_level is True - - output = config.dump_simple(sectional_exiting=True) - assert output == ( - "policy-map TEST_POLICY", - " class TEST_CLASS", - " set precedence 5", - " exit", - "end-policy-map", - ) - - -def test_sectional_exit_text_parent_level_class_map() -> None: - """Test that class-map exit text appears at parent level (no indentation).""" - platform = Platform.CISCO_XR - config = get_hconfig_fast_load( - platform, - ( - "class-map match-any TEST_CLASS", - " match access-group TEST_ACL", - ), - ) - - class_map = config.get_child(equals="class-map match-any TEST_CLASS") - assert class_map is not None - assert class_map.sectional_exit_text_parent_level is True - - output = config.dump_simple(sectional_exiting=True) - assert output == ( - "class-map match-any TEST_CLASS", - " match access-group TEST_ACL", - "end-class-map", - ) - - -def test_sectional_exit_text_parent_level_community_set() -> None: - """Test that community-set exit text appears at parent level (no indentation).""" - platform = Platform.CISCO_XR - config = get_hconfig_fast_load( - platform, - ( - "community-set TEST_COMM", - " 65001:100", - " 65001:200", - ), - ) - - community_set = config.get_child(equals="community-set TEST_COMM") - assert community_set is not None - assert community_set.sectional_exit_text_parent_level is True - - output = config.dump_simple(sectional_exiting=True) - assert output == ( - "community-set TEST_COMM", - " 65001:100", - " 65001:200", - "end-set", - ) - - -def test_sectional_exit_text_parent_level_extcommunity_set() -> None: - """Test that extcommunity-set exit text appears at parent level (no indentation).""" - platform = Platform.CISCO_XR - config = get_hconfig_fast_load( - platform, - ( - "extcommunity-set rt TEST_RT", - " 1:100", - " 2:200", - ), - ) - - extcommunity_set = config.get_child(equals="extcommunity-set rt TEST_RT") - assert extcommunity_set is not None - assert extcommunity_set.sectional_exit_text_parent_level is True - - output = config.dump_simple(sectional_exiting=True) - assert output == ( - "extcommunity-set rt TEST_RT", - " 1:100", - " 2:200", - "end-set", - ) - - -def test_sectional_exit_text_parent_level_template() -> None: - """Test that template exit text appears at parent level (no indentation).""" - platform = Platform.CISCO_XR - config = get_hconfig_fast_load( - platform, - ( - "template TEST_TEMPLATE", - " description test template", - ), - ) - - template = config.get_child(equals="template TEST_TEMPLATE") - assert template is not None - assert template.sectional_exit_text_parent_level is True - - output = config.dump_simple(sectional_exiting=True) - assert output == ( - "template TEST_TEMPLATE", - " description test template", - "end-template", - ) - - -def test_sectional_exit_text_current_level_interface() -> None: - """Test that interface exit text appears at current level (with indentation).""" - platform = Platform.CISCO_XR - config = get_hconfig_fast_load( - platform, - ( - "interface GigabitEthernet0/0/0/0", - " description test interface", - " ipv4 address 192.0.2.1 255.255.255.0", - ), - ) - - interface = config.get_child(equals="interface GigabitEthernet0/0/0/0") - assert interface is not None - assert interface.sectional_exit_text_parent_level is False - - output = config.dump_simple(sectional_exiting=True) - assert output == ( - "interface GigabitEthernet0/0/0/0", - " description test interface", - " ipv4 address 192.0.2.1 255.255.255.0", - " root", - ) - - -def test_sectional_exit_text_current_level_router_bgp() -> None: - """Test that router bgp exit text appears at current level (with indentation).""" - platform = Platform.CISCO_XR - config = get_hconfig_fast_load( - platform, - ( - "router bgp 65000", - " bgp router-id 192.0.2.1", - " address-family ipv4 unicast", - ), - ) - - router_bgp = config.get_child(equals="router bgp 65000") - assert router_bgp is not None - assert router_bgp.sectional_exit_text_parent_level is False - - output = config.dump_simple(sectional_exiting=True) - assert output == ( - "router bgp 65000", - " bgp router-id 192.0.2.1", - " address-family ipv4 unicast", - " root", - ) - - -def test_sectional_exit_text_multiple_sections() -> None: - """Test multiple sections with different exit text level behaviors.""" - platform = Platform.CISCO_XR - config = get_hconfig_fast_load( - platform, - ( - "route-policy TEST1", - " pass", - "!", - "interface GigabitEthernet0/0/0/0", - " description test", - "!", - "prefix-set TEST_PREFIX", - " 192.0.2.0/24", - ), - ) - - route_policy = config.get_child(equals="route-policy TEST1") - assert route_policy is not None - assert route_policy.sectional_exit_text_parent_level is True - - interface = config.get_child(equals="interface GigabitEthernet0/0/0/0") - assert interface is not None - assert interface.sectional_exit_text_parent_level is False - - prefix_set = config.get_child(equals="prefix-set TEST_PREFIX") - assert prefix_set is not None - assert prefix_set.sectional_exit_text_parent_level is True - - output = config.dump_simple(sectional_exiting=True) - assert output == ( - "route-policy TEST1", - " pass", - "end-policy", - "interface GigabitEthernet0/0/0/0", - " description test", - " root", - "prefix-set TEST_PREFIX", - " 192.0.2.0/24", - "end-set", - ) - - -def test_indented_bang_section_separators_no_duplicate_child_error() -> None: - """Test that indented ! section separators don't raise DuplicateChildError (issue #231).""" - platform = Platform.CISCO_XR - config_text = """\ -telemetry model-driven - destination-group DEST-GROUP-1 - address-family ipv4 10.0.0.1 port 57000 - encoding self-describing-gpb - protocol tcp - ! - ! - destination-group DEST-GROUP-2 - address-family ipv4 10.0.0.2 port 57000 - encoding self-describing-gpb - protocol tcp - ! - ! - sensor-group SENSOR-1 - sensor-path openconfig-platform:components/component/cpu - sensor-path openconfig-platform:components/component/memory - ! - sensor-group SENSOR-2 - sensor-path openconfig-interfaces:interfaces/interface/state/counters - ! -! -""" - hconfig = get_hconfig(platform, config_text) - telemetry = hconfig.get_child(equals="telemetry model-driven") - assert telemetry is not None - child_texts = [child.text for child in telemetry.children] - assert "destination-group DEST-GROUP-1" in child_texts - assert "destination-group DEST-GROUP-2" in child_texts - assert "sensor-group SENSOR-1" in child_texts - assert "sensor-group SENSOR-2" in child_texts - - def test_running_with_bang_separators_intended_without_no_remediation() -> None: """Running config with indented ! separators and intended without produces no remediation.""" platform = Platform.CISCO_XR @@ -712,7 +388,7 @@ def test_running_with_bang_separators_intended_without_no_remediation() -> None: protocol tcp """, ) - assert running.config_to_get_to(intended).dump_simple() == () + assert running.remediation(intended).to_lines() == () def test_intended_with_bang_comments_running_without_no_remediation() -> None: @@ -748,7 +424,7 @@ def test_intended_with_bang_comments_running_without_no_remediation() -> None: protocol tcp """, ) - assert running.config_to_get_to(intended).dump_simple() == () + assert running.remediation(intended).to_lines() == () def test_differing_bang_comment_text_produces_no_remediation() -> None: @@ -770,4 +446,4 @@ def test_differing_bang_comment_text_produces_no_remediation() -> None: net 49.0001.1921.2022.0222.00 """, ) - assert running.config_to_get_to(intended).dump_simple() == () + assert running.remediation(intended).to_lines() == () diff --git a/tests/test_driver_fortinet_fortios.py b/tests/integration/test_fortinet_fortios.py similarity index 74% rename from tests/test_driver_fortinet_fortios.py rename to tests/integration/test_fortinet_fortios.py index a168d734..97da42f5 100644 --- a/tests/test_driver_fortinet_fortios.py +++ b/tests/integration/test_fortinet_fortios.py @@ -1,8 +1,6 @@ from hier_config import get_hconfig_fast_load -from hier_config.child import HConfigChild from hier_config.constructors import get_hconfig from hier_config.models import Platform -from hier_config.platforms.fortinet_fortios.driver import HConfigDriverFortinetFortiOS def test_swap_negation() -> None: @@ -35,8 +33,8 @@ def test_swap_negation() -> None: "end", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple(sectional_exiting=True) == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines(sectional_exiting=True) == ( "config system interface", " edit port1", " unset description", @@ -80,8 +78,8 @@ def test_idempotent_for() -> None: "end", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple(sectional_exiting=True) == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines(sectional_exiting=True) == ( "config system interface", " edit port1", " set description 'New Description'", @@ -109,17 +107,3 @@ def test_future() -> None: ) future_config = running_config.future(remediation_config) assert not tuple(remediation_config.unified_diff(future_config)) - - -def test_swap_negation_direct() -> None: - """Test swap_negation method directly to cover set-to-unset conversion (covers line 45).""" - driver = HConfigDriverFortinetFortiOS() - config = get_hconfig(Platform.FORTINET_FORTIOS) - child = HConfigChild(config, "set description 'test value'") - result = driver.swap_negation(child) - assert result.text == "unset description" - - child2 = HConfigChild(config, "unset description") - result2 = driver.swap_negation(child2) - - assert result2.text == "set description" diff --git a/tests/test_driver_generic.py b/tests/integration/test_generic.py similarity index 91% rename from tests/test_driver_generic.py rename to tests/integration/test_generic.py index 417a08cb..c384a0b4 100644 --- a/tests/test_driver_generic.py +++ b/tests/integration/test_generic.py @@ -4,7 +4,7 @@ from hier_config.exceptions import DuplicateChildError from hier_config.models import Platform from hier_config.root import HConfig -from hier_config.utils import load_hconfig_v2_options +from hier_config.utils import load_driver_rules def test_generic_snmp_scenario_1() -> None: @@ -20,8 +20,8 @@ def test_generic_snmp_scenario_1() -> None: "snmp-server host 192.2.0.3 trap version v2c community examplekey3", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "no snmp-server community public", "snmp-server community examplekey1", "snmp-server community examplekey2", @@ -33,7 +33,7 @@ def test_generic_snmp_scenario_1() -> None: def test_generic_snmp_scenario_2() -> None: platform = Platform.GENERIC - driver = load_hconfig_v2_options( + driver = load_driver_rules( { "parent_allows_duplicate_child": [ {"lineage": [{"startswith": ["snmp-server community"]}]}, @@ -76,8 +76,8 @@ def test_generic_aaa_scenario_1() -> None: " server 192.2.0.121", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "no aaa group server tacacs TACACS_GROUP1", "no aaa group server radius RADIUS_GROUP1", "aaa group server tacacs TACACS_GROUP2", @@ -111,7 +111,7 @@ def test_generic_aaa_scenario_2() -> None: ), ) # Create a driver with ordering rules for aaa group server management - driver = load_hconfig_v2_options( + driver = load_driver_rules( { "ordering": [ {"lineage": [{"startswith": "aaa group server radius "}], "order": 520}, @@ -128,7 +128,7 @@ def test_generic_aaa_scenario_2() -> None: Platform.GENERIC, ) - base_remediation = running_config.config_to_get_to(generated_config) + base_remediation = running_config.remediation(generated_config) remediation_config = HConfig(driver) for child in base_remediation.children: @@ -149,7 +149,7 @@ def test_generic_aaa_scenario_2() -> None: remediation_config.set_order_weight() - assert remediation_config.dump_simple() == ( + assert remediation_config.to_lines() == ( "aaa group server tacacs TACACS_GROUP1", " no server 192.2.0.3", " no server 192.2.0.7", diff --git a/tests/integration/test_hp_procurve.py b/tests/integration/test_hp_procurve.py new file mode 100644 index 00000000..6c9833a1 --- /dev/null +++ b/tests/integration/test_hp_procurve.py @@ -0,0 +1,126 @@ +from hier_config import get_hconfig_fast_load +from hier_config.constructors import get_hconfig +from hier_config.models import Platform + + +def test_negate_with() -> None: + platform = Platform.HP_PROCURVE + running_config = get_hconfig_fast_load( + platform, + ( + "aaa port-access authenticator 1/1 tx-period 3", + "aaa port-access authenticator 1/1 supplicant-timeout 3", + "aaa port-access authenticator 1/1 client-limit 4", + "aaa port-access mac-based 1/1 addr-limit 4", + "aaa port-access mac-based 1/1 logoff-period 3", + 'aaa port-access 1/1 critical-auth user-role "allowall"', + ), + ) + generated_config = get_hconfig(platform) + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( + "aaa port-access authenticator 1/1 tx-period 30", + "aaa port-access authenticator 1/1 supplicant-timeout 30", + "no aaa port-access authenticator 1/1 client-limit", + "aaa port-access mac-based 1/1 addr-limit 1", + "aaa port-access mac-based 1/1 logoff-period 300", + "no aaa port-access 1/1 critical-auth user-role", + ) + + +def test_idempotent_for() -> None: + platform = Platform.HP_PROCURVE + running_config = get_hconfig_fast_load( + platform, + ( + "aaa port-access authenticator 1/1 tx-period 3", + "aaa port-access authenticator 1/1 supplicant-timeout 3", + "aaa port-access authenticator 1/1 client-limit 4", + "aaa port-access mac-based 1/1 addr-limit 4", + "aaa port-access mac-based 1/1 logoff-period 3", + 'aaa port-access 1/1 critical-auth user-role "allowall"', + ), + ) + generated_config = get_hconfig_fast_load( + platform, + ( + "aaa port-access authenticator 1/1 tx-period 4", + "aaa port-access authenticator 1/1 supplicant-timeout 4", + "aaa port-access authenticator 1/1 client-limit 5", + "aaa port-access mac-based 1/1 addr-limit 5", + "aaa port-access mac-based 1/1 logoff-period 4", + 'aaa port-access 1/1 critical-auth user-role "allownone"', + ), + ) + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( + "aaa port-access authenticator 1/1 tx-period 4", + "aaa port-access authenticator 1/1 supplicant-timeout 4", + "aaa port-access authenticator 1/1 client-limit 5", + "aaa port-access mac-based 1/1 addr-limit 5", + "aaa port-access mac-based 1/1 logoff-period 4", + 'aaa port-access 1/1 critical-auth user-role "allownone"', + ) + + +def test_future() -> None: + platform = Platform.HP_PROCURVE + running_config = get_hconfig(platform) + remediation_config = get_hconfig_fast_load( + platform, + ( + "aaa port-access authenticator 3/34", + "aaa port-access authenticator 3/34 tx-period 10", + "aaa port-access authenticator 3/34 supplicant-timeout 10", + "aaa port-access authenticator 3/34 client-limit 2", + "aaa port-access mac-based 3/34", + "aaa port-access mac-based 3/34 addr-limit 2", + 'aaa port-access 3/34 critical-auth user-role "allowall"', + ), + ) + future_config = running_config.future(remediation_config) + assert not tuple(remediation_config.unified_diff(future_config)) + + +def test_negate_with_child_config() -> None: + """Test negate_with returns None for non-root config without special rule (covers line 166).""" + platform = Platform.HP_PROCURVE + running_config = get_hconfig_fast_load( + platform, + ( + "interface 1/1", + " speed-duplex auto", + ), + ) + generated_config = get_hconfig_fast_load( + platform, + ("interface 1/1",), + ) + remediation_config = running_config.remediation(generated_config) + + assert remediation_config.to_lines() == ( + "interface 1/1", + " no speed-duplex auto", + ) + + +def test_negate_with_from_base_driver() -> None: + """Test negate_with uses parent driver rule when applicable (covers line 163).""" + platform = Platform.HP_PROCURVE + running_config = get_hconfig_fast_load( + platform, + ( + "interface 1/1", + " disable", + ), + ) + generated_config = get_hconfig_fast_load( + platform, + ("interface 1/1",), + ) + remediation_config = running_config.remediation(generated_config) + + assert remediation_config.to_lines() == ( + "interface 1/1", + " enable", + ) diff --git a/tests/test_driver_huawei_vrp.py b/tests/integration/test_huawei_vrp.py similarity index 77% rename from tests/test_driver_huawei_vrp.py rename to tests/integration/test_huawei_vrp.py index 7f74541c..b2519611 100644 --- a/tests/test_driver_huawei_vrp.py +++ b/tests/integration/test_huawei_vrp.py @@ -11,8 +11,8 @@ def test_merge_with_undo() -> None: generated_config = get_hconfig_fast_load( platform, ("undo test_for_undo", "test_for_redo") ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ("undo test_for_undo", "test_for_redo") + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ("undo test_for_undo", "test_for_redo") def test_negate_description() -> None: @@ -23,8 +23,8 @@ def test_negate_description() -> None: generated_config = get_hconfig_fast_load( platform, ("interface GigabitEthernet0/0/0",) ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "interface GigabitEthernet0/0/0", " undo description", ) @@ -36,8 +36,8 @@ def test_negate_remark() -> None: platform, ("acl number 2000", " rule 5 remark some old remark") ) generated_config = get_hconfig_fast_load(platform, ("acl number 2000",)) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "acl number 2000", " undo rule 5 remark", ) @@ -51,8 +51,8 @@ def test_negate_alias() -> None: generated_config = get_hconfig_fast_load( platform, ("interface GigabitEthernet0/0/0",) ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "interface GigabitEthernet0/0/0", " undo alias", ) @@ -64,8 +64,8 @@ def test_negate_snmp_agent_community() -> None: platform, ("snmp-agent community read cipher %^%#blabla%^%# acl 2000",) ) generated_config = get_hconfig(platform) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "undo snmp-agent community read cipher %^%#blabla%^%#", ) @@ -83,7 +83,7 @@ def test_comments_stripped() -> None: "! yet another comment", ), ) - assert config.dump_simple() == ( + assert config.to_lines() == ( "interface GigabitEthernet0/0/0", " description test", ) @@ -98,7 +98,7 @@ def test_sectional_exit_is_quit() -> None: " description test", ), ) - assert config.dump_simple(sectional_exiting=True) == ( + assert config.to_lines(sectional_exiting=True) == ( "interface GigabitEthernet0/0/0", " description test", " quit", diff --git a/tests/test_idempotent_commands.py b/tests/integration/test_idempotent_commands.py similarity index 90% rename from tests/test_idempotent_commands.py rename to tests/integration/test_idempotent_commands.py index 63b3939d..aa9cb9fb 100644 --- a/tests/test_idempotent_commands.py +++ b/tests/integration/test_idempotent_commands.py @@ -34,8 +34,8 @@ def test_parameterized_regex_same_key_is_idempotent() -> None: driver, ("client 10.1.1.1 server-key KEY_NEW",), ) - remediation = running.config_to_get_to(generated) - assert remediation.dump_simple() == ("client 10.1.1.1 server-key KEY_NEW",) + remediation = running.remediation(generated) + assert remediation.to_lines() == ("client 10.1.1.1 server-key KEY_NEW",) def test_parameterized_regex_different_key_not_idempotent() -> None: @@ -58,9 +58,9 @@ def test_parameterized_regex_different_key_not_idempotent() -> None: driver, ("client 10.1.1.1 server-key KEY1",), ) - remediation = running.config_to_get_to(generated) + remediation = running.remediation(generated) # 10.2.2.2 is removed because it's not in generated (not idempotent with 10.1.1.1) - assert remediation.dump_simple() == ("no client 10.2.2.2 server-key KEY2",) + assert remediation.to_lines() == ("no client 10.2.2.2 server-key KEY2",) def test_bgp_neighbor_regex_idempotent() -> None: @@ -84,8 +84,8 @@ def test_bgp_neighbor_regex_idempotent() -> None: " neighbor 1000::8 remote-as 2002", ), ) - remediation = running.config_to_get_to(generated) - lines = remediation.dump_simple() + remediation = running.remediation(generated) + lines = remediation.to_lines() # The changed ASN for 40.0.0.0 should appear assert " neighbor 40.0.0.0 remote-as 44001" in lines # New neighbors should be added @@ -122,8 +122,8 @@ def test_startswith_rules_do_not_cross_contaminate() -> None: "hardware profile tcam region racl 512", ), ) - remediation = running.config_to_get_to(generated) - lines = remediation.dump_simple() + remediation = running.remediation(generated) + lines = remediation.to_lines() # Both should be updated independently (idempotent within their own rule) assert "hardware access-list tcam region arp-ether 256" in lines assert "hardware profile tcam region racl 512" in lines diff --git a/tests/test_driver_juniper_junos.py b/tests/integration/test_juniper_junos.py similarity index 74% rename from tests/test_driver_juniper_junos.py rename to tests/integration/test_juniper_junos.py index fa2ae3f4..3c3e6e37 100644 --- a/tests/test_driver_juniper_junos.py +++ b/tests/integration/test_juniper_junos.py @@ -1,11 +1,5 @@ -import pytest - from hier_config import WorkflowRemediation, get_hconfig, get_hconfig_fast_load -from hier_config.child import HConfigChild from hier_config.models import Platform -from hier_config.platforms.juniper_junos.driver import HConfigDriverJuniperJUNOS - -# Tests moved from test_juniper_syntax.py def test_junos_basic_remediation() -> None: @@ -55,59 +49,6 @@ def test_flat_junos_remediation( assert line in remediation_list -# New comprehensive driver tests for 100% coverage - - -def test_swap_negation_delete_to_set() -> None: - """Test swapping from 'delete' to 'set' prefix (covers line 9-11).""" - platform = Platform.JUNIPER_JUNOS - driver = HConfigDriverJuniperJUNOS() - root = get_hconfig(platform) - - # Create a child with 'delete' prefix - child = HConfigChild(root, "delete vlans test_vlan vlan-id 100") - - # Swap negation should convert to 'set' - result = driver.swap_negation(child) - - assert result.text == "set vlans test_vlan vlan-id 100" - assert result.text.startswith("set ") - - -def test_swap_negation_set_to_delete() -> None: - """Test swapping from 'set' to 'delete' prefix (covers lines 10, 12).""" - platform = Platform.JUNIPER_JUNOS - driver = HConfigDriverJuniperJUNOS() - root = get_hconfig(platform) - - # Create a child with 'set' prefix - child = HConfigChild(root, "set vlans test_vlan vlan-id 100") - - # Swap negation should convert to 'delete' - result = driver.swap_negation(child) - - assert result.text == "delete vlans test_vlan vlan-id 100" - assert result.text.startswith("delete ") - - -def test_swap_negation_invalid_prefix() -> None: - """Test ValueError when text has neither 'set' nor 'delete' prefix (covers lines 14-15).""" - platform = Platform.JUNIPER_JUNOS - driver = HConfigDriverJuniperJUNOS() - root = get_hconfig(platform) - - # Create a child without proper prefix - child = HConfigChild(root, "vlans test_vlan vlan-id 100") - - # Should raise ValueError - with pytest.raises(ValueError, match="did not start with") as exc_info: - driver.swap_negation(child) - - assert "did not start with" in str(exc_info.value) - assert "delete " in str(exc_info.value) - assert "set " in str(exc_info.value) - - def test_vlan_addition_scenario() -> None: """Test adding a new VLAN to the configuration.""" platform = Platform.JUNIPER_JUNOS @@ -127,8 +68,8 @@ def test_vlan_addition_scenario() -> None: "set vlans switch_mgmt_10.0.3.0/24 l3-interface irb.3", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "set vlans switch_mgmt_10.0.3.0/24 vlan-id 3", "set vlans switch_mgmt_10.0.3.0/24 l3-interface irb.3", ) @@ -153,8 +94,8 @@ def test_vlan_removal_scenario() -> None: "set vlans switch_mgmt_10.0.2.0/24 l3-interface irb.2", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "delete vlans switch_mgmt_10.0.3.0/24 vlan-id 3", "delete vlans switch_mgmt_10.0.3.0/24 l3-interface irb.3", ) @@ -176,8 +117,8 @@ def test_interface_unit_configuration_scenario() -> None: "set interfaces irb unit 2 family inet description switch_mgmt_10.0.2.0/24", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "set interfaces irb unit 2 family inet filter input TEST", "set interfaces irb unit 2 family inet mtu 9000", "set interfaces irb unit 2 family inet description switch_mgmt_10.0.2.0/24", @@ -195,8 +136,8 @@ def test_interface_address_change_scenario() -> None: platform, ("set interfaces irb unit 3 family inet address 10.0.3.1/16",), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "delete interfaces irb unit 3 family inet address 10.0.4.1/16", "set interfaces irb unit 3 family inet address 10.0.3.1/16", ) @@ -216,8 +157,8 @@ def test_interface_disable_enable_scenario() -> None: platform, ("set interfaces irb unit 2 family inet address 10.0.2.1/24",), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "delete interfaces irb unit 2 family inet disable", ) @@ -241,8 +182,8 @@ def test_firewall_filter_configuration_scenario() -> None: "set firewall family inet filter TEST term 2 then reject", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "set firewall family inet filter TEST term 2 from destination-address 192.168.1.0/24", "set firewall family inet filter TEST term 2 then reject", ) @@ -263,8 +204,8 @@ def test_physical_interface_configuration_scenario() -> None: "set interfaces xe-0/0/0 unit 0 family inet6 address 2001:db8:5695::1/64", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "set interfaces xe-0/0/0 description bb01.lax01:Ethernet2; ID:YT661812121", "set interfaces xe-0/0/0 mtu 9160", "set interfaces xe-0/0/0 unit 0 family iso", @@ -273,7 +214,7 @@ def test_physical_interface_configuration_scenario() -> None: "set interfaces xe-0/0/0 unit 0 family inet6 address 2001:db8:5695::1/64", ) future_config = running_config.future(remediation_config) - assert future_config.dump_simple() == ( + assert future_config.to_lines() == ( "set interfaces xe-0/0/0 description bb01.lax01:Ethernet2; ID:YT661812121", "set interfaces xe-0/0/0 mtu 9160", "set interfaces xe-0/0/0 unit 0 family iso", @@ -294,8 +235,8 @@ def test_system_hostname_change_scenario() -> None: platform, ("set system host-name new-router.example.com",), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "delete system host-name old-router.example.com", "set system host-name new-router.example.com", ) diff --git a/tests/test_negate_with_undo.py b/tests/integration/test_negate_with_undo.py similarity index 100% rename from tests/test_negate_with_undo.py rename to tests/integration/test_negate_with_undo.py diff --git a/tests/test_negation_sub.py b/tests/integration/test_negation_sub.py similarity index 78% rename from tests/test_negation_sub.py rename to tests/integration/test_negation_sub.py index c5033ed1..1b60cab7 100644 --- a/tests/test_negation_sub.py +++ b/tests/integration/test_negation_sub.py @@ -6,7 +6,7 @@ ) from hier_config.platforms.driver_base import HConfigDriverRules from hier_config.platforms.generic.driver import HConfigDriverGeneric -from hier_config.utils import load_hconfig_v2_options +from hier_config.utils import load_driver_rules def _make_driver( @@ -34,8 +34,8 @@ def test_negation_sub_truncates_snmp_user() -> None: ("snmp-server user admin auth sha secret",), ) generated = get_hconfig_fast_load(driver, ()) - remediation = running.config_to_get_to(generated) - assert remediation.dump_simple() == ("no snmp-server user admin",) + remediation = running.remediation(generated) + assert remediation.to_lines() == ("no snmp-server user admin",) def test_negation_sub_truncates_prefix_list() -> None: @@ -54,8 +54,8 @@ def test_negation_sub_truncates_prefix_list() -> None: ("ipv6 prefix-list PL seq 1 permit 2801::/64 ge 65",), ) generated = get_hconfig_fast_load(driver, ()) - remediation = running.config_to_get_to(generated) - assert remediation.dump_simple() == ("no ipv6 prefix-list PL seq 1",) + remediation = running.remediation(generated) + assert remediation.to_lines() == ("no ipv6 prefix-list PL seq 1",) def test_negation_sub_no_match_uses_normal_negation() -> None: @@ -74,8 +74,8 @@ def test_negation_sub_no_match_uses_normal_negation() -> None: ("hostname router1",), ) generated = get_hconfig_fast_load(driver, ()) - remediation = running.config_to_get_to(generated) - assert remediation.dump_simple() == ("no hostname router1",) + remediation = running.remediation(generated) + assert remediation.to_lines() == ("no hostname router1",) def test_negation_sub_full_remediation() -> None: @@ -100,13 +100,13 @@ def test_negation_sub_full_remediation() -> None: driver, ("snmp-server user monitor auth sha secret2",), ) - remediation = running.config_to_get_to(generated) - assert remediation.dump_simple() == ("no snmp-server user admin",) + remediation = running.remediation(generated) + assert remediation.to_lines() == ("no snmp-server user admin",) -def test_negation_sub_via_v2_options() -> None: - """Negation sub rules loaded via load_hconfig_v2_options work correctly.""" - v2_options: dict[str, object] = { +def test_negation_sub_via_load_driver_rules() -> None: + """Negation sub rules loaded via load_driver_rules work correctly.""" + options: dict[str, object] = { "negation_sub": [ { "lineage": [{"startswith": "snmp-server user "}], @@ -115,11 +115,11 @@ def test_negation_sub_via_v2_options() -> None: }, ], } - driver = load_hconfig_v2_options(v2_options, Platform.GENERIC) + driver = load_driver_rules(options, Platform.GENERIC) running = get_hconfig_fast_load( driver, ("snmp-server user admin auth sha secret",), ) generated = get_hconfig_fast_load(driver, ()) - remediation = running.config_to_get_to(generated) - assert remediation.dump_simple() == ("no snmp-server user admin",) + remediation = running.remediation(generated) + assert remediation.to_lines() == ("no snmp-server user admin",) diff --git a/tests/integration/test_remediation.py b/tests/integration/test_remediation.py new file mode 100644 index 00000000..82fcfaf8 --- /dev/null +++ b/tests/integration/test_remediation.py @@ -0,0 +1,529 @@ +"""Integration tests for remediation, future, difference, and sectional overwrite.""" + +from hier_config import ( + HConfigChild, + WorkflowRemediation, + get_hconfig, + get_hconfig_driver, + get_hconfig_fast_load, +) +from hier_config.models import Platform + + +def test_remediation(platform_a: Platform) -> None: + running_config_hier = get_hconfig(platform_a) + interface = running_config_hier.add_child("interface Vlan2") + interface.add_child("ip address 192.168.1.1/24") + generated_config_hier = get_hconfig(platform_a) + generated_config_hier.add_child("interface Vlan3") + remediation_config_hier = running_config_hier.remediation( + generated_config_hier, + ) + assert len(tuple(remediation_config_hier.all_children())) == 2 + + +def test_remediation2(platform_a: Platform) -> None: + running_config_hier = get_hconfig(platform_a) + running_config_hier.add_child("do not add me") + generated_config_hier = get_hconfig(platform_a) + generated_config_hier.add_child("do not add me") + generated_config_hier.add_child("add me") + delta = get_hconfig(platform_a) + running_config_hier.remediation( + generated_config_hier, + delta, + ) + assert "do not add me" not in delta.children + assert "add me" in delta.children + + +def test_future_config(platform_a: Platform) -> None: + running_config = get_hconfig(platform_a) + running_config.add_children_deep(("a", "aa", "aaa", "aaaa")) + running_config.add_children_deep(("a", "ab", "aba", "abaa")) + config = get_hconfig(platform_a) + config.add_children_deep(("a", "ac")) + config.add_children_deep(("a", "no ab")) + config.add_children_deep(("a", "no az")) + + future_config = running_config.future(config) + assert tuple(c.indented_text() for c in future_config.all_children()) == ( + "a", + " ac", # config lines are added first + " no az", + " aa", # self lines not in config are added last + " aaa", + " aaaa", + ) + + +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 ``remediation`` 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.to_lines(sectional_exiting=True) == expected_future + + rollback_config = future_config.remediation(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.to_lines(sectional_exiting=True) == expected_rollback + + +def test_idempotent_commands() -> None: + platform = Platform.HP_PROCURVE + config_a = get_hconfig(platform) + config_b = get_hconfig(platform) + interface_name = "interface 1/1" + config_a.add_children_deep((interface_name, "untagged vlan 1")) + config_b.add_children_deep((interface_name, "untagged vlan 2")) + interface = config_a.remediation(config_b).get_child(equals=interface_name) + assert interface is not None + assert interface.get_child(equals="untagged vlan 2") + assert len(interface.children) == 1 + + +def test_idempotent_commands2() -> None: + platform = Platform.CISCO_IOS + config_a = get_hconfig(platform) + config_b = get_hconfig(platform) + interface_name = "interface 1/1" + config_a.add_children_deep((interface_name, "authentication host-mode multi-auth")) + config_b.add_children_deep( + (interface_name, "authentication host-mode multi-domain"), + ) + interface = config_a.remediation(config_b).get_child(equals=interface_name) + assert interface is not None + assert interface.get_child(equals="authentication host-mode multi-domain") + assert len(interface.children) == 1 + + +def test_future_config_no_command_in_source() -> None: + platform = Platform.HP_PROCURVE + running_config = get_hconfig(platform) + generated_config = get_hconfig(platform) + generated_config.add_child("no service dhcp") + + remediation_config = running_config.remediation(generated_config) + future_config = running_config.future(remediation_config) + assert len(future_config.children) == 1 + assert future_config.get_child(equals="no service dhcp") + assert not tuple(future_config.unified_diff(generated_config)) + rollback_config = future_config.remediation(running_config) + assert len(rollback_config.children) == 1 + assert rollback_config.get_child(equals="service dhcp") + calculated_running_config = future_config.future(rollback_config) + assert not calculated_running_config.children + assert not tuple(calculated_running_config.unified_diff(running_config)) + + +def test_sectional_overwrite() -> None: + platform = Platform.CISCO_XR + # There is a sectional_overwrite rules in the CISCO_XR driver for "template". + running_config = get_hconfig_fast_load(platform, "template test\n a\n b") + generated_config = get_hconfig_fast_load(platform, "template test\n a") + expected_remediation_config = get_hconfig_fast_load( + platform, "no template test\ntemplate test\n a" + ) + workflow_remediation = WorkflowRemediation(running_config, generated_config) + remediation_config = workflow_remediation.remediation_config + assert remediation_config == expected_remediation_config + + +def test_sectional_overwrite_no_negate() -> None: + platform = Platform.CISCO_XR + running_config = get_hconfig_fast_load(platform, "as-path-set test\n a\n b") + generated_config = get_hconfig_fast_load(platform, "as-path-set test\n a") + expected_remediation_config = get_hconfig_fast_load( + platform, "as-path-set test\n a" + ) + workflow_remediation = WorkflowRemediation(running_config, generated_config) + remediation_config = workflow_remediation.remediation_config + assert remediation_config == expected_remediation_config + + +def test_sectional_overwrite_no_negate2() -> None: + platform = Platform.CISCO_XR + running_config = get_hconfig_fast_load( + platform, + "route-policy test\n duplicate\n not_duplicate1\n duplicate\n not_duplicate2", + ) + generated_config = get_hconfig_fast_load( + platform, "route-policy test\n duplicate\n not_duplicate1" + ) + expected_remediation_config = get_hconfig_fast_load( + platform, "route-policy test\n duplicate\n not_duplicate1" + ) + workflow_remediation = WorkflowRemediation(running_config, generated_config) + remediation_config = workflow_remediation.remediation_config + assert remediation_config == expected_remediation_config + + +def test_overwrite_with_negate() -> None: + platform = Platform.CISCO_XR + running_config = get_hconfig_fast_load( + platform, "route-policy test\n duplicate\n not_duplicate\n duplicate" + ) + generated_config = get_hconfig_fast_load( + platform, "route-policy test\n duplicate\n not_duplicate" + ) + expected_config = get_hconfig_fast_load( + platform, + "no route-policy test\nroute-policy test\n duplicate\n not_duplicate", + ) + delta_config = get_hconfig(platform) + running_config.children["route-policy test"].overwrite_with( + generated_config.children["route-policy test"], delta_config + ) + assert delta_config == expected_config + + +def test_overwrite_with_no_negate() -> None: + platform = Platform.CISCO_XR + running_config = get_hconfig_fast_load( + platform, + "route-policy test\n duplicate\n not-duplicate\n duplicate\n duplicate", + ) + generated_config = get_hconfig_fast_load( + platform, "route-policy test\n duplicate\n not-duplicate\n duplicate" + ) + expected_config = get_hconfig_fast_load( + platform, + "route-policy test\n duplicate\n not-duplicate\n duplicate", + ) + delta_config = get_hconfig(platform) + running_config.children["route-policy test"].overwrite_with( + generated_config.children["route-policy test"], delta_config, negate=False + ) + assert delta_config == expected_config + + +def test_remediation_parent_identity() -> None: + interface_vlan2 = "interface Vlan2" + platform = Platform.CISCO_IOS + running_config_hier = get_hconfig(platform) + running_config_hier.add_children_deep( + (interface_vlan2, "ip address 192.168.1.1/24") + ) + generated_config_hier = get_hconfig(platform) + generated_config_hier.add_child(interface_vlan2) + remediation_config_hier = running_config_hier.remediation( + generated_config_hier, + ) + remediation_config_interface = remediation_config_hier.get_child( + equals=interface_vlan2 + ) + assert remediation_config_interface + assert id(remediation_config_interface.parent) == id(remediation_config_hier) + assert id(remediation_config_interface.root) == id(remediation_config_hier) + + +def test_difference1(platform_a: Platform) -> None: + rc = ("a", " a1", " a2", " a3", "b") + step = ("a", " a1", " a2", " a3", " a4", " a5", "b", "c", "d", " d1") + rc_hier = get_hconfig(get_hconfig_driver(platform_a), "\n".join(rc)) + + difference = get_hconfig( + get_hconfig_driver(platform_a), "\n".join(step) + ).difference(rc_hier) + difference_children = tuple( + c.indented_text() for c in difference.all_children_sorted() + ) + + assert len(difference_children) == 6 + assert "c" in difference.children + assert "d" in difference.children + difference_a = difference.get_child(equals="a") + assert isinstance(difference_a, HConfigChild) + assert "a4" in difference_a.children + assert "a5" in difference_a.children + difference_d = difference.get_child(equals="d") + assert isinstance(difference_d, HConfigChild) + assert "d1" in difference_d.children + + +def test_difference2() -> None: + platform = Platform.CISCO_IOS + rc = ("a", " a1", " a2", " a3", "b") + step = ("a", " a1", " a2", " a3", " a4", " a5", "b", "c", "d", " d1") + rc_hier = get_hconfig(get_hconfig_driver(platform), "\n".join(rc)) + step_hier = get_hconfig(get_hconfig_driver(platform), "\n".join(step)) + + difference_children = tuple( + c.indented_text() for c in step_hier.difference(rc_hier).all_children_sorted() + ) + assert len(difference_children) == 6 + + +def test_difference3() -> None: + platform = Platform.CISCO_IOS + rc = ("ip access-list extended test", " 10 a", " 20 b") + step = ("ip access-list extended test", " 10 a", " 20 b", " 30 c") + rc_hier = get_hconfig(get_hconfig_driver(platform), "\n".join(rc)) + step_hier = get_hconfig(get_hconfig_driver(platform), "\n".join(step)) + + difference_children = tuple( + c.indented_text() for c in step_hier.difference(rc_hier).all_children_sorted() + ) + assert difference_children == ("ip access-list extended test", " 30 c") + + +def test_difference_with_acl_none_target() -> None: + """Test _difference with ACL when target_acl_children is None.""" + platform = Platform.CISCO_IOS + running_config = get_hconfig(platform) + + acl = running_config.add_child("ip access-list extended test") + acl.add_child("10 permit ip any any") + target_config = get_hconfig(platform) + difference = running_config.difference(target_config) + + assert difference.get_child(equals="ip access-list extended test") is not None + + +def test_difference_with_negation() -> None: + """Test _difference with negation prefix.""" + platform = Platform.CISCO_IOS + running_config = get_hconfig(platform) + running_config.add_child("interface GigabitEthernet0/0") + running_config.add_child("logging console") + generated_config = get_hconfig(platform) + generated_config.add_child("interface GigabitEthernet0/0") + difference = running_config.difference(generated_config) + + assert difference.get_child(equals="logging console") is not None + + +def test_difference_with_default_prefix() -> None: + """Test _difference skips lines with 'default' prefix.""" + platform = Platform.CISCO_IOS + running_config = get_hconfig(platform) + running_config.add_child("interface GigabitEthernet0/0") + running_config.add_child("default interface GigabitEthernet0/1") + generated_config = get_hconfig(platform) + generated_config.add_child("interface GigabitEthernet0/0") + difference = running_config.difference(generated_config) + + assert difference.get_child(startswith="default") is None + + +def test_future_with_negated_command_in_config() -> None: + """Test _future with negated command.""" + platform = Platform.CISCO_IOS + running_config = get_hconfig(platform) + running_config.add_child("interface GigabitEthernet0/0") + remediation_config = get_hconfig(platform) + remediation_config.add_child("no interface GigabitEthernet0/0") + future_config = running_config.future(remediation_config) + + assert future_config.get_child(equals="interface GigabitEthernet0/0") is None + + +def test_future_with_negation_prefix_match() -> None: + """Test _future when negated form exists.""" + platform = Platform.CISCO_IOS + running_config = get_hconfig(platform) + running_config.add_child("no logging console") + remediation_config = get_hconfig(platform) + remediation_config.add_child("logging console") + future_config = running_config.future(remediation_config) + + assert future_config.get_child(equals="logging console") is not None + assert future_config.get_child(equals="no logging console") is None + + +def test_future_with_negation_prefix() -> None: + """Test _future with negation prefix in self.""" + platform = Platform.CISCO_IOS + running_config = get_hconfig(platform) + running_config.add_child("no ip routing") + remediation_config = get_hconfig(platform) + remediation_config.add_child("ip routing") + future_config = running_config.future(remediation_config) + + assert future_config.get_child(equals="ip routing") is None + assert future_config.get_child(equals="no ip routing") is None + + +def test_future_self_child_not_in_negated_or_recursed() -> None: + """Test _future when self_child is not in negated_or_recursed.""" + platform = Platform.CISCO_IOS + running_config = get_hconfig(platform) + running_config.add_child("hostname router1") + running_config.add_child("interface GigabitEthernet0/0") + remediation_config = get_hconfig(platform) + remediation_config.add_child("hostname router2") + future_config = running_config.future(remediation_config) + + assert future_config.get_child(equals="hostname router2") is not None + assert future_config.get_child(equals="interface GigabitEthernet0/0") is not None + + +def test_future_with_idempotent_command() -> None: + """Test _future with idempotent command.""" + platform = Platform.HP_PROCURVE + running_config = get_hconfig(platform) + interface = running_config.add_child("interface 1/1") + interface.add_child("untagged vlan 1") + remediation_config = get_hconfig(platform) + remediation_interface = remediation_config.add_child("interface 1/1") + remediation_interface.add_child("untagged vlan 2") + future_config = running_config.future(remediation_config) + future_interface = future_config.get_child(equals="interface 1/1") + + assert future_interface is not None + assert future_interface.get_child(equals="untagged vlan 2") is not None + + +def test_sectional_exit_text_parent_level_cisco_xr() -> None: + """Test sectional_exit_text_parent_level returns True for Cisco XR configs with parent-level exit text.""" + platform = Platform.CISCO_XR + config = get_hconfig(platform) + + # Test route-policy which has exit_text_parent_level=True + route_policy = config.add_child("route-policy TEST") + assert route_policy.sectional_exit_text_parent_level is True + + # Test prefix-set which has exit_text_parent_level=True + prefix_set = config.add_child("prefix-set TEST") + assert prefix_set.sectional_exit_text_parent_level is True + + # Test policy-map which has exit_text_parent_level=True + policy_map = config.add_child("policy-map TEST") + assert policy_map.sectional_exit_text_parent_level is True + + # Test class-map which has exit_text_parent_level=True + class_map = config.add_child("class-map TEST") + assert class_map.sectional_exit_text_parent_level is True + + # Test community-set which has exit_text_parent_level=True + community_set = config.add_child("community-set TEST") + assert community_set.sectional_exit_text_parent_level is True + + # Test extcommunity-set which has exit_text_parent_level=True + extcommunity_set = config.add_child("extcommunity-set TEST") + assert extcommunity_set.sectional_exit_text_parent_level is True + + # Test template which has exit_text_parent_level=True + template = config.add_child("template TEST") + assert template.sectional_exit_text_parent_level is True + + +def test_sectional_exit_text_parent_level_cisco_xr_false() -> None: + """Test sectional_exit_text_parent_level returns False for Cisco XR configs without parent-level exit text.""" + platform = Platform.CISCO_XR + config = get_hconfig(platform) + + # Test interface which has exit_text_parent_level=False (default) + interface = config.add_child("interface GigabitEthernet0/0/0/0") + assert interface.sectional_exit_text_parent_level is False + + # Test router bgp which has exit_text_parent_level=False (default) + router_bgp = config.add_child("router bgp 65000") + assert router_bgp.sectional_exit_text_parent_level is False + + +def test_sectional_exit_text_parent_level_cisco_ios() -> None: + """Test sectional_exit_text_parent_level returns False for standard Cisco IOS configs.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + + # Cisco IOS interfaces don't have exit_text_parent_level=True + interface = config.add_child("interface GigabitEthernet0/0") + assert interface.sectional_exit_text_parent_level is False + + # Cisco IOS router configurations don't have exit_text_parent_level=True + router = config.add_child("router ospf 1") + assert router.sectional_exit_text_parent_level is False + + # Standard configuration sections + line = config.add_child("line vty 0 4") + assert line.sectional_exit_text_parent_level is False + + +def test_sectional_exit_text_parent_level_no_match() -> None: + """Test sectional_exit_text_parent_level returns False when no rules match.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + + # A child that doesn't match any sectional_exiting rules + hostname = config.add_child("hostname TEST") + assert hostname.sectional_exit_text_parent_level is False + + # A simple config line without children + ntp = config.add_child("ntp server 10.0.0.1") + assert ntp.sectional_exit_text_parent_level is False + + +def test_sectional_exit_text_parent_level_with_nested_children() -> None: + """Test sectional_exit_text_parent_level with nested child configurations.""" + platform = Platform.CISCO_XR + config = get_hconfig(platform) + + # Create a route-policy with nested children + route_policy = config.add_child("route-policy TEST") + if_statement = route_policy.add_child("if destination in (192.0.2.0/24) then") + + # Parent (route-policy) should have exit_text_parent_level=True + assert route_policy.sectional_exit_text_parent_level is True + + # Nested child should not match the sectional_exiting rule for route-policy + assert if_statement.sectional_exit_text_parent_level is False + + +def test_sectional_exit_text_parent_level_indentation_in_lines() -> None: + """Test that sectional_exit_text_parent_level affects indentation in lines output.""" + platform = Platform.CISCO_XR + config = get_hconfig(platform) + + # Create a route-policy with children - exit text should be at parent level (depth - 1) + route_policy = config.add_child("route-policy TEST") + route_policy.add_child("set local-preference 200") + route_policy.add_child("pass") + + # Get lines with sectional_exiting=True + lines = list(config.lines(sectional_exiting=True)) + + # The last line should be "end-policy" at depth 0 (parent level) + # route-policy is at depth 1, so exit text at depth 0 means no indentation + assert lines[-1] == "end-policy" + assert not lines[-1].startswith(" ") + + +def test_sectional_exit_text_parent_level_generic_platform() -> None: + """Test sectional_exit_text_parent_level with generic platform.""" + platform = Platform.GENERIC + config = get_hconfig(platform) + + # Generic platform has no specific sectional_exiting rules with parent_level=True + section = config.add_child("section test") + assert section.sectional_exit_text_parent_level is False diff --git a/tests/test_unused_objects.py b/tests/integration/test_unused_objects.py similarity index 95% rename from tests/test_unused_objects.py rename to tests/integration/test_unused_objects.py index f784bdb0..845acf08 100644 --- a/tests/test_unused_objects.py +++ b/tests/integration/test_unused_objects.py @@ -7,7 +7,7 @@ ) from hier_config.platforms.driver_base import HConfigDriverRules from hier_config.platforms.generic.driver import HConfigDriverGeneric -from hier_config.utils import load_hconfig_v2_options +from hier_config.utils import load_driver_rules def _make_driver( @@ -125,9 +125,9 @@ def test_multiple_reference_locations() -> None: assert unused == ["route-policy UNUSED_POLICY"] -def test_unused_objects_via_v2_options() -> None: - """Test unused object detection loaded via load_hconfig_v2_options.""" - v2_options: dict[str, object] = { +def test_unused_objects_via_load_driver_rules() -> None: + """Test unused object detection loaded via load_driver_rules.""" + options: dict[str, object] = { "unused_objects": [ { "lineage": [{"startswith": "ipv4 access-list "}], @@ -141,7 +141,7 @@ def test_unused_objects_via_v2_options() -> None: }, ], } - driver = load_hconfig_v2_options(v2_options, Platform.CISCO_XR) + driver = load_driver_rules(options, Platform.CISCO_XR) config = get_hconfig_fast_load( driver, ( diff --git a/tests/test_various.py b/tests/integration/test_various.py similarity index 86% rename from tests/test_various.py rename to tests/integration/test_various.py index 3c0257b2..7b96c6cc 100644 --- a/tests/test_various.py +++ b/tests/integration/test_various.py @@ -14,7 +14,7 @@ def test_issue104() -> None: platform = Platform.CISCO_NXOS running_config = get_hconfig(get_hconfig_driver(platform), running_config_raw) generated_config = get_hconfig(get_hconfig_driver(platform), generated_config_raw) - remediation_config = running_config.config_to_get_to(generated_config) + remediation_config = running_config.remediation(generated_config) expected_rem_lines = { "no tacacs-server deadtime 3", "no tacacs-server host 192.168.1.99 key 7 Test12345", @@ -22,6 +22,6 @@ def test_issue104() -> None: "tacacs-server host 192.168.100.98 key 0 test135 timeout 3", } remediation_lines = { - line.cisco_style_text() for line in remediation_config.all_children() + line.indented_text() for line in remediation_config.all_children() } assert expected_rem_lines == remediation_lines diff --git a/tests/test_driver_vyos.py b/tests/integration/test_vyos.py similarity index 62% rename from tests/test_driver_vyos.py rename to tests/integration/test_vyos.py index 3b1af754..8eae12e3 100644 --- a/tests/test_driver_vyos.py +++ b/tests/integration/test_vyos.py @@ -1,7 +1,5 @@ from hier_config import WorkflowRemediation, get_hconfig, get_hconfig_fast_load -from hier_config.child import HConfigChild from hier_config.models import Platform -from hier_config.platforms.vyos.driver import HConfigDriverVYOS def test_vyos_basic_remediation() -> None: @@ -19,87 +17,6 @@ def test_vyos_basic_remediation() -> None: assert workflow_remediation.remediation_config_filtered_text() == remediation_str -def test_swap_negation_delete_to_set() -> None: - """Test swapping from 'delete' to 'set' prefix (covers lines 9-11).""" - platform = Platform.VYOS - driver = HConfigDriverVYOS() - root = get_hconfig(platform) - - # Create a child with 'delete' prefix - child = HConfigChild(root, "delete interfaces ethernet eth0 address 192.168.1.1/24") - - # Swap negation should convert to 'set' - result = driver.swap_negation(child) - - assert result.text == "set interfaces ethernet eth0 address 192.168.1.1/24" - assert result.text.startswith("set ") - - -def test_swap_negation_set_to_delete() -> None: - """Test swapping from 'set' to 'delete' prefix (covers lines 10, 12).""" - platform = Platform.VYOS - driver = HConfigDriverVYOS() - root = get_hconfig(platform) - - # Create a child with 'set' prefix - child = HConfigChild(root, "set interfaces ethernet eth0 address 192.168.1.1/24") - - # Swap negation should convert to 'delete' - result = driver.swap_negation(child) - - assert result.text == "delete interfaces ethernet eth0 address 192.168.1.1/24" - assert result.text.startswith("delete ") - - -def test_swap_negation_no_prefix() -> None: - """Test swap_negation behavior when text has neither prefix (covers VyOS-specific behavior).""" - platform = Platform.VYOS - driver = HConfigDriverVYOS() - root = get_hconfig(platform) - - # Create a child without proper prefix - child = HConfigChild(root, "interfaces ethernet eth0 address 192.168.1.1/24") - original_text = child.text - - # VyOS driver doesn't raise an error, it just returns the child unchanged - result = driver.swap_negation(child) - - # Text should remain unchanged since neither if/elif matched - assert result.text == original_text - - -def test_declaration_prefix() -> None: - """Test declaration_prefix property (covers line 18).""" - driver = HConfigDriverVYOS() - assert driver.declaration_prefix == "set " - - -def test_negation_prefix() -> None: - """Test negation_prefix property (covers line 22).""" - driver = HConfigDriverVYOS() - assert driver.negation_prefix == "delete " - - -def test_config_preprocessor() -> None: - """Test config_preprocessor with hierarchical VyOS config (covers line 26).""" - hierarchical_config = """interfaces { - ethernet eth0 { - address 192.168.1.1/24 - description "WAN Interface" - } -} -system { - host-name vyos-router -}""" - - result = HConfigDriverVYOS.config_preprocessor(hierarchical_config) - - # Should convert to set commands - assert "set interfaces ethernet eth0 address 192.168.1.1/24" in result - assert "set interfaces ethernet eth0 description" in result - assert "set system host-name vyos-router" in result - - def test_interface_address_addition_scenario() -> None: """Test adding an interface address.""" platform = Platform.VYOS @@ -114,8 +31,8 @@ def test_interface_address_addition_scenario() -> None: "set interfaces ethernet eth0 address 192.168.1.2/24", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "set interfaces ethernet eth0 address 192.168.1.2/24", ) @@ -137,8 +54,8 @@ def test_interface_description_modification_scenario() -> None: "set interfaces ethernet eth0 description New Description", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "delete interfaces ethernet eth0 description Old Description", "set interfaces ethernet eth0 description New Description", ) @@ -162,8 +79,8 @@ def test_interface_removal_scenario() -> None: "set interfaces ethernet eth0 description WAN Interface", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "delete interfaces ethernet eth1 address 10.0.0.1/24", ) @@ -186,8 +103,8 @@ def test_system_configuration_scenario() -> None: "set system time-zone America/New_York", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "delete system host-name old-vyos-router", "set system host-name new-vyos-router", "set system time-zone America/New_York", @@ -205,13 +122,13 @@ def test_empty_to_basic_config_scenario() -> None: "set interfaces ethernet eth0 address 192.168.1.1/24", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "set system host-name test-router", "set interfaces ethernet eth0 address 192.168.1.1/24", ) future_config = running_config.future(remediation_config) - assert future_config.dump_simple() == ( + assert future_config.to_lines() == ( "set system host-name test-router", "set interfaces ethernet eth0 address 192.168.1.1/24", ) @@ -236,8 +153,8 @@ def test_nat_configuration_scenario() -> None: "set nat source rule 10 translation address masquerade", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "delete nat source rule 10 source address 192.168.1.0/24", "set nat source rule 10 source address 192.168.2.0/24", ) @@ -263,8 +180,8 @@ def test_firewall_rule_scenario() -> None: "set firewall name WAN_LOCAL rule 10 state related enable", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "set firewall name WAN_LOCAL rule 10 state related enable", ) @@ -280,8 +197,8 @@ def test_ipv6_address_configuration_scenario() -> None: platform, ("set interfaces ethernet eth0 address 2001:db8:2::1/64",), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "delete interfaces ethernet eth0 address 2001:db8:1::1/64", "set interfaces ethernet eth0 address 2001:db8:2::1/64", ) diff --git a/tests/test_juniper_syntax.py b/tests/test_juniper_syntax.py deleted file mode 100644 index bc2ccb11..00000000 --- a/tests/test_juniper_syntax.py +++ /dev/null @@ -1,49 +0,0 @@ -from hier_config import WorkflowRemediation, get_hconfig, get_hconfig_fast_load -from hier_config.models import Platform - - -def test_junos_basic_remediation() -> None: - platform = Platform.JUNIPER_JUNOS - running_config_str = "set vlans switch_mgmt_10.0.2.0/24 vlan-id 2" - generated_config_str = "set vlans switch_mgmt_10.0.3.0/24 vlan-id 3" - remediation_str = "delete vlans switch_mgmt_10.0.2.0/24 vlan-id 2\nset vlans switch_mgmt_10.0.3.0/24 vlan-id 3" - - workflow_remediation = WorkflowRemediation( - get_hconfig_fast_load(platform, running_config_str), - get_hconfig_fast_load(platform, generated_config_str), - ) - - assert workflow_remediation.remediation_config_filtered_text() == remediation_str - - -def test_junos_convert_to_set( - running_config_junos: str, - generated_config_junos: str, - remediation_config_flat_junos: str, -) -> None: - platform = Platform.JUNIPER_JUNOS - workflow_remediation = WorkflowRemediation( - get_hconfig(platform, running_config_junos), - get_hconfig(platform, generated_config_junos), - ) - - assert ( - workflow_remediation.remediation_config_filtered_text() - == remediation_config_flat_junos - ) - - -def test_flat_junos_remediation( - running_config_flat_junos: str, - generated_config_flat_junos: str, - remediation_config_flat_junos: str, -) -> None: - platform = Platform.JUNIPER_JUNOS - workflow_remediation = WorkflowRemediation( - get_hconfig_fast_load(platform, running_config_flat_junos), - get_hconfig_fast_load(platform, generated_config_flat_junos), - ) - - remediation_list = remediation_config_flat_junos.splitlines() - for line in str(workflow_remediation.remediation_config).splitlines(): - assert line in remediation_list diff --git a/tests/test_xr_comments.py b/tests/test_xr_comments.py deleted file mode 100644 index d16db673..00000000 --- a/tests/test_xr_comments.py +++ /dev/null @@ -1,144 +0,0 @@ -from hier_config import get_hconfig, get_hconfig_fast_load -from hier_config.models import Platform - - -def test_xr_comment_attached_to_next_sibling() -> None: - """IOS-XR inline comments are attached to the next sibling's comments set.""" - config = get_hconfig( - Platform.CISCO_XR, - """\ -router isis backbone - ! ISIS network number should be encoded with 0-padded loopback IP - net 49.0001.1921.2022.0222.00 -""", - ) - router_isis = config.get_child(equals="router isis backbone") - assert router_isis is not None - net_child = router_isis.get_child( - startswith="net ", - ) - assert net_child is not None - assert ( - "ISIS network number should be encoded with 0-padded loopback IP" - in net_child.comments - ) - - -def test_xr_multiple_comments_before_line() -> None: - """Multiple consecutive comment lines are all attached to the next sibling.""" - config = get_hconfig( - Platform.CISCO_XR, - """\ -router isis backbone - ! first comment - ! second comment - net 49.0001.1921.2022.0222.00 -""", - ) - router_isis = config.get_child(equals="router isis backbone") - assert router_isis is not None - net_child = router_isis.get_child(startswith="net ") - assert net_child is not None - assert "first comment" in net_child.comments - assert "second comment" in net_child.comments - - -def test_xr_comment_lines_not_parsed_as_children() -> None: - """Comment lines starting with ! should not appear as config children.""" - config = get_hconfig( - Platform.CISCO_XR, - """\ -router isis backbone - ! this is a comment - net 49.0001.1921.2022.0222.00 -""", - ) - router_isis = config.get_child(equals="router isis backbone") - assert router_isis is not None - for child in router_isis.all_children(): - assert not child.text.startswith("!") - - -def test_xr_top_level_bang_delimiters_stripped() -> None: - """Top-level ! delimiters (with no comment text) are stripped.""" - config = get_hconfig( - Platform.CISCO_XR, - """\ -hostname router1 -! -interface GigabitEthernet0/0/0/0 - description test -! -""", - ) - children = [child.text for child in config.children] - assert "hostname router1" in children - assert "interface GigabitEthernet0/0/0/0" in children - assert "!" not in children - - -def test_xr_comment_preservation_with_fast_load() -> None: - """Comments are also preserved when using get_hconfig_fast_load.""" - config = get_hconfig_fast_load( - Platform.CISCO_XR, - ( - "router isis backbone", - " ! loopback comment", - " net 49.0001.0000.0000.0001.00", - ), - ) - router_isis = config.get_child(equals="router isis backbone") - assert router_isis is not None - net_child = router_isis.get_child(startswith="net ") - assert net_child is not None - assert "loopback comment" in net_child.comments - - -def test_xr_hash_comments_still_stripped() -> None: - """Lines starting with # are still stripped (not preserved).""" - config = get_hconfig( - Platform.CISCO_XR, - """\ -hostname router1 -# this should be stripped -interface GigabitEthernet0/0/0/0 -""", - ) - for child in config.all_children(): - assert not child.text.startswith("#") - - -def test_xr_comment_with_leading_bang_preserved() -> None: - """A comment containing ! in its body is preserved correctly.""" - config = get_hconfig( - Platform.CISCO_XR, - """\ -router isis backbone - ! !important note about ISIS - net 49.0001.1921.2022.0222.00 -""", - ) - router_isis = config.get_child(equals="router isis backbone") - assert router_isis is not None - net_child = router_isis.get_child(startswith="net ") - assert net_child is not None - assert "!important note about ISIS" in net_child.comments - - -def test_xr_trailing_comment_with_no_following_sibling_is_dropped() -> None: - """A trailing ! comment at the end of a section with no following sibling is silently dropped.""" - config = get_hconfig( - Platform.CISCO_XR, - """\ -router isis backbone - net 49.0001.1921.2022.0222.00 - ! trailing comment with no following sibling -""", - ) - router_isis = config.get_child(equals="router isis backbone") - assert router_isis is not None - net_child = router_isis.get_child(startswith="net ") - assert net_child is not None - assert len(net_child.comments) == 0 - for child in router_isis.all_children(): - assert not child.text.startswith("!") diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/platforms/__init__.py b/tests/unit/platforms/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/platforms/test_cisco_ios.py b/tests/unit/platforms/test_cisco_ios.py new file mode 100644 index 00000000..8adcb4fd --- /dev/null +++ b/tests/unit/platforms/test_cisco_ios.py @@ -0,0 +1,41 @@ +from hier_config.constructors import get_hconfig +from hier_config.models import Platform + + +def test_rm_ipv6_acl_sequence_numbers() -> None: + """Test post-load callback that removes IPv6 ACL sequence numbers.""" + platform = Platform.CISCO_IOS + config_text = "ipv6 access-list TEST_IPV6_ACL\n sequence 10 permit tcp any any eq 443\n sequence 20 deny ipv6 any any" + config = get_hconfig(platform, config_text) + acl = config.get_child(equals="ipv6 access-list TEST_IPV6_ACL") + + assert acl is not None + assert acl.get_child(equals="permit tcp any any eq 443") is not None + assert acl.get_child(equals="deny ipv6 any any") is not None + assert acl.get_child(startswith="sequence") is None + + +def test_remove_ipv4_acl_remarks() -> None: + """Test post-load callback that removes IPv4 ACL remarks.""" + platform = Platform.CISCO_IOS + config_text = "ip access-list extended TEST_ACL\n remark Allow HTTPS traffic\n permit tcp any any eq 443\n remark Block all other traffic\n deny ip any any" + config = get_hconfig(platform, config_text) + acl = config.get_child(equals="ip access-list extended TEST_ACL") + + assert acl is not None + assert acl.get_child(equals="10 permit tcp any any eq 443") is not None + assert acl.get_child(equals="20 deny ip any any") is not None + assert acl.get_child(startswith="remark") is None + + +def test_add_acl_sequence_numbers() -> None: + """Test post-load callback that adds sequence numbers to IPv4 ACLs.""" + platform = Platform.CISCO_IOS + config_text = "ip access-list extended TEST_ACL\n permit tcp any any eq 443\n permit tcp any any eq 80\n deny ip any any" + config = get_hconfig(platform, config_text) + acl = config.get_child(equals="ip access-list extended TEST_ACL") + + assert acl is not None + assert acl.get_child(equals="10 permit tcp any any eq 443") is not None + assert acl.get_child(equals="20 permit tcp any any eq 80") is not None + assert acl.get_child(equals="30 deny ip any any") is not None diff --git a/tests/unit/platforms/test_cisco_xr.py b/tests/unit/platforms/test_cisco_xr.py new file mode 100644 index 00000000..b75ca631 --- /dev/null +++ b/tests/unit/platforms/test_cisco_xr.py @@ -0,0 +1,468 @@ +from hier_config import get_hconfig, get_hconfig_fast_load +from hier_config.models import Platform + + +def test_multiple_groups_no_duplicate_child_error() -> None: + """Test that multiple group blocks don't raise DuplicateChildError (issue #209).""" + platform = Platform.CISCO_XR + config_text = """\ +hostname router1 +group core + interface 'Bundle-Ether.*' + mtu 9188 + ! +end-group +group edge + interface 'Bundle-Ether.*' + mtu 9092 + ! +end-group +""" + hconfig = get_hconfig(platform, config_text) + children = [child.text for child in hconfig.children] + assert "hostname router1" in children + assert "group core" in children + assert "group edge" in children + + +def test_sectional_exit_text_parent_level_route_policy() -> None: + """Test that route-policy exit text appears at parent level (no indentation).""" + platform = Platform.CISCO_XR + config = get_hconfig_fast_load( + platform, + ( + "route-policy TEST", + " set local-preference 200", + " pass", + ), + ) + + route_policy = config.get_child(equals="route-policy TEST") + assert route_policy is not None + assert route_policy.sectional_exit_text_parent_level is True + + output = config.to_lines(sectional_exiting=True) + assert output == ( + "route-policy TEST", + " set local-preference 200", + " pass", + "end-policy", + ) + + +def test_sectional_exit_text_parent_level_prefix_set() -> None: + """Test that prefix-set exit text appears at parent level (no indentation).""" + platform = Platform.CISCO_XR + config = get_hconfig_fast_load( + platform, + ( + "prefix-set TEST_PREFIX", + " 192.0.2.0/24", + " 198.51.100.0/24", + ), + ) + + prefix_set = config.get_child(equals="prefix-set TEST_PREFIX") + assert prefix_set is not None + assert prefix_set.sectional_exit_text_parent_level is True + + output = config.to_lines(sectional_exiting=True) + assert output == ( + "prefix-set TEST_PREFIX", + " 192.0.2.0/24", + " 198.51.100.0/24", + "end-set", + ) + + +def test_sectional_exit_text_parent_level_policy_map() -> None: + """Test that policy-map exit text appears at parent level (no indentation).""" + platform = Platform.CISCO_XR + config = get_hconfig_fast_load( + platform, + ( + "policy-map TEST_POLICY", + " class TEST_CLASS", + " set precedence 5", + ), + ) + + policy_map = config.get_child(equals="policy-map TEST_POLICY") + assert policy_map is not None + assert policy_map.sectional_exit_text_parent_level is True + + output = config.to_lines(sectional_exiting=True) + assert output == ( + "policy-map TEST_POLICY", + " class TEST_CLASS", + " set precedence 5", + " exit", + "end-policy-map", + ) + + +def test_sectional_exit_text_parent_level_class_map() -> None: + """Test that class-map exit text appears at parent level (no indentation).""" + platform = Platform.CISCO_XR + config = get_hconfig_fast_load( + platform, + ( + "class-map match-any TEST_CLASS", + " match access-group TEST_ACL", + ), + ) + + class_map = config.get_child(equals="class-map match-any TEST_CLASS") + assert class_map is not None + assert class_map.sectional_exit_text_parent_level is True + + output = config.to_lines(sectional_exiting=True) + assert output == ( + "class-map match-any TEST_CLASS", + " match access-group TEST_ACL", + "end-class-map", + ) + + +def test_sectional_exit_text_parent_level_community_set() -> None: + """Test that community-set exit text appears at parent level (no indentation).""" + platform = Platform.CISCO_XR + config = get_hconfig_fast_load( + platform, + ( + "community-set TEST_COMM", + " 65001:100", + " 65001:200", + ), + ) + + community_set = config.get_child(equals="community-set TEST_COMM") + assert community_set is not None + assert community_set.sectional_exit_text_parent_level is True + + output = config.to_lines(sectional_exiting=True) + assert output == ( + "community-set TEST_COMM", + " 65001:100", + " 65001:200", + "end-set", + ) + + +def test_sectional_exit_text_parent_level_extcommunity_set() -> None: + """Test that extcommunity-set exit text appears at parent level (no indentation).""" + platform = Platform.CISCO_XR + config = get_hconfig_fast_load( + platform, + ( + "extcommunity-set rt TEST_RT", + " 1:100", + " 2:200", + ), + ) + + extcommunity_set = config.get_child(equals="extcommunity-set rt TEST_RT") + assert extcommunity_set is not None + assert extcommunity_set.sectional_exit_text_parent_level is True + + output = config.to_lines(sectional_exiting=True) + assert output == ( + "extcommunity-set rt TEST_RT", + " 1:100", + " 2:200", + "end-set", + ) + + +def test_sectional_exit_text_parent_level_template() -> None: + """Test that template exit text appears at parent level (no indentation).""" + platform = Platform.CISCO_XR + config = get_hconfig_fast_load( + platform, + ( + "template TEST_TEMPLATE", + " description test template", + ), + ) + + template = config.get_child(equals="template TEST_TEMPLATE") + assert template is not None + assert template.sectional_exit_text_parent_level is True + + output = config.to_lines(sectional_exiting=True) + assert output == ( + "template TEST_TEMPLATE", + " description test template", + "end-template", + ) + + +def test_sectional_exit_text_current_level_interface() -> None: + """Test that interface exit text appears at current level (with indentation).""" + platform = Platform.CISCO_XR + config = get_hconfig_fast_load( + platform, + ( + "interface GigabitEthernet0/0/0/0", + " description test interface", + " ipv4 address 192.0.2.1 255.255.255.0", + ), + ) + + interface = config.get_child(equals="interface GigabitEthernet0/0/0/0") + assert interface is not None + assert interface.sectional_exit_text_parent_level is False + + output = config.to_lines(sectional_exiting=True) + assert output == ( + "interface GigabitEthernet0/0/0/0", + " description test interface", + " ipv4 address 192.0.2.1 255.255.255.0", + " root", + ) + + +def test_sectional_exit_text_current_level_router_bgp() -> None: + """Test that router bgp exit text appears at current level (with indentation).""" + platform = Platform.CISCO_XR + config = get_hconfig_fast_load( + platform, + ( + "router bgp 65000", + " bgp router-id 192.0.2.1", + " address-family ipv4 unicast", + ), + ) + + router_bgp = config.get_child(equals="router bgp 65000") + assert router_bgp is not None + assert router_bgp.sectional_exit_text_parent_level is False + + output = config.to_lines(sectional_exiting=True) + assert output == ( + "router bgp 65000", + " bgp router-id 192.0.2.1", + " address-family ipv4 unicast", + " root", + ) + + +def test_sectional_exit_text_multiple_sections() -> None: + """Test multiple sections with different exit text level behaviors.""" + platform = Platform.CISCO_XR + config = get_hconfig_fast_load( + platform, + ( + "route-policy TEST1", + " pass", + "!", + "interface GigabitEthernet0/0/0/0", + " description test", + "!", + "prefix-set TEST_PREFIX", + " 192.0.2.0/24", + ), + ) + + route_policy = config.get_child(equals="route-policy TEST1") + assert route_policy is not None + assert route_policy.sectional_exit_text_parent_level is True + + interface = config.get_child(equals="interface GigabitEthernet0/0/0/0") + assert interface is not None + assert interface.sectional_exit_text_parent_level is False + + prefix_set = config.get_child(equals="prefix-set TEST_PREFIX") + assert prefix_set is not None + assert prefix_set.sectional_exit_text_parent_level is True + + output = config.to_lines(sectional_exiting=True) + assert output == ( + "route-policy TEST1", + " pass", + "end-policy", + "interface GigabitEthernet0/0/0/0", + " description test", + " root", + "prefix-set TEST_PREFIX", + " 192.0.2.0/24", + "end-set", + ) + + +def test_indented_bang_section_separators_no_duplicate_child_error() -> None: + """Test that indented ! section separators don't raise DuplicateChildError (issue #231).""" + platform = Platform.CISCO_XR + config_text = """\ +telemetry model-driven + destination-group DEST-GROUP-1 + address-family ipv4 10.0.0.1 port 57000 + encoding self-describing-gpb + protocol tcp + ! + ! + destination-group DEST-GROUP-2 + address-family ipv4 10.0.0.2 port 57000 + encoding self-describing-gpb + protocol tcp + ! + ! + sensor-group SENSOR-1 + sensor-path openconfig-platform:components/component/cpu + sensor-path openconfig-platform:components/component/memory + ! + sensor-group SENSOR-2 + sensor-path openconfig-interfaces:interfaces/interface/state/counters + ! +! +""" + hconfig = get_hconfig(platform, config_text) + telemetry = hconfig.get_child(equals="telemetry model-driven") + assert telemetry is not None + child_texts = [child.text for child in telemetry.children] + assert "destination-group DEST-GROUP-1" in child_texts + assert "destination-group DEST-GROUP-2" in child_texts + assert "sensor-group SENSOR-1" in child_texts + assert "sensor-group SENSOR-2" in child_texts + + +def test_xr_comment_attached_to_next_sibling() -> None: + """IOS-XR inline comments are attached to the next sibling's comments set.""" + config = get_hconfig( + Platform.CISCO_XR, + """\ +router isis backbone + ! ISIS network number should be encoded with 0-padded loopback IP + net 49.0001.1921.2022.0222.00 +""", + ) + router_isis = config.get_child(equals="router isis backbone") + assert router_isis is not None + net_child = router_isis.get_child( + startswith="net ", + ) + assert net_child is not None + assert ( + "ISIS network number should be encoded with 0-padded loopback IP" + in net_child.comments + ) + + +def test_xr_multiple_comments_before_line() -> None: + """Multiple consecutive comment lines are all attached to the next sibling.""" + config = get_hconfig( + Platform.CISCO_XR, + """\ +router isis backbone + ! first comment + ! second comment + net 49.0001.1921.2022.0222.00 +""", + ) + router_isis = config.get_child(equals="router isis backbone") + assert router_isis is not None + net_child = router_isis.get_child(startswith="net ") + assert net_child is not None + assert "first comment" in net_child.comments + assert "second comment" in net_child.comments + + +def test_xr_comment_lines_not_parsed_as_children() -> None: + """Comment lines starting with ! should not appear as config children.""" + config = get_hconfig( + Platform.CISCO_XR, + """\ +router isis backbone + ! this is a comment + net 49.0001.1921.2022.0222.00 +""", + ) + router_isis = config.get_child(equals="router isis backbone") + assert router_isis is not None + for child in router_isis.all_children(): + assert not child.text.startswith("!") + + +def test_xr_top_level_bang_delimiters_stripped() -> None: + """Top-level ! delimiters (with no comment text) are stripped.""" + config = get_hconfig( + Platform.CISCO_XR, + """\ +hostname router1 +! +interface GigabitEthernet0/0/0/0 + description test +! +""", + ) + children = [child.text for child in config.children] + assert "hostname router1" in children + assert "interface GigabitEthernet0/0/0/0" in children + assert "!" not in children + + +def test_xr_comment_preservation_with_fast_load() -> None: + """Comments are also preserved when using get_hconfig_fast_load.""" + config = get_hconfig_fast_load( + Platform.CISCO_XR, + ( + "router isis backbone", + " ! loopback comment", + " net 49.0001.0000.0000.0001.00", + ), + ) + router_isis = config.get_child(equals="router isis backbone") + assert router_isis is not None + net_child = router_isis.get_child(startswith="net ") + assert net_child is not None + assert "loopback comment" in net_child.comments + + +def test_xr_hash_comments_still_stripped() -> None: + """Lines starting with # are still stripped (not preserved).""" + config = get_hconfig( + Platform.CISCO_XR, + """\ +hostname router1 +# this should be stripped +interface GigabitEthernet0/0/0/0 +""", + ) + for child in config.all_children(): + assert not child.text.startswith("#") + + +def test_xr_comment_with_leading_bang_preserved() -> None: + """A comment containing ! in its body is preserved correctly.""" + config = get_hconfig( + Platform.CISCO_XR, + """\ +router isis backbone + ! !important note about ISIS + net 49.0001.1921.2022.0222.00 +""", + ) + router_isis = config.get_child(equals="router isis backbone") + assert router_isis is not None + net_child = router_isis.get_child(startswith="net ") + assert net_child is not None + assert "!important note about ISIS" in net_child.comments + + +def test_xr_trailing_comment_with_no_following_sibling_is_dropped() -> None: + """A trailing ! comment at the end of a section with no following sibling is silently dropped.""" + config = get_hconfig( + Platform.CISCO_XR, + """\ +router isis backbone + net 49.0001.1921.2022.0222.00 + ! trailing comment with no following sibling +""", + ) + router_isis = config.get_child(equals="router isis backbone") + assert router_isis is not None + net_child = router_isis.get_child(startswith="net ") + assert net_child is not None + assert len(net_child.comments) == 0 + for child in router_isis.all_children(): + assert not child.text.startswith("!") diff --git a/tests/test_driver.py b/tests/unit/platforms/test_driver_base.py similarity index 100% rename from tests/test_driver.py rename to tests/unit/platforms/test_driver_base.py diff --git a/tests/unit/platforms/test_fortinet_fortios.py b/tests/unit/platforms/test_fortinet_fortios.py new file mode 100644 index 00000000..a162e7fd --- /dev/null +++ b/tests/unit/platforms/test_fortinet_fortios.py @@ -0,0 +1,18 @@ +from hier_config.child import HConfigChild +from hier_config.constructors import get_hconfig +from hier_config.models import Platform +from hier_config.platforms.fortinet_fortios.driver import HConfigDriverFortinetFortiOS + + +def test_swap_negation_direct() -> None: + """Test swap_negation method directly to cover set-to-unset conversion.""" + driver = HConfigDriverFortinetFortiOS() + config = get_hconfig(Platform.FORTINET_FORTIOS) + child = HConfigChild(config, "set description 'test value'") + result = driver.swap_negation(child) + assert result.text == "unset description" + + child2 = HConfigChild(config, "unset description") + result2 = driver.swap_negation(child2) + + assert result2.text == "set description" diff --git a/tests/test_driver_hp_procurve.py b/tests/unit/platforms/test_hp_procurve.py similarity index 52% rename from tests/test_driver_hp_procurve.py rename to tests/unit/platforms/test_hp_procurve.py index e3221946..bd62669b 100644 --- a/tests/test_driver_hp_procurve.py +++ b/tests/unit/platforms/test_hp_procurve.py @@ -1,87 +1,7 @@ -from hier_config import get_hconfig_fast_load from hier_config.constructors import get_hconfig from hier_config.models import Platform -def test_negate_with() -> None: - platform = Platform.HP_PROCURVE - running_config = get_hconfig_fast_load( - platform, - ( - "aaa port-access authenticator 1/1 tx-period 3", - "aaa port-access authenticator 1/1 supplicant-timeout 3", - "aaa port-access authenticator 1/1 client-limit 4", - "aaa port-access mac-based 1/1 addr-limit 4", - "aaa port-access mac-based 1/1 logoff-period 3", - 'aaa port-access 1/1 critical-auth user-role "allowall"', - ), - ) - generated_config = get_hconfig(platform) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( - "aaa port-access authenticator 1/1 tx-period 30", - "aaa port-access authenticator 1/1 supplicant-timeout 30", - "no aaa port-access authenticator 1/1 client-limit", - "aaa port-access mac-based 1/1 addr-limit 1", - "aaa port-access mac-based 1/1 logoff-period 300", - "no aaa port-access 1/1 critical-auth user-role", - ) - - -def test_idempotent_for() -> None: - platform = Platform.HP_PROCURVE - running_config = get_hconfig_fast_load( - platform, - ( - "aaa port-access authenticator 1/1 tx-period 3", - "aaa port-access authenticator 1/1 supplicant-timeout 3", - "aaa port-access authenticator 1/1 client-limit 4", - "aaa port-access mac-based 1/1 addr-limit 4", - "aaa port-access mac-based 1/1 logoff-period 3", - 'aaa port-access 1/1 critical-auth user-role "allowall"', - ), - ) - generated_config = get_hconfig_fast_load( - platform, - ( - "aaa port-access authenticator 1/1 tx-period 4", - "aaa port-access authenticator 1/1 supplicant-timeout 4", - "aaa port-access authenticator 1/1 client-limit 5", - "aaa port-access mac-based 1/1 addr-limit 5", - "aaa port-access mac-based 1/1 logoff-period 4", - 'aaa port-access 1/1 critical-auth user-role "allownone"', - ), - ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( - "aaa port-access authenticator 1/1 tx-period 4", - "aaa port-access authenticator 1/1 supplicant-timeout 4", - "aaa port-access authenticator 1/1 client-limit 5", - "aaa port-access mac-based 1/1 addr-limit 5", - "aaa port-access mac-based 1/1 logoff-period 4", - 'aaa port-access 1/1 critical-auth user-role "allownone"', - ) - - -def test_future() -> None: - platform = Platform.HP_PROCURVE - running_config = get_hconfig(platform) - remediation_config = get_hconfig_fast_load( - platform, - ( - "aaa port-access authenticator 3/34", - "aaa port-access authenticator 3/34 tx-period 10", - "aaa port-access authenticator 3/34 supplicant-timeout 10", - "aaa port-access authenticator 3/34 client-limit 2", - "aaa port-access mac-based 3/34", - "aaa port-access mac-based 3/34 addr-limit 2", - 'aaa port-access 3/34 critical-auth user-role "allowall"', - ), - ) - future_config = running_config.future(remediation_config) - assert not tuple(remediation_config.unified_diff(future_config)) - - def test_fixup_aaa_port_access_ranges() -> None: """Test post-load callback that expands interface ranges in AAA port-access commands (covers lines 33-38).""" platform = Platform.HP_PROCURVE @@ -176,47 +96,3 @@ def test_fixup_device_profile_tagged_vlans() -> None: assert device_profile_printer is not None assert device_profile_printer.get_child(equals="tagged-vlan 40") is not None - - -def test_negate_with_child_config() -> None: - """Test negate_with returns None for non-root config without special rule (covers line 166).""" - platform = Platform.HP_PROCURVE - running_config = get_hconfig_fast_load( - platform, - ( - "interface 1/1", - " speed-duplex auto", - ), - ) - generated_config = get_hconfig_fast_load( - platform, - ("interface 1/1",), - ) - remediation_config = running_config.config_to_get_to(generated_config) - - assert remediation_config.dump_simple() == ( - "interface 1/1", - " no speed-duplex auto", - ) - - -def test_negate_with_from_base_driver() -> None: - """Test negate_with uses parent driver rule when applicable (covers line 163).""" - platform = Platform.HP_PROCURVE - running_config = get_hconfig_fast_load( - platform, - ( - "interface 1/1", - " disable", - ), - ) - generated_config = get_hconfig_fast_load( - platform, - ("interface 1/1",), - ) - remediation_config = running_config.config_to_get_to(generated_config) - - assert remediation_config.dump_simple() == ( - "interface 1/1", - " enable", - ) diff --git a/tests/unit/platforms/test_juniper_junos.py b/tests/unit/platforms/test_juniper_junos.py new file mode 100644 index 00000000..9da0125c --- /dev/null +++ b/tests/unit/platforms/test_juniper_junos.py @@ -0,0 +1,41 @@ +import pytest + +from hier_config.child import HConfigChild +from hier_config.constructors import get_hconfig +from hier_config.models import Platform +from hier_config.platforms.juniper_junos.driver import HConfigDriverJuniperJUNOS + + +def test_swap_negation_delete_to_set() -> None: + """Test swapping from 'delete' to 'set' prefix.""" + platform = Platform.JUNIPER_JUNOS + driver = HConfigDriverJuniperJUNOS() + root = get_hconfig(platform) + child = HConfigChild(root, "delete vlans test_vlan vlan-id 100") + result = driver.swap_negation(child) + assert result.text == "set vlans test_vlan vlan-id 100" + assert result.text.startswith("set ") + + +def test_swap_negation_set_to_delete() -> None: + """Test swapping from 'set' to 'delete' prefix.""" + platform = Platform.JUNIPER_JUNOS + driver = HConfigDriverJuniperJUNOS() + root = get_hconfig(platform) + child = HConfigChild(root, "set vlans test_vlan vlan-id 100") + result = driver.swap_negation(child) + assert result.text == "delete vlans test_vlan vlan-id 100" + assert result.text.startswith("delete ") + + +def test_swap_negation_invalid_prefix() -> None: + """Test ValueError when text has neither 'set' nor 'delete' prefix.""" + platform = Platform.JUNIPER_JUNOS + driver = HConfigDriverJuniperJUNOS() + root = get_hconfig(platform) + child = HConfigChild(root, "vlans test_vlan vlan-id 100") + with pytest.raises(ValueError, match="did not start with") as exc_info: + driver.swap_negation(child) + assert "did not start with" in str(exc_info.value) + assert "delete " in str(exc_info.value) + assert "set " in str(exc_info.value) diff --git a/tests/unit/platforms/test_vyos.py b/tests/unit/platforms/test_vyos.py new file mode 100644 index 00000000..faf23479 --- /dev/null +++ b/tests/unit/platforms/test_vyos.py @@ -0,0 +1,85 @@ +from hier_config import get_hconfig +from hier_config.child import HConfigChild +from hier_config.models import Platform +from hier_config.platforms.vyos.driver import HConfigDriverVYOS + + +def test_swap_negation_delete_to_set() -> None: + """Test swapping from 'delete' to 'set' prefix (covers lines 9-11).""" + platform = Platform.VYOS + driver = HConfigDriverVYOS() + root = get_hconfig(platform) + + # Create a child with 'delete' prefix + child = HConfigChild(root, "delete interfaces ethernet eth0 address 192.168.1.1/24") + + # Swap negation should convert to 'set' + result = driver.swap_negation(child) + + assert result.text == "set interfaces ethernet eth0 address 192.168.1.1/24" + assert result.text.startswith("set ") + + +def test_swap_negation_set_to_delete() -> None: + """Test swapping from 'set' to 'delete' prefix (covers lines 10, 12).""" + platform = Platform.VYOS + driver = HConfigDriverVYOS() + root = get_hconfig(platform) + + # Create a child with 'set' prefix + child = HConfigChild(root, "set interfaces ethernet eth0 address 192.168.1.1/24") + + # Swap negation should convert to 'delete' + result = driver.swap_negation(child) + + assert result.text == "delete interfaces ethernet eth0 address 192.168.1.1/24" + assert result.text.startswith("delete ") + + +def test_swap_negation_no_prefix() -> None: + """Test swap_negation behavior when text has neither prefix (covers VyOS-specific behavior).""" + platform = Platform.VYOS + driver = HConfigDriverVYOS() + root = get_hconfig(platform) + + # Create a child without proper prefix + child = HConfigChild(root, "interfaces ethernet eth0 address 192.168.1.1/24") + original_text = child.text + + # VyOS driver doesn't raise an error, it just returns the child unchanged + result = driver.swap_negation(child) + + # Text should remain unchanged since neither if/elif matched + assert result.text == original_text + + +def test_declaration_prefix() -> None: + """Test declaration_prefix property (covers line 18).""" + driver = HConfigDriverVYOS() + assert driver.declaration_prefix == "set " + + +def test_negation_prefix() -> None: + """Test negation_prefix property (covers line 22).""" + driver = HConfigDriverVYOS() + assert driver.negation_prefix == "delete " + + +def test_config_preprocessor() -> None: + """Test config_preprocessor with hierarchical VyOS config (covers line 26).""" + hierarchical_config = """interfaces { + ethernet eth0 { + address 192.168.1.1/24 + description "WAN Interface" + } +} +system { + host-name vyos-router +}""" + + result = HConfigDriverVYOS.config_preprocessor(hierarchical_config) + + # Should convert to set commands + assert "set interfaces ethernet eth0 address 192.168.1.1/24" in result + assert "set interfaces ethernet eth0 description" in result + assert "set system host-name vyos-router" in result diff --git a/tests/unit/platforms/views/__init__.py b/tests/unit/platforms/views/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/config_view/test_view_arista_eos.py b/tests/unit/platforms/views/test_arista_eos.py similarity index 100% rename from tests/config_view/test_view_arista_eos.py rename to tests/unit/platforms/views/test_arista_eos.py diff --git a/tests/config_view/test_view_cisco_ios.py b/tests/unit/platforms/views/test_cisco_ios.py similarity index 100% rename from tests/config_view/test_view_cisco_ios.py rename to tests/unit/platforms/views/test_cisco_ios.py diff --git a/tests/config_view/test_view_cisco_nxos.py b/tests/unit/platforms/views/test_cisco_nxos.py similarity index 100% rename from tests/config_view/test_view_cisco_nxos.py rename to tests/unit/platforms/views/test_cisco_nxos.py diff --git a/tests/config_view/test_view_cisco_xr.py b/tests/unit/platforms/views/test_cisco_xr.py similarity index 100% rename from tests/config_view/test_view_cisco_xr.py rename to tests/unit/platforms/views/test_cisco_xr.py diff --git a/tests/config_view/test_view_hp_procurve.py b/tests/unit/platforms/views/test_hp_procurve.py similarity index 100% rename from tests/config_view/test_view_hp_procurve.py rename to tests/unit/platforms/views/test_hp_procurve.py diff --git a/tests/config_view/test_interface.py b/tests/unit/platforms/views/test_interface.py similarity index 100% rename from tests/config_view/test_interface.py rename to tests/unit/platforms/views/test_interface.py diff --git a/tests/config_view/test_view.py b/tests/unit/platforms/views/test_view.py similarity index 100% rename from tests/config_view/test_view.py rename to tests/unit/platforms/views/test_view.py diff --git a/tests/test_hier_config.py b/tests/unit/test_child.py similarity index 54% rename from tests/test_hier_config.py rename to tests/unit/test_child.py index 90f70676..cbc314c3 100644 --- a/tests/test_hier_config.py +++ b/tests/unit/test_child.py @@ -1,87 +1,19 @@ -"""Tests for hier_config functionality.""" +"""Tests for HConfigChild functionality.""" # pylint: disable=too-many-lines -import tempfile import types -from pathlib import Path import pytest from hier_config import ( HConfigChild, - WorkflowRemediation, get_hconfig, - get_hconfig_driver, - get_hconfig_fast_load, - get_hconfig_from_dump, ) from hier_config.exceptions import DuplicateChildError 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: - config = get_hconfig(platform_a) - assert config - - -def test_hash(platform_a: Platform) -> None: - config = get_hconfig_fast_load(platform_a, ("interface 1/1", " untagged vlan 5")) - assert hash(config) - - -def test_merge(platform_a: Platform, platform_b: Platform) -> None: - hier1 = get_hconfig(platform_a) - hier1.add_child("interface Vlan2") - hier2 = get_hconfig(platform_b) - hier2.add_child("interface Vlan3") - - assert len(tuple(hier1.all_children())) == 1 - assert len(tuple(hier2.all_children())) == 1 - - hier1.merge(hier2) - - assert len(tuple(hier1.all_children())) == 2 - - -def test_load_from_file(platform_a: Platform) -> None: - config = "interface Vlan2\n ip address 1.1.1.1 255.255.255.0" - - with tempfile.NamedTemporaryFile( - mode="r+", - delete=False, - encoding="utf8", - ) as myfile: - myfile.file.write(config) - myfile.file.flush() - myfile.close() - hier = get_hconfig(get_hconfig_driver(platform_a), Path(myfile.name)) - Path(myfile.name).unlink() - - assert len(tuple(hier.all_children())) == 2 - - -def test_load_from_config_text(platform_a: Platform) -> None: - config = "interface Vlan2\n ip address 1.1.1.1 255.255.255.0" - hier = get_hconfig(get_hconfig_driver(platform_a), config) - assert len(tuple(hier.all_children())) == 2 - - -def test_dump_and_load_from_dump_and_compare(platform_a: Platform) -> None: - hier_pre_dump = get_hconfig(platform_a) - b2 = hier_pre_dump.add_children_deep(("a1", "b2")) - - b2.order_weight = 400 - b2.tags_add("test") - b2.comments.add("test comment") - b2.new_in_config = True - - dump = hier_pre_dump.dump() - hier_post_dump = get_hconfig_from_dump(hier_pre_dump.driver, dump) - - assert hier_pre_dump == hier_post_dump - - def test_add_ancestor_copy_of(platform_a: Platform) -> None: source_config = get_hconfig(platform_a) ipv4_address = source_config.add_children_deep( @@ -98,7 +30,7 @@ def test_depth(platform_a: Platform) -> None: ip_address = get_hconfig(platform_a).add_children_deep( ("interface Vlan2", "ip address 192.168.1.1 255.255.255.0"), ) - assert ip_address.depth() == 2 + assert ip_address.depth == 2 def test_get_child(platform_a: Platform) -> None: @@ -232,19 +164,6 @@ def test_del_child(platform_a: Platform) -> None: assert not tuple(hier1.all_children()) -def test_rebuild_children_dict(platform_a: Platform) -> None: - hier1 = get_hconfig(platform_a) - interface = hier1.add_child("interface Vlan2") - interface.add_children( - ("description switch-mgmt-192.168.1.0/24", "ip address 192.168.1.0/24"), - ) - delta_a = hier1 - hier1.children.rebuild_mapping() - delta_b = hier1 - - assert tuple(delta_a.all_children()) == tuple(delta_b.all_children()) - - def test_add_children(platform_a: Platform) -> None: interface_items1 = ( "description switch-mgmt 192.168.1.0/24", @@ -267,7 +186,7 @@ def test_add_children(platform_a: Platform) -> None: def test_add_child(platform_a: Platform) -> None: config = get_hconfig(platform_a) interface = config.add_child("interface Vlan2") - assert interface.depth() == 1 + assert interface.depth == 1 assert interface.text == "interface Vlan2" with pytest.raises(DuplicateChildError): config.add_child("interface Vlan2") @@ -292,15 +211,15 @@ def test_path(platform_a: Platform) -> None: assert tuple(config_aaa.path()) == ("a", "aa", "aaa") -def test_cisco_style_text(platform_a: Platform) -> None: +def test_indented_text(platform_a: Platform) -> None: ip_address = ( get_hconfig(platform_a) .add_child("interface Vlan2") .add_child("ip address 192.168.1.1 255.255.255.0") ) - assert ip_address.cisco_style_text() == " ip address 192.168.1.1 255.255.255.0" - assert isinstance(ip_address.cisco_style_text(), str) - assert not isinstance(ip_address.cisco_style_text(), list) + assert ip_address.indented_text() == " ip address 192.168.1.1 255.255.255.0" + assert isinstance(ip_address.indented_text(), str) + assert not isinstance(ip_address.indented_text(), list) def test_all_children_sorted_by_tags(platform_a: Platform) -> None: @@ -310,8 +229,8 @@ def test_all_children_sorted_by_tags(platform_a: Platform) -> None: config_a.add_child("ab") config_aaa = config_aa.add_child("aaa") config_aab = config_aa.add_child("aab") - config_aaa.tags_add("aaa") - config_aab.tags_add("aab") + config_aaa.add_tags("aaa") + config_aab.add_tags("aab") case_1_matches = [ c.text @@ -361,19 +280,19 @@ def test_set_order_weight(platform_a: Platform) -> None: assert child.order_weight == 200 -def test_tags_add(platform_a: Platform) -> None: +def test_add_tags(platform_a: Platform) -> None: interface = get_hconfig(platform_a).add_child("interface Vlan2") ip_address = interface.add_child("ip address 192.168.1.1/24") assert not interface.tags assert not ip_address.tags - ip_address.tags_add("a") + ip_address.add_tags("a") assert "a" in interface.tags assert "a" in ip_address.tags assert "b" not in interface.tags assert "b" not in ip_address.tags - interface.tags_add("c") + interface.add_tags("c") assert "c" in ip_address.tags - interface.tags_remove("c") + interface.remove_tags("c") assert "c" not in ip_address.tags @@ -381,7 +300,7 @@ def test_append_tags(platform_a: Platform) -> None: config = get_hconfig(platform_a) interface = config.add_child("interface Vlan2") ip_address = interface.add_child("ip address 192.168.1.1/24") - ip_address.tags_add("test_tag") + ip_address.add_tags("test_tag") assert "test_tag" in config.tags assert "test_tag" in interface.tags assert "test_tag" in ip_address.tags @@ -391,11 +310,11 @@ def test_remove_tags(platform_a: Platform) -> None: config = get_hconfig(platform_a) interface = config.add_child("interface Vlan2") ip_address = interface.add_child("ip address 192.168.1.1/24") - ip_address.tags_add("test_tag") + ip_address.add_tags("test_tag") assert "test_tag" in config.tags assert "test_tag" in interface.tags assert "test_tag" in ip_address.tags - ip_address.tags_remove("test_tag") + ip_address.remove_tags("test_tag") assert "test_tag" not in config.tags assert "test_tag" not in interface.tags assert "test_tag" not in ip_address.tags @@ -409,38 +328,11 @@ def test_negate(platform_a: Platform) -> None: assert config.children.get("no interface Vlan2") is interface -def test_config_to_get_to(platform_a: Platform) -> None: - running_config_hier = get_hconfig(platform_a) - interface = running_config_hier.add_child("interface Vlan2") - interface.add_child("ip address 192.168.1.1/24") - generated_config_hier = get_hconfig(platform_a) - generated_config_hier.add_child("interface Vlan3") - remediation_config_hier = running_config_hier.config_to_get_to( - generated_config_hier, - ) - assert len(tuple(remediation_config_hier.all_children())) == 2 - - -def test_config_to_get_to2(platform_a: Platform) -> None: - running_config_hier = get_hconfig(platform_a) - running_config_hier.add_child("do not add me") - generated_config_hier = get_hconfig(platform_a) - generated_config_hier.add_child("do not add me") - generated_config_hier.add_child("add me") - delta = get_hconfig(platform_a) - running_config_hier.config_to_get_to( - generated_config_hier, - delta, - ) - assert "do not add me" not in delta.children - assert "add me" in delta.children - - def test_add_shallow_copy_of(platform_a: Platform) -> None: base_config = get_hconfig(platform_a) interface_a = get_hconfig(platform_a).add_child("interface Vlan2") - interface_a.tags_add(frozenset(("ta", "tb"))) + interface_a.add_tags(frozenset(("ta", "tb"))) interface_a.comments.add("ca") interface_a.order_weight = 200 @@ -461,7 +353,7 @@ def test_line_inclusion_test(platform_a: Platform) -> None: ip_address_ab = get_hconfig(platform_a).add_children_deep( ("interface Vlan2", "ip address 192.168.2.1/24"), ) - ip_address_ab.tags_add(frozenset(("a", "b"))) + ip_address_ab.add_tags(frozenset(("a", "b"))) assert not ip_address_ab.line_inclusion_test(frozenset(("a",)), frozenset(("b",))) assert not ip_address_ab.line_inclusion_test(frozenset(), frozenset(("a",))) @@ -469,1721 +361,923 @@ def test_line_inclusion_test(platform_a: Platform) -> None: assert not ip_address_ab.line_inclusion_test(frozenset(), frozenset()) -def test_future_config(platform_a: Platform) -> None: - running_config = get_hconfig(platform_a) - running_config.add_children_deep(("a", "aa", "aaa", "aaaa")) - running_config.add_children_deep(("a", "ab", "aba", "abaa")) - config = get_hconfig(platform_a) - config.add_children_deep(("a", "ac")) - config.add_children_deep(("a", "no ab")) - config.add_children_deep(("a", "no az")) - - future_config = running_config.future(config) - assert tuple(c.cisco_style_text() for c in future_config.all_children()) == ( - "a", - " ac", # config lines are added first - " no az", - " aa", # self lines not in config are added last - " aaa", - " aaaa", - ) - - -def test_future_preserves_bgp_neighbor_description() -> None: - """Validate Arista BGP neighbors keep untouched descriptions across future/rollback. +def test_add_child_with_empty_text() -> None: + """Test that add_child raises ValueError when text is empty.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) - 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 -""" + with pytest.raises(ValueError, match="text was empty"): + config.add_child("") - 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_add_child_duplicate_error() -> None: + """Test DuplicateChildError when adding duplicate child.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + config.add_child("interface GigabitEthernet0/0") -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"),), + with pytest.raises(DuplicateChildError, match="Found a duplicate section"): + config.add_child( + "interface GigabitEthernet0/0", + check_if_present=True, + return_if_present=False, ) - ) - 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_add_child_return_if_present() -> None: + """Test return_if_present option in add_child.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + child1 = config.add_child("interface GigabitEthernet0/0") + child2 = config.add_child("interface GigabitEthernet0/0", return_if_present=True) + assert id(child1) == id(child2) -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)) +def test_child_repr() -> None: + """Test HConfigChild __repr__ method.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + child = config.add_child("interface GigabitEthernet0/0") + subchild = child.add_child("description test") + repr_str = repr(child) - # 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",) + assert "HConfigChild(HConfig, interface GigabitEthernet0/0)" in repr_str + repr_str2 = repr(subchild) -def test_idempotency_key_no_match_rules() -> None: - """Test idempotency key falls back to text when no match rules apply.""" - driver = HConfigDriverCiscoIOS() + assert "HConfigChild(HConfigChild, description test)" in repr_str2 - 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_child_ne() -> None: + """Test HConfigChild __ne__ method.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + child1 = config.add_child("interface GigabitEthernet0/0") + child2 = config.add_child("interface GigabitEthernet0/1") + assert child1 != child2 -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)) +def test_indented_text_with_comments() -> None: + """Test indented_text with comments.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + child = config.add_child("interface GigabitEthernet0/0") + child.comments.add("test comment") + child.comments.add("another comment") + line = child.indented_text(style="with_comments") - # 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",) + assert "!another comment, test comment" in line + instance = Instance( + id=1, comments=frozenset(["instance comment"]), tags=frozenset(["tag1"]) + ) + child.instances.append(instance) + line_merged = child.indented_text(style="merged", tag="tag1") -def test_idempotency_key_suffix_no_match() -> None: - """Test idempotency key when suffix doesn't match.""" - driver = HConfigDriverCiscoIOS() + assert "1 instance" in line_merged + assert "instance comment" in line_merged - config_raw = """logging console -""" - config = get_hconfig(driver, config_raw) - child = next(iter(config.children)) + instance2 = Instance(id=2, comments=frozenset(), tags=frozenset(["tag1"])) + child.instances.append(instance2) + line_merged2 = child.indented_text(style="merged", tag="tag1") - # 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",) + assert "2 instances" in line_merged2 -def test_idempotency_key_contains_no_match() -> None: - """Test idempotency key when contains doesn't match.""" - driver = HConfigDriverCiscoIOS() +def test_child_sectional_exit_no_exit_text() -> None: + """Test sectional_exit when rule returns None.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + child = config.add_child("hostname test") - config_raw = """logging console -""" - config = get_hconfig(driver, config_raw) - child = next(iter(config.children)) + assert child.sectional_exit is None - # 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_child_is_match_endswith() -> None: + """Test is_match with endswith filter.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + interface = config.add_child("interface GigabitEthernet0/0") -def test_idempotency_key_regex_no_match() -> None: - """Test idempotency key when regex doesn't match.""" - driver = HConfigDriverCiscoIOS() + assert interface.is_match(endswith="Ethernet0/0") + assert not interface.is_match(endswith="Ethernet0/1") - 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_child_is_match_contains_single() -> None: + """Test is_match with single contains filter.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + interface = config.add_child("interface GigabitEthernet0/0") + assert interface.is_match(contains="Gigabit") + assert not interface.is_match(contains="FastEthernet") -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)) +def test_child_is_match_contains_tuple() -> None: + """Test is_match with tuple contains filter.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + interface = config.add_child("interface GigabitEthernet0/0") - # 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",) + assert interface.is_match(contains=("Gigabit", "FastEthernet")) + assert not interface.is_match(contains=("TenGigabit", "FastEthernet")) -def test_idempotency_key_prefix_tuple_match() -> None: - """Test idempotency key with tuple of prefixes that match.""" - driver = HConfigDriverCiscoIOS() +def test_child_use_default_for_negation() -> None: + """Test use_default_for_negation.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + interface = config.add_child("interface GigabitEthernet0/0") + description = interface.add_child("description test") + uses_default = description.use_default_for_negation(description) - config_raw = """logging console -""" - config = get_hconfig(driver, config_raw) - child = next(iter(config.children)) + assert isinstance(uses_default, bool) - # 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_child_remove_tags_leaf_iterable() -> None: + """Test remove_tags on leaf with iterable.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + interface = config.add_child("interface GigabitEthernet0/0") + description = interface.add_child("description test") + description.add_tags(frozenset(["tag1", "tag2", "tag3"])) + description.remove_tags(["tag1", "tag2"]) -def test_idempotency_key_suffix_tuple_no_match() -> None: - """Test idempotency key with tuple of suffixes that don't match.""" - driver = HConfigDriverCiscoIOS() + assert "tag1" not in description.tags + assert "tag2" not in description.tags + assert "tag3" in description.tags - 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") - rc_hier = get_hconfig(get_hconfig_driver(platform_a), "\n".join(rc)) - - difference = get_hconfig( - get_hconfig_driver(platform_a), "\n".join(step) - ).difference(rc_hier) - difference_children = tuple( - c.cisco_style_text() for c in difference.all_children_sorted() - ) - - assert len(difference_children) == 6 - assert "c" in difference.children - assert "d" in difference.children - difference_a = difference.get_child(equals="a") - assert isinstance(difference_a, HConfigChild) - assert "a4" in difference_a.children - assert "a5" in difference_a.children - difference_d = difference.get_child(equals="d") - assert isinstance(difference_d, HConfigChild) - assert "d1" in difference_d.children - - -def test_difference2() -> None: - platform = Platform.CISCO_IOS - rc = ("a", " a1", " a2", " a3", "b") - step = ("a", " a1", " a2", " a3", " a4", " a5", "b", "c", "d", " d1") - rc_hier = get_hconfig(get_hconfig_driver(platform), "\n".join(rc)) - step_hier = get_hconfig(get_hconfig_driver(platform), "\n".join(step)) - - difference_children = tuple( - c.cisco_style_text() - for c in step_hier.difference(rc_hier).all_children_sorted() - ) - assert len(difference_children) == 6 - - -def test_difference3() -> None: - platform = Platform.CISCO_IOS - rc = ("ip access-list extended test", " 10 a", " 20 b") - step = ("ip access-list extended test", " 10 a", " 20 b", " 30 c") - rc_hier = get_hconfig(get_hconfig_driver(platform), "\n".join(rc)) - step_hier = get_hconfig(get_hconfig_driver(platform), "\n".join(step)) - - difference_children = tuple( - c.cisco_style_text() - for c in step_hier.difference(rc_hier).all_children_sorted() - ) - assert difference_children == ("ip access-list extended test", " 30 c") - - -def test_unified_diff() -> None: - platform = Platform.CISCO_IOS - - config_a = get_hconfig(platform) - config_b = get_hconfig(platform) - # deep differences - config_a.add_children_deep(("a", "aa", "aaa", "aaaa")) - config_b.add_children_deep(("a", "aa", "aab", "aaba")) - # these children will be the same and should not appear in the diff - config_a.add_children_deep(("b", "ba", "baa")) - config_b.add_children_deep(("b", "ba", "baa")) - # root level differences - config_a.add_children_deep(("c", "ca")) - config_b.add_child("d") - - diff = tuple(config_a.unified_diff(config_b)) - assert diff == ( - "a", - " aa", - " - aaa", - " - aaaa", - " + aab", - " + aaba", - "- c", - " - ca", - "+ d", - ) - - -def test_idempotent_commands() -> None: - platform = Platform.HP_PROCURVE - config_a = get_hconfig(platform) - config_b = get_hconfig(platform) - interface_name = "interface 1/1" - config_a.add_children_deep((interface_name, "untagged vlan 1")) - config_b.add_children_deep((interface_name, "untagged vlan 2")) - interface = config_a.config_to_get_to(config_b).get_child(equals=interface_name) - assert interface is not None - assert interface.get_child(equals="untagged vlan 2") - assert len(interface.children) == 1 - - -def test_idempotent_commands2() -> None: - platform = Platform.CISCO_IOS - config_a = get_hconfig(platform) - config_b = get_hconfig(platform) - interface_name = "interface 1/1" - config_a.add_children_deep((interface_name, "authentication host-mode multi-auth")) - config_b.add_children_deep( - (interface_name, "authentication host-mode multi-domain"), - ) - interface = config_a.config_to_get_to(config_b).get_child(equals=interface_name) - assert interface is not None - assert interface.get_child(equals="authentication host-mode multi-domain") - assert len(interface.children) == 1 - - -def test_future_config_no_command_in_source() -> None: - platform = Platform.HP_PROCURVE - running_config = get_hconfig(platform) - generated_config = get_hconfig(platform) - generated_config.add_child("no service dhcp") - - remediation_config = running_config.config_to_get_to(generated_config) - future_config = running_config.future(remediation_config) - assert len(future_config.children) == 1 - assert future_config.get_child(equals="no service dhcp") - assert not tuple(future_config.unified_diff(generated_config)) - rollback_config = future_config.config_to_get_to(running_config) - assert len(rollback_config.children) == 1 - assert rollback_config.get_child(equals="service dhcp") - calculated_running_config = future_config.future(rollback_config) - assert not calculated_running_config.children - assert not tuple(calculated_running_config.unified_diff(running_config)) - - -def test_sectional_overwrite() -> None: - platform = Platform.CISCO_XR - # There is a sectional_overwrite rules in the CISCO_XR driver for "template". - running_config = get_hconfig_fast_load(platform, "template test\n a\n b") - generated_config = get_hconfig_fast_load(platform, "template test\n a") - expected_remediation_config = get_hconfig_fast_load( - platform, "no template test\ntemplate test\n a" - ) - workflow_remediation = WorkflowRemediation(running_config, generated_config) - remediation_config = workflow_remediation.remediation_config - assert remediation_config == expected_remediation_config - - -def test_sectional_overwrite_no_negate() -> None: - platform = Platform.CISCO_XR - running_config = get_hconfig_fast_load(platform, "as-path-set test\n a\n b") - generated_config = get_hconfig_fast_load(platform, "as-path-set test\n a") - expected_remediation_config = get_hconfig_fast_load( - platform, "as-path-set test\n a" - ) - workflow_remediation = WorkflowRemediation(running_config, generated_config) - remediation_config = workflow_remediation.remediation_config - assert remediation_config == expected_remediation_config - - -def test_sectional_overwrite_no_negate2() -> None: - platform = Platform.CISCO_XR - running_config = get_hconfig_fast_load( - platform, - "route-policy test\n duplicate\n not_duplicate1\n duplicate\n not_duplicate2", - ) - generated_config = get_hconfig_fast_load( - platform, "route-policy test\n duplicate\n not_duplicate1" - ) - expected_remediation_config = get_hconfig_fast_load( - platform, "route-policy test\n duplicate\n not_duplicate1" - ) - workflow_remediation = WorkflowRemediation(running_config, generated_config) - remediation_config = workflow_remediation.remediation_config - assert remediation_config == expected_remediation_config - - -def test_overwrite_with_negate() -> None: - platform = Platform.CISCO_XR - running_config = get_hconfig_fast_load( - platform, "route-policy test\n duplicate\n not_duplicate\n duplicate" - ) - generated_config = get_hconfig_fast_load( - platform, "route-policy test\n duplicate\n not_duplicate" - ) - expected_config = get_hconfig_fast_load( - platform, - "no route-policy test\nroute-policy test\n duplicate\n not_duplicate", - ) - delta_config = get_hconfig(platform) - running_config.children["route-policy test"].overwrite_with( - generated_config.children["route-policy test"], delta_config - ) - assert delta_config == expected_config - - -def test_overwrite_with_no_negate() -> None: - platform = Platform.CISCO_XR - running_config = get_hconfig_fast_load( - platform, - "route-policy test\n duplicate\n not-duplicate\n duplicate\n duplicate", - ) - generated_config = get_hconfig_fast_load( - platform, "route-policy test\n duplicate\n not-duplicate\n duplicate" - ) - expected_config = get_hconfig_fast_load( - platform, - "route-policy test\n duplicate\n not-duplicate\n duplicate", - ) - delta_config = get_hconfig(platform) - running_config.children["route-policy test"].overwrite_with( - generated_config.children["route-policy test"], delta_config, negate=False - ) - assert delta_config == expected_config - - -def test_config_to_get_to_parent_identity() -> None: - interface_vlan2 = "interface Vlan2" - platform = Platform.CISCO_IOS - running_config_hier = get_hconfig(platform) - running_config_hier.add_children_deep( - (interface_vlan2, "ip address 192.168.1.1/24") - ) - generated_config_hier = get_hconfig(platform) - generated_config_hier.add_child(interface_vlan2) - remediation_config_hier = running_config_hier.config_to_get_to( - generated_config_hier, - ) - remediation_config_interface = remediation_config_hier.get_child( - equals=interface_vlan2 - ) - assert remediation_config_interface - assert id(remediation_config_interface.parent) == id(remediation_config_hier) - assert id(remediation_config_interface.root) == id(remediation_config_hier) - - -def test_add_child_with_empty_text() -> None: - """Test that add_child raises ValueError when text is empty.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - - with pytest.raises(ValueError, match="text was empty"): - config.add_child("") - - -def test_add_child_duplicate_error() -> None: - """Test DuplicateChildError when adding duplicate child.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - config.add_child("interface GigabitEthernet0/0") - - with pytest.raises(DuplicateChildError, match="Found a duplicate section"): - config.add_child( - "interface GigabitEthernet0/0", - check_if_present=True, - return_if_present=False, - ) - - -def test_add_child_return_if_present() -> None: - """Test return_if_present option in add_child.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - child1 = config.add_child("interface GigabitEthernet0/0") - child2 = config.add_child("interface GigabitEthernet0/0", return_if_present=True) - - assert id(child1) == id(child2) - - -def test_child_repr() -> None: - """Test HConfigChild __repr__ method.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - child = config.add_child("interface GigabitEthernet0/0") - subchild = child.add_child("description test") - repr_str = repr(child) - - assert "HConfigChild(HConfig, interface GigabitEthernet0/0)" in repr_str - - repr_str2 = repr(subchild) - - assert "HConfigChild(HConfigChild, description test)" in repr_str2 - - -def test_child_ne() -> None: - """Test HConfigChild __ne__ method.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - child1 = config.add_child("interface GigabitEthernet0/0") - child2 = config.add_child("interface GigabitEthernet0/1") - - assert child1 != child2 - - -def test_cisco_style_text_with_comments() -> None: - """Test cisco_style_text with comments.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - child = config.add_child("interface GigabitEthernet0/0") - child.comments.add("test comment") - child.comments.add("another comment") - line = child.cisco_style_text(style="with_comments") - - assert "!another comment, test comment" in line - - instance = Instance( - id=1, comments=frozenset(["instance comment"]), tags=frozenset(["tag1"]) - ) - child.instances.append(instance) - line_merged = child.cisco_style_text(style="merged", tag="tag1") - - assert "1 instance" in line_merged - assert "instance comment" in line_merged - - instance2 = Instance(id=2, comments=frozenset(), tags=frozenset(["tag1"])) - child.instances.append(instance2) - line_merged2 = child.cisco_style_text(style="merged", tag="tag1") - - assert "2 instances" in line_merged2 - - -def test_hconfig_children_setitem() -> None: - """Test HConfigChildren __setitem__.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - config.add_child("interface GigabitEthernet0/0") - child2_text = "interface GigabitEthernet0/1" - config.add_child(child2_text) - child3_text = "interface GigabitEthernet0/2" - child3 = config.instantiate_child(child3_text) - config.children[1] = child3 - - assert config.children[1].text == child3_text - assert child3_text in config.children - - -def test_hconfig_children_contains() -> None: - """Test HConfigChildren __contains__.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - config.add_child("interface GigabitEthernet0/0") - - assert "interface GigabitEthernet0/0" in config.children - assert "interface GigabitEthernet0/1" not in config.children - - -def test_hconfig_children_eq_fast_fail() -> None: - """Test HConfigChildren __eq__ fast fail.""" - platform = Platform.CISCO_IOS - config1 = get_hconfig(platform) - config2 = get_hconfig(platform) - - config1.add_child("interface GigabitEthernet0/0") - config2.add_child("interface GigabitEthernet0/0") - config2.add_child("interface GigabitEthernet0/1") - - assert config1.children != config2.children - - -def test_hconfig_children_eq_keys_mismatch() -> None: - """Test HConfigChildren __eq__ key mismatch.""" - platform = Platform.CISCO_IOS - config1 = get_hconfig(platform) - config2 = get_hconfig(platform) - - config1.add_child("interface GigabitEthernet0/0") - config2.add_child("interface GigabitEthernet0/1") - - assert config1.children != config2.children - - -def test_hconfig_children_hash() -> None: - """Test HConfigChildren __hash__.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - config.add_child("interface GigabitEthernet0/0") - hash_val = hash(config.children) - - assert isinstance(hash_val, int) - - -def test_hconfig_children_getitem_slice() -> None: - """Test HConfigChildren __getitem__ with slice.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - config.add_child("interface GigabitEthernet0/0") - config.add_child("interface GigabitEthernet0/1") - config.add_child("interface GigabitEthernet0/2") - slice_result = config.children[0:2] - - assert isinstance(slice_result, list) - assert len(slice_result) == 2 - assert slice_result[0].text == "interface GigabitEthernet0/0" - - -def test_future_with_negated_command_in_config() -> None: - """Test _future with negated command.""" - platform = Platform.CISCO_IOS - running_config = get_hconfig(platform) - running_config.add_child("interface GigabitEthernet0/0") - remediation_config = get_hconfig(platform) - remediation_config.add_child("no interface GigabitEthernet0/0") - future_config = running_config.future(remediation_config) - - assert future_config.get_child(equals="interface GigabitEthernet0/0") is None - - -def test_future_with_negation_prefix_match() -> None: - """Test _future when negated form exists.""" - platform = Platform.CISCO_IOS - running_config = get_hconfig(platform) - running_config.add_child("no logging console") - remediation_config = get_hconfig(platform) - remediation_config.add_child("logging console") - future_config = running_config.future(remediation_config) - - assert future_config.get_child(equals="logging console") is not None - assert future_config.get_child(equals="no logging console") is None - - -def test_difference_with_negation() -> None: - """Test _difference with negation prefix.""" - platform = Platform.CISCO_IOS - running_config = get_hconfig(platform) - running_config.add_child("interface GigabitEthernet0/0") - running_config.add_child("logging console") - generated_config = get_hconfig(platform) - generated_config.add_child("interface GigabitEthernet0/0") - difference = running_config.difference(generated_config) - - assert difference.get_child(equals="logging console") is not None - - -def test_child_lt_comparison() -> None: - """Test HConfigChild __lt__ for ordering.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - child1 = config.add_child("interface GigabitEthernet0/0") - child2 = config.add_child("interface GigabitEthernet0/1") - child1.order_weight = 100 - child2.order_weight = 50 - - assert child2 < child1 - assert not child1 < child2 # pylint: disable=unneeded-not - - -def test_child_hash_consistency() -> None: - """Test HConfigChild __hash__.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - child = config.add_child("interface GigabitEthernet0/0") - child.add_child("description test") - hash1 = hash(child) - hash2 = hash(child) - - assert hash1 == hash2 - - -def test_child_hash_eq_consistency_new_in_config() -> None: - """Test that equal HConfigChild objects have equal hashes regardless of new_in_config. - - Validates the bug in issue #185: __hash__ includes new_in_config but __eq__ does not, - violating the Python invariant that a == b implies hash(a) == hash(b). - """ - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - child1 = config.add_child("interface GigabitEthernet0/0") - config2 = get_hconfig(platform) - child2 = config2.add_child("interface GigabitEthernet0/0") - - child1.new_in_config = False - child2.new_in_config = True - - # These two children compare as equal (same text, no tags, no children) - assert child1 == child2 - # Python invariant: equal objects must have equal hashes - assert hash(child1) == hash(child2) - - -def test_child_hash_eq_consistency_order_weight() -> None: - """Test that equal HConfigChild objects have equal hashes regardless of order_weight. - - Validates the bug in issue #185: __hash__ includes order_weight but __eq__ does not, - violating the Python invariant that a == b implies hash(a) == hash(b). - """ - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - child1 = config.add_child("interface GigabitEthernet0/0") - config2 = get_hconfig(platform) - child2 = config2.add_child("interface GigabitEthernet0/0") - - child1.order_weight = 0 - child2.order_weight = 100 - - # These two children compare as equal (same text, no tags, no children) - assert child1 == child2 - # Python invariant: equal objects must have equal hashes - assert hash(child1) == hash(child2) - - -def test_child_hash_eq_consistency_tags() -> None: - """Test that __hash__ and __eq__ agree on whether tags affect equality. - - Validates the bug in issue #185: __eq__ checks tags but __hash__ does not include - tags, meaning two objects that compare unequal could have the same hash (not a - correctness violation, but inconsistent) while also raising the question of whether - tags should be part of the hash. - """ - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - child1 = config.add_child("interface GigabitEthernet0/0") - config2 = get_hconfig(platform) - child2 = config2.add_child("interface GigabitEthernet0/0") - - child1.tags = frozenset({"safe"}) - child2.tags = frozenset() - - # __eq__ considers tags, so these are unequal - assert child1 != child2 - # Since they are unequal, their hashes should differ to avoid excessive collisions - # (not strictly required by the invariant, but required for correctness in reverse: - # if hash(a) != hash(b) then a != b must hold — currently tags are in __eq__ but - # not __hash__, so unequal objects can share a hash, which means dict/set lookup - # will fall back to __eq__ unexpectedly) - assert hash(child1) != hash(child2) - - -def test_child_set_deduplication_with_new_in_config() -> None: - """Test that equal HConfigChild objects are deduplicated correctly in sets. - - Validates the practical impact of issue #185: when new_in_config differs, - two logically equal children occupy different set buckets, causing duplicates. - """ +def test_child_tags_setter_on_branch() -> None: + """Test tags setter on branch node.""" platform = Platform.CISCO_IOS config = get_hconfig(platform) - child1 = config.add_child("interface GigabitEthernet0/0") - config2 = get_hconfig(platform) - child2 = config2.add_child("interface GigabitEthernet0/0") - - child1.new_in_config = False - child2.new_in_config = True - - assert child1 == child2 - # Equal objects must collapse to one entry in a set - assert len({child1, child2}) == 1 + interface = config.add_child("interface GigabitEthernet0/0") + description = interface.add_child("description test") + interface.tags = frozenset(["production", "critical"]) + assert "production" in description.tags + assert "critical" in description.tags -def test_child_dict_key_lookup_with_order_weight() -> None: - """Test that HConfigChild objects with differing order_weight work as dict keys. - Validates the practical impact of issue #185: when order_weight differs, a - logically equal child cannot be found as a dict key. - """ +def test_child_is_idempotent_command_avoid() -> None: + """Test is_idempotent_command with avoid rule.""" platform = Platform.CISCO_IOS config = get_hconfig(platform) - child1 = config.add_child("interface GigabitEthernet0/0") - config2 = get_hconfig(platform) - child2 = config2.add_child("interface GigabitEthernet0/0") - - child1.order_weight = 0 - child2.order_weight = 100 + interface = config.add_child("interface GigabitEthernet0/0") + ip_address = interface.add_child("ip address 192.168.1.1 255.255.255.0") + other_children: list[HConfigChild] = [] + result = ip_address.is_idempotent_command(other_children) - assert child1 == child2 - lookup: dict[HConfigChild, str] = {child1: "found"} - # child2 is equal to child1, so it must find the same dict entry - assert lookup[child2] == "found" + assert isinstance(result, bool) -def test_with_tags_recursive() -> None: - """Test _with_tags recursion.""" +def test_child_is_idempotent_command_with_avoid_rule() -> None: + """Test is_idempotent_command with avoid rule match.""" platform = Platform.CISCO_IOS config = get_hconfig(platform) interface = config.add_child("interface GigabitEthernet0/0") - interface.tags = frozenset(["production"]) - desc = interface.add_child("description test") - desc.tags = frozenset(["production"]) - tagged_config = config.with_tags(frozenset(["production"])) - - assert tagged_config.get_child(equals="interface GigabitEthernet0/0") is not None - - tagged_interface = tagged_config.get_child(equals="interface GigabitEthernet0/0") + ip_access_group = interface.add_child("ip access-group test in") + result = ip_access_group.is_idempotent_command([]) - assert tagged_interface is not None - assert tagged_interface.get_child(equals="description test") is not None + assert isinstance(result, bool) -def test_difference_with_default_prefix() -> None: - """Test _difference skips lines with 'default' prefix.""" +def test_child_overwrite_with_negate_else_branch() -> None: + """Test overwrite_with when negated child doesn't exist.""" platform = Platform.CISCO_IOS running_config = get_hconfig(platform) - running_config.add_child("interface GigabitEthernet0/0") - running_config.add_child("default interface GigabitEthernet0/1") + running_interface = running_config.add_child("interface GigabitEthernet0/0") + running_interface.add_child("description old") generated_config = get_hconfig(platform) - generated_config.add_child("interface GigabitEthernet0/0") - difference = running_config.difference(generated_config) + generated_interface = generated_config.add_child("interface GigabitEthernet0/0") + generated_interface.add_child("description new") + delta_config = get_hconfig(platform) + running_interface.overwrite_with(generated_interface, delta_config, negate=True) + delta_interface = delta_config.get_child(equals="interface GigabitEthernet0/0") - assert difference.get_child(startswith="default") is None + assert delta_interface is not None -def test_add_child_with_duplicates_allowed() -> None: - """Test add_child when duplicates are allowed.""" - platform = Platform.CISCO_XR - config = get_hconfig(platform) - route_policy = config.add_child("route-policy test") - child1 = route_policy.add_child("if destination in test then") - child2 = route_policy.add_child("if destination in test then") +def test_child_overwrite_with_existing_negated() -> None: + """Test overwrite_with when negated child exists in delta.""" + platform = Platform.CISCO_IOS + running_config = get_hconfig(platform) + running_interface = running_config.add_child("interface GigabitEthernet0/0") + running_interface.add_child("description old") + generated_config = get_hconfig(platform) + generated_interface = generated_config.add_child("interface GigabitEthernet0/0") + generated_interface.add_child("description new") + delta_config = get_hconfig(platform) + delta_config.add_child("interface GigabitEthernet0/0") + running_interface.overwrite_with(generated_interface, delta_config, negate=True) + delta_interface = delta_config.get_child(equals="interface GigabitEthernet0/0") - assert id(child1) != id(child2) - assert child1.text == child2.text + assert delta_interface is not None -def test_get_children_with_duplicates() -> None: - """Test get_children when duplicates are allowed.""" - platform = Platform.CISCO_XR +def test_child_remove_tags_branch() -> None: + """Test remove_tags on branch node.""" + platform = Platform.CISCO_IOS config = get_hconfig(platform) - route_policy = config.add_child("route-policy test") - route_policy.add_child("if destination in test then") - route_policy.add_child("if destination in test then") - route_policy.add_child("if source in test then") - children = tuple(route_policy.get_children(startswith="if destination")) + interface = config.add_child("interface GigabitEthernet0/0") + description = interface.add_child("description test") + description.add_tags("test_tag") + interface.remove_tags("test_tag") - assert len(children) == 2 + assert "test_tag" not in description.tags -def test_child_sectional_exit_no_exit_text() -> None: - """Test sectional_exit when rule returns None.""" +def test_child_add_children_deep() -> None: + """Test add_children_deep method.""" platform = Platform.CISCO_IOS config = get_hconfig(platform) - child = config.add_child("hostname test") + interface = config.add_child("interface GigabitEthernet0/0") + result = interface.add_children_deep( + ["ip access-group test in", "description test"] + ) - assert child.sectional_exit is None + assert result.text == "description test" + assert result.depth == 3 -def test_child_is_match_endswith() -> None: - """Test is_match with endswith filter.""" +def test_child_default_method() -> None: + """Test _default method.""" platform = Platform.CISCO_IOS config = get_hconfig(platform) interface = config.add_child("interface GigabitEthernet0/0") + description = interface.add_child("description test") + description._default() # noqa: SLF001 # pyright: ignore[reportPrivateUsage] - assert interface.is_match(endswith="Ethernet0/0") - assert not interface.is_match(endswith="Ethernet0/1") + assert description.text == "default description test" -def test_child_is_match_contains_single() -> None: - """Test is_match with single contains filter.""" +def test_abstract_methods_coverage() -> None: + """Test coverage of abstract method implementations.""" platform = Platform.CISCO_IOS config = get_hconfig(platform) interface = config.add_child("interface GigabitEthernet0/0") + desc = interface.add_child("description test") - assert interface.is_match(contains="Gigabit") - assert not interface.is_match(contains="FastEthernet") + assert interface.root is config + assert desc.root is config + + assert interface.driver is not None + assert config.driver is not None + + lineage = tuple(desc.lineage()) + assert len(lineage) == 2 + assert lineage[0] is interface + assert config.depth == 0 + assert interface.depth == 1 + assert desc.depth == 2 -def test_child_is_match_contains_tuple() -> None: - """Test is_match with tuple contains filter.""" + hash_value = hash(interface) + assert isinstance(hash_value, int) + + children_list = list(config) + assert len(children_list) == 1 + assert children_list[0] is interface + + +def test_get_child_deep_none() -> None: + """Test get_child_deep returns None when no match.""" platform = Platform.CISCO_IOS config = get_hconfig(platform) - interface = config.add_child("interface GigabitEthernet0/0") + config.add_child("interface GigabitEthernet0/0") + result = config.get_child_deep((MatchRule(equals="interface GigabitEthernet0/1"),)) - assert interface.is_match(contains=("Gigabit", "FastEthernet")) - assert not interface.is_match(contains=("TenGigabit", "FastEthernet")) + assert result is None -def test_child_use_default_for_negation() -> None: - """Test use_default_for_negation.""" +def test_child_eq_comparison() -> None: + """Test HConfigChild __eq__ returns False for different text.""" platform = Platform.CISCO_IOS config = get_hconfig(platform) - interface = config.add_child("interface GigabitEthernet0/0") - description = interface.add_child("description test") - uses_default = description.use_default_for_negation(description) + child1 = config.add_child("interface GigabitEthernet0/0") + child2 = config.add_child("interface GigabitEthernet0/1") - assert isinstance(uses_default, bool) + assert child1 != child2 + + config2 = get_hconfig(platform) + child3 = config2.add_child("interface GigabitEthernet0/0") + assert child1 == child3 -def test_child_tags_remove_branch() -> None: - """Test tags_remove on branch node.""" +def test_child_hash_consistency() -> None: + """Test HConfigChild __hash__.""" platform = Platform.CISCO_IOS config = get_hconfig(platform) - interface = config.add_child("interface GigabitEthernet0/0") - description = interface.add_child("description test") - description.tags_add("test_tag") - interface.tags_remove("test_tag") + child = config.add_child("interface GigabitEthernet0/0") + child.add_child("description test") + hash1 = hash(child) + hash2 = hash(child) - assert "test_tag" not in description.tags + assert hash1 == hash2 -def test_child_is_idempotent_command_avoid() -> None: - """Test is_idempotent_command with avoid rule.""" +def test_with_tags_recursive() -> None: + """Test _with_tags recursion.""" platform = Platform.CISCO_IOS config = get_hconfig(platform) interface = config.add_child("interface GigabitEthernet0/0") - ip_address = interface.add_child("ip address 192.168.1.1 255.255.255.0") - other_children: list[HConfigChild] = [] - result = ip_address.is_idempotent_command(other_children) - - assert isinstance(result, bool) + interface.tags = frozenset(["production"]) + desc = interface.add_child("description test") + desc.tags = frozenset(["production"]) + tagged_config = config.with_tags(frozenset(["production"])) + assert tagged_config.get_child(equals="interface GigabitEthernet0/0") is not None -def test_child_overwrite_with_negate_else_branch() -> None: - """Test overwrite_with when negated child doesn't exist.""" - platform = Platform.CISCO_IOS - running_config = get_hconfig(platform) - running_interface = running_config.add_child("interface GigabitEthernet0/0") - running_interface.add_child("description old") - generated_config = get_hconfig(platform) - generated_interface = generated_config.add_child("interface GigabitEthernet0/0") - generated_interface.add_child("description new") - delta_config = get_hconfig(platform) - running_interface.overwrite_with(generated_interface, delta_config, negate=True) - delta_interface = delta_config.get_child(equals="interface GigabitEthernet0/0") + tagged_interface = tagged_config.get_child(equals="interface GigabitEthernet0/0") - assert delta_interface is not None + assert tagged_interface is not None + assert tagged_interface.get_child(equals="description test") is not None -def test_child_tags_setter_on_branch() -> None: - """Test tags setter on branch node.""" +def test_child_sectional_exit_with_exit_text() -> None: + """Test sectional_exit when rule has exit_text.""" platform = Platform.CISCO_IOS config = get_hconfig(platform) interface = config.add_child("interface GigabitEthernet0/0") - description = interface.add_child("description test") - interface.tags = frozenset(["production", "critical"]) + interface.add_child("description test") + exit_text = interface.sectional_exit - assert "production" in description.tags - assert "critical" in description.tags + assert exit_text == "exit" -def test_child_add_children_deep() -> None: - """Test add_children_deep method.""" +def test_child_use_default_for_negation_true() -> None: + """Test use_default_for_negation returns True.""" platform = Platform.CISCO_IOS config = get_hconfig(platform) interface = config.add_child("interface GigabitEthernet0/0") - result = interface.add_children_deep( - ["ip access-group test in", "description test"] - ) + description = interface.add_child("description test") + result = description.use_default_for_negation(description) - assert result.text == "description test" - assert result.depth() == 3 + assert isinstance(result, bool) -def test_child_default_method() -> None: - """Test _default method.""" +def test_child_lt_comparison() -> None: + """Test HConfigChild __lt__ for ordering.""" platform = Platform.CISCO_IOS config = get_hconfig(platform) - interface = config.add_child("interface GigabitEthernet0/0") - description = interface.add_child("description test") - description._default() # noqa: SLF001 # pyright: ignore[reportPrivateUsage] + child1 = config.add_child("interface GigabitEthernet0/0") + child2 = config.add_child("interface GigabitEthernet0/1") + child1.order_weight = 100 + child2.order_weight = 50 - assert description.text == "default description test" + assert child2 < child1 + assert not child1 < child2 # pylint: disable=unneeded-not -def test_abstract_methods_coverage() -> None: - """Test coverage of abstract method implementations.""" - platform = Platform.CISCO_IOS +def test_add_child_with_duplicates_allowed() -> None: + """Test add_child when duplicates are allowed.""" + platform = Platform.CISCO_XR config = get_hconfig(platform) - interface = config.add_child("interface GigabitEthernet0/0") - desc = interface.add_child("description test") + route_policy = config.add_child("route-policy test") + child1 = route_policy.add_child("if destination in test then") + child2 = route_policy.add_child("if destination in test then") - assert interface.root is config - assert desc.root is config + assert id(child1) != id(child2) + assert child1.text == child2.text + + +def test_get_children_with_duplicates() -> None: + """Test get_children when duplicates are allowed.""" + platform = Platform.CISCO_XR + config = get_hconfig(platform) + route_policy = config.add_child("route-policy test") + route_policy.add_child("if destination in test then") + route_policy.add_child("if destination in test then") + route_policy.add_child("if source in test then") + children = tuple(route_policy.get_children(startswith="if destination")) - assert interface.driver is not None - assert config.driver is not None + assert len(children) == 2 - lineage = tuple(desc.lineage()) - assert len(lineage) == 2 - assert lineage[0] is interface - assert config.depth() == 0 - assert interface.depth() == 1 - assert desc.depth() == 2 +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"),), + ) + ) - hash_value = hash(interface) - assert isinstance(hash_value, int) + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = next(iter(config.children)) - children_list = list(config) - assert len(children_list) == 1 - assert children_list[0] is interface + # 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_get_child_deep_none() -> None: - """Test get_child_deep returns None when no match.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - config.add_child("interface GigabitEthernet0/0") - result = config.get_child_deep((MatchRule(equals="interface GigabitEthernet0/1"),)) +def test_idempotency_key_with_equals_frozenset() -> None: + """Test idempotency key generation with equals constraint as frozenset.""" + driver = HConfigDriverCiscoIOS() - assert result is None + 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_future_with_idempotent_command() -> None: - """Test _future with idempotent command.""" - platform = Platform.HP_PROCURVE - running_config = get_hconfig(platform) - interface = running_config.add_child("interface 1/1") - interface.add_child("untagged vlan 1") - remediation_config = get_hconfig(platform) - remediation_interface = remediation_config.add_child("interface 1/1") - remediation_interface.add_child("untagged vlan 2") - future_config = running_config.future(remediation_config) - future_interface = future_config.get_child(equals="interface 1/1") - assert future_interface is not None - assert future_interface.get_child(equals="untagged vlan 2") is not None +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)) -def test_future_with_negation_prefix() -> None: - """Test _future with negation prefix in self.""" - platform = Platform.CISCO_IOS - running_config = get_hconfig(platform) - running_config.add_child("no ip routing") - remediation_config = get_hconfig(platform) - remediation_config.add_child("ip routing") - future_config = running_config.future(remediation_config) + # Empty MatchRule should fall back to text + key = driver._idempotency_key(child, (MatchRule(),)) # noqa: SLF001 # pyright: ignore[reportPrivateUsage] + assert key == ("text|some command",) - assert future_config.get_child(equals="ip routing") is None - assert future_config.get_child(equals="no ip routing") is None +def test_idempotency_key_prefix_no_match() -> None: + """Test idempotency key when prefix doesn't match.""" + driver = HConfigDriverCiscoIOS() -def test_future_self_child_not_in_negated_or_recursed() -> None: - """Test _future when self_child is not in negated_or_recursed.""" - platform = Platform.CISCO_IOS - running_config = get_hconfig(platform) - running_config.add_child("hostname router1") - running_config.add_child("interface GigabitEthernet0/0") - remediation_config = get_hconfig(platform) - remediation_config.add_child("hostname router2") - future_config = running_config.future(remediation_config) + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = next(iter(config.children)) - assert future_config.get_child(equals="hostname router2") is not None - assert future_config.get_child(equals="interface GigabitEthernet0/0") is not None + # 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_difference_with_acl_none_target() -> None: - """Test _difference with ACL when target_acl_children is None.""" - platform = Platform.CISCO_IOS - running_config = get_hconfig(platform) +def test_idempotency_key_suffix_no_match() -> None: + """Test idempotency key when suffix doesn't match.""" + driver = HConfigDriverCiscoIOS() - acl = running_config.add_child("ip access-list extended test") - acl.add_child("10 permit ip any any") - target_config = get_hconfig(platform) - difference = running_config.difference(target_config) + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = next(iter(config.children)) - assert difference.get_child(equals="ip access-list extended test") is not None + # 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_child_eq_comparison() -> None: - """Test HConfigChild __eq__ returns False for different text.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - child1 = config.add_child("interface GigabitEthernet0/0") - child2 = config.add_child("interface GigabitEthernet0/1") +def test_idempotency_key_contains_no_match() -> None: + """Test idempotency key when contains doesn't match.""" + driver = HConfigDriverCiscoIOS() - assert child1 != child2 + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = next(iter(config.children)) - config2 = get_hconfig(platform) - child3 = config2.add_child("interface GigabitEthernet0/0") - assert child1 == child3 + # 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_child_sectional_exit_with_exit_text() -> None: - """Test sectional_exit when rule has exit_text.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - interface = config.add_child("interface GigabitEthernet0/0") - interface.add_child("description test") - exit_text = interface.sectional_exit +def test_idempotency_key_regex_no_match() -> None: + """Test idempotency key when regex doesn't match.""" + driver = HConfigDriverCiscoIOS() - assert exit_text == "exit" + 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_child_tags_remove_leaf_iterable() -> None: - """Test tags_remove on leaf with iterable.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - interface = config.add_child("interface GigabitEthernet0/0") - description = interface.add_child("description test") - description.tags_add(frozenset(["tag1", "tag2", "tag3"])) - description.tags_remove(["tag1", "tag2"]) - assert "tag1" not in description.tags - assert "tag2" not in description.tags - assert "tag3" in description.tags +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)) -def test_child_use_default_for_negation_true() -> None: - """Test use_default_for_negation returns True.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - interface = config.add_child("interface GigabitEthernet0/0") - description = interface.add_child("description test") - result = description.use_default_for_negation(description) + # 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",) - assert isinstance(result, bool) +def test_idempotency_key_prefix_tuple_match() -> None: + """Test idempotency key with tuple of prefixes that match.""" + driver = HConfigDriverCiscoIOS() -def test_child_is_idempotent_command_with_avoid_rule() -> None: - """Test is_idempotent_command with avoid rule match.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - interface = config.add_child("interface GigabitEthernet0/0") - ip_access_group = interface.add_child("ip access-group test in") - result = ip_access_group.is_idempotent_command([]) + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = next(iter(config.children)) - assert isinstance(result, bool) + # 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_child_overwrite_with_existing_negated() -> None: - """Test overwrite_with when negated child exists in delta.""" - platform = Platform.CISCO_IOS - running_config = get_hconfig(platform) - running_interface = running_config.add_child("interface GigabitEthernet0/0") - running_interface.add_child("description old") - generated_config = get_hconfig(platform) - generated_interface = generated_config.add_child("interface GigabitEthernet0/0") - generated_interface.add_child("description new") - delta_config = get_hconfig(platform) - delta_config.add_child("interface GigabitEthernet0/0") - running_interface.overwrite_with(generated_interface, delta_config, negate=True) - delta_interface = delta_config.get_child(equals="interface GigabitEthernet0/0") +def test_idempotency_key_suffix_tuple_no_match() -> None: + """Test idempotency key with tuple of suffixes that don't match.""" + driver = HConfigDriverCiscoIOS() - assert delta_interface is not None + 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_children_eq_empty_fast_success() -> None: - """Test HConfigChildren __eq__ fast success for empty.""" - platform = Platform.CISCO_IOS - config1 = get_hconfig(platform) - config2 = get_hconfig(platform) - assert config1.children == config2.children +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)) -def test_children_hash_with_data() -> None: - """Test HConfigChildren __hash__ with data.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - config.add_child("interface GigabitEthernet0/0") - config.add_child("interface GigabitEthernet0/1") - hash1 = hash(config.children) - hash2 = hash(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",) - assert hash1 == hash2 - assert isinstance(hash1, int) +def test_idempotency_key_contains_tuple_no_match() -> None: + """Test idempotency key with tuple of contains that don't match.""" + driver = HConfigDriverCiscoIOS() -def test_children_getitem_with_slice() -> None: - """Test HConfigChildren __getitem__ with slice.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - config.add_child("interface GigabitEthernet0/0") - config.add_child("interface GigabitEthernet0/1") - config.add_child("interface GigabitEthernet0/2") - config.add_child("interface GigabitEthernet0/3") - slice1 = config.children[1:3] - assert len(slice1) == 2 + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = next(iter(config.children)) - slice2 = config.children[::2] - assert len(slice2) == 2 + # 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",) - slice3 = config.children[:2] - assert len(slice3) == 2 +def test_idempotency_key_contains_tuple_match() -> None: + """Test idempotency key with tuple of contains that match.""" + driver = HConfigDriverCiscoIOS() -def test_hconfig_str() -> None: - """Test HConfig __str__ method.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - config.add_child("hostname router1") - config.add_child("interface GigabitEthernet0/0") - str_output = str(config) + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = next(iter(config.children)) - assert "hostname router1" in str_output - assert "interface GigabitEthernet0/0" in str_output - assert isinstance(str_output, str) + # 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_hconfig_eq_not_hconfig() -> None: - """Test HConfig __eq__ with non-HConfig object.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - result = config == "not an HConfig" +def test_idempotency_key_regex_with_groups() -> None: + """Test idempotency key with regex capture groups.""" + driver = HConfigDriverCiscoIOS() - assert not result + 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_hconfig_real_indent_level() -> None: - """Test HConfig real_indent_level property.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - assert config.real_indent_level == -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)) -def test_hconfig_parent_property() -> None: - """Test HConfig parent property returns self.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) + # 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] - assert config.parent is config +def test_idempotency_key_regex_greedy_pattern() -> None: + """Test idempotency key with greedy regex pattern (.* or .+).""" + driver = HConfigDriverCiscoIOS() -def test_hconfig_is_leaf() -> None: - """Test HConfig is_leaf property.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) + config_raw = """logging console emergency +""" + config = get_hconfig(driver, config_raw) + child = next(iter(config.children)) - assert config.is_leaf is False + # 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_hconfig_tags_setter() -> None: - """Test HConfig tags setter.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - interface = config.add_child("interface GigabitEthernet0/0") - desc = interface.add_child("description test") - config.tags = frozenset(["production", "core"]) +def test_idempotency_key_regex_greedy_pattern_with_dollar() -> None: + """Test idempotency key with greedy regex pattern with $ anchor.""" + driver = HConfigDriverCiscoIOS() - assert "production" in desc.tags - assert "core" in desc.tags + 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_hconfig_add_children_deep_typeerror() -> None: - """Test HConfig add_children_deep raises TypeError.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - with pytest.raises(TypeError, match="base was an HConfig object"): - config.add_children_deep([]) +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)) -def test_hconfig_deep_copy() -> None: - """Test HConfig deep_copy method).""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - interface = config.add_child("interface GigabitEthernet0/0") - interface.add_child("description test") - config.add_child("hostname router1") - config_copy = config.deep_copy() + # 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",) - assert config_copy is not config - assert len(tuple(config_copy.all_children())) == len(tuple(config.all_children())) - assert config_copy.get_child(equals="interface GigabitEthernet0/0") is not None - assert config_copy.get_child(equals="hostname router1") is not None - original_interface = config.get_child(equals="interface GigabitEthernet0/0") - copied_interface = config_copy.get_child(equals="interface GigabitEthernet0/0") - assert original_interface is not None - assert copied_interface is not None - assert original_interface is not copied_interface +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)) -def test_sectional_exit_text_parent_level_cisco_xr() -> None: - """Test sectional_exit_text_parent_level returns True for Cisco XR configs with parent-level exit text.""" - platform = Platform.CISCO_XR - config = get_hconfig(platform) + # 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 - # Test route-policy which has exit_text_parent_level=True - route_policy = config.add_child("route-policy TEST") - assert route_policy.sectional_exit_text_parent_level is True - # Test prefix-set which has exit_text_parent_level=True - prefix_set = config.add_child("prefix-set TEST") - assert prefix_set.sectional_exit_text_parent_level is True +def test_idempotency_key_negated_command() -> None: + """Test idempotency key with negated command.""" + driver = HConfigDriverCiscoIOS() - # Test policy-map which has exit_text_parent_level=True - policy_map = config.add_child("policy-map TEST") - assert policy_map.sectional_exit_text_parent_level is True + config_raw = """no logging console +""" + config = get_hconfig(driver, config_raw) + child = next(iter(config.children)) - # Test class-map which has exit_text_parent_level=True - class_map = config.add_child("class-map TEST") - assert class_map.sectional_exit_text_parent_level is True + # 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",) - # Test community-set which has exit_text_parent_level=True - community_set = config.add_child("community-set TEST") - assert community_set.sectional_exit_text_parent_level is True - # Test extcommunity-set which has exit_text_parent_level=True - extcommunity_set = config.add_child("extcommunity-set TEST") - assert extcommunity_set.sectional_exit_text_parent_level is True +def test_idempotency_key_regex_fallback_to_original() -> None: + """Test idempotency key regex matching fallback to original text.""" + driver = HConfigDriverCiscoIOS() - # Test template which has exit_text_parent_level=True - template = config.add_child("template TEST") - assert template.sectional_exit_text_parent_level is True + 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_sectional_exit_text_parent_level_cisco_xr_false() -> None: - """Test sectional_exit_text_parent_level returns False for Cisco XR configs without parent-level exit text.""" - platform = Platform.CISCO_XR - config = get_hconfig(platform) - # Test interface which has exit_text_parent_level=False (default) - interface = config.add_child("interface GigabitEthernet0/0/0/0") - assert interface.sectional_exit_text_parent_level is False +def test_idempotency_key_suffix_single_match() -> None: + """Test idempotency key with single suffix that matches (not tuple).""" + driver = HConfigDriverCiscoIOS() - # Test router bgp which has exit_text_parent_level=False (default) - router_bgp = config.add_child("router bgp 65000") - assert router_bgp.sectional_exit_text_parent_level is False + 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_sectional_exit_text_parent_level_cisco_ios() -> None: - """Test sectional_exit_text_parent_level returns False for standard Cisco IOS configs.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - # Cisco IOS interfaces don't have exit_text_parent_level=True - interface = config.add_child("interface GigabitEthernet0/0") - assert interface.sectional_exit_text_parent_level is False +def test_idempotency_key_contains_single_match() -> None: + """Test idempotency key with single contains that matches (not tuple).""" + driver = HConfigDriverCiscoIOS() - # Cisco IOS router configurations don't have exit_text_parent_level=True - router = config.add_child("router ospf 1") - assert router.sectional_exit_text_parent_level is False + config_raw = """logging console emergency +""" + config = get_hconfig(driver, config_raw) + child = next(iter(config.children)) - # Standard configuration sections - line = config.add_child("line vty 0 4") - assert line.sectional_exit_text_parent_level is False + # 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_sectional_exit_text_parent_level_no_match() -> None: - """Test sectional_exit_text_parent_level returns False when no rules match.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) +def test_idempotency_key_regex_greedy_with_plus() -> None: + """Test idempotency key with greedy regex using .+ suffix.""" + driver = HConfigDriverCiscoIOS() - # A child that doesn't match any sectional_exiting rules - hostname = config.add_child("hostname TEST") - assert hostname.sectional_exit_text_parent_level is False + config_raw = """interface GigabitEthernet1 +""" + config = get_hconfig(driver, config_raw) + child = next(iter(config.children)) - # A simple config line without children - ntp = config.add_child("ntp server 10.0.0.1") - assert ntp.sectional_exit_text_parent_level is False + # 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_sectional_exit_text_parent_level_with_nested_children() -> None: - """Test sectional_exit_text_parent_level with nested child configurations.""" - platform = Platform.CISCO_XR - config = get_hconfig(platform) +def test_idempotency_key_regex_trimmed_to_no_match() -> None: + """Test idempotency key when trimmed regex doesn't match.""" + driver = HConfigDriverCiscoIOS() - # Create a route-policy with nested children - route_policy = config.add_child("route-policy TEST") - if_statement = route_policy.add_child("if destination in (192.0.2.0/24) then") + config_raw = """logging console +""" + config = get_hconfig(driver, config_raw) + child = next(iter(config.children)) - # Parent (route-policy) should have exit_text_parent_level=True - assert route_policy.sectional_exit_text_parent_level is True + # 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",) - # Nested child should not match the sectional_exiting rule for route-policy - assert if_statement.sectional_exit_text_parent_level is False +def test_child_hash_eq_consistency_new_in_config() -> None: + """Test that equal HConfigChild objects have equal hashes regardless of new_in_config. -def test_sectional_exit_text_parent_level_indentation_in_lines() -> None: - """Test that sectional_exit_text_parent_level affects indentation in lines output.""" - platform = Platform.CISCO_XR + Validates the bug in issue #185: __hash__ includes new_in_config but __eq__ does not, + violating the Python invariant that a == b implies hash(a) == hash(b). + """ + platform = Platform.CISCO_IOS config = get_hconfig(platform) + child1 = config.add_child("interface GigabitEthernet0/0") + config2 = get_hconfig(platform) + child2 = config2.add_child("interface GigabitEthernet0/0") - # Create a route-policy with children - exit text should be at parent level (depth - 1) - route_policy = config.add_child("route-policy TEST") - route_policy.add_child("set local-preference 200") - route_policy.add_child("pass") - - # Get lines with sectional_exiting=True - lines = list(config.lines(sectional_exiting=True)) - - # The last line should be "end-policy" at depth 0 (parent level) - # route-policy is at depth 1, so exit text at depth 0 means no indentation - assert lines[-1] == "end-policy" - assert not lines[-1].startswith(" ") - + child1.new_in_config = False + child2.new_in_config = True -def test_sectional_exit_text_parent_level_generic_platform() -> None: - """Test sectional_exit_text_parent_level with generic platform.""" - platform = Platform.GENERIC - config = get_hconfig(platform) + # These two children compare as equal (same text, no tags, no children) + assert child1 == child2 + # Python invariant: equal objects must have equal hashes + assert hash(child1) == hash(child2) - # Generic platform has no specific sectional_exiting rules with parent_level=True - section = config.add_child("section test") - assert section.sectional_exit_text_parent_level is False +def test_child_hash_eq_consistency_order_weight() -> None: + """Test that equal HConfigChild objects have equal hashes regardless of order_weight. -def test_children_eq_with_non_children_type() -> None: - """Test HConfigChildren.__eq__ with non-HConfigChildren object returns NotImplemented.""" + Validates the bug in issue #185: __hash__ includes order_weight but __eq__ does not, + violating the Python invariant that a == b implies hash(a) == hash(b). + """ platform = Platform.CISCO_IOS config = get_hconfig(platform) - interface = config.add_child("interface GigabitEthernet0/0") + child1 = config.add_child("interface GigabitEthernet0/0") + config2 = get_hconfig(platform) + child2 = config2.add_child("interface GigabitEthernet0/0") + + child1.order_weight = 0 + child2.order_weight = 100 - # Directly call __eq__ to verify it returns NotImplemented for non-HConfigChildren types - # We must use __eq__ directly here to test the NotImplemented return value - result = interface.children.__eq__("not a children object") # pylint: disable=unnecessary-dunder-call # noqa: PLC2801 - assert result is NotImplemented + # These two children compare as equal (same text, no tags, no children) + assert child1 == child2 + # Python invariant: equal objects must have equal hashes + assert hash(child1) == hash(child2) - # This allows Python to try the reverse comparison, which results in False - assert interface.children != "not a children object" +def test_child_hash_eq_consistency_tags() -> None: + """Test that __hash__ and __eq__ agree on whether tags affect equality. -def test_children_clear() -> None: - """Test HConfigChildren.clear() method.""" + Validates the bug in issue #185: __eq__ checks tags but __hash__ does not include + tags, meaning two objects that compare unequal could have the same hash (not a + correctness violation, but inconsistent) while also raising the question of whether + tags should be part of the hash. + """ platform = Platform.CISCO_IOS config = get_hconfig(platform) - interface = config.add_child("interface GigabitEthernet0/0") - interface.add_child("description test") - interface.add_child("ip address 192.0.2.1 255.255.255.0") + child1 = config.add_child("interface GigabitEthernet0/0") + config2 = get_hconfig(platform) + child2 = config2.add_child("interface GigabitEthernet0/0") - # Verify children exist - assert len(interface.children) == 2 - assert "description test" in interface.children + child1.tags = frozenset({"safe"}) + child2.tags = frozenset() - # Clear all children - interface.children.clear() + # __eq__ considers tags, so these are unequal + assert child1 != child2 + # Since they are unequal, their hashes should differ to avoid excessive collisions + # (not strictly required by the invariant, but required for correctness in reverse: + # if hash(a) != hash(b) then a != b must hold — currently tags are in __eq__ but + # not __hash__, so unequal objects can share a hash, which means dict/set lookup + # will fall back to __eq__ unexpectedly) + assert hash(child1) != hash(child2) - # Verify children are gone - assert len(interface.children) == 0 - assert "description test" not in interface.children +def test_child_set_deduplication_with_new_in_config() -> None: + """Test that equal HConfigChild objects are deduplicated correctly in sets. -def test_children_delete_by_child_object() -> None: - """Test HConfigChildren.delete() with HConfigChild object.""" + Validates the practical impact of issue #185: when new_in_config differs, + two logically equal children occupy different set buckets, causing duplicates. + """ platform = Platform.CISCO_IOS config = get_hconfig(platform) - interface = config.add_child("interface GigabitEthernet0/0") - desc = interface.add_child("description test") - ip_addr = interface.add_child("ip address 192.0.2.1 255.255.255.0") + child1 = config.add_child("interface GigabitEthernet0/0") + config2 = get_hconfig(platform) + child2 = config2.add_child("interface GigabitEthernet0/0") - # Verify both children exist - assert len(interface.children) == 2 + child1.new_in_config = False + child2.new_in_config = True - # Delete by child object - interface.children.delete(desc) + assert child1 == child2 + # Equal objects must collapse to one entry in a set + assert len({child1, child2}) == 1 - # Verify only one child remains - assert len(interface.children) == 1 - assert interface.children[0] is ip_addr - assert "description test" not in interface.children +def test_child_dict_key_lookup_with_order_weight() -> None: + """Test that HConfigChild objects with differing order_weight work as dict keys. -def test_children_delete_by_child_object_not_present() -> None: - """Test HConfigChildren.delete() with HConfigChild object that's not in the collection.""" + Validates the practical impact of issue #185: when order_weight differs, a + logically equal child cannot be found as a dict key. + """ platform = Platform.CISCO_IOS config = get_hconfig(platform) - interface = config.add_child("interface GigabitEthernet0/0") - interface.add_child("description test") - - # Create a child that's not part of this interface - other_interface = config.add_child("interface GigabitEthernet0/1") - other_child = other_interface.add_child("description other") - - # Verify interface has 1 child - assert len(interface.children) == 1 + child1 = config.add_child("interface GigabitEthernet0/0") + config2 = get_hconfig(platform) + child2 = config2.add_child("interface GigabitEthernet0/0") - # Try to delete a child that's not in the collection - interface.children.delete(other_child) + child1.order_weight = 0 + child2.order_weight = 100 - # Verify child count hasn't changed - assert len(interface.children) == 1 + assert child1 == child2 + lookup: dict[HConfigChild, str] = {child1: "found"} + # child2 is equal to child1, so it must find the same dict entry + assert lookup[child2] == "found" -def test_children_extend() -> None: - """Test HConfigChildren.extend() method.""" +def test_indented_text_style_literal_values() -> None: + """Test that indented_text accepts each valid TextStyle literal value.""" platform = Platform.CISCO_IOS config = get_hconfig(platform) - interface1 = config.add_child("interface GigabitEthernet0/0") - interface2 = config.add_child("interface GigabitEthernet0/1") - - # Add children to interface2 - desc = interface2.add_child("description test") - ip_addr = interface2.add_child("ip address 192.0.2.1 255.255.255.0") + child = config.add_child("interface GigabitEthernet0/0") + child.comments.add("a comment") - # Verify interface1 has no children - assert len(interface1.children) == 0 + # without_comments: should NOT include the comment + result_without = child.indented_text(style="without_comments") + assert "interface GigabitEthernet0/0" in result_without + assert "!" not in result_without - # Extend interface1's children with interface2's children - interface1.children.extend([desc, ip_addr]) + # with_comments: should include the comment + result_with = child.indented_text(style="with_comments") + assert "interface GigabitEthernet0/0" in result_with + assert "!a comment" in result_with - # Verify interface1 now has 2 children - assert len(interface1.children) == 2 - assert "description test" in interface1.children - assert "ip address 192.0.2.1 255.255.255.0" in interface1.children + # merged: should include instance count info + instance = Instance( + id=1, comments=frozenset(["inst comment"]), tags=frozenset(["tag1"]) + ) + child.instances.append(instance) + result_merged = child.indented_text(style="merged") + assert "1 instance" in result_merged diff --git a/tests/unit/test_children.py b/tests/unit/test_children.py new file mode 100644 index 00000000..8a2e725d --- /dev/null +++ b/tests/unit/test_children.py @@ -0,0 +1,230 @@ +"""Unit tests for HConfigChildren.""" + +from hier_config import get_hconfig +from hier_config.models import Platform + + +def test_rebuild_children_dict(platform_a: Platform) -> None: + hier1 = get_hconfig(platform_a) + interface = hier1.add_child("interface Vlan2") + interface.add_children( + ("description switch-mgmt-192.168.1.0/24", "ip address 192.168.1.0/24"), + ) + delta_a = hier1 + hier1.children.rebuild_mapping() + delta_b = hier1 + + assert tuple(delta_a.all_children()) == tuple(delta_b.all_children()) + + +def test_hconfig_children_setitem() -> None: + """Test HConfigChildren __setitem__.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + config.add_child("interface GigabitEthernet0/0") + child2_text = "interface GigabitEthernet0/1" + config.add_child(child2_text) + child3_text = "interface GigabitEthernet0/2" + child3 = config.instantiate_child(child3_text) + config.children[1] = child3 + + assert config.children[1].text == child3_text + assert child3_text in config.children + + +def test_hconfig_children_contains() -> None: + """Test HConfigChildren __contains__.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + config.add_child("interface GigabitEthernet0/0") + + assert "interface GigabitEthernet0/0" in config.children + assert "interface GigabitEthernet0/1" not in config.children + + +def test_hconfig_children_eq_fast_fail() -> None: + """Test HConfigChildren __eq__ fast fail.""" + platform = Platform.CISCO_IOS + config1 = get_hconfig(platform) + config2 = get_hconfig(platform) + + config1.add_child("interface GigabitEthernet0/0") + config2.add_child("interface GigabitEthernet0/0") + config2.add_child("interface GigabitEthernet0/1") + + assert config1.children != config2.children + + +def test_hconfig_children_eq_keys_mismatch() -> None: + """Test HConfigChildren __eq__ key mismatch.""" + platform = Platform.CISCO_IOS + config1 = get_hconfig(platform) + config2 = get_hconfig(platform) + + config1.add_child("interface GigabitEthernet0/0") + config2.add_child("interface GigabitEthernet0/1") + + assert config1.children != config2.children + + +def test_hconfig_children_hash() -> None: + """Test HConfigChildren __hash__.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + config.add_child("interface GigabitEthernet0/0") + hash_val = hash(config.children) + + assert isinstance(hash_val, int) + + +def test_hconfig_children_getitem_slice() -> None: + """Test HConfigChildren __getitem__ with slice.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + config.add_child("interface GigabitEthernet0/0") + config.add_child("interface GigabitEthernet0/1") + config.add_child("interface GigabitEthernet0/2") + slice_result = config.children[0:2] + + assert isinstance(slice_result, list) + assert len(slice_result) == 2 + assert slice_result[0].text == "interface GigabitEthernet0/0" + + +def test_children_eq_with_non_children_type() -> None: + """Test HConfigChildren.__eq__ with non-HConfigChildren object returns NotImplemented.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + interface = config.add_child("interface GigabitEthernet0/0") + + # Directly call __eq__ to verify it returns NotImplemented for non-HConfigChildren types + # We must use __eq__ directly here to test the NotImplemented return value + result = interface.children.__eq__("not a children object") # pylint: disable=unnecessary-dunder-call # noqa: PLC2801 + assert result is NotImplemented + + # This allows Python to try the reverse comparison, which results in False + assert interface.children != "not a children object" + + +def test_children_clear() -> None: + """Test HConfigChildren.clear() method.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + interface = config.add_child("interface GigabitEthernet0/0") + interface.add_child("description test") + interface.add_child("ip address 192.0.2.1 255.255.255.0") + + # Verify children exist + assert len(interface.children) == 2 + assert "description test" in interface.children + + # Clear all children + interface.children.clear() + + # Verify children are gone + assert len(interface.children) == 0 + assert "description test" not in interface.children + + +def test_children_delete_by_child_object() -> None: + """Test HConfigChildren.delete() with HConfigChild object.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + interface = config.add_child("interface GigabitEthernet0/0") + desc = interface.add_child("description test") + ip_addr = interface.add_child("ip address 192.0.2.1 255.255.255.0") + + # Verify both children exist + assert len(interface.children) == 2 + + # Delete by child object + interface.children.delete(desc) + + # Verify only one child remains + assert len(interface.children) == 1 + assert interface.children[0] is ip_addr + assert "description test" not in interface.children + + +def test_children_delete_by_child_object_not_present() -> None: + """Test HConfigChildren.delete() with HConfigChild object that's not in the collection.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + interface = config.add_child("interface GigabitEthernet0/0") + interface.add_child("description test") + + # Create a child that's not part of this interface + other_interface = config.add_child("interface GigabitEthernet0/1") + other_child = other_interface.add_child("description other") + + # Verify interface has 1 child + assert len(interface.children) == 1 + + # Try to delete a child that's not in the collection + interface.children.delete(other_child) + + # Verify child count hasn't changed + assert len(interface.children) == 1 + + +def test_children_extend() -> None: + """Test HConfigChildren.extend() method.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + interface1 = config.add_child("interface GigabitEthernet0/0") + interface2 = config.add_child("interface GigabitEthernet0/1") + + # Add children to interface2 + desc = interface2.add_child("description test") + ip_addr = interface2.add_child("ip address 192.0.2.1 255.255.255.0") + + # Verify interface1 has no children + assert len(interface1.children) == 0 + + # Extend interface1's children with interface2's children + interface1.children.extend([desc, ip_addr]) + + # Verify interface1 now has 2 children + assert len(interface1.children) == 2 + assert "description test" in interface1.children + assert "ip address 192.0.2.1 255.255.255.0" in interface1.children + + +def test_children_eq_empty_fast_success() -> None: + """Test HConfigChildren __eq__ fast success for empty.""" + platform = Platform.CISCO_IOS + config1 = get_hconfig(platform) + config2 = get_hconfig(platform) + + assert config1.children == config2.children + + +def test_children_hash_with_data() -> None: + """Test HConfigChildren __hash__ with data.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + config.add_child("interface GigabitEthernet0/0") + config.add_child("interface GigabitEthernet0/1") + hash1 = hash(config.children) + hash2 = hash(config.children) + + assert hash1 == hash2 + assert isinstance(hash1, int) + + +def test_children_getitem_with_slice() -> None: + """Test HConfigChildren __getitem__ with slice.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + config.add_child("interface GigabitEthernet0/0") + config.add_child("interface GigabitEthernet0/1") + config.add_child("interface GigabitEthernet0/2") + config.add_child("interface GigabitEthernet0/3") + slice1 = config.children[1:3] + assert len(slice1) == 2 + + slice2 = config.children[::2] + assert len(slice2) == 2 + + slice3 = config.children[:2] + assert len(slice3) == 2 diff --git a/tests/test_constructors.py b/tests/unit/test_constructors.py similarity index 93% rename from tests/test_constructors.py rename to tests/unit/test_constructors.py index 6b1b1d6c..4b236d31 100644 --- a/tests/test_constructors.py +++ b/tests/unit/test_constructors.py @@ -18,22 +18,25 @@ _load_from_string_lines, # pyright: ignore[reportPrivateUsage] get_hconfig_fast_generic_load, ) +from hier_config.exceptions import DriverNotFoundError, InvalidConfigError from hier_config.models import Platform from hier_config.root import HConfig def test_get_hconfig_driver_unsupported_platform() -> None: - """Test ValueError when platform is not supported (lines 49-50).""" - with pytest.raises(ValueError, match="Unsupported platform: invalid_platform"): + """Test DriverNotFoundError when platform is not supported (lines 49-50).""" + with pytest.raises( + DriverNotFoundError, match="Unsupported platform: invalid_platform" + ): get_hconfig_driver("invalid_platform") # type: ignore[arg-type] def test_get_hconfig_view_unsupported_platform() -> None: - """Test ValueError when platform is not supported (lines 72-73).""" + """Test DriverNotFoundError when platform is not supported (lines 72-73).""" driver = get_hconfig_driver(Platform.FORTINET_FORTIOS) config = HConfig(driver=driver) with pytest.raises( - ValueError, match="Unsupported platform: HConfigDriverFortinetFortiOS" + DriverNotFoundError, match="Unsupported platform: HConfigDriverFortinetFortiOS" ): get_hconfig_view(config) @@ -83,9 +86,9 @@ def test_get_hconfig_from_dump_with_depth_calculation() -> None: assert interface is not None assert len(interface.children) > 0 - assert interface.depth() == 1 + assert interface.depth == 1 for subchild in interface.children: - assert subchild.depth() == 2 + assert subchild.depth == 2 def test_get_hconfig_fast_generic_load_with_string_conversion() -> None: @@ -237,7 +240,9 @@ def test_load_from_string_lines_with_incomplete_banner() -> None: This is line 1 This is line 2 """ - with pytest.raises(ValueError, match="we are still in a banner for some reason"): + with pytest.raises( + InvalidConfigError, match="we are still in a banner for some reason" + ): _load_from_string_lines(config, config_text) @@ -298,7 +303,7 @@ def test_get_hconfig_from_dump_parent_depth_traversal() -> None: break assert router_bgp is not None - assert router_bgp.depth() == 1 + assert router_bgp.depth == 1 if router_bgp.children: address_family = None @@ -308,10 +313,10 @@ def test_get_hconfig_from_dump_parent_depth_traversal() -> None: break if address_family: - assert address_family.depth() == 2 + assert address_family.depth == 2 if address_family.children: for nested_child in address_family.children: - assert nested_child.depth() == 3 + assert nested_child.depth == 3 def test_banner_detection_with_various_delimiters() -> None: diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py new file mode 100644 index 00000000..cfc3d98e --- /dev/null +++ b/tests/unit/test_exceptions.py @@ -0,0 +1,52 @@ +"""Tests for the custom exception hierarchy (#219).""" + +import pytest + +from hier_config import Platform, WorkflowRemediation, get_hconfig, get_hconfig_driver +from hier_config.exceptions import ( + DriverNotFoundError, + DuplicateChildError, + HierConfigError, + IncompatibleDriverError, + InvalidConfigError, +) + + +def test_hier_config_error_is_base_exception() -> None: + """All custom exceptions inherit from HierConfigError.""" + assert issubclass(DuplicateChildError, HierConfigError) + assert issubclass(DriverNotFoundError, HierConfigError) + assert issubclass(InvalidConfigError, HierConfigError) + assert issubclass(IncompatibleDriverError, HierConfigError) + + +def test_hier_config_error_is_catchable_as_exception() -> None: + """HierConfigError itself inherits from Exception.""" + assert issubclass(HierConfigError, Exception) + + +def test_duplicate_child_error_on_duplicate_section() -> None: + config = get_hconfig(Platform.CISCO_IOS, "interface Loopback0") + with pytest.raises(DuplicateChildError, match="Found a duplicate section"): + config.add_child("interface Loopback0") + + +def test_driver_not_found_error_invalid_platform() -> None: + """get_hconfig_driver raises DriverNotFoundError for unsupported platforms.""" + with pytest.raises(DriverNotFoundError, match="Unsupported platform"): + get_hconfig_driver("bogus_platform") # type: ignore[arg-type] + + +def test_incompatible_driver_error_mismatched_drivers() -> None: + """WorkflowRemediation raises IncompatibleDriverError for mismatched drivers.""" + running = get_hconfig(Platform.CISCO_IOS) + generated = get_hconfig(Platform.ARISTA_EOS) + with pytest.raises(IncompatibleDriverError, match="same driver"): + WorkflowRemediation(running, generated) + + +def test_invalid_config_error_banner_parsing() -> None: + """Malformed banner config raises InvalidConfigError.""" + config_text = "banner motd ^C\nthis banner never ends" + with pytest.raises(InvalidConfigError, match="banner"): + get_hconfig(Platform.CISCO_IOS, config_text) diff --git a/tests/test_reporting.py b/tests/unit/test_reporting.py similarity index 100% rename from tests/test_reporting.py rename to tests/unit/test_reporting.py diff --git a/tests/unit/test_root.py b/tests/unit/test_root.py new file mode 100644 index 00000000..3c5496a4 --- /dev/null +++ b/tests/unit/test_root.py @@ -0,0 +1,193 @@ +"""Tests for HConfig root node behavior.""" + +import tempfile +from pathlib import Path + +import pytest + +from hier_config import ( + get_hconfig, + get_hconfig_driver, + get_hconfig_fast_load, + get_hconfig_from_dump, +) +from hier_config.models import Platform + + +def test_bool(platform_a: Platform) -> None: + config = get_hconfig(platform_a) + assert config + + +def test_hash(platform_a: Platform) -> None: + config = get_hconfig_fast_load(platform_a, ("interface 1/1", " untagged vlan 5")) + assert hash(config) + + +def test_merge(platform_a: Platform, platform_b: Platform) -> None: + hier1 = get_hconfig(platform_a) + hier1.add_child("interface Vlan2") + hier2 = get_hconfig(platform_b) + hier2.add_child("interface Vlan3") + + assert len(tuple(hier1.all_children())) == 1 + assert len(tuple(hier2.all_children())) == 1 + + hier1.merge(hier2) + + assert len(tuple(hier1.all_children())) == 2 + + +def test_load_from_file(platform_a: Platform) -> None: + config = "interface Vlan2\n ip address 1.1.1.1 255.255.255.0" + + with tempfile.NamedTemporaryFile( + mode="r+", + delete=False, + encoding="utf8", + ) as myfile: + myfile.file.write(config) + myfile.file.flush() + myfile.close() + hier = get_hconfig(get_hconfig_driver(platform_a), Path(myfile.name)) + Path(myfile.name).unlink() + + assert len(tuple(hier.all_children())) == 2 + + +def test_load_from_config_text(platform_a: Platform) -> None: + config = "interface Vlan2\n ip address 1.1.1.1 255.255.255.0" + hier = get_hconfig(get_hconfig_driver(platform_a), config) + assert len(tuple(hier.all_children())) == 2 + + +def test_dump_and_load_from_dump_and_compare(platform_a: Platform) -> None: + hier_pre_dump = get_hconfig(platform_a) + b2 = hier_pre_dump.add_children_deep(("a1", "b2")) + + b2.order_weight = 400 + b2.add_tags("test") + b2.comments.add("test comment") + b2.new_in_config = True + + dump = hier_pre_dump.dump() + hier_post_dump = get_hconfig_from_dump(hier_pre_dump.driver, dump) + + assert hier_pre_dump == hier_post_dump + + +def test_unified_diff() -> None: + platform = Platform.CISCO_IOS + + config_a = get_hconfig(platform) + config_b = get_hconfig(platform) + # deep differences + config_a.add_children_deep(("a", "aa", "aaa", "aaaa")) + config_b.add_children_deep(("a", "aa", "aab", "aaba")) + # these children will be the same and should not appear in the diff + config_a.add_children_deep(("b", "ba", "baa")) + config_b.add_children_deep(("b", "ba", "baa")) + # root level differences + config_a.add_children_deep(("c", "ca")) + config_b.add_child("d") + + diff = tuple(config_a.unified_diff(config_b)) + assert diff == ( + "a", + " aa", + " - aaa", + " - aaaa", + " + aab", + " + aaba", + "- c", + " - ca", + "+ d", + ) + + +def test_hconfig_str() -> None: + """Test HConfig __str__ method.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + config.add_child("hostname router1") + config.add_child("interface GigabitEthernet0/0") + str_output = str(config) + + assert "hostname router1" in str_output + assert "interface GigabitEthernet0/0" in str_output + assert isinstance(str_output, str) + + +def test_hconfig_eq_not_hconfig() -> None: + """Test HConfig __eq__ with non-HConfig object.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + result = config == "not an HConfig" + + assert not result + + +def test_hconfig_real_indent_level() -> None: + """Test HConfig real_indent_level property.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + + assert config.real_indent_level == -1 + + +def test_hconfig_parent_property() -> None: + """Test HConfig parent property returns self.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + + assert config.parent is config + + +def test_hconfig_is_leaf() -> None: + """Test HConfig is_leaf property.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + + assert config.is_leaf is False + + +def test_hconfig_tags_setter() -> None: + """Test HConfig tags setter.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + interface = config.add_child("interface GigabitEthernet0/0") + desc = interface.add_child("description test") + config.tags = frozenset(["production", "core"]) + + assert "production" in desc.tags + assert "core" in desc.tags + + +def test_hconfig_add_children_deep_typeerror() -> None: + """Test HConfig add_children_deep raises TypeError.""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + + with pytest.raises(TypeError, match="base was an HConfig object"): + config.add_children_deep([]) + + +def test_hconfig_deep_copy() -> None: + """Test HConfig deep_copy method).""" + platform = Platform.CISCO_IOS + config = get_hconfig(platform) + interface = config.add_child("interface GigabitEthernet0/0") + interface.add_child("description test") + config.add_child("hostname router1") + config_copy = config.deep_copy() + + assert config_copy is not config + assert len(tuple(config_copy.all_children())) == len(tuple(config.all_children())) + assert config_copy.get_child(equals="interface GigabitEthernet0/0") is not None + assert config_copy.get_child(equals="hostname router1") is not None + + original_interface = config.get_child(equals="interface GigabitEthernet0/0") + copied_interface = config_copy.get_child(equals="interface GigabitEthernet0/0") + assert original_interface is not None + assert copied_interface is not None + assert original_interface is not copied_interface diff --git a/tests/test_utils.py b/tests/unit/test_utils.py similarity index 73% rename from tests/test_utils.py rename to tests/unit/test_utils.py index 63ee3bfe..3575e21c 100644 --- a/tests/test_utils.py +++ b/tests/unit/test_utils.py @@ -9,11 +9,9 @@ from hier_config.models import MatchRule, TagRule from hier_config.utils import ( _set_match_rule, # pyright: ignore[reportPrivateUsage] - hconfig_v2_os_v3_platform_mapper, - hconfig_v3_platform_v2_os_mapper, - load_hconfig_v2_options, - load_hconfig_v2_tags, + load_driver_rules, load_hier_config_tags, + load_tag_rules, read_text_from_file, ) @@ -91,29 +89,48 @@ def test_load_hier_config_tags_empty_file(tmp_path: Path) -> None: load_hier_config_tags(str(empty_file)) -def test_hconfig_v2_os_v3_platform_mapper() -> None: - # Valid mappings - assert hconfig_v2_os_v3_platform_mapper("ios") == Platform.CISCO_IOS - assert hconfig_v2_os_v3_platform_mapper("nxos") == Platform.CISCO_NXOS - assert hconfig_v2_os_v3_platform_mapper("junos") == Platform.JUNIPER_JUNOS - assert hconfig_v2_os_v3_platform_mapper("invalid") == Platform.GENERIC - - -def test_hconfig_v3_platform_v2_os_mapper() -> None: - # Valid mappings - assert hconfig_v3_platform_v2_os_mapper(Platform.CISCO_IOS) == "ios" - assert hconfig_v3_platform_v2_os_mapper(Platform.CISCO_NXOS) == "nxos" - assert hconfig_v3_platform_v2_os_mapper(Platform.JUNIPER_JUNOS) == "junos" - assert hconfig_v3_platform_v2_os_mapper(Platform.GENERIC) == "generic" - - -def test_load_hconfig_v2_options( - platform_generic: Platform, v2_options: dict[str, Any] -) -> None: - # pylint: disable=redefined-outer-name, unused-argument - platform = platform_generic - - driver = load_hconfig_v2_options(v2_options, platform) +def test_load_driver_rules(platform_generic: Platform) -> None: + # pylint: disable=redefined-outer-name + options: dict[str, Any] = { + "negation": "no", + "sectional_overwrite": [{"lineage": [{"startswith": "template"}]}], + "sectional_overwrite_no_negate": [{"lineage": [{"startswith": "as-path-set"}]}], + "ordering": [{"lineage": [{"startswith": "ntp"}], "order": 700}], + "indent_adjust": [ + { + "start_expression": "^\\s*template", + "end_expression": "^\\s*end-template", + } + ], + "parent_allows_duplicate_child": [ + {"lineage": [{"startswith": "route-policy"}]} + ], + "sectional_exiting": [ + {"lineage": [{"startswith": "router bgp"}], "exit_text": "exit"} + ], + "full_text_sub": [{"search": "banner motd # replace me #", "replace": ""}], + "per_line_sub": [{"search": "^!.*Generated.*$", "replace": ""}], + "idempotent_commands_blacklist": [ + { + "lineage": [ + {"startswith": "interface"}, + {"re_search": "ip address.*secondary"}, + ] + } + ], + "idempotent_commands": [{"lineage": [{"startswith": "interface"}]}], + "negation_negate_with": [ + { + "lineage": [ + {"startswith": "interface Ethernet"}, + {"startswith": "spanning-tree port type"}, + ], + "use": "no spanning-tree port type", + } + ], + } + + driver = load_driver_rules(options, platform_generic) # Assert sectional overwrite assert len(driver.rules.sectional_overwrite) == 1 @@ -183,8 +200,8 @@ def test_load_hconfig_v2_options( assert driver.rules.negate_with[0].use == "no spanning-tree port type" -def test_load_hconfig_v2_tags_valid_input() -> None: - v2_tags = [ +def test_load_tag_rules_valid_input() -> None: + tags = [ { "lineage": [ {"startswith": ["ip name-server", "no ip name-server", "ntp", "no ntp"]} @@ -212,21 +229,21 @@ def test_load_hconfig_v2_tags_valid_input() -> None: ), ) - result = load_hconfig_v2_tags(v2_tags) + result = load_tag_rules(tags) assert result == expected_output -def test_load_hconfig_v2_tags_empty_input() -> None: - v2_tags: list[dict[str, Any]] = [] +def test_load_tag_rules_empty_input() -> None: + tags: list[dict[str, Any]] = [] expected_output = () - result = load_hconfig_v2_tags(v2_tags) + result = load_tag_rules(tags) assert result == expected_output -def test_load_hconfig_v2_tags_multiple_lineage_fields() -> None: - v2_tags = [ +def test_load_tag_rules_multiple_lineage_fields() -> None: + tags = [ { "lineage": [ {"startswith": ["ip name-server"]}, @@ -246,12 +263,12 @@ def test_load_hconfig_v2_tags_multiple_lineage_fields() -> None: ), ) - result = load_hconfig_v2_tags(v2_tags) + result = load_tag_rules(tags) assert result == expected_output -def test_load_hconfig_v2_tags_empty_lineage() -> None: - v2_tags: list[dict[str, str | list[str]]] = [ +def test_load_tag_rules_empty_lineage() -> None: + tags: list[dict[str, str | list[str]]] = [ { "lineage": [], "add_tags": "empty", @@ -260,13 +277,13 @@ def test_load_hconfig_v2_tags_empty_lineage() -> None: expected_output = (TagRule(match_rules=(), apply_tags=frozenset(["empty"])),) - result = load_hconfig_v2_tags(v2_tags) + result = load_tag_rules(tags) assert result == expected_output -def test_load_hconfig_v2_options_from_file_valid(tmp_path: Path) -> None: - """Test loading valid v2 options from a YAML file.""" - file_path = tmp_path / "v2_options.yml" +def test_load_driver_rules_from_file_valid(tmp_path: Path) -> None: + """Test loading valid driver rules from a YAML file.""" + file_path = tmp_path / "options.yml" file_content = """ordering: - lineage: - startswith: ntp @@ -281,7 +298,7 @@ def test_load_hconfig_v2_options_from_file_valid(tmp_path: Path) -> None: file_path.write_text(file_content) platform = Platform.GENERIC - driver = load_hconfig_v2_options(v2_options=str(file_path), platform=platform) + driver = load_driver_rules(options=str(file_path), platform=platform) assert len(driver.rules.ordering) == 1 assert driver.rules.ordering[0].match_rules[0].startswith == "ntp" @@ -295,9 +312,9 @@ def test_load_hconfig_v2_options_from_file_valid(tmp_path: Path) -> None: assert driver.rules.indent_adjust[0].end_expression == "end expression" -def test_load_hconfig_v2_options_from_file_invalid_yaml(tmp_path: Path) -> None: - """Test loading v2 options from a file with invalid YAML syntax.""" - file_path = tmp_path / "invalid_v2_options.yml" +def test_load_driver_rules_from_file_invalid_yaml(tmp_path: Path) -> None: + """Test loading driver rules from a file with invalid YAML syntax.""" + file_path = tmp_path / "invalid_options.yml" file_content = """ordering: - lineage: - startswith: ntp @@ -307,12 +324,12 @@ def test_load_hconfig_v2_options_from_file_invalid_yaml(tmp_path: Path) -> None: platform = Platform.GENERIC with pytest.raises(TypeError): - load_hconfig_v2_options(v2_options=str(file_path), platform=platform) + load_driver_rules(options=str(file_path), platform=platform) -def test_load_hconfig_v2_tags_from_file_valid(tmp_path: Path) -> None: - """Test loading valid v2 tags from a YAML file.""" - file_path = tmp_path / "v2_tags.yml" +def test_load_tag_rules_from_file_valid(tmp_path: Path) -> None: + """Test loading valid tag rules from a YAML file.""" + file_path = tmp_path / "tags.yml" file_content = """- lineage: - startswith: ip name-server add_tags: dns @@ -322,16 +339,16 @@ def test_load_hconfig_v2_tags_from_file_valid(tmp_path: Path) -> None: """ file_path.write_text(file_content) - result = load_hconfig_v2_tags(v2_tags=str(file_path)) + result = load_tag_rules(tags=str(file_path)) assert len(result) == 2 assert result[0].apply_tags == frozenset(["dns"]) assert result[1].apply_tags == frozenset(["bgp"]) -def test_load_hconfig_v2_tags_from_file_invalid_yaml(tmp_path: Path) -> None: - """Test loading v2 tags from a file with invalid YAML syntax.""" - file_path = tmp_path / "invalid_v2_tags.yml" +def test_load_tag_rules_from_file_invalid_yaml(tmp_path: Path) -> None: + """Test loading tag rules from a file with invalid YAML syntax.""" + file_path = tmp_path / "invalid_tags.yml" file_content = """- lineage: - startswith: ip name-server add_tags dns # Missing colon causes a syntax error @@ -339,16 +356,16 @@ def test_load_hconfig_v2_tags_from_file_invalid_yaml(tmp_path: Path) -> None: file_path.write_text(file_content) with pytest.raises(yaml.YAMLError): - load_hconfig_v2_tags(v2_tags=str(file_path)) + load_tag_rules(tags=str(file_path)) -def test_load_hconfig_v2_tags_from_file_empty_file(tmp_path: Path) -> None: - """Test loading v2 tags from an empty file.""" - file_path = tmp_path / "empty_v2_tags.yml" +def test_load_tag_rules_from_file_empty_file(tmp_path: Path) -> None: + """Test loading tag rules from an empty file.""" + file_path = tmp_path / "empty_tags.yml" file_path.write_text("") with pytest.raises(TypeError): - load_hconfig_v2_tags(v2_tags=str(file_path)) + load_tag_rules(tags=str(file_path)) def test_set_match_rule_endswith() -> None: @@ -386,24 +403,24 @@ def test_set_match_rule_none() -> None: assert result is None -def test_load_hconfig_v2_options_invalid_type() -> None: - """Test load_hconfig_v2_options with invalid type.""" +def test_load_driver_rules_invalid_type() -> None: + """Test load_driver_rules with invalid type.""" with pytest.raises( - TypeError, match="v2_options must be a dictionary or a valid file path" + TypeError, match="options must be a dictionary or a valid file path" ): - load_hconfig_v2_options(v2_options=123, platform=Platform.CISCO_IOS) # type: ignore[arg-type] + load_driver_rules(options=123, platform=Platform.CISCO_IOS) # type: ignore[arg-type] -def test_load_hconfig_v2_tags_from_file(tmp_path: Path) -> None: - """Test load_hconfig_v2_tags with file path.""" - file_path = tmp_path / "test_v2_tags.yml" +def test_load_tag_rules_from_file(tmp_path: Path) -> None: + """Test load_tag_rules with file path.""" + file_path = tmp_path / "test_tags.yml" tags_content = """ - lineage: - startswith: interface add_tags: interfaces """ file_path.write_text(tags_content) - result = load_hconfig_v2_tags(v2_tags=str(file_path)) + result = load_tag_rules(tags=str(file_path)) assert len(result) == 1 assert result[0].apply_tags == frozenset(["interfaces"]) diff --git a/tests/test_workflow.py b/tests/unit/test_workflows.py similarity index 91% rename from tests/test_workflow.py rename to tests/unit/test_workflows.py index e74d7e61..eb63fb84 100644 --- a/tests/test_workflow.py +++ b/tests/unit/test_workflows.py @@ -1,6 +1,7 @@ import pytest from hier_config import WorkflowRemediation, get_hconfig +from hier_config.exceptions import IncompatibleDriverError from hier_config.models import Platform, TagRule @@ -50,7 +51,8 @@ def test_remediation_config_driver_mismatch() -> None: generated_config = get_hconfig(Platform.JUNIPER_JUNOS, "dummy_config") with pytest.raises( - ValueError, match=r"The running and generated configs must use the same driver." + IncompatibleDriverError, + match=r"The running and generated configs must use the same driver.", ): WorkflowRemediation(running_config, generated_config) @@ -66,7 +68,7 @@ def test_rollback_config_reverts_changes(wfr: WorkflowRemediation) -> None: # Test if rollback config correctly represents changes needed to revert generated to running rollback_config = wfr.rollback_config rollback_text = "\n".join( - line.cisco_style_text() for line in rollback_config.all_children_sorted() + line.indented_text() for line in rollback_config.all_children_sorted() ) expected_text = "no vlan 4\nno interface Vlan4\nvlan 3\n name switch_mgmt_10.0.4.0/24\ninterface Vlan2\n no mtu 9000\n no ip access-group TEST in\n shutdown\ninterface Vlan3\n description switch_mgmt_10.0.4.0/24\n ip address 10.0.4.1 255.255.0.0" assert rollback_text == expected_text