diff --git a/pyxll_pycharm/__init__.py b/pyxll_pycharm/__init__.py index d410add..79c7153 100644 --- a/pyxll_pycharm/__init__.py +++ b/pyxll_pycharm/__init__.py @@ -6,6 +6,7 @@ Requires: - PyXLL >= 5.0.0 - PyCharm Professional + - Python >= 3.7 To install this package use:: @@ -16,7 +17,9 @@ [PYCHARM] port = 5000 suspend = 0 + fix_pydevd_import = 1 """ +from ._import_pydevd import use_pycharm_pydevd from pyxll import get_config import pkg_resources import ctypes @@ -25,23 +28,21 @@ _log = logging.getLogger(__name__) + _MB_YESNO = 0x04 _MB_OK = 0x0 _IDYES = 0x6 +# def connect_to_pycharm(*args): """Connect to the remote PyCharm debugger.""" - # Defer importing pydevd until it's actually needed as it will conflict with using - # other debuggers such as VS Code. - import pydevd_pycharm - import pydevd - # Get the settings from the config port = 5000 suspend = False stdout_to_server = True stderr_to_server = True + fix_pydevd_import = True cfg = get_config() if cfg.has_option("PYCHARM", "port"): @@ -68,74 +69,83 @@ def connect_to_pycharm(*args): except (ValueError, TypeError): _log.error("Unexpected value for PYCHARM.stderr_to_server.") - # If the debugger is not already running ask the user if they have started the debug server - if not pydevd.connected: - result = ctypes.windll.user32.MessageBoxA( - 0, - b"Have you started the PyCharm remote debugger on port %d?" % port, - b"PyCharm Remote Debug", - _MB_YESNO) - - if result != _IDYES: - ctypes.windll.user32.MessageBoxA( + if cfg.has_option("PYCHARM", "fix_pydevd_import"): + try: + fix_pydevd_import = bool(int(cfg.get("PYCHARM", "fix_pydevd_import"))) + except (ValueError, TypeError): + _log.error("Unexpected value for PYCHARM.fix_pydevd_import.") + + # Import pydevd_pycharm and pydevd, working around issues with multiple versions + # of pydevd being installed (e.g. when using ipython and debugpy). + with use_pycharm_pydevd(fix_pydevd_import) as (pydevd_pycharm, pydevd): + # If the debugger is not already running ask the user if they have started the debug server + if not pydevd.connected: + result = ctypes.windll.user32.MessageBoxA( 0, - b"Please start the PyCharm remote debugger on port %d first." % port, + b"Have you started the PyCharm remote debugger on port %d?" % port, b"PyCharm Remote Debug", - _MB_OK) - return - else: - # The debugger is already running so check if the user has restarted the debug server - result = ctypes.windll.user32.MessageBoxA( - 0, - b"The PyCharm debugger was already connected.\n" + - b"Have you re-started the PyCharm remote debugger on port %d?" % port, - b"PyCharm Remote Debug", - _MB_YESNO) - - if result != _IDYES: - ctypes.windll.user32.MessageBoxA( + _MB_YESNO) + + if result != _IDYES: + ctypes.windll.user32.MessageBoxA( + 0, + b"Please start the PyCharm remote debugger on port %d first." % port, + b"PyCharm Remote Debug", + _MB_OK) + return + else: + # The debugger is already running so check if the user has restarted the debug server + result = ctypes.windll.user32.MessageBoxA( 0, - b"Please re-start the PyCharm remote debugger on port %d first." % port, + b"The PyCharm debugger was already connected.\n" + + b"Have you re-started the PyCharm remote debugger on port %d?" % port, b"PyCharm Remote Debug", - _MB_OK) - return - - # Call stoptrace (this sets pydevd.connected to False) - _log.debug("Disconnecting from the PyCharm debugger...") - pydevd.stoptrace() - - # Undo the stdout/stderr redirection (not strictly necessary!) - if hasattr(sys, "_pydevd_out_buffer_") and hasattr(sys, "stdout_original"): - sys.stdout = sys.stdout_original - del sys._pydevd_out_buffer_ - - if hasattr(sys, "_pydevd_err_buffer_") and hasattr(sys, "stderr_original"): - sys.stderr = sys.stderr_original - del sys._pydevd_err_buffer_ - - # End the debugging session and kill all the pydevd threads - pydb = pydevd.get_global_debugger() - pydb.finish_debugging_session() - pydevd.kill_all_pydev_threads() - - # Wait for all the pydevd threads to end - _log.debug("Waiting for the pydevd threads to finish.") - threads = list(pydevd.PyDBDaemonThread.created_pydb_daemon_threads.keys()) - for thread in threads: - thread.join(timeout=1.0) - if thread.is_alive(): - raise RuntimeError("Timed out waiting for pydevd thread to terminate.") - - # Connect to the remote debugger - _log.debug("Connecting to the PyCharm debugger...") - pydevd_pycharm.settrace("localhost", - port=port, - suspend=suspend, - stdoutToServer=stdout_to_server, - stderrToServer=stderr_to_server) - - # Reset excepthook to the default to avoid a PyCharm bug - sys.excepthook = sys.__excepthook__ + _MB_YESNO) + + if result != _IDYES: + ctypes.windll.user32.MessageBoxA( + 0, + b"Please re-start the PyCharm remote debugger on port %d first." % port, + b"PyCharm Remote Debug", + _MB_OK) + return + + # Call stoptrace (this sets pydevd.connected to False) + _log.debug("Disconnecting from the PyCharm debugger...") + pydevd.stoptrace() + + # Undo the stdout/stderr redirection (not strictly necessary!) + if hasattr(sys, "_pydevd_out_buffer_") and hasattr(sys, "stdout_original"): + sys.stdout = sys.stdout_original + del sys._pydevd_out_buffer_ + + if hasattr(sys, "_pydevd_err_buffer_") and hasattr(sys, "stderr_original"): + sys.stderr = sys.stderr_original + del sys._pydevd_err_buffer_ + + # End the debugging session and kill all the pydevd threads + pydb = pydevd.get_global_debugger() + pydb.finish_debugging_session() + pydevd.kill_all_pydev_threads() + + # Wait for all the pydevd threads to end + _log.debug("Waiting for the pydevd threads to finish.") + threads = list(pydevd.PyDBDaemonThread.created_pydb_daemon_threads.keys()) + for thread in threads: + thread.join(timeout=1.0) + if thread.is_alive(): + raise RuntimeError("Timed out waiting for pydevd thread to terminate.") + + # Connect to the remote debugger + _log.debug("Connecting to the PyCharm debugger...") + pydevd_pycharm.settrace("localhost", + port=port, + suspend=suspend, + stdoutToServer=stdout_to_server, + stderrToServer=stderr_to_server) + + # Reset excepthook to the default to avoid a PyCharm bug + sys.excepthook = sys.__excepthook__ ctypes.windll.user32.MessageBoxA( 0, diff --git a/pyxll_pycharm/_import_pydevd.py b/pyxll_pycharm/_import_pydevd.py new file mode 100644 index 0000000..6da3753 --- /dev/null +++ b/pyxll_pycharm/_import_pydevd.py @@ -0,0 +1,129 @@ +from os.path import expandvars +from itertools import chain +from glob import glob +from pathlib import Path +from functools import lru_cache +import contextlib +import logging +import sys + +_log = logging.getLogger(__name__) + + +@contextlib.contextmanager +def _restore_sys_modules(): + # Take a copy of sys.modules and sys.path + sys_modules = dict(sys.modules) + sys_path = list(sys.path) + + yield + + # Restore sys.modules and sys.path + sys.modules.clear() + sys.modules.update(sys_modules) + sys.path.clear() + sys.path.extend(sys_path) + + +def _path_is_relative_to(a, b): + # Path.is_relative_to is new in Python 3.9 + try: + Path(a).relative_to(b) + return Path(a).relative_to(b) + except ValueError: + return False + + +@lru_cache(maxsize=2) +def _import_pydevd(apply_fix=True): + """Imports pydevd_pycharm and pydevd from the pydevd_pycharm package. + Manipulates sys.modules and sys.path to try and import the right packages. + This works around issues with debugpy also being installed. + See https://youtrack.jetbrains.com/issue/PY-40661/pydevd-pycharm-conflicts-with-pydevd-package. + """ + # If apply_fix is False then simply import and return the modules + if not apply_fix: + import pydevd_pycharm + import pydevd + + pydevd_pycharm_dir = Path(pydevd_pycharm.__file__).parent + pydevd_dir = Path(pydevd.__file__).parent + if pydevd_pycharm_dir != pydevd_dir: + _log.warning("Potential pydevd version conflict found. " + + f"Try uninstalling pydevd from '{pydevd_dir}' and reinstall pydevd_pycharm.") + + return pydevd_pycharm, pydevd + + try: + # Try importing pydevd_pycharm from the default sys.path first + import pydevd_pycharm + except ImportError: + # If that fails look for PyCharm and add it to sys.path + _log.debug("pydevd_pycharm not found on sys.path. Looking for PyCharm install...") + pydevd_eggs = list(reversed(sorted(chain.from_iterable( + glob(expandvars(f"{env_var}\\JetBrains\\PyCharm*\\debug-eggs\\pydevd-pycharm.egg"), recursive=True) + for env_var in ("${ProgramFiles}", "${ProgramFiles(x86)}"))))) + + if pydevd_eggs: + pydevd_egg = pydevd_eggs[0] + _log.debug(f"Found pydevd-pycharm in PyCharm install: {pydevd_egg}") + sys.path.insert(0, pydevd_egg) + + # Try to import pydevd_pycharm again (sys.path may have changed) + import pydevd_pycharm + + # Next import pydev + import pydevd + + # pydevd should be distributed as part of pydevd_pycharm + pydevd_pycharm_dir = Path(pydevd_pycharm.__file__).parent + pydevd_dir = Path(pydevd.__file__).parent + if pydevd_pycharm_dir != pydevd_dir: + _log.debug(f"Attempting to work around incompatible version of pydevd found: {pydevd.__file__}") + + # Remove all the existing pydevd modules from sys.modules + pydev_modules = set() + for name, module in sys.modules.items(): + if ((name.startswith("pydev") or name.startswith("_pydev")) + and (_path_is_relative_to(module.__file__, pydevd_dir) or + _path_is_relative_to(module.__file__, pydevd_pycharm_dir))): + pydev_modules.add(name) + + for name in pydev_modules: + del sys.modules[name] + + # Re-import pydevd_pycharm and pydevd with the PyCharm path appearing first on sys.path + sys.path.insert(0, str(pydevd_pycharm_dir)) + import pydevd_pycharm + import pydevd + + # Check what we've imported is now correct + pydevd_pycharm_dir = Path(pydevd_pycharm.__file__).parent + pydevd_dir = Path(pydevd.__file__).parent + if pydevd_pycharm_dir != pydevd_dir: + _log.warning("Potential pydevd version conflict found. " + + f"Try uninstalling pydevd from '{pydevd_dir}' and reinstall pydevd_pycharm.") + + return pydevd_pycharm, pydevd + + +@contextlib.contextmanager +def use_pycharm_pydevd(apply_fix=True): + """Context manager that imports pydevd_pycharm and pydevd + and ensures that sys.path includes the pycharm_pydevd path + for any late imports. + """ + if not apply_fix: + yield _import_pydevd(apply_fix) + return + + with _restore_sys_modules(): + pydevd_pycharm, pydevd = _import_pydevd(apply_fix) + + # Make sure that pydevd_pycharm.__file__ is first on the path + if apply_fix: + pycharm_dir = str(Path(pydevd_pycharm.__file__).parent) + if sys.path[0] != pycharm_dir: + sys.path.insert(0, sys.path.dirname(pydevd_pycharm.__file__)) + + yield pydevd_pycharm, pydevd diff --git a/setup.py b/setup.py index 7a13d62..40be984 100644 --- a/setup.py +++ b/setup.py @@ -4,6 +4,7 @@ PyCharm debugging support for PyXLL. Requires: + - Python >= 3.7 - PyXLL >= 5.0.0 - PyCharm Professional @@ -31,7 +32,7 @@ description="Adds PyCharm debugging support to PyXLL.", long_description=long_description, long_description_content_type='text/markdown', - version="0.2.1", + version="0.3.0", packages=find_packages(), include_package_data=True, package_data={ @@ -45,7 +46,7 @@ "Tracker": "https://github.com/pyxll/pyxll-pycharm/issues", }, classifiers=[ - "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", "License :: OSI Approved :: MIT License", "Operating System :: Microsoft :: Windows" ], @@ -58,5 +59,6 @@ install_requires=[ "pyxll >= 5.0.0", "pydevd-pycharm" - ] + ], + python_requires=">=3.7" )