Coverage for netbox_interface_name_rules / family / targets.py: 92.31%
42 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"""Compute the names a rule intends for one interface family.
5Every planner names a family through this module, so an installed plan, a prospective plan and a
6structural plan cannot spell the same family differently. Nothing here reads or writes a row: the
7inputs are names, channel identifiers and the rule.
8"""
10from dataclasses import dataclass
12from ..choices import BreakoutModeChoices
13from ..name_template import evaluate_name_template
14from .domain import FamilyStatus
16AMBIGUOUS_SUFFIX_REASON = "channel suffix is ambiguous or unavailable"
17UNCLAIMED_BASE_REASON = "no single interface template claims this name as its raw name or its renamed form"
20@dataclass(frozen=True, slots=True)
21class FamilyTargets:
22 """The names a rule intends for one channelized family, and why it may refuse."""
24 parent_name: str
25 channels: tuple[tuple[str | None, str], ...]
26 status: FamilyStatus | None = None
27 reason: str = ""
30def child_name_suffix(child_name, parent_name): # pragma: no cover - requires channelization support
31 """Return the suffix *child_name* adds to *parent_name*, or None when it adds none.
33 The first character must be non-alphanumeric so ``et0``/``et01`` is never mistaken for a
34 family; the punctuation itself is free-form (``:``, ``-``, ``_`` and ``@`` all occur in the
35 wild), so it is not restricted to a fixed separator.
36 """
37 if not parent_name or not child_name.startswith(parent_name):
38 return None
39 suffix = child_name[len(parent_name) :]
40 if not suffix or suffix[0].isalnum():
41 return None
42 return suffix
45def template_channel_suffixes(templates): # pragma: no cover - requires channelization support
46 """Map each channel identifier to the name suffixes the module type's templates give it.
48 The suffix comes from the template family itself, so a child that lost its parent's prefix in
49 an earlier partial rename can still be repaired. A module type with several families may spell
50 the same channel differently in each (``et0:2`` vs ``sw0.2``), so the suffixes are collected per
51 channel rather than overwritten: recovery only uses one when every family agrees on it.
52 """
53 parents = {template.pk: template.resolved for template in templates if template.channels is not None}
54 suffixes: dict[int, set[str]] = {}
55 for template in templates:
56 if template.channel_id is None or template.parent_id not in parents:
57 continue
58 suffix = child_name_suffix(template.resolved, parents[template.parent_id])
59 if suffix is not None:
60 suffixes.setdefault(template.channel_id, set()).add(suffix)
61 return suffixes
64def breaks_out(rule) -> bool:
65 """Return whether *rule* builds an interface family, flat or channelized."""
66 return rule.channel_count > 0
69def builds_channelized_family(rule) -> bool:
70 """Return whether *rule* builds a channelized family instead of flat sibling interfaces."""
71 return rule.channel_count > 0 and rule.breakout_mode == BreakoutModeChoices.CHANNELIZED
74def builds_flat_family(rule) -> bool:
75 """Return whether *rule* builds a flat breakout family of sibling interfaces."""
76 return rule.channel_count > 0 and rule.breakout_mode == BreakoutModeChoices.FLAT
79def names_installed_parent(rule) -> bool:
80 """Return whether *rule* gives the parent of an installed channelized family a new name."""
81 if rule.channel_count <= 0:
82 return True # a simple rule renames the parent, and the channels follow it in lockstep
83 return bool(used_parent_template(rule))
86def used_parent_template(rule) -> str:
87 """Return the parent name template that *rule* applies, or an empty string when it applies none."""
88 return rule.parent_name_template if builds_channelized_family(rule) else ""
91def one_family_per_name_set(candidates):
92 """Return the index of one candidate base per family, in candidate order.
94 *candidates* pairs each base name with the names the rule intends for the family built on it.
95 Two bases that intend the same names describe one family an earlier apply already started, so
96 only one of them may build it; the base the family already names is preferred, because renaming
97 it onto itself is the no-op the other base cannot manage.
98 """
99 kept: dict[tuple[str, ...], int] = {}
100 for index, (base_name, target_names) in enumerate(candidates):
101 current = kept.get(target_names)
102 if current is None or (base_name == target_names[0] and candidates[current][0] != target_names[0]):
103 kept[target_names] = index
104 return tuple(sorted(kept.values()))
107def flat_family_names(rule, variables, base_name):
108 """Return the names of the flat sibling family that *rule* defines on *base_name*."""
109 family_variables = {**variables, "base": base_name}
110 return tuple(
111 evaluate_name_template(
112 rule.name_template,
113 {**family_variables, "channel": str(rule.channel_start + offset)},
114 )
115 for offset in range(rule.channel_count)
116 )
119def channelized_family_names(rule, current_name, base_name, variables): # pragma: no cover - channelization only
120 """Return ``(parent_name, ((channel_id, name), ...))`` for the family *rule* builds on *current_name*.
122 *base_name* is ``{base}`` for the parent and every channel; ``{channel}`` is
123 ``channel_start + channel_id - 1``. A blank parent template leaves *current_name* alone.
124 Takes names rather than the interface so prediction can reuse it without a row to point at.
125 """
126 family_variables = {**variables, "base": base_name}
127 parent_name = current_name
128 if rule.parent_name_template:
129 parent_name = evaluate_name_template(rule.parent_name_template, family_variables)
130 channels = tuple(
131 (
132 channel_id,
133 evaluate_name_template(
134 rule.name_template, {**family_variables, "channel": str(rule.channel_start + channel_id - 1)}
135 ),
136 )
137 for channel_id in range(1, rule.channel_count + 1)
138 )
139 return parent_name, channels
142def intended_family_names(rule, variables, current_name, base_name):
143 """Return every name *rule* intends for the family it builds on *current_name*, with *base_name* as ``{base}``.
145 A rule without channels intends one name. A base whose names cannot be evaluated is its own
146 family: it names nothing else, so nothing else can be grouped with it.
147 """
148 try:
149 if rule.channel_count <= 0:
150 return (evaluate_name_template(rule.name_template, {**variables, "base": base_name}),)
151 if builds_channelized_family(rule): 151 ↛ 154line 151 didn't jump to line 154 because the condition on line 151 was always true
152 parent_name, channels = channelized_family_names(rule, current_name, base_name, variables)
153 return (parent_name, *(name for _channel_id, name in channels))
154 return flat_family_names(rule, variables, base_name)
155 except (TypeError, ValueError):
156 return (current_name,)
159def _simple_child_target(child_name, channel_id, parent_name, parent_target, suffixes): # pragma: no cover
160 """Return a simple rule's child target, or None plus the reason it cannot be derived."""
161 suffix = child_name_suffix(child_name, parent_name)
162 if suffix is None:
163 candidates = suffixes.get(channel_id, set())
164 if len(candidates) == 1:
165 suffix = next(iter(candidates))
166 if suffix is None:
167 return None, AMBIGUOUS_SUFFIX_REASON
168 return parent_target + suffix, ""
171def _failed(parent_name, children, error): # pragma: no cover - requires channelization support
172 """Return targets that leave the whole family alone because a template could not be evaluated."""
173 reason = f"failed to evaluate family targets: {error}"
174 return FamilyTargets(
175 parent_name=parent_name,
176 channels=tuple((child_name, reason) for child_name, _channel_id in children),
177 status=FamilyStatus.FAILED,
178 reason=reason,
179 )
182def _unclaimed(parent_name, children): # pragma: no cover - requires channelization support
183 """Return targets that leave a family alone because no template gives it a raw name."""
184 return FamilyTargets(
185 parent_name=parent_name,
186 channels=tuple((child_name, UNCLAIMED_BASE_REASON) for child_name, _channel_id in children),
187 status=FamilyStatus.BLOCKED,
188 reason=UNCLAIMED_BASE_REASON,
189 )
192def _breakout_targets(rule, variables, parent_name, base_name, parent_channels, children): # pragma: no cover
193 """Return the names a breakout rule intends for an existing channelized family."""
194 if parent_channels != rule.channel_count:
195 reason = f"installed parent declares {parent_channels} channels but the rule defines {rule.channel_count}"
196 return FamilyTargets(
197 parent_name=parent_name,
198 channels=tuple((child_name, reason) for child_name, _channel_id in children),
199 status=FamilyStatus.BLOCKED,
200 reason=reason,
201 )
202 if len(children) < parent_channels:
203 reason = f"installed family is missing {parent_channels - len(children)} of {parent_channels} channels"
204 return FamilyTargets(
205 parent_name=parent_name,
206 channels=tuple((child_name, reason) for child_name, _channel_id in children),
207 status=FamilyStatus.BLOCKED,
208 reason=reason,
209 )
210 try:
211 parent_target = parent_name
212 if names_installed_parent(rule):
213 parent_target = evaluate_name_template(rule.parent_name_template, {**variables, "base": base_name})
214 channels = tuple(
215 (
216 evaluate_name_template(
217 rule.name_template,
218 {**variables, "base": base_name, "channel": str(rule.channel_start + channel_id - 1)},
219 ),
220 "",
221 )
222 for _child_name, channel_id in children
223 )
224 except (TypeError, ValueError) as error:
225 return _failed(parent_name, children, error)
226 return FamilyTargets(parent_name=parent_target, channels=channels)
229def lockstep_family_targets(rule, variables, parent_name, base_name, children, suffixes): # pragma: no cover
230 """Return the names a simple rule intends for a family renamed in lockstep with its parent.
232 The rule's channel count says nothing here: this family already exists, and every member keeps
233 the suffix it carries under whatever name the parent takes.
234 """
235 try:
236 parent_target = evaluate_name_template(rule.name_template, {**variables, "base": base_name})
237 except (TypeError, ValueError) as error:
238 return _failed(parent_name, children, error)
239 channels = tuple(
240 _simple_child_target(child_name, channel_id, parent_name, parent_target, suffixes)
241 for child_name, channel_id in children
242 )
243 return FamilyTargets(parent_name=parent_target, channels=channels)
246def channelized_family_targets( # pragma: no cover - requires channelization support
247 rule, variables, parent_name, base_name, parent_channels, children, suffixes
248) -> FamilyTargets:
249 """Return the names *rule* intends for the channelized family named *parent_name*.
251 *base_name* is the value of ``{base}``, or None when no template claims the parent.
252 *children* pairs each channel's current name with its channel identifier, in channel order.
253 A breakout rule renames the channels it describes; any other rule carries the family along with
254 its parent, keeping each channel's own suffix.
255 """
256 if base_name is None:
257 return _unclaimed(parent_name, children)
258 if rule.channel_count > 0:
259 return _breakout_targets(rule, variables, parent_name, base_name, parent_channels, children)
260 return lockstep_family_targets(rule, variables, parent_name, base_name, children, suffixes)