Skip to content

Commit ee69194

Browse files
Merge pull request #72 from byteskeptical/err_care
Err Care
2 parents a6d8e98 + 81ed572 commit ee69194

7 files changed

Lines changed: 130 additions & 44 deletions

File tree

.github/workflows/test.yml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,6 @@ jobs:
1919
python-version: 3.7
2020
- os: ubuntu-latest
2121
python-version: 3.7
22-
- os: windows-latest
23-
python-version: 3.13
24-
- os: windows-latest
25-
python-version: 3.14
2622

2723
steps:
2824
- name: Clone Repository

docs/changes.rst

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
1-
1.1.10 (current, released 2025-10-14)
2-
-----------------------------------
1+
1.2.0 (current, released 2025-12-21)
2+
------------------------------------
3+
* fix for change in pathlib behavior in Python 3.13+ on Windows.
4+
* fix for parsing limitation of .stem causing issues with dots in path names.
5+
6+
1.1.11 (released 2025-10-22)
7+
----------------------------
8+
* improved exception handling in _sftp_channel.
9+
10+
1.1.10 (released 2025-10-14)
11+
----------------------------
312
* fix for channel re-use limitation.
413
* regression fix for properly closing channel cache sockets.
514

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

docs/conf.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,16 +47,16 @@
4747

4848
# General information about the project.
4949
project = u'sftpretty'
50-
copyright = u'2020, byteskeptical'
50+
copyright = u'2025, byteskeptical'
5151

5252
# The version info for the project you're documenting, acts as replacement for
5353
# |version| and |release|, also used in various other places throughout the
5454
# built documents.
5555
#
5656
# The short X.Y version.
57-
version = '1.1.10'
57+
version = '1.2.0'
5858
# The full version, including alpha/beta/rc tags.
59-
release = '1.1.10'
59+
release = '1.2.0'
6060

6161
# The language for content autogenerated by Sphinx. Refer to documentation
6262
# for a list of supported languages.

docs/contributing.rst

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@ Code
1313
#. Fork the repository `sftpretty <https://github.com/byteskeptical/sftpretty>`_
1414
#. Install supporting software packages and sftpretty in --editable mode
1515

16-
a. Make a virtualenv, clone the repos, install the deps from pip install -r requirements-dev.txt
17-
b. Install sftpretty in editable mode, pip install -e .
16+
a. Make a virtualenv, python3 -m venv .sftpretty
17+
b. Clone the repo, git clone https://github.com/`username`/sftpretty
18+
c. Install sftpretty and it's dependencies in editable mode, python3 -m pip install -e .[dev,lint,test]
19+
1820
#. Write any new tests needed and ensure existing tests continue to pass without modification.
1921

20-
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.
21-
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.
22-
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.
22+
a. Setup CI testing for your fork. Currently testing is done on Github Actions but feel free to use the framework of your choosing.
23+
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.
24+
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.
2325

2426
#. 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.
2527
#. Submit a Pull Request to the project.
@@ -40,4 +42,4 @@ This section lists the priority that will be assigned to an issue:
4042

4143
Testing
4244
-------
43-
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.
45+
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.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ keywords = [
4848
name = 'sftpretty'
4949
readme = 'README.rst'
5050
requires-python = '>=3.6'
51-
version = '1.1.10'
51+
version = '1.2.0'
5252

5353
[project.scripts]
5454
sftpretty = 'sftpretty:Connection'

sftpretty/__init__.py

Lines changed: 101 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
from concurrent.futures import as_completed, ThreadPoolExecutor
22
from contextlib import contextmanager
3+
from errno import ECONNRESET, EPIPE, errorcode
34
from functools import partial
45
from logging import (DEBUG, ERROR, FileHandler, Formatter, getLogger, INFO,
56
StreamHandler, WARN)
67
from os import environ, SEEK_END, utime
7-
from paramiko import (Agent, hostkeys, SFTPClient, SSHConfig, Transport,
8-
ConfigParseError, PasswordRequiredException,
9-
SSHException, ECDSAKey, Ed25519Key, RSAKey)
8+
from paramiko import (Agent, ChannelException, ConfigParseError, ECDSAKey,
9+
Ed25519Key, hostkeys, PasswordRequiredException,
10+
SFTPClient, SFTPError, SFTP_FAILURE, SFTP_NO_SUCH_FILE,
11+
SFTP_OP_UNSUPPORTED, SFTP_PERMISSION_DENIED, SSHConfig,
12+
SSHException, RSAKey, Transport)
1013
from pathlib import Path
1114
from sftpretty.exceptions import (CredentialException, ConnectionException,
1215
HostKeysException, LoggingException)
1316
from sftpretty.helpers import _callback, drivedrop, hash, localtree, retry
14-
from socket import gaierror
17+
from socket import gaierror, timeout
1518
from stat import S_ISDIR, S_ISREG
1619
from tempfile import mkstemp
1720
from threading import local as cache
@@ -295,6 +298,7 @@ def _set_username(self, username):
295298
def _sftp_channel(self):
296299
'''Establish new SFTP channel.'''
297300
channel = None
301+
fatal = False
298302

299303
try:
300304
channel_name, data = next(
@@ -310,17 +314,17 @@ def _sftp_channel(self):
310314
except StopIteration:
311315
pass
312316

313-
if channel is None:
314-
channel = SFTPClient.from_transport(self._transport)
315-
channel_name = uuid4().hex
316-
meta = channel.get_channel()
317-
meta.set_name(channel_name)
318-
log.debug(f'Channel Name: [{channel_name}]')
319-
self._channels[channel_name] = {
320-
'busy': True, 'channel': channel, 'meta': meta
321-
}
322-
323317
try:
318+
if channel is None:
319+
channel = SFTPClient.from_transport(self._transport)
320+
channel_name = uuid4().hex
321+
meta = channel.get_channel()
322+
meta.set_name(channel_name)
323+
log.debug(f'Channel Name: [{channel_name}]')
324+
self._channels[channel_name] = {
325+
'busy': True, 'channel': channel, 'meta': meta
326+
}
327+
324328
meta.settimeout(self._timeout)
325329
self._cache.__dict__.setdefault('cwd', self._default_path)
326330

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

333337
yield channel
334-
except IOError as err:
335-
log.error(f'Failed Directory Change: [{self._cache.cwd}]')
338+
except timeout:
339+
fatal = True
340+
_message = (
341+
f'Channel [{channel_name}] operation timed out after '
342+
f'{self._timeout}s while accessing: [{self._cache.cwd}]'
343+
)
344+
log.error(_message)
345+
raise TimeoutError(_message)
346+
except SFTPError as err:
347+
_message_map = {
348+
SFTP_FAILURE: (
349+
'A generic failure occurred on the SFTP server for path: '
350+
f'[{self._cache.cwd}]'
351+
),
352+
SFTP_NO_SUCH_FILE: (
353+
f'Directory or file does not exist: [{self._cache.cwd}]'
354+
),
355+
SFTP_OP_UNSUPPORTED: (
356+
'Operation (e.g., chdir) unsupported by server for path: '
357+
f'[{self._cache.cwd}]'
358+
),
359+
SFTP_PERMISSION_DENIED: (
360+
f'Permission denied for: [{self._cache.cwd}]'
361+
),
362+
}
363+
_message = _message_map.get(
364+
err.errno,
365+
('Unhandled SFTP error on directory change to '
366+
f'[{self._cache.cwd}] (Code {err.errno}): {err}')
367+
)
368+
log.error(_message)
369+
raise err
370+
except ChannelException as err:
371+
fatal = True
372+
log.error(f'Channel [{channel_name}] is invalid or closed: {err}')
373+
raise err
374+
except SSHException as err:
375+
fatal = True
376+
log.error(
377+
(f'Protocol error occurred during channel [{channel_name}] '
378+
f'setup: {err}')
379+
)
380+
raise err
381+
except OSError as err:
382+
fatal = True
383+
_message = (f'Channel [{channel_name}] experienced an OS-level '
384+
f'network error (Code: {err.errno} - '
385+
f'{errorcode.get(err.errno)}): {err}')
386+
387+
if err.errno == ECONNRESET:
388+
_message = (
389+
f'Channel [{channel_name}] connection forcefully reset by '
390+
f'the remote host: {err}'
391+
)
392+
elif err.errno == EPIPE:
393+
_message = (
394+
f'Channel [{channel_name}] connection was broken '
395+
f'(broken pipe): {err}'
396+
)
397+
398+
log.error(_message)
336399
raise err
337400
except Exception as err:
338-
if channel:
339-
channel.close()
401+
err_type = type(err).__name__
402+
fatal = True
403+
log.error(
404+
(f'An unexpected error of type [{err_type}] occurred in '
405+
f'channel [{channel_name}]: {err}')
406+
)
340407
raise err
341408
finally:
342-
if not meta.closed:
409+
if fatal and channel:
410+
channel.close()
411+
log.debug(
412+
(f'Closed compromised channel [{channel_name}] due to '
413+
'fatal error!')
414+
)
415+
self._channels.pop(channel_name, None)
416+
elif channel and not meta.closed:
417+
log.debug(
418+
f'Recycling channel [{channel_name}] back to the pool.'
419+
)
343420
self._channels[channel_name]['busy'] = False
344421

345422
def _start_transport(self, host, port):
@@ -884,7 +961,7 @@ def put_d(self, localdir, remotedir, callback=None, confirm=True,
884961
'''
885962
localdir = Path(localdir)
886963

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

889966
paths = [
890967
(localpath.as_posix(),
@@ -1203,7 +1280,7 @@ def getcwd(self):
12031280
:returns: (str) Remote current working directory. None, if not set.
12041281
'''
12051282
with self._sftp_channel() as channel:
1206-
cwd = channel.getcwd()
1283+
cwd = drivedrop(channel.getcwd())
12071284

12081285
return cwd
12091286

@@ -1332,11 +1409,11 @@ def mkdir_p(self, remotedir, mode=700):
13321409
'already exists.'))
13331410
else:
13341411
parent = Path(remotedir).parent.as_posix()
1335-
stem = Path(remotedir).stem
1412+
stem = Path(remotedir).parts[-1]
13361413
if parent != remotedir:
13371414
if not self.isdir(parent):
13381415
self.mkdir_p(parent, mode=mode)
1339-
if stem:
1416+
if stem and stem != Path(remotedir).root:
13401417
self.mkdir(remotedir, mode=mode)
13411418
except Exception as err:
13421419
raise err
@@ -1410,7 +1487,7 @@ def remotetree(self, container, remotedir, localdir, recurse=True):
14101487
remote = Path(remotedir).joinpath(
14111488
attribute.filename).as_posix()
14121489
local = Path(localdir).joinpath(
1413-
Path(remote).stem).as_posix()
1490+
Path(remote).parts[-1]).as_posix()
14141491
if remotedir in container.keys():
14151492
container[remotedir].append((remote, local))
14161493
else:

sftpretty/helpers.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from functools import wraps
22
from hashlib import new, sha3_512
33
from io import BytesIO, IOBase
4-
from pathlib import Path, PureWindowsPath
4+
from pathlib import Path, PurePosixPath, PureWindowsPath
55
from stat import S_IMODE
66
from time import sleep
77

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

2425
return filepath
2526

@@ -88,7 +89,8 @@ def localtree(container, localdir, remotedir, recurse=True):
8889
for localpath in Path(localdir).iterdir():
8990
if localpath.is_dir():
9091
local = localpath.as_posix()
91-
remote = Path(remotedir).joinpath(localpath.stem).as_posix()
92+
remote = Path(remotedir).joinpath(localpath.relative_to(
93+
localdir).as_posix()).as_posix()
9294
if localdir.as_posix() in container.keys():
9395
container[localdir.as_posix()].append((local, remote))
9496
else:

0 commit comments

Comments
 (0)