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,468 @@
1
+ """BigQuery introspection via ``google-cloud-bigquery`` (optional extra)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import logging
7
+ from datetime import datetime, timezone
8
+ from typing import Any
9
+
10
+ from pretensor.connectors.base import (
11
+ BaseConnector,
12
+ ColumnInfo,
13
+ ColumnStats,
14
+ ForeignKeyInfo,
15
+ TableInfo,
16
+ )
17
+ from pretensor.errors import ConnectorError as _ConnectorError
18
+ from pretensor.introspection.models.config import ConnectionConfig, SchemaFilter
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ _STALE_ACCESS_DAYS = 90
23
+ _JOBS_LOOKBACK_DAYS = 90
24
+ _NESTED_SCHEMA_MAX_DEPTH = 3
25
+
26
+ _BIGQUERY_EXTRA_HINT = (
27
+ "Install the BigQuery extra: pip install 'pretensor[bigquery]' "
28
+ "(or pip install google-cloud-bigquery)."
29
+ )
30
+
31
+
32
+ class BigQueryConnectorError(_ConnectorError):
33
+ """Raised when BigQuery connector operations fail."""
34
+
35
+
36
+ def _ensure_bigquery() -> Any:
37
+ try:
38
+ return importlib.import_module("google.cloud.bigquery")
39
+ except ImportError as exc:
40
+ msg = (
41
+ f"BigQuery connector requires google-cloud-bigquery. {_BIGQUERY_EXTRA_HINT}"
42
+ )
43
+ raise ImportError(msg) from exc
44
+
45
+
46
+ def _bq_table_type(raw: str | None) -> str:
47
+ if not raw:
48
+ return "table"
49
+ u = raw.upper()
50
+ if u == "BASE TABLE":
51
+ return "table"
52
+ if u == "VIEW":
53
+ return "view"
54
+ if u == "MATERIALIZED VIEW":
55
+ return "materialized_view"
56
+ if u == "EXTERNAL":
57
+ return "foreign_table"
58
+ return raw.lower().replace(" ", "_")
59
+
60
+
61
+ def _jobs_region_dataset(location: str | None) -> str:
62
+ """Region qualifier for project-level ``INFORMATION_SCHEMA.JOBS``.
63
+
64
+ Only the BigQuery multi-region identifiers (``US`` and ``EU``) have
65
+ dedicated ``region-us`` / ``region-eu`` INFORMATION_SCHEMA datasets.
66
+ All single-region locations (e.g. ``us-central1``, ``europe-west1``)
67
+ must use their own ``region-<location>`` qualifier.
68
+ """
69
+ if not location:
70
+ return "region-us"
71
+ loc = location.strip().upper()
72
+ if loc == "US":
73
+ return "region-us"
74
+ if loc == "EU":
75
+ return "region-eu"
76
+ return f"region-{location.strip().lower()}"
77
+
78
+
79
+ def _schema_field_to_columns(
80
+ field: Any,
81
+ *,
82
+ parent_path: str | None,
83
+ depth: int,
84
+ ordinal_counter: list[int],
85
+ ) -> list[ColumnInfo]:
86
+ """Flatten ``SchemaField`` tree into :class:`ColumnInfo` rows (depth-capped)."""
87
+ out: list[ColumnInfo] = []
88
+ name = field.name
89
+ full_name = f"{parent_path}.{name}" if parent_path else name
90
+ mode = (getattr(field, "mode", None) or "").upper()
91
+ is_repeated = mode == "REPEATED"
92
+ field_type = (getattr(field, "field_type", None) or "").upper()
93
+ is_record = field_type in ("RECORD", "STRUCT")
94
+
95
+ bq_type = str(getattr(field, "field_type", "") or "STRING")
96
+ if is_repeated and not is_record:
97
+ data_type = f"ARRAY<{bq_type}>"
98
+ elif is_record:
99
+ data_type = "RECORD"
100
+ else:
101
+ data_type = bq_type
102
+
103
+ nullable = mode != "REQUIRED" and not is_repeated
104
+ ordinal_counter[0] += 1
105
+ ord_pos = ordinal_counter[0]
106
+ out.append(
107
+ ColumnInfo(
108
+ name=full_name,
109
+ data_type=data_type,
110
+ nullable=nullable,
111
+ is_primary_key=False,
112
+ default_value=None,
113
+ comment=getattr(field, "description", None) or None,
114
+ is_indexed=False,
115
+ ordinal_position=ord_pos,
116
+ parent_column=parent_path,
117
+ is_array=is_repeated,
118
+ )
119
+ )
120
+ subfields = list(getattr(field, "fields", ()) or ())
121
+ if not subfields or depth >= _NESTED_SCHEMA_MAX_DEPTH:
122
+ return out
123
+ for sub in subfields:
124
+ out.extend(
125
+ _schema_field_to_columns(
126
+ sub,
127
+ parent_path=full_name,
128
+ depth=depth + 1,
129
+ ordinal_counter=ordinal_counter,
130
+ )
131
+ )
132
+ return out
133
+
134
+
135
+ class BigQueryConnector(BaseConnector):
136
+ """Introspect BigQuery via INFORMATION_SCHEMA and table metadata API."""
137
+
138
+ def __init__(self, config: ConnectionConfig) -> None:
139
+ super().__init__(config)
140
+ self._client: Any = None
141
+
142
+ @property
143
+ def project_id(self) -> str:
144
+ extra = self.config.metadata_extra or {}
145
+ pid = (extra.get("bq_project") or self.config.host or "").strip()
146
+ if not pid:
147
+ raise BigQueryConnectorError(
148
+ "BigQuery project id is missing from connection config"
149
+ )
150
+ return pid
151
+
152
+ @property
153
+ def dataset_id(self) -> str:
154
+ sf = self.config.schema_filter
155
+ if sf.include and len(sf.include) == 1:
156
+ return sf.include[0]
157
+ db = (self.config.database or "").strip()
158
+ if "/" in db:
159
+ return db.split("/", 1)[1]
160
+ raise BigQueryConnectorError(
161
+ "BigQuery dataset id is required (DSN path bigquery://project/dataset)"
162
+ )
163
+
164
+ @property
165
+ def location(self) -> str | None:
166
+ extra = self.config.metadata_extra or {}
167
+ loc = extra.get("bq_location")
168
+ return str(loc).strip() if loc else None
169
+
170
+ def _fq(self, suffix: str) -> str:
171
+ return f"`{self.project_id}.{self.dataset_id}.{suffix}`"
172
+
173
+ def _fq_jobs(self) -> str:
174
+ region_ds = _jobs_region_dataset(self.location)
175
+ return f"`{self.project_id}.{region_ds}.INFORMATION_SCHEMA.JOBS`"
176
+
177
+ def connect(self) -> None:
178
+ bigquery = _ensure_bigquery()
179
+ try:
180
+ client_kw: dict[str, Any] = {"project": self.project_id}
181
+ if self.location:
182
+ client_kw["location"] = self.location
183
+ self._client = bigquery.Client(**client_kw)
184
+ list(self._client.query("SELECT 1").result())
185
+ logger.info(
186
+ "Connected to BigQuery project %s dataset %s",
187
+ self.project_id,
188
+ self.dataset_id,
189
+ )
190
+ except Exception as exc:
191
+ raise BigQueryConnectorError(
192
+ f"Failed to connect to BigQuery: {exc}"
193
+ ) from exc
194
+
195
+ def disconnect(self) -> None:
196
+ self._client = None
197
+
198
+ @property
199
+ def client(self) -> Any:
200
+ if self._client is None:
201
+ raise BigQueryConnectorError("Not connected.")
202
+ return self._client
203
+
204
+ def _run_query(self, sql: str) -> list[dict[str, Any]]:
205
+ rows: list[dict[str, Any]] = []
206
+ job = self.client.query(sql)
207
+ for row in job.result(page_size=1000):
208
+ rows.append(dict(row.items()))
209
+ return rows
210
+
211
+ def _schema_ok(self, schema_filter: SchemaFilter, schema_name: str) -> bool:
212
+ if schema_filter.include and schema_name not in schema_filter.include:
213
+ return False
214
+ if schema_filter.exclude and schema_name in schema_filter.exclude:
215
+ return False
216
+ return True
217
+
218
+ def get_tables(self, schema_filter: SchemaFilter | None = None) -> list[TableInfo]:
219
+ sf = schema_filter or self.config.schema_filter
220
+ desc_sql = f"""
221
+ SELECT table_name, option_value AS description
222
+ FROM {self._fq("INFORMATION_SCHEMA.TABLE_OPTIONS")}
223
+ WHERE table_schema = '{self.dataset_id}'
224
+ AND option_name = 'description'
225
+ """
226
+ descriptions: dict[str, str] = {}
227
+ try:
228
+ for row in self._run_query(desc_sql):
229
+ tname = row.get("table_name")
230
+ desc = row.get("description")
231
+ if tname and desc:
232
+ descriptions[str(tname)] = str(desc)
233
+ except Exception as exc:
234
+ logger.warning("BigQuery TABLE_OPTIONS query skipped: %s", exc)
235
+
236
+ tables_sql = f"""
237
+ SELECT table_schema, table_name, table_type, creation_time
238
+ FROM {self._fq("INFORMATION_SCHEMA.TABLES")}
239
+ WHERE table_schema = '{self.dataset_id}'
240
+ ORDER BY table_name
241
+ """
242
+ storage_sql = f"""
243
+ SELECT table_name, total_rows
244
+ FROM {self._fq("INFORMATION_SCHEMA.TABLE_STORAGE")}
245
+ WHERE table_schema = '{self.dataset_id}'
246
+ """
247
+
248
+ storage: dict[str, int | None] = {}
249
+ try:
250
+ for row in self._run_query(storage_sql):
251
+ tn = str(row["table_name"])
252
+ tr = row.get("total_rows")
253
+ storage[tn] = int(tr) if tr is not None else None
254
+ except Exception as exc:
255
+ logger.warning("BigQuery TABLE_STORAGE row counts skipped: %s", exc)
256
+
257
+ out: list[TableInfo] = []
258
+ for row in self._run_query(tables_sql):
259
+ schema_name = str(row.get("table_schema") or self.dataset_id)
260
+ if not self._schema_ok(sf, schema_name):
261
+ continue
262
+ tname = str(row["table_name"])
263
+ out.append(
264
+ TableInfo(
265
+ name=tname,
266
+ schema_name=schema_name,
267
+ row_count=storage.get(tname),
268
+ comment=descriptions.get(tname),
269
+ table_type=_bq_table_type(
270
+ str(row["table_type"]) if row.get("table_type") else None
271
+ ),
272
+ )
273
+ )
274
+ return out
275
+
276
+ def get_columns(self, table_name: str, schema_name: str) -> list[ColumnInfo]:
277
+ _ = schema_name
278
+ ref = f"{self.project_id}.{self.dataset_id}.{table_name}"
279
+ try:
280
+ table = self.client.get_table(ref)
281
+ except Exception as exc:
282
+ raise BigQueryConnectorError(
283
+ f"Failed to load BigQuery table schema for {ref}: {exc}"
284
+ ) from exc
285
+ cols: list[ColumnInfo] = []
286
+ counter = [0]
287
+ for field in table.schema:
288
+ cols.extend(
289
+ _schema_field_to_columns(
290
+ field, parent_path=None, depth=1, ordinal_counter=counter
291
+ )
292
+ )
293
+ return cols
294
+
295
+ def get_foreign_keys(self) -> list[ForeignKeyInfo]:
296
+ return []
297
+
298
+ def get_table_row_count(self, table_name: str, schema_name: str) -> int:
299
+ tables = self.get_tables(
300
+ SchemaFilter(include=[schema_name]) if schema_name else None
301
+ )
302
+ for t in tables:
303
+ if t.name == table_name and t.schema_name == schema_name:
304
+ return int(t.row_count or 0)
305
+ return 0
306
+
307
+ def get_column_stats(
308
+ self, table_name: str, column_name: str, schema_name: str
309
+ ) -> ColumnStats:
310
+ _ = (table_name, column_name, schema_name)
311
+ return ColumnStats()
312
+
313
+ def execute_query(self, sql: str) -> list[dict[str, Any]]:
314
+ return self._run_query(sql)
315
+
316
+ def load_deep_catalog(
317
+ self, schema_filter: SchemaFilter
318
+ ) -> tuple[
319
+ dict[tuple[str, str], dict[str, Any]],
320
+ dict[tuple[str, str, str], dict[str, Any]],
321
+ ]:
322
+ """TABLE_STORAGE bytes, partition/cluster hints, and JOBS read aggregates."""
323
+ table_extra: dict[tuple[str, str], dict[str, Any]] = {}
324
+ column_extra: dict[tuple[str, str, str], dict[str, Any]] = {}
325
+
326
+ def _ok(sn: str) -> bool:
327
+ return self._schema_ok(schema_filter, sn)
328
+
329
+ storage_sql = f"""
330
+ SELECT table_name, active_logical_bytes
331
+ FROM {self._fq("INFORMATION_SCHEMA.TABLE_STORAGE")}
332
+ WHERE table_schema = '{self.dataset_id}'
333
+ """
334
+ try:
335
+ for row in self._run_query(storage_sql):
336
+ tn = str(row["table_name"])
337
+ key = (self.dataset_id, tn)
338
+ if not _ok(self.dataset_id):
339
+ continue
340
+ b = row.get("active_logical_bytes")
341
+ if b is not None:
342
+ table_extra.setdefault(key, {})["table_bytes"] = int(b)
343
+ except Exception as exc:
344
+ logger.warning("BigQuery TABLE_STORAGE bytes skipped: %s", exc)
345
+
346
+ cluster_sql = f"""
347
+ SELECT
348
+ table_name,
349
+ STRING_AGG(column_name ORDER BY clustering_ordinal_position) AS clustering_key
350
+ FROM {self._fq("INFORMATION_SCHEMA.COLUMNS")}
351
+ WHERE table_schema = '{self.dataset_id}'
352
+ AND clustering_ordinal_position IS NOT NULL
353
+ GROUP BY table_name
354
+ """
355
+ try:
356
+ for row in self._run_query(cluster_sql):
357
+ tn = row.get("table_name")
358
+ ck = row.get("clustering_key")
359
+ if tn and ck:
360
+ key = (self.dataset_id, str(tn))
361
+ if _ok(self.dataset_id):
362
+ table_extra.setdefault(key, {})["clustering_key"] = str(ck)
363
+ except Exception as exc:
364
+ logger.warning("BigQuery clustering metadata skipped: %s", exc)
365
+
366
+ part_sql = f"""
367
+ SELECT
368
+ table_name,
369
+ STRING_AGG(column_name ORDER BY ordinal_position) AS partition_key
370
+ FROM {self._fq("INFORMATION_SCHEMA.COLUMNS")}
371
+ WHERE table_schema = '{self.dataset_id}'
372
+ AND is_partitioning_column = 'YES'
373
+ GROUP BY table_name
374
+ """
375
+ try:
376
+ for row in self._run_query(part_sql):
377
+ tn = row.get("table_name")
378
+ pk = row.get("partition_key")
379
+ if tn and pk:
380
+ key = (self.dataset_id, str(tn))
381
+ if _ok(self.dataset_id):
382
+ table_extra.setdefault(key, {}).update(
383
+ {
384
+ "is_partitioned": True,
385
+ "partition_key": str(pk),
386
+ }
387
+ )
388
+ except Exception as exc:
389
+ logger.warning("BigQuery partition metadata skipped: %s", exc)
390
+
391
+ jobs_sql = f"""
392
+ SELECT
393
+ ref.project_id AS project_id,
394
+ ref.dataset_id AS dataset_id,
395
+ ref.table_id AS table_id,
396
+ COUNT(*) AS read_count,
397
+ MAX(j.creation_time) AS last_read
398
+ FROM {self._fq_jobs()} AS j,
399
+ UNNEST(j.referenced_tables) AS ref
400
+ WHERE j.job_type = 'QUERY'
401
+ AND j.creation_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL {_JOBS_LOOKBACK_DAYS} DAY)
402
+ AND (j.cache_hit IS NOT TRUE)
403
+ GROUP BY project_id, dataset_id, table_id
404
+ """
405
+ jobs_ok = False
406
+ try:
407
+ read_rows = self._run_query(jobs_sql)
408
+ jobs_ok = True
409
+ except Exception as exc:
410
+ logger.warning(
411
+ "BigQuery INFORMATION_SCHEMA.JOBS access skipped (permissions or region): %s",
412
+ exc,
413
+ )
414
+ read_rows = []
415
+
416
+ now = datetime.now(timezone.utc)
417
+ for row in read_rows:
418
+ pid = (row.get("project_id") or "").strip()
419
+ did = (row.get("dataset_id") or "").strip()
420
+ tid = (row.get("table_id") or "").strip()
421
+ if pid != self.project_id or did != self.dataset_id or not tid:
422
+ continue
423
+ if not _ok(self.dataset_id):
424
+ continue
425
+ key = (self.dataset_id, tid)
426
+ rc = int(row.get("read_count") or 0)
427
+ last_read = row.get("last_read")
428
+ days_since: int | None = None
429
+ if last_read is not None:
430
+ lr = last_read
431
+ if isinstance(lr, datetime):
432
+ if lr.tzinfo is None:
433
+ lr = lr.replace(tzinfo=timezone.utc)
434
+ days_since = max(0, int((now - lr.astimezone(timezone.utc)).days))
435
+ potentially_unused = rc == 0 and (
436
+ days_since is None or days_since > _STALE_ACCESS_DAYS
437
+ )
438
+ table_extra.setdefault(key, {}).update(
439
+ {
440
+ "access_read_count": rc,
441
+ "days_since_last_access": days_since,
442
+ "potentially_unused": potentially_unused,
443
+ }
444
+ )
445
+
446
+ if jobs_ok:
447
+ list_sql = f"""
448
+ SELECT table_name
449
+ FROM {self._fq("INFORMATION_SCHEMA.TABLES")}
450
+ WHERE table_schema = '{self.dataset_id}'
451
+ """
452
+ try:
453
+ table_names = [str(r["table_name"]) for r in self._run_query(list_sql)]
454
+ except Exception:
455
+ table_names = []
456
+ for tn in table_names:
457
+ if not _ok(self.dataset_id):
458
+ continue
459
+ key = (self.dataset_id, tn)
460
+ entry = table_extra.setdefault(key, {})
461
+ if "access_read_count" not in entry:
462
+ entry["access_read_count"] = 0
463
+ if "days_since_last_access" not in entry:
464
+ entry["days_since_last_access"] = None
465
+ if "potentially_unused" not in entry:
466
+ entry["potentially_unused"] = True
467
+
468
+ return table_extra, column_extra