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.
- pretensor/__init__.py +50 -0
- pretensor/benchmark/__init__.py +54 -0
- pretensor/benchmark/cli.py +294 -0
- pretensor/benchmark/fixtures.py +84 -0
- pretensor/benchmark/l1/__init__.py +23 -0
- pretensor/benchmark/l1/metrics.py +141 -0
- pretensor/benchmark/l1/pipeline.py +188 -0
- pretensor/benchmark/l1/runner.py +245 -0
- pretensor/benchmark/l2/__init__.py +27 -0
- pretensor/benchmark/l2/gold.py +236 -0
- pretensor/benchmark/l2/metrics.py +146 -0
- pretensor/benchmark/l2/pipeline.py +124 -0
- pretensor/benchmark/l2/runner.py +530 -0
- pretensor/benchmark/l3/__init__.py +73 -0
- pretensor/benchmark/l3/agent.py +316 -0
- pretensor/benchmark/l3/db.py +188 -0
- pretensor/benchmark/l3/gold.py +85 -0
- pretensor/benchmark/l3/llm_client.py +395 -0
- pretensor/benchmark/l3/mcp_client.py +357 -0
- pretensor/benchmark/l3/pretensor_runner.py +456 -0
- pretensor/benchmark/l3/prompt.py +132 -0
- pretensor/benchmark/l3/runner.py +358 -0
- pretensor/benchmark/l3/sql_equivalence.py +176 -0
- pretensor/benchmark/release_gate.py +448 -0
- pretensor/benchmark/results.py +298 -0
- pretensor/benchmark/runner.py +109 -0
- pretensor/cli/__init__.py +1 -0
- pretensor/cli/commands/_source_runner.py +147 -0
- pretensor/cli/commands/analyze.py +201 -0
- pretensor/cli/commands/connections/__init__.py +7 -0
- pretensor/cli/commands/connections/add_remove.py +126 -0
- pretensor/cli/commands/connections/register.py +12 -0
- pretensor/cli/commands/export.py +131 -0
- pretensor/cli/commands/index.py +559 -0
- pretensor/cli/commands/list.py +76 -0
- pretensor/cli/commands/quickstart.py +207 -0
- pretensor/cli/commands/reindex.py +646 -0
- pretensor/cli/commands/semantic.py +190 -0
- pretensor/cli/commands/serve.py +144 -0
- pretensor/cli/commands/sync_grants.py +149 -0
- pretensor/cli/commands/validate.py +176 -0
- pretensor/cli/config_file.py +442 -0
- pretensor/cli/constants.py +10 -0
- pretensor/cli/dbt_enrichment.py +96 -0
- pretensor/cli/main.py +109 -0
- pretensor/cli/paths.py +43 -0
- pretensor/cli/plugin.py +52 -0
- pretensor/config.py +226 -0
- pretensor/connectors/__init__.py +29 -0
- pretensor/connectors/base.py +165 -0
- pretensor/connectors/bigquery.py +468 -0
- pretensor/connectors/inspect.py +321 -0
- pretensor/connectors/lineage_sqlglot.py +97 -0
- pretensor/connectors/models.py +130 -0
- pretensor/connectors/mysql.py +402 -0
- pretensor/connectors/pg_array_parse.py +53 -0
- pretensor/connectors/postgres.py +938 -0
- pretensor/connectors/registry.py +93 -0
- pretensor/connectors/snapshot.py +244 -0
- pretensor/connectors/snowflake.py +908 -0
- pretensor/core/__init__.py +1 -0
- pretensor/core/builder.py +307 -0
- pretensor/core/dsn_crypto.py +51 -0
- pretensor/core/graph_schema_manager.py +246 -0
- pretensor/core/graph_store.py +1226 -0
- pretensor/core/ids.py +101 -0
- pretensor/core/portable_export.py +276 -0
- pretensor/core/query_runner.py +67 -0
- pretensor/core/registry.py +209 -0
- pretensor/core/schema.py +473 -0
- pretensor/core/secure_io.py +93 -0
- pretensor/core/store.py +469 -0
- pretensor/enrichment/__init__.py +1 -0
- pretensor/enrichment/analyze/__init__.py +0 -0
- pretensor/enrichment/analyze/classify.py +49 -0
- pretensor/enrichment/analyze/extract_python.py +196 -0
- pretensor/enrichment/analyze/parse.py +141 -0
- pretensor/enrichment/analyze/pipeline.py +195 -0
- pretensor/enrichment/analyze/summary.py +38 -0
- pretensor/enrichment/analyze/walker.py +98 -0
- pretensor/enrichment/analyze/writers.py +214 -0
- pretensor/enrichment/dbt/__init__.py +30 -0
- pretensor/enrichment/dbt/lineage.py +100 -0
- pretensor/enrichment/dbt/manifest.py +300 -0
- pretensor/enrichment/dbt/metadata.py +263 -0
- pretensor/enrichment/dbt/pipeline.py +77 -0
- pretensor/enrichment/dbt/resolution.py +101 -0
- pretensor/enrichment/dbt/signals.py +305 -0
- pretensor/entities/__init__.py +27 -0
- pretensor/entities/builder.py +63 -0
- pretensor/entities/classifier.py +383 -0
- pretensor/entities/llm_extract.py +66 -0
- pretensor/errors.py +35 -0
- pretensor/graph_models/__init__.py +17 -0
- pretensor/graph_models/base.py +11 -0
- pretensor/graph_models/consumer.py +71 -0
- pretensor/graph_models/edge.py +35 -0
- pretensor/graph_models/entity.py +21 -0
- pretensor/graph_models/node.py +79 -0
- pretensor/graph_models/relationship.py +33 -0
- pretensor/integrations/__init__.py +42 -0
- pretensor/integrations/_base.py +138 -0
- pretensor/integrations/google_adk.py +49 -0
- pretensor/integrations/langchain.py +55 -0
- pretensor/integrations/llamaindex.py +53 -0
- pretensor/intelligence/__init__.py +33 -0
- pretensor/intelligence/cluster_labeler.py +425 -0
- pretensor/intelligence/clustering.py +168 -0
- pretensor/intelligence/combining.py +32 -0
- pretensor/intelligence/discovery.py +114 -0
- pretensor/intelligence/embeddings.py +317 -0
- pretensor/intelligence/graph_export.py +200 -0
- pretensor/intelligence/heuristic.py +544 -0
- pretensor/intelligence/join_paths/__init__.py +130 -0
- pretensor/intelligence/join_paths/on_demand.py +516 -0
- pretensor/intelligence/join_paths/storage.py +70 -0
- pretensor/intelligence/llm_infer.py +78 -0
- pretensor/intelligence/llm_runtime.py +62 -0
- pretensor/intelligence/metric_templates.py +193 -0
- pretensor/intelligence/pipeline.py +364 -0
- pretensor/intelligence/role_exemplars.py +263 -0
- pretensor/intelligence/schema_classification.py +360 -0
- pretensor/intelligence/scoring.py +76 -0
- pretensor/intelligence/semantic.py +240 -0
- pretensor/intelligence/shadow_alias.py +101 -0
- pretensor/intelligence/statistical.py +50 -0
- pretensor/intelligence/steps.py +191 -0
- pretensor/intelligence/steps_embedding.py +168 -0
- pretensor/introspection/__init__.py +6 -0
- pretensor/introspection/inspector.py +5 -0
- pretensor/introspection/models/__init__.py +0 -0
- pretensor/introspection/models/base.py +5 -0
- pretensor/introspection/models/config.py +237 -0
- pretensor/introspection/models/dsn.py +550 -0
- pretensor/introspection/models/plan.py +116 -0
- pretensor/introspection/models/schema.py +10 -0
- pretensor/introspection/models/semantic.py +121 -0
- pretensor/introspection/models/validation.py +116 -0
- pretensor/introspection/snapshot.py +46 -0
- pretensor/mcp/__init__.py +16 -0
- pretensor/mcp/config_json.py +24 -0
- pretensor/mcp/payload_types.py +274 -0
- pretensor/mcp/resources/__init__.py +17 -0
- pretensor/mcp/resources/markdown.py +314 -0
- pretensor/mcp/server.py +285 -0
- pretensor/mcp/service.py +49 -0
- pretensor/mcp/service_context.py +142 -0
- pretensor/mcp/service_registry.py +294 -0
- pretensor/mcp/store_cache.py +43 -0
- pretensor/mcp/tool_registry.py +136 -0
- pretensor/mcp/tools/__init__.py +1 -0
- pretensor/mcp/tools/_rank.py +244 -0
- pretensor/mcp/tools/_timed.py +26 -0
- pretensor/mcp/tools/compile_metric.py +144 -0
- pretensor/mcp/tools/consumers.py +161 -0
- pretensor/mcp/tools/context.py +1121 -0
- pretensor/mcp/tools/cypher.py +509 -0
- pretensor/mcp/tools/detect_changes.py +254 -0
- pretensor/mcp/tools/impact.py +271 -0
- pretensor/mcp/tools/list.py +131 -0
- pretensor/mcp/tools/schema.py +170 -0
- pretensor/mcp/tools/search.py +316 -0
- pretensor/mcp/tools/semantic_search.py +282 -0
- pretensor/mcp/tools/traverse.py +1027 -0
- pretensor/mcp/tools/validate_sql.py +150 -0
- pretensor/observability.py +203 -0
- pretensor/py.typed +0 -0
- pretensor/quickstart/README.md +29 -0
- pretensor/quickstart/__init__.py +6 -0
- pretensor/quickstart/docker-compose.yml +18 -0
- pretensor/quickstart/pagila_data.sql +63 -0
- pretensor/quickstart/pagila_ddl.sql +92 -0
- pretensor/search/__init__.py +6 -0
- pretensor/search/base.py +80 -0
- pretensor/search/index.py +435 -0
- pretensor/semantic/__init__.py +24 -0
- pretensor/semantic/base.py +123 -0
- pretensor/semantic/compiler.py +487 -0
- pretensor/semantic/yaml_layer.py +180 -0
- pretensor/skills/__init__.py +5 -0
- pretensor/skills/generator.py +235 -0
- pretensor/staleness/__init__.py +15 -0
- pretensor/staleness/graph_patcher.py +355 -0
- pretensor/staleness/impact_analyzer.py +162 -0
- pretensor/staleness/snapshot_store.py +38 -0
- pretensor/validation/__init__.py +9 -0
- pretensor/validation/query_validator.py +436 -0
- pretensor/visibility/__init__.py +23 -0
- pretensor/visibility/config.py +126 -0
- pretensor/visibility/filter.py +143 -0
- pretensor/visibility/kuzu_helpers.py +32 -0
- pretensor/visibility/runtime.py +36 -0
- pretensor/visibility/sync_grants.py +188 -0
- pretensor-0.1.0.dist-info/METADATA +251 -0
- pretensor-0.1.0.dist-info/RECORD +198 -0
- pretensor-0.1.0.dist-info/WHEEL +4 -0
- pretensor-0.1.0.dist-info/entry_points.txt +2 -0
- 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)
|