Coverage for netbox_data_import/trace_workbook.py: 98.40%

501 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-30 09:47 +0000

1# SPDX-License-Identifier: Apache-2.0 

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

3"""Interpret trace workbooks without accessing the ORM.""" 

4 

5from __future__ import annotations 

6 

7from collections import defaultdict 

8from dataclasses import asdict, dataclass, field, replace 

9from hashlib import sha256 

10from io import BytesIO 

11import itertools 

12import json 

13from typing import Iterable, Mapping, Sequence 

14 

15import openpyxl 

16 

17from .adapters import SourceDiagnostic, SourceUnreadable 

18from .field_keys import ( 

19 FRONT_PORT_CLASSES, 

20 INTERFACE_PORT_CLASSES, 

21 PORT_CLASSES, 

22 POWER_CLASSES, 

23 REAR_PORT_CLASSES, 

24 same_device_and_cards, 

25) 

26from .source_trace import ( 

27 EndpointSummary, 

28 IdentityKey, 

29 PassThroughClaim, 

30 PortClassLabel, 

31 SegmentEvidence, 

32 SourceTrace, 

33 TerminationReference, 

34 TraceProvenance, 

35) 

36from .trace_schema import TRACE_EXPORT_TIMESTAMP_MAX_LENGTH 

37from .identity import identity_text 

38from .values import source_text 

39 

40TRACE_PATH_SHEET = "Trace From To" 

41TRACE_LIST_SHEET = "Trace List" 

42 

43_PATH_HEADER = ( 

44 "Port", 

45 "PortClass", 

46 "Cards", 

47 "Device", 

48 "UPos", 

49 "Rack", 

50 "Location", 

51 "CableClass", 

52 "Port", 

53 "PortClass", 

54 "Cards", 

55 "Device", 

56 "UPos", 

57 "Rack", 

58 "Location", 

59) 

60_LIST_HEADER = ("Location", "Rack", "UPos", "Device", "Cards", "Port", "PortClass", "Cable") 

61_BLOCK_LINE_MARKERS = ("From", "To") 

62 

63 

64@dataclass(frozen=True) 

65class TraceWorkbookConfig: 

66 """The detached trace-workbook configuration: each mapped PortClass label and its fixed PortClass.""" 

67 

68 port_class_map: Mapping[str, str] = field(default_factory=dict) 

69 

70 

71@dataclass(frozen=True) 

72class _RawRow: 

73 """One spreadsheet row, holding the cells the sheet width defines.""" 

74 

75 row_number: int 

76 values: tuple[object, ...] 

77 

78 

79@dataclass(frozen=True) 

80class _Block: 

81 """One From and To block with its header row, data rows, and workbook provenance.""" 

82 

83 sheet: str 

84 ordinal: int 

85 row_start: int 

86 row_end: int 

87 from_text: str 

88 to_text: str 

89 has_to_line: bool 

90 header: tuple[str, ...] 

91 rows: tuple[_RawRow, ...] 

92 export_timestamp: str 

93 workbook_fingerprint: str 

94 

95 @property 

96 def pair_key(self) -> tuple[str, str]: 

97 """Return the From and To text that pairs this block with the other sheet.""" 

98 return source_text(self.from_text), source_text(self.to_text) 

99 

100 

101@dataclass(frozen=True) 

102class _ParsedSegment: 

103 """One Segment Evidence entry with the row that stated it.""" 

104 

105 evidence: SegmentEvidence 

106 row_number: int 

107 

108 

109@dataclass(frozen=True) 

110class _ParsedVisit: 

111 """One Trace List visit with the row that stated it.""" 

112 

113 termination: TerminationReference 

114 row_number: int 

115 

116 

117_SegmentClaim = tuple[IdentityKey, IdentityKey] 

118 

119 

120def parse_endpoint_line(line: str) -> TerminationReference: 

121 """Parse ``Device > [Cards > ]Port (PortClass)`` into a Termination Reference.""" 

122 parts = source_text(line).split(" > ") 

123 if len(parts) not in (2, 3): 

124 raise ValueError("Endpoint line must contain a device, an optional cards label, and a port.") 

125 device = source_text(parts[0]) 

126 cards = source_text(parts[1]) if len(parts) == 3 else "" 

127 port_and_class = source_text(parts[-1]) 

128 marker = port_and_class.rfind(" (") 

129 if marker <= 0 or not port_and_class.endswith(")"): 

130 raise ValueError("Endpoint line must end with a PortClass in parentheses.") 

131 port = source_text(port_and_class[:marker]) 

132 port_class = source_text(port_and_class[marker + 2 : -1]) 

133 if not device or not port or not port_class: 

134 raise ValueError("Endpoint line contains an empty device, port, or PortClass.") 

135 return TerminationReference(device=device, cards=cards, port=port, port_class=port_class) 

136 

137 

138def canonical_trace_identity( 

139 first: TerminationReference, 

140 second: TerminationReference, 

141) -> str: 

142 """Return the sorted endpoint pair as compact canonical JSON.""" 

143 endpoints = sorted((first.identity_key, second.identity_key)) 

144 return json.dumps(endpoints, ensure_ascii=False, separators=(",", ":")) 

145 

146 

147def canonical_orientation( 

148 from_termination: TerminationReference, 

149 to_termination: TerminationReference, 

150 segments: Sequence[SegmentEvidence], 

151) -> tuple[SegmentEvidence, ...]: 

152 """Orient Segment Evidence from the endpoint whose identity key sorts first.""" 

153 if from_termination.identity_key <= to_termination.identity_key: 

154 return tuple(segments) 

155 return tuple( 

156 SegmentEvidence(left=segment.right, cable_class=segment.cable_class, right=segment.left) 

157 for segment in reversed(segments) 

158 ) 

159 

160 

161def content_fingerprint( 

162 from_termination: TerminationReference, 

163 to_termination: TerminationReference, 

164 segments: Sequence[SegmentEvidence], 

165) -> str: 

166 """Hash the canonical endpoints, Segment Evidence, and Pass-Through Claims.""" 

167 oriented = canonical_orientation(from_termination, to_termination, segments) 

168 claims = _pass_through_claims(oriented) 

169 endpoints = sorted((from_termination, to_termination), key=lambda termination: termination.identity_key) 

170 payload = { 

171 # An Endpoint Summary fallback states no segment, so the endpoints carry its whole content. 

172 "endpoints": [[termination.identity_key, termination.port_class] for termination in endpoints], 

173 "segments": [ 

174 [ 

175 segment.left.identity_key, 

176 segment.left.port_class, 

177 segment.cable_class, 

178 segment.right.identity_key, 

179 segment.right.port_class, 

180 ] 

181 for segment in oriented 

182 ], 

183 "pass_through_claims": [ 

184 [ 

185 identity_text(claim.device), 

186 identity_text(claim.cards), 

187 identity_text(claim.entry_port), 

188 identity_text(claim.exit_port), 

189 ] 

190 for claim in claims 

191 ], 

192 } 

193 serialized = json.dumps(payload, ensure_ascii=False, separators=(",", ":"), sort_keys=True) 

194 return sha256(serialized.encode()).hexdigest() 

195 

196 

197def _cell_text(value: object) -> str: 

198 """Return the raw text of a cell value, empty for None.""" 

199 return "" if value is None else str(value) 

200 

201 

202def _normalized_header(values: Iterable[object]) -> tuple[str, ...]: 

203 """Return one header row as trimmed text.""" 

204 return tuple(source_text(value) for value in values) 

205 

206 

207def _row_has_data(values: Iterable[object]) -> bool: 

208 """Return whether any cell in the row carries text.""" 

209 return any(source_text(value) for value in values) 

210 

211 

212def _row_values(sheet, row_number: int, width: int) -> tuple[object, ...]: 

213 """Return the cells of one row across the sheet's fixed width.""" 

214 return tuple(sheet.cell(row=row_number, column=column).value for column in range(1, width + 1)) 

215 

216 

217def _last_data_row(values_by_row: Mapping[int, tuple[object, ...]], start: int, stop: int) -> int: 

218 """Return the last row in the range that carries data.""" 

219 for row_number in range(stop, start - 1, -1): 

220 if _row_has_data(values_by_row[row_number]): 

221 return row_number 

222 return start 

223 

224 

225def _extract_blocks(sheet, workbook_fingerprint: str) -> tuple[_Block, ...]: 

226 """Return every block on one sheet, including visits the next block's lines overwrite.""" 

227 width = len(_PATH_HEADER) if sheet.title == TRACE_PATH_SHEET else len(_LIST_HEADER) 

228 # openpyxl builds a cell object on first access, so the sheet is read once and every step reuses it. 

229 values_by_row = {row: _row_values(sheet, row, width) for row in range(1, sheet.max_row + 1)} 

230 starts = [row for row, values in values_by_row.items() if source_text(values[0]) == "From"] 

231 first_row = values_by_row.get(1, ()) 

232 export_timestamp = source_text(first_row[1]) if first_row and source_text(first_row[0]) == "Executed" else "" 

233 blocks = [] 

234 for index, start in enumerate(starts): 

235 boundary = starts[index + 1] if index + 1 < len(starts) else sheet.max_row + 1 

236 to_row = start + 1 

237 has_to_line = to_row < boundary and source_text(values_by_row[to_row][0]) == "To" 

238 header_row = start + 2 

239 header = _normalized_header(values_by_row[header_row]) if header_row < boundary else () 

240 rows = [ 

241 _RawRow(row_number, values_by_row[row_number]) 

242 for row_number in range(start + 3, boundary) 

243 if _row_has_data(values_by_row[row_number]) 

244 ] 

245 row_end = _last_data_row(values_by_row, start, boundary - 1) 

246 # A block that fills its separator row keeps its last rows under the next block's lines. 

247 for carry_row in (boundary, boundary + 1): 

248 if carry_row > sheet.max_row: 

249 continue 

250 values = values_by_row[carry_row] 

251 if source_text(values[0]) not in _BLOCK_LINE_MARKERS: 

252 continue 

253 if _row_has_data(values[2:]): 

254 rows.append(_RawRow(carry_row, ("", "", *values[2:]))) 

255 row_end = carry_row 

256 blocks.append( 

257 _Block( 

258 sheet=sheet.title, 

259 ordinal=index + 1, 

260 row_start=start, 

261 row_end=row_end, 

262 from_text=_cell_text(values_by_row[start][1]), 

263 to_text=_cell_text(values_by_row[to_row][1]) if has_to_line else "", 

264 has_to_line=has_to_line, 

265 header=header, 

266 rows=tuple(rows), 

267 export_timestamp=export_timestamp, 

268 workbook_fingerprint=workbook_fingerprint, 

269 ) 

270 ) 

271 return tuple(blocks) 

272 

273 

274def _location(block: _Block) -> str: 

275 """Return the readable source position of one block.""" 

276 return f"{block.sheet} block {block.ordinal} (rows {block.row_start}-{block.row_end})" 

277 

278 

279def _error(block: _Block, code: str, detail: str, row_number: int | None = None) -> SourceDiagnostic: 

280 """Return one source diagnostic that names the block it came from.""" 

281 return SourceDiagnostic( 

282 code=code, 

283 message=f"{_location(block)}: {detail}", 

284 row_number=block.row_start if row_number is None else row_number, 

285 ) 

286 

287 

288def _metadata_errors(blocks: Iterable[_Block | None]) -> list[SourceDiagnostic]: 

289 """Return diagnostics for raw workbook metadata that cannot fit its stored representation.""" 

290 errors = [] 

291 for block in blocks: 

292 if block is not None and len(block.export_timestamp) > TRACE_EXPORT_TIMESTAMP_MAX_LENGTH: 

293 errors.append( 

294 _error( 

295 block, 

296 "trace.metadata_too_long", 

297 f"The export timestamp exceeds {TRACE_EXPORT_TIMESTAMP_MAX_LENGTH} characters.", 

298 row_number=1, 

299 ) 

300 ) 

301 return errors 

302 

303 

304def _endpoint_summary(block: _Block) -> tuple[EndpointSummary | None, list[SourceDiagnostic]]: 

305 """Return the Endpoint Summary of one block, or the reason it has none.""" 

306 errors = [] 

307 if not block.has_to_line: 

308 errors.append(_error(block, "trace.incomplete_block", "The block has no To line.")) 

309 return None, errors 

310 try: 

311 from_termination = parse_endpoint_line(block.from_text) 

312 to_termination = parse_endpoint_line(block.to_text) 

313 except ValueError as exc: 

314 errors.append(_error(block, "trace.incomplete_block", str(exc))) 

315 return None, errors 

316 return ( 

317 EndpointSummary( 

318 from_termination=from_termination, 

319 to_termination=to_termination, 

320 from_text=block.from_text, 

321 to_text=block.to_text, 

322 ), 

323 errors, 

324 ) 

325 

326 

327def _termination_from_path(values: tuple[object, ...], offset: int) -> TerminationReference: 

328 """Return the Termination Reference one side of a Segment Evidence row states.""" 

329 termination = TerminationReference( 

330 port=source_text(values[offset]), 

331 port_class=source_text(values[offset + 1]), 

332 cards=source_text(values[offset + 2]), 

333 device=source_text(values[offset + 3]), 

334 u_position=source_text(values[offset + 4]), 

335 rack=source_text(values[offset + 5]), 

336 location=source_text(values[offset + 6]), 

337 ) 

338 if not termination.device or not termination.port or not termination.port_class: 

339 raise ValueError("A Segment Evidence row has an empty device, port, or PortClass.") 

340 return termination 

341 

342 

343def _parse_segments(block: _Block) -> tuple[tuple[_ParsedSegment, ...], list[SourceDiagnostic]]: 

344 """Return the Segment Evidence a path block states, and the rows it could not read.""" 

345 if block.header != _PATH_HEADER: 

346 return (), [_error(block, "trace.incomplete_block", "The block has no recognized Segment Evidence header.")] 

347 parsed = [] 

348 errors = [] 

349 for row in block.rows: 

350 try: 

351 left = _termination_from_path(row.values, 0) 

352 cable_class = source_text(row.values[7]) 

353 right = _termination_from_path(row.values, 8) 

354 except (IndexError, ValueError) as exc: 

355 errors.append(_error(block, "trace.incomplete_block", str(exc), row.row_number)) 

356 continue 

357 if not cable_class: 

358 errors.append( 

359 _error( 

360 block, "trace.incomplete_block", "A Segment Evidence row has an empty CableClass.", row.row_number 

361 ) 

362 ) 

363 continue 

364 parsed.append(_ParsedSegment(SegmentEvidence(left, cable_class, right), row.row_number)) 

365 return tuple(parsed), errors 

366 

367 

368def _parse_visits(block: _Block) -> tuple[tuple[_ParsedVisit, ...], list[SourceDiagnostic]]: 

369 """Return the visits a Trace List block states, and the rows it could not read.""" 

370 if block.header != _LIST_HEADER: 

371 return (), [_error(block, "trace.incomplete_block", "The block has no recognized Trace List header.")] 

372 parsed = [] 

373 errors = [] 

374 for row in block.rows: 

375 try: 

376 termination = TerminationReference( 

377 location=source_text(row.values[0]), 

378 rack=source_text(row.values[1]), 

379 u_position=source_text(row.values[2]), 

380 device=source_text(row.values[3]), 

381 cards=source_text(row.values[4]), 

382 port=source_text(row.values[5]), 

383 port_class=source_text(row.values[6]), 

384 ) 

385 except IndexError: 

386 termination = TerminationReference("", "", "", "") 

387 if not termination.device or not termination.port or not termination.port_class: 

388 errors.append( 

389 _error( 

390 block, 

391 "trace.corroboration_mismatch", 

392 "A Trace List visit has an empty device, port, or PortClass.", 

393 row.row_number, 

394 ) 

395 ) 

396 continue 

397 parsed.append(_ParsedVisit(termination, row.row_number)) 

398 return tuple(parsed), errors 

399 

400 

401def _unknown_port_class_error( 

402 block: _Block, 

403 terminations: Iterable[tuple[TerminationReference, int]], 

404) -> SourceDiagnostic | None: 

405 """Return the first PortClass outside the fixed vocabulary as a diagnostic.""" 

406 for termination, row_number in terminations: 

407 if termination.port_class not in PORT_CLASSES: 

408 return _error( 

409 block, 

410 "trace.unknown_port_class", 

411 f"PortClass '{termination.port_class}' is outside the fixed vocabulary.", 

412 row_number, 

413 ) 

414 return None 

415 

416 

417def _port_class_labels( 

418 terminations: Iterable[TerminationReference], port_class_map: Mapping[str, str] 

419) -> tuple[PortClassLabel, ...]: 

420 """Return each stated label outside the fixed vocabulary, with the PortClass the map gives it.""" 

421 labels = {termination.port_class for termination in terminations if termination.port_class not in PORT_CLASSES} 

422 return tuple(PortClassLabel(label, port_class_map.get(label, "")) for label in sorted(labels)) 

423 

424 

425def _mapped(termination: TerminationReference, port_class_map: Mapping[str, str]) -> TerminationReference: 

426 """Return the termination with the fixed PortClass its source label maps to.""" 

427 port_class = port_class_map.get(termination.port_class) 

428 return termination if port_class is None else replace(termination, port_class=port_class) 

429 

430 

431def _mapped_segments( 

432 segments: Iterable[_ParsedSegment], port_class_map: Mapping[str, str] 

433) -> tuple[_ParsedSegment, ...]: 

434 """Return Segment Evidence rows whose two ends carry their mapped PortClass.""" 

435 return tuple( 

436 _ParsedSegment( 

437 SegmentEvidence( 

438 _mapped(parsed.evidence.left, port_class_map), 

439 parsed.evidence.cable_class, 

440 _mapped(parsed.evidence.right, port_class_map), 

441 ), 

442 parsed.row_number, 

443 ) 

444 for parsed in segments 

445 ) 

446 

447 

448def _mapped_visits(visits: Iterable[_ParsedVisit], port_class_map: Mapping[str, str]) -> tuple[_ParsedVisit, ...]: 

449 """Return Trace List visits that carry their mapped PortClass.""" 

450 return tuple(_ParsedVisit(_mapped(visit.termination, port_class_map), visit.row_number) for visit in visits) 

451 

452 

453def _mapped_summary(summary: EndpointSummary, port_class_map: Mapping[str, str]) -> EndpointSummary: 

454 """Return the Endpoint Summary whose two terminations carry their mapped PortClass.""" 

455 return replace( 

456 summary, 

457 from_termination=_mapped(summary.from_termination, port_class_map), 

458 to_termination=_mapped(summary.to_termination, port_class_map), 

459 ) 

460 

461 

462def _termination_text(termination: TerminationReference) -> str: 

463 """Return one termination in the endpoint-line form the source export writes.""" 

464 parts = [termination.device, termination.cards, f"{termination.port} ({termination.port_class})"] 

465 return " > ".join(part for part in parts if part) 

466 

467 

468def _linearity_error( 

469 block: _Block, summary: EndpointSummary, segments: Sequence[_ParsedSegment] 

470) -> SourceDiagnostic | None: 

471 """Return the first structural break in a path, or None when the path is linear.""" 

472 # A path states two terminations whether or not it states the segments between them. 

473 if summary.from_termination.identity_key == summary.to_termination.identity_key: 

474 return _error( 

475 block, 

476 "trace.non_linear_path", 

477 "The From and To lines name one termination, so the path does not join two terminations.", 

478 segments[-1].row_number if segments else block.row_start, 

479 ) 

480 if not segments: 

481 return None 

482 if segments[0].evidence.left.identity_key != summary.from_termination.identity_key: 

483 return _error( 

484 block, 

485 "trace.non_linear_path", 

486 "The first Segment Evidence termination contradicts the From line.", 

487 segments[0].row_number, 

488 ) 

489 if segments[-1].evidence.right.identity_key != summary.to_termination.identity_key: 

490 return _error( 

491 block, 

492 "trace.non_linear_path", 

493 "The last Segment Evidence termination contradicts the To line.", 

494 segments[-1].row_number, 

495 ) 

496 seen_segments = set() 

497 left_destinations: dict[IdentityKey, set[IdentityKey]] = defaultdict(set) 

498 right_sources: dict[IdentityKey, set[IdentityKey]] = defaultdict(set) 

499 for parsed in segments: 

500 left_key = parsed.evidence.left.identity_key 

501 right_key = parsed.evidence.right.identity_key 

502 # One cable has no direction, so a reversed row restates the segment rather than adding one. 

503 pair = tuple(sorted((left_key, right_key))) 

504 if pair in seen_segments: 

505 return _error( 

506 block, 

507 "trace.non_linear_path", 

508 "The path repeats a Segment Evidence row.", 

509 parsed.row_number, 

510 ) 

511 seen_segments.add(pair) 

512 left_destinations[left_key].add(right_key) 

513 right_sources[right_key].add(left_key) 

514 if len(left_destinations[left_key]) > 1 or len(right_sources[right_key]) > 1: 

515 return _error( 

516 block, 

517 "trace.non_linear_path", 

518 "The Segment Evidence rows branch.", 

519 parsed.row_number, 

520 ) 

521 for previous, following in itertools.pairwise(segments): 

522 if not same_device_and_cards(previous.evidence.right, following.evidence.left): 

523 return _error( 

524 block, 

525 "trace.non_linear_path", 

526 "Consecutive Segment Evidence rows do not share a device and cards label: " 

527 f"row {previous.row_number} ends at {_termination_text(previous.evidence.right)}, " 

528 f"but row {following.row_number} starts at {_termination_text(following.evidence.left)}.", 

529 following.row_number, 

530 ) 

531 return None 

532 

533 

534def _power_evidence( 

535 block: _Block, summary: EndpointSummary, segments: Sequence[_ParsedSegment] 

536) -> tuple[EndpointSummary, tuple[_ParsedSegment, ...], SourceDiagnostic | None]: 

537 """Return the one cable a power block names, from its From termination to that row's peer. 

538 

539 A power block states a PDU fan-out, not a path. Only the row that touches From names a cable of 

540 this trace, and its peer replaces the To line, which names an arbitrary leaf of the fan-out. 

541 """ 

542 from_key = summary.from_termination.identity_key 

543 touching = [] 

544 for parsed in segments: 

545 evidence = parsed.evidence 

546 if evidence.left.identity_key != from_key and evidence.right.identity_key == from_key: 

547 evidence = SegmentEvidence(left=evidence.right, cable_class=evidence.cable_class, right=evidence.left) 

548 if evidence.left.identity_key == from_key: 

549 touching.append(_ParsedSegment(evidence, parsed.row_number)) 

550 if len(touching) == 1: 

551 (selected,) = touching 

552 return replace(summary, to_termination=selected.evidence.right), (selected,), None 

553 if touching: 

554 detail = ( 

555 f"{len(touching)} Segment Evidence rows touch the From termination, " 

556 "so the power block names no single cable." 

557 ) 

558 row_number = touching[1].row_number 

559 else: 

560 detail = "No Segment Evidence row touches the From termination, so the power block names no cable." 

561 row_number = block.row_start 

562 return summary, (), _error(block, "trace.non_linear_path", detail, row_number) 

563 

564 

565def _power_corroboration_error( 

566 block: _Block, segment: SegmentEvidence, visits: Sequence[_ParsedVisit] 

567) -> SourceDiagnostic | None: 

568 """Return a contradiction when a non-empty Trace List does not visit both ends of the power cable.""" 

569 visited = {visit.termination.identity_key for visit in visits} 

570 if not visits or {segment.left.identity_key, segment.right.identity_key} <= visited: 

571 return None 

572 return _error( 

573 block, 

574 "trace.corroboration_mismatch", 

575 "The Trace List does not visit both terminations of the cable at the From termination.", 

576 visits[0].row_number, 

577 ) 

578 

579 

580def _endpoint_visits( 

581 summary: EndpointSummary, visits: Iterable[TerminationReference] 

582) -> tuple[TerminationReference, ...]: 

583 """Return the visits of the two endpoints, which drops the rest of a PDU fan-out.""" 

584 endpoints = {summary.from_termination.identity_key, summary.to_termination.identity_key} 

585 return tuple(visit for visit in visits if visit.identity_key in endpoints) 

586 

587 

588def _pass_through_claims(segments: Sequence[SegmentEvidence]) -> tuple[PassThroughClaim, ...]: 

589 """Return the continuation each pair of consecutive segments claims.""" 

590 claims = [] 

591 for previous, following in itertools.pairwise(segments): 

592 if not same_device_and_cards(previous.right, following.left): 

593 continue 

594 claims.append( 

595 PassThroughClaim( 

596 device=previous.right.device, 

597 cards=previous.right.cards, 

598 entry_port=previous.right.port, 

599 exit_port=following.left.port, 

600 ) 

601 ) 

602 return tuple(claims) 

603 

604 

605def _pass_through_error(block: _Block, segments: Sequence[_ParsedSegment]) -> SourceDiagnostic | None: 

606 """Return a diagnostic for the first Pass-Through Claim at an interface PortClass.""" 

607 for previous, following in itertools.pairwise(segments): 

608 if not same_device_and_cards(previous.evidence.right, following.evidence.left): 

609 continue 

610 if ( 

611 previous.evidence.right.port_class in INTERFACE_PORT_CLASSES 

612 or following.evidence.left.port_class in INTERFACE_PORT_CLASSES 

613 ): 

614 entry = previous.evidence.right 

615 exit_reference = following.evidence.left 

616 return _error( 

617 block, 

618 "trace.pass_through_at_interface", 

619 ( 

620 f"The path continues through {entry.device} from {entry.port} ({entry.port_class}) " 

621 f"to {exit_reference.port} ({exit_reference.port_class}). " 

622 "An interface PortClass can terminate a trace, " 

623 "but it cannot join two cable segments. Correct the source path or PortClass." 

624 ), 

625 following.row_number, 

626 ) 

627 return None 

628 

629 

630def _group_consecutive_visits(visits: Sequence[_ParsedVisit]) -> tuple[tuple[_ParsedVisit, ...], ...]: 

631 """Group Trace List visits into one entry per visited device.""" 

632 groups: list[list[_ParsedVisit]] = [] 

633 for visit in visits: 

634 device = identity_text(visit.termination.device) 

635 if not groups or identity_text(groups[-1][0].termination.device) != device: 

636 groups.append([]) 

637 groups[-1].append(visit) 

638 return tuple(tuple(group) for group in groups) 

639 

640 

641def _expected_visits(segments: Sequence[SegmentEvidence]) -> tuple[tuple[TerminationReference, ...], ...]: 

642 """Return the terminations each device visit of the path presents.""" 

643 if not segments: 

644 return () 

645 visits: list[tuple[TerminationReference, ...]] = [(segments[0].left,)] 

646 visits.extend((previous.right, following.left) for previous, following in itertools.pairwise(segments)) 

647 visits.append((segments[-1].right,)) 

648 return tuple(visits) 

649 

650 

651def _corroboration_error( 

652 block: _Block, 

653 segments: Sequence[SegmentEvidence], 

654 visits: Sequence[_ParsedVisit], 

655) -> SourceDiagnostic | None: 

656 """Return the first contradiction between the Trace List and the path rows.""" 

657 if not visits or not segments: 

658 return None 

659 actual = _group_consecutive_visits(visits) 

660 expected = _expected_visits(segments) 

661 if segments[-1].right.port_class in REAR_PORT_CLASSES and len(actual) == len(expected) - 1: 

662 expected = expected[:-1] 

663 if len(actual) != len(expected): 

664 return _error( 

665 block, 

666 "trace.corroboration_mismatch", 

667 "The Trace List device sequence does not match the Segment Evidence visits.", 

668 actual[0][0].row_number, 

669 ) 

670 for actual_group, expected_group in zip(actual, expected, strict=True): 

671 allowed = {termination.identity_key for termination in expected_group} 

672 if any(visit.termination.identity_key not in allowed for visit in actual_group): 

673 return _error( 

674 block, 

675 "trace.corroboration_mismatch", 

676 "A Trace List termination contradicts its Segment Evidence visit.", 

677 actual_group[0].row_number, 

678 ) 

679 return None 

680 

681 

682def _enrich_termination( 

683 termination: TerminationReference, 

684 corroboration: Iterable[TerminationReference], 

685) -> TerminationReference: 

686 """Return the termination with the corroboration values the source states for it.""" 

687 enriched = termination 

688 # A path row is read before a Trace List visit, so an empty cell there must not hide a later one. 

689 for candidate in corroboration: 

690 if candidate.identity_key == termination.identity_key: 

691 enriched = replace( 

692 enriched, 

693 u_position=enriched.u_position or candidate.u_position, 

694 rack=enriched.rack or candidate.rack, 

695 location=enriched.location or candidate.location, 

696 ) 

697 return enriched 

698 

699 

700def _enrich_summary( 

701 summary: EndpointSummary, 

702 corroboration: Iterable[TerminationReference], 

703) -> EndpointSummary: 

704 """Return the Endpoint Summary with corroboration on both endpoints.""" 

705 corroboration = tuple(corroboration) 

706 return replace( 

707 summary, 

708 from_termination=_enrich_termination(summary.from_termination, corroboration), 

709 to_termination=_enrich_termination(summary.to_termination, corroboration), 

710 ) 

711 

712 

713def _provenance(block: _Block, summary: EndpointSummary) -> TraceProvenance: 

714 """Return where one block stated the trace, and in which direction.""" 

715 direction = ( 

716 "canonical" if summary.from_termination.identity_key <= summary.to_termination.identity_key else "reversed" 

717 ) 

718 return TraceProvenance( 

719 workbook_fingerprint=block.workbook_fingerprint, 

720 sheet=block.sheet, 

721 block_ordinal=block.ordinal, 

722 row_start=block.row_start, 

723 row_end=block.row_end, 

724 export_timestamp=block.export_timestamp, 

725 from_text=block.from_text, 

726 to_text=block.to_text, 

727 direction=direction, 

728 ) 

729 

730 

731def _deduplicate_errors(errors: Iterable[SourceDiagnostic]) -> tuple[SourceDiagnostic, ...]: 

732 """Drop a repeated diagnostic, so every distinct finding keeps its own row.""" 

733 return tuple(dict.fromkeys(errors)) 

734 

735 

736def _path_trace( 

737 path_block: _Block, list_block: _Block | None, port_class_map: Mapping[str, str] 

738) -> tuple[SourceTrace | None, tuple[SourceDiagnostic, ...]]: 

739 """Return the Source Trace one path block states, with any block-level diagnostic.""" 

740 summary, errors = _endpoint_summary(path_block) 

741 errors.extend(_metadata_errors((path_block, list_block))) 

742 if summary is None: 

743 return None, tuple(errors) 

744 parsed_segments, segment_errors = _parse_segments(path_block) 

745 errors.extend(segment_errors) 

746 parsed_visits: tuple[_ParsedVisit, ...] = () 

747 if list_block is not None: 

748 parsed_visits, visit_errors = _parse_visits(list_block) 

749 errors.extend(visit_errors) 

750 port_class_labels = _port_class_labels( 

751 ( 

752 summary.from_termination, 

753 summary.to_termination, 

754 *(end for parsed in parsed_segments for end in (parsed.evidence.left, parsed.evidence.right)), 

755 *(visit.termination for visit in parsed_visits), 

756 ), 

757 port_class_map, 

758 ) 

759 # Every identity key below reads the mapped PortClass, so the map applies before any of them. 

760 summary = _mapped_summary(summary, port_class_map) 

761 parsed_segments = _mapped_segments(parsed_segments, port_class_map) 

762 parsed_visits = _mapped_visits(parsed_visits, port_class_map) 

763 endpoint_terms = ( 

764 (summary.from_termination, path_block.row_start), 

765 (summary.to_termination, path_block.row_start + 1), 

766 ) 

767 path_terms = tuple( 

768 (termination, parsed.row_number) 

769 for parsed in parsed_segments 

770 for termination in (parsed.evidence.left, parsed.evidence.right) 

771 ) 

772 visit_terms = tuple((visit.termination, visit.row_number) for visit in parsed_visits) 

773 unknown = _unknown_port_class_error(path_block, (*endpoint_terms, *path_terms)) 

774 if unknown is None and list_block is not None: 

775 unknown = _unknown_port_class_error(list_block, visit_terms) 

776 if unknown is not None: 

777 errors.append(unknown) 

778 broken = any(error.code == "trace.incomplete_block" for error in errors) 

779 power = summary.from_termination.port_class in POWER_CLASSES 

780 if power: 

781 summary, parsed_segments, power_error = ( 

782 (summary, (), None) if broken else _power_evidence(path_block, summary, parsed_segments) 

783 ) 

784 if power_error is not None: 

785 errors.append(power_error) 

786 broken = broken or power_error is not None 

787 linearity = None if broken else _linearity_error(path_block, summary, parsed_segments) 

788 if linearity is not None: 

789 errors.append(linearity) 

790 if not broken and linearity is None: 

791 pass_through = _pass_through_error(path_block, parsed_segments) 

792 if pass_through is not None: 

793 errors.append(pass_through) 

794 if list_block is not None: 

795 stated = tuple(parsed.evidence for parsed in parsed_segments) 

796 mismatch = ( 

797 _power_corroboration_error(list_block, stated[0], parsed_visits) 

798 if power 

799 else _corroboration_error(list_block, stated, parsed_visits) 

800 ) 

801 if mismatch is not None: 

802 errors.append(mismatch) 

803 stated_segments = tuple(parsed.evidence for parsed in parsed_segments) 

804 visits = tuple(visit.termination for visit in parsed_visits) 

805 if power: 

806 visits = _endpoint_visits(summary, visits) 

807 path_corroboration = tuple( 

808 termination for segment in stated_segments for termination in (segment.left, segment.right) 

809 ) 

810 summary = _enrich_summary(summary, (*path_corroboration, *visits)) 

811 segments = canonical_orientation(summary.from_termination, summary.to_termination, stated_segments) 

812 provenance = [_provenance(path_block, summary)] 

813 if list_block is not None: 

814 provenance.append(_provenance(list_block, summary)) 

815 return ( 

816 SourceTrace( 

817 endpoint_summary=summary, 

818 segments=segments, 

819 pass_through_claims=_pass_through_claims(segments), 

820 corroboration=visits, 

821 identity=canonical_trace_identity(summary.from_termination, summary.to_termination), 

822 content_fingerprint=content_fingerprint( 

823 summary.from_termination, 

824 summary.to_termination, 

825 stated_segments, 

826 ), 

827 provenance=tuple(provenance), 

828 errors=_deduplicate_errors(errors), 

829 port_class_labels=port_class_labels, 

830 ), 

831 (), 

832 ) 

833 

834 

835def _fallback_trace( 

836 block: _Block, port_class_map: Mapping[str, str] 

837) -> tuple[SourceTrace | None, tuple[SourceDiagnostic, ...]]: 

838 """Return the Endpoint Summary fallback an unpaired Trace List block states.""" 

839 summary, errors = _endpoint_summary(block) 

840 errors.extend(_metadata_errors((block,))) 

841 visits, visit_errors = _parse_visits(block) 

842 errors.extend(visit_errors) 

843 if summary is None or not visits: 

844 return None, tuple(errors) 

845 port_class_labels = _port_class_labels( 

846 (summary.from_termination, summary.to_termination, *(visit.termination for visit in visits)), 

847 port_class_map, 

848 ) 

849 summary = _mapped_summary(summary, port_class_map) 

850 visits = _mapped_visits(visits, port_class_map) 

851 terms = ( 

852 (summary.from_termination, block.row_start), 

853 (summary.to_termination, block.row_start + 1), 

854 *((visit.termination, visit.row_number) for visit in visits), 

855 ) 

856 unknown = _unknown_port_class_error(block, terms) 

857 if unknown is not None: 

858 errors.append(unknown) 

859 loop = _linearity_error(block, summary, ()) 

860 if loop is not None: 

861 errors.append(loop) 

862 corroboration = tuple(visit.termination for visit in visits) 

863 if summary.from_termination.port_class in POWER_CLASSES: 

864 errors.append( 

865 _error( 

866 block, 

867 "trace.non_linear_path", 

868 "A Trace List block states no Segment Evidence, " 

869 "so no row names the cable at the power From termination.", 

870 ) 

871 ) 

872 corroboration = _endpoint_visits(summary, corroboration) 

873 summary = _enrich_summary(summary, corroboration) 

874 return ( 

875 SourceTrace( 

876 endpoint_summary=summary, 

877 segments=(), 

878 pass_through_claims=(), 

879 corroboration=corroboration, 

880 identity=canonical_trace_identity(summary.from_termination, summary.to_termination), 

881 content_fingerprint=content_fingerprint(summary.from_termination, summary.to_termination, ()), 

882 provenance=(_provenance(block, summary),), 

883 errors=_deduplicate_errors(errors), 

884 port_class_labels=port_class_labels, 

885 ), 

886 (), 

887 ) 

888 

889 

890def _trace_selection_key(trace: SourceTrace) -> tuple[bool, bool, str]: 

891 """Rank Segment Evidence first, then canonical direction and serialized occurrence content.""" 

892 summary = trace.endpoint_summary 

893 # Provenance and errors carry the block position, and collapse merges the labels of every occurrence. 

894 content = { 

895 key: value for key, value in asdict(trace).items() if key not in ("provenance", "errors", "port_class_labels") 

896 } 

897 return ( 

898 not bool(trace.segments), 

899 summary.from_termination.identity_key > summary.to_termination.identity_key, 

900 json.dumps(content, ensure_ascii=False, separators=(",", ":"), sort_keys=True), 

901 ) 

902 

903 

904def _collapse_duplicates(traces: Sequence[SourceTrace]) -> tuple[SourceTrace, ...]: 

905 """Collapse occurrences that share an identity, and flag differing evidence.""" 

906 by_identity: dict[str, list[SourceTrace]] = {} 

907 for trace in traces: 

908 by_identity.setdefault(trace.identity, []).append(trace) 

909 collapsed = [] 

910 for occurrences in by_identity.values(): 

911 ordered = sorted(occurrences, key=_trace_selection_key) 

912 selected = ordered[0] 

913 provenance = tuple(dict.fromkeys(item for trace in ordered for item in trace.provenance)) 

914 # The fingerprint excludes Trace List data, so a later occurrence can state its own finding. 

915 errors = _first_of_each_code(ordered) 

916 # An endpoint-only fallback states no segments, so it contradicts no segment evidence. 

917 compared = [trace for trace in ordered if trace.segments] or ordered 

918 if len({trace.content_fingerprint for trace in compared}) > 1: 

919 locations = "; ".join(_location_from_provenance(trace.provenance[0]) for trace in compared) 

920 errors.append( 

921 SourceDiagnostic( 

922 code="trace.duplicate_conflict", 

923 message=f"Source Trace occurrences have differing evidence: {locations}.", 

924 row_number=min(item.row_start for item in provenance), 

925 ) 

926 ) 

927 labels = tuple(sorted({label for trace in ordered for label in trace.port_class_labels})) 

928 collapsed.append( 

929 replace(selected, provenance=provenance, errors=_deduplicate_errors(errors), port_class_labels=labels) 

930 ) 

931 return tuple(collapsed) 

932 

933 

934def _first_of_each_code(occurrences: Sequence[SourceTrace]) -> list[SourceDiagnostic]: 

935 """Return every occurrence's findings, reporting one repeated condition once.""" 

936 errors: list[SourceDiagnostic] = [] 

937 seen_codes: set[str] = set() 

938 for occurrence in occurrences: 

939 for error in occurrence.errors: 

940 if error.code in seen_codes and occurrence is not occurrences[0]: 

941 continue 

942 seen_codes.add(error.code) 

943 errors.append(error) 

944 return errors 

945 

946 

947def _location_from_provenance(provenance: TraceProvenance) -> str: 

948 """Return the readable source position one provenance record holds.""" 

949 return f"{provenance.sheet} block {provenance.block_ordinal} (rows {provenance.row_start}-{provenance.row_end})" 

950 

951 

952def _claimed_endpoints(trace: SourceTrace) -> tuple[TerminationReference, ...]: 

953 """Return the endpoints a trace claims, which leaves out the To leaf of a power block with no cable.""" 

954 summary = trace.endpoint_summary 

955 if summary.from_termination.port_class in POWER_CLASSES and not trace.segments: 

956 return (summary.from_termination,) 

957 return summary.from_termination, summary.to_termination 

958 

959 

960def _endpoint_claims(occurrences: Iterable[SourceTrace]) -> dict[str, set[IdentityKey]]: 

961 """Return the endpoints every occurrence of each identity claims, before collapse keeps one summary.""" 

962 claims: dict[str, set[IdentityKey]] = defaultdict(set) 

963 for occurrence in occurrences: 

964 claims[occurrence.identity].update(endpoint.identity_key for endpoint in _claimed_endpoints(occurrence)) 

965 return claims 

966 

967 

968def _cross_trace_conflicts( 

969 traces: Sequence[SourceTrace], endpoint_claims: Mapping[str, set[IdentityKey]] 

970) -> tuple[SourceTrace, ...]: 

971 """Flag every trace that claims a termination another Source Trace claims for another segment.""" 

972 termination_claims: dict[IdentityKey, dict[int, set[_SegmentClaim]]] = defaultdict(lambda: defaultdict(set)) 

973 for index, trace in enumerate(traces): 

974 claims: dict[IdentityKey, set[_SegmentClaim]] = defaultdict(set) 

975 for segment in trace.segments: 

976 ordered_pair = sorted((segment.left.identity_key, segment.right.identity_key)) 

977 # Two labels for one pair can resolve to one policy, so the label decides nothing here. 

978 segment_pair = ordered_pair[0], ordered_pair[1] 

979 claims[segment.left.identity_key].add(segment_pair) 

980 claims[segment.right.identity_key].add(segment_pair) 

981 for endpoint in endpoint_claims[trace.identity]: 

982 claims.setdefault(endpoint, set()) 

983 for termination, trace_claims in claims.items(): 

984 termination_claims[termination][index].update(trace_claims) 

985 conflicts: dict[int, set[str]] = defaultdict(set) 

986 for claims_by_owner in termination_claims.values(): 

987 owner_claims = list(claims_by_owner.values()) 

988 identical_shared_segment = bool(owner_claims[0]) and all(claims == owner_claims[0] for claims in owner_claims) 

989 if len(claims_by_owner) > 1 and not identical_shared_segment: 

990 for owner in claims_by_owner: 

991 conflicts[owner].add("a termination is claimed by another Source Trace") 

992 checked = [] 

993 for index, trace in enumerate(traces): 

994 if index not in conflicts: 

995 checked.append(trace) 

996 continue 

997 provenance = trace.provenance[0] 

998 detail = "; ".join(sorted(conflicts[index])) 

999 error = SourceDiagnostic( 

1000 code="trace.cross_trace_conflict", 

1001 message=f"{_location_from_provenance(provenance)}: {detail}.", 

1002 row_number=provenance.row_start, 

1003 ) 

1004 checked.append(replace(trace, errors=_deduplicate_errors((*trace.errors, error)))) 

1005 return tuple(checked) 

1006 

1007 

1008def interpret( 

1009 content: bytes, config: TraceWorkbookConfig 

1010) -> tuple[tuple[SourceTrace, ...], tuple[SourceDiagnostic, ...]]: 

1011 """Return typed Source Traces and source diagnostics from workbook bytes.""" 

1012 try: 

1013 book = openpyxl.load_workbook(BytesIO(content), data_only=True) 

1014 except Exception as exc: 

1015 raise SourceUnreadable(f"Cannot open Excel file: {exc}") from exc 

1016 try: 

1017 recognized = [name for name in (TRACE_PATH_SHEET, TRACE_LIST_SHEET) if name in book.sheetnames] 

1018 if not recognized: 

1019 diagnostic = SourceDiagnostic( 

1020 code="trace.no_recognized_sheet", 

1021 message=f"Workbook has neither '{TRACE_PATH_SHEET}' nor '{TRACE_LIST_SHEET}'.", 

1022 ) 

1023 return (), (diagnostic,) 

1024 workbook_fingerprint = sha256(content).hexdigest() 

1025 path_blocks = ( 

1026 _extract_blocks(book[TRACE_PATH_SHEET], workbook_fingerprint) if TRACE_PATH_SHEET in recognized else () 

1027 ) 

1028 list_blocks = ( 

1029 _extract_blocks(book[TRACE_LIST_SHEET], workbook_fingerprint) if TRACE_LIST_SHEET in recognized else () 

1030 ) 

1031 finally: 

1032 # close() releases an archive only in read-only mode, but it stays paired with the load. 

1033 book.close() 

1034 lists_by_pair: dict[tuple[str, str], list[_Block]] = defaultdict(list) 

1035 for block in list_blocks: 

1036 lists_by_pair[block.pair_key].append(block) 

1037 traces: list[SourceTrace] = [] 

1038 diagnostics: list[SourceDiagnostic] = [] 

1039 paired_lists: set[tuple[str, int]] = set() 

1040 for path_block in path_blocks: 

1041 candidates = lists_by_pair[path_block.pair_key] 

1042 list_block = candidates.pop(0) if candidates else None 

1043 if list_block is not None: 

1044 paired_lists.add((list_block.sheet, list_block.ordinal)) 

1045 trace, block_diagnostics = _path_trace(path_block, list_block, config.port_class_map) 

1046 diagnostics.extend(block_diagnostics) 

1047 if trace is not None: 

1048 traces.append(trace) 

1049 for block in list_blocks: 

1050 if (block.sheet, block.ordinal) in paired_lists: 

1051 continue 

1052 trace, block_diagnostics = _fallback_trace(block, config.port_class_map) 

1053 diagnostics.extend(block_diagnostics) 

1054 if trace is not None: 

1055 traces.append(trace) 

1056 checked = _cross_trace_conflicts(_collapse_duplicates(traces), _endpoint_claims(traces)) 

1057 diagnostics.extend(error for trace in checked for error in trace.errors) 

1058 return checked, tuple(diagnostics) 

1059 

1060 

1061__all__ = ( 

1062 "FRONT_PORT_CLASSES", 

1063 "INTERFACE_PORT_CLASSES", 

1064 "PORT_CLASSES", 

1065 "REAR_PORT_CLASSES", 

1066 "TraceWorkbookConfig", 

1067 "canonical_orientation", 

1068 "canonical_trace_identity", 

1069 "content_fingerprint", 

1070 "interpret", 

1071 "parse_endpoint_line", 

1072)