dbt-extra 0.1.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 (52) hide show
  1. dbt_extra/__init__.py +18 -0
  2. dbt_extra/__main__.py +3 -0
  3. dbt_extra/analysis/__init__.py +5 -0
  4. dbt_extra/analysis/analyzer.py +111 -0
  5. dbt_extra/analysis/dependencies.py +26 -0
  6. dbt_extra/analysis/lineage.py +35 -0
  7. dbt_extra/analysis/models.py +34 -0
  8. dbt_extra/analysis/types.py +12 -0
  9. dbt_extra/cli.py +243 -0
  10. dbt_extra/config.py +71 -0
  11. dbt_extra/enrichment.py +84 -0
  12. dbt_extra/errors.py +5 -0
  13. dbt_extra/files/__init__.py +1 -0
  14. dbt_extra/files/changes.py +185 -0
  15. dbt_extra/files/paths.py +29 -0
  16. dbt_extra/files/yaml.py +150 -0
  17. dbt_extra/inspection.py +188 -0
  18. dbt_extra/optimization.py +160 -0
  19. dbt_extra/project/__init__.py +6 -0
  20. dbt_extra/project/artifacts.py +28 -0
  21. dbt_extra/project/discovery.py +110 -0
  22. dbt_extra/project/project.py +96 -0
  23. dbt_extra/project/properties.py +42 -0
  24. dbt_extra/project/resources.py +42 -0
  25. dbt_extra/project/selectors.py +30 -0
  26. dbt_extra/review/__init__.py +6 -0
  27. dbt_extra/review/contract.py +51 -0
  28. dbt_extra/review/csv.py +39 -0
  29. dbt_extra/review/export.py +75 -0
  30. dbt_extra/review/import_.py +172 -0
  31. dbt_extra/review/json.py +7 -0
  32. dbt_extra/review/workbook.py +596 -0
  33. dbt_extra/sql/__init__.py +6 -0
  34. dbt_extra/sql/inspection_rules.py +92 -0
  35. dbt_extra/sql/jinja.py +77 -0
  36. dbt_extra/sql/macros.py +304 -0
  37. dbt_extra/sql/parser.py +130 -0
  38. dbt_extra/sql/qualification.py +143 -0
  39. dbt_extra/sql/references.py +31 -0
  40. dbt_extra/sql/relations.py +105 -0
  41. dbt_extra/sql/renderer.py +75 -0
  42. dbt_extra/sql/schema_evidence.py +90 -0
  43. dbt_extra/sql/scopes.py +109 -0
  44. dbt_extra/sql/shorthand.py +197 -0
  45. dbt_extra/sql/source_expressions.py +116 -0
  46. dbt_extra/sql/source_rules.py +317 -0
  47. dbt_extra/validation.py +26 -0
  48. dbt_extra-0.1.0.dist-info/METADATA +206 -0
  49. dbt_extra-0.1.0.dist-info/RECORD +52 -0
  50. dbt_extra-0.1.0.dist-info/WHEEL +4 -0
  51. dbt_extra-0.1.0.dist-info/entry_points.txt +2 -0
  52. dbt_extra-0.1.0.dist-info/licenses/LICENSE +21 -0
dbt_extra/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """Offline dbt transformation review, inspection, and checked source edits."""
2
+
3
+ from dbt_extra.errors import SpecError
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ __all__ = ["Project", "ChangeSet", "SpecError", "__version__"]
8
+
9
+
10
+ def __getattr__(name):
11
+ # Version/error access (including CLI --version) needs no project loading.
12
+ if name == "Project":
13
+ from dbt_extra.project import Project
14
+ return Project
15
+ if name == "ChangeSet":
16
+ from dbt_extra.files.changes import ChangeSet
17
+ return ChangeSet
18
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
dbt_extra/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from dbt_extra.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,5 @@
1
+ """Shared dbt analysis used by inspection, review, and enrichment."""
2
+
3
+ from dbt_extra.analysis.analyzer import analyze, catalog
4
+
5
+ __all__ = ["analyze", "catalog"]
@@ -0,0 +1,111 @@
1
+ """Shared best-effort dbt source analysis with explicit coverage and provenance."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from sqlglot import exp
6
+ from sqlglot.errors import SqlglotError
7
+ from dbt_extra.analysis.lineage import column_lineage, qualify_columns
8
+ from dbt_extra.analysis.models import ResourceAnalysis, coverage
9
+ from dbt_extra.analysis.types import inferred_types
10
+ from dbt_extra.sql.macros import macro_calls, parse_model, contains_opaque
11
+ from dbt_extra.sql.references import resolve_ref
12
+ from dbt_extra.errors import SpecError
13
+ from dbt_extra.sql.scopes import relation_tables
14
+ from dbt_extra.sql.renderer import sql, render
15
+
16
+
17
+ def analyze(project, resource, *, expand=False) -> ResourceAnalysis:
18
+ result = {"resource_id": resource.id, "name": resource.name, "resource_type": resource.kind,
19
+ "package": resource.package, "model_path": resource.model_path, "source_file": resource.source_file,
20
+ "properties_file": resource.property_path.relative_to(project.root).as_posix() if resource.property_path else None,
21
+ "description": resource.properties.get("description", ""), "properties": resource.properties,
22
+ "config": resource.config, "sql": resource.code, "columns": [], "dependencies": [],
23
+ "status": "metadata_only", "reason": None, "provenance": "authored" if resource.path else "artifact"}
24
+ declared = resource.columns
25
+ outputs = {name: {"name": name, "expression": "", "description": props.get("description", ""),
26
+ "data_type": props.get("data_type", ""), "properties": props,
27
+ "upstream_columns": [], "status": "declared", "provenance": "authored" if resource.property_path else "artifact"}
28
+ for name, props in declared.items()}
29
+ if resource.kind == "model" and (not resource.path or resource.path.suffix == ".sql"):
30
+ try:
31
+ dialect = project.require_dialect()
32
+ parsed = parse_model(project, resource.code, resource.id, expand=expand, partial=True)
33
+ provenance = "expanded_macro_sql" if expand and macro_calls(resource.code) else "authored_sql"
34
+ result["config"] = {**result["config"], **parsed.config}
35
+ result["status"] = "parsed"
36
+ if parsed.opaque_macros:
37
+ result["status"], result["reason"] = "partial", "; ".join(dict.fromkeys(parsed.opaque_macros.values()))
38
+ dependencies = {}
39
+ schema = {}
40
+ for token, ref in parsed.refs.items():
41
+ upstream = resolve_ref(project, ref)
42
+ dependencies[token] = upstream.id if upstream else ref.text
43
+ if upstream:
44
+ if upstream.known_columns:
45
+ schema[token] = {name: p.get("data_type") or "UNKNOWN" for name, p in upstream.known_columns.items()}
46
+ result["dependencies"] = list(dict.fromkeys(dependencies.values()))
47
+ result["context"] = {"query": resource.code, "relations": [sql(t, dialect) for t in relation_tables(parsed.tree)],
48
+ "note": "Expressions depend on complete query context, including joins, filters, grouping and multiplicity"}
49
+ projections = parsed.tree.selects
50
+ counts = {}
51
+ for p in projections:
52
+ counts[p.output_name] = counts.get(p.output_name, 0) + 1
53
+ for projection in projections:
54
+ if contains_opaque(projection, parsed):
55
+ continue # A template boundary may emit any number of columns.
56
+ name = projection.output_name
57
+ if not name or projection.is_star or counts[name] != 1:
58
+ result["status"], result["reason"] = "partial", "Wildcard, unnamed, or duplicate output columns require additional schema evidence"
59
+ continue
60
+ item = outputs.setdefault(name, {"name": name, "description": "", "data_type": "", "properties": {}, "upstream_columns": []})
61
+ expression = projection.this if isinstance(projection, exp.Alias) else projection
62
+ expression_text = render(expression, parsed, dialect, pretty=False).strip().rstrip(";")
63
+ # Template relations in scalar subqueries remain readable; expression
64
+ # writeback itself is restricted to independently parseable expressions.
65
+ item.update(expression=expression_text, status="parsed", provenance=provenance)
66
+ # Unknown shapes cannot supply schema/type/lineage evidence. Keep
67
+ # independently parsed expressions, without qualifying through them.
68
+ if not parsed.opaque_macros:
69
+ try:
70
+ qualified = qualify_columns(parsed.tree, schema, dialect)
71
+ for projection in qualified.selects:
72
+ name = projection.output_name
73
+ if name and not projection.is_star and name not in outputs:
74
+ expression = projection.this if isinstance(projection, exp.Alias) else projection
75
+ outputs[name] = {"name": name, "description": "", "data_type": "", "properties": {},
76
+ "upstream_columns": [], "expression": render(expression, parsed, dialect, pretty=False).strip().rstrip(";"), "status": "parsed", "provenance": provenance}
77
+ if not any(p.is_star for p in qualified.selects) and result["reason"] == "Wildcard, unnamed, or duplicate output columns require additional schema evidence":
78
+ result["status"], result["reason"] = "parsed", None
79
+ types = inferred_types(qualified, schema, dialect)
80
+ lineage_for = column_lineage(qualified, schema, dialect, dependencies)
81
+ output_names = {p.output_name for p in qualified.selects if not p.is_star}
82
+ for name, item in outputs.items():
83
+ if name not in output_names:
84
+ continue
85
+ if name in types:
86
+ item["inferred_type"] = types[name]
87
+ leaves = lineage_for(name)
88
+ item["upstream_columns"] = leaves
89
+ item["status"] = "resolved" if leaves else "parsed"
90
+ except (SqlglotError, ValueError, KeyError) as exc:
91
+ result["status"], result["reason"] = "partial", f"Column qualification unavailable: {exc}"
92
+ except (SpecError, SqlglotError) as exc:
93
+ result["status"], result["reason"] = "unavailable", str(exc)
94
+ elif resource.kind == "macro":
95
+ result["status"], result["reason"] = "source_only", "Macro files are editable source, not expanded SQL"
96
+ elif resource.kind == "snapshot":
97
+ result["reason"] = "Snapshots retain their execution/history boundary"
98
+ if not resource.path:
99
+ result["reason"] = "Artifact/package resource is read-only; source freshness is unverified"
100
+ result["columns"] = list(outputs.values())
101
+ return result
102
+
103
+
104
+ def catalog(project, resources, *, expand=False):
105
+ models = [analyze(project, resource, expand=expand) for resource in resources]
106
+ return {"format_version": 1, "project": project.name, "dialect": project.dialect,
107
+ "source_of_truth": "dbt", "resources": models, "diagnostics": project.diagnostics,
108
+ "artifact_evidence": {"manifest": str(project.manifest_path) if project.manifest else None,
109
+ "generated_at": project.manifest.get("metadata", {}).get("generated_at"),
110
+ "compiled_sql_used_for_rewriting": False},
111
+ "coverage": coverage(models)}
@@ -0,0 +1,26 @@
1
+ """Resource dependency graphs and cycle checks."""
2
+
3
+ from dbt_extra.errors import SpecError
4
+ from dbt_extra.sql.references import resolve_ref
5
+
6
+
7
+ def dependencies(project, parsed):
8
+ return [up.id for ref in parsed.refs.values() if (up := resolve_ref(project, ref))]
9
+
10
+
11
+ def validate_cycles(graph):
12
+ active, done = [], set()
13
+
14
+ def visit(uid):
15
+ if uid in active:
16
+ raise SpecError(f"Cyclic dbt references: {' -> '.join((*active, uid))}")
17
+ if uid in done:
18
+ return
19
+ active.append(uid)
20
+ for dependency in graph.get(uid, []):
21
+ visit(dependency)
22
+ active.pop()
23
+ done.add(uid)
24
+
25
+ for uid in graph:
26
+ visit(uid)
@@ -0,0 +1,35 @@
1
+ """Conservative qualification and direct column lineage."""
2
+
3
+ from sqlglot import exp
4
+ from sqlglot.lineage import lineage
5
+ from sqlglot.optimizer.qualify import qualify
6
+ from sqlglot.optimizer.scope import build_scope
7
+
8
+ from dbt_extra.errors import SpecError
9
+ from dbt_extra.sql.qualification import bind_single_inputs, check_bindings
10
+ from dbt_extra.sql.renderer import sql
11
+
12
+
13
+ def qualify_columns(tree, schema, dialect):
14
+ try:
15
+ schema = check_bindings(tree, schema, dialect, strict=False)
16
+ except SpecError as exc:
17
+ raise ValueError(str(exc)) from exc
18
+ return qualify(bind_single_inputs(tree.copy(), schema, dialect), dialect=dialect,
19
+ schema=schema, validate_qualify_columns=True)
20
+
21
+
22
+ def column_lineage(tree, schema, dialect, dependencies):
23
+ scope = build_scope(tree)
24
+
25
+ def for_column(name):
26
+ graph = lineage(name, tree, schema=schema, dialect=dialect, scope=scope, trim_selects=False)
27
+ leaves = []
28
+ for node in graph.walk():
29
+ if not node.downstream and isinstance(node.expression, exp.Table):
30
+ relation = dependencies.get(node.expression.name, sql(node.expression, dialect))
31
+ column = node.name.rsplit(".", 1)[-1].strip('"`[]')
32
+ leaves.append({"resource_id": relation, "column": column})
33
+ return leaves
34
+
35
+ return for_column
@@ -0,0 +1,34 @@
1
+ """Coverage and provenance vocabulary for JSON-compatible analysis results.
2
+
3
+ Results describe dbt definitions, not an independently authored semantic model.
4
+ """
5
+
6
+ from collections import Counter
7
+ from typing import Literal, NotRequired, TypedDict
8
+
9
+
10
+ Status = Literal["metadata_only", "parsed", "partial", "unavailable", "source_only"]
11
+
12
+
13
+ class ResourceAnalysis(TypedDict):
14
+ resource_id: str
15
+ name: str
16
+ resource_type: str
17
+ package: str
18
+ model_path: str
19
+ source_file: str
20
+ properties_file: str | None
21
+ description: str
22
+ properties: dict
23
+ config: dict
24
+ sql: str
25
+ columns: list[dict]
26
+ dependencies: list[str]
27
+ status: Status
28
+ reason: str | None
29
+ provenance: str
30
+ context: NotRequired[dict]
31
+
32
+
33
+ def coverage(resources):
34
+ return dict(sorted(Counter(r["status"] for r in resources).items()))
@@ -0,0 +1,12 @@
1
+ """Best-effort type inference from qualified SQL and declared schema evidence."""
2
+
3
+ from sqlglot import exp
4
+ from sqlglot.optimizer.annotate_types import annotate_types
5
+
6
+ from dbt_extra.sql.renderer import sql
7
+
8
+
9
+ def inferred_types(tree, schema, dialect):
10
+ typed = annotate_types(tree, schema=schema, dialect=dialect)
11
+ return {p.output_name: sql(p.type, dialect) for p in typed.selects
12
+ if not p.is_star and p.type and p.type.this not in {exp.DataType.Type.UNKNOWN, exp.DataType.Type.NULL}}
dbt_extra/cli.py ADDED
@@ -0,0 +1,243 @@
1
+ """dbt-native command orchestration; no dbt or warehouse execution."""
2
+
3
+ from functools import wraps
4
+ from pathlib import Path
5
+ from zipfile import BadZipFile
6
+
7
+ import click
8
+ from sqlglot.errors import SqlglotError
9
+
10
+ from dbt_extra import __version__
11
+ from dbt_extra.enrichment import enrich
12
+ from dbt_extra.files.changes import ChangeSet
13
+ from dbt_extra.project import Project
14
+ from dbt_extra.review import export_review, import_review
15
+ from dbt_extra.inspection import inspect, plan
16
+ from dbt_extra.optimization import optimize
17
+ from dbt_extra.sql.renderer import render
18
+ from dbt_extra.validation import validate as validate_project
19
+ from dbt_extra.errors import SpecError
20
+ from dbt_extra.files.yaml import dump_yaml
21
+
22
+
23
+ class Names(click.Option):
24
+ """A repeatable option accepting one or more space-separated selectors."""
25
+ def __init__(self, *args, **kwargs):
26
+ kwargs["type"] = click.UNPROCESSED
27
+ super().__init__(*args, **kwargs)
28
+
29
+ def add_to_parser(self, parser, ctx):
30
+ super().add_to_parser(parser, ctx)
31
+ option = next((parser._long_opt.get(name) or parser._short_opt.get(name) for name in self.opts
32
+ if parser._long_opt.get(name) or parser._short_opt.get(name)), None)
33
+ original = option.process
34
+ def process(value, state):
35
+ values = [value]
36
+ while state.rargs and not state.rargs[0].startswith("-"):
37
+ values.append(state.rargs.pop(0))
38
+ original(tuple(values), state)
39
+ option.process = process
40
+
41
+
42
+ def names(values):
43
+ return [name for group in values for name in group] if values else None
44
+
45
+
46
+ def guarded(function):
47
+ @wraps(function)
48
+ def run(*args, **kwargs):
49
+ try:
50
+ return function(*args, **kwargs)
51
+ except (ValueError, OSError, UnicodeError, BadZipFile, SqlglotError) as exc:
52
+ raise click.ClickException(str(exc)) from exc
53
+ return run
54
+
55
+
56
+ def loaded(ctx, dialect=None):
57
+ return Project(ctx.obj["directory"], dialect=dialect or ctx.obj["dialect"], manifest=ctx.obj["manifest"])
58
+
59
+
60
+ def selectors(function):
61
+ function = click.option("--exclude", cls=Names, multiple=True, help="Selectors to exclude; explicit lists replace configuration.")(function)
62
+ return click.option("--select", "-s", cls=Names, multiple=True, help="Names, resource IDs, or path:/tag:/package:/resource_type: selectors.")(function)
63
+
64
+
65
+ def preview_option(function):
66
+ return click.option("--preview", "--dry-run", is_flag=True, help="Show proposed changes without writing files.")(function)
67
+
68
+
69
+ def summary(changes, preview):
70
+ for path in changes:
71
+ click.echo(f"update: {path}")
72
+ click.echo(f"{'Preview: ' if preview else ''}{len(changes)} file change(s)")
73
+
74
+
75
+ def finish(changes, preview):
76
+ if preview:
77
+ click.echo(changes.diffs(), nl=False)
78
+ summary(changes.apply(preview=preview), preview)
79
+
80
+
81
+ @click.group()
82
+ @click.version_option(version=__version__, prog_name="extra")
83
+ @click.option("--project-dir", type=click.Path(path_type=Path), envvar="EXTRA_PROJECT_DIR")
84
+ @click.option("--dialect", help="SQL dialect; overrides configuration and manifest adapter.")
85
+ @click.option("--manifest", type=click.Path(exists=True, dir_okay=False, path_type=Path), help="Optional dbt manifest evidence.")
86
+ @click.pass_context
87
+ def cli(ctx, project_dir, dialect, manifest):
88
+ """Review, inspect, and edit native dbt transformations offline."""
89
+ ctx.obj = {"directory": project_dir, "dialect": dialect, "manifest": manifest}
90
+
91
+
92
+ @cli.command("init")
93
+ @click.argument("directory", required=False, default=".", type=click.Path(path_type=Path))
94
+ @preview_option
95
+ @click.pass_context
96
+ @guarded
97
+ def init_command(ctx, directory, preview):
98
+ """Add optional dbt-extra settings to an existing native dbt project."""
99
+ base = ctx.obj["directory"] or Path.cwd()
100
+ root = (base / directory).resolve()
101
+ if not (root / "dbt_project.yml").is_file():
102
+ raise SpecError("init requires an existing dbt_project.yml; dbt-extra does not scaffold a second transformation source")
103
+ path = root / "extra_project.yaml"
104
+ if path.exists():
105
+ raise SpecError(f"Refusing to overwrite {path}")
106
+ changes = ChangeSet(root)
107
+ changes.set(path, dump_yaml({"version": 2, "analysis": {"dialect": ctx.obj["dialect"] or "auto"},
108
+ "export": {"output_dir": "extra/review", "format": "xlsx"}, "optimization": {"format": "pretty", "preserve_macros": True}}))
109
+ finish(changes, preview)
110
+
111
+
112
+ @cli.command()
113
+ @click.option("--dialect")
114
+ @click.pass_context
115
+ @guarded
116
+ def validate(ctx, dialect):
117
+ """Check local structure, static SQL, and reference cycles; never run dbt."""
118
+ project = loaded(ctx, dialect)
119
+ skipped = validate_project(project)
120
+ for uid, reason in skipped.items():
121
+ click.echo(f"Unavailable {uid}: {reason}", err=True)
122
+ click.echo(f"Validated {len(project.resources)} dbt resources offline; {len(skipped)} SQL analysis unavailable. Not a dbt parse/build.")
123
+
124
+
125
+ @cli.command("inspect")
126
+ @click.argument("table")
127
+ @click.option("--columns", cls=Names, multiple=True)
128
+ @click.option("--from", "from_tables", cls=Names, multiple=True, help="Upstream models to retain as relation boundaries.")
129
+ @click.option("--optimize", "optimized", type=click.BOOL, default=None)
130
+ @click.option("--expand", type=click.BOOL, default=None, help="Expand upstream models and statically resolvable local macros.")
131
+ @click.option("--compile", "compiled", type=click.BOOL, default=None, help="Resolve physical relation names offline (default: true); does not run dbt compile.")
132
+ @click.option("--view", type=click.Choice(["sql", "plan"]), default="sql")
133
+ @click.option("--dialect")
134
+ @click.pass_context
135
+ @guarded
136
+ def inspect_command(ctx, table, columns, from_tables, optimized, expand, compiled, view, dialect):
137
+ """Explain full model or selected-column logic without changing files."""
138
+ project = loaded(ctx, dialect)
139
+ settings = project.options("inspection", {"optimize": optimized, "expand": expand, "compile": compiled, "boundaries": names(from_tables)})
140
+ tree, parsed, issues = inspect(project, table, names(columns), boundaries=settings.get("boundaries", []),
141
+ expanded=settings.get("expand", True), optimized=settings.get("optimize", True), compiled=settings.get("compile", True))
142
+ click.echo("-- Definition explanation, not stored-relation equivalence or a warehouse execution plan.")
143
+ click.echo("-- Offline relation resolution; unresolved dbt templates retained. Not executed. Authored files unchanged."
144
+ if settings.get("compile", True) else "-- Authored dbt ref/source boundaries retained; not executed. Authored files unchanged.")
145
+ for issue in issues:
146
+ click.echo(f"-- Boundary/coverage: {issue['resource_id']}: {issue['reason']}".replace("\n", " "))
147
+ click.echo(plan(tree, parsed, project.dialect) if view == "plan" else render(tree, parsed, project.dialect), nl=view == "plan")
148
+
149
+
150
+ @cli.command("export")
151
+ @click.option("--format", type=click.Choice(["xlsx", "csv", "json"]))
152
+ @click.option("--output-dir", "--path", type=click.Path(path_type=Path))
153
+ @click.option("--view", help="Named view from export.views.")
154
+ @click.option("--expand/--no-expand", default=None, help="Show read-only SQL/columns from statically resolvable local macros.")
155
+ @selectors
156
+ @preview_option
157
+ @click.option("--dialect")
158
+ @click.pass_context
159
+ @guarded
160
+ def export_command(ctx, format, output_dir, view, expand, select, exclude, preview, dialect):
161
+ """Export tabular XLSX/CSV review or a read-only JSON analysis catalog."""
162
+ project = loaded(ctx, dialect)
163
+ options = project.options("export", {"format": format, "output_dir": output_dir, "select": names(select), "exclude": names(exclude), "expand": expand}, view=view)
164
+ paths, coverage = export_review(project, format=options.get("format", "xlsx"), output_dir=options.get("output_dir"),
165
+ select=options.get("select"), exclude=options.get("exclude"), preview=preview, expand=options.get("expand", False))
166
+ summary(paths, preview)
167
+ click.echo("Coverage: " + ", ".join(f"{k}={v}" for k, v in coverage.items()))
168
+
169
+
170
+ @cli.command("import")
171
+ @click.argument("path", type=click.Path(exists=True, path_type=Path))
172
+ @preview_option
173
+ @click.option("--dialect")
174
+ @click.pass_context
175
+ @guarded
176
+ def import_command(ctx, path, preview, dialect):
177
+ """Apply supported XLSX/CSV edits to their original native dbt files."""
178
+ project = loaded(ctx, dialect)
179
+ changes = import_review(project, path)
180
+ for uid, reason in changes.metadata_skipped.items():
181
+ click.echo(f"Skipped metadata {uid}: {reason}", err=True)
182
+ finish(changes, preview)
183
+
184
+
185
+ @cli.command("optimize")
186
+ @selectors
187
+ @click.option("--format", type=click.Choice(["pretty", "compact"]))
188
+ @click.option("--preserve-macro", "preserve_macros", type=click.BOOL, default=None)
189
+ @click.option("--enrich", "enrich_enabled", type=click.BOOL, default=None)
190
+ @preview_option
191
+ @click.option("--dialect")
192
+ @click.pass_context
193
+ @guarded
194
+ def optimize_command(ctx, select, exclude, format, preserve_macros, enrich_enabled, preview, dialect):
195
+ """Conservatively rewrite authored dbt SQL, never compiled SQL."""
196
+ project = loaded(ctx, dialect)
197
+ options = project.options("optimization", {"select": names(select), "exclude": names(exclude), "format": format,
198
+ "preserve_macros": preserve_macros, "enrich": enrich_enabled})
199
+ changes, skipped = optimize(project, select=options.get("select"), exclude=options.get("exclude"),
200
+ format=options.get("format", "pretty"), preserve_macros=options.get("preserve_macros", True))
201
+ for uid, reason in skipped.items():
202
+ click.echo(f"Skipped {uid}: {reason}. Model unchanged.", err=True)
203
+ optimization_report = changes.optimization_report
204
+ counts = {status: sum(r["status"] == status for r in optimization_report.values())
205
+ for status in ("rewritten", "qualified", "formatted", "unchanged")}
206
+ click.echo(f"Optimization: {len(optimization_report)} accepted "
207
+ f"({counts['rewritten']} structurally rewritten, {counts['qualified']} qualified, "
208
+ f"{counts['formatted']} formatting-only, {counts['unchanged']} unchanged); "
209
+ f"{sum(r['changed'] for r in optimization_report.values())} proposed SQL change(s); {len(skipped)} skipped")
210
+ for uid, report in optimization_report.items():
211
+ for reason in report["retained"]:
212
+ click.echo(f"Retained {uid}: {reason}")
213
+ if options.get("enrich", False):
214
+ changes, report = enrich(project, select=options.get("select"), exclude=options.get("exclude"), changes=changes)
215
+ enrichment_summary(report)
216
+ finish(changes, preview)
217
+ click.echo(f"Optimization SQL files written: {0 if preview else sum(r['changed'] for r in optimization_report.values())}")
218
+
219
+
220
+ def enrichment_summary(report):
221
+ click.echo(f"Metadata: {sum('value' in r for r in report)} missing properties inferred; {sum('reason' in r for r in report)} unresolved")
222
+ for item in report:
223
+ if "reason" in item and "column" not in item:
224
+ click.echo(f"Skipped metadata {item['resource_id']}: {item['reason']}", err=True)
225
+
226
+
227
+ @cli.command("enrich")
228
+ @selectors
229
+ @preview_option
230
+ @click.option("--dialect")
231
+ @click.pass_context
232
+ @guarded
233
+ def enrich_command(ctx, select, exclude, preview, dialect):
234
+ """Fill missing inferred types and safe passthrough descriptions in dbt YAML."""
235
+ project = loaded(ctx, dialect)
236
+ options = project.options("metadata", {"select": names(select), "exclude": names(exclude)})
237
+ changes, report = enrich(project, select=options.get("select"), exclude=options.get("exclude"))
238
+ enrichment_summary(report)
239
+ finish(changes, preview)
240
+
241
+
242
+ def main(argv=None):
243
+ return cli.main(args=argv, prog_name="extra")
dbt_extra/config.py ADDED
@@ -0,0 +1,71 @@
1
+ """Optional tool settings, project resolution, and explicit option overrides."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from dbt_extra.errors import SpecError
7
+ from dbt_extra.files.yaml import fields, mapping
8
+
9
+
10
+ def resolve_project(project_dir=None):
11
+ value = project_dir if project_dir is not None else os.environ.get("EXTRA_PROJECT_DIR")
12
+ if value is not None:
13
+ return Path(value).absolute() / "extra_project.yaml"
14
+ for directory in (Path.cwd(), *Path.cwd().parents):
15
+ candidate = directory / "extra_project.yaml"
16
+ if candidate.is_file():
17
+ return candidate
18
+ return Path.cwd() / "extra_project.yaml"
19
+
20
+
21
+ def strings(value, context):
22
+ if not isinstance(value, list) or any(not isinstance(v, str) or not v for v in value):
23
+ raise SpecError(f"{context}: expected a list of nonempty strings")
24
+ return value
25
+
26
+
27
+ def validate_settings(settings):
28
+ allowed = {
29
+ "dbt": {"project_dir", "artifacts"}, "analysis": {"dialect"},
30
+ "inspection": {"expand", "optimize", "compile", "boundaries"},
31
+ "export": {"output_dir", "default_view", "format", "select", "exclude", "views", "expand"},
32
+ "optimization": {"format", "select", "exclude", "preserve_macros", "enrich"},
33
+ "metadata": {"select", "exclude"},
34
+ }
35
+ fields(settings, {"version", *allowed}, set(), "extra_project.yaml")
36
+ if settings.get("version", 2) != 2:
37
+ raise SpecError("extra_project.yaml must use version: 2 tool configuration; models are dbt files")
38
+ for key, keys in allowed.items():
39
+ fields(mapping(settings.get(key, {}), key), keys, set(), key)
40
+ for section, options in settings.items():
41
+ if not isinstance(options, dict):
42
+ continue
43
+ for key, value in options.items():
44
+ if key in {"select", "exclude", "boundaries"}:
45
+ strings(value, f"{section}.{key}")
46
+ if key in {"expand", "optimize", "compile", "preserve_macros", "enrich"} and type(value) is not bool:
47
+ raise SpecError(f"{section}.{key}: expected a boolean")
48
+ if key in {"output_dir", "default_view", "format", "dialect", "project_dir"} and (not isinstance(value, str) or not value):
49
+ raise SpecError(f"{section}.{key}: expected nonempty text")
50
+ views = mapping(settings.get("export", {}).get("views", {}), "export.views")
51
+ for name, view in views.items():
52
+ fields(mapping(view, f"export.views.{name}"), {"format", "select", "exclude", "expand"}, set(), f"export.views.{name}")
53
+ if "expand" in view and type(view["expand"]) is not bool:
54
+ raise SpecError(f"export.views.{name}.expand: expected a boolean")
55
+ for key in {"select", "exclude"} & view.keys():
56
+ strings(view[key], f"export.views.{name}.{key}")
57
+
58
+
59
+ def options(settings, section, overrides=None, *, view=None):
60
+ result = dict(settings.get(section, {}))
61
+ views = result.pop("views", {})
62
+ if section == "export":
63
+ selected = view or result.get("default_view")
64
+ if selected:
65
+ if selected not in views:
66
+ raise SpecError(f"Unknown export view: {selected}")
67
+ setting = mapping(views[selected], f"export.views.{selected}")
68
+ fields(setting, {"format", "select", "exclude", "expand"}, set(), f"export.views.{selected}")
69
+ result.update(setting)
70
+ result.update({k: v for k, v in (overrides or {}).items() if v is not None})
71
+ return result
@@ -0,0 +1,84 @@
1
+ """Fill missing metadata using shared analysis; never overwrite authored values."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+
7
+ from sqlglot import exp
8
+
9
+ from dbt_extra.analysis import analyze
10
+ from dbt_extra.files.changes import ChangeSet, digest
11
+ from dbt_extra.sql.parser import parse_authored
12
+ from dbt_extra.errors import SpecError
13
+
14
+
15
+ def enrich(project, *, select=None, exclude=None, changes=None):
16
+ project.require_dialect()
17
+ changes = changes or ChangeSet(project.root)
18
+ for path, data in project.contents.items():
19
+ changes.watch(path, digest(data), check=True, external=not path.is_relative_to(project.root))
20
+ report, cache, active = [], {}, set()
21
+
22
+ def metadata(r):
23
+ if r.id in cache:
24
+ return cache[r.id]
25
+ if r.id in active:
26
+ return r.columns
27
+ active.add(r.id)
28
+ result = copy.deepcopy(r.columns)
29
+ analysis = analyze(project, r)
30
+ try:
31
+ parsed = parse_authored(r.code, project.dialect, r.id)
32
+ output = {p.output_name: p for p in parsed.tree.selects}
33
+ except SpecError:
34
+ parsed, output = None, {}
35
+ for item in analysis["columns"]:
36
+ name = item["name"]
37
+ props = result.setdefault(name, {"name": name})
38
+ if not props.get("data_type") and item.get("inferred_type"):
39
+ props["data_type"] = item["inferred_type"]
40
+ projection = output.get(name)
41
+ expression = projection.this if isinstance(projection, exp.Alias) else projection
42
+ # Only bare passthrough/renamed columns inherit a description. The
43
+ # lineage relation must resolve uniquely; transformed values do not.
44
+ if not props.get("description") and isinstance(expression, exp.Column):
45
+ leaves = item["upstream_columns"]
46
+ if len(leaves) == 1:
47
+ leaf = leaves[0]
48
+ upstream = project.resources.get(leaf["resource_id"])
49
+ if upstream:
50
+ inherited = metadata(upstream).get(leaf["column"], {}).get("description")
51
+ if inherited:
52
+ props["description"] = inherited
53
+ active.remove(r.id)
54
+ cache[r.id] = result
55
+ return result
56
+
57
+ for r in project.select(select, exclude, kinds={"model"}):
58
+ if not r.path or r.path.suffix != ".sql" or r.properties.get("versions"):
59
+ report.append({"resource_id": r.id, "reason": "Read-only or versioned metadata"})
60
+ continue
61
+ if not r.property_path:
62
+ report.append({"resource_id": r.id, "reason": "No existing writable dbt YAML entry; add one in dbt and retry"})
63
+ continue
64
+ proposed = copy.deepcopy(r.properties)
65
+ columns = proposed.setdefault("columns", [])
66
+ by_name = {c["name"]: c for c in columns}
67
+ for name, inferred in metadata(r).items():
68
+ target = by_name.get(name)
69
+ for key in ("data_type", "description"):
70
+ if target and target.get(key):
71
+ continue
72
+ if inferred.get(key):
73
+ if target is None:
74
+ target = {"name": name}
75
+ columns.append(target)
76
+ by_name[name] = target
77
+ target[key] = inferred[key]
78
+ report.append({"resource_id": r.id, "column": name, "property": key, "value": inferred[key]})
79
+ else:
80
+ report.append({"resource_id": r.id, "column": name, "property": key, "reason": "Not safely inferable from available source metadata"})
81
+ proposed.setdefault("name", r.name)
82
+ if any("value" in item and item["resource_id"] == r.id for item in report):
83
+ changes.properties(project, r, proposed)
84
+ return changes, report
dbt_extra/errors.py ADDED
@@ -0,0 +1,5 @@
1
+ """Errors shared by project loading, analysis, and checked source edits."""
2
+
3
+
4
+ class SpecError(ValueError):
5
+ """Invalid project data, unsupported analysis, or an unsafe file operation."""