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 +1 -0
- dbt_scribe/analyzer.py +348 -0
- dbt_scribe/cli.py +254 -0
- dbt_scribe/config.py +157 -0
- dbt_scribe/coverage.py +1 -0
- dbt_scribe/generators/__init__.py +0 -0
- dbt_scribe/generators/base_generator.py +41 -0
- dbt_scribe/generators/docs_generator.py +192 -0
- dbt_scribe/generators/providers/__init__.py +0 -0
- dbt_scribe/generators/providers/anthropic_provider.py +30 -0
- dbt_scribe/generators/providers/google_provider.py +31 -0
- dbt_scribe/generators/providers/openai_provider.py +31 -0
- dbt_scribe/generators/tests_generator.py +248 -0
- dbt_scribe/parsers/__init__.py +0 -0
- dbt_scribe/parsers/manifest_parser.py +188 -0
- dbt_scribe/parsers/yaml_parser.py +64 -0
- dbt_scribe/prompts/docs_intermediate.j2 +26 -0
- dbt_scribe/prompts/docs_mart.j2 +31 -0
- dbt_scribe/prompts/docs_staging.j2 +32 -0
- dbt_scribe/prompts/tests_generic.j2 +78 -0
- dbt_scribe/prompts/tests_singular.j2 +1 -0
- dbt_scribe/resolver.py +51 -0
- dbt_scribe/writers/__init__.py +0 -0
- dbt_scribe/writers/docs_writer.py +65 -0
- dbt_scribe/writers/singular_test_writer.py +1 -0
- dbt_scribe/writers/yaml_writer.py +126 -0
- dbt_scribe-0.1.0.dist-info/METADATA +432 -0
- dbt_scribe-0.1.0.dist-info/RECORD +30 -0
- dbt_scribe-0.1.0.dist-info/WHEEL +4 -0
- dbt_scribe-0.1.0.dist-info/entry_points.txt +2 -0
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)
|