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,908 @@
1
+ """Snowflake connector using SQLAlchemy (snowflake-sqlalchemy)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+ from datetime import datetime, timezone
8
+ from typing import Any
9
+
10
+ from sqlalchemy import create_engine, text
11
+ from sqlalchemy.engine import Engine
12
+ from sqlalchemy.exc import SQLAlchemyError
13
+
14
+ from pretensor.connectors.base import (
15
+ BaseConnector,
16
+ ColumnInfo,
17
+ ColumnStats,
18
+ ForeignKeyInfo,
19
+ TableGrant,
20
+ TableInfo,
21
+ )
22
+ from pretensor.connectors.lineage_sqlglot import dml_write_targets, table_refs_from_sql
23
+ from pretensor.connectors.models import ViewDependency
24
+ from pretensor.errors import ConnectorError as _ConnectorError
25
+ from pretensor.introspection.models.config import ConnectionConfig, SchemaFilter
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ _FQ_TABLE_QUOTED_RE = re.compile(
30
+ r'^"(?P<db>[^"]+)"\."(?P<schema>[^"]+)"\."(?P<table>[^"]+)"$'
31
+ )
32
+ _STALE_ACCESS_DAYS = 90
33
+
34
+
35
+ def _safe_int(value: Any, default: int = 0) -> int:
36
+ try:
37
+ return int(value)
38
+ except (TypeError, ValueError):
39
+ return default
40
+
41
+
42
+ def _parse_snowflake_table_fqn(fq: str, expected_db: str) -> tuple[str, str] | None:
43
+ """Return ``(schema, table)`` when FQN targets ``expected_db``."""
44
+ m = _FQ_TABLE_QUOTED_RE.match(fq.strip())
45
+ if m:
46
+ if m.group("db").upper() != expected_db.upper():
47
+ return None
48
+ return m.group("schema"), m.group("table")
49
+ parts = fq.split(".")
50
+ if len(parts) == 3:
51
+ db, sn, tn = (p.strip().strip('"') for p in parts)
52
+ if db.upper() != expected_db.upper():
53
+ return None
54
+ return sn, tn
55
+ return None
56
+
57
+
58
+ class SnowflakeConnectorError(_ConnectorError):
59
+ """Raised when Snowflake connector operations fail."""
60
+
61
+
62
+ def _sf_table_type(raw: str | None) -> str:
63
+ if not raw:
64
+ return "table"
65
+ u = raw.upper()
66
+ if u == "BASE TABLE":
67
+ return "table"
68
+ if u == "VIEW":
69
+ return "view"
70
+ if u == "MATERIALIZED VIEW":
71
+ return "materialized_view"
72
+ if u == "EXTERNAL TABLE":
73
+ return "foreign_table"
74
+ return raw.lower().replace(" ", "_")
75
+
76
+
77
+ def _quote_ident(name: str) -> str:
78
+ """Quote a Snowflake identifier with double-quote escaping."""
79
+ return f'"{name.replace(chr(34), chr(34) * 2)}"'
80
+
81
+
82
+ def _load_private_key_bytes(path: str, passphrase: str | None) -> bytes:
83
+ """Read a PEM private key file and return its DER-encoded bytes for Snowflake."""
84
+ from pathlib import Path as _Path
85
+
86
+ key_path = _Path(path).expanduser().resolve()
87
+ try:
88
+ key_data = key_path.read_bytes()
89
+ except FileNotFoundError:
90
+ raise SnowflakeConnectorError(
91
+ f"Private key file not found: {key_path}"
92
+ ) from None
93
+ except OSError as exc:
94
+ raise SnowflakeConnectorError(
95
+ f"Cannot read private key file {key_path}: {exc}"
96
+ ) from exc
97
+ try:
98
+ from cryptography.hazmat.primitives.serialization import (
99
+ Encoding,
100
+ NoEncryption,
101
+ PrivateFormat,
102
+ load_pem_private_key,
103
+ )
104
+ except ImportError as exc:
105
+ raise SnowflakeConnectorError(
106
+ "Private key authentication requires the `cryptography` package. "
107
+ "Install `pretensor[snowflake]` to get it."
108
+ ) from exc
109
+ try:
110
+ pkey = load_pem_private_key(
111
+ key_data,
112
+ password=passphrase.encode() if passphrase else None,
113
+ )
114
+ return pkey.private_bytes(Encoding.DER, PrivateFormat.PKCS8, NoEncryption())
115
+ except SnowflakeConnectorError:
116
+ raise
117
+ except Exception as exc:
118
+ raise SnowflakeConnectorError(
119
+ f"Invalid private key in {key_path}: {exc}"
120
+ ) from exc
121
+
122
+
123
+ class SnowflakeConnector(BaseConnector):
124
+ """Introspect Snowflake via INFORMATION_SCHEMA and SHOW commands."""
125
+
126
+ def __init__(self, config: ConnectionConfig) -> None:
127
+ super().__init__(config)
128
+ self._engine: Engine | None = None
129
+ self._pk_cache: dict[tuple[str, str], set[str]] | None = None
130
+
131
+ def _snowflake_url(self) -> str:
132
+ cfg = self.config
133
+ account = (cfg.host or "").strip()
134
+ if not account:
135
+ raise SnowflakeConnectorError("Snowflake account (DSN host) is required")
136
+
137
+ user = cfg.user or ""
138
+ password = cfg.password or ""
139
+ database = (cfg.database or "").strip()
140
+ if not database:
141
+ raise SnowflakeConnectorError(
142
+ "Snowflake database name is required in the DSN path"
143
+ )
144
+
145
+ extra = cfg.metadata_extra or {}
146
+ schema = (extra.get("snowflake_schema") or "").strip()
147
+ warehouse = (extra.get("warehouse") or "").strip()
148
+ role = (extra.get("role") or "").strip()
149
+
150
+ path = f"/{database}"
151
+ if schema:
152
+ path += f"/{schema}"
153
+
154
+ query_parts: list[str] = []
155
+ if warehouse:
156
+ from urllib.parse import quote
157
+
158
+ query_parts.append(f"warehouse={quote(warehouse, safe='')}")
159
+ if role:
160
+ from urllib.parse import quote
161
+
162
+ query_parts.append(f"role={quote(role, safe='')}")
163
+
164
+ q = ("?" + "&".join(query_parts)) if query_parts else ""
165
+ private_key_path = (extra.get("private_key_path") or "").strip()
166
+ if private_key_path:
167
+ # Key-pair auth: password is passed via connect_args, not in the URL.
168
+ return f"snowflake://{user}@{account}{path}{q}"
169
+ return f"snowflake://{user}:{password}@{account}{path}{q}"
170
+
171
+ def connect(self) -> None:
172
+ extra = self.config.metadata_extra or {}
173
+ private_key_path = (extra.get("private_key_path") or "").strip()
174
+ engine_kwargs: dict[str, Any] = {"pool_pre_ping": True}
175
+ if private_key_path:
176
+ pkb = _load_private_key_bytes(
177
+ private_key_path, extra.get("private_key_passphrase") or None
178
+ )
179
+ engine_kwargs["connect_args"] = {"private_key": pkb}
180
+ url = self._snowflake_url()
181
+ try:
182
+ self._engine = create_engine(url, **engine_kwargs)
183
+ with self._engine.connect() as conn:
184
+ conn.execute(text("SELECT 1"))
185
+ logger.info(
186
+ "Connected to Snowflake account %s db %s",
187
+ self.config.host,
188
+ self.config.database,
189
+ )
190
+ except SnowflakeConnectorError:
191
+ raise
192
+ except Exception as exc:
193
+ # The driver error may echo the DSN (incl. password); keep it in the
194
+ # server log only and raise an account/db-scoped message with no
195
+ # secret. (The Snowflake dialect uses a two-segment ``/db/schema``
196
+ # path that the generic ``URL.create`` cannot represent, so the URL
197
+ # is still assembled as a string above; SQLAlchemy masks the
198
+ # password in its own URL repr.)
199
+ logger.warning(
200
+ "Snowflake connection failed for account %s db %s: %s",
201
+ self.config.host,
202
+ self.config.database,
203
+ exc,
204
+ )
205
+ raise SnowflakeConnectorError(
206
+ f"Failed to connect to Snowflake account {self.config.host!r} "
207
+ f"database {self.config.database!r}"
208
+ ) from exc
209
+
210
+ def disconnect(self) -> None:
211
+ if self._engine is not None:
212
+ self._engine.dispose()
213
+ self._engine = None
214
+
215
+ @property
216
+ def engine(self) -> Engine:
217
+ if self._engine is None:
218
+ raise SnowflakeConnectorError("Not connected.")
219
+ return self._engine
220
+
221
+ def _qualified_information_schema(self) -> str:
222
+ db = (self.config.database or "").replace('"', '""')
223
+ return f'"{db}".INFORMATION_SCHEMA'
224
+
225
+ def get_tables(self, schema_filter: SchemaFilter | None = None) -> list[TableInfo]:
226
+ sf = schema_filter or self.config.schema_filter
227
+ ischema = self._qualified_information_schema()
228
+
229
+ query = text(f"""\
230
+ SELECT
231
+ table_schema,
232
+ table_name,
233
+ table_type,
234
+ comment,
235
+ row_count
236
+ FROM {ischema}.TABLES
237
+ WHERE table_schema NOT IN ('INFORMATION_SCHEMA')
238
+ ORDER BY table_schema, table_name
239
+ """)
240
+
241
+ with self.engine.connect() as conn:
242
+ rows = conn.execute(query).mappings().all()
243
+
244
+ out: list[TableInfo] = []
245
+ for row in rows:
246
+ schema_name = str(row["table_schema"])
247
+ if sf.include and schema_name not in sf.include:
248
+ continue
249
+ if sf.exclude and schema_name in sf.exclude:
250
+ continue
251
+ rc = row["row_count"]
252
+ out.append(
253
+ TableInfo(
254
+ name=str(row["table_name"]),
255
+ schema_name=schema_name,
256
+ row_count=int(rc) if rc is not None else None,
257
+ comment=str(row["comment"]) if row["comment"] else None,
258
+ table_type=_sf_table_type(
259
+ str(row["table_type"]) if row["table_type"] else None
260
+ ),
261
+ )
262
+ )
263
+ return out
264
+
265
+ def get_columns(self, table_name: str, schema_name: str) -> list[ColumnInfo]:
266
+ ischema = self._qualified_information_schema()
267
+ query = text(f"""\
268
+ SELECT
269
+ column_name,
270
+ ordinal_position,
271
+ data_type,
272
+ is_nullable,
273
+ column_default,
274
+ comment
275
+ FROM {ischema}.COLUMNS
276
+ WHERE table_schema = :schema_name
277
+ AND table_name = :table_name
278
+ ORDER BY ordinal_position
279
+ """)
280
+
281
+ pk_cols = self._primary_key_columns(schema_name, table_name)
282
+ checks_by_col = self._load_check_constraints_for_table(schema_name, table_name)
283
+
284
+ with self.engine.connect() as conn:
285
+ rows = (
286
+ conn.execute(
287
+ query,
288
+ {"schema_name": schema_name, "table_name": table_name},
289
+ )
290
+ .mappings()
291
+ .all()
292
+ )
293
+
294
+ columns: list[ColumnInfo] = []
295
+ for row in rows:
296
+ cname = str(row["column_name"])
297
+ ord_raw = row["ordinal_position"]
298
+ columns.append(
299
+ ColumnInfo(
300
+ name=cname,
301
+ data_type=str(row["data_type"] or ""),
302
+ nullable=str(row["is_nullable"] or "").upper() == "YES",
303
+ is_primary_key=cname in pk_cols,
304
+ default_value=str(row["column_default"])
305
+ if row["column_default"] is not None
306
+ else None,
307
+ comment=str(row["comment"]) if row["comment"] else None,
308
+ is_indexed=False,
309
+ check_constraints=list(checks_by_col.get(cname, [])),
310
+ ordinal_position=int(ord_raw) if ord_raw is not None else None,
311
+ )
312
+ )
313
+ return columns
314
+
315
+ def _load_primary_keys_for_schema(self, schema_name: str) -> None:
316
+ """Populate ``_pk_cache`` via ``SHOW PRIMARY KEYS IN SCHEMA``.
317
+
318
+ Uses ``exec_driver_sql`` because SHOW commands don't support bind
319
+ params. Result columns by index: 0=created_on, 1=database_name,
320
+ 2=schema_name, 3=table_name, 4=column_name, 5=key_sequence,
321
+ 6=constraint_name.
322
+ """
323
+ db = _quote_ident(self.config.database or "")
324
+ schema = _quote_ident(schema_name)
325
+ sql = f"SHOW PRIMARY KEYS IN SCHEMA {db}.{schema}"
326
+ cache: dict[tuple[str, str], set[str]] = {}
327
+ try:
328
+ with self.engine.connect() as conn:
329
+ rows = conn.exec_driver_sql(sql).all()
330
+ for row in rows:
331
+ sn, tn, col = str(row[2]), str(row[3]), str(row[4])
332
+ cache.setdefault((sn, tn), set()).add(col)
333
+ logger.debug(
334
+ "Loaded %d PK entries for %s.%s",
335
+ sum(len(v) for v in cache.values()),
336
+ self.config.database,
337
+ schema_name,
338
+ )
339
+ except Exception:
340
+ logger.debug(
341
+ "SHOW PRIMARY KEYS failed for %s.%s; PK info unavailable",
342
+ self.config.database,
343
+ schema_name,
344
+ )
345
+ self._pk_cache = cache
346
+
347
+ def _primary_key_columns(self, schema_name: str, table_name: str) -> set[str]:
348
+ if self._pk_cache is None:
349
+ self._load_primary_keys_for_schema(schema_name)
350
+ assert self._pk_cache is not None # populated by _load above
351
+ return self._pk_cache.get((schema_name, table_name), set())
352
+
353
+ def _load_check_constraints_for_table(
354
+ self, schema_name: str, table_name: str
355
+ ) -> dict[str, list[str]]:
356
+ # Snowflake doesn't enforce CHECK constraints and exposes no SHOW
357
+ # equivalent — return empty unconditionally.
358
+ return {}
359
+
360
+ def get_foreign_keys(self) -> list[ForeignKeyInfo]:
361
+ show_fks, show_succeeded = self._foreign_keys_from_show()
362
+ if show_fks:
363
+ return show_fks
364
+ if show_succeeded:
365
+ logger.info(
366
+ "SHOW IMPORTED KEYS returned no rows for %s; trying INFORMATION_SCHEMA fallback",
367
+ self.config.database,
368
+ )
369
+ else:
370
+ logger.info(
371
+ "SHOW IMPORTED KEYS failed for %s; trying INFORMATION_SCHEMA fallback",
372
+ self.config.database,
373
+ )
374
+ return self._foreign_keys_from_information_schema()
375
+
376
+ def _foreign_keys_from_show(self) -> tuple[list[ForeignKeyInfo], bool]:
377
+ """Fetch FKs via ``SHOW IMPORTED KEYS IN DATABASE``.
378
+
379
+ Result columns by index: 0=created_on, 1=pk_database_name,
380
+ 2=pk_schema_name, 3=pk_table_name, 4=pk_column_name,
381
+ 5=fk_database_name, 6=fk_schema_name, 7=fk_table_name,
382
+ 8=fk_column_name, 9=key_sequence, 10=update_rule,
383
+ 11=delete_rule, 12=fk_name, …
384
+
385
+ Returns:
386
+ Tuple of ``(foreign_keys, show_succeeded)`` where
387
+ ``show_succeeded`` is False only if SHOW itself errored.
388
+ """
389
+ db = _quote_ident(self.config.database or "")
390
+ sql = f"SHOW IMPORTED KEYS IN DATABASE {db}"
391
+ try:
392
+ with self.engine.connect() as conn:
393
+ rows = conn.exec_driver_sql(sql).all()
394
+ except Exception:
395
+ logger.debug(
396
+ "SHOW IMPORTED KEYS failed for %s",
397
+ self.config.database,
398
+ )
399
+ return [], False
400
+ if not rows:
401
+ return [], True
402
+
403
+ # Sort by fk_schema(6), fk_table(7), fk_name(12), key_sequence(9)
404
+ # so composite FK columns are ordered correctly for downstream grouping.
405
+ sorted_rows = sorted(
406
+ rows,
407
+ key=lambda r: (str(r[6]), str(r[7]), str(r[12]), _safe_int(r[9])),
408
+ )
409
+ fks = [
410
+ ForeignKeyInfo(
411
+ constraint_name=str(row[12]),
412
+ source_schema=str(row[6]),
413
+ source_table=str(row[7]),
414
+ source_column=str(row[8]),
415
+ target_schema=str(row[2]),
416
+ target_table=str(row[3]),
417
+ target_column=str(row[4]),
418
+ )
419
+ for row in sorted_rows
420
+ ]
421
+ return fks, True
422
+
423
+ def _foreign_keys_from_information_schema(self) -> list[ForeignKeyInfo]:
424
+ ischema = self._qualified_information_schema()
425
+ query = text(f"""\
426
+ SELECT
427
+ tc.constraint_name,
428
+ kcu_src.table_schema AS source_schema,
429
+ kcu_src.table_name AS source_table,
430
+ kcu_src.column_name AS source_column,
431
+ kcu_ref.table_schema AS target_schema,
432
+ kcu_ref.table_name AS target_table,
433
+ kcu_ref.column_name AS target_column,
434
+ kcu_src.ordinal_position AS source_ordinal_position
435
+ FROM {ischema}.TABLE_CONSTRAINTS tc
436
+ JOIN {ischema}.KEY_COLUMN_USAGE kcu_src
437
+ ON tc.constraint_name = kcu_src.constraint_name
438
+ AND tc.constraint_schema = kcu_src.constraint_schema
439
+ AND tc.table_schema = kcu_src.table_schema
440
+ AND tc.table_name = kcu_src.table_name
441
+ JOIN {ischema}.REFERENTIAL_CONSTRAINTS rc
442
+ ON tc.constraint_name = rc.constraint_name
443
+ AND tc.constraint_schema = rc.constraint_schema
444
+ JOIN {ischema}.KEY_COLUMN_USAGE kcu_ref
445
+ ON rc.unique_constraint_name = kcu_ref.constraint_name
446
+ AND rc.unique_constraint_schema = kcu_ref.constraint_schema
447
+ AND kcu_src.position_in_unique_constraint = kcu_ref.ordinal_position
448
+ WHERE tc.constraint_type = 'FOREIGN KEY'
449
+ """)
450
+ try:
451
+ with self.engine.connect() as conn:
452
+ rows = conn.execute(query).mappings().all()
453
+ except SQLAlchemyError:
454
+ logger.debug(
455
+ "INFORMATION_SCHEMA FK fallback failed for %s",
456
+ self.config.database,
457
+ )
458
+ return []
459
+ sorted_rows = sorted(
460
+ rows,
461
+ key=lambda row: (
462
+ str(row["source_schema"]),
463
+ str(row["source_table"]),
464
+ str(row["constraint_name"]),
465
+ _safe_int(row["source_ordinal_position"]),
466
+ ),
467
+ )
468
+ return [
469
+ ForeignKeyInfo(
470
+ constraint_name=str(row["constraint_name"]),
471
+ source_schema=str(row["source_schema"]),
472
+ source_table=str(row["source_table"]),
473
+ source_column=str(row["source_column"]),
474
+ target_schema=str(row["target_schema"]),
475
+ target_table=str(row["target_table"]),
476
+ target_column=str(row["target_column"]),
477
+ )
478
+ for row in sorted_rows
479
+ ]
480
+
481
+ def get_table_row_count(self, table_name: str, schema_name: str) -> int:
482
+ ischema = self._qualified_information_schema()
483
+ q = text(f"""\
484
+ SELECT row_count
485
+ FROM {ischema}.TABLES
486
+ WHERE table_schema = :sn AND table_name = :tn
487
+ """)
488
+ with self.engine.connect() as conn:
489
+ row = conn.execute(q, {"sn": schema_name, "tn": table_name}).first()
490
+ if row is None or row[0] is None:
491
+ return 0
492
+ return int(row[0])
493
+
494
+ def get_column_stats(
495
+ self, table_name: str, column_name: str, schema_name: str
496
+ ) -> ColumnStats:
497
+ return ColumnStats()
498
+
499
+ def execute_query(self, sql: str) -> list[dict[str, Any]]:
500
+ with self.engine.connect() as conn:
501
+ rows = conn.execute(text(sql)).mappings().all()
502
+ return [dict(row) for row in rows]
503
+
504
+ def get_table_grants(
505
+ self, schema_filter: SchemaFilter | None = None
506
+ ) -> list[TableGrant]:
507
+ """Return direct SELECT grants on tables from OBJECT_PRIVILEGES.
508
+
509
+ Uses ``GRANTEE``, ``OBJECT_SCHEMA``, and ``OBJECT_NAME`` (aliased in SQL).
510
+ Recursive expansion of nested roles (``SHOW GRANTS TO ROLE``) is not
511
+ implemented; only privileges visible in INFORMATION_SCHEMA are returned.
512
+ """
513
+ sf = schema_filter or self.config.schema_filter
514
+ ischema = self._qualified_information_schema()
515
+ query = text(f"""\
516
+ SELECT
517
+ GRANTEE AS grantee_name,
518
+ OBJECT_SCHEMA AS table_schema,
519
+ OBJECT_NAME AS table_name
520
+ FROM {ischema}.OBJECT_PRIVILEGES
521
+ WHERE PRIVILEGE_TYPE = 'SELECT'
522
+ AND OBJECT_TYPE = 'TABLE'
523
+ """)
524
+ try:
525
+ with self.engine.connect() as conn:
526
+ rows = conn.execute(query).mappings().all()
527
+ except SQLAlchemyError as exc:
528
+ logger.warning(
529
+ "Could not read SELECT grants from information_schema.object_privileges: %s",
530
+ exc,
531
+ )
532
+ return []
533
+
534
+ seen: set[tuple[str, str, str]] = set()
535
+ out: list[TableGrant] = []
536
+ for row in rows:
537
+ ge = row["grantee_name"]
538
+ ts = row["table_schema"]
539
+ tn = row["table_name"]
540
+ if ge is None or ts is None or tn is None:
541
+ continue
542
+ grantee = str(ge)
543
+ schema_name = str(ts)
544
+ table_name = str(tn)
545
+ if not self._schema_ok(schema_name, sf):
546
+ continue
547
+ key = (grantee, schema_name, table_name)
548
+ if key in seen:
549
+ continue
550
+ seen.add(key)
551
+ out.append(
552
+ TableGrant(
553
+ grantee=grantee,
554
+ schema_name=schema_name,
555
+ table_name=table_name,
556
+ )
557
+ )
558
+ out.sort(key=lambda tg: (tg.schema_name, tg.table_name, tg.grantee))
559
+ return out
560
+
561
+ def _schema_ok(self, sn: str, schema_filter: SchemaFilter) -> bool:
562
+ if schema_filter.include and sn not in schema_filter.include:
563
+ return False
564
+ if schema_filter.exclude and sn in schema_filter.exclude:
565
+ return False
566
+ return sn.upper() != "INFORMATION_SCHEMA"
567
+
568
+ def _sf_lineage_type(self, domain: str | None) -> str | None:
569
+ if not domain:
570
+ return None
571
+ u = str(domain).strip().upper().replace(" ", "_")
572
+ if u == "MATERIALIZED_VIEW" or u == "MATERIALIZEDVIEW":
573
+ return "MATERIALIZED_VIEW"
574
+ if u in ("VIEW", "TRIGGER", "TASK", "STREAM"):
575
+ return u
576
+ return None
577
+
578
+ def load_view_dependencies(
579
+ self, schema_filter: SchemaFilter
580
+ ) -> list[ViewDependency]:
581
+ """Lineage from OBJECT_DEPENDENCIES, view SQL, SHOW STREAMS, and SHOW TASKS."""
582
+ deps: list[ViewDependency] = []
583
+ obj_dep_keys: set[tuple[str, str, str, str]] = set()
584
+ deps.extend(self._snowflake_object_dependencies(schema_filter, obj_dep_keys))
585
+ deps.extend(self._snowflake_view_sql_fallback(schema_filter, obj_dep_keys))
586
+ deps.extend(self._snowflake_streams_lineage(schema_filter))
587
+ deps.extend(self._snowflake_tasks_lineage(schema_filter))
588
+ return deps
589
+
590
+ def _snowflake_object_dependencies(
591
+ self,
592
+ schema_filter: SchemaFilter,
593
+ obj_dep_keys: set[tuple[str, str, str, str]],
594
+ ) -> list[ViewDependency]:
595
+ out: list[ViewDependency] = []
596
+ ischema = self._qualified_information_schema()
597
+ q = text(f"""\
598
+ SELECT
599
+ referencing_object_schema,
600
+ referencing_object_name,
601
+ referencing_object_domain,
602
+ referenced_object_schema,
603
+ referenced_object_name
604
+ FROM {ischema}.OBJECT_DEPENDENCIES
605
+ WHERE referenced_object_domain = 'TABLE'
606
+ """)
607
+ try:
608
+ with self.engine.connect() as conn:
609
+ rows = conn.execute(q).mappings().all()
610
+ except Exception as exc:
611
+ logger.debug("Snowflake OBJECT_DEPENDENCIES unavailable: %s", exc)
612
+ return out
613
+ for row in rows:
614
+ ref_schema = str(row["referenced_object_schema"] or "")
615
+ ref_name = str(row["referenced_object_name"] or "")
616
+ tgt_schema = str(row["referencing_object_schema"] or "")
617
+ tgt_name = str(row["referencing_object_name"] or "")
618
+ dom = row["referencing_object_domain"]
619
+ ltype = self._sf_lineage_type(str(dom) if dom is not None else None)
620
+ if ltype is None:
621
+ continue
622
+ if not self._schema_ok(ref_schema, schema_filter):
623
+ continue
624
+ if not self._schema_ok(tgt_schema, schema_filter):
625
+ continue
626
+ obj = f"{tgt_schema}.{tgt_name}"
627
+ out.append(
628
+ ViewDependency(
629
+ source_schema=ref_schema,
630
+ source_table=ref_name,
631
+ target_schema=tgt_schema,
632
+ target_table=tgt_name,
633
+ lineage_type=ltype,
634
+ object_name=obj,
635
+ confidence=1.0,
636
+ )
637
+ )
638
+ obj_dep_keys.add((tgt_schema, tgt_name, ref_schema, ref_name))
639
+ return out
640
+
641
+ def _snowflake_view_sql_fallback(
642
+ self,
643
+ schema_filter: SchemaFilter,
644
+ obj_dep_keys: set[tuple[str, str, str, str]],
645
+ ) -> list[ViewDependency]:
646
+ out: list[ViewDependency] = []
647
+ ischema = self._qualified_information_schema()
648
+ q = text(f"""\
649
+ SELECT table_schema, table_name, view_definition
650
+ FROM {ischema}.VIEWS
651
+ WHERE table_schema NOT IN ('INFORMATION_SCHEMA')
652
+ """)
653
+ try:
654
+ with self.engine.connect() as conn:
655
+ rows = conn.execute(q).mappings().all()
656
+ except Exception as exc:
657
+ logger.debug("Snowflake VIEWS lineage fallback skipped: %s", exc)
658
+ return out
659
+ for row in rows:
660
+ ts = str(row["table_schema"] or "")
661
+ if not self._schema_ok(ts, schema_filter):
662
+ continue
663
+ tname = str(row["table_name"] or "")
664
+ vd = row["view_definition"]
665
+ if vd is None:
666
+ continue
667
+ sql_text = str(vd)
668
+ for src_schema, src_table in table_refs_from_sql(
669
+ sql_text, dialect="snowflake", default_schema=ts
670
+ ):
671
+ if not self._schema_ok(src_schema, schema_filter):
672
+ continue
673
+ if src_schema == ts and src_table == tname:
674
+ continue
675
+ key = (ts, tname, src_schema, src_table)
676
+ if key in obj_dep_keys:
677
+ continue
678
+ out.append(
679
+ ViewDependency(
680
+ source_schema=src_schema,
681
+ source_table=src_table,
682
+ target_schema=ts,
683
+ target_table=tname,
684
+ lineage_type="VIEW",
685
+ object_name=f"{ts}.{tname}",
686
+ confidence=0.85,
687
+ )
688
+ )
689
+ return out
690
+
691
+ def _snowflake_streams_lineage(
692
+ self, schema_filter: SchemaFilter
693
+ ) -> list[ViewDependency]:
694
+ out: list[ViewDependency] = []
695
+ db = (self.config.database or "").replace('"', '""')
696
+ if not db:
697
+ return out
698
+ q = text(f'SHOW STREAMS IN DATABASE "{db}"')
699
+ try:
700
+ with self.engine.connect() as conn:
701
+ rows = conn.execute(q).mappings().all()
702
+ except Exception as exc:
703
+ logger.debug("Snowflake SHOW STREAMS unavailable: %s", exc)
704
+ return out
705
+ for row in rows:
706
+ m = dict(row)
707
+ name = m.get("name") or m.get("NAME")
708
+ schema_key = m.get("schema_name") or m.get("SCHEMA_NAME")
709
+ table_key = m.get("table_name") or m.get("TABLE_NAME")
710
+ if name is None or schema_key is None or table_key is None:
711
+ continue
712
+ tgt_schema = str(schema_key)
713
+ tgt_name = str(name)
714
+ src_table = str(table_key)
715
+ src_schema = str(
716
+ m.get("source_schema") or m.get("SOURCE_SCHEMA") or tgt_schema
717
+ )
718
+ if not self._schema_ok(tgt_schema, schema_filter):
719
+ continue
720
+ if not self._schema_ok(src_schema, schema_filter):
721
+ continue
722
+ out.append(
723
+ ViewDependency(
724
+ source_schema=src_schema,
725
+ source_table=src_table,
726
+ target_schema=tgt_schema,
727
+ target_table=tgt_name,
728
+ lineage_type="STREAM",
729
+ object_name=f"{tgt_schema}.{tgt_name}",
730
+ confidence=1.0,
731
+ )
732
+ )
733
+ return out
734
+
735
+ def _snowflake_tasks_lineage(
736
+ self, schema_filter: SchemaFilter
737
+ ) -> list[ViewDependency]:
738
+ out: list[ViewDependency] = []
739
+ db = (self.config.database or "").replace('"', '""')
740
+ if not db:
741
+ return out
742
+ q = text(f'SHOW TASKS IN DATABASE "{db}"')
743
+ try:
744
+ with self.engine.connect() as conn:
745
+ rows = conn.execute(q).mappings().all()
746
+ except Exception as exc:
747
+ logger.debug("Snowflake SHOW TASKS unavailable: %s", exc)
748
+ return out
749
+ for row in rows:
750
+ m = dict(row)
751
+ name = m.get("name") or m.get("NAME")
752
+ schema_key = m.get("schema_name") or m.get("SCHEMA_NAME")
753
+ definition = m.get("definition") or m.get("DEFINITION")
754
+ if name is None or schema_key is None or definition is None:
755
+ continue
756
+ ts = str(schema_key)
757
+ if not self._schema_ok(ts, schema_filter):
758
+ continue
759
+ tname = str(name)
760
+ sql_text = str(definition)
761
+ reads = table_refs_from_sql(
762
+ sql_text, dialect="snowflake", default_schema=ts
763
+ )
764
+ writes = dml_write_targets(sql_text, dialect="snowflake", default_schema=ts)
765
+ if not writes:
766
+ continue
767
+ for ws, wt in writes:
768
+ if not self._schema_ok(ws, schema_filter):
769
+ continue
770
+ for rs, rt in reads:
771
+ if not self._schema_ok(rs, schema_filter):
772
+ continue
773
+ if rs == ws and rt == wt:
774
+ continue
775
+ out.append(
776
+ ViewDependency(
777
+ source_schema=rs,
778
+ source_table=rt,
779
+ target_schema=ws,
780
+ target_table=wt,
781
+ lineage_type="TASK",
782
+ object_name=f"{ts}.{tname}",
783
+ confidence=0.85,
784
+ )
785
+ )
786
+ return out
787
+
788
+ def load_deep_catalog(
789
+ self, schema_filter: SchemaFilter
790
+ ) -> tuple[
791
+ dict[tuple[str, str], dict[str, Any]],
792
+ dict[tuple[str, str, str], dict[str, Any]],
793
+ ]:
794
+ """TABLES extras and optional ACCOUNT_USAGE access aggregates (best-effort)."""
795
+ table_extra: dict[tuple[str, str], dict[str, Any]] = {}
796
+ column_extra: dict[tuple[str, str, str], dict[str, Any]] = {}
797
+
798
+ def _schema_ok(sn: str) -> bool:
799
+ if schema_filter.include and sn not in schema_filter.include:
800
+ return False
801
+ if schema_filter.exclude and sn in schema_filter.exclude:
802
+ return False
803
+ return True
804
+
805
+ ischema = self._qualified_information_schema()
806
+ db_name = (self.config.database or "").replace('"', '""')
807
+
808
+ ext_sql = text(f"""\
809
+ SELECT table_schema, table_name, bytes, clustering_key
810
+ FROM {ischema}.TABLES
811
+ WHERE table_schema NOT IN ('INFORMATION_SCHEMA')
812
+ """)
813
+ try:
814
+ with self.engine.connect() as conn:
815
+ for row in conn.execute(ext_sql).mappings().all():
816
+ sn = str(row["table_schema"])
817
+ tn = str(row["table_name"])
818
+ if not _schema_ok(sn):
819
+ continue
820
+ key = (sn, tn)
821
+ entry: dict[str, Any] = {}
822
+ b = row.get("bytes")
823
+ if b is not None:
824
+ entry["table_bytes"] = int(b)
825
+ ck = row.get("clustering_key")
826
+ if ck:
827
+ entry["clustering_key"] = str(ck)
828
+ if entry:
829
+ table_extra.setdefault(key, {}).update(entry)
830
+ except Exception as exc:
831
+ logger.debug("Snowflake TABLES extended fields skipped: %s", exc)
832
+
833
+ read_sql = text("""\
834
+ SELECT
835
+ f.value:objectName::string AS fq_name,
836
+ COUNT(*) AS read_count,
837
+ MAX(q.query_start_time) AS last_read
838
+ FROM snowflake.account_usage.access_history q,
839
+ LATERAL FLATTEN(input => q.base_objects_accessed) f
840
+ WHERE q.query_start_time >= DATEADD(day, -365, CURRENT_TIMESTAMP())
841
+ AND f.value:objectDomain::string = 'Table'
842
+ GROUP BY 1
843
+ """)
844
+ write_sql = text("""\
845
+ SELECT
846
+ f.value:objectName::string AS fq_name,
847
+ COUNT(*) AS write_count
848
+ FROM snowflake.account_usage.access_history q,
849
+ LATERAL FLATTEN(input => q.objects_modified) f
850
+ WHERE q.query_start_time >= DATEADD(day, -365, CURRENT_TIMESTAMP())
851
+ AND f.value:objectDomain::string IN ('Table', 'Materialized View')
852
+ GROUP BY 1
853
+ """)
854
+
855
+ read_map: dict[str, tuple[int, datetime | None]] = {}
856
+ write_map: dict[str, int] = {}
857
+ try:
858
+ with self.engine.connect() as conn:
859
+ for row in conn.execute(read_sql).mappings().all():
860
+ fq = row["fq_name"]
861
+ if fq is None:
862
+ continue
863
+ read_map[str(fq)] = (
864
+ int(row["read_count"] or 0),
865
+ row["last_read"],
866
+ )
867
+ for row in conn.execute(write_sql).mappings().all():
868
+ fq = row["fq_name"]
869
+ if fq is None:
870
+ continue
871
+ write_map[str(fq)] = int(row["write_count"] or 0)
872
+ except Exception as exc:
873
+ logger.debug("Snowflake ACCOUNT_USAGE access history skipped: %s", exc)
874
+
875
+ now = datetime.now(timezone.utc)
876
+ all_fq = set(read_map) | set(write_map)
877
+ for fq in all_fq:
878
+ parsed = _parse_snowflake_table_fqn(fq, db_name)
879
+ if parsed is None:
880
+ continue
881
+ sn, tn = parsed
882
+ if not _schema_ok(sn):
883
+ continue
884
+ rc, last_read = read_map.get(fq, (0, None))
885
+ wc = write_map.get(fq, 0)
886
+ key = (sn, tn)
887
+ days_since: int | None = None
888
+ if last_read is not None:
889
+ lr = last_read
890
+ if lr.tzinfo is None:
891
+ lr = lr.replace(tzinfo=timezone.utc)
892
+ days_since = max(0, int((now - lr.astimezone(timezone.utc)).days))
893
+ # A table absent from read_map was never accessed in the 365-day window,
894
+ # which is a stronger unused signal than one accessed >90 days ago.
895
+ fq_in_read_map = fq in read_map
896
+ potentially_unused = not fq_in_read_map or (
897
+ days_since is not None and days_since >= _STALE_ACCESS_DAYS
898
+ )
899
+ table_extra.setdefault(key, {}).update(
900
+ {
901
+ "access_read_count": rc,
902
+ "access_write_count": wc,
903
+ "days_since_last_access": days_since,
904
+ "potentially_unused": potentially_unused,
905
+ }
906
+ )
907
+
908
+ return table_extra, column_extra