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,442 @@
|
|
|
1
|
+
"""CLI config-file loading and option precedence helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from click.core import ParameterSource
|
|
11
|
+
from ruamel.yaml import YAML
|
|
12
|
+
|
|
13
|
+
from pretensor.config import GraphConfig
|
|
14
|
+
from pretensor.errors import PretensorError
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"DEFAULT_CONFIG_PATH",
|
|
18
|
+
"CliConfigError",
|
|
19
|
+
"ConnectionDefaults",
|
|
20
|
+
"LlmDefaults",
|
|
21
|
+
"PretensorCliConfig",
|
|
22
|
+
"SourceConfig",
|
|
23
|
+
"VisibilityDefaults",
|
|
24
|
+
"get_cli_config",
|
|
25
|
+
"load_cli_config",
|
|
26
|
+
"resolve_aliased_path_option",
|
|
27
|
+
"resolve_optional_path_option",
|
|
28
|
+
"resolve_optional_str_option",
|
|
29
|
+
"resolve_path_option",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
DEFAULT_CONFIG_PATH = Path(".pretensor") / "config.yaml"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CliConfigError(PretensorError, ValueError):
|
|
36
|
+
"""Raised when the CLI config file is unreadable or invalid."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class LlmDefaults:
|
|
41
|
+
"""LLM defaults accepted by config for compatibility with external workflows."""
|
|
42
|
+
|
|
43
|
+
model: str | None = None
|
|
44
|
+
budget_usd: float | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class VisibilityDefaults:
|
|
49
|
+
"""Visibility defaults for ``index`` and ``serve``."""
|
|
50
|
+
|
|
51
|
+
path: Path | None = None
|
|
52
|
+
profile: str | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class ConnectionDefaults:
|
|
57
|
+
"""Default connection settings for DSN-driven commands."""
|
|
58
|
+
|
|
59
|
+
name: str | None = None
|
|
60
|
+
dialect: str | None = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_SOURCE_ALLOWED_KEYS = frozenset(
|
|
64
|
+
{
|
|
65
|
+
"dialect",
|
|
66
|
+
"host",
|
|
67
|
+
"port",
|
|
68
|
+
"user",
|
|
69
|
+
"password",
|
|
70
|
+
"database",
|
|
71
|
+
# Snowflake
|
|
72
|
+
"account",
|
|
73
|
+
"schema",
|
|
74
|
+
"warehouse",
|
|
75
|
+
"role",
|
|
76
|
+
"private_key_path",
|
|
77
|
+
"private_key_passphrase",
|
|
78
|
+
# BigQuery
|
|
79
|
+
"project",
|
|
80
|
+
"dataset",
|
|
81
|
+
"location",
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True, slots=True)
|
|
87
|
+
class SourceConfig:
|
|
88
|
+
"""Declarative database source definition from ``sources:`` config block."""
|
|
89
|
+
|
|
90
|
+
dialect: str
|
|
91
|
+
host: str | None = None
|
|
92
|
+
port: int | None = None
|
|
93
|
+
user: str | None = None
|
|
94
|
+
password: str | None = None
|
|
95
|
+
database: str | None = None
|
|
96
|
+
# Snowflake
|
|
97
|
+
account: str | None = None
|
|
98
|
+
schema: str | None = None
|
|
99
|
+
warehouse: str | None = None
|
|
100
|
+
role: str | None = None
|
|
101
|
+
private_key_path: str | None = None
|
|
102
|
+
private_key_passphrase: str | None = None
|
|
103
|
+
# BigQuery
|
|
104
|
+
project: str | None = None
|
|
105
|
+
dataset: str | None = None
|
|
106
|
+
location: str | None = None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True, slots=True)
|
|
110
|
+
class PretensorCliConfig:
|
|
111
|
+
"""Normalized runtime config loaded from YAML (or empty defaults)."""
|
|
112
|
+
|
|
113
|
+
source_path: Path | None = None
|
|
114
|
+
state_dir: Path | None = None
|
|
115
|
+
graph: GraphConfig = GraphConfig()
|
|
116
|
+
llm: LlmDefaults = LlmDefaults()
|
|
117
|
+
visibility: VisibilityDefaults = VisibilityDefaults()
|
|
118
|
+
connection_defaults: ConnectionDefaults = ConnectionDefaults()
|
|
119
|
+
sources: dict[str, SourceConfig] = field(default_factory=dict)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def loaded(self) -> bool:
|
|
123
|
+
"""Whether this config came from a file on disk."""
|
|
124
|
+
return self.source_path is not None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _as_mapping(raw: Any, *, field: str) -> dict[str, Any]:
|
|
128
|
+
if raw is None:
|
|
129
|
+
return {}
|
|
130
|
+
if not isinstance(raw, dict):
|
|
131
|
+
raise CliConfigError(f"`{field}` must be a mapping in config YAML")
|
|
132
|
+
return raw
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _resolved_path(raw: Any, *, field: str, base_dir: Path) -> Path:
|
|
136
|
+
if not isinstance(raw, str) or not raw.strip():
|
|
137
|
+
raise CliConfigError(f"`{field}` must be a non-empty string path")
|
|
138
|
+
value = Path(raw.strip()).expanduser()
|
|
139
|
+
if not value.is_absolute():
|
|
140
|
+
value = (base_dir / value).resolve()
|
|
141
|
+
else:
|
|
142
|
+
value = value.resolve()
|
|
143
|
+
return value
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _resolved_optional_path(raw: Any, *, field: str, base_dir: Path) -> Path | None:
|
|
147
|
+
if raw is None:
|
|
148
|
+
return None
|
|
149
|
+
return _resolved_path(raw, field=field, base_dir=base_dir)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _normalized_optional_str(raw: Any, *, field: str) -> str | None:
|
|
153
|
+
if raw is None:
|
|
154
|
+
return None
|
|
155
|
+
if not isinstance(raw, str):
|
|
156
|
+
raise CliConfigError(f"`{field}` must be a string when provided")
|
|
157
|
+
value = raw.strip()
|
|
158
|
+
return value or None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _normalized_optional_float(raw: Any, *, field: str) -> float | None:
|
|
162
|
+
if raw is None:
|
|
163
|
+
return None
|
|
164
|
+
if isinstance(raw, bool):
|
|
165
|
+
raise CliConfigError(f"`{field}` must be a number when provided")
|
|
166
|
+
if isinstance(raw, (int, float)):
|
|
167
|
+
return float(raw)
|
|
168
|
+
raise CliConfigError(f"`{field}` must be a number when provided")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _graph_config_from_mapping(raw: Any) -> GraphConfig:
|
|
172
|
+
data = _as_mapping(raw, field="graph")
|
|
173
|
+
allowed = {
|
|
174
|
+
"stale_index_warning_days",
|
|
175
|
+
"clustering_resolution_override",
|
|
176
|
+
"join_path_max_depth",
|
|
177
|
+
"min_cluster_size_merge",
|
|
178
|
+
"collapse_shadow_aliases",
|
|
179
|
+
"lineage_in_max_for_alias",
|
|
180
|
+
}
|
|
181
|
+
unknown = sorted(set(data) - allowed)
|
|
182
|
+
if unknown:
|
|
183
|
+
joined = ", ".join(unknown)
|
|
184
|
+
raise CliConfigError(f"Unknown `graph` config key(s): {joined}")
|
|
185
|
+
try:
|
|
186
|
+
return GraphConfig(**data)
|
|
187
|
+
except TypeError as exc:
|
|
188
|
+
raise CliConfigError(f"Invalid `graph` config: {exc}") from exc
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _source_config_from_mapping(
|
|
192
|
+
name: str,
|
|
193
|
+
raw: Any,
|
|
194
|
+
) -> SourceConfig:
|
|
195
|
+
"""Validate and build a :class:`SourceConfig` from a raw YAML mapping."""
|
|
196
|
+
if not isinstance(raw, dict):
|
|
197
|
+
raise CliConfigError(f"Source `{name}` must be a mapping")
|
|
198
|
+
data: dict[str, Any] = dict(raw)
|
|
199
|
+
unknown = sorted(set(data) - _SOURCE_ALLOWED_KEYS)
|
|
200
|
+
if unknown:
|
|
201
|
+
joined = ", ".join(unknown)
|
|
202
|
+
raise CliConfigError(f"Unknown key(s) in source `{name}`: {joined}")
|
|
203
|
+
dialect = data.get("dialect")
|
|
204
|
+
if not isinstance(dialect, str) or not dialect.strip():
|
|
205
|
+
raise CliConfigError(f"Source `{name}` requires a `dialect` string")
|
|
206
|
+
port = data.get("port")
|
|
207
|
+
if port is not None:
|
|
208
|
+
if isinstance(port, bool) or not isinstance(port, int):
|
|
209
|
+
raise CliConfigError(f"Source `{name}`: `port` must be an integer")
|
|
210
|
+
str_fields = {
|
|
211
|
+
"host",
|
|
212
|
+
"user",
|
|
213
|
+
"password",
|
|
214
|
+
"database",
|
|
215
|
+
"account",
|
|
216
|
+
"schema",
|
|
217
|
+
"warehouse",
|
|
218
|
+
"role",
|
|
219
|
+
"private_key_path",
|
|
220
|
+
"private_key_passphrase",
|
|
221
|
+
"project",
|
|
222
|
+
"dataset",
|
|
223
|
+
"location",
|
|
224
|
+
}
|
|
225
|
+
kwargs: dict[str, Any] = {"dialect": dialect.strip().lower(), "port": port}
|
|
226
|
+
for key in str_fields:
|
|
227
|
+
val = data.get(key)
|
|
228
|
+
if val is None:
|
|
229
|
+
kwargs[key] = None
|
|
230
|
+
elif not isinstance(val, str):
|
|
231
|
+
raise CliConfigError(f"Source `{name}`: `{key}` must be a string")
|
|
232
|
+
else:
|
|
233
|
+
kwargs[key] = val.strip() or None
|
|
234
|
+
return SourceConfig(**kwargs)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _parse_sources(
|
|
238
|
+
raw: Any,
|
|
239
|
+
secrets_raw: dict[str, Any] | None,
|
|
240
|
+
) -> dict[str, SourceConfig]:
|
|
241
|
+
"""Parse the ``sources:`` block and merge optional secrets overlay."""
|
|
242
|
+
mapping = _as_mapping(raw, field="sources")
|
|
243
|
+
if not mapping:
|
|
244
|
+
return {}
|
|
245
|
+
sources: dict[str, SourceConfig] = {}
|
|
246
|
+
for name, block in mapping.items():
|
|
247
|
+
if not isinstance(name, str) or not name.strip():
|
|
248
|
+
raise CliConfigError("Source names must be non-empty strings")
|
|
249
|
+
merged = dict(block) if isinstance(block, dict) else block
|
|
250
|
+
if secrets_raw and name in secrets_raw:
|
|
251
|
+
secret_block = secrets_raw[name]
|
|
252
|
+
if not isinstance(secret_block, dict):
|
|
253
|
+
raise CliConfigError(
|
|
254
|
+
f"Secrets entry for source `{name}` must be a mapping"
|
|
255
|
+
)
|
|
256
|
+
if isinstance(merged, dict):
|
|
257
|
+
merged = {**merged, **secret_block}
|
|
258
|
+
sources[name.strip()] = _source_config_from_mapping(name.strip(), merged)
|
|
259
|
+
return sources
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _load_secrets_file(base_dir: Path) -> dict[str, Any] | None:
|
|
263
|
+
"""Load the optional ``sources.secrets.yaml`` sibling to the config file."""
|
|
264
|
+
secrets_path = base_dir / "sources.secrets.yaml"
|
|
265
|
+
if not secrets_path.exists():
|
|
266
|
+
return None
|
|
267
|
+
return _parse_yaml_file(secrets_path)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _parse_yaml_file(path: Path) -> dict[str, Any]:
|
|
271
|
+
yaml = YAML(typ="safe")
|
|
272
|
+
try:
|
|
273
|
+
loaded = yaml.load(path.read_text(encoding="utf-8"))
|
|
274
|
+
except OSError as exc:
|
|
275
|
+
raise CliConfigError(f"Cannot read config file {path}: {exc}") from exc
|
|
276
|
+
except Exception as exc:
|
|
277
|
+
raise CliConfigError(f"Invalid YAML in config file {path}: {exc}") from exc
|
|
278
|
+
if loaded is None:
|
|
279
|
+
return {}
|
|
280
|
+
if not isinstance(loaded, dict):
|
|
281
|
+
raise CliConfigError("Config file root must be a mapping")
|
|
282
|
+
return dict(loaded)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def load_cli_config(config_path: Path | None) -> PretensorCliConfig:
|
|
286
|
+
"""Load and normalize CLI defaults from YAML."""
|
|
287
|
+
target = (config_path or DEFAULT_CONFIG_PATH).expanduser()
|
|
288
|
+
explicit = config_path is not None
|
|
289
|
+
if not target.exists():
|
|
290
|
+
if explicit:
|
|
291
|
+
raise CliConfigError(f"Config file not found: {target}")
|
|
292
|
+
return PretensorCliConfig()
|
|
293
|
+
|
|
294
|
+
source = target.resolve()
|
|
295
|
+
base_dir = source.parent
|
|
296
|
+
raw = _parse_yaml_file(source)
|
|
297
|
+
|
|
298
|
+
state_dir = _resolved_optional_path(
|
|
299
|
+
raw.get("state_dir"),
|
|
300
|
+
field="state_dir",
|
|
301
|
+
base_dir=base_dir,
|
|
302
|
+
)
|
|
303
|
+
graph = _graph_config_from_mapping(raw.get("graph"))
|
|
304
|
+
|
|
305
|
+
llm_raw = _as_mapping(raw.get("llm"), field="llm")
|
|
306
|
+
llm = LlmDefaults(
|
|
307
|
+
model=_normalized_optional_str(llm_raw.get("model"), field="llm.model"),
|
|
308
|
+
budget_usd=_normalized_optional_float(
|
|
309
|
+
llm_raw.get("budget_usd"),
|
|
310
|
+
field="llm.budget_usd",
|
|
311
|
+
),
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
visibility_raw = _as_mapping(raw.get("visibility"), field="visibility")
|
|
315
|
+
visibility = VisibilityDefaults(
|
|
316
|
+
path=_resolved_optional_path(
|
|
317
|
+
visibility_raw.get("path"),
|
|
318
|
+
field="visibility.path",
|
|
319
|
+
base_dir=base_dir,
|
|
320
|
+
),
|
|
321
|
+
profile=_normalized_optional_str(
|
|
322
|
+
visibility_raw.get("profile"),
|
|
323
|
+
field="visibility.profile",
|
|
324
|
+
),
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
conn_raw = _as_mapping(raw.get("connection_defaults"), field="connection_defaults")
|
|
328
|
+
connection_defaults = ConnectionDefaults(
|
|
329
|
+
name=_normalized_optional_str(
|
|
330
|
+
conn_raw.get("name"),
|
|
331
|
+
field="connection_defaults.name",
|
|
332
|
+
),
|
|
333
|
+
dialect=_normalized_optional_str(
|
|
334
|
+
conn_raw.get("dialect"),
|
|
335
|
+
field="connection_defaults.dialect",
|
|
336
|
+
),
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
secrets_raw = _load_secrets_file(base_dir)
|
|
340
|
+
sources = _parse_sources(raw.get("sources"), secrets_raw)
|
|
341
|
+
|
|
342
|
+
return PretensorCliConfig(
|
|
343
|
+
source_path=source,
|
|
344
|
+
state_dir=state_dir,
|
|
345
|
+
graph=graph,
|
|
346
|
+
llm=llm,
|
|
347
|
+
visibility=visibility,
|
|
348
|
+
connection_defaults=connection_defaults,
|
|
349
|
+
sources=sources,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def get_cli_config(ctx: typer.Context | None) -> PretensorCliConfig:
|
|
354
|
+
"""Return config stored in Typer context, or an empty config."""
|
|
355
|
+
if ctx is None:
|
|
356
|
+
return PretensorCliConfig()
|
|
357
|
+
obj = ctx.obj
|
|
358
|
+
if isinstance(obj, PretensorCliConfig):
|
|
359
|
+
return obj
|
|
360
|
+
if isinstance(obj, dict):
|
|
361
|
+
cfg = obj.get("config")
|
|
362
|
+
if isinstance(cfg, PretensorCliConfig):
|
|
363
|
+
return cfg
|
|
364
|
+
return PretensorCliConfig()
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _is_user_override(ctx: typer.Context | None, param_name: str) -> bool:
|
|
368
|
+
if ctx is None:
|
|
369
|
+
return False
|
|
370
|
+
try:
|
|
371
|
+
source = ctx.get_parameter_source(param_name)
|
|
372
|
+
except Exception:
|
|
373
|
+
return False
|
|
374
|
+
if source is None:
|
|
375
|
+
return False
|
|
376
|
+
return source is not ParameterSource.DEFAULT
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def resolve_path_option(
|
|
380
|
+
ctx: typer.Context | None,
|
|
381
|
+
*,
|
|
382
|
+
param_name: str,
|
|
383
|
+
cli_value: Path,
|
|
384
|
+
config_value: Path | None,
|
|
385
|
+
) -> Path:
|
|
386
|
+
"""Return effective path option with ``CLI > config > default`` precedence."""
|
|
387
|
+
if _is_user_override(ctx, param_name):
|
|
388
|
+
return cli_value
|
|
389
|
+
return config_value or cli_value
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def resolve_aliased_path_option(
|
|
393
|
+
ctx: typer.Context | None,
|
|
394
|
+
*,
|
|
395
|
+
primary_param: str,
|
|
396
|
+
primary_value: Path,
|
|
397
|
+
alias_param: str,
|
|
398
|
+
alias_value: Path | None,
|
|
399
|
+
config_value: Path | None,
|
|
400
|
+
) -> Path:
|
|
401
|
+
"""Resolve a path option that has a hidden, deprecated alias flag.
|
|
402
|
+
|
|
403
|
+
Precedence: explicit ``primary_param`` > explicit ``alias_param`` > config
|
|
404
|
+
> ``primary_value`` default. If both flags are passed explicitly, the
|
|
405
|
+
primary (canonical) one wins.
|
|
406
|
+
"""
|
|
407
|
+
if _is_user_override(ctx, primary_param):
|
|
408
|
+
return primary_value
|
|
409
|
+
if _is_user_override(ctx, alias_param) and alias_value is not None:
|
|
410
|
+
return alias_value
|
|
411
|
+
return config_value if config_value is not None else primary_value
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def resolve_optional_path_option(
|
|
415
|
+
ctx: typer.Context | None,
|
|
416
|
+
*,
|
|
417
|
+
param_name: str,
|
|
418
|
+
cli_value: Path | None,
|
|
419
|
+
config_value: Path | None,
|
|
420
|
+
) -> Path | None:
|
|
421
|
+
"""Return effective optional path with ``CLI > config > default`` precedence."""
|
|
422
|
+
if _is_user_override(ctx, param_name):
|
|
423
|
+
return cli_value
|
|
424
|
+
return config_value if config_value is not None else cli_value
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def resolve_optional_str_option(
|
|
428
|
+
ctx: typer.Context | None,
|
|
429
|
+
*,
|
|
430
|
+
param_name: str,
|
|
431
|
+
cli_value: str | None,
|
|
432
|
+
config_value: str | None,
|
|
433
|
+
) -> str | None:
|
|
434
|
+
"""Return effective optional string with ``CLI > config > default`` precedence."""
|
|
435
|
+
if _is_user_override(ctx, param_name):
|
|
436
|
+
return cli_value
|
|
437
|
+
return config_value if config_value is not None else cli_value
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def resolve_graph_config(ctx: typer.Context | None) -> GraphConfig:
|
|
441
|
+
"""Return graph config from context, falling back to OSS defaults."""
|
|
442
|
+
return get_cli_config(ctx).graph
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Shared CLI literals (state dir, registry filenames, graph layout)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
DEFAULT_STATE_DIR = Path(".pretensor")
|
|
8
|
+
GRAPHS_SUBDIR = "graphs"
|
|
9
|
+
REGISTRY_FILENAME = "registry.json"
|
|
10
|
+
UNIFIED_GRAPH_BASENAME = "unified.kuzu"
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Shared dbt manifest / sources path handling for CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from pretensor.core.store import KuzuStore
|
|
11
|
+
from pretensor.enrichment.dbt.manifest import DbtManifest, DbtManifestError
|
|
12
|
+
from pretensor.enrichment.dbt.pipeline import run_dbt_enrichment_from_manifest
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def resolve_dbt_sources_path(
|
|
16
|
+
manifest_path: Path, explicit: Path | None
|
|
17
|
+
) -> Path | None:
|
|
18
|
+
"""Return path to ``sources.json`` for freshness signals.
|
|
19
|
+
|
|
20
|
+
If ``explicit`` is set, it must exist as a file. If omitted, use
|
|
21
|
+
``<manifest_dir>/sources.json`` when that file exists.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
manifest_path: Resolved manifest path (must exist when called for defaulting).
|
|
25
|
+
explicit: User-provided ``--dbt-sources`` or ``None``.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Path to read, or ``None`` when no sources file should be used.
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
FileNotFoundError: When ``explicit`` is set but is not a file.
|
|
32
|
+
"""
|
|
33
|
+
if explicit is not None:
|
|
34
|
+
if not explicit.is_file():
|
|
35
|
+
msg = f"dbt sources file not found or not a file: {explicit}"
|
|
36
|
+
raise FileNotFoundError(msg)
|
|
37
|
+
return explicit
|
|
38
|
+
default = manifest_path.parent / "sources.json"
|
|
39
|
+
return default if default.is_file() else None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def validate_dbt_manifest_path(path: Path) -> None:
|
|
43
|
+
"""Ensure ``--dbt-manifest`` points at an existing file before heavy work."""
|
|
44
|
+
if not path.is_file():
|
|
45
|
+
msg = f"dbt manifest not found or not a file: {path}"
|
|
46
|
+
raise FileNotFoundError(msg)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def preload_dbt_manifest(
|
|
50
|
+
manifest_path: Path, sources_explicit: Path | None, *, console: Console
|
|
51
|
+
) -> tuple[DbtManifest, Path | None]:
|
|
52
|
+
"""Validate and parse the manifest (and ``sources.json``) up front.
|
|
53
|
+
|
|
54
|
+
Running this before any graph mutations means a malformed ``manifest.json``
|
|
55
|
+
aborts the command *before* the DB is re-introspected and patched, instead
|
|
56
|
+
of leaving a half-updated graph that then fails during enrichment.
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
typer.Exit: With exit code 1 if either file is missing or the manifest
|
|
60
|
+
cannot be parsed.
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
validate_dbt_manifest_path(manifest_path)
|
|
64
|
+
sources = resolve_dbt_sources_path(manifest_path, sources_explicit)
|
|
65
|
+
manifest = DbtManifest.load(manifest_path)
|
|
66
|
+
except DbtManifestError as exc:
|
|
67
|
+
console.print(f"[red]{exc}[/red]")
|
|
68
|
+
raise typer.Exit(1) from exc
|
|
69
|
+
except FileNotFoundError as exc:
|
|
70
|
+
console.print(f"[red]{exc}[/red]")
|
|
71
|
+
raise typer.Exit(1) from exc
|
|
72
|
+
return manifest, sources
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def apply_dbt_enrichment_cli(
|
|
76
|
+
*,
|
|
77
|
+
manifest: DbtManifest,
|
|
78
|
+
sources_path: Path | None,
|
|
79
|
+
store: KuzuStore,
|
|
80
|
+
connection_name: str,
|
|
81
|
+
console: Console,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Run dbt enrichment on an already-parsed manifest and print a summary line."""
|
|
84
|
+
summary = run_dbt_enrichment_from_manifest(
|
|
85
|
+
manifest, sources_path, store, connection_name
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
console.print(
|
|
89
|
+
"[green]dbt enrichment:[/green] "
|
|
90
|
+
f"lineage_edges={summary.lineage_edges} "
|
|
91
|
+
f"tables_enriched={summary.tables_enriched} "
|
|
92
|
+
f"tags_set={summary.tags_set} "
|
|
93
|
+
f"exposures_marked={summary.exposures_marked} "
|
|
94
|
+
f"freshness_rows={summary.freshness_rows_applied} "
|
|
95
|
+
f"tests_counted={summary.tests_counted}"
|
|
96
|
+
)
|
pretensor/cli/main.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Typer entrypoint for ``pretensor``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError
|
|
6
|
+
from importlib.metadata import version as _pkg_version
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
from pretensor.benchmark import register_benchmark_command
|
|
13
|
+
from pretensor.cli.commands.analyze import register_analyze_command
|
|
14
|
+
from pretensor.cli.commands.connections import register_connection_commands
|
|
15
|
+
from pretensor.cli.commands.export import register_export_command
|
|
16
|
+
from pretensor.cli.commands.index import register_index_command
|
|
17
|
+
from pretensor.cli.commands.list import register_list_command
|
|
18
|
+
from pretensor.cli.commands.quickstart import register_quickstart_command
|
|
19
|
+
from pretensor.cli.commands.reindex import register_reindex_command
|
|
20
|
+
from pretensor.cli.commands.semantic import register_semantic_commands
|
|
21
|
+
from pretensor.cli.commands.serve import register_serve_command
|
|
22
|
+
from pretensor.cli.commands.sync_grants import register_sync_grants_command
|
|
23
|
+
from pretensor.cli.commands.validate import register_validate_command
|
|
24
|
+
from pretensor.cli.config_file import CliConfigError, load_cli_config
|
|
25
|
+
from pretensor.cli.plugin import discover_cli_plugins
|
|
26
|
+
from pretensor.observability import LogFormat, LogLevel, configure_logging
|
|
27
|
+
|
|
28
|
+
app = typer.Typer(
|
|
29
|
+
no_args_is_help=True,
|
|
30
|
+
add_completion=False,
|
|
31
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _version_callback(value: bool) -> None:
|
|
36
|
+
if not value:
|
|
37
|
+
return
|
|
38
|
+
try:
|
|
39
|
+
ver = _pkg_version("pretensor")
|
|
40
|
+
except PackageNotFoundError:
|
|
41
|
+
ver = "unknown"
|
|
42
|
+
typer.echo(f"pretensor {ver}")
|
|
43
|
+
raise typer.Exit()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@app.callback()
|
|
47
|
+
def configure_root_logging(
|
|
48
|
+
ctx: typer.Context,
|
|
49
|
+
version: bool = typer.Option(
|
|
50
|
+
False,
|
|
51
|
+
"--version",
|
|
52
|
+
callback=_version_callback,
|
|
53
|
+
is_eager=True,
|
|
54
|
+
help="Show the pretensor version and exit.",
|
|
55
|
+
),
|
|
56
|
+
log_level: LogLevel = typer.Option(
|
|
57
|
+
"warning",
|
|
58
|
+
"--log-level",
|
|
59
|
+
help="Logging level: debug, info, warning, error.",
|
|
60
|
+
),
|
|
61
|
+
log_format: LogFormat = typer.Option(
|
|
62
|
+
"text",
|
|
63
|
+
"--log-format",
|
|
64
|
+
help="Log output format: text or json.",
|
|
65
|
+
),
|
|
66
|
+
log_file: Path | None = typer.Option(
|
|
67
|
+
None,
|
|
68
|
+
"--log-file",
|
|
69
|
+
help="Optional file path for duplicate log output.",
|
|
70
|
+
dir_okay=False,
|
|
71
|
+
writable=True,
|
|
72
|
+
resolve_path=True,
|
|
73
|
+
),
|
|
74
|
+
config: Path | None = typer.Option(
|
|
75
|
+
None,
|
|
76
|
+
"--config",
|
|
77
|
+
help="Optional path to pretensor CLI YAML config.",
|
|
78
|
+
dir_okay=False,
|
|
79
|
+
file_okay=True,
|
|
80
|
+
resolve_path=True,
|
|
81
|
+
),
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Configure process-wide logging for all CLI subcommands."""
|
|
84
|
+
configure_logging(level=log_level, log_format=log_format, log_file=log_file)
|
|
85
|
+
try:
|
|
86
|
+
cli_config = load_cli_config(config)
|
|
87
|
+
except CliConfigError as exc:
|
|
88
|
+
Console().print(f"[red]{exc}[/red]")
|
|
89
|
+
raise typer.Exit(1) from exc
|
|
90
|
+
ctx.obj = {"config": cli_config}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
register_serve_command(app)
|
|
94
|
+
register_sync_grants_command(app)
|
|
95
|
+
console = Console()
|
|
96
|
+
|
|
97
|
+
register_connection_commands(app)
|
|
98
|
+
|
|
99
|
+
register_benchmark_command(app)
|
|
100
|
+
register_index_command(app, console=console)
|
|
101
|
+
register_analyze_command(app, console=console)
|
|
102
|
+
register_list_command(app, console=console)
|
|
103
|
+
register_quickstart_command(app, console=console)
|
|
104
|
+
register_reindex_command(app, console=console)
|
|
105
|
+
register_export_command(app, console=console)
|
|
106
|
+
register_semantic_commands(app, console=console)
|
|
107
|
+
register_validate_command(app, console=console)
|
|
108
|
+
|
|
109
|
+
discover_cli_plugins(app)
|
pretensor/cli/paths.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Path helpers for CLI state directory layout."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pretensor.cli import constants as c
|
|
8
|
+
from pretensor.introspection.models.config import DatabaseType
|
|
9
|
+
from pretensor.introspection.models.dsn import (
|
|
10
|
+
connection_config_from_url,
|
|
11
|
+
infer_database_type_from_dsn,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def default_connection_name(dsn: str) -> str:
|
|
16
|
+
"""Use the database name from the URL when present, else ``default``."""
|
|
17
|
+
raw = dsn.strip()
|
|
18
|
+
try:
|
|
19
|
+
if infer_database_type_from_dsn(raw) == DatabaseType.BIGQUERY:
|
|
20
|
+
cfg = connection_config_from_url(raw, "_tmp")
|
|
21
|
+
logical_db = (cfg.database or "").strip()
|
|
22
|
+
if "/" in logical_db:
|
|
23
|
+
return logical_db.split("/", 1)[1]
|
|
24
|
+
return logical_db or "default"
|
|
25
|
+
except ValueError:
|
|
26
|
+
pass
|
|
27
|
+
cfg = connection_config_from_url(raw, "_tmp")
|
|
28
|
+
if cfg.database:
|
|
29
|
+
return cfg.database.replace("/", "_")
|
|
30
|
+
return "default"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def graph_file_for_connection(state_dir: Path, connection_name: str) -> Path:
|
|
34
|
+
safe = connection_name.replace("/", "_")
|
|
35
|
+
return state_dir / c.GRAPHS_SUBDIR / f"{safe}.kuzu"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def unified_graph_path(state_dir: Path) -> Path:
|
|
39
|
+
return state_dir / c.GRAPHS_SUBDIR / c.UNIFIED_GRAPH_BASENAME
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def keystore_path(state_dir: Path) -> Path:
|
|
43
|
+
return state_dir / "keystore"
|