parser.py 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. from __future__ import annotations
  2. import csv
  3. import io
  4. import re
  5. from datetime import datetime
  6. from pathlib import Path
  7. _FOOTER_RE = re.compile(
  8. r"^(Component\s+Groups?|Component\s+Count|Fitted|Total\s+comp|"
  9. r"Schematic|PCB\s+Variant|BoM\s+Date)\s*[:\-]",
  10. re.IGNORECASE,
  11. )
  12. _TOTALS_RE = re.compile(
  13. r"^(Estimated\s+Total|Total\s+Cost|Grand\s+Total)",
  14. re.IGNORECASE,
  15. )
  16. def parse_bom(content: bytes, filename: str) -> list[dict[str, str]]:
  17. suffix = Path(filename).suffix.lower()
  18. if suffix == ".csv":
  19. return _parse_csv(content)
  20. elif suffix == ".xlsx":
  21. return _parse_xlsx(content)
  22. elif suffix == ".xls":
  23. return _parse_xls(content)
  24. else:
  25. raise ValueError(f"Unsupported format: {suffix!r}")
  26. # ---------------------------------------------------------------------------
  27. # CSV
  28. # ---------------------------------------------------------------------------
  29. def _parse_csv(content: bytes) -> list[dict[str, str]]:
  30. text = content.decode("utf-8-sig")
  31. delimiter = _detect_delimiter(text)
  32. reader = csv.reader(io.StringIO(text), delimiter=delimiter)
  33. all_rows = list(reader)
  34. if not all_rows:
  35. return []
  36. headers = [h.strip() for h in all_rows[0]]
  37. result: list[dict[str, str]] = []
  38. for row in all_rows[1:]:
  39. # pad short rows
  40. while len(row) < len(headers):
  41. row.append("")
  42. row_dict = {headers[i]: row[i].strip() for i in range(len(headers)) if headers[i]}
  43. # stop at footer
  44. first_val = next((v for v in row_dict.values() if v), "")
  45. if _FOOTER_RE.match(first_val) or _TOTALS_RE.match(first_val):
  46. break
  47. # skip all-empty or all-non-alpha rows (e.g. blank semicolon rows)
  48. vals = [v for v in row_dict.values() if v]
  49. if not vals or all(not re.search(r"[A-Za-z0-9]", v) for v in vals):
  50. continue
  51. result.append(row_dict)
  52. return result
  53. def _detect_delimiter(text: str) -> str:
  54. first_line = text.split("\n", 1)[0]
  55. comma_count = first_line.count(",")
  56. semi_count = first_line.count(";")
  57. return ";" if semi_count > comma_count else ","
  58. # ---------------------------------------------------------------------------
  59. # XLSX
  60. # ---------------------------------------------------------------------------
  61. def _parse_xlsx(content: bytes) -> list[dict[str, str]]:
  62. import openpyxl
  63. wb = openpyxl.load_workbook(io.BytesIO(content), data_only=True)
  64. ws = wb.active
  65. all_rows = list(ws.iter_rows(values_only=True))
  66. return _extract_table(all_rows)
  67. # ---------------------------------------------------------------------------
  68. # XLS
  69. # ---------------------------------------------------------------------------
  70. def _parse_xls(content: bytes) -> list[dict[str, str]]:
  71. import xlrd
  72. wb = xlrd.open_workbook(file_contents=content)
  73. ws = wb.sheet_by_index(0)
  74. all_rows = []
  75. for r in range(ws.nrows):
  76. row = []
  77. for c in range(ws.ncols):
  78. cell = ws.cell(r, c)
  79. if cell.ctype == xlrd.XL_CELL_DATE:
  80. try:
  81. dt = xlrd.xldate_as_datetime(cell.value, wb.datemode)
  82. row.append(dt.isoformat())
  83. except Exception:
  84. row.append(str(cell.value))
  85. elif cell.ctype == xlrd.XL_CELL_EMPTY:
  86. row.append(None)
  87. else:
  88. row.append(cell.value)
  89. all_rows.append(tuple(row))
  90. return _extract_table(all_rows)
  91. # ---------------------------------------------------------------------------
  92. # Shared table extraction (XLSX + XLS)
  93. # ---------------------------------------------------------------------------
  94. def _extract_table(all_rows: list[tuple]) -> list[dict[str, str]]:
  95. if not all_rows:
  96. return []
  97. header_idx, col_offset = _find_header(all_rows)
  98. if header_idx is None:
  99. return []
  100. raw_headers = all_rows[header_idx]
  101. headers: list[str] = []
  102. for cell in raw_headers[col_offset:]:
  103. if cell is None:
  104. headers.append("")
  105. else:
  106. h = str(cell).replace("\n", " ").strip()
  107. headers.append(h)
  108. # drop trailing None/empty headers
  109. while headers and not headers[-1]:
  110. headers.pop()
  111. result: list[dict[str, str]] = []
  112. for row in all_rows[header_idx + 1:]:
  113. row_slice = row[col_offset: col_offset + len(headers)]
  114. # pad
  115. row_slice = list(row_slice) + [""] * (len(headers) - len(row_slice))
  116. row_dict: dict[str, str] = {}
  117. for i, h in enumerate(headers):
  118. if not h:
  119. continue
  120. cell = row_slice[i]
  121. row_dict[h] = _cell_to_str(cell)
  122. # stop at footer / totals
  123. first_val = next((v for v in row_dict.values() if v), "")
  124. if _FOOTER_RE.match(first_val) or _TOTALS_RE.match(first_val):
  125. break
  126. # skip rows where every value is empty (blank separator rows)
  127. if not any(row_dict.values()):
  128. continue
  129. result.append(row_dict)
  130. return result
  131. def _find_header(all_rows: list[tuple]) -> tuple[int | None, int]:
  132. from bom_assistant.ingestion.column_mapper import _load_synonyms
  133. all_synonyms: set[str] = set()
  134. for syns in _load_synonyms().values():
  135. all_synonyms.update(s.lower() for s in syns)
  136. for row_idx, row in enumerate(all_rows[:15]):
  137. string_cells = [c for c in row if c is not None and isinstance(c, str) and c.strip()]
  138. if len(string_cells) < 2:
  139. continue
  140. matches = sum(1 for c in string_cells if c.strip().lower() in all_synonyms)
  141. if matches >= 1:
  142. # find first non-None column offset
  143. col_offset = next((i for i, c in enumerate(row) if c is not None), 0)
  144. return row_idx, col_offset
  145. return None, 0
  146. def _cell_to_str(cell) -> str:
  147. if cell is None:
  148. return ""
  149. if isinstance(cell, datetime):
  150. return cell.isoformat()
  151. if isinstance(cell, float) and cell == int(cell):
  152. return str(int(cell))
  153. return str(cell).strip()