diff --git a/Changelog.rst b/Changelog.rst index 4de37c94..1070c413 100644 --- a/Changelog.rst +++ b/Changelog.rst @@ -7,6 +7,9 @@ Change Log Changes -------- +* Added a native ``SFTPClient`` via ``SSHClient.open_sftp`` with public remote + directory, metadata, mutation and transfer operations, plus remote current + working directory support. * 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..6f9ae0c8 100644 --- a/pssh/clients/native/__init__.py +++ b/pssh/clients/native/__init__.py @@ -18,3 +18,4 @@ # flake8: noqa: F401 from .parallel import ParallelSSHClient from .single import SSHClient, logger +from .sftp import SFTPClient 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_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()