Support dealing with paths with inconsistent casing on Mac OS. Fixes #1031

This commit is contained in:
Fabio Zadrozny 2022-10-20 16:44:57 -03:00
parent ac6465760a
commit ae189da2a8
3 changed files with 164 additions and 63 deletions

View file

@ -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

View file

@ -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

View file

@ -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')