From 0fee65dd991e1bd186675e4a51e37875cb641df7 Mon Sep 17 00:00:00 2001
From: dalthviz <16781833+dalthviz@users.noreply.github.com>
Date: Wed, 2 Oct 2024 18:56:07 -0500
Subject: [PATCH 01/19] IPython Console: Handle case when kernel fault file
doesn't exist and show error with info explainig no connection was possible
---
spyder/plugins/ipythonconsole/widgets/shell.py | 17 +++++++++++++----
1 file changed, 13 insertions(+), 4 deletions(-)
diff --git a/spyder/plugins/ipythonconsole/widgets/shell.py b/spyder/plugins/ipythonconsole/widgets/shell.py
index 21f805adde4..ea7fa03da73 100644
--- a/spyder/plugins/ipythonconsole/widgets/shell.py
+++ b/spyder/plugins/ipythonconsole/widgets/shell.py
@@ -1395,10 +1395,19 @@ def _kernel_restarted_message(self, died=True):
and not self.is_remote()
):
# The kernel might never restart, show position of fault file
- msg += (
- "\n" + _("Its crash file is located at:") + " "
- + self.kernel_handler.fault_filename()
- )
+ # if available else show kernel error
+ if self.kernel_handler.fault_filename():
+ msg += (
+ "\n" + _("Its crash file is located at:") + " "
+ + self.kernel_handler.fault_filename()
+ )
+ else:
+ self.ipyclient.show_kernel_error(
+ _("Unable to connect with the kernel. If you are trying "
+ "to connect to an existing kernel check that the "
+ "connection file actually corresponds with the kernel "
+ "you want to connect to")
+ )
self._append_html(f"
{msg}
", before_prompt=False)
self.insert_horizontal_ruler()
From c4ae9441f6d925cde79b2e0f1c9823c5b1c1b357 Mon Sep 17 00:00:00 2001
From: Ryan Clary <9618975+mrclary@users.noreply.github.com>
Date: Tue, 24 Sep 2024 22:06:53 -0700
Subject: [PATCH 02/19] Check that the download asset is available before
alerting the user to an available update. Create a function that determines
the asset name, update type, and url, depending on the latest release and the
current Spyder version. Fixes spyder-ide/spyder#22566
---
.../plugins/updatemanager/widgets/update.py | 26 ++-----
spyder/plugins/updatemanager/workers.py | 71 +++++++++++++++++--
2 files changed, 70 insertions(+), 27 deletions(-)
diff --git a/spyder/plugins/updatemanager/widgets/update.py b/spyder/plugins/updatemanager/widgets/update.py
index ffb656142b2..5bcdd085d1b 100644
--- a/spyder/plugins/updatemanager/widgets/update.py
+++ b/spyder/plugins/updatemanager/widgets/update.py
@@ -10,14 +10,12 @@
import logging
import os
import os.path as osp
-import platform
import shutil
import subprocess
import sys
from sysconfig import get_path
# Third-party imports
-from packaging.version import parse
from qtpy.QtCore import Qt, QThread, QTimer, Signal
from qtpy.QtWidgets import QMessageBox, QWidget, QProgressBar, QPushButton
from spyder_kernels.utils.pythonenv import is_conda_env
@@ -28,6 +26,7 @@
from spyder.api.translations import _
from spyder.config.base import is_conda_based_app
from spyder.plugins.updatemanager.workers import (
+ get_asset_info,
WorkerUpdate,
WorkerDownloadInstaller
)
@@ -251,28 +250,11 @@ def _process_check_update(self):
def _set_installer_path(self):
"""Set the temp file path for the downloaded installer."""
- if parse(__version__).major < parse(self.latest_release).major:
- self.update_type = 'major'
- elif parse(__version__).minor < parse(self.latest_release).minor:
- self.update_type = 'minor'
- else:
- self.update_type = 'micro'
-
- mach = platform.machine().lower().replace("amd64", "x86_64")
-
- if self.update_type == 'major' or not is_conda_based_app():
- if os.name == 'nt':
- plat, ext = 'Windows', 'exe'
- if sys.platform == 'darwin':
- plat, ext = 'macOS', 'pkg'
- if sys.platform.startswith('linux'):
- plat, ext = 'Linux', 'sh'
- fname = f'Spyder-{plat}-{mach}.{ext}'
- else:
- fname = 'spyder-conda-lock.zip'
+ asset_info = get_asset_info(self.latest_release)
+ self.update_type = asset_info['update_type']
dirname = osp.join(get_temp_dir(), 'updates', self.latest_release)
- self.installer_path = osp.join(dirname, fname)
+ self.installer_path = osp.join(dirname, asset_info['name'])
self.installer_size_path = osp.join(dirname, "size")
logger.info(f"Update type: {self.update_type}")
diff --git a/spyder/plugins/updatemanager/workers.py b/spyder/plugins/updatemanager/workers.py
index 72fcfe244d7..0a8add499d7 100644
--- a/spyder/plugins/updatemanager/workers.py
+++ b/spyder/plugins/updatemanager/workers.py
@@ -9,6 +9,7 @@
import logging
import os
import os.path as osp
+import platform
import shutil
import sys
from time import sleep
@@ -24,7 +25,9 @@
# Local imports
from spyder import __version__
-from spyder.config.base import _, is_stable_version, running_in_ci
+from spyder.config.base import (
+ _, is_conda_based_app, is_stable_version, running_in_ci
+)
from spyder.utils.conda import get_spyder_conda_channel
from spyder.utils.programs import check_version
@@ -66,6 +69,54 @@ def _rate_limits(page):
logger.debug("\n\t".join(msg_items))
+def get_asset_info(release):
+ """
+ Get the name, update type, and download URL for the asset of the given
+ release.
+
+ Parameters
+ ----------
+ release: str
+ Release version
+
+ Returns
+ -------
+ asset_info: dict
+ name: str
+ Filename with extension of the release asset to download.
+ update_type: str
+ Type of update. One of {'major', 'minor', 'micro'}.
+ url: str
+ Download URL for the asset.
+ """
+ if parse(__version__).major < parse(release).major:
+ update_type = 'major'
+ elif parse(__version__).minor < parse(release).minor:
+ update_type = 'minor'
+ else:
+ update_type = 'micro'
+
+ mach = platform.machine().lower().replace("amd64", "x86_64")
+
+ if update_type == 'major' or not is_conda_based_app():
+ if os.name == 'nt':
+ plat, ext = 'Windows', 'exe'
+ if sys.platform == 'darwin':
+ plat, ext = 'macOS', 'pkg'
+ if sys.platform.startswith('linux'):
+ plat, ext = 'Linux', 'sh'
+ name = f'Spyder-{plat}-{mach}.{ext}'
+ else:
+ name = 'spyder-conda-lock.zip'
+
+ url = (
+ 'https://github.com/spyder-ide/spyder/releases/download/'
+ f'v{release}/{name}'
+ )
+
+ return {'name': name, 'update_type': update_type, 'url': url}
+
+
class UpdateDownloadCancelledException(Exception):
"""Download for installer to update was cancelled."""
pass
@@ -182,6 +233,18 @@ def start(self):
self.releases.sort(key=parse)
self._check_update_available()
+
+ # Check if asset is available for download
+ if url.endswith('releases') and self.update_available:
+ asset_info = get_asset_info(self.latest_release)
+ page = requests.head(asset_info['url'], headers=headers)
+ _rate_limits(page)
+ if page.status_code == 404:
+ # The asset is not available
+ self.latest_release = __version__
+ self.update_available = False
+ logger.debug(f"Asset is not available: {url}")
+
except SSLError as err:
error_msg = SSL_ERROR_MSG
logger.warning(err, exc_info=err)
@@ -252,10 +315,8 @@ def _progress_reporter(self, progress, total_size):
def _download_installer(self):
"""Donwload Spyder installer."""
- url = (
- 'https://github.com/spyder-ide/spyder/releases/download/'
- f'v{self.latest_release}/{osp.basename(self.installer_path)}'
- )
+ asset_info = get_asset_info(self.latest_release)
+ url = asset_info['url']
logger.info(f"Downloading {url} to {self.installer_path}")
dirname = osp.dirname(self.installer_path)
From 071f47494da85bfaaa216803f84eafcec9940084 Mon Sep 17 00:00:00 2001
From: Ryan Clary <9618975+mrclary@users.noreply.github.com>
Date: Wed, 25 Sep 2024 18:14:34 -0700
Subject: [PATCH 03/19] Use osascript in order not to pollute shell history on
macOS
---
spyder/plugins/updatemanager/widgets/update.py | 17 +++++++++--------
1 file changed, 9 insertions(+), 8 deletions(-)
diff --git a/spyder/plugins/updatemanager/widgets/update.py b/spyder/plugins/updatemanager/widgets/update.py
index 5bcdd085d1b..6e4714ef2b8 100644
--- a/spyder/plugins/updatemanager/widgets/update.py
+++ b/spyder/plugins/updatemanager/widgets/update.py
@@ -454,14 +454,15 @@ def start_install(self):
if os.name == 'nt':
cmd = ['start', '"Update Spyder"'] + sub_cmd
elif sys.platform == 'darwin':
- # Terminal cannot accept a command with arguments therefore
- # create a temporary script
- tmpscript = osp.join(get_temp_dir(), 'tmp_install.sh')
- with open(tmpscript, 'w') as f:
- f.write(' '.join(sub_cmd))
- os.chmod(tmpscript, 0o711) # set executable permissions
-
- cmd = ['open', '-b', 'com.apple.terminal', tmpscript]
+ # Terminal cannot accept a command with arguments. Creating a
+ # wrapper script pollutes the shell history. Best option is to
+ # use osascript
+ sub_cmd_str = ' '.join(sub_cmd)
+ cmd = [
+ "osascript", "-e",
+ ("""'tell application "Terminal" to do script"""
+ f""" "set +o history; {sub_cmd_str}; exit;"'"""),
+ ]
else:
programs = [
{'cmd': 'gnome-terminal', 'exe-opt': '--window --'},
From cc1fa001308d4355c4ee801cf0b592d8def1020c Mon Sep 17 00:00:00 2001
From: Ryan Clary <9618975+mrclary@users.noreply.github.com>
Date: Thu, 26 Sep 2024 16:30:51 -0700
Subject: [PATCH 04/19] Set WorkerUpdate.channel to pypi if Spyder is not
installed by conda. Fixes spyder-ide/spyder#22572
---
spyder/plugins/updatemanager/widgets/update.py | 2 +-
spyder/plugins/updatemanager/workers.py | 16 ++++++++++------
2 files changed, 11 insertions(+), 7 deletions(-)
diff --git a/spyder/plugins/updatemanager/widgets/update.py b/spyder/plugins/updatemanager/widgets/update.py
index 6e4714ef2b8..651cbc84e88 100644
--- a/spyder/plugins/updatemanager/widgets/update.py
+++ b/spyder/plugins/updatemanager/widgets/update.py
@@ -612,7 +612,7 @@ def manual_update_messagebox(parent, latest_release, channel):
).format(dont_mix_pip_conda_video)
else:
if channel == 'pkgs/main':
- channel = ''
+ channel = '-c defaults'
else:
channel = f'-c {channel}'
diff --git a/spyder/plugins/updatemanager/workers.py b/spyder/plugins/updatemanager/workers.py
index 0a8add499d7..4554af2e1fc 100644
--- a/spyder/plugins/updatemanager/workers.py
+++ b/spyder/plugins/updatemanager/workers.py
@@ -172,6 +172,7 @@ def __init__(self, stable_only):
self.releases = None
self.update_available = False
self.error = None
+ self.channel = None
def _check_update_available(self):
"""Checks if there is an update available from releases."""
@@ -200,12 +201,15 @@ def start(self):
error_msg = None
url = 'https://api.github.com/repos/spyder-ide/spyder/releases'
- # If Spyder is installed from defaults channel (pkgs/main), then use
- # that channel to get updates. The defaults channel can be far behind
- # our latest release
- if is_conda_env(sys.prefix):
- channel, channel_url = get_spyder_conda_channel()
- if channel == "pkgs/main":
+ if not is_conda_based_app():
+ self.channel = "pypi" # Default channel if not conda
+ if is_conda_env(sys.prefix):
+ self.channel, channel_url = get_spyder_conda_channel()
+
+ # If Spyder is installed from defaults channel (pkgs/main), then
+ # use that channel to get updates. The defaults channel can be far
+ # behind our latest release.
+ if self.channel == "pkgs/main":
url = channel_url + '/channeldata.json'
headers = {}
From 6a40570d77af5a6f7913b1185bcda6786cdfba0b Mon Sep 17 00:00:00 2001
From: Ryan Clary <9618975+mrclary@users.noreply.github.com>
Date: Thu, 26 Sep 2024 17:14:19 -0700
Subject: [PATCH 05/19] UpdateManagerWidget.latest_release,
WorkerUpdate.latest_release, and WorkerDownloadInstaller.latest_release are
now packaging.version.Version objects instead of strings. We can now use
packaging.version mechanisms for comparison, sorting, and stable release
checks.
---
.../plugins/updatemanager/widgets/update.py | 2 +-
spyder/plugins/updatemanager/workers.py | 35 ++++++++-----------
2 files changed, 16 insertions(+), 21 deletions(-)
diff --git a/spyder/plugins/updatemanager/widgets/update.py b/spyder/plugins/updatemanager/widgets/update.py
index 651cbc84e88..7d7d58d65a9 100644
--- a/spyder/plugins/updatemanager/widgets/update.py
+++ b/spyder/plugins/updatemanager/widgets/update.py
@@ -148,7 +148,7 @@ def __init__(self, parent):
def set_status(self, status=NO_STATUS):
"""Set the update manager status."""
- self.sig_set_status.emit(status, self.latest_release)
+ self.sig_set_status.emit(status, str(self.latest_release))
def cleanup_threads(self):
"""Clean up QThreads"""
diff --git a/spyder/plugins/updatemanager/workers.py b/spyder/plugins/updatemanager/workers.py
index 4554af2e1fc..28ab3c7bde1 100644
--- a/spyder/plugins/updatemanager/workers.py
+++ b/spyder/plugins/updatemanager/workers.py
@@ -25,15 +25,13 @@
# Local imports
from spyder import __version__
-from spyder.config.base import (
- _, is_conda_based_app, is_stable_version, running_in_ci
-)
+from spyder.config.base import _, is_conda_based_app, running_in_ci
from spyder.utils.conda import get_spyder_conda_channel
-from spyder.utils.programs import check_version
# Logger setup
logger = logging.getLogger(__name__)
+CURR_VER = parse(__version__)
CONNECT_ERROR_MSG = _(
'Unable to connect to the Spyder update service.'
@@ -76,7 +74,7 @@ def get_asset_info(release):
Parameters
----------
- release: str
+ release: str | packaging.version.Version
Release version
Returns
@@ -89,9 +87,12 @@ def get_asset_info(release):
url: str
Download URL for the asset.
"""
- if parse(__version__).major < parse(release).major:
+ if isinstance(release, str):
+ release = parse(release)
+
+ if CURR_VER.major < release.major:
update_type = 'major'
- elif parse(__version__).minor < parse(release).minor:
+ elif CURR_VER.minor < release.minor:
update_type = 'minor'
else:
update_type = 'micro'
@@ -180,15 +181,11 @@ def _check_update_available(self):
releases = self.releases.copy()
if self.stable_only:
# Only use stable releases
- releases = [r for r in releases if is_stable_version(r)]
+ releases = [r for r in releases if not r.is_prerelease]
logger.debug(f"Available versions: {self.releases}")
- self.latest_release = releases[-1] if releases else __version__
- self.update_available = check_version(
- __version__,
- self.latest_release,
- '<'
- )
+ self.latest_release = max(releases) if releases else CURR_VER
+ self.update_available = CURR_VER < self.latest_release
logger.debug(f"Update available: {self.update_available}")
logger.debug(f"Latest release: {self.latest_release}")
@@ -226,15 +223,13 @@ def start(self):
data = page.json()
if url.endswith('releases'):
# Github url
- self.releases = [
- item['tag_name'].replace('v', '') for item in data
- ]
+ self.releases = [parse(item['tag_name']) for item in data]
else:
# Conda url
spyder_data = data['packages'].get('spyder')
if spyder_data:
- self.releases = [spyder_data["version"]]
- self.releases.sort(key=parse)
+ self.releases = [parse(spyder_data["version"])]
+ self.releases.sort()
self._check_update_available()
@@ -245,7 +240,7 @@ def start(self):
_rate_limits(page)
if page.status_code == 404:
# The asset is not available
- self.latest_release = __version__
+ self.latest_release = CURR_VER
self.update_available = False
logger.debug(f"Asset is not available: {url}")
From 7e51ec27ac3ec43bd20d3f6d085eb3778f60099f Mon Sep 17 00:00:00 2001
From: Ryan Clary <9618975+mrclary@users.noreply.github.com>
Date: Fri, 27 Sep 2024 20:39:19 -0700
Subject: [PATCH 06/19] Check previous releases for available asset if latest
asset is not available.
---
.../plugins/updatemanager/widgets/update.py | 2 +-
spyder/plugins/updatemanager/workers.py | 71 ++++++++++++-------
2 files changed, 46 insertions(+), 27 deletions(-)
diff --git a/spyder/plugins/updatemanager/widgets/update.py b/spyder/plugins/updatemanager/widgets/update.py
index 7d7d58d65a9..9cba9c085e5 100644
--- a/spyder/plugins/updatemanager/widgets/update.py
+++ b/spyder/plugins/updatemanager/widgets/update.py
@@ -253,7 +253,7 @@ def _set_installer_path(self):
asset_info = get_asset_info(self.latest_release)
self.update_type = asset_info['update_type']
- dirname = osp.join(get_temp_dir(), 'updates', self.latest_release)
+ dirname = osp.join(get_temp_dir(), 'updates', str(self.latest_release))
self.installer_path = osp.join(dirname, asset_info['name'])
self.installer_size_path = osp.join(dirname, "size")
diff --git a/spyder/plugins/updatemanager/workers.py b/spyder/plugins/updatemanager/workers.py
index 28ab3c7bde1..bbd06f4341c 100644
--- a/spyder/plugins/updatemanager/workers.py
+++ b/spyder/plugins/updatemanager/workers.py
@@ -170,25 +170,54 @@ def __init__(self, stable_only):
super().__init__()
self.stable_only = stable_only
self.latest_release = None
- self.releases = None
self.update_available = False
self.error = None
self.channel = None
- def _check_update_available(self):
+ def _check_update_available(self, releases, github=True):
"""Checks if there is an update available from releases."""
- # Filter releases
- releases = self.releases.copy()
if self.stable_only:
# Only use stable releases
releases = [r for r in releases if not r.is_prerelease]
- logger.debug(f"Available versions: {self.releases}")
+ logger.debug(f"Available versions: {releases}")
+
+ latest_release = max(releases) if releases else CURR_VER
+ update_available = CURR_VER < latest_release
+
+ logger.debug(f"Latest release: {latest_release}")
+ logger.debug(f"Update available: {update_available}")
+
+ # Check if the asset is available for download.
+ # If the asset is not available, then check the next latest
+ # release, and so on until either a new asset is available or there
+ # is no update available.
+ if github:
+ asset_available = False
+ while update_available and not asset_available:
+ asset_info = get_asset_info(latest_release)
+ page = requests.head(asset_info['url'])
+ if page.status_code == 302:
+ # The asset is found
+ logger.debug(f"Asset available for url: {page.url}")
+ asset_available = True
+ else:
+ # The asset is not available
+ logger.debug(
+ "Asset not available: "
+ f"{page.status_code} Client Error: {page.reason}"
+ f" for url: {page.url}"
+ )
+ asset_available = False
+ releases.remove(latest_release)
+
+ latest_release = max(releases) if releases else CURR_VER
+ update_available = CURR_VER < latest_release
- self.latest_release = max(releases) if releases else CURR_VER
- self.update_available = CURR_VER < self.latest_release
+ logger.debug(f"Latest release: {latest_release}")
+ logger.debug(f"Update available: {update_available}")
- logger.debug(f"Update available: {self.update_available}")
- logger.debug(f"Latest release: {self.latest_release}")
+ self.latest_release = latest_release
+ self.update_available = update_available
def start(self):
"""Main method of the worker."""
@@ -208,6 +237,7 @@ def start(self):
# behind our latest release.
if self.channel == "pkgs/main":
url = channel_url + '/channeldata.json'
+ github = "api.github.com" in url
headers = {}
token = os.getenv('GITHUB_TOKEN')
@@ -221,28 +251,17 @@ def start(self):
page.raise_for_status()
data = page.json()
- if url.endswith('releases'):
+ if github:
# Github url
- self.releases = [parse(item['tag_name']) for item in data]
+ releases = [parse(item['tag_name']) for item in data]
else:
- # Conda url
+ # Conda pkgs/main url
spyder_data = data['packages'].get('spyder')
if spyder_data:
- self.releases = [parse(spyder_data["version"])]
- self.releases.sort()
+ releases = [parse(spyder_data["version"])]
+ releases.sort()
- self._check_update_available()
-
- # Check if asset is available for download
- if url.endswith('releases') and self.update_available:
- asset_info = get_asset_info(self.latest_release)
- page = requests.head(asset_info['url'], headers=headers)
- _rate_limits(page)
- if page.status_code == 404:
- # The asset is not available
- self.latest_release = CURR_VER
- self.update_available = False
- logger.debug(f"Asset is not available: {url}")
+ self._check_update_available(releases, github)
except SSLError as err:
error_msg = SSL_ERROR_MSG
From 96e68631a4a7b980e165d3ae3eb1e876d6e3bf64 Mon Sep 17 00:00:00 2001
From: Ryan Clary <9618975+mrclary@users.noreply.github.com>
Date: Fri, 27 Sep 2024 20:47:00 -0700
Subject: [PATCH 07/19] Update tests. Added test_update_no_asset and
test_get_asset_info
---
.../tests/test_update_manager.py | 59 +++++++++++++++----
1 file changed, 48 insertions(+), 11 deletions(-)
diff --git a/spyder/plugins/updatemanager/tests/test_update_manager.py b/spyder/plugins/updatemanager/tests/test_update_manager.py
index a59e61de4e1..30fac1e39fc 100644
--- a/spyder/plugins/updatemanager/tests/test_update_manager.py
+++ b/spyder/plugins/updatemanager/tests/test_update_manager.py
@@ -6,12 +6,15 @@
import os
import logging
+from packaging.version import parse
import pytest
from spyder.config.base import running_in_ci
from spyder.plugins.updatemanager import workers
-from spyder.plugins.updatemanager.workers import WorkerUpdate, HTTP_ERROR_MSG
+from spyder.plugins.updatemanager.workers import (
+ get_asset_info, WorkerUpdate, HTTP_ERROR_MSG
+)
from spyder.plugins.updatemanager.widgets import update
from spyder.plugins.updatemanager.widgets.update import UpdateManagerWidget
@@ -49,7 +52,7 @@ def test_updates(qtbot, mocker, caplog, version, channel):
mocker.patch.object(
UpdateManagerWidget, "start_update", new=lambda x: None
)
- mocker.patch.object(workers, "__version__", new=version)
+ mocker.patch.object(workers, "CURR_VER", new=parse(version))
mocker.patch.object(
workers, "get_spyder_conda_channel", return_value=channel
)
@@ -77,25 +80,59 @@ def test_updates(qtbot, mocker, caplog, version, channel):
assert update_available
else:
assert not update_available
- assert len(um.update_worker.releases) >= 1
-@pytest.mark.parametrize("release", ["4.0.1", "4.0.1a1"])
+@pytest.mark.parametrize("release", ["6.0.0", "6.0.0b3"])
@pytest.mark.parametrize("version", ["4.0.0a1", "4.0.0"])
@pytest.mark.parametrize("stable_only", [True, False])
def test_update_non_stable(qtbot, mocker, version, release, stable_only):
"""Test we offer unstable updates."""
- mocker.patch.object(workers, "__version__", new=version)
+ mocker.patch.object(workers, "CURR_VER", new=parse(version))
+ release = parse(release)
worker = WorkerUpdate(stable_only)
- worker.releases = [release]
- worker._check_update_available()
+ worker._check_update_available([release])
- update_available = worker.update_available
- if "a" in release and stable_only:
- assert not update_available
+ if release.is_prerelease and stable_only:
+ assert not worker.update_available
else:
- assert update_available
+ assert worker.update_available
+
+
+@pytest.mark.parametrize("version", ["4.0.0", "6.0.0"])
+def test_update_no_asset(qtbot, mocker, version):
+ """Test update availability when asset is not available"""
+ mocker.patch.object(workers, "CURR_VER", new=parse(version))
+
+ releases = [parse("6.0.1"), parse("6.100.0")]
+ worker = WorkerUpdate(True)
+ worker._check_update_available(releases)
+
+ # For both values of version, there should be an update available
+ # However, the available version should be 6.0.1, since there is
+ # no asset for 6.100.0
+ assert worker.update_available
+ assert worker.latest_release == releases[0]
+
+
+@pytest.mark.parametrize(
+ "release,update_type",
+ [("6.0.1", "micro"), ("6.1.0", "minor"), ("7.0.0", "major")]
+)
+@pytest.mark.parametrize("app", [True, False])
+def test_get_asset_info(qtbot, mocker, release, update_type, app):
+ mocker.patch.object(workers, "CURR_VER", new=parse("6.0.0"))
+ mocker.patch.object(workers, "is_conda_based_app", return_value=app)
+
+ info = get_asset_info(release)
+ assert info['update_type'] == update_type
+
+ if update_type == "major" or not app:
+ assert info['url'].endswith(('.exe', '.pkg', '.sh'))
+ assert info['name'].endswith(('.exe', '.pkg', '.sh'))
+ else:
+ assert info['url'].endswith(".zip")
+ assert info['name'].endswith(".zip")
# ---- Test WorkerDownloadInstaller
From 24e8366dea3bab8636e2fd1b0e221b7abfc4d602 Mon Sep 17 00:00:00 2001
From: Ryan Clary <9618975+mrclary@users.noreply.github.com>
Date: Mon, 14 Oct 2024 17:35:50 -0700
Subject: [PATCH 08/19] Apply suggestions from code review
Co-authored-by: Carlos Cordoba
---
.../tests/test_update_manager.py | 20 +++---
.../plugins/updatemanager/widgets/update.py | 2 +-
spyder/plugins/updatemanager/workers.py | 70 ++++++++++++-------
3 files changed, 59 insertions(+), 33 deletions(-)
diff --git a/spyder/plugins/updatemanager/tests/test_update_manager.py b/spyder/plugins/updatemanager/tests/test_update_manager.py
index 30fac1e39fc..3a8da7c6c3f 100644
--- a/spyder/plugins/updatemanager/tests/test_update_manager.py
+++ b/spyder/plugins/updatemanager/tests/test_update_manager.py
@@ -13,7 +13,7 @@
from spyder.config.base import running_in_ci
from spyder.plugins.updatemanager import workers
from spyder.plugins.updatemanager.workers import (
- get_asset_info, WorkerUpdate, HTTP_ERROR_MSG
+ UpdateType, get_asset_info, WorkerUpdate, HTTP_ERROR_MSG
)
from spyder.plugins.updatemanager.widgets import update
from spyder.plugins.updatemanager.widgets.update import UpdateManagerWidget
@@ -52,7 +52,7 @@ def test_updates(qtbot, mocker, caplog, version, channel):
mocker.patch.object(
UpdateManagerWidget, "start_update", new=lambda x: None
)
- mocker.patch.object(workers, "CURR_VER", new=parse(version))
+ mocker.patch.object(workers, "CURRENT_VERSION", new=parse(version))
mocker.patch.object(
workers, "get_spyder_conda_channel", return_value=channel
)
@@ -87,7 +87,7 @@ def test_updates(qtbot, mocker, caplog, version, channel):
@pytest.mark.parametrize("stable_only", [True, False])
def test_update_non_stable(qtbot, mocker, version, release, stable_only):
"""Test we offer unstable updates."""
- mocker.patch.object(workers, "CURR_VER", new=parse(version))
+ mocker.patch.object(workers, "CURRENT_VERSION", new=parse(version))
release = parse(release)
worker = WorkerUpdate(stable_only)
@@ -102,7 +102,7 @@ def test_update_non_stable(qtbot, mocker, version, release, stable_only):
@pytest.mark.parametrize("version", ["4.0.0", "6.0.0"])
def test_update_no_asset(qtbot, mocker, version):
"""Test update availability when asset is not available"""
- mocker.patch.object(workers, "CURR_VER", new=parse(version))
+ mocker.patch.object(workers, "CURRENT_VERSION", new=parse(version))
releases = [parse("6.0.1"), parse("6.100.0")]
worker = WorkerUpdate(True)
@@ -117,11 +117,15 @@ def test_update_no_asset(qtbot, mocker, version):
@pytest.mark.parametrize(
"release,update_type",
- [("6.0.1", "micro"), ("6.1.0", "minor"), ("7.0.0", "major")]
+ [
+ ("6.0.1", UpdateType.Micro),
+ ("6.1.0", UpdateType.Minor),
+ ("7.0.0", UpdateType.Major)
+ ]
)
@pytest.mark.parametrize("app", [True, False])
def test_get_asset_info(qtbot, mocker, release, update_type, app):
- mocker.patch.object(workers, "CURR_VER", new=parse("6.0.0"))
+ mocker.patch.object(workers, "CURRENT_VERSION", new=parse("6.0.0"))
mocker.patch.object(workers, "is_conda_based_app", return_value=app)
info = get_asset_info(release)
@@ -129,10 +133,10 @@ def test_get_asset_info(qtbot, mocker, release, update_type, app):
if update_type == "major" or not app:
assert info['url'].endswith(('.exe', '.pkg', '.sh'))
- assert info['name'].endswith(('.exe', '.pkg', '.sh'))
+ assert info['filename'].endswith(('.exe', '.pkg', '.sh'))
else:
assert info['url'].endswith(".zip")
- assert info['name'].endswith(".zip")
+ assert info['filename'].endswith(".zip")
# ---- Test WorkerDownloadInstaller
diff --git a/spyder/plugins/updatemanager/widgets/update.py b/spyder/plugins/updatemanager/widgets/update.py
index 9cba9c085e5..afc20fbf6f8 100644
--- a/spyder/plugins/updatemanager/widgets/update.py
+++ b/spyder/plugins/updatemanager/widgets/update.py
@@ -254,7 +254,7 @@ def _set_installer_path(self):
self.update_type = asset_info['update_type']
dirname = osp.join(get_temp_dir(), 'updates', str(self.latest_release))
- self.installer_path = osp.join(dirname, asset_info['name'])
+ self.installer_path = osp.join(dirname, asset_info['filename'])
self.installer_size_path = osp.join(dirname, "size")
logger.info(f"Update type: {self.update_type}")
diff --git a/spyder/plugins/updatemanager/workers.py b/spyder/plugins/updatemanager/workers.py
index bbd06f4341c..c0781b7d4ef 100644
--- a/spyder/plugins/updatemanager/workers.py
+++ b/spyder/plugins/updatemanager/workers.py
@@ -5,6 +5,7 @@
# (see spyder/__init__.py for details)
# Standard library imports
+from __future__ import annotations # noqa; required for typing in Python 3.8
from datetime import datetime as dt
import logging
import os
@@ -14,10 +15,11 @@
import sys
from time import sleep
import traceback
+from typing import TypedDict
from zipfile import ZipFile
# Third party imports
-from packaging.version import parse
+from packaging.version import parse, Version
from qtpy.QtCore import QObject, Signal
import requests
from requests.exceptions import ConnectionError, HTTPError, SSLError
@@ -31,7 +33,7 @@
# Logger setup
logger = logging.getLogger(__name__)
-CURR_VER = parse(__version__)
+CURRENT_VERSION = parse(__version__)
CONNECT_ERROR_MSG = _(
'Unable to connect to the Spyder update service.'
@@ -67,7 +69,28 @@ def _rate_limits(page):
logger.debug("\n\t".join(msg_items))
-def get_asset_info(release):
+class UpdateType:
+ """Enum with the different update types."""
+
+ Major = "major"
+ Minor = "minor"
+ Micro = "micro"
+
+
+class AssetInfo(TypedDict):
+ """Schema for asset information."""
+
+ # Filename with extension of the release asset to download.
+ filename: str
+
+ # Type of update
+ update_type: UpdateType
+
+ # Download URL for the asset.
+ url: str
+
+
+def get_asset_info(release: str | Version) -> AssetInfo:
"""
Get the name, update type, and download URL for the asset of the given
release.
@@ -79,27 +102,22 @@ def get_asset_info(release):
Returns
-------
- asset_info: dict
- name: str
- Filename with extension of the release asset to download.
- update_type: str
- Type of update. One of {'major', 'minor', 'micro'}.
- url: str
- Download URL for the asset.
+ asset_info: AssetInfo
+ Information about the asset.
"""
if isinstance(release, str):
release = parse(release)
- if CURR_VER.major < release.major:
- update_type = 'major'
- elif CURR_VER.minor < release.minor:
- update_type = 'minor'
+ if CURRENT_VERSION.major < release.major:
+ update_type = UpdateType.Major
+ elif CURRENT_VERSION.minor < release.minor:
+ update_type = UpdateType.Minor
else:
- update_type = 'micro'
+ update_type = UpdateType.Micro
mach = platform.machine().lower().replace("amd64", "x86_64")
- if update_type == 'major' or not is_conda_based_app():
+ if update_type == UpdateType.Major or not is_conda_based_app():
if os.name == 'nt':
plat, ext = 'Windows', 'exe'
if sys.platform == 'darwin':
@@ -115,7 +133,7 @@ def get_asset_info(release):
f'v{release}/{name}'
)
- return {'name': name, 'update_type': update_type, 'url': url}
+ return AssetInfo(filename=name, update_type=update_type, url=url)
class UpdateDownloadCancelledException(Exception):
@@ -174,15 +192,19 @@ def __init__(self, stable_only):
self.error = None
self.channel = None
- def _check_update_available(self, releases, github=True):
+ def _check_update_available(
+ self,
+ releases: list[Version],
+ github: bool = True
+ ):
"""Checks if there is an update available from releases."""
if self.stable_only:
# Only use stable releases
releases = [r for r in releases if not r.is_prerelease]
logger.debug(f"Available versions: {releases}")
- latest_release = max(releases) if releases else CURR_VER
- update_available = CURR_VER < latest_release
+ latest_release = max(releases) if releases else CURRENT_VERSION
+ update_available = CURRENT_VERSION < latest_release
logger.debug(f"Latest release: {latest_release}")
logger.debug(f"Update available: {update_available}")
@@ -204,14 +226,14 @@ def _check_update_available(self, releases, github=True):
# The asset is not available
logger.debug(
"Asset not available: "
- f"{page.status_code} Client Error: {page.reason}"
- f" for url: {page.url}"
+ f"{page.status_code} Client Error: {page.reason} "
+ f"for url: {page.url}"
)
asset_available = False
releases.remove(latest_release)
- latest_release = max(releases) if releases else CURR_VER
- update_available = CURR_VER < latest_release
+ latest_release = max(releases) if releases else CURRENT_VERSION
+ update_available = CURRENT_VERSION < latest_release
logger.debug(f"Latest release: {latest_release}")
logger.debug(f"Update available: {update_available}")
From 7c673644f43878eb4573b837d8845949c9726f51 Mon Sep 17 00:00:00 2001
From: Hendrik Dumith Louzada
Date: Fri, 18 Oct 2024 15:25:27 -0300
Subject: [PATCH 09/19] git subrepo clone
https://github.com/spyder-ide/spyder-remote-services
external-deps/spyder-remote-services
subrepo:
subdir: "external-deps/spyder-remote-services"
merged: "8eb6fb334"
upstream:
origin: "https://github.com/spyder-ide/spyder-remote-services"
branch: "main"
commit: "8eb6fb334"
git-subrepo:
version: "0.4.9"
origin: "https://github.com/ingydotnet/git-subrepo"
commit: "cce3d93"
---
.../.github/workflows/python-publish.yml | 30 ++
.../spyder-remote-services/.gitignore | 133 +++++++
external-deps/spyder-remote-services/.gitrepo | 12 +
.../spyder-remote-services/AUTHORS.txt | 5 +
.../spyder-remote-services/LICENSE.txt | 21 +
.../spyder-remote-services/README.md | 0
.../spyder-remote-services/environment.yml | 12 +
.../kernel_enviroment.yml | 12 +
.../spyder-remote-services/pyinstaller.py | 28 ++
.../spyder-remote-services/pyproject.toml | 29 ++
.../scripts/installer.sh | 107 +++++
.../spyder_remote_services/__init__.py | 2 +
.../spyder_remote_services/__main__.py | 26 ++
.../jupyter_client/__init__.py | 0
.../jupyter_client/manager.py | 15 +
.../jupyter_server/__init__.py | 0
.../jupyter_server/kernelmanager.py | 39 ++
.../jupyter_server/serverapp.py | 85 ++++
.../spyder_remote_services/utils.py | 21 +
.../spyder-remote-services/tests/Dockerfile | 46 +++
.../tests/client/api.py | 369 ++++++++++++++++++
.../tests/client/auth.py | 50 +++
.../tests/client/execute.py | 153 ++++++++
.../tests/client/installation.py | 7 +
.../tests/client/simulate.py | 23 ++
.../tests/client/utils.py | 83 ++++
.../tests/docker-compose.yaml | 18 +
.../spyder-remote-services/tests/test.py | 86 ++++
28 files changed, 1412 insertions(+)
create mode 100644 external-deps/spyder-remote-services/.github/workflows/python-publish.yml
create mode 100644 external-deps/spyder-remote-services/.gitignore
create mode 100644 external-deps/spyder-remote-services/.gitrepo
create mode 100644 external-deps/spyder-remote-services/AUTHORS.txt
create mode 100644 external-deps/spyder-remote-services/LICENSE.txt
create mode 100644 external-deps/spyder-remote-services/README.md
create mode 100644 external-deps/spyder-remote-services/environment.yml
create mode 100644 external-deps/spyder-remote-services/kernel_enviroment.yml
create mode 100644 external-deps/spyder-remote-services/pyinstaller.py
create mode 100644 external-deps/spyder-remote-services/pyproject.toml
create mode 100755 external-deps/spyder-remote-services/scripts/installer.sh
create mode 100644 external-deps/spyder-remote-services/spyder_remote_services/__init__.py
create mode 100644 external-deps/spyder-remote-services/spyder_remote_services/__main__.py
create mode 100644 external-deps/spyder-remote-services/spyder_remote_services/jupyter_client/__init__.py
create mode 100644 external-deps/spyder-remote-services/spyder_remote_services/jupyter_client/manager.py
create mode 100644 external-deps/spyder-remote-services/spyder_remote_services/jupyter_server/__init__.py
create mode 100644 external-deps/spyder-remote-services/spyder_remote_services/jupyter_server/kernelmanager.py
create mode 100644 external-deps/spyder-remote-services/spyder_remote_services/jupyter_server/serverapp.py
create mode 100644 external-deps/spyder-remote-services/spyder_remote_services/utils.py
create mode 100644 external-deps/spyder-remote-services/tests/Dockerfile
create mode 100644 external-deps/spyder-remote-services/tests/client/api.py
create mode 100644 external-deps/spyder-remote-services/tests/client/auth.py
create mode 100644 external-deps/spyder-remote-services/tests/client/execute.py
create mode 100644 external-deps/spyder-remote-services/tests/client/installation.py
create mode 100644 external-deps/spyder-remote-services/tests/client/simulate.py
create mode 100644 external-deps/spyder-remote-services/tests/client/utils.py
create mode 100644 external-deps/spyder-remote-services/tests/docker-compose.yaml
create mode 100644 external-deps/spyder-remote-services/tests/test.py
diff --git a/external-deps/spyder-remote-services/.github/workflows/python-publish.yml b/external-deps/spyder-remote-services/.github/workflows/python-publish.yml
new file mode 100644
index 00000000000..79e43279cfa
--- /dev/null
+++ b/external-deps/spyder-remote-services/.github/workflows/python-publish.yml
@@ -0,0 +1,30 @@
+name: Release to PyPI
+on:
+ release:
+ types: [published]
+
+permissions:
+ contents: read
+
+jobs:
+ release:
+ runs-on: ubuntu-latest
+ environment:
+ name: release
+ url: https://pypi.org/p/spyder-remote-services
+ permissions:
+ id-token: write
+ steps:
+ - name: Setup python to build package
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.x"
+ - name: Install build
+ run: python -m pip install build
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - name: Build package
+ run: pyproject-build -s -w . -o dist
+ - name: Publish to PyPI
+ uses: pypa/gh-action-pypi-publish@v1.8.11
diff --git a/external-deps/spyder-remote-services/.gitignore b/external-deps/spyder-remote-services/.gitignore
new file mode 100644
index 00000000000..87e810c2457
--- /dev/null
+++ b/external-deps/spyder-remote-services/.gitignore
@@ -0,0 +1,133 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+.hypothesis/
+.pytest_cache/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+.python-version
+
+# pipenv
+# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+# However, in case of collaboration, if having platform-specific dependencies or dependencies
+# having no cross-platform support, pipenv may install dependencies that don't work, or not
+# install all needed dependencies.
+#Pipfile.lock
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# Ignore VSCode settings
+.vscode/
diff --git a/external-deps/spyder-remote-services/.gitrepo b/external-deps/spyder-remote-services/.gitrepo
new file mode 100644
index 00000000000..df584d3acfc
--- /dev/null
+++ b/external-deps/spyder-remote-services/.gitrepo
@@ -0,0 +1,12 @@
+; DO NOT EDIT (unless you know what you are doing)
+;
+; This subdirectory is a git "subrepo", and this file is maintained by the
+; git-subrepo command. See https://github.com/ingydotnet/git-subrepo#readme
+;
+[subrepo]
+ remote = https://github.com/spyder-ide/spyder-remote-services
+ branch = main
+ commit = 8eb6fb3345095d3ae3f573be2e59a44a51bb6b9b
+ parent = f9dfab7ac825489a86103cfce8c8d80788daa413
+ method = merge
+ cmdver = 0.4.9
diff --git a/external-deps/spyder-remote-services/AUTHORS.txt b/external-deps/spyder-remote-services/AUTHORS.txt
new file mode 100644
index 00000000000..aa9e9da3e44
--- /dev/null
+++ b/external-deps/spyder-remote-services/AUTHORS.txt
@@ -0,0 +1,5 @@
+The Spyder Remote Services Contributors are composed of:
+
+* Carlos Cordoba (Current Spyder/-Remote Services maintainer)
+* All other developers that have committed to the spyder-remote-services repository:
+
diff --git a/external-deps/spyder-remote-services/LICENSE.txt b/external-deps/spyder-remote-services/LICENSE.txt
new file mode 100644
index 00000000000..e9838e3452a
--- /dev/null
+++ b/external-deps/spyder-remote-services/LICENSE.txt
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024- Spyder Remote Services Contributors (see AUTHORS.txt)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/external-deps/spyder-remote-services/README.md b/external-deps/spyder-remote-services/README.md
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/external-deps/spyder-remote-services/environment.yml b/external-deps/spyder-remote-services/environment.yml
new file mode 100644
index 00000000000..179f7fae705
--- /dev/null
+++ b/external-deps/spyder-remote-services/environment.yml
@@ -0,0 +1,12 @@
+name: spyder-remote
+channels:
+ - conda-forge
+ # We want to have a reproducible setup, so we don't want default channels,
+ # which may be different for different users. All required channels should
+ # be listed explicitly here.
+ - nodefaults
+dependencies:
+ - python=3.12.*
+ - pip
+ - jupyter_server >=2.14.2,<3.0
+ - jupyter_client >=8.6.2,<9.0
diff --git a/external-deps/spyder-remote-services/kernel_enviroment.yml b/external-deps/spyder-remote-services/kernel_enviroment.yml
new file mode 100644
index 00000000000..08e88b0de33
--- /dev/null
+++ b/external-deps/spyder-remote-services/kernel_enviroment.yml
@@ -0,0 +1,12 @@
+name: spyder-kernel
+channels:
+ - conda-forge/label/spyder_kernels_rc
+ - conda-forge
+ # We want to have a reproducible setup, so we don't want default channels,
+ # which may be different for different users. All required channels should
+ # be listed explicitly here.
+ - nodefaults
+dependencies:
+ - python=3.12.*
+ - pip
+ - spyder-kernels
diff --git a/external-deps/spyder-remote-services/pyinstaller.py b/external-deps/spyder-remote-services/pyinstaller.py
new file mode 100644
index 00000000000..65a18a1b5cc
--- /dev/null
+++ b/external-deps/spyder-remote-services/pyinstaller.py
@@ -0,0 +1,28 @@
+from pathlib import Path
+
+import PyInstaller.__main__
+import jupyterhub
+
+
+SPYDER_REMOTE_SERVER = Path(__file__).parent.absolute() / "spyder_remote_server"
+path_to_main = str(SPYDER_REMOTE_SERVER / "__main__.py")
+# path_to_run_jupyterhub = str(SPYDER_REMOTE_SERVER / "run_jupyterhub.py")
+# path_to_run_service = str(SPYDER_REMOTE_SERVER / "run_service.py")
+path_to_jupyterhub_config = str(SPYDER_REMOTE_SERVER / "jupyterhub_config.py")
+
+JUPYTERHUB_PATH = Path(jupyterhub.__file__).parent.absolute()
+path_to_alembic = str(JUPYTERHUB_PATH / "alembic")
+path_to_alembic_ini = str(JUPYTERHUB_PATH / "alembic.ini")
+
+def install():
+ PyInstaller.__main__.run([
+ path_to_main,
+ # '--add-data', f'{path_to_run_jupyterhub}:.',
+ # '--add-data', f'{path_to_run_service}:spyder_remote_server',
+ '--add-data', f'{path_to_jupyterhub_config}:spyder_remote_server',
+ '--add-data', f'{path_to_alembic}:jupyterhub/alembic',
+ '--add-data', f'{path_to_alembic_ini}:jupyterhub',
+ '--name', 'spyder-remote-server',
+ '--onefile',
+ '--noconsole',
+ ])
diff --git a/external-deps/spyder-remote-services/pyproject.toml b/external-deps/spyder-remote-services/pyproject.toml
new file mode 100644
index 00000000000..4b1c676368c
--- /dev/null
+++ b/external-deps/spyder-remote-services/pyproject.toml
@@ -0,0 +1,29 @@
+[project]
+name = "spyder-remote-services"
+authors = [{name = "Hendrik Louzada", email = "hendriklouzada@gmail.com"}]
+description = "A remote server for Spyder IDE"
+readme = "README.md"
+license = { file = "LICENSE.txt" }
+dynamic = ["version"]
+requires-python = ">=3.10"
+dependencies = [
+ "jupyter_server >=2.14.2,<3.0",
+ "jupyter_client >=8.6.2,<9.0",
+]
+
+[tool.setuptools.dynamic]
+version = {attr = "spyder_remote_services.__version__"}
+
+[project.scripts]
+spyder-server = "spyder_remote_services.__main__:main"
+
+[project.optional-dependencies]
+dev = [
+ "pytest >= 7.3.1",
+ "ruff >= 0.4.1",
+ #"pyinstaller >= 5.10.1"
+]
+
+[build-system]
+requires = ["setuptools >= 61.0"]
+build-backend = "setuptools.build_meta"
diff --git a/external-deps/spyder-remote-services/scripts/installer.sh b/external-deps/spyder-remote-services/scripts/installer.sh
new file mode 100755
index 00000000000..b34e091bcf4
--- /dev/null
+++ b/external-deps/spyder-remote-services/scripts/installer.sh
@@ -0,0 +1,107 @@
+#!/bin/sh
+
+# Detect the shell from which the script was called
+parent=$(ps -o comm $PPID |tail -1)
+parent=${parent#-} # remove the leading dash that login shells have
+case "$parent" in
+ # shells supported
+ bash|fish|xonsh|zsh)
+ shell=$parent
+ ;;
+ *)
+ # use the login shell (basename of $SHELL) as a fallback
+ shell=${SHELL##*/}
+ ;;
+esac
+
+function download {
+ if hash curl >/dev/null 2>&1; then
+ curl $1 -o $2 -fsSL --compressed ${CURL_OPTS:-}
+ elif hash wget >/dev/null 2>&1; then
+ wget ${WGET_OPTS:-} -qO $2 $1
+ else
+ echo "Neither curl nor wget was found" >&2
+ exit 1
+ fi
+}
+
+function get_enviroment_name {
+ echo $(sed -n -e 's/^.*name:\s*//p' $1)
+}
+
+
+# Variables
+PACKAGE_NAME="spyder-remote-services"
+VERSION=${1:-latest}
+KERNEL_VERSION=${2:-latest}
+
+SERVER_ENV="spyder-remote"
+KERNEL_ENV="spyder-kernel"
+
+MICROMAMBA_VERSION="latest"
+BIN_FOLDER="${HOME}/.local/bin"
+PREFIX_LOCATION="${HOME}/micromamba"
+
+PYTHON_VERSION="3.12"
+
+
+# Detecting platform
+case "$(uname)" in
+ Linux)
+ PLATFORM="linux" ;;
+ Darwin)
+ PLATFORM="osx" ;;
+ *NT*)
+ PLATFORM="win" ;;
+esac
+
+ARCH="$(uname -m)"
+case "$ARCH" in
+ aarch64|ppc64le|arm64)
+ ;; # pass
+ *)
+ ARCH="64" ;;
+esac
+
+case "$PLATFORM-$ARCH" in
+ linux-aarch64|linux-ppc64le|linux-64|osx-arm64|osx-64|win-64)
+ ;; # pass
+ *)
+ echo "Failed to detect your OS" >&2
+ exit 1
+ ;;
+esac
+
+
+# Install micromamba
+RELEASE_URL="https://github.com/mamba-org/micromamba-releases/releases/${MICROMAMBA_VERSION}/download/micromamba-${PLATFORM}-${ARCH}"
+
+mkdir -p "${BIN_FOLDER}"
+download "${RELEASE_URL}" "${BIN_FOLDER}/micromamba"
+chmod +x "${BIN_FOLDER}/micromamba"
+
+eval "$("${BIN_FOLDER}/micromamba" shell hook --shell ${shell})"
+
+
+# Install spyder-remote-services
+micromamba create -y -n $SERVER_ENV -c conda-forge "python=${PYTHON_VERSION}" pip
+
+if [ $VERSION == "latest" ]; then
+ micromamba run -n $SERVER_ENV pip install ${PACKAGE_NAME}
+elif [[ $VERSION != *"=="* ]] && [[ $VERSION != *">="* ]] && [[ $VERSION != *"<="* ]] && [[ $VERSION != *">"* ]] && [[ $VERSION != *"<"* ]]; then
+ micromamba run -n $SERVER_ENV pip install ${PACKAGE_NAME}==$VERSION
+else
+ micromamba run -n $SERVER_ENV pip install ${PACKAGE_NAME}${VERSION}
+fi
+
+
+# Install spyder-kernel
+if [ $KERNEL_VERSION == "latest" ]; then
+ micromamba create -y -n $KERNEL_ENV -c conda-forge -c conda-forge/label/spyder_kernels_rc "python=${PYTHON_VERSION}" spyder-kernels
+elif [[ $KERNEL_VERSION != *"="* ]] && [[ $KERNEL_VERSION != *">="* ]] && [[ $KERNEL_VERSION != *"<="* ]] && [[ $KERNEL_VERSION != *">"* ]] && [[ $KERNEL_VERSION != *"<"* ]]; then
+ micromamba create -y -n $KERNEL_ENV -c conda-forge -c conda-forge/label/spyder_kernels_rc "python=${PYTHON_VERSION}" "spyder-kernels=$KERNEL_VERSION"
+else
+ micromamba create -y -n $KERNEL_ENV -c conda-forge -c conda-forge/label/spyder_kernels_rc "python=${PYTHON_VERSION}" "spyder-kernels${KERNEL_VERSION}"
+fi
+
+micromamba run -n $KERNEL_ENV python -m ipykernel install --user --name $KERNEL_ENV
diff --git a/external-deps/spyder-remote-services/spyder_remote_services/__init__.py b/external-deps/spyder-remote-services/spyder_remote_services/__init__.py
new file mode 100644
index 00000000000..9dd5eb4692f
--- /dev/null
+++ b/external-deps/spyder-remote-services/spyder_remote_services/__init__.py
@@ -0,0 +1,2 @@
+
+__version__ = '0.1.3'
diff --git a/external-deps/spyder-remote-services/spyder_remote_services/__main__.py b/external-deps/spyder-remote-services/spyder_remote_services/__main__.py
new file mode 100644
index 00000000000..f92a9ba240e
--- /dev/null
+++ b/external-deps/spyder-remote-services/spyder_remote_services/__main__.py
@@ -0,0 +1,26 @@
+import argparse
+
+from spyder_remote_services.jupyter_server.serverapp import (
+ get_running_server,
+ launch_new_instance,
+)
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser()
+ parser.add_argument('--jupyter-server', action='store_true', help="Start the Spyder's Jupyter server")
+ parser.add_argument('--get-running-info', action='store_true', help="Get the running server info")
+ args, rest = parser.parse_known_args(argv)
+ if args.jupyter_server:
+ launch_new_instance(rest)
+ elif args.get_running_info:
+ if info := get_running_server(as_str=True):
+ print(info)
+ else:
+ print('No info found.')
+ else:
+ parser.print_help()
+
+
+if __name__ == '__main__':
+ main()
diff --git a/external-deps/spyder-remote-services/spyder_remote_services/jupyter_client/__init__.py b/external-deps/spyder-remote-services/spyder_remote_services/jupyter_client/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/external-deps/spyder-remote-services/spyder_remote_services/jupyter_client/manager.py b/external-deps/spyder-remote-services/spyder_remote_services/jupyter_client/manager.py
new file mode 100644
index 00000000000..d11f5cdfc34
--- /dev/null
+++ b/external-deps/spyder-remote-services/spyder_remote_services/jupyter_client/manager.py
@@ -0,0 +1,15 @@
+from jupyter_client.ioloop import AsyncIOLoopKernelManager
+
+
+class SpyderAsyncIOLoopKernelManager(AsyncIOLoopKernelManager):
+ def format_kernel_cmd(self, extra_arguments=None):
+ """Format the kernel command line to be run."""
+ # avoids sporadical warning on kernel restart
+ self.update_env(env={'PYDEVD_DISABLE_FILE_VALIDATION': '1'})
+
+ cmd = super().format_kernel_cmd(extra_arguments)
+ # Replace the `ipykernel_launcher` with `spyder_kernel.console`
+ cmd_indx = cmd.index('ipykernel_launcher')
+ if cmd_indx != -1:
+ cmd[cmd_indx] = 'spyder_kernels.console'
+ return cmd
diff --git a/external-deps/spyder-remote-services/spyder_remote_services/jupyter_server/__init__.py b/external-deps/spyder-remote-services/spyder_remote_services/jupyter_server/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/external-deps/spyder-remote-services/spyder_remote_services/jupyter_server/kernelmanager.py b/external-deps/spyder-remote-services/spyder_remote_services/jupyter_server/kernelmanager.py
new file mode 100644
index 00000000000..045059c3dca
--- /dev/null
+++ b/external-deps/spyder-remote-services/spyder_remote_services/jupyter_server/kernelmanager.py
@@ -0,0 +1,39 @@
+from jupyter_server.services.kernels.kernelmanager import (
+ AsyncMappingKernelManager,
+)
+from jupyter_server._tz import isoformat
+from traitlets import Unicode
+
+
+
+class SpyderAsyncMappingKernelManager(AsyncMappingKernelManager):
+ kernel_manager_class = 'spyder_remote_services.jupyter_client.manager.SpyderAsyncIOLoopKernelManager'
+
+ default_kernel_name = Unicode(
+ 'spyder-kernel', help='The name of the default kernel to start'
+ ).tag(config=True)
+
+ def kernel_model(self, kernel_id):
+ """Return a JSON-safe dict representing a kernel
+
+ For use in representing kernels in the JSON APIs.
+ """
+ self._check_kernel_id(kernel_id)
+ kernel = self._kernels[kernel_id]
+
+ conn_info = kernel.get_connection_info()
+
+ # convert key bytes to str
+ conn_info["key"] = conn_info["key"].decode()
+
+ model = {
+ "id": kernel_id,
+ "name": kernel.kernel_name,
+ "last_activity": isoformat(kernel.last_activity),
+ "execution_state": kernel.execution_state,
+ "connections": self._kernel_connections.get(kernel_id, 0),
+ "connection_info": conn_info,
+ }
+ if getattr(kernel, "reason", None):
+ model["reason"] = kernel.reason
+ return model
diff --git a/external-deps/spyder-remote-services/spyder_remote_services/jupyter_server/serverapp.py b/external-deps/spyder-remote-services/spyder_remote_services/jupyter_server/serverapp.py
new file mode 100644
index 00000000000..195e80867d4
--- /dev/null
+++ b/external-deps/spyder-remote-services/spyder_remote_services/jupyter_server/serverapp.py
@@ -0,0 +1,85 @@
+import json
+import os
+from pathlib import Path
+
+from jupyter_server.transutils import _i18n
+from jupyter_server.utils import check_pid
+from jupyter_core.paths import jupyter_runtime_dir
+from jupyter_server.serverapp import ServerApp
+from traitlets import Bool, default
+
+from spyder_remote_services.jupyter_server.kernelmanager import (
+ SpyderAsyncMappingKernelManager,
+)
+from spyder_remote_services.utils import get_free_port
+
+
+SYPDER_SERVER_INFO_FILE = "jpserver-spyder.json"
+
+class SpyderServerApp(ServerApp):
+ kernel_manager_class = SpyderAsyncMappingKernelManager
+
+ set_dynamic_port = Bool(
+ True,
+ help="""Set the port dynamically.
+
+ Get an available port instead of using the default port
+ if no port is provided.
+ """,
+ ).tag(config=True)
+
+ @default("port")
+ def port_default(self):
+ if self.set_dynamic_port:
+ return get_free_port()
+ return int(os.getenv(self.port_env, self.port_default_value))
+
+ @property
+ def info_file(self):
+ return str((Path(self.runtime_dir) /
+ SYPDER_SERVER_INFO_FILE).resolve())
+
+
+def get_running_server(runtime_dir=None, log=None, *, as_str=False):
+ """Iterate over the server info files of running Jupyter servers.
+
+ Given a runtime directory, find jpserver-* files in the security directory,
+ and yield dicts of their information, each one pertaining to
+ a currently running Jupyter server instance.
+ """
+ if runtime_dir is None:
+ runtime_dir = jupyter_runtime_dir()
+
+ runtime_dir = Path(runtime_dir)
+
+ # The runtime dir might not exist
+ if not runtime_dir.is_dir():
+ return None
+
+ conf_file = runtime_dir / SYPDER_SERVER_INFO_FILE
+
+ if not conf_file.exists():
+ return None
+
+ with conf_file.open(mode="rb") as f:
+ info = json.load(f)
+
+ # Simple check whether that process is really still running
+ # Also remove leftover files from IPython 2.x without a pid field
+ if ("pid" in info) and check_pid(info["pid"]):
+ if as_str:
+ return json.dumps(info, indent=None)
+ return info
+
+ # If the process has died, try to delete its info file
+ try:
+ conf_file.unlink()
+ except OSError as e:
+ if log:
+ log.warning(_i18n("Deleting server info file failed: %s.") % e)
+
+
+main = launch_new_instance = SpyderServerApp.launch_instance
+
+if __name__ == '__main__':
+ main()
diff --git a/external-deps/spyder-remote-services/spyder_remote_services/utils.py b/external-deps/spyder-remote-services/spyder_remote_services/utils.py
new file mode 100644
index 00000000000..7a4dc45f35c
--- /dev/null
+++ b/external-deps/spyder-remote-services/spyder_remote_services/utils.py
@@ -0,0 +1,21 @@
+import socket
+import sys
+import os
+
+
+if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
+ SYS_EXEC = sys.executable
+else:
+ SYS_EXEC = 'spyder-remote-server'
+
+
+def get_free_port():
+ """Request a free port from the OS."""
+ with socket.socket() as s:
+ s.bind(('', 0))
+ return s.getsockname()[1]
+
+
+def generate_token():
+ """Generate a random token."""
+ return os.urandom(64).hex()
diff --git a/external-deps/spyder-remote-services/tests/Dockerfile b/external-deps/spyder-remote-services/tests/Dockerfile
new file mode 100644
index 00000000000..e24153f0828
--- /dev/null
+++ b/external-deps/spyder-remote-services/tests/Dockerfile
@@ -0,0 +1,46 @@
+FROM ubuntu:focal AS ubuntu-base
+ENV DEBIAN_FRONTEND noninteractive
+SHELL ["/bin/bash", "-o", "pipefail", "-c"]
+
+# Setup the default user.
+RUN useradd -rm -d /home/ubuntu -s /bin/bash -g root -G sudo ubuntu
+RUN echo 'ubuntu:ubuntu' | chpasswd
+USER ubuntu
+WORKDIR /home/ubuntu
+
+# Build image with Python and SSHD.
+FROM ubuntu-base AS ubuntu-with-sshd
+USER root
+
+# Install required tools.
+RUN apt-get -qq update \
+ && apt-get -qq --no-install-recommends install curl \
+ && apt-get -qq --no-install-recommends install ca-certificates \
+ && apt-get -qq --no-install-recommends install vim-tiny \
+ && apt-get -qq --no-install-recommends install sudo \
+ && apt-get -qq --no-install-recommends install git \
+ && apt-get -qq --no-install-recommends install openssh-server \
+ && apt-get -qq clean \
+ && rm -rf /var/lib/apt/lists/*
+
+# Configure SSHD.
+# SSH login fix. Otherwise user is kicked off after login
+RUN sed 's@session\s*required\s*pam_loginuid.so@session optional pam_loginuid.so@g' -i /etc/pam.d/sshd
+RUN mkdir /var/run/sshd
+RUN bash -c 'install -m755 <(printf "#!/bin/sh\nexit 0") /usr/sbin/policy-rc.d'
+RUN ex +'%s/^#\zeListenAddress/\1/g' -scwq /etc/ssh/sshd_config
+RUN ex +'%s/^#\zeHostKey .*ssh_host_.*_key/\1/g' -scwq /etc/ssh/sshd_config
+RUN RUNLEVEL=1 dpkg-reconfigure openssh-server
+RUN ssh-keygen -A -v
+RUN update-rc.d ssh defaults
+
+# Configure sudo.
+RUN ex +"%s/^%sudo.*$/%sudo ALL=(ALL:ALL) NOPASSWD:ALL/g" -scwq! /etc/sudoers
+
+# Generate and configure user keys.
+USER ubuntu
+RUN ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519
+#COPY --chown=ubuntu:root "./files/authorized_keys" /home/ubuntu/.ssh/authorized_keys
+
+
+CMD ["/usr/bin/sudo", "/usr/sbin/sshd", "-D", "-o", "ListenAddress=0.0.0.0"]
diff --git a/external-deps/spyder-remote-services/tests/client/api.py b/external-deps/spyder-remote-services/tests/client/api.py
new file mode 100644
index 00000000000..8231c598174
--- /dev/null
+++ b/external-deps/spyder-remote-services/tests/client/api.py
@@ -0,0 +1,369 @@
+import uuid
+import logging
+import time
+import asyncio
+
+import yarl
+import aiohttp
+
+from client import auth
+
+
+logger = logging.getLogger(__name__)
+
+
+class JupyterHubAPI:
+ def __init__(self, hub_url, auth_type="token", verify_ssl=True, **kwargs):
+ self.hub_url = yarl.URL(hub_url)
+ self.api_url = self.hub_url / "hub/api"
+ self.auth_type = auth_type
+ self.verify_ssl = verify_ssl
+
+ if auth_type == "token":
+ self.api_token = kwargs.get("api_token")
+ elif auth_type == "basic" or auth_type == "keycloak":
+ self.username = kwargs.get("username")
+ self.password = kwargs.get("password")
+
+ async def __aenter__(self):
+ if self.auth_type == "token":
+ self.session = await auth.token_authentication(
+ self.api_token, verify_ssl=self.verify_ssl
+ )
+ elif self.auth_type == "basic":
+ self.session = await auth.basic_authentication(
+ self.hub_url, self.username, self.password, verify_ssl=self.verify_ssl
+ )
+ self.api_token = await self.create_token(self.username)
+ await self.session.close()
+ logger.debug("upgrading basic authentication to token authentication")
+ self.session = await auth.token_authentication(
+ self.api_token, verify_ssl=self.verify_ssl
+ )
+ elif self.auth_type == "keycloak":
+ self.session = await auth.keycloak_authentication(
+ self.hub_url, self.username, self.password, verify_ssl=self.verify_ssl
+ )
+ self.api_token = await self.create_token(self.username)
+ await self.session.close()
+ logger.debug("upgrading keycloak authentication to token authentication")
+ self.session = await auth.token_authentication(
+ self.api_token, verify_ssl=self.verify_ssl
+ )
+ return self
+
+ async def __aexit__(self, exc_type, exc, tb):
+ await self.session.close()
+
+ async def ensure_user(self, username, create_user=False):
+ user = await self.get_user(username)
+ if user is None:
+ if create_user:
+ await self.create_user(username)
+ else:
+ raise ValueError(
+ f"current username={username} does not exist and create_user={create_user}"
+ )
+ user = await self.get_user(username)
+ return user
+
+ async def get_user(self, username):
+ async with self.session.get(self.api_url / "users" / username) as response:
+ if response.status == 200:
+ return await response.json()
+ elif response.status == 404:
+ logger.info(f"username={username} does not exist")
+ return None
+
+ async def create_user(self, username):
+ async with self.session.post(self.api_url / "users" / username) as response:
+ if response.status == 201:
+ logger.info(f"created username={username}")
+ response = await response.json()
+ self.api_token = await self.create_token(username)
+ return response
+ elif response.status == 409:
+ raise ValueError(f"username={username} already exists")
+
+ async def delete_user(self, username):
+ async with self.session.delete(self.api_url / "users" / username) as response:
+ if response.status == 204:
+ logger.info(f"deleted username={username}")
+ elif response.status == 404:
+ raise ValueError(f"username={username} does not exist cannot delete")
+
+ async def ensure_server(
+ self, username, timeout, user_options=None, create_user=False
+ ):
+ user = await self.ensure_user(username, create_user=create_user)
+ if user["server"] is None:
+ await self.create_server(username, user_options=user_options)
+
+ start_time = time.time()
+ while True:
+ user = await self.get_user(username)
+ if user["server"] and user["pending"] is None:
+ return JupyterAPI(
+ self.hub_url / "user" / username,
+ self.api_token,
+ verify_ssl=self.verify_ssl,
+ )
+
+ await asyncio.sleep(5)
+ total_time = time.time() - start_time
+ if total_time > timeout:
+ logger.error(f"jupyterhub server creation timeout={timeout:.0f} [s]")
+ raise TimeoutError(
+ f"jupyterhub server creation timeout={timeout:.0f} [s]"
+ )
+
+ logger.info(f"pending spawn polling for seconds={total_time:.0f} [s]")
+
+ async def ensure_server_deleted(self, username, timeout):
+ user = await self.get_user(username)
+ if user is None:
+ return # user doesn't exist so server can't exist
+
+ start_time = time.time()
+ while True:
+ server_status = await self.delete_server(username)
+ if server_status == 204:
+ return
+
+ await asyncio.sleep(5)
+ total_time = time.time() - start_time
+ if total_time > timeout:
+ logger.error(f"jupyterhub server deletion timeout={timeout:.0f} [s]")
+ raise TimeoutError(
+ f"jupyterhub server deletion timeout={timeout:.0f} [s]"
+ )
+
+ logger.info(f"pending deletion polling for seconds={total_time:.0f} [s]")
+
+ async def create_token(self, username, token_name=None):
+ token_name = token_name or "jhub-client"
+ async with self.session.post(
+ self.api_url / "users" / username / "tokens", json={"note": token_name}
+ ) as response:
+ logger.info(f"created token for username={username}")
+ return (await response.json())["token"]
+
+ async def create_server(self, username, user_options=None):
+ user_options = user_options or {}
+ async with self.session.post(
+ self.api_url / "users" / username / "server", json=user_options
+ ) as response:
+ logger.info(
+ f"creating cluster username={username} user_options={user_options}"
+ )
+ if response.status == 400:
+ raise ValueError(f"server for username={username} is already running")
+ elif response.status == 201:
+ logger.info(
+ f"created server for username={username} with user_options={user_options}"
+ )
+ return True
+
+ async def delete_server(self, username):
+ response = await self.session.delete(
+ self.api_url / "users" / username / "server"
+ )
+ logger.info(f"deleted server for username={username}")
+ return response.status
+
+ async def info(self):
+ async with self.session.get(self.api_url / "info") as response:
+ return await response.json()
+
+ async def list_users(self):
+ async with self.session.get(self.api_url / "users") as response:
+ return await response.json()
+
+ async def list_proxy(self):
+ async with self.session.get(self.api_url / "proxy") as response:
+ return await response.json()
+
+ async def identify_token(self, token):
+ async with self.session.get(
+ self.api_url / "authorizations" / "token" / token
+ ) as response:
+ return await response.json()
+
+ async def get_services(self):
+ async with self.session.get(self.api_url / "services") as response:
+ return await response.json()
+
+
+ async def get_service(self, service_name):
+ async with self.session.get(self.api_url / "services" / service_name) as response:
+ if response.status == 404:
+ return None
+ elif response.status == 200:
+ return await response.json()
+
+ async def execute_post_service(self, service_name, url='', data=None):
+ async with self.session.post(self.hub_url / "services" / service_name / url, data=data) as response:
+ if response.status == 404:
+ return None
+ elif response.status == 200:
+ return await response.json()
+
+ async def execute_get_service(self, service_name, url=''):
+ async with self.session.get(self.hub_url / "services" / service_name / url) as response:
+ if response.status == 404:
+ return None
+ elif response.status == 200:
+ return await response.json()
+
+ async def execute_delete_service(self, service_name, url=''):
+ async with self.session.delete(self.hub_url / "services" / service_name / url) as response:
+ if response.status == 404:
+ return None
+ elif response.status == 200:
+ return await response.json()
+
+
+
+
+class JupyterAPI:
+ def __init__(self, notebook_url, api_token, verify_ssl=True):
+ self.api_url = yarl.URL(notebook_url) / "api"
+ self.api_token = api_token
+ self.verify_ssl = verify_ssl
+
+ async def __aenter__(self):
+ self.session = aiohttp.ClientSession(
+ headers={"Authorization": f"token {self.api_token}"},
+ connector=aiohttp.TCPConnector(ssl=None if self.verify_ssl else False),
+ )
+ return self
+
+ async def __aexit__(self, exc_type, exc, tb):
+ await self.session.close()
+
+ async def create_kernel(self, kernel_spec=None):
+ data = {"kernel_spec": kernel_spec} if kernel_spec else None
+
+ async with self.session.post(self.api_url / "kernels", json=data) as response:
+ data = await response.json()
+ logger.info(
+ f'created kernel_spec={kernel_spec} kernel={data["id"]} for jupyter'
+ )
+ return data
+
+ async def list_kernel_specs(self):
+ async with self.session.get(self.api_url / "kernelspecs") as response:
+ return await response.json()
+
+ async def list_kernels(self):
+ async with self.session.get(self.api_url / "kernels") as response:
+ return await response.json()
+
+ async def ensure_kernel(self, kernel_spec=None):
+ kernel_specs = await self.list_kernel_specs()
+ if kernel_spec is None:
+ kernel_spec = kernel_specs["default"]
+ else:
+ available_kernel_specs = list(kernel_specs["kernelspecs"].keys())
+ if kernel_spec not in kernel_specs["kernelspecs"]:
+ logger.error(
+ f"kernel_spec={kernel_spec} not listed in available kernel specifications={available_kernel_specs}"
+ )
+ raise ValueError(
+ f"kernel_spec={kernel_spec} not listed in available kernel specifications={available_kernel_specs}"
+ )
+
+ kernel_id = (await self.create_kernel(kernel_spec=kernel_spec))["id"]
+ return kernel_id, JupyterKernelAPI(
+ self.api_url / "kernels" / kernel_id,
+ self.api_token,
+ verify_ssl=self.verify_ssl,
+ )
+
+ async def get_kernel(self, kernel_id):
+ async with self.session.get(self.api_url / "kernels" / kernel_id) as response:
+ if response.status == 404:
+ return None
+ elif response.status == 200:
+ return await response.json()
+
+ async def delete_kernel(self, kernel_id):
+ async with self.session.delete(
+ self.api_url / "kernels" / kernel_id
+ ) as response:
+ if response.status == 404:
+ raise ValueError(
+ f"failed to delete kernel_id={kernel_id} does not exist"
+ )
+ elif response.status == 204:
+ logger.info(f"deleted kernel={kernel_id} for jupyter")
+ return True
+
+
+class JupyterKernelAPI:
+ def __init__(self, kernel_url, api_token, verify_ssl=True):
+ self.api_url = kernel_url
+ self.api_token = api_token
+ self.verify_ssl = verify_ssl
+
+ async def __aenter__(self):
+ self.session = aiohttp.ClientSession(
+ headers={"Authorization": f"token {self.api_token}"},
+ connector=aiohttp.TCPConnector(ssl=None if self.verify_ssl else False),
+ )
+ self.websocket = await self.session.ws_connect(self.api_url / "channels")
+ return self
+
+ async def __aexit__(self, exc_type, exc, tb):
+ await self.session.close()
+
+ def request_execute_code(self, msg_id, username, code):
+ return {
+ "header": {
+ "msg_id": msg_id,
+ "username": username,
+ "msg_type": "execute_request",
+ "version": "5.2",
+ },
+ "metadata": {},
+ "content": {
+ "code": code,
+ "silent": False,
+ "store_history": True,
+ "user_expressions": {},
+ "allow_stdin": True,
+ "stop_on_error": True,
+ },
+ "buffers": [],
+ "parent_header": {},
+ "channel": "shell",
+ }
+
+ async def send_code(self, username, code, wait=True, timeout=None):
+ msg_id = str(uuid.uuid4())
+
+ await self.websocket.send_json(
+ self.request_execute_code(msg_id, username, code)
+ )
+
+ if not wait:
+ return None
+
+ async for msg_text in self.websocket:
+ if msg_text.type != aiohttp.WSMsgType.TEXT:
+ return False
+
+ # TODO: timeout is ignored
+
+ msg = msg_text.json()
+
+ if "parent_header" in msg and msg["parent_header"].get("msg_id") == msg_id:
+ # These are responses to our request
+ if msg["channel"] == "iopub":
+ if msg["msg_type"] == "execute_result":
+ return msg["content"]["data"]["text/plain"]
+ elif msg["msg_type"] == "stream":
+ return msg["content"]["text"]
+ # cell did not produce output
+ elif msg["content"].get("execution_state") == "idle":
+ return ""
diff --git a/external-deps/spyder-remote-services/tests/client/auth.py b/external-deps/spyder-remote-services/tests/client/auth.py
new file mode 100644
index 00000000000..eff9c61ebde
--- /dev/null
+++ b/external-deps/spyder-remote-services/tests/client/auth.py
@@ -0,0 +1,50 @@
+import re
+
+import aiohttp
+import yarl
+
+
+async def token_authentication(api_token, verify_ssl=True):
+ return aiohttp.ClientSession(
+ headers={"Authorization": f"token {api_token}"},
+ connector=aiohttp.TCPConnector(ssl=None if verify_ssl else False),
+ )
+
+
+async def basic_authentication(hub_url, username, password, verify_ssl=True):
+ session = aiohttp.ClientSession(
+ headers={"Referer": str(yarl.URL(hub_url) / "hub" / "api")},
+ connector=aiohttp.TCPConnector(ssl=None if verify_ssl else False),
+ )
+
+ await session.post(
+ yarl.URL(hub_url) / "hub" / "login",
+ data={
+ "username": username,
+ "password": password,
+ },
+ )
+
+ return session
+
+
+async def keycloak_authentication(hub_url, username, password, verify_ssl=True):
+ session = aiohttp.ClientSession(
+ headers={"Referer": str(yarl.URL(hub_url) / "hub" / "api")},
+ connector=aiohttp.TCPConnector(ssl=None if verify_ssl else False),
+ )
+
+ response = await session.get(yarl.URL(hub_url) / "hub" / "oauth_login")
+ content = await response.content.read()
+ auth_url = re.search('action="([^"]+)"', content.decode("utf8")).group(1)
+
+ response = await session.post(
+ auth_url.replace("&", "&"),
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
+ data={
+ "username": username,
+ "password": password,
+ "credentialId": "",
+ },
+ )
+ return session
diff --git a/external-deps/spyder-remote-services/tests/client/execute.py b/external-deps/spyder-remote-services/tests/client/execute.py
new file mode 100644
index 00000000000..c86bb394938
--- /dev/null
+++ b/external-deps/spyder-remote-services/tests/client/execute.py
@@ -0,0 +1,153 @@
+import uuid
+import difflib
+import logging
+import textwrap
+
+from client.api import JupyterHubAPI
+from client.utils import parse_notebook_cells
+
+logger = logging.getLogger(__name__)
+
+
+DAEMONIZED_STOP_SERVER_HEADER = """
+def _client_stop_server():
+ import urllib.request
+ request = urllib.request.Request(url="{delete_server_endpoint}", method= "DELETE")
+ request.add_header("Authorization", "token {api_token}")
+ urllib.request.urlopen(request)
+
+def custom_exc(shell, etype, evalue, tb, tb_offset=None):
+ _jupyerhub_client_stop_server()
+
+get_ipython().set_custom_exc((Exception,), custom_exc)
+"""
+
+
+async def determine_username(
+ hub,
+ username=None,
+ user_format="user-{user}-{id}",
+ service_format="service-{name}-{id}",
+ temporary_user=False,
+):
+ token = await hub.identify_token(hub.api_token)
+
+ if username is None and not temporary_user:
+ if token["kind"] == "service":
+ logger.error(
+ "cannot execute without specified username or temporary_user=True for service api token"
+ )
+ raise ValueError(
+ "Service api token cannot execute without specified username or temporary_user=True for"
+ )
+ return token["name"]
+ elif username is None and temporary_user:
+ if token["kind"] == "service":
+ return service_format.format(id=str(uuid.uuid4()), name=token["name"])
+ else:
+ return user_format.format(id=str(uuid.uuid4()), name=token["name"])
+ else:
+ return username
+
+
+async def execute_code(
+ hub_url,
+ cells,
+ username=None,
+ temporary_user=False,
+ create_user=False,
+ delete_user=False,
+ server_creation_timeout=60,
+ server_deletion_timeout=60,
+ kernel_execution_timeout=60,
+ daemonized=False,
+ validate=False,
+ stop_server=True,
+ user_options=None,
+ kernel_spec=None,
+ auth_type="token",
+ verify_ssl=True,
+):
+ hub = JupyterHubAPI(hub_url, auth_type=auth_type, verify_ssl=verify_ssl)
+ result_cells = []
+
+ async with hub:
+ username = await determine_username(
+ hub, username, temporary_user=temporary_user
+ )
+ try:
+ jupyter = await hub.ensure_server(
+ username,
+ create_user=create_user,
+ user_options=user_options,
+ timeout=server_creation_timeout,
+ )
+
+ async with jupyter:
+ kernel_id, kernel = await jupyter.ensure_kernel(kernel_spec=kernel_spec)
+ async with kernel:
+ if daemonized and stop_server:
+ await kernel.send_code(
+ username,
+ DAEMONIZED_STOP_SERVER_HEADER.format(
+ delete_server_endpoint=hub.api_url
+ / "users"
+ / username
+ / "server",
+ api_token=hub.api_token,
+ ),
+ wait=False,
+ )
+
+ for i, (code, expected_result) in enumerate(cells):
+ kernel_result = await kernel.send_code(
+ username,
+ code,
+ timeout=kernel_execution_timeout,
+ wait=(not daemonized),
+ )
+ result_cells.append((code, kernel_result))
+ if daemonized:
+ logger.debug(
+ f'kernel submitted cell={i} code=\n{textwrap.indent(code, " >>> ")}'
+ )
+ else:
+ logger.debug(
+ f'kernel executing cell={i} code=\n{textwrap.indent(code, " >>> ")}'
+ )
+ logger.debug(
+ f'kernel result cell={i} result=\n{textwrap.indent(kernel_result, " | ")}'
+ )
+ if validate and (
+ kernel_result.strip() != expected_result.strip()
+ ):
+ diff = "".join(
+ difflib.unified_diff(kernel_result, expected_result)
+ )
+ logger.error(
+ f"kernel result did not match expected result diff={diff}"
+ )
+ raise ValueError(
+ f"execution of cell={i} did not match expected result diff={diff}"
+ )
+
+ if daemonized and stop_server:
+ await kernel.send_code(
+ username, "__client_stop_server()", wait=False
+ )
+ if not daemonized:
+ await jupyter.delete_kernel(kernel_id)
+ if not daemonized and stop_server:
+ await hub.ensure_server_deleted(
+ username, timeout=server_deletion_timeout
+ )
+ finally:
+ if delete_user and not daemonized:
+ await hub.delete_user(username)
+
+ return result_cells
+
+
+async def execute_notebook(hub_url, notebook_path, **kwargs):
+ cells = parse_notebook_cells(notebook_path)
+ return await execute_code(hub_url, cells, **kwargs)
diff --git a/external-deps/spyder-remote-services/tests/client/installation.py b/external-deps/spyder-remote-services/tests/client/installation.py
new file mode 100644
index 00000000000..8a14bfaa2c7
--- /dev/null
+++ b/external-deps/spyder-remote-services/tests/client/installation.py
@@ -0,0 +1,7 @@
+MICROMAMBA_INSTALLER = """\
+"${SHELL}" <(curl -L micro.mamba.pm/install.sh)
+"""
+
+MICROMAMBA_INSTALLER_PS = """\
+Invoke-Expression ((Invoke-WebRequest -Uri https://micro.mamba.pm/install.ps1).Content)
+"""
diff --git a/external-deps/spyder-remote-services/tests/client/simulate.py b/external-deps/spyder-remote-services/tests/client/simulate.py
new file mode 100644
index 00000000000..992a4dba8f4
--- /dev/null
+++ b/external-deps/spyder-remote-services/tests/client/simulate.py
@@ -0,0 +1,23 @@
+import asyncio
+
+from client.execute import execute_code
+
+
+async def simulate_users(hub_url, num_users, user_generator, workflow="concurrent"):
+ jupyterhub_sessions = []
+
+ if workflow == "concurrent":
+ for i, (username, cells) in zip(range(num_users), user_generator):
+ jupyterhub_sessions.append(
+ execute_code(
+ hub_url=hub_url,
+ username=username,
+ cells=cells,
+ create_user=True,
+ delete_user=True,
+ )
+ )
+
+ return await asyncio.gather(*jupyterhub_sessions)
+ else:
+ raise ValueError("uknown type of jupyterhub workflow to simulate")
diff --git a/external-deps/spyder-remote-services/tests/client/utils.py b/external-deps/spyder-remote-services/tests/client/utils.py
new file mode 100644
index 00000000000..9183086ccd4
--- /dev/null
+++ b/external-deps/spyder-remote-services/tests/client/utils.py
@@ -0,0 +1,83 @@
+import json
+
+
+def parse_notebook_cells(notebook_path):
+ with open(notebook_path) as f:
+ notebook_data = json.load(f)
+
+ cells = []
+ for cell in notebook_data["cells"]:
+ if cell["cell_type"] == "code":
+ source = "".join(cell["source"])
+ outputs = []
+ for output in cell["outputs"]:
+ if output["output_type"] == "stream":
+ outputs.append("".join(output["text"]))
+ elif output["output_type"] == "execute_result":
+ outputs.append("".join(output["data"]["text/plain"]))
+ result = "\n".join(outputs)
+ cells.append((source, result))
+
+ return cells
+
+
+def render_notebook(cells):
+ notebook_template = {
+ "cells": [],
+ "nbformat": 4,
+ "nbformat_minor": 4,
+ "metadata": {},
+ }
+
+ for i, (code, result) in enumerate(cells, start=1):
+ notebook_template["cells"].append(
+ {
+ "cell_type": "code",
+ "execution_count": i,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {"text/plain": result},
+ "execution_count": i,
+ "metadata": {},
+ "output_type": "execute_result",
+ }
+ ],
+ "source": code,
+ }
+ )
+
+ return notebook_template
+
+
+TEMPLATE_SCRIPT_HEADER = """
+import os
+import sys
+import logging
+
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger('client')
+
+OUTPUT_FORMAT = '{output_format}'
+STDOUT_FILENAME = os.path.expanduser('{stdout_filename}')
+STDERR_FILENAME = os.path.expanduser('{stderr_filename}')
+
+if OUTPUT_FORMAT == 'file':
+ logger.info('writting output to files stdout={stdout_filename} and stderr={stderr_filename}')
+ sys.stdout = open(STDOUT_FILENAME, 'w')
+ sys.stderr = open(STDERR_FILENAME, 'w')
+
+"""
+
+
+def tangle_cells(
+ cells, output_format="file", stdout_filename=None, stderr_filename=None
+):
+ # TODO: eventually support writing output to notebook
+
+ tangled_code = []
+ for i, (code, expected_result) in enumerate(cells):
+ tangled_code.append('logger.info("beginning execution cell={i}")')
+ tangled_code.append(code)
+ tangled_code.append('logger.info("completed execution cell={i}")')
+ return TEMPLATE_SCRIPT_HEADER + "\n".join(tangled_code)
diff --git a/external-deps/spyder-remote-services/tests/docker-compose.yaml b/external-deps/spyder-remote-services/tests/docker-compose.yaml
new file mode 100644
index 00000000000..98e652dc64e
--- /dev/null
+++ b/external-deps/spyder-remote-services/tests/docker-compose.yaml
@@ -0,0 +1,18 @@
+version: "3"
+
+services:
+ spyder-remote-server:
+ build: .
+ # volumes:
+ # - "..:/home/ubuntu/spyder_remote_server"
+ networks:
+ mynet:
+ ipv4_address: 172.16.128.2
+ ports:
+ - "2222:22"
+ privileged: true # Required for /usr/sbin/init
+networks:
+ mynet:
+ ipam:
+ config:
+ - subnet: 172.16.128.0/24
diff --git a/external-deps/spyder-remote-services/tests/test.py b/external-deps/spyder-remote-services/tests/test.py
new file mode 100644
index 00000000000..be3e81cce98
--- /dev/null
+++ b/external-deps/spyder-remote-services/tests/test.py
@@ -0,0 +1,86 @@
+import asyncio
+import logging
+
+import textwrap
+
+from client.api import JupyterHubAPI
+
+logger = logging.getLogger(__name__)
+
+SERVER_TIMEOUT = 3600
+KERNEL_EXECUTION_TIMEOUT = 3600
+
+
+SERVER_URL = "http://localhost:8000"
+
+USERNAME = "user-test-1"
+
+async def test():
+ result_cells = []
+ cells = [
+ "a, b = 1, 2",
+ "a + b"
+ ]
+
+ async with JupyterHubAPI(
+ SERVER_URL,
+ auth_type="token",
+ api_token="GiJ96ujfLpPsq7oatW1IJuER01FbZsgyCM0xH6oMZXDAV6zUZsFy3xQBZakSBo6P",
+ verify_ssl=False
+ ) as hub:
+ try:
+ # jupyter = await hub.ensure_server(
+ # USERNAME,
+ # timeout=SERVER_TIMEOUT,
+ # create_user=True,
+ # )
+
+ # # test kernel
+ # async with jupyter:
+ # kernel_id, kernel = await jupyter.ensure_kernel()
+ # async with kernel:
+ # for i, code in enumerate(cells):
+ # kernel_result = await kernel.send_code(
+ # USERNAME,
+ # code,
+ # timeout=KERNEL_EXECUTION_TIMEOUT,
+ # wait=True,
+ # )
+ # result_cells.append((code, kernel_result))
+ # logger.warning(
+ # f'kernel executing cell={i} code=\n{textwrap.indent(code, " >>> ")}'
+ # )
+ # logger.warning(
+ # f'kernel result cell={i} result=\n{textwrap.indent(kernel_result, " | ")}'
+ # )
+
+ # test custom spyder-service
+ # spyder_service_response = await hub.get_service("spyder-service")
+ # logger.warning(f'spyder-service: {spyder_service_response}')
+
+ spyder_service_response = await hub.execute_get_service("spyder-service", "kernel")
+ logger.warning(f'spyder-service-kernel-get: {spyder_service_response}')
+
+ spyder_service_response = await hub.execute_post_service("spyder-service", "kernel")
+ logger.warning(f'spyder-service-kernel-post: {spyder_service_response}')
+
+ key = spyder_service_response['key']
+
+ spyder_service_response = await hub.execute_get_service("spyder-service", f"kernel/{key}")
+ logger.warning(f'spyder-service-kernel-get: {spyder_service_response}')
+
+ spyder_service_response = await hub.execute_delete_service("spyder-service", f"kernel/{key}")
+ logger.warning(f'spyder-service-kernel-delete: {spyder_service_response}')
+
+ spyder_service_response = await hub.execute_get_service("spyder-service", "kernel")
+ logger.warning(f'spyder-service-kernel-get: {spyder_service_response}')
+
+ finally:
+ if await hub.get_user(USERNAME) is not None:
+ await hub.delete_user(USERNAME)
+
+if __name__ == "__main__":
+ logging.basicConfig(level=logging.DEBUG)
+ loop = asyncio.get_event_loop()
+ loop.run_until_complete(test())
+ loop.close()
From 56ea3e3573d9fa5b37c3ea0dfb3f66da5efcc114 Mon Sep 17 00:00:00 2001
From: Hendrik Dumith Louzada
Date: Fri, 18 Oct 2024 16:13:23 -0300
Subject: [PATCH 10/19] feat: use subrepo to test remoteclient
---
spyder/plugins/ipythonconsole/__init__.py | 6 ++----
spyder/plugins/remoteclient/tests/Dockerfile | 8 ++++++++
spyder/plugins/remoteclient/tests/docker-compose.yml | 9 ++++++++-
spyder/plugins/remoteclient/utils/installation.py | 4 ++++
4 files changed, 22 insertions(+), 5 deletions(-)
diff --git a/spyder/plugins/ipythonconsole/__init__.py b/spyder/plugins/ipythonconsole/__init__.py
index 56c49deb74e..2ff34ccd29d 100644
--- a/spyder/plugins/ipythonconsole/__init__.py
+++ b/spyder/plugins/ipythonconsole/__init__.py
@@ -11,7 +11,7 @@
IPython Console plugin based on QtConsole
"""
-from spyder.config.base import is_stable_version, running_remoteclient_tests
+from spyder.config.base import is_stable_version
# Use this variable, which corresponds to the html dash symbol, for any command
@@ -20,9 +20,7 @@
_d = '-'
# Required version of Spyder-kernels
-SPYDER_KERNELS_MIN_VERSION = (
- "3.0.0" if running_remoteclient_tests() else "3.1.0.dev0"
-)
+SPYDER_KERNELS_MIN_VERSION = "3.1.0.dev0"
SPYDER_KERNELS_MAX_VERSION = '3.2.0'
SPYDER_KERNELS_VERSION = (
f'>={SPYDER_KERNELS_MIN_VERSION},<{SPYDER_KERNELS_MAX_VERSION}'
diff --git a/spyder/plugins/remoteclient/tests/Dockerfile b/spyder/plugins/remoteclient/tests/Dockerfile
index 72ae9e0d6f9..e8564e5d11d 100644
--- a/spyder/plugins/remoteclient/tests/Dockerfile
+++ b/spyder/plugins/remoteclient/tests/Dockerfile
@@ -34,4 +34,12 @@ RUN ex +"%s/^%sudo.*$/%sudo ALL=(ALL:ALL) NOPASSWD:ALL/g" -scwq! /etc/sudoers
USER ubuntu
RUN ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519
+
+# Configure and Install the spyder-remote-services
+
+COPY --from=external-deps spyder-remote-services /home/ubuntu/spyder-remote-services
+COPY --from=external-deps spyder-kernels /home/ubuntu/spyder-kernels
+
+RUN bash /home/ubuntu/spyder-remote-services/scripts/installer_dev.sh /home/ubuntu/spyder-remote-services /home/ubuntu/spyder-kernels
+
CMD ["/usr/bin/sudo", "/usr/sbin/sshd", "-D", "-o", "ListenAddress=172.16.128.2"]
diff --git a/spyder/plugins/remoteclient/tests/docker-compose.yml b/spyder/plugins/remoteclient/tests/docker-compose.yml
index 4f470411813..0ba20c27479 100644
--- a/spyder/plugins/remoteclient/tests/docker-compose.yml
+++ b/spyder/plugins/remoteclient/tests/docker-compose.yml
@@ -1,6 +1,13 @@
services:
test-spyder-remote-server:
- build: .
+ build:
+ context: ./
+ additional_contexts:
+ external-deps: ../../../../external-deps
+ dockerfile: Dockerfile
+ volumes:
+ - "../../../../external-deps/spyder-remote-services:/home/ubuntu/spyder-remote-services"
+ - "../../../../external-deps/spyder-kernels:/home/ubuntu/spyder-kernels"
ports:
- "22"
privileged: true # Required for /usr/sbin/init
diff --git a/spyder/plugins/remoteclient/utils/installation.py b/spyder/plugins/remoteclient/utils/installation.py
index 0a5aa4f80d2..b611a22a616 100644
--- a/spyder/plugins/remoteclient/utils/installation.py
+++ b/spyder/plugins/remoteclient/utils/installation.py
@@ -5,6 +5,7 @@
# (see spyder/__init__.py for details)
from spyder.plugins.ipythonconsole import SPYDER_KERNELS_VERSION
+from spyder.config.base import running_remoteclient_tests
SERVER_ENTRY_POINT = "spyder-server"
@@ -21,6 +22,9 @@
def get_installer_command(platform: str) -> str:
if platform == "win":
raise NotImplementedError("Windows is not supported yet")
+
+ if running_remoteclient_tests():
+ return '\n' # server should be aready installed in the test environment
return (
f'"${{SHELL}}" <(curl -L {SCRIPT_URL}/installer.sh) '
From 26aa9a1fef9db9dedb43213f8f359446bd55748b Mon Sep 17 00:00:00 2001
From: Hendrik Dumith Louzada
Date: Fri, 18 Oct 2024 16:13:27 -0300
Subject: [PATCH 11/19] git subrepo pull external-deps/spyder-remote-services
subrepo:
subdir: "external-deps/spyder-remote-services"
merged: "d425e769d"
upstream:
origin: "https://github.com/spyder-ide/spyder-remote-services"
branch: "main"
commit: "d425e769d"
git-subrepo:
version: "0.4.9"
origin: "https://github.com/ingydotnet/git-subrepo"
commit: "cce3d93"
---
external-deps/spyder-remote-services/.gitrepo | 4 +-
.../scripts/installer_dev.sh | 93 +++++++++++++++++++
spyder/plugins/remoteclient/tests/Dockerfile | 4 +-
3 files changed, 97 insertions(+), 4 deletions(-)
create mode 100755 external-deps/spyder-remote-services/scripts/installer_dev.sh
diff --git a/external-deps/spyder-remote-services/.gitrepo b/external-deps/spyder-remote-services/.gitrepo
index df584d3acfc..b37a80d0fff 100644
--- a/external-deps/spyder-remote-services/.gitrepo
+++ b/external-deps/spyder-remote-services/.gitrepo
@@ -6,7 +6,7 @@
[subrepo]
remote = https://github.com/spyder-ide/spyder-remote-services
branch = main
- commit = 8eb6fb3345095d3ae3f573be2e59a44a51bb6b9b
- parent = f9dfab7ac825489a86103cfce8c8d80788daa413
+ commit = d425e769dc85783c1a95d1791d98a025341dafd1
+ parent = 56ea3e3573d9fa5b37c3ea0dfb3f66da5efcc114
method = merge
cmdver = 0.4.9
diff --git a/external-deps/spyder-remote-services/scripts/installer_dev.sh b/external-deps/spyder-remote-services/scripts/installer_dev.sh
new file mode 100755
index 00000000000..2a5bb517288
--- /dev/null
+++ b/external-deps/spyder-remote-services/scripts/installer_dev.sh
@@ -0,0 +1,93 @@
+#!/bin/sh
+
+# Detect the shell from which the script was called
+parent=$(ps -o comm $PPID |tail -1)
+parent=${parent#-} # remove the leading dash that login shells have
+case "$parent" in
+ # shells supported
+ bash|fish|xonsh|zsh)
+ shell=$parent
+ ;;
+ *)
+ # use the login shell (basename of $SHELL) as a fallback
+ shell=${SHELL##*/}
+ ;;
+esac
+
+function download {
+ if hash curl >/dev/null 2>&1; then
+ curl $1 -o $2 -fsSL --compressed ${CURL_OPTS:-}
+ elif hash wget >/dev/null 2>&1; then
+ wget ${WGET_OPTS:-} -qO $2 $1
+ else
+ echo "Neither curl nor wget was found" >&2
+ exit 1
+ fi
+}
+
+function get_enviroment_name {
+ echo $(sed -n -e 's/^.*name:\s*//p' $1)
+}
+
+
+# Variables
+PACKAGE_PATH=${1:-spyder-remote-services}
+KERNEL_PATH=${2:-spyder-kernels}
+
+SERVER_ENV="spyder-remote"
+KERNEL_ENV="spyder-kernel"
+
+MICROMAMBA_VERSION="latest"
+BIN_FOLDER="${HOME}/.local/bin"
+PREFIX_LOCATION="${HOME}/micromamba"
+
+PYTHON_VERSION="3.12"
+
+
+# Detecting platform
+case "$(uname)" in
+ Linux)
+ PLATFORM="linux" ;;
+ Darwin)
+ PLATFORM="osx" ;;
+ *NT*)
+ PLATFORM="win" ;;
+esac
+
+ARCH="$(uname -m)"
+case "$ARCH" in
+ aarch64|ppc64le|arm64)
+ ;; # pass
+ *)
+ ARCH="64" ;;
+esac
+
+case "$PLATFORM-$ARCH" in
+ linux-aarch64|linux-ppc64le|linux-64|osx-arm64|osx-64|win-64)
+ ;; # pass
+ *)
+ echo "Failed to detect your OS" >&2
+ exit 1
+ ;;
+esac
+
+
+# Install micromamba
+RELEASE_URL="https://github.com/mamba-org/micromamba-releases/releases/${MICROMAMBA_VERSION}/download/micromamba-${PLATFORM}-${ARCH}"
+
+mkdir -p "${BIN_FOLDER}"
+download "${RELEASE_URL}" "${BIN_FOLDER}/micromamba"
+chmod +x "${BIN_FOLDER}/micromamba"
+
+eval "$("${BIN_FOLDER}/micromamba" shell hook --shell ${shell})"
+
+
+# Install spyder-remote-services
+micromamba create -y -n $SERVER_ENV -c conda-forge "python=${PYTHON_VERSION}" pip
+micromamba run -n $SERVER_ENV pip install -e ${PACKAGE_PATH}
+
+# Install spyder-kernel
+micromamba create -y -n $KERNEL_ENV -c conda-forge "python=${PYTHON_VERSION}" pip
+micromamba run -n $KERNEL_ENV pip install -e ${KERNEL_PATH}
+
+micromamba run -n $KERNEL_ENV python -m ipykernel install --user --name $KERNEL_ENV
diff --git a/spyder/plugins/remoteclient/tests/Dockerfile b/spyder/plugins/remoteclient/tests/Dockerfile
index e8564e5d11d..9d803a4b78c 100644
--- a/spyder/plugins/remoteclient/tests/Dockerfile
+++ b/spyder/plugins/remoteclient/tests/Dockerfile
@@ -37,8 +37,8 @@ RUN ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519
# Configure and Install the spyder-remote-services
-COPY --from=external-deps spyder-remote-services /home/ubuntu/spyder-remote-services
-COPY --from=external-deps spyder-kernels /home/ubuntu/spyder-kernels
+COPY --from=external-deps --chown=ubuntu spyder-remote-services /home/ubuntu/spyder-remote-services
+COPY --from=external-deps --chown=ubuntu spyder-kernels /home/ubuntu/spyder-kernels
RUN bash /home/ubuntu/spyder-remote-services/scripts/installer_dev.sh /home/ubuntu/spyder-remote-services /home/ubuntu/spyder-kernels
From 1ac93671077d03b11b4739258389186413acb787 Mon Sep 17 00:00:00 2001
From: Hendrik Dumith Louzada
Date: Fri, 18 Oct 2024 17:43:37 -0300
Subject: [PATCH 12/19] fix: ignore spyder-remote-services as to be installed
from the external-deps
---
.github/scripts/install.sh | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/scripts/install.sh b/.github/scripts/install.sh
index a4f5d729c70..a4846a46a31 100755
--- a/.github/scripts/install.sh
+++ b/.github/scripts/install.sh
@@ -62,7 +62,7 @@ else
fi
# Install subrepos from source
-python -bb -X dev install_dev_repos.py --not-editable --no-install spyder
+python -bb -X dev install_dev_repos.py --not-editable --no-install "spyder spyder-remote-services"
# Install Spyder to test it as if it was properly installed.
python -bb -X dev -m build
From 11b628362124a27c9e0895e5a7f70629c6953fbe Mon Sep 17 00:00:00 2001
From: Hendrik Dumith Louzada
Date: Fri, 18 Oct 2024 17:52:54 -0300
Subject: [PATCH 13/19] fix: remote quotes from no install argument in
install_dev_repos
---
.github/scripts/install.sh | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/scripts/install.sh b/.github/scripts/install.sh
index a4846a46a31..f62334ec370 100755
--- a/.github/scripts/install.sh
+++ b/.github/scripts/install.sh
@@ -62,7 +62,7 @@ else
fi
# Install subrepos from source
-python -bb -X dev install_dev_repos.py --not-editable --no-install "spyder spyder-remote-services"
+python -bb -X dev install_dev_repos.py --not-editable --no-install spyder spyder-remote-services
# Install Spyder to test it as if it was properly installed.
python -bb -X dev -m build
From f8dcf699fdf9c108601b67413b0d0d73b13faedb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Daniel=20Althviz=20Mor=C3=A9?=
<16781833+dalthviz@users.noreply.github.com>
Date: Fri, 18 Oct 2024 21:04:12 -0500
Subject: [PATCH 14/19] Update spyder/plugins/ipythonconsole/widgets/shell.py
Co-authored-by: Carlos Cordoba
---
spyder/plugins/ipythonconsole/widgets/shell.py | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/spyder/plugins/ipythonconsole/widgets/shell.py b/spyder/plugins/ipythonconsole/widgets/shell.py
index ea7fa03da73..2a8ee8d7475 100644
--- a/spyder/plugins/ipythonconsole/widgets/shell.py
+++ b/spyder/plugins/ipythonconsole/widgets/shell.py
@@ -1403,10 +1403,12 @@ def _kernel_restarted_message(self, died=True):
)
else:
self.ipyclient.show_kernel_error(
- _("Unable to connect with the kernel. If you are trying "
- "to connect to an existing kernel check that the "
- "connection file actually corresponds with the kernel "
- "you want to connect to")
+ _(
+ "It was not possible to connect to the kernel. If you "
+ "are trying to connect to an existing kernel, check "
+ "that the connection file you selected actually "
+ "corresponds to the kernel you want to connect to."
+ )
)
self._append_html(f"
{msg}
", before_prompt=False)
From 2fe945e102f1b3504ec598546501ddf7fb65fdd5 Mon Sep 17 00:00:00 2001
From: Carlos Cordoba
Date: Sun, 20 Oct 2024 18:18:40 -0500
Subject: [PATCH 15/19] Bootstrap: Don't install the spyder-remote-services
subrepo
---
bootstrap.py | 13 ++++++++-----
1 file changed, 8 insertions(+), 5 deletions(-)
diff --git a/bootstrap.py b/bootstrap.py
index c011d4fff98..99bdf33f37d 100755
--- a/bootstrap.py
+++ b/bootstrap.py
@@ -101,11 +101,14 @@
REPOS[DEVPATH.name]["editable"] = False
for name in REPOS.keys():
- if not REPOS[name]['editable']:
- install_repo(name)
- installed_dev_repo = True
- else:
- logger.info("%s installed in editable mode", name)
+ # Don't install the spyder-remote-services subrepo because it's not
+ # necessary on the Spyder side.
+ if name != "spyder-remote-services":
+ if not REPOS[name]['editable']:
+ install_repo(name)
+ installed_dev_repo = True
+ else:
+ logger.info("%s installed in editable mode", name)
if installed_dev_repo:
logger.info("Restarting bootstrap to pick up installed subrepos")
From 581ce15f4c7d6d1e00aca539bb0eb6d54c057c62 Mon Sep 17 00:00:00 2001
From: Carlos Cordoba
Date: Sun, 20 Oct 2024 18:24:55 -0500
Subject: [PATCH 16/19] Application: Add "Help Spyder" entry to the Help menu
This will help users to find where to donate in our interface in case
they miss the heart hint we show in the status bar.
---
spyder/plugins/application/container.py | 8 ++++++++
spyder/plugins/application/plugin.py | 11 ++++++++---
spyder/plugins/application/widgets/status.py | 2 ++
3 files changed, 18 insertions(+), 3 deletions(-)
diff --git a/spyder/plugins/application/container.py b/spyder/plugins/application/container.py
index 39654bb5c30..7b4bd29370f 100644
--- a/spyder/plugins/application/container.py
+++ b/spyder/plugins/application/container.py
@@ -55,6 +55,7 @@ class ApplicationActions:
SpyderTroubleshootingAction = "spyder_troubleshooting_action"
SpyderDependenciesAction = "spyder_dependencies_action"
SpyderSupportAction = "spyder_support_action"
+ HelpSpyderAction = "help_spyder_action"
SpyderAbout = "spyder_about_action"
# Tools
@@ -137,6 +138,13 @@ def setup(self):
_("Spyder support..."),
triggered=lambda: start_file(__forum_url__))
+ self.create_action(
+ ApplicationActions.HelpSpyderAction,
+ _("Help Spyder..."),
+ icon=self.create_icon("inapp_appeal"),
+ triggered=self.inapp_appeal_status.show_appeal
+ )
+
# About action
self.about_action = self.create_action(
ApplicationActions.SpyderAbout,
diff --git a/spyder/plugins/application/plugin.py b/spyder/plugins/application/plugin.py
index 3ae21547916..e49e04598a8 100644
--- a/spyder/plugins/application/plugin.py
+++ b/spyder/plugins/application/plugin.py
@@ -223,13 +223,18 @@ def _populate_help_menu_support_section(self):
"""Add Spyder base support actions to the Help main menu."""
mainmenu = self.get_plugin(Plugins.MainMenu)
for support_action in [
- self.trouble_action, self.report_action,
- self.dependencies_action, self.support_group_action]:
+ self.trouble_action,
+ self.report_action,
+ self.dependencies_action,
+ self.support_group_action,
+ self.get_action(ApplicationActions.HelpSpyderAction),
+ ]:
mainmenu.add_item_to_application_menu(
support_action,
menu_id=ApplicationMenus.Help,
section=HelpMenuSections.Support,
- before_section=HelpMenuSections.ExternalDocumentation)
+ before_section=HelpMenuSections.ExternalDocumentation
+ )
def _populate_help_menu_about_section(self):
"""Create Spyder base about actions."""
diff --git a/spyder/plugins/application/widgets/status.py b/spyder/plugins/application/widgets/status.py
index ce433706e49..80c89caf0e8 100644
--- a/spyder/plugins/application/widgets/status.py
+++ b/spyder/plugins/application/widgets/status.py
@@ -118,6 +118,8 @@ def _on_click(self):
def show_appeal(self):
if self._appeal_dialog is None:
self._appeal_dialog = InAppAppealDialog(self)
+
+ if not self._appeal_dialog.isVisible():
self._appeal_dialog.show()
# ---- StatusBarWidget API
From 438985372c7e0faa733b005fc5dea55fcd12e611 Mon Sep 17 00:00:00 2001
From: Carlos Cordoba
Date: Wed, 9 Oct 2024 11:30:10 -0500
Subject: [PATCH 17/19] Utils: Always use utf-8 when handling QByteArray data
in ProcessWorker
---
spyder/utils/workers.py | 17 +++++------------
1 file changed, 5 insertions(+), 12 deletions(-)
diff --git a/spyder/utils/workers.py b/spyder/utils/workers.py
index f7e961ee1ab..744606847d7 100644
--- a/spyder/utils/workers.py
+++ b/spyder/utils/workers.py
@@ -13,7 +13,6 @@
# Standard library imports
from collections import deque
import logging
-import os
import sys
# Third party imports
@@ -124,17 +123,11 @@ def __init__(self, parent, cmd_list, environ=None):
self._process.readyReadStandardOutput.connect(self._partial)
def _get_encoding(self):
- """Return the encoding/codepage to use."""
- enco = 'utf-8'
-
- # Currently only cp1252 is allowed?
- if os.name == 'nt':
- import ctypes
- codepage = to_text_string(ctypes.cdll.kernel32.GetACP())
- # import locale
- # locale.getpreferredencoding() # Differences?
- enco = 'cp' + codepage
- return enco
+ """Return the encoding to use."""
+ # It seems that in Python 3 we only need this encoding to correctly
+ # decode bytes on all operating systems.
+ # See spyder-ide/spyder#22546
+ return 'utf-8'
def _set_environment(self, environ):
"""Set the environment on the QProcess."""
From 8adda5394e549bb577ca8be39f57385a570d2158 Mon Sep 17 00:00:00 2001
From: Ryan Clary <9618975+mrclary@users.noreply.github.com>
Date: Tue, 22 Oct 2024 15:08:10 -0700
Subject: [PATCH 18/19] Update installing developer repos to accommodate
dist-info
---
install_dev_repos.py | 17 ++++++++++++-----
1 file changed, 12 insertions(+), 5 deletions(-)
diff --git a/install_dev_repos.py b/install_dev_repos.py
index 65db0fb4e16..f1c534638be 100755
--- a/install_dev_repos.py
+++ b/install_dev_repos.py
@@ -10,16 +10,17 @@
import argparse
from importlib.metadata import PackageNotFoundError, distribution
-import os
-import sys
+from json import loads
from logging import Formatter, StreamHandler, getLogger
+import os
from pathlib import Path
from subprocess import check_output
+import sys
from packaging.requirements import Requirement
-# Remove current/script directory from sys.path[0] if added by the Python invocation,
-# otherwise Spyder's install status may be incorrectly determined.
+# Remove current/script directory from sys.path[0] if added by the Python
+# invocation, otherwise Spyder's install status may be incorrectly determined.
SYS_PATH_0 = Path(sys.path[0]).resolve()
if SYS_PATH_0 in (Path(__file__).resolve().parent, Path.cwd()):
sys.path.pop(0)
@@ -43,7 +44,13 @@
dist = None
editable = None
else:
- editable = (p == dist._path or p in dist._path.parents)
+ direct_url = dist.read_text('direct_url.json')
+ if direct_url:
+ editable = (
+ loads(direct_url).get('dir_info', {}).get('editable', False)
+ )
+ else:
+ editable = (p == dist._path or p in dist._path.parents)
# This fixes detecting that PyLSP was installed in editable mode under
# some scenarios.
From 010f4976b30259b4fe5e31b6835c5fc0957c39e4 Mon Sep 17 00:00:00 2001
From: dalthviz <16781833+dalthviz@users.noreply.github.com>
Date: Wed, 23 Oct 2024 14:36:21 -0500
Subject: [PATCH 19/19] git subrepo pull (merge)
--remote=https://github.com/jupyter/qtconsole.git --branch=main --update
--force external-deps/qtconsole
subrepo:
subdir: "external-deps/qtconsole"
merged: "791569470"
upstream:
origin: "https://github.com/jupyter/qtconsole.git"
branch: "main"
commit: "791569470"
git-subrepo:
version: "0.4.3"
origin: "???"
commit: "???"
---
external-deps/qtconsole/.gitrepo | 4 +-
.../qtconsole/ansi_code_processor.py | 25 ++++++++---
.../qtconsole/qtconsole/console_widget.py | 35 ++++++++++++++-
.../tests/test_ansi_code_processor.py | 45 ++++++++++++++++++-
4 files changed, 99 insertions(+), 10 deletions(-)
diff --git a/external-deps/qtconsole/.gitrepo b/external-deps/qtconsole/.gitrepo
index b1be2c7cd0e..39d5d433ab3 100644
--- a/external-deps/qtconsole/.gitrepo
+++ b/external-deps/qtconsole/.gitrepo
@@ -6,7 +6,7 @@
[subrepo]
remote = https://github.com/jupyter/qtconsole.git
branch = main
- commit = a72387f01dea2076346ecb38857de5266414dbe6
- parent = 8d3b773f38158025f7d65a503c2dd55cf6c15b46
+ commit = 7915694709460f95d92d48daf3c57eb7a3f2bce4
+ parent = 3274e793422497da39940e8ac4dad3ef793acafe
method = merge
cmdver = 0.4.3
diff --git a/external-deps/qtconsole/qtconsole/ansi_code_processor.py b/external-deps/qtconsole/qtconsole/ansi_code_processor.py
index 063c9067441..16f2ddde6a9 100644
--- a/external-deps/qtconsole/qtconsole/ansi_code_processor.py
+++ b/external-deps/qtconsole/qtconsole/ansi_code_processor.py
@@ -92,10 +92,7 @@ def split_string(self, string):
self.actions = []
start = 0
- # strings ending with \r are assumed to be ending in \r\n since
- # \n is appended to output strings automatically. Accounting
- # for that, here.
- last_char = '\n' if len(string) > 0 and string[-1] == '\n' else None
+ last_char = None
string = string[:-1] if last_char is not None else string
for match in ANSI_OR_SPECIAL_PATTERN.finditer(string):
@@ -122,7 +119,7 @@ def split_string(self, string):
self.actions = []
elif g0 == '\n' or g0 == '\r\n':
self.actions.append(NewLineAction('newline'))
- yield g0
+ yield None
self.actions = []
else:
params = [ param for param in groups[1].split(';') if param ]
@@ -147,7 +144,7 @@ def split_string(self, string):
if last_char is not None:
self.actions.append(NewLineAction('newline'))
- yield last_char
+ yield None
def set_csi_code(self, command, params=[]):
""" Set attributes based on CSI (Control Sequence Introducer) code.
@@ -185,6 +182,22 @@ def set_csi_code(self, command, params=[]):
count = params[0] if params else 1
self.actions.append(ScrollAction('scroll', dir, 'line', count))
+ elif command == 'A': # Move N lines Up
+ dir = 'up'
+ count = params[0] if params else 1
+ self.actions.append(MoveAction('move', dir, 'line', count))
+
+ elif command == 'B': # Move N lines Down
+ dir = 'down'
+ count = params[0] if params else 1
+ self.actions.append(MoveAction('move', dir, 'line', count))
+
+ elif command == 'F': # Goes back to the begining of the n-th previous line
+ dir = 'leftup'
+ count = params[0] if params else 1
+ self.actions.append(MoveAction('move', dir, 'line', count))
+
+
def set_osc_code(self, params):
""" Set attributes based on OSC (Operating System Command) parameters.
diff --git a/external-deps/qtconsole/qtconsole/console_widget.py b/external-deps/qtconsole/qtconsole/console_widget.py
index 09bd7f71a1d..aa492bafdc4 100644
--- a/external-deps/qtconsole/qtconsole/console_widget.py
+++ b/external-deps/qtconsole/qtconsole/console_widget.py
@@ -2188,6 +2188,27 @@ def _insert_plain_text(self, cursor, text, flush=False):
cursor.select(QtGui.QTextCursor.Document)
cursor.removeSelectedText()
+ elif act.action == 'move' and act.unit == 'line':
+ if act.dir == 'up':
+ for i in range(act.count):
+ cursor.movePosition(
+ QtGui.QTextCursor.Up
+ )
+ elif act.dir == 'down':
+ for i in range(act.count):
+ cursor.movePosition(
+ QtGui.QTextCursor.Down
+ )
+ elif act.dir == 'leftup':
+ for i in range(act.count):
+ cursor.movePosition(
+ QtGui.QTextCursor.Up
+ )
+ cursor.movePosition(
+ QtGui.QTextCursor.StartOfLine,
+ QtGui.QTextCursor.MoveAnchor
+ )
+
elif act.action == 'carriage-return':
cursor.movePosition(
QtGui.QTextCursor.StartOfLine,
@@ -2203,7 +2224,19 @@ def _insert_plain_text(self, cursor, text, flush=False):
QtGui.QTextCursor.MoveAnchor)
elif act.action == 'newline':
- cursor.movePosition(QtGui.QTextCursor.EndOfLine)
+ if (
+ cursor.block() != cursor.document().lastBlock()
+ and not cursor.document()
+ .toPlainText()
+ .endswith(self._prompt)
+ ):
+ cursor.movePosition(QtGui.QTextCursor.NextBlock)
+ else:
+ cursor.movePosition(
+ QtGui.QTextCursor.EndOfLine,
+ QtGui.QTextCursor.MoveAnchor,
+ )
+ cursor.insertText("\n")
# simulate replacement mode
if substring is not None:
diff --git a/external-deps/qtconsole/qtconsole/tests/test_ansi_code_processor.py b/external-deps/qtconsole/qtconsole/tests/test_ansi_code_processor.py
index 2b7dd71f42f..ed00d631cc7 100644
--- a/external-deps/qtconsole/qtconsole/tests/test_ansi_code_processor.py
+++ b/external-deps/qtconsole/qtconsole/tests/test_ansi_code_processor.py
@@ -139,7 +139,7 @@ def test_carriage_return_newline(self):
for split in self.processor.split_string(string):
splits.append(split)
actions.append([action.action for action in self.processor.actions])
- self.assertEqual(splits, ['foo', None, 'bar', '\r\n', 'cat', '\r\n', '\n'])
+ self.assertEqual(splits, ['foo', None, 'bar', None, 'cat', None, None])
self.assertEqual(actions, [[], ['carriage-return'], [], ['newline'], [], ['newline'], ['newline']])
def test_beep(self):
@@ -182,6 +182,49 @@ def test_combined(self):
self.assertEqual(splits, ['abc', None, 'def', None])
self.assertEqual(actions, [[], ['carriage-return'], [], ['backspace']])
+ def test_move_cursor_up(self):
+ """Are the ANSI commands for the cursor movement actions
+ (movement up and to the beginning of the line) processed correctly?
+ """
+ # This line moves the cursor up once, then moves it up five more lines.
+ # Next, it moves the cursor to the beginning of the previous line, and
+ # finally moves it to the beginning of the fifth line above the current
+ # position
+ string = '\x1b[A\x1b[5A\x1b[F\x1b[5F'
+ i = -1
+ for i, substring in enumerate(self.processor.split_string(string)):
+ if i == 0:
+ self.assertEqual(len(self.processor.actions), 1)
+ action = self.processor.actions[0]
+ self.assertEqual(action.action, 'move')
+ self.assertEqual(action.dir, 'up')
+ self.assertEqual(action.unit, 'line')
+ self.assertEqual(action.count, 1)
+ elif i == 1:
+ self.assertEqual(len(self.processor.actions), 1)
+ action = self.processor.actions[0]
+ self.assertEqual(action.action, 'move')
+ self.assertEqual(action.dir, 'up')
+ self.assertEqual(action.unit, 'line')
+ self.assertEqual(action.count, 5)
+ elif i == 2:
+ self.assertEqual(len(self.processor.actions), 1)
+ action = self.processor.actions[0]
+ self.assertEqual(action.action, 'move')
+ self.assertEqual(action.dir, 'leftup')
+ self.assertEqual(action.unit, 'line')
+ self.assertEqual(action.count, 1)
+ elif i == 3:
+ self.assertEqual(len(self.processor.actions), 1)
+ action = self.processor.actions[0]
+ self.assertEqual(action.action, 'move')
+ self.assertEqual(action.dir, 'leftup')
+ self.assertEqual(action.unit, 'line')
+ self.assertEqual(action.count, 5)
+ else:
+ self.fail('Too many substrings.')
+ self.assertEqual(i, 3, 'Too few substrings.')
+
if __name__ == '__main__':
unittest.main()