Hello community,

here is the log from the commit of package python-asyncssh for openSUSE:Factory 
checked in at 2020-07-10 14:13:14
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-asyncssh (Old)
 and      /work/SRC/openSUSE:Factory/.python-asyncssh.new.3060 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-asyncssh"

Fri Jul 10 14:13:14 2020 rev:12 rq:819833 version:2.2.1

Changes:
--------
--- /work/SRC/openSUSE:Factory/python-asyncssh/python-asyncssh.changes  
2020-03-03 10:20:27.615174225 +0100
+++ 
/work/SRC/openSUSE:Factory/.python-asyncssh.new.3060/python-asyncssh.changes    
    2020-07-10 14:13:19.499616871 +0200
@@ -1,0 +2,16 @@
+Thu Jul  9 22:36:54 UTC 2020 - Ondřej Súkup <[email protected]>
+
+- update to 2.2.1
+ * Added optional timeout parameter to SSHClientProcess.wait()
+    and SSHClientConnection.run() methods.
+ * Created subclasses for SFTPError exceptions, allowing applications
+    to more easily have distinct exception handling for different errors.
+ * Fixed an issue in SFTP parallel I/O related to handling low-level
+    connection failures
+ * Fixed an issue with SFTP file copy where a local file could sometimes
+    be left open if an attempt to close a remote file failed.
+ * Fixed an issue in the handling of boolean return values when
+    SSHServer.server_requested() returns a coroutine
+ * Fixed an issue with passing tuples to the SFTP copy functions.
+
+-------------------------------------------------------------------

Old:
----
  asyncssh-2.2.0.tar.gz

New:
----
  asyncssh-2.2.1.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-asyncssh.spec ++++++
--- /var/tmp/diff_new_pack.S2Y6Wi/_old  2020-07-10 14:13:20.379619761 +0200
+++ /var/tmp/diff_new_pack.S2Y6Wi/_new  2020-07-10 14:13:20.383619774 +0200
@@ -19,7 +19,7 @@
 %{?!python_module:%define python_module() python-%{**} python3-%{**}}
 %define skip_python2 1
 Name:           python-asyncssh
-Version:        2.2.0
+Version:        2.2.1
 Release:        0
 Summary:        Asynchronous SSHv2 client and server library
 License:        EPL-2.0 OR GPL-2.0-or-later

++++++ asyncssh-2.2.0.tar.gz -> asyncssh-2.2.1.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asyncssh-2.2.0/PKG-INFO new/asyncssh-2.2.1/PKG-INFO
--- old/asyncssh-2.2.0/PKG-INFO 2020-03-01 00:59:29.000000000 +0100
+++ new/asyncssh-2.2.1/PKG-INFO 2020-04-18 19:20:47.000000000 +0200
@@ -1,6 +1,6 @@
 Metadata-Version: 2.1
 Name: asyncssh
-Version: 2.2.0
+Version: 2.2.1
 Summary: AsyncSSH: Asynchronous SSHv2 client and server library
 Home-page: http://asyncssh.timeheart.net
 Author: Ron Frederick
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asyncssh-2.2.0/asyncssh/__init__.py 
new/asyncssh-2.2.1/asyncssh/__init__.py
--- old/asyncssh-2.2.0/asyncssh/__init__.py     2020-03-01 00:16:54.000000000 
+0100
+++ new/asyncssh-2.2.1/asyncssh/__init__.py     2020-04-18 18:36:42.000000000 
+0200
@@ -65,6 +65,7 @@
 
 from .process import SSHClientProcess, SSHServerProcess
 from .process import SSHCompletedProcess, ProcessError
+from .process import TimeoutError # pylint: disable=redefined-builtin
 from .process import DEVNULL, PIPE, STDOUT
 
 from .public_key import SSHKey, SSHKeyPair, SSHCertificate
@@ -85,6 +86,9 @@
 from .server import SSHServer
 
 from .sftp import SFTPClient, SFTPClientFile, SFTPServer, SFTPError
+from .sftp import SFTPEOFError, SFTPNoSuchFile, SFTPPermissionDenied
+from .sftp import SFTPFailure, SFTPBadMessage, SFTPNoConnection
+from .sftp import SFTPConnectionLost, SFTPOpUnsupported
 from .sftp import SFTPAttrs, SFTPVFSAttrs, SFTPName
 from .sftp import SEEK_SET, SEEK_CUR, SEEK_END
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asyncssh-2.2.0/asyncssh/connection.py 
new/asyncssh-2.2.1/asyncssh/connection.py
--- old/asyncssh-2.2.0/asyncssh/connection.py   2020-03-01 00:16:54.000000000 
+0100
+++ new/asyncssh-2.2.1/asyncssh/connection.py   2020-04-18 18:36:42.000000000 
+0200
@@ -3114,7 +3114,7 @@
         return transport, transport.get_protocol()
     # pylint: enable=redefined-builtin
 
-    async def run(self, *args, check=False, **kwargs):
+    async def run(self, *args, check=False, timeout=None, **kwargs):
         """Run a command on the remote system and collect its output
 
            This method is a coroutine wrapper around :meth:`create_process`
@@ -3133,21 +3133,30 @@
            In addition to the argument below, all arguments to
            :meth:`create_process` are supported and have the same meaning.
 
+           If a timeout is specified and it expires before the process
+           exits, the :exc:`TimeoutError` exception will be raised. By
+           default, no timeout is set and this call will wait indefinitely.
+
            :param check: (optional)
                Whether or not to raise :exc:`ProcessError` when a non-zero
                exit status is returned
+           :param timeout:
+               Amount of time in seconds to wait for process to exit or
+               `None` to wait indefinitely
            :type check: `bool`
+           :type timeout: `int`, `float`, or `None`
 
            :returns: :class:`SSHCompletedProcess`
 
            :raises: | :exc:`ChannelOpenError` if the session can't be opened
                     | :exc:`ProcessError` if checking non-zero exit status
+                    | :exc:`TimeoutError` if the timeout expires before exit
 
         """
 
         process = await self.create_process(*args, **kwargs)
 
-        return await process.wait(check)
+        return await process.wait(check, timeout)
 
     async def create_connection(self, session_factory, remote_host, 
remote_port,
                                 orig_host='', orig_port=0, *, encoding=None,
@@ -4343,17 +4352,6 @@
 
         result = self._owner.server_requested(listen_host, listen_port)
 
-        if not result:
-            self.logger.info('Request for TCP listener on %s denied by '
-                             'application', (listen_host, listen_port))
-
-            self._report_global_response(False)
-            return
-
-        if result is True:
-            result = self.forward_local_port(listen_host, listen_port,
-                                             listen_host, listen_port)
-
         self.create_task(self._finish_port_forward(result, listen_host,
                                                    listen_port))
 
@@ -4364,21 +4362,33 @@
             if inspect.isawaitable(listener):
                 listener = await listener
 
-            if listen_port == 0:
-                listen_port = listener.get_port()
-                result = UInt32(listen_port)
-            else:
-                result = True
-
-            self._local_listeners[listen_host, listen_port] = listener
-
-            self.logger.info('Created TCP listener on %s',
-                             (listen_host, listen_port))
-
-            self._report_global_response(result)
+            if listener is True:
+                listener = await self.forward_local_port(
+                    listen_host, listen_port, listen_host, listen_port)
         except OSError:
             self.logger.debug1('Failed to create TCP listener')
             self._report_global_response(False)
+            return
+
+        if not listener:
+            self.logger.info('Request for TCP listener on %s denied by '
+                             'application', (listen_host, listen_port))
+
+            self._report_global_response(False)
+            return
+
+        if listen_port == 0:
+            listen_port = listener.get_port()
+            result = UInt32(listen_port)
+        else:
+            result = True
+
+        self._local_listeners[listen_host, listen_port] = listener
+
+        self.logger.info('Created TCP listener on %s',
+                         (listen_host, listen_port))
+
+        self._report_global_response(result)
 
     def _process_cancel_tcpip_forward_global_request(self, packet):
         """Process a request to cancel TCP/IP port forwarding"""
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asyncssh-2.2.0/asyncssh/misc.py 
new/asyncssh-2.2.1/asyncssh/misc.py
--- old/asyncssh-2.2.0/asyncssh/misc.py 2020-03-01 00:16:54.000000000 +0100
+++ new/asyncssh-2.2.1/asyncssh/misc.py 2020-04-18 18:36:42.000000000 +0200
@@ -622,7 +622,7 @@
 
 
 def construct_disc_error(code, reason, lang):
-    """Map discussion error code to appropriate DisconnectError exception"""
+    """Map disconnect error code to appropriate DisconnectError exception"""
 
     try:
         return _disc_error_map[code](reason, lang)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asyncssh-2.2.0/asyncssh/process.py 
new/asyncssh-2.2.1/asyncssh/process.py
--- old/asyncssh-2.2.0/asyncssh/process.py      2019-11-30 19:27:24.000000000 
+0100
+++ new/asyncssh-2.2.1/asyncssh/process.py      2020-04-18 18:36:42.000000000 
+0200
@@ -454,7 +454,7 @@
                       process to execute (if any)
          subsystem    The subsystem the client requested the  `str` or `None`
                       process to open (if any)
-         exit_status  The exit status returned, or -1 if an   `int`
+         exit_status  The exit status returned, or -1 if an   `int` or `None`
                       exit signal is sent
          exit_signal  The exit signal sent (if any) in the    `tuple` or `None`
                       form of a tuple containing the signal
@@ -462,7 +462,7 @@
                       occurred, a message associated with the
                       signal, and the language the message
                       was in
-         returncode   The exit status returned, or negative   `int`
+         returncode   The exit status returned, or negative   `int` or `None`
                       of the signal number when an exit
                       signal is sent
          stdout       The output sent by the process to       `str` or `bytes`
@@ -474,7 +474,8 @@
     """
 
     def __init__(self, env, command, subsystem, exit_status,
-                 exit_signal, returncode, stdout, stderr):
+                 exit_signal, returncode, stdout, stderr,
+                 reason='', lang=DEFAULT_LANG):
         self.env = env
         self.command = command
         self.subsystem = subsystem
@@ -489,14 +490,30 @@
             reason = 'Process exited with signal %s%s%s' % \
                 (signal, ': ' + msg if msg else '',
                  ' (core dumped)' if core_dumped else '')
-        else:
+        elif exit_status:
             reason = 'Process exited with non-zero exit status %s' % \
                 exit_status
-            lang = DEFAULT_LANG
 
         super().__init__(exit_status, reason, lang)
 
 
+# pylint: disable=redefined-builtin
+class TimeoutError(ProcessError, asyncio.TimeoutError):
+    """SSH Process timeout error
+
+       This exception is raised when a timeout occurs when calling the
+       :meth:`wait <SSHClientProcess.wait>` method on :class:`SSHClientProcess`
+       or the :meth:`run <SSHClientConnection.run>` method on
+       :class:`SSHClientConnection`. It is a subclass of :class:`ProcessError`
+       and contains all of the fields documented there, including any output
+       received on stdout and stderr prior to when the timeout occurred. It
+       is also a subclass of :class:`asyncio.TimeoutError`, for code that
+       might be expecting that.
+
+    """
+# pylint: enable=redefined-builtin
+
+
 class SSHCompletedProcess(Record):
     """Results from running an SSH process
 
@@ -1168,7 +1185,7 @@
 
         self._chan.kill()
 
-    async def wait(self, check=False):
+    async def wait(self, check=False, timeout=None):
         """Wait for process to exit
 
            This method is a coroutine which waits for the process to
@@ -1180,18 +1197,37 @@
            status from the process with trigger the :exc:`ProcessError`
            exception to be raised.
 
+           If a timeout is specified and it expires before the process
+           exits, the :exc:`TimeoutError` exception will be raised. By
+           default, no timeout is set and this call will wait indefinitely.
+
            :param check:
                Whether or not to raise an error on non-zero exit status
+           :param timeout:
+               Amount of time in seconds to wait for process to exit, or
+               `None` to wait indefinitely
            :type check: `bool`
+           :type timeout: `int`, `float`, or `None`
 
            :returns: :class:`SSHCompletedProcess`
 
-           :raises: :exc:`ProcessError` if check is set to `True`
-                    and the process returns a non-zero exit status
+           :raises: | :exc:`ProcessError` if check is set to `True`
+                      and the process returns a non-zero exit status
+                    | :exc:`TimeoutError` if the timeout expires
+                      before the process exits
 
         """
 
-        stdout_data, stderr_data = await self.communicate()
+        try:
+            stdout_data, stderr_data = \
+                await asyncio.wait_for(self.communicate(), timeout)
+        except asyncio.TimeoutError:
+            stdout_data, stderr_data = self.collect_output()
+
+            raise TimeoutError(self.env, self.command, self.subsystem,
+                               self.exit_status, self.exit_signal,
+                               self.returncode, stdout_data,
+                               stderr_data) from None
 
         if check and self.exit_status:
             raise ProcessError(self.env, self.command, self.subsystem,
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asyncssh-2.2.0/asyncssh/scp.py 
new/asyncssh-2.2.1/asyncssh/scp.py
--- old/asyncssh-2.2.0/asyncssh/scp.py  2020-03-01 00:16:54.000000000 +0100
+++ new/asyncssh-2.2.1/asyncssh/scp.py  2020-04-18 18:36:42.000000000 +0200
@@ -31,12 +31,34 @@
 import sys
 
 from .constants import DEFAULT_LANG
-from .constants import FX_BAD_MESSAGE, FX_CONNECTION_LOST, FX_FAILURE
 
 from .misc import plural
 
 from .sftp import LocalFile, match_glob
-from .sftp import SFTP_BLOCK_SIZE, SFTPAttrs, SFTPError, SFTPServerFile
+from .sftp import SFTP_BLOCK_SIZE, SFTPAttrs, SFTPServerFile
+from .sftp import SFTPError, SFTPFailure, SFTPBadMessage, SFTPConnectionLost
+
+
+def _scp_error(exc_class, reason, path=None, fatal=False,
+               suppress_send=False, lang=DEFAULT_LANG):
+    """Construct SCP version of SFTPError exception"""
+
+    if isinstance(reason, bytes):
+        reason = reason.decode('utf-8', errors='replace')
+
+    if isinstance(path, bytes):
+        path = path.decode('utf-8', errors='replace')
+
+    if path:
+        reason = reason + ': ' + path
+
+    exc = exc_class(reason, lang)
+
+    # pylint: disable=attribute-defined-outside-init
+    exc.fatal = fatal
+    exc.suppress_send = suppress_send
+
+    return exc
 
 
 def _parse_cd_args(args):
@@ -46,7 +68,8 @@
         permissions, size, name = args.split(None, 2)
         return int(permissions, 8), int(size), name
     except ValueError:
-        raise SCPError(FX_BAD_MESSAGE, 'Invalid copy or dir request') from None
+        raise _scp_error(SFTPBadMessage,
+                         'Invalid copy or dir request') from None
 
 
 def _parse_t_args(args):
@@ -56,7 +79,7 @@
         mtime, _, atime, _ = args.split()
         return int(atime), int(mtime)
     except ValueError:
-        raise SCPError(FX_BAD_MESSAGE, 'Invalid time request') from None
+        raise _scp_error(SFTPBadMessage, 'Invalid time request') from None
 
 
 async def _parse_path(path, **kwargs):
@@ -115,25 +138,6 @@
     return reader, writer
 
 
-class SCPError(SFTPError):
-    """SCP error"""
-
-    def __init__(self, code, reason, path=None, fatal=False,
-                 suppress_send=False, lang=DEFAULT_LANG):
-        if isinstance(reason, bytes):
-            reason = reason.decode('utf-8', errors='replace')
-
-        if isinstance(path, bytes):
-            path = path.decode('utf-8', errors='replace')
-
-        if path:
-            reason = reason + ': ' + path
-
-        super().__init__(code, reason, lang)
-        self.fatal = fatal
-        self.suppress_send = suppress_send
-
-
 class _SCPArgParser(argparse.ArgumentParser):
     """A parser for SCP arguments"""
 
@@ -185,14 +189,14 @@
             reason = await self._reader.readline()
 
             if not result or not reason.endswith(b'\n'):
-                raise SCPError(FX_CONNECTION_LOST, 'Connection lost',
-                               fatal=True, suppress_send=True)
+                raise _scp_error(SFTPConnectionLost, 'Connection lost',
+                                 fatal=True, suppress_send=True)
 
             if result not in b'\x01\x02':
                 reason = result + reason
 
-            return SCPError(FX_FAILURE, reason[:-1], fatal=result != b'\x01',
-                            suppress_send=True)
+            return _scp_error(SFTPFailure, reason[:-1], fatal=result != 
b'\x01',
+                              suppress_send=True)
 
         self.logger.debug1('Received SCP OK')
 
@@ -288,8 +292,8 @@
         """Handle an SCP error"""
 
         if isinstance(exc, BrokenPipeError):
-            exc = SCPError(FX_CONNECTION_LOST, 'Connection lost',
-                           fatal=True, suppress_send=True)
+            exc = _scp_error(SFTPConnectionLost, 'Connection lost',
+                             fatal=True, suppress_send=True)
 
         if not getattr(exc, 'suppress_send', False):
             self.send_error(exc)
@@ -362,7 +366,7 @@
                         data = await file_obj.read(blocklen, offset)
 
                         if not data:
-                            raise SCPError(FX_FAILURE, 'Unexpected EOF')
+                            raise _scp_error(SFTPFailure, 'Unexpected EOF')
                     except (OSError, SFTPError) as exc:
                         local_exc = exc
 
@@ -418,7 +422,7 @@
             elif stat.S_ISREG(attrs.permissions):
                 await self._send_file(srcpath, dstpath, attrs)
             else:
-                raise SCPError(FX_FAILURE, 'Not a regular file', srcpath)
+                raise _scp_error(SFTPFailure, 'Not a regular file', srcpath)
         except (OSError, SFTPError, ValueError) as exc:
             self.handle_error(exc)
 
@@ -474,8 +478,8 @@
                 data = await self.recv_data(blocklen)
 
                 if not data:
-                    raise SCPError(FX_CONNECTION_LOST, 'Connection lost',
-                                   fatal=True, suppress_send=True)
+                    raise _scp_error(SFTPConnectionLost, 'Connection lost',
+                                     fatal=True, suppress_send=True)
 
                 if not local_exc:
                     try:
@@ -507,13 +511,14 @@
         """Receive a directory over SCP"""
 
         if not self._recurse:
-            raise SCPError(FX_BAD_MESSAGE, 'Directory received without 
recurse')
+            raise _scp_error(SFTPBadMessage,
+                             'Directory received without recurse')
 
         self.logger.info('  Starting receive of directory %s', dstpath)
 
         if await self._fs.exists(dstpath):
             if not await self._fs.isdir(dstpath):
-                raise SCPError(FX_FAILURE, 'Not a directory', dstpath)
+                raise _scp_error(SFTPFailure, 'Not a directory', dstpath)
         else:
             await self._fs.mkdir(dstpath)
 
@@ -536,8 +541,8 @@
 
             try:
                 if action in b'\x01\x02':
-                    raise SCPError(FX_FAILURE, args, fatal=action != b'\x01',
-                                   suppress_send=True)
+                    raise _scp_error(SFTPFailure, args, fatal=action != 
b'\x01',
+                                     suppress_send=True)
                 elif action == b'T':
                     if self._preserve:
                         attrs.atime, attrs.mtime = _parse_t_args(args)
@@ -569,7 +574,7 @@
                     finally:
                         attrs = SFTPAttrs()
                 else:
-                    raise SCPError(FX_BAD_MESSAGE, 'Unknown request')
+                    raise _scp_error(SFTPBadMessage, 'Unknown request')
             except (OSError, SFTPError) as exc:
                 self.handle_error(exc)
 
@@ -581,8 +586,8 @@
                 dstpath = dstpath.encode('utf-8')
 
             if self._must_be_dir and not await self._fs.isdir(dstpath):
-                self.handle_error(SCPError(FX_FAILURE, 'Not a directory',
-                                           dstpath))
+                self.handle_error(_scp_error(SFTPFailure, 'Not a directory',
+                                             dstpath))
             else:
                 await self._recv_files(b'', dstpath)
         except (OSError, SFTPError, ValueError) as exc:
@@ -614,7 +619,7 @@
         """Handle an SCP error"""
 
         if isinstance(exc, BrokenPipeError):
-            exc = SCPError(FX_CONNECTION_LOST, 'Connection lost', fatal=True)
+            exc = _scp_error(SFTPConnectionLost, 'Connection lost', fatal=True)
 
         self.logger.debug1('Handling SCP error: %s', exc)
 
@@ -652,8 +657,8 @@
             data = await self._source.recv_data(blocklen)
 
             if not data:
-                raise SCPError(FX_CONNECTION_LOST, 'Connection lost',
-                               fatal=True)
+                raise _scp_error(SFTPConnectionLost, 'Connection lost',
+                                 fatal=True)
 
             await self._sink.send_data(data)
             offset += len(data)
@@ -690,7 +695,7 @@
             self._sink.send_request(action, args)
 
             if action in b'\x01\x02':
-                exc = SCPError(FX_FAILURE, args, fatal=action != b'\x01')
+                exc = _scp_error(SFTPFailure, args, fatal=action != b'\x01')
                 self._handle_error(exc)
                 continue
 
@@ -729,7 +734,7 @@
             elif action == b'T':
                 attrs.atime, attrs.mtime = _parse_t_args(args)
             else:
-                raise SCPError(FX_BAD_MESSAGE, 'Unknown SCP action')
+                raise _scp_error(SFTPBadMessage, 'Unknown SCP action')
 
     async def run(self):
         """Start SCP remote-to-remote transfer"""
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asyncssh-2.2.0/asyncssh/server.py 
new/asyncssh-2.2.1/asyncssh/server.py
--- old/asyncssh-2.2.0/asyncssh/server.py       2019-10-27 01:29:42.000000000 
+0200
+++ new/asyncssh-2.2.1/asyncssh/server.py       2020-04-18 18:36:42.000000000 
+0200
@@ -783,11 +783,10 @@
 
            :returns: One of the following:
 
-                     * An :class:`SSHListener` object or a coroutine
-                       which returns an :class:`SSHListener` or `False`
-                       if the listener can't be opened
+                     * An :class:`SSHListener` object
                      * `True` to set up standard port forwarding
                      * `False` to reject the request
+                     * A coroutine object which returns one of the above
 
         """
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asyncssh-2.2.0/asyncssh/sftp.py 
new/asyncssh-2.2.1/asyncssh/sftp.py
--- old/asyncssh-2.2.0/asyncssh/sftp.py 2019-11-30 19:27:24.000000000 +0100
+++ new/asyncssh-2.2.1/asyncssh/sftp.py 2020-04-18 18:36:42.000000000 +0200
@@ -139,7 +139,7 @@
         try:
             os.chown(path, attrs.uid, attrs.gid)
         except AttributeError: # pragma: no cover
-            raise NotImplementedError
+            raise NotImplementedError from None
 
     if attrs.permissions is not None:
         os.chmod(path, stat.S_IMODE(attrs.permissions))
@@ -230,7 +230,7 @@
             await _glob(fs, basedir, patlist, names)
 
             if not names:
-                raise SFTPError(FX_NO_SUCH_FILE, 'No matches found')
+                raise SFTPNoSuchFile('No matches found')
         else:
             await fs.stat(pattern)
             names.append(pattern)
@@ -241,7 +241,7 @@
         if error_handler:
             error_handler(exc)
         else:
-            raise exc
+            raise
 
     return names
 
@@ -433,7 +433,7 @@
                 for task in done:
                     exc = task.exception()
 
-                    if exc and exc.code != FX_EOF:
+                    if exc and not isinstance(exc, SFTPEOFError):
                         exceptions.append(exc)
 
                 if exceptions:
@@ -541,7 +541,7 @@
             data = await self._src.read(size, offset)
 
             if not data:
-                exc = SFTPError(FX_FAILURE, 'Unexpected EOF during file copy')
+                exc = SFTPFailure('Unexpected EOF during file copy')
 
                 # pylint: disable=attribute-defined-outside-init
                 exc.filename = self._srcpath
@@ -564,11 +564,12 @@
     async def cleanup(self):
         """Clean up parallel copy"""
 
-        if self._src: # pragma: no branch
-            await self._src.close()
-
-        if self._dst: # pragma: no branch
-            await self._dst.close()
+        try:
+            if self._src: # pragma: no branch
+                await self._src.close()
+        finally:
+            if self._dst: # pragma: no branch
+                await self._dst.close()
 
 
 class SFTPError(Error):
@@ -583,7 +584,7 @@
            codes <DisconnectReasons>`
        :param reason:
            A human-readable reason for the disconnect
-       :param lang:
+       :param lang: (optional)
            The language the reason is in
        :type code: `int`
        :type reason: `str`
@@ -592,6 +593,177 @@
     """
 
 
+class SFTPEOFError(SFTPError):
+    """SFTP EOF error
+
+       This exception is raised when end of file is reached when
+       reading a file or directory.
+
+       :param reason: (optional)
+           Details about the EOF
+       :param lang: (optional)
+           The language the reason is in
+       :type reason: `str`
+       :type lang: `str`
+
+    """
+
+    def __init__(self, reason='', lang=DEFAULT_LANG):
+        super().__init__(FX_EOF, reason, lang)
+
+
+class SFTPNoSuchFile(SFTPError):
+    """SFTP no such file
+
+       This exception is raised when the requested file is not found.
+
+       :param reason:
+           Details about the missing file
+       :param lang: (optional)
+           The language the reason is in
+       :type reason: `str`
+       :type lang: `str`
+
+    """
+
+    def __init__(self, reason, lang=DEFAULT_LANG):
+        super().__init__(FX_NO_SUCH_FILE, reason, lang)
+
+
+class SFTPPermissionDenied(SFTPError):
+    """SFTP permission denied
+
+       This exception is raised when the permissions are not available
+       to perform the requested operation.
+
+       :param reason:
+           Details about the invalid permissions
+       :param lang: (optional)
+           The language the reason is in
+       :type reason: `str`
+       :type lang: `str`
+
+    """
+
+    def __init__(self, reason, lang=DEFAULT_LANG):
+        super().__init__(FX_PERMISSION_DENIED, reason, lang)
+
+
+class SFTPFailure(SFTPError):
+    """SFTP failure
+
+       This exception is raised when an unexpected SFTP failure occurs.
+
+       :param reason:
+           Details about the failure
+       :param lang: (optional)
+           The language the reason is in
+       :type reason: `str`
+       :type lang: `str`
+
+    """
+
+    def __init__(self, reason, lang=DEFAULT_LANG):
+        super().__init__(FX_FAILURE, reason, lang)
+
+
+class SFTPBadMessage(SFTPError):
+    """SFTP bad message
+
+       This exception is raised when an invalid SFTP message is
+       received.
+
+       :param reason:
+           Details about the invalid message
+       :param lang: (optional)
+           The language the reason is in
+       :type reason: `str`
+       :type lang: `str`
+
+    """
+
+    def __init__(self, reason, lang=DEFAULT_LANG):
+        super().__init__(FX_BAD_MESSAGE, reason, lang)
+
+
+class SFTPNoConnection(SFTPError):
+    """SFTP no connection
+
+       This exception is raised when an SFTP request is made on a
+       closed SSH connection.
+
+       :param reason:
+           Details about the closed connection
+       :param lang: (optional)
+           The language the reason is in
+       :type reason: `str`
+       :type lang: `str`
+
+    """
+
+    def __init__(self, reason, lang=DEFAULT_LANG):
+        super().__init__(FX_NO_CONNECTION, reason, lang)
+
+
+class SFTPConnectionLost(SFTPError):
+    """SFTP connection lost
+
+       This exception is raised when the SSH connection is lost or
+       closed while making an SFTP request.
+
+       :param reason:
+           Details about the connection failure
+       :param lang: (optional)
+           The language the reason is in
+       :type reason: `str`
+       :type lang: `str`
+
+    """
+
+    def __init__(self, reason, lang=DEFAULT_LANG):
+        super().__init__(FX_CONNECTION_LOST, reason, lang)
+
+
+class SFTPOpUnsupported(SFTPError):
+    """SFTP operation unsupported
+
+       This exception is raised when the requested SFTP operation
+       is not supported.
+
+       :param reason:
+           Details about the unsupported operation
+       :param lang: (optional)
+           The language the reason is in
+       :type reason: `str`
+       :type lang: `str`
+
+    """
+
+    def __init__(self, reason, lang=DEFAULT_LANG):
+        super().__init__(FX_OP_UNSUPPORTED, reason, lang)
+
+
+_sftp_error_map = {
+    FX_EOF: SFTPEOFError,
+    FX_NO_SUCH_FILE: SFTPNoSuchFile,
+    FX_PERMISSION_DENIED: SFTPPermissionDenied,
+    FX_FAILURE: SFTPFailure,
+    FX_BAD_MESSAGE: SFTPBadMessage,
+    FX_NO_CONNECTION: SFTPNoConnection,
+    FX_CONNECTION_LOST: SFTPConnectionLost,
+    FX_OP_UNSUPPORTED: SFTPOpUnsupported
+}
+
+
+def _construct_sftp_error(code, reason, lang):
+    """Map SFTP error code to appropriate SFTPError exception"""
+
+    try:
+        return _sftp_error_map[code](reason, lang)
+    except KeyError:
+        return SFTPError(code, '%s (error %d)' % (reason, code), lang)
+
+
 class SFTPAttrs(Record):
     """SFTP file attributes
 
@@ -681,7 +853,7 @@
         attrs = cls()
 
         if flags & FILEXFER_ATTR_UNDEFINED:
-            raise SFTPError(FX_BAD_MESSAGE, 'Unsupported attribute flags')
+            raise SFTPBadMessage('Unsupported attribute flags')
 
         if flags & FILEXFER_ATTR_SIZE:
             attrs.size = packet.get_uint64()
@@ -901,7 +1073,7 @@
         try:
             self._writer.write(UInt32(len(payload)) + payload)
         except ConnectionError as exc:
-            raise SFTPError(FX_CONNECTION_LOST, str(exc)) from None
+            raise SFTPConnectionLost(str(exc)) from None
 
         self.log_sent_packet(pkttype, pktid, payload)
 
@@ -928,7 +1100,7 @@
 
                 await self._process_packet(pkttype, pktid, packet)
         except PacketDecodeError as exc:
-            await self._cleanup(SFTPError(FX_BAD_MESSAGE, str(exc)))
+            await self._cleanup(SFTPBadMessage(str(exc)))
         except EOFError:
             await self._cleanup(None)
         except (OSError, Error) as exc:
@@ -957,7 +1129,7 @@
     async def _cleanup(self, exc):
         """Clean up this SFTP client session"""
 
-        req_exc = exc or SFTPError(FX_CONNECTION_LOST, 'Connection closed')
+        req_exc = exc or SFTPConnectionLost('Connection closed')
 
         for waiter in self._requests.values():
             if not waiter.cancelled(): # pragma: no branch
@@ -975,8 +1147,7 @@
         try:
             waiter = self._requests.pop(pktid)
         except KeyError:
-            await self._cleanup(SFTPError(FX_BAD_MESSAGE,
-                                          'Invalid response id'))
+            await self._cleanup(SFTPBadMessage('Invalid response id'))
         else:
             if not waiter.cancelled(): # pragma: no branch
                 waiter.set_result((pkttype, packet))
@@ -985,7 +1156,7 @@
         """Send an SFTP request"""
 
         if not self._writer:
-            raise SFTPError(FX_NO_CONNECTION, 'Connection not open')
+            raise SFTPNoConnection('Connection not open')
 
         pktid = self._next_pktid
         self._next_pktid = (self._next_pktid + 1) & 0xffffffff
@@ -1010,15 +1181,14 @@
         return_type = self._return_types.get(pkttype)
 
         if resptype not in (FXP_STATUS, return_type):
-            raise SFTPError(FX_BAD_MESSAGE,
-                            'Unexpected response type: %s' % resptype)
+            raise SFTPBadMessage('Unexpected response type: %s' % resptype)
 
         result = self._packet_handlers[resptype](self, resp)
 
         if result is not None or return_type is None:
             return result
         else:
-            raise SFTPError(FX_BAD_MESSAGE, 'Unexpected FX_OK response')
+            raise SFTPBadMessage('Unexpected FX_OK response')
 
     def _process_status(self, packet):
         """Process an incoming SFTP status response"""
@@ -1030,8 +1200,7 @@
                 reason = packet.get_string().decode('utf-8')
                 lang = packet.get_string().decode('ascii')
             except UnicodeDecodeError:
-                raise SFTPError(FX_BAD_MESSAGE,
-                                'Invalid status message') from None
+                raise SFTPBadMessage('Invalid status message') from None
         else:
             # Some servers may not always send reason and lang (usually
             # when responding with FX_OK). Tolerate this, automatically
@@ -1046,7 +1215,7 @@
             self.logger.debug1('Received OK')
             return None
         else:
-            raise SFTPError(code, reason, lang)
+            raise _construct_sftp_error(code, reason, lang)
 
     def _process_handle(self, packet):
         """Process an incoming SFTP handle response"""
@@ -1131,13 +1300,12 @@
             self.log_received_packet(resptype, None, resp)
 
             if resptype != FXP_VERSION:
-                raise SFTPError(FX_BAD_MESSAGE, 'Expected version message')
+                raise SFTPBadMessage('Expected version message')
 
             version = resp.get_uint32()
 
             if version != _SFTP_VERSION:
-                raise SFTPError(FX_BAD_MESSAGE,
-                                'Unsupported version: %d' % version)
+                raise SFTPBadMessage('Unsupported version: %d' % version)
 
             self._version = version
 
@@ -1148,9 +1316,9 @@
                 data = resp.get_string()
                 extensions.append((name, data))
         except PacketDecodeError as exc:
-            raise SFTPError(FX_BAD_MESSAGE, str(exc))
+            raise SFTPBadMessage(str(exc)) from None
         except (asyncio.IncompleteReadError, Error) as exc:
-            raise SFTPError(FX_FAILURE, str(exc))
+            raise SFTPFailure(str(exc)) from None
 
         self.logger.debug1('Received version=%d%s', version,
                            ', extensions:' if extensions else '')
@@ -1267,7 +1435,7 @@
 
             return vfsattrs
         else:
-            raise SFTPError(FX_OP_UNSUPPORTED, 'statvfs not supported')
+            raise SFTPOpUnsupported('statvfs not supported')
 
     async def fstatvfs(self, handle):
         """Make an SFTP fstatvfs request"""
@@ -1284,7 +1452,7 @@
 
             return vfsattrs
         else:
-            raise SFTPError(FX_OP_UNSUPPORTED, 'fstatvfs not supported')
+            raise SFTPOpUnsupported('fstatvfs not supported')
 
     async def remove(self, path):
         """Make an SFTP remove request"""
@@ -1312,7 +1480,7 @@
             return await self._make_request(b'[email protected]',
                                             String(oldpath), String(newpath))
         else:
-            raise SFTPError(FX_OP_UNSUPPORTED, 'POSIX rename not supported')
+            raise SFTPOpUnsupported('POSIX rename not supported')
 
     async def opendir(self, path):
         """Make an SFTP opendir request"""
@@ -1379,7 +1547,7 @@
             return await self._make_request(b'[email protected]',
                                             String(oldpath), String(newpath))
         else:
-            raise SFTPError(FX_OP_UNSUPPORTED, 'link not supported')
+            raise SFTPOpUnsupported('link not supported')
 
     async def fsync(self, handle):
         """Make an SFTP fsync request"""
@@ -1390,7 +1558,7 @@
             return await self._make_request(b'[email protected]',
                                             String(handle))
         else:
-            raise SFTPError(FX_OP_UNSUPPORTED, 'fsync not supported')
+            raise SFTPOpUnsupported('fsync not supported')
 
     def exit(self):
         """Handle a request to close the SFTP session"""
@@ -1500,9 +1668,8 @@
                 else:
                     data = await self._handler.read(self._handle, offset, size)
                 self._offset = offset + len(data)
-            except SFTPError as exc:
-                if exc.code != FX_EOF:
-                    raise
+            except SFTPEOFError:
+                pass
 
         if self._encoding:
             data = data.decode(self._encoding, self._errors)
@@ -1813,8 +1980,8 @@
             if self._path_encoding:
                 path = path.encode(self._path_encoding, self._path_errors)
             else:
-                raise SFTPError(FX_BAD_MESSAGE, 'Path must be bytes when '
-                                'encoding is not set')
+                raise SFTPBadMessage('Path must be bytes when '
+                                     'encoding is not set')
 
         return path
 
@@ -1829,8 +1996,7 @@
             try:
                 path = path.decode(self._path_encoding, self._path_errors)
             except UnicodeDecodeError:
-                raise SFTPError(FX_BAD_MESSAGE,
-                                'Unable to decode name') from None
+                raise SFTPBadMessage('Unable to decode name') from None
 
         return path
 
@@ -1857,11 +2023,8 @@
 
         try:
             return (await statfunc(path)).permissions
-        except SFTPError as exc:
-            if exc.code in (FX_NO_SUCH_FILE, FX_PERMISSION_DENIED):
-                return 0
-            else:
-                raise
+        except (SFTPNoSuchFile, SFTPPermissionDenied):
+            return 0
 
     async def _glob(self, fs, patterns, error_handler):
         """Begin a new glob pattern match"""
@@ -1899,8 +2062,8 @@
         try:
             if stat.S_ISDIR(srcattrs.permissions):
                 if not recurse:
-                    raise SFTPError(FX_FAILURE, '%s is a directory' %
-                                    srcpath.decode('utf-8', errors='replace'))
+                    raise SFTPFailure('%s is a directory' %
+                                      srcpath.decode('utf-8', 
errors='replace'))
 
                 self.logger.info('  Starting copy of directory %s to %s',
                                  srcpath, dstpath)
@@ -1958,11 +2121,21 @@
             else:
                 raise
 
-    async def _begin_copy(self, srcfs, dstfs, srcpaths, dstpath, preserve,
-                          recurse, follow_symlinks, block_size, max_requests,
-                          progress_handler, error_handler):
+    async def _begin_copy(self, srcfs, dstfs, srcpaths, dstpath, copy_type,
+                          expand_glob, preserve, recurse, follow_symlinks,
+                          block_size, max_requests, progress_handler,
+                          error_handler):
         """Begin a new file upload, download, or copy"""
 
+        if isinstance(srcpaths, tuple):
+            srcpaths = list(srcpaths)
+
+        self.logger.info('Starting SFTP %s of %s to %s',
+                         copy_type, srcpaths, dstpath)
+
+        if expand_glob:
+            srcpaths = await self._glob(srcfs, srcpaths, error_handler)
+
         dst_isdir = dstpath is None or (await dstfs.isdir(dstpath))
 
         if dstpath:
@@ -1971,8 +2144,8 @@
         if isinstance(srcpaths, (str, bytes, PurePath)):
             srcpaths = [srcpaths]
         elif not dst_isdir:
-            raise SFTPError(FX_FAILURE, '%s must be a directory' %
-                            dstpath.decode('utf-8', errors='replace'))
+            raise SFTPFailure('%s must be a directory' %
+                              dstpath.decode('utf-8', errors='replace'))
 
         for srcfile in srcpaths:
             srcfile = srcfs.encode(srcfile)
@@ -2085,12 +2258,10 @@
 
         """
 
-        self.logger.info('Starting SFTP get of %s to %s',
-                         remotepaths, localpath)
-
-        await self._begin_copy(self, LocalFile, remotepaths, localpath,
-                               preserve, recurse, follow_symlinks, block_size,
-                               max_requests, progress_handler, error_handler)
+        await self._begin_copy(self, LocalFile, remotepaths, localpath, 'get',
+                               False, preserve, recurse, follow_symlinks,
+                               block_size, max_requests, progress_handler,
+                               error_handler)
 
     async def put(self, localpaths, remotepath=None, *, preserve=False,
                   recurse=False, follow_symlinks=False,
@@ -2188,12 +2359,10 @@
 
         """
 
-        self.logger.info('Starting SFTP put of %s to %s',
-                         localpaths, remotepath)
-
-        await self._begin_copy(LocalFile, self, localpaths, remotepath,
-                               preserve, recurse, follow_symlinks, block_size,
-                               max_requests, progress_handler, error_handler)
+        await self._begin_copy(LocalFile, self, localpaths, remotepath, 'put',
+                               False, preserve, recurse, follow_symlinks,
+                               block_size, max_requests, progress_handler,
+                               error_handler)
 
     async def copy(self, srcpaths, dstpath=None, *, preserve=False,
                    recurse=False, follow_symlinks=False,
@@ -2291,12 +2460,10 @@
 
         """
 
-        self.logger.info('Starting SFTP remote copy of %s to %s',
-                         srcpaths, dstpath)
-
-        await self._begin_copy(self, self, srcpaths, dstpath, preserve,
-                               recurse, follow_symlinks, block_size,
-                               max_requests, progress_handler, error_handler)
+        await self._begin_copy(self, self, srcpaths, dstpath, 'remote copy',
+                               False, preserve, recurse, follow_symlinks,
+                               block_size, max_requests, progress_handler,
+                               error_handler)
 
     async def mget(self, remotepaths, localpath=None, *, preserve=False,
                    recurse=False, follow_symlinks=False,
@@ -2313,14 +2480,10 @@
 
         """
 
-        self.logger.info('Starting SFTP mget of %s to %s',
-                         remotepaths, localpath)
-
-        matches = await self._glob(self, remotepaths, error_handler)
-
-        await self._begin_copy(self, LocalFile, matches, localpath,
-                               preserve, recurse, follow_symlinks, block_size,
-                               max_requests, progress_handler, error_handler)
+        await self._begin_copy(self, LocalFile, remotepaths, localpath, 'mget',
+                               True, preserve, recurse, follow_symlinks,
+                               block_size, max_requests, progress_handler,
+                               error_handler)
 
     async def mput(self, localpaths, remotepath=None, *, preserve=False,
                    recurse=False, follow_symlinks=False,
@@ -2337,14 +2500,10 @@
 
         """
 
-        self.logger.info('Starting SFTP mput of %s to %s',
-                         localpaths, remotepath)
-
-        matches = await self._glob(LocalFile, localpaths, error_handler)
-
-        await self._begin_copy(LocalFile, self, matches, remotepath,
-                               preserve, recurse, follow_symlinks, block_size,
-                               max_requests, progress_handler, error_handler)
+        await self._begin_copy(LocalFile, self, localpaths, remotepath, 'mput',
+                               True, preserve, recurse, follow_symlinks,
+                               block_size, max_requests, progress_handler,
+                               error_handler)
 
     async def mcopy(self, srcpaths, dstpath=None, *, preserve=False,
                     recurse=False, follow_symlinks=False,
@@ -2361,14 +2520,10 @@
 
         """
 
-        self.logger.info('Starting SFTP remote mcopy of %s to %s',
-                         srcpaths, dstpath)
-
-        matches = await self._glob(self, srcpaths, error_handler)
-
-        await self._begin_copy(self, self, matches, dstpath, preserve,
-                               recurse, follow_symlinks, block_size,
-                               max_requests, progress_handler, error_handler)
+        await self._begin_copy(self, self, srcpaths, dstpath, 'remote mcopy',
+                               True, preserve, recurse, follow_symlinks,
+                               block_size, max_requests, progress_handler,
+                               error_handler)
 
     async def glob(self, patterns, error_handler=None):
         """Match remote files against glob patterns
@@ -2924,9 +3079,8 @@
         try:
             while True:
                 names.extend((await self._handler.readdir(handle)))
-        except SFTPError as exc:
-            if exc.code != FX_EOF:
-                raise
+        except SFTPEOFError:
+            pass
         finally:
             await self._handler.close(handle)
 
@@ -3015,7 +3169,7 @@
         names = await self._handler.realpath(fullpath)
 
         if len(names) > 1:
-            raise SFTPError(FX_BAD_MESSAGE, 'Too many names returned')
+            raise SFTPBadMessage('Too many names returned')
 
         return self.decode(names[0].filename, isinstance(path, (str, 
PurePath)))
 
@@ -3066,7 +3220,7 @@
         names = await self._handler.readlink(linkpath)
 
         if len(names) > 1:
-            raise SFTPError(FX_BAD_MESSAGE, 'Too many names returned')
+            raise SFTPBadMessage('Too many names returned')
 
         return self.decode(names[0].filename, isinstance(path, str))
 
@@ -3201,8 +3355,8 @@
 
             handler = self._packet_handlers.get(pkttype)
             if not handler:
-                raise SFTPError(FX_OP_UNSUPPORTED,
-                                'Unsupported request type: %s' % pkttype)
+                raise SFTPOpUnsupported('Unsupported request type: %s' %
+                                        pkttype)
 
             return_type = self._return_types.get(pkttype, FXP_STATUS)
             result = await handler(self, packet)
@@ -3333,7 +3487,7 @@
         if self._dir_handles.pop(handle, None) is not None:
             return
 
-        raise SFTPError(FX_FAILURE, 'Invalid file handle')
+        raise SFTPFailure('Invalid file handle')
 
     async def _process_read(self, packet):
         """Process an incoming SFTP read request"""
@@ -3357,9 +3511,9 @@
             if result:
                 return result
             else:
-                raise SFTPError(FX_EOF, '')
+                raise SFTPEOFError
         else:
-            raise SFTPError(FX_FAILURE, 'Invalid file handle')
+            raise SFTPFailure('Invalid file handle')
 
     async def _process_write(self, packet):
         """Process an incoming SFTP write request"""
@@ -3382,7 +3536,7 @@
 
             return result
         else:
-            raise SFTPError(FX_FAILURE, 'Invalid file handle')
+            raise SFTPFailure('Invalid file handle')
 
     async def _process_lstat(self, packet):
         """Process an incoming SFTP lstat request"""
@@ -3417,7 +3571,7 @@
 
             return result
         else:
-            raise SFTPError(FX_FAILURE, 'Invalid file handle')
+            raise SFTPFailure('Invalid file handle')
 
     async def _process_setstat(self, packet):
         """Process an incoming SFTP setstat request"""
@@ -3455,7 +3609,7 @@
 
             return result
         else:
-            raise SFTPError(FX_FAILURE, 'Invalid file handle')
+            raise SFTPFailure('Invalid file handle')
 
     async def _process_opendir(self, packet):
         """Process an incoming SFTP opendir request"""
@@ -3512,7 +3666,7 @@
             del names[:_MAX_READDIR_NAMES]
             return result
         else:
-            raise SFTPError(FX_EOF, '')
+            raise SFTPEOFError
 
     async def _process_remove(self, packet):
         """Process an incoming SFTP remove request"""
@@ -3694,7 +3848,7 @@
 
             return result
         else:
-            raise SFTPError(FX_FAILURE, 'Invalid file handle')
+            raise SFTPFailure('Invalid file handle')
 
     async def _process_link(self, packet):
         """Process an incoming SFTP hard link request"""
@@ -3731,7 +3885,7 @@
 
             return result
         else:
-            raise SFTPError(FX_FAILURE, 'Invalid file handle')
+            raise SFTPFailure('Invalid file handle')
 
     _packet_handlers = {
         FXP_OPEN:                     _process_open,
@@ -3778,15 +3932,14 @@
                 data = packet.get_string()
                 extensions.append((name, data))
         except PacketDecodeError as exc:
-            await self._cleanup(SFTPError(FX_BAD_MESSAGE, str(exc)))
+            await self._cleanup(SFTPBadMessage(str(exc)))
             return
         except Error as exc:
             await self._cleanup(exc)
             return
 
         if pkttype != FXP_INIT:
-            await self._cleanup(SFTPError(FX_BAD_MESSAGE,
-                                          'Expected init message'))
+            await self._cleanup(SFTPBadMessage('Expected init message'))
             return
 
         self.logger.debug1('Received init, version=%d%s', version,
@@ -4066,7 +4219,7 @@
             elif path.startswith(self._chroot + b'/'):
                 return path[len(self._chroot):]
             else:
-                raise SFTPError(FX_NO_SUCH_FILE, 'File not found')
+                raise SFTPNoSuchFile('File not found')
         else:
             return path
 
@@ -4413,7 +4566,7 @@
         newpath = _to_local_path(self.map_path(newpath))
 
         if os.path.exists(newpath):
-            raise SFTPError(FX_FAILURE, 'File already exists')
+            raise SFTPFailure('File already exists')
 
         os.rename(oldpath, newpath)
 
@@ -4503,7 +4656,7 @@
         try:
             return os.statvfs(_to_local_path(self.map_path(path)))
         except AttributeError: # pragma: no cover
-            raise SFTPError(FX_OP_UNSUPPORTED, 'statvfs not supported')
+            raise SFTPOpUnsupported('statvfs not supported') from None
 
     def fstatvfs(self, file_obj):
         """Return attributes of the file system containing an open file
@@ -4522,7 +4675,7 @@
         try:
             return os.statvfs(file_obj.fileno())
         except AttributeError: # pragma: no cover
-            raise SFTPError(FX_OP_UNSUPPORTED, 'fstatvfs not supported')
+            raise SFTPOpUnsupported('fstatvfs not supported') from None
 
     def link(self, oldpath, newpath):
         """Create a hard link
@@ -4604,11 +4757,8 @@
                 return 0
             else:
                 raise
-        except SFTPError as exc:
-            if exc.code in (FX_NO_SUCH_FILE, FX_PERMISSION_DENIED):
-                return 0
-            else:
-                raise
+        except (SFTPNoSuchFile, SFTPPermissionDenied):
+            return 0
 
     async def exists(self, path):
         """Return if a path exists"""
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asyncssh-2.2.0/asyncssh/version.py 
new/asyncssh-2.2.1/asyncssh/version.py
--- old/asyncssh-2.2.0/asyncssh/version.py      2020-03-01 00:17:18.000000000 
+0100
+++ new/asyncssh-2.2.1/asyncssh/version.py      2020-04-18 18:37:35.000000000 
+0200
@@ -26,4 +26,4 @@
 
 __url__ = 'http://asyncssh.timeheart.net'
 
-__version__ = '2.2.0'
+__version__ = '2.2.1'
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asyncssh-2.2.0/asyncssh.egg-info/PKG-INFO 
new/asyncssh-2.2.1/asyncssh.egg-info/PKG-INFO
--- old/asyncssh-2.2.0/asyncssh.egg-info/PKG-INFO       2020-03-01 
00:59:29.000000000 +0100
+++ new/asyncssh-2.2.1/asyncssh.egg-info/PKG-INFO       2020-04-18 
19:20:46.000000000 +0200
@@ -1,6 +1,6 @@
 Metadata-Version: 2.1
 Name: asyncssh
-Version: 2.2.0
+Version: 2.2.1
 Summary: AsyncSSH: Asynchronous SSHv2 client and server library
 Home-page: http://asyncssh.timeheart.net
 Author: Ron Frederick
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asyncssh-2.2.0/tests/test_forward.py 
new/asyncssh-2.2.1/tests/test_forward.py
--- old/asyncssh-2.2.0/tests/test_forward.py    2019-11-16 21:33:08.000000000 
+0100
+++ new/asyncssh-2.2.1/tests/test_forward.py    2020-04-18 18:36:42.000000000 
+0200
@@ -167,6 +167,18 @@
             return listen_host != 'fail'
 
 
+class _TCPAsyncConnectionServer(_TCPConnectionServer):
+    """Server for testing async direct and forwarded TCP connections"""
+
+    async def server_requested(self, listen_host, listen_port):
+        """Handle a request to create a new socket listener"""
+
+        if listen_host == 'open':
+            return _EchoPortListener(self._conn)
+        else:
+            return listen_host != 'fail'
+
+
 class _UNIXConnectionServer(Server):
     """Server for testing direct and forwarded UNIX domain connections"""
 
@@ -647,6 +659,17 @@
                 await listener.wait_closed()
 
 
+class _TestAsyncTCPForwarding(_TestTCPForwarding):
+    """Unit tests for AsyncSSH TCP connection forwarding with async return"""
+
+    @classmethod
+    async def start_server(cls):
+        """Start an SSH server which supports TCP connection forwarding"""
+
+        return await cls.create_server(
+            _TCPAsyncConnectionServer, 
authorized_client_keys='authorized_keys')
+
+
 @unittest.skipIf(sys.platform == 'win32',
                  'skip UNIX domain socket tests on Windows')
 class _TestUNIXForwarding(_CheckForwarding):
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asyncssh-2.2.0/tests/test_process.py 
new/asyncssh-2.2.1/tests/test_process.py
--- old/asyncssh-2.2.0/tests/test_process.py    2019-11-30 19:27:24.000000000 
+0100
+++ new/asyncssh-2.2.1/tests/test_process.py    2020-04-18 18:36:42.000000000 
+0200
@@ -84,6 +84,10 @@
         except asyncssh.TerminalSizeChanged as exc:
             process.exit_with_signal('ABRT', False,
                                      '%sx%s' % (exc.width, exc.height))
+    elif action == 'timeout':
+        process.channel.set_encoding('utf-8')
+        process.stdout.write('Sleeping')
+        await asyncio.sleep(1)
     else:
         process.exit(255)
 
@@ -317,6 +321,18 @@
         self.assertEqual(exc.exception.returncode, 1)
 
     @asynctest
+    async def test_raise_on_timeout(self):
+        """Test raising an exception on timeout"""
+
+        async with self.connect() as conn:
+            with self.assertRaises(asyncssh.ProcessError) as exc:
+                await conn.run('timeout', timeout=0.1)
+
+        self.assertEqual(exc.exception.command, 'timeout')
+        self.assertEqual(exc.exception.reason, '')
+        self.assertEqual(exc.exception.stdout, 'Sleeping')
+
+    @asynctest
     async def test_exit_signal(self):
         """Test checking exit signal"""
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asyncssh-2.2.0/tests/test_sftp.py 
new/asyncssh-2.2.1/tests/test_sftp.py
--- old/asyncssh-2.2.0/tests/test_sftp.py       2019-11-16 21:33:08.000000000 
+0100
+++ new/asyncssh-2.2.1/tests/test_sftp.py       2020-04-18 18:36:42.000000000 
+0200
@@ -34,12 +34,12 @@
 
 import asyncssh
 
-from asyncssh import SFTPError, SFTPAttrs, SFTPVFSAttrs, SFTPName, SFTPServer
+from asyncssh import SFTPError, SFTPFailure, SFTPPermissionDenied
+from asyncssh import SFTPAttrs, SFTPVFSAttrs, SFTPName, SFTPServer
 from asyncssh import SEEK_CUR, SEEK_END
 from asyncssh import FXP_INIT, FXP_VERSION, FXP_OPEN, FXP_READ
 from asyncssh import FXP_WRITE, FXP_STATUS, FXP_HANDLE, FXP_DATA
-from asyncssh import FILEXFER_ATTR_UNDEFINED
-from asyncssh import FX_OK, FX_PERMISSION_DENIED, FX_FAILURE
+from asyncssh import FILEXFER_ATTR_UNDEFINED, FX_OK
 from asyncssh import scp
 
 from asyncssh.packet import SSHPacket, String, UInt32
@@ -153,7 +153,7 @@
         """Return an error for reads past 64 KB in a file"""
 
         if offset >= 65536:
-            raise SFTPError(FX_FAILURE, 'I/O error')
+            raise SFTPFailure('I/O error')
         else:
             return super().read(file_obj, offset, size)
 
@@ -161,7 +161,7 @@
         """Return an error for writes past 64 KB in a file"""
 
         if offset >= 65536:
-            raise SFTPError(FX_FAILURE, 'I/O error')
+            raise SFTPFailure('I/O error')
         else:
             super().write(file_obj, offset, data)
 
@@ -290,9 +290,9 @@
             return SFTPAttrs.from_local(super().stat(path))
         except OSError as exc:
             if exc.errno == errno.EACCES:
-                raise SFTPError(FX_PERMISSION_DENIED, exc.strerror)
+                raise SFTPPermissionDenied(exc.strerror)
             else:
-                raise SFTPError(FX_FAILURE, exc.strerror)
+                raise SFTPError(99, exc.strerror)
 
 
 class _AsyncSFTPServer(SFTPServer):
@@ -649,6 +649,26 @@
     async def test_multiple_copy(self, sftp):
         """Test copying multiple files over SFTP"""
 
+        for method in ('get', 'put', 'copy'):
+            for seq in (list, tuple):
+                with self.subTest(method=method):
+                    try:
+                        self._create_file('src1', 'xxx')
+                        self._create_file('src2', 'yyy')
+                        os.mkdir('dst')
+
+                        await getattr(sftp, method)(seq(('src1', 'src2')),
+                                                    'dst')
+
+                        self._check_file('src1', 'dst/src1')
+                        self._check_file('src2', 'dst/src2')
+                    finally:
+                        remove('src1 src2 dst')
+
+    @sftp_test
+    async def test_multiple_copy_glob(self, sftp):
+        """Test copying multiple files via glob over SFTP"""
+
         for method in ('mget', 'mput', 'mcopy'):
             with self.subTest(method=method):
                 try:
@@ -1093,7 +1113,7 @@
 
             # pylint: disable=unused-argument
 
-            raise SFTPError(FX_FAILURE, 'I/O error')
+            raise SFTPFailure('I/O error')
 
         try:
             os.mkdir('dir')
@@ -2354,6 +2374,25 @@
             remove('chroot/link1 chroot/link2')
 
 
+class _TestSFTPUnknownError(_CheckSFTP):
+    """Unit test for SFTP server returning unknown error"""
+
+    @classmethod
+    async def start_server(cls):
+        """Start an SFTP server which returns unknown error"""
+
+        return await cls.create_server(sftp_factory=_SFTPAttrsSFTPServer)
+
+    @sftp_test
+    async def test_stat_error(self, sftp):
+        """Test error when getting attributes of a file on an SFTP server"""
+
+        with self.assertRaises(SFTPError) as exc:
+            await sftp.stat('file')
+
+        self.assertEqual(exc.exception.code, 99)
+
+
 class _TestSFTPIOError(_CheckSFTP):
     """Unit test for SFTP server returning file I/O error"""
 


Reply via email to