sql-lineage-extractor 0.1.1__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.
@@ -0,0 +1,53 @@
1
+ """Column-to-column lineage for a single target column, via sqlglot."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from sqlglot import exp
6
+ from sqlglot.lineage import Node, lineage
7
+
8
+ from ..models import ColumnSource
9
+
10
+
11
+ def extract_column_lineage(
12
+ column: str, sql: str, dialect: str
13
+ ) -> tuple[str, list[ColumnSource]]:
14
+ """Return (target expression SQL, sorted upstream (table, column) pairs).
15
+
16
+ Walks the sqlglot lineage tree down to its leaves; each leaf backed by a
17
+ base table becomes one ColumnSource. Results are de-duplicated and sorted so
18
+ the manifest stays deterministic.
19
+ """
20
+ node = lineage(column, sql, dialect=dialect)
21
+ expression_sql = node.expression.sql(dialect=dialect)
22
+
23
+ sources: set[ColumnSource] = set()
24
+ for leaf in _leaves(node):
25
+ resolved = _resolve_leaf(leaf)
26
+ if resolved is not None:
27
+ sources.add(resolved)
28
+
29
+ return expression_sql, sorted(sources, key=lambda s: (s.table, s.column))
30
+
31
+
32
+ def _leaves(node: Node):
33
+ if not node.downstream:
34
+ yield node
35
+ return
36
+ for child in node.downstream:
37
+ yield from _leaves(child)
38
+
39
+
40
+ def _resolve_leaf(leaf: Node) -> ColumnSource | None:
41
+ source = leaf.source
42
+ if not isinstance(source, exp.Table):
43
+ return None
44
+ table = _qualified_table(source)
45
+ column = leaf.name.rsplit(".", 1)[-1]
46
+ if not table or not column:
47
+ return None
48
+ return ColumnSource(table=table, column=column)
49
+
50
+
51
+ def _qualified_table(table: exp.Table) -> str:
52
+ parts = [p for p in (table.catalog, table.db, table.name) if p]
53
+ return ".".join(parts)
@@ -0,0 +1,27 @@
1
+ """Glue the four layers together: source -> parse -> graph -> (nodes, errors)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .graph import build_graph
6
+ from .models import ObjectNode, ParseError
7
+ from .parsing import parse_unit
8
+ from .sources.base import SQLSource
9
+
10
+
11
+ def run_extraction(source: SQLSource) -> tuple[list[ObjectNode], list[ParseError]]:
12
+ """Extract every unit from ``source`` and resolve the cross-object graph.
13
+
14
+ A parse failure on one object is collected as a ParseError and never stops
15
+ the others.
16
+ """
17
+ nodes: list[ObjectNode] = []
18
+ errors: list[ParseError] = []
19
+ for unit in source.extract():
20
+ result = parse_unit(unit)
21
+ if isinstance(result, ParseError):
22
+ errors.append(result)
23
+ else:
24
+ nodes.append(result)
25
+ build_graph(nodes)
26
+ nodes.sort(key=lambda n: n.id)
27
+ return nodes, errors
sql_lineage/py.typed ADDED
File without changes
@@ -0,0 +1,13 @@
1
+ from ..config import LineageConfig
2
+ from .base import SQLSource
3
+ from .file_source import FileSQLSource
4
+ from .live_sql_source import LiveSQLEndpointSource
5
+ from .notebook_source import NotebookSQLSource
6
+
7
+ __all__ = [
8
+ "SQLSource",
9
+ "FileSQLSource",
10
+ "NotebookSQLSource",
11
+ "LiveSQLEndpointSource",
12
+ "LineageConfig",
13
+ ]
@@ -0,0 +1,14 @@
1
+ """Common source interface. Every source yields SQLUnit objects and nothing else."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol, runtime_checkable
6
+
7
+ from ..models import SQLUnit
8
+
9
+
10
+ @runtime_checkable
11
+ class SQLSource(Protocol):
12
+ def extract(self) -> list[SQLUnit]:
13
+ """Return the normalized SQL units this source exposes."""
14
+ ...
@@ -0,0 +1,53 @@
1
+ """FileSQLSource — scan a directory for .sql files, one file == one SQLUnit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from ..config import LineageConfig
8
+ from ..models import SQLUnit
9
+
10
+
11
+ class FileSQLSource:
12
+ """Yield one SQLUnit per file matching ``pattern`` under ``root``.
13
+
14
+ The ``id`` defaults to the file stem (``escale.sql`` -> ``escale``), but an
15
+ explicit ``-- lineage:id=`` comment inside the file wins (same convention as
16
+ notebooks, resolved through the injected :class:`LineageConfig`). Origins are
17
+ stored as POSIX-style relative paths when possible, for stable, diffable
18
+ manifests across operating systems.
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ root: str | Path,
24
+ dialect: str | None = None,
25
+ pattern: str = "**/*.sql",
26
+ *,
27
+ config: LineageConfig | None = None,
28
+ ) -> None:
29
+ self.root = Path(root)
30
+ self.config = config or LineageConfig()
31
+ self.dialect = dialect if dialect is not None else self.config.default_dialect
32
+ self.pattern = pattern
33
+
34
+ def extract(self) -> list[SQLUnit]:
35
+ units: list[SQLUnit] = []
36
+ for path in sorted(self.root.glob(self.pattern)):
37
+ if not path.is_file():
38
+ continue
39
+ text = path.read_text(encoding="utf-8")
40
+ try:
41
+ origine = path.relative_to(self.root).as_posix()
42
+ except ValueError:
43
+ origine = path.as_posix()
44
+ uid = self.config.id_from_comment(text) or path.stem
45
+ units.append(
46
+ SQLUnit(
47
+ id=uid,
48
+ dialecte=self.dialect,
49
+ sql_text=text.strip(),
50
+ origine=origine,
51
+ )
52
+ )
53
+ return units
@@ -0,0 +1,81 @@
1
+ """LiveSQLEndpointSource — read view definitions from a live DB-API connection.
2
+
3
+ The connection object is *injected*, never built here: authentication (Entra ID
4
+ for Fabric, SQL auth for SQL Server, ...) and driver choice are the caller's
5
+ concern, keeping this library free of any proprietary Fabric SDK dependency.
6
+
7
+ The default query targets T-SQL system catalogs (``sys.sql_modules``); if that
8
+ fails (permissions, non-SQL-Server engine) it falls back to the ANSI standard
9
+ ``information_schema.views``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ from ..config import LineageConfig
17
+ from ..models import SQLUnit
18
+
19
+ # Full CREATE VIEW ... AS <select> definitions, richest source when available.
20
+ TSQL_QUERY = (
21
+ "SELECT v.name AS nom_vue, m.definition AS sql_text "
22
+ "FROM sys.views v "
23
+ "JOIN sys.sql_modules m ON v.object_id = m.object_id"
24
+ )
25
+
26
+ # Portable fallback: exposes only the SELECT body, no CREATE VIEW wrapper.
27
+ ANSI_FALLBACK_QUERY = (
28
+ "SELECT table_name AS nom_vue, view_definition AS sql_text "
29
+ "FROM information_schema.views"
30
+ )
31
+
32
+
33
+ class LiveSQLEndpointSource:
34
+ def __init__(
35
+ self,
36
+ connection: Any,
37
+ dialect: str | None = None,
38
+ origine: str = "live",
39
+ query: str | None = None,
40
+ fallback_query: str | None = None,
41
+ *,
42
+ config: LineageConfig | None = None,
43
+ ) -> None:
44
+ self.connection = connection
45
+ self.config = config or LineageConfig()
46
+ self.dialect = dialect if dialect is not None else self.config.default_dialect
47
+ self.origine = origine
48
+ self.query = query or TSQL_QUERY
49
+ self.fallback_query = fallback_query or ANSI_FALLBACK_QUERY
50
+
51
+ def extract(self) -> list[SQLUnit]:
52
+ try:
53
+ rows = self._run(self.query)
54
+ except Exception:
55
+ # Any failure on the primary path (missing sys.sql_modules, denied
56
+ # permissions, unsupported engine) triggers the portable fallback.
57
+ rows = self._run(self.fallback_query)
58
+
59
+ units: list[SQLUnit] = []
60
+ for name, sql_text in rows:
61
+ if sql_text is None:
62
+ continue
63
+ units.append(
64
+ SQLUnit(
65
+ id=str(name),
66
+ dialecte=self.dialect,
67
+ sql_text=str(sql_text).strip(),
68
+ origine=f"{self.origine}:{name}",
69
+ )
70
+ )
71
+ return units
72
+
73
+ def _run(self, query: str) -> list[tuple[Any, Any]]:
74
+ cursor = self.connection.cursor()
75
+ try:
76
+ cursor.execute(query)
77
+ return [(row[0], row[1]) for row in cursor.fetchall()]
78
+ finally:
79
+ close = getattr(cursor, "close", None)
80
+ if callable(close):
81
+ close()
@@ -0,0 +1,261 @@
1
+ """NotebookSQLSource — extract SQL from Jupyter/Fabric ``.ipynb`` notebooks.
2
+
3
+ Three cell shapes are understood:
4
+
5
+ * ``%%sql`` magic cells: everything after the magic line is taken verbatim.
6
+ * Python cells calling ``<something>.sql("...")`` with a string literal.
7
+ * Python cells calling ``<something>.sql(f"...")`` with an f-string; each
8
+ ``{...}`` interpolation is replaced by a neutral token so sqlglot can still
9
+ parse the statement. The replaced fragments are recorded on the SQLUnit.
10
+
11
+ Python is parsed with the standard :mod:`ast` module — never with regexes —
12
+ because real notebook code mixes multi-line strings, nested calls and quote
13
+ styles that regexes get wrong.
14
+
15
+ Every convention (the ``-- lineage:id=`` comment, the ``df_`` variable prefix,
16
+ the neutralization token, the default dialect) comes from an injected
17
+ :class:`~sql_lineage.config.LineageConfig`; nothing is hard-coded here.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import ast
23
+ import json
24
+ from pathlib import Path
25
+ from typing import TypeGuard
26
+
27
+ from ..config import DEFAULT_PARAM_TOKEN, LineageConfig
28
+ from ..models import SQLUnit
29
+
30
+ # Backward-compatible module-level alias for the default neutralization token.
31
+ # Kept so ``from ...notebook_source import PARAM_TOKEN`` keeps working.
32
+ PARAM_TOKEN = DEFAULT_PARAM_TOKEN
33
+
34
+
35
+ class NotebookSQLSource:
36
+ def __init__(
37
+ self,
38
+ root: str | Path,
39
+ dialect: str | None = None,
40
+ pattern: str = "**/*.ipynb",
41
+ *,
42
+ config: LineageConfig | None = None,
43
+ ) -> None:
44
+ self.root = Path(root)
45
+ self.config = config or LineageConfig()
46
+ self.dialect = dialect if dialect is not None else self.config.default_dialect
47
+ self.pattern = pattern
48
+
49
+ def extract(self) -> list[SQLUnit]:
50
+ units: list[SQLUnit] = []
51
+ for path in sorted(self.root.glob(self.pattern)):
52
+ if not path.is_file():
53
+ continue
54
+ units.extend(self._extract_notebook(path))
55
+ return units
56
+
57
+ # -- per-notebook ---------------------------------------------------------
58
+
59
+ def _extract_notebook(self, path: Path) -> list[SQLUnit]:
60
+ try:
61
+ origine = path.relative_to(self.root).as_posix()
62
+ except ValueError:
63
+ origine = path.as_posix()
64
+
65
+ nb = json.loads(path.read_text(encoding="utf-8"))
66
+ notebook_name = path.stem
67
+ units: list[SQLUnit] = []
68
+
69
+ cell_index = -1
70
+ for cell in nb.get("cells", []):
71
+ cell_index += 1
72
+ if cell.get("cell_type") != "code":
73
+ continue
74
+ src = _cell_source(cell)
75
+ if not src.strip():
76
+ continue
77
+
78
+ magic_sql = _strip_sql_magic(src)
79
+ if magic_sql is not None:
80
+ units.append(
81
+ self._make_unit(
82
+ sql_text=magic_sql,
83
+ params=[],
84
+ var_name=None,
85
+ origine=origine,
86
+ notebook_name=notebook_name,
87
+ cell_index=cell_index,
88
+ call_index=0,
89
+ )
90
+ )
91
+ continue
92
+
93
+ calls = _python_sql_calls(src, self.config.param_token)
94
+ for call_index, (sql_text, params, var_name) in enumerate(calls):
95
+ units.append(
96
+ self._make_unit(
97
+ sql_text=sql_text,
98
+ params=params,
99
+ var_name=var_name,
100
+ origine=origine,
101
+ notebook_name=notebook_name,
102
+ cell_index=cell_index,
103
+ call_index=call_index,
104
+ )
105
+ )
106
+ return units
107
+
108
+ def _make_unit(
109
+ self,
110
+ *,
111
+ sql_text: str,
112
+ params: list[str],
113
+ var_name: str | None,
114
+ origine: str,
115
+ notebook_name: str,
116
+ cell_index: int,
117
+ call_index: int,
118
+ ) -> SQLUnit:
119
+ warnings: list[str] = []
120
+ uid = self.config.id_from_comment(sql_text)
121
+ if uid is None and var_name:
122
+ uid = self.config.strip_var_prefix(var_name)
123
+ if uid is None:
124
+ uid = f"{notebook_name}#{cell_index}"
125
+ if call_index:
126
+ uid = f"{uid}#{call_index}"
127
+ warnings.append(
128
+ f"Aucun id explicite trouvé (ni commentaire '-- lineage:id=', ni "
129
+ f"variable cible) ; repli sur '{uid}'."
130
+ )
131
+ return SQLUnit(
132
+ id=uid,
133
+ dialecte=self.dialect,
134
+ sql_text=sql_text.strip(),
135
+ origine=f"{origine}#cell{cell_index}",
136
+ parametres_neutralises=params,
137
+ avertissements=warnings,
138
+ )
139
+
140
+
141
+ # -- module-level helpers -----------------------------------------------------
142
+
143
+
144
+ def _cell_source(cell: dict) -> str:
145
+ src = cell.get("source", "")
146
+ if isinstance(src, list):
147
+ return "".join(src)
148
+ return src
149
+
150
+
151
+ def _strip_sql_magic(src: str) -> str | None:
152
+ """Return the SQL body if ``src`` is a ``%%sql`` cell, else None."""
153
+ lines = src.splitlines()
154
+ for i, line in enumerate(lines):
155
+ if not line.strip():
156
+ continue
157
+ if line.strip().startswith("%%sql"):
158
+ return "\n".join(lines[i + 1 :])
159
+ return None
160
+ return None
161
+
162
+
163
+ def _python_sql_calls(
164
+ src: str, param_token: str
165
+ ) -> list[tuple[str, list[str], str | None]]:
166
+ """Find every ``*.sql(<str|fstring>)`` call, returning (sql, params, var)."""
167
+ try:
168
+ tree = ast.parse(src)
169
+ except SyntaxError:
170
+ return []
171
+
172
+ # Map matching Call nodes to the variable they are assigned to (if any).
173
+ assigned: dict[int, str | None] = {}
174
+ for node in ast.walk(tree):
175
+ if isinstance(node, ast.Assign) and _is_sql_call(node.value):
176
+ assigned[id(node.value)] = _target_name(node.targets)
177
+
178
+ calls = [n for n in ast.walk(tree) if _is_sql_call(n)]
179
+ calls.sort(key=lambda n: (getattr(n, "lineno", 0), getattr(n, "col_offset", 0)))
180
+
181
+ results: list[tuple[str, list[str], str | None]] = []
182
+ for call in calls:
183
+ extracted = _extract_sql_arg(call.args[0], param_token)
184
+ if extracted is None:
185
+ continue
186
+ sql_text, params = extracted
187
+ results.append((sql_text, params, assigned.get(id(call))))
188
+ return results
189
+
190
+
191
+ def _is_sql_call(node: ast.AST) -> TypeGuard[ast.Call]:
192
+ return (
193
+ isinstance(node, ast.Call)
194
+ and isinstance(node.func, ast.Attribute)
195
+ and node.func.attr == "sql"
196
+ and len(node.args) >= 1
197
+ )
198
+
199
+
200
+ def _target_name(targets: list[ast.expr]) -> str | None:
201
+ for target in targets:
202
+ if isinstance(target, ast.Name):
203
+ return target.id
204
+ return None
205
+
206
+
207
+ def _extract_sql_arg(arg: ast.expr, param_token: str) -> tuple[str, list[str]] | None:
208
+ """Return (sql_text, neutralized_params) for a string or f-string arg."""
209
+ if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
210
+ return arg.value, []
211
+ if isinstance(arg, ast.JoinedStr):
212
+ return _neutralize_fstring(arg, param_token)
213
+ return None
214
+
215
+
216
+ def _neutralize_fstring(node: ast.JoinedStr, param_token: str) -> tuple[str, list[str]]:
217
+ parts: list[str] = []
218
+ params: list[str] = []
219
+ values = node.values
220
+ for i, value in enumerate(values):
221
+ if isinstance(value, ast.Constant) and isinstance(value.value, str):
222
+ parts.append(value.value)
223
+ elif isinstance(value, ast.FormattedValue):
224
+ try:
225
+ expr_src = ast.unparse(value.value)
226
+ except Exception: # pragma: no cover - very old python fallback
227
+ expr_src = "?"
228
+ params.append(expr_src)
229
+ # An interpolation standing alone on its line is a whole SQL *clause*
230
+ # (e.g. ``{'' if x is None else 'AND t.c IN ' + x}``), not a value.
231
+ # Replacing it by the neutral token would leave an unparsable bare
232
+ # word, so we drop it instead — WHERE/HAVING predicates never feed
233
+ # column lineage, so removing one is safe.
234
+ before = "".join(parts)
235
+ nxt = values[i + 1] if i + 1 < len(values) else None
236
+ after = (
237
+ nxt.value
238
+ if isinstance(nxt, ast.Constant) and isinstance(nxt.value, str)
239
+ else ""
240
+ )
241
+ parts.append("" if _is_statement_fragment(before, after) else param_token)
242
+ return "".join(parts), params
243
+
244
+
245
+ def _is_statement_fragment(before: str, after: str) -> bool:
246
+ """Whether an interpolation stands alone on its line (a whole SQL clause).
247
+
248
+ True when nothing meaningful precedes it on the line (start of string or the
249
+ preceding text ends the line) and nothing meaningful follows it on the line
250
+ (end of string, a newline, or a closing paren). Such fragments are dropped
251
+ rather than tokenized. Value-position interpolations (``>= {x}``, ``'{x}'``,
252
+ ``IN ({x})``) fail these tests and keep the neutral token.
253
+ """
254
+ left_clear = before == "" or before.rstrip(" \t").endswith("\n")
255
+ stripped_after = after.lstrip(" \t")
256
+ right_clear = (
257
+ stripped_after == ""
258
+ or stripped_after.startswith("\n")
259
+ or stripped_after.startswith(")")
260
+ )
261
+ return left_clear and right_clear