sql2api 0.1.0__py3-none-any.whl → 0.2.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.
- sql2api/__init__.py +1 -1
- sql2api/app.py +23 -4
- sql2api/config.py +39 -0
- sql2api/engine.py +6 -3
- sql2api/openapi.py +4 -1
- sql2api/pool.py +146 -0
- sql2api/runners.py +261 -49
- {sql2api-0.1.0.dist-info → sql2api-0.2.0.dist-info}/METADATA +8 -1
- sql2api-0.2.0.dist-info/RECORD +20 -0
- sql2api-0.1.0.dist-info/RECORD +0 -19
- {sql2api-0.1.0.dist-info → sql2api-0.2.0.dist-info}/WHEEL +0 -0
- {sql2api-0.1.0.dist-info → sql2api-0.2.0.dist-info}/entry_points.txt +0 -0
- {sql2api-0.1.0.dist-info → sql2api-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {sql2api-0.1.0.dist-info → sql2api-0.2.0.dist-info}/top_level.txt +0 -0
sql2api/__init__.py
CHANGED
sql2api/app.py
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
"""The Flask application: HTTP routes on top of the store, engine and formatters."""
|
|
2
2
|
import hmac
|
|
3
3
|
import logging
|
|
4
|
+
import math
|
|
4
5
|
|
|
5
6
|
from flask import Blueprint, Flask, Response, jsonify, redirect, request, url_for
|
|
6
7
|
from flask.json.provider import DefaultJSONProvider
|
|
7
8
|
from werkzeug.exceptions import HTTPException
|
|
8
9
|
|
|
9
|
-
from . import config, engine, openapi, sqltools, store
|
|
10
|
+
from . import config, engine, openapi, pool, sqltools, store
|
|
10
11
|
from .errors import ApiError
|
|
11
12
|
from .formats import FORMATTERS, json_default
|
|
12
13
|
|
|
@@ -14,7 +15,7 @@ log = logging.getLogger('sql2api')
|
|
|
14
15
|
bp = Blueprint('api', __name__)
|
|
15
16
|
|
|
16
17
|
# Query-string arguments that control a request rather than supplying query parameters.
|
|
17
|
-
RESERVED_ARGS = {'format', 'page', 'page_size', 'connection_name', 'version'}
|
|
18
|
+
RESERVED_ARGS = {'format', 'page', 'page_size', 'connection_name', 'version', 'timeout'}
|
|
18
19
|
PUBLIC_ENDPOINTS = {'api.index', 'api.favicon', 'api.health', 'api.docs', 'api.openapi_spec'}
|
|
19
20
|
|
|
20
21
|
|
|
@@ -105,6 +106,20 @@ def get_output_format(body=None):
|
|
|
105
106
|
return output_format
|
|
106
107
|
|
|
107
108
|
|
|
109
|
+
def get_timeout(body=None):
|
|
110
|
+
"""Seconds allowed for the query: ?timeout= may lower the server limit but never raise it."""
|
|
111
|
+
raw = request.args.get('timeout', (body or {}).get('timeout'))
|
|
112
|
+
if raw in (None, ''):
|
|
113
|
+
return config.effective_timeout(None)
|
|
114
|
+
try:
|
|
115
|
+
value = float(raw)
|
|
116
|
+
except (TypeError, ValueError):
|
|
117
|
+
raise ApiError('timeout must be a number of seconds') from None
|
|
118
|
+
if not math.isfinite(value) or value <= 0:
|
|
119
|
+
raise ApiError('timeout must be a positive number of seconds')
|
|
120
|
+
return config.effective_timeout(value)
|
|
121
|
+
|
|
122
|
+
|
|
108
123
|
def render(result, output_format, page, page_size):
|
|
109
124
|
response = jsonify({'message': 'No results returned'}) if not result else FORMATTERS[output_format](result)
|
|
110
125
|
response.headers['X-Page'] = str(page)
|
|
@@ -126,8 +141,9 @@ def execute_sql_endpoint():
|
|
|
126
141
|
raise ApiError('Connection name is missing')
|
|
127
142
|
params = get_object(data.get('params'), 'params')
|
|
128
143
|
output_format = get_output_format(data)
|
|
144
|
+
timeout = get_timeout(data)
|
|
129
145
|
limit, offset, page = get_pagination()
|
|
130
|
-
result = engine.execute_sql(data['sql'], data['connection_name'], limit, offset, params)
|
|
146
|
+
result = engine.execute_sql(data['sql'], data['connection_name'], limit, offset, params, timeout)
|
|
131
147
|
return render(result, output_format, page, limit)
|
|
132
148
|
|
|
133
149
|
|
|
@@ -148,11 +164,12 @@ def run_saved(ref, body, url_params):
|
|
|
148
164
|
params = sqltools.coerce_params(saved.get('query_parameters'), raw)
|
|
149
165
|
sql = sqltools.fill_placeholders(saved['sql_query'], params)
|
|
150
166
|
output_format = get_output_format(body)
|
|
167
|
+
timeout = get_timeout(body)
|
|
151
168
|
limit, offset, page = get_pagination()
|
|
152
169
|
|
|
153
170
|
entry = {'executed_at': store.now(), 'connection_name': connection_name}
|
|
154
171
|
try:
|
|
155
|
-
result, elapsed_ms = engine.timed(engine.execute_sql, sql, connection_name, limit, offset, params)
|
|
172
|
+
result, elapsed_ms = engine.timed(engine.execute_sql, sql, connection_name, limit, offset, params, timeout)
|
|
156
173
|
except ApiError as error:
|
|
157
174
|
store.record_execution(path, number, {**entry, 'status': 'error', 'error': error.message})
|
|
158
175
|
raise
|
|
@@ -256,12 +273,14 @@ def update_connections():
|
|
|
256
273
|
if not connections or not isinstance(connections, dict):
|
|
257
274
|
raise ApiError('Connections data is missing')
|
|
258
275
|
store.update_connections(connections)
|
|
276
|
+
pool.close_pooled_connections() # new settings or credentials must not be served by old connections
|
|
259
277
|
return jsonify({'message': 'Connections updated successfully'}), 200
|
|
260
278
|
|
|
261
279
|
|
|
262
280
|
@bp.route('/connections/<name>', methods=['DELETE'])
|
|
263
281
|
def delete_connection(name):
|
|
264
282
|
store.delete_connection(name)
|
|
283
|
+
pool.close_pooled_connections() # a removed connection must not keep serving from idle sockets
|
|
265
284
|
return jsonify({'message': f"Connection '{name}' deleted"}), 200
|
|
266
285
|
|
|
267
286
|
|
sql2api/config.py
CHANGED
|
@@ -6,6 +6,9 @@ SUPPORTED_DB_TYPES = ('mysql', 'postgres', 'clickhouse', 'sqlite', 'h2')
|
|
|
6
6
|
PASSWORD_MASK = '********'
|
|
7
7
|
CONNECT_TIMEOUT = 10 # seconds
|
|
8
8
|
HISTORY_LIMIT = 50 # executions remembered per saved-query version
|
|
9
|
+
DEFAULT_QUERY_TIMEOUT = 30.0 # seconds
|
|
10
|
+
DEFAULT_POOL_SIZE = 5 # idle connections kept per distinct connection
|
|
11
|
+
DEFAULT_POOL_IDLE_TIMEOUT = 300.0 # seconds
|
|
9
12
|
|
|
10
13
|
# Ships with the package; override with SQL2API_H2_JAR to use a different H2 version.
|
|
11
14
|
BUNDLED_H2_JAR = Path(__file__).parent / 'lib' / 'h2-2.2.224.jar'
|
|
@@ -55,6 +58,42 @@ def api_key():
|
|
|
55
58
|
return os.environ.get('SQL2API_API_KEY') or None
|
|
56
59
|
|
|
57
60
|
|
|
61
|
+
def query_timeout():
|
|
62
|
+
"""Server-wide statement time limit in seconds (SQL2API_QUERY_TIMEOUT); None when disabled (set to 0)."""
|
|
63
|
+
try:
|
|
64
|
+
value = float(os.environ.get('SQL2API_QUERY_TIMEOUT', DEFAULT_QUERY_TIMEOUT))
|
|
65
|
+
except ValueError:
|
|
66
|
+
return DEFAULT_QUERY_TIMEOUT
|
|
67
|
+
if value == 0:
|
|
68
|
+
return None
|
|
69
|
+
return value if value > 0 else DEFAULT_QUERY_TIMEOUT
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def effective_timeout(requested=None):
|
|
73
|
+
"""The limit to enforce: a request may ask for less time than the server allows, never more."""
|
|
74
|
+
limit = query_timeout()
|
|
75
|
+
if requested is None:
|
|
76
|
+
return limit
|
|
77
|
+
return requested if limit is None else min(requested, limit)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def pool_size():
|
|
81
|
+
"""Idle connections kept per distinct connection (SQL2API_POOL_SIZE); 0 disables pooling."""
|
|
82
|
+
try:
|
|
83
|
+
return max(0, int(os.environ.get('SQL2API_POOL_SIZE', DEFAULT_POOL_SIZE)))
|
|
84
|
+
except ValueError:
|
|
85
|
+
return DEFAULT_POOL_SIZE
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def pool_idle_timeout():
|
|
89
|
+
"""Seconds an idle pooled connection is kept before it is closed (SQL2API_POOL_IDLE_TIMEOUT)."""
|
|
90
|
+
try:
|
|
91
|
+
value = float(os.environ.get('SQL2API_POOL_IDLE_TIMEOUT', DEFAULT_POOL_IDLE_TIMEOUT))
|
|
92
|
+
except ValueError:
|
|
93
|
+
return DEFAULT_POOL_IDLE_TIMEOUT
|
|
94
|
+
return value if value > 0 else DEFAULT_POOL_IDLE_TIMEOUT
|
|
95
|
+
|
|
96
|
+
|
|
58
97
|
def max_page_size():
|
|
59
98
|
try:
|
|
60
99
|
return max(1, int(os.environ.get('SQL2API_MAX_PAGE_SIZE', 1000)))
|
sql2api/engine.py
CHANGED
|
@@ -5,19 +5,22 @@ import time
|
|
|
5
5
|
from . import config, store
|
|
6
6
|
from .errors import ApiError
|
|
7
7
|
from .formats import ResultSetDTO
|
|
8
|
+
from .pool import get_pool
|
|
8
9
|
from .runners import RUNNERS
|
|
9
10
|
from .sqltools import validate_sql
|
|
10
11
|
|
|
11
12
|
log = logging.getLogger('sql2api')
|
|
12
13
|
|
|
13
14
|
|
|
14
|
-
def execute_sql(sql, connection_name, limit, offset, params=None):
|
|
15
|
+
def execute_sql(sql, connection_name, limit, offset, params=None, timeout=None):
|
|
15
16
|
"""Run ``sql`` on a named connection and return the requested page as a ResultSetDTO."""
|
|
16
17
|
details = store.get_connection(connection_name)
|
|
17
18
|
sql = validate_sql(sql)
|
|
18
|
-
log.info('Executing on %s (%s), limit=%s offset=%s: %s',
|
|
19
|
+
log.info('Executing on %s (%s), limit=%s offset=%s timeout=%s: %s',
|
|
20
|
+
connection_name, details['db'], limit, offset, timeout, sql)
|
|
19
21
|
try:
|
|
20
|
-
columns, rows = RUNNERS[details['db']](details, sql, params, limit, offset, not config.allow_writes()
|
|
22
|
+
columns, rows = RUNNERS[details['db']](details, sql, params, limit, offset, not config.allow_writes(), timeout,
|
|
23
|
+
get_pool())
|
|
21
24
|
except ApiError:
|
|
22
25
|
raise
|
|
23
26
|
except ImportError as error:
|
sql2api/openapi.py
CHANGED
|
@@ -7,12 +7,15 @@ _PAGE_PARAMS = [
|
|
|
7
7
|
{'name': 'page', 'in': 'query', 'schema': {'type': 'integer', 'minimum': 1, 'default': 1}},
|
|
8
8
|
{'name': 'page_size', 'in': 'query', 'schema': {'type': 'integer', 'minimum': 1, 'default': 10},
|
|
9
9
|
'description': 'Upper limit is SQL2API_MAX_PAGE_SIZE (default 1000).'},
|
|
10
|
+
{'name': 'timeout', 'in': 'query', 'schema': {'type': 'number', 'exclusiveMinimum': 0},
|
|
11
|
+
'description': 'Seconds the query may run before it is cancelled (504). Can lower, never raise, the server '
|
|
12
|
+
'limit SQL2API_QUERY_TIMEOUT (default 30; 0 disables the server limit).'},
|
|
10
13
|
]
|
|
11
14
|
_ROWS = {'description': 'The requested page. Header X-Has-More says whether another page follows.',
|
|
12
15
|
'headers': {'X-Page': {'schema': {'type': 'integer'}}, 'X-Page-Size': {'schema': {'type': 'integer'}},
|
|
13
16
|
'X-Has-More': {'schema': {'type': 'string', 'enum': ['true', 'false']}}},
|
|
14
17
|
'content': {'application/json': {'schema': {'type': 'array', 'items': {'type': 'object'}}}}}
|
|
15
|
-
_ERRORS = {c: {'$ref': '#/components/responses/Error'} for c in ('400', '401', '403', '404', '500')}
|
|
18
|
+
_ERRORS = {c: {'$ref': '#/components/responses/Error'} for c in ('400', '401', '403', '404', '500', '504')}
|
|
16
19
|
|
|
17
20
|
|
|
18
21
|
def _body(properties, required):
|
sql2api/pool.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""A small thread-safe pool of live database connections.
|
|
2
|
+
|
|
3
|
+
Only *idle* connections are pooled: a connection is checked out exclusively by one request, and on the way
|
|
4
|
+
back it is reset (its transaction ended) and kept for reuse - unless the request failed, in which case its
|
|
5
|
+
state is unknown and it is closed instead. The number of concurrent connections is not limited here; the
|
|
6
|
+
pool only bounds how many idle ones are kept around.
|
|
7
|
+
"""
|
|
8
|
+
import atexit
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from collections import deque
|
|
15
|
+
from contextlib import contextmanager
|
|
16
|
+
|
|
17
|
+
from . import config
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger('sql2api')
|
|
20
|
+
|
|
21
|
+
# A connection that has been idle for less than this is trusted without a round trip to check it.
|
|
22
|
+
VALIDATE_AFTER = 5.0 # seconds
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Session:
|
|
26
|
+
"""A connection plus what the driver wants to remember about it (e.g. limits already applied)."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, conn, driver):
|
|
29
|
+
self.conn = conn
|
|
30
|
+
self.driver = driver
|
|
31
|
+
self.state = {}
|
|
32
|
+
self.idle_since = time.monotonic()
|
|
33
|
+
|
|
34
|
+
def close(self):
|
|
35
|
+
try:
|
|
36
|
+
self.driver.close(self)
|
|
37
|
+
except Exception: # a connection that cannot be closed cleanly is gone anyway
|
|
38
|
+
log.debug('Error while closing a connection', exc_info=True)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def fingerprint(details, read_only):
|
|
42
|
+
"""Pool key: connections are interchangeable when their settings and read-only mode are identical."""
|
|
43
|
+
blob = json.dumps(details, sort_keys=True, default=str)
|
|
44
|
+
return hashlib.sha256(blob.encode('utf-8')).hexdigest(), bool(read_only)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ConnectionPool:
|
|
48
|
+
def __init__(self):
|
|
49
|
+
self._idle = {} # key -> deque of Sessions, least recently used on the left
|
|
50
|
+
self._lock = threading.Lock()
|
|
51
|
+
|
|
52
|
+
@contextmanager
|
|
53
|
+
def checkout(self, driver, details, read_only):
|
|
54
|
+
key = fingerprint(details, read_only)
|
|
55
|
+
session = self._acquire(key)
|
|
56
|
+
if session is None:
|
|
57
|
+
session = Session(driver.connect(details, read_only), driver)
|
|
58
|
+
try:
|
|
59
|
+
yield session
|
|
60
|
+
except BaseException:
|
|
61
|
+
session.close() # after an error its state is unknown - never reuse it
|
|
62
|
+
raise
|
|
63
|
+
self._release(key, session)
|
|
64
|
+
|
|
65
|
+
# ---- internals ------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
def _acquire(self, key):
|
|
68
|
+
while True:
|
|
69
|
+
with self._lock:
|
|
70
|
+
queue = self._idle.get(key)
|
|
71
|
+
if not queue:
|
|
72
|
+
return None
|
|
73
|
+
session = queue.pop() # most recently used first, so spare connections age out
|
|
74
|
+
if not queue:
|
|
75
|
+
del self._idle[key]
|
|
76
|
+
idle_for = time.monotonic() - session.idle_since
|
|
77
|
+
if idle_for > config.pool_idle_timeout():
|
|
78
|
+
session.close()
|
|
79
|
+
continue
|
|
80
|
+
if idle_for > VALIDATE_AFTER:
|
|
81
|
+
try:
|
|
82
|
+
alive = session.driver.is_alive(session)
|
|
83
|
+
except Exception:
|
|
84
|
+
alive = False
|
|
85
|
+
if not alive:
|
|
86
|
+
log.info('Discarding a dead pooled connection')
|
|
87
|
+
session.close()
|
|
88
|
+
continue
|
|
89
|
+
return session
|
|
90
|
+
|
|
91
|
+
def _release(self, key, session):
|
|
92
|
+
try:
|
|
93
|
+
session.driver.reset(session) # ends the transaction so the next user sees fresh data
|
|
94
|
+
except Exception:
|
|
95
|
+
log.debug('Could not reset a connection; closing it', exc_info=True)
|
|
96
|
+
session.close()
|
|
97
|
+
return
|
|
98
|
+
session.idle_since = time.monotonic()
|
|
99
|
+
surplus = []
|
|
100
|
+
with self._lock:
|
|
101
|
+
queue = self._idle.setdefault(key, deque())
|
|
102
|
+
queue.append(session)
|
|
103
|
+
while len(queue) > config.pool_size():
|
|
104
|
+
surplus.append(queue.popleft())
|
|
105
|
+
surplus.extend(self._expired_locked())
|
|
106
|
+
for old in surplus:
|
|
107
|
+
old.close()
|
|
108
|
+
|
|
109
|
+
def _expired_locked(self):
|
|
110
|
+
"""Remove and return idle sessions past the idle timeout, across every key (caller holds the lock)."""
|
|
111
|
+
cutoff = time.monotonic() - config.pool_idle_timeout()
|
|
112
|
+
expired = []
|
|
113
|
+
for key in list(self._idle):
|
|
114
|
+
queue = self._idle[key]
|
|
115
|
+
while queue and queue[0].idle_since < cutoff:
|
|
116
|
+
expired.append(queue.popleft())
|
|
117
|
+
if not queue:
|
|
118
|
+
del self._idle[key]
|
|
119
|
+
return expired
|
|
120
|
+
|
|
121
|
+
# ---- housekeeping ---------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def idle_count(self):
|
|
124
|
+
with self._lock:
|
|
125
|
+
return sum(len(q) for q in self._idle.values())
|
|
126
|
+
|
|
127
|
+
def close_all(self):
|
|
128
|
+
with self._lock:
|
|
129
|
+
sessions = [s for queue in self._idle.values() for s in queue]
|
|
130
|
+
self._idle.clear()
|
|
131
|
+
for session in sessions:
|
|
132
|
+
session.close()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
_default = ConnectionPool()
|
|
136
|
+
atexit.register(_default.close_all)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def close_pooled_connections():
|
|
140
|
+
"""Close every idle connection in the shared pool (also runs at interpreter exit)."""
|
|
141
|
+
_default.close_all()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def get_pool():
|
|
145
|
+
"""The shared pool, or None when pooling is disabled (SQL2API_POOL_SIZE=0)."""
|
|
146
|
+
return _default if config.pool_size() > 0 else None
|
sql2api/runners.py
CHANGED
|
@@ -1,16 +1,35 @@
|
|
|
1
|
-
"""One runner per database type
|
|
1
|
+
"""One runner per database type: connect (or borrow a pooled connection), run one statement, return (columns, rows).
|
|
2
2
|
|
|
3
3
|
Runners fetch ``limit + 1`` rows: the extra row is how the caller learns whether another page exists.
|
|
4
4
|
Database drivers are imported lazily so only the ones you actually use need to be installed.
|
|
5
|
+
|
|
6
|
+
Each network database is a small ``_Driver`` subclass. ``_make_runner`` turns it into the callable stored in
|
|
7
|
+
``RUNNERS``, which either opens a connection for the single call or borrows one from the pool.
|
|
5
8
|
"""
|
|
9
|
+
import atexit
|
|
10
|
+
import logging
|
|
11
|
+
import math
|
|
6
12
|
import os
|
|
7
13
|
import sqlite3
|
|
14
|
+
import time
|
|
8
15
|
from pathlib import Path
|
|
9
16
|
|
|
10
17
|
from . import config
|
|
11
18
|
from .errors import ApiError
|
|
19
|
+
from .pool import Session, close_pooled_connections
|
|
12
20
|
from .sqltools import bind_parameters, is_paginated, paginate
|
|
13
21
|
|
|
22
|
+
log = logging.getLogger('sql2api')
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _timed_out(timeout):
|
|
26
|
+
return ApiError(f'The query exceeded the time limit of {timeout:g} seconds and was cancelled', 504,
|
|
27
|
+
timeout=timeout)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _millis(timeout):
|
|
31
|
+
return max(1, int(timeout * 1000))
|
|
32
|
+
|
|
14
33
|
|
|
15
34
|
def _connect_args(details, **renames):
|
|
16
35
|
"""Pick the standard connection fields out of a stored connection, renaming keys per driver."""
|
|
@@ -45,53 +64,250 @@ def _fetch_page(cursor, sql, params, style, limit, offset):
|
|
|
45
64
|
return [d[0] for d in cursor.description], rows
|
|
46
65
|
|
|
47
66
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
67
|
+
# --------------------------------------------------------------------------------------
|
|
68
|
+
# Drivers
|
|
69
|
+
# --------------------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
class _Driver:
|
|
72
|
+
"""How to connect to, check, reset, query and close one kind of database."""
|
|
73
|
+
|
|
74
|
+
def connect(self, details, read_only):
|
|
75
|
+
raise NotImplementedError
|
|
76
|
+
|
|
77
|
+
def is_alive(self, session):
|
|
78
|
+
"""Cheap check that a connection that sat idle is still usable."""
|
|
79
|
+
return True
|
|
80
|
+
|
|
81
|
+
def reset(self, session):
|
|
82
|
+
"""Put a used connection back into a clean state: end its transaction so no stale snapshot lingers."""
|
|
83
|
+
|
|
84
|
+
def close(self, session):
|
|
85
|
+
session.conn.close()
|
|
86
|
+
|
|
87
|
+
def query(self, session, sql, params, limit, offset, read_only, timeout):
|
|
88
|
+
raise NotImplementedError
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# MySQL reports 3024 (ER_QUERY_TIMEOUT), MariaDB 1969 (ER_STATEMENT_TIMEOUT)
|
|
92
|
+
_MYSQL_TIMEOUT_ERRNOS = (3024, 1969)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class _MySQL(_Driver):
|
|
96
|
+
def connect(self, details, read_only):
|
|
97
|
+
import mysql.connector
|
|
98
|
+
conn = mysql.connector.connect(connection_timeout=config.CONNECT_TIMEOUT, **_connect_args(details))
|
|
53
99
|
if read_only:
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
100
|
+
try:
|
|
101
|
+
cursor = conn.cursor()
|
|
102
|
+
cursor.execute('SET SESSION TRANSACTION READ ONLY')
|
|
103
|
+
cursor.close()
|
|
104
|
+
except Exception:
|
|
105
|
+
conn.close()
|
|
106
|
+
raise
|
|
107
|
+
return conn
|
|
61
108
|
|
|
109
|
+
def is_alive(self, session):
|
|
110
|
+
return session.conn.is_connected()
|
|
62
111
|
|
|
63
|
-
def
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
112
|
+
def reset(self, session):
|
|
113
|
+
session.conn.rollback()
|
|
114
|
+
|
|
115
|
+
def query(self, session, sql, params, limit, offset, read_only, timeout):
|
|
116
|
+
import mysql.connector
|
|
117
|
+
conn = session.conn
|
|
118
|
+
cursor = conn.cursor()
|
|
119
|
+
try:
|
|
120
|
+
if timeout != session.state.get('timeout'):
|
|
121
|
+
# MySQL limits SELECT statements in milliseconds; MariaDB uses a differently named variable
|
|
122
|
+
# in seconds. 0 removes a limit that an earlier request applied to this pooled connection.
|
|
123
|
+
try:
|
|
124
|
+
cursor.execute(f'SET SESSION max_execution_time = {_millis(timeout) if timeout else 0}')
|
|
125
|
+
except mysql.connector.Error:
|
|
126
|
+
try:
|
|
127
|
+
cursor.execute(f'SET SESSION max_statement_time = {timeout:g}' if timeout
|
|
128
|
+
else 'SET SESSION max_statement_time = 0')
|
|
129
|
+
except mysql.connector.Error:
|
|
130
|
+
log.warning('This MySQL server supports neither max_execution_time nor '
|
|
131
|
+
'max_statement_time; the query time limit is not enforced')
|
|
132
|
+
session.state['timeout'] = timeout
|
|
133
|
+
try:
|
|
134
|
+
result = _fetch_page(cursor, sql, params, 'format', limit, offset)
|
|
135
|
+
except mysql.connector.Error as error:
|
|
136
|
+
if getattr(error, 'errno', None) in _MYSQL_TIMEOUT_ERRNOS:
|
|
137
|
+
raise _timed_out(timeout) from None
|
|
138
|
+
raise
|
|
139
|
+
if not read_only:
|
|
140
|
+
conn.commit()
|
|
141
|
+
return result
|
|
142
|
+
finally:
|
|
143
|
+
cursor.close()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class _Postgres(_Driver):
|
|
147
|
+
def connect(self, details, read_only):
|
|
148
|
+
import psycopg2
|
|
149
|
+
conn = psycopg2.connect(connect_timeout=config.CONNECT_TIMEOUT, **_connect_args(details, database='dbname'))
|
|
67
150
|
if read_only:
|
|
68
|
-
|
|
151
|
+
try:
|
|
152
|
+
conn.set_session(readonly=True)
|
|
153
|
+
except Exception:
|
|
154
|
+
conn.close()
|
|
155
|
+
raise
|
|
156
|
+
return conn
|
|
157
|
+
|
|
158
|
+
def is_alive(self, session):
|
|
159
|
+
if session.conn.closed:
|
|
160
|
+
return False
|
|
161
|
+
with session.conn.cursor() as cursor:
|
|
162
|
+
cursor.execute('SELECT 1')
|
|
163
|
+
return True
|
|
164
|
+
|
|
165
|
+
def reset(self, session):
|
|
166
|
+
session.conn.rollback()
|
|
167
|
+
|
|
168
|
+
def query(self, session, sql, params, limit, offset, read_only, timeout):
|
|
169
|
+
import psycopg2.errors
|
|
170
|
+
conn = session.conn
|
|
69
171
|
with conn.cursor() as cursor:
|
|
70
|
-
|
|
172
|
+
if timeout:
|
|
173
|
+
# LOCAL scopes the limit to this transaction, so nothing leaks to the connection's next user
|
|
174
|
+
cursor.execute(f'SET LOCAL statement_timeout = {_millis(timeout)}')
|
|
175
|
+
try:
|
|
176
|
+
result = _fetch_page(cursor, sql, params, 'format', limit, offset)
|
|
177
|
+
except psycopg2.errors.QueryCanceled:
|
|
178
|
+
raise _timed_out(timeout) from None
|
|
71
179
|
if not read_only:
|
|
72
180
|
conn.commit()
|
|
73
181
|
return result
|
|
74
|
-
finally:
|
|
75
|
-
conn.close()
|
|
76
182
|
|
|
77
183
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
184
|
+
_CLICKHOUSE_TIMEOUT_EXCEEDED = 159
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class _ClickHouse(_Driver):
|
|
188
|
+
# The native-protocol Client pings before each query and reconnects by itself, so it needs no
|
|
189
|
+
# is_alive check, and every setting is sent per query, so there is no session state to reset.
|
|
190
|
+
def connect(self, details, read_only):
|
|
191
|
+
from clickhouse_driver import Client
|
|
192
|
+
client_args = {}
|
|
193
|
+
limit = config.query_timeout()
|
|
194
|
+
if limit:
|
|
195
|
+
# Backstop in case the server never answers; the server-side limit below is what normally fires.
|
|
196
|
+
client_args['send_receive_timeout'] = math.ceil(limit) + 5
|
|
197
|
+
return Client(connect_timeout=config.CONNECT_TIMEOUT, **client_args, **_connect_args(details))
|
|
198
|
+
|
|
199
|
+
def close(self, session):
|
|
200
|
+
session.conn.disconnect()
|
|
201
|
+
|
|
202
|
+
def query(self, session, sql, params, limit, offset, read_only, timeout):
|
|
203
|
+
from clickhouse_driver.errors import ServerException
|
|
82
204
|
sql, args, sliced = _prepare(sql, params, 'pyformat', limit, offset)
|
|
83
|
-
|
|
84
|
-
|
|
205
|
+
settings = {}
|
|
206
|
+
if timeout:
|
|
207
|
+
settings['max_execution_time'] = math.ceil(timeout) # whole seconds
|
|
208
|
+
if read_only:
|
|
209
|
+
settings['readonly'] = 1 # last: it forbids changing settings after it
|
|
210
|
+
try:
|
|
211
|
+
result = session.conn.execute(sql, args, with_column_types=True, settings=settings or None)
|
|
212
|
+
except ServerException as error:
|
|
213
|
+
if error.code == _CLICKHOUSE_TIMEOUT_EXCEEDED:
|
|
214
|
+
raise _timed_out(timeout) from None
|
|
215
|
+
raise
|
|
85
216
|
# Statements without a result set (DDL, INSERT) may not return a (rows, types) pair.
|
|
86
217
|
rows, column_types = result if isinstance(result, tuple) else ([], [])
|
|
87
218
|
if not sliced:
|
|
88
219
|
rows = rows[offset:offset + limit + 1]
|
|
89
220
|
return [c[0] for c in column_types or []], rows
|
|
90
|
-
finally:
|
|
91
|
-
client.disconnect()
|
|
92
221
|
|
|
93
222
|
|
|
94
|
-
def
|
|
223
|
+
def _attach_thread_as_daemon():
|
|
224
|
+
"""Make the calling thread a *daemon* thread as far as the JVM is concerned.
|
|
225
|
+
|
|
226
|
+
jaydebeapi attaches every thread that uses H2 to the JVM as a non-daemon thread, and JPype's shutdown then
|
|
227
|
+
waits for those threads forever - so a server that has handled concurrent H2 requests would hang on exit.
|
|
228
|
+
Attaching them as daemons first (jaydebeapi only attaches threads that are not attached yet) avoids that.
|
|
229
|
+
"""
|
|
230
|
+
import jpype
|
|
231
|
+
if jpype.isJVMStarted() and not jpype.java.lang.Thread.isAttached():
|
|
232
|
+
jpype.java.lang.Thread.attachAsDaemon()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class _H2(_Driver):
|
|
236
|
+
_exit_hook_registered = False
|
|
237
|
+
|
|
238
|
+
def connect(self, details, read_only):
|
|
239
|
+
import jaydebeapi
|
|
240
|
+
_attach_thread_as_daemon()
|
|
241
|
+
host = details.get('host') or 'localhost'
|
|
242
|
+
if details.get('port') and ':' not in host:
|
|
243
|
+
host = f"{host}:{details['port']}"
|
|
244
|
+
url = f"jdbc:h2:tcp://{host}/~/{details.get('database')}"
|
|
245
|
+
# Unlike the other drivers, H2's JDBC setReadOnly() is only a hint and does not block writes,
|
|
246
|
+
# so here the read-only guarantee rests on validate_sql() alone.
|
|
247
|
+
conn = jaydebeapi.connect('org.h2.Driver', url, [details.get('user'), details.get('password')],
|
|
248
|
+
[config.h2_jar()])
|
|
249
|
+
if not _H2._exit_hook_registered:
|
|
250
|
+
# JPype shuts the JVM down from its own atexit hook, which it registers when the JVM starts (just
|
|
251
|
+
# now). Hooks run last-in first-out, so registering ours after that makes pooled connections close
|
|
252
|
+
# while the JVM is still alive - closing them afterwards would hang the interpreter at exit.
|
|
253
|
+
atexit.register(close_pooled_connections)
|
|
254
|
+
_H2._exit_hook_registered = True
|
|
255
|
+
return conn
|
|
256
|
+
|
|
257
|
+
def is_alive(self, session):
|
|
258
|
+
_attach_thread_as_daemon()
|
|
259
|
+
return bool(session.conn.jconn.isValid(2))
|
|
260
|
+
|
|
261
|
+
def reset(self, session):
|
|
262
|
+
_attach_thread_as_daemon()
|
|
263
|
+
session.conn.rollback()
|
|
264
|
+
|
|
265
|
+
def close(self, session):
|
|
266
|
+
_attach_thread_as_daemon()
|
|
267
|
+
session.conn.close()
|
|
268
|
+
|
|
269
|
+
def query(self, session, sql, params, limit, offset, read_only, timeout):
|
|
270
|
+
import jaydebeapi
|
|
271
|
+
_attach_thread_as_daemon()
|
|
272
|
+
conn = session.conn
|
|
273
|
+
cursor = conn.cursor()
|
|
274
|
+
if timeout != session.state.get('timeout'):
|
|
275
|
+
cursor.execute(f'SET QUERY_TIMEOUT {_millis(timeout) if timeout else 0}')
|
|
276
|
+
session.state['timeout'] = timeout
|
|
277
|
+
try:
|
|
278
|
+
result = _fetch_page(cursor, sql, params, 'qmark', limit, offset)
|
|
279
|
+
except jaydebeapi.Error as error:
|
|
280
|
+
# H2: "Statement was canceled or the session timed out" (error 57014 / 90051)
|
|
281
|
+
if timeout and any(word in str(error).lower() for word in ('canceled', 'timed out')):
|
|
282
|
+
raise _timed_out(timeout) from None
|
|
283
|
+
raise
|
|
284
|
+
if not read_only:
|
|
285
|
+
conn.commit()
|
|
286
|
+
return result
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _make_runner(driver):
|
|
290
|
+
def run(details, sql, params, limit, offset, read_only, timeout=None, pool=None):
|
|
291
|
+
if pool is None:
|
|
292
|
+
session = Session(driver.connect(details, read_only), driver)
|
|
293
|
+
try:
|
|
294
|
+
return driver.query(session, sql, params, limit, offset, read_only, timeout)
|
|
295
|
+
finally:
|
|
296
|
+
session.close()
|
|
297
|
+
with pool.checkout(driver, details, read_only) as session:
|
|
298
|
+
return driver.query(session, sql, params, limit, offset, read_only, timeout)
|
|
299
|
+
|
|
300
|
+
return run
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
_run_mysql = _make_runner(_MySQL())
|
|
304
|
+
_run_postgres = _make_runner(_Postgres())
|
|
305
|
+
_run_clickhouse = _make_runner(_ClickHouse())
|
|
306
|
+
_run_h2 = _make_runner(_H2())
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _run_sqlite(details, sql, params, limit, offset, read_only, timeout=None, pool=None):
|
|
310
|
+
"""SQLite is a local file, so opening it per call is cheap and it is never pooled."""
|
|
95
311
|
path = details.get('database')
|
|
96
312
|
if not path:
|
|
97
313
|
raise ApiError('Database file path not provided')
|
|
@@ -104,28 +320,24 @@ def _run_sqlite(details, sql, params, limit, offset, read_only):
|
|
|
104
320
|
else:
|
|
105
321
|
conn = sqlite3.connect(path, timeout=config.CONNECT_TIMEOUT)
|
|
106
322
|
try:
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
conn.commit()
|
|
111
|
-
return result
|
|
112
|
-
finally:
|
|
113
|
-
conn.close()
|
|
323
|
+
expired = []
|
|
324
|
+
if timeout:
|
|
325
|
+
deadline = time.monotonic() + timeout
|
|
114
326
|
|
|
327
|
+
def check_deadline():
|
|
328
|
+
if time.monotonic() > deadline:
|
|
329
|
+
expired.append(True)
|
|
330
|
+
return 1 # non-zero aborts the running statement
|
|
331
|
+
return 0
|
|
115
332
|
|
|
116
|
-
|
|
117
|
-
import jaydebeapi
|
|
118
|
-
host = details.get('host') or 'localhost'
|
|
119
|
-
if details.get('port') and ':' not in host:
|
|
120
|
-
host = f"{host}:{details['port']}"
|
|
121
|
-
url = f"jdbc:h2:tcp://{host}/~/{details.get('database')}"
|
|
122
|
-
conn = jaydebeapi.connect('org.h2.Driver', url, [details.get('user'), details.get('password')],
|
|
123
|
-
[config.h2_jar()])
|
|
124
|
-
try:
|
|
125
|
-
# Unlike the other drivers, H2's JDBC setReadOnly() is only a hint and does not block writes,
|
|
126
|
-
# so here the read-only guarantee rests on validate_sql() alone.
|
|
333
|
+
conn.set_progress_handler(check_deadline, 10000) # called every 10 000 VM instructions
|
|
127
334
|
cursor = conn.cursor()
|
|
128
|
-
|
|
335
|
+
try:
|
|
336
|
+
result = _fetch_page(cursor, sql, params, 'qmark', limit, offset)
|
|
337
|
+
except sqlite3.OperationalError:
|
|
338
|
+
if expired:
|
|
339
|
+
raise _timed_out(timeout) from None
|
|
340
|
+
raise
|
|
129
341
|
if not read_only:
|
|
130
342
|
conn.commit()
|
|
131
343
|
return result
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sql2api
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Turn SQL into a REST API: run queries against MySQL, PostgreSQL, ClickHouse, SQLite or H2 over HTTP and get JSON, CSV, XML, YAML or XLSX back.
|
|
5
5
|
Author-email: Anantha Raju C <arcswdev@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -75,6 +75,10 @@ film_id,title,rating,length
|
|
|
75
75
|
- **Bound parameters** - write `WHERE id = :id` and the value is sent to the database separately from the SQL, so it
|
|
76
76
|
cannot inject anything. Declare types (`{"id": "int"}`) and query-string values are converted for you.
|
|
77
77
|
- **Pagination** - `?page=2&page_size=50`, with `X-Has-More` telling you whether another page exists.
|
|
78
|
+
- **Connection pooling** - MySQL, PostgreSQL, ClickHouse and H2 connections are reused between requests instead of
|
|
79
|
+
opened for each one (about 30x lower per-request overhead on MySQL and H2 against a local server; more over a network).
|
|
80
|
+
- **Query time limit** - runaway queries are cancelled on the database (30 s by default, `?timeout=` per request) so
|
|
81
|
+
they cannot tie up the service.
|
|
78
82
|
- **Read-only by default** - only single `SELECT`/`WITH`/`SHOW`/`DESCRIBE`/`EXPLAIN` statements run, and sessions are
|
|
79
83
|
opened read-only where the database supports it.
|
|
80
84
|
- **Secrets stay out of files** - `"password": "${PG_PASSWORD}"` in `db_connections.json` reads the environment.
|
|
@@ -136,6 +140,9 @@ Everything is configured through environment variables (all optional):
|
|
|
136
140
|
| `SQL2API_ALLOW_WRITES` | off | Allow `INSERT`/`UPDATE`/DDL. Otherwise only single read-only statements are accepted. |
|
|
137
141
|
| `SQL2API_API_KEY` | unset | When set, every request (except `/health` and `/docs`) needs a matching `X-API-Key` header. |
|
|
138
142
|
| `SQL2API_MAX_PAGE_SIZE` | `1000` | Upper limit for `page_size`. |
|
|
143
|
+
| `SQL2API_POOL_SIZE` | `5` | Idle connections kept per distinct connection setting. `0` turns pooling off. |
|
|
144
|
+
| `SQL2API_POOL_IDLE_TIMEOUT` | `300` | Seconds an idle pooled connection is kept before it is closed. |
|
|
145
|
+
| `SQL2API_QUERY_TIMEOUT` | `30` | Seconds a query may run before it is cancelled (HTTP 504). `0` disables the limit. A request can lower it with `?timeout=`, never raise it. |
|
|
139
146
|
| `SQL2API_HOST` / `SQL2API_PORT` | `127.0.0.1` / `5000` | Bind address for `sql2api serve`. |
|
|
140
147
|
| `SQL2API_DEBUG` | off | Flask debug mode. Never enable on a reachable host. |
|
|
141
148
|
| `SQL2API_H2_JAR` | bundled | Path to a different H2 JDBC jar. |
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
sql2api/__init__.py,sha256=aVq6e6s7KZt7JlbdHpxY4q_3dOZSuzf8wFrLLggZyQc,185
|
|
2
|
+
sql2api/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
|
|
3
|
+
sql2api/app.py,sha256=AcYUNu31OJ_aW9_kbXtuYJDAJH5UW3paNuv52UgMWOs,12335
|
|
4
|
+
sql2api/cli.py,sha256=1L3RYiCgnh1w_speXB4RPqxNWs5kR7kT68X9L-aqDLk,2712
|
|
5
|
+
sql2api/config.py,sha256=ACMeLisww_6WmzdbIysmZjBui6QxQLWh1evv8z1l9aw,3663
|
|
6
|
+
sql2api/engine.py,sha256=hys-ArxL4n3WWIsrk8hcERaLB11io_f5Wpr5x9zVBr4,1691
|
|
7
|
+
sql2api/errors.py,sha256=nJddO1gAjxQZH7-IXf3Bt8ruxv3EhVw6t6viFKfluG4,264
|
|
8
|
+
sql2api/formats.py,sha256=SbLDZ-yQIt7pcWXYi8WNYEmG6JNqoeeGeRwIg5XHmos,4817
|
|
9
|
+
sql2api/openapi.py,sha256=O7iRvEic1L2OXAp-P3N1FXpPdpTM1w0rAEShWEcpjfk,8905
|
|
10
|
+
sql2api/pool.py,sha256=hIxkaVkDnjQEV_4f62yq_HtZkCEHiq_qR_fUGyHGhiA,5170
|
|
11
|
+
sql2api/runners.py,sha256=ANYrGluet1HOV_Fi1IvJFZMEj1CFtjKEbQzuksqLi0g,13782
|
|
12
|
+
sql2api/sqltools.py,sha256=HPNC2hCSkBqiJ8O-WYWj7gwILnNOtyVD5A_58SG3lik,6657
|
|
13
|
+
sql2api/store.py,sha256=OHLE1PU7ZrqQcPJec4jJbqNeFBDSt7TOUO8NfH7QLJg,9890
|
|
14
|
+
sql2api/lib/h2-2.2.224.jar,sha256=udjxk1itqCpPbrWxdMbP4yCjdbWpy1pP5FbWI-blVJc,2614933
|
|
15
|
+
sql2api-0.2.0.dist-info/licenses/LICENSE,sha256=ufgSbLEx8zM6J0kFgDoK-6NNb0gqrsFfCb-CnNbZGg0,1071
|
|
16
|
+
sql2api-0.2.0.dist-info/METADATA,sha256=YgkgbcA2O-BWhcS77nr8YzltW3YB8PO9NftPfBoEQmU,10241
|
|
17
|
+
sql2api-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
18
|
+
sql2api-0.2.0.dist-info/entry_points.txt,sha256=63gZjCA84qTkErE2iiuTiGS7GtdamvOqi2elEXwy_zE,45
|
|
19
|
+
sql2api-0.2.0.dist-info/top_level.txt,sha256=dicWZ6j0AE1xBDKr0-qh2CkuP7Yc1CWQf_T6r8LS6ms,8
|
|
20
|
+
sql2api-0.2.0.dist-info/RECORD,,
|
sql2api-0.1.0.dist-info/RECORD
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
sql2api/__init__.py,sha256=FeKzfwtM7UszmZ7HBQ8AQi_WNHabfDeAIpWARwhqfys,185
|
|
2
|
-
sql2api/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
|
|
3
|
-
sql2api/app.py,sha256=VwoiN9TC2a8wPbCyf3IFMuWsnjteH5scrItLKIgbOoI,11447
|
|
4
|
-
sql2api/cli.py,sha256=1L3RYiCgnh1w_speXB4RPqxNWs5kR7kT68X9L-aqDLk,2712
|
|
5
|
-
sql2api/config.py,sha256=goaJF810BKzyeENR-6lc1l6k1OdSaF4-3CJkpMWv4gU,2222
|
|
6
|
-
sql2api/engine.py,sha256=b4uS0FZAWuUipr_CpAWjyVYPyzTtiDD2HDnfvI1PNP0,1551
|
|
7
|
-
sql2api/errors.py,sha256=nJddO1gAjxQZH7-IXf3Bt8ruxv3EhVw6t6viFKfluG4,264
|
|
8
|
-
sql2api/formats.py,sha256=SbLDZ-yQIt7pcWXYi8WNYEmG6JNqoeeGeRwIg5XHmos,4817
|
|
9
|
-
sql2api/openapi.py,sha256=evoO6KsPN0UjvdxYR0dwHTMygAI-7FIPxhURh7i4rXY,8597
|
|
10
|
-
sql2api/runners.py,sha256=tiP4-zNaD-bSdTycr1OBiHx00A-KESpLXXudhAhwBFw,5218
|
|
11
|
-
sql2api/sqltools.py,sha256=HPNC2hCSkBqiJ8O-WYWj7gwILnNOtyVD5A_58SG3lik,6657
|
|
12
|
-
sql2api/store.py,sha256=OHLE1PU7ZrqQcPJec4jJbqNeFBDSt7TOUO8NfH7QLJg,9890
|
|
13
|
-
sql2api/lib/h2-2.2.224.jar,sha256=udjxk1itqCpPbrWxdMbP4yCjdbWpy1pP5FbWI-blVJc,2614933
|
|
14
|
-
sql2api-0.1.0.dist-info/licenses/LICENSE,sha256=ufgSbLEx8zM6J0kFgDoK-6NNb0gqrsFfCb-CnNbZGg0,1071
|
|
15
|
-
sql2api-0.1.0.dist-info/METADATA,sha256=xrC-ppNP7rufNkYTdi_eFDmkieHx_jEjirZOFsf8F38,9461
|
|
16
|
-
sql2api-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
17
|
-
sql2api-0.1.0.dist-info/entry_points.txt,sha256=63gZjCA84qTkErE2iiuTiGS7GtdamvOqi2elEXwy_zE,45
|
|
18
|
-
sql2api-0.1.0.dist-info/top_level.txt,sha256=dicWZ6j0AE1xBDKr0-qh2CkuP7Yc1CWQf_T6r8LS6ms,8
|
|
19
|
-
sql2api-0.1.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|