diff --git a/main.py b/main.py index 79a0ed4..57def47 100644 --- a/main.py +++ b/main.py @@ -38,18 +38,18 @@ def __init__( self.dangle: bool = not dont_dangle self.odd: bool = odd - self.source_abs = os.path.abspath(self.source) - self.replica_abs = os.path.abspath(self.replica) + self.source_real = os.path.realpath(self.source) + self.replica_real = os.path.realpath(self.replica) self.encountered_inodes = {} self.logger = logging.getLogger(__name__) self.logger.info(f"Source: {self.source}") - self.logger.debug(f"Source absolute: {self.source_abs}") + self.logger.debug(f"Source real: {self.source_real}") self.logger.info(f"Replica: {self.replica}") - self.logger.debug(f"Replica absolute: {self.replica_abs}") + self.logger.debug(f"Replica real: {self.replica_real}") self.logger.debug(f"Interval: {self.interval} s") self.logger.debug(f"Count: {self.count}") @@ -69,30 +69,30 @@ def run(self): def _sync(self): """Synchronize source folder to replica folder.""" - if not os.path.exists(self.source_abs) or not os.path.isdir(self.source_abs): + if not os.path.exists(self.source_real) or not os.path.isdir(self.source_real): self.logger.error( - f"Source {self.source_abs} is not a directory or doesn't exist! Quitting" + f"Source {self.source_real} is not a directory or doesn't exist! Quitting" ) quit() - if os.path.commonpath([self.source_abs, self.replica_abs]) in [ - self.source_abs, - self.replica_abs, + if os.path.commonpath([self.source_real, self.replica_real]) in [ + self.source_real, + self.replica_real, ]: self.logger.error( - f"Replica {self.replica_abs} or source {self.source_abs} is relative to the other! Quitting" + f"Replica {self.replica_real} or source {self.source_real} is relative to the other! Quitting" ) quit() self.logger.info("Syncing...") try: - self._sync_folder(self.source, self.replica) + self._sync_folder(self.source_real, self.replica_real) except NotADirectoryError as e: - if not os.path.isdir(self.replica_abs): + if not os.path.isdir(self.replica_real): self.logger.error( - f"Replica {self.replica_abs} exists, but is not a directory! Quitting" + f"Replica {self.replica_real} exists, but is not a directory! Quitting" ) quit() @@ -100,6 +100,12 @@ def _sync(self): raise e def _sync_folder(self, src, dst): + """Sync source and destination folders. + + Args: + src: source folder path + dst: destination folder path + """ if not os.path.lexists(dst): self._mkdir(dst) @@ -123,15 +129,24 @@ def _sync_folder(self, src, dst): for i in src_entries: self.logger.debug(f"Syncing entry: {i}: Inode {i.inode()}") - if i.inode() in self.encountered_inodes.keys(): - self.logger.warning( - f"{i.path} was already encountered in {self.encountered_inodes[i.inode()]}, skipping!" - ) - - continue - - else: - self.encountered_inodes[i.inode()] = i.path + # Implementation of inode checking for hardlink recursion + # + # However, I was unable to find a way to make this work + # with junction handling + # + # So the decision was to work with the assumption + # of a tree-ish like structure and ignore + # hardlink recursion for the time being + # + # if i.inode() in self.encountered_inodes.keys(): + # self.logger.warning( + # f"{i.path} was already encountered in {self.encountered_inodes[i.inode()]}, skipping!" + # ) + # + # continue + # + # else: + # self.encountered_inodes[i.inode()] = i.path if i.name in dst_contents: same = self._compare_entry(i, src, dst) @@ -148,6 +163,16 @@ def _sync_folder(self, src, dst): self._sync_item(i, src, dst) def _compare_entry(self, entry: os.DirEntry[str], src, dst) -> bool: + """Check whether an entry was already synced or not. + + Args: + entry: os.DirEntry of the source object + src: current working source path + dst: current working destination path + + Returns: + bool: True if entry was already synced, else False + """ same = False # By default, assume source and destination are different if entry.is_dir(follow_symlinks=False): @@ -211,8 +236,8 @@ def _sync_item(self, entry: os.DirEntry[str], src, dst): self.logger.debug(f"Item sync: Destination {os.path.join(dst, entry.name)}") if entry.is_junction(): - if os.path.exists(os.path.join(dst, entry.name)): - self._remove(os.path.join(dst, entry.name)) + # if os.path.lexists(os.path.join(dst, entry.name)): + # self._remove(os.path.join(dst, entry.name)) self._handle_junction(entry, src, dst) @@ -265,9 +290,36 @@ def _handle_junction(self, entry: os.DirEntry[str], src, dst): self.logger.debug("Copy Junction") self.logger.warning( - f"Junction in path {os.path.abspath(os.path.join(src, entry.name))}, recursing as regular folder..." + f"Junction in path {os.path.abspath(os.path.join(src, entry.name))}, attempting recurse as a regular folder..." ) + real_path = os.path.realpath(entry.path) + + self.logger.debug(f"Junction: real path: {real_path}") + self.logger.debug(f"Junction: src real path: {os.path.realpath(src)}") + + if real_path in os.path.realpath(src): + self.logger.warning(f"Junction {entry.path} is recursive! Skipping") + + if os.path.lexists(os.path.join(dst, entry.name)): + # junction could be invalid, but present in dst -> remove from destination + # (e.g. junction became invalid between syncs) + self._remove(os.path.join(dst, entry.name)) + + return + + if not os.path.exists(real_path): + self.logger.warning( + f"Junction {entry.path} target {real_path} does not exist! Skipping" + ) + + if os.path.lexists(os.path.join(dst, entry.name)): + # junction could be invalid, but present in dst -> remove from destination + # (e.g. junction became invalid between syncs) + self._remove(os.path.join(dst, entry.name)) + + return + self._sync_folder(os.path.join(src, entry.name), os.path.join(dst, entry.name)) def _handle_symlink(self, entry: os.DirEntry[str], src, dst): @@ -425,8 +477,8 @@ def _get_symlink_target_path( if self.dangle: self.logger.debug("Symlink: --dont-dangle-symlinks is disabled") - if self.source_abs == os.path.commonpath( - [self.source_abs, symlink_target_path_absolute] + if self.source_real == os.path.commonpath( + [self.source_real, symlink_target_path_absolute] ): self.logger.debug(f"Symlink inside source, using {symlink_target_path}") @@ -438,8 +490,8 @@ def _get_symlink_target_path( return symlink_target_path elif not dangling: - if self.source_abs == os.path.commonpath( - [self.source_abs, symlink_target_path_absolute] + if self.source_real == os.path.commonpath( + [self.source_real, symlink_target_path_absolute] ): self.logger.debug(f"Symlink inside source, using {symlink_target_path}") return symlink_target_path @@ -465,6 +517,7 @@ def _detranslate_symlink_target_path( Raises: ValueError: if the symlink target is not a previously translated symlink target """ + # Example implementation of a detranslation function for synced translated symlinks self.logger.debug(f"Symlink target path on disk: {symlink_target_path}") detranslated = symlink_target_path[symlink_target_path.index("/./") + 3 :] diff --git a/pyproject.toml b/pyproject.toml index 45c00de..f942243 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "folder-syncer" -version = "1.1.1.dev1" +version = "1.1.1.dev2" description = "Folder syncer for Veeam technical assessment" readme = "README.md" authors = [ diff --git a/uv.lock b/uv.lock index b3d4a5b..de4179e 100644 --- a/uv.lock +++ b/uv.lock @@ -4,5 +4,5 @@ requires-python = ">=3.13" [[package]] name = "folder-syncer" -version = "1.1.1.dev1" +version = "1.1.1.dev2" source = { virtual = "." }