sqlakit-lsp 0.3.0__tar.gz → 0.3.2__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.3.0
3
+ Version: 0.3.2
4
4
  Summary: The language server for SQLAKit templates.
5
5
  Keywords: sqlalchemy,sqlakit,lsp,language-server,sql
6
6
  Author: Anton Ruhlov
@@ -41,7 +41,8 @@ templates. In a `.sql` template:
41
41
  - the macros' calls and the parameters coloured
42
42
  - a parameter no call of the project passes, marked, with a quick fix for a
43
43
  name close to one the calls pass
44
- - **Show rendered SQL**, the whole template as the SQL it writes
44
+ - **Show rendered SQL**, the whole template as the SQL it writes, with `:name`
45
+ or with `?` for each parameter
45
46
  - the outline of a template, and a search for a macro or a template
46
47
 
47
48
  In Python, the template name in `db.sql("...")` completes and links to its
@@ -14,7 +14,8 @@ templates. In a `.sql` template:
14
14
  - the macros' calls and the parameters coloured
15
15
  - a parameter no call of the project passes, marked, with a quick fix for a
16
16
  name close to one the calls pass
17
- - **Show rendered SQL**, the whole template as the SQL it writes
17
+ - **Show rendered SQL**, the whole template as the SQL it writes, with `:name`
18
+ or with `?` for each parameter
18
19
  - the outline of a template, and a search for a macro or a template
19
20
 
20
21
  In Python, the template name in `db.sql("...")` completes and links to its
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlakit-lsp"
3
- version = "0.3.0"
3
+ version = "0.3.2"
4
4
  description = "The language server for SQLAKit templates."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlakit-lsp"
3
- version = "0.3.0"
3
+ version = "0.3.2"
4
4
  description = "The language server for SQLAKit templates."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -0,0 +1,92 @@
1
+ """Render a template with the project's own macros, in the project's Python.
2
+
3
+ The server reads the project's code and never runs it, so a macro written in
4
+ Python stays a call when it renders a template. **Show rendered SQL** asks for
5
+ more: the server runs this file with the project's interpreter, which imports
6
+ the modules of those macros, and prints the SQL as JSON.
7
+
8
+ It reads its request as JSON on stdin, and imports nothing of the server: the
9
+ project's environment has `sqlakit`, and not the server.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import sys
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+
20
+ class _Example:
21
+ """A value to render a template with: any path reads another of it."""
22
+
23
+ def __getattr__(self, _: str) -> _Example:
24
+ return self
25
+
26
+ def __getitem__(self, _: object) -> _Example:
27
+ return self
28
+
29
+ def __iter__(self) -> Any: # noqa: ANN401
30
+ return iter((self,))
31
+
32
+ def __len__(self) -> int:
33
+ return 1
34
+
35
+ def __str__(self) -> str:
36
+ return "x"
37
+
38
+
39
+ def _module(path: Path) -> tuple[Path, str]:
40
+ """Return the directory a module is imported from, and its dotted name."""
41
+ parts = [path.stem]
42
+ folder = path.parent
43
+ while (folder / "__init__.py").is_file():
44
+ parts.append(folder.name)
45
+ folder = folder.parent
46
+ return folder, ".".join(reversed(parts))
47
+
48
+
49
+ def _preparer(dialect: str) -> Any: # noqa: ANN401
50
+ import sqlalchemy as sa # noqa: PLC0415
51
+ import sqlalchemy.engine.default # noqa: PLC0415
52
+ import sqlalchemy.exc # noqa: PLC0415
53
+
54
+ try:
55
+ return sa.engine.make_url(f"{dialect}://").get_dialect()().identifier_preparer
56
+ except sa.exc.NoSuchModuleError:
57
+ default = sa.engine.default.DefaultDialect()
58
+ default.name = dialect
59
+ return default.identifier_preparer
60
+
61
+
62
+ def main() -> None:
63
+ request = json.load(sys.stdin)
64
+ from sqlakit._project import load_project # noqa: PLC0415
65
+ from sqlakit._sql import Context, calls_kept, registered # noqa: PLC0415
66
+
67
+ project = load_project(Path(request["root"]))
68
+ for file in request["modules"]:
69
+ folder, name = _module(Path(file))
70
+ if str(folder) not in sys.path:
71
+ sys.path.insert(0, str(folder))
72
+ try:
73
+ found = registered([name])
74
+ except Exception as error: # noqa: BLE001 - the project's code raises anything
75
+ sys.stdout.write(
76
+ json.dumps({"error": f"{name} cannot be imported: {error}"}) + "\n"
77
+ )
78
+ return
79
+ project.templates.macros.update(
80
+ (key, macro) for key, macro in found.items() if key in request["macros"]
81
+ )
82
+ template = project.load(request["name"], request["source"])
83
+ values: dict[str, Any] = dict.fromkeys(template.parameters(), _Example())
84
+ values.update(dict.fromkeys(request["not_given"]))
85
+ dialect = request["dialect"]
86
+ with calls_kept():
87
+ sql = template.render(Context(dialect, _preparer(dialect), values)).strip()
88
+ sys.stdout.write(json.dumps({"sql": sql}) + "\n")
89
+
90
+
91
+ if __name__ == "__main__":
92
+ main()
@@ -23,8 +23,10 @@ import asyncio
23
23
  import bisect
24
24
  import difflib
25
25
  import inspect
26
+ import json
26
27
  import os
27
28
  import re
29
+ import subprocess
28
30
  import tempfile
29
31
  import uuid
30
32
  from collections import OrderedDict
@@ -157,6 +159,66 @@ def _innermost(parts: Sequence[Any], offset: int) -> Any: # noqa: ANN401
157
159
  return None
158
160
 
159
161
 
162
+ _BOUND = re.compile(
163
+ rf"{_LITERAL}|(?<![:\w\\]):(?P<bound>[A-Za-z_]\w*)(?![\w:])", re.DOTALL
164
+ )
165
+ """A placeholder of rendered SQL, and the strings and comments to skip."""
166
+
167
+ _LIVE = Path(__file__).with_name("_live.py")
168
+ """The script the project's interpreter renders a template with."""
169
+
170
+ _LIVE_SECONDS = 20
171
+ """How long a render with the project's macros may take: it imports them."""
172
+
173
+
174
+ def _interpreter(root: Path) -> Path | None:
175
+ """Return the Python of the project's `.venv`, if it has one."""
176
+ for found in (
177
+ root / ".venv" / "bin" / "python",
178
+ root / ".venv" / "Scripts" / "python.exe",
179
+ ):
180
+ if found.is_file():
181
+ return found
182
+ return None
183
+
184
+
185
+ def _run_live(root: Path, request: dict[str, Any]) -> tuple[str | None, str]:
186
+ """Render a template in the project's interpreter: the SQL, or why not."""
187
+ python = _interpreter(root)
188
+ if python is None:
189
+ return None, "the project has no .venv to run its macros in."
190
+ try:
191
+ done = subprocess.run( # noqa: S603 - the project's own interpreter
192
+ [str(python), str(_LIVE)],
193
+ input=json.dumps(request),
194
+ capture_output=True,
195
+ text=True,
196
+ cwd=root,
197
+ timeout=_LIVE_SECONDS,
198
+ check=False,
199
+ )
200
+ except (OSError, subprocess.TimeoutExpired) as error:
201
+ return None, f"running {_relative(python, root)} failed: {error}"
202
+ try:
203
+ answer = json.loads(done.stdout.strip().splitlines()[-1])
204
+ except (IndexError, ValueError):
205
+ last = (done.stderr.strip().splitlines() or ["no output"])[-1]
206
+ return None, f"{_relative(python, root)} failed: {last}"
207
+ if "sql" in answer:
208
+ return answer["sql"], ""
209
+ return None, str(answer.get("error", "no SQL came back"))
210
+
211
+
212
+ def _calls(parts: Sequence[Any]) -> Iterator[Any]:
213
+ """Yield every macro call of a read template, the ones in arguments too."""
214
+ for part in parts:
215
+ if isinstance(part, str):
216
+ continue
217
+ yield part
218
+ for argument in part.args:
219
+ yield from _calls(argument.parts)
220
+
221
+
160
222
  def _rendered(template: Any, dialect: str, values: dict[str, Any]) -> str | None: # noqa: ANN401
161
223
  """Return the SQL a read template writes for these values, if it can.
162
224
 
@@ -327,6 +389,9 @@ _WORD = re.compile(r"\w+")
327
389
  SHOW = "Show rendered SQL"
328
390
  """The title of the action that shows a whole template as SQL."""
329
391
 
392
+ SHOW_MARKED = "Show rendered SQL with ?"
393
+ """The title of the action that shows it with `?` for each parameter."""
394
+
330
395
  RENDER = "sqlakit.render"
331
396
  """The command that writes a whole template as SQL, for an editor to open."""
332
397
 
@@ -815,7 +880,13 @@ class _Assistant:
815
880
  """Return the whole template as the database gets it, on the project's dialect.
816
881
 
817
882
  Every parameter counts as given, so every optional part is there, and
818
- stays a placeholder. A call that cannot be made so stays a call.
883
+ stays a placeholder. A parameter a built-in macro cannot take made up,
884
+ the column `tpl.order_by` sorts by, counts as not given.
885
+
886
+ The server reads the project's Python macros and does not run them, so
887
+ when the template calls one, it is rendered again by the project's own
888
+ interpreter, `.venv/bin/python`, which imports them. Without one, or
889
+ when that fails, the calls stay calls, and a comment on top says why.
819
890
  """
820
891
  name = self.project.name_of(path) or path.name
821
892
  dialect = self.project.dialect or "postgresql"
@@ -823,11 +894,102 @@ class _Assistant:
823
894
  template = self.compiled(name, source)
824
895
  except SQLAKitError as error:
825
896
  return f"-- {name} cannot be read: {error}\n"
826
- values = dict.fromkeys(template.parameters(), _Example())
897
+ calls = list(_calls(template.parts))
898
+ values: dict[str, Any] = dict.fromkeys(template.parameters(), _Example())
899
+ values.update(dict.fromkeys(self._not_given(name, source, calls, dialect)))
827
900
  sql = _rendered(template, dialect, values)
828
901
  if sql is None:
829
902
  return f"-- {name} cannot be written with made-up values.\n"
830
- return f"-- {name} on {dialect}\n{sql}\n"
903
+ namespace = self.project.templates.namespace
904
+ # A call of an included template is in the SQL too, and not in `calls`.
905
+ static = {
906
+ key: macro
907
+ for key, macro in self.project.templates.macros.items()
908
+ if isinstance(macro, StaticMacro)
909
+ }
910
+ kept = {
911
+ key: macro
912
+ for key, macro in static.items()
913
+ if re.search(rf"(?<![\w.]){namespace}\.{key}\s*\(", sql, re.IGNORECASE)
914
+ }
915
+ if not kept:
916
+ return f"-- {name} on {dialect}\n{sql}\n"
917
+ request = {
918
+ "root": str(self.project.root),
919
+ "name": name,
920
+ "source": source,
921
+ "dialect": dialect,
922
+ # Every one, as a macro may call another inside, as a file macro's SQL does.
923
+ "modules": sorted({str(macro.path) for macro in static.values()}),
924
+ "macros": sorted(static),
925
+ "not_given": sorted(key for key, value in values.items() if value is None),
926
+ }
927
+ run, problem = _run_live(self.project.root, request)
928
+ if run is not None:
929
+ return f"-- {name} on {dialect}\n{run}\n"
930
+ listed = ", ".join(f"{namespace}.{key}" for key in sorted(kept))
931
+ return f"-- {name} on {dialect}\n-- {listed} stays a call: {problem}\n{sql}\n"
932
+
933
+ def rendered_marked(self, path: Path, source: str) -> str:
934
+ """Return the rendered template with `?` for each parameter, in order.
935
+
936
+ A comment on top lists what each `?` stands for, as a driver that
937
+ takes `?` is given the values in that order.
938
+ """
939
+ text = self.rendered(path, source)
940
+ names: list[str] = []
941
+
942
+ def marked(found: re.Match[str]) -> str:
943
+ name = found.group("bound")
944
+ if name is None:
945
+ return found.group(0)
946
+ names.append(name)
947
+ return "?"
948
+
949
+ text = _BOUND.sub(marked, text)
950
+ if not names:
951
+ return text
952
+ first, _, rest = text.partition("\n")
953
+ listed = ", ".join(f":{name}" for name in names)
954
+ return f"{first}\n-- ? in order: {listed}\n{rest}"
955
+
956
+ def _not_given(
957
+ self, name: str, source: str, calls: Sequence[Any], dialect: str
958
+ ) -> set[str]:
959
+ """Return the parameters to render as not given.
960
+
961
+ They are those of a call of a built-in macro that cannot be made with a
962
+ made-up value, and can with none: `:sort` of `tpl.order_by`, whose value
963
+ must be a column it lists.
964
+ """
965
+ found: set[str] = set()
966
+ for call in calls:
967
+ if isinstance(self.project.templates.macros.get(call.name), StaticMacro):
968
+ continue
969
+ text = source[call.span[0] : call.span[1]]
970
+ try:
971
+ alone = self.compiled(name, text)
972
+ except SQLAKitError:
973
+ continue
974
+ names = alone.parameters()
975
+ given = _rendered(alone, dialect, dict.fromkeys(names, _Example()))
976
+ if names and self._kept(given, call.name):
977
+ missing = _rendered(alone, dialect, dict.fromkeys(names))
978
+ if not self._kept(missing, call.name):
979
+ found.update(names)
980
+ return found
981
+
982
+ def _kept(self, sql: str | None, macro: str) -> bool:
983
+ """Whether a call rendered alone could not be made, and wrote itself.
984
+
985
+ It writes itself on one line, whatever lines it was written on.
986
+ """
987
+ if sql is None:
988
+ return True
989
+ namespace = re.escape(self.project.templates.namespace)
990
+ return bool(
991
+ re.match(rf"{namespace}\.{re.escape(macro)}\s*\(", sql, re.IGNORECASE)
992
+ )
831
993
 
832
994
  def holds_macros(self, path: Path) -> bool:
833
995
  """Whether a file defines macros: SQL macros, or the SQL of a file macro."""
@@ -911,7 +1073,7 @@ class _Assistant:
911
1073
  missing = _rendered(alone, dialect, dict.fromkeys(names))
912
1074
  # A call that cannot be made writes itself, which says nothing new.
913
1075
  given, missing = (
914
- None if one == text.strip() else one for one in (given, missing)
1076
+ None if self._kept(one, call.name) else one for one in (given, missing)
915
1077
  )
916
1078
  if not names or given == missing:
917
1079
  one = given or missing
@@ -2454,21 +2616,30 @@ def _server() -> Any: # noqa: ANN401, C901, PLR0915 - a handler for each reques
2454
2616
  support = None if action is None else action.resolve_support
2455
2617
  return bool(support is not None and "edit" in support.properties)
2456
2618
 
2457
- def rendered_file(ls: LanguageServer, uri: str) -> tuple[Path, str]:
2619
+ def rendered_file(
2620
+ ls: LanguageServer, uri: str, *, marked: bool = False
2621
+ ) -> tuple[Path, str]:
2458
2622
  """Return a new file for the rendered template, and what goes in it."""
2459
2623
  helper = assistant()
2460
2624
  path = _path(uri)
2461
2625
  source = ls.workspace.get_text_document(uri).source
2462
2626
  name = helper.project.name_of(path) or path.name if helper else path.name
2463
- text = helper.rendered(path, source) if helper else ""
2627
+ if helper is None:
2628
+ text = ""
2629
+ elif marked:
2630
+ text = helper.rendered_marked(path, source)
2631
+ else:
2632
+ text = helper.rendered(path, source)
2464
2633
  # A new file each time: the editor may still hold the one before.
2465
2634
  written = _RENDERED / uuid.uuid4().hex[:8] / name
2466
2635
  written.parent.mkdir(parents=True, exist_ok=True)
2467
2636
  return written, text
2468
2637
 
2469
- def rendered_edit(ls: LanguageServer, uri: str) -> types.WorkspaceEdit:
2638
+ def rendered_edit(
2639
+ ls: LanguageServer, uri: str, *, marked: bool = False
2640
+ ) -> types.WorkspaceEdit:
2470
2641
  """Return an edit that makes the file of the rendered template."""
2471
- written, text = rendered_file(ls, uri)
2642
+ written, text = rendered_file(ls, uri, marked=marked)
2472
2643
  target = written.as_uri()
2473
2644
  start = types.Position(0, 0)
2474
2645
  return types.WorkspaceEdit(
@@ -2502,14 +2673,18 @@ def _server() -> Any: # noqa: ANN401, C901, PLR0915 - a handler for each reques
2502
2673
  return actions or None
2503
2674
  if not path.name.endswith(".sql"):
2504
2675
  return actions or None
2505
- action = types.CodeAction(title=SHOW, kind=types.CodeActionKind.Empty)
2506
- if shows_documents(ls):
2507
- action.command = types.Command(title=SHOW, command=RENDER, arguments=[uri])
2508
- elif resolves_edits(ls):
2509
- action.data = {"uri": uri}
2510
- else:
2511
- action.edit = rendered_edit(ls, uri)
2512
- return [*actions, action]
2676
+ for title, marked in ((SHOW, False), (SHOW_MARKED, True)):
2677
+ action = types.CodeAction(title=title, kind=types.CodeActionKind.Empty)
2678
+ if shows_documents(ls):
2679
+ action.command = types.Command(
2680
+ title=title, command=RENDER, arguments=[uri, marked]
2681
+ )
2682
+ elif resolves_edits(ls):
2683
+ action.data = {"uri": uri, "marked": marked}
2684
+ else:
2685
+ action.edit = rendered_edit(ls, uri, marked=marked)
2686
+ actions.append(action)
2687
+ return actions
2513
2688
 
2514
2689
  def fixes(ls: LanguageServer, params: Any) -> list[types.CodeAction]: # noqa: ANN401
2515
2690
  """Return a quick fix for each problem of ours the editor sends along.
@@ -2552,13 +2727,15 @@ def _server() -> Any: # noqa: ANN401, C901, PLR0915 - a handler for each reques
2552
2727
  """Work out the edit of an action once the editor has chosen it."""
2553
2728
  data = action.data or {}
2554
2729
  if "uri" in data:
2555
- action.edit = rendered_edit(ls, data["uri"])
2730
+ action.edit = rendered_edit(
2731
+ ls, data["uri"], marked=bool(data.get("marked"))
2732
+ )
2556
2733
  return action
2557
2734
 
2558
2735
  @server.command(RENDER)
2559
- async def render(ls: LanguageServer, uri: str) -> None:
2736
+ async def render(ls: LanguageServer, uri: str, marked: bool = False) -> None: # noqa: FBT001, FBT002 - a command's arguments come in order
2560
2737
  """Write the rendered template to a file, and have the editor open it."""
2561
- written, text = rendered_file(ls, uri)
2738
+ written, text = rendered_file(ls, uri, marked=marked)
2562
2739
  written.write_text(text, encoding="utf-8")
2563
2740
  await ls.window_show_document_async(
2564
2741
  types.ShowDocumentParams(written.as_uri(), take_focus=True)
File without changes