dbt-ls 0.1.1__py3-none-any.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.
dbt_ls/__init__.py ADDED
@@ -0,0 +1,201 @@
1
+ from pygls.lsp.server import LanguageServer
2
+ import logging
3
+ import os
4
+ import sys
5
+ from lsprotocol import types
6
+ from importlib.metadata import version
7
+ from dbt_ls.pattern import completion_context, ref_model_at
8
+ from dbt_ls.model import (
9
+ discover_models,
10
+ enrich_models_from_database,
11
+ )
12
+ from dbt_ls.source import discover_sources, enrich_sources_from_catalog
13
+ from pathlib import Path
14
+ from dbt_ls.alias import parse_aliases
15
+ from dbt_ls.project import Project
16
+ from dbt_ls.profiles import Profiles
17
+ import debugpy
18
+ import argparse
19
+
20
+ logging.basicConfig(
21
+ stream=sys.stderr,
22
+ level=os.environ.get("DBT_LS_LOG_LEVEL", "DEBUG").upper(),
23
+ force=True, # tear down pygls' root handler and use ours
24
+ )
25
+
26
+ logging.getLogger("pygls").setLevel(logging.WARNING)
27
+ log = logging.getLogger("dbt_ls")
28
+
29
+ __version__ = version("dbt-ls")
30
+
31
+ server = LanguageServer("dbt-ls", __version__)
32
+
33
+
34
+ def find_dbt_project_root(root: str) -> str:
35
+ for p in Path(root).rglob("dbt_project.yml"):
36
+ if "target" not in p.parts:
37
+ return str(p.parent)
38
+ return "."
39
+
40
+
41
+ @server.feature(types.INITIALIZE)
42
+ def on_initialize(params: types.InitializeParams):
43
+ global models
44
+ global sources
45
+ global dbt_root
46
+ global project
47
+
48
+ if params.root_path:
49
+ dbt_root = find_dbt_project_root(params.root_path)
50
+ project = Project(dbt_root)
51
+ catalog_path = Path(f"{dbt_root}/target/catalog.json")
52
+
53
+ profile = Profiles.locate(project.root)
54
+ profile_target = profile.resolve(project.profile) if profile else None
55
+
56
+ models = discover_models(root=params.root_path, model_paths=project.model_paths)
57
+ log.debug("Finished parsing documented models")
58
+ sources = discover_sources(params.root_path)
59
+ log.debug("Finished parsing documented sources")
60
+ sources = enrich_sources_from_catalog(sources, catalog_path)
61
+ log.debug("Finished parsing column info for sources from catalog")
62
+ database_models = enrich_models_from_database(
63
+ models, profile_target, project.root
64
+ )
65
+ if database_models:
66
+ models = database_models
67
+ log.debug("Finished parsing column info for models from database")
68
+
69
+
70
+ @server.feature(
71
+ types.TEXT_DOCUMENT_COMPLETION,
72
+ types.CompletionOptions(trigger_characters=["'", '"', "(", "."]),
73
+ )
74
+ def completions(params: types.CompletionParams):
75
+ document = server.workspace.get_text_document(params.text_document.uri)
76
+ current_line = document.lines[params.position.line].strip()
77
+ pos = params.position
78
+ line = document.lines[pos.line] if pos.line < len(document.lines) else ""
79
+ line_prefix = line[: pos.character]
80
+
81
+ ctx = completion_context(line_prefix)
82
+ if ctx is None:
83
+ log.debug("no pattern matched for %r", current_line, " (early exit)")
84
+ return None
85
+
86
+ log.debug("completion @ %d:%d | line=%r", pos.line, pos.character, current_line)
87
+
88
+ kind, info = ctx
89
+
90
+ if kind == "ref":
91
+ log.info(
92
+ "REF path matched %r → serving %d models: %s",
93
+ current_line,
94
+ len(models),
95
+ [m.name for m in models[:15]],
96
+ )
97
+ return [
98
+ types.CompletionItem(
99
+ m.name,
100
+ kind=types.CompletionItemKind(18),
101
+ label_details=types.CompletionItemLabelDetails(
102
+ m.path.split(dbt_root)[-1]
103
+ ),
104
+ )
105
+ for m in models
106
+ ]
107
+ elif kind == "source_name":
108
+ [log.debug(c) for m in (*models, *sources) for c in m.columns]
109
+ log.info(
110
+ "SOURCE path matched %r → serving %d sources: %s",
111
+ current_line,
112
+ len(sources),
113
+ [s.name for s in sources[:15]],
114
+ )
115
+ return [
116
+ types.CompletionItem(
117
+ s.name,
118
+ kind=types.CompletionItemKind(10),
119
+ label_details=types.CompletionItemLabelDetails(s.database),
120
+ insert_text=f'{s.source_name}", "{s.name}',
121
+ insert_text_format=types.InsertTextFormat.PlainText,
122
+ )
123
+ for s in sources
124
+ ]
125
+ elif kind == "column":
126
+ alias = info["alias"]
127
+ alias_map = parse_aliases(document.source)
128
+ model_name = alias_map.get(alias)
129
+ log.info("COLUMN path: alias=%r → model=%r", alias, model_name)
130
+
131
+ return [
132
+ types.CompletionItem(
133
+ label=c.name,
134
+ kind=types.CompletionItemKind(5),
135
+ label_details=types.CompletionItemLabelDetails(c.data_type),
136
+ )
137
+ for m in (*models, *sources)
138
+ for c in m.columns
139
+ if m.name == model_name
140
+ ]
141
+ else:
142
+ log.debug("no pattern matched for %r", current_line)
143
+ return []
144
+
145
+
146
+ @server.feature(types.TEXT_DOCUMENT_DEFINITION)
147
+ def definition(params: types.DefinitionParams):
148
+ """Jump from a ref('model') to that model's .sql file."""
149
+ document = server.workspace.get_text_document(params.text_document.uri)
150
+ pos = params.position
151
+ line = document.lines[pos.line] if pos.line < len(document.lines) else ""
152
+
153
+ model_name = ref_model_at(line, pos.character)
154
+ if model_name is None:
155
+ return None
156
+
157
+ target = next((m for m in models if m.name == model_name and m.path), None)
158
+ if target is None:
159
+ log.info("DEFINITION: no model file found for %r", model_name)
160
+ return None
161
+
162
+ log.info("DEFINITION: %r → %s", model_name, target.path)
163
+ start = types.Position(line=0, character=0)
164
+ return types.Location(
165
+ uri=Path(target.path).as_uri(),
166
+ range=types.Range(start=start, end=start),
167
+ )
168
+
169
+
170
+ def main():
171
+ banner = f"""
172
+ ╔═══════════════════════════════════════╗
173
+ ║ ║
174
+ ║ _ _ _ _ ║
175
+ ║ __| | |__ | |_ | |___ ║
176
+ ║ / _` | '_ \\| __|____| / __| ║
177
+ ║ | (_| | |_) | ||_____| \\__ \\ ║
178
+ ║ \\__,_|_.__/ \\__| |_|___/ ║
179
+ ║ ║
180
+ ║ {__version__:^5} · Language Server · stdio ║
181
+ ║ ║
182
+ ╚═══════════════════════════════════════╝
183
+ """
184
+ print(banner)
185
+
186
+ p = argparse.ArgumentParser()
187
+ p.add_argument("--tcp", action="store_true")
188
+ p.add_argument("--host", default="127.0.0.1")
189
+ p.add_argument("--port", type=int, default=8765)
190
+ args = p.parse_args()
191
+ if args.tcp:
192
+ debugpy.listen(("127.0.0.1", 5678))
193
+ debugpy.wait_for_client()
194
+ server.start_tcp(args.host, args.port)
195
+ else:
196
+ server.start_io()
197
+ logging.info("DBT Language Server started")
198
+
199
+
200
+ if __name__ == "__main__":
201
+ main()
dbt_ls/alias.py ADDED
@@ -0,0 +1,12 @@
1
+ import re
2
+
3
+
4
+ def parse_aliases(text: str) -> dict[str, str]:
5
+ aliases = {}
6
+ for match in re.finditer(r"""\{{\s*ref\((['"])(\w+)\1\)\s*}}\s+(\w+)""", text):
7
+ aliases[match.group(3)] = match.group(2)
8
+ for match in re.finditer(
9
+ r"""\{{\s*source\((['"])(\w+)\1,\s*(['"])(\w+)\3\)\s*}}\s+(\w+)""", text
10
+ ):
11
+ aliases[match.group(5)] = match.group(4)
12
+ return aliases
dbt_ls/column.py ADDED
@@ -0,0 +1,7 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True)
5
+ class Column:
6
+ name: str
7
+ data_type: str | None = None
dbt_ls/model.py ADDED
@@ -0,0 +1,94 @@
1
+ from dataclasses import dataclass
2
+ from pathlib import Path
3
+ from dbt_ls.column import Column
4
+ import json
5
+ from dbt_ls.profiles import ProfileTarget
6
+ import ibis
7
+ from ibis.expr.schema import Schema
8
+ from ibis.expr.types.relations import (
9
+ Table,
10
+ )
11
+ from dbt_ls.profiles import DuckDBTarget, DatabaseTarget
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Model:
16
+ name: str
17
+ path: str
18
+ columns: tuple[Column, ...] = ()
19
+
20
+
21
+ def discover_models(root: str, model_paths: list[str]) -> list[Model]:
22
+ return [
23
+ Model(name=p.stem, path=str(p))
24
+ for model_path in model_paths
25
+ for p in (Path(root) / model_path).rglob("*.sql")
26
+ ]
27
+
28
+
29
+ def enrich_models_from_catalog(models: list[Model], catalog_path: Path) -> list[Model]:
30
+ path = Path(catalog_path)
31
+ if not path.is_file():
32
+ return models
33
+
34
+ catalog = json.loads(path.read_text())
35
+ nodes = catalog.get("nodes", {})
36
+
37
+ # Build a lookup: model name -> columns
38
+ columns_by_name: dict[str, tuple[Column, ...]] = {}
39
+ for node in nodes.values():
40
+ if not node.get("unique_id", "").startswith("model."):
41
+ continue
42
+ name = node["metadata"]["name"]
43
+ columns_by_name[name] = tuple(
44
+ Column(name=c["name"], data_type=c.get("type"))
45
+ for c in node.get("columns", {}).values()
46
+ )
47
+
48
+ return [
49
+ Model(name=m.name, path=m.path, columns=columns_by_name.get(m.name, m.columns))
50
+ for m in models
51
+ ]
52
+
53
+
54
+ def get_duckdb_models(
55
+ models: list[Model], profile_target: DuckDBTarget, project_root: str | Path
56
+ ) -> list[Model] | None:
57
+ ibis.set_backend("duckdb")
58
+
59
+ connection_path = (
60
+ profile_target.path
61
+ if Path(profile_target.path).is_absolute()
62
+ else Path(project_root).joinpath(profile_target.path)
63
+ )
64
+ con = ibis.duckdb.connect(connection_path)
65
+
66
+ con = ibis.duckdb.connect("myproject/" + profile_target.path)
67
+ columns_by_name: dict[str, tuple[Column, ...]] = {}
68
+
69
+ tables = con.list_tables()
70
+ for t in tables:
71
+ table: Table = con.table(t)
72
+ schema: Schema = table.schema()
73
+
74
+ columns_by_name[t] = tuple(
75
+ Column(name=name, data_type=str(dtype)) for name, dtype in schema.items()
76
+ )
77
+
78
+ return [
79
+ Model(name=m.name, path=m.path, columns=columns_by_name.get(m.name, ()))
80
+ for m in models
81
+ ]
82
+
83
+
84
+ def enrich_models_from_database(
85
+ models: list[Model],
86
+ profile_target: DuckDBTarget | DatabaseTarget,
87
+ project_root: str | Path,
88
+ ) -> list[Model] | None:
89
+ match profile_target:
90
+ case DuckDBTarget():
91
+ return get_duckdb_models(models, profile_target, project_root)
92
+ case _:
93
+ print("here")
94
+ return None
dbt_ls/pattern.py ADDED
@@ -0,0 +1,36 @@
1
+ import re
2
+
3
+
4
+ REF_RE = re.compile(r"""ref\(\s*(?P<q>['"])(?P<model>[^'"]*)$""")
5
+ # Full ref('model') call, used to find the model the cursor is *inside of*.
6
+ REF_FULL_RE = re.compile(r"""ref\(\s*['"](?P<model>[^'"]+)['"]""")
7
+ SOURCE_RE = re.compile(
8
+ r"""source\(\s*(?P<q1>['"])(?P<src>[^'"]*)"""
9
+ r"""(?:(?P=q1)\s*,\s*(?P<q2>['"])(?P<tbl>[^'"]*))?$"""
10
+ )
11
+ COLUMN_RE = re.compile(r"(?P<alias>[a-zA-Z_]\w*)\.(?P<col>[a-zA-Z0-9_]*)$")
12
+
13
+
14
+ def completion_context(line_prefix: str):
15
+ """What is the cursor currently completing? None if not in a ref/source."""
16
+ if m := SOURCE_RE.search(line_prefix):
17
+ # second arg started -> completing the table within a known source
18
+ if m.group("tbl") is not None:
19
+ return ("source_table", {"source": m.group("src")})
20
+ # still in first arg -> completing the source name
21
+ return ("source_name", {})
22
+ if m := REF_RE.search(line_prefix):
23
+ return ("ref", {})
24
+ if m := COLUMN_RE.search(line_prefix):
25
+ return ("column", {"alias": m.group("alias")})
26
+ return None
27
+
28
+
29
+ def ref_model_at(line: str, character: int) -> str | None:
30
+ """
31
+ Check if cursor is on a model
32
+ """
33
+ for m in REF_FULL_RE.finditer(line):
34
+ if m.start("model") <= character <= m.end("model"):
35
+ return m.group("model")
36
+ return None
dbt_ls/profiles.py ADDED
@@ -0,0 +1,74 @@
1
+ from pathlib import Path
2
+ from dataclasses import dataclass, fields
3
+ import yaml
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(kw_only=True)
8
+ class ProfileTarget:
9
+ type: str
10
+ threads: int = 1
11
+
12
+ @classmethod
13
+ def from_dict(cls, data: dict) -> "ProfileTarget":
14
+ target_cls = _TARGET_REGISTRY.get(data.get("type", ""), cls)
15
+ allowed = {f.name for f in fields(target_cls)}
16
+ return target_cls(**{k: v for k, v in data.items() if k in allowed})
17
+
18
+
19
+ @dataclass(kw_only=True)
20
+ class DuckDBTarget(ProfileTarget):
21
+ path: str
22
+
23
+ # def __post_init__(self):
24
+ # if not Path(self.path).is_absolute():
25
+ # self.path = str(Path("root").joinpath(self.path))
26
+
27
+
28
+ @dataclass(kw_only=True)
29
+ class DatabaseTarget(ProfileTarget):
30
+ user: str
31
+ password: str
32
+ host: str
33
+ port: int
34
+ dbname: str
35
+
36
+
37
+ _TARGET_REGISTRY: dict[str, type[ProfileTarget]] = {
38
+ "duckdb": DuckDBTarget,
39
+ "postgres": DatabaseTarget,
40
+ }
41
+
42
+
43
+ class Profiles:
44
+ def __init__(self, path: Path):
45
+ self.path = path
46
+ self.config: dict[str, Any] = yaml.safe_load(self.path.read_text()) or {}
47
+
48
+ @classmethod
49
+ def locate(cls, project_root: str) -> "Profiles | None":
50
+ candidate = cls._search_dirs(project_root)
51
+ if not candidate:
52
+ return None
53
+ if candidate.exists():
54
+ return cls(candidate)
55
+
56
+ return None
57
+
58
+ @staticmethod
59
+ def _search_dirs(project_root: str) -> Path | None:
60
+ profile_paths = [Path(project_root), Path.home().joinpath(".dbt")]
61
+
62
+ for profile_path in profile_paths:
63
+ if Path(profile_path.joinpath("profiles.yml")).exists():
64
+ return Path(profile_path.joinpath("profiles.yml"))
65
+
66
+ return None
67
+
68
+ def resolve(self, profile_name: str, target: str | None = None) -> ProfileTarget:
69
+ if not target:
70
+ target = self.config[profile_name]["target"]
71
+
72
+ profile_target = self.config[profile_name]["outputs"][target]
73
+
74
+ return ProfileTarget.from_dict(profile_target)
dbt_ls/project.py ADDED
@@ -0,0 +1,21 @@
1
+ import yaml
2
+ import os
3
+
4
+
5
+ class Project:
6
+ def __init__(self, root: str = "."):
7
+ self.root = root
8
+ self.config = self._load_config()
9
+
10
+ def _load_config(self) -> dict:
11
+ path = os.path.join(self.root, "dbt_project.yml")
12
+ with open(path) as f:
13
+ return yaml.safe_load(f) or {}
14
+
15
+ @property
16
+ def model_paths(self) -> list[str]:
17
+ return self.config.get("model-paths", [])
18
+
19
+ @property
20
+ def profile(self) -> str:
21
+ return self.config.get("profile", "")
dbt_ls/source.py ADDED
@@ -0,0 +1,71 @@
1
+ from dataclasses import dataclass
2
+ import yaml
3
+ from pathlib import Path
4
+ import json
5
+
6
+ from dbt_ls.column import Column
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class SourceTable:
11
+ name: str
12
+ source_name: str
13
+ database: str | None = None
14
+ schema: str | None = None
15
+ columns: tuple[Column, ...] = ()
16
+
17
+
18
+ def discover_sources(root: str) -> list:
19
+ sources = []
20
+ for p in Path(root).rglob("*.yml"):
21
+ if "target" in p.parts or not p.is_file():
22
+ continue
23
+ doc = yaml.safe_load(p.read_text())
24
+ if not doc or "sources" not in doc:
25
+ continue
26
+ for src in doc["sources"]:
27
+ source_name = src.get("name", "")
28
+ for table in src.get("tables", []):
29
+ sources.append(
30
+ SourceTable(
31
+ name=table["name"],
32
+ source_name=source_name,
33
+ database=src.get("database"),
34
+ schema=src.get("schema"),
35
+ columns=tuple(
36
+ [
37
+ Column(name=c["name"], data_type=c.get("data_type"))
38
+ for c in table.get("columns", [])
39
+ ]
40
+ ),
41
+ )
42
+ )
43
+ return sources
44
+
45
+
46
+ def enrich_sources_from_catalog(
47
+ sources: list[SourceTable], catalog_path: Path
48
+ ) -> list[SourceTable]:
49
+ path = Path(catalog_path)
50
+ if not path.is_file():
51
+ return sources
52
+ catalog = json.loads(path.read_text())
53
+ catalog_sources = catalog.get("sources", {})
54
+ # Build a lookup: source name -> columns
55
+ columns_by_name: dict[str, tuple[Column, ...]] = {}
56
+ for source in catalog_sources.values():
57
+ if not source.get("unique_id", "").startswith("source."):
58
+ continue
59
+ name = source["metadata"]["name"]
60
+ columns_by_name[name] = tuple(
61
+ Column(name=c["name"], data_type=c.get("type"))
62
+ for c in source.get("columns", {}).values()
63
+ )
64
+ return [
65
+ SourceTable(
66
+ name=s.name,
67
+ source_name=s.source_name,
68
+ columns=columns_by_name.get(s.name) or s.columns,
69
+ )
70
+ for s in sources
71
+ ]
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: dbt-ls
3
+ Version: 0.1.1
4
+ Summary: Add your description here
5
+ Author-email: HuhtaLauri <huhta.lauri@gmail.com>
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: black>=25.12.0
8
+ Requires-Dist: dbt-core>1.5.1
9
+ Requires-Dist: dbt-duckdb>=1.10.1
10
+ Requires-Dist: hatch>=1.16.5
11
+ Requires-Dist: ibis-framework[duckdb]>=12.0.0
12
+ Requires-Dist: pygls>=2.1.1
13
+ Requires-Dist: pytest>=9.0.3
14
+ Requires-Dist: pyyaml>=6.0.3
15
+ Description-Content-Type: text/markdown
16
+
17
+ # DBT-Language-Server
18
+
19
+
20
+ Prompt a hover tooltip what columns model returns
@@ -0,0 +1,12 @@
1
+ dbt_ls/__init__.py,sha256=1PtsUfZKT5b7zko-etaPqum9HZVLDtv9MzgxU5_WDBA,6874
2
+ dbt_ls/alias.py,sha256=jDDM2wmU47M76E49cuiURKZ7NIyIUJ-vX1xQePnmrMs,396
3
+ dbt_ls/column.py,sha256=Pf_uZ5PZfLUc3TNCdbVbcdYcVa_UJjmbuF2fmoXQeM0,121
4
+ dbt_ls/model.py,sha256=BZOrcZye8mEwT3doeRnKF0YJUcbLwpsiTFNDtj_yQBo,2690
5
+ dbt_ls/pattern.py,sha256=-Wr82H_O0bNRTv6CFGm45LeCF_uc842l8Xmx5MiiJiA,1316
6
+ dbt_ls/profiles.py,sha256=ivRNRctDaIlGU2YhdPF-oUZwBzXJzwQooJKkcEbjR00,2008
7
+ dbt_ls/project.py,sha256=p0hGVAUjjzArBkbWgr23irFDuOHDO9wgUEEWf5-JMB0,511
8
+ dbt_ls/source.py,sha256=nVoJdRJ3GJ6dyk9qPbt0gBH6Y4DfaKxFdkONkmAcWEU,2226
9
+ dbt_ls-0.1.1.dist-info/METADATA,sha256=harh3ZezkOd-05TozPPePGilsigg9waf75NdFsq9CIo,528
10
+ dbt_ls-0.1.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
11
+ dbt_ls-0.1.1.dist-info/entry_points.txt,sha256=18sOzg1GYOzOESCOT1JDK6HDd4iqMGffxR5WnvzdM2s,39
12
+ dbt_ls-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dbt-ls = dbt_ls:main