codepbi 0.1.7__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.
@@ -0,0 +1,69 @@
1
+ # Generic helpers for reading a dbt model's own compiled SELECT list, used by
2
+ # check_schema_vs_sql. Requires `dbt compile` (or `dbt run`) to have already
3
+ # produced target/manifest.json and target/compiled/... .
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from sqlglot import exp, parse_one
9
+
10
+
11
+ def sql_exprs(sql: str, dialect: str = "tsql") -> dict:
12
+ """A model's own SELECT list: {output column: raw SQL expression text}."""
13
+ stmt = parse_one(sql, dialect=dialect)
14
+ cte_map = {
15
+ cte.alias.lower(): {
16
+ s.alias_or_name.lower(): str(s.this if isinstance(s, exp.Alias) else s)
17
+ for s in cte.this.selects if s.alias_or_name
18
+ }
19
+ for cte in stmt.find_all(exp.CTE)
20
+ }
21
+ result = {}
22
+ for s in stmt.selects:
23
+ alias = s.alias_or_name
24
+ expr = s.this if isinstance(s, exp.Alias) else s
25
+ if isinstance(expr, exp.Column) and not expr.table:
26
+ result[alias] = next(
27
+ (c[expr.name.lower()] for c in cte_map.values() if expr.name.lower() in c),
28
+ str(expr),
29
+ )
30
+ else:
31
+ result[alias] = str(expr)
32
+ return result
33
+
34
+
35
+ def load_model_data(manifest_path: Path, compiled_dir: Path) -> dict:
36
+ """dbt model name -> {dbt_db, dbt_schema, sql, compiled_path, compiled_exists}.
37
+
38
+ manifest_path: path to target/manifest.json.
39
+ compiled_dir: the folder compiled SQL is actually written under -- dbt's real
40
+ layout is target/compiled/<dbt_project_name>/<model-paths>/..., and the
41
+ project name and model-paths config aren't generically derivable, so the
42
+ caller must resolve this themselves (it's just target/compiled/<your
43
+ project name>/models for the common case).
44
+
45
+ The compiled path comes from the manifest node's own `path` (relative to
46
+ model-paths), not from the bare model name -- dbt writes to
47
+ <compiled_dir>/<subfolder>/<name>.sql, so a name-only lookup misses the
48
+ moment models live in folders.
49
+
50
+ The raw_code fallback is kept for models that genuinely have no compiled
51
+ output, but it is Jinja, not SQL: sqlglot parses it into garbage rather than
52
+ failing, which yields silently wrong results. compiled_exists lets callers
53
+ refuse instead.
54
+ """
55
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
56
+ result = {}
57
+ for node in manifest["nodes"].values():
58
+ if node.get("resource_type") != "model":
59
+ continue
60
+ p = compiled_dir / node.get("path", f"{node['name']}.sql")
61
+ exists = p.exists()
62
+ result[node["name"]] = {
63
+ "dbt_db": node.get("database", ""),
64
+ "dbt_schema": node.get("schema", ""),
65
+ "sql": p.read_text(encoding="utf-8") if exists else node.get("raw_code", ""),
66
+ "compiled_path": p,
67
+ "compiled_exists": exists,
68
+ }
69
+ return result
@@ -0,0 +1,138 @@
1
+ # Shared dbt schema YAML parser. Every generator/sync function that needs column or
2
+ # table metadata calls `load_schema` from here instead of parsing schema YAML
3
+ # itself -- schema YAML is the single source of truth for column metadata, so
4
+ # there is exactly one reader for it.
5
+
6
+ import yaml
7
+
8
+ from . import tom_utils
9
+ from .config import TmdlProjectConfig
10
+
11
+ # schema YAML data_type -> TMDL sourceProviderType + formatString.
12
+ # 'format' is only the default for a bare data_type; meta.format_string overrides it.
13
+ # double has no default format on purpose: a money column must declare one, or it
14
+ # renders as a raw number with no totals.
15
+ DATATYPE_MAP = {
16
+ "int64": {"provider": "int", "format": "0"},
17
+ "string": {"provider": "nvarchar(255)", "format": None},
18
+ "dateTime": {"provider": "datetime2", "format": "General Date"},
19
+ "boolean": {"provider": "bit", "format": '"TRUE";"TRUE";"FALSE"'},
20
+ "double": {"provider": "float", "format": None},
21
+ }
22
+
23
+ # meta.summarize_by -> TOM AggregateFunction member name. 'none' makes a numeric column
24
+ # behave as an attribute: no implicit aggregation and no totals row. That is correct for
25
+ # semi-additive snapshots (a point-in-time balance must not be summed across periods) and
26
+ # wrong for an ordinary money column.
27
+ SUMMARIZE_MAP = {
28
+ "none": "None", "default": "Default", "sum": "Sum", "average": "Average",
29
+ "count": "Count", "distinctCount": "DistinctCount", "min": "Min", "max": "Max",
30
+ }
31
+
32
+ # TOM DataType name -> schema YAML data_type, for the drift check in sync_descriptions.
33
+ TOM_TO_YAML = {"String": "string", "Int64": "int64", "DateTime": "dateTime",
34
+ "Double": "double", "Boolean": "boolean"}
35
+
36
+
37
+ def aggregate_function(value, source):
38
+ from Microsoft.AnalysisServices.Tabular import AggregateFunction
39
+
40
+ if value not in SUMMARIZE_MAP:
41
+ raise SystemExit(
42
+ f"Unknown summarize_by {value!r} for column {source!r}. "
43
+ f"Must be one of: {sorted(SUMMARIZE_MAP)}"
44
+ )
45
+ return getattr(AggregateFunction, SUMMARIZE_MAP[value])
46
+
47
+
48
+ def _model_blocks(config: TmdlProjectConfig):
49
+ """Every `models:` entry across the configured schema YAML glob, sorted by model name.
50
+
51
+ Lets schema YAML be split across multiple files (e.g. per layer) without every
52
+ caller needing to know the split. semantic_models_subdir and any `sources.yml`
53
+ are excluded: they are different documents that also carry a top-level `models`-
54
+ shaped key we would otherwise misread.
55
+ """
56
+ sm_dir = config.semantic_models_dir
57
+ blocks, seen = [], {}
58
+ for path in sorted(config.dbt_project_dir.glob(config.models_glob)):
59
+ if sm_dir in path.parents or path.name == "sources.yml":
60
+ continue
61
+ with open(path, encoding="utf-8") as f:
62
+ doc = yaml.safe_load(f) or {}
63
+ for model in doc.get("models", []) or []:
64
+ name = model.get("name")
65
+ if name in seen:
66
+ raise SystemExit(
67
+ f"duplicate model {name!r} in schema YAML: "
68
+ f"{seen[name]} and {path}. "
69
+ "A silent last-write-wins here loses a model's column metadata."
70
+ )
71
+ seen[name] = path
72
+ blocks.append(model)
73
+ blocks.sort(key=lambda m: m.get("name") or "")
74
+ return blocks
75
+
76
+
77
+ def load_schema(config: TmdlProjectConfig) -> dict:
78
+ """Schema YAML -> {pbi_table: {'entity','grain','columns': [colinfo]}}.
79
+
80
+ colinfo carries everything TMDL needs; the schema YAML is the only source of truth.
81
+ """
82
+ out = {}
83
+ for model in _model_blocks(config):
84
+ meta = model.get("meta", {}) or {}
85
+ pbi_table = tom_utils.pbi_table_of(meta)
86
+ if not pbi_table:
87
+ continue
88
+ cols = []
89
+ for col in model.get("columns", []):
90
+ cm = col.get("meta", {}) or {}
91
+ colinfo = {
92
+ "source": col["name"],
93
+ "display": cm.get("display_name", col["name"]),
94
+ "desc": col.get("description", ""),
95
+ "folder": cm.get("displayFolder", ""),
96
+ "hidden": bool(cm.get("hidden", False)),
97
+ "data_type": cm.get("data_type"),
98
+ # None means "not declared" -> leave whatever TMDL has. Declaring these
99
+ # is what makes a money column survive a table rebuild.
100
+ "format_string": cm.get("format_string"),
101
+ "summarize_by": cm.get("summarize_by"),
102
+ }
103
+ for key, default in config.extra_column_meta.items():
104
+ colinfo[key] = cm.get(key, default)
105
+ cols.append(colinfo)
106
+ table_info = {
107
+ "entity": meta.get("entity", ""),
108
+ "grain": meta.get("grain", ""),
109
+ # The dbt model name, so generators can emit ref() without reading the
110
+ # TMDL partition to find out which model backs the table.
111
+ "model": model["name"],
112
+ "columns": cols,
113
+ }
114
+ for key, default in config.extra_table_meta.items():
115
+ table_info[key] = meta.get(key, default)
116
+ out[pbi_table] = table_info
117
+ return out
118
+
119
+
120
+ def model_to_table(config: TmdlProjectConfig) -> dict:
121
+ return {spec["model"]: name for name, spec in load_schema(config).items()}
122
+
123
+
124
+ def iter_semantic_models(config: TmdlProjectConfig):
125
+ """Yield (path, doc, sm, table_name) for every semantic-model YAML file with a
126
+ table resolvable via schema YAML. Shared by every function that reads
127
+ entities/measures out of those files, so the glob-and-parse loop exists once."""
128
+ m2t = model_to_table(config)
129
+ for path in sorted(config.semantic_models_dir.glob("*.yml")):
130
+ if path.name.startswith("_"):
131
+ continue
132
+ with open(path, encoding="utf-8") as f:
133
+ doc = yaml.safe_load(f) or {}
134
+ for sm in doc.get("semantic_models") or []:
135
+ dbt_model = sm["model"][len("ref('"):-len("')")]
136
+ table_name = m2t.get(dbt_model)
137
+ if table_name:
138
+ yield path, doc, sm, table_name
@@ -0,0 +1,147 @@
1
+ # Sync schema YAML -> TMDL columns, descriptions, folders, hidden flags, format,
2
+ # summarization. Inserts columns that don't exist yet. Descriptions are tagged
3
+ # [synced] so hand-written ones (untagged) are never overwritten.
4
+ #
5
+ # TMDL is read and written through TOM (see tom_utils) -- never regex.
6
+
7
+ import uuid
8
+
9
+ from . import tom_utils
10
+ from .config import TmdlProjectConfig
11
+ from .schema_loader import DATATYPE_MAP, TOM_TO_YAML, aggregate_function, load_schema
12
+ from .validate_tmdl import validate_tmdl
13
+
14
+ SYNCED_TAG = "[synced]"
15
+
16
+
17
+ def _new_column(info):
18
+ """Build a TOM DataColumn from a schema YAML column entry."""
19
+ from Microsoft.AnalysisServices.Tabular import AggregateFunction, Annotation, DataColumn, DataType
20
+
21
+ data_type = info["data_type"]
22
+ if data_type not in DATATYPE_MAP:
23
+ raise SystemExit(
24
+ f"Unknown data_type {data_type!r} for column {info['source']!r}. "
25
+ f"Must be one of: {sorted(DATATYPE_MAP)}"
26
+ )
27
+ spec = DATATYPE_MAP[data_type]
28
+
29
+ col = DataColumn()
30
+ col.Name = info["display"]
31
+ col.DataType = getattr(DataType, data_type[0].upper() + data_type[1:])
32
+ col.SourceColumn = info["source"]
33
+ col.SourceProviderType = spec["provider"]
34
+ fmt = info["format_string"] or spec["format"]
35
+ if fmt:
36
+ col.FormatString = fmt
37
+ if info["folder"]:
38
+ col.DisplayFolder = info["folder"]
39
+ if info["hidden"]:
40
+ col.IsHidden = True
41
+ if info["desc"]:
42
+ col.Description = info["desc"]
43
+ col.LineageTag = str(uuid.uuid4())
44
+ col.SummarizeBy = (
45
+ aggregate_function(info["summarize_by"], info["source"])
46
+ if info["summarize_by"]
47
+ else getattr(AggregateFunction, "None")
48
+ )
49
+
50
+ ann = Annotation()
51
+ ann.Name = "SummarizationSetBy"
52
+ ann.Value = "Automatic"
53
+ col.Annotations.Add(ann)
54
+ return col
55
+
56
+
57
+ def sync(config: TmdlProjectConfig):
58
+ """Sync schema YAML -> TMDL: display names, descriptions, folders, hidden flags.
59
+
60
+ Inserts columns that don't exist yet. Applies schema YAML first, then validates
61
+ the result -- validating first would deadlock, since the fix for a TMDL error
62
+ often lives in schema YAML and can only land by syncing.
63
+ """
64
+ wanted = load_schema(config)
65
+ db, model = tom_utils.load_model(config)
66
+
67
+ inserts, renames, updates = [], [], 0
68
+ type_drift = []
69
+
70
+ for pbi_table, spec in wanted.items():
71
+ if not model.Tables.ContainsName(pbi_table):
72
+ continue
73
+ table = model.Tables[pbi_table]
74
+ by_source = {c.SourceColumn: c for c in table.Columns if c.SourceColumn}
75
+
76
+ for info in spec["columns"]:
77
+ col = by_source.get(info["source"])
78
+
79
+ # A column's type is only ever set at creation, so a declaration that
80
+ # disagrees with TMDL is silently inert. This also catches Power BI
81
+ # Desktop retyping a column behind your back (it does this sometimes
82
+ # when a view's inferred type looks different on refresh).
83
+ if col is not None and info["data_type"]:
84
+ actual = TOM_TO_YAML.get(str(col.DataType))
85
+ if actual and actual != info["data_type"]:
86
+ type_drift.append(
87
+ f"{pbi_table}.{info['display']} ({info['source']}): "
88
+ f"schema YAML says {info['data_type']}, TMDL has {actual}"
89
+ )
90
+
91
+ if col is None:
92
+ if not info["data_type"]:
93
+ raise SystemExit(
94
+ f"{pbi_table}: new column {info['source']!r} missing "
95
+ f"meta.data_type in schema YAML (required for auto-insert)"
96
+ )
97
+ table.Columns.Add(_new_column(info))
98
+ inserts.append(f"{pbi_table}.{info['source']}")
99
+ continue
100
+
101
+ if col.Name != info["display"]:
102
+ renames.append(f"{pbi_table}: {col.Name!r} -> {info['display']!r}")
103
+ col.Name = info["display"]
104
+
105
+ # Only manage descriptions this sync owns -- leave untagged ones alone.
106
+ current = col.Description or ""
107
+ if info["desc"] and (not current or SYNCED_TAG in current):
108
+ updates += tom_utils.set_if_changed(col, "Description", f"{SYNCED_TAG} {info['desc']}")
109
+
110
+ updates += tom_utils.set_if_changed(col, "IsHidden", info["hidden"], bool)
111
+
112
+ if info["folder"]:
113
+ updates += tom_utils.set_if_changed(col, "DisplayFolder", info["folder"])
114
+
115
+ # Only touch these where schema YAML declares them -- applying a default to
116
+ # every column would strip the format off columns that already carry one.
117
+ if info["format_string"]:
118
+ updates += tom_utils.set_if_changed(col, "FormatString", info["format_string"])
119
+
120
+ if info["summarize_by"]:
121
+ want = aggregate_function(info["summarize_by"], info["source"])
122
+ updates += tom_utils.set_if_changed(col, "SummarizeBy", want, lambda v: v)
123
+
124
+ tom_utils.save_model(config, db)
125
+
126
+ print(f"TMDL: {len(inserts)} new column(s) inserted.")
127
+ for i in inserts:
128
+ print(f" inserted {i}")
129
+ print(f"TMDL: {updates} property update(s).")
130
+ if renames:
131
+ print(f"TMDL: {len(renames)} column display-name rename(s):")
132
+ for r in renames:
133
+ print(f" {r}")
134
+ print("Review DAX/relationship/role refs -- sync only renames the column header.")
135
+
136
+ if type_drift:
137
+ print(f"\nERROR: {len(type_drift)} column data_type mismatch(es):")
138
+ for d in type_drift:
139
+ print(f" {d}")
140
+ print("\nA column type is only applied when the column is first created, so one "
141
+ "of the two is wrong and neither will change on its own.")
142
+ print("Check what the underlying SQL actually returns, then fix whichever side "
143
+ "disagrees with it. Only dataType is checked -- sourceProviderType is left "
144
+ "to Power BI Desktop, which legitimately refines it (e.g. datetime2 -> date).")
145
+ raise SystemExit(1)
146
+
147
+ validate_tmdl(config)
@@ -0,0 +1,127 @@
1
+ # Apply semantic-model YAML `measures:` -> TMDL, and export back the other way.
2
+ #
3
+ # semantic-model YAML is the measure authority -- DAX lives in each measure's
4
+ # `description` (after the trailing "DAX: " marker), format/folder in
5
+ # `config.meta`. Only touches measures -- columns, partitions, relationships,
6
+ # entities and dimensions untouched. Idempotent: rerun with same config = same
7
+ # output.
8
+
9
+ import re
10
+
11
+ import yaml
12
+
13
+ from .config import TmdlProjectConfig
14
+ from .schema_loader import iter_semantic_models, load_schema
15
+ from .tom_utils import load_model, save_model, set_if_changed
16
+
17
+ _DAX_RE = re.compile(r"DAX:\s*(.*)$", re.DOTALL)
18
+
19
+
20
+ def _dax_from_description(description):
21
+ m = _DAX_RE.search(description or "")
22
+ return m.group(1).strip() if m else ""
23
+
24
+
25
+ def load_by_table(config: TmdlProjectConfig) -> dict:
26
+ """semantic-model YAML -> {tmdl_table_name: [{name, dax, formatString, displayFolder}]}."""
27
+ by_table = {}
28
+ for _path, _doc, sm, table_name in iter_semantic_models(config):
29
+ for m in sm.get("measures") or []:
30
+ meta = ((m.get("config") or {}).get("meta")) or {}
31
+ by_table.setdefault(table_name, []).append({
32
+ "name": m.get("label") or m["name"],
33
+ "dax": _dax_from_description(m.get("description", "")),
34
+ "formatString": meta.get("format_string", ""),
35
+ "displayFolder": meta.get("displayFolder", "Calculations"),
36
+ })
37
+ return by_table
38
+
39
+
40
+ def export(config: TmdlProjectConfig):
41
+ """Export TMDL measures back into each table's semantic-model YAML, replacing
42
+ only its `measures:` block -- entities/dimensions/description untouched."""
43
+ table_to_model = {name: spec["model"] for name, spec in load_schema(config).items()}
44
+
45
+ db, model = load_model(config)
46
+ by_table = {}
47
+ for table in model.Tables:
48
+ entries = []
49
+ for m in table.Measures:
50
+ entries.append({
51
+ "name": m.Name,
52
+ "agg": "sum",
53
+ "label": m.Name,
54
+ "description": f"DAX: {m.Expression.strip()}",
55
+ "config": {"meta": {
56
+ "format_string": m.FormatString or "",
57
+ "displayFolder": m.DisplayFolder or "Calculations",
58
+ }},
59
+ })
60
+ if entries:
61
+ entries.sort(key=lambda e: e["name"])
62
+ by_table[table.Name] = entries
63
+
64
+ written = 0
65
+ for table_name, measures in by_table.items():
66
+ dbt_model = table_to_model.get(table_name)
67
+ if not dbt_model:
68
+ print(f' WARNING: "{table_name}" has TMDL measures but no schema YAML entry, skipping')
69
+ continue
70
+ out_file = config.semantic_models_dir / f"{re.sub(r'[^A-Za-z0-9]+', '_', table_name).strip('_').lower()}.yml"
71
+ if not out_file.exists():
72
+ print(f' WARNING: no semantic model file for "{table_name}" at {out_file.name}, skipping')
73
+ continue
74
+ with open(out_file, encoding="utf-8") as f:
75
+ doc = yaml.safe_load(f)
76
+ doc["semantic_models"][0]["measures"] = measures
77
+ text = yaml.safe_dump(doc, default_flow_style=False, sort_keys=False, allow_unicode=True, width=200)
78
+ out_file.write_text(text, encoding="utf-8")
79
+ written += 1
80
+
81
+ total = sum(len(v) for v in by_table.values())
82
+ print(f"Exported {total} measure(s) across {written} file(s)")
83
+
84
+
85
+ def sync(config: TmdlProjectConfig):
86
+ """Apply semantic-model YAML measures -> TMDL via TOM."""
87
+ by_table = load_by_table(config)
88
+ total = sum(len(v) for v in by_table.values())
89
+ if not total:
90
+ print("No measures found in semantic-model YAML")
91
+ return
92
+
93
+ db, model = load_model(config)
94
+ from Microsoft.AnalysisServices.Tabular import Measure
95
+
96
+ updated = created = 0
97
+
98
+ for table_name, entries in by_table.items():
99
+ table = model.Tables.Find(table_name)
100
+ if table is None:
101
+ print(f' WARNING: table "{table_name}" not found, skipping its measures')
102
+ continue
103
+
104
+ for entry in entries:
105
+ measure_name = entry["name"]
106
+ dax = entry["dax"]
107
+ fmt = entry.get("formatString", "")
108
+ folder = entry.get("displayFolder", "Calculations")
109
+
110
+ existing = table.Measures.Find(measure_name)
111
+ if existing:
112
+ changed = set_if_changed(existing, "Expression", dax.strip(), lambda v: (v or "").strip())
113
+ changed |= set_if_changed(existing, "FormatString", fmt)
114
+ changed |= set_if_changed(existing, "DisplayFolder", folder)
115
+ if changed:
116
+ updated += 1
117
+ else:
118
+ m = Measure()
119
+ m.Name = measure_name
120
+ m.Expression = dax
121
+ m.FormatString = fmt
122
+ m.DisplayFolder = folder
123
+ table.Measures.Add(m)
124
+ created += 1
125
+
126
+ save_model(config, db)
127
+ print(f"Measures synced: {created} created, {updated} updated ({total} total in config)")