Spaces:
Running
Running
Adding backend tests (#58)
Browse files* Added tests for lynxkite core
* Added tests for lynxkite-app
* Added tests for lynxkite-graph-analytics
* Better error message when workspace path is outside data dir
* Remove register_area operation, it is not used anymore
* Add some types & docstrings
* Update README
---------
Co-authored-by: JMLizano <[email protected]>
Co-authored-by: Daniel Darabos <[email protected]>
- README.md +12 -1
- lynxkite-app/pyproject.toml +5 -0
- lynxkite-app/src/lynxkite/app/crdt.py +79 -14
- lynxkite-app/tests/test_crdt.py +72 -0
- lynxkite-app/tests/test_main.py +77 -0
- lynxkite-core/pyproject.toml +5 -0
- lynxkite-core/src/lynxkite/core/executors/one_by_one.py +3 -1
- lynxkite-core/src/lynxkite/core/ops.py +2 -13
- lynxkite-core/src/lynxkite/core/workspace.py +35 -2
- lynxkite-core/tests/test_ops.py +85 -0
- lynxkite-core/tests/test_workspace.py +101 -0
- lynxkite-graph-analytics/pyproject.toml +7 -0
- lynxkite-graph-analytics/src/lynxkite_plugins/graph_analytics/lynxkite_ops.py +10 -4
- lynxkite-graph-analytics/src/lynxkite_plugins/graph_analytics/pytorch_model_ops.py +0 -2
- lynxkite-graph-analytics/tests/test_lynxkite_ops.py +94 -0
README.md
CHANGED
@@ -23,7 +23,8 @@ Install everything like this:
|
|
23 |
```bash
|
24 |
uv venv
|
25 |
source .venv/bin/activate
|
26 |
-
|
|
|
27 |
```
|
28 |
|
29 |
This also builds the frontend, hopefully very quickly. To run it:
|
@@ -40,6 +41,16 @@ cd lynxkite-app/web
|
|
40 |
npm run dev
|
41 |
```
|
42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
43 |
## Documentation
|
44 |
|
45 |
To work on the documentation:
|
|
|
23 |
```bash
|
24 |
uv venv
|
25 |
source .venv/bin/activate
|
26 |
+
# The [dev] tag is only needed if you intend on running tests
|
27 |
+
uv pip install -e lynxkite-core/[dev] lynxkite-app/[dev] lynxkite-graph-analytics/[dev] lynxkite-lynxscribe/ lynxkite-pillow-example/
|
28 |
```
|
29 |
|
30 |
This also builds the frontend, hopefully very quickly. To run it:
|
|
|
41 |
npm run dev
|
42 |
```
|
43 |
|
44 |
+
## Executing tests
|
45 |
+
|
46 |
+
Just go into each directory and execute `pytest`.
|
47 |
+
|
48 |
+
```bash
|
49 |
+
# Same thing for lynxkite-core and lynxkite-graph-analytics
|
50 |
+
$ cd lynxkite-app
|
51 |
+
$ pytest
|
52 |
+
```
|
53 |
+
|
54 |
## Documentation
|
55 |
|
56 |
To work on the documentation:
|
lynxkite-app/pyproject.toml
CHANGED
@@ -13,6 +13,11 @@ dependencies = [
|
|
13 |
"sse-starlette>=2.2.1",
|
14 |
]
|
15 |
|
|
|
|
|
|
|
|
|
|
|
16 |
[tool.uv.sources]
|
17 |
lynxkite-core = { path = "../lynxkite-core" }
|
18 |
|
|
|
13 |
"sse-starlette>=2.2.1",
|
14 |
]
|
15 |
|
16 |
+
[project.optional-dependencies]
|
17 |
+
dev = [
|
18 |
+
"pytest",
|
19 |
+
]
|
20 |
+
|
21 |
[tool.uv.sources]
|
22 |
lynxkite-core = { path = "../lynxkite-core" }
|
23 |
|
lynxkite-app/src/lynxkite/app/crdt.py
CHANGED
@@ -29,7 +29,11 @@ def ws_exception_handler(exception, log):
|
|
29 |
|
30 |
|
31 |
class WebsocketServer(pycrdt_websocket.WebsocketServer):
|
32 |
-
async def init_room(self, name):
|
|
|
|
|
|
|
|
|
33 |
path = CRDT_PATH / f"{name}.crdt"
|
34 |
assert path.is_relative_to(CRDT_PATH)
|
35 |
ystore = pycrdt_websocket.ystore.FileYStore(path)
|
@@ -49,6 +53,8 @@ class WebsocketServer(pycrdt_websocket.WebsocketServer):
|
|
49 |
ws["edges"] = pycrdt.Array()
|
50 |
if "env" not in ws:
|
51 |
ws["env"] = "unset"
|
|
|
|
|
52 |
try_to_load_workspace(ws, name)
|
53 |
room = pycrdt_websocket.YRoom(
|
54 |
ystore=ystore, ydoc=ydoc, exception_handler=ws_exception_handler
|
@@ -62,6 +68,12 @@ class WebsocketServer(pycrdt_websocket.WebsocketServer):
|
|
62 |
return room
|
63 |
|
64 |
async def get_room(self, name: str) -> pycrdt_websocket.YRoom:
|
|
|
|
|
|
|
|
|
|
|
|
|
65 |
if name not in self.rooms:
|
66 |
self.rooms[name] = await self.init_room(name)
|
67 |
room = self.rooms[name]
|
@@ -83,21 +95,43 @@ def clean_input(ws_pyd):
|
|
83 |
delattr(node, key)
|
84 |
|
85 |
|
86 |
-
def crdt_update(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
87 |
if isinstance(python_obj, dict):
|
88 |
for key, value in python_obj.items():
|
89 |
-
if key in
|
90 |
crdt_obj[key] = value
|
91 |
elif isinstance(value, dict):
|
92 |
if crdt_obj.get(key) is None:
|
93 |
crdt_obj[key] = pycrdt.Map()
|
94 |
-
crdt_update(crdt_obj[key], value,
|
95 |
elif isinstance(value, list):
|
96 |
if crdt_obj.get(key) is None:
|
97 |
crdt_obj[key] = pycrdt.Array()
|
98 |
-
crdt_update(crdt_obj[key], value,
|
99 |
elif isinstance(value, enum.Enum):
|
100 |
-
crdt_obj[key] = str(value)
|
101 |
else:
|
102 |
crdt_obj[key] = value
|
103 |
elif isinstance(python_obj, list):
|
@@ -105,12 +139,14 @@ def crdt_update(crdt_obj, python_obj, boxes=set()):
|
|
105 |
if isinstance(value, dict):
|
106 |
if i >= len(crdt_obj):
|
107 |
crdt_obj.append(pycrdt.Map())
|
108 |
-
crdt_update(crdt_obj[i], value,
|
109 |
elif isinstance(value, list):
|
110 |
if i >= len(crdt_obj):
|
111 |
crdt_obj.append(pycrdt.Array())
|
112 |
-
crdt_update(crdt_obj[i], value,
|
113 |
else:
|
|
|
|
|
114 |
if i >= len(crdt_obj):
|
115 |
crdt_obj.append(value)
|
116 |
else:
|
@@ -119,18 +155,34 @@ def crdt_update(crdt_obj, python_obj, boxes=set()):
|
|
119 |
raise ValueError("Invalid type:", python_obj)
|
120 |
|
121 |
|
122 |
-
def try_to_load_workspace(ws, name):
|
|
|
|
|
|
|
|
|
|
|
|
|
123 |
json_path = f"data/{name}"
|
124 |
if os.path.exists(json_path):
|
125 |
ws_pyd = workspace.load(json_path)
|
126 |
-
|
|
|
|
|
127 |
|
128 |
|
129 |
last_known_versions = {}
|
130 |
delayed_executions = {}
|
131 |
|
132 |
|
133 |
-
async def workspace_changed(name, changes, ws_crdt):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
134 |
ws_pyd = workspace.Workspace.model_validate(ws_crdt.to_py())
|
135 |
# Do not trigger execution for superficial changes.
|
136 |
# This is a quick solution until we build proper caching.
|
@@ -154,22 +206,35 @@ async def workspace_changed(name, changes, ws_crdt):
|
|
154 |
await execute(name, ws_crdt, ws_pyd)
|
155 |
|
156 |
|
157 |
-
async def execute(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
158 |
if delay:
|
159 |
try:
|
160 |
await asyncio.sleep(delay)
|
161 |
except asyncio.CancelledError:
|
162 |
return
|
163 |
path = DATA_PATH / name
|
164 |
-
assert path.is_relative_to(DATA_PATH)
|
|
|
165 |
workspace.save(ws_pyd, path)
|
166 |
await workspace.execute(ws_pyd)
|
167 |
workspace.save(ws_pyd, path)
|
|
|
|
|
168 |
with ws_crdt.doc.transaction():
|
169 |
for nc, np in zip(ws_crdt["nodes"], ws_pyd.nodes):
|
170 |
if "data" not in nc:
|
171 |
nc["data"] = pycrdt.Map()
|
172 |
-
# Display is added as
|
173 |
nc["data"]["display"] = np.data.display
|
174 |
nc["data"]["error"] = np.data.error
|
175 |
|
|
|
29 |
|
30 |
|
31 |
class WebsocketServer(pycrdt_websocket.WebsocketServer):
|
32 |
+
async def init_room(self, name: str) -> pycrdt_websocket.YRoom:
|
33 |
+
"""Initialize a room for the workspace with the given name.
|
34 |
+
|
35 |
+
The workspace is loaded from "crdt_data" if it exists there, or from "data", or a new workspace is created.
|
36 |
+
"""
|
37 |
path = CRDT_PATH / f"{name}.crdt"
|
38 |
assert path.is_relative_to(CRDT_PATH)
|
39 |
ystore = pycrdt_websocket.ystore.FileYStore(path)
|
|
|
53 |
ws["edges"] = pycrdt.Array()
|
54 |
if "env" not in ws:
|
55 |
ws["env"] = "unset"
|
56 |
+
# We have two possible sources of truth for the workspaces, the YStore and the JSON files.
|
57 |
+
# In case we didn't find the workspace in the YStore, we try to load it from the JSON files.
|
58 |
try_to_load_workspace(ws, name)
|
59 |
room = pycrdt_websocket.YRoom(
|
60 |
ystore=ystore, ydoc=ydoc, exception_handler=ws_exception_handler
|
|
|
68 |
return room
|
69 |
|
70 |
async def get_room(self, name: str) -> pycrdt_websocket.YRoom:
|
71 |
+
"""Get a room by name.
|
72 |
+
|
73 |
+
This method overrides the parent get_room method. The original creates an empty room,
|
74 |
+
with no associated Ydoc. Instead, we want to initialize the the room with a Workspace
|
75 |
+
object.
|
76 |
+
"""
|
77 |
if name not in self.rooms:
|
78 |
self.rooms[name] = await self.init_room(name)
|
79 |
room = self.rooms[name]
|
|
|
95 |
delattr(node, key)
|
96 |
|
97 |
|
98 |
+
def crdt_update(
|
99 |
+
crdt_obj: pycrdt.Map | pycrdt.Array,
|
100 |
+
python_obj: dict | list,
|
101 |
+
non_collaborative_fields: set[str] = set(),
|
102 |
+
):
|
103 |
+
"""Update a CRDT object to match a Python object.
|
104 |
+
|
105 |
+
The types between the CRDT object and the Python object must match. If the Python object
|
106 |
+
is a dict, the CRDT object must be a Map. If the Python object is a list, the CRDT object
|
107 |
+
must be an Array.
|
108 |
+
|
109 |
+
Args:
|
110 |
+
crdt_obj: The CRDT object, that will be updated to match the Python object.
|
111 |
+
python_obj: The Python object to update with.
|
112 |
+
non_collaborative_fields: List of fields to treat as a black box. Black boxes are
|
113 |
+
updated as a whole, instead of having a fine-grained data structure to edit
|
114 |
+
collaboratively. Useful for complex fields that contain auto-generated data or
|
115 |
+
metadata.
|
116 |
+
The default is an empty set.
|
117 |
+
|
118 |
+
Raises:
|
119 |
+
ValueError: If the Python object provided is not a dict or list.
|
120 |
+
"""
|
121 |
if isinstance(python_obj, dict):
|
122 |
for key, value in python_obj.items():
|
123 |
+
if key in non_collaborative_fields:
|
124 |
crdt_obj[key] = value
|
125 |
elif isinstance(value, dict):
|
126 |
if crdt_obj.get(key) is None:
|
127 |
crdt_obj[key] = pycrdt.Map()
|
128 |
+
crdt_update(crdt_obj[key], value, non_collaborative_fields)
|
129 |
elif isinstance(value, list):
|
130 |
if crdt_obj.get(key) is None:
|
131 |
crdt_obj[key] = pycrdt.Array()
|
132 |
+
crdt_update(crdt_obj[key], value, non_collaborative_fields)
|
133 |
elif isinstance(value, enum.Enum):
|
134 |
+
crdt_obj[key] = str(value.value)
|
135 |
else:
|
136 |
crdt_obj[key] = value
|
137 |
elif isinstance(python_obj, list):
|
|
|
139 |
if isinstance(value, dict):
|
140 |
if i >= len(crdt_obj):
|
141 |
crdt_obj.append(pycrdt.Map())
|
142 |
+
crdt_update(crdt_obj[i], value, non_collaborative_fields)
|
143 |
elif isinstance(value, list):
|
144 |
if i >= len(crdt_obj):
|
145 |
crdt_obj.append(pycrdt.Array())
|
146 |
+
crdt_update(crdt_obj[i], value, non_collaborative_fields)
|
147 |
else:
|
148 |
+
if isinstance(value, enum.Enum):
|
149 |
+
value = str(value.value)
|
150 |
if i >= len(crdt_obj):
|
151 |
crdt_obj.append(value)
|
152 |
else:
|
|
|
155 |
raise ValueError("Invalid type:", python_obj)
|
156 |
|
157 |
|
158 |
+
def try_to_load_workspace(ws: pycrdt.Map, name: str):
|
159 |
+
"""Load the workspace `name`, if it exists, and update the `ws` CRDT object to match its contents.
|
160 |
+
|
161 |
+
Args:
|
162 |
+
ws: CRDT object to udpate with the workspace contents.
|
163 |
+
name: Name of the workspace to load.
|
164 |
+
"""
|
165 |
json_path = f"data/{name}"
|
166 |
if os.path.exists(json_path):
|
167 |
ws_pyd = workspace.load(json_path)
|
168 |
+
# We treat the display field as a black box, since it is a large
|
169 |
+
# dictionary that is meant to change as a whole.
|
170 |
+
crdt_update(ws, ws_pyd.model_dump(), non_collaborative_fields={"display"})
|
171 |
|
172 |
|
173 |
last_known_versions = {}
|
174 |
delayed_executions = {}
|
175 |
|
176 |
|
177 |
+
async def workspace_changed(name: str, changes: pycrdt.MapEvent, ws_crdt: pycrdt.Map):
|
178 |
+
"""Callback to react to changes in the workspace.
|
179 |
+
|
180 |
+
|
181 |
+
Args:
|
182 |
+
name: Name of the workspace.
|
183 |
+
changes: Changes performed to the workspace.
|
184 |
+
ws_crdt: CRDT object representing the workspace.
|
185 |
+
"""
|
186 |
ws_pyd = workspace.Workspace.model_validate(ws_crdt.to_py())
|
187 |
# Do not trigger execution for superficial changes.
|
188 |
# This is a quick solution until we build proper caching.
|
|
|
206 |
await execute(name, ws_crdt, ws_pyd)
|
207 |
|
208 |
|
209 |
+
async def execute(
|
210 |
+
name: str, ws_crdt: pycrdt.Map, ws_pyd: workspace.Workspace, delay: int = 0
|
211 |
+
):
|
212 |
+
"""Execute the workspace and update the CRDT object with the results.
|
213 |
+
|
214 |
+
Args:
|
215 |
+
name: Name of the workspace.
|
216 |
+
ws_crdt: CRDT object representing the workspace.
|
217 |
+
ws_pyd: Workspace object to execute.
|
218 |
+
delay: Wait time before executing the workspace. The default is 0.
|
219 |
+
"""
|
220 |
if delay:
|
221 |
try:
|
222 |
await asyncio.sleep(delay)
|
223 |
except asyncio.CancelledError:
|
224 |
return
|
225 |
path = DATA_PATH / name
|
226 |
+
assert path.is_relative_to(DATA_PATH),"Provided workspace path is invalid"
|
227 |
+
# Save user changes before executing, in case the execution fails.
|
228 |
workspace.save(ws_pyd, path)
|
229 |
await workspace.execute(ws_pyd)
|
230 |
workspace.save(ws_pyd, path)
|
231 |
+
# Execution happened on the Python object, we need to replicate
|
232 |
+
# the results to the CRDT object.
|
233 |
with ws_crdt.doc.transaction():
|
234 |
for nc, np in zip(ws_crdt["nodes"], ws_pyd.nodes):
|
235 |
if "data" not in nc:
|
236 |
nc["data"] = pycrdt.Map()
|
237 |
+
# Display is added as a non collaborative field.
|
238 |
nc["data"]["display"] = np.data.display
|
239 |
nc["data"]["error"] = np.data.error
|
240 |
|
lynxkite-app/tests/test_crdt.py
ADDED
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from enum import Enum
|
2 |
+
import pycrdt
|
3 |
+
import pytest
|
4 |
+
from lynxkite.app.crdt import crdt_update
|
5 |
+
|
6 |
+
|
7 |
+
@pytest.fixture
|
8 |
+
def empty_dict_workspace():
|
9 |
+
ydoc = pycrdt.Doc()
|
10 |
+
ydoc["workspace"] = ws = pycrdt.Map()
|
11 |
+
yield ws
|
12 |
+
|
13 |
+
|
14 |
+
@pytest.fixture
|
15 |
+
def empty_list_workspace():
|
16 |
+
ydoc = pycrdt.Doc()
|
17 |
+
ydoc["workspace"] = ws = pycrdt.Array()
|
18 |
+
yield ws
|
19 |
+
|
20 |
+
|
21 |
+
class MyEnum(Enum):
|
22 |
+
VALUE = 1
|
23 |
+
|
24 |
+
|
25 |
+
@pytest.mark.parametrize(
|
26 |
+
"python_obj,expected",
|
27 |
+
[
|
28 |
+
(
|
29 |
+
{
|
30 |
+
"key1": "value1",
|
31 |
+
"key2": {
|
32 |
+
"nested_key1": "nested_value1",
|
33 |
+
"nested_key2": ["nested_value2"],
|
34 |
+
"nested_key3": MyEnum.VALUE,
|
35 |
+
},
|
36 |
+
},
|
37 |
+
{
|
38 |
+
"key1": "value1",
|
39 |
+
"key2": {
|
40 |
+
"nested_key1": "nested_value1",
|
41 |
+
"nested_key2": ["nested_value2"],
|
42 |
+
"nested_key3": "1",
|
43 |
+
},
|
44 |
+
},
|
45 |
+
)
|
46 |
+
],
|
47 |
+
)
|
48 |
+
def test_crdt_update_with_dict(empty_dict_workspace, python_obj, expected):
|
49 |
+
crdt_update(empty_dict_workspace, python_obj)
|
50 |
+
assert empty_dict_workspace.to_py() == expected
|
51 |
+
|
52 |
+
|
53 |
+
@pytest.mark.parametrize(
|
54 |
+
"python_obj,expected",
|
55 |
+
[
|
56 |
+
(
|
57 |
+
[
|
58 |
+
"value1",
|
59 |
+
{"nested_key1": "nested_value1", "nested_key2": ["nested_value2"]},
|
60 |
+
MyEnum.VALUE,
|
61 |
+
],
|
62 |
+
[
|
63 |
+
"value1",
|
64 |
+
{"nested_key1": "nested_value1", "nested_key2": ["nested_value2"]},
|
65 |
+
"1",
|
66 |
+
],
|
67 |
+
),
|
68 |
+
],
|
69 |
+
)
|
70 |
+
def test_crdt_update_with_list(empty_list_workspace, python_obj, expected):
|
71 |
+
crdt_update(empty_list_workspace, python_obj)
|
72 |
+
assert empty_list_workspace.to_py() == expected
|
lynxkite-app/tests/test_main.py
ADDED
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import uuid
|
2 |
+
from fastapi.testclient import TestClient
|
3 |
+
from lynxkite.app.main import app, detect_plugins, DATA_PATH
|
4 |
+
import os
|
5 |
+
|
6 |
+
|
7 |
+
client = TestClient(app)
|
8 |
+
|
9 |
+
|
10 |
+
def test_detect_plugins_with_plugins():
|
11 |
+
# This test assumes that these plugins are installed as part of the testing process.
|
12 |
+
plugins = detect_plugins()
|
13 |
+
assert all(
|
14 |
+
plugin in plugins.keys()
|
15 |
+
for plugin in [
|
16 |
+
"lynxkite_plugins.graph_analytics",
|
17 |
+
"lynxkite_plugins.lynxscribe",
|
18 |
+
"lynxkite_plugins.pillow_example",
|
19 |
+
]
|
20 |
+
)
|
21 |
+
|
22 |
+
|
23 |
+
def test_get_catalog():
|
24 |
+
response = client.get("/api/catalog")
|
25 |
+
assert response.status_code == 200
|
26 |
+
|
27 |
+
|
28 |
+
def test_save_and_load():
|
29 |
+
save_request = {
|
30 |
+
"path": "test",
|
31 |
+
"ws": {
|
32 |
+
"env": "test",
|
33 |
+
"nodes": [
|
34 |
+
{
|
35 |
+
"id": "Node_1",
|
36 |
+
"type": "basic",
|
37 |
+
"data": {
|
38 |
+
"display": None,
|
39 |
+
"error": "Unknown operation.",
|
40 |
+
"title": "Test node",
|
41 |
+
"params": {"param1": "value"},
|
42 |
+
},
|
43 |
+
"position": {"x": -493.5496596237119, "y": 20.90123252513356},
|
44 |
+
}
|
45 |
+
],
|
46 |
+
"edges": [],
|
47 |
+
},
|
48 |
+
}
|
49 |
+
response = client.post("/api/save", json=save_request)
|
50 |
+
saved_ws = response.json()
|
51 |
+
assert response.status_code == 200
|
52 |
+
response = client.get("/api/load?path=test")
|
53 |
+
assert response.status_code == 200
|
54 |
+
assert saved_ws == response.json()
|
55 |
+
|
56 |
+
|
57 |
+
def test_list_dir():
|
58 |
+
test_dir = str(uuid.uuid4())
|
59 |
+
test_dir_full_path = DATA_PATH / test_dir
|
60 |
+
test_dir_full_path.mkdir(exist_ok=True)
|
61 |
+
test_file = test_dir_full_path / "test_file.txt"
|
62 |
+
test_file.touch()
|
63 |
+
response = client.get(f"/api/dir/list?path={str(test_dir)}")
|
64 |
+
assert response.status_code == 200
|
65 |
+
assert len(response.json()) == 1
|
66 |
+
assert response.json()[0]["name"] == f"{test_dir}/test_file.txt"
|
67 |
+
assert response.json()[0]["type"] == "workspace"
|
68 |
+
test_file.unlink()
|
69 |
+
test_dir_full_path.rmdir()
|
70 |
+
|
71 |
+
|
72 |
+
def test_make_dir():
|
73 |
+
dir_name = str(uuid.uuid4())
|
74 |
+
response = client.post("/api/dir/mkdir", json={"path": dir_name})
|
75 |
+
assert response.status_code == 200
|
76 |
+
assert os.path.exists(DATA_PATH / dir_name)
|
77 |
+
os.rmdir(DATA_PATH / dir_name)
|
lynxkite-core/pyproject.toml
CHANGED
@@ -6,3 +6,8 @@ readme = "README.md"
|
|
6 |
requires-python = ">=3.11"
|
7 |
dependencies = [
|
8 |
]
|
|
|
|
|
|
|
|
|
|
|
|
6 |
requires-python = ">=3.11"
|
7 |
dependencies = [
|
8 |
]
|
9 |
+
|
10 |
+
[project.optional-dependencies]
|
11 |
+
dev = [
|
12 |
+
"pytest",
|
13 |
+
]
|
lynxkite-core/src/lynxkite/core/executors/one_by_one.py
CHANGED
@@ -51,6 +51,8 @@ def get_stages(ws, catalog):
|
|
51 |
nodes = {n.id: n for n in ws.nodes}
|
52 |
batch_inputs = {}
|
53 |
inputs = {}
|
|
|
|
|
54 |
for edge in ws.edges:
|
55 |
inputs.setdefault(edge.target, []).append(edge.source)
|
56 |
node = nodes[edge.target]
|
@@ -93,7 +95,7 @@ async def await_if_needed(obj):
|
|
93 |
return obj
|
94 |
|
95 |
|
96 |
-
async def execute(ws, catalog, cache=None):
|
97 |
nodes = {n.id: n for n in ws.nodes}
|
98 |
contexts = {n.id: Context(node=n) for n in ws.nodes}
|
99 |
edges = {n.id: [] for n in ws.nodes}
|
|
|
51 |
nodes = {n.id: n for n in ws.nodes}
|
52 |
batch_inputs = {}
|
53 |
inputs = {}
|
54 |
+
# For each edge in the workspacce, we record the inputs (sources)
|
55 |
+
# required for each node (target).
|
56 |
for edge in ws.edges:
|
57 |
inputs.setdefault(edge.target, []).append(edge.source)
|
58 |
node = nodes[edge.target]
|
|
|
95 |
return obj
|
96 |
|
97 |
|
98 |
+
async def execute(ws: workspace.Workspace, catalog, cache=None):
|
99 |
nodes = {n.id: n for n in ws.nodes}
|
100 |
contexts = {n.id: Context(node=n) for n in ws.nodes}
|
101 |
edges = {n.id: [] for n in ws.nodes}
|
lynxkite-core/src/lynxkite/core/ops.py
CHANGED
@@ -64,6 +64,7 @@ class Parameter(BaseConfig):
|
|
64 |
class Input(BaseConfig):
|
65 |
name: str
|
66 |
type: Type
|
|
|
67 |
position: str = "left"
|
68 |
|
69 |
|
@@ -90,6 +91,7 @@ class Op(BaseConfig):
|
|
90 |
params: dict[str, Parameter]
|
91 |
inputs: dict[str, Input]
|
92 |
outputs: dict[str, Output]
|
|
|
93 |
type: str = "basic" # The UI to use for this operation.
|
94 |
|
95 |
def __call__(self, *inputs, **params):
|
@@ -209,16 +211,3 @@ def op_registration(env: str):
|
|
209 |
|
210 |
def passive_op_registration(env: str):
|
211 |
return functools.partial(register_passive_op, env)
|
212 |
-
|
213 |
-
|
214 |
-
def register_area(env, name, params=[]):
|
215 |
-
"""A node that represents an area. It can contain other nodes, but does not restrict movement in any way."""
|
216 |
-
op = Op(
|
217 |
-
func=no_op,
|
218 |
-
name=name,
|
219 |
-
params={p.name: p for p in params},
|
220 |
-
inputs={},
|
221 |
-
outputs={},
|
222 |
-
type="area",
|
223 |
-
)
|
224 |
-
CATALOGS[env][name] = op
|
|
|
64 |
class Input(BaseConfig):
|
65 |
name: str
|
66 |
type: Type
|
67 |
+
# TODO: Make position an enum with the possible values.
|
68 |
position: str = "left"
|
69 |
|
70 |
|
|
|
91 |
params: dict[str, Parameter]
|
92 |
inputs: dict[str, Input]
|
93 |
outputs: dict[str, Output]
|
94 |
+
# TODO: Make type an enum with the possible values.
|
95 |
type: str = "basic" # The UI to use for this operation.
|
96 |
|
97 |
def __call__(self, *inputs, **params):
|
|
|
211 |
|
212 |
def passive_op_registration(env: str):
|
213 |
return functools.partial(register_passive_op, env)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
lynxkite-core/src/lynxkite/core/workspace.py
CHANGED
@@ -29,6 +29,8 @@ class WorkspaceNodeData(BaseConfig):
|
|
29 |
|
30 |
|
31 |
class WorkspaceNode(BaseConfig):
|
|
|
|
|
32 |
id: str
|
33 |
type: str
|
34 |
data: WorkspaceNodeData
|
@@ -44,6 +46,13 @@ class WorkspaceEdge(BaseConfig):
|
|
44 |
|
45 |
|
46 |
class Workspace(BaseConfig):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
47 |
env: str = ""
|
48 |
nodes: list[WorkspaceNode] = dataclasses.field(default_factory=list)
|
49 |
edges: list[WorkspaceEdge] = dataclasses.field(default_factory=list)
|
@@ -55,6 +64,7 @@ async def execute(ws: Workspace):
|
|
55 |
|
56 |
|
57 |
def save(ws: Workspace, path: str):
|
|
|
58 |
j = ws.model_dump_json(indent=2)
|
59 |
dirname, basename = os.path.split(path)
|
60 |
# Create temp file in the same directory to make sure it's on the same filesystem.
|
@@ -66,7 +76,17 @@ def save(ws: Workspace, path: str):
|
|
66 |
os.replace(temp_name, path)
|
67 |
|
68 |
|
69 |
-
def load(path: str):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
70 |
with open(path) as f:
|
71 |
j = f.read()
|
72 |
ws = Workspace.model_validate_json(j)
|
@@ -75,13 +95,26 @@ def load(path: str):
|
|
75 |
return ws
|
76 |
|
77 |
|
78 |
-
def _update_metadata(ws):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
79 |
catalog = ops.CATALOGS.get(ws.env, {})
|
80 |
nodes = {node.id: node for node in ws.nodes}
|
81 |
done = set()
|
82 |
while len(done) < len(nodes):
|
83 |
for node in ws.nodes:
|
84 |
if node.id in done:
|
|
|
85 |
continue
|
86 |
data = node.data
|
87 |
op = catalog.get(data.title)
|
|
|
29 |
|
30 |
|
31 |
class WorkspaceNode(BaseConfig):
|
32 |
+
# The naming of these attributes matches the ones for the NodeBase type in React flow
|
33 |
+
# modyfing them will break the frontend.
|
34 |
id: str
|
35 |
type: str
|
36 |
data: WorkspaceNodeData
|
|
|
46 |
|
47 |
|
48 |
class Workspace(BaseConfig):
|
49 |
+
"""A workspace is a representation of a computational graph that consists of nodes and edges.
|
50 |
+
|
51 |
+
Each node represents an operation or task, and the edges represent the flow of data between
|
52 |
+
the nodes. Each workspace is associated with an environment, which determines the operations
|
53 |
+
that can be performed in the workspace and the execution method for the operations.
|
54 |
+
"""
|
55 |
+
|
56 |
env: str = ""
|
57 |
nodes: list[WorkspaceNode] = dataclasses.field(default_factory=list)
|
58 |
edges: list[WorkspaceEdge] = dataclasses.field(default_factory=list)
|
|
|
64 |
|
65 |
|
66 |
def save(ws: Workspace, path: str):
|
67 |
+
"""Persist a workspace to a local file in JSON format."""
|
68 |
j = ws.model_dump_json(indent=2)
|
69 |
dirname, basename = os.path.split(path)
|
70 |
# Create temp file in the same directory to make sure it's on the same filesystem.
|
|
|
76 |
os.replace(temp_name, path)
|
77 |
|
78 |
|
79 |
+
def load(path: str) -> Workspace:
|
80 |
+
"""Load a workspace from a file.
|
81 |
+
|
82 |
+
After loading the workspace, the metadata of the workspace is updated.
|
83 |
+
|
84 |
+
Args:
|
85 |
+
path (str): The path to the file to load the workspace from.
|
86 |
+
|
87 |
+
Returns:
|
88 |
+
Workspace: The loaded workspace object, with updated metadata.
|
89 |
+
"""
|
90 |
with open(path) as f:
|
91 |
j = f.read()
|
92 |
ws = Workspace.model_validate_json(j)
|
|
|
95 |
return ws
|
96 |
|
97 |
|
98 |
+
def _update_metadata(ws: Workspace) -> Workspace:
|
99 |
+
"""Update the metadata of the given workspace object.
|
100 |
+
|
101 |
+
The metadata is the information about the operations that the nodes in the workspace represent,
|
102 |
+
like the parameters and their possible values.
|
103 |
+
This information comes from the catalog of operations for the environment of the workspace.
|
104 |
+
|
105 |
+
Args:
|
106 |
+
ws: The workspace object to update.
|
107 |
+
|
108 |
+
Returns:
|
109 |
+
Workspace: The updated workspace object.
|
110 |
+
"""
|
111 |
catalog = ops.CATALOGS.get(ws.env, {})
|
112 |
nodes = {node.id: node for node in ws.nodes}
|
113 |
done = set()
|
114 |
while len(done) < len(nodes):
|
115 |
for node in ws.nodes:
|
116 |
if node.id in done:
|
117 |
+
# TODO: Can nodes with the same ID reference different operations?
|
118 |
continue
|
119 |
data = node.data
|
120 |
op = catalog.get(data.title)
|
lynxkite-core/tests/test_ops.py
ADDED
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import inspect
|
2 |
+
from lynxkite.core import ops
|
3 |
+
import enum
|
4 |
+
|
5 |
+
|
6 |
+
def test_op_decorator_no_params_no_types_default_positions():
|
7 |
+
@ops.op(env="test", name="add", view="basic", outputs=["result"])
|
8 |
+
def add(a, b):
|
9 |
+
return a + b
|
10 |
+
|
11 |
+
assert add.__op__.name == "add"
|
12 |
+
assert add.__op__.params == {}
|
13 |
+
assert add.__op__.inputs == {
|
14 |
+
"a": ops.Input(name="a", type=inspect._empty, position="left"),
|
15 |
+
"b": ops.Input(name="b", type=inspect._empty, position="left"),
|
16 |
+
}
|
17 |
+
assert add.__op__.outputs == {
|
18 |
+
"result": ops.Output(name="result", type=None, position="right")
|
19 |
+
}
|
20 |
+
assert add.__op__.type == "basic"
|
21 |
+
assert ops.CATALOGS["test"]["add"] == add.__op__
|
22 |
+
|
23 |
+
|
24 |
+
def test_op_decorator_custom_positions():
|
25 |
+
@ops.input_position(a="right", b="top")
|
26 |
+
@ops.output_position(result="bottom")
|
27 |
+
@ops.op(env="test", name="add", view="basic", outputs=["result"])
|
28 |
+
def add(a, b):
|
29 |
+
return a + b
|
30 |
+
|
31 |
+
assert add.__op__.name == "add"
|
32 |
+
assert add.__op__.params == {}
|
33 |
+
assert add.__op__.inputs == {
|
34 |
+
"a": ops.Input(name="a", type=inspect._empty, position="right"),
|
35 |
+
"b": ops.Input(name="b", type=inspect._empty, position="top"),
|
36 |
+
}
|
37 |
+
assert add.__op__.outputs == {
|
38 |
+
"result": ops.Output(name="result", type=None, position="bottom")
|
39 |
+
}
|
40 |
+
assert add.__op__.type == "basic"
|
41 |
+
assert ops.CATALOGS["test"]["add"] == add.__op__
|
42 |
+
|
43 |
+
|
44 |
+
def test_op_decorator_with_params_and_types_():
|
45 |
+
@ops.op(env="test", name="multiply", view="basic", outputs=["result"])
|
46 |
+
def multiply(a: int, b: float = 2.0, *, param: str = "param"):
|
47 |
+
return a * b
|
48 |
+
|
49 |
+
assert multiply.__op__.name == "multiply"
|
50 |
+
assert multiply.__op__.params == {
|
51 |
+
"param": ops.Parameter(name="param", default="param", type=str)
|
52 |
+
}
|
53 |
+
assert multiply.__op__.inputs == {
|
54 |
+
"a": ops.Input(name="a", type=int, position="left"),
|
55 |
+
"b": ops.Input(name="b", type=float, position="left"),
|
56 |
+
}
|
57 |
+
assert multiply.__op__.outputs == {
|
58 |
+
"result": ops.Output(name="result", type=None, position="right")
|
59 |
+
}
|
60 |
+
assert multiply.__op__.type == "basic"
|
61 |
+
assert ops.CATALOGS["test"]["multiply"] == multiply.__op__
|
62 |
+
|
63 |
+
|
64 |
+
def test_op_decorator_with_complex_types():
|
65 |
+
class Color(enum.Enum):
|
66 |
+
RED = 1
|
67 |
+
GREEN = 2
|
68 |
+
BLUE = 3
|
69 |
+
|
70 |
+
@ops.op(env="test", name="color_op", view="basic", outputs=["result"])
|
71 |
+
def complex_op(color: Color, color_list: list[Color], color_dict: dict[str, Color]):
|
72 |
+
return color.name
|
73 |
+
|
74 |
+
assert complex_op.__op__.name == "color_op"
|
75 |
+
assert complex_op.__op__.params == {}
|
76 |
+
assert complex_op.__op__.inputs == {
|
77 |
+
"color": ops.Input(name="color", type=Color, position="left"),
|
78 |
+
"color_list": ops.Input(name="color_list", type=list[Color], position="left"),
|
79 |
+
"color_dict": ops.Input(name="color_dict", type=dict[str, Color], position="left"),
|
80 |
+
}
|
81 |
+
assert complex_op.__op__.type == "basic"
|
82 |
+
assert complex_op.__op__.outputs == {
|
83 |
+
"result": ops.Output(name="result", type=None, position="right")
|
84 |
+
}
|
85 |
+
assert ops.CATALOGS["test"]["color_op"] == complex_op.__op__
|
lynxkite-core/tests/test_workspace.py
ADDED
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import pytest
|
3 |
+
import tempfile
|
4 |
+
from lynxkite.core import workspace
|
5 |
+
|
6 |
+
|
7 |
+
def test_save_load():
|
8 |
+
ws = workspace.Workspace(env="test")
|
9 |
+
ws.nodes.append(
|
10 |
+
workspace.WorkspaceNode(
|
11 |
+
id="1",
|
12 |
+
type="node_type",
|
13 |
+
data=workspace.WorkspaceNodeData(title="Node 1", params={}),
|
14 |
+
position=workspace.Position(x=0, y=0),
|
15 |
+
)
|
16 |
+
)
|
17 |
+
ws.nodes.append(
|
18 |
+
workspace.WorkspaceNode(
|
19 |
+
id="2",
|
20 |
+
type="node_type",
|
21 |
+
data=workspace.WorkspaceNodeData(title="Node 2", params={}),
|
22 |
+
position=workspace.Position(x=0, y=0),
|
23 |
+
)
|
24 |
+
)
|
25 |
+
ws.edges.append(
|
26 |
+
workspace.WorkspaceEdge(
|
27 |
+
id="edge1",
|
28 |
+
source="1",
|
29 |
+
target="2",
|
30 |
+
sourceHandle="",
|
31 |
+
targetHandle="",
|
32 |
+
)
|
33 |
+
)
|
34 |
+
path = os.path.join(tempfile.gettempdir(), "test_workspace.json")
|
35 |
+
|
36 |
+
try:
|
37 |
+
workspace.save(ws, path)
|
38 |
+
assert os.path.exists(path)
|
39 |
+
loaded_ws = workspace.load(path)
|
40 |
+
assert loaded_ws.env == ws.env
|
41 |
+
assert len(loaded_ws.nodes) == len(ws.nodes)
|
42 |
+
assert len(loaded_ws.edges) == len(ws.edges)
|
43 |
+
sorted_ws_nodes = sorted(ws.nodes, key=lambda x: x.id)
|
44 |
+
sorted_loaded_ws_nodes = sorted(loaded_ws.nodes, key=lambda x: x.id)
|
45 |
+
# We do manual assertion on each attribute because metadata is added at
|
46 |
+
# loading time, which makes the objects different.
|
47 |
+
for node, loaded_node in zip(sorted_ws_nodes, sorted_loaded_ws_nodes):
|
48 |
+
assert node.id == loaded_node.id
|
49 |
+
assert node.type == loaded_node.type
|
50 |
+
assert node.data.title == loaded_node.data.title
|
51 |
+
assert node.data.params == loaded_node.data.params
|
52 |
+
assert node.position.x == loaded_node.position.x
|
53 |
+
assert node.position.y == loaded_node.position.y
|
54 |
+
sorted_ws_edges = sorted(ws.edges, key=lambda x: x.id)
|
55 |
+
sorted_loaded_ws_edges = sorted(loaded_ws.edges, key=lambda x: x.id)
|
56 |
+
for edge, loaded_edge in zip(sorted_ws_edges, sorted_loaded_ws_edges):
|
57 |
+
assert edge.id == loaded_edge.id
|
58 |
+
assert edge.source == loaded_edge.source
|
59 |
+
assert edge.target == loaded_edge.target
|
60 |
+
assert edge.sourceHandle == loaded_edge.sourceHandle
|
61 |
+
assert edge.targetHandle == loaded_edge.targetHandle
|
62 |
+
finally:
|
63 |
+
os.remove(path)
|
64 |
+
|
65 |
+
|
66 |
+
@pytest.fixture(scope="session", autouse=True)
|
67 |
+
def populate_ops_catalog():
|
68 |
+
from lynxkite.core import ops
|
69 |
+
|
70 |
+
ops.register_passive_op("test", "Test Operation", [])
|
71 |
+
|
72 |
+
|
73 |
+
def test_update_metadata():
|
74 |
+
ws = workspace.Workspace(env="test")
|
75 |
+
ws.nodes.append(
|
76 |
+
workspace.WorkspaceNode(
|
77 |
+
id="1",
|
78 |
+
type="basic",
|
79 |
+
data=workspace.WorkspaceNodeData(title="Test Operation", params={}),
|
80 |
+
position=workspace.Position(x=0, y=0),
|
81 |
+
)
|
82 |
+
)
|
83 |
+
ws.nodes.append(
|
84 |
+
workspace.WorkspaceNode(
|
85 |
+
id="2",
|
86 |
+
type="basic",
|
87 |
+
data=workspace.WorkspaceNodeData(title="Unknown Operation", params={}),
|
88 |
+
position=workspace.Position(x=0, y=0),
|
89 |
+
)
|
90 |
+
)
|
91 |
+
updated_ws = workspace._update_metadata(ws)
|
92 |
+
assert updated_ws.nodes[0].data.meta.name == "Test Operation"
|
93 |
+
assert updated_ws.nodes[0].data.error is None
|
94 |
+
assert not hasattr(updated_ws.nodes[1].data, "meta")
|
95 |
+
assert updated_ws.nodes[1].data.error == "Unknown operation."
|
96 |
+
|
97 |
+
|
98 |
+
def test_update_metadata_with_empty_workspace():
|
99 |
+
ws = workspace.Workspace(env="test")
|
100 |
+
updated_ws = workspace._update_metadata(ws)
|
101 |
+
assert len(updated_ws.nodes) == 0
|
lynxkite-graph-analytics/pyproject.toml
CHANGED
@@ -14,9 +14,16 @@ dependencies = [
|
|
14 |
]
|
15 |
|
16 |
[project.optional-dependencies]
|
|
|
|
|
|
|
|
|
17 |
gpu = [
|
18 |
"nx-cugraph-cu12>=24.12.0",
|
19 |
]
|
20 |
|
21 |
[tool.uv.sources]
|
22 |
lynxkite-core = { path = "../lynxkite-core" }
|
|
|
|
|
|
|
|
14 |
]
|
15 |
|
16 |
[project.optional-dependencies]
|
17 |
+
dev = [
|
18 |
+
"pytest",
|
19 |
+
"pytest-asyncio",
|
20 |
+
]
|
21 |
gpu = [
|
22 |
"nx-cugraph-cu12>=24.12.0",
|
23 |
]
|
24 |
|
25 |
[tool.uv.sources]
|
26 |
lynxkite-core = { path = "../lynxkite-core" }
|
27 |
+
|
28 |
+
[tool.pytest.ini_options]
|
29 |
+
asyncio_mode = "auto"
|
lynxkite-graph-analytics/src/lynxkite_plugins/graph_analytics/lynxkite_ops.py
CHANGED
@@ -1,7 +1,7 @@
|
|
1 |
"""Graph analytics operations. To be split into separate files when we have more."""
|
2 |
|
3 |
import os
|
4 |
-
from lynxkite.core import ops
|
5 |
from collections import deque
|
6 |
import dataclasses
|
7 |
import functools
|
@@ -119,7 +119,7 @@ def disambiguate_edges(ws):
|
|
119 |
|
120 |
@ops.register_executor(ENV)
|
121 |
async def execute(ws):
|
122 |
-
catalog = ops.CATALOGS[ENV]
|
123 |
disambiguate_edges(ws)
|
124 |
outputs = {}
|
125 |
failed = 0
|
@@ -130,12 +130,17 @@ async def execute(ws):
|
|
130 |
# TODO: Take the input/output handles into account.
|
131 |
inputs = [edge.source for edge in ws.edges if edge.target == node.id]
|
132 |
if all(input in outputs for input in inputs):
|
|
|
133 |
inputs = [outputs[input] for input in inputs]
|
134 |
data = node.data
|
135 |
-
op = catalog[data.title]
|
136 |
params = {**data.params}
|
137 |
-
|
|
|
|
|
|
|
|
|
138 |
try:
|
|
|
139 |
for i, (x, p) in enumerate(zip(inputs, op.inputs.values())):
|
140 |
if p.type == nx.Graph and isinstance(x, Bundle):
|
141 |
inputs[i] = x.to_nx()
|
@@ -191,6 +196,7 @@ def create_scale_free_graph(*, nodes: int = 10):
|
|
191 |
@op("Compute PageRank")
|
192 |
@nx_node_attribute_func("pagerank")
|
193 |
def compute_pagerank(graph: nx.Graph, *, damping=0.85, iterations=100):
|
|
|
194 |
return nx.pagerank(graph, alpha=damping, max_iter=iterations)
|
195 |
|
196 |
|
|
|
1 |
"""Graph analytics operations. To be split into separate files when we have more."""
|
2 |
|
3 |
import os
|
4 |
+
from lynxkite.core import ops, workspace
|
5 |
from collections import deque
|
6 |
import dataclasses
|
7 |
import functools
|
|
|
119 |
|
120 |
@ops.register_executor(ENV)
|
121 |
async def execute(ws):
|
122 |
+
catalog: dict[str, ops.Op] = ops.CATALOGS[ENV]
|
123 |
disambiguate_edges(ws)
|
124 |
outputs = {}
|
125 |
failed = 0
|
|
|
130 |
# TODO: Take the input/output handles into account.
|
131 |
inputs = [edge.source for edge in ws.edges if edge.target == node.id]
|
132 |
if all(input in outputs for input in inputs):
|
133 |
+
# All inputs for this node are ready, we can compute the output.
|
134 |
inputs = [outputs[input] for input in inputs]
|
135 |
data = node.data
|
|
|
136 |
params = {**data.params}
|
137 |
+
op = catalog.get(data.title)
|
138 |
+
if not op:
|
139 |
+
data.error = "Operation not found in catalog"
|
140 |
+
failed += 1
|
141 |
+
continue
|
142 |
try:
|
143 |
+
# Convert inputs types to match operation signature.
|
144 |
for i, (x, p) in enumerate(zip(inputs, op.inputs.values())):
|
145 |
if p.type == nx.Graph and isinstance(x, Bundle):
|
146 |
inputs[i] = x.to_nx()
|
|
|
196 |
@op("Compute PageRank")
|
197 |
@nx_node_attribute_func("pagerank")
|
198 |
def compute_pagerank(graph: nx.Graph, *, damping=0.85, iterations=100):
|
199 |
+
# TODO: This requires scipy to be installed.
|
200 |
return nx.pagerank(graph, alpha=damping, max_iter=iterations)
|
201 |
|
202 |
|
lynxkite-graph-analytics/src/lynxkite_plugins/graph_analytics/pytorch_model_ops.py
CHANGED
@@ -65,5 +65,3 @@ reg(
|
|
65 |
P.basic("lr", 0.001),
|
66 |
],
|
67 |
)
|
68 |
-
|
69 |
-
ops.register_area(ENV, "Repeat", params=[ops.Parameter.basic("times", 1, int)])
|
|
|
65 |
P.basic("lr", 0.001),
|
66 |
],
|
67 |
)
|
|
|
|
lynxkite-graph-analytics/tests/test_lynxkite_ops.py
ADDED
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import pandas as pd
|
2 |
+
import pytest
|
3 |
+
import networkx as nx
|
4 |
+
|
5 |
+
from lynxkite.core import workspace
|
6 |
+
from lynxkite_plugins.graph_analytics.lynxkite_ops import Bundle, execute, op
|
7 |
+
|
8 |
+
|
9 |
+
async def test_execute_operation_not_in_catalog():
|
10 |
+
ws = workspace.Workspace(env="test")
|
11 |
+
ws.nodes.append(
|
12 |
+
workspace.WorkspaceNode(
|
13 |
+
id="1",
|
14 |
+
type="node_type",
|
15 |
+
data=workspace.WorkspaceNodeData(title="Non existing op", params={}),
|
16 |
+
position=workspace.Position(x=0, y=0),
|
17 |
+
)
|
18 |
+
)
|
19 |
+
await execute(ws)
|
20 |
+
assert ws.nodes[0].data.error == "Operation not found in catalog"
|
21 |
+
|
22 |
+
|
23 |
+
async def test_execute_operation_inputs_correct_cast():
|
24 |
+
# Test that the automatic casting of operation inputs works correctly.
|
25 |
+
|
26 |
+
@op("Create Bundle")
|
27 |
+
def create_bundle() -> Bundle:
|
28 |
+
df = pd.DataFrame({"source": [1, 2, 3], "target": [4, 5, 6]})
|
29 |
+
return Bundle(dfs={"edges": df})
|
30 |
+
|
31 |
+
@op("Bundle to Graph")
|
32 |
+
def bundle_to_graph(graph: nx.Graph) -> nx.Graph:
|
33 |
+
return graph
|
34 |
+
|
35 |
+
@op("Graph to Bundle")
|
36 |
+
def graph_to_bundle(bundle: Bundle) -> pd.DataFrame:
|
37 |
+
return list(bundle.dfs.values())[0]
|
38 |
+
|
39 |
+
@op("Dataframe to Bundle")
|
40 |
+
def dataframe_to_bundle(bundle: Bundle) -> Bundle:
|
41 |
+
return bundle
|
42 |
+
|
43 |
+
ws = workspace.Workspace(env="test")
|
44 |
+
ws.nodes.append(
|
45 |
+
workspace.WorkspaceNode(
|
46 |
+
id="1",
|
47 |
+
type="node_type",
|
48 |
+
data=workspace.WorkspaceNodeData(title="Create Bundle", params={}),
|
49 |
+
position=workspace.Position(x=0, y=0),
|
50 |
+
)
|
51 |
+
)
|
52 |
+
ws.nodes.append(
|
53 |
+
workspace.WorkspaceNode(
|
54 |
+
id="2",
|
55 |
+
type="node_type",
|
56 |
+
data=workspace.WorkspaceNodeData(title="Bundle to Graph", params={}),
|
57 |
+
position=workspace.Position(x=100, y=0),
|
58 |
+
)
|
59 |
+
)
|
60 |
+
ws.nodes.append(
|
61 |
+
workspace.WorkspaceNode(
|
62 |
+
id="3",
|
63 |
+
type="node_type",
|
64 |
+
data=workspace.WorkspaceNodeData(title="Graph to Bundle", params={}),
|
65 |
+
position=workspace.Position(x=200, y=0),
|
66 |
+
)
|
67 |
+
)
|
68 |
+
ws.nodes.append(
|
69 |
+
workspace.WorkspaceNode(
|
70 |
+
id="4",
|
71 |
+
type="node_type",
|
72 |
+
data=workspace.WorkspaceNodeData(title="Dataframe to Bundle", params={}),
|
73 |
+
position=workspace.Position(x=300, y=0),
|
74 |
+
)
|
75 |
+
)
|
76 |
+
ws.edges = [
|
77 |
+
workspace.WorkspaceEdge(
|
78 |
+
id="1", source="1", target="2", sourceHandle="1", targetHandle="2"
|
79 |
+
),
|
80 |
+
workspace.WorkspaceEdge(
|
81 |
+
id="2", source="2", target="3", sourceHandle="2", targetHandle="3"
|
82 |
+
),
|
83 |
+
workspace.WorkspaceEdge(
|
84 |
+
id="3", source="3", target="4", sourceHandle="3", targetHandle="4"
|
85 |
+
),
|
86 |
+
]
|
87 |
+
|
88 |
+
await execute(ws)
|
89 |
+
|
90 |
+
assert all([node.data.error is None for node in ws.nodes])
|
91 |
+
|
92 |
+
|
93 |
+
if __name__ == "__main__":
|
94 |
+
pytest.main()
|