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