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.
Files changed (40) hide show
  1. sqlscope_rs-0.3.0/.gitignore +10 -0
  2. {sqlscope_rs-0.1.0 → sqlscope_rs-0.3.0}/PKG-INFO +18 -11
  3. sqlscope_rs-0.3.0/README.md +32 -0
  4. {sqlscope_rs-0.1.0 → sqlscope_rs-0.3.0}/pyproject.toml +8 -8
  5. {sqlscope_rs-0.1.0/python → sqlscope_rs-0.3.0/src}/sqlscope/__init__.py +62 -28
  6. sqlscope_rs-0.3.0/src/sqlscope/_library.py +157 -0
  7. {sqlscope_rs-0.1.0/python → sqlscope_rs-0.3.0}/tests/test_sqlscope.py +16 -1
  8. sqlscope_rs-0.1.0/Cargo.lock +0 -397
  9. sqlscope_rs-0.1.0/Cargo.toml +0 -29
  10. sqlscope_rs-0.1.0/README.md +0 -23
  11. sqlscope_rs-0.1.0/crates/sqlscope/Cargo.toml +0 -23
  12. sqlscope_rs-0.1.0/crates/sqlscope/README.md +0 -189
  13. sqlscope_rs-0.1.0/crates/sqlscope/src/ast.rs +0 -353
  14. sqlscope_rs-0.1.0/crates/sqlscope/src/column_origins.rs +0 -85
  15. sqlscope_rs-0.1.0/crates/sqlscope/src/column_usages.rs +0 -168
  16. sqlscope_rs-0.1.0/crates/sqlscope/src/error.rs +0 -104
  17. sqlscope_rs-0.1.0/crates/sqlscope/src/inject_ctes.rs +0 -136
  18. sqlscope_rs-0.1.0/crates/sqlscope/src/lib.rs +0 -38
  19. sqlscope_rs-0.1.0/crates/sqlscope/src/normalize.rs +0 -170
  20. sqlscope_rs-0.1.0/crates/sqlscope/src/options.rs +0 -165
  21. sqlscope_rs-0.1.0/crates/sqlscope/src/output_columns.rs +0 -176
  22. sqlscope_rs-0.1.0/crates/sqlscope/src/resolver.rs +0 -1061
  23. sqlscope_rs-0.1.0/crates/sqlscope/src/rewrite.rs +0 -670
  24. sqlscope_rs-0.1.0/crates/sqlscope/src/rewrite_tables.rs +0 -278
  25. sqlscope_rs-0.1.0/crates/sqlscope/src/row_filter.rs +0 -220
  26. sqlscope_rs-0.1.0/crates/sqlscope/src/schema.rs +0 -70
  27. sqlscope_rs-0.1.0/crates/sqlscope/tests/column_origins.rs +0 -288
  28. sqlscope_rs-0.1.0/crates/sqlscope/tests/common/mod.rs +0 -152
  29. sqlscope_rs-0.1.0/crates/sqlscope/tests/guards.rs +0 -97
  30. sqlscope_rs-0.1.0/crates/sqlscope/tests/inject_ctes.rs +0 -273
  31. sqlscope_rs-0.1.0/crates/sqlscope/tests/output_columns.rs +0 -237
  32. sqlscope_rs-0.1.0/crates/sqlscope/tests/referenced_columns.rs +0 -722
  33. sqlscope_rs-0.1.0/crates/sqlscope/tests/rewrite_tables.rs +0 -478
  34. sqlscope_rs-0.1.0/crates/sqlscope/tests/row_filter.rs +0 -616
  35. sqlscope_rs-0.1.0/python/Cargo.toml +0 -21
  36. sqlscope_rs-0.1.0/python/README.md +0 -23
  37. sqlscope_rs-0.1.0/python/build.rs +0 -4
  38. sqlscope_rs-0.1.0/python/sqlscope/_native.pyi +0 -39
  39. sqlscope_rs-0.1.0/python/src/lib.rs +0 -215
  40. {sqlscope_rs-0.1.0/python → sqlscope_rs-0.3.0/src}/sqlscope/py.typed +0 -0
@@ -0,0 +1,10 @@
1
+ target/
2
+ .venv/
3
+ __pycache__/
4
+ *.pyc
5
+ *.so
6
+ .pytest_cache/
7
+ dist/
8
+ *.egg-info/
9
+ *.dylib
10
+ *.dll
@@ -1,21 +1,27 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: sqlscope-rs
3
- Version: 0.1.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; charset=UTF-8; variant=GFM
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 Rust
18
- [sqlscope](https://github.com/dcalsky/sqlscope) crate.
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 = ["maturin>=1.8,<2"]
3
- build-backend = "maturin"
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.maturin]
24
- module-name = "sqlscope._native"
25
- features = ["pyo3/extension-module"]
26
- manifest-path = "python/Cargo.toml"
27
- python-source = "python"
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 _native
26
- from ._native import (
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__: str = _native.__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
- return _native.apply_row_filter(
196
+ result: str = _run(
197
+ "apply_row_filter",
173
198
  sql,
174
- predicate,
199
+ predicate=predicate,
175
200
  dialect=dialect,
176
- table_names=_list(table_names),
177
- table_patterns=_list(table_patterns),
178
- default_db=default_db,
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
- pairs = [(cte.name, cte.query) if isinstance(cte, CteDef) else (cte[0], cte[1]) for cte in ctes]
194
- return _native.inject_ctes(sql, pairs, dialect=dialect)
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) -> Tuple[str, Optional[str], Optional[str]]:
198
- return (ref.table, ref.schema, ref.catalog)
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
- return _native.rewrite_tables(sql, plan, dialect=dialect, strip_catalogs=_list(strip_catalogs))
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
- return _native.column_origins(sql, dialect=dialect, schema=_schema(schema))
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
- return _native.output_columns(sql, dialect=dialect, schema=_schema(schema))
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
- return _native.referenced_columns(sql, dialect=dialect, schema=_schema(schema))
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 table, column, clause in _native.column_usages(sql, dialect=dialect, schema=_schema(schema))
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 test_version():
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"]}]