diff --git a/rbtools/api/cache.py b/rbtools/api/cache.py
index 434334ee12b744f8d30331b7b52718229ef7ab9e..3dd48c2922db0bb30f261c31f96b451bf700a86c 100644
--- a/rbtools/api/cache.py
+++ b/rbtools/api/cache.py
@@ -120,8 +120,9 @@ class CacheEntry(object):
 class LiveHTTPResponse(object):
     """An uncached HTTP response that can be read() more than once.
 
-    This is intended to be API-compatible with a urllib response object. This
-    allows a response to be read more than once.
+    This is intended to be API-compatible with an
+    :py:class:`http.client.HTTPResponse` object. This allows a response to be
+    read more than once.
     """
 
     def __init__(
@@ -138,15 +139,31 @@ class LiveHTTPResponse(object):
         """
         self.headers = response.info()
         self.content = response.read()
-        self.code = response.getcode()
+        self.status = response.status
+
+    @property
+    def code(self) -> int:
+        """The HTTP response code.
+
+        Type:
+            int
+        """
+        return self.status
 
     def info(self) -> Message:
         """Return the headers associated with the response.
 
+        Deprecated:
+            4.0:
+            Deprecated in favor of the :py:attr:`headers` attribute.
+
         Returns:
             email.message.Message:
             The response headers.
         """
+        RemovedInRBTools50Warning.warn(
+            'LiveHTTPResponse.info() is deprecated and will be removed in '
+            'RBTools 5.0. Use LiveHTTPResponse.headers instead.')
         return self.headers
 
     def read(self) -> bytes:
@@ -161,11 +178,18 @@ class LiveHTTPResponse(object):
     def getcode(self) -> int:
         """Return the associated HTTP response code.
 
+        Deprecated:
+            4.0:
+            Deprecated in favor of the :py:attr:`code` attribute.
+
         Returns:
             int:
             The HTTP response code.
         """
-        return self.code
+        RemovedInRBTools50Warning.warn(
+            'LiveHTTPResponseInfo.getcode() is deprecated and will be removed '
+            'in RBTools 5.0. Use LiveHTTPResponse.code instead.')
+        return self.status
 
 
 class CachedHTTPResponse(object):
@@ -190,14 +214,31 @@ class CachedHTTPResponse(object):
         }
 
         self.content = cache_entry.response_body
+        self.status = 200
+
+    @property
+    def code(self) -> int:
+        """The HTTP response code.
+
+        Type:
+            int
+        """
+        return self.status
 
     def info(self) -> dict:
         """Return the headers associated with the response.
 
+        Deprecated:
+            4.0:
+            Deprecated in favor of the :py:attr:`headers` attribute.
+
         Returns:
             dict:
             The cached response headers.
         """
+        RemovedInRBTools50Warning.warn(
+            'CachedHTTPResponse.info() is deprecated and will be removed in '
+            'RBTools 5.0. Use CachedHTTPResponse.headers instead.')
         return self.headers
 
     def read(self) -> bytes:
@@ -212,11 +253,18 @@ class CachedHTTPResponse(object):
     def getcode(self) -> int:
         """Return the associated HTTP response code, which is always 200.
 
+        Deprecated:
+            4.0:
+            Deprecated in favor of the :py:attr:`code` attribute.
+
         Returns:
             int:
             200, always. This pretends that the response is the successful
             result of an HTTP request.
         """
+        RemovedInRBTools50Warning.warn(
+            'CachedHTTPResponseInfo.getcode() is deprecated and will be '
+            'removed in RBTools 5.0. Use CachedHTTPResponse.code instead.')
         return 200
 
 
diff --git a/rbtools/api/client.py b/rbtools/api/client.py
index dcee61ceff7d17e36864101f917f368980e91a81..d69c3636380a19b1a015d6b28487c4f9311521d7 100644
--- a/rbtools/api/client.py
+++ b/rbtools/api/client.py
@@ -1,33 +1,215 @@
-from __future__ import unicode_literals
-
-from six.moves.urllib.parse import urlparse
+from typing import Optional, Type
+from urllib.parse import urlparse
 
+from rbtools.api.resource import Resource
+from rbtools.api.transport import Transport
 from rbtools.api.transport.sync import SyncTransport
 
 
-class RBClient(object):
-    """Entry point for accessing RB resources through the web API.
+class RBClient:
+    """Main client used to talk to a Review Board server's API.
+
+    This provides methods used to authenticate with a Review Board API and
+    perform API requests.
+
+    Clients make use of a transport class for all server communication. This
+    handles all HTTP-related state and communication, and can be used to mock,
+    intercept, or alter the way in which clients talk to Review Board.
 
-    By default the synchronous transport will be used. To use a
-    different transport, provide the transport class in the
-    'transport_cls' parameter.
+    Most methods wrap methods on the transport, which may change how arguments
+    are provided and data is returned. With the default sync transport, no
+    additional arguments are provided in any ``*args`` or ``**kwargs``, and
+    results are returned directly from the methods.
     """
-    def __init__(self, url, transport_cls=SyncTransport, *args, **kwargs):
+
+    ######################
+    # Instance variables #
+    ######################
+
+    #: The domain name of the Review Board server.
+    #:
+    #: Type: str
+    domain: str
+
+    #: The URL of the Review Board server.
+    #:
+    #: Type: str
+    url: str
+
+    def __init__(
+        self,
+        url: str,
+        transport_cls: Type[Transport] = SyncTransport,
+        *args,
+        **kwargs,
+    ) -> None:
+        """Initialize the client.
+
+        Args:
+            url (str):
+                The URL of the Review Board server.
+
+            transport_cls (type, optional):
+                The type of transport to use for communicating with the server.
+
+            *args (tuple):
+                Positional arguments to pass to the transport.
+
+            **kwargs (dict):
+                Keyword arguments to pass to the transport.
+        """
         self.url = url
         self.domain = urlparse(url)[1]
         self._transport = transport_cls(url, *args, **kwargs)
 
-    def get_root(self, *args, **kwargs):
+    def get_root(
+        self,
+        *args,
+        **kwargs,
+    ) -> Optional[Resource]:
+        """Return the root resource of the API.
+
+        Args:
+            *args (tuple):
+                Positional arguments to pass to the transport's
+                :py:meth:`~rbtools.api.transport.Transport.get_root`.
+
+            **kwargs (dict):
+                Keyword arguments to pass to the transport's
+                :py:meth:`~rbtools.api.transport.Transport.get_root`.
+
+        Returns:
+            rbtools.api.resource.Resource:
+            The root API resource.
+
+        Raises:
+            rbtools.api.errors.APIError:
+                The API returned an error. Details are in the error object.
+
+            rbtools.api.errors.ServerInterfaceError:
+                There was a non-API error communicating with the Review Board
+                server. The URL may have been invalid. The reason is in the
+                exception's message.
+        """
         return self._transport.get_root(*args, **kwargs)
 
-    def get_path(self, path, *args, **kwargs):
+    def get_path(
+        self,
+        path: str,
+        *args,
+        **kwargs,
+    ) -> Optional[Resource]:
+        """Return the API resource at the given path.
+
+        Args:
+            path (str):
+                The path relative to the Review Board server URL.
+
+            *args (tuple):
+                Positional arguments to pass to the transport's
+                :py:meth:`~rbtools.api.transport.Transport.get_path`.
+
+            **kwargs (dict):
+                Keyword arguments to pass to the transport's
+                :py:meth:`~rbtools.api.transport.Transport.get_path`.
+
+        Returns:
+            rbtools.api.resource.Resource:
+            The resource at the given path.
+
+        Raises:
+            rbtools.api.errors.APIError:
+                The API returned an error. Details are in the error object.
+
+            rbtools.api.errors.ServerInterfaceError:
+                There was a non-API error communicating with the Review Board
+                server. The URL may have been invalid. The reason is in the
+                exception's message.
+        """
         return self._transport.get_path(path, *args, **kwargs)
 
-    def get_url(self, url, *args, **kwargs):
+    def get_url(
+        self,
+        url: str,
+        *args,
+        **kwargs,
+    ) -> Optional[Resource]:
+        """Return the API resource at the given URL.
+
+        Args:
+            url (str):
+                The URL of the resource to fetch.
+
+            *args (tuple):
+                Positional arguments to pass to the transport's
+                :py:meth:`~rbtools.api.transport.Transport.get_url`.
+
+            **kwargs (dict):
+                Keyword arguments to pass to the transport's
+                :py:meth:`~rbtools.api.transport.Transport.get_url`.
+
+        Returns:
+            rbtools.api.resource.Resource:
+            The resource at the given path.
+
+        Raises:
+            rbtools.api.errors.APIError:
+                The API returned an error. Details are in the error object.
+
+            rbtools.api.errors.ServerInterfaceError:
+                There was a non-API error communicating with the Review Board
+                server. The URL may have been invalid. The reason is in the
+                exception's message.
+        """
         return self._transport.get_url(url, *args, **kwargs)
 
-    def login(self, *args, **kwargs):
-        return self._transport.login(*args, **kwargs)
+    def login(self, *args, **kwargs) -> None:
+        """Log in to the Review Board server.
+
+        Args:
+            *args (tuple):
+                Positional arguments to pass to the transport's
+                :py:meth:`~rbtools.api.transport.Transport.login`.
+
+            **kwargs (dict):
+                Keyword arguments to pass to the transport's
+                :py:meth:`~rbtools.api.transport.Transport.login`.
+
+        Raises:
+            rbtools.api.errors.APIError:
+                The API returned an error. Details are in the error object.
+
+            rbtools.api.errors.ServerInterfaceError:
+                There was a non-API error communicating with the Review Board
+                server. The URL may have been invalid. The reason is in the
+                exception's message.
+        """
+        self._transport.login(*args, **kwargs)
+
+    def logout(self, *args, **kwargs) -> None:
+        """Log out from the Review Board server.
+
+        Args:
+            *args (tuple):
+                Positional arguments to pass to the transport's
+                :py:meth:`~rbtools.api.transport.Transport.logout`.
+
+            **kwargs (dict):
+                Keyword arguments to pass to the transport's
+                :py:meth:`~rbtools.api.transport.Transport.logout`.
+
+        Returns:
+            object:
+            The return value from
+            :py:meth:`~rbtools.api.transport.Transport.logout`.
+
+        Raises:
+            rbtools.api.errors.APIError:
+                The API returned an error. Details are in the error object.
 
-    def logout(self, *args, **kwargs):
-        return self._transport.logout(*args, **kwargs)
+            rbtools.api.errors.ServerInterfaceError:
+                There was a non-API error communicating with the Review Board
+                server. The URL may have been invalid. The reason is in the
+                exception's message.
+        """
+        self._transport.logout(*args, **kwargs)
diff --git a/rbtools/api/decode.py b/rbtools/api/decode.py
index 0aa1de5b79a7a70b618c3aa28a2a66750b831603..cc1e5d57bcf4b86822f1582d760169600ebb6429 100644
--- a/rbtools/api/decode.py
+++ b/rbtools/api/decode.py
@@ -1,20 +1,28 @@
-from __future__ import unicode_literals
+"""API payload decoders."""
 
 import json
+from typing import Dict, Union
 
 from rbtools.api.utils import parse_mimetype
 from rbtools.utils.encoding import force_unicode
 
 
-DECODER_MAP = {}
-
-
-def DefaultDecoder(payload):
+def DefaultDecoder(
+    payload: Union[bytes, str],
+) -> Dict:
     """Default decoder for API payloads.
 
     The default decoder is used when a decoder is not found in the
     DECODER_MAP. This will stick the body of the response into the
     'data' field.
+
+    Args:
+        payload (bytes or str):
+            The API payload.
+
+    Returns:
+        dict:
+        The decoded API object.
     """
     return {
         'resource': {
@@ -23,19 +31,34 @@ def DefaultDecoder(payload):
     }
 
 
-DEFAULT_DECODER = DefaultDecoder
+def JsonDecoder(
+    payload: Union[bytes, str],
+) -> Dict:
+    """Decode an application/json-encoded API response.
 
+    Args:
+        payload (bytes or str):
+            The API payload.
 
-def JsonDecoder(payload):
-    # In Python 3, the payload can be bytes, not str, and json.loads explicitly
-    # requires decoded strings.
+    Returns:
+        dict:
+        The decoded API object.
+    """
     return json.loads(force_unicode(payload))
 
 
-DECODER_MAP['application/json'] = JsonDecoder
+#: Mapping from API format to decoder method.
+#:
+#: Type: dict
+DECODER_MAP = {
+    'application/json': JsonDecoder,
+}
 
 
-def decode_response(payload, mime_type):
+def decode_response(
+    payload: Union[bytes, str],
+    mime_type: str,
+) -> Dict:
     """Decode a Web API response.
 
     The body of a Web API response will be decoded into a dictionary,
@@ -44,10 +67,6 @@ def decode_response(payload, mime_type):
     mime = parse_mimetype(mime_type)
 
     format = '%s/%s' % (mime['main_type'], mime['format'])
-
-    if format in DECODER_MAP:
-        decoder = DECODER_MAP[format]
-    else:
-        decoder = DEFAULT_DECODER
+    decoder = DECODER_MAP.get(format, DefaultDecoder)
 
     return decoder(payload)
diff --git a/rbtools/api/decorators.py b/rbtools/api/decorators.py
index 855a9df63eec06cfaffc2f5efbb3f7f741e4edd8..1dae8fdd83ed86ca30c64bf89f500dc0ec245c53 100644
--- a/rbtools/api/decorators.py
+++ b/rbtools/api/decorators.py
@@ -1,8 +1,5 @@
-from __future__ import unicode_literals
-
-
 def request_method_decorator(f):
-    """Wraps methods returned from a resource to capture HttpRequests.
+    """Wrap a method returned from a resource to capture HttpRequests.
 
     When a method which returns HttpRequests is called, it will
     pass the method and arguments off to the transport to be executed.
diff --git a/rbtools/api/errors.py b/rbtools/api/errors.py
index 6ab53744fea10ee5be5b1454f67500911ea847ec..a0e10bbc95be37e2d47b78da7734e62812d2f6f3 100644
--- a/rbtools/api/errors.py
+++ b/rbtools/api/errors.py
@@ -1,11 +1,9 @@
-from __future__ import unicode_literals
-
-import six
+from typing import Dict, Optional, Type
 
 from rbtools.utils.encoding import force_unicode
 
 
-HTTP_STATUS_CODES = {
+HTTP_STATUS_CODES: Dict[int, str] = {
     100: 'Continue',
     101: 'Switching Protocols',
     102: 'Processing',
@@ -79,7 +77,7 @@ HTTP_STATUS_CODES = {
 }
 
 
-API_ERROR_CODES = {
+API_ERROR_CODES: Dict[int, str] = {
     0: 'No Error',
     1: 'Service Not Configured',
     100: 'Does Not Exist',
@@ -144,7 +142,7 @@ class APIError(Exception):
         http_status (int):
             The HTTP status code.
 
-        message (unicode):
+        message (str):
             The error message from the API response. This may be ``None``.
 
             Version Added:
@@ -160,11 +158,19 @@ class APIError(Exception):
     #:     3.1
     #:
     #: Type:
-    #:     unicode
-    default_message = 'An error occurred when communicating with Review Board.'
-
-    def __init__(self, http_status=None, error_code=None, rsp=None,
-                 message=None, *args, **kwargs):
+    #:     str
+    default_message: str = \
+        'An error occurred when communicating with Review Board.'
+
+    def __init__(
+        self,
+        http_status: Optional[int] = None,
+        error_code: Optional[int] = None,
+        rsp: Optional[Dict] = None,
+        message: Optional[str] = None,
+        *args,
+        **kwargs,
+    ) -> None:
         """Initialize the error.
 
         Args:
@@ -186,7 +192,7 @@ class APIError(Exception):
                 The API response payload. This may be ``None`` for non-API
                 Error payloads.
 
-            message (unicode, optional):
+            message (str, optional):
                 A specific error message to use. This will take precedence
                 over any errors in ``rsp``.
 
@@ -212,7 +218,7 @@ class APIError(Exception):
 
         self.message = message or self.default_message
 
-    def __str__(self):
+    def __str__(self) -> str:
         """Return a string representation of the error.
 
         The explicit :py:attr:`message` passed to the constructor will be
@@ -225,7 +231,7 @@ class APIError(Exception):
         be included instead.
 
         Returns:
-            unicode:
+            str:
             The error message.
         """
         http_status = self.http_status
@@ -233,7 +239,7 @@ class APIError(Exception):
 
         details = None
 
-        if self.error_code is not None:
+        if error_code is not None:
             error_name = API_ERROR_CODES.get(error_code)
             details = 'API Error %s' % error_code
 
@@ -257,22 +263,22 @@ class APIError(Exception):
 class AuthorizationError(APIError):
     """Authorization error when communicating with the API."""
 
-    default_message = 'Error authenticating to Review Board.'
+    default_message: str = 'Error authenticating to Review Board.'
 
 
 class BadRequestError(APIError):
     """Bad request data made to an API."""
 
-    default_message = 'Missing or invalid data was sent to Review Board.'
+    default_message: str = 'Missing or invalid data was sent to Review Board.'
 
-    def __str__(self):
+    def __str__(self) -> str:
         """Return a string representation of the error.
 
         If the payload contains a list of fields, the error associated with
         each field will be included.
 
         Returns:
-            unicode:
+            str:
             The error message.
         """
         lines = [super(BadRequestError, self).__str__()]
@@ -280,7 +286,7 @@ class BadRequestError(APIError):
         if self.rsp and 'fields' in self.rsp:
             lines.append('')
 
-            for field, error in sorted(six.iteritems(self.rsp['fields']),
+            for field, error in sorted(self.rsp['fields'].items(),
                                        key=lambda pair: pair[0]):
                 lines.append('    %s: %s' % (field, '; '.join(error)))
 
@@ -292,26 +298,65 @@ class CacheError(Exception):
 
 
 class ServerInterfaceError(Exception):
-    def __init__(self, msg, *args, **kwargs):
+    """A non-API error when communicating with a server."""
+
+    def __init__(
+        self,
+        msg: str,
+        *args,
+        **kwargs,
+    ) -> None:
+        """Initialize the error.
+
+        Args:
+            msg (str):
+                The error's message.
+
+            *args (tuple):
+                Positional arguments to pass through to the base class.
+
+            **kwargs (dict):
+                Keyword arguments to pass through to the base class.
+        """
         Exception.__init__(self, *args, **kwargs)
         self.msg = msg
 
-    def __str__(self):
+    def __str__(self) -> str:
         """Return the error message as a unicode string.
 
         Returns:
-            unicode:
+            str:
             The error message as a unicode string.
         """
         return force_unicode(self.msg)
 
 
-API_ERROR_TYPE = {
+API_ERROR_TYPE: Dict[int, Type[APIError]] = {
     400: BadRequestError,
     401: AuthorizationError,
 }
 
 
-def create_api_error(http_status, *args, **kwargs):
+def create_api_error(
+    http_status: int,
+    *args,
+    **kwargs,
+) -> APIError:
+    """Create an error instance.
+
+    Args:
+        http_status (int):
+            The HTTP status code.
+
+        *args (tuple):
+            Positional arguments to pass through to the error class.
+
+        **kwargs (dict):
+            Keyword arguments to pass through to the error class.
+
+    Returns:
+        APIError:
+        The error instance.
+    """
     error_type = API_ERROR_TYPE.get(http_status, APIError)
     return error_type(http_status, *args, **kwargs)
diff --git a/rbtools/api/factory.py b/rbtools/api/factory.py
index d974c4fdf46d6e9d69e88ee21925979c4d6ebef7..2a3733afa74f174aa7844d0a21da41da7fb8e160 100644
--- a/rbtools/api/factory.py
+++ b/rbtools/api/factory.py
@@ -1,28 +1,61 @@
-from __future__ import unicode_literals
+from typing import Optional, Type
 
-from rbtools.api.resource import (CountResource, ItemResource,
-                                  ListResource, RESOURCE_MAP)
+from rbtools.api.resource import (CountResource,
+                                  ItemResource,
+                                  ListResource,
+                                  Resource,
+                                  RESOURCE_MAP)
+from rbtools.api.transport import Transport
 from rbtools.api.utils import rem_mime_format
 
 
-SPECIAL_KEYS = set(('links', 'total_results', 'stat', 'count'))
+SPECIAL_KEYS = {
+    'links',
+    'total_results',
+    'stat',
+    'count',
+}
 
 
-def create_resource(transport, payload, url, mime_type=None,
-                    item_mime_type=None, guess_token=True):
+def create_resource(
+    transport: Transport,
+    payload: dict,
+    url: str,
+    mime_type: Optional[str] = None,
+    item_mime_type: Optional[str] = None,
+    guess_token: bool = True,
+) -> Resource:
     """Construct and return a resource object.
 
-    The mime type will be used to find a resource specific base class.
-    Alternatively, if no resource specific base class exists, one of
-    the generic base classes, Resource or ResourceList, will be used.
+    Args:
+        transport (rbtools.api.transport.Transport):
+            The API transport.
 
-    If an item mime type is provided, it will be used by list
-    resources to construct item resources from the list.
+        payload (dict):
+            The payload returned from the API endpoint.
 
-    If 'guess_token' is True, we will try and guess what key the
-    resources body lives under. If False, we assume that the resource
-    body is the body of the payload itself. This is important for
-    constructing Item resources from a resource list.
+        url (str):
+            The URL of the API endpoint.
+
+        mime_type (str, optional):
+            The MIME type of the API response. This is used to find a resource
+            specific class. If no resource specific class exists, one of the
+            generic base classes (:py:class:`~rbtools.api.resource.Resource`
+            or :py:class:`~rbtools.api.resource.ResourceList`) will be used.
+
+        item_mime_type (str, optional):
+            The MIME type to use when constructing individual items within a
+            list resource.
+
+        guess_token (bool, optional):
+            Whether to guess the key for the API response body. If ``False``,
+            we assume that the resource body is the body of the payload itself.
+            This is important for constructing item resources from a resource
+            list.
+
+    Returns:
+        rbtools.api.resource.Resource:
+        The resource instance.
     """
 
     # Determine the key for the resources data.
@@ -30,9 +63,12 @@ def create_resource(transport, payload, url, mime_type=None,
 
     if guess_token:
         other_keys = set(payload.keys()).difference(SPECIAL_KEYS)
+
         if len(other_keys) == 1:
             token = other_keys.pop()
 
+    resource_class: Type[Resource]
+
     # Select the base class for the resource.
     if 'count' in payload:
         resource_class = CountResource
diff --git a/rbtools/api/request.py b/rbtools/api/request.py
index 15d86afaec523904bd8b1bdf38f64c49c493d681..e3bf92d6a015b169330eff0b1574a0cb7cde0aa4 100644
--- a/rbtools/api/request.py
+++ b/rbtools/api/request.py
@@ -1,55 +1,53 @@
-from __future__ import unicode_literals
-
 import base64
 import logging
 import mimetypes
 import os
 import random
 import shutil
+import ssl
 import sys
 from collections import OrderedDict
+from http.client import HTTPMessage, HTTPResponse, NOT_MODIFIED
+from http.cookiejar import Cookie, CookieJar, MozillaCookieJar
 from io import BytesIO
 from json import loads as json_loads
-
-import six
-from six.moves.http_client import UNAUTHORIZED, NOT_MODIFIED
-from six.moves.http_cookiejar import Cookie, CookieJar, MozillaCookieJar
-from six.moves.urllib.error import HTTPError, URLError
-from six.moves.urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
-from six.moves.urllib.request import (
+from typing import Callable, Dict, List, Optional, Tuple, Union
+from urllib.error import HTTPError, URLError
+from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
+from urllib.request import (
     BaseHandler,
     HTTPBasicAuthHandler,
     HTTPCookieProcessor,
     HTTPDigestAuthHandler,
     HTTPErrorProcessor,
     HTTPPasswordMgr,
+    HTTPSHandler,
     ProxyHandler,
     Request as URLRequest,
     build_opener,
     install_opener,
     urlopen)
 
+from typing_extensions import TypeAlias
+
 from rbtools import get_package_version
-from rbtools.api.cache import APICache
+from rbtools.api.cache import APICache, CachedHTTPResponse, LiveHTTPResponse
 from rbtools.api.errors import APIError, create_api_error, ServerInterfaceError
+from rbtools.deprecation import RemovedInRBTools50Warning
 from rbtools.utils.encoding import force_bytes, force_unicode
 from rbtools.utils.filesystem import get_home_path
 
-# Python 2.7.9+ added strict HTTPS certificate validation (finally). These APIs
-# don't exist everywhere so soft-import them.
-try:
-    import ssl
-    from six.moves.urllib.request import HTTPSHandler
-except ImportError:
-    ssl = None
-    HTTPSHandler = None
-
 
 RBTOOLS_COOKIE_FILE = '.rbtools-cookies'
 RB_COOKIE_NAME = 'rbsessionid'
 
 
-class HttpRequest(object):
+AuthCallback: TypeAlias = Callable[..., Tuple[str, str]]
+OTPCallback: TypeAlias = Callable[[str, str], str]
+QueryArgs: TypeAlias = Union[bool, int, float, bytes, str]
+
+
+class HttpRequest:
     """A high-level HTTP request.
 
     This is used to construct an HTTP request to a Review Board server.
@@ -60,63 +58,58 @@ class HttpRequest(object):
     Instances are intentionally generic and not tied to :py:mod:`urllib2`,
     providing API stability and a path toward eventually interfacing with
     other HTTP backends.
-
-    Attributes:
-        headers (dict):
-            Any HTTP headers to provide in the request.
-
-        url (unicode):
-            The URL to request.
     """
 
-    def __init__(self, url, method='GET', query_args={}, headers={}):
+    #: HTTP headers to provide when making the request
+    #:
+    #: Type: dict
+    headers: Dict[str, str]
+
+    #: The URL te request
+    #:
+    #: Type: str
+    url: str
+
+    def __init__(
+        self,
+        url: str,
+        method: str = 'GET',
+        query_args: Dict[str, QueryArgs] = {},
+        headers: Dict[str, str] = {},
+    ) -> None:
         """Initialize the HTTP request.
 
         Args:
-            url (bytes or unicode):
+            url (bytes or str):
                 The URL to request.
 
-            method (bytes or unicode, optional):
+            method (bytes or str, optional):
                 The HTTP method to send to the server.
 
             query_args (dict, optional):
                 Any query arguments to add to the URL.
 
-                All keys and values are expected to be strings (either
-                byte strings or unicode strings).
-
             headers (dict, optional):
                 Any HTTP headers to provide in the request.
-
-                All keys and values are expected to be strings (either
-                byte strings or unicode strings).
         """
-        self.method = method
+        self._method = method
+        self.headers = headers
         self._fields = OrderedDict()
         self._files = OrderedDict()
 
-        # Replace all underscores in each query argument
-        # key with dashes.
-        query_args = {
-            self.encode_url_key(key): self.encode_url_value(key, value)
-            for key, value in six.iteritems(query_args)
-        }
-
-        # Make sure headers are always in the native string type.
-        self.headers = {
-            str(key): str(value)
-            for key, value in six.iteritems(headers)
-        }
-
         # Add the query arguments to the url
-        url_parts = list(urlparse(str(url)))
-        query = dict(parse_qsl(url_parts[4]))
-        query.update(query_args)
+        url_parts = list(urlparse(url))
+        query: Dict[str, str] = dict(parse_qsl(url_parts[4]))
+        query.update({
+            # Replace all underscores in each query argument key with dashes.
+            self.encode_url_key(key): self.encode_url_value(key, value)
+            for key, value in query_args.items()
+        })
 
         url_parts[4] = urlencode(
             OrderedDict(
                 pair
-                for pair in sorted(six.iteritems(query),
+                for pair in sorted(query.items(),
                                    key=lambda pair: pair[0])
             ),
             doseq=True
@@ -124,11 +117,14 @@ class HttpRequest(object):
 
         self.url = urlunparse(url_parts)
 
-    def encode_url_key(self, key):
+    def encode_url_key(
+        self,
+        key: str,
+    ) -> str:
         """Encode the given key for inclusion in a URL.
 
         Args:
-            key (unicode):
+            key (str):
                 The key that is being encoded.
 
         Raises:
@@ -136,18 +132,23 @@ class HttpRequest(object):
                 The given key was neither a unicode string or byte string.
 
         Returns:
-            unicode:
+            str:
             The key encoded as a unicode string.
         """
         return force_unicode(key).replace('_', '-')
 
-    def encode_url_value(self, key, value):
+    def encode_url_value(
+        self,
+        key: Union[bytes, str],
+        value: QueryArgs,
+    ) -> str:
         """Encode the given value for inclusion in a URL.
 
         Args:
-            key (unicode):
+            key (str):
                 The field name for which the value is being encoded.
                 This argument is only used to generate an error message.
+
             value (object):
                 The value to be encoded.
 
@@ -156,7 +157,7 @@ class HttpRequest(object):
                 The given value could not be encoded.
 
         Returns:
-            unicode:
+            str:
             The value encoded as a unicode string.
         """
         if isinstance(value, bool):
@@ -164,9 +165,9 @@ class HttpRequest(object):
                 value = '1'
             else:
                 value = '0'
-        elif isinstance(value, six.integer_types + (float,)):
-            value = six.text_type(value)
-        elif isinstance(value, (bytes, six.text_type)):
+        elif isinstance(value, (int, float)):
+            value = str(value)
+        elif isinstance(value, (bytes, str)):
             value = force_unicode(value)
         else:
             raise ValueError(
@@ -175,78 +176,102 @@ class HttpRequest(object):
                 % (key, value, type(value).__name__)
             )
 
+        assert isinstance(value, str)
         return value
 
     @property
-    def method(self):
+    def method(self) -> str:
         """The HTTP method to send to the server."""
         return self._method
 
     @method.setter
-    def method(self, method):
+    def method(
+        self,
+        method: str,
+    ) -> None:
         """The HTTP method to send to the server.
 
         Args:
-            method (bytes or unicode):
+            method (str):
                 The HTTP method to send to the server.
         """
         self._method = str(method)
 
-    def add_field(self, name, value):
+    def add_field(
+        self,
+        name: Union[bytes, str],
+        value: Union[bytes, str],
+    ) -> None:
         """Add a form-data field for the request.
 
+        Version Changed:
+            4.0:
+            Values of types other than bytes or str are now deprecated, and
+            will be removed in 5.0.
+
         Args:
-            name (bytes or unicode):
+            name (bytes or str):
                 The name of the field.
 
-            value (bytes or unicode):
+            value (bytes or str):
                 The value to send for the field.
 
-                For backwards-compatibility, other values will be
-                converted to strings. This may turn into a warning in
-                future releases. Callers are encouraged to only send
-                strings.
+                For backwards-compatibility, other values will be converted to
+                strings. This will be removed in 5.0.
         """
-        if not isinstance(value, (bytes, six.text_type)):
+        if not isinstance(value, (bytes, str)):
+            RemovedInRBTools50Warning.warn(
+                'A value of type %s was passed to HttpRequest.add_field. In '
+                'RBTools 5.0, only values of bytes or str types will be '
+                'accepted.')
             value = str(value)
 
         self._fields[force_bytes(name)] = force_bytes(value)
 
-    def add_file(self, name, filename, content, mimetype=None):
+    def add_file(
+        self,
+        name: Union[bytes, str],
+        filename: Union[bytes, str],
+        content: Union[bytes, str],
+        mimetype: Optional[Union[bytes, str]] = None,
+    ) -> None:
         """Add an uploaded file for the request.
 
         Args:
-            name (bytes or unicode):
+            name (bytes or str):
                 The name of the field representing the file.
 
-            filename (bytes or unicode):
+            filename (bytes or str):
                 The filename.
 
-            content (bytes or unicode):
+            content (bytes or str):
                 The contents of the file.
 
-            mimetype (bytes or unicode, optional):
+            mimetype (bytes or str, optional):
                 The optional mimetype of the content. If not provided, it
                 will be guessed.
         """
-        mimetype = force_bytes(
-            mimetypes.guess_type(force_unicode(filename))[0] or
-            b'application/octet-stream')
+        if not mimetype:
+            mimetype = (
+                mimetypes.guess_type(force_unicode(filename))[0] or
+                b'application/octet-stream')
 
         self._files[force_bytes(name)] = {
             'filename': force_bytes(filename),
             'content': force_bytes(content),
-            'mimetype': mimetype,
+            'mimetype': force_bytes(mimetype),
         }
 
-    def encode_multipart_formdata(self):
-        """Encode the request into a multi-aprt form-data payload.
+    def encode_multipart_formdata(
+        self,
+    ) -> Tuple[Optional[str], Optional[bytes]]:
+        """Encode the request into a multi-part form-data payload.
 
         Returns:
             tuple:
             A tuple containing:
 
-            * The content type (:py:class:`unicode`)
+            * The content type (:py:class:`str`)
             * The form-data payload (:py:class:`bytes`)
 
             If there are no fields or files in the request, both values will
@@ -259,7 +284,7 @@ class HttpRequest(object):
         BOUNDARY = self._make_mime_boundary()
         content = BytesIO()
 
-        for key, value in six.iteritems(self._fields):
+        for key, value in self._fields.items():
             content.write(b'--%s%s' % (BOUNDARY, NEWLINE))
             content.write(b'Content-Disposition: form-data; name="%s"%s'
                           % (key, NEWLINE))
@@ -267,7 +292,7 @@ class HttpRequest(object):
             content.write(value)
             content.write(NEWLINE)
 
-        for key, file_info in six.iteritems(self._files):
+        for key, file_info in self._files.items():
             content.write(b'--%s%s' % (BOUNDARY, NEWLINE))
             content.write(b'Content-Disposition: form-data; name="%s"; ' % key)
             content.write(b'filename="%s"%s' % (file_info['filename'],
@@ -284,7 +309,7 @@ class HttpRequest(object):
 
         return content_type, content.getvalue()
 
-    def _make_mime_boundary(self):
+    def _make_mime_boundary(self) -> bytes:
         """Create a mime boundary.
 
         This exists because :py:func:`mimetools.choose_boundary` is gone in
@@ -302,36 +327,233 @@ class HttpRequest(object):
 
 class Request(URLRequest):
     """A request which contains a method attribute."""
-    def __init__(self, url, body=b'', headers={}, method='PUT'):
-        normalized_headers = {
-            str(key): str(value)
-            for key, value in six.iteritems(headers)
-        }
 
-        URLRequest.__init__(self, str(url), body, normalized_headers)
-        self.method = str(method)
+    #: The HTTP method to use.
+    #:
+    #: Type: str
+    method: str
+
+    def __init__(
+        self,
+        url: str,
+        body: Optional[bytes] = b'',
+        headers: Dict[str, str] = {},
+        method: str = 'PUT',
+    ) -> None:
+        """Initialize the request.
+
+        Args:
+            url (str):
+                The URL to make the request at.
+
+            body (bytes, optional):
+                The body to send with the request.
 
-    def get_method(self):
+            headers (dict, optional):
+                The headers to send with the request.
+
+            method (str, optional):
+                The HTTP method to use.
+        """
+        super().__init__(url, body, headers)
+        self.method = method
+
+    def get_method(self) -> str:
+        """Return the HTTP method.
+
+        Returns:
+            str:
+            The HTTP method.
+        """
         return self.method
 
 
+class ReviewBoardHTTPErrorProcessor(HTTPErrorProcessor):
+    """Processes HTTP error codes.
+
+    Python's built-in error processing understands 2XX responses as successful,
+    but processes 3XX as an error. This handler ensures that all valid
+    responses from the API are processed as such.
+    """
+
+    def http_response(self, request, response):
+        if not (200 <= response.status < 300 or
+                response.status == NOT_MODIFIED):
+            response = self.parent.error('http', request, response,
+                                         response.status, response.msg,
+                                         response.headers)
+
+        return response
+
+    https_response = http_response
+
+
+class ReviewBoardHTTPPasswordMgr(HTTPPasswordMgr):
+    """Adds HTTP authentication support for URLs."""
+
+    def __init__(
+        self,
+        reviewboard_url: str,
+        rb_user: Optional[str] = None,
+        rb_pass: Optional[str] = None,
+        api_token: Optional[str] = None,
+        auth_callback: Optional[AuthCallback] = None,
+        otp_token_callback: Optional[OTPCallback] = None,
+    ) -> None:
+        """Initialize the password manager.
+
+        Args:
+            reviewboard_url (str):
+                The URL of the Review Board server.
+
+            rb_user (str, optional):
+                The username to authenticate with.
+
+            rb_pass (str, optional):
+                The password to authenticate with.
+
+            api_token (str, optional):
+                The API token to authenticate with. If present, this takes
+                priority over the username and password.
+
+            auth_callback (callable, optional):
+                A callback to prompt the user for their username and password.
+
+            otp_token_callback (callable, optional):
+                A callback to prompt the user for their two-factor
+                authentication code.
+        """
+        super().__init__()
+        self.rb_url = reviewboard_url
+        self.rb_user = rb_user
+        self.rb_pass = rb_pass
+        self.api_token = api_token
+        self.auth_callback = auth_callback
+        self.otp_token_callback = otp_token_callback
+
+    def find_user_password(
+        self,
+        realm: str,
+        uri: str,
+    ) -> Tuple[Optional[str], Optional[str]]:
+        """Return the username and password for the given realm.
+
+        Args:
+            realm (str):
+                The HTTP Basic authentication realm.
+
+            uri (str):
+                The URI being accessed.
+
+        Returns:
+            tuple:
+            A 2-tuple containing:
+
+            Tuple:
+                0 (str):
+                    The username to use.
+
+                1 (str):
+                    The password to use.
+        """
+        if realm == 'Web API':
+            if self.auth_callback:
+                username, password = self.auth_callback(realm, uri,
+                                                        username=self.rb_user,
+                                                        password=self.rb_pass)
+                self.rb_user = username
+                self.rb_pass = password
+
+            return self.rb_user, self.rb_pass
+        else:
+            # If this is an auth request for some other domain (since HTTP
+            # handlers are globaltake), fall back to standard password
+            # management.
+            return HTTPPasswordMgr.find_user_password(self, realm, uri)
+
+    def get_otp_token(
+        self,
+        uri: str,
+        method: str,
+    ) -> Optional[str]:
+        """Return the two-factor authentication code.
+
+        Args:
+            uri (str):
+                The URI being accessed.
+
+            method (str):
+                The HTTP method being used.
+
+        Returns:
+            str:
+            The user's two-factor authentication code, if available.
+        """
+        if self.otp_token_callback:
+            return self.otp_token_callback(uri, method)
+
+        return None
+
+
 class PresetHTTPAuthHandler(BaseHandler):
     """Handler that presets the use of HTTP Basic Auth."""
+
     handler_order = 480  # After Basic auth
 
     AUTH_HEADER = 'Authorization'
 
-    def __init__(self, url, password_mgr):
+    def __init__(
+        self,
+        url: str,
+        password_mgr: ReviewBoardHTTPPasswordMgr,
+    ) -> None:
+        """Initialize the handler.
+
+        Args:
+            url (str):
+                The URL fo the Review Board server.
+
+            password_mgr (ReviewBoardHTTPPasswordMgr):
+                The password manager to use for requests.
+        """
         self.url = url
         self.password_mgr = password_mgr
         self.used = False
 
-    def reset(self, username, password):
+    def reset(
+        self,
+        username: Optional[str],
+        password: Optional[str],
+    ) -> None:
+        """Reset the stored authentication credentials.
+
+        Args:
+            username (str):
+                The username to use for authentication. If ``None``, this will
+                effectively log out the user.
+
+            passsword (str):
+                The password to use for authentication. If ``None``, this will
+                effectively log out the user.
+        """
         self.password_mgr.rb_user = username
         self.password_mgr.rb_pass = password
         self.used = False
 
-    def http_request(self, request):
+    def http_request(
+        self,
+        request: Request,
+    ) -> Request:
+        """Modify an HTTP request with authentication information.
+
+        Args:
+            request (rbtools.api.request.Request):
+                The HTTP request to make.
+
+        Returns:
+            rbtools.api.request.Request:
+            The HTTP request, with authentication headers added.
+        """
         if not self.used:
             if self.password_mgr.api_token:
                 request.add_header(self.AUTH_HEADER,
@@ -354,26 +576,6 @@ class PresetHTTPAuthHandler(BaseHandler):
     https_request = http_request
 
 
-class ReviewBoardHTTPErrorProcessor(HTTPErrorProcessor):
-    """Processes HTTP error codes.
-
-    Python's built-in error processing understands 2XX responses as successful,
-    but processes 3XX as an error. This handler ensures that all valid
-    responses from the API are processed as such.
-    """
-
-    def http_response(self, request, response):
-        if not (200 <= response.code < 300 or
-                response.code == NOT_MODIFIED):
-            response = self.parent.error('http', request, response,
-                                         response.code, response.msg,
-                                         response.info())
-
-        return response
-
-    https_response = http_response
-
-
 class ReviewBoardHTTPBasicAuthHandler(HTTPBasicAuthHandler):
     """Custom Basic Auth handler that doesn't retry excessively.
 
@@ -392,7 +594,9 @@ class ReviewBoardHTTPBasicAuthHandler(HTTPBasicAuthHandler):
     OTP_TOKEN_HEADER = 'X-ReviewBoard-OTP'
     MAX_OTP_TOKEN_ATTEMPTS = 2
 
-    def __init__(self, *args, **kwargs):
+    passwd: ReviewBoardHTTPPasswordMgr
+
+    def __init__(self, *args, **kwargs) -> None:
         """Initialize the Basic Auth handler.
 
         Args:
@@ -404,12 +608,18 @@ class ReviewBoardHTTPBasicAuthHandler(HTTPBasicAuthHandler):
         """
         HTTPBasicAuthHandler.__init__(self, *args, **kwargs)
 
-        self._tried_login = False
-        self._otp_token_method = None
-        self._otp_token_attempts = 0
-        self._last_otp_token = None
-
-    def http_error_auth_reqed(self, authreq, host, req, headers):
+        self._tried_login: bool = False
+        self._otp_token_method: Optional[str] = None
+        self._otp_token_attempts: int = 0
+        self._last_otp_token: Optional[str] = None
+
+    def http_error_auth_reqed(
+        self,
+        authreq: str,
+        host: str,
+        req: URLRequest,
+        headers: HTTPMessage,
+    ) -> None:
         """Handle an HTTP 401 Unauthorized from an API request.
 
         This will start by checking whether a two-factor authentication
@@ -419,20 +629,20 @@ class ReviewBoardHTTPBasicAuthHandler(HTTPBasicAuthHandler):
         :py:meth:`retry_http_basic_auth`.
 
         Args:
-            authreq (unicode):
+            authreq (str):
                 The authentication request type.
 
-            host (unicode):
+            host (str):
                 The URL being accessed.
 
             req (rbtools.api.request.Request):
                 The API request being made.
 
-            headers (dict):
+            headers (http.client.HTTPMessage):
                 The headers sent in the Unauthorized error response.
 
         Returns:
-            httplib.HTTPResponse:
+            http.client.HTTPResponse:
             If attempting another request, this will be the HTTP response
             from that request. This will be ``None`` if not making another
             request.
@@ -457,7 +667,12 @@ class ReviewBoardHTTPBasicAuthHandler(HTTPBasicAuthHandler):
         return HTTPBasicAuthHandler.http_error_auth_reqed(
             self, authreq, host, req, headers)
 
-    def retry_http_basic_auth(self, host, request, realm):
+    def retry_http_basic_auth(
+        self,
+        host: str,
+        request: URLRequest,
+        realm: str,
+    ) -> Optional[HTTPResponse]:
         """Attempt another HTTP Basic Auth request.
 
         This will determine if another request should be made (based on
@@ -465,18 +680,18 @@ class ReviewBoardHTTPBasicAuthHandler(HTTPBasicAuthHandler):
         another attempt.
 
         Args:
-            host (unicode):
+            host (str):
                 The URL being accessed.
 
             request (rbtools.api.request.Request):
                 The API request being made.
 
-            realm (unicode):
+            realm (str):
                 The Basic Auth realm, which will be used to look up any
                 stored passwords.
 
         Returns:
-            httplib.HTTPResponse:
+            http.client.HTTPResponse:
             If attempting another request, this will be the HTTP response
             from that request. This will be ``None`` if not making another
             request.
@@ -511,11 +726,8 @@ class ReviewBoardHTTPBasicAuthHandler(HTTPBasicAuthHandler):
         # If the response had sent a X-ReviewBoard-OTP header stating that
         # a 2FA token is required, request it from the user.
         if self._otp_token_method:
-            otp_token = (
-                self.passwd.get_otp_token(request.get_full_url(),
-                                          self._otp_token_method)
-                .encode('utf-8')
-            )
+            otp_token = self.passwd.get_otp_token(
+                request.get_full_url(), self._otp_token_method)
         else:
             otp_token = None
 
@@ -546,41 +758,9 @@ class ReviewBoardHTTPBasicAuthHandler(HTTPBasicAuthHandler):
         return self.parent.open(request, timeout=request.timeout)
 
 
-class ReviewBoardHTTPPasswordMgr(HTTPPasswordMgr):
-    """Adds HTTP authentication support for URLs."""
-
-    def __init__(self, reviewboard_url, rb_user=None, rb_pass=None,
-                 api_token=None, auth_callback=None, otp_token_callback=None):
-        HTTPPasswordMgr.__init__(self)
-        self.passwd = {}
-        self.rb_url = reviewboard_url
-        self.rb_user = rb_user
-        self.rb_pass = rb_pass
-        self.api_token = api_token
-        self.auth_callback = auth_callback
-        self.otp_token_callback = otp_token_callback
-
-    def find_user_password(self, realm, uri):
-        if realm == 'Web API':
-            if self.auth_callback:
-                username, password = self.auth_callback(realm, uri,
-                                                        username=self.rb_user,
-                                                        password=self.rb_pass)
-                self.rb_user = username
-                self.rb_pass = password
-
-            return self.rb_user, self.rb_pass
-        else:
-            # If this is an auth request for some other domain (since HTTP
-            # handlers are global), fall back to standard password management.
-            return HTTPPasswordMgr.find_user_password(self, realm, uri)
-
-    def get_otp_token(self, uri, method):
-        if self.otp_token_callback:
-            return self.otp_token_callback(uri, method)
-
-
-def create_cookie_jar(cookie_file=None):
+def create_cookie_jar(
+    cookie_file: Optional[str] = None,
+) -> Tuple[MozillaCookieJar, str]:
     """Return a cookie jar backed by cookie_file
 
     If cooie_file is not provided, we will default it. If the
@@ -589,10 +769,25 @@ def create_cookie_jar(cookie_file=None):
 
     In the case where we default cookie_file, and it does not exist,
     we will attempt to copy the .post-review-cookies.txt file.
-    """
-    home_path = get_home_path()
 
+    Args:
+        cookie_file (str, optional):
+            The filename to use for cookies.
+
+    Returns:
+        tuple:
+        A two-tuple containing:
+
+
+        Tuple:
+            0 (http.cookiejar.MozillaCookieJar):
+                The cookie jar object.
+
+            1 (str):
+                The name of the cookie file.
+    """
     if not cookie_file:
+        home_path = get_home_path()
         cookie_file = os.path.join(home_path, RBTOOLS_COOKIE_FILE)
         post_review_cookies = os.path.join(home_path,
                                            '.post-review-cookies.txt')
@@ -617,7 +812,7 @@ def create_cookie_jar(cookie_file=None):
     return MozillaCookieJar(cookie_file), cookie_file
 
 
-class ReviewBoardServer(object):
+class ReviewBoardServer:
     """Represents a Review Board server we are communicating with.
 
     Provides methods for executing HTTP requests on a Review Board
@@ -629,12 +824,104 @@ class ReviewBoardServer(object):
     return a 2-tuple of username, password. The user can be prompted
     for their credentials using this mechanism.
     """
-    def __init__(self, url, cookie_file=None, username=None, password=None,
-                 api_token=None, agent=None, session=None, disable_proxy=False,
-                 auth_callback=None, otp_token_callback=None,
-                 verify_ssl=True, save_cookies=True, ext_auth_cookies=None,
-                 ca_certs=None, client_key=None, client_cert=None,
-                 proxy_authorization=None):
+
+    ######################
+    # Instance variables #
+    ######################
+
+    #: The path to the file for storing authentication cookies.
+    #:
+    #: Type:
+    #:     str
+    cookie_file: Optional[str]
+
+    #: The cookie jar object for managing authentication cookies.
+    #:
+    #: Type:
+    #:     http.cookiejar.CookieJar
+    cookie_jar: CookieJar
+
+    _cache: Optional[APICache] = None
+
+    def __init__(
+        self,
+        url: str,
+        cookie_file: Optional[str] = None,
+        username: Optional[str] = None,
+        password: Optional[str] = None,
+        api_token: Optional[str] = None,
+        agent: Optional[str] = None,
+        session: Optional[str] = None,
+        disable_proxy: bool = False,
+        auth_callback: Optional[AuthCallback] = None,
+        otp_token_callback: Optional[OTPCallback] = None,
+        verify_ssl: bool = True,
+        save_cookies: bool = True,
+        ext_auth_cookies: Optional[str] = None,
+        ca_certs: Optional[str] = None,
+        client_key: Optional[str] = None,
+        client_cert: Optional[str] = None,
+        proxy_authorization: Optional[str] = None,
+    ) -> None:
+        """Initialize the server object.
+
+        Args:
+            url (str):
+                The URL of the Review Board server.
+
+            cookie_file (str, optional):
+                The name of the file to store authentication cookies in.
+
+            username (str, optional):
+                The username to use for authentication.
+
+            password (str, optional):
+                The password to use for authentication.
+
+            api_token (str, optional):
+                An API token to use for authentication. If present, this is
+                preferred over the username and password.
+
+            agent (str, optional):
+                A User-Agent string to use for the client. If not specified,
+                the default RBTools User-Agent will be used.
+
+            session (str, optional):
+                An ``rbsessionid`` string to use for authentication.
+
+            disable_proxy (bool, optional):
+                Whether to disable HTTP proxies.
+
+            auth_callback (callable, optional):
+                A callback method to prompt the user for a username and
+                password.
+
+            otp_callback (callable, optional):
+                A callback method to prompt the user for their two-factor
+                authentication code.
+
+            verify_ssl (bool, optional):
+                Whether to verify SSL certificates.
+
+            save_cookies (bool, optional):
+                Whether to save authentication cookies.
+
+            ext_auth_cookies (str, optional):
+                The name of a file to load additional cookies from. These will
+                be layered on top of any cookies loaded from ``cookie_file``.
+
+            ca_certs (str, optional):
+                The name of a file to load certificates from.
+
+            client_key (str, optional):
+                The key for a client certificate to load into the chain.
+
+            client_cert (str, optional):
+                A client certificate to load into the chain.
+
+            proxy_authorization (str, optional):
+                A string to use for the ``Proxy-Authorization`` header.
+        """
         if not url.endswith('/'):
             url += '/'
 
@@ -657,6 +944,7 @@ class ReviewBoardServer(object):
 
         if self.ext_auth_cookies:
             try:
+                assert isinstance(self.cookie_jar, MozillaCookieJar)
                 self.cookie_jar.load(ext_auth_cookies, ignore_expires=True)
             except IOError as e:
                 logging.critical('There was an error while loading a '
@@ -689,10 +977,11 @@ class ReviewBoardServer(object):
                 discard=False,
                 comment=None,
                 comment_url=None,
-                rest={'HttpOnly': None})
+                rest={'HttpOnly': ''})
             self.cookie_jar.set_cookie(cookie)
 
             if self.save_cookies:
+                assert isinstance(self.cookie_jar, MozillaCookieJar)
                 self.cookie_jar.save()
 
         if username:
@@ -715,7 +1004,7 @@ class ReviewBoardServer(object):
         self.preset_auth_handler = PresetHTTPAuthHandler(self.url,
                                                          password_mgr)
 
-        handlers = []
+        handlers: List[BaseHandler] = []
 
         if not verify_ssl:
             context = ssl._create_unverified_context()
@@ -756,13 +1045,25 @@ class ReviewBoardServer(object):
         self._cache = None
         self._urlopen = urlopen
 
-    def enable_cache(self, cache_location=None, in_memory=False):
+    def enable_cache(
+        self,
+        cache_location: Optional[str] = None,
+        in_memory: bool = False,
+    ) -> None:
         """Enable caching for all future HTTP requests.
 
         The cache will be created at the default location if none is provided.
 
         If the in_memory parameter is True, the cache will be created in memory
         instead of on disk. This overrides the cache_location parameter.
+
+        Args:
+            cache_location (str, optional):
+                The name of the file to use for the cache database.
+
+            in_memory (bool, optional):
+                Whether to only use in-memory caching. If ``True``, the
+                ``cache_location`` argument is ignored.
         """
         if not self._cache:
             self._cache = APICache(create_db_in_memory=in_memory,
@@ -770,28 +1071,55 @@ class ReviewBoardServer(object):
 
             self._urlopen = self._cache.make_request
 
-    def login(self, username, password):
-        """Reset the user information"""
+    def login(
+        self,
+        username: str,
+        password: str,
+    ) -> None:
+        """Log in to the Review Board server.
+
+        Args:
+            username (str):
+                The username to use to log in.
+
+            password (str):
+                The password to use to log in.
+        """
         self.preset_auth_handler.reset(username, password)
 
-    def logout(self):
-        """Logs the user out of the session."""
+    def logout(self) -> None:
+        """Log the user out of the session."""
         self.preset_auth_handler.reset(None, None)
         self.make_request(HttpRequest('%ssession/' % self.url,
                                       method='DELETE'))
         self.cookie_jar.clear(self.domain)
 
         if self.save_cookies:
+            assert isinstance(self.cookie_jar, MozillaCookieJar)
             self.cookie_jar.save()
 
-    def process_error(self, http_status, data):
-        """Processes an error, raising an APIError with the information."""
-        # In Python 3, the data can be bytes, not str, and json.loads
-        # explicitly requires decoded strings.
-        data = force_unicode(data)
+    def process_error(
+        self,
+        http_status: int,
+        data: Union[str, bytes],
+    ) -> None:
+        """Process an error, raising an APIError with the information.
+
+        Args:
+            http_status (int):
+                The HTTP status code.
+
+            data (bytes or str):
+                The data returned by the server.
+
+        Raises:
+            rbtools.api.errors.APIError:
+                The API error object.
+        """
+        data_str = force_unicode(data)
 
         try:
-            rsp = json_loads(data)
+            rsp = json_loads(data_str)
 
             assert rsp['stat'] == 'fail'
 
@@ -802,20 +1130,30 @@ class ReviewBoardServer(object):
             raise create_api_error(http_status, rsp['err']['code'], rsp,
                                    rsp['err']['msg'])
         except ValueError:
-            logging.debug('Got HTTP error: %s: %s', http_status, data)
-            raise APIError(http_status, None, None, data)
+            logging.debug('Got HTTP error: %s: %s', http_status, data_str)
+            raise APIError(http_status, None, None, data_str)
 
-    def make_request(self, request):
+    def make_request(
+        self,
+        request: HttpRequest,
+    ) -> Optional[Union[HTTPResponse, CachedHTTPResponse, LiveHTTPResponse]]:
         """Perform an http request.
 
-        The request argument should be an instance of
-        'rbtools.api.request.HttpRequest'.
+        Args:
+            request (rbtools.api.request.HttpRequest):
+                The request object.
+
+        Returns:
+            http.client.HTTPResponse:
+            The HTTP response.
         """
+        rsp = None
+
         try:
             content_type, body = request.encode_multipart_formdata()
             headers = request.headers
 
-            if body:
+            if content_type and body:
                 headers.update({
                     'Content-Type': content_type,
                     'Content-Length': str(len(body)),
@@ -832,6 +1170,7 @@ class ReviewBoardServer(object):
 
         if self.save_cookies:
             try:
+                assert isinstance(self.cookie_jar, MozillaCookieJar)
                 self.cookie_jar.save()
             except IOError:
                 pass
diff --git a/rbtools/api/tests/test_http_request.py b/rbtools/api/tests/test_http_request.py
index 885f62b22b30fb58f7ae639150eff1d9b141e2da..17fad9578923332dccdf4acf31081fa900d83c1e 100644
--- a/rbtools/api/tests/test_http_request.py
+++ b/rbtools/api/tests/test_http_request.py
@@ -29,8 +29,8 @@ class HttpRequestTests(SpyAgency, TestCase):
         request = HttpRequest(
             url='/',
             query_args={
-                b'a_b': 'c',
-                'd-e': b'f',
+                'a_b': 'c',
+                'd-e': 'f',
             })
 
         self.assertEqual(request.url, '/?a-b=c&d-e=f')
@@ -40,11 +40,11 @@ class HttpRequestTests(SpyAgency, TestCase):
         request = HttpRequest(
             url='/',
             headers={
-                b'a': 'b',
-                'c': b'd',
+                'a': 'b',
+                'c': 'd',
             })
 
-        keys = list(six.iterkeys(request.headers))
+        keys = list(request.headers.keys())
         self.assertIs(type(keys[0]), str)
         self.assertIs(type(keys[1]), str)
         self.assertIs(type(request.headers[keys[0]]), str)
@@ -166,7 +166,7 @@ class HttpRequestTests(SpyAgency, TestCase):
                 'false': '0',
             })
 
-        for key, value in six.iteritems(query_args):
+        for key, value in query_args.items():
             self.assertIsInstance(key, str)
             self.assertIsInstance(value, str)
 
diff --git a/rbtools/api/transport/__init__.py b/rbtools/api/transport/__init__.py
index 9fc085e87d24434d3cf4f7660365ae6163e3b9ad..030a56607a0ed0a13083661c7a86eb88b77927df 100644
--- a/rbtools/api/transport/__init__.py
+++ b/rbtools/api/transport/__init__.py
@@ -1,7 +1,9 @@
-from __future__ import unicode_literals
+from typing import Any, Callable, Optional
 
+from rbtools.api.resource import Resource
 
-class Transport(object):
+
+class Transport:
     """Base class for API Transport layers.
 
     An API Transport layer acts as an intermediary between the API
@@ -12,53 +14,179 @@ class Transport(object):
     classes. Specifically, this allows for both a synchronous, and an
     asynchronous implementation of the transport.
     """
-    def __init__(self, url, *args, **kwargs):
+
+    def __init__(
+        self,
+        url: str,
+        *args,
+        **kwargs,
+    ) -> None:
+        """Initialize the transport.
+
+        Args:
+            url (str):
+                The URL of the Review Board server
+
+            *args (tuple, unused):
+                Positional arguments, reserved for future expansion.
+
+            **kwargs (tuple, unused):
+                Keyword arguments, reserved for future expansion.
+        """
         self.url = url
 
-    def get_root(self, *args, **kwargs):
-        """Retrieve the root api resource."""
+    def get_root(
+        self,
+        *args,
+        **kwargs,
+    ) -> Optional[Resource]:
+        """Return the root API resource.
+
+        Args:
+            *args (tuple, unused):
+                Positional arguments (may be used by the transport
+                implementation).
+
+            **kwargs (dict, unused):
+                Keyword arguments (may be used by the transport
+                implementation).
+
+        Returns:
+            rbtools.api.resource.Resource:
+            The root API resource.
+        """
         raise NotImplementedError
 
-    def get_path(self, path, *args, **kwargs):
-        """Retrieve the api resource at the provided path."""
+    def get_path(
+        self,
+        path: str,
+        *args,
+        **kwargs,
+    ) -> Optional[Resource]:
+        """Return the API resource at the provided path.
+
+        Args:
+            path (str):
+                The path to the API resource.
+
+            *args (tuple, unused):
+                Positional arguments (may be used by the transport
+                implementation).
+
+            **kwargs (dict, unused):
+                Keyword arguments (may be used by the transport
+                implementation).
+
+        Returns:
+            rbtools.api.resource.Resource:
+            The resource at the given path.
+        """
         raise NotImplementedError
 
-    def get_url(self, url, *args, **kwargs):
-        """Retrieve the resource at the provided URL.
+    def get_url(
+        self,
+        url: str,
+        *args,
+        **kwargs,
+    ) -> Optional[Resource]:
+        """Return the API resource at the provided URL.
 
         The URL is not guaranteed to be part of the configured Review
         Board domain.
-        """
-        raise NotImplementedError
 
-    def login(self, username, password, *args, **kwargs):
-        """Reset login information to be populated on next request.
+        Args:
+            url (str):
+                The URL to the API resource.
+
+            *args (tuple, unused):
+                Positional arguments (may be used by the transport
+                implementation).
 
-        The transport should override this method and provide a way
-        to reset the username and password which will be populated
-        in the next request.
+            **kwargs (dict, unused):
+                Keyword arguments (may be used by the transport
+                implementation).
+
+        Returns:
+            rbtools.api.resource.Resource:
+            The resource at the given path.
         """
         raise NotImplementedError
 
-    def logout(self):
-        """Logs out of a Review Board session on the server.
+    def login(
+        self,
+        username: str,
+        password: str,
+        *args,
+        **kwargs,
+    ) -> None:
+        """Log in to the Review Board server.
+
+        Args:
+            username (str):
+                The username to log in with.
 
-        The transport should override this method and provide a way
-        to reset the username and password which will be populated
-        in the next request.
+            password (str):
+                The password to log in with.
+
+            *args (tuple, unused):
+                Positional arguments (may be used by the transport
+                implementation).
+
+            **kwargs (dict, unused):
+                Keyword arguments (may be used by the transport
+                implementation).
         """
         raise NotImplementedError
 
-    def execute_request_method(self, method, *args, **kwargs):
-        """Execute a method and carry out the returned HttpRequest."""
+    def logout(self) -> None:
+        """Log out of a session on the Review Board server."""
+        raise NotImplementedError
+
+    def execute_request_method(
+        self,
+        method: Callable,
+        *args,
+        **kwargs,
+    ) -> Any:
+        """Execute a method and carry out the returned HttpRequest.
+
+        Args:
+            method (callable):
+                The method to run.
+
+            *args (tuple):
+                Positional arguments to pass to the method.
+
+            **kwargs (dict):
+                Keyword arguments to pass to the method.
+
+        Returns:
+            rbtools.api.resource.Resource or object:
+            If the method returns an HttpRequest, this will construct a
+            resource from that. If it returns another value, that value will be
+            returned directly.
+        """
         return method(*args, **kwargs)
 
-    def enable_cache(self, cache_location=None, in_memory=False):
+    def enable_cache(
+        self,
+        cache_location: Optional[str] = None,
+        in_memory: bool = False,
+    ) -> None:
         """Enable caching for all future HTTP requests.
 
         The cache will be created at the default location if none is provided.
 
         If the in_memory parameter is True, the cache will be created in memory
         instead of on disk. This overrides the cache_location parameter.
+
+        Args:
+            cache_location (str, optional):
+                The filename to store the cache in, if using a persistent
+                cache.
+
+            in_memory (bool, optional):
+                Whether to keep the cache data in memory rather than persisting
+                to a file.
         """
         raise NotImplementedError
diff --git a/rbtools/api/transport/sync.py b/rbtools/api/transport/sync.py
index 82c50914d6fffcdf1076cc4d08fd4ea79cae98f0..643b7d2c5e0401120a04c8cbddb026c34dbef2d2 100644
--- a/rbtools/api/transport/sync.py
+++ b/rbtools/api/transport/sync.py
@@ -1,13 +1,19 @@
-from __future__ import unicode_literals
-
 import logging
+from typing import Any, Callable, Optional
 
 from rbtools.api.decode import decode_response
 from rbtools.api.factory import create_resource
-from rbtools.api.request import HttpRequest, ReviewBoardServer
+from rbtools.api.request import (AuthCallback,
+                                 HttpRequest,
+                                 OTPCallback,
+                                 ReviewBoardServer)
+from rbtools.api.resource import Resource
 from rbtools.api.transport import Transport
 
 
+logger = logging.getLogger(__name__)
+
+
 class SyncTransport(Transport):
     """A synchronous transport layer for the API client.
 
@@ -21,15 +27,109 @@ class SyncTransport(Transport):
     The optional session can be used to specify an 'rbsessionid'
     to use when authenticating with reviewboard.
     """
-    def __init__(self, url, cookie_file=None, username=None, password=None,
-                 api_token=None, agent=None, session=None, disable_proxy=False,
-                 auth_callback=None, otp_token_callback=None,
-                 verify_ssl=True, allow_caching=True,
-                 cache_location=None, in_memory_cache=False,
-                 save_cookies=True, ext_auth_cookies=None,
-                 ca_certs=None, client_key=None, client_cert=None,
-                 proxy_authorization=None, *args, **kwargs):
-        super(SyncTransport, self).__init__(url, *args, **kwargs)
+    def __init__(
+        self,
+        url: str,
+        cookie_file: Optional[str] = None,
+        username: Optional[str] = None,
+        password: Optional[str] = None,
+        api_token: Optional[str] = None,
+        agent: Optional[str] = None,
+        session: Optional[str] = None,
+        disable_proxy: bool = False,
+        auth_callback: Optional[AuthCallback] = None,
+        otp_token_callback: Optional[OTPCallback] = None,
+        verify_ssl: bool = True,
+        allow_caching: bool = True,
+        cache_location: Optional[str] = None,
+        in_memory_cache: bool = False,
+        save_cookies: bool = True,
+        ext_auth_cookies: Optional[str] = None,
+        ca_certs: Optional[str] = None,
+        client_key: Optional[str] = None,
+        client_cert: Optional[str] = None,
+        proxy_authorization: Optional[str] = None,
+        *args,
+        **kwargs,
+    ) -> None:
+        """Initialize the transport.
+
+        Args:
+            url (str):
+                The URL of the Review Board server.
+
+            cookie_file (str, optional):
+                The name of the file to store authentication cookies in.
+
+            username (str, optional):
+                The username to use for authentication.
+
+            password (str, optional):
+                The password to use for authentication.
+
+            api_token (str, optional):
+                An API token to use for authentication. If present, this is
+                preferred over the username and password.
+
+            agent (str, optional):
+                A User-Agent string to use for the client. If not specified,
+                the default RBTools User-Agent will be used.
+
+            session (str, optional):
+                An ``rbsessionid`` string to use for authentication.
+
+            disable_proxy (bool):
+                Whether to disable HTTP proxies.
+
+            auth_callback (callable, optional):
+                A callback method to prompt the user for a username and
+                password.
+
+            otp_token_callback (callable, optional):
+                A callback method to prompt the user for their two-factor
+                authentication code.
+
+            verify_ssl (bool, optional):
+                Whether to verify SSL certificates.
+
+            allow_caching (bool, optional):
+                Whether to cache the result of HTTP requests.
+
+            cache_location (str, optional):
+                The filename to store the cache in, if using a persistent
+                cache.
+
+            in_memory_cache (bool, optional):
+                Whether to keep the cache data in memory rather than persisting
+                to a file.
+
+            save_cookies (bool, optional):
+                Whether to save authentication cookies.
+
+            ext_auth_cookies (str, optional):
+                The name of a file to load additional cookies from. These will
+                be layered on top of any cookies loaded from ``cookie_file``.
+
+            ca_certs (str, optional):
+                The name of a file to load certificates from.
+
+            client_key (str, optional):
+                The key for a client certificate to load into the chain.
+
+            client_cert (str, optional):
+                A client certificate to load into the chain.
+
+            proxy_authorization (str, optional):
+                A string to use for the ``Proxy-Authorization`` header.
+
+            *args (tuple):
+                Positional arguments to pass to the base class.
+
+            **kwargs (dict):
+                Keyword arguments to pass to the base class.
+        """
+        super().__init__(url, *args, **kwargs)
+
         self.allow_caching = allow_caching
         self.cache_location = cache_location
         self.in_memory_cache = in_memory_cache
@@ -51,10 +151,50 @@ class SyncTransport(Transport):
             client_cert=client_cert,
             proxy_authorization=proxy_authorization)
 
-    def get_root(self):
+    def get_root(
+        self,
+        *args,
+        **kwargs,
+    ) -> Optional[Resource]:
+        """Return the root API resource.
+
+        Args:
+            *args (tuple, unused):
+                Positional arguments (may be used by the transport
+                implementation).
+
+            **kwargs (dict, unused):
+                Keyword arguments (may be used by the transport
+                implementation).
+
+        Returns:
+            rbtools.api.resource.Resource:
+            The root API resource.
+        """
         return self._execute_request(HttpRequest(self.server.url))
 
-    def get_path(self, path, *args, **kwargs):
+    def get_path(
+        self,
+        path: str,
+        *args,
+        **kwargs,
+    ) -> Optional[Resource]:
+        """Return the API resource at the provided path.
+
+        Args:
+            path (str):
+                The path to the API resource.
+
+            *args (tuple, unused):
+                Additional positional arguments.
+
+            **kwargs (dict, unused):
+                Additional keyword arguments.
+
+        Returns:
+            rbtools.api.resource.Resource:
+            The resource at the given path.
+        """
         if not path.endswith('/'):
             path = path + '/'
 
@@ -64,19 +204,85 @@ class SyncTransport(Transport):
         return self._execute_request(
             HttpRequest(self.server.url + path, query_args=kwargs))
 
-    def get_url(self, url, *args, **kwargs):
+    def get_url(
+        self,
+        url: str,
+        *args,
+        **kwargs,
+    ) -> Optional[Resource]:
+        """Return the API resource at the provided URL.
+
+        Args:
+            url (str):
+                The URL to the API resource.
+
+            *args (tuple, unused):
+                Additional positional arguments.
+
+            **kwargs (dict, unused):
+                Additional keyword arguments.
+
+        Returns:
+            rbtools.api.resource.Resource:
+            The resource at the given path.
+        """
         if not url.endswith('/'):
             url = url + '/'
 
         return self._execute_request(HttpRequest(url, query_args=kwargs))
 
-    def login(self, username, password):
+    def login(
+        self,
+        username: str,
+        password: str,
+        *args,
+        **kwargs,
+    ) -> None:
+        """Log in to the Review Board server.
+
+        Args:
+            username (str):
+                The username to log in with.
+
+            password (str):
+                The password to log in with.
+
+            *args (tuple, unused):
+                Unused positional arguments.
+
+            **kwargs (dict, unused):
+                Unused keyword arguments.
+        """
         self.server.login(username, password)
 
     def logout(self):
+        """Log out of a session on the Review Board server."""
         self.server.logout()
 
-    def execute_request_method(self, method, *args, **kwargs):
+    def execute_request_method(
+        self,
+        method: Callable,
+        *args,
+        **kwargs,
+    ) -> Any:
+        """Execute a method and return the resulting resource.
+
+        Args:
+            method (callable):
+                The method to run.
+
+            *s (tuple):
+                Positional arguments to pass to the method.
+
+            **kwargs (dict):
+                Keyword arguments to pass to the method.
+
+        Returns:
+            rbtools.api.resource.Resource or object:
+            If the method returns an HttpRequest, this will construct a
+            resource from that. If it returns another value, that value will be
+            returned directly.
+        """
         request = method(*args, **kwargs)
 
         if isinstance(request, HttpRequest):
@@ -84,12 +290,26 @@ class SyncTransport(Transport):
 
         return request
 
-    def _execute_request(self, request):
-        """Execute an HTTPRequest and construct a resource from the payload"""
-        logging.debug('Making HTTP %s request to %s',
-                      request.method, request.url)
+    def _execute_request(
+        self,
+        request: HttpRequest,
+    ) -> Optional[Resource]:
+        """Execute an HTTPRequest and construct a resource from the payload.
+
+        Args:
+            request (rbtools.api.request.HttpRequest):
+                The HTTP request.
+
+        Returns:
+            rbtools.api.resource.Resource:
+            The resource object, if available.
+        """
+        logger.debug('Making HTTP %s request to %s',
+                     request.method, request.url)
 
         rsp = self.server.make_request(request)
+        assert rsp is not None
+
         info = rsp.info()
         mime_type = info['Content-Type']
         item_content_type = info.get('Item-Content-Type', None)
@@ -98,26 +318,44 @@ class SyncTransport(Transport):
             # DELETE calls don't return any data. Everything else should.
             return None
         else:
-            payload = rsp.read()
-            payload = decode_response(payload, mime_type)
+            payload = decode_response(rsp.read(), mime_type)
 
             return create_resource(self, payload, request.url,
                                    mime_type=mime_type,
                                    item_mime_type=item_content_type)
 
-    def enable_cache(self):
+    def enable_cache(
+        self,
+        cache_location: Optional[str] = None,
+        in_memory: bool = False,
+    ) -> None:
         """Enable caching for all future HTTP requests.
 
         The cache will be created at the default location if none is provided.
 
-        If the in_memory parameter is True, the cache will be created in memory
-        instead of on disk. This overrides the cache_location parameter.
+        Args:
+            cache_location (str, optional):
+                The filename to store the cache in, if using a persistent
+                cache.
+
+            in_memory (bool, optional):
+                Whether to keep the cache data in memory rather than persisting
+                to a file.
         """
         if self.allow_caching:
-            self.server.enable_cache(cache_location=self.cache_location,
-                                     in_memory=self.in_memory_cache)
+            cache_location = cache_location or self.cache_location
+            in_memory = in_memory or self.in_memory_cache
 
-    def __repr__(self):
+            self.server.enable_cache(cache_location=cache_location,
+                                     in_memory=in_memory)
+
+    def __repr__(self) -> str:
+        """Return a string representation of the object.
+
+        Returns:
+            str:
+            A string representation of the object.
+        """
         return '<%s(url=%r, cookie_file=%r, agent=%r)>' % (
             self.__class__.__name__,
             self.url,
diff --git a/rbtools/api/utils.py b/rbtools/api/utils.py
index caebb58f6610b87ac1cd74adad4fa660618ee676..d94c01556b7e80edaa8610cce16ecf9c16e85459 100644
--- a/rbtools/api/utils.py
+++ b/rbtools/api/utils.py
@@ -1,38 +1,114 @@
-from __future__ import unicode_literals
+"""Utilities used by the API interfaces."""
 
+from typing import TypedDict
 
-def parse_mimetype(mime_type):
-    """Parse the mime type in to it's component parts."""
-    types = mime_type.split(';')[0].split('/')
 
-    ret_val = {
-        'type': mime_type,
-        'main_type': types[0],
-        'sub_type': types[1]
-    }
+class ParsedMIMEType(TypedDict):
+    """A MIME type, parsed into its component parts.
+
+    Version Added:
+        4.0
+    """
+
+    #: The full MIME type.
+    #:
+    #: Type:
+    #:     str
+    type: str
+
+    #: Main type (For example, "application" for "application/octet-stream")
+    #:
+    #: Type:
+    #:     str
+    main_type: str
+
+    #: Sub-type (for example, "plain" for "text/plain").
+    #:
+    #: Type:
+    #:     str
+    sub_type: str
+
+    #: The vendor tag, if available.
+    #:
+    #: For example, "vnd.reviewboard.org.test" in
+    #: "application/vnd.reviewboard.org.test+json".
+    #:
+    #: Type:
+    #:     str
+    vendor: str
+
+    #: The sub-type format, if available.
+    #:
+    #: For example, "json" in "application/vnd.reviewboard.org.test+json".
+    #:
+    #: Type:
+    #:     str
+    format: str
+
+    #: The particular API resource name, if available.
+    #:
+    #: For example, "test" in "application/vnd.reviewboard.org.test+json".
+    #:
+    #: Type:
+    #:     str
+    resource: str
+
+
+def parse_mimetype(
+    mime_type: str,
+) -> ParsedMIMEType:
+    """Parse a mime type into its component parts.
+
+    Args:
+        mime_type (str):
+            The MIME type to parse.
+
+    Returns:
+        ParsedMIMEType:
+        The type, parsed into its component parts.
+    """
+    types = mime_type.split(';')[0].split('/')
 
     sub_type = types[1].split('+')
-    ret_val['vendor'] = ''
+
     if len(sub_type) == 1:
-        ret_val['format'] = sub_type[0]
+        vendor = ''
+        format = sub_type[0]
     else:
-        ret_val['format'] = sub_type[1]
-        ret_val['vendor'] = sub_type[0]
+        vendor = sub_type[0]
+        format = sub_type[1]
+
+    vendor_parts = vendor.split('.')
 
-    vendor = ret_val['vendor'].split('.')
-    if len(vendor) > 1:
-        ret_val['resource'] = vendor[-1].replace('-', '_')
+    if len(vendor_parts) > 1:
+        resource = vendor_parts[-1].replace('-', '_')
     else:
-        ret_val['resource'] = ''
+        resource = ''
 
-    return ret_val
+    return ParsedMIMEType(
+        type=mime_type,
+        main_type=types[0],
+        sub_type=types[0],
+        vendor=vendor,
+        format=format,
+        resource=resource)
 
 
-def rem_mime_format(mime_type):
+def rem_mime_format(
+    mime_type: str,
+) -> str:
     """Strip the subtype from a mimetype, leaving vendor specific information.
 
     Removes the portion of the subtype after a +, or the entire
     subtype if no vendor specific type information is present.
+
+    Args:
+        mime_type (str):
+            The MIME type string to modify.
+
+    Returns:
+        str:
+        The MIME type less any subtypes.
     """
     if mime_type.rfind('+') != 0:
         return mime_type.rsplit('+', 1)[0]
