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.
@@ -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