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 @@
1
+ """Core graph storage, schema DDL, and build pipeline."""
@@ -0,0 +1,307 @@
1
+ """Build a Kuzu graph from a pipeline :class:`SchemaSnapshot`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+
8
+ from pretensor.config import GraphConfig, PretensorConfig
9
+ from pretensor.connectors.models import Column, SchemaSnapshot, ViewDependency
10
+ from pretensor.core.ids import (
11
+ column_node_id,
12
+ fk_edge_id,
13
+ lineage_edge_id,
14
+ table_node_id,
15
+ )
16
+ from pretensor.core.store import KuzuStore
17
+ from pretensor.graph_models.edge import GraphEdge, LineageEdge
18
+ from pretensor.graph_models.node import GraphNode
19
+ from pretensor.intelligence.discovery import RelationshipDiscovery
20
+ from pretensor.intelligence.embeddings import embeddings_disabled_via_env
21
+ from pretensor.intelligence.pipeline import run_intelligence_layer_sync
22
+ from pretensor.intelligence.steps_embedding import compute_table_embeddings
23
+ from pretensor.visibility.filter import VisibilityFilter
24
+
25
+ __all__ = ["GraphBuilder", "table_node_id", "fk_edge_id"]
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ def _filtered_snapshot_for_visibility(
31
+ snapshot: SchemaSnapshot,
32
+ visibility_filter: VisibilityFilter,
33
+ ) -> SchemaSnapshot:
34
+ """Drop hidden tables and view dependencies that touch hidden tables."""
35
+ visible_tables = [
36
+ t
37
+ for t in snapshot.tables
38
+ if visibility_filter.is_table_visible(
39
+ snapshot.connection_name, t.schema_name, t.name
40
+ )
41
+ ]
42
+ keys = {(t.schema_name, t.name) for t in visible_tables}
43
+ deps: list[ViewDependency] = []
44
+ for dep in snapshot.view_dependencies:
45
+ if (dep.source_schema, dep.source_table) not in keys:
46
+ continue
47
+ if (dep.target_schema, dep.target_table) not in keys:
48
+ continue
49
+ deps.append(dep)
50
+ return snapshot.model_copy(
51
+ update={"tables": visible_tables, "view_dependencies": deps}
52
+ )
53
+
54
+
55
+ class GraphBuilder:
56
+ """Writes table nodes and foreign-key edges from a snapshot."""
57
+
58
+ def build(
59
+ self,
60
+ snapshot: SchemaSnapshot,
61
+ store: KuzuStore,
62
+ *,
63
+ run_relationship_discovery: bool = True,
64
+ replace_mode: str = "full",
65
+ graph_config: GraphConfig | None = None,
66
+ config: PretensorConfig | None = None,
67
+ visibility_filter: VisibilityFilter | None = None,
68
+ ) -> None:
69
+ """Populate ``store`` from ``snapshot`` (replaces existing graph content).
70
+
71
+ When ``run_relationship_discovery`` is True, runs heuristic discovery and
72
+ writes ``INFERRED_JOIN`` edges that are not already explicit FKs.
73
+
74
+ Args:
75
+ replace_mode: ``full`` clears the entire graph (default per-connection index).
76
+ ``connection`` removes only nodes/edges for ``snapshot.connection_name``
77
+ (unified multi-DB graph file).
78
+ graph_config: Deprecated. Use ``config.graph`` instead. If both are given,
79
+ ``config.graph`` takes precedence.
80
+ config: Full pluggable configuration. When provided, its ``scorer_registry``
81
+ and ``combiner`` are forwarded to :class:`RelationshipDiscovery` and
82
+ its ``graph`` field is forwarded to the intelligence pipeline.
83
+ """
84
+ store.ensure_schema()
85
+ effective_embeddings = (config or PretensorConfig()).embeddings
86
+ if not effective_embeddings.index_tables and store.has_any_table_embeddings():
87
+ logger.warning(
88
+ "Rebuilding without --embeddings: previously computed table "
89
+ "embeddings will be dropped with the old graph rows. Re-run "
90
+ "with --embeddings to recompute them."
91
+ )
92
+ if replace_mode == "connection":
93
+ store.clear_connection_subgraph(snapshot.connection_name)
94
+ else:
95
+ store.clear_graph()
96
+
97
+ snap = (
98
+ _filtered_snapshot_for_visibility(snapshot, visibility_filter)
99
+ if visibility_filter is not None
100
+ else snapshot
101
+ )
102
+
103
+ for table in snap.tables:
104
+ grants_json = (
105
+ json.dumps(table.grants, ensure_ascii=False) if table.grants else None
106
+ )
107
+ node = GraphNode(
108
+ node_id=table_node_id(
109
+ snap.connection_name, table.schema_name, table.name
110
+ ),
111
+ connection_name=snap.connection_name,
112
+ database=snap.database,
113
+ schema_name=table.schema_name,
114
+ table_name=table.name,
115
+ row_count=table.row_count,
116
+ row_count_source=table.row_count_source,
117
+ comment=table.comment,
118
+ table_type=table.table_type,
119
+ seq_scan_count=table.seq_scan_count,
120
+ idx_scan_count=table.idx_scan_count,
121
+ insert_count=table.insert_count,
122
+ update_count=table.update_count,
123
+ delete_count=table.delete_count,
124
+ is_partitioned=table.is_partitioned,
125
+ partition_key=table.partition_key,
126
+ grants_json=grants_json,
127
+ access_read_count=table.access_read_count,
128
+ access_write_count=table.access_write_count,
129
+ days_since_last_access=table.days_since_last_access,
130
+ potentially_unused=table.potentially_unused,
131
+ table_bytes=table.table_bytes,
132
+ clustering_key=table.clustering_key,
133
+ )
134
+ store.upsert_table(node)
135
+ col_names = [c.name for c in table.columns]
136
+ if visibility_filter is not None:
137
+ allowed_cols = visibility_filter.visible_columns(
138
+ snap.connection_name,
139
+ table.schema_name,
140
+ table.name,
141
+ col_names,
142
+ )
143
+ allowed_set = set(allowed_cols)
144
+ pending = [c for c in table.columns if c.name in allowed_set]
145
+ else:
146
+ pending = list(table.columns)
147
+ written: set[str] = set()
148
+ while pending:
149
+ progressed = False
150
+ next_pass: list[Column] = []
151
+ for col in pending:
152
+ parent_id: str | None = None
153
+ if col.parent_column is not None:
154
+ if col.parent_column not in written:
155
+ next_pass.append(col)
156
+ continue
157
+ parent_id = column_node_id(
158
+ snap.connection_name,
159
+ table.schema_name,
160
+ table.name,
161
+ col.parent_column,
162
+ )
163
+ cid = column_node_id(
164
+ snap.connection_name, table.schema_name, table.name, col.name
165
+ )
166
+ mcv_json = (
167
+ json.dumps(col.most_common_values, ensure_ascii=False)
168
+ if col.most_common_values
169
+ else None
170
+ )
171
+ hb_json = (
172
+ json.dumps(col.histogram_bounds, ensure_ascii=False)
173
+ if col.histogram_bounds
174
+ else None
175
+ )
176
+ store.upsert_column_for_table(
177
+ column_node_id=cid,
178
+ connection_name=snap.connection_name,
179
+ database=snap.database,
180
+ schema_name=table.schema_name,
181
+ table_name=table.name,
182
+ column_name=col.name,
183
+ data_type=col.data_type,
184
+ nullable=col.nullable,
185
+ is_primary_key=col.is_primary_key,
186
+ is_foreign_key=col.is_foreign_key,
187
+ table_node_id=node.node_id,
188
+ comment=col.comment,
189
+ default_value=col.default_value,
190
+ is_indexed=col.is_indexed,
191
+ check_constraints=col.check_constraints,
192
+ ordinal_position=col.ordinal_position,
193
+ most_common_values_json=mcv_json,
194
+ histogram_bounds_json=hb_json,
195
+ stats_correlation=col.stats_correlation,
196
+ column_cardinality=col.column_cardinality,
197
+ index_type=col.index_type,
198
+ index_is_unique=col.index_is_unique,
199
+ parent_column_id=parent_id,
200
+ is_array=col.is_array,
201
+ )
202
+ written.add(col.name)
203
+ progressed = True
204
+ if not progressed and next_pass:
205
+ logger.warning(
206
+ "build: %d column(s) for table %s.%s skipped — "
207
+ "unresolvable parent_column reference(s): %s",
208
+ len(next_pass),
209
+ table.schema_name,
210
+ table.name,
211
+ ", ".join(col.name for col in next_pass),
212
+ )
213
+ break
214
+ pending = next_pass
215
+
216
+ known = {
217
+ table_node_id(snap.connection_name, t.schema_name, t.name)
218
+ for t in snap.tables
219
+ }
220
+
221
+ for table in snap.tables:
222
+ for fk in table.foreign_keys:
223
+ src_id = table_node_id(
224
+ snap.connection_name, fk.source_schema, fk.source_table
225
+ )
226
+ dst_id = table_node_id(
227
+ snap.connection_name, fk.target_schema, fk.target_table
228
+ )
229
+ if src_id not in known or dst_id not in known:
230
+ continue
231
+ edge = GraphEdge(
232
+ edge_id=fk_edge_id(fk),
233
+ source_node_id=src_id,
234
+ target_node_id=dst_id,
235
+ source_column=fk.source_column,
236
+ target_column=fk.target_column,
237
+ constraint_name=fk.constraint_name,
238
+ )
239
+ store.merge_fk_edge(edge)
240
+
241
+ lineage_by_id: dict[str, LineageEdge] = {}
242
+ for dep in snap.view_dependencies:
243
+ src_id = table_node_id(
244
+ snap.connection_name, dep.source_schema, dep.source_table
245
+ )
246
+ dst_id = table_node_id(
247
+ snap.connection_name, dep.target_schema, dep.target_table
248
+ )
249
+ if src_id not in known or dst_id not in known:
250
+ continue
251
+ eid = lineage_edge_id(snap.connection_name, dep)
252
+ src_label = f"{snap.connection_name}:{dep.object_name}"
253
+ cand = LineageEdge(
254
+ edge_id=eid,
255
+ source_node_id=src_id,
256
+ target_node_id=dst_id,
257
+ source=src_label,
258
+ lineage_type=dep.lineage_type,
259
+ confidence=float(dep.confidence),
260
+ )
261
+ existing = lineage_by_id.get(eid)
262
+ if existing is None or cand.confidence > existing.confidence:
263
+ lineage_by_id[eid] = cand
264
+ for ledge in lineage_by_id.values():
265
+ store.upsert_lineage_edge(ledge)
266
+
267
+ effective_config = config or PretensorConfig()
268
+ effective_graph_cfg = (
269
+ effective_config.graph if config else (graph_config or GraphConfig())
270
+ )
271
+
272
+ # Compute embeddings right after schema rows land and BEFORE
273
+ # relationship discovery, so the embedding relationship scorer, the
274
+ # clustering blend, and the classify role vote all read vectors
275
+ # from THIS run instead of lagging one index cycle behind.
276
+ embeddings_precomputed = False
277
+ if (
278
+ effective_config.embeddings.index_tables
279
+ and not embeddings_disabled_via_env()
280
+ ):
281
+ compute_table_embeddings(store, snap.database)
282
+ embeddings_precomputed = True
283
+
284
+ if run_relationship_discovery:
285
+ from pretensor.intelligence.semantic import (
286
+ extend_with_embedding_scorer,
287
+ )
288
+
289
+ scorers = extend_with_embedding_scorer(
290
+ effective_config.scorer_registry,
291
+ store=store,
292
+ database_key=snap.database,
293
+ threshold=effective_config.embeddings.join_threshold,
294
+ )
295
+ RelationshipDiscovery(
296
+ store,
297
+ scorers=scorers,
298
+ combiner=effective_config.combiner,
299
+ graph_config=effective_graph_cfg,
300
+ ).discover(snap)
301
+
302
+ run_intelligence_layer_sync(
303
+ store,
304
+ snap.database,
305
+ config=effective_config,
306
+ embeddings_precomputed=embeddings_precomputed,
307
+ )
@@ -0,0 +1,51 @@
1
+ """Fernet encryption for DSN strings at rest under ``.pretensor/``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from cryptography.fernet import Fernet, InvalidToken
8
+
9
+ from pretensor.core.secure_io import (
10
+ atomic_write_bytes,
11
+ secure_mkdir,
12
+ warn_if_world_readable,
13
+ )
14
+ from pretensor.errors import PretensorError
15
+
16
+ __all__ = ["DSNEncryptor", "DSNDecryptError"]
17
+
18
+
19
+ class DSNDecryptError(PretensorError, RuntimeError):
20
+ """Raised when an encrypted DSN cannot be decrypted."""
21
+
22
+
23
+ class DSNEncryptor:
24
+ """Symmetric encryption for connection strings (key file beside registry)."""
25
+
26
+ def __init__(self, key_path: Path) -> None:
27
+ self._key_path = key_path
28
+ self._fernet = Fernet(self._load_or_create_key())
29
+
30
+ def _load_or_create_key(self) -> bytes:
31
+ # The directory holding the sole secret that decrypts every DSN is
32
+ # created 0o700, and the key file 0o600, so a local user on a shared
33
+ # host cannot read it. An existing too-loose key triggers a warning.
34
+ secure_mkdir(self._key_path.parent)
35
+ if self._key_path.exists():
36
+ warn_if_world_readable(self._key_path)
37
+ return self._key_path.read_bytes().strip()
38
+ key = Fernet.generate_key()
39
+ atomic_write_bytes(self._key_path, key + b"\n", mode=0o600)
40
+ return key
41
+
42
+ def encrypt(self, dsn: str) -> str:
43
+ """Return urlsafe base64 ciphertext."""
44
+ return self._fernet.encrypt(dsn.encode("utf-8")).decode("ascii")
45
+
46
+ def decrypt(self, token: str) -> str:
47
+ """Recover the original DSN or raise :class:`DSNDecryptError`."""
48
+ try:
49
+ return self._fernet.decrypt(token.encode("ascii")).decode("utf-8")
50
+ except InvalidToken as exc:
51
+ raise DSNDecryptError("Invalid encrypted DSN or wrong keystore") from exc
@@ -0,0 +1,246 @@
1
+ """Schema creation and idempotent column migration for the Kuzu graph.
2
+
3
+ Extracted from :class:`~pretensor.core.store.KuzuStore` to isolate the
4
+ schema-migration concern from the rest of the store layer.
5
+ :class:`GraphSchemaManager` is composed inside ``KuzuStore``; it owns every
6
+ ``ensure_schema`` DDL call and every ``_ensure_*`` / ``_drop_*`` column-migration
7
+ helper so that ``KuzuStore`` can focus on query, upsert, and read operations.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+
14
+ import kuzu
15
+
16
+ from pretensor.core import schema as graph_schema
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # Kept in sync with ``pretensor.intelligence.embeddings.EMBEDDING_DIM`` (384).
21
+ # Duplicated here to avoid the circular import created by
22
+ # ``intelligence/__init__.py`` eagerly loading ``cluster_labeler`` (which itself
23
+ # imports ``KuzuStore``). The schema DDL literal ``FLOAT[384]`` and this
24
+ # constant are both asserted against the real ``EMBEDDING_DIM`` in tests.
25
+ _SCHEMA_TABLE_EMBEDDING_DIM = 384
26
+
27
+
28
+ class GraphSchemaManager:
29
+ """Owns schema creation and idempotent column migrations for the Kuzu graph."""
30
+
31
+ def __init__(self, conn: kuzu.Connection) -> None:
32
+ self._conn = conn
33
+
34
+ def _add_column_if_missing(self, table: str, col_name: str, col_type: str) -> None:
35
+ try:
36
+ self._conn.execute(f"ALTER TABLE {table} ADD {col_name} {col_type}")
37
+ except RuntimeError as exc:
38
+ message = str(exc).lower()
39
+ if col_name.lower() in message and "already has" in message:
40
+ return
41
+ logger.warning("%s ALTER for %s: %s", table, col_name, exc)
42
+
43
+ def _drop_column_if_present(self, table: str, col_name: str) -> None:
44
+ try:
45
+ self._conn.execute(f"ALTER TABLE {table} DROP {col_name}")
46
+ except RuntimeError as exc:
47
+ message = str(exc).lower()
48
+ if "does not have" in message or "does not exist" in message:
49
+ return
50
+ logger.debug("%s DROP for %s: %s", table, col_name, exc)
51
+
52
+ def ensure_schema(self) -> None:
53
+ """Create node and relationship tables if they do not exist."""
54
+ self._conn.execute(graph_schema.DDL_CREATE_TABLE_NODE)
55
+ self._ensure_schema_table_entity_type_column()
56
+ self._ensure_schema_table_table_type_column()
57
+ self._ensure_schema_table_catalog_enrichment_columns()
58
+ self._ensure_schema_table_classification_columns()
59
+ self._ensure_schema_table_dbt_enrichment_columns()
60
+ self._ensure_schema_table_description_tags_columns()
61
+ self._ensure_schema_table_staleness_columns()
62
+ self._ensure_schema_table_row_count_source_column()
63
+ self._ensure_schema_table_embedding_column()
64
+ self._drop_schema_table_legacy_dbt_columns()
65
+ self._conn.execute(graph_schema.DDL_CREATE_COLUMN_NODE)
66
+ self._ensure_schema_column_description_column()
67
+ self._ensure_schema_column_enrichment_columns()
68
+ self._ensure_schema_column_stats_columns()
69
+ self._ensure_schema_column_catalog_enrichment_columns()
70
+ self._ensure_schema_column_nested_columns()
71
+ self._conn.execute(graph_schema.DDL_CREATE_HAS_COLUMN_REL)
72
+ self._conn.execute(graph_schema.DDL_CREATE_HAS_SUBCOLUMN_REL)
73
+ self._conn.execute(graph_schema.DDL_CREATE_ENTITY_NODE)
74
+ self._conn.execute(graph_schema.DDL_CREATE_REPRESENTS_REL)
75
+ self._conn.execute(graph_schema.DDL_CREATE_FK_REL)
76
+ self._ensure_fk_constraint_name_column()
77
+ self._conn.execute(graph_schema.DDL_CREATE_INFERRED_JOIN_REL)
78
+ self._conn.execute(graph_schema.DDL_CREATE_LINEAGE_REL)
79
+ self._conn.execute(graph_schema.DDL_CREATE_SAME_ENTITY_REL)
80
+ self._conn.execute(graph_schema.DDL_CREATE_CLUSTER_NODE)
81
+ self._ensure_cluster_stale_column()
82
+ self._ensure_cluster_schema_pattern_column()
83
+ self._conn.execute(graph_schema.DDL_CREATE_IN_CLUSTER_REL)
84
+ self._conn.execute(graph_schema.DDL_CREATE_JOIN_PATH_NODE)
85
+ self._ensure_join_path_stale_column()
86
+ self._conn.execute(graph_schema.DDL_CREATE_METRIC_TEMPLATE_NODE)
87
+ self._ensure_metric_template_stale_column()
88
+ self._ensure_metric_template_dialect_column()
89
+ self._conn.execute(graph_schema.DDL_CREATE_METRIC_DEPENDS_REL)
90
+ self._conn.execute(graph_schema.DDL_CREATE_SEMANTIC_METRIC_NODE)
91
+ self._conn.execute(graph_schema.DDL_CREATE_SEMANTIC_DIMENSION_NODE)
92
+ self._conn.execute(graph_schema.DDL_CREATE_SEMANTIC_BUSINESS_RULE_NODE)
93
+ self._conn.execute(graph_schema.DDL_CREATE_SEMANTIC_METRIC_DEPENDS_REL)
94
+ self._conn.execute(graph_schema.DDL_CREATE_SEMANTIC_DIMENSION_LEVEL_REL)
95
+ self._conn.execute(graph_schema.DDL_CREATE_SEMANTIC_RULE_APPLIES_TO_REL)
96
+ self._conn.execute(graph_schema.DDL_CREATE_EXTERNAL_CONSUMER_NODE)
97
+ self._conn.execute(graph_schema.DDL_CREATE_CONSUMES_REL)
98
+
99
+ def _ensure_schema_table_entity_type_column(self) -> None:
100
+ """Add ``entity_type`` to ``SchemaTable`` when upgrading older graph files."""
101
+ self._add_column_if_missing("SchemaTable", "entity_type", "STRING")
102
+
103
+ def _ensure_schema_table_table_type_column(self) -> None:
104
+ """Add ``table_type`` to ``SchemaTable`` when upgrading older graph files."""
105
+ self._add_column_if_missing("SchemaTable", "table_type", "STRING")
106
+
107
+ def _ensure_schema_table_catalog_enrichment_columns(self) -> None:
108
+ """Add usage, partition, grant, and Snowflake catalog fields for older graphs."""
109
+ for col_name, col_type in (
110
+ ("seq_scan_count", "INT64"),
111
+ ("idx_scan_count", "INT64"),
112
+ ("insert_count", "INT64"),
113
+ ("update_count", "INT64"),
114
+ ("delete_count", "INT64"),
115
+ ("is_partitioned", "BOOL"),
116
+ ("partition_key", "STRING"),
117
+ ("grants_json", "STRING"),
118
+ ("access_read_count", "INT64"),
119
+ ("access_write_count", "INT64"),
120
+ ("days_since_last_access", "INT64"),
121
+ ("potentially_unused", "BOOL"),
122
+ ("table_bytes", "INT64"),
123
+ ("clustering_key", "STRING"),
124
+ ):
125
+ self._add_column_if_missing("SchemaTable", col_name, col_type)
126
+
127
+ def _ensure_schema_table_classification_columns(self) -> None:
128
+ """Add classifier fields for older graph files."""
129
+ for col_name, col_type in (
130
+ ("role", "STRING"),
131
+ ("role_confidence", "DOUBLE"),
132
+ ("classification_signals", "STRING"),
133
+ ):
134
+ self._add_column_if_missing("SchemaTable", col_name, col_type)
135
+
136
+ def _ensure_schema_table_dbt_enrichment_columns(self) -> None:
137
+ """Add dbt semantic-layer fields for older graph files."""
138
+ for col_name, col_type in (
139
+ ("has_external_consumers", "BOOL"),
140
+ ("test_count", "INT64"),
141
+ ):
142
+ self._add_column_if_missing("SchemaTable", col_name, col_type)
143
+
144
+ def _ensure_schema_table_description_tags_columns(self) -> None:
145
+ """Add first-class ``description`` and ``tags`` for dbt / LLM context."""
146
+ for col_name, col_type in (
147
+ ("description", "STRING"),
148
+ ("tags", "STRING[]"),
149
+ ):
150
+ self._add_column_if_missing("SchemaTable", col_name, col_type)
151
+
152
+ def _ensure_schema_table_staleness_columns(self) -> None:
153
+ """Add dbt source-freshness fields (``staleness_status``/``staleness_as_of``)."""
154
+ for col_name, col_type in (
155
+ ("staleness_status", "STRING"),
156
+ ("staleness_as_of", "STRING"),
157
+ ):
158
+ self._add_column_if_missing("SchemaTable", col_name, col_type)
159
+
160
+ def _ensure_schema_table_row_count_source_column(self) -> None:
161
+ """Add ``row_count_source`` for older graph files (view-count provenance)."""
162
+ self._add_column_if_missing("SchemaTable", "row_count_source", "STRING")
163
+
164
+ def _ensure_schema_table_embedding_column(self) -> None:
165
+ """Add ``embedding FLOAT[384]`` to ``SchemaTable`` for older graph files."""
166
+ self._add_column_if_missing(
167
+ "SchemaTable",
168
+ "embedding",
169
+ f"FLOAT[{_SCHEMA_TABLE_EMBEDDING_DIM}]",
170
+ )
171
+
172
+ def _drop_schema_table_legacy_dbt_columns(self) -> None:
173
+ """Drop legacy duplicates ``dbt_description`` and ``tags_json``.
174
+
175
+ These were superseded by the first-class ``description`` / ``tags``
176
+ fields added in the dbt enrichment update. Older graph files that were
177
+ written before the consolidation may still have them; silently drop if
178
+ present.
179
+ """
180
+ for col_name in ("dbt_description", "tags_json"):
181
+ self._drop_column_if_present("SchemaTable", col_name)
182
+
183
+ def _ensure_schema_column_description_column(self) -> None:
184
+ """Add ``description`` on ``SchemaColumn`` for dbt column docs."""
185
+ self._add_column_if_missing("SchemaColumn", "description", "STRING")
186
+
187
+ def _ensure_schema_column_enrichment_columns(self) -> None:
188
+ """Add column comment / constraint fields for older Kuzu files."""
189
+ for col_name, col_type in (
190
+ ("comment", "STRING"),
191
+ ("default_value", "STRING"),
192
+ ("is_indexed", "BOOL"),
193
+ ("check_constraints_json", "STRING"),
194
+ ("ordinal_position", "INT64"),
195
+ ):
196
+ self._add_column_if_missing("SchemaColumn", col_name, col_type)
197
+
198
+ def _ensure_schema_column_stats_columns(self) -> None:
199
+ """Add planner statistics mirror fields for older Kuzu files."""
200
+ for col_name, col_type in (
201
+ ("most_common_values_json", "STRING"),
202
+ ("histogram_bounds_json", "STRING"),
203
+ ("stats_correlation", "DOUBLE"),
204
+ ):
205
+ self._add_column_if_missing("SchemaColumn", col_name, col_type)
206
+
207
+ def _ensure_schema_column_catalog_enrichment_columns(self) -> None:
208
+ """Add catalog cardinality and index-detail fields for older Kuzu files."""
209
+ for col_name, col_type in (
210
+ ("column_cardinality", "INT64"),
211
+ ("index_type", "STRING"),
212
+ ("index_is_unique", "BOOL"),
213
+ ):
214
+ self._add_column_if_missing("SchemaColumn", col_name, col_type)
215
+
216
+ def _ensure_schema_column_nested_columns(self) -> None:
217
+ """Add nested-column linkage fields for older Kuzu files."""
218
+ for col_name, col_type in (
219
+ ("parent_column_id", "STRING"),
220
+ ("is_array", "BOOL"),
221
+ ):
222
+ self._add_column_if_missing("SchemaColumn", col_name, col_type)
223
+
224
+ def _ensure_fk_constraint_name_column(self) -> None:
225
+ """Add ``constraint_name`` to ``FK_REFERENCES`` when upgrading older graph files."""
226
+ self._add_column_if_missing("FK_REFERENCES", "constraint_name", "STRING")
227
+
228
+ def _ensure_cluster_stale_column(self) -> None:
229
+ """Add ``stale`` to ``Cluster`` when upgrading older graph files."""
230
+ self._add_column_if_missing("Cluster", "stale", "BOOL")
231
+
232
+ def _ensure_cluster_schema_pattern_column(self) -> None:
233
+ """Add ``schema_pattern`` to ``Cluster`` when upgrading older graph files."""
234
+ self._add_column_if_missing("Cluster", "schema_pattern", "STRING")
235
+
236
+ def _ensure_join_path_stale_column(self) -> None:
237
+ """Add ``stale`` to ``JoinPath`` when upgrading older graph files."""
238
+ self._add_column_if_missing("JoinPath", "stale", "BOOL")
239
+
240
+ def _ensure_metric_template_stale_column(self) -> None:
241
+ """Add ``stale`` to ``MetricTemplate`` when upgrading older graph files."""
242
+ self._add_column_if_missing("MetricTemplate", "stale", "BOOL")
243
+
244
+ def _ensure_metric_template_dialect_column(self) -> None:
245
+ """Add ``dialect`` to ``MetricTemplate`` when upgrading older graph files."""
246
+ self._add_column_if_missing("MetricTemplate", "dialect", "STRING")