From ae189da2a861d39ce31ca24efc121b442edbbfaa Mon Sep 17 00:00:00 2001 From: Fabio Zadrozny Date: Thu, 20 Oct 2022 16:44:57 -0300 Subject: [PATCH] Support dealing with paths with inconsistent casing on Mac OS. Fixes #1031 --- .../_vendored/pydevd/pydevd_file_utils.py | 143 +++++++++++------- .../tests_python/test_convert_utilities.py | 70 ++++++++- .../tests_python/test_pydevd_filtering.py | 14 +- 3 files changed, 164 insertions(+), 63 deletions(-) diff --git a/src/debugpy/_vendored/pydevd/pydevd_file_utils.py b/src/debugpy/_vendored/pydevd/pydevd_file_utils.py index ffe0d7f6..07ef9cb8 100644 --- a/src/debugpy/_vendored/pydevd/pydevd_file_utils.py +++ b/src/debugpy/_vendored/pydevd/pydevd_file_utils.py @@ -43,7 +43,7 @@ r''' from _pydev_bundle import pydev_log from _pydevd_bundle.pydevd_constants import DebugInfoHolder, IS_WINDOWS, IS_JYTHON, \ - DISABLE_FILE_VALIDATION, is_true_in_env + DISABLE_FILE_VALIDATION, is_true_in_env, IS_MAC from _pydev_bundle._pydev_filesystem_encoding import getfilesystemencoding from _pydevd_bundle.pydevd_comm_constants import file_system_encoding, filesystem_encoding_is_utf8 from _pydev_bundle.pydev_log import error_once @@ -124,6 +124,67 @@ convert_to_long_pathname = lambda filename:filename convert_to_short_pathname = lambda filename:filename get_path_with_real_case = lambda filename:filename +# Note that we have a cache for previous list dirs... the only case where this may be an +# issue is if the user actually changes the case of an existing file on while +# the debugger is executing (as this seems very unlikely and the cache can save a +# reasonable time -- especially on mapped drives -- it seems nice to have it). +_listdir_cache = {} + +# May be changed during tests. +os_listdir = os.listdir + + +def _resolve_listing(resolved, iter_parts_lowercase, cache=_listdir_cache): + while True: # Note: while True to make iterative and not recursive + try: + resolve_lowercase = next(iter_parts_lowercase) # must be lowercase already + except StopIteration: + return resolved + + resolved_lower = resolved.lower() + + resolved_joined = cache.get((resolved_lower, resolve_lowercase)) + if resolved_joined is None: + dir_contents = cache.get(resolved_lower) + if dir_contents is None: + dir_contents = cache[resolved_lower] = os_listdir(resolved) + + for filename in dir_contents: + if filename.lower() == resolve_lowercase: + resolved_joined = os.path.join(resolved, filename) + cache[(resolved_lower, resolve_lowercase)] = resolved_joined + break + else: + raise FileNotFoundError('Unable to find: %s in %s. Dir Contents: %s' % ( + resolve_lowercase, resolved, dir_contents)) + + resolved = resolved_joined + + +def _resolve_listing_parts(resolved, parts_in_lowercase, filename): + try: + if parts_in_lowercase == ['']: + return resolved + return _resolve_listing(resolved, iter(parts_in_lowercase)) + except FileNotFoundError: + _listdir_cache.clear() + # Retry once after clearing the cache we have. + try: + return _resolve_listing(resolved, iter(parts_in_lowercase)) + except FileNotFoundError: + if os_path_exists(filename): + # This is really strange, ask the user to report as error. + pydev_log.critical( + 'pydev debugger: critical: unable to get real case for file. Details:\n' + 'filename: %s\ndrive: %s\nparts: %s\n' + '(please create a ticket in the tracker to address this).', + filename, resolved, parts_in_lowercase + ) + pydev_log.exception() + # Don't fail, just return the original file passed. + return filename + + if sys.platform == 'win32': try: import ctypes @@ -155,38 +216,6 @@ if sys.platform == 'win32': return filename - # Note that we have a cache for previous list dirs... the only case where this may be an - # issue is if the user actually changes the case of an existing file on windows while - # the debugger is executing (as this seems very unlikely and the cache can save a - # reasonable time -- especially on mapped drives -- it seems nice to have it). - _listdir_cache = {} - - def _resolve_listing(resolved, iter_parts, cache=_listdir_cache): - while True: # Note: while True to make iterative and not recursive - try: - resolve_lowercase = next(iter_parts) # must be lowercase already - except StopIteration: - return resolved - - resolved_lower = resolved.lower() - - resolved_joined = cache.get((resolved_lower, resolve_lowercase)) - if resolved_joined is None: - dir_contents = cache.get(resolved_lower) - if dir_contents is None: - dir_contents = cache[resolved_lower] = os.listdir(resolved) - - for filename in dir_contents: - if filename.lower() == resolve_lowercase: - resolved_joined = os.path.join(resolved, filename) - cache[(resolved_lower, resolve_lowercase)] = resolved_joined - break - else: - raise FileNotFoundError('Unable to find: %s in %s' % ( - resolve_lowercase, resolved)) - - resolved = resolved_joined - def _get_path_with_real_case(filename): # Note: this previously made: # convert_to_long_pathname(convert_to_short_pathname(filename)) @@ -206,28 +235,7 @@ if sys.platform == 'win32': parts = parts[1:] drive += os.path.sep parts = parts.lower().split(os.path.sep) - - try: - if parts == ['']: - return drive - return _resolve_listing(drive, iter(parts)) - except FileNotFoundError: - _listdir_cache.clear() - # Retry once after clearing the cache we have. - try: - return _resolve_listing(drive, iter(parts)) - except FileNotFoundError: - if os_path_exists(filename): - # This is really strange, ask the user to report as error. - pydev_log.critical( - 'pydev debugger: critical: unable to get real case for file. Details:\n' - 'filename: %s\ndrive: %s\nparts: %s\n' - '(please create a ticket in the tracker to address this).', - filename, drive, parts - ) - pydev_log.exception() - # Don't fail, just return the original file passed. - return filename + return _resolve_listing_parts(drive, parts, filename) # Check that it actually works _get_path_with_real_case(__file__) @@ -243,11 +251,29 @@ if sys.platform == 'win32': elif IS_JYTHON and IS_WINDOWS: def get_path_with_real_case(filename): + if filename.startswith('<'): + return filename + from java.io import File # noqa f = File(filename) ret = f.getCanonicalPath() return ret +elif IS_MAC: + + def get_path_with_real_case(filename): + if filename.startswith('<') or not os_path_exists(filename): + return filename # Not much we can do. + + parts = filename.lower().split('/') + + found = '' + while parts and parts[0] == '': + found += '/' + parts = parts[1:] + + return _resolve_listing_parts(found, parts, filename) + if IS_JYTHON: def _normcase_windows(filename): @@ -286,6 +312,13 @@ elif _filename_normalization == 'none': elif IS_WINDOWS: _default_normcase = _normcase_windows +elif IS_MAC: + + def _normcase_lower(filename): + return filename.lower() + + _default_normcase = _normcase_lower + else: _default_normcase = _normcase_linux diff --git a/src/debugpy/_vendored/pydevd/tests_python/test_convert_utilities.py b/src/debugpy/_vendored/pydevd/tests_python/test_convert_utilities.py index 54e664d7..d2511a8f 100644 --- a/src/debugpy/_vendored/pydevd/tests_python/test_convert_utilities.py +++ b/src/debugpy/_vendored/pydevd/tests_python/test_convert_utilities.py @@ -1,6 +1,6 @@ # coding: utf-8 import os.path -from _pydevd_bundle.pydevd_constants import IS_WINDOWS +from _pydevd_bundle.pydevd_constants import IS_WINDOWS, IS_MAC import io from _pydev_bundle.pydev_log import log_context import pytest @@ -14,6 +14,62 @@ def _reset_ide_os(): set_ide_os('WINDOWS' if sys.platform == 'win32' else 'UNIX') +@pytest.mark.skipif(sys.platform != 'win32', reason='Windows-only test.') +def test_get_path_with_real_case_windows_unc_path(monkeypatch): + import pydevd_file_utils + from pydevd_file_utils import get_path_with_real_case + + def temp_listdir(d): + # When we have a UNC drive in windows the "drive" is something as: + # \\MACHINE_NAME\MOUNT_POINT\ + if d == '\\\\A\\B\\': + return ['Cc'] + raise AssertionError('Unexpected: %s' % (d,)) + + monkeypatch.setattr(pydevd_file_utils, 'os_path_exists', lambda *args: True) + monkeypatch.setattr(pydevd_file_utils, 'os_listdir', temp_listdir) + assert get_path_with_real_case(r'\\a\b\cc') == r'\\A\B\Cc' + + +@pytest.mark.skipif(sys.platform != 'win32', reason='Windows-only test.') +def test_get_path_with_real_case_windows_slashes_drive(tmpdir): + from pydevd_file_utils import get_path_with_real_case + test_dir = str(tmpdir.mkdir("Test_Convert_Utilities")).lower() + real_case = get_path_with_real_case(test_dir) + assert real_case.endswith("Test_Convert_Utilities") + + prefix = '\\\\?\\' + path = prefix + test_dir + real_case = get_path_with_real_case(path) + assert real_case.endswith("Test_Convert_Utilities") + assert path.startswith(prefix) + + +@pytest.mark.skipif(not IS_MAC, reason='Mac-only test.') +def test_get_path_with_real_case_mac_os(tmpdir): + from pydevd_file_utils import get_path_with_real_case + test_dir = str(tmpdir.mkdir("Test_Convert_Utilities")).lower() + real_case = get_path_with_real_case(test_dir) + assert real_case.endswith("Test_Convert_Utilities") + + +@pytest.mark.skipif(not IS_MAC, reason='Mac-only test.') +def test_double_slash_mac(monkeypatch): + import pydevd_file_utils + from pydevd_file_utils import get_path_with_real_case + + def temp_listdir(d): + if d == '//': + return ['A'] + if d == '//A': + return ['Bb'] + raise AssertionError('Unexpected: %s' % (d,)) + + monkeypatch.setattr(pydevd_file_utils, 'os_path_exists', lambda *args: True) + monkeypatch.setattr(pydevd_file_utils, 'os_listdir', temp_listdir) + assert get_path_with_real_case(r'//a/bb') == r'//A/Bb' + + def test_convert_utilities(tmpdir): import pydevd_file_utils @@ -60,8 +116,12 @@ def test_convert_utilities(tmpdir): assert with_real_case.endswith('Test_Convert_Utilities') assert '~' not in with_real_case + elif IS_MAC: + assert pydevd_file_utils.normcase(test_dir) == test_dir.lower() + assert pydevd_file_utils.get_path_with_real_case(test_dir) == test_dir + else: - # On other platforms, nothing should change + # On Linux, nothing should change assert pydevd_file_utils.normcase(test_dir) == test_dir assert pydevd_file_utils.get_path_with_real_case(test_dir) == test_dir @@ -351,7 +411,7 @@ def test_zip_paths(tmpdir): # Check that we can deal with the zip path. assert pydevd_file_utils.exists(zipfile_path) abspath, realpath, basename = pydevd_file_utils.get_abs_path_real_path_and_base_from_file(zipfile_path) - if IS_WINDOWS: + if IS_WINDOWS or IS_MAC: assert abspath == zipfile_path assert basename == zip_basename.lower() else: @@ -431,7 +491,7 @@ def test_source_mapping(): assert source_mapping.map_to_client(filename, 12) == (filename, 12, False) -@pytest.mark.skipif(IS_WINDOWS, reason='Linux-only test') +@pytest.mark.skipif(IS_WINDOWS, reason='Linux/Mac-only test') def test_mapping_conflict_to_client(): import pydevd_file_utils @@ -481,7 +541,7 @@ _MAPPING_CONFLICT = [ ] -@pytest.mark.skipif(IS_WINDOWS, reason='Linux-only test') +@pytest.mark.skipif(IS_WINDOWS, reason='Linux/Mac-only test') def test_mapping_conflict_to_server(): import pydevd_file_utils diff --git a/src/debugpy/_vendored/pydevd/tests_python/test_pydevd_filtering.py b/src/debugpy/_vendored/pydevd/tests_python/test_pydevd_filtering.py index f1c3d7b8..6bf57721 100644 --- a/src/debugpy/_vendored/pydevd/tests_python/test_pydevd_filtering.py +++ b/src/debugpy/_vendored/pydevd/tests_python/test_pydevd_filtering.py @@ -1,4 +1,4 @@ -from _pydevd_bundle.pydevd_constants import IS_WINDOWS +from _pydevd_bundle.pydevd_constants import IS_WINDOWS, IS_MAC def test_in_project_roots_prefix_01(tmpdir): @@ -43,8 +43,16 @@ def test_in_project_roots(tmpdir): import os.path import sys - assert files_filtering._get_library_roots() == [ - os.path.normcase(x) + ('\\' if IS_WINDOWS else '/') for x in files_filtering._get_default_library_roots()] + + if IS_WINDOWS: + assert files_filtering._get_library_roots() == [ + os.path.normcase(x) + '\\' for x in files_filtering._get_default_library_roots()] + elif IS_MAC: + assert files_filtering._get_library_roots() == [ + x.lower() + '/' for x in files_filtering._get_default_library_roots()] + else: + assert files_filtering._get_library_roots() == [ + os.path.normcase(x) + '/' for x in files_filtering._get_default_library_roots()] site_packages = tmpdir.mkdir('site-packages') project_dir = tmpdir.mkdir('project')