diff --git a/.github/workflows/linux-pip-tests.yml b/.github/workflows/linux-pip-tests.yml index e88b9e6f..e8d0de90 100644 --- a/.github/workflows/linux-pip-tests.yml +++ b/.github/workflows/linux-pip-tests.yml @@ -16,7 +16,7 @@ concurrency: jobs: linux: - name: Py${{ matrix.PYTHON_VERSION }} + name: Linux (pip) - Py${{ matrix.PYTHON_VERSION }} runs-on: ubuntu-latest env: CI: True @@ -34,7 +34,7 @@ jobs: - name: Install System Packages run: | sudo apt-get update - sudo apt-get install libegl1-mesa + sudo apt-get install libegl1-mesa libopengl0 - name: Install Conda uses: conda-incubator/setup-miniconda@v2 with: @@ -51,6 +51,9 @@ jobs: run: | conda info conda list + # - name: Setup Remote SSH Connection + # uses: mxschmitt/action-tmate@v3 + # timeout-minutes: 60 - name: Run tests shell: bash -l {0} run: | diff --git a/.github/workflows/linux-tests.yml b/.github/workflows/linux-tests.yml index f0900308..27d22081 100644 --- a/.github/workflows/linux-tests.yml +++ b/.github/workflows/linux-tests.yml @@ -16,7 +16,7 @@ concurrency: jobs: linux: - name: Py${{ matrix.PYTHON_VERSION }} + name: Linux - Py${{ matrix.PYTHON_VERSION }} runs-on: ubuntu-latest env: CI: True @@ -34,7 +34,7 @@ jobs: - name: Install System Packages run: | sudo apt-get update - sudo apt-get install libegl1-mesa + sudo apt-get install libegl1-mesa libopengl0 - name: Install Conda uses: conda-incubator/setup-miniconda@v2 with: @@ -59,6 +59,9 @@ jobs: run: | conda info conda list + # - name: Setup Remote SSH Connection + # uses: mxschmitt/action-tmate@v3 + # timeout-minutes: 60 - name: Run tests shell: bash -l {0} run: | diff --git a/.github/workflows/macos-tests.yml b/.github/workflows/macos-tests.yml index 819f9a1e..98eb2780 100644 --- a/.github/workflows/macos-tests.yml +++ b/.github/workflows/macos-tests.yml @@ -16,7 +16,7 @@ concurrency: jobs: macos: - name: Py${{ matrix.PYTHON_VERSION }} + name: macOS - Py${{ matrix.PYTHON_VERSION }} runs-on: macos-latest env: CI: True @@ -55,6 +55,9 @@ jobs: run: | conda info conda list + # - name: Setup Remote SSH Connection + # uses: mxschmitt/action-tmate@v3 + # timeout-minutes: 60 - name: Run tests shell: bash -l {0} run: | diff --git a/.github/workflows/windows-tests.yml b/.github/workflows/windows-tests.yml index b402cb71..18afee46 100644 --- a/.github/workflows/windows-tests.yml +++ b/.github/workflows/windows-tests.yml @@ -16,7 +16,7 @@ concurrency: jobs: windows: - name: Py${{ matrix.PYTHON_VERSION }} + name: Windows - Py${{ matrix.PYTHON_VERSION }} runs-on: windows-latest env: CI: True @@ -55,6 +55,9 @@ jobs: run: | conda info conda list + # - name: Setup Remote SSH Connection + # uses: mxschmitt/action-tmate@v3 + # timeout-minutes: 60 - name: Run tests shell: bash -l {0} run: | diff --git a/spyder_kernels/comms/utils.py b/spyder_kernels/comms/utils.py index 00332349..a1c73c51 100644 --- a/spyder_kernels/comms/utils.py +++ b/spyder_kernels/comms/utils.py @@ -59,7 +59,10 @@ def is_benign_message(self, message): "Warning: Cannot change to a different GUI toolkit", "%pylab is deprecated", "Populating the interactive namespace", - "\n" + "\n", + # Fixes spyder-ide/spyder#21652 + "WARNING", + "Active device does not have an attribute", ] return any([msg in message for msg in benign_messages]) diff --git a/spyder_kernels/console/kernel.py b/spyder_kernels/console/kernel.py index 925cf429..ad16c084 100644 --- a/spyder_kernels/console/kernel.py +++ b/spyder_kernels/console/kernel.py @@ -25,6 +25,7 @@ # Third-party imports from ipykernel.ipkernel import IPythonKernel from ipykernel import get_connection_info +from IPython.core import release as ipython_release from traitlets.config.loader import Config, LazyConfigValue import zmq from zmq.utils.garbage import gc @@ -35,6 +36,13 @@ from spyder_kernels.comms.frontendcomm import FrontendComm from spyder_kernels.comms.decorators import ( register_comm_handlers, comm_handler) +from spyder_kernels.utils.pythonenv import ( + get_env_dir, + is_conda_env, + is_pyenv_env, + PythonEnvInfo, + PythonEnvType, +) from spyder_kernels.utils.iofuncs import iofunctions from spyder_kernels.utils.mpl import automatic_backend, MPL_BACKENDS_TO_SPYDER from spyder_kernels.utils.nsview import ( @@ -81,6 +89,9 @@ def __init__(self, *args, **kwargs): # To track the interactive backend self.interactive_backend = None + # To save the python env info + self.pythonenv_info: PythonEnvInfo = {} + @property def kernel_info(self): # Used for checking correct version by spyder @@ -756,6 +767,35 @@ def update_syspath(self, path_dict, new_path_dict): else: os.environ.pop('PYTHONPATH', None) + @comm_handler + def get_pythonenv_info(self): + """Get the Python env info in which this kernel is installed.""" + # We only need to compute this once + if not self.pythonenv_info: + path = sys.executable.replace("pythonw.exe", "python.exe") + + if is_conda_env(pyexec=path): + env_type = PythonEnvType.Conda + elif is_pyenv_env(path): + env_type = PythonEnvType.PyEnv + else: + env_type = PythonEnvType.Custom + + self.pythonenv_info = PythonEnvInfo( + path=path, + env_type=env_type, + name=get_env_dir(path, only_dir=True), + python_version=".".join( + [str(n) for n in sys.version_info[:3]] + ), + # These keys are necessary to build the console banner in + # Spyder + ipython_version=ipython_release.version, + sys_version=sys.version, + ) + + return self.pythonenv_info + # -- Private API --------------------------------------------------- # --- For the Variable Explorer def _get_len(self, var): diff --git a/spyder_kernels/console/tests/test_console_kernel.py b/spyder_kernels/console/tests/test_console_kernel.py index 60f240d3..f6f58c39 100644 --- a/spyder_kernels/console/tests/test_console_kernel.py +++ b/spyder_kernels/console/tests/test_console_kernel.py @@ -24,17 +24,19 @@ from collections import namedtuple # Test imports -import pytest from flaky import flaky +from IPython.core import release as ipython_release from jupyter_core import paths from jupyter_client import BlockingKernelClient import numpy as np +import pytest # Local imports +from spyder_kernels.comms.commbase import CommBase +from spyder_kernels.customize.spyderpdb import SpyderPdb from spyder_kernels.utils.iofuncs import iofunctions +from spyder_kernels.utils.pythonenv import PythonEnvType from spyder_kernels.utils.test_utils import get_kernel, get_log_text -from spyder_kernels.customize.spyderpdb import SpyderPdb -from spyder_kernels.comms.commbase import CommBase # ============================================================================= # Constants and utility functions @@ -277,7 +279,7 @@ def test_get_namespace_view(kernel): """ Test the namespace view of the kernel. """ - execute = asyncio.run(kernel.do_execute('a = 1', True)) + asyncio.run(kernel.do_execute('a = 1', True)) nsview = repr(kernel.get_namespace_view()) assert "'a':" in nsview @@ -293,7 +295,7 @@ def test_get_namespace_view_filter_on(kernel, filter_on): """ Test the namespace view of the kernel with filters on and off. """ - execute = asyncio.run(kernel.do_execute('a = 1', True)) + asyncio.run(kernel.do_execute('a = 1', True)) asyncio.run(kernel.do_execute('TestFilterOff = 1', True)) settings = kernel.namespace_view_settings @@ -985,7 +987,7 @@ def test_namespaces_in_pdb(kernel): Test namespaces in pdb """ # Define get_ipython for timeit - get_ipython = lambda: kernel.shell + get_ipython = lambda: kernel.shell # noqa kernel.shell.user_ns["test"] = 0 pdb_obj = SpyderPdb() pdb_obj.curframe = inspect.currentframe() @@ -1061,7 +1063,7 @@ def test_functions_with_locals_in_pdb_2(kernel): This is another regression test for spyder-ide/spyder-kernels#345 """ - baba = 1 + baba = 1 # noqa pdb_obj = SpyderPdb() pdb_obj.curframe = inspect.currentframe() pdb_obj.curframe_locals = pdb_obj.curframe.f_locals @@ -1098,7 +1100,7 @@ def test_locals_globals_in_pdb(kernel): """ Test thal locals and globals work properly in Pdb. """ - a = 1 + a = 1 # noqa pdb_obj = SpyderPdb() pdb_obj.curframe = inspect.currentframe() pdb_obj.curframe_locals = pdb_obj.curframe.f_locals @@ -1140,10 +1142,8 @@ def test_locals_globals_in_pdb(kernel): @pytest.mark.parametrize("backend", [None, 'inline', 'tk', 'qt']) @pytest.mark.skipif( os.environ.get('USE_CONDA') != 'true', - reason="Doesn't work with pip packages") -@pytest.mark.skipif( - sys.version_info[:2] < (3, 9), - reason="Too flaky in Python 3.8 and doesn't work in older versions") + reason="Doesn't work with pip packages" +) def test_get_interactive_backend(backend): """ Test that we correctly get the interactive backend set in the kernel. @@ -1157,14 +1157,17 @@ def test_get_interactive_backend(backend): # Set backend if backend is not None: client.execute_interactive( - "%matplotlib {}".format(backend), timeout=TIMEOUT) + "%matplotlib {}".format(backend), timeout=TIMEOUT + ) client.execute_interactive( - "import time; time.sleep(.1)", timeout=TIMEOUT) + "import time; time.sleep(.1)", timeout=TIMEOUT + ) # Get backend code = "backend = get_ipython().kernel.get_mpl_interactive_backend()" reply = client.execute_interactive( - code, user_expressions={'output': 'backend'}, timeout=TIMEOUT) + code, user_expressions={'output': 'backend'}, timeout=TIMEOUT + ) # Get value obtained through user_expressions user_expressions = reply['content']['user_expressions'] @@ -1239,7 +1242,7 @@ def test_debug_namespace(tmpdir): d.write('def func():\n bb = "hello"\n breakpoint()\nfunc()') # Run code file `d` - msg_id = client.execute("%runfile {}".format(repr(str(d)))) + client.execute("%runfile {}".format(repr(str(d)))) # make sure that 'bb' returns 'hello' client.get_stdin_msg(timeout=TIMEOUT) @@ -1370,8 +1373,7 @@ def test_non_strings_in_locals(kernel): This is a regression test for issue spyder-ide/spyder#19145 """ - execute = asyncio.run(kernel.do_execute('locals().update({1:2})', True)) - + asyncio.run(kernel.do_execute('locals().update({1:2})', True)) nsview = repr(kernel.get_namespace_view()) assert "1:" in nsview @@ -1382,9 +1384,7 @@ def test_django_settings(kernel): This is a regression test for issue spyder-ide/spyder#19516 """ - execute = asyncio.run(kernel.do_execute( - 'from django.conf import settings', True)) - + asyncio.run(kernel.do_execute('from django.conf import settings', True)) nsview = repr(kernel.get_namespace_view()) assert "'settings':" in nsview @@ -1410,5 +1410,28 @@ def test_hard_link_pdb(tmpdir): assert pdb_obj.canonic(str(d)) == pdb_obj.canonic(str(hard_link)) +@pytest.mark.skipif(not os.environ.get('CI'), reason="Only works on CIs") +def test_get_pythonenv_info(kernel): + """Test the output we get from this method.""" + output = kernel.get_pythonenv_info() + assert output["path"] == sys.executable + + if os.environ.get('USE_CONDA'): + assert output["name"] == "test" + assert output["env_type"] == PythonEnvType.Conda + else: + assert output["env_type"] in [ + # This Custom here accounts for Linux packagers that run our tests + # in their CIs + PythonEnvType.Custom, + PythonEnvType.Conda, + ] + + # Check these keys are present. Otherwise we'll break Spyder. + assert output["python_version"] == sys.version.split()[0] + assert output["ipython_version"] == ipython_release.version + assert output["sys_version"] == sys.version + + if __name__ == "__main__": pytest.main() diff --git a/spyder_kernels/utils/pythonenv.py b/spyder_kernels/utils/pythonenv.py new file mode 100644 index 00000000..06e90811 --- /dev/null +++ b/spyder_kernels/utils/pythonenv.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) 2009- Spyder Kernels Contributors +# +# Licensed under the terms of the MIT License +# (see spyder_kernels/__init__.py for details) +# ----------------------------------------------------------------------------- + +"""Utilities to get information about Python environments.""" + +# Standard library imports +from __future__ import annotations +import os +from pathlib import Path +from typing import TypedDict + + +class PythonEnvType: + """Enum with the different types of Python environments we can detect.""" + + Conda = "conda" + PyEnv = "pyenv" + Custom = "custom" # Nor Conda or Pyenv + + +class PythonEnvInfo(TypedDict): + """Schema for Python environment information.""" + + path: str + env_type: PythonEnvType + name: str + python_version: str + + # These keys are necessary to build the console banner in Spyder + ipython_version: str + sys_version: str + + +def add_quotes(path): + """Return quotes if needed for spaces on path.""" + quotes = '"' if ' ' in path and '"' not in path else '' + return '{quotes}{path}{quotes}'.format(quotes=quotes, path=path) + + +def get_conda_env_path(pyexec, quote=False): + """ + Return the full path to the conda environment from a given python + executable. + + If `quote` is True, then quotes are added if spaces are found in the path. + """ + pyexec = pyexec.replace('\\', '/') + if os.name == 'nt': + conda_env = os.path.dirname(pyexec) + else: + conda_env = os.path.dirname(os.path.dirname(pyexec)) + + if quote: + conda_env = add_quotes(conda_env) + + return conda_env + + +def is_conda_env(prefix=None, pyexec=None): + """Check if prefix or python executable are in a conda environment.""" + if pyexec is not None: + pyexec = pyexec.replace('\\', '/') + + if (prefix is None and pyexec is None) or (prefix and pyexec): + raise ValueError('Only `prefix` or `pyexec` should be provided!') + + if pyexec and prefix is None: + prefix = get_conda_env_path(pyexec).replace('\\', '/') + + return os.path.exists(os.path.join(prefix, 'conda-meta')) + + +def is_pyenv_env(pyexec): + """Check if a python executable is a Pyenv environment.""" + path = Path(pyexec) + return "pyenv" in path.parts[:-1] + + +def get_env_dir(interpreter, only_dir=False): + """Get the environment directory from the interpreter executable.""" + path = Path(interpreter) + + if os.name == 'nt': + # This is enough for Conda and Pyenv envs + env_dir = path.parent + + # This is necessary for envs created with `python -m venv` + if env_dir.parts[-1].lower() == "scripts": + env_dir = path.parents[1] + else: + env_dir = path.parents[1] + + return env_dir.parts[-1] if only_dir else str(env_dir) diff --git a/spyder_kernels/utils/tests/test_pythonenv.py b/spyder_kernels/utils/tests/test_pythonenv.py new file mode 100644 index 00000000..025a7f40 --- /dev/null +++ b/spyder_kernels/utils/tests/test_pythonenv.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) 2009- Spyder Kernels Contributors +# +# Licensed under the terms of the MIT License +# (see spyder_kernels/__init__.py for details) +# ----------------------------------------------------------------------------- + +""" +Tests for utilities in the pythonenv module +""" + +# Standard library imports +import os + +# Third-party imports +import pytest + +# Local imports +from spyder_kernels.utils.pythonenv import ( + add_quotes, + get_conda_env_path, + get_env_dir, +) + + +if os.name == 'nt': + TEST_PYEXEC = 'c:/miniconda/envs/foobar/python.exe' +else: + TEST_PYEXEC = '/miniconda/envs/foobar/bin/python' + + +def test_add_quotes(): + output = add_quotes('/some path/with spaces') + assert output == '"/some path/with spaces"' + + output = add_quotes('/some-path/with-no-spaces') + assert output == '/some-path/with-no-spaces' + + +def test_get_conda_env_path(): + output = get_conda_env_path(TEST_PYEXEC) + if os.name == 'nt': + assert output == 'c:/miniconda/envs/foobar' + else: + assert output == '/miniconda/envs/foobar' + + +def test_get_env_dir(): + output_dir = get_env_dir(TEST_PYEXEC, only_dir=False) + if os.name == "nt": + assert output_dir == 'c:\\miniconda\\envs\\foobar' + else: + assert output_dir == '/miniconda/envs/foobar' + + output = get_env_dir(TEST_PYEXEC, only_dir=True) + assert output == "foobar" + + if os.name == "nt": + venv_pyexec = 'C:\\Miniconda3\\envs\\foobar\\Scripts\\python.exe' + output = get_env_dir(venv_pyexec, only_dir=True) + assert output == "foobar" + + +if __name__ == "__main__": + pytest.main()