darabos commited on
Commit
cdac958
·
unverified ·
2 Parent(s): 5d834adb3e9de4

Merge pull request #41 from lynxkite/darabos-ty

Browse files
.github/workflows/test.yaml CHANGED
@@ -6,19 +6,20 @@ on:
6
  jobs:
7
  test:
8
  runs-on: ubuntu-latest
9
- env:
10
- UV_SYSTEM_PYTHON: 1
11
  steps:
12
  - uses: actions/checkout@v4
13
 
14
  - name: Install uv
15
- uses: astral-sh/setup-uv@v5
16
- with:
17
- enable-cache: true
18
 
19
- - uses: actions/setup-python@v5
20
- with:
21
- python-version: "3.12"
 
 
 
 
 
22
 
23
  - name: Install dependencies
24
  run: |
@@ -33,27 +34,16 @@ jobs:
33
  - name: Run pre-commits
34
  run: |
35
  uv pip install pre-commit
36
- pre-commit run --all-files
37
-
38
- - name: Run core tests
39
- run: |
40
- cd lynxkite-core
41
- pytest
42
-
43
- - name: Run app tests
44
- run: |
45
- cd lynxkite-app
46
- pytest
47
 
48
- - name: Run graph analytics tests
49
  run: |
50
- cd lynxkite-graph-analytics
51
- pytest
52
 
53
- - name: Try building the documentation
54
  run: |
55
  uv pip install mkdocs-material mkdocstrings[python]
56
- mkdocs build
57
 
58
  - uses: actions/setup-node@v4
59
  with:
@@ -67,9 +57,7 @@ jobs:
67
 
68
  - name: Run Playwright tests
69
  run: |
70
- cd lynxkite-app/web
71
- npm run build
72
- npm run test
73
 
74
  - uses: actions/upload-artifact@v4
75
  name: Upload playwright report
 
6
  jobs:
7
  test:
8
  runs-on: ubuntu-latest
 
 
9
  steps:
10
  - uses: actions/checkout@v4
11
 
12
  - name: Install uv
13
+ uses: astral-sh/setup-uv@v6
 
 
14
 
15
+ - name: Set up Python
16
+ run: uv python install
17
+
18
+ # - name: Debug ty issue
19
+ # run: |
20
+ # uv pip install ty
21
+ # uv pip list
22
+ # uv run python -m ty check
23
 
24
  - name: Install dependencies
25
  run: |
 
34
  - name: Run pre-commits
35
  run: |
36
  uv pip install pre-commit
37
+ uv run pre-commit run --all-files
 
 
 
 
 
 
 
 
 
 
38
 
39
+ - name: Run Python unittests
40
  run: |
41
+ uv run pytest --asyncio-mode=auto
 
42
 
43
+ - name: Build the documentation
44
  run: |
45
  uv pip install mkdocs-material mkdocstrings[python]
46
+ uv run mkdocs build
47
 
48
  - uses: actions/setup-node@v4
49
  with:
 
57
 
58
  - name: Run Playwright tests
59
  run: |
60
+ uv run bash -c 'cd lynxkite-app/web; npm run build; npm run test'
 
 
61
 
62
  - uses: actions/upload-artifact@v4
63
  name: Upload playwright report
.pre-commit-config.yaml CHANGED
@@ -15,3 +15,13 @@ repos:
15
  rev: v1.9.4
16
  hooks:
17
  - id: biome-check
 
 
 
 
 
 
 
 
 
 
 
15
  rev: v1.9.4
16
  hooks:
17
  - id: biome-check
18
+ # https://github.com/astral-sh/ty/issues/269
19
+ - repo: local
20
+ hooks:
21
+ - id: ty-check
22
+ name: ty-check
23
+ language: python
24
+ entry: uv run ty check
25
+ pass_filenames: false
26
+ args: [--python=.venv/]
27
+ additional_dependencies: [ty]
examples/fake_data.py CHANGED
@@ -1,5 +1,5 @@
1
  from lynxkite.core.ops import op
2
- from faker import Faker
3
  import pandas as pd
4
 
5
  faker = Faker()
 
1
  from lynxkite.core.ops import op
2
+ from faker import Faker # ty: ignore[unresolved-import]
3
  import pandas as pd
4
 
5
  faker = Faker()
examples/word2vec.py CHANGED
@@ -6,7 +6,7 @@ ENV = "LynxKite Graph Analytics"
6
 
7
  @op(ENV, "Word2vec for the top 1000 words", slow=True)
8
  def word2vec_1000():
9
- import staticvectors
10
 
11
  model = staticvectors.StaticVectors("neuml/word2vec-quantized")
12
  df = pd.read_csv(
 
6
 
7
  @op(ENV, "Word2vec for the top 1000 words", slow=True)
8
  def word2vec_1000():
9
+ import staticvectors # ty: ignore[unresolved-import]
10
 
11
  model = staticvectors.StaticVectors("neuml/word2vec-quantized")
12
  df = pd.read_csv(
lynxkite-app/src/lynxkite_app/crdt.py CHANGED
@@ -7,8 +7,8 @@ import pathlib
7
  import fastapi
8
  import os.path
9
  import pycrdt.websocket
10
- import pycrdt.store
11
- import uvicorn
12
  import builtins
13
  from lynxkite.core import workspace, ops
14
 
@@ -59,7 +59,8 @@ class WorkspaceWebsocketServer(pycrdt.websocket.WebsocketServer):
59
  room = pycrdt.websocket.YRoom(
60
  ystore=ystore, ydoc=ydoc, exception_handler=ws_exception_handler
61
  )
62
- room.ws = ws
 
63
 
64
  def on_change(changes):
65
  task = asyncio.create_task(workspace_changed(name, changes, ws))
@@ -106,7 +107,8 @@ class CodeWebsocketServer(WorkspaceWebsocketServer):
106
  room = pycrdt.websocket.YRoom(
107
  ystore=ystore, ydoc=ydoc, exception_handler=ws_exception_handler
108
  )
109
- room.text = text
 
110
 
111
  def on_change(changes):
112
  asyncio.create_task(code_changed(name, changes, text))
@@ -163,6 +165,7 @@ def crdt_update(
163
  ValueError: If the Python object provided is not a dict or list.
164
  """
165
  if isinstance(python_obj, dict):
 
166
  for key, value in python_obj.items():
167
  if key in non_collaborative_fields:
168
  crdt_obj[key] = value
@@ -179,6 +182,7 @@ def crdt_update(
179
  else:
180
  crdt_obj[key] = value
181
  elif isinstance(python_obj, list):
 
182
  for i, value in enumerate(python_obj):
183
  if isinstance(value, dict):
184
  if i >= len(crdt_obj):
@@ -220,7 +224,7 @@ last_known_versions = {}
220
  delayed_executions = {}
221
 
222
 
223
- async def workspace_changed(name: str, changes: pycrdt.MapEvent, ws_crdt: pycrdt.Map):
224
  """Callback to react to changes in the workspace.
225
 
226
  Args:
@@ -295,6 +299,14 @@ async def code_changed(name: str, changes: pycrdt.TextEvent, text: pycrdt.Text):
295
  f.write(contents)
296
 
297
 
 
 
 
 
 
 
 
 
298
  @contextlib.asynccontextmanager
299
  async def lifespan(app):
300
  global ws_websocket_server
 
7
  import fastapi
8
  import os.path
9
  import pycrdt.websocket
10
+ import pycrdt.store.file
11
+ import uvicorn.protocols.utils
12
  import builtins
13
  from lynxkite.core import workspace, ops
14
 
 
59
  room = pycrdt.websocket.YRoom(
60
  ystore=ystore, ydoc=ydoc, exception_handler=ws_exception_handler
61
  )
62
+ # We hang the YDoc pointer on the room, so it only gets garbage collected when the room does.
63
+ room.ws = ws # ty: ignore[unresolved-attribute]
64
 
65
  def on_change(changes):
66
  task = asyncio.create_task(workspace_changed(name, changes, ws))
 
107
  room = pycrdt.websocket.YRoom(
108
  ystore=ystore, ydoc=ydoc, exception_handler=ws_exception_handler
109
  )
110
+ # We hang the YDoc pointer on the room, so it only gets garbage collected when the room does.
111
+ room.text = text # ty: ignore[unresolved-attribute]
112
 
113
  def on_change(changes):
114
  asyncio.create_task(code_changed(name, changes, text))
 
165
  ValueError: If the Python object provided is not a dict or list.
166
  """
167
  if isinstance(python_obj, dict):
168
+ assert isinstance(crdt_obj, pycrdt.Map), "CRDT object must be a Map for a dict input"
169
  for key, value in python_obj.items():
170
  if key in non_collaborative_fields:
171
  crdt_obj[key] = value
 
182
  else:
183
  crdt_obj[key] = value
184
  elif isinstance(python_obj, list):
185
+ assert isinstance(crdt_obj, pycrdt.Array), "CRDT object must be an Array for a list input"
186
  for i, value in enumerate(python_obj):
187
  if isinstance(value, dict):
188
  if i >= len(crdt_obj):
 
224
  delayed_executions = {}
225
 
226
 
227
+ async def workspace_changed(name: str, changes: list[pycrdt.MapEvent], ws_crdt: pycrdt.Map):
228
  """Callback to react to changes in the workspace.
229
 
230
  Args:
 
299
  f.write(contents)
300
 
301
 
302
+ ws_websocket_server: WorkspaceWebsocketServer
303
+ code_websocket_server: CodeWebsocketServer
304
+
305
+
306
+ def get_room(name):
307
+ return ws_websocket_server.get_room(name)
308
+
309
+
310
  @contextlib.asynccontextmanager
311
  async def lifespan(app):
312
  global ws_websocket_server
lynxkite-app/src/lynxkite_app/main.py CHANGED
@@ -8,7 +8,7 @@ import pathlib
8
  import pkgutil
9
  from fastapi.staticfiles import StaticFiles
10
  from fastapi.middleware.gzip import GZipMiddleware
11
- import starlette
12
  from lynxkite.core import ops
13
  from lynxkite.core import workspace
14
  from . import crdt
@@ -136,7 +136,7 @@ async def upload(req: fastapi.Request):
136
  @app.post("/api/execute_workspace")
137
  async def execute_workspace(name: str):
138
  """Trigger and await the execution of a workspace."""
139
- room = await crdt.ws_websocket_server.get_room(name)
140
  ws_pyd = workspace.Workspace.model_validate(room.ws.to_py())
141
  await crdt.execute(name, room.ws, ws_pyd)
142
 
 
8
  import pkgutil
9
  from fastapi.staticfiles import StaticFiles
10
  from fastapi.middleware.gzip import GZipMiddleware
11
+ import starlette.exceptions
12
  from lynxkite.core import ops
13
  from lynxkite.core import workspace
14
  from . import crdt
 
136
  @app.post("/api/execute_workspace")
137
  async def execute_workspace(name: str):
138
  """Trigger and await the execution of a workspace."""
139
+ room = await crdt.get_room(name)
140
  ws_pyd = workspace.Workspace.model_validate(room.ws.to_py())
141
  await crdt.execute(name, room.ws, ws_pyd)
142
 
lynxkite-core/src/lynxkite/core/executors/one_by_one.py CHANGED
@@ -48,10 +48,10 @@ def register(env: str, cache: bool = True):
48
  """
49
  if cache:
50
  CACHES[env] = {}
51
- cache = CACHES[env]
52
  else:
53
- cache = None
54
- ops.EXECUTORS[env] = lambda ws: _execute(ws, ops.CATALOGS[env], cache=cache)
55
 
56
 
57
  def _get_stages(ws, catalog: ops.Catalog):
@@ -102,7 +102,9 @@ async def _await_if_needed(obj):
102
  return obj
103
 
104
 
105
- async def _execute(ws: workspace.Workspace, catalog: ops.Catalog, cache=None):
 
 
106
  nodes = {n.id: n for n in ws.nodes}
107
  contexts = {n.id: Context(node=n) for n in ws.nodes}
108
  edges = {n.id: [] for n in ws.nodes}
 
48
  """
49
  if cache:
50
  CACHES[env] = {}
51
+ _cache = CACHES[env]
52
  else:
53
+ _cache = None
54
+ ops.EXECUTORS[env] = lambda ws: _execute(ws, ops.CATALOGS[env], cache=_cache)
55
 
56
 
57
  def _get_stages(ws, catalog: ops.Catalog):
 
102
  return obj
103
 
104
 
105
+ async def _execute(
106
+ ws: workspace.Workspace, catalog: ops.Catalog, cache: typing.Optional[dict] = None
107
+ ):
108
  nodes = {n.id: n for n in ws.nodes}
109
  contexts = {n.id: Context(node=n) for n in ws.nodes}
110
  edges = {n.id: [] for n in ws.nodes}
lynxkite-core/src/lynxkite/core/ops.py CHANGED
@@ -6,7 +6,7 @@ import asyncio
6
  import enum
7
  import functools
8
  import json
9
- import importlib
10
  import inspect
11
  import pathlib
12
  import subprocess
@@ -498,6 +498,7 @@ def install_requirements(req: pathlib.Path):
498
 
499
  def run_user_script(script_path: pathlib.Path):
500
  spec = importlib.util.spec_from_file_location(script_path.stem, str(script_path))
 
501
  module = importlib.util.module_from_spec(spec)
502
  spec.loader.exec_module(module)
503
 
 
6
  import enum
7
  import functools
8
  import json
9
+ import importlib.util
10
  import inspect
11
  import pathlib
12
  import subprocess
 
498
 
499
  def run_user_script(script_path: pathlib.Path):
500
  spec = importlib.util.spec_from_file_location(script_path.stem, str(script_path))
501
+ assert spec
502
  module = importlib.util.module_from_spec(spec)
503
  spec.loader.exec_module(module)
504
 
lynxkite-core/src/lynxkite/core/workspace.py CHANGED
@@ -1,7 +1,7 @@
1
  """For working with LynxKite workspaces."""
2
 
3
  import json
4
- from typing import Optional
5
  import dataclasses
6
  import enum
7
  import os
@@ -10,6 +10,9 @@ import pydantic
10
  import tempfile
11
  from . import ops
12
 
 
 
 
13
 
14
  class BaseConfig(pydantic.BaseModel):
15
  model_config = pydantic.ConfigDict(
@@ -36,8 +39,7 @@ class WorkspaceNodeData(BaseConfig):
36
  input_metadata: Optional[object] = None
37
  error: Optional[str] = None
38
  status: NodeStatus = NodeStatus.done
39
- # Also contains a "meta" field when going out.
40
- # This is ignored when coming back from the frontend.
41
 
42
  @pydantic.model_validator(mode="before")
43
  @classmethod
@@ -47,6 +49,13 @@ class WorkspaceNodeData(BaseConfig):
47
  data["op_id"] = data["title"]
48
  return data
49
 
 
 
 
 
 
 
 
50
 
51
  class WorkspaceNode(BaseConfig):
52
  # Most of these fields are shared with ReactFlow.
@@ -56,13 +65,13 @@ class WorkspaceNode(BaseConfig):
56
  position: Position
57
  width: Optional[float] = None
58
  height: Optional[float] = None
59
- _crdt: pycrdt.Map
60
 
61
  def publish_started(self):
62
  """Notifies the frontend that work has started on this node."""
63
  self.data.error = None
64
  self.data.status = NodeStatus.active
65
- if hasattr(self, "_crdt") and "data" in self._crdt:
66
  with self._crdt.doc.transaction():
67
  self._crdt["data"]["error"] = None
68
  self._crdt["data"]["status"] = NodeStatus.active
@@ -73,7 +82,7 @@ class WorkspaceNode(BaseConfig):
73
  self.data.input_metadata = result.input_metadata
74
  self.data.error = result.error
75
  self.data.status = NodeStatus.done
76
- if hasattr(self, "_crdt") and "data" in self._crdt:
77
  with self._crdt.doc.transaction():
78
  try:
79
  self._crdt["data"]["status"] = NodeStatus.done
@@ -109,7 +118,7 @@ class Workspace(BaseConfig):
109
  env: str = ""
110
  nodes: list[WorkspaceNode] = dataclasses.field(default_factory=list)
111
  edges: list[WorkspaceEdge] = dataclasses.field(default_factory=list)
112
- _crdt: pycrdt.Map
113
 
114
  def normalize(self):
115
  if self.env not in ops.CATALOGS:
@@ -194,19 +203,19 @@ class Workspace(BaseConfig):
194
  if getattr(data, "meta", None) != op:
195
  data.meta = op
196
  # If the node is connected to a CRDT, update that too.
197
- if hasattr(node, "_crdt"):
198
  node._crdt["data"]["meta"] = op.model_dump()
199
  if node.type != op.type:
200
  node.type = op.type
201
- if hasattr(node, "_crdt"):
202
  node._crdt["type"] = op.type
203
  if data.error == "Unknown operation.":
204
  data.error = None
205
- if hasattr(node, "_crdt"):
206
  node._crdt["data"]["error"] = None
207
  else:
208
  data.error = "Unknown operation."
209
- if hasattr(node, "_crdt"):
210
  node._crdt["data"]["meta"] = {}
211
  node._crdt["data"]["error"] = "Unknown operation."
212
 
@@ -226,12 +235,16 @@ class Workspace(BaseConfig):
226
  kwargs["data"] = WorkspaceNodeData(
227
  title=func.__op__.name, op_id=func.__op__.id, params={}
228
  )
 
 
 
 
229
  kwargs.setdefault("type", "basic")
230
  kwargs.setdefault("id", f"{kwargs['data'].title} {random_string}")
231
  kwargs.setdefault("position", Position(x=0, y=0))
232
  kwargs.setdefault("width", 100)
233
  kwargs.setdefault("height", 100)
234
- node = WorkspaceNode(**kwargs)
235
  self.nodes.append(node)
236
  return node
237
 
 
1
  """For working with LynxKite workspaces."""
2
 
3
  import json
4
+ from typing import Optional, TYPE_CHECKING
5
  import dataclasses
6
  import enum
7
  import os
 
10
  import tempfile
11
  from . import ops
12
 
13
+ if TYPE_CHECKING:
14
+ from lynxkite.core import ops
15
+
16
 
17
  class BaseConfig(pydantic.BaseModel):
18
  model_config = pydantic.ConfigDict(
 
39
  input_metadata: Optional[object] = None
40
  error: Optional[str] = None
41
  status: NodeStatus = NodeStatus.done
42
+ meta: Optional["ops.Op"] = None
 
43
 
44
  @pydantic.model_validator(mode="before")
45
  @classmethod
 
49
  data["op_id"] = data["title"]
50
  return data
51
 
52
+ @pydantic.model_validator(mode="before")
53
+ @classmethod
54
+ def ignore_meta(cls, data: dict) -> dict:
55
+ """Metadata is never loaded. We will use fresh metadata."""
56
+ data["meta"] = None
57
+ return data
58
+
59
 
60
  class WorkspaceNode(BaseConfig):
61
  # Most of these fields are shared with ReactFlow.
 
65
  position: Position
66
  width: Optional[float] = None
67
  height: Optional[float] = None
68
+ _crdt: Optional[pycrdt.Map] = None
69
 
70
  def publish_started(self):
71
  """Notifies the frontend that work has started on this node."""
72
  self.data.error = None
73
  self.data.status = NodeStatus.active
74
+ if self._crdt and "data" in self._crdt:
75
  with self._crdt.doc.transaction():
76
  self._crdt["data"]["error"] = None
77
  self._crdt["data"]["status"] = NodeStatus.active
 
82
  self.data.input_metadata = result.input_metadata
83
  self.data.error = result.error
84
  self.data.status = NodeStatus.done
85
+ if self._crdt and "data" in self._crdt:
86
  with self._crdt.doc.transaction():
87
  try:
88
  self._crdt["data"]["status"] = NodeStatus.done
 
118
  env: str = ""
119
  nodes: list[WorkspaceNode] = dataclasses.field(default_factory=list)
120
  edges: list[WorkspaceEdge] = dataclasses.field(default_factory=list)
121
+ _crdt: Optional[pycrdt.Map] = None
122
 
123
  def normalize(self):
124
  if self.env not in ops.CATALOGS:
 
203
  if getattr(data, "meta", None) != op:
204
  data.meta = op
205
  # If the node is connected to a CRDT, update that too.
206
+ if node._crdt:
207
  node._crdt["data"]["meta"] = op.model_dump()
208
  if node.type != op.type:
209
  node.type = op.type
210
+ if node._crdt:
211
  node._crdt["type"] = op.type
212
  if data.error == "Unknown operation.":
213
  data.error = None
214
+ if node._crdt:
215
  node._crdt["data"]["error"] = None
216
  else:
217
  data.error = "Unknown operation."
218
+ if node._crdt:
219
  node._crdt["data"]["meta"] = {}
220
  node._crdt["data"]["error"] = "Unknown operation."
221
 
 
235
  kwargs["data"] = WorkspaceNodeData(
236
  title=func.__op__.name, op_id=func.__op__.id, params={}
237
  )
238
+ elif "title" in kwargs:
239
+ kwargs["data"] = WorkspaceNodeData(
240
+ title=kwargs["title"], op_id=kwargs["title"], params=kwargs.get("params", {})
241
+ )
242
  kwargs.setdefault("type", "basic")
243
  kwargs.setdefault("id", f"{kwargs['data'].title} {random_string}")
244
  kwargs.setdefault("position", Position(x=0, y=0))
245
  kwargs.setdefault("width", 100)
246
  kwargs.setdefault("height", 100)
247
+ node = WorkspaceNode(**kwargs) # ty: ignore[missing-argument]
248
  self.nodes.append(node)
249
  return node
250
 
lynxkite-core/tests/test_one_by_one.py CHANGED
@@ -13,8 +13,8 @@ async def test_optional_inputs():
13
  return [a + b for a, b in zip(a, b)] if b else a
14
 
15
  assert maybe_add.__op__.inputs == [
16
- ops.Input(name="a", type=list[int], position="bottom"),
17
- ops.Input(name="b", type=list[int] | None, position="bottom"),
18
  ]
19
  one_by_one.register("test")
20
  ws = workspace.Workspace(env="test", nodes=[], edges=[])
 
13
  return [a + b for a, b in zip(a, b)] if b else a
14
 
15
  assert maybe_add.__op__.inputs == [
16
+ ops.Input(name="a", type=list[int], position=ops.Position.BOTTOM),
17
+ ops.Input(name="b", type=list[int] | None, position=ops.Position.BOTTOM),
18
  ]
19
  one_by_one.register("test")
20
  ws = workspace.Workspace(env="test", nodes=[], edges=[])
lynxkite-core/tests/test_ops.py CHANGED
@@ -11,10 +11,10 @@ def test_op_decorator_no_params_no_types_default_positions():
11
  assert add.__op__.name == "add"
12
  assert add.__op__.params == []
13
  assert add.__op__.inputs == [
14
- ops.Input(name="a", type=inspect._empty, position="left"),
15
- ops.Input(name="b", type=inspect._empty, position="left"),
16
  ]
17
- assert add.__op__.outputs == [ops.Output(name="result", type=None, position="right")]
18
  assert add.__op__.type == "basic"
19
  assert ops.CATALOGS["test"]["add"] == add.__op__
20
 
@@ -29,10 +29,12 @@ def test_op_decorator_custom_positions():
29
  assert add.__op__.name == "add"
30
  assert add.__op__.params == []
31
  assert add.__op__.inputs == [
32
- ops.Input(name="a", type=inspect._empty, position="right"),
33
- ops.Input(name="b", type=inspect._empty, position="top"),
 
 
 
34
  ]
35
- assert add.__op__.outputs == [ops.Output(name="result", type=None, position="bottom")]
36
  assert add.__op__.type == "basic"
37
  assert ops.CATALOGS["test"]["add"] == add.__op__
38
 
@@ -45,10 +47,12 @@ def test_op_decorator_with_params_and_types_():
45
  assert multiply.__op__.name == "multiply"
46
  assert multiply.__op__.params == [ops.Parameter(name="param", default="param", type=str)]
47
  assert multiply.__op__.inputs == [
48
- ops.Input(name="a", type=int, position="left"),
49
- ops.Input(name="b", type=float, position="left"),
 
 
 
50
  ]
51
- assert multiply.__op__.outputs == [ops.Output(name="result", type=None, position="right")]
52
  assert multiply.__op__.type == "basic"
53
  assert ops.CATALOGS["test"]["multiply"] == multiply.__op__
54
 
@@ -66,12 +70,14 @@ def test_op_decorator_with_complex_types():
66
  assert complex_op.__op__.name == "color_op"
67
  assert complex_op.__op__.params == []
68
  assert complex_op.__op__.inputs == [
69
- ops.Input(name="color", type=Color, position="left"),
70
- ops.Input(name="color_list", type=list[Color], position="left"),
71
- ops.Input(name="color_dict", type=dict[str, Color], position="left"),
72
  ]
73
  assert complex_op.__op__.type == "basic"
74
- assert complex_op.__op__.outputs == [ops.Output(name="result", type=None, position="right")]
 
 
75
  assert ops.CATALOGS["test"]["color_op"] == complex_op.__op__
76
 
77
 
 
11
  assert add.__op__.name == "add"
12
  assert add.__op__.params == []
13
  assert add.__op__.inputs == [
14
+ ops.Input(name="a", type=inspect._empty, position=ops.Position.LEFT),
15
+ ops.Input(name="b", type=inspect._empty, position=ops.Position.LEFT),
16
  ]
17
+ assert add.__op__.outputs == [ops.Output(name="result", type=None, position=ops.Position.RIGHT)]
18
  assert add.__op__.type == "basic"
19
  assert ops.CATALOGS["test"]["add"] == add.__op__
20
 
 
29
  assert add.__op__.name == "add"
30
  assert add.__op__.params == []
31
  assert add.__op__.inputs == [
32
+ ops.Input(name="a", type=inspect._empty, position=ops.Position.RIGHT),
33
+ ops.Input(name="b", type=inspect._empty, position=ops.Position.TOP),
34
+ ]
35
+ assert add.__op__.outputs == [
36
+ ops.Output(name="result", type=None, position=ops.Position.BOTTOM)
37
  ]
 
38
  assert add.__op__.type == "basic"
39
  assert ops.CATALOGS["test"]["add"] == add.__op__
40
 
 
47
  assert multiply.__op__.name == "multiply"
48
  assert multiply.__op__.params == [ops.Parameter(name="param", default="param", type=str)]
49
  assert multiply.__op__.inputs == [
50
+ ops.Input(name="a", type=int, position=ops.Position.LEFT),
51
+ ops.Input(name="b", type=float, position=ops.Position.LEFT),
52
+ ]
53
+ assert multiply.__op__.outputs == [
54
+ ops.Output(name="result", type=None, position=ops.Position.RIGHT)
55
  ]
 
56
  assert multiply.__op__.type == "basic"
57
  assert ops.CATALOGS["test"]["multiply"] == multiply.__op__
58
 
 
70
  assert complex_op.__op__.name == "color_op"
71
  assert complex_op.__op__.params == []
72
  assert complex_op.__op__.inputs == [
73
+ ops.Input(name="color", type=Color, position=ops.Position.LEFT),
74
+ ops.Input(name="color_list", type=list[Color], position=ops.Position.LEFT),
75
+ ops.Input(name="color_dict", type=dict[str, Color], position=ops.Position.LEFT),
76
  ]
77
  assert complex_op.__op__.type == "basic"
78
+ assert complex_op.__op__.outputs == [
79
+ ops.Output(name="result", type=None, position=ops.Position.RIGHT)
80
+ ]
81
  assert ops.CATALOGS["test"]["color_op"] == complex_op.__op__
82
 
83
 
lynxkite-core/tests/test_simple.py CHANGED
@@ -12,8 +12,8 @@ async def test_optional_inputs():
12
  return a + (b or 0)
13
 
14
  assert maybe_add.__op__.inputs == [
15
- ops.Input(name="a", type=int, position="left"),
16
- ops.Input(name="b", type=int | None, position="left"),
17
  ]
18
  simple.register("test")
19
  ws = workspace.Workspace(env="test", nodes=[], edges=[])
 
12
  return a + (b or 0)
13
 
14
  assert maybe_add.__op__.inputs == [
15
+ ops.Input(name="a", type=int, position=ops.Position.LEFT),
16
+ ops.Input(name="b", type=int | None, position=ops.Position.LEFT),
17
  ]
18
  simple.register("test")
19
  ws = workspace.Workspace(env="test", nodes=[], edges=[])
lynxkite-core/tests/test_workspace.py CHANGED
@@ -9,12 +9,12 @@ def test_save_load():
9
  ws.add_node(
10
  id="1",
11
  type="node_type",
12
- data=workspace.WorkspaceNodeData(title="Node 1", params={}),
13
  )
14
  ws.add_node(
15
  id="2",
16
  type="node_type",
17
- data=workspace.WorkspaceNodeData(title="Node 2", params={}),
18
  )
19
  ws.edges.append(
20
  workspace.WorkspaceEdge(
@@ -70,17 +70,18 @@ def test_update_metadata():
70
  ws.add_node(
71
  id="1",
72
  type="basic",
73
- data=workspace.WorkspaceNodeData(title="Test Operation", params={}),
74
  )
75
  ws.add_node(
76
  id="2",
77
  type="basic",
78
- data=workspace.WorkspaceNodeData(title="Unknown Operation", params={}),
79
  )
80
  ws.update_metadata()
 
81
  assert ws.nodes[0].data.meta.name == "Test Operation"
82
  assert ws.nodes[0].data.error is None
83
- assert not hasattr(ws.nodes[1].data, "meta")
84
  assert ws.nodes[1].data.error == "Unknown operation."
85
 
86
 
 
9
  ws.add_node(
10
  id="1",
11
  type="node_type",
12
+ title="Node 1",
13
  )
14
  ws.add_node(
15
  id="2",
16
  type="node_type",
17
+ title="Node 2",
18
  )
19
  ws.edges.append(
20
  workspace.WorkspaceEdge(
 
70
  ws.add_node(
71
  id="1",
72
  type="basic",
73
+ title="Test Operation",
74
  )
75
  ws.add_node(
76
  id="2",
77
  type="basic",
78
+ title="Unknown Operation",
79
  )
80
  ws.update_metadata()
81
+ assert ws.nodes[0].data.meta
82
  assert ws.nodes[0].data.meta.name == "Test Operation"
83
  assert ws.nodes[0].data.error is None
84
+ assert ws.nodes[1].data.meta is None
85
  assert ws.nodes[1].data.error == "Unknown operation."
86
 
87
 
lynxkite-graph-analytics/src/lynxkite_graph_analytics/__init__.py CHANGED
@@ -4,7 +4,7 @@ import os
4
  import pandas as pd
5
 
6
  if os.environ.get("NX_CUGRAPH_AUTOCONFIG", "").strip().lower() == "true":
7
- import cudf.pandas
8
 
9
  cudf.pandas.install()
10
 
 
4
  import pandas as pd
5
 
6
  if os.environ.get("NX_CUGRAPH_AUTOCONFIG", "").strip().lower() == "true":
7
+ import cudf.pandas # ty: ignore[unresolved-import]
8
 
9
  cudf.pandas.install()
10
 
lynxkite-graph-analytics/src/lynxkite_graph_analytics/core.py CHANGED
@@ -227,13 +227,13 @@ def disambiguate_edges(ws: workspace.Workspace):
227
  for edge in reversed(ws.edges):
228
  dst_node = nodes[edge.target]
229
  op = catalog.get(dst_node.data.op_id)
230
- if op.get_input(edge.targetHandle).type == list[Bundle]:
231
  # Takes multiple bundles as an input. No need to disambiguate.
232
  continue
233
  if (edge.target, edge.targetHandle) in seen:
234
  i = ws.edges.index(edge)
235
  del ws.edges[i]
236
- if hasattr(ws, "_crdt"):
237
  del ws._crdt["edges"][i]
238
  seen.add((edge.target, edge.targetHandle))
239
 
 
227
  for edge in reversed(ws.edges):
228
  dst_node = nodes[edge.target]
229
  op = catalog.get(dst_node.data.op_id)
230
+ if not op or op.get_input(edge.targetHandle).type == list[Bundle]:
231
  # Takes multiple bundles as an input. No need to disambiguate.
232
  continue
233
  if (edge.target, edge.targetHandle) in seen:
234
  i = ws.edges.index(edge)
235
  del ws.edges[i]
236
+ if ws._crdt:
237
  del ws._crdt["edges"][i]
238
  seen.add((edge.target, edge.targetHandle))
239
 
lynxkite-graph-analytics/src/lynxkite_graph_analytics/lynxkite_ops.py CHANGED
@@ -8,7 +8,8 @@ from collections import deque
8
 
9
  from . import core
10
  import grandcypher
11
- import matplotlib
 
12
  import networkx as nx
13
  import pandas as pd
14
  import polars as pl
@@ -205,6 +206,7 @@ def _map_color(value):
205
  else:
206
  cmap = matplotlib.cm.get_cmap("Paired")
207
  categories = pd.Index(value.unique())
 
208
  colors = cmap.colors[: len(categories)]
209
  return [
210
  "#{:02x}{:02x}{:02x}".format(int(r * 255), int(g * 255), int(b * 255))
@@ -312,7 +314,7 @@ def view_tables(bundle: core.Bundle, *, _tables_open: str = "", limit: int = 100
312
  view="graph_creation_view",
313
  outputs=["output"],
314
  )
315
- def organize(bundles: list[core.Bundle], *, relations: str = None) -> core.Bundle:
316
  """Merge multiple inputs and construct graphs from the tables.
317
 
318
  To create a graph, import tables for edges and nodes, and combine them in this operation.
@@ -322,6 +324,9 @@ def organize(bundles: list[core.Bundle], *, relations: str = None) -> core.Bundl
322
  bundle.dfs.update(b.dfs)
323
  bundle.relations.extend(b.relations)
324
  bundle.other.update(b.other)
325
- if not (relations is None or relations.strip() == ""):
326
- bundle.relations = [core.RelationDefinition(**r) for r in json.loads(relations).values()]
 
 
 
327
  return ops.Result(output=bundle, display=bundle.to_dict(limit=100))
 
8
 
9
  from . import core
10
  import grandcypher
11
+ import matplotlib.cm
12
+ import matplotlib.colors
13
  import networkx as nx
14
  import pandas as pd
15
  import polars as pl
 
206
  else:
207
  cmap = matplotlib.cm.get_cmap("Paired")
208
  categories = pd.Index(value.unique())
209
+ assert isinstance(cmap, matplotlib.colors.ListedColormap)
210
  colors = cmap.colors[: len(categories)]
211
  return [
212
  "#{:02x}{:02x}{:02x}".format(int(r * 255), int(g * 255), int(b * 255))
 
314
  view="graph_creation_view",
315
  outputs=["output"],
316
  )
317
+ def organize(bundles: list[core.Bundle], *, relations: str = ""):
318
  """Merge multiple inputs and construct graphs from the tables.
319
 
320
  To create a graph, import tables for edges and nodes, and combine them in this operation.
 
324
  bundle.dfs.update(b.dfs)
325
  bundle.relations.extend(b.relations)
326
  bundle.other.update(b.other)
327
+ if relations.strip():
328
+ bundle.relations = [
329
+ core.RelationDefinition(**r) # ty: ignore[missing-argument]
330
+ for r in json.loads(relations).values()
331
+ ]
332
  return ops.Result(output=bundle, display=bundle.to_dict(limit=100))
lynxkite-graph-analytics/src/lynxkite_graph_analytics/ml_ops.py CHANGED
@@ -216,7 +216,7 @@ def view_vectors(
216
  metric: UMAPMetric = UMAPMetric.euclidean,
217
  ):
218
  try:
219
- from cuml.manifold.umap import UMAP
220
  except ImportError:
221
  from umap import UMAP
222
  vec = np.stack(bundle.dfs[table_name][vector_column].to_numpy())
 
216
  metric: UMAPMetric = UMAPMetric.euclidean,
217
  ):
218
  try:
219
+ from cuml.manifold.umap import UMAP # ty: ignore[unresolved-import]
220
  except ImportError:
221
  from umap import UMAP
222
  vec = np.stack(bundle.dfs[table_name][vector_column].to_numpy())
lynxkite-graph-analytics/src/lynxkite_graph_analytics/networkx_ops.py CHANGED
@@ -1,14 +1,14 @@
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
 
@@ -17,20 +17,22 @@ 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",
@@ -54,15 +56,15 @@ def doc_to_type(name: str, type_hint: str) -> type:
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"]:
@@ -72,27 +74,27 @@ def doc_to_type(name: str, type_hint: str) -> type:
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",
@@ -104,13 +106,13 @@ def doc_to_type(name: str, type_hint: str) -> type:
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":
@@ -135,8 +137,8 @@ def doc_to_type(name: str, type_hint: str) -> type:
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]:
@@ -186,13 +188,13 @@ def wrapped(name: str, func):
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:
@@ -203,10 +205,10 @@ def _get_params(func) -> dict | None:
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
  p = ops.Parameter.basic(
212
  name=name,
 
1
  """Automatically wraps all NetworkX functions as LynxKite operations."""
2
 
 
 
3
  from lynxkite.core import ops
4
+ import collections.abc
5
+ import enum
6
  import functools
7
  import inspect
8
  import networkx as nx
 
 
9
  import pandas as pd
10
+ import re
11
+ import types
12
 
13
  ENV = "LynxKite Graph Analytics"
14
 
 
17
  pass
18
 
19
 
20
+ class Failure(str, enum.Enum):
21
+ UNSUPPORTED = "unsupported" # This parameter will be hidden.
22
+ SKIP = "skip" # We have to skip the whole function.
23
 
24
 
25
+ def doc_to_type(name: str, type_hint: str) -> type | types.UnionType | Failure:
26
  type_hint = type_hint.lower()
27
  type_hint = re.sub("[(][^)]+[)]", "", type_hint).strip().strip(".")
28
  if " " in name or "http" in name:
29
+ return Failure.UNSUPPORTED # Not a parameter type.
30
  if type_hint.endswith(", optional"):
31
  w = doc_to_type(name, type_hint.removesuffix(", optional").strip())
32
+ if w is Failure.UNSUPPORTED or w is Failure.SKIP:
33
+ return Failure.SKIP
34
+ assert not isinstance(w, Failure)
35
+ return w | None
36
  if type_hint in [
37
  "a digraph or multidigraph",
38
  "a graph g",
 
56
  ]:
57
  return nx.DiGraph
58
  elif type_hint == "node":
59
+ return Failure.UNSUPPORTED
60
  elif type_hint == '"node (optional)"':
61
+ return Failure.SKIP
62
  elif type_hint == '"edge"':
63
+ return Failure.UNSUPPORTED
64
  elif type_hint == '"edge (optional)"':
65
+ return Failure.SKIP
66
  elif type_hint in ["class", "data type"]:
67
+ return Failure.UNSUPPORTED
68
  elif type_hint in ["string", "str", "node label"]:
69
  return str
70
  elif type_hint in ["string or none", "none or string", "string, or none"]:
 
74
  elif type_hint in ["bool", "boolean"]:
75
  return bool
76
  elif type_hint == "tuple":
77
+ return Failure.UNSUPPORTED
78
  elif type_hint == "set":
79
+ return Failure.UNSUPPORTED
80
  elif type_hint == "list of floats":
81
+ return Failure.UNSUPPORTED
82
  elif type_hint == "list of floats or float":
83
  return float
84
  elif type_hint in ["dict", "dictionary"]:
85
+ return Failure.UNSUPPORTED
86
  elif type_hint == "scalar or dictionary":
87
  return float
88
  elif type_hint == "none or dict":
89
+ return Failure.SKIP
90
  elif type_hint in ["function", "callable"]:
91
+ return Failure.UNSUPPORTED
92
  elif type_hint in [
93
  "collection",
94
  "container of nodes",
95
  "list of nodes",
96
  ]:
97
+ return Failure.UNSUPPORTED
98
  elif type_hint in [
99
  "container",
100
  "generator",
 
106
  "list or tuple",
107
  "list",
108
  ]:
109
+ return Failure.UNSUPPORTED
110
  elif type_hint == "generator of sets":
111
+ return Failure.UNSUPPORTED
112
  elif type_hint == "dict or a set of 2 or 3 tuples":
113
+ return Failure.UNSUPPORTED
114
  elif type_hint == "set of 2 or 3 tuples":
115
+ return Failure.UNSUPPORTED
116
  elif type_hint == "none, string or function":
117
  return str | None
118
  elif type_hint == "string or function" and name == "weight":
 
137
  elif name == "weight":
138
  return str
139
  elif type_hint == "object":
140
+ return Failure.UNSUPPORTED
141
+ return Failure.SKIP
142
 
143
 
144
  def types_from_doc(doc: str) -> dict[str, type]:
 
188
  return wrapper
189
 
190
 
191
+ def _get_params(func) -> list[ops.Parameter | ops.ParameterGroup]:
192
  sig = inspect.signature(func)
193
  # Get types from docstring.
194
  types = types_from_doc(func.__doc__)
195
  # Always hide these.
196
  for k in ["backend", "backend_kwargs", "create_using"]:
197
+ types[k] = Failure.SKIP
198
  # Add in types based on signature.
199
  for k, param in sig.parameters.items():
200
  if k in types:
 
205
  types[k] = int
206
  params = []
207
  for name, param in sig.parameters.items():
208
+ _type = types.get(name, Failure.UNSUPPORTED)
209
+ if _type is Failure.UNSUPPORTED:
210
  raise UnsupportedParameterType(name)
211
+ if _type is Failure.SKIP or _type in [nx.Graph, nx.DiGraph]:
212
  continue
213
  p = ops.Parameter.basic(
214
  name=name,
lynxkite-graph-analytics/src/lynxkite_graph_analytics/pytorch/pytorch_core.py CHANGED
@@ -3,7 +3,7 @@
3
  import copy
4
  import graphlib
5
  import io
6
- import numpy as np
7
  import pydantic
8
  from lynxkite.core import ops, workspace
9
  import torch
@@ -36,8 +36,12 @@ def reg(name, inputs=[], outputs=None, params=[], **kwargs):
36
  return ops.register_passive_op(
37
  ENV,
38
  name,
39
- inputs=[ops.Input(name=name, position="bottom", type="tensor") for name in inputs],
40
- outputs=[ops.Output(name=name, position="top", type="tensor") for name in outputs],
 
 
 
 
41
  params=params,
42
  **kwargs,
43
  )
@@ -75,16 +79,16 @@ class ModelMapping(pydantic.BaseModel):
75
  map: dict[str, ColumnSpec]
76
 
77
 
78
- def _torch_save(data) -> str:
79
  """Saves PyTorch data (modules, tensors) as a string."""
80
  buffer = io.BytesIO()
81
  torch.save(data, buffer)
82
  return buffer.getvalue()
83
 
84
 
85
- def _torch_load(data: str) -> any:
86
  """Loads PyTorch data (modules, tensors) from a string."""
87
- buffer = io.BytesIO(data) # noqa: F821
88
  return torch.load(buffer)
89
 
90
 
@@ -98,7 +102,7 @@ class ModelConfig:
98
  input_output_names: list[str]
99
  loss: torch.nn.Module
100
  source_workspace_json: str
101
- optimizer_parameters: dict[str, any]
102
  optimizer: torch.optim.Optimizer | None = None
103
  source_workspace: str | None = None
104
  trained: bool = False
@@ -116,7 +120,7 @@ class ModelConfig:
116
  values = {k: v for k, v in zip(self.model_outputs, output)}
117
  return values
118
 
119
- def inference(self, inputs: dict[str, torch.Tensor]) -> dict[str, np.ndarray]:
120
  """Inference on a single batch."""
121
  self.model.eval()
122
  return self._forward(inputs)
@@ -422,7 +426,7 @@ class ModelBuilder:
422
  op = self.catalog["Optimizer"]
423
  cfg["optimizer_parameters"] = op.convert_params(self.nodes[self.optimizer].data.params)
424
  cfg["source_workspace_json"] = self.ws.model_dump_json()
425
- return ModelConfig(**cfg)
426
 
427
  def get_names(self, *ids: list[str]) -> dict[str, str]:
428
  """Returns a mapping from internal IDs to human-readable names."""
@@ -448,7 +452,7 @@ class ModelBuilder:
448
 
449
 
450
  def to_batch_tensors(
451
- b: core.Bundle, batch_size: int, batch_index: int, m: ModelMapping | None
452
  ) -> dict[str, torch.Tensor]:
453
  """Extracts tensors from a bundle for a specific batch using a model mapping."""
454
  tensors = {}
 
3
  import copy
4
  import graphlib
5
  import io
6
+ import typing
7
  import pydantic
8
  from lynxkite.core import ops, workspace
9
  import torch
 
36
  return ops.register_passive_op(
37
  ENV,
38
  name,
39
+ inputs=[
40
+ ops.Input(name=name, position=ops.Position.BOTTOM, type="tensor") for name in inputs
41
+ ],
42
+ outputs=[
43
+ ops.Output(name=name, position=ops.Position.TOP, type="tensor") for name in outputs
44
+ ],
45
  params=params,
46
  **kwargs,
47
  )
 
79
  map: dict[str, ColumnSpec]
80
 
81
 
82
+ def _torch_save(data) -> bytes:
83
  """Saves PyTorch data (modules, tensors) as a string."""
84
  buffer = io.BytesIO()
85
  torch.save(data, buffer)
86
  return buffer.getvalue()
87
 
88
 
89
+ def _torch_load(data: bytes) -> typing.Any:
90
  """Loads PyTorch data (modules, tensors) from a string."""
91
+ buffer = io.BytesIO(data)
92
  return torch.load(buffer)
93
 
94
 
 
102
  input_output_names: list[str]
103
  loss: torch.nn.Module
104
  source_workspace_json: str
105
+ optimizer_parameters: dict[str, typing.Any]
106
  optimizer: torch.optim.Optimizer | None = None
107
  source_workspace: str | None = None
108
  trained: bool = False
 
120
  values = {k: v for k, v in zip(self.model_outputs, output)}
121
  return values
122
 
123
+ def inference(self, inputs: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]:
124
  """Inference on a single batch."""
125
  self.model.eval()
126
  return self._forward(inputs)
 
426
  op = self.catalog["Optimizer"]
427
  cfg["optimizer_parameters"] = op.convert_params(self.nodes[self.optimizer].data.params)
428
  cfg["source_workspace_json"] = self.ws.model_dump_json()
429
+ return ModelConfig(**cfg) # ty: ignore[missing-argument]
430
 
431
  def get_names(self, *ids: list[str]) -> dict[str, str]:
432
  """Returns a mapping from internal IDs to human-readable names."""
 
452
 
453
 
454
  def to_batch_tensors(
455
+ b: core.Bundle, batch_size: int, batch_index: int, m: ModelMapping
456
  ) -> dict[str, torch.Tensor]:
457
  """Extracts tensors from a bundle for a specific batch using a model mapping."""
458
  tensors = {}
lynxkite-graph-analytics/src/lynxkite_graph_analytics/pytorch/pytorch_ops.py CHANGED
@@ -117,7 +117,7 @@ def neural_ode_mlp(
117
 
118
  @op("Attention", outputs=["outputs", "weights"])
119
  def attention(query, key, value, *, embed_dim=1024, num_heads=1, dropout=0.0):
120
- return torch.nn.MultiHeadAttention(embed_dim, num_heads, dropout=dropout, need_weights=True)
121
 
122
 
123
  @op("LayerNorm", outputs=["outputs", "weights"])
@@ -250,8 +250,8 @@ reg(
250
  ops.register_passive_op(
251
  ENV,
252
  "Repeat",
253
- inputs=[ops.Input(name="input", position="top", type="tensor")],
254
- outputs=[ops.Output(name="output", position="bottom", type="tensor")],
255
  params=[
256
  ops.Parameter.basic("times", 1, int),
257
  ops.Parameter.basic("same_weights", False, bool),
@@ -261,8 +261,8 @@ ops.register_passive_op(
261
  ops.register_passive_op(
262
  ENV,
263
  "Recurrent chain",
264
- inputs=[ops.Input(name="input", position="top", type="tensor")],
265
- outputs=[ops.Output(name="output", position="bottom", type="tensor")],
266
  params=[],
267
  )
268
 
 
117
 
118
  @op("Attention", outputs=["outputs", "weights"])
119
  def attention(query, key, value, *, embed_dim=1024, num_heads=1, dropout=0.0):
120
+ return torch.nn.MultiheadAttention(embed_dim, num_heads, dropout=dropout)
121
 
122
 
123
  @op("LayerNorm", outputs=["outputs", "weights"])
 
250
  ops.register_passive_op(
251
  ENV,
252
  "Repeat",
253
+ inputs=[ops.Input(name="input", position=ops.Position.TOP, type="tensor")],
254
+ outputs=[ops.Output(name="output", position=ops.Position.BOTTOM, type="tensor")],
255
  params=[
256
  ops.Parameter.basic("times", 1, int),
257
  ops.Parameter.basic("same_weights", False, bool),
 
261
  ops.register_passive_op(
262
  ENV,
263
  "Recurrent chain",
264
+ inputs=[ops.Input(name="input", position=ops.Position.TOP, type="tensor")],
265
+ outputs=[ops.Output(name="output", position=ops.Position.BOTTOM, type="tensor")],
266
  params=[],
267
  )
268
 
lynxkite-graph-analytics/tests/test_core.py CHANGED
@@ -20,19 +20,19 @@ async def test_multi_input_box():
20
  ws.add_node(
21
  id="1",
22
  type="node_type",
23
- data=workspace.WorkspaceNodeData(title="Create Bundle", params={}),
24
  position=workspace.Position(x=0, y=0),
25
  )
26
  ws.add_node(
27
  id="2",
28
  type="node_type",
29
- data=workspace.WorkspaceNodeData(title="Create Bundle", params={}),
30
  position=workspace.Position(x=0, y=0),
31
  )
32
  ws.add_node(
33
  id="3",
34
  type="node_type",
35
- data=workspace.WorkspaceNodeData(title="Multi input op", params={}),
36
  position=workspace.Position(x=0, y=0),
37
  )
38
  ws.edges = [
 
20
  ws.add_node(
21
  id="1",
22
  type="node_type",
23
+ title="Create Bundle",
24
  position=workspace.Position(x=0, y=0),
25
  )
26
  ws.add_node(
27
  id="2",
28
  type="node_type",
29
+ title="Create Bundle",
30
  position=workspace.Position(x=0, y=0),
31
  )
32
  ws.add_node(
33
  id="3",
34
  type="node_type",
35
+ title="Multi input op",
36
  position=workspace.Position(x=0, y=0),
37
  )
38
  ws.edges = [
lynxkite-graph-analytics/tests/test_lynxkite_ops.py CHANGED
@@ -12,7 +12,7 @@ async def test_execute_operation_not_in_catalog():
12
  ws.add_node(
13
  id="1",
14
  type="node_type",
15
- data=workspace.WorkspaceNodeData(title="Non existing op", params={}),
16
  position=workspace.Position(x=0, y=0),
17
  )
18
  await execute(ws)
@@ -78,25 +78,25 @@ async def test_execute_operation_inputs_correct_cast():
78
  ws.add_node(
79
  id="1",
80
  type="node_type",
81
- data=workspace.WorkspaceNodeData(title="Create Bundle", params={}),
82
  position=workspace.Position(x=0, y=0),
83
  )
84
  ws.add_node(
85
  id="2",
86
  type="node_type",
87
- data=workspace.WorkspaceNodeData(title="Bundle to Graph", params={}),
88
  position=workspace.Position(x=100, y=0),
89
  )
90
  ws.add_node(
91
  id="3",
92
  type="node_type",
93
- data=workspace.WorkspaceNodeData(title="Graph to Bundle", params={}),
94
  position=workspace.Position(x=200, y=0),
95
  )
96
  ws.add_node(
97
  id="4",
98
  type="node_type",
99
- data=workspace.WorkspaceNodeData(title="Dataframe to Bundle", params={}),
100
  position=workspace.Position(x=300, y=0),
101
  )
102
  ws.edges = [
@@ -136,19 +136,19 @@ async def test_multiple_inputs():
136
  ws.add_node(
137
  id="one",
138
  type="cool",
139
- data=workspace.WorkspaceNodeData(title="One", params={}),
140
  position=workspace.Position(x=0, y=0),
141
  )
142
  ws.add_node(
143
  id="two",
144
  type="cool",
145
- data=workspace.WorkspaceNodeData(title="Two", params={}),
146
  position=workspace.Position(x=100, y=0),
147
  )
148
  ws.add_node(
149
  id="smaller",
150
  type="cool",
151
- data=workspace.WorkspaceNodeData(title="Smaller?", params={}),
152
  position=workspace.Position(x=200, y=0),
153
  )
154
  ws.edges = [
@@ -188,8 +188,8 @@ async def test_optional_inputs():
188
  return a + (b or 0)
189
 
190
  assert maybe_add.__op__.inputs == [
191
- ops.Input(name="a", type=int, position="left"),
192
- ops.Input(name="b", type=int | None, position="left"),
193
  ]
194
  ws = workspace.Workspace(env="test", nodes=[], edges=[])
195
  a = ws.add_node(one)
 
12
  ws.add_node(
13
  id="1",
14
  type="node_type",
15
+ title="Non existing op",
16
  position=workspace.Position(x=0, y=0),
17
  )
18
  await execute(ws)
 
78
  ws.add_node(
79
  id="1",
80
  type="node_type",
81
+ title="Create Bundle",
82
  position=workspace.Position(x=0, y=0),
83
  )
84
  ws.add_node(
85
  id="2",
86
  type="node_type",
87
+ title="Bundle to Graph",
88
  position=workspace.Position(x=100, y=0),
89
  )
90
  ws.add_node(
91
  id="3",
92
  type="node_type",
93
+ title="Graph to Bundle",
94
  position=workspace.Position(x=200, y=0),
95
  )
96
  ws.add_node(
97
  id="4",
98
  type="node_type",
99
+ title="Dataframe to Bundle",
100
  position=workspace.Position(x=300, y=0),
101
  )
102
  ws.edges = [
 
136
  ws.add_node(
137
  id="one",
138
  type="cool",
139
+ title="One",
140
  position=workspace.Position(x=0, y=0),
141
  )
142
  ws.add_node(
143
  id="two",
144
  type="cool",
145
+ title="Two",
146
  position=workspace.Position(x=100, y=0),
147
  )
148
  ws.add_node(
149
  id="smaller",
150
  type="cool",
151
+ title="Smaller?",
152
  position=workspace.Position(x=200, y=0),
153
  )
154
  ws.edges = [
 
188
  return a + (b or 0)
189
 
190
  assert maybe_add.__op__.inputs == [
191
+ ops.Input(name="a", type=int, position=ops.Position.LEFT),
192
+ ops.Input(name="b", type=int | None, position=ops.Position.LEFT),
193
  ]
194
  ws = workspace.Workspace(env="test", nodes=[], edges=[])
195
  a = ws.add_node(one)
lynxkite-graph-analytics/tests/test_pytorch_model_ops.py CHANGED
@@ -1,3 +1,4 @@
 
1
  from lynxkite.core import workspace
2
  from lynxkite_graph_analytics.pytorch import pytorch_core
3
  import torch
@@ -11,7 +12,8 @@ def make_ws(env, nodes: dict[str, dict], edges: list[tuple[str, str]]):
11
  del data["title"]
12
  ws.add_node(
13
  id=id,
14
- data=workspace.WorkspaceNodeData(title=title, params=data),
 
15
  )
16
  ws.edges = [
17
  workspace.WorkspaceEdge(
@@ -27,10 +29,12 @@ def make_ws(env, nodes: dict[str, dict], edges: list[tuple[str, str]]):
27
 
28
 
29
  def summarize_layers(m: pytorch_core.ModelConfig) -> str:
 
30
  return "".join(str(e)[:2] for e in m.model)
31
 
32
 
33
  def summarize_connections(m: pytorch_core.ModelConfig) -> str:
 
34
  return " ".join(
35
  "".join(n[0] for n in c.param_names) + "->" + "".join(n[0] for n in c.return_names)
36
  for c in m.model._children
 
1
+ import torch_geometric.nn as pyg_nn
2
  from lynxkite.core import workspace
3
  from lynxkite_graph_analytics.pytorch import pytorch_core
4
  import torch
 
12
  del data["title"]
13
  ws.add_node(
14
  id=id,
15
+ title=title,
16
+ params=data,
17
  )
18
  ws.edges = [
19
  workspace.WorkspaceEdge(
 
29
 
30
 
31
  def summarize_layers(m: pytorch_core.ModelConfig) -> str:
32
+ assert isinstance(m.model, pyg_nn.Sequential)
33
  return "".join(str(e)[:2] for e in m.model)
34
 
35
 
36
  def summarize_connections(m: pytorch_core.ModelConfig) -> str:
37
+ assert isinstance(m.model, pyg_nn.Sequential)
38
  return " ".join(
39
  "".join(n[0] for n in c.param_names) + "->" + "".join(n[0] for n in c.return_names)
40
  for c in m.model._children
lynxkite-pillow-example/src/lynxkite_pillow_example/__init__.py CHANGED
@@ -20,48 +20,48 @@ def open_image(*, filename: str):
20
 
21
 
22
  @op("Save image")
23
- def save_image(image: Image, *, filename: str):
24
  with fsspec.open(filename, "wb") as f:
25
  image.save(f)
26
 
27
 
28
  @op("Crop")
29
- def crop(image: Image, *, top: int, left: int, bottom: int, right: int):
30
  return image.crop((left, top, right, bottom))
31
 
32
 
33
  @op("Flip horizontally")
34
- def flip_horizontally(image: Image):
35
  return image.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
36
 
37
 
38
  @op("Flip vertically")
39
- def flip_vertically(image: Image):
40
  return image.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
41
 
42
 
43
  @op("Blur")
44
- def blur(image: Image, *, radius: float = 5):
45
  return image.filter(ImageFilter.GaussianBlur(radius))
46
 
47
 
48
  @op("Detail")
49
- def detail(image: Image):
50
  return image.filter(ImageFilter.DETAIL)
51
 
52
 
53
  @op("Edge enhance")
54
- def edge_enhance(image: Image):
55
  return image.filter(ImageFilter.EDGE_ENHANCE)
56
 
57
 
58
  @op("To grayscale")
59
- def to_grayscale(image: Image):
60
  return image.convert("L")
61
 
62
 
63
  @op("View image", view="image")
64
- def view_image(image: Image):
65
  buffered = io.BytesIO()
66
  image.save(buffered, format="webp")
67
  b64 = base64.b64encode(buffered.getvalue()).decode("utf-8")
 
20
 
21
 
22
  @op("Save image")
23
+ def save_image(image: Image.Image, *, filename: str):
24
  with fsspec.open(filename, "wb") as f:
25
  image.save(f)
26
 
27
 
28
  @op("Crop")
29
+ def crop(image: Image.Image, *, top: int, left: int, bottom: int, right: int):
30
  return image.crop((left, top, right, bottom))
31
 
32
 
33
  @op("Flip horizontally")
34
+ def flip_horizontally(image: Image.Image):
35
  return image.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
36
 
37
 
38
  @op("Flip vertically")
39
+ def flip_vertically(image: Image.Image):
40
  return image.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
41
 
42
 
43
  @op("Blur")
44
+ def blur(image: Image.Image, *, radius: float = 5):
45
  return image.filter(ImageFilter.GaussianBlur(radius))
46
 
47
 
48
  @op("Detail")
49
+ def detail(image: Image.Image):
50
  return image.filter(ImageFilter.DETAIL)
51
 
52
 
53
  @op("Edge enhance")
54
+ def edge_enhance(image: Image.Image):
55
  return image.filter(ImageFilter.EDGE_ENHANCE)
56
 
57
 
58
  @op("To grayscale")
59
+ def to_grayscale(image: Image.Image):
60
  return image.convert("L")
61
 
62
 
63
  @op("View image", view="image")
64
+ def view_image(image: Image.Image):
65
  buffered = io.BytesIO()
66
  image.save(buffered, format="webp")
67
  b64 = base64.b64encode(buffered.getvalue()).decode("utf-8")