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.
Files changed (71) hide show
  1. sqlty/__init__.py +99 -0
  2. sqlty/__main__.py +5 -0
  3. sqlty/_breaking.py +191 -0
  4. sqlty/_check.py +880 -0
  5. sqlty/_cli.py +1219 -0
  6. sqlty/_config.py +666 -0
  7. sqlty/_core.cp315-win_amd64.pyd +0 -0
  8. sqlty/_core.pyi +108 -0
  9. sqlty/_decode.py +62 -0
  10. sqlty/_drivers.py +274 -0
  11. sqlty/_gitignore.py +58 -0
  12. sqlty/_helpers.py +613 -0
  13. sqlty/_json_shape.py +299 -0
  14. sqlty/_json_types.py +225 -0
  15. sqlty/_lint.py +350 -0
  16. sqlty/_params.py +177 -0
  17. sqlty/_psycopg.py +269 -0
  18. sqlty/_source.py +654 -0
  19. sqlty/_type_extractor/__init__.py +51 -0
  20. sqlty/_type_extractor/ast_fields.py +823 -0
  21. sqlty/_type_extractor/compat.py +252 -0
  22. sqlty/_type_extractor/extract.py +179 -0
  23. sqlty/_type_extractor/generic.py +95 -0
  24. sqlty/_type_extractor/info.py +161 -0
  25. sqlty/_type_extractor/sandbox.py +385 -0
  26. sqlty/_type_extractor/subtype.py +156 -0
  27. sqlty/_widening.py +89 -0
  28. sqlty/core.py +566 -0
  29. sqlty/drivers/psycopg.py +104 -0
  30. sqlty/magic.py +126 -0
  31. sqlty/py.typed +0 -0
  32. sqlty/rules/ambiguous-column.md +48 -0
  33. sqlty/rules/column-count-mismatch.md +30 -0
  34. sqlty/rules/column-name-mismatch.md +34 -0
  35. sqlty/rules/column-type-mismatch.md +75 -0
  36. sqlty/rules/fstring-skipped.md +23 -0
  37. sqlty/rules/inline-sql-comment.md +27 -0
  38. sqlty/rules/insert-missing-column.md +58 -0
  39. sqlty/rules/json-shape-mismatch.md +96 -0
  40. sqlty/rules/literal-mismatch.md +83 -0
  41. sqlty/rules/mysql-sql-marker.md +32 -0
  42. sqlty/rules/nullable-mismatch.md +76 -0
  43. sqlty/rules/param-count-mismatch.md +66 -0
  44. sqlty/rules/param-type-mismatch.md +73 -0
  45. sqlty/rules/redundant-literal.md +68 -0
  46. sqlty/rules/redundant-optional.md +100 -0
  47. sqlty/rules/select-star-no-schema.md +24 -0
  48. sqlty/rules/select-star.md +77 -0
  49. sqlty/rules/stale-rewrite.md +33 -0
  50. sqlty/rules/stale-type.md +50 -0
  51. sqlty/rules/tstring-skipped.md +68 -0
  52. sqlty/rules/typeddict-inheritance.md +35 -0
  53. sqlty/rules/typeddict-key-mismatch.md +38 -0
  54. sqlty/rules/unchecked-query.md +34 -0
  55. sqlty/rules/ungrouped-column.md +67 -0
  56. sqlty/rules/unknown-column.md +68 -0
  57. sqlty/rules/unknown-function.md +49 -0
  58. sqlty/rules/unknown-table.md +47 -0
  59. sqlty/rules/unknown-type.md +74 -0
  60. sqlty/rules/unparsed-query.md +46 -0
  61. sqlty/rules/unsupported-syntax.md +69 -0
  62. sqlty/rules/untyped-query.md +39 -0
  63. sqlty/rules/unused-ignore.md +36 -0
  64. sqlty/skill/SKILL.md +110 -0
  65. sqlty-0.1.0.dist-info/METADATA +210 -0
  66. sqlty-0.1.0.dist-info/RECORD +71 -0
  67. sqlty-0.1.0.dist-info/WHEEL +4 -0
  68. sqlty-0.1.0.dist-info/entry_points.txt +2 -0
  69. sqlty-0.1.0.dist-info/licenses/LICENSE +21 -0
  70. sqlty-0.1.0.dist-info/sboms/sqlty-core.cyclonedx.json +1891 -0
  71. 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)),)