Coverage for netbox_interface_name_rules / family / template_names.py: 100.00%
78 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"""Resolve current and historical NetBox interface-template names.
5The refetch and template queries here use the default manager, so NetBox's router sends them to the
6active netbox-branching branch, or to main. The block cache is keyed by primary key alone. That holds
7because every read of one block goes to one alias: netbox-branching activates one branch for a whole
8request or job, and the plugin does not change the active branch inside a block.
9"""
11import contextlib
12import copy
13import re
14import threading
15from dataclasses import dataclass, replace
16from re import Pattern
18from dcim.models import InterfaceTemplate, Module, VirtualChassis
20from ..rule_selection import compile_stored_pattern
22BAY_CHAIN_RELATIONS = (
23 "device",
24 "device__virtual_chassis",
25 "module_bay",
26 "module_bay__parent",
27 "module_bay__module",
28 "module_bay__module__module_bay",
29 "module_bay__module__module_bay__parent",
30 "module_bay__module__module_bay__module",
31)
33# PostgreSQL text cannot hold NUL, so no stored template name or bay position can spell this marker.
34_VC_SENTINEL = "\x00{}\x00"
35_VC_SENTINEL_RE = re.compile(r"\x00(\d+)\x00")
36# NetBox stores vc_position in a PositiveIntegerField, so ten digits cover every valid value.
37VC_POSITION_DIGITS = r"\d{1,10}"
40@dataclass(frozen=True, slots=True)
41class ResolvedTemplateName:
42 """One template's current name, its optional historical-name matcher, and what NetBox resolved it from.
44 ``source`` holds the template and the module that NetBox resolved the name against. It is None
45 on a NetBox without the ``{vc_position}`` token, because the name is then the same at every position.
46 """
48 pk: int
49 template_name: str
50 resolved: str
51 historical_pattern: Pattern[str] | None
52 parent_id: int | None
53 channel_id: int | None
54 channels: int | None
55 source: tuple | None = None
57 def at_chassis_position(self, vc_position, *, move=False):
58 """Return this template as NetBox names it at *vc_position*, which None puts off a chassis.
60 NetBox names an installed component with ``resolve_name``, and a moved one with the resolver of
61 its move planner; *move* selects that one.
62 """
63 if self.source is None:
64 return self
65 template, module = self.source
66 stand_in = _module_at_chassis_position(module, vc_position)
67 return replace(self, resolved=_move_name(template, stand_in) if move else template.resolve_name(stand_in))
70def _move_name(template, module): # pragma: no cover - requires a NetBox that renames moved components
71 """Return the name that NetBox's move planner gives *template* on *module* in its bay.
73 Unlike ``resolve_name``, the planner resolves ``{vc_position}`` also when only a bay position brings
74 the token in. Positions that do not fit the template raise here as they do in ``resolve_name``.
75 """
76 from dcim.models.module_moves import ModuleMovePlan
77 from dcim.utils import get_module_bay_positions
79 positions = get_module_bay_positions(module.module_bay)
80 return ModuleMovePlan._resolve(template, template.name, positions, module.device)
83def _module_at_chassis_position(module, vc_position): # pragma: no cover - requires virtual-chassis token support
84 """Return a copy of *module* whose device is a copy at *vc_position*; None puts that device off a chassis.
86 NetBox reads only whether the device has a virtual chassis, and its position. The copies share the
87 bay chain that *module* already loaded, change nothing on *module* or its device, and are not saved.
88 """
89 device = copy.copy(module.device)
90 device.virtual_chassis = None if vc_position is None else VirtualChassis()
91 device.vc_position = vc_position
92 stand_in = copy.copy(module)
93 stand_in.device = device
94 return stand_in
97def vc_position_re():
98 """Return NetBox's virtual-chassis template-token pattern when available."""
99 try:
100 from dcim.constants import VC_POSITION_RE
101 except ImportError:
102 return None
103 return VC_POSITION_RE # pragma: no cover - only available on NetBox releases with the token
106def _vc_position_alternatives(fallback): # pragma: no cover - requires virtual-chassis token support
107 """Return every value represented by one virtual-chassis position token."""
108 if fallback is None:
109 return VC_POSITION_DIGITS
110 return f"(?:{VC_POSITION_DIGITS}|{re.escape(fallback)})"
113def _historical_pattern(template, module, token_re): # pragma: no cover - requires virtual-chassis token support
114 """Return a matcher for every historical resolution of *template*."""
115 fallbacks = []
117 def mark(match):
118 fallbacks.append(match.group(1))
119 return _VC_SENTINEL.format(len(fallbacks) - 1)
121 marked = token_re.sub(mark, template.name)
122 if not fallbacks:
123 return None
124 stub = copy.copy(template)
125 stub.name = marked
126 literals = _VC_SENTINEL_RE.split(stub.resolve_name(module))[0::2]
127 # Adjacent tokens cannot be told apart, and their alternatives would backtrack without bound.
128 if any(not literal for literal in literals[1:-1]):
129 return None
130 pattern = re.escape(literals[0])
131 for fallback, literal in zip(fallbacks, literals[1:], strict=True):
132 pattern += _vc_position_alternatives(fallback) + re.escape(literal)
133 return compile_stored_pattern(pattern)
136# One batch of modules shares its module chains, template rows and resolved names, thread-locally.
137_pin = threading.local()
140@contextlib.contextmanager
141def pinned_template_cache(modules=()):
142 """Share resolved interface templates across every module in one batch.
144 Each module in *modules* must already carry ``BAY_CHAIN_RELATIONS``, so its templates resolve
145 without a refetch. Modules the block meets later are chained and cached on first use, and one
146 module type's template rows are read once. Nested blocks share the outermost cache.
147 """
148 depth = getattr(_pin, "depth", 0)
149 _pin.depth = depth + 1
150 try:
151 # Setting up the cache must sit inside the try, or a raise here strands the depth forever.
152 if depth == 0:
153 _pin.chained = {}
154 _pin.templates = {}
155 _pin.resolved = {}
156 _pin.chained.update({module.pk: module for module in modules})
157 yield
158 finally:
159 _pin.depth -= 1
160 if _pin.depth == 0:
161 for attr in ("chained", "templates", "resolved"):
162 _pin.__dict__.pop(attr, None)
165def module_with_bay_chain(module):
166 """Re-fetch *module* with every relation template name resolution uses."""
167 chained = getattr(_pin, "chained", None)
168 if chained is None:
169 return Module.objects.select_related(*BAY_CHAIN_RELATIONS).get(pk=module.pk)
170 if module.pk not in chained:
171 chained[module.pk] = Module.objects.select_related(*BAY_CHAIN_RELATIONS).get(pk=module.pk)
172 return chained[module.pk]
175def _interface_templates(module_type_id):
176 """Return one module type's interface templates in primary-key order."""
177 templates = getattr(_pin, "templates", None)
178 if templates is None:
179 return list(InterfaceTemplate.objects.filter(module_type_id=module_type_id).order_by("pk"))
180 if module_type_id not in templates:
181 templates[module_type_id] = list(InterfaceTemplate.objects.filter(module_type_id=module_type_id).order_by("pk"))
182 return templates[module_type_id]
185def _resolve_template(template, module, token_re) -> ResolvedTemplateName:
186 """Resolve one interface template against *module*."""
187 return ResolvedTemplateName(
188 pk=template.pk,
189 template_name=template.name,
190 resolved=template.resolve_name(module),
191 historical_pattern=None if token_re is None else _historical_pattern(template, module, token_re),
192 parent_id=getattr(template, "parent_id", None),
193 channel_id=getattr(template, "channel_id", None),
194 channels=getattr(template, "channels", None),
195 source=None if token_re is None else (template, module),
196 )
199def resolve_templates(templates, module) -> tuple[ResolvedTemplateName, ...]:
200 """Resolve already-loaded interface templates against *module*."""
201 token_re = vc_position_re()
202 return tuple(_resolve_template(template, module, token_re) for template in templates)
205def resolved_template_names(module) -> tuple[ResolvedTemplateName, ...]:
206 """Load and resolve every interface template for *module* once."""
207 resolved = getattr(_pin, "resolved", None)
208 if resolved is not None and module.pk in resolved:
209 return resolved[module.pk]
210 chained = module_with_bay_chain(module)
211 names = resolve_templates(_interface_templates(chained.module_type_id), chained)
212 if resolved is not None:
213 resolved[module.pk] = names
214 return names
217class TemplateNames:
218 """The module type's resolved template names, read only where a plan needs them."""
220 def __init__(self, module):
221 self._module = module
222 self._templates = None
224 def get(self):
225 """Return every resolved template name for the module, loading them once."""
226 if self._templates is None:
227 self._templates = resolved_template_names(self._module)
228 return self._templates