Repository: jjjake/internetarchive (language: python)

Issue:
Consider dropping Python 2 support
It's been almost two years since Python 2 has reached end-of-life. Some things already broke, and this will only get worse over time. Also, the code is significantly more complex (everything involving six; see also my struggles on #405).

I suggest dropping Python 2 support and removing the accompanying code cruft.

@jjjake has previously said in #405 that he 'would like to support [Python 2] as long as we're able to'. As such, I don't expect this to be acted on immediately. But I think it is inevitable as dependencies have begun to drop Py2 support, slowly making it harder to keep the support here.

Relevant issues so far (in no particular order):

* `ItemLocate` error causes crash under Python 2: #385
* contextlib2, an upstream dependency of schema, no longer supports Python 2; by extension, the self-contained binary is now broken: https://github.com/jjjake/internetarchive/issues/420#issuecomment-922371186
* `ia tasks` on Python 2 can't work reliably (without API changes) due to the lack of appropriate decoding error handlers: #359 and #360

As of right now, the contextlib2 development breaks new ia installations entirely on Python 2 unless you manually install an older version of that package. It could be pinned, but that's just delaying the inevitable.

Further things to look forward to:

* pip has removed Python 2 support as of version 21.0: https://github.com/pypa/pip/issues/6148 https://github.com/pypa/pip/pull/9189
* Requests maintainers have previously stated that they will drop support once pip does: https://github.com/psf/requests/issues/5211


Discussion / hints:
Since many of the libraries that we rely on will soon be dropping their Python 2 support, I'm generally in favour of also dropping the Python 2 support for `internetarchive`.

In the past I've had to deal with environments where I could only easily run Python 2, since running Python 3 would have required that I cross compile it myself and somehow get it to permanently reside in the memory of those rather special systems, so there are certain use cases I see for which it would be good to keep Python 2 support.

But the only realistic option to keep the Python 2 support would be to pin the latest Python 2 supporting versions of all our dependencies, which is pretty bad from a security point of view.
Backporting the newer versions of all our dependencies to Python 2 is just not a realistic option.

Therefore it is most likely the best option to just drop the Python 2 support.

I don't see any reason to do so right away, but as soon as the first major dependency drops the support, we pretty much also have to do so. Maybe we should prepare for that time by stating in all important places, that the Python 2 support will be dropped to give the users time to prepare (and try to find out the rough time when this will take place).
urllib3, upstream dependency of requests, has dropped Python 2 support, though this isn't released yet: https://github.com/urllib3/urllib3/pull/2044
They additionally dropped the Python 3.5 support in that pull request, which we should also watch.
👍 Next week it is 700 days since Python 2 went EOL.  Let's cut a final release that supports legacy Python and then move forward.
Ok. Thanks to everyone for pushing on this, and thanks for your patience. It's been a long time coming! I think it's time, let's start talking about what a final PY2 release would look like (any outstanding bugs in PY2 we should try and deal with before shipping the last PY2 version, etc.), and go from there. Thanks again!
Some thoughts in no particular order:

* The only direct and fixable Py2 bug in the actual code I'm aware of is #385, which isn't a major issue in my opinion. Would be nice to fix, sure, but not convinced it deserves an extra release.
* #359 can't be fixed in Py2 without unreasonable effort. #360 uses `errors='surrogateescape'` even in the Py2 code, but that's not an really issue because the handler is only checked when something blows up anyway. No action needed here, I think.
* contextlib2 is actually alright, sort of. As long as users have a modern enough version of `pip`, they should automatically get the older version of the package when installing under Py2.
* The test suite dependencies are broken on Py2. I fixed this and will send a PR once #450 is merged (since it depends on that). But since that's in the test suite, no release needed for when it's fixed as the test code isn't shipped anyway.
* #420 is caused by how the pex is built rather than the Python code/package itself, so that doesn't require code changes I think, just possibly some updates to pex-requirements.txt (e.g. pinning contextlib2 despite not being a direct dependency).
* Otherwise, as far as I'm aware, it has mostly been a case of making code development harder due to having to constantly come up with Python 2 workarounds.

So overall, I can't really think of any particular significant things that should be fixed for a final Py2-compatible release. However, I also never used ia on Py2 myself, so I may be missing something.
Considering the above and the recent release of version 2.3.0, can we drop Py2 yet? :-)
@JustAnotherArchivist Yeah, I think we should move ahead with dropping PY2 support! Thanks for taking the time to consider all of the above, and following up on it.
Yay! I'll work on a PR to remove the cruft.

Fix patch:
diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml
index 644546c3..6bdc8446 100644
--- a/.github/workflows/tox.yml
+++ b/.github/workflows/tox.yml
@@ -6,7 +6,7 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        python: ['2.7', '3.5', '3.6', '3.7', '3.8', '3.9', '3.10', 'pypy-3.7']
+        python: ['3.6', '3.7', '3.8', '3.9', '3.10', 'pypy-3.7']
     steps:
       - uses: actions/checkout@v2
       - uses: actions/setup-python@v2
diff --git a/HISTORY.rst b/HISTORY.rst
index cdf02292..bbce8c97 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -3,6 +3,17 @@
 Release History
 ---------------
 
+Unreleased
+++++++++++
+
+**Breaking changes**
+
+- Removed Python 2.7 and 3.5 support
+
+**Bugfixes**
+
+- Fixed treatment of list-like file metadata in ``ia list`` under Python 3
+
 2.3.0 (2022-01-20)
 ++++++++++++++++++
 
diff --git a/Makefile b/Makefile
index 3bfeb349..9f17c1c4 100644
--- a/Makefile
+++ b/Makefile
@@ -30,8 +30,8 @@ docs:
 	@echo "\033[95m\n\nBuild successful! View the docs homepage at docs/build/html/index.html.\n\033[0m"
 
 binary:
-	pex . --python-shebang='/usr/bin/env python' --python python3 --python python2 -e internetarchive.cli.ia:main -o ia-$(VERSION)-py2.py3-none-any.pex -r pex-requirements.txt
+	pex . --python-shebang='/usr/bin/env python' --python python3 -e internetarchive.cli.ia:main -o ia-$(VERSION)-py3-none-any.pex -r pex-requirements.txt
 
 publish-binary:
-	./ia-$(VERSION)-py2.py3-none-any.pex upload ia-pex ia-$(VERSION)-py2.py3-none-any.pex --no-derive
-	./ia-$(VERSION)-py2.py3-none-any.pex upload ia-pex ia-$(VERSION)-py2.py3-none-any.pex --remote-name=ia --no-derive
+	./ia-$(VERSION)-py3-none-any.pex upload ia-pex ia-$(VERSION)-py3-none-any.pex --no-derive
+	./ia-$(VERSION)-py3-none-any.pex upload ia-pex ia-$(VERSION)-py3-none-any.pex --remote-name=ia --no-derive
diff --git a/docs/source/api.rst b/docs/source/api.rst
index f4a0b6e9..cd6d6475 100644
--- a/docs/source/api.rst
+++ b/docs/source/api.rst
@@ -92,7 +92,7 @@ By default logging is turned off.
 Other Configuration
 ~~~~~~~~~~~~~~~~~~~
 
-By default all requests are HTTPS in Python versions 2.7.10 or newer.
+By default all requests are HTTPS.
 You can change this setting in your config file in the ``general`` section::
 
     [general]
@@ -161,7 +161,7 @@ The default target to write to is ``metadata``.
 If you would like to write to another target, such as ``files``, you can specify so using the ``target`` parameter.
 For example, if we had an item whose identifier was ``my_identifier`` and you wanted to add a metadata field to a file within the item called foo.txt::
 
-    >>> r = modify_metadata('my_identifier', metadata=dict(title='My File'), target='files/foo.txt')
+    >>> r = modify_metadata('my_identifier', metadata={'title': 'My File'}, target='files/foo.txt')
     >>> from internetarchive import get_files
     >>> f = list(get_files('iacli-test-item301', 'foo.txt'))[0]
     >>> f.title
@@ -169,7 +169,7 @@ For example, if we had an item whose identifier was ``my_identifier`` and you wa
 
 You can also create new targets if they don’t exist::
 
-    >>> r = modify_metadata('my_identifier', metadata=dict(foo='bar'), target='extra_metadata')
+    >>> r = modify_metadata('my_identifier', metadata={'foo': 'bar'}, target='extra_metadata')
     >>> from internetarchive import get_item
     >>> item = get_item('my_identifier')
     >>> item.item_metadata['extra_metadata']
diff --git a/docs/source/conf.py b/docs/source/conf.py
index d37a247e..345df520 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -1,6 +1,5 @@
 # -*- coding: utf-8 -*-
-from __future__ import unicode_literals
-
+#
 # internetarchive documentation build configuration file, created by
 # sphinx-quickstart on Mon Sep 23 20:16:03 2013.
 #
@@ -290,6 +289,6 @@
 #autodoc_member_order = 'bysource'
 
 intersphinx_mapping = {
-    'python': ('https://docs.python.org/2.7', None),
+    'python': ('https://docs.python.org/3', None),
     'requests': ('http://docs.python-requests.org/en/latest/', None)
 }
diff --git a/docs/source/metadata.rst b/docs/source/metadata.rst
index 428391d7..3f9f7849 100644
--- a/docs/source/metadata.rst
+++ b/docs/source/metadata.rst
@@ -233,7 +233,7 @@ Or, in Python:
 .. code:: python
 
     >>> from internetarchive import modify_metadata
-    >>> md = dict(subject=['foo', 'bar'])
+    >>> md = {'subject': ['foo', 'bar']}
     >>> r = modify_metadata('<identifier>', md)
 
 It is helpful but **not** necessary for you to use `Library of Congress Subject Headings <http://id.loc.gov/authorities/subjects.html>`_ for the value of this metadata header.
diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst
index 530491e3..d439334b 100644
--- a/docs/source/quickstart.rst
+++ b/docs/source/quickstart.rst
@@ -30,7 +30,7 @@ Uploading
 Creating a new `item on archive.org <//archive.org/services/docs/api/items.html>`_ and uploading files to it is as easy as::
 
     >>> from internetarchive import upload
-    >>> md = dict(collection='test_collection', title='My New Item', mediatype='movies')
+    >>> md = {'collection': 'test_collection', 'title': 'My New Item', 'mediatype': 'movies'}
     >>> r = upload('<identifier>', files=['foo.txt', 'bar.mov'], metadata=md)
     >>> r[0].status_code
     200
@@ -41,7 +41,7 @@ You can set remote filename using a dictionary::
 
 You can upload file-like objects::
 
-    >>> r = upload('iacli-test-item301', {'foo.txt': StringIO(u'bar baz boo')})
+    >>> r = upload('iacli-test-item301', {'foo.txt': StringIO('bar baz boo')})
 
 If the item already has a file with the same filename, the existing file within the item will be overwritten.
 
@@ -104,28 +104,28 @@ Writing Metadata
 Adding new metadata to an item can be done using the :func:`modify_metadata <internetarchive.modify_metadata>` function::
 
     >>> from internetarchive import modify_metadata
-    >>> r = modify_metadata('<identifier>', metadata=dict(title='My Stuff'))
+    >>> r = modify_metadata('<identifier>', metadata={'title': 'My Stuff'})
     >>> r.status_code
     200
 
 Modifying metadata can also be done via the :class:`Item <internetarchive.Item>` object. For example, changing the title we set in the example above can be done like so::
     
-    >>> r = item.modify_metadata(dict(title='My New Title'))
+    >>> r = item.modify_metadata({'title': 'My New Title'})
     >>> item.metadata['title']
     'My New Title'
 
 To remove a metadata field from an item's metadata, set the value to ``'REMOVE_TAG'``::
 
-    >>> r = item.modify_metadata(dict(foo='new metadata field.'))
+    >>> r = item.modify_metadata({'foo': 'new metadata field.'})
     >>> item.metadata['foo']
     'new metadata field.'
-    >>> r = item.modify_metadata(dict(foo='REMOVE_TAG'))
+    >>> r = item.modify_metadata({'foo': 'REMOVE_TAG'})
     >>> print(item.metadata.get('foo'))
     None
 
 The default behaviour of :func:`modify_metadata <internetarchive.modify_metadata>` is to modify item-level metadata (i.e. title, description, etc.). If we want to modify different kinds of metadata, say the metadata of a specific file, we have to change the metadata ``target`` in the call to :func:`modify_metadata <internetarchive.modify_metadata>`::
 
-    >>> r = item.modify_metadata(dict(title='My File Title'), target='files/foo.txt')
+    >>> r = item.modify_metadata({'title': 'My File Title'}, target='files/foo.txt')
     >>> f = item.get_file('foo.txt')
     >>> f.title
     'My File Title'
diff --git a/docs/source/troubleshooting.rst b/docs/source/troubleshooting.rst
index 6b197ed4..2d1f2da2 100644
--- a/docs/source/troubleshooting.rst
+++ b/docs/source/troubleshooting.rst
@@ -30,7 +30,7 @@ If you are using a Python version earlier than 2.7.9, you might see ``InsecurePl
                .. code:: python
 
                  >>> from internetarchive import get_item
-                 >>> config = dict(general=dict(secure=False))
+                 >>> config = {'general': {'secure': False}}
                  >>> item = get_item('<identifier>', config=config)
 
            + In the command-line interface, use the ``--insecure`` option:
diff --git a/internetarchive/__init__.py b/internetarchive/__init__.py
index 552b40a4..c91bdea4 100644
--- a/internetarchive/__init__.py
+++ b/internetarchive/__init__.py
@@ -34,8 +34,6 @@
 :license: AGPL 3, see LICENSE for more details.
 """
 
-from __future__ import absolute_import
-
 __title__ = 'internetarchive'
 __version__ = '2.3.0'
 __author__ = 'Jacob M. Johnson'
@@ -79,13 +77,6 @@
 
 # Set default logging handler to avoid "No handler found" warnings.
 import logging
-try:  # Python 2.7+
-    from logging import NullHandler
-except ImportError:
-    class NullHandler(logging.Handler):
-        def emit(self, record):
-            pass
-
 
 log = logging.getLogger(__name__)
-log.addHandler(NullHandler())
+log.addHandler(logging.NullHandler())
diff --git a/internetarchive/api.py b/internetarchive/api.py
index 74a60699..8d4fb95b 100644
--- a/internetarchive/api.py
+++ b/internetarchive/api.py
@@ -26,9 +26,6 @@
 :copyright: (C) 2012-2019 by Internet Archive.
 :license: AGPL 3, see LICENSE for more details.
 """
-from __future__ import absolute_import
-
-from six.moves import input
 from getpass import getpass
 import requests
 
@@ -58,7 +55,7 @@ def get_session(config=None, config_file=None, debug=None, http_adapter_kwargs=N
     Usage:
 
         >>> from internetarchive import get_session
-        >>> config = dict(s3=dict(access='foo', secret='bar'))
+        >>> config = {'s3': {'access': 'foo', 'secret': 'bar'}}
         >>> s = get_session(config)
         >>> s.access_key
         'foo'
@@ -600,7 +597,7 @@ def get_user_info(access_key, secret_key):
     :param secret_key: IA-S3 secret_key to use when making the given request.
     """
     u = 'https://s3.us.archive.org'
-    p = dict(check_auth=1)
+    p = {'check_auth': 1}
     r = requests.get(u, params=p, auth=auth.S3Auth(access_key, secret_key))
     r.raise_for_status()
     j = r.json()
diff --git a/internetarchive/auth.py b/internetarchive/auth.py
index bc4cdbee..d4515631 100644
--- a/internetarchive/auth.py
+++ b/internetarchive/auth.py
@@ -49,7 +49,7 @@ def __call__(self, r):
                 raise AuthenticationError('No access_key or secret_key set!'
                                           ' Have you run `ia configure`?')
 
-        auth_str = 'LOW {a}:{s}'.format(a=self.access_key, s=self.secret_key)
+        auth_str = f'LOW {self.access_key}:{self.secret_key}'
         r.headers['Authorization'] = auth_str
         return r
 
@@ -61,7 +61,7 @@ def __init__(self, access_key=None, secret_key=None):
         self.secret_key = secret_key
 
     def __call__(self, r):
-        auth_str = '&access={a}&secret={s}'.format(a=self.access_key, s=self.secret_key)
+        auth_str = f'&access={self.access_key}&secret={self.secret_key}'
         if not r.body:
             r.body = ''
         r.body += auth_str
diff --git a/internetarchive/catalog.py b/internetarchive/catalog.py
index db23b338..92c5d092 100644
--- a/internetarchive/catalog.py
+++ b/internetarchive/catalog.py
@@ -26,8 +26,6 @@
 :copyright: (C) 2012-2019 by Internet Archive.
 :license: AGPL 3, see LICENSE for more details.
 """
-from __future__ import absolute_import
-
 try:
     import ujson as json
 except ImportError:
@@ -35,7 +33,6 @@
 from logging import getLogger
 from datetime import datetime
 
-import six
 from requests.exceptions import HTTPError
 import collections
 
@@ -54,7 +51,7 @@ def sort_by_date(task_dict):
         return datetime.strptime(task_dict['submittime'], '%Y-%m-%d %H:%M:%S')
 
 
-class Catalog(object):
+class Catalog:
     """This class represents the Archive.org catalog.
     You can use this class to access and submit tasks from the catalog.
 
@@ -90,9 +87,8 @@ def __init__(self, archive_session, request_kwargs=None):
         """
         self.session = archive_session
         self.auth = auth.S3Auth(self.session.access_key, self.session.secret_key)
-        self.request_kwargs = request_kwargs if request_kwargs else dict()
-        self.url = '{}//{}/services/tasks.php'.format(self.session.protocol,
-                                                      self.session.host)
+        self.request_kwargs = request_kwargs if request_kwargs else {}
+        self.url = f'{self.session.protocol}//{self.session.host}/services/tasks.php'
 
     def get_summary(self, identifier=None, params=None):
         """Get the total counts of catalog tasks meeting all criteria,
@@ -110,10 +106,10 @@ def get_summary(self, identifier=None, params=None):
 
         :rtype: dict
         """
-        params = params if params else dict()
+        params = params if params else {}
         if identifier:
             params['identifier'] = identifier
-        params.update(dict(summary=1, history=0, catalog=0))
+        params.update({'summary': 1, 'history': 0, 'catalog': 0})
         r = self.make_tasks_request(params)
         j = r.json()
         if j.get('success') is True:
@@ -160,22 +156,21 @@ def iter_tasks(self, params=None):
         while True:
             r = self.make_tasks_request(params)
             j = r.json()
-            for row in j.get('value', dict()).get('catalog', list()):
+            for row in j.get('value', {}).get('catalog', []):
                 yield CatalogTask(row, self)
-            for row in j.get('value', dict()).get('history', list()):
+            for row in j.get('value', {}).get('history', []):
                 yield CatalogTask(row, self)
-            if not j.get('value', dict()).get('cursor'):
+            if not j.get('value', {}).get('cursor'):
                 break
             params['cursor'] = j['value']['cursor']
 
     def get_rate_limit(self, cmd='derive.php'):
-        params = dict(rate_limits=1, cmd=cmd)
+        params = {'rate_limits': 1, 'cmd': cmd}
         r = self.make_tasks_request(params)
         line = ''
-        tasks = list()
+        tasks = []
         for c in r.iter_content():
-            if six.PY3:
-                c = c.decode('utf-8')
+            c = c.decode('utf-8')
             if c == '\n':
                 j = json.loads(line)
                 task = CatalogTask(j, self)
@@ -202,18 +197,17 @@ def get_tasks(self, identifier=None, params=None):
 
         :rtype: List[CatalogTask]
         """
-        params = params if params else dict()
+        params = params if params else {}
         if identifier:
-            params.update(dict(identifier=identifier))
-        params.update(dict(limit=0))
+            params.update({'identifier': identifier})
+        params.update({'limit': 0})
         if not params.get('summary'):
             params['summary'] = 0
         r = self.make_tasks_request(params)
         line = ''
-        tasks = list()
+        tasks = []
         for c in r.iter_content():
-            if six.PY3:
-                c = c.decode('utf-8')
+            c = c.decode('utf-8')
             if c == '\n':
                 j = json.loads(line)
                 task = CatalogTask(j, self)
@@ -261,13 +255,13 @@ def submit_task(self, identifier, cmd,
 
         :rtype: :class:`requests.Response`
         """
-        data = dict() if not data else data
-        data.update(dict(cmd=cmd, identifier=identifier))
+        data = {} if not data else data
+        data.update({'cmd': cmd, 'identifier': identifier})
         if comment:
             if 'args' in data:
                 data['args']['comment'] = comment
             else:
-                data['args'] = dict(comment=comment)
+                data['args'] = {'comment': comment}
         if priority:
             data['priority'] = priority
         r = self.session.post(self.url,
@@ -278,7 +272,7 @@ def submit_task(self, identifier, cmd,
         return r
 
 
-class CatalogTask(object):
+class CatalogTask:
     """This class represents an Archive.org catalog task. It is primarily used by
     :class:`Catalog`, and should not be used directly.
     """
@@ -335,14 +329,14 @@ def get_task_log(task_id, session, request_kwargs=None):
         :rtype: str
         :returns: The task log as a string.
         """
-        request_kwargs = request_kwargs if request_kwargs else dict()
+        request_kwargs = request_kwargs if request_kwargs else {}
         _auth = auth.S3Auth(session.access_key, session.secret_key)
         if session.host == 'archive.org':
             host = 'catalogd.archive.org'
         else:
             host = session.host
-        url = '{}//{}/services/tasks.php'.format(session.protocol, host)
-        params = dict(task_log=task_id)
+        url = f'{session.protocol}//{host}/services/tasks.php'
+        params = {'task_log': task_id}
         r = session.get(url, params=params, auth=_auth, **request_kwargs)
         r.raise_for_status()
         return r.content.decode('utf-8', errors='surrogateescape')
diff --git a/internetarchive/cli/argparser.py b/internetarchive/cli/argparser.py
index 1c3e9cd4..59c935c0 100644
--- a/internetarchive/cli/argparser.py
+++ b/internetarchive/cli/argparser.py
@@ -26,8 +26,7 @@
 """
 from collections import defaultdict
 import sys
-
-from six.moves.urllib.parse import parse_qsl
+from urllib.parse import parse_qsl
 
 
 def get_args_dict(args, query_string=False, header=False):
@@ -57,7 +56,7 @@ def get_args_dict(args, query_string=False, header=False):
 
 def get_args_header_dict(args):
     h = get_args_dict(args)
-    return dict((k, v.strip()) for k, v in h.items())
+    return {k: v.strip() for k, v in h.items()}
 
 
 def get_args_dict_many_write(metadata):
@@ -73,7 +72,7 @@ def get_args_dict_many_write(metadata):
 
 
 def convert_str_list_to_unicode(str_list):
-    unicode_list = list()
+    unicode_list = []
     for x in str_list:
         unicode_list.append(x.decode(sys.getfilesystemencoding()))
     return unicode_list
diff --git a/internetarchive/cli/ia.py b/internetarchive/cli/ia.py
index 321cc14c..d7f36feb 100644
--- a/internetarchive/cli/ia.py
+++ b/internetarchive/cli/ia.py
@@ -57,8 +57,6 @@
 
 See 'ia help <command>' for help on a specific command.
 """
-from __future__ import absolute_import, unicode_literals, print_function
-
 import sys
 import os
 import difflib
@@ -67,7 +65,6 @@
 
 from docopt import docopt, printable_usage
 from schema import Schema, Or, SchemaError
-import six
 
 from internetarchive import __version__
 from internetarchive.api import get_session
@@ -75,39 +72,39 @@
 suppress_keyboard_interrupt_message()
 
 
-cmd_aliases = dict(
-    co='configure',
-    md='metadata',
-    up='upload',
-    do='download',
-    rm='delete',
-    se='search',
-    ta='tasks',
-    ls='list',
-    cp='copy',
-    mv='move',
-    re='reviews',
-)
+cmd_aliases = {
+    'co': 'configure',
+    'md': 'metadata',
+    'up': 'upload',
+    'do': 'download',
+    'rm': 'delete',
+    'se': 'search',
+    'ta': 'tasks',
+    'ls': 'list',
+    'cp': 'copy',
+    'mv': 'move',
+    're': 'reviews',
+}
 
 
 def load_ia_module(cmd):
     """Dynamically import ia module."""
     try:
         if cmd in list(cmd_aliases.keys()) + list(cmd_aliases.values()):
-            _module = 'internetarchive.cli.ia_{0}'.format(cmd)
+            _module = f'internetarchive.cli.ia_{cmd}'
             return __import__(_module, fromlist=['internetarchive.cli'])
         else:
-            _module = 'ia_{0}'.format(cmd)
+            _module = f'ia_{cmd}'
             for ep in iter_entry_points('internetarchive.cli.plugins'):
                 if ep.name == _module:
                     return ep.load()
             raise ImportError
     except (ImportError, DistributionNotFound):
-        print("error: '{0}' is not an ia command! See 'ia help'".format(cmd),
+        print(f"error: '{cmd}' is not an ia command! See 'ia help'",
               file=sys.stderr)
         matches = '\t'.join(difflib.get_close_matches(cmd, cmd_aliases.values()))
         if matches:
-            print('\nDid you mean one of these?\n\t{0}'.format(matches))
+            print(f'\nDid you mean one of these?\n\t{matches}')
         sys.exit(127)
 
 
@@ -117,16 +114,16 @@ def main():
 
     # Validate args.
     s = Schema({
-        six.text_type: bool,
-        '--config-file': Or(None, *six.string_types),
-        '--host': Or(None, *six.string_types),
+        str: bool,
+        '--config-file': Or(None, str),
+        '--host': Or(None, str),
         '<args>': list,
         '<command>': Or(str, lambda _: 'help'),
     })
     try:
         args = s.validate(args)
     except SchemaError as exc:
-        print('{0}\n{1}'.format(str(exc), printable_usage(__doc__)), file=sys.stderr)
+        print(f'{exc}\n{printable_usage(__doc__)}', file=sys.stderr)
         sys.exit(1)
 
     # Get subcommand.
@@ -143,25 +140,25 @@ def main():
 
     if cmd != 'configure' and args['--config-file']:
         if not os.path.isfile(args['--config-file']):
-            print('--config-file should be a readable file.\n{0}'.format(
-                printable_usage(__doc__)), file=sys.stderr)
+            print(f'--config-file should be a readable file.\n{printable_usage(__doc__)}',
+                  file=sys.stderr)
             sys.exit(1)
 
     argv = [cmd] + args['<args>']
 
-    config = dict()
+    config = {}
     if args['--log']:
         config['logging'] = {'level': 'INFO'}
     elif args['--debug']:
         config['logging'] = {'level': 'DEBUG'}
 
     if args['--insecure']:
-        config['general'] = dict(secure=False)
+        config['general'] = {'secure': False}
     if args['--host']:
         if config.get('general'):
             config['general']['host'] = args['--host']
         else:
-            config['general'] = dict(host=args['--host'])
+            config['general'] = {'host': args['--host']}
 
     session = get_session(config_file=args['--config-file'],
                           config=config,
diff --git a/internetarchive/cli/ia_configure.py b/internetarchive/cli/ia_configure.py
index 96ad7094..506019db 100644
--- a/internetarchive/cli/ia_configure.py
+++ b/internetarchive/cli/ia_configure.py
@@ -35,7 +35,6 @@
     -n, --netrc                Use netrc file for login.
     -c, --print-cookies        Print archive.org logged-in-* cookies.
 """
-from __future__ import absolute_import, print_function, unicode_literals
 import sys
 import netrc
 
@@ -61,7 +60,7 @@ def main(argv, session):
                 print('error: "logged-in-sig" cookie not found in config file, '
                       'try reconfiguring.', file=sys.stderr)
             sys.exit(1)
-        print('logged-in-user={}; logged-in-sig={}'.format(user, sig))
+        print(f'logged-in-user={user}; logged-in-sig={sig}')
         sys.exit()
     try:
         # CLI params.
@@ -70,7 +69,7 @@ def main(argv, session):
                                          args['--password'],
                                          config_file=session.config_file,
                                          host=session.host)
-            print('Config saved to: {0}'.format(config_file_path))
+            print(f'Config saved to: {config_file_path}')
 
         # Netrc
         elif args['--netrc']:
@@ -85,15 +84,15 @@ def main(argv, session):
                                          password,
                                          config_file=session.config_file,
                                          host=session.host)
-            print('Config saved to: {0}'.format(config_file_path))
+            print(f'Config saved to: {config_file_path}')
 
         # Interactive input.
         else:
             print("Enter your Archive.org credentials below to configure 'ia'.\n")
             config_file_path = configure(config_file=session.config_file,
                                          host=session.host)
-            print('\nConfig saved to: {0}'.format(config_file_path))
+            print(f'\nConfig saved to: {config_file_path}')
 
     except AuthenticationError as exc:
-        print('\nerror: {0}'.format(str(exc)))
+        print(f'\nerror: {exc}')
         sys.exit(1)
diff --git a/internetarchive/cli/ia_copy.py b/internetarchive/cli/ia_copy.py
index 0707aa1b..4dc48f57 100644
--- a/internetarchive/cli/ia_copy.py
+++ b/internetarchive/cli/ia_copy.py
@@ -36,12 +36,11 @@
                                    will not be saved to history/files/$key.~N~
                                    [default: True].
 """
-from __future__ import print_function, absolute_import
 import sys
+from urllib.parse import quote
 
 from docopt import docopt, printable_usage
 from schema import Schema, Use, Or, And, SchemaError
-from six.moves.urllib import parse
 
 import internetarchive as ia
 from internetarchive.cli.argparser import get_args_dict
@@ -79,9 +78,8 @@ def main(argv, session, cmd='copy'):
         '<src-identifier>/<src-file>': And(str, And(And(str, lambda x: '/' in x,
             error='Destination not formatted correctly. See usage example.'),
             assert_src_file_exists, error=(
-            'https://{}/download/{} does not exist. '
-            'Please check the identifier and filepath and retry.'.format(session.host,
-                                                                         src_path)))),
+            f'https://{session.host}/download/{src_path} does not exist. '
+            'Please check the identifier and filepath and retry.'))),
         '<dest-identifier>/<dest-file>': And(str, lambda x: '/' in x,
             error='Destination not formatted correctly. See usage example.'),
         '--metadata': Or(None, And(Use(get_args_dict), dict),
@@ -97,11 +95,11 @@ def main(argv, session, cmd='copy'):
     except SchemaError as exc:
         # This module is sometimes called by other modules.
         # Replace references to 'ia copy' in ___doc__ to 'ia {cmd}' for clarity.
-        usage = printable_usage(__doc__.replace('ia copy', 'ia {}'.format(cmd)))
-        print('{0}\n{1}'.format(str(exc), usage), file=sys.stderr)
+        usage = printable_usage(__doc__.replace('ia copy', f'ia {cmd}'))
+        print(f'{exc}\n{usage}', file=sys.stderr)
         sys.exit(1)
 
-    args['--header']['x-amz-copy-source'] = '/{}'.format(parse.quote(src_path))
+    args['--header']['x-amz-copy-source'] = f'/{quote(src_path)}'
     # Copy the old metadata verbatim if no additional metadata is supplied,
     # else combine the old and the new metadata in a sensible manner.
     if args['--metadata'] or args['--replace-metadata']:
@@ -124,7 +122,7 @@ def main(argv, session, cmd='copy'):
     if not args['--header'].get('x-archive-keep-old-version') and not args['--no-backup']:
         args['--header']['x-archive-keep-old-version'] = '1'
 
-    url = '{}//s3.us.archive.org/{}'.format(session.protocol, parse.quote(dest_path))
+    url = f'{session.protocol}//s3.us.archive.org/{quote(dest_path)}'
     queue_derive = True if args['--no-derive'] is False else False
     req = ia.iarequest.S3Request(url=url,
                                  method='PUT',
@@ -141,10 +139,9 @@ def main(argv, session, cmd='copy'):
             msg = get_s3_xml_text(r.text)
         except Exception as e:
             msg = r.text
-        print('error: failed to {} "{}" to "{}" - {}'.format(
-            cmd, src_path, dest_path, msg))
+        print(f'error: failed to {cmd} "{src_path}" to "{dest_path}" - {msg}')
         sys.exit(1)
     elif cmd == 'copy':
-        print('success: copied "{}" to "{}".'.format(src_path, dest_path))
+        print(f'success: copied "{src_path}" to "{dest_path}".')
     else:
         return (r, SRC_FILE)
diff --git a/internetarchive/cli/ia_delete.py b/internetarchive/cli/ia_delete.py
index 8e2c9823..2cb56c5d 100644
--- a/internetarchive/cli/ia_delete.py
+++ b/internetarchive/cli/ia_delete.py
@@ -44,11 +44,7 @@
                                  will not be saved to history/files/$key.~N~
                                  [default: True].
 """
-from __future__ import absolute_import, print_function, unicode_literals
-
 import sys
-import six
-
 import requests.exceptions
 from docopt import docopt, printable_usage
 from schema import Schema, SchemaError, Use, Or, And
@@ -67,9 +63,8 @@ def main(argv, session):
 
     # Validate args.
     s = Schema({
-        six.text_type: Use(lambda x: bool(x)),
-        '<file>': And(list, Use(
-            lambda x: convert_str_list_to_unicode(x) if six.PY2 else x)),
+        str: Use(bool),
+        '<file>': list,
         '--format': list,
         '--header': Or(None, And(Use(get_args_dict), dict),
                        error='--header must be formatted as --header="key:value"'),
@@ -81,7 +76,7 @@ def main(argv, session):
     try:
         args = s.validate(args)
     except SchemaError as exc:
-        print('{0}\n{1}'.format(str(exc), printable_usage(__doc__)), file=sys.stderr)
+        print(f'{exc}\n{printable_usage(__doc__)}', file=sys.stderr)
         sys.exit(1)
 
     verbose = True if not args['--quiet'] else False
@@ -97,7 +92,7 @@ def main(argv, session):
         args['--header']['x-archive-keep-old-version'] = '1'
 
     if verbose:
-        print('Deleting files from {0}'.format(item.identifier))
+        print(f'Deleting files from {item.identifier}')
 
     if args['--all']:
         files = [f for f in item.get_files()]
@@ -109,10 +104,7 @@ def main(argv, session):
     else:
         fnames = []
         if args['<file>'] == ['-']:
-            if six.PY2:
-                fnames = convert_str_list_to_unicode([f.strip() for f in sys.stdin])
-            else:
-                fnames = [f.strip() for f in sys.stdin]
+            fnames = [f.strip() for f in sys.stdin]
         else:
             fnames = [f.strip() for f in args['<file>']]
 
@@ -127,12 +119,12 @@ def main(argv, session):
     for f in files:
         if not f:
             if verbose:
-                print(' error: "{0}" does not exist'.format(f.name), file=sys.stderr)
+                print(f' error: "{f.name}" does not exist', file=sys.stderr)
             errors = True
         if any(f.name.endswith(s) for s in no_delete):
             continue
         if args['--dry-run']:
-            print(' will delete: {0}/{1}'.format(item.identifier, f.name))
+            print(f' will delete: {item.identifier}/{f.name}')
             continue
         try:
             resp = f.delete(verbose=verbose,
@@ -140,14 +132,14 @@ def main(argv, session):
                             headers=args['--header'],
                             retries=args['--retries'])
         except requests.exceptions.RetryError as e:
-            print(' error: max retries exceeded for {0}'.format(f.name), file=sys.stderr)
+            print(f' error: max retries exceeded for {f.name}', file=sys.stderr)
             errors = True
             continue
 
         if resp.status_code != 204:
             errors = True
             msg = get_s3_xml_text(resp.content)
-            print(' error: {0} ({1})'.format(msg, resp.status_code), file=sys.stderr)
+            print(f' error: {msg} ({resp.status_code})', file=sys.stderr)
             continue
 
     if errors is True:
diff --git a/internetarchive/cli/ia_download.py b/internetarchive/cli/ia_download.py
index 408ede33..0b4f24f2 100644
--- a/internetarchive/cli/ia_download.py
+++ b/internetarchive/cli/ia_download.py
@@ -62,12 +62,10 @@
     -p, --parameters=<key:value>...          Parameters to send with your query (e.g. `cnt=0`).
     -a, --download-history                   Also download files from the history directory.
 """
-from __future__ import print_function, absolute_import
 import os
 from os.path import exists as dir_exists
 import sys
 
-import six
 from docopt import docopt, printable_usage
 from schema import Schema, Use, Or, And, SchemaError
 
@@ -99,10 +97,6 @@ def main(argv, session):
         '--parameters': Use(lambda x: get_args_dict(x, query_string=True)),
     })
 
-    # Filenames should be unicode literals. Support PY2 and PY3.
-    if six.PY2:
-        args['<file>'] = [f.decode('utf-8') for f in args['<file>']]
-
     try:
         args = s.validate(args)
         if args['--glob']:
@@ -110,8 +104,7 @@ def main(argv, session):
                 raise(SchemaError(None, '--glob and --format cannot be used together.'))
 
     except SchemaError as exc:
-        print('{0}\n{1}'.format(
-            str(exc), printable_usage(__doc__)), file=sys.stderr)
+        print(f'{exc}\n{printable_usage(__doc__)}', file=sys.stderr)
         sys.exit(1)
 
     retries = int(args['--retries'])
@@ -126,12 +119,11 @@ def main(argv, session):
                                            params=args['--search-parameters'])
             total_ids = _search.num_found
             if total_ids == 0:
-                print('error: the query "{0}" returned no results'.format(
-                    args['--search']), file=sys.stderr)
+                print(f'error: the query "{args["--search"]}" returned no results', file=sys.stderr)
                 sys.exit(1)
             ids = _search
         except ValueError as e:
-            print('error: {0}'.format(e), file=sys.stderr)
+            print(f'error: {e}', file=sys.stderr)
             sys.exit(1)
 
     # Download specific files.
@@ -151,7 +143,7 @@ def main(argv, session):
     else:
         files = None
 
-    errors = list()
+    errors = []
     for i, identifier in enumerate(ids):
         if args['--stdout']:
             item = session.get_item(identifier)
@@ -159,13 +151,9 @@ def main(argv, session):
             try:
                 assert len(f) == 1
             except AssertionError:
-                print('error: {0}/{1} does not exist!'.format(
-                    identifier, args['<file>'][0]), file=sys.stderr)
+                print(f'error: {identifier}/{args["<file>"][0]} does not exist!', file=sys.stderr)
                 sys.exit(1)
-            if six.PY2:
-                stdout_buf = sys.stdout
-            else:
-                stdout_buf = sys.stdout.buffer
+            stdout_buf = sys.stdout.buffer
             f[0].download(retries=args['--retries'],
                           fileobj=stdout_buf,
                           params=args['--parameters'])
@@ -175,17 +163,17 @@ def main(argv, session):
         except AttributeError:
             identifier = identifier.get('identifier')
         if total_ids > 1:
-            item_index = '{0}/{1}'.format((i + 1), total_ids)
+            item_index = f'{i + 1}/{total_ids}'
         else:
             item_index = None
 
         try:
             item = session.get_item(identifier)
         except Exception as exc:
-            print('{0}: failed to retrieve item metadata - errors'.format(identifier),
-                  file=sys.stderr)
+            print(f'{identifier}: failed to retrieve item metadata - errors', file=sys.stderr)
+            raise
             if 'You are attempting to make an HTTPS' in str(exc):
-                print('\n{0}'.format(exc), file=sys.stderr)
+                print(f'\n{exc}', file=sys.stderr)
                 sys.exit(1)
             else:
                 continue
diff --git a/internetarchive/cli/ia_list.py b/internetarchive/cli/ia_list.py
index b36d1b4c..a88905ce 100644
--- a/internetarchive/cli/ia_list.py
+++ b/internetarchive/cli/ia_list.py
@@ -36,7 +36,6 @@
 import csv
 from itertools import chain
 from fnmatch import fnmatch
-import six
 
 from docopt import docopt
 
@@ -69,15 +68,10 @@ def main(argv, session):
         file_dict = {}
         for key, val in f.items():
             if key in columns:
-                if six.PY2:
-                    if isinstance(val, (list, tuple, set)):
-                        val = ';'.join(val)
-                    val = val.encode('utf-8')
+                if isinstance(val, (list, tuple, set)):
+                    val = ';'.join(val)
                 if key == 'name' and args.get('--location'):
-                    file_dict[key] = ('https://{host}/download/'
-                                      '{id}/{f}'.format(host=session.host,
-                                                        id=item.identifier,
-                                                        f=val))
+                    file_dict[key] = f'https://{session.host}/download/{item.identifier}/{val}'
                 else:
                     file_dict[key] = val
         output.append(file_dict)
diff --git a/internetarchive/cli/ia_metadata.py b/internetarchive/cli/ia_metadata.py
index 7e9e38b5..835d8098 100644
--- a/internetarchive/cli/ia_metadata.py
+++ b/internetarchive/cli/ia_metadata.py
@@ -48,7 +48,6 @@
                                         Works on both single and multi-field metadata
                                         elements.
 """
-from __future__ import absolute_import, unicode_literals, print_function
 import sys
 import os
 try:
@@ -58,22 +57,15 @@
 import io
 from collections import defaultdict
 from copy import copy
+import csv
 
 from docopt import docopt, printable_usage
 from schema import Schema, SchemaError, Or, And, Use
-import six
 
 from internetarchive.cli.argparser import get_args_dict, get_args_dict_many_write,\
     get_args_header_dict
 from internetarchive.exceptions import ItemLocateError
 
-# Only import backports.csv for Python2 (in support of FreeBSD port).
-PY2 = sys.version_info[0] == 2
-if sys.version_info[0] == 2:
-    from backports import csv
-else:
-    import csv
-
 
 def modify_metadata(item, metadata, args):
     append = True if args['--append'] else False
@@ -83,7 +75,7 @@ def modify_metadata(item, metadata, args):
                                  priority=args['--priority'], append_list=append_list,
                                  headers=args['--header'])
     except ItemLocateError as exc:
-        print('{} - error: {}'.format(item.identifier, str(exc)), file=sys.stderr)
+        print(f'{item.identifier} - error: {exc}', file=sys.stderr)
         sys.exit(1)
     if not r.json()['success']:
         error_msg = r.json()['error']
@@ -91,10 +83,9 @@ def modify_metadata(item, metadata, args):
             etype = 'warning'
         else:
             etype = 'error'
-        print('{0} - {1} ({2}): {3}'.format(
-            item.identifier, etype, r.status_code, error_msg), file=sys.stderr)
+        print(f'{item.identifier} - {etype} ({r.status_code}): {error_msg}', file=sys.stderr)
         return r
-    print('{0} - success: {1}'.format(item.identifier, r.json()['log']))
+    print(f'{item.identifier} - success: {r.json()["log"]}')
     return r
 
 
@@ -103,8 +94,7 @@ def remove_metadata(item, metadata, args):
     for key in metadata:
         src_md = copy(item.metadata.get(key))
         if not src_md:
-            print('{0}/metadata/{1} does not exist, skipping.'.format(
-                item.identifier, key), file=sys.stderr)
+            print(f'{item.identifier}/metadata/{key} does not exist, skipping.', file=sys.stderr)
             continue
 
         if key == 'collection':
@@ -119,23 +109,21 @@ def remove_metadata(item, metadata, args):
                     r = item.remove_from_simplelist(c, 'holdings')
                     j = r.json()
                     if j.get('success'):
-                        print('{} - success: {} no longer in {}'.format(
-                              item.identifier, item.identifier, c))
+                        print(f'{item.identifier} - success: {item.identifier} no longer in {c}')
                         sys.exit(0)
                     elif j.get('error', '').startswith('no row to delete for'):
-                        print('{} - success: {} no longer in {}'.format(
-                              item.identifier, item.identifier, c))
+                        print(f'{item.identifier} - success: {item.identifier} no longer in {c}')
                         sys.exit(0)
                     else:
-                        print('{} - error: {}'.format(item.identifier, j.get('error')))
+                        print(f'{item.identifier} - error: {j.get("error")}')
                         sys.exit(1)
 
         if not isinstance(src_md, list):
             if key == 'subject':
                 src_md = src_md.split(';')
             elif key == 'collection':
-                print('{} - error: all collections would be removed, '
-                      'not submitting task.'.format(item.identifier), file=sys.stderr)
+                print(f'{item.identifier} - error: all collections would be removed, '
+                      'not submitting task.', file=sys.stderr)
                 sys.exit(1)
 
             if src_md == metadata[key]:
@@ -159,12 +147,11 @@ def remove_metadata(item, metadata, args):
             md[key] = 'REMOVE_TAG'
 
     if md.get('collection') == []:
-        print('{} - error: all collections would be removed, not submitting task.'.format(
-            item.identifier), file=sys.stderr)
+        print(f'{item.identifier} - error: all collections would be removed, not submitting task.',
+              file=sys.stderr)
         sys.exit(1)
     elif not md:
-        print('{} - warning: nothing needed to be removed.'.format(
-            item.identifier), file=sys.stderr)
+        print(f'{item.identifier} - warning: nothing needed to be removed.', file=sys.stderr)
         sys.exit(0)
 
     r = modify_metadata(item, md, args)
@@ -176,7 +163,7 @@ def main(argv, session):
 
     # Validate args.
     s = Schema({
-        six.text_type: bool,
+        str: bool,
         '<identifier>': list,
         '--modify': list,
         '--header': Or(None, And(Use(get_args_header_dict), dict),
@@ -192,7 +179,7 @@ def main(argv, session):
     try:
         args = s.validate(args)
     except SchemaError as exc:
-        print('{0}\n{1}'.format(str(exc), printable_usage(__doc__)), file=sys.stderr)
+        print(f'{exc}\n{printable_usage(__doc__)}', file=sys.stderr)
         sys.exit(1)
 
     formats = set()
@@ -205,10 +192,10 @@ def main(argv, session):
         if args['--exists']:
             if item.exists:
                 responses.append(True)
-                print('{0} exists'.format(identifier))
+                print(f'{identifier} exists')
             else:
                 responses.append(False)
-                print('{0} does not exist'.format(identifier), file=sys.stderr)
+                print(f'{identifier} does not exist', file=sys.stderr)
             if (i + 1) == len(args['<identifier>']):
                 if all(r is True for r in responses):
                     sys.exit(0)
@@ -279,7 +266,7 @@ def main(argv, session):
                 item = session.get_item(row['identifier'])
                 if row.get('file'):
                     del row['file']
-                metadata = dict((k.lower(), v) for (k, v) in row.items() if v)
+                metadata = {k.lower(): v for k, v in row.items() if v}
                 responses.append(modify_metadata(item, metadata, args))
 
             if all(r.status_code == 200 for r in responses):
diff --git a/internetarchive/cli/ia_move.py b/internetarchive/cli/ia_move.py
index ed463103..9e52cded 100644
--- a/internetarchive/cli/ia_move.py
+++ b/internetarchive/cli/ia_move.py
@@ -33,7 +33,6 @@
                                    will not be saved to history/files/$key.~N~
                                    [default: True].
 """
-from __future__ import print_function, absolute_import
 import sys
 
 from docopt import docopt, printable_usage
@@ -62,7 +61,7 @@ def main(argv, session):
     try:
         args = s.validate(args)
     except SchemaError as exc:
-        print('{0}\n{1}'.format(str(exc), printable_usage(__doc__)), file=sys.stderr)
+        print(f'{exc}\n{printable_usage(__doc__)}', file=sys.stderr)
         sys.exit(1)
 
     # Add keep-old-version by default.
@@ -77,6 +76,6 @@ def main(argv, session):
     r, src_file = ia_copy.main(argv, session, cmd='move')
     dr = src_file.delete(headers=args['--header'], cascade_delete=True)
     if dr.status_code == 204:
-        print('success: moved {} to {}'.format(src_path, dest_path))
+        print(f'success: moved {src_path} to {dest_path}')
         sys.exit(0)
-    print('error: {}'.format(dr.content))
+    print(f'error: {dr.content}')
diff --git a/internetarchive/cli/ia_reviews.py b/internetarchive/cli/ia_reviews.py
index d9336fc8..fd295531 100644
--- a/internetarchive/cli/ia_reviews.py
+++ b/internetarchive/cli/ia_reviews.py
@@ -40,7 +40,6 @@
 examples:
     ia reviews nasa
 """
-from __future__ import absolute_import, print_function
 import sys
 
 from docopt import docopt
@@ -67,13 +66,12 @@ def main(argv, session):
         r = item.review(args['--title'], args['--body'], args['--stars'])
     j = r.json()
     if j.get('success') or 'no change detected' in j.get('error', '').lower():
-        task_id = j.get('value', dict()).get('task_id')
+        task_id = j.get('value', {}).get('task_id')
         if task_id:
-            print('{} - success: https://catalogd.archive.org/log/{}'.format(
-                item.identifier, task_id))
+            print(f'{item.identifier} - success: https://catalogd.archive.org/log/{task_id}')
         else:
-            print('{} - warning: no changes detected!'.format(item.identifier))
+            print(f'{item.identifier} - warning: no changes detected!')
         sys.exit(0)
     else:
-        print('{} - error: {}'.format(item.identifier, j.get('error')))
+        print(f'{item.identifier} - error: {j.get("error")}')
         sys.exit(1)
diff --git a/internetarchive/cli/ia_search.py b/internetarchive/cli/ia_search.py
index d50eac17..002838ca 100644
--- a/internetarchive/cli/ia_search.py
+++ b/internetarchive/cli/ia_search.py
@@ -38,7 +38,6 @@
     -D, --dsl-fts                    Sumbit --fts query in dsl [default: False].
     -t, --timeout=<seconds>          Set the timeout in seconds [default: 300].
 """
-from __future__ import absolute_import, print_function, unicode_literals
 import sys
 try:
     import ujson as json
@@ -48,7 +47,6 @@
 
 from docopt import docopt, printable_usage
 from schema import Schema, SchemaError, Use, Or, And
-import six
 from requests.exceptions import ConnectTimeout, ReadTimeout
 
 from internetarchive import search_items
@@ -61,7 +59,7 @@ def main(argv, session=None):
 
     # Validate args.
     s = Schema({
-        six.text_type: Use(bool),
+        str: Use(bool),
         '<query>': Use(lambda x: ' '.join(x)),
         '--parameters': Use(lambda x: get_args_dict(x, query_string=True)),
         '--header': Or(None, And(Use(get_args_dict), dict),
@@ -74,17 +72,17 @@ def main(argv, session=None):
     try:
         args = s.validate(args)
     except SchemaError as exc:
-        print('{0}\n{1}'.format(str(exc), printable_usage(__doc__)), file=sys.stderr)
+        print(f'{exc}\n{printable_usage(__doc__)}', file=sys.stderr)
         sys.exit(1)
 
     # Support comma separated values.
     fields = list(chain.from_iterable([x.split(',') for x in args['--field']]))
     sorts = list(chain.from_iterable([x.split(',') for x in args['--sort']]))
 
-    r_kwargs = dict(
-        headers=args['--header'],
-        timeout=args['--timeout'],
-    )
+    r_kwargs = {
+        'headers': args['--header'],
+        'timeout': args['--timeout'],
+    }
 
     search = session.search_items(args['<query>'],
                                   fields=fields,
@@ -96,7 +94,7 @@ def main(argv, session=None):
 
     try:
         if args['--num-found']:
-            print('{0}'.format(search.num_found))
+            print(search.num_found)
             sys.exit(0)
 
         for result in search:
@@ -108,7 +106,7 @@ def main(argv, session=None):
                 if result.get('error'):
                     sys.exit(1)
     except ValueError as e:
-        print('error: {0}'.format(e), file=sys.stderr)
+        print(f'error: {e}', file=sys.stderr)
     except ConnectTimeout as exc:
         print('error: Request timed out. Increase the --timeout and try again.',
               file=sys.stderr)
@@ -118,5 +116,5 @@ def main(argv, session=None):
               ' please try again', file=sys.stderr)
         sys.exit(1)
     except AuthenticationError as exc:
-        print('error: {}'.format(exc), file=sys.stderr)
+        print(f'error: {exc}', file=sys.stderr)
         sys.exit(1)
diff --git a/internetarchive/cli/ia_tasks.py b/internetarchive/cli/ia_tasks.py
index 0abd5b03..3c4971d5 100644
--- a/internetarchive/cli/ia_tasks.py
+++ b/internetarchive/cli/ia_tasks.py
@@ -65,13 +65,11 @@
     ia tasks <id> --cmd fixer.php --task-args 'noop:1;asr:1'  # submit multiple fixer ops
     ia tasks --get-rate-limit --cmd derive.php  # Get rate-limit information for a specific command
 """
-from __future__ import absolute_import, print_function
 import sys
 import warnings
 import json
 
 from docopt import docopt
-import six
 
 from internetarchive.cli.argparser import get_args_dict
 
@@ -96,28 +94,26 @@ def main(argv, session):
                                 data=data)
         j = r.json()
         if j.get('success'):
-            print('success: {}'.format(j.get('value', dict()).get('log')))
+            task_log_url = j.get('value', {}).get('log')
+            print(f'success: {task_log_url}')
             sys.exit(0)
         elif 'already queued/running' in j.get('error', ''):
-            print('success: {} task already queued/running'.format(args['--cmd']))
+            print(f'success: {args["--cmd"]} task already queued/running')
             sys.exit(0)
         else:
-            print('error: {}'.format(j.get('error')))
+            print(f'error: {j.get("error")}')
             sys.exit(1)
 
     # Tasks read API.
     params = get_args_dict(args['--parameter'], query_string=True)
     if args['<identifier>']:
-        _params = dict(identifier=args['<identifier>'], catalog=1, history=1)
+        _params = {'identifier': args['<identifier>'], 'catalog': 1, 'history': 1}
         _params.update(params)
         params = _params
     elif args['--get-task-log']:
         log = session.get_task_log(args['--get-task-log'], params)
-        if six.PY2:
-            print(log.encode('utf-8', errors='surrogateescape'))
-        else:
-            print(log.encode('utf-8', errors='surrogateescape')
-                     .decode('utf-8', errors='replace'))
+        print(log.encode('utf-8', errors='surrogateescape')
+                 .decode('utf-8', errors='replace'))
         sys.exit(0)
 
     queryable_params = [
@@ -134,12 +130,12 @@ def main(argv, session):
 
     if not (args['<identifier>']
             or params.get('task_id')):
-        _params = dict(catalog=1, history=0)
+        _params = {'catalog': 1, 'history': 0}
         _params.update(params)
         params = _params
 
     if not any(x in params for x in queryable_params):
-        _params = dict(submitter=session.user_email, catalog=1, history=0, summary=0)
+        _params = {'submitter': session.user_email, 'catalog': 1, 'history': 0, 'summary': 0}
         _params.update(params)
         params = _params
 
@@ -151,7 +147,7 @@ def main(argv, session):
         # Legacy support for tab-delimted output.
         if args['--tab-output']:
             color = t.color if t.color else 'done'
-            task_args = '\t'.join(['{}={}'.format(k, v) for k, v in t.args.items()])
+            task_args = '\t'.join([f'{k}={v}' for k, v in t.args.items()])
             output = '\t'.join([str(x) for x in [
                 t.identifier,
                 t.task_id,
diff --git a/internetarchive/cli/ia_upload.py b/internetarchive/cli/ia_upload.py
index b852eb4d..aefae834 100644
--- a/internetarchive/cli/ia_upload.py
+++ b/internetarchive/cli/ia_upload.py
@@ -60,8 +60,6 @@
                                          will not be saved to history/files/$key.~N~
                                          [default: True].
 """
-from __future__ import absolute_import, unicode_literals, print_function
-
 import io
 import os
 import sys
@@ -69,8 +67,8 @@
 from copy import deepcopy
 import webbrowser
 import json
+import csv
 
-import six
 from docopt import docopt, printable_usage
 from requests.exceptions import HTTPError
 from schema import Schema, Use, Or, And, SchemaError
@@ -80,19 +78,12 @@
 from internetarchive.utils import (InvalidIdentifierException, get_s3_xml_text,
                                    is_valid_metadata_key, validate_s3_identifier)
 
-# Only import backports.csv for Python2 (in support of FreeBSD port).
-PY2 = sys.version_info[0] == 2
-if PY2:
-    from backports import csv
-else:
-    import csv
-
 
 def _upload_files(item, files, upload_kwargs, prev_identifier=None, archive_session=None):
     """Helper function for calling :meth:`Item.upload`"""
     responses = []
     if (upload_kwargs['verbose']) and (prev_identifier != item.identifier):
-        print('{0}:'.format(item.identifier))
+        print(f'{item.identifier}:')
 
     try:
         response = item.upload(files, **upload_kwargs)
@@ -109,10 +100,10 @@ def _upload_files(item, files, upload_kwargs, prev_identifier=None, archive_sess
                 if i != 0:
                     print('---')
                 headers = '\n'.join(
-                    [' {0}:{1}'.format(k, v) for (k, v) in r.headers.items()]
+                    [f' {k}:{v}' for (k, v) in r.headers.items()]
                 )
-                print('Endpoint:\n {0}\n'.format(r.url))
-                print('HTTP Headers:\n{0}'.format(headers))
+                print(f'Endpoint:\n {r.url}\n')
+                print(f'HTTP Headers:\n{headers}')
                 return responses
 
         # Format error message for any non 200 responses that
@@ -130,10 +121,7 @@ def _upload_files(item, files, upload_kwargs, prev_identifier=None, archive_sess
 
 
 def main(argv, session):
-    if six.PY2:
-        args = docopt(__doc__.encode('utf-8'), argv=argv)
-    else:
-        args = docopt(__doc__, argv=argv)
+    args = docopt(__doc__, argv=argv)
     ERRORS = False
 
     # Validate args.
@@ -145,13 +133,11 @@ def main(argv, session):
                    'underscores "_", or dashes "-". However, <identifier> cannot begin '
                    'with periods, underscores, or dashes.'))),
         '<file>': And(
-            Use(lambda l: l if not six.PY2 else convert_str_list_to_unicode(l)),
             And(lambda f: all(os.path.exists(x) for x in f if x != '-'),
                 error='<file> should be a readable file or directory.'),
             And(lambda f: False if f == ['-'] and not args['--remote-name'] else True,
                 error='--remote-name must be provided when uploading from stdin.')),
-        '--remote-name': Or(None,
-            Use(lambda x: x.decode(sys.getfilesystemencoding()) if six.PY2 else x)),
+        '--remote-name': Or(None, str),
         '--spreadsheet': Or(None, os.path.isfile,
                             error='--spreadsheet should be a readable file.'),
         '--file-metadata': Or(None, os.path.isfile,
@@ -169,7 +155,7 @@ def main(argv, session):
     try:
         args = s.validate(args)
     except SchemaError as exc:
-        print('{0}\n{1}'.format(str(exc), printable_usage(__doc__)), file=sys.stderr)
+        print(f'{exc}\n{printable_usage(__doc__)}', file=sys.stderr)
         sys.exit(1)
 
     # Make sure the collection being uploaded to exists.
@@ -180,20 +166,19 @@ def main(argv, session):
         collection = session.get_item(collection_id)
         if not collection.exists:
             print('You must upload to a collection that exists. '
-                  '"{0}" does not exist.\n{1}'.format(collection_id,
-                                                      printable_usage(__doc__)),
+                  f'"{collection_id}" does not exist.\n{printable_usage(__doc__)}',
                   file=sys.stderr)
             sys.exit(1)
 
     # Status check.
     if args['--status-check']:
         if session.s3_is_overloaded():
-            print('warning: {0} is over limit, and not accepting requests. '
-                  'Expect 503 SlowDown errors.'.format(args['<identifier>']),
+            print(f'warning: {args["<identifier>"]} is over limit, and not accepting requests. '
+                  'Expect 503 SlowDown errors.',
                   file=sys.stderr)
             sys.exit(1)
         else:
-            print('success: {0} is accepting requests.'.format(args['<identifier>']))
+            print(f'success: {args["<identifier>"]} is accepting requests.')
             sys.exit()
 
     elif args['<identifier>']:
@@ -213,23 +198,23 @@ def main(argv, session):
         try:
             args['<file>'] = json.load(open(args['--file-metadata']))
         except json.decoder.JSONDecodeError:
-            args['<file>'] = list()
+            args['<file>'] = []
             for line in open(args['--file-metadata']):
                 j = json.loads(line.strip())
                 args['<file>'].append(j)
-    upload_kwargs = dict(
-        metadata=args['--metadata'],
-        headers=args['--header'],
-        debug=args['--debug'],
-        queue_derive=queue_derive,
-        verbose=verbose,
-        verify=args['--verify'],
-        checksum=args['--checksum'],
-        retries=args['--retries'],
-        retries_sleep=args['--sleep'],
-        delete=args['--delete'],
-        validate_identifier=True
-    )
+    upload_kwargs = {
+        'metadata': args['--metadata'],
+        'headers': args['--header'],
+        'debug': args['--debug'],
+        'queue_derive': queue_derive,
+        'verbose': verbose,
+        'verify': args['--verify'],
+        'checksum': args['--checksum'],
+        'retries': args['--retries'],
+        'retries_sleep': args['--sleep'],
+        'delete': args['--delete'],
+        'validate_identifier': True,
+    }
 
     # Upload files.
     if not args['--spreadsheet']:
@@ -254,8 +239,8 @@ def main(argv, session):
                 ERRORS = True
             else:
                 if args['--open-after-upload']:
-                    webbrowser.open_new_tab('{}//{}/details/{}'.format(
-                        session.protocol, session.host, item.identifier))
+                    url = f'{session.protocol}//{session.host}/details/{item.identifier}'
+                    webbrowser.open_new_tab(url)
 
     # Bulk upload using spreadsheet.
     else:
@@ -266,7 +251,7 @@ def main(argv, session):
             for row in spreadsheet:
                 for metadata_key in row:
                     if not is_valid_metadata_key(metadata_key):
-                        print('error: "%s" is not a valid metadata key.' % metadata_key,
+                        print(f'error: "{metadata_key}" is not a valid metadata key.',
                               file=sys.stderr)
                         sys.exit(1)
                 upload_kwargs_copy = deepcopy(upload_kwargs)
@@ -290,7 +275,7 @@ def main(argv, session):
                 item = session.get_item(identifier)
                 # TODO: Clean up how indexed metadata items are coerced
                 # into metadata.
-                md_args = ['{0}:{1}'.format(k.lower(), v) for (k, v) in row.items() if v]
+                md_args = [f'{k.lower()}:{v}' for (k, v) in row.items() if v]
                 metadata = get_args_dict(md_args)
                 upload_kwargs_copy['metadata'].update(metadata)
                 r = _upload_files(item, local_file, upload_kwargs_copy, prev_identifier,
@@ -302,8 +287,8 @@ def main(argv, session):
                         ERRORS = True
                     else:
                         if args['--open-after-upload']:
-                            webbrowser.open_new_tab('{}//{}/details/{}'.format(
-                                session.protocol, session.host, identifier))
+                            url = f'{session.protocol}//{session.host}/details/{identifier}'
+                            webbrowser.open_new_tab(url)
                 prev_identifier = identifier
 
     if ERRORS:
diff --git a/internetarchive/config.py b/internetarchive/config.py
index d42f8dc0..cb128b37 100644
--- a/internetarchive/config.py
+++ b/internetarchive/config.py
@@ -24,12 +24,10 @@
 :copyright: (C) 2012-2019 by Internet Archive.
 :license: AGPL 3, see LICENSE for more details.
 """
-from __future__ import absolute_import
-
 import errno
 import os
 from collections import defaultdict
-from six.moves import configparser
+import configparser
 
 import requests
 
@@ -40,9 +38,9 @@
 
 def get_auth_config(email, password, host=None):
     host = host if host else 'archive.org'
-    u = 'https://{}/services/xauthn/'.format(host)
-    p = dict(op='login')
-    d = dict(email=email, password=password)
+    u = f'https://{host}/services/xauthn/'
+    p = {'op': 'login'}
+    d = {'email': email, 'password': password}
     r = requests.post(u, params=p, data=d)
     j = r.json()
     if not j.get('success'):
@@ -55,7 +53,7 @@ def get_auth_config(email, password, host=None):
         elif msg == 'account_bad_password':
             msg = 'Incorrect password, try again.'
         else:
-            msg = 'Authentication failed: {}'.format(msg)
+            msg = f'Authentication failed: {msg}'
         raise AuthenticationError(msg)
     auth_config = {
         's3': {
@@ -98,14 +96,7 @@ def write_config_file(auth_config, config_file=None):
         # The XDG Base Dir spec requires that the XDG_CONFIG_HOME directory be created with mode 700.
         # is_xdg will be True iff config_file is ${XDG_CONFIG_HOME}/internetarchive/ia.ini.
         # So create grandparent first if necessary then parent to ensure both have the right mode.
-        try:
-            os.makedirs(os.path.dirname(config_directory), mode=0o700, exist_ok=True)
-        except TypeError:  # Python 2 doesn't have exist_ok
-            try:
-                os.makedirs(os.path.dirname(config_directory), mode=0o700)
-            except OSError as e:
-                if e.errno != errno.EEXIST or not os.path.isdir(os.path.dirname(config_directory)):
-                    raise
+        os.makedirs(os.path.dirname(config_directory), mode=0o700, exist_ok=True)
         os.mkdir(config_directory, 0o700)
 
     # Write config file.
@@ -187,4 +178,4 @@ def get_config(config=None, config_file=None):
     # Recursive/deep update.
     deep_update(config_dict, _config)
 
-    return dict((k, v) for k, v in config_dict.items() if v is not None)
+    return {k: v for k, v in config_dict.items() if v is not None}
diff --git a/internetarchive/files.py b/internetarchive/files.py
index 7c08d616..d8794fe3 100644
--- a/internetarchive/files.py
+++ b/internetarchive/files.py
@@ -24,15 +24,11 @@
 :copyright: (C) 2012-2019 by Internet Archive.
 :license: AGPL 3, see LICENSE for more details.
 """
-from __future__ import absolute_import, unicode_literals, print_function
-
 import os
 import sys
 import logging
 import socket
-
-import six.moves.urllib as urllib
-import six
+from urllib.parse import quote
 from requests.exceptions import HTTPError, RetryError, ConnectTimeout, \
     ConnectionError, ReadTimeout
 
@@ -42,11 +38,11 @@
 log = logging.getLogger(__name__)
 
 
-class BaseFile(object):
+class BaseFile:
 
     def __init__(self, item_metadata, name, file_metadata=None):
         if file_metadata is None:
-            file_metadata = dict()
+            file_metadata = {}
         name = name.strip('/')
         if not file_metadata:
             for f in item_metadata.get('files', []):
@@ -113,19 +109,14 @@ def __init__(self, item, name, file_metadata=None):
         :param file_metadata: (optional) a dict of metadata for the
                               given file.
         """
-        if six.PY2:
-            try:
-                name = name.decode('utf-8')
-            except UnicodeEncodeError:
-                pass
-        super(File, self).__init__(item.item_metadata, name, file_metadata)
+        super().__init__(item.item_metadata, name, file_metadata)
         self.item = item
-        url_parts = dict(
-            protocol=item.session.protocol,
-            id=self.identifier,
-            name=urllib.parse.quote(name.encode('utf-8')),
-            host=item.session.host,
-        )
+        url_parts = {
+            'protocol': item.session.protocol,
+            'id': self.identifier,
+            'name': quote(name.encode('utf-8')),
+            'host': item.session.host,
+        }
         self.url = '{protocol}//{host}/download/{id}/{name}'.format(**url_parts)
         if self.item.session.access_key and self.item.session.secret_key:
             self.auth = auth.S3Auth(self.item.session.access_key,
@@ -134,10 +125,10 @@ def __init__(self, item, name, file_metadata=None):
             self.auth = None
 
     def __repr__(self):
-        return ('File(identifier={identifier!r}, '
-                'filename={name!r}, '
-                'size={size!r}, '
-                'format={format!r})'.format(**self.__dict__))
+        return (f'File(identifier={self.identifier!r}, '
+                f'filename={self.name!r}, '
+                f'size={self.size!r}, '
+                f'format={self.format!r})')
 
     def download(self, file_path=None, verbose=None, silent=None, ignore_existing=None,
                  checksum=None, destdir=None, retries=None, ignore_errors=None,
@@ -213,15 +204,15 @@ def download(self, file_path=None, verbose=None, silent=None, ignore_existing=No
             if not os.path.exists(destdir) and return_responses is not True:
                 os.mkdir(destdir)
             if os.path.isfile(destdir):
-                raise IOError('{} is not a directory!'.format(destdir))
+                raise IOError(f'{destdir} is not a directory!')
             file_path = os.path.join(destdir, file_path)
 
         if not return_responses and os.path.exists(file_path.encode('utf-8')):
             if ignore_existing:
-                msg = 'skipping {0}, file already exists.'.format(file_path)
+                msg = f'skipping {file_path}, file already exists.'
                 log.info(msg)
                 if verbose:
-                    print(' ' + msg)
+                    print(f' {msg}')
                 elif silent is False:
                     print('.', end='')
                     sys.stdout.flush()
@@ -231,11 +222,10 @@ def download(self, file_path=None, verbose=None, silent=None, ignore_existing=No
                     md5_sum = utils.get_md5(fp)
 
                 if md5_sum == self.md5:
-                    msg = ('skipping {0}, '
-                           'file already exists based on checksum.'.format(file_path))
+                    msg = f'skipping {file_path}, file already exists based on checksum.'
                     log.info(msg)
                     if verbose:
-                        print(' ' + msg)
+                        print(f' {msg}')
                     elif silent is False:
                         print('.', end='')
                         sys.stdout.flush()
@@ -244,11 +234,10 @@ def download(self, file_path=None, verbose=None, silent=None, ignore_existing=No
                 st = os.stat(file_path.encode('utf-8'))
                 if (st.st_mtime == self.mtime) and (st.st_size == self.size) \
                         or self.name.endswith('_files.xml') and st.st_size != 0:
-                    msg = ('skipping {0}, file already exists '
-                           'based on length and date.'.format(file_path))
+                    msg = f'skipping {file_path}, file already exists based on length and date.'
                     log.info(msg)
                     if verbose:
-                        print(' ' + msg)
+                        print(f' {msg}')
                     elif silent is False:
                         print('.', end='')
                         sys.stdout.flush()
@@ -281,13 +270,12 @@ def download(self, file_path=None, verbose=None, silent=None, ignore_existing=No
                         fileobj.write(chunk)
         except (RetryError, HTTPError, ConnectTimeout,
                 ConnectionError, socket.error, ReadTimeout) as exc:
-            msg = ('error downloading file {0}, '
-                   'exception raised: {1}'.format(file_path, exc))
+            msg = f'error downloading file {file_path}, exception raised: {exc}'
             log.error(msg)
             if os.path.exists(file_path):
                 os.remove(file_path)
             if verbose:
-                print(' ' + msg)
+                print(f' {msg}')
             elif silent is False:
                 print('e', end='')
                 sys.stdout.flush()
@@ -305,12 +293,10 @@ def download(self, file_path=None, verbose=None, silent=None, ignore_existing=No
                 # Probably file-like object, e.g. sys.stdout.
                 pass
 
-        msg = 'downloaded {0}/{1} to {2}'.format(self.identifier,
-                                                 self.name,
-                                                 file_path)
+        msg = f'downloaded {self.identifier}/{self.name} to {file_path}'
         log.info(msg)
         if verbose:
-            print(' ' + msg)
+            print(f' {msg}')
         elif silent is False:
             print('d', end='')
             sys.stdout.flush()
@@ -347,14 +333,12 @@ def delete(self, cascade_delete=None, access_key=None, secret_key=None, verbose=
         debug = False if not debug else debug
         verbose = False if not verbose else verbose
         max_retries = 2 if retries is None else retries
-        headers = dict() if headers is None else headers
+        headers = {} if headers is None else headers
 
         if 'x-archive-cascade-delete' not in headers:
             headers['x-archive-cascade-delete'] = cascade_delete
 
-        url = '{0}//s3.us.archive.org/{1}/{2}'.format(self.item.session.protocol,
-                                                      self.identifier,
-                                                      urllib.parse.quote(self.name))
+        url = f'{self.item.session.protocol}//s3.us.archive.org/{self.identifier}/{quote(self.name)}'
         self.item.session.mount_http_adapter(max_retries=max_retries,
                                              status_forcelist=[503],
                                              host='s3.us.archive.org')
@@ -369,7 +353,7 @@ def delete(self, cascade_delete=None, access_key=None, secret_key=None, verbose=
             return request
         else:
             if verbose:
-                msg = ' deleting: {0}'.format(self.name)
+                msg = f' deleting: {self.name}'
                 if cascade_delete:
                     msg += ' and all derivative files.'
                 print(msg, file=sys.stderr)
@@ -380,7 +364,7 @@ def delete(self, cascade_delete=None, access_key=None, secret_key=None, verbose=
                 resp.raise_for_status()
             except (RetryError, HTTPError, ConnectTimeout,
                     ConnectionError, socket.error, ReadTimeout) as exc:
-                error_msg = 'Error deleting {0}, {1}'.format(url, exc)
+                error_msg = f'Error deleting {url}, {exc}'
                 log.error(error_msg)
                 raise
             else:
@@ -391,7 +375,7 @@ def delete(self, cascade_delete=None, access_key=None, secret_key=None, verbose=
                 # mounted if and when the session object is used for an
                 # upload. This is important because we use custom retry
                 # handling for IA-S3 uploads.
-                url_prefix = '{0}//s3.us.archive.org'.format(self.item.session.protocol)
+                url_prefix = f'{self.item.session.protocol}//s3.us.archive.org'
                 del self.item.session.adapters[url_prefix]
 
 
@@ -405,4 +389,4 @@ def __init__(self, item, name):
         :param name: The filename of the file.
 
         """
-        super(OnTheFlyFile, self).__init__(item.item_metadata, name)
+        super().__init__(item.item_metadata, name)
diff --git a/internetarchive/iarequest.py b/internetarchive/iarequest.py
index 8e823ece..74070cf4 100644
--- a/internetarchive/iarequest.py
+++ b/internetarchive/iarequest.py
@@ -24,8 +24,6 @@
 :copyright: (C) 2012-2021 by Internet Archive.
 :license: AGPL 3, see LICENSE for more details.
 """
-from __future__ import absolute_import
-
 try:
     import ujson as json
 except ImportError:
@@ -33,12 +31,10 @@
 import re
 import copy
 import logging
-
-from six.moves import urllib
+from urllib.parse import quote
 import requests.models
 import requests
 from jsonpatch import make_patch
-import six
 
 from internetarchive import auth, __version__
 from internetarchive.utils import needs_quote, delete_items_from_dict
@@ -57,7 +53,7 @@ def __init__(self,
                  secret_key=None,
                  **kwargs):
 
-        super(S3Request, self).__init__(**kwargs)
+        super().__init__(**kwargs)
 
         if not self.auth:
             self.auth = auth.S3Auth(access_key, secret_key)
@@ -91,11 +87,6 @@ def prepare(self):
 
 
 class S3PreparedRequest(requests.models.PreparedRequest):
-
-    # __init__()
-    def __init__(self):
-        super(S3PreparedRequest, self).__init__()
-
     def prepare(self, method=None, url=None, headers=None, files=None, data=None,
                 params=None, auth=None, cookies=None, hooks=None, queue_derive=None,
                 metadata=None, file_metadata=None):
@@ -125,11 +116,11 @@ def prepare_headers(self, headers, metadata, file_metadata=None, queue_derive=Tr
         :param headers: (optional) S3 compatible HTTP headers.
 
         """
-        metadata = dict() if metadata is None else metadata
-        file_metadata = dict() if file_metadata is None else file_metadata
+        metadata = {} if metadata is None else metadata
+        file_metadata = {} if file_metadata is None else file_metadata
 
         if not metadata.get('scanner'):
-            scanner = 'Internet Archive Python library {0}'.format(__version__)
+            scanner = f'Internet Archive Python library {__version__}'
             metadata['scanner'] = scanner
         prepared_metadata = prepare_metadata(metadata)
         prepared_file_metadata = prepare_metadata(file_metadata)
@@ -150,19 +141,16 @@ def _prepare_metadata_headers(prepared_metadata, meta_type='meta'):
                     meta_value = json.dumps(meta_value)
                 # Convert the metadata value into a list if it is not already
                 # iterable.
-                if (isinstance(meta_value, six.string_types)
-                        or not hasattr(meta_value, '__iter__')):
+                if (isinstance(meta_value, str) or not hasattr(meta_value, '__iter__')):
                     meta_value = [meta_value]
                 # Convert metadata items into HTTP headers and add to
                 # ``headers`` dict.
                 for i, value in enumerate(meta_value):
                     if not value:
                         continue
-                    header_key = 'x-archive-{0}{1:02d}-{2}'.format(meta_type, i, meta_key)
-                    if (isinstance(value, six.string_types) and needs_quote(value)):
-                        if six.PY2 and isinstance(value, six.text_type):
-                            value = value.encode('utf-8')
-                        value = 'uri({0})'.format(urllib.parse.quote(value))
+                    header_key = f'x-archive-{meta_type}{i:02d}-{meta_key}'
+                    if (isinstance(value, str) and needs_quote(value)):
+                        value = f'uri({quote(value)})'
                     # because rfc822 http headers disallow _ in names, IA-S3 will
                     # translate two hyphens in a row (--) into an underscore (_).
                     header_key = header_key.replace('_', '--')
@@ -173,7 +161,7 @@ def _prepare_metadata_headers(prepared_metadata, meta_type='meta'):
         _prepare_metadata_headers(prepared_metadata)
         _prepare_metadata_headers(prepared_file_metadata, meta_type='filemeta')
 
-        super(S3PreparedRequest, self).prepare_headers(headers)
+        super().prepare_headers(headers)
 
 
 class MetadataRequest(requests.models.Request):
@@ -188,7 +176,7 @@ def __init__(self,
                  append_list=None,
                  **kwargs):
 
-        super(MetadataRequest, self).__init__(**kwargs)
+        super().__init__(**kwargs)
 
         if not self.auth:
             self.auth = auth.S3PostAuth(access_key, secret_key)
@@ -226,11 +214,6 @@ def prepare(self):
 
 
 class MetadataPreparedRequest(requests.models.PreparedRequest):
-
-    # __init__()
-    def __init__(self):
-        super(MetadataPreparedRequest, self).__init__()
-
     def prepare(self, method=None, url=None, headers=None, files=None, data=None,
                 params=None, auth=None, cookies=None, hooks=None, metadata={},
                 source_metadata=None, target=None, priority=None, append=None,
@@ -260,7 +243,7 @@ def prepare_body(self, metadata, source_metadata, target, priority, append,
         if (isinstance(metadata, list)
                 or any('/' in k for k in metadata)
                 or all(isinstance(k, dict) for k in metadata.values())):
-            changes = list()
+            changes = []
 
             if any(not k for k in metadata):
                 raise ValueError('Invalid metadata provided, '
@@ -292,7 +275,7 @@ def prepare_body(self, metadata, source_metadata, target, priority, append,
                 '-changes': json.dumps(changes),
                 'priority': priority,
             }
-            logger.debug('submitting metadata request: {}'.format(self.data))
+            logger.debug(f'submitting metadata request: {self.data}')
         # Write to single target
         else:
             if not target or 'metadata' in target:
@@ -314,8 +297,8 @@ def prepare_body(self, metadata, source_metadata, target, priority, append,
                 '-target': target,
                 'priority': priority,
             }
-            logger.debug('submitting metadata request: {}'.format(self.data))
-        super(MetadataPreparedRequest, self).prepare_body(self.data, None)
+            logger.debug(f'submitting metadata request: {self.data}')
+        super().prepare_body(self.data, None)
 
 
 def prepare_patch(metadata, source_metadata, append, append_list=None):
@@ -323,7 +306,7 @@ def prepare_patch(metadata, source_metadata, append, append_list=None):
     if isinstance(metadata, list):
         prepared_metadata = metadata
         if not destination_metadata:
-            destination_metadata = list()
+            destination_metadata = []
     else:
         prepared_metadata = prepare_metadata(metadata, source_metadata, append,
                                              append_list)
@@ -359,9 +342,9 @@ def dictify(lst, key=None, value=None):
         metadata = dictify(_k.split('/')[1:], _k.split('/')[-1], metadata[_k])
     for i, _k in enumerate(key.split('/')):
         if i == 0:
-            source_metadata = source_metadata.get(_k, dict())
+            source_metadata = source_metadata.get(_k, {})
         else:
-            source_metadata[_k] = source_metadata.get(_k, dict()).get(_k, dict())
+            source_metadata[_k] = source_metadata.get(_k, {}).get(_k, {})
     patch = prepare_patch(metadata, source_metadata, append, append_list)
     return patch
 
@@ -412,7 +395,7 @@ def rm_index(key):
     indexed_keys = {}
     for key in metadata:
         # Convert number values to strings!
-        if isinstance(metadata[key], (six.integer_types, float, complex)):
+        if isinstance(metadata[key], (int, float, complex)):
             metadata[key] = str(metadata[key])
         if get_index(key) is None:
             continue
@@ -458,8 +441,7 @@ def rm_index(key):
                     prepared_metadata[key] = source_metadata[key]
                 prepared_metadata[key].append(v)
         elif append and source_metadata.get(key):
-            prepared_metadata[key] = '{0} {1}'.format(
-                source_metadata[key], metadata[key])
+            prepared_metadata[key] = f'{source_metadata[key]} {metadata[key]}'
         else:
             prepared_metadata[key] = metadata[key]
 
diff --git a/internetarchive/item.py b/internetarchive/item.py
index b64e23b6..28cfc7e4 100644
--- a/internetarchive/item.py
+++ b/internetarchive/item.py
@@ -24,8 +24,6 @@
 :copyright: (C) 2012-2021 by Internet Archive.
 :license: AGPL 3, see LICENSE for more details.
 """
-from __future__ import absolute_import, unicode_literals, print_function
-
 import os
 import sys
 from fnmatch import fnmatch
@@ -34,15 +32,11 @@
 import math
 from xml.parsers.expat import ExpatError
 
-try:
-    from functools import total_ordering
-except ImportError:
-    from total_ordering import total_ordering
+from functools import total_ordering
 import json
 from copy import deepcopy
 
-from six import string_types
-from six.moves import urllib
+from urllib.parse import quote
 from requests import Response
 from tqdm import tqdm
 from requests.exceptions import HTTPError
@@ -61,8 +55,8 @@
 
 
 @total_ordering
-class BaseItem(object):
-    EXCLUDED_ITEM_METADATA_KEYS = (u'workable_servers', u'server')
+class BaseItem:
+    EXCLUDED_ITEM_METADATA_KEYS = ('workable_servers', 'server')
 
     def __init__(self, identifier=None, item_metadata=None):
         # Default attributes.
@@ -90,8 +84,8 @@ def __init__(self, identifier=None, item_metadata=None):
         self.load()
 
     def __repr__(self):
-        return ('{0.__class__.__name__}(identifier={0.identifier!r}{notloaded})'.format(
-            self, notloaded=', item_metadata={}' if not self.exists else ''))
+        notloaded = ', item_metadata={}' if not self.exists else ''
+        return f'{self.__class__.__name__}(identifier={self.identifier!r}{notloaded})'
 
     def load(self, item_metadata=None):
         if item_metadata:
@@ -119,9 +113,9 @@ def __le__(self, other):
         return self.identifier <= other.identifier
 
     def __hash__(self):
-        without_excluded_keys = dict(
-            (k, v) for (k, v) in self.item_metadata.items()
-            if k not in self.EXCLUDED_ITEM_METADATA_KEYS)
+        without_excluded_keys = {
+            k: v for k, v in self.item_metadata.items()
+            if k not in self.EXCLUDED_ITEM_METADATA_KEYS}
         return hash(json.dumps(without_excluded_keys,
                                sort_keys=True, check_circular=False))
 
@@ -137,7 +131,7 @@ class Item(BaseItem):
 
     Or to modify the metadata for an item::
 
-        >>> metadata = dict(title='The Stairs')
+        >>> metadata = {'title': 'The Stairs'}
         >>> item.modify_metadata(metadata)
         >>> print(item.metadata['title'])
         'The Stairs'
@@ -178,14 +172,13 @@ def __init__(self, archive_session, identifier, item_metadata=None):
                               retrieved from Archive.org using the provided identifier.
         """
         self.session = archive_session
-        super(Item, self).__init__(identifier, item_metadata)
+        super().__init__(identifier, item_metadata)
 
         self.urls = Item.URLs(self)
 
         if self.metadata.get('title'):
             # A copyable link to the item, in MediaWiki format
-            self.wikilink = ('* [{0.urls.details} {0.identifier}] '
-                             '-- {0.metadata[title]}').format(self)
+            self.wikilink = f'* [{self.urls.details} {self.identifier}] -- {self.metadata["title"]}'
 
     class URLs:
         def __init__(self, itm_obj):
@@ -204,7 +197,7 @@ def __init__(self, itm_obj):
 
         def _make_tab_URL(self, tab):
             """Make URLs for the separate tabs of Collections details page."""
-            self._make_URL(tab, self.details + "&tab={tab}".format(tab=tab))
+            self._make_URL(tab, self.details + f'&tab={tab}')
 
         DEFAULT_URL_FORMAT = ('{0.session.protocol}//{0.session.host}'
                               '/{path}/{0.identifier}')
@@ -214,8 +207,7 @@ def _make_URL(self, path, url_format=DEFAULT_URL_FORMAT):
             self._paths.append(path)
 
         def __str__(self):
-            return ("URLs ({1}) for {0.identifier}"
-                    .format(self._itm_obj, ', '.join(self._paths)))
+            return f'URLs ({", ".join(self._paths)}) for {self._itm_obj.identifier}'
 
     def refresh(self, item_metadata=None, **kwargs):
         if not item_metadata:
@@ -230,9 +222,8 @@ def identifier_available(self):
         :return: `True` if identifier is available, or `False` if it is
                  not available.
         """
-        url = '{}//{}/services/check_identifier.php'.format(self.session.protocol,
-                                                            self.session.host)
-        params = dict(output='json', identifier=self.identifier)
+        url = f'{self.session.protocol}//{self.session.host}/services/check_identifier.php'
+        params = {'output': 'json', 'identifier': self.identifier}
         response = self.session.get(url, params=params)
         availability = response.json()['code']
         return availability == 'available'
@@ -273,8 +264,8 @@ def get_all_item_tasks(self, params=None, request_kwargs=None):
 
         :rtype: List[CatalogTask]
         """
-        params = dict() if not params else params
-        params.update(dict(catalog=1, history=1))
+        params = {} if not params else params
+        params.update({'catalog': 1, 'history': 1})
         return self.session.get_tasks(self.identifier, params, request_kwargs)
 
     def get_history(self, params=None, request_kwargs=None):
@@ -288,7 +279,7 @@ def get_history(self, params=None, request_kwargs=None):
 
         :rtype: List[CatalogTask]
         """
-        history = list()
+        history = []
         for t in self.session.iter_history(self.identifier, params, request_kwargs):
             history.append(t)
         return history
@@ -304,7 +295,7 @@ def get_catalog(self, params=None, request_kwargs=None):
 
         :rtype: List[CatalogTask]
         """
-        catalog = list()
+        catalog = []
         for t in self.session.iter_catalog(self.identifier, params, request_kwargs):
             catalog.append(t)
         return catalog
@@ -341,7 +332,7 @@ def derive(self,
 
         :rtype: :class:`requests.Response`
         """
-        data = dict() if not data else data
+        data = {} if not data else data
 
         if remove_derived is not None:
             if not data.get('args'):
@@ -390,14 +381,14 @@ def fixer(self,
 
         :rtype: :class:`requests.Response`
         """
-        data = dict() if not data else data
+        data = {} if not data else data
 
         if not ops:
             ops = ['noop']
         if not isinstance(ops, (list, tuple, set)):
             ops = [ops]
         if not data.get('args'):
-            data['args'] = dict()
+            data['args'] = {}
         for op in ops:
             data['args'][op] = '1'
 
@@ -490,31 +481,25 @@ def dark(self, comment,
         return r
 
     def get_review(self):
-        u = '{protocol}//{host}/services/reviews.php'.format(
-            protocol=self.session.protocol,
-            host=self.session.host)
-        p = dict(identifier=self.identifier)
+        u = f'{self.session.protocol}//{self.session.host}/services/reviews.php'
+        p = {'identifier': self.identifier}
         a = S3Auth(self.session.access_key, self.session.secret_key)
         r = self.session.get(u, params=p, auth=a)
         r.raise_for_status()
         return r
 
     def delete_review(self):
-        u = '{protocol}//{host}/services/reviews.php'.format(
-            protocol=self.session.protocol,
-            host=self.session.host)
-        p = dict(identifier=self.identifier)
+        u = f'{self.session.protocol}//{self.session.host}/services/reviews.php'
+        p = {'identifier': self.identifier}
         a = S3Auth(self.session.access_key, self.session.secret_key)
         r = self.session.delete(u, params=p, auth=a)
         r.raise_for_status()
         return r
 
     def review(self, title, body, stars=None):
-        u = '{protocol}//{host}/services/reviews.php'.format(
-            protocol=self.session.protocol,
-            host=self.session.host)
-        p = dict(identifier=self.identifier)
-        d = dict(title=title, body=body)
+        u = f'{self.session.protocol}//{self.session.host}/services/reviews.php'
+        p = {'identifier': self.identifier}
+        d = {'title': title, 'body': body}
         if stars:
             d['stars'] = stars
         a = S3Auth(self.session.access_key, self.session.secret_key)
@@ -548,13 +533,13 @@ def get_files(self, files=None, formats=None, glob_pattern=None, on_the_fly=None
         # Add support for on-the-fly files (e.g. EPUB).
         if on_the_fly:
             otf_files = [
-                '{0}.epub'.format(self.identifier),
-                '{0}.mobi'.format(self.identifier),
-                '{0}_daisy.zip'.format(self.identifier),
-                '{0}_archive_marc.xml'.format(self.identifier),
+                f'{self.identifier}.epub',
+                f'{self.identifier}.mobi',
+                f'{self.identifier}_daisy.zip',
+                f'{self.identifier}_archive_marc.xml',
             ]
             for f in otf_files:
-                item_files.append(dict(name=f, otf=True))
+                item_files.append({'name': f, 'otf': True})
 
         if not any(k for k in [files, formats, glob_pattern]):
             for f in item_files:
@@ -679,28 +664,28 @@ def download(self,
 
         if not dry_run:
             if item_index and verbose is True:
-                print('{0} ({1}):'.format(self.identifier, item_index))
+                print(f'{self.identifier} ({item_index}):')
             elif item_index and silent is False:
-                print('{0} ({1}): '.format(self.identifier, item_index), end='')
+                print(f'{self.identifier} ({item_index}): ', end='')
             elif item_index is None and verbose is True:
-                print('{0}:'.format(self.identifier))
+                print(f'{self.identifier}:')
             elif item_index is None and silent is False:
                 print(self.identifier, end=': ')
             sys.stdout.flush()
 
         if self.is_dark is True:
-            msg = 'skipping {0}, item is dark'.format(self.identifier)
+            msg = f'skipping {self.identifier}, item is dark'
             log.warning(msg)
             if verbose:
-                print(' ' + msg)
+                print(f' {msg}')
             elif silent is False:
                 print(msg)
             return
         elif self.metadata == {}:
-            msg = 'skipping {0}, item does not exist.'.format(self.identifier)
+            msg = f'skipping {self.identifier}, item does not exist.'
             log.warning(msg)
             if verbose:
-                print(' ' + msg)
+                print(f' {msg}')
             elif silent is False:
                 print(msg)
             return
@@ -715,16 +700,16 @@ def download(self,
             files = self.get_files(glob_pattern=glob_pattern, on_the_fly=on_the_fly)
 
         if not files:
-            msg = 'skipping {0}, no matching files found.'.format(self.identifier)
+            msg = f'skipping {self.identifier}, no matching files found.'
             log.info(msg)
             if verbose:
-                print(' ' + msg)
+                print(f' {msg}')
             elif silent is False:
                 print(msg, end='')
 
-        errors = list()
+        errors = []
         downloaded = 0
-        responses = list()
+        responses = []
 
         for f in files:
             if ignore_history_dir is True:
@@ -795,7 +780,7 @@ def modify_metadata(self, metadata,
 
             >>> import internetarchive
             >>> item = internetarchive.Item('mapi_test_item1')
-            >>> md = dict(new_key='new_value', foo=['bar', 'bar2'])
+            >>> md = {'new_key': 'new_value', 'foo': ['bar', 'bar2']}
             >>> item.modify_metadata(md)
 
         :rtype: Response
@@ -812,10 +797,7 @@ def modify_metadata(self, metadata,
         _headers = self.session.headers.copy()
         _headers.update(headers)
 
-        url = '{protocol}//{host}/metadata/{identifier}'.format(
-            protocol=self.session.protocol,
-            identifier=self.identifier,
-            host=self.session.host)
+        url = f'{self.session.protocol}//{self.session.host}/metadata/{self.identifier}'
         # TODO: currently files and metadata targets do not support dict's,
         # but they might someday?? refactor this check.
         source_metadata = self.item_metadata
@@ -846,11 +828,11 @@ def remove_from_simplelist(self, parent, list):
 
         :rtype: :class:`requests.Response`
         """
-        patch = dict(
-            op='delete',
-            parent=parent,
-            list=list,
-        )
+        patch = {
+            'op': 'delete',
+            'parent': parent,
+            'list': list,
+        }
         data = {
             '-patch': json.dumps(patch),
             '-target': 'simplelists',
@@ -979,25 +961,22 @@ def upload_file(self, body,
         if validate_identifier:
             validate_s3_identifier(self.identifier)
         key = norm_filepath(filename).split('/')[-1] if key is None else key
-        base_url = '{0.session.protocol}//s3.us.archive.org/{0.identifier}'.format(self)
-        url = '{0}/{1}'.format(
-            base_url, urllib.parse.quote(norm_filepath(key).lstrip('/').encode('utf-8')))
+        base_url = f'{self.session.protocol}//s3.us.archive.org/{self.identifier}'
+        url = f'{base_url}/{quote(norm_filepath(key).lstrip("/").encode("utf-8"))}'
 
         # Skip based on checksum.
         if checksum:
             md5_sum = get_md5(body)
             ia_file = self.get_file(key)
             if (not self.tasks) and (ia_file) and (ia_file.md5 == md5_sum):
-                log.info('{f} already exists: {u}'.format(f=key, u=url))
+                log.info(f'{key} already exists: {url}')
                 if verbose:
-                    print(' {f} already exists, skipping.'.format(f=key))
+                    print(f' {key} already exists, skipping.')
                 if delete:
                     log.info(
-                        '{f} successfully uploaded to '
-                        'https://archive.org/download/{i}/{f} '
-                        'and verified, deleting '
-                        'local copy'.format(i=self.identifier,
-                                            f=key))
+                        f'{key} successfully uploaded to '
+                        f'https://archive.org/download/{self.identifier}/{key} '
+                        'and verified, deleting local copy')
                     body.close()
                     os.remove(filename)
                 # Return an empty response object if checksums match.
@@ -1024,13 +1003,13 @@ def _build_request():
                     expected_size = math.ceil(size / chunk_size)
                     chunks = chunk_generator(body, chunk_size)
                     progress_generator = tqdm(chunks,
-                                              desc=' uploading {}'.format(key),
+                                              desc=f' uploading {key}',
                                               dynamic_ncols=True,
                                               total=expected_size,
                                               unit='MiB')
                     data = IterableToFileAdapter(progress_generator, size)
                 except:
-                    print(' uploading {f}'.format(f=key))
+                    print(f' uploading {key}')
                     data = body
             else:
                 data = body
@@ -1055,14 +1034,14 @@ def _build_request():
             try:
                 while True:
                     error_msg = ('s3 is overloaded, sleeping for '
-                                 '{0} seconds and retrying. '
-                                 '{1} retries left.'.format(retries_sleep, retries))
+                                 f'{retries_sleep} seconds and retrying. '
+                                 f'{retries} retries left.')
                     if retries > 0:
                         if self.session.s3_is_overloaded(access_key):
                             sleep(retries_sleep)
                             log.info(error_msg)
                             if verbose:
-                                print(' warning: {0}'.format(error_msg), file=sys.stderr)
+                                print(f' warning: {error_msg}', file=sys.stderr)
                             retries -= 1
                             continue
                     request = _build_request()
@@ -1080,7 +1059,7 @@ def _build_request():
                     if (response.status_code == 503) and (retries > 0):
                         log.info(error_msg)
                         if verbose:
-                            print(' warning: {0}'.format(error_msg), file=sys.stderr)
+                            print(f' warning: {error_msg}', file=sys.stderr)
                         sleep(retries_sleep)
                         retries -= 1
                         continue
@@ -1089,12 +1068,12 @@ def _build_request():
                             log.info('maximum retries exceeded, upload failed.')
                         break
                 response.raise_for_status()
-                log.info(u'uploaded {f} to {u}'.format(f=key, u=url))
+                log.info(f'uploaded {key} to {url}')
                 if delete and response.status_code == 200:
                     log.info(
-                        '{f} successfully uploaded to '
-                        'https://archive.org/download/{i}/{f} and verified, deleting '
-                        'local copy'.format(i=self.identifier, f=key))
+                        f'{key} successfully uploaded to '
+                        f'https://archive.org/download/{self.identifier}/{key} and verified, '
+                        'deleting local copy')
                     body.close()
                     os.remove(filename)
                 response.close()
@@ -1103,16 +1082,15 @@ def _build_request():
                 try:
                     msg = get_s3_xml_text(exc.response.content)
                 except ExpatError:  # probably HTTP 500 error and response is invalid XML
-                    msg = ("IA S3 returned invalid XML (HTTP status code {0}). "
-                           "This is a server side error which is either temporary, "
-                           "or requires the intervention of IA admins."
-                           "".format(exc.response.status_code))
+                    msg = ('IA S3 returned invalid XML '
+                           f'(HTTP status code {exc.response.status_code}). '
+                           'This is a server side error which is either temporary, '
+                           'or requires the intervention of IA admins.')
 
-                error_msg = (' error uploading {0} to {1}, '
-                             '{2}'.format(key, self.identifier, msg))
+                error_msg = f' error uploading {key} to {self.identifier}, {msg}'
                 log.error(error_msg)
                 if verbose:
-                    print(' error uploading {0}: {1}'.format(key, msg), file=sys.stderr)
+                    print(f' error uploading {key}: {msg}', file=sys.stderr)
                 # Raise HTTPError with error message.
                 raise type(exc)(error_msg, response=exc.response, request=exc.request)
             finally:
@@ -1145,7 +1123,7 @@ def upload(self, files,
 
             >>> import internetarchive
             >>> item = internetarchive.Item('identifier')
-            >>> md = dict(mediatype='image', creator='Jake Johnson')
+            >>> md = {'mediatype': 'image', 'creator': 'Jake Johnson'}
             >>> item.upload('/path/to/image.jpg', metadata=md, queue_derive=False)
             [<Response [200]>]
 
@@ -1202,7 +1180,7 @@ def upload(self, files,
                     file_metadata = f.copy()
                     del file_metadata['name']
                     f = f['name']
-            if ((isinstance(f, string_types) and is_dir(f))
+            if ((isinstance(f, str) and is_dir(f))
                     or (isinstance(f, tuple) and is_dir(f[-1]))):
                 if isinstance(f, tuple):
                     remote_dir_name = f[0].strip('/')
@@ -1217,11 +1195,11 @@ def upload(self, files,
                         _queue_derive = False
                     if not f.endswith('/'):
                         if remote_dir_name:
-                            key = '{0}{1}/{2}'.format(remote_dir_name, f, key)
+                            key = f'{remote_dir_name}{f}/{key}'
                         else:
-                            key = '{0}/{1}'.format(f, key)
+                            key = f'{f}/{key}'
                     elif remote_dir_name:
-                        key = '{0}/{1}'.format(remote_dir_name, key)
+                        key = f'{remote_dir_name}/{key}'
                     key = norm_filepath(key)
                     resp = self.upload_file(filepath,
                                             key=key,
@@ -1255,7 +1233,7 @@ def upload(self, files,
                     key, body = (None, f)
                 else:
                     key, body = f
-                if key and not isinstance(key, string_types):
+                if key and not isinstance(key, str):
                     key = str(key)
                 resp = self.upload_file(body,
                                         key=key,
@@ -1286,21 +1264,21 @@ def __init__(self, *args, **kwargs):
         if isinstance(args[0], Item):
             orig = args[0]
             args = (orig.session, orig.identifier, orig.item_metadata)
-        super(Collection, self).__init__(*args, **kwargs)
-        if self.metadata.get(u'mediatype', u'collection') != 'collection':
+        super().__init__(*args, **kwargs)
+        if self.metadata.get('mediatype', 'collection') != 'collection':
             raise ValueError('mediatype is not "collection"!')
 
-        deflt_srh = "collection:{0.identifier}".format(self)
+        deflt_srh = f'collection:{self.identifier}'
         self._make_search('contents',
-                          self.metadata.get(u'search_collection', deflt_srh))
+                          self.metadata.get('search_collection', deflt_srh))
         self._make_search('subcollections',
-                          deflt_srh + " AND mediatype:collection")
+                          f'{deflt_srh} AND mediatype:collection')
 
     def _do_search(self, name, query):
         rtn = self.searches.setdefault(
-            name, self.session.search_items(query, fields=[u'identifier']))
-        if not hasattr(self, name + "_count"):
-            setattr(self, name + "_count", self.searches[name].num_found)
+            name, self.session.search_items(query, fields=['identifier']))
+        if not hasattr(self, f'{name}_count'):
+            setattr(self, f'{name}_count', self.searches[name].num_found)
         return rtn.iter_as_items()
 
     def _make_search(self, name, query):
diff --git a/internetarchive/search.py b/internetarchive/search.py
index 728c65cd..ca5164e3 100644
--- a/internetarchive/search.py
+++ b/internetarchive/search.py
@@ -27,12 +27,8 @@
 :copyright: (C) 2012-2019 by Internet Archive.
 :license: AGPL 3, see LICENSE for more details.
 """
-from __future__ import absolute_import, unicode_literals
-
 import itertools
 from logging import getLogger
-
-import six
 from requests.exceptions import ReadTimeout
 
 from internetarchive.auth import S3Auth
@@ -41,7 +37,7 @@
 log = getLogger(__name__)
 
 
-class Search(object):
+class Search:
     """This class represents an archive.org item search. You can use
     this class to search for Archive.org items using the advanced search
     engine.
@@ -74,17 +70,14 @@ def __init__(self, archive_session, query,
             self.fts = False
         self.query = query
         if self.fts and not self.dsl_fts:
-            self.query = '!L {}'.format(self.query)
-        self.fields = fields or list()
-        self.sorts = sorts or list()
-        self.request_kwargs = request_kwargs or dict()
+            self.query = f'!L {self.query}'
+        self.fields = fields or []
+        self.sorts = sorts or []
+        self.request_kwargs = request_kwargs or {}
         self._num_found = None
-        self.fts_url = '{0}//be-api.us.archive.org/ia-pub-fts-api'.format(
-            self.session.protocol)
-        self.scrape_url = '{0}//{1}/services/search/v1/scrape'.format(
-            self.session.protocol, self.session.host)
-        self.search_url = '{0}//{1}/advancedsearch.php'.format(
-            self.session.protocol, self.session.host)
+        self.fts_url = f'{self.session.protocol}//be-api.us.archive.org/ia-pub-fts-api'
+        self.scrape_url = f'{self.session.protocol}//{self.session.host}/services/search/v1/scrape'
+        self.search_url = f'{self.session.protocol}//{self.session.host}/advancedsearch.php'
         if self.session.access_key and self.session.secret_key:
             self.auth = S3Auth(self.session.access_key, self.session.secret_key)
         else:
@@ -92,7 +85,7 @@ def __init__(self, archive_session, query,
         self.max_retries = max_retries if max_retries is not None else 5
 
         # Initialize params.
-        default_params = dict(q=self.query)
+        default_params = {'q': self.query}
         if 'page' not in params:
             default_params['count'] = 10000
         else:
@@ -113,7 +106,7 @@ def __init__(self, archive_session, query,
         self.session.mount_http_adapter(max_retries=self.max_retries)
 
     def __repr__(self):
-        return 'Search(query={query!r})'.format(query=self.query)
+        return f'Search(query={self.query!r})'
 
     def __iter__(self):
         return self.iter_as_results()
@@ -123,18 +116,16 @@ def _advanced_search(self):
         if 'identifier' not in self.fields:
             self.fields.append('identifier')
         for k, v in enumerate(self.fields):
-            key = 'fl[{0}]'.format(k)
-            self.params[key] = v
+            self.params[f'fl[{k}]'] = v
 
         for i, field in enumerate(self.sorts):
-            self.params['sort[{0}]'.format(i)] = field
+            self.params[f'sort[{i}]'] = field
 
         self.params['output'] = 'json'
 
         r = self.session.get(self.search_url, params=self.params, **self.request_kwargs)
         j = r.json()
-        for item in j.get('response', {}).get('docs', []):
-            yield item
+        yield from j.get('response', {}).get('docs', [])
 
     def _scrape(self):
         if self.fields:
@@ -158,8 +149,7 @@ def _scrape(self):
             if 'cursor' not in j:
                 if i != num_found:
                     raise ReadTimeout('The server failed to return results in the'
-                                      ' allotted amount of time for'
-                                      ' {}'.format(r.request.url))
+                                      f' allotted amount of time for {r.request.url}')
                 break
 
     def _full_text_search(self):
@@ -182,11 +172,10 @@ def _full_text_search(self):
                                   **self.request_kwargs)
             j = r.json()
             scroll_id = j.get('_scroll_id')
-            hits = j.get('hits', dict()).get('hits')
+            hits = j.get('hits', {}).get('hits')
             if not hits:
                 return
-            for hit in hits:
-                yield hit
+            yield from hits
             if not hits or d['scroll'] is False:
                 break
             d['scroll_id'] = scroll_id
@@ -211,7 +200,7 @@ def _user_aggs(self):
         j = r.json()
         if j.get('error'):
             yield j
-        for agg in j.get('response', dict()).get('aggregations', dict()).items():
+        for agg in j.get('response', {}).get('aggregations', {}).items():
             yield {agg[0]: agg[1]}
 
     @property
@@ -234,7 +223,7 @@ def num_found(self):
                                      auth=self.auth,
                                      **self.request_kwargs)
                 j = r.json()
-                self._num_found = j.get('hits', dict()).get('total')
+                self._num_found = j.get('hits', {}).get('total')
         return self._num_found
 
     def _handle_scrape_error(self, j):
@@ -242,8 +231,7 @@ def _handle_scrape_error(self, j):
             if all(s in j['error'].lower() for s in ['invalid', 'secret']):
                 if not j['error'].endswith('.'):
                     j['error'] += '.'
-                raise ValueError("{0} Try running 'ia configure' "
-                                 "and retrying.".format(j['error']))
+                raise ValueError(f"{j['error']} Try running 'ia configure' and retrying.")
             raise ValueError(j.get('error'))
 
     def _get_item_from_search_result(self, search_result):
@@ -253,18 +241,14 @@ def iter_as_results(self):
         return SearchIterator(self, self._make_results_generator())
 
     def iter_as_items(self):
-        if six.PY2:
-            _map = itertools.imap(self._get_item_from_search_result,
-                                  self._make_results_generator())
-        else:
-            _map = map(self._get_item_from_search_result, self._make_results_generator())
+        _map = map(self._get_item_from_search_result, self._make_results_generator())
         return SearchIterator(self, _map)
 
     def __len__(self):
         return self.num_found
 
 
-class SearchIterator(object):
+class SearchIterator:
     """This class is an iterator wrapper for search results.
 
     It provides access to the underlying Search, and supports
@@ -287,4 +271,4 @@ def __iter__(self):
         return self
 
     def __repr__(self):
-        return '{0.__class__.__name__}({0.search!r}, {0.iterator!r})'.format(self)
+        return f'{self.__class__.__name__}({self.search!r}, {self.iterator!r})'
diff --git a/internetarchive/session.py b/internetarchive/session.py
index 26474354..8955374d 100644
--- a/internetarchive/session.py
+++ b/internetarchive/session.py
@@ -27,9 +27,6 @@
 :copyright: (C) 2012-2021 by Internet Archive.
 :license: AGPL 3, see LICENSE for more details.
 """
-
-from __future__ import absolute_import, unicode_literals
-
 import os
 import locale
 import sys
@@ -45,8 +42,8 @@
 from requests.utils import default_headers
 from requests.adapters import HTTPAdapter
 from requests.packages.urllib3 import Retry
-from six.moves.urllib.parse import urlparse, unquote
 from requests.cookies import create_cookie
+from urllib.parse import urlparse, unquote
 
 from internetarchive import __version__, auth
 from internetarchive.config import get_config
@@ -100,14 +97,14 @@ def __init__(self,
 
         :returns: :class:`ArchiveSession` object.
         """
-        super(ArchiveSession, self).__init__()
+        super().__init__()
         http_adapter_kwargs = {} if not http_adapter_kwargs else http_adapter_kwargs
         debug = False if not debug else True
 
         self.config = get_config(config, config_file)
         self.config_file = config_file
         for ck, cv in self.config.get('cookies', {}).items():
-            raw_cookie = '{}={}'.format(ck, cv)
+            raw_cookie = f'{ck}={cv}'
             cookie_dict = parse_dict_cookies(raw_cookie)
             if not cookie_dict.get(ck):
                 continue
@@ -121,7 +118,7 @@ def __init__(self,
         if 'archive.org' not in self.host:
             self.host += '.archive.org'
         self.protocol = 'https:' if self.secure else 'http:'
-        user_email = self.config.get('cookies', dict()).get('logged-in-user')
+        user_email = self.config.get('cookies', {}).get('logged-in-user')
         if user_email:
             user_email = user_email.split(';')[0]
             user_email = unquote(user_email)
@@ -153,8 +150,9 @@ def _get_user_agent_string(self):
         except:
             lang = ''
         py_version = '{0}.{1}.{2}'.format(*sys.version_info)
-        return 'internetarchive/{0} ({1} {2}; N; {3}; {4}) Python/{5}'.format(
-            __version__, uname[0], uname[-1], lang, self.access_key, py_version)
+        return (f'internetarchive/{__version__} '
+                f'({uname[0]} {uname[-1]}; N; {lang}; {self.access_key}) '
+                f'Python/{py_version}')
 
     def rebuild_auth(self, prepared_request, response):
         """Never rebuild auth for archive.org URLs.
@@ -162,7 +160,7 @@ def rebuild_auth(self, prepared_request, response):
         u = urlparse(prepared_request.url)
         if u.netloc.endswith('archive.org'):
             return
-        super(ArchiveSession, self).rebuild_auth(prepared_request, response)
+        super().rebuild_auth(prepared_request, response)
 
     def mount_http_adapter(self, protocol=None, max_retries=None,
                            status_forcelist=None, host=None):
@@ -201,7 +199,7 @@ def mount_http_adapter(self, protocol=None, max_retries=None,
         max_retries_adapter = HTTPAdapter(**self.http_adapter_kwargs)
         # Don't mount on s3.us.archive.org, only archive.org!
         # IA-S3 requires a more complicated retry workflow.
-        self.mount('{0}//{1}'.format(protocol, host), max_retries_adapter)
+        self.mount(f'{protocol}//{host}', max_retries_adapter)
 
     def set_file_logger(self, log_level, path, logger_name='internetarchive'):
         """Convenience function to quickly configure any level of
@@ -257,8 +255,7 @@ def get_item(self, identifier, item_metadata=None, request_kwargs=None):
         """
         request_kwargs = {} if not request_kwargs else request_kwargs
         if not item_metadata:
-            logger.debug('no metadata provided for "{0}", '
-                         'retrieving now.'.format(identifier))
+            logger.debug(f'no metadata provided for "{identifier}", retrieving now.')
             item_metadata = self.get_metadata(identifier, request_kwargs)
         mediatype = item_metadata.get('metadata', {}).get('mediatype')
         try:
@@ -278,7 +275,7 @@ def get_metadata(self, identifier, request_kwargs=None):
         :returns: Metadat API response.
         """
         request_kwargs = {} if not request_kwargs else request_kwargs
-        url = '{0}//{1}/metadata/{2}'.format(self.protocol, self.host, identifier)
+        url = f'{self.protocol}//{self.host}/metadata/{identifier}'
         if 'timeout' not in request_kwargs:
             request_kwargs['timeout'] = 12
         try:
@@ -289,7 +286,7 @@ def get_metadata(self, identifier, request_kwargs=None):
             resp = self.get(url, auth=s3_auth, **request_kwargs)
             resp.raise_for_status()
         except Exception as exc:
-            error_msg = 'Error retrieving metadata from {0}, {1}'.format(url, exc)
+            error_msg = f'Error retrieving metadata from {url}, {exc}'
             logger.error(error_msg)
             raise type(exc)(error_msg)
         return resp.json()
@@ -342,12 +339,12 @@ def s3_is_overloaded(self, identifier=None, access_key=None, request_kwargs=None
         if 'timeout' not in request_kwargs:
             request_kwargs['timeout'] = 12
 
-        u = '{protocol}//s3.us.archive.org'.format(protocol=self.protocol)
-        p = dict(
-            check_limit=1,
-            accesskey=access_key,
-            bucket=identifier,
-        )
+        u = f'{self.protocol}//s3.us.archive.org'
+        p = {
+            'check_limit': 1,
+            'accesskey': access_key,
+            'bucket': identifier,
+        }
         try:
             r = self.get(u, params=p, **request_kwargs)
         except:
@@ -409,7 +406,7 @@ def submit_task(self, identifier, cmd, comment=None, priority=None, data=None,
 
         :rtype: :class:`requests.Response`
         """
-        headers = dict() if not headers else headers
+        headers = {} if not headers else headers
         if reduced_priority is not None:
             headers.update({'X-Accept-Reduced-Priority': '1'})
 
@@ -439,11 +436,10 @@ def iter_history(self, identifier, params=None, request_kwargs=None):
 
         :rtype: collections.Iterable[CatalogTask]
         """
-        params = dict() if not params else params
-        params.update(dict(identifier=identifier, catalog=0, summary=0, history=1))
+        params = {} if not params else params
+        params.update({'identifier': identifier, 'catalog': 0, 'summary': 0, 'history': 1})
         c = Catalog(self, request_kwargs)
-        for j in c.iter_tasks(params):
-            yield j
+        yield from c.iter_tasks(params)
 
     def iter_catalog(self, identifier=None, params=None, request_kwargs=None):
         """A generator that returns queued or running tasks.
@@ -463,11 +459,10 @@ def iter_catalog(self, identifier=None, params=None, request_kwargs=None):
 
         :rtype: collections.Iterable[CatalogTask]
         """
-        params = dict() if not params else params
-        params.update(dict(identifier=identifier, catalog=1, summary=0, history=0))
+        params = {} if not params else params
+        params.update({'identifier': identifier, 'catalog': 1, 'summary': 0, 'history': 0})
         c = Catalog(self, request_kwargs)
-        for j in c.iter_tasks(params):
-            yield j
+        yield from c.iter_tasks(params)
 
     def get_tasks_summary(self, identifier=None, params=None, request_kwargs=None):
         """Get the total counts of catalog tasks meeting all criteria,
@@ -512,7 +507,7 @@ def get_tasks(self, identifier=None, params=None, request_kwargs=None):
 
         :rtype: List[CatalogTask]
         """
-        params = dict() if not params else params
+        params = {} if not params else params
         c = Catalog(self, request_kwargs)
         if 'history' not in params:
             params['history'] = 1
@@ -535,8 +530,8 @@ def get_my_catalog(self, params=None, request_kwargs=None):
 
         :rtype: List[CatalogTask]
         """
-        params = dict() if not params else params
-        _params = dict(submitter=self.user_email, catalog=1, history=0, summary=0)
+        params = {} if not params else params
+        _params = {'submitter': self.user_email, 'catalog': 1, 'history': 0, 'summary': 0}
         params.update(_params)
         return self.get_tasks(params=params, request_kwargs=request_kwargs)
 
@@ -561,7 +556,7 @@ def send(self, request, **kwargs):
         with warnings.catch_warnings(record=True) as w:
             warnings.filterwarnings('always')
             try:
-                r = super(ArchiveSession, self).send(request, **kwargs)
+                r = super().send(request, **kwargs)
             except Exception as e:
                 try:
                     reraise_modify(e, e.request.url, prepend=False)
diff --git a/internetarchive/utils.py b/internetarchive/utils.py
index f1d76c16..312ba774 100644
--- a/internetarchive/utils.py
+++ b/internetarchive/utils.py
@@ -30,12 +30,8 @@
 import os
 import re
 import sys
-from itertools import starmap
 from xml.dom.minidom import parseString
-
-import six
-from six.moves import zip_longest
-from six.moves.collections_abc import Mapping
+from collections.abc import Mapping
 
 
 def deep_update(d, u):
@@ -48,14 +44,6 @@ def deep_update(d, u):
     return d
 
 
-def map2x(func, *iterables):
-    """map() function for Python 2/3 compatibility"""
-    zipped = zip_longest(*iterables)
-    if func is None:
-        return zipped
-    return starmap(func, zipped)
-
-
 class InvalidIdentifierException(Exception):
     pass
 
@@ -91,9 +79,9 @@ def needs_quote(s):
 
 def norm_filepath(fp):
     fp = fp.replace(os.path.sep, '/')
-    if not fp[0] == '/':
-        fp = '/' + fp
-    if isinstance(fp, six.binary_type):
+    if not fp.startswith('/'):
+        fp = f'/{fp}'
+    if isinstance(fp, bytes):
         fp = fp.decode('utf-8')
     return fp
 
@@ -133,7 +121,7 @@ def new_hook(type, value, traceback):
     sys.excepthook = new_hook
 
 
-class IterableToFileAdapter(object):
+class IterableToFileAdapter:
     def __init__(self, iterable, size):
         self.iterator = iter(iterable)
         self.length = size
@@ -145,7 +133,7 @@ def __len__(self):
         return self.length
 
 
-class IdentifierListAsItems(object):
+class IdentifierListAsItems:
     """This class is a lazily-loaded list of Items, accessible by index or identifier.
     """
 
@@ -172,7 +160,7 @@ def __getattr__(self, name):
             raise AttributeError
 
     def __repr__(self):
-        return '{0.__class__.__name__}({0.ids!r})'.format(self)
+        return f'{self.__class__.__name__}({self.ids!r})'
 
 
 def get_s3_xml_text(xml_str):
@@ -192,7 +180,7 @@ def _get_tag_text(tag_name, xml_obj):
         _resource = _get_tag_text('Resource', p)
         # Avoid weird Resource text that contains PUT method.
         if _resource and "'PUT" not in _resource:
-            return '{0} - {1}'.format(_msg, _resource.strip())
+            return f'{_msg} - {_resource.strip()}'
         else:
             return _msg
     except:
@@ -229,7 +217,7 @@ def recursive_file_count(files, item=None, checksum=False):
     if checksum is True:
         md5s = [f.get('md5') for f in item.files]
     else:
-        md5s = list()
+        md5s = []
     if isinstance(files, dict):
         # make sure to use local filenames.
         _files = files.values()
@@ -284,8 +272,8 @@ def reraise_modify(caught_exc, append_msg, prepend=False):
     Preserves exception class, and exception traceback.
 
     Note:
-        This function needs to be called inside an except because
-        `sys.exc_info()` requires the exception context.
+        This function needs to be called inside an except because an exception
+        must be active in the current scope.
 
     Args:
         caught_exc(Exception): The caught exception object
@@ -299,9 +287,6 @@ def reraise_modify(caught_exc, append_msg, prepend=False):
         Re-raises the exception with the preserved data / trace but
         modified message
     """
-    ExceptClass = type(caught_exc)
-    # Keep old traceback
-    traceback = sys.exc_info()[2]
     if not caught_exc.args:
         # If no args, create our own tuple
         arg_list = [append_msg]
@@ -321,9 +306,7 @@ def reraise_modify(caught_exc, append_msg, prepend=False):
         else:
             arg_list += [last_arg, append_msg]
     caught_exc.args = tuple(arg_list)
-    six.reraise(ExceptClass,
-                caught_exc,
-                traceback)
+    raise
 
 
 def remove_none(obj):
diff --git a/pex-requirements.txt b/pex-requirements.txt
index 0679e4c3..b344ec50 100644
--- a/pex-requirements.txt
+++ b/pex-requirements.txt
@@ -1,8 +1,7 @@
-backports.csv
 docopt>=0.6.0,<0.7.0
 jsonpatch>=0.4
-requests>=2.9.1,<3.0.0
+requests>=2.25.0,<3.0.0
 schema>=0.4.0
 setuptools
-six>=1.13.0,<2.0.0
 tqdm>=4.0.0
+urllib3>=1.26.0
diff --git a/setup.cfg b/setup.cfg
index 4baa09dd..7c10d656 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,3 @@
-[bdist_wheel]
-universal = 1
-
 [flake8]
 ignore = E128,E722,F401,F841,W503,W605
 max-complexity = 34
diff --git a/setup.py b/setup.py
index 4508e56d..9e3fd16c 100644
--- a/setup.py
+++ b/setup.py
@@ -35,13 +35,12 @@
             'ia = internetarchive.cli.ia:main',
         ],
     },
+    python_requires='>=3.6',
     install_requires=[
-        'backports.csv < 1.07;python_version<"3.4"',
         'docopt>=0.6.0,<0.7.0',
         'jsonpatch>=0.4',
         'requests>=2.25.0,<3.0.0',
         'schema>=0.4.0',
-        'six>=1.13.0,<2.0.0',
         'tqdm>=4.0.0',
         'urllib3>=1.26.0',
     ],
@@ -51,9 +50,8 @@
         'Natural Language :: English',
         'License :: OSI Approved :: GNU Affero General Public License v3',
         'Programming Language :: Python',
-        'Programming Language :: Python :: 2',
-        'Programming Language :: Python :: 2.7',
         'Programming Language :: Python :: 3',
+        'Programming Language :: Python :: 3 :: Only',
         'Programming Language :: Python :: 3.6',
         'Programming Language :: Python :: 3.7',
         'Programming Language :: Python :: 3.8',
diff --git a/tox.ini b/tox.ini
index 3bdf79c8..8c75ad65 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,5 +1,5 @@
 [tox]
-envlist = py27,py35,py36,py37,py38,py39,py310,pypy37
+envlist = py36,py37,py38,py39,py310,pypy37
 
 [testenv]
 deps = -r tests/requirements.txt
@@ -7,12 +7,6 @@ deps = -r tests/requirements.txt
 commands = flake8
            pytest {posargs}
 
-[testenv:py27]
-basepython=python2.7
-
-[testenv:py35]
-basepython=python3.5
-
 [testenv:py36]
 basepython=python3.6
 


Test patch:
diff --git a/tests/cli/test_ia_download.py b/tests/cli/test_ia_download.py
index fa986b09..0275643f 100644
--- a/tests/cli/test_ia_download.py
+++ b/tests/cli/test_ia_download.py
@@ -11,32 +11,28 @@ def test_no_args(tmpdir_ch):
 
 
 def test_https(tmpdir_ch):
-    if sys.version_info < (2, 7, 9):
-        stdout, stderr = call_cmd('ia download nasa', expected_exit_code=1)
-        assert 'You are attempting to make an HTTPS' in stderr
-    else:
-        call_cmd('ia download nasa')
-        assert files_downloaded(path='nasa') == NASA_EXPECTED_FILES
+    call_cmd('ia download nasa')
+    assert files_downloaded(path='nasa') == NASA_EXPECTED_FILES
 
 
 def test_dry_run():
     nasa_url = 'http://archive.org/download/nasa/'
-    expected_urls = set([nasa_url + f for f in NASA_EXPECTED_FILES])
+    expected_urls = {nasa_url + f for f in NASA_EXPECTED_FILES}
 
     stdout, stderr = call_cmd('ia --insecure download --dry-run nasa')
     output_lines = stdout.split('\n')
-    dry_run_urls = set([x.strip() for x in output_lines if x and 'nasa:' not in x])
+    dry_run_urls = {x.strip() for x in output_lines if x and 'nasa:' not in x}
 
     assert expected_urls == dry_run_urls
 
 
 def test_glob(tmpdir_ch):
-    expected_files = set([
+    expected_files = {
         'globe_west_540.jpg',
         'globe_west_540_thumb.jpg',
         'nasa_itemimage.jpg',
         '__ia_thumb.jpg',
-    ])
+    }
 
     call_cmd('ia --insecure download --glob="*jpg" nasa')
     assert files_downloaded(path='nasa') == expected_files
@@ -44,32 +40,32 @@ def test_glob(tmpdir_ch):
 
 def test_format(tmpdir_ch):
     call_cmd('ia --insecure download --format="Archive BitTorrent" nasa')
-    assert files_downloaded(path='nasa') == set(['nasa_archive.torrent'])
+    assert files_downloaded(path='nasa') == {'nasa_archive.torrent'}
 
 
 def test_clobber(tmpdir_ch):
     cmd = 'ia --insecure download nasa nasa_meta.xml'
     call_cmd(cmd)
-    assert files_downloaded('nasa') == set(['nasa_meta.xml'])
+    assert files_downloaded('nasa') == {'nasa_meta.xml'}
 
     stdout, stderr = call_cmd(cmd)
-    assert files_downloaded('nasa') == set(['nasa_meta.xml'])
+    assert files_downloaded('nasa') == {'nasa_meta.xml'}
     assert 'nasa: . - success' == stdout
 
 
 def test_checksum(tmpdir_ch):
     call_cmd('ia --insecure download nasa nasa_meta.xml')
-    assert files_downloaded('nasa') == set(['nasa_meta.xml'])
+    assert files_downloaded('nasa') == {'nasa_meta.xml'}
 
     stdout, stderr = call_cmd('ia --insecure download --checksum nasa nasa_meta.xml')
-    assert files_downloaded('nasa') == set(['nasa_meta.xml'])
+    assert files_downloaded('nasa') == {'nasa_meta.xml'}
 
     assert 'nasa: . - success' == stdout
 
 
 def test_no_directories(tmpdir_ch):
     call_cmd('ia --insecure download --no-directories nasa nasa_meta.xml')
-    assert files_downloaded('.') == set(['nasa_meta.xml'])
+    assert files_downloaded('.') == {'nasa_meta.xml'}
 
 
 def test_destdir(tmpdir_ch):
@@ -80,13 +76,13 @@ def test_destdir(tmpdir_ch):
 
     tmpdir_ch.mkdir('thisdirdoesnotexist/')
     call_cmd(cmd)
-    assert files_downloaded('thisdirdoesnotexist/nasa') == set(['nasa_meta.xml'])
+    assert files_downloaded('thisdirdoesnotexist/nasa') == {'nasa_meta.xml'}
 
     tmpdir_ch.mkdir('dir2/')
     cmd = ('ia --insecure download --no-directories --destdir=dir2/ '
            'nasa nasa_meta.xml')
     call_cmd(cmd)
-    assert files_downloaded('dir2') == set(['nasa_meta.xml'])
+    assert files_downloaded('dir2') == {'nasa_meta.xml'}
 
 
 def test_no_change_timestamp(tmpdir_ch):
diff --git a/tests/cli/test_ia_list.py b/tests/cli/test_ia_list.py
index ef18eca7..f8ed758e 100644
--- a/tests/cli/test_ia_list.py
+++ b/tests/cli/test_ia_list.py
@@ -6,20 +6,20 @@
 
 SESSION = get_session()
 
-NASA_FILES = set([
+NASA_FILES = {
     'NASAarchiveLogo.jpg',
     'globe_west_540.jpg',
     'nasa_reviews.xml',
     'nasa_meta.xml',
     'nasa_archive.torrent',
     'nasa_files.xml'
-])
+}
 
 
 def test_ia_list(capsys, nasa_mocker):
     ia_list.main(['list', 'nasa'], SESSION)
     out, err = capsys.readouterr()
-    assert set([l for l in out.split('\n') if l]) == NASA_FILES
+    assert {l for l in out.split('\n') if l} == NASA_FILES
 
 
 def test_ia_list_verbose(capsys, nasa_mocker):
@@ -28,7 +28,7 @@ def test_ia_list_verbose(capsys, nasa_mocker):
     out, err = capsys.readouterr()
     _nasa_files = deepcopy(NASA_FILES)
     _nasa_files.add('name')
-    assert set([l for l in out.split('\n') if l]) == _nasa_files
+    assert {l for l in out.split('\n') if l} == _nasa_files
 
 
 def test_ia_list_all(capsys, nasa_mocker):
@@ -74,12 +74,12 @@ def test_ia_list_format(capsys, nasa_mocker):
     ia_list.main(['list', '--format', 'Metadata', 'nasa'], SESSION)
 
     out, err = capsys.readouterr()
-    expected_output = set([
+    expected_output = {
         'nasa_reviews.xml',
         'nasa_files.xml',
         'nasa_meta.xml',
-    ])
-    assert set([f for f in out.split('\n') if f]) == expected_output
+    }
+    assert {f for f in out.split('\n') if f} == expected_output
 
 
 def test_ia_list_non_existing(capsys):
diff --git a/tests/cli/test_ia_metadata.py b/tests/cli/test_ia_metadata.py
index 27b348b3..400ccbe7 100644
--- a/tests/cli/test_ia_metadata.py
+++ b/tests/cli/test_ia_metadata.py
@@ -23,8 +23,8 @@ def test_ia_metadata_exists(capsys):
 def test_ia_metadata_formats(capsys, nasa_mocker):
     ia_call(['ia', 'metadata', '--formats', 'nasa'])
     out, err = capsys.readouterr()
-    expected_formats = set(['Collection Header', 'Archive BitTorrent', 'JPEG',
-                            'Metadata', ''])
+    expected_formats = {'Collection Header', 'Archive BitTorrent', 'JPEG',
+                        'Metadata', ''}
     assert set(out.split('\n')) == expected_formats
 
 
@@ -35,8 +35,7 @@ def test_ia_metadata_modify(capsys):
         rsps.add_metadata_mock('nasa')
         rsps.add_metadata_mock('nasa', body=md_rsp, method=responses.POST)
         rsps.add_metadata_mock('nasa')
-        valid_key = "foo-{k}".format(k=int(time()))
-        ia_call(['ia', 'metadata', '--modify', '{0}:test_value'.format(valid_key),
-                 'nasa'])
+        valid_key = f'foo-{int(time())}'
+        ia_call(['ia', 'metadata', '--modify', f'{valid_key}:test_value', 'nasa'])
         out, err = capsys.readouterr()
         assert out == 'nasa - success: https://catalogd.archive.org/log/447613301\n'
diff --git a/tests/cli/test_ia_search.py b/tests/cli/test_ia_search.py
index 26cc5f43..bf3f9b6b 100644
--- a/tests/cli/test_ia_search.py
+++ b/tests/cli/test_ia_search.py
@@ -12,13 +12,13 @@ def test_ia_search_itemlist(capsys):
     test_scrape_response = load_test_data_file('scrape_response.json')
 
     with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
-        url1 = ('{0}//archive.org/services/search/v1/scrape'
+        url1 = (f'{PROTOCOL}//archive.org/services/search/v1/scrape'
                 '?q=collection%3Aattentionkmartshoppers'
-                '&count=10000'.format(PROTOCOL))
-        url2 = ('{0}//archive.org/services/search/v1/scrape?'
+                '&count=10000')
+        url2 = (f'{PROTOCOL}//archive.org/services/search/v1/scrape?'
                 'cursor=W3siaWRlbnRpZmllciI6IjE5NjEtTC0wNTkxNCJ9XQ%3D%3D'
                 '&q=collection%3Aattentionkmartshoppers'
-                '&count=10000'.format(PROTOCOL))
+                '&count=10000')
         rsps.add(responses.POST, url1,
                  body=test_scrape_response,
                  match_querystring=True)
@@ -36,9 +36,9 @@ def test_ia_search_itemlist(capsys):
 
 def test_ia_search_num_found(capsys):
     with IaRequestsMock(assert_all_requests_are_fired=False) as rsps:
-        url = ('{0}//archive.org/services/search/v1/scrape'
+        url = (f'{PROTOCOL}//archive.org/services/search/v1/scrape'
                '?q=collection%3Anasa&total_only=true'
-               '&count=10000'.format(PROTOCOL))
+               '&count=10000')
         rsps.add(responses.POST, url,
                  body='{"items":[],"count":0,"total":50}',
                  match_querystring=True)
diff --git a/tests/cli/test_ia_upload.py b/tests/cli/test_ia_upload.py
index 50a59c5c..f78e58d5 100644
--- a/tests/cli/test_ia_upload.py
+++ b/tests/cli/test_ia_upload.py
@@ -15,13 +15,12 @@ def test_ia_upload(tmpdir_ch, caplog):
 
     with IaRequestsMock() as rsps:
         rsps.add_metadata_mock('nasa')
-        rsps.add(responses.PUT, '{0}//s3.us.archive.org/nasa/test.txt'.format(PROTOCOL),
+        rsps.add(responses.PUT, f'{PROTOCOL}//s3.us.archive.org/nasa/test.txt',
                  body='',
                  content_type='text/plain')
         ia_call(['ia', '--log', 'upload', 'nasa', 'test.txt'])
 
-    assert ('uploaded test.txt to {0}//s3.us.archive.org/nasa/'
-            'test.txt'.format(PROTOCOL)) in caplog.text
+    assert f'uploaded test.txt to {PROTOCOL}//s3.us.archive.org/nasa/test.txt' in caplog.text
 
 
 def test_ia_upload_invalid_identifier(capsys, caplog):
@@ -40,7 +39,7 @@ def test_ia_upload_invalid_identifier(capsys, caplog):
 
 def test_ia_upload_status_check(capsys):
     with IaRequestsMock() as rsps:
-        rsps.add(responses.GET, '{0}//s3.us.archive.org'.format(PROTOCOL),
+        rsps.add(responses.GET, f'{PROTOCOL}//s3.us.archive.org',
                  body=STATUS_CHECK_RESPONSE,
                  content_type='application/json')
 
@@ -51,7 +50,7 @@ def test_ia_upload_status_check(capsys):
         j = json.loads(STATUS_CHECK_RESPONSE)
         j['over_limit'] = 1
         rsps.reset()
-        rsps.add(responses.GET, '{0}//s3.us.archive.org'.format(PROTOCOL),
+        rsps.add(responses.GET, f'{PROTOCOL}//s3.us.archive.org',
                  body=json.dumps(j),
                  content_type='application/json')
 
@@ -90,7 +89,7 @@ def test_ia_upload_403(capsys):
     with IaRequestsMock() as rsps:
         rsps.add_metadata_mock('nasa')
         rsps.add(responses.PUT,
-                 '{0}//s3.us.archive.org/nasa/test_ia_upload.py'.format(PROTOCOL),
+                 f'{PROTOCOL}//s3.us.archive.org/nasa/test_ia_upload.py',
                  body=s3_error,
                  status=403,
                  content_type='text/plain')
@@ -130,15 +129,15 @@ def test_ia_upload_unicode(tmpdir_ch, caplog):
     with IaRequestsMock(assert_all_requests_are_fired=False) as rsps:
         rsps.add_metadata_mock('nasa')
         rsps.add(responses.PUT,
-                 '{0}//s3.us.archive.org/nasa/{1}'.format(PROTOCOL, efname),
+                 f'{PROTOCOL}//s3.us.archive.org/nasa/{efname}',
                  body='',
                  content_type='text/plain')
         ia_call(['ia', '--log', 'upload', 'nasa', 'தமிழ் - baz ∆.txt',
                  '--metadata', 'foo:∆'])
 
-    assert (u'uploaded தமிழ் - baz ∆.txt to {0}//s3.us.archive.org/nasa/'
-            u'%E0%AE%A4%E0%AE%AE%E0%AE%BF%E0%AE%B4%E0%AF%8D%20-%20'
-            u'baz%20%E2%88%86.txt'.format(PROTOCOL)) in caplog.text
+    assert (f'uploaded தமிழ் - baz ∆.txt to {PROTOCOL}//s3.us.archive.org/nasa/'
+            '%E0%AE%A4%E0%AE%AE%E0%AE%BF%E0%AE%B4%E0%AF%8D%20-%20'
+            'baz%20%E2%88%86.txt') in caplog.text
 
 
 def test_ia_upload_remote_name(tmpdir_ch, caplog):
@@ -147,11 +146,10 @@ def test_ia_upload_remote_name(tmpdir_ch, caplog):
 
     with IaRequestsMock() as rsps:
         rsps.add_metadata_mock('nasa')
-        rsps.add(responses.PUT, '{0}//s3.us.archive.org/nasa/hi.txt'.format(PROTOCOL),
+        rsps.add(responses.PUT, f'{PROTOCOL}//s3.us.archive.org/nasa/hi.txt',
                  body='',
                  content_type='text/plain')
         ia_call(['ia', '--log', 'upload', 'nasa', 'test.txt', '--remote-name',
                  'hi.txt'])
 
-    assert ('uploaded hi.txt to {0}//s3.us.archive.org/nasa/'
-            'hi.txt'.format(PROTOCOL)) in caplog.text
+    assert f'uploaded hi.txt to {PROTOCOL}//s3.us.archive.org/nasa/hi.txt' in caplog.text
diff --git a/tests/conftest.py b/tests/conftest.py
index bf4fe442..72868a08 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,5 +1,3 @@
-from __future__ import absolute_import
-
 import json
 import os
 import re
@@ -27,11 +25,11 @@ class WindowsError(Exception):
 
 PROTOCOL = 'https:'
 BASE_URL = 'https://archive.org/'
-METADATA_URL = BASE_URL + 'metadata/'
+METADATA_URL = f'{BASE_URL}metadata/'
 ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 TEST_CONFIG = os.path.join(ROOT_DIR, 'tests/ia.ini')
 NASA_METADATA_PATH = os.path.join(ROOT_DIR, 'tests/data/metadata/nasa.json')
-NASA_EXPECTED_FILES = set([
+NASA_EXPECTED_FILES = {
     'globe_west_540.jpg',
     'globe_west_540_thumb.jpg',
     'nasa_archive.torrent',
@@ -42,7 +40,7 @@ class WindowsError(Exception):
     'nasa_itemimage.jpg',
     'globe_west_540_thumb.jpg',
     '__ia_thumb.jpg',
-])
+}
 
 
 def ia_call(argv, expected_exit_code=0):
@@ -58,7 +56,7 @@ def ia_call(argv, expected_exit_code=0):
 
 
 def files_downloaded(path):
-    found_files = set([])
+    found_files = set()
     try:
         found_files = set(os.listdir(path))
     except (FileNotFoundError, WindowsError, OSError):
@@ -90,13 +88,13 @@ def call_cmd(cmd, expected_exit_code=0):
 class IaRequestsMock(RequestsMock):
     def add_metadata_mock(self, identifier, body=None, method=responses.GET,
                           protocol='https?'):
-        url = re.compile(r'%s://archive.org/metadata/%s' % (protocol, identifier))
+        url = re.compile(f'{protocol}://archive.org/metadata/{identifier}')
         if body is None:
-            body = load_test_data_file('metadata/' + identifier + '.json')
+            body = load_test_data_file(f'metadata/{identifier}.json')
         self.add(method, url, body=body, content_type='application/json')
 
     def mock_all_downloads(self, num_calls=1, body='test content', protocol='https?'):
-        url = re.compile(r'{0}://archive.org/download/.*'.format(protocol))
+        url = re.compile(f'{protocol}://archive.org/download/.*')
         for _ in range(6):
             self.add(responses.GET, url, body=body)
 
@@ -124,7 +122,7 @@ def nasa_item():
 
 @pytest.fixture
 def session():
-    return get_session(config=dict(s3=dict(access='access', secret='secret')))
+    return get_session(config={'s3': {'access': 'access', 'secret': 'secret'}})
 
 
 @pytest.fixture
diff --git a/tests/requirements.txt b/tests/requirements.txt
index 30bf2aab..71a23657 100644
--- a/tests/requirements.txt
+++ b/tests/requirements.txt
@@ -1,6 +1,3 @@
-flake8==3.9.2; python_version < '3.6'
-flake8==4.0.1; python_version >= '3.6'
-pytest==4.6.11; python_version < '3.0'
-pytest==6.1.2; python_version >= '3.0' and python_version < '3.6'
-pytest==6.2.5; python_version >= '3.6'
+flake8==4.0.1
+pytest==6.2.5
 responses==0.14.0
diff --git a/tests/test_api.py b/tests/test_api.py
index 9b1f20fb..4e1d7f92 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -1,12 +1,9 @@
 # -*- coding: utf-8 -*-
-from __future__ import unicode_literals
-
 import json
 import os
 import re
 
 import responses
-import six
 from requests.packages import urllib3
 
 from internetarchive import (get_session, get_item, get_files, modify_metadata,
@@ -16,8 +13,6 @@
 from tests.conftest import (NASA_METADATA_PATH, PROTOCOL, IaRequestsMock,
                             load_test_data_file, load_file)
 
-PY3 = six.PY3
-
 TEST_SEARCH_RESPONSE = load_test_data_file('advanced_search_response.json')
 TEST_SCRAPE_RESPONSE = load_test_data_file('scrape_response.json')
 _j = json.loads(TEST_SCRAPE_RESPONSE)
@@ -71,7 +66,7 @@ def test_get_item_with_kwargs():
     with IaRequestsMock(assert_all_requests_are_fired=False) as rsps:
         rsps.add_metadata_mock('nasa')
         item = get_item('nasa', http_adapter_kwargs={'max_retries': 13})
-        assert isinstance(item.session.adapters['{0}//'.format(PROTOCOL)].max_retries,
+        assert isinstance(item.session.adapters[f'{PROTOCOL}//'].max_retries,
                           urllib3.Retry)
 
     try:
@@ -84,15 +79,15 @@ def test_get_files():
     with IaRequestsMock(assert_all_requests_are_fired=False) as rsps:
         rsps.add_metadata_mock('nasa')
         files = get_files('nasa')
-        expected_files = set([
+        expected_files = {
             'NASAarchiveLogo.jpg',
             'globe_west_540.jpg',
             'nasa_reviews.xml',
             'nasa_meta.xml',
             'nasa_archive.torrent',
             'nasa_files.xml',
-        ])
-        assert set([f.name for f in files]) == expected_files
+        }
+        assert {f.name for f in files} == expected_files
 
 
 def test_get_files_with_get_item_kwargs(tmpdir):
@@ -156,11 +151,11 @@ def test_get_files_formats():
         assert files[0].name == 'globe_west_540.jpg'
 
         files = get_files('nasa', formats=['JPEG', 'Collection Header'])
-        expected_files = set([
+        expected_files = {
             'globe_west_540.jpg',
             'NASAarchiveLogo.jpg',
-        ])
-        assert set([f.name for f in files]) == expected_files
+        }
+        assert {f.name for f in files} == expected_files
 
 
 def test_get_files_glob_pattern():
@@ -172,22 +167,22 @@ def test_get_files_glob_pattern():
         assert files[0].name == 'nasa_archive.torrent'
 
         files = get_files('nasa', glob_pattern='*torrent|*jpg')
-        expected_files = set([
+        expected_files = {
             'globe_west_540.jpg',
             'NASAarchiveLogo.jpg',
             'nasa_archive.torrent',
-        ])
-        assert set([f.name for f in files]) == expected_files
+        }
+        assert {f.name for f in files} == expected_files
 
 
 def test_modify_metadata():
     with IaRequestsMock(assert_all_requests_are_fired=False) as rsps:
-        rsps.add(responses.GET, '{0}//archive.org/metadata/nasa'.format(PROTOCOL),
+        rsps.add(responses.GET, f'{PROTOCOL}//archive.org/metadata/nasa',
                  body='{"metadata":{"title":"foo"}}')
-        rsps.add(responses.POST, '{0}//archive.org/metadata/nasa'.format(PROTOCOL),
+        rsps.add(responses.POST, f'{PROTOCOL}//archive.org/metadata/nasa',
                  body=('{"success":true,"task_id":423444944,'
                        '"log":"https://catalogd.archive.org/log/423444944"}'))
-        r = modify_metadata('nasa', dict(foo=1))
+        r = modify_metadata('nasa', {'foo': 1})
         assert r.status_code == 200
         assert r.json() == {
             'task_id': 423444944,
@@ -209,14 +204,14 @@ def test_upload():
         rsps.add(responses.PUT, re.compile(r'.*s3.us.archive.org/.*'),
                  adding_headers=expected_s3_headers)
         rsps.add_metadata_mock('nasa')
-        rsps.add(responses.GET, '{0}//archive.org/metadata/nasa'.format(PROTOCOL),
+        rsps.add(responses.GET, f'{PROTOCOL}//archive.org/metadata/nasa',
                  body='{}')
         _responses = upload('nasa', NASA_METADATA_PATH,
                             access_key='test_access',
                             secret_key='test_secret')
         for response in _responses:
             req = response.request
-            headers = dict((k.lower(), str(v)) for k, v in req.headers.items())
+            headers = {k.lower(): str(v) for k, v in req.headers.items()}
             scanner_header = '%20'.join(
                 response.headers['x-archive-meta00-scanner'].split('%20')[:4])
             headers['x-archive-meta00-scanner'] = scanner_header
@@ -226,7 +221,7 @@ def test_upload():
             del headers['connection']
             del headers['user-agent']
             assert headers == expected_s3_headers
-            assert req.url == '{0}//s3.us.archive.org/nasa/nasa.json'.format(PROTOCOL)
+            assert req.url == f'{PROTOCOL}//s3.us.archive.org/nasa/nasa.json'
 
 
 def test_upload_validate_identifier():
@@ -251,7 +246,7 @@ def test_upload_validate_identifier():
         rsps.add(responses.PUT, re.compile(r'.*s3.us.archive.org/.*'),
                  adding_headers=expected_s3_headers)
         rsps.add_metadata_mock('nasa')
-        rsps.add(responses.GET, '{0}//archive.org/metadata/nasa'.format(PROTOCOL),
+        rsps.add(responses.GET, f'{PROTOCOL}//archive.org/metadata/nasa',
                  body='{}')
         upload('nasa', NASA_METADATA_PATH,
               access_key='test_access',
@@ -264,7 +259,7 @@ def test_download(tmpdir):
     tmpdir.chdir()
     with IaRequestsMock() as rsps:
         rsps.add(responses.GET,
-                 '{0}//archive.org/download/nasa/nasa_meta.xml'.format(PROTOCOL),
+                 f'{PROTOCOL}//archive.org/download/nasa/nasa_meta.xml',
                  body='test content')
         rsps.add_metadata_mock('nasa')
         download('nasa', 'nasa_meta.xml')
@@ -274,11 +269,11 @@ def test_download(tmpdir):
 
 
 def test_search_items(session):
-    results_url = ('{0}//archive.org/services/search/v1/scrape'
-                   '?q=identifier%3Anasa&count=10000'.format(PROTOCOL))
-    count_url = ('{0}//archive.org/services/search/v1/scrape'
+    results_url = (f'{PROTOCOL}//archive.org/services/search/v1/scrape'
+                   '?q=identifier%3Anasa&count=10000')
+    count_url = (f'{PROTOCOL}//archive.org/services/search/v1/scrape'
                  '?q=identifier%3Anasa&total_only=true'
-                 '&count=10000'.format(PROTOCOL))
+                 '&count=10000')
     with IaRequestsMock(assert_all_requests_are_fired=False) as rsps:
         rsps.add(responses.POST, results_url,
                  body=TEST_SCRAPE_RESPONSE,
@@ -304,12 +299,12 @@ def test_search_items_with_fields(session):
         {'identifier': 'nasa', 'title': 'NASA Images'}
     ]
     search_response_str = json.dumps(_j)
-    results_url = ('{0}//archive.org/services/search/v1/scrape'
+    results_url = (f'{PROTOCOL}//archive.org/services/search/v1/scrape'
                    '?q=identifier%3Anasa&count=10000'
-                   '&fields=identifier%2Ctitle'.format(PROTOCOL))
-    count_url = ('{0}//archive.org/services/search/v1/scrape'
+                   '&fields=identifier%2Ctitle')
+    count_url = (f'{PROTOCOL}//archive.org/services/search/v1/scrape'
                  '?q=identifier%3Anasa&total_only=true'
-                 '&count=10000'.format(PROTOCOL))
+                 '&count=10000')
     with IaRequestsMock() as rsps:
         rsps.add(responses.POST, results_url,
                  match_querystring=True,
@@ -326,7 +321,7 @@ def test_search_items_with_fields(session):
 def test_search_items_as_items(session):
     with IaRequestsMock(assert_all_requests_are_fired=False) as rsps:
         rsps.add(responses.POST,
-                 '{0}//archive.org/services/search/v1/scrape'.format(PROTOCOL),
+                 f'{PROTOCOL}//archive.org/services/search/v1/scrape',
                  body=TEST_SCRAPE_RESPONSE)
         rsps.add_metadata_mock('nasa')
         r = search_items('identifier:nasa', archive_session=session)
@@ -337,7 +332,7 @@ def test_search_items_as_items(session):
 def test_search_items_fts(session):
     with IaRequestsMock(assert_all_requests_are_fired=False) as rsps:
         rsps.add(responses.POST,
-                 '{0}//be-api.us.archive.org/ia-pub-fts-api'.format(PROTOCOL),
+                 f'{PROTOCOL}//be-api.us.archive.org/ia-pub-fts-api',
                  body=TEST_SCRAPE_RESPONSE)
         rsps.add_metadata_mock('nasa')
 
@@ -373,11 +368,11 @@ def test_page_row_specification(session):
     _j['response']['numFound'] = 1
     _search_r = json.dumps(_j)
     with IaRequestsMock(assert_all_requests_are_fired=False) as rsps:
-        rsps.add(responses.GET, '{0}//archive.org/advancedsearch.php'.format(PROTOCOL),
+        rsps.add(responses.GET, f'{PROTOCOL}//archive.org/advancedsearch.php',
                  body=_search_r)
         rsps.add_metadata_mock('nasa')
         rsps.add(responses.POST,
-                 '{0}//archive.org/services/search/v1/scrape'.format(PROTOCOL),
+                 f'{PROTOCOL}//archive.org/services/search/v1/scrape',
                  body='{"items":[],"count":0,"total":1}',
                  match_querystring=False,
                  content_type='application/json; charset=UTF-8')
diff --git a/tests/test_config.py b/tests/test_config.py
index 5676fcae..a4645875 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -1,12 +1,6 @@
 import contextlib
 import os
-import six
-from six.moves import http_client as httplib
-from six import StringIO
-try:
-    import mock
-except ImportError:
-    from unittest import mock
+from unittest import mock
 import pytest
 import responses
 import requests.adapters
@@ -179,21 +173,6 @@ def _environ(**kwargs):
                 del os.environ[k]
 
 
-try:
-    TemporaryDirectory = tempfile.TemporaryDirectory
-except AttributeError:
-    # Crutch for Python 2
-    import shutil
-
-    @contextlib.contextmanager
-    def TemporaryDirectory():
-        path = tempfile.mkdtemp()
-        try:
-            yield path
-        finally:
-            shutil.rmtree(path)
-
-
 def _test_parse_config_file(
         expected_result,
         config_file_contents='',
@@ -212,7 +191,7 @@ def _test_parse_config_file(
     if not config_file_paths:
         config_file_paths = []
 
-    with TemporaryDirectory() as tmp_test_dir:
+    with tempfile.TemporaryDirectory() as tmp_test_dir:
         def _replace_path(s):
             if s and s.startswith('$TMPTESTDIR/'):
                 return os.path.join(tmp_test_dir, s.split('/', 1)[1])
@@ -225,11 +204,7 @@ def _replace_path(s):
         config_file_param = _replace_path(config_file_param)
 
         for p in config_file_paths:
-            try:
-                os.makedirs(os.path.dirname(p), exist_ok=True)
-            except TypeError:  # Python 2 doesn't have exist_ok
-                if not os.path.exists(os.path.dirname(p)):
-                    os.makedirs(os.path.dirname(p))
+            os.makedirs(os.path.dirname(p), exist_ok=True)
             with open(p, 'w') as fp:
                 fp.write(config_file_contents)
 
@@ -348,7 +323,7 @@ def _test_write_config_file(
     # All paths are evaluated relative to a temporary HOME.
     # Mode comparison accounts for the umask; expected_modes does not need to care about it.
 
-    with TemporaryDirectory() as temp_home_dir:
+    with tempfile.TemporaryDirectory() as temp_home_dir:
         expected_config_file = os.path.join(temp_home_dir, expected_config_file)
         if dirs:
             dirs = [os.path.join(temp_home_dir, d) for d in dirs]
@@ -455,7 +430,7 @@ def test_write_config_file_custom_path_existing():
 
 def test_write_config_file_custom_path_not_existing():
     '''Ensure that an exception is thrown if the custom path dir doesn't exist'''
-    with TemporaryDirectory() as temp_home_dir:
+    with tempfile.TemporaryDirectory() as temp_home_dir:
         with _environ(HOME=temp_home_dir):
             config_file = os.path.join(temp_home_dir, 'foo/ia.ini')
             with pytest.raises(IOError):
diff --git a/tests/test_item.py b/tests/test_item.py
index d85e9282..04082491 100644
--- a/tests/test_item.py
+++ b/tests/test_item.py
@@ -1,6 +1,4 @@
 # -*- coding: utf-8 -*-
-from __future__ import unicode_literals
-
 from internetarchive.api import get_item
 from internetarchive.utils import norm_filepath, InvalidIdentifierException
 from tests.conftest import (PROTOCOL, IaRequestsMock, load_file,
@@ -22,8 +20,8 @@
 from internetarchive import get_session
 import internetarchive.files
 
-S3_URL = r'{0}//s3.us.archive.org/'.format(PROTOCOL)
-DOWNLOAD_URL_RE = re.compile(r'{0}//archive.org/download/.*'.format(PROTOCOL))
+S3_URL = f'{PROTOCOL}//s3.us.archive.org/'
+DOWNLOAD_URL_RE = re.compile(f'{PROTOCOL}//archive.org/download/.*')
 S3_URL_RE = re.compile(r'.*s3.us.archive.org/.*')
 
 EXPECTED_S3_HEADERS = {
@@ -69,58 +67,54 @@ def test_get_files(nasa_item):
     files = nasa_item.get_files()
     assert isinstance(files, types.GeneratorType)
 
-    expected_files = set(['NASAarchiveLogo.jpg',
-                          'globe_west_540.jpg',
-                          'nasa_reviews.xml',
-                          'nasa_meta.xml',
-                          'nasa_archive.torrent',
-                          'nasa_files.xml', ])
-    files = set(x.name for x in files)
+    expected_files = {'NASAarchiveLogo.jpg',
+                      'globe_west_540.jpg',
+                      'nasa_reviews.xml',
+                      'nasa_meta.xml',
+                      'nasa_archive.torrent',
+                      'nasa_files.xml'}
+    files = {x.name for x in files}
     assert files == expected_files
 
 
 def test_get_files_by_name(nasa_item):
     files = nasa_item.get_files('globe_west_540.jpg')
-    assert set(f.name for f in files) == set(['globe_west_540.jpg'])
+    assert {f.name for f in files} == {'globe_west_540.jpg'}
 
     files = nasa_item.get_files(['globe_west_540.jpg', 'nasa_meta.xml'])
-    assert set(f.name
-               for f in files) == set(['globe_west_540.jpg', 'nasa_meta.xml'])
+    assert {f.name for f in files} == {'globe_west_540.jpg', 'nasa_meta.xml'}
 
 
 def test_get_files_by_formats(nasa_item):
-    files = set(f.name for f in nasa_item.get_files(formats='Archive BitTorrent'))
-    expected_files = set(['nasa_archive.torrent'])
+    files = {f.name for f in nasa_item.get_files(formats='Archive BitTorrent')}
+    expected_files = {'nasa_archive.torrent'}
     assert files == expected_files
 
-    files = set(
-        f.name for f in nasa_item.get_files(formats=['Archive BitTorrent', 'JPEG']))
-    expected_files = set(['nasa_archive.torrent', 'globe_west_540.jpg', ])
+    files = {f.name for f in nasa_item.get_files(formats=['Archive BitTorrent', 'JPEG'])}
+    expected_files = {'nasa_archive.torrent', 'globe_west_540.jpg'}
     assert files == expected_files
 
 
 def test_get_files_by_glob(nasa_item):
-    files = set(f.name for f in nasa_item.get_files(glob_pattern='*jpg|*torrent'))
-    expected_files = set(['NASAarchiveLogo.jpg',
-                          'globe_west_540.jpg',
-                          'nasa_archive.torrent', ])
+    files = {f.name for f in nasa_item.get_files(glob_pattern='*jpg|*torrent')}
+    expected_files = {'NASAarchiveLogo.jpg',
+                      'globe_west_540.jpg',
+                      'nasa_archive.torrent'}
     assert files == expected_files
 
-    files = set(f.name
-                for f in nasa_item.get_files(glob_pattern=['*jpg', '*torrent']))
-    expected_files = set(['NASAarchiveLogo.jpg',
-                          'globe_west_540.jpg',
-                          'nasa_archive.torrent', ])
+    files = {f.name for f in nasa_item.get_files(glob_pattern=['*jpg', '*torrent'])}
+    expected_files = {'NASAarchiveLogo.jpg',
+                      'globe_west_540.jpg',
+                      'nasa_archive.torrent'}
     assert files == expected_files
 
 
 def test_get_files_with_multiple_filters(nasa_item):
-    files = set(f.name for f in nasa_item.get_files(formats='JPEG',
-                                                    glob_pattern='*xml'))
-    expected_files = set(['globe_west_540.jpg',
-                          'nasa_reviews.xml',
-                          'nasa_meta.xml',
-                          'nasa_files.xml', ])
+    files = {f.name for f in nasa_item.get_files(formats='JPEG', glob_pattern='*xml')}
+    expected_files = {'globe_west_540.jpg',
+                      'nasa_reviews.xml',
+                      'nasa_meta.xml',
+                      'nasa_files.xml'}
     assert files == expected_files
 
 
@@ -223,7 +217,7 @@ def test_download_destdir(tmpdir, nasa_item):
 
 
 def test_download_no_directory(tmpdir, nasa_item):
-    url_re = re.compile(r'{0}//archive.org/download/.*'.format(PROTOCOL))
+    url_re = re.compile(f'{PROTOCOL}//archive.org/download/.*')
     tmpdir.chdir()
     with IaRequestsMock() as rsps:
         rsps.add(responses.GET, url_re, body='no dest dir')
@@ -240,10 +234,10 @@ def test_download_dry_run(tmpdir, capsys, nasa_item):
                  adding_headers={'content-length': '100'})
         nasa_item.download(formats='Metadata', dry_run=True)
 
-    expected = set(['nasa_reviews.xml', 'nasa_meta.xml', 'nasa_files.xml'])
+    expected = {'nasa_reviews.xml', 'nasa_meta.xml', 'nasa_files.xml'}
     out, err = capsys.readouterr()
 
-    assert set([x.split('/')[-1] for x in out.split('\n') if x]) == expected
+    assert {x.split('/')[-1] for x in out.split('\n') if x} == expected
 
 
 def test_download_verbose(tmpdir, capsys, nasa_item):
@@ -265,7 +259,7 @@ def test_download_dark_item(tmpdir, capsys, nasa_metadata, session):
         nasa_metadata['metadata']['identifier'] = 'dark-item'
         nasa_metadata['is_dark'] = True
         _item_metadata = json.dumps(nasa_metadata)
-        rsps.add(responses.GET, '{0}//archive.org/metadata/dark-item'.format(PROTOCOL),
+        rsps.add(responses.GET, f'{PROTOCOL}//archive.org/metadata/dark-item',
                  body=_item_metadata,
                  content_type='application/json')
         _item = session.get_item('dark-item')
@@ -287,14 +281,14 @@ def test_upload(nasa_item):
                                       secret_key='b')
         for resp in _responses:
             request = resp.request
-            headers = dict((k.lower(), str(v)) for k, v in request.headers.items())
+            headers = {k.lower(): str(v) for k, v in request.headers.items()}
             scanner_header = '%20'.join(
                 resp.headers['x-archive-meta00-scanner'].split('%20')[:4])
             headers['x-archive-meta00-scanner'] = scanner_header
             assert 'user-agent' in headers
             del headers['user-agent']
             assert headers == EXPECTED_S3_HEADERS
-            assert request.url == '{0}//s3.us.archive.org/nasa/nasa.json'.format(PROTOCOL)
+            assert request.url == f'{PROTOCOL}//s3.us.archive.org/nasa/nasa.json'
 
 
 def test_upload_validate_identifier(session):
@@ -351,13 +345,13 @@ def test_upload_metadata(nasa_item):
             'D0%BD%D0%B5%D1%82...)')
         rsps.add(responses.PUT, S3_URL_RE,
                  adding_headers=_expected_headers)
-        md = dict(
-            foo='bar',
-            subject=['first', 'second'],
-            baz='Почему бы и нет...',
-            baz2=(u'\u041f\u043e\u0447\u0435\u043c\u0443 \u0431\u044b \u0438 '
-                  u'\u043d\u0435\u0442...'),
-        )
+        md = {
+            'foo': 'bar',
+            'subject': ['first', 'second'],
+            'baz': 'Почему бы и нет...',
+            'baz2': ('\u041f\u043e\u0447\u0435\u043c\u0443 \u0431\u044b \u0438 '
+                     '\u043d\u0435\u0442...'),
+        }
         _responses = nasa_item.upload(NASA_METADATA_PATH,
                                       metadata=md,
                                       access_key='a',
@@ -365,7 +359,7 @@ def test_upload_metadata(nasa_item):
         for resp in _responses:
             request = resp.request
             del request.headers['x-archive-meta00-scanner']
-            headers = dict((k.lower(), str(v)) for k, v in request.headers.items())
+            headers = {k.lower(): str(v) for k, v in request.headers.items()}
             assert 'user-agent' in headers
             del headers['user-agent']
             assert headers == _expected_headers
@@ -404,8 +398,8 @@ def test_upload_file_keys(nasa_item):
         files = {'new_key.txt': NASA_METADATA_PATH, '222': NASA_METADATA_PATH}
         _responses = nasa_item.upload(files, access_key='a', secret_key='b')
         expected_urls = [
-            '{0}//s3.us.archive.org/nasa/new_key.txt'.format(PROTOCOL),
-            '{0}//s3.us.archive.org/nasa/222'.format(PROTOCOL)
+            f'{PROTOCOL}//s3.us.archive.org/nasa/new_key.txt',
+            f'{PROTOCOL}//s3.us.archive.org/nasa/222',
         ]
         for resp in _responses:
             assert resp.request.url in expected_urls
@@ -427,8 +421,8 @@ def test_upload_dir(tmpdir, nasa_item):
                                       access_key='a',
                                       secret_key='b')
         expected_eps = [
-            '{0}nasa/foo.txt'.format(S3_URL),
-            '{0}nasa/foo2.txt'.format(S3_URL),
+            f'{S3_URL}nasa/foo.txt',
+            f'{S3_URL}nasa/foo2.txt',
         ]
         for resp in _responses:
             assert resp.request.url in expected_eps
@@ -439,8 +433,8 @@ def test_upload_dir(tmpdir, nasa_item):
                                       secret_key='b')
         tmp_path = norm_filepath(str(tmpdir))
         expected_eps = [
-            '{0}nasa{1}/dir_test/{2}'.format(S3_URL, tmp_path, 'foo.txt'),
-            '{0}nasa{1}/dir_test/{2}'.format(S3_URL, tmp_path, 'foo2.txt'),
+            f'{S3_URL}nasa{tmp_path}/dir_test/foo.txt',
+            f'{S3_URL}nasa{tmp_path}/dir_test/foo2.txt',
         ]
         for resp in _responses:
             assert resp.request.url in expected_eps
@@ -454,7 +448,7 @@ def test_upload_queue_derive(nasa_item):
         rsps.add(responses.PUT, S3_URL_RE, adding_headers=_expected_headers)
         _responses = nasa_item.upload(NASA_METADATA_PATH, access_key='a', secret_key='b')
         for resp in _responses:
-            headers = dict((k.lower(), str(v)) for k, v in resp.request.headers.items())
+            headers = {k.lower(): str(v) for k, v in resp.request.headers.items()}
             del headers['x-archive-meta00-scanner']
             assert 'user-agent' in headers
             del headers['user-agent']
@@ -506,7 +500,7 @@ def test_upload_delete(tmpdir, nasa_item):
                                 delete=True,
                                 queue_derive=True)
         for r in resp:
-            headers = dict((k.lower(), str(v)) for k, v in r.headers.items())
+            headers = {k.lower(): str(v) for k, v in r.headers.items()}
             del headers['content-type']
             assert headers == _expected_headers
             assert len(tmpdir.listdir()) == 0
@@ -532,27 +526,26 @@ def test_upload_checksum(tmpdir, nasa_item):
                                 secret_key='b',
                                 checksum=True)
         for r in resp:
-            headers = dict((k.lower(), str(v)) for k, v in r.headers.items())
+            headers = {k.lower(): str(v) for k, v in r.headers.items()}
             del headers['content-type']
             assert headers == _expected_headers
             assert r.status_code == 200
 
         # Skip.
         nasa_item.item_metadata['files'].append(
-            dict(name='checksum_test.txt',
-                 md5='33213e7683c1e6d15b2a658f3c567717'))
+            {'name': 'checksum_test.txt', 'md5': '33213e7683c1e6d15b2a658f3c567717'})
         resp = nasa_item.upload(test_file,
                                 access_key='a',
                                 secret_key='b',
                                 checksum=True)
         for r in resp:
-            headers = dict((k.lower(), str(v)) for k, v in r.headers.items())
+            headers = {k.lower(): str(v) for k, v in r.headers.items()}
             assert r.status_code is None
 
 
 def test_modify_metadata(nasa_item, nasa_metadata):
     with IaRequestsMock(assert_all_requests_are_fired=False) as rsps:
-        rsps.add(responses.POST, '{0}//archive.org/metadata/nasa'.format(PROTOCOL))
+        rsps.add(responses.POST, f'{PROTOCOL}//archive.org/metadata/nasa')
 
         # Test simple add.
         md = {'foo': 'bar'}
@@ -635,8 +628,7 @@ def test_modify_metadata(nasa_item, nasa_metadata):
         md = {'title': 'new title'}
         nasa_metadata['metadata']['title'] = 'new title'
         _item_metadata = json.dumps(nasa_metadata)
-        rsps.add(responses.GET, '{0}//archive.org/metadata/nasa'.format(PROTOCOL),
-                 body=_item_metadata)
+        rsps.add(responses.GET, f'{PROTOCOL}//archive.org/metadata/nasa', body=_item_metadata)
         nasa_item.modify_metadata(md,
                                   access_key='a',
                                   secret_key='b')
diff --git a/tests/test_session.py b/tests/test_session.py
index 8f3014d2..b09e99df 100644
--- a/tests/test_session.py
+++ b/tests/test_session.py
@@ -36,7 +36,7 @@ def test_archive_session(tmpdir):
     assert s.protocol == PROTOCOL
     assert s.access_key == 'test_access'
     assert s.secret_key == 'test_secret'
-    assert s.headers['user-agent'].startswith('internetarchive/{0}'.format(__version__))
+    assert s.headers['user-agent'].startswith(f'internetarchive/{__version__}')
 
 
 def test_get_item(tmpdir):
@@ -46,7 +46,7 @@ def test_get_item(tmpdir):
         item_metadata = fh.read().strip()
 
     with responses.RequestsMock() as rsps:
-        rsps.add(responses.GET, '{0}//archive.org/metadata/nasa'.format(PROTOCOL),
+        rsps.add(responses.GET, f'{PROTOCOL}//archive.org/metadata/nasa',
                  body=item_metadata,
                  content_type='application/json')
 
@@ -56,7 +56,7 @@ def test_get_item(tmpdir):
         assert item.identifier == 'nasa'
 
     with responses.RequestsMock() as rsps:
-        rsps.add(responses.GET, '{0}//archive.org/metadata/nasa'.format(PROTOCOL),
+        rsps.add(responses.GET, f'{PROTOCOL}//archive.org/metadata/nasa',
                  body=item_metadata,
                  status=400,
                  content_type='application/json')
@@ -88,7 +88,7 @@ def test_s3_is_overloaded():
     }"""
 
     with IaRequestsMock() as rsps:
-        rsps.add(responses.GET, '{0}//s3.us.archive.org'.format(PROTOCOL),
+        rsps.add(responses.GET, f'{PROTOCOL}//s3.us.archive.org',
                  body=test_body,
                  content_type='application/json')
         s = internetarchive.session.ArchiveSession(CONFIG)
@@ -113,7 +113,7 @@ def test_s3_is_overloaded():
     }"""
 
     with responses.RequestsMock() as rsps:
-        rsps.add(responses.GET, '{0}//s3.us.archive.org'.format(PROTOCOL),
+        rsps.add(responses.GET, f'{PROTOCOL}//s3.us.archive.org',
                  body=test_body,
                  content_type='application/json')
         s = internetarchive.session.ArchiveSession(CONFIG)
@@ -123,9 +123,9 @@ def test_s3_is_overloaded():
 
 def test_cookies():
     with responses.RequestsMock() as rsps:
-        rsps.add(responses.GET, '{0}//archive.org'.format(PROTOCOL))
+        rsps.add(responses.GET, f'{PROTOCOL}//archive.org')
         s = internetarchive.session.ArchiveSession(CONFIG)
-        r = s.get('{}//archive.org'.format(PROTOCOL))
+        r = s.get(f'{PROTOCOL}//archive.org')
         assert 'logged-in-sig' in r.request.headers['Cookie']
         assert 'logged-in-user' in r.request.headers['Cookie']
         for c in s.cookies:
@@ -133,9 +133,9 @@ def test_cookies():
                 assert c.domain == '.archive.org'
 
     with responses.RequestsMock() as rsps:
-        rsps.add(responses.GET, '{0}//example.com'.format(PROTOCOL))
+        rsps.add(responses.GET, f'{PROTOCOL}//example.com')
         s = internetarchive.session.ArchiveSession(CONFIG)
-        r = s.get('{}//example.com'.format(PROTOCOL))
+        r = s.get(f'{PROTOCOL}//example.com')
         assert 'logged-in-sig' not in r.request.headers.get('Cookie', str())
         assert 'logged-in-user' not in r.request.headers.get('Cookie', str())
         assert '.archive.org' not in r.request.headers.get('Cookie', str())
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 1bde6956..7cb5d4fe 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -4,7 +4,6 @@
 
 from tests.conftest import IaRequestsMock, NASA_METADATA_PATH
 
-import six
 import internetarchive.utils
 
 
@@ -43,16 +42,7 @@ def test_validate_s3_identifier():
 def test_get_md5():
     with open(__file__, 'rb') as fp:
         md5 = internetarchive.utils.get_md5(fp)
-    assert isinstance(md5, six.string_types)
-
-
-def test_map2x():
-    keys = ('first', 'second')
-    columns = ('first', 'second')
-    for key, value in internetarchive.utils.map2x(None, keys, columns):
-        assert key == value
-    for key, value in internetarchive.utils.map2x(lambda k, v: [k, v], keys, columns):
-        assert key == value
+    assert isinstance(md5, str)
 
 
 def test_IdentifierListAsItems(session):