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.
Files changed (198) hide show
  1. pretensor/__init__.py +50 -0
  2. pretensor/benchmark/__init__.py +54 -0
  3. pretensor/benchmark/cli.py +294 -0
  4. pretensor/benchmark/fixtures.py +84 -0
  5. pretensor/benchmark/l1/__init__.py +23 -0
  6. pretensor/benchmark/l1/metrics.py +141 -0
  7. pretensor/benchmark/l1/pipeline.py +188 -0
  8. pretensor/benchmark/l1/runner.py +245 -0
  9. pretensor/benchmark/l2/__init__.py +27 -0
  10. pretensor/benchmark/l2/gold.py +236 -0
  11. pretensor/benchmark/l2/metrics.py +146 -0
  12. pretensor/benchmark/l2/pipeline.py +124 -0
  13. pretensor/benchmark/l2/runner.py +530 -0
  14. pretensor/benchmark/l3/__init__.py +73 -0
  15. pretensor/benchmark/l3/agent.py +316 -0
  16. pretensor/benchmark/l3/db.py +188 -0
  17. pretensor/benchmark/l3/gold.py +85 -0
  18. pretensor/benchmark/l3/llm_client.py +395 -0
  19. pretensor/benchmark/l3/mcp_client.py +357 -0
  20. pretensor/benchmark/l3/pretensor_runner.py +456 -0
  21. pretensor/benchmark/l3/prompt.py +132 -0
  22. pretensor/benchmark/l3/runner.py +358 -0
  23. pretensor/benchmark/l3/sql_equivalence.py +176 -0
  24. pretensor/benchmark/release_gate.py +448 -0
  25. pretensor/benchmark/results.py +298 -0
  26. pretensor/benchmark/runner.py +109 -0
  27. pretensor/cli/__init__.py +1 -0
  28. pretensor/cli/commands/_source_runner.py +147 -0
  29. pretensor/cli/commands/analyze.py +201 -0
  30. pretensor/cli/commands/connections/__init__.py +7 -0
  31. pretensor/cli/commands/connections/add_remove.py +126 -0
  32. pretensor/cli/commands/connections/register.py +12 -0
  33. pretensor/cli/commands/export.py +131 -0
  34. pretensor/cli/commands/index.py +559 -0
  35. pretensor/cli/commands/list.py +76 -0
  36. pretensor/cli/commands/quickstart.py +207 -0
  37. pretensor/cli/commands/reindex.py +646 -0
  38. pretensor/cli/commands/semantic.py +190 -0
  39. pretensor/cli/commands/serve.py +144 -0
  40. pretensor/cli/commands/sync_grants.py +149 -0
  41. pretensor/cli/commands/validate.py +176 -0
  42. pretensor/cli/config_file.py +442 -0
  43. pretensor/cli/constants.py +10 -0
  44. pretensor/cli/dbt_enrichment.py +96 -0
  45. pretensor/cli/main.py +109 -0
  46. pretensor/cli/paths.py +43 -0
  47. pretensor/cli/plugin.py +52 -0
  48. pretensor/config.py +226 -0
  49. pretensor/connectors/__init__.py +29 -0
  50. pretensor/connectors/base.py +165 -0
  51. pretensor/connectors/bigquery.py +468 -0
  52. pretensor/connectors/inspect.py +321 -0
  53. pretensor/connectors/lineage_sqlglot.py +97 -0
  54. pretensor/connectors/models.py +130 -0
  55. pretensor/connectors/mysql.py +402 -0
  56. pretensor/connectors/pg_array_parse.py +53 -0
  57. pretensor/connectors/postgres.py +938 -0
  58. pretensor/connectors/registry.py +93 -0
  59. pretensor/connectors/snapshot.py +244 -0
  60. pretensor/connectors/snowflake.py +908 -0
  61. pretensor/core/__init__.py +1 -0
  62. pretensor/core/builder.py +307 -0
  63. pretensor/core/dsn_crypto.py +51 -0
  64. pretensor/core/graph_schema_manager.py +246 -0
  65. pretensor/core/graph_store.py +1226 -0
  66. pretensor/core/ids.py +101 -0
  67. pretensor/core/portable_export.py +276 -0
  68. pretensor/core/query_runner.py +67 -0
  69. pretensor/core/registry.py +209 -0
  70. pretensor/core/schema.py +473 -0
  71. pretensor/core/secure_io.py +93 -0
  72. pretensor/core/store.py +469 -0
  73. pretensor/enrichment/__init__.py +1 -0
  74. pretensor/enrichment/analyze/__init__.py +0 -0
  75. pretensor/enrichment/analyze/classify.py +49 -0
  76. pretensor/enrichment/analyze/extract_python.py +196 -0
  77. pretensor/enrichment/analyze/parse.py +141 -0
  78. pretensor/enrichment/analyze/pipeline.py +195 -0
  79. pretensor/enrichment/analyze/summary.py +38 -0
  80. pretensor/enrichment/analyze/walker.py +98 -0
  81. pretensor/enrichment/analyze/writers.py +214 -0
  82. pretensor/enrichment/dbt/__init__.py +30 -0
  83. pretensor/enrichment/dbt/lineage.py +100 -0
  84. pretensor/enrichment/dbt/manifest.py +300 -0
  85. pretensor/enrichment/dbt/metadata.py +263 -0
  86. pretensor/enrichment/dbt/pipeline.py +77 -0
  87. pretensor/enrichment/dbt/resolution.py +101 -0
  88. pretensor/enrichment/dbt/signals.py +305 -0
  89. pretensor/entities/__init__.py +27 -0
  90. pretensor/entities/builder.py +63 -0
  91. pretensor/entities/classifier.py +383 -0
  92. pretensor/entities/llm_extract.py +66 -0
  93. pretensor/errors.py +35 -0
  94. pretensor/graph_models/__init__.py +17 -0
  95. pretensor/graph_models/base.py +11 -0
  96. pretensor/graph_models/consumer.py +71 -0
  97. pretensor/graph_models/edge.py +35 -0
  98. pretensor/graph_models/entity.py +21 -0
  99. pretensor/graph_models/node.py +79 -0
  100. pretensor/graph_models/relationship.py +33 -0
  101. pretensor/integrations/__init__.py +42 -0
  102. pretensor/integrations/_base.py +138 -0
  103. pretensor/integrations/google_adk.py +49 -0
  104. pretensor/integrations/langchain.py +55 -0
  105. pretensor/integrations/llamaindex.py +53 -0
  106. pretensor/intelligence/__init__.py +33 -0
  107. pretensor/intelligence/cluster_labeler.py +425 -0
  108. pretensor/intelligence/clustering.py +168 -0
  109. pretensor/intelligence/combining.py +32 -0
  110. pretensor/intelligence/discovery.py +114 -0
  111. pretensor/intelligence/embeddings.py +317 -0
  112. pretensor/intelligence/graph_export.py +200 -0
  113. pretensor/intelligence/heuristic.py +544 -0
  114. pretensor/intelligence/join_paths/__init__.py +130 -0
  115. pretensor/intelligence/join_paths/on_demand.py +516 -0
  116. pretensor/intelligence/join_paths/storage.py +70 -0
  117. pretensor/intelligence/llm_infer.py +78 -0
  118. pretensor/intelligence/llm_runtime.py +62 -0
  119. pretensor/intelligence/metric_templates.py +193 -0
  120. pretensor/intelligence/pipeline.py +364 -0
  121. pretensor/intelligence/role_exemplars.py +263 -0
  122. pretensor/intelligence/schema_classification.py +360 -0
  123. pretensor/intelligence/scoring.py +76 -0
  124. pretensor/intelligence/semantic.py +240 -0
  125. pretensor/intelligence/shadow_alias.py +101 -0
  126. pretensor/intelligence/statistical.py +50 -0
  127. pretensor/intelligence/steps.py +191 -0
  128. pretensor/intelligence/steps_embedding.py +168 -0
  129. pretensor/introspection/__init__.py +6 -0
  130. pretensor/introspection/inspector.py +5 -0
  131. pretensor/introspection/models/__init__.py +0 -0
  132. pretensor/introspection/models/base.py +5 -0
  133. pretensor/introspection/models/config.py +237 -0
  134. pretensor/introspection/models/dsn.py +550 -0
  135. pretensor/introspection/models/plan.py +116 -0
  136. pretensor/introspection/models/schema.py +10 -0
  137. pretensor/introspection/models/semantic.py +121 -0
  138. pretensor/introspection/models/validation.py +116 -0
  139. pretensor/introspection/snapshot.py +46 -0
  140. pretensor/mcp/__init__.py +16 -0
  141. pretensor/mcp/config_json.py +24 -0
  142. pretensor/mcp/payload_types.py +274 -0
  143. pretensor/mcp/resources/__init__.py +17 -0
  144. pretensor/mcp/resources/markdown.py +314 -0
  145. pretensor/mcp/server.py +285 -0
  146. pretensor/mcp/service.py +49 -0
  147. pretensor/mcp/service_context.py +142 -0
  148. pretensor/mcp/service_registry.py +294 -0
  149. pretensor/mcp/store_cache.py +43 -0
  150. pretensor/mcp/tool_registry.py +136 -0
  151. pretensor/mcp/tools/__init__.py +1 -0
  152. pretensor/mcp/tools/_rank.py +244 -0
  153. pretensor/mcp/tools/_timed.py +26 -0
  154. pretensor/mcp/tools/compile_metric.py +144 -0
  155. pretensor/mcp/tools/consumers.py +161 -0
  156. pretensor/mcp/tools/context.py +1121 -0
  157. pretensor/mcp/tools/cypher.py +509 -0
  158. pretensor/mcp/tools/detect_changes.py +254 -0
  159. pretensor/mcp/tools/impact.py +271 -0
  160. pretensor/mcp/tools/list.py +131 -0
  161. pretensor/mcp/tools/schema.py +170 -0
  162. pretensor/mcp/tools/search.py +316 -0
  163. pretensor/mcp/tools/semantic_search.py +282 -0
  164. pretensor/mcp/tools/traverse.py +1027 -0
  165. pretensor/mcp/tools/validate_sql.py +150 -0
  166. pretensor/observability.py +203 -0
  167. pretensor/py.typed +0 -0
  168. pretensor/quickstart/README.md +29 -0
  169. pretensor/quickstart/__init__.py +6 -0
  170. pretensor/quickstart/docker-compose.yml +18 -0
  171. pretensor/quickstart/pagila_data.sql +63 -0
  172. pretensor/quickstart/pagila_ddl.sql +92 -0
  173. pretensor/search/__init__.py +6 -0
  174. pretensor/search/base.py +80 -0
  175. pretensor/search/index.py +435 -0
  176. pretensor/semantic/__init__.py +24 -0
  177. pretensor/semantic/base.py +123 -0
  178. pretensor/semantic/compiler.py +487 -0
  179. pretensor/semantic/yaml_layer.py +180 -0
  180. pretensor/skills/__init__.py +5 -0
  181. pretensor/skills/generator.py +235 -0
  182. pretensor/staleness/__init__.py +15 -0
  183. pretensor/staleness/graph_patcher.py +355 -0
  184. pretensor/staleness/impact_analyzer.py +162 -0
  185. pretensor/staleness/snapshot_store.py +38 -0
  186. pretensor/validation/__init__.py +9 -0
  187. pretensor/validation/query_validator.py +436 -0
  188. pretensor/visibility/__init__.py +23 -0
  189. pretensor/visibility/config.py +126 -0
  190. pretensor/visibility/filter.py +143 -0
  191. pretensor/visibility/kuzu_helpers.py +32 -0
  192. pretensor/visibility/runtime.py +36 -0
  193. pretensor/visibility/sync_grants.py +188 -0
  194. pretensor-0.1.0.dist-info/METADATA +251 -0
  195. pretensor-0.1.0.dist-info/RECORD +198 -0
  196. pretensor-0.1.0.dist-info/WHEEL +4 -0
  197. pretensor-0.1.0.dist-info/entry_points.txt +2 -0
  198. 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
+ )