Spaces:
Running
Running
Merge pull request #85 from biggraph/darabos-nx
Browse files- examples/Airlines demo +0 -0
- examples/NetworkX demo +0 -0
- lynxkite-app/src/lynxkite_app/crdt.py +7 -1
- lynxkite-app/web/src/index.css +19 -0
- lynxkite-app/web/src/workspace/EnvironmentSelector.tsx +1 -1
- lynxkite-app/web/src/workspace/NodeSearch.tsx +6 -1
- lynxkite-app/web/src/workspace/Workspace.tsx +3 -3
- lynxkite-app/web/src/workspace/nodes/NodeParameter.tsx +4 -0
- lynxkite-app/web/src/workspace/nodes/NodeWithParams.tsx +37 -24
- lynxkite-app/web/src/workspace/nodes/NodeWithVisualization.tsx +15 -7
- lynxkite-app/web/tests/basic.spec.ts +3 -3
- lynxkite-app/web/tests/errors.spec.ts +11 -11
- lynxkite-app/web/tests/examples.spec.ts +2 -2
- lynxkite-app/web/tests/graph_creation.spec.ts +6 -2
- lynxkite-app/web/tests/lynxkite.ts +4 -1
- lynxkite-core/src/lynxkite/core/executors/one_by_one.py +4 -4
- lynxkite-core/src/lynxkite/core/ops.py +21 -6
- lynxkite-core/src/lynxkite/core/workspace.py +1 -0
- lynxkite-graph-analytics/src/lynxkite_graph_analytics/core.py +57 -37
- lynxkite-graph-analytics/src/lynxkite_graph_analytics/lynxkite_ops.py +0 -19
- lynxkite-graph-analytics/src/lynxkite_graph_analytics/networkx_ops.py +236 -24
- lynxkite-graph-analytics/tests/test_lynxkite_ops.py +71 -3
- lynxkite-lynxscribe/README.md +1 -1
- lynxkite-lynxscribe/src/lynxkite_lynxscribe/lynxscribe_ops.py +7 -6
examples/Airlines demo
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
examples/NetworkX demo
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
lynxkite-app/src/lynxkite_app/crdt.py
CHANGED
|
@@ -54,6 +54,10 @@ class WebsocketServer(pycrdt_websocket.WebsocketServer):
|
|
| 54 |
# We have two possible sources of truth for the workspaces, the YStore and the JSON files.
|
| 55 |
# In case we didn't find the workspace in the YStore, we try to load it from the JSON files.
|
| 56 |
try_to_load_workspace(ws, name)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
room = pycrdt_websocket.YRoom(
|
| 58 |
ystore=ystore, ydoc=ydoc, exception_handler=ws_exception_handler
|
| 59 |
)
|
|
@@ -197,7 +201,6 @@ async def workspace_changed(name: str, changes: pycrdt.MapEvent, ws_crdt: pycrdt
|
|
| 197 |
getattr(change, "keys", {}).get("__execution_delay", {}).get("newValue", 0)
|
| 198 |
for change in changes
|
| 199 |
)
|
| 200 |
-
print(f"Running {name} in {ws_pyd.env}...")
|
| 201 |
if delay:
|
| 202 |
task = asyncio.create_task(execute(name, ws_crdt, ws_pyd, delay))
|
| 203 |
delayed_executions[name] = task
|
|
@@ -221,10 +224,12 @@ async def execute(
|
|
| 221 |
await asyncio.sleep(delay)
|
| 222 |
except asyncio.CancelledError:
|
| 223 |
return
|
|
|
|
| 224 |
path = config.DATA_PATH / name
|
| 225 |
assert path.is_relative_to(config.DATA_PATH), "Provided workspace path is invalid"
|
| 226 |
# Save user changes before executing, in case the execution fails.
|
| 227 |
workspace.save(ws_pyd, path)
|
|
|
|
| 228 |
with ws_crdt.doc.transaction():
|
| 229 |
for nc, np in zip(ws_crdt["nodes"], ws_pyd.nodes):
|
| 230 |
if "data" not in nc:
|
|
@@ -234,6 +239,7 @@ async def execute(
|
|
| 234 |
np._crdt = nc
|
| 235 |
await workspace.execute(ws_pyd)
|
| 236 |
workspace.save(ws_pyd, path)
|
|
|
|
| 237 |
|
| 238 |
|
| 239 |
@contextlib.asynccontextmanager
|
|
|
|
| 54 |
# We have two possible sources of truth for the workspaces, the YStore and the JSON files.
|
| 55 |
# In case we didn't find the workspace in the YStore, we try to load it from the JSON files.
|
| 56 |
try_to_load_workspace(ws, name)
|
| 57 |
+
ws_simple = workspace.Workspace.model_validate(ws.to_py())
|
| 58 |
+
clean_input(ws_simple)
|
| 59 |
+
# Set the last known version to the current state, so we don't trigger a change event.
|
| 60 |
+
last_known_versions[name] = ws_simple
|
| 61 |
room = pycrdt_websocket.YRoom(
|
| 62 |
ystore=ystore, ydoc=ydoc, exception_handler=ws_exception_handler
|
| 63 |
)
|
|
|
|
| 201 |
getattr(change, "keys", {}).get("__execution_delay", {}).get("newValue", 0)
|
| 202 |
for change in changes
|
| 203 |
)
|
|
|
|
| 204 |
if delay:
|
| 205 |
task = asyncio.create_task(execute(name, ws_crdt, ws_pyd, delay))
|
| 206 |
delayed_executions[name] = task
|
|
|
|
| 224 |
await asyncio.sleep(delay)
|
| 225 |
except asyncio.CancelledError:
|
| 226 |
return
|
| 227 |
+
print(f"Running {name} in {ws_pyd.env}...")
|
| 228 |
path = config.DATA_PATH / name
|
| 229 |
assert path.is_relative_to(config.DATA_PATH), "Provided workspace path is invalid"
|
| 230 |
# Save user changes before executing, in case the execution fails.
|
| 231 |
workspace.save(ws_pyd, path)
|
| 232 |
+
ws_pyd._crdt = ws_crdt
|
| 233 |
with ws_crdt.doc.transaction():
|
| 234 |
for nc, np in zip(ws_crdt["nodes"], ws_pyd.nodes):
|
| 235 |
if "data" not in nc:
|
|
|
|
| 239 |
np._crdt = nc
|
| 240 |
await workspace.execute(ws_pyd)
|
| 241 |
workspace.save(ws_pyd, path)
|
| 242 |
+
print(f"Finished running {name} in {ws_pyd.env}.")
|
| 243 |
|
| 244 |
|
| 245 |
@contextlib.asynccontextmanager
|
lynxkite-app/web/src/index.css
CHANGED
|
@@ -235,6 +235,25 @@ body {
|
|
| 235 |
margin: 10px;
|
| 236 |
}
|
| 237 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
}
|
| 239 |
|
| 240 |
.directory {
|
|
|
|
| 235 |
margin: 10px;
|
| 236 |
}
|
| 237 |
}
|
| 238 |
+
|
| 239 |
+
.env-select {
|
| 240 |
+
background: transparent;
|
| 241 |
+
color: #39bcf3;
|
| 242 |
+
}
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
.params-expander {
|
| 246 |
+
font-size: 15px;
|
| 247 |
+
padding: 4px;
|
| 248 |
+
color: #000a;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
.flippy {
|
| 252 |
+
transition: transform 0.5s;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
.flippy.flippy-90 {
|
| 256 |
+
transform: rotate(-90deg);
|
| 257 |
}
|
| 258 |
|
| 259 |
.directory {
|
lynxkite-app/web/src/workspace/EnvironmentSelector.tsx
CHANGED
|
@@ -6,7 +6,7 @@ export default function EnvironmentSelector(props: {
|
|
| 6 |
return (
|
| 7 |
<>
|
| 8 |
<select
|
| 9 |
-
className="select w-full max-w-xs"
|
| 10 |
name="workspace-env"
|
| 11 |
value={props.value}
|
| 12 |
onChange={(evt) => props.onChange(evt.currentTarget.value)}
|
|
|
|
| 6 |
return (
|
| 7 |
<>
|
| 8 |
<select
|
| 9 |
+
className="env-select select w-full max-w-xs"
|
| 10 |
name="workspace-env"
|
| 11 |
value={props.value}
|
| 12 |
onChange={(evt) => props.onChange(evt.currentTarget.value)}
|
lynxkite-app/web/src/workspace/NodeSearch.tsx
CHANGED
|
@@ -25,9 +25,14 @@ export default function (props: {
|
|
| 25 |
}),
|
| 26 |
[props.boxes],
|
| 27 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
const hits: { item: OpsOp }[] = searchText
|
| 29 |
? fuse.search<OpsOp>(searchText)
|
| 30 |
-
:
|
| 31 |
const [selectedIndex, setSelectedIndex] = useState(0);
|
| 32 |
useEffect(() => searchBox.current.focus());
|
| 33 |
function typed(text: string) {
|
|
|
|
| 25 |
}),
|
| 26 |
[props.boxes],
|
| 27 |
);
|
| 28 |
+
const allOps = useMemo(() => {
|
| 29 |
+
const boxes = Object.values(props.boxes).map((box) => ({ item: box }));
|
| 30 |
+
boxes.sort((a, b) => a.item.name.localeCompare(b.item.name));
|
| 31 |
+
return boxes;
|
| 32 |
+
}, [props.boxes]);
|
| 33 |
const hits: { item: OpsOp }[] = searchText
|
| 34 |
? fuse.search<OpsOp>(searchText)
|
| 35 |
+
: allOps;
|
| 36 |
const [selectedIndex, setSelectedIndex] = useState(0);
|
| 37 |
useEffect(() => searchBox.current.focus());
|
| 38 |
function typed(text: string) {
|
lynxkite-app/web/src/workspace/Workspace.tsx
CHANGED
|
@@ -26,11 +26,11 @@ import { useParams } from "react-router";
|
|
| 26 |
import useSWR, { type Fetcher } from "swr";
|
| 27 |
import { WebsocketProvider } from "y-websocket";
|
| 28 |
// @ts-ignore
|
| 29 |
-
import ArrowBack from "~icons/tabler/arrow-back.jsx";
|
| 30 |
-
// @ts-ignore
|
| 31 |
import Atom from "~icons/tabler/atom.jsx";
|
| 32 |
// @ts-ignore
|
| 33 |
import Backspace from "~icons/tabler/backspace.jsx";
|
|
|
|
|
|
|
| 34 |
import type { Workspace, WorkspaceNode } from "../apiTypes.ts";
|
| 35 |
import favicon from "../assets/favicon.ico";
|
| 36 |
// import NodeWithTableView from './NodeWithTableView';
|
|
@@ -303,7 +303,7 @@ function LynxKiteFlow() {
|
|
| 303 |
<Backspace />
|
| 304 |
</a>
|
| 305 |
<a href={`/dir/${parentDir}`}>
|
| 306 |
-
<
|
| 307 |
</a>
|
| 308 |
</div>
|
| 309 |
</div>
|
|
|
|
| 26 |
import useSWR, { type Fetcher } from "swr";
|
| 27 |
import { WebsocketProvider } from "y-websocket";
|
| 28 |
// @ts-ignore
|
|
|
|
|
|
|
| 29 |
import Atom from "~icons/tabler/atom.jsx";
|
| 30 |
// @ts-ignore
|
| 31 |
import Backspace from "~icons/tabler/backspace.jsx";
|
| 32 |
+
// @ts-ignore
|
| 33 |
+
import Close from "~icons/tabler/x.jsx";
|
| 34 |
import type { Workspace, WorkspaceNode } from "../apiTypes.ts";
|
| 35 |
import favicon from "../assets/favicon.ico";
|
| 36 |
// import NodeWithTableView from './NodeWithTableView';
|
|
|
|
| 303 |
<Backspace />
|
| 304 |
</a>
|
| 305 |
<a href={`/dir/${parentDir}`}>
|
| 306 |
+
<Close />
|
| 307 |
</a>
|
| 308 |
</div>
|
| 309 |
</div>
|
lynxkite-app/web/src/workspace/nodes/NodeParameter.tsx
CHANGED
|
@@ -73,6 +73,10 @@ export default function NodeParameter({
|
|
| 73 |
value={value || ""}
|
| 74 |
onChange={(evt) => onChange(evt.currentTarget.value, { delay: 2 })}
|
| 75 |
onBlur={(evt) => onChange(evt.currentTarget.value, { delay: 0 })}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
/>
|
| 77 |
</>
|
| 78 |
)}
|
|
|
|
| 73 |
value={value || ""}
|
| 74 |
onChange={(evt) => onChange(evt.currentTarget.value, { delay: 2 })}
|
| 75 |
onBlur={(evt) => onChange(evt.currentTarget.value, { delay: 0 })}
|
| 76 |
+
onKeyDown={(evt) =>
|
| 77 |
+
evt.code === "Enter" &&
|
| 78 |
+
onChange(evt.currentTarget.value, { delay: 0 })
|
| 79 |
+
}
|
| 80 |
/>
|
| 81 |
</>
|
| 82 |
)}
|
lynxkite-app/web/src/workspace/nodes/NodeWithParams.tsx
CHANGED
|
@@ -1,4 +1,7 @@
|
|
| 1 |
import { useReactFlow } from "@xyflow/react";
|
|
|
|
|
|
|
|
|
|
| 2 |
import LynxKiteNode from "./LynxKiteNode";
|
| 3 |
import NodeGroupParameter from "./NodeGroupParameter";
|
| 4 |
import NodeParameter from "./NodeParameter";
|
|
@@ -8,6 +11,7 @@ export type UpdateOptions = { delay?: number };
|
|
| 8 |
function NodeWithParams(props: any) {
|
| 9 |
const reactFlow = useReactFlow();
|
| 10 |
const metaParams = props.data.meta?.params;
|
|
|
|
| 11 |
|
| 12 |
function setParam(name: string, newValue: any, opts: UpdateOptions) {
|
| 13 |
reactFlow.updateNodeData(props.id, (prevData: any) => ({
|
|
@@ -31,31 +35,40 @@ function NodeWithParams(props: any) {
|
|
| 31 |
|
| 32 |
return (
|
| 33 |
<LynxKiteNode {...props}>
|
| 34 |
-
{
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
setParam(name, value, opts || {})
|
| 42 |
-
}
|
| 43 |
-
deleteParam={(name: string, opts?: UpdateOptions) =>
|
| 44 |
-
deleteParam(name, opts || {})
|
| 45 |
-
}
|
| 46 |
-
/>
|
| 47 |
-
) : (
|
| 48 |
-
<NodeParameter
|
| 49 |
-
name={name}
|
| 50 |
-
key={name}
|
| 51 |
-
value={value}
|
| 52 |
-
meta={metaParams?.[name]}
|
| 53 |
-
onChange={(value: any, opts?: UpdateOptions) =>
|
| 54 |
-
setParam(name, value, opts || {})
|
| 55 |
-
}
|
| 56 |
-
/>
|
| 57 |
-
),
|
| 58 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
{props.children}
|
| 60 |
</LynxKiteNode>
|
| 61 |
);
|
|
|
|
| 1 |
import { useReactFlow } from "@xyflow/react";
|
| 2 |
+
import React from "react";
|
| 3 |
+
// @ts-ignore
|
| 4 |
+
import Triangle from "~icons/tabler/triangle-inverted-filled.jsx";
|
| 5 |
import LynxKiteNode from "./LynxKiteNode";
|
| 6 |
import NodeGroupParameter from "./NodeGroupParameter";
|
| 7 |
import NodeParameter from "./NodeParameter";
|
|
|
|
| 11 |
function NodeWithParams(props: any) {
|
| 12 |
const reactFlow = useReactFlow();
|
| 13 |
const metaParams = props.data.meta?.params;
|
| 14 |
+
const [collapsed, setCollapsed] = React.useState(props.collapsed);
|
| 15 |
|
| 16 |
function setParam(name: string, newValue: any, opts: UpdateOptions) {
|
| 17 |
reactFlow.updateNodeData(props.id, (prevData: any) => ({
|
|
|
|
| 35 |
|
| 36 |
return (
|
| 37 |
<LynxKiteNode {...props}>
|
| 38 |
+
{props.collapsed && (
|
| 39 |
+
<div
|
| 40 |
+
className="params-expander"
|
| 41 |
+
onClick={() => setCollapsed(!collapsed)}
|
| 42 |
+
>
|
| 43 |
+
<Triangle className={`flippy ${collapsed ? "flippy-90" : ""}`} />
|
| 44 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
)}
|
| 46 |
+
{!collapsed &&
|
| 47 |
+
params.map(([name, value]) =>
|
| 48 |
+
metaParams?.[name]?.type === "group" ? (
|
| 49 |
+
<NodeGroupParameter
|
| 50 |
+
key={name}
|
| 51 |
+
value={value}
|
| 52 |
+
meta={metaParams?.[name]}
|
| 53 |
+
setParam={(name: string, value: any, opts?: UpdateOptions) =>
|
| 54 |
+
setParam(name, value, opts || {})
|
| 55 |
+
}
|
| 56 |
+
deleteParam={(name: string, opts?: UpdateOptions) =>
|
| 57 |
+
deleteParam(name, opts || {})
|
| 58 |
+
}
|
| 59 |
+
/>
|
| 60 |
+
) : (
|
| 61 |
+
<NodeParameter
|
| 62 |
+
name={name}
|
| 63 |
+
key={name}
|
| 64 |
+
value={value}
|
| 65 |
+
meta={metaParams?.[name]}
|
| 66 |
+
onChange={(value: any, opts?: UpdateOptions) =>
|
| 67 |
+
setParam(name, value, opts || {})
|
| 68 |
+
}
|
| 69 |
+
/>
|
| 70 |
+
),
|
| 71 |
+
)}
|
| 72 |
{props.children}
|
| 73 |
</LynxKiteNode>
|
| 74 |
);
|
lynxkite-app/web/src/workspace/nodes/NodeWithVisualization.tsx
CHANGED
|
@@ -10,20 +10,28 @@ const NodeWithVisualization = (props: any) => {
|
|
| 10 |
if (!opts || !chartsRef.current) return;
|
| 11 |
chartsInstanceRef.current = echarts.init(chartsRef.current, null, {
|
| 12 |
renderer: "canvas",
|
| 13 |
-
width:
|
| 14 |
-
height:
|
| 15 |
});
|
| 16 |
chartsInstanceRef.current.setOption(opts);
|
| 17 |
-
const
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
return () => {
|
| 20 |
-
|
| 21 |
chartsInstanceRef.current?.dispose();
|
| 22 |
};
|
| 23 |
}, [props.data?.display?.value]);
|
|
|
|
|
|
|
| 24 |
return (
|
| 25 |
-
<NodeWithParams {...props}>
|
| 26 |
-
<div
|
| 27 |
</NodeWithParams>
|
| 28 |
);
|
| 29 |
};
|
|
|
|
| 10 |
if (!opts || !chartsRef.current) return;
|
| 11 |
chartsInstanceRef.current = echarts.init(chartsRef.current, null, {
|
| 12 |
renderer: "canvas",
|
| 13 |
+
width: "auto",
|
| 14 |
+
height: "auto",
|
| 15 |
});
|
| 16 |
chartsInstanceRef.current.setOption(opts);
|
| 17 |
+
const resizeObserver = new ResizeObserver(() => {
|
| 18 |
+
const e = chartsRef.current!;
|
| 19 |
+
e.style.padding = "1px";
|
| 20 |
+
chartsInstanceRef.current?.resize();
|
| 21 |
+
e.style.padding = "0";
|
| 22 |
+
});
|
| 23 |
+
const observed = chartsRef.current;
|
| 24 |
+
resizeObserver.observe(observed);
|
| 25 |
return () => {
|
| 26 |
+
resizeObserver.unobserve(observed);
|
| 27 |
chartsInstanceRef.current?.dispose();
|
| 28 |
};
|
| 29 |
}, [props.data?.display?.value]);
|
| 30 |
+
const nodeStyle = { display: "flex", flexDirection: "column" };
|
| 31 |
+
const vizStyle = { flex: 1 };
|
| 32 |
return (
|
| 33 |
+
<NodeWithParams nodeStyle={nodeStyle} collapsed {...props}>
|
| 34 |
+
<div style={vizStyle} ref={chartsRef} />
|
| 35 |
</NodeWithParams>
|
| 36 |
);
|
| 37 |
};
|
lynxkite-app/web/tests/basic.spec.ts
CHANGED
|
@@ -35,9 +35,9 @@ test("Box creation & deletion per env", async () => {
|
|
| 35 |
});
|
| 36 |
|
| 37 |
test("Delete multi-handle boxes", async () => {
|
| 38 |
-
await workspace.addBox("
|
| 39 |
-
await workspace.deleteBoxes(["
|
| 40 |
-
await expect(workspace.getBox("
|
| 41 |
});
|
| 42 |
|
| 43 |
test("Drag box", async () => {
|
|
|
|
| 35 |
});
|
| 36 |
|
| 37 |
test("Delete multi-handle boxes", async () => {
|
| 38 |
+
await workspace.addBox("NX › PageRank");
|
| 39 |
+
await workspace.deleteBoxes(["NX › PageRank 1"]);
|
| 40 |
+
await expect(workspace.getBox("NX › PageRank 1")).not.toBeVisible();
|
| 41 |
});
|
| 42 |
|
| 43 |
test("Drag box", async () => {
|
lynxkite-app/web/tests/errors.spec.ts
CHANGED
|
@@ -20,24 +20,24 @@ test.afterEach(async () => {
|
|
| 20 |
test("missing parameter", async () => {
|
| 21 |
// Test the correct error message is displayed when a required parameter is missing,
|
| 22 |
// and that the error message is removed when the parameter is filled.
|
| 23 |
-
await workspace.addBox("
|
| 24 |
-
const graphBox = workspace.getBox("
|
| 25 |
-
await graphBox.locator("
|
| 26 |
-
|
| 27 |
-
"invalid literal for int() with base 10: ''",
|
| 28 |
-
);
|
| 29 |
-
await graphBox.locator("input").fill("10");
|
| 30 |
await expect(graphBox.locator(".error")).not.toBeVisible();
|
| 31 |
});
|
| 32 |
|
| 33 |
test("unknown operation", async () => {
|
| 34 |
// Test that the correct error is displayed when the operation does not belong to
|
| 35 |
// the current environment.
|
| 36 |
-
await workspace.addBox("
|
|
|
|
|
|
|
| 37 |
await workspace.setEnv("LynxScribe");
|
| 38 |
-
const csvBox = workspace.getBox("
|
| 39 |
-
|
| 40 |
-
|
|
|
|
| 41 |
await workspace.setEnv("LynxKite Graph Analytics");
|
| 42 |
await expect(csvBox.locator(".error")).not.toBeVisible();
|
| 43 |
});
|
|
|
|
| 20 |
test("missing parameter", async () => {
|
| 21 |
// Test the correct error message is displayed when a required parameter is missing,
|
| 22 |
// and that the error message is removed when the parameter is filled.
|
| 23 |
+
await workspace.addBox("NX › Scale-Free Graph");
|
| 24 |
+
const graphBox = workspace.getBox("NX › Scale-Free Graph 1");
|
| 25 |
+
await expect(graphBox.locator(".error")).toHaveText("n is unset.");
|
| 26 |
+
await graphBox.getByLabel("n", { exact: true }).fill("10");
|
|
|
|
|
|
|
|
|
|
| 27 |
await expect(graphBox.locator(".error")).not.toBeVisible();
|
| 28 |
});
|
| 29 |
|
| 30 |
test("unknown operation", async () => {
|
| 31 |
// Test that the correct error is displayed when the operation does not belong to
|
| 32 |
// the current environment.
|
| 33 |
+
await workspace.addBox("NX › Scale-Free Graph");
|
| 34 |
+
const graphBox = workspace.getBox("NX › Scale-Free Graph 1");
|
| 35 |
+
await graphBox.getByLabel("n", { exact: true }).fill("10");
|
| 36 |
await workspace.setEnv("LynxScribe");
|
| 37 |
+
const csvBox = workspace.getBox("NX › Scale-Free Graph 1");
|
| 38 |
+
await expect(csvBox.locator(".error")).toHaveText(
|
| 39 |
+
'Operation "NX › Scale-Free Graph" not found.',
|
| 40 |
+
);
|
| 41 |
await workspace.setEnv("LynxKite Graph Analytics");
|
| 42 |
await expect(csvBox.locator(".error")).not.toBeVisible();
|
| 43 |
});
|
lynxkite-app/web/tests/examples.spec.ts
CHANGED
|
@@ -23,13 +23,13 @@ test.fail("AIMO example", async ({ page }) => {
|
|
| 23 |
await ws.expectErrorFree();
|
| 24 |
});
|
| 25 |
|
| 26 |
-
test
|
| 27 |
// Fails because of missing OPENAI_API_KEY
|
| 28 |
const ws = await Workspace.open(page, "LynxScribe demo");
|
| 29 |
await ws.expectErrorFree();
|
| 30 |
});
|
| 31 |
|
| 32 |
-
test
|
| 33 |
// Fails due to some issue with ChromaDB
|
| 34 |
const ws = await Workspace.open(page, "Graph RAG");
|
| 35 |
await ws.expectErrorFree(process.env.CI ? 2000 : 500);
|
|
|
|
| 23 |
await ws.expectErrorFree();
|
| 24 |
});
|
| 25 |
|
| 26 |
+
test("LynxScribe example", async ({ page }) => {
|
| 27 |
// Fails because of missing OPENAI_API_KEY
|
| 28 |
const ws = await Workspace.open(page, "LynxScribe demo");
|
| 29 |
await ws.expectErrorFree();
|
| 30 |
});
|
| 31 |
|
| 32 |
+
test("Graph RAG", async ({ page }) => {
|
| 33 |
// Fails due to some issue with ChromaDB
|
| 34 |
const ws = await Workspace.open(page, "Graph RAG");
|
| 35 |
await ws.expectErrorFree(process.env.CI ? 2000 : 500);
|
lynxkite-app/web/tests/graph_creation.spec.ts
CHANGED
|
@@ -9,9 +9,13 @@ test.beforeEach(async ({ browser }) => {
|
|
| 9 |
await browser.newPage(),
|
| 10 |
"graph_creation_spec_test",
|
| 11 |
);
|
| 12 |
-
await workspace.addBox("
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
await workspace.addBox("Create graph");
|
| 14 |
-
await workspace.connectBoxes("
|
| 15 |
});
|
| 16 |
|
| 17 |
test.afterEach(async () => {
|
|
|
|
| 9 |
await browser.newPage(),
|
| 10 |
"graph_creation_spec_test",
|
| 11 |
);
|
| 12 |
+
await workspace.addBox("NX › Scale-Free Graph");
|
| 13 |
+
await workspace
|
| 14 |
+
.getBox("NX › Scale-Free Graph 1")
|
| 15 |
+
.getByLabel("n", { exact: true })
|
| 16 |
+
.fill("10");
|
| 17 |
await workspace.addBox("Create graph");
|
| 18 |
+
await workspace.connectBoxes("NX › Scale-Free Graph 1", "Create graph 1");
|
| 19 |
});
|
| 20 |
|
| 21 |
test.afterEach(async () => {
|
lynxkite-app/web/tests/lynxkite.ts
CHANGED
|
@@ -65,7 +65,10 @@ export class Workspace {
|
|
| 65 |
// Some x,y offset, otherwise the box handle may fall outside the viewport.
|
| 66 |
await this.page.locator(".ws-name").click();
|
| 67 |
await this.page.keyboard.press("/");
|
| 68 |
-
await this.page
|
|
|
|
|
|
|
|
|
|
| 69 |
await expect(this.getBoxes()).toHaveCount(allBoxes.length + 1);
|
| 70 |
}
|
| 71 |
|
|
|
|
| 65 |
// Some x,y offset, otherwise the box handle may fall outside the viewport.
|
| 66 |
await this.page.locator(".ws-name").click();
|
| 67 |
await this.page.keyboard.press("/");
|
| 68 |
+
await this.page
|
| 69 |
+
.locator(".node-search")
|
| 70 |
+
.getByText(boxName, { exact: true })
|
| 71 |
+
.click();
|
| 72 |
await expect(this.getBoxes()).toHaveCount(allBoxes.length + 1);
|
| 73 |
}
|
| 74 |
|
lynxkite-core/src/lynxkite/core/executors/one_by_one.py
CHANGED
|
@@ -142,12 +142,12 @@ async def execute(ws: workspace.Workspace, catalog, cache=None):
|
|
| 142 |
key = make_cache_key((inputs, params))
|
| 143 |
if key not in cache:
|
| 144 |
result: ops.Result = op(*inputs, **params)
|
| 145 |
-
output = await await_if_needed(result.output)
|
| 146 |
-
cache[key] =
|
| 147 |
-
|
| 148 |
else:
|
| 149 |
result = op(*inputs, **params)
|
| 150 |
-
|
| 151 |
except Exception as e:
|
| 152 |
traceback.print_exc()
|
| 153 |
node.publish_error(e)
|
|
|
|
| 142 |
key = make_cache_key((inputs, params))
|
| 143 |
if key not in cache:
|
| 144 |
result: ops.Result = op(*inputs, **params)
|
| 145 |
+
result.output = await await_if_needed(result.output)
|
| 146 |
+
cache[key] = result
|
| 147 |
+
result = cache[key]
|
| 148 |
else:
|
| 149 |
result = op(*inputs, **params)
|
| 150 |
+
output = await await_if_needed(result.output)
|
| 151 |
except Exception as e:
|
| 152 |
traceback.print_exc()
|
| 153 |
node.publish_error(e)
|
lynxkite-core/src/lynxkite/core/ops.py
CHANGED
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
| 4 |
import enum
|
| 5 |
import functools
|
| 6 |
import inspect
|
|
|
|
| 7 |
import pydantic
|
| 8 |
import typing
|
| 9 |
from dataclasses import dataclass
|
|
@@ -123,6 +124,25 @@ def basic_outputs(*names):
|
|
| 123 |
return {name: Output(name=name, type=None) for name in names}
|
| 124 |
|
| 125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
class Op(BaseConfig):
|
| 127 |
func: typing.Callable = pydantic.Field(exclude=True)
|
| 128 |
name: str
|
|
@@ -136,12 +156,7 @@ class Op(BaseConfig):
|
|
| 136 |
# Convert parameters.
|
| 137 |
for p in params:
|
| 138 |
if p in self.params:
|
| 139 |
-
|
| 140 |
-
params[p] = int(params[p])
|
| 141 |
-
elif self.params[p].type is float:
|
| 142 |
-
params[p] = float(params[p])
|
| 143 |
-
elif isinstance(self.params[p].type, enum.EnumMeta):
|
| 144 |
-
params[p] = self.params[p].type[params[p]]
|
| 145 |
res = self.func(*inputs, **params)
|
| 146 |
if not isinstance(res, Result):
|
| 147 |
# Automatically wrap the result in a Result object, if it isn't already.
|
|
|
|
| 4 |
import enum
|
| 5 |
import functools
|
| 6 |
import inspect
|
| 7 |
+
import types
|
| 8 |
import pydantic
|
| 9 |
import typing
|
| 10 |
from dataclasses import dataclass
|
|
|
|
| 124 |
return {name: Output(name=name, type=None) for name in names}
|
| 125 |
|
| 126 |
|
| 127 |
+
def _param_to_type(name, value, type):
|
| 128 |
+
value = value or ""
|
| 129 |
+
if type is int:
|
| 130 |
+
assert value != "", f"{name} is unset."
|
| 131 |
+
return int(value)
|
| 132 |
+
if type is float:
|
| 133 |
+
assert value != "", f"{name} is unset."
|
| 134 |
+
return float(value)
|
| 135 |
+
if isinstance(type, enum.EnumMeta):
|
| 136 |
+
return type[value]
|
| 137 |
+
if isinstance(type, types.UnionType):
|
| 138 |
+
match type.__args__:
|
| 139 |
+
case (types.NoneType, type):
|
| 140 |
+
return None if value == "" else _param_to_type(name, value, type)
|
| 141 |
+
case (type, types.NoneType):
|
| 142 |
+
return None if value == "" else _param_to_type(name, value, type)
|
| 143 |
+
return value
|
| 144 |
+
|
| 145 |
+
|
| 146 |
class Op(BaseConfig):
|
| 147 |
func: typing.Callable = pydantic.Field(exclude=True)
|
| 148 |
name: str
|
|
|
|
| 156 |
# Convert parameters.
|
| 157 |
for p in params:
|
| 158 |
if p in self.params:
|
| 159 |
+
params[p] = _param_to_type(p, params[p], self.params[p].type)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
res = self.func(*inputs, **params)
|
| 161 |
if not isinstance(res, Result):
|
| 162 |
# Automatically wrap the result in a Result object, if it isn't already.
|
lynxkite-core/src/lynxkite/core/workspace.py
CHANGED
|
@@ -92,6 +92,7 @@ class Workspace(BaseConfig):
|
|
| 92 |
env: str = ""
|
| 93 |
nodes: list[WorkspaceNode] = dataclasses.field(default_factory=list)
|
| 94 |
edges: list[WorkspaceEdge] = dataclasses.field(default_factory=list)
|
|
|
|
| 95 |
|
| 96 |
|
| 97 |
async def execute(ws: Workspace):
|
|
|
|
| 92 |
env: str = ""
|
| 93 |
nodes: list[WorkspaceNode] = dataclasses.field(default_factory=list)
|
| 94 |
edges: list[WorkspaceEdge] = dataclasses.field(default_factory=list)
|
| 95 |
+
_crdt: pycrdt.Map
|
| 96 |
|
| 97 |
|
| 98 |
async def execute(ws: Workspace):
|
lynxkite-graph-analytics/src/lynxkite_graph_analytics/core.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
"""Graph analytics executor and data types."""
|
| 2 |
|
| 3 |
-
|
|
|
|
| 4 |
import dataclasses
|
| 5 |
import functools
|
| 6 |
import networkx as nx
|
|
@@ -134,54 +135,73 @@ def nx_node_attribute_func(name):
|
|
| 134 |
return decorator
|
| 135 |
|
| 136 |
|
| 137 |
-
def disambiguate_edges(ws):
|
| 138 |
"""If an input plug is connected to multiple edges, keep only the last edge."""
|
| 139 |
seen = set()
|
| 140 |
for edge in reversed(ws.edges):
|
| 141 |
if (edge.target, edge.targetHandle) in seen:
|
| 142 |
-
ws.edges.
|
|
|
|
|
|
|
|
|
|
| 143 |
seen.add((edge.target, edge.targetHandle))
|
| 144 |
|
| 145 |
|
| 146 |
@ops.register_executor(ENV)
|
| 147 |
-
async def execute(ws):
|
| 148 |
catalog: dict[str, ops.Op] = ops.CATALOGS[ws.env]
|
| 149 |
disambiguate_edges(ws)
|
| 150 |
outputs = {}
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
|
|
|
| 159 |
# All inputs for this node are ready, we can compute the output.
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
|
| 186 |
|
| 187 |
def df_for_frontend(df: pd.DataFrame, limit: int) -> pd.DataFrame:
|
|
|
|
| 1 |
"""Graph analytics executor and data types."""
|
| 2 |
|
| 3 |
+
import os
|
| 4 |
+
from lynxkite.core import ops, workspace
|
| 5 |
import dataclasses
|
| 6 |
import functools
|
| 7 |
import networkx as nx
|
|
|
|
| 135 |
return decorator
|
| 136 |
|
| 137 |
|
| 138 |
+
def disambiguate_edges(ws: workspace.Workspace):
|
| 139 |
"""If an input plug is connected to multiple edges, keep only the last edge."""
|
| 140 |
seen = set()
|
| 141 |
for edge in reversed(ws.edges):
|
| 142 |
if (edge.target, edge.targetHandle) in seen:
|
| 143 |
+
i = ws.edges.index(edge)
|
| 144 |
+
del ws.edges[i]
|
| 145 |
+
if hasattr(ws, "_crdt"):
|
| 146 |
+
del ws._crdt["edges"][i]
|
| 147 |
seen.add((edge.target, edge.targetHandle))
|
| 148 |
|
| 149 |
|
| 150 |
@ops.register_executor(ENV)
|
| 151 |
+
async def execute(ws: workspace.Workspace):
|
| 152 |
catalog: dict[str, ops.Op] = ops.CATALOGS[ws.env]
|
| 153 |
disambiguate_edges(ws)
|
| 154 |
outputs = {}
|
| 155 |
+
nodes = {node.id: node for node in ws.nodes}
|
| 156 |
+
todo = set(nodes.keys())
|
| 157 |
+
progress = True
|
| 158 |
+
while progress:
|
| 159 |
+
progress = False
|
| 160 |
+
for id in list(todo):
|
| 161 |
+
node = nodes[id]
|
| 162 |
+
input_nodes = [edge.source for edge in ws.edges if edge.target == id]
|
| 163 |
+
if all(input in outputs for input in input_nodes):
|
| 164 |
# All inputs for this node are ready, we can compute the output.
|
| 165 |
+
todo.remove(id)
|
| 166 |
+
progress = True
|
| 167 |
+
_execute_node(node, ws, catalog, outputs)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _execute_node(node, ws, catalog, outputs):
|
| 171 |
+
params = {**node.data.params}
|
| 172 |
+
op = catalog.get(node.data.title)
|
| 173 |
+
if not op:
|
| 174 |
+
node.publish_error("Operation not found in catalog")
|
| 175 |
+
return
|
| 176 |
+
node.publish_started()
|
| 177 |
+
input_map = {
|
| 178 |
+
edge.targetHandle: outputs[edge.source]
|
| 179 |
+
for edge in ws.edges
|
| 180 |
+
if edge.target == node.id
|
| 181 |
+
}
|
| 182 |
+
try:
|
| 183 |
+
# Convert inputs types to match operation signature.
|
| 184 |
+
inputs = []
|
| 185 |
+
for p in op.inputs.values():
|
| 186 |
+
if p.name not in input_map:
|
| 187 |
+
node.publish_error(f"Missing input: {p.name}")
|
| 188 |
+
return
|
| 189 |
+
x = input_map[p.name]
|
| 190 |
+
if p.type == nx.Graph and isinstance(x, Bundle):
|
| 191 |
+
x = x.to_nx()
|
| 192 |
+
elif p.type == Bundle and isinstance(x, nx.Graph):
|
| 193 |
+
x = Bundle.from_nx(x)
|
| 194 |
+
elif p.type == Bundle and isinstance(x, pd.DataFrame):
|
| 195 |
+
x = Bundle.from_df(x)
|
| 196 |
+
inputs.append(x)
|
| 197 |
+
result = op(*inputs, **params)
|
| 198 |
+
except Exception as e:
|
| 199 |
+
if os.environ.get("LYNXKITE_LOG_OP_ERRORS"):
|
| 200 |
+
traceback.print_exc()
|
| 201 |
+
node.publish_error(e)
|
| 202 |
+
return
|
| 203 |
+
outputs[node.id] = result.output
|
| 204 |
+
node.publish_result(result)
|
| 205 |
|
| 206 |
|
| 207 |
def df_for_frontend(df: pd.DataFrame, limit: int) -> pd.DataFrame:
|
lynxkite-graph-analytics/src/lynxkite_graph_analytics/lynxkite_ops.py
CHANGED
|
@@ -122,25 +122,6 @@ def import_osm(*, location: str):
|
|
| 122 |
return ox.graph.graph_from_place(location, network_type="drive")
|
| 123 |
|
| 124 |
|
| 125 |
-
@op("Create scale-free graph")
|
| 126 |
-
def create_scale_free_graph(*, nodes: int = 10):
|
| 127 |
-
"""Creates a scale-free graph with the given number of nodes."""
|
| 128 |
-
return nx.scale_free_graph(nodes)
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
@op("Compute PageRank")
|
| 132 |
-
@core.nx_node_attribute_func("pagerank")
|
| 133 |
-
def compute_pagerank(graph: nx.Graph, *, damping=0.85, iterations=100):
|
| 134 |
-
# TODO: This requires scipy to be installed.
|
| 135 |
-
return nx.pagerank(graph, alpha=damping, max_iter=iterations)
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
@op("Compute betweenness centrality")
|
| 139 |
-
@core.nx_node_attribute_func("betweenness_centrality")
|
| 140 |
-
def compute_betweenness_centrality(graph: nx.Graph, *, k=10):
|
| 141 |
-
return nx.betweenness_centrality(graph, k=k)
|
| 142 |
-
|
| 143 |
-
|
| 144 |
@op("Discard loop edges")
|
| 145 |
def discard_loop_edges(graph: nx.Graph):
|
| 146 |
graph = graph.copy()
|
|
|
|
| 122 |
return ox.graph.graph_from_place(location, network_type="drive")
|
| 123 |
|
| 124 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
@op("Discard loop edges")
|
| 126 |
def discard_loop_edges(graph: nx.Graph):
|
| 127 |
graph = graph.copy()
|
lynxkite-graph-analytics/src/lynxkite_graph_analytics/networkx_ops.py
CHANGED
|
@@ -1,13 +1,155 @@
|
|
| 1 |
"""Automatically wraps all NetworkX functions as LynxKite operations."""
|
| 2 |
|
|
|
|
|
|
|
| 3 |
from lynxkite.core import ops
|
| 4 |
import functools
|
| 5 |
import inspect
|
| 6 |
import networkx as nx
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
ENV = "LynxKite Graph Analytics"
|
| 9 |
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
def wrapped(name: str, func):
|
| 12 |
@functools.wraps(func)
|
| 13 |
def wrapper(*args, **kwargs):
|
|
@@ -15,48 +157,118 @@ def wrapped(name: str, func):
|
|
| 15 |
if v == "None":
|
| 16 |
kwargs[k] = None
|
| 17 |
res = func(*args, **kwargs)
|
|
|
|
| 18 |
if isinstance(res, nx.Graph):
|
| 19 |
return res
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
return wrapper
|
| 26 |
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
def register_networkx(env: str):
|
| 29 |
cat = ops.CATALOGS.setdefault(env, {})
|
|
|
|
| 30 |
for name, func in nx.__dict__.items():
|
| 31 |
if hasattr(func, "graphs"):
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
| 33 |
inputs = {k: ops.Input(name=k, type=nx.Graph) for k in func.graphs}
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
str(param.default)
|
| 38 |
-
if type(param.default) in [str, int, float]
|
| 39 |
-
else None,
|
| 40 |
-
param.annotation,
|
| 41 |
-
)
|
| 42 |
-
for name, param in sig.parameters.items()
|
| 43 |
-
if name not in ["G", "backend", "backend_kwargs", "create_using"]
|
| 44 |
-
}
|
| 45 |
-
for p in params.values():
|
| 46 |
-
if not p.type:
|
| 47 |
-
# Guess the type based on the name.
|
| 48 |
-
if len(p.name) == 1:
|
| 49 |
-
p.type = int
|
| 50 |
-
name = "NX › " + name.replace("_", " ").title()
|
| 51 |
op = ops.Op(
|
| 52 |
func=wrapped(name, func),
|
| 53 |
-
name=
|
| 54 |
params=params,
|
| 55 |
inputs=inputs,
|
| 56 |
outputs={"output": ops.Output(name="output", type=nx.Graph)},
|
| 57 |
type="basic",
|
| 58 |
)
|
| 59 |
-
cat[
|
|
|
|
|
|
|
| 60 |
|
| 61 |
|
| 62 |
register_networkx(ENV)
|
|
|
|
| 1 |
"""Automatically wraps all NetworkX functions as LynxKite operations."""
|
| 2 |
|
| 3 |
+
import collections
|
| 4 |
+
import types
|
| 5 |
from lynxkite.core import ops
|
| 6 |
import functools
|
| 7 |
import inspect
|
| 8 |
import networkx as nx
|
| 9 |
+
import re
|
| 10 |
+
|
| 11 |
+
import pandas as pd
|
| 12 |
|
| 13 |
ENV = "LynxKite Graph Analytics"
|
| 14 |
|
| 15 |
|
| 16 |
+
class UnsupportedParameterType(Exception):
|
| 17 |
+
pass
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
_UNSUPPORTED = object()
|
| 21 |
+
_SKIP = object()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def doc_to_type(name: str, type_hint: str) -> type:
|
| 25 |
+
type_hint = type_hint.lower()
|
| 26 |
+
type_hint = re.sub("[(][^)]+[)]", "", type_hint).strip().strip(".")
|
| 27 |
+
if " " in name or "http" in name:
|
| 28 |
+
return _UNSUPPORTED # Not a parameter type.
|
| 29 |
+
if type_hint.endswith(", optional"):
|
| 30 |
+
w = doc_to_type(name, type_hint.removesuffix(", optional").strip())
|
| 31 |
+
if w is _UNSUPPORTED:
|
| 32 |
+
return _SKIP
|
| 33 |
+
return w if w is _SKIP else w | None
|
| 34 |
+
if type_hint in [
|
| 35 |
+
"a digraph or multidigraph",
|
| 36 |
+
"a graph g",
|
| 37 |
+
"graph",
|
| 38 |
+
"graphs",
|
| 39 |
+
"networkx graph instance",
|
| 40 |
+
"networkx graph",
|
| 41 |
+
"networkx undirected graph",
|
| 42 |
+
"nx.graph",
|
| 43 |
+
"undirected graph",
|
| 44 |
+
"undirected networkx graph",
|
| 45 |
+
] or type_hint.startswith("networkx graph"):
|
| 46 |
+
return nx.Graph
|
| 47 |
+
elif type_hint in [
|
| 48 |
+
"digraph-like",
|
| 49 |
+
"digraph",
|
| 50 |
+
"directed graph",
|
| 51 |
+
"networkx digraph",
|
| 52 |
+
"networkx directed graph",
|
| 53 |
+
"nx.digraph",
|
| 54 |
+
]:
|
| 55 |
+
return nx.DiGraph
|
| 56 |
+
elif type_hint == "node":
|
| 57 |
+
return _UNSUPPORTED
|
| 58 |
+
elif type_hint == '"node (optional)"':
|
| 59 |
+
return _SKIP
|
| 60 |
+
elif type_hint == '"edge"':
|
| 61 |
+
return _UNSUPPORTED
|
| 62 |
+
elif type_hint == '"edge (optional)"':
|
| 63 |
+
return _SKIP
|
| 64 |
+
elif type_hint in ["class", "data type"]:
|
| 65 |
+
return _UNSUPPORTED
|
| 66 |
+
elif type_hint in ["string", "str", "node label"]:
|
| 67 |
+
return str
|
| 68 |
+
elif type_hint in ["string or none", "none or string", "string, or none"]:
|
| 69 |
+
return str | None
|
| 70 |
+
elif type_hint in ["int", "integer"]:
|
| 71 |
+
return int
|
| 72 |
+
elif type_hint in ["bool", "boolean"]:
|
| 73 |
+
return bool
|
| 74 |
+
elif type_hint == "tuple":
|
| 75 |
+
return _UNSUPPORTED
|
| 76 |
+
elif type_hint == "set":
|
| 77 |
+
return _UNSUPPORTED
|
| 78 |
+
elif type_hint == "list of floats":
|
| 79 |
+
return _UNSUPPORTED
|
| 80 |
+
elif type_hint == "list of floats or float":
|
| 81 |
+
return float
|
| 82 |
+
elif type_hint in ["dict", "dictionary"]:
|
| 83 |
+
return _UNSUPPORTED
|
| 84 |
+
elif type_hint == "scalar or dictionary":
|
| 85 |
+
return float
|
| 86 |
+
elif type_hint == "none or dict":
|
| 87 |
+
return _SKIP
|
| 88 |
+
elif type_hint in ["function", "callable"]:
|
| 89 |
+
return _UNSUPPORTED
|
| 90 |
+
elif type_hint in [
|
| 91 |
+
"collection",
|
| 92 |
+
"container of nodes",
|
| 93 |
+
"list of nodes",
|
| 94 |
+
]:
|
| 95 |
+
return _UNSUPPORTED
|
| 96 |
+
elif type_hint in [
|
| 97 |
+
"container",
|
| 98 |
+
"generator",
|
| 99 |
+
"iterable",
|
| 100 |
+
"iterator",
|
| 101 |
+
"list or iterable container",
|
| 102 |
+
"list or iterable",
|
| 103 |
+
"list or set",
|
| 104 |
+
"list or tuple",
|
| 105 |
+
"list",
|
| 106 |
+
]:
|
| 107 |
+
return _UNSUPPORTED
|
| 108 |
+
elif type_hint == "generator of sets":
|
| 109 |
+
return _UNSUPPORTED
|
| 110 |
+
elif type_hint == "dict or a set of 2 or 3 tuples":
|
| 111 |
+
return _UNSUPPORTED
|
| 112 |
+
elif type_hint == "set of 2 or 3 tuples":
|
| 113 |
+
return _UNSUPPORTED
|
| 114 |
+
elif type_hint == "none, string or function":
|
| 115 |
+
return str | None
|
| 116 |
+
elif type_hint == "string or function" and name == "weight":
|
| 117 |
+
return str
|
| 118 |
+
elif type_hint == "integer, float, or none":
|
| 119 |
+
return float | None
|
| 120 |
+
elif type_hint in [
|
| 121 |
+
"float",
|
| 122 |
+
"int or float",
|
| 123 |
+
"integer or float",
|
| 124 |
+
"integer, float",
|
| 125 |
+
"number",
|
| 126 |
+
"numeric",
|
| 127 |
+
"real",
|
| 128 |
+
"scalar",
|
| 129 |
+
]:
|
| 130 |
+
return float
|
| 131 |
+
elif type_hint in ["integer or none", "int or none"]:
|
| 132 |
+
return int | None
|
| 133 |
+
elif name == "seed":
|
| 134 |
+
return int | None
|
| 135 |
+
elif name == "weight":
|
| 136 |
+
return str
|
| 137 |
+
elif type_hint == "object":
|
| 138 |
+
return _UNSUPPORTED
|
| 139 |
+
return _SKIP
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def types_from_doc(doc: str) -> dict[str, type]:
|
| 143 |
+
types = {}
|
| 144 |
+
for line in doc.splitlines():
|
| 145 |
+
if ":" in line:
|
| 146 |
+
a, b = line.split(":", 1)
|
| 147 |
+
for a in a.split(","):
|
| 148 |
+
a = a.strip()
|
| 149 |
+
types[a] = doc_to_type(a, b)
|
| 150 |
+
return types
|
| 151 |
+
|
| 152 |
+
|
| 153 |
def wrapped(name: str, func):
|
| 154 |
@functools.wraps(func)
|
| 155 |
def wrapper(*args, **kwargs):
|
|
|
|
| 157 |
if v == "None":
|
| 158 |
kwargs[k] = None
|
| 159 |
res = func(*args, **kwargs)
|
| 160 |
+
# Figure out what the returned value is.
|
| 161 |
if isinstance(res, nx.Graph):
|
| 162 |
return res
|
| 163 |
+
if isinstance(res, types.GeneratorType):
|
| 164 |
+
res = list(res)
|
| 165 |
+
if name in ["articulation_points"]:
|
| 166 |
+
graph = args[0].copy()
|
| 167 |
+
nx.set_node_attributes(graph, 0, name=name)
|
| 168 |
+
nx.set_node_attributes(graph, {r: 1 for r in res}, name=name)
|
| 169 |
+
return graph
|
| 170 |
+
if isinstance(res, collections.abc.Sized):
|
| 171 |
+
if len(res) == 0:
|
| 172 |
+
return pd.DataFrame()
|
| 173 |
+
for a in args:
|
| 174 |
+
if isinstance(a, nx.Graph):
|
| 175 |
+
if a.number_of_nodes() == len(res):
|
| 176 |
+
graph = a.copy()
|
| 177 |
+
nx.set_node_attributes(graph, values=res, name=name)
|
| 178 |
+
return graph
|
| 179 |
+
if a.number_of_edges() == len(res):
|
| 180 |
+
graph = a.copy()
|
| 181 |
+
nx.set_edge_attributes(graph, values=res, name=name)
|
| 182 |
+
return graph
|
| 183 |
+
return pd.DataFrame({name: res})
|
| 184 |
+
return pd.DataFrame({name: [res]})
|
| 185 |
|
| 186 |
return wrapper
|
| 187 |
|
| 188 |
|
| 189 |
+
def _get_params(func) -> dict | None:
|
| 190 |
+
sig = inspect.signature(func)
|
| 191 |
+
# Get types from docstring.
|
| 192 |
+
types = types_from_doc(func.__doc__)
|
| 193 |
+
# Always hide these.
|
| 194 |
+
for k in ["backend", "backend_kwargs", "create_using"]:
|
| 195 |
+
types[k] = _SKIP
|
| 196 |
+
# Add in types based on signature.
|
| 197 |
+
for k, param in sig.parameters.items():
|
| 198 |
+
if k in types:
|
| 199 |
+
continue
|
| 200 |
+
if param.annotation is not param.empty:
|
| 201 |
+
types[k] = param.annotation
|
| 202 |
+
if k in ["i", "j", "n"]:
|
| 203 |
+
types[k] = int
|
| 204 |
+
params = {}
|
| 205 |
+
for name, param in sig.parameters.items():
|
| 206 |
+
_type = types.get(name, _UNSUPPORTED)
|
| 207 |
+
if _type is _UNSUPPORTED:
|
| 208 |
+
raise UnsupportedParameterType(name)
|
| 209 |
+
if _type is _SKIP or _type in [nx.Graph, nx.DiGraph]:
|
| 210 |
+
continue
|
| 211 |
+
params[name] = ops.Parameter.basic(
|
| 212 |
+
name=name,
|
| 213 |
+
default=str(param.default)
|
| 214 |
+
if type(param.default) in [str, int, float]
|
| 215 |
+
else None,
|
| 216 |
+
type=_type,
|
| 217 |
+
)
|
| 218 |
+
return params
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
_REPLACEMENTS = [
|
| 222 |
+
("Barabasi Albert", "Barabasi–Albert"),
|
| 223 |
+
("Bellman Ford", "Bellman–Ford"),
|
| 224 |
+
("Bethe Hessian", "Bethe–Hessian"),
|
| 225 |
+
("Bfs", "BFS"),
|
| 226 |
+
("Dag ", "DAG "),
|
| 227 |
+
("Dfs", "DFS"),
|
| 228 |
+
("Dorogovtsev Goltsev Mendes", "Dorogovtsev–Goltsev–Mendes"),
|
| 229 |
+
("Erdos Renyi", "Erdos–Renyi"),
|
| 230 |
+
("Floyd Warshall", "Floyd–Warshall"),
|
| 231 |
+
("Gnc", "G(n,c)"),
|
| 232 |
+
("Gnm", "G(n,m)"),
|
| 233 |
+
("Gnp", "G(n,p)"),
|
| 234 |
+
("Gnr", "G(n,r)"),
|
| 235 |
+
("Havel Hakimi", "Havel–Hakimi"),
|
| 236 |
+
("Hkn", "H(k,n)"),
|
| 237 |
+
("Hnm", "H(n,m)"),
|
| 238 |
+
("Kl ", "KL "),
|
| 239 |
+
("Moebius Kantor", "Moebius–Kantor"),
|
| 240 |
+
("Pagerank", "PageRank"),
|
| 241 |
+
("Scale Free", "Scale-Free"),
|
| 242 |
+
("Vf2Pp", "VF2++"),
|
| 243 |
+
("Watts Strogatz", "Watts–Strogatz"),
|
| 244 |
+
("Weisfeiler Lehman", "Weisfeiler–Lehman"),
|
| 245 |
+
]
|
| 246 |
+
|
| 247 |
+
|
| 248 |
def register_networkx(env: str):
|
| 249 |
cat = ops.CATALOGS.setdefault(env, {})
|
| 250 |
+
counter = 0
|
| 251 |
for name, func in nx.__dict__.items():
|
| 252 |
if hasattr(func, "graphs"):
|
| 253 |
+
try:
|
| 254 |
+
params = _get_params(func)
|
| 255 |
+
except UnsupportedParameterType:
|
| 256 |
+
continue
|
| 257 |
inputs = {k: ops.Input(name=k, type=nx.Graph) for k in func.graphs}
|
| 258 |
+
nicename = "NX › " + name.replace("_", " ").title()
|
| 259 |
+
for a, b in _REPLACEMENTS:
|
| 260 |
+
nicename = nicename.replace(a, b)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
op = ops.Op(
|
| 262 |
func=wrapped(name, func),
|
| 263 |
+
name=nicename,
|
| 264 |
params=params,
|
| 265 |
inputs=inputs,
|
| 266 |
outputs={"output": ops.Output(name="output", type=nx.Graph)},
|
| 267 |
type="basic",
|
| 268 |
)
|
| 269 |
+
cat[nicename] = op
|
| 270 |
+
counter += 1
|
| 271 |
+
print(f"Registered {counter} NetworkX operations.")
|
| 272 |
|
| 273 |
|
| 274 |
register_networkx(ENV)
|
lynxkite-graph-analytics/tests/test_lynxkite_ops.py
CHANGED
|
@@ -77,13 +77,13 @@ async def test_execute_operation_inputs_correct_cast():
|
|
| 77 |
)
|
| 78 |
ws.edges = [
|
| 79 |
workspace.WorkspaceEdge(
|
| 80 |
-
id="1", source="1", target="2", sourceHandle="
|
| 81 |
),
|
| 82 |
workspace.WorkspaceEdge(
|
| 83 |
-
id="2", source="2", target="3", sourceHandle="
|
| 84 |
),
|
| 85 |
workspace.WorkspaceEdge(
|
| 86 |
-
id="3", source="3", target="4", sourceHandle="
|
| 87 |
),
|
| 88 |
]
|
| 89 |
|
|
@@ -92,5 +92,73 @@ async def test_execute_operation_inputs_correct_cast():
|
|
| 92 |
assert all([node.data.error is None for node in ws.nodes])
|
| 93 |
|
| 94 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
if __name__ == "__main__":
|
| 96 |
pytest.main()
|
|
|
|
| 77 |
)
|
| 78 |
ws.edges = [
|
| 79 |
workspace.WorkspaceEdge(
|
| 80 |
+
id="1", source="1", target="2", sourceHandle="output", targetHandle="graph"
|
| 81 |
),
|
| 82 |
workspace.WorkspaceEdge(
|
| 83 |
+
id="2", source="2", target="3", sourceHandle="output", targetHandle="bundle"
|
| 84 |
),
|
| 85 |
workspace.WorkspaceEdge(
|
| 86 |
+
id="3", source="3", target="4", sourceHandle="output", targetHandle="bundle"
|
| 87 |
),
|
| 88 |
]
|
| 89 |
|
|
|
|
| 92 |
assert all([node.data.error is None for node in ws.nodes])
|
| 93 |
|
| 94 |
|
| 95 |
+
async def test_multiple_inputs():
|
| 96 |
+
"""Make sure each input goes to the right argument."""
|
| 97 |
+
op = ops.op_registration("test")
|
| 98 |
+
|
| 99 |
+
@op("One")
|
| 100 |
+
def one():
|
| 101 |
+
return 1
|
| 102 |
+
|
| 103 |
+
@op("Two")
|
| 104 |
+
def two():
|
| 105 |
+
return 2
|
| 106 |
+
|
| 107 |
+
@op("Smaller?", view="visualization")
|
| 108 |
+
def is_smaller(a, b):
|
| 109 |
+
return a < b
|
| 110 |
+
|
| 111 |
+
ws = workspace.Workspace(env="test")
|
| 112 |
+
ws.nodes.append(
|
| 113 |
+
workspace.WorkspaceNode(
|
| 114 |
+
id="one",
|
| 115 |
+
type="cool",
|
| 116 |
+
data=workspace.WorkspaceNodeData(title="One", params={}),
|
| 117 |
+
position=workspace.Position(x=0, y=0),
|
| 118 |
+
)
|
| 119 |
+
)
|
| 120 |
+
ws.nodes.append(
|
| 121 |
+
workspace.WorkspaceNode(
|
| 122 |
+
id="two",
|
| 123 |
+
type="cool",
|
| 124 |
+
data=workspace.WorkspaceNodeData(title="Two", params={}),
|
| 125 |
+
position=workspace.Position(x=100, y=0),
|
| 126 |
+
)
|
| 127 |
+
)
|
| 128 |
+
ws.nodes.append(
|
| 129 |
+
workspace.WorkspaceNode(
|
| 130 |
+
id="smaller",
|
| 131 |
+
type="cool",
|
| 132 |
+
data=workspace.WorkspaceNodeData(title="Smaller?", params={}),
|
| 133 |
+
position=workspace.Position(x=200, y=0),
|
| 134 |
+
)
|
| 135 |
+
)
|
| 136 |
+
ws.edges = [
|
| 137 |
+
workspace.WorkspaceEdge(
|
| 138 |
+
id="one",
|
| 139 |
+
source="one",
|
| 140 |
+
target="smaller",
|
| 141 |
+
sourceHandle="output",
|
| 142 |
+
targetHandle="a",
|
| 143 |
+
),
|
| 144 |
+
workspace.WorkspaceEdge(
|
| 145 |
+
id="two",
|
| 146 |
+
source="two",
|
| 147 |
+
target="smaller",
|
| 148 |
+
sourceHandle="output",
|
| 149 |
+
targetHandle="b",
|
| 150 |
+
),
|
| 151 |
+
]
|
| 152 |
+
|
| 153 |
+
await execute(ws)
|
| 154 |
+
|
| 155 |
+
assert ws.nodes[-1].data.display is True
|
| 156 |
+
# Flip the inputs.
|
| 157 |
+
ws.edges[0].targetHandle = "b"
|
| 158 |
+
ws.edges[1].targetHandle = "a"
|
| 159 |
+
await execute(ws)
|
| 160 |
+
assert ws.nodes[-1].data.display is False
|
| 161 |
+
|
| 162 |
+
|
| 163 |
if __name__ == "__main__":
|
| 164 |
pytest.main()
|
lynxkite-lynxscribe/README.md
CHANGED
|
@@ -5,7 +5,7 @@ LynxKite UI for building LynxScribe chat applications. Also runs the chat applic
|
|
| 5 |
To run a chat UI for LynxScribe workspaces:
|
| 6 |
|
| 7 |
```bash
|
| 8 |
-
WEBUI_AUTH=false OPENAI_API_BASE_URL=http://localhost:8000/api/service/
|
| 9 |
```
|
| 10 |
|
| 11 |
Or use [Lynx WebUI](https://github.com/biggraph/lynx-webui/) instead of Open WebUI.
|
|
|
|
| 5 |
To run a chat UI for LynxScribe workspaces:
|
| 6 |
|
| 7 |
```bash
|
| 8 |
+
WEBUI_AUTH=false OPENAI_API_BASE_URL=http://localhost:8000/api/service/lynxkite_lynxscribe uvx open-webui serve
|
| 9 |
```
|
| 10 |
|
| 11 |
Or use [Lynx WebUI](https://github.com/biggraph/lynx-webui/) instead of Open WebUI.
|
lynxkite-lynxscribe/src/lynxkite_lynxscribe/lynxscribe_ops.py
CHANGED
|
@@ -2,6 +2,8 @@
|
|
| 2 |
LynxScribe configuration and testing in LynxKite.
|
| 3 |
"""
|
| 4 |
|
|
|
|
|
|
|
| 5 |
from lynxscribe.core.llm.base import get_llm_engine
|
| 6 |
from lynxscribe.core.vector_store.base import get_vector_store
|
| 7 |
from lynxscribe.common.config import load_config
|
|
@@ -221,10 +223,8 @@ def view(input):
|
|
| 221 |
|
| 222 |
|
| 223 |
async def get_chat_api(ws):
|
| 224 |
-
import pathlib
|
| 225 |
from lynxkite.core import workspace
|
| 226 |
|
| 227 |
-
DATA_PATH = pathlib.Path.cwd() / "data"
|
| 228 |
path = DATA_PATH / ws
|
| 229 |
assert path.is_relative_to(DATA_PATH)
|
| 230 |
assert path.exists(), f"Workspace {path} does not exist"
|
|
@@ -285,18 +285,19 @@ async def api_service_get(request):
|
|
| 285 |
return {"error": "Not found"}
|
| 286 |
|
| 287 |
|
|
|
|
|
|
|
|
|
|
| 288 |
def get_lynxscribe_workspaces():
|
| 289 |
-
import pathlib
|
| 290 |
from lynxkite.core import workspace
|
| 291 |
|
| 292 |
-
DATA_DIR = pathlib.Path.cwd() / "data"
|
| 293 |
workspaces = []
|
| 294 |
-
for p in
|
| 295 |
if p.is_file():
|
| 296 |
try:
|
| 297 |
ws = workspace.load(p)
|
| 298 |
if ws.env == ENV:
|
| 299 |
-
workspaces.append(p.relative_to(
|
| 300 |
except Exception:
|
| 301 |
pass # Ignore files that are not valid workspaces.
|
| 302 |
workspaces.sort()
|
|
|
|
| 2 |
LynxScribe configuration and testing in LynxKite.
|
| 3 |
"""
|
| 4 |
|
| 5 |
+
import os
|
| 6 |
+
import pathlib
|
| 7 |
from lynxscribe.core.llm.base import get_llm_engine
|
| 8 |
from lynxscribe.core.vector_store.base import get_vector_store
|
| 9 |
from lynxscribe.common.config import load_config
|
|
|
|
| 223 |
|
| 224 |
|
| 225 |
async def get_chat_api(ws):
|
|
|
|
| 226 |
from lynxkite.core import workspace
|
| 227 |
|
|
|
|
| 228 |
path = DATA_PATH / ws
|
| 229 |
assert path.is_relative_to(DATA_PATH)
|
| 230 |
assert path.exists(), f"Workspace {path} does not exist"
|
|
|
|
| 285 |
return {"error": "Not found"}
|
| 286 |
|
| 287 |
|
| 288 |
+
DATA_PATH = pathlib.Path(os.environ.get("LYNXKITE_DATA", "lynxkite_data"))
|
| 289 |
+
|
| 290 |
+
|
| 291 |
def get_lynxscribe_workspaces():
|
|
|
|
| 292 |
from lynxkite.core import workspace
|
| 293 |
|
|
|
|
| 294 |
workspaces = []
|
| 295 |
+
for p in DATA_PATH.glob("**/*"):
|
| 296 |
if p.is_file():
|
| 297 |
try:
|
| 298 |
ws = workspace.load(p)
|
| 299 |
if ws.env == ENV:
|
| 300 |
+
workspaces.append(p.relative_to(DATA_PATH))
|
| 301 |
except Exception:
|
| 302 |
pass # Ignore files that are not valid workspaces.
|
| 303 |
workspaces.sort()
|