dbt-scribe 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_scribe/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
dbt_scribe/analyzer.py ADDED
@@ -0,0 +1,348 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from enum import Enum
6
+ from typing import Any
7
+
8
+ import sqlglot
9
+ from sqlglot import exp
10
+
11
+ from dbt_scribe.config import ConventionsConfig, ScribeConfig, TestsConfig
12
+ from dbt_scribe.parsers.manifest_parser import ManifestNode
13
+ from dbt_scribe.parsers.yaml_parser import YamlModel, is_description_set
14
+
15
+
16
+ class Layer(Enum):
17
+ STAGING = "staging"
18
+ INTERMEDIATE = "intermediate"
19
+ MARTS = "marts"
20
+ UNKNOWN = "unknown"
21
+
22
+
23
+ class ColumnType(Enum):
24
+ PRIMARY_KEY = "primary_key"
25
+ FOREIGN_KEY = "foreign_key"
26
+ ENUM = "enum"
27
+ SHARED = "shared"
28
+ BOOLEAN = "boolean"
29
+ TIMESTAMP = "timestamp"
30
+ METRIC = "metric"
31
+ CALCULATED = "calculated"
32
+ TEXT = "text"
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class EnrichedColumn:
37
+ name: str
38
+ data_type: str | None
39
+ description: str | None
40
+ tests: list[Any]
41
+ column_type: ColumnType
42
+ sql_expression: str | None
43
+ needs_doc: bool
44
+ needs_tests: bool
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class EnrichedModel:
49
+ unique_id: str
50
+ name: str
51
+ fqn: list[str]
52
+ layer: Layer
53
+ description: str | None
54
+ compiled_code: str
55
+ columns: dict[str, EnrichedColumn]
56
+ depends_on_nodes: list[str]
57
+ tags: list[str]
58
+ materialized: str | None
59
+ path: str
60
+ adapter_type: str | None
61
+
62
+
63
+ AGGREGATION_PATTERN = re.compile(
64
+ r"\b(count|sum|avg|min|max|median)\s*\(", re.IGNORECASE
65
+ )
66
+ ARITHMETIC_PATTERN = re.compile(r"\s[+\-*/]\s")
67
+
68
+
69
+ def detect_layer(fqn: list[str], config: ConventionsConfig) -> Layer:
70
+ """Detect the dbt layer from a node's fully-qualified name.
71
+
72
+ Matches fqn[1] against configured layer prefixes. Also accepts common
73
+ alternate spellings (e.g. 'mart' for 'marts') to avoid silent failures
74
+ when the project folder name differs slightly from the config value.
75
+
76
+ Args:
77
+ fqn: Fully-qualified node name list from the manifest.
78
+ config: Conventions config containing layer folder names.
79
+
80
+ Returns:
81
+ The detected Layer enum value, or Layer.UNKNOWN if unrecognised.
82
+ """
83
+ if len(fqn) < 2:
84
+ return Layer.UNKNOWN
85
+
86
+ layer_name = fqn[1]
87
+
88
+ # Exact match first
89
+ if layer_name == config.staging_prefix:
90
+ return Layer.STAGING
91
+ if layer_name == config.intermediate_prefix:
92
+ return Layer.INTERMEDIATE
93
+ if layer_name == config.marts_prefix:
94
+ return Layer.MARTS
95
+
96
+ # Common alternate spellings — mart vs marts
97
+ if layer_name in ("mart", "marts", "mrt"):
98
+ return Layer.MARTS
99
+ if layer_name in ("staging", "stage", "stg"):
100
+ return Layer.STAGING
101
+ if layer_name in ("intermediate", "int"):
102
+ return Layer.INTERMEDIATE
103
+
104
+ return Layer.UNKNOWN
105
+
106
+
107
+ def _find_primary_key_column(
108
+ column_names: list[str],
109
+ config: TestsConfig,
110
+ ) -> str | None:
111
+ """Return the name of the most likely primary key column, or None.
112
+
113
+ A column is considered the primary key only when it is the sole column
114
+ matching pk_patterns. When multiple columns match (e.g. order_id and
115
+ user_id in an orders table), none is a true PK — they are foreign keys
116
+ and should be typed accordingly.
117
+
118
+ Args:
119
+ column_names: Ordered list of column names from the model.
120
+ config: Tests config containing pk_patterns.
121
+
122
+ Returns:
123
+ The PK column name if exactly one column matches, otherwise None.
124
+ """
125
+ pk_candidates = [
126
+ name for name in column_names
127
+ if _matches_any(name, config.pk_patterns)
128
+ ]
129
+ # Only treat as PK when there is exactly one candidate — unambiguous
130
+ return pk_candidates[0] if len(pk_candidates) == 1 else None
131
+
132
+
133
+ def infer_column_type(
134
+ column_name: str,
135
+ sql_expression: str | None,
136
+ config: TestsConfig,
137
+ *,
138
+ shared_columns: list[str] | None = None,
139
+ pk_column: str | None = None,
140
+ ) -> ColumnType:
141
+ """Infer the semantic type of a column from its name, SQL expression, and config.
142
+
143
+ Applies a priority cascade — the first matching rule wins:
144
+ 1. Exact PK match (only when pk_column is set — i.e. unambiguous single PK)
145
+ 2. FK patterns from config
146
+ 3. PK patterns not designated as PK → FK (e.g. order_id in a multi-key model)
147
+ 4. Enum patterns from config
148
+ 5. Shared columns list from docs config
149
+ 6. Boolean name heuristics (is_, has_, did_)
150
+ 7. Timestamp name heuristics (_at, _date)
151
+ 8. Metric (aggregation in SQL expression)
152
+ 9. Calculated (arithmetic in SQL expression)
153
+ 10. Default: text
154
+
155
+ Args:
156
+ column_name: The column name to classify.
157
+ sql_expression: The SQL expression for this column if computed, else None.
158
+ config: Tests config with pk/fk/enum patterns.
159
+ shared_columns: List of shared column names from docs config.
160
+ pk_column: The designated PK column name for this model, or None.
161
+
162
+ Returns:
163
+ The inferred ColumnType.
164
+ """
165
+ # Rule 1 — designated primary key (unambiguous single PK)
166
+ if pk_column and column_name == pk_column:
167
+ return ColumnType.PRIMARY_KEY
168
+
169
+ # Rule 2 — explicit FK patterns (e.g. customer_fk)
170
+ if _matches_any(column_name, config.fk_patterns):
171
+ return ColumnType.FOREIGN_KEY
172
+
173
+ # Rule 3 — matches pk_patterns but NOT the designated PK → treat as FK
174
+ # Example: user_id in an orders table where order_id is the PK
175
+ if _matches_any(column_name, config.pk_patterns):
176
+ return ColumnType.FOREIGN_KEY
177
+
178
+ # Rule 4 — enum patterns
179
+ if _matches_any(column_name, config.enum_patterns):
180
+ return ColumnType.ENUM
181
+
182
+ # Rule 5 — shared columns
183
+ if shared_columns and column_name in shared_columns:
184
+ return ColumnType.SHARED
185
+
186
+ # Rule 6 — boolean heuristics
187
+ if column_name.startswith(("is_", "has_", "did_")):
188
+ return ColumnType.BOOLEAN
189
+
190
+ # Rule 7 — timestamp heuristics
191
+ if column_name.endswith(("_at", "_date")):
192
+ return ColumnType.TIMESTAMP
193
+
194
+ # Rule 8 — metric (aggregation function in SQL expression)
195
+ if sql_expression and AGGREGATION_PATTERN.search(sql_expression):
196
+ return ColumnType.METRIC
197
+
198
+ # Rule 9 — calculated (arithmetic in SQL expression)
199
+ if sql_expression and ARITHMETIC_PATTERN.search(sql_expression):
200
+ return ColumnType.CALCULATED
201
+
202
+ # Rule 10 — default
203
+ return ColumnType.TEXT
204
+
205
+
206
+ def build_enriched_model(
207
+ node: ManifestNode,
208
+ yaml_model: YamlModel | None,
209
+ config: ScribeConfig,
210
+ *,
211
+ overwrite_existing: bool = False,
212
+ ) -> EnrichedModel:
213
+ """Build an EnrichedModel from a manifest node and existing YAML state.
214
+
215
+ Combines manifest metadata, existing YAML documentation and tests, compiled
216
+ SQL expressions, and generation flags (needs_doc, needs_tests) into a single
217
+ object consumed by the generators and writers.
218
+
219
+ Args:
220
+ node: Parsed manifest node for this model.
221
+ yaml_model: Existing YAML state for this model, or None if no YAML exists.
222
+ config: Project-level dbt-scribe configuration.
223
+ overwrite_existing: When True, sets needs_doc and needs_tests to True
224
+ even for columns that already have descriptions or tests.
225
+
226
+ Returns:
227
+ A fully populated EnrichedModel instance.
228
+ """
229
+ expressions = _extract_select_expressions(node.compiled_code, node.adapter_type)
230
+ yaml_columns = yaml_model.columns if yaml_model else {}
231
+
232
+ # Determine the unambiguous PK column for this model before typing columns
233
+ pk_column = _find_primary_key_column(
234
+ list(node.columns.keys()), config.tests
235
+ )
236
+
237
+ columns = {}
238
+ for column_name, manifest_column in node.columns.items():
239
+ yaml_column = yaml_columns.get(column_name)
240
+ description = (
241
+ yaml_column.description
242
+ if yaml_column and yaml_column.description is not None
243
+ else manifest_column.description
244
+ )
245
+ tests = list(yaml_column.tests) if yaml_column else []
246
+ sql_expression = expressions.get(column_name)
247
+
248
+ columns[column_name] = EnrichedColumn(
249
+ name=column_name,
250
+ data_type=manifest_column.data_type,
251
+ description=description,
252
+ tests=tests,
253
+ column_type=infer_column_type(
254
+ column_name,
255
+ sql_expression,
256
+ config.tests,
257
+ shared_columns=config.docs.shared_columns,
258
+ pk_column=pk_column,
259
+ ),
260
+ sql_expression=sql_expression,
261
+ needs_doc=overwrite_existing or not is_description_set(description),
262
+ needs_tests=overwrite_existing or not tests,
263
+ )
264
+
265
+ return EnrichedModel(
266
+ unique_id=node.unique_id,
267
+ name=node.name,
268
+ fqn=node.fqn,
269
+ layer=detect_layer(node.fqn, config.conventions),
270
+ description=yaml_model.description if yaml_model else None,
271
+ compiled_code=node.compiled_code,
272
+ columns=columns,
273
+ depends_on_nodes=node.depends_on_nodes,
274
+ tags=node.tags,
275
+ materialized=node.materialized,
276
+ path=node.path,
277
+ adapter_type=node.adapter_type,
278
+ )
279
+
280
+
281
+ def _matches_any(value: str, patterns: list[str]) -> bool:
282
+ """Return True if value matches any of the given regex patterns.
283
+
284
+ Args:
285
+ value: String to test.
286
+ patterns: List of regex pattern strings.
287
+
288
+ Returns:
289
+ True if any pattern matches.
290
+ """
291
+ return any(re.match(pattern, value) for pattern in patterns)
292
+
293
+
294
+ def _extract_select_expressions(
295
+ compiled_code: str,
296
+ adapter_type: str | None,
297
+ ) -> dict[str, str]:
298
+ """Extract column name → SQL expression mappings from compiled SQL.
299
+
300
+ Uses sqlglot to parse the compiled SQL and extract the expression for
301
+ each selected column. Falls back to a regex-based approach if sqlglot
302
+ fails to parse the SQL.
303
+
304
+ Args:
305
+ compiled_code: Fully compiled SQL string (Jinja2 already resolved).
306
+ adapter_type: dbt adapter name used as the sqlglot read dialect.
307
+
308
+ Returns:
309
+ Dict mapping column alias/name to its SQL expression string.
310
+ """
311
+ try:
312
+ tree = sqlglot.parse_one(compiled_code, read=adapter_type or None)
313
+ except sqlglot.errors.SqlglotError:
314
+ return _extract_select_expressions_fallback(compiled_code)
315
+
316
+ expressions: dict[str, str] = {}
317
+ for select in tree.find_all(exp.Select):
318
+ for select_expression in select.expressions:
319
+ alias = select_expression.alias_or_name
320
+ if alias:
321
+ expressions[alias] = (
322
+ select_expression.this.sql()
323
+ if isinstance(select_expression, exp.Alias)
324
+ else select_expression.sql()
325
+ )
326
+ return expressions
327
+
328
+
329
+ def _extract_select_expressions_fallback(compiled_code: str) -> dict[str, str]:
330
+ """Regex-based fallback for SQL expression extraction when sqlglot fails.
331
+
332
+ Args:
333
+ compiled_code: Fully compiled SQL string.
334
+
335
+ Returns:
336
+ Dict mapping column alias/name to its SQL expression string.
337
+ """
338
+ expressions: dict[str, str] = {}
339
+ for expression, alias in re.findall(
340
+ r"(?im)^\s*(.+?)\s+as\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*,?$",
341
+ compiled_code,
342
+ ):
343
+ expressions[alias] = expression.strip()
344
+ for column in re.findall(
345
+ r"(?im)^\s*([a-zA-Z_][a-zA-Z0-9_\.]*)\s*,?$", compiled_code
346
+ ):
347
+ expressions[column.split(".")[-1]] = column.strip()
348
+ return expressions
dbt_scribe/cli.py ADDED
@@ -0,0 +1,254 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import click
6
+
7
+ from dbt_scribe import __version__
8
+ from dbt_scribe.analyzer import build_enriched_model
9
+ from dbt_scribe.config import ConfigError, ScribeConfig, load_config, resolve_provider
10
+ from dbt_scribe.generators.docs_generator import DocsResult, generate_docs
11
+ from dbt_scribe.generators.tests_generator import TestsResult, generate_tests
12
+ from dbt_scribe.parsers.manifest_parser import ManifestNode, parse_manifest
13
+ from dbt_scribe.parsers.yaml_parser import is_description_set, parse_yaml
14
+ from dbt_scribe.resolver import resolve_target
15
+ from dbt_scribe.writers.docs_writer import write_docs_block
16
+ from dbt_scribe.writers.yaml_writer import write_yaml
17
+
18
+ _REQUIRED_FILES = ["dbt_project.yml", "target/manifest.json", "dbt-scribe.yml"]
19
+
20
+ _DEFAULT_CONFIG = """\
21
+ version: 1
22
+
23
+ llm:
24
+ provider: anthropic
25
+ # model: claude-sonnet-4-20250514 # defaults to latest Sonnet
26
+ temperature: 0.2
27
+
28
+ docs:
29
+ two_tier: true
30
+ shared_columns:
31
+ - created_at
32
+ - updated_at
33
+ - _fivetran_synced
34
+ default_owner: "Data Team"
35
+
36
+ tests:
37
+ pk_patterns:
38
+ - "^.*_id$"
39
+ - "^id$"
40
+ fk_patterns:
41
+ - "^.*_fk$"
42
+ enum_patterns:
43
+ - "^.*_type$"
44
+ - "^.*_status$"
45
+ - "^.*_category$"
46
+
47
+ conventions:
48
+ staging_prefix: staging
49
+ intermediate_prefix: intermediate
50
+ marts_prefix: marts
51
+
52
+ coverage:
53
+ min_doc_coverage: 80
54
+ min_test_coverage: 70
55
+ """
56
+
57
+
58
+ class BootstrapError(Exception):
59
+ pass
60
+
61
+
62
+ def _bootstrap(cwd: Path | None = None) -> None:
63
+ root = cwd or Path.cwd()
64
+ missing = [f for f in _REQUIRED_FILES if not (root / f).exists()]
65
+ if not missing:
66
+ return
67
+ hints = {
68
+ "dbt_project.yml": "Are you in the dbt project root directory?",
69
+ "target/manifest.json": "Run 'dbt compile' to generate the manifest.",
70
+ "dbt-scribe.yml": "Run 'dbt-scribe init' to create the configuration file.",
71
+ }
72
+ lines = ["Bootstrap check failed. Missing files:"]
73
+ for f in missing:
74
+ lines.append(f" • {f} → {hints[f]}")
75
+ raise BootstrapError("\n".join(lines))
76
+
77
+
78
+ @click.group()
79
+ @click.version_option(__version__, prog_name="dbt-scribe")
80
+ def cli() -> None:
81
+ """LLM-powered documentation and test generation for dbt Core projects."""
82
+
83
+
84
+ @cli.command()
85
+ def init() -> None:
86
+ """Generate a dbt-scribe.yml config in the current dbt project."""
87
+ root = Path.cwd()
88
+ if not (root / "dbt_project.yml").exists():
89
+ raise click.ClickException(
90
+ "dbt_project.yml not found. Are you in the dbt project root directory?"
91
+ )
92
+ config_path = root / "dbt-scribe.yml"
93
+ if config_path.exists():
94
+ raise click.ClickException(
95
+ "dbt-scribe.yml already exists. Remove it first or edit it directly."
96
+ )
97
+ config_path.write_text(_DEFAULT_CONFIG)
98
+ click.echo(f"Created {config_path}")
99
+ click.echo("Edit dbt-scribe.yml to set your LLM provider and project conventions.")
100
+
101
+
102
+ @cli.command()
103
+ @click.option("--target", default="models/", help="File, directory, or project root to process.")
104
+ @click.option("--dry-run", is_flag=True, help="Preview changes without writing files.")
105
+ @click.option("--force", is_flag=True, help="Overwrite existing descriptions.")
106
+ def docs(target: str, dry_run: bool, force: bool) -> None:
107
+ """Generate model and column documentation."""
108
+ config, nodes = _load_project(target)
109
+ provider = resolve_provider(config)
110
+ for node in nodes:
111
+ model = _build_model(node, config, overwrite_existing=force)
112
+ docs_result = generate_docs(model, provider, config)
113
+ yaml_result = write_yaml(
114
+ model,
115
+ docs_result,
116
+ TestsResult(columns={}),
117
+ config,
118
+ dry_run,
119
+ force=force,
120
+ )
121
+ docs_block_result = write_docs_block(model, docs_result, config, dry_run)
122
+ _echo_status("docs", model.name, dry_run, yaml_result.changed or docs_block_result.changed)
123
+ _echo_summary("docs", nodes, dry_run)
124
+
125
+
126
+ @cli.command()
127
+ @click.option("--target", default="models/", help="File, directory, or project root to process.")
128
+ @click.option("--dry-run", is_flag=True, help="Preview changes without writing files.")
129
+ @click.option("--force", is_flag=True, help="Overwrite existing tests.")
130
+ def tests(target: str, dry_run: bool, force: bool) -> None:
131
+ """Generate generic YAML tests."""
132
+ config, nodes = _load_project(target)
133
+ provider = resolve_provider(config)
134
+ for node in nodes:
135
+ model = _build_model(node, config, overwrite_existing=force)
136
+ tests_result = generate_tests(model, provider, config)
137
+ yaml_result = write_yaml(
138
+ model,
139
+ DocsResult(model_description=model.description or "", docs_block_content="", columns={}),
140
+ tests_result,
141
+ config,
142
+ dry_run,
143
+ force=force,
144
+ )
145
+ _echo_status("tests", model.name, dry_run, yaml_result.changed)
146
+ _echo_summary("tests", nodes, dry_run)
147
+
148
+
149
+ @cli.command()
150
+ @click.option("--target", default="models/", help="File, directory, or project root to process.")
151
+ @click.option("--dry-run", is_flag=True, help="Preview changes without writing files.")
152
+ @click.option("--force", is_flag=True, help="Overwrite existing documentation and tests.")
153
+ def generate(target: str, dry_run: bool, force: bool) -> None:
154
+ """Generate documentation and tests in one pass."""
155
+ config, nodes = _load_project(target)
156
+ provider = resolve_provider(config)
157
+ for node in nodes:
158
+ model = _build_model(node, config, overwrite_existing=force)
159
+ docs_result = generate_docs(model, provider, config)
160
+ tests_result = generate_tests(model, provider, config)
161
+ yaml_result = write_yaml(
162
+ model,
163
+ docs_result,
164
+ tests_result,
165
+ config,
166
+ dry_run,
167
+ force=force,
168
+ )
169
+ docs_block_result = write_docs_block(model, docs_result, config, dry_run)
170
+ _echo_status("generate", model.name, dry_run, yaml_result.changed or docs_block_result.changed)
171
+ _echo_summary("generate", nodes, dry_run)
172
+
173
+
174
+ @cli.command()
175
+ @click.option("--target", default="models/", help="File, directory, or project root to audit.")
176
+ def audit(target: str) -> None:
177
+ """Show documentation and test coverage report."""
178
+ config, nodes = _load_project(target, check_api_key=False)
179
+ click.echo("Audit summary")
180
+ for node in nodes:
181
+ model = _build_model(node, config)
182
+ total_columns = len(model.columns)
183
+ documented_columns = sum(
184
+ 1 for column in model.columns.values() if is_description_set(column.description)
185
+ )
186
+ tested_columns = sum(1 for column in model.columns.values() if column.tests)
187
+ doc_coverage = _percentage(documented_columns, total_columns)
188
+ test_coverage = _percentage(tested_columns, total_columns)
189
+ click.echo(
190
+ f"- {model.name}: doc coverage {doc_coverage}% "
191
+ f"({documented_columns}/{total_columns}), test coverage {test_coverage}% "
192
+ f"({tested_columns}/{total_columns})"
193
+ )
194
+
195
+
196
+ def _load_project(
197
+ target: str,
198
+ *,
199
+ check_api_key: bool = True,
200
+ ) -> tuple[ScribeConfig, list[ManifestNode]]:
201
+ try:
202
+ _bootstrap()
203
+ model_root = _read_model_root()
204
+ config = load_config("dbt-scribe.yml", check_api_key=check_api_key, model_root=model_root)
205
+ nodes = resolve_target(target, parse_manifest("target/manifest.json"), model_root=model_root)
206
+ except (BootstrapError, ValueError, ConfigError) as exc:
207
+ raise click.ClickException(str(exc)) from exc
208
+ return config, nodes
209
+
210
+
211
+ def _read_model_root() -> str:
212
+ import yaml as _yaml
213
+
214
+ try:
215
+ raw = _yaml.safe_load(Path("dbt_project.yml").read_text()) or {}
216
+ except Exception:
217
+ return "models"
218
+ paths = raw.get("model-paths") or raw.get("source-paths")
219
+ if isinstance(paths, list) and paths:
220
+ return paths[0]
221
+ return "models"
222
+
223
+
224
+ def _build_model(
225
+ node: ManifestNode,
226
+ config: ScribeConfig,
227
+ *,
228
+ overwrite_existing: bool = False,
229
+ ):
230
+ yaml_path = Path(config.model_root) / Path(node.path).with_suffix(".yml")
231
+ yaml_model = parse_yaml(yaml_path)
232
+ return build_enriched_model(
233
+ node,
234
+ yaml_model,
235
+ config,
236
+ overwrite_existing=overwrite_existing,
237
+ )
238
+
239
+
240
+ def _echo_status(command: str, model_name: str, dry_run: bool, changed: bool) -> None:
241
+ mode = "dry-run" if dry_run else "write"
242
+ status = "changed" if changed else "unchanged"
243
+ click.echo(f"{command}: {model_name} [{mode}, {status}]")
244
+
245
+
246
+ def _echo_summary(command: str, nodes: list[ManifestNode], dry_run: bool) -> None:
247
+ suffix = " (dry-run)" if dry_run else ""
248
+ click.echo(f"Summary: {command} processed {len(nodes)} model(s){suffix}.")
249
+
250
+
251
+ def _percentage(numerator: int, denominator: int) -> int:
252
+ if denominator == 0:
253
+ return 100
254
+ return round((numerator / denominator) * 100)