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