pretensor 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.
- pretensor/__init__.py +50 -0
- pretensor/benchmark/__init__.py +54 -0
- pretensor/benchmark/cli.py +294 -0
- pretensor/benchmark/fixtures.py +84 -0
- pretensor/benchmark/l1/__init__.py +23 -0
- pretensor/benchmark/l1/metrics.py +141 -0
- pretensor/benchmark/l1/pipeline.py +188 -0
- pretensor/benchmark/l1/runner.py +245 -0
- pretensor/benchmark/l2/__init__.py +27 -0
- pretensor/benchmark/l2/gold.py +236 -0
- pretensor/benchmark/l2/metrics.py +146 -0
- pretensor/benchmark/l2/pipeline.py +124 -0
- pretensor/benchmark/l2/runner.py +530 -0
- pretensor/benchmark/l3/__init__.py +73 -0
- pretensor/benchmark/l3/agent.py +316 -0
- pretensor/benchmark/l3/db.py +188 -0
- pretensor/benchmark/l3/gold.py +85 -0
- pretensor/benchmark/l3/llm_client.py +395 -0
- pretensor/benchmark/l3/mcp_client.py +357 -0
- pretensor/benchmark/l3/pretensor_runner.py +456 -0
- pretensor/benchmark/l3/prompt.py +132 -0
- pretensor/benchmark/l3/runner.py +358 -0
- pretensor/benchmark/l3/sql_equivalence.py +176 -0
- pretensor/benchmark/release_gate.py +448 -0
- pretensor/benchmark/results.py +298 -0
- pretensor/benchmark/runner.py +109 -0
- pretensor/cli/__init__.py +1 -0
- pretensor/cli/commands/_source_runner.py +147 -0
- pretensor/cli/commands/analyze.py +201 -0
- pretensor/cli/commands/connections/__init__.py +7 -0
- pretensor/cli/commands/connections/add_remove.py +126 -0
- pretensor/cli/commands/connections/register.py +12 -0
- pretensor/cli/commands/export.py +131 -0
- pretensor/cli/commands/index.py +559 -0
- pretensor/cli/commands/list.py +76 -0
- pretensor/cli/commands/quickstart.py +207 -0
- pretensor/cli/commands/reindex.py +646 -0
- pretensor/cli/commands/semantic.py +190 -0
- pretensor/cli/commands/serve.py +144 -0
- pretensor/cli/commands/sync_grants.py +149 -0
- pretensor/cli/commands/validate.py +176 -0
- pretensor/cli/config_file.py +442 -0
- pretensor/cli/constants.py +10 -0
- pretensor/cli/dbt_enrichment.py +96 -0
- pretensor/cli/main.py +109 -0
- pretensor/cli/paths.py +43 -0
- pretensor/cli/plugin.py +52 -0
- pretensor/config.py +226 -0
- pretensor/connectors/__init__.py +29 -0
- pretensor/connectors/base.py +165 -0
- pretensor/connectors/bigquery.py +468 -0
- pretensor/connectors/inspect.py +321 -0
- pretensor/connectors/lineage_sqlglot.py +97 -0
- pretensor/connectors/models.py +130 -0
- pretensor/connectors/mysql.py +402 -0
- pretensor/connectors/pg_array_parse.py +53 -0
- pretensor/connectors/postgres.py +938 -0
- pretensor/connectors/registry.py +93 -0
- pretensor/connectors/snapshot.py +244 -0
- pretensor/connectors/snowflake.py +908 -0
- pretensor/core/__init__.py +1 -0
- pretensor/core/builder.py +307 -0
- pretensor/core/dsn_crypto.py +51 -0
- pretensor/core/graph_schema_manager.py +246 -0
- pretensor/core/graph_store.py +1226 -0
- pretensor/core/ids.py +101 -0
- pretensor/core/portable_export.py +276 -0
- pretensor/core/query_runner.py +67 -0
- pretensor/core/registry.py +209 -0
- pretensor/core/schema.py +473 -0
- pretensor/core/secure_io.py +93 -0
- pretensor/core/store.py +469 -0
- pretensor/enrichment/__init__.py +1 -0
- pretensor/enrichment/analyze/__init__.py +0 -0
- pretensor/enrichment/analyze/classify.py +49 -0
- pretensor/enrichment/analyze/extract_python.py +196 -0
- pretensor/enrichment/analyze/parse.py +141 -0
- pretensor/enrichment/analyze/pipeline.py +195 -0
- pretensor/enrichment/analyze/summary.py +38 -0
- pretensor/enrichment/analyze/walker.py +98 -0
- pretensor/enrichment/analyze/writers.py +214 -0
- pretensor/enrichment/dbt/__init__.py +30 -0
- pretensor/enrichment/dbt/lineage.py +100 -0
- pretensor/enrichment/dbt/manifest.py +300 -0
- pretensor/enrichment/dbt/metadata.py +263 -0
- pretensor/enrichment/dbt/pipeline.py +77 -0
- pretensor/enrichment/dbt/resolution.py +101 -0
- pretensor/enrichment/dbt/signals.py +305 -0
- pretensor/entities/__init__.py +27 -0
- pretensor/entities/builder.py +63 -0
- pretensor/entities/classifier.py +383 -0
- pretensor/entities/llm_extract.py +66 -0
- pretensor/errors.py +35 -0
- pretensor/graph_models/__init__.py +17 -0
- pretensor/graph_models/base.py +11 -0
- pretensor/graph_models/consumer.py +71 -0
- pretensor/graph_models/edge.py +35 -0
- pretensor/graph_models/entity.py +21 -0
- pretensor/graph_models/node.py +79 -0
- pretensor/graph_models/relationship.py +33 -0
- pretensor/integrations/__init__.py +42 -0
- pretensor/integrations/_base.py +138 -0
- pretensor/integrations/google_adk.py +49 -0
- pretensor/integrations/langchain.py +55 -0
- pretensor/integrations/llamaindex.py +53 -0
- pretensor/intelligence/__init__.py +33 -0
- pretensor/intelligence/cluster_labeler.py +425 -0
- pretensor/intelligence/clustering.py +168 -0
- pretensor/intelligence/combining.py +32 -0
- pretensor/intelligence/discovery.py +114 -0
- pretensor/intelligence/embeddings.py +317 -0
- pretensor/intelligence/graph_export.py +200 -0
- pretensor/intelligence/heuristic.py +544 -0
- pretensor/intelligence/join_paths/__init__.py +130 -0
- pretensor/intelligence/join_paths/on_demand.py +516 -0
- pretensor/intelligence/join_paths/storage.py +70 -0
- pretensor/intelligence/llm_infer.py +78 -0
- pretensor/intelligence/llm_runtime.py +62 -0
- pretensor/intelligence/metric_templates.py +193 -0
- pretensor/intelligence/pipeline.py +364 -0
- pretensor/intelligence/role_exemplars.py +263 -0
- pretensor/intelligence/schema_classification.py +360 -0
- pretensor/intelligence/scoring.py +76 -0
- pretensor/intelligence/semantic.py +240 -0
- pretensor/intelligence/shadow_alias.py +101 -0
- pretensor/intelligence/statistical.py +50 -0
- pretensor/intelligence/steps.py +191 -0
- pretensor/intelligence/steps_embedding.py +168 -0
- pretensor/introspection/__init__.py +6 -0
- pretensor/introspection/inspector.py +5 -0
- pretensor/introspection/models/__init__.py +0 -0
- pretensor/introspection/models/base.py +5 -0
- pretensor/introspection/models/config.py +237 -0
- pretensor/introspection/models/dsn.py +550 -0
- pretensor/introspection/models/plan.py +116 -0
- pretensor/introspection/models/schema.py +10 -0
- pretensor/introspection/models/semantic.py +121 -0
- pretensor/introspection/models/validation.py +116 -0
- pretensor/introspection/snapshot.py +46 -0
- pretensor/mcp/__init__.py +16 -0
- pretensor/mcp/config_json.py +24 -0
- pretensor/mcp/payload_types.py +274 -0
- pretensor/mcp/resources/__init__.py +17 -0
- pretensor/mcp/resources/markdown.py +314 -0
- pretensor/mcp/server.py +285 -0
- pretensor/mcp/service.py +49 -0
- pretensor/mcp/service_context.py +142 -0
- pretensor/mcp/service_registry.py +294 -0
- pretensor/mcp/store_cache.py +43 -0
- pretensor/mcp/tool_registry.py +136 -0
- pretensor/mcp/tools/__init__.py +1 -0
- pretensor/mcp/tools/_rank.py +244 -0
- pretensor/mcp/tools/_timed.py +26 -0
- pretensor/mcp/tools/compile_metric.py +144 -0
- pretensor/mcp/tools/consumers.py +161 -0
- pretensor/mcp/tools/context.py +1121 -0
- pretensor/mcp/tools/cypher.py +509 -0
- pretensor/mcp/tools/detect_changes.py +254 -0
- pretensor/mcp/tools/impact.py +271 -0
- pretensor/mcp/tools/list.py +131 -0
- pretensor/mcp/tools/schema.py +170 -0
- pretensor/mcp/tools/search.py +316 -0
- pretensor/mcp/tools/semantic_search.py +282 -0
- pretensor/mcp/tools/traverse.py +1027 -0
- pretensor/mcp/tools/validate_sql.py +150 -0
- pretensor/observability.py +203 -0
- pretensor/py.typed +0 -0
- pretensor/quickstart/README.md +29 -0
- pretensor/quickstart/__init__.py +6 -0
- pretensor/quickstart/docker-compose.yml +18 -0
- pretensor/quickstart/pagila_data.sql +63 -0
- pretensor/quickstart/pagila_ddl.sql +92 -0
- pretensor/search/__init__.py +6 -0
- pretensor/search/base.py +80 -0
- pretensor/search/index.py +435 -0
- pretensor/semantic/__init__.py +24 -0
- pretensor/semantic/base.py +123 -0
- pretensor/semantic/compiler.py +487 -0
- pretensor/semantic/yaml_layer.py +180 -0
- pretensor/skills/__init__.py +5 -0
- pretensor/skills/generator.py +235 -0
- pretensor/staleness/__init__.py +15 -0
- pretensor/staleness/graph_patcher.py +355 -0
- pretensor/staleness/impact_analyzer.py +162 -0
- pretensor/staleness/snapshot_store.py +38 -0
- pretensor/validation/__init__.py +9 -0
- pretensor/validation/query_validator.py +436 -0
- pretensor/visibility/__init__.py +23 -0
- pretensor/visibility/config.py +126 -0
- pretensor/visibility/filter.py +143 -0
- pretensor/visibility/kuzu_helpers.py +32 -0
- pretensor/visibility/runtime.py +36 -0
- pretensor/visibility/sync_grants.py +188 -0
- pretensor-0.1.0.dist-info/METADATA +251 -0
- pretensor-0.1.0.dist-info/RECORD +198 -0
- pretensor-0.1.0.dist-info/WHEEL +4 -0
- pretensor-0.1.0.dist-info/entry_points.txt +2 -0
- pretensor-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Write ExternalConsumer nodes and CONSUMES edges into the Kuzu graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from pretensor.core.ids import (
|
|
9
|
+
consumes_edge_id,
|
|
10
|
+
external_consumer_node_id,
|
|
11
|
+
table_node_id,
|
|
12
|
+
)
|
|
13
|
+
from pretensor.core.store import KuzuStore
|
|
14
|
+
from pretensor.enrichment.analyze.classify import CONFIDENCE_FLOAT
|
|
15
|
+
from pretensor.enrichment.analyze.extract_python import SqlCandidate
|
|
16
|
+
from pretensor.enrichment.analyze.parse import ParsedSql
|
|
17
|
+
from pretensor.enrichment.analyze.summary import ConsumerRow
|
|
18
|
+
from pretensor.graph_models.consumer import ConsumesEdge, ExternalConsumerNode
|
|
19
|
+
|
|
20
|
+
__all__ = ["ParsedCandidate", "WriteSummary", "write_consumers_for_run"]
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
_CONSUMES_SOURCE = "analyze"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ParsedCandidate:
|
|
29
|
+
"""A SQL candidate paired with its parsed table refs and per-file provenance."""
|
|
30
|
+
|
|
31
|
+
candidate: SqlCandidate
|
|
32
|
+
parsed: ParsedSql
|
|
33
|
+
rel_path: str # repo-relative path (never absolute)
|
|
34
|
+
language: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class WriteSummary:
|
|
39
|
+
"""Counts and planned/written rows from ``write_consumers_for_run``."""
|
|
40
|
+
|
|
41
|
+
consumers_written: int
|
|
42
|
+
edges_written: int
|
|
43
|
+
cross_connection_dropped: int
|
|
44
|
+
rows: tuple[ConsumerRow, ...] = ()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def write_consumers_for_run(
|
|
48
|
+
store: KuzuStore,
|
|
49
|
+
connection_name: str,
|
|
50
|
+
candidates: list[ParsedCandidate],
|
|
51
|
+
scan_run_id: str,
|
|
52
|
+
*,
|
|
53
|
+
service_name: str,
|
|
54
|
+
min_confidence: float = 0.6,
|
|
55
|
+
dry_run: bool = False,
|
|
56
|
+
) -> WriteSummary:
|
|
57
|
+
"""Upsert ``ExternalConsumer`` nodes and ``CONSUMES`` edges for one scan run.
|
|
58
|
+
|
|
59
|
+
For each candidate:
|
|
60
|
+
- Splits parsed refs into reads (``table_refs`` minus write targets) and
|
|
61
|
+
writes (``write_targets``), so each CONSUMES edge carries a truthful ``op``.
|
|
62
|
+
- Resolves each ``(schema, table)`` ref to an existing ``SchemaTable`` node id;
|
|
63
|
+
unresolved (cross-connection or unknown) refs are dropped with a counter.
|
|
64
|
+
- Upserts one ``ExternalConsumer`` node (no raw SQL stored, only the fingerprint).
|
|
65
|
+
- Upserts one ``CONSUMES`` edge per resolved ``(table, op)``.
|
|
66
|
+
- Marks ``has_external_consumers = true`` on every touched ``SchemaTable``.
|
|
67
|
+
|
|
68
|
+
After all candidates, sweeps ``ExternalConsumer`` rows (and stale
|
|
69
|
+
``CONSUMES`` edges) for this ``service_name``/``connection_name`` carrying a
|
|
70
|
+
different ``scan_run_id``, and clears ``has_external_consumers`` on tables
|
|
71
|
+
that lost their last consumer.
|
|
72
|
+
"""
|
|
73
|
+
known_tables = _known_table_ids(store, connection_name)
|
|
74
|
+
folded_known = _casefold_index(known_tables)
|
|
75
|
+
written_consumers: set[str] = set()
|
|
76
|
+
written_edges: set[str] = set()
|
|
77
|
+
cross_connection_dropped = 0
|
|
78
|
+
touched_tables: set[str] = set()
|
|
79
|
+
rows: list[ConsumerRow] = []
|
|
80
|
+
|
|
81
|
+
for pc in candidates:
|
|
82
|
+
cand = pc.candidate
|
|
83
|
+
parsed = pc.parsed
|
|
84
|
+
|
|
85
|
+
confidence = CONFIDENCE_FLOAT.get(cand.confidence_bucket, 0.0)
|
|
86
|
+
if confidence < min_confidence:
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
write_set = _dedup_refs(parsed.write_targets)
|
|
90
|
+
read_set = [r for r in _dedup_refs(parsed.table_refs) if r not in write_set]
|
|
91
|
+
|
|
92
|
+
resolved: list[tuple[str, str, str]] = [] # (table_node_id, op, "schema.table")
|
|
93
|
+
for refs, op in ((read_set, "read"), (write_set, "write")):
|
|
94
|
+
for schema, table in refs:
|
|
95
|
+
nid = table_node_id(connection_name, schema, table)
|
|
96
|
+
if nid not in known_tables:
|
|
97
|
+
nid = folded_known.get(nid.casefold(), "")
|
|
98
|
+
if not nid:
|
|
99
|
+
logger.debug(
|
|
100
|
+
"analyze writers: unresolved ref %s.%s for connection %s (dropped)",
|
|
101
|
+
schema,
|
|
102
|
+
table,
|
|
103
|
+
connection_name,
|
|
104
|
+
)
|
|
105
|
+
cross_connection_dropped += 1
|
|
106
|
+
continue
|
|
107
|
+
_, canon_schema, canon_table = nid.split("::", 2)
|
|
108
|
+
resolved.append((nid, op, f"{canon_schema}.{canon_table}"))
|
|
109
|
+
|
|
110
|
+
if not resolved:
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
consumer_nid = external_consumer_node_id(
|
|
114
|
+
connection_name,
|
|
115
|
+
service_name,
|
|
116
|
+
pc.rel_path,
|
|
117
|
+
cand.line_start,
|
|
118
|
+
parsed.fingerprint,
|
|
119
|
+
)
|
|
120
|
+
if not dry_run:
|
|
121
|
+
store.upsert_external_consumer(
|
|
122
|
+
ExternalConsumerNode(
|
|
123
|
+
node_id=consumer_nid,
|
|
124
|
+
connection_name=connection_name,
|
|
125
|
+
service_name=service_name,
|
|
126
|
+
file_path=pc.rel_path,
|
|
127
|
+
language=pc.language,
|
|
128
|
+
symbol=cand.symbol,
|
|
129
|
+
kind=cand.kind,
|
|
130
|
+
line_start=cand.line_start,
|
|
131
|
+
line_end=cand.line_end,
|
|
132
|
+
sql_fingerprint=parsed.fingerprint,
|
|
133
|
+
confidence=confidence,
|
|
134
|
+
dialect_used=parsed.dialect_used,
|
|
135
|
+
scan_run_id=scan_run_id,
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
written_consumers.add(consumer_nid)
|
|
139
|
+
|
|
140
|
+
for table_nid, op, table_label in resolved:
|
|
141
|
+
eid = consumes_edge_id(consumer_nid, table_nid, op)
|
|
142
|
+
if eid in written_edges:
|
|
143
|
+
continue
|
|
144
|
+
if not dry_run:
|
|
145
|
+
store.upsert_consumes_edge(
|
|
146
|
+
ConsumesEdge(
|
|
147
|
+
edge_id=eid,
|
|
148
|
+
source_node_id=consumer_nid,
|
|
149
|
+
target_node_id=table_nid,
|
|
150
|
+
op=op,
|
|
151
|
+
source=_CONSUMES_SOURCE,
|
|
152
|
+
confidence=confidence,
|
|
153
|
+
scan_run_id=scan_run_id,
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
touched_tables.add(table_nid)
|
|
157
|
+
written_edges.add(eid)
|
|
158
|
+
rows.append(
|
|
159
|
+
ConsumerRow(
|
|
160
|
+
service_name=service_name,
|
|
161
|
+
table=table_label,
|
|
162
|
+
op=op,
|
|
163
|
+
file_path=pc.rel_path,
|
|
164
|
+
line_start=cand.line_start,
|
|
165
|
+
line_end=cand.line_end,
|
|
166
|
+
confidence=confidence,
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
if not dry_run:
|
|
171
|
+
store.mark_tables_have_external_consumers(sorted(touched_tables))
|
|
172
|
+
store.sweep_stale_consumers(service_name, connection_name, scan_run_id)
|
|
173
|
+
|
|
174
|
+
return WriteSummary(
|
|
175
|
+
consumers_written=len(written_consumers),
|
|
176
|
+
edges_written=len(written_edges),
|
|
177
|
+
cross_connection_dropped=cross_connection_dropped,
|
|
178
|
+
rows=tuple(rows),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _known_table_ids(store: KuzuStore, connection_name: str) -> set[str]:
|
|
183
|
+
"""All ``SchemaTable`` node ids indexed under ``connection_name``, in one query."""
|
|
184
|
+
rows = store.query_all_rows(
|
|
185
|
+
"MATCH (t:SchemaTable {connection_name: $cn}) RETURN t.node_id",
|
|
186
|
+
{"cn": connection_name},
|
|
187
|
+
)
|
|
188
|
+
return {str(r[0]) for r in rows}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _casefold_index(known: set[str]) -> dict[str, str]:
|
|
192
|
+
"""Casefolded node id → node id, for unique case-insensitive fallback.
|
|
193
|
+
|
|
194
|
+
Snowflake stores unquoted identifiers uppercase while application SQL is
|
|
195
|
+
conventionally lowercase, so an exact-match-only lookup drops every ref.
|
|
196
|
+
A ref that misses exactly resolves case-insensitively when precisely one
|
|
197
|
+
stored table matches; ambiguous folds (case-variant twins) stay dropped.
|
|
198
|
+
"""
|
|
199
|
+
index: dict[str, str | None] = {}
|
|
200
|
+
for nid in known:
|
|
201
|
+
key = nid.casefold()
|
|
202
|
+
index[key] = None if key in index else nid
|
|
203
|
+
return {k: v for k, v in index.items() if v is not None}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _dedup_refs(refs: list[tuple[str, str]]) -> list[tuple[str, str]]:
|
|
207
|
+
"""Deduplicate (schema, table) pairs while preserving order."""
|
|
208
|
+
seen: set[tuple[str, str]] = set()
|
|
209
|
+
out: list[tuple[str, str]] = []
|
|
210
|
+
for ref in refs:
|
|
211
|
+
if ref not in seen:
|
|
212
|
+
seen.add(ref)
|
|
213
|
+
out.append(ref)
|
|
214
|
+
return out
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""dbt manifest parsing for graph enrichment."""
|
|
2
|
+
|
|
3
|
+
from pretensor.enrichment.dbt.lineage import write_dbt_lineage
|
|
4
|
+
from pretensor.enrichment.dbt.manifest import (
|
|
5
|
+
DbtExposure,
|
|
6
|
+
DbtManifest,
|
|
7
|
+
DbtManifestError,
|
|
8
|
+
DbtModel,
|
|
9
|
+
DbtSource,
|
|
10
|
+
DbtTest,
|
|
11
|
+
)
|
|
12
|
+
from pretensor.enrichment.dbt.metadata import DbtMetadataWriteStats, write_dbt_metadata
|
|
13
|
+
from pretensor.enrichment.dbt.pipeline import DbtEnrichmentSummary, run_dbt_enrichment
|
|
14
|
+
from pretensor.enrichment.dbt.signals import DbtSignalsWriteStats, write_dbt_signals
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"DbtExposure",
|
|
18
|
+
"DbtManifest",
|
|
19
|
+
"DbtManifestError",
|
|
20
|
+
"DbtModel",
|
|
21
|
+
"DbtSource",
|
|
22
|
+
"DbtEnrichmentSummary",
|
|
23
|
+
"DbtMetadataWriteStats",
|
|
24
|
+
"DbtSignalsWriteStats",
|
|
25
|
+
"DbtTest",
|
|
26
|
+
"run_dbt_enrichment",
|
|
27
|
+
"write_dbt_lineage",
|
|
28
|
+
"write_dbt_metadata",
|
|
29
|
+
"write_dbt_signals",
|
|
30
|
+
]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Write table-level ``LINEAGE`` edges from dbt ``manifest.json`` ``parent_map``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from pretensor.core.ids import dbt_lineage_edge_id
|
|
8
|
+
from pretensor.core.store import KuzuStore
|
|
9
|
+
from pretensor.enrichment.dbt.manifest import DbtManifest
|
|
10
|
+
from pretensor.enrichment.dbt.resolution import (
|
|
11
|
+
resolve_dbt_child_node_id,
|
|
12
|
+
resolve_dbt_parent_node_id,
|
|
13
|
+
)
|
|
14
|
+
from pretensor.graph_models.edge import LineageEdge
|
|
15
|
+
|
|
16
|
+
__all__ = ["write_dbt_lineage"]
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
_DBT_LINEAGE_SOURCE = "dbt"
|
|
21
|
+
_DBT_LINEAGE_TYPE = "model_dependency"
|
|
22
|
+
_DBT_LINEAGE_CONFIDENCE = 1.0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _schema_table_exists(store: KuzuStore, node_id: str) -> bool:
|
|
26
|
+
rows = store.query_all_rows(
|
|
27
|
+
"""
|
|
28
|
+
MATCH (t:SchemaTable {node_id: $nid})
|
|
29
|
+
RETURN 1
|
|
30
|
+
LIMIT 1
|
|
31
|
+
""",
|
|
32
|
+
{"nid": node_id},
|
|
33
|
+
)
|
|
34
|
+
return len(rows) > 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def write_dbt_lineage(
|
|
38
|
+
manifest: DbtManifest, store: KuzuStore, connection_name: str
|
|
39
|
+
) -> int:
|
|
40
|
+
"""Emit ``LINEAGE`` edges from dbt ``parent_map`` for one indexed connection.
|
|
41
|
+
|
|
42
|
+
For each ``model.*`` entry, creates one edge per direct parent that is a
|
|
43
|
+
``source.*`` or ``model.*`` node, when both endpoints exist as
|
|
44
|
+
``SchemaTable`` rows (matched by ``connection_name``, schema, and physical
|
|
45
|
+
table name). Upstream is the parent; downstream is the child model.
|
|
46
|
+
|
|
47
|
+
Idempotent per connection: existing dbt ``model_dependency`` edges for this
|
|
48
|
+
connection are removed first, then rewritten. Note that this scopes the
|
|
49
|
+
clear to edges where **both** endpoints are on ``connection_name`` — in
|
|
50
|
+
``--unified`` mode, a dbt project that crosses two Pretensor connections
|
|
51
|
+
would need per-connection re-runs to fully refresh. Cross-connection dbt
|
|
52
|
+
lineage is not supported in Phase 1 (out-of-scope).
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
manifest: Parsed dbt manifest (includes ``parent_map``).
|
|
56
|
+
store: Open Kuzu store with schema ensured.
|
|
57
|
+
connection_name: Pretensor connection name for ``SchemaTable`` nodes.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Number of ``LINEAGE`` edges written.
|
|
61
|
+
"""
|
|
62
|
+
store.clear_dbt_model_dependency_lineage(connection_name)
|
|
63
|
+
written = 0
|
|
64
|
+
for child_id, parents in manifest.parent_map.items():
|
|
65
|
+
child_nid = resolve_dbt_child_node_id(manifest, connection_name, child_id)
|
|
66
|
+
if child_nid is None:
|
|
67
|
+
continue
|
|
68
|
+
if not _schema_table_exists(store, child_nid):
|
|
69
|
+
logger.debug(
|
|
70
|
+
"dbt lineage: child SchemaTable missing for %s -> %s",
|
|
71
|
+
child_id,
|
|
72
|
+
child_nid,
|
|
73
|
+
)
|
|
74
|
+
continue
|
|
75
|
+
for parent_id in parents:
|
|
76
|
+
parent_nid = resolve_dbt_parent_node_id(
|
|
77
|
+
manifest, connection_name, parent_id
|
|
78
|
+
)
|
|
79
|
+
if parent_nid is None:
|
|
80
|
+
continue
|
|
81
|
+
if not _schema_table_exists(store, parent_nid):
|
|
82
|
+
logger.debug(
|
|
83
|
+
"dbt lineage: parent SchemaTable missing for %s -> %s",
|
|
84
|
+
parent_id,
|
|
85
|
+
parent_nid,
|
|
86
|
+
)
|
|
87
|
+
continue
|
|
88
|
+
eid = dbt_lineage_edge_id(connection_name, parent_id, child_id)
|
|
89
|
+
store.upsert_lineage_edge(
|
|
90
|
+
LineageEdge(
|
|
91
|
+
edge_id=eid,
|
|
92
|
+
source_node_id=parent_nid,
|
|
93
|
+
target_node_id=child_nid,
|
|
94
|
+
source=_DBT_LINEAGE_SOURCE,
|
|
95
|
+
lineage_type=_DBT_LINEAGE_TYPE,
|
|
96
|
+
confidence=_DBT_LINEAGE_CONFIDENCE,
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
written += 1
|
|
100
|
+
return written
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Load and parse dbt ``manifest.json`` artifacts for graph enrichment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Mapping
|
|
10
|
+
|
|
11
|
+
from pretensor.errors import PretensorError
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
_SUPPORTED_SCHEMA_MARKERS = ("manifest/v9", "manifest/v10", "/v9.json", "/v10.json")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DbtManifestError(PretensorError):
|
|
19
|
+
"""Raised when a dbt manifest file is missing or cannot be parsed."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class DbtModel:
|
|
24
|
+
"""A dbt model node from ``manifest.json`` ``nodes``."""
|
|
25
|
+
|
|
26
|
+
unique_id: str
|
|
27
|
+
name: str
|
|
28
|
+
description: str | None = None
|
|
29
|
+
tags: tuple[str, ...] = ()
|
|
30
|
+
package_name: str | None = None
|
|
31
|
+
database: str | None = None
|
|
32
|
+
schema_name: str | None = None
|
|
33
|
+
alias: str | None = None
|
|
34
|
+
column_descriptions: dict[str, str] = field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class DbtSource:
|
|
39
|
+
"""A dbt source from ``manifest.json`` ``sources``."""
|
|
40
|
+
|
|
41
|
+
unique_id: str
|
|
42
|
+
name: str
|
|
43
|
+
source_name: str | None = None
|
|
44
|
+
description: str | None = None
|
|
45
|
+
tags: tuple[str, ...] = ()
|
|
46
|
+
package_name: str | None = None
|
|
47
|
+
database: str | None = None
|
|
48
|
+
schema_name: str | None = None
|
|
49
|
+
identifier: str | None = None
|
|
50
|
+
column_descriptions: dict[str, str] = field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class DbtExposure:
|
|
55
|
+
"""A dbt exposure from ``manifest.json`` ``exposures``."""
|
|
56
|
+
|
|
57
|
+
unique_id: str
|
|
58
|
+
name: str
|
|
59
|
+
description: str | None = None
|
|
60
|
+
tags: tuple[str, ...] = ()
|
|
61
|
+
package_name: str | None = None
|
|
62
|
+
depends_on_nodes: tuple[str, ...] = ()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True, slots=True)
|
|
66
|
+
class DbtTest:
|
|
67
|
+
"""A dbt test node (typically under ``nodes`` with ``resource_type`` test)."""
|
|
68
|
+
|
|
69
|
+
unique_id: str
|
|
70
|
+
name: str | None = None
|
|
71
|
+
package_name: str | None = None
|
|
72
|
+
attached_node: str | None = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _is_supported_schema_version(version: str | None) -> bool:
|
|
76
|
+
if version is None:
|
|
77
|
+
return True
|
|
78
|
+
lowered = version.lower()
|
|
79
|
+
return any(marker in lowered for marker in _SUPPORTED_SCHEMA_MARKERS)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _warn_if_unsupported_schema(version: str | None) -> None:
|
|
83
|
+
if version is not None and not _is_supported_schema_version(version):
|
|
84
|
+
logger.warning(
|
|
85
|
+
"dbt manifest schema version may be unsupported: %s "
|
|
86
|
+
"(expected v9 or v10 URL in metadata.dbt_schema_version)",
|
|
87
|
+
version,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _as_str(value: Any) -> str | None:
|
|
92
|
+
if value is None:
|
|
93
|
+
return None
|
|
94
|
+
if isinstance(value, str):
|
|
95
|
+
return value
|
|
96
|
+
return str(value)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _as_str_tuple(value: Any) -> tuple[str, ...]:
|
|
100
|
+
if value is None:
|
|
101
|
+
return ()
|
|
102
|
+
if isinstance(value, str):
|
|
103
|
+
return (value,)
|
|
104
|
+
if isinstance(value, (list, tuple)):
|
|
105
|
+
out: list[str] = []
|
|
106
|
+
for item in value:
|
|
107
|
+
s = _as_str(item)
|
|
108
|
+
if s is not None:
|
|
109
|
+
out.append(s)
|
|
110
|
+
return tuple(out)
|
|
111
|
+
return ()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _depends_on_nodes(raw: Mapping[str, Any]) -> tuple[str, ...]:
|
|
115
|
+
depends_on = raw.get("depends_on")
|
|
116
|
+
if not isinstance(depends_on, dict):
|
|
117
|
+
return ()
|
|
118
|
+
nodes = depends_on.get("nodes")
|
|
119
|
+
if not isinstance(nodes, list):
|
|
120
|
+
return ()
|
|
121
|
+
return tuple(n for n in (_as_str(x) for x in nodes) if n is not None)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _column_descriptions_from_raw(raw: Mapping[str, Any]) -> dict[str, str]:
|
|
125
|
+
"""Map column name -> non-empty inline ``description`` from manifest ``columns``."""
|
|
126
|
+
cols = raw.get("columns")
|
|
127
|
+
if not isinstance(cols, dict):
|
|
128
|
+
return {}
|
|
129
|
+
out: dict[str, str] = {}
|
|
130
|
+
for key, col_raw in cols.items():
|
|
131
|
+
col_name = _as_str(key)
|
|
132
|
+
if col_name is None or not isinstance(col_raw, dict):
|
|
133
|
+
continue
|
|
134
|
+
desc = _as_str(col_raw.get("description"))
|
|
135
|
+
if desc is None:
|
|
136
|
+
continue
|
|
137
|
+
stripped = desc.strip()
|
|
138
|
+
if stripped:
|
|
139
|
+
out[col_name] = stripped
|
|
140
|
+
return out
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _parse_model(unique_id: str, raw: Mapping[str, Any]) -> DbtModel:
|
|
144
|
+
return DbtModel(
|
|
145
|
+
unique_id=unique_id,
|
|
146
|
+
name=_as_str(raw.get("name")) or unique_id,
|
|
147
|
+
description=_as_str(raw.get("description")),
|
|
148
|
+
tags=_as_str_tuple(raw.get("tags")),
|
|
149
|
+
package_name=_as_str(raw.get("package_name")),
|
|
150
|
+
database=_as_str(raw.get("database")),
|
|
151
|
+
schema_name=_as_str(raw.get("schema")),
|
|
152
|
+
alias=_as_str(raw.get("alias")),
|
|
153
|
+
column_descriptions=_column_descriptions_from_raw(raw),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _parse_source(unique_id: str, raw: Mapping[str, Any]) -> DbtSource:
|
|
158
|
+
return DbtSource(
|
|
159
|
+
unique_id=unique_id,
|
|
160
|
+
name=_as_str(raw.get("name")) or unique_id,
|
|
161
|
+
source_name=_as_str(raw.get("source_name")),
|
|
162
|
+
description=_as_str(raw.get("description")),
|
|
163
|
+
tags=_as_str_tuple(raw.get("tags")),
|
|
164
|
+
package_name=_as_str(raw.get("package_name")),
|
|
165
|
+
database=_as_str(raw.get("database")),
|
|
166
|
+
schema_name=_as_str(raw.get("schema")),
|
|
167
|
+
identifier=_as_str(raw.get("identifier")),
|
|
168
|
+
column_descriptions=_column_descriptions_from_raw(raw),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _parse_exposure(unique_id: str, raw: Mapping[str, Any]) -> DbtExposure:
|
|
173
|
+
return DbtExposure(
|
|
174
|
+
unique_id=unique_id,
|
|
175
|
+
name=_as_str(raw.get("name")) or unique_id,
|
|
176
|
+
description=_as_str(raw.get("description")),
|
|
177
|
+
tags=_as_str_tuple(raw.get("tags")),
|
|
178
|
+
package_name=_as_str(raw.get("package_name")),
|
|
179
|
+
depends_on_nodes=_depends_on_nodes(raw),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _parse_test(unique_id: str, raw: Mapping[str, Any]) -> DbtTest:
|
|
184
|
+
return DbtTest(
|
|
185
|
+
unique_id=unique_id,
|
|
186
|
+
name=_as_str(raw.get("name")),
|
|
187
|
+
package_name=_as_str(raw.get("package_name")),
|
|
188
|
+
attached_node=_as_str(raw.get("attached_node")),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _parse_parent_map(raw: Any) -> dict[str, list[str]]:
|
|
193
|
+
if not isinstance(raw, dict):
|
|
194
|
+
return {}
|
|
195
|
+
result: dict[str, list[str]] = {}
|
|
196
|
+
for key, value in raw.items():
|
|
197
|
+
kid = _as_str(key)
|
|
198
|
+
if kid is None:
|
|
199
|
+
continue
|
|
200
|
+
if not isinstance(value, list):
|
|
201
|
+
result[kid] = []
|
|
202
|
+
continue
|
|
203
|
+
parents: list[str] = []
|
|
204
|
+
for item in value:
|
|
205
|
+
sid = _as_str(item)
|
|
206
|
+
if sid is not None:
|
|
207
|
+
parents.append(sid)
|
|
208
|
+
result[kid] = parents
|
|
209
|
+
return result
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@dataclass(frozen=True, slots=True)
|
|
213
|
+
class DbtManifest:
|
|
214
|
+
"""Parsed dbt ``manifest.json`` (v9/v10-oriented; unknown fields ignored)."""
|
|
215
|
+
|
|
216
|
+
schema_version: str | None
|
|
217
|
+
nodes: dict[str, DbtModel]
|
|
218
|
+
sources: dict[str, DbtSource]
|
|
219
|
+
exposures: dict[str, DbtExposure]
|
|
220
|
+
tests: dict[str, DbtTest]
|
|
221
|
+
parent_map: dict[str, list[str]]
|
|
222
|
+
|
|
223
|
+
@classmethod
|
|
224
|
+
def load(cls, path: Path) -> DbtManifest:
|
|
225
|
+
"""Load a manifest from ``path``.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
path: Filesystem path to ``manifest.json``.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
Parsed manifest.
|
|
232
|
+
|
|
233
|
+
Raises:
|
|
234
|
+
DbtManifestError: If the file is missing or JSON is invalid.
|
|
235
|
+
"""
|
|
236
|
+
if not path.is_file():
|
|
237
|
+
msg = f"dbt manifest not found or not a file: {path}"
|
|
238
|
+
raise DbtManifestError(msg)
|
|
239
|
+
try:
|
|
240
|
+
text = path.read_text(encoding="utf-8")
|
|
241
|
+
except OSError as exc:
|
|
242
|
+
msg = f"cannot read dbt manifest: {path}"
|
|
243
|
+
raise DbtManifestError(msg) from exc
|
|
244
|
+
try:
|
|
245
|
+
payload = json.loads(text)
|
|
246
|
+
except json.JSONDecodeError as exc:
|
|
247
|
+
msg = f"dbt manifest is not valid JSON: {path}"
|
|
248
|
+
raise DbtManifestError(msg) from exc
|
|
249
|
+
if not isinstance(payload, dict):
|
|
250
|
+
msg = f"dbt manifest root must be a JSON object: {path}"
|
|
251
|
+
raise DbtManifestError(msg)
|
|
252
|
+
|
|
253
|
+
metadata = payload.get("metadata")
|
|
254
|
+
schema_version: str | None = None
|
|
255
|
+
if isinstance(metadata, dict):
|
|
256
|
+
schema_version = _as_str(metadata.get("dbt_schema_version"))
|
|
257
|
+
_warn_if_unsupported_schema(schema_version)
|
|
258
|
+
|
|
259
|
+
nodes_out: dict[str, DbtModel] = {}
|
|
260
|
+
tests_out: dict[str, DbtTest] = {}
|
|
261
|
+
raw_nodes = payload.get("nodes")
|
|
262
|
+
if isinstance(raw_nodes, dict):
|
|
263
|
+
for node_id, node_raw in raw_nodes.items():
|
|
264
|
+
nid = _as_str(node_id)
|
|
265
|
+
if nid is None or not isinstance(node_raw, dict):
|
|
266
|
+
continue
|
|
267
|
+
rtype = _as_str(node_raw.get("resource_type"))
|
|
268
|
+
if rtype == "model":
|
|
269
|
+
nodes_out[nid] = _parse_model(nid, node_raw)
|
|
270
|
+
elif rtype == "test":
|
|
271
|
+
tests_out[nid] = _parse_test(nid, node_raw)
|
|
272
|
+
|
|
273
|
+
sources_out: dict[str, DbtSource] = {}
|
|
274
|
+
raw_sources = payload.get("sources")
|
|
275
|
+
if isinstance(raw_sources, dict):
|
|
276
|
+
for source_id, source_raw in raw_sources.items():
|
|
277
|
+
sid = _as_str(source_id)
|
|
278
|
+
if sid is None or not isinstance(source_raw, dict):
|
|
279
|
+
continue
|
|
280
|
+
sources_out[sid] = _parse_source(sid, source_raw)
|
|
281
|
+
|
|
282
|
+
exposures_out: dict[str, DbtExposure] = {}
|
|
283
|
+
raw_exposures = payload.get("exposures")
|
|
284
|
+
if isinstance(raw_exposures, dict):
|
|
285
|
+
for exposure_id, exposure_raw in raw_exposures.items():
|
|
286
|
+
eid = _as_str(exposure_id)
|
|
287
|
+
if eid is None or not isinstance(exposure_raw, dict):
|
|
288
|
+
continue
|
|
289
|
+
exposures_out[eid] = _parse_exposure(eid, exposure_raw)
|
|
290
|
+
|
|
291
|
+
parent_map = _parse_parent_map(payload.get("parent_map"))
|
|
292
|
+
|
|
293
|
+
return cls(
|
|
294
|
+
schema_version=schema_version,
|
|
295
|
+
nodes=nodes_out,
|
|
296
|
+
sources=sources_out,
|
|
297
|
+
exposures=exposures_out,
|
|
298
|
+
tests=tests_out,
|
|
299
|
+
parent_map=parent_map,
|
|
300
|
+
)
|