Skip to content

Commit

Permalink
Support dealing with paths with inconsistent casing on Mac OS. Fixes #…
Browse files Browse the repository at this point in the history
  • Loading branch information
fabioz committed Oct 28, 2022
1 parent ac64657 commit ae189da
Show file tree
Hide file tree
Showing 3 changed files with 164 additions and 63 deletions.
143 changes: 88 additions & 55 deletions src/debugpy/_vendored/pydevd/pydevd_file_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@

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
Expand Down Expand Up @@ -124,6 +124,67 @@ def _get_library_dir():
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
Expand Down Expand Up @@ -155,38 +216,6 @@ def _convert_to_short_pathname(filename):

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))
Expand All @@ -206,28 +235,7 @@ def _get_path_with_real_case(filename):
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__)
Expand All @@ -243,11 +251,29 @@ def _get_path_with_real_case(filename):
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):
Expand Down Expand Up @@ -286,6 +312,13 @@ def _normcase_lower(filename):
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

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -431,7 +491,7 @@ class _DummyPyDB(object):
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

Expand Down Expand Up @@ -481,7 +541,7 @@ def test_mapping_conflict_to_client():
]


@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

Expand Down
14 changes: 11 additions & 3 deletions src/debugpy/_vendored/pydevd/tests_python/test_pydevd_filtering.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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')
Expand Down

0 comments on commit ae189da

Please sign in to comment.