sqlscope-rs 0.1.0__tar.gz → 0.3.0__tar.gz
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.
- sqlscope_rs-0.3.0/.gitignore +10 -0
- {sqlscope_rs-0.1.0 → sqlscope_rs-0.3.0}/PKG-INFO +18 -11
- sqlscope_rs-0.3.0/README.md +32 -0
- {sqlscope_rs-0.1.0 → sqlscope_rs-0.3.0}/pyproject.toml +8 -8
- {sqlscope_rs-0.1.0/python → sqlscope_rs-0.3.0/src}/sqlscope/__init__.py +62 -28
- sqlscope_rs-0.3.0/src/sqlscope/_library.py +157 -0
- {sqlscope_rs-0.1.0/python → sqlscope_rs-0.3.0}/tests/test_sqlscope.py +16 -1
- sqlscope_rs-0.1.0/Cargo.lock +0 -397
- sqlscope_rs-0.1.0/Cargo.toml +0 -29
- sqlscope_rs-0.1.0/README.md +0 -23
- sqlscope_rs-0.1.0/crates/sqlscope/Cargo.toml +0 -23
- sqlscope_rs-0.1.0/crates/sqlscope/README.md +0 -189
- sqlscope_rs-0.1.0/crates/sqlscope/src/ast.rs +0 -353
- sqlscope_rs-0.1.0/crates/sqlscope/src/column_origins.rs +0 -85
- sqlscope_rs-0.1.0/crates/sqlscope/src/column_usages.rs +0 -168
- sqlscope_rs-0.1.0/crates/sqlscope/src/error.rs +0 -104
- sqlscope_rs-0.1.0/crates/sqlscope/src/inject_ctes.rs +0 -136
- sqlscope_rs-0.1.0/crates/sqlscope/src/lib.rs +0 -38
- sqlscope_rs-0.1.0/crates/sqlscope/src/normalize.rs +0 -170
- sqlscope_rs-0.1.0/crates/sqlscope/src/options.rs +0 -165
- sqlscope_rs-0.1.0/crates/sqlscope/src/output_columns.rs +0 -176
- sqlscope_rs-0.1.0/crates/sqlscope/src/resolver.rs +0 -1061
- sqlscope_rs-0.1.0/crates/sqlscope/src/rewrite.rs +0 -670
- sqlscope_rs-0.1.0/crates/sqlscope/src/rewrite_tables.rs +0 -278
- sqlscope_rs-0.1.0/crates/sqlscope/src/row_filter.rs +0 -220
- sqlscope_rs-0.1.0/crates/sqlscope/src/schema.rs +0 -70
- sqlscope_rs-0.1.0/crates/sqlscope/tests/column_origins.rs +0 -288
- sqlscope_rs-0.1.0/crates/sqlscope/tests/common/mod.rs +0 -152
- sqlscope_rs-0.1.0/crates/sqlscope/tests/guards.rs +0 -97
- sqlscope_rs-0.1.0/crates/sqlscope/tests/inject_ctes.rs +0 -273
- sqlscope_rs-0.1.0/crates/sqlscope/tests/output_columns.rs +0 -237
- sqlscope_rs-0.1.0/crates/sqlscope/tests/referenced_columns.rs +0 -722
- sqlscope_rs-0.1.0/crates/sqlscope/tests/rewrite_tables.rs +0 -478
- sqlscope_rs-0.1.0/crates/sqlscope/tests/row_filter.rs +0 -616
- sqlscope_rs-0.1.0/python/Cargo.toml +0 -21
- sqlscope_rs-0.1.0/python/README.md +0 -23
- sqlscope_rs-0.1.0/python/build.rs +0 -4
- sqlscope_rs-0.1.0/python/sqlscope/_native.pyi +0 -39
- sqlscope_rs-0.1.0/python/src/lib.rs +0 -215
- {sqlscope_rs-0.1.0/python → sqlscope_rs-0.3.0/src}/sqlscope/py.typed +0 -0
|
@@ -1,21 +1,27 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: sqlscope-rs
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Scope-aware SQL analysis and rewriting: row filters, CTE injection, table rewrites, column lineage.
|
|
5
|
+
Project-URL: Repository, https://github.com/dcalsky/sqlscope
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: lineage,parser,row-level-security,sql
|
|
4
8
|
Classifier: Programming Language :: Python :: 3
|
|
5
|
-
Classifier: Programming Language :: Rust
|
|
6
9
|
Classifier: Topic :: Database
|
|
7
10
|
Classifier: Typing :: Typed
|
|
8
|
-
Summary: Scope-aware SQL analysis and rewriting: row filters, CTE injection, table rewrites, column lineage.
|
|
9
|
-
Keywords: sql,lineage,row-level-security,parser
|
|
10
|
-
License-Expression: MIT
|
|
11
11
|
Requires-Python: >=3.9
|
|
12
|
-
Description-Content-Type: text/markdown
|
|
13
|
-
Project-URL: Repository, https://github.com/dcalsky/sqlscope
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
14
13
|
|
|
15
14
|
# sqlscope (Python)
|
|
16
15
|
|
|
17
|
-
Scope-aware SQL analysis and rewriting, backed by the
|
|
18
|
-
[sqlscope](https://github.com/dcalsky/sqlscope)
|
|
16
|
+
Scope-aware SQL analysis and rewriting, backed by the
|
|
17
|
+
[sqlscope](https://github.com/dcalsky/sqlscope) FFI library.
|
|
18
|
+
|
|
19
|
+
This package is pure Python. It loads the sqlscope shared library
|
|
20
|
+
(`libsqlscope_ffi.so`, `libsqlscope_ffi.dylib` or `sqlscope_ffi.dll`) from a
|
|
21
|
+
[sqlscope release](https://github.com/dcalsky/sqlscope/releases) through
|
|
22
|
+
`ctypes`, and never bundles or downloads it. Point it at the library with
|
|
23
|
+
`SQLSCOPE_LIBRARY_PATH` or `sqlscope.load(path)`; otherwise it looks in the
|
|
24
|
+
package directory and then on the system library search path.
|
|
19
25
|
|
|
20
26
|
```bash
|
|
21
27
|
pip install sqlscope-rs
|
|
@@ -24,6 +30,8 @@ pip install sqlscope-rs
|
|
|
24
30
|
```python
|
|
25
31
|
import sqlscope
|
|
26
32
|
|
|
33
|
+
sqlscope.load("/opt/sqlscope/libsqlscope_ffi.so") # optional with SQLSCOPE_LIBRARY_PATH
|
|
34
|
+
|
|
27
35
|
sqlscope.apply_row_filter("SELECT id FROM orders", "tenant_id = 7", dialect="postgres")
|
|
28
36
|
# 'SELECT id FROM (SELECT * FROM orders WHERE tenant_id = 7) AS orders'
|
|
29
37
|
|
|
@@ -35,4 +43,3 @@ sqlscope.column_origins(
|
|
|
35
43
|
|
|
36
44
|
See the [project README](https://github.com/dcalsky/sqlscope#readme) for the
|
|
37
45
|
full API.
|
|
38
|
-
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# sqlscope (Python)
|
|
2
|
+
|
|
3
|
+
Scope-aware SQL analysis and rewriting, backed by the
|
|
4
|
+
[sqlscope](https://github.com/dcalsky/sqlscope) FFI library.
|
|
5
|
+
|
|
6
|
+
This package is pure Python. It loads the sqlscope shared library
|
|
7
|
+
(`libsqlscope_ffi.so`, `libsqlscope_ffi.dylib` or `sqlscope_ffi.dll`) from a
|
|
8
|
+
[sqlscope release](https://github.com/dcalsky/sqlscope/releases) through
|
|
9
|
+
`ctypes`, and never bundles or downloads it. Point it at the library with
|
|
10
|
+
`SQLSCOPE_LIBRARY_PATH` or `sqlscope.load(path)`; otherwise it looks in the
|
|
11
|
+
package directory and then on the system library search path.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install sqlscope-rs
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
import sqlscope
|
|
19
|
+
|
|
20
|
+
sqlscope.load("/opt/sqlscope/libsqlscope_ffi.so") # optional with SQLSCOPE_LIBRARY_PATH
|
|
21
|
+
|
|
22
|
+
sqlscope.apply_row_filter("SELECT id FROM orders", "tenant_id = 7", dialect="postgres")
|
|
23
|
+
# 'SELECT id FROM (SELECT * FROM orders WHERE tenant_id = 7) AS orders'
|
|
24
|
+
|
|
25
|
+
sqlscope.column_origins(
|
|
26
|
+
"SELECT o.id, p.amount FROM orders o JOIN payments p ON o.id = p.order_id WHERE o.status = 'PAID'"
|
|
27
|
+
)
|
|
28
|
+
# {'orders': ['id'], 'payments': ['amount']}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
See the [project README](https://github.com/dcalsky/sqlscope#readme) for the
|
|
32
|
+
full API.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["
|
|
3
|
-
build-backend = "
|
|
2
|
+
requires = ["hatchling>=1.24"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "sqlscope-rs"
|
|
@@ -12,7 +12,6 @@ dynamic = ["version"]
|
|
|
12
12
|
keywords = ["sql", "lineage", "row-level-security", "parser"]
|
|
13
13
|
classifiers = [
|
|
14
14
|
"Programming Language :: Python :: 3",
|
|
15
|
-
"Programming Language :: Rust",
|
|
16
15
|
"Topic :: Database",
|
|
17
16
|
"Typing :: Typed",
|
|
18
17
|
]
|
|
@@ -20,14 +19,15 @@ classifiers = [
|
|
|
20
19
|
[project.urls]
|
|
21
20
|
Repository = "https://github.com/dcalsky/sqlscope"
|
|
22
21
|
|
|
23
|
-
[tool.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
22
|
+
[tool.hatch.version]
|
|
23
|
+
path = "src/sqlscope/__init__.py"
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.wheel]
|
|
26
|
+
packages = ["src/sqlscope"]
|
|
28
27
|
|
|
29
28
|
[dependency-groups]
|
|
30
29
|
dev = ["pytest>=8", "mypy>=1.10"]
|
|
31
30
|
|
|
32
31
|
[tool.pytest.ini_options]
|
|
33
32
|
testpaths = ["tests"]
|
|
33
|
+
pythonpath = ["src"]
|
|
@@ -14,24 +14,34 @@ column_usages Column references with the clause they appear in.
|
|
|
14
14
|
|
|
15
15
|
Every function accepts ``dialect`` (default ``"trino"``). Functions release the
|
|
16
16
|
GIL while they run and are safe to call from several threads.
|
|
17
|
+
|
|
18
|
+
The SQL engine is the sqlscope FFI shared library (``libsqlscope_ffi.so``,
|
|
19
|
+
``libsqlscope_ffi.dylib`` or ``sqlscope_ffi.dll``) published with each sqlscope
|
|
20
|
+
release. It is loaded on first use; see :func:`load` for how it is located.
|
|
21
|
+
This package neither bundles nor downloads it.
|
|
17
22
|
"""
|
|
18
23
|
|
|
19
24
|
from __future__ import annotations
|
|
20
25
|
|
|
21
26
|
from dataclasses import dataclass, field
|
|
22
27
|
from enum import Enum
|
|
23
|
-
from typing import Dict, Iterable, List, Mapping, Optional, Sequence, Tuple, Union
|
|
28
|
+
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple, Union
|
|
24
29
|
|
|
25
|
-
from . import
|
|
26
|
-
|
|
30
|
+
from ._library import (
|
|
31
|
+
LIBRARY_PATH_ENV,
|
|
27
32
|
Error,
|
|
28
33
|
InternalError,
|
|
29
34
|
InvalidArgumentError,
|
|
35
|
+
LibraryNotFoundError,
|
|
30
36
|
ParseError,
|
|
31
37
|
UnsupportedError,
|
|
38
|
+
call,
|
|
39
|
+
library_file_name,
|
|
40
|
+
library_version,
|
|
41
|
+
load,
|
|
32
42
|
)
|
|
33
43
|
|
|
34
|
-
__version__
|
|
44
|
+
__version__ = "0.3.0"
|
|
35
45
|
|
|
36
46
|
__all__ = [
|
|
37
47
|
"Clause",
|
|
@@ -40,6 +50,8 @@ __all__ = [
|
|
|
40
50
|
"Error",
|
|
41
51
|
"InternalError",
|
|
42
52
|
"InvalidArgumentError",
|
|
53
|
+
"LIBRARY_PATH_ENV",
|
|
54
|
+
"LibraryNotFoundError",
|
|
43
55
|
"ParseError",
|
|
44
56
|
"Schema",
|
|
45
57
|
"TableRef",
|
|
@@ -50,6 +62,9 @@ __all__ = [
|
|
|
50
62
|
"column_origins",
|
|
51
63
|
"column_usages",
|
|
52
64
|
"inject_ctes",
|
|
65
|
+
"library_file_name",
|
|
66
|
+
"library_version",
|
|
67
|
+
"load",
|
|
53
68
|
"output_columns",
|
|
54
69
|
"referenced_columns",
|
|
55
70
|
"rewrite_tables",
|
|
@@ -150,6 +165,15 @@ def _list(values: Optional[Iterable[str]]) -> Optional[List[str]]:
|
|
|
150
165
|
return list(values)
|
|
151
166
|
|
|
152
167
|
|
|
168
|
+
def _run(operation: str, sql: str, *, dialect: Optional[str], **fields: Any) -> Any:
|
|
169
|
+
"""Calls ``operation``; ``fields`` holds request fields and options, ``None`` meaning unset."""
|
|
170
|
+
options = {"dialect": dialect}
|
|
171
|
+
for name in ("schema", "tableNames", "tablePatterns", "defaultDb", "stripCatalogs"):
|
|
172
|
+
options[name] = fields.pop(name, None)
|
|
173
|
+
request = {"sql": sql, **fields, "options": {k: v for k, v in options.items() if v is not None}}
|
|
174
|
+
return call(operation, request)
|
|
175
|
+
|
|
176
|
+
|
|
153
177
|
def apply_row_filter(
|
|
154
178
|
sql: str,
|
|
155
179
|
predicate: str,
|
|
@@ -169,14 +193,16 @@ def apply_row_filter(
|
|
|
169
193
|
boolean expression; bind or escape its values before calling. Returns the
|
|
170
194
|
input unchanged when no table is in scope.
|
|
171
195
|
"""
|
|
172
|
-
|
|
196
|
+
result: str = _run(
|
|
197
|
+
"apply_row_filter",
|
|
173
198
|
sql,
|
|
174
|
-
predicate,
|
|
199
|
+
predicate=predicate,
|
|
175
200
|
dialect=dialect,
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
201
|
+
tableNames=_list(table_names),
|
|
202
|
+
tablePatterns=_list(table_patterns),
|
|
203
|
+
defaultDb=default_db,
|
|
179
204
|
)
|
|
205
|
+
return result
|
|
180
206
|
|
|
181
207
|
|
|
182
208
|
def inject_ctes(
|
|
@@ -190,12 +216,16 @@ def inject_ctes(
|
|
|
190
216
|
Definitions keep their order, so each may use earlier ones. A name that
|
|
191
217
|
repeats another definition or an existing root CTE is rejected.
|
|
192
218
|
"""
|
|
193
|
-
|
|
194
|
-
|
|
219
|
+
defs = [
|
|
220
|
+
{"name": cte.name, "query": cte.query} if isinstance(cte, CteDef) else {"name": cte[0], "query": cte[1]}
|
|
221
|
+
for cte in ctes
|
|
222
|
+
]
|
|
223
|
+
result: str = _run("inject_ctes", sql, ctes=defs, dialect=dialect)
|
|
224
|
+
return result
|
|
195
225
|
|
|
196
226
|
|
|
197
|
-
def _table(ref: TableRef) ->
|
|
198
|
-
return
|
|
227
|
+
def _table(ref: TableRef) -> Dict[str, Optional[str]]:
|
|
228
|
+
return {"table": ref.table, "schema": ref.schema, "catalog": ref.catalog}
|
|
199
229
|
|
|
200
230
|
|
|
201
231
|
def rewrite_tables(
|
|
@@ -213,20 +243,21 @@ def rewrite_tables(
|
|
|
213
243
|
unchanged when nothing matches.
|
|
214
244
|
"""
|
|
215
245
|
plan = [
|
|
216
|
-
|
|
217
|
-
rewrite.match_key,
|
|
218
|
-
_table(rewrite.inline) if rewrite.inline is not None else None,
|
|
219
|
-
|
|
220
|
-
rewrite.union.table_alias,
|
|
221
|
-
list(rewrite.union.columns),
|
|
222
|
-
[_table(branch) for branch in rewrite.union.branches],
|
|
223
|
-
|
|
246
|
+
{
|
|
247
|
+
"matchKey": rewrite.match_key,
|
|
248
|
+
"inline": _table(rewrite.inline) if rewrite.inline is not None else None,
|
|
249
|
+
"union": {
|
|
250
|
+
"tableAlias": rewrite.union.table_alias,
|
|
251
|
+
"columns": list(rewrite.union.columns),
|
|
252
|
+
"branches": [_table(branch) for branch in rewrite.union.branches],
|
|
253
|
+
}
|
|
224
254
|
if rewrite.union is not None
|
|
225
255
|
else None,
|
|
226
|
-
|
|
256
|
+
}
|
|
227
257
|
for rewrite in rewrites
|
|
228
258
|
]
|
|
229
|
-
|
|
259
|
+
result: str = _run("rewrite_tables", sql, rewrites=plan, dialect=dialect, stripCatalogs=_list(strip_catalogs))
|
|
260
|
+
return result
|
|
230
261
|
|
|
231
262
|
|
|
232
263
|
def column_origins(
|
|
@@ -238,7 +269,8 @@ def column_origins(
|
|
|
238
269
|
right side of INTERSECT / EXCEPT are excluded. Every table read is present,
|
|
239
270
|
possibly with an empty list.
|
|
240
271
|
"""
|
|
241
|
-
|
|
272
|
+
result: Dict[str, List[str]] = _run("column_origins", sql, dialect=dialect, schema=_schema(schema))
|
|
273
|
+
return result
|
|
242
274
|
|
|
243
275
|
|
|
244
276
|
def output_columns(
|
|
@@ -249,14 +281,16 @@ def output_columns(
|
|
|
249
281
|
Unaliased expressions are named ``_col{i}``; ``*`` expands from ``schema``
|
|
250
282
|
or stays ``"*"``. Returns ``None`` for statements without columns.
|
|
251
283
|
"""
|
|
252
|
-
|
|
284
|
+
result: Optional[List[str]] = _run("output_columns", sql, dialect=dialect, schema=_schema(schema))
|
|
285
|
+
return result
|
|
253
286
|
|
|
254
287
|
|
|
255
288
|
def referenced_columns(
|
|
256
289
|
sql: str, *, dialect: Optional[str] = None, schema: Optional[Schema] = None
|
|
257
290
|
) -> Dict[str, List[str]]:
|
|
258
291
|
"""Columns referenced anywhere in a statement (including filters), keyed by root table."""
|
|
259
|
-
|
|
292
|
+
result: Dict[str, List[str]] = _run("referenced_columns", sql, dialect=dialect, schema=_schema(schema))
|
|
293
|
+
return result
|
|
260
294
|
|
|
261
295
|
|
|
262
296
|
def column_usages(
|
|
@@ -264,6 +298,6 @@ def column_usages(
|
|
|
264
298
|
) -> List[ColumnUsage]:
|
|
265
299
|
"""Every distinct ``(table, column, clause)`` use in a statement, sorted."""
|
|
266
300
|
return [
|
|
267
|
-
ColumnUsage(table, column, Clause(clause))
|
|
268
|
-
for
|
|
301
|
+
ColumnUsage(usage["table"], usage["column"], Clause(usage["clause"]))
|
|
302
|
+
for usage in _run("column_usages", sql, dialect=dialect, schema=_schema(schema))
|
|
269
303
|
]
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Loads the sqlscope FFI library and calls into it through ctypes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ctypes
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Dict, Optional
|
|
12
|
+
|
|
13
|
+
LIBRARY_PATH_ENV = "SQLSCOPE_LIBRARY_PATH"
|
|
14
|
+
"""Environment variable that locates the library when :func:`load` gets no path."""
|
|
15
|
+
|
|
16
|
+
_ABI_VERSION = 1
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Error(Exception):
|
|
20
|
+
"""Base class of every sqlscope error."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class InvalidArgumentError(Error):
|
|
24
|
+
"""An option or argument is invalid."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ParseError(Error):
|
|
28
|
+
"""The SQL text could not be parsed."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class UnsupportedError(Error):
|
|
32
|
+
"""The statement shape is not supported, or the input exceeded a safety limit."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class InternalError(Error):
|
|
36
|
+
"""sqlscope produced an invalid result (a bug)."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class LibraryNotFoundError(Error, OSError):
|
|
40
|
+
"""The sqlscope FFI library could not be loaded."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_ERRORS = {
|
|
44
|
+
"invalid_argument": InvalidArgumentError,
|
|
45
|
+
"parse": ParseError,
|
|
46
|
+
"unsupported": UnsupportedError,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def library_file_name() -> str:
|
|
51
|
+
"""The platform's file name of the sqlscope shared library."""
|
|
52
|
+
if sys.platform == "win32":
|
|
53
|
+
return "sqlscope_ffi.dll"
|
|
54
|
+
if sys.platform == "darwin":
|
|
55
|
+
return "libsqlscope_ffi.dylib"
|
|
56
|
+
return "libsqlscope_ffi.so"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _Library:
|
|
60
|
+
def __init__(self, path: str) -> None:
|
|
61
|
+
try:
|
|
62
|
+
lib = ctypes.CDLL(path)
|
|
63
|
+
except OSError as error:
|
|
64
|
+
raise LibraryNotFoundError(f"cannot load {path}: {error}") from error
|
|
65
|
+
try:
|
|
66
|
+
lib.sqlscope_abi_version.restype = ctypes.c_uint32
|
|
67
|
+
lib.sqlscope_abi_version.argtypes = []
|
|
68
|
+
lib.sqlscope_version.restype = ctypes.c_char_p
|
|
69
|
+
lib.sqlscope_version.argtypes = []
|
|
70
|
+
lib.sqlscope_call.restype = ctypes.c_void_p
|
|
71
|
+
lib.sqlscope_call.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
|
|
72
|
+
lib.sqlscope_free.restype = None
|
|
73
|
+
lib.sqlscope_free.argtypes = [ctypes.c_void_p]
|
|
74
|
+
except AttributeError as error:
|
|
75
|
+
raise LibraryNotFoundError(f"{path} is not a sqlscope library: {error}") from error
|
|
76
|
+
abi = lib.sqlscope_abi_version()
|
|
77
|
+
if abi != _ABI_VERSION:
|
|
78
|
+
raise LibraryNotFoundError(f"{path} implements ABI {abi}, expected {_ABI_VERSION}")
|
|
79
|
+
self.path = path
|
|
80
|
+
self.version: str = lib.sqlscope_version().decode()
|
|
81
|
+
self._lib = lib
|
|
82
|
+
|
|
83
|
+
def call(self, operation: str, request: bytes) -> bytes:
|
|
84
|
+
# ctypes releases the GIL for the duration of the call.
|
|
85
|
+
response = self._lib.sqlscope_call(operation.encode(), request)
|
|
86
|
+
try:
|
|
87
|
+
return ctypes.string_at(response)
|
|
88
|
+
finally:
|
|
89
|
+
self._lib.sqlscope_free(response)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
_loaded: Optional[_Library] = None
|
|
93
|
+
_lock = threading.Lock()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _default_candidates() -> list[str]:
|
|
97
|
+
path = os.environ.get(LIBRARY_PATH_ENV)
|
|
98
|
+
if path:
|
|
99
|
+
return [path]
|
|
100
|
+
name = library_file_name()
|
|
101
|
+
return [str(Path(__file__).resolve().parent / name), name]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def load(path: Optional[str] = None) -> None:
|
|
105
|
+
"""Load the sqlscope FFI library, the shared library published with each sqlscope release.
|
|
106
|
+
|
|
107
|
+
Without ``path`` it tries, in order, the path in ``$SQLSCOPE_LIBRARY_PATH``,
|
|
108
|
+
:func:`library_file_name` inside the ``sqlscope`` package directory, and
|
|
109
|
+
:func:`library_file_name` on the system library search path. Nothing is
|
|
110
|
+
ever downloaded.
|
|
111
|
+
|
|
112
|
+
Calling ``load`` is optional: the first operation loads the library the
|
|
113
|
+
same way. Once a library is loaded it stays loaded; loading a different
|
|
114
|
+
path afterwards raises :class:`LibraryNotFoundError`.
|
|
115
|
+
"""
|
|
116
|
+
global _loaded
|
|
117
|
+
with _lock:
|
|
118
|
+
if _loaded is not None:
|
|
119
|
+
if path is None or os.fspath(path) == _loaded.path:
|
|
120
|
+
return
|
|
121
|
+
raise LibraryNotFoundError(f"sqlscope library already loaded from {_loaded.path}")
|
|
122
|
+
if path is not None:
|
|
123
|
+
_loaded = _Library(os.fspath(path))
|
|
124
|
+
return
|
|
125
|
+
failures = []
|
|
126
|
+
for candidate in _default_candidates():
|
|
127
|
+
try:
|
|
128
|
+
_loaded = _Library(candidate)
|
|
129
|
+
return
|
|
130
|
+
except LibraryNotFoundError as error:
|
|
131
|
+
failures.append(str(error))
|
|
132
|
+
raise LibraryNotFoundError(
|
|
133
|
+
f"cannot load {library_file_name()}; download it from a sqlscope release and pass its "
|
|
134
|
+
f"path to sqlscope.load() or set {LIBRARY_PATH_ENV}: " + "; ".join(failures)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _library() -> _Library:
|
|
139
|
+
if _loaded is None:
|
|
140
|
+
load()
|
|
141
|
+
assert _loaded is not None
|
|
142
|
+
return _loaded
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def library_version() -> str:
|
|
146
|
+
"""The version of the loaded sqlscope FFI library, loading it if needed."""
|
|
147
|
+
return _library().version
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def call(operation: str, request: Dict[str, Any]) -> Any:
|
|
151
|
+
"""Run one operation and return its decoded result, raising on error."""
|
|
152
|
+
payload = json.dumps(request).encode()
|
|
153
|
+
response = json.loads(_library().call(operation, payload))
|
|
154
|
+
error = response.get("error")
|
|
155
|
+
if error is not None:
|
|
156
|
+
raise _ERRORS.get(error["kind"], InternalError)(error["message"])
|
|
157
|
+
return response["ok"]
|
|
@@ -101,5 +101,20 @@ def test_threads():
|
|
|
101
101
|
assert sorted(results) == sorted([[f"a{i}"] for i in range(8)])
|
|
102
102
|
|
|
103
103
|
|
|
104
|
-
def
|
|
104
|
+
def test_library():
|
|
105
105
|
assert sqlscope.__version__
|
|
106
|
+
assert sqlscope.library_version()
|
|
107
|
+
sqlscope.load() # already loaded: no-op
|
|
108
|
+
with pytest.raises(sqlscope.LibraryNotFoundError):
|
|
109
|
+
sqlscope.load("/nonexistent/" + sqlscope.library_file_name())
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_deep_nesting_in_threads():
|
|
113
|
+
sql = "SELECT a FROM t"
|
|
114
|
+
for i in range(120):
|
|
115
|
+
sql = f"SELECT a FROM ({sql}) x{i}"
|
|
116
|
+
results = []
|
|
117
|
+
thread = threading.Thread(target=lambda: results.append(sqlscope.column_origins(sql)))
|
|
118
|
+
thread.start()
|
|
119
|
+
thread.join()
|
|
120
|
+
assert results == [{"t": ["a"]}]
|