# SPDX-License-Identifier: BUSL-1.1 # Copyright (c) 2024-2026 Ali Sadeghi Aghili """Where the setup wizard writes, and how a new run is merged into an old one. ``setup_project.py`` produces *drafts*: aliases, rules and examples suggested by a model, to be reviewed before they replace anything curated. This module holds the parts of that rule which need no database and no model, so they can be tested on their own: * which directories the wizard refuses to write into without ``--overwrite-live`` (:func:`protected_directory`, :func:`refusal_message`), and the ``.bak-`` copies it makes first (:func:`back_up`); * how the files of a second run are merged into those of the first, by key, without shrinking them (:func:`merge_mapping`, :func:`merge_synonyms`, :func:`merge_examples`, :func:`merge_relationships`), with the counts the summary prints (:class:`MergeStats`); * how two earlier entries that the new keys would put on one key become one (:func:`collapse_same_key`), so a merge never leaves two entries for one table, and how a model's alias list is made usable (:func:`tidy_aliases`); * how the entries that a run of 6.13 to 6.16.2 wrote into ``ring_aliases`` -- one per *table*, where a key must be the value of a ring -- are dropped from an earlier ``aliases.yaml``, their aliases kept in ``entities.yaml`` (:func:`drop_table_rings`, :class:`RingCleanup`). Merging never removes an entry, except that two old entries for one table become one and that the table-keyed ``ring_aliases`` entries above are dropped (and counted). An entry the new run produced replaces the old one with the same key; every other old entry stays as it was. Public API ---------- DEFAULT_DRAFT_DIR Directory name the wizard writes to by default. protected_directory(output_dir, live_dir, repo_root) -> str | None refusal_message(output_dir, label) -> str back_up(paths, stamp) -> list[Path] MergeStats merge_mapping(existing, new, rename) -> (dict, MergeStats) merge_synonyms(existing, new) -> (dict, MergeStats) merge_examples(existing, new) -> (list, MergeStats) merge_relationships(existing, new, rename) -> (list, MergeStats) collapse_same_key(existing, rename, combine) -> (dict, tuple[(dropped, kept), ...]) tidy_aliases(aliases, limit) -> list[str] RingCleanup, drop_table_rings(ring_aliases, entities, view) -> RingCleanup """ from __future__ import annotations import re import shutil from collections.abc import Callable, Iterable, Mapping, Sequence from dataclasses import dataclass from pathlib import Path from typing import Any from core import config_doctor as doctor from core import ring_names from core.persian import normalize_for_matching __all__ = [ "ALIAS_LIMIT", "DEFAULT_DRAFT_DIR", "EXAMPLE_DIR_NAME", "MergeStats", "RingCleanup", "back_up", "collapse_same_key", "drop_table_rings", "hold_back_empty", "merge_examples", "merge_mapping", "merge_relationships", "merge_synonyms", "protected_directory", "refusal_message", "tidy_aliases", ] #: Directory the wizard writes to unless ``--output`` says otherwise. It is #: git-ignored, like the drafts of ``database/schema_inspector_cli.py``. DEFAULT_DRAFT_DIR = "project_config_draft" #: The committed template directory; never a place for drafts built from a #: real warehouse. EXAMPLE_DIR_NAME = "project_config.example" #: Aliases kept per table. A model asked for aliases of one table sometimes #: answers with every concatenation of the same three words (22 for one table #: in a real run); a handful of distinct phrasings is what retrieval can use. ALIAS_LIMIT = 8 # --------------------------------------------------------------------------- # Protected directories and backups # --------------------------------------------------------------------------- def protected_directory(output_dir: Path, live_dir: Path, repo_root: Path) -> str | None: """Name the protected directory *output_dir* is, or ``None`` when it is not one. The live configuration directory and ``project_config.example/`` are protected. Paths are compared after ``resolve()``, so a relative spelling, a trailing slash or a symbolic link does not get around the check. Args: output_dir: Where the wizard was asked to write. live_dir: The directory the application reads (``PROJECT_CONFIG_DIR``, resolved against *repo_root* when relative). repo_root: The repository root, where ``project_config.example/`` lives. Returns: ``"live"`` for the live directory, ``"example"`` for the template directory, else ``None``. Raises: Nothing for ordinary paths; ``resolve()`` is not strict. Examples: >>> root = Path("/srv/app") >>> protected_directory(Path("/srv/app/project_config"), root / "project_config", root) 'live' >>> protected_directory(Path("/srv/app/project_config.example/"), root / "project_config", root) 'example' >>> protected_directory(Path("/srv/app/project_config_draft"), root / "project_config", root) is None True >>> protected_directory(Path("/srv/app/x/../project_config"), root / "project_config", root) 'live' """ resolved = output_dir.resolve() if resolved == live_dir.resolve(): return "live" if resolved in {(repo_root / EXAMPLE_DIR_NAME).resolve(), Path(EXAMPLE_DIR_NAME).resolve()}: return "example" return None def refusal_message(output_dir: Path, label: str) -> str: """The one-paragraph message printed when the wizard refuses to write. Args: output_dir: The directory that was refused. label: What :func:`protected_directory` returned for it. Returns: The message, ending with how to proceed. Raises: Nothing. This function never raises. Examples: >>> text = refusal_message(Path("project_config"), "live") >>> "--overwrite-live" in text and "project_config_draft" in text True >>> "\\n" in refusal_message(Path("x"), "example") False """ what = ( "the live configuration directory the application reads" if label == "live" else f"the committed template directory ({EXAMPLE_DIR_NAME}/), which git tracks" ) return ( f"Refusing to write into {output_dir}: it is {what}. The wizard writes drafts of " "aliases, rules and examples suggested by a model, and they must be reviewed before " "they replace curated files. Run it with the default output " f"({DEFAULT_DRAFT_DIR}/) or another scratch directory (--output), review the files " "and copy the ones you accept across yourself. To write here anyway, add " "--overwrite-live: every file the wizard replaces is first copied to " ".bak- next to it, and the copies are listed." ) def _backup_path(path: Path, stamp: str) -> Path: """``.bak-`` next to *path*, made unique if that name is taken.""" candidate = path.with_name(f"{path.name}.bak-{stamp}") n = 1 while candidate.exists(): n += 1 candidate = path.with_name(f"{path.name}.bak-{stamp}-{n}") return candidate def back_up(paths: Iterable[Path], stamp: str) -> list[Path]: """Copy each existing file in *paths* to ``.bak-`` beside it. Args: paths: Files about to be replaced. One that does not exist is skipped. stamp: The timestamp part of the name, ``YYYYmmdd-HHMMSS``. Returns: The backup files created, in the order of *paths*. Raises: OSError: When a copy cannot be made; nothing is replaced then. Examples: >>> import tempfile >>> with tempfile.TemporaryDirectory() as tmp: ... f = Path(tmp, "a.yaml"); _ = f.write_text("x") ... made = back_up([f, Path(tmp, "missing.yaml")], "20260101-000000") ... [p.name for p in made], made[0].read_text() (['a.yaml.bak-20260101-000000'], 'x') """ made: list[Path] = [] for path in paths: if not path.is_file(): continue target = _backup_path(path, stamp) shutil.copy2(path, target) made.append(target) return made # --------------------------------------------------------------------------- # Merging # --------------------------------------------------------------------------- @dataclass(frozen=True) class MergeStats: """What a merge did to one file. Attributes: kept: Entries of the old file the new run did not touch. added: Entries the old file did not have. replaced: Entries the new run produced for a key the old file had. rekeyed: ``(old key, new key)`` for old entries whose key changed because a bare table name is now shared by several schemas. They count under *kept* or *replaced* as well. held: Keys of old entries kept (they count under *kept*) because this run had only an empty answer for them; see :func:`hold_back_empty`. """ kept: int = 0 added: int = 0 replaced: int = 0 rekeyed: tuple[tuple[str, str], ...] = () held: tuple[str, ...] = () def __add__(self, other: "MergeStats") -> "MergeStats": """Counts added up, for a file that holds two merged sections. Examples: >>> (MergeStats(1, 2, 3) + MergeStats(4, 5, 6)).describe() 'kept 5, added 7, replaced 9' """ return MergeStats( self.kept + other.kept, self.added + other.added, self.replaced + other.replaced, self.rekeyed + other.rekeyed, self.held + other.held, ) def describe(self) -> str: """``"kept 2, added 1, replaced 0"``, plus the re-keyed count when there is one. Examples: >>> MergeStats(2, 1, 0).describe() 'kept 2, added 1, replaced 0' >>> MergeStats(0, 1, 0, (("Trade", "sales.Trade"),)).describe() 'kept 0, added 1, replaced 0, re-keyed 1' """ text = f"kept {self.kept}, added {self.added}, replaced {self.replaced}" if self.rekeyed: text += f", re-keyed {len(self.rekeyed)}" return text def _fold(key: str) -> str: return key.casefold() def hold_back_empty( existing: Mapping[str, Any], new: Mapping[str, Any], is_empty: Callable[[Any], bool], rename: Mapping[str, str] | None = None, ) -> tuple[dict[str, Any], tuple[str, ...]]: """Drop the new entries that say nothing where the old file already says something. A model that failed, or answered with nothing, for one table must not erase what an earlier run or a person wrote for it. An entry of *new* is dropped when *is_empty* is true for it and false for the old entry with the same key (case-insensitive, after *rename*); the merge then keeps the old one and counts it as kept. Args: existing: The old file's mapping. new: This run's entries. is_empty: Whether an entry carries no answer. rename: The renames the merge will apply to *existing*. Returns: ``(entries to merge, keys held back)``. Raises: Nothing. This function never raises. Examples: >>> kept, held = hold_back_empty({"A": ["x"], "B": []}, {"A": [], "B": [], "C": []}, lambda v: not v) >>> kept, held ({'B': [], 'C': []}, ('A',)) """ lowered = {_fold(k): v for k, v in (rename or {}).items()} old = {_fold(lowered.get(_fold(k), k)): v for k, v in existing.items()} kept: dict[str, Any] = {} held: list[str] = [] for key, value in new.items(): before = old.get(_fold(key)) if before is not None and is_empty(value) and not is_empty(before): held.append(key) else: kept[key] = value return kept, tuple(held) def merge_mapping( existing: Mapping[str, Any], new: Mapping[str, Any], rename: Mapping[str, str] | None = None, ) -> tuple[dict[str, Any], MergeStats]: """Merge *new* into *existing* by key; keys compare case-insensitively. Old entries come first, in their order, under their (possibly renamed) key; an entry of *new* with a key already present replaces that value in place; the others are appended. Nothing is removed. Args: existing: The old file's mapping. new: The entries this run produced. rename: ``{old key: new key}`` for old entries to re-key before the merge (a bare name that must now be qualified). Returns: ``(merged, stats)``. Raises: Nothing. This function never raises. Examples: >>> merged, stats = merge_mapping({"A": 1, "B": 2}, {"B": 3, "C": 4}) >>> merged, stats.describe() ({'A': 1, 'B': 3, 'C': 4}, 'kept 1, added 1, replaced 1') >>> merged, stats = merge_mapping({"Trade": 1}, {"ref.Trade": 2}, {"Trade": "sales.Trade"}) >>> merged, stats.rekeyed ({'sales.Trade': 1, 'ref.Trade': 2}, (('Trade', 'sales.Trade'),)) >>> merge_mapping({"a": 1}, {"A": 2})[0] {'A': 2} """ lowered_rename = {_fold(k): v for k, v in (rename or {}).items()} merged: dict[str, Any] = {} index: dict[str, str] = {} rekeyed: list[tuple[str, str]] = [] for key, value in existing.items(): target = lowered_rename.get(_fold(key), key) if target != key: rekeyed.append((key, target)) index[_fold(target)] = target merged[target] = value untouched = set(merged) added = replaced = 0 for key, value in new.items(): present = index.get(_fold(key)) if present is None: index[_fold(key)] = key merged[key] = value added += 1 continue if present == key: merged[key] = value else: # Same table, spelled the way this run spells it; keep its place. merged = {(key if k == present else k): (value if k == present else v) for k, v in merged.items()} index[_fold(key)] = key untouched.discard(present) replaced += 1 return merged, MergeStats(len(untouched), added, replaced, tuple(rekeyed)) def merge_synonyms( existing: Mapping[str, Sequence[str]], new: Mapping[str, Sequence[str]], ) -> tuple[dict[str, list[str]], MergeStats]: """Merge the ``synonyms`` of two runs: a word that is in both keeps both token lists. Synonyms are keyed by a word, not by a table, so there is nothing for a run to replace: the tokens of the old and the new entry are united (old first). Such an entry counts as *replaced* when it gained a token. Args: existing: The old file's ``synonyms``. new: This run's ``synonyms``. Returns: ``(merged, stats)``. Raises: Nothing. This function never raises. Examples: >>> merged, stats = merge_synonyms({"a": ["x"], "b": ["y"]}, {"a": ["x", "z"], "c": ["w"]}) >>> merged, stats.describe() ({'a': ['x', 'z'], 'b': ['y'], 'c': ['w']}, 'kept 1, added 1, replaced 1') """ merged: dict[str, list[str]] = {k: list(v) for k, v in existing.items()} index = {_fold(k): k for k in merged} untouched = set(merged) added = replaced = 0 for word, tokens in new.items(): present = index.get(_fold(word)) if present is None: merged[word] = list(tokens) index[_fold(word)] = word added += 1 continue gained = [t for t in tokens if t not in merged[present]] if gained: merged[present].extend(gained) untouched.discard(present) replaced += 1 return merged, MergeStats(len(untouched), added, replaced) def _question_key(entry: Mapping[str, Any]) -> str: return " ".join(str(entry.get("question", "")).split()).casefold() def merge_examples( existing: Sequence[Mapping[str, Any]], new: Sequence[Mapping[str, Any]], ) -> tuple[list[dict[str, Any]], MergeStats]: """Merge example lists, de-duplicated by question text. Questions compare case-insensitively with runs of whitespace collapsed. A new example whose question the old list has replaces that example in place; the others are appended. An example without a question or an SQL text is not an example and is ignored on the new side. A question repeated within *new* counts once (the first). Args: existing: The old file's ``examples``. new: This run's examples. Returns: ``(merged, stats)``. Raises: Nothing. This function never raises. Examples: >>> old = [{"question": "How many?", "sql": "S1"}, {"question": "Top 5", "sql": "S2"}] >>> fresh = [{"question": "how many?", "sql": "S3"}, {"question": "New", "sql": "S4"}] >>> merged, stats = merge_examples(old, fresh) >>> [e["sql"] for e in merged], stats.describe() (['S3', 'S2', 'S4'], 'kept 1, added 1, replaced 1') """ merged: list[dict[str, Any]] = [dict(e) for e in existing] index: dict[str, int] = {} for position, entry in enumerate(merged): index.setdefault(_question_key(entry), position) untouched = set(range(len(merged))) seen: set[str] = set() added = replaced = 0 for entry in new: if not (entry.get("question") and entry.get("sql")): continue key = _question_key(entry) if key in seen: continue seen.add(key) if key in index: merged[index[key]] = dict(entry) untouched.discard(index[key]) replaced += 1 else: index[key] = len(merged) merged.append(dict(entry)) added += 1 return merged, MergeStats(len(untouched), added, replaced) def _endpoints(entry: Mapping[str, Any], rename: Mapping[str, str]) -> frozenset[tuple[str, str]] | None: """The two ends of a relationship entry as ``(table, column)``, lower-cased; ``None`` if incomplete.""" ends: list[tuple[str, str]] = [] for side in ("from", "to"): table = str(entry.get(f"{side}_table") or "").strip() column = str(entry.get(f"{side}_column") or "").strip() if not (table and column): return None ends.append((rename.get(_fold(table), table).casefold(), column.casefold())) return frozenset(ends) def merge_relationships( existing: Sequence[Mapping[str, Any]], new: Sequence[Mapping[str, Any]], rename: Mapping[str, str] | None = None, ) -> tuple[list[dict[str, Any]], MergeStats]: """Merge relationship lists, de-duplicated by their endpoints. Two entries are the same relationship when they join the same two ``(table, column)`` ends, in either direction. A new entry replaces the old one in place; the others are appended. An entry marked as inferred (it has a ``basis``) never replaces one that is not: a person has reviewed that one, or the database declares it. Args: existing: The old file's ``relationships``. new: This run's relationships. rename: ``{old table key: new key}`` applied to the old entries' ``from_table`` and ``to_table`` first. Returns: ``(merged, stats)``. Raises: Nothing. This function never raises. Examples: >>> old = [{"from_table": "A", "from_column": "b_id", "to_table": "B", "to_column": "id", "join_hint": "J1"}] >>> fresh = [{"from_table": "B", "from_column": "ID", "to_table": "a", "to_column": "B_ID", "join_hint": "J2"}, ... {"from_table": "A", "from_column": "c_id", "to_table": "C", "to_column": "id", "join_hint": "J3"}] >>> merged, stats = merge_relationships(old, fresh) >>> [e["join_hint"] for e in merged], stats.describe() (['J2', 'J3'], 'kept 0, added 1, replaced 1') >>> guess = [dict(fresh[0], join_hint="J4", basis="naming convention")] >>> merge_relationships(old, guess)[0][0]["join_hint"] 'J1' """ lowered = {_fold(k): v for k, v in (rename or {}).items()} merged: list[dict[str, Any]] = [] rekeyed: list[tuple[str, str]] = [] for entry in existing: item = dict(entry) for side in ("from_table", "to_table"): old = str(item.get(side) or "") target = lowered.get(_fold(old)) if target and target != old: item[side] = target if (old, target) not in rekeyed: rekeyed.append((old, target)) merged.append(item) index: dict[frozenset[tuple[str, str]], int] = {} for position, item in enumerate(merged): ends = _endpoints(item, {}) if ends is not None: index.setdefault(ends, position) untouched = set(range(len(merged))) added = replaced = 0 for entry in new: ends = _endpoints(entry, {}) position = index.get(ends) if ends is not None else None if position is None: if ends is not None: index[ends] = len(merged) merged.append(dict(entry)) added += 1 elif entry.get("basis") and not merged[position].get("basis"): continue else: merged[position] = dict(entry) untouched.discard(position) replaced += 1 return merged, MergeStats(len(untouched), added, replaced, tuple(rekeyed)) def collapse_same_key( existing: Mapping[str, Any], rename: Mapping[str, str], combine: Callable[[Any, Any], Any], ) -> tuple[dict[str, Any], tuple[tuple[str, str], ...]]: """Make one entry of the old entries that *rename* would put on the same key. :func:`merge_mapping` re-keys old entries and, when two end up on one key, keeps whichever it saw last. Two earlier entries for one table (a bare one from the inspector's draft and a qualified one from an earlier wizard run) are exactly that case, and neither should be lost: they are combined here, before the merge. Keys compare case-insensitively. The entry whose key already is the target keeps its key, and so does the first of the group when none is; the others are dropped, and their data is folded into it with *combine*, in file order. Parameters ---------- existing: The old file's mapping. rename: ``{old key: new key}`` the merge will apply. combine: ``combine(kept, other)`` returns the entry that replaces *kept* once *other* is folded into it. Returns ------- tuple ``(entries, dropped)``: the mapping without the duplicates, and ``(dropped key, kept key)`` for each entry folded away. Raises ------ Nothing. This function never raises. Examples -------- >>> old = {"Trade": ["a"], "sales.Trade": ["b"], "Customer": ["c"]} >>> merged, dropped = collapse_same_key(old, {"sales.Trade": "Trade"}, lambda keep, other: keep + other) >>> merged, dropped ({'Trade': ['a', 'b'], 'Customer': ['c']}, (('sales.Trade', 'Trade'),)) >>> collapse_same_key({"A": 1, "B": 2}, {}, lambda keep, other: keep)[1] () """ lowered = {_fold(k): v for k, v in rename.items()} def final(key: str) -> str: return _fold(lowered.get(_fold(key), key)) groups: dict[str, list[str]] = {} for key in existing: groups.setdefault(final(key), []).append(key) result: dict[str, Any] = {} dropped: list[tuple[str, str]] = [] for key in existing: group = groups[final(key)] if len(group) == 1: result[key] = existing[key] elif key == group[0]: keeper = next((k for k in group if _fold(k) == final(key)), key) value = existing[keeper] for other in group: if other != keeper: value = combine(value, existing[other]) dropped.append((other, keeper)) result[keeper] = value return result, tuple(dropped) def _alias_key(text: str) -> str: """The words of an alias, folded and without punctuation: what makes two aliases the same.""" return " ".join(re.findall(r"\w+", normalize_for_matching(text))) def tidy_aliases(aliases: Iterable[Any] | None, limit: int = ALIAS_LIMIT) -> list[str]: """A model's alias list made usable: strings, no repeats, at most *limit*. Aliases are compared after folding (case, Persian and Arabic letter and digit forms, punctuation, spacing), and the first spelling of each is kept. When more than *limit* remain, an alias whose words are all in a shorter alias of the list, or that has the same words in another order, is dropped (``"bank name list"`` beside ``"bank name"``), and if that is not enough the shortest are kept. The order of what is left is the model's. Parameters ---------- aliases: What the model returned; anything that is not text is ignored. limit: The most aliases to keep. Returns ------- list[str] The aliases to write. Raises ------ Nothing. This function never raises. Examples -------- >>> tidy_aliases(["Bank", "bank", " Bank ", "banks", None, 3, ""]) ['Bank', 'banks', '3'] >>> many = ["bank name", "bank name list", "list bank name", "name bank", "bank", "bank list", ... "list of bank", "banks", "bank names", "bank name code", "the bank"] >>> tidy_aliases(many, 4) ['bank', 'banks'] >>> tidy_aliases(["alpha beta", "gamma", "delta epsilon zeta", "eta theta"], 2) ['alpha beta', 'gamma'] >>> tidy_aliases(["a b", "a b c"], 8) ['a b', 'a b c'] """ seen: dict[str, str] = {} for raw in aliases or []: if isinstance(raw, bool) or not isinstance(raw, (str, int, float)): continue text = " ".join(str(raw).split()) key = _alias_key(text) if key and key not in seen: seen[key] = text items = list(seen.items()) if len(items) <= limit: return [text for _, text in items] words = [frozenset(key.split()) for key, _ in items] kept = [ i for i in range(len(items)) if not any( j != i and words[j] <= words[i] and (len(words[j]) < len(words[i]) or j < i) for j in range(len(items)) ) ] if len(kept) > limit: kept = sorted(sorted(kept, key=lambda i: (len(words[i]), i))[:limit]) return [items[i][1] for i in kept] # --------------------------------------------------------------------------- # ring_aliases entries that are tables # --------------------------------------------------------------------------- @dataclass(frozen=True) class RingCleanup: """What :func:`drop_table_rings` did to an earlier ``aliases.yaml``. Attributes: ring_aliases: The entries that stay: every one whose key is not a table. entities: The entities, with the aliases of the dropped entries added. dropped: The keys dropped (table names, not data), in file order. carried: Entities that gained aliases from a dropped entry. created: Entities made for a table that had none. unplaced: Dropped keys whose aliases went nowhere: the key names no single table, so there was no entity to put them in. """ ring_aliases: dict[str, list[str]] entities: dict[str, dict[str, Any]] dropped: tuple[str, ...] = () carried: int = 0 created: int = 0 unplaced: tuple[str, ...] = () def _union_aliases(first: Iterable[str], second: Iterable[str]) -> list[str]: """*first* then what *second* adds, without repeats (case ignored).""" seen: dict[str, str] = {} for item in (*first, *second): seen.setdefault(str(item).casefold(), str(item)) return list(seen.values()) def _table_key(view: doctor.SchemaView, key: str) -> str: """The key of the one table of *view* that *key* names, or ``""``.""" found = doctor.resolve_table_keys(view, key) if len(found) == 1: return found[0] if not found: spelled = doctor.entity_key_findings(view, (), [key]).unknown_rings return next((right for _, right in spelled if right), "") return "" def drop_table_rings( ring_aliases: Mapping[str, list[str]], entities: Mapping[str, Mapping[str, Any]], view: doctor.SchemaView, ) -> RingCleanup: """Drop the ``ring_aliases`` entries that name a table; keep their aliases as the table's entity aliases. A ``ring_aliases`` key is the exact value of a ring's name column. A run of the wizard of 6.13 to 6.16.2 wrote one entry per table instead, keyed by the table and holding the table's aliases, which are also in ``entities.yaml``. An entry whose key names a table of *view* by its key, by a bare name that finds one key, or by a ``Schema.Table`` spelling (:func:`core.ring_names.ring_keys_naming_tables`) is dropped. Every other entry -- a real ring, whatever words its name shares with a table -- is returned as it was. Nothing is lost: the aliases of a dropped entry are added to the entity of that table (the entity has them already when the entry was written by the wizard), and an entity is made for a table that has none. Parameters ---------- ring_aliases: ``aliases.yaml`` ``ring_aliases`` of the earlier run. entities: ``entities.yaml`` of the earlier run, as the wizard reads it (``{key: {"table", "schema", "aliases", ...}}``). view: The tables the wizard knows: this run's, the earlier entities' and ``schema.yaml``'s (:func:`core.ring_names.table_view`). Returns ------- RingCleanup With *entities* itself when no entry was dropped. Examples -------- >>> view = ring_names.table_view([("Hall", "ref", "Hall"), ("sales.Trade", "sales", "Trade")]) >>> rings = {"North Hall": ["north"], "Hall": ["hall"], "Trade": ["deal"], "Region A": ["a"]} >>> entities = {"Hall": {"table": "Hall", "schema": "ref", "aliases": ["hall"]}} >>> cleaned = drop_table_rings(rings, entities, view) >>> list(cleaned.ring_aliases), cleaned.dropped (['North Hall', 'Region A'], ('Hall', 'Trade')) >>> cleaned.carried, cleaned.created, cleaned.entities["sales.Trade"]["aliases"] (0, 1, ['deal']) >>> drop_table_rings({"Region A": ["a"]}, entities, view).entities is entities True """ doomed = ring_names.ring_keys_naming_tables(view, ring_aliases) if not doomed: return RingCleanup(dict(ring_aliases), entities) # type: ignore[arg-type] gone = set(doomed) grown = {key: dict(entry) for key, entry in entities.items()} named = {key.casefold(): key for key in grown} by_key = {table.key: table for table in view.tables} carried = created = 0 unplaced: list[str] = [] for key in doomed: aliases = list(ring_aliases[key]) table_key = _table_key(view, key) target = named.get(key.casefold()) or (named.get(table_key.casefold()) if table_key else None) if target is not None: before = list(grown[target].get("aliases") or []) after = _union_aliases(before, aliases) if len(after) > len(before): grown[target]["aliases"] = after carried += 1 elif table_key: table = by_key[table_key] grown[table_key] = {"table": table.name, "schema": table.schema or None, "aliases": aliases} named[table_key.casefold()] = table_key created += 1 else: unplaced.append(key) return RingCleanup( {key: list(value) for key, value in ring_aliases.items() if key not in gone}, grown, tuple(doomed), carried, created, tuple(unplaced), )