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.
- localtools_sql_formatter/__init__.py +0 -0
- localtools_sql_formatter/__main__.py +311 -0
- localtools_sql_formatter/engine.py +284 -0
- localtools_sql_formatter/localtools.py +223 -0
- localtools_sql_formatter/ui/index.html +294 -0
- localtools_sql_formatter/ui/kit/fonts/OFL.txt +93 -0
- localtools_sql_formatter/ui/kit/fonts/jetbrains-mono-latin-bold.woff2 +0 -0
- localtools_sql_formatter/ui/kit/fonts/jetbrains-mono-latin-regular.woff2 +0 -0
- localtools_sql_formatter/ui/kit/localtools.css +189 -0
- localtools_sql_formatter/ui/kit/localtools.js +191 -0
- localtools_sql_formatter-1.0.0.dist-info/METADATA +84 -0
- localtools_sql_formatter-1.0.0.dist-info/RECORD +15 -0
- localtools_sql_formatter-1.0.0.dist-info/WHEEL +4 -0
- localtools_sql_formatter-1.0.0.dist-info/entry_points.txt +2 -0
- localtools_sql_formatter-1.0.0.dist-info/licenses/LICENSE +21 -0
|
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)
|