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.
- dbt_extra/__init__.py +18 -0
- dbt_extra/__main__.py +3 -0
- dbt_extra/analysis/__init__.py +5 -0
- dbt_extra/analysis/analyzer.py +111 -0
- dbt_extra/analysis/dependencies.py +26 -0
- dbt_extra/analysis/lineage.py +35 -0
- dbt_extra/analysis/models.py +34 -0
- dbt_extra/analysis/types.py +12 -0
- dbt_extra/cli.py +243 -0
- dbt_extra/config.py +71 -0
- dbt_extra/enrichment.py +84 -0
- dbt_extra/errors.py +5 -0
- dbt_extra/files/__init__.py +1 -0
- dbt_extra/files/changes.py +185 -0
- dbt_extra/files/paths.py +29 -0
- dbt_extra/files/yaml.py +150 -0
- dbt_extra/inspection.py +188 -0
- dbt_extra/optimization.py +160 -0
- dbt_extra/project/__init__.py +6 -0
- dbt_extra/project/artifacts.py +28 -0
- dbt_extra/project/discovery.py +110 -0
- dbt_extra/project/project.py +96 -0
- dbt_extra/project/properties.py +42 -0
- dbt_extra/project/resources.py +42 -0
- dbt_extra/project/selectors.py +30 -0
- dbt_extra/review/__init__.py +6 -0
- dbt_extra/review/contract.py +51 -0
- dbt_extra/review/csv.py +39 -0
- dbt_extra/review/export.py +75 -0
- dbt_extra/review/import_.py +172 -0
- dbt_extra/review/json.py +7 -0
- dbt_extra/review/workbook.py +596 -0
- dbt_extra/sql/__init__.py +6 -0
- dbt_extra/sql/inspection_rules.py +92 -0
- dbt_extra/sql/jinja.py +77 -0
- dbt_extra/sql/macros.py +304 -0
- dbt_extra/sql/parser.py +130 -0
- dbt_extra/sql/qualification.py +143 -0
- dbt_extra/sql/references.py +31 -0
- dbt_extra/sql/relations.py +105 -0
- dbt_extra/sql/renderer.py +75 -0
- dbt_extra/sql/schema_evidence.py +90 -0
- dbt_extra/sql/scopes.py +109 -0
- dbt_extra/sql/shorthand.py +197 -0
- dbt_extra/sql/source_expressions.py +116 -0
- dbt_extra/sql/source_rules.py +317 -0
- dbt_extra/validation.py +26 -0
- dbt_extra-0.1.0.dist-info/METADATA +206 -0
- dbt_extra-0.1.0.dist-info/RECORD +52 -0
- dbt_extra-0.1.0.dist-info/WHEEL +4 -0
- dbt_extra-0.1.0.dist-info/entry_points.txt +2 -0
- 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,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
|
dbt_extra/enrichment.py
ADDED
|
@@ -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
|