From 8c731830c92193f06c03fd0c6dc3448537b771df Mon Sep 17 00:00:00 2001 From: Sam Schott Date: Fri, 22 May 2020 15:46:00 +0200 Subject: [PATCH 01/10] [sync, utils.path] improvements to case conversions --- maestral/sync.py | 11 ++- maestral/utils/path.py | 146 +++++++++++++++++++++++++++++---------- tests/test_sync.py | 5 +- tests/utils/test_path.py | 53 +++++++++++--- 4 files changed, 159 insertions(+), 56 deletions(-) diff --git a/maestral/sync.py b/maestral/sync.py index 4ea41531..c4d9a28c 100644 --- a/maestral/sync.py +++ b/maestral/sync.py @@ -60,7 +60,7 @@ from maestral.errors import ( from maestral.utils.content_hasher import DropboxContentHasher from maestral.utils.notify import MaestralDesktopNotifier, FILECHANGE from maestral.utils.path import ( - generate_cc_name, path_exists_case_insensitive, to_cased_path, is_fs_case_sensitive, + generate_cc_name, cased_path_candidates, to_cased_path, is_fs_case_sensitive, move, delete, is_child, is_equal_or_child ) from maestral.utils.appdirs import get_data_path @@ -953,10 +953,7 @@ class SyncEngine: local_parent = to_cased_path(dbx_path_parent, root=self.dropbox_path) - if local_parent == '': - return osp.join(self.dropbox_path, dbx_path.lstrip(osp.sep)) - else: - return osp.join(local_parent, dbx_path_basename) + return osp.join(local_parent, dbx_path_basename) def get_local_path(self, md): """ @@ -1542,7 +1539,7 @@ class SyncEngine: :rtype: bool """ - if not self._is_case_sensitive: + if not self.is_case_sensitive: return False if event.event_type not in (EVENT_TYPE_CREATED, EVENT_TYPE_MOVED): @@ -1553,7 +1550,7 @@ class SyncEngine: dirname, basename = osp.split(local_path) # check number of paths with the same case - if len(path_exists_case_insensitive(basename, root=dirname)) > 1: + if len(cased_path_candidates(basename, root=dirname)) > 1: local_path_cc = generate_cc_name(local_path, suffix='case conflict') diff --git a/maestral/utils/path.py b/maestral/utils/path.py index 866a1454..9a729c48 100644 --- a/maestral/utils/path.py +++ b/maestral/utils/path.py @@ -12,13 +12,26 @@ This module contains functions for common path operations used by Maestral. import os import os.path as osp import shutil -import tempfile +import itertools def is_fs_case_sensitive(path): - # create a cased temp file and check if the lower case version exists - with tempfile.NamedTemporaryFile(dir=path, prefix='.TmP') as tmp_file: - return not os.path.exists(tmp_file.name.lower()) + """ + Checks if ``path`` lies on a partition with a case-sensitive file system. + + :param str path: Path to check. + :returns: Whether ``path`` lies on a partition with a case-sensitive file system. + :rtype: bool + """ + if path.islower(): + check_path = path.upper() + else: + check_path = path.lower() + + if osp.exists(path) and not osp.exists(check_path): + return True + else: + return not osp.samefile(path, check_path) def is_child(path, parent): @@ -28,8 +41,7 @@ def is_child(path, parent): :param str path: Item path. :param str parent: Parent path. - :returns: ``True`` if ``path`` semantically lies inside ``parent`` or - ``path == parent``, ``False`` otherwise. + :returns: Whether ``path`` semantically lies inside ``parent``. :rtype: bool """ @@ -54,63 +66,125 @@ def is_equal_or_child(path, parent): return is_child(path, parent) or path == parent -def path_exists_case_insensitive(path, root='/'): +def cased_path_candidates(path, root='/', is_fs_case_sensitive=True): """ - Checks if a ``path`` exists in given ``root`` directory, similar to ``os.path.exists`` - but case-insensitive. A list of all case-insensitive matches is returned. + Returns a list of cased versions of the given path as far as corresponding nodes + exist in the given root directory. For instance, if a case sensitive root directory + contains two folders "/parent/subfolder/child" and "/parent/Subfolder/child", + there will be two matches for "/parent/subfolder/child/file.txt". - :param str path: Path relative to ``root``. - :param str root: Directory where we will look for ``path``. There are significant + :param str path: Original path relative to ``root``. + :param str root: Parent directory to search in. There are significant performance improvements if a root directory with a small tree is given. - :return: List of absolute and case-sensitive to search results. + :param bool is_fs_case_sensitive: Bool indicating if the file system is case + sensitive. If ``False``, we know that there can be at most one match and choose + a faster algorithm. + :returns: Candidates for correctly cased local paths. :rtype: list[str] """ if not osp.isdir(root): - return [] + raise FileNotFoundError(f'Root directory "{root}" does not exist.') if path in ('', '/'): return [root] path_list = path.lstrip(osp.sep).split(osp.sep) - path_list_lower = [x.lower() for x in path_list] + n_components = len(path_list) + + candidates = dict() - i = 0 - local_paths = [] for root, dirs, files in os.walk(root): - for d in list(dirs): - if d.lower() != path_list_lower[i]: - dirs.remove(d) - for f in list(files): - if f.lower() != path_list_lower[i]: - files.remove(f) - local_paths = [osp.join(root, name) for name in dirs + files] + depth = 0 if root == osp.sep else root.count(osp.sep) - i += 1 - if i == len(path_list_lower): - break + if depth == n_components: + if is_fs_case_sensitive: + continue + else: + break + + all_dirs = dirs.copy() + all_files = files.copy() + + dirs.clear() + files.clear() + + found = False + path_lower = path_list[depth].lower() + + for d in all_dirs: + if d.lower() == path_lower: + dirs.append(d) + + if not is_fs_case_sensitive: + # skip to next iteration since there can be no more matches + found = True + break + + if depth + 1 == n_components and not found: + # look at files + for f in all_files: + if f.lower() == path_lower: + files.append(f) + + if not is_fs_case_sensitive: + # skip to next iteration since there can be no more matches + break + + new_candidates = [osp.join(root, name) for name in itertools.chain(dirs, files)] + + if new_candidates: + try: + candidates[depth].extend(new_candidates) + except KeyError: + candidates[depth] = new_candidates + + i_max = max(candidates.keys()) + local_paths = [osp.join(node, *path_list[i_max+1:]) for node in candidates[i_max]] return local_paths -def to_cased_path(path, root='/'): +def to_cased_path(path, root='/', is_fs_case_sensitive=True): """ - Returns a cased version of the given path, if exists in the given root directory, - or an empty string otherwise. + Returns a cased version of the given path as far as corresponding nodes + exist in the given root directory. If multiple matches are found, only one + is returned. - :param str path: Original path. - :param str root: Parent directory to search in. + :param str path: Original path relative to ``root``. + :param str root: Parent directory to search in. There are significant + performance improvements if a root directory with a small tree is given. + :param bool is_fs_case_sensitive: Bool indicating if the file system is case + sensitive. If ``False``, we know that there can be at most one match and choose + a faster algorithm. + :returns: Candidates for c :returns: Absolute and cased version of given path or empty string. :rtype: str """ + candidates = cased_path_candidates(path, root, is_fs_case_sensitive) - path_list = path_exists_case_insensitive(path, root) + return candidates[0] - if len(path_list) > 0: - return path_list[0] - else: - return '' + +def path_exists_case_insensitive(path, root='/', is_fs_case_sensitive=True): + """ + Checks if a ``path`` exists in given ``root`` directory, similar to ``os.path.exists`` + but case-insensitive. + + :param str path: Path relative to ``root``. + :param str root: Directory where we will look for ``path``. There are significant + performance improvements if a root directory with a small tree is given. + :param bool is_fs_case_sensitive: Bool indicating if the file system is case + sensitive. If ``False``, we know that there can be at most one match and choose + a faster algorithm. + :returns: Whether an arbitrarily cased version of ``path`` exists. + :rtype: bool + """ + + candidates = cased_path_candidates(path, root, is_fs_case_sensitive) + + return any([c.lower() == path.lower() for c in candidates]) def generate_cc_name(path, suffix='conflicting copy'): diff --git a/tests/test_sync.py b/tests/test_sync.py index 512543e2..f6d54372 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -18,13 +18,12 @@ from maestral.sync import ( DirCreatedEvent, DirDeletedEvent, DirMovedEvent, ) from maestral.sync import delete, move -from maestral.sync import is_child +from maestral.sync import is_child, is_fs_case_sensitive from maestral.sync import get_local_hash, DirectorySnapshot from maestral.sync import SyncEngine, Observer, FSEventHandler from maestral.errors import NotFoundError, FolderConflictError from maestral.main import Maestral from maestral.main import get_log_path -from maestral.constants import IS_FS_CASE_SENSITIVE import unittest from unittest import TestCase @@ -980,7 +979,7 @@ class TestSync(TestCase): self.assertIsNotNone(self.m.client.get_metadata(self.test_folder_dbx + '/folder (selective sync conflict)')) self.assertIsNotNone(self.m.client.get_metadata(self.test_folder_dbx + '/folder (selective sync conflict 1)')) - @unittest.skipUnless(IS_FS_CASE_SENSITIVE, 'file system is not case sensitive') + @unittest.skipUnless(is_fs_case_sensitive('/home'), 'file system is not case sensitive') def test_case_conflict(self): os.mkdir(self.test_folder_local + '/folder') diff --git a/tests/utils/test_path.py b/tests/utils/test_path.py index 7abf4f80..37dd8e5f 100644 --- a/tests/utils/test_path.py +++ b/tests/utils/test_path.py @@ -6,10 +6,13 @@ """ import os +import os.path as osp import tempfile from maestral.utils.path import ( - path_exists_case_insensitive, to_cased_path, is_child, delete + path_exists_case_insensitive, cased_path_candidates, to_cased_path, + is_fs_case_sensitive, is_child, delete ) +from maestral.utils.appdirs import get_home_dir def test_path_exists_case_insensitive(): @@ -21,16 +24,46 @@ def test_path_exists_case_insensitive(): assert to_cased_path(path.upper()) == path # choose a random path that likely does not exist - root = '/' path = '/usr/local/share/test_folder/path_928' - if not os.path.exists(path): - assert len(path_exists_case_insensitive(path, root)) == 0 + if not osp.exists(path): + assert not path_exists_case_insensitive(path) # choose a random parent that likely does not exist root = '/test_folder/path_928' path = '/usr' - if not os.path.exists(root): - assert len(path_exists_case_insensitive(path, root)) == 0 + if not osp.exists(root): + assert not path_exists_case_insensitive(path, root) + + +def test_cased_path_candidates(): + + # choose a path which exists on all Unix systems + path = '/usr/local/share'.upper() + + assert len(cased_path_candidates(path)) == 1 + assert path in cased_path_candidates(path) + + home = get_home_dir() + + if is_fs_case_sensitive(home): + + parent0 = osp.join(home, 'test folder/subfolder') + parent1 = osp.join(home, 'Test Folder/subfolder') + + os.makedirs(parent0) + os.makedirs(parent1) + + path = osp.join(parent0.lower(), 'File.txt') + + candidates = cased_path_candidates(path) + + try: + assert len(candidates) == 2 + assert osp.join(parent0, 'File.txt') in candidates + assert osp.join(parent1, 'File.txt') in candidates + finally: + delete(parent0) + delete(parent1) def test_is_child(): @@ -43,12 +76,12 @@ def test_is_child(): def test_delete(): # test deleting file test_file = tempfile.NamedTemporaryFile() - assert os.path.isfile(test_file.name) + assert osp.isfile(test_file.name) delete(test_file.name) - assert not os.path.exists(test_file.name) + assert not osp.exists(test_file.name) # test deleting directory test_dir = tempfile.TemporaryDirectory() - assert os.path.isdir(test_dir.name) + assert osp.isdir(test_dir.name) delete(test_dir.name) - assert not os.path.exists(test_dir.name) + assert not osp.exists(test_dir.name) From bd2366d5af28b04816192e24bc984c61dc4f74f9 Mon Sep 17 00:00:00 2001 From: Sam Schott Date: Fri, 22 May 2020 15:51:13 +0200 Subject: [PATCH 02/10] flake8 --- maestral/constants.py | 1 - maestral/utils/path.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/maestral/constants.py b/maestral/constants.py index e04230ac..211c846c 100644 --- a/maestral/constants.py +++ b/maestral/constants.py @@ -13,7 +13,6 @@ be kept free of memory heavy imports. import os import platform import sys -import tempfile from enum import Enum diff --git a/maestral/utils/path.py b/maestral/utils/path.py index 9a729c48..d1114815 100644 --- a/maestral/utils/path.py +++ b/maestral/utils/path.py @@ -141,7 +141,7 @@ def cased_path_candidates(path, root='/', is_fs_case_sensitive=True): candidates[depth] = new_candidates i_max = max(candidates.keys()) - local_paths = [osp.join(node, *path_list[i_max+1:]) for node in candidates[i_max]] + local_paths = [osp.join(node, *path_list[i_max + 1:]) for node in candidates[i_max]] return local_paths From 5f9358d1f0344b8c4b9801428405cbdb113e3feb Mon Sep 17 00:00:00 2001 From: Sam Schott Date: Fri, 22 May 2020 15:55:12 +0200 Subject: [PATCH 03/10] [sync] bug fix when checking for case-sensitive before dropbox_path exists --- maestral/sync.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/maestral/sync.py b/maestral/sync.py index c4d9a28c..89352b7b 100644 --- a/maestral/sync.py +++ b/maestral/sync.py @@ -63,7 +63,7 @@ from maestral.utils.path import ( generate_cc_name, cased_path_candidates, to_cased_path, is_fs_case_sensitive, move, delete, is_child, is_equal_or_child ) -from maestral.utils.appdirs import get_data_path +from maestral.utils.appdirs import get_data_path, get_home_dir logger = logging.getLogger(__name__) @@ -475,7 +475,8 @@ class SyncEngine: self._mignore_path = osp.join(self._dropbox_path, MIGNORE_FILE) self._file_cache_path = osp.join(self._dropbox_path, FILE_CACHE) self._rev_file_path = get_data_path('maestral', f'{self.config_name}.index') - self._is_case_sensitive = is_fs_case_sensitive(self._dropbox_path) + # check for home, update later + self._is_case_sensitive = is_fs_case_sensitive(get_home_dir()) self._rev_dict_cache = dict() self._load_rev_dict_from_file(raise_exception=True) From c695bba04a29ae6e7c64b83a3ef483e5fbaa2442 Mon Sep 17 00:00:00 2001 From: Sam Schott Date: Fri, 22 May 2020 16:34:30 +0200 Subject: [PATCH 04/10] [utils.path] bug fixes --- maestral/utils/path.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/maestral/utils/path.py b/maestral/utils/path.py index d1114815..4526891c 100644 --- a/maestral/utils/path.py +++ b/maestral/utils/path.py @@ -91,12 +91,16 @@ def cased_path_candidates(path, root='/', is_fs_case_sensitive=True): path_list = path.lstrip(osp.sep).split(osp.sep) n_components = len(path_list) + n_components_root = len(root.lstrip(osp.sep).split(osp.sep)) candidates = dict() for root, dirs, files in os.walk(root): - depth = 0 if root == osp.sep else root.count(osp.sep) + depth = len(root.lstrip(osp.sep).split(osp.sep)) - n_components_root + + print(root) + print(depth, ' from ', n_components) if depth == n_components: if is_fs_case_sensitive: From dd6981e6f0996735dc37dfba6421803c5409fa72 Mon Sep 17 00:00:00 2001 From: Sam Schott Date: Fri, 22 May 2020 16:34:46 +0200 Subject: [PATCH 05/10] [tests] better tests for utils.path --- tests/utils/test_path.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/utils/test_path.py b/tests/utils/test_path.py index 37dd8e5f..589786c4 100644 --- a/tests/utils/test_path.py +++ b/tests/utils/test_path.py @@ -55,12 +55,20 @@ def test_cased_path_candidates(): path = osp.join(parent0.lower(), 'File.txt') - candidates = cased_path_candidates(path) - try: + candidates = cased_path_candidates(path) + assert len(candidates) == 2 assert osp.join(parent0, 'File.txt') in candidates assert osp.join(parent1, 'File.txt') in candidates + + candidates = cased_path_candidates('/test folder/subfolder/File.txt', + root=home) + + assert len(candidates) == 2 + assert osp.join(parent0, 'File.txt') in candidates + assert osp.join(parent1, 'File.txt') in candidates + finally: delete(parent0) delete(parent1) From 550d6b0a5ee0c19c893056b6925917f2f5a2d6f7 Mon Sep 17 00:00:00 2001 From: Sam Schott Date: Fri, 22 May 2020 16:52:39 +0200 Subject: [PATCH 06/10] [utils.path] more bug fixes --- maestral/sync.py | 17 ++++++++++++----- maestral/utils/path.py | 40 ++++++++++++++++++++++++++++------------ tests/utils/test_path.py | 6 +++--- 3 files changed, 43 insertions(+), 20 deletions(-) diff --git a/maestral/sync.py b/maestral/sync.py index 89352b7b..0614dd30 100644 --- a/maestral/sync.py +++ b/maestral/sync.py @@ -952,7 +952,8 @@ class SyncEngine: dbx_path = dbx_path.replace('/', osp.sep) dbx_path_parent, dbx_path_basename = osp.split(dbx_path) - local_parent = to_cased_path(dbx_path_parent, root=self.dropbox_path) + local_parent = to_cased_path(dbx_path_parent, root=self.dropbox_path, + is_fs_case_sensitive=self.is_case_sensitive) return osp.join(local_parent, dbx_path_basename) @@ -1553,7 +1554,8 @@ class SyncEngine: # check number of paths with the same case if len(cased_path_candidates(basename, root=dirname)) > 1: - local_path_cc = generate_cc_name(local_path, suffix='case conflict') + local_path_cc = generate_cc_name(local_path, suffix='case conflict', + is_fs_case_sensitive=self.is_case_sensitive) event_cls = DirMovedEvent if osp.isdir(local_path) else FileMovedEvent with self.fs_events.ignore(event_cls(local_path, local_path_cc)): @@ -1587,7 +1589,8 @@ class SyncEngine: if self.is_excluded_by_user(dbx_path): local_path_cc = generate_cc_name(local_path, - suffix='selective sync conflict') + suffix='selective sync conflict', + is_fs_case_sensitive=self.is_case_sensitive) event_cls = DirMovedEvent if osp.isdir(local_path) else FileMovedEvent with self.fs_events.ignore(event_cls(local_path, local_path_cc)): @@ -2500,7 +2503,9 @@ class SyncEngine: # re-check for conflict and move the conflict # out of the way if anything has changed if self._check_download_conflict(entry) == Conflict.Conflict: - new_local_path = generate_cc_name(local_path) + new_local_path = generate_cc_name( + local_path, is_fs_case_sensitive=self.is_case_sensitive + ) event_cls = DirMovedEvent if osp.isdir(local_path) else FileMovedEvent with self.fs_events.ignore(event_cls(local_path, new_local_path)): exc = move(local_path, new_local_path) @@ -2537,7 +2542,9 @@ class SyncEngine: # replace it but leave the children as they are. if conflict_check == Conflict.Conflict: - new_local_path = generate_cc_name(local_path) + new_local_path = generate_cc_name( + local_path, is_fs_case_sensitive=self.is_case_sensitive + ) event_cls = DirMovedEvent if osp.isdir(local_path) else FileMovedEvent with self.fs_events.ignore(event_cls(local_path, new_local_path)): exc = move(local_path, new_local_path) diff --git a/maestral/utils/path.py b/maestral/utils/path.py index 4526891c..d2ebaac7 100644 --- a/maestral/utils/path.py +++ b/maestral/utils/path.py @@ -91,32 +91,36 @@ def cased_path_candidates(path, root='/', is_fs_case_sensitive=True): path_list = path.lstrip(osp.sep).split(osp.sep) n_components = len(path_list) - n_components_root = len(root.lstrip(osp.sep).split(osp.sep)) + n_components_root = 0 if root == osp.sep else len(root.lstrip(osp.sep).split(osp.sep)) candidates = dict() for root, dirs, files in os.walk(root): - depth = len(root.lstrip(osp.sep).split(osp.sep)) - n_components_root + n_components_current_root = (0 if root == osp.sep + else len(root.lstrip(osp.sep).split(osp.sep))) + depth = n_components_current_root - n_components_root print(root) print(depth, ' from ', n_components) - if depth == n_components: - if is_fs_case_sensitive: - continue - else: - break - all_dirs = dirs.copy() all_files = files.copy() dirs.clear() files.clear() + if depth >= n_components: + if is_fs_case_sensitive: + continue + else: + break + found = False path_lower = path_list[depth].lower() + print(path_lower, ' in ', all_dirs, '?') + for d in all_dirs: if d.lower() == path_lower: dirs.append(d) @@ -186,12 +190,21 @@ def path_exists_case_insensitive(path, root='/', is_fs_case_sensitive=True): :rtype: bool """ - candidates = cased_path_candidates(path, root, is_fs_case_sensitive) + if is_fs_case_sensitive: - return any([c.lower() == path.lower() for c in candidates]) + candidates = cased_path_candidates(path, root, is_fs_case_sensitive) + + for c in candidates: + if osp.exists(c): + return True + + return False + + else: + return osp.exists(osp.join(root, path.lstrip(osp.sep))) -def generate_cc_name(path, suffix='conflicting copy'): +def generate_cc_name(path, suffix='conflicting copy', is_fs_case_sensitive=True): """ Generates a path for a conflicting copy of ``path``. The file name is created by inserting the given ``suffix`` between the the filename and extension. For instance: @@ -205,6 +218,9 @@ def generate_cc_name(path, suffix='conflicting copy'): :param str path: Original path name. :param str suffix: Suffix to use. Defaults to 'conflicting copy'. + :param bool is_fs_case_sensitive: Bool indicating if the file system is case + sensitive. If ``False``, we know that there can be at most one match and choose + a faster algorithm. :returns: New path. :rtype: str """ @@ -215,7 +231,7 @@ def generate_cc_name(path, suffix='conflicting copy'): i = 0 cc_candidate = f'{filename} ({suffix}){ext}' - while path_exists_case_insensitive(cc_candidate, dirname): + while path_exists_case_insensitive(cc_candidate, dirname, is_fs_case_sensitive): i += 1 cc_candidate = f'{filename} ({suffix} {i}){ext}' diff --git a/tests/utils/test_path.py b/tests/utils/test_path.py index 589786c4..d763d5ea 100644 --- a/tests/utils/test_path.py +++ b/tests/utils/test_path.py @@ -29,8 +29,8 @@ def test_path_exists_case_insensitive(): assert not path_exists_case_insensitive(path) # choose a random parent that likely does not exist - root = '/test_folder/path_928' - path = '/usr' + path = '/test_folder/path_928' + root = '/usr' if not osp.exists(root): assert not path_exists_case_insensitive(path, root) @@ -41,7 +41,7 @@ def test_cased_path_candidates(): path = '/usr/local/share'.upper() assert len(cased_path_candidates(path)) == 1 - assert path in cased_path_candidates(path) + assert '/usr/local/share' in cased_path_candidates(path) home = get_home_dir() From 1f674e45cff70089d0a6149cc88ed46f513eedfc Mon Sep 17 00:00:00 2001 From: Sam Schott Date: Fri, 22 May 2020 19:30:33 +0200 Subject: [PATCH 07/10] more fixes --- maestral/utils/path.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/maestral/utils/path.py b/maestral/utils/path.py index d2ebaac7..19348509 100644 --- a/maestral/utils/path.py +++ b/maestral/utils/path.py @@ -93,7 +93,7 @@ def cased_path_candidates(path, root='/', is_fs_case_sensitive=True): n_components = len(path_list) n_components_root = 0 if root == osp.sep else len(root.lstrip(osp.sep).split(osp.sep)) - candidates = dict() + candidates = {-1: [root]} for root, dirs, files in os.walk(root): @@ -101,9 +101,6 @@ def cased_path_candidates(path, root='/', is_fs_case_sensitive=True): else len(root.lstrip(osp.sep).split(osp.sep))) depth = n_components_current_root - n_components_root - print(root) - print(depth, ' from ', n_components) - all_dirs = dirs.copy() all_files = files.copy() @@ -119,8 +116,6 @@ def cased_path_candidates(path, root='/', is_fs_case_sensitive=True): found = False path_lower = path_list[depth].lower() - print(path_lower, ' in ', all_dirs, '?') - for d in all_dirs: if d.lower() == path_lower: dirs.append(d) From 46edb405e80b2d45fc4942002c1f4a1aaf1aae7c Mon Sep 17 00:00:00 2001 From: Sam Schott Date: Fri, 22 May 2020 19:33:53 +0200 Subject: [PATCH 08/10] updated path tests --- tests/utils/test_path.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/utils/test_path.py b/tests/utils/test_path.py index d763d5ea..6196b4f7 100644 --- a/tests/utils/test_path.py +++ b/tests/utils/test_path.py @@ -39,9 +39,15 @@ def test_cased_path_candidates(): # choose a path which exists on all Unix systems path = '/usr/local/share'.upper() + candidates = cased_path_candidates(path) - assert len(cased_path_candidates(path)) == 1 - assert '/usr/local/share' in cased_path_candidates(path) + assert len(candidates) == 1 + assert '/usr/local/share' in candidates + + candidates = cased_path_candidates('/test', root='/usr/local/share') + + assert len(candidates) == 1 + assert '/usr/local/share/test' in candidates home = get_home_dir() From 71a124079c2a833249ca4c330d3c239aca99465c Mon Sep 17 00:00:00 2001 From: Sam Schott Date: Sun, 24 May 2020 22:33:18 +0200 Subject: [PATCH 09/10] bug fixes --- maestral/sync.py | 2 +- maestral/utils/path.py | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/maestral/sync.py b/maestral/sync.py index 0614dd30..af78906a 100644 --- a/maestral/sync.py +++ b/maestral/sync.py @@ -1537,7 +1537,7 @@ class SyncEngine: case. Renames items if necessary. Only needed for case sensitive file systems. :param FileSystemEvent event: Created or moved event. - :returns: ``True`` or ``False``. + :returns: Whether a case conflict was detected and handled. :rtype: bool """ diff --git a/maestral/utils/path.py b/maestral/utils/path.py index 19348509..a2b89d48 100644 --- a/maestral/utils/path.py +++ b/maestral/utils/path.py @@ -71,7 +71,8 @@ def cased_path_candidates(path, root='/', is_fs_case_sensitive=True): Returns a list of cased versions of the given path as far as corresponding nodes exist in the given root directory. For instance, if a case sensitive root directory contains two folders "/parent/subfolder/child" and "/parent/Subfolder/child", - there will be two matches for "/parent/subfolder/child/file.txt". + there will be two matches for "/parent/subfolder/child/file.txt". If the root + directory does not exist, only one candidate ``os.path.join(root, path)`` is returned. :param str path: Original path relative to ``root``. :param str root: Parent directory to search in. There are significant @@ -83,9 +84,6 @@ def cased_path_candidates(path, root='/', is_fs_case_sensitive=True): :rtype: list[str] """ - if not osp.isdir(root): - raise FileNotFoundError(f'Root directory "{root}" does not exist.') - if path in ('', '/'): return [root] @@ -162,7 +160,7 @@ def to_cased_path(path, root='/', is_fs_case_sensitive=True): sensitive. If ``False``, we know that there can be at most one match and choose a faster algorithm. :returns: Candidates for c - :returns: Absolute and cased version of given path or empty string. + :returns: Absolute and cased version of given path. :rtype: str """ candidates = cased_path_candidates(path, root, is_fs_case_sensitive) From fc7473de0811c041fd1fb988c924244a7ac44678 Mon Sep 17 00:00:00 2001 From: Sam Schott Date: Mon, 25 May 2020 12:23:55 +0200 Subject: [PATCH 10/10] [utils.path] cleanup --- maestral/utils/path.py | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/maestral/utils/path.py b/maestral/utils/path.py index a2b89d48..0b003c53 100644 --- a/maestral/utils/path.py +++ b/maestral/utils/path.py @@ -15,6 +15,12 @@ import shutil import itertools +def _path_components(path): + components = path.strip(osp.sep).split(osp.sep) + cleaned_components = [c for c in components if c] + return cleaned_components + + def is_fs_case_sensitive(path): """ Checks if ``path`` lies on a partition with a case-sensitive file system. @@ -66,7 +72,7 @@ def is_equal_or_child(path, parent): return is_child(path, parent) or path == parent -def cased_path_candidates(path, root='/', is_fs_case_sensitive=True): +def cased_path_candidates(path, root=osp.sep, is_fs_case_sensitive=True): """ Returns a list of cased versions of the given path as far as corresponding nodes exist in the given root directory. For instance, if a case sensitive root directory @@ -84,19 +90,20 @@ def cased_path_candidates(path, root='/', is_fs_case_sensitive=True): :rtype: list[str] """ - if path in ('', '/'): + path = path.lstrip(osp.sep) + + if path == '': return [root] - path_list = path.lstrip(osp.sep).split(osp.sep) + path_list = _path_components(path) n_components = len(path_list) - n_components_root = 0 if root == osp.sep else len(root.lstrip(osp.sep).split(osp.sep)) + n_components_root = len(_path_components(root)) candidates = {-1: [root]} for root, dirs, files in os.walk(root): - n_components_current_root = (0 if root == osp.sep - else len(root.lstrip(osp.sep).split(osp.sep))) + n_components_current_root = len(_path_components(root)) depth = n_components_current_root - n_components_root all_dirs = dirs.copy() @@ -147,11 +154,12 @@ def cased_path_candidates(path, root='/', is_fs_case_sensitive=True): return local_paths -def to_cased_path(path, root='/', is_fs_case_sensitive=True): +def to_cased_path(path, root=osp.sep, is_fs_case_sensitive=True): """ - Returns a cased version of the given path as far as corresponding nodes - exist in the given root directory. If multiple matches are found, only one - is returned. + Returns a cased version of the given path as far as corresponding nodes exist in the + given root directory. If multiple matches are found, only one is returned. If ``path`` + does not exist in root ``root`` or ``root`` does not exist, the return value will be + ``os.path.join(root, path)``. :param str path: Original path relative to ``root``. :param str root: Parent directory to search in. There are significant @@ -163,12 +171,12 @@ def to_cased_path(path, root='/', is_fs_case_sensitive=True): :returns: Absolute and cased version of given path. :rtype: str """ - candidates = cased_path_candidates(path, root, is_fs_case_sensitive) + candidates = cased_path_candidates(path, root, is_fs_case_sensitive) return candidates[0] -def path_exists_case_insensitive(path, root='/', is_fs_case_sensitive=True): +def path_exists_case_insensitive(path, root=osp.sep, is_fs_case_sensitive=True): """ Checks if a ``path`` exists in given ``root`` directory, similar to ``os.path.exists`` but case-insensitive.