From 96523ed668ce20c5c13f13618e557dfc72cb63e6 Mon Sep 17 00:00:00 2001 From: Sam Schott Date: Sun, 8 Sep 2024 20:11:00 +0200 Subject: [PATCH] support bytes and str paths from fsevents --- src/maestral/models.py | 4 ++-- src/maestral/sync.py | 35 ++++++++++++++++++++--------------- src/maestral/utils/path.py | 27 ++++++++++++++++----------- 3 files changed, 38 insertions(+), 28 deletions(-) diff --git a/src/maestral/models.py b/src/maestral/models.py index 04650214..69799294 100644 --- a/src/maestral/models.py +++ b/src/maestral/models.py @@ -48,7 +48,7 @@ __all__ = [ ] -def get_dest_path(event: FileSystemEvent) -> str: +def get_dest_path(event: FileSystemEvent) -> str | bytes: """ Returns the dest_path of a file system event if present (moved events only) otherwise returns the src_path (which is also the "destination"). @@ -425,7 +425,7 @@ class SyncEvent(Model): change_time = stat.st_ctime if stat else None size = stat.st_size if stat else 0 try: - symlink_target = os.readlink(event.src_path) + symlink_target = os.readlink(os.fsdecode(event.src_path)) except OSError: symlink_target = None diff --git a/src/maestral/sync.py b/src/maestral/sync.py index 771ec25b..1b3d76b8 100644 --- a/src/maestral/sync.py +++ b/src/maestral/sync.py @@ -1066,7 +1066,7 @@ class SyncEngine: # ==== Content hashing ============================================================= - def get_local_hash(self, local_path: str) -> str | None: + def get_local_hash(self, local_path: str | bytes) -> str | None: """ Computes content hash of a local file. @@ -1074,6 +1074,8 @@ class SyncEngine: :returns: Content hash to compare with Dropbox's content hash, or 'folder' if the path points to a directory. ``None`` if there is nothing at the path. """ + local_path = os.fsdecode(local_path) + try: stat = os.lstat(local_path) except (FileNotFoundError, NotADirectoryError): @@ -1360,7 +1362,7 @@ class SyncEngine: return dbx_path_cased - def to_dbx_path(self, local_path: str) -> str: + def to_dbx_path(self, local_path: str | bytes) -> str: """ Converts a local path to a path relative to the Dropbox folder. Casing of the given ``local_path`` will be preserved. @@ -1369,15 +1371,15 @@ class SyncEngine: :returns: Relative path with respect to Dropbox folder. :raises ValueError: When the path lies outside the local Dropbox folder. """ - if not is_equal_or_child( - local_path, self.dropbox_path, self.is_fs_case_sensitive - ): - raise ValueError(f'"{local_path}" is not in "{self.dropbox_path}"') + path = os.fsdecode(local_path) + + if not is_equal_or_child(path, self.dropbox_path, self.is_fs_case_sensitive): + raise ValueError(f'"{path}" is not in "{self.dropbox_path}"') return "/" + removeprefix( - local_path, self.dropbox_path, self.is_fs_case_sensitive + path, self.dropbox_path, self.is_fs_case_sensitive ).lstrip("/") - def to_dbx_path_lower(self, local_path: str) -> str: + def to_dbx_path_lower(self, local_path: str | bytes) -> str: """ Converts a local path to a path relative to the Dropbox folder. The path will be normalized as on Dropbox servers (lower case and some additional @@ -1414,7 +1416,7 @@ class SyncEngine: dbx_path_cased = self.correct_case(dbx_path) return self.to_local_path_from_cased(dbx_path_cased) - def is_excluded(self, path: str) -> bool: + def is_excluded(self, path: str | bytes) -> bool: """ Checks if a file is excluded from sync. Certain file names are always excluded from syncing, following the Dropbox support article: @@ -1429,6 +1431,7 @@ class SyncEngine: just a file name. Does not need to be normalized. :returns: Whether the path is excluded from syncing. """ + path = os.fsdecode(path) dirname, basename = osp.split(path) # Is in excluded files? @@ -1996,7 +1999,9 @@ class SyncEngine: # from sync. # mapping of path -> event history - events_for_path: defaultdict[str, list[FileSystemEvent]] = defaultdict(list) + events_for_path: defaultdict[str | bytes, list[FileSystemEvent]] = defaultdict( + list + ) # mapping of source deletion event -> destination creation event moved_from_to: dict[FileSystemEvent, FileSystemEvent] = {} @@ -2119,8 +2124,8 @@ class SyncEngine: # 0) Collect all moved and deleted events in sets. - dir_moved_paths: set[tuple[str, str]] = set() - dir_deleted_paths: set[str] = set() + dir_moved_paths: set[tuple[str | bytes, str | bytes]] = set() + dir_deleted_paths: set[str | bytes] = set() for events in events_for_path.values(): event = events[0] @@ -2132,7 +2137,7 @@ class SyncEngine: # 1) Combine moved events of folders and their children into one event. if len(dir_moved_paths) > 0: - child_moved_dst_paths: set[str] = set() + child_moved_dst_paths: set[str | bytes] = set() # For each event, check if it is a child of a moved event discard it if yes. for events in events_for_path.values(): @@ -2151,7 +2156,7 @@ class SyncEngine: # 2) Combine deleted events of folders and their children to one event. if len(dir_deleted_paths) > 0: - child_deleted_paths: set[str] = set() + child_deleted_paths: set[str | bytes] = set() for events in events_for_path.values(): event = events[0] @@ -3737,7 +3742,7 @@ class SyncEngine: self._logger.debug('Renamed "%s" to "%s"', local_path_old, event.local_path) - def rescan(self, local_path: str) -> None: + def rescan(self, local_path: str | bytes) -> None: """ Forces a rescan of a local path: schedules created events for every folder, modified events for every file and deleted events for every deleted item diff --git a/src/maestral/utils/path.py b/src/maestral/utils/path.py index 70cfbeb9..efa8971e 100644 --- a/src/maestral/utils/path.py +++ b/src/maestral/utils/path.py @@ -2,6 +2,8 @@ This module contains functions for common path operations. """ +from __future__ import annotations + import errno import fcntl import itertools @@ -36,7 +38,9 @@ _AnyPath = Union[str, bytes, "os.PathLike[str]", "os.PathLike[bytes]"] # ==== path relationships ============================================================== -def is_child(path: str, parent: str, case_sensitive: bool = True) -> bool: +def is_child( + path: str | bytes, parent: str | bytes, case_sensitive: bool = True +) -> bool: """ Checks if ``path`` semantically is inside ``parent``. Neither path needs to refer to an actual item on the drive. This function is case-sensitive. @@ -49,6 +53,9 @@ def is_child(path: str, parent: str, case_sensitive: bool = True) -> bool: if not case_sensitive: path = normalize(path) parent = normalize(parent) + else: + path = os.fsdecode(path) + parent = os.fsdecode(parent) parent = parent.rstrip(osp.sep) + osp.sep path = path.rstrip(osp.sep) @@ -56,7 +63,9 @@ def is_child(path: str, parent: str, case_sensitive: bool = True) -> bool: return path.startswith(parent) -def is_equal_or_child(path: str, parent: str, case_sensitive: bool = True) -> bool: +def is_equal_or_child( + path: str | bytes, parent: str | bytes, case_sensitive: bool = True +) -> bool: """ Checks if ``path`` semantically is inside ``parent`` or equals ``parent``. Neither path needs to refer to an actual item on the drive. This function is case-sensitive. @@ -67,11 +76,7 @@ def is_equal_or_child(path: str, parent: str, case_sensitive: bool = True) -> bo :returns: ``True`` if ``path`` semantically lies inside ``parent`` or ``path == parent``. """ - if not case_sensitive: - path = normalize(path) - parent = normalize(parent) - - return is_child(path, parent) or path == parent + return is_child(path, parent, case_sensitive) or path == parent # ==== case sensitivity and normalization ============================================== @@ -98,7 +103,7 @@ def normalize_unicode(string: str) -> str: return unicodedata.normalize("NFC", string) -def normalize(string: str) -> str: +def normalize(path: str | bytes) -> str: """ Replicates the path normalization performed by Dropbox servers. This typically only involves converting the path to lower case, with a few (undocumented) exceptions: @@ -121,7 +126,7 @@ def normalize(string: str) -> str: :param string: Original path. :returns: Normalized path. """ - return normalize_case(normalize_unicode(string)) + return normalize_case(normalize_unicode(os.fsdecode(path))) def is_fs_case_sensitive(path: str) -> bool: @@ -417,7 +422,7 @@ def move( def walk( - root: str, + root: str | bytes, listdir: Callable[[str], Iterable["os.DirEntry[str]"]] = os.scandir, ) -> Iterator[Tuple[str, os.stat_result]]: """ @@ -427,7 +432,7 @@ def walk( :param listdir: Function to call to get the folder content. :returns: Iterator over (path, stat) results. """ - for entry in listdir(root): + for entry in listdir(os.fsdecode(root)): try: path = entry.path stat = entry.stat(follow_symlinks=False)