Coverage for netbox_data_import/plan.py: 100%

256 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"""The target-neutral Import Plan. 

4 

5An Import Plan is a serializable derived artifact: Synchronization Units holding typed Planned 

6Changes, their dispositions, and structured diagnostics. It is the contract between the Import 

7Engine, the Review Workspace, the session, and a background job payload. 

8 

9This module holds no NetBox import. Every value that enters a plan passes through a canonical JSON 

10round trip, so a live ORM object, a queryset, a callable, or a template fragment cannot reach one. 

11""" 

12 

13from __future__ import annotations 

14 

15import hashlib 

16import json 

17import re 

18from collections import Counter 

19from collections.abc import Mapping 

20from dataclasses import dataclass, field 

21from typing import Any 

22 

23SCHEMA_VERSION = 2 

24 

25_DIAGNOSTIC_CODE = re.compile(r"^[a-z0-9]+(?:_[a-z0-9]+)*\.[a-z0-9]+(?:_[a-z0-9]+)*$") 

26 

27 

28class PlanError(Exception): 

29 """Base class for the Import Plan failures the coordinator reports.""" 

30 

31 

32class PlanInvalid(PlanError): 

33 """A plan violates a structural invariant and cannot execute.""" 

34 

35 

36class PlanSchemaMismatch(PlanError): 

37 """A serialized plan states a schema version this release does not execute.""" 

38 

39 

40class Disposition: 

41 """The exactly-one state a Synchronization Unit carries (section 4.2).""" 

42 

43 ACTIONABLE = "actionable" 

44 NO_OP = "no-op" 

45 BLOCKED = "blocked" 

46 INVALID = "invalid" 

47 EXCLUDED = "excluded" 

48 

49 ALL = frozenset({ACTIONABLE, NO_OP, BLOCKED, INVALID, EXCLUDED}) 

50 

51 

52class Severity: 

53 """Diagnostic severity (section 4.2).""" 

54 

55 INFO = "info" 

56 WARNING = "warning" 

57 ERROR = "error" 

58 

59 ALL = frozenset({INFO, WARNING, ERROR}) 

60 

61 

62def canonical_json(value: Any) -> str: 

63 """Return the canonical serialization used for every fingerprint. 

64 

65 ``allow_nan`` stays off: NaN and Infinity are not JSON, PostgreSQL rejects them in a JSONField, 

66 and NaN never equals itself, which would make two identical shared changes look conflicting. 

67 """ 

68 return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False) 

69 

70 

71def fingerprint_of(value: Any) -> str: 

72 """Return the SHA-256 digest of the canonical serialization of *value*.""" 

73 return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest() 

74 

75 

76class FrozenDict(dict): 

77 """The JSON object a plan hands out: JSON data, closed to mutation. 

78 

79 A frozen plan value stays JSON data, because a Target Module writes one into a JSONField. 

80 """ 

81 

82 __slots__ = () 

83 

84 def _immutable(self, *args, **kwargs): 

85 """Refuse one mutation, so a shared plan value cannot change under its other readers.""" 

86 raise TypeError("An Import Plan value cannot be changed.") 

87 

88 __setitem__ = _immutable 

89 __delitem__ = _immutable 

90 __ior__ = _immutable 

91 clear = _immutable 

92 pop = _immutable 

93 popitem = _immutable 

94 setdefault = _immutable 

95 update = _immutable 

96 

97 

98def _freeze_json(value: Any) -> Any: 

99 """Return recursively immutable JSON data.""" 

100 if isinstance(value, dict): 

101 return FrozenDict({key: _freeze_json(item) for key, item in value.items()}) 

102 if isinstance(value, list): 

103 return tuple(_freeze_json(item) for item in value) 

104 return value 

105 

106 

107def _thaw_json(value: Any) -> Any: 

108 """Return detached plain JSON data from an immutable plan value.""" 

109 if isinstance(value, Mapping): 

110 return {key: _thaw_json(item) for key, item in value.items()} 

111 if isinstance(value, tuple): 

112 return [_thaw_json(item) for item in value] 

113 return value 

114 

115 

116def _frozen_json(value: Any, label: str) -> Any: 

117 """Validate, detach, and recursively freeze JSON data entering a plan.""" 

118 try: 

119 return _freeze_json(json.loads(canonical_json(value))) 

120 except (TypeError, ValueError) as exc: 

121 raise PlanInvalid(f"{label} must be JSON-serializable plan data: {exc}") from exc 

122 

123 

124def _plan_mapping(value: Any, label: str) -> Mapping[str, Any]: 

125 """Return *value* as the immutable JSON object the field declares.""" 

126 frozen = _frozen_json(value, label) 

127 if not isinstance(frozen, Mapping): 

128 raise PlanInvalid(f"{label} must be a JSON object, not {type(value).__name__}.") 

129 return frozen 

130 

131 

132def _plan_text(value, label: str) -> str: 

133 """Return *value* as the string the field declares.""" 

134 if not isinstance(value, str): 

135 raise PlanInvalid(f"{label} must be a string, not {type(value).__name__}.") 

136 return value 

137 

138 

139def _plan_int(value, label: str) -> int: 

140 """Return *value* as the integer the field declares. 

141 

142 A bool is an int in Python, and `from_dict` refuses one, so a plan built from a bool could not 

143 survive its own serialization. 

144 """ 

145 if isinstance(value, bool) or not isinstance(value, int): 

146 raise PlanInvalid(f"{label} must be an integer, not {type(value).__name__}.") 

147 return value 

148 

149 

150def _elements(value, kind, label: str) -> tuple: 

151 """Return *value* as a tuple, rejecting any element that is not an instance of *kind*.""" 

152 if isinstance(value, str) or not isinstance(value, (list, tuple)): 

153 raise PlanInvalid(f"{label} must be a list or tuple of {kind.__name__} objects.") 

154 for item in value: 

155 if not isinstance(item, kind): 

156 raise PlanInvalid(f"{label} must hold {kind.__name__} objects, not {type(item).__name__}.") 

157 return tuple(value) 

158 

159 

160def _identities(value, label: str) -> tuple[str, ...]: 

161 """Return *value* as a tuple of identity strings. 

162 

163 A bare string is refused because tuple("rack:1") would silently become six identities. 

164 """ 

165 if isinstance(value, str) or not isinstance(value, (list, tuple)): 

166 raise PlanInvalid(f"{label} must be a list or tuple of identity strings.") 

167 for item in value: 

168 if not isinstance(item, str): 

169 raise PlanInvalid(f"{label} must hold identity strings, not {type(item).__name__}.") 

170 return tuple(value) 

171 

172 

173@dataclass(frozen=True) 

174class Diagnostic: 

175 """One structured finding attached to a plan or a unit.""" 

176 

177 code: str 

178 severity: str 

179 identities: tuple[str, ...] = () 

180 display: Mapping[str, Any] = field(default_factory=dict) 

181 

182 def __post_init__(self): 

183 """Validate the code namespace and the severity vocabulary.""" 

184 if not _DIAGNOSTIC_CODE.match(_plan_text(self.code, "Diagnostic code")): 

185 raise PlanInvalid(f"Diagnostic code '{self.code}' must use the '<domain>.<condition>' form.") 

186 if _plan_text(self.severity, "Diagnostic severity") not in Severity.ALL: 

187 raise PlanInvalid(f"Unknown diagnostic severity '{self.severity}'.") 

188 object.__setattr__(self, "identities", _identities(self.identities, "Diagnostic identities")) 

189 object.__setattr__(self, "display", _plan_mapping(self.display, "Diagnostic display")) 

190 

191 def __hash__(self): 

192 """Hash over the serialized form, which mirrors the generated equality.""" 

193 return hash(canonical_json(self.to_dict())) 

194 

195 @property 

196 def fingerprint_data(self): 

197 """Return the decision inputs: the code, the severity, and the affected identities.""" 

198 return {"code": self.code, "severity": self.severity, "identities": list(self.identities)} 

199 

200 def to_dict(self) -> dict: 

201 """Return the serialized form.""" 

202 return { 

203 "code": self.code, 

204 "severity": self.severity, 

205 "identities": list(self.identities), 

206 "display": _thaw_json(self.display), 

207 } 

208 

209 @classmethod 

210 def from_dict(cls, data: dict) -> Diagnostic: 

211 """Rebuild a diagnostic from its serialized form.""" 

212 return cls( 

213 code=data["code"], 

214 severity=data["severity"], 

215 identities=tuple(data.get("identities", ())), 

216 display=data.get("display", {}), 

217 ) 

218 

219 

220@dataclass(frozen=True) 

221class PlannedChange: 

222 """One typed target write with its dependencies and target-state preconditions.""" 

223 

224 identity: str 

225 target_module: str 

226 operation: str 

227 payload: Mapping[str, Any] 

228 dependencies: tuple[str, ...] = () 

229 preconditions: Mapping[str, Any] = field(default_factory=dict) 

230 

231 def __post_init__(self): 

232 """Detach the mappings from planning state and reject anything a plan may not carry.""" 

233 if not _plan_text(self.identity, "Planned Change identity"): 

234 raise PlanInvalid("A Planned Change needs a stable identity.") 

235 for name in ("target_module", "operation"): 

236 value = getattr(self, name) 

237 if not isinstance(value, str) or not value: 

238 raise PlanInvalid(f"A Planned Change needs a non-empty {name.replace('_', ' ')}.") 

239 object.__setattr__(self, "dependencies", _identities(self.dependencies, "Planned Change dependencies")) 

240 object.__setattr__(self, "payload", _plan_mapping(self.payload, "Planned Change payload")) 

241 object.__setattr__( 

242 self, 

243 "preconditions", 

244 _plan_mapping(self.preconditions, "Planned Change preconditions"), 

245 ) 

246 

247 def __hash__(self): 

248 """Hash over the serialized form, which mirrors the generated equality.""" 

249 return hash(canonical_json(self.to_dict())) 

250 

251 @property 

252 def fingerprint_data(self): 

253 """Return every decision input this change contributes.""" 

254 return { 

255 "identity": self.identity, 

256 "target_module": self.target_module, 

257 "operation": self.operation, 

258 "payload": _thaw_json(self.payload), 

259 "dependencies": list(self.dependencies), 

260 "preconditions": _thaw_json(self.preconditions), 

261 } 

262 

263 def to_dict(self) -> dict: 

264 """Return a detached copy of the serialized form, which is exactly the fingerprint data.""" 

265 return json.loads(canonical_json(self.fingerprint_data)) 

266 

267 @classmethod 

268 def from_dict(cls, data: dict) -> PlannedChange: 

269 """Rebuild a change from its serialized form.""" 

270 return cls( 

271 identity=data["identity"], 

272 target_module=data["target_module"], 

273 operation=data["operation"], 

274 payload=data.get("payload", {}), 

275 dependencies=tuple(data.get("dependencies", ())), 

276 preconditions=data.get("preconditions", {}), 

277 ) 

278 

279 

280@dataclass(frozen=True) 

281class SynchronizationUnit: 

282 """The smallest independently reviewable and executable part of a plan.""" 

283 

284 identity: str 

285 disposition: str 

286 changes: tuple[PlannedChange, ...] = () 

287 diagnostics: tuple[Diagnostic, ...] = () 

288 display: Mapping[str, Any] = field(default_factory=dict) 

289 

290 def __post_init__(self): 

291 """Validate the disposition and detach the display data.""" 

292 if not _plan_text(self.identity, "Synchronization Unit identity"): 

293 raise PlanInvalid("A Synchronization Unit needs a stable identity.") 

294 if _plan_text(self.disposition, "Synchronization Unit disposition") not in Disposition.ALL: 

295 raise PlanInvalid(f"Unknown disposition '{self.disposition}'.") 

296 object.__setattr__(self, "changes", _elements(self.changes, PlannedChange, "Synchronization Unit changes")) 

297 object.__setattr__( 

298 self, "diagnostics", _elements(self.diagnostics, Diagnostic, "Synchronization Unit diagnostics") 

299 ) 

300 object.__setattr__(self, "display", _plan_mapping(self.display, "Synchronization Unit display")) 

301 

302 def __hash__(self): 

303 """Hash over the serialized form, which mirrors the generated equality.""" 

304 return hash(canonical_json(self.to_dict())) 

305 

306 @property 

307 def fingerprint_data(self): 

308 """Return the decision inputs, which exclude the display wording.""" 

309 return { 

310 "identity": self.identity, 

311 "disposition": self.disposition, 

312 "changes": [change.fingerprint_data for change in self.changes], 

313 "diagnostics": [diagnostic.fingerprint_data for diagnostic in self.diagnostics], 

314 } 

315 

316 def to_dict(self) -> dict: 

317 """Return the serialized form.""" 

318 return { 

319 "identity": self.identity, 

320 "disposition": self.disposition, 

321 "changes": [change.to_dict() for change in self.changes], 

322 "diagnostics": [diagnostic.to_dict() for diagnostic in self.diagnostics], 

323 "display": _thaw_json(self.display), 

324 } 

325 

326 @classmethod 

327 def from_dict(cls, data: dict) -> SynchronizationUnit: 

328 """Rebuild a unit from its serialized form.""" 

329 return cls( 

330 identity=data["identity"], 

331 disposition=data["disposition"], 

332 changes=tuple(PlannedChange.from_dict(item) for item in data.get("changes", ())), 

333 diagnostics=tuple(Diagnostic.from_dict(item) for item in data.get("diagnostics", ())), 

334 display=data.get("display", {}), 

335 ) 

336 

337 

338@dataclass(frozen=True) 

339class ImportPlan: 

340 """A serializable plan: units, diagnostics, and the inputs its fingerprint covers.""" 

341 

342 units: tuple[SynchronizationUnit, ...] = () 

343 diagnostics: tuple[Diagnostic, ...] = () 

344 source_fingerprint: str = "" 

345 profile_fingerprint: str = "" 

346 actor: str = "" 

347 planning_context: Mapping[str, Any] = field(default_factory=dict) 

348 revision: int = 1 

349 schema_version: int = SCHEMA_VERSION 

350 

351 def __post_init__(self): 

352 """Detach the planning context from planning state.""" 

353 object.__setattr__(self, "units", _elements(self.units, SynchronizationUnit, "Import Plan units")) 

354 object.__setattr__(self, "diagnostics", _elements(self.diagnostics, Diagnostic, "Import Plan diagnostics")) 

355 object.__setattr__(self, "planning_context", _plan_mapping(self.planning_context, "Planning context")) 

356 # canonical_json raises a bare TypeError past `except PlanError`, and takes a dict for a str. 

357 for name in ("source_fingerprint", "profile_fingerprint", "actor"): 

358 _plan_text(getattr(self, name), f"Import Plan {name}") 

359 for name in ("revision", "schema_version"): 

360 _plan_int(getattr(self, name), f"Import Plan {name}") 

361 counts = Counter(unit.identity for unit in self.units) 

362 duplicates = sorted(identity for identity, count in counts.items() if count > 1) 

363 if duplicates: 

364 raise PlanInvalid(f"Synchronization Unit identities must be unique: {', '.join(duplicates)}.") 

365 

366 def __hash__(self): 

367 """Hash over the serialized form, which mirrors the generated equality.""" 

368 return hash(canonical_json(self.to_dict())) 

369 

370 @property 

371 def _selection_context_data(self): 

372 """Return the plan-wide inputs that invalidate every accepted unit.""" 

373 return { 

374 "schema_version": self.schema_version, 

375 "source_fingerprint": self.source_fingerprint, 

376 "profile_fingerprint": self.profile_fingerprint, 

377 "actor": self.actor, 

378 "planning_context": _thaw_json(self.planning_context), 

379 } 

380 

381 @property 

382 def fingerprint_data(self): 

383 """Return the decision inputs of section 4.3, which exclude display data and the revision.""" 

384 return { 

385 **self._selection_context_data, 

386 "units": [unit.fingerprint_data for unit in self.units], 

387 "diagnostics": [diagnostic.fingerprint_data for diagnostic in self.diagnostics], 

388 } 

389 

390 @property 

391 def fingerprint(self) -> str: 

392 """Return the canonical plan fingerprint.""" 

393 return fingerprint_of(self.fingerprint_data) 

394 

395 def unit(self, identity: str) -> SynchronizationUnit | None: 

396 """Return one indexed unit without repeating a linear scan for each lookup.""" 

397 index = self.__dict__.get("_unit_index") 

398 if index is None: 

399 index = {unit.identity: unit for unit in self.units} 

400 object.__setattr__(self, "_unit_index", index) 

401 return index.get(identity) 

402 

403 def unit_fingerprint(self, identity: str) -> str: 

404 """Return one unit's decision inputs plus the context shared by every unit.""" 

405 unit = self.unit(identity) 

406 if unit is None: 

407 raise PlanInvalid(f"The Import Plan has no Synchronization Unit '{identity}'.") 

408 return fingerprint_of({**self._selection_context_data, "unit": unit.fingerprint_data}) 

409 

410 def to_dict(self) -> dict: 

411 """Return the serialized form the session and a job payload carry.""" 

412 return { 

413 "schema_version": self.schema_version, 

414 "units": [unit.to_dict() for unit in self.units], 

415 "diagnostics": [diagnostic.to_dict() for diagnostic in self.diagnostics], 

416 "source_fingerprint": self.source_fingerprint, 

417 "profile_fingerprint": self.profile_fingerprint, 

418 "actor": self.actor, 

419 "planning_context": _thaw_json(self.planning_context), 

420 "revision": self.revision, 

421 } 

422 

423 @classmethod 

424 def from_dict(cls, data: Any) -> ImportPlan: 

425 """Rebuild a plan, rejecting a schema version this release does not execute. 

426 

427 Every other malformed payload also raises a PlanError, so one caller-side ``except PlanError`` 

428 covers a corrupted session entry or job payload (section 4.8). 

429 """ 

430 try: 

431 version = data.get("schema_version") 

432 if not isinstance(version, int) or isinstance(version, bool) or version != SCHEMA_VERSION: 

433 raise PlanSchemaMismatch(f"Import Plan schema version {version} is not version {SCHEMA_VERSION}.") 

434 return cls( 

435 units=tuple(SynchronizationUnit.from_dict(item) for item in data["units"]), 

436 diagnostics=tuple(Diagnostic.from_dict(item) for item in data["diagnostics"]), 

437 source_fingerprint=data.get("source_fingerprint", ""), 

438 profile_fingerprint=data.get("profile_fingerprint", ""), 

439 actor=data.get("actor", ""), 

440 planning_context=data.get("planning_context", {}), 

441 revision=data.get("revision", 1), 

442 schema_version=version, 

443 ) 

444 except PlanError: 

445 raise 

446 except (AttributeError, KeyError, TypeError, ValueError) as exc: 

447 raise PlanInvalid(f"The serialized Import Plan is malformed: {exc!r}") from exc 

448 

449 

450def executable_units(units) -> tuple[SynchronizationUnit, ...]: 

451 """Return only the actionable units. 

452 

453 Section 4.6: blocked, invalid, excluded, and no-op units never enter an execution transaction. 

454 """ 

455 return tuple(unit for unit in units if unit.disposition == Disposition.ACTIONABLE) 

456 

457 

458def merge_changes(units, *, reconciled=()) -> tuple[PlannedChange, ...]: 

459 """Merge the changes of *units* into one deterministic acyclic execution order. 

460 

461 Identical identities are shared and execute once. A conflicting payload or precondition, a 

462 dangling dependency, and a cycle each make the plan invalid (section 4.4). 

463 

464 *reconciled* holds the identities a previous execution already applied. They satisfy a 

465 dependency without being merged in, which is what section 4.5 means by a dependency that is 

466 already reconciled. Passing an identity here never adds work to the selection. 

467 

468 This merges whatever units it is given. Filter with ``executable_units`` before executing. 

469 """ 

470 merged: dict[str, PlannedChange] = {} 

471 order: list[str] = [] 

472 for unit in units: 

473 for change in unit.changes: 

474 existing = merged.get(change.identity) 

475 if existing is None: 

476 merged[change.identity] = change 

477 order.append(change.identity) 

478 elif existing.fingerprint_data != change.fingerprint_data: 

479 raise PlanInvalid( 

480 f"Planned Change '{change.identity}' appears with conflicting content, so the plan cannot share it." 

481 ) 

482 

483 already_applied = frozenset(reconciled) 

484 for identity, change in merged.items(): 

485 for dependency in change.dependencies: 

486 if dependency not in merged and dependency not in already_applied: 

487 raise PlanInvalid( 

488 f"Planned Change '{identity}' depends on '{dependency}', " 

489 "which the selection neither contains nor has reconciled." 

490 ) 

491 

492 return _topological_order(merged, order) 

493 

494 

495def _topological_order(merged: dict, order: list[str]) -> tuple[PlannedChange, ...]: 

496 """Return the changes in dependency order, breaking ties by first appearance.""" 

497 position = {identity: index for index, identity in enumerate(order)} 

498 state: dict[str, int] = {} 

499 result: list[PlannedChange] = [] 

500 

501 def visit(identity: str, path: tuple[str, ...]): 

502 """Depth-first visit that reports the identities forming a cycle.""" 

503 if state.get(identity) == 2: 

504 return 

505 if state.get(identity) == 1: 

506 cycle = " -> ".join((*path[path.index(identity) :], identity)) 

507 raise PlanInvalid(f"The Planned Change dependencies form a cycle: {cycle}.") 

508 state[identity] = 1 

509 for dependency in sorted( 

510 (dep for dep in merged[identity].dependencies if dep in merged), key=lambda dep: position[dep] 

511 ): 

512 visit(dependency, (*path, identity)) 

513 state[identity] = 2 

514 result.append(merged[identity]) 

515 

516 for identity in order: 

517 visit(identity, ()) 

518 return tuple(result) 

519 

520 

521__all__ = ( 

522 "SCHEMA_VERSION", 

523 "Diagnostic", 

524 "Disposition", 

525 "FrozenDict", 

526 "ImportPlan", 

527 "PlanError", 

528 "PlanInvalid", 

529 "PlanSchemaMismatch", 

530 "PlannedChange", 

531 "Severity", 

532 "SynchronizationUnit", 

533 "canonical_json", 

534 "executable_units", 

535 "fingerprint_of", 

536 "merge_changes", 

537)