darabos commited on
Commit
f2dea93
·
unverified ·
2 Parent(s): 7baf2a1 ffaf155

Merge pull request #85 from biggraph/darabos-nx

Browse files
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
- : Object.values(props.boxes).map((box) => ({ item: box }));
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
- <ArrowBack />
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
- {params.map(([name, value]) =>
35
- metaParams?.[name]?.type === "group" ? (
36
- <NodeGroupParameter
37
- key={name}
38
- value={value}
39
- meta={metaParams?.[name]}
40
- setParam={(name: string, value: any, opts?: UpdateOptions) =>
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: 800,
14
- height: 800,
15
  });
16
  chartsInstanceRef.current.setOption(opts);
17
- const onResize = () => chartsInstanceRef.current?.resize();
18
- window.addEventListener("resize", onResize);
 
 
 
 
 
 
19
  return () => {
20
- window.removeEventListener("resize", onResize);
21
  chartsInstanceRef.current?.dispose();
22
  };
23
  }, [props.data?.display?.value]);
 
 
24
  return (
25
- <NodeWithParams {...props}>
26
- <div className="box" draggable={false} ref={chartsRef} />
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("Compute PageRank");
39
- await workspace.deleteBoxes(["Compute PageRank 1"]);
40
- await expect(workspace.getBox("Compute PageRank 1")).not.toBeVisible();
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("Create scale-free graph");
24
- const graphBox = workspace.getBox("Create scale-free graph 1");
25
- await graphBox.locator("input").fill("");
26
- expect(await graphBox.locator(".error").innerText()).toBe(
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("Create scale-free graph");
 
 
37
  await workspace.setEnv("LynxScribe");
38
- const csvBox = workspace.getBox("Create scale-free graph 1");
39
- const errorText = await csvBox.locator(".error").innerText();
40
- expect(errorText).toBe('Operation "Create scale-free graph" not found.');
 
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.fail("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.fail("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);
 
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("Create scale-free graph");
 
 
 
 
13
  await workspace.addBox("Create graph");
14
- await workspace.connectBoxes("Create scale-free graph 1", "Create graph 1");
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.locator(".node-search").getByText(boxName).click();
 
 
 
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] = output
147
- output = 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)
 
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
- if self.params[p].type is int:
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
- from lynxkite.core import ops
 
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.remove(edge)
 
 
 
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
- failed = 0
152
- while len(outputs) + failed < len(ws.nodes):
153
- for node in ws.nodes:
154
- if node.id in outputs:
155
- continue
156
- # TODO: Take the input/output handles into account.
157
- inputs = [edge.source for edge in ws.edges if edge.target == node.id]
158
- if all(input in outputs for input in inputs):
 
159
  # All inputs for this node are ready, we can compute the output.
160
- inputs = [outputs[input] for input in inputs]
161
- params = {**node.data.params}
162
- op = catalog.get(node.data.title)
163
- if not op:
164
- node.publish_error("Operation not found in catalog")
165
- failed += 1
166
- continue
167
- node.publish_started()
168
- try:
169
- # Convert inputs types to match operation signature.
170
- for i, (x, p) in enumerate(zip(inputs, op.inputs.values())):
171
- if p.type == nx.Graph and isinstance(x, Bundle):
172
- inputs[i] = x.to_nx()
173
- elif p.type == Bundle and isinstance(x, nx.Graph):
174
- inputs[i] = Bundle.from_nx(x)
175
- elif p.type == Bundle and isinstance(x, pd.DataFrame):
176
- inputs[i] = Bundle.from_df(x)
177
- result = op(*inputs, **params)
178
- except Exception as e:
179
- traceback.print_exc()
180
- node.publish_error(e)
181
- failed += 1
182
- continue
183
- outputs[node.id] = result.output
184
- node.publish_result(result)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # Otherwise it's a node attribute.
21
- graph = args[0].copy()
22
- nx.set_node_attributes(graph, values=res, name=name)
23
- return graph
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- sig = inspect.signature(func)
 
 
 
33
  inputs = {k: ops.Input(name=k, type=nx.Graph) for k in func.graphs}
34
- params = {
35
- name: ops.Parameter.basic(
36
- name,
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=name,
54
  params=params,
55
  inputs=inputs,
56
  outputs={"output": ops.Output(name="output", type=nx.Graph)},
57
  type="basic",
58
  )
59
- cat[name] = op
 
 
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="1", targetHandle="2"
81
  ),
82
  workspace.WorkspaceEdge(
83
- id="2", source="2", target="3", sourceHandle="2", targetHandle="3"
84
  ),
85
  workspace.WorkspaceEdge(
86
- id="3", source="3", target="4", sourceHandle="3", targetHandle="4"
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/lynxscribe/lynxscribe_ops uvx open-webui serve
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 DATA_DIR.glob("**/*"):
295
  if p.is_file():
296
  try:
297
  ws = workspace.load(p)
298
  if ws.env == ENV:
299
- workspaces.append(p.relative_to(DATA_DIR))
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()