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
« 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."""
5import ast
6import itertools
7import operator
8import re
9from dataclasses import KW_ONLY, dataclass
10from enum import StrEnum
12from django.core.exceptions import ValidationError
14from .choices import BreakoutModeChoices
17class NamingContext(StrEnum):
18 """Name the contexts in which a name template is evaluated."""
20 MODULE_MEMBER = "module_member"
21 MODULE_PARENT = "module_parent"
22 DEVICE_INTERFACE = "device_interface"
25class TemplateVariableSource(StrEnum):
26 """Name how a template variable reaches the evaluator."""
28 MODULE_BAY_CHAIN = "module_bay_chain"
29 RENAME_CALLER = "rename_caller"
32class TemplateVariableCondition(StrEnum):
33 """Name conditions that control whether a template variable is available."""
35 VIRTUAL_CHASSIS_MEMBER = "virtual_chassis_member"
36 BAY_POSITION_HAS_DIGIT = "bay_position_has_digit"
37 RULE_DECLARES_CHANNELS = "rule_declares_channels"
40@dataclass(frozen=True, slots=True)
41class TemplateVariable:
42 """Describe one template variable and its naming-context metadata.
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 """
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
59@dataclass(frozen=True, slots=True)
60class _EvaluatedField:
61 """Locate one brace group the evaluator reads."""
63 start: int
64 end: int
65 expression: str
68@dataclass(frozen=True, slots=True)
69class _ParsedBraceGroups:
70 """Hold the legacy reference and evaluation views of one name template."""
72 reference_fields: tuple[str, ...]
73 evaluated_fields: tuple[_EvaluatedField, ...]
74 balanced: bool
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)
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)
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}
232class UnavailableVariableError(ValueError):
233 """Report each variable that a name template reads and the device or the module bay does not provide."""
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 )
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}
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
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)
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
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)
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 )
326def _parse_expression(expression):
327 """Parse one brace-group expression."""
328 return ast.parse(expression, mode="eval")
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__}")
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]
354class _FormatFieldError(ValueError):
355 """Report a brace group written as a str.format conversion or format specification."""
358_FORMAT_FIELD_MESSAGE = (
359 "Name templates take a variable or an arithmetic expression, not str.format "
360 "conversions and format specifications: {group}"
361)
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)
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
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
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
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
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
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
425def variable_token(name):
426 """Return the exact text a name template substitutes for the variable *name*."""
427 return f"{{{name}}}"
430def references_variable(template, name):
431 """Return whether evaluating *template* substitutes the variable *name*."""
432 return variable_token(name) in template
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)
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 )
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)
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."})
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}
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 )
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
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 ]
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)
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
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)
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)
628def evaluate_name_template(template: str, variables: dict) -> str:
629 """Evaluate a name template with variable substitution and safe arithmetic.
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.
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.
640 For example, ``GigabitEthernet{slot_num}/{8 + {sfp_slot}}`` substitutes the variables before
641 evaluating the arithmetic expression.
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)