Coverage for netbox_interface_name_rules / name_template.py: 100.00%

283 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"""Define and evaluate the name-template language.""" 

4 

5import ast 

6import itertools 

7import operator 

8import re 

9from dataclasses import KW_ONLY, dataclass 

10from enum import StrEnum 

11 

12from django.core.exceptions import ValidationError 

13 

14from .choices import BreakoutModeChoices 

15 

16 

17class NamingContext(StrEnum): 

18 """Name the contexts in which a name template is evaluated.""" 

19 

20 MODULE_MEMBER = "module_member" 

21 MODULE_PARENT = "module_parent" 

22 DEVICE_INTERFACE = "device_interface" 

23 

24 

25class TemplateVariableSource(StrEnum): 

26 """Name how a template variable reaches the evaluator.""" 

27 

28 MODULE_BAY_CHAIN = "module_bay_chain" 

29 RENAME_CALLER = "rename_caller" 

30 

31 

32class TemplateVariableCondition(StrEnum): 

33 """Name conditions that control whether a template variable is available.""" 

34 

35 VIRTUAL_CHASSIS_MEMBER = "virtual_chassis_member" 

36 BAY_POSITION_HAS_DIGIT = "bay_position_has_digit" 

37 RULE_DECLARES_CHANNELS = "rule_declares_channels" 

38 

39 

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

41class TemplateVariable: 

42 """Describe one template variable and its naming-context metadata. 

43 

44 *text* is True when the value can be any text, so arithmetic cannot read it, and 

45 *numeric_counterpart* names the integer variable that arithmetic reads instead. 

46 """ 

47 

48 name: str 

49 providers: tuple[tuple[NamingContext, TemplateVariableSource], ...] 

50 descriptions: tuple[tuple[NamingContext, str], ...] 

51 example: str 

52 condition: TemplateVariableCondition | None = None 

53 refusals: tuple[tuple[NamingContext, str], ...] = () 

54 _: KW_ONLY 

55 text: bool 

56 numeric_counterpart: str | None = None 

57 

58 

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

60class _EvaluatedField: 

61 """Locate one brace group the evaluator reads.""" 

62 

63 start: int 

64 end: int 

65 expression: str 

66 

67 

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

69class _ParsedBraceGroups: 

70 """Hold the legacy reference and evaluation views of one name template.""" 

71 

72 reference_fields: tuple[str, ...] 

73 evaluated_fields: tuple[_EvaluatedField, ...] 

74 balanced: bool 

75 

76 

77_MEMBER = NamingContext.MODULE_MEMBER 

78_PARENT = NamingContext.MODULE_PARENT 

79_DEVICE = NamingContext.DEVICE_INTERFACE 

80_BUILT = TemplateVariableSource.MODULE_BAY_CHAIN 

81_CALLER = TemplateVariableSource.RENAME_CALLER 

82_SLOT_DESCRIPTION = "Top-level module bay position. A bay on the device gives only the trailing digits of its position." 

83_VC_POSITION_DESCRIPTION = "Virtual Chassis member position. Available only on a member device." 

84_BAY_POSITION_PATH_DESCRIPTION = ( 

85 "Position of the bay that holds the module, from its first digit 0-9: et-0/0/3 gives 0/0/3. " 

86 "Available only when the position has such a digit." 

87) 

88 

89TEMPLATE_VARIABLES = ( 

90 TemplateVariable( 

91 "slot", 

92 ((_MEMBER, _BUILT), (_PARENT, _BUILT)), 

93 ( 

94 (_MEMBER, _SLOT_DESCRIPTION), 

95 (_PARENT, _SLOT_DESCRIPTION), 

96 ), 

97 "3", 

98 text=True, 

99 numeric_counterpart="slot_num", 

100 ), 

101 TemplateVariable( 

102 "slot_num", 

103 ((_MEMBER, _BUILT), (_PARENT, _BUILT)), 

104 ( 

105 (_MEMBER, "Numeric suffix of the top-level module bay position."), 

106 (_PARENT, "Numeric suffix of the top-level module bay position."), 

107 ), 

108 "3", 

109 text=False, 

110 ), 

111 TemplateVariable( 

112 "bay_position", 

113 ((_MEMBER, _BUILT), (_PARENT, _BUILT)), 

114 ( 

115 (_MEMBER, "Position of the bay that holds the module."), 

116 (_PARENT, "Position of the bay that holds the module."), 

117 ), 

118 "swp1", 

119 text=True, 

120 numeric_counterpart="bay_position_num", 

121 ), 

122 TemplateVariable( 

123 "bay_position_num", 

124 ((_MEMBER, _BUILT), (_PARENT, _BUILT)), 

125 ( 

126 (_MEMBER, "Numeric suffix of the module bay position."), 

127 (_PARENT, "Numeric suffix of the module bay position."), 

128 ), 

129 "1", 

130 text=False, 

131 ), 

132 TemplateVariable( 

133 "bay_position_path", 

134 ((_MEMBER, _BUILT), (_PARENT, _BUILT)), 

135 ( 

136 (_MEMBER, _BAY_POSITION_PATH_DESCRIPTION), 

137 (_PARENT, _BAY_POSITION_PATH_DESCRIPTION), 

138 ), 

139 "0/0/3", 

140 condition=TemplateVariableCondition.BAY_POSITION_HAS_DIGIT, 

141 text=True, 

142 ), 

143 TemplateVariable( 

144 "parent_bay_position", 

145 ((_MEMBER, _BUILT), (_PARENT, _BUILT)), 

146 ( 

147 (_MEMBER, "Position of the parent module's bay."), 

148 (_PARENT, "Position of the parent module's bay."), 

149 ), 

150 "TenGigabitEthernet3/2", 

151 text=True, 

152 numeric_counterpart="parent_bay_position_num", 

153 ), 

154 TemplateVariable( 

155 "parent_bay_position_num", 

156 ((_MEMBER, _BUILT), (_PARENT, _BUILT)), 

157 ( 

158 (_MEMBER, "Numeric suffix of the parent module's bay position."), 

159 (_PARENT, "Numeric suffix of the parent module's bay position."), 

160 ), 

161 "2", 

162 text=False, 

163 ), 

164 TemplateVariable( 

165 "sfp_slot", 

166 ((_MEMBER, _BUILT), (_PARENT, _BUILT)), 

167 ( 

168 (_MEMBER, "Numeric sub-bay index within the parent module."), 

169 (_PARENT, "Numeric sub-bay index within the parent module."), 

170 ), 

171 "0", 

172 text=False, 

173 ), 

174 TemplateVariable( 

175 "base", 

176 ((_MEMBER, _CALLER), (_PARENT, _CALLER), (_DEVICE, _CALLER)), 

177 ( 

178 (_MEMBER, "The raw template name of the interface the rule renames."), 

179 (_PARENT, "The raw template name of the interface the rule renames."), 

180 (_DEVICE, "Current interface name before the rule applies."), 

181 ), 

182 "et-0/0/1", 

183 text=True, 

184 ), 

185 TemplateVariable( 

186 "vc_position", 

187 ((_MEMBER, _BUILT), (_PARENT, _BUILT), (_DEVICE, _CALLER)), 

188 ( 

189 (_MEMBER, _VC_POSITION_DESCRIPTION), 

190 (_PARENT, _VC_POSITION_DESCRIPTION), 

191 (_DEVICE, _VC_POSITION_DESCRIPTION), 

192 ), 

193 "2", 

194 condition=TemplateVariableCondition.VIRTUAL_CHASSIS_MEMBER, 

195 text=False, 

196 ), 

197 TemplateVariable( 

198 "channel", 

199 ((_MEMBER, _CALLER),), 

200 ((_MEMBER, "Breakout channel number. Available when the rule declares channels."),), 

201 "0", 

202 condition=TemplateVariableCondition.RULE_DECLARES_CHANNELS, 

203 refusals=((_PARENT, "The parent interface has no channel number; remove {channel}."),), 

204 text=False, 

205 ), 

206 TemplateVariable( 

207 "port", 

208 ((_DEVICE, _CALLER),), 

209 ( 

210 ( 

211 _DEVICE, 

212 "Segment after the last slash in the current interface name. Uses the full name when no slash is present.", 

213 ), 

214 ), 

215 "1", 

216 text=True, 

217 ), 

218) 

219 

220 

221_UNAVAILABLE_REASONS = { 

222 TemplateVariableCondition.VIRTUAL_CHASSIS_MEMBER: "is not available on this device", 

223 TemplateVariableCondition.BAY_POSITION_HAS_DIGIT: "is not available because the module bay position has no digit", 

224} 

225_RUNTIME_CONDITIONAL_VARIABLES = { 

226 variable.name: _UNAVAILABLE_REASONS[variable.condition] 

227 for variable in TEMPLATE_VARIABLES 

228 if variable.condition not in (None, TemplateVariableCondition.RULE_DECLARES_CHANNELS) 

229} 

230 

231 

232class UnavailableVariableError(ValueError): 

233 """Report each variable that a name template reads and the device or the module bay does not provide.""" 

234 

235 

236def variables_for_context(context: NamingContext, source: TemplateVariableSource | None = None): 

237 """Return the catalogue entries available in one naming context.""" 

238 return tuple( 

239 variable 

240 for variable in TEMPLATE_VARIABLES 

241 if any( 

242 provider_context == context and (source is None or provider_source == source) 

243 for provider_context, provider_source in variable.providers 

244 ) 

245 ) 

246 

247 

248_BINARY_OPERATORS = { 

249 ast.Add: operator.add, 

250 ast.Sub: operator.sub, 

251 ast.Mult: operator.mul, 

252 ast.FloorDiv: operator.floordiv, 

253} 

254_UNARY_OPERATORS = { 

255 ast.UAdd: operator.pos, 

256 ast.USub: operator.neg, 

257} 

258 

259 

260def _identifiers(text): 

261 """Return each identifier-character run in *text* from its first identifier start, so none is lost.""" 

262 names = [] 

263 for part, chars in itertools.groupby(text, lambda char: f"_{char}".isidentifier()): 

264 run = "".join(chars) 

265 start = next((index for index, char in enumerate(run) if char.isidentifier()), None) 

266 if part and start is not None: 

267 names.append(run[start:]) 

268 return names 

269 

270 

271def _reference_brace_fields(template): 

272 """Return fields from each closed group, starting at its last opening brace.""" 

273 reference_fields = [] 

274 reference_start = None 

275 for index, char in enumerate(template): 

276 if char == "{": 

277 reference_start = index 

278 elif char == "}" and reference_start is not None: 

279 reference_fields.append(template[reference_start + 1 : index]) 

280 reference_start = None 

281 return tuple(reference_fields) 

282 

283 

284def _braces_balanced(template): 

285 """Return whether every closing brace matches an earlier opening brace and none stay open.""" 

286 depth = 0 

287 for char in template: 

288 if char == "{": 

289 depth += 1 

290 elif char == "}": 

291 if not depth: 

292 return False 

293 depth -= 1 

294 return depth == 0 

295 

296 

297def _evaluated_brace_fields(template, literal_spans=()): 

298 """Return nonempty groups from their first opening brace to the next closing brace.""" 

299 evaluated_fields = [] 

300 evaluation_start = None 

301 spans = iter(literal_spans) 

302 span = next(spans, None) 

303 for index, char in enumerate(template): 

304 while span is not None and index >= span[1]: 

305 span = next(spans, None) 

306 if span is not None and index >= span[0]: 

307 continue 

308 if char == "{" and evaluation_start is None: 

309 evaluation_start = index 

310 elif char == "}" and evaluation_start is not None: 

311 if index > evaluation_start + 1: 

312 evaluated_fields.append( 

313 _EvaluatedField(evaluation_start, index + 1, template[evaluation_start + 1 : index]) 

314 ) 

315 evaluation_start = None 

316 return tuple(evaluated_fields) 

317 

318 

319def _parse_brace_groups(template, literal_spans=()): 

320 """Return the reference, evaluation, and balance views of a template.""" 

321 return _ParsedBraceGroups( 

322 _reference_brace_fields(template), _evaluated_brace_fields(template, literal_spans), _braces_balanced(template) 

323 ) 

324 

325 

326def _parse_expression(expression): 

327 """Parse one brace-group expression.""" 

328 return ast.parse(expression, mode="eval") 

329 

330 

331def _arithmetic_operands(node): 

332 """Return the operand nodes of an integer arithmetic node, or raise ValueError for any other node.""" 

333 if isinstance(node, ast.Expression): 

334 return [node.body] 

335 if isinstance(node, ast.Constant) and type(node.value) is int: 

336 return [] 

337 if isinstance(node, ast.BinOp) and type(node.op) in _BINARY_OPERATORS: 

338 return [node.left, node.right] 

339 if isinstance(node, ast.UnaryOp) and type(node.op) in _UNARY_OPERATORS: 

340 return [node.operand] 

341 raise ValueError(f"Unsafe AST node in expression: {type(node).__name__}") 

342 

343 

344def _evaluate_arithmetic(node): 

345 """Evaluate one integer arithmetic syntax tree without executing code.""" 

346 values = [_evaluate_arithmetic(operand) for operand in _arithmetic_operands(node)] 

347 if isinstance(node, ast.BinOp): 

348 return _BINARY_OPERATORS[type(node.op)](*values) 

349 if isinstance(node, ast.UnaryOp): 

350 return _UNARY_OPERATORS[type(node.op)](*values) 

351 return node.value if isinstance(node, ast.Constant) else values[0] 

352 

353 

354class _FormatFieldError(ValueError): 

355 """Report a brace group written as a str.format conversion or format specification.""" 

356 

357 

358_FORMAT_FIELD_MESSAGE = ( 

359 "Name templates take a variable or an arithmetic expression, not str.format " 

360 "conversions and format specifications: {group}" 

361) 

362 

363 

364def _is_format_field(expr): 

365 """Return whether *expr* is a name followed by a str.format conversion or format specification.""" 

366 split = min((index for index in (expr.find("!"), expr.find(":")) if index >= 0), default=-1) 

367 if split < 0 or not expr[:split].rstrip().isidentifier(): 

368 return False 

369 rest = expr[split:] 

370 return rest in ("!r", "!s", "!a") or (rest[0] == ":" and "{" not in rest and "}" not in rest) 

371 

372 

373def _substitute_tokens(template, replacements): 

374 """Replace each exact token in *replacements* and return the text and the spans of the inserted values.""" 

375 literal_spans = [] 

376 if not replacements: 

377 return template, literal_spans 

378 offset = 0 

379 

380 def substitute(match): 

381 nonlocal offset 

382 value = replacements[match.group()] 

383 start = match.start() + offset 

384 literal_spans.append((start, start + len(value))) 

385 offset += len(value) - len(match.group()) 

386 return value 

387 

388 pattern = "|".join(re.escape(token) for token in sorted(replacements, key=lambda token: (-len(token), token))) 

389 return re.sub(pattern, substitute, template), literal_spans 

390 

391 

392def _group_tree(expr): 

393 """Return the syntax tree of one stripped brace group that passes the text checks, or raise ValueError.""" 

394 if _is_format_field(expr): 

395 raise _FormatFieldError(_FORMAT_FIELD_MESSAGE.format(group=f"{{{expr}}}")) 

396 if not re.match(r"^(?!.*(?<!/)/(?!/))[\d\s\+\-\*\(\/\)]+$", expr): 

397 raise ValueError(f"Unsafe expression in name template: {expr}") 

398 try: 

399 return _parse_expression(expr) 

400 except SyntaxError as exc: 

401 raise ValueError(f"Invalid arithmetic expression '{expr}': {exc}") from exc 

402 

403 

404def _evaluate_group(expression): 

405 """Evaluate one brace group after substitution, or raise ValueError.""" 

406 expr = expression.strip() 

407 node = _group_tree(expr) 

408 try: 

409 return str(int(_evaluate_arithmetic(node))) 

410 except (TypeError, ZeroDivisionError) as exc: 

411 raise ValueError(f"Invalid arithmetic expression '{expr}': {exc}") from exc 

412 

413 

414def _group_shape_error(expression): 

415 """Return the ValueError the evaluator raises for one brace group before it computes, or None.""" 

416 try: 

417 pending = [_group_tree(expression.strip())] 

418 while pending: 

419 pending.extend(_arithmetic_operands(pending.pop())) 

420 except ValueError as exc: 

421 return exc 

422 return None 

423 

424 

425def variable_token(name): 

426 """Return the exact text a name template substitutes for the variable *name*.""" 

427 return f"{{{name}}}" 

428 

429 

430def references_variable(template, name): 

431 """Return whether evaluating *template* substitutes the variable *name*.""" 

432 return variable_token(name) in template 

433 

434 

435def referenced_variables(template): 

436 """Return distinct variable names in order of first appearance.""" 

437 names = {} 

438 for field in _parse_brace_groups(template).reference_fields: 

439 head = field.split("!", 1)[0].split(":", 1)[0].strip() 

440 named_head = re.split(r"[.\[]", head, maxsplit=1)[0] 

441 if named_head.isidentifier(): 

442 names.setdefault(named_head, None) 

443 for source in (field.strip(), head): 

444 try: 

445 tree = _parse_expression(source) 

446 except (SyntaxError, ValueError): 

447 continue 

448 references = sorted( 

449 (node for node in ast.walk(tree) if isinstance(node, ast.Name)), 

450 key=lambda node: (node.lineno, node.col_offset), 

451 ) 

452 for node in references: 

453 names.setdefault(node.id, None) 

454 break 

455 else: 

456 # A group that does not parse names every identifier in it, so the check fails closed. 

457 for name in _identifiers(head): 

458 names.setdefault(name, None) 

459 return tuple(names) 

460 

461 

462def unavailable_variables(variables, *templates) -> tuple[str, ...]: 

463 """Return the conditional variables that *templates* read and *variables* leave out, once each.""" 

464 return tuple( 

465 dict.fromkeys( 

466 name 

467 for template in templates 

468 for name in referenced_variables(template) 

469 if name in _RUNTIME_CONDITIONAL_VARIABLES and name not in variables 

470 ) 

471 ) 

472 

473 

474def unavailable_reason(names) -> str: 

475 """Return why each variable in *names*, which ``unavailable_variables`` returned, is not available.""" 

476 return "; ".join(f"{variable_token(name)} {_RUNTIME_CONDITIONAL_VARIABLES[name]}" for name in names) 

477 

478 

479def _validate_breakout_topology(breakout_mode, channel_count, parent_name_template, applies_to_device_interfaces): 

480 """Check that the mode, channel count, and parent template describe one topology.""" 

481 channelized = breakout_mode == BreakoutModeChoices.CHANNELIZED 

482 if applies_to_device_interfaces: 

483 if channelized: 

484 raise ValidationError({"breakout_mode": "Device-level interface rules cannot build a channelized family."}) 

485 if parent_name_template: 

486 raise ValidationError( 

487 {"parent_name_template": "Parent name template is not available for device-level interface rules."} 

488 ) 

489 if parent_name_template and not channelized: 

490 raise ValidationError({"parent_name_template": "Parent name template requires the channelized breakout mode."}) 

491 if channelized and not channel_count: 

492 raise ValidationError({"channel_count": "A channelized rule must define at least one channel."}) 

493 

494 

495_VARIABLE_NAMES = frozenset(variable.name for variable in TEMPLATE_VARIABLES) 

496_CONTEXT_LABELS = { 

497 NamingContext.DEVICE_INTERFACE: "device-level rule's name template", 

498 NamingContext.MODULE_MEMBER: "module rule's name template", 

499 NamingContext.MODULE_PARENT: "module rule's parent name template", 

500} 

501_REFUSALS = { 

502 (context, variable.name): message for variable in TEMPLATE_VARIABLES for context, message in variable.refusals 

503} 

504_TEXT_ARITHMETIC_REFUSALS = { 

505 variable.name: f"{variable_token(variable.name)} holds text and cannot be used in arithmetic." 

506 + ("" if variable.numeric_counterpart is None else f" Use {variable_token(variable.numeric_counterpart)} instead.") 

507 for variable in TEMPLATE_VARIABLES 

508 if variable.text 

509} 

510 

511 

512def _template_errors(template, context, channel_count): 

513 """Return brace, context, group-shape, and text-arithmetic errors for one template.""" 

514 if not _parse_brace_groups(template).balanced: 

515 return ["Unbalanced braces — every '{' in the template needs a '}'."] 

516 available = { 

517 variable.name 

518 for variable in variables_for_context(context) 

519 if variable.condition != TemplateVariableCondition.RULE_DECLARES_CHANNELS or channel_count > 0 

520 } 

521 available_text = ", ".join(variable_token(name) for name in sorted(available)) 

522 text, groups = _token_groups(template) 

523 return ( 

524 [ 

525 _REFUSALS.get((context, name)) 

526 or f"{variable_token(name)} is not available in a {_CONTEXT_LABELS[context]}. Available: {available_text}." 

527 for name in referenced_variables(template) 

528 if name not in available 

529 ] 

530 + _group_errors(template, text, groups) 

531 + _text_arithmetic_errors(template, groups, available) 

532 ) 

533 

534 

535def _token_groups(template): 

536 """Return *template* with each variable token as digits, and each evaluated group with the token spans in it.""" 

537 tokens = tuple(variable_token(name) for name in referenced_variables(template) if name.isidentifier()) 

538 # Same-length placeholders keep each evaluated group at its offset in the operator's template. 

539 text, literal_spans = _substitute_tokens(template, {token: "1" * len(token) for token in tokens}) 

540 spans = iter(literal_spans) 

541 span = next(spans, None) 

542 groups = [] 

543 for field in _parse_brace_groups(text, literal_spans).evaluated_fields: 

544 group_spans = [] 

545 while span is not None and span[0] < field.end: 

546 if span[0] > field.start: 

547 group_spans.append(span) 

548 span = next(spans, None) 

549 groups.append((field, group_spans)) 

550 return text, groups 

551 

552 

553def _text_arithmetic_errors(template, groups, available): 

554 """Return one error for each available text variable whose token is inside an evaluated group.""" 

555 names = dict.fromkeys(template[start + 1 : end - 1] for _field, spans in groups for start, end in spans) 

556 return [ 

557 _TEXT_ARITHMETIC_REFUSALS[name] for name in names if name in available and name in _TEXT_ARITHMETIC_REFUSALS 

558 ] 

559 

560 

561def _group_errors(template, text, groups): 

562 """Return one error for each distinct brace group that no variable values can evaluate.""" 

563 errors = {} 

564 for field, group_spans in groups: 

565 failure = _group_shape_refusal(template, text, field, group_spans) 

566 group = template[field.start : field.end] 

567 if isinstance(failure, _FormatFieldError): 

568 errors.setdefault(_FORMAT_FIELD_MESSAGE.format(group=group)) 

569 elif failure: 

570 errors.setdefault( 

571 f"{group} is neither a variable token nor integer arithmetic over variable tokens. " 

572 "Write each variable as its own {name} token, and use only +, -, *, // and parentheses, " 

573 "as in {{slot_num} // 2}." 

574 ) 

575 return list(errors) 

576 

577 

578def _group_shape_refusal(template, text, field, spans): 

579 """Return the all-ones shape error of one group when no 0 or 1 digit per variable passes, or None.""" 

580 # Unknown names share one digit: the context check refuses them, and each would double the work. 

581 names = {span: template[span[0] + 1 : span[1] - 1] for span in spans} 

582 choices = {span: name if name in _VARIABLE_NAMES else None for span, name in names.items()} 

583 variables = tuple(dict.fromkeys(choices.values())) 

584 first_error = None 

585 for digits in itertools.product("10", repeat=len(variables)): 

586 digit_of = dict(zip(variables, digits, strict=True)) 

587 chars = list(text[field.start + 1 : field.end - 1]) 

588 for (start, end), variable in choices.items(): 

589 chars[start - field.start - 1 : end - field.start - 1] = digit_of[variable] * (end - start) 

590 error = _group_shape_error("".join(chars)) 

591 if error is None: 

592 return None 

593 first_error = first_error or error 

594 return first_error 

595 

596 

597def validate_rule(*, breakout_mode, channel_count, name_template, parent_name_template, applies_to_device_interfaces): 

598 """Validate topology first, then collect both templates' brace and context errors.""" 

599 _validate_breakout_topology(breakout_mode, channel_count, parent_name_template, applies_to_device_interfaces) 

600 context = NamingContext.DEVICE_INTERFACE if applies_to_device_interfaces else NamingContext.MODULE_MEMBER 

601 errors = {} 

602 for field, template, naming_context in ( 

603 ("name_template", name_template, context), 

604 ("parent_name_template", parent_name_template, NamingContext.MODULE_PARENT), 

605 ): 

606 messages = _template_errors(template, naming_context, channel_count) 

607 if messages: 

608 errors[field] = messages 

609 if errors: 

610 raise ValidationError(errors) 

611 

612 

613def log_refused_rules(rules, logger): 

614 """Log each stored rule in the *rules* queryset that ``validate_rule`` refuses, and change no row.""" 

615 rows = rules.values( 

616 "pk", "breakout_mode", "channel_count", "name_template", "parent_name_template", "applies_to_device_interfaces" 

617 ) 

618 for fields in rows.iterator(): 

619 pk = fields.pop("pk") 

620 try: 

621 validate_rule(**fields) 

622 except ValidationError as exc: 

623 for field, messages in exc.message_dict.items(): 

624 for message in messages: 

625 logger.warning("InterfaceNameRule ID %s: %s: %s", pk, field, message) 

626 

627 

628def evaluate_name_template(template: str, variables: dict) -> str: 

629 """Evaluate a name template with variable substitution and safe arithmetic. 

630 

631 A brace group holds either a documented variable name or an arithmetic expression over the 

632 substituted values. This is not ``str.format``: braces nest for arithmetic, and ``str.format`` 

633 conversions (``!r``) and format specifications (``:>2``) are not part of the language. 

634 

635 Substituted values are literal text, not template syntax. 

636 Variables are substituted before remaining brace-enclosed arithmetic is evaluated. True 

637 division is not allowed. Arithmetic results become integers so interface names contain whole 

638 numbers. 

639 

640 For example, ``GigabitEthernet{slot_num}/{8 + {sfp_slot}}`` substitutes the variables before 

641 evaluating the arithmetic expression. 

642 

643 A template that reads a conditional variable that *variables* leaves out raises 

644 ``UnavailableVariableError``, which says why the variable is not available. Only a failed 

645 evaluation reads the catalogue, so a successful one does no extra work. 

646 """ 

647 replacements = {variable_token(key): str(value) for key, value in variables.items()} 

648 result, literal_spans = _substitute_tokens(template, replacements) 

649 parsed = _parse_brace_groups(result, literal_spans) 

650 parts = [] 

651 end = 0 

652 try: 

653 for field in parsed.evaluated_fields: 

654 parts.extend((result[end : field.start], _evaluate_group(field.expression))) 

655 end = field.end 

656 except ValueError as error: 

657 missing = unavailable_variables(variables, template) 

658 if missing: 

659 raise UnavailableVariableError(unavailable_reason(missing)) from error 

660 raise 

661 parts.append(result[end:]) 

662 return "".join(parts)