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,190 @@
1
+ """``pretensor semantic`` command group — compile YAML metrics to SQL."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+
12
+ from pretensor.cli import constants as cli_constants
13
+ from pretensor.cli.config_file import get_cli_config, resolve_path_option
14
+ from pretensor.introspection.models.semantic import (
15
+ SemanticLayer as SemanticLayerModel,
16
+ )
17
+ from pretensor.mcp.service_registry import (
18
+ load_registry,
19
+ open_store_for_entry,
20
+ resolve_registry_entry,
21
+ )
22
+ from pretensor.semantic.compiler import MetricCompileError, MetricSqlCompiler
23
+
24
+ __all__ = ["register_semantic_commands"]
25
+
26
+
27
+ _EXIT_INVALID = 1
28
+ _EXIT_BAD_ARGS = 2
29
+
30
+
31
+ def register_semantic_commands(app: typer.Typer, *, console: Console) -> None:
32
+ """Register ``pretensor semantic compile`` onto ``app``."""
33
+ semantic_app = typer.Typer(
34
+ name="semantic",
35
+ help="Compile and inspect user-authored semantic layers (OSS).",
36
+ no_args_is_help=True,
37
+ )
38
+
39
+ @semantic_app.command("compile")
40
+ def compile_command(
41
+ yaml_path: Path = typer.Argument(
42
+ ...,
43
+ help="Path to a semantic layer YAML file.",
44
+ exists=True,
45
+ dir_okay=False,
46
+ resolve_path=True,
47
+ ),
48
+ metric: str = typer.Option(
49
+ ...,
50
+ "--metric",
51
+ help="Name of the metric to compile.",
52
+ ),
53
+ db: str | None = typer.Option(
54
+ None,
55
+ "--db",
56
+ help="connection_name or logical database; omit if only one is indexed.",
57
+ ),
58
+ state_dir: Path = typer.Option(
59
+ cli_constants.DEFAULT_STATE_DIR,
60
+ "--state-dir",
61
+ help="Directory containing registry.json.",
62
+ file_okay=False,
63
+ dir_okay=True,
64
+ resolve_path=True,
65
+ ),
66
+ as_json: bool = typer.Option(
67
+ False,
68
+ "--json",
69
+ help="Emit machine-readable JSON instead of a rich panel.",
70
+ ),
71
+ ctx: typer.Context = typer.Option(None, hidden=True),
72
+ ) -> None:
73
+ """Compile a YAML metric to SQL and validate it against the graph."""
74
+ cli_config = get_cli_config(ctx)
75
+ state_dir = resolve_path_option(
76
+ ctx,
77
+ param_name="state_dir",
78
+ cli_value=state_dir,
79
+ config_value=cli_config.state_dir,
80
+ )
81
+
82
+ reg = load_registry(state_dir)
83
+ entry = resolve_registry_entry(reg, db)
84
+ if entry is None:
85
+ msg = (
86
+ f"no registry entry matches --db {db!r}"
87
+ if db is not None
88
+ else "multiple indexed databases — pass --db"
89
+ )
90
+ console.print(f"[red]{msg}[/red]")
91
+ raise typer.Exit(_EXIT_BAD_ARGS)
92
+
93
+ try:
94
+ layer = SemanticLayerModel.from_yaml(
95
+ yaml_path.read_text(encoding="utf-8")
96
+ )
97
+ except Exception as exc:
98
+ console.print(f"[red]Failed to parse {yaml_path}:[/red] {exc}")
99
+ raise typer.Exit(_EXIT_BAD_ARGS) from exc
100
+
101
+ if layer.connection_name != entry.connection_name and (
102
+ layer.connection_name != entry.database
103
+ ):
104
+ console.print(
105
+ f"[yellow]Warning: YAML connection_name {layer.connection_name!r} "
106
+ f"does not match registry entry "
107
+ f"({entry.connection_name!r} / {entry.database!r}).[/yellow]"
108
+ )
109
+
110
+ store = open_store_for_entry(entry)
111
+ try:
112
+ compiler = MetricSqlCompiler(
113
+ store,
114
+ connection_name=entry.connection_name,
115
+ database_key=entry.database,
116
+ )
117
+ try:
118
+ compiled = compiler.compile(layer, metric)
119
+ except MetricCompileError as exc:
120
+ if as_json:
121
+ typer.echo(json.dumps({"error": str(exc)}))
122
+ else:
123
+ console.print(f"[red]Compile error:[/red] {exc}")
124
+ raise typer.Exit(_EXIT_BAD_ARGS) from exc
125
+
126
+ if as_json:
127
+ payload: dict[str, object] = {
128
+ "metric": compiled.metric,
129
+ "entity": compiled.entity,
130
+ "sql": compiled.sql,
131
+ "dialect": compiled.dialect,
132
+ "warnings": compiled.warnings,
133
+ "valid": compiled.validation.valid,
134
+ "syntax_errors": compiled.validation.syntax_errors,
135
+ "missing_tables": compiled.validation.missing_tables,
136
+ "missing_columns": compiled.validation.missing_columns,
137
+ "invalid_joins": [
138
+ {
139
+ "message": j.message,
140
+ "left_table": j.left_table,
141
+ "right_table": j.right_table,
142
+ }
143
+ for j in compiled.validation.invalid_joins
144
+ ],
145
+ "suggestions": compiled.validation.suggestions,
146
+ }
147
+ typer.echo(json.dumps(payload, indent=2))
148
+ else:
149
+ valid_label = (
150
+ "[green]valid[/green]"
151
+ if compiled.validation.valid
152
+ else "[red]invalid[/red]"
153
+ )
154
+ lines = [
155
+ f"[bold]Entity:[/bold] {compiled.entity}",
156
+ f"[bold]Dialect:[/bold] {compiled.dialect}",
157
+ f"[bold]Valid:[/bold] {valid_label}",
158
+ "",
159
+ "[bold]SQL[/bold]",
160
+ compiled.sql,
161
+ ]
162
+ if compiled.warnings:
163
+ lines.append("")
164
+ lines.append("[bold]Warnings[/bold]")
165
+ lines.extend(f" - {w}" for w in compiled.warnings)
166
+ if not compiled.validation.valid:
167
+ lines.append("")
168
+ lines.append("[bold]Validation errors[/bold]")
169
+ for err in compiled.validation.syntax_errors:
170
+ lines.append(f" - syntax: {err}")
171
+ for t in compiled.validation.missing_tables:
172
+ lines.append(f" - missing_table: {t}")
173
+ for c in compiled.validation.missing_columns:
174
+ lines.append(f" - missing_column: {c}")
175
+ for j in compiled.validation.invalid_joins:
176
+ lines.append(f" - invalid_join: {j.message}")
177
+ if compiled.validation.suggestions:
178
+ lines.append("")
179
+ lines.append("[bold]Suggestions[/bold]")
180
+ lines.extend(f" - {s}" for s in compiled.validation.suggestions)
181
+ console.print(
182
+ Panel("\n".join(lines), title=f"metric: {compiled.metric}")
183
+ )
184
+
185
+ if not compiled.validation.valid:
186
+ raise typer.Exit(_EXIT_INVALID)
187
+ finally:
188
+ store.close()
189
+
190
+ app.add_typer(semantic_app)
@@ -0,0 +1,144 @@
1
+ """``pretensor serve`` — MCP stdio server + client config snippet."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ from rich.console import Console
10
+
11
+ from pretensor.cli.config_file import (
12
+ get_cli_config,
13
+ resolve_aliased_path_option,
14
+ resolve_optional_path_option,
15
+ resolve_optional_str_option,
16
+ )
17
+ from pretensor.config import PretensorConfig
18
+ from pretensor.mcp.server import print_mcp_config, run_server
19
+ from pretensor.mcp.service import mcp_config_json
20
+ from pretensor.visibility.config import default_visibility_path
21
+
22
+ console = Console()
23
+
24
+
25
+ def register_serve_command(app: typer.Typer) -> None:
26
+ """Attach ``serve`` to the root Typer app."""
27
+
28
+ @app.command("serve")
29
+ def serve_command(
30
+ state_dir: Path = typer.Option(
31
+ Path(".pretensor"),
32
+ "--state-dir",
33
+ help="State directory (registry.json, graphs/, search index).",
34
+ file_okay=False,
35
+ dir_okay=True,
36
+ resolve_path=True,
37
+ ),
38
+ graph_dir: Path | None = typer.Option(
39
+ None,
40
+ "--graph-dir",
41
+ hidden=True,
42
+ help="Deprecated alias for --state-dir.",
43
+ file_okay=False,
44
+ dir_okay=True,
45
+ resolve_path=True,
46
+ ),
47
+ visibility_file: Path | None = typer.Option(
48
+ None,
49
+ "--visibility",
50
+ help="Path to visibility.yml (default: <graph-dir>/visibility.yml).",
51
+ file_okay=True,
52
+ dir_okay=False,
53
+ resolve_path=True,
54
+ ),
55
+ profile: str | None = typer.Option(
56
+ None,
57
+ "--profile",
58
+ help="Named visibility profile from visibility.yml (serve-time filter).",
59
+ ),
60
+ config_only: bool = typer.Option(
61
+ False,
62
+ "--config-only",
63
+ help="Print mcpServers JSON to stdout and exit (for piping).",
64
+ ),
65
+ print_config: bool = typer.Option(
66
+ True,
67
+ "--print-config/--no-print-config",
68
+ help="When starting the server, also print the mcpServers JSON (to stderr by default).",
69
+ ),
70
+ ctx: typer.Context = typer.Option(None, hidden=True),
71
+ ) -> None:
72
+ """Run the Pretensor MCP server on stdio (JSON-RPC over stdout).
73
+
74
+ The MCP protocol uses stdout for JSON-RPC. The Claude/Cursor ``mcpServers``
75
+ snippet is printed to **stderr** by default so it does not corrupt the
76
+ protocol stream. Use ``--config-only`` to print the snippet to stdout and exit.
77
+ """
78
+ cli_config = get_cli_config(ctx)
79
+ state_dir = resolve_aliased_path_option(
80
+ ctx,
81
+ primary_param="state_dir",
82
+ primary_value=state_dir,
83
+ alias_param="graph_dir",
84
+ alias_value=graph_dir,
85
+ config_value=cli_config.state_dir,
86
+ ).resolve()
87
+ visibility_file = resolve_optional_path_option(
88
+ ctx,
89
+ param_name="visibility_file",
90
+ cli_value=visibility_file,
91
+ config_value=(
92
+ cli_config.visibility.path
93
+ if cli_config.visibility.path is not None
94
+ else default_visibility_path(state_dir)
95
+ ),
96
+ )
97
+ profile = resolve_optional_str_option(
98
+ ctx,
99
+ param_name="profile",
100
+ cli_value=profile,
101
+ config_value=cli_config.visibility.profile,
102
+ )
103
+ vis_path = Path(visibility_file) if visibility_file is not None else None
104
+ if profile is not None and not str(profile).strip():
105
+ profile = None
106
+ if config_only:
107
+ try:
108
+ sys.stdout.write(mcp_config_json(state_dir) + "\n")
109
+ sys.stdout.flush()
110
+ except Exception as e:
111
+ console.print(f"[red]Failed to generate config JSON:[/red] {e}")
112
+ raise typer.Exit(1) from e
113
+ return
114
+ if print_config:
115
+ try:
116
+ print_mcp_config(state_dir, stream=sys.stderr)
117
+ except Exception:
118
+ pass
119
+ Console(file=sys.stderr).print(
120
+ "[dim]MCP JSON-RPC on stdout — stderr above is config only.[/dim]",
121
+ )
122
+ # Startup/runtime errors must go to stderr: stdout is the JSON-RPC
123
+ # protocol channel, and MCP clients surface stderr — not stdout — in
124
+ # their logs, so an error printed to stdout reads as a silent crash.
125
+ err_console = Console(file=sys.stderr)
126
+ try:
127
+ run_server(
128
+ state_dir,
129
+ visibility_path=vis_path,
130
+ profile=profile,
131
+ config=PretensorConfig(graph=cli_config.graph),
132
+ )
133
+ except ValueError as e:
134
+ err_console.print(f"[red]{e}[/red]")
135
+ raise typer.Exit(1) from e
136
+ except PermissionError as e:
137
+ err_console.print(
138
+ f"[red]Permission denied accessing state directory:[/red] {e}\n"
139
+ "Check read permissions on the --state-dir path."
140
+ )
141
+ raise typer.Exit(1) from e
142
+ except Exception as e:
143
+ err_console.print(f"[red]MCP server error:[/red] {e}")
144
+ raise typer.Exit(1) from e
@@ -0,0 +1,149 @@
1
+ """``pretensor sync-grants`` — generate ``visibility.yml`` profiles from DB grants."""
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.config_file import (
11
+ get_cli_config,
12
+ resolve_optional_str_option,
13
+ resolve_path_option,
14
+ )
15
+ from pretensor.cli.paths import default_connection_name
16
+ from pretensor.connectors.registry import get_connector
17
+ from pretensor.introspection.models.config import DatabaseType
18
+ from pretensor.introspection.models.dsn import (
19
+ connection_config_from_url,
20
+ infer_database_type_from_dsn,
21
+ )
22
+ from pretensor.visibility.sync_grants import run_sync_grants
23
+
24
+ console = Console()
25
+
26
+
27
+ def _parse_roles_option(raw: str | None) -> frozenset[str] | None:
28
+ if raw is None or not str(raw).strip():
29
+ return None
30
+ parts = [p.strip() for p in str(raw).split(",")]
31
+ names = {p for p in parts if p}
32
+ return frozenset(names) if names else None
33
+
34
+
35
+ def register_sync_grants_command(app: typer.Typer) -> None:
36
+ """Attach ``sync-grants`` to the root Typer app."""
37
+
38
+ @app.command("sync-grants")
39
+ def sync_grants_command(
40
+ dsn: str = typer.Option(
41
+ ...,
42
+ "--dsn",
43
+ help="Admin-capable database URL (postgresql:// or snowflake://).",
44
+ ),
45
+ name: str | None = typer.Option(
46
+ None,
47
+ "--name",
48
+ "-n",
49
+ help=(
50
+ "Logical connection name (defaults to the database name from the DSN). "
51
+ "Must match the value used with ``pretensor index`` so generated "
52
+ "``connection::schema.table`` patterns match at serve time."
53
+ ),
54
+ ),
55
+ output: Path = typer.Option(
56
+ Path(".pretensor") / "visibility.yml",
57
+ "--output",
58
+ help="Destination visibility.yml path.",
59
+ file_okay=True,
60
+ dir_okay=False,
61
+ resolve_path=True,
62
+ ),
63
+ roles: str | None = typer.Option(
64
+ None,
65
+ "--roles",
66
+ help="Comma-separated DB role names to sync (omit to sync all grantees).",
67
+ ),
68
+ ctx: typer.Context = typer.Option(None, hidden=True),
69
+ ) -> None:
70
+ """Write or merge ``profiles`` with ``allowed_tables`` from SELECT table grants."""
71
+ cli_config = get_cli_config(ctx)
72
+ configured_output = cli_config.visibility.path
73
+ if configured_output is None and cli_config.state_dir is not None:
74
+ configured_output = cli_config.state_dir / "visibility.yml"
75
+ output = resolve_path_option(
76
+ ctx,
77
+ param_name="output",
78
+ cli_value=output,
79
+ config_value=configured_output,
80
+ )
81
+ name = resolve_optional_str_option(
82
+ ctx,
83
+ param_name="name",
84
+ cli_value=name,
85
+ config_value=cli_config.connection_defaults.name,
86
+ )
87
+ raw_dsn = dsn.strip()
88
+ try:
89
+ db_type = infer_database_type_from_dsn(raw_dsn)
90
+ except ValueError as exc:
91
+ console.print(f"[red]Invalid DSN:[/red] {exc}")
92
+ raise typer.Exit(1) from exc
93
+ if db_type == DatabaseType.BIGQUERY:
94
+ console.print(
95
+ "[red]sync-grants does not support BigQuery in v1 "
96
+ "(IAM grants are out of scope).[/red]",
97
+ )
98
+ raise typer.Exit(1)
99
+
100
+ try:
101
+ connection_name = name or default_connection_name(raw_dsn)
102
+ except ValueError as exc:
103
+ console.print(f"[red]Invalid DSN:[/red] {exc}")
104
+ raise typer.Exit(1) from exc
105
+ try:
106
+ cfg = connection_config_from_url(raw_dsn, connection_name)
107
+ except ValueError as exc:
108
+ console.print(f"[red]Invalid DSN:[/red] {exc}")
109
+ raise typer.Exit(1) from exc
110
+ roles_filter = _parse_roles_option(roles)
111
+
112
+ try:
113
+ with get_connector(cfg) as connector:
114
+ grantee_count = run_sync_grants(
115
+ connector,
116
+ output_path=output.resolve(),
117
+ connection_name=connection_name,
118
+ roles_filter=roles_filter,
119
+ )
120
+ except ValueError as exc:
121
+ console.print(f"[red]{exc}[/red]")
122
+ raise typer.Exit(1) from exc
123
+ except ImportError as exc:
124
+ console.print(f"[red]{exc}[/red]")
125
+ raise typer.Exit(1) from exc
126
+ except PermissionError as exc:
127
+ console.print(
128
+ f"[red]Cannot write visibility file {output}:[/red] {exc}\n"
129
+ "Check write permissions on the output path."
130
+ )
131
+ raise typer.Exit(1) from exc
132
+ except Exception as exc:
133
+ console.print(
134
+ f"[red]Cannot connect to database:[/red] {exc}\n"
135
+ "Check that the DSN is correct and the database is reachable."
136
+ )
137
+ raise typer.Exit(1) from exc
138
+
139
+ if grantee_count == 0:
140
+ console.print(
141
+ "[yellow]No SELECT grants returned by the connector.[/yellow] "
142
+ "The DB role used for --dsn may lack catalog access, or the database "
143
+ "has no non-PUBLIC grants. visibility.yml was still written (merged "
144
+ "with any existing base rules).",
145
+ )
146
+ console.print(
147
+ f"[green]Wrote merged visibility rules to[/green] {output.resolve()} "
148
+ f"({grantee_count} grantee profile(s))",
149
+ )
@@ -0,0 +1,176 @@
1
+ """``pretensor validate`` — sqlglot + graph SQL validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+
12
+ from pretensor.cli import constants as cli_constants
13
+ from pretensor.cli.config_file import get_cli_config, resolve_aliased_path_option
14
+ from pretensor.mcp.service_registry import (
15
+ load_registry,
16
+ open_store_for_entry,
17
+ resolve_registry_entry,
18
+ )
19
+ from pretensor.validation.query_validator import QueryValidator
20
+
21
+ __all__ = ["register_validate_command"]
22
+
23
+
24
+ _EXIT_INVALID = 1
25
+ _EXIT_BAD_ARGS = 2
26
+
27
+
28
+ def register_validate_command(app: typer.Typer, *, console: Console) -> None:
29
+ """Register ``pretensor validate`` onto ``app``."""
30
+
31
+ @app.command("validate")
32
+ def validate_command(
33
+ sql_or_path: str = typer.Argument(
34
+ ...,
35
+ help=(
36
+ "SQL text or path to a .sql file. If the argument names an "
37
+ "existing file, its contents are read."
38
+ ),
39
+ ),
40
+ database: str | None = typer.Option(
41
+ None,
42
+ "--database",
43
+ "-d",
44
+ help="connection_name or logical database; omit if only one is indexed.",
45
+ ),
46
+ db: str | None = typer.Option(
47
+ None,
48
+ "--db",
49
+ hidden=True,
50
+ help="Deprecated alias for --database/-d.",
51
+ ),
52
+ dialect: str = typer.Option(
53
+ "postgres",
54
+ "--dialect",
55
+ help="sqlglot dialect (default 'postgres').",
56
+ ),
57
+ state_dir: Path = typer.Option(
58
+ cli_constants.DEFAULT_STATE_DIR,
59
+ "--state-dir",
60
+ help="Directory containing registry.json.",
61
+ file_okay=False,
62
+ dir_okay=True,
63
+ resolve_path=True,
64
+ ),
65
+ graph_dir: Path | None = typer.Option(
66
+ None,
67
+ "--graph-dir",
68
+ hidden=True,
69
+ help="Deprecated alias for --state-dir.",
70
+ file_okay=False,
71
+ dir_okay=True,
72
+ resolve_path=True,
73
+ ),
74
+ as_json: bool = typer.Option(
75
+ False,
76
+ "--json",
77
+ help="Emit machine-readable JSON instead of a rich panel.",
78
+ ),
79
+ ctx: typer.Context = typer.Option(None, hidden=True),
80
+ ) -> None:
81
+ """Validate SQL against the indexed graph for one database."""
82
+ cli_config = get_cli_config(ctx)
83
+ if database is None and db is not None:
84
+ database = db
85
+ state_dir = resolve_aliased_path_option(
86
+ ctx,
87
+ primary_param="state_dir",
88
+ primary_value=state_dir,
89
+ alias_param="graph_dir",
90
+ alias_value=graph_dir,
91
+ config_value=cli_config.state_dir,
92
+ )
93
+
94
+ maybe_path = Path(sql_or_path)
95
+ if maybe_path.exists() and maybe_path.is_file():
96
+ sql_text = maybe_path.read_text(encoding="utf-8")
97
+ else:
98
+ sql_text = sql_or_path
99
+
100
+ if not sql_text.strip():
101
+ console.print("[red]Empty SQL input.[/red]")
102
+ raise typer.Exit(_EXIT_BAD_ARGS)
103
+
104
+ reg = load_registry(state_dir)
105
+ entry = resolve_registry_entry(reg, database)
106
+ if entry is None:
107
+ msg = (
108
+ f"no registry entry matches --database {database!r}"
109
+ if database is not None
110
+ else "multiple indexed databases — pass --database"
111
+ )
112
+ console.print(f"[red]{msg}[/red]")
113
+ raise typer.Exit(_EXIT_BAD_ARGS)
114
+
115
+ store = open_store_for_entry(entry)
116
+ try:
117
+ validator = QueryValidator(
118
+ store,
119
+ connection_name=entry.connection_name,
120
+ database_key=entry.database,
121
+ dialect=dialect,
122
+ )
123
+ result = validator.validate(sql_text)
124
+ finally:
125
+ store.close()
126
+
127
+ if as_json:
128
+ payload: dict[str, object] = {
129
+ "valid": result.valid,
130
+ "dialect": dialect,
131
+ "syntax_errors": result.syntax_errors,
132
+ "missing_tables": result.missing_tables,
133
+ "missing_columns": result.missing_columns,
134
+ "invalid_joins": [
135
+ {
136
+ "message": j.message,
137
+ "left_table": j.left_table,
138
+ "right_table": j.right_table,
139
+ }
140
+ for j in result.invalid_joins
141
+ ],
142
+ "suggestions": result.suggestions,
143
+ }
144
+ typer.echo(json.dumps(payload, indent=2))
145
+ else:
146
+ valid_label = (
147
+ "[green]valid[/green]" if result.valid else "[red]invalid[/red]"
148
+ )
149
+ lines = [
150
+ f"[bold]Dialect:[/bold] {dialect}",
151
+ f"[bold]Valid:[/bold] {valid_label}",
152
+ ]
153
+ if result.syntax_errors:
154
+ lines.append("")
155
+ lines.append("[bold]Syntax errors[/bold]")
156
+ lines.extend(f" - {e}" for e in result.syntax_errors)
157
+ if result.missing_tables:
158
+ lines.append("")
159
+ lines.append("[bold]Missing tables[/bold]")
160
+ lines.extend(f" - {t}" for t in result.missing_tables)
161
+ if result.missing_columns:
162
+ lines.append("")
163
+ lines.append("[bold]Missing columns[/bold]")
164
+ lines.extend(f" - {c}" for c in result.missing_columns)
165
+ if result.invalid_joins:
166
+ lines.append("")
167
+ lines.append("[bold]Invalid joins[/bold]")
168
+ lines.extend(f" - {j.message}" for j in result.invalid_joins)
169
+ if result.suggestions:
170
+ lines.append("")
171
+ lines.append("[bold]Suggestions[/bold]")
172
+ lines.extend(f" - {s}" for s in result.suggestions)
173
+ console.print(Panel("\n".join(lines), title="validate_sql"))
174
+
175
+ if not result.valid:
176
+ raise typer.Exit(_EXIT_INVALID)