Spaces:
Running
Running
Merge branch 'main' into darabos-rdkit
Browse files- .gitignore +1 -1
- lynxkite-app/.gitignore +4 -3
- lynxkite-app/web/src/index.css +82 -0
- lynxkite-app/web/src/workspace/Workspace.tsx +2 -0
- lynxkite-app/web/src/workspace/nodes/GraphCreationNode.tsx +308 -0
- lynxkite-app/web/src/workspace/nodes/Table.tsx +1 -1
- lynxkite-app/web/tests/directory.spec.ts +1 -1
- lynxkite-app/web/tests/graph_creation.spec.ts +89 -0
- lynxkite-app/web/tests/lynxkite.ts +11 -5
- lynxkite-core/src/lynxkite/core/executors/one_by_one.py +14 -15
- lynxkite-core/src/lynxkite/core/ops.py +36 -0
- lynxkite-core/tests/test_ops.py +32 -0
- lynxkite-graph-analytics/src/lynxkite_graph_analytics/lynxkite_ops.py +50 -20
.gitignore
CHANGED
|
@@ -15,4 +15,4 @@ build
|
|
| 15 |
joblib-cache
|
| 16 |
*.egg-info
|
| 17 |
|
| 18 |
-
|
|
|
|
| 15 |
joblib-cache
|
| 16 |
*.egg-info
|
| 17 |
|
| 18 |
+
lynxkite_crdt_data
|
lynxkite-app/.gitignore
CHANGED
|
@@ -1,3 +1,4 @@
|
|
| 1 |
-
/src/
|
| 2 |
-
!/src/
|
| 3 |
-
!/src/
|
|
|
|
|
|
| 1 |
+
/src/lynxkite_app/web_assets
|
| 2 |
+
!/src/lynxkite_app/web_assets/__init__.py
|
| 3 |
+
!/src/lynxkite_app/web_assets/assets/__init__.py
|
| 4 |
+
data/
|
lynxkite-app/web/src/index.css
CHANGED
|
@@ -348,3 +348,85 @@ body {
|
|
| 348 |
outline: var(--xy-selection-border, var(--xy-selection-border-default));
|
| 349 |
outline-offset: 7.5px;
|
| 350 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
outline: var(--xy-selection-border, var(--xy-selection-border-default));
|
| 349 |
outline-offset: 7.5px;
|
| 350 |
}
|
| 351 |
+
|
| 352 |
+
.graph-creation-view {
|
| 353 |
+
display: flex;
|
| 354 |
+
width: 100%;
|
| 355 |
+
margin-top: 10px;
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
.graph-tables, .graph-relations {
|
| 359 |
+
flex: 1;
|
| 360 |
+
padding-left: 10px;
|
| 361 |
+
padding-right: 10px;
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
.graph-table-header{
|
| 365 |
+
display: flex;
|
| 366 |
+
justify-content: space-between;
|
| 367 |
+
font-weight: bold;
|
| 368 |
+
text-align: left;
|
| 369 |
+
background-color: #333;
|
| 370 |
+
color: white;
|
| 371 |
+
padding: 10px;
|
| 372 |
+
border-bottom: 2px solid #222;
|
| 373 |
+
font-size: 16px;
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
.graph-creation-view .df-head {
|
| 377 |
+
font-weight: bold;
|
| 378 |
+
display: flex;
|
| 379 |
+
justify-content: space-between;
|
| 380 |
+
padding: 8px 12px;
|
| 381 |
+
border-bottom: 1px solid #ccc; /* Adds a separator between rows */
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
/* Alternating background colors for table-like effect */
|
| 385 |
+
.graph-creation-view .df-head:nth-child(odd) {
|
| 386 |
+
background-color: #f9f9f9;
|
| 387 |
+
}
|
| 388 |
+
.graph-creation-view .df-head:nth-child(even) {
|
| 389 |
+
background-color: #e0e0e0;
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
.graph-relation-attributes {
|
| 393 |
+
display: flex;
|
| 394 |
+
flex-direction: column;
|
| 395 |
+
gap: 10px; /* Adds space between each label-input pair */
|
| 396 |
+
width: 100%;
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
.graph-relation-attributes label {
|
| 400 |
+
font-size: 12px;
|
| 401 |
+
font-weight: bold;
|
| 402 |
+
display: block;
|
| 403 |
+
margin-bottom: 2px;
|
| 404 |
+
color: #666; /* Lighter text for labels */
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
.graph-relation-attributes input {
|
| 408 |
+
width: 100%;
|
| 409 |
+
padding: 8px;
|
| 410 |
+
font-size: 14px;
|
| 411 |
+
border: 1px solid #ccc;
|
| 412 |
+
border-radius: 4px;
|
| 413 |
+
outline: none;
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
.graph-relation-attributes input:focus {
|
| 417 |
+
border-color: #007bff; /* Highlight input on focus */
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
.add-relationship-button {
|
| 421 |
+
background-color: #28a745;
|
| 422 |
+
color: white;
|
| 423 |
+
border: none;
|
| 424 |
+
font-size: 16px;
|
| 425 |
+
cursor: pointer;
|
| 426 |
+
padding: 4px 10px;
|
| 427 |
+
border-radius: 4px;
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
.add-relationship-button:hover {
|
| 431 |
+
background-color: #218838;
|
| 432 |
+
}
|
lynxkite-app/web/src/workspace/Workspace.tsx
CHANGED
|
@@ -45,6 +45,7 @@ import NodeWithImage from "./nodes/NodeWithImage.tsx";
|
|
| 45 |
import NodeWithParams from "./nodes/NodeWithParams";
|
| 46 |
import NodeWithTableView from "./nodes/NodeWithTableView.tsx";
|
| 47 |
import NodeWithVisualization from "./nodes/NodeWithVisualization.tsx";
|
|
|
|
| 48 |
|
| 49 |
export default function (props: any) {
|
| 50 |
return (
|
|
@@ -175,6 +176,7 @@ function LynxKiteFlow() {
|
|
| 175 |
visualization: NodeWithVisualization,
|
| 176 |
image: NodeWithImage,
|
| 177 |
table_view: NodeWithTableView,
|
|
|
|
| 178 |
}),
|
| 179 |
[],
|
| 180 |
);
|
|
|
|
| 45 |
import NodeWithParams from "./nodes/NodeWithParams";
|
| 46 |
import NodeWithTableView from "./nodes/NodeWithTableView.tsx";
|
| 47 |
import NodeWithVisualization from "./nodes/NodeWithVisualization.tsx";
|
| 48 |
+
import NodeWithGraphCreationView from "./nodes/GraphCreationNode.tsx";
|
| 49 |
|
| 50 |
export default function (props: any) {
|
| 51 |
return (
|
|
|
|
| 176 |
visualization: NodeWithVisualization,
|
| 177 |
image: NodeWithImage,
|
| 178 |
table_view: NodeWithTableView,
|
| 179 |
+
graph_creation_view: NodeWithGraphCreationView,
|
| 180 |
}),
|
| 181 |
[],
|
| 182 |
);
|
lynxkite-app/web/src/workspace/nodes/GraphCreationNode.tsx
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useReactFlow } from "@xyflow/react";
|
| 2 |
+
import { useState } from "react";
|
| 3 |
+
import React from "react";
|
| 4 |
+
import Markdown from "react-markdown";
|
| 5 |
+
// @ts-ignore
|
| 6 |
+
import Trash from "~icons/tabler/trash";
|
| 7 |
+
import LynxKiteNode from "./LynxKiteNode";
|
| 8 |
+
import Table from "./Table";
|
| 9 |
+
|
| 10 |
+
function toMD(v: any): string {
|
| 11 |
+
if (typeof v === "string") {
|
| 12 |
+
return v;
|
| 13 |
+
}
|
| 14 |
+
if (Array.isArray(v)) {
|
| 15 |
+
return v.map(toMD).join("\n\n");
|
| 16 |
+
}
|
| 17 |
+
return JSON.stringify(v);
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
function displayTable(name: string, df: any) {
|
| 21 |
+
if (df.data.length > 1) {
|
| 22 |
+
return (
|
| 23 |
+
<Table
|
| 24 |
+
key={`${name}-table`}
|
| 25 |
+
name={`${name}-table`}
|
| 26 |
+
columns={df.columns}
|
| 27 |
+
data={df.data}
|
| 28 |
+
/>
|
| 29 |
+
);
|
| 30 |
+
}
|
| 31 |
+
if (df.data.length) {
|
| 32 |
+
return (
|
| 33 |
+
<dl key={`${name}-dl`}>
|
| 34 |
+
{df.columns.map((c: string, i: number) => (
|
| 35 |
+
<React.Fragment key={`${name}-${c}`}>
|
| 36 |
+
<dt>{c}</dt>
|
| 37 |
+
<dd>
|
| 38 |
+
<Markdown>{toMD(df.data[0][i])}</Markdown>
|
| 39 |
+
</dd>
|
| 40 |
+
</React.Fragment>
|
| 41 |
+
))}
|
| 42 |
+
</dl>
|
| 43 |
+
);
|
| 44 |
+
}
|
| 45 |
+
return JSON.stringify(df.data);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
function relationsToDict(relations: any[]) {
|
| 49 |
+
if (!relations) {
|
| 50 |
+
return {};
|
| 51 |
+
}
|
| 52 |
+
return Object.assign({}, ...relations.map((r: any) => ({ [r.name]: r })));
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
export type UpdateOptions = { delay?: number };
|
| 56 |
+
|
| 57 |
+
export default function NodeWithGraphCreationView(props: any) {
|
| 58 |
+
const reactFlow = useReactFlow();
|
| 59 |
+
const [open, setOpen] = useState({} as { [name: string]: boolean });
|
| 60 |
+
const display = props.data.display?.value;
|
| 61 |
+
const tables = display?.dataframes || {};
|
| 62 |
+
const singleTable = tables && Object.keys(tables).length === 1;
|
| 63 |
+
const [relations, setRelations] = useState(
|
| 64 |
+
relationsToDict(display?.relations) || {},
|
| 65 |
+
);
|
| 66 |
+
const singleRelation = relations && Object.keys(relations).length === 1;
|
| 67 |
+
function setParam(name: string, newValue: any, opts: UpdateOptions) {
|
| 68 |
+
reactFlow.updateNodeData(props.id, {
|
| 69 |
+
params: { ...props.data.params, [name]: newValue },
|
| 70 |
+
__execution_delay: opts.delay || 0,
|
| 71 |
+
});
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
function updateRelation(event: any, relation: any) {
|
| 75 |
+
event.preventDefault();
|
| 76 |
+
|
| 77 |
+
const updatedRelation = {
|
| 78 |
+
...relation,
|
| 79 |
+
...Object.fromEntries(new FormData(event.target).entries()),
|
| 80 |
+
};
|
| 81 |
+
|
| 82 |
+
// Avoid mutating React state directly
|
| 83 |
+
const newRelations = { ...relations };
|
| 84 |
+
if (relation.name !== updatedRelation.name) {
|
| 85 |
+
delete newRelations[relation.name];
|
| 86 |
+
}
|
| 87 |
+
newRelations[updatedRelation.name] = updatedRelation;
|
| 88 |
+
setRelations(newRelations);
|
| 89 |
+
// There is some issue with how Yjs handles complex objects (maps, arrays)
|
| 90 |
+
// so we need to serialize the relations object to a string
|
| 91 |
+
setParam("relations", JSON.stringify(newRelations), {});
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
const addRelation = () => {
|
| 95 |
+
const new_relation = {
|
| 96 |
+
name: "new_relation",
|
| 97 |
+
df: "",
|
| 98 |
+
source_column: "",
|
| 99 |
+
target_column: "",
|
| 100 |
+
source_table: "",
|
| 101 |
+
target_table: "",
|
| 102 |
+
source_key: "",
|
| 103 |
+
target_key: "",
|
| 104 |
+
};
|
| 105 |
+
setRelations({
|
| 106 |
+
...relations,
|
| 107 |
+
[new_relation.name]: new_relation,
|
| 108 |
+
});
|
| 109 |
+
setOpen({ ...open, [new_relation.name]: true });
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
const deleteRelation = (relation: any) => {
|
| 113 |
+
const newOpen = { ...open };
|
| 114 |
+
delete newOpen[relation.name];
|
| 115 |
+
setOpen(newOpen);
|
| 116 |
+
const newRelations = { ...relations };
|
| 117 |
+
delete newRelations[relation.name];
|
| 118 |
+
setRelations(newRelations);
|
| 119 |
+
// There is some issue with how Yjs handles complex objects (maps, arrays)
|
| 120 |
+
// so we need to serialize the relations object to a string
|
| 121 |
+
setParam("relations", JSON.stringify(newRelations), {});
|
| 122 |
+
};
|
| 123 |
+
|
| 124 |
+
function displayRelation(relation: any) {
|
| 125 |
+
// TODO: Dynamic autocomplete
|
| 126 |
+
return (
|
| 127 |
+
<form
|
| 128 |
+
className="graph-relation-attributes"
|
| 129 |
+
onSubmit={(e) => {
|
| 130 |
+
updateRelation(e, relation);
|
| 131 |
+
}}
|
| 132 |
+
>
|
| 133 |
+
<label htmlFor="name">Name:</label>
|
| 134 |
+
<input type="text" id="name" name="name" defaultValue={relation.name} />
|
| 135 |
+
|
| 136 |
+
<label htmlFor="df">DataFrame:</label>
|
| 137 |
+
<input
|
| 138 |
+
type="text"
|
| 139 |
+
id="df"
|
| 140 |
+
name="df"
|
| 141 |
+
defaultValue={relation.df}
|
| 142 |
+
list="df-options"
|
| 143 |
+
required
|
| 144 |
+
/>
|
| 145 |
+
|
| 146 |
+
<label htmlFor="source_column">Source Column:</label>
|
| 147 |
+
<input
|
| 148 |
+
type="text"
|
| 149 |
+
id="source_column"
|
| 150 |
+
name="source_column"
|
| 151 |
+
defaultValue={relation.source_column}
|
| 152 |
+
list="edges-column-options"
|
| 153 |
+
required
|
| 154 |
+
/>
|
| 155 |
+
|
| 156 |
+
<label htmlFor="target_column">Target Column:</label>
|
| 157 |
+
<input
|
| 158 |
+
type="text"
|
| 159 |
+
id="target_column"
|
| 160 |
+
name="target_column"
|
| 161 |
+
defaultValue={relation.target_column}
|
| 162 |
+
list="edges-column-options"
|
| 163 |
+
required
|
| 164 |
+
/>
|
| 165 |
+
|
| 166 |
+
<label htmlFor="source_table">Source Table:</label>
|
| 167 |
+
<input
|
| 168 |
+
type="text"
|
| 169 |
+
id="source_table"
|
| 170 |
+
name="source_table"
|
| 171 |
+
defaultValue={relation.source_table}
|
| 172 |
+
list="df-options"
|
| 173 |
+
required
|
| 174 |
+
/>
|
| 175 |
+
|
| 176 |
+
<label htmlFor="target_table">Target Table:</label>
|
| 177 |
+
<input
|
| 178 |
+
type="text"
|
| 179 |
+
id="target_table"
|
| 180 |
+
name="target_table"
|
| 181 |
+
defaultValue={relation.target_table}
|
| 182 |
+
list="df-options"
|
| 183 |
+
required
|
| 184 |
+
/>
|
| 185 |
+
|
| 186 |
+
<label htmlFor="source_key">Source Key:</label>
|
| 187 |
+
<input
|
| 188 |
+
type="text"
|
| 189 |
+
id="source_key"
|
| 190 |
+
name="source_key"
|
| 191 |
+
defaultValue={relation.source_key}
|
| 192 |
+
list="source-node-column-options"
|
| 193 |
+
required
|
| 194 |
+
/>
|
| 195 |
+
|
| 196 |
+
<label htmlFor="target_key">Target Key:</label>
|
| 197 |
+
<input
|
| 198 |
+
type="text"
|
| 199 |
+
id="target_key"
|
| 200 |
+
name="target_key"
|
| 201 |
+
defaultValue={relation.target_key}
|
| 202 |
+
list="target-node-column-options"
|
| 203 |
+
required
|
| 204 |
+
/>
|
| 205 |
+
|
| 206 |
+
<datalist id="df-options">
|
| 207 |
+
{Object.keys(tables).map((name) => (
|
| 208 |
+
<option key={name} value={name} />
|
| 209 |
+
))}
|
| 210 |
+
</datalist>
|
| 211 |
+
|
| 212 |
+
<datalist id="edges-column-options">
|
| 213 |
+
{tables[relation.source_table] &&
|
| 214 |
+
tables[relation.df].columns.map((name: string) => (
|
| 215 |
+
<option key={name} value={name} />
|
| 216 |
+
))}
|
| 217 |
+
</datalist>
|
| 218 |
+
|
| 219 |
+
<datalist id="source-node-column-options">
|
| 220 |
+
{tables[relation.source_table] &&
|
| 221 |
+
tables[relation.source_table].columns.map((name: string) => (
|
| 222 |
+
<option key={name} value={name} />
|
| 223 |
+
))}
|
| 224 |
+
</datalist>
|
| 225 |
+
|
| 226 |
+
<datalist id="target-node-column-options">
|
| 227 |
+
{tables[relation.source_table] &&
|
| 228 |
+
tables[relation.target_table].columns.map((name: string) => (
|
| 229 |
+
<option key={name} value={name} />
|
| 230 |
+
))}
|
| 231 |
+
</datalist>
|
| 232 |
+
|
| 233 |
+
<button className="submit-relationship-button" type="submit">
|
| 234 |
+
Create
|
| 235 |
+
</button>
|
| 236 |
+
</form>
|
| 237 |
+
);
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
return (
|
| 241 |
+
<LynxKiteNode {...props}>
|
| 242 |
+
<div className="graph-creation-view">
|
| 243 |
+
<div className="graph-tables">
|
| 244 |
+
<div className="graph-table-header">Node Tables</div>
|
| 245 |
+
{display && [
|
| 246 |
+
Object.entries(tables).map(([name, df]: [string, any]) => (
|
| 247 |
+
<React.Fragment key={name}>
|
| 248 |
+
{!singleTable && (
|
| 249 |
+
<div
|
| 250 |
+
key={`${name}-header`}
|
| 251 |
+
className="df-head"
|
| 252 |
+
onClick={() => setOpen({ ...open, [name]: !open[name] })}
|
| 253 |
+
>
|
| 254 |
+
{name}
|
| 255 |
+
</div>
|
| 256 |
+
)}
|
| 257 |
+
{(singleTable || open[name]) && displayTable(name, df)}
|
| 258 |
+
</React.Fragment>
|
| 259 |
+
)),
|
| 260 |
+
Object.entries(display.others || {}).map(([name, o]) => (
|
| 261 |
+
<>
|
| 262 |
+
<div
|
| 263 |
+
key={name}
|
| 264 |
+
className="df-head"
|
| 265 |
+
onClick={() => setOpen({ ...open, [name]: !open[name] })}
|
| 266 |
+
>
|
| 267 |
+
{name}
|
| 268 |
+
</div>
|
| 269 |
+
{open[name] && <pre>{(o as any).toString()}</pre>}
|
| 270 |
+
</>
|
| 271 |
+
)),
|
| 272 |
+
]}
|
| 273 |
+
</div>
|
| 274 |
+
<div className="graph-relations">
|
| 275 |
+
<div className="graph-table-header">
|
| 276 |
+
Relationships
|
| 277 |
+
<button
|
| 278 |
+
className="add-relationship-button"
|
| 279 |
+
onClick={(_) => addRelation()}
|
| 280 |
+
>
|
| 281 |
+
+
|
| 282 |
+
</button>
|
| 283 |
+
</div>
|
| 284 |
+
{relations &&
|
| 285 |
+
Object.entries(relations).map(([name, relation]: [string, any]) => (
|
| 286 |
+
<React.Fragment key={name}>
|
| 287 |
+
<div
|
| 288 |
+
key={`${name}-header`}
|
| 289 |
+
className="df-head"
|
| 290 |
+
onClick={() => setOpen({ ...open, [name]: !open[name] })}
|
| 291 |
+
>
|
| 292 |
+
{name}
|
| 293 |
+
<button
|
| 294 |
+
onClick={() => {
|
| 295 |
+
deleteRelation(relation);
|
| 296 |
+
}}
|
| 297 |
+
>
|
| 298 |
+
<Trash />
|
| 299 |
+
</button>
|
| 300 |
+
</div>
|
| 301 |
+
{(singleRelation || open[name]) && displayRelation(relation)}
|
| 302 |
+
</React.Fragment>
|
| 303 |
+
))}
|
| 304 |
+
</div>
|
| 305 |
+
</div>
|
| 306 |
+
</LynxKiteNode>
|
| 307 |
+
);
|
| 308 |
+
}
|
lynxkite-app/web/src/workspace/nodes/Table.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
export default function Table(props: any) {
|
| 2 |
return (
|
| 3 |
-
<table>
|
| 4 |
<thead>
|
| 5 |
<tr>
|
| 6 |
{props.columns.map((column: string) => (
|
|
|
|
| 1 |
export default function Table(props: any) {
|
| 2 |
return (
|
| 3 |
+
<table id={props.name || "table"}>
|
| 4 |
<thead>
|
| 5 |
<tr>
|
| 6 |
{props.columns.map((column: string) => (
|
lynxkite-app/web/tests/directory.spec.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
// Tests
|
| 2 |
import { expect, test } from "@playwright/test";
|
| 3 |
import { Splash, Workspace } from "./lynxkite";
|
| 4 |
|
|
|
|
| 1 |
+
// Tests the basic directory operations, such as creating and deleting folders and workspaces.
|
| 2 |
import { expect, test } from "@playwright/test";
|
| 3 |
import { Splash, Workspace } from "./lynxkite";
|
| 4 |
|
lynxkite-app/web/tests/graph_creation.spec.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Test the graph creation box in LynxKite
|
| 2 |
+
import { expect, test } from "@playwright/test";
|
| 3 |
+
import { Splash, Workspace } from "./lynxkite";
|
| 4 |
+
|
| 5 |
+
let workspace: Workspace;
|
| 6 |
+
|
| 7 |
+
test.beforeEach(async ({ browser }) => {
|
| 8 |
+
workspace = await Workspace.empty(
|
| 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 () => {
|
| 18 |
+
await workspace.close();
|
| 19 |
+
const splash = await new Splash(workspace.page);
|
| 20 |
+
splash.page.on("dialog", async (dialog) => {
|
| 21 |
+
await dialog.accept();
|
| 22 |
+
});
|
| 23 |
+
await splash.deleteEntry("graph_creation_spec_test");
|
| 24 |
+
});
|
| 25 |
+
|
| 26 |
+
test("Tables are displayed in the Graph creation box", async () => {
|
| 27 |
+
const graphBox = await workspace.getBox("Create graph 1");
|
| 28 |
+
const nodesTableHeader = await graphBox.locator(".graph-tables .df-head", {
|
| 29 |
+
hasText: "nodes",
|
| 30 |
+
});
|
| 31 |
+
const edgesTableHeader = await graphBox.locator(".graph-tables .df-head", {
|
| 32 |
+
hasText: "edges",
|
| 33 |
+
});
|
| 34 |
+
await expect(nodesTableHeader).toBeVisible();
|
| 35 |
+
await expect(edgesTableHeader).toBeVisible();
|
| 36 |
+
nodesTableHeader.click();
|
| 37 |
+
await expect(graphBox.locator("#nodes-table")).toBeVisible();
|
| 38 |
+
edgesTableHeader.click();
|
| 39 |
+
await expect(graphBox.locator("#edges-table")).toBeVisible();
|
| 40 |
+
});
|
| 41 |
+
|
| 42 |
+
test("Adding and removing relationships", async () => {
|
| 43 |
+
const graphBox = await workspace.getBox("Create graph 1");
|
| 44 |
+
const addRelationshipButton = await graphBox.locator(
|
| 45 |
+
".add-relationship-button",
|
| 46 |
+
);
|
| 47 |
+
await addRelationshipButton.click();
|
| 48 |
+
const formData: Record<string, string> = {
|
| 49 |
+
name: "relation_1",
|
| 50 |
+
df: "edges",
|
| 51 |
+
source_column: "source_id",
|
| 52 |
+
target_column: "target_id",
|
| 53 |
+
source_table: "nodes",
|
| 54 |
+
target_table: "nodes",
|
| 55 |
+
source_key: "node_id",
|
| 56 |
+
target_key: "node_id",
|
| 57 |
+
};
|
| 58 |
+
for (const [fieldName, fieldValue] of Object.entries(formData)) {
|
| 59 |
+
const inputField = await graphBox.locator(
|
| 60 |
+
`.graph-relation-attributes input[name="${fieldName}"]`,
|
| 61 |
+
);
|
| 62 |
+
await inputField.fill(fieldValue);
|
| 63 |
+
}
|
| 64 |
+
await graphBox.locator(".submit-relationship-button").click();
|
| 65 |
+
// check that the relationship has been saved in the backend
|
| 66 |
+
await workspace.page.reload();
|
| 67 |
+
const graphBoxAfterReload = await workspace.getBox("Create graph 1");
|
| 68 |
+
const relationHeader = await graphBoxAfterReload.locator(
|
| 69 |
+
".graph-relations .df-head",
|
| 70 |
+
{ hasText: "relation_1" },
|
| 71 |
+
);
|
| 72 |
+
await expect(relationHeader).toBeVisible();
|
| 73 |
+
await relationHeader.locator("button").click(); // Delete the relationship
|
| 74 |
+
await expect(relationHeader).not.toBeVisible();
|
| 75 |
+
});
|
| 76 |
+
|
| 77 |
+
test("Output of the box is a bundle", async () => {
|
| 78 |
+
await workspace.addBox("View tables");
|
| 79 |
+
const tableView = await workspace.getBox("View tables 1");
|
| 80 |
+
await workspace.connectBoxes("Create graph 1", "View tables 1");
|
| 81 |
+
const nodesTableHeader = await tableView.locator(".df-head", {
|
| 82 |
+
hasText: "nodes",
|
| 83 |
+
});
|
| 84 |
+
const edgesTableHeader = await tableView.locator(".df-head", {
|
| 85 |
+
hasText: "edges",
|
| 86 |
+
});
|
| 87 |
+
await expect(nodesTableHeader).toBeVisible();
|
| 88 |
+
await expect(edgesTableHeader).toBeVisible();
|
| 89 |
+
});
|
lynxkite-app/web/tests/lynxkite.ts
CHANGED
|
@@ -57,8 +57,9 @@ export class Workspace {
|
|
| 57 |
const allBoxes = await this.getBoxes();
|
| 58 |
if (allBoxes) {
|
| 59 |
// Avoid overlapping with existing nodes
|
| 60 |
-
const numNodes = allBoxes.length;
|
| 61 |
-
await this.page.mouse.wheel(0, numNodes *
|
|
|
|
| 62 |
}
|
| 63 |
|
| 64 |
// Some x,y offset, otherwise the box handle may fall outside the viewport.
|
|
@@ -97,7 +98,12 @@ export class Workspace {
|
|
| 97 |
return this.page.locator(".react-flow__node").all();
|
| 98 |
}
|
| 99 |
|
| 100 |
-
getBoxHandle(boxId: string) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
return this.page.getByTestId(boxId);
|
| 102 |
}
|
| 103 |
|
|
@@ -130,8 +136,8 @@ export class Workspace {
|
|
| 130 |
}
|
| 131 |
|
| 132 |
async connectBoxes(sourceId: string, targetId: string) {
|
| 133 |
-
const sourceHandle = this.getBoxHandle(sourceId);
|
| 134 |
-
const targetHandle = this.getBoxHandle(targetId);
|
| 135 |
await sourceHandle.hover();
|
| 136 |
await this.page.mouse.down();
|
| 137 |
await targetHandle.hover();
|
|
|
|
| 57 |
const allBoxes = await this.getBoxes();
|
| 58 |
if (allBoxes) {
|
| 59 |
// Avoid overlapping with existing nodes
|
| 60 |
+
const numNodes = allBoxes.length || 1;
|
| 61 |
+
await this.page.mouse.wheel(0, numNodes * 400);
|
| 62 |
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
| 63 |
}
|
| 64 |
|
| 65 |
// Some x,y offset, otherwise the box handle may fall outside the viewport.
|
|
|
|
| 98 |
return this.page.locator(".react-flow__node").all();
|
| 99 |
}
|
| 100 |
|
| 101 |
+
getBoxHandle(boxId: string, pos?: string) {
|
| 102 |
+
if (pos) {
|
| 103 |
+
return this.page.locator(
|
| 104 |
+
`[data-id="${boxId}"] [data-handlepos="${pos}"]`,
|
| 105 |
+
);
|
| 106 |
+
}
|
| 107 |
return this.page.getByTestId(boxId);
|
| 108 |
}
|
| 109 |
|
|
|
|
| 136 |
}
|
| 137 |
|
| 138 |
async connectBoxes(sourceId: string, targetId: string) {
|
| 139 |
+
const sourceHandle = this.getBoxHandle(sourceId, "right");
|
| 140 |
+
const targetHandle = this.getBoxHandle(targetId, "left");
|
| 141 |
await sourceHandle.hover();
|
| 142 |
await this.page.mouse.down();
|
| 143 |
await targetHandle.hover();
|
lynxkite-core/src/lynxkite/core/executors/one_by_one.py
CHANGED
|
@@ -141,28 +141,27 @@ async def execute(ws: workspace.Workspace, catalog, cache=None):
|
|
| 141 |
if cache is not None:
|
| 142 |
key = make_cache_key((inputs, params))
|
| 143 |
if key not in cache:
|
| 144 |
-
|
| 145 |
-
|
|
|
|
|
|
|
| 146 |
else:
|
| 147 |
-
result =
|
|
|
|
| 148 |
except Exception as e:
|
| 149 |
traceback.print_exc()
|
| 150 |
data.error = str(e)
|
| 151 |
break
|
| 152 |
-
contexts[node.id].last_result =
|
| 153 |
# Returned lists and DataFrames are considered multiple tasks.
|
| 154 |
-
if isinstance(
|
| 155 |
-
|
| 156 |
-
elif not isinstance(
|
| 157 |
-
|
| 158 |
-
results.extend(
|
| 159 |
else: # Finished all tasks without errors.
|
| 160 |
-
if
|
| 161 |
-
|
| 162 |
-
or op.type == "table_view"
|
| 163 |
-
or op.type == "image"
|
| 164 |
-
):
|
| 165 |
-
data.display = results[0]
|
| 166 |
for edge in edges[node.id]:
|
| 167 |
t = nodes[edge.target]
|
| 168 |
op = catalog[t.data.title]
|
|
|
|
| 141 |
if cache is not 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 |
data.error = str(e)
|
| 154 |
break
|
| 155 |
+
contexts[node.id].last_result = output
|
| 156 |
# Returned lists and DataFrames are considered multiple tasks.
|
| 157 |
+
if isinstance(output, pd.DataFrame):
|
| 158 |
+
output = df_to_list(output)
|
| 159 |
+
elif not isinstance(output, list):
|
| 160 |
+
output = [output]
|
| 161 |
+
results.extend(output)
|
| 162 |
else: # Finished all tasks without errors.
|
| 163 |
+
if result.display:
|
| 164 |
+
data.display = await await_if_needed(result.display)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
for edge in edges[node.id]:
|
| 166 |
t = nodes[edge.target]
|
| 167 |
op = catalog[t.data.title]
|
lynxkite-core/src/lynxkite/core/ops.py
CHANGED
|
@@ -6,6 +6,7 @@ import functools
|
|
| 6 |
import inspect
|
| 7 |
import pydantic
|
| 8 |
import typing
|
|
|
|
| 9 |
from typing_extensions import Annotated
|
| 10 |
|
| 11 |
CATALOGS = {}
|
|
@@ -28,6 +29,16 @@ PathStr = Annotated[str, {"format": "path"}]
|
|
| 28 |
CollapsedStr = Annotated[str, {"format": "collapsed"}]
|
| 29 |
NodeAttribute = Annotated[str, {"format": "node attribute"}]
|
| 30 |
EdgeAttribute = Annotated[str, {"format": "edge attribute"}]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
|
| 33 |
class BaseConfig(pydantic.BaseModel):
|
|
@@ -74,6 +85,19 @@ class Output(BaseConfig):
|
|
| 74 |
position: str = "right"
|
| 75 |
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
MULTI_INPUT = Input(name="multi", type="*")
|
| 78 |
|
| 79 |
|
|
@@ -105,6 +129,18 @@ class Op(BaseConfig):
|
|
| 105 |
elif isinstance(self.params[p].type, enum.EnumMeta):
|
| 106 |
params[p] = self.params[p].type[params[p]]
|
| 107 |
res = self.func(*inputs, **params)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
return res
|
| 109 |
|
| 110 |
|
|
|
|
| 6 |
import inspect
|
| 7 |
import pydantic
|
| 8 |
import typing
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
from typing_extensions import Annotated
|
| 11 |
|
| 12 |
CATALOGS = {}
|
|
|
|
| 29 |
CollapsedStr = Annotated[str, {"format": "collapsed"}]
|
| 30 |
NodeAttribute = Annotated[str, {"format": "node attribute"}]
|
| 31 |
EdgeAttribute = Annotated[str, {"format": "edge attribute"}]
|
| 32 |
+
# https://github.com/python/typing/issues/182#issuecomment-1320974824
|
| 33 |
+
ReadOnlyJSON: typing.TypeAlias = (
|
| 34 |
+
typing.Mapping[str, "ReadOnlyJSON"]
|
| 35 |
+
| typing.Sequence["ReadOnlyJSON"]
|
| 36 |
+
| str
|
| 37 |
+
| int
|
| 38 |
+
| float
|
| 39 |
+
| bool
|
| 40 |
+
| None
|
| 41 |
+
)
|
| 42 |
|
| 43 |
|
| 44 |
class BaseConfig(pydantic.BaseModel):
|
|
|
|
| 85 |
position: str = "right"
|
| 86 |
|
| 87 |
|
| 88 |
+
@dataclass
|
| 89 |
+
class Result:
|
| 90 |
+
"""Represents the result of an operation.
|
| 91 |
+
|
| 92 |
+
The `output` attribute is what will be used as input for other operations.
|
| 93 |
+
The `display` attribute is used to send data to display on the UI. The value has to be
|
| 94 |
+
JSON-serializable.
|
| 95 |
+
"""
|
| 96 |
+
|
| 97 |
+
output: typing.Any
|
| 98 |
+
display: ReadOnlyJSON | None = None
|
| 99 |
+
|
| 100 |
+
|
| 101 |
MULTI_INPUT = Input(name="multi", type="*")
|
| 102 |
|
| 103 |
|
|
|
|
| 129 |
elif isinstance(self.params[p].type, enum.EnumMeta):
|
| 130 |
params[p] = self.params[p].type[params[p]]
|
| 131 |
res = self.func(*inputs, **params)
|
| 132 |
+
if not isinstance(res, Result):
|
| 133 |
+
# Automatically wrap the result in a Result object, if it isn't already.
|
| 134 |
+
res = Result(output=res)
|
| 135 |
+
if self.type in [
|
| 136 |
+
"visualization",
|
| 137 |
+
"table_view",
|
| 138 |
+
"graph_creation_view",
|
| 139 |
+
"image",
|
| 140 |
+
]:
|
| 141 |
+
# If the operation is some kind of visualization, we use the output as the
|
| 142 |
+
# value to display by default.
|
| 143 |
+
res.display = res.output
|
| 144 |
return res
|
| 145 |
|
| 146 |
|
lynxkite-core/tests/test_ops.py
CHANGED
|
@@ -85,3 +85,35 @@ def test_op_decorator_with_complex_types():
|
|
| 85 |
"result": ops.Output(name="result", type=None, position="right")
|
| 86 |
}
|
| 87 |
assert ops.CATALOGS["test"]["color_op"] == complex_op.__op__
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
"result": ops.Output(name="result", type=None, position="right")
|
| 86 |
}
|
| 87 |
assert ops.CATALOGS["test"]["color_op"] == complex_op.__op__
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def test_operation_can_return_non_result_instance():
|
| 91 |
+
@ops.op(env="test", name="subtract", view="basic", outputs=["result"])
|
| 92 |
+
def subtract(a, b):
|
| 93 |
+
return a - b
|
| 94 |
+
|
| 95 |
+
result = ops.CATALOGS["test"]["subtract"](5, 3)
|
| 96 |
+
assert isinstance(result, ops.Result)
|
| 97 |
+
assert result.output == 2
|
| 98 |
+
assert result.display is None
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def test_operation_can_return_result_instance():
|
| 102 |
+
@ops.op(env="test", name="subtract", view="basic", outputs=["result"])
|
| 103 |
+
def subtract(a, b):
|
| 104 |
+
return ops.Result(output=a - b, display=None)
|
| 105 |
+
|
| 106 |
+
result = ops.CATALOGS["test"]["subtract"](5, 3)
|
| 107 |
+
assert isinstance(result, ops.Result)
|
| 108 |
+
assert result.output == 2
|
| 109 |
+
assert result.display is None
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def test_visualization_operations_display_is_populated_automatically():
|
| 113 |
+
@ops.op(env="test", name="display_op", view="visualization", outputs=["result"])
|
| 114 |
+
def display_op():
|
| 115 |
+
return {"display_value": 1}
|
| 116 |
+
|
| 117 |
+
result = ops.CATALOGS["test"]["display_op"]()
|
| 118 |
+
assert isinstance(result, ops.Result)
|
| 119 |
+
assert result.output == result.display == {"display_value": 1}
|
lynxkite-graph-analytics/src/lynxkite_graph_analytics/lynxkite_ops.py
CHANGED
|
@@ -14,6 +14,8 @@ import pandas as pd
|
|
| 14 |
import polars as pl
|
| 15 |
import traceback
|
| 16 |
import typing
|
|
|
|
|
|
|
| 17 |
|
| 18 |
mem = joblib.Memory("../joblib-cache")
|
| 19 |
ENV = "LynxKite Graph Analytics"
|
|
@@ -35,6 +37,7 @@ class RelationDefinition:
|
|
| 35 |
target_table: str # The DataFrame that contains the target nodes.
|
| 36 |
source_key: str # The column in the source table that contains the node ID.
|
| 37 |
target_key: str # The column in the target table that contains the node ID.
|
|
|
|
| 38 |
|
| 39 |
|
| 40 |
@dataclasses.dataclass
|
|
@@ -105,6 +108,19 @@ class Bundle:
|
|
| 105 |
other=dict(self.other) if self.other else None,
|
| 106 |
)
|
| 107 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
def nx_node_attribute_func(name):
|
| 110 |
"""Decorator for wrapping a function that adds a NetworkX node attribute."""
|
|
@@ -162,7 +178,7 @@ async def execute(ws):
|
|
| 162 |
inputs[i] = Bundle.from_nx(x)
|
| 163 |
elif p.type == Bundle and isinstance(x, pd.DataFrame):
|
| 164 |
inputs[i] = Bundle.from_df(x)
|
| 165 |
-
|
| 166 |
except Exception as e:
|
| 167 |
traceback.print_exc()
|
| 168 |
data.error = str(e)
|
|
@@ -172,13 +188,9 @@ async def execute(ws):
|
|
| 172 |
# It's a flexible input. Create n+1 handles.
|
| 173 |
data.inputs = {f"input{i}": None for i in range(len(inputs) + 1)}
|
| 174 |
data.error = None
|
| 175 |
-
outputs[node.id] = output
|
| 176 |
-
if
|
| 177 |
-
|
| 178 |
-
or op.type == "table_view"
|
| 179 |
-
or op.type == "image"
|
| 180 |
-
):
|
| 181 |
-
data.display = output
|
| 182 |
|
| 183 |
|
| 184 |
@op("Import Parquet")
|
|
@@ -445,15 +457,33 @@ def df_for_frontend(df: pd.DataFrame, limit: int) -> pd.DataFrame:
|
|
| 445 |
|
| 446 |
@op("View tables", view="table_view")
|
| 447 |
def view_tables(bundle: Bundle, *, limit: int = 100):
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
import polars as pl
|
| 15 |
import traceback
|
| 16 |
import typing
|
| 17 |
+
import json
|
| 18 |
+
|
| 19 |
|
| 20 |
mem = joblib.Memory("../joblib-cache")
|
| 21 |
ENV = "LynxKite Graph Analytics"
|
|
|
|
| 37 |
target_table: str # The DataFrame that contains the target nodes.
|
| 38 |
source_key: str # The column in the source table that contains the node ID.
|
| 39 |
target_key: str # The column in the target table that contains the node ID.
|
| 40 |
+
name: str | None = None # Descriptive name for the relation.
|
| 41 |
|
| 42 |
|
| 43 |
@dataclasses.dataclass
|
|
|
|
| 108 |
other=dict(self.other) if self.other else None,
|
| 109 |
)
|
| 110 |
|
| 111 |
+
def to_dict(self, limit: int = 100):
|
| 112 |
+
return {
|
| 113 |
+
"dataframes": {
|
| 114 |
+
name: {
|
| 115 |
+
"columns": [str(c) for c in df.columns],
|
| 116 |
+
"data": df_for_frontend(df, limit).values.tolist(),
|
| 117 |
+
}
|
| 118 |
+
for name, df in self.dfs.items()
|
| 119 |
+
},
|
| 120 |
+
"relations": [dataclasses.asdict(relation) for relation in self.relations],
|
| 121 |
+
"other": self.other,
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
|
| 125 |
def nx_node_attribute_func(name):
|
| 126 |
"""Decorator for wrapping a function that adds a NetworkX node attribute."""
|
|
|
|
| 178 |
inputs[i] = Bundle.from_nx(x)
|
| 179 |
elif p.type == Bundle and isinstance(x, pd.DataFrame):
|
| 180 |
inputs[i] = Bundle.from_df(x)
|
| 181 |
+
result = op(*inputs, **params)
|
| 182 |
except Exception as e:
|
| 183 |
traceback.print_exc()
|
| 184 |
data.error = str(e)
|
|
|
|
| 188 |
# It's a flexible input. Create n+1 handles.
|
| 189 |
data.inputs = {f"input{i}": None for i in range(len(inputs) + 1)}
|
| 190 |
data.error = None
|
| 191 |
+
outputs[node.id] = result.output
|
| 192 |
+
if result.display:
|
| 193 |
+
data.display = result.display
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
|
| 195 |
|
| 196 |
@op("Import Parquet")
|
|
|
|
| 457 |
|
| 458 |
@op("View tables", view="table_view")
|
| 459 |
def view_tables(bundle: Bundle, *, limit: int = 100):
|
| 460 |
+
return bundle.to_dict(limit=limit)
|
| 461 |
+
|
| 462 |
+
|
| 463 |
+
@op(
|
| 464 |
+
"Create graph",
|
| 465 |
+
view="graph_creation_view",
|
| 466 |
+
outputs=["output"],
|
| 467 |
+
)
|
| 468 |
+
def create_graph(bundle: Bundle, *, relations: str = None) -> Bundle:
|
| 469 |
+
"""Replace relations of the given bundle
|
| 470 |
+
|
| 471 |
+
relations is a stringified JSON, instead of a dict, because complex Yjs types (arrays, maps)
|
| 472 |
+
are not currently supported in the UI.
|
| 473 |
+
|
| 474 |
+
Args:
|
| 475 |
+
bundle: Bundle to modify
|
| 476 |
+
relations (str, optional): Set of relations to set for the bundle. The parameter
|
| 477 |
+
should be a JSON object where the keys are relation names and the values are
|
| 478 |
+
a dictionary representation of a `RelationDefinition`.
|
| 479 |
+
Defaults to None.
|
| 480 |
+
|
| 481 |
+
Returns:
|
| 482 |
+
Bundle: The input bundle with the new relations set.
|
| 483 |
+
"""
|
| 484 |
+
bundle = bundle.copy()
|
| 485 |
+
if not (relations is None or relations.strip() == ""):
|
| 486 |
+
bundle.relations = [
|
| 487 |
+
RelationDefinition(**r) for r in json.loads(relations).values()
|
| 488 |
+
]
|
| 489 |
+
return ops.Result(output=bundle, display=bundle.to_dict(limit=100))
|