dbt-ls 0.1.1__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.
Files changed (54) hide show
  1. dbt_ls-0.1.1/.github/workflows/ci.yml +64 -0
  2. dbt_ls-0.1.1/.gitignore +12 -0
  3. dbt_ls-0.1.1/.python-version +1 -0
  4. dbt_ls-0.1.1/PKG-INFO +20 -0
  5. dbt_ls-0.1.1/README.md +4 -0
  6. dbt_ls-0.1.1/dev.duckdb +0 -0
  7. dbt_ls-0.1.1/logs/dbt.log +16 -0
  8. dbt_ls-0.1.1/pyproject.toml +29 -0
  9. dbt_ls-0.1.1/src/dbt_ls/__init__.py +201 -0
  10. dbt_ls-0.1.1/src/dbt_ls/alias.py +12 -0
  11. dbt_ls-0.1.1/src/dbt_ls/column.py +7 -0
  12. dbt_ls-0.1.1/src/dbt_ls/model.py +94 -0
  13. dbt_ls-0.1.1/src/dbt_ls/pattern.py +36 -0
  14. dbt_ls-0.1.1/src/dbt_ls/profiles.py +74 -0
  15. dbt_ls-0.1.1/src/dbt_ls/project.py +21 -0
  16. dbt_ls-0.1.1/src/dbt_ls/source.py +71 -0
  17. dbt_ls-0.1.1/src/foo.py +74 -0
  18. dbt_ls-0.1.1/test/__init__.py +0 -0
  19. dbt_ls-0.1.1/test/test_alias.py +19 -0
  20. dbt_ls-0.1.1/test/test_definition.py +82 -0
  21. dbt_ls-0.1.1/test/test_model.py +15 -0
  22. dbt_ls-0.1.1/test/test_pattern.py +55 -0
  23. dbt_ls-0.1.1/testdata/project/.gitignore +4 -0
  24. dbt_ls-0.1.1/testdata/project/README.md +15 -0
  25. dbt_ls-0.1.1/testdata/project/analyses/.gitkeep +0 -0
  26. dbt_ls-0.1.1/testdata/project/dbt_project.yml +36 -0
  27. dbt_ls-0.1.1/testdata/project/dev.duckdb +0 -0
  28. dbt_ls-0.1.1/testdata/project/logs/dbt.log +1137 -0
  29. dbt_ls-0.1.1/testdata/project/macros/.gitkeep +0 -0
  30. dbt_ls-0.1.1/testdata/project/models/example/my_first_dbt_model.sql +27 -0
  31. dbt_ls-0.1.1/testdata/project/models/example/my_second_dbt_model.sql +6 -0
  32. dbt_ls-0.1.1/testdata/project/models/example/schema.yml +34 -0
  33. dbt_ls-0.1.1/testdata/project/seeds/.gitkeep +0 -0
  34. dbt_ls-0.1.1/testdata/project/snapshots/.gitkeep +0 -0
  35. dbt_ls-0.1.1/testdata/project/target/catalog.json +1 -0
  36. dbt_ls-0.1.1/testdata/project/target/compiled/project/models/example/my_first_dbt_model.sql +26 -0
  37. dbt_ls-0.1.1/testdata/project/target/compiled/project/models/example/my_second_dbt_model.sql +5 -0
  38. dbt_ls-0.1.1/testdata/project/target/compiled/project/models/example/schema.yml/not_null_my_first_dbt_model_id.sql +11 -0
  39. dbt_ls-0.1.1/testdata/project/target/compiled/project/models/example/schema.yml/not_null_my_second_dbt_model_id.sql +11 -0
  40. dbt_ls-0.1.1/testdata/project/target/compiled/project/models/example/schema.yml/unique_my_first_dbt_model_id.sql +14 -0
  41. dbt_ls-0.1.1/testdata/project/target/compiled/project/models/example/schema.yml/unique_my_second_dbt_model_id.sql +14 -0
  42. dbt_ls-0.1.1/testdata/project/target/graph.gpickle +0 -0
  43. dbt_ls-0.1.1/testdata/project/target/graph_summary.json +1 -0
  44. dbt_ls-0.1.1/testdata/project/target/index.html +3 -0
  45. dbt_ls-0.1.1/testdata/project/target/manifest.json +1 -0
  46. dbt_ls-0.1.1/testdata/project/target/partial_parse.msgpack +0 -0
  47. dbt_ls-0.1.1/testdata/project/target/run/project/models/example/my_first_dbt_model.sql +38 -0
  48. dbt_ls-0.1.1/testdata/project/target/run/project/models/example/my_second_dbt_model.sql +9 -0
  49. dbt_ls-0.1.1/testdata/project/target/run/project/models/example/schema.yml/not_null_my_first_dbt_model_id.sql +25 -0
  50. dbt_ls-0.1.1/testdata/project/target/run/project/models/example/schema.yml/unique_my_first_dbt_model_id.sql +28 -0
  51. dbt_ls-0.1.1/testdata/project/target/run_results.json +1 -0
  52. dbt_ls-0.1.1/testdata/project/target/semantic_manifest.json +1 -0
  53. dbt_ls-0.1.1/testdata/project/tests/.gitkeep +0 -0
  54. dbt_ls-0.1.1/uv.lock +2645 -0
@@ -0,0 +1,64 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - '0.*'
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+
12
+ steps:
13
+ - name: Checkout
14
+ uses: actions/checkout@v4
15
+
16
+ - name: Set up Python
17
+ uses: actions/setup-python@v5
18
+ with:
19
+ python-version: "3.10"
20
+
21
+ - name: Install project
22
+ run: pip install -e .
23
+
24
+ - name: Run tests
25
+ run: pytest -vv
26
+
27
+ build:
28
+ # Runs only when a version tag is pushed.
29
+ if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags/')
30
+ needs:
31
+ - test
32
+ runs-on: ubuntu-latest
33
+
34
+ steps:
35
+ - name: Checkout
36
+ uses: actions/checkout@v4
37
+
38
+ - name: Set up Python
39
+ uses: actions/setup-python@v5
40
+ with:
41
+ python-version: "3.10"
42
+
43
+ - name: Install Hatch
44
+ run: pip install hatch
45
+
46
+ - name: Verify tag matches project version
47
+ run: |
48
+ VERSION="$(hatch version)"
49
+ TAG="${GITHUB_REF_NAME#v}"
50
+ echo "Project version: $VERSION"
51
+ echo "Tag version: $TAG"
52
+ if [ "$VERSION" != "$TAG" ]; then
53
+ echo "::error::Tag '$GITHUB_REF_NAME' does not match project version '$VERSION'."
54
+ exit 1
55
+ fi
56
+
57
+ - name: Build
58
+ run: hatch build
59
+
60
+ - name: Upload build artifacts
61
+ uses: actions/upload-artifact@v4
62
+ with:
63
+ name: dist
64
+ path: dist/
@@ -0,0 +1,12 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ .act
@@ -0,0 +1 @@
1
+ 3.10
dbt_ls-0.1.1/PKG-INFO ADDED
@@ -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
dbt_ls-0.1.1/README.md ADDED
@@ -0,0 +1,4 @@
1
+ # DBT-Language-Server
2
+
3
+
4
+ Prompt a hover tooltip what columns model returns
Binary file
@@ -0,0 +1,16 @@
1
+ 19:25:59.543661 [debug] [MainThread]: Sending event: {'category': 'dbt', 'action': 'invocation', 'label': 'start', 'context': [<snowplow_tracker.self_describing_json.SelfDescribingJson object at 0x79235221d120>, <snowplow_tracker.self_describing_json.SelfDescribingJson object at 0x79235119a620>, <snowplow_tracker.self_describing_json.SelfDescribingJson object at 0x7923511992d0>]}
2
+
3
+
4
+ ============================== 19:25:59.550899 | 18c50a94-b7cb-46ec-a9b7-147a9d5c7e74 ==============================
5
+ 19:25:59.550899 [info ] [MainThread]: Running with dbt=1.11.11
6
+ 19:25:59.551726 [debug] [MainThread]: running dbt with arguments {'warn_error': 'None', 'no_print': 'None', 'debug': 'False', 'quiet': 'False', 'warn_error_options': 'WarnErrorOptionsV2(error=[], warn=[], silence=[])', 'log_format': 'default', 'indirect_selection': 'eager', 'empty': 'None', 'partial_parse': 'True', 'fail_fast': 'False', 'static_parser': 'True', 'invocation_command': 'dbt parse', 'target_path': 'None', 'log_path': 'logs', 'send_anonymous_usage_stats': 'True', 'introspect': 'True', 'printer_width': '80', 'version_check': 'True', 'log_cache_events': 'False', 'cache_selected_only': 'False', 'write_json': 'True', 'use_experimental_parser': 'False', 'profiles_dir': '/home/lauri/.dbt', 'use_colors': 'True'}
7
+ 19:25:59.552484 [error] [MainThread]: Encountered an error:
8
+ Runtime Error
9
+ No dbt_project.yml found at expected path /home/lauri/repos/dbt-ls/dbt_project.yml
10
+ Verify that each entry within packages.yml (and their transitive dependencies) contains a file named dbt_project.yml
11
+
12
+ 19:25:59.554119 [debug] [MainThread]: Resource report: {"command_name": "parse", "command_success": false, "command_wall_clock_time": 0.09467357, "process_in_blocks": "368", "process_kernel_time": 0.099586, "process_mem_max_rss": "98856", "process_out_blocks": "8", "process_user_time": 1.581434}
13
+ 19:25:59.554834 [debug] [MainThread]: Command `dbt parse` failed at 19:25:59.554691 after 0.10 seconds
14
+ 19:25:59.555425 [debug] [MainThread]: Sending event: {'category': 'dbt', 'action': 'invocation', 'label': 'end', 'context': [<snowplow_tracker.self_describing_json.SelfDescribingJson object at 0x79235221d120>, <snowplow_tracker.self_describing_json.SelfDescribingJson object at 0x792351198070>, <snowplow_tracker.self_describing_json.SelfDescribingJson object at 0x7923511992d0>]}
15
+ 19:25:59.555973 [debug] [MainThread]: Flushing usage events
16
+ 19:26:00.489373 [debug] [MainThread]: An error was encountered while trying to flush usage events
@@ -0,0 +1,29 @@
1
+ [project]
2
+ name = "dbt-ls"
3
+ version = "0.1.1"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "HuhtaLauri", email = "huhta.lauri@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "black>=25.12.0",
12
+ "dbt-core>1.5.1",
13
+ "dbt-duckdb>=1.10.1",
14
+ "hatch>=1.16.5",
15
+ "ibis-framework[duckdb]>=12.0.0",
16
+ "pygls>=2.1.1",
17
+ "pytest>=9.0.3",
18
+ "pyyaml>=6.0.3",
19
+ ]
20
+
21
+ [project.scripts]
22
+ dbt-ls = "dbt_ls:main"
23
+
24
+ [build-system]
25
+ requires = ["hatchling"]
26
+ build-backend = "hatchling.build"
27
+
28
+ [tool.pytest.ini_options]
29
+ testpaths = ["test"]
@@ -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()
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
@@ -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", "")
@@ -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
+ ]