localtools-sql-formatter 1.0.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.
File without changes
@@ -0,0 +1,311 @@
1
+ import argparse
2
+ import json
3
+ import os
4
+ import select
5
+ import socket
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ import threading
10
+ import time
11
+ from pathlib import Path
12
+
13
+ import multipart
14
+
15
+ from . import engine
16
+ from .localtools import HTTPError, Tool, main as run
17
+
18
+ USAGE = """localtools-sql-formatter: lint, fix and transpile SQL in any major dialect, offline (sqlfluff + sqlglot).
19
+
20
+ Usage:
21
+ localtools-sql-formatter [serve] [--port N] [--no-open] start the web UI (default port 47117)
22
+ localtools-sql-formatter lint FILE... [options] report rule violations; exit 1 if any
23
+ --format human|json|sarif
24
+ localtools-sql-formatter fix FILE... [options] print the fixed SQL (one file), or:
25
+ --in-place rewrite the files --diff print a unified diff
26
+ exit 1 if violations that cannot be fixed remain (or the file does not parse)
27
+ localtools-sql-formatter transpile FILE... --read D --write D [--no-pretty] convert between dialects (sqlglot)
28
+ localtools-sql-formatter dialects | rules list dialects / lint rules
29
+ localtools-sql-formatter update | uninstall re-run the installer / remove the tool
30
+ localtools-sql-formatter --version | --help
31
+
32
+ Options for lint and fix (FILE "-" reads stdin):
33
+ --dialect D ansi (default), postgres, mysql, bigquery, snowflake, tsql, sqlite, duckdb, ...
34
+ --rules R only these rules, e.g. LT01,CP01 or core --exclude-rules R skip these
35
+ --templater T raw | jinja | placeholder:colon | placeholder:question_mark | placeholder:dollar | ...
36
+ --config FILE .sqlfluff, setup.cfg, tox.ini or pyproject.toml ([tool.sqlfluff]); paths, python/dbt
37
+ templaters and library code in it are ignored (nothing in a config is executed)
38
+
39
+ API: POST /api/lint | /api/fix | /api/transpile (multipart: sql and/or file..., dialect, rules, exclude_rules,
40
+ templater, config (file); transpile: read, write), GET /api/meta (dialects + rules).
41
+ /api/lint with format=sarif returns SARIF 2.1.0.
42
+ """
43
+
44
+ UI = Path(__file__).with_name("ui") # wheel: packaged ui/; source checkout: repo-root ui/
45
+ if not UI.is_dir():
46
+ UI = Path(__file__).parents[1] / "ui"
47
+ DEADLINE = float(os.environ.get("LOCALTOOLS_SQL_DEADLINE") or 120)
48
+ JOBS = int(os.environ.get("LOCALTOOLS_SQL_JOBS") or max(1, min(4, (os.cpu_count() or 2) // 2)))
49
+ SLOTS = threading.BoundedSemaphore(JOBS)
50
+
51
+
52
+ def text(data, name):
53
+ try:
54
+ return engine.newlines(data.decode("utf-8-sig"))
55
+ except UnicodeDecodeError:
56
+ raise HTTPError(400, f"{name}: not UTF-8 text")
57
+
58
+
59
+ def read_form(req):
60
+ ctype, opts = multipart.parse_options_header(req.headers.get("Content-Type", ""))
61
+ if ctype != "multipart/form-data" or "boundary" not in opts:
62
+ raise HTTPError(400, "expected multipart/form-data")
63
+ size = int(req.headers.get("Content-Length") or 0)
64
+ if size > engine.MAX_BYTES * 2 + (1 << 20):
65
+ raise HTTPError(413, f"upload too large ({engine.MAX_BYTES >> 10} KB of SQL max)")
66
+ try:
67
+ parts = list(multipart.MultipartParser(req.rfile, opts["boundary"], size))
68
+ except multipart.MultipartError as e:
69
+ raise HTTPError(400, f"malformed upload: {e}")
70
+ try:
71
+ fields = {p.name: p.value for p in parts if not p.filename}
72
+ files = [(p.filename, text(p.raw, p.filename)) for p in parts if p.filename and p.name == "file"]
73
+ cfg = next(((p.filename, text(p.raw, p.filename)) for p in parts if p.filename and p.name == "config"), None)
74
+ finally:
75
+ for p in parts:
76
+ p.close()
77
+ if fields.get("sql", "").strip():
78
+ files.insert(0, (fields.get("name") or "pasted.sql", engine.newlines(fields["sql"])))
79
+ if not files:
80
+ raise HTTPError(400, "paste SQL or upload at least one .sql file")
81
+ if cfg:
82
+ fields["config_name"], fields["config"] = cfg
83
+ return files, fields
84
+
85
+
86
+ def client_gone(req):
87
+ try:
88
+ r, _, _ = select.select([req.connection], [], [], 0)
89
+ return bool(r) and req.connection.recv(1, socket.MSG_PEEK) == b""
90
+ except OSError:
91
+ return True
92
+
93
+
94
+ def run_worker(req, job):
95
+ """One worker process per request: killed at the deadline, when the client disconnects, and (stdin EOF)
96
+ when this server exits. At most JOBS run at once; more get 503."""
97
+ if not SLOTS.acquire(blocking=False):
98
+ raise HTTPError(503, f"busy: {JOBS} SQL job(s) already running; try again when they finish")
99
+ try:
100
+ with tempfile.TemporaryDirectory(prefix="lt-sqlf-") as d:
101
+ jp = Path(d, "job.json")
102
+ jp.write_text(json.dumps(job), encoding="utf-8")
103
+ cmd = [sys.executable, *(["-I"] if sys.flags.isolated else []), "-m", __package__, "_worker", str(jp)]
104
+ p = subprocess.Popen(cmd, cwd=d, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
105
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
106
+ end = time.monotonic() + DEADLINE
107
+ try:
108
+ while p.poll() is None:
109
+ if time.monotonic() > end:
110
+ raise HTTPError(422, f"sqlfluff did not finish within {DEADLINE:g} s on this input; "
111
+ "split it, narrow the rules, or use the CLI (no deadline)")
112
+ if client_gone(req):
113
+ raise HTTPError(499, "client disconnected")
114
+ try:
115
+ p.wait(0.2)
116
+ except subprocess.TimeoutExpired:
117
+ pass
118
+ finally:
119
+ if p.poll() is None:
120
+ p.kill()
121
+ p.wait()
122
+ p.stdin.close()
123
+ out = jp.with_suffix(".out")
124
+ if not out.exists():
125
+ raise HTTPError(422, f"sqlfluff crashed on this input (exit code {p.returncode}); it may be nested too deeply")
126
+ res = json.loads(out.read_text(encoding="utf-8"))
127
+ finally:
128
+ SLOTS.release()
129
+ if "error" in res:
130
+ raise HTTPError(res["code"], res["error"])
131
+ return res
132
+
133
+
134
+ def job(op, files, f):
135
+ try:
136
+ engine.check_limits(files)
137
+ except engine.SQLError as e:
138
+ raise HTTPError(413, str(e))
139
+ keys = ("read", "write") if op == "transpile" else ("dialect", "rules", "exclude_rules", "templater", "config", "config_name")
140
+ return {"op": op, "files": files, **{k: f[k] for k in keys if k in f}}
141
+
142
+
143
+ def api(op):
144
+ def handler(req):
145
+ files, f = read_form(req)
146
+ res = run_worker(req, job(op, files, f))
147
+ if op == "lint" and f.get("format") == "sarif":
148
+ body = json.dumps(engine.sarif(res["files"], sqlfluff_version()), indent=2).encode()
149
+ req.send_response(200)
150
+ req.send_header("Content-Type", "application/sarif+json")
151
+ req.send_header("Content-Disposition", 'attachment; filename="sqlfluff.sarif"')
152
+ req.send_header("Content-Length", str(len(body)))
153
+ req.end_headers()
154
+ return req.wfile.write(body)
155
+ req.send_json(200, res)
156
+ return handler
157
+
158
+
159
+ def sqlfluff_version():
160
+ from importlib.metadata import version
161
+ return version("sqlfluff")
162
+
163
+
164
+ META = {}
165
+
166
+
167
+ def api_meta(req):
168
+ if not META:
169
+ META.update(engine.dialects(), rules=engine.rules(), versions={"sqlfluff": sqlfluff_version()},
170
+ limits={"bytes": engine.MAX_BYTES, "statements": engine.MAX_STATEMENTS, "deadline_s": DEADLINE},
171
+ param_styles=engine.PARAM_STYLES)
172
+ req.send_json(200, META)
173
+
174
+
175
+ # ---------- CLI ----------
176
+ def read_inputs(paths):
177
+ out = []
178
+ for p in paths:
179
+ if p == "-":
180
+ out.append(("stdin.sql", engine.newlines(sys.stdin.buffer.read().decode("utf-8-sig"))))
181
+ else:
182
+ try:
183
+ out.append((p, engine.newlines(Path(p).read_bytes().decode("utf-8-sig"))))
184
+ except OSError as e:
185
+ raise ValueError(f"{p}: {e.strerror}")
186
+ except UnicodeDecodeError:
187
+ raise ValueError(f"{p}: not UTF-8 text")
188
+ return out
189
+
190
+
191
+ def lint_parser(prog):
192
+ p = argparse.ArgumentParser(prog=f"localtools-sql-formatter {prog}")
193
+ p.add_argument("files", nargs="+")
194
+ p.add_argument("--dialect", default="")
195
+ p.add_argument("--rules", default="")
196
+ p.add_argument("--exclude-rules", default="")
197
+ p.add_argument("--templater", default="")
198
+ p.add_argument("--config")
199
+ return p
200
+
201
+
202
+ def cli_config(a):
203
+ cfg = Path(a.config).read_text(encoding="utf-8-sig") if a.config else ""
204
+ return engine.make_config(a.dialect, a.rules, a.exclude_rules, a.templater, cfg, Path(a.config).name if a.config else "")
205
+
206
+
207
+ def human(results):
208
+ for f in results:
209
+ for v in f.get("violations", f.get("remaining", [])):
210
+ print(f"{f['name']}:{v['line']}:{v['col']}: {v['code']} {v['description']}" + (" (warning)" if v["warning"] else ""))
211
+
212
+
213
+ def utf8():
214
+ for s in (sys.stdout, sys.stderr):
215
+ s.reconfigure(encoding="utf-8", newline="") # SQL out exactly as fixed (no CRLF translation on Windows)
216
+
217
+
218
+ def cli_lint(args):
219
+ p = lint_parser("lint")
220
+ p.add_argument("--format", default="human", choices=["human", "json", "sarif"])
221
+ a = p.parse_args(args)
222
+ utf8()
223
+ cfg, dropped = cli_config(a)
224
+ for k in dropped:
225
+ print(f"note: ignored config key {k}", file=sys.stderr)
226
+ res = engine.lint(read_inputs(a.files), cfg)
227
+ if a.format == "json":
228
+ print(json.dumps(res, indent=2, ensure_ascii=False))
229
+ elif a.format == "sarif":
230
+ print(json.dumps(engine.sarif(res, sqlfluff_version()), indent=2, ensure_ascii=False))
231
+ else:
232
+ human(res)
233
+ n = sum(len(f["violations"]) for f in res)
234
+ print(f"{n} violation(s) in {len(res)} file(s)" if n else f"{len(res)} file(s) clean", file=sys.stderr)
235
+ return 1 if any(f["violations"] for f in res) else 0
236
+
237
+
238
+ def cli_fix(args):
239
+ p = lint_parser("fix")
240
+ p.add_argument("--in-place", action="store_true")
241
+ p.add_argument("--diff", action="store_true")
242
+ a = p.parse_args(args)
243
+ utf8()
244
+ files = read_inputs(a.files)
245
+ if len(files) > 1 and not (a.in_place or a.diff):
246
+ raise ValueError("several files: add --in-place to rewrite them or --diff to print a diff")
247
+ if a.in_place and "-" in a.files:
248
+ raise ValueError("--in-place cannot rewrite stdin")
249
+ cfg, _ = cli_config(a)
250
+ res = engine.fix(files, cfg)
251
+ for f in res:
252
+ if a.in_place:
253
+ if f["changed"]:
254
+ Path(f["name"]).write_text(f["fixed"], encoding="utf-8", newline="")
255
+ print(f"{f['name']}: {'fixed' if f['changed'] else 'unchanged'}", file=sys.stderr)
256
+ elif a.diff:
257
+ sys.stdout.write(f["diff"])
258
+ else:
259
+ sys.stdout.write(f["fixed"])
260
+ if f["unparsable"]:
261
+ print(f"{f['name']}: not fixed, the file does not parse:", file=sys.stderr)
262
+ elif f["remaining"]:
263
+ print(f"{f['name']}: {len(f['remaining'])} violation(s) cannot be fixed automatically:", file=sys.stderr)
264
+ for v in f["remaining"]:
265
+ print(f" {v['line']}:{v['col']}: {v['code']} {v['description']}", file=sys.stderr)
266
+ return 1 if any(f["remaining"] for f in res) else 0
267
+
268
+
269
+ def cli_transpile(args):
270
+ p = argparse.ArgumentParser(prog="localtools-sql-formatter transpile")
271
+ p.add_argument("files", nargs="+")
272
+ p.add_argument("--read", default="")
273
+ p.add_argument("--write", default="")
274
+ p.add_argument("--no-pretty", action="store_true")
275
+ a = p.parse_args(args)
276
+ utf8()
277
+ for f in engine.transpile(read_inputs(a.files), a.read, a.write, not a.no_pretty):
278
+ sys.stdout.write(f["sql"])
279
+ for w in f["warnings"]:
280
+ print(f"{f['name']}: warning: {w}", file=sys.stderr)
281
+
282
+
283
+ def cli_dialects(args):
284
+ d = engine.dialects()
285
+ print("lint/fix (sqlfluff):", " ".join(d["sqlfluff"]))
286
+ print("transpile (sqlglot):", " ".join(d["sqlglot"]))
287
+
288
+
289
+ def cli_rules(args):
290
+ utf8()
291
+ for r in engine.rules():
292
+ print(f"{r['code']} {r['name']:<36} {r['description']}")
293
+
294
+
295
+ def cli_worker(args):
296
+ engine.worker_main(args[0])
297
+
298
+
299
+ tool = Tool(slug="sql-formatter", port=47117, usage=USAGE, ui=UI,
300
+ routes={"POST /api/lint": api("lint"), "POST /api/fix": api("fix"), "POST /api/transpile": api("transpile"),
301
+ "GET /api/meta": api_meta},
302
+ cli={"lint": cli_lint, "fix": cli_fix, "transpile": cli_transpile,
303
+ "dialects": cli_dialects, "rules": cli_rules, "_worker": cli_worker})
304
+
305
+
306
+ def main():
307
+ sys.exit(run(tool))
308
+
309
+
310
+ if __name__ == "__main__":
311
+ main()
@@ -0,0 +1,284 @@
1
+ """sqlfluff lint/fix and sqlglot transpile, safe config loading, SARIF output and the worker process entry."""
2
+ import difflib
3
+ import json
4
+ import logging
5
+ import os
6
+ import sys
7
+ import tempfile
8
+ import threading
9
+ from pathlib import Path
10
+
11
+ MAX_BYTES = 512 << 10 # per request (server); sqlfluff lints roughly 1-2 KB/s of messy SQL, so more would hit the deadline
12
+ MAX_STATEMENTS = 2000
13
+ DOCS = "https://docs.sqlfluff.com/en/stable/reference/rules/{group}.html#{code}"
14
+ TEMPLATERS = ("raw", "jinja", "placeholder")
15
+ PARAM_STYLES = ("colon", "colon_nospaces", "numeric_colon", "question_mark", "numeric_dollar", "dollar",
16
+ "percent", "pyformat", "at", "ampersand", "apache_ignite", "flyway_var", "dollar_brace")
17
+
18
+
19
+ class SQLError(ValueError):
20
+ pass
21
+
22
+
23
+ def newlines(sql):
24
+ """sqlfluff's CLI reads files with universal newlines; do the same so results and fixes match it."""
25
+ return sql.replace("\r\n", "\n").replace("\r", "\n")
26
+
27
+
28
+ def check_limits(files):
29
+ size = sum(len(sql.encode()) for _, sql in files)
30
+ if size > MAX_BYTES:
31
+ raise SQLError(f"input is {size >> 10} KB; the limit is {MAX_BYTES >> 10} KB per request (use the CLI for bigger files)")
32
+ n = sum(sql.count(";") for _, sql in files)
33
+ if n > MAX_STATEMENTS:
34
+ raise SQLError(f"input has about {n} statements; the limit is {MAX_STATEMENTS} per request (use the CLI for bigger files)")
35
+
36
+
37
+ # ---------- config ----------
38
+ def _scrub(d, dropped, path=()):
39
+ """Remove everything that makes sqlfluff read files or import code: *_path / *_dir keys (library_path,
40
+ load_macros_from_path, loader_search_path...), the python and dbt templaters, and multiprocessing."""
41
+ for k in list(d):
42
+ v, here = d[k], path + (k.lower(),)
43
+ if here == ("templater", "jinja", "context"):
44
+ continue # opaque template variables
45
+ if isinstance(v, dict):
46
+ if here in (("templater", "python"), ("templater", "dbt")):
47
+ dropped.append(":".join(here))
48
+ del d[k]
49
+ else:
50
+ _scrub(v, dropped, here)
51
+ elif k.lower().endswith(("_path", "_dir")) or here == ("core", "processes"):
52
+ dropped.append(":".join(here))
53
+ del d[k]
54
+
55
+
56
+ def load_user_config(text, name):
57
+ """Parse an uploaded .sqlfluff / setup.cfg / tox.ini (INI) or pyproject.toml ([tool.sqlfluff]) without running
58
+ anything. Returns (config dict, [dropped keys])."""
59
+ from sqlfluff.core.config.ini import load_ini_string
60
+ from sqlfluff.core.config.toml import load_toml_file_config
61
+ from sqlfluff.core.config.validate import validate_config_dict
62
+ from sqlfluff.core.errors import SQLFluffUserError
63
+ try:
64
+ if name.lower().endswith(".toml"):
65
+ with tempfile.TemporaryDirectory() as d:
66
+ p = Path(d, "pyproject.toml")
67
+ p.write_text(text, encoding="utf-8")
68
+ cfg = load_toml_file_config(str(p))
69
+ else:
70
+ cfg = load_ini_string(text)
71
+ dropped = []
72
+ _scrub(cfg, dropped)
73
+ t = str(cfg.get("core", {}).get("templater", "raw"))
74
+ if t not in TEMPLATERS:
75
+ raise SQLError(f'{name}: templater "{t}" is not supported here (use raw, jinja or placeholder)')
76
+ validate_config_dict(cfg, name)
77
+ return cfg, dropped
78
+ except SQLFluffUserError as e:
79
+ raise SQLError(f"{name}: {e}".replace(str(Path(tempfile.gettempdir())), "")) from None
80
+ except Exception as e: # configparser / toml syntax errors
81
+ if isinstance(e, SQLError):
82
+ raise
83
+ raise SQLError(f"{name}: not a valid sqlfluff config ({str(e).splitlines()[0]})") from None
84
+
85
+
86
+ def make_config(dialect="", rules="", exclude_rules="", templater="", config="", config_name=".sqlfluff"):
87
+ """templater: raw | jinja | placeholder:<param_style>; empty dialect/templater = from the config file, else ansi/raw."""
88
+ from sqlfluff.core import FluffConfig
89
+ from sqlfluff.core.errors import SQLFluffUserError
90
+ cfg, dropped = load_user_config(config, config_name) if config.strip() else ({}, [])
91
+ from sqlfluff.core import dialect_readout
92
+ known = [d.label for d in dialect_readout()]
93
+ for d in (dialect, cfg.get("core", {}).get("dialect")):
94
+ if d and d not in known:
95
+ raise SQLError(f'unknown dialect "{d}" (choose from {", ".join(known)})')
96
+ ov = {"large_file_skip_byte_limit": 0, "processes": 1} # our own size limit and deadline apply instead
97
+ if dialect or not cfg.get("core", {}).get("dialect"):
98
+ ov["dialect"] = dialect or "ansi"
99
+ if rules.strip():
100
+ ov["rules"] = rules.replace(" ", "")
101
+ if exclude_rules.strip():
102
+ ov["exclude_rules"] = exclude_rules.replace(" ", "")
103
+ if templater:
104
+ kind, _, style = templater.partition(":")
105
+ if kind not in TEMPLATERS or (kind == "placeholder") != bool(style) or (style and style not in PARAM_STYLES):
106
+ raise SQLError(f'unknown templater "{templater}" (raw, jinja or placeholder:<{"|".join(PARAM_STYLES)}>)')
107
+ ov["templater"] = kind
108
+ if style:
109
+ cfg.setdefault("templater", {}).setdefault("placeholder", {})["param_style"] = style
110
+ try:
111
+ return FluffConfig(configs=cfg, overrides=ov, ignore_local_config=True), dropped
112
+ except SQLFluffUserError as e:
113
+ raise SQLError(str(e)) from None
114
+
115
+
116
+ # ---------- engines ----------
117
+ def violation(v):
118
+ d = v.to_dict()
119
+ code, name = d["code"], d.get("name", "")
120
+ group = name.split(".")[0] if "." in name else ""
121
+ return {"code": code, "name": name, "description": d["description"],
122
+ "line": d["start_line_no"], "col": d["start_line_pos"],
123
+ "end_line": d.get("end_line_no", d["start_line_no"]), "end_col": d.get("end_line_pos", d["start_line_pos"]),
124
+ "warning": d.get("warning", False), "fixable": bool(d.get("fixes")),
125
+ "docs": DOCS.format(group=group, code=code.lower()) if group else ""}
126
+
127
+
128
+ def lint_string(linter, sql, name, fix=False):
129
+ try:
130
+ return linter.lint_string(sql, fname=name, fix=fix)
131
+ except Exception:
132
+ raise
133
+ except BaseException as e: # sqlfluffrs raises pyo3 PanicException (a BaseException) when its parser gives up
134
+ if type(e).__name__ != "PanicException":
135
+ raise
136
+ raise SQLError(f"{name}: sqlfluff's parser gave up on this input ({str(e).splitlines()[0]})") from None
137
+
138
+
139
+ def lint(files, cfg):
140
+ from sqlfluff.core import Linter
141
+ linter = Linter(config=cfg)
142
+ return [{"name": name, "violations": [violation(v) for v in lint_string(linter, sql, name).get_violations()]}
143
+ for name, sql in files]
144
+
145
+
146
+ def fix(files, cfg, passes=5):
147
+ """sqlfluff fix, repeated until a pass changes nothing (or a cycle settles on its smallest state): one sqlfluff pass can stop at its loop limit or
148
+ leave work for a later pass, so a single run is not always idempotent. Files that do not parse are left
149
+ alone, like the sqlfluff CLI does."""
150
+ from sqlfluff.core import Linter
151
+ linter = Linter(config=cfg)
152
+ out = []
153
+ for name, sql in files:
154
+ cur, first, unparsable, seen = sql, None, False, [sql]
155
+ for _ in range(passes):
156
+ lf = lint_string(linter, cur, name, fix=True)
157
+ vs = lf.get_violations()
158
+ if any(v.rule_code() in ("PRS", "TMP") for v in vs) and not cfg.get("fix_even_unparsable"):
159
+ unparsable = first is None
160
+ break
161
+ first = vs if first is None else first
162
+ new = lf.fix_string()[0]
163
+ if new == cur:
164
+ break # stable: whatever is still reported needs a human
165
+ if new in seen: # sqlfluff can oscillate (A -> B -> A); settle on one state of the cycle so output is idempotent
166
+ cur = new = min(seen[seen.index(new):])
167
+ break
168
+ seen.append(new)
169
+ cur = new
170
+ left = vs if unparsable or new == cur else [v for v in vs if not getattr(v, "fixes", None)]
171
+ out.append({"name": name, "fixed": cur, "changed": cur != sql, "unparsable": unparsable,
172
+ "fixed_count": max(0, len(first or []) - len(left)),
173
+ "remaining": [violation(v) for v in left],
174
+ "diff": "".join(difflib.unified_diff(sql.splitlines(True), cur.splitlines(True), f"a/{name}", f"b/{name}"))})
175
+ return out
176
+
177
+
178
+ class _Collect(logging.Handler):
179
+ def __init__(self):
180
+ super().__init__(logging.WARNING)
181
+ self.messages = []
182
+
183
+ def emit(self, record):
184
+ self.messages.append(record.getMessage())
185
+
186
+
187
+ def transpile(files, read="", write="", pretty=True):
188
+ import sqlglot
189
+ from sqlglot.errors import ErrorLevel, ParseError, TokenError
190
+ log, h = logging.getLogger("sqlglot"), _Collect()
191
+ log.addHandler(h)
192
+ log.propagate = False
193
+ out = []
194
+ try:
195
+ for name, sql in files:
196
+ h.messages = []
197
+ try:
198
+ stmts = sqlglot.transpile(sql, read=read or None, write=write or None, pretty=pretty,
199
+ unsupported_level=ErrorLevel.WARN)
200
+ except ParseError as e:
201
+ err = e.errors[0] if e.errors else {}
202
+ where = f"line {err['line']}, col {err['col']}: " if err.get("line") else ""
203
+ raise SQLError(f"{name}: {where}{err.get('description') or str(e).splitlines()[0]}") from None
204
+ except TokenError as e:
205
+ raise SQLError(f"{name}: {str(e).splitlines()[0]}") from None
206
+ except ValueError as e: # unknown dialect
207
+ raise SQLError(str(e).splitlines()[0]) from None
208
+ text = ";\n\n".join(s for s in stmts if s.strip())
209
+ out.append({"name": name, "sql": text + ";\n" if text else "", "warnings": list(dict.fromkeys(h.messages))})
210
+ finally:
211
+ log.removeHandler(h)
212
+ return out
213
+
214
+
215
+ def dialects():
216
+ from sqlfluff.core import dialect_readout
217
+ from sqlglot.dialects.dialect import Dialects
218
+ return {"sqlfluff": [d.label for d in dialect_readout()], "sqlglot": sorted(d.value for d in Dialects if d.value)}
219
+
220
+
221
+ def rules():
222
+ import sqlfluff
223
+ return [{"code": r.code, "name": r.name, "description": r.description} for r in sqlfluff.list_rules()]
224
+
225
+
226
+ def run_job(job):
227
+ files = [tuple(f) for f in job["files"]]
228
+ if job["op"] == "transpile":
229
+ return {"files": transpile(files, job.get("read", ""), job.get("write", ""), job.get("pretty", True))}
230
+ cfg, dropped = make_config(job.get("dialect", ""), job.get("rules", ""), job.get("exclude_rules", ""),
231
+ job.get("templater", ""), job.get("config", ""), job.get("config_name", ".sqlfluff"))
232
+ res = (lint if job["op"] == "lint" else fix)(files, cfg)
233
+ return {"files": res, "dialect": cfg.get("dialect"), "ignored_config": dropped}
234
+
235
+
236
+ def sarif(results, version):
237
+ used = {v["code"]: v for f in results for v in f.get("violations", f.get("remaining", []))}
238
+ return {
239
+ "$schema": "https://json.schemastore.org/sarif-2.1.0.json", "version": "2.1.0",
240
+ "runs": [{
241
+ "tool": {"driver": {"name": "sqlfluff", "version": version, "informationUri": "https://sqlfluff.com",
242
+ "rules": [{"id": c, "name": v["name"] or c, "shortDescription": {"text": v["description"]},
243
+ **({"helpUri": v["docs"]} if v["docs"] else {})} for c, v in sorted(used.items())]}},
244
+ "results": [{"ruleId": v["code"], "level": "warning" if v["warning"] else "error",
245
+ "message": {"text": v["description"]},
246
+ "locations": [{"physicalLocation": {"artifactLocation": {"uri": f["name"].replace("\\", "/")},
247
+ "region": {"startLine": v["line"], "startColumn": v["col"],
248
+ "endLine": v["end_line"], "endColumn": v["end_col"]}}}]}
249
+ for f in results for v in f.get("violations", f.get("remaining", []))],
250
+ }],
251
+ }
252
+
253
+
254
+ # ---------- worker process ----------
255
+ def _die_with_parent():
256
+ """The server keeps our stdin open; EOF means it closed it or died (any OS), so stop at once."""
257
+ sys.stdin.buffer.read()
258
+ os._exit(3)
259
+
260
+
261
+ def _limit_memory(nbytes=2 << 30):
262
+ try:
263
+ import resource
264
+ resource.setrlimit(resource.RLIMIT_AS, (nbytes, nbytes))
265
+ except (ImportError, ValueError, OSError): # Windows has no rlimit; macOS refuses RLIMIT_AS
266
+ pass
267
+
268
+
269
+ def worker_main(job_path):
270
+ threading.Thread(target=_die_with_parent, daemon=True).start()
271
+ _limit_memory()
272
+ sys.setrecursionlimit(20000)
273
+ try:
274
+ res = run_job(json.loads(Path(job_path).read_text(encoding="utf-8")))
275
+ except SQLError as e:
276
+ res = {"error": str(e), "code": 400}
277
+ except MemoryError:
278
+ res = {"error": "sqlfluff ran out of memory (2 GB limit) on this input; split it into smaller files", "code": 413}
279
+ except RecursionError:
280
+ res = {"error": "the SQL is nested too deeply for sqlfluff; simplify or split it", "code": 413}
281
+ except Exception as e:
282
+ res = {"error": f"sqlfluff failed on this input ({type(e).__name__}: {str(e).splitlines()[0] if str(e) else ''})", "code": 500}
283
+ Path(job_path).with_suffix(".out").write_text(json.dumps(res), encoding="utf-8")
284
+ os._exit(0)