tsql-fabric-debugger 0.3.2__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.
- tsql_fabric_debugger/__init__.py +36 -0
- tsql_fabric_debugger/cli.py +126 -0
- tsql_fabric_debugger/connection.py +147 -0
- tsql_fabric_debugger/dap.py +661 -0
- tsql_fabric_debugger/engine.py +2076 -0
- tsql_fabric_debugger/introspect.py +104 -0
- tsql_fabric_debugger/parser.py +691 -0
- tsql_fabric_debugger/py.typed +0 -0
- tsql_fabric_debugger/runner.py +242 -0
- tsql_fabric_debugger/scanner.py +83 -0
- tsql_fabric_debugger-0.3.2.dist-info/METADATA +358 -0
- tsql_fabric_debugger-0.3.2.dist-info/RECORD +15 -0
- tsql_fabric_debugger-0.3.2.dist-info/WHEEL +4 -0
- tsql_fabric_debugger-0.3.2.dist-info/entry_points.txt +4 -0
- tsql_fabric_debugger-0.3.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""tsql-fabric-debugger — step-by-step debugger for T-SQL procedures on Fabric Warehouse.
|
|
3
|
+
|
|
4
|
+
The Fabric Warehouse has no T-SQL debugger: no breakpoints, no watch, no way
|
|
5
|
+
to run half a procedure. This library solves that without touching the .sql:
|
|
6
|
+
it parses the procedure in memory, slices the body into steps and preserves
|
|
7
|
+
variable state between them (DECLARE + re-injection + statement + capture
|
|
8
|
+
batch). Everything runs inside a transaction with ROLLBACK by default.
|
|
9
|
+
|
|
10
|
+
Quick start:
|
|
11
|
+
|
|
12
|
+
from tsql_fabric_debugger import TSQLDebugger
|
|
13
|
+
dbg = TSQLDebugger("prd_load.sql", params={"@year": 2015},
|
|
14
|
+
server="<endpoint>.datawarehouse.fabric.microsoft.com",
|
|
15
|
+
database="my_warehouse")
|
|
16
|
+
dbg.list_steps(); dbg.step(); dbg.step_into(); dbg.show_vars()
|
|
17
|
+
dbg.close() # ROLLBACK
|
|
18
|
+
|
|
19
|
+
Batch mode: run_procedure(...). Scripts without CREATE PROCEDURE: run_script(...).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from .connection import connect, fetch_source, kill_orphan_sessions
|
|
23
|
+
from .engine import TSQLDebugger
|
|
24
|
+
from .runner import diff_logs, run_procedure, run_script, summarize
|
|
25
|
+
|
|
26
|
+
__version__ = "0.3.2"
|
|
27
|
+
__all__ = ["TSQLDebugger", "connect", "fetch_source", "kill_orphan_sessions", "list_procedures", "diff_logs", "run_procedure", "run_script", "summarize", "__version__"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def __getattr__(name):
|
|
31
|
+
# lazy so `python -m tsql_fabric_debugger.introspect` does not trigger a
|
|
32
|
+
# runpy "found in sys.modules" RuntimeWarning from an eager import here
|
|
33
|
+
if name == "list_procedures":
|
|
34
|
+
from .introspect import list_procedures
|
|
35
|
+
return list_procedures
|
|
36
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""CLI: `tsql-debug file.sql` runs a whole procedure with a step-by-step log.
|
|
3
|
+
|
|
4
|
+
Examples:
|
|
5
|
+
tsql-debug prd_load.sql --server X.datawarehouse.fabric.microsoft.com \\
|
|
6
|
+
--database my_warehouse --param @year=2015
|
|
7
|
+
tsql-debug load.sql --param @code='00123' --commit --csv log.csv --log-level full
|
|
8
|
+
|
|
9
|
+
For interactive debugging (step/step_into/show_vars), use the TSQLDebugger
|
|
10
|
+
class in a notebook, IPython or `python -i`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
from . import __version__
|
|
18
|
+
from .parser import find_procedure, read_sql_file
|
|
19
|
+
from .runner import count_errors, run_procedure, run_script, save_log_csv
|
|
20
|
+
from .scanner import scan
|
|
21
|
+
|
|
22
|
+
_INT_RE = re.compile(r"-?(0|[1-9]\d*)")
|
|
23
|
+
_FLOAT_RE = re.compile(r"-?(0|[1-9]\d*)\.\d+")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _parse_param(raw):
|
|
27
|
+
"""'@name=value' -> ('@name', value).
|
|
28
|
+
|
|
29
|
+
Quoted values ('...' or "...") are always strings — the way to keep
|
|
30
|
+
leading zeros, pass the literal word NULL, or force '1e5' as text.
|
|
31
|
+
Unquoted: NULL -> None; strict int/float (no leading zeros, no
|
|
32
|
+
scientific notation, no nan/inf); everything else stays a string.
|
|
33
|
+
"""
|
|
34
|
+
if "=" not in raw:
|
|
35
|
+
raise argparse.ArgumentTypeError(f"Invalid parameter: {raw!r} (use @name=value)")
|
|
36
|
+
name, value = raw.split("=", 1)
|
|
37
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
|
38
|
+
return name, value[1:-1]
|
|
39
|
+
if value.upper() == "NULL":
|
|
40
|
+
return name, None
|
|
41
|
+
if _INT_RE.fullmatch(value):
|
|
42
|
+
return name, int(value)
|
|
43
|
+
if _FLOAT_RE.fullmatch(value):
|
|
44
|
+
return name, float(value)
|
|
45
|
+
return name, value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def main(argv=None):
|
|
49
|
+
ap = argparse.ArgumentParser(
|
|
50
|
+
prog="tsql-debug",
|
|
51
|
+
description="Runs a Fabric Warehouse T-SQL procedure step by step, "
|
|
52
|
+
"with ROLLBACK at the end by default.",
|
|
53
|
+
epilog="Exit codes: 0 = no step errored; 1 = at least one step recorded an "
|
|
54
|
+
"ERROR (even when the procedure's own CATCH handled it); "
|
|
55
|
+
"2 = usage or file error.",
|
|
56
|
+
)
|
|
57
|
+
ap.add_argument("sql_file", nargs="?",
|
|
58
|
+
help=".sql file with the CREATE PROCEDURE (or a loose script)")
|
|
59
|
+
ap.add_argument("--server", help="Warehouse SQL endpoint (or env FABRIC_TSQL_SERVER)")
|
|
60
|
+
ap.add_argument("--database", help="Warehouse name (or env FABRIC_TSQL_DATABASE)")
|
|
61
|
+
ap.add_argument("--param", action="append", type=_parse_param, default=[],
|
|
62
|
+
metavar="@NAME=VALUE",
|
|
63
|
+
help="test value for a parameter (repeatable); quote the value "
|
|
64
|
+
"('00123') to force a string")
|
|
65
|
+
ap.add_argument("--commit", action="store_true",
|
|
66
|
+
help="persist the effects (default: ROLLBACK at the end)")
|
|
67
|
+
ap.add_argument("--csv", metavar="FILE", help="save the execution log as CSV")
|
|
68
|
+
ap.add_argument("--log-level", choices=["simple", "full"], default="simple")
|
|
69
|
+
ap.add_argument("--step-timeout", type=int, metavar="SECONDS",
|
|
70
|
+
help="per-step query timeout")
|
|
71
|
+
ap.add_argument("--kill-orphans", action="store_true",
|
|
72
|
+
help="instead of running a file: KILL leftover debugger "
|
|
73
|
+
"sessions (sleeping, open transaction, idle 15+ min) "
|
|
74
|
+
"whose locks block other sessions — combine with "
|
|
75
|
+
"--min-idle to change the threshold")
|
|
76
|
+
ap.add_argument("--min-idle", type=int, default=900, metavar="SECONDS",
|
|
77
|
+
help="idle threshold for --kill-orphans (default 900)")
|
|
78
|
+
ap.add_argument("--lock-timeout", type=int, metavar="SECONDS",
|
|
79
|
+
help="fail a lock-blocked step fast instead of hanging")
|
|
80
|
+
ap.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
81
|
+
args = ap.parse_args(argv)
|
|
82
|
+
|
|
83
|
+
if args.kill_orphans:
|
|
84
|
+
from .connection import kill_orphan_sessions
|
|
85
|
+
kill_orphan_sessions(args.server, args.database,
|
|
86
|
+
min_idle_seconds=args.min_idle)
|
|
87
|
+
return 0
|
|
88
|
+
if not args.sql_file:
|
|
89
|
+
ap.error("sql_file is required (or use --kill-orphans)")
|
|
90
|
+
|
|
91
|
+
import signal
|
|
92
|
+
# a polite kill (SIGTERM) must roll the warehouse session back: SystemExit
|
|
93
|
+
# lets run_procedure/run_script finally-blocks close the connection
|
|
94
|
+
try:
|
|
95
|
+
signal.signal(signal.SIGTERM, lambda *_: sys.exit(143))
|
|
96
|
+
except ValueError:
|
|
97
|
+
pass # not the main thread (embedded use)
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
sql_text = read_sql_file(args.sql_file)
|
|
101
|
+
except FileNotFoundError:
|
|
102
|
+
print(f"tsql-debug: file not found: {args.sql_file}", file=sys.stderr)
|
|
103
|
+
return 2
|
|
104
|
+
except OSError as exc:
|
|
105
|
+
print(f"tsql-debug: cannot read {args.sql_file}: {exc}", file=sys.stderr)
|
|
106
|
+
return 2
|
|
107
|
+
|
|
108
|
+
if find_procedure(scan(sql_text)) is not None:
|
|
109
|
+
df = run_procedure(sql_text=sql_text, params=dict(args.param),
|
|
110
|
+
server=args.server, database=args.database,
|
|
111
|
+
commit=args.commit, save_csv=args.csv,
|
|
112
|
+
log_level=args.log_level, step_timeout=args.step_timeout,
|
|
113
|
+
lock_timeout=args.lock_timeout)
|
|
114
|
+
else:
|
|
115
|
+
print("[WARNING] no CREATE PROCEDURE found — running as a loose script "
|
|
116
|
+
"(--param and --log-level do not apply on this path).")
|
|
117
|
+
df = run_script(sql_text=sql_text, server=args.server, database=args.database,
|
|
118
|
+
commit=args.commit, lock_timeout=args.lock_timeout)
|
|
119
|
+
if args.csv:
|
|
120
|
+
save_log_csv(df, args.csv)
|
|
121
|
+
|
|
122
|
+
return 1 if count_errors(df) else 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
sys.exit(main())
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Authenticated connection to the Fabric Warehouse (Entra ID).
|
|
3
|
+
|
|
4
|
+
Two token chains, resolved automatically:
|
|
5
|
+
1. Inside a Fabric notebook: the notebook's own token (notebookutils).
|
|
6
|
+
2. Anywhere else (local machine, CI): AzureCliCredential — the same
|
|
7
|
+
`az login` you already use. Requires the `[local]` extra (azure-identity)
|
|
8
|
+
and the ODBC Driver 18 for SQL Server installed.
|
|
9
|
+
|
|
10
|
+
Server and database can be passed as parameters or through the
|
|
11
|
+
FABRIC_TSQL_SERVER and FABRIC_TSQL_DATABASE environment variables.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import struct
|
|
16
|
+
|
|
17
|
+
TOKEN_SCOPE = "https://database.windows.net/.default"
|
|
18
|
+
SERVER_ENV = "FABRIC_TSQL_SERVER"
|
|
19
|
+
DATABASE_ENV = "FABRIC_TSQL_DATABASE"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
ACCESS_TOKEN_ENV = "FABRIC_TSQL_ACCESS_TOKEN"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _get_token():
|
|
26
|
+
# A caller (e.g. the VS Code extension) can hand us a database access token
|
|
27
|
+
# via the environment so each short-lived process does not pay the Azure
|
|
28
|
+
# CLI cold start again — the biggest part of "connect" latency.
|
|
29
|
+
env_token = os.environ.get(ACCESS_TOKEN_ENV)
|
|
30
|
+
if env_token:
|
|
31
|
+
return env_token
|
|
32
|
+
try:
|
|
33
|
+
return notebookutils.credentials.getToken(TOKEN_SCOPE) # noqa: F821
|
|
34
|
+
except NameError:
|
|
35
|
+
from azure.identity import AzureCliCredential
|
|
36
|
+
return AzureCliCredential().get_token(TOKEN_SCOPE).token
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def kill_orphan_sessions(server: str | None = None, database: str | None = None,
|
|
40
|
+
min_idle_seconds: int = 900, echo=print) -> list:
|
|
41
|
+
"""KILL leftover debugger sessions so their locks stop blocking everyone.
|
|
42
|
+
|
|
43
|
+
A debugger process that dies without close() (SIGKILL, crashed kernel,
|
|
44
|
+
killed test run) leaves its warehouse session sleeping with the debug
|
|
45
|
+
transaction OPEN — schema locks included, which blocks OBJECT_DEFINITION
|
|
46
|
+
and DDL for every other session until the server notices the dead TCP
|
|
47
|
+
connection (minutes). This finds sessions tagged by this library
|
|
48
|
+
(program_name 'tsql-fabric-debugger'), sleeping with an open transaction
|
|
49
|
+
and idle for at least min_idle_seconds, and KILLs them: the server rolls
|
|
50
|
+
their transaction back, releasing the locks.
|
|
51
|
+
|
|
52
|
+
CAUTION: an interactive debug someone left paused looks exactly like an
|
|
53
|
+
orphan. The default threshold (15 minutes) is a compromise — raise it on
|
|
54
|
+
shared warehouses, or only run this when you know no one is mid-debug.
|
|
55
|
+
Returns the killed session ids.
|
|
56
|
+
"""
|
|
57
|
+
conn = connect(server, database, autocommit=True)
|
|
58
|
+
try:
|
|
59
|
+
cur = conn.cursor()
|
|
60
|
+
cur.execute(
|
|
61
|
+
"SELECT session_id FROM sys.dm_exec_sessions "
|
|
62
|
+
"WHERE program_name = 'tsql-fabric-debugger' "
|
|
63
|
+
" AND session_id <> @@SPID AND status = 'sleeping' "
|
|
64
|
+
" AND open_transaction_count > 0 "
|
|
65
|
+
" AND DATEDIFF(second, COALESCE(last_request_end_time, login_time), "
|
|
66
|
+
" SYSUTCDATETIME()) >= ?;", (int(min_idle_seconds),))
|
|
67
|
+
orphans = [r[0] for r in cur.fetchall()]
|
|
68
|
+
killed = []
|
|
69
|
+
for sid in orphans:
|
|
70
|
+
try:
|
|
71
|
+
cur.execute(f"KILL {int(sid)};")
|
|
72
|
+
killed.append(sid)
|
|
73
|
+
echo(f"orphan debugger session {sid} killed — its transaction "
|
|
74
|
+
"was rolled back by the server.")
|
|
75
|
+
except Exception as exc:
|
|
76
|
+
echo(f"session {sid} not killed: {exc}")
|
|
77
|
+
if not orphans:
|
|
78
|
+
echo("no orphan debugger sessions found.")
|
|
79
|
+
return killed
|
|
80
|
+
finally:
|
|
81
|
+
conn.close()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def fetch_source(proc_name: str, server: str | None = None,
|
|
85
|
+
database: str | None = None) -> str:
|
|
86
|
+
"""Fetch the deployed source of a procedure straight from the warehouse.
|
|
87
|
+
|
|
88
|
+
Opens a short-lived autocommit session, reads OBJECT_DEFINITION and closes.
|
|
89
|
+
A name without schema resolves to dbo. Raises ValueError when the object
|
|
90
|
+
does not exist or the caller lacks VIEW DEFINITION permission.
|
|
91
|
+
"""
|
|
92
|
+
conn = connect(server, database, autocommit=True)
|
|
93
|
+
try:
|
|
94
|
+
cur = conn.cursor()
|
|
95
|
+
cur.execute("SELECT OBJECT_DEFINITION(OBJECT_ID(?));", (proc_name,))
|
|
96
|
+
row = cur.fetchone()
|
|
97
|
+
source = row[0] if row else None
|
|
98
|
+
finally:
|
|
99
|
+
conn.close()
|
|
100
|
+
if not source:
|
|
101
|
+
raise ValueError(f"{proc_name}: source not available "
|
|
102
|
+
"(missing object or no VIEW DEFINITION permission).")
|
|
103
|
+
return source
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def connect(server: str | None = None, database: str | None = None,
|
|
107
|
+
autocommit: bool = True, lock_timeout: int | None = None):
|
|
108
|
+
"""Open an authenticated (Entra ID) connection. One session = one debug.
|
|
109
|
+
|
|
110
|
+
lock_timeout (seconds): when set, a statement that waits for a lock this
|
|
111
|
+
long fails with SQL error 1222 instead of blocking forever. It does NOT
|
|
112
|
+
prevent an orphaned session from holding locks — only the server can reap
|
|
113
|
+
that — but it turns "hangs indefinitely behind an orphan" into an
|
|
114
|
+
immediate, actionable error. Applies to the whole session.
|
|
115
|
+
"""
|
|
116
|
+
import pyodbc
|
|
117
|
+
|
|
118
|
+
server = server or os.environ.get(SERVER_ENV)
|
|
119
|
+
database = database or os.environ.get(DATABASE_ENV)
|
|
120
|
+
if not server or not database:
|
|
121
|
+
raise ValueError(
|
|
122
|
+
"Provide server and database (or set the "
|
|
123
|
+
f"{SERVER_ENV} and {DATABASE_ENV} environment variables)."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
token = _get_token()
|
|
127
|
+
token_bytes = token.encode("utf-16-le")
|
|
128
|
+
token_struct = struct.pack(f"<I{len(token_bytes)}s", len(token_bytes), token_bytes)
|
|
129
|
+
SQL_COPT_SS_ACCESS_TOKEN = 1256
|
|
130
|
+
|
|
131
|
+
# APP= makes the debugger identifiable in sys.dm_exec_sessions.program_name
|
|
132
|
+
# during a blocking investigation; LoginTimeout bounds the connect phase
|
|
133
|
+
# (step_timeout only covers command execution).
|
|
134
|
+
conn = pyodbc.connect(
|
|
135
|
+
f"DRIVER={{ODBC Driver 18 for SQL Server}};"
|
|
136
|
+
f"SERVER={server};DATABASE={database};Encrypt=yes;"
|
|
137
|
+
f"APP=tsql-fabric-debugger;LoginTimeout=30;",
|
|
138
|
+
attrs_before={SQL_COPT_SS_ACCESS_TOKEN: token_struct},
|
|
139
|
+
autocommit=autocommit,
|
|
140
|
+
)
|
|
141
|
+
if lock_timeout is not None:
|
|
142
|
+
# a session option: must run outside pyodbc's parameterized path so it
|
|
143
|
+
# persists for the session (sp_prepexec would revert it per batch)
|
|
144
|
+
cur = conn.cursor()
|
|
145
|
+
cur.execute(f"SET LOCK_TIMEOUT {int(lock_timeout) * 1000};")
|
|
146
|
+
cur.close()
|
|
147
|
+
return conn
|