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/_config.py ADDED
@@ -0,0 +1,666 @@
1
+ """Configuration loading from pyproject.toml."""
2
+
3
+ import ast
4
+ import copy
5
+ import difflib
6
+ import glob
7
+ import logging
8
+ from functools import lru_cache
9
+ from pathlib import Path
10
+ from typing import Any, NamedTuple, TypedDict, cast
11
+
12
+ SchemaSpec = str | list[str]
13
+
14
+ TOOL_KEYS = (
15
+ "dialect",
16
+ "schema",
17
+ "rules",
18
+ "overrides",
19
+ "import-style",
20
+ "untyped",
21
+ "json-types",
22
+ "type-modules",
23
+ "functions",
24
+ "types",
25
+ "format-command",
26
+ )
27
+ """Keys of ``[tool.sqlty]`` that are not file discovery; see also ``DISCOVERY_KEYS``."""
28
+
29
+ DISCOVERY_KEYS = ("exclude", "extend-exclude", "respect-gitignore")
30
+ """``[tool.sqlty]`` keys that pick the files to process."""
31
+
32
+ FUNCTION_KEYS = ("import-path", "query-param", "return-type-param")
33
+
34
+ OVERRIDE_KEYS = ("include", "exclude", "rules")
35
+
36
+
37
+ class KeyDoc(NamedTuple):
38
+ """Reference documentation of one configuration key.
39
+
40
+ ``docs/reference/configuration.md`` renders its tables from these entries
41
+ (``make generate-docs``); ``tests/test_docs.py`` fails when the page drifts.
42
+ """
43
+
44
+ type: str
45
+ default: str
46
+ used_by: str
47
+ summary: str
48
+ required: bool = False
49
+
50
+
51
+ KEY_DOCS: dict[str, KeyDoc] = {
52
+ "dialect": KeyDoc(
53
+ '`"postgres"`, `"sqlite"`, `"mysql"`',
54
+ '`"postgres"`',
55
+ "`rewrite`, `check`",
56
+ "SQL dialect of queries and schema",
57
+ ),
58
+ "schema": KeyDoc(
59
+ "string or list of strings",
60
+ "none",
61
+ "`rewrite`, `check`",
62
+ "Schema `.sql` files, directories or globs, relative to `pyproject.toml`",
63
+ ),
64
+ "rules": KeyDoc(
65
+ "table of rule = severity",
66
+ "per-rule defaults",
67
+ "`check`",
68
+ 'Severity per lint rule: `"error"`, `"warn"` or `"ignore"`',
69
+ ),
70
+ "overrides": KeyDoc(
71
+ "array of tables",
72
+ "`[]`",
73
+ "`check`",
74
+ "Rule severities for the files `include` matches and `exclude` does not, on top of `rules`",
75
+ ),
76
+ "import-style": KeyDoc(
77
+ "string",
78
+ '`"from sqlty import SQL"`',
79
+ "`rewrite`, `strip`, `check`, magic codec",
80
+ "How rewritten code imports and spells the wrapper",
81
+ ),
82
+ "untyped": KeyDoc(
83
+ '`"skip"`, `"sentinel"`',
84
+ '`"skip"`',
85
+ "`rewrite`",
86
+ "Whether queries of unknown row type are wrapped as `SQL[UNTYPED]`",
87
+ ),
88
+ "json-types": KeyDoc(
89
+ '`"typing.Any"`, `"typing.TypedDict"`',
90
+ '`"typing.Any"`',
91
+ "`rewrite`, `check --fix`",
92
+ "How a JSON column of known shape is typed: `typing.Any`, or a generated `TypedDict`",
93
+ ),
94
+ "type-modules": KeyDoc(
95
+ "list of strings",
96
+ "`[]`",
97
+ "`check`",
98
+ "Modules `check` may import to resolve row type fields",
99
+ ),
100
+ "functions": KeyDoc(
101
+ "array of tables",
102
+ "`[]`",
103
+ "`check`",
104
+ "Helper functions whose string argument is a query",
105
+ ),
106
+ "types": KeyDoc(
107
+ "table of SQL type or `table.column` = Python type",
108
+ "`{}`",
109
+ "`rewrite`, `check`",
110
+ "Python types a converter above the driver gives a SQL type or a column",
111
+ ),
112
+ "format-command": KeyDoc(
113
+ "list of strings",
114
+ "none",
115
+ "`rewrite`, `check --fix`",
116
+ "Formatter run on each file `rewrite` or `check --fix` changes, with the path appended",
117
+ ),
118
+ "exclude": KeyDoc(
119
+ "list of globs",
120
+ "see [Default excludes](#exclude-and-extend-exclude)",
121
+ "`rewrite`, `strip`, `check`",
122
+ "Globs skipped while walking a directory; replaces the default list",
123
+ ),
124
+ "extend-exclude": KeyDoc(
125
+ "list of globs",
126
+ "`[]`",
127
+ "`rewrite`, `strip`, `check`",
128
+ "Globs skipped in addition to `exclude` or the defaults",
129
+ ),
130
+ "respect-gitignore": KeyDoc(
131
+ "boolean",
132
+ "`true`",
133
+ "`rewrite`, `strip`, `check`",
134
+ "Skip the files `.gitignore` ignores while walking a directory",
135
+ ),
136
+ }
137
+ """Reference docs per key of ``[tool.sqlty]``, in documentation order."""
138
+
139
+ FUNCTION_KEY_DOCS: dict[str, KeyDoc] = {
140
+ "import-path": KeyDoc(
141
+ "string",
142
+ "none",
143
+ "`check`",
144
+ 'Module path and function name, for example `"app.db.sql"`; a method is `"pkg.db.Db.fetch_all"`. '
145
+ 'Use `"__main__.sql"` or the file name for a script run directly',
146
+ required=True,
147
+ ),
148
+ "query-param": KeyDoc(
149
+ "string",
150
+ "none",
151
+ "`check`",
152
+ "Name of the parameter that holds the query",
153
+ required=True,
154
+ ),
155
+ "return-type-param": KeyDoc(
156
+ "string",
157
+ "none",
158
+ "`check`",
159
+ "Parameter whose argument, such as `return_tp=int`, is the row type of that call "
160
+ "instead of an inferred one; `check` compares it with the query columns",
161
+ ),
162
+ }
163
+ """Reference docs per key of a ``[[tool.sqlty.functions]]`` entry."""
164
+
165
+ DEFAULT_EXCLUDE = (
166
+ ".bzr",
167
+ ".direnv",
168
+ ".eggs",
169
+ ".git",
170
+ ".git-rewrite",
171
+ ".hg",
172
+ ".ipynb_checkpoints",
173
+ ".mypy_cache",
174
+ ".nox",
175
+ ".pants.d",
176
+ ".pyenv",
177
+ ".pytest_cache",
178
+ ".pytype",
179
+ ".ruff_cache",
180
+ ".svn",
181
+ ".tox",
182
+ ".venv",
183
+ ".vscode",
184
+ "__pypackages__",
185
+ "_build",
186
+ "buck-out",
187
+ "dist",
188
+ "node_modules",
189
+ "site-packages",
190
+ "venv",
191
+ )
192
+ """Directories skipped when walking a directory, unless ``exclude`` replaces them."""
193
+
194
+
195
+ class ConfigError(Exception):
196
+ """A user error in configuration or command-line paths; the CLI prints it and exits 2."""
197
+
198
+
199
+ def _suggest(name: str, choices: tuple[str, ...] | list[str]) -> str:
200
+ close = difflib.get_close_matches(name, choices, n=1)
201
+ return f" (did you mean {close[0]!r}?)" if close else ""
202
+
203
+
204
+ def dialect_problem(dialect: object) -> str | None:
205
+ """Why ``dialect`` is not a dialect name, or ``None`` when it is one (in any case).
206
+
207
+ Example:
208
+ >>> dialect_problem("PostgreSQL") is None
209
+ True
210
+ >>> dialect_problem("postgress") # doctest: +ELLIPSIS
211
+ "unknown dialect 'postgress' (did you mean 'postgres'?); expected one of: postgres, ..."
212
+ """
213
+ from sqlty._core import dialect_names
214
+
215
+ names = dialect_names()
216
+ if isinstance(dialect, str) and dialect.lower() in (*names, "postgresql", "tsql"):
217
+ return None
218
+ return (
219
+ f"unknown dialect {dialect!r}{_suggest(str(dialect), names)}; "
220
+ f"expected one of: {', '.join(names)}"
221
+ )
222
+
223
+
224
+ def validate_tool_table(table: dict[str, Any], source: str) -> None:
225
+ """Raise ``ConfigError`` for an unknown key, rule or severity in a ``[tool.sqlty]`` table.
226
+
227
+ ``source`` names the file in the message. Shared by the CLI and the mdtest runner.
228
+
229
+ Example:
230
+ >>> validate_tool_table({"dialect": "mysql"}, "pyproject.toml")
231
+ >>> validate_tool_table({"import_style": "x"}, "pyproject.toml") # doctest: +ELLIPSIS
232
+ Traceback (most recent call last):
233
+ sqlty._config.ConfigError: pyproject.toml: unknown key 'import_style' in [tool.sqlty] (did you mean 'import-style'?); ...
234
+ """
235
+ from sqlty._lint import RULES, Severity
236
+
237
+ keys = (*TOOL_KEYS, *DISCOVERY_KEYS)
238
+ for key in table:
239
+ if key not in keys:
240
+ raise ConfigError(
241
+ f"{source}: unknown key {key!r} in [tool.sqlty]{_suggest(key, keys)}; "
242
+ f"expected one of: {', '.join(keys)}"
243
+ )
244
+ severities = [s.value for s in Severity]
245
+
246
+ def check_rules(rules: object, key: str, where: str) -> None:
247
+ if not isinstance(rules, dict):
248
+ raise ConfigError(f"{source}: {key} must be a table of rule = severity")
249
+ for rule, severity in rules.items():
250
+ if rule not in RULES:
251
+ raise ConfigError(
252
+ f"{source}: unknown rule {rule!r} in {where}"
253
+ f"{_suggest(rule, list(RULES))}; run `sqlty rule` for the list"
254
+ )
255
+ if severity not in severities:
256
+ raise ConfigError(
257
+ f"{source}: invalid severity {severity!r} for rule {rule!r} in "
258
+ f"{where}; expected one of: {', '.join(severities)}"
259
+ )
260
+
261
+ if "dialect" in table and (problem := dialect_problem(table["dialect"])):
262
+ raise ConfigError(f"{source}: [tool.sqlty] {problem}")
263
+ check_rules(table.get("rules", {}), "[tool.sqlty] rules", "[tool.sqlty.rules]")
264
+ overrides = table.get("overrides", [])
265
+ if not isinstance(overrides, list) or not all(isinstance(o, dict) for o in overrides):
266
+ raise ConfigError(f"{source}: [tool.sqlty] overrides must be an array of tables")
267
+ for override in overrides:
268
+ for key in override:
269
+ if key not in OVERRIDE_KEYS:
270
+ raise ConfigError(
271
+ f"{source}: unknown key {key!r} in [[tool.sqlty.overrides]]"
272
+ f"{_suggest(key, OVERRIDE_KEYS)}; expected one of: {', '.join(OVERRIDE_KEYS)}"
273
+ )
274
+ for key in ("include", "exclude"):
275
+ globs = override.get(key, [])
276
+ if not isinstance(globs, list) or not all(isinstance(g, str) for g in globs):
277
+ raise ConfigError(
278
+ f"{source}: [[tool.sqlty.overrides]] {key} must be a list of glob strings"
279
+ )
280
+ check_rules(
281
+ override.get("rules", {}),
282
+ "[[tool.sqlty.overrides]] rules",
283
+ "[tool.sqlty.overrides.rules]",
284
+ )
285
+ functions = table.get("functions", [])
286
+ if not isinstance(functions, list):
287
+ raise ConfigError(f"{source}: [tool.sqlty] functions must be an array of tables")
288
+ for function in functions:
289
+ if isinstance(function, dict):
290
+ for key in function:
291
+ if key not in FUNCTION_KEYS:
292
+ raise ConfigError(
293
+ f"{source}: unknown key {key!r} in [[tool.sqlty.functions]]"
294
+ f"{_suggest(key, FUNCTION_KEYS)}; expected one of: {', '.join(FUNCTION_KEYS)}"
295
+ )
296
+ types = table.get("types", {})
297
+ if not isinstance(types, dict) or not all(isinstance(v, str) for v in types.values()):
298
+ raise ConfigError(
299
+ f"{source}: [tool.sqlty] types must be a table of SQL type or table.column = "
300
+ 'Python type, such as DATE = "datetime.date"'
301
+ )
302
+ for key, python in types.items():
303
+ try:
304
+ ast.parse(python, mode="eval")
305
+ except SyntaxError:
306
+ raise ConfigError(
307
+ f"{source}: [tool.sqlty.types] {key} = {python!r} is not a Python type expression"
308
+ ) from None
309
+ command = table.get("format-command", [])
310
+ if not isinstance(command, list) or not all(isinstance(part, str) for part in command):
311
+ raise ConfigError(
312
+ f"{source}: [tool.sqlty] format-command must be a list of strings, "
313
+ 'such as ["ruff", "format"]'
314
+ )
315
+ if not isinstance(table.get("respect-gitignore", True), bool):
316
+ raise ConfigError(f"{source}: [tool.sqlty] respect-gitignore must be true or false")
317
+ for key in ("exclude", "extend-exclude"):
318
+ value = table.get(key, [])
319
+ if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
320
+ raise ConfigError(f"{source}: [tool.sqlty] {key} must be a list of glob strings")
321
+
322
+
323
+ class RuleOverride(TypedDict):
324
+ """A ``[[tool.sqlty.overrides]]`` entry, as ty spells them: rule severities for the files
325
+ ``include`` matches (every file when it is empty) and ``exclude`` does not.
326
+
327
+ Globs are relative to the ``pyproject.toml`` directory; a glob without a slash
328
+ matches a file or directory name at any depth, as in ``exclude``.
329
+ """
330
+
331
+ include: list[str]
332
+ exclude: list[str]
333
+ rules: dict[str, str]
334
+
335
+
336
+ class FunctionConfigComplete(TypedDict, total=False):
337
+ """A ``[[tool.sqlty.functions]]`` entry: a helper whose string argument is a query.
338
+
339
+ In TOML the keys are ``import-path`` and ``query-param`` (required) and
340
+ ``return-type-param`` (optional); here they are the snake_case fields below.
341
+ """
342
+
343
+ import_path: str # Full import path, e.g. "module.function_name" (required)
344
+ query_param: str # Parameter holding the SQL query (required)
345
+ return_type_param: str # Parameter holding the row type (optional)
346
+
347
+
348
+ class SQLTyConfig(TypedDict, total=False):
349
+ """Configuration for SQLTy from pyproject.toml.
350
+
351
+ Corresponds to the ``[tool.sqlty]`` section:
352
+
353
+ .. code-block:: toml
354
+
355
+ [tool.sqlty]
356
+ dialect = "postgres"
357
+ schema = "schema.sql"
358
+ import-style = "from sqlty import SQL"
359
+ untyped = "sentinel"
360
+ type-modules = ["myapp.types"]
361
+ extend-exclude = ["migrations"]
362
+
363
+ [tool.sqlty.rules]
364
+ select-star = "ignore"
365
+ stale-type = "error"
366
+
367
+ [[tool.sqlty.functions]]
368
+ import-path = "db.query"
369
+ query-param = "sql"
370
+ """
371
+
372
+ dialect: str
373
+ schema: SchemaSpec # Schema file, directory or glob, or a list of them
374
+ rules: dict[str, str] # rule-name -> severity (error/warn/ignore)
375
+ overrides: list[RuleOverride] # rule severities for files matching globs
376
+ import_style: str # e.g., "from sqlty import SQL as Q" or "import sqlty"
377
+ untyped: str # "skip" (default) or "sentinel": SQL[UNTYPED] for uninferable queries
378
+ json_types: (
379
+ str # "typing.Any" (default) or "typing.TypedDict": generated TypedDicts for JSON columns
380
+ )
381
+ type_modules: list[str] # modules whose imports may run to resolve row types
382
+ exclude: list[str] # globs replacing the default excludes, relative to pyproject.toml
383
+ extend_exclude: list[str] # globs excluded in addition to the above
384
+ respect_gitignore: bool # skip what .gitignore ignores while walking
385
+ functions: list[FunctionConfigComplete] # List of functions to track and analyze
386
+ types: dict[str, str] # SQL type or table.column -> Python type a converter gives it
387
+ format_command: list[str] # formatter run on changed files, path appended
388
+
389
+
390
+ def _schema_spec(value: Any) -> SchemaSpec:
391
+ if isinstance(value, list):
392
+ return [str(v) for v in value]
393
+ return str(value)
394
+
395
+
396
+ def expand_schema(spec: SchemaSpec | Path, base: Path) -> list[Path]:
397
+ """List the schema files named by ``spec``, in order.
398
+
399
+ ``spec`` is a path or a list of paths; each entry is a file, a directory
400
+ (every ``*.sql`` under it, recursively) or a glob (``*``, ``?``, ``[``;
401
+ ``**`` recurses), relative to ``base``. Directory and glob matches are sorted
402
+ by path; repeated files are listed once. A plain path is kept even if it
403
+ does not exist, so callers report it; a glob that matches nothing is logged.
404
+ """
405
+ entries = [spec] if isinstance(spec, (str, Path)) else spec
406
+ files: dict[Path, None] = {}
407
+ for entry in entries:
408
+ path = base / entry
409
+ if glob.has_magic(str(entry)):
410
+ matches = [
411
+ base / m for m in sorted(glob.glob(str(entry), root_dir=base, recursive=True))
412
+ ]
413
+ found = [m for m in matches if m.is_file()]
414
+ if not found:
415
+ logging.getLogger(__name__).warning("Schema pattern matches no files: %s", entry)
416
+ elif path.is_dir():
417
+ found = sorted(p for p in path.rglob("*.sql") if p.is_file())
418
+ else:
419
+ found = [path]
420
+ files.update(dict.fromkeys(found))
421
+ return list(files)
422
+
423
+
424
+ def load_config(project_root: Path | None = None) -> tuple[SQLTyConfig, Path | None]:
425
+ """Load the ``[tool.sqlty]`` table of the nearest ``pyproject.toml`` that has one.
426
+
427
+ Walks up from ``project_root``, skipping ``pyproject.toml`` files without a
428
+ ``[tool.sqlty]`` table (like ruff), so a package's packaging-only
429
+ ``pyproject.toml`` does not hide the workspace config. The closest table
430
+ wins; tables are never merged.
431
+
432
+ Args:
433
+ project_root: Directory to start the upward search from; cwd if None.
434
+
435
+ Returns:
436
+ Tuple of (configuration dictionary, directory containing that
437
+ pyproject.toml, or None if none was found or it failed to parse)
438
+ """
439
+ if project_root is None:
440
+ project_root = Path.cwd()
441
+
442
+ # Search for pyproject.toml up the directory tree
443
+ current = project_root.resolve()
444
+ while current != current.parent:
445
+ pyproject_path = current / "pyproject.toml"
446
+ if pyproject_path.exists():
447
+ try:
448
+ config = _parse_pyproject_cached(pyproject_path, pyproject_path.stat().st_mtime_ns)
449
+ except ConfigError:
450
+ raise
451
+ except Exception as e:
452
+ logging.getLogger(__name__).warning("Failed to parse %s: %s", pyproject_path, e)
453
+ return {}, None
454
+ if config is not None:
455
+ return copy.deepcopy(config), current
456
+ current = current.parent
457
+
458
+ # No [tool.sqlty] table found
459
+ return {}, None
460
+
461
+
462
+ @lru_cache(maxsize=256)
463
+ def _parse_pyproject_cached(pyproject_path: Path, mtime_ns: int) -> SQLTyConfig | None:
464
+ """Parse ``pyproject_path`` once per (path, mtime_ns); an edit changes the mtime and re-parses.
465
+
466
+ The mtime is part of the key so long-running processes see edits. Failures
467
+ are not cached.
468
+ """
469
+ return _parse_pyproject(pyproject_path)
470
+
471
+
472
+ def _parse_pyproject(pyproject_path: Path) -> SQLTyConfig | None:
473
+ """Parse pyproject.toml and extract [tool.sqlty] section.
474
+
475
+ Args:
476
+ pyproject_path: Path to pyproject.toml
477
+
478
+ Returns:
479
+ Configuration dictionary, or None if there is no [tool.sqlty] table
480
+ """
481
+ import tomllib
482
+
483
+ with open(pyproject_path, "rb") as f:
484
+ data: dict[str, Any] = tomllib.load(f)
485
+
486
+ # Extract [tool.sqlty] section
487
+ tool_config: dict[str, Any] | None = data.get("tool", {}).get("sqlty")
488
+ if tool_config is None:
489
+ return None
490
+ return _parse_table(tool_config, str(pyproject_path))
491
+
492
+
493
+ def load_config_file(path: Path) -> SQLTyConfig:
494
+ """Load the configuration of a file named by ``--config``.
495
+
496
+ A file named ``pyproject.toml`` must have a ``[tool.sqlty]`` table; any other
497
+ file, such as ``sqlty.toml``, holds the same keys at the top level. Raises
498
+ ``ConfigError`` when the file is missing, is not TOML, is a ``pyproject.toml``
499
+ without ``[tool.sqlty]`` or has an invalid key.
500
+
501
+ Example:
502
+ >>> load_config_file(Path("missing.toml")) # doctest: +ELLIPSIS
503
+ Traceback (most recent call last):
504
+ sqlty._config.ConfigError: config file does not exist: missing.toml
505
+ """
506
+ import tomllib
507
+
508
+ if not path.exists():
509
+ raise ConfigError(f"config file does not exist: {path}")
510
+ if not path.is_file():
511
+ raise ConfigError(f"config path is not a file: {path}")
512
+ try:
513
+ with open(path, "rb") as f:
514
+ data: dict[str, Any] = tomllib.load(f)
515
+ except tomllib.TOMLDecodeError as exc:
516
+ raise ConfigError(f"{path}: {exc}") from None
517
+ if path.name == "pyproject.toml":
518
+ table = data.get("tool", {}).get("sqlty")
519
+ if table is None:
520
+ raise ConfigError(f"{path}: no [tool.sqlty] table")
521
+ data = table
522
+ return _parse_table(data, str(path))
523
+
524
+
525
+ def parse_overrides(assignments: list[str]) -> SQLTyConfig:
526
+ """Parse ``-c/--config`` TOML assignments into one configuration; later ones win.
527
+
528
+ Tables merge key by key, so ``rules.a = "ignore"`` and ``rules.b = "warn"``
529
+ both apply. Raises ``ConfigError`` for a value that is not a TOML assignment
530
+ or names an unknown key.
531
+
532
+ Example:
533
+ >>> parse_overrides(['dialect = "mysql"', 'rules.select-star = "ignore"'])
534
+ {'dialect': 'mysql', 'rules': {'select-star': 'ignore'}}
535
+ """
536
+ import tomllib
537
+
538
+ merged: dict[str, Any] = {}
539
+ for assignment in assignments:
540
+ if "=" not in assignment:
541
+ raise ConfigError(
542
+ f"--config expects a TOML assignment such as 'dialect = \"mysql\"', got "
543
+ f"{assignment!r}; use --config-file for a file"
544
+ )
545
+ try:
546
+ _merge(merged, tomllib.loads(assignment))
547
+ except tomllib.TOMLDecodeError as exc:
548
+ raise ConfigError(
549
+ f"--config {assignment!r}: {exc}; quote strings, as in -c 'dialect = \"mysql\"', "
550
+ "or use --config-file for a file"
551
+ ) from None
552
+ return _parse_table(merged, "--config")
553
+
554
+
555
+ def _merge(target: dict[str, Any], update: dict[str, Any]) -> None:
556
+ for key, value in update.items():
557
+ if isinstance(value, dict) and isinstance(target.get(key), dict):
558
+ _merge(target[key], value)
559
+ else:
560
+ target[key] = value
561
+
562
+
563
+ def validate_rule_severity(pairs: list[tuple[str, str]]) -> dict[str, str]:
564
+ """Rule severities from ``--error``/``--warn``/``--ignore RULE``, the last flag per rule winning.
565
+
566
+ Example:
567
+ >>> validate_rule_severity([("select-star", "error"), ("select-star", "ignore")])
568
+ {'select-star': 'ignore'}
569
+ """
570
+ from sqlty._lint import RULES
571
+
572
+ for rule, severity in pairs:
573
+ if rule not in RULES:
574
+ raise ConfigError(
575
+ f"--{severity}: unknown rule {rule!r}{_suggest(rule, list(RULES))}; "
576
+ "run `sqlty rule` for the list"
577
+ )
578
+ return dict(pairs)
579
+
580
+
581
+ def _parse_table(tool_config: dict[str, Any], source: str) -> SQLTyConfig:
582
+ """Validate a ``[tool.sqlty]`` table and convert it to ``SQLTyConfig``."""
583
+ validate_tool_table(tool_config, source)
584
+ config: SQLTyConfig = {}
585
+
586
+ if "exclude" in tool_config:
587
+ config["exclude"] = list(tool_config["exclude"])
588
+ if "extend-exclude" in tool_config:
589
+ config["extend_exclude"] = list(tool_config["extend-exclude"])
590
+ if "respect-gitignore" in tool_config:
591
+ config["respect_gitignore"] = tool_config["respect-gitignore"]
592
+
593
+ # Parse each configuration option
594
+ if "dialect" in tool_config:
595
+ config["dialect"] = str(tool_config["dialect"])
596
+
597
+ # Single schema path (top-level convenience key)
598
+ if "schema" in tool_config:
599
+ config["schema"] = _schema_spec(tool_config["schema"])
600
+ if "format-command" in tool_config:
601
+ config["format_command"] = list(tool_config["format-command"])
602
+
603
+ # Rule severity overrides: [tool.sqlty.rules]
604
+ if "rules" in tool_config and isinstance(tool_config["rules"], dict):
605
+ config["rules"] = {str(k): str(v) for k, v in tool_config["rules"].items()}
606
+ if "overrides" in tool_config:
607
+ config["overrides"] = [
608
+ {
609
+ "include": list(o.get("include", [])),
610
+ "exclude": list(o.get("exclude", [])),
611
+ "rules": dict(o.get("rules", {})),
612
+ }
613
+ for o in tool_config["overrides"]
614
+ ]
615
+
616
+ # Allow hyphenated key in pyproject: import-style
617
+ if "import-style" in tool_config:
618
+ config["import_style"] = str(tool_config["import-style"])
619
+
620
+ if "untyped" in tool_config:
621
+ config["untyped"] = str(tool_config["untyped"])
622
+
623
+ if "json-types" in tool_config:
624
+ config["json_types"] = str(tool_config["json-types"])
625
+
626
+ if "types" in tool_config and isinstance(tool_config["types"], dict):
627
+ config["types"] = {str(k): str(v) for k, v in tool_config["types"].items()}
628
+
629
+ if "type-modules" in tool_config:
630
+ modules = tool_config["type-modules"]
631
+ config["type_modules"] = (
632
+ [str(m) for m in modules] if isinstance(modules, list) else [str(modules)]
633
+ )
634
+
635
+ # Parse function configurations
636
+ if "functions" in tool_config and isinstance(tool_config["functions"], list):
637
+ functions: list[FunctionConfigComplete] = []
638
+ for func_config in tool_config["functions"]:
639
+ if not isinstance(func_config, dict):
640
+ continue
641
+
642
+ # Validate required fields
643
+ if "import-path" not in func_config or "query-param" not in func_config:
644
+ logging.getLogger(__name__).warning(
645
+ "Skipping function config missing required fields: %s", func_config
646
+ )
647
+ continue
648
+
649
+ # Build function config with all fields
650
+ function_dict: dict[str, Any] = {
651
+ "import_path": str(func_config["import-path"]),
652
+ "query_param": str(func_config["query-param"]),
653
+ }
654
+
655
+ # Optional return-type-param field
656
+ if "return-type-param" in func_config:
657
+ function_dict["return_type_param"] = str(func_config["return-type-param"])
658
+
659
+ # Cast to FunctionConfigComplete after all fields are set
660
+ function = cast(FunctionConfigComplete, function_dict)
661
+ functions.append(function)
662
+
663
+ if functions:
664
+ config["functions"] = functions
665
+
666
+ return config
Binary file