From 1c09bb2111bf43abe356169b40dad7c56f4f1cd2 Mon Sep 17 00:00:00 2001 From: Zio Gabber <78922322+Gabrymi93@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:00:41 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat(core):=20preprocessing=20utilities=20?= =?UTF-8?q?=E2=80=94=20normalize=5Fnumber,=20normalize=5Fcolumns=5Fmap,=20?= =?UTF-8?q?decode=5Fbytes=20(#411)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + tests/test_normalize.py | 307 ++++++++++++++++++++++++++++++++++++++ toolkit/core/normalize.py | 197 ++++++++++++++++++++++++ toolkit/version.py | 2 +- 4 files changed, 506 insertions(+), 1 deletion(-) create mode 100644 tests/test_normalize.py create mode 100644 toolkit/core/normalize.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a50a611..3c91dcb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ All notable changes to this project will be documented in this file. ### Added +- **`core/normalize.py` — utility preprocessing centralizzate**: `normalize_number` (normalizza numeri formato italiano), `normalize_columns_map` (mapping colonne via regex), e `decode_bytes`/`decode_csv_bytes` (decodifica multi-encoding con fallback). Rimpiazza 13 copie identiche nei candidate di dataset-incubator (PR #411). - **HTTP centralizzato su `lab_connectors.http`**: `http_file`, `ckan`, `sdmx` e `inspect url` ora usano `HttpClient` invece di `requests.get` diretto, con retry, SSL fallback e timeout uniformi (PR #232, #233, #234, #235). - `lab-connectors` aggiunto come dipendenza core (git URL in `pyproject.toml`). diff --git a/tests/test_normalize.py b/tests/test_normalize.py new file mode 100644 index 00000000..23670ea0 --- /dev/null +++ b/tests/test_normalize.py @@ -0,0 +1,307 @@ +"""Tests for toolkit/core/normalize.py. + +Copre: +- normalize_number (numeri formato italiano, valori speciali, strip_dot_zero) +- normalize_columns_map (mappatura colonne con regex) +- decode_bytes (encoding tentativi, fallback) +- decode_csv_bytes (wrapping) +""" + +from __future__ import annotations + +import re + +import pytest + +from toolkit.core.normalize import ( + decode_bytes, + decode_csv_bytes, + normalize_columns_map, + normalize_number, +) + +pytestmark = pytest.mark.pure_unit + + +# =========================================================================== +# normalize_number +# =========================================================================== + + +class TestNormalizeNumber: + """Contratto: normalize_number normalizza numeri in formato italiano.""" + + # ── Casi base ────────────────────────────────────────────────────── + + def test_simple_integer(self) -> None: + assert normalize_number("1234") == "1234" + + def test_thousands_separator(self) -> None: + assert normalize_number("1.234") == "1234" + + def test_comma_decimal(self) -> None: + assert normalize_number("1234,56") == "1234.56" + + def test_thousands_and_comma(self) -> None: + assert normalize_number("1.234,56") == "1234.56" + + def test_large_number(self) -> None: + assert normalize_number("12.345.678") == "12345678" + + def test_decimal_with_dot_zero_stripped(self) -> None: + """5849,00 → 5849 (strip_dot_zero=True di default).""" + assert normalize_number("5849,00") == "5849" + + def test_decimal_with_nonzero_preserved(self) -> None: + assert normalize_number("1234,50") == "1234.50" + + # ── strip_dot_zero=False ────────────────────────────────────────── + + def test_strip_dot_zero_false_with_comma(self) -> None: + assert normalize_number("5849,00", strip_dot_zero=False) == "5849.00" + + def test_strip_dot_zero_false_integer(self) -> None: + assert normalize_number("1234", strip_dot_zero=False) == "1234" + + # ── Valori speciali → None ──────────────────────────────────────── + + @pytest.mark.parametrize( + "val", + [ + "***", + "-", + "N.D.", + "", + " ", + ], + ) + def test_empty_values_return_none(self, val: str) -> None: + assert normalize_number(val) is None + + def test_quoted_value_stripped(self) -> None: + assert normalize_number('"1.234,56"') == "1234.56" + + def test_quoted_empty_returns_none(self) -> None: + assert normalize_number('""') is None + + # ── custom empty_values ─────────────────────────────────────────── + + def test_custom_empty_values(self) -> None: + assert normalize_number("X", empty_values=frozenset({"X"})) is None + + def test_custom_empty_values_no_match(self) -> None: + """Valore speciale non negli empty_values -> None perche' non e' un numero.""" + assert normalize_number("N.D.", empty_values=frozenset({"X"})) is None + + def test_custom_empty_values_with_real_number(self) -> None: + """Valore numerico con empty_values custom funziona comunque.""" + assert normalize_number("1234", empty_values=frozenset({"X"})) == "1234" + + # ── Non-numeric after cleanup ───────────────────────────────────── + + def test_non_numeric_string_returns_none(self) -> None: + """Stringa che dopo rimozione punti contiene lettere -> None.""" + assert normalize_number("N.D.") is None + assert normalize_number("TEST") is None + assert normalize_number("1a234") is None + + def test_mixed_string_returns_none(self) -> None: + assert normalize_number("1.234a") is None + + # ── Edge cases ──────────────────────────────────────────────────── + + def test_non_string_returns_none(self) -> None: + assert normalize_number(1234) is None # type: ignore[arg-type] + + def test_negative_number(self) -> None: + """Numeri negativi non sono gestiti esplicitamente ma passano.""" + assert normalize_number("-1234") == "-1234" + + def test_negative_with_thousands(self) -> None: + assert normalize_number("-1.234,56") == "-1234.56" + + +# =========================================================================== +# normalize_columns_map +# =========================================================================== + + +class TestNormalizeColumnsMap: + """Contratto: normalize_columns_map applica regex map e restituisce + nomi normalizzati + indici originali.""" + + COL_MAP: list[tuple[re.Pattern, str]] = [ + (re.compile(r"^REG(IONE)?$", re.I), "regione"), + (re.compile(r"^PROV(INCIA)?$", re.I), "provincia"), + (re.compile(r"^COMUNE$", re.I), "comune"), + (re.compile(r"^(ELETTORI(TOT)?)$", re.I), "elettori"), + (re.compile(r"^(VOTI_LISTA|VOTILISTA)$", re.I), "voti_lista"), + ] + + def test_basic_mapping(self) -> None: + header = ["REGIONE", "PROV", "COMUNE", "SKIP"] + names, indices = normalize_columns_map(header, self.COL_MAP) + assert names == ["regione", "provincia", "comune"] + assert indices == [0, 1, 2] + + def test_regex_variants(self) -> None: + header = ["REG", "PROVINCIA", "COMUNE"] + names, indices = normalize_columns_map(header, self.COL_MAP) + assert names == ["regione", "provincia", "comune"] + assert indices == [0, 1, 2] + + def test_voti_variants(self) -> None: + header = ["VOTI_LISTA", "VOTILISTA"] + names, _ = normalize_columns_map(header, self.COL_MAP) + assert names == ["voti_lista", "voti_lista"] + + def test_no_match_returns_empty(self) -> None: + names, indices = normalize_columns_map(["SKIP1", "SKIP2"], self.COL_MAP) + assert names == [] + assert indices == [] + + def test_empty_header(self) -> None: + names, indices = normalize_columns_map([], self.COL_MAP) + assert names == [] + assert indices == [] + + def test_empty_col_map(self) -> None: + header = ["A", "B"] + names, indices = normalize_columns_map(header, []) + assert names == [] + assert indices == [] + + def test_strips_whitespace_and_quotes(self) -> None: + header = ['"REGIONE"', " PROV "] + names, indices = normalize_columns_map(header, self.COL_MAP) + assert names == ["regione", "provincia"] + assert indices == [0, 1] + + def test_first_pattern_wins(self) -> None: + """L'ordine di col_map determina la priorita'.""" + dup_map = [ + (re.compile(r"^A$"), "prima"), + (re.compile(r"^A$"), "seconda"), # non matcha mai + ] + names, indices = normalize_columns_map(["A"], dup_map) + assert names == ["prima"] + assert indices == [0] + + def test_case_insensitive(self) -> None: + names, indices = normalize_columns_map(["comune", "Comune", "COMUNE"], self.COL_MAP) + assert names == ["comune", "comune", "comune"] + assert indices == [0, 1, 2] + + def test_real_elezioni_pattern(self) -> None: + """Test con COL_MAP reale da elezioni-regionali.""" + real_map = [ + (re.compile(r"^REG(IONE)?$", re.I), "regione"), + (re.compile(r"^CIRC(OSCR(IZIONE)?)?$", re.I), "circoscrizione"), + (re.compile(r"^PROV(INCIA)?$", re.I), "provincia"), + (re.compile(r"^COMUNE$", re.I), "comune"), + (re.compile(r"^(ELETTORI(TOT)?)$", re.I), "elettori"), + (re.compile(r"^(VOTANTI(TOT)?)$", re.I), "votanti"), + (re.compile(r"^(SCHEDE_BIANCHE|SKBIANCHE)$", re.I), "schede_bianche"), + (re.compile(r"^(COGNOME|COGNOME_CANDIDATO)$", re.I), "cognome"), + (re.compile(r"^(NOME|NOME_CANDIDATO)$", re.I), "nome"), + (re.compile(r"^(LISTA|DESCRLISTA)$", re.I), "lista"), + (re.compile(r"^(VOTI_LISTA|VOTILISTA)$", re.I), "voti_lista"), + (re.compile(r"^(VOTI_CAND(IDATO)?|VOTICAND(IDATO)?)$", re.I), "voti_candidato"), + ] + header = [ + "REGIONE", + "PROVINCIA", + "COMUNE", + "ELETTORI", + "VOTANTI", + "SCHEDE_BIANCHE", + "COGNOME_CANDIDATO", + "NOME_CANDIDATO", + "LISTA", + "VOTI_LISTA", + "DATA", + ] + names, indices = normalize_columns_map(header, real_map) + assert len(names) == 10 + assert names[0] == "regione" + assert names[-1] == "voti_lista" + assert "DATA" not in names + assert indices == [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] + + +# =========================================================================== +# decode_bytes +# =========================================================================== + + +class TestDecodeBytes: + """Contratto: decode_bytes tenta encoding in ordine, fallback utf-8 replace.""" + + def test_utf8(self) -> None: + assert decode_bytes(b"hello") == "hello" + + def test_utf8_sig(self) -> None: + """BOM UTF-8 viene rimosso da utf-8-sig.""" + data = "\ufeffCIAO".encode("utf-8") + assert decode_bytes(data) == "CIAO" + + def test_latin1_fallback(self) -> None: + data = "café".encode("latin-1") + # utf-8 decodifica latin-1 senza errori (latin-1 e' un subset di utf-8 + # per i primi 128, ma café ha byte > 127 che utf-8 non accetta) + result = decode_bytes(data) + assert result == "café" + + def test_cp1252(self) -> None: + """Caratteri speciali cp1252 (0x80-0x9F) non validi in utf-8 né latin-1.""" + # EURO SIGN € in cp1252 = 0x80 + data = bytes([0x80]) + result = decode_bytes(data) + # cp1252 e' l'ultimo tentativo prima del fallback + assert result == "€" + + def test_iso_8859_1_sequence(self) -> None: + data = "café".encode("iso-8859-1") + result = decode_bytes(data) + assert result == "café" + + def test_fallback_replace(self) -> None: + """Bytes che falliscono tutti gli encoding → utf-8 replace.""" + data = b"\xff\xfe\x00\x01" + result = decode_bytes(data) + assert isinstance(result, str) + assert len(result) > 0 # replace produce caratteri + + def test_custom_encodings(self) -> None: + data = "hello".encode("ascii") + result = decode_bytes(data, encodings=("ascii",)) + assert result == "hello" + + def test_custom_encodings_fallback(self) -> None: + data = b"\xff" + result = decode_bytes(data, encodings=("ascii",)) + # ascii fallisce → fallback utf-8 replace + assert isinstance(result, str) + + +# =========================================================================== +# decode_csv_bytes +# =========================================================================== + + +class TestDecodeCsvBytes: + """Contratto: decode_csv_bytes e' un wrapper di decode_bytes.""" + + def test_delegates_to_decode_bytes(self) -> None: + data = "a,b,c\n1,2,3".encode("utf-8") + assert decode_csv_bytes(data) == decode_bytes(data) + + def test_latin1_csv(self) -> None: + data = "nome,valore\ncaffè,123".encode("latin-1") + result = decode_csv_bytes(data) + assert "caffè" in result + + def test_utf8_sig_csv(self) -> None: + data = "\ufeffnome,valore\n1,2".encode("utf-8") + result = decode_csv_bytes(data) + assert result == "nome,valore\n1,2" # BOM stripped diff --git a/toolkit/core/normalize.py b/toolkit/core/normalize.py new file mode 100644 index 00000000..020df3e0 --- /dev/null +++ b/toolkit/core/normalize.py @@ -0,0 +1,197 @@ +"""Utility di preprocessing: normalizzazione numeri, colonne, e decodifica. + +Centralizza i pattern piu' comuni che appaiono copiati in 4-5 candidate +di dataset-incubator:: + + - normalize_number → rimpiazza 5 copie (elezioni-*, iva-regionale) + - normalize_columns_map → rimpiazza 4 copie (elezioni-*) + - decode_bytes → rimpiazza 4 copie smart_decode (elezioni-*) +""" + +from __future__ import annotations + +import re +from typing import Sequence + + +# ── normalize_number ────────────────────────────────────────────────────── + +_DEFAULT_EMPTY = frozenset({"***", "-", "N.D."}) + + +def normalize_number( + val: str, + *, + strip_dot_zero: bool = True, + empty_values: frozenset[str] | None = None, +) -> str | None: + """Normalizza un numero in formato italiano (punti migliaia, virgola decimale). + + "1.234,56" → "1234.56" + "5849,00" → "5849" (se **strip_dot_zero** = True, default) + "1234" → "1234" + "***" → None + "-" → None + "N.D." → None + + Args: + val: Stringa da normalizzare. + strip_dot_zero: Se True (default), rimuove ``.0`` finale. + empty_values: Valori considerati "vuoti" → None. + Default: ``{"***", "-", "N.D."}``. + + Returns: + Stringa normalizzata, o None se il valore e' vuoto/speciale. + """ + if not isinstance(val, str): + return None + + val = val.strip().strip('"') + if not val: + return None + + empty = empty_values if empty_values is not None else _DEFAULT_EMPTY + if val in empty: + return None + + if "," in val: + # Rimuovi punti migliaia, converti virgola in punto + val = val.replace(".", "").replace(",", ".") + if strip_dot_zero: + if val.endswith(".00"): + val = val[:-3] + elif val.endswith(".0"): + val = val[:-2] + else: + # Rimuovi punti (se ci sono — numeri con migliaia senza decimali) + val = val.replace(".", "") + + # Se dopo la normalizzazione non e' un numero valido, scarta + if not _looks_like_number(val): + return None + + return val + + +def _looks_like_number(val: str) -> bool: + """True se val e' un numero valido dopo normalizzazione.""" + if not val: + return False + # Ammetti: cifre, singolo punto decimale, leading minus + has_dot = False + for i, ch in enumerate(val): + if ch.isdigit(): + continue + if ch == "." and not has_dot: + has_dot = True + continue + if ch == "-" and i == 0: + continue + return False + return True + + +# ── normalize_columns_map ───────────────────────────────────────────────── + + +def normalize_columns_map( + header: Sequence[str], + col_map: Sequence[tuple[re.Pattern, str]], +) -> tuple[list[str], list[int]]: + """Applica una mappa di espressioni regolari alle colonne di un header. + + Ogni entry di **col_map** e' ``(pattern, nome_normalizzato)``. + Per ogni colonna in **header** viene cercato il primo pattern matching; + se matcha, la colonna viene inclusa nell'output col nome normalizzato. + + Args: + header: Lista dei nomi colonna originali. + col_map: Lista di ``(regex_pattern, nome_normalizzato)``. + L'ordine determina la priorita' — vince il primo pattern che matcha. + + Returns: + ``(nomi_normalizzati, indici_colonne_matchate)``, due liste parallele. + - **nomi_normalizzati**: i nomi di output (uno per colonna matchata). + - **indici_colonne_matchate**: la posizione originale in **header**. + + Example: + >>> col_map = [ + ... (re.compile(r"^REG(IONE)?$", re.I), "regione"), + ... (re.compile(r"^PROV(INCIA)?$", re.I), "provincia"), + ... (re.compile(r"^COMUNE$", re.I), "comune"), + ... ] + >>> normalize_columns_map(["REGIONE", "PROV", "COMUNE", "SKIP"], col_map) + (["regione", "provincia", "comune"], [0, 1, 2]) + """ + norm: list[str] = [] + indices: list[int] = [] + + for i, col in enumerate(header): + col = col.strip().strip('"') + mapped: str | None = None + for pattern, name in col_map: + if pattern.match(col): + mapped = name + break + if mapped is not None: + norm.append(mapped) + indices.append(i) + + return norm, indices + + +# ── decode_bytes ────────────────────────────────────────────────────────── + +_DEFAULT_ENCODINGS = ("utf-8-sig", "utf-8", "cp1252", "iso-8859-1") + + +def decode_bytes( + data: bytes, + encodings: tuple[str, ...] | None = None, +) -> str: + """Decodifica bytes tentando multipli encoding con fallback progressivo. + + Prova ogni encoding in ordine; al primo che non solleva + ``UnicodeDecodeError`` si ferma. Se nessuno funziona, usa ``utf-8`` + con ``errors="replace"``. + + Args: + data: Bytes da decodificare. + encodings: Tuple di encoding da tentare in ordine. + Default: ``("utf-8-sig", "utf-8", "iso-8859-1", "cp1252")``. + + Returns: + Stringa decodificata. + + Example: + >>> decode_bytes(b"hello") # utf-8 direct + 'hello' + >>> decode_bytes("café".encode("latin-1")) + 'café' + """ + encs = encodings if encodings is not None else _DEFAULT_ENCODINGS + for enc in encs: + try: + return data.decode(enc) + except UnicodeDecodeError: + continue + return data.decode("utf-8", errors="replace") + + +def decode_csv_bytes( + data: bytes, + encodings: tuple[str, ...] | None = None, +) -> str: + """Decodifica bytes CSV tentando multipli encoding. + + Come :func:`decode_bytes` ma con default ottimizzati per CSV italiani: + ``utf-8-sig`` → ``utf-8`` → ``latin-1``/``cp1252``. + + Args: + data: Bytes CSV da decodificare. + encodings: Encoding da tentare (default: stesso di decode_bytes). + + Returns: + Stringa CSV decodificata. + """ + return decode_bytes(data, encodings=encodings) diff --git a/toolkit/version.py b/toolkit/version.py index 5f3948ed..e8f5c607 100644 --- a/toolkit/version.py +++ b/toolkit/version.py @@ -1 +1 @@ -__version__ = "1.44.0" +__version__ = "1.45.0" From 2093f51345ec1e4546cf8c027607f17ef5904e85 Mon Sep 17 00:00:00 2001 From: Zio Gabber <78922322+Gabrymi93@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:10:26 +0100 Subject: [PATCH 2/2] fix(normalize): _looks_like_number richiede almeno una cifra + allinea docstring encoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _looks_like_number: aggiunto re.search(r'\d', val) per evitare che normalize_number(',') ritorni '.' o normalize_number('-') ritorni '-' - decode_bytes: docstring allineata all'ordine reale (cp1252 prima di iso-8859-1) con nota sulla preservazione di € - Test: 6 nuovi casi edge per normalize_number (',' , '-,' , '.' , '-' con empty_values custom, punteggiatura varia) - Test: commento cp1252 aggiornato - .gitignore: aggiunto .tmp/ (DuckDB temp storage) --- .gitignore | 3 +++ tests/test_normalize.py | 26 +++++++++++++++++++++++++- toolkit/core/normalize.py | 12 ++++++++++-- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index fc3a1166..50fc1325 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,9 @@ coverage.xml # Jupyter / Colab .ipynb_checkpoints/ +# DuckDB temp storage +.tmp/ + # OS .DS_Store Thumbs.db diff --git a/tests/test_normalize.py b/tests/test_normalize.py index 23670ea0..144f1d97 100644 --- a/tests/test_normalize.py +++ b/tests/test_normalize.py @@ -120,6 +120,30 @@ def test_negative_number(self) -> None: def test_negative_with_thousands(self) -> None: assert normalize_number("-1.234,56") == "-1234.56" + # ── Guard: non-numeric after cleanup ────────────────────────────── + + def test_comma_only_returns_none(self) -> None: + ""","" da solo non e' un numero → None.""" + assert normalize_number(",") is None + + def test_comma_minus_only_returns_none(self) -> None: + """ ","" con meno non e' un numero → None.""" + assert normalize_number("-,") is None + + def test_dot_only_returns_none(self) -> None: + """Punto da solo non e' un numero → None.""" + assert normalize_number(".") is None + + def test_minus_only_custom_empty_values_returns_none(self) -> None: + """Meno da solo con empty_values custom non e' un numero → None.""" + assert normalize_number("-", empty_values=frozenset()) is None + + def test_all_punctuation_returns_none(self) -> None: + """Solo punteggiatura senza cifre → None.""" + assert normalize_number("---") is None + assert normalize_number(".,.") is None + assert normalize_number("-.-") is None + # =========================================================================== # normalize_columns_map @@ -257,7 +281,7 @@ def test_cp1252(self) -> None: # EURO SIGN € in cp1252 = 0x80 data = bytes([0x80]) result = decode_bytes(data) - # cp1252 e' l'ultimo tentativo prima del fallback + # cp1252 e' tentato prima di iso-8859-1 per preservare € assert result == "€" def test_iso_8859_1_sequence(self) -> None: diff --git a/toolkit/core/normalize.py b/toolkit/core/normalize.py index 020df3e0..336585c3 100644 --- a/toolkit/core/normalize.py +++ b/toolkit/core/normalize.py @@ -74,9 +74,16 @@ def normalize_number( def _looks_like_number(val: str) -> bool: - """True se val e' un numero valido dopo normalizzazione.""" + """True se val e' un numero valido dopo normalizzazione. + + Richiede almeno una cifra — evita che stringhe non numeriche come ``","`` + o ``"-,",`` (da empty_values custom) passino la validazione. + """ if not val: return False + # Richiede almeno una cifra dopo cleanup + if not re.search(r"\d", val): + return False # Ammetti: cifre, singolo punto decimale, leading minus has_dot = False for i, ch in enumerate(val): @@ -158,7 +165,8 @@ def decode_bytes( Args: data: Bytes da decodificare. encodings: Tuple di encoding da tentare in ordine. - Default: ``("utf-8-sig", "utf-8", "iso-8859-1", "cp1252")``. + Default: ``("utf-8-sig", "utf-8", "cp1252", "iso-8859-1")``. + ``cp1252`` prima di ``iso-8859-1`` per preservare ``€`` (0x80). Returns: Stringa decodificata.