Coverage for netbox_data_import/catalog.py: 100%

127 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-09 20:50 +0000

1# SPDX-License-Identifier: Apache-2.0 

2# SPDX-FileCopyrightText: 2026 Marcin Zieba <marcinpsk@gmail.com> 

3"""Static target-field catalog and policy applicability. 

4 

5This module is the one source of Target Field keys. Forms, REST, GraphQL, YAML, and validation 

6derive their choices from it and keep no local field list. It imports nothing from the plugin and 

7nothing from NetBox, so every other layer may depend on it. 

8""" 

9 

10from __future__ import annotations 

11 

12from collections.abc import Iterator, Sequence 

13from contextlib import contextmanager 

14from contextvars import ContextVar 

15from dataclasses import dataclass, field 

16 

17 

18class OutputKind: 

19 """The typed source items a Source Adapter can emit.""" 

20 

21 DEVICE_SOURCE_ROW = "device_source_row" 

22 RACK_SOURCE_ROW = "rack_source_row" 

23 SOURCE_TRACE = "source_trace" 

24 

25 

26class TargetModuleKey: 

27 """The target domains a Target Module writes to.""" 

28 

29 DEVICE = "device" 

30 RACK = "rack" 

31 CABLE = "cable" 

32 

33 

34class ValueKind: 

35 """How a Target Field's value is interpreted once a source supplies it.""" 

36 

37 TEXT = "text" 

38 INTEGER = "integer" 

39 DECIMAL = "decimal" 

40 CHOICE = "choice" 

41 IP_ADDRESS = "ip_address" 

42 CANDIDATE = "candidate" 

43 

44 

45@dataclass(frozen=True) 

46class TargetModule: 

47 """A Target Module declares the adapter output kinds it consumes.""" 

48 

49 key: str 

50 label: str 

51 consumes: frozenset[str] 

52 # A declared module the release does not implement yet cannot make its adapters selectable. 

53 implemented: bool 

54 

55 

56TARGET_MODULES: tuple[TargetModule, ...] = ( 

57 TargetModule( 

58 key=TargetModuleKey.DEVICE, 

59 label="Device", 

60 consumes=frozenset({OutputKind.DEVICE_SOURCE_ROW}), 

61 implemented=True, 

62 ), 

63 TargetModule( 

64 key=TargetModuleKey.RACK, 

65 label="Rack", 

66 consumes=frozenset({OutputKind.RACK_SOURCE_ROW}), 

67 implemented=True, 

68 ), 

69 TargetModule( 

70 key=TargetModuleKey.CABLE, 

71 label="Cable", 

72 consumes=frozenset({OutputKind.SOURCE_TRACE}), 

73 implemented=True, 

74 ), 

75) 

76 

77_MODULES_BY_KEY = {module.key: module for module in TARGET_MODULES} 

78 

79 

80@dataclass(frozen=True) 

81class TargetField: 

82 """One catalog entry with a fixed key.""" 

83 

84 key: str 

85 label: str 

86 value_kind: str 

87 output_kinds: frozenset[str] 

88 # A candidate target supplies review candidates rather than a written value. ColumnTransformRule 

89 # excludes these because a regex capture group cannot produce a candidate bundle. 

90 candidate_target: bool = False 

91 

92 @property 

93 def target_modules(self) -> frozenset[str]: 

94 """Return the Target Modules that consume this field, derived from its output kinds.""" 

95 return frozenset(m.key for m in TARGET_MODULES if m.consumes & self.output_kinds) 

96 

97 

98@dataclass(frozen=True) 

99class KeyFamily: 

100 """A catalog entry whose exact key is data, not a fixed choice. 

101 

102 The key is the prefix plus a name. Every surface resolves a family key through this validator, 

103 so no surface reimplements the prefix rule. 

104 """ 

105 

106 prefix: str 

107 label: str 

108 value_kind: str 

109 output_kinds: frozenset[str] 

110 name_label: str = "Custom field" 

111 

112 def matches(self, key: str) -> bool: 

113 """Return True when *key* carries this family's prefix.""" 

114 return key.startswith(self.prefix) 

115 

116 def name_of(self, key: str) -> str: 

117 """Return the name part of *key*, without the prefix.""" 

118 return key[len(self.prefix) :] 

119 

120 def is_valid(self, key: str) -> bool: 

121 """Return True when *key* is a well-formed member: the prefix plus a non-empty name.""" 

122 return self.matches(key) and bool(self.name_of(key).strip()) 

123 

124 def display(self, key: str) -> str: 

125 """Return the human-readable name for a member key.""" 

126 return f"{self.name_label}: {self.name_of(key)}" 

127 

128 @property 

129 def target_modules(self) -> frozenset[str]: 

130 """Return the Target Modules that consume this family, derived from its output kinds.""" 

131 return frozenset(m.key for m in TARGET_MODULES if m.consumes & self.output_kinds) 

132 

133 

134_FLAT_ROW_KINDS = frozenset({OutputKind.DEVICE_SOURCE_ROW, OutputKind.RACK_SOURCE_ROW}) 

135_DEVICE_ONLY = frozenset({OutputKind.DEVICE_SOURCE_ROW}) 

136_TRACE_ONLY = frozenset({OutputKind.SOURCE_TRACE}) 

137 

138EXTRA_JSON_PREFIX = "extra_json:" 

139CANDIDATE_TARGET_PREFIX = "candidate:" 

140 

141 

142@dataclass(frozen=True) 

143class TargetFieldCatalog: 

144 """The static registry of Target Fields and key families.""" 

145 

146 fields: tuple[TargetField, ...] 

147 families: tuple[KeyFamily, ...] 

148 _by_key: dict[str, TargetField] = field(init=False, repr=False, compare=False, default_factory=dict) 

149 

150 def __post_init__(self): 

151 object.__setattr__(self, "_by_key", {entry.key: entry for entry in self.fields}) 

152 

153 def entry(self, key: str) -> TargetField | None: 

154 """Return the fixed-key entry for *key*, or None.""" 

155 return self._by_key.get(key) 

156 

157 def family(self, key: str) -> KeyFamily | None: 

158 """Return the key family that claims *key*, or None.""" 

159 for candidate in self.families: 

160 if candidate.matches(key): 

161 return candidate 

162 return None 

163 

164 def is_valid(self, key: str, *, output_kinds: frozenset[str] | None = None, allow_candidates: bool = True) -> bool: 

165 """Return True when *key* is a Target Field the given output kinds can supply.""" 

166 entry = self.entry(key) 

167 if entry is not None: 

168 if entry.candidate_target and not allow_candidates: 

169 return False 

170 return output_kinds is None or bool(entry.output_kinds & output_kinds) 

171 family = self.family(key) 

172 if family is None or not family.is_valid(key): 

173 return False 

174 return output_kinds is None or bool(family.output_kinds & output_kinds) 

175 

176 def display(self, key: str) -> str: 

177 """Return the human-readable name for any valid key, or the key itself.""" 

178 entry = self.entry(key) 

179 if entry is not None: 

180 return entry.label 

181 family = self.family(key) 

182 if family is not None and family.is_valid(key): 

183 return family.display(key) 

184 return key 

185 

186 def choices(self, *, output_kinds: frozenset[str] | None = None, allow_candidates: bool = True): 

187 """Return Django choice pairs for the fixed-key entries the given output kinds can supply.""" 

188 return [ 

189 (entry.key, entry.label) 

190 for entry in self.fields 

191 if (allow_candidates or not entry.candidate_target) 

192 and (output_kinds is None or entry.output_kinds & output_kinds) 

193 ] 

194 

195 def invalid_key_message(self, key: str) -> str: 

196 """Return the validation message for a rejected key.""" 

197 prefixes = ", ".join(f"'{f.prefix}'" for f in self.families) 

198 return f"Value '{key}' is not a valid choice. Must be one of the standard field names or start with {prefixes}." 

199 

200 

201CATALOG = TargetFieldCatalog( 

202 fields=( 

203 TargetField("rack_name", "Rack name", ValueKind.TEXT, _FLAT_ROW_KINDS), 

204 TargetField("device_name", "Device name", ValueKind.TEXT, _FLAT_ROW_KINDS), 

205 TargetField("device_class", "Device class (maps to role/rack)", ValueKind.TEXT, _FLAT_ROW_KINDS), 

206 TargetField("face", "Face (Front/Back)", ValueKind.CHOICE, _DEVICE_ONLY), 

207 TargetField("airflow", "Airflow", ValueKind.CHOICE, _DEVICE_ONLY), 

208 TargetField("u_position", "U position", ValueKind.INTEGER, _DEVICE_ONLY), 

209 TargetField("status", "Status", ValueKind.CHOICE, _DEVICE_ONLY), 

210 TargetField("make", "Make (manufacturer)", ValueKind.TEXT, _DEVICE_ONLY), 

211 TargetField("model", "Model (device type)", ValueKind.TEXT, _DEVICE_ONLY), 

212 TargetField("u_height", "U height", ValueKind.DECIMAL, _FLAT_ROW_KINDS), 

213 TargetField("serial", "Serial number", ValueKind.TEXT, _FLAT_ROW_KINDS), 

214 TargetField("asset_tag", "Asset tag", ValueKind.TEXT, _DEVICE_ONLY), 

215 TargetField("primary_ip4", "Primary IPv4", ValueKind.IP_ADDRESS, _DEVICE_ONLY), 

216 TargetField("primary_ip6", "Primary IPv6", ValueKind.IP_ADDRESS, _DEVICE_ONLY), 

217 TargetField("oob_ip", "Out-of-band IP", ValueKind.IP_ADDRESS, _DEVICE_ONLY), 

218 TargetField("primary_contact", "Primary contact", ValueKind.TEXT, _DEVICE_ONLY), 

219 TargetField("source_id", "Source ID (stored in custom field)", ValueKind.TEXT, _FLAT_ROW_KINDS), 

220 TargetField( 

221 f"{CANDIDATE_TARGET_PREFIX}contact", 

222 "Candidate values: Contact fields", 

223 ValueKind.CANDIDATE, 

224 _DEVICE_ONLY, 

225 candidate_target=True, 

226 ), 

227 ), 

228 families=( 

229 KeyFamily( 

230 prefix=EXTRA_JSON_PREFIX, 

231 label="Custom field", 

232 value_kind=ValueKind.TEXT, 

233 output_kinds=_DEVICE_ONLY, 

234 ), 

235 ), 

236) 

237 

238 

239@dataclass(frozen=True) 

240class PolicySection: 

241 """A profile policy table and the adapter output kinds it applies to.""" 

242 

243 key: str 

244 label: str 

245 output_kinds: frozenset[str] 

246 

247 def applies_to(self, output_kinds: frozenset[str]) -> bool: 

248 """Return True when an adapter emitting *output_kinds* can use this section.""" 

249 return bool(self.output_kinds & output_kinds) 

250 

251 

252POLICY_SECTIONS: tuple[PolicySection, ...] = ( 

253 PolicySection("column_mappings", "Column Mappings", _FLAT_ROW_KINDS), 

254 PolicySection("column_transform_rules", "Column Transform Rules", _FLAT_ROW_KINDS), 

255 PolicySection("class_role_mappings", "Class/Role Mappings", _FLAT_ROW_KINDS), 

256 PolicySection("device_type_mappings", "Device Type Mappings", _FLAT_ROW_KINDS), 

257 PolicySection("manufacturer_mappings", "Manufacturer Mappings", _FLAT_ROW_KINDS), 

258 PolicySection("ignored_devices", "Ignored Devices", _FLAT_ROW_KINDS), 

259 PolicySection("source_resolutions", "Source Resolutions", _DEVICE_ONLY), 

260 PolicySection("device_existing_matches", "Device Existing Matches", _DEVICE_ONLY), 

261 PolicySection("ignored_field_differences", "Ignored Field Differences", _DEVICE_ONLY), 

262 PolicySection("termination_resolutions", "Termination Resolutions", _TRACE_ONLY), 

263 PolicySection("cable_class_mappings", "CableClass Mappings", _TRACE_ONLY), 

264) 

265 

266_SECTIONS_BY_KEY = {section.key: section for section in POLICY_SECTIONS} 

267 

268 

269def target_module(key: str) -> TargetModule | None: 

270 """Return the Target Module declared under *key*.""" 

271 return _MODULES_BY_KEY.get(key) 

272 

273 

274def policy_section(key: str) -> PolicySection | None: 

275 """Return the policy section declared under *key*.""" 

276 return _SECTIONS_BY_KEY.get(key) 

277 

278 

279_declared_override: ContextVar[tuple[TargetModule, ...] | None] = ContextVar("declared_modules", default=None) 

280 

281 

282def declared_modules() -> tuple[TargetModule, ...]: 

283 """Return the Target Module declarations in force.""" 

284 override = _declared_override.get() 

285 return TARGET_MODULES if override is None else override 

286 

287 

288@contextmanager 

289def declared_modules_override(modules: Sequence[TargetModule]) -> Iterator[None]: 

290 """Run the block against *modules*, so a caller can state a table this release does not ship. 

291 

292 Every entry point reaches the runtime gate through `ImportProfile.clean`, far below its own 

293 signature, so a declaration table cannot be passed down to it as an argument. 

294 """ 

295 token = _declared_override.set(tuple(modules)) 

296 try: 

297 yield 

298 finally: 

299 _declared_override.reset(token) 

300 

301 

302def consuming_modules(output_kinds: frozenset[str]) -> tuple[TargetModule, ...]: 

303 """Return the Target Modules that consume any of *output_kinds*.""" 

304 return tuple(module for module in declared_modules() if module.consumes & output_kinds) 

305 

306 

307def has_implemented_module(output_kinds: frozenset[str]) -> bool: 

308 """Return True when a Target Module this release implements consumes any of *output_kinds*.""" 

309 return any(module.implemented for module in consuming_modules(output_kinds)) 

310 

311 

312__all__ = ( 

313 "CANDIDATE_TARGET_PREFIX", 

314 "CATALOG", 

315 "EXTRA_JSON_PREFIX", 

316 "POLICY_SECTIONS", 

317 "TARGET_MODULES", 

318 "KeyFamily", 

319 "OutputKind", 

320 "PolicySection", 

321 "TargetField", 

322 "TargetFieldCatalog", 

323 "TargetModule", 

324 "TargetModuleKey", 

325 "ValueKind", 

326 "consuming_modules", 

327 "declared_modules", 

328 "declared_modules_override", 

329 "has_implemented_module", 

330 "policy_section", 

331 "target_module", 

332)