diff --git a/Changelog.rst b/Changelog.rst index 4de37c94..e92b3624 100644 --- a/Changelog.rst +++ b/Changelog.rst @@ -7,6 +7,10 @@ Change Log Changes -------- +* Added native ``SFTPClient`` and ``ParallelSFTPClient`` APIs with public remote + directory, metadata, mutation and transfer operations, plus remote current + working directory support. ``ParallelSFTPClient`` runs operations on all + configured hosts concurrently and preserves configured host order. * All local file operations now use a thread pool to improve local file I/O performance. This includes loading private key files from a local file path, identity authentication using local files as well as SFTP read/write operations on local files. diff --git a/ci/integration_tests/libssh2_clients/test_sftp_client.py b/ci/integration_tests/libssh2_clients/test_sftp_client.py new file mode 100644 index 00000000..64933325 --- /dev/null +++ b/ci/integration_tests/libssh2_clients/test_sftp_client.py @@ -0,0 +1,43 @@ +# This file is part of parallel-ssh. +# Copyright (C) 2014-2026 Panos Kittenis. +# Copyright (C) 2014-2026 parallel-ssh Contributors. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation, version 2.1. + +import os +import shutil +import tempfile + +from .base_ssh2_case import SSH2TestCase + + +class SFTPClientTest(SSH2TestCase): + + def test_cwd_directory_and_transfer_operations(self): + remote_root = tempfile.mkdtemp(prefix='parallel-ssh-sftp-') + local_root = tempfile.mkdtemp(prefix='parallel-ssh-local-') + local_source = os.path.join(local_root, 'source.txt') + local_copy = os.path.join(local_root, 'copy.txt') + try: + with open(local_source, 'w') as handle: + handle.write('parallel-ssh') + sftp = self.client.open_sftp() + self.assertTrue(sftp.getcwd().startswith('/')) + sftp.chdir(remote_root) + self.assertEqual(sftp.getcwd(), os.path.realpath(remote_root)) + sftp.mkdir('nested') + self.assertIn('nested', sftp.listdir('.')) + sftp.put(local_source, 'nested/remote.txt') + self.assertIn('remote.txt', sftp.listdir('nested')) + sftp.get('nested/remote.txt', local_copy) + with open(local_copy) as handle: + self.assertEqual(handle.read(), 'parallel-ssh') + sftp.rename('nested/remote.txt', 'nested/renamed.txt') + sftp.remove('nested/renamed.txt') + sftp.rmdir('nested') + self.assertNotIn('nested', sftp.listdir('.')) + finally: + shutil.rmtree(remote_root, ignore_errors=True) + shutil.rmtree(local_root, ignore_errors=True) diff --git a/doc/api.rst b/doc/api.rst index 7f45c00a..3c710fa3 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -6,6 +6,7 @@ API Documentation native_parallel native_single + native_sftp ssh_parallel ssh_single base_parallel diff --git a/doc/native_sftp.rst b/doc/native_sftp.rst new file mode 100644 index 00000000..f2a38f45 --- /dev/null +++ b/doc/native_sftp.rst @@ -0,0 +1,27 @@ +Native SFTP Client +================== + +The native client can open a user-facing SFTP client that owns one reusable +SFTP channel and tracks a remote current working directory. + +.. code-block:: python + + from pssh.clients import SSHClient + + client = SSHClient('localhost') + sftp = client.open_sftp() + sftp.chdir('/srv/uploads') + sftp.mkdir('incoming') + sftp.put('local.txt', 'incoming/remote.txt') + print(sftp.listdir('incoming')) + sftp.get('incoming/remote.txt', 'downloaded.txt') + +Relative remote paths are resolved against ``sftp.getcwd()`` using POSIX path +semantics. The SFTP client is bound to its parent ``SSHClient`` connection. +This API is available for the native ``ssh2-python`` client only; the +``pssh.clients.ssh`` backend does not currently support SFTP. + +.. automodule:: pssh.clients.native.sftp + :members: + :undoc-members: + :member-order: groupwise diff --git a/pssh/clients/native/__init__.py b/pssh/clients/native/__init__.py index 5e5f19ad..4b51a167 100644 --- a/pssh/clients/native/__init__.py +++ b/pssh/clients/native/__init__.py @@ -16,5 +16,6 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA # flake8: noqa: F401 -from .parallel import ParallelSSHClient +from .parallel import ParallelSFTPClient, ParallelSSHClient from .single import SSHClient, logger +from .sftp import SFTPClient diff --git a/pssh/clients/native/parallel.py b/pssh/clients/native/parallel.py index 2612f548..c2a5bba5 100644 --- a/pssh/clients/native/parallel.py +++ b/pssh/clients/native/parallel.py @@ -17,7 +17,10 @@ import logging +from gevent import Timeout as GTimeout + from .single import SSHClient +from .sftp import SFTPClient from ..base.parallel import BaseParallelSSHClient from ..common import _validate_pkey from ...constants import DEFAULT_RETRIES, RETRY_DELAY @@ -513,3 +516,135 @@ def scp_recv(self, remote_file, local_file, recurse=False, copy_args=None, raise HostArgumentError( "Number of per-host copy arguments provided does not match " "number of hosts") + + +class ParallelSFTPClient(ParallelSSHClient): + """Run native SFTP operations on a collection of SSH hosts.""" + + @property + def hosts(self): + return self._hosts + + @hosts.setter + def hosts(self, hosts): + BaseParallelSSHClient.hosts.fset(self, hosts) + if hasattr(self, '_sftp_clients'): + self._sftp_clients.clear() + + def _make_sftp_client(self, host_i, host): + ssh_client = self._get_ssh_client(host_i, host) + return SFTPClient(ssh_client) + + def _get_sftp_client(self, host_i, host): + try: + clients = self._sftp_clients + except AttributeError: + clients = self._sftp_clients = {} + key = (host_i, host) + client = clients.get(key) + if client is None: + client = self._make_sftp_client(host_i, host) + clients[key] = client + return client + + @staticmethod + def _collect(tasks, stop_on_errors): + results = [] + for task in tasks: + try: + results.append(task.get()) + except (GTimeout, Exception) as exc: + if stop_on_errors: + raise + results.append(exc) + return results + + def _run_sftp_operation(self, host_i, host, operation, args, kwargs): + client = self._get_sftp_client(host_i, host) + result = getattr(client, operation)(*args, **kwargs) + if operation == 'listdir': + return list(result) + return result + + def _run_parallel(self, operation, args=(), kwargs=None, + stop_on_errors=True): + kwargs = {} if kwargs is None else kwargs + tasks = [self.pool.spawn( + self._run_sftp_operation, host_i, host, operation, args, kwargs) + for host_i, host in enumerate(self.hosts)] + return self._collect(tasks, stop_on_errors) + + def connect(self, stop_on_errors=True): + """Create and return one :class:`SFTPClient` per configured host. + + Connections are initialized concurrently using this client's pool and + cached for subsequent parallel operations. Results always follow + configured host order. When ``stop_on_errors`` is false, an exception + is returned in place of the failed host's client instead of being + raised. + + :param stop_on_errors: Raise SFTP initialization errors when true. + :type stop_on_errors: bool + :rtype: list(:class:`SFTPClient` or Exception) + """ + tasks = [self.pool.spawn(self._get_sftp_client, host_i, host) + for host_i, host in enumerate(self.hosts)] + return self._collect(tasks, stop_on_errors) + + def getcwd(self, stop_on_errors=True): + """Return the current remote directory for every host.""" + return self._run_parallel('getcwd', stop_on_errors=stop_on_errors) + + def chdir(self, path, stop_on_errors=True): + """Change the current remote directory on every host.""" + return self._run_parallel( + 'chdir', (path,), stop_on_errors=stop_on_errors) + + def listdir(self, path='.', encoding='utf-8', stop_on_errors=True): + """Return directory entry lists from every host.""" + return self._run_parallel( + 'listdir', (path, encoding), stop_on_errors=stop_on_errors) + + def stat(self, path, stop_on_errors=True): + """Return attributes for a remote path on every host.""" + return self._run_parallel( + 'stat', (path,), stop_on_errors=stop_on_errors) + + def lstat(self, path, stop_on_errors=True): + """Return attributes without following links on every host.""" + return self._run_parallel( + 'lstat', (path,), stop_on_errors=stop_on_errors) + + def mkdir(self, path, stop_on_errors=True): + """Create a remote directory on every host.""" + return self._run_parallel( + 'mkdir', (path,), stop_on_errors=stop_on_errors) + + def rmdir(self, path, stop_on_errors=True): + """Remove an empty remote directory on every host.""" + return self._run_parallel( + 'rmdir', (path,), stop_on_errors=stop_on_errors) + + def rename(self, source, destination, stop_on_errors=True): + """Rename a remote path on every host.""" + return self._run_parallel( + 'rename', (source, destination), stop_on_errors=stop_on_errors) + + def remove(self, path, stop_on_errors=True): + """Remove a remote file on every host.""" + return self._run_parallel( + 'remove', (path,), stop_on_errors=stop_on_errors) + + unlink = remove + + def get(self, remote_file, local_file, stop_on_errors=True): + """Copy a remote file from every host to a local path.""" + return self._run_parallel( + 'get', (remote_file, local_file), + stop_on_errors=stop_on_errors) + + def put(self, local_file, remote_file, stop_on_errors=True): + """Copy a local file to every host.""" + return self._run_parallel( + 'put', (local_file, remote_file), + stop_on_errors=stop_on_errors) diff --git a/pssh/clients/native/sftp.py b/pssh/clients/native/sftp.py new file mode 100644 index 00000000..45aafb58 --- /dev/null +++ b/pssh/clients/native/sftp.py @@ -0,0 +1,112 @@ +# This file is part of parallel-ssh. +# Copyright (C) 2014-2026 Panos Kittenis. +# Copyright (C) 2014-2026 parallel-ssh Contributors. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation, version 2.1. + +import posixpath + + +class SFTPClient(object): + """User-facing SFTP operations bound to one native SSH client.""" + + __slots__ = ('_client', '_sftp', '_cwd') + + def __init__(self, client, sftp=None): + self._client = client + self._sftp = client._make_sftp() if sftp is None else sftp + self._cwd = self._canonical_path('.') + + def _canonical_path(self, path): + return self._client.eagain(self._sftp.realpath, path) + + def _remote_path(self, path): + if not isinstance(path, str): + raise TypeError("Remote path must be a string.") + if not path: + return self._cwd + if posixpath.isabs(path): + return posixpath.normpath(path) + return posixpath.normpath(posixpath.join(self._cwd, path)) + + def getcwd(self): + """Get the current remote working directory.""" + return self._cwd + + def chdir(self, path): + """Change the current remote working directory.""" + target = self._canonical_path(self._remote_path(path)) + with self._client._sftp_openfh(self._sftp.opendir, target): + pass + self._cwd = target + return self._cwd + + def listdir(self, path='.', encoding='utf-8'): + """List names in a remote directory.""" + with self._client._sftp_openfh( + self._sftp.opendir, self._remote_path(path)) as dir_h: + entries = self._client._sftp_readdir(dir_h) + names = [entry.decode(encoding) for entry in entries] + return [name for name in names if name not in ('.', '..')] + + def stat(self, path): + """Return attributes for a remote path, following symbolic links.""" + return self._client.eagain(self._sftp.stat, self._remote_path(path)) + + def lstat(self, path): + """Return attributes for a remote path without following links.""" + return self._client.eagain(self._sftp.lstat, self._remote_path(path)) + + def mkdir(self, path): + """Create a remote directory and missing parent directories.""" + return self._client.mkdir(self._sftp, self._remote_path(path)) + + def rmdir(self, path): + """Remove an empty remote directory.""" + return self._client.eagain(self._sftp.rmdir, self._remote_path(path)) + + def rename(self, source, destination): + """Rename a remote path.""" + return self._client.eagain( + self._sftp.rename, + self._remote_path(source), + self._remote_path(destination), + ) + + def remove(self, path): + """Remove a remote file.""" + return self._client.eagain(self._sftp.unlink, self._remote_path(path)) + + unlink = remove + + def get(self, remote_file, local_file): + """Copy one remote file to a local path.""" + return self._client.sftp_get( + self._sftp, self._remote_path(remote_file), local_file) + + def put(self, local_file, remote_file): + """Copy one local file to a remote path.""" + return self._client.sftp_put( + self._sftp, local_file, self._remote_path(remote_file)) + + def copy_file(self, local_file, remote_file, recurse=False): + """Copy a local file or directory to a remote path.""" + return self._client.copy_file( + local_file, + self._remote_path(remote_file), + recurse=recurse, + sftp=self._sftp, + ) + + def copy_remote_file(self, remote_file, local_file, recurse=False, + encoding='utf-8'): + """Copy a remote file or directory to a local path.""" + return self._client.copy_remote_file( + self._remote_path(remote_file), + local_file, + recurse=recurse, + sftp=self._sftp, + encoding=encoding, + ) diff --git a/pssh/clients/native/single.py b/pssh/clients/native/single.py index ee1d9855..7a4446a1 100644 --- a/pssh/clients/native/single.py +++ b/pssh/clients/native/single.py @@ -35,6 +35,7 @@ LIBSSH2_SFTP_S_IXGRP, LIBSSH2_SFTP_S_IXOTH from .tunnel import FORWARDER +from .sftp import SFTPClient from ..base.single import BaseSSHClient, PollMixIn from ...constants import DEFAULT_RETRIES, RETRY_DELAY from ...exceptions import SessionError, SFTPError, \ @@ -441,6 +442,10 @@ def _make_sftp(self): raise SFTPError(ex) return sftp + def open_sftp(self): + """Open a user-facing SFTP client bound to this SSH session.""" + return SFTPClient(self) + def _mkdir(self, sftp, directory): """Make directory via SFTP channel. diff --git a/tests/test_native_parallel.py b/tests/test_native_parallel.py new file mode 100644 index 00000000..6e80621d --- /dev/null +++ b/tests/test_native_parallel.py @@ -0,0 +1,191 @@ +# This file is part of parallel-ssh. +# Copyright (C) 2014-2025 Panos Kittenis. +# Copyright (C) 2014-2025 parallel-ssh Contributors. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation, version 2.1. + +from unittest import TestCase +from unittest.mock import Mock, call, patch + +from gevent import Timeout + +from pssh.clients.native import ParallelSFTPClient +from pssh.clients.native import parallel + + +class DeferredTask: + + def __init__(self, func, *args): + self.func = func + self.args = args + + def get(self): + return self.func(*self.args) + + +class RecordingPool: + + def __init__(self): + self.calls = [] + + def spawn(self, func, *args): + self.calls.append(args) + return DeferredTask(func, *args) + + +class ParallelSFTPClientTest(TestCase): + + def make_client(self, hosts, host_clients): + client = Mock() + client.hosts = hosts + client.pool = RecordingPool() + client._get_ssh_client = Mock( + side_effect=lambda host_i, _host: host_clients[host_i]) + client._make_sftp_client = ParallelSFTPClient._make_sftp_client.__get__( + client) + client._get_sftp_client = ParallelSFTPClient._get_sftp_client.__get__( + client) + client._collect = ParallelSFTPClient._collect + client._sftp_clients = {} + return client + + @patch.object(parallel, 'SFTPClient') + def test_connect_creates_sftp_clients_in_host_order(self, sftp_client): + hosts = ['host-b', 'host-a', 'host-c'] + host_clients = [Mock(name=host) for host in hosts] + sftp_clients = [Mock(name='%s-sftp' % host) for host in hosts] + sftp_client.side_effect = sftp_clients + client = self.make_client(hosts, host_clients) + + result = ParallelSFTPClient.connect(client) + + self.assertEqual(sftp_clients, result) + self.assertEqual([(0, 'host-b'), (1, 'host-a'), (2, 'host-c')], + client.pool.calls) + self.assertEqual([call(host_client) for host_client in host_clients], + sftp_client.call_args_list) + + self.assertEqual(sftp_clients, ParallelSFTPClient.connect(client)) + self.assertEqual(len(hosts), sftp_client.call_count) + + @patch.object(parallel, 'SFTPClient') + def test_connect_returns_errors_in_host_order_when_not_stopping(self, + sftp_client): + hosts = ['first', 'failing', 'last'] + host_clients = [Mock(name=host) for host in hosts] + first = Mock(name='first-sftp') + error = RuntimeError('SFTP unavailable') + last = Mock(name='last-sftp') + sftp_client.side_effect = [first, error, last] + client = self.make_client(hosts, host_clients) + + result = ParallelSFTPClient.connect(client, stop_on_errors=False) + + self.assertEqual([first, error, last], result) + + @patch.object(parallel, 'SFTPClient') + def test_connect_raises_when_stopping_on_errors(self, sftp_client): + error = RuntimeError('SFTP unavailable') + sftp_client.side_effect = [Mock(name='first-sftp'), error] + client = self.make_client( + ['first', 'failing'], [Mock(name='first'), Mock(name='failing')]) + + with self.assertRaisesRegex(RuntimeError, 'SFTP unavailable'): + ParallelSFTPClient.connect(client, stop_on_errors=True) + + @patch.object(parallel, 'SFTPClient') + def test_operations_run_for_every_host_and_preserve_result_order( + self, sftp_client): + hosts = ['host-b', 'host-a'] + host_clients = [Mock(name=host) for host in hosts] + sftp_clients = [Mock(name='%s-sftp' % host) for host in hosts] + sftp_client.side_effect = sftp_clients + client = self.make_client(hosts, host_clients) + client._run_sftp_operation = \ + ParallelSFTPClient._run_sftp_operation.__get__(client) + client._run_parallel = ParallelSFTPClient._run_parallel.__get__(client) + sftp_clients[0].listdir.return_value = iter(['b-one', 'b-two']) + sftp_clients[1].listdir.return_value = iter(['a-one']) + + result = ParallelSFTPClient.listdir(client, 'data') + + self.assertEqual([['b-one', 'b-two'], ['a-one']], result) + self.assertEqual( + [call('data', 'utf-8'), call('data', 'utf-8')], + [sftp.listdir.call_args for sftp in sftp_clients], + ) + self.assertEqual(len(hosts), sftp_client.call_count) + + self.assertEqual( + [sftp.getcwd.return_value for sftp in sftp_clients], + ParallelSFTPClient.getcwd(client), + ) + self.assertEqual(len(hosts), sftp_client.call_count) + + @patch.object(parallel, 'SFTPClient') + def test_operation_errors_follow_stop_on_errors(self, sftp_client): + hosts = ['first', 'failing', 'last'] + sftp_clients = [Mock(name='%s-sftp' % host) for host in hosts] + error = RuntimeError('stat unavailable') + sftp_clients[0].stat.return_value = 'first-stat' + sftp_clients[1].stat.side_effect = error + sftp_clients[2].stat.return_value = 'last-stat' + sftp_client.side_effect = sftp_clients + client = self.make_client(hosts, [Mock(name=host) for host in hosts]) + client._run_sftp_operation = \ + ParallelSFTPClient._run_sftp_operation.__get__(client) + client._run_parallel = ParallelSFTPClient._run_parallel.__get__(client) + + result = ParallelSFTPClient.stat( + client, 'target', stop_on_errors=False) + + self.assertEqual(['first-stat', error, 'last-stat'], result) + + with self.assertRaisesRegex(RuntimeError, 'stat unavailable'): + ParallelSFTPClient.stat(client, 'target') + + def test_collect_can_return_gevent_timeout(self): + timeout = Timeout(1) + + def raise_timeout(): + raise timeout + + result = ParallelSFTPClient._collect( + [DeferredTask(raise_timeout)], stop_on_errors=False) + + self.assertEqual([timeout], result) + + def test_public_operations_forward_arguments(self): + client = Mock() + operations = [ + ('chdir', ('directory',), ('chdir', ('directory',))), + ('stat', ('file',), ('stat', ('file',))), + ('lstat', ('link',), ('lstat', ('link',))), + ('mkdir', ('directory',), ('mkdir', ('directory',))), + ('rmdir', ('directory',), ('rmdir', ('directory',))), + ('rename', ('old', 'new'), ('rename', ('old', 'new'))), + ('remove', ('file',), ('remove', ('file',))), + ('unlink', ('file',), ('remove', ('file',))), + ('get', ('remote', 'local'), ('get', ('remote', 'local'))), + ('put', ('local', 'remote'), ('put', ('local', 'remote'))), + ] + + for method, args, forwarded in operations: + with self.subTest(method=method): + getattr(ParallelSFTPClient, method)(client, *args) + client._run_parallel.assert_called_once_with( + forwarded[0], forwarded[1], stop_on_errors=True) + client._run_parallel.reset_mock() + + def test_changing_hosts_invalidates_sftp_clients(self): + client = object.__new__(ParallelSFTPClient) + client._hosts = ['old-host'] + client._host_clients = {(0, 'old-host'): Mock()} + client._sftp_clients = {(0, 'old-host'): Mock()} + + client.hosts = ['new-host'] + + self.assertEqual({}, client._host_clients) + self.assertEqual({}, client._sftp_clients) diff --git a/tests/test_native_sftp.py b/tests/test_native_sftp.py new file mode 100644 index 00000000..c24750a7 --- /dev/null +++ b/tests/test_native_sftp.py @@ -0,0 +1,183 @@ +# This file is part of parallel-ssh. +# Copyright (C) 2014-2026 Panos Kittenis. +# Copyright (C) 2014-2026 parallel-ssh Contributors. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation, version 2.1. + +import unittest + +from pssh.clients.native.sftp import SFTPClient + + +class DirectoryHandle(object): + + def __init__(self): + self.closed = False + + def __enter__(self): + return self + + def __exit__(self, *_args): + self.closed = True + + +class SFTP(object): + + def __init__(self): + self.realpath_calls = [] + self.opendir_calls = [] + self.handles = [] + self.calls = [] + + def realpath(self, path): + self.realpath_calls.append(path) + return '/home/tester' if path == '.' else path + + def opendir(self, path): + self.opendir_calls.append(path) + handle = DirectoryHandle() + self.handles.append(handle) + return handle + + def stat(self, path): + self.calls.append(('stat', path)) + return 'stat-result' + + def lstat(self, path): + self.calls.append(('lstat', path)) + return 'lstat-result' + + def rmdir(self, path): + self.calls.append(('rmdir', path)) + return 0 + + def rename(self, source, destination): + self.calls.append(('rename', source, destination)) + return 0 + + def unlink(self, path): + self.calls.append(('unlink', path)) + return 0 + + +class SSHClient(object): + + def __init__(self, sftp): + self.sftp = sftp + + def _make_sftp(self): + return self.sftp + + def eagain(self, func, *args): + return func(*args) + + def _sftp_openfh(self, func, *args): + return func(*args) + + def _sftp_readdir(self, _handle): + return iter((b'.', b'..', b'file.txt', b'data')) + + def mkdir(self, sftp, path): + self.calls = getattr(self, 'calls', []) + self.calls.append(('mkdir', sftp, path)) + + def sftp_get(self, sftp, remote_file, local_file): + self.calls = getattr(self, 'calls', []) + self.calls.append(('get', sftp, remote_file, local_file)) + + def sftp_put(self, sftp, local_file, remote_file): + self.calls = getattr(self, 'calls', []) + self.calls.append(('put', sftp, local_file, remote_file)) + + def copy_file(self, local_file, remote_file, recurse=False, sftp=None): + self.calls = getattr(self, 'calls', []) + self.calls.append( + ('copy_file', sftp, local_file, remote_file, recurse)) + + def copy_remote_file(self, remote_file, local_file, recurse=False, + sftp=None, encoding='utf-8'): + self.calls = getattr(self, 'calls', []) + self.calls.append( + ('copy_remote_file', sftp, remote_file, local_file, + recurse, encoding)) + + +class NativeSFTPClientTest(unittest.TestCase): + + def setUp(self): + self.sftp = SFTP() + self.ssh_client = SSHClient(self.sftp) + self.client = SFTPClient(self.ssh_client) + + def test_initial_cwd_uses_server_realpath(self): + self.assertEqual(self.client.getcwd(), '/home/tester') + self.assertEqual(self.sftp.realpath_calls, ['.']) + + def test_remote_path_uses_posix_semantics(self): + self.assertEqual( + self.client._remote_path('../shared/./file'), '/home/shared/file') + self.assertEqual( + self.client._remote_path('/var//data/../log'), '/var/log') + + def test_chdir_canonicalizes_and_verifies_directory(self): + cwd = self.client.chdir('data') + + self.assertEqual(cwd, '/home/tester/data') + self.assertEqual(self.client.getcwd(), '/home/tester/data') + self.assertEqual(self.sftp.opendir_calls, ['/home/tester/data']) + self.assertTrue(self.sftp.handles[0].closed) + + def test_invalid_path_type_fails_before_transport(self): + with self.assertRaises(TypeError): + self.client.chdir(None) + + self.assertEqual(self.sftp.opendir_calls, []) + + def test_listdir_filters_navigation_entries(self): + self.assertEqual(self.client.listdir('data'), ['file.txt', 'data']) + self.assertEqual(self.sftp.opendir_calls, ['/home/tester/data']) + + def test_metadata_and_mutations_resolve_remote_paths(self): + self.assertEqual(self.client.stat('file'), 'stat-result') + self.assertEqual(self.client.lstat('../link'), 'lstat-result') + self.client.rmdir('empty') + self.client.rename('old', '../new') + self.client.remove('obsolete') + + self.assertEqual( + self.sftp.calls, + [ + ('stat', '/home/tester/file'), + ('lstat', '/home/link'), + ('rmdir', '/home/tester/empty'), + ('rename', '/home/tester/old', '/home/new'), + ('unlink', '/home/tester/obsolete'), + ], + ) + + def test_transfer_helpers_reuse_bound_channel_and_cwd(self): + self.client.mkdir('new/child') + self.client.get('remote.txt', 'local.txt') + self.client.put('local.bin', '../remote.bin') + self.client.copy_file('tree', 'remote-tree', recurse=True) + self.client.copy_remote_file( + 'remote-tree', 'local-tree', recurse=True, encoding='ascii') + + self.assertEqual( + self.ssh_client.calls, + [ + ('mkdir', self.sftp, '/home/tester/new/child'), + ('get', self.sftp, '/home/tester/remote.txt', 'local.txt'), + ('put', self.sftp, 'local.bin', '/home/remote.bin'), + ('copy_file', self.sftp, 'tree', + '/home/tester/remote-tree', True), + ('copy_remote_file', self.sftp, + '/home/tester/remote-tree', 'local-tree', True, 'ascii'), + ], + ) + + +if __name__ == '__main__': + unittest.main()