sqlakit-lsp 0.1.1__tar.gz → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlakit-lsp
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: The language server for SQLAKit templates.
5
5
  Keywords: sqlalchemy,sqlakit,lsp,language-server,sql
6
6
  Author: Anton Ruhlov
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlakit-lsp"
3
- version = "0.1.1"
3
+ version = "0.2.0"
4
4
  description = "The language server for SQLAKit templates."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -131,7 +131,7 @@ dev = [
131
131
  "pytest>=9.1.1",
132
132
  "pytest-cov>=7.1.0",
133
133
  "anyio>=4.14.2",
134
- "ty>=0.0.80",
134
+ "ty>=0.0.84",
135
135
  "ruff>=0.16.6",
136
136
  "poethepoet>=0.48.0",
137
137
  "codespell>=2.4.3",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlakit-lsp"
3
- version = "0.1.1"
3
+ version = "0.2.0"
4
4
  description = "The language server for SQLAKit templates."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -22,8 +22,6 @@ classifiers = [
22
22
  ]
23
23
  requires-python = ">=3.11"
24
24
  dependencies = [
25
- # The server reads the private modules of `sqlakit`, which a minor version
26
- # may change.
27
25
  "sqlakit>=0.21.0,<0.23",
28
26
  "pygls>=2.1,<3",
29
27
  ]
@@ -51,7 +49,7 @@ dev = [
51
49
  "pytest>=9.1.1",
52
50
  "pytest-cov>=7.1.0",
53
51
  "anyio>=4.14.2",
54
- "ty>=0.0.80",
52
+ "ty>=0.0.84",
55
53
  "ruff>=0.16.6",
56
54
  "poethepoet>=0.48.0",
57
55
  "codespell>=2.4.3",
@@ -23,16 +23,18 @@ import asyncio
23
23
  import bisect
24
24
  import difflib
25
25
  import inspect
26
+ import os
26
27
  import re
27
28
  import tempfile
28
29
  import uuid
29
30
  from collections import OrderedDict
30
- from dataclasses import dataclass, field
31
- from functools import lru_cache
31
+ from dataclasses import dataclass, field, replace
32
+ from functools import cached_property, lru_cache
32
33
  from importlib.metadata import version
33
34
  from pathlib import Path
34
35
  from typing import TYPE_CHECKING, Any
35
36
 
37
+ import sqlakit
36
38
  import sqlalchemy as sa
37
39
  import sqlalchemy.engine.default
38
40
  import sqlalchemy.exc
@@ -421,6 +423,34 @@ class _Assistant:
421
423
  resolved = path.resolve()
422
424
  return any(resolved in files for files in self._files)
423
425
 
426
+ def _source(self, macro: Macro) -> Target | None:
427
+ """Return where a macro is written, in the project's own `sqlakit`.
428
+
429
+ A server that `uvx` runs imports `sqlakit` from a cache of its own. The
430
+ project holds the same version in `.venv`, which is the file to open.
431
+ """
432
+ target = _source_of(macro)
433
+ if target is None or self._library is None:
434
+ return target
435
+ try:
436
+ inside = target.path.resolve().relative_to(_LIBRARY)
437
+ except ValueError:
438
+ return target
439
+ return replace(target, path=self._library / inside)
440
+
441
+ @cached_property
442
+ def _library(self) -> Path | None:
443
+ """Return the project's `sqlakit` in `.venv`, when it is this version."""
444
+ installed = f"sqlakit-{version('sqlakit')}.dist-info"
445
+ for site in (
446
+ *self._root.glob(".venv/lib/python*/site-packages"),
447
+ self._root / ".venv/Lib/site-packages",
448
+ ):
449
+ library = site / "sqlakit"
450
+ if (site / installed).is_dir() and library.resolve() != _LIBRARY:
451
+ return library
452
+ return None
453
+
424
454
  def owns(self, path: Path) -> bool:
425
455
  """Whether a file is the project's own, where `walk` goes.
426
456
 
@@ -447,9 +477,27 @@ class _Assistant:
447
477
  def names(self) -> list[str]:
448
478
  """Every template's name, read once until `forget_files`."""
449
479
  if self._names is None:
450
- self._names = self.project.templates.names()
480
+ self._names = sorted(self._on_disk())
451
481
  return self._names
452
482
 
483
+ def _on_disk(self) -> dict[str, Path]:
484
+ """Return each template's name and file, as `Templates.names` finds them.
485
+
486
+ One walk of the directories, in strings: a file made has the next
487
+ keystroke read them again, and paths cost more than the walk.
488
+ """
489
+ skipped = {str(path) for path in self._macro_files | self._macro_sql}
490
+ found: dict[str, Path] = {}
491
+ for root in self._template_roots:
492
+ start = len(str(root)) + 1
493
+ for directory, _, files in os.walk(root):
494
+ for file in files:
495
+ written = os.path.join(directory, file) # noqa: PTH118 - strings, for speed
496
+ if file.endswith(".sql") and written not in skipped:
497
+ name = written[start:].replace(os.sep, "/")
498
+ found.setdefault(name, Path(written))
499
+ return found
500
+
453
501
  def defines_macros(self, path: Path) -> bool:
454
502
  """Whether a file defines a macro the project calls, its own or built in.
455
503
 
@@ -460,7 +508,7 @@ class _Assistant:
460
508
  self._sources = {
461
509
  target.path.resolve()
462
510
  for macro in self.project.templates.macros.values()
463
- if (target := _source_of(macro)) is not None
511
+ if (target := self._source(macro)) is not None
464
512
  }
465
513
  return path.resolve() in self._sources
466
514
 
@@ -688,7 +736,7 @@ class _Assistant:
688
736
  signature_of(macro, namespace),
689
737
  )
690
738
  for macro in self.project.templates.macros.values()
691
- if (target := _source_of(macro)) is not None
739
+ if (target := self._source(macro)) is not None
692
740
  ]
693
741
  everything.extend(
694
742
  (name, "template", Target(path, 0), "")
@@ -942,7 +990,7 @@ class _Assistant:
942
990
  return None if path is None else Target(path, 0)
943
991
  macro = self.project.templates.macros.get(self._macro_at(source, offset) or "")
944
992
  if macro is not None:
945
- return _source_of(macro)
993
+ return self._source(macro)
946
994
  if path is None:
947
995
  return None
948
996
  # The SQL of `@sql_macro("file.sql")` goes to its function.
@@ -963,7 +1011,7 @@ class _Assistant:
963
1011
  if macro is None or sql is None:
964
1012
  return None
965
1013
  if Path(sql).resolve() == path.resolve():
966
- return _source_of(macro)
1014
+ return self._source(macro)
967
1015
  statement = next(
968
1016
  (one for one in sql_macros(Path(sql)) if one.name == macro.name), None
969
1017
  )
@@ -1002,7 +1050,7 @@ class _Assistant:
1002
1050
  return next(
1003
1051
  (
1004
1052
  (start, end)
1005
- for start, end, _ in _template_names(source)
1053
+ for start, end, _ in self._files_named(source, path)
1006
1054
  if start <= offset <= end
1007
1055
  ),
1008
1056
  defined,
@@ -1100,25 +1148,46 @@ class _Assistant:
1100
1148
  def python_definition(
1101
1149
  self, source: str, offset: int, path: Path | None = None
1102
1150
  ) -> Target | None:
1103
- """Return the template the name under the offset reads.
1151
+ """Return the file the name under the offset reads.
1104
1152
 
1105
- On the name of a `def` that defines a macro, that is the name itself.
1153
+ That is a template in `db.sql("...")`, or the SQL of a macro in
1154
+ `@sql_macro("...")`. On the name of a `def` that defines a macro, it is
1155
+ the name itself.
1106
1156
  """
1107
- for start, end, name in _template_names(source):
1157
+ for start, end, found in self._files_named(source, path):
1108
1158
  if start <= offset <= end:
1109
- found = self.project.path_of(name)
1110
- return None if found is None else Target(found, 0)
1159
+ return Target(found, 0)
1111
1160
  return None if path is None else self._itself(path, source, offset)
1112
1161
 
1162
+ def _files_named(
1163
+ self, source: str, path: Path | None
1164
+ ) -> list[tuple[int, int, Path]]:
1165
+ """Return each file the Python names, where the name is, and the file.
1166
+
1167
+ A template is looked for under the template directories, and the SQL of
1168
+ `@sql_macro("...")` next to the module, as `sql_macro` reads it.
1169
+ """
1170
+ found = [
1171
+ (start, end, target)
1172
+ for start, end, name in _template_names(source)
1173
+ if (target := self.project.path_of(name)) is not None
1174
+ ]
1175
+ if path is not None:
1176
+ found.extend(
1177
+ (start, end, target)
1178
+ for start, end, name in _sql_macro_files(source)
1179
+ if (target := path.parent / name).is_file()
1180
+ )
1181
+ return found
1182
+
1113
1183
  def links(self, path: Path, source: str) -> list[tuple[int, int, Path]]:
1114
- """Return each template the text names, where the name is, and its file."""
1115
- if self.reads_python(path):
1116
- named = _template_names(source)
1117
- else:
1118
- named = [
1119
- (found.start(1), found.end(1), found.group(1))
1120
- for found in self._include_path.finditer(source)
1121
- ]
1184
+ """Return each file the text names, where the name is, and the file."""
1185
+ if path.suffix == ".py":
1186
+ return self._files_named(source, path)
1187
+ named = [
1188
+ (found.start(1), found.end(1), found.group(1))
1189
+ for found in self._include_path.finditer(source)
1190
+ ]
1122
1191
  return [
1123
1192
  (start, end, target)
1124
1193
  for start, end, name in named
@@ -1271,7 +1340,7 @@ class _Assistant:
1271
1340
  def declaration(self, name: str) -> Reference | None:
1272
1341
  """Return where a macro's name is written, for a list of its references."""
1273
1342
  macro = self.project.templates.macros.get(name)
1274
- if macro is None or (target := _source_of(macro)) is None:
1343
+ if macro is None or (target := self._source(macro)) is None:
1275
1344
  return None
1276
1345
  text = _read(target.path)
1277
1346
  start = _line_span(text, target.line + 1)[0] + target.column
@@ -1300,7 +1369,7 @@ class _Assistant:
1300
1369
  sql = getattr(macro, "sql_path", None)
1301
1370
  if sql is not None and Path(sql).resolve() == path.resolve():
1302
1371
  return word
1303
- target = _source_of(macro)
1372
+ target = self._source(macro)
1304
1373
  if target is None or target.path.resolve() != path.resolve():
1305
1374
  return None
1306
1375
  return word if target.line + 1 == line else None
@@ -1368,12 +1437,9 @@ class _Assistant:
1368
1437
  The paths are resolved, as the editor's open files are keyed.
1369
1438
  """
1370
1439
  if self._files is None:
1371
- named = {}
1372
- for name in self.names():
1373
- for root in self._template_roots:
1374
- if (root / name).is_file():
1375
- named[root / name] = name
1376
- break
1440
+ templates = self._on_disk()
1441
+ self._names = sorted(templates)
1442
+ named = {path: name for name, path in templates.items()}
1377
1443
  sql_files = [
1378
1444
  *named,
1379
1445
  *(path.resolve() for path in self.project.macro_files()),
@@ -1615,6 +1681,46 @@ def _template_names(source: str) -> list[tuple[int, int, str]]:
1615
1681
  return found
1616
1682
 
1617
1683
 
1684
+ def _sql_macro_files(source: str) -> list[tuple[int, int, str]]:
1685
+ """Return where `@sql_macro("file.sql")` names its SQL, the quotes left out."""
1686
+ if "sql_macro" not in source:
1687
+ return []
1688
+ try:
1689
+ tree = ast.parse(source)
1690
+ except SyntaxError:
1691
+ return []
1692
+ starts = _line_starts(source)
1693
+ found = []
1694
+ for node in ast.walk(tree):
1695
+ if not isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
1696
+ continue
1697
+ for decorator in node.decorator_list:
1698
+ if not (isinstance(decorator, ast.Call) and decorator.args):
1699
+ continue
1700
+ func = decorator.func
1701
+ called = (
1702
+ func.attr
1703
+ if isinstance(func, ast.Attribute)
1704
+ else getattr(func, "id", "")
1705
+ )
1706
+ name = decorator.args[0]
1707
+ if not (
1708
+ called == "sql_macro"
1709
+ and isinstance(name, ast.Constant)
1710
+ and isinstance(name.value, str)
1711
+ and name.value.endswith(".sql")
1712
+ and name.end_lineno is not None
1713
+ and name.end_col_offset is not None
1714
+ ):
1715
+ continue
1716
+ start = _char_offset(source, starts, name.lineno, name.col_offset)
1717
+ end = _char_offset(source, starts, name.end_lineno, name.end_col_offset)
1718
+ quote = source.find(name.value, start, end)
1719
+ if quote >= 0:
1720
+ found.append((quote, quote + len(name.value), name.value))
1721
+ return found
1722
+
1723
+
1618
1724
  def _char_offset(source: str, starts: list[int], line: int, column: int) -> int:
1619
1725
  """Return the offset of a position `ast` gives, its column in UTF-8 bytes."""
1620
1726
  begin = starts[line - 1]
@@ -1673,6 +1779,9 @@ def _source_of(macro: Macro) -> Target | None:
1673
1779
 
1674
1780
  _DEF = re.compile(r"\s*(?:async\s+)?def\s+(\w+)")
1675
1781
 
1782
+ _LIBRARY = Path(sqlakit.__file__).resolve().parent
1783
+ """The `sqlakit` this server imports."""
1784
+
1676
1785
 
1677
1786
  @dataclass(frozen=True, slots=True)
1678
1787
  class _At:
File without changes
File without changes