diff --git a/.condarc b/.condarc new file mode 100644 index 0000000..d5faf1f --- /dev/null +++ b/.condarc @@ -0,0 +1,3 @@ +channels: + - default +# - mjirik \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..0e77a5c --- /dev/null +++ b/.travis.yml @@ -0,0 +1,83 @@ +language: python +os: linux +# Ubuntu 14.04 Trusty support +sudo: required +# dist: trusty +# install new cmake +#addons: +# apt: +# packages: +# - cmake +# sources: +# - kalakris-cmake +env: + # - CONDA_PYTHON_VERSION=2.7 + - CONDA_PYTHON_VERSION=3.6 + - CONDA_PYTHON_VERSION=3.7 + +matrix: + allow_failures: + - env: CONDA_PYTHON_VERSION=2.7 + - env: CONDA_PYTHON_VERSION=3.7 + fast_finish: true +# virtualenv: +# system_site_packages: true +before_script: + # GUI + - "export DISPLAY=:99.0" +before_script: + # GUI + - "export DISPLAY=:99.0" + # - "sh -e /etc/init.d/xvfb start" + # - sleep 3 # give xvfb sume time to start + +before_install: + - sudo apt-get update + - sudo apt-get install -qq cmake libinsighttoolkit3-dev libpng12-dev libgdcm2-dev + + - wget http://home.zcu.cz/~mjirik/lisa/install/install_conda.sh && source install_conda.sh + # We do this conditionally because it saves us some downloading if the + # version is the same. + # - if [[ "$CONDA_PYTHON_VERSION" == "2.7" ]]; then + # echo "python 2" + # else + # echo "python 3" + # fi + - hash -r + - conda config --set always_yes yes --set changeps1 no + - conda config --add channels mjirik + - conda config --add channels conda-forge + - conda update -q conda + # Useful for debugging any issues with conda + - conda info -a + +# command to install dependencies +install: + + # - sudo apt-get install -qq $(< apt_requirements.txt) + - conda create --yes -n travis pip nose coveralls python=$CONDA_PYTHON_VERSION + - source activate travis +# - Install dependencies + - conda install --yes -c SimpleITK -c luispedro -c mjirik --file requirements_conda.txt + - conda install --yes pip nose coveralls pytest pytest-cov +# - pip install -r requirements_pip.txt +# - "echo $LD_LIBRARY_PATH" +# - "pip install -r requirements.txt" +# - 'mkdir build' +# - "cd build" +# - "cmake .." +# - "cmake --build ." +# - "sudo make install" +# - pip install . +# - "cd .." +# - 'echo "include /usr/local/lib" | sudo tee -a /etc/ld.so.conf' +# - 'sudo ldconfig -v' +# - conda list -e +# - python -m io3d.datasets -l 3Dircadb1.1 jatra_5mm exp_small sliver_training_001 io3d_sample_data head volumetrie +# command to run tests +# script: nosetests -v --with-coverage --cover-package=sftpsync + +script: + - python -m pytest --cov=sftpsync/ +after_success: + - coveralls diff --git a/README b/README index e7ce43d..b4a82b4 100644 --- a/README +++ b/README @@ -26,3 +26,11 @@ Example: dst = '/mnt/sdcard/data/' sftp.sync(src, dst, download=False, delete=True) + + +Install: + + pip install sftpsync + +or + conda install -c mjirik sftpsync diff --git a/conda-recipe/bld.bat b/conda-recipe/bld.bat new file mode 100644 index 0000000..6021130 --- /dev/null +++ b/conda-recipe/bld.bat @@ -0,0 +1,2 @@ +"%PYTHON%" setup.py install +if errorlevel 1 exit 1 \ No newline at end of file diff --git a/conda-recipe/build.sh b/conda-recipe/build.sh new file mode 100644 index 0000000..8e25a14 --- /dev/null +++ b/conda-recipe/build.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +$PYTHON setup.py install diff --git a/conda-recipe/meta.yaml b/conda-recipe/meta.yaml new file mode 100644 index 0000000..a777873 --- /dev/null +++ b/conda-recipe/meta.yaml @@ -0,0 +1,68 @@ +package: + name: sftpsync + version: "1.0.13" + +source: +# this is used for build from git hub + git_rev: 1.0.13 + git_url: https://github.com/mjirik/sftpsync-py.git + +# this is used for pypi + # fn: io3d-1.0.130.tar.gz + # url: https://pypi.python.org/packages/source/i/io3d/io3d-1.0.130.tar.gz + # md5: a3ce512c4c97ac2410e6dcc96a801bd8 +# patches: + # List any patch files here + # - fix.patch + +# build: + # noarch_python: True + # preserve_egg_dir: True + # entry_points: + # Put any entry points (scripts to be generated automatically) here. The + # syntax is module:function. For example + # + # - io3d = io3d:main + # + # Would create an entry point called io3d that calls io3d.main() + + + # If this is a new build for the same version, increment the build + # number. If you do not include this key, it defaults to 0. + # number: 1 + +requirements: + build: + - python + - setuptools + - paramiko + + run: + - python + - paramiko + +test: + # Python imports + imports: + - sftpsync + + # commands: + # You can put test commands to be run here. Use this to test that the + # entry points work. + + + # You can also put a file called run_test.py in the recipe that will be run + # at test time. + + # requires: + # Put any additional test requirements here. For example + # - nose + +about: + home: https://github.com/mjirik/sftpsync-py + license: s + summary: 'Sync files and directories over SSH' + +# See +# http://docs.continuum.io/conda/build.html for +# more information about meta.yaml diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..f0cb6e5 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,13 @@ +[bumpversion] +current_version = 1.0.13 +files = setup.py conda-recipe/meta.yaml +commit = True +tag = True +tag_name = {new_version} + +[tool:pytest] +addopts = -m "not interactive and not slow" +markers = + interactive: marks interactive tests + slow: marks slow tests + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..4faafad --- /dev/null +++ b/setup.py @@ -0,0 +1,83 @@ +# Fallowing command is used to upload to pipy +# python setup.py register sdist upload +from setuptools import setup, find_packages +# Always prefer setuptools over distutils +from os import path + +here = path.abspath(path.dirname(__file__)) +setup( + name='sftpsync', + description='Sync files and directories over SSH', + # Versions should comply with PEP440. For a discussion on single-sourcing + # the version across setup.py and the project code, see + # http://packaging.python.org/en/latest/tutorial.html#version + version='1.0.13', + url='https://github.com/mjirik/sftpsync', + author='Jerome Clerc', + author_email='', + license='', + + # See https://pypi.python.org/pypi?%3Aaction=list_classifiers + classifiers=[ + # How mature is this project? Common values are + # 3 - Alpha + # 4 - Beta + # 5 - Production/Stable + 'Development Status :: 3 - Alpha', + + # Indicate who your project is intended for + 'Intended Audience :: Developers', + # 'Topic :: Scientific/Engineering :: Bio-Informatics', + + # Pick your license as you wish (should match "license" above) + # 'License :: OSI Approved :: BSD License', + + # Specify the Python versions you support here. In particular, ensure + # that you indicate whether you support Python 2, Python 3 or both. + # 'Programming Language :: Python :: 2', + # 'Programming Language :: Python :: 2.6', + 'Programming Language :: Python :: 2.7', + # 'Programming Language :: Python :: 3', + # 'Programming Language :: Python :: 3.2', + # 'Programming Language :: Python :: 3.3', + # 'Programming Language :: Python :: 3.4', + ], + + # What does your project relate to? + keywords='sftp sync', + + # You can just specify the packages manually here if your project is + # simple. Or you can use find_packages(). + packages=find_packages(exclude=['dist', 'docs', 'tests*']), + + # List run-time dependencies here. These will be installed by pip when + # your project is installed. For an analysis of "install_requires" vs pip's + # requirements files see: + # https://packaging.python.org/en/latest/technical.html#install-requires-vs-requirements-files + install_requires=['paramiko'], + # 'SimpleITK'], # Removed becaouse of errors when pip is installing + dependency_links=[], + + # If there are data files included in your packages that need to be + # installed, specify them here. If using Python 2.6 or less, then these + # have to be included in MANIFEST.in as well. + # package_data={ + # 'sample': ['package_data.dat'], + # }, + + # Although 'package_data' is the preferred approach, in some case you may + # need to place data files outside of your packages. + # see + # http://docs.python.org/3.4/distutils/setupscript.html#installing-additional-files # noqa + # In this case, 'data_file' will be installed into '/my_data' + # data_files=[('my_data', ['data/data_file'])], + + # To provide executable scripts, use entry points in preference to the + # "scripts" keyword. Entry points provide cross-platform support and allow + # pip to create the appropriate form of executable for the target platform. + # entry_points={ + # 'console_scripts': [ + # 'sample=sample:main', + # ], + # }, +) diff --git a/sftpsync/__init__.py b/sftpsync/__init__.py index 28335d8..51c8935 100755 --- a/sftpsync/__init__.py +++ b/sftpsync/__init__.py @@ -35,14 +35,48 @@ def __init__(self, host, username, password=None, port=22, timeout=10, password=password, timeout=timeout, **kwargs) self.sftp = self.client.open_sftp() return - except (paramiko.BadHostKeyException, paramiko.AuthenticationException), e: + except (paramiko.BadHostKeyException, paramiko.AuthenticationException) as e: raise AuthenticationError(str(e)) - except socket.timeout, e: + except socket.timeout as e: raise TimeoutError(str(e)) - except Exception, e: + except Exception as e: if i == max_attempts - 1: raise SshError(str(e)) + def _join(self, path1, path2, remote, path2_start=None): + if remote: + if not path2_start is None: + # this is necessary on windows + # path2 is there different from the linux + path2 = path2.replace('\\','/') + path2_start = path2_start.replace('\\','/') + if path2_start[-1] != '/': + path2_start += '/' + + if path2.startswith(path2_start): + logger.debug('make relative path to start') + logger.debug("path2 : %s", path2) + logger.debug("path2_start : %s", path2_start) + path2 = path2[len(path2_start):] + logger.debug("path2 after : %s", path2) + + dst = self._join_remote(path1, path2) + else: + dst = os.path.join(path1, path2) + # dst = self._join_remote(path1, path2) + return dst + + def _join_remote(self, path1, path2): + """ + for remote path is separator always "/" + :param path1: + :param path2: + :return: joined paths + """ + + path1 = path1.rstrip('/') + return path1 + "/" + path2 + def _walk_remote(self, path, topdown=True): try: res = self.sftp.listdir_attr(path) @@ -50,7 +84,7 @@ def _walk_remote(self, path, topdown=True): res = [] for stat in res: - file = os.path.join(path, stat.filename) + file = self._join_remote(path, stat.filename) if not S_ISDIR(stat.st_mode): yield 'file', file, stat @@ -65,6 +99,7 @@ def _walk_remote(self, path, topdown=True): yield 'dir', file, None def _walk_local(self, path, topdown=True): + logger.debug('local walk %s', path) for path, dirs, files in os.walk(path, topdown=topdown): for file in files: file = os.path.join(path, file) @@ -80,25 +115,35 @@ def _walk(self, *args, **kwargs): else: return self._walk_local(*args, **kwargs) - def _makedirs_dst(self, path, remote=True, dry=False): + def _mkdir_dst(self, path, src_stat=None, remote=True, dry=False): if remote: - paths = [] - while path not in ('/', ''): - paths.insert(0, path) - path = os.path.dirname(path) - - for path in paths: - try: - self.sftp.lstat(path) - except Exception: - if not dry: - self.sftp.mkdir(path) - logger.debug('created destination directory %s', path) + try: + self.sftp.lstat(path) + except Exception: + if not dry: + self.sftp.mkdir(path) + logger.debug('created destination directory %s', path) + if not dry and src_stat: + self.sftp.chmod(path, src_stat.st_mode) else: if not os.path.exists(path): if not dry: - os.makedirs(path) + os.mkdir(path) logger.debug('created destination directory %s', path) + if not dry and src_stat: + os.chmod(path, src_stat.st_mode) + + def _makedirs_dst(self, path, remote=True, dry=False): + paths = [] + while path not in ('/', ''): + # break also if path is like C:/ + if len(path) == 3 and path[1:] == ":/": + break + paths.insert(0, path) + path = os.path.dirname(path) + + for path in paths: + self._mkdir_dst(path, src_stat=None, remote=remote, dry=dry) def _validate_src(self, file, include, exclude): for re_ in include: @@ -126,17 +171,21 @@ def _validate_dst(self, file, src_stat, remote=True): return if dst_stat.st_size != src_stat.st_size: return + if dst_stat.st_mode != src_stat.st_mode: + return return True def _save(self, src, dst, src_stat, remote=True): if remote: logger.info('copying %s to %s@%s:%s', src, self.username, self.host, dst) - self.sftp.put(src, dst) + self.sftp.put(src, dst, callback=self.callback) self.sftp.utime(dst, (int(src_stat.st_atime), int(src_stat.st_mtime))) + self.sftp.chmod(dst, src_stat.st_mode) else: logger.info('copying %s@%s:%s to %s', self.username, self.host, src, dst) - self.sftp.get(src, dst) + self.sftp.get(src, dst, callback=self.callback) os.utime(dst, (int(src_stat.st_atime), int(src_stat.st_mtime))) + os.chmod(dst, src_stat.st_mode) def _delete_dst(self, path, files, remote=True, dry=False): if remote: @@ -149,7 +198,7 @@ def _delete_dst(self, path, files, remote=True, dry=False): if not dry: try: callables[type](file) - except Exception, e: + except Exception as e: logger.debug('failed to remove %s: %s', file, str(e)) continue @@ -160,7 +209,7 @@ def _get_filters(self, filters): return [] return [re.compile(f) for f in filters] - def sync(self, src, dst, download=True, include=None, exclude=None, delete=False, dry=False): + def sync(self, src, dst, download=True, include=None, exclude=None, delete=False, dry=False, callback=None): '''Sync files and directories. :param src: source directory @@ -171,12 +220,19 @@ def sync(self, src, dst, download=True, include=None, exclude=None, delete=False :param exclude: list of regex patterns the source files must not match :param delete: remove destination files and directories not present at source or filtered by the include/exlude patterns + :param callback: callback function (form: func(int, int)) that accepts the + bytes transferred so far and the total bytes to be transferred ''' + + self.callback = callback include = self._get_filters(include) exclude = self._get_filters(exclude) + src = src.replace('\\', '/') + dst = dst.replace('\\', '/') if src.endswith('/') != dst.endswith('/'): - dst = os.path.join(dst, os.path.basename(src.rstrip('/'))) + logger.debug("Paths ends with different symbol. Paths are joined.") + dst = self._join(dst, os.path.basename(src.rstrip('/')), remote=not download) src = src.rstrip('/') re_base = re.compile(r'^%s/' % re.escape(src)) if not src: @@ -190,15 +246,18 @@ def sync(self, src, dst, download=True, include=None, exclude=None, delete=False for type, file, stat in self._walk(src, remote=download): file_ = re_base.sub('', file) + file_ = file_.replace('\\', '/') if not self._validate_src(file_, include, exclude): logger.debug('filtered %s', file) continue - dst_file = os.path.join(dst, file_) + dst_file = self._join(dst, file_, remote=not download, path2_start=src) + logger.debug("walk %s", file) + logger.debug("walk full %s", dst_file) dst_list[type].append(dst_file) if type == 'dir': - self._makedirs_dst(dst_file, remote=not download, dry=dry) + self._mkdir_dst(dst_file, stat, remote=not download, dry=dry) elif type == 'file': if not self._validate_dst(dst_file, stat, remote=not download): if not dry: diff --git a/tests/sftpsync_test.py b/tests/sftpsync_test.py new file mode 100644 index 0000000..d3286b0 --- /dev/null +++ b/tests/sftpsync_test.py @@ -0,0 +1,259 @@ +import logging +logger = logging.getLogger(__name__) +import unittest +import shutil +import os +import os.path as op + +clean = False + + +def touch(fname, times=None): + with open(fname, 'a'): + os.utime(fname, times) + + +class SftpTestBase(unittest.TestCase): + @classmethod + def setUpClass(cls): + import subprocess + import time + + super(SftpTestBase, cls).setUpClass() + # prepare structure for test SFTP server + pth_from = "test_server/from_server/foo" + pth_to = "test_server/to_server" + if not os.path.exists(pth_from): + os.makedirs(pth_from) + if not os.path.exists(pth_to): + os.makedirs(pth_to) + touch("test_server/from_server/test.txt") + touch("test_server/from_server/foo/bar.txt") + # create RSA key + if not os.path.exists("id_rsa"): + os.system('ssh-keygen -t rsa -q -f id_rsa -P ""') + + # run test SFTP server + # port = 22 + cls.port = 3373 + cls.host = "localhost" + + subprocess.Popen( + "sftpserver -k ../id_rsa -p " + str(cls.port), + cwd="test_server", + shell=True) + time.sleep(1) + + # Server is available for all users and any password + # sftp://user@localhost:3373/ + + @classmethod + def tearDownClass(cls): + super(SftpTestBase, cls).setUpClass() + os.system("pkill sftpserver") + + +class SftpTests(SftpTestBase): + def test_test_server(self): + import paramiko + pkey = paramiko.RSAKey.from_private_key_file('id_rsa') + transport = paramiko.Transport(('localhost', self.port)) + transport.connect(username='admin', password='admin')# , pkey=pkey) + sftp = paramiko.SFTPClient.from_transport(transport) + sftp.listdir('.') + transport.close() + + def test_connection(self): + from sftpsync import Sftp + + sftp = Sftp(self.host, 'paul', 'P4ul', port=self.port) + # hu = sftp.sftp.listdir_attr("from_server") + dir_list = sftp.sftp.listdir_attr("from_server") + self.assertIn(dir_list[0].filename, ["test.txt", "foo"]) + self.assertIn(dir_list[1].filename, ["test.txt", "foo"]) + dir_list2 = sftp.sftp.listdir_attr("from_server/foo") + self.assertEqual(dir_list2[0].filename, 'bar.txt') + + sftp.client.close() + + def test_sync(self): + from sftpsync import Sftp + + src = 'from_server/' + dst = 'test_temp/' + sftp = Sftp(self.host, 'paul', 'P4ul', port=self.port) + + dst = op.expanduser(dst) + + if op.exists(dst): + shutil.rmtree(dst) + # We don't want to backup everything + exclude = [r'^Music/', r'^Video/'] + sftp.sync(src, dst, download=True, exclude=exclude, delete=False) + self.assertTrue(op.exists(op.join(dst, "test.txt"))) + if clean and op.exists(dst): + shutil.rmtree(dst) + + sftp.client.close() + + def test_sync_different_separator(self): + from sftpsync import Sftp + + src = 'from_server/' + dst = 'test_temp_different_separator\\' + sftp = Sftp(self.host, 'paul', 'P4ul', port=self.port) + + dst = op.expanduser(dst) + if op.exists(dst): + shutil.rmtree(dst) + + # We don't want to backup everything + exclude = [r'^Music/', r'^Video/'] + + sftp.sync(src, dst, download=True, exclude=exclude, delete=False) + self.assertTrue(op.exists(op.join(dst.rstrip("\\"),"test.txt"))) + if clean and op.exists(dst): + shutil.rmtree(dst) + + sftp.client.close() + + def test_sync_abspath(self): + from sftpsync import Sftp + + src = 'from_server/' + dst = 'test_temp_abspath\\' + sftp = Sftp(self.host, 'paul', 'P4ul', port=self.port) + + dst = op.abspath(dst) + if not (dst.endswith('/') or dst.endswith('\\')): + dst += '\\' + if op.exists(dst): + shutil.rmtree(dst) + + # We don't want to backup everything + exclude = [r'^Music/', r'^Video/'] + logger.debug("src %s", src) + logger.debug("dst %s", dst) + sftp.sync(src, dst , download=True, exclude=exclude, delete=False) + expected_path = op.join(dst.rstrip("\\"),"test.txt") + logger.debug("Expected path: %s", expected_path) + self.assertTrue(op.exists(expected_path)) + + if clean and op.exists(dst): + shutil.rmtree(dst) + + sftp.client.close() + + def test_sync_upload(self): + from sftpsync import Sftp + src = 'to_server/' + dst = 'to_server/' + + # create test dir + srcfile = op.join(src, 'test_file.txt') + + if not op.exists(src): + logger.debug('creating dir %s', src) + os.makedirs(src) + + with open(srcfile,"a+") as f: + f.write("text\n") + + # connect to sftp + sftp = Sftp(self.host, 'paul', 'P4ul', port=self.port) + + # make sure that test file is not on server + dir_list = sftp.sftp.listdir_attr("to_server/") + fnames = [record.filename for record in dir_list] + if 'test_file.txt' in fnames: + sftp.sftp.remove("to_server/test_file.txt") + + # Make test: sync local directory + exclude = [r'^Music/', r'^Video/'] + sftp.sync(src, dst, download=False, exclude=exclude, delete=True) + dir_list = sftp.sftp.listdir_attr("to_server/") + # check if file is created + self.assertEqual(dir_list[0].filename, 'test_file.txt') + + # remove file and sync again + os.remove(srcfile) + sftp.sync(src, dst, download=False, exclude=exclude, delete=True) + dir_list = sftp.sftp.listdir_attr("to_server/") + # check if direcotry is empty + self.assertEqual(len(dir_list), 0) + + sftp.client.close() + + +class SftpTestFilePermissions(SftpTestBase): + def setUp(self): + from sftpsync import Sftp + self.sftp = Sftp(self.host, 'paul', 'P4ul', port=self.port) + + self.local_dir = 'test_local_file_permissions' + self.remote_relative_dir = 'test_remote_file_permissions' # Relative to the remote session + self.remote_dir = os.path.join('test_server', self.remote_relative_dir) + + self.test_local_sub_dir = os.path.join(self.local_dir, 'test_dir') + self.test_local_file = os.path.join(self.local_dir, 'test.txt') + self.test_remote_sub_dir = os.path.join(self.remote_dir, 'test_dir') + self.test_remote_file = os.path.join(self.remote_dir, 'test.txt') + + def tearDown(self): + shutil.rmtree(self.local_dir) + shutil.rmtree(self.remote_dir) + self.sftp.client.close() + + def test_file_permissions_download(self): + # Make remote files. + os.makedirs(self.test_remote_sub_dir) + touch(self.test_remote_file) + + # Change permissions. + os.chmod(self.test_remote_sub_dir, os.stat(self.test_remote_sub_dir).st_mode | 0o007) + os.chmod(self.test_remote_file, os.stat(self.test_remote_file).st_mode | 0o007) + + self.sftp.sync(src=self.remote_relative_dir, dst=self.local_dir, download=True) + + # Check permissions match. + self.assertEqual(os.stat(self.test_remote_sub_dir).st_mode, os.stat(self.test_local_sub_dir).st_mode) + self.assertEqual(os.stat(self.test_remote_file).st_mode, os.stat(self.test_local_file).st_mode) + + # Change permissions again (to check that files are re-syncd). + os.chmod(self.test_remote_sub_dir, os.stat(self.test_remote_sub_dir).st_mode & ~0o007) + os.chmod(self.test_remote_file, os.stat(self.test_remote_file).st_mode & ~0o007) + + self.sftp.sync(src=self.remote_relative_dir, dst=self.local_dir, download=True) + + # Check permissions match. + self.assertEqual(os.stat(self.test_remote_sub_dir).st_mode, os.stat(self.test_local_sub_dir).st_mode) + self.assertEqual(os.stat(self.test_remote_file).st_mode, os.stat(self.test_local_file).st_mode) + + def test_file_permissions_upload(self): + # Make local files. + os.makedirs(self.test_local_sub_dir) + touch(self.test_local_file) + + # Change permissions. + os.chmod(self.test_local_sub_dir, os.stat(self.test_local_sub_dir).st_mode | 0o007) + os.chmod(self.test_local_file, os.stat(self.test_local_file).st_mode | 0o007) + + self.sftp.sync(src=self.local_dir, dst=self.remote_relative_dir, download=False) + + # Check permissions match. + self.assertEqual(os.stat(self.test_local_sub_dir).st_mode, os.stat(self.test_remote_sub_dir).st_mode) + self.assertEqual(os.stat(self.test_local_file).st_mode, os.stat(self.test_remote_file).st_mode) + + # Change permissions again (to check that files are re-syncd). + os.chmod(self.test_local_sub_dir, os.stat(self.test_local_sub_dir).st_mode & ~0o007) + os.chmod(self.test_local_file, os.stat(self.test_local_file).st_mode & ~0o007) + + self.sftp.sync(src=self.local_dir, dst=self.remote_relative_dir, download=False) + + # Check permissions match. + self.assertEqual(os.stat(self.test_local_sub_dir).st_mode, os.stat(self.test_remote_sub_dir).st_mode) + self.assertEqual(os.stat(self.test_local_file).st_mode, os.stat(self.test_remote_file).st_mode) + + +if __name__ == '__main__': + unittest.main()