fix: harden Task 2 schema quality boundaries
This commit is contained in:
@@ -1,10 +1,13 @@
|
|||||||
# ruff: noqa: DTZ001
|
# ruff: noqa: DTZ001
|
||||||
|
import warnings
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
import yaml
|
import yaml
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
from tht.cli import app
|
from tht.cli import app
|
||||||
|
from tht.cli.schema_cmd import check_schema_data
|
||||||
from tht.mschema.merge import find_orphans
|
from tht.mschema.merge import find_orphans
|
||||||
from tht.mschema.models import (
|
from tht.mschema.models import (
|
||||||
Annotations,
|
Annotations,
|
||||||
@@ -163,6 +166,10 @@ def test_suggest_fks_skips_generic_and_ambiguous_pks(tmp_path):
|
|||||||
assert res.exit_code == 0, res.output
|
assert res.exit_code == 0, res.output
|
||||||
assert "nessuna FK da suggerire" in res.output # id generico, cod_x ambigua
|
assert "nessuna FK da suggerire" in res.output # id generico, cod_x ambigua
|
||||||
assert "cod_x" in res.output # segnalata come ambigua saltata
|
assert "cod_x" in res.output # segnalata come ambigua saltata
|
||||||
|
exact = CliRunner().invoke(
|
||||||
|
app, ["schema", "suggest-fks", "--json", "-c", str(cfg)]
|
||||||
|
)
|
||||||
|
assert yaml.safe_load(exact.stdout)["ambiguous_columns"] == ["cod_x"]
|
||||||
|
|
||||||
# --assume disambigua la PK multi-proprietario
|
# --assume disambigua la PK multi-proprietario
|
||||||
res2 = CliRunner().invoke(
|
res2 = CliRunner().invoke(
|
||||||
@@ -205,6 +212,18 @@ def test_suggest_fks_from_sql_mines_joins(tmp_path):
|
|||||||
assert "ref_table: dim_patient" in res.output
|
assert "ref_table: dim_patient" in res.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_fks_reports_staged_file_without_mined_joins(tmp_path):
|
||||||
|
cfg = _write_workspace(tmp_path)
|
||||||
|
staged = tmp_path / "approved"
|
||||||
|
staged.mkdir()
|
||||||
|
(staged / "no-joins.sql").write_text("SELECT 1")
|
||||||
|
response = CliRunner().invoke(
|
||||||
|
app, ["schema", "suggest-fks", "-c", str(cfg), "--from-sql", str(staged)]
|
||||||
|
)
|
||||||
|
assert response.exit_code == 0, response.output
|
||||||
|
assert "Minati 0 equi-join da 1 file SQL" in response.output
|
||||||
|
|
||||||
|
|
||||||
def test_suggest_fks_write_merges_and_is_idempotent(tmp_path):
|
def test_suggest_fks_write_merges_and_is_idempotent(tmp_path):
|
||||||
cfg = _write_workspace(tmp_path)
|
cfg = _write_workspace(tmp_path)
|
||||||
ann_path = tmp_path / "artifacts" / "mschema" / "annotations.yaml"
|
ann_path = tmp_path / "artifacts" / "mschema" / "annotations.yaml"
|
||||||
@@ -435,3 +454,59 @@ def test_schema_human_suggest_missing_config_has_original_error(tmp_path):
|
|||||||
assert response.exit_code == 1
|
assert response.exit_code == 1
|
||||||
assert response.output
|
assert response.output
|
||||||
assert "ERRORE:" in response.output
|
assert "ERRORE:" in response.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_human_config_keeps_legacy_warning_but_json_suppresses_it(tmp_path):
|
||||||
|
cfg = _write_workspace(tmp_path)
|
||||||
|
with pytest.warns(FutureWarning, match="legacy workspace resource keys"):
|
||||||
|
check_schema_data(cfg)
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
check_schema_data(cfg, suppress_legacy_warning=True)
|
||||||
|
assert not [item for item in caught if issubclass(item.category, FutureWarning)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_staged_sql_enumeration_stops_at_count_sentinel(tmp_path, monkeypatch):
|
||||||
|
from tht.cli.schema_cmd import _MachineSchemaError, _staged_sql_files
|
||||||
|
|
||||||
|
staged = tmp_path / "staged"
|
||||||
|
staged.mkdir()
|
||||||
|
files = [staged / f"q{i:03d}.sql" for i in range(100)]
|
||||||
|
for path in files:
|
||||||
|
path.write_text("SELECT 1")
|
||||||
|
yielded = 0
|
||||||
|
|
||||||
|
def bounded_rglob(_self, _pattern):
|
||||||
|
nonlocal yielded
|
||||||
|
for path in files:
|
||||||
|
yielded += 1
|
||||||
|
yield path
|
||||||
|
|
||||||
|
monkeypatch.setattr(type(staged), "rglob", bounded_rglob)
|
||||||
|
with pytest.raises(_MachineSchemaError, match="staged_sql_too_many"):
|
||||||
|
_staged_sql_files([staged])
|
||||||
|
assert yielded == 33
|
||||||
|
|
||||||
|
|
||||||
|
def test_staged_sql_oversize_read_is_bounded(tmp_path, monkeypatch):
|
||||||
|
from tht.cli.schema_cmd import _MachineSchemaError, _read_staged_sql
|
||||||
|
|
||||||
|
staged = tmp_path / "oversize.sql"
|
||||||
|
staged.write_bytes(b"unused")
|
||||||
|
reads = []
|
||||||
|
|
||||||
|
class BoundedReader:
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_args):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def read(self, size):
|
||||||
|
reads.append(size)
|
||||||
|
return b"x" * size
|
||||||
|
|
||||||
|
monkeypatch.setattr(type(staged), "open", lambda *_args, **_kwargs: BoundedReader())
|
||||||
|
with pytest.raises(_MachineSchemaError, match="staged_sql_too_large"):
|
||||||
|
_read_staged_sql([staged])
|
||||||
|
assert reads == [(1 << 20) + 1]
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
# ruff: noqa: BLE001, S110, B008
|
|
||||||
import logging
|
import logging
|
||||||
import warnings
|
import warnings
|
||||||
|
from itertools import islice
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TypedDict
|
from typing import Literal, TypedDict
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
import yaml
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from tht.adapters.factory import build_dwh
|
from tht.adapters.factory import build_dwh
|
||||||
from tht.cli.config_cmd import CONFIG_OPT
|
from tht.cli.config_cmd import CONFIG_OPT
|
||||||
@@ -25,7 +27,7 @@ def _add_examples(dwh, phys, examples) -> None:
|
|||||||
sampled = dwh.sample_column(
|
sampled = dwh.sample_column(
|
||||||
table_name, column_name, limit=examples.max_per_column
|
table_name, column_name, limit=examples.max_per_column
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc: # noqa: BLE001 - sampling adapter boundary
|
||||||
logger.warning("Campionamento saltato per %s.%s: %s",
|
logger.warning("Campionamento saltato per %s.%s: %s",
|
||||||
table_name, column_name, exc)
|
table_name, column_name, exc)
|
||||||
continue
|
continue
|
||||||
@@ -87,8 +89,8 @@ def introspect_cmd(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
cached = PhysicalSchema.from_yaml(out)
|
cached = PhysicalSchema.from_yaml(out)
|
||||||
except Exception:
|
except (OSError, UnicodeError, TypeError, ValueError, yaml.YAMLError, ValidationError) as exc:
|
||||||
pass # catalogo illeggibile: procedi con la re-introspezione
|
logger.debug("Catalogo cache non leggibile: %s", exc)
|
||||||
else:
|
else:
|
||||||
ts = cached.introspected_at
|
ts = cached.introspected_at
|
||||||
if ts.tzinfo is None:
|
if ts.tzinfo is None:
|
||||||
@@ -109,7 +111,7 @@ def introspect_cmd(
|
|||||||
raise RuntimeError("DWH preprocessing failed")
|
raise RuntimeError("DWH preprocessing failed")
|
||||||
out = physical_path(cfg)
|
out = physical_path(cfg)
|
||||||
phys = PhysicalSchema.from_yaml(out)
|
phys = PhysicalSchema.from_yaml(out)
|
||||||
except Exception as e:
|
except Exception as e: # noqa: BLE001 - introspection CLI boundary
|
||||||
typer.secho(f"ERRORE: {e}", fg=typer.colors.RED, err=True)
|
typer.secho(f"ERRORE: {e}", fg=typer.colors.RED, err=True)
|
||||||
raise typer.Exit(code=1)
|
raise typer.Exit(code=1)
|
||||||
n_cols = sum(len(t.columns) for t in phys.tables.values())
|
n_cols = sum(len(t.columns) for t in phys.tables.values())
|
||||||
@@ -143,20 +145,17 @@ def _schema_json(payload: dict) -> None:
|
|||||||
typer.echo(json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")))
|
typer.echo(json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")))
|
||||||
|
|
||||||
|
|
||||||
def _machine_config(config: Path):
|
def _load_schema_config(config: Path, *, suppress_legacy_warning: bool = False):
|
||||||
# Legacy resource keys remain accepted, but their deprecation warning is not part of
|
"""Load configuration, optionally hiding the legacy-key warning for JSON callers."""
|
||||||
# the JSON machine contract. Keep this filter scoped to this call so human commands
|
if not suppress_legacy_warning:
|
||||||
# (and unrelated warnings/errors) retain their existing behavior.
|
return load_config(config)
|
||||||
with warnings.catch_warnings():
|
with warnings.catch_warnings():
|
||||||
warnings.filterwarnings(
|
warnings.filterwarnings(
|
||||||
"ignore",
|
"ignore",
|
||||||
message=r"^DEPRECATION: legacy workspace resource keys are deprecated;",
|
message=r"^DEPRECATION: legacy workspace resource keys are deprecated;",
|
||||||
category=FutureWarning,
|
category=FutureWarning,
|
||||||
)
|
)
|
||||||
try:
|
return load_config(config)
|
||||||
return load_config(config)
|
|
||||||
except ConfigError:
|
|
||||||
raise _MachineSchemaError("invalid_configuration") from None
|
|
||||||
|
|
||||||
|
|
||||||
def _physical_or_error(cfg):
|
def _physical_or_error(cfg):
|
||||||
@@ -169,22 +168,28 @@ def _physical_or_error(cfg):
|
|||||||
return PhysicalSchema.from_yaml(path)
|
return PhysicalSchema.from_yaml(path)
|
||||||
except _MachineSchemaError:
|
except _MachineSchemaError:
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except (OSError, UnicodeError, TypeError, ValueError, yaml.YAMLError, ValidationError):
|
||||||
raise _MachineSchemaError("physical_schema_invalid") from None
|
raise _MachineSchemaError("physical_schema_invalid") from None
|
||||||
|
|
||||||
|
|
||||||
def _staged_sql_files(inputs: list[Path] | None) -> list[Path]:
|
def _staged_sql_files(inputs: list[Path] | None) -> list[Path]:
|
||||||
files: list[Path] = []
|
"""Collect staged SQL paths without traversing beyond the file-count bound."""
|
||||||
for item in inputs or []:
|
def candidates():
|
||||||
if item.is_file():
|
for item in inputs or []:
|
||||||
files.append(item)
|
if item.is_file():
|
||||||
elif item.is_dir():
|
yield item
|
||||||
files.extend(path for path in item.rglob("*.sql") if path.is_file())
|
elif item.is_dir():
|
||||||
else:
|
for path in item.rglob("*.sql"):
|
||||||
raise _MachineSchemaError("staged_sql_invalid")
|
if path.is_file():
|
||||||
# Resolve only for ordering; content remains read from the caller's staged path.
|
yield path
|
||||||
|
else:
|
||||||
|
raise _MachineSchemaError("staged_sql_invalid")
|
||||||
|
|
||||||
|
# islice consumes at most the sentinel (33rd) match; unlike a list-producing
|
||||||
|
# rglob this never enumerates an unbounded directory before rejecting it.
|
||||||
|
files = list(islice(candidates(), _MAX_STAGED_SQL_FILES + 1))
|
||||||
ordered = 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:
|
if len(ordered) > _MAX_STAGED_SQL_FILES or len(files) > _MAX_STAGED_SQL_FILES:
|
||||||
raise _MachineSchemaError("staged_sql_too_many")
|
raise _MachineSchemaError("staged_sql_too_many")
|
||||||
return ordered
|
return ordered
|
||||||
|
|
||||||
@@ -194,11 +199,13 @@ def _read_staged_sql(inputs: list[Path] | None) -> tuple[list[Path], list[str]]:
|
|||||||
contents: list[str] = []
|
contents: list[str] = []
|
||||||
total = 0
|
total = 0
|
||||||
for path in paths:
|
for path in paths:
|
||||||
|
allowance = min(_MAX_STAGED_SQL_BYTES, _MAX_STAGED_SQL_TOTAL - total)
|
||||||
try:
|
try:
|
||||||
raw = path.read_bytes()
|
with path.open("rb") as stream:
|
||||||
|
raw = stream.read(allowance + 1)
|
||||||
except (OSError, UnicodeError):
|
except (OSError, UnicodeError):
|
||||||
raise _MachineSchemaError("staged_sql_invalid") from None
|
raise _MachineSchemaError("staged_sql_invalid") from None
|
||||||
if len(raw) > _MAX_STAGED_SQL_BYTES or total + len(raw) > _MAX_STAGED_SQL_TOTAL:
|
if len(raw) > allowance:
|
||||||
raise _MachineSchemaError("staged_sql_too_large")
|
raise _MachineSchemaError("staged_sql_too_large")
|
||||||
total += len(raw)
|
total += len(raw)
|
||||||
try:
|
try:
|
||||||
@@ -220,8 +227,8 @@ class FKCandidate(TypedDict):
|
|||||||
|
|
||||||
|
|
||||||
class SuggestFksResult(TypedDict):
|
class SuggestFksResult(TypedDict):
|
||||||
status: str
|
status: Literal["succeeded", "failed"]
|
||||||
code: str
|
code: Literal["ok"]
|
||||||
candidates: list[FKCandidate]
|
candidates: list[FKCandidate]
|
||||||
candidate_count: int
|
candidate_count: int
|
||||||
candidate_digest: str
|
candidate_digest: str
|
||||||
@@ -232,8 +239,8 @@ class SuggestFksResult(TypedDict):
|
|||||||
|
|
||||||
|
|
||||||
class CheckSchemaResult(TypedDict):
|
class CheckSchemaResult(TypedDict):
|
||||||
status: str
|
status: Literal["succeeded", "failed"]
|
||||||
code: str
|
code: Literal["ok", "annotation_orphans"]
|
||||||
orphan_count: int
|
orphan_count: int
|
||||||
orphans: list[str]
|
orphans: list[str]
|
||||||
ignored: list[str]
|
ignored: list[str]
|
||||||
@@ -243,8 +250,13 @@ def _candidate_key(fk) -> tuple:
|
|||||||
return (tuple(fk.columns), fk.ref_table, tuple(fk.ref_columns))
|
return (tuple(fk.columns), fk.ref_table, tuple(fk.ref_columns))
|
||||||
|
|
||||||
|
|
||||||
def suggest_fks_data(config: Path, *, from_sql: list[Path] | None = None,
|
def suggest_fks_data(
|
||||||
assume: list[str] | None = None) -> SuggestFksResult:
|
config: Path,
|
||||||
|
*,
|
||||||
|
from_sql: list[Path] | None = None,
|
||||||
|
assume: list[str] | None = None,
|
||||||
|
suppress_legacy_warning: bool = False,
|
||||||
|
) -> SuggestFksResult:
|
||||||
"""Return deterministic FK candidates without reviewing or mutating annotations."""
|
"""Return deterministic FK candidates without reviewing or mutating annotations."""
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
@@ -253,7 +265,10 @@ def suggest_fks_data(config: Path, *, from_sql: list[Path] | None = None,
|
|||||||
from tht.mschema.merge import find_orphans
|
from tht.mschema.merge import find_orphans
|
||||||
from tht.mschema.models import Annotations, ForeignKey
|
from tht.mschema.models import Annotations, ForeignKey
|
||||||
|
|
||||||
cfg = _machine_config(config)
|
try:
|
||||||
|
cfg = _load_schema_config(config, suppress_legacy_warning=suppress_legacy_warning)
|
||||||
|
except ConfigError:
|
||||||
|
raise _MachineSchemaError("invalid_configuration") from None
|
||||||
physical = _physical_or_error(cfg)
|
physical = _physical_or_error(cfg)
|
||||||
_, sql_contents = _read_staged_sql(from_sql)
|
_, sql_contents = _read_staged_sql(from_sql)
|
||||||
annotations = Annotations.from_yaml(annotations_path(cfg))
|
annotations = Annotations.from_yaml(annotations_path(cfg))
|
||||||
@@ -316,11 +331,13 @@ def suggest_fks_data(config: Path, *, from_sql: list[Path] | None = None,
|
|||||||
if assumed[column_name] != table_name:
|
if assumed[column_name] != table_name:
|
||||||
add(table_name, column_name, assumed[column_name], column_name)
|
add(table_name, column_name, assumed[column_name], column_name)
|
||||||
continue
|
continue
|
||||||
|
if column_name in _GENERIC_PK_NAMES:
|
||||||
|
continue
|
||||||
owners = [owner for owner in pk_owners.get(column_name, []) if owner != table_name]
|
owners = [owner for owner in pk_owners.get(column_name, []) if owner != table_name]
|
||||||
if len(pk_owners.get(column_name, [])) > 1:
|
if len(pk_owners.get(column_name, [])) > 1:
|
||||||
ambiguous_columns.add(column_name)
|
ambiguous_columns.add(column_name)
|
||||||
continue
|
continue
|
||||||
if not owners or column_name in _GENERIC_PK_NAMES:
|
if not owners:
|
||||||
continue
|
continue
|
||||||
add(table_name, column_name, owners[0], column_name)
|
add(table_name, column_name, owners[0], column_name)
|
||||||
|
|
||||||
@@ -350,16 +367,21 @@ def suggest_fks_data(config: Path, *, from_sql: list[Path] | None = None,
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def check_schema_data(config: Path) -> CheckSchemaResult:
|
def check_schema_data(
|
||||||
|
config: Path, *, suppress_legacy_warning: bool = False
|
||||||
|
) -> CheckSchemaResult:
|
||||||
"""Validate the physical catalog and imported annotations without writing."""
|
"""Validate the physical catalog and imported annotations without writing."""
|
||||||
from tht.mschema.merge import find_orphans
|
from tht.mschema.merge import find_orphans
|
||||||
from tht.mschema.models import Annotations
|
from tht.mschema.models import Annotations
|
||||||
|
|
||||||
cfg = _machine_config(config)
|
try:
|
||||||
|
cfg = _load_schema_config(config, suppress_legacy_warning=suppress_legacy_warning)
|
||||||
|
except ConfigError:
|
||||||
|
raise _MachineSchemaError("invalid_configuration") from None
|
||||||
physical = _physical_or_error(cfg)
|
physical = _physical_or_error(cfg)
|
||||||
try:
|
try:
|
||||||
annotations = Annotations.from_yaml(annotations_path(cfg))
|
annotations = Annotations.from_yaml(annotations_path(cfg))
|
||||||
except Exception:
|
except (OSError, UnicodeError, TypeError, ValueError, yaml.YAMLError, ValidationError):
|
||||||
raise _MachineSchemaError("annotations_invalid") from None
|
raise _MachineSchemaError("annotations_invalid") from None
|
||||||
orphans = find_orphans(physical, annotations)
|
orphans = find_orphans(physical, annotations)
|
||||||
ignored = [
|
ignored = [
|
||||||
@@ -384,13 +406,13 @@ def check_cmd(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Confronta physical.yaml e annotations.yaml; segnala annotazioni orfane."""
|
"""Confronta physical.yaml e annotations.yaml; segnala annotazioni orfane."""
|
||||||
try:
|
try:
|
||||||
payload = check_schema_data(config)
|
payload = check_schema_data(config, suppress_legacy_warning=json_output)
|
||||||
except _MachineSchemaError as error:
|
except _MachineSchemaError as error:
|
||||||
if json_output:
|
if json_output:
|
||||||
_schema_json({"status": "failed", "code": error.code})
|
_schema_json({"status": "failed", "code": error.code})
|
||||||
raise typer.Exit(code=1) from None
|
raise typer.Exit(code=1) from None
|
||||||
_render_schema_machine_error(error, config)
|
_render_schema_machine_error(error, config)
|
||||||
except Exception:
|
except Exception: # noqa: BLE001 - JSON CLI boundary
|
||||||
if json_output:
|
if json_output:
|
||||||
_schema_json({"status": "failed", "code": "schema_check_failed"})
|
_schema_json({"status": "failed", "code": "schema_check_failed"})
|
||||||
raise typer.Exit(code=1) from None
|
raise typer.Exit(code=1) from None
|
||||||
@@ -441,8 +463,8 @@ def _render_schema_machine_error(error: _MachineSchemaError, config: Path) -> No
|
|||||||
@schema_app.command("suggest-fks")
|
@schema_app.command("suggest-fks")
|
||||||
def suggest_fks_cmd(
|
def suggest_fks_cmd(
|
||||||
config: Path = CONFIG_OPT,
|
config: Path = CONFIG_OPT,
|
||||||
from_sql: list[Path] = typer.Option(None, "--from-sql", help="Directory/file SQL approvati (ripetibile)."),
|
from_sql: list[Path] = typer.Option(None, "--from-sql", help="Directory/file SQL approvati (ripetibile)."), # noqa: B008
|
||||||
assume: list[str] = typer.Option(None, "--assume", help="Disambigua una PK: col=tabella."),
|
assume: list[str] = typer.Option(None, "--assume", help="Disambigua una PK: col=tabella."), # noqa: B008
|
||||||
write: bool = typer.Option(False, "--write", help="Fonde i suggerimenti in annotations.yaml."),
|
write: bool = typer.Option(False, "--write", help="Fonde i suggerimenti in annotations.yaml."),
|
||||||
json_output: bool = typer.Option(False, "--json", help="Emetti JSON puro su stdout."),
|
json_output: bool = typer.Option(False, "--json", help="Emetti JSON puro su stdout."),
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -455,13 +477,18 @@ def suggest_fks_cmd(
|
|||||||
_schema_json({"status": "failed", "code": "write_not_allowed"})
|
_schema_json({"status": "failed", "code": "write_not_allowed"})
|
||||||
raise typer.Exit(code=2)
|
raise typer.Exit(code=2)
|
||||||
try:
|
try:
|
||||||
payload = suggest_fks_data(config, from_sql=from_sql, assume=assume)
|
payload = suggest_fks_data(
|
||||||
|
config,
|
||||||
|
from_sql=from_sql,
|
||||||
|
assume=assume,
|
||||||
|
suppress_legacy_warning=json_output,
|
||||||
|
)
|
||||||
except _MachineSchemaError as error:
|
except _MachineSchemaError as error:
|
||||||
if json_output:
|
if json_output:
|
||||||
_schema_json({"status": "failed", "code": error.code})
|
_schema_json({"status": "failed", "code": error.code})
|
||||||
raise typer.Exit(code=1) from None
|
raise typer.Exit(code=1) from None
|
||||||
_render_schema_machine_error(error, config)
|
_render_schema_machine_error(error, config)
|
||||||
except Exception:
|
except Exception: # noqa: BLE001 - JSON CLI boundary
|
||||||
if json_output:
|
if json_output:
|
||||||
_schema_json({"status": "failed", "code": "schema_suggestion_failed"})
|
_schema_json({"status": "failed", "code": "schema_suggestion_failed"})
|
||||||
raise typer.Exit(code=1) from None
|
raise typer.Exit(code=1) from None
|
||||||
@@ -472,7 +499,7 @@ def suggest_fks_cmd(
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Human mode remains the original renderer over the pure result.
|
# Human mode remains the original renderer over the pure result.
|
||||||
if payload["mined_join_count"]:
|
if payload["staged_sql_count"]:
|
||||||
typer.secho(
|
typer.secho(
|
||||||
f"Minati {payload['mined_join_count']} equi-join da {payload['staged_sql_count']} file SQL.",
|
f"Minati {payload['mined_join_count']} equi-join da {payload['staged_sql_count']} file SQL.",
|
||||||
fg=typer.colors.BLUE, err=True,
|
fg=typer.colors.BLUE, err=True,
|
||||||
@@ -524,10 +551,10 @@ def render_cmd(
|
|||||||
format: str = typer.Option(
|
format: str = typer.Option(
|
||||||
"markdown", "--format", "-f", help="Formato: markdown | mschema-text | schema-dict"
|
"markdown", "--format", "-f", help="Formato: markdown | mschema-text | schema-dict"
|
||||||
),
|
),
|
||||||
tables: list[str] = typer.Option(
|
tables: list[str] = typer.Option( # noqa: B008
|
||||||
None, "--table", "-t", help="Limita alle tabelle indicate (ripetibile)."
|
None, "--table", "-t", help="Limita alle tabelle indicate (ripetibile)."
|
||||||
),
|
),
|
||||||
output: Path = typer.Option(None, "--output", "-o", help="File di output (default stdout)."),
|
output: Path = typer.Option(None, "--output", "-o", help="File di output (default stdout)."), # noqa: B008
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Serializza mschema (physical + annotations) nel formato richiesto."""
|
"""Serializza mschema (physical + annotations) nel formato richiesto."""
|
||||||
import json
|
import json
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# ruff: noqa: BLE001
|
import logging
|
||||||
|
from collections.abc import Mapping
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TypedDict
|
from typing import Literal, TypedDict
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ from tht.ports.vector import VectorWriteRecord
|
|||||||
from tht.vectorstore.store import SyncStats, content_hash
|
from tht.vectorstore.store import SyncStats, content_hash
|
||||||
|
|
||||||
vector_app = typer.Typer(help="Indice semantico Qdrant (derivato, rigenerabile)")
|
vector_app = typer.Typer(help="Indice semantico Qdrant (derivato, rigenerabile)")
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def make_embedder(embeddings_cfg):
|
def make_embedder(embeddings_cfg):
|
||||||
@@ -80,10 +82,19 @@ def sync_canonical_records(collection, records, *, store, embedder):
|
|||||||
return stats
|
return stats
|
||||||
|
|
||||||
|
|
||||||
def _print_stats(stats) -> None:
|
def _print_stats(stats: SyncStats | Mapping[str, int]) -> None:
|
||||||
|
if isinstance(stats, SyncStats):
|
||||||
|
counts = {
|
||||||
|
"added": stats.added,
|
||||||
|
"updated": stats.updated,
|
||||||
|
"deleted": stats.deleted,
|
||||||
|
"unchanged": stats.unchanged,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
counts = stats
|
||||||
typer.secho(
|
typer.secho(
|
||||||
f"OK: {stats.added} nuovi, {stats.updated} aggiornati, "
|
f"OK: {counts['added']} nuovi, {counts['updated']} aggiornati, "
|
||||||
f"{stats.deleted} rimossi, {stats.unchanged} invariati",
|
f"{counts['deleted']} rimossi, {counts['unchanged']} invariati",
|
||||||
fg=typer.colors.GREEN,
|
fg=typer.colors.GREEN,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -138,8 +149,8 @@ class IndexCounts(TypedDict):
|
|||||||
|
|
||||||
|
|
||||||
class IndexSchemaResult(TypedDict):
|
class IndexSchemaResult(TypedDict):
|
||||||
status: str
|
status: Literal["succeeded", "failed"]
|
||||||
code: str
|
code: Literal["ok"]
|
||||||
counts: IndexCounts
|
counts: IndexCounts
|
||||||
|
|
||||||
|
|
||||||
@@ -158,26 +169,31 @@ def _vector_write_or_error(cfg) -> None:
|
|||||||
raise _MachineVectorError("vector_write_not_allowed")
|
raise _MachineVectorError("vector_write_not_allowed")
|
||||||
|
|
||||||
|
|
||||||
def index_schema_data(config: Path) -> IndexSchemaResult:
|
def index_schema_data(
|
||||||
|
config: Path, *, suppress_legacy_warning: bool = False
|
||||||
|
) -> IndexSchemaResult:
|
||||||
"""Synchronize schema records and return a bounded machine result."""
|
"""Synchronize schema records and return a bounded machine result."""
|
||||||
from tht.cli.schema_cmd import _machine_config
|
from tht.cli.schema_cmd import _load_schema_config
|
||||||
|
from tht.config import ConfigError
|
||||||
from tht.mschema.models import Annotations, PhysicalSchema
|
from tht.mschema.models import Annotations, PhysicalSchema
|
||||||
from tht.vectorstore.records import schema_records
|
from tht.vectorstore.records import schema_records
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cfg = _machine_config(config)
|
cfg = _load_schema_config(config, suppress_legacy_warning=suppress_legacy_warning)
|
||||||
except Exception:
|
except ConfigError:
|
||||||
# Keep this helper free of Typer rendering while preserving a stable code.
|
|
||||||
raise _MachineVectorError("invalid_configuration") from None
|
raise _MachineVectorError("invalid_configuration") from None
|
||||||
_vector_write_or_error(cfg)
|
_vector_write_or_error(cfg)
|
||||||
_vector_cfg_or_error(cfg)
|
_vector_cfg_or_error(cfg)
|
||||||
phys_file = physical_path(cfg)
|
phys_file = physical_path(cfg)
|
||||||
if not phys_file.exists():
|
if not phys_file.exists():
|
||||||
raise _MachineVectorError("physical_schema_missing")
|
raise _MachineVectorError("physical_schema_missing")
|
||||||
|
import yaml
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
try:
|
try:
|
||||||
physical = PhysicalSchema.from_yaml(phys_file)
|
physical = PhysicalSchema.from_yaml(phys_file)
|
||||||
annotations = Annotations.from_yaml(annotations_path(cfg))
|
annotations = Annotations.from_yaml(annotations_path(cfg))
|
||||||
except Exception:
|
except (OSError, UnicodeError, TypeError, ValueError, yaml.YAMLError, ValidationError):
|
||||||
raise _MachineVectorError("schema_artifacts_invalid") from None
|
raise _MachineVectorError("schema_artifacts_invalid") from None
|
||||||
records = schema_records(physical, annotations)
|
records = schema_records(physical, annotations)
|
||||||
from tht.adapters.factory import build_vector_store
|
from tht.adapters.factory import build_vector_store
|
||||||
@@ -210,23 +226,24 @@ def index_schema_cmd(
|
|||||||
|
|
||||||
if json_output:
|
if json_output:
|
||||||
try:
|
try:
|
||||||
payload = index_schema_data(config)
|
payload = index_schema_data(config, suppress_legacy_warning=True)
|
||||||
except _MachineVectorError as error:
|
except _MachineVectorError as error:
|
||||||
typer.echo(json.dumps({"status": "failed", "code": error.code}, sort_keys=True, separators=(",", ":")))
|
typer.echo(json.dumps({"status": "failed", "code": error.code}, sort_keys=True, separators=(",", ":")))
|
||||||
raise typer.Exit(code=1) from None
|
raise typer.Exit(code=1) from None
|
||||||
except Exception:
|
except Exception: # noqa: BLE001 - JSON CLI boundary
|
||||||
typer.echo(json.dumps({"status": "failed", "code": "schema_index_failed"}, sort_keys=True, separators=(",", ":")))
|
typer.echo(json.dumps({"status": "failed", "code": "schema_index_failed"}, sort_keys=True, separators=(",", ":")))
|
||||||
raise typer.Exit(code=1) from None
|
raise typer.Exit(code=1) from None
|
||||||
typer.echo(json.dumps(payload, sort_keys=True, separators=(",", ":")))
|
typer.echo(json.dumps(payload, sort_keys=True, separators=(",", ":")))
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
payload = index_schema_data(config)
|
payload = index_schema_data(config, suppress_legacy_warning=False)
|
||||||
except _MachineVectorError as error:
|
except _MachineVectorError as error:
|
||||||
_render_index_schema_error(error, config)
|
_render_index_schema_error(error, config)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.exception("Schema indexing failed")
|
||||||
typer.secho("ERRORE: impossibile indicizzare lo schema.", fg=typer.colors.RED, err=True)
|
typer.secho("ERRORE: impossibile indicizzare lo schema.", fg=typer.colors.RED, err=True)
|
||||||
raise typer.Exit(code=1) from None
|
raise typer.Exit(code=1) from None
|
||||||
_print_stats(type("Stats", (), payload["counts"])())
|
_print_stats(payload["counts"])
|
||||||
|
|
||||||
|
|
||||||
def _render_index_schema_error(error: _MachineVectorError, config: Path) -> None:
|
def _render_index_schema_error(error: _MachineVectorError, config: Path) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user