Repository: giampaolo/pyftpdlib (language: python)

Issue:
Drop Python 2.7 support
Statistics as of today. Python 2.7 represents 2.25% of total downloads.

```
$ pypinfo pyftpdlib pyversion
Served from cache: False
Data processed: 2.17 GiB
Data billed: 2.17 GiB
Estimated cost: $0.02

| python_version | download_count | percentage |
| -------------- | -------------- | ---------- |
| 3.10           | 160,511        | 60.91%     |
| 3.8            | 32,816         | 12.46%     |
| 3.12           | 18,976         | 7.20%      |
| 3.9            | 18,598         | 7.06%      |
| 3.11           | 14,820         | 5.62%      |
| 3.7            | 8,173          | 3.10%      |
| 2.7            | 5,927          | 2.25%      |
| 3.6            | 3,615          | 1.37%      |
| 3.13           | 52             | 0.02%      |
| 3.5            | 36             | 0.01%      |
| Total          | 263,524        | 100.00%    |
```


Discussion / hints:

Fix patch:
diff --git a/HISTORY.rst b/HISTORY.rst
index 7d9fb0d3..fdca9019 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -1,5 +1,18 @@
 Bug tracker at https://github.com/giampaolo/pyftpdlib/issues
 
+Version: 2.0.0 - (IN DEVELOPMENT)
+=================================
+
+**Enhancements**
+
+* #629: removed Python 2.7 support.
+
+**Notes about backward compatibility**
+
+* #629: Python 2.7 is no longer supported.
+* #629: pysendfile module is no longer a required dependency, because we ceased
+  support for Python 2.
+
 Version: 1.5.10 - 2024-06-23
 ============================
 
diff --git a/MANIFEST.in b/MANIFEST.in
index ec2fbf0f..5c579014 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -36,7 +36,6 @@ include pyftpdlib/__init__.py
 include pyftpdlib/__main__.py
 include pyftpdlib/_asynchat.py
 include pyftpdlib/_asyncore.py
-include pyftpdlib/_compat.py
 include pyftpdlib/authorizers.py
 include pyftpdlib/filesystems.py
 include pyftpdlib/handlers.py
diff --git a/Makefile b/Makefile
index 65d3cdff..0b2d5bd0 100644
--- a/Makefile
+++ b/Makefile
@@ -28,17 +28,6 @@ ifndef GITHUB_ACTIONS
 		toml-sort \
 		twine
 endif
-# python 2 deps
-ifeq ($(shell $(PYTHON) -c "import sys; print(sys.version_info[0])"), 2)
-	PYDEPS = \
-		ipaddress \
-		mock \
-		psutil \
-		pytest \
-		pyopenssl \
-		pysendfile \
-		setuptools
-endif
 
 # In not in a virtualenv, add --user options for install commands.
 INSTALL_OPTS = `$(PYTHON) -c "import sys; print('' if hasattr(sys, 'real_prefix') else '--user')"`
@@ -94,12 +83,11 @@ uninstall:  ## Uninstall this package.
 install-pip:  ## Install pip (no-op if already installed).
 	@$(PYTHON) -c \
 		"import sys, ssl, os, pkgutil, tempfile, atexit; \
+		from urllib.request import urlopen; \
 		sys.exit(0) if pkgutil.find_loader('pip') else None; \
-		PY3 = sys.version_info[0] == 3; \
-		pyexc = 'from urllib.request import urlopen' if PY3 else 'from urllib2 import urlopen'; \
 		exec(pyexc); \
 		ctx = ssl._create_unverified_context() if hasattr(ssl, '_create_unverified_context') else None; \
-		url = 'https://bootstrap.pypa.io/pip/2.7/get-pip.py' if not PY3 else 'https://bootstrap.pypa.io/get-pip.py'; \
+		url = 'https://bootstrap.pypa.io/get-pip.py'; \
 		kw = dict(context=ctx) if ctx else {}; \
 		req = urlopen(url, **kw); \
 		data = req.read(); \
diff --git a/README.rst b/README.rst
index b7fb9a0b..ac898d86 100644
--- a/README.rst
+++ b/README.rst
@@ -1,6 +1,6 @@
 |  |downloads| |stars| |forks| |contributors|
 |  |version| |packages| |license|
-|  |github-actions| |appveyor| |doc| |twitter|
+|  |github-actions| |doc| |twitter|
 
 .. |downloads| image:: https://img.shields.io/pypi/dm/pyftpdlib.svg
     :target: https://pepy.tech/project/pyftpdlib
@@ -22,10 +22,6 @@
     :target: https://github.com/giampaolo/pyftpdlib/actions
     :alt: GH actions
 
-.. |appveyor| image:: https://img.shields.io/appveyor/build/giampaolo/pyftpdlib/master.svg?maxAge=3600&label=Windows%20(py2)
-    :target: https://ci.appveyor.com/project/giampaolo/pyftpdlib
-    :alt: Windows (Py2, Windows)
-
 .. |doc| image:: https://readthedocs.org/projects/pyftpdlib/badge/?version=latest
     :target: https://pyftpdlib.readthedocs.io/en/latest/
     :alt: Documentation Status
@@ -80,8 +76,7 @@ Features
 - ...But can optionally skip to a
   `multiple thread / process <http://pyftpdlib.readthedocs.io/en/latest/tutorial.html#changing-the-concurrency-model>`__
   model (as in: you'll be free to block or use slow filesystems).
-- Portable: entirely written in pure Python; works with Python **2.7** and
-  **3.X** using a single code base.
+- Portable: entirely written in pure Python.
 - Supports **FTPS** (`RFC-4217 <http://tools.ietf.org/html/rfc4217>`__),
   **IPv6** (`RFC-2428 <ftp://ftp.rfc-editor.org/in-notes/rfc2428.txt>`__),
   **Unicode** file names (`RFC-2640 <http://tools.ietf.org/html/rfc2640>`__),
diff --git a/appveyor.yml b/appveyor.yml
deleted file mode 100644
index 04a97c5f..00000000
--- a/appveyor.yml
+++ /dev/null
@@ -1,47 +0,0 @@
-# Build: 3 (bump this up by 1 to force an appveyor run)
-
-os: Visual Studio 2015
-# avoid 2 builds when pushing on PRs
-skip_branch_with_pr: true
-# avoid build on new GIT tag
-skip_tags: true
-matrix:
-  # stop build on first failure
-  fast_finish: true
-environment:
-  global:
-    # SDK v7.0 MSVC Express 2008's SetEnv.cmd script will fail if the
-    # /E:ON and /V:ON options are not enabled in the batch script interpreter
-    # See: http://stackoverflow.com/a/13751649/163740
-    # WITH_COMPILER: "cmd /E:ON /V:ON /C .\\scripts\\internal\\appveyor_run_with_compiler.cmd"
-    PYTHONWARNINGS: always
-    PYTHONUNBUFFERED: 1
-    PSUTIL_DEBUG: 1
-  matrix:
-    - PYTHON: "C:\\Python27-x64"
-      PYTHON_VERSION: "2.7.x"
-      PYTHON_ARCH: "64"
-
-init:
-  - "ECHO %PYTHON% %PYTHON_VERSION% %PYTHON_ARCH%"
-
-install:
-  - "%WITH_COMPILER% %PYTHON%/python.exe -m pip --version"
-  - "%WITH_COMPILER% %PYTHON%/python.exe -m pip install --upgrade --user setuptools pip"
-  - "%WITH_COMPILER% %PYTHON%/python.exe scripts/internal/winmake.py setup-dev-env"
-  - "%WITH_COMPILER% %PYTHON%/python.exe -m pip freeze"
-  - "%WITH_COMPILER% %PYTHON%/python.exe scripts/internal/winmake.py install"
-
-build: off
-
-test_script:
-  - "%WITH_COMPILER% %PYTHON%/python.exe scripts/internal/winmake.py test"
-
-cache:
-  - '%LOCALAPPDATA%\pip\Cache'
-
-# on_success:
-#   - might want to upload the content of dist/*.whl to a public wheelhouse
-
-skip_commits:
-  message: skip-appveyor
diff --git a/demo/unix_daemon.py b/demo/unix_daemon.py
index 1f3a58cf..ca88722a 100755
--- a/demo/unix_daemon.py
+++ b/demo/unix_daemon.py
@@ -32,7 +32,6 @@
 
 import argparse
 import atexit
-import errno
 import os
 import signal
 import sys
@@ -57,8 +56,9 @@ def pid_exists(pid):
     """Return True if a process with the given PID is currently running."""
     try:
         os.kill(pid, 0)
-    except OSError as err:
-        return err.errno == errno.EPERM
+    except PermissionError:
+        # EPERM clearly means there's a process to deny access to
+        return True
     else:
         return True
 
@@ -68,9 +68,8 @@ def get_pid():
     try:
         with open(PID_FILE) as f:
             return int(f.read().strip())
-    except IOError as err:
-        if err.errno != errno.ENOENT:
-            raise
+    except FileNotFoundError:
+        pass
 
 
 def stop():
@@ -87,12 +86,8 @@ def stop():
         sys.stdout.flush()
         try:
             os.kill(pid, sig)
-        except OSError as err:
-            if err.errno == errno.ESRCH:
-                print("\nstopped (pid %s)" % pid)
-                return
-            else:
-                raise
+        except ProcessLookupError:
+            print("\nstopped (pid %s)" % pid)
         i += 1
         if i == 25:
             sig = signal.SIGKILL
diff --git a/docs/api.rst b/docs/api.rst
index 87722935..0ea05b8b 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -527,8 +527,7 @@ Filesystem
 
     Wrapper around
     `os.listdir <http://docs.python.org/library/os.html#os.listdir>`_.
-    It is expected to return a list of unicode strings or a generator yielding
-    unicode strings.
+    It is expected to return a list of strings or a generator yielding strings.
 
     .. versionchanged:: 1.6.0 can also return a generator.
 
diff --git a/docs/faqs.rst b/docs/faqs.rst
index 00dbef04..a10ed8d1 100644
--- a/docs/faqs.rst
+++ b/docs/faqs.rst
@@ -84,14 +84,23 @@ If you are not new to Python you probably don't need that, otherwise follow the
 Which Python versions are compatible?
 -------------------------------------
 
-*2.7* and *3.X*.
+Python *3.X*. Anything above 3.8 should be good to go. Pypy should also work.
+
+What about Python 2.7?
+----------------------
+
+Latest pyftpdlib version supporting Python 2.7 is 1.5.10. You can install it
+with:
+
+.. code-block:: sh
+
+    python3 -m pip install pyftpdlib==1.5.10
 
 On which platforms can pyftpdlib be used?
 -----------------------------------------
 
 pyftpdlib should work on any platform where **select()**, **poll()**,
-**epoll()** or **kqueue()** system calls are available and on any Python
-implementation which refers to *cPython 2.7* or superior.
+**epoll()** or **kqueue()** system calls are available.
 The development team has mainly tested it under various *Linux*, *Windows*,
 *OSX* and *FreeBSD* systems.
 For FreeBSD is also available a
@@ -140,8 +149,8 @@ avoid race conditions, dead locks etc.
 Another possibility is to
 `change the default concurrency model <tutorial.html#changing-the-concurrency-model>`__.
 
-Why do I get socket.error "Permission denied" error on ftpd starting?
----------------------------------------------------------------------
+Why do I get "Permission denied" error on startup?
+--------------------------------------------------
 
 Probably because you're on a Unix system and you're trying to start ftpd as an
 unprivileged user. FTP servers bind on port 21 by default and only super-user
@@ -244,10 +253,8 @@ Implementation
 sendfile()
 ----------
 
-Starting from version 0.7.0 if
-`pysendfile <https://github.com/giampaolo/pysendfile/>`__ module is installed
-sendfile(2) system call be used when uploading files (from server to client)
-via RETR command.
+Starting from version 0.7.0, sendfile(2) system call be used when uploading
+files (from server to client) via RETR command.
 Using sendfile(2) usually results in transfer rates from 2x to 3x faster
 and less CPU usage.
 Note: use of sendfile() might introduce some unexpected issues with "non
diff --git a/docs/install.rst b/docs/install.rst
index 1da7c59f..9ec32f0b 100644
--- a/docs/install.rst
+++ b/docs/install.rst
@@ -32,12 +32,3 @@ Additional dependencies
 .. code-block:: sh
 
     $ pip install PyOpenSSL
-
-`pysendfile <https://github.com/giampaolo/pysendfile>`__, if you're on UNIX,
-in order to
-`speedup uploads <http://pyftpdlib.readthedocs.io/faqs.html#sendfile>`__
-(from server to client):
-
-.. code-block:: sh
-
-    $ pip install pysendfile
diff --git a/make.bat b/make.bat
index 263fadda..e9c9a9e1 100644
--- a/make.bat
+++ b/make.bat
@@ -6,12 +6,6 @@ rem It is primarly intended as a shortcut for compiling / installing
 rem psutil ("make.bat build", "make.bat install") and running tests
 rem ("make.bat test").
 rem
-rem This script is modeled after my Windows installation which uses:
-rem - Visual studio 2008 for Python 2.7
-rem - Visual studio 2010 for Python 3.4+
-rem ...therefore it might not work on your Windows installation.
-rem
-rem By default C:\Python27\python.exe is used.
 rem To compile for a specific Python version run:
 rem     set PYTHON=C:\Python34\python.exe & make.bat build
 rem
diff --git a/pyftpdlib/__init__.py b/pyftpdlib/__init__.py
index f14d6274..f9d2d743 100644
--- a/pyftpdlib/__init__.py
+++ b/pyftpdlib/__init__.py
@@ -69,6 +69,6 @@ class used to interact with the file system, providing a high level,
 """
 
 
-__ver__ = '1.5.10'
+__ver__ = '2.0.0'
 __author__ = "Giampaolo Rodola' <g.rodola@gmail.com>"
 __web__ = 'https://github.com/giampaolo/pyftpdlib/'
diff --git a/pyftpdlib/__main__.py b/pyftpdlib/__main__.py
index 8f2077b3..6167207c 100644
--- a/pyftpdlib/__main__.py
+++ b/pyftpdlib/__main__.py
@@ -14,7 +14,6 @@
 import sys
 
 from . import __ver__
-from ._compat import getcwdu
 from .authorizers import DummyAuthorizer
 from .handlers import FTPHandler
 from .log import config_logging
@@ -53,7 +52,7 @@ def main(args=None):
     parser.add_argument(
         '-d',
         '--directory',
-        default=getcwdu(),
+        default=os.getcwd(),
         metavar="FOLDER",
         help="specify the directory to share (default current directory)",
     )
diff --git a/pyftpdlib/_compat.py b/pyftpdlib/_compat.py
deleted file mode 100644
index 410fd65d..00000000
--- a/pyftpdlib/_compat.py
+++ /dev/null
@@ -1,182 +0,0 @@
-# Copyright (C) 2007 Giampaolo Rodola' <g.rodola@gmail.com>.
-# Use of this source code is governed by MIT license that can be
-# found in the LICENSE file.
-
-"""
-Compatibility module similar to six lib, which helps maintaining
-a single code base working with both python 2.7 and 3.x.
-"""
-
-import errno
-import os
-import sys
-import types
-
-
-PY3 = sys.version_info[0] >= 3
-_SENTINEL = object()
-
-if PY3:
-
-    def u(s):
-        return s
-
-    def b(s):
-        return s.encode("latin-1")
-
-    getcwdu = os.getcwd
-    unicode = str
-    xrange = range
-    long = int
-else:
-
-    def u(s):
-        return unicode(s)
-
-    def b(s):
-        return s
-
-    getcwdu = os.getcwdu
-    unicode = unicode
-    xrange = xrange
-    long = long
-
-
-# removed in 3.0, reintroduced in 3.2
-try:
-    callable = callable
-except Exception:
-
-    def callable(obj):
-        return any("__call__" in klass.__dict__ for klass in type(obj).__mro__)
-
-
-# --- exceptions
-
-
-if PY3:
-    FileNotFoundError = FileNotFoundError  # NOQA
-    FileExistsError = FileExistsError  # NOQA
-    InterruptedError = InterruptedError  # NOQA
-    PermissionError = PermissionError  # NOQA
-else:
-    # https://github.com/PythonCharmers/python-future/blob/exceptions/
-    #     src/future/types/exceptions/pep3151.py
-    import platform
-
-    def _instance_checking_exception(base_exception=Exception):
-        def wrapped(instance_checker):
-            class TemporaryClass(base_exception):
-
-                def __init__(self, *args, **kwargs):
-                    if len(args) == 1 and isinstance(args[0], TemporaryClass):
-                        unwrap_me = args[0]
-                        for attr in dir(unwrap_me):
-                            if not attr.startswith('__'):
-                                setattr(self, attr, getattr(unwrap_me, attr))
-                    else:
-                        super(TemporaryClass, self).__init__(  # noqa
-                            *args, **kwargs
-                        )
-
-                class __metaclass__(type):
-                    def __instancecheck__(cls, inst):
-                        return instance_checker(inst)
-
-                    def __subclasscheck__(cls, classinfo):
-                        value = sys.exc_info()[1]
-                        return isinstance(value, cls)
-
-            TemporaryClass.__name__ = instance_checker.__name__
-            TemporaryClass.__doc__ = instance_checker.__doc__
-            return TemporaryClass
-
-        return wrapped
-
-    @_instance_checking_exception(EnvironmentError)
-    def FileNotFoundError(inst):
-        return getattr(inst, 'errno', _SENTINEL) == errno.ENOENT
-
-    @_instance_checking_exception(EnvironmentError)
-    def InterruptedError(inst):
-        return getattr(inst, 'errno', _SENTINEL) == errno.EINTR
-
-    @_instance_checking_exception(EnvironmentError)
-    def PermissionError(inst):
-        return getattr(inst, 'errno', _SENTINEL) in (errno.EACCES, errno.EPERM)
-
-    @_instance_checking_exception(EnvironmentError)
-    def FileExistsError(inst):
-        return getattr(inst, 'errno', _SENTINEL) == errno.EEXIST
-
-    if platform.python_implementation() != "CPython":
-        try:
-            raise OSError(errno.EEXIST, "perm")
-        except FileExistsError:
-            pass
-        except OSError:
-            raise RuntimeError(
-                "broken or incompatible Python implementation, see: "
-                "https://github.com/giampaolo/psutil/issues/1659"
-            )
-
-
-# Python 3 super().
-# Taken from "future" package.
-# Credit: Ryan Kelly
-if PY3:
-    super = super
-else:
-    _builtin_super = super
-
-    def super(type_=_SENTINEL, type_or_obj=_SENTINEL, framedepth=1):
-        """Like Python 3 builtin super(). If called without any arguments
-        it attempts to infer them at runtime.
-        """
-        if type_ is _SENTINEL:
-            f = sys._getframe(framedepth)
-            try:
-                # Get the function's first positional argument.
-                type_or_obj = f.f_locals[f.f_code.co_varnames[0]]
-            except (IndexError, KeyError):
-                raise RuntimeError('super() used in a function with no args')
-            try:
-                # Get the MRO so we can crawl it.
-                mro = type_or_obj.__mro__
-            except (AttributeError, RuntimeError):
-                try:
-                    mro = type_or_obj.__class__.__mro__
-                except AttributeError:
-                    raise RuntimeError('super() used in a non-newstyle class')
-            for type_ in mro:
-                #  Find the class that owns the currently-executing method.
-                for meth in type_.__dict__.values():
-                    # Drill down through any wrappers to the underlying func.
-                    # This handles e.g. classmethod() and staticmethod().
-                    try:
-                        while not isinstance(meth, types.FunctionType):
-                            if isinstance(meth, property):
-                                # Calling __get__ on the property will invoke
-                                # user code which might throw exceptions or
-                                # have side effects
-                                meth = meth.fget
-                            else:
-                                try:
-                                    meth = meth.__func__
-                                except AttributeError:
-                                    meth = meth.__get__(type_or_obj, type_)
-                    except (AttributeError, TypeError):
-                        continue
-                    if meth.func_code is f.f_code:
-                        break  # found
-                else:
-                    # Not found. Move onto the next class in MRO.
-                    continue
-                break  # found
-            else:
-                raise RuntimeError('super() called outside a method')
-
-        # Dispatch to builtin super().
-        if type_or_obj is not _SENTINEL:
-            return _builtin_super(type_, type_or_obj)
-        return _builtin_super(type_)
diff --git a/pyftpdlib/authorizers.py b/pyftpdlib/authorizers.py
index c2d63d77..6efd542d 100644
--- a/pyftpdlib/authorizers.py
+++ b/pyftpdlib/authorizers.py
@@ -18,14 +18,9 @@ class for:
 """
 
 
-import errno
 import os
 import warnings
 
-from ._compat import PY3
-from ._compat import getcwdu
-from ._compat import unicode
-
 
 __all__ = [
     'DummyAuthorizer',
@@ -110,8 +105,6 @@ def add_user(
         """
         if self.has_user(username):
             raise ValueError('user %r already exists' % username)
-        if not isinstance(homedir, unicode):
-            homedir = homedir.decode('utf8')
         if not os.path.isdir(homedir):
             raise ValueError('no such directory: %r' % homedir)
         homedir = os.path.realpath(homedir)
@@ -353,8 +346,6 @@ def override_user(
             raise AuthorizerError("can't assign password to anonymous user")
         if not self.has_user(username):
             raise AuthorizerError('no such user %s' % username)
-        if homedir is not None and not isinstance(homedir, unicode):
-            homedir = homedir.decode('utf8')
 
         if username in self._dummy_authorizer.user_table:
             # re-set parameters
@@ -362,7 +353,7 @@ def override_user(
         self._dummy_authorizer.add_user(
             username,
             password or "",
-            homedir or getcwdu(),
+            homedir or os.getcwd(),
             perm or "",
             msg_login or "",
             msg_quit or "",
@@ -484,19 +475,13 @@ def has_user(self, username):
         def get_home_dir(self, username):
             """Return user home directory."""
             try:
-                home = pwd.getpwnam(username).pw_dir
+                return pwd.getpwnam(username).pw_dir
             except KeyError:
                 raise AuthorizerError(self.msg_no_such_user)
-            else:
-                if not PY3:
-                    home = home.decode('utf8')
-                return home
 
         @staticmethod
         def _get_system_users():
             """Return all users defined on the UNIX system."""
-            # there should be no need to convert usernames to unicode
-            # as UNIX does not allow chars outside of ASCII set
             return [entry.pw_name for entry in pwd.getpwall()]
 
         def get_msg_login(self, username):
@@ -664,10 +649,8 @@ def _has_valid_shell(username):
             """
             try:
                 file = open('/etc/shells')
-            except IOError as err:
-                if err.errno == errno.ENOENT:
-                    return True
-                raise
+            except FileNotFoundError:
+                return True
             else:
                 with file:
                     try:
@@ -697,10 +680,7 @@ def _has_valid_shell(username):
 except ImportError:
     pass
 else:  # pragma: no cover
-    if PY3:
-        import winreg
-    else:
-        import _winreg as winreg
+    import winreg
 
     __all__.extend(['BaseWindowsAuthorizer', 'WindowsAuthorizer'])
 
@@ -773,14 +753,12 @@ def get_home_dir(self, username):
             path += r"\CurrentVersion\ProfileList" + "\\" + sid
             try:
                 key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path)
-            except WindowsError:
+            except OSError:
                 raise AuthorizerError(
                     "No profile directory defined for user %s" % username
                 )
             value = winreg.QueryValueEx(key, "ProfileImagePath")[0]
             home = win32api.ExpandEnvironmentStrings(value)
-            if not PY3 and not isinstance(home, unicode):
-                home = home.decode('utf8')
             return home
 
         @classmethod
@@ -949,6 +927,4 @@ def get_home_dir(self, username):
                 home = overridden_home
             else:
                 home = BaseWindowsAuthorizer.get_home_dir(self, username)
-            if not PY3 and not isinstance(home, unicode):
-                home = home.decode('utf8')
             return home
diff --git a/pyftpdlib/filesystems.py b/pyftpdlib/filesystems.py
index 9e39ff1f..9a9d7ac4 100644
--- a/pyftpdlib/filesystems.py
+++ b/pyftpdlib/filesystems.py
@@ -8,20 +8,12 @@
 import time
 
 
-try:
-    from stat import filemode as _filemode  # PY 3.3
-except ImportError:
-    from tarfile import filemode as _filemode
 try:
     import grp
     import pwd
 except ImportError:
     pwd = grp = None
 
-from ._compat import PY3
-from ._compat import u
-from ._compat import unicode
-
 
 __all__ = ['FilesystemError', 'AbstractedFS']
 
@@ -100,13 +92,12 @@ def __init__(self, root, cmd_channel):
         - (str) root: the user "real" home directory (e.g. '/home/user')
         - (instance) cmd_channel: the FTPHandler class instance.
         """
-        assert isinstance(root, unicode)
         # Set initial current working directory.
         # By default initial cwd is set to "/" to emulate a chroot jail.
         # If a different behavior is desired (e.g. initial cwd = root,
         # to reflect the real filesystem) users overriding this class
         # are responsible to set _cwd attribute as necessary.
-        self._cwd = u('/')
+        self._cwd = '/'
         self._root = root
         self.cmd_channel = cmd_channel
 
@@ -122,12 +113,10 @@ def cwd(self):
 
     @root.setter
     def root(self, path):
-        assert isinstance(path, unicode), path
         self._root = path
 
     @cwd.setter
     def cwd(self, path):
-        assert isinstance(path, unicode), path
         self._cwd = path
 
     # --- Pathname / conversion utilities
@@ -143,7 +132,6 @@ def ftpnorm(self, ftppath):
         Note: directory separators are system independent ("/").
         Pathname returned is always absolutized.
         """
-        assert isinstance(ftppath, unicode), ftppath
         if os.path.isabs(ftppath):
             p = os.path.normpath(ftppath)
         else:
@@ -161,7 +149,7 @@ def ftpnorm(self, ftppath):
         # that self.cwd is not absolute, return "/" as a safety measure.
         # This is for extra protection, maybe not really necessary.
         if not os.path.isabs(p):
-            p = u("/")
+            p = "/"
         return p
 
     def ftp2fs(self, ftppath):
@@ -175,7 +163,6 @@ def ftp2fs(self, ftppath):
 
         Note: directory separators are system dependent.
         """
-        assert isinstance(ftppath, unicode), ftppath
         # as far as I know, it should always be path traversal safe...
         if os.path.normpath(self.root) == os.sep:
             return os.path.normpath(self.ftpnorm(ftppath))
@@ -198,13 +185,12 @@ def fs2ftp(self, fspath):
         On invalid pathnames escaping from user's root directory
         (e.g. "/home" when root is "/home/user") always return "/".
         """
-        assert isinstance(fspath, unicode), fspath
         if os.path.isabs(fspath):
             p = os.path.normpath(fspath)
         else:
             p = os.path.normpath(os.path.join(self.root, fspath))
         if not self.validpath(p):
-            return u('/')
+            return '/'
         p = p.replace(os.sep, "/")
         p = p[len(self.root) :]
         if not p.startswith('/'):
@@ -221,7 +207,6 @@ def validpath(self, path):
         Pathnames escaping from user's root directory are considered
         not valid.
         """
-        assert isinstance(path, unicode), path
         root = self.realpath(self.root)
         path = self.realpath(path)
         if not root.endswith(os.sep):
@@ -234,7 +219,6 @@ def validpath(self, path):
 
     def open(self, filename, mode):
         """Open a file returning its handler."""
-        assert isinstance(filename, unicode), filename
         return open(filename, mode)
 
     def mkstemp(self, suffix='', prefix='', dir=None, mode='wb'):
@@ -266,52 +250,41 @@ def chdir(self, path):
         it is vital that `cwd` attribute gets set.
         """
         # note: process cwd will be reset by the caller
-        assert isinstance(path, unicode), path
         os.chdir(path)
         self.cwd = self.fs2ftp(path)
 
     def mkdir(self, path):
         """Create the specified directory."""
-        assert isinstance(path, unicode), path
         os.mkdir(path)
 
     def listdir(self, path):
         """List the content of a directory."""
-        assert isinstance(path, unicode), path
         return os.listdir(path)
 
     def listdirinfo(self, path):
         """List the content of a directory."""
-        assert isinstance(path, unicode), path
         return os.listdir(path)
 
     def rmdir(self, path):
         """Remove the specified directory."""
-        assert isinstance(path, unicode), path
         os.rmdir(path)
 
     def remove(self, path):
         """Remove the specified file."""
-        assert isinstance(path, unicode), path
         os.remove(path)
 
     def rename(self, src, dst):
         """Rename the specified src file to the dst filename."""
-        assert isinstance(src, unicode), src
-        assert isinstance(dst, unicode), dst
         os.rename(src, dst)
 
     def chmod(self, path, mode):
         """Change file/directory mode."""
-        assert isinstance(path, unicode), path
         if not hasattr(os, 'chmod'):
             raise NotImplementedError
         os.chmod(path, mode)
 
     def stat(self, path):
         """Perform a stat() system call on the given path."""
-        # on python 2 we might also get bytes from os.lisdir()
-        # assert isinstance(path, unicode), path
         return os.stat(path)
 
     def utime(self, path, timeval):
@@ -324,8 +297,6 @@ def utime(self, path, timeval):
 
         def lstat(self, path):
             """Like stat but does not follow symbolic links."""
-            # on python 2 we might also get bytes from os.lisdir()
-            # assert isinstance(path, unicode), path
             return os.lstat(path)
 
     else:
@@ -337,35 +308,29 @@ def readlink(self, path):
             """Return a string representing the path to which a
             symbolic link points.
             """
-            assert isinstance(path, unicode), path
             return os.readlink(path)
 
     # --- Wrapper methods around os.path.* calls
 
     def isfile(self, path):
         """Return True if path is a file."""
-        assert isinstance(path, unicode), path
         return os.path.isfile(path)
 
     def islink(self, path):
         """Return True if path is a symbolic link."""
-        assert isinstance(path, unicode), path
         return os.path.islink(path)
 
     def isdir(self, path):
         """Return True if path is a directory."""
-        assert isinstance(path, unicode), path
         return os.path.isdir(path)
 
     def getsize(self, path):
         """Return the size of the specified file in bytes."""
-        assert isinstance(path, unicode), path
         return os.path.getsize(path)
 
     def getmtime(self, path):
         """Return the last modified time as a number of seconds since
         the epoch."""
-        assert isinstance(path, unicode), path
         return os.path.getmtime(path)
 
     def realpath(self, path):
@@ -373,14 +338,12 @@ def realpath(self, path):
         symbolic links encountered in the path (if they are
         supported by the operating system).
         """
-        assert isinstance(path, unicode), path
         return os.path.realpath(path)
 
     def lexists(self, path):
         """Return True if path refers to an existing path, including
         a broken or circular symbolic link.
         """
-        assert isinstance(path, unicode), path
         return os.path.lexists(path)
 
     if pwd is not None:
@@ -449,7 +412,6 @@ def get_user_by_uid(uid):
         def get_group_by_gid(gid):
             return self.get_group_by_gid(gid)
 
-        assert isinstance(basedir, unicode), basedir
         if self.cmd_channel.use_gmt_times:
             timefunc = time.gmtime
         else:
@@ -458,20 +420,7 @@ def get_group_by_gid(gid):
         readlink = getattr(self, 'readlink', None)
         now = time.time()
         for basename in listing:
-            if not PY3:
-                try:
-                    file = os.path.join(basedir, basename)
-                except UnicodeDecodeError:
-                    # (Python 2 only) might happen on filesystem not
-                    # supporting UTF8 meaning os.listdir() returned a list
-                    # of mixed bytes and unicode strings:
-                    # http://goo.gl/6DLHD
-                    # http://bugs.python.org/issue683592
-                    file = os.path.join(bytes(basedir), bytes(basename))
-                    if not isinstance(basename, unicode):
-                        basename = unicode(basename, 'utf8', 'ignore')
-            else:
-                file = os.path.join(basedir, basename)
+            file = os.path.join(basedir, basename)
             try:
                 st = self.lstat(file)
             except (OSError, FilesystemError):
@@ -479,7 +428,7 @@ def get_group_by_gid(gid):
                     continue
                 raise
 
-            perms = _filemode(st.st_mode)  # permissions
+            perms = stat.filemode(st.st_mode)  # permissions
             nlinks = st.st_nlink  # number of links to inode
             if not nlinks:  # non-posix system, let's use a bogus value
                 nlinks = 1
@@ -555,7 +504,6 @@ def format_mlsx(self, basedir, listing, perms, facts, ignore_err=True):
         type=dir;size=0;perm=el;modify=20071127230206;unique=801e33; ebooks
         type=file;size=211;perm=r;modify=20071103093626;unique=192; module.py
         """
-        assert isinstance(basedir, unicode), basedir
         if self.cmd_channel.use_gmt_times:
             timefunc = time.gmtime
         else:
@@ -577,20 +525,7 @@ def format_mlsx(self, basedir, listing, perms, facts, ignore_err=True):
         show_unique = 'unique' in facts
         for basename in listing:
             retfacts = {}
-            if not PY3:
-                try:
-                    file = os.path.join(basedir, basename)
-                except UnicodeDecodeError:
-                    # (Python 2 only) might happen on filesystem not
-                    # supporting UTF8 meaning os.listdir() returned a list
-                    # of mixed bytes and unicode strings:
-                    # http://goo.gl/6DLHD
-                    # http://bugs.python.org/issue683592
-                    file = os.path.join(bytes(basedir), bytes(basename))
-                    if not isinstance(basename, unicode):
-                        basename = unicode(basename, 'utf8', 'ignore')
-            else:
-                file = os.path.join(basedir, basename)
+            file = os.path.join(basedir, basename)
             # in order to properly implement 'unique' fact (RFC-3659,
             # chapter 7.5.2) we are supposed to follow symlinks, hence
             # use os.stat() instead of os.lstat()
diff --git a/pyftpdlib/handlers.py b/pyftpdlib/handlers.py
index afd78742..a0040dc7 100644
--- a/pyftpdlib/handlers.py
+++ b/pyftpdlib/handlers.py
@@ -12,7 +12,6 @@
 import sys
 import time
 import traceback
-import warnings
 from datetime import datetime
 
 
@@ -27,20 +26,8 @@
 except ImportError:
     SSL = None
 
-try:
-    from collections import OrderedDict  # python >= 2.7
-except ImportError:
-    OrderedDict = dict
-
 from . import __ver__
-from ._compat import PY3
-from ._compat import PermissionError
-from ._compat import b
-from ._compat import getcwdu
-from ._compat import super
-from ._compat import u
-from ._compat import unicode
-from ._compat import xrange
+from . import _asynchat as asynchat
 from .authorizers import AuthenticationFailed
 from .authorizers import AuthorizerError
 from .authorizers import DummyAuthorizer
@@ -57,38 +44,9 @@
 from .log import logger
 
 
-if PY3:
-    from . import _asynchat as asynchat
-else:
-    import asynchat
-
-
 CR_BYTE = ord('\r')
 
 
-def _import_sendfile():
-    # By default attempt to use os.sendfile introduced in Python 3.3:
-    # http://bugs.python.org/issue10882
-    # ...otherwise fallback on using third-party pysendfile module:
-    # https://github.com/giampaolo/pysendfile/
-    if os.name == 'posix':
-        try:
-            return os.sendfile  # py >= 3.3
-        except AttributeError:
-            try:
-                import sendfile as sf
-
-                # dirty hack to detect whether old 1.2.4 version is installed
-                if hasattr(sf, 'has_sf_hdtr'):
-                    raise ImportError
-                return sf.sendfile
-            except ImportError:
-                pass
-    return None
-
-
-sendfile = _import_sendfile()
-
 proto_cmds = {
     'ABOR': dict(
         perm=None, auth=True, arg=False, help='Syntax: ABOR (abort transfer).'
@@ -403,7 +361,7 @@ def _support_hybrid_ipv6():
             return False
         with contextlib.closing(socket.socket(socket.AF_INET6)) as sock:
             return not sock.getsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY)
-    except (socket.error, AttributeError):
+    except (OSError, AttributeError):
         return False
 
 
@@ -483,7 +441,7 @@ def __init__(self, cmd_channel, extmode=False):
                         "ignoring EPERM when bind()ing port %s" % port,
                         logfun=logger.debug,
                     )
-                except socket.error as err:
+                except OSError as err:
                     if err.errno == errno.EADDRINUSE:  # port already in use
                         if ports:
                             continue
@@ -548,7 +506,7 @@ def handle_accepted(self, sock, addr):
             if not self.cmd_channel.permit_foreign_addresses:
                 try:
                     sock.close()
-                except socket.error:
+                except OSError:
                     pass
                 msg = (
                     '425 Rejected data connection from foreign address '
@@ -637,7 +595,7 @@ def __init__(self, ip, port, cmd_channel):
         # dual stack IPv4/IPv6 support
         try:
             self.connect_af_unspecified((ip, port), (source_ip, 0))
-        except (socket.gaierror, socket.error):
+        except (socket.gaierror, OSError):
             self.handle_close()
 
     def readable(self):
@@ -650,17 +608,13 @@ def handle_connect(self):
             self._idler.cancel()
         if not self.cmd_channel.connected:
             return self.close()
-        # fix for asyncore on python < 2.6, meaning we aren't
-        # actually connected.
         # test_active_conn_error tests this condition
         err = self.socket.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR)
         if err != 0:
-            raise socket.error(err)
-        #
+            raise OSError(err)
         msg = 'Active data connection established.'
         self.cmd_channel.respond('200 ' + msg)
         self.cmd_channel.log_cmd(self._cmd, self._normalized_addr, 200, msg)
-        #
         if not self.cmd_channel.connected:
             return self.close()
         # delegate such connection to DTP handler
@@ -694,7 +648,7 @@ def handle_error(self):
         """Called to handle any uncaught exceptions."""
         try:
             raise  # noqa: PLE0704
-        except (socket.gaierror, socket.error):
+        except (socket.gaierror, OSError):
             pass
         except Exception:
             self.log_exception(self)
@@ -759,7 +713,7 @@ def __init__(self, sock, cmd_channel):
         self._initialized = False
         try:
             AsyncChat.__init__(self, sock, ioloop=cmd_channel.ioloop)
-        except socket.error as err:
+        except OSError as err:
             # if we get an exception here we want the dispatcher
             # instance to set socket attribute before closing, see:
             # https://github.com/giampaolo/pyftpdlib/issues/188
@@ -842,7 +796,7 @@ def initiate_send(self):
     def initiate_sendfile(self):
         """A wrapper around sendfile."""
         try:
-            sent = sendfile(
+            sent = os.sendfile(
                 self._fileno,
                 self._filefd,
                 self._offset,
@@ -886,7 +840,7 @@ def _posix_ascii_data_wrapper(self, chunk):
         else:
             self._had_cr = False
 
-        return chunk.replace(b'\r\n', b(os.linesep))
+        return chunk.replace(b'\r\n', bytes(os.linesep, "ascii"))
 
     def enable_receiving(self, type, cmd):
         """Enable receiving of data over the channel. Depending on the
@@ -964,7 +918,7 @@ def handle_read(self):
             chunk = self.recv(self.ac_in_buffer_size)
         except RetryError:
             pass
-        except socket.error:
+        except OSError:
             self.handle_error()
         else:
             self.tot_bytes_received += len(chunk)
@@ -1096,22 +1050,7 @@ def close(self):
             self.cmd_channel._on_dtp_close()
 
 
-# dirty hack in order to turn AsyncChat into a new style class in
-# python 2.x so that we can use super()
-if PY3:
-
-    class _AsyncChatNewStyle(AsyncChat):
-        pass
-
-else:
-
-    class _AsyncChatNewStyle(object, AsyncChat):  # noqa
-
-        def __init__(self, *args, **kwargs):
-            super(object, self).__init__(*args, **kwargs)  # bypass object
-
-
-class ThrottledDTPHandler(_AsyncChatNewStyle, DTPHandler):
+class ThrottledDTPHandler(DTPHandler):
     """A DTPHandler subclass which wraps sending and receiving in a data
     counter and temporarily "sleeps" the channel so that you burst to no
     more than x Kb/sec average.
@@ -1270,7 +1209,7 @@ def more(self):
         its next() method different times.
         """
         buffer = []
-        for _ in xrange(self.loops):
+        for _ in range(self.loops):
             try:
                 buffer.append(next(self.iterator))
             except StopIteration:
@@ -1392,7 +1331,7 @@ class FTPHandler(AsyncChat):
     masquerade_address_map = {}
     passive_ports = None
     use_gmt_times = True
-    use_sendfile = sendfile is not None
+    use_sendfile = hasattr(os, "sendfile")  # added in python 3.3
     tcp_no_delay = hasattr(socket, "TCP_NODELAY")
     unicode_errors = 'replace'
     log_prefix = '%(remote_ip)s:%(remote_port)s-[%(username)s]'
@@ -1447,7 +1386,7 @@ def __init__(self, conn, server, ioloop=None):
 
         try:
             AsyncChat.__init__(self, conn, ioloop=ioloop)
-        except socket.error as err:
+        except OSError as err:
             # if we get an exception here we want the dispatcher
             # instance to set socket attribute before closing, see:
             # https://github.com/giampaolo/pyftpdlib/issues/188
@@ -1464,7 +1403,7 @@ def __init__(self, conn, server, ioloop=None):
         # connection properties
         try:
             self.remote_ip, self.remote_port = self.socket.getpeername()[:2]
-        except socket.error as err:
+        except OSError as err:
             debug(
                 "call: FTPHandler.__init__, err on getpeername() %r" % err,
                 self,
@@ -1484,7 +1423,7 @@ def __init__(self, conn, server, ioloop=None):
         # try to handle urgent data inline
         try:
             self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_OOBINLINE, 1)
-        except socket.error as err:
+        except OSError as err:
             debug(
                 "call: FTPHandler.__init__, err on SO_OOBINLINE %r" % err, self
             )
@@ -1494,7 +1433,7 @@ def __init__(self, conn, server, ioloop=None):
         if self.tcp_no_delay:
             try:
                 self.socket.setsockopt(socket.SOL_TCP, socket.TCP_NODELAY, 1)
-            except socket.error as err:
+            except OSError as err:
                 debug(
                     "call: FTPHandler.__init__, err on TCP_NODELAY %r" % err,
                     self,
@@ -1513,7 +1452,7 @@ def __init__(self, conn, server, ioloop=None):
     def get_repr_info(self, as_str=False, extra_info=None):
         if extra_info is None:
             extra_info = {}
-        info = OrderedDict()
+        info = {}
         info['id'] = id(self)
         info['addr'] = "%s:%s" % (self.remote_ip, self.remote_port)
         if _is_ssl_sock(self.socket):
@@ -1687,16 +1626,16 @@ def pre_process_command(self, line, cmd, arg):
                 return
         else:
             if (cmd == 'STAT') and not arg:
-                self.ftp_STAT(u(''))
+                self.ftp_STAT('')
                 return
 
             # for file-system related commands check whether real path
             # destination is valid
             if self.proto_cmds[cmd]['perm'] and (cmd != 'STOU'):
                 if cmd in ('CWD', 'XCWD'):
-                    arg = self.fs.ftp2fs(arg or u('/'))
+                    arg = self.fs.ftp2fs(arg or '/')
                 elif cmd in ('CDUP', 'XCUP'):
-                    arg = self.fs.ftp2fs(u('..'))
+                    arg = self.fs.ftp2fs('..')
                 elif cmd == 'LIST':
                     if arg.lower() in ('-a', '-l', '-al', '-la'):
                         arg = self.fs.ftp2fs(self.fs.cwd)
@@ -2376,17 +2315,8 @@ def ftp_LIST(self, path):
             isdir = self.fs.isdir(path)
             if isdir:
                 listing = self.run_as_current_user(self.fs.listdir, path)
-                if isinstance(listing, list):
-                    try:
-                        # RFC 959 recommends the listing to be sorted.
-                        listing.sort()
-                    except UnicodeDecodeError:
-                        # (Python 2 only) might happen on filesystem not
-                        # supporting UTF8 meaning os.listdir() returned a list
-                        # of mixed bytes and unicode strings:
-                        # http://goo.gl/6DLHD
-                        # http://bugs.python.org/issue683592
-                        pass
+                # RFC 959 recommends the listing to be sorted.
+                listing.sort()
                 iterator = self.fs.format_list(path, listing)
             else:
                 basedir, filename = os.path.split(path)
@@ -2417,20 +2347,8 @@ def ftp_NLST(self, path):
         else:
             data = ''
             if listing:
-                try:
-                    listing.sort()
-                except UnicodeDecodeError:
-                    # (Python 2 only) might happen on filesystem not
-                    # supporting UTF8 meaning os.listdir() returned a list
-                    # of mixed bytes and unicode strings:
-                    # http://goo.gl/6DLHD
-                    # http://bugs.python.org/issue683592
-                    ls = []
-                    for x in listing:
-                        if not isinstance(x, unicode):
-                            x = unicode(x, 'utf8')
-                        ls.append(x)
-                    listing = sorted(ls)
+                # RFC 959 recommends the listing to be sorted.
+                listing.sort()
                 data = '\r\n'.join(listing) + '\r\n'
             data = data.encode('utf8', self.unicode_errors)
             self.push_dtp_data(data, cmd="NLST")
@@ -2506,7 +2424,7 @@ def ftp_RETR(self, file):
         self._restart_position = 0
         try:
             fd = self.run_as_current_user(self.fs.open, file, 'rb')
-        except (EnvironmentError, FilesystemError) as err:
+        except (OSError, FilesystemError) as err:
             why = _strerror(err)
             self.respond('550 %s.' % why)
             return
@@ -2530,7 +2448,7 @@ def ftp_RETR(self, file):
                         rest_pos,
                         fsize,
                     )
-                except (EnvironmentError, FilesystemError) as err:
+                except (OSError, FilesystemError) as err:
                     why = _strerror(err)
                 if not ok:
                     fd.close()
@@ -2559,7 +2477,7 @@ def ftp_STOR(self, file, mode='w'):
             mode = 'r+'
         try:
             fd = self.run_as_current_user(self.fs.open, file, mode + 'b')
-        except (EnvironmentError, FilesystemError) as err:
+        except (OSError, FilesystemError) as err:
             why = _strerror(err)
             self.respond('550 %s.' % why)
             return
@@ -2583,7 +2501,7 @@ def ftp_STOR(self, file, mode='w'):
                         rest_pos,
                         fsize,
                     )
-                except (EnvironmentError, FilesystemError) as err:
+                except (OSError, FilesystemError) as err:
                     why = _strerror(err)
                 if not ok:
                     fd.close()
@@ -2633,7 +2551,7 @@ def ftp_STOU(self, line):
             fd = self.run_as_current_user(
                 self.fs.mkstemp, prefix=prefix, dir=basedir
             )
-        except (EnvironmentError, FilesystemError) as err:
+        except (OSError, FilesystemError) as err:
             # likely, we hit the max number of retries to find out a
             # file with a unique name
             if getattr(err, "errno", -1) == errno.EEXIST:
@@ -2790,19 +2708,6 @@ def callback(username, password, msg):
         self.username = ""
 
     def handle_auth_success(self, home, password, msg_login):
-        if not isinstance(home, unicode):
-            if PY3:
-                raise TypeError('type(home) != text')
-            else:
-                warnings.warn(
-                    '%s.get_home_dir returned a non-unicode string; now '
-                    'casting to unicode'
-                    % (self.authorizer.__class__.__name__),
-                    RuntimeWarning,
-                    stacklevel=2,
-                )
-                home = home.decode('utf8')
-
         if len(msg_login) <= 75:
             self.respond('230 %s' % msg_login)
         else:
@@ -2856,7 +2761,6 @@ def ftp_PWD(self, line):
         # name and in case it contains embedded double-quotes
         # they must be doubled (see RFC-959, chapter 7, appendix 2).
         cwd = self.fs.cwd
-        assert isinstance(cwd, unicode), cwd
         self.respond(
             '257 "%s" is the current directory.' % cwd.replace('"', '""')
         )
@@ -2872,7 +2776,7 @@ def ftp_CWD(self, path):
         # the process is started we'll get into troubles (os.getcwd()
         # will fail with ENOENT) but we can't do anything about that
         # except logging an error.
-        init_cwd = getcwdu()
+        init_cwd = os.getcwd()
         try:
             self.run_as_current_user(self.fs.chdir, path)
         except (OSError, FilesystemError) as err:
@@ -2880,9 +2784,8 @@ def ftp_CWD(self, path):
             self.respond('550 %s.' % why)
         else:
             cwd = self.fs.cwd
-            assert isinstance(cwd, unicode), cwd
             self.respond('250 "%s" is the current directory.' % cwd)
-            if getcwdu() != init_cwd:
+            if os.getcwd() != init_cwd:
                 os.chdir(init_cwd)
             return path
 
@@ -3174,16 +3077,8 @@ def ftp_STAT(self, path):
                 if isdir:
                     listing = self.run_as_current_user(self.fs.listdir, path)
                     if isinstance(listing, list):
-                        try:
-                            # RFC 959 recommends the listing to be sorted.
-                            listing.sort()
-                        except UnicodeDecodeError:
-                            # (Python 2 only) might happen on filesystem not
-                            # supporting UTF8 meaning os.listdir() returned a
-                            # list of mixed bytes and unicode strings:
-                            # http://goo.gl/6DLHD
-                            # http://bugs.python.org/issue683592
-                            pass
+                        # RFC 959 recommends the listing to be sorted.
+                        listing.sort()
                     iterator = self.fs.format_list(path, listing)
                 else:
                     basedir, filename = os.path.split(path)
@@ -3200,7 +3095,7 @@ def ftp_STAT(self, path):
 
     def ftp_FEAT(self, line):
         """List all new features supported as defined in RFC-2398."""
-        features = set(['UTF8', 'TVFS'])
+        features = {'UTF8', 'TVFS'}
         features.update(
             [
                 feat
@@ -3374,7 +3269,7 @@ def ftp_XRMD(self, line):
 
 if SSL is not None:
 
-    class SSLConnection(_AsyncChatNewStyle):
+    class SSLConnection:
         """An AsyncChat subclass supporting TLS/SSL."""
 
         _ssl_accepting = False
@@ -3406,7 +3301,7 @@ def secure_connection(self, ssl_context):
             self._ssl_requested = True
             try:
                 self.socket = SSL.Connection(ssl_context, self.socket)
-            except socket.error as err:
+            except OSError as err:
                 # may happen in case the client connects/disconnects
                 # very quickly
                 debug(
@@ -3613,7 +3508,7 @@ def _do_ssl_shutdown(self):
                 # connection has gone away
                 try:
                     os.write(self.socket.fileno(), b'')
-                except (OSError, socket.error) as err:
+                except OSError as err:
                     debug(
                         "call: _do_ssl_shutdown() -> os.write, err: %r" % err,
                         inst=self,
@@ -3688,7 +3583,7 @@ def _do_ssl_shutdown(self):
                     pass
                 else:
                     raise
-            except socket.error as err:
+            except OSError as err:
                 debug(
                     "call: _do_ssl_shutdown() -> shutdown(), err: %r" % err,
                     inst=self,
diff --git a/pyftpdlib/ioloop.py b/pyftpdlib/ioloop.py
index 8ac317f8..f0fa46b5 100644
--- a/pyftpdlib/ioloop.py
+++ b/pyftpdlib/ioloop.py
@@ -62,49 +62,32 @@ def handle_accepted(self, sock, addr):
 import select
 import socket
 import sys
+import threading
 import time
 import traceback
 
-from ._compat import PY3
-from ._compat import InterruptedError
-
-
-try:
-    import threading
-except ImportError:
-    import dummy_threading as threading
-
-from ._compat import callable
+from . import _asynchat as asynchat
+from . import _asyncore as asyncore
 from .log import config_logging
 from .log import debug
 from .log import is_logging_configured
 from .log import logger
 
 
-if PY3:
-    from . import _asynchat as asynchat
-    from . import _asyncore as asyncore
-else:
-    import asynchat
-    import asyncore
-
-
 timer = getattr(time, 'monotonic', time.time)
 _read = asyncore.read
 _write = asyncore.write
 
 # These errnos indicate that a connection has been abruptly terminated.
-_ERRNOS_DISCONNECTED = set(
-    (
-        errno.ECONNRESET,
-        errno.ENOTCONN,
-        errno.ESHUTDOWN,
-        errno.ECONNABORTED,
-        errno.EPIPE,
-        errno.EBADF,
-        errno.ETIMEDOUT,
-    )
-)
+_ERRNOS_DISCONNECTED = {
+    errno.ECONNRESET,
+    errno.ENOTCONN,
+    errno.ESHUTDOWN,
+    errno.ECONNABORTED,
+    errno.EPIPE,
+    errno.EBADF,
+    errno.ETIMEDOUT,
+}
 if hasattr(errno, "WSAECONNRESET"):
     _ERRNOS_DISCONNECTED.add(errno.WSAECONNRESET)
 if hasattr(errno, "WSAECONNABORTED"):
@@ -112,7 +95,7 @@ def handle_accepted(self, sock, addr):
 
 # These errnos indicate that a non-blocking operation must be retried
 # at a later time.
-_ERRNOS_RETRY = set((errno.EAGAIN, errno.EWOULDBLOCK))
+_ERRNOS_RETRY = {errno.EAGAIN, errno.EWOULDBLOCK}
 if hasattr(errno, "WSAEWOULDBLOCK"):
     _ERRNOS_RETRY.add(errno.WSAEWOULDBLOCK)
 
@@ -521,11 +504,8 @@ def __init__(self):
     def register(self, fd, instance, events):
         try:
             self._poller.register(fd, events)
-        except EnvironmentError as err:
-            if err.errno == errno.EEXIST:
-                debug("call: register(); poller raised EEXIST; ignored", self)
-            else:
-                raise
+        except FileExistsError:
+            debug("call: register(); poller raised EEXIST; ignored", self)
         self.socket_map[fd] = instance
 
     def unregister(self, fd):
@@ -536,7 +516,7 @@ def unregister(self, fd):
         else:
             try:
                 self._poller.unregister(fd)
-            except EnvironmentError as err:
+            except OSError as err:
                 if err.errno in (errno.ENOENT, errno.EBADF):
                     debug(
                         "call: unregister(); poller returned %r; ignoring it"
@@ -549,8 +529,8 @@ def unregister(self, fd):
     def modify(self, fd, events):
         try:
             self._poller.modify(fd, events)
-        except OSError as err:
-            if err.errno == errno.ENOENT and fd in self.socket_map:
+        except FileNotFoundError:
+            if fd in self.socket_map:
                 # XXX - see:
                 # https://github.com/giampaolo/pyftpdlib/issues/329
                 instance = self.socket_map[fd]
@@ -695,13 +675,8 @@ def register(self, fd, instance, events):
             self.socket_map[fd] = instance
             try:
                 self._control(fd, events, select.KQ_EV_ADD)
-            except EnvironmentError as err:
-                if err.errno == errno.EEXIST:
-                    debug(
-                        "call: register(); poller raised EEXIST; ignored", self
-                    )
-                else:
-                    raise
+            except FileExistsError:
+                debug("call: register(); poller raised EEXIST; ignored", self)
             self._active[fd] = events
 
         def unregister(self, fd):
@@ -713,7 +688,7 @@ def unregister(self, fd):
             else:
                 try:
                     self._control(fd, events, select.KQ_EV_DELETE)
-                except EnvironmentError as err:
+                except OSError as err:
                     if err.errno in (errno.ENOENT, errno.EBADF):
                         debug(
                             "call: unregister(); poller returned %r; "
@@ -931,7 +906,7 @@ def connect_af_unspecified(self, addr, source_address=None):
                         )
                     self.bind(source_address)
                 self.connect((host, port))
-            except socket.error as _:
+            except OSError as _:
                 err = _
                 if self.socket is not None:
                     self.socket.close()
@@ -941,7 +916,7 @@ def connect_af_unspecified(self, addr, source_address=None):
             break
         if self.socket is None:
             self.del_channel()
-            raise socket.error(err)
+            raise OSError(err)
         return af
 
     # send() and recv() overridden as a fix around various bugs:
@@ -952,7 +927,7 @@ def connect_af_unspecified(self, addr, source_address=None):
     def send(self, data):
         try:
             return self.socket.send(data)
-        except socket.error as err:
+        except OSError as err:
             debug("call: send(), err: %s" % err, inst=self)
             if err.errno in _ERRNOS_RETRY:
                 return 0
@@ -965,7 +940,7 @@ def send(self, data):
     def recv(self, buffer_size):
         try:
             data = self.socket.recv(buffer_size)
-        except socket.error as err:
+        except OSError as err:
             debug("call: recv(), err: %s" % err, inst=self)
             if err.errno in _ERRNOS_DISCONNECTED:
                 self.handle_close()
@@ -1080,7 +1055,7 @@ def bind_af_unspecified(self, addr):
                 self.create_socket(af, socktype)
                 self.set_reuse_addr()
                 self.bind(sa)
-            except socket.error as _:
+            except OSError as _:
                 err = _
                 if self.socket is not None:
                     self.socket.close()
@@ -1090,7 +1065,7 @@ def bind_af_unspecified(self, addr):
             break
         if self.socket is None:
             self.del_channel()
-            raise socket.error(err)
+            raise OSError(err)
         return af
 
     def listen(self, num):
@@ -1111,7 +1086,7 @@ def handle_accept(self):
             # https://github.com/giampaolo/pyftpdlib/issues/91
             debug("call: handle_accept(); accept() returned None", self)
             return
-        except socket.error as err:
+        except OSError as err:
             # ECONNABORTED might be thrown on *BSD, see:
             # https://github.com/giampaolo/pyftpdlib/issues/105
             if err.errno != errno.ECONNABORTED:
diff --git a/pyftpdlib/log.py b/pyftpdlib/log.py
index cdaed866..905172a9 100644
--- a/pyftpdlib/log.py
+++ b/pyftpdlib/log.py
@@ -21,9 +21,6 @@
 except ImportError:
     curses = None
 
-from ._compat import PY3
-from ._compat import unicode
-
 
 # default logger
 logger = logging.getLogger('pyftpdlib')
@@ -71,24 +68,22 @@ def __init__(self, *args, **kwargs):
             # bytes, but only accept strings. In addition, we want to
             # output these strings with the logging module, which
             # works with unicode strings. The explicit calls to
-            # unicode() below are harmless in python2 but will do the
+            # str() below are harmless in python2 but will do the
             # right conversion in python 3.
             fg_color = (
                 curses.tigetstr("setaf") or curses.tigetstr("setf") or ""
             )
-            if not PY3:
-                fg_color = unicode(fg_color, "ascii")
             self._colors = {
                 # blues
-                logging.DEBUG: unicode(curses.tparm(fg_color, 4), "ascii"),
+                logging.DEBUG: str(curses.tparm(fg_color, 4), "ascii"),
                 # green
-                logging.INFO: unicode(curses.tparm(fg_color, 2), "ascii"),
+                logging.INFO: str(curses.tparm(fg_color, 2), "ascii"),
                 # yellow
-                logging.WARNING: unicode(curses.tparm(fg_color, 3), "ascii"),
+                logging.WARNING: str(curses.tparm(fg_color, 3), "ascii"),
                 # red
-                logging.ERROR: unicode(curses.tparm(fg_color, 1), "ascii"),
+                logging.ERROR: str(curses.tparm(fg_color, 1), "ascii"),
             }
-            self._normal = unicode(curses.tigetstr("sgr0"), "ascii")
+            self._normal = str(curses.tigetstr("sgr0"), "ascii")
 
     def format(self, record):
         try:
@@ -124,7 +119,7 @@ def format(self, record):
         # result are so useless (and tornado is fond of using utf8-encoded
         # byte strings wherever possible).
         try:
-            message = unicode(record.message)
+            message = str(record.message)
         except UnicodeDecodeError:
             message = repr(record.message)
 
diff --git a/pyftpdlib/prefork.py b/pyftpdlib/prefork.py
index e6700a28..de97dee1 100644
--- a/pyftpdlib/prefork.py
+++ b/pyftpdlib/prefork.py
@@ -5,6 +5,7 @@
 """Process utils."""
 
 import os
+import random
 import sys
 import time
 from binascii import hexlify
@@ -15,8 +16,6 @@
 except ImportError:
     multiprocessing = None
 
-from ._compat import InterruptedError
-from ._compat import long
 from .log import logger
 
 
@@ -39,15 +38,12 @@ def cpu_count():
 
 
 def _reseed_random():
-    if 'random' not in sys.modules:
-        return
-    import random
 
     # If os.urandom is available, this method does the same thing as
     # random.seed.  If os.urandom is not available, we mix in the pid in
     # addition to a timestamp.
     try:
-        seed = long(hexlify(os.urandom(16)), 16)
+        seed = int(hexlify(os.urandom(16)), 16)
     except NotImplementedError:
         seed = int(time.time() * 1000) ^ os.getpid()
     random.seed(seed)
diff --git a/pyftpdlib/servers.py b/pyftpdlib/servers.py
index 21101d8c..1a9015f8 100644
--- a/pyftpdlib/servers.py
+++ b/pyftpdlib/servers.py
@@ -229,7 +229,6 @@ def serve_forever(
         """
         log = handle_exit and blocking
 
-        #
         if worker_processes != 1 and os.name == 'posix':
             if not blocking:
                 raise ValueError(
@@ -242,14 +241,12 @@ def serve_forever(
             if log:
                 self._log_start()
 
-        #
         proto = "FTP+SSL" if hasattr(self.handler, 'ssl_protocol') else "FTP"
         logger.info(
             ">>> starting %s server on %s:%s, pid=%i <<<"
             % (proto, self.address[0], self.address[1], os.getpid())
         )
 
-        #
         if handle_exit:
             try:
                 self.ioloop.loop(timeout, blocking)
@@ -397,7 +394,7 @@ def _loop(self, handler):
             handler.ioloop = ioloop
             try:
                 handler.add_channel()
-            except EnvironmentError as err:
+            except OSError as err:
                 if err.errno == errno.EBADF:
                     # we might get here in case the other end quickly
                     # disconnected (see test_quick_connect())
@@ -449,7 +446,7 @@ def _loop(self, handler):
                         for fd in list(ioloop.socket_map.keys()):
                             try:
                                 select.select([fd], [], [], 0)
-                            except select.error:
+                            except OSError:
                                 try:
                                     logger.info(
                                         "discarding broken socket %r",
@@ -524,9 +521,8 @@ def _terminate_task(self, t):
                     # as the process hangs on kqueue.control() or
                     # select.select(). Use SIGKILL instead.
                     os.kill(t.pid, signal.SIGKILL)
-            except OSError as err:
-                if err.errno != errno.ESRCH:
-                    raise
+            except ProcessLookupError:
+                pass
 
     def _join_task(self, t):
         logger.debug("join()ing task %r" % t)
diff --git a/pyproject.toml b/pyproject.toml
index d4c7df57..e516d0fa 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -15,13 +15,13 @@ line-length = 79
 preview = true
 select = [
     "ALL",  # to get a list of all values: `python3 -m ruff linter`
+    "PLR2044",  # [*] Line with empty comment
 ]
 ignore = [
     "A",  # flake8-builtins
     "ANN",  # flake8-annotations
     "ARG",  # flake8-unused-arguments
     "B007",  # Loop control variable `x` not used within loop body
-    "B904",  # Within an `except` clause, raise exceptions with `raise ... from err` (PYTHON2.7 COMPAT)
     "B904",  # Use `raise from` to specify exception cause (PYTHON2.7 COMPAT)
     "BLE001",  # Do not catch blind exception: `Exception`
     "C4",  # flake8-comprehensions (PYTHON2.7 COMPAT)
@@ -49,7 +49,6 @@ ignore = [
     "N818",  # Exception name `FooBar` should be named with an Error suffix
     "PERF",  # Perflint
     "PGH004",  # Use specific rule codes when using `noqa`
-    "PLC0415",  # `import` should be at the top-level of a file
     "PLC2701",  # Private name import `_winreg`
     "PLR",  # pylint
     "PLW",  # pylint
@@ -69,8 +68,6 @@ ignore = [
     "TRY003",  # Avoid specifying long messages outside the exception class
     "TRY300",  # Consider moving this statement to an `else` block
     "TRY301",  # Abstract `raise` to an inner function
-    "UP010",  # [*] Unnecessary `__future__` import `print_function` for target Python version (PYTHON2.7 COMPAT)
-    "UP024",  # [*] Replace aliased errors with `OSError` (PYTHON2.7 COMPAT)
     "UP031",  # [*] Use format specifiers instead of percent format
 ]
 
@@ -94,7 +91,6 @@ omit = [
 ]
 exclude_lines = [
     "except ImportError:",
-    "if PY3:",
     "if __name__ == .__main__.:",
     "if hasattr(select, 'devpoll'):",
     "if hasattr(select, 'epoll'):",
diff --git a/scripts/ftpbench b/scripts/ftpbench
index 36911841..ed6e4a90 100755
--- a/scripts/ftpbench
+++ b/scripts/ftpbench
@@ -63,9 +63,6 @@ Example usages:
 #   300 concurrent clients (STOR 10.0M file)               9.74 secs    140.9M
 #   300 concurrent clients (QUIT)                          0.00 secs
 
-from __future__ import division
-from __future__ import print_function
-
 import argparse
 import asynchat
 import asyncore
@@ -99,15 +96,8 @@ SERVER_PROC = None
 TIMEOUT = None
 FILE_SIZE = "10M"
 SSL = False
-PY3 = sys.version_info >= (3, 0)
 
 server_memory = []
-# python >= 2.7.9
-SSLWantReadError = getattr(ssl, "SSLWantReadError", object())
-SSLWantWriteError = getattr(ssl, "SSLWantWriteError", object())
-# python <= 2.7.8
-SSL_ERROR_WANT_READ = getattr(ssl, "SSL_ERROR_WANT_READ", object())
-SSL_ERROR_WANT_WRITE = getattr(ssl, "SSL_ERROR_WANT_WRITE", object())
 
 
 if not sys.stdout.isatty() or os.name != 'posix':
@@ -412,15 +402,9 @@ def bench_multi(howmany):
 def handle_ssl_want_rw_errs():
     try:
         yield
-    except (SSLWantReadError, SSLWantWriteError) as err:
+    except (ssl.SSLWantReadError, ssl.SSLWantWriteError) as err:
         if DEBUG:
             print(err)
-    except ssl.SSLError as err:
-        if err.args[0] in (SSL_ERROR_WANT_READ, SSL_ERROR_WANT_WRITE):
-            if DEBUG:
-                print(err)
-        else:
-            raise
 
 
 class AsyncReader(asyncore.dispatcher):
@@ -442,7 +426,7 @@ class AsyncReader(asyncore.dispatcher):
         self.close()
 
     def handle_error(self):
-        raise
+        raise  # noqa
 
 
 class AsyncWriter(asyncore.dispatcher):
@@ -464,7 +448,7 @@ class AsyncWriter(asyncore.dispatcher):
             self.handle_close()
 
     def handle_error(self):
-        raise
+        raise  # noqa
 
 
 class AsyncQuit(asynchat.async_chat):
diff --git a/scripts/internal/generate_manifest.py b/scripts/internal/generate_manifest.py
index 88249a37..f2613c7c 100755
--- a/scripts/internal/generate_manifest.py
+++ b/scripts/internal/generate_manifest.py
@@ -13,7 +13,7 @@
 
 
 SKIP_EXTS = ('.png', '.jpg', '.jpeg', '.svg')
-SKIP_FILES = 'appveyor.yml'
+SKIP_FILES = tuple()
 SKIP_PREFIXES = ('.ci/', '.github/')
 
 
diff --git a/scripts/internal/git_pre_commit.py b/scripts/internal/git_pre_commit.py
index 30331668..a7f3b1ec 100755
--- a/scripts/internal/git_pre_commit.py
+++ b/scripts/internal/git_pre_commit.py
@@ -11,7 +11,6 @@
 install-git-hooks".
 """
 
-from __future__ import print_function
 
 import os
 import shlex
@@ -20,13 +19,12 @@
 
 
 PYTHON = sys.executable
-PY3 = sys.version_info[0] >= 3
 THIS_SCRIPT = os.path.realpath(__file__)
 
 
 def term_supports_colors():
     try:
-        import curses
+        import curses  # noqa: PLC0415
 
         assert sys.stderr.isatty()
         curses.setupterm()
@@ -77,8 +75,7 @@ def sh(cmd):
 
 
 def open_text(path):
-    kw = {"encoding": "utf8"} if PY3 else {}
-    return open(path, **kw)
+    return open(path, encoding="utf8")
 
 
 def git_committed_files():
diff --git a/scripts/internal/winmake.py b/scripts/internal/winmake.py
index ee78907a..f8290155 100755
--- a/scripts/internal/winmake.py
+++ b/scripts/internal/winmake.py
@@ -11,7 +11,6 @@
 that they should be deemed illegal!
 """
 
-from __future__ import print_function
 
 import argparse
 import atexit
@@ -25,15 +24,12 @@
 import subprocess
 import sys
 import tempfile
+from urllib.request import urlopen
 
 
-APPVEYOR = bool(os.environ.get('APPVEYOR'))
-PYTHON = sys.executable if APPVEYOR else os.getenv('PYTHON', sys.executable)
+PYTHON = os.getenv('PYTHON', sys.executable)
 GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py"
-PY3 = sys.version_info[0] >= 3
-PYTEST_ARGS = "-v --tb=native "
-if PY3:
-    PYTEST_ARGS += "-o "
+PYTEST_ARGS = "-v --tb=native -o"
 HERE = os.path.abspath(os.path.dirname(__file__))
 ROOT_DIR = os.path.realpath(os.path.join(HERE, "..", ".."))
 PYPY = '__pypy__' in sys.builtin_module_names
@@ -47,17 +43,7 @@
     "wmi",
 ]
 
-if sys.version_info[:2] == (2, 7):
-    DEPS.extend(
-        [
-            "ipaddress",
-            "mock",
-        ]
-    )
-
 _cmds = {}
-if PY3:
-    basestring = str
 
 GREEN = 2
 LIGHTBLUE = 3
@@ -75,9 +61,8 @@ def safe_print(text, file=sys.stdout):
     """Prints a (unicode) string to the console, encoded depending on
     the stdout/file encoding (eg. cp437 on Windows). This is to avoid
     encoding errors in case of funky path names.
-    Works with Python 2 and 3.
     """
-    if not isinstance(text, basestring):
+    if not isinstance(text, str):
         return print(text, file=file)
     try:
         file.write(text)
@@ -123,15 +108,6 @@ def sh(cmd, nolog=False):
 def rm(pattern, directory=False):
     """Recursively remove a file or dir by pattern."""
 
-    def safe_remove(path):
-        try:
-            os.remove(path)
-        except OSError as err:
-            if err.errno != errno.ENOENT:
-                raise
-        else:
-            safe_print("rm %s" % path)
-
     def safe_rmtree(path):
         def onerror(fun, path, excinfo):
             exc = excinfo[1]
@@ -168,9 +144,8 @@ def onerror(fun, path, excinfo):
 def safe_remove(path):
     try:
         os.remove(path)
-    except OSError as err:
-        if err.errno != errno.ENOENT:
-            raise
+    except FileNotFoundError:
+        pass
     else:
         safe_print("rm %s" % path)
 
@@ -216,11 +191,11 @@ def build():
 
     cmd = [PYTHON, "setup.py", "build"]
     # Print coloured warnings in real time.
-    p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
+    p = subprocess.Popen(
+        cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True
+    )
     try:
-        for line in iter(p.stdout.readline, b''):
-            if PY3:
-                line = line.decode()
+        for line in iter(p.stdout.readline, ''):
             line = line.strip()
             if 'warning' in line:
                 win_colorprint(line, YELLOW)
@@ -259,11 +234,6 @@ def install_pip():
     try:
         sh('%s -c "import pip"' % PYTHON)
     except SystemExit:
-        if PY3:
-            from urllib.request import urlopen
-        else:
-            from urllib2 import urlopen
-
         if hasattr(ssl, '_create_unverified_context'):
             ctx = ssl._create_unverified_context()
         else:
@@ -366,8 +336,7 @@ def setup_dev_env():
 def lint():
     """Run flake8 against all py files."""
     py_files = subprocess.check_output("git ls-files")
-    if PY3:
-        py_files = py_files.decode()
+    py_files = py_files.decode()
     py_files = [x for x in py_files.split() if x.endswith('.py')]
     py_files = ' '.join(py_files)
     sh("%s -m flake8 %s" % (PYTHON, py_files), nolog=True)
diff --git a/setup.py b/setup.py
index dee1f04b..09cce803 100644
--- a/setup.py
+++ b/setup.py
@@ -7,7 +7,6 @@
 $ python setup.py install
 """
 
-from __future__ import print_function
 
 import ast
 import os
@@ -38,7 +37,7 @@ def get_version():
 
 def term_supports_colors():
     try:
-        import curses
+        import curses  # noqa: PLC0415
 
         assert sys.stderr.isatty()
         curses.setupterm()
@@ -66,14 +65,13 @@ def hilite(s, ok=True, bold=False):
         return '\x1b[%sm%s\x1b[0m' % (';'.join(attr), s)
 
 
-if sys.version_info < (2, 7):  # noqa
-    sys.exit('python version not supported (< 2.7)')
-
-require_pysendfile = os.name == 'posix' and sys.version_info < (3, 3)
+if sys.version_info[0] < 3:
+    sys.exit(
+        'Python 2 is no longer supported. Latest version is 1.5.10; use:\n'
+        'python3 -m pip install pyftpdlib==1.5.10'
+    )
 
 extras_require = {'ssl': ["PyOpenSSL"]}
-if require_pysendfile:
-    extras_require.update({'sendfile': ['pysendfile']})
 
 VERSION = get_version()
 
@@ -119,31 +117,10 @@ def main():
             'Topic :: Software Development :: Libraries :: Python Modules',
             'Topic :: System :: Filesystems',
             'Programming Language :: Python',
-            'Programming Language :: Python :: 2',
             'Programming Language :: Python :: 3',
         ],
     )
 
-    # suggest to install pysendfile
-    if require_pysendfile:
-        try:
-            # os.sendfile() appeared in python 3.3
-            # http://bugs.python.org/issue10882
-            if not hasattr(os, 'sendfile'):
-                # fallback on using third-party pysendfile module
-                # https://github.com/giampaolo/pysendfile/
-                import sendfile
-
-                if hasattr(sendfile, 'has_sf_hdtr'):  # old 1.2.4 version
-                    raise ImportError
-        except ImportError:
-            msg = textwrap.dedent("""
-                'pysendfile' third-party module is not installed. This is not
-                essential but it considerably speeds up file transfers.
-                You can install it with 'pip install pysendfile'.
-                More at: https://github.com/giampaolo/pysendfile""")
-            print(hilite(msg, ok=False), file=sys.stderr)
-
     try:
         from OpenSSL import SSL  # NOQA
     except ImportError:


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 07ff515b..1db81a50 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -8,7 +8,7 @@ concurrency:
   cancel-in-progress: true
 jobs:
   # Run unit tests
-  py3:
+  tests:
     name: "py-${{ matrix.python-version }}, ${{ matrix.os }}"
     runs-on: ${{ matrix.os }}
     timeout-minutes: 20
diff --git a/pyftpdlib/test/__init__.py b/pyftpdlib/test/__init__.py
index 5a359809..4d851d39 100644
--- a/pyftpdlib/test/__init__.py
+++ b/pyftpdlib/test/__init__.py
@@ -2,7 +2,6 @@
 # Use of this source code is governed by MIT license that can be
 # found in the LICENSE file.
 
-from __future__ import print_function
 
 import atexit
 import contextlib
@@ -22,37 +21,19 @@
 
 import psutil
 
-from pyftpdlib._compat import PY3
-from pyftpdlib._compat import FileNotFoundError
-from pyftpdlib._compat import getcwdu
-from pyftpdlib._compat import super
 from pyftpdlib.authorizers import DummyAuthorizer
 from pyftpdlib.handlers import FTPHandler
-from pyftpdlib.handlers import _import_sendfile
 from pyftpdlib.ioloop import IOLoop
 from pyftpdlib.servers import FTPServer
 
 
-try:
-    from unittest import mock  # py3
-except ImportError:
-    with warnings.catch_warnings():
-        warnings.simplefilter("ignore")
-        import mock  # NOQA - requires "pip install mock"
-
-
-sendfile = _import_sendfile()
-
-
 # --- platforms
 
 HERE = os.path.realpath(os.path.abspath(os.path.dirname(__file__)))
 ROOT_DIR = os.path.realpath(os.path.join(HERE, '..', '..'))
 PYPY = '__pypy__' in sys.builtin_module_names
-# whether we're running this test suite on a Continuous Integration service
-APPVEYOR = 'APPVEYOR' in os.environ
 GITHUB_ACTIONS = 'GITHUB_ACTIONS' in os.environ or 'CIBUILDWHEEL' in os.environ
-CI_TESTING = APPVEYOR or GITHUB_ACTIONS
+CI_TESTING = GITHUB_ACTIONS
 COVERAGE = 'COVERAGE_RUN' in os.environ
 # are we a 64 bit process?
 IS_64BIT = sys.maxsize > 2**32
@@ -67,12 +48,12 @@
 # Attempt to use IP rather than hostname (test suite will run a lot faster)
 try:
     HOST = socket.gethostbyname('localhost')
-except socket.error:
+except OSError:
     HOST = 'localhost'
 
 USER = 'user'
 PASSWD = '12345'
-HOME = getcwdu()
+HOME = os.getcwd()
 # Use PID to disambiguate file name for parallel testing.
 TESTFN_PREFIX = 'pyftpd-tmp-%s-' % os.getpid()
 GLOBAL_TIMEOUT = 2
@@ -101,11 +82,6 @@ def __str__(self):
             self._testMethodName,
         )
 
-    # assertRaisesRegexp renamed to assertRaisesRegex in 3.3;
-    # add support for the new name.
-    if not hasattr(unittest.TestCase, 'assertRaisesRegex'):
-        assertRaisesRegex = unittest.TestCase.assertRaisesRegexp  # noqa
-
     def get_testfn(self, suffix="", dir=None):
         fname = get_testfn(suffix=suffix, dir=dir)
         self.addCleanup(safe_rmpath, fname)
@@ -134,7 +110,7 @@ def try_address(host, port=0, family=socket.AF_INET):
     try:
         with contextlib.closing(socket.socket(family)) as sock:
             sock.bind((host, port))
-    except (socket.error, socket.gaierror):
+    except (OSError, socket.gaierror):
         return False
     else:
         return True
@@ -142,7 +118,6 @@ def try_address(host, port=0, family=socket.AF_INET):
 
 SUPPORTS_IPV4 = try_address('127.0.0.1')
 SUPPORTS_IPV6 = socket.has_ipv6 and try_address('::1', family=socket.AF_INET6)
-SUPPORTS_SENDFILE = hasattr(os, 'sendfile') or sendfile is not None
 
 
 def get_testfn(suffix="", dir=None):
@@ -173,7 +148,7 @@ def retry_fun(fun):
                 return fun()
             except FileNotFoundError:
                 pass
-            except WindowsError as _:
+            except OSError as _:
                 err = _
                 warnings.warn(
                     "ignoring %s" % str(err), UserWarning, stacklevel=2
@@ -290,10 +265,8 @@ def wrapper(cls, *args, **kwargs):
                         cls.tearDown()
                         cls.setUp()
                     continue
-            if PY3:
-                raise exc  # noqa: PLE0704
-            else:
-                raise  # noqa: PLE0704
+
+            raise exc  # noqa: PLE0704
 
         # This way the user of the decorated function can change config
         # parameters.
@@ -394,8 +367,8 @@ def assert_free_resources(parent_pid=None):
 def reset_server_opts():
     # Since all pyftpdlib configurable "options" are class attributes
     # we reset them at module.class level.
-    import pyftpdlib.handlers
-    import pyftpdlib.servers
+    import pyftpdlib.handlers  # noqa: PLC0415
+    import pyftpdlib.servers  # noqa: PLC0415
 
     # Control handlers.
     tls_handler = getattr(
@@ -415,7 +388,7 @@ def reset_server_opts():
         klass.timeout = 300
         klass.unicode_errors = "replace"
         klass.use_gmt_times = True
-        klass.use_sendfile = _import_sendfile() is not None
+        klass.use_sendfile = hasattr(os, "sendfile")
         klass.ac_in_buffer_size = 4096
         klass.ac_out_buffer_size = 4096
         if klass.__name__ == 'TLS_FTPHandler':
diff --git a/pyftpdlib/test/test_authorizers.py b/pyftpdlib/test/test_authorizers.py
index 23506c06..cbd1eb41 100644
--- a/pyftpdlib/test/test_authorizers.py
+++ b/pyftpdlib/test/test_authorizers.py
@@ -10,9 +10,6 @@
 
 import pytest
 
-from pyftpdlib._compat import getcwdu
-from pyftpdlib._compat import super
-from pyftpdlib._compat import unicode
 from pyftpdlib.authorizers import AuthenticationFailed
 from pyftpdlib.authorizers import AuthorizerError
 from pyftpdlib.authorizers import DummyAuthorizer
@@ -246,7 +243,6 @@ def assertRaisesWithMsg(self, excClass, msg, callableObj, *args, **kwargs):
     def test_get_home_dir(self):
         auth = self.authorizer_class()
         home = auth.get_home_dir(self.get_current_user())
-        assert isinstance(home, unicode)
         nonexistent_user = self.get_nonexistent_user()
         assert os.path.isdir(home)
         if auth.has_user('nobody'):
@@ -384,7 +380,7 @@ def test_override_user_password(self):
     def test_override_user_homedir(self):
         auth = self.authorizer_class()
         user = self.get_current_user()
-        dir = os.path.dirname(getcwdu())
+        dir = os.path.dirname(os.getcwd())
         auth.override_user(user, homedir=dir)
         assert auth.get_home_dir(user) == dir
         # make sure other settings keep using default values
diff --git a/pyftpdlib/test/test_cli.py b/pyftpdlib/test/test_cli.py
index 54f0cde7..9b6b7154 100644
--- a/pyftpdlib/test/test_cli.py
+++ b/pyftpdlib/test/test_cli.py
@@ -2,26 +2,19 @@
 # Use of this source code is governed by MIT license that can be
 # found in the LICENSE file.
 
+import io
 import os
 import warnings
-
-
-try:
-    from StringIO import StringIO as BytesIO
-except ImportError:
-    from io import BytesIO
+from unittest.mock import patch
 
 import pytest
 
 import pyftpdlib
 from pyftpdlib import __ver__
 from pyftpdlib.__main__ import main
-from pyftpdlib._compat import PY3
-from pyftpdlib._compat import super
 from pyftpdlib.authorizers import DummyAuthorizer
 from pyftpdlib.servers import FTPServer
 from pyftpdlib.test import PyftpdlibTestCase
-from pyftpdlib.test import mock
 
 
 class TestCommandLineParser(PyftpdlibTestCase):
@@ -38,14 +31,9 @@ class DummyFTPServer(FTPServer):
             def serve_forever(self, *args, **kwargs):
                 self.close_all()
 
-        if PY3:
-            import io
-
-            self.devnull = io.StringIO()
-        else:
-            self.devnull = BytesIO()
+        self.devnull = io.BytesIO()
         self.original_ftpserver_class = FTPServer
-        self.clog = mock.patch("pyftpdlib.__main__.config_logging")
+        self.clog = patch("pyftpdlib.__main__.config_logging")
         self.clog.start()
         pyftpdlib.__main__.FTPServer = DummyFTPServer
 
diff --git a/pyftpdlib/test/test_filesystems.py b/pyftpdlib/test/test_filesystems.py
index 609d9df4..572af9ea 100644
--- a/pyftpdlib/test/test_filesystems.py
+++ b/pyftpdlib/test/test_filesystems.py
@@ -7,8 +7,6 @@
 
 import pytest
 
-from pyftpdlib._compat import getcwdu
-from pyftpdlib._compat import u
 from pyftpdlib.filesystems import AbstractedFS
 from pyftpdlib.test import HOME
 from pyftpdlib.test import POSIX
@@ -27,34 +25,34 @@ class TestAbstractedFS(PyftpdlibTestCase):
     def test_ftpnorm(self):
         # Tests for ftpnorm method.
         ae = self.assertEqual
-        fs = AbstractedFS(u('/'), None)
-
-        fs._cwd = u('/')
-        ae(fs.ftpnorm(u('')), u('/'))
-        ae(fs.ftpnorm(u('/')), u('/'))
-        ae(fs.ftpnorm(u('.')), u('/'))
-        ae(fs.ftpnorm(u('..')), u('/'))
-        ae(fs.ftpnorm(u('a')), u('/a'))
-        ae(fs.ftpnorm(u('/a')), u('/a'))
-        ae(fs.ftpnorm(u('/a/')), u('/a'))
-        ae(fs.ftpnorm(u('a/..')), u('/'))
-        ae(fs.ftpnorm(u('a/b')), '/a/b')
-        ae(fs.ftpnorm(u('a/b/..')), u('/a'))
-        ae(fs.ftpnorm(u('a/b/../..')), u('/'))
-        fs._cwd = u('/sub')
-        ae(fs.ftpnorm(u('')), u('/sub'))
-        ae(fs.ftpnorm(u('/')), u('/'))
-        ae(fs.ftpnorm(u('.')), u('/sub'))
-        ae(fs.ftpnorm(u('..')), u('/'))
-        ae(fs.ftpnorm(u('a')), u('/sub/a'))
-        ae(fs.ftpnorm(u('a/')), u('/sub/a'))
-        ae(fs.ftpnorm(u('a/..')), u('/sub'))
-        ae(fs.ftpnorm(u('a/b')), u('/sub/a/b'))
-        ae(fs.ftpnorm(u('a/b/')), u('/sub/a/b'))
-        ae(fs.ftpnorm(u('a/b/..')), u('/sub/a'))
-        ae(fs.ftpnorm(u('a/b/../..')), u('/sub'))
-        ae(fs.ftpnorm(u('a/b/../../..')), u('/'))
-        ae(fs.ftpnorm(u('//')), u('/'))  # UNC paths must be collapsed
+        fs = AbstractedFS('/', None)
+
+        fs._cwd = '/'
+        ae(fs.ftpnorm(''), '/')
+        ae(fs.ftpnorm('/'), '/')
+        ae(fs.ftpnorm('.'), '/')
+        ae(fs.ftpnorm('..'), '/')
+        ae(fs.ftpnorm('a'), '/a')
+        ae(fs.ftpnorm('/a'), '/a')
+        ae(fs.ftpnorm('/a/'), '/a')
+        ae(fs.ftpnorm('a/..'), '/')
+        ae(fs.ftpnorm('a/b'), '/a/b')
+        ae(fs.ftpnorm('a/b/..'), '/a')
+        ae(fs.ftpnorm('a/b/../..'), '/')
+        fs._cwd = '/sub'
+        ae(fs.ftpnorm(''), '/sub')
+        ae(fs.ftpnorm('/'), '/')
+        ae(fs.ftpnorm('.'), '/sub')
+        ae(fs.ftpnorm('..'), '/')
+        ae(fs.ftpnorm('a'), '/sub/a')
+        ae(fs.ftpnorm('a/'), '/sub/a')
+        ae(fs.ftpnorm('a/..'), '/sub')
+        ae(fs.ftpnorm('a/b'), '/sub/a/b')
+        ae(fs.ftpnorm('a/b/'), '/sub/a/b')
+        ae(fs.ftpnorm('a/b/..'), '/sub/a')
+        ae(fs.ftpnorm('a/b/../..'), '/sub')
+        ae(fs.ftpnorm('a/b/../../..'), '/')
+        ae(fs.ftpnorm('//'), '/')  # UNC paths must be collapsed
 
     def test_ftp2fs(self):
         # Tests for ftp2fs method.
@@ -62,50 +60,50 @@ def join(x, y):
             return os.path.join(x, y.replace('/', os.sep))
 
         ae = self.assertEqual
-        fs = AbstractedFS(u('/'), None)
+        fs = AbstractedFS('/', None)
 
         def goforit(root):
             fs._root = root
-            fs._cwd = u('/')
-            ae(fs.ftp2fs(u('')), root)
-            ae(fs.ftp2fs(u('/')), root)
-            ae(fs.ftp2fs(u('.')), root)
-            ae(fs.ftp2fs(u('..')), root)
-            ae(fs.ftp2fs(u('a')), join(root, u('a')))
-            ae(fs.ftp2fs(u('/a')), join(root, u('a')))
-            ae(fs.ftp2fs(u('/a/')), join(root, u('a')))
-            ae(fs.ftp2fs(u('a/..')), root)
-            ae(fs.ftp2fs(u('a/b')), join(root, u(r'a/b')))
-            ae(fs.ftp2fs(u('/a/b')), join(root, u(r'a/b')))
-            ae(fs.ftp2fs(u('/a/b/..')), join(root, u('a')))
-            ae(fs.ftp2fs(u('/a/b/../..')), root)
-            fs._cwd = u('/sub')
-            ae(fs.ftp2fs(u('')), join(root, u('sub')))
-            ae(fs.ftp2fs(u('/')), root)
-            ae(fs.ftp2fs(u('.')), join(root, u('sub')))
-            ae(fs.ftp2fs(u('..')), root)
-            ae(fs.ftp2fs(u('a')), join(root, u('sub/a')))
-            ae(fs.ftp2fs(u('a/')), join(root, u('sub/a')))
-            ae(fs.ftp2fs(u('a/..')), join(root, u('sub')))
-            ae(fs.ftp2fs(u('a/b')), join(root, 'sub/a/b'))
-            ae(fs.ftp2fs(u('a/b/..')), join(root, u('sub/a')))
-            ae(fs.ftp2fs(u('a/b/../..')), join(root, u('sub')))
-            ae(fs.ftp2fs(u('a/b/../../..')), root)
+            fs._cwd = '/'
+            ae(fs.ftp2fs(''), root)
+            ae(fs.ftp2fs('/'), root)
+            ae(fs.ftp2fs('.'), root)
+            ae(fs.ftp2fs('..'), root)
+            ae(fs.ftp2fs('a'), join(root, 'a'))
+            ae(fs.ftp2fs('/a'), join(root, 'a'))
+            ae(fs.ftp2fs('/a/'), join(root, 'a'))
+            ae(fs.ftp2fs('a/..'), root)
+            ae(fs.ftp2fs('a/b'), join(root, r'a/b'))
+            ae(fs.ftp2fs('/a/b'), join(root, r'a/b'))
+            ae(fs.ftp2fs('/a/b/..'), join(root, 'a'))
+            ae(fs.ftp2fs('/a/b/../..'), root)
+            fs._cwd = '/sub'
+            ae(fs.ftp2fs(''), join(root, 'sub'))
+            ae(fs.ftp2fs('/'), root)
+            ae(fs.ftp2fs('.'), join(root, 'sub'))
+            ae(fs.ftp2fs('..'), root)
+            ae(fs.ftp2fs('a'), join(root, 'sub/a'))
+            ae(fs.ftp2fs('a/'), join(root, 'sub/a'))
+            ae(fs.ftp2fs('a/..'), join(root, 'sub'))
+            ae(fs.ftp2fs('a/b'), join(root, 'sub/a/b'))
+            ae(fs.ftp2fs('a/b/..'), join(root, 'sub/a'))
+            ae(fs.ftp2fs('a/b/../..'), join(root, 'sub'))
+            ae(fs.ftp2fs('a/b/../../..'), root)
             # UNC paths must be collapsed
-            ae(fs.ftp2fs(u('//a')), join(root, u('a')))
+            ae(fs.ftp2fs('//a'), join(root, 'a'))
 
         if os.sep == '\\':
-            goforit(u(r'C:\dir'))
-            goforit(u('C:\\'))
+            goforit(r'C:\dir')
+            goforit('C:\\')
             # on DOS-derived filesystems (e.g. Windows) this is the same
             # as specifying the current drive directory (e.g. 'C:\\')
-            goforit(u('\\'))
+            goforit('\\')
         elif os.sep == '/':
-            goforit(u('/home/user'))
-            goforit(u('/'))
+            goforit('/home/user')
+            goforit('/')
         else:
             # os.sep == ':'? Don't know... let's try it anyway
-            goforit(getcwdu())
+            goforit(os.getcwd())
 
     def test_fs2ftp(self):
         # Tests for fs2ftp method.
@@ -113,51 +111,51 @@ def join(x, y):
             return os.path.join(x, y.replace('/', os.sep))
 
         ae = self.assertEqual
-        fs = AbstractedFS(u('/'), None)
+        fs = AbstractedFS('/', None)
 
         def goforit(root):
             fs._root = root
-            ae(fs.fs2ftp(root), u('/'))
-            ae(fs.fs2ftp(join(root, u('/'))), u('/'))
-            ae(fs.fs2ftp(join(root, u('.'))), u('/'))
+            ae(fs.fs2ftp(root), '/')
+            ae(fs.fs2ftp(join(root, '/')), '/')
+            ae(fs.fs2ftp(join(root, '.')), '/')
             # can't escape from root
-            ae(fs.fs2ftp(join(root, u('..'))), u('/'))
-            ae(fs.fs2ftp(join(root, u('a'))), u('/a'))
-            ae(fs.fs2ftp(join(root, u('a/'))), u('/a'))
-            ae(fs.fs2ftp(join(root, u('a/..'))), u('/'))
-            ae(fs.fs2ftp(join(root, u('a/b'))), u('/a/b'))
-            ae(fs.fs2ftp(join(root, u('a/b'))), u('/a/b'))
-            ae(fs.fs2ftp(join(root, u('a/b/..'))), u('/a'))
-            ae(fs.fs2ftp(join(root, u('/a/b/../..'))), u('/'))
-            fs._cwd = u('/sub')
-            ae(fs.fs2ftp(join(root, 'a/')), u('/a'))
+            ae(fs.fs2ftp(join(root, '..')), '/')
+            ae(fs.fs2ftp(join(root, 'a')), '/a')
+            ae(fs.fs2ftp(join(root, 'a/')), '/a')
+            ae(fs.fs2ftp(join(root, 'a/..')), '/')
+            ae(fs.fs2ftp(join(root, 'a/b')), '/a/b')
+            ae(fs.fs2ftp(join(root, 'a/b')), '/a/b')
+            ae(fs.fs2ftp(join(root, 'a/b/..')), '/a')
+            ae(fs.fs2ftp(join(root, '/a/b/../..')), '/')
+            fs._cwd = '/sub'
+            ae(fs.fs2ftp(join(root, 'a/')), '/a')
 
         if os.sep == '\\':
-            goforit(u(r'C:\dir'))
-            goforit(u('C:\\'))
+            goforit(r'C:\dir')
+            goforit('C:\\')
             # on DOS-derived filesystems (e.g. Windows) this is the same
             # as specifying the current drive directory (e.g. 'C:\\')
-            goforit(u('\\'))
-            fs._root = u(r'C:\dir')
-            ae(fs.fs2ftp(u('C:\\')), u('/'))
-            ae(fs.fs2ftp(u('D:\\')), u('/'))
-            ae(fs.fs2ftp(u('D:\\dir')), u('/'))
+            goforit('\\')
+            fs._root = r'C:\dir'
+            ae(fs.fs2ftp('C:\\'), '/')
+            ae(fs.fs2ftp('D:\\'), '/')
+            ae(fs.fs2ftp('D:\\dir'), '/')
         elif os.sep == '/':
-            goforit(u('/'))
+            goforit('/')
             if os.path.realpath('/__home/user') != '/__home/user':
                 self.fail('Test skipped (symlinks not allowed).')
-            goforit(u('/__home/user'))
-            fs._root = u('/__home/user')
-            ae(fs.fs2ftp(u('/__home')), u('/'))
-            ae(fs.fs2ftp(u('/')), u('/'))
-            ae(fs.fs2ftp(u('/__home/userx')), u('/'))
+            goforit('/__home/user')
+            fs._root = '/__home/user'
+            ae(fs.fs2ftp('/__home'), '/')
+            ae(fs.fs2ftp('/'), '/')
+            ae(fs.fs2ftp('/__home/userx'), '/')
         else:
             # os.sep == ':'? Don't know... let's try it anyway
-            goforit(getcwdu())
+            goforit(os.getcwd())
 
     def test_validpath(self):
         # Tests for validpath method.
-        fs = AbstractedFS(u('/'), None)
+        fs = AbstractedFS('/', None)
         fs._root = HOME
         assert fs.validpath(HOME)
         assert fs.validpath(HOME + '/')
@@ -170,16 +168,16 @@ def test_validpath_validlink(self):
             # inside the root directory.
             testfn = self.get_testfn()
             testfn2 = self.get_testfn()
-            fs = AbstractedFS(u('/'), None)
+            fs = AbstractedFS('/', None)
             fs._root = HOME
             touch(testfn)
             os.symlink(testfn, testfn2)
-            assert fs.validpath(u(testfn))
+            assert fs.validpath(testfn)
 
         def test_validpath_external_symlink(self):
             # Test validpath by issuing a symlink pointing to a path
             # outside the root directory.
-            fs = AbstractedFS(u('/'), None)
+            fs = AbstractedFS('/', None)
             fs._root = HOME
             # tempfile should create our file in /tmp directory
             # which should be outside the user root.  If it is
@@ -190,7 +188,7 @@ def test_validpath_external_symlink(self):
                     if os.path.dirname(file.name) == HOME:
                         return
                     os.symlink(file.name, testfn)
-                    assert not fs.validpath(u(testfn))
+                    assert not fs.validpath(testfn)
                 finally:
                     safe_rmpath(testfn)
 
@@ -199,10 +197,10 @@ def test_validpath_external_symlink(self):
 class TestUnixFilesystem(PyftpdlibTestCase):
 
     def test_case(self):
-        root = getcwdu()
+        root = os.getcwd()
         fs = UnixFilesystem(root, None)
         assert fs.root == root
         assert fs.cwd == root
         cdup = os.path.dirname(root)
-        assert fs.ftp2fs(u('..')) == cdup
+        assert fs.ftp2fs('..') == cdup
         assert fs.fs2ftp(root) == root
diff --git a/pyftpdlib/test/test_functional.py b/pyftpdlib/test/test_functional.py
index 7cb47eeb..457f462a 100644
--- a/pyftpdlib/test/test_functional.py
+++ b/pyftpdlib/test/test_functional.py
@@ -14,27 +14,17 @@
 import socket
 import ssl
 import stat
+import struct
 import time
-
-
-try:
-    from StringIO import StringIO as BytesIO
-except ImportError:
-    from io import BytesIO
-
+from unittest.mock import patch
 
 import pytest
 
-from pyftpdlib._compat import PY3
-from pyftpdlib._compat import b
-from pyftpdlib._compat import super
-from pyftpdlib._compat import u
 from pyftpdlib.filesystems import AbstractedFS
 from pyftpdlib.handlers import SUPPORTS_HYBRID_IPV6
 from pyftpdlib.handlers import DTPHandler
 from pyftpdlib.handlers import FTPHandler
 from pyftpdlib.handlers import ThrottledDTPHandler
-from pyftpdlib.handlers import _import_sendfile
 from pyftpdlib.ioloop import IOLoop
 from pyftpdlib.servers import FTPServer
 from pyftpdlib.test import BUFSIZE
@@ -48,7 +38,6 @@
 from pyftpdlib.test import POSIX
 from pyftpdlib.test import SUPPORTS_IPV4
 from pyftpdlib.test import SUPPORTS_IPV6
-from pyftpdlib.test import SUPPORTS_SENDFILE
 from pyftpdlib.test import USER
 from pyftpdlib.test import WINDOWS
 from pyftpdlib.test import PyftpdlibTestCase
@@ -56,15 +45,11 @@
 from pyftpdlib.test import close_client
 from pyftpdlib.test import disable_log_warning
 from pyftpdlib.test import get_server_handler
-from pyftpdlib.test import mock
 from pyftpdlib.test import retry_on_failure
 from pyftpdlib.test import safe_rmpath
 from pyftpdlib.test import touch
 
 
-sendfile = _import_sendfile()
-
-
 class TestFtpAuthentication(PyftpdlibTestCase):
     """Test: USER, PASS, REIN."""
 
@@ -79,7 +64,7 @@ def setUp(self):
         self.client.connect(self.server.host, self.server.port)
         self.testfn = self.get_testfn()
         self.file = open(self.testfn, 'w+b')
-        self.dummyfile = BytesIO()
+        self.dummyfile = io.BytesIO()
 
     def tearDown(self):
         close_client(self.client)
@@ -133,10 +118,10 @@ def test_max_auth(self):
         # If authentication fails for 3 times ftpd disconnects the
         # client.  We can check if that happens by using self.client.sendcmd()
         # on the 'dead' socket object.  If socket object is really
-        # closed it should be raised a socket.error exception (Windows)
+        # closed it should be raised a OSError exception (Windows)
         # or a EOFError exception (Linux).
         self.client.sock.settimeout(0.1)
-        with pytest.raises((socket.error, EOFError)):
+        with pytest.raises((OSError, EOFError)):
             self.client.sendcmd('')
 
     def test_rein(self):
@@ -540,17 +525,17 @@ def test_cwd(self):
         # cwd provided with no arguments is supposed to move us to the
         # root directory
         self.client.sendcmd('cwd')
-        assert self.client.pwd() == u('/')
+        assert self.client.pwd() == '/'
 
     def test_pwd(self):
-        assert self.client.pwd() == u('/')
+        assert self.client.pwd() == '/'
         self.client.cwd(self.tempdir)
         assert self.client.pwd() == '/' + self.tempdir
 
     def test_cdup(self):
         subfolder = self.get_testfn(dir=self.tempdir)
         os.mkdir(os.path.join(self.tempdir, subfolder))
-        assert self.client.pwd() == u('/')
+        assert self.client.pwd() == '/'
         self.client.cwd(self.tempdir)
         assert self.client.pwd() == '/%s' % self.tempdir
         self.client.cwd(subfolder)
@@ -558,11 +543,11 @@ def test_cdup(self):
         self.client.sendcmd('cdup')
         assert self.client.pwd() == '/%s' % self.tempdir
         self.client.sendcmd('cdup')
-        assert self.client.pwd() == u('/')
+        assert self.client.pwd() == '/'
 
         # make sure we can't escape from root directory
         self.client.sendcmd('cdup')
-        assert self.client.pwd() == u('/')
+        assert self.client.pwd() == '/'
 
     def test_mkd(self):
         tempdir = self.get_testfn()
@@ -588,7 +573,7 @@ def test_rmd(self):
         with pytest.raises(
             ftplib.error_perm, match="Can't remove root directory"
         ):
-            self.client.rmd(u('/'))
+            self.client.rmd('/')
 
     def test_dele(self):
         self.client.delete(self.tempfile)
@@ -609,7 +594,7 @@ def test_rnfr_rnto(self):
         with pytest.raises(ftplib.error_perm):
             self.client.rename(bogus, '/x')
         with pytest.raises(ftplib.error_perm):
-            self.client.rename(self.tempfile, u('/'))
+            self.client.rename(self.tempfile, '/')
         # rnto sent without first specifying the source
         with pytest.raises(ftplib.error_perm):
             self.client.sendcmd('rnto ' + self.tempfile)
@@ -709,8 +694,7 @@ def test_site_chmod(self):
 
             def getmode():
                 mode = oct(stat.S_IMODE(os.stat(self.tempfile).st_mode))
-                if PY3:
-                    mode = mode.replace('o', '')
+                mode = mode.replace('o', '')
                 return mode
 
             # on Windows it is possible to set read-only flag only
@@ -767,8 +751,8 @@ def setUp(self):
             self.dummy_recvfile = CustomIO()
             self.dummy_sendfile = CustomIO()
         else:
-            self.dummy_recvfile = BytesIO()
-            self.dummy_sendfile = BytesIO()
+            self.dummy_recvfile = io.BytesIO()
+            self.dummy_sendfile = io.BytesIO()
         self.testfn = self.get_testfn()
 
     def tearDown(self):
@@ -777,7 +761,7 @@ def tearDown(self):
         self.dummy_recvfile.close()
         self.dummy_sendfile.close()
         if self.use_sendfile is not None:
-            self.server.handler.use_sendfile = _import_sendfile() is not None
+            self.server.handler.use_sendfile = hasattr(os, "sendfile")
         super().tearDown()
 
     def test_stor(self):
@@ -823,7 +807,7 @@ def store(cmd, fp, blocksize=8192):
         self.client.retrbinary(
             'retr ' + self.testfn, self.dummy_recvfile.write
         )
-        expected = data.replace(b'\r\n', b(os.linesep))
+        expected = data.replace(b'\r\n', bytes(os.linesep, "ascii"))
         self.dummy_recvfile.seek(0)
         datafile = self.dummy_recvfile.read()
         assert len(expected) == len(datafile)
@@ -857,7 +841,7 @@ def store(cmd, fp, blocksize=8192):
             self.dummy_sendfile.seek(0)
             store('stor ' + self.testfn, self.dummy_sendfile)
 
-            expected = data.replace(b'\r\n', b(os.linesep))
+            expected = data.replace(b'\r\n', bytes(os.linesep, "ascii"))
             self.client.retrbinary(
                 'retr ' + self.testfn, self.dummy_recvfile.write
             )
@@ -903,7 +887,7 @@ def test_stou(self):
             if os.path.exists(filename):
                 try:
                     self.client.delete(filename)
-                except (ftplib.Error, EOFError, socket.error):
+                except (ftplib.Error, EOFError, OSError):
                     safe_rmpath(filename)
 
     def test_stou_rest(self):
@@ -1028,10 +1012,10 @@ def test_quit_during_transfer(self):
         # expect the response (transfer ok)
         assert self.client.voidresp()[:3] == '226'
         # Make sure client has been disconnected.
-        # socket.error (Windows) or EOFError (Linux) exception is supposed
+        # OSError (Windows) or EOFError (Linux) exception is supposed
         # to be raised in such a case.
         self.client.sock.settimeout(0.1)
-        with pytest.raises((socket.error, EOFError)):
+        with pytest.raises((OSError, EOFError)):
             self.client.sendcmd('noop')
 
     def test_stor_empty_file(self):
@@ -1042,9 +1026,6 @@ def test_stor_empty_file(self):
 
 
 @pytest.mark.skipif(not POSIX, reason="POSIX only")
-@pytest.mark.skipif(
-    not PY3 and sendfile is None, reason="pysendfile not installed"
-)
 class TestFtpStoreDataNoSendfile(TestFtpStoreData):
     """Test STOR, STOU, APPE, REST, TYPE not using sendfile()."""
 
@@ -1090,14 +1071,14 @@ def setUp(self):
         if self.use_custom_io:
             self.dummyfile = CustomIO()
         else:
-            self.dummyfile = BytesIO()
+            self.dummyfile = io.BytesIO()
 
     def tearDown(self):
         close_client(self.client)
         self.server.stop()
         self.dummyfile.close()
         if self.use_sendfile is not None:
-            self.server.handler.use_sendfile = _import_sendfile() is not None
+            self.server.handler.use_sendfile = hasattr(os, "sendfile")
         super().tearDown()
 
     def test_retr(self):
@@ -1117,11 +1098,11 @@ def test_retr(self):
 
     def test_retr_ascii(self):
         # Test RETR in ASCII mode.
-        data = (b'abcde12345' + b(os.linesep)) * 100000
+        data = (b'abcde12345' + bytes(os.linesep, "ascii")) * 100000
         with open(self.testfn, 'wb') as f:
             f.write(data)
         self.retrieve_ascii("retr " + self.testfn, self.dummyfile.write)
-        expected = data.replace(b(os.linesep), b'\r\n')
+        expected = data.replace(bytes(os.linesep, "ascii"), b'\r\n')
         self.dummyfile.seek(0)
         datafile = self.dummyfile.read()
         assert len(expected) == len(datafile)
@@ -1185,9 +1166,6 @@ def test_retr_empty_file(self):
 
 
 @pytest.mark.skipif(not POSIX, reason="POSIX only")
-@pytest.mark.skipif(
-    not PY3 and sendfile is None, reason="pysendfile not installed"
-)
 class TestFtpRetrieveDataNoSendfile(TestFtpRetrieveData):
     """Test RETR, REST, TYPE by not using sendfile()."""
 
@@ -1447,8 +1425,8 @@ def test_oob_abor(self):
         # due to a different SO_OOBINLINE behavior.
         # On some platforms (e.g. Python CE) the test may fail
         # although the MSG_OOB constant is defined.
-        self.client.sock.sendall(b(chr(244)), socket.MSG_OOB)
-        self.client.sock.sendall(b(chr(255)), socket.MSG_OOB)
+        self.client.sock.sendall(bytes(chr(244), "latin-1"), socket.MSG_OOB)
+        self.client.sock.sendall(bytes(chr(255), "latin-1"), socket.MSG_OOB)
         self.client.sock.sendall(b'abor\r\n')
         assert self.client.getresp()[:3] == '225'
 
@@ -1482,7 +1460,7 @@ def _throttle_bandwidth(self, *args, **kwargs):
         self.client = self.client_class(timeout=GLOBAL_TIMEOUT)
         self.client.connect(self.server.host, self.server.port)
         self.client.login(USER, PASSWD)
-        self.dummyfile = BytesIO()
+        self.dummyfile = io.BytesIO()
         self.testfn = self.get_testfn()
 
     def tearDown(self):
@@ -1578,7 +1556,7 @@ def tearDown(self):
     #     data = self.client.sock.recv(BUFSIZE)
     #     self.assertEqual(data, b"421 Control connection timed out.\r\n")
     #     # ensure client has been kicked off
-    #     self.assertRaises((socket.error, EOFError), self.client.sendcmd,
+    #     self.assertRaises((OSError, EOFError), self.client.sendcmd,
     #                       'noop')
 
     def test_data_timeout(self):
@@ -1595,7 +1573,7 @@ def test_data_timeout(self):
             data = self.client.sock.recv(BUFSIZE)
             assert data == b"421 Data connection timed out.\r\n"
             # ensure client has been kicked off
-            with pytest.raises((socket.error, EOFError)):
+            with pytest.raises((OSError, EOFError)):
                 self.client.sendcmd('noop')
 
     def test_data_timeout_not_reached(self):
@@ -1630,7 +1608,7 @@ def test_idle_data_timeout1(self):
             data = self.client.sock.recv(BUFSIZE)
             assert data == b"421 Data connection timed out.\r\n"
             # ensure client has been kicked off
-            with pytest.raises((socket.error, EOFError)):
+            with pytest.raises((OSError, EOFError)):
                 self.client.sendcmd('noop')
 
     def test_idle_data_timeout2(self):
@@ -1650,7 +1628,7 @@ def test_idle_data_timeout2(self):
             data = self.client.sock.recv(BUFSIZE)
             assert data == b"421 Control connection timed out.\r\n"
             # ensure client has been kicked off
-            with pytest.raises((socket.error, EOFError)):
+            with pytest.raises((OSError, EOFError)):
                 self.client.sendcmd('noop')
 
     def test_pasv_timeout(self):
@@ -1788,7 +1766,7 @@ def test_max_connections(self):
             for c in (c1, c2, c3):
                 try:
                     c.quit()
-                except (socket.error, EOFError, ftplib.Error):
+                except (OSError, EOFError, ftplib.Error):
                     # already disconnected
                     pass
                 finally:
@@ -1815,15 +1793,15 @@ def test_max_connections_per_ip(self):
                     self.server.port,
                 )
             # Make sure client has been disconnected.
-            # socket.error (Windows) or EOFError (Linux) exception is
+            # OSError (Windows) or EOFError (Linux) exception is
             # supposed to be raised in such a case.
-            with pytest.raises((socket.error, EOFError)):
+            with pytest.raises((OSError, EOFError)):
                 c4.sendcmd('noop')
         finally:
             for c in (c1, c2, c3, c4):
                 try:
                     c.quit()
-                except (socket.error, EOFError):  # already disconnected
+                except (OSError, EOFError):  # already disconnected
                     c.close()
 
     def test_banner(self):
@@ -1844,10 +1822,10 @@ def test_max_login_attempts(self):
         self.connect()
         with pytest.raises(ftplib.error_perm):
             self.client.login('wrong', 'wrong')
-        # socket.error (Windows) or EOFError (Linux) exceptions are
+        # OSError (Windows) or EOFError (Linux) exceptions are
         # supposed to be raised when attempting to send/recv some data
         # using a disconnected socket
-        with pytest.raises((socket.error, EOFError)):
+        with pytest.raises((OSError, EOFError)):
             self.client.sendcmd('noop')
 
     def test_masquerade_address(self):
@@ -1945,7 +1923,7 @@ def setUp(self):
         class Handler(FTPHandler):
 
             def write(self, text):
-                with open(testfn, "at") as f:
+                with open(testfn, "a") as f:
                     f.write(text)
 
             def on_connect(self):
@@ -2042,7 +2020,7 @@ def test_on_login_failed(self):
 
     def test_on_file_received(self):
         data = b'abcde12345' * 100000
-        dummyfile = BytesIO()
+        dummyfile = io.BytesIO()
         dummyfile.write(data)
         dummyfile.seek(0)
         self.client.login(USER, PASSWD)
@@ -2066,7 +2044,7 @@ def test_on_file_sent(self):
     def test_on_incomplete_file_received(self):
         self.client.login(USER, PASSWD)
         data = b'abcde12345' * 1000000
-        dummyfile = BytesIO()
+        dummyfile = io.BytesIO()
         dummyfile.write(data)
         dummyfile.seek(0)
         with contextlib.closing(
@@ -2114,7 +2092,7 @@ def test_on_incomplete_file_sent(self):
         )
 
 
-class _TestNetworkProtocols(object):  # noqa
+class _TestNetworkProtocols:
     """Test PASV, EPSV, PORT and EPRT commands.
 
     Do not use this class directly, let TestIPv4Environment and
@@ -2447,9 +2425,9 @@ def test_port_race_condition(self):
 
             hbytes = host.split('.')
             pbytes = [repr(port // 256), repr(port % 256)]
-            bytes = hbytes + pbytes
-            cmd = 'PORT ' + ','.join(bytes) + '\r\n'
-            self.client.sock.sendall(b(cmd))
+            data = hbytes + pbytes
+            cmd = 'PORT ' + ','.join(data) + '\r\n'
+            self.client.sock.sendall(bytes(cmd, "latin-1"))
             self.client.getresp()
             s, _ = sock.accept()
             s.close()
@@ -2461,8 +2439,6 @@ def test_quick_connect(self):
         # initial part of the connection process.
         # Tracked in issues #91, #104 and #105.
         # See also https://bugs.launchpad.net/zodb/+bug/135108
-        import struct
-
         def connect(addr):
             with contextlib.closing(socket.socket()) as s:
                 # Set SO_LINGER to 1,0 causes a connection reset (RST) to
@@ -2476,7 +2452,7 @@ def connect(addr):
                 s.settimeout(GLOBAL_TIMEOUT)
                 try:
                     s.connect(addr)
-                except socket.error:
+                except OSError:
                     pass
 
         for _ in range(10):
@@ -2526,18 +2502,6 @@ def test_repr(self):
                 repr(inst)
                 str(inst)
 
-    if hasattr(os, 'sendfile'):
-
-        def test_sendfile(self):
-            # make sure that on python >= 3.3 we're using os.sendfile
-            # rather than third party pysendfile module
-            assert sendfile is os.sendfile
-
-    if SUPPORTS_SENDFILE:
-
-        def test_sendfile_enabled(self):
-            assert FTPHandler.use_sendfile is True
-
     if hasattr(select, 'epoll') or hasattr(select, 'kqueue'):
 
         def test_ioloop_fileno(self):
@@ -2561,20 +2525,12 @@ def test_ioloop_fileno(self):
 #         self.server = self.server_class()
 #         self.server.start()
 #         self.client = self.client_class(timeout=GLOBAL_TIMEOUT)
-#         self.client.encoding = 'utf8'  # PY3 only
+#         self.client.encoding = 'utf8'
 #         self.client.connect(self.server.host, self.server.port)
 #         self.client.login(USER, PASSWD)
-#         if PY3:
-#             safe_mkdir(bytes(TESTFN_UNICODE, 'utf8'))
-#             touch(bytes(TESTFN_UNICODE_2, 'utf8'))
-#             self.utf8fs = TESTFN_UNICODE in os.listdir('.')
-#         else:
-#             warnings.filterwarnings("ignore")
-#             safe_mkdir(TESTFN_UNICODE)
-#             touch(TESTFN_UNICODE_2)
-#             self.utf8fs = \
-#                 unicode(TESTFN_UNICODE, 'utf8') in os.listdir(u('.'))
-#             warnings.resetwarnings()
+#         safe_mkdir(bytes(TESTFN_UNICODE, 'utf8'))
+#         touch(bytes(TESTFN_UNICODE_2, 'utf8'))
+#         self.utf8fs = TESTFN_UNICODE in os.listdir('.')
 
 #     def tearDown(self):
 #         close_client(self.client)
@@ -2695,17 +2651,17 @@ def test_ioloop_fileno(self):
 #         if self.utf8fs:
 #             data = b'abcde12345' * 500
 #             os.remove(TESTFN_UNICODE_2)
-#             dummy = BytesIO()
+#             dummy = io.BytesIO()
 #             dummy.write(data)
 #             dummy.seek(0)
 #             self.client.storbinary('stor ' + TESTFN_UNICODE_2, dummy)
-#             dummy_recv = BytesIO()
+#             dummy_recv = io.BytesIO()
 #             self.client.retrbinary('retr ' + TESTFN_UNICODE_2,
 #                                    dummy_recv.write)
 #             dummy_recv.seek(0)
 #             self.assertEqual(dummy_recv.read(), data)
 #         else:
-#             dummy = BytesIO()
+#             dummy = io.BytesIO()
 #             self.assertRaises(ftplib.error_perm, self.client.storbinary,
 #                               'stor ' + TESTFN_UNICODE_2, dummy)
 
@@ -2714,12 +2670,12 @@ def test_ioloop_fileno(self):
 #             data = b'abcd1234' * 500
 #             with open(TESTFN_UNICODE_2, 'wb') as f:
 #                 f.write(data)
-#             dummy = BytesIO()
+#             dummy = io.BytesIO()
 #             self.client.retrbinary('retr ' + TESTFN_UNICODE_2, dummy.write)
 #             dummy.seek(0)
 #             self.assertEqual(dummy.read(), data)
 #         else:
-#             dummy = BytesIO()
+#             dummy = io.BytesIO()
 #             self.assertRaises(ftplib.error_perm, self.client.retrbinary,
 #                               'retr ' + TESTFN_UNICODE_2, dummy.write)
 
@@ -2737,8 +2693,8 @@ def setUp(self):
         self.tempdir = self.get_testfn()
         touch(self.tempfile)
         touch(self.tempdir)
-        self.dummy_recvfile = BytesIO()
-        self.dummy_sendfile = BytesIO()
+        self.dummy_recvfile = io.BytesIO()
+        self.dummy_sendfile = io.BytesIO()
 
     def tearDown(self):
         if self.client:
@@ -2764,7 +2720,7 @@ def test_stou_max_tries(self):
         class TestFS(AbstractedFS):
 
             def mkstemp(self, *args, **kwargs):
-                raise IOError(
+                raise OSError(
                     errno.EEXIST, "No usable temporary file name found"
                 )
 
@@ -2793,7 +2749,7 @@ def test_idle_timeout(self):
         data = self.client.sock.recv(BUFSIZE)
         assert data == b"421 Control connection timed out.\r\n"
         # ensure client has been kicked off
-        with pytest.raises((socket.error, EOFError)):
+        with pytest.raises((OSError, EOFError)):
             self.client.sendcmd('noop')
 
     @retry_on_failure()
@@ -2834,7 +2790,7 @@ def test_permit_privileged_ports(self):
         for port in reversed(range(1, 1024)):
             try:
                 socket.getservbyport(port)
-            except socket.error:
+            except OSError:
                 # not registered port; go on
                 try:
                     sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
@@ -2842,7 +2798,7 @@ def test_permit_privileged_ports(self):
                     sock.settimeout(GLOBAL_TIMEOUT)
                     sock.bind((HOST, port))
                     break
-                except socket.error as err:
+                except OSError as err:
                     if err.errno == errno.EACCES:
                         # root privileges needed
                         if sock is not None:
@@ -2884,7 +2840,7 @@ def test_permit_privileged_ports(self):
 
     @pytest.mark.skipif(not POSIX, reason="POSIX only")
     @pytest.mark.skipif(
-        not PY3 and sendfile is None, reason="pysendfile not installed"
+        not hasattr(os, "sendfile"), reason="os.sendfile() not available"
     )
     @retry_on_failure()
     def test_sendfile_fails(self):
@@ -2898,8 +2854,8 @@ def test_sendfile_fails(self):
         self.dummy_sendfile.write(data)
         self.dummy_sendfile.seek(0)
         self.client.storbinary('stor ' + self.tempfile, self.dummy_sendfile)
-        with mock.patch(
-            'pyftpdlib.handlers.sendfile', side_effect=OSError(errno.EINVAL)
+        with patch(
+            'pyftpdlib.handlers.os.sendfile', side_effect=OSError(errno.EINVAL)
         ) as fun:
             self.client.retrbinary(
                 'retr ' + self.tempfile, self.dummy_recvfile.write
diff --git a/pyftpdlib/test/test_functional_ssl.py b/pyftpdlib/test/test_functional_ssl.py
index 861daf6c..30f94d69 100644
--- a/pyftpdlib/test/test_functional_ssl.py
+++ b/pyftpdlib/test/test_functional_ssl.py
@@ -5,13 +5,11 @@
 import contextlib
 import ftplib
 import os
-import socket
 import ssl
 
 import OpenSSL  # requires "pip install pyopenssl"
 import pytest
 
-from pyftpdlib._compat import super
 from pyftpdlib.handlers import TLS_FTPHandler
 from pyftpdlib.test import CI_TESTING
 from pyftpdlib.test import GLOBAL_TIMEOUT
@@ -279,7 +277,7 @@ def test_unforseen_ssl_shutdown(self):
         self.client.login()
         try:
             sock = self.client.sock.unwrap()
-        except socket.error as err:
+        except OSError as err:
             if err.errno == 0:
                 return
             raise
@@ -287,7 +285,7 @@ def test_unforseen_ssl_shutdown(self):
         sock.sendall(b'noop')
         try:
             chunk = sock.recv(1024)
-        except socket.error:
+        except OSError:
             pass
         else:
             assert chunk == b""
@@ -320,7 +318,7 @@ def try_protocol_combo(self, server_protocol, client_protocol):
         self.client.connect(self.server.host, self.server.port)
         try:
             self.client.login()
-        except (ssl.SSLError, socket.error):
+        except (ssl.SSLError, OSError):
             self.client.close()
         else:
             self.client.quit()
@@ -347,10 +345,10 @@ def test_sslv2(self):
             if not OSX:
                 with self.server.lock:
                     self.client.connect(self.server.host, self.server.port)
-                with pytest.raises(socket.error):
+                with pytest.raises(OSError):
                     self.client.login()
             else:
-                with self.server.lock, pytest.raises(socket.error):
+                with self.server.lock, pytest.raises(OSError):
                     self.client.connect(
                         self.server.host, self.server.port, timeout=0.1
                     )
diff --git a/pyftpdlib/test/test_ioloop.py b/pyftpdlib/test/test_ioloop.py
index c533ec52..c88b0bec 100644
--- a/pyftpdlib/test/test_ioloop.py
+++ b/pyftpdlib/test/test_ioloop.py
@@ -4,23 +4,20 @@
 
 import contextlib
 import errno
-import select
 import socket
 import time
+from unittest.mock import Mock
+from unittest.mock import patch
 
 import pytest
 
 import pyftpdlib.ioloop
-from pyftpdlib._compat import PY3
-from pyftpdlib._compat import InterruptedError
-from pyftpdlib._compat import super
 from pyftpdlib.ioloop import Acceptor
 from pyftpdlib.ioloop import AsyncChat
 from pyftpdlib.ioloop import IOLoop
 from pyftpdlib.ioloop import RetryError
 from pyftpdlib.test import POSIX
 from pyftpdlib.test import PyftpdlibTestCase
-from pyftpdlib.test import mock
 
 
 if hasattr(socket, 'socketpair'):
@@ -115,7 +112,7 @@ def close(self):
         rd, wr = self.make_socketpair()
         handler = Handler(rd)
         s.register(rd, handler, s.READ)
-        with mock.patch("pyftpdlib.ioloop.logger.error") as m:
+        with patch("pyftpdlib.ioloop.logger.error") as m:
             s.close()
             assert m.called
             assert 'ZeroDivisionError' in m.call_args[0][0]
@@ -133,15 +130,15 @@ def close(self):
         rd, wr = self.make_socketpair()
         handler = Handler(rd)
         s.register(rd, handler, s.READ)
-        with mock.patch("pyftpdlib.ioloop.logger.error") as m:
+        with patch("pyftpdlib.ioloop.logger.error") as m:
             s.close()
             assert not m.called
 
     def test_close_w_callback_exc(self):
         # Simulate an exception when close()ing the IO loop and a
         # scheduled callback raises an exception on cancel().
-        with mock.patch("pyftpdlib.ioloop.logger.error") as logerr:
-            with mock.patch(
+        with patch("pyftpdlib.ioloop.logger.error") as logerr:
+            with patch(
                 "pyftpdlib.ioloop._CallLater.cancel", side_effect=lambda: 1 / 0
             ) as cancel:
                 s = self.ioloop_class()
@@ -167,18 +164,18 @@ class SelectIOLoopTestCase(PyftpdlibTestCase, BaseIOLoopTestCase):
 
     def test_select_eintr(self):
         # EINTR is supposed to be ignored
-        with mock.patch(
+        with patch(
             'pyftpdlib.ioloop.select.select', side_effect=InterruptedError
         ) as m:
             s, rd, wr = self.test_register()
             s.poll(0)
         # ...but just that
-        with mock.patch(
-            'pyftpdlib.ioloop.select.select', side_effect=select.error()
+        with patch(
+            'pyftpdlib.ioloop.select.select', side_effect=OSError()
         ) as m:
             m.side_effect.errno = errno.EBADF
             s, rd, wr = self.test_register()
-            with pytest.raises(select.error):
+            with pytest.raises(OSError):
                 s.poll(0)
 
 
@@ -197,63 +194,47 @@ class PollIOLoopTestCase(PyftpdlibTestCase, BaseIOLoopTestCase):
 
     def test_eintr_on_poll(self):
         # EINTR is supposed to be ignored
-        with mock.patch(self.poller_mock, return_vaue=mock.Mock()) as m:
-            if not PY3:
-                m.return_value.poll.side_effect = select.error
-                m.return_value.poll.side_effect.errno = errno.EINTR
-            else:
-                m.return_value.poll.side_effect = OSError(errno.EINTR, "")
+        with patch(self.poller_mock, return_vaue=Mock()) as m:
+            m.return_value.poll.side_effect = OSError(errno.EINTR, "")
             s, rd, wr = self.test_register()
             s.poll(0)
             assert m.called
         # ...but just that
-        with mock.patch(self.poller_mock, return_vaue=mock.Mock()) as m:
-            if not PY3:
-                m.return_value.poll.side_effect = select.error
-                m.return_value.poll.side_effect.errno = errno.EBADF
-            else:
-                m.return_value.poll.side_effect = OSError(errno.EBADF, "")
+        with patch(self.poller_mock, return_vaue=Mock()) as m:
+            m.return_value.poll.side_effect = OSError(errno.EBADF, "")
             s, rd, wr = self.test_register()
-            with pytest.raises(select.error):
+            with pytest.raises(OSError):
                 s.poll(0)
             assert m.called
 
     def test_eexist_on_register(self):
         # EEXIST is supposed to be ignored
-        with mock.patch(self.poller_mock, return_vaue=mock.Mock()) as m:
-            m.return_value.register.side_effect = EnvironmentError(
-                errno.EEXIST, ""
-            )
+        with patch(self.poller_mock, return_vaue=Mock()) as m:
+            m.return_value.register.side_effect = OSError(errno.EEXIST, "")
             s, rd, wr = self.test_register()
         # ...but just that
-        with mock.patch(self.poller_mock, return_vaue=mock.Mock()) as m:
-            m.return_value.register.side_effect = EnvironmentError(
-                errno.EBADF, ""
-            )
+        with patch(self.poller_mock, return_vaue=Mock()) as m:
+            m.return_value.register.side_effect = OSError(errno.EBADF, "")
             with pytest.raises(EnvironmentError):
                 self.test_register()
 
     def test_enoent_ebadf_on_unregister(self):
         # ENOENT and EBADF are supposed to be ignored
         for errnum in (errno.EBADF, errno.ENOENT):
-            with mock.patch(self.poller_mock, return_vaue=mock.Mock()) as m:
-                m.return_value.unregister.side_effect = EnvironmentError(
-                    errnum, ""
-                )
+            with patch(self.poller_mock, return_vaue=Mock()) as m:
+                m.return_value.unregister.side_effect = OSError(errnum, "")
                 s, rd, wr = self.test_register()
                 s.unregister(rd)
         # ...but just those
-        with mock.patch(self.poller_mock, return_vaue=mock.Mock()) as m:
-            m.return_value.unregister.side_effect = EnvironmentError(
-                errno.EEXIST, ""
-            )
+        with patch(self.poller_mock, return_vaue=Mock()) as m:
+            m.return_value.unregister.side_effect = OSError(errno.EEXIST, "")
             s, rd, wr = self.test_register()
             with pytest.raises(EnvironmentError):
                 s.unregister(rd)
 
     def test_enoent_on_modify(self):
         # ENOENT is supposed to be ignored
-        with mock.patch(self.poller_mock, return_vaue=mock.Mock()) as m:
+        with patch(self.poller_mock, return_vaue=Mock()) as m:
             m.return_value.modify.side_effect = OSError(errno.ENOENT, "")
             s, rd, wr = self.test_register()
             s.modify(rd, s.READ)
@@ -506,9 +487,9 @@ def get_connected_handler(self):
     def test_send_retry(self):
         ac = self.get_connected_handler()
         for errnum in pyftpdlib.ioloop._ERRNOS_RETRY:
-            with mock.patch(
+            with patch(
                 "pyftpdlib.ioloop.socket.socket.send",
-                side_effect=socket.error(errnum, ""),
+                side_effect=OSError(errnum, ""),
             ) as m:
                 assert ac.send(b"x") == 0
                 assert m.called
@@ -516,11 +497,11 @@ def test_send_retry(self):
     def test_send_disconnect(self):
         ac = self.get_connected_handler()
         for errnum in pyftpdlib.ioloop._ERRNOS_DISCONNECTED:
-            with mock.patch(
+            with patch(
                 "pyftpdlib.ioloop.socket.socket.send",
-                side_effect=socket.error(errnum, ""),
+                side_effect=OSError(errnum, ""),
             ) as send:
-                with mock.patch.object(ac, "handle_close") as handle_close:
+                with patch.object(ac, "handle_close") as handle_close:
                     assert ac.send(b"x") == 0
                     assert send.called
                     assert handle_close.called
@@ -528,9 +509,9 @@ def test_send_disconnect(self):
     def test_recv_retry(self):
         ac = self.get_connected_handler()
         for errnum in pyftpdlib.ioloop._ERRNOS_RETRY:
-            with mock.patch(
+            with patch(
                 "pyftpdlib.ioloop.socket.socket.recv",
-                side_effect=socket.error(errnum, ""),
+                side_effect=OSError(errnum, ""),
             ) as m:
                 with pytest.raises(RetryError):
                     ac.recv(1024)
@@ -539,21 +520,21 @@ def test_recv_retry(self):
     def test_recv_disconnect(self):
         ac = self.get_connected_handler()
         for errnum in pyftpdlib.ioloop._ERRNOS_DISCONNECTED:
-            with mock.patch(
+            with patch(
                 "pyftpdlib.ioloop.socket.socket.recv",
-                side_effect=socket.error(errnum, ""),
+                side_effect=OSError(errnum, ""),
             ) as send:
-                with mock.patch.object(ac, "handle_close") as handle_close:
+                with patch.object(ac, "handle_close") as handle_close:
                     assert ac.recv(b"x") == b''
                     assert send.called
                     assert handle_close.called
 
     def test_connect_af_unspecified_err(self):
         ac = AsyncChat()
-        with mock.patch.object(
-            ac, "connect", side_effect=socket.error(errno.EBADF, "")
+        with patch.object(
+            ac, "connect", side_effect=OSError(errno.EBADF, "")
         ) as m:
-            with pytest.raises(socket.error):
+            with pytest.raises(OSError):
                 ac.connect_af_unspecified(("localhost", 0))
             assert m.called
             assert ac.socket is None
@@ -563,10 +544,10 @@ class TestAcceptor(PyftpdlibTestCase):
 
     def test_bind_af_unspecified_err(self):
         ac = Acceptor()
-        with mock.patch.object(
-            ac, "bind", side_effect=socket.error(errno.EBADF, "")
+        with patch.object(
+            ac, "bind", side_effect=OSError(errno.EBADF, "")
         ) as m:
-            with pytest.raises(socket.error):
+            with pytest.raises(OSError):
                 ac.bind_af_unspecified(("localhost", 0))
             assert m.called
             assert ac.socket is None
@@ -574,8 +555,8 @@ def test_bind_af_unspecified_err(self):
     def test_handle_accept_econnacorted(self):
         # https://github.com/giampaolo/pyftpdlib/issues/105
         ac = Acceptor()
-        with mock.patch.object(
-            ac, "accept", side_effect=socket.error(errno.ECONNABORTED, "")
+        with patch.object(
+            ac, "accept", side_effect=OSError(errno.ECONNABORTED, "")
         ) as m:
             ac.handle_accept()
             assert m.called
@@ -584,7 +565,7 @@ def test_handle_accept_econnacorted(self):
     def test_handle_accept_typeerror(self):
         # https://github.com/giampaolo/pyftpdlib/issues/91
         ac = Acceptor()
-        with mock.patch.object(ac, "accept", side_effect=TypeError) as m:
+        with patch.object(ac, "accept", side_effect=TypeError) as m:
             ac.handle_accept()
             assert m.called
             assert ac.socket is None
diff --git a/pyftpdlib/test/test_servers.py b/pyftpdlib/test/test_servers.py
index dce8c521..5f0eaed0 100644
--- a/pyftpdlib/test/test_servers.py
+++ b/pyftpdlib/test/test_servers.py
@@ -10,7 +10,6 @@
 
 from pyftpdlib import handlers
 from pyftpdlib import servers
-from pyftpdlib._compat import super
 from pyftpdlib.test import BSD
 from pyftpdlib.test import GITHUB_ACTIONS
 from pyftpdlib.test import GLOBAL_TIMEOUT