Skip to content
Merged
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
4 changes: 0 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,6 @@ jobs:
python-version: 3.7
- os: ubuntu-latest
python-version: 3.7
- os: windows-latest
python-version: 3.13
- os: windows-latest
python-version: 3.14

steps:
- name: Clone Repository
Expand Down
15 changes: 12 additions & 3 deletions docs/changes.rst
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
1.1.10 (current, released 2025-10-14)
-----------------------------------
1.2.0 (current, released 2025-12-21)
------------------------------------
* fix for change in pathlib behavior in Python 3.13+ on Windows.
* fix for parsing limitation of .stem causing issues with dots in path names.

1.1.11 (released 2025-10-22)
----------------------------
* improved exception handling in _sftp_channel.

1.1.10 (released 2025-10-14)
----------------------------
* fix for channel re-use limitation.
* regression fix for properly closing channel cache sockets.

1.1.9 (released 2025-8-06)
-----------------------------------
--------------------------
* adding channel cache to place upper limit on creation overhead.
* removing ssh-dss key type as it was deprecated in paramiko in 4.0.0.

Expand Down
6 changes: 3 additions & 3 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,16 +47,16 @@

# General information about the project.
project = u'sftpretty'
copyright = u'2020, byteskeptical'
copyright = u'2025, byteskeptical'

# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
version = '1.1.10'
version = '1.2.0'
# The full version, including alpha/beta/rc tags.
release = '1.1.10'
release = '1.2.0'

# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
Expand Down
14 changes: 8 additions & 6 deletions docs/contributing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ Code
#. Fork the repository `sftpretty <https://github.com/byteskeptical/sftpretty>`_
#. Install supporting software packages and sftpretty in --editable mode

a. Make a virtualenv, clone the repos, install the deps from pip install -r requirements-dev.txt
b. Install sftpretty in editable mode, pip install -e .
a. Make a virtualenv, python3 -m venv .sftpretty
b. Clone the repo, git clone https://github.com/`username`/sftpretty
c. Install sftpretty and it's dependencies in editable mode, python3 -m pip install -e .[dev,lint,test]

#. Write any new tests needed and ensure existing tests continue to pass without modification.

a. Setup CI testing for your Fork. Currently testing is done on Github Actions but feel free to use the testing framework of your choosing.
b. Testing features that concern chmod, chown on Windows is NOT supported. Testing compression has to be ran against a local compatible sshd and not the plugin as it does NOT support this test.
c. You will need to setup an ssh daemon on your local machine and create a user: copy the contents of id_sftpretty.pub to the newly created user's authorized_keys file -- Tests that can only be run locally are skipped using the @skip_if_ci decorator so they don't fail when the test suite is run on the CI server.
a. Setup CI testing for your fork. Currently testing is done on Github Actions but feel free to use the framework of your choosing.
b. Testing features that concern chmod, chown on Windows is NOT supported. Testing compression has to be ran against a local compatible sshd and not the pytest-sftpserver plugin as it does NOT support this feature.
c. You will need to setup an ssh daemon on your local machine and create a user: copy the contents of id_sftpretty.pub to the newly created user's authorized_keys file -- Tests that can only be ran locally are skipped using the @skip_if_ci decorator so they don't fail when the test suite runs on the CI server.

#. Ensure that your name is added to the end of the :doc:`authors` file using the format Name <email@domain.com> (url), where the (url) portion is optional.
#. Submit a Pull Request to the project.
Expand All @@ -40,4 +42,4 @@ This section lists the priority that will be assigned to an issue:

Testing
-------
Tests specific to an issue should be put in the tests/ directory and the module should be named test_issue_xx.py The tests within that module should be named test_issue_xx or test_issue_xx_YYYYYY if more than one test. Pull requests should not modify existing tests (exceptions apply). See tests/test_issue_xx.py for a template and further explanation.
Tests specific to an issue should be placed inside the tests/ directory and the file should be named test_issue_xx.py. The tests within that module should be named test_issue_xx or test_issue_xx_YYYYYY if more than one test exists. Pull requests should not modify existing tests with extremely rare exception. See tests/test_issue_xx.py for a template and additional context.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ keywords = [
name = 'sftpretty'
readme = 'README.rst'
requires-python = '>=3.6'
version = '1.1.10'
version = '1.2.0'

[project.scripts]
sftpretty = 'sftpretty:Connection'
Expand Down
125 changes: 101 additions & 24 deletions sftpretty/__init__.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
from concurrent.futures import as_completed, ThreadPoolExecutor
from contextlib import contextmanager
from errno import ECONNRESET, EPIPE, errorcode
from functools import partial
from logging import (DEBUG, ERROR, FileHandler, Formatter, getLogger, INFO,
StreamHandler, WARN)
from os import environ, SEEK_END, utime
from paramiko import (Agent, hostkeys, SFTPClient, SSHConfig, Transport,
ConfigParseError, PasswordRequiredException,
SSHException, ECDSAKey, Ed25519Key, RSAKey)
from paramiko import (Agent, ChannelException, ConfigParseError, ECDSAKey,
Ed25519Key, hostkeys, PasswordRequiredException,
SFTPClient, SFTPError, SFTP_FAILURE, SFTP_NO_SUCH_FILE,
SFTP_OP_UNSUPPORTED, SFTP_PERMISSION_DENIED, SSHConfig,
SSHException, RSAKey, Transport)
from pathlib import Path
from sftpretty.exceptions import (CredentialException, ConnectionException,
HostKeysException, LoggingException)
from sftpretty.helpers import _callback, drivedrop, hash, localtree, retry
from socket import gaierror
from socket import gaierror, timeout
from stat import S_ISDIR, S_ISREG
from tempfile import mkstemp
from threading import local as cache
Expand Down Expand Up @@ -295,6 +298,7 @@ def _set_username(self, username):
def _sftp_channel(self):
'''Establish new SFTP channel.'''
channel = None
fatal = False

try:
channel_name, data = next(
Expand All @@ -310,17 +314,17 @@ def _sftp_channel(self):
except StopIteration:
pass

if channel is None:
channel = SFTPClient.from_transport(self._transport)
channel_name = uuid4().hex
meta = channel.get_channel()
meta.set_name(channel_name)
log.debug(f'Channel Name: [{channel_name}]')
self._channels[channel_name] = {
'busy': True, 'channel': channel, 'meta': meta
}

try:
if channel is None:
channel = SFTPClient.from_transport(self._transport)
channel_name = uuid4().hex
meta = channel.get_channel()
meta.set_name(channel_name)
log.debug(f'Channel Name: [{channel_name}]')
self._channels[channel_name] = {
'busy': True, 'channel': channel, 'meta': meta
}

meta.settimeout(self._timeout)
self._cache.__dict__.setdefault('cwd', self._default_path)

Expand All @@ -331,15 +335,88 @@ def _sftp_channel(self):
log.info(f'Current Working Directory: [{self._cache.cwd}]')

yield channel
except IOError as err:
log.error(f'Failed Directory Change: [{self._cache.cwd}]')
except timeout:
fatal = True
_message = (
f'Channel [{channel_name}] operation timed out after '
f'{self._timeout}s while accessing: [{self._cache.cwd}]'
)
log.error(_message)
raise TimeoutError(_message)
except SFTPError as err:
_message_map = {
SFTP_FAILURE: (
'A generic failure occurred on the SFTP server for path: '
f'[{self._cache.cwd}]'
),
SFTP_NO_SUCH_FILE: (
f'Directory or file does not exist: [{self._cache.cwd}]'
),
SFTP_OP_UNSUPPORTED: (
'Operation (e.g., chdir) unsupported by server for path: '
f'[{self._cache.cwd}]'
),
SFTP_PERMISSION_DENIED: (
f'Permission denied for: [{self._cache.cwd}]'
),
}
_message = _message_map.get(
err.errno,
('Unhandled SFTP error on directory change to '
f'[{self._cache.cwd}] (Code {err.errno}): {err}')
)
log.error(_message)
raise err
except ChannelException as err:
fatal = True
log.error(f'Channel [{channel_name}] is invalid or closed: {err}')
raise err
except SSHException as err:
fatal = True
log.error(
(f'Protocol error occurred during channel [{channel_name}] '
f'setup: {err}')
)
raise err
except OSError as err:
fatal = True
_message = (f'Channel [{channel_name}] experienced an OS-level '
f'network error (Code: {err.errno} - '
f'{errorcode.get(err.errno)}): {err}')

if err.errno == ECONNRESET:
_message = (
f'Channel [{channel_name}] connection forcefully reset by '
f'the remote host: {err}'
)
elif err.errno == EPIPE:
_message = (
f'Channel [{channel_name}] connection was broken '
f'(broken pipe): {err}'
)

log.error(_message)
raise err
except Exception as err:
if channel:
channel.close()
err_type = type(err).__name__
fatal = True
log.error(
(f'An unexpected error of type [{err_type}] occurred in '
f'channel [{channel_name}]: {err}')
)
raise err
finally:
if not meta.closed:
if fatal and channel:
channel.close()
log.debug(
(f'Closed compromised channel [{channel_name}] due to '
'fatal error!')
)
self._channels.pop(channel_name, None)
elif channel and not meta.closed:
log.debug(
f'Recycling channel [{channel_name}] back to the pool.'
)
self._channels[channel_name]['busy'] = False

def _start_transport(self, host, port):
Expand Down Expand Up @@ -884,7 +961,7 @@ def put_d(self, localdir, remotedir, callback=None, confirm=True,
'''
localdir = Path(localdir)

self.mkdir_p(Path(remotedir).joinpath(localdir.stem).as_posix())
self.mkdir_p(Path(remotedir).joinpath(localdir.parts[-1]).as_posix())

paths = [
(localpath.as_posix(),
Expand Down Expand Up @@ -1203,7 +1280,7 @@ def getcwd(self):
:returns: (str) Remote current working directory. None, if not set.
'''
with self._sftp_channel() as channel:
cwd = channel.getcwd()
cwd = drivedrop(channel.getcwd())

return cwd

Expand Down Expand Up @@ -1332,11 +1409,11 @@ def mkdir_p(self, remotedir, mode=700):
'already exists.'))
else:
parent = Path(remotedir).parent.as_posix()
stem = Path(remotedir).stem
stem = Path(remotedir).parts[-1]
if parent != remotedir:
if not self.isdir(parent):
self.mkdir_p(parent, mode=mode)
if stem:
if stem and stem != Path(remotedir).root:
self.mkdir(remotedir, mode=mode)
except Exception as err:
raise err
Expand Down Expand Up @@ -1410,7 +1487,7 @@ def remotetree(self, container, remotedir, localdir, recurse=True):
remote = Path(remotedir).joinpath(
attribute.filename).as_posix()
local = Path(localdir).joinpath(
Path(remote).stem).as_posix()
Path(remote).parts[-1]).as_posix()
if remotedir in container.keys():
container[remotedir].append((remote, local))
else:
Expand Down
8 changes: 5 additions & 3 deletions sftpretty/helpers.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from functools import wraps
from hashlib import new, sha3_512
from io import BytesIO, IOBase
from pathlib import Path, PureWindowsPath
from pathlib import Path, PurePosixPath, PureWindowsPath
from stat import S_IMODE
from time import sleep

Expand All @@ -19,7 +19,8 @@ def _callback(filename, bytes_so_far, bytes_total, logger=None):
def drivedrop(filepath):
if filepath:
if PureWindowsPath(filepath).drive:
filepath = Path('/').joinpath(*Path(filepath).parts[1:]).as_posix()
filepath = PurePosixPath('/').joinpath(
*PurePosixPath(filepath).parts[1:]).as_posix()

return filepath

Expand Down Expand Up @@ -88,7 +89,8 @@ def localtree(container, localdir, remotedir, recurse=True):
for localpath in Path(localdir).iterdir():
if localpath.is_dir():
local = localpath.as_posix()
remote = Path(remotedir).joinpath(localpath.stem).as_posix()
remote = Path(remotedir).joinpath(localpath.relative_to(
localdir).as_posix()).as_posix()
if localdir.as_posix() in container.keys():
container[localdir.as_posix()].append((local, remote))
else:
Expand Down
Loading