From 7f6b24faf678574536dfb1958fd93ea374d49bec Mon Sep 17 00:00:00 2001 From: Alexandre SAJUS Date: Sat, 9 Oct 2021 00:17:22 +0200 Subject: [PATCH 01/32] :tada: Add IPython Execution --- opencodeblocks/graphics/function_parsing.py | 95 +++++++++++++++++++++ opencodeblocks/graphics/pyeditor.py | 26 ++++++ 2 files changed, 121 insertions(+) create mode 100644 opencodeblocks/graphics/function_parsing.py diff --git a/opencodeblocks/graphics/function_parsing.py b/opencodeblocks/graphics/function_parsing.py new file mode 100644 index 00000000..5964c533 --- /dev/null +++ b/opencodeblocks/graphics/function_parsing.py @@ -0,0 +1,95 @@ +from IPython.testing.globalipapp import get_ipython +from IPython.utils.io import capture_output +ip = get_ipython() + +def run_cell(cell): + """ + Executes a string of code in an IPython shell and returns the execution result + :param cell: string containing Python code + :type cell: String + :return: execution result + :rtype: Execution Result + """ + with capture_output() as io: + res = ip.run_cell(cell) + res_out = io.stdout + return res_out + +def get_function_name(code): + """ + Parses a string of code and returns the first function name it finds + :param cell: string containing Python code + :type cell: String + :return: name of first defined function + :rtype: String + """ + start = 0 + end = 0 + for i in range(len(code)): + if code[i:i+3] == "def": + start = i+4 + break + for i in range(start, len(code)): + if code[i] == "(": + end = i + break + return code[start:end] + +def get_signature(code): + """ + Returns the signature of a string of Python code defining a function + For example: the signature of def hello(a,b,c=3) is "(a,b,c=3)" + :param cell: string containing Python code + :type cell: String + :return: signature of first defined function + :rtype: String + """ + name = get_function_name(code) + run_cell(code) + run_cell("from inspect import signature") + return run_cell("print(signature(" + name + "))") + +def extract_args(code): + """ + Returns the args and kwargs of a string of Python code defining a function + For example: for def hello(a,b,c=3) it returns (["a","b"],["c=3"]) + :param cell: string containing Python code + :type cell: String + :return: (args,kwargs) of first defined function + :rtype: Tuple + """ + sig = get_signature(code) + sig = str(sig) + sig = sig[1:-2] + if sig == "": + return ([], []) + sig = sig.replace(" ","") + sig = sig.split(",") + kwarg_index = len(sig) + for i in range(len(sig)): + if "=" in sig[i]: + kwarg_index = i + break + return sig[:kwarg_index], sig[kwarg_index:] + +def execute_function(code, args, kwargs): + """ + Executes the function defined in code in an IPython shell and runs it fed by args and kwargs + Returns the execution result + :param cell: string containing Python code + :type cell: String + :return: execution result of function_in_code(args,kwargs) + :rtype: Execution Result + """ + run_cell(code) + function_name = get_function_name(code) + execution_code = function_name + "(" + if len(args) == 0: + return run_cell(execution_code + ")") + for arg in args: + execution_code += arg + "," + if len(kwargs) == 0: + return run_cell(execution_code[:-1] + ")") + for kwarg in kwargs: + execution_code += kwarg + "," + return run_cell(execution_code[:-1] + ")") \ No newline at end of file diff --git a/opencodeblocks/graphics/pyeditor.py b/opencodeblocks/graphics/pyeditor.py index 9a2ab71f..28ea6246 100644 --- a/opencodeblocks/graphics/pyeditor.py +++ b/opencodeblocks/graphics/pyeditor.py @@ -9,6 +9,8 @@ from opencodeblocks.graphics.blocks.block import OCBBlock +from opencodeblocks.graphics.function_parsing import execute_function, extract_args + class PythonEditor(QsciScintilla): @@ -100,4 +102,28 @@ def focusOutEvent(self, event: QFocusEvent): if self.isModified(): self.block.source = self.text() self.setModified(False) + + # This is the part that parses and executes the code + # Predefine the args and kwargs here + args = ["'Hello'","10","[1,2,3]"] + kwargs = ["d='World'"] + + print("") + print("args are: " + str(args)) + print("kwargs are: " + str(kwargs)) + + code = str(self.text()) + print("default args are: " + str(extract_args(code))) + print("") + print("Execution result:") + print(str(execute_function(code,args,kwargs))) + return super().focusInEvent(event) + +""" +Here is a test function: +def test(a,b, c, d='Nope', e=20): + print(a + ' ' + d) + print(b + e) + return max(c) +""" \ No newline at end of file From 103e3cb93e9409b22bec33fd6bc755fc48958d54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Math=C3=AFs=20F=C3=A9d=C3=A9rico?= Date: Sun, 10 Oct 2021 12:27:59 +0200 Subject: [PATCH 02/32] :memo: Add Ipython to requirements --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 043f5918..791bfb43 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ pyqt5>=5.15.4 QtPy>=1.9.0 -qscintilla>=2.13.0 \ No newline at end of file +qscintilla>=2.13.0 +Ipython>=7.27.0 \ No newline at end of file From acc0e7f0d95e7bbe94ab8294e9016637bc162258 Mon Sep 17 00:00:00 2001 From: Alexandre SAJUS Date: Sat, 9 Oct 2021 00:17:22 +0200 Subject: [PATCH 03/32] :tada: Add IPython Execution --- opencodeblocks/graphics/function_parsing.py | 95 +++++++++++++++++++++ opencodeblocks/graphics/pyeditor.py | 26 ++++++ 2 files changed, 121 insertions(+) create mode 100644 opencodeblocks/graphics/function_parsing.py diff --git a/opencodeblocks/graphics/function_parsing.py b/opencodeblocks/graphics/function_parsing.py new file mode 100644 index 00000000..5964c533 --- /dev/null +++ b/opencodeblocks/graphics/function_parsing.py @@ -0,0 +1,95 @@ +from IPython.testing.globalipapp import get_ipython +from IPython.utils.io import capture_output +ip = get_ipython() + +def run_cell(cell): + """ + Executes a string of code in an IPython shell and returns the execution result + :param cell: string containing Python code + :type cell: String + :return: execution result + :rtype: Execution Result + """ + with capture_output() as io: + res = ip.run_cell(cell) + res_out = io.stdout + return res_out + +def get_function_name(code): + """ + Parses a string of code and returns the first function name it finds + :param cell: string containing Python code + :type cell: String + :return: name of first defined function + :rtype: String + """ + start = 0 + end = 0 + for i in range(len(code)): + if code[i:i+3] == "def": + start = i+4 + break + for i in range(start, len(code)): + if code[i] == "(": + end = i + break + return code[start:end] + +def get_signature(code): + """ + Returns the signature of a string of Python code defining a function + For example: the signature of def hello(a,b,c=3) is "(a,b,c=3)" + :param cell: string containing Python code + :type cell: String + :return: signature of first defined function + :rtype: String + """ + name = get_function_name(code) + run_cell(code) + run_cell("from inspect import signature") + return run_cell("print(signature(" + name + "))") + +def extract_args(code): + """ + Returns the args and kwargs of a string of Python code defining a function + For example: for def hello(a,b,c=3) it returns (["a","b"],["c=3"]) + :param cell: string containing Python code + :type cell: String + :return: (args,kwargs) of first defined function + :rtype: Tuple + """ + sig = get_signature(code) + sig = str(sig) + sig = sig[1:-2] + if sig == "": + return ([], []) + sig = sig.replace(" ","") + sig = sig.split(",") + kwarg_index = len(sig) + for i in range(len(sig)): + if "=" in sig[i]: + kwarg_index = i + break + return sig[:kwarg_index], sig[kwarg_index:] + +def execute_function(code, args, kwargs): + """ + Executes the function defined in code in an IPython shell and runs it fed by args and kwargs + Returns the execution result + :param cell: string containing Python code + :type cell: String + :return: execution result of function_in_code(args,kwargs) + :rtype: Execution Result + """ + run_cell(code) + function_name = get_function_name(code) + execution_code = function_name + "(" + if len(args) == 0: + return run_cell(execution_code + ")") + for arg in args: + execution_code += arg + "," + if len(kwargs) == 0: + return run_cell(execution_code[:-1] + ")") + for kwarg in kwargs: + execution_code += kwarg + "," + return run_cell(execution_code[:-1] + ")") \ No newline at end of file diff --git a/opencodeblocks/graphics/pyeditor.py b/opencodeblocks/graphics/pyeditor.py index 9a2ab71f..28ea6246 100644 --- a/opencodeblocks/graphics/pyeditor.py +++ b/opencodeblocks/graphics/pyeditor.py @@ -9,6 +9,8 @@ from opencodeblocks.graphics.blocks.block import OCBBlock +from opencodeblocks.graphics.function_parsing import execute_function, extract_args + class PythonEditor(QsciScintilla): @@ -100,4 +102,28 @@ def focusOutEvent(self, event: QFocusEvent): if self.isModified(): self.block.source = self.text() self.setModified(False) + + # This is the part that parses and executes the code + # Predefine the args and kwargs here + args = ["'Hello'","10","[1,2,3]"] + kwargs = ["d='World'"] + + print("") + print("args are: " + str(args)) + print("kwargs are: " + str(kwargs)) + + code = str(self.text()) + print("default args are: " + str(extract_args(code))) + print("") + print("Execution result:") + print(str(execute_function(code,args,kwargs))) + return super().focusInEvent(event) + +""" +Here is a test function: +def test(a,b, c, d='Nope', e=20): + print(a + ' ' + d) + print(b + e) + return max(c) +""" \ No newline at end of file From a0203804be78169565293d83f0d9d46b681b5e0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Math=C3=AFs=20F=C3=A9d=C3=A9rico?= Date: Sun, 10 Oct 2021 12:27:59 +0200 Subject: [PATCH 04/32] :memo: Add Ipython to requirements --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 043f5918..791bfb43 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ pyqt5>=5.15.4 QtPy>=1.9.0 -qscintilla>=2.13.0 \ No newline at end of file +qscintilla>=2.13.0 +Ipython>=7.27.0 \ No newline at end of file From ad33fab0f99e58d23851ce73592bb341dbb23e9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Math=C3=AFs=20F=C3=A9d=C3=A9rico?= Date: Sun, 10 Oct 2021 13:02:51 +0200 Subject: [PATCH 05/32] :wrench: Refactor pyeditor focusOutEvent :wrench: Refactor execute_function :memo: Refactor execute_function docstring --- main.py | 4 ++- opencodeblocks/graphics/function_parsing.py | 36 ++++++++++--------- opencodeblocks/graphics/pyeditor.py | 38 +++++++-------------- 3 files changed, 35 insertions(+), 43 deletions(-) diff --git a/main.py b/main.py index b64e2c7a..5f571f14 100644 --- a/main.py +++ b/main.py @@ -14,8 +14,10 @@ sys.path.insert(0, os.path.join( os.path.dirname(__file__), "..", ".." )) SOURCE_TEST = \ -'''def absolute_chicken(a, b): +'''def absolute_chicken(a, b, chicken=False): """ Compute the absolute value of inputs difference. """ + if chicken: + return 'chicken' if a > b: return a - b else: diff --git a/opencodeblocks/graphics/function_parsing.py b/opencodeblocks/graphics/function_parsing.py index 5964c533..99c329dc 100644 --- a/opencodeblocks/graphics/function_parsing.py +++ b/opencodeblocks/graphics/function_parsing.py @@ -1,3 +1,4 @@ +from typing import Any, Dict from IPython.testing.globalipapp import get_ipython from IPython.utils.io import capture_output ip = get_ipython() @@ -72,24 +73,25 @@ def extract_args(code): break return sig[:kwarg_index], sig[kwarg_index:] -def execute_function(code, args, kwargs): +def execute_function(code:str, *args, **kwargs) -> str: """ - Executes the function defined in code in an IPython shell and runs it fed by args and kwargs - Returns the execution result - :param cell: string containing Python code - :type cell: String - :return: execution result of function_in_code(args,kwargs) - :rtype: Execution Result + Executes the function defined in code in an IPython shell and runs it fed by args and kwargs. + Other arguments than the first are passed to the function when executing it. + Keyword arguments are passed to the function when executing it. + + Args: + code: String representing the function code to execute. + + Return: + String representing the output given by the IPython shell when executing the function. + """ - run_cell(code) function_name = get_function_name(code) - execution_code = function_name + "(" - if len(args) == 0: - return run_cell(execution_code + ")") + execution_code = f'{function_name}(' for arg in args: - execution_code += arg + "," - if len(kwargs) == 0: - return run_cell(execution_code[:-1] + ")") - for kwarg in kwargs: - execution_code += kwarg + "," - return run_cell(execution_code[:-1] + ")") \ No newline at end of file + execution_code += f'{arg},' + for name, value in kwargs.items(): + execution_code += f'{name}={value},' + + run_cell(code) + return run_cell(execution_code + ')') diff --git a/opencodeblocks/graphics/pyeditor.py b/opencodeblocks/graphics/pyeditor.py index 28ea6246..a8c36ff8 100644 --- a/opencodeblocks/graphics/pyeditor.py +++ b/opencodeblocks/graphics/pyeditor.py @@ -3,6 +3,7 @@ """ Module for OCB in block python editor. """ +from typing import Any, Dict, List from PyQt5.QtCore import Qt from PyQt5.QtGui import QFocusEvent, QFont, QFontMetrics, QColor from PyQt5.Qsci import QsciScintilla, QsciLexerPython @@ -99,31 +100,18 @@ def focusInEvent(self, event: QFocusEvent): def focusOutEvent(self, event: QFocusEvent): self.set_views_mode("MODE_NOOP") - if self.isModified(): - self.block.source = self.text() + + code = self.text() + if self.isModified() and code != self.block.source: + self.block.source = code self.setModified(False) - - # This is the part that parses and executes the code - # Predefine the args and kwargs here - args = ["'Hello'","10","[1,2,3]"] - kwargs = ["d='World'"] - - print("") - print("args are: " + str(args)) - print("kwargs are: " + str(kwargs)) - - code = str(self.text()) - print("default args are: " + str(extract_args(code))) - print("") - print("Execution result:") - print(str(execute_function(code,args,kwargs))) - return super().focusInEvent(event) + args, kwargs = self.gatherBlockInputs() + self.output = execute_function(code, *args, **kwargs) + print(self.output) + return super().focusOutEvent(event) -""" -Here is a test function: -def test(a,b, c, d='Nope', e=20): - print(a + ' ' + d) - print(b + e) - return max(c) -""" \ No newline at end of file + def gatherBlockInputs(self): + args = [2, 3] + kwargs = {"chicken": False} + return args, kwargs From ea04fc9c5827df26a5d2dcdfd8b931def0d66074 Mon Sep 17 00:00:00 2001 From: AlexandreSajus Date: Thu, 14 Oct 2021 20:25:38 +0200 Subject: [PATCH 06/32] :sparkles: new docstrings and tests --- opencodeblocks/graphics/function_parsing.py | 86 ++++++++++++--------- tests/unit/scene/test_function_parsing.py | 22 ++++++ 2 files changed, 73 insertions(+), 35 deletions(-) create mode 100644 tests/unit/scene/test_function_parsing.py diff --git a/opencodeblocks/graphics/function_parsing.py b/opencodeblocks/graphics/function_parsing.py index 99c329dc..bc7d01cd 100644 --- a/opencodeblocks/graphics/function_parsing.py +++ b/opencodeblocks/graphics/function_parsing.py @@ -3,75 +3,91 @@ from IPython.utils.io import capture_output ip = get_ipython() -def run_cell(cell): +def run_cell(cell:str): """ Executes a string of code in an IPython shell and returns the execution result - :param cell: string containing Python code - :type cell: String - :return: execution result - :rtype: Execution Result + + Args: + cell: String containing Python code + + Return: + Execution result of cell + """ with capture_output() as io: - res = ip.run_cell(cell) + _ = ip.run_cell(cell) res_out = io.stdout return res_out -def get_function_name(code): +def get_function_name(code:str) -> str: """ Parses a string of code and returns the first function name it finds + + Args: + code: String containing Python code + + Return: + Name of first defined function + :param cell: string containing Python code :type cell: String :return: name of first defined function :rtype: String """ - start = 0 - end = 0 + start_of_name = 0 + end_of_name = 0 for i in range(len(code)): if code[i:i+3] == "def": - start = i+4 + start_of_name = i+4 break - for i in range(start, len(code)): + for i in range(start_of_name, len(code)): if code[i] == "(": - end = i + end_of_name = i break - return code[start:end] + return code[start_of_name:end_of_name] -def get_signature(code): +def get_signature(code:str) -> str: """ Returns the signature of a string of Python code defining a function For example: the signature of def hello(a,b,c=3) is "(a,b,c=3)" - :param cell: string containing Python code - :type cell: String - :return: signature of first defined function - :rtype: String + + Args: + code: String containing Python code + + Return: + Signature of first defined function + """ name = get_function_name(code) run_cell(code) run_cell("from inspect import signature") - return run_cell("print(signature(" + name + "))") + return run_cell(f"print(signature({name}))") -def extract_args(code): +def extract_args(code:str) -> tuple: """ Returns the args and kwargs of a string of Python code defining a function For example: for def hello(a,b,c=3) it returns (["a","b"],["c=3"]) - :param cell: string containing Python code - :type cell: String - :return: (args,kwargs) of first defined function - :rtype: Tuple + + Args: + code: String containing Python code + + Return: + (args, kwargs) of first defined function + """ - sig = get_signature(code) - sig = str(sig) - sig = sig[1:-2] - if sig == "": + signature = get_signature(code) + signature_string = str(signature) + signature_string = signature_string[1:-2] + if signature_string == "": return ([], []) - sig = sig.replace(" ","") - sig = sig.split(",") - kwarg_index = len(sig) - for i in range(len(sig)): - if "=" in sig[i]: + signature_string = signature_string.replace(" ","") + signature_couple = signature_string.split(",") + kwarg_index = len(signature_couple) + for i, item in enumerate(signature_couple): + if "=" in item: kwarg_index = i break - return sig[:kwarg_index], sig[kwarg_index:] + return signature_couple[:kwarg_index], signature_couple[kwarg_index:] def execute_function(code:str, *args, **kwargs) -> str: """ @@ -94,4 +110,4 @@ def execute_function(code:str, *args, **kwargs) -> str: execution_code += f'{name}={value},' run_cell(code) - return run_cell(execution_code + ')') + return run_cell(execution_code + ')') \ No newline at end of file diff --git a/tests/unit/scene/test_function_parsing.py b/tests/unit/scene/test_function_parsing.py new file mode 100644 index 00000000..afc234ef --- /dev/null +++ b/tests/unit/scene/test_function_parsing.py @@ -0,0 +1,22 @@ +""" Unit tests for the opencodeblocks function parsing module. """ + +import pytest +from pytest_mock import MockerFixture +import pytest_check as check + +from opencodeblocks.graphics.function_parsing import run_cell, get_function_name, get_signature, extract_args, execute_function + +class TestFunctionParsing: + def test_run_cell(self, mocker:MockerFixture): + check.equal(run_cell("print(10)"), '10\n') + + def test_get_function_name(self, mocker:MockerFixture): + check.equal(get_function_name("def function():\n return 'Hello'"), 'function') + check.equal(get_function_name("#Hello\ndef function():\n return 'Hello'\na = 10"), 'function') + check.equal(get_function_name("#Hello\ndef function(a,b=10):\n return 'Hello'\na = 10"), 'function') + + def test_extract_args(self, mocker:MockerFixture): + mocker.patch('opencodeblocks.graphics.function_parsing.get_signature', return_value="()\n") + check.equal(extract_args("def function(a,b,c = 10):\n return 'Hello'"), ([],[])) + mocker.patch('opencodeblocks.graphics.function_parsing.get_signature', return_value="(a,b,c = 10)\n") + check.equal(extract_args("def function(a,b,c = 10):\n return 'Hello'"), (["a","b"],["c=10"])) \ No newline at end of file From 014cccf557d6b233e2f0f07e9acaebe890e264c9 Mon Sep 17 00:00:00 2001 From: AlexandreSajus Date: Wed, 20 Oct 2021 16:18:08 +0200 Subject: [PATCH 07/32] :wrench: some of the refactors requested --- opencodeblocks/graphics/function_parsing.py | 28 ++++++--------------- tests/unit/scene/test_function_parsing.py | 2 +- 2 files changed, 9 insertions(+), 21 deletions(-) diff --git a/opencodeblocks/graphics/function_parsing.py b/opencodeblocks/graphics/function_parsing.py index bc7d01cd..5f06bcc2 100644 --- a/opencodeblocks/graphics/function_parsing.py +++ b/opencodeblocks/graphics/function_parsing.py @@ -28,22 +28,9 @@ def get_function_name(code:str) -> str: Return: Name of first defined function - - :param cell: string containing Python code - :type cell: String - :return: name of first defined function - :rtype: String """ - start_of_name = 0 - end_of_name = 0 - for i in range(len(code)): - if code[i:i+3] == "def": - start_of_name = i+4 - break - for i in range(start_of_name, len(code)): - if code[i] == "(": - end_of_name = i - break + start_of_name = code.index("def") + 4 + end_of_name = code.index("(") return code[start_of_name:end_of_name] def get_signature(code:str) -> str: @@ -66,7 +53,8 @@ def get_signature(code:str) -> str: def extract_args(code:str) -> tuple: """ Returns the args and kwargs of a string of Python code defining a function - For example: for def hello(a,b,c=3) it returns (["a","b"],["c=3"]) + Examples: + get_signature(def hello(a,b,c=3)) -> "(a,b,c=3)" Args: code: String containing Python code @@ -75,12 +63,12 @@ def extract_args(code:str) -> tuple: (args, kwargs) of first defined function """ - signature = get_signature(code) - signature_string = str(signature) + signature_string = get_signature(code) + # Remove parentheses signature_string = signature_string[1:-2] + signature_string = signature_string.replace(" ","") if signature_string == "": return ([], []) - signature_string = signature_string.replace(" ","") signature_couple = signature_string.split(",") kwarg_index = len(signature_couple) for i, item in enumerate(signature_couple): @@ -110,4 +98,4 @@ def execute_function(code:str, *args, **kwargs) -> str: execution_code += f'{name}={value},' run_cell(code) - return run_cell(execution_code + ')') \ No newline at end of file + return run_cell(execution_code + ')') diff --git a/tests/unit/scene/test_function_parsing.py b/tests/unit/scene/test_function_parsing.py index afc234ef..6a98fca7 100644 --- a/tests/unit/scene/test_function_parsing.py +++ b/tests/unit/scene/test_function_parsing.py @@ -19,4 +19,4 @@ def test_extract_args(self, mocker:MockerFixture): mocker.patch('opencodeblocks.graphics.function_parsing.get_signature', return_value="()\n") check.equal(extract_args("def function(a,b,c = 10):\n return 'Hello'"), ([],[])) mocker.patch('opencodeblocks.graphics.function_parsing.get_signature', return_value="(a,b,c = 10)\n") - check.equal(extract_args("def function(a,b,c = 10):\n return 'Hello'"), (["a","b"],["c=10"])) \ No newline at end of file + check.equal(extract_args("def function(a,b,c = 10):\n return 'Hello'"), (["a","b"],["c=10"])) From 9e4d582eccafae5b5ccb1984587e53a9bb20e319 Mon Sep 17 00:00:00 2001 From: Alexandre Sajus Date: Fri, 29 Oct 2021 15:09:32 +0200 Subject: [PATCH 08/32] :hammer: Changes requested last commit --- opencodeblocks/graphics/function_parsing.py | 66 +++++++++++---- tests/unit/scene/test_function_parsing.py | 92 ++++++++++++++++++--- 2 files changed, 129 insertions(+), 29 deletions(-) diff --git a/opencodeblocks/graphics/function_parsing.py b/opencodeblocks/graphics/function_parsing.py index 5f06bcc2..1d4a772a 100644 --- a/opencodeblocks/graphics/function_parsing.py +++ b/opencodeblocks/graphics/function_parsing.py @@ -3,7 +3,8 @@ from IPython.utils.io import capture_output ip = get_ipython() -def run_cell(cell:str): + +def run_cell(cell: str): """ Executes a string of code in an IPython shell and returns the execution result @@ -19,7 +20,8 @@ def run_cell(cell:str): res_out = io.stdout return res_out -def get_function_name(code:str) -> str: + +def get_function_name(code: str) -> str: """ Parses a string of code and returns the first function name it finds @@ -29,11 +31,18 @@ def get_function_name(code:str) -> str: Return: Name of first defined function """ - start_of_name = code.index("def") + 4 - end_of_name = code.index("(") + def_index = code.find("def") + if def_index == -1: + raise ValueError("'def' not found in source code") + start_of_name = def_index + 4 + parenthesis_index = code.find("(", start_of_name) + if parenthesis_index == -1: + raise ValueError("'(' not found in source code") + end_of_name = parenthesis_index return code[start_of_name:end_of_name] -def get_signature(code:str) -> str: + +def get_signature(code: str) -> str: """ Returns the signature of a string of Python code defining a function For example: the signature of def hello(a,b,c=3) is "(a,b,c=3)" @@ -50,11 +59,34 @@ def get_signature(code:str) -> str: run_cell("from inspect import signature") return run_cell(f"print(signature({name}))") -def extract_args(code:str) -> tuple: + +def find_kwarg_index(signature_couple): + """ + Returns the index delimiting the args and kwargs in a list of arguments + Examples: + find_kwwarg_index(['a','b','c=3']) -> 2 + find_kwwarg_index([]) -> None + + Args: + list of Strings representing the arguments of a function + + Return: + index delimiting the args and kwargs in a list of arguments + + """ + kwarg_index = len(signature_couple) + for i, item in enumerate(signature_couple): + if "=" in item: + kwarg_index = i + break + return kwarg_index + + +def extract_args(code: str) -> tuple: """ Returns the args and kwargs of a string of Python code defining a function Examples: - get_signature(def hello(a,b,c=3)) -> "(a,b,c=3)" + get_signature(def hello(a,b,c=3)...) -> "(a,b,c=3)" Args: code: String containing Python code @@ -64,20 +96,19 @@ def extract_args(code:str) -> tuple: """ signature_string = get_signature(code) + print(signature_string) # Remove parentheses signature_string = signature_string[1:-2] - signature_string = signature_string.replace(" ","") + signature_string = signature_string.replace(" ", "") if signature_string == "": return ([], []) - signature_couple = signature_string.split(",") - kwarg_index = len(signature_couple) - for i, item in enumerate(signature_couple): - if "=" in item: - kwarg_index = i - break - return signature_couple[:kwarg_index], signature_couple[kwarg_index:] + signature_list = signature_string.split(",") + kwarg_index = find_kwarg_index(signature_list) + print(kwarg_index) + return signature_list[:kwarg_index], signature_list[kwarg_index:] -def execute_function(code:str, *args, **kwargs) -> str: + +def execute_function(code: str, *args, **kwargs) -> str: """ Executes the function defined in code in an IPython shell and runs it fed by args and kwargs. Other arguments than the first are passed to the function when executing it. @@ -99,3 +130,6 @@ def execute_function(code:str, *args, **kwargs) -> str: run_cell(code) return run_cell(execution_code + ')') + + +print(execute_function("def function(a,b,c=10):\n return a+b+c", 10, 5)) diff --git a/tests/unit/scene/test_function_parsing.py b/tests/unit/scene/test_function_parsing.py index 6a98fca7..d6e01cad 100644 --- a/tests/unit/scene/test_function_parsing.py +++ b/tests/unit/scene/test_function_parsing.py @@ -4,19 +4,85 @@ from pytest_mock import MockerFixture import pytest_check as check -from opencodeblocks.graphics.function_parsing import run_cell, get_function_name, get_signature, extract_args, execute_function +from opencodeblocks.graphics.function_parsing import (find_kwarg_index, run_cell, + get_function_name, + get_signature, + extract_args, + execute_function, + find_kwarg_index) + class TestFunctionParsing: - def test_run_cell(self, mocker:MockerFixture): + + """Testing function_parsing functions""" + + def test_run_cell(self, mocker: MockerFixture): + """ Test run_cell """ check.equal(run_cell("print(10)"), '10\n') - - def test_get_function_name(self, mocker:MockerFixture): - check.equal(get_function_name("def function():\n return 'Hello'"), 'function') - check.equal(get_function_name("#Hello\ndef function():\n return 'Hello'\na = 10"), 'function') - check.equal(get_function_name("#Hello\ndef function(a,b=10):\n return 'Hello'\na = 10"), 'function') - - def test_extract_args(self, mocker:MockerFixture): - mocker.patch('opencodeblocks.graphics.function_parsing.get_signature', return_value="()\n") - check.equal(extract_args("def function(a,b,c = 10):\n return 'Hello'"), ([],[])) - mocker.patch('opencodeblocks.graphics.function_parsing.get_signature', return_value="(a,b,c = 10)\n") - check.equal(extract_args("def function(a,b,c = 10):\n return 'Hello'"), (["a","b"],["c=10"])) + + def test_get_function_name(self, mocker: MockerFixture): + """ Test get_function_name """ + check.equal(get_function_name( + "def function():\n return 'Hello'"), 'function') + check.equal(get_function_name( + "#Hello\ndef function():\n return 'Hello'\na = 10"), 'function') + check.equal(get_function_name( + "#Hello\ndef function(a,b=10):\n return 'Hello'\na = 10"), 'function') + + def test_get_function_name_error(self, mocker: MockerFixture): + """ Return ValueError if get_function_name has wrong input """ + with pytest.raises(ValueError): + get_function_name("") + get_function_name("#Hello") + get_function_name("def function") + + def test_get_signature(self, mocker: MockerFixture): + """ Test get_signature """ + mocker.patch( + 'opencodeblocks.graphics.function_parsing.run_cell', return_value="(a, b, c=10)\n") + check.equal(get_signature( + "def function(a,b, c=10):\n return None"), "(a, b, c=10)\n") + + def test_find_kwarg_index(self, mocker: MockerFixture): + """ Test find_kwarg_index """ + check.equal(find_kwarg_index(['a', 'b', 'c=10']), 2) + check.equal(find_kwarg_index([]), 0) + + def test_extract_args(self, mocker: MockerFixture): + """ Test extract_args """ + mocker.patch( + 'opencodeblocks.graphics.function_parsing.get_signature', return_value="()\n") + mocker.patch( + 'opencodeblocks.graphics.function_parsing.find_kwarg_index', return_value=0) + check.equal(extract_args( + "def function():\n return 'Hello'"), ([], [])) + mocker.patch('opencodeblocks.graphics.function_parsing.get_signature', + return_value="(a,b,c = 10)\n") + mocker.patch( + 'opencodeblocks.graphics.function_parsing.find_kwarg_index', return_value=2) + check.equal(extract_args( + "def function(a,b,c = 10):\n return 'Hello'"), (["a", "b"], ["c=10"])) + + def test_extract_args_empty(self, mocker: MockerFixture): + """ Return a couple of empty lists if signature is empty """ + mocker.patch('opencodeblocks.graphics.function_parsing.get_signature', + return_value="()\n") + mocker.patch( + 'opencodeblocks.graphics.function_parsing.find_kwarg_index', return_value=None) + check.equal(extract_args( + "def function( ):\n return 'Hello'"), ([], [])) + mocker.patch('opencodeblocks.graphics.function_parsing.get_signature', + return_value="()\n") + mocker.patch( + 'opencodeblocks.graphics.function_parsing.find_kwarg_index', return_value=None) + check.equal(extract_args( + "def function():\n return 'Hello'"), ([], [])) + + def test_execute_function(self, mocker: MockerFixture): + """ Test execute_function """ + mocker.patch('opencodeblocks.graphics.function_parsing.get_function_name', + return_value="function") + mocker.patch( + 'opencodeblocks.graphics.function_parsing.run_cell', return_value="Out[1]: 25\n") + check.equal(execute_function( + "def function(a,b,c=10):\n return a+b+c", 10, 5), "Out[1]: 25\n") From d524fa71fdbab78548d8fbe1960a9956a756ed2d Mon Sep 17 00:00:00 2001 From: Alexandre Sajus Date: Sun, 31 Oct 2021 00:26:58 +0200 Subject: [PATCH 09/32] :wrench: removed a print --- opencodeblocks/graphics/function_parsing.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/opencodeblocks/graphics/function_parsing.py b/opencodeblocks/graphics/function_parsing.py index 1d4a772a..3b20883a 100644 --- a/opencodeblocks/graphics/function_parsing.py +++ b/opencodeblocks/graphics/function_parsing.py @@ -130,6 +130,3 @@ def execute_function(code: str, *args, **kwargs) -> str: run_cell(code) return run_cell(execution_code + ')') - - -print(execute_function("def function(a,b,c=10):\n return a+b+c", 10, 5)) From a79e1ed1c42e2aa025767e036b8539d21e0747fe Mon Sep 17 00:00:00 2001 From: Alexandre Sajus Date: Sun, 31 Oct 2021 19:52:19 +0100 Subject: [PATCH 10/32] :tada: execution of code through a kernel and image support --- opencodeblocks/graphics/function_parsing.py | 21 +++-------- opencodeblocks/graphics/kernel.py | 40 +++++++++++++++++++++ 2 files changed, 44 insertions(+), 17 deletions(-) create mode 100644 opencodeblocks/graphics/kernel.py diff --git a/opencodeblocks/graphics/function_parsing.py b/opencodeblocks/graphics/function_parsing.py index 3b20883a..d8fd4029 100644 --- a/opencodeblocks/graphics/function_parsing.py +++ b/opencodeblocks/graphics/function_parsing.py @@ -1,24 +1,11 @@ from typing import Any, Dict -from IPython.testing.globalipapp import get_ipython -from IPython.utils.io import capture_output -ip = get_ipython() +from opencodeblocks.graphics.kernel import Kernel +kernel = Kernel() -def run_cell(cell: str): - """ - Executes a string of code in an IPython shell and returns the execution result - - Args: - cell: String containing Python code - Return: - Execution result of cell - - """ - with capture_output() as io: - _ = ip.run_cell(cell) - res_out = io.stdout - return res_out +def run_cell(cell: str): + return kernel.execute(cell) def get_function_name(code: str) -> str: diff --git a/opencodeblocks/graphics/kernel.py b/opencodeblocks/graphics/kernel.py new file mode 100644 index 00000000..7495f5a4 --- /dev/null +++ b/opencodeblocks/graphics/kernel.py @@ -0,0 +1,40 @@ +import queue +from jupyter_client.manager import start_new_kernel + + +class Kernel(): + + def __init__(self): + self.kernel_manager, self.client = start_new_kernel() + + def execute(self, code): + _ = self.client.execute(code) + io_msg_content = [] + if 'execution_state' in io_msg_content and io_msg_content['execution_state'] == 'idle': + return "no output" + + while True: + temp = io_msg_content + try: + io_msg_content = self.client.get_iopub_msg(timeout=1000)[ + 'content'] + if 'execution_state' in io_msg_content and io_msg_content['execution_state'] == 'idle': + break + except queue.Empty: + break + + if 'data' in temp: + if 'image/png' in temp['data']: + out = temp['data']['image/png'] + else: + out = temp['data']['text/plain'] + elif 'name' in temp and temp['name'] == "stdout": + out = temp['text'] + elif 'traceback' in temp: + out = '\n'.join(temp['traceback']) + else: + out = '' + return out + + def __del__(self): + self.kernel_manager.shutdown_kernel() From 5b1ab386fe079757a79112660d39e2c6913aefa7 Mon Sep 17 00:00:00 2001 From: Alexandre Sajus Date: Mon, 1 Nov 2021 23:04:15 +0100 Subject: [PATCH 11/32] :tada: support for variable output (ex: Tensorflow progress bars) --- opencodeblocks/graphics/function_parsing.py | 30 ++++++++ opencodeblocks/graphics/kernel.py | 79 +++++++++++++++++---- 2 files changed, 95 insertions(+), 14 deletions(-) diff --git a/opencodeblocks/graphics/function_parsing.py b/opencodeblocks/graphics/function_parsing.py index d8fd4029..c366c944 100644 --- a/opencodeblocks/graphics/function_parsing.py +++ b/opencodeblocks/graphics/function_parsing.py @@ -1,3 +1,6 @@ + +""" Module for code parsing and code execution """ + from typing import Any, Dict from opencodeblocks.graphics.kernel import Kernel @@ -5,9 +8,36 @@ def run_cell(cell: str): + """ + Executes a piece of Python code in an ipython kernel, returns its last output + + Args: + cell: String containing Python code + + Return: + output in the last message sent by the kernel + """ return kernel.execute(cell) +def run_with_variable_output(cell: str): + """ + This is a proof of concept to show that it is possible + to collect a variable output from a kernel execution + + Here the kernel executes the code and prints the output repeatedly + For example: if cell="model.fit(...)", this would print the progress bar progressing + + Args: + cell: String containing Python code + """ + kernel.client.execute(cell) + done = False + while done == False: + output, done = kernel.update_output() + print(output) + + def get_function_name(code: str) -> str: """ Parses a string of code and returns the first function name it finds diff --git a/opencodeblocks/graphics/kernel.py b/opencodeblocks/graphics/kernel.py index 7495f5a4..88380611 100644 --- a/opencodeblocks/graphics/kernel.py +++ b/opencodeblocks/graphics/kernel.py @@ -1,3 +1,6 @@ + +""" Module to create and manage ipython kernels """ + import queue from jupyter_client.manager import start_new_kernel @@ -7,14 +10,51 @@ class Kernel(): def __init__(self): self.kernel_manager, self.client = start_new_kernel() - def execute(self, code): + def message_to_output(self, message: dict): + """ + Converts a message sent by the kernel into a relevant output + + Args: + message: dict representing the a message sent by the kernel + + Return: + single output found in the message in that order of priority: image > text data > text print > error > nothing + """ + if 'data' in message: + if 'image/png' in message['data']: + # output an image (from plt.plot or plt.imshow) + out = message['data']['image/png'] + else: + # output data as str (for example if code="a=10\na") + out = message['data']['text/plain'] + elif 'name' in message and message['name'] == "stdout": + # output a print (print("Hello World")) + out = message['text'] + elif 'traceback' in message: + # output an error + out = '\n'.join(message['traceback']) + else: + out = '' + return out + + def execute(self, code: str): + """ + Executes code in the kernel and returns the output of the last message sent by the kernel in return + + Args: + code: str representing a piece of Python code to execute + + Return: + output from the last message sent by the kernel in return + """ _ = self.client.execute(code) io_msg_content = [] if 'execution_state' in io_msg_content and io_msg_content['execution_state'] == 'idle': return "no output" while True: - temp = io_msg_content + # Check for messages, break the loop when the kernel stops sending messages + message = io_msg_content try: io_msg_content = self.client.get_iopub_msg(timeout=1000)[ 'content'] @@ -23,18 +63,29 @@ def execute(self, code): except queue.Empty: break - if 'data' in temp: - if 'image/png' in temp['data']: - out = temp['data']['image/png'] - else: - out = temp['data']['text/plain'] - elif 'name' in temp and temp['name'] == "stdout": - out = temp['text'] - elif 'traceback' in temp: - out = '\n'.join(temp['traceback']) - else: - out = '' - return out + return self.message_to_output(message) + + def update_output(self): + """ + Returns the current output of the kernel + + Return: + current output of the kernel; done: bool, True if the kernel has no message to send + """ + message = None + done = False + try: + message = self.client.get_iopub_msg(timeout=1000)[ + 'content'] + if 'execution_state' in message and message['execution_state'] == 'idle': + done = True + except queue.Empty: + done = True + + return self.message_to_output(message), done def __del__(self): + """ + Shuts down the kernel + """ self.kernel_manager.shutdown_kernel() From 0755cc0380f997fa9edacd1edc8a1f9b30ee7e4b Mon Sep 17 00:00:00 2001 From: Alexandre Sajus Date: Tue, 2 Nov 2021 14:21:17 +0100 Subject: [PATCH 12/32] :wrench: removed unnecessary prints --- opencodeblocks/graphics/function_parsing.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/opencodeblocks/graphics/function_parsing.py b/opencodeblocks/graphics/function_parsing.py index c366c944..1b22a5d6 100644 --- a/opencodeblocks/graphics/function_parsing.py +++ b/opencodeblocks/graphics/function_parsing.py @@ -113,7 +113,6 @@ def extract_args(code: str) -> tuple: """ signature_string = get_signature(code) - print(signature_string) # Remove parentheses signature_string = signature_string[1:-2] signature_string = signature_string.replace(" ", "") @@ -121,7 +120,6 @@ def extract_args(code: str) -> tuple: return ([], []) signature_list = signature_string.split(",") kwarg_index = find_kwarg_index(signature_list) - print(kwarg_index) return signature_list[:kwarg_index], signature_list[kwarg_index:] From a9b8dd15d67d0a153565209cc3cac7aae7ec6b37 Mon Sep 17 00:00:00 2001 From: Alexandre Sajus Date: Tue, 2 Nov 2021 15:01:45 +0100 Subject: [PATCH 13/32] :wrench: specified args and return types --- opencodeblocks/graphics/function_parsing.py | 10 +++++----- opencodeblocks/graphics/kernel.py | 7 ++++--- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/opencodeblocks/graphics/function_parsing.py b/opencodeblocks/graphics/function_parsing.py index 1b22a5d6..33d5564a 100644 --- a/opencodeblocks/graphics/function_parsing.py +++ b/opencodeblocks/graphics/function_parsing.py @@ -1,13 +1,13 @@ """ Module for code parsing and code execution """ -from typing import Any, Dict +from typing import List, Tuple from opencodeblocks.graphics.kernel import Kernel kernel = Kernel() -def run_cell(cell: str): +def run_cell(cell: str) -> str: """ Executes a piece of Python code in an ipython kernel, returns its last output @@ -20,7 +20,7 @@ def run_cell(cell: str): return kernel.execute(cell) -def run_with_variable_output(cell: str): +def run_with_variable_output(cell: str) -> None: """ This is a proof of concept to show that it is possible to collect a variable output from a kernel execution @@ -77,7 +77,7 @@ def get_signature(code: str) -> str: return run_cell(f"print(signature({name}))") -def find_kwarg_index(signature_couple): +def find_kwarg_index(signature_couple: List[str]) -> int: """ Returns the index delimiting the args and kwargs in a list of arguments Examples: @@ -99,7 +99,7 @@ def find_kwarg_index(signature_couple): return kwarg_index -def extract_args(code: str) -> tuple: +def extract_args(code: str) -> Tuple[List[str], List[str]]: """ Returns the args and kwargs of a string of Python code defining a function Examples: diff --git a/opencodeblocks/graphics/kernel.py b/opencodeblocks/graphics/kernel.py index 88380611..93f83774 100644 --- a/opencodeblocks/graphics/kernel.py +++ b/opencodeblocks/graphics/kernel.py @@ -2,6 +2,7 @@ """ Module to create and manage ipython kernels """ import queue +from typing import Tuple from jupyter_client.manager import start_new_kernel @@ -10,7 +11,7 @@ class Kernel(): def __init__(self): self.kernel_manager, self.client = start_new_kernel() - def message_to_output(self, message: dict): + def message_to_output(self, message: dict) -> str: """ Converts a message sent by the kernel into a relevant output @@ -37,7 +38,7 @@ def message_to_output(self, message: dict): out = '' return out - def execute(self, code: str): + def execute(self, code: str) -> str: """ Executes code in the kernel and returns the output of the last message sent by the kernel in return @@ -65,7 +66,7 @@ def execute(self, code: str): return self.message_to_output(message) - def update_output(self): + def update_output(self) -> Tuple[str, bool]: """ Returns the current output of the kernel From a00c37eacdd717cbe1971b2c926f9e8cbd5c165e Mon Sep 17 00:00:00 2001 From: AlexandreSajus Date: Wed, 3 Nov 2021 14:43:51 +0100 Subject: [PATCH 14/32] :wrench: added requirements --- opencodeblocks/graphics/kernel.py | 2 +- requirements.txt | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/opencodeblocks/graphics/kernel.py b/opencodeblocks/graphics/kernel.py index 93f83774..35f032b9 100644 --- a/opencodeblocks/graphics/kernel.py +++ b/opencodeblocks/graphics/kernel.py @@ -49,7 +49,7 @@ def execute(self, code: str) -> str: output from the last message sent by the kernel in return """ _ = self.client.execute(code) - io_msg_content = [] + io_msg_content = {} if 'execution_state' in io_msg_content and io_msg_content['execution_state'] == 'idle': return "no output" diff --git a/requirements.txt b/requirements.txt index 791bfb43..c9931472 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ pyqt5>=5.15.4 QtPy>=1.9.0 qscintilla>=2.13.0 -Ipython>=7.27.0 \ No newline at end of file +Ipython>=7.27.0 +jupyter_client +ipykernel \ No newline at end of file From b89f9949aa472dd687e86a8578b46eda98b341df Mon Sep 17 00:00:00 2001 From: AlexandreSajus Date: Thu, 4 Nov 2021 15:39:33 +0100 Subject: [PATCH 15/32] :tada: Fully functional notebook --- opencodeblocks/graphics/blocks/codeblock.py | 81 ++++++++++++++++++++- opencodeblocks/graphics/kernel.py | 18 +++-- opencodeblocks/graphics/pyeditor.py | 21 ++++-- 3 files changed, 108 insertions(+), 12 deletions(-) diff --git a/opencodeblocks/graphics/blocks/codeblock.py b/opencodeblocks/graphics/blocks/codeblock.py index 306a2e96..07f3dcd0 100644 --- a/opencodeblocks/graphics/blocks/codeblock.py +++ b/opencodeblocks/graphics/blocks/codeblock.py @@ -3,7 +3,11 @@ """ Module for the base OCB Code Block. """ -from PyQt5.QtWidgets import QGraphicsProxyWidget +from typing import Optional + +from PyQt5.QtCore import Qt, QByteArray +from PyQt5.QtGui import QPainter, QPainterPath, QPixmap +from PyQt5.QtWidgets import QStyleOptionGraphicsItem, QWidget, QGraphicsProxyWidget, QLabel from opencodeblocks.graphics.blocks.block import OCBBlock from opencodeblocks.graphics.pyeditor import PythonEditor @@ -15,6 +19,9 @@ class OCBCodeBlock(OCBBlock): def __init__(self, **kwargs): super().__init__(block_type='code', **kwargs) self.source_editor = self.init_source_editor() + self.display = self.init_display() + self.stdout = "" + self.image = "" def init_source_editor(self): """ Initialize the python source code editor. """ @@ -40,6 +47,13 @@ def update_all(self): int(self._width - 2*self.edge_size), int(self.height - self.title_height - 2*self.edge_size) ) + editor_widget = self.display.widget() + editor_widget.setGeometry( + int(self.edge_size), + int(self.edge_size + self.height), + int(self.width - 2*self.edge_size), + int(self.height*0.3 - 2*self.edge_size) + ) super().update_all() @property @@ -52,3 +66,68 @@ def source(self, value:str): if hasattr(self, 'source_editor'): editor_widget = self.source_editor.widget() editor_widget.setText(self._source) + + @property + def stdout(self) -> str: + """ Code output. """ + return self._stdout + @stdout.setter + def stdout(self, value:str): + self._stdout = value + if hasattr(self, 'source_editor'): + # If there is a text output, erase the image output and display the text output + self.image = "" + editor_widget = self.display.widget() + editor_widget.setText(self._stdout) + + @property + def image(self) -> str: + """ Code output. """ + return self._image + @image.setter + def image(self, value:str): + self._image = value + if hasattr(self, 'source_editor') and self.image != "": + # If there is an image output, erase the text output and display the image output + editor_widget = self.display.widget() + editor_widget.setText("") + qlabel = editor_widget + ba = QByteArray.fromBase64(str.encode(self.image)) + pixmap = QPixmap() + pixmap.loadFromData(ba) + qlabel.setPixmap(pixmap) + + @source.setter + def source(self, value:str): + self._source = value + if hasattr(self, 'source_editor'): + editor_widget = self.source_editor.widget() + editor_widget.setText(self._source) + + def paint(self, painter: QPainter, + option: QStyleOptionGraphicsItem, #pylint:disable=unused-argument + widget: Optional[QWidget]=None): #pylint:disable=unused-argument + """ Paint the output panel """ + super().paint(painter, option, widget) + path_title = QPainterPath() + path_title.setFillRule(Qt.FillRule.WindingFill) + path_title.addRoundedRect(0, self.height, self.width, 0.3*self.height, + self.edge_size, self.edge_size) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(self._brush_background) + painter.drawPath(path_title.simplified()) + + def init_display(self): + """ Initialize the ouptput display widget: QLabel """ + display_graphics = QGraphicsProxyWidget(self) + display = QLabel() + display.setText("") + display.setGeometry( + int(self.edge_size), + int(self.edge_size + self.height), + int(self.width - 2*self.edge_size), + int(self.height*0.3 - 2*self.edge_size) + ) + display_graphics.setWidget(display) + display_graphics.setZValue(-1) + return display_graphics diff --git a/opencodeblocks/graphics/kernel.py b/opencodeblocks/graphics/kernel.py index 35f032b9..41751b54 100644 --- a/opencodeblocks/graphics/kernel.py +++ b/opencodeblocks/graphics/kernel.py @@ -11,7 +11,7 @@ class Kernel(): def __init__(self): self.kernel_manager, self.client = start_new_kernel() - def message_to_output(self, message: dict) -> str: + def message_to_output(self, message: dict) -> Tuple[str, str]: """ Converts a message sent by the kernel into a relevant output @@ -21,22 +21,28 @@ def message_to_output(self, message: dict) -> str: Return: single output found in the message in that order of priority: image > text data > text print > error > nothing """ + type = 'None' if 'data' in message: if 'image/png' in message['data']: + type = 'image' # output an image (from plt.plot or plt.imshow) out = message['data']['image/png'] else: + type = 'text' # output data as str (for example if code="a=10\na") out = message['data']['text/plain'] elif 'name' in message and message['name'] == "stdout": + type = 'text' # output a print (print("Hello World")) out = message['text'] elif 'traceback' in message: + type = 'text' # output an error out = '\n'.join(message['traceback']) else: + type = 'text' out = '' - return out + return out, type def execute(self, code: str) -> str: """ @@ -64,9 +70,9 @@ def execute(self, code: str) -> str: except queue.Empty: break - return self.message_to_output(message) + return self.message_to_output(message)[0] - def update_output(self) -> Tuple[str, bool]: + def update_output(self) -> Tuple[str, str, bool]: """ Returns the current output of the kernel @@ -83,7 +89,9 @@ def update_output(self) -> Tuple[str, bool]: except queue.Empty: done = True - return self.message_to_output(message), done + out, type = self.message_to_output(message) + + return out, type, done def __del__(self): """ diff --git a/opencodeblocks/graphics/pyeditor.py b/opencodeblocks/graphics/pyeditor.py index f7cb9b1d..3bbebc1d 100644 --- a/opencodeblocks/graphics/pyeditor.py +++ b/opencodeblocks/graphics/pyeditor.py @@ -4,12 +4,14 @@ """ Module for OCB in block python editor. """ from typing import TYPE_CHECKING, List -from PyQt5.QtCore import Qt +from PyQt5.QtCore import Qt, QCoreApplication from PyQt5.QtGui import QFocusEvent, QFont, QFontMetrics, QColor from PyQt5.Qsci import QsciScintilla, QsciLexerPython from opencodeblocks.graphics.blocks.block import OCBBlock -from opencodeblocks.graphics.function_parsing import execute_function +from opencodeblocks.graphics.kernel import Kernel + +kernel = Kernel() if TYPE_CHECKING: from opencodeblocks.graphics.view import OCBView @@ -121,10 +123,17 @@ def focusOutEvent(self, event: QFocusEvent): if self.isModified() and code != self.block.source: self.block.source = code self.setModified(False) - - args, kwargs = self.gatherBlockInputs() - self.output = execute_function(code, *args, **kwargs) - print(self.output) + kernel.client.execute(code) + done = False + while done is False: + QCoreApplication.processEvents() + output, type, done = kernel.update_output() + if done is False: + print(output) + if type == 'text': + self.block.stdout = output + elif type == 'image': + self.block.image = output return super().focusOutEvent(event) def gatherBlockInputs(self): From fda4dec2436a1b0a7ef35d4d16ef6b4ff13d2050 Mon Sep 17 00:00:00 2001 From: AlexandreSajus Date: Thu, 4 Nov 2021 16:45:41 +0100 Subject: [PATCH 16/32] :wrench: Comments --- opencodeblocks/graphics/kernel.py | 1 + opencodeblocks/graphics/pyeditor.py | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/opencodeblocks/graphics/kernel.py b/opencodeblocks/graphics/kernel.py index 41751b54..87ad8e14 100644 --- a/opencodeblocks/graphics/kernel.py +++ b/opencodeblocks/graphics/kernel.py @@ -20,6 +20,7 @@ def message_to_output(self, message: dict) -> Tuple[str, str]: Return: single output found in the message in that order of priority: image > text data > text print > error > nothing + type: 'image' or 'text' """ type = 'None' if 'data' in message: diff --git a/opencodeblocks/graphics/pyeditor.py b/opencodeblocks/graphics/pyeditor.py index 3bbebc1d..1e40c510 100644 --- a/opencodeblocks/graphics/pyeditor.py +++ b/opencodeblocks/graphics/pyeditor.py @@ -123,13 +123,16 @@ def focusOutEvent(self, event: QFocusEvent): if self.isModified() and code != self.block.source: self.block.source = code self.setModified(False) + # Execute the code kernel.client.execute(code) done = False + # While the kernel sends messages while done is False: + # Keep the GUI alive QCoreApplication.processEvents() + # Save kernel message and display it output, type, done = kernel.update_output() if done is False: - print(output) if type == 'text': self.block.stdout = output elif type == 'image': From e001977f0bf7ee2598c090596e55d8ab22f04ecd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Math=C3=AFs=20F=C3=A9d=C3=A9rico?= Date: Sun, 7 Nov 2021 00:50:45 +0100 Subject: [PATCH 17/32] :memo: Update requirements with versions --- requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index c9931472..18f07683 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,5 +2,5 @@ pyqt5>=5.15.4 QtPy>=1.9.0 qscintilla>=2.13.0 Ipython>=7.27.0 -jupyter_client -ipykernel \ No newline at end of file +jupyter_client>=7.0.6 +ipykernel>=6.5.0 \ No newline at end of file From b947f24b70c0b634c4344d0643b8993b308a1cb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Math=C3=AFs=20F=C3=A9d=C3=A9rico?= Date: Sat, 13 Nov 2021 14:43:22 +0100 Subject: [PATCH 18/32] :beetle: Fix crash on open if json is modified --- opencodeblocks/graphics/blocks/codeblock.py | 10 +++++----- opencodeblocks/graphics/edge.py | 14 +++++++++----- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/opencodeblocks/graphics/blocks/codeblock.py b/opencodeblocks/graphics/blocks/codeblock.py index 07f3dcd0..7ede6bb6 100644 --- a/opencodeblocks/graphics/blocks/codeblock.py +++ b/opencodeblocks/graphics/blocks/codeblock.py @@ -47,10 +47,10 @@ def update_all(self): int(self._width - 2*self.edge_size), int(self.height - self.title_height - 2*self.edge_size) ) - editor_widget = self.display.widget() - editor_widget.setGeometry( + display_widget = self.display.widget() + display_widget.setGeometry( int(self.edge_size), - int(self.edge_size + self.height), + int(self.height + self.edge_size), int(self.width - 2*self.edge_size), int(self.height*0.3 - 2*self.edge_size) ) @@ -107,11 +107,11 @@ def source(self, value:str): def paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, #pylint:disable=unused-argument widget: Optional[QWidget]=None): #pylint:disable=unused-argument - """ Paint the output panel """ + """ Paint the code output panel """ super().paint(painter, option, widget) path_title = QPainterPath() path_title.setFillRule(Qt.FillRule.WindingFill) - path_title.addRoundedRect(0, self.height, self.width, 0.3*self.height, + path_title.addRoundedRect(0, 0, self.width, 1.3*self.height, self.edge_size, self.edge_size) painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(self._brush_background) diff --git a/opencodeblocks/graphics/edge.py b/opencodeblocks/graphics/edge.py index 5210c9b7..1daa4b90 100644 --- a/opencodeblocks/graphics/edge.py +++ b/opencodeblocks/graphics/edge.py @@ -183,8 +183,12 @@ def deserialize(self, data: OrderedDict, hashmap: dict = None, restore_id=True): if restore_id: self.id = data['id'] self.path_type = data['path_type'] - self.source_socket = hashmap[data['source']['socket']] - self.source_socket.add_edge(self) - self.destination_socket = hashmap[data['destination']['socket']] - self.destination_socket.add_edge(self) - self.update_path() + try: + self.source_socket = hashmap[data['source']['socket']] + self.source_socket.add_edge(self) + + self.destination_socket = hashmap[data['destination']['socket']] + self.destination_socket.add_edge(self) + self.update_path() + except KeyError: + self.remove() From c106af317cbf4cfa0e06307de5426add268ba1a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoine=20Del=C3=A8gue?= Date: Sat, 13 Nov 2021 16:48:57 +0100 Subject: [PATCH 19/32] The cursor changes to resize mode when hovering over the resizing area. This makes it easier to locate the resizing area when using the interface. --- opencodeblocks/graphics/blocks/block.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/opencodeblocks/graphics/blocks/block.py b/opencodeblocks/graphics/blocks/block.py index 90630bf6..c82f7e73 100644 --- a/opencodeblocks/graphics/blocks/block.py +++ b/opencodeblocks/graphics/blocks/block.py @@ -72,7 +72,10 @@ def __init__(self, block_type:str='base', source:str='', position:tuple=(0, 0), self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable) self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable) + self.setAcceptHoverEvents(True) + self.resizing = False + self.resizing_hover = False # Is the mouse hovering over the resizing area ? self.moved = False self.metadata = { 'title_metadata': { @@ -172,6 +175,24 @@ def remove_socket(self, socket:OCBSocket): socket.remove() self.update_sockets() + def hoverMoveEvent(self, event): + pos = event.pos() + if self._is_in_resize_area(pos): + if not self.resizing_hover: + self.resizing_hover = True + QApplication.setOverrideCursor(Qt.CursorShape.SizeFDiagCursor) + elif self.resizing_hover: + self.resizing_hover = False + QApplication.restoreOverrideCursor() + + return super().hoverMoveEvent(event) + def hoverLeaveEvent(self, event): + if self.resizing_hover: + self.resizing_hover = False + QApplication.restoreOverrideCursor() + + return super().hoverLeaveEvent(event) + def mousePressEvent(self, event:QGraphicsSceneMouseEvent): """ OCBBlock reaction to a mousePressEvent. """ pos = event.pos() From 4560323d190ea481f4d5cb29fe63a441d574e084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoine=20Del=C3=A8gue?= Date: Sat, 13 Nov 2021 18:05:38 +0100 Subject: [PATCH 20/32] Add ability to resize the source_editor and the output display part of a block independently. During this process, some duplicated code for setting the size of said panel is removed. --- opencodeblocks/graphics/blocks/block.py | 2 + opencodeblocks/graphics/blocks/codeblock.py | 138 +++++++++++++++++--- 2 files changed, 119 insertions(+), 21 deletions(-) diff --git a/opencodeblocks/graphics/blocks/block.py b/opencodeblocks/graphics/blocks/block.py index c82f7e73..a704b75d 100644 --- a/opencodeblocks/graphics/blocks/block.py +++ b/opencodeblocks/graphics/blocks/block.py @@ -176,6 +176,7 @@ def remove_socket(self, socket:OCBSocket): self.update_sockets() def hoverMoveEvent(self, event): + """ Triggered when hovering over a block """ pos = event.pos() if self._is_in_resize_area(pos): if not self.resizing_hover: @@ -187,6 +188,7 @@ def hoverMoveEvent(self, event): return super().hoverMoveEvent(event) def hoverLeaveEvent(self, event): + """ Triggered when the mouse stops hovering over a block """ if self.resizing_hover: self.resizing_hover = False QApplication.restoreOverrideCursor() diff --git a/opencodeblocks/graphics/blocks/codeblock.py b/opencodeblocks/graphics/blocks/codeblock.py index 7ede6bb6..6f956ce7 100644 --- a/opencodeblocks/graphics/blocks/codeblock.py +++ b/opencodeblocks/graphics/blocks/codeblock.py @@ -5,38 +5,61 @@ from typing import Optional -from PyQt5.QtCore import Qt, QByteArray +from PyQt5.QtCore import Qt, QByteArray, QPointF from PyQt5.QtGui import QPainter, QPainterPath, QPixmap -from PyQt5.QtWidgets import QStyleOptionGraphicsItem, QWidget, QGraphicsProxyWidget, QLabel +from PyQt5.QtWidgets import QStyleOptionGraphicsItem, QWidget, QGraphicsProxyWidget, QLabel, \ + QGraphicsSceneMouseEvent, QApplication from opencodeblocks.graphics.blocks.block import OCBBlock from opencodeblocks.graphics.pyeditor import PythonEditor class OCBCodeBlock(OCBBlock): - """ Code Block. """ + """ + Code Block + + Features an area to edit code as well as a panel to display the output. + + """ def __init__(self, **kwargs): + """ + Note that self.output_panel_height < self.height, + because the output panel is part of the display. + Moreover, the following is always true: + output_panel_height + source_panel_height + edge_size*2 + title_height == height + """ super().__init__(block_type='code', **kwargs) + + + self.output_panel_height = 100 + self._min_output_panel_height = 20 + self._min_source_editor_height = 20 + + assert self.height - self.output_panel_height \ + - self.title_height - self.edge_size*2 > 0 + self.source_editor = self.init_source_editor() self.display = self.init_display() self.stdout = "" self.image = "" + self.resizing_source_code = False + + self.update_all() # Set the geometry of display and source_editor + def init_source_editor(self): """ Initialize the python source code editor. """ source_editor_graphics = QGraphicsProxyWidget(self) source_editor = PythonEditor(self) - source_editor.setGeometry( - int(self.edge_size), - int(self.edge_size + self.title_height), - int(self.width - 2*self.edge_size), - int(self.height - self.title_height - 2*self.edge_size) - ) source_editor_graphics.setWidget(source_editor) source_editor_graphics.setZValue(-1) return source_editor_graphics + def _editor_widget_height(self): + return self.height - self.title_height - 2*self.edge_size \ + - self.output_panel_height + def update_all(self): """ Update the code block parts. """ if hasattr(self, 'source_editor'): @@ -45,14 +68,14 @@ def update_all(self): int(self.edge_size), int(self.edge_size + self.title_height), int(self._width - 2*self.edge_size), - int(self.height - self.title_height - 2*self.edge_size) + int(self._editor_widget_height()) ) display_widget = self.display.widget() display_widget.setGeometry( int(self.edge_size), - int(self.height + self.edge_size), + int(self.height - self.output_panel_height - self.edge_size), int(self.width - 2*self.edge_size), - int(self.height*0.3 - 2*self.edge_size) + int(self.output_panel_height) ) super().update_all() @@ -69,7 +92,7 @@ def source(self, value:str): @property def stdout(self) -> str: - """ Code output. """ + """ Code output, without errors """ return self._stdout @stdout.setter def stdout(self, value:str): @@ -111,23 +134,96 @@ def paint(self, painter: QPainter, super().paint(painter, option, widget) path_title = QPainterPath() path_title.setFillRule(Qt.FillRule.WindingFill) - path_title.addRoundedRect(0, 0, self.width, 1.3*self.height, + path_title.addRoundedRect(0, 0, self.width, self.height, self.edge_size, self.edge_size) painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(self._brush_background) painter.drawPath(path_title.simplified()) + + + def _is_in_code_output_resize_area(self, pos:QPointF): + """ Return True if the given position is in the block resize_area. """ + source_editor_start = self.height - self.output_panel_height - self.edge_size + + return self.width - 2 * self.edge_size < pos.x() and \ + source_editor_start - self.edge_size < pos.y() < source_editor_start + self.edge_size + + def hoverMoveEvent(self, event): + """ Triggered when hovering over a block """ + pos = event.pos() + if self._is_in_resize_area(pos) or self._is_in_code_output_resize_area(pos): + if not self.resizing_hover: + self.resizing_hover = True + QApplication.setOverrideCursor(Qt.CursorShape.SizeFDiagCursor) + elif self.resizing_hover: + self.resizing_hover = False + QApplication.restoreOverrideCursor() + # Don't call super() because this might override the cursor + def hoverLeaveEvent(self, event): + """ Triggered when the mouse stops hovering over a block """ + if self.resizing_hover: + self.resizing_hover = False + QApplication.restoreOverrideCursor() + # Don't call super() because this might override the cursor + + def mousePressEvent(self, event:QGraphicsSceneMouseEvent): + """ OCBBlock reaction to a mousePressEvent. """ + pos = event.pos() + resizing_source_code = self._is_in_code_output_resize_area(pos) + if (self._is_in_resize_area(pos) or resizing_source_code) and \ + event.buttons() == Qt.MouseButton.LeftButton: + self.resize_start = pos + self.resizing = True + self.resizing_source_code = resizing_source_code + QApplication.setOverrideCursor(Qt.CursorShape.SizeFDiagCursor) + + super().mousePressEvent(event) + + def mouseReleaseEvent(self, event:QGraphicsSceneMouseEvent): + """ OCBBlock reaction to a mouseReleaseEvent. """ + if self.resizing: + self.scene().history.checkpoint("Resized block", set_modified=True) + self.resizing = False + self.resizing_source_code = False + QApplication.restoreOverrideCursor() + if self.moved: + self.moved = False + self.scene().history.checkpoint("Moved block", set_modified=True) + super().mouseReleaseEvent(event) + + def mouseMoveEvent(self, event:QGraphicsSceneMouseEvent): + """ + We override the default resizing behavior as the code part and the display part of the block + block can be resized. + """ + if self.resizing: + delta = event.pos() - self.resize_start + self.width = max(self.width + delta.x(), self._min_width) + + height_delta = max(delta.y(), + # List of all the quantities that must remain negative. + # Mainly: min_height - height must be negative for all elements + self._min_output_panel_height - self.output_panel_height, + self._min_height - self.height, + self._min_source_editor_height - self._editor_widget_height() + ) + + self.height += height_delta + if not self.resizing_source_code: + self.output_panel_height += height_delta + + self.resize_start = event.pos() + self.title_graphics.setTextWidth(self.width - 2 * self.edge_size) + self.update() + else: + super().mouseMoveEvent(event) + self.moved = True def init_display(self): - """ Initialize the ouptput display widget: QLabel """ + """ Initialize the output display widget: QLabel """ display_graphics = QGraphicsProxyWidget(self) display = QLabel() display.setText("") - display.setGeometry( - int(self.edge_size), - int(self.edge_size + self.height), - int(self.width - 2*self.edge_size), - int(self.height*0.3 - 2*self.edge_size) - ) display_graphics.setWidget(display) display_graphics.setZValue(-1) return display_graphics From 2e7461a80201d84c8c076d962659244bb3bd7d3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoine=20Del=C3=A8gue?= Date: Sat, 13 Nov 2021 18:13:38 +0100 Subject: [PATCH 21/32] Removed trailing whitespace --- opencodeblocks/graphics/blocks/codeblock.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/opencodeblocks/graphics/blocks/codeblock.py b/opencodeblocks/graphics/blocks/codeblock.py index 6f956ce7..bd311ea2 100644 --- a/opencodeblocks/graphics/blocks/codeblock.py +++ b/opencodeblocks/graphics/blocks/codeblock.py @@ -30,7 +30,7 @@ def __init__(self, **kwargs): output_panel_height + source_panel_height + edge_size*2 + title_height == height """ super().__init__(block_type='code', **kwargs) - + self.output_panel_height = 100 self._min_output_panel_height = 20 @@ -139,7 +139,7 @@ def paint(self, painter: QPainter, painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(self._brush_background) painter.drawPath(path_title.simplified()) - + def _is_in_code_output_resize_area(self, pos:QPointF): """ Return True if the given position is in the block resize_area. """ @@ -199,7 +199,7 @@ def mouseMoveEvent(self, event:QGraphicsSceneMouseEvent): if self.resizing: delta = event.pos() - self.resize_start self.width = max(self.width + delta.x(), self._min_width) - + height_delta = max(delta.y(), # List of all the quantities that must remain negative. # Mainly: min_height - height must be negative for all elements From 51c356139ea98ce67f12810077c83e18f7364400 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoine=20Del=C3=A8gue?= Date: Sun, 14 Nov 2021 01:19:30 +0100 Subject: [PATCH 22/32] :wrench: Rewrote part of block.py to remove repetitions related to hovering events. --- opencodeblocks/graphics/blocks/block.py | 40 +++++---- opencodeblocks/graphics/blocks/codeblock.py | 99 ++++++++------------- 2 files changed, 64 insertions(+), 75 deletions(-) diff --git a/opencodeblocks/graphics/blocks/block.py b/opencodeblocks/graphics/blocks/block.py index a704b75d..ec7ab976 100644 --- a/opencodeblocks/graphics/blocks/block.py +++ b/opencodeblocks/graphics/blocks/block.py @@ -8,7 +8,7 @@ from PyQt5.QtCore import QPointF, QRectF, Qt from PyQt5.QtGui import QBrush, QPen, QColor, QFont, QPainter, QPainterPath from PyQt5.QtWidgets import QGraphicsItem, QGraphicsSceneMouseEvent, QGraphicsTextItem, \ - QStyleOptionGraphicsItem, QWidget, QApplication + QStyleOptionGraphicsItem, QWidget, QApplication, QGraphicsSceneHoverEvent from opencodeblocks.core.serializable import Serializable from opencodeblocks.graphics.socket import OCBSocket @@ -180,36 +180,46 @@ def hoverMoveEvent(self, event): pos = event.pos() if self._is_in_resize_area(pos): if not self.resizing_hover: - self.resizing_hover = True - QApplication.setOverrideCursor(Qt.CursorShape.SizeFDiagCursor) + self._start_hovering() elif self.resizing_hover: - self.resizing_hover = False - QApplication.restoreOverrideCursor() - + self._stop_hovering() return super().hoverMoveEvent(event) - def hoverLeaveEvent(self, event): + + def _start_hovering(self): + self.resizing_hover = True + QApplication.setOverrideCursor(Qt.CursorShape.SizeFDiagCursor) + + def _stop_hovering(self): + self.resizing_hover = False + QApplication.restoreOverrideCursor() + + def _start_resize(self,pos:QPointF): + self.resizing = True + self.resize_start = pos + QApplication.setOverrideCursor(Qt.CursorShape.SizeFDiagCursor) + + def _stop_resize(self): + self.resizing = False + QApplication.setOverrideCursor(Qt.CursorShape.SizeFDiagCursor) + + def hoverLeaveEvent(self, event:QGraphicsSceneHoverEvent): """ Triggered when the mouse stops hovering over a block """ if self.resizing_hover: - self.resizing_hover = False - QApplication.restoreOverrideCursor() - + self._stop_hovering() return super().hoverLeaveEvent(event) def mousePressEvent(self, event:QGraphicsSceneMouseEvent): """ OCBBlock reaction to a mousePressEvent. """ pos = event.pos() if self._is_in_resize_area(pos) and event.buttons() == Qt.MouseButton.LeftButton: - self.resize_start = pos - self.resizing = True - QApplication.setOverrideCursor(Qt.CursorShape.SizeFDiagCursor) + self._start_resize(pos) super().mousePressEvent(event) def mouseReleaseEvent(self, event:QGraphicsSceneMouseEvent): """ OCBBlock reaction to a mouseReleaseEvent. """ if self.resizing: self.scene().history.checkpoint("Resized block", set_modified=True) - self.resizing = False - QApplication.restoreOverrideCursor() + self._stop_resize() if self.moved: self.moved = False self.scene().history.checkpoint("Moved block", set_modified=True) diff --git a/opencodeblocks/graphics/blocks/codeblock.py b/opencodeblocks/graphics/blocks/codeblock.py index bd311ea2..909f600e 100644 --- a/opencodeblocks/graphics/blocks/codeblock.py +++ b/opencodeblocks/graphics/blocks/codeblock.py @@ -20,25 +20,18 @@ class OCBCodeBlock(OCBBlock): Features an area to edit code as well as a panel to display the output. + The following is always true: + output_panel_height + source_panel_height + edge_size*2 + title_height == height + """ def __init__(self, **kwargs): - """ - Note that self.output_panel_height < self.height, - because the output panel is part of the display. - Moreover, the following is always true: - output_panel_height + source_panel_height + edge_size*2 + title_height == height - """ super().__init__(block_type='code', **kwargs) - - self.output_panel_height = 100 + self.output_panel_height = self.height / 3 self._min_output_panel_height = 20 self._min_source_editor_height = 20 - assert self.height - self.output_panel_height \ - - self.title_height - self.edge_size*2 > 0 - self.source_editor = self.init_source_editor() self.display = self.init_display() self.stdout = "" @@ -56,10 +49,15 @@ def init_source_editor(self): source_editor_graphics.setZValue(-1) return source_editor_graphics + @property def _editor_widget_height(self): return self.height - self.title_height - 2*self.edge_size \ - self.output_panel_height + @_editor_widget_height.setter + def _editor_widget_height(self, value: int): + self.output_panel_height = self.height - value - self.title_height - 2*self.edge_size + def update_all(self): """ Update the code block parts. """ if hasattr(self, 'source_editor'): @@ -68,7 +66,7 @@ def update_all(self): int(self.edge_size), int(self.edge_size + self.title_height), int(self._width - 2*self.edge_size), - int(self._editor_widget_height()) + int(self._editor_widget_height) ) display_widget = self.display.widget() display_widget.setGeometry( @@ -83,6 +81,7 @@ def update_all(self): def source(self) -> str: """ Source code. """ return self._source + @source.setter def source(self, value:str): self._source = value @@ -107,6 +106,7 @@ def stdout(self, value:str): def image(self) -> str: """ Code output. """ return self._image + @image.setter def image(self, value:str): self._image = value @@ -140,61 +140,40 @@ def paint(self, painter: QPainter, painter.setBrush(self._brush_background) painter.drawPath(path_title.simplified()) - - def _is_in_code_output_resize_area(self, pos:QPointF): - """ Return True if the given position is in the block resize_area. """ + def _is_in_resize_source_code_area(self, pos:QPointF): + """ + Return True if the given position is in the area + used to resize the source code widget + """ source_editor_start = self.height - self.output_panel_height - self.edge_size return self.width - 2 * self.edge_size < pos.x() and \ source_editor_start - self.edge_size < pos.y() < source_editor_start + self.edge_size - - def hoverMoveEvent(self, event): - """ Triggered when hovering over a block """ - pos = event.pos() - if self._is_in_resize_area(pos) or self._is_in_code_output_resize_area(pos): - if not self.resizing_hover: - self.resizing_hover = True - QApplication.setOverrideCursor(Qt.CursorShape.SizeFDiagCursor) - elif self.resizing_hover: - self.resizing_hover = False - QApplication.restoreOverrideCursor() - # Don't call super() because this might override the cursor - def hoverLeaveEvent(self, event): - """ Triggered when the mouse stops hovering over a block """ - if self.resizing_hover: - self.resizing_hover = False - QApplication.restoreOverrideCursor() - # Don't call super() because this might override the cursor - - def mousePressEvent(self, event:QGraphicsSceneMouseEvent): - """ OCBBlock reaction to a mousePressEvent. """ - pos = event.pos() - resizing_source_code = self._is_in_code_output_resize_area(pos) - if (self._is_in_resize_area(pos) or resizing_source_code) and \ - event.buttons() == Qt.MouseButton.LeftButton: - self.resize_start = pos - self.resizing = True - self.resizing_source_code = resizing_source_code - QApplication.setOverrideCursor(Qt.CursorShape.SizeFDiagCursor) - - super().mousePressEvent(event) - - def mouseReleaseEvent(self, event:QGraphicsSceneMouseEvent): - """ OCBBlock reaction to a mouseReleaseEvent. """ - if self.resizing: - self.scene().history.checkpoint("Resized block", set_modified=True) + + + def _is_in_resize_area(self, pos:QPointF): + """ Return True if the given position is in the block resize_area. """ + + # This block features 2 resizing areas with 2 different behaviors + is_in_bottom_left = super()._is_in_resize_area(pos) + return is_in_bottom_left or self._is_in_resize_source_code_area(pos) + + def _start_resize(self,pos:QPointF): + self.resizing = True + self.resize_start = pos + if self._is_in_resize_source_code_area(pos): + self.resizing_source_code = True + QApplication.setOverrideCursor(Qt.CursorShape.SizeFDiagCursor) + + def _stop_resize(self): self.resizing = False self.resizing_source_code = False QApplication.restoreOverrideCursor() - if self.moved: - self.moved = False - self.scene().history.checkpoint("Moved block", set_modified=True) - super().mouseReleaseEvent(event) def mouseMoveEvent(self, event:QGraphicsSceneMouseEvent): """ We override the default resizing behavior as the code part and the display part of the block - block can be resized. + block can be resized independently. """ if self.resizing: delta = event.pos() - self.resize_start @@ -205,7 +184,7 @@ def mouseMoveEvent(self, event:QGraphicsSceneMouseEvent): # Mainly: min_height - height must be negative for all elements self._min_output_panel_height - self.output_panel_height, self._min_height - self.height, - self._min_source_editor_height - self._editor_widget_height() + self._min_source_editor_height - self._editor_widget_height ) self.height += height_delta @@ -215,9 +194,9 @@ def mouseMoveEvent(self, event:QGraphicsSceneMouseEvent): self.resize_start = event.pos() self.title_graphics.setTextWidth(self.width - 2 * self.edge_size) self.update() - else: - super().mouseMoveEvent(event) - self.moved = True + + self.moved = True + super().mouseMoveEvent(event) def init_display(self): """ Initialize the output display widget: QLabel """ From 0c2b5bf1388db289d42cade5e2a98bca3e5cce36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoine=20Del=C3=A8gue?= Date: Sun, 14 Nov 2021 01:22:37 +0100 Subject: [PATCH 23/32] :wrench: Added type annotation to event --- opencodeblocks/graphics/blocks/block.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opencodeblocks/graphics/blocks/block.py b/opencodeblocks/graphics/blocks/block.py index ec7ab976..8de00ec3 100644 --- a/opencodeblocks/graphics/blocks/block.py +++ b/opencodeblocks/graphics/blocks/block.py @@ -175,7 +175,7 @@ def remove_socket(self, socket:OCBSocket): socket.remove() self.update_sockets() - def hoverMoveEvent(self, event): + def hoverMoveEvent(self, event:QGraphicsSceneHoverEvent): """ Triggered when hovering over a block """ pos = event.pos() if self._is_in_resize_area(pos): From b5d2bc800eb3c56cbc30cd008f1930f64f4ca3d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoine=20Del=C3=A8gue?= Date: Sun, 14 Nov 2021 01:34:13 +0100 Subject: [PATCH 24/32] :memo: Remove an incorrect comment --- opencodeblocks/graphics/blocks/codeblock.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opencodeblocks/graphics/blocks/codeblock.py b/opencodeblocks/graphics/blocks/codeblock.py index 909f600e..b6bff90f 100644 --- a/opencodeblocks/graphics/blocks/codeblock.py +++ b/opencodeblocks/graphics/blocks/codeblock.py @@ -91,7 +91,7 @@ def source(self, value:str): @property def stdout(self) -> str: - """ Code output, without errors """ + """ Code output. Be careful, this also includes stderr """ return self._stdout @stdout.setter def stdout(self, value:str): From 5e5ea8d5c267b030b345984bbddbadae47b0cfa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoine=20Del=C3=A8gue?= Date: Sun, 14 Nov 2021 01:45:40 +0100 Subject: [PATCH 25/32] :sparkles: Add autopep8 as a dependency to make to easier to fix most pylint issues. Did not run to yet to avoid merge conflicts. --- CONTRIBUTING.md | 6 ++++++ requirements-dev.txt | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f52c6233..fbdd09f2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,6 +24,12 @@ Before doing your **pull request**, check using `pylint` and `pytest` that there pylint .\opencodeblocks\ ``` +Some `pylint` issues can be fixed automatically using `autopep8`, with the following command: + +```bash +autopep8 --in-place --recursive --aggressive opencodeblocks +``` + ```bash pytest --cov=opencodeblocks --cov-report=html tests/unit ``` diff --git a/requirements-dev.txt b/requirements-dev.txt index 764c45a9..f18845f0 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,4 +4,5 @@ pytest-mock pytest-check pytest-pspec pylint -pylint-pytest \ No newline at end of file +pylint-pytest +autopep8 \ No newline at end of file From c9ed4daf6fe8ab95828068b6f5c810685f2259e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoine=20Del=C3=A8gue?= Date: Sun, 14 Nov 2021 02:39:33 +0100 Subject: [PATCH 26/32] :beetle: Fix a bug where the cursor would remain in resize mode when the mouse moves quickly from the resizing area to outside of it. Also, tied the start of the resizing to the cursor appearance to help with consistency between cursor appearance and if the area is resizable if changes are made. --- opencodeblocks/graphics/blocks/block.py | 6 +++--- opencodeblocks/graphics/blocks/codeblock.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/opencodeblocks/graphics/blocks/block.py b/opencodeblocks/graphics/blocks/block.py index 8de00ec3..5475a3b9 100644 --- a/opencodeblocks/graphics/blocks/block.py +++ b/opencodeblocks/graphics/blocks/block.py @@ -133,8 +133,8 @@ def paint(self, painter: QPainter, def _is_in_resize_area(self, pos:QPointF): """ Return True if the given position is in the block resize_area. """ - return self.width - pos.x() < 2 * self.edge_size \ - and self.height - pos.y() < 2 * self.edge_size + return self.width - self.edge_size*2 < pos.x() \ + and self.height - self.edge_size*2 < pos.y() def get_socket_pos(self, socket:OCBSocket) -> Tuple[float]: """ Get a socket position to place them on the block sides. """ @@ -211,7 +211,7 @@ def hoverLeaveEvent(self, event:QGraphicsSceneHoverEvent): def mousePressEvent(self, event:QGraphicsSceneMouseEvent): """ OCBBlock reaction to a mousePressEvent. """ pos = event.pos() - if self._is_in_resize_area(pos) and event.buttons() == Qt.MouseButton.LeftButton: + if self.resizing_hover and event.buttons() == Qt.MouseButton.LeftButton: self._start_resize(pos) super().mousePressEvent(event) diff --git a/opencodeblocks/graphics/blocks/codeblock.py b/opencodeblocks/graphics/blocks/codeblock.py index b6bff90f..3a6b7baf 100644 --- a/opencodeblocks/graphics/blocks/codeblock.py +++ b/opencodeblocks/graphics/blocks/codeblock.py @@ -147,7 +147,7 @@ def _is_in_resize_source_code_area(self, pos:QPointF): """ source_editor_start = self.height - self.output_panel_height - self.edge_size - return self.width - 2 * self.edge_size < pos.x() and \ + return self.width - self.edge_size/2 < pos.x() and \ source_editor_start - self.edge_size < pos.y() < source_editor_start + self.edge_size From c48caa34b2dcbdb463e375b584bcbbadaa702c12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Math=C3=AFs=20F=C3=A9d=C3=A9rico?= Date: Sun, 14 Nov 2021 13:51:31 +0100 Subject: [PATCH 27/32] :wrench: Replace %string by f-string --- opencodeblocks/graphics/window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opencodeblocks/graphics/window.py b/opencodeblocks/graphics/window.py index 98923246..c2ea695e 100644 --- a/opencodeblocks/graphics/window.py +++ b/opencodeblocks/graphics/window.py @@ -180,7 +180,7 @@ def updateWindowMenu(self): for i, window in enumerate(windows): child = window.widget() - text = "%d %s" % (i + 1, child.windowTitle()) + text = f"{i + 1} {child.windowTitle()}" if i < 9: text = '&' + text From d49aeefce7b5279faf28a622b9464808a01b212d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Math=C3=AFs=20F=C3=A9d=C3=A9rico?= Date: Sun, 14 Nov 2021 13:54:03 +0100 Subject: [PATCH 28/32] :sparkles: Fix Redefining built-in type --- opencodeblocks/graphics/kernel.py | 18 +++++++++--------- opencodeblocks/graphics/pyeditor.py | 6 +++--- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/opencodeblocks/graphics/kernel.py b/opencodeblocks/graphics/kernel.py index 87ad8e14..4fb71a21 100644 --- a/opencodeblocks/graphics/kernel.py +++ b/opencodeblocks/graphics/kernel.py @@ -19,31 +19,31 @@ def message_to_output(self, message: dict) -> Tuple[str, str]: message: dict representing the a message sent by the kernel Return: - single output found in the message in that order of priority: image > text data > text print > error > nothing - type: 'image' or 'text' + single output found in the message in that order of priority: + image > text data > text print > error > nothing """ - type = 'None' + message_type = 'None' if 'data' in message: if 'image/png' in message['data']: - type = 'image' + message_type = 'image' # output an image (from plt.plot or plt.imshow) out = message['data']['image/png'] else: - type = 'text' + message_type = 'text' # output data as str (for example if code="a=10\na") out = message['data']['text/plain'] elif 'name' in message and message['name'] == "stdout": - type = 'text' + message_type = 'text' # output a print (print("Hello World")) out = message['text'] elif 'traceback' in message: - type = 'text' + message_type = 'text' # output an error out = '\n'.join(message['traceback']) else: - type = 'text' + message_type = 'text' out = '' - return out, type + return out, message_type def execute(self, code: str) -> str: """ diff --git a/opencodeblocks/graphics/pyeditor.py b/opencodeblocks/graphics/pyeditor.py index 1e40c510..689a14a1 100644 --- a/opencodeblocks/graphics/pyeditor.py +++ b/opencodeblocks/graphics/pyeditor.py @@ -131,11 +131,11 @@ def focusOutEvent(self, event: QFocusEvent): # Keep the GUI alive QCoreApplication.processEvents() # Save kernel message and display it - output, type, done = kernel.update_output() + output, output_type, done = kernel.update_output() if done is False: - if type == 'text': + if output_type == 'text': self.block.stdout = output - elif type == 'image': + elif output_type == 'image': self.block.image = output return super().focusOutEvent(event) From 5b60941f6398e75f6b50c7f2cca4d2026f309078 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Math=C3=AFs=20F=C3=A9d=C3=A9rico?= Date: Sun, 14 Nov 2021 14:13:05 +0100 Subject: [PATCH 29/32] :sparkles: Fix pylint issues --- opencodeblocks/graphics/blocks/codeblock.py | 4 ++-- opencodeblocks/graphics/function_parsing.py | 5 ++--- opencodeblocks/graphics/qss/dark_resources.py | 6 ++++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/opencodeblocks/graphics/blocks/codeblock.py b/opencodeblocks/graphics/blocks/codeblock.py index 3a6b7baf..ef088285 100644 --- a/opencodeblocks/graphics/blocks/codeblock.py +++ b/opencodeblocks/graphics/blocks/codeblock.py @@ -15,7 +15,7 @@ class OCBCodeBlock(OCBBlock): - """ + """ Code Block Features an area to edit code as well as a panel to display the output. @@ -154,7 +154,7 @@ def _is_in_resize_source_code_area(self, pos:QPointF): def _is_in_resize_area(self, pos:QPointF): """ Return True if the given position is in the block resize_area. """ - # This block features 2 resizing areas with 2 different behaviors + # This block features 2 resizing areas with 2 different behaviors is_in_bottom_left = super()._is_in_resize_area(pos) return is_in_bottom_left or self._is_in_resize_source_code_area(pos) diff --git a/opencodeblocks/graphics/function_parsing.py b/opencodeblocks/graphics/function_parsing.py index 33d5564a..1b371ec6 100644 --- a/opencodeblocks/graphics/function_parsing.py +++ b/opencodeblocks/graphics/function_parsing.py @@ -22,7 +22,7 @@ def run_cell(cell: str) -> str: def run_with_variable_output(cell: str) -> None: """ - This is a proof of concept to show that it is possible + This is a proof of concept to show that it is possible to collect a variable output from a kernel execution Here the kernel executes the code and prints the output repeatedly @@ -33,11 +33,10 @@ def run_with_variable_output(cell: str) -> None: """ kernel.client.execute(cell) done = False - while done == False: + while not done: output, done = kernel.update_output() print(output) - def get_function_name(code: str) -> str: """ Parses a string of code and returns the first function name it finds diff --git a/opencodeblocks/graphics/qss/dark_resources.py b/opencodeblocks/graphics/qss/dark_resources.py index 358c0113..3b7d2693 100644 --- a/opencodeblocks/graphics/qss/dark_resources.py +++ b/opencodeblocks/graphics/qss/dark_resources.py @@ -501,9 +501,11 @@ qt_resource_struct = qt_resource_struct_v2 def qInitResources(): - QtCore.qRegisterResourceData(rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data) + QtCore.qRegisterResourceData(rcc_version, qt_resource_struct, + qt_resource_name, qt_resource_data) def qCleanupResources(): - QtCore.qUnregisterResourceData(rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data) + QtCore.qUnregisterResourceData(rcc_version, qt_resource_struct, + qt_resource_name, qt_resource_data) qInitResources() From 73cffafa3e5ec09229a45d2e7bb7df475b95c419 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Math=C3=AFs=20F=C3=A9d=C3=A9rico?= Date: Sun, 14 Nov 2021 14:15:56 +0100 Subject: [PATCH 30/32] :wrench: Refactor Kernel Refactor execute and update_output using new method get_message --- opencodeblocks/graphics/kernel.py | 51 ++++++++++++++++--------------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/opencodeblocks/graphics/kernel.py b/opencodeblocks/graphics/kernel.py index 4fb71a21..f4f6282e 100644 --- a/opencodeblocks/graphics/kernel.py +++ b/opencodeblocks/graphics/kernel.py @@ -47,52 +47,53 @@ def message_to_output(self, message: dict) -> Tuple[str, str]: def execute(self, code: str) -> str: """ - Executes code in the kernel and returns the output of the last message sent by the kernel in return + Executes code in the kernel and returns the output of the last message sent by the kernel Args: - code: str representing a piece of Python code to execute + code: String representing a piece of Python code to execute Return: - output from the last message sent by the kernel in return + output from the last message sent by the kernel """ _ = self.client.execute(code) - io_msg_content = {} - if 'execution_state' in io_msg_content and io_msg_content['execution_state'] == 'idle': - return "no output" - - while True: + done = False + while not done: # Check for messages, break the loop when the kernel stops sending messages - message = io_msg_content - try: - io_msg_content = self.client.get_iopub_msg(timeout=1000)[ - 'content'] - if 'execution_state' in io_msg_content and io_msg_content['execution_state'] == 'idle': - break - except queue.Empty: - break - + message, done = self.get_message() return self.message_to_output(message)[0] - def update_output(self) -> Tuple[str, str, bool]: + def get_message(self) -> Tuple[str, bool]: """ - Returns the current output of the kernel + Get message in the jupyter kernel + + Args: + code: String representing a piece of Python code to execute Return: - current output of the kernel; done: bool, True if the kernel has no message to send + Tuple of: + - output from the last message sent by the kernel + - boolean repesenting if the kernel as any other message to send. """ - message = None done = False try: - message = self.client.get_iopub_msg(timeout=1000)[ - 'content'] + message = self.client.get_iopub_msg(timeout=1000)['content'] if 'execution_state' in message and message['execution_state'] == 'idle': done = True except queue.Empty: + message = None done = True + return message, done - out, type = self.message_to_output(message) + def update_output(self) -> Tuple[str, str, bool]: + """ + Returns the current output of the kernel - return out, type, done + Return: + current output of the kernel; done: bool, True if the kernel has no message to send + """ + message, done = self.get_message() + out, _ = self.message_to_output(message) + return out, done def __del__(self): """ From a4b9acda1fde245d31dfcab4ba201208fcc48cdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Math=C3=AFs=20F=C3=A9d=C3=A9rico?= Date: Sun, 14 Nov 2021 14:34:14 +0100 Subject: [PATCH 31/32] :beetle: Fix Kernel.execute message was overriden on last iteration when execution_state is idle. --- opencodeblocks/graphics/kernel.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/opencodeblocks/graphics/kernel.py b/opencodeblocks/graphics/kernel.py index f4f6282e..5d16ea74 100644 --- a/opencodeblocks/graphics/kernel.py +++ b/opencodeblocks/graphics/kernel.py @@ -59,7 +59,9 @@ def execute(self, code: str) -> str: done = False while not done: # Check for messages, break the loop when the kernel stops sending messages - message, done = self.get_message() + new_message, done = self.get_message() + if not done: + message = new_message return self.message_to_output(message)[0] def get_message(self) -> Tuple[str, bool]: From 0f367f4afc09f0d620c7b2b84713e33bd368eae7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Math=C3=AFs=20F=C3=A9d=C3=A9rico?= Date: Mon, 15 Nov 2021 16:42:18 +0100 Subject: [PATCH 32/32] :beetle: Fix kernel update output --- opencodeblocks/graphics/kernel.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/opencodeblocks/graphics/kernel.py b/opencodeblocks/graphics/kernel.py index 5d16ea74..9fd3abd7 100644 --- a/opencodeblocks/graphics/kernel.py +++ b/opencodeblocks/graphics/kernel.py @@ -94,8 +94,8 @@ def update_output(self) -> Tuple[str, str, bool]: current output of the kernel; done: bool, True if the kernel has no message to send """ message, done = self.get_message() - out, _ = self.message_to_output(message) - return out, done + out, output_type = self.message_to_output(message) + return out, output_type, done def __del__(self): """