diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index daace509..c3d3f1ef 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/docs/changes.rst b/docs/changes.rst index 77ea3f1d..3073d4ed 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -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. diff --git a/docs/conf.py b/docs/conf.py index b3fee7c5..1ec0b7fd 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -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. diff --git a/docs/contributing.rst b/docs/contributing.rst index 677d5ec1..c805c1fb 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -13,13 +13,15 @@ Code #. Fork the repository `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 (url), where the (url) portion is optional. #. Submit a Pull Request to the project. @@ -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. diff --git a/pyproject.toml b/pyproject.toml index 6a158c49..18381556 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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' diff --git a/sftpretty/__init__.py b/sftpretty/__init__.py index 020ea528..5b313c32 100644 --- a/sftpretty/__init__.py +++ b/sftpretty/__init__.py @@ -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 @@ -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( @@ -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) @@ -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): @@ -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(), @@ -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 @@ -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 @@ -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: diff --git a/sftpretty/helpers.py b/sftpretty/helpers.py index 1487ba88..4ed65795 100644 --- a/sftpretty/helpers.py +++ b/sftpretty/helpers.py @@ -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 @@ -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 @@ -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: