Lint / JS (eslint) (push) Successful in 7s
Lint / Python (flake8) (push) Successful in 1m36s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m40s
Lint / Python (flake8) (pull_request) Successful in 52s
Test / Python Tests (pytest) (push) Successful in 1m4s
Lint / JS (eslint) (pull_request) Successful in 7s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 1m13s
Test / Python Tests (pytest) (pull_request) Successful in 1m15s
A new Cutsheet page records where every cable is meant to go and compares it with what Gandalf discovers. - Inventory for what discovery can't see: patch panels, wall jacks, shelves and rack positions. Panels and jacks pass through front to rear. - Cables with two endpoints, ID, type, length, color, status (planned, installed, dark, retired) and notes; the far end may be unterminated. - Each cable's full run is traced through panels and jacks, and runs ending on a switch port are checked against its discovered neighbor (LLDP, client table, or a DAC serial seen at both ends): matches, wrong neighbor, no link, missing port, port conflict, unverified, idle. - Undocumented links: live switch ports no cable mentions, one click to add with what Gandalf already knows. Port panels show the planned cable. - Labels to make: cable IDs and panel-port labels not yet applied, with a print view, for a handheld label maker. - CSV export, and all-or-nothing import that upserts by cable ID. - Cables, panels and jacks appear in the dashboard search. Editing needs the admin group and a JSON request body. Every change is written to cutsheet_history in the same transaction. Tables are created on first use. Refs #12 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
472 lines
20 KiB
Python
472 lines
20 KiB
Python
"""Cutsheets: where every cable is meant to go, compared with what Gandalf sees.
|
|
|
|
Pure functions with no I/O, like devices.py. app.py loads rows from the
|
|
database and the monitor's cached link stats and passes them in.
|
|
|
|
Model
|
|
-----
|
|
* An **item** is something Gandalf can't discover: a patch panel, a wall jack,
|
|
a shelf, or a rack position for a device. Items of a passive kind (panels and
|
|
jacks) pass a signal straight through, from a port's front to the same port's
|
|
rear.
|
|
* A **cable** joins two endpoints. An endpoint is a device or item name, a port
|
|
(a switch's port number, a host's interface name, or a panel port), and for
|
|
passive items a side. The far end may be left blank for a cable that ends
|
|
nowhere yet, such as an unterminated fibre.
|
|
* A **path** is the chain of cables joined through passive items, e.g. switch
|
|
port 13 → panel port 5 (front) … panel port 5 (rear) → bedroom jack.
|
|
"""
|
|
import csv
|
|
import io
|
|
import re
|
|
from typing import Dict, List, Optional, Tuple
|
|
|
|
ITEM_KINDS = ('patch_panel', 'wall_jack', 'device', 'shelf', 'other')
|
|
PASSIVE_KINDS = ('patch_panel', 'wall_jack')
|
|
SIDES = ('front', 'rear')
|
|
STATUSES = ('planned', 'installed', 'dark', 'retired')
|
|
CABLE_TYPES = ('Cat5e', 'Cat6', 'Cat6a', 'DAC', 'AOC', 'OM3', 'OM4', 'OS2', 'Power', 'Other')
|
|
|
|
LABEL_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$')
|
|
NAME_MAX = 100
|
|
PORT_MAX = 64
|
|
TEXT_MAX = 255
|
|
NOTES_MAX = 2000
|
|
|
|
# Comparison outcomes, and whether each counts as a problem
|
|
VERDICTS = {
|
|
'match': 'Matches what Gandalf sees',
|
|
'wrong_neighbor': 'A different device is connected',
|
|
'no_link': 'Planned device is not linked',
|
|
'missing_port': 'That port is not reported by the device',
|
|
'unverified': "Gandalf can't confirm the far end",
|
|
'idle': 'Ends at a jack or panel with nothing linked',
|
|
'not_checked': 'No switch port to check against',
|
|
'planned': 'Planned, not installed yet',
|
|
'dark': 'Unused (dark)',
|
|
'retired': 'Retired',
|
|
'conflict': 'Another cable uses the same port',
|
|
}
|
|
PROBLEM_VERDICTS = ('wrong_neighbor', 'no_link', 'missing_port', 'conflict')
|
|
|
|
|
|
class ValidationError(ValueError):
|
|
"""A request the cutsheet can't accept, with a message for the user."""
|
|
|
|
|
|
# ── Validation ──────────────────────────────────────────────────────────────
|
|
|
|
def _text(data: dict, key: str, limit: int = TEXT_MAX, required: bool = False, label: str = '') -> str:
|
|
value = data.get(key)
|
|
value = '' if value is None else str(value).strip()
|
|
if required and not value:
|
|
raise ValidationError(f'{label or key} is required')
|
|
if len(value) > limit:
|
|
raise ValidationError(f'{label or key} must be {limit} characters or fewer')
|
|
return value
|
|
|
|
|
|
def _int(data: dict, key: str, low: int, high: int, label: str) -> Optional[int]:
|
|
value = data.get(key)
|
|
if value in (None, ''):
|
|
return None
|
|
try:
|
|
number = int(value)
|
|
except (TypeError, ValueError):
|
|
raise ValidationError(f'{label} must be a whole number')
|
|
if not low <= number <= high:
|
|
raise ValidationError(f'{label} must be between {low} and {high}')
|
|
return number
|
|
|
|
|
|
def validate_item(data: dict) -> dict:
|
|
kind = _text(data, 'kind', 20) or 'other'
|
|
if kind not in ITEM_KINDS:
|
|
raise ValidationError(f'kind must be one of {", ".join(ITEM_KINDS)}')
|
|
item = {
|
|
'name': _text(data, 'name', NAME_MAX, required=True, label='Name'),
|
|
'kind': kind,
|
|
'port_count': _int(data, 'port_count', 1, 512, 'Port count'),
|
|
'location': _text(data, 'location'),
|
|
'rack_u': _int(data, 'rack_u', 1, 60, 'Rack unit'),
|
|
'rack_height': _int(data, 'rack_height', 1, 20, 'Height'),
|
|
'notes': _text(data, 'notes', NOTES_MAX),
|
|
}
|
|
if item['rack_height'] and not item['rack_u']:
|
|
raise ValidationError('Set a rack unit to go with the height')
|
|
if item['rack_u'] and not item['rack_height']:
|
|
item['rack_height'] = 1
|
|
if kind == 'patch_panel' and not item['port_count']:
|
|
raise ValidationError('A patch panel needs a port count')
|
|
return item
|
|
|
|
|
|
def _endpoint(data: dict, prefix: str, items: Dict[str, dict], required: bool) -> Tuple[str, str, str]:
|
|
device = _text(data, f'{prefix}_device', NAME_MAX, required=required,
|
|
label='From device' if prefix == 'a' else 'To device')
|
|
port = _text(data, f'{prefix}_port', PORT_MAX)
|
|
side = _text(data, f'{prefix}_side', 10)
|
|
if not device:
|
|
if port or side:
|
|
raise ValidationError('Choose a device before a port')
|
|
return '', '', ''
|
|
item = items.get(device)
|
|
passive = bool(item and item['kind'] in PASSIVE_KINDS)
|
|
if passive:
|
|
if side not in SIDES:
|
|
raise ValidationError(f'Choose the front or rear of {device}')
|
|
if not port:
|
|
raise ValidationError(f'Choose a port on {device}')
|
|
count = item.get('port_count')
|
|
if count and (not port.isdigit() or not 1 <= int(port) <= count):
|
|
raise ValidationError(f'{device} has ports 1 to {count}')
|
|
else:
|
|
side = ''
|
|
return device, port, side
|
|
|
|
|
|
def validate_cable(data: dict, items: Dict[str, dict]) -> dict:
|
|
label = _text(data, 'label', 32)
|
|
if label and not LABEL_RE.match(label):
|
|
raise ValidationError('Label may use letters, digits, dot, dash and underscore (up to 32)')
|
|
status = _text(data, 'status', 20) or 'installed'
|
|
if status not in STATUSES:
|
|
raise ValidationError(f'status must be one of {", ".join(STATUSES)}')
|
|
cable_type = _text(data, 'cable_type', 32)
|
|
if cable_type and cable_type not in CABLE_TYPES:
|
|
raise ValidationError(f'Type must be one of {", ".join(CABLE_TYPES)}')
|
|
length = data.get('length_m')
|
|
if length in (None, ''):
|
|
length = None
|
|
else:
|
|
try:
|
|
length = round(float(length), 2)
|
|
except (TypeError, ValueError):
|
|
raise ValidationError('Length must be a number of metres')
|
|
if not 0 < length <= 999:
|
|
raise ValidationError('Length must be between 0 and 999 metres')
|
|
a = _endpoint(data, 'a', items, required=True)
|
|
b = _endpoint(data, 'b', items, required=False)
|
|
if a == b:
|
|
raise ValidationError('Both ends are the same port')
|
|
return {
|
|
'label': label or None,
|
|
'a_device': a[0], 'a_port': a[1], 'a_side': a[2],
|
|
'b_device': b[0], 'b_port': b[1], 'b_side': b[2],
|
|
'cable_type': cable_type, 'length_m': length,
|
|
'color': _text(data, 'color', 32), 'status': status,
|
|
'notes': _text(data, 'notes', NOTES_MAX),
|
|
'label_applied': bool(data.get('label_applied')),
|
|
}
|
|
|
|
|
|
def next_label(cables: List[dict], prefix: str = 'C') -> str:
|
|
"""The next free cable ID: C01, C02, ... (wider once past 99)."""
|
|
used = set()
|
|
for c in cables:
|
|
m = re.match(rf'^{re.escape(prefix)}(\d+)$', c.get('label') or '', re.IGNORECASE)
|
|
if m:
|
|
used.add(int(m.group(1)))
|
|
n = 1
|
|
while n in used:
|
|
n += 1
|
|
return f'{prefix}{n:02d}'
|
|
|
|
|
|
# ── Paths ───────────────────────────────────────────────────────────────────
|
|
|
|
def _node(cable: dict, end: str) -> Optional[tuple]:
|
|
device = cable.get(f'{end}_device') or ''
|
|
if not device:
|
|
return None
|
|
return (device, cable.get(f'{end}_port') or '', cable.get(f'{end}_side') or '')
|
|
|
|
|
|
def _other_side(node: tuple, items: Dict[str, dict]) -> Optional[tuple]:
|
|
"""Through a passive item, the same port's other side; otherwise the path ends."""
|
|
item = items.get(node[0])
|
|
if not item or item['kind'] not in PASSIVE_KINDS or node[2] not in SIDES:
|
|
return None
|
|
return (node[0], node[1], 'rear' if node[2] == 'front' else 'front')
|
|
|
|
|
|
def _live(cable: dict) -> bool:
|
|
return cable.get('status') != 'retired'
|
|
|
|
|
|
def node_index(cables: List[dict]) -> Dict[tuple, List[dict]]:
|
|
"""Map each endpoint to the live cables plugged into it."""
|
|
index: Dict[tuple, List[dict]] = {}
|
|
for c in cables:
|
|
if not _live(c):
|
|
continue
|
|
for end in ('a', 'b'):
|
|
node = _node(c, end)
|
|
if node:
|
|
index.setdefault(node, []).append(c)
|
|
return index
|
|
|
|
|
|
def trace(cable: dict, cables: List[dict], items: Dict[str, dict],
|
|
index: Optional[Dict[tuple, List[dict]]] = None) -> dict:
|
|
"""Follow a cable through passive items in both directions.
|
|
|
|
Returns {'hops': [node, cable_id, node, ...], 'cable_ids': [...]} with the
|
|
hops ordered from one end of the run to the other. A node is
|
|
(device, port, side), or None for an unterminated end.
|
|
"""
|
|
index = index if index is not None else node_index(cables)
|
|
|
|
def walk(node: Optional[tuple], came_from: dict, seen: set) -> list:
|
|
hops = []
|
|
while node is not None:
|
|
through = _other_side(node, items)
|
|
if through is None:
|
|
break
|
|
nxt = next((c for c in index.get(through, []) if c['id'] != came_from['id'] and c['id'] not in seen),
|
|
None)
|
|
hops.append(through)
|
|
if nxt is None:
|
|
break
|
|
seen.add(nxt['id'])
|
|
hops.append(nxt['id'])
|
|
far = 'b' if _node(nxt, 'a') == through else 'a'
|
|
node = _node(nxt, far)
|
|
hops.append(node)
|
|
came_from = nxt
|
|
return hops
|
|
|
|
seen = {cable['id']}
|
|
toward_a = walk(_node(cable, 'a'), cable, seen)
|
|
toward_b = walk(_node(cable, 'b'), cable, seen)
|
|
hops = list(reversed(toward_a)) + [_node(cable, 'a'), cable['id'], _node(cable, 'b')] + toward_b
|
|
return {'hops': hops, 'cable_ids': [h for h in hops if isinstance(h, int)]}
|
|
|
|
|
|
# ── Plan vs. actual ─────────────────────────────────────────────────────────
|
|
|
|
def _switch_port(node: Optional[tuple], switches: dict) -> Optional[dict]:
|
|
"""The discovered port behind an endpoint, if it is a switch or router port."""
|
|
if not node or node[0] not in switches or not node[1].isdigit():
|
|
return None
|
|
for port in (switches[node[0]].get('ports') or {}).values():
|
|
if port.get('port_idx') == int(node[1]):
|
|
return port
|
|
return {} # the device is known but doesn't report this port
|
|
|
|
|
|
def _same(a: str, b: str) -> bool:
|
|
return bool(a) and bool(b) and a.strip().lower() == b.strip().lower()
|
|
|
|
|
|
def _check_end(node: tuple, other: Optional[tuple], switches: dict, items: Dict[str, dict]) -> Optional[dict]:
|
|
port = _switch_port(node, switches)
|
|
if port is None:
|
|
return None
|
|
if port == {}:
|
|
return {'verdict': 'missing_port', 'detail': f'{node[0]} reports no port {node[1]}'}
|
|
other_passive = other is None or (items.get(other[0], {}).get('kind') in PASSIVE_KINDS)
|
|
expected = None if other_passive else other[0]
|
|
lldp = port.get('lldp') or {}
|
|
seen = lldp.get('system_name') or ''
|
|
sfp_peer = (port.get('sfp') or {}).get('cable_peer')
|
|
|
|
if sfp_peer and other is not None and not other_passive:
|
|
if _same(sfp_peer.get('switch', ''), other[0]) and str(sfp_peer.get('port_idx')) == other[1]:
|
|
return {'verdict': 'match', 'detail': 'Both ends report the same DAC serial'}
|
|
if not port.get('up'):
|
|
if expected:
|
|
return {'verdict': 'no_link', 'detail': f'{node[0]} port {node[1]} is down'}
|
|
return {'verdict': 'idle', 'detail': f'{node[0]} port {node[1]} is down'}
|
|
if expected:
|
|
if not seen:
|
|
return {'verdict': 'unverified', 'detail': f'Link up on port {node[1]}, but no neighbor is identified'}
|
|
if _same(seen, expected):
|
|
return {'verdict': 'match', 'detail': f'{node[0]} port {node[1]} sees {seen}'}
|
|
return {'verdict': 'wrong_neighbor', 'detail': f'{node[0]} port {node[1]} sees {seen}, not {expected}'}
|
|
detail = f'Link up on port {node[1]}' + (f', sees {seen}' if seen else '')
|
|
return {'verdict': 'unverified', 'detail': detail}
|
|
|
|
|
|
_VERDICT_RANK = ['wrong_neighbor', 'no_link', 'missing_port', 'unverified', 'idle', 'match']
|
|
|
|
|
|
def compare_path(path: dict, cables_by_id: Dict[int, dict], switches: dict, items: Dict[str, dict]) -> dict:
|
|
"""Check a traced run against discovery. Returns {'verdict', 'detail'}."""
|
|
run = [cables_by_id[i] for i in path['cable_ids']]
|
|
statuses = {c['status'] for c in run}
|
|
for status in ('retired', 'dark', 'planned'):
|
|
if status in statuses:
|
|
return {'verdict': status, 'detail': VERDICTS[status]}
|
|
ends = (path['hops'][0], path['hops'][-1])
|
|
checks = [c for c in (_check_end(ends[0], ends[1], switches, items),
|
|
_check_end(ends[1], ends[0], switches, items)) if c]
|
|
if not checks:
|
|
return {'verdict': 'not_checked', 'detail': VERDICTS['not_checked']}
|
|
# A switch-to-switch run is checked from both ends: report the worst
|
|
return min(checks, key=lambda c: _VERDICT_RANK.index(c['verdict']))
|
|
|
|
|
|
def evaluate(cables: List[dict], items: Dict[str, dict], switches: dict) -> Dict[int, dict]:
|
|
"""Trace and compare every cable. Returns {cable_id: {'path', 'verdict', 'detail'}}."""
|
|
switches = switches or {}
|
|
index = node_index(cables)
|
|
by_id = {c['id']: c for c in cables}
|
|
result: Dict[int, dict] = {}
|
|
for c in cables:
|
|
if not _live(c):
|
|
result[c['id']] = {'path': {'hops': [_node(c, 'a'), c['id'], _node(c, 'b')], 'cable_ids': [c['id']]},
|
|
'verdict': 'retired', 'detail': VERDICTS['retired']}
|
|
continue
|
|
conflict = next((n for n in (_node(c, 'a'), _node(c, 'b'))
|
|
if n and len([x for x in index.get(n, []) if x['status'] in ('installed', 'dark')]) > 1
|
|
and c['status'] in ('installed', 'dark')), None)
|
|
path = trace(c, cables, items, index)
|
|
if conflict:
|
|
others = [x.get('label') or f'#{x["id"]}' for x in index[conflict] if x['id'] != c['id']]
|
|
result[c['id']] = {'path': path, 'verdict': 'conflict',
|
|
'detail': f'{describe_node(conflict)} is also used by {", ".join(others)}'}
|
|
continue
|
|
result[c['id']] = dict(compare_path(path, by_id, switches, items), path=path)
|
|
return result
|
|
|
|
|
|
def describe_node(node: Optional[tuple]) -> str:
|
|
if node is None:
|
|
return 'unterminated'
|
|
device, port, side = node
|
|
text = device
|
|
if port:
|
|
text += f' port {port}' if port.isdigit() else f' {port}'
|
|
if side:
|
|
text += f' ({side})'
|
|
return text
|
|
|
|
|
|
# ── Undocumented links ──────────────────────────────────────────────────────
|
|
|
|
def undocumented(cables: List[dict], switches: dict) -> List[dict]:
|
|
"""Linked switch ports that no live cable mentions, as suggestions to add."""
|
|
documented = set()
|
|
for c in cables:
|
|
if not _live(c):
|
|
continue
|
|
for end in ('a', 'b'):
|
|
node = _node(c, end)
|
|
if node:
|
|
documented.add((node[0].lower(), node[1]))
|
|
suggestions = []
|
|
for sw_name, sw in sorted((switches or {}).items()):
|
|
for port_name, port in (sw.get('ports') or {}).items():
|
|
idx = port.get('port_idx')
|
|
if not port.get('up') or idx is None or (sw_name.lower(), str(idx)) in documented:
|
|
continue
|
|
lldp = port.get('lldp') or {}
|
|
sfp = port.get('sfp') or {}
|
|
peer = sfp.get('cable_peer')
|
|
suggestion = {
|
|
'switch': sw_name, 'port_idx': idx, 'port_name': port_name,
|
|
'neighbor': lldp.get('system_name') or '', 'neighbor_port': lldp.get('port_id') or '',
|
|
'speed_mbps': port.get('speed_mbps') or 0,
|
|
'cable_type': 'DAC' if sfp.get('kind') == 'dac' else '',
|
|
'via': 'lldp' if lldp.get('system_name') else '',
|
|
}
|
|
if lldp.get('source') == 'unifi-clients':
|
|
suggestion['via'] = 'client table'
|
|
if peer:
|
|
suggestion.update(neighbor=peer['switch'], neighbor_port=str(peer['port_idx']), via='DAC serial')
|
|
# Offer each DAC once, from the end that sorts first
|
|
if (peer['switch'].lower(), str(peer['port_idx'])) in documented:
|
|
continue
|
|
if (peer['switch'], peer['port_idx']) < (sw_name, idx):
|
|
continue
|
|
suggestions.append(suggestion)
|
|
return suggestions
|
|
|
|
|
|
# ── Labels to make ──────────────────────────────────────────────────────────
|
|
|
|
def labels_to_make(cables: List[dict], items: Dict[str, dict], port_labels: List[dict]) -> dict:
|
|
"""Cable IDs and panel-port labels that haven't been stuck on yet."""
|
|
cords = [
|
|
{'id': c['id'], 'text': c['label'], 'copies': 2 if c.get('b_device') else 1,
|
|
'where': f'{describe_node(_node(c, "a"))} ↔ {describe_node(_node(c, "b"))}'}
|
|
for c in sorted(cables, key=lambda c: _natural(c.get('label') or ''))
|
|
if c.get('label') and not c.get('label_applied') and c['status'] in ('installed', 'dark')
|
|
]
|
|
unlabeled = [c['id'] for c in cables if not c.get('label') and c['status'] in ('installed', 'dark')]
|
|
ports = [
|
|
{'item': p['item_name'], 'port': p['port'], 'text': p['label']}
|
|
for p in sorted(port_labels, key=lambda p: (p['item_name'].lower(), _natural(p['port'])))
|
|
if p.get('label') and not p.get('applied')
|
|
]
|
|
return {'cables': cords, 'ports': ports, 'unlabeled_cable_ids': unlabeled}
|
|
|
|
|
|
def _natural(text: str) -> list:
|
|
return [int(t) if t.isdigit() else t.lower() for t in re.split(r'(\d+)', text or '')]
|
|
|
|
|
|
# ── CSV ─────────────────────────────────────────────────────────────────────
|
|
|
|
CSV_FIELDS = ['label', 'a_device', 'a_port', 'a_side', 'b_device', 'b_port', 'b_side',
|
|
'cable_type', 'length_m', 'color', 'status', 'label_applied', 'notes']
|
|
|
|
|
|
def cables_to_csv(cables: List[dict]) -> str:
|
|
out = io.StringIO()
|
|
writer = csv.DictWriter(out, fieldnames=CSV_FIELDS, extrasaction='ignore', lineterminator='\n')
|
|
writer.writeheader()
|
|
for c in sorted(cables, key=lambda c: _natural(c.get('label') or '')):
|
|
row = {k: c.get(k) for k in CSV_FIELDS}
|
|
row['label_applied'] = 'yes' if c.get('label_applied') else ''
|
|
row['length_m'] = '' if c.get('length_m') is None else f'{float(c["length_m"]):g}'
|
|
# Keep spreadsheet apps from running a cell as a formula
|
|
writer.writerow({k: _csv_safe(v) for k, v in row.items()})
|
|
return out.getvalue()
|
|
|
|
|
|
def _csv_safe(value) -> str:
|
|
text = '' if value is None else str(value)
|
|
return "'" + text if text[:1] in ('=', '+', '-', '@', '\t', '\r') and not _is_number(text) else text
|
|
|
|
|
|
def _is_number(text: str) -> bool:
|
|
try:
|
|
float(text)
|
|
return True
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def parse_csv(text: str, items: Dict[str, dict], max_rows: int = 2000) -> Tuple[List[dict], List[str]]:
|
|
"""Validate a cable CSV. Returns (cables, errors); nothing is applied on any error."""
|
|
reader = csv.DictReader(io.StringIO(text))
|
|
missing = [f for f in ('a_device',) if f not in (reader.fieldnames or [])]
|
|
if missing:
|
|
return [], [f'Missing column: {", ".join(missing)}']
|
|
rows, errors, labels = [], [], set()
|
|
for line, raw in enumerate(reader, start=2):
|
|
if line - 1 > max_rows:
|
|
errors.append(f'More than {max_rows} rows')
|
|
break
|
|
data = {k: (v or '').strip() for k, v in raw.items() if k}
|
|
for k in list(data):
|
|
if data[k].startswith("'") and data[k][1:2] in ('=', '+', '-', '@'):
|
|
data[k] = data[k][1:]
|
|
if not any(data.values()):
|
|
continue
|
|
data['label_applied'] = data.get('label_applied', '').lower() in ('yes', 'true', '1', 'y')
|
|
try:
|
|
cable = validate_cable(data, items)
|
|
except ValidationError as e:
|
|
errors.append(f'Line {line}: {e}')
|
|
continue
|
|
if cable['label']:
|
|
if cable['label'].lower() in labels:
|
|
errors.append(f'Line {line}: label {cable["label"]} appears twice')
|
|
continue
|
|
labels.add(cable['label'].lower())
|
|
rows.append(cable)
|
|
return rows, errors
|