pretensor 0.1.0a0__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 (138) hide show
  1. pretensor/__init__.py +50 -0
  2. pretensor/cli/__init__.py +1 -0
  3. pretensor/cli/commands/connections/__init__.py +7 -0
  4. pretensor/cli/commands/connections/add_remove.py +122 -0
  5. pretensor/cli/commands/connections/register.py +12 -0
  6. pretensor/cli/commands/export.py +120 -0
  7. pretensor/cli/commands/index.py +551 -0
  8. pretensor/cli/commands/list.py +65 -0
  9. pretensor/cli/commands/quickstart.py +171 -0
  10. pretensor/cli/commands/reindex.py +581 -0
  11. pretensor/cli/commands/semantic.py +190 -0
  12. pretensor/cli/commands/serve.py +129 -0
  13. pretensor/cli/commands/sync_grants.py +149 -0
  14. pretensor/cli/commands/validate.py +156 -0
  15. pretensor/cli/config_file.py +412 -0
  16. pretensor/cli/constants.py +10 -0
  17. pretensor/cli/dbt_enrichment.py +96 -0
  18. pretensor/cli/main.py +81 -0
  19. pretensor/cli/paths.py +43 -0
  20. pretensor/cli/plugin.py +52 -0
  21. pretensor/config.py +145 -0
  22. pretensor/connectors/SKILL.md +16 -0
  23. pretensor/connectors/__init__.py +29 -0
  24. pretensor/connectors/base.py +165 -0
  25. pretensor/connectors/bigquery.py +463 -0
  26. pretensor/connectors/inspect.py +321 -0
  27. pretensor/connectors/lineage_sqlglot.py +91 -0
  28. pretensor/connectors/models.py +130 -0
  29. pretensor/connectors/pg_array_parse.py +53 -0
  30. pretensor/connectors/postgres.py +921 -0
  31. pretensor/connectors/registry.py +51 -0
  32. pretensor/connectors/snapshot.py +244 -0
  33. pretensor/connectors/snowflake.py +840 -0
  34. pretensor/core/__init__.py +1 -0
  35. pretensor/core/builder.py +268 -0
  36. pretensor/core/dsn_crypto.py +40 -0
  37. pretensor/core/ids.py +68 -0
  38. pretensor/core/portable_export.py +253 -0
  39. pretensor/core/registry.py +204 -0
  40. pretensor/core/schema.py +368 -0
  41. pretensor/core/store.py +1343 -0
  42. pretensor/enrichment/__init__.py +1 -0
  43. pretensor/enrichment/dbt/__init__.py +30 -0
  44. pretensor/enrichment/dbt/lineage.py +100 -0
  45. pretensor/enrichment/dbt/manifest.py +298 -0
  46. pretensor/enrichment/dbt/metadata.py +263 -0
  47. pretensor/enrichment/dbt/pipeline.py +77 -0
  48. pretensor/enrichment/dbt/resolution.py +101 -0
  49. pretensor/enrichment/dbt/signals.py +305 -0
  50. pretensor/entities/__init__.py +27 -0
  51. pretensor/entities/builder.py +63 -0
  52. pretensor/entities/classifier.py +359 -0
  53. pretensor/entities/llm_extract.py +66 -0
  54. pretensor/graph_models/__init__.py +14 -0
  55. pretensor/graph_models/base.py +11 -0
  56. pretensor/graph_models/edge.py +35 -0
  57. pretensor/graph_models/entity.py +21 -0
  58. pretensor/graph_models/node.py +75 -0
  59. pretensor/graph_models/relationship.py +27 -0
  60. pretensor/intelligence/__init__.py +33 -0
  61. pretensor/intelligence/cluster_labeler.py +335 -0
  62. pretensor/intelligence/clustering.py +162 -0
  63. pretensor/intelligence/combining.py +32 -0
  64. pretensor/intelligence/discovery.py +114 -0
  65. pretensor/intelligence/embeddings.py +198 -0
  66. pretensor/intelligence/graph_export.py +139 -0
  67. pretensor/intelligence/heuristic.py +540 -0
  68. pretensor/intelligence/join_paths/__init__.py +130 -0
  69. pretensor/intelligence/join_paths/on_demand.py +516 -0
  70. pretensor/intelligence/join_paths/storage.py +70 -0
  71. pretensor/intelligence/llm_infer.py +78 -0
  72. pretensor/intelligence/llm_runtime.py +60 -0
  73. pretensor/intelligence/metric_templates.py +193 -0
  74. pretensor/intelligence/pipeline.py +254 -0
  75. pretensor/intelligence/schema_classification.py +306 -0
  76. pretensor/intelligence/scoring.py +66 -0
  77. pretensor/intelligence/shadow_alias.py +101 -0
  78. pretensor/intelligence/statistical.py +50 -0
  79. pretensor/intelligence/steps.py +161 -0
  80. pretensor/introspection/__init__.py +6 -0
  81. pretensor/introspection/inspector.py +5 -0
  82. pretensor/introspection/models/__init__.py +0 -0
  83. pretensor/introspection/models/base.py +5 -0
  84. pretensor/introspection/models/config.py +236 -0
  85. pretensor/introspection/models/dsn.py +442 -0
  86. pretensor/introspection/models/plan.py +116 -0
  87. pretensor/introspection/models/schema.py +10 -0
  88. pretensor/introspection/models/semantic.py +121 -0
  89. pretensor/introspection/models/validation.py +116 -0
  90. pretensor/introspection/snapshot.py +46 -0
  91. pretensor/mcp/__init__.py +16 -0
  92. pretensor/mcp/config_json.py +24 -0
  93. pretensor/mcp/payload_types.py +248 -0
  94. pretensor/mcp/resources/__init__.py +19 -0
  95. pretensor/mcp/resources/markdown.py +420 -0
  96. pretensor/mcp/server.py +723 -0
  97. pretensor/mcp/service.py +47 -0
  98. pretensor/mcp/service_context.py +126 -0
  99. pretensor/mcp/service_registry.py +242 -0
  100. pretensor/mcp/tool_registry.py +126 -0
  101. pretensor/mcp/tools/__init__.py +1 -0
  102. pretensor/mcp/tools/compile_metric.py +87 -0
  103. pretensor/mcp/tools/context.py +894 -0
  104. pretensor/mcp/tools/cypher.py +333 -0
  105. pretensor/mcp/tools/detect_changes.py +226 -0
  106. pretensor/mcp/tools/impact.py +165 -0
  107. pretensor/mcp/tools/list.py +110 -0
  108. pretensor/mcp/tools/schema.py +128 -0
  109. pretensor/mcp/tools/search.py +108 -0
  110. pretensor/mcp/tools/traverse.py +753 -0
  111. pretensor/mcp/tools/validate_sql.py +74 -0
  112. pretensor/observability.py +203 -0
  113. pretensor/search/__init__.py +6 -0
  114. pretensor/search/base.py +80 -0
  115. pretensor/search/index.py +435 -0
  116. pretensor/semantic/__init__.py +24 -0
  117. pretensor/semantic/base.py +123 -0
  118. pretensor/semantic/compiler.py +346 -0
  119. pretensor/semantic/yaml_layer.py +182 -0
  120. pretensor/skills/__init__.py +5 -0
  121. pretensor/skills/generator.py +235 -0
  122. pretensor/staleness/__init__.py +15 -0
  123. pretensor/staleness/graph_patcher.py +355 -0
  124. pretensor/staleness/impact_analyzer.py +162 -0
  125. pretensor/staleness/snapshot_store.py +38 -0
  126. pretensor/validation/__init__.py +9 -0
  127. pretensor/validation/query_validator.py +436 -0
  128. pretensor/visibility/__init__.py +23 -0
  129. pretensor/visibility/config.py +126 -0
  130. pretensor/visibility/filter.py +143 -0
  131. pretensor/visibility/kuzu_helpers.py +32 -0
  132. pretensor/visibility/runtime.py +36 -0
  133. pretensor/visibility/sync_grants.py +188 -0
  134. pretensor-0.1.0a0.dist-info/METADATA +175 -0
  135. pretensor-0.1.0a0.dist-info/RECORD +138 -0
  136. pretensor-0.1.0a0.dist-info/WHEEL +4 -0
  137. pretensor-0.1.0a0.dist-info/entry_points.txt +2 -0
  138. pretensor-0.1.0a0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,551 @@
1
+ """``pretensor index`` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import time
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+
11
+ import typer
12
+ from rich.console import Console
13
+
14
+ from pretensor.cli import constants as cli_constants
15
+ from pretensor.cli.config_file import (
16
+ get_cli_config,
17
+ resolve_optional_path_option,
18
+ resolve_optional_str_option,
19
+ resolve_path_option,
20
+ )
21
+ from pretensor.cli.dbt_enrichment import (
22
+ apply_dbt_enrichment_cli,
23
+ preload_dbt_manifest,
24
+ )
25
+ from pretensor.cli.paths import (
26
+ default_connection_name,
27
+ graph_file_for_connection,
28
+ keystore_path,
29
+ unified_graph_path,
30
+ )
31
+ from pretensor.connectors.inspect import inspect
32
+ from pretensor.core.builder import GraphBuilder
33
+ from pretensor.core.dsn_crypto import DSNEncryptor
34
+ from pretensor.core.registry import GraphRegistry
35
+ from pretensor.core.store import KuzuStore
36
+ from pretensor.introspection.models.dsn import (
37
+ connection_config_from_source,
38
+ connection_config_from_url,
39
+ dsn_from_source,
40
+ registry_dialect_for,
41
+ validate_source_env_vars,
42
+ )
43
+ from pretensor.observability import log_timed_operation
44
+ from pretensor.skills.generator import SkillGenerator
45
+ from pretensor.staleness.snapshot_store import SnapshotStore
46
+ from pretensor.visibility.config import load_visibility_config, merge_profile_into_base
47
+ from pretensor.visibility.filter import VisibilityFilter
48
+
49
+ _PROFILE_INDEX = os.environ.get("PRETENSOR_PROFILE_INDEX", "").lower() not in (
50
+ "",
51
+ "0",
52
+ "false",
53
+ "no",
54
+ )
55
+ logger = logging.getLogger(__name__)
56
+
57
+
58
+ def register_index_command(app: typer.Typer, *, console: Console) -> None:
59
+ @app.command("index")
60
+ def index_command(
61
+ dsn: str | None = typer.Argument(
62
+ None,
63
+ help=(
64
+ "Database DSN URL (postgresql://, postgres://, snowflake://, or bigquery://). "
65
+ "Mutually exclusive with --source and --all."
66
+ ),
67
+ ),
68
+ source: str | None = typer.Option(
69
+ None,
70
+ "--source",
71
+ "-s",
72
+ help="Named source from .pretensor/config.yaml sources: block.",
73
+ ),
74
+ all_sources: bool = typer.Option(
75
+ False,
76
+ "--all",
77
+ help="Index all sources defined in .pretensor/config.yaml.",
78
+ ),
79
+ dialect: str | None = typer.Option(
80
+ None,
81
+ "--dialect",
82
+ help=(
83
+ "Force connector: postgres, postgresql, snowflake, or bigquery "
84
+ "(overrides URL scheme). Only used with DSN argument."
85
+ ),
86
+ ),
87
+ name: str | None = typer.Option(
88
+ None,
89
+ "--name",
90
+ "-n",
91
+ help="Logical connection name (defaults to the database name from the DSN).",
92
+ ),
93
+ state_dir: Path = typer.Option(
94
+ cli_constants.DEFAULT_STATE_DIR,
95
+ "--state-dir",
96
+ help="Directory for registry.json and graph files.",
97
+ file_okay=False,
98
+ dir_okay=True,
99
+ writable=True,
100
+ resolve_path=True,
101
+ ),
102
+ unified: bool = typer.Option(
103
+ False,
104
+ "--unified",
105
+ help="Merge into a single Kuzu file (graphs/unified.kuzu) for cross-DB linking.",
106
+ ),
107
+ skills_target: str = typer.Option(
108
+ "claude",
109
+ "--skills-target",
110
+ help=(
111
+ "Where to write the generated skill: claude (.claude/skills/), "
112
+ "cursor (.cursor/rules/), all (both), or a file path."
113
+ ),
114
+ ),
115
+ visibility_file: Path | None = typer.Option(
116
+ None,
117
+ "--visibility",
118
+ help="Path to visibility.yml (default: <state-dir>/visibility.yml).",
119
+ file_okay=True,
120
+ dir_okay=False,
121
+ resolve_path=True,
122
+ ),
123
+ profile: str | None = typer.Option(
124
+ None,
125
+ "--profile",
126
+ help="Named visibility profile from visibility.yml (merged at index time).",
127
+ ),
128
+ dbt_manifest: Path | None = typer.Option(
129
+ None,
130
+ "--dbt-manifest",
131
+ help=(
132
+ "Path to dbt target/manifest.json. Run `dbt compile` or `dbt run` first "
133
+ "so the manifest exists; then lineage, descriptions/tags, and exposure "
134
+ "signals are merged into the graph after indexing."
135
+ ),
136
+ file_okay=True,
137
+ dir_okay=False,
138
+ resolve_path=True,
139
+ ),
140
+ dbt_sources: Path | None = typer.Option(
141
+ None,
142
+ "--dbt-sources",
143
+ help=(
144
+ "Path to dbt sources.json (e.g. from `dbt source freshness`). "
145
+ "Defaults to target/sources.json next to the manifest when that file exists."
146
+ ),
147
+ file_okay=True,
148
+ dir_okay=False,
149
+ resolve_path=True,
150
+ ),
151
+ ctx: typer.Context = typer.Option(
152
+ None,
153
+ hidden=True,
154
+ ),
155
+ ) -> None:
156
+ """Introspect a database and write a Kuzu graph with explicit FK edges."""
157
+ cli_config = get_cli_config(ctx)
158
+
159
+ # --- mutual exclusion -------------------------------------------------
160
+ modes = sum([dsn is not None, source is not None, all_sources])
161
+ if modes == 0:
162
+ console.print(
163
+ "[red]Provide a DSN argument, --source <name>, or --all.[/red]"
164
+ )
165
+ raise typer.Exit(1)
166
+ if modes > 1:
167
+ console.print(
168
+ "[red]DSN argument, --source, and --all are mutually exclusive.[/red]"
169
+ )
170
+ raise typer.Exit(1)
171
+
172
+ state_dir = resolve_path_option(
173
+ ctx,
174
+ param_name="state_dir",
175
+ cli_value=state_dir,
176
+ config_value=cli_config.state_dir,
177
+ )
178
+ visibility_file = resolve_optional_path_option(
179
+ ctx,
180
+ param_name="visibility_file",
181
+ cli_value=visibility_file,
182
+ config_value=cli_config.visibility.path,
183
+ )
184
+ profile = resolve_optional_str_option(
185
+ ctx,
186
+ param_name="profile",
187
+ cli_value=profile,
188
+ config_value=cli_config.visibility.profile,
189
+ )
190
+
191
+ # --- --all: iterate all configured sources ----------------------------
192
+ if all_sources:
193
+ if not cli_config.sources:
194
+ console.print(
195
+ "[red]No sources defined in config.[/red] "
196
+ "Add a `sources:` section to .pretensor/config.yaml."
197
+ )
198
+ raise typer.Exit(1)
199
+ results: list[tuple[str, bool, str]] = []
200
+ for src_name, src_cfg in cli_config.sources.items():
201
+ console.print(
202
+ f"\n[bold]{'─' * 40}[/bold]\n"
203
+ f"[bold blue]Source:[/bold blue] {src_name}\n"
204
+ )
205
+ missing_vars = validate_source_env_vars(src_cfg)
206
+ if missing_vars:
207
+ msg = f"missing env vars: {', '.join(missing_vars)}"
208
+ console.print(f"[yellow]Skipping {src_name}:[/yellow] {msg}")
209
+ logger.warning("Skipping source %s: %s", src_name, msg)
210
+ results.append((src_name, False, f"skipped ({msg})"))
211
+ continue
212
+ try:
213
+ src_config = connection_config_from_source(src_name, src_cfg)
214
+ src_dsn = dsn_from_source(src_name, src_cfg)
215
+ _run_index(
216
+ console=console,
217
+ cli_config=cli_config,
218
+ dsn=src_dsn,
219
+ connection_name=src_name,
220
+ config=src_config,
221
+ state_dir=state_dir,
222
+ unified=unified,
223
+ skills_target=skills_target,
224
+ visibility_file=visibility_file,
225
+ profile=profile,
226
+ dbt_manifest=dbt_manifest,
227
+ dbt_sources=dbt_sources,
228
+ )
229
+ results.append((src_name, True, "ok"))
230
+ except typer.Exit:
231
+ logger.warning("Source %s exited with failure", src_name)
232
+ results.append((src_name, False, "failed"))
233
+ except Exception as e:
234
+ logger.exception("Error indexing source %s", src_name)
235
+ console.print(f"[red]Error indexing {src_name}:[/red] {e}")
236
+ results.append((src_name, False, str(e)))
237
+
238
+ console.print(f"\n[bold]{'─' * 40}[/bold]")
239
+ console.print("[bold]Summary:[/bold]")
240
+ failed = 0
241
+ for src_name, ok, msg in results:
242
+ status = "[green]✓[/green]" if ok else "[red]✗[/red]"
243
+ console.print(f" {status} {src_name}: {msg}")
244
+ if not ok:
245
+ failed += 1
246
+ if failed:
247
+ raise typer.Exit(1)
248
+ return
249
+
250
+ # --- --source: single named source ------------------------------------
251
+ if source is not None:
252
+ if source not in cli_config.sources:
253
+ available = ", ".join(cli_config.sources) or "(none)"
254
+ console.print(
255
+ f"[red]Unknown source {source!r}.[/red] "
256
+ f"Available: {available}"
257
+ )
258
+ raise typer.Exit(1)
259
+ src_cfg = cli_config.sources[source]
260
+ try:
261
+ config = connection_config_from_source(source, src_cfg)
262
+ except ValueError as e:
263
+ console.print(f"[red]Invalid source config:[/red] {e}")
264
+ raise typer.Exit(1) from e
265
+ try:
266
+ dsn_str = dsn_from_source(source, src_cfg)
267
+ except ValueError as e:
268
+ console.print(f"[red]Cannot build DSN from source:[/red] {e}")
269
+ raise typer.Exit(1) from e
270
+ connection_name = source
271
+ _run_index(
272
+ console=console,
273
+ cli_config=cli_config,
274
+ dsn=dsn_str,
275
+ connection_name=connection_name,
276
+ config=config,
277
+ state_dir=state_dir,
278
+ unified=unified,
279
+ skills_target=skills_target,
280
+ visibility_file=visibility_file,
281
+ profile=profile,
282
+ dbt_manifest=dbt_manifest,
283
+ dbt_sources=dbt_sources,
284
+ )
285
+ return
286
+
287
+ # --- DSN argument (original path) -------------------------------------
288
+ assert dsn is not None
289
+ name = resolve_optional_str_option(
290
+ ctx,
291
+ param_name="name",
292
+ cli_value=name,
293
+ config_value=cli_config.connection_defaults.name,
294
+ )
295
+ dialect = resolve_optional_str_option(
296
+ ctx,
297
+ param_name="dialect",
298
+ cli_value=dialect,
299
+ config_value=cli_config.connection_defaults.dialect,
300
+ )
301
+ try:
302
+ connection_name = name or default_connection_name(dsn)
303
+ except ValueError as e:
304
+ console.print(f"[red]Invalid DSN:[/red] {e}")
305
+ raise typer.Exit(1) from e
306
+ try:
307
+ config = connection_config_from_url(
308
+ dsn, connection_name, dialect_override=dialect
309
+ )
310
+ except ValueError as e:
311
+ console.print(f"[red]Invalid DSN:[/red] {e}")
312
+ raise typer.Exit(1) from e
313
+ _run_index(
314
+ console=console,
315
+ cli_config=cli_config,
316
+ dsn=dsn,
317
+ connection_name=connection_name,
318
+ config=config,
319
+ state_dir=state_dir,
320
+ unified=unified,
321
+ skills_target=skills_target,
322
+ visibility_file=visibility_file,
323
+ profile=profile,
324
+ dbt_manifest=dbt_manifest,
325
+ dbt_sources=dbt_sources,
326
+ )
327
+
328
+
329
+ def _run_index(
330
+ *,
331
+ console: Console,
332
+ cli_config: object,
333
+ dsn: str,
334
+ connection_name: str,
335
+ config: object,
336
+ state_dir: Path,
337
+ unified: bool,
338
+ skills_target: str,
339
+ visibility_file: Path | None,
340
+ profile: str | None,
341
+ dbt_manifest: Path | None,
342
+ dbt_sources: Path | None,
343
+ ) -> None:
344
+ """Core indexing logic shared by DSN, --source, and --all paths."""
345
+ from pretensor.introspection.models.config import ConnectionConfig as _CC
346
+
347
+ assert isinstance(config, _CC)
348
+
349
+ total_started = time.perf_counter()
350
+ sd = Path(state_dir)
351
+ vis_path = (
352
+ Path(visibility_file)
353
+ if visibility_file is not None
354
+ else sd / "visibility.yml"
355
+ )
356
+ try:
357
+ vis_cfg = merge_profile_into_base(load_visibility_config(vis_path), profile)
358
+ except ValueError as e:
359
+ console.print(f"[red]{e}[/red]")
360
+ raise typer.Exit(1) from e
361
+ visibility_filter = (
362
+ VisibilityFilter.from_config(vis_cfg)
363
+ if vis_cfg.hidden_schemas
364
+ or vis_cfg.hidden_tables
365
+ or vis_cfg.hidden_columns
366
+ or vis_cfg.allowed_schemas
367
+ or vis_cfg.allowed_tables
368
+ else None
369
+ )
370
+ preloaded_manifest = None
371
+ preloaded_sources: Path | None = None
372
+ if dbt_manifest is not None:
373
+ preloaded_manifest, preloaded_sources = preload_dbt_manifest(
374
+ dbt_manifest, dbt_sources, console=console
375
+ )
376
+
377
+ try:
378
+ sd.mkdir(parents=True, exist_ok=True)
379
+ except PermissionError as e:
380
+ console.print(
381
+ f"[red]Cannot create state directory {sd}:[/red] {e}\n"
382
+ "Check write permissions or use --state-dir to specify a writable path."
383
+ )
384
+ raise typer.Exit(1) from e
385
+
386
+ console.print(f"[bold blue]Introspecting[/bold blue] '{connection_name}'...")
387
+ _t_inspect = time.perf_counter() if _PROFILE_INDEX else 0.0
388
+ try:
389
+ with log_timed_operation(
390
+ logger,
391
+ event="index.inspect",
392
+ connection_name=connection_name,
393
+ dialect=str(config.type),
394
+ ):
395
+ snapshot = inspect(config)
396
+ except Exception as e:
397
+ console.print(
398
+ f"[red]Cannot connect to database:[/red] {e}\n"
399
+ "Check that the DSN is correct and the database is reachable."
400
+ )
401
+ raise typer.Exit(1) from e
402
+ if _PROFILE_INDEX:
403
+ console.print(
404
+ f"[dim][profile] inspect: {time.perf_counter() - _t_inspect:.2f}s[/dim]"
405
+ )
406
+
407
+ graph_path = (
408
+ unified_graph_path(sd)
409
+ if unified
410
+ else graph_file_for_connection(sd, connection_name)
411
+ )
412
+ graph_path.parent.mkdir(parents=True, exist_ok=True)
413
+
414
+ if unified:
415
+ if graph_path.exists():
416
+ try:
417
+ store = KuzuStore(graph_path)
418
+ except Exception as e:
419
+ console.print(
420
+ f"[red]Cannot open graph file {graph_path}:[/red] {e}\n"
421
+ "The file may be corrupt. Delete it and re-run `pretensor index`."
422
+ )
423
+ raise typer.Exit(1) from e
424
+ try:
425
+ store.ensure_schema()
426
+ store.clear_connection_subgraph(connection_name)
427
+ finally:
428
+ store.close()
429
+ else:
430
+ if graph_path.exists():
431
+ try:
432
+ graph_path.unlink()
433
+ except PermissionError as e:
434
+ console.print(
435
+ f"[red]Cannot remove existing graph file {graph_path}:[/red] {e}"
436
+ )
437
+ raise typer.Exit(1) from e
438
+
439
+ try:
440
+ store = KuzuStore(graph_path)
441
+ except Exception as e:
442
+ console.print(
443
+ f"[red]Cannot create graph file {graph_path}:[/red] {e}\n"
444
+ "Check disk space and permissions."
445
+ )
446
+ raise typer.Exit(1) from e
447
+ try:
448
+ _t_build = time.perf_counter() if _PROFILE_INDEX else 0.0
449
+ with log_timed_operation(
450
+ logger,
451
+ event="index.graph_build",
452
+ connection_name=connection_name,
453
+ replace_mode="connection" if unified else "full",
454
+ ):
455
+ GraphBuilder().build(
456
+ snapshot,
457
+ store,
458
+ replace_mode="connection" if unified else "full",
459
+ visibility_filter=visibility_filter,
460
+ )
461
+ if _PROFILE_INDEX:
462
+ console.print(
463
+ f"[dim][profile] build (incl. intelligence layer): {time.perf_counter() - _t_build:.2f}s[/dim]"
464
+ )
465
+ if preloaded_manifest is not None:
466
+ apply_dbt_enrichment_cli(
467
+ manifest=preloaded_manifest,
468
+ sources_path=preloaded_sources,
469
+ store=store,
470
+ connection_name=connection_name,
471
+ console=console,
472
+ )
473
+ tc_rows = store.query_all_rows(
474
+ """
475
+ MATCH (t:SchemaTable {connection_name: $cn})
476
+ RETURN count(*)
477
+ """,
478
+ {"cn": connection_name},
479
+ )
480
+ table_count = (
481
+ int(tc_rows[0][0]) if tc_rows and tc_rows[0][0] is not None else 0
482
+ )
483
+ finally:
484
+ store.close()
485
+
486
+ try:
487
+ reg = GraphRegistry(sd / cli_constants.REGISTRY_FILENAME).load()
488
+ except Exception as e:
489
+ console.print(
490
+ f"[red]Cannot read registry file:[/red] {e}\n"
491
+ "The registry.json may be corrupt. Delete it and re-run `pretensor index`."
492
+ )
493
+ raise typer.Exit(1) from e
494
+ indexed_at = datetime.now(timezone.utc)
495
+ enc: DSNEncryptor | None = None
496
+ encrypt = keystore_path(sd).exists()
497
+ if encrypt:
498
+ enc = DSNEncryptor(keystore_path(sd))
499
+ reg.upsert(
500
+ connection_name=connection_name,
501
+ database=snapshot.database,
502
+ dsn=dsn,
503
+ graph_path=graph_path,
504
+ unified_graph_path=graph_path if unified else None,
505
+ indexed_at=indexed_at,
506
+ encrypt_dsn=encrypt,
507
+ encryptor=enc,
508
+ dialect=registry_dialect_for(config.type),
509
+ table_count=table_count,
510
+ dbt_manifest_path=str(dbt_manifest) if dbt_manifest is not None else None,
511
+ )
512
+ try:
513
+ reg.save()
514
+ except PermissionError as e:
515
+ console.print(
516
+ f"[red]Cannot write registry file:[/red] {e}\n"
517
+ "Check write permissions on the state directory."
518
+ )
519
+ raise typer.Exit(1) from e
520
+
521
+ snap_store = SnapshotStore(sd)
522
+ snap_store.save(connection_name, snapshot)
523
+
524
+ entry = reg.get(connection_name)
525
+ if entry is not None:
526
+ skill_store = KuzuStore(graph_path)
527
+ try:
528
+ skill_paths = SkillGenerator.write_for_index(
529
+ store=skill_store,
530
+ entry=entry,
531
+ skills_target=skills_target,
532
+ )
533
+ finally:
534
+ skill_store.close()
535
+ for p in skill_paths:
536
+ console.print(f"[green]Skill written:[/green] {p}")
537
+
538
+ console.print(f"[green]Graph written:[/green] {graph_path}")
539
+ console.print(f"[green]Registry updated:[/green] {reg.path}")
540
+ logger.info(
541
+ "index completed in %.2fms",
542
+ (time.perf_counter() - total_started) * 1000,
543
+ extra={
544
+ "event": "index.total",
545
+ "status": "ok",
546
+ "duration_ms": (time.perf_counter() - total_started) * 1000,
547
+ "connection_name": connection_name,
548
+ "unified": unified,
549
+ "table_count": table_count,
550
+ },
551
+ )
@@ -0,0 +1,65 @@
1
+ """``pretensor list`` command."""
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.cli import constants as cli_constants
11
+ from pretensor.cli.config_file import get_cli_config, resolve_path_option
12
+ from pretensor.core.registry import GraphRegistry
13
+
14
+
15
+ def register_list_command(app: typer.Typer, *, console: Console) -> None:
16
+ @app.command("list")
17
+ def list_command(
18
+ state_dir: Path = typer.Option(
19
+ cli_constants.DEFAULT_STATE_DIR,
20
+ "--state-dir",
21
+ help="Directory containing registry.json.",
22
+ file_okay=False,
23
+ dir_okay=True,
24
+ resolve_path=True,
25
+ ),
26
+ ctx: typer.Context = typer.Option(
27
+ None,
28
+ hidden=True,
29
+ ),
30
+ ) -> None:
31
+ """List indexed connections from the registry."""
32
+ cli_config = get_cli_config(ctx)
33
+ state_dir = resolve_path_option(
34
+ ctx,
35
+ param_name="state_dir",
36
+ cli_value=state_dir,
37
+ config_value=cli_config.state_dir,
38
+ )
39
+ path = state_dir / cli_constants.REGISTRY_FILENAME
40
+ if not path.exists():
41
+ console.print("[dim]No registry found.[/dim]")
42
+ raise typer.Exit(0)
43
+
44
+ try:
45
+ reg = GraphRegistry(path).load()
46
+ except Exception as e:
47
+ console.print(
48
+ f"[red]Cannot read registry file {path}:[/red] {e}\n"
49
+ "The registry.json may be corrupt. Delete it and re-run `pretensor index`."
50
+ )
51
+ raise typer.Exit(1) from e
52
+ entries = reg.list_entries()
53
+ if not entries:
54
+ console.print("[dim]Registry is empty.[/dim]")
55
+ return
56
+
57
+ for entry in entries:
58
+ ts = entry.last_indexed_at.isoformat()
59
+ tc = entry.table_count
60
+ tc_s = f" tables={tc}" if tc is not None else ""
61
+ console.print(
62
+ f"[bold]{entry.connection_name}[/bold] "
63
+ f"db={entry.database} indexed={ts}{tc_s}\n"
64
+ f" graph: {entry.unified_graph_path or entry.graph_path}"
65
+ )