diff --git a/.github/workflows/main.yml b/.github/workflows/test_backend.yml similarity index 55% rename from .github/workflows/main.yml rename to .github/workflows/test_backend.yml index 7be4877..63a4291 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/test_backend.yml @@ -1,40 +1,38 @@ -# This is a basic workflow to help you get started with Actions +# This workflow tests starting the back-end server, unit and API tests -name: Postman Tests +name: Test back-end -# Controls when the action will run. Triggers the workflow on push or pull request -# events but only for the master branch on: push: pull_request: branches: [ master ] -# A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: - # This workflow contains a single job called "build" - build: - # The type of runner that the job will run on + setup-back-end: runs-on: ubuntu-latest - - # Steps represent a sequence of tasks that will be executed as part of the job steps: - # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + # Checks-out your repository, for access in the workflow - uses: actions/checkout@v2 + # Setup python - name: Set up Python 3.x uses: actions/setup-python@v1 with: python-version: 3.x + # Install dependencies - name: Install dependencies - run: | python3 -m pip install --upgrade pip pip3 install pipenv cd back-end - pipenv install - pipenv run echo "SECRET_KEY='TEMPORARY SECRET KEY'" > vp/.environment + pipenv install --dev + # Add .env and test data + - name: Add test/env data + run: | + cd back-end + pipenv run echo "SECRET_KEY='TEMPORARY SECRET KEY'" > vp/.environment echo ",key,A 0,K0,A0 1,K1,A1 @@ -43,17 +41,23 @@ jobs: 4,K4,A4 5,K5,A5" > /tmp/sample.csv + # Run unittests + - name: Run unittests + run: | + cd back-end/pyworkflow/pyworkflow + pipenv run coverage run -m unittest tests/*.py + pipenv run coverage report - cd pyworkflow/pyworkflow - pipenv run python3 -m unittest tests/*.py - cd ../../vp + # Start server in background for API tests + - name: Start server + run: | + cd back-end/vp pipenv run nohup python3 manage.py runserver & - newman: - needs: build - runs-on: ubuntu-latest - # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + postman-tests: + needs: setup-back-end + runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 @@ -62,3 +66,4 @@ jobs: with: collection: Postman/Visual\ Programming-Tests.postman_collection.json environment: Postman/Local\ Testing.postman_environment.json + \ No newline at end of file diff --git a/.github/workflows/test_docker.yml b/.github/workflows/test_docker.yml new file mode 100644 index 0000000..0234d05 --- /dev/null +++ b/.github/workflows/test_docker.yml @@ -0,0 +1,23 @@ +# This workflow tests building the Docker images and starting containers + +name: Test Docker build + +on: + push: + pull_request: + branches: [ master ] + +jobs: + build-docker: + runs-on: ubuntu-latest + steps: + # Checks-out your repository, for access in the workflow + - uses: actions/checkout@v2 + + # Build containers + - name: Build docker container + run: docker-compose build + + # Run containers + - name: Start Docker containers + run: docker-compose up -d diff --git a/.github/workflows/test_frontend.yml b/.github/workflows/test_frontend.yml new file mode 100644 index 0000000..8c1fc31 --- /dev/null +++ b/.github/workflows/test_frontend.yml @@ -0,0 +1,24 @@ +# This workflow tests front-end + +name: Test front-end + +on: + push: + pull_request: + branches: [ master ] + +jobs: + test-front-end: + runs-on: ubuntu-latest + steps: + # Checks-out your repository, for access in the workflow + - uses: actions/checkout@v2 + - name: Install dependencies + run: | + cd front-end + npm ci + + - name: Run unit tests + run: | + cd front-end + npm test diff --git a/README.md b/README.md index ad24742..eee160a 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,11 @@ # PyWorkflow -| | | +| | Status | |------------|--------| -| Docker | TBD | -| Back-end | ![Postman Tests](https://github.com/matthew-t-smith/visual-programming/workflows/Postman%20Tests/badge.svg) | -| Front-end | TBD | +| Docker | ![Test Docker build](https://github.com/matthew-t-smith/visual-programming/workflows/Test%20Docker%20build/badge.svg) | +| Back-end | ![Test back-end](https://github.com/matthew-t-smith/visual-programming/workflows/Test%20back-end/badge.svg) | +| Front-end | ![Test front-end](https://github.com/matthew-t-smith/visual-programming/workflows/Test%20front-end/badge.svg) | | PyWorkflow | ![Code Coverage](./docs/media/pyworkflow_coverage.svg) | -| CLI | TBD | -| Jest | TBD | +| UI | ![Code Coverage](./docs/media/ui_coverage.svg) | PyWorkflow is a visual programming application for building data science pipelines and workflows. It is inspired by [KNIME](https://www.knime.com) diff --git a/back-end/pyworkflow/pyworkflow/.coveragerc b/back-end/pyworkflow/pyworkflow/.coveragerc index cafe73c..34656f5 100644 --- a/back-end/pyworkflow/pyworkflow/.coveragerc +++ b/back-end/pyworkflow/pyworkflow/.coveragerc @@ -1,4 +1,5 @@ [run] omit= */.local/share/virtualenvs/* - ./tests/* \ No newline at end of file + ./tests/* + ./nodes/custom_nodes/* \ No newline at end of file diff --git a/back-end/pyworkflow/pyworkflow/node.py b/back-end/pyworkflow/pyworkflow/node.py index 898fe1c..4533118 100644 --- a/back-end/pyworkflow/pyworkflow/node.py +++ b/back-end/pyworkflow/pyworkflow/node.py @@ -109,7 +109,7 @@ def to_json(self): } def __str__(self): - return "Test" + return self.name class FlowNode(Node): diff --git a/back-end/pyworkflow/pyworkflow/node_factory.py b/back-end/pyworkflow/pyworkflow/node_factory.py index ecf1a8a..e2fa432 100644 --- a/back-end/pyworkflow/pyworkflow/node_factory.py +++ b/back-end/pyworkflow/pyworkflow/node_factory.py @@ -67,5 +67,5 @@ def custom_node(node_key, node_info): return instance except Exception as e: - print(str(e)) + # print(str(e)) return None diff --git a/back-end/pyworkflow/pyworkflow/tests/sample_test_data.py b/back-end/pyworkflow/pyworkflow/tests/sample_test_data.py index 145c17c..5854da8 100644 --- a/back-end/pyworkflow/pyworkflow/tests/sample_test_data.py +++ b/back-end/pyworkflow/pyworkflow/tests/sample_test_data.py @@ -28,7 +28,13 @@ "node_key": "JoinNode", "is_global": False, "options": { - "on": "key" + "on": "to_replace" + }, + "option_replace": { + "on": { + "node_id": "7", + "is_global": False, + } } }, "filter_node": { @@ -51,14 +57,32 @@ "on": "key" } }, + "graph_node": { + "name": "Graph", + "node_id": "6", + "node_type": "visualization", + "node_key": "GraphNode", + "is_global": False, + }, "string_input": { "name": "String Input", - "node_id": "6", + "node_id": "7", "node_type": "flow_control", "node_key": "StringNode", "is_global": False, "options": { - "default_value": "My value", + "default_value": "key", + "var_name": "local_flow_var" + } + }, + "integer_input": { + "name": "Integer Input", + "node_id": "8", + "node_type": "flow_control", + "node_key": "IntegerNode", + "is_global": False, + "options": { + "default_value": 42, "var_name": "my_var" } }, @@ -69,8 +93,8 @@ "node_key": "StringNode", "is_global": True, "options": { - "default_value": "My value", - "var_name": "my_var" + "default_value": ",", + "var_name": "global_flow_var" } }, } @@ -97,6 +121,13 @@ "node_key": "foobar", "is_global": False, }, + "bad_visualization_node": { + "name": "Foobar", + "node_id": "1", + "node_type": "visualization", + "node_key": "foobar", + "is_global": False, + }, "bad_node_type": { "name": "Foobar", "node_id": "1", @@ -112,6 +143,11 @@ default='my value', docstring='my docstring' ), + "text_param": TextParameter( + 'CSV Input', + default='my value', + docstring='my docstring' + ), "bool_param": BooleanParameter( 'Drop NaN columns', default=True, @@ -126,6 +162,12 @@ default=42, docstring="CSV File" ), + "select_param": SelectParameter( + 'Graph type', + options=["area", "bar", "line", "point"], + default='bar', + docstring='my docstring' + ), } BAD_PARAMETERS = { @@ -149,6 +191,16 @@ default=42, docstring="CSV File" ), + "bad_text_param": TextParameter( + 'Bad Bool Param', + default=42, + docstring="CSV File" + ), + "bad_select_param": SelectParameter( + 'Bad Bool Param', + default=42, + docstring="CSV File" + ), } DATA_FILES = { @@ -162,5 +214,22 @@ "sample2": (',key,B\n' '0,K0,B0\n' '1,K1,B1\n' - '2,K2,B2\n') + '2,K2,B2\n'), + "good_custom_node": ('from pyworkflow.node import Node, NodeException\n' + 'from pyworkflow.parameters import *\n' + 'class MyGoodCustomNode(Node):\n' + '\tname="Custom Node"\n' + '\tnum_in=1\n' + '\tnum_out=1\n' + '\tdef execute(self, predecessor_data, flow_vars):\n' + '\t\tprint("Hello world")\n'), + "bad_custom_node": ('from pyworkflow.node import Node, NodeException\n' + 'from pyworkflow.parameters import *\n' + 'import torch\n' + 'class MyBadCustomNode(Node):\n' + '\tname="Custom Node"\n' + '\tnum_in=1\n' + '\tnum_out=1\n' + '\tdef execute(self, predecessor_data, flow_vars):\n' + '\t\tprint("Hello world")\n'), } \ No newline at end of file diff --git a/back-end/pyworkflow/pyworkflow/tests/test_node.py b/back-end/pyworkflow/pyworkflow/tests/test_node.py index d1e586c..3968de9 100644 --- a/back-end/pyworkflow/pyworkflow/tests/test_node.py +++ b/back-end/pyworkflow/pyworkflow/tests/test_node.py @@ -17,27 +17,64 @@ def test_add_pivot_csv_node(self): node_to_add = node_factory(GOOD_NODES["pivot_node"]) self.assertIsInstance(node_to_add, PivotNode) + def test_add_graph_csv_node(self): + node_to_add = node_factory(GOOD_NODES["graph_node"]) + self.assertIsInstance(node_to_add, GraphNode) + def test_add_string_node(self): node_to_add = node_factory(GOOD_NODES["string_input"]) self.assertIsInstance(node_to_add, StringNode) + def test_add_integer_node(self): + node_to_add = node_factory(GOOD_NODES["integer_input"]) + self.assertIsInstance(node_to_add, IntegerNode) + def test_fail_add_node(self): bad_nodes = [ node_factory(BAD_NODES["bad_node_type"]), node_factory(BAD_NODES["bad_flow_node"]), node_factory(BAD_NODES["bad_io_node"]), - node_factory(BAD_NODES["bad_manipulation_node"]) + node_factory(BAD_NODES["bad_manipulation_node"]), + node_factory(BAD_NODES["bad_visualization_node"]) ] for bad_node in bad_nodes: self.assertIsNone(bad_node) + def test_flow_node_replacement_value(self): + node_to_add = node_factory(GOOD_NODES["string_input"]) + self.assertEqual(node_to_add.get_replacement_value(), "key") + + def test_node_to_string(self): + node_to_add = node_factory(GOOD_NODES["string_input"]) + self.assertEqual(str(node_to_add), "String Input") + + def test_node_to_json(self): + node_to_add = node_factory(GOOD_NODES["string_input"]) + + dict_to_compare = { + "name": "String Input", + "node_id": "7", + "node_type": "flow_control", + "node_key": "StringNode", + "data": None, + "is_global": False, + "option_replace": {}, + "option_values": { + "default_value": "key", + "var_name": "local_flow_var" + } + } + + self.assertDictEqual(node_to_add.to_json(), dict_to_compare) + def test_node_execute_not_implemented(self): test_node = Node(dict()) test_io_node = IONode(dict()) test_manipulation_node = ManipulationNode(dict()) + test_visualization_node = VizNode(dict()) - nodes = [test_node, test_io_node, test_manipulation_node] + nodes = [test_node, test_io_node, test_manipulation_node, test_visualization_node] for node_to_execute in nodes: with self.assertRaises(NotImplementedError): @@ -52,3 +89,20 @@ def test_node_execute_exception(self): for node_to_execute in nodes: with self.assertRaises(NodeException): node_to_execute.execute(dict(), dict()) + + def test_validate_node(self): + node_to_validate = node_factory(GOOD_NODES["string_input"]) + node_to_validate.validate() + + def test_validate_input_data(self): + node_to_validate = node_factory(GOOD_NODES["join_node"]) + node_to_validate.validate_input_data(2) + + def test_validate_input_data_exception(self): + node_to_validate = node_factory(GOOD_NODES["join_node"]) + + try: + node_to_validate.validate_input_data(0) + except NodeException as e: + self.assertEqual(str(e), "execute: JoinNode requires 2 inputs. 0 were provided") + diff --git a/back-end/pyworkflow/pyworkflow/tests/test_parameters.py b/back-end/pyworkflow/pyworkflow/tests/test_parameters.py index 173250b..371f566 100644 --- a/back-end/pyworkflow/pyworkflow/tests/test_parameters.py +++ b/back-end/pyworkflow/pyworkflow/tests/test_parameters.py @@ -15,6 +15,14 @@ def test_string_param(self): self.assertDictEqual(GOOD_PARAMETERS["string_param"].to_json(), full_json) + def test_parameter_validate_not_implemented(self): + test_param = Parameter(dict()) + params = [test_param] + + for param_to_validate in params: + with self.assertRaises(NotImplementedError): + param_to_validate.validate() + def test_validate_string_param(self): with self.assertRaises(ParameterValidationError): BAD_PARAMETERS["bad_string_param"].validate() @@ -27,6 +35,14 @@ def test_validate_boolean_param(self): with self.assertRaises(ParameterValidationError): BAD_PARAMETERS["bad_bool_param"].validate() + def test_validate_text_param(self): + with self.assertRaises(ParameterValidationError): + BAD_PARAMETERS["bad_text_param"].validate() + + def test_validate_select_param(self): + with self.assertRaises(ParameterValidationError): + BAD_PARAMETERS["bad_select_param"].validate() + def test_validate_file_param(self): with self.assertRaises(ParameterValidationError): BAD_PARAMETERS["bad_file_param"].validate() diff --git a/back-end/pyworkflow/pyworkflow/tests/test_pyworkflow.py b/back-end/pyworkflow/pyworkflow/tests/test_pyworkflow.py index 1dcf8ca..13c254d 100644 --- a/back-end/pyworkflow/pyworkflow/tests/test_pyworkflow.py +++ b/back-end/pyworkflow/pyworkflow/tests/test_pyworkflow.py @@ -17,16 +17,7 @@ def setUp(self): self.pyworkflow = Workflow("My Workflow", root_dir="/tmp") - self.read_csv_node_1 = Node({ - "name": "Read CSV", - "node_id": "1", - "node_type": "io", - "node_key": "ReadCsvNode", - "is_global": False, - "options": { - "file": "/tmp/sample1.csv" - } - }) + self.read_csv_node_1 = Node(GOOD_NODES["read_csv_node"]) self.read_csv_node_2 = Node({ "name": "Read CSV", @@ -35,20 +26,18 @@ def setUp(self): "node_key": "ReadCsvNode", "is_global": False, "options": { - "file": "/tmp/sample2.csv" + "file": "/tmp/sample2.csv", + "sep": ";", + }, + "option_replace": { + "sep": { + "node_id": "1", + "is_global": True, + } } }) - self.join_node = Node({ - "name": "Joiner", - "node_id": "3", - "node_type": "manipulation", - "node_key": "JoinNode", - "is_global": False, - "options": { - "on": "key" - } - }) + self.join_node = Node(GOOD_NODES["join_node"]) self.write_csv_node = Node({ "name": "Write CSV", @@ -61,8 +50,18 @@ def setUp(self): } }) - self.nodes = [self.read_csv_node_1, self.read_csv_node_2, self.join_node, self.write_csv_node] - self.edges = [("1", "3"), ("2", "3"), ("3", "4")] + self.string_flow_node = Node(GOOD_NODES["string_input"]) + self.string_global_flow_node = Node(GOOD_NODES["global_flow_var"]) + + self.nodes = [ + self.read_csv_node_1, + self.read_csv_node_2, + self.join_node, + self.write_csv_node, + self.string_flow_node, + self.string_global_flow_node, + ] + self.edges = [("1", "3"), ("2", "3"), ("3", "4"), ("7", "3")] def create_workflow(self): # When created in setUp(), duplicate Node/Edge errors would arise @@ -74,18 +73,37 @@ def create_workflow(self): target_node = self.pyworkflow.get_node(edge[1]) self.pyworkflow.add_edge(source_node, target_node) + def test_get_local_flow_nodes(self): + node_with_flow = self.pyworkflow.get_node("3") + flow_nodes = self.pyworkflow.load_flow_nodes(node_with_flow.option_replace) + self.assertEqual(len(flow_nodes), 1) + + def test_get_global_flow_nodes(self): + node_with_flow = self.pyworkflow.get_node("2") + flow_nodes = self.pyworkflow.load_flow_nodes(node_with_flow.option_replace) + self.assertEqual(len(flow_nodes), 1) + + def test_get_global_flow_node_exception(self): + node_with_flow = self.pyworkflow.get_node("1") + flow_nodes = self.pyworkflow.load_flow_nodes(node_with_flow.option_replace) + self.assertEqual(len(flow_nodes), 0) + def test_get_execution_order(self): self.create_workflow() order = self.pyworkflow.execution_order() - self.assertEqual(order, ["2", "1", "3", "4"]) + self.assertEqual(order, ["7", "2", "1", "3", "4"]) - def test_execute_workflow(self): + def test_xexecute_workflow(self): order = self.pyworkflow.execution_order() for node in order: executed_node = self.pyworkflow.execute(node) self.pyworkflow.update_or_add_node(executed_node) + # def test_execute_workflow_load_data(self): + # print(self.pyworkflow.graph.nodes) + # data = self.pyworkflow.load_input_data("3") + def test_fail_execute_node(self): with self.assertRaises(WorkflowException): self.pyworkflow.execute("100") diff --git a/back-end/pyworkflow/pyworkflow/tests/test_workflow.py b/back-end/pyworkflow/pyworkflow/tests/test_workflow.py index 18a755a..8ecffa7 100644 --- a/back-end/pyworkflow/pyworkflow/tests/test_workflow.py +++ b/back-end/pyworkflow/pyworkflow/tests/test_workflow.py @@ -4,11 +4,14 @@ import networkx as nx from pyworkflow.tests.sample_test_data import GOOD_NODES, BAD_NODES, DATA_FILES + + class WorkflowTestCase(unittest.TestCase): def setUp(self): self.workflow = Workflow("Untitled", root_dir="/tmp", node_dir=os.path.join(os.getcwd(), 'nodes')) self.read_csv_node = GOOD_NODES["read_csv_node"] + self.local_flow_node = GOOD_NODES["string_input"] self.write_csv_node = GOOD_NODES["write_csv_node"] self.join_node = GOOD_NODES["join_node"] self.global_flow_var = GOOD_NODES["global_flow_var"] @@ -28,6 +31,13 @@ def add_edge(self, source_node, target_node): def test_workflow_name(self): self.assertEqual(self.workflow.name, "Untitled") + def test_workflow_dir_os_error(self): + try: + os.makedirs("foobar", 0000) + test_workflow = Workflow(node_dir="foobar") + except WorkflowException as e: + self.assertEqual(e.action, "init workflow") + def test_workflow_node_dir(self): self.assertEqual(self.workflow.node_dir, os.path.join(os.getcwd(), 'nodes')) @@ -41,6 +51,28 @@ def test_set_workflow_name(self): def test_workflow_filename(self): self.assertEqual(self.workflow.filename, "Untitled.json") + def test_workflow_from_json(self): + new_workflow = Workflow("Untitled", root_dir="/tmp") + workflow_copy = Workflow.from_json(self.workflow.to_session_dict()) + + self.assertEqual(new_workflow.name, workflow_copy.name) + + def test_workflow_from_json_key_error(self): + with self.assertRaises(WorkflowException): + new_workflow = Workflow.from_json(dict()) + + def test_empty_workflow_to_session(self): + new_workflow = Workflow("Untitled", root_dir="/tmp") + saved_workflow = new_workflow.to_session_dict() + + workflow_to_compare = { + 'name': 'Untitled', + 'root_dir': '/tmp', + 'graph': Workflow.to_graph_json(new_workflow.graph), + 'flow_vars': Workflow.to_graph_json(new_workflow.flow_vars), + } + self.assertDictEqual(new_workflow.to_session_dict(), workflow_to_compare) + ########################## # Node lists ########################## @@ -48,6 +80,10 @@ def test_workflow_packaged_nodes(self): nodes = self.workflow.get_packaged_nodes() self.assertEqual(len(nodes), 5) + def test_workflow_packaged_nodes_exception(self): + result = self.workflow.get_packaged_nodes(root_path="foobar") + self.assertIsNone(result) + def test_get_flow_variables(self): flow_var_options = self.workflow.get_all_flow_var_options("1") @@ -59,8 +95,10 @@ def test_get_node_successors(self): self.assertEqual(successors, ["3", "2"]) def test_fail_get_node_successors(self): - with self.assertRaises(WorkflowException): + try: successors = self.workflow.get_node_successors("100") + except WorkflowException as e: + self.assertEqual(str(e), "get node successors: The node 100 is not in the digraph.") def test_fail_get_node_predecessors(self): with self.assertRaises(WorkflowException): @@ -80,6 +118,23 @@ def test_fail_get_execution_order(self): ########################## # Node operations ########################## + def test_add_custom_node(self): + with open(self.workflow.node_path('custom_nodes', 'good_custom_node.py'), 'w') as f: + f.write((DATA_FILES['good_custom_node'])) + + custom_node_info = { + "name": "Custom Node", + "node_id": "50", + "node_type": "custom_node", + "node_key": "MyGoodCustomNode", + "is_global": False, + } + + node_to_add = Node(custom_node_info) + added_node = self.add_node(custom_node_info, "50") + + self.assertDictEqual(node_to_add.__dict__, added_node.__dict__) + def test_add_read_csv_node(self): node_to_add = Node(self.read_csv_node) added_node = self.add_node(self.read_csv_node, "1") @@ -132,13 +187,13 @@ def test_get_flow_var(self): ########################## # Edge operations ########################## - def test_add_xedge_1_to_2(self): + def test_add_node_edge_1_to_2(self): node_1 = self.workflow.get_node("1") node_2 = self.workflow.get_node("2") self.add_edge(node_1, node_2) return - def test_add_xedge_duplicated(self): + def test_add_node_edge_duplicated(self): node_1 = self.workflow.get_node("1") node_2 = self.workflow.get_node("2") diff --git a/docs/media/ui_coverage.svg b/docs/media/ui_coverage.svg new file mode 100644 index 0000000..3f77961 --- /dev/null +++ b/docs/media/ui_coverage.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + coverage + coverage + 64% + 64% + +