Coverage for netbox_interface_name_rules / family / prospective.py: 100.00%

105 statements  

« 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"""Plan interface families for interfaces described by name alone. 

4 

5A prospective plan says what a rule intends, using the same naming rules installed execution 

6follows. It carries no row snapshot, so it can describe a family NetBox has not created yet and 

7the executors refuse to take one. 

8""" 

9 

10import logging 

11from dataclasses import dataclass 

12 

13from ..choices import BreakoutModeChoices 

14from ..name_template import evaluate_name_template 

15from .capabilities import supports_channelization 

16from .domain import ( 

17 FamilyStatus, 

18 FamilyTopology, 

19 MemberRole, 

20 ProspectiveFamilyPlan, 

21 ProspectiveFamilyPlanSet, 

22 ProspectiveMember, 

23) 

24from .names import COLLISION_REASON, repeated_name_refusal, repeated_names 

25from .structural import UNSUPPORTED_REASON, has_flat_expansion, module_taken_over_names 

26from .targets import ( 

27 UNCLAIMED_BASE_REASON, 

28 breaks_out, 

29 channelized_family_names, 

30 channelized_family_targets, 

31 flat_family_names, 

32 template_channel_suffixes, 

33) 

34from .template_names import resolved_template_names 

35 

36logger = logging.getLogger(__name__) 

37 

38FLAT_EXPANSION_REASON = "the module already carries a flat breakout family" 

39 

40 

41@dataclass(frozen=True, slots=True) 

42class ProspectiveInterface: 

43 """One interface a prospective plan reasons about, described without a row.""" 

44 

45 name: str 

46 parent_name: str | None = None 

47 channel_id: int | None = None 

48 channels: int | None = None 

49 

50 

51def describe_interfaces(interfaces) -> tuple[ProspectiveInterface, ...]: 

52 """Describe live interface rows for prospective planning.""" 

53 interfaces = tuple(interfaces) 

54 names_by_pk = {interface.pk: interface.name for interface in interfaces} 

55 return tuple( 

56 ProspectiveInterface( 

57 name=interface.name, 

58 parent_name=names_by_pk.get(getattr(interface, "parent_id", None)), 

59 channel_id=getattr(interface, "channel_id", None), 

60 channels=getattr(interface, "channels", None), 

61 ) 

62 for interface in interfaces 

63 ) 

64 

65 

66def describe_template_interfaces(templates, names=()) -> tuple[ProspectiveInterface, ...]: 

67 """Describe the interfaces a module type's templates produce, plus any extra *names*. 

68 

69 A channel is paired with its parent only where that parent declares a channel count, because a 

70 template family is what the parent's capacity defines. A name no template resolves to is 

71 described as a plain interface. 

72 """ 

73 parents = {template.pk: template for template in templates if template.channels is not None} 

74 described = {} 

75 for template in templates: 

76 parent = parents.get(template.parent_id) if template.channel_id is not None else None 

77 described[template.resolved] = ProspectiveInterface( 

78 name=template.resolved, 

79 parent_name=None if parent is None else parent.resolved, 

80 channel_id=None if parent is None else template.channel_id, 

81 channels=template.channels, 

82 ) 

83 described.update( 

84 {name: ProspectiveInterface(name=name) for name in names if name not in described}, 

85 ) 

86 return tuple(described.values()) 

87 

88 

89def describe_module_interfaces(module, names=()) -> tuple[ProspectiveInterface, ...]: 

90 """Describe the interfaces *module*'s templates produce, plus any extra *names*. 

91 

92 A release that cannot model channelized families describes the names alone: its templates carry 

93 no family structure, so reading them would buy nothing. 

94 """ 

95 if not supports_channelization(): 

96 return tuple(ProspectiveInterface(name=name) for name in names) 

97 return describe_template_interfaces( # pragma: no cover - requires channelization support 

98 resolved_template_names(module), names 

99 ) 

100 

101 

102def _partition(interfaces): 

103 """Split described interfaces into ``(roots, children_by_parent_name)``. 

104 

105 A channel is never an independent candidate: it belongs to the interface it names as its 

106 parent, and one whose parent is not described here is planned by nothing at all. 

107 """ 

108 described = {interface.name for interface in interfaces} 

109 roots = [] 

110 children: dict[str, list] = {} 

111 for interface in interfaces: 

112 if interface.channel_id is None: 

113 roots.append(interface) 

114 elif interface.parent_name in described: # pragma: no cover - requires channelization support 

115 children.setdefault(interface.parent_name, []).append(interface) 

116 return roots, children 

117 

118 

119def _is_family_root(rule, interface, children): 

120 """Return whether *interface* owns a family the rule renames as a unit. 

121 

122 A breakout rule renames only a family NetBox models with a channel count; on anything else it 

123 builds a new family from the interface itself. Any other rule carries whatever channels the 

124 interface has along with it. 

125 """ 

126 if rule.channel_count > 0: 

127 return interface.channels is not None 

128 return bool(children) 

129 

130 

131def _rename_plan(rule, variables, parent, children, suffixes, bases): # pragma: no cover - channelization only 

132 """Plan the rename of a family the templates or rows already describe.""" 

133 children = sorted(children, key=lambda child: child.channel_id) 

134 targets = channelized_family_targets( 

135 rule, 

136 variables, 

137 parent.name, 

138 bases.base_for(parent.name), 

139 parent.channels, 

140 tuple((child.name, child.channel_id) for child in children), 

141 suffixes, 

142 ) 

143 members = [ProspectiveMember(source_name=parent.name, target_name=targets.parent_name, role=MemberRole.PARENT)] 

144 members.extend( 

145 ProspectiveMember( 

146 # A channel whose target cannot be derived keeps the name it has. 

147 source_name=child.name, 

148 target_name=child.name if target is None else target, 

149 role=MemberRole.CHANNEL, 

150 channel_id=child.channel_id, 

151 reason=reason, 

152 ) 

153 for child, (target, reason) in zip(children, targets.channels, strict=True) 

154 ) 

155 return ProspectiveFamilyPlan( 

156 family_id=f"channelized:{parent.name}", 

157 topology=FamilyTopology.CHANNELIZED, 

158 base_name=None, 

159 members=tuple(members), 

160 precondition_status=targets.status, 

161 precondition_reason=targets.reason, 

162 ) 

163 

164 

165def _refused_creation(base_name, topology, role, status, reason): 

166 """Plan a family that will not be built, so the base keeps the name it has.""" 

167 return ProspectiveFamilyPlan( 

168 family_id=f"{topology}:{base_name}", 

169 topology=topology, 

170 base_name=base_name, 

171 members=(ProspectiveMember(source_name=base_name, target_name=base_name, role=role),), 

172 precondition_status=status, 

173 precondition_reason=reason, 

174 ) 

175 

176 

177def _unclaimed(base_name, topology, role): 

178 """Plan a family that will not be built because no template gives *base_name* a raw name.""" 

179 return _refused_creation(base_name, topology, role, FamilyStatus.BLOCKED, UNCLAIMED_BASE_REASON) 

180 

181 

182def _simple_plan(rule, variables, base_name, base): 

183 """Plan the rename of one interface that owns no family.""" 

184 if base is None: 

185 return _unclaimed(base_name, FamilyTopology.FLAT, MemberRole.FLAT_MEMBER) 

186 try: 

187 target_name = evaluate_name_template(rule.name_template, {**variables, "base": base}) 

188 except (TypeError, ValueError) as error: 

189 return _refused_creation( 

190 base_name, FamilyTopology.FLAT, MemberRole.FLAT_MEMBER, FamilyStatus.FAILED, str(error) 

191 ) 

192 return ProspectiveFamilyPlan( 

193 family_id=f"flat:{base_name}", 

194 topology=FamilyTopology.FLAT, 

195 base_name=base_name, 

196 members=(ProspectiveMember(source_name=base_name, target_name=target_name, role=MemberRole.FLAT_MEMBER),), 

197 ) 

198 

199 

200def _flat_creation_plan(rule, variables, base_name, base): 

201 """Plan the flat sibling family a breakout rule creates on one plain interface.""" 

202 if base is None: 

203 return _unclaimed(base_name, FamilyTopology.FLAT, MemberRole.FLAT_MEMBER) 

204 try: 

205 target_names = flat_family_names(rule, variables, base) 

206 except (TypeError, ValueError) as error: 

207 return _refused_creation( 

208 base_name, FamilyTopology.FLAT, MemberRole.FLAT_MEMBER, FamilyStatus.FAILED, str(error) 

209 ) 

210 members = tuple( 

211 ProspectiveMember( 

212 source_name=base_name if offset == 0 else None, 

213 target_name=target_name, 

214 role=MemberRole.FLAT_MEMBER, 

215 ) 

216 for offset, target_name in enumerate(target_names) 

217 ) 

218 return ProspectiveFamilyPlan( 

219 family_id=f"flat:{base_name}", 

220 topology=FamilyTopology.FLAT, 

221 base_name=base_name, 

222 members=members, 

223 ) 

224 

225 

226def _modelled_structural_plan(rule, variables, base_name, base): # pragma: no cover - see below 

227 """Plan the channelized family for a NetBox release that can hold it.""" 

228 if base is None: 

229 return _unclaimed(base_name, FamilyTopology.CHANNELIZED, MemberRole.PARENT) 

230 try: 

231 parent_name, channels = channelized_family_names(rule, base_name, base, variables) 

232 except (TypeError, ValueError) as error: 

233 return _refused_creation( 

234 base_name, FamilyTopology.CHANNELIZED, MemberRole.PARENT, FamilyStatus.FAILED, str(error) 

235 ) 

236 members = ( 

237 ProspectiveMember(source_name=base_name, target_name=parent_name, role=MemberRole.PARENT), 

238 *( 

239 ProspectiveMember(source_name=None, target_name=name, role=MemberRole.CHANNEL, channel_id=channel_id) 

240 for channel_id, name in channels 

241 ), 

242 ) 

243 return ProspectiveFamilyPlan( 

244 family_id=f"channelized:{base_name}", 

245 topology=FamilyTopology.CHANNELIZED, 

246 base_name=base_name, 

247 members=members, 

248 ) 

249 

250 

251def _structural_plan(rule, variables, base_name, base): 

252 """Plan the channelized family a rule builds on one plain interface.""" 

253 if not supports_channelization(): 

254 return _refused_creation( 

255 base_name, FamilyTopology.CHANNELIZED, MemberRole.PARENT, FamilyStatus.UNSUPPORTED, UNSUPPORTED_REASON 

256 ) 

257 return _modelled_structural_plan(rule, variables, base_name, base) # pragma: no cover - see above 

258 

259 

260def _plain_plan(rule, variables, base_name, base): 

261 """Plan the family *rule* intends on the plain interface *base_name*, with *base* as ``{base}``.""" 

262 if rule.channel_count <= 0: 

263 return _simple_plan(rule, variables, base_name, base) 

264 if rule.breakout_mode == BreakoutModeChoices.CHANNELIZED: 

265 return _structural_plan(rule, variables, base_name, base) 

266 return _flat_creation_plan(rule, variables, base_name, base) 

267 

268 

269@dataclass(frozen=True, slots=True) 

270class _CreationContext: 

271 """The module-wide facts every creation plan on one module shares.""" 

272 

273 module: object 

274 taken_names: frozenset 

275 flat_expansion: bool 

276 

277 

278def _creation_context(module, rule, interfaces, plain): 

279 """Read the module-wide facts once, and only where a creation plan can use them.""" 

280 builds_channels = rule.channel_count > 0 and rule.breakout_mode == BreakoutModeChoices.CHANNELIZED 

281 flat_expansion = bool(plain) and builds_channels and supports_channelization() and has_flat_expansion(module) 

282 return _CreationContext( 

283 module=module, 

284 taken_names=frozenset(interface.name for interface in interfaces), 

285 flat_expansion=flat_expansion, 

286 ) 

287 

288 

289class _TemplateSuffixes: 

290 """The module type's per-channel name suffixes, read only if a channel name needs recovering.""" 

291 

292 def __init__(self, module): 

293 self._module = module 

294 self._suffixes = None 

295 

296 def get(self, channel_id, default=None): # pragma: no cover - requires channelization support 

297 """Return the suffixes the templates give *channel_id*, reading them on first use.""" 

298 if self._suffixes is None: 

299 self._suffixes = template_channel_suffixes(resolved_template_names(self._module)) 

300 return self._suffixes.get(channel_id, default) 

301 

302 

303def _plain_base(rule, bases, name): 

304 """Return ``{base}`` for *name*; a breakout rule builds only on a name that one template alone claims.""" 

305 return bases.builds_on(name) if breaks_out(rule) else bases.base_for(name) 

306 

307 

308def plan_prospective_families(module, rule, variables, interfaces, bases) -> ProspectiveFamilyPlanSet: 

309 """Return one plan for each family *rule* intends on the described *interfaces*. 

310 

311 *interfaces* are ``ProspectiveInterface`` values, so a name NetBox has not created yet is 

312 planned exactly like one it has and no row is written. Collision checking covers the names 

313 described here, because a prospective plan knows only the interfaces it was given. *bases* 

314 gives each described name its ``{base}`` value. 

315 """ 

316 interfaces = tuple(interfaces) 

317 roots, children = _partition(interfaces) 

318 families = [(root, children.get(root.name, ())) for root in roots] 

319 context = _creation_context( 

320 module, rule, interfaces, [root for root, family in families if not _is_family_root(rule, root, family)] 

321 ) 

322 suffixes = _TemplateSuffixes(module) 

323 plans = tuple( 

324 _rename_plan(rule, variables, root, family, suffixes, bases) 

325 if _is_family_root(rule, root, family) 

326 else _plain_plan(rule, variables, root.name, _plain_base(rule, bases, root.name)) 

327 for root, family in families 

328 ) 

329 return ProspectiveFamilyPlanSet(module_id=module.pk, plans=_settled_creations(plans, context, bases)) 

330 

331 

332def _is_structural_creation(plan) -> bool: 

333 """Return whether *plan* builds a channelized family whose names were evaluated.""" 

334 return ( 

335 plan.topology == FamilyTopology.CHANNELIZED and plan.base_name is not None and plan.precondition_status is None 

336 ) 

337 

338 

339def _settled_creations(plans, context, bases): 

340 """Refuse the channelized families the module cannot hold, as installed execution refuses them. 

341 

342 The families are settled against each other as ``settle_module_families`` settles them: a name 

343 they want more than once as a channel refuses each family that wants it, and the rows that they take over 

344 (ADR 0017) are not flat siblings. A described interface with a channel name is one a family can 

345 take over; only the rows the preview reads can say whether it does, so only the parent name is 

346 checked against the described names here. 

347 """ 

348 creations = [plan for plan in plans if _is_structural_creation(plan)] 

349 if not creations: 

350 return plans 

351 repeated = repeated_names(plan.target_names[1:] for plan in creations) 

352 families = ((plan.base_name, plan.target_names[1:], plan.precondition_status) for plan in creations) 

353 names = module_taken_over_names(families, repeated, bases) 

354 expanded = context.flat_expansion and has_flat_expansion(context.module, names) 

355 return tuple( 

356 _settled_creation(plan, context, repeated, expanded) if _is_structural_creation(plan) else plan 

357 for plan in plans 

358 ) 

359 

360 

361def _settled_creation(plan, context, repeated, expanded): # pragma: no cover - requires channelization support 

362 """Return *plan*, or the refusal of the family it would build when the module cannot hold it.""" 

363 parent_name = plan.target_names[0] 

364 refusal = repeated_name_refusal(plan.target_names[1:], repeated) 

365 if refusal is not None: 

366 reason = refusal[1] 

367 elif expanded: 

368 # Converting one sibling into a parent would strand the others beside the new family. 

369 reason = FLAT_EXPANSION_REASON 

370 elif parent_name != plan.base_name and parent_name in context.taken_names: 

371 reason = f"{COLLISION_REASON}: {parent_name}" 

372 else: 

373 return plan 

374 return _refused_creation( 

375 plan.base_name, FamilyTopology.CHANNELIZED, MemberRole.PARENT, FamilyStatus.BLOCKED, reason 

376 )