diff --git a/src/smbclient/shutil.py b/src/smbclient/shutil.py index c9d7aefe..6bc23c35 100644 --- a/src/smbclient/shutil.py +++ b/src/smbclient/shutil.py @@ -293,6 +293,16 @@ def copytree( :param kwargs: Common arguments used to build the SMB Session for any UNC paths. :return: The dst path. """ + + class LocalDirEntry: + """Mimics the structure of os.DirEntry which is not exposed https://github.com/python/cpython/issues/71225""" + + def __init__(self, path) -> None: + self.path = path + + def is_dir(self): + return os.path.isdir(self.path) + if is_remote_path(src): dir_entries = list(scandir(src, **kwargs)) else: @@ -315,20 +325,29 @@ def copytree( src_path = _join_local_or_remote_path(src, dir_entry.name) dst_path = _join_local_or_remote_path(dst, dir_entry.name) + is_smb_dir_entry = isinstance(dir_entry, SMBDirEntry) + try: if dir_entry.is_symlink(): - if not isinstance(dir_entry, SMBDirEntry): - raise AssertionError("copytree doesn't yet support symlinks for local to remote operations") - - link_target = readlink(src_path, **kwargs) + if is_smb_dir_entry: + link_target = readlink(src_path, **kwargs) + else: + link_target = os.path.realpath(src_path) if symlinks: + if not is_smb_dir_entry: + raise AssertionError( + "copytree doesn't yet support unresolved symlinks for local to remote operations" + ) symlink(link_target, dst_path, **kwargs) copystat(src_path, dst_path, follow_symlinks=False) continue else: # Manually override the dir_entry with a new one that is the link target and copy that below. try: - dir_entry = SMBDirEntry.from_path(link_target, **kwargs) + if not is_remote_path(link_target): + dir_entry = LocalDirEntry(link_target) + else: + dir_entry = SMBDirEntry.from_path(link_target, **kwargs) except OSError as err: if err.errno == errno.ENOENT and ignore_dangling_symlinks: continue diff --git a/tests/test_smbclient_shutil.py b/tests/test_smbclient_shutil.py index 5b8fd56a..dab940c2 100644 --- a/tests/test_smbclient_shutil.py +++ b/tests/test_smbclient_shutil.py @@ -1203,6 +1203,32 @@ def test_copytree_with_local_src(smb_share, tmp_path): assert fd.read() == "file3.txt" +@pytest.mark.skipif(os.name != "nt", reason="Samba target doesn't support symlinks.") +def test_copytree_with_local_src_and_symlinks(smb_share, tmp_path): + src_dirname = str(tmp_path / "source") + alt_src_dirname = str(tmp_path / "source2") + dst_dirname = "%s\\target" % smb_share + alt_dst_dirname = "%s\\target2" % smb_share + + os.makedirs(os.path.join(src_dirname, "dir1", "subdir1")) + os.makedirs(alt_src_dirname) + + path_to_symlink = os.path.join(src_dirname, "dir1", "subdir1", "symlink-to-resolve") + path_to_symlink_target = os.path.join(alt_src_dirname, "symlink-target.txt") + + with open(path_to_symlink_target, mode="w") as fd: + fd.write("symlink-target.txt") + os.symlink(path_to_symlink_target, path_to_symlink) + + actual = copytree(src_dirname, dst_dirname) + assert actual == dst_dirname + + assert sorted(list(listdir(alt_dst_dirname))) == ["symlink-target.txt"] + + with open_file("%s\\symlink-target.txt" % alt_dst_dirname) as fd: + assert fd.read() == "symlink-target.txt" + + @pytest.mark.skipif( os.name != "nt" and not os.environ.get("SMB_FORCE", False), reason="Samba does not update timestamps" )