Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions tests/test_catalog_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -985,3 +985,46 @@ def test_resolve_mart_local_and_gcs(
buckets = {(f["bucket"], f.get("table")) for f in res}
assert ("local-mart", "mart_aria_ora") in buckets
assert ("dataciviclab-mart", "mart_aria_ora") in buckets


class TestScanWorkspaceConfigsCanonicalSlug:
"""Lo slug viene da dataset.name (chiave canonica), non dalla dir.

Regressione: _scan_workspace_configs usava data['slug'] or dir_slug,
producendo slug dalla dir (es. 'precipitazioni') invece del name
canonico (es. 'precipitazioni_bologna') per 22/188 dataset.
"""

@pytest.mark.contract
def test_slug_from_dataset_name(self, tmp_path: "Any") -> None:
"""Dir diversa dal name → slug = dataset.name."""
repo = tmp_path / "dcl-bologna"
(repo / "datasets" / "precipitazioni").mkdir(parents=True)
(repo / "datasets" / "precipitazioni" / "dataset.yml").write_text(
"dataset:\n name: 'precipitazioni_bologna'\n source_id: 'comune_bologna_opendata'\n"
" years: [2026]\n",
encoding="utf-8",
)

from toolkit.domain.catalog import _scan_workspace_configs

configs = _scan_workspace_configs(tmp_path)

assert "precipitazioni_bologna" in configs
assert "precipitazioni" not in configs

@pytest.mark.contract
def test_slug_fallback_to_dir_without_name(self, tmp_path: "Any") -> None:
"""Config legacy senza dataset.name → fallback alla dir."""
repo = tmp_path / "legacy-repo"
(repo / "datasets" / "vecchio-dataset").mkdir(parents=True)
(repo / "datasets" / "vecchio-dataset" / "dataset.yml").write_text(
"dataset:\n source_id: 'x'\n years: [2024]\n",
encoding="utf-8",
)

from toolkit.domain.catalog import _scan_workspace_configs

configs = _scan_workspace_configs(tmp_path)

assert "vecchio_dataset" in configs
24 changes: 24 additions & 0 deletions tests/test_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,27 @@ def test_resolve_config_path_not_found(tmp_path):

with pytest.raises(FileNotFoundError, match="Nessun dataset trovato"):
resolve_config_path("slug-inesistente", workspace=tmp_path)


@pytest.mark.contract
def test_resolve_config_path_dir_totally_different(tmp_path):
"""Dir completamente diversa dallo slug → risolve via mappa scan.

Regressione: la dir può non corrispondere al slug in nessuna forma
(es. anag-enti vs ca_anag_enti_seed; monthly HDD senza dir locale).
Fallback Stage 4: mappa slug→config_path da dataset.name.
"""
from toolkit.core.discovery import resolve_config_path

# Repo con dir "anag-enti" ma slug "ca_anag_enti_seed" (dataset.name)
repo = tmp_path / "open-conto-annuale"
(repo / "datasets" / "anag-enti").mkdir(parents=True)
(repo / "datasets" / "anag-enti" / "dataset.yml").write_text(
"dataset:\n name: 'ca_anag_enti_seed'\n source_id: 'openbdap'\n",
encoding="utf-8",
)

assert (
resolve_config_path("ca_anag_enti_seed", workspace=tmp_path)
== (repo / "datasets" / "anag-enti" / "dataset.yml").resolve()
)
73 changes: 63 additions & 10 deletions tests/test_registry_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -502,29 +502,82 @@ def test_entity_graph_from_catalog(self, tmp_path: Path) -> None:


# ---------------------------------------------------------------------------
# Mappa canonica repo → dataset_dirs (fusion ADR generalizzata)
# Scoperta sezioni dati per convenzione (risoluzione univoca layout)
# ---------------------------------------------------------------------------


class TestRepoDatasetDirs:
"""La mappa REPO_DATASET_DIRS è la fonte unica dei layout per repo."""
"""Scoperta per convenzione: dir con {slug}/dataset.yml = sezione dati.

Sostituisce la vecchia mappa REPO_DATASET_DIRS: nessun repo va dichiarato,
la sezione si scopre dalla struttura (scalabile a qualsiasi layout).
"""

@pytest.mark.contract
def test_discovers_flat_datasets(self, tmp_path: Path) -> None:
"""Repo con datasets/{slug}/dataset.yml → sezione datasets."""
from toolkit.registry.layout import repo_dataset_dirs

repo = tmp_path / "eurostat"
(repo / "datasets" / "crime").mkdir(parents=True)
(repo / "datasets" / "crime" / "dataset.yml").write_text("x", encoding="utf-8")

assert repo_dataset_dirs(repo) == ("datasets",)

@pytest.mark.contract
def test_default_flat_for_unknown_repos(self) -> None:
"""Repo non mappato → default ('datasets',) — scalabile senza codice."""
def test_discovers_multiple_sections(self, tmp_path: Path) -> None:
"""Repo con più sezioni (datasets + support) → tutte scoperte."""
from toolkit.registry.layout import repo_dataset_dirs

assert repo_dataset_dirs("eurostat") == ("datasets",)
assert repo_dataset_dirs("dcl-bologna") == ("datasets",)
assert repo_dataset_dirs("nuovo-repo-futuro") == ("datasets",)
repo = tmp_path / "open-conto-annuale"
(repo / "datasets" / "personale").mkdir(parents=True)
(repo / "datasets" / "personale" / "dataset.yml").write_text("x", encoding="utf-8")
(repo / "support" / "anag-enti").mkdir(parents=True)
(repo / "support" / "anag-enti" / "dataset.yml").write_text("x", encoding="utf-8")

assert repo_dataset_dirs(repo) == ("datasets", "support")

@pytest.mark.contract
def test_di_custom_dirs(self) -> None:
"""dataset-incubator dichiara i suoi tre layout."""
def test_discovers_candidates_compose_support(self, tmp_path: Path) -> None:
"""DI: candidates/compose/support_datasets scoperti (nessuna mappa)."""
from toolkit.registry.layout import repo_dataset_dirs

assert repo_dataset_dirs("dataset-incubator") == (
repo = tmp_path / "dataset-incubator"
for section in ("candidates", "compose", "support_datasets"):
d = repo / section / "ds"
d.mkdir(parents=True)
(d / "dataset.yml").write_text("x", encoding="utf-8")

assert repo_dataset_dirs(repo) == (
"candidates",
"compose",
"support_datasets",
)

@pytest.mark.contract
def test_excludes_templates_and_hidden(self, tmp_path: Path) -> None:
"""templates/, dir nascoste e smoke non sono sezioni dati."""
from toolkit.registry.layout import repo_dataset_dirs

repo = tmp_path / "dataset-incubator"
(repo / "candidates" / "ds").mkdir(parents=True)
(repo / "candidates" / "ds" / "dataset.yml").write_text("x", encoding="utf-8")
(repo / "templates" / "candidate").mkdir(parents=True)
(repo / "templates" / "candidate" / "dataset.yml").write_text("x", encoding="utf-8")
(repo / ".github" / "ISSUE_TEMPLATE").mkdir(parents=True)
(repo / ".github" / "ISSUE_TEMPLATE" / "dataset.yml").write_text("x", encoding="utf-8")
(repo / "smoke" / "bdap_http_csv").mkdir(parents=True)
(repo / "smoke" / "bdap_http_csv" / "dataset.yml").write_text("x", encoding="utf-8")

assert repo_dataset_dirs(repo) == ("candidates",)

@pytest.mark.contract
def test_no_sections_returns_empty(self, tmp_path: Path) -> None:
"""Repo senza sezioni → tuple vuota (non è un repo dati)."""
from toolkit.registry.layout import repo_dataset_dirs

repo = tmp_path / "nuovo-repo"
repo.mkdir()
(repo / "README.md").write_text("x", encoding="utf-8")

assert repo_dataset_dirs(repo) == ()
12 changes: 11 additions & 1 deletion toolkit/core/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ def resolve_config_path(

searched: list[str] = []
for repo_dir in sorted(p for p in ws.iterdir() if p.is_dir()):
for section in repo_dataset_dirs(repo_dir.name):
for section in repo_dataset_dirs(repo_dir):
section_dir = repo_dir / section
if not section_dir.is_dir():
continue
Expand All @@ -91,6 +91,16 @@ def resolve_config_path(
return probe.resolve()
searched.append(str(section_dir / form))

# ── Stage 4: fallback alla mappa reale slug→config_path ──────────
# La dir può differire completamente dal slug (es. anag-enti vs
# ca_anag_enti_seed; monthly HDD senza dir locale). La mappa canonica
# (da dataset.name nello scan) è la fonte ultima.
from toolkit.domain.catalog import _scan_workspace_configs

configs = _scan_workspace_configs(ws)
if hint_str in configs:
return Path(configs[hint_str]["config_path"]).resolve()

raise FileNotFoundError(
f"Nessun dataset trovato per '{hint_str}'.\n"
f" Cercato in: {', '.join(searched) or ws}\n"
Expand Down
22 changes: 12 additions & 10 deletions toolkit/domain/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,11 +226,11 @@ def _scan_workspace_configs(
) -> dict[str, dict[str, Any]]:
"""Scansiona dataset.yml nel workspace e restituisce metadata di pipeline.

Cross-repo (come ``_scan_workspace_parquets``): usa la mappa canonica
``REPO_DATASET_DIRS`` (toolkit.registry.layout) — dataset-incubator legge
``candidates/compose/support_datasets``, gli altri repo (eurostat,
dcl-bologna, ...) ``datasets/``. Nuovi repo con layout flat funzionano
senza toccare il codice.
Cross-repo (come ``_scan_workspace_parquets``): usa la scoperta per
convenzione ``repo_dataset_dirs`` (toolkit.registry.layout) — ogni dir di
primo livello con {slug}/dataset.yml è una sezione dati (datasets/,
support/, candidates/...). Nuovi repo con layout custom funzionano senza
toccare il codice.

Returns:
Dict slug → {dataset_name, stage, years, has_clean, has_mart,
Expand All @@ -245,7 +245,7 @@ def _scan_workspace_configs(
dirs_to_scan: list[tuple[str, Path]] = []

for repo_dir in sorted(p for p in workspace.iterdir() if p.is_dir()):
for section in repo_dataset_dirs(repo_dir.name):
for section in repo_dataset_dirs(repo_dir):
section_dir = repo_dir / section
if not section_dir.is_dir():
continue
Expand Down Expand Up @@ -276,11 +276,13 @@ def _scan_workspace_configs(
if not isinstance(data, dict):
continue

# Slug: dataset.yml > directory name — normalizzato a underscore
slug = (data.get("slug") or dir_slug).replace("-", "_")

ds = data.get("dataset", {}) or {}
name = ds.get("name", slug) if isinstance(ds, dict) else slug
# Chiave canonica: dataset.name (underscore). La dir è solo un
# contenitore (può differire: anag-enti vs ca_anag_enti_seed).
# Fallback alla dir solo per config legacy senza dataset.name.
name_raw = ds.get("name") if isinstance(ds, dict) else None
slug = str(name_raw or dir_slug).replace("-", "_")
name = slug
years = ds.get("years", []) if isinstance(ds, dict) else []
if isinstance(years, int):
years = [years]
Expand Down
49 changes: 39 additions & 10 deletions toolkit/registry/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,50 @@

DEFAULT_DATASET_DIRS: tuple[str, ...] = ("datasets",)

# Mappa canonica repo → dataset_dirs (fusion ADR generalizzata).
# Ogni repo del workspace può dichiarare dove vivono i suoi dataset.yml;
# i repo non elencati usano il default ``("datasets",)`` (layout flat).
REPO_DATASET_DIRS: dict[str, tuple[str, ...]] = {
"dataset-incubator": ("candidates", "compose", "support_datasets"),
# Dir escluse dalla scoperta delle sezioni dati: nascoste (.github, .git),
# template (candidate seed) e fixture di test (smoke). La convenzione è:
# una sezione dati è una dir di primo livello del repo che contiene
# {slug}/dataset.yml.
_EXCLUDED_SECTION_DIRS = {
".github",
".git",
".venv",
"templates",
"smoke",
"__pycache__",
"node_modules",
}


def repo_dataset_dirs(repo_name: str) -> tuple[str, ...]:
"""Dataset dirs di un repo del workspace (default flat se non mappato).
def repo_dataset_dirs(repo_dir: Path) -> tuple[str, ...]:
"""Sezioni dati di un repo, scoperte per convenzione.

La mappa ``REPO_DATASET_DIRS`` è la fonte unica: aggiungere un repo con
layout custom = una riga qui, senza toccare resolver, discovery o CLI.
Scoperta (non mappa): ogni dir di primo livello del repo che contiene
almeno un ``{slug}/dataset.yml`` è una sezione dati (``datasets/``,
``support/``, ``candidates/``, ...). Vale per qualsiasi layout presente e
futuro — nessuna lista hardcoded per repo.

Un repo senza sezioni scoperte NON è un repo dati → tuple vuota (il
chiamante lo salta). Niente fallback a ``("datasets",)``: una dir
``datasets/`` vuota non è una sezione reale.

Args:
repo_dir: Root del repo (es. ``.../open-conto-annuale``).

Returns:
Tuple dei nomi delle sezioni dati (ordine stabile), vuota se il
repo non ha sezioni con dataset.yml.
"""
return REPO_DATASET_DIRS.get(repo_name, DEFAULT_DATASET_DIRS)
sections: list[str] = []
if not repo_dir.is_dir():
return tuple(sections)
for entry in sorted(p for p in repo_dir.iterdir() if p.is_dir()):
if entry.name in _EXCLUDED_SECTION_DIRS:
continue
# Sezione dati: contiene almeno un {slug}/dataset.yml a 1 livello.
if any(p.is_file() for p in entry.glob("*/dataset.yml")):
sections.append(entry.name)
return tuple(sections)


@dataclass(frozen=True)
Expand Down
Loading