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
pretensor/core/ids.py ADDED
@@ -0,0 +1,101 @@
1
+ """Stable identifiers for graph nodes and explicit FK edges."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+
7
+ from pretensor.connectors.models import ForeignKey, ViewDependency
8
+
9
+ __all__ = [
10
+ "table_node_id",
11
+ "column_node_id",
12
+ "fk_edge_id",
13
+ "lineage_edge_id",
14
+ "dbt_lineage_edge_id",
15
+ "entity_node_id",
16
+ "metric_template_node_id",
17
+ "external_consumer_node_id",
18
+ "consumes_edge_id",
19
+ ]
20
+
21
+
22
+ def table_node_id(connection_name: str, schema_name: str, table_name: str) -> str:
23
+ """Stable node id for a physical table within one indexed connection."""
24
+ return f"{connection_name}::{schema_name}::{table_name}"
25
+
26
+
27
+ def column_node_id(
28
+ connection_name: str, schema_name: str, table_name: str, column_name: str
29
+ ) -> str:
30
+ """Stable node id for a physical column."""
31
+ return f"{connection_name}::{schema_name}::{table_name}::{column_name}"
32
+
33
+
34
+ def entity_node_id(connection_name: str, entity_name: str) -> str:
35
+ """Stable node id for a business entity within one indexed connection."""
36
+ return f"{connection_name}::entity::{entity_name}"
37
+
38
+
39
+ def metric_template_node_id(
40
+ connection_name: str, database: str, template_name: str
41
+ ) -> str:
42
+ """Stable node id for a metric template scoped to one logical database."""
43
+ return f"{connection_name}::{database}::metric::{template_name}"
44
+
45
+
46
+ def fk_edge_id(fk: ForeignKey) -> str:
47
+ """Stable edge id from explicit FK column pair (per source table)."""
48
+ return (
49
+ f"{fk.source_schema}.{fk.source_table}.{fk.source_column}"
50
+ f"->{fk.target_schema}.{fk.target_table}.{fk.target_column}"
51
+ )
52
+
53
+
54
+ def lineage_edge_id(connection_name: str, dep: ViewDependency) -> str:
55
+ """Stable id for a LINEAGE edge (dedupes re-index and multiple evidence paths)."""
56
+ raw = (
57
+ f"{connection_name}|{dep.source_schema}.{dep.source_table}"
58
+ f"->{dep.target_schema}.{dep.target_table}|{dep.lineage_type}"
59
+ )
60
+ digest = hashlib.sha256(raw.encode()).hexdigest()[:32]
61
+ return f"lineage::{digest}"
62
+
63
+
64
+ def dbt_lineage_edge_id(
65
+ connection_name: str, parent_dbt_id: str, child_dbt_id: str
66
+ ) -> str:
67
+ """Stable id for a dbt ``parent_map`` LINEAGE edge (idempotent re-runs)."""
68
+ raw = f"{connection_name}|dbt|{parent_dbt_id}->{child_dbt_id}|model_dependency"
69
+ digest = hashlib.sha256(raw.encode()).hexdigest()[:32]
70
+ return f"lineage::dbt::{digest}"
71
+
72
+
73
+ def external_consumer_node_id(
74
+ connection_name: str,
75
+ service_name: str,
76
+ file_path: str,
77
+ line_start: int,
78
+ sql_fingerprint: str,
79
+ ) -> str:
80
+ """Stable node id for one code location that issues a specific SQL statement.
81
+
82
+ Keyed by ``(connection, service, repo-relative file, line, normalized SQL
83
+ fingerprint)`` so the same statement in the same place dedupes across
84
+ re-scans, while the same statement at two call sites — or resolved against
85
+ two different connections — gets its own node. Line drift between scans
86
+ produces a fresh id; the stale-consumer sweep reclaims the old one.
87
+ """
88
+ raw = f"{connection_name}|{service_name}|{file_path}|{line_start}|{sql_fingerprint}"
89
+ digest = hashlib.sha256(raw.encode()).hexdigest()[:32]
90
+ return f"consumer::{digest}"
91
+
92
+
93
+ def consumes_edge_id(consumer_node_id: str, table_node_id: str, op: str) -> str:
94
+ """Stable id for a CONSUMES edge (idempotent by consumer, table, and op).
95
+
96
+ ``op`` is part of the key so a statement that both reads and writes the same
97
+ table yields two distinct edges rather than clobbering one.
98
+ """
99
+ raw = f"{consumer_node_id}->{table_node_id}|{op}"
100
+ digest = hashlib.sha256(raw.encode()).hexdigest()[:32]
101
+ return f"consumes::{digest}"
@@ -0,0 +1,276 @@
1
+ """Portable JSON export helpers for Kuzu graphs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date, datetime, timezone
6
+ from pathlib import Path
7
+ from typing import TypeAlias, TypedDict
8
+
9
+ from pretensor.core.store import KuzuStore
10
+
11
+ JsonPrimitive: TypeAlias = str | int | float | bool | None
12
+ JsonValue: TypeAlias = JsonPrimitive | list["JsonValue"] | dict[str, "JsonValue"]
13
+ EdgeRow: TypeAlias = dict[str, JsonValue]
14
+
15
+ _EDGE_ENDPOINT_COLUMNS = frozenset({"from", "to", "src", "dst"})
16
+
17
+
18
+ class NodeTypeExport(TypedDict):
19
+ """Portable representation of one node label."""
20
+
21
+ type: str
22
+ properties: list[str]
23
+ rows: list[dict[str, JsonValue]]
24
+
25
+
26
+ class EdgeTypeExport(TypedDict):
27
+ """Portable representation of one edge label."""
28
+
29
+ type: str
30
+ properties: list[str]
31
+ rows: list[EdgeRow]
32
+
33
+
34
+ class PortableGraphExport(TypedDict):
35
+ """Top-level export payload shape."""
36
+
37
+ connection_name: str
38
+ database: str
39
+ graph_path: str
40
+ exported_at: str
41
+ node_types: list[NodeTypeExport]
42
+ edge_types: list[EdgeTypeExport]
43
+ stats: dict[str, int]
44
+
45
+
46
+ class _NodeExportResult(TypedDict):
47
+ payload: NodeTypeExport
48
+ node_ids: set[str]
49
+
50
+
51
+ def export_graph_payload(
52
+ store: KuzuStore,
53
+ *,
54
+ connection_name: str,
55
+ database_name: str,
56
+ graph_path: Path,
57
+ ) -> PortableGraphExport:
58
+ """Materialize a portable JSON payload for all node and edge labels.
59
+
60
+ Args:
61
+ store: Open graph store.
62
+ connection_name: Logical registry connection name being exported.
63
+ database_name: Database key from the registry entry.
64
+ graph_path: Path to the Kuzu graph file.
65
+
66
+ Returns:
67
+ Portable export payload containing all label rows and properties.
68
+ """
69
+ rows = store.query_all_rows("CALL show_tables() RETURN name, type ORDER BY name")
70
+ node_types: list[NodeTypeExport] = []
71
+ edge_types: list[EdgeTypeExport] = []
72
+ included_node_ids: set[str] = set()
73
+ node_count = 0
74
+ edge_count = 0
75
+ node_table_names: list[str] = []
76
+ edge_table_names: list[str] = []
77
+ for table_name_raw, table_type_raw in rows:
78
+ table_name = str(table_name_raw)
79
+ table_type = str(table_type_raw).upper()
80
+ if "REL" in table_type:
81
+ edge_table_names.append(table_name)
82
+ else:
83
+ node_table_names.append(table_name)
84
+
85
+ for table_name in node_table_names:
86
+ columns = _table_columns(store, table_name)
87
+ node_result = _export_node_type(
88
+ store,
89
+ table_name=table_name,
90
+ columns=columns,
91
+ connection_name=connection_name,
92
+ database_name=database_name,
93
+ )
94
+ node_types.append(node_result["payload"])
95
+ included_node_ids.update(node_result["node_ids"])
96
+ node_count += len(node_result["payload"]["rows"])
97
+
98
+ for table_name in edge_table_names:
99
+ columns = _table_columns(store, table_name)
100
+ edge_payload = _export_edge_type(
101
+ store,
102
+ table_name=table_name,
103
+ columns=columns,
104
+ included_node_ids=included_node_ids,
105
+ )
106
+ edge_types.append(edge_payload)
107
+ edge_count += len(edge_payload["rows"])
108
+ return {
109
+ "connection_name": connection_name,
110
+ "database": database_name,
111
+ "graph_path": str(graph_path),
112
+ "exported_at": datetime.now(timezone.utc).isoformat(),
113
+ "node_types": node_types,
114
+ "edge_types": edge_types,
115
+ "stats": {
116
+ "node_types": len(node_types),
117
+ "edge_types": len(edge_types),
118
+ "node_count": node_count,
119
+ "edge_count": edge_count,
120
+ },
121
+ }
122
+
123
+
124
+ def _table_columns(store: KuzuStore, table_name: str) -> list[str]:
125
+ quoted = table_name.replace("'", "''")
126
+ rows = store.query_all_rows(f"CALL table_info('{quoted}') RETURN name, type")
127
+ return [str(row[0]) for row in rows if row and row[0] is not None]
128
+
129
+
130
+ def _export_node_type(
131
+ store: KuzuStore,
132
+ *,
133
+ table_name: str,
134
+ columns: list[str],
135
+ connection_name: str,
136
+ database_name: str,
137
+ ) -> _NodeExportResult:
138
+ projections = ", ".join(
139
+ f"n.{_quote_identifier(column)} AS {_quote_identifier(column)}"
140
+ for column in columns
141
+ )
142
+ order_clause = (
143
+ f" ORDER BY n.{_quote_identifier('node_id')}" if "node_id" in columns else ""
144
+ )
145
+ scope = _scope_predicate(columns, "n")
146
+ if scope is None:
147
+ where_clause = ""
148
+ query_params: dict[str, str] = {}
149
+ else:
150
+ scope_predicate, param_name = scope
151
+ where_clause = f" WHERE {scope_predicate}"
152
+ query_params = {
153
+ param_name: connection_name
154
+ if param_name == "connection_name"
155
+ else database_name
156
+ }
157
+ cypher = (
158
+ f"MATCH (n:{_quote_identifier(table_name)})"
159
+ f"{where_clause} "
160
+ f"RETURN {projections}{order_clause}"
161
+ )
162
+ rows = store.query_all_rows(cypher, query_params)
163
+ materialized_rows = [_materialize_row(columns, row) for row in rows]
164
+ node_ids = {
165
+ str(row_map["node_id"])
166
+ for row_map in materialized_rows
167
+ if "node_id" in row_map and row_map["node_id"] is not None
168
+ }
169
+ return {
170
+ "payload": {
171
+ "type": table_name,
172
+ "properties": columns,
173
+ "rows": materialized_rows,
174
+ },
175
+ "node_ids": node_ids,
176
+ }
177
+
178
+
179
+ def _export_edge_type(
180
+ store: KuzuStore,
181
+ *,
182
+ table_name: str,
183
+ columns: list[str],
184
+ included_node_ids: set[str],
185
+ ) -> EdgeTypeExport:
186
+ property_columns = [
187
+ column for column in columns if column.lower() not in _EDGE_ENDPOINT_COLUMNS
188
+ ]
189
+ projections = [
190
+ f"src.{_quote_identifier('node_id')} AS {_quote_identifier('__from_node_id')}",
191
+ f"dst.{_quote_identifier('node_id')} AS {_quote_identifier('__to_node_id')}",
192
+ ]
193
+ projections.extend(
194
+ f"r.{_quote_identifier(column)} AS {_quote_identifier(column)}"
195
+ for column in property_columns
196
+ )
197
+ field_names = ["__from_node_id", "__to_node_id", *property_columns]
198
+ cypher = (
199
+ f"MATCH (src)-[r:{_quote_identifier(table_name)}]->(dst) "
200
+ f"RETURN {', '.join(projections)} "
201
+ f"ORDER BY {_quote_identifier('__from_node_id')}, {_quote_identifier('__to_node_id')}"
202
+ )
203
+ rows = store.query_all_rows(cypher)
204
+ filtered_rows = [
205
+ row
206
+ for row in rows
207
+ if str(row[0]) in included_node_ids and str(row[1]) in included_node_ids
208
+ ]
209
+ return {
210
+ "type": table_name,
211
+ "properties": property_columns,
212
+ "rows": [_materialize_edge_row(field_names, row) for row in filtered_rows],
213
+ }
214
+
215
+
216
+ def _scope_predicate(columns: list[str], alias: str) -> tuple[str, str] | None:
217
+ """Return (predicate, query param name) for the first matching scope column.
218
+
219
+ Only one of ``connection_name``/``database_name`` is ever bound per query,
220
+ so the predicate text and the params dict built from it must stay in sync
221
+ (Kuzu prepared statements reject params the query text never references).
222
+ """
223
+ if "connection_name" in columns:
224
+ return (
225
+ f"{alias}.{_quote_identifier('connection_name')} = $connection_name",
226
+ "connection_name",
227
+ )
228
+ if "database" in columns:
229
+ return (
230
+ f"{alias}.{_quote_identifier('database')} = $database_name",
231
+ "database_name",
232
+ )
233
+ if "database_key" in columns:
234
+ return (
235
+ f"{alias}.{_quote_identifier('database_key')} = $database_name",
236
+ "database_name",
237
+ )
238
+ return None
239
+
240
+
241
+ def _materialize_row(
242
+ field_names: list[str], row: tuple[object, ...]
243
+ ) -> dict[str, JsonValue]:
244
+ return {
245
+ field_names[idx]: _to_json_value(value)
246
+ for idx, value in enumerate(row[: len(field_names)])
247
+ }
248
+
249
+
250
+ def _materialize_edge_row(field_names: list[str], row: tuple[object, ...]) -> EdgeRow:
251
+ row_map = _materialize_row(field_names, row)
252
+ return {
253
+ "__from_node_id": row_map["__from_node_id"],
254
+ "__to_node_id": row_map["__to_node_id"],
255
+ **{
256
+ key: value
257
+ for key, value in row_map.items()
258
+ if key not in {"__from_node_id", "__to_node_id"}
259
+ },
260
+ }
261
+
262
+
263
+ def _to_json_value(value: object) -> JsonValue:
264
+ if value is None or isinstance(value, (str, int, float, bool)):
265
+ return value
266
+ if isinstance(value, (datetime, date)):
267
+ return value.isoformat()
268
+ if isinstance(value, (list, tuple)):
269
+ return [_to_json_value(item) for item in value]
270
+ if isinstance(value, dict):
271
+ return {str(key): _to_json_value(item) for key, item in value.items()}
272
+ return str(value)
273
+
274
+
275
+ def _quote_identifier(identifier: str) -> str:
276
+ return "`" + identifier.replace("`", "``") + "`"
@@ -0,0 +1,67 @@
1
+ """Raw Cypher execution wrapper extracted from KuzuStore."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import kuzu
10
+
11
+ from pretensor.observability import log_timed_operation
12
+
13
+ # Use the same logger name as store.py so existing log pipelines see identical
14
+ # source fields regardless of which layer emits the record.
15
+ logger = logging.getLogger("pretensor.core.store")
16
+
17
+
18
+ class QueryRunner:
19
+ """Thin wrapper around a kuzu.Connection that handles all Cypher execution.
20
+
21
+ Owns the three execution methods that were previously on KuzuStore:
22
+ ``execute``, ``execute_write``, and ``query_all_rows``.
23
+ """
24
+
25
+ def __init__(self, conn: kuzu.Connection, path: Path) -> None:
26
+ self._conn = conn
27
+ # Stored so execute_write can include graph_path in the log record,
28
+ # matching the field emitted by the original KuzuStore.execute_write.
29
+ self._path = path
30
+
31
+ def execute_write(self, query: str, params: dict[str, Any] | None = None) -> None:
32
+ """Execute a write (mutation) Cypher query. Use this for DELETE / SET operations."""
33
+ with log_timed_operation(
34
+ logger,
35
+ event="graph.execute_write",
36
+ level=logging.DEBUG,
37
+ has_params=bool(params),
38
+ query_preview=query.strip().splitlines()[0][:120] if query.strip() else "",
39
+ graph_path=str(self._path),
40
+ ):
41
+ if params:
42
+ self._conn.execute(query, params)
43
+ else:
44
+ self._conn.execute(query)
45
+
46
+ def execute(
47
+ self, cypher: str, parameters: dict[str, Any] | None = None
48
+ ) -> kuzu.QueryResult | list[kuzu.QueryResult]:
49
+ """Run a read query (or arbitrary Cypher) with optional parameters."""
50
+ return self._conn.execute(cypher, parameters)
51
+
52
+ def query_all_rows(
53
+ self, cypher: str, parameters: dict[str, Any] | None = None
54
+ ) -> list[tuple[Any, ...]]:
55
+ """Execute Cypher and materialize all result rows as tuples.
56
+
57
+ Keeps callers (MCP, search index) off raw ``kuzu.QueryResult`` handles
58
+ while still routing reads through :class:`QueryRunner`.
59
+ """
60
+ result = self.execute(cypher, parameters)
61
+ if isinstance(result, list):
62
+ raise TypeError("Expected a single QueryResult from query_all_rows")
63
+ rows: list[tuple[Any, ...]] = []
64
+ while result.has_next():
65
+ nxt = result.get_next()
66
+ rows.append(tuple(nxt) if not isinstance(nxt, tuple) else nxt)
67
+ return rows
@@ -0,0 +1,209 @@
1
+ """JSON registry of indexed databases under ``.pretensor/``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Any, Literal
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field
11
+
12
+ from pretensor.core.dsn_crypto import DSNEncryptor
13
+ from pretensor.core.secure_io import atomic_write_text
14
+ from pretensor.errors import PretensorError
15
+
16
+ __all__ = [
17
+ "GraphRegistry",
18
+ "MultiDatabaseRegistry",
19
+ "RegistryEntry",
20
+ "DatabaseNotFoundError",
21
+ ]
22
+
23
+ _REGISTRY_VERSION = 1
24
+
25
+
26
+ class DatabaseNotFoundError(PretensorError, KeyError):
27
+ """Raised when a connection name is missing from the registry."""
28
+
29
+
30
+ class RegistryEntry(BaseModel):
31
+ """One indexed connection."""
32
+
33
+ model_config = ConfigDict(frozen=True)
34
+
35
+ connection_name: str
36
+ database: str
37
+ dsn: str = ""
38
+ dsn_encrypted: str | None = None
39
+ graph_path: str
40
+ unified_graph_path: str | None = None
41
+ last_indexed_at: datetime
42
+ # Persisted by index/add so reindex/detect_changes can parse stored DSNs.
43
+ dialect: Literal["postgres", "mysql", "snowflake", "bigquery"] = "postgres"
44
+ table_count: int | None = None
45
+ description: str | None = None
46
+ # Capability provenance for ``list_databases``: distinguishes "user didn't
47
+ # run this" ("not_attempted") from "ran but found nothing" ("empty").
48
+ dbt_manifest_path: str | None = None
49
+ llm_enrichment_ran: bool = False
50
+
51
+ def plaintext_dsn(self, encryptor: DSNEncryptor | None) -> str:
52
+ """Return the connection URL, decrypting when ``dsn_encrypted`` is set."""
53
+ if self.dsn_encrypted:
54
+ if encryptor is None:
55
+ raise RuntimeError(
56
+ "DSNEncryptor required for encrypted registry entries"
57
+ )
58
+ return encryptor.decrypt(self.dsn_encrypted)
59
+ return self.dsn
60
+
61
+
62
+ class _RegistryFile(BaseModel):
63
+ version: int = _REGISTRY_VERSION
64
+ entries: dict[str, RegistryEntry] = Field(default_factory=dict)
65
+
66
+
67
+ class GraphRegistry:
68
+ """Load and save ``registry.json`` tracking graph files per connection."""
69
+
70
+ def __init__(self, registry_path: Path) -> None:
71
+ self._path = registry_path
72
+ self._data = _RegistryFile()
73
+
74
+ @property
75
+ def path(self) -> Path:
76
+ return self._path
77
+
78
+ def load(self) -> GraphRegistry:
79
+ """Load from disk if the file exists; otherwise start empty."""
80
+ if not self._path.exists():
81
+ return self
82
+ raw = json.loads(self._path.read_text(encoding="utf-8"))
83
+ if isinstance(raw, dict) and "entries" not in raw and "version" not in raw:
84
+ raw = {"version": _REGISTRY_VERSION, "entries": raw}
85
+ self._data = _RegistryFile.model_validate(raw)
86
+ return self
87
+
88
+ def get(self, connection_name: str) -> RegistryEntry | None:
89
+ return self._data.entries.get(connection_name)
90
+
91
+ def require(self, connection_name: str) -> RegistryEntry:
92
+ """Return an entry or raise :class:`DatabaseNotFoundError`."""
93
+ entry = self.get(connection_name)
94
+ if entry is None:
95
+ raise DatabaseNotFoundError(
96
+ f"No registry entry for connection {connection_name!r}. "
97
+ "Use `pretensor list` or `pretensor add`."
98
+ )
99
+ return entry
100
+
101
+ def list_entries(self) -> list[RegistryEntry]:
102
+ """Return all entries sorted by connection name."""
103
+ return sorted(self._data.entries.values(), key=lambda e: e.connection_name)
104
+
105
+ def remove(self, connection_name: str) -> None:
106
+ """Drop a connection from the in-memory registry (call :meth:`save`)."""
107
+ entries = dict(self._data.entries)
108
+ entries.pop(connection_name, None)
109
+ self._data = _RegistryFile(version=self._data.version, entries=entries)
110
+
111
+ def upsert(
112
+ self,
113
+ *,
114
+ connection_name: str,
115
+ database: str,
116
+ dsn: str,
117
+ graph_path: Path,
118
+ indexed_at: datetime | None = None,
119
+ encrypt_dsn: bool = False,
120
+ encryptor: DSNEncryptor | None = None,
121
+ unified_graph_path: Path | None = None,
122
+ dialect: Literal["postgres", "mysql", "snowflake", "bigquery"] = "postgres",
123
+ table_count: int | None = None,
124
+ description: str | None = None,
125
+ dbt_manifest_path: str | None = None,
126
+ llm_enrichment_ran: bool = False,
127
+ ) -> None:
128
+ """Record or replace an entry for ``connection_name``."""
129
+ when = indexed_at or datetime.now(timezone.utc)
130
+ dsn_plain = ""
131
+ dsn_enc: str | None = None
132
+ if encrypt_dsn:
133
+ if encryptor is None:
134
+ raise ValueError("encryptor is required when encrypt_dsn is True")
135
+ dsn_enc = encryptor.encrypt(dsn)
136
+ else:
137
+ dsn_plain = dsn
138
+ uni = str(unified_graph_path.resolve()) if unified_graph_path else None
139
+ entry = RegistryEntry(
140
+ connection_name=connection_name,
141
+ database=database,
142
+ dsn=dsn_plain,
143
+ dsn_encrypted=dsn_enc,
144
+ graph_path=str(graph_path.resolve()),
145
+ unified_graph_path=uni,
146
+ last_indexed_at=when,
147
+ dialect=dialect,
148
+ table_count=table_count,
149
+ description=description,
150
+ dbt_manifest_path=dbt_manifest_path,
151
+ llm_enrichment_ran=llm_enrichment_ran,
152
+ )
153
+ entries = dict(self._data.entries)
154
+ entries[connection_name] = entry
155
+ self._data = _RegistryFile(version=_REGISTRY_VERSION, entries=entries)
156
+
157
+ def update_indexed_metadata(
158
+ self,
159
+ connection_name: str,
160
+ *,
161
+ table_count: int,
162
+ indexed_at: datetime | None = None,
163
+ ) -> None:
164
+ """Refresh ``table_count`` and optional timestamp after indexing."""
165
+ cur = self.require(connection_name)
166
+ when = indexed_at or datetime.now(timezone.utc)
167
+ entries = dict(self._data.entries)
168
+ entries[connection_name] = RegistryEntry(
169
+ connection_name=cur.connection_name,
170
+ database=cur.database,
171
+ dsn=cur.dsn,
172
+ dsn_encrypted=cur.dsn_encrypted,
173
+ graph_path=cur.graph_path,
174
+ unified_graph_path=cur.unified_graph_path,
175
+ last_indexed_at=when,
176
+ dialect=cur.dialect,
177
+ table_count=table_count,
178
+ description=cur.description,
179
+ dbt_manifest_path=cur.dbt_manifest_path,
180
+ llm_enrichment_ran=cur.llm_enrichment_ran,
181
+ )
182
+ self._data = _RegistryFile(version=self._data.version, entries=entries)
183
+
184
+ def unified_graph_file(self) -> Path | None:
185
+ """Return the shared Kuzu path when all entries agree, else ``None``."""
186
+ entries = self.list_entries()
187
+ if not entries:
188
+ return None
189
+ paths = {e.unified_graph_path or e.graph_path for e in entries}
190
+ if len(paths) != 1:
191
+ return None
192
+ return Path(next(iter(paths)))
193
+
194
+ def save(self) -> None:
195
+ """Write the registry atomically with owner-only (0o600) permissions.
196
+
197
+ The registry can hold a cleartext DSN for entries that predate
198
+ default-on encryption, so it is written 0o600 to keep credentials off
199
+ a shared host's other users.
200
+ """
201
+ self._path.parent.mkdir(parents=True, exist_ok=True)
202
+ payload: dict[str, Any] = self._data.model_dump(mode="json")
203
+ atomic_write_text(self._path, json.dumps(payload, indent=2), mode=0o600)
204
+
205
+
206
+ class MultiDatabaseRegistry(GraphRegistry):
207
+ """Alias for the multi-connection registry (Phase 3 unified graph)."""
208
+
209
+ pass