"""Fail-closed parser for CONFAZ Convenio ICMS 142/18 CEST HTML.""" from dataclasses import dataclass import hashlib from html.parser import HTMLParser import re _ANNEX_NUMBERS = set(range(2, 27)) @dataclass(frozen=True) class CestEntry: code: str description: str annex: str ncm_codes: tuple[str, ...] @dataclass(frozen=True) class CestPayload: version: str sha256: str entries: tuple[CestEntry, ...] class _TableParser(HTMLParser): def __init__(self): super().__init__(convert_charrefs=True) self.annex = None self.tables = [] self._table = None self._row = None self._cell = None def handle_starttag(self, tag, attrs): if tag == "table": self._table = [] elif tag == "tr" and self._table is not None: self._row = [] elif tag in {"td", "th"} and self._row is not None: self._cell = [] elif tag == "br" and self._cell is not None: self._cell.append(" ") def handle_endtag(self, tag): if tag in {"td", "th"} and self._cell is not None: self._row.append("".join(self._cell).strip()) self._cell = None elif tag == "tr" and self._row is not None: self._table.append(self._row) self._row = None elif tag == "table" and self._table is not None: self.tables.append((self.annex, self._table)) self._table = None def handle_data(self, data): if self._cell is not None: self._cell.append(data) else: found = re.search(r"\bANEXO\s+([IVXLCDM]+)\b", data.upper()) if found: self.annex = found.group(1) def _roman_to_number(value: str) -> int: values = {"I": 1, "V": 5, "X": 10, "L": 50, "C": 100, "D": 500, "M": 1000} total = 0 previous = 0 for character in reversed(value): current = values[character] total += -current if current < previous else current previous = max(previous, current) return total def _normalized_header(value: str) -> str: return re.sub(r"[^A-Z0-9]+", "", value.upper()) def _codes(value: str) -> tuple[str, ...]: return tuple(dict.fromkeys(re.sub(r"\D", "", item) for item in re.findall(r"\b\d{2}(?:[.\s]?\d{2}){0,3}\b", value))) def parse_cest_html(payload: bytes) -> CestPayload: try: html = payload.decode("utf-8") except UnicodeDecodeError as error: raise ValueError("official CEST HTML must be UTF-8") from error parser = _TableParser() parser.feed(html) entries = [] for annex, table in parser.tables: if annex is None or _roman_to_number(annex) not in _ANNEX_NUMBERS: continue if not table: raise ValueError("annex table is empty") headers = [_normalized_header(value) for value in table[0]] try: cest_column = next(index for index, value in enumerate(headers) if value == "CEST") ncm_column = next(index for index, value in enumerate(headers) if "NCM" in value) except StopIteration as error: raise ValueError("annex table is missing required columns") from error description_column = next((index for index, value in enumerate(headers) if "DESCR" in value), None) for row in table[1:]: if len(row) <= max(cest_column, ncm_column): if cest_column >= len(row) or not re.search(r"\d{2}[.\s]?\d{3}[.\s]?\d{2}", row[cest_column]): continue raise ValueError("annex table row is incomplete") cest = re.sub(r"\D", "", row[cest_column]) if not cest: continue ncm_codes = _codes(row[ncm_column]) if len(cest) != 7: raise ValueError("annex table row is invalid") description = row[description_column] if description_column is not None and len(row) > description_column else "" entries.append(CestEntry(f"{cest[:2]}.{cest[2:5]}.{cest[5:]}", description, annex, ncm_codes)) if not entries: raise ValueError("no CEST records found in CONFAZ annexes II-XXVI") return CestPayload("CV142_18", hashlib.sha256(payload).hexdigest(), tuple(entries))