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,196 @@
|
|
|
1
|
+
"""Stdlib ``ast`` extractor for SQL-bearing string literals in Python source."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import io
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
import tokenize
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
__all__ = ["SqlCandidate", "extract_sql_candidates"]
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
_SQL_IDENTIFIER_NAMES: frozenset[str] = frozenset({"query", "sql", "stmt", "statement"})
|
|
18
|
+
_SQL_IDENTIFIER_SUFFIXES: tuple[str, ...] = ("_query", "_sql", "_stmt", "_statement")
|
|
19
|
+
_SQL_IDENTIFIER_PREFIXES: tuple[str, ...] = ("query_", "sql_", "stmt_", "statement_")
|
|
20
|
+
_SQL_METHOD_NAMES: frozenset[str] = frozenset(
|
|
21
|
+
{"execute", "executemany", "query", "raw", "exec_driver_sql"}
|
|
22
|
+
)
|
|
23
|
+
_NOQA_MARKER = "noqa: pretensor-analyze"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _is_sql_identifier(name: str) -> bool:
|
|
27
|
+
"""True for variable names that conventionally hold SQL text.
|
|
28
|
+
|
|
29
|
+
Exact names (``sql``, ``query``, …) plus affixed forms (``merge_sql``,
|
|
30
|
+
``orders_query``, ``sql_fetch_users``). The is-this-SQL classifier still
|
|
31
|
+
gates every candidate, so a ``graphql_query`` holding non-SQL text is
|
|
32
|
+
rejected downstream rather than here.
|
|
33
|
+
"""
|
|
34
|
+
lowered = name.lower()
|
|
35
|
+
return (
|
|
36
|
+
lowered in _SQL_IDENTIFIER_NAMES
|
|
37
|
+
or lowered.endswith(_SQL_IDENTIFIER_SUFFIXES)
|
|
38
|
+
or lowered.startswith(_SQL_IDENTIFIER_PREFIXES)
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_SQL_FSTRING_PREFIX_RE = re.compile(
|
|
43
|
+
r"^\s*(SELECT|INSERT|UPDATE|DELETE|MERGE|WITH)\b", re.IGNORECASE
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class SqlCandidate:
|
|
49
|
+
"""A SQL string literal candidate extracted from Python source."""
|
|
50
|
+
|
|
51
|
+
text: str
|
|
52
|
+
confidence_bucket: (
|
|
53
|
+
str # "high" | "medium" | "low" — updated by pipeline for fstrings
|
|
54
|
+
)
|
|
55
|
+
line_start: int
|
|
56
|
+
line_end: int
|
|
57
|
+
file_path: Path
|
|
58
|
+
symbol: str
|
|
59
|
+
kind: str # extraction context: "assignment" | "call" | "fstring"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def extract_sql_candidates(file_path: Path) -> list[SqlCandidate]:
|
|
63
|
+
"""Extract SQL-bearing string literals from a Python source file.
|
|
64
|
+
|
|
65
|
+
Uses syntactic context only — whether the string is assigned to a SQL-named
|
|
66
|
+
variable or passed as the first argument to a SQL-execution method. SQL
|
|
67
|
+
content classification is handled separately by ``classify.py``.
|
|
68
|
+
"""
|
|
69
|
+
try:
|
|
70
|
+
source = file_path.read_text(encoding="utf-8", errors="replace")
|
|
71
|
+
except OSError as exc:
|
|
72
|
+
logger.debug("extract_python: cannot read %s: %s", file_path, exc)
|
|
73
|
+
return []
|
|
74
|
+
|
|
75
|
+
noqa_lines = _collect_noqa_lines(source)
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
tree = ast.parse(source, filename=str(file_path))
|
|
79
|
+
except SyntaxError as exc:
|
|
80
|
+
logger.debug("extract_python: syntax error in %s: %s", file_path, exc)
|
|
81
|
+
return []
|
|
82
|
+
|
|
83
|
+
visitor = _SqlVisitor(file_path, noqa_lines)
|
|
84
|
+
visitor.visit(tree)
|
|
85
|
+
return visitor.candidates
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _collect_noqa_lines(source: str) -> set[int]:
|
|
89
|
+
"""Return the set of line numbers (1-indexed) carrying a pretensor-analyze noqa marker."""
|
|
90
|
+
noqa: set[int] = set()
|
|
91
|
+
try:
|
|
92
|
+
tokens = tokenize.generate_tokens(io.StringIO(source).readline)
|
|
93
|
+
for tok_type, tok_val, (srow, _scol), *_ in tokens:
|
|
94
|
+
if tok_type == tokenize.COMMENT and _NOQA_MARKER in tok_val:
|
|
95
|
+
noqa.add(srow)
|
|
96
|
+
except tokenize.TokenError:
|
|
97
|
+
pass
|
|
98
|
+
return noqa
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class _SqlVisitor(ast.NodeVisitor):
|
|
102
|
+
"""AST visitor that collects SQL-bearing string literals by syntactic context."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, file_path: Path, noqa_lines: set[int]) -> None:
|
|
105
|
+
self.file_path = file_path
|
|
106
|
+
self.noqa_lines = noqa_lines
|
|
107
|
+
self.candidates: list[SqlCandidate] = []
|
|
108
|
+
|
|
109
|
+
def _is_noqa(self, line_start: int) -> bool:
|
|
110
|
+
return line_start in self.noqa_lines or (line_start - 1) in self.noqa_lines
|
|
111
|
+
|
|
112
|
+
def _emit_constant(
|
|
113
|
+
self,
|
|
114
|
+
node: ast.Constant,
|
|
115
|
+
*,
|
|
116
|
+
symbol: str,
|
|
117
|
+
kind: str,
|
|
118
|
+
confidence_bucket: str = "high",
|
|
119
|
+
) -> None:
|
|
120
|
+
if not isinstance(node.value, str):
|
|
121
|
+
return
|
|
122
|
+
text = node.value
|
|
123
|
+
if not text.strip():
|
|
124
|
+
return
|
|
125
|
+
line_start = node.lineno
|
|
126
|
+
line_end = getattr(node, "end_lineno", node.lineno)
|
|
127
|
+
if self._is_noqa(line_start):
|
|
128
|
+
return
|
|
129
|
+
self.candidates.append(
|
|
130
|
+
SqlCandidate(
|
|
131
|
+
text=text,
|
|
132
|
+
confidence_bucket=confidence_bucket,
|
|
133
|
+
line_start=line_start,
|
|
134
|
+
line_end=line_end,
|
|
135
|
+
file_path=self.file_path,
|
|
136
|
+
symbol=symbol,
|
|
137
|
+
kind=kind,
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def _emit_fstring(self, node: ast.JoinedStr, *, symbol: str) -> None:
|
|
142
|
+
"""Emit a candidate for an f-string whose first element is a SQL-keyword prefix."""
|
|
143
|
+
if not node.values:
|
|
144
|
+
return
|
|
145
|
+
first = node.values[0]
|
|
146
|
+
if not isinstance(first, ast.Constant) or not isinstance(first.value, str):
|
|
147
|
+
return
|
|
148
|
+
prefix = first.value
|
|
149
|
+
if not _SQL_FSTRING_PREFIX_RE.match(prefix):
|
|
150
|
+
return
|
|
151
|
+
line_start = node.lineno
|
|
152
|
+
line_end = getattr(node, "end_lineno", node.lineno)
|
|
153
|
+
if self._is_noqa(line_start):
|
|
154
|
+
return
|
|
155
|
+
self.candidates.append(
|
|
156
|
+
SqlCandidate(
|
|
157
|
+
text=prefix,
|
|
158
|
+
confidence_bucket="low",
|
|
159
|
+
line_start=line_start,
|
|
160
|
+
line_end=line_end,
|
|
161
|
+
file_path=self.file_path,
|
|
162
|
+
symbol=symbol,
|
|
163
|
+
kind="fstring",
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def _handle_value(self, value: ast.expr, *, symbol: str, kind: str) -> None:
|
|
168
|
+
if isinstance(value, ast.Constant):
|
|
169
|
+
self._emit_constant(value, symbol=symbol, kind=kind)
|
|
170
|
+
elif isinstance(value, ast.JoinedStr):
|
|
171
|
+
self._emit_fstring(value, symbol=symbol)
|
|
172
|
+
|
|
173
|
+
def visit_Assign(self, node: ast.Assign) -> None:
|
|
174
|
+
for target in node.targets:
|
|
175
|
+
if isinstance(target, ast.Name) and _is_sql_identifier(target.id):
|
|
176
|
+
self._handle_value(node.value, symbol=target.id, kind="assignment")
|
|
177
|
+
break
|
|
178
|
+
self.generic_visit(node)
|
|
179
|
+
|
|
180
|
+
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
|
|
181
|
+
if (
|
|
182
|
+
isinstance(node.target, ast.Name)
|
|
183
|
+
and _is_sql_identifier(node.target.id)
|
|
184
|
+
and node.value is not None
|
|
185
|
+
):
|
|
186
|
+
self._handle_value(node.value, symbol=node.target.id, kind="assignment")
|
|
187
|
+
self.generic_visit(node)
|
|
188
|
+
|
|
189
|
+
def visit_Call(self, node: ast.Call) -> None:
|
|
190
|
+
if (
|
|
191
|
+
isinstance(node.func, ast.Attribute)
|
|
192
|
+
and node.func.attr.lower() in _SQL_METHOD_NAMES
|
|
193
|
+
and node.args
|
|
194
|
+
):
|
|
195
|
+
self._handle_value(node.args[0], symbol=node.func.attr, kind="call")
|
|
196
|
+
self.generic_visit(node)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Multi-dialect sqlglot retry wrapper for SQL table reference extraction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Iterator
|
|
11
|
+
|
|
12
|
+
from pretensor.connectors.lineage_sqlglot import dml_write_targets, table_refs_from_sql
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"ParsedSql",
|
|
16
|
+
"normalize_placeholders",
|
|
17
|
+
"normalize_sql",
|
|
18
|
+
"parse_sql",
|
|
19
|
+
"quiet_sqlglot",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
_DIALECTS = ["", "postgres", "mysql", "bigquery", "snowflake", "trino", "spark"]
|
|
25
|
+
|
|
26
|
+
# DBAPI-style bind placeholders that break sqlglot's parser when left in place.
|
|
27
|
+
# Replaced with NULL before parsing so parameterized statements (the norm in
|
|
28
|
+
# scanned application code) keep their full structure — most importantly the
|
|
29
|
+
# INSERT/UPDATE node, which carries the write-target classification.
|
|
30
|
+
_PLACEHOLDER_RES: tuple[re.Pattern[str], ...] = (
|
|
31
|
+
re.compile(r"%\([A-Za-z_][A-Za-z0-9_]*\)s"), # psycopg named: %(name)s
|
|
32
|
+
re.compile(r"%s"), # psycopg positional: %s
|
|
33
|
+
re.compile(r"(?<![:\w]):[A-Za-z_][A-Za-z0-9_]*"), # named binds :name, not ::casts
|
|
34
|
+
re.compile(r"(?<=[\s(=,<>])\?(?=[\s),;]|$)"), # qmark style: ?
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# MySQL/SQLite REPLACE INTO is a delete-then-insert write to the target table,
|
|
39
|
+
# but sqlglot cannot parse it in any dialect (it falls back to an opaque
|
|
40
|
+
# Command with no table nodes). Rewriting the head to INSERT INTO preserves
|
|
41
|
+
# the write-target classification; the fingerprint is computed from the
|
|
42
|
+
# original text so identity is unaffected.
|
|
43
|
+
_REPLACE_INTO_RE = re.compile(
|
|
44
|
+
r"^(\s*)REPLACE\s+(?:LOW_PRIORITY\s+|DELAYED\s+)?(?:INTO\s+)?",
|
|
45
|
+
re.IGNORECASE,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def normalize_placeholders(sql: str) -> str:
|
|
50
|
+
"""Replace DBAPI bind placeholders (``%s``, ``%(name)s``, ``:name``, ``?``)
|
|
51
|
+
with ``NULL`` so sqlglot can parse parameterized statements. ``::`` casts and
|
|
52
|
+
string contents that don't look like bind markers are left alone."""
|
|
53
|
+
for pattern in _PLACEHOLDER_RES:
|
|
54
|
+
sql = pattern.sub("NULL", sql)
|
|
55
|
+
return sql
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def normalize_sql(sql: str) -> str:
|
|
59
|
+
"""Full pre-parse normalization: placeholder substitution plus the
|
|
60
|
+
``REPLACE INTO`` → ``INSERT INTO`` rewrite."""
|
|
61
|
+
return _REPLACE_INTO_RE.sub(r"\1INSERT INTO ", normalize_placeholders(sql))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Characters that never appear in a correctly parsed identifier part. A dialect
|
|
65
|
+
# that "succeeds" by tokenizing another dialect's quoting into the name itself
|
|
66
|
+
# (e.g. the generic dialect turning `my-project.sales.orders` into a literal
|
|
67
|
+
# backtick) must not win the retry — its refs are garbage and the right
|
|
68
|
+
# dialect would never get tried.
|
|
69
|
+
_INVALID_IDENT_CHARS = ("`", '"', "'", ";", "\n")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _refs_look_valid(refs: list[tuple[str, str]]) -> bool:
|
|
73
|
+
for schema, table in refs:
|
|
74
|
+
if not table:
|
|
75
|
+
return False
|
|
76
|
+
for part in (schema, table):
|
|
77
|
+
if any(ch in part for ch in _INVALID_IDENT_CHARS):
|
|
78
|
+
return False
|
|
79
|
+
if "." in table:
|
|
80
|
+
return False
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@contextmanager
|
|
85
|
+
def quiet_sqlglot() -> Iterator[None]:
|
|
86
|
+
"""Silence sqlglot's own error logging during trial parses.
|
|
87
|
+
|
|
88
|
+
The multi-dialect retry intentionally feeds sqlglot statements it may not
|
|
89
|
+
parse; without this, every failed attempt prints an ERROR line to the
|
|
90
|
+
user's console.
|
|
91
|
+
"""
|
|
92
|
+
sqlglot_logger = logging.getLogger("sqlglot")
|
|
93
|
+
previous = sqlglot_logger.level
|
|
94
|
+
sqlglot_logger.setLevel(logging.CRITICAL)
|
|
95
|
+
try:
|
|
96
|
+
yield
|
|
97
|
+
finally:
|
|
98
|
+
sqlglot_logger.setLevel(previous)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class ParsedSql:
|
|
103
|
+
"""Extracted table references and fingerprint from a SQL string."""
|
|
104
|
+
|
|
105
|
+
table_refs: list[tuple[str, str]] = field(default_factory=list)
|
|
106
|
+
write_targets: list[tuple[str, str]] = field(default_factory=list)
|
|
107
|
+
dialect_used: str = ""
|
|
108
|
+
fingerprint: str = ""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def parse_sql(sql: str, *, default_schema: str = "public") -> ParsedSql:
|
|
112
|
+
"""Extract table references via multi-dialect retry.
|
|
113
|
+
|
|
114
|
+
Tries each dialect in ``_DIALECTS`` until one returns non-empty,
|
|
115
|
+
plausible ``table_refs`` — refs whose names contain quoting characters or
|
|
116
|
+
dots are treated as a failed parse so the retry can reach the dialect that
|
|
117
|
+
understands the statement's quoting (BigQuery/MySQL backticks).
|
|
118
|
+
Always returns a ``ParsedSql`` (``table_refs`` may be empty if all dialects fail).
|
|
119
|
+
The ``fingerprint`` is ``sha256(stripped+lowercased sql)[:16]`` and is always
|
|
120
|
+
computed from the original text, before normalization.
|
|
121
|
+
"""
|
|
122
|
+
fingerprint = hashlib.sha256(sql.strip().lower().encode()).hexdigest()[:16]
|
|
123
|
+
normalized = normalize_sql(sql)
|
|
124
|
+
|
|
125
|
+
with quiet_sqlglot():
|
|
126
|
+
for dialect in _DIALECTS:
|
|
127
|
+
refs = table_refs_from_sql(
|
|
128
|
+
normalized, dialect=dialect, default_schema=default_schema
|
|
129
|
+
)
|
|
130
|
+
if refs and _refs_look_valid(refs):
|
|
131
|
+
targets = dml_write_targets(
|
|
132
|
+
normalized, dialect=dialect, default_schema=default_schema
|
|
133
|
+
)
|
|
134
|
+
return ParsedSql(
|
|
135
|
+
table_refs=refs,
|
|
136
|
+
write_targets=targets,
|
|
137
|
+
dialect_used=dialect,
|
|
138
|
+
fingerprint=fingerprint,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
return ParsedSql(fingerprint=fingerprint)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Orchestrate the analyze enrichment pipeline (walker → extractor → writer)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from pretensor.core.store import KuzuStore
|
|
12
|
+
from pretensor.enrichment.analyze.classify import CONFIDENCE_FLOAT, classify_sql
|
|
13
|
+
from pretensor.enrichment.analyze.extract_python import extract_sql_candidates
|
|
14
|
+
from pretensor.enrichment.analyze.parse import parse_sql
|
|
15
|
+
from pretensor.enrichment.analyze.summary import AnalyzeSummary
|
|
16
|
+
from pretensor.enrichment.analyze.walker import walk_repo
|
|
17
|
+
from pretensor.enrichment.analyze.writers import (
|
|
18
|
+
ParsedCandidate,
|
|
19
|
+
write_consumers_for_run,
|
|
20
|
+
)
|
|
21
|
+
from pretensor.observability import log_timed_operation
|
|
22
|
+
|
|
23
|
+
__all__ = ["EmptyGraphError", "run_analyze_enrichment"]
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
_FSTRING_CONFIDENCE_BUCKET = "low"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class EmptyGraphError(ValueError):
|
|
31
|
+
"""The graph has no ``SchemaTable`` rows for the requested connection."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def run_analyze_enrichment(
|
|
35
|
+
repo_path: Path,
|
|
36
|
+
store: KuzuStore,
|
|
37
|
+
connection_name: str,
|
|
38
|
+
*,
|
|
39
|
+
service_name: str | None = None,
|
|
40
|
+
includes: Sequence[str] = (),
|
|
41
|
+
excludes: Sequence[str] = (),
|
|
42
|
+
max_file_bytes: int = 1_000_000,
|
|
43
|
+
min_confidence: float = 0.6,
|
|
44
|
+
default_schema: str = "public",
|
|
45
|
+
scan_run_id: str | None = None,
|
|
46
|
+
dry_run: bool = False,
|
|
47
|
+
) -> AnalyzeSummary:
|
|
48
|
+
"""Walk ``repo_path``, extract SQL from Python files, and write graph nodes/edges.
|
|
49
|
+
|
|
50
|
+
Hard-errors when the graph has no ``SchemaTable`` rows for ``connection_name``
|
|
51
|
+
(the user must run ``pretensor index --connection <name>`` first).
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
repo_path: Root of the repository to scan.
|
|
55
|
+
store: Open Kuzu graph store.
|
|
56
|
+
connection_name: Pretensor connection name; only tables indexed under this
|
|
57
|
+
connection are considered valid CONSUMES targets.
|
|
58
|
+
service_name: Label for the scanned service (defaults to ``repo_path.name``).
|
|
59
|
+
includes: Gitignore-style glob patterns limiting which files are scanned.
|
|
60
|
+
excludes: Gitignore-style glob patterns excluding files from scanning.
|
|
61
|
+
max_file_bytes: Skip files larger than this (default 1 MB).
|
|
62
|
+
min_confidence: Drop candidates below this confidence level (default 0.6,
|
|
63
|
+
which excludes ``low``-confidence f-string prefix candidates).
|
|
64
|
+
default_schema: Schema assumed for unqualified table references in the
|
|
65
|
+
scanned SQL (default ``"public"``; pass the connection's actual
|
|
66
|
+
default schema for non-Postgres dialects).
|
|
67
|
+
scan_run_id: Idempotency key for this run; stale consumers from other run IDs
|
|
68
|
+
are swept at the end. Defaults to a random 8-hex-char string.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
``AnalyzeSummary`` with counts and timing.
|
|
72
|
+
"""
|
|
73
|
+
_guard_non_empty_graph(store, connection_name)
|
|
74
|
+
|
|
75
|
+
effective_service = service_name or repo_path.name
|
|
76
|
+
effective_run_id = scan_run_id or uuid.uuid4().hex[:8]
|
|
77
|
+
|
|
78
|
+
started = time.perf_counter()
|
|
79
|
+
files_scanned = 0
|
|
80
|
+
sql_candidates_found = 0
|
|
81
|
+
sql_candidates_failed = 0
|
|
82
|
+
parsed_candidates: list[ParsedCandidate] = []
|
|
83
|
+
|
|
84
|
+
with log_timed_operation(
|
|
85
|
+
logger,
|
|
86
|
+
event="analyze.walk",
|
|
87
|
+
connection_name=connection_name,
|
|
88
|
+
repo_path=str(repo_path),
|
|
89
|
+
):
|
|
90
|
+
file_list = list(
|
|
91
|
+
walk_repo(
|
|
92
|
+
repo_path,
|
|
93
|
+
includes=includes,
|
|
94
|
+
excludes=excludes,
|
|
95
|
+
max_file_bytes=max_file_bytes,
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
with log_timed_operation(
|
|
100
|
+
logger,
|
|
101
|
+
event="analyze.extract",
|
|
102
|
+
connection_name=connection_name,
|
|
103
|
+
file_count=len(file_list),
|
|
104
|
+
):
|
|
105
|
+
for file_path, language in file_list:
|
|
106
|
+
files_scanned += 1
|
|
107
|
+
if language != "python":
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
raw_candidates = extract_sql_candidates(file_path)
|
|
111
|
+
for cand in raw_candidates:
|
|
112
|
+
sql_candidates_found += 1
|
|
113
|
+
|
|
114
|
+
bucket, conf = classify_sql(cand.text)
|
|
115
|
+
if not bucket:
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
if cand.kind == "fstring":
|
|
119
|
+
bucket = _FSTRING_CONFIDENCE_BUCKET
|
|
120
|
+
conf = CONFIDENCE_FLOAT[bucket]
|
|
121
|
+
cand.confidence_bucket = bucket
|
|
122
|
+
|
|
123
|
+
if conf < min_confidence:
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
parsed = parse_sql(cand.text, default_schema=default_schema)
|
|
128
|
+
except Exception as exc:
|
|
129
|
+
logger.debug(
|
|
130
|
+
"analyze: parse error for candidate in %s: %s", file_path, exc
|
|
131
|
+
)
|
|
132
|
+
sql_candidates_failed += 1
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
if not parsed.table_refs and not parsed.write_targets:
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
rel_path = str(file_path.relative_to(repo_path))
|
|
140
|
+
except ValueError:
|
|
141
|
+
rel_path = file_path.name
|
|
142
|
+
|
|
143
|
+
parsed_candidates.append(
|
|
144
|
+
ParsedCandidate(
|
|
145
|
+
candidate=cand,
|
|
146
|
+
parsed=parsed,
|
|
147
|
+
rel_path=rel_path,
|
|
148
|
+
language=language,
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
with log_timed_operation(
|
|
153
|
+
logger,
|
|
154
|
+
event="analyze.write",
|
|
155
|
+
connection_name=connection_name,
|
|
156
|
+
candidate_count=len(parsed_candidates),
|
|
157
|
+
):
|
|
158
|
+
write_summary = write_consumers_for_run(
|
|
159
|
+
store,
|
|
160
|
+
connection_name,
|
|
161
|
+
parsed_candidates,
|
|
162
|
+
effective_run_id,
|
|
163
|
+
service_name=effective_service,
|
|
164
|
+
min_confidence=min_confidence,
|
|
165
|
+
dry_run=dry_run,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
duration_ms = (time.perf_counter() - started) * 1000
|
|
169
|
+
|
|
170
|
+
return AnalyzeSummary(
|
|
171
|
+
files_scanned=files_scanned,
|
|
172
|
+
sql_candidates_found=sql_candidates_found,
|
|
173
|
+
sql_candidates_parsed=len(parsed_candidates),
|
|
174
|
+
sql_candidates_failed=sql_candidates_failed,
|
|
175
|
+
consumers_written=write_summary.consumers_written,
|
|
176
|
+
edges_written=write_summary.edges_written,
|
|
177
|
+
cross_connection_dropped=write_summary.cross_connection_dropped,
|
|
178
|
+
scan_run_id=effective_run_id,
|
|
179
|
+
duration_ms=duration_ms,
|
|
180
|
+
rows=write_summary.rows,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _guard_non_empty_graph(store: KuzuStore, connection_name: str) -> None:
|
|
185
|
+
"""Raise ``EmptyGraphError`` when no ``SchemaTable`` rows exist for the connection."""
|
|
186
|
+
rows = store.query_all_rows(
|
|
187
|
+
"MATCH (t:SchemaTable {connection_name: $cn}) RETURN count(*) LIMIT 1",
|
|
188
|
+
{"cn": connection_name},
|
|
189
|
+
)
|
|
190
|
+
count = int(rows[0][0]) if rows and rows[0][0] is not None else 0
|
|
191
|
+
if count == 0:
|
|
192
|
+
raise EmptyGraphError(
|
|
193
|
+
f"No SchemaTable nodes found for connection '{connection_name}'. "
|
|
194
|
+
f"Index the database first: pretensor index --connection {connection_name}"
|
|
195
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Frozen summary dataclasses for the analyze enrichment pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
__all__ = ["AnalyzeSummary", "ConsumerRow"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class ConsumerRow:
|
|
12
|
+
"""One planned/written CONSUMES edge, for CLI rendering (no raw SQL)."""
|
|
13
|
+
|
|
14
|
+
service_name: str
|
|
15
|
+
table: str # "schema.table" as resolved
|
|
16
|
+
op: str # "read" | "write"
|
|
17
|
+
file_path: str # repo-relative
|
|
18
|
+
line_start: int
|
|
19
|
+
line_end: int
|
|
20
|
+
confidence: float
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class AnalyzeSummary:
|
|
25
|
+
"""Aggregate counts returned by ``run_analyze_enrichment``."""
|
|
26
|
+
|
|
27
|
+
files_scanned: int
|
|
28
|
+
sql_candidates_found: int
|
|
29
|
+
sql_candidates_parsed: int
|
|
30
|
+
sql_candidates_failed: int
|
|
31
|
+
consumers_written: int
|
|
32
|
+
edges_written: int
|
|
33
|
+
cross_connection_dropped: int
|
|
34
|
+
scan_run_id: str
|
|
35
|
+
duration_ms: float
|
|
36
|
+
# Planned (dry-run) or written CONSUMES rows, for CLI rendering. Defaulted so
|
|
37
|
+
# existing keyword construction and the frozen field set stay compatible.
|
|
38
|
+
rows: tuple[ConsumerRow, ...] = field(default=())
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Gitignore-aware repository file walker for the analyze enrichment pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Iterator, Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pathspec
|
|
10
|
+
|
|
11
|
+
__all__ = ["walk_repo", "SKIP_DIRS"]
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
SKIP_DIRS: frozenset[str] = frozenset(
|
|
16
|
+
{"node_modules", ".venv", "dist", "build", ".tox", ".git"}
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
_LANG_MAP: dict[str, str] = {".py": "python"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def walk_repo(
|
|
23
|
+
repo_path: Path,
|
|
24
|
+
*,
|
|
25
|
+
includes: Sequence[str] = (),
|
|
26
|
+
excludes: Sequence[str] = (),
|
|
27
|
+
max_file_bytes: int = 1_000_000,
|
|
28
|
+
) -> Iterator[tuple[Path, str]]:
|
|
29
|
+
"""Yield ``(absolute_file_path, language)`` for each candidate source file.
|
|
30
|
+
|
|
31
|
+
Respects ``.gitignore`` in ``repo_path``, prunes ``SKIP_DIRS``, skips files
|
|
32
|
+
exceeding ``max_file_bytes``, and filters by ``includes``/``excludes`` glob
|
|
33
|
+
patterns when provided.
|
|
34
|
+
"""
|
|
35
|
+
ignore_spec = _load_gitignore_spec(repo_path)
|
|
36
|
+
include_spec = (
|
|
37
|
+
pathspec.PathSpec.from_lines("gitignore", includes) if includes else None
|
|
38
|
+
)
|
|
39
|
+
exclude_spec = (
|
|
40
|
+
pathspec.PathSpec.from_lines("gitignore", excludes) if excludes else None
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
for file_path in _iter_files(repo_path):
|
|
44
|
+
rel = file_path.relative_to(repo_path)
|
|
45
|
+
rel_str = str(rel)
|
|
46
|
+
|
|
47
|
+
if ignore_spec and ignore_spec.match_file(rel_str):
|
|
48
|
+
continue
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
size = file_path.stat().st_size
|
|
52
|
+
except OSError:
|
|
53
|
+
continue
|
|
54
|
+
if size > max_file_bytes:
|
|
55
|
+
logger.debug("walker: skipping large file %s (%d bytes)", rel, size)
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
lang = _LANG_MAP.get(file_path.suffix)
|
|
59
|
+
if lang is None:
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
if include_spec and not include_spec.match_file(rel_str):
|
|
63
|
+
continue
|
|
64
|
+
if exclude_spec and exclude_spec.match_file(rel_str):
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
yield file_path, lang
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _iter_files(directory: Path) -> Iterator[Path]:
|
|
71
|
+
"""Recursively yield files, pruning SKIP_DIRS directories early."""
|
|
72
|
+
try:
|
|
73
|
+
entries = list(directory.iterdir())
|
|
74
|
+
except PermissionError:
|
|
75
|
+
return
|
|
76
|
+
for entry in entries:
|
|
77
|
+
if entry.is_symlink():
|
|
78
|
+
# Never follow symlinks: a link cycle would recurse forever, and a
|
|
79
|
+
# link out of the repo would break repo-relative path invariants.
|
|
80
|
+
continue
|
|
81
|
+
if entry.is_dir():
|
|
82
|
+
if entry.name in SKIP_DIRS:
|
|
83
|
+
continue
|
|
84
|
+
yield from _iter_files(entry)
|
|
85
|
+
elif entry.is_file():
|
|
86
|
+
yield entry
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _load_gitignore_spec(repo_root: Path) -> pathspec.PathSpec | None:
|
|
90
|
+
"""Load ``.gitignore`` from the repo root into a PathSpec (best-effort)."""
|
|
91
|
+
gitignore = repo_root / ".gitignore"
|
|
92
|
+
if not gitignore.is_file():
|
|
93
|
+
return None
|
|
94
|
+
try:
|
|
95
|
+
lines = gitignore.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
96
|
+
except OSError:
|
|
97
|
+
return None
|
|
98
|
+
return pathspec.PathSpec.from_lines("gitignore", lines)
|