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.
- dbt_ls-0.1.1/.github/workflows/ci.yml +64 -0
- dbt_ls-0.1.1/.gitignore +12 -0
- dbt_ls-0.1.1/.python-version +1 -0
- dbt_ls-0.1.1/PKG-INFO +20 -0
- dbt_ls-0.1.1/README.md +4 -0
- dbt_ls-0.1.1/dev.duckdb +0 -0
- dbt_ls-0.1.1/logs/dbt.log +16 -0
- dbt_ls-0.1.1/pyproject.toml +29 -0
- dbt_ls-0.1.1/src/dbt_ls/__init__.py +201 -0
- dbt_ls-0.1.1/src/dbt_ls/alias.py +12 -0
- dbt_ls-0.1.1/src/dbt_ls/column.py +7 -0
- dbt_ls-0.1.1/src/dbt_ls/model.py +94 -0
- dbt_ls-0.1.1/src/dbt_ls/pattern.py +36 -0
- dbt_ls-0.1.1/src/dbt_ls/profiles.py +74 -0
- dbt_ls-0.1.1/src/dbt_ls/project.py +21 -0
- dbt_ls-0.1.1/src/dbt_ls/source.py +71 -0
- dbt_ls-0.1.1/src/foo.py +74 -0
- dbt_ls-0.1.1/test/__init__.py +0 -0
- dbt_ls-0.1.1/test/test_alias.py +19 -0
- dbt_ls-0.1.1/test/test_definition.py +82 -0
- dbt_ls-0.1.1/test/test_model.py +15 -0
- dbt_ls-0.1.1/test/test_pattern.py +55 -0
- dbt_ls-0.1.1/testdata/project/.gitignore +4 -0
- dbt_ls-0.1.1/testdata/project/README.md +15 -0
- dbt_ls-0.1.1/testdata/project/analyses/.gitkeep +0 -0
- dbt_ls-0.1.1/testdata/project/dbt_project.yml +36 -0
- dbt_ls-0.1.1/testdata/project/dev.duckdb +0 -0
- dbt_ls-0.1.1/testdata/project/logs/dbt.log +1137 -0
- dbt_ls-0.1.1/testdata/project/macros/.gitkeep +0 -0
- dbt_ls-0.1.1/testdata/project/models/example/my_first_dbt_model.sql +27 -0
- dbt_ls-0.1.1/testdata/project/models/example/my_second_dbt_model.sql +6 -0
- dbt_ls-0.1.1/testdata/project/models/example/schema.yml +34 -0
- dbt_ls-0.1.1/testdata/project/seeds/.gitkeep +0 -0
- dbt_ls-0.1.1/testdata/project/snapshots/.gitkeep +0 -0
- dbt_ls-0.1.1/testdata/project/target/catalog.json +1 -0
- dbt_ls-0.1.1/testdata/project/target/compiled/project/models/example/my_first_dbt_model.sql +26 -0
- dbt_ls-0.1.1/testdata/project/target/compiled/project/models/example/my_second_dbt_model.sql +5 -0
- dbt_ls-0.1.1/testdata/project/target/compiled/project/models/example/schema.yml/not_null_my_first_dbt_model_id.sql +11 -0
- dbt_ls-0.1.1/testdata/project/target/compiled/project/models/example/schema.yml/not_null_my_second_dbt_model_id.sql +11 -0
- dbt_ls-0.1.1/testdata/project/target/compiled/project/models/example/schema.yml/unique_my_first_dbt_model_id.sql +14 -0
- dbt_ls-0.1.1/testdata/project/target/compiled/project/models/example/schema.yml/unique_my_second_dbt_model_id.sql +14 -0
- dbt_ls-0.1.1/testdata/project/target/graph.gpickle +0 -0
- dbt_ls-0.1.1/testdata/project/target/graph_summary.json +1 -0
- dbt_ls-0.1.1/testdata/project/target/index.html +3 -0
- dbt_ls-0.1.1/testdata/project/target/manifest.json +1 -0
- dbt_ls-0.1.1/testdata/project/target/partial_parse.msgpack +0 -0
- dbt_ls-0.1.1/testdata/project/target/run/project/models/example/my_first_dbt_model.sql +38 -0
- dbt_ls-0.1.1/testdata/project/target/run/project/models/example/my_second_dbt_model.sql +9 -0
- dbt_ls-0.1.1/testdata/project/target/run/project/models/example/schema.yml/not_null_my_first_dbt_model_id.sql +25 -0
- dbt_ls-0.1.1/testdata/project/target/run/project/models/example/schema.yml/unique_my_first_dbt_model_id.sql +28 -0
- dbt_ls-0.1.1/testdata/project/target/run_results.json +1 -0
- dbt_ls-0.1.1/testdata/project/target/semantic_manifest.json +1 -0
- dbt_ls-0.1.1/testdata/project/tests/.gitkeep +0 -0
- 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/
|
dbt_ls-0.1.1/.gitignore
ADDED
|
@@ -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
dbt_ls-0.1.1/dev.duckdb
ADDED
|
Binary file
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[0m19: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
|
+
[0m19:25:59.550899 [info ] [MainThread]: Running with dbt=1.11.11
|
|
6
|
+
[0m19: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
|
+
[0m19: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
|
+
[0m19: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
|
+
[0m19:25:59.554834 [debug] [MainThread]: Command `dbt parse` failed at 19:25:59.554691 after 0.10 seconds
|
|
14
|
+
[0m19: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
|
+
[0m19:25:59.555973 [debug] [MainThread]: Flushing usage events
|
|
16
|
+
[0m19: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,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
|
+
]
|