Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
3cabcad
mismatched sock
byteskeptical Aug 16, 2025
c73db27
adding python 3.13 to the test suite and as the default to the publis…
byteskeptical Aug 16, 2025
c6093c5
addition of python 3.13 requires another call to drivedrop to avoid n…
byteskeptical Aug 20, 2025
8c24763
unfortunately this is a deeper issue that requires upstream parity. W…
byteskeptical Aug 20, 2025
8e41a59
and there's a trailing backslash now as well, 3.13 support off to a r…
byteskeptical Aug 20, 2025
4c382b5
tests caught something yay!
byteskeptical Aug 20, 2025
236f478
is this a bug?
byteskeptical Aug 25, 2025
fa5cfa0
resetting to root on channel reuse
byteskeptical Aug 25, 2025
08f02b8
bring the test to parity with similar cd ones, just use channel name …
byteskeptical Aug 25, 2025
bd5776c
coming into focus
byteskeptical Aug 26, 2025
b3daf93
lets do both
byteskeptical Aug 26, 2025
a641b3e
now what is going on with Windows python3.13
byteskeptical Aug 26, 2025
61b2a84
this could be the cause of my windows python 3.13 issues
byteskeptical Aug 26, 2025
5b52585
keepin it posix for test asserts, definitely need the chdir reset in …
byteskeptical Aug 26, 2025
fff996f
I suspect this is all attributable to the stricter absolute path defi…
byteskeptical Aug 27, 2025
023a26d
need both apparently
byteskeptical Aug 27, 2025
1156977
absolutely sus
byteskeptical Aug 27, 2025
44bbc3c
unfortunate complexity
byteskeptical Aug 30, 2025
6a4d3a9
taking care of the none case for the root
byteskeptical Aug 30, 2025
0294913
additional logging
byteskeptical Sep 2, 2025
42dd827
upstream issue is spreading in 3.13
byteskeptical Sep 2, 2025
da686fd
need another test
byteskeptical Sep 2, 2025
47e5949
refactor reached
byteskeptical Sep 2, 2025
d0eb86f
normalize not feeling so normal
byteskeptical Sep 2, 2025
e6b0904
moving timeout setter ordering in channel creation as well as channel…
byteskeptical Sep 2, 2025
9108b4b
final attempt for the night
byteskeptical Sep 2, 2025
37efb3b
this is the last one
byteskeptical Sep 2, 2025
bcf2ffe
I will go to sleep after this
byteskeptical Sep 2, 2025
ac48ad0
how could it not
byteskeptical Sep 2, 2025
84d9856
adding _default_path to both scenarios
byteskeptical Sep 2, 2025
b0ca43c
setting a default cwd cache wise, then setting each threads version a…
byteskeptical Sep 2, 2025
6af2294
what I get for language hopping
byteskeptical Sep 2, 2025
c63ed88
refactored channel cache and default_path usage
byteskeptical Sep 3, 2025
98daf0f
the blight of windows 3.13 continues
byteskeptical Sep 7, 2025
accb70c
debug log for failing tests
byteskeptical Sep 7, 2025
ddf0c99
another one
byteskeptical Sep 8, 2025
d56982a
where does it have to be set?
byteskeptical Sep 13, 2025
7736a9c
pwd is returning something other than on 3.13, this is the fix, hopef…
byteskeptical Sep 20, 2025
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
2 changes: 1 addition & 1 deletion .github/workflows/document.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
fail-fast: true
matrix:
os: [ubuntu-latest]
python-version: ['3.12']
python-version: ['3.13']

steps:
- name: Clone Repository
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
fail-fast: true
matrix:
os: [ubuntu-latest]
python-version: ['3.12']
python-version: ['3.13']

steps:
- name: Clone Repository
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
fail-fast: false
matrix:
os: [macos-13, macos-latest, ubuntu-22.04, ubuntu-latest, windows-latest]
python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12']
python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12', '3.13']
exclude:
- os: macos-latest
python-version: 3.7
Expand Down
2 changes: 1 addition & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,6 @@ paramiko >= 2.7.0

Supports
--------
Tested on Python 3.6, 3.7, 3.8, 3.9, 3.10, 3.11, 3.12
Tested on Python 3.6, 3.7, 3.8, 3.9, 3.10, 3.11, 3.12, 3.13


12 changes: 8 additions & 4 deletions docs/changes.rst
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
Change Log
==========

1.1.9 (current, released 2025-8-06)

1.1.10 (current, released 2025-9-19)
-----------------------------------
* fix for channel re-use limitation.
* regression fix for properly closing channel cache sockets.

1.1.9 (released 2025-8-06)
-----------------------------------
* removing ssh-dss key type as it was deprecated in paramiko in 4.0.0.
* 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.

1.1.8 (released 2025-6-13)
--------------------------
Expand Down
4 changes: 2 additions & 2 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,9 @@
# built documents.
#
# The short X.Y version.
version = '1.1.9'
version = '1.1.10'
# The full version, including alpha/beta/rc tags.
release = '1.1.9'
release = '1.1.10'

# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
Expand Down
17 changes: 9 additions & 8 deletions docs/cookbook.rst
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ private key or password authentication.
# do stuff here

Config options always take precedence over parameters if both exist. Keep in
mind there will more than likely be a delta between the security option
mind there it's quite likely there will be a delta between the security option
algorithms your verion of SSH supports and those supported by the underlying
paramiko dependency.

Expand Down Expand Up @@ -127,7 +127,7 @@ always attempted unless an alternative is passed. If you wish to disable host
key checking, **NOT ADVISED**, you will need to modify the default CnOpts and
set the knownhosts to None if no such file exists. You can still modify an
existing CnOpts by setting cnopts.hostkeys to None if a default known_hosts
exists or an alternative file was passed when CnOpts was created.
exists or an alternative file was passed when the CnOpts was created.

.. code-block:: python

Expand All @@ -146,7 +146,7 @@ exists or an alternative file was passed when CnOpts was created.
with sftpretty.Connection('host', username='me', password='pass', cnopts=cnopts):
# do stuff here

To use a completely different known_hosts file, you can override CnOpts looking
To use a completely different known_hosts file, you can override CnOpts search
for ``~/.ssh/known_hosts`` by specifying the file when instantiating.

.. code-block:: python
Expand Down Expand Up @@ -201,7 +201,8 @@ Just send the dict into the connection object like so.

import sftpretty

cinfo = {'host':'hostname', 'username':'me', 'password':'secret', 'port':2222}
cinfo = {'host': 'hostname', 'username': 'me',
'password': 'secret', 'port': 2222}
with sftpretty.Connection(**cinfo) as sftp:
#
# ... do sftp operations
Expand All @@ -219,7 +220,7 @@ the modification times on the local copy match those on the server.
# ...
sftp.get('myfile', preserve_mtime=True)

Now with the ability to resume a previously started download. Based on local
Resuming a previously initiated download is supported and based on local
destination path matching.

.. code-block:: python
Expand Down Expand Up @@ -258,16 +259,16 @@ connections from above still applies.
--------------------------------
In addition to the normal paramiko call, you can optionally set the
``preserve_mtime`` parameter to ``True`` and the operation will make sure that
the modification times on the server copy match those on the local.
the modification times on the server copy match those of the local.

.. code-block:: python

# copy myfile, to the current working directory on the server,
# preserving modification time
sftp.put('myfile', preserve_mtime=True)

Now with the ability to resume a prematurely ended upload. Based on remote
destination path matching.
Resume a prematurely ended upload if desired, still based on destination path
matching.

.. code-block:: python

Expand Down
2 changes: 1 addition & 1 deletion docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ paramiko >= 2.7.0

Supports
--------
Tested on Python 3.6, 3.7, 3.8, 3.9, 3.10, 3.11, 3.12
Tested on Python 3.6, 3.7, 3.8, 3.9, 3.10, 3.11, 3.12, 3.13

Contents
--------
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ classifiers = [
'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11',
'Programming Language :: Python :: 3.12',
'Programming Language :: Python :: 3.13',
'Programming Language :: Python :: Implementation :: CPython'
]
dependencies = [
Expand All @@ -46,7 +47,7 @@ keywords = [
name = 'sftpretty'
readme = 'README.rst'
requires-python = '>=3.6'
version = '1.1.9'
version = '1.1.10'

[project.scripts]
sftpretty = 'sftpretty:Connection'
Expand Down
92 changes: 56 additions & 36 deletions sftpretty/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from socket import gaierror
from stat import S_ISDIR, S_ISREG
from tempfile import mkstemp
from threading import get_ident, local as cache
from threading import local as cache
from uuid import uuid4


Expand Down Expand Up @@ -191,7 +191,8 @@ def __init__(self, host, cnopts=None, default_path=None, password=None,
port=22, private_key=None, private_key_pass=None,
timeout=None, username=None):
self._cache = cache()
self._channels = []
self._cache.__dict__.setdefault('cwd', default_path)
self._channels = {}
self._cnopts = cnopts or CnOpts()
self._config = self._cnopts.get_config(host)
self._default_path = default_path
Expand Down Expand Up @@ -294,34 +295,51 @@ def _set_username(self, username):
@contextmanager
def _sftp_channel(self):
'''Establish new SFTP channel.'''
_channel = getattr(self._cache, 'channel', None)
channel = None

try:
if _channel is None or _channel.get_channel().closed:
_channel = SFTPClient.from_transport(self._transport)
channel = _channel.get_channel()
channel_name = uuid4().hex
channel.set_name(channel_name)
channel.settimeout(self._timeout)
log.debug(f'Channel Name: [{channel_name}]')

if self._default_path is not None:
_channel.chdir(drivedrop(self._default_path))
log.info(('Current Working Directory: '
f'[{self._default_path}]'))

self._cache.channel = _channel
self._channels.append(_channel)
log.debug(f'Thread Cached: [{get_ident()}]')
else:
channel = _channel.get_channel()
channel.settimeout(self._timeout)
channel_name, data = next(
(key, value)
for key, value in self._channels.items()
if not value['busy']
)
meta = data['meta']
if not meta.closed:
channel = data['channel']
self._channels[channel_name]['busy'] = True
log.debug(f'Cached Channel: [{channel_name}]')
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:
meta.settimeout(self._timeout)
self._cache.__dict__.setdefault('cwd', self._default_path)

yield _channel
if self._cache.cwd:
try:
channel.chdir(drivedrop(self._cache.cwd))
log.info(f'Current Working Directory: [{self._cache.cwd}]')
except IOError as err:
log.error(f'Failed Directory Change: [{self._cache.cwd}]')
raise err

yield channel
except Exception as err:
_channel.close()
self._cache.channel = None
channel.close()
raise err
finally:
if not meta.closed:
self._channels[channel_name]['busy'] = False

def _start_transport(self, host, port):
'''Start the transport and set connection options if specified.'''
Expand Down Expand Up @@ -653,7 +671,7 @@ def get_r(self, remotedir, localdir, callback=None,
self.chdir(remotedir)

lwd = Path(localdir).absolute().as_posix()
rwd = self._default_path
rwd = self._cache.cwd

tree = {}
tree[rwd] = [(rwd, lwd)]
Expand Down Expand Up @@ -1094,7 +1112,8 @@ def chdir(self, remotepath):
'''
with self._sftp_channel() as channel:
channel.chdir(drivedrop(remotepath))
self._default_path = channel.normalize('.')
self._cache.cwd = drivedrop(channel.normalize('.'))
self._default_path = self._cache.cwd

def chmod(self, remotepath, mode=700):
'''Set the permission mode of a remotepath, where mode is an octal.
Expand Down Expand Up @@ -1137,15 +1156,15 @@ def close(self):
'''Terminate transport connection and clean up the bits.'''
try:
# Close cached channels
for channel in self._channels:
if not channel.closed:
channel.close()
for channel_name, data in self._channels.items():
if not data['channel'].sock.closed:
data['channel'].close()

# Close the transport.
if self._transport and self._transport.is_active():
self._transport.close()

self._cache = cache()
self._channels = []
self._channels = {}
self._transport = None

# Clean up any loggers
Expand Down Expand Up @@ -1247,7 +1266,7 @@ def listdir(self, remotepath='.'):
return directory

def listdir_attr(self, remotepath='.'):
'''Return a non-sorted list of SFTPAttribute objects for the remote
'''Return a sorted list of SFTPAttribute objects for the remote
directory contents. Will not include the special entries '.' and '..'.

The returned SFTPAttributes objects will each have an additional field:
Expand Down Expand Up @@ -1334,7 +1353,7 @@ def normalize(self, remotepath):
with self._sftp_channel() as channel:
expanded_path = channel.normalize(drivedrop(remotepath))

return expanded_path
return drivedrop(expanded_path)

def open(self, remotefile, bufsize=-1, mode='r'):
'''Open a file on the remote server.
Expand Down Expand Up @@ -1364,7 +1383,7 @@ def readlink(self, remotelink):
remotelink = drivedrop(remotelink)
link_destination = channel.normalize(channel.readlink(remotelink))

return link_destination
return drivedrop(link_destination)

def remotetree(self, container, remotedir, localdir, recurse=True):
'''Recursively map remote directory tree to a dictionary container.
Expand Down Expand Up @@ -1518,7 +1537,8 @@ def pwd(self):
:returns: (str) Current working directory.
'''
with self._sftp_channel() as channel:
pwd = channel.normalize('.')
pwd = drivedrop(channel.normalize('.'))
self._default_path = pwd

return pwd

Expand Down
5 changes: 3 additions & 2 deletions sftpretty/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ def _callback(filename, bytes_so_far, bytes_total, logger=None):


def drivedrop(filepath):
if PureWindowsPath(filepath).drive:
filepath = Path('/').joinpath(*Path(filepath).parts[1:]).as_posix()
if filepath:
if PureWindowsPath(filepath).drive:
filepath = Path('/').joinpath(*Path(filepath).parts[1:]).as_posix()

return filepath

Expand Down
1 change: 1 addition & 0 deletions tests/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
def conn(sftpsrv):
'''return a dictionary holding argument info for the sftpretty client'''
cnopts = CnOpts(knownhosts='sftpserver.pub')
cnopts.log_level = 'debug'
return {'cnopts': cnopts, 'default_path': '/home/test',
'host': sftpsrv.host, 'port': sftpsrv.port,
'private_key': 'id_sftpretty', 'private_key_pass': PASS,
Expand Down
8 changes: 4 additions & 4 deletions tests/test_cd.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
import pytest

from common import conn, VFS
from pathlib import Path
from pathlib import PurePosixPath
from sftpretty import Connection


def test_cd_none(sftpserver):
'''test sftpretty.cd with None'''
pubpath = Path('/home/test').joinpath('pub')
pubpath = PurePosixPath('/home/test').joinpath('pub')
with sftpserver.serve_content(VFS):
with Connection(**conn(sftpserver)) as sftp:
home = sftp.pwd
Expand All @@ -21,7 +21,7 @@ def test_cd_none(sftpserver):

def test_cd_path(sftpserver):
'''test sftpretty.cd with a path'''
pubpath = Path('/home/test').joinpath('pub')
pubpath = PurePosixPath('/home/test').joinpath('pub')
with sftpserver.serve_content(VFS):
with Connection(**conn(sftpserver)) as sftp:
home = sftp.pwd
Expand All @@ -32,7 +32,7 @@ def test_cd_path(sftpserver):

def test_cd_nested(sftpserver):
'''test nested cd's'''
pubpath = Path('/home/test').joinpath('pub')
pubpath = PurePosixPath('/home/test').joinpath('pub')
with sftpserver.serve_content(VFS):
with Connection(**conn(sftpserver)) as sftp:
home = sftp.pwd
Expand Down
Loading
Loading