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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `HConfig.future_with_report()` returns the predicted future config together
with a frozen `FutureReport` listing unresolved negations (negations that
matched nothing in the running config) and idempotency-tracked negation
replacements, so change-validation pipelines can assert
`not report.unresolved_negations` instead of grepping the render for
`no ` lines (#285).
- Migration guide for v3 → v4 upgrades (`docs/user/migrating-from-v3.md`):
rename tables for constructors, methods, and utilities, the unified
negation rule mapping, exception and config-view changes, and behavior
Expand Down
2 changes: 2 additions & 0 deletions docs/dev/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ Auto-generated reference documentation for the `hier_config` public API. Signatu

::: hier_config.ChangeDetail

::: hier_config.FutureReport

---

## Driver System
Expand Down
20 changes: 19 additions & 1 deletion docs/user/future-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,25 @@ If you receive a `DuplicateChildError` while calling `merge()`, consider whether
- **Exact negation** — a `no <command>` whose positive form exists in the running config removes that command; neither line survives in the prediction. This is evaluated before the idempotency rules, so an idempotency rule that happens to match the negation text cannot accidentally keep it as a literal child.
- **Shorthand negation** — a valueless negation such as `no description` removes the valued lines it matches (`description foo`), just as the device CLI does.
- **Idempotency-tracked negated forms** — when a negated form is itself tracked by an idempotency rule (e.g. IOS `no logging console`), it replaces its counterpart and *persists* in the rendered future config, because the device stores it as explicit configuration.
- **Unmatched negations** — a negation that matches nothing in the running config is kept in the output as a signal that the change would not apply cleanly.
- **Unmatched negations** — a negation that matches nothing in the running config is kept in the output as a signal that the change would not apply cleanly. Use [`future_with_report()`](#auditing-negation-resolution) to detect these explicitly instead of scanning the render.

## Auditing negation resolution

`HConfig.future_with_report()` behaves exactly like `future()` but also returns a `FutureReport` describing how the change's negations resolved:

```python
future_config, report = running_config.future_with_report(change_config)

report.unresolved_negations # negations that matched nothing in the running config
report.idempotency_replacements # negations that displaced an idempotency-tracked line but persist
```

Change-validation pipelines can assert `not report.unresolved_negations` instead of grepping the rendered output for `no ` lines. Both fields hold `HConfigChild` nodes that live in the returned future config tree, so `path()` and `lineage()` give the surrounding context:

```python
for negation in report.unresolved_negations:
print(" > ".join(negation.path()))
```

## Pruning emptied sections

Expand Down
2 changes: 2 additions & 0 deletions docs/user/migrating-from-v3.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,8 @@ Not required for migration, but these are the headline additions:
[custom drivers](../dev/creating-drivers.md).
- Built-in post-load callbacks are public functions removable by identity —
see [Customizing Driver Rules](../admin/customizing-rules.md#customizing-post-load-callbacks).
- `HConfig.future_with_report()` — predict a future config and get a
`FutureReport` of [how the change's negations resolved](future-config.md#auditing-negation-resolution).

## Next steps

Expand Down
2 changes: 2 additions & 0 deletions hier_config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,15 @@
)
from .reporting import RemediationReporter
from .root import HConfig
from .tree_algorithms import FutureReport
from .workflows import WorkflowRemediation

__all__ = (
"ChangeDetail",
"ConfigViewInterfaceBase",
"DriverNotFoundError",
"DuplicateChildError",
"FutureReport",
"HConfig",
"HConfigChild",
"HConfigDriverBase",
Expand Down
29 changes: 26 additions & 3 deletions hier_config/root.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
from .child import HConfigChild
from .models import Dump, DumpLine, Platform, ReferenceLocation
from .tree_algorithms import (
FutureReport,
compute_difference,
compute_future,
compute_future_with_report,
compute_remediation,
compute_with_tags,
prune_emptied_branches,
Expand Down Expand Up @@ -297,11 +298,33 @@ def future(
removed, matching devices that prune empty stanzas on commit; sections
that were already empty are kept.
"""
future_config, _ = self.future_with_report(
config,
prune_empty_branches=prune_empty_branches,
)
return future_config

def future_with_report(
self,
config: HConfig,
*,
prune_empty_branches: bool = False,
) -> tuple[HConfig, FutureReport]:
"""EXPERIMENTAL - like `future()`, but also report how negations resolved.

Returns the predicted future config together with a `FutureReport`
whose `unresolved_negations` are negations that matched nothing in
self and `idempotency_replacements` are negations that displaced an
idempotency-tracked line but persist in the render. Both hold nodes
of the returned future config tree. Change-validation pipelines can
assert `not report.unresolved_negations` instead of grepping the
render for negation lines.
"""
future_config = HConfig(self.driver)
compute_future(self, config, future_config)
report = compute_future_with_report(self, config, future_config)
if prune_empty_branches:
prune_emptied_branches(self, future_config)
return future_config
return future_config, report

def with_tags(self, tags: Iterable[str]) -> HConfig:
"""Returns a new instance recursively containing children that only have a subset of tags."""
Expand Down
65 changes: 62 additions & 3 deletions hier_config/tree_algorithms.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from __future__ import annotations

from dataclasses import dataclass, field
from typing import TYPE_CHECKING, TypeVar

if TYPE_CHECKING:
Expand All @@ -19,6 +20,58 @@
_HConfigRootOrChildT = TypeVar("_HConfigRootOrChildT", bound=HConfig | HConfigChild)


@dataclass(frozen=True, slots=True)
class FutureReport:
"""How `HConfig.future_with_report()` resolved a change's negations (#285).

The nodes reference the returned future config tree, so `path()` and
`lineage()` give the surrounding context.
"""

unresolved_negations: tuple[HConfigChild, ...]
idempotency_replacements: tuple[HConfigChild, ...]


def _new_child_list() -> list[HConfigChild]:
return []


@dataclass(slots=True)
class _FutureReportBuilder:
"""Mutable collector threaded through the `compute_future` recursion."""

unresolved_negations: list[HConfigChild] = field(default_factory=_new_child_list)
idempotency_replacements: list[HConfigChild] = field(
default_factory=_new_child_list,
)

def record_unresolved(self, node: HConfigChild) -> None:
"""Record a kept negation that matched nothing in the source config."""
self.unresolved_negations.append(node)

def record_idempotency(self, node: HConfigChild, *, is_negation: bool) -> None:
"""Record a persisting idempotency replacement when it is a negation."""
if is_negation:
self.idempotency_replacements.append(node)

def build(self) -> FutureReport:
return FutureReport(
unresolved_negations=tuple(self.unresolved_negations),
idempotency_replacements=tuple(self.idempotency_replacements),
)


def compute_future_with_report(
source: HConfigBase,
config: HConfig | HConfigChild,
future_config: HConfig | HConfigChild,
) -> FutureReport:
"""Compute the future config subtree and report how negations resolved."""
report = _FutureReportBuilder()
compute_future(source, config, future_config, report=report)
return report.build()


def compute_remediation(
source: HConfigBase,
target: _HConfigRootOrChildT,
Expand Down Expand Up @@ -171,6 +224,8 @@ def compute_future( # ruff:ignore[complex-structure]
source: HConfigBase,
config: HConfig | HConfigChild,
future_config: HConfig | HConfigChild,
*,
report: _FutureReportBuilder | None = None,
) -> None:
"""Recursively compute the future configuration subtree.

Expand All @@ -185,6 +240,7 @@ def compute_future( # ruff:ignore[complex-structure]
- Idempotent command avoid list
- And likely other edge cases
"""
report = report or _FutureReportBuilder()
negated_or_recursed, config_children_ignore = _future_pre(source, config)

for config_child in config.children:
Expand Down Expand Up @@ -212,7 +268,10 @@ def compute_future( # ruff:ignore[complex-structure]
config_child,
source.children,
):
future_config.add_deep_copy_of(config_child)
report.record_idempotency(
future_config.add_deep_copy_of(config_child),
is_negation=is_negation,
)
negated_or_recursed.add(self_child.text)
# Shorthand negation: `no description` removes `description foo`, as
# devices do (#269).
Expand All @@ -229,13 +288,13 @@ def compute_future( # ruff:ignore[complex-structure]
# config_child is already in source
elif self_child := source.get_child(equals=config_child.text):
future_child = future_config.add_shallow_copy_of(self_child)
compute_future(self_child, config_child, future_child)
compute_future(self_child, config_child, future_child, report=report)
negated_or_recursed.add(config_child.text)
# A negation matching nothing is kept: it accounts for "no ..." lines
# native to the running config and doubles as a did-not-apply-cleanly
# signal for callers (#269).
elif is_negation:
future_config.add_shallow_copy_of(config_child)
report.record_unresolved(future_config.add_shallow_copy_of(config_child))
# The negated form of config_child is in source.children
elif self_child := source.get_child(
equals=f"{source.driver.negation_prefix}{config_child.text}",
Expand Down
Loading