fix: complete P2 Task 2 schema machine contracts

This commit is contained in:
2026-08-11 05:38:25 +02:00
parent b235132d89
commit 554de4ff2f
4 changed files with 356 additions and 30 deletions
+86 -17
View File
@@ -1,6 +1,7 @@
# ruff: noqa: BLE001, S110, B008
import logging
from pathlib import Path
from typing import TypedDict
import typer
@@ -124,13 +125,15 @@ def introspect_cmd(
class _MachineSchemaError(Exception):
"""An expected schema CLI failure with a stable public code."""
def __init__(self, code: str):
def __init__(self, code: str, detail: str = ""):
self.code = code
self.detail = detail
super().__init__(code)
_MAX_STAGED_SQL_BYTES = 1 << 20
_MAX_STAGED_SQL_TOTAL = 16 << 20
_MAX_STAGED_SQL_FILES = 32
def _schema_json(payload: dict) -> None:
@@ -170,7 +173,10 @@ def _staged_sql_files(inputs: list[Path] | None) -> list[Path]:
else:
raise _MachineSchemaError("staged_sql_invalid")
# Resolve only for ordering; content remains read from the caller's staged path.
return sorted(set(files), key=lambda path: path.resolve().as_posix())
ordered = sorted(set(files), key=lambda path: path.resolve().as_posix())
if len(ordered) > _MAX_STAGED_SQL_FILES:
raise _MachineSchemaError("staged_sql_too_many")
return ordered
def _read_staged_sql(inputs: list[Path] | None) -> tuple[list[Path], list[str]]:
@@ -192,12 +198,43 @@ def _read_staged_sql(inputs: list[Path] | None) -> tuple[list[Path], list[str]]:
return paths, contents
class CandidateForeignKey(TypedDict):
columns: list[str]
ref_table: str
ref_columns: list[str]
class FKCandidate(TypedDict):
table: str
foreign_keys: list[CandidateForeignKey]
class SuggestFksResult(TypedDict):
status: str
code: str
candidates: list[FKCandidate]
candidate_count: int
candidate_digest: str
orphan_count: int
staged_sql_count: int
mined_join_count: int
ambiguous_columns: list[str]
class CheckSchemaResult(TypedDict):
status: str
code: str
orphan_count: int
orphans: list[str]
ignored: list[str]
def _candidate_key(fk) -> tuple:
return (tuple(fk.columns), fk.ref_table, tuple(fk.ref_columns))
def suggest_fks_data(config: Path, *, from_sql: list[Path] | None = None,
assume: list[str] | None = None) -> dict:
assume: list[str] | None = None) -> SuggestFksResult:
"""Return deterministic FK candidates without reviewing or mutating annotations."""
import hashlib
import json
@@ -215,7 +252,7 @@ def suggest_fks_data(config: Path, *, from_sql: list[Path] | None = None,
for value in assume or []:
col, sep, ref = value.partition("=")
if not sep or not col or ref not in physical.tables:
raise _MachineSchemaError("assumption_invalid")
raise _MachineSchemaError("assumption_invalid", value)
assumed[col] = ref
def single_pk(table) -> str | None:
@@ -303,7 +340,7 @@ def suggest_fks_data(config: Path, *, from_sql: list[Path] | None = None,
}
def check_schema_data(config: Path) -> dict:
def check_schema_data(config: Path) -> CheckSchemaResult:
"""Validate the physical catalog and imported annotations without writing."""
from tht.mschema.merge import find_orphans
from tht.mschema.models import Annotations
@@ -315,11 +352,18 @@ def check_schema_data(config: Path) -> dict:
except Exception:
raise _MachineSchemaError("annotations_invalid") from None
orphans = find_orphans(physical, annotations)
ignored = [
f"{table_name}.{column_name} ({column.eligibility_reason})"
for table_name, table in physical.tables.items()
for column_name, column in table.columns.items()
if not column.eligible
]
return {
"status": "succeeded" if not orphans else "failed",
"code": "ok" if not orphans else "annotation_orphans",
"orphan_count": len(orphans),
"orphans": orphans,
"ignored": ignored,
}
@@ -335,8 +379,7 @@ def check_cmd(
if json_output:
_schema_json({"status": "failed", "code": error.code})
raise typer.Exit(code=1) from None
_load_config_or_exit(config)
raise typer.Exit(code=1) from None
_render_schema_machine_error(error, config)
except Exception:
if json_output:
_schema_json({"status": "failed", "code": "schema_check_failed"})
@@ -344,11 +387,18 @@ def check_cmd(
typer.secho("ERRORE: impossibile verificare le annotazioni.", fg=typer.colors.RED, err=True)
raise typer.Exit(code=1) from None
if json_output:
# Keep the machine response to one object, including expected validation failures.
_schema_json(payload)
# ``ignored`` is human diagnostic context, not part of the machine contract.
_schema_json({key: payload[key] for key in ("status", "code", "orphan_count", "orphans")})
if payload["status"] != "succeeded":
raise typer.Exit(code=3)
return
if payload["ignored"]:
typer.secho(
f"Colonne ignorate (testo ampio, {len(payload['ignored'])}):",
fg=typer.colors.YELLOW,
)
for line in payload["ignored"]:
typer.echo(f" - {line}")
if payload["orphan_count"]:
typer.secho(f"ATTENZIONE: {payload['orphan_count']} annotazioni orfane:", fg=typer.colors.YELLOW)
for orphan in payload["orphans"]:
@@ -357,6 +407,27 @@ def check_cmd(
typer.secho("OK: nessuna annotazione orfana.", fg=typer.colors.GREEN)
def _render_schema_machine_error(error: _MachineSchemaError, config: Path) -> None:
"""Render expected schema failures for the legacy human command contract."""
cfg = _load_config_or_exit(config)
if error.code == "physical_schema_missing":
typer.secho(
f"ERRORE: {physical_path(cfg)} non trovato. Esegui prima `tht schema introspect`.",
fg=typer.colors.RED,
err=True,
)
elif error.code == "assumption_invalid":
detail = getattr(error, "detail", "")
typer.secho(
f"ERRORE: --assume '{detail}' non valido (atteso col=tabella nel catalogo).",
fg=typer.colors.RED,
err=True,
)
else:
typer.secho("ERRORE: impossibile elaborare il catalogo.", fg=typer.colors.RED, err=True)
raise typer.Exit(code=1) from None
@schema_app.command("suggest-fks")
def suggest_fks_cmd(
config: Path = CONFIG_OPT,
@@ -379,12 +450,7 @@ def suggest_fks_cmd(
if json_output:
_schema_json({"status": "failed", "code": error.code})
raise typer.Exit(code=1) from None
if error.code == "assumption_invalid":
typer.secho("ERRORE: --assume non valido (atteso col=tabella nel catalogo).", fg=typer.colors.RED, err=True)
else:
_load_config_or_exit(config)
typer.secho("ERRORE: impossibile elaborare il catalogo.", fg=typer.colors.RED, err=True)
raise typer.Exit(code=1) from None
_render_schema_machine_error(error, config)
except Exception:
if json_output:
_schema_json({"status": "failed", "code": "schema_suggestion_failed"})
@@ -403,7 +469,9 @@ def suggest_fks_cmd(
)
if payload["ambiguous_columns"]:
typer.secho(
"PK ambigue saltate dalla regola same-name: " + ", ".join(payload["ambiguous_columns"]),
"PK ambigue saltate dalla regola same-name (piu' tabelle proprietarie): "
+ ", ".join(payload["ambiguous_columns"])
+ ". Se servono, aggiungile a mano o passa --from-sql.",
fg=typer.colors.YELLOW, err=True,
)
if not payload["candidates"]:
@@ -422,7 +490,8 @@ def suggest_fks_cmd(
ann.foreign_keys.extend(ForeignKey(**fk) for fk in table_payload["foreign_keys"])
annotations.to_yaml(annotations_path(cfg))
typer.secho(
f"OK: {payload['candidate_count']} FK suggerite aggiunte a {annotations_path(cfg)}.",
f"OK: {payload['candidate_count']} FK suggerite aggiunte a {annotations_path(cfg)} "
f"({len(candidate_tables)} tabelle). Rivedile a mano prima dell'uso.",
fg=typer.colors.GREEN,
)
return