Skip to content

Commit d12f4dc

Browse files
ziltoziltoskrawcz
authored
Adds VSCode extension and LSP package (#992)
This adds the the `sf-hamilton-lsp` package which powers the Hamilton VSCode extension. Right now the extension has limitations: - most decorators don't resolve, i.e. code with `@config` does not work. - it's better than it was, but it still has much further to go. The [Hamilton VSCode extension](https://marketplace.visualstudio.com/items?itemName=DAGWorks.hamilton-vsc) is available through the VSCode marketplace. It is powered by the `sf-hamilton-lsp` [published on PyPi](https://pypi.org/project/sf-hamilton-lsp/) and aliases to `sf-hamilton[lsp]`. TODOs that this commit doesn't fulfill: - add tests for LSP - better communicate to the user when the viz should be reloaded - better communicate when Python dependencies are missing - add automated documentation for LSP code (i.e., a "Reference" section) - unify the documentation and the extension walkthrough feature Squashed commits: * packaged LSP * migrated repo from zilto/vscode-hamilton * renamed package to avoid conflict with existing extension * updated README links * released minor patch * fixed completion suggestions * added project urls * removed invalid PyPi classifier * improved installation process * add sf-hamilton[lsp] installation target * added docs * removed notebooks from supported types * PR updates * Sets up LSP code for a unit test Currently it's not implemented. * Adds dummy test --------- Co-authored-by: zilto <tjean@DESKTOP-V6JDCS2> Co-authored-by: Stefan Krawczyk <stefan@dagworks.io>
1 parent 929e4fd commit d12f4dc

50 files changed

Lines changed: 10030 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/hamilton-lsp.yml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
name: LSP Test Workflow
2+
3+
on:
4+
push:
5+
branches:
6+
- main # or any specific branches you want to include
7+
paths:
8+
- 'dev_tools/language_server/**'
9+
10+
pull_request:
11+
paths:
12+
- 'dev_tools/language_server/**'
13+
14+
15+
jobs:
16+
lsp-unit-test:
17+
runs-on: ubuntu-latest
18+
strategy:
19+
matrix:
20+
python-version: ['3.9', '3.10', '3.11']
21+
defaults:
22+
run:
23+
working-directory: dev_tools/language_server
24+
steps:
25+
- uses: actions/checkout@v3
26+
- name: Set up Python ${{ matrix.python-version }}
27+
uses: actions/setup-python@v4
28+
with:
29+
python-version: ${{ matrix.python-version }}
30+
- name: Install dependencies
31+
run: |
32+
python -m pip install --upgrade pip pytest
33+
pip install -e .
34+
- name: Run unit tests
35+
run: |
36+
pytest tests/
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Hamilton Language Server (`hamilton_lsp`)
2+
3+
This is an implementation of the [Language Server Protocol](https://microsoft.github.io/language-server-protocol/) to provide a rich IDE experience when creating Hamilton dataflows.
4+
5+
It currently powers the Hamilton VSCode extension and could be integrated into other IDEs.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__version__ = "0.1.0"
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from hamilton_lsp.server import HamiltonLanguageServer, register_server_features
2+
3+
4+
# TODO use argparse to allow
5+
# - io, tcp, websocket modes
6+
# - select host and port
7+
def main():
8+
language_server = HamiltonLanguageServer()
9+
language_server = register_server_features(language_server)
10+
11+
language_server.start_io()
12+
# tcp is good for debugging
13+
# server.start_tcp("127.0.0.1", 8087)
14+
15+
16+
if __name__ == "__main__":
17+
main()
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import inspect
2+
import re
3+
from typing import Type
4+
5+
from hamilton_lsp import __version__
6+
from lsprotocol.types import (
7+
TEXT_DOCUMENT_COMPLETION,
8+
TEXT_DOCUMENT_DID_CHANGE,
9+
TEXT_DOCUMENT_DID_OPEN,
10+
TEXT_DOCUMENT_DOCUMENT_SYMBOL,
11+
CompletionItem,
12+
CompletionItemKind,
13+
CompletionItemLabelDetails,
14+
CompletionList,
15+
CompletionParams,
16+
DidChangeTextDocumentParams,
17+
DidOpenTextDocumentParams,
18+
DocumentSymbolParams,
19+
Location,
20+
Position,
21+
Range,
22+
SymbolInformation,
23+
SymbolKind,
24+
VersionedTextDocumentIdentifier,
25+
)
26+
from pygls.server import LanguageServer
27+
28+
from hamilton import ad_hoc_utils
29+
from hamilton.graph import FunctionGraph, create_graphviz_graph
30+
from hamilton.graph_types import HamiltonGraph
31+
32+
33+
def _type_to_string(type_: Type):
34+
"""Return the full path of type, but may not be accessible from document
35+
For example, `pandas.core.series.Series` while document defines `pandas as pd`
36+
"""
37+
if type_.__module__ == "builtins":
38+
type_string = str(type_.__name__)
39+
else:
40+
type_string = f"{str(type_.__module__)}.{str(type_.__name__)}"
41+
42+
return type_string
43+
44+
45+
def _parse_function_tokens(source: str) -> dict[str, str]:
46+
"""Get a more precise type definition"""
47+
# re.DOTALL allows for multiline definition
48+
FUNCTION_PATTERN = re.compile(r"def\s+(\w+)\((.*?)\)\s*->\s*([^\n:]+)", re.DOTALL)
49+
50+
# {function_name: type}
51+
results = {}
52+
for matching in FUNCTION_PATTERN.finditer(source):
53+
function_name = matching.group(1)
54+
return_type = matching.group(3)
55+
results[function_name] = return_type
56+
57+
argument_string = matching.group(2)
58+
if argument_string:
59+
for arg_with_type in argument_string.split(","):
60+
arg, _, arg_type = arg_with_type.strip().partition(":")
61+
arg_type, _, _ = arg_type.partition("=")
62+
results[arg.strip()] = arg_type.strip()
63+
64+
return results
65+
66+
67+
class HamiltonLanguageServer(LanguageServer):
68+
CMD_VIEW_REQUEST = "lsp-view-request"
69+
CMD_VIEW_RESPONSE = "lsp-view-response"
70+
71+
def __init__(self, server: str = "HamiltonServer", version: str = __version__, loop=None):
72+
super().__init__(server, version, loop=loop, max_workers=2)
73+
74+
self.active_uri: str = ""
75+
self.active_version: str = ""
76+
self.orientation = "LR"
77+
self.node_locations = {}
78+
self.fn_graph = FunctionGraph({}, {})
79+
self.h_graph = HamiltonGraph.from_graph(self.fn_graph)
80+
81+
# def get_range(self, node):
82+
# FUNCTION = re.compile(r"^fn ([a-z]\w+)\(")
83+
# ARGUMENT = re.compile(r"(?P<name>\w+): (?P<type>\w+)")
84+
85+
# origin = node.originating_functions[0]
86+
# name = node.name
87+
# # get node type icon (function, inputs, config, materializers)
88+
# # get location
89+
# lines, linenum = inspect.getsourcelines(origin)
90+
91+
# for incr, line in enumerate(lines):
92+
# if (match := FUNCTION.match(line)) is not None:
93+
# symbol_name = match.group(1)
94+
# if name in symbol_name:
95+
# start_char = match.start() + line.find(name)
96+
# return Range(
97+
# start=Position(line=linenum+incr, character=start_char),
98+
# end=Position(line=linenum+incr, character=start_char + len(name)),
99+
# )
100+
101+
102+
def register_server_features(ls: HamiltonLanguageServer) -> HamiltonLanguageServer:
103+
@ls.feature(TEXT_DOCUMENT_DID_CHANGE)
104+
def did_change(server: HamiltonLanguageServer, params: DidChangeTextDocumentParams):
105+
"""try to build the dataflow and cache it on the server by creating
106+
a temporary module from the document's source code
107+
"""
108+
uri = params.text_document.uri
109+
document = server.workspace.get_document(uri)
110+
server.active_uri = uri
111+
112+
try:
113+
config = {}
114+
module = ad_hoc_utils.module_from_source(document.source)
115+
fn_graph = FunctionGraph.from_modules(module, config=config)
116+
h_graph = HamiltonGraph.from_graph(fn_graph)
117+
# store the updated HamiltonGraph on server state
118+
server.fn_graph = fn_graph
119+
server.h_graph = h_graph
120+
except BaseException:
121+
pass
122+
123+
# refresh the visualization if new graph version
124+
if server.active_version != server.h_graph.version:
125+
server.active_version = server.h_graph.version
126+
hamilton_view(server, [{}])
127+
128+
@ls.feature(TEXT_DOCUMENT_DID_OPEN)
129+
def did_open(server: HamiltonLanguageServer, params: DidOpenTextDocumentParams):
130+
"""trigger the did_change() event"""
131+
did_change(
132+
server,
133+
DidChangeTextDocumentParams(
134+
text_document=VersionedTextDocumentIdentifier(
135+
version=0,
136+
uri=params.text_document.uri,
137+
),
138+
content_changes=[],
139+
),
140+
)
141+
142+
@ls.feature(TEXT_DOCUMENT_COMPLETION) # , CompletionOptions(trigger_characters=["(", ","]))
143+
def on_completion(server: HamiltonLanguageServer, params: CompletionParams) -> CompletionList:
144+
"""Return completion items based on the cached dataflow nodes name and type."""
145+
uri = params.text_document.uri
146+
document = server.workspace.get_document(uri)
147+
148+
tokens = _parse_function_tokens(document.source)
149+
150+
# could be refactored to a single loop, but this logic might be reused elsewhere
151+
local_node_types = {}
152+
for node in server.h_graph.nodes:
153+
origin = node.originating_functions[0]
154+
155+
origin_name = getattr(origin, "__original_name__", origin.__name__)
156+
type_ = tokens.get(origin_name, _type_to_string(node.type))
157+
local_node_types[node.name] = type_
158+
159+
return CompletionList(
160+
is_incomplete=False,
161+
items=[
162+
CompletionItem(
163+
label=node.name,
164+
label_details=CompletionItemLabelDetails(
165+
detail=f" {local_node_types[node.name]}",
166+
description="Node",
167+
),
168+
kind=CompletionItemKind(3), # 3 is the enum for `Function` kind
169+
documentation=node.documentation,
170+
insert_text=f"{node.name}: {local_node_types[node.name]}",
171+
)
172+
for node in server.h_graph.nodes
173+
],
174+
)
175+
176+
@ls.feature(TEXT_DOCUMENT_DOCUMENT_SYMBOL)
177+
def document_symbols(
178+
server: HamiltonLanguageServer, params: DocumentSymbolParams
179+
) -> list[SymbolInformation]:
180+
symbols = []
181+
182+
for node in server.h_graph.nodes:
183+
origin = node.originating_functions[0]
184+
name = node.name
185+
# get node type icon (function, inputs, config, materializers)
186+
node_kind = SymbolKind.Function
187+
if node.is_external_input:
188+
node_kind = SymbolKind.Field
189+
190+
# get location
191+
_, starting_line = inspect.getsourcelines(origin)
192+
loc = Location(
193+
uri=params.text_document.uri,
194+
range=Range(
195+
start=Position(line=starting_line - 1, character=0),
196+
end=Position(line=starting_line, character=0),
197+
),
198+
)
199+
server.node_locations[name] = loc
200+
201+
# create symbol
202+
symbol = SymbolInformation(
203+
name=name,
204+
kind=node_kind,
205+
location=loc,
206+
container_name="Hamilton",
207+
)
208+
symbols.append(symbol)
209+
210+
return symbols
211+
212+
# @ls.feature(TEXT_DOCUMENT_REFERENCES)
213+
# def find_references(
214+
# server: HamiltonLanguageServer,
215+
# params: ReferenceParams
216+
# ) -> list[Location]:
217+
# doc = ls.workspace.get_text_document(params.text_document.uri)
218+
219+
# input_position = params.position
220+
# if not server.node_locations:
221+
# server.send_notification(TEXT_DOCUMENT_DOCUMENT_SYMBOL, DocumentSymbolParams(params.text_document))
222+
223+
# word = doc.word_at_position(input_position)
224+
# server.show_message_log(f"{word}")
225+
226+
# # server.show_message_log(input_node)
227+
# depend_on_input = []
228+
# for node in server.h_graph.nodes:
229+
# dependencies = [*node.optional_dependencies, *node.required_dependencies]
230+
# for dep in dependencies:
231+
# if word != dep:
232+
# continue
233+
# depend_on_input.append(node.name)
234+
235+
# return [server.node_locations[name] for name in depend_on_input]
236+
237+
@ls.thread()
238+
@ls.command(HamiltonLanguageServer.CMD_VIEW_REQUEST)
239+
def hamilton_view(server: HamiltonLanguageServer, args: list[dict]):
240+
"""View the cached dataflow and send the graphviz string to the extension host."""
241+
params = args[0]
242+
243+
if params.get("rotate"):
244+
if server.orientation == "LR":
245+
server.orientation = "TB"
246+
else:
247+
server.orientation = "LR"
248+
249+
dot = create_graphviz_graph(
250+
nodes=set(server.fn_graph.get_nodes()),
251+
comment="vscode-dataflow",
252+
node_modifiers=dict(),
253+
strictly_display_only_nodes_passed_in=True,
254+
graphviz_kwargs=dict(
255+
graph_attr=dict(bgcolor="transparent"),
256+
edge_attr=dict(color="white"),
257+
),
258+
orient=server.orientation,
259+
config={},
260+
)
261+
262+
server.send_notification(
263+
HamiltonLanguageServer.CMD_VIEW_RESPONSE, dict(uri=server.active_uri, dot=dot.source)
264+
)
265+
266+
return ls
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
[build-system]
2+
requires = ["setuptools >= 65.0.0"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
name = "sf-hamilton-lsp"
7+
description = "Hamilton Language Server powering IDE features."
8+
authors = [
9+
{name = "Thierry Jean", email = "thierry@dagworks.io"},
10+
]
11+
dynamic = ["version"]
12+
readme = "README.md"
13+
keywords = [
14+
"hamilton",
15+
"dagworks",
16+
"vscode",
17+
"extension",
18+
"data science",
19+
"pipelines",
20+
]
21+
classifiers = [
22+
"Topic :: Text Editors :: Integrated Development Environments (IDE)",
23+
"Intended Audience :: Developers",
24+
"License :: OSI Approved :: BSD License",
25+
"Programming Language :: Python",
26+
"Programming Language :: Python :: 3",
27+
"Programming Language :: Python :: 3 :: Only",
28+
]
29+
requires-python = ">=3.8, <4"
30+
dependencies = [
31+
"pygls>=1.3.1",
32+
"sf-hamilton[visualization]>=1.56",
33+
]
34+
35+
[project.urls]
36+
"Homepage" = "https://github.com/dagworks-inc/hamilton/"
37+
"Bug Reports" = "https://github.com/dagworks-inc/hamilton/issues"
38+
"Source" = "https://github.com/dagworks-inc/hamilton/tree/main/dev_tools/language_server"
39+
"Documenation" = "https://hamilton.dagworks.io/"
40+
41+
[project.optional-dependencies]
42+
test = [
43+
"pre-commit",
44+
"pytest",
45+
]
46+
47+
[project.scripts]
48+
hamilton_lsp = "hamilton_lsp.__main__:main"
49+
50+
[tool.setuptools.packages.find]
51+
where = ["."]
52+
include = ["hamilton_lsp*"]
53+
54+
[tool.setuptools.dynamic]
55+
version = { attr = "hamilton_lsp.__version__"}

dev_tools/language_server/tests/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)