localtools-postgres-migration-checker 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,257 @@
1
+ import argparse
2
+ import json
3
+ import os
4
+ import re
5
+ import select
6
+ import shutil
7
+ import socket
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ import threading
12
+ import time
13
+ from pathlib import Path
14
+
15
+ import multipart
16
+ from . import engine
17
+ from .localtools import HTTPError, Tool, main as run
18
+ from .limits import limit_memory
19
+
20
+ USAGE = '''localtools-postgres-migration-checker: offline PostgreSQL migration safety estimates.
21
+
22
+ Usage:
23
+ localtools-postgres-migration-checker [serve] [--port N] [--no-open]
24
+ localtools-postgres-migration-checker check FILE... [--pg 11..18] [--transaction auto|always|never]
25
+ [--format human|json|markdown|sarif] [--fail-on high|medium|low|never] [-o FILE]
26
+ localtools-postgres-migration-checker update | uninstall | --version | --help
27
+
28
+ FILE '-' reads UTF-8 SQL from stdin. Files run in supplied order. No SQL is executed.
29
+ Limits: 100 files, 4 MiB aggregate SQL, 5000 statements, 16 MiB report, 30 seconds, 2 concurrent checks.
30
+ POST /api/check: multipart sql, name, file (repeatable), pg, transaction, format.
31
+ '''
32
+ UI = Path(__file__).with_name('ui')
33
+ if not UI.is_dir():
34
+ UI = Path(__file__).parents[1] / 'ui'
35
+ DEADLINE = 30
36
+ MAX_REPORT = 16 << 20
37
+ SLOTS = threading.BoundedSemaphore(2)
38
+ PREFIX = 'lt-pgmc-'
39
+
40
+
41
+ def sweep():
42
+ for p in Path(tempfile.gettempdir()).glob(PREFIX + '*'):
43
+ try:
44
+ if (re.fullmatch(r'lt-pgmc-[a-z0-9_]{8}', p.name) and not p.is_symlink()
45
+ and not (getattr(p.lstat(), 'st_file_attributes', 0) & 1024)
46
+ and p.is_dir() and time.time() - p.stat().st_mtime > 3600):
47
+ shutil.rmtree(p)
48
+ except OSError:
49
+ pass
50
+
51
+
52
+ def decode(data, name):
53
+ if b'\x00' in data:
54
+ raise HTTPError(400, f'{name}: NUL bytes are not valid SQL text')
55
+ try:
56
+ return engine.newlines(data.decode('utf-8-sig'))
57
+ except UnicodeDecodeError:
58
+ raise HTTPError(400, f'{name}: expected UTF-8 SQL') from None
59
+
60
+
61
+ def read_form(req):
62
+ ctype, opts = multipart.parse_options_header(req.headers.get('Content-Type', ''))
63
+ if ctype != 'multipart/form-data' or 'boundary' not in opts:
64
+ raise HTTPError(400, 'expected multipart/form-data')
65
+ try:
66
+ size = int(req.headers.get('Content-Length', '0'))
67
+ except ValueError:
68
+ raise HTTPError(400, 'invalid Content-Length') from None
69
+ if size <= 0:
70
+ raise HTTPError(400, 'send SQL or a .sql file')
71
+ if size > engine.MAX_BYTES + (256 << 10):
72
+ raise HTTPError(413, 'limit: 4 MiB aggregate SQL plus 256 KiB form overhead')
73
+ parts, files, fields = [], [], {}
74
+ try:
75
+ for p in multipart.MultipartParser(req.rfile, opts['boundary'], size,
76
+ part_limit=engine.MAX_FILES + 8, memory_limit=1 << 20, partsize_limit=engine.MAX_BYTES):
77
+ parts.append(p)
78
+ if p.filename:
79
+ if p.name != 'file':
80
+ raise HTTPError(400, 'uploaded SQL must use the file field')
81
+ name = p.filename.replace('\\', '/').split('/')[-1][:200]
82
+ files.append((name, decode(p.raw, name)))
83
+ else:
84
+ if p.name not in ('sql', 'name', 'pg', 'transaction', 'format') or p.name in fields:
85
+ raise HTTPError(400, 'unknown or repeated form field')
86
+ fields[p.name] = decode(p.raw, p.name)
87
+ if fields.get('sql', '').strip():
88
+ files = [(fields.get('name', 'pasted.sql')[:200], fields['sql'])]
89
+ return files, fields
90
+ except multipart.ParserLimitReached:
91
+ raise HTTPError(413, 'upload exceeds file, field, or 4 MiB SQL limit') from None
92
+ except multipart.MultipartError:
93
+ raise HTTPError(400, 'malformed multipart upload or field limit exceeded') from None
94
+ finally:
95
+ for p in parts:
96
+ p.close()
97
+
98
+
99
+ def disconnected(req):
100
+ if req is None:
101
+ return False
102
+ try:
103
+ ready, _, _ = select.select([req.connection], [], [], 0)
104
+ return bool(ready) and req.connection.recv(1, socket.MSG_PEEK) == b''
105
+ except OSError:
106
+ return True
107
+
108
+
109
+ def worker(args):
110
+ # EOF is reliable on all platforms and kills the worker if its parent dies.
111
+ def parent_watch():
112
+ os.read(sys.stdin.fileno(), 1)
113
+ os._exit(1)
114
+ threading.Thread(target=parent_watch, daemon=True).start()
115
+ path = Path(args[0])
116
+ try:
117
+ memory_job = limit_memory()
118
+ job = json.loads(path.read_text(encoding='utf-8'))
119
+ rep = engine.check(job['files'], pg=job['pg'], txn_mode=job['transaction'])
120
+ fmt = job['format']
121
+ output = (engine.markdown(rep) if fmt == 'markdown' else engine.human(rep) if fmt == 'human'
122
+ else engine.dumps(engine.sarif(rep, '1.0.0')) if fmt == 'sarif' else engine.dumps(rep))
123
+ if len(output.encode('utf-8')) > MAX_REPORT:
124
+ raise ValueError('report exceeds 16 MiB; split the migration')
125
+ res = {'output': output, 'failed': engine.failing(rep, job.get('fail_on', 'high'))}
126
+ except MemoryError:
127
+ res = {'error': 'SQL exceeds the 512 MiB parser worker memory budget; split it', 'code': 422}
128
+ except (ValueError, RecursionError) as e:
129
+ res = {'error': str(e) if not isinstance(e, RecursionError) else 'SQL is nested too deeply', 'code': 422}
130
+ except Exception:
131
+ res = {'error': 'unsupported SQL structure; split the migration or report this input', 'code': 422}
132
+ path.with_suffix('.out').write_text(json.dumps(res, ensure_ascii=False), encoding='utf-8')
133
+
134
+
135
+ def execute(files, fields, req=None):
136
+ try:
137
+ pg = int(fields.get('pg') or engine.PG_DEFAULT)
138
+ except ValueError:
139
+ raise HTTPError(400, 'pg must be an integer from 11 to 18') from None
140
+ txn = fields.get('transaction', 'auto')
141
+ fmt = fields.get('format', 'json')
142
+ if not engine.PG_MIN <= pg <= engine.PG_MAX or txn not in engine.TXN_MODES:
143
+ raise HTTPError(400, 'pg must be 11-18; transaction must be auto, always or never')
144
+ if fmt not in ('json', 'markdown', 'sarif', 'human'):
145
+ raise HTTPError(400, 'format must be json, markdown, sarif or human')
146
+ if not files or not any(t.strip() for _, t in files):
147
+ raise HTTPError(400, 'paste SQL or upload a non-empty .sql file')
148
+ if len(files) > engine.MAX_FILES or sum(len(t.encode('utf-8')) for _, t in files) > engine.MAX_BYTES:
149
+ raise HTTPError(413, 'limit: 100 files and 4 MiB aggregate UTF-8 SQL')
150
+ if not SLOTS.acquire(blocking=False):
151
+ raise HTTPError(503, 'busy: two checks are running; retry when one finishes')
152
+ try:
153
+ with tempfile.TemporaryDirectory(prefix=PREFIX) as d:
154
+ path = Path(d, 'job.json')
155
+ path.write_text(json.dumps({'files': files, 'pg': pg, 'transaction': txn, 'format': fmt,
156
+ 'fail_on': fields.get('fail_on', 'high')}), encoding='utf-8')
157
+ cmd = [sys.executable, *(['-I'] if sys.flags.isolated else []), '-m', __package__, '_worker', str(path)]
158
+ p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
159
+ creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0))
160
+ end = time.monotonic() + DEADLINE
161
+ try:
162
+ while p.poll() is None:
163
+ if time.monotonic() > end:
164
+ raise HTTPError(422, 'check exceeded 30 seconds; split the migration')
165
+ if disconnected(req):
166
+ raise HTTPError(499, 'client disconnected')
167
+ try:
168
+ p.wait(.1)
169
+ except subprocess.TimeoutExpired:
170
+ pass
171
+ finally:
172
+ if p.poll() is None:
173
+ p.kill()
174
+ p.wait()
175
+ p.stdin.close()
176
+ out = path.with_suffix('.out')
177
+ if not out.is_file() or out.stat().st_size > MAX_REPORT * 2 + 1024:
178
+ raise HTTPError(422, 'parser could not process this SQL; nesting may be too deep')
179
+ res = json.loads(out.read_text(encoding='utf-8'))
180
+ finally:
181
+ SLOTS.release()
182
+ if 'error' in res:
183
+ raise HTTPError(res['code'], res['error'])
184
+ return res
185
+
186
+
187
+ def api_check(req):
188
+ files, fields = read_form(req)
189
+ res = execute(files, fields, req)
190
+ fmt = fields.get('format', 'json')
191
+ types = {'json': 'application/json', 'sarif': 'application/sarif+json', 'markdown': 'text/markdown', 'human': 'text/plain'}
192
+ req.send_body(200, res['output'].encode('utf-8'), types[fmt] + '; charset=utf-8')
193
+
194
+
195
+ def cli_check(args):
196
+ p = argparse.ArgumentParser(prog='localtools-postgres-migration-checker check')
197
+ p.add_argument('files', nargs='+')
198
+ p.add_argument('--pg', type=int, default=engine.PG_DEFAULT)
199
+ p.add_argument('--transaction', choices=engine.TXN_MODES, default='auto')
200
+ p.add_argument('--format', choices=('human', 'json', 'markdown', 'sarif'), default='human')
201
+ p.add_argument('--fail-on', choices=('high', 'medium', 'low', 'never'), default='high')
202
+ p.add_argument('-o', '--output')
203
+ a = p.parse_args(args)
204
+ if len(a.files) > engine.MAX_FILES:
205
+ raise ValueError('limit: 100 files')
206
+ files, total = [], 0
207
+ for name in a.files:
208
+ try:
209
+ stream = sys.stdin.buffer if name == '-' else open(name, 'rb')
210
+ try:
211
+ data = stream.read(engine.MAX_BYTES - total + 1)
212
+ finally:
213
+ if name != '-':
214
+ stream.close()
215
+ total += len(data)
216
+ if total > engine.MAX_BYTES:
217
+ raise ValueError('limit: 4 MiB aggregate UTF-8 SQL')
218
+ files.append((Path(name).name if name != '-' else 'stdin.sql', decode(data, name)))
219
+ except OSError:
220
+ raise ValueError(f'cannot read {Path(name).name}') from None
221
+ res = execute(files, vars(a))
222
+ output = res['output']
223
+ if a.output:
224
+ atomic_write(Path(a.output), output)
225
+ else:
226
+ sys.stdout.reconfigure(encoding='utf-8', newline='')
227
+ print(output, end='')
228
+ return int(res['failed'])
229
+
230
+
231
+ def atomic_write(path, output):
232
+ temp = None
233
+ try:
234
+ with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', newline='', dir=path.parent,
235
+ prefix='.pgmc-', delete=False) as f:
236
+ temp = Path(f.name)
237
+ f.write(output)
238
+ os.replace(temp, path)
239
+ except OSError:
240
+ raise ValueError('cannot write report; check the output folder and permissions') from None
241
+ finally:
242
+ if temp is not None:
243
+ temp.unlink(missing_ok=True)
244
+
245
+
246
+ tool = Tool(slug='postgres-migration-checker', port=47116, usage=USAGE, ui=UI,
247
+ routes={'POST /api/check': api_check}, cli={'check': cli_check, '_worker': worker})
248
+
249
+
250
+ def main():
251
+ if len(sys.argv) < 2 or sys.argv[1] != '_worker':
252
+ sweep()
253
+ sys.exit(run(tool))
254
+
255
+
256
+ if __name__ == '__main__':
257
+ main()