From 445a5c98d008150be0e13d760191488eb5ae804f Mon Sep 17 00:00:00 2001 From: Zio Gabber <78922322+Gabrymi93@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:16:56 +0100 Subject: [PATCH 1/3] test: skip ODS senza odfpy + gating network smoke dietro env var MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_clean_duckdb_read: i 3 test ODS usano importorskip("odf") — fallivano in env senza extra pandas (CI resta verde grazie a [dev,pandas]) - test_smoke_scout: i 4 test con URL reali (finanze.gov.it, consip.it) dietro RUN_NETWORK_SMOoke=1 come RUN_S3_SMOKE — lo step smoke CI (-m smoke) li includeva nonostante il docstring dicesse il contrario, creando dipendenza da endpoint esterni; il test puro infer_years resta smoke e gira sempre --- tests/test_smoke_scout.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/test_smoke_scout.py b/tests/test_smoke_scout.py index 6536a4a..e548bca 100644 --- a/tests/test_smoke_scout.py +++ b/tests/test_smoke_scout.py @@ -30,7 +30,6 @@ def test_scout_infer_years_real() -> None: assert infer_years("REG_tipo_reddito_2025.csv") == (2025, 2025) -@pytest.mark.smoke @needs_network def test_scout_direct_csv() -> None: """CSV diretto: probe → file, formato CSV.""" @@ -42,7 +41,6 @@ def test_scout_direct_csv() -> None: assert result["kind"] == "file" -@pytest.mark.smoke @needs_network def test_scout_html_with_links() -> None: """Pagina HTML con link CSV: probe → html, link trovati.""" @@ -55,7 +53,6 @@ def test_scout_html_with_links() -> None: assert len(result.get("candidate_links", [])) > 50 # SO registry dice 147 -@pytest.mark.smoke @needs_network def test_scout_routed_ckan_dataset() -> None: """CKAN dataset: probe_routed → ckan, risorse trovate.""" @@ -67,7 +64,6 @@ def test_scout_routed_ckan_dataset() -> None: assert len(result.get("ckan_resources") or []) > 0 -@pytest.mark.smoke @needs_network def test_scout_routed_ckan_homepage() -> None: """CKAN homepage: probe_routed → ckan_portal.""" From 80d148c0ef21c13b3ee324fbb17ba24133e08ee4 Mon Sep 17 00:00:00 2001 From: Zio Gabber <78922322+Gabrymi93@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:26:06 +0100 Subject: [PATCH 2/3] test(smoke_scout): ripristina marker smoke sui test di rete gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'audit markers richiede un marker per ogni test nei file toccati; i 4 test di rete avevano solo skipif senza marker. Stesso pattern di test_s3_parquet_smoke: @pytest.mark.smoke + skipif env var — restano collezionabili da -m smoke ma saltano senza RUN_NETWORK_SMOKE=1. --- tests/test_smoke_scout.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_smoke_scout.py b/tests/test_smoke_scout.py index e548bca..6536a4a 100644 --- a/tests/test_smoke_scout.py +++ b/tests/test_smoke_scout.py @@ -30,6 +30,7 @@ def test_scout_infer_years_real() -> None: assert infer_years("REG_tipo_reddito_2025.csv") == (2025, 2025) +@pytest.mark.smoke @needs_network def test_scout_direct_csv() -> None: """CSV diretto: probe → file, formato CSV.""" @@ -41,6 +42,7 @@ def test_scout_direct_csv() -> None: assert result["kind"] == "file" +@pytest.mark.smoke @needs_network def test_scout_html_with_links() -> None: """Pagina HTML con link CSV: probe → html, link trovati.""" @@ -53,6 +55,7 @@ def test_scout_html_with_links() -> None: assert len(result.get("candidate_links", [])) > 50 # SO registry dice 147 +@pytest.mark.smoke @needs_network def test_scout_routed_ckan_dataset() -> None: """CKAN dataset: probe_routed → ckan, risorse trovate.""" @@ -64,6 +67,7 @@ def test_scout_routed_ckan_dataset() -> None: assert len(result.get("ckan_resources") or []) > 0 +@pytest.mark.smoke @needs_network def test_scout_routed_ckan_homepage() -> None: """CKAN homepage: probe_routed → ckan_portal.""" From 6be1f39335cd91f4da9aabc915a51596aef99644 Mon Sep 17 00:00:00 2001 From: Zio Gabber <78922322+Gabrymi93@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:49:14 +0100 Subject: [PATCH 3/3] chore(scripts): rimuovi 8 script morti, sostituisci audit-imports con test di layering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rimossi (nessun riferimento da CI/Makefile/docs, o referenziano moduli/file eliminati): - audit-imports.py (referenziava core.parquet/core.artifacts, eliminati) - audit_test_markers.py (sovrapposto a conftest + marker pytest) - find-contract.sh (equivale a rg) - generate-contracts-map.py (sostituito da toolkit contract CLI + MCP) - quality/check_tests.py + legacy/strict lists (mai wired nel CI, sovrapposto alla allow-list di conftest) - usage_matrix.py (referenzia file morti: dataset.test.yml, ecc.) Tenuti: smoke_install_cli.py (usato dalla CI, testa il packaging) e pre-commit (citato da CONTRIBUTING, hook manuale senza framework). Sostituto del valore residuo di audit-imports: tests/test_layering_guard.py — test pytest che fallisce se un modulo toolkit.core.* importa un layer superiore. Rende meccanico il guard che PR #504 ha ripristinato a mano. Suite: 1317 passed, 0 falli, 23s. --- scripts/audit-imports.py | 362 -------------------------- scripts/audit_test_markers.py | 147 ----------- scripts/find-contract.sh | 73 ------ scripts/generate-contracts-map.py | 198 -------------- scripts/quality/check_tests.py | 196 -------------- scripts/quality/legacy_test_files.txt | 31 --- scripts/quality/strict_test_files.txt | 11 - scripts/usage_matrix.py | 136 ---------- tests/test_layering_guard.py | 58 +++++ 9 files changed, 58 insertions(+), 1154 deletions(-) delete mode 100644 scripts/audit-imports.py delete mode 100644 scripts/audit_test_markers.py delete mode 100755 scripts/find-contract.sh delete mode 100644 scripts/generate-contracts-map.py delete mode 100644 scripts/quality/check_tests.py delete mode 100644 scripts/quality/legacy_test_files.txt delete mode 100644 scripts/quality/strict_test_files.txt delete mode 100644 scripts/usage_matrix.py create mode 100644 tests/test_layering_guard.py diff --git a/scripts/audit-imports.py b/scripts/audit-imports.py deleted file mode 100644 index cf54012..0000000 --- a/scripts/audit-imports.py +++ /dev/null @@ -1,362 +0,0 @@ -#!/usr/bin/env python3 -"""Audit delle import: mappa chi usa cosa nei moduli centrali del toolkit. - -Scansiona tutti i file Python del toolkit (esclusi test e venv) e produce: - -1. Per ogni modulo in ``toolkit/core/``, la lista dei consumer (chi lo importa) -2. Pattern "bypass": codice che fa cose già centralizzate in core ma non le usa - -Uso: - python scripts/audit-imports.py # stampa tutto - python scripts/audit-imports.py --consumer cli # solo consumer in cli/ - python scripts/audit-imports.py --provider core # solo cosa importa da core/ - python scripts/audit-imports.py --bypass # solo pattern bypass - python scripts/audit-imports.py --markdown # output in markdown -""" - -from __future__ import annotations - -import ast -import os -import re -import sys -from collections import defaultdict -from pathlib import Path -from typing import Any - -TOOLKIT_DIR = Path(__file__).resolve().parent.parent - -# Moduli centrali (provider) -CORE_MODULES = [ - "toolkit.core", - "toolkit.core.paths", - "toolkit.core.sql_utils", - "toolkit.core.io", - "toolkit.core.csv_read", - "toolkit.core.parquet", - "toolkit.core.config", - "toolkit.core.metadata", - "toolkit.core.validation", - "toolkit.core.run_records", - "toolkit.core.run_context", - "toolkit.core.template", - "toolkit.core.support", - "toolkit.core.column_rules", - "toolkit.core.layer_profile", - "toolkit.core.artifacts", - "toolkit.core.registry", - "toolkit.core.multi_year_source", - "toolkit.core.exceptions", - "toolkit.core.logging", -] - -# Pattern di bypass da rilevare (patrono → suggerimento) -BYPASS_PATTERNS: list[tuple[str, str, str]] = [ - # DuckDB connection - ( - r"duckdb\.connect\(", - "usa safe_connect() da lab_connectors.duckdb invece di duckdb.connect() diretto", - "duckdb.connect diretto", - ), - # JSON I/O - ( - r"(? list[dict[str, Any]]: - """Estrae tutte le import da un file Python.""" - imports: list[dict[str, Any]] = [] - try: - source = filepath.read_text(encoding="utf-8") - tree = ast.parse(source) - except (SyntaxError, UnicodeDecodeError): - return imports - - for node in ast.walk(tree): - # import X.Y.Z - if isinstance(node, ast.Import): - for alias in node.names: - imports.append( - { - "type": "import", - "module": alias.name, - "name": alias.asname or alias.name, - "line": node.lineno, - } - ) - # from X.Y import Z - elif isinstance(node, ast.ImportFrom): - if node.module: - for alias in node.names: - imports.append( - { - "type": "from", - "module": node.module, - "name": alias.name, - "asname": alias.asname, - "line": node.lineno, - } - ) - return imports - - -def scan_bypass_patterns(filepath: Path) -> list[dict[str, Any]]: - """Cerca pattern di bypass in un file.""" - findings: list[dict[str, Any]] = [] - try: - source = filepath.read_text(encoding="utf-8") - except (UnicodeDecodeError, OSError): - return findings - - for i, line in enumerate(source.split("\n"), 1): - for pattern, suggestion, label in BYPASS_PATTERNS: - if re.search(pattern, line): - findings.append( - { - "line": i, - "pattern": label, - "code": line.strip()[:100], - "suggestion": suggestion, - } - ) - return findings - - -def module_short(module: str) -> str: - """Rende un modulo leggibile: toolkit.core.paths → core/paths.""" - return module.replace("toolkit.", "").replace(".", "/") - - -def run_audit( - consumer_filter: str | None = None, - provider_filter: str | None = None, - only_bypass: bool = False, - output_markdown: bool = False, -) -> str: - # Raccogli tutti i file Python del toolkit - py_files: list[Path] = [] - for root, dirs, files in os.walk(TOOLKIT_DIR / "toolkit"): - dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS] - for f in files: - if f.endswith(".py") and f not in EXCLUDE_FILES: - py_files.append(Path(root) / f) - - # Raccogli import per file - file_imports: dict[str, list[dict[str, Any]]] = {} - for fp in py_files: - rel = fp.relative_to(TOOLKIT_DIR).as_posix() - imports = scan_imports(fp) - if imports: - file_imports[rel] = imports - - # Mappa inversa: modulo_core → [consumer] - consumer_map: dict[str, list[tuple[str, int, str]]] = defaultdict(list) - for filepath, imports in file_imports.items(): - for imp in imports: - mod = imp["module"] - # match qualsiasi modulo che inizi con toolkit.core - for core_mod in CORE_MODULES: - if mod == core_mod or mod.startswith(core_mod + "."): - consumer_map[core_mod].append( - ( - filepath, - imp["line"], - imp["name"], - ) - ) - - # Bypass per file - bypass_by_file: dict[str, list[dict[str, Any]]] = {} - for fp in py_files: - rel = fp.relative_to(TOOLKIT_DIR).as_posix() - findings = scan_bypass_patterns(fp) - if findings: - bypass_by_file[rel] = findings - - # Genera output - result: list[str] = [] - - if output_markdown: - result.append("# Audit import toolkit core\n") - result.append(f"Generato da `scripts/audit-imports.py` su {len(py_files)} file.\n") - else: - result.append(f"Audit import toolkit core — {len(py_files)} file scansionati\n") - result.append("=" * 60 + "\n") - - if not only_bypass: - # --- Sezione 1: Consumer per provider --- - if output_markdown: - result.append("## Consumer per modulo core\n") - else: - result.append("CONSUMER PER MODULO CORE\n") - result.append("-" * 40) - - for core_mod in sorted(CORE_MODULES): - consumers = consumer_map.get(core_mod, []) - if provider_filter and provider_filter not in core_mod: - continue - - if output_markdown: - result.append(f"\n### `{module_short(core_mod)}` ({len(consumers)} consumer)\n") - else: - result.append(f"\n{module_short(core_mod)} ({len(consumers)} import)") - - if not consumers: - result.append(" (nessun consumer)") - continue - - # Raggruppa per file consumer - by_file: dict[str, list[tuple[int, str]]] = defaultdict(list) - for filepath, line, name in consumers: - if consumer_filter and consumer_filter not in filepath: - continue - by_file[filepath].append((line, name)) - - for filepath in sorted(by_file): - # meglio: elenca ciò che importa - imported = sorted(set(n for _, n in by_file[filepath])) - if output_markdown: - result.append(f" - `{filepath}` → {', '.join(f'`{n}`' for n in imported)}") - else: - result.append(f" {filepath}") - for n in imported: - result.append(f" - {n}") - - # --- Sezione 2: File che non importano mai core (segnali) --- - non_consumers: list[str] = [] - for fp in py_files: - rel = fp.relative_to(TOOLKIT_DIR).as_posix() - if consumer_filter and consumer_filter not in rel: - continue - imports = file_imports.get(rel, []) - imports_core = [ - i - for i in imports - if any(i["module"] == m or i["module"].startswith(m + ".") for m in CORE_MODULES) - ] - if not imports_core and rel.startswith("toolkit"): - non_consumers.append(rel) - - if non_consumers: - if output_markdown: - result.append(f"\n### File che NON importano core ({len(non_consumers)})\n") - for f in sorted(non_consumers): - result.append(f" - `{f}`") - else: - result.append(f"\nFILE CHE NON IMPORTANO CORE ({len(non_consumers)})\n") - result.append("-" * 40) - for f in sorted(non_consumers): - result.append(f" {f}") - - # --- Sezione 3: Pattern bypass --- - if bypass_by_file: - if output_markdown: - result.append("\n## Pattern bypass (non usa contratti core)\n") - else: - result.append("\nPATTERN BYPASS (non usa contratti core)\n") - result.append("-" * 40) - - for filepath in sorted(bypass_by_file): - if consumer_filter and consumer_filter not in filepath: - continue - findings = bypass_by_file[filepath] - if output_markdown: - result.append(f"\n### `{filepath}` ({len(findings)})\n") - for f in findings: - result.append(f" - Riga {f['line']}: `{f['code']}`") - result.append(f" → {f['suggestion']}") - else: - result.append(f"\n{filepath}") - for f in findings: - result.append(f" Riga {f['line']:4d} | {f['pattern']}") - result.append(f" {f['code']}") - result.append(f" → {f['suggestion']}") - - total_imports = sum(len(v) for v in consumer_map.values()) - total_bypass = sum(len(v) for v in bypass_by_file.values()) - - if output_markdown: - result.append( - f"\n---\n**Riepilogo**: {total_imports} import da core, {total_bypass} pattern bypass in {len(bypass_by_file)} file.\n" - ) - else: - result.append( - f"\n--- Riepilogo: {total_imports} import da core, {total_bypass} bypass in {len(bypass_by_file)} file ---\n" - ) - - return "\n".join(result) - - -def main(): - flags = set(a for a in sys.argv[1:] if a.startswith("--")) - - consumer_filter = None - provider_filter = None - if "consumer" in str(sys.argv): - idx = next(i for i, a in enumerate(sys.argv) if a == "--consumer") - if idx + 1 < len(sys.argv): - consumer_filter = sys.argv[idx + 1] - if "provider" in str(sys.argv): - idx = next(i for i, a in enumerate(sys.argv) if a == "--provider") - if idx + 1 < len(sys.argv): - provider_filter = sys.argv[idx + 1] - - output = run_audit( - consumer_filter=consumer_filter, - provider_filter=provider_filter, - only_bypass="--bypass" in flags, - output_markdown="--markdown" in flags, - ) - print(output) - - -if __name__ == "__main__": - main() diff --git a/scripts/audit_test_markers.py b/scripts/audit_test_markers.py deleted file mode 100644 index c8c622a..0000000 --- a/scripts/audit_test_markers.py +++ /dev/null @@ -1,147 +0,0 @@ -#!/usr/bin/env python3 -"""Audit test markers in toolkit/tests/. - -Non-blocking. Lists tests that lack any of the Lab-wide markers: -contract, policy, regression, adapter, pure_unit, smoke - -Usage: - python scripts/audit_test_markers.py # all tests - python scripts/audit_test_markers.py --fix # print marker lines to add - python scripts/audit_test_markers.py --json # machine-readable output -""" - -from __future__ import annotations - -import ast -import json -import re -import sys -from pathlib import Path - -MARKERS = {"contract", "policy", "regression", "adapter", "pure_unit", "smoke"} -#: ast.unparse returns 'pytest.mark.contract' (no @), so match after 'mark.' -_MARKER_RE = re.compile(r"mark\.(\w+)") - - -class TestCollector(ast.NodeVisitor): - """Collect test functions and their markers via AST.""" - - def __init__(self) -> None: - self.results: list[dict] = [] - - def visit_Module(self, node: ast.Module) -> None: - for stmt in ast.iter_child_nodes(node): - if isinstance(stmt, ast.FunctionDef) and stmt.name.startswith("test_"): - self._visit_test_function(stmt) - elif isinstance(stmt, ast.ClassDef): - for child in ast.iter_child_nodes(stmt): - if isinstance(child, ast.FunctionDef) and child.name.startswith("test_"): - self._visit_test_function(child) - self.generic_visit(node) - - def _visit_test_function(self, node: ast.FunctionDef) -> None: - markers: set[str] = set() - for decorator in reversed(node.decorator_list): # reversed = bottom to top - marker = self._get_marker_name(decorator) - if marker: - markers.add(marker) - - # Detect parametrize for informational note - parametrized = any( - isinstance(d, ast.Call) - and isinstance(d.func, ast.Attribute) - and d.func.attr == "parametrize" - or isinstance(d, ast.Attribute) - and d.attr == "parametrize" - for d in node.decorator_list - ) - - self.results.append( - { - "test": node.name, - "markers": sorted(markers & MARKERS), - "missing": sorted(MARKERS - markers), - "unmarked": not bool(markers & MARKERS), - "parametrized": parametrized, - } - ) - - def _get_marker_name(self, node: ast.expr) -> str | None: - """Extract Lab marker name from a decorator via ast.unparse + regex.""" - try: - src = ast.unparse(node) - m = _MARKER_RE.search(src) - if m and m.group(1) in MARKERS: - return m.group(1) - except Exception: - pass - return None - - -def collect_tests(tests_dir: Path) -> list[dict]: - results = [] - for fpath in sorted(tests_dir.glob("test_*.py")): - if fpath.name == "conftest.py": - continue - try: - tree = ast.parse(fpath.read_text(), filename=fpath.name) - except SyntaxError: - continue - visitor = TestCollector() - visitor.visit(tree) - for r in visitor.results: - r["file"] = fpath.name - results.append(r) - return results - - -def main() -> None: - scripts_dir = Path(__file__).parent - toolkit_root = scripts_dir.parent - tests_dir = toolkit_root / "tests" - - results = collect_tests(tests_dir) - - unmarked = [r for r in results if r["unmarked"]] - marked = [r for r in results if not r["unmarked"]] - - mode = "json" if "--json" in sys.argv else "fix" if "--fix" in sys.argv else "text" - - if mode == "json": - print( - json.dumps( - { - "total": len(results), - "marked": len(marked), - "unmarked": len(unmarked), - "tests": results, - }, - indent=2, - ) - ) - return - - print("=== Test Marker Audit ===") - print(f"Total: {len(results)} | Marked: {len(marked)} | Unmarked: {len(unmarked)}") - print() - - if unmarked: - print(f"--- UNMARKED TESTS ({len(unmarked)}) ---") - for r in unmarked: - note = " (parametrized)" if r["parametrized"] else "" - print(f" {r['file']}::{r['test']}{note}") - print() - - if unmarked and mode == "fix": - print("--- FIX: add these markers ---") - for r in unmarked: - print(f"# {r['file']}::{r['test']}") - print(" @pytest.mark.pure_unit # TODO: pick correct marker") - print(f" def {r['test']}(self): ...") - - if not unmarked: - print("All tests have markers. OK") - - -if __name__ == "__main__": - main() diff --git a/scripts/find-contract.sh b/scripts/find-contract.sh deleted file mode 100755 index 1672e21..0000000 --- a/scripts/find-contract.sh +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env bash -# find-contract.sh — cerca una funzione/contratto nei moduli centrali del toolkit. -# -# Uso: -# ./scripts/find-contract.sh sql_literal # cerca "sql_literal" per nome -# ./scripts/find-contract.sh truncate # cerca "truncate" nel nome o docstring -# ./scripts/find-contract.sh -d "quote" # cerca solo nelle definizioni (def/class) -# -# I moduli scanditi sono quelli di toolkit/core/, toolkit/plugins/_http_utils, -# toolkit/profile/. Se non trovi lì, probabilmente non esiste come contratto centrale. -# -# Per una mappa completa: ./scripts/generate-contracts-map.py - -set -euo pipefail - -TOOLKIT_DIR="$(cd "$(dirname "$0")/.." && pwd)" -SEARCH_DIRS=( - "$TOOLKIT_DIR/toolkit/core" - "$TOOLKIT_DIR/toolkit/plugins/_http_utils.py" - "$TOOLKIT_DIR/toolkit/profile/raw.py" -) - -pattern="${1:-}" -if [ -z "$pattern" ]; then - echo "Uso: $0 [-d] " - echo " -d cerca solo nelle definizioni (def/class)" - exit 1 -fi - -only_defs=false -if [ "$1" = "-d" ]; then - only_defs=true - pattern="${2:-}" - if [ -z "$pattern" ]; then - echo "Uso: $0 -d " - exit 1 - fi -fi - -found=0 -for dir in "${SEARCH_DIRS[@]}"; do - if [ -f "$dir" ]; then - targets=("$dir") - else - targets=("$dir"/*.py) - fi - - for file in "${targets[@]}"; do - [ -f "$file" ] || continue - basename="${file#$TOOLKIT_DIR/}" - - if [ "$only_defs" = true ]; then - matches=$(grep -n "^\(async \)\?\(def \|class \)" "$file" \ - | grep -i "$pattern" || true) - else - matches=$(grep -i "$pattern" "$file" || true) - fi - - if [ -n "$matches" ]; then - echo "━━━ $basename" - echo "$matches" - echo "" - found=$((found + 1)) - fi - done -done - -if [ "$found" -eq 0 ]; then - echo "Nessun risultato per '$pattern' nei contratti centrali." - echo "Suggerimento: se stai cercando una funzione che non esiste," - echo "valuta se aggiungerla in toolkit/core/ invece che localmente." - exit 1 -fi diff --git a/scripts/generate-contracts-map.py b/scripts/generate-contracts-map.py deleted file mode 100644 index 8fe2d8f..0000000 --- a/scripts/generate-contracts-map.py +++ /dev/null @@ -1,198 +0,0 @@ -#!/usr/bin/env python3 -"""Generate CONTRACTS.md — mappa delle funzioni pubbliche nei moduli centrali. - -Scansiona toolkit/core/ (esclusi __init__, config_models, manifest), -toolkit/plugins/_http_utils.py e toolkit/profile/raw.py. -Per ogni funzione/class pubblica (non ``_``-prefissa) estrae nome, -firma e prima riga di docstring. - -Uso: - python scripts/generate-contracts-map.py # stampa su stdout - python scripts/generate-contracts-map.py --write # sovrascrive CONTRACTS.md -""" - -from __future__ import annotations - -import ast -import pathlib -import sys - -TOOLKIT_DIR = pathlib.Path(__file__).resolve().parent.parent - -# Moduli da scandire: path relativo al toolkit -SCAN_PATHS: list[str] = [ - "toolkit/core/*.py", - "toolkit/plugins/_http_utils.py", - "toolkit/profile/raw.py", -] - -EXCLUDE_FILES = { - "__init__.py", - "manifest.py", -} - -EXCLUDE_DIRS = { - "config_models", -} - - -def _first_docstring(node: ast.AST) -> str: - """Estrae la prima riga della docstring da un body.""" - if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): - return "" - if not node.body: - return "" - first = node.body[0] - if isinstance(first, ast.Expr) and isinstance(first.value, ast.Constant): - text = first.value.value - if isinstance(text, str): - # prima riga significativa - line = text.strip().split("\n")[0] - if len(line) > 80: - line = line[:77] + "..." - return line - return "" - - -def _is_public(name: str) -> bool: - return not name.startswith("_") - - -def scan_file(filepath: pathlib.Path) -> list[dict]: - entries: list[dict] = [] - - try: - source = filepath.read_text(encoding="utf-8") - except Exception: - return entries - - try: - tree = ast.parse(source) - except SyntaxError: - return entries - - rel_path = filepath.relative_to(TOOLKIT_DIR).as_posix() - - for node in ast.walk(tree): - if isinstance(node, ast.FunctionDef) and _is_public(node.name): - doc = _first_docstring(node) - args = ast.unparse(node.args) if hasattr(node, "args") else "(...)" - # pulisci args: rimuovi self/ - if args.startswith("self"): - args = args[4:].lstrip(", ") - sig = f"{node.name}({args})" - entries.append( - { - "kind": "fun", - "name": node.name, - "signature": sig, - "doc": doc, - "file": rel_path, - "line": node.lineno, - } - ) - elif isinstance(node, ast.ClassDef) and _is_public(node.name): - doc = _first_docstring(node) - entries.append( - { - "kind": "class", - "name": node.name, - "signature": node.name, - "doc": doc, - "file": rel_path, - "line": node.lineno, - } - ) - # metodi pubblici della classe - for item in node.body: - if ( - isinstance(item, ast.FunctionDef) - and _is_public(item.name) - and item.name != "__init__" - ): - mdoc = _first_docstring(item) - margs = ast.unparse(item.args) if hasattr(item, "args") else "(...)" - # skip self - if margs.startswith("self"): - margs = margs[4:].lstrip(", ") - entries.append( - { - "kind": "method", - "name": f" {item.name}", - "signature": f" {item.name}({margs})", - "doc": mdoc, - "file": rel_path, - "line": item.lineno, - } - ) - - return entries - - -def generate() -> str: - lines: list[str] = [] - lines.append("# Mappa dei contratti centrali del toolkit") - lines.append("") - lines.append("Generata automaticamente da `scripts/generate-contracts-map.py`.") - lines.append("Elenco delle funzioni pubbliche nei moduli centrali (`toolkit/core/`,") - lines.append("`toolkit/plugins/_http_utils.py`, `toolkit/profile/raw.py`).") - lines.append("") - lines.append("Consulta questa mappa **prima** di implementare una nuova funzione") - lines.append("per verificare se il contratto esiste già.") - lines.append("") - lines.append("Totale: {count} funzioni/metodi in {files} file.") - lines.append("") - - # Raccogli per file - all_entries: list[dict] = [] - all_files: set[str] = set() - - for pattern in SCAN_PATHS: - for path in TOOLKIT_DIR.glob(pattern): - if path.name in EXCLUDE_FILES: - continue - if path.parent.name in EXCLUDE_DIRS: - continue - if not path.is_file() or path.suffix != ".py": - continue - - entries = scan_file(path) - all_entries.extend(entries) - if entries: - all_files.add(path.relative_to(TOOLKIT_DIR).as_posix()) - - # Raggruppa per file - by_file: dict[str, list[dict]] = {} - for e in all_entries: - by_file.setdefault(e["file"], []).append(e) - - for filepath in sorted(by_file): - lines.append(f"## `{filepath}`") - lines.append("") - for e in by_file[filepath]: - if e["doc"]: - lines.append(f"- **{e['signature']}** — {e['doc']}") - else: - lines.append(f"- **{e['signature']}**") - lines.append("") - - result = "\n".join(lines) - result = result.replace("{count}", str(len(all_entries))) - result = result.replace("{files}", str(len(all_files))) - - return result - - -def main(): - output = generate() - - if "--write" in sys.argv: - dest = TOOLKIT_DIR / "CONTRACTS.md" - dest.write_text(output, encoding="utf-8") - print(f"Scritto {dest} ({len(output)} byte)") - else: - print(output) - - -if __name__ == "__main__": - main() diff --git a/scripts/quality/check_tests.py b/scripts/quality/check_tests.py deleted file mode 100644 index 77ae99e..0000000 --- a/scripts/quality/check_tests.py +++ /dev/null @@ -1,196 +0,0 @@ -#!/usr/bin/env python3 -"""Test quality ratchet — single entry point for CI and developers. - -Usage: - python scripts/quality/check_tests.py --strict # fails on ratchet violations - python scripts/quality/check_tests.py # advisory (always passes) - -Strict mode fails if: - 1. A strict file contains unmarked tests - 2. A NEW test file (not in legacy) has any unmarked tests - -It does NOT fail on unmarked legacy tests. -""" - -from __future__ import annotations - -import argparse -import ast -import json -import sys -from pathlib import Path - -MARKERS = {"contract", "policy", "regression", "adapter", "pure_unit", "smoke"} -_MARKER_RE = __import__("re").compile(r"mark\.(\w+)") - - -class TestCollector(ast.NodeVisitor): - def visit_Module(self, node: ast.Module) -> None: - for stmt in ast.iter_child_nodes(node): - if isinstance(stmt, ast.FunctionDef) and stmt.name.startswith("test_"): - self._visit_test_function(stmt) - elif isinstance(stmt, ast.ClassDef): - for child in ast.iter_child_nodes(stmt): - if isinstance(child, ast.FunctionDef) and child.name.startswith("test_"): - self._visit_test_function(child) - self.generic_visit(node) - - def _visit_test_function(self, node: ast.FunctionDef) -> None: - markers: set[str] = set() - for decorator in reversed(node.decorator_list): - try: - src = ast.unparse(decorator) - m = _MARKER_RE.search(src) - if m and m.group(1) in MARKERS: - markers.add(m.group(1)) - except Exception: - pass - self.results.append( - { - "test": node.name, - "markers": sorted(markers & MARKERS), - "unmarked": not bool(markers & MARKERS), - } - ) - - def __init__(self) -> None: - self.results: list[dict] = [] - - -def collect_tests(tests_dir: Path) -> list[dict]: - results = [] - for fpath in sorted(tests_dir.glob("test_*.py")): - if fpath.name == "conftest.py": - continue - try: - tree = ast.parse(fpath.read_text(), filename=fpath.name) - except SyntaxError: - continue - visitor = TestCollector() - visitor.visit(tree) - for r in visitor.results: - r["file"] = fpath.name - results.append(r) - return results - - -def main() -> None: - parser = argparse.ArgumentParser(prog="scripts/quality/check_tests.py") - parser.add_argument( - "--strict", - action="store_true", - help="Fail on ratchet violations (strict mode)", - ) - parser.add_argument( - "--json", - action="store_true", - help="Machine-readable output", - ) - parser.add_argument( - "--quiet", - action="store_true", - help="Only print status line", - ) - args = parser.parse_args() - - toolkit_root = Path(__file__).parent.parent.parent - tests_dir = toolkit_root / "tests" - scripts_dir = Path(__file__).parent - - # Load strict and legacy file lists - strict_files = _read_file_list(scripts_dir / "strict_test_files.txt") - legacy_files = _read_file_list(scripts_dir / "legacy_test_files.txt") - - results = collect_tests(tests_dir) - - # Aggregate by file - file_results: dict[str, dict] = {} - for r in results: - fname = r["file"] - if fname not in file_results: - file_results[fname] = {"marked": 0, "unmarked": 0, "tests": []} - file_results[fname]["tests"].append(r) - if r["unmarked"]: - file_results[fname]["unmarked"] += 1 - else: - file_results[fname]["marked"] += 1 - - total = len(results) - marked = sum(1 for r in results if not r["unmarked"]) - unmarked = total - marked - strict_failures: list[str] = [] - new_unclassified_files: list[str] = [] - - for fname, fr in file_results.items(): - if fname in strict_files: - if fr["unmarked"] > 0: - strict_failures.append(f" {fname}: {fr['unmarked']} unmarked test(s)") - elif fname not in legacy_files: - # New test file — ALL tests must be marked, partial marking is not acceptable - if fr["unmarked"] > 0: - new_unclassified_files.append(f" {fname}") - - status = "ok" - if args.strict: - if strict_failures: - status = "strict_fail" - elif new_unclassified_files: - status = "new_unclassified_fail" - - if args.json: - output = { - "total_tests": total, - "marked_tests": marked, - "unmarked_tests": unmarked, - "strict_files": len(strict_files), - "strict_failures": len(strict_failures), - "new_unclassified_files": len(new_unclassified_files), - "strict_failure_list": strict_failures, - "new_unclassified_file_list": new_unclassified_files, - "status": status, - } - print(json.dumps(output, indent=2)) - return - - # Human-readable output - if not args.quiet: - print("test quality ratchet") - print(f" total_tests: {total}") - print(f" marked_tests: {marked}") - print(f" unmarked_tests: {unmarked}") - print(f" strict_files: {len(strict_files)}") - print(f" strict_failures: {len(strict_failures)}") - print(f" new_unclassified_files: {len(new_unclassified_files)}") - if strict_failures: - print(" STRICT FAILURES:") - for f in strict_failures: - print(f) - if new_unclassified_files: - print(" NEW UNCLASSIFIED FILES (strict mode):") - for f in new_unclassified_files: - print(f) - - status_line = f"status: {status}" - print(status_line) - - # Exit code - if args.strict and status != "ok": - sys.exit(1) - - -def _read_file_list(path: Path) -> set[str]: - """Read file list, normalize to just filenames (no directory prefix).""" - if not path.exists(): - return set() - result: set[str] = set() - for line in path.read_text().splitlines(): - stripped = line.strip() - if not stripped: - continue - # Normalize: "tests/foo.py" -> "foo.py", "foo.py" -> "foo.py" - result.add(Path(stripped).name) - return result - - -if __name__ == "__main__": - main() diff --git a/scripts/quality/legacy_test_files.txt b/scripts/quality/legacy_test_files.txt deleted file mode 100644 index 75f4c80..0000000 --- a/scripts/quality/legacy_test_files.txt +++ /dev/null @@ -1,31 +0,0 @@ -test_batch_cli.py -test_ckan_plugin.py -test_clean_input_selection.py -test_cli_inspect_paths.py -test_cli_inspect_schema_diff.py -test_cli_path_contract.py -test_cli_profile.py -test_cli_resume.py -test_cli_scout_url.py -test_cli_status.py -test_cmd_batch.py -test_config_helpers.py -test_csv_read_option_strings.py -test_extractors.py -test_http_file_plugin.py -test_logging_context.py -test_mcp_server.py -test_mcp_toolkit_client.py -test_metadata_hash.py -test_pipeline_integration.py -test_raw_ext_inference.py -test_raw_profile_hints.py -test_raw_run_format_args.py -test_registry.py -test_run_context.py -test_run_validation_gate.py -test_sdmx_plugin.py -test_sparql_plugin.py -test_sql_utils.py -test_support.py -test_template.py diff --git a/scripts/quality/strict_test_files.txt b/scripts/quality/strict_test_files.txt deleted file mode 100644 index 1905bc3..0000000 --- a/scripts/quality/strict_test_files.txt +++ /dev/null @@ -1,11 +0,0 @@ -tests/test_cmd_url_inspect.py -tests/test_config_loading.py -tests/test_sql_dry_run.py -tests/test_clean_duckdb_read.py -tests/test_run_dry_run.py -tests/test_validate_layers.py -tests/test_paths.py -tests/test_csv_read.py -tests/test_profile_sniff.py -tests/test_clean_csv_columns.py -tests/test_validate_rules.py diff --git a/scripts/usage_matrix.py b/scripts/usage_matrix.py deleted file mode 100644 index 2b1eb0a..0000000 --- a/scripts/usage_matrix.py +++ /dev/null @@ -1,136 +0,0 @@ -from __future__ import annotations - -import argparse -from pathlib import Path - - -TARGETS = { - "dataset.test.yml": "dataset.test.yml", - "clean_ispra_test.sql": "clean_ispra_test.sql", -} - -SKIP_DIRS = { - ".git", - ".pytest_cache", - ".ruff_cache", - ".mypy_cache", - "__pycache__", - "dataciviclab_toolkit.egg-info", -} - -TEXT_EXTS = { - ".py", - ".md", - ".toml", - ".yml", - ".yaml", - ".json", - ".ini", - ".cfg", - ".txt", - ".sql", - ".gitignore", - ".editorconfig", -} - - -def _is_text_file(path: Path) -> bool: - return path.suffix.lower() in TEXT_EXTS or path.name in {".gitignore", ".editorconfig"} - - -def _iter_repo_files(root: Path) -> list[Path]: - files: list[Path] = [] - self_path = Path(__file__).resolve() - for path in root.rglob("*"): - if any(part in SKIP_DIRS for part in path.parts): - continue - if path.resolve() == self_path: - continue - if path.is_file() and _is_text_file(path): - files.append(path) - return sorted(files) - - -def _scan_file(path: Path, root: Path) -> dict[str, list[tuple[str, str]]]: - matches: dict[str, list[tuple[str, str]]] = {label: [] for label in TARGETS} - try: - text = path.read_text(encoding="utf-8") - except UnicodeDecodeError: - text = path.read_text(encoding="utf-8", errors="replace") - - for lineno, line in enumerate(text.splitlines(), start=1): - for label, needle in TARGETS.items(): - if needle in line: - relpath = path.relative_to(root).as_posix() - matches[label].append((f"{relpath}:{lineno}", line.strip())) - return {label: refs for label, refs in matches.items() if refs} - - -def build_report(root: Path) -> str: - references: dict[str, list[tuple[str, str]]] = {label: [] for label in TARGETS} - - for path in _iter_repo_files(root): - file_matches = _scan_file(path, root) - for label, refs in file_matches.items(): - references[label].extend(refs) - - lines: list[str] = [] - lines.append("# Dev Usage Matrix") - lines.append("") - lines.append(f"Scanned root: `{root}`") - lines.append("") - - for label in TARGETS: - lines.append(f"## `{label}`") - lines.append("") - refs = references[label] - if not refs: - lines.append("_No references found._") - lines.append("") - continue - - lines.append("| Referencing File | Snippet |") - lines.append("| --- | --- |") - for location, snippet in refs: - escaped = snippet.replace("|", "\\|").replace("`", "\\`") - lines.append(f"| `{location}` | `{escaped}` |") - lines.append("") - - return "\n".join(lines) + "\n" - - -def main() -> int: - parser = argparse.ArgumentParser( - description="Generate an internal usage matrix for selected files/symbols." - ) - parser.add_argument( - "--root", - type=Path, - default=Path(__file__).resolve().parents[1], - help="Repository root to scan.", - ) - parser.add_argument( - "--write", - type=Path, - default=None, - help="Optional path to write the markdown report instead of stdout.", - ) - args = parser.parse_args() - - root = args.root.resolve() - report = build_report(root) - - if args.write is not None: - out = args.write - if not out.is_absolute(): - out = root / out - out.parent.mkdir(parents=True, exist_ok=True) - out.write_text(report, encoding="utf-8") - else: - print(report, end="") - - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/test_layering_guard.py b/tests/test_layering_guard.py new file mode 100644 index 0000000..c151aa0 --- /dev/null +++ b/tests/test_layering_guard.py @@ -0,0 +1,58 @@ +"""Guard di layering: core/ non deve importare layer superiori. + +Contratto architetturale (README): core = infrastruttura pura al fondo; +sopra stanno domain (orchestration), raw/clean/mart (layer pipeline), +plugins, scout, scaffold, cli, mcp. Un import core→superiore è +un'inversione — storicamente nascosta da import function-local +(6 inversioni rimosse in PR #504). + +Sostituisce lo script manuale scripts/audit-imports.py. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.policy + +_PKG = Path(__file__).resolve().parent.parent / "toolkit" +_UPPER = ( + "toolkit.clean", + "toolkit.mart", + "toolkit.raw", + "toolkit.domain", + "toolkit.scout", + "toolkit.scaffold", + "toolkit.plugins", + "toolkit.cli", + "toolkit.mcp", +) + + +def _imported_modules(tree: ast.AST) -> list[tuple[str, int]]: + mods: list[tuple[str, int]] = [] + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: + mods.append((node.module, node.lineno)) + elif isinstance(node, ast.Import): + mods.extend((alias.name, node.lineno) for alias in node.names) + return mods + + +def test_core_does_not_import_upper_layers() -> None: + """Nessun modulo toolkit.core.* importa layer superiori.""" + violations: list[str] = [] + for f in sorted((_PKG / "core").rglob("*.py")): + mod = ".".join(f.with_suffix("").parts) + for imported, lineno in _imported_modules(ast.parse(f.read_text(encoding="utf-8"))): + for upper in _UPPER: + if imported == upper or imported.startswith(upper + "."): + violations.append(f"{mod}:{lineno} -> {imported}") + assert not violations, ( + "Inversioni di layering core→superiore:\n" + + "\n".join(violations) + + "\n(Muovere il modulo nel layer giusto, come in PR #504.)" + )