From 27820ec6e6bd46d965640c88035ad959bd00a4ea Mon Sep 17 00:00:00 2001
From: ptsavol <43600314+ptsavol@users.noreply.github.com>
Date: Thu, 16 Jan 2025 17:02:20 +0200
Subject: [PATCH] Open default Python or Julia in Basic Console (#3033)
- Enables opening the default Python or Julia in the Basic Console without running a Tool first. There are two new buttons in the Main Windows' Consoles menu for this purpose
---
CHANGELOG.md | 2 +
spinetoolbox/helpers.py | 17 +
spinetoolbox/resources_icons_rc.py | 469 ++++++++++++++----
spinetoolbox/ui/mainwindow.py | 18 +-
spinetoolbox/ui/mainwindow.ui | 20 +
spinetoolbox/ui/resources/julia-logo.svg | 8 +
.../ui/resources/menu_icons/terminal.svg | 1 +
spinetoolbox/ui/resources/python-logo.svg | 265 ++++++++++
spinetoolbox/ui/resources/resources_icons.qrc | 3 +
spinetoolbox/ui_main.py | 76 ++-
.../widgets/persistent_console_widget.py | 227 +++++++--
tests/mock_helpers.py | 1 -
tests/test_ToolboxUI.py | 93 +++-
13 files changed, 1032 insertions(+), 168 deletions(-)
create mode 100644 spinetoolbox/ui/resources/julia-logo.svg
create mode 100644 spinetoolbox/ui/resources/menu_icons/terminal.svg
create mode 100644 spinetoolbox/ui/resources/python-logo.svg
diff --git a/CHANGELOG.md b/CHANGELOG.md
index df2a005a5..6de930585 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.1.0/)
### Added
- Support for Python 3.13.
+- You can now open a detached Basic Console with the default Python or Julia from the Consoles menu. Default
+Python and Julia are defined in app settings (File->Settings->Tools)
### Changed
diff --git a/spinetoolbox/helpers.py b/spinetoolbox/helpers.py
index 0e53fe092..77d788e42 100644
--- a/spinetoolbox/helpers.py
+++ b/spinetoolbox/helpers.py
@@ -672,6 +672,23 @@ def icon(self, info):
return super().icon(info)
+def basic_console_icon(language):
+ """Returns an SVG icon for the given language or an empty QIcon if not available.
+
+ Args:
+ language (str): Kernel language
+
+ Returns:
+ QIcon: Icon
+ """
+ if language == "python":
+ return QIcon(":/symbols/python-logo.svg")
+ elif language == "julia":
+ return QIcon(":/symbols/julia-logo.svg")
+ else:
+ return QIcon()
+
+
def ensure_window_is_on_screen(window, size):
"""
Checks if window is on screen and if not, moves and resizes it to make it visible on the primary screen.
diff --git a/spinetoolbox/resources_icons_rc.py b/spinetoolbox/resources_icons_rc.py
index c2238df72..c41ef068c 100644
--- a/spinetoolbox/resources_icons_rc.py
+++ b/spinetoolbox/resources_icons_rc.py
@@ -179,6 +179,234 @@
\x0d\x02\xac\x9e\x05\xbe\xb8\x9d\x00\x06\x02\xb0S\xb7\xc4|\
\x98\xbb\xb2?!\x00;\x00\x00\x00\x00\x00\x00\x00\x00\x00\
\
+\x00\x00\x09\x85\
+\x00\
+\x00%\x85x\xda\xedZ[o\xdb\xc8\x15~\xcf\xaf`\
+\x95\x97\x04\x15Gs\xbf(V\xf6!\x8b-\xf6\xa1(\
+\xd0MZ\xa0o\xb48\xb2\xd8P\xa2@\xd2\xb7\xfc\xfa\
+~\xc3\x9bH[\xde\xd8^'\x01\x8a\x15\x11D\
+\xd9a\x22\x87\xdbz[\xec\xe3\xbc\xb8(\xe2b\x9f\xdf\
+\x12\xc4n\x5c\xaf\xb3\xb4\xde\xaefV\x10a\x18e\xe6\
+P7\xe6\xad\xcf.\xb65\x82Q$L\x19\xb5\x9d}\
+H\xc7\xdf\x1c\x8a\xb2\x8e7Y\xee\x1f\x88p\xd8_\x9c\
+\xecr\x93\x1e\xb2\xd5\x8c\x0bN\xa4<\xe9p{\xd7\x01\
+\x8b\xb6\xaf\x96\xbd\xdbj\xf6\x10\xe6!\x91\xea\x90\xac}\
+\xb5\xe8\xed\xa3\xfe=(C\xff\xde@\xaa\xe2\xb2\x5c\xfb\
+\x0d\x86\xf0d\xef\xeb\xc5\xcf\x1f\x7f\x1e\x1acJ\xd2:\
+\x1d\x0ds\x93c\xecI\x0e\xd7\xa2\x89\xce\x9cs\x8b\xa6\
+\xf5\xe8}\xca\x8fSJ\x17=\xfc]jW\x17\x8f\xf4\
+,\xd3\xcd\x83\xb1)_\x80\x06\xf0\x88\xab\xdb}\x9d\xdc\
+\xc4\xfb\xea\xf5\xa8\xebz=\xf4\x5c\x07.gW~]\
+\xecv\xc5\xbej\xc1\x9b8\xa7G\xe7\xc3e\x997\x1e\
+\xe9z\xe1s\xbf\xf3\xfb\xbaZ0\xc2\x16\xb3\xf7\xf0?\
+\xdb\xf9:I\x93:\x09}[Z\xf7\x16\x10\xaaq\x81\
+\x13\x92Z\xfe\xf3\xe7_\xda;\xdc\xaf\xd7\xcb\x7f\x17\xe5\
+\xe7\xee\x16\x9f\xe0\x90\x9c\x17\x97\xa0\xdc\xec\xfd`>K\
+\xd7K,\xcb.\xa9\xdfg\xbb\xe4\xc2\x070\xfe\x8a\x04\
+\xcf\x16\xc7\x86\x89s}{\xf0\xc7A\xdbaK\xdf\xae\
+\xef\xc9\x09\xa5\xeb]\x16:-~\xab\xb3<\xff5\x04\
+\x99E\x8b!\xcfE\x97h7\x8d\xc5h\x1eg\x8b~\
+\x9e\xcd\xdd \xb9@\xc2\xf4*\xf3\xd7\x1d =\xb9\xaf\
+\xb3}Z\x5c\xc7\xbd\xb08\xb5m!\xb8\xef\xd2I\x92\
+9\xa3\xefz\x1c\x90_\xb5M\xe0\x85\x11N5\x16\xa0\
+?&\x14\x0aH?\xfc\xc5e\x96\xfa\xba\xc8}\x99\xec\
+\x03\x0al\xd4T\x22\xd4\xc9\x96\xe2\xfc\xbf~]\x9fn\
+;/\xca\xd4\x97C$v\xa7a]\xe4E\xb9\x9a\xbd\
+\xd6\xcd\xa7k\x0a\xb9\xf5\x0d\x9b\xe63;\x12\xe6<\xa9\
+\xfc\xdd\xc9|)\x8a\x1d\xe6H\x98\xd4L\xcb{s]\
+\xdf\xacf\x8e\x11\xa5\xacv\xf6^#\xb2\x92\x868\xc7\
+\xf5\x03\x10\xdf\x84\x099\xfe@+\xba\x9b\xfb\x11/\xcb\
+\x12\xd4\x8f\xf3\xe4\xd6\x97\xa3\xda=\x94P\xce\xe8n\xd7\
+\x99\xfaU\x96G\xd3\xe5>\xabQ\x0f\x86\xfba\xe0j\
+[\x5c\x7fu]\xd7[\xbf\xfe\xec\xcb\xf3\x22)\x01\xd8\
+=\xe6\xa4\xbe\xfa\xdc\xc3\x9b\xb2p\xdd\xf3(\xd6\x97A\
+\xbaq\x97GW\xcf\xb1W |\xe0\xc1j\xb6I\xf2\
+\xfb\xeb\xd0A\xb2Kn\xb2]\xf6\xc57\xc1[\x81\x9c\
+\xa5~S\x1d\x171\xdc\xc9^\xef\xa8\x82>)\xffV\
+&i\x86\x98\xbd\x22\x83\xdb\xb4\x85\x1b\xa7\x06\xbd\x9fU\
+uq8\xaa\xb7\xaaos\x10/\x18\xe3fn\xcb\xd7\
+\xe76\x5c\xef\x1aS\xc7\xbf%%\xd2Y*\x1c\xe7\xef\
+f\xc7\xce\xc5fS\xf9\xfa\x88T\x1f>tEP3\
+\x16\xf9W\xc3\x9aM\xb8\xee\x84=\x15\x8d\x9d\x8e\xe6\x86\
+hg\x8b\xe9\xfc\x9f\x0c\x975\xb3?\x987Q\x8f\xc7\
+\xc9\xba\xef\x8a\x13{)\x9c\x84F\xe9|\x02\xadx\xb8\
+\x9e\x8d\x13\xa2\xd9'\xe1t.\xc2\xf5L\x9c\x84\xb6\xf4\
+\xc5p\xe2\xe2)8md\xb8\xa6y\xb3\xc7\x82\xc4q\
+\x98{\x02H\xd7\xdb\xac\xf6_\x0f\xc5N\x87z1\x84\
+\xa4\x1e\x0e1\x8fBh\x93Jq~'\xed\xc7\xf2\x08\
+\xb1\xc4\x93x\xb4\xd9xk\xc4\xf3@B0\xf5r \
+\xa1P<>m\x958\xc0\xf4l\x90Fe\xe21\xd1\
+\x04\xd5\xce\xd9GD;\x8d\x92\x13\xcfC\xe9\x86a'\
+\xe7\x92p\xe1\x9c\x1eF\xbe\x85\x95II\x8c2\xcc\x0c\
+\xd6\x1b\xbe\x9a\xc5Z\x11A\xad\xa2\xfc\xe8\xccO9\x9f\
+\xd8\x15\x9c\x1d\x8d\x15\x1e@\x96\xdb\xd2\xe39\xe1\xf5\x09\
+.\xf7~\x17\x9d\xf1S{\x1c\xb8\xac|\xf9[xt\
+\xfa\xc7\xfe\xd3p\x088z}\xc4)\xb0\x0a\x07\xee\xd5\
+\xac\x0e_s<\x0a\xbfa\x94\x12n(\x9f;G4\
+cL\xbf=B\xf50(\xccp\xe2$\x8e\xbf\x13P\
+\x8c!\xd2(g\xc5\x04\x14\xae\x89F\x00g'\x98\x18\
+M\x04\xc3%~\x17\x13G\x1f\x87\x09\xa8\xfbC19\
+\x1e0\x8b<\xc7\x99{5K\xf2\xeb\xe4\xb6zr\xf6\
+'@P#b|\x9b\xc9}\xab\x95\xfd\xa6\xa0\x8dd\
+p\x124\xf7\x1dA{\xd1\x1a\xf1\x03\xa9\xc6\xa5\xfd\x93\
+jO\xa5\x1aW\xf4O\xaa=\x035\xf5\xc7Q\xdb%\
+u\x99\xdd\xbc\xc11_s%\xd9\x9c\x86\x0b7x6\
+\xe4\xf3\x981\xa2\x9c\x91\xf3\xd8\x10M\x9d\x92\xff\xa7\x9a\
+U\xf6\xc7\x02\xf9\x80\x8c5aF\x03\xe4\xe7\xc98\xa4\
+\x98\xe4/\x89c\xf3\x96b\x8c\xe34\x04\xb7\xf4\xc8\xd7\
+\xf0bJc\xd2\xcc\xdaQ\x1d\x0ao\xa4\x98\xe0\x84[\
+e\x8eCmN\xfanN\xfa\x96@\xc0\x11*p\xfc\
+\x15\x8fX\x8e~\x1d\x981Nk|e\xd4\x12#\xc5\
+h\x01~\x7f\xc5\x7f\x9c\xbe\x994\xdf^\xdf\x928\xc7\
+XKP\xb0J\x18v\x87\x99\x8a\x12\x17j\xe8T\xe2\
+\x8e\x13\xa1\x98\xd1\x13fb\x1c,\x0c\x93r\xaapa\
+P\x8c\xf9\xf8\xe5\xcfwW8\xa0\xb4?\x1c\xca \x5c\
+i\x9d0\x13(9EA\x10\xc6\xb2)\x94B\x11\xad\
+\x15WS(\x11C8\xa3\xcd\x0f\x149\x93\x96\xbe\x18\
+\x94\x00J:\xaa\xb4\xf2\xb1\x9d\xc7\xb4\xd9L\xf0\xa8\xa9\
+\xe7\x8cP%\xb5\xb6sA\x8ccJ*\xe3c3\x8f\
+-n)\xb5s&9\x91\x9a\xd3\x11\xc2\xdf\xa5\xdet\
+/|\x17\xe1\x1do\xf3\xed\x90\xd4\xdbW\xe3'\xeeM\
+\x96\xe7\xcb\xcb2\x7f\xf3\xfa>\x03\xdf\xbe\x0b\xad\xf7\xde\
+P\x00\xe2\xbfG\x0a\xeca\xd6X5w\x04\xea2\x92\
+3\x1f\xcb\xe8C\x04\xfd\x09HMpP\x8dr\xce\xb1\
+U\x98H*\xe2\x94\xb1\x12\x05\x8d\xc8P\xf6\xb5\x83\x91\
+\x13F5\xb2\x0f\xe8\x01F<\xb2G\x82\x12\xa3)\x9e\
+\xfc\x01$\x05=\x8d`\x11\xb7\x01D\xcch\x0ea:\
+i\x0c\x93\x83\x0d}\x15&\xcb\x99v\xd1U\xc4\xb0\x22\
+HJE\xdb\x08\xe4\xb5\x8c+\x18\x05\x91\x88\x12lq\
+o\x8c\xc3\x8f\x22\xc1\xb6\x8e\xb0\xbf\x19\xc7\xa5rs\x1a\
+\x05Ih\x060\xad\x9d\xe3\x9b\x85\x22\x0c\x8c:,:\
+>XF\x81MQ`\xfc8,'\xb3\xac1\x86\xa0\
+\x1c\xc5:XU\xf8-V\x85\x15\xd7DY\x0dUE\
+t\xce\x91\xb7\x8a\x18\x01s\x1c\xb7\x02\xd3\xc0 V\xf1\
+H\x13\x10EI`\xd5\x8d,m\x84\x1c\xf8\xddp\xdb\
+\xc8u\x13\xbbB>\x9c4\x99S\xd0\x8bX\xb8R\x1e\
+a\x93Fd\x89\xed\x1aq5\xe4\x1e\x06B\xdej0\
+t\x98\x98f\xfekt\x90J:\x85\x9a\x10\xb1\x0e \
+1\x8f\xb1\x7f\x0b\xcb\xb4\x1cl\xe8.\x88n`\x8c\xb9\
+\x22J\xb0>4\xa6\xa4\xb5\x10\x16\xb3F/\x1e\xd2\x0d\
+\x99\x19\x8e\xf51,\x8a'#\x84\x999\xac\xf8\x87H\
+cv\x16\xfa\xb0\xf3\xf0C\xb9q\x12\xe5!R\x8e\xe0\
+\x98%E#)\xcaQ]\x1c\x15\x0f1\xec?\xd1\xae\
+Y(5\xb7-&\x149\x19\x87\xac\x10]\xe3\xa4b\
+0\xa7\xc0\x09\x98\xe7\x80\xcaY-\x95\x1e,\x0a\xc9\x06\
+x\xc3j\xf9X\xc3\x03\x13\x16\xa2Y<\xae\x99\xa1\x02\
+.\xb4]\xe4c\x1f\xda\xaf:vei4&\x85\xbe\
+G\x87\xd0\xd7\x18\xc9F}\xe2\xa1\x13\xa3M)@ \
+\xae\x85\x12QX>\xec\xee2\xb8\xf4\xb9\x8cz\xf5\xa6\
+/\xa3\xdf\xd9\x82j\x99\x93\xfd\xcb\xea'\xa9X=\xac\
+\xe2]dQ\xb5\x91$d\x04A\x05\x0a\xb52\x0aT\
+\xd5\xddJ\x83y\x82\x1a\xa5\x82Rlx\xc6V\x81\xdb\
+\xae\xf9\xb4\xe2\x80\xc1t\xea\xea\xe9\x05_\x14\x01+D\
+\xa3\xaa\x96\x0a\xd2\xcd5`\xb2\xd2\x8a\x11=\x06~u\
+\xf4\x92\xa6\x89\xda\xd2K\xca(\x1c\x13m\x10d8\x90\
+J(}D\xce\xb6/\xa52\xb2DZ#\x04\xc3r\
+\x82\xd5\x9a\x05\xf2k\xae\xb1\xa7\xcd\xc3A\x15C\x0d\xe4\
+GF!g\xa84p\x13e\x09\x14\x1ck@\x11k\
+M\x18\xe1\x8e\x04B\x98\x7fE\x16\xb2\xa6\xd417\x99\
+0\xd4q\xac2\x83\xb5\x1fA6z\x0bUF7z\
+\xc3\x06\xea`f!\x96\x14\x98\x94\xed]\xb1G\xc7\x83\
+\xe8A6\xe7\x80 T.\x1c\x0a\xbf\x86E[\x94\xd4\
+F\xd3XXctPb[]\xc2T\xa0\x03\x1b\x94\
+\x89&)Y\xe0\x22vjkFc\xc6\xa7\x02}i\
+\x14\xa5\x9a\x8a8\x87@\xdbr\xd1\xe8)\xb0\x1d{Z\
+ \xfbHR\x81\xea&\x9a\xaa\x03\xeb6\x08*\xf0\x5c\
+6\x82\x0aDo5\xd7\xe0\x17\x8fU\xd8ZZ\xd1\x86\
+U\x99\xa8\x09\xaa\x9d\x8a\xa9\xf3oB\xc4\x9dh\xa3F\
+\xb2\xd8Jz\xb5I\x13\xdd\xd1\x1f,\xf7\x85\xa4\x86_\
+B}\x9eg\x87\xcaO\xb44\xfa}\x12%\x89\xbf;\
+j\xeb\xfe\xc1\xe2\xae\xb6\xda\xdb\xf22\xf7\xcb}\xb1\xff\
+\xe2\xcb\xe2]U\x97\xc5\xe7\xe6\xd6w\xdf\xdb\xbf\x13X\
+\x02q\xc0({\xe3.\xab}\x99g\xf8o9\xd8\xd2\
+\xa4\xda&e\x99\xdcN\xba\xdfU\xf201\xeb\xfa\x93\
+l8\x5c(\x15P\x02\x95{[8.\x80,\xd87\
+M\x7f\xf6,\xe1\x87\x93\x9b\x13\xd4\x99\xfe\x98W\xc2\x0f\
+\x02\xd7\xd8zY{\x06>\x0b\x7f\xbe\xf1\xfe\xd5\xff\x00\
+\x0d\x9d\xd5s\
+\x00\x00\x04m\
+<\
+?xml version=\x221.\
+0\x22 encoding=\x22UTF\
+-8\x22?>\x0a\x0a\
\x00\x00\x06U\
\x89\
PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\
@@ -31243,6 +31471,39 @@
6.207 9.997-36.2\
04-.001z\x22/>\
+\x00\x00\x01\xe1\
+<\
+svg xmlns=\x22http:\
+//www.w3.org/200\
+0/svg\x22 viewBox=\x22\
+0 0 640 512\x22>\
+\
\x00\x00\x0b\xf3\
<\
?xml version=\x221.\
@@ -32024,6 +32285,14 @@
\x0b\xac\x876\
\x00a\
\x00j\x00a\x00x\x00-\x00l\x00o\x00a\x00d\x00e\x00r\x00.\x00g\x00i\x00f\
+\x00\x0f\
+\x00O\x92\x87\
+\x00p\
+\x00y\x00t\x00h\x00o\x00n\x00-\x00l\x00o\x00g\x00o\x00.\x00s\x00v\x00g\
+\x00\x0e\
+\x0d\xc0\xc6\xc7\
+\x00j\
+\x00u\x00l\x00i\x00a\x00-\x00l\x00o\x00g\x00o\x00.\x00s\x00v\x00g\
\x00\x10\
\x00$\x93'\
\x00S\
@@ -32353,6 +32622,10 @@
\x00c\
\x00h\x00e\x00c\x00k\x00.\x00s\x00v\x00g\
\x00\x0c\
+\x0b&r\xc7\
+\x00t\
+\x00e\x00r\x00m\x00i\x00n\x00a\x00l\x00.\x00s\x00v\x00g\
+\x00\x0c\
\x0c\x16L\x07\
\x00c\
\x00u\x00b\x00e\x00_\x00p\x00e\x00n\x00.\x00s\x00v\x00g\
@@ -32399,199 +32672,205 @@
qt_resource_struct = b"\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x04\x00\x00\x00\x01\
\x00\x00\x00\x00\x00\x00\x00\x00\
-\x00\x00\x00\x10\x00\x02\x00\x00\x00\x03\x00\x00\x00_\
+\x00\x00\x00\x10\x00\x02\x00\x00\x00\x03\x00\x00\x00b\
\x00\x00\x00\x00\x00\x00\x00\x00\
-\x00\x00\x00\x00\x00\x02\x00\x00\x00\x1a\x00\x00\x00\x09\
+\x00\x00\x00\x00\x00\x02\x00\x00\x00\x1a\x00\x00\x00\x0b\
\x00\x00\x00\x00\x00\x00\x00\x00\
-\x00\x00\x00 \x00\x02\x00\x00\x00\x03\x00\x00\x00\x06\
+\x00\x00\x00 \x00\x02\x00\x00\x00\x05\x00\x00\x00\x06\
\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x004\x00\x02\x00\x00\x00\x01\x00\x00\x00\x05\
\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x00T\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\
\x00\x00\x01\x8f\xec\xcf\x12\xea\
-\x00\x00\x00x\x00\x00\x00\x00\x00\x01\x00\x00\x09\xf5\
+\x00\x00\x00\xbe\x00\x00\x00\x00\x00\x01\x00\x00\x17\xef\
\x00\x00\x01\x8f\xec\xcf\x12\xea\
-\x00\x00\x00\xb2\x00\x00\x00\x00\x00\x01\x00\x008\x89\
+\x00\x00\x00x\x00\x01\x00\x00\x00\x01\x00\x00\x09\xf5\
+\x00\x00\x01\x8f\xee\x1c\xe1_\
+\x00\x00\x00\xf8\x00\x00\x00\x00\x00\x01\x00\x00F\x83\
\x00\x00\x01\x8f\xec\xcf\x13\x09\
-\x00\x00\x00\x9e\x00\x01\x00\x00\x00\x01\x00\x00\x10N\
+\x00\x00\x00\xe4\x00\x01\x00\x00\x00\x01\x00\x00\x1eH\
\x00\x00\x01\x8f\xec\xcf\x12\xea\
-\x00\x00\x02R\x00\x00\x00\x00\x00\x01\x00\x06A\x9c\
+\x00\x00\x00\x9c\x00\x00\x00\x00\x00\x01\x00\x00\x13~\
+\x00\x00\x01\x93\x87\xeb\x09\xf1\
+\x00\x00\x02\x98\x00\x00\x00\x00\x00\x01\x00\x06O\x96\
\x00\x00\x01\x8f\xec\xcf\x12\xea\
-\x00\x00\x01\xea\x00\x00\x00\x00\x00\x01\x00\x062\x92\
+\x00\x00\x020\x00\x00\x00\x00\x00\x01\x00\x06@\x8c\
\x00\x00\x01\x8f\xec\xcf\x13\x09\
-\x00\x00\x04p\x00\x00\x00\x00\x00\x01\x00\x06\xa8\xcd\
+\x00\x00\x04\xb6\x00\x00\x00\x00\x00\x01\x00\x06\xb6\xc7\
\x00\x00\x01\x8f\xec\xcf\x12\xea\
-\x00\x00\x04\xb8\x00\x00\x00\x00\x00\x01\x00\x06\xbd\xb7\
+\x00\x00\x04\xfe\x00\x00\x00\x00\x00\x01\x00\x06\xcb\xb1\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x03D\x00\x00\x00\x00\x00\x01\x00\x06\x88\x1a\
+\x00\x00\x03\x8a\x00\x00\x00\x00\x00\x01\x00\x06\x96\x14\
\x00\x00\x01\x8f\xec\xcf\x13\x09\
-\x00\x00\x04\x98\x00\x00\x00\x00\x00\x01\x00\x06\xaa\xa1\
+\x00\x00\x04\xde\x00\x00\x00\x00\x00\x01\x00\x06\xb8\x9b\
\x00\x00\x01\x8f\xec\xcf\x12\xea\
-\x00\x00\x04\x0c\x00\x00\x00\x00\x00\x01\x00\x06\x99\xa7\
+\x00\x00\x04R\x00\x00\x00\x00\x00\x01\x00\x06\xa7\xa1\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x02\x80\x00\x00\x00\x00\x00\x01\x00\x06E\xb2\
+\x00\x00\x02\xc6\x00\x00\x00\x00\x00\x01\x00\x06S\xac\
\x00\x00\x01\x8f\xec\xcf\x12\xea\
-\x00\x00\x02h\x00\x00\x00\x00\x00\x01\x00\x06B\xab\
+\x00\x00\x02\xae\x00\x00\x00\x00\x00\x01\x00\x06P\xa5\
\x00\x00\x01\x8f\xec\xcf\x13\x09\
-\x00\x00\x03\xf6\x00\x00\x00\x00\x00\x01\x00\x06\x978\
+\x00\x00\x04<\x00\x00\x00\x00\x00\x01\x00\x06\xa52\
\x00\x00\x01\x8f\xec\xcf\x12\xea\
-\x00\x00\x03^\x00\x00\x00\x00\x00\x01\x00\x06\x8a3\
+\x00\x00\x03\xa4\x00\x00\x00\x00\x00\x01\x00\x06\x98-\
\x00\x00\x01\x8f\xec\xcf\x12\xea\
-\x00\x00\x02\xc4\x00\x00\x00\x00\x00\x01\x00\x06Q\xfb\
+\x00\x00\x03\x0a\x00\x00\x00\x00\x00\x01\x00\x06_\xf5\
\x00\x00\x01\x8f\xec\xcf\x12\xea\
-\x00\x00\x02.\x00\x00\x00\x00\x00\x01\x00\x06?\xec\
+\x00\x00\x02t\x00\x00\x00\x00\x00\x01\x00\x06M\xe6\
\x00\x00\x01\x8f\xec\xcf\x12\xea\
-\x00\x00\x03(\x00\x00\x00\x00\x00\x01\x00\x06b\xb3\
+\x00\x00\x03n\x00\x00\x00\x00\x00\x01\x00\x06p\xad\
\x00\x00\x01\x8f\xec\xcf\x12\xea\
-\x00\x00\x01\xbe\x00\x00\x00\x00\x00\x01\x00\x060\xaf\
+\x00\x00\x02\x04\x00\x00\x00\x00\x00\x01\x00\x06>\xa9\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x03\x98\x00\x00\x00\x00\x00\x01\x00\x06\x94o\
+\x00\x00\x03\xde\x00\x00\x00\x00\x00\x01\x00\x06\xa2i\
\x00\x00\x01\x8f\xec\xcf\x13\x09\
-\x00\x00\x01\xa6\x00\x00\x00\x00\x00\x01\x00\x06/\x9c\
+\x00\x00\x01\xec\x00\x00\x00\x00\x00\x01\x00\x06=\x96\
\x00\x00\x01\x8f\xec\xcf\x13\x09\
-\x00\x00\x04$\x00\x00\x00\x00\x00\x01\x00\x06\xa0\x10\
+\x00\x00\x04j\x00\x00\x00\x00\x00\x01\x00\x06\xae\x0a\
\x00\x00\x01\x8f\xec\xcf\x13\x09\
-\x00\x00\x03\xb0\x00\x02\x00\x00\x00\x09\x00\x00\x00V\
+\x00\x00\x03\xf6\x00\x02\x00\x00\x00\x09\x00\x00\x00Y\
\x00\x00\x00\x00\x00\x00\x00\x00\
-\x00\x00\x03~\x00\x02\x00\x00\x003\x00\x00\x00#\
+\x00\x00\x03\xc4\x00\x02\x00\x00\x004\x00\x00\x00%\
\x00\x00\x00\x00\x00\x00\x00\x00\
-\x00\x00\x02\x0a\x00\x00\x00\x00\x00\x01\x00\x065G\
+\x00\x00\x02P\x00\x00\x00\x00\x00\x01\x00\x06CA\
\x00\x00\x01\x8f\xec\xcf\x13\x09\
-\x00\x00\x04T\x00\x00\x00\x00\x00\x01\x00\x06\xa3)\
+\x00\x00\x04\x9a\x00\x00\x00\x00\x00\x01\x00\x06\xb1#\
\x00\x00\x01\x8f\xec\xcf\x13\x09\
-\x00\x00\x03\xda\x00\x00\x00\x00\x00\x01\x00\x06\x96\x00\
+\x00\x00\x04 \x00\x00\x00\x00\x00\x01\x00\x06\xa3\xfa\
\x00\x00\x01\x8f\xec\xcf\x12\xea\
-\x00\x00\x02\xda\x00\x00\x00\x00\x00\x01\x00\x06T\x05\
+\x00\x00\x03 \x00\x00\x00\x00\x00\x01\x00\x06a\xff\
\x00\x00\x01\x8f\xec\xcf\x12\xea\
-\x00\x00\x02\x9e\x00\x00\x00\x00\x00\x01\x00\x06G\x93\
+\x00\x00\x02\xe4\x00\x00\x00\x00\x00\x01\x00\x06U\x8d\
\x00\x00\x01\x8f\xec\xcf\x13\x09\
-\x00\x00\x03\x02\x00\x00\x00\x00\x00\x01\x00\x06`G\
+\x00\x00\x03H\x00\x00\x00\x00\x00\x01\x00\x06nA\
\x00\x00\x01\x8f\xec\xcf\x12\xea\
-\x00\x00\x02R\x00\x00\x00\x00\x00\x01\x00\x07\x0a\xf8\
+\x00\x00\x02\x98\x00\x00\x00\x00\x00\x01\x00\x07\x18\xf2\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x0b\xec\x00\x00\x00\x00\x00\x01\x00\x07\xbf\xb6\
+\x00\x00\x0cP\x00\x00\x00\x00\x00\x01\x00\x07\xcf\x95\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x060\x00\x00\x00\x00\x00\x01\x00\x06\xec\xdf\
+\x00\x00\x06v\x00\x00\x00\x00\x00\x01\x00\x06\xfa\xd9\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x09\x14\x00\x00\x00\x00\x00\x01\x00\x07Hx\
+\x00\x00\x09Z\x00\x00\x00\x00\x00\x01\x00\x07Vr\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x09\x9a\x00\x00\x00\x00\x00\x01\x00\x07_J\
+\x00\x00\x09\xe0\x00\x00\x00\x00\x00\x01\x00\x07mD\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x06\xf6\x00\x00\x00\x00\x00\x01\x00\x07\x08\x9c\
+\x00\x00\x07<\x00\x00\x00\x00\x00\x01\x00\x07\x16\x96\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x08\x98\x00\x00\x00\x00\x00\x01\x00\x07B\xb4\
+\x00\x00\x08\xde\x00\x00\x00\x00\x00\x01\x00\x07P\xae\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x09v\x00\x00\x00\x00\x00\x01\x00\x07]\x88\
+\x00\x00\x09\xbc\x00\x00\x00\x00\x00\x01\x00\x07k\x82\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x0a\xce\x00\x00\x00\x00\x00\x01\x00\x07\xa0\x8e\
+\x00\x00\x0b2\x00\x00\x00\x00\x00\x01\x00\x07\xb0m\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x07\xa0\x00\x00\x00\x00\x00\x01\x00\x07\x1ez\
+\x00\x00\x07\xe6\x00\x00\x00\x00\x00\x01\x00\x07,t\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x07\x16\x00\x00\x00\x00\x00\x01\x00\x07\x0e8\
+\x00\x00\x07\x5c\x00\x00\x00\x00\x00\x01\x00\x07\x1c2\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x094\x00\x00\x00\x00\x00\x01\x00\x07ZV\
+\x00\x00\x09z\x00\x00\x00\x00\x00\x01\x00\x07hP\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x08x\x00\x00\x00\x00\x00\x01\x00\x07)\xa4\
+\x00\x00\x08\xbe\x00\x00\x00\x00\x00\x01\x00\x077\x9e\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x08\xba\x00\x00\x00\x00\x00\x01\x00\x07D\xc1\
+\x00\x00\x09\x00\x00\x00\x00\x00\x00\x01\x00\x07R\xbb\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x07\xf2\x00\x00\x00\x00\x00\x01\x00\x07$u\
+\x00\x00\x088\x00\x00\x00\x00\x00\x01\x00\x072o\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x08\xee\x00\x00\x00\x00\x00\x01\x00\x07F\x1d\
+\x00\x00\x094\x00\x00\x00\x00\x00\x01\x00\x07T\x17\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x06V\x00\x00\x00\x00\x00\x01\x00\x06\xee\xe8\
+\x00\x00\x06\x9c\x00\x00\x00\x00\x00\x01\x00\x06\xfc\xe2\
\x00\x00\x01\x8f\xec\xcf\x12\xea\
-\x00\x00\x0a\x00\x00\x00\x00\x00\x00\x01\x00\x07~\x92\
+\x00\x00\x0aF\x00\x00\x00\x00\x00\x01\x00\x07\x8c\x8c\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x07\xb8\x00\x00\x00\x00\x00\x01\x00\x07 m\
+\x00\x00\x07\xfe\x00\x00\x00\x00\x00\x01\x00\x07.g\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x0b>\x00\x00\x00\x00\x00\x01\x00\x07\xa6\xd3\
+\x00\x00\x0b\xa2\x00\x00\x00\x00\x00\x01\x00\x07\xb6\xb2\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x06\x1a\x00\x00\x00\x00\x00\x01\x00\x06\xeap\
+\x00\x00\x06`\x00\x00\x00\x00\x00\x01\x00\x06\xf8j\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x06\xd4\x00\x00\x00\x00\x00\x01\x00\x06\xfa\xbf\
+\x00\x00\x07\x1a\x00\x00\x00\x00\x00\x01\x00\x07\x08\xb9\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x08T\x00\x00\x00\x00\x00\x01\x00\x07(B\
+\x00\x00\x08\x9a\x00\x00\x00\x00\x00\x01\x00\x076<\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x07X\x00\x00\x00\x00\x00\x01\x00\x07\x1a]\
+\x00\x00\x07\x9e\x00\x00\x00\x00\x00\x01\x00\x07(W\
\x00\x00\x01\x8f\xec\xcf\x12\xea\
-\x00\x00\x0b\xa4\x00\x00\x00\x00\x00\x01\x00\x07\xad\xdf\
+\x00\x00\x0c\x08\x00\x00\x00\x00\x00\x01\x00\x07\xbd\xbe\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x0b\x0e\x00\x00\x00\x00\x00\x01\x00\x07\xa5\xce\
+\x00\x00\x0br\x00\x00\x00\x00\x00\x01\x00\x07\xb5\xad\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x05\xfe\x00\x00\x00\x00\x00\x01\x00\x06\xe7K\
+\x00\x00\x06D\x00\x00\x00\x00\x00\x01\x00\x06\xf5E\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x07\xda\x00\x00\x00\x00\x00\x01\x00\x07!\x9a\
+\x00\x00\x08 \x00\x00\x00\x00\x00\x01\x00\x07/\x94\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x09\xd2\x00\x00\x00\x00\x00\x01\x00\x07y\xdc\
+\x00\x00\x0a\x18\x00\x00\x00\x00\x00\x01\x00\x07\x87\xd6\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x0b\xcc\x00\x00\x00\x00\x00\x01\x00\x07\xb0:\
+\x00\x00\x0c0\x00\x00\x00\x00\x00\x01\x00\x07\xc0\x19\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x07:\x00\x01\x00\x00\x00\x01\x00\x07\x18\xeb\
+\x00\x00\x07\x80\x00\x01\x00\x00\x00\x01\x00\x07&\xe5\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x0aT\x00\x00\x00\x00\x00\x01\x00\x07\x8e%\
+\x00\x00\x0a\x9a\x00\x00\x00\x00\x00\x01\x00\x07\x9c\x1f\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x09^\x00\x00\x00\x00\x00\x01\x00\x07\x5ci\
+\x00\x00\x09\xa4\x00\x00\x00\x00\x00\x01\x00\x07jc\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x09\xbc\x00\x00\x00\x00\x00\x01\x00\x07m\xb8\
+\x00\x00\x0a\x02\x00\x00\x00\x00\x00\x01\x00\x07{\xb2\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x06\xc0\x00\x00\x00\x00\x00\x01\x00\x06\xf6%\
+\x00\x00\x07\x06\x00\x00\x00\x00\x00\x01\x00\x07\x04\x1f\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x0a\xf2\x00\x00\x00\x00\x00\x01\x00\x07\xa2T\
+\x00\x00\x0bV\x00\x00\x00\x00\x00\x01\x00\x07\xb23\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x0a\x80\x00\x00\x00\x00\x00\x01\x00\x07\x90\xe7\
+\x00\x00\x0a\xc6\x00\x00\x00\x00\x00\x01\x00\x07\x9e\xe1\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x0a$\x00\x00\x00\x00\x00\x01\x00\x07\x8c\x8f\
+\x00\x00\x0aj\x00\x00\x00\x00\x00\x01\x00\x07\x9a\x89\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x09\xe8\x00\x00\x00\x00\x00\x01\x00\x07{\x8f\
+\x00\x00\x0a.\x00\x00\x00\x00\x00\x01\x00\x07\x89\x89\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x07\x8a\x00\x00\x00\x00\x00\x01\x00\x07\x1c+\
+\x00\x00\x07\xd0\x00\x00\x00\x00\x00\x01\x00\x07*%\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x0a\x98\x00\x00\x00\x00\x00\x01\x00\x07\x931\
+\x00\x00\x0a\xf6\x00\x00\x00\x00\x00\x01\x00\x07\xa2\x91\
+\x00\x00\x01f\xd4x\xbb0\
+\x00\x00\x0a\xde\x00\x00\x00\x00\x00\x01\x00\x07\xa1+\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x06\xaa\x00\x00\x00\x00\x00\x01\x00\x06\xf4{\
+\x00\x00\x06\xf0\x00\x00\x00\x00\x00\x01\x00\x07\x02u\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x0a\xb0\x00\x00\x00\x00\x00\x01\x00\x07\x94\x97\
+\x00\x00\x0b\x14\x00\x00\x00\x00\x00\x01\x00\x07\xa4v\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x0b\x88\x00\x00\x00\x00\x00\x01\x00\x07\xaa\x95\
+\x00\x00\x0b\xec\x00\x00\x00\x00\x00\x01\x00\x07\xbat\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x08&\x00\x01\x00\x00\x00\x01\x00\x07%\xca\
+\x00\x00\x08l\x00\x01\x00\x00\x00\x01\x00\x073\xc4\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x08@\x00\x00\x00\x00\x00\x01\x00\x07&\xa4\
+\x00\x00\x08\x86\x00\x00\x00\x00\x00\x01\x00\x074\x9e\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x0bb\x00\x00\x00\x00\x00\x01\x00\x07\xa8\xb6\
+\x00\x00\x0b\xc6\x00\x00\x00\x00\x00\x01\x00\x07\xb8\x95\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x06\x86\x00\x00\x00\x00\x00\x01\x00\x06\xf0\xb4\
+\x00\x00\x06\xcc\x00\x00\x00\x00\x00\x01\x00\x06\xfe\xae\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x04\xf0\x00\x00\x00\x00\x00\x01\x00\x07\x0c\x07\
+\x00\x00\x056\x00\x00\x00\x00\x00\x01\x00\x07\x1a\x01\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x05X\x00\x00\x00\x00\x00\x01\x00\x078\x22\
+\x00\x00\x05\x9e\x00\x00\x00\x00\x00\x01\x00\x07F\x1c\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x05\x84\x00\x00\x00\x00\x00\x01\x00\x07o\x0c\
+\x00\x00\x05\xca\x00\x00\x00\x00\x00\x01\x00\x07}\x06\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x05\xb0\x00\x00\x00\x00\x00\x01\x00\x06\xe3:\
+\x00\x00\x05\xf6\x00\x00\x00\x00\x00\x01\x00\x06\xf14\
\x00\x00\x01\x8f\xec\xcf\x13\x09\
-\x00\x00\x05\x0e\x00\x00\x00\x00\x00\x01\x00\x06\xca\xc1\
+\x00\x00\x05T\x00\x00\x00\x00\x00\x01\x00\x06\xd8\xbb\
\x00\x00\x01\x8f\xec\xcf\x13\x09\
-\x00\x00\x02\x80\x00\x00\x00\x00\x00\x01\x00\x06\xc6\xaf\
+\x00\x00\x02\xc6\x00\x00\x00\x00\x00\x01\x00\x06\xd4\xa9\
\x00\x00\x01\x8f\xec\xcf\x13\x09\
-\x00\x00\x04\xce\x00\x00\x00\x00\x00\x01\x00\x06\xc4\x9c\
+\x00\x00\x05\x14\x00\x00\x00\x00\x00\x01\x00\x06\xd2\x96\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x05\xe2\x00\x00\x00\x00\x00\x01\x00\x06\xe5\x0c\
+\x00\x00\x06(\x00\x00\x00\x00\x00\x01\x00\x06\xf3\x06\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x05(\x00\x00\x00\x00\x00\x01\x00\x06\xcdK\
+\x00\x00\x05n\x00\x00\x00\x00\x00\x01\x00\x06\xdbE\
\x00\x00\x01\x8f\xec\xcf\x13\x09\
-\x00\x00\x04\xf0\x00\x00\x00\x00\x00\x01\x00\x06\xc8\x90\
+\x00\x00\x056\x00\x00\x00\x00\x00\x01\x00\x06\xd6\x8a\
\x00\x00\x01\x8f\xec\xcf\x13\x09\
-\x00\x00\x05X\x00\x00\x00\x00\x00\x01\x00\x06\xcf\xfe\
+\x00\x00\x05\x9e\x00\x00\x00\x00\x00\x01\x00\x06\xdd\xf8\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x05\x84\x00\x00\x00\x00\x00\x01\x00\x06\xd9\xb8\
+\x00\x00\x05\xca\x00\x00\x00\x00\x00\x01\x00\x06\xe7\xb2\
\x00\x00\x01\x8f\xec\xcf\x12\xfa\
-\x00\x00\x01&\x00\x01\x00\x00\x00\x01\x00\x03\x04\x82\
+\x00\x00\x01l\x00\x01\x00\x00\x00\x01\x00\x03\x12|\
\x00\x00\x01\x8f\xec\xcf\x12\xea\
-\x00\x00\x00\xe6\x00\x01\x00\x00\x00\x01\x00\x02\xbaN\
+\x00\x00\x01,\x00\x01\x00\x00\x00\x01\x00\x02\xc8H\
\x00\x00\x01\x8f\xec\xcf\x12\xea\
-\x00\x00\x01d\x00\x00\x00\x00\x00\x01\x00\x03\x10\xa8\
+\x00\x00\x01\xaa\x00\x00\x00\x00\x00\x01\x00\x03\x1e\xa2\
\x00\x00\x01\x8f\xec\xcf\x12\xea\
"
diff --git a/spinetoolbox/ui/mainwindow.py b/spinetoolbox/ui/mainwindow.py
index 1e716bc71..09a77ff2a 100644
--- a/spinetoolbox/ui/mainwindow.py
+++ b/spinetoolbox/ui/mainwindow.py
@@ -192,6 +192,14 @@ def setupUi(self, MainWindow):
icon18 = QIcon()
icon18.addFile(u":/icons/menu_icons/github-mark.svg", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionGitHub.setIcon(icon18)
+ self.actionStart_default_python_in_basic_console = QAction(MainWindow)
+ self.actionStart_default_python_in_basic_console.setObjectName(u"actionStart_default_python_in_basic_console")
+ icon19 = QIcon()
+ icon19.addFile(u":/icons/menu_icons/terminal.svg", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
+ self.actionStart_default_python_in_basic_console.setIcon(icon19)
+ self.actionStart_default_julia_in_basic_console = QAction(MainWindow)
+ self.actionStart_default_julia_in_basic_console.setObjectName(u"actionStart_default_julia_in_basic_console")
+ self.actionStart_default_julia_in_basic_console.setIcon(icon19)
self.centralwidget = QWidget(MainWindow)
self.centralwidget.setObjectName(u"centralwidget")
MainWindow.setCentralWidget(self.centralwidget)
@@ -256,9 +264,9 @@ def setupUi(self, MainWindow):
self.toolButton_executions = QToolButton(self.dockWidgetContents)
self.toolButton_executions.setObjectName(u"toolButton_executions")
- icon19 = QIcon()
- icon19.addFile(u":/icons/check-circle.svg", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
- self.toolButton_executions.setIcon(icon19)
+ icon20 = QIcon()
+ icon20.addFile(u":/icons/check-circle.svg", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
+ self.toolButton_executions.setIcon(icon20)
self.toolButton_executions.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
self.toolButton_executions.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
@@ -402,6 +410,8 @@ def setupUi(self, MainWindow):
self.menuPlugins.addSeparator()
self.menuPlugins.addAction(self.actionCreate_plugin)
self.menuConsoles.addAction(self.actionStart_jupyter_console)
+ self.menuConsoles.addAction(self.actionStart_default_python_in_basic_console)
+ self.menuConsoles.addAction(self.actionStart_default_julia_in_basic_console)
self.menuServer.addAction(self.actionRetrieve_project)
self.retranslateUi(MainWindow)
@@ -610,6 +620,8 @@ def retranslateUi(self, MainWindow):
#if QT_CONFIG(tooltip)
self.actionGitHub.setToolTip(QCoreApplication.translate("MainWindow", u"
Open Spine-Toolbox repository in GitHub
", None))
#endif // QT_CONFIG(tooltip)
+ self.actionStart_default_python_in_basic_console.setText(QCoreApplication.translate("MainWindow", u"Start Default Python in Basic Console", None))
+ self.actionStart_default_julia_in_basic_console.setText(QCoreApplication.translate("MainWindow", u"Start Default Julia in Basic Console", None))
self.menuFile.setTitle(QCoreApplication.translate("MainWindow", u"&File", None))
self.menuHelp.setTitle(QCoreApplication.translate("MainWindow", u"&Help", None))
self.menuEdit.setTitle(QCoreApplication.translate("MainWindow", u"&Edit", None))
diff --git a/spinetoolbox/ui/mainwindow.ui b/spinetoolbox/ui/mainwindow.ui
index 6563a0165..f34080b10 100644
--- a/spinetoolbox/ui/mainwindow.ui
+++ b/spinetoolbox/ui/mainwindow.ui
@@ -120,6 +120,8 @@
&Consoles
+
+
diff --git a/spinetoolbox/ui/resources/julia-logo.svg b/spinetoolbox/ui/resources/julia-logo.svg
new file mode 100644
index 000000000..ed7f17bb3
--- /dev/null
+++ b/spinetoolbox/ui/resources/julia-logo.svg
@@ -0,0 +1,8 @@
+
+
diff --git a/spinetoolbox/ui/resources/menu_icons/terminal.svg b/spinetoolbox/ui/resources/menu_icons/terminal.svg
new file mode 100644
index 000000000..85b2b5e85
--- /dev/null
+++ b/spinetoolbox/ui/resources/menu_icons/terminal.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/spinetoolbox/ui/resources/python-logo.svg b/spinetoolbox/ui/resources/python-logo.svg
new file mode 100644
index 000000000..467b07b26
--- /dev/null
+++ b/spinetoolbox/ui/resources/python-logo.svg
@@ -0,0 +1,265 @@
+
+
+
+
diff --git a/spinetoolbox/ui/resources/resources_icons.qrc b/spinetoolbox/ui/resources/resources_icons.qrc
index 6e52c62b3..a30b6430e 100644
--- a/spinetoolbox/ui/resources/resources_icons.qrc
+++ b/spinetoolbox/ui/resources/resources_icons.qrc
@@ -1,6 +1,8 @@
Spine_symbol.png
+ julia-logo.svg
+ python-logo.svg
spinetoolbox_on_wht.png
app.ico
@@ -13,6 +15,7 @@
fontawesome5-searchterms.json
+ menu_icons/terminal.svg
menu_icons/bolt-lightning.svg
share.svg
menu_icons/server.svg
diff --git a/spinetoolbox/ui_main.py b/spinetoolbox/ui_main.py
index bff0761c2..3e9b3881e 100644
--- a/spinetoolbox/ui_main.py
+++ b/spinetoolbox/ui_main.py
@@ -17,6 +17,7 @@
import os
import pathlib
import sys
+import threading
from zipfile import ZipFile
import numpy as np
from PySide6.QtCore import QByteArray, QEvent, QMimeData, QModelIndex, QPoint, QSettings, Qt, QUrl, Signal, Slot
@@ -43,13 +44,13 @@
QMainWindow,
QMenu,
QMessageBox,
- QScrollArea,
QStyleFactory,
QToolButton,
- QVBoxLayout,
QWidget,
)
+from spine_engine.spine_engine import _set_resource_limits
from spine_engine.load_project_items import load_item_specification_factories
+from spine_engine.utils.helpers import resolve_python_interpreter, resolve_julia_executable, resolve_julia_project
from spinetoolbox.server.engine_client import ClientSecurityModel, EngineClient, RemoteEngineInitFailed
from .config import DEFAULT_WORK_DIR, MAINWINDOW_SS, ONLINE_DOCUMENTATION_URL, SPINE_TOOLBOX_REPO_URL
from .helpers import (
@@ -70,6 +71,7 @@
supported_img_formats,
unique_name,
clear_qsettings,
+ basic_console_icon,
)
from .kernel_fetcher import KernelFetcher
from .link import JUMP_COLOR, LINK_COLOR, JumpLink, Link
@@ -104,7 +106,7 @@
from .widgets.link_properties_widget import LinkPropertiesWidget
from .widgets.multi_tab_spec_editor import MultiTabSpecEditor
from .widgets.open_project_dialog import OpenProjectDialog
-from .widgets.persistent_console_widget import PersistentConsoleWidget
+from .widgets.persistent_console_widget import PersistentConsoleWidget, ConsoleWindow
from .widgets.set_description_dialog import SetDescriptionDialog
from .widgets.settings_widget import SettingsWidget
@@ -266,6 +268,8 @@ def connect_signals(self):
self.ui.actionStart_jupyter_console.setMenu(self.kernels_menu)
self.kernels_menu.aboutToShow.connect(self.fetch_kernels)
self.kernels_menu.aboutToHide.connect(self.stop_fetching_kernels)
+ self.ui.actionStart_default_python_in_basic_console.triggered.connect(self.start_detached_python_basic_console)
+ self.ui.actionStart_default_julia_in_basic_console.triggered.connect(self.start_detached_julia_basic_console)
self.ui.actionSave.triggered.connect(self.save_project)
self.ui.actionSave_As.triggered.connect(self.save_project_as)
self.ui.actionClose.triggered.connect(lambda _checked=False: self.close_project())
@@ -2135,7 +2139,7 @@ def _add_item_edit_actions(self):
self.ui.actionRemove,
]
for action in actions:
- action.setShortcutContext(Qt.WidgetShortcut)
+ action.setShortcutContext(Qt.ShortcutContext.WidgetShortcut)
self.ui.graphicsView.addAction(action)
@Slot(str, str)
@@ -2148,7 +2152,7 @@ def _show_error_box(self, title, message):
"""Shows an error message with the given title and message."""
box = QErrorMessage(self)
box.setWindowTitle(title)
- box.setWindowModality(Qt.ApplicationModal)
+ box.setWindowModality(Qt.WindowModality.ApplicationModal)
box.showMessage(message)
def _connect_project_signals(self):
@@ -2295,7 +2299,8 @@ def _rename_project_item(self, _):
"""Renames active project item."""
item = self.active_project_item
answer = QInputDialog.getText(
- self, "Rename Item", "New name:", text=item.name, flags=Qt.WindowTitleHint | Qt.WindowCloseButtonHint
+ self, "Rename Item", "New name:", text=item.name,
+ flags=Qt.WindowType.WindowTitleHint | Qt.WindowType.WindowCloseButtonHint
)
if not answer[1]:
return
@@ -2331,6 +2336,54 @@ def project_item_context_menu(self, additional_actions):
menu.aboutToHide.connect(self.enable_edit_actions)
return menu
+ @Slot()
+ def start_detached_python_basic_console(self):
+ """Starts basic console with the default Python interpreter."""
+ python = resolve_python_interpreter(self.qsettings())
+ _set_resource_limits(self.qsettings(), threading.Lock())
+ self.start_detached_basic_console("python", python)
+
+ @Slot()
+ def start_detached_julia_basic_console(self):
+ """Starts basic console with the default Julia executable."""
+ julia = resolve_julia_executable(self.qsettings())
+ project = resolve_julia_project(self.qsettings())
+ if not julia:
+ self.msg_warning.emit("No Julia installation found. Add path to a Julia executable in Spine "
+ "Toolbox Settings [File->Settings->Tools]")
+ return
+ _set_resource_limits(self.qsettings(), threading.Lock())
+ self.start_detached_basic_console("julia", julia, project)
+
+ def start_detached_basic_console(self, language, executable, julia_project=None):
+ """Launches a new detached basic console with the given executable
+ or activates an existing Console if the kernel is already running.
+
+ Args:
+ language (str): Console kernel language
+ executable (str): Abs. path to kernel file
+ julia_project (str): Path to Julia environment
+ """
+ for pcw in self._persistent_consoles.values():
+ if pcw.detached_console_id is not None:
+ if os.path.samefile(pcw.detached_console_id, executable) and None in pcw.owners:
+ # Console running the requested kernel already exists, show and activate it
+ if pcw.parent().isMinimized():
+ pcw.parent().showNormal()
+ pcw.parent().activateWindow()
+ return
+ icon = basic_console_icon(language)
+ console_window = ConsoleWindow(icon, language)
+ c = PersistentConsoleWidget(self, None, language, None, console_window)
+ key = c.request_start_kernel(executable, julia_project)
+ if not key:
+ self.msg_error.emit(f"Starting Basic Console for {executable} failed")
+ return
+ c.insert_banner(language)
+ self._persistent_consoles[key] = c
+ console_window.console_closed.connect(self._cleanup_basic_console)
+ c.show()
+
@Slot(str, QIcon, bool)
def start_detached_jupyter_console(self, kernel_name, icon, conda):
"""Launches a new detached Console with the given kernel
@@ -2478,6 +2531,17 @@ def _cleanup_jupyter_console(self, conn_file):
engine_mngr = make_engine_manager(exec_remotely)
engine_mngr.shutdown_kernel(conn_file)
+ @Slot(str)
+ def _cleanup_basic_console(self, key):
+ """Removes reference to the Basic Console and closes the kernel manager on Engine."""
+ c = self._persistent_consoles.pop(key, None)
+ if not c:
+ return
+ c.shutdown_executor()
+ exec_remotely = self.qsettings().value("engineSettings/remoteExecutionEnabled", "false") == "true"
+ engine_mngr = make_engine_manager(exec_remotely)
+ engine_mngr.kill_persistent(key)
+
def _shutdown_engine_kernels(self):
"""Shuts down all persistent and Jupyter kernels managed by Spine Engine."""
exec_remotely = self.qsettings().value("engineSettings/remoteExecutionEnabled", "false") == "true"
diff --git a/spinetoolbox/widgets/persistent_console_widget.py b/spinetoolbox/widgets/persistent_console_widget.py
index 3c5b35827..482887da7 100644
--- a/spinetoolbox/widgets/persistent_console_widget.py
+++ b/spinetoolbox/widgets/persistent_console_widget.py
@@ -12,7 +12,10 @@
"""Contains a widget acting as a console for Julia & Python REPL's."""
import os
+import sys
import uuid
+import multiprocessing
+from queue import Empty
from pygments.lexers import get_lexer_by_name
from pygments.styles import get_style_by_name
from pygments.token import Token
@@ -28,8 +31,13 @@
QTextCursor,
QTextOption,
)
-from PySide6.QtWidgets import QPlainTextEdit, QSizePolicy
+from PySide6.QtWidgets import QPlainTextEdit, QSizePolicy, QWidget, QVBoxLayout
from spine_engine.exception import RemoteEngineInitFailed
+from spine_engine.execution_managers.persistent_execution_manager import (
+ PythonPersistentExecutionManager,
+ JuliaPersistentExecutionManager,
+)
+from spine_engine.utils.queue_logger import QueueLogger
from spinetoolbox.helpers import CustomSyntaxHighlighter
from spinetoolbox.qthread_pool_executor import QtBasedThreadPoolExecutor
from spinetoolbox.spine_engine_manager import make_engine_manager
@@ -42,13 +50,13 @@ def __init__(self, console):
self._console = console
self._current_prompt = ""
self.setStyleSheet("QPlainTextEdit {background-color: transparent; color: transparent; border:none;}")
- self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
- self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.document().setDocumentMargin(0)
- self.setAttribute(Qt.WA_TransparentForMouseEvents)
+ self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
self.setTabChangesFocus(False)
self.setUndoRedoEnabled(False)
- self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
+ self.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.MinimumExpanding)
self.cursorPositionChanged.connect(self._handle_cursor_position_changed)
self.textChanged.connect(self._handle_text_changed)
@@ -116,10 +124,10 @@ def _handle_cursor_position_changed(self):
self._updating = False
def keyPressEvent(self, ev):
- if ev.matches(QKeySequence.Copy):
+ if ev.matches(QKeySequence.StandardKey.Copy):
ev.ignore()
return
- if ev.key() == Qt.Key_Backspace:
+ if ev.key() == Qt.Key.Key_Backspace:
cursor = self.textCursor()
if cursor.position() == self.min_pos:
return
@@ -131,13 +139,13 @@ def keyPressEvent(self, ev):
)
cursor.removeSelectedText()
return
- if ev.key() == Qt.Key_Left:
+ if ev.key() == Qt.Key.Key_Left:
cursor = self.textCursor()
if cursor.positionInBlock() == self.new_line_indent:
cursor.movePosition(QTextCursor.MoveOperation.PreviousCharacter, n=self.new_line_indent + 1)
self.setTextCursor(cursor)
return
- if ev.key() == Qt.Key_Delete:
+ if ev.key() == Qt.Key.Key_Delete:
cursor = self.textCursor()
if cursor.atBlockEnd():
cursor.movePosition(
@@ -166,18 +174,22 @@ class PersistentConsoleWidget(QPlainTextEdit):
_MAX_LINES_PER_CYCLE = _MAX_LINES_PER_SECOND * 1000 / _FLUSH_INTERVAL
_MAX_LINES_COUNT = 2000
- def __init__(self, toolbox, key, language, owner=None):
+ def __init__(self, toolbox, key, language, owner=None, console=None):
"""
Args:
toolbox (ToolboxUI)
key (tuple): persistent process identifier
language (str): for syntax highlighting and prompting, etc.
owner (ProjectItemBase, optional): console owner
+ console (ConsoleWindow): A window for displaying the console if running in detached mode
"""
- super().__init__(parent=toolbox)
+ parent_widget = toolbox if not console else console
+ super().__init__(parent=parent_widget)
+ if console is not None:
+ console.lay_out.addWidget(self)
self._executor = QtBasedThreadPoolExecutor(max_workers=1)
self._updating = False
- font = QFontDatabase.systemFont(QFontDatabase.FixedFont)
+ font = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
self.setFont(font)
self.setMaximumBlockCount(self._MAX_LINES_COUNT)
self._toolbox = toolbox
@@ -198,12 +210,12 @@ def __init__(self, toolbox, key, language, owner=None):
f"QPlainTextEdit {{background-color: {background_color}; color: {foreground_color}; border: 0px}}"
)
cursor_width = self.fontMetrics().horizontalAdvance("x")
- self.setWordWrapMode(QTextOption.WrapAnywhere)
+ self.setWordWrapMode(QTextOption.WrapMode.WrapAnywhere)
self.setTabStopDistance(4 * cursor_width)
self._line_edit = _CustomLineEdit(self)
self._line_edit.setFont(font)
self._line_edit.setCursorWidth(cursor_width)
- self._line_edit.setWordWrapMode(QTextOption.WrapAnywhere)
+ self._line_edit.setWordWrapMode(QTextOption.WrapMode.WrapAnywhere)
self._line_edit.setTabStopDistance(4 * cursor_width)
self._highlighter = CustomSyntaxHighlighter(None)
self._highlighter.set_style(self._style)
@@ -236,9 +248,19 @@ def __init__(self, toolbox, key, language, owner=None):
self._completions_available.connect(self._display_completions)
self._restarted.connect(self._handle_restarted)
self._killed.connect(self._do_set_killed)
+ self._execution_manager = None
+ self._q = multiprocessing.Queue()
+ self._logger = QueueLogger(self._q, "DetachedBasicConsole", None, {})
+ self._console = console
+ self.detached_console_id = None
def closeEvent(self, ev):
super().closeEvent(ev)
+ self.shutdown_executor()
+ if isinstance(self.parent(), ConsoleWindow):
+ self.parent().close()
+
+ def shutdown_executor(self):
self._executor.shutdown()
def name(self):
@@ -263,9 +285,9 @@ def focusInEvent(self, ev):
def mouseMoveEvent(self, ev):
super().mouseMoveEvent(ev)
if self.anchorAt(ev.position().toPoint()):
- self.viewport().setCursor(Qt.PointingHandCursor)
+ self.viewport().setCursor(Qt.CursorShape.PointingHandCursor)
else:
- self.viewport().setCursor(Qt.IBeamCursor)
+ self.viewport().setCursor(Qt.CursorShape.IBeamCursor)
def mousePressEvent(self, ev):
super().mousePressEvent(ev)
@@ -279,7 +301,7 @@ def mouseReleaseEvent(self, ev):
if text_buffer is None:
return
cursor = self.cursorForPosition(ev.position().toPoint())
- cursor.select(cursor.BlockUnderCursor)
+ cursor.select(cursor.SelectionType.BlockUnderCursor)
cursor.removeSelectedText()
cursor.beginEditBlock()
while text_buffer:
@@ -398,9 +420,9 @@ def _flush_text_buffer(self):
def _make_prompt(self):
text_format = QTextCharFormat()
if self._language == "julia":
- prompt = "\njulia> "
- text_format.setForeground(Qt.darkGreen)
- text_format.setFontWeight(QFont.Bold)
+ prompt = "julia> "
+ text_format.setForeground(Qt.GlobalColor.darkGreen)
+ text_format.setFontWeight(QFont.Weight.Bold)
elif self._language == "python":
prompt = ">>> "
else:
@@ -548,13 +570,13 @@ def key_press_event(self, ev):
"""
self._at_bottom = True
text = self._get_current_text()
- if ev.key() in (Qt.Key_Return, Qt.Key_Enter):
+ if ev.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
self._issue_command(text)
- elif ev.key() == Qt.Key_Up:
+ elif ev.key() == Qt.Key.Key_Up:
self._move_history(text, True)
- elif ev.key() == Qt.Key_Down:
+ elif ev.key() == Qt.Key.Key_Down:
self._move_history(text, False)
- elif ev.key() == Qt.Key_Tab:
+ elif ev.key() == Qt.Key.Key_Tab:
self._autocomplete(text)
else:
return False
@@ -585,12 +607,12 @@ def _issue_command(self, text):
Args:
text (str)
"""
+ if not text.strip(): # Don't send empty command to executor
+ self._make_prompt_block(prompt=self._prompt)
+ return
self._executor.submit(self._do_check_command, text)
def _do_check_command(self, text):
- if not text.strip(): # Don't send empty command to execution manager
- self._make_prompt_block(prompt=self._prompt)
- return
engine_mngr = self.create_engine_manager()
if not engine_mngr:
return
@@ -769,6 +791,95 @@ def contextMenuEvent(self, ev):
self._extend_menu(menu)
menu.exec(ev.globalPos())
+ def request_start_kernel(self, exec_path, julia_project=None):
+ """Requests Spine Engine to launch a persistent kernel manager for the given Python.
+
+ Args:
+ exec_path (str): Abs. path to kernel file (e.g. ../../julia.exe or ../../python.exe)
+ julia_project (str): Path to Julia environment
+
+ Returns:
+ str or None: Kernel manager key if kernel manager was launched successfully, None otherwise
+ """
+ args = [exec_path]
+ if self._language == "python":
+ manager_class = PythonPersistentExecutionManager
+ elif self._language == "julia":
+ manager_class = JuliaPersistentExecutionManager
+ if julia_project:
+ args += ["--project=" + julia_project]
+ else:
+ self._logger.msg_error.emit(f"Unsupported console language '{self._language}'")
+ return None
+ self._execution_manager = manager_class(
+ self._logger, args, [], f"Detached Basic {self._language.capitalize()} Console", False, None
+ )
+ try:
+ msg_type, msg = self._q.get(timeout=20) # Blocks until msg (tuple(str, dict) is received, or timeout.
+ except Empty:
+ msg_type, msg = "No response from Engine", {}
+ if msg_type != "persistent_execution_msg":
+ self._toolbox.msg_error.emit(f"Starting console failed: {msg_type} [{msg}]")
+ self.release_exec_mngr_resources()
+ return None
+ else:
+ retval = self._handle_exec_mngr_started_msg(msg)
+ self.detached_console_id = exec_path
+ return retval
+
+ def insert_banner(self, language):
+ """Inserts banner for the detached Python console on Windows.
+
+ Note: Julia consoles on Windows are missing the banner. You can get the banner
+ in Julia 1.11 with REPL.banner() but this function doesn't exist on older Julias
+ (e.g. Julia 1.8). On older Julias, you can get the banner using the Base.banner()
+ function, which doesn't exist on Julia 1.11.
+ """
+ if sys.platform != "win32":
+ return
+ if language == "python":
+ engine_mngr = self.create_engine_manager()
+ if not engine_mngr:
+ return
+ sys_version = ""
+ sys_platform = ""
+ for msg in engine_mngr.issue_persistent_command(self._key, "sys.version"):
+ if msg["type"] == "stdout":
+ sys_version = msg["data"]
+ for msg in engine_mngr.issue_persistent_command(self._key, "sys.platform"):
+ if msg["type"] == "stdout":
+ sys_platform = msg["data"]
+ banner = "Python " + sys_version.replace("'", "") + " on " + sys_platform.replace("'", "")
+ self._insert_text_before_prompt(banner)
+
+ def release_exec_mngr_resources(self):
+ """Closes _io.TextIOWrapper files."""
+ if self._execution_manager is not None:
+ self._execution_manager.std_out.close()
+ self._execution_manager.std_err.close()
+ self._execution_manager = None
+
+ def _handle_exec_mngr_started_msg(self, msg):
+ """Handles the response message from PythonPersistentExecutionManager.
+
+ Args:
+ msg (dict): Message with item_name, type, etc. keys
+
+ Returns:
+ str or None: Persistent process key if engine started the requested kernel
+ manager successfully, None otherwise.
+ """
+ if msg["type"] == "persistent_started":
+ self._key = msg["key"]
+ self.parent().set_key(self._key)
+ return self._key
+ if msg["type"] == "persistent_failed_to_start":
+ self._toolbox.msg_error.emit(f"Basic Console failed to start:
{msg}")
+ return None
+ else:
+ self._toolbox.msg.emit(f"Unhandled message: {msg}")
+ return None
+
# Translated from
# https://code.qt.io/cgit/qt-creator/qt-creator.git/tree/src/libs/utils/ansiescapecodehandler.cpp?h=master
@@ -787,10 +898,10 @@ def _make_default_format(self):
default_format.setForeground(self._fg_color)
return default_format
- def endFormatScope(self):
+ def end_format_scope(self):
self._previous_format_closed = True
- def setFormatScope(self, char_format):
+ def set_format_scope(self, char_format):
self._previous_format = char_format
self._previous_format_closed = False
@@ -880,55 +991,55 @@ class AnsiEscapeCode:
stripped_text = stripped_text[1:]
if not numbers:
char_format = self._make_default_format()
- self.endFormatScope()
+ self.end_format_scope()
for i in range(len(numbers)): # pylint: disable=consider-using-enumerate
code = int(numbers[i])
if AnsiEscapeCode.TextColorStart <= code <= AnsiEscapeCode.TextColorEnd:
char_format.setForeground(_ansi_color(code - AnsiEscapeCode.TextColorStart))
- self.setFormatScope(char_format)
+ self.set_format_scope(char_format)
elif AnsiEscapeCode.BrightTextColorStart <= code <= AnsiEscapeCode.BrightTextColorEnd:
char_format.setForeground(_ansi_color(code - AnsiEscapeCode.BrightTextColorStart, bright=True))
- self.setFormatScope(char_format)
+ self.set_format_scope(char_format)
elif AnsiEscapeCode.BackgroundColorStart <= code <= AnsiEscapeCode.BackgroundColorEnd:
char_format.setBackground(_ansi_color(code - AnsiEscapeCode.BackgroundColorStart))
- self.setFormatScope(char_format)
+ self.set_format_scope(char_format)
elif AnsiEscapeCode.BrightBackgroundColorStart <= code <= AnsiEscapeCode.BrightBackgroundColorEnd:
char_format.setBackground(
_ansi_color(code - AnsiEscapeCode.BrightBackgroundColorStart, bright=True)
)
- self.setFormatScope(char_format)
+ self.set_format_scope(char_format)
else:
if code == AnsiEscapeCode.ResetFormat:
char_format = self._make_default_format()
- self.endFormatScope()
+ self.end_format_scope()
break
if code == AnsiEscapeCode.BoldText:
- char_format.setFontWeight(QFont.Bold)
- self.setFormatScope(char_format)
+ char_format.setFontWeight(QFont.Weight.Bold)
+ self.set_format_scope(char_format)
break
if code == AnsiEscapeCode.FaintText:
- char_format.setFontWeight(QFont.Light)
- self.setFormatScope(char_format)
+ char_format.setFontWeight(QFont.Weight.Light)
+ self.set_format_scope(char_format)
break
if code == AnsiEscapeCode.ItalicText:
char_format.setFontItalic(True)
- self.setFormatScope(char_format)
+ self.set_format_scope(char_format)
break
if code == AnsiEscapeCode.NormalIntensity:
- char_format.setFontWeight(QFont.Normal)
- self.setFormatScope(char_format)
+ char_format.setFontWeight(QFont.Weight.Normal)
+ self.set_format_scope(char_format)
break
if code == AnsiEscapeCode.NotItalic:
char_format.setFontItalic(False)
- self.setFormatScope(char_format)
+ self.set_format_scope(char_format)
break
if code == AnsiEscapeCode.DefaultTextColor:
char_format.setForeground(self._fg_color)
- self.setFormatScope(char_format)
+ self.set_format_scope(char_format)
break
if code == AnsiEscapeCode.DefaultBackgroundColor:
char_format.setBackground(self._bg_color)
- self.setFormatScope(char_format)
+ self.set_format_scope(char_format)
break
if code == AnsiEscapeCode.RgbBackgroundColor:
# See http://en.wikipedia.org/wiki/ANSI_escape_code#Colors
@@ -944,7 +1055,7 @@ class AnsiEscapeCode:
char_format.setForeground(color)
else:
char_format.setBackground(color)
- self.setFormatScope(char_format)
+ self.set_format_scope(char_format)
i += 3
break
if j == 5:
@@ -969,7 +1080,7 @@ class AnsiEscapeCode:
char_format.setForeground(color)
else:
char_format.setBackground(color)
- self.setFormatScope(char_format)
+ self.set_format_scope(char_format)
i += 1
break
break
@@ -984,3 +1095,25 @@ def _ansi_color(code, bright=False):
green = on if code & 2 else off
blue = on if code & 4 else off
return QColor(red, green, blue)
+
+
+class ConsoleWindow(QWidget):
+ """A window for displaying a detached basic console."""
+
+ console_closed = Signal(str)
+
+ def __init__(self, icon, language):
+ super().__init__(parent=None, f=Qt.WindowType.Window)
+ self.setWindowIcon(icon)
+ self.resize(600, 350)
+ self.lay_out = QVBoxLayout(self)
+ self.lay_out.setContentsMargins(0, 0, 0, 0)
+ self._key = None
+ self.setWindowTitle(f"{language.capitalize()}" + " Basic Console [Detached]")
+ self.show()
+
+ def set_key(self, key):
+ self._key = key
+
+ def closeEvent(self, event):
+ self.console_closed.emit(self._key)
diff --git a/tests/mock_helpers.py b/tests/mock_helpers.py
index 78370e99a..238e1e638 100644
--- a/tests/mock_helpers.py
+++ b/tests/mock_helpers.py
@@ -67,7 +67,6 @@ def create_toolboxui_with_project(project_dir):
mock.patch("spinetoolbox.ui_main.QSettings.setValue"),
mock.patch("spinetoolbox.ui_main.QSettings.sync"),
mock.patch("spinetoolbox.plugin_manager.PluginManager.load_installed_plugins"),
- mock.patch("spinetoolbox.ui_main.QScrollArea.setWidget"),
):
mock_qsettings_value.side_effect = qsettings_value_side_effect
mock_set_app_style.return_value = True
diff --git a/tests/test_ToolboxUI.py b/tests/test_ToolboxUI.py
index b7a965640..78064e68a 100644
--- a/tests/test_ToolboxUI.py
+++ b/tests/test_ToolboxUI.py
@@ -11,6 +11,7 @@
######################################################################################################################
"""Unit tests for ToolboxUI class."""
+import sys
from contextlib import contextmanager
import json
import os
@@ -28,7 +29,7 @@
from spinetoolbox.project_item.project_item import ProjectItem
from spinetoolbox.resources_icons_rc import qInitResources
import spinetoolbox.ui_main
-from spinetoolbox.widgets.persistent_console_widget import PersistentConsoleWidget
+from spinetoolbox.widgets.persistent_console_widget import PersistentConsoleWidget, ConsoleWindow
from spinetoolbox.widgets.project_item_drag import NiceButton, ProjectItemDragMixin
from .mock_helpers import (
TestCaseWithQApplication,
@@ -219,7 +220,7 @@ def test_prevent_project_closing_with_unsaved_changes(self):
# Make sure that the test uses LocalSpineEngineManager
mock_qsettings_value.side_effect = qsettings_value_side_effect
# Selecting cancel on the project close confirmation
- with mock.patch.object(QMessageBox, "exec", return_value=QMessageBox.Cancel):
+ with mock.patch.object(QMessageBox, "exec", return_value=QMessageBox.StandardButton.Cancel):
self.assertFalse(self.toolbox.close_project())
mock_qsettings_value.assert_called()
with (
@@ -227,7 +228,7 @@ def test_prevent_project_closing_with_unsaved_changes(self):
mock.patch("spinetoolbox.project.create_dir"),
mock.patch("spinetoolbox.project_item.project_item.create_dir"),
mock.patch("spinetoolbox.ui_main.ToolboxUI.update_recent_projects"),
- mock.patch.object(QMessageBox, "exec", return_value=QMessageBox.Cancel),
+ mock.patch.object(QMessageBox, "exec", return_value=QMessageBox.StandardButton.Cancel),
):
# Selecting cancel on the project close confirmation
with mock.patch("spinetoolbox.ui_main.ToolboxUI.add_warning_message") as warning_msg:
@@ -336,7 +337,7 @@ def test_selection_in_design_view_1(self):
dc1_item = self.toolbox.project().get_item(dc1)
dc1_center_point = self.find_click_point_of_pi(dc1_item, gv) # Center point in graphics view viewport coords.
# Simulate mouse click on Data Connection in Design View
- QTest.mouseClick(gv.viewport(), Qt.LeftButton, Qt.NoModifier, dc1_center_point)
+ QTest.mouseClick(gv.viewport(), Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier, dc1_center_point)
self.assertEqual(1, len(gv.scene().selectedItems()))
# Active project item should be DC1
self.assertEqual(self.toolbox.project().get_item(dc1), self.toolbox.active_project_item)
@@ -359,9 +360,9 @@ def test_selection_in_design_view_2(self):
dc1_center_point = self.find_click_point_of_pi(dc1_item, gv)
dc2_center_point = self.find_click_point_of_pi(dc2_item, gv)
# Mouse click on dc1
- QTest.mouseClick(gv.viewport(), Qt.LeftButton, Qt.NoModifier, dc1_center_point)
+ QTest.mouseClick(gv.viewport(), Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier, dc1_center_point)
# Then mouse click on dc2
- QTest.mouseClick(gv.viewport(), Qt.LeftButton, Qt.NoModifier, dc2_center_point)
+ QTest.mouseClick(gv.viewport(), Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier, dc2_center_point)
self.assertEqual(1, len(gv.scene().selectedItems()))
# Active project item should be DC2
self.assertEqual(self.toolbox.project().get_item(dc2), self.toolbox.active_project_item)
@@ -378,9 +379,9 @@ def test_selection_in_design_view_3(self):
dc1_item = self.toolbox.project().get_item(dc1)
dc1_center_point = self.find_click_point_of_pi(dc1_item, gv)
# Mouse click on dc1
- QTest.mouseClick(gv.viewport(), Qt.LeftButton, Qt.NoModifier, dc1_center_point)
+ QTest.mouseClick(gv.viewport(), Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier, dc1_center_point)
# Then mouse click somewhere else in Design View (not on project item)
- QTest.mouseClick(gv.viewport(), Qt.LeftButton, Qt.NoModifier, QPoint(1, 1))
+ QTest.mouseClick(gv.viewport(), Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier, QPoint(1, 1))
self.assertEqual(0, len(gv.scene().selectedItems())) # No items in design view should be selected
# Active project item should be None
self.assertIsNone(self.toolbox.active_project_item)
@@ -410,7 +411,7 @@ def test_selection_in_design_view_4(self):
self.assertEqual(1, len(links))
link_center_point = self.find_click_point_of_link(links[0], gv)
# Mouse click on link
- QTest.mouseClick(gv.viewport(), Qt.LeftButton, Qt.NoModifier, link_center_point)
+ QTest.mouseClick(gv.viewport(), Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier, link_center_point)
# One item should be selected in Design View (the Link)
selected_items = gv.scene().selectedItems()
self.assertEqual(1, len(selected_items))
@@ -445,9 +446,9 @@ def test_selection_in_design_view_5(self):
dc1_center_point = self.find_click_point_of_pi(dc1_item, gv)
link_center_point = self.find_click_point_of_link(links[0], gv)
# Mouse click on dc1
- QTest.mouseClick(gv.viewport(), Qt.LeftButton, Qt.NoModifier, dc1_center_point)
+ QTest.mouseClick(gv.viewport(), Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier, dc1_center_point)
# Mouse click on link
- QTest.mouseClick(gv.viewport(), Qt.LeftButton, Qt.NoModifier, link_center_point)
+ QTest.mouseClick(gv.viewport(), Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier, link_center_point)
# One item should be selected in Design View (the Link)
selected_items = gv.scene().selectedItems()
self.assertEqual(1, len(selected_items))
@@ -475,9 +476,13 @@ def test_selection_in_design_view_6(self):
dc1_center_point = self.find_click_point_of_pi(dc1_item, gv)
dc2_center_point = self.find_click_point_of_pi(dc2_item, gv)
# Mouse click on dc1
- QTest.mouseClick(gv.viewport(), Qt.LeftButton, Qt.ControlModifier, dc1_center_point)
+ QTest.mouseClick(
+ gv.viewport(), Qt.MouseButton.LeftButton, Qt.KeyboardModifier.ControlModifier, dc1_center_point
+ )
# Then mouse click on dc2
- QTest.mouseClick(gv.viewport(), Qt.LeftButton, Qt.ControlModifier, dc2_center_point)
+ QTest.mouseClick(
+ gv.viewport(), Qt.MouseButton.LeftButton, Qt.KeyboardModifier.ControlModifier, dc2_center_point
+ )
self.assertEqual(2, len(gv.scene().selectedItems()))
# Active project item should be None
self.assertIsNone(self.toolbox.active_project_item)
@@ -486,7 +491,9 @@ def test_drop_invalid_drag_on_design_view(self):
mime_data = QMimeData()
gv = self.toolbox.ui.graphicsView
pos = QPoint(0, 0)
- event = QDropEvent(pos, Qt.CopyAction, mime_data, Qt.NoButton, Qt.NoModifier)
+ event = QDropEvent(
+ pos, Qt.DropAction.CopyAction, mime_data, Qt.MouseButton.NoButton, Qt.KeyboardModifier.NoModifier
+ )
with (
mock.patch("PySide6.QtWidgets.QGraphicsSceneDragDropEvent.source") as mock_drop_event_source,
mock.patch.object(self.toolbox, "project"),
@@ -505,7 +512,9 @@ def test_drop_project_item_on_design_view(self):
gv = self.toolbox.ui.graphicsView
scene_pos = QPointF(44, 20)
pos = gv.mapFromScene(scene_pos)
- event = QDropEvent(pos, Qt.CopyAction, mime_data, Qt.NoButton, Qt.NoModifier)
+ event = QDropEvent(
+ pos, Qt.DropAction.CopyAction, mime_data, Qt.MouseButton.NoButton, Qt.KeyboardModifier.NoModifier
+ )
with (
mock.patch("PySide6.QtWidgets.QGraphicsSceneDragDropEvent.source") as mock_drop_event_source,
mock.patch.object(self.toolbox, "project"),
@@ -725,11 +734,58 @@ def test_filtered_persistent_consoles_requested(self):
ind = view.model().index(row, 0)
view.scrollTo(ind)
rect = view.visualRect(ind)
- QTest.mouseClick(view.viewport(), Qt.LeftButton, Qt.ControlModifier, rect.center())
+ QTest.mouseClick(
+ view.viewport(), Qt.MouseButton.LeftButton, Qt.KeyboardModifier.ControlModifier, rect.center()
+ )
console = self.toolbox.ui.splitter_console.widget(1)
self.assertTrue(isinstance(console, PersistentConsoleWidget))
self.assertEqual(console.owners, {item})
+ def test_detached_python_basic_console(self):
+ with (
+ mock.patch("spinetoolbox.widgets.persistent_console_widget.ConsoleWindow.show") as mock_show,
+ mock.patch("spinetoolbox.ui_main.resolve_python_interpreter") as mock_resolve_python,
+ ):
+ mock_resolve_python.return_value = sys.executable
+ self.toolbox.ui.actionStart_default_python_in_basic_console.trigger()
+ mock_resolve_python.assert_called()
+ mock_show.assert_called()
+ self.assertEqual(len(self.toolbox._persistent_consoles), 1)
+ pcw = self.toolbox._persistent_consoles[list(self.toolbox._persistent_consoles.keys())[0]]
+ self.assertIsInstance(pcw, PersistentConsoleWidget)
+ self.assertIsInstance(pcw.parent(), ConsoleWindow)
+ self.assertTrue(pcw.detached_console_id, sys.executable)
+ pcw.parent().close() # Send close event to ConsoleWindow
+ self.assertEqual(len(self.toolbox._persistent_consoles), 0)
+
+ def test_detached_julia_basic_console(self):
+ with (
+ mock.patch("spinetoolbox.widgets.persistent_console_widget.ConsoleWindow.show") as mock_show,
+ mock.patch("spinetoolbox.ui_main.resolve_julia_executable") as mock_resolve_julia,
+ mock.patch("spinetoolbox.ui_main.resolve_julia_project") as mock_resolve_julia_project,
+ mock.patch(
+ "spinetoolbox.widgets.persistent_console_widget.JuliaPersistentExecutionManager"
+ ) as mock_julia_manager_class,
+ mock.patch("spinetoolbox.widgets.persistent_console_widget.multiprocessing.Queue") as mock_queue,
+ ):
+ mock_resolve_julia.return_value = "/some/julia"
+ mock_resolve_julia_project.return_value = "/some/julia/env"
+ mock_queue.return_value = MockQueue()
+ self.toolbox.ui.actionStart_default_julia_in_basic_console.trigger()
+ mock_show.assert_called()
+ mock_resolve_julia.assert_called()
+ mock_resolve_julia_project.assert_called()
+ mock_julia_manager_class.assert_called_once()
+ self.assertEqual(mock_julia_manager_class.call_args.args[1], ["/some/julia", "--project=/some/julia/env"])
+ mock_queue.assert_called()
+ self.assertEqual(len(self.toolbox._persistent_consoles), 1)
+ pcw = self.toolbox._persistent_consoles[list(self.toolbox._persistent_consoles.keys())[0]]
+ self.assertIsInstance(pcw, PersistentConsoleWidget)
+ self.assertIsInstance(pcw.parent(), ConsoleWindow)
+ self.assertTrue(pcw.detached_console_id, "/some/julia")
+ pcw.parent().close()
+ self.assertEqual(len(self.toolbox._persistent_consoles), 0)
+
def test_closeEvent_saves_window_state(self):
self.toolbox._qsettings = mock.NonCallableMagicMock()
self.toolbox._perform_pre_exit_tasks = mock.MagicMock(return_value=True)
@@ -890,5 +946,10 @@ def exec(self, pos):
return True
+class MockQueue:
+ def get(self, timeout=1):
+ return "persistent_execution_msg", {"type": "persistent_started", "key": "123"}
+
+
if __name__ == "__main__":
unittest.main()