clearmetric-core 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. clearmetric/cli/__init__.py +157 -0
  2. clearmetric/cli/__main__.py +8 -0
  3. clearmetric/core/__init__.py +78 -0
  4. clearmetric/core/_version.py +3 -0
  5. clearmetric/core/aliases.py +91 -0
  6. clearmetric/core/errors.py +19 -0
  7. clearmetric/core/ids.py +172 -0
  8. clearmetric/core/interop.py +126 -0
  9. clearmetric/core/merge.py +147 -0
  10. clearmetric/core/models.py +68 -0
  11. clearmetric/core/serialize.py +10 -0
  12. clearmetric/lineage/__init__.py +36 -0
  13. clearmetric/lineage/_version.py +5 -0
  14. clearmetric/lineage/api.py +86 -0
  15. clearmetric/lineage/build.py +961 -0
  16. clearmetric/lineage/coverage.py +198 -0
  17. clearmetric/lineage/errors.py +15 -0
  18. clearmetric/lineage/graph.py +143 -0
  19. clearmetric/lineage/loaders.py +249 -0
  20. clearmetric/lineage/models.py +34 -0
  21. clearmetric/lineage/render/json.py +10 -0
  22. clearmetric/lineage/render/mermaid.py +40 -0
  23. clearmetric/lineage/render/text.py +108 -0
  24. clearmetric/lineage/sql_analyzer.py +409 -0
  25. clearmetric/powerbi/__init__.py +28 -0
  26. clearmetric/powerbi/_version.py +5 -0
  27. clearmetric/powerbi/api.py +55 -0
  28. clearmetric/powerbi/build.py +369 -0
  29. clearmetric/powerbi/discovery.py +109 -0
  30. clearmetric/powerbi/errors.py +20 -0
  31. clearmetric/powerbi/m_parser.py +242 -0
  32. clearmetric/powerbi/models.py +63 -0
  33. clearmetric/powerbi/native_sql.py +35 -0
  34. clearmetric/powerbi/render/json.py +15 -0
  35. clearmetric/powerbi/render/text.py +31 -0
  36. clearmetric/powerbi/report_parser.py +245 -0
  37. clearmetric/powerbi/tmdl.py +59 -0
  38. clearmetric/query/__init__.py +39 -0
  39. clearmetric/query/_version.py +5 -0
  40. clearmetric/query/api.py +39 -0
  41. clearmetric/query/ast_utils.py +81 -0
  42. clearmetric/query/build.py +263 -0
  43. clearmetric/query/ctes.py +127 -0
  44. clearmetric/query/errors.py +15 -0
  45. clearmetric/query/models.py +93 -0
  46. clearmetric/query/parser.py +67 -0
  47. clearmetric/query/relations.py +229 -0
  48. clearmetric/query/render/__init__.py +1 -0
  49. clearmetric/query/render/json.py +10 -0
  50. clearmetric/query/render/text.py +42 -0
  51. clearmetric_core-0.2.0.dist-info/METADATA +91 -0
  52. clearmetric_core-0.2.0.dist-info/RECORD +55 -0
  53. clearmetric_core-0.2.0.dist-info/WHEEL +5 -0
  54. clearmetric_core-0.2.0.dist-info/entry_points.txt +2 -0
  55. clearmetric_core-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,157 @@
1
+ """ClearMetric Core CLI — ``cm`` command router."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+
9
+ from clearmetric.core import __version__, render_json
10
+ from clearmetric.lineage import (
11
+ build_catalog_artifact,
12
+ build_lineage_map,
13
+ trace_downstream,
14
+ trace_upstream,
15
+ )
16
+ from clearmetric.lineage.errors import LineageError
17
+ from clearmetric.lineage.render.mermaid import render_traversal_mermaid
18
+ from clearmetric.lineage.render.text import render_text, render_traversal_tree
19
+
20
+
21
+ def _build_root_parser() -> argparse.ArgumentParser:
22
+ parser = argparse.ArgumentParser(
23
+ prog="cm",
24
+ description="ClearMetric Core — local compiler, graph engine, and CLI.",
25
+ )
26
+ parser.add_argument(
27
+ "--version",
28
+ action="version",
29
+ version=f"cm {__version__} (ClearMetric Core)",
30
+ )
31
+ subparsers = parser.add_subparsers(dest="command", required=True)
32
+
33
+ compile_parser = subparsers.add_parser(
34
+ "compile",
35
+ help="Compile project input into a catalog graph artifact (JSON).",
36
+ )
37
+ compile_parser.add_argument(
38
+ "project_input",
39
+ help="Path to a dbt manifest.json file or a folder of UTF-8 .sql files.",
40
+ )
41
+ compile_parser.add_argument(
42
+ "--dialect",
43
+ required=True,
44
+ help="sqlglot dialect name, for example postgres, snowflake, tsql, or bigquery.",
45
+ )
46
+ compile_parser.add_argument(
47
+ "--format",
48
+ choices=("json", "text"),
49
+ default="json",
50
+ help="Output format (default: json).",
51
+ )
52
+
53
+ impact_parser = subparsers.add_parser(
54
+ "impact",
55
+ help="Trace upstream or downstream column lineage for one selection.",
56
+ )
57
+ impact_parser.add_argument(
58
+ "selection",
59
+ help="Dataset column selection, for example orders.amount.",
60
+ )
61
+ impact_parser.add_argument(
62
+ "project_input",
63
+ help="Path to a dbt manifest.json file or a folder of UTF-8 .sql files.",
64
+ )
65
+ impact_parser.add_argument(
66
+ "--dialect",
67
+ required=True,
68
+ help="sqlglot dialect name, for example postgres, snowflake, tsql, or bigquery.",
69
+ )
70
+ traversal = impact_parser.add_mutually_exclusive_group(required=True)
71
+ traversal.add_argument(
72
+ "--upstream",
73
+ action="store_true",
74
+ help="Trace upstream lineage for the selection.",
75
+ )
76
+ traversal.add_argument(
77
+ "--downstream",
78
+ action="store_true",
79
+ help="Trace downstream impact for the selection.",
80
+ )
81
+ impact_parser.add_argument(
82
+ "--format",
83
+ choices=("text", "json", "mermaid"),
84
+ default="text",
85
+ help="Output format (default: text).",
86
+ )
87
+ return parser
88
+
89
+
90
+ def _run_compile(args: argparse.Namespace) -> int:
91
+ if args.format == "json":
92
+ artifact = build_catalog_artifact(args.project_input, dialect=args.dialect)
93
+ print(json.dumps(render_json(artifact), indent=2, sort_keys=False))
94
+ else:
95
+ lineage_map = build_lineage_map(args.project_input, dialect=args.dialect)
96
+ print(render_text(lineage_map))
97
+ return 0
98
+
99
+
100
+ def _run_impact(args: argparse.Namespace) -> int:
101
+ direction = "upstream" if args.upstream else "downstream"
102
+ if direction == "upstream":
103
+ result = trace_upstream(
104
+ args.project_input,
105
+ dialect=args.dialect,
106
+ selection=args.selection,
107
+ )
108
+ else:
109
+ result = trace_downstream(
110
+ args.project_input,
111
+ dialect=args.dialect,
112
+ selection=args.selection,
113
+ )
114
+
115
+ if args.format == "json":
116
+ print(json.dumps(result.model_dump(mode="json"), indent=2, sort_keys=False))
117
+ return 0
118
+
119
+ artifact = build_catalog_artifact(args.project_input, dialect=args.dialect)
120
+ if args.format == "mermaid":
121
+ print(
122
+ render_traversal_mermaid(
123
+ result.selection_id,
124
+ artifact,
125
+ direction=direction,
126
+ )
127
+ )
128
+ return 0
129
+
130
+ print(
131
+ render_traversal_tree(
132
+ result,
133
+ artifact,
134
+ direction=direction,
135
+ )
136
+ )
137
+ return 0
138
+
139
+
140
+ def main(argv: list[str] | None = None) -> int:
141
+ parser = _build_root_parser()
142
+ args = parser.parse_args(argv)
143
+
144
+ try:
145
+ if args.command == "compile":
146
+ return _run_compile(args)
147
+ if args.command == "impact":
148
+ return _run_impact(args)
149
+ except LineageError as exc:
150
+ print(f"cm error: {exc}", file=sys.stderr)
151
+ return 1
152
+
153
+ print(f"cm: unknown command {args.command!r}", file=sys.stderr)
154
+ return 1
155
+
156
+
157
+ __all__ = ["main"]
@@ -0,0 +1,8 @@
1
+ """``python -m clearmetric.cli`` entry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from . import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
@@ -0,0 +1,78 @@
1
+ """Public package surface for clearmetric-core."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._version import __version__
6
+ from .aliases import load_table_alias_map
7
+ from .errors import (
8
+ AliasMapError,
9
+ CanonicalIdError,
10
+ ClearMetricError,
11
+ MergeConflictError,
12
+ )
13
+ from .ids import (
14
+ asset_id,
15
+ column_id,
16
+ cte_id,
17
+ leaf_name,
18
+ measure_id,
19
+ model_id,
20
+ normalize_identifier,
21
+ normalize_identifier_part,
22
+ normalize_identifier_parts,
23
+ page_id,
24
+ report_id,
25
+ schema_name,
26
+ split_qualified_identifier,
27
+ table_id,
28
+ visual_id,
29
+ )
30
+ from .interop import (
31
+ AliasMap,
32
+ apply_alias_map,
33
+ normalize_fqn_for_matching,
34
+ resolve_table_match,
35
+ warehouse_table_fqn_candidates,
36
+ warehouse_table_fqn_candidates_from_name,
37
+ )
38
+ from .merge import merge
39
+ from .models import CatalogArtifact, Edge, Evidence, MatchStatus, Node, Warning
40
+ from .serialize import render_json
41
+
42
+ __all__ = [
43
+ "__version__",
44
+ "AliasMap",
45
+ "AliasMapError",
46
+ "MatchStatus",
47
+ "apply_alias_map",
48
+ "asset_id",
49
+ "CatalogArtifact",
50
+ "ClearMetricError",
51
+ "CanonicalIdError",
52
+ "column_id",
53
+ "cte_id",
54
+ "Edge",
55
+ "Evidence",
56
+ "leaf_name",
57
+ "load_table_alias_map",
58
+ "measure_id",
59
+ "merge",
60
+ "normalize_fqn_for_matching",
61
+ "MergeConflictError",
62
+ "model_id",
63
+ "Node",
64
+ "page_id",
65
+ "normalize_identifier",
66
+ "normalize_identifier_part",
67
+ "normalize_identifier_parts",
68
+ "render_json",
69
+ "report_id",
70
+ "resolve_table_match",
71
+ "schema_name",
72
+ "split_qualified_identifier",
73
+ "table_id",
74
+ "visual_id",
75
+ "warehouse_table_fqn_candidates",
76
+ "warehouse_table_fqn_candidates_from_name",
77
+ "Warning",
78
+ ]
@@ -0,0 +1,3 @@
1
+ """Package version."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,91 @@
1
+ """Load versioned table alias files for cross-graph matching."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from .errors import AliasMapError
8
+ from .interop import AliasMap, normalize_fqn_for_matching
9
+
10
+ _SUPPORTED_VERSION = "1"
11
+
12
+
13
+ def load_table_alias_map(path: str | Path) -> AliasMap:
14
+ """
15
+ Load a version-1 alias file into an ``AliasMap``.
16
+
17
+ Expected format::
18
+
19
+ version: 1
20
+ table_aliases:
21
+ salesmart.dbo.orders: orders
22
+ """
23
+ file_path = Path(path).expanduser().resolve()
24
+ if not file_path.is_file():
25
+ raise AliasMapError(f"Alias file does not exist: {file_path}")
26
+
27
+ version: str | None = None
28
+ aliases: AliasMap = {}
29
+ in_table_aliases = False
30
+
31
+ for line_number, raw_line in enumerate(
32
+ file_path.read_text(encoding="utf-8").splitlines(), start=1
33
+ ):
34
+ line = raw_line.strip()
35
+ if not line or line.startswith("#"):
36
+ continue
37
+
38
+ if line.startswith("version:"):
39
+ version = line.split(":", 1)[1].strip()
40
+ if version != _SUPPORTED_VERSION:
41
+ raise AliasMapError(
42
+ f"Unsupported alias file version {version!r} at {file_path}:{line_number}; "
43
+ f"expected {_SUPPORTED_VERSION!r}."
44
+ )
45
+ continue
46
+
47
+ if line == "table_aliases:":
48
+ in_table_aliases = True
49
+ continue
50
+
51
+ if not in_table_aliases:
52
+ raise AliasMapError(
53
+ f"Unexpected content at {file_path}:{line_number}; "
54
+ "expected 'version:' and 'table_aliases:' sections."
55
+ )
56
+
57
+ if ":" not in line:
58
+ raise AliasMapError(
59
+ f"Invalid alias entry at {file_path}:{line_number}; "
60
+ "expected 'source: target' form."
61
+ )
62
+
63
+ source, target = line.split(":", 1)
64
+ source = source.strip()
65
+ target = target.strip()
66
+ if not source or not target:
67
+ raise AliasMapError(
68
+ f"Invalid alias entry at {file_path}:{line_number}; "
69
+ "source and target must be non-empty."
70
+ )
71
+
72
+ normalized_source = normalize_fqn_for_matching(source)
73
+ normalized_target = normalize_fqn_for_matching(target)
74
+ if (
75
+ normalized_source in aliases
76
+ and aliases[normalized_source] != normalized_target
77
+ ):
78
+ raise AliasMapError(
79
+ f"Duplicate alias key {normalized_source!r} with conflicting targets "
80
+ f"at {file_path}:{line_number}."
81
+ )
82
+ aliases[normalized_source] = normalized_target
83
+
84
+ if version is None:
85
+ raise AliasMapError(f"Missing 'version:' in alias file: {file_path}")
86
+ if not in_table_aliases:
87
+ raise AliasMapError(
88
+ f"Missing 'table_aliases:' section in alias file: {file_path}"
89
+ )
90
+
91
+ return aliases
@@ -0,0 +1,19 @@
1
+ """Shared errors for clearmetric-core."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class ClearMetricError(Exception):
7
+ """Base class for clearmetric-core failures."""
8
+
9
+
10
+ class CanonicalIdError(ClearMetricError):
11
+ """Raised when an identifier cannot be normalized into a canonical ID."""
12
+
13
+
14
+ class MergeConflictError(ClearMetricError):
15
+ """Raised when artifacts cannot be merged without losing information."""
16
+
17
+
18
+ class AliasMapError(ClearMetricError):
19
+ """Raised when a table alias file is invalid or unsupported."""
@@ -0,0 +1,172 @@
1
+ """Canonical identifier normalization and ID builders."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+
7
+ from .errors import CanonicalIdError
8
+
9
+ _QUOTE_PAIRS = {
10
+ '"': '"',
11
+ "`": "`",
12
+ "[": "]",
13
+ }
14
+
15
+
16
+ def _strip_matching_quotes(value: str) -> str:
17
+ if len(value) < 2:
18
+ return value
19
+
20
+ first = value[0]
21
+ last = value[-1]
22
+ expected_last = _QUOTE_PAIRS.get(first)
23
+ if expected_last == last:
24
+ return value[1:-1]
25
+ return value
26
+
27
+
28
+ def normalize_identifier(value: str) -> str:
29
+ """Normalize a possibly qualified identifier into canonical dotted form."""
30
+ parts = split_qualified_identifier(value)
31
+ return normalize_identifier_parts(parts)
32
+
33
+
34
+ def normalize_identifier_parts(parts: Iterable[str]) -> str:
35
+ """Normalize already separated identifier parts into canonical dotted form."""
36
+ normalized_parts = [
37
+ normalize_identifier_part(part) for part in parts if str(part).strip()
38
+ ]
39
+ if not normalized_parts:
40
+ raise CanonicalIdError("Identifier must contain at least one non-empty part.")
41
+ return ".".join(normalized_parts)
42
+
43
+
44
+ def normalize_identifier_part(part: str) -> str:
45
+ """Normalize one identifier segment."""
46
+ value = str(part).strip()
47
+ if not value:
48
+ raise CanonicalIdError("Identifier part cannot be empty.")
49
+ if value == "*":
50
+ raise CanonicalIdError("Wildcard identifiers cannot be canonicalized.")
51
+ unquoted = _strip_matching_quotes(value).strip()
52
+ if not unquoted:
53
+ raise CanonicalIdError("Identifier part cannot be empty after unquoting.")
54
+ return unquoted.lower()
55
+
56
+
57
+ def split_qualified_identifier(value: str) -> list[str]:
58
+ """Split a qualified identifier on dots while respecting quoted segments."""
59
+ text = str(value).strip()
60
+ if not text:
61
+ raise CanonicalIdError("Identifier cannot be empty.")
62
+
63
+ parts: list[str] = []
64
+ current: list[str] = []
65
+ quote_stack: list[str] = []
66
+
67
+ for char in text:
68
+ if quote_stack:
69
+ current.append(char)
70
+ if char == quote_stack[-1]:
71
+ quote_stack.pop()
72
+ continue
73
+
74
+ if char in _QUOTE_PAIRS:
75
+ quote_stack.append(_QUOTE_PAIRS[char])
76
+ current.append(char)
77
+ continue
78
+
79
+ if char == ".":
80
+ part = "".join(current).strip()
81
+ if not part:
82
+ raise CanonicalIdError(f"Invalid qualified identifier {value!r}.")
83
+ parts.append(part)
84
+ current = []
85
+ continue
86
+
87
+ current.append(char)
88
+
89
+ if quote_stack:
90
+ raise CanonicalIdError(f"Unclosed quote in identifier {value!r}.")
91
+
92
+ final_part = "".join(current).strip()
93
+ if not final_part:
94
+ raise CanonicalIdError(f"Invalid qualified identifier {value!r}.")
95
+ parts.append(final_part)
96
+ return parts
97
+
98
+
99
+ def table_id(qualified_name: str) -> str:
100
+ return f"table:{normalize_identifier(qualified_name)}"
101
+
102
+
103
+ def cte_id(name: str) -> str:
104
+ return f"cte:{normalize_identifier_part(name)}"
105
+
106
+
107
+ def column_id(parent_qualified_name: str, column_name: str) -> str:
108
+ parent = normalize_identifier(parent_qualified_name)
109
+ column = normalize_identifier_part(column_name)
110
+ return f"column:{parent}.{column}"
111
+
112
+
113
+ def model_id(qualified_name: str) -> str:
114
+ return f"model:{normalize_identifier(qualified_name)}"
115
+
116
+
117
+ def report_id(qualified_name: str) -> str:
118
+ return f"report:{normalize_identifier(qualified_name)}"
119
+
120
+
121
+ def asset_id(qualified_name: str) -> str:
122
+ return f"asset:{normalize_identifier(qualified_name)}"
123
+
124
+
125
+ def visual_id(report_qualified_name: str, page_id: str, visual_id_value: str) -> str:
126
+ parent = normalize_identifier_parts(
127
+ [report_qualified_name, page_id, visual_id_value]
128
+ )
129
+ return f"visual:{parent}"
130
+
131
+
132
+ def page_id(report_qualified_name: str, page_id_value: str) -> str:
133
+ parent = normalize_identifier_parts([report_qualified_name, page_id_value])
134
+ return f"page:{parent}"
135
+
136
+
137
+ def measure_id(table_qualified_name: str, measure_name: str) -> str:
138
+ parent = normalize_identifier(table_qualified_name)
139
+ measure = normalize_identifier_part(measure_name)
140
+ return f"measure:{parent}.{measure}"
141
+
142
+
143
+ def schema_name(qualified_name: str) -> str | None:
144
+ normalized = normalize_identifier(qualified_name)
145
+ parts = normalized.split(".")
146
+ if len(parts) <= 1:
147
+ return None
148
+ return ".".join(parts[:-1])
149
+
150
+
151
+ def leaf_name(qualified_name: str) -> str:
152
+ normalized = normalize_identifier(qualified_name)
153
+ return normalized.split(".")[-1]
154
+
155
+
156
+ __all__ = [
157
+ "asset_id",
158
+ "column_id",
159
+ "cte_id",
160
+ "leaf_name",
161
+ "measure_id",
162
+ "model_id",
163
+ "normalize_identifier",
164
+ "normalize_identifier_part",
165
+ "normalize_identifier_parts",
166
+ "page_id",
167
+ "report_id",
168
+ "schema_name",
169
+ "split_qualified_identifier",
170
+ "table_id",
171
+ "visual_id",
172
+ ]
@@ -0,0 +1,126 @@
1
+ """Cross-graph interop: FQN matching, alias maps, and match status."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .errors import CanonicalIdError
6
+ from .ids import normalize_identifier
7
+ from .models import MatchStatus
8
+
9
+ AliasMap = dict[str, str]
10
+
11
+
12
+ def normalize_fqn_for_matching(value: str) -> str:
13
+ """Normalize a fully-qualified name for case-insensitive cross-graph comparison."""
14
+ return normalize_identifier(value)
15
+
16
+
17
+ def warehouse_table_fqn_candidates(
18
+ *,
19
+ database: str | None = None,
20
+ schema: str | None = None,
21
+ table: str,
22
+ ) -> list[str]:
23
+ """Build ordered FQN candidates for matching a warehouse table reference."""
24
+ if not str(table).strip():
25
+ raise CanonicalIdError(
26
+ "Table name is required to build warehouse FQN candidates."
27
+ )
28
+
29
+ parts: list[str] = []
30
+ if database and str(database).strip():
31
+ parts.append(str(database).strip())
32
+ if schema and str(schema).strip():
33
+ parts.append(str(schema).strip())
34
+ parts.append(str(table).strip())
35
+
36
+ candidates: list[str] = []
37
+ if len(parts) == 3:
38
+ candidates.append(normalize_fqn_for_matching(".".join(parts)))
39
+ if len(parts) >= 2:
40
+ candidates.append(normalize_fqn_for_matching(".".join(parts[-2:])))
41
+ candidates.append(normalize_fqn_for_matching(parts[-1]))
42
+
43
+ seen: set[str] = set()
44
+ ordered: list[str] = []
45
+ for candidate in candidates:
46
+ if candidate in seen:
47
+ continue
48
+ seen.add(candidate)
49
+ ordered.append(candidate)
50
+ return ordered
51
+
52
+
53
+ def warehouse_table_fqn_candidates_from_name(normalized_fqn: str) -> list[str]:
54
+ """Rebuild ordered warehouse FQN candidates from one normalized dotted name."""
55
+ parts = normalized_fqn.split(".")
56
+ if len(parts) == 3:
57
+ return warehouse_table_fqn_candidates(
58
+ database=parts[0],
59
+ schema=parts[1],
60
+ table=parts[2],
61
+ )
62
+ if len(parts) == 2:
63
+ return warehouse_table_fqn_candidates(schema=parts[0], table=parts[1])
64
+ if len(parts) == 1:
65
+ return warehouse_table_fqn_candidates(table=parts[0])
66
+ raise CanonicalIdError(
67
+ f"Cannot derive warehouse FQN candidates from name: {normalized_fqn!r}"
68
+ )
69
+
70
+
71
+ def apply_alias_map(name: str, alias_map: AliasMap | None) -> str:
72
+ """Resolve a name through the alias map, returning normalized form."""
73
+ normalized = normalize_fqn_for_matching(name)
74
+ if not alias_map:
75
+ return normalized
76
+ mapped = alias_map.get(normalized)
77
+ if mapped is None:
78
+ return normalized
79
+ return normalize_fqn_for_matching(mapped)
80
+
81
+
82
+ def resolve_table_match(
83
+ source_candidates: list[str],
84
+ target_table_ids: set[str],
85
+ *,
86
+ alias_map: AliasMap | None = None,
87
+ ) -> tuple[str | None, MatchStatus]:
88
+ """
89
+ Match source FQN candidates against canonical ``table:`` node IDs.
90
+
91
+ Returns the matched ``table:...`` ID and match status.
92
+ """
93
+ if not source_candidates:
94
+ return None, "unresolved"
95
+
96
+ target_by_normalized = {
97
+ normalize_fqn_for_matching(tid.removeprefix("table:")): tid
98
+ for tid in target_table_ids
99
+ }
100
+
101
+ matches: list[str] = []
102
+ for raw_candidate in source_candidates:
103
+ candidate = apply_alias_map(raw_candidate, alias_map)
104
+ for normalized_target, table_id in target_by_normalized.items():
105
+ if candidate == normalized_target or normalized_target.endswith(
106
+ f".{candidate}"
107
+ ):
108
+ matches.append(table_id)
109
+
110
+ unique_matches = sorted(set(matches))
111
+ if len(unique_matches) == 1:
112
+ return unique_matches[0], "resolved"
113
+ if len(unique_matches) > 1:
114
+ return unique_matches[0], "ambiguous"
115
+ return None, "unresolved"
116
+
117
+
118
+ __all__ = [
119
+ "AliasMap",
120
+ "MatchStatus",
121
+ "apply_alias_map",
122
+ "normalize_fqn_for_matching",
123
+ "resolve_table_match",
124
+ "warehouse_table_fqn_candidates",
125
+ "warehouse_table_fqn_candidates_from_name",
126
+ ]