diff --git a/.gitignore b/.gitignore index 44716ea..50c8521 100644 --- a/.gitignore +++ b/.gitignore @@ -232,3 +232,7 @@ fabric.properties # Android studio 3.1+ serialized cache file .idea/caches/build_file_checksums.ser + +# local config files +requirements.txt +plugin_src/PyCharmDebug/Config/tool_config.json \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9faef7f..13194be 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,13 +55,8 @@ Project dependencies are available in the [requirements.in](requirements.in) fil pip-compile --output-file requirements.txt requirements.in requirements-dev.in requirements-test.in ``` 4. Install dependencies: - * Windows: - * ```sh - pip-sync --python-executable %VIRTUAL_ENV%/scripts/python.exe requirements.txt - ``` - * Linux: - * ```sh - pip-sync --python-executable $VIRTUAL_ENV/bin/python.exe requirements.txt + * ```sh + pip-sync ``` diff --git a/plugin_src/PyCharmDebug/Content/Python/init_unreal.py b/plugin_src/PyCharmDebug/Content/Python/init_unreal.py index 4da91e2..1ccf9db 100644 --- a/plugin_src/PyCharmDebug/Content/Python/init_unreal.py +++ b/plugin_src/PyCharmDebug/Content/Python/init_unreal.py @@ -1,4 +1,4 @@ -""" Plugin initialization script """ +"""Plugin initialization script""" try: from pycharmdebug.menu import install # type: ignore diff --git a/plugin_src/PyCharmDebug/Content/Python/pycharmdebug/utils.py b/plugin_src/PyCharmDebug/Content/Python/pycharmdebug/utils.py index c750603..d1fe5ff 100644 --- a/plugin_src/PyCharmDebug/Content/Python/pycharmdebug/utils.py +++ b/plugin_src/PyCharmDebug/Content/Python/pycharmdebug/utils.py @@ -1,8 +1,14 @@ -from pathlib import Path import os +import sys import json +import subprocess +from pathlib import Path +from typing import Optional -from unreal import PluginBlueprintLibrary +from unreal import ( + PluginBlueprintLibrary, + log_error, +) from .exceptions import ( PyCharmDebugRuntimeError, @@ -115,23 +121,52 @@ def find_system_dbg_egg() -> str: PyCharm bin path not found System debug egg not found """ - pycharm_bin_dir = os.environ.get("PyCharm") - if pycharm_bin_dir is None: + pycharm_installation_path = resolve_os_specific_pycharm_path() + if not pycharm_installation_path or not pycharm_installation_path.is_dir(): raise PyCharmDebugRuntimeError("PyCharm installation not found") - pycharm_dir_path: Path = Path(pycharm_bin_dir.split(";")[0]) - - if pycharm_dir_path.is_dir() is False: - raise PyCharmDebugRuntimeError("PyCharm bin path not found") - - egg_path: Path = pycharm_dir_path.parent.joinpath("debug-eggs/pydevd-pycharm.egg") + egg_path: Path = pycharm_installation_path.joinpath("debug-eggs/pydevd-pycharm.egg") - if egg_path.is_file() is False: + if not egg_path.is_file(): raise PyCharmDebugRuntimeError("System debug egg not found") return egg_path.as_posix() +def resolve_os_specific_pycharm_path() -> Optional[Path]: + """Attempt to resolve the OS specific PyCharm installation path + + Returns: + Path: PyCharm installation path or None + """ + if sys.platform.startswith("win"): + pycharm_bin_dir = os.environ.get("PyCharm") + if not pycharm_bin_dir: + return None + return Path(pycharm_bin_dir.split(";")[0]).parent + + if sys.platform.startswith("darwin"): # macOS + try: + output = subprocess.check_output( + ["mdfind", "kMDItemCFBundleIdentifier == 'com.jetbrains.pycharm'"], + text=True, + ).strip() + return ( + Path(output.split("\n", maxsplit=1)[0]) / "Contents" if output else None + ) + + except Exception: # pylint: disable=broad-exception-caught + log_error("Failed to import pydevd_pycharm") + + if sys.platform.startswith("linux"): + log_error("Linux") + return None + + log_error(f"Unknown: {sys.platform}") + + return None + + def get_debug_egg() -> str: """Get the debug egg location from the config file. diff --git a/requirements.txt b/requirements.txt index 8b13789..e69de29 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +0,0 @@ - diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 63e6589..cb13b8e 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -1,12 +1,14 @@ +import sys + import pytest def test_get_plugin_config_expects_plugin_config_path(mocker): # Arrange from pycharmdebug.utils import get_plugin_config + mocker.patch( - "unreal.PluginBlueprintLibrary.get_plugin_base_dir", - return_value="/foo/bar" + "unreal.PluginBlueprintLibrary.get_plugin_base_dir", return_value="/foo/bar" ) mocker.patch("pathlib.Path.exists", return_value=True) @@ -20,9 +22,9 @@ def test_get_plugin_config_expects_plugin_config_path(mocker): def test_get_plugin_config_create_on_fail_expects_plugin_config_created(mocker): # Arrange from pycharmdebug.utils import get_plugin_config + mocker.patch( - "unreal.PluginBlueprintLibrary.get_plugin_base_dir", - return_value="/foo/bar" + "unreal.PluginBlueprintLibrary.get_plugin_base_dir", return_value="/foo/bar" ) mocker.patch("pathlib.Path.exists", return_value=False) mocked_path_touch = mocker.patch("pathlib.Path.touch") @@ -34,14 +36,16 @@ def test_get_plugin_config_create_on_fail_expects_plugin_config_created(mocker): mocked_path_touch.assert_called_once() -def test_get_plugin_config_plugin_config_not_found_expects_raises_PyCharmDebugRuntimeError(mocker): +def test_get_plugin_config_plugin_config_not_found_expects_raises_PyCharmDebugRuntimeError( + mocker, +): # Arrange from pycharmdebug.utils import get_plugin_config from pycharmdebug.exceptions import PyCharmDebugRuntimeError mocker.patch( "pycharmdebug.utils.PluginBlueprintLibrary.get_plugin_base_dir", - return_value=None + return_value=None, ) # Act @@ -55,6 +59,7 @@ def test_get_plugin_config_plugin_config_not_found_expects_raises_PyCharmDebugRu def test_get_debug_port_expects_42(mocker): # Arrange from pycharmdebug.utils import get_debug_port + mocker.patch("builtins.open") mocker.patch("json.load", return_value={"port_number": 42}) mocker.patch("pycharmdebug.utils.get_plugin_config") @@ -66,17 +71,14 @@ def test_get_debug_port_expects_42(mocker): assert result == 42 -def test_get_debug_port_plugin_config_not_found_expects_fallback_to_default_port(mocker): +def test_get_debug_port_plugin_config_not_found_expects_fallback_to_default_port( + mocker, +): # Arrange - from pycharmdebug.utils import ( - get_debug_port, - DEFAULT_PORT_NUMBER - ) + from pycharmdebug.utils import get_debug_port, DEFAULT_PORT_NUMBER + mocker.patch("builtins.open") - mocker.patch( - "pycharmdebug.utils.get_plugin_config", - return_value=None - ) + mocker.patch("pycharmdebug.utils.get_plugin_config", return_value=None) # Act result = get_debug_port() @@ -91,6 +93,7 @@ def test_get_debug_port_config_entry_empty_expects_fallback_to_default_port(mock get_debug_port, DEFAULT_PORT_NUMBER, ) + mocker.patch("builtins.open") mocker.patch("json.load", return_value={}) mocker.patch("pycharmdebug.utils.get_plugin_config") @@ -108,6 +111,7 @@ def test_get_debug_port_config_entry_is_none_expects_fallback_to_default_port(mo get_debug_port, DEFAULT_PORT_NUMBER, ) + mocker.patch("builtins.open") mocker.patch("json.load", return_value={"port_number": None}) mocker.patch("pycharmdebug.utils.get_plugin_config") @@ -125,6 +129,7 @@ def test_set_debug_port_expects_42_dumped_true_returned(mocker): set_debug_port, DEFAULT_PORT_NUMBER, ) + mocker.patch("builtins.open") mocker.patch("json.load", return_value={"port_number": DEFAULT_PORT_NUMBER}) mock_dump = mocker.patch("json.dump") @@ -134,20 +139,23 @@ def test_set_debug_port_expects_42_dumped_true_returned(mocker): result = set_debug_port(42) # Assert - mock_dump.assert_called_once_with( - {"port_number": 42}, mocker.ANY, indent=4 - ) + mock_dump.assert_called_once_with({"port_number": 42}, mocker.ANY, indent=4) assert result is True -def test_set_debug_port_existing_data_in_config_expects_existing_data_maintained(mocker): +def test_set_debug_port_existing_data_in_config_expects_existing_data_maintained( + mocker, +): # Arrange from pycharmdebug.utils import ( set_debug_port, DEFAULT_PORT_NUMBER, ) + mocker.patch("builtins.open") - mocker.patch("json.load", return_value={"port_number": DEFAULT_PORT_NUMBER, "foo": "bar"}) + mocker.patch( + "json.load", return_value={"port_number": DEFAULT_PORT_NUMBER, "foo": "bar"} + ) mock_dump = mocker.patch("json.dump") mocker.patch("pycharmdebug.utils.get_plugin_config") @@ -156,18 +164,20 @@ def test_set_debug_port_existing_data_in_config_expects_existing_data_maintained # Assert mock_dump.assert_called_once_with( - {"port_number": 42, "foo": "bar"}, mocker.ANY, indent=4 + {"port_number": 42, "foo": "bar"}, mocker.ANY, indent=4 ) assert result is True - def test_set_debug_port_incorrect_type_expects_raises_PyCharmDebugTypeError(mocker): # Arrange from pycharmdebug.exceptions import PyCharmDebugTypeError from pycharmdebug.utils import set_debug_port + mocker.patch("builtins.open") - mocker.patch("json.load", return_value={"port_number": 5678, "debug_egg": "/foo/bar.egg"}) + mocker.patch( + "json.load", return_value={"port_number": 5678, "debug_egg": "/foo/bar.egg"} + ) mocker.patch("pycharmdebug.utils.get_plugin_config") # Act @@ -186,15 +196,16 @@ def test_set_debug_port_higher_than_max_expects_raises_PyCharmDebugRuntimeError( DEFAULT_PORT_NUMBER, MAX_PORT_NUMBER, ) + mocker.patch("builtins.open") mocker.patch( "json.load", - return_value={"port_number": DEFAULT_PORT_NUMBER, "debug_egg": "/foo/bar.egg"} + return_value={"port_number": DEFAULT_PORT_NUMBER, "debug_egg": "/foo/bar.egg"}, ) # Act with pytest.raises(PyCharmDebugRuntimeError) as _ex: - set_debug_port(MAX_PORT_NUMBER+1) + set_debug_port(MAX_PORT_NUMBER + 1) # Assert assert "Port must be between 0 and 65535" in str(_ex) @@ -212,21 +223,23 @@ def test_set_debug_port_lower_than_min_expects_raises_PyCharmDebugRuntimeError(m mocker.patch("builtins.open") mocker.patch( "json.load", - return_value={"port_number": DEFAULT_PORT_NUMBER, "debug_egg": "/foo/bar.egg"} + return_value={"port_number": DEFAULT_PORT_NUMBER, "debug_egg": "/foo/bar.egg"}, ) # Act with pytest.raises(PyCharmDebugRuntimeError) as _ex: - set_debug_port(MIN_PORT_NUMBER-1) + set_debug_port(MIN_PORT_NUMBER - 1) # Assert assert "Port must be between 0 and 65535" in str(_ex) - -def test_set_debug_port_no_plugin_config_exists_expects_true_42_dumped_and_file_created(mocker): +def test_set_debug_port_no_plugin_config_exists_expects_true_42_dumped_and_file_created( + mocker, +): # Arrange from pycharmdebug.utils import set_debug_port + mocker.patch("builtins.open") mocker.patch("json.load", return_value={}) mocked_dump = mocker.patch("json.dump") @@ -247,6 +260,7 @@ def test_set_debug_port_plugin_config_is_none_and_fails_to_create_expects_false( set_debug_port, DEFAULT_PORT_NUMBER, ) + mocker.patch("builtins.open") mocker.patch("json.load", return_value={"port_number": DEFAULT_PORT_NUMBER}) mock_dump = mocker.patch("json.dump") @@ -259,9 +273,13 @@ def test_set_debug_port_plugin_config_is_none_and_fails_to_create_expects_false( assert result is False +@pytest.mark.skipif( + not sys.platform.startswith("win"), reason="This test is specific to Windows." +) def test_find_system_dbg_egg_expects_path_to_egg(mocker): # Arrange from pycharmdebug.utils import find_system_dbg_egg + mocker.patch("os.environ.get", return_value="/foo/bar/bin;") mocker.patch("pathlib.Path.is_dir", return_value=True) mocker.patch("pathlib.Path.is_file", return_value=True) @@ -273,11 +291,18 @@ def test_find_system_dbg_egg_expects_path_to_egg(mocker): assert result == "/foo/bar/debug-eggs/pydevd-pycharm.egg" -def test_find_system_dbg_egg_cant_resolve_pycharm_installation_raises_PyCharmDebugRuntimeError(mocker): +def test_find_system_dbg_egg_windows_cant_resolve_pycharm_installation_raises_PyCharmDebugRuntimeError( + mocker, +): # Arrange + from pathlib import Path from pycharmdebug.utils import find_system_dbg_egg from pycharmdebug.exceptions import PyCharmDebugRuntimeError + mocker.patch("os.environ.get", return_value=None) + mocker.patch( + Path("pycharmdebug.utils.resolve_os_specific_pycharm_path"), return_value="win" + ) # Act with pytest.raises(PyCharmDebugRuntimeError) as _ex: @@ -287,24 +312,12 @@ def test_find_system_dbg_egg_cant_resolve_pycharm_installation_raises_PyCharmDeb assert "PyCharm installation not found" in str(_ex) -def test_find_system_dbg_egg_cant_resolve_pycharm_bin_dir_expects_raises_PyCharmDebugRuntimeError(mocker): - # Arrange - from pycharmdebug.utils import find_system_dbg_egg - from pycharmdebug.exceptions import PyCharmDebugRuntimeError - - mocker.patch("os.environ.get", return_value="/foo/bar/bin;") - mocker.patch("pathlib.Path.is_dir", return_value=False) - - # Act - with pytest.raises(PyCharmDebugRuntimeError) as _ex: - find_system_dbg_egg() - - # Assert - - assert "PyCharm bin path not found" in str(_ex) - -# -def test_find_system_dbg_egg_cant_resolve_debug_egg_file_expects_raises_PyCharmDebugRuntimeError(mocker): +@pytest.mark.skipif( + not sys.platform.startswith("win"), reason="This test is specific to Windows." +) +def test_find_system_dbg_egg_cant_resolve_debug_egg_file_expects_raises_PyCharmDebugRuntimeError( + mocker, +): # Arrange from pycharmdebug.utils import find_system_dbg_egg from pycharmdebug.exceptions import PyCharmDebugRuntimeError @@ -321,15 +334,16 @@ def test_find_system_dbg_egg_cant_resolve_debug_egg_file_expects_raises_PyCharmD assert "System debug egg not found" in str(_ex) -def test_get_debug_egg_exists_in_config_expects_config_value_mocked_find_system_dbg_egg_not_called(mocker): +def test_get_debug_egg_exists_in_config_expects_config_value_mocked_find_system_dbg_egg_not_called( + mocker, +): # Arrange from pycharmdebug.utils import get_debug_egg + mocker.patch("builtins.open") mocker.patch("pycharmdebug.utils.get_plugin_config") mocker.patch("json.load", return_value={"debug_egg": "/foo/bar/pydevd-pycharm.egg"}) - mocked_find_system_dbg_egg = mocker.patch( - "pycharmdebug.utils.find_system_dbg_egg" - ) + mocked_find_system_dbg_egg = mocker.patch("pycharmdebug.utils.find_system_dbg_egg") mocker.patch("pathlib.Path.is_file", return_value=True) # Act @@ -340,9 +354,12 @@ def test_get_debug_egg_exists_in_config_expects_config_value_mocked_find_system_ mocked_find_system_dbg_egg.assert_not_called() -def test_get_debug_egg_serialized_data_is_empty_string_expects_empty_string_returned(mocker): +def test_get_debug_egg_serialized_data_is_empty_string_expects_empty_string_returned( + mocker, +): # Arrange from pycharmdebug.utils import get_debug_egg + mocker.patch("builtins.open") mocker.patch("pycharmdebug.utils.get_plugin_config") mocker.patch("json.load", return_value={"debug_egg": ""}) @@ -355,7 +372,9 @@ def test_get_debug_egg_serialized_data_is_empty_string_expects_empty_string_retu assert result == "" -def test_get_debug_egg_serialized_data_is_invalid_expects_PyCharmDebugRuntimeError_raised(mocker): +def test_get_debug_egg_serialized_data_is_invalid_expects_PyCharmDebugRuntimeError_raised( + mocker, +): # Arrange from pycharmdebug.utils import get_debug_egg from pycharmdebug.exceptions import PyCharmDebugRuntimeError @@ -372,10 +391,10 @@ def test_get_debug_egg_serialized_data_is_invalid_expects_PyCharmDebugRuntimeErr assert "No valid debug_egg location saved in the config" in str(_ex) - def test_set_debug_egg_expects_path_dumped_and_returns_true(mocker): # Arrange from pycharmdebug.utils import set_debug_egg + mocker.patch("pycharmdebug.utils.get_plugin_config") mocker.patch("builtins.open") mocker.patch("json.load", return_value={}) @@ -388,7 +407,8 @@ def test_set_debug_egg_expects_path_dumped_and_returns_true(mocker): # Assert assert result is True mocked_dump.assert_called_once_with( - {"debug_egg": "/foo/bar/pydevd-pycharm.egg"}, mocker.ANY, indent=4) + {"debug_egg": "/foo/bar/pydevd-pycharm.egg"}, mocker.ANY, indent=4 + ) def test_set_debug_egg_invalid_egg_file_expects_raises_PyCharmDebugTypeError(mocker): @@ -408,9 +428,12 @@ def test_set_debug_egg_invalid_egg_file_expects_raises_PyCharmDebugTypeError(moc assert "Invalid egg file" in str(_ex) -def test_set_debug_egg_config_doesnt_exist_gets_created_and_value_dumped_returns_true(mocker): +def test_set_debug_egg_config_doesnt_exist_gets_created_and_value_dumped_returns_true( + mocker, +): # Arrange from pycharmdebug.utils import set_debug_egg + mocker.patch("builtins.open") mocker.patch("json.load", return_value={}) mocker.patch("pathlib.Path.is_file", return_value=True) @@ -424,14 +447,15 @@ def test_set_debug_egg_config_doesnt_exist_gets_created_and_value_dumped_returns # Assert assert result is True mocked_dump.assert_called_once_with( - {"debug_egg": "/foo/bar/pydevd-pycharm.egg"}, mocker.ANY, indent=4) + {"debug_egg": "/foo/bar/pydevd-pycharm.egg"}, mocker.ANY, indent=4 + ) mocked_path_touch.assert_called_once() - def test_set_debug_egg_config_has_other_data_expects_other_data_maintained(mocker): # Arrange from pycharmdebug.utils import set_debug_egg + mocker.patch("pycharmdebug.utils.get_plugin_config") mocker.patch("builtins.open") mocker.patch("json.load", return_value={"foo": "bar"}) @@ -444,18 +468,14 @@ def test_set_debug_egg_config_has_other_data_expects_other_data_maintained(mocke # Assert assert result is True mocked_dump.assert_called_once_with( - { - "foo": "bar", - "debug_egg": "/foo/bar/pydevd-pycharm.egg" - }, - mocker.ANY, - indent=4 + {"foo": "bar", "debug_egg": "/foo/bar/pydevd-pycharm.egg"}, mocker.ANY, indent=4 ) def test_set_debug_egg_empty_string_set_expects_empty_string_serialized(mocker): # Arrange from pycharmdebug.utils import set_debug_egg + mocker.patch("pycharmdebug.utils.get_plugin_config") mocker.patch("builtins.open") mocker.patch("json.load", return_value={"debug_egg": "foo"}) @@ -466,7 +486,7 @@ def test_set_debug_egg_empty_string_set_expects_empty_string_serialized(mocker): # Assert assert result is True - mocked_dump.assert_called_once_with({"debug_egg": ""}, mocker.ANY,indent=4) + mocked_dump.assert_called_once_with({"debug_egg": ""}, mocker.ANY, indent=4) def test_set_debug_egg_no_config_found_expects_PyCharmDebugRuntimeError(mocker):