sqlty 0.1.0__cp315-cp315-win_amd64.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.
- sqlty/__init__.py +99 -0
- sqlty/__main__.py +5 -0
- sqlty/_breaking.py +191 -0
- sqlty/_check.py +880 -0
- sqlty/_cli.py +1219 -0
- sqlty/_config.py +666 -0
- sqlty/_core.cp315-win_amd64.pyd +0 -0
- sqlty/_core.pyi +108 -0
- sqlty/_decode.py +62 -0
- sqlty/_drivers.py +274 -0
- sqlty/_gitignore.py +58 -0
- sqlty/_helpers.py +613 -0
- sqlty/_json_shape.py +299 -0
- sqlty/_json_types.py +225 -0
- sqlty/_lint.py +350 -0
- sqlty/_params.py +177 -0
- sqlty/_psycopg.py +269 -0
- sqlty/_source.py +654 -0
- sqlty/_type_extractor/__init__.py +51 -0
- sqlty/_type_extractor/ast_fields.py +823 -0
- sqlty/_type_extractor/compat.py +252 -0
- sqlty/_type_extractor/extract.py +179 -0
- sqlty/_type_extractor/generic.py +95 -0
- sqlty/_type_extractor/info.py +161 -0
- sqlty/_type_extractor/sandbox.py +385 -0
- sqlty/_type_extractor/subtype.py +156 -0
- sqlty/_widening.py +89 -0
- sqlty/core.py +566 -0
- sqlty/drivers/psycopg.py +104 -0
- sqlty/magic.py +126 -0
- sqlty/py.typed +0 -0
- sqlty/rules/ambiguous-column.md +48 -0
- sqlty/rules/column-count-mismatch.md +30 -0
- sqlty/rules/column-name-mismatch.md +34 -0
- sqlty/rules/column-type-mismatch.md +75 -0
- sqlty/rules/fstring-skipped.md +23 -0
- sqlty/rules/inline-sql-comment.md +27 -0
- sqlty/rules/insert-missing-column.md +58 -0
- sqlty/rules/json-shape-mismatch.md +96 -0
- sqlty/rules/literal-mismatch.md +83 -0
- sqlty/rules/mysql-sql-marker.md +32 -0
- sqlty/rules/nullable-mismatch.md +76 -0
- sqlty/rules/param-count-mismatch.md +66 -0
- sqlty/rules/param-type-mismatch.md +73 -0
- sqlty/rules/redundant-literal.md +68 -0
- sqlty/rules/redundant-optional.md +100 -0
- sqlty/rules/select-star-no-schema.md +24 -0
- sqlty/rules/select-star.md +77 -0
- sqlty/rules/stale-rewrite.md +33 -0
- sqlty/rules/stale-type.md +50 -0
- sqlty/rules/tstring-skipped.md +68 -0
- sqlty/rules/typeddict-inheritance.md +35 -0
- sqlty/rules/typeddict-key-mismatch.md +38 -0
- sqlty/rules/unchecked-query.md +34 -0
- sqlty/rules/ungrouped-column.md +67 -0
- sqlty/rules/unknown-column.md +68 -0
- sqlty/rules/unknown-function.md +49 -0
- sqlty/rules/unknown-table.md +47 -0
- sqlty/rules/unknown-type.md +74 -0
- sqlty/rules/unparsed-query.md +46 -0
- sqlty/rules/unsupported-syntax.md +69 -0
- sqlty/rules/untyped-query.md +39 -0
- sqlty/rules/unused-ignore.md +36 -0
- sqlty/skill/SKILL.md +110 -0
- sqlty-0.1.0.dist-info/METADATA +210 -0
- sqlty-0.1.0.dist-info/RECORD +71 -0
- sqlty-0.1.0.dist-info/WHEEL +4 -0
- sqlty-0.1.0.dist-info/entry_points.txt +2 -0
- sqlty-0.1.0.dist-info/licenses/LICENSE +21 -0
- sqlty-0.1.0.dist-info/sboms/sqlty-core.cyclonedx.json +1891 -0
- sqlty_magic.pth +1 -0
sqlty/_core.pyi
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Type stubs for the Rust extension module (sqlty._core)."""
|
|
2
|
+
|
|
3
|
+
from typing import final
|
|
4
|
+
|
|
5
|
+
@final
|
|
6
|
+
class Schema:
|
|
7
|
+
@property
|
|
8
|
+
def dialect(self) -> str: ...
|
|
9
|
+
|
|
10
|
+
def load_schema(
|
|
11
|
+
schema_sql: str, *, dialect: str = "postgres", types: list[tuple[str, str]] | None = None
|
|
12
|
+
) -> Schema: ...
|
|
13
|
+
def infer_type(
|
|
14
|
+
sql: str,
|
|
15
|
+
*,
|
|
16
|
+
dialect: str = "postgres",
|
|
17
|
+
schema_sql: str | Schema | None = None,
|
|
18
|
+
) -> str | None: ...
|
|
19
|
+
def analyze_select(
|
|
20
|
+
sql: str,
|
|
21
|
+
*,
|
|
22
|
+
dialect: str = "postgres",
|
|
23
|
+
schema_sql: str | Schema | None = None,
|
|
24
|
+
) -> list[tuple[str, str | None, str | None]] | None: ...
|
|
25
|
+
def explain_select(
|
|
26
|
+
sql: str,
|
|
27
|
+
*,
|
|
28
|
+
dialect: str = "postgres",
|
|
29
|
+
schema_sql: str | Schema | None = None,
|
|
30
|
+
) -> list[tuple[str, tuple[str, str, str] | None, tuple[str, str, str] | None]] | None: ...
|
|
31
|
+
def analyze_columns(
|
|
32
|
+
sql: str,
|
|
33
|
+
*,
|
|
34
|
+
dialect: str = "postgres",
|
|
35
|
+
schema_sql: str | Schema | None = None,
|
|
36
|
+
) -> list[tuple[str, str | None, str | None, bool | None, str | None]] | None: ...
|
|
37
|
+
def analyze_params(
|
|
38
|
+
sql: str,
|
|
39
|
+
*,
|
|
40
|
+
dialect: str = "postgres",
|
|
41
|
+
schema_sql: str | Schema | None = None,
|
|
42
|
+
) -> list[tuple[int, str | None, str | None, str | None, bool | None, bool]] | None: ...
|
|
43
|
+
def composite_fields(
|
|
44
|
+
type_name: str,
|
|
45
|
+
*,
|
|
46
|
+
dialect: str = "postgres",
|
|
47
|
+
schema_sql: str | Schema | None = None,
|
|
48
|
+
) -> list[tuple[str, str | None, str | None, bool | None]] | None: ...
|
|
49
|
+
def query_diagnostics(
|
|
50
|
+
sql: str,
|
|
51
|
+
*,
|
|
52
|
+
dialect: str = "postgres",
|
|
53
|
+
schema_sql: str | Schema | None = None,
|
|
54
|
+
) -> list[tuple[str, str]]: ...
|
|
55
|
+
def name_rules() -> list[str]: ...
|
|
56
|
+
def dialect_names() -> list[str]: ...
|
|
57
|
+
def python_types() -> list[str]: ...
|
|
58
|
+
def parse_error(sql: str, *, dialect: str = "postgres") -> str | None: ...
|
|
59
|
+
def returns_rows(sql: str, *, dialect: str = "postgres") -> bool: ...
|
|
60
|
+
def rewrite_source(
|
|
61
|
+
source: str,
|
|
62
|
+
*,
|
|
63
|
+
dialect: str = "postgres",
|
|
64
|
+
schema_sql: str | Schema | None = None,
|
|
65
|
+
import_style: str | None = None,
|
|
66
|
+
untyped: bool = False,
|
|
67
|
+
keep_lines: bool = False,
|
|
68
|
+
) -> str: ...
|
|
69
|
+
def strip_source(source: str, *, import_style: str | None = None) -> str: ...
|
|
70
|
+
def fix_source(
|
|
71
|
+
source: str,
|
|
72
|
+
*,
|
|
73
|
+
dialect: str = "postgres",
|
|
74
|
+
schema_sql: str | Schema | None = None,
|
|
75
|
+
import_style: str | None = None,
|
|
76
|
+
untyped: bool = False,
|
|
77
|
+
rules: dict[str, str] | None = None,
|
|
78
|
+
suppressed: dict[int, list[str] | None] | None = None,
|
|
79
|
+
) -> str: ...
|
|
80
|
+
def expand_star(
|
|
81
|
+
sql: str,
|
|
82
|
+
*,
|
|
83
|
+
dialect: str = "postgres",
|
|
84
|
+
schema_sql: str | Schema | None = None,
|
|
85
|
+
) -> str | None: ...
|
|
86
|
+
def select_star_finding(
|
|
87
|
+
sql: str, *, dialect: str = "postgres", has_schema: bool = False
|
|
88
|
+
) -> tuple[str, str] | None: ...
|
|
89
|
+
def check_source(
|
|
90
|
+
source: str,
|
|
91
|
+
*,
|
|
92
|
+
dialect: str = "postgres",
|
|
93
|
+
schema_sql: str | Schema | None = None,
|
|
94
|
+
rules: dict[str, str] | None = None,
|
|
95
|
+
import_style: str | None = None,
|
|
96
|
+
untyped: bool = False,
|
|
97
|
+
any_calls: list[int] = ...,
|
|
98
|
+
) -> list[dict[str, str | int | None]]: ...
|
|
99
|
+
def wrapped_queries(
|
|
100
|
+
source: str, *, import_style: str | None = None
|
|
101
|
+
) -> list[tuple[int, str, str | None]]: ...
|
|
102
|
+
def query_sites(
|
|
103
|
+
source: str, *, import_style: str | None = None, any_calls: list[int] = ...
|
|
104
|
+
) -> list[tuple[int, str, str]]: ...
|
|
105
|
+
def schema_skipped_tables(schema_sql: str | Schema, *, dialect: str = "postgres") -> list[str]: ...
|
|
106
|
+
def schema_skipped_objects(
|
|
107
|
+
schema_sql: str | Schema, *, dialect: str = "postgres"
|
|
108
|
+
) -> list[tuple[str, str]]: ...
|
sqlty/_decode.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
r"""The ``sqlty`` codec's decoder, without registering the codec.
|
|
2
|
+
|
|
3
|
+
``decode`` turns source bytes into text with ``--sql`` strings wrapped as
|
|
4
|
+
``SQL[T](...)``, as ``sqlty rewrite`` does, and adds the imports they need:
|
|
5
|
+
|
|
6
|
+
>>> from sqlty._decode import decode
|
|
7
|
+
>>> decode(b'q = "--sql\\nSELECT 1::INTEGER"')[0]
|
|
8
|
+
'from sqlty import SQL; q = SQL[tuple[int]]("--sql\\nSELECT 1::INTEGER")'
|
|
9
|
+
|
|
10
|
+
Strings whose row type can't be inferred (invalid SQL, DML without
|
|
11
|
+
RETURNING, DDL, empty queries) are left as they are. The imports are joined
|
|
12
|
+
with ``;`` onto an existing line (after the docstring or ``__future__``
|
|
13
|
+
imports, else before the first statement) rather than a new line above
|
|
14
|
+
the code: when CPython runs a file as a script, it decodes from the end of the
|
|
15
|
+
coding line and drops the first decoded line (#111). Line numbers match the file
|
|
16
|
+
on disk, except when a compound statement (``def``, ``class``, ``@``, ``if``,
|
|
17
|
+
...) comes right after the coding line, with no docstring, blank or comment
|
|
18
|
+
line in between; then one line is added. The ``# coding: sqlty``
|
|
19
|
+
declaration becomes ``utf-8``, so the decoded text runs without the codec.
|
|
20
|
+
``import-style`` comes from ``[tool.sqlty]`` in pyproject.toml; on any error the
|
|
21
|
+
source is returned unchanged. Import ``sqlty.magic`` to register the codec.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import codecs
|
|
27
|
+
import encodings
|
|
28
|
+
import re
|
|
29
|
+
from collections.abc import Buffer
|
|
30
|
+
from functools import cache
|
|
31
|
+
|
|
32
|
+
__all__ = ["decode"]
|
|
33
|
+
|
|
34
|
+
_utf_8_codec = encodings.search_function("utf8")
|
|
35
|
+
assert _utf_8_codec is not None, "UTF-8 codec not found"
|
|
36
|
+
utf_8_codec: codecs.CodecInfo = _utf_8_codec
|
|
37
|
+
|
|
38
|
+
# PEP 263 declaration of this codec on the first or second line.
|
|
39
|
+
_CODING = re.compile(r"\A((?:[^\n]*\n)?[ \t\f]*#[^\n]*?coding[:=][ \t]*)sqlty\b")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@cache
|
|
43
|
+
def _import_style() -> str | None:
|
|
44
|
+
try:
|
|
45
|
+
from sqlty._config import load_config
|
|
46
|
+
|
|
47
|
+
config, _ = load_config()
|
|
48
|
+
return config.get("import_style")
|
|
49
|
+
except Exception:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def decode(b: Buffer, errors: str = "strict") -> tuple[str, int]:
|
|
54
|
+
"""Decode UTF-8 ``b``, wrapping ``--sql`` strings; returns ``(text, length)``."""
|
|
55
|
+
source, length = utf_8_codec.decode(b, errors)
|
|
56
|
+
try:
|
|
57
|
+
from sqlty.core import rewrite_source
|
|
58
|
+
|
|
59
|
+
decoded = rewrite_source(source, import_style=_import_style(), keep_lines=True)
|
|
60
|
+
return _CODING.sub(r"\1utf-8", decoded, count=1), length
|
|
61
|
+
except Exception:
|
|
62
|
+
return source, length
|
sqlty/_drivers.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Driver calls that pass a query and its parameters: ``cursor.execute(query, params)``.
|
|
2
|
+
|
|
3
|
+
A receiver is recognised only when the file shows what it is: a variable bound to
|
|
4
|
+
``psycopg.connect(...)``, ``sqlite3.connect(...)`` or ``asyncpg.connect(...)`` (also as
|
|
5
|
+
``with ... as name``), to ``<connection>.cursor()``, or annotated with a driver class. The
|
|
6
|
+
query is a string literal, ``SQL[T]("...")`` of one, or a name assigned exactly one of
|
|
7
|
+
those. Anything else is skipped.
|
|
8
|
+
|
|
9
|
+
Names are resolved per scope, and a name a loop, comprehension, unpacking, import or
|
|
10
|
+
unannotated parameter binds is unknown.
|
|
11
|
+
|
|
12
|
+
Example:
|
|
13
|
+
>>> src = '''
|
|
14
|
+
... import sqlite3
|
|
15
|
+
... conn = sqlite3.connect("db")
|
|
16
|
+
... conn.execute("SELECT 1 WHERE ? = 1", (1, 2))
|
|
17
|
+
... '''
|
|
18
|
+
>>> [(c.line, c.query, [a.key for a in c.params or ()]) for c in find_driver_calls(src)]
|
|
19
|
+
[(4, 'SELECT 1 WHERE ? = 1', [1, 2])]
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import ast
|
|
25
|
+
from collections import defaultdict
|
|
26
|
+
|
|
27
|
+
from sqlty._helpers import HelperCall
|
|
28
|
+
from sqlty._params import ParamArg, arg_type, literal_params
|
|
29
|
+
from sqlty._source import ParsedSource
|
|
30
|
+
|
|
31
|
+
# Driver factories and classes, by the dotted name they are imported as, to the driver.
|
|
32
|
+
_CONNECTIONS = {
|
|
33
|
+
"psycopg.connect": "psycopg",
|
|
34
|
+
"psycopg.Connection": "psycopg",
|
|
35
|
+
"psycopg.AsyncConnection": "psycopg",
|
|
36
|
+
"psycopg.AsyncConnection.connect": "psycopg",
|
|
37
|
+
"psycopg.Connection.connect": "psycopg",
|
|
38
|
+
"sqlite3.connect": "sqlite3",
|
|
39
|
+
"sqlite3.Connection": "sqlite3",
|
|
40
|
+
"asyncpg.connect": "asyncpg",
|
|
41
|
+
"asyncpg.Connection": "asyncpg",
|
|
42
|
+
"asyncpg.create_pool": "asyncpg",
|
|
43
|
+
"asyncpg.Pool": "asyncpg",
|
|
44
|
+
}
|
|
45
|
+
_CURSORS = {
|
|
46
|
+
"psycopg.Cursor": "psycopg",
|
|
47
|
+
"psycopg.AsyncCursor": "psycopg",
|
|
48
|
+
"sqlite3.Cursor": "sqlite3",
|
|
49
|
+
}
|
|
50
|
+
_METHODS = {
|
|
51
|
+
"psycopg": {"execute", "executemany"},
|
|
52
|
+
"sqlite3": {"execute", "executemany"},
|
|
53
|
+
"asyncpg": {"execute", "fetch", "fetchrow", "fetchval", "executemany"},
|
|
54
|
+
}
|
|
55
|
+
_PARAM_KEYWORD = {"psycopg": "params", "sqlite3": "parameters"}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _dotted(node: ast.expr) -> str | None:
|
|
59
|
+
parts: list[str] = []
|
|
60
|
+
while isinstance(node, ast.Attribute):
|
|
61
|
+
parts.append(node.attr)
|
|
62
|
+
node = node.value
|
|
63
|
+
return ".".join([node.id, *reversed(parts)]) if isinstance(node, ast.Name) else None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _qualified(name: str | None, parsed: ParsedSource) -> str | None:
|
|
67
|
+
"""``name`` with its first part replaced by what it was imported as."""
|
|
68
|
+
if name is None:
|
|
69
|
+
return None
|
|
70
|
+
head, _, rest = name.partition(".")
|
|
71
|
+
imported = parsed.imported_names.get(head)
|
|
72
|
+
return None if imported is None else f"{imported}.{rest}" if rest else imported
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_Binding = tuple[ast.expr | None, ast.expr | None] # (value, annotation); both None: unknown
|
|
76
|
+
_Scope = ast.AST # the module, or a function
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class _Scopes:
|
|
80
|
+
"""What names mean in each scope of a file.
|
|
81
|
+
|
|
82
|
+
A name takes its meaning from the innermost enclosing function (or the module) that
|
|
83
|
+
binds it. Names bound by anything but a plain assignment, an annotated parameter or
|
|
84
|
+
``with ... as name`` (loops, comprehensions, unpacking, imports, definitions, an
|
|
85
|
+
unannotated parameter) are unknown.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(self, parsed: ParsedSource) -> None:
|
|
89
|
+
assert parsed.tree is not None
|
|
90
|
+
self.parsed = parsed
|
|
91
|
+
self.tree: _Scope = parsed.tree
|
|
92
|
+
self._bindings: dict[int, dict[str, list[_Binding]]] = {}
|
|
93
|
+
self._resolving: set[tuple[int, str]] = set()
|
|
94
|
+
|
|
95
|
+
def chain(self, call: ast.Call) -> list[_Scope]:
|
|
96
|
+
"""The scopes around ``call``, outermost (the module) first."""
|
|
97
|
+
inner = [
|
|
98
|
+
s
|
|
99
|
+
for s in self.parsed.scopes_of(call)
|
|
100
|
+
if isinstance(s, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
101
|
+
]
|
|
102
|
+
return [self.tree, *inner]
|
|
103
|
+
|
|
104
|
+
def _of(self, scope: _Scope) -> dict[str, list[_Binding]]:
|
|
105
|
+
if (found := self._bindings.get(id(scope))) is not None:
|
|
106
|
+
return found
|
|
107
|
+
names: dict[str, list[_Binding]] = defaultdict(list)
|
|
108
|
+
self._bindings[id(scope)] = names
|
|
109
|
+
unknown: _Binding = (None, None)
|
|
110
|
+
|
|
111
|
+
def bind(target: ast.AST, binding: _Binding = unknown) -> None:
|
|
112
|
+
if isinstance(target, ast.Name):
|
|
113
|
+
names[target.id].append(binding)
|
|
114
|
+
else:
|
|
115
|
+
for node in ast.walk(target):
|
|
116
|
+
if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store):
|
|
117
|
+
names[node.id].append(unknown)
|
|
118
|
+
|
|
119
|
+
if isinstance(scope, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
120
|
+
args = scope.args
|
|
121
|
+
for arg in [*args.posonlyargs, *args.args, *args.kwonlyargs, args.vararg, args.kwarg]:
|
|
122
|
+
if arg is not None:
|
|
123
|
+
names[arg.arg].append((None, arg.annotation))
|
|
124
|
+
pending = list(ast.iter_child_nodes(scope))
|
|
125
|
+
while pending:
|
|
126
|
+
node = pending.pop()
|
|
127
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
128
|
+
names[node.name].append(unknown)
|
|
129
|
+
continue
|
|
130
|
+
if isinstance(node, ast.Lambda):
|
|
131
|
+
continue
|
|
132
|
+
if isinstance(node, ast.Assign):
|
|
133
|
+
for target in node.targets:
|
|
134
|
+
bind(target, (node.value, None) if isinstance(target, ast.Name) else unknown)
|
|
135
|
+
elif isinstance(node, ast.AnnAssign):
|
|
136
|
+
bind(node.target, (node.value, node.annotation))
|
|
137
|
+
elif isinstance(node, (ast.With, ast.AsyncWith)):
|
|
138
|
+
for item in node.items:
|
|
139
|
+
if item.optional_vars is not None:
|
|
140
|
+
bind(item.optional_vars, (item.context_expr, None))
|
|
141
|
+
elif isinstance(
|
|
142
|
+
node, (ast.For, ast.AsyncFor, ast.comprehension, ast.AugAssign, ast.NamedExpr)
|
|
143
|
+
):
|
|
144
|
+
bind(node.target)
|
|
145
|
+
elif isinstance(node, ast.ExceptHandler) and node.name:
|
|
146
|
+
names[node.name].append(unknown)
|
|
147
|
+
elif isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
148
|
+
for alias in node.names:
|
|
149
|
+
names[(alias.asname or alias.name).partition(".")[0]].append(unknown)
|
|
150
|
+
pending.extend(ast.iter_child_nodes(node))
|
|
151
|
+
return names
|
|
152
|
+
|
|
153
|
+
def _lookup(self, name: str, scopes: list[_Scope]) -> list[_Binding] | None:
|
|
154
|
+
for scope in reversed(scopes):
|
|
155
|
+
if (found := self._of(scope).get(name)) is not None:
|
|
156
|
+
return found
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
def driver(self, name: str, scopes: list[_Scope]) -> str | None:
|
|
160
|
+
"""The driver of the connection or cursor ``name`` is, in ``scopes``, when every binding agrees."""
|
|
161
|
+
key = (id(scopes[-1]), name)
|
|
162
|
+
if key in self._resolving or (bindings := self._lookup(name, scopes)) is None:
|
|
163
|
+
return None
|
|
164
|
+
self._resolving.add(key)
|
|
165
|
+
try:
|
|
166
|
+
drivers = {self._kind(value, annotation, scopes) for value, annotation in bindings}
|
|
167
|
+
finally:
|
|
168
|
+
self._resolving.discard(key)
|
|
169
|
+
return drivers.pop() if len(drivers) == 1 else None
|
|
170
|
+
|
|
171
|
+
def _kind(
|
|
172
|
+
self, value: ast.expr | None, annotation: ast.expr | None, scopes: list[_Scope]
|
|
173
|
+
) -> str | None:
|
|
174
|
+
if annotation is not None:
|
|
175
|
+
name = _qualified(_dotted(annotation), self.parsed)
|
|
176
|
+
if name in _CONNECTIONS:
|
|
177
|
+
return _CONNECTIONS[name]
|
|
178
|
+
if name in _CURSORS:
|
|
179
|
+
return _CURSORS[name]
|
|
180
|
+
while isinstance(value, ast.Await):
|
|
181
|
+
value = value.value
|
|
182
|
+
if isinstance(value, ast.Call):
|
|
183
|
+
name = _qualified(_dotted(value.func), self.parsed)
|
|
184
|
+
if name in _CONNECTIONS:
|
|
185
|
+
return _CONNECTIONS[name]
|
|
186
|
+
func = value.func
|
|
187
|
+
if (
|
|
188
|
+
isinstance(func, ast.Attribute)
|
|
189
|
+
and func.attr == "cursor"
|
|
190
|
+
and isinstance(func.value, ast.Name)
|
|
191
|
+
):
|
|
192
|
+
return self.driver(func.value.id, scopes)
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
def query(self, name: str, scopes: list[_Scope]) -> str | None:
|
|
196
|
+
"""The query literal ``name`` is bound to, in ``scopes``, when it has exactly one binding."""
|
|
197
|
+
bindings = self._lookup(name, scopes)
|
|
198
|
+
if bindings is None or len(bindings) != 1:
|
|
199
|
+
return None
|
|
200
|
+
return _literal(bindings[0][0])
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _literal(node: ast.expr | None) -> str | None:
|
|
204
|
+
"""The text of a string literal, possibly wrapped as ``SQL[T]("...")``."""
|
|
205
|
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Subscript) and len(node.args) == 1:
|
|
206
|
+
if _dotted(node.func.value) in {"SQL", "sqlty.SQL"}:
|
|
207
|
+
return _literal(node.args[0])
|
|
208
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
209
|
+
return node.value
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def find_driver_calls(source: str | ParsedSource) -> list[HelperCall]:
|
|
214
|
+
"""Calls of ``execute``-like methods on a recognised connection or cursor, with their query and parameters.
|
|
215
|
+
|
|
216
|
+
A call with no parameters has ``params == ()``, so a query that has placeholders is
|
|
217
|
+
reported. ``executemany`` gives one entry per row of a list or tuple literal.
|
|
218
|
+
"""
|
|
219
|
+
parsed = ParsedSource.of(source)
|
|
220
|
+
if not any(driver in parsed.text for driver in ("psycopg", "sqlite3", "asyncpg")):
|
|
221
|
+
return []
|
|
222
|
+
if parsed.tree is None:
|
|
223
|
+
return []
|
|
224
|
+
scopes = _Scopes(parsed)
|
|
225
|
+
found: list[HelperCall] = []
|
|
226
|
+
aliases = parsed.typing_aliases
|
|
227
|
+
for call in parsed.calls_to(None):
|
|
228
|
+
func = call.func
|
|
229
|
+
if not (
|
|
230
|
+
isinstance(func, ast.Attribute)
|
|
231
|
+
and isinstance(func.value, ast.Name)
|
|
232
|
+
and (driver := scopes.driver(func.value.id, chain := scopes.chain(call)))
|
|
233
|
+
and func.attr in _METHODS[driver]
|
|
234
|
+
and call.args
|
|
235
|
+
and not any(isinstance(arg, ast.Starred) for arg in call.args)
|
|
236
|
+
):
|
|
237
|
+
continue
|
|
238
|
+
first = call.args[0]
|
|
239
|
+
query = _literal(first)
|
|
240
|
+
if query is None and isinstance(first, ast.Name):
|
|
241
|
+
query = scopes.query(first.id, chain)
|
|
242
|
+
if query is None:
|
|
243
|
+
continue
|
|
244
|
+
stack = parsed.scopes_of(call)
|
|
245
|
+
if driver == "asyncpg":
|
|
246
|
+
if func.attr == "executemany":
|
|
247
|
+
continue
|
|
248
|
+
params: tuple[ParamArg, ...] | None = tuple(
|
|
249
|
+
ParamArg(i, arg_type(arg, parsed, stack)) for i, arg in enumerate(call.args[1:], 1)
|
|
250
|
+
)
|
|
251
|
+
found.append(HelperCall(call.lineno, query, None, aliases, params))
|
|
252
|
+
continue
|
|
253
|
+
keyword = _PARAM_KEYWORD[driver]
|
|
254
|
+
arg = call.args[1] if len(call.args) > 1 else None
|
|
255
|
+
if arg is None:
|
|
256
|
+
arg = next((k.value for k in call.keywords if k.arg == keyword), None)
|
|
257
|
+
if arg is None and any(k.arg is None for k in call.keywords):
|
|
258
|
+
continue
|
|
259
|
+
if func.attr == "executemany":
|
|
260
|
+
if isinstance(arg, (ast.List, ast.Tuple)):
|
|
261
|
+
for row in arg.elts:
|
|
262
|
+
if (params := literal_params(row, parsed, stack)) is not None:
|
|
263
|
+
found.append(HelperCall(call.lineno, query, None, aliases, params))
|
|
264
|
+
continue
|
|
265
|
+
found.append(
|
|
266
|
+
HelperCall(
|
|
267
|
+
call.lineno,
|
|
268
|
+
query,
|
|
269
|
+
None,
|
|
270
|
+
aliases,
|
|
271
|
+
() if arg is None else literal_params(arg, parsed, stack),
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
return found
|
sqlty/_gitignore.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Which paths of a git work tree its ``.gitignore`` files ignore."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pathspec import GitIgnoreSpec
|
|
8
|
+
|
|
9
|
+
_Chain = tuple[tuple[Path, GitIgnoreSpec], ...]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GitIgnore:
|
|
13
|
+
"""The ignore rules of the repositories a walk enters, read once per directory.
|
|
14
|
+
|
|
15
|
+
A directory's rules are those of ``.git/info/exclude`` and every ``.gitignore``
|
|
16
|
+
from the repository root down to it; a deeper file overrides a shallower one,
|
|
17
|
+
and a later line an earlier one, as in git. Outside a repository nothing is
|
|
18
|
+
ignored.
|
|
19
|
+
|
|
20
|
+
Example:
|
|
21
|
+
>>> GitIgnore().ignored(Path("/"), "tmp", is_dir=True)
|
|
22
|
+
False
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
self._chains: dict[Path, _Chain | None] = {}
|
|
27
|
+
|
|
28
|
+
def ignored(self, directory: Path, name: str, *, is_dir: bool) -> bool:
|
|
29
|
+
"""Whether the entry ``name`` of the resolved ``directory`` is ignored."""
|
|
30
|
+
verdict = False
|
|
31
|
+
for base, spec in self._chain(directory) or ():
|
|
32
|
+
relative = (directory / name).relative_to(base).as_posix()
|
|
33
|
+
if (
|
|
34
|
+
include := spec.check_file(relative + "/" if is_dir else relative).include
|
|
35
|
+
) is not None:
|
|
36
|
+
verdict = include
|
|
37
|
+
return verdict
|
|
38
|
+
|
|
39
|
+
def _chain(self, directory: Path) -> _Chain | None:
|
|
40
|
+
if directory not in self._chains:
|
|
41
|
+
if (directory / ".git").exists():
|
|
42
|
+
chain: _Chain | None = _read(directory, directory / ".git" / "info" / "exclude")
|
|
43
|
+
elif directory.parent == directory:
|
|
44
|
+
chain = None
|
|
45
|
+
else:
|
|
46
|
+
chain = self._chain(directory.parent)
|
|
47
|
+
if chain is not None:
|
|
48
|
+
chain += _read(directory, directory / ".gitignore")
|
|
49
|
+
self._chains[directory] = chain
|
|
50
|
+
return self._chains[directory]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _read(base: Path, path: Path) -> _Chain:
|
|
54
|
+
try:
|
|
55
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
56
|
+
except OSError, UnicodeDecodeError:
|
|
57
|
+
return ()
|
|
58
|
+
return ((base, GitIgnoreSpec.from_lines(lines)),)
|