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,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"