git-subtree-dir: fiscal git-subtree-mainline:d95165fc8fgit-subtree-split:102ba84500
121 lines
4.2 KiB
Python
121 lines
4.2 KiB
Python
"""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))
|