Skip to content

Commit 83ea0ef

Browse files
committed
addresses #156
1 parent 3b693e1 commit 83ea0ef

2 files changed

Lines changed: 215 additions & 4 deletions

File tree

hier_config/platforms/driver_base.py

Lines changed: 179 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from abc import ABC, abstractmethod
22
from collections.abc import Callable, Iterable
3+
from re import Match, search
34

45
from pydantic import Field, PositiveInt
56

@@ -10,6 +11,7 @@
1011
IdempotentCommandsAvoidRule,
1112
IdempotentCommandsRule,
1213
IndentAdjustRule,
14+
MatchRule,
1315
NegationDefaultWhenRule,
1416
NegationDefaultWithRule,
1517
OrderingRule,
@@ -133,10 +135,18 @@ def idempotent_for(
133135
other_children: Iterable[HConfigChild],
134136
) -> HConfigChild | None:
135137
for rule in self.rules.idempotent_commands:
136-
if config.is_lineage_match(rule.match_rules):
137-
for other_child in other_children:
138-
if other_child.is_lineage_match(rule.match_rules):
139-
return other_child
138+
if not config.is_lineage_match(rule.match_rules):
139+
continue
140+
141+
config_key = self._idempotency_key(config, rule.match_rules)
142+
143+
for other_child in other_children:
144+
if not other_child.is_lineage_match(rule.match_rules):
145+
continue
146+
147+
if self._idempotency_key(other_child, rule.match_rules) == config_key:
148+
return other_child
149+
140150
return None
141151

142152
def negate_with(self, config: HConfigChild) -> str | None:
@@ -154,6 +164,171 @@ def swap_negation(self, child: HConfigChild) -> HConfigChild:
154164

155165
return child
156166

167+
def _idempotency_key(
168+
self,
169+
config: HConfigChild,
170+
match_rules: tuple[MatchRule, ...],
171+
) -> tuple[str, ...]:
172+
lineage = tuple(config.lineage())
173+
if len(lineage) != len(match_rules):
174+
return ()
175+
176+
return tuple(map(self._idempotency_component_key, lineage, match_rules))
177+
178+
def _idempotency_component_key(
179+
self,
180+
child: HConfigChild,
181+
rule: MatchRule,
182+
) -> str:
183+
text = child.text
184+
normalized_text = text.removeprefix(self.negation_prefix)
185+
186+
parts: list[str] = []
187+
parts.extend(self._key_from_equals(rule.equals, text))
188+
parts.extend(self._key_from_prefix(rule.startswith, normalized_text))
189+
parts.extend(self._key_from_suffix(rule.endswith, normalized_text))
190+
parts.extend(self._key_from_contains(rule.contains, normalized_text))
191+
parts.extend(self._key_from_regex(rule.re_search, normalized_text, text))
192+
193+
if not parts:
194+
parts.append(f"text|{normalized_text}")
195+
196+
return ";".join(parts)
197+
198+
@staticmethod
199+
def _key_from_equals(
200+
equals: str | frozenset[str] | None, text: str
201+
) -> list[str]:
202+
if equals is None:
203+
return []
204+
if isinstance(equals, str):
205+
return [f"equals|{equals}"]
206+
return [f"equals|{text}"]
207+
208+
def _key_from_prefix(
209+
self,
210+
prefix: str | tuple[str, ...] | None,
211+
normalized_text: str,
212+
) -> list[str]:
213+
if prefix is None:
214+
return []
215+
matched = self._match_prefix(normalized_text, prefix)
216+
if matched is None:
217+
return []
218+
return [f"startswith|{matched}"]
219+
220+
def _key_from_suffix(
221+
self,
222+
suffix: str | tuple[str, ...] | None,
223+
normalized_text: str,
224+
) -> list[str]:
225+
if suffix is None:
226+
return []
227+
matched = self._match_suffix(normalized_text, suffix)
228+
if matched is None:
229+
return []
230+
return [f"endswith|{matched}"]
231+
232+
def _key_from_contains(
233+
self,
234+
contains: str | tuple[str, ...] | None,
235+
normalized_text: str,
236+
) -> list[str]:
237+
if contains is None:
238+
return []
239+
matched = self._match_contains(normalized_text, contains)
240+
if matched is None:
241+
return []
242+
return [f"contains|{matched}"]
243+
244+
def _key_from_regex(
245+
self,
246+
pattern: str | None,
247+
normalized_text: str,
248+
original_text: str,
249+
) -> list[str]:
250+
if pattern is None:
251+
return []
252+
253+
match = search(pattern, normalized_text)
254+
match_source = normalized_text
255+
if match is None:
256+
match = search(pattern, original_text)
257+
match_source = original_text
258+
259+
if match is None:
260+
return []
261+
262+
regex_key = self._normalize_regex_key(pattern, match_source, match)
263+
return [f"re|{regex_key}"]
264+
265+
@staticmethod
266+
def _match_prefix(value: str, prefix: str | tuple[str, ...]) -> str | None:
267+
if isinstance(prefix, tuple):
268+
matches = [candidate for candidate in prefix if value.startswith(candidate)]
269+
if matches:
270+
return max(matches, key=len)
271+
return None
272+
273+
if value.startswith(prefix):
274+
return prefix
275+
276+
return None
277+
278+
@staticmethod
279+
def _match_suffix(value: str, suffix: str | tuple[str, ...]) -> str | None:
280+
if isinstance(suffix, tuple):
281+
matches = [candidate for candidate in suffix if value.endswith(candidate)]
282+
if matches:
283+
return max(matches, key=len)
284+
return None
285+
286+
if value.endswith(suffix):
287+
return suffix
288+
289+
return None
290+
291+
@staticmethod
292+
def _match_contains(
293+
value: str, contains: str | tuple[str, ...]
294+
) -> str | None:
295+
if isinstance(contains, tuple):
296+
matches = [candidate for candidate in contains if candidate in value]
297+
if matches:
298+
return max(matches, key=len)
299+
return None
300+
301+
if contains in value:
302+
return contains
303+
304+
return None
305+
306+
@staticmethod
307+
def _normalize_regex_key(pattern: str, value: str, match: Match[str]) -> str:
308+
result = match.group(0)
309+
310+
if match.re.groups:
311+
groups = tuple(g or "" for g in match.groups())
312+
if any(groups):
313+
normalized_groups = tuple(group.strip() for group in groups)
314+
if any(normalized_groups):
315+
return "|".join(normalized_groups)
316+
317+
trimmed_pattern = pattern.rstrip("$")
318+
for suffix in (".*", ".+"):
319+
if trimmed_pattern.endswith(suffix):
320+
candidate_pattern = trimmed_pattern[: -len(suffix)]
321+
if not candidate_pattern:
322+
break
323+
trimmed_match = search(candidate_pattern, value)
324+
if trimmed_match is not None:
325+
candidate = trimmed_match.group(0).strip()
326+
if candidate:
327+
return candidate
328+
break
329+
330+
return result.strip()
331+
157332
@property
158333
def declaration_prefix(self) -> str:
159334
return ""

tests/test_hier_config.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,42 @@ def test_future_config(platform_a: Platform) -> None:
485485
)
486486

487487

488+
def test_future_preserves_bgp_neighbor_description() -> None:
489+
platform = Platform.ARISTA_EOS
490+
running_raw = """router bgp 1
491+
neighbor 2.2.2.2 description neighbor2
492+
neighbor 2.2.2.2 remote-as 2
493+
!
494+
"""
495+
change_raw = """router bgp 1
496+
neighbor 3.3.3.3 description neighbor3
497+
neighbor 3.3.3.3 remote-as 3
498+
"""
499+
500+
running_config = get_hconfig(platform, running_raw)
501+
change_config = get_hconfig(platform, change_raw)
502+
503+
future_config = running_config.future(change_config)
504+
expected_future = (
505+
"router bgp 1",
506+
" neighbor 3.3.3.3 description neighbor3",
507+
" neighbor 3.3.3.3 remote-as 3",
508+
" neighbor 2.2.2.2 description neighbor2",
509+
" neighbor 2.2.2.2 remote-as 2",
510+
" exit",
511+
)
512+
assert future_config.dump_simple(sectional_exiting=True) == expected_future
513+
514+
rollback_config = future_config.config_to_get_to(running_config)
515+
expected_rollback = (
516+
"router bgp 1",
517+
" no neighbor 3.3.3.3 description neighbor3",
518+
" no neighbor 3.3.3.3 remote-as 3",
519+
" exit",
520+
)
521+
assert rollback_config.dump_simple(sectional_exiting=True) == expected_rollback
522+
523+
488524
def test_difference1(platform_a: Platform) -> None:
489525
rc = ("a", " a1", " a2", " a3", "b")
490526
step = ("a", " a1", " a2", " a3", " a4", " a5", "b", "c", "d", " d1")

0 commit comments

Comments
 (0)