Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
43 changes: 43 additions & 0 deletions ci/integration_tests/libssh2_clients/test_sftp_client.py
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)
1 change: 1 addition & 0 deletions doc/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ API Documentation

native_parallel
native_single
native_sftp
ssh_parallel
ssh_single
base_parallel
Expand Down
27 changes: 27 additions & 0 deletions doc/native_sftp.rst
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
1 change: 1 addition & 0 deletions pssh/clients/native/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@
# flake8: noqa: F401
from .parallel import ParallelSSHClient
from .single import SSHClient, logger
from .sftp import SFTPClient
112 changes: 112 additions & 0 deletions pssh/clients/native/sftp.py
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 ('.', '..')]

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,
)
5 changes: 5 additions & 0 deletions pssh/clients/native/single.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, \
Expand Down Expand Up @@ -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.

Expand Down
Loading