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,550 @@
1
+ """Parse database connection strings into :class:`ConnectionConfig`.
2
+
3
+ Supports PostgreSQL (``postgres://``, ``postgresql://``, ``postgresql+driver://``),
4
+ Snowflake (``snowflake://``), and BigQuery (``bigquery://project/dataset``).
5
+ Optional ``--dialect`` overrides scheme detection.
6
+
7
+ Also provides :func:`connection_config_from_source` and :func:`dsn_from_source`
8
+ for building configs from declarative ``sources:`` YAML blocks.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from typing import TYPE_CHECKING, Any, Literal
15
+ from urllib.parse import quote, unquote, urlparse
16
+
17
+ from .config import ENV_VAR_PATTERN, ConnectionConfig, DatabaseType, SchemaFilter
18
+
19
+ if TYPE_CHECKING:
20
+ from pretensor.cli.config_file import SourceConfig
21
+
22
+ RegistryDialect = Literal["postgres", "mysql", "snowflake", "bigquery"]
23
+
24
+
25
+ def registry_dialect_for(db_type: DatabaseType) -> RegistryDialect:
26
+ """Map :class:`DatabaseType` to the value stored in ``RegistryEntry.dialect``."""
27
+ if db_type == DatabaseType.SNOWFLAKE:
28
+ return "snowflake"
29
+ if db_type == DatabaseType.BIGQUERY:
30
+ return "bigquery"
31
+ if db_type == DatabaseType.MYSQL:
32
+ return "mysql"
33
+ return "postgres"
34
+
35
+
36
+ __all__ = [
37
+ "RegistryDialect",
38
+ "connection_config_from_postgres_dsn",
39
+ "connection_config_from_registry_dsn",
40
+ "connection_config_from_source",
41
+ "connection_config_from_url",
42
+ "dsn_from_source",
43
+ "infer_database_type_from_dsn",
44
+ "redact_dsn",
45
+ "registry_dialect_for",
46
+ "validate_source_env_vars",
47
+ ]
48
+
49
+
50
+ def redact_dsn(dsn: str) -> str:
51
+ """Mask the password in a DSN's userinfo for safe display/logging.
52
+
53
+ ``postgres://user:secret@host/db`` -> ``postgres://user:***@host/db``.
54
+ Falls back to a fully masked ``***`` if the string cannot be parsed, so a
55
+ malformed DSN never leaks its contents.
56
+ """
57
+ raw = dsn.strip()
58
+ scheme, sep, rest = raw.partition("://")
59
+ if not sep or "@" not in rest:
60
+ return raw
61
+ try:
62
+ # The last "@" separates userinfo from host; rpartition keeps the host
63
+ # intact even if the password happens to contain an "@".
64
+ userinfo, _, hostpart = rest.rpartition("@")
65
+ if ":" in userinfo:
66
+ user, _, _pw = userinfo.partition(":")
67
+ userinfo = f"{user}:***"
68
+ return f"{scheme}://{userinfo}@{hostpart}"
69
+ except Exception:
70
+ return "***"
71
+
72
+
73
+ _DIALECT_ALIASES: dict[str, DatabaseType] = {
74
+ "postgres": DatabaseType.POSTGRES,
75
+ "postgresql": DatabaseType.POSTGRES,
76
+ "snowflake": DatabaseType.SNOWFLAKE,
77
+ "bigquery": DatabaseType.BIGQUERY,
78
+ "mysql": DatabaseType.MYSQL,
79
+ }
80
+
81
+
82
+ def infer_database_type_from_dsn(dsn: str) -> DatabaseType:
83
+ """Infer :class:`DatabaseType` from the URL scheme (before ``+driver``)."""
84
+ raw = dsn.strip()
85
+ if "://" not in raw:
86
+ msg = "DSN must be a URL with a scheme, e.g. postgresql://user@host/db"
87
+ raise ValueError(msg)
88
+ scheme_part, _, _ = raw.partition("://")
89
+ base_scheme = scheme_part.lower().split("+", 1)[0]
90
+ if base_scheme in ("postgres", "postgresql"):
91
+ return DatabaseType.POSTGRES
92
+ if base_scheme == "snowflake":
93
+ return DatabaseType.SNOWFLAKE
94
+ if base_scheme == "bigquery":
95
+ return DatabaseType.BIGQUERY
96
+ if base_scheme == "mysql":
97
+ return DatabaseType.MYSQL
98
+ msg = (
99
+ f"Unsupported DSN scheme {scheme_part!r}; use postgresql://, snowflake://, "
100
+ "bigquery://, or mysql://"
101
+ )
102
+ raise ValueError(msg)
103
+
104
+
105
+ def connection_config_from_url(
106
+ dsn: str,
107
+ connection_name: str,
108
+ *,
109
+ dialect_override: str | None = None,
110
+ ) -> ConnectionConfig:
111
+ """Build a :class:`ConnectionConfig` from a database URL.
112
+
113
+ Args:
114
+ dsn: Connection URL for PostgreSQL or Snowflake.
115
+ connection_name: Logical name for snapshots and the graph registry.
116
+ dialect_override: When set, force connector type (``postgres``,
117
+ ``postgresql``, ``snowflake``, or ``bigquery``) regardless of URL scheme.
118
+
119
+ Returns:
120
+ A mutable :class:`ConnectionConfig` for connector dispatch.
121
+
122
+ Raises:
123
+ ValueError: If the URL or dialect is not supported.
124
+ """
125
+ raw = dsn.strip()
126
+ if "://" not in raw:
127
+ msg = "DSN must be a URL with a scheme, e.g. postgresql://user@host/db"
128
+ raise ValueError(msg)
129
+
130
+ conn_type: DatabaseType
131
+ if dialect_override is not None:
132
+ key = dialect_override.strip().lower()
133
+ try:
134
+ conn_type = _DIALECT_ALIASES[key]
135
+ except KeyError as exc:
136
+ allowed = ", ".join(sorted(_DIALECT_ALIASES))
137
+ msg = f"Unknown dialect {dialect_override!r}; expected one of: {allowed}"
138
+ raise ValueError(msg) from exc
139
+ else:
140
+ conn_type = infer_database_type_from_dsn(raw)
141
+
142
+ if conn_type == DatabaseType.POSTGRES:
143
+ return _config_from_postgres_url(raw, connection_name)
144
+ if conn_type == DatabaseType.SNOWFLAKE:
145
+ return _config_from_snowflake_url(raw, connection_name)
146
+ if conn_type == DatabaseType.BIGQUERY:
147
+ scheme_part, _, remainder = raw.partition("://")
148
+ base_scheme = scheme_part.lower().split("+", 1)[0]
149
+ parse_url = raw if base_scheme == "bigquery" else f"bigquery://{remainder}"
150
+ return _config_from_bigquery_url(parse_url, connection_name)
151
+ if conn_type == DatabaseType.MYSQL:
152
+ return _config_from_mysql_url(raw, connection_name)
153
+ msg = f"No URL parser for database type: {conn_type}"
154
+ raise ValueError(msg)
155
+
156
+
157
+ def connection_config_from_postgres_dsn(
158
+ dsn: str, connection_name: str
159
+ ) -> ConnectionConfig:
160
+ """Build a :class:`ConnectionConfig` from a PostgreSQL DSN URL.
161
+
162
+ Deprecated path name; prefer :func:`connection_config_from_url`.
163
+ """
164
+ return connection_config_from_url(dsn, connection_name)
165
+
166
+
167
+ def connection_config_from_registry_dsn(
168
+ dsn: str,
169
+ connection_name: str,
170
+ dialect: RegistryDialect,
171
+ ) -> ConnectionConfig:
172
+ """Parse a registry-stored DSN using the recorded connector dialect."""
173
+ if dialect == "snowflake":
174
+ return connection_config_from_url(
175
+ dsn, connection_name, dialect_override="snowflake"
176
+ )
177
+ if dialect == "bigquery":
178
+ return connection_config_from_url(
179
+ dsn, connection_name, dialect_override="bigquery"
180
+ )
181
+ if dialect == "mysql":
182
+ return connection_config_from_url(
183
+ dsn, connection_name, dialect_override="mysql"
184
+ )
185
+ return connection_config_from_url(dsn, connection_name)
186
+
187
+
188
+ def _config_from_postgres_url(raw: str, connection_name: str) -> ConnectionConfig:
189
+ scheme_part, _, remainder = raw.partition("://")
190
+ base_scheme = scheme_part.lower().split("+", 1)[0]
191
+ if base_scheme not in ("postgres", "postgresql"):
192
+ msg = f"Expected a postgres or postgresql DSN, got scheme {scheme_part!r}"
193
+ raise ValueError(msg)
194
+
195
+ parsed = urlparse(f"postgres://{remainder}")
196
+ if parsed.hostname is None or parsed.hostname == "":
197
+ msg = "PostgreSQL DSN must include a host"
198
+ raise ValueError(msg)
199
+
200
+ database = parsed.path.lstrip("/") or None
201
+ user = unquote(parsed.username) if parsed.username else ""
202
+ password = unquote(parsed.password) if parsed.password else None
203
+
204
+ return ConnectionConfig(
205
+ name=connection_name,
206
+ type=DatabaseType.POSTGRES,
207
+ host=parsed.hostname,
208
+ port=parsed.port,
209
+ database=database,
210
+ user=user or None,
211
+ password=password,
212
+ )
213
+
214
+
215
+ def _config_from_snowflake_url(raw: str, connection_name: str) -> ConnectionConfig:
216
+ """Parse ``snowflake://user:pass@account/db/schema?warehouse=...`` into config."""
217
+ scheme_part, _, remainder = raw.partition("://")
218
+ base_scheme = scheme_part.lower().split("+", 1)[0]
219
+ if base_scheme != "snowflake":
220
+ msg = f"Expected a snowflake DSN, got scheme {scheme_part!r}"
221
+ raise ValueError(msg)
222
+
223
+ parsed = urlparse(f"snowflake://{remainder}")
224
+ account = parsed.hostname
225
+ if account is None or account == "":
226
+ msg = "Snowflake DSN must include account host (e.g. xy12345.us-east-1.aws)"
227
+ raise ValueError(msg)
228
+
229
+ path = parsed.path.strip("/")
230
+ path_parts = [p for p in path.split("/") if p]
231
+ database = path_parts[0] if len(path_parts) > 0 else None
232
+ snowflake_schema = path_parts[1] if len(path_parts) > 1 else None
233
+
234
+ user = unquote(parsed.username) if parsed.username else None
235
+ password = unquote(parsed.password) if parsed.password else None
236
+
237
+ warehouse: str | None = None
238
+ role: str | None = None
239
+ private_key_path: str | None = None
240
+ private_key_passphrase: str | None = None
241
+ if parsed.query:
242
+ from urllib.parse import parse_qs
243
+
244
+ qs = parse_qs(parsed.query, keep_blank_values=True)
245
+ wh = qs.get("warehouse", [None])[0]
246
+ if wh:
247
+ warehouse = unquote(wh)
248
+ rl = qs.get("role", [None])[0]
249
+ if rl:
250
+ role = unquote(rl)
251
+ pkp = qs.get("private_key_path", [None])[0]
252
+ if pkp:
253
+ private_key_path = unquote(pkp)
254
+ pkpass = qs.get("private_key_passphrase", [None])[0]
255
+ if pkpass:
256
+ private_key_passphrase = unquote(pkpass)
257
+
258
+ return ConnectionConfig(
259
+ name=connection_name,
260
+ type=DatabaseType.SNOWFLAKE,
261
+ host=account,
262
+ database=database,
263
+ user=user,
264
+ password=password,
265
+ schema_filter=_snowflake_schema_filter(snowflake_schema),
266
+ metadata_extra={
267
+ "snowflake_schema": snowflake_schema,
268
+ "warehouse": warehouse,
269
+ "role": role,
270
+ "private_key_path": private_key_path,
271
+ "private_key_passphrase": private_key_passphrase,
272
+ },
273
+ )
274
+
275
+
276
+ def _snowflake_schema_filter(snowflake_schema: str | None) -> SchemaFilter:
277
+ if snowflake_schema:
278
+ return SchemaFilter(include=[snowflake_schema.upper()])
279
+ return SchemaFilter()
280
+
281
+
282
+ def validate_source_env_vars(source: SourceConfig) -> list[str]:
283
+ """Return names of unset environment variables referenced in *source* fields.
284
+
285
+ Iterates all fields on the dataclass dynamically so new fields are
286
+ automatically covered without maintaining a hardcoded list.
287
+ Returns an empty list when every reference resolves.
288
+ """
289
+ import dataclasses
290
+
291
+ missing: list[str] = []
292
+ for f in dataclasses.fields(source):
293
+ value = getattr(source, f.name, None)
294
+ if isinstance(value, str):
295
+ for match in ENV_VAR_PATTERN.finditer(value):
296
+ var = match.group(1)
297
+ if os.environ.get(var) is None and var not in missing:
298
+ missing.append(var)
299
+ return missing
300
+
301
+
302
+ def connection_config_from_source(
303
+ name: str,
304
+ source: SourceConfig,
305
+ ) -> ConnectionConfig:
306
+ """Build a :class:`ConnectionConfig` from a declarative source block.
307
+
308
+ Args:
309
+ name: Logical connection name (the YAML key under ``sources:``).
310
+ source: Parsed :class:`SourceConfig` from the config file.
311
+
312
+ Returns:
313
+ A :class:`ConnectionConfig` ready for connector dispatch.
314
+
315
+ Raises:
316
+ ValueError: If the dialect or required fields are invalid.
317
+ """
318
+ key = source.dialect.strip().lower()
319
+ try:
320
+ conn_type = _DIALECT_ALIASES[key]
321
+ except KeyError as exc:
322
+ allowed = ", ".join(sorted(_DIALECT_ALIASES))
323
+ msg = f"Unknown dialect {source.dialect!r} in source `{name}`; expected one of: {allowed}"
324
+ raise ValueError(msg) from exc
325
+
326
+ if conn_type == DatabaseType.POSTGRES:
327
+ if not source.host:
328
+ raise ValueError(f"Source `{name}` (postgres) requires `host`")
329
+ return ConnectionConfig(
330
+ name=name,
331
+ type=DatabaseType.POSTGRES,
332
+ host=source.host,
333
+ port=source.port,
334
+ database=source.database,
335
+ user=source.user,
336
+ password=source.password,
337
+ )
338
+
339
+ if conn_type == DatabaseType.SNOWFLAKE:
340
+ if not source.account:
341
+ raise ValueError(f"Source `{name}` (snowflake) requires `account`")
342
+ sf_schema = source.schema
343
+ return ConnectionConfig(
344
+ name=name,
345
+ type=DatabaseType.SNOWFLAKE,
346
+ host=source.account,
347
+ database=source.database,
348
+ user=source.user,
349
+ password=source.password,
350
+ schema_filter=_snowflake_schema_filter(sf_schema),
351
+ metadata_extra={
352
+ "snowflake_schema": sf_schema,
353
+ "warehouse": source.warehouse,
354
+ "role": source.role,
355
+ "private_key_path": source.private_key_path,
356
+ "private_key_passphrase": source.private_key_passphrase,
357
+ },
358
+ )
359
+
360
+ if conn_type == DatabaseType.BIGQUERY:
361
+ project = source.project
362
+ dataset = source.dataset
363
+ if not project or not dataset:
364
+ raise ValueError(
365
+ f"Source `{name}` (bigquery) requires both `project` and `dataset`"
366
+ )
367
+ return ConnectionConfig(
368
+ name=name,
369
+ type=DatabaseType.BIGQUERY,
370
+ host=project,
371
+ database=f"{project}/{dataset}",
372
+ metadata_extra={"bq_project": project, "bq_location": source.location},
373
+ schema_filter=SchemaFilter(include=[dataset]),
374
+ )
375
+
376
+ if conn_type == DatabaseType.MYSQL:
377
+ if not source.host:
378
+ raise ValueError(f"Source `{name}` (mysql) requires `host`")
379
+ db = source.database
380
+ return ConnectionConfig(
381
+ name=name,
382
+ type=DatabaseType.MYSQL,
383
+ host=source.host,
384
+ port=source.port,
385
+ database=db,
386
+ user=source.user,
387
+ password=source.password,
388
+ schema_filter=SchemaFilter(include=[db] if db else []),
389
+ )
390
+
391
+ msg = f"No source builder for dialect: {source.dialect}"
392
+ raise ValueError(msg)
393
+
394
+
395
+ def dsn_from_source(name: str, source: SourceConfig) -> str:
396
+ """Build a DSN URL string from a declarative source block.
397
+
398
+ Used for registry storage so that ``reindex`` can reconstruct connections
399
+ from stored DSNs without the config file present.
400
+ """
401
+ key = source.dialect.strip().lower()
402
+ try:
403
+ conn_type = _DIALECT_ALIASES[key]
404
+ except KeyError as exc:
405
+ msg = f"Unknown dialect {source.dialect!r} in source `{name}`"
406
+ raise ValueError(msg) from exc
407
+
408
+ def _enc(s: str | None) -> str:
409
+ return quote(s, safe="") if s else ""
410
+
411
+ if conn_type == DatabaseType.POSTGRES:
412
+ user = _enc(source.user) or "postgres"
413
+ pw = f":{_enc(source.password)}" if source.password else ""
414
+ host = source.host or "localhost"
415
+ port = f":{source.port}" if source.port else ""
416
+ db = f"/{source.database}" if source.database else ""
417
+ return f"postgresql://{user}{pw}@{host}{port}{db}"
418
+
419
+ if conn_type == DatabaseType.SNOWFLAKE:
420
+ user = _enc(source.user) or ""
421
+ pw = (
422
+ f":{_enc(source.password)}"
423
+ if source.password and not source.private_key_path
424
+ else ""
425
+ )
426
+ account = source.account or ""
427
+ db = f"/{source.database}" if source.database else ""
428
+ schema = f"/{source.schema}" if source.schema else ""
429
+ params: list[str] = []
430
+ if source.warehouse:
431
+ params.append(f"warehouse={_enc(source.warehouse)}")
432
+ if source.role:
433
+ params.append(f"role={_enc(source.role)}")
434
+ if source.private_key_path:
435
+ params.append(f"private_key_path={_enc(source.private_key_path)}")
436
+ if source.private_key_passphrase:
437
+ params.append(
438
+ f"private_key_passphrase={_enc(source.private_key_passphrase)}"
439
+ )
440
+ qs = f"?{'&'.join(params)}" if params else ""
441
+ cred = f"{user}{pw}@" if user else ""
442
+ return f"snowflake://{cred}{account}{db}{schema}{qs}"
443
+
444
+ if conn_type == DatabaseType.BIGQUERY:
445
+ project = source.project or ""
446
+ dataset = source.dataset or ""
447
+ loc = f"?location={_enc(source.location)}" if source.location else ""
448
+ return f"bigquery://{project}/{dataset}{loc}"
449
+
450
+ if conn_type == DatabaseType.MYSQL:
451
+ user = _enc(source.user) or "root"
452
+ pw = f":{_enc(source.password)}" if source.password else ""
453
+ host = source.host or "localhost"
454
+ port = f":{source.port}" if source.port else ""
455
+ db = f"/{source.database}" if source.database else ""
456
+ return f"mysql://{user}{pw}@{host}{port}{db}"
457
+
458
+ msg = f"No DSN builder for dialect: {source.dialect}"
459
+ raise ValueError(msg)
460
+
461
+
462
+ def _config_from_bigquery_url(raw: str, connection_name: str) -> ConnectionConfig:
463
+ """Parse ``bigquery://project/dataset?location=...`` into config.
464
+
465
+ The URL path is ``/dataset`` or ``/project/dataset``; when the host is
466
+ non-empty it is treated as the GCP project id and the first path segment
467
+ is the dataset. When the host is empty, the first path segment is project
468
+ and the second is dataset.
469
+ """
470
+ scheme_part, _, remainder = raw.partition("://")
471
+ base_scheme = scheme_part.lower().split("+", 1)[0]
472
+ if base_scheme != "bigquery":
473
+ msg = f"Expected a bigquery DSN, got scheme {scheme_part!r}"
474
+ raise ValueError(msg)
475
+
476
+ parsed = urlparse(f"bigquery://{remainder}")
477
+ path = parsed.path.strip("/")
478
+ path_parts = [p for p in path.split("/") if p]
479
+
480
+ project: str | None = None
481
+ dataset: str | None = None
482
+ host = (parsed.hostname or "").strip()
483
+ if host:
484
+ project = host
485
+ dataset = path_parts[0] if path_parts else None
486
+ else:
487
+ if len(path_parts) >= 2:
488
+ project, dataset = path_parts[0], path_parts[1]
489
+ elif len(path_parts) == 1:
490
+ msg = (
491
+ "BigQuery DSN must include project and dataset "
492
+ "(e.g. bigquery://my-project/my-dataset)"
493
+ )
494
+ raise ValueError(msg)
495
+
496
+ if not project or not dataset:
497
+ msg = (
498
+ "BigQuery DSN must include project and dataset "
499
+ "(e.g. bigquery://my-project/my-dataset)"
500
+ )
501
+ raise ValueError(msg)
502
+
503
+ location: str | None = None
504
+ if parsed.query:
505
+ from urllib.parse import parse_qs
506
+
507
+ qs = parse_qs(parsed.query, keep_blank_values=True)
508
+ loc = qs.get("location", [None])[0]
509
+ if loc:
510
+ location = unquote(loc)
511
+
512
+ extra: dict[str, Any] = {"bq_project": project, "bq_location": location}
513
+
514
+ return ConnectionConfig(
515
+ name=connection_name,
516
+ type=DatabaseType.BIGQUERY,
517
+ host=project,
518
+ database=f"{project}/{dataset}",
519
+ metadata_extra=extra,
520
+ schema_filter=SchemaFilter(include=[dataset]),
521
+ )
522
+
523
+
524
+ def _config_from_mysql_url(raw: str, connection_name: str) -> ConnectionConfig:
525
+ """Parse ``mysql://user:pass@host:port/database`` (or ``mysql+pymysql://``) into config."""
526
+ scheme_part, _, remainder = raw.partition("://")
527
+ base_scheme = scheme_part.lower().split("+", 1)[0]
528
+ if base_scheme != "mysql":
529
+ msg = f"Expected a mysql DSN, got scheme {scheme_part!r}"
530
+ raise ValueError(msg)
531
+
532
+ parsed = urlparse(f"mysql://{remainder}")
533
+ if parsed.hostname is None or parsed.hostname == "":
534
+ msg = "MySQL DSN must include a host"
535
+ raise ValueError(msg)
536
+
537
+ database = parsed.path.lstrip("/") or None
538
+ user = unquote(parsed.username) if parsed.username else None
539
+ password = unquote(parsed.password) if parsed.password else None
540
+
541
+ return ConnectionConfig(
542
+ name=connection_name,
543
+ type=DatabaseType.MYSQL,
544
+ host=parsed.hostname,
545
+ port=parsed.port,
546
+ database=database,
547
+ user=user,
548
+ password=password,
549
+ schema_filter=SchemaFilter(include=[database] if database else []),
550
+ )
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import StrEnum
4
+ from io import StringIO
5
+ from typing import Any
6
+
7
+ from pydantic import Field
8
+ from ruamel.yaml import YAML
9
+
10
+ from .base import PretensorModel
11
+
12
+
13
+ class ChartType(StrEnum):
14
+ LINE = "line"
15
+ BAR = "bar"
16
+ HORIZONTAL_BAR = "horizontal_bar"
17
+ AREA = "area"
18
+ PIE = "pie"
19
+ BIG_VALUE = "big_value"
20
+ TABLE = "table"
21
+
22
+
23
+ class SectionLayout(StrEnum):
24
+ KPI_ROW = "kpi_row"
25
+ SINGLE_CHART = "single_chart"
26
+ TWO_COLUMN = "two_column"
27
+
28
+
29
+ class ChartConfig(PretensorModel):
30
+ chart_type: ChartType
31
+ metrics: list[str]
32
+ dimensions: list[str] = Field(default_factory=list)
33
+ time_range: str | None = None
34
+ granularity: str | None = None
35
+ sort: str | None = None
36
+ limit: int | None = None
37
+ format_overrides: dict[str, Any] = Field(default_factory=dict)
38
+
39
+
40
+ class Section(PretensorModel):
41
+ title: str
42
+ description: str | None = None
43
+ layout: SectionLayout = SectionLayout.SINGLE_CHART
44
+ charts: list[ChartConfig] = Field(default_factory=list)
45
+
46
+
47
+ class DashboardPlan(PretensorModel):
48
+ title: str
49
+ description: str | None = None
50
+ default_time_range: str | None = None
51
+ default_comparison: str | None = None
52
+ sections: list[Section] = Field(default_factory=list)
53
+ filters: list[str] = Field(default_factory=list)
54
+ target_page_path: str | None = None
55
+
56
+ def to_yaml(self) -> str:
57
+ data = self.model_dump(mode="json")
58
+ yaml = YAML()
59
+ yaml.default_flow_style = False
60
+ buf = StringIO()
61
+ yaml.dump(data, buf)
62
+ return buf.getvalue()
63
+
64
+ @classmethod
65
+ def from_yaml(cls, yaml_str: str) -> DashboardPlan:
66
+ yaml = YAML()
67
+ data = yaml.load(yaml_str)
68
+ return cls.model_validate(data)
69
+
70
+ def to_markdown(self) -> str:
71
+ """Render a human-readable markdown summary for review."""
72
+ lines: list[str] = []
73
+ lines.append(f"# {self.title}")
74
+ if self.description:
75
+ lines.append(f"\n{self.description}")
76
+ lines.append("")
77
+
78
+ meta: list[str] = []
79
+ if self.default_time_range:
80
+ meta.append(f"- **Time range:** {self.default_time_range}")
81
+ if self.default_comparison:
82
+ meta.append(f"- **Comparison:** {self.default_comparison}")
83
+ if self.filters:
84
+ meta.append(f"- **Filters:** {', '.join(self.filters)}")
85
+ if self.target_page_path:
86
+ meta.append(f"- **Page path:** `{self.target_page_path}`")
87
+ if meta:
88
+ lines.extend(meta)
89
+ lines.append("")
90
+
91
+ for idx, section in enumerate(self.sections, 1):
92
+ lines.append(f"## {idx}. {section.title}")
93
+ if section.description:
94
+ lines.append(f"\n{section.description}")
95
+ lines.append(f"\n*Layout: {section.layout.value}*\n")
96
+
97
+ for chart in section.charts:
98
+ lines.append(
99
+ f"- **{chart.chart_type.value}**: {', '.join(chart.metrics)}"
100
+ )
101
+ details: list[str] = []
102
+ if chart.dimensions:
103
+ details.append(f"dimensions: {', '.join(chart.dimensions)}")
104
+ if chart.granularity:
105
+ details.append(f"granularity: {chart.granularity}")
106
+ if chart.time_range:
107
+ details.append(f"time range: {chart.time_range}")
108
+ if chart.sort:
109
+ details.append(f"sort: {chart.sort}")
110
+ if chart.limit:
111
+ details.append(f"limit: {chart.limit}")
112
+ if details:
113
+ lines.append(f" {' | '.join(details)}")
114
+ lines.append("")
115
+
116
+ return "\n".join(lines)
@@ -0,0 +1,10 @@
1
+ """Schema DTOs — re-exported from ``pretensor.connectors.models``."""
2
+
3
+ from pretensor.connectors.models import (
4
+ Column,
5
+ ForeignKey,
6
+ SchemaSnapshot,
7
+ Table,
8
+ )
9
+
10
+ __all__ = ["Column", "ForeignKey", "SchemaSnapshot", "Table"]