Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,18 @@ Platform drivers: `CISCO_IOS`, `CISCO_XR`, `CISCO_NXOS`, `ARISTA_EOS`, `FORTINET

Pattern: add frozen Pydantic model in `models.py` → add default factory + field in `HConfigDriverRules` (driver_base.py) → consume in `child.py` or `root.py` → populate in platform driver's `_instantiate_rules()`.

## Development Approach

This project follows **Test-Driven Development (TDD)**:

1. **Write a failing test first** that validates the expected behavior.
2. **Run the test to confirm it fails** for the right reason.
3. **Implement the minimal code** to make the test pass.
4. **Run the full test suite** to ensure no regressions.
5. **Refactor** if needed, keeping tests green.

All new features and bug fixes must have corresponding tests. Write tests before or alongside implementation, not after.

## Code Style

- Strict type checking: pyright strict mode + mypy strict + pylint with pydantic plugin.
Expand Down
86 changes: 86 additions & 0 deletions docs/drivers.md
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,92 @@ Both approaches allow you to extend the functionality of the Cisco IOS driver:
1. **Subclassing:** Recommended for reusable, modular extensions where the driver logic can be encapsulated in a new class.
2. **Dynamic Modification:** Useful when the driver is instantiated dynamically, and you need to modify the rules at runtime.

### Example 3: Adding Unused Object Detection

Unused object detection is not enabled in any driver by default — it must be explicitly configured. This ensures no unintended side-effects for users who are not expecting it.

You can add unused object rules dynamically or via `load_hconfig_v2_options`:

#### Dynamic Extension

```python
from hier_config import get_hconfig, get_hconfig_driver, Platform
from hier_config.models import MatchRule, ReferenceLocation, UnusedObjectRule

driver = get_hconfig_driver(Platform.CISCO_XR)

# Detect unused IPv4 ACLs
driver.rules.unused_objects.append(
UnusedObjectRule(
match_rules=(MatchRule(startswith="ipv4 access-list "),),
name_re=r"^ipv4 access-list (?P<name>\S+)",
reference_locations=(
ReferenceLocation(
match_rules=(MatchRule(startswith="interface "),),
reference_re=r"\bipv4 access-group {name}\b",
),
),
)
)

config = get_hconfig(driver, running_config_text)
for unused in config.unused_objects():
print(f"Unused: {unused.text}")
```

#### Via `load_hconfig_v2_options`

```python
from hier_config import get_hconfig, Platform
from hier_config.utils import load_hconfig_v2_options

options = {
"unused_objects": [
{
"lineage": [{"startswith": "ipv4 access-list "}],
"name_re": r"^ipv4 access-list (?P<name>\S+)",
"reference_locations": [
{
"lineage": [{"startswith": "interface "}],
"reference_re": r"\bipv4 access-group {name}\b",
},
],
},
],
}
driver = load_hconfig_v2_options(options, Platform.CISCO_XR)
config = get_hconfig(driver, running_config_text)

for unused in config.unused_objects():
print(f"Unused: {unused.text}")
```

Each `UnusedObjectRule` requires:

- `match_rules` — locates the object definition (e.g., `startswith="ipv4 access-list "`)
- `name_re` — regex with a `(?P<name>...)` capture group to extract the object name
- `reference_locations` — a tuple of `ReferenceLocation` entries, each specifying where to search and what regex pattern (with `{name}` placeholder) to match

### Example 4: Adding Negation Substitution

Some platforms require negation commands to be truncated or transformed. Use `NegationSubRule` for regex-based negation transformations:

```python
from hier_config import get_hconfig_driver, Platform
from hier_config.models import MatchRule, NegationSubRule

driver = get_hconfig_driver(Platform.CISCO_NXOS)

# Truncate SNMP user negation after the username
driver.rules.negation_sub.append(
NegationSubRule(
match_rules=(MatchRule(startswith="snmp-server user "),),
search=r"(no snmp-server user \S+).*",
replace=r"\1",
)
)
```

## Creating a Custom Driver

This guide walks you through the process of creating a custom driver using the `HConfigDriverBase` class from the `hier_config.platforms.driver_base` module. Custom drivers allow you to define operating system-specific rules and behaviors for managing device configurations.
Expand Down
15 changes: 13 additions & 2 deletions hier_config/child.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from itertools import chain
from logging import getLogger
from re import search
from re import search, sub
from typing import TYPE_CHECKING, Any

from .base import HConfigBase
Expand Down Expand Up @@ -257,7 +257,9 @@ def negate(self) -> HConfigChild:
negation string defined in the driver (e.g. ``no ip route``).
2. ``negation_default_when`` rule — rewrites the command to its
``default`` form (e.g. ``no shutdown`` → ``default shutdown``).
3. ``swap_negation`` — toggles the negation prefix/declaration
3. ``negation_sub`` rule — applies a regex substitution to the
negated text (e.g. truncating after a specific token).
4. ``swap_negation`` — toggles the negation prefix/declaration
prefix (e.g. ``shutdown`` ↔ ``no shutdown``).

Returns self so that callers can chain further operations.
Expand All @@ -269,6 +271,15 @@ def negate(self) -> HConfigChild:
if self.use_default_for_negation(self):
return self._default()

for rule in self.driver.rules.negation_sub:
if self.is_lineage_match(rule.match_rules):
self.text = sub(
rule.search,
rule.replace,
f"{self.driver.negation_prefix}{self.text}",
)
return self

return self.driver.swap_negation(self)

def use_default_for_negation(self, config: HConfigChild) -> bool:
Expand Down
3 changes: 3 additions & 0 deletions hier_config/constructors.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,9 @@ def get_hconfig_fast_load(
for child in tuple(config.all_children()):
child.delete_sectional_exit()

for callback in driver.rules.post_load_callbacks:
callback(config)

return config


Expand Down
42 changes: 42 additions & 0 deletions hier_config/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,48 @@ class NegationDefaultWithRule(BaseModel):
use: str


class NegationSubRule(BaseModel):
r"""Regex substitution applied to a command during negation.

When a negated command matches ``match_rules``, ``re.sub(search, replace, text)``
is applied to transform the negation line. Useful when a platform requires
truncated or reformatted negation commands — e.g. NX-OS SNMP user removal
must drop everything after the username.

The regex is applied to the **already-negated** text (with ``no `` prepended).
``replace`` supports back-references such as ``\1``.
"""

match_rules: tuple[MatchRule, ...]
search: str
replace: str


class ReferenceLocation(BaseModel):
"""A location in the config tree where object name references are searched.

``match_rules`` defines a lineage prefix that narrows the search scope.
``reference_re`` is a regex pattern containing ``{name}`` which is
interpolated with the extracted object name before matching.
"""

match_rules: tuple[MatchRule, ...]
reference_re: str


class UnusedObjectRule(BaseModel):
r"""Rule for detecting unused named objects in a configuration.

Identifies object definitions via ``match_rules``, extracts the object
name using ``name_re`` (a regex with a ``(?P<name>...)`` capture group),
and searches for references across all ``reference_locations``.
"""

match_rules: tuple[MatchRule, ...]
name_re: str
reference_locations: tuple[ReferenceLocation, ...]


SetLikeOfStr = frozenset[str] | set[str]


Expand Down
30 changes: 28 additions & 2 deletions hier_config/platforms/cisco_xr/driver.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from collections.abc import Iterable
from __future__ import annotations

from typing import TYPE_CHECKING

from hier_config.child import HConfigChild
from hier_config.models import (
Expand All @@ -14,6 +16,28 @@
)
from hier_config.platforms.driver_base import HConfigDriverBase, HConfigDriverRules

if TYPE_CHECKING:
from collections.abc import Iterable

from hier_config.root import HConfig


def _fixup_xr_comments(config: HConfig) -> None:
"""Move ``!`` comment lines into the next sibling's comments set."""
for parent in (config, *config.all_children()):
siblings = list(parent.children)
comment_buffer: list[str] = []
for sibling in siblings:
if sibling.text.startswith("!"):
comment_text = sibling.text.removeprefix("!").lstrip(" ")
if comment_text:
comment_buffer.append(comment_text)
sibling.delete()
elif comment_buffer:
for comment in comment_buffer:
sibling.comments.add(comment)
comment_buffer.clear()


class HConfigDriverCiscoIOSXR(HConfigDriverBase): # pylint: disable=too-many-instance-attributes
"""Driver for Cisco IOS XR.
Expand Down Expand Up @@ -154,8 +178,10 @@ def _instantiate_rules() -> HConfigDriverRules:
PerLineSubRule(search="^end-set$", replace=" end-set"),
PerLineSubRule(search="^end-group$", replace=" end-group"),
PerLineSubRule(search="^end$", replace=""),
PerLineSubRule(search="^\\s*[#!].*", replace=""),
PerLineSubRule(search="^\\s*#.*", replace=""),
PerLineSubRule(search="^!\\s*$", replace=""),
],
post_load_callbacks=[_fixup_xr_comments],
idempotent_commands=[
IdempotentCommandsRule(
match_rules=(
Expand Down
16 changes: 16 additions & 0 deletions hier_config/platforms/driver_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@
MatchRule,
NegationDefaultWhenRule,
NegationDefaultWithRule,
NegationSubRule,
OrderingRule,
ParentAllowsDuplicateChildRule,
PerLineSubRule,
SectionalExitingRule,
SectionalOverwriteNoNegateRule,
SectionalOverwriteRule,
UnusedObjectRule,
)
from hier_config.root import HConfig

Expand Down Expand Up @@ -80,6 +82,14 @@ def _sectional_overwrite_no_negate_rules_default() -> list[
return []


def _negation_sub_rules_default() -> list[NegationSubRule]:
return []


def _unused_object_rules_default() -> list[UnusedObjectRule]:
return []


class HConfigDriverRules(BaseModel): # pylint: disable=too-many-instance-attributes
"""Pydantic model holding all rule collections for a platform driver.

Expand Down Expand Up @@ -126,6 +136,12 @@ class HConfigDriverRules(BaseModel): # pylint: disable=too-many-instance-attrib
sectional_overwrite_no_negate: list[SectionalOverwriteNoNegateRule] = Field(
default_factory=_sectional_overwrite_no_negate_rules_default
)
negation_sub: list[NegationSubRule] = Field(
default_factory=_negation_sub_rules_default
)
unused_objects: list[UnusedObjectRule] = Field(
default_factory=_unused_object_rules_default
)


class HConfigDriverBase(ABC):
Expand Down
41 changes: 40 additions & 1 deletion hier_config/root.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from .base import HConfigBase
from .child import HConfigChild
from .models import Dump, DumpLine
from .models import Dump, DumpLine, ReferenceLocation

if TYPE_CHECKING:
from collections.abc import Iterable, Iterator
Expand Down Expand Up @@ -208,6 +208,45 @@ def deep_copy(self) -> HConfig:
new_instance.add_deep_copy_of(child)
return new_instance

def unused_objects(self) -> Iterator[HConfigChild]:
"""Yield top-level children that are defined objects with no references.

Uses ``self.driver.rules.unused_objects`` to identify object definitions,
extract their names, and search for references across the config tree.
Objects with zero references are yielded.
"""
from re import search as _re_search # noqa: PLC0415

for rule in self.driver.rules.unused_objects:
seen_names: set[str] = set()
for definition in self.get_children_deep(rule.match_rules):
match = _re_search(rule.name_re, definition.text)
if not match:
continue
name = match.group("name")
if name in seen_names:
continue
seen_names.add(name)

if not self._is_object_referenced(name, rule.reference_locations):
yield definition

def _is_object_referenced(
self,
name: str,
reference_locations: tuple[ReferenceLocation, ...],
) -> bool:
"""Return True if *name* is found in any reference location."""
from re import escape as _re_escape # noqa: PLC0415
from re import search as _re_search # noqa: PLC0415

for ref_location in reference_locations:
pattern = ref_location.reference_re.format(name=_re_escape(name))
for section in self.get_children_deep(ref_location.match_rules):
if any(_re_search(pattern, d.text) for d in section.all_children()):
return True
return False

def _is_duplicate_child_allowed(self) -> bool: # noqa: PLR6301
"""Determine if duplicate(identical text) children are allowed under the parent."""
return False
Loading
Loading