Coverage for netbox_interface_name_rules / family / raw_bases.py: 100.00%
169 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-16 10:13 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-16 10:13 +0000
1# SPDX-License-Identifier: Apache-2.0
2# Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
3"""Claim each live interface name for the one interface template it stands for.
5NetBox keeps no link from an interface to the template that created it, so every path claims each
6live name for the templates whose forms spell it, in one pass over every form of every template. A
7name that no single template claims has no base, and nothing renames it.
8"""
10import logging
11import re
12from typing import NamedTuple
14from ..name_template import evaluate_name_template, references_variable
15from .claims import TemplateClaim, resolve_template_claims
16from .targets import breaks_out, builds_channelized_family, flat_family_names, used_parent_template
17from .template_names import VC_POSITION_DIGITS, ResolvedTemplateName
19logger = logging.getLogger(__name__)
21# PostgreSQL text cannot hold NUL, so no stored name, template or value can spell these markers.
22_MARKERS = {"base": "\x00base\x00", "vc_position": "\x00vc_position\x00"}
23# A module type without interface templates claims as one template whose raw name is the bay position.
24_STAND_IN = "{bay_position}"
27def rule_reads_base(rule) -> bool:
28 """Return whether any of *rule*'s name templates substitutes ``{base}``."""
29 return any(references_variable(template, "base") for template in (rule.name_template, used_parent_template(rule)))
32def _renaming_template(rule):
33 """Return the template that gives a family's base interface its name under *rule*, or None."""
34 if rule.channel_count <= 0:
35 return rule.name_template
36 return used_parent_template(rule) or None
39def _parent_name_needs_channels(rule):
40 """Return whether *rule* gives every parent one name, so only the family's channels carry its base."""
41 parent_template = used_parent_template(rule)
42 return bool(parent_template) and not references_variable(parent_template, "base")
45def _marked_evaluation(template, variables, raw):
46 """Return the template evaluated with markers for ``{base}`` and ``{vc_position}``, or None.
48 Arithmetic cannot take a marker, so a variable used in arithmetic gets its real value instead.
49 The position marker stands for any position, also on a device that has no position now.
50 """
51 vc_values = (_MARKERS["vc_position"], *((variables["vc_position"],) if "vc_position" in variables else ()))
52 for vc_position in vc_values:
53 for base in (_MARKERS["base"], raw):
54 try:
55 return evaluate_name_template(template, {**variables, "base": base, "vc_position": vc_position})
56 except (TypeError, ValueError):
57 continue
58 return None
61def _renamed_pattern(template, variables, raw, historical):
62 """Return a matcher for the name *template* gives *raw*, at any virtual-chassis position, or None."""
63 marked = _marked_evaluation(template, variables, raw)
64 if marked is None:
65 return None
66 groups = {
67 _MARKERS["base"]: (
68 "base",
69 re.escape(raw) if historical is None else f"(?:{re.escape(raw)}|{historical.pattern})",
70 ),
71 _MARKERS["vc_position"]: ("vc", VC_POSITION_DIGITS),
72 }
73 pattern = []
74 opened = set()
75 for part in re.split(f"({_MARKERS['base']}|{_MARKERS['vc_position']})", marked):
76 if part not in groups:
77 pattern.append(re.escape(part))
78 continue
79 name, alternatives = groups[part]
80 pattern.append(f"(?P={name})" if name in opened else f"(?P<{name}>{alternatives})")
81 opened.add(name)
82 return re.compile("".join(pattern))
85def _channel_patterns(rule, variables, raw, historical):
86 """Return, by channel id, a matcher or None for the channel name *rule* gives *raw*'s family."""
87 return {
88 channel_id: _renamed_pattern(
89 rule.name_template,
90 {**variables, "channel": str(rule.channel_start + channel_id - 1)},
91 raw,
92 historical,
93 )
94 for channel_id in range(1, rule.channel_count + 1)
95 }
98class TemplateForms(NamedTuple):
99 """The names one template claims under one rule: its raw name and matchers for what the rule gives it.
101 *renamed* is None when the rule gives the template no name of its own. *channels* is given when a
102 renamed parent counts only through one of its channels.
103 """
105 raw: str
106 renamed: re.Pattern | None
107 channels: dict | None
110def _template_forms(rule, variables, raw, historical):
111 """Return the forms *rule* gives the template that resolves to *raw*."""
112 renaming = _renaming_template(rule)
113 renamed = None if renaming is None else _renamed_pattern(renaming, variables, raw, historical)
114 channels = _channel_patterns(rule, variables, raw, historical) if _parent_name_needs_channels(rule) else None
115 return TemplateForms(raw, renamed, channels)
118class FlatFamily(NamedTuple):
119 """A flat breakout family that one template claims.
121 *base_name* is the template's raw name now, and *names* are the names the family's rows carry,
122 in channel order.
123 """
125 base_name: str
126 names: tuple[str, ...]
129class NameClaim(NamedTuple):
130 """Whether a template claims one name, one template alone does, and one does as a raw name now or earlier."""
132 claimed: bool
133 accepted: bool
134 raw: bool
137_UNCLAIMED = NameClaim(claimed=False, accepted=False, raw=False)
140class _Resolution(NamedTuple):
141 """What one claim pass decided: the raw base of each name one template alone claims, families and claims."""
143 bases: dict
144 families: tuple[FlatFamily, ...]
145 claims: dict
148class RawBases:
149 """The one claim over every name form of a module's interfaces under one rule (ADR 0013).
151 *families* maps each name outside a channel to its channels; *plain* names the flat-family candidates.
152 *earlier_raw_names* maps a template's primary key to the raw names it also gave earlier.
153 """
155 def __init__(self, module, rule, variables, families, plain, catalog, previous_forms=None, earlier_raw_names=None):
156 self._module = module
157 self._rule = rule
158 self._variables = variables
159 self._families = dict(families)
160 self._names = tuple(self._families)
161 self._plain = frozenset(plain)
162 self.catalog = catalog
163 self.previous_forms = previous_forms
164 self._earlier_raw_names = earlier_raw_names or {}
165 self._reads_base = previous_forms is not None or rule_reads_base(rule)
166 self._breaks_out = breaks_out(rule)
167 self._claims_flat = previous_forms is None and self._breaks_out
168 self._builds_channels = builds_channelized_family(rule)
169 self._resolution = None
171 def base_for(self, name):
172 """Return ``{base}`` for *name*: its own name for a rule that needs no claim, else ``builds_on``."""
173 return self.builds_on(name) if self._reads_base else name
175 def builds_on(self, name):
176 """Return the raw template name *name* stands for, or None when no single template claims it."""
177 return self._resolved().bases.get(name)
179 def claims_alone(self, name) -> bool:
180 """Return whether one template alone claims *name*, so that no family takes its row over (ADR 0017)."""
181 return self.claim(name).accepted
183 def is_ambiguous(self, name):
184 """Return whether a rule that reads the claim finds *name* claimed, but not by one template alone."""
185 if not (self._reads_base or self._breaks_out):
186 return False
187 claim = self.claim(name)
188 return claim.claimed and not claim.accepted
190 def claim(self, name):
191 """Return how the claim treats *name*."""
192 return self._resolved().claims.get(name, _UNCLAIMED)
194 def flat_families(self):
195 """Return every flat family that one template alone claims."""
196 if not self._claims_flat:
197 return ()
198 return self._resolved().families
200 def _resolved(self):
201 """Resolve the claim on first use."""
202 if self._resolution is None:
203 self._resolution = self._claim()
204 return self._resolution
206 def _claim(self):
207 """Resolve every form of every template in one pass, in the order ADR 0013 lists."""
208 templates = [template for template in self.catalog.get() if template.channel_id is None]
209 if not templates and self.previous_forms is None:
210 templates = [ResolvedTemplateName(None, _STAND_IN, self._variables["bay_position"], None, None, None, None)]
211 forms = {template.pk: self._raw_forms(template) for template in templates}
212 raw_names = set().union(*forms.values())
213 raw_by_claimant = {template.pk: template.resolved for template in templates}
214 claims, flat, raw_claimed = [], {}, set()
215 for template in templates:
216 single = self._single_names(template, raw_names, forms[template.pk])
217 raw_claimed.update(
218 name for name in single if self._is_raw_name(name, template, raw_names, forms[template.pk])
219 )
220 families = self._flat_families(template, raw_names, forms[template.pk])
221 flat.update(((template.pk, unit), family) for unit, family in families.items())
222 claims.append(
223 TemplateClaim(template.pk, template.template_name, [(name,) for name in single] + [*families])
224 )
225 accepted, messages = resolve_template_claims(claims, module=self._module)
226 for message in messages:
227 logger.warning("%s", message)
228 bases = {name: raw_by_claimant[claimant_id] for claimant_id, unit in accepted for name in unit}
229 claimed = dict.fromkeys(name for claim in claims for unit in claim.units for name in unit)
230 return _Resolution(
231 bases=bases,
232 families=tuple(flat[pair] for pair in accepted if pair in flat),
233 claims={name: NameClaim(True, name in bases, name in raw_claimed) for name in claimed},
234 )
236 def _single_names(self, template, raw_names, raw_forms):
237 """Return each name that one of *template*'s forms spells, other than a flat family."""
238 current = _template_forms(self._rule, self._variables, template.resolved, template.historical_pattern)
239 previous = self._previous_template_forms(template.pk)
240 return tuple(
241 name
242 for name in self._names
243 if self._matches(name, current)
244 or self._is_raw_name(name, template, raw_names, raw_forms)
245 or (previous is not None and self._matches(name, previous))
246 )
248 def _raw_forms(self, template):
249 """Return *template*'s raw name now and the raw names it gave earlier."""
250 return {template.resolved, *self._earlier_raw_names.get(template.pk, ())}
252 @staticmethod
253 def _is_raw_name(name, template, raw_names, raw_forms):
254 """Return whether *name* is one of *template*'s *raw_forms*, or its raw name at a position no other raw name holds."""
255 historical = template.historical_pattern
256 return name in raw_forms or (
257 historical is not None and name not in raw_names and historical.fullmatch(name) is not None
258 )
260 def _flat_families(self, template, raw_names, raw_forms):
261 """Return, by the plain interfaces the module carries, each flat family whose first one it carries.
263 *raw_names* and *raw_forms* are what ``_is_raw_name`` reads to recognise the template's raw name.
264 """
265 if not self._claims_flat:
266 return {}
267 # A flat apply renames the port, so beside a raw-named port a channelized rule takes the rows over (ADR 0017).
268 if self._builds_channels and any(
269 self._is_raw_name(name, template, raw_names, raw_forms) for name in self._plain
270 ):
271 return {}
272 rule = self._rule
273 first = _renamed_pattern(
274 rule.name_template,
275 {**self._variables, "channel": str(rule.channel_start)},
276 template.resolved,
277 template.historical_pattern,
278 )
279 if first is None:
280 return {}
281 families = {}
282 for name in self._names:
283 match = None if name not in self._plain else first.fullmatch(name)
284 family = None if match is None else self._flat_family(match.groupdict(), template.resolved)
285 if family is not None:
286 families[tuple(member for member in family.names if member in self._plain)] = family
287 return families
289 def _flat_family(self, groups, raw):
290 """Return the flat family whose first name matched with *groups*, or None when the rule cannot name it."""
291 variables = self._variables if "vc" not in groups else {**self._variables, "vc_position": groups["vc"]}
292 try:
293 names = flat_family_names(self._rule, variables, groups.get("base", raw))
294 except (TypeError, ValueError):
295 return None
296 return FlatFamily(raw, names) if len(set(names)) == len(names) else None
298 def _previous_template_forms(self, claimant_id):
299 """Return the forms the template had in the previous state, or None without a previous state."""
300 previous_forms = self.previous_forms
301 template = None if previous_forms is None else previous_forms.templates.get(claimant_id)
302 if template is None:
303 return None
304 if previous_forms.rule is None:
305 return TemplateForms(template.resolved, None, None)
306 return _template_forms(
307 previous_forms.rule, previous_forms.variables, template.resolved, template.historical_pattern
308 )
310 def _matches(self, name, forms):
311 """Return whether *name* is the raw name in *forms* or a name its rule gives."""
312 return name == forms.raw or self._is_renamed(name, forms.renamed, forms.channels)
314 def _is_renamed(self, name, renamed, channels):
315 """Return whether *name* is a renamed form and, where *channels* is given, one of its channels is too."""
316 if renamed is None or not renamed.fullmatch(name):
317 return False
318 if channels is None:
319 return True
320 return any(
321 (pattern := channels.get(channel_id)) is not None and pattern.fullmatch(channel_name)
322 for channel_name, channel_id in self._families[name]
323 )
326class GivenRawNames:
327 """Bases for names a caller gives as raw template names, such as the names prediction receives.
329 A given name is its own base, so a stale name is predicted from itself. A name the claim finds
330 ambiguous has no base, because installation would not rename it either.
331 """
333 def __init__(self, bases):
334 self._bases = bases
336 def base_for(self, name):
337 """Return *name*, or None when the claim over the given names finds it ambiguous."""
338 return None if self._bases.is_ambiguous(name) else name
340 builds_on = base_for
342 def claims_alone(self, name) -> bool:
343 """Return whether one template alone claims *name* among the given names; any other name is no template's."""
344 return self._bases.claims_alone(name)