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

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. 

4 

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""" 

9 

10import logging 

11import re 

12from typing import NamedTuple 

13 

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 

18 

19logger = logging.getLogger(__name__) 

20 

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}" 

25 

26 

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))) 

30 

31 

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 

37 

38 

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") 

43 

44 

45def _marked_evaluation(template, variables, raw): 

46 """Return the template evaluated with markers for ``{base}`` and ``{vc_position}``, or None. 

47 

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 

59 

60 

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)) 

83 

84 

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 } 

96 

97 

98class TemplateForms(NamedTuple): 

99 """The names one template claims under one rule: its raw name and matchers for what the rule gives it. 

100 

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 """ 

104 

105 raw: str 

106 renamed: re.Pattern | None 

107 channels: dict | None 

108 

109 

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) 

116 

117 

118class FlatFamily(NamedTuple): 

119 """A flat breakout family that one template claims. 

120 

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 """ 

124 

125 base_name: str 

126 names: tuple[str, ...] 

127 

128 

129class NameClaim(NamedTuple): 

130 """Whether a template claims one name, one template alone does, and one does as a raw name now or earlier.""" 

131 

132 claimed: bool 

133 accepted: bool 

134 raw: bool 

135 

136 

137_UNCLAIMED = NameClaim(claimed=False, accepted=False, raw=False) 

138 

139 

140class _Resolution(NamedTuple): 

141 """What one claim pass decided: the raw base of each name one template alone claims, families and claims.""" 

142 

143 bases: dict 

144 families: tuple[FlatFamily, ...] 

145 claims: dict 

146 

147 

148class RawBases: 

149 """The one claim over every name form of a module's interfaces under one rule (ADR 0013). 

150 

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 """ 

154 

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 

170 

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 

174 

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) 

178 

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 

182 

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 

189 

190 def claim(self, name): 

191 """Return how the claim treats *name*.""" 

192 return self._resolved().claims.get(name, _UNCLAIMED) 

193 

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 

199 

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 

205 

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 ) 

235 

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 ) 

247 

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, ())} 

251 

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 ) 

259 

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. 

262 

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 

288 

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 

297 

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 ) 

309 

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) 

313 

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 ) 

324 

325 

326class GivenRawNames: 

327 """Bases for names a caller gives as raw template names, such as the names prediction receives. 

328 

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 """ 

332 

333 def __init__(self, bases): 

334 self._bases = bases 

335 

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 

339 

340 builds_on = base_for 

341 

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)