-
Notifications
You must be signed in to change notification settings - Fork 154
feat(native): add SFTP client API #426
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ API Documentation | |
|
|
||
| native_parallel | ||
| native_single | ||
| native_sftp | ||
| ssh_parallel | ||
| ssh_single | ||
| base_parallel | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ('.', '..')] | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should be removed in favour of generator. |
||
|
|
||
| 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): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Recursion would be nice to have here, but can be added later. |
||
| """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, | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Calling this |
||
| """Open a user-facing SFTP client bound to this SSH session.""" | ||
| return SFTPClient(self) | ||
|
|
||
| def _mkdir(self, sftp, directory): | ||
| """Make directory via SFTP channel. | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Returning a generator allows other greenlets to run while this is blocked, which will be needed for a
ParallelSFTPClient.Also matches the rest of the API which returns generators.