Repository: fedora-infra/bodhi

Issue:
"bodhi updates request revoke" does not work properly.
[Vít Ondruch reported this issue](https://bugzilla.redhat.com/show_bug.cgi?id=1386173):

```
Description of problem:

$ bodhi updates request revoke rubygem-httparty-0.14.0-1.fc25
Password: 
Traceback (most recent call last):
  File "/usr/bin/bodhi", line 11, in <module>
    load_entry_point('bodhi-client==2.2.4', 'console_scripts', 'bodhi')()
  File "/usr/lib/python2.7/site-packages/click/core.py", line 716, in __call__
    return self.main(*args, **kwargs)
  File "/usr/lib/python2.7/site-packages/click/core.py", line 696, in main
    rv = self.invoke(ctx)
  File "/usr/lib/python2.7/site-packages/click/core.py", line 1060, in invoke
    return _process_result(sub_ctx.command.invoke(sub_ctx))
  File "/usr/lib/python2.7/site-packages/click/core.py", line 1060, in invoke
    return _process_result(sub_ctx.command.invoke(sub_ctx))
  File "/usr/lib/python2.7/site-packages/click/core.py", line 889, in invoke
    return ctx.invoke(self.callback, **ctx.params)
  File "/usr/lib/python2.7/site-packages/click/core.py", line 534, in invoke
    return callback(*args, **kwargs)
  File "/usr/lib/python2.7/site-packages/bodhi/client/__init__.py", line 135, in request
    resp = client.request(update, state)
  File "/usr/lib/python2.7/site-packages/fedora/client/bodhi.py", line 82, in wrapper
    result = method(*args, **kwargs)
  File "/usr/lib/python2.7/site-packages/fedora/client/bodhi.py", line 175, in request
    'csrf_token': self.csrf()})
  File "/usr/lib/python2.7/site-packages/fedora/client/openidbaseclient.py", line 254, in send_request
    'output': to_bytes(output.text),
fedora.client.ServerError: ServerError(https://bodhi.fedoraproject.org/updates/revoke/request, 404, Error returned from json module while processing https://bodhi.fedoraproject.org/updates/revoke/request: Expecting value: line 6 column 1 (char 5)





<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="description" content="management of Fedora Project updates" />
    <meta name="author" content="Luke Macken" />
    <meta name="author" content="Ralph Bean" />

    <!-- Only allow websockets connections to fedoraproject.org. -->
    <meta http-equiv="Content-Security-Policy" content="connect-src https://*.fedoraproject.org/ wss://hub.fedoraproject.org:9939/">

    <link rel="shortcut icon" href="https://bodhi.fedoraproject.org/static/ico/favicon.ico">

    <base href="https://bodhi.fedoraproject.org/"/>
        <title>Fedora Updates System</title>
    <link href="https://bodhi.fedoraproject.org/static/bootstrap/css/bootstrap.css" rel="stylesheet" />
    <link href="https://bodhi.fedoraproject.org/static/font-awesome-4.4.0/css/font-awesome.min.css" rel="stylesheet" />
    <link href="https://bodhi.fedoraproject.org/static/css/text.css" rel="stylesheet" />
    <link href="https://bodhi.fedoraproject.org/static/css/navbar.css" rel="stylesheet" />
    <link href="https://bodhi.fedoraproject.org/static/css/footer.css" rel="stylesheet" />
    <link href="https://bodhi.fedoraproject.org/static/css/site.css" rel="stylesheet" />
    <link href="https://bodhi.fedoraproject.org/static/css/datagrepper-feed.css" rel="stylesheet" />
    <link href="https://bodhi.fedoraproject.org/static/css/panel.css" rel="stylesheet" />
    <script src="https://bodhi.fedoraproject.org/static/js/jquery-1.10.2.min.js"></script>
    <script src="https://bodhi.fedoraproject.org/static/js/Chart-0.2.0.min.js"></script>
  </head>

  <body>
    <div id="wrap">
      <!-- Static navbar -->
      <div class="navbar navbar-default navbar-fixed-top">
        <div class="container">
          <div class="navbar-header">
            <button type="button" class="navbar-toggle" data-toggle="collapse"
                    data-target=".navbar-collapse">
              <span class="icon-bar"></span>
              <span class="icon-bar"></span>
              <span class="icon-bar"></span>
            </button>
            <a class="navbar-brand" href="https://bodhi.fedoraproject.org/">
              Fedora Update System
            </a>
          </div>

          <div class="navbar-collapse collapse">
            <form id="search" role="search" class="nav navbar-form navbar-left">
              <div id="bloodhound" class="form-group">
                <input class="typeahead form-control" name="term" type="text" placeholder="Search...">
              </div>
            </form>

            <div class="clearfix hidden-md hidden-lg"></div>

            <ul class="nav navbar-nav navbar-right">
              <li class="dropdown">
                <a class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
                  <span class="glyphicon glyphicon-plus"></span>
                  Create
                </a>
                <ul class="dropdown-menu">
                  <li><a href="https://bodhi.fedoraproject.org/updates/new">
                      New Update
                  </a></li>
                  <li><a href="https://bodhi.fedoraproject.org/overrides/new">
                      New Override
                  </a></li>
                </ul>
              </li>

              <li class="dropdown">
                <a class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
                  <span class="glyphicon glyphicon-tags"></span>
                  Releases
                </a>
                <ul class="dropdown-menu">
                  <li>
                    <a href="https://bodhi.fedoraproject.org/metrics">
                      Overall Metrics
                    </a>
                  </li>
                  <li>
                    <a href="https://bodhi.fedoraproject.org/masher">
                      Masher Status
                    </a>
                  </li>
                  <li role="separator" class="divider"></li>
                  <li>
                    <a href="https://bodhi.fedoraproject.org/releases/F24">
                      Fedora 24
                    </a>
                  </li>
                  <li>
                    <a href="https://bodhi.fedoraproject.org/releases/F23">
                      Fedora 23
                    </a>
                  </li>
                  <li>
                    <a href="https://bodhi.fedoraproject.org/releases/EPEL-7">
                      Fedora EPEL 7
                    </a>
                  </li>
                  <li>
                    <a href="https://bodhi.fedoraproject.org/releases/EL-6">
                      Fedora EPEL 6
                    </a>
                  </li>
                  <li>
                    <a href="https://bodhi.fedoraproject.org/releases/EL-5">
                      Fedora EPEL 5
                    </a>
                  </li>
                  <li role="separator" class="divider"></li>
                  <li>
                    <a href="https://bodhi.fedoraproject.org/releases/F22">
                      Fedora 22
                    </a>
                  </li>
                  <li>
                    <a href="https://bodhi.fedoraproject.org/releases/F21">
                      Fedora 21
                    </a>
                  </li>
                  <li role="separator" class="divider"></li>
                  <li>
                    <a href="https://bodhi.fedoraproject.org/releases/F25">
                      Fedora 25
                    </a>
                  </li>
                </ul>
              </li>

              <li class="dropdown">
                <a class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
                  <span class="glyphicon glyphicon-user"></span>
                  Profile
                </a>
                <ul class="dropdown-menu">

                  <li>
                    <a href="https://bodhi.fedoraproject.org/users/vondruch">
                      Your Profile
                    </a>
                  </li>
                  <li>
                    <a href="https://apps.fedoraproject.org/notifications/vondruch.id.fedoraproject.org/">
                      Manage Alerts
                    </a>
                  </li>
                  <li>
                    <form id="popup_toggle" action="https://bodhi.fedoraproject.org/popup_toggle" method="POST">
                      <input type="hidden" name="next" value="https://bodhi.fedoraproject.org/updates/revoke/request">
                    </form>
                    <a href="javascript:$('#popup_toggle').submit();">
                      Disable popups
                    </a>
                  </li>
                </ul>
              </li>


              <li>
                <a href="https://bodhi.fedoraproject.org/logout">
                  <span class="glyphicon glyphicon-log-out"></span>
                  Logout</a>
              </li>
            </ul>
          </div>
        </div><!--/.nav-collapse -->
      </div>

      <div class="container">


<div class="row">
  <div class="col-md-offset-3 col-md-6">
    <div class="panel error-page-panel">
      <h1>404 <small>Not Found</small></h1>
      <p class="lead">"rubygem-httparty-0.14.0-1.fc25" is not one of unpush, testing, revoke, obsolete, stable</p>
      <p class="lead">Invalid update id</p>
      <p class="lead">ACL validation mechanism was unable to determine ACLs.</p>
    </div>
  </div>
</div>

      </div>

      <div id="ghost-cabbage">
        <img src="https://bodhi.fedoraproject.org/static/img/logo-large.png"/>
      </div>
    </div> <!-- /#wrap -->

    <div id="footer">
      <div class="container">
        <p class="text-muted text-center"> Running
          <code>bodhi-2.2.4</code> on
          <code>bodhi04.phx2.fedoraproject.org</code>.
        <a href="https://github.com/fedora-infra/bodhi">
        bodhi<span class="glyphicon glyphicon-link"></span></a> is Free Software.
        Please <a href="https://github.com/fedora-infra/bodhi/issues">
        file issues<span class="glyphicon glyphicon-link"></span></a>
        if you have any problems.  Copyright &copy; 2007-2015 Red Hat, Inc. and
        <a href="https://github.com/fedora-infra/bodhi/graphs/contributors">
        others<span class="glyphicon glyphicon-link"></span></a>.
        </p>
      </div>
    </div>

    <!-- Placed at the end of the document so the pages load faster -->
    <script src="https://bodhi.fedoraproject.org/static/jquery.flot.js"></script>
    <script src="https://bodhi.fedoraproject.org/static/jquery.flot.stack.js"></script>
    <script src="https://bodhi.fedoraproject.org/static/bootstrap/js/bootstrap.min.js"></script>
    <script src="https://bodhi.fedoraproject.org/static/moment/moment.min.js"></script>
    <script src="https://bodhi.fedoraproject.org/static/js/cabbage.js"></script>
    <script src="https://bodhi.fedoraproject.org/static/js/forms.js"></script>
    <script src="https://bodhi.fedoraproject.org/static/js/site.js"></script>
    <script src="https://bodhi.fedoraproject.org/static/js/live.js"></script>
    <script src="https://bodhi.fedoraproject.org/static/messenger/js/messenger.min.js"></script>
    <script src="https://bodhi.fedoraproject.org/static/messenger/js/messenger-theme-flat.js"></script>
    <link href="https://bodhi.fedoraproject.org/static/messenger/css/messenger.css" rel="stylesheet" />
    <link href="https://bodhi.fedoraproject.org/static/messenger/css/messenger-theme-flat.css" rel="stylesheet" />
    <script src="https://bodhi.fedoraproject.org/static/js/typeahead.bundle.js"></script>
    <script src="https://bodhi.fedoraproject.org/static/js/search.js"></script>
    <script src="https://bodhi.fedoraproject.org/static/js/konami.js"></script>

    <script src="https://apps.fedoraproject.org/fedmenu/js/fedora-libravatar.js"></script>
    <script src="https://apps.fedoraproject.org/fedmenu/js/fedmenu.js"></script>
    <script>
      fedmenu({
          'url': 'https://apps.fedoraproject.org/js/data.js',
          'mimeType': 'application/javascript',
          'position': 'bottom-right',
      });
    </script>

  </body>
</html>
)



Version-Release number of selected component (if applicable):
$ rpm -q bodhi-client
bodhi-client-2.2.4-1.fc26.noarch


How reproducible:
Always


Steps to Reproduce:
1.
2.
3.

Actual results:


Expected results:


Additional info:
I discovered this trying to reproduce bug 1337548 using bodhi-client 2.x
```



Discussion:
Vit further comented:

> I'd say there is several issues in here:
> 
> 1) The server is not returning JSON where it is expected.
> 2) The error itself is weird:
> 
>       <p class="lead">"rubygem-httparty-0.14.0-1.fc25" is not one of unpush, testing, revoke, obsolete, stable</p>
>       <p class="lead">Invalid update id</p>
>       <p class="lead">ACL validation mechanism was unable to determine ACLs.</p>
> 
>    Trying the same withupdate ID "FEDORA-2016-e4fe08f108" fails similarly
> 
> 3) There should be not tracebacks, no HTML output, but some clear error message appropriate for
> CLI.
I learned that the order of the arguments used in the bug report is backwards - you have to specify the update id and then the state you wish the update to be moved to. Thus instead of:

```
$ bodhi updates request revoke rubygem-httparty-0.14.0-1.fc25
```

it should be:

```
$ bodhi updates request rubygem-httparty-0.14.0-1.fc25 revoke
```

The server was sending a 404 since it could not find an update with id "revoke". However, the CLI should have made this clearer for the user when reporting the error so I will see what I can do about fixing the error reporting.

Resolving patch:
diff --git a/bodhi/client/__init__.py b/bodhi/client/__init__.py
--- a/bodhi/client/__init__.py
+++ b/bodhi/client/__init__.py
@@ -20,8 +20,7 @@
 
 import click
 
-from fedora.client import BodhiClientException
-from fedora.client.bodhi import Bodhi2Client
+from bodhi.client import bindings
 
 
 @click.group()
@@ -57,7 +56,7 @@ def updates():
 @click.option('--staging', help='Use the staging bodhi instance',
               is_flag=True, default=False)
 def new(user, password, **kwargs):
-    client = Bodhi2Client(username=user, password=password, staging=kwargs['staging'])
+    client = bindings.BodhiClient(username=user, password=password, staging=kwargs['staging'])
 
     if kwargs['file'] is None:
         updates = [kwargs]
@@ -78,7 +77,7 @@ def new(user, password, **kwargs):
         try:
             resp = client.save(**update)
             print_resp(resp, client)
-        except BodhiClientException as e:
+        except bindings.BodhiClientException as e:
             click.echo(str(e))
         except Exception as e:
             traceback.print_exc()
@@ -116,7 +115,7 @@ def new(user, password, **kwargs):
 @click.option('--staging', help='Use the staging bodhi instance',
               is_flag=True, default=False)
 def query(**kwargs):
-    client = Bodhi2Client(staging=kwargs['staging'])
+    client = bindings.BodhiClient(staging=kwargs['staging'])
     resp = client.query(**kwargs)
     print_resp(resp, client)
 
@@ -131,8 +130,13 @@ def query(**kwargs):
 @click.option('--staging', help='Use the staging bodhi instance',
               is_flag=True, default=False)
 def request(update, state, user, password, **kwargs):
-    client = Bodhi2Client(username=user, password=password, staging=kwargs['staging'])
-    resp = client.request(update, state)
+    client = bindings.BodhiClient(username=user, password=password, staging=kwargs['staging'])
+
+    try:
+        resp = client.request(update, state)
+    except bindings.UpdateNotFound as exc:
+        raise click.BadParameter(unicode(exc), param_hint='UPDATE')
+
     print_resp(resp, client)
 
 
@@ -145,7 +149,7 @@ def request(update, state, user, password, **kwargs):
 @click.option('--staging', help='Use the staging bodhi instance',
               is_flag=True, default=False)
 def comment(update, text, karma, user, password, **kwargs):
-    client = Bodhi2Client(username=user, password=password, staging=kwargs['staging'])
+    client = bindings.BodhiClient(username=user, password=password, staging=kwargs['staging'])
     print('%r %r %r' % (update, text, karma))
     resp = client.comment(update, text, karma)
     print_resp(resp, client)
@@ -158,7 +162,7 @@ def comment(update, text, karma, user, password, **kwargs):
 @click.option('--updateid', help='Download update(s) by ID(s) (comma-separated list)')
 @click.option('--builds', help='Download update(s) by build NVR(s) (comma-separated list)')
 def download(**kwargs):
-    client = Bodhi2Client(staging=kwargs['staging'])
+    client = bindings.BodhiClient(staging=kwargs['staging'])
     del(kwargs['staging'])
     # At this point we need to have reduced the kwargs dict to only our
     # query options (cves, updateid, builds)
@@ -205,7 +209,7 @@ def overrides():
 @click.option('--staging', help='Use the staging bodhi instance',
               is_flag=True, default=False)
 def query_buildroot_overrides(user=None, **kwargs):
-    client = Bodhi2Client(staging=kwargs['staging'])
+    client = bindings.BodhiClient(staging=kwargs['staging'])
     resp = client.list_overrides(user=user)
     print_resp(resp, client)
 
@@ -221,7 +225,7 @@ def query_buildroot_overrides(user=None, **kwargs):
 @click.option('--staging', help='Use the staging bodhi instance',
               is_flag=True, default=False)
 def save_buildroot_overrides(nvr, duration, notes, user, password, staging):
-    client = Bodhi2Client(username=user, password=password, staging=staging)
+    client = bindings.BodhiClient(username=user, password=password, staging=staging)
     resp = client.save_override(nvr=nvr, duration=duration, notes=notes)
     print_resp(resp, client)
 
diff --git a/bodhi/client/bindings.py b/bodhi/client/bindings.py
new file mode 100644
--- /dev/null
+++ b/bodhi/client/bindings.py
@@ -0,0 +1,523 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright 2007-2016  Red Hat, Inc.
+# This file is part of bodhi
+#
+# This software is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This software is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this software; if not, see <http://www.gnu.org/licenses/>
+#
+"""
+This module provides Python bindings to the Bodhi REST API.
+
+
+.. moduleauthor:: Luke Macken <lmacken@redhat.com>
+.. moduleauthor:: Toshio Kuratomi <tkuratom@redhat.com>
+.. moduleauthor:: Ralph Bean <rbean@redhat.com>
+.. moduleauthor:: Randy Barlow <bowlofeggs@fedoraproject.org>
+"""
+
+import os
+import datetime
+import functools
+import getpass
+import logging
+import textwrap
+import warnings
+import re
+
+import six
+
+from fedora.client import OpenIdBaseClient, FedoraClientError
+import fedora.client.openidproxyclient
+
+
+log = logging.getLogger(__name__)
+
+BASE_URL = 'https://bodhi.fedoraproject.org/'
+STG_BASE_URL = 'https://bodhi.stg.fedoraproject.org/'
+STG_OPENID_API = 'https://id.stg.fedoraproject.org/api/v1/'
+
+
+class BodhiClientException(FedoraClientError):
+    pass
+
+
+class UpdateNotFound(BodhiClientException):
+    def __init__(self, update):
+        self.update = unicode(update)
+
+    def __unicode__(self):
+        return u'Update not found: {}'.format(self.update)
+
+
+def errorhandled(method):
+    """ A decorator for BodhiClient that raises exceptions on failure. """
+    @functools.wraps(method)
+    def wrapper(*args, **kwargs):
+        result = method(*args, **kwargs)
+
+        if 'errors' not in result:
+            return result
+
+        # Otherwise, there was a problem...
+        problems = 'An unhandled error occurred in the BodhiClient'
+        try:
+            problems = "\n".join([e['description'] for e in result['errors']])
+        except Exception:
+            pass
+        raise BodhiClientException(problems)
+    return wrapper
+
+
+class BodhiClient(OpenIdBaseClient):
+
+    def __init__(self, base_url=BASE_URL, username=None, password=None, staging=False, **kwargs):
+        if staging:
+            fedora.client.openidproxyclient.FEDORA_OPENID_API = STG_OPENID_API
+            base_url = STG_BASE_URL
+
+        super(BodhiClient, self).__init__(base_url, login_url=base_url +
+                'login', username=username, **kwargs)
+
+        self._password = password
+        self.csrf_token = None
+
+    @property
+    def password(self):
+        if not self._password:
+            self._password = getpass.getpass()
+        return self._password
+
+    @errorhandled
+    def save(self, **kwargs):
+        """ Save an update.
+
+        This entails either creating a new update, or editing an existing one.
+        To edit an existing update, you must specify the update title in
+        the ``edited`` keyword argument.
+
+        :kwarg builds: A list of koji builds for this update.
+        :kwarg type: The type of this update: ``security``, ``bugfix``,
+            ``enhancement``, and ``newpackage``.
+        :kwarg bugs: A list of Red Hat Bugzilla ID's associated with this
+            update.
+        :kwarg notes: Details as to why this update exists.
+        :kwarg request: Request for this update to change state, either to
+            ``testing``, ``stable``, ``unpush``, ``obsolete`` or None.
+        :kwarg close_bugs: Close bugs when update is stable
+        :kwarg suggest: Suggest that the user reboot or logout after update.
+            (``reboot``, ``logout``)
+        :kwarg inheritance: Follow koji build inheritance, which may result in
+            this update being pushed out to additional releases.
+        :kwarg autokarma: Allow bodhi to automatically change the state of this
+            update based on the ``karma`` from user feedback.  It will
+            push your update to ``stable`` once it reaches the ``stable_karma``
+            and unpush your update when reaching ``unstable_karma``.
+        :kwarg stable_karma: The upper threshold for marking an update as
+            ``stable``.
+        :kwarg unstable_karma: The lower threshold for unpushing an update.
+        :kwarg edited: The update title of the existing update that we are
+            editing.
+        :kwarg severity: The severity of this update (``urgent``, ``high``,
+            ``medium``, ``low``)
+        :kwarg requirements: A list of required Taskotron tests that must pass
+            for this update to reach stable. (``depcheck``, ``upgradepath``,
+            ``rpmlint``)
+        :kwarg require_bugs: A boolean to require that all of the bugs in your
+            update have been confirmed by testers.
+        :kwarg require_testcases: A boolean to require that this update passes
+            all test cases before reaching stable.
+
+        """
+        kwargs['csrf_token'] = self.csrf()
+        if 'type_' in kwargs:
+            # backwards compat
+            kwargs['type'] = kwargs['type_']
+        return self.send_request('updates/', verb='POST', auth=True,
+                                 data=kwargs)
+
+    @errorhandled
+    def request(self, update, request):
+        """ Request an update state change.
+
+        :arg update: The title of the update
+        :arg request: The request (``testing``, ``stable``, ``obsolete``,
+                                   ``unpush``, ``revoke``)
+        """
+        try:
+            return self.send_request('updates/{0}/request'.format(update),
+                                     verb='POST', auth=True,
+                                     data={'update': update, 'request': request,
+                                           'csrf_token': self.csrf()})
+        except fedora.client.ServerError as exc:
+            if exc.code == 404:
+                # The Bodhi server gave us a 404 on the resource, so let's raise an UpdateNotFound.
+                raise UpdateNotFound(update)
+            else:
+                raise
+
+    @errorhandled
+    def delete(self, update):
+        warnings.warn('Deleting updates has been disabled in Bodhi. '
+                      'This API call will unpush the update instead. '
+                      'Please use `set_request(update, "unpush")` instead')
+        self.request(update, 'unpush')
+
+    @errorhandled
+    def query(self, **kwargs):
+        """ Query bodhi for a list of updates.
+
+        :kwarg releases: A list of releases that you wish to query updates for.
+        :kwarg status: The update status (``pending``, ``testing``, ``stable``,
+            ``obsolete``, ``unpushed``, ``processing``)
+        :kwarg type: The type of this update: ``security``, ``bugfix``,
+            ``enhancement``, and ``newpackage``.
+        :kwarg bugs: A list of Red Hat Bugzilla ID's
+        :kwarg request: An update request to query for
+            ``testing``, ``stable``, ``unpush``, ``obsolete`` or None.
+        :kwarg mine: If True, only query the users updates.  Default: False.
+        :kwarg packages: A space or comma delimited list of package names
+        :kwarg limit: A deprecated argument, sets ``rows_per_page``. See its docstring for more info.
+        :kwarg approved_before: A datetime string
+        :kwarg approved_since: A datetime string
+        :kwarg builds: A space or comma delimited string of build nvrs
+        :kwarg critpath: A boolean to query only critical path updates
+        :kwarg cves: Filter by CVE IDs
+        :kwarg locked: A boolean to filter only locked updates
+        :kwarg modified_before: A datetime string to query updates that have
+            been modified before a certain time.
+        :kwarg modified_since: A datetime string to query updates that have
+            been modified since a certain time.
+        :kwarg pushed: A boolean to filter only pushed updates
+        :kwarg pushed_before: A datetime string to filter updates pushed before a
+            certain time.
+        :kwarg pushed_since: A datetime string to filter updates pushed since a
+            certain time.
+        :kwarg severity: A severity type to filter by (``unspecified``,
+            ``urgent``, ``high``, ``medium``, ``low``)
+        :kwarg submitted_before: A datetime string to filter updates submitted
+            before a certain time.
+        :kwarg submitted_since: A datetime string to filter updates submitted
+            after a certain time.
+        :kwarg suggest: Query for updates that suggest a user restart
+            (``logout``, ``reboot``)
+        :kwarg user: Query for updates submitted by a specific user.
+        :kwarg rows_per_page: Limit the results to a certain number of rows per
+                    page (min:1 max: 100 default: 20)
+        :kwarg page: Return a specific page of results
+
+        """
+        # bodhi1 compat
+        if 'limit' in kwargs:
+            kwargs['rows_per_page'] = kwargs['limit']
+            del(kwargs['limit'])
+        # 'mine' may be in kwargs, but set False
+        if kwargs.get('mine'):
+            kwargs['user'] = self.username
+        if 'package' in kwargs:
+            # for Bodhi 1, 'package' could be a package name, build, or
+            # update ID, so try and figure it out
+            if re.search(r'(\.el|\.fc)\d\d?', kwargs['package']):
+                kwargs['builds'] = kwargs['package']
+            elif re.search(r'FEDORA-(EPEL)?-\d{4,4}', kwargs['package']):
+                kwargs['updateid'] = kwargs['package']
+            else:
+                kwargs['packages'] = kwargs['package']
+            del(kwargs['package'])
+        if 'release' in kwargs:
+            kwargs['releases'] = kwargs['release']
+            del(kwargs['release'])
+        if 'type_' in kwargs:
+            kwargs['type'] = kwargs['type_']
+            del(kwargs['type_'])
+        # Old Bodhi CLI set bugs default to "", but new Bodhi API
+        # checks for 'if bugs is not None', not 'if not bugs'
+        if 'bugs' in kwargs and kwargs['bugs'] == '':
+            kwargs['bugs'] = None
+        return self.send_request('updates/', verb='GET', params=kwargs)
+
+    @errorhandled
+    def comment(self, update, comment, karma=0, email=None):
+        """ Add a comment to an update.
+
+        :arg update: The title of the update comment on.
+        :arg comment: The text of the comment.
+        :kwarg karma: The karma of this comment (-1, 0, 1)
+        :kwarg email: Whether or not to trigger email notifications
+
+        """
+        return self.send_request('comments/', verb='POST', auth=True,
+                data={'update': update, 'text': comment,
+                      'karma': karma, 'email': email,
+                      'csrf_token': self.csrf()})
+
+    @errorhandled
+    def save_override(self, nvr, duration, notes):
+        """ Save a buildroot override.
+
+        This entails either creating a new buildroot override, or editing an
+        existing one.
+
+        :kwarg nvr: A nvr of a koji build.
+        :kwarg duration: Number of days from now that this override should
+            expire.
+        :kwarg notes: Notes about why this override is in place.
+
+        """
+        expiration_date = datetime.datetime.utcnow() + \
+            datetime.timedelta(days=duration)
+
+        return self.send_request(
+            'overrides/', verb='POST', auth=True, data={
+                'nvr': nvr,
+                'expiration_date': expiration_date,
+                'notes': notes,
+                'csrf_token': self.csrf(),
+            })
+
+    @errorhandled
+    def list_overrides(self, user=None):
+        """ Save a buildroot overrides.
+
+        This entails either creating a new buildroot override, or editing an
+        existing one.
+
+        :kwarg user: A username whose buildroot overrides you want returned.
+
+        """
+        params = {}
+        if user:
+            params['user'] = user
+        return self.send_request('overrides/', verb='GET', params=params)
+
+    @errorhandled
+    def csrf(self):
+        if not self.csrf_token:
+            if not self.has_cookies():
+                self.login(self.username, self.password)
+            self.csrf_token = self.send_request(
+                'csrf', verb='GET', auth=True)['csrf_token']
+        return self.csrf_token
+
+    def parse_file(self, input_file):
+        """ Parse an update template file.
+
+        :arg input_file: The filename of the update template.
+
+        Returns an array of dictionaries of parsed update values which
+        can be directly passed to the ``save`` method.
+
+        """
+        from six.moves import configparser
+
+        if not os.path.exists(input_file):
+            raise ValueError("No such file or directory: %s" % input_file)
+
+        defaults = dict(severity='unspecified', suggest='unspecified')
+        config = configparser.SafeConfigParser(defaults=defaults)
+        read = config.read(input_file)
+
+        if len(read) != 1 or read[0] != input_file:
+            raise ValueError("Invalid input file: %s" % input_file)
+
+        updates = []
+
+        for section in config.sections():
+            update = {
+                'builds': section,
+                'bugs': config.get(section, 'bugs', raw=True),
+                'close_bugs': config.getboolean(section, 'close_bugs'),
+                'type': config.get(section, 'type', raw=True),
+                'type_': config.get(section, 'type', raw=True),
+                'request': config.get(section, 'request', raw=True),
+                'severity': config.get(section, 'severity', raw=True),
+                'notes': config.get(section, 'notes', raw=True),
+                'autokarma': config.get(section, 'autokarma', raw=True),
+                'stable_karma': config.get(section, 'stable_karma', raw=True),
+                'unstable_karma': config.get(
+                    section, 'unstable_karma', raw=True),
+                'suggest': config.get(section, 'suggest', raw=True),
+                }
+
+            updates.append(update)
+
+        return updates
+
+    @errorhandled
+    def latest_builds(self, package):
+        return self.send_request('latest_builds', params={'package': package})
+
+    def testable(self):
+        """ Get a list of installed testing updates.
+
+        This method is a generate that yields packages that you currently
+        have installed that you have yet to test and provide feedback for.
+
+        Only works on systems with dnf.
+        """
+        import dnf
+        base = dnf.Base()
+        sack = base.fill_sack(load_system_repo=True)
+        query = sack.query()
+        installed = query.installed()
+        with open('/etc/fedora-release', 'r') as f:
+            fedora = f.readlines()[0].split()[2]
+        tag = 'f%s-updates-testing' % fedora
+        builds = self.get_koji_session(
+            login=False).listTagged(tag, latest=True)
+        for build in builds:
+            pkgs = installed.filter(name=build['name'], version=build['version'],
+                                    release=build['release']).run()
+            if len(pkgs):
+                update_list = self.query(builds=build['nvr'])['updates']
+                for update in update_list:
+                    yield update
+
+    def override_str(self, override):
+        """ Return a string representation of a given override dictionary.
+
+        :arg override: An override dictionary.
+
+        """
+        if isinstance(override, six.string_types):
+            return override
+
+        # TODO -- make this fancy.
+        return "{submitter}'s {build} override (expires {expiry})".format(
+            submitter=override['submitter']['name'],
+            build=override['build']['nvr'],
+            expiry=override['expiration_date'],
+        )
+
+    def update_str(self, update, minimal=False):
+        """ Return a string representation of a given update dictionary.
+
+        :arg update: An update dictionary, acquired by the ``list`` method.
+        :kwarg minimal: Return a minimal one-line representation of the update.
+
+        """
+        if isinstance(update, six.string_types):
+            return update
+        if minimal:
+            val = ""
+            date = update['date_pushed'] and update['date_pushed'].split()[0] \
+                or update['date_submitted'].split()[0]
+            val += ' %-43s  %-11s  %-8s  %10s ' % (update['builds'][0]['nvr'],
+                                                   update['type'],
+                                                   update['status'], date)
+            for build in update['builds'][1:]:
+                val += '\n %s' % build['nvr']
+            return val
+        val = "%s\n%s\n%s\n" % ('=' * 80, '\n'.join(
+            textwrap.wrap(update['title'].replace(',', ', '), width=80,
+                          initial_indent=' '*5, subsequent_indent=' '*5)), '=' * 80)
+        if update['alias']:
+            val += "  Update ID: %s\n" % update['alias']
+        val += """    Release: %s
+     Status: %s
+       Type: %s
+      Karma: %d""" % (update['release']['long_name'], update['status'],
+                      update['type'], update['karma'])
+        if update['request'] is not None:
+            val += "\n    Request: %s" % update['request']
+        if len(update['bugs']):
+            bugs = ''
+            i = 0
+            for bug in update['bugs']:
+                bugstr = '%s%s - %s\n' % (i and ' ' * 11 + ': ' or '',
+                                          bug['bug_id'], bug['title'])
+                bugs += '\n'.join(textwrap.wrap(bugstr, width=67,
+                                                subsequent_indent=' '*11+': ')) + '\n'
+                i += 1
+            bugs = bugs[:-1]
+            val += "\n       Bugs: %s" % bugs
+        if update['notes']:
+            notes = textwrap.wrap(update['notes'], width=67,
+                                  subsequent_indent=' ' * 11 + ': ')
+            val += "\n      Notes: %s" % '\n'.join(notes)
+        val += """
+  Submitter: %s
+  Submitted: %s\n""" % (update['user']['name'], update['date_submitted'])
+        if len(update['comments']):
+            val += "   Comments: "
+            comments = []
+            for comment in update['comments']:
+                if comment['anonymous']:
+                    anonymous = " (unauthenticated)"
+                else:
+                    anonymous = ""
+                comments.append("%s%s%s - %s (karma %s)" % (' ' * 13,
+                                comment['user']['name'], anonymous,
+                                comment['timestamp'], comment['karma']))
+                if comment['text']:
+                    text = textwrap.wrap(comment['text'], initial_indent=' ' * 13,
+                                         subsequent_indent=' ' * 13, width=67)
+                    comments.append('\n'.join(text))
+            val += '\n'.join(comments).lstrip() + '\n'
+        if update['alias']:
+            val += "\n  %s\n" % ('%supdates/%s' % (self.base_url,
+                                                   update['alias']))
+        else:
+            val += "\n  %s\n" % ('%s%s' % (self.base_url, update['title']))
+        return val
+
+    @errorhandled
+    def get_releases(self, **kwargs):
+        """ Return a list of bodhi releases.
+
+        This method returns a dictionary in the following format::
+
+            {"releases": [
+                {"dist_tag": "dist-f12", "id_prefix": "FEDORA",
+                 "locked": false, "name": "F12", "long_name": "Fedora 12"}]}
+        """
+        return self.send_request('releases/', params=kwargs)
+
+    def get_koji_session(self, login=True):
+        """ Return an authenticated koji session """
+        import koji
+        from iniparse.compat import ConfigParser
+        config = ConfigParser()
+        if os.path.exists(os.path.join(os.path.expanduser('~'), '.koji', 'config')):
+            config.readfp(open(os.path.join(os.path.expanduser('~'), '.koji', 'config')))
+        else:
+            config.readfp(open('/etc/koji.conf'))
+        cert = os.path.expanduser(config.get('koji', 'cert'))
+        ca = os.path.expanduser(config.get('koji', 'ca'))
+        serverca = os.path.expanduser(config.get('koji', 'serverca'))
+        session = koji.ClientSession(config.get('koji', 'server'))
+        if login:
+            session.ssl_login(cert=cert, ca=ca, serverca=serverca)
+        return session
+
+    koji_session = property(fget=get_koji_session)
+
+    def candidates(self):
+        """ Get a list list of update candidates.
+
+        This method is a generator that returns a list of koji builds that
+        could potentially be pushed as updates.
+        """
+        if not self.username:
+            raise BodhiClientException('You must specify a username')
+        builds = []
+        data = self.get_releases()
+        koji = self.get_koji_session(login=False)
+        for release in data['releases']:
+            try:
+                for build in koji.listTagged(release['candidate_tag'], latest=True):
+                    if build['owner_name'] == self.username:
+                        builds.append(build)
+            except:
+                log.exception('Unable to query candidate builds for %s' % release)
+        return builds


Test patch:
diff --git a/bodhi/tests/client/__init__.py b/bodhi/tests/client/__init__.py
new file mode 100644
--- /dev/null
+++ b/bodhi/tests/client/__init__.py
@@ -0,0 +1,72 @@
+# -*- coding: utf-8 -*-
+
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+"""This module contains test data for bodhi.client."""
+
+from munch import Munch
+
+
+EXAMPLE_UPDATE_MUNCH = Munch({
+    u'date_testing': u'2016-10-06 00:55:15', u'old_updateid': None, u'pushed': True,
+    u'require_testcases': True, u'date_locked': None, u'critpath': False, u'date_approved': None,
+    u'stable_karma': 3, u'date_pushed': u'2016-10-21 13:23:01', u'requirements': u'',
+    u'severity': u'unspecified', u'autokarma': True, u'title': u'bodhi-2.2.4-1.el7',
+    u'suggest': u'unspecified', u'require_bugs': True,
+    u'comments': [
+        Munch({
+            u'bug_feedback': [], u'user_id': 91, u'timestamp': u'2016-10-05 18:10:22',
+            u'testcase_feedback': [], u'karma_critpath': 0, u'update_id': 69704, u'karma': 0,
+            u'anonymous': False,
+            u'text': u'This update has been submitted for testing by bowlofeggs. ', u'id': 501425,
+            u'user': Munch({
+                u'openid': u'bodhi.id.fedoraproject.org', u'name': u'bodhi', u'show_popups': True,
+                u'id': 91, u'avatar': u'https://apps.fedoraproject.org/img/icons/bodhi-24.png',
+                u'groups': [], u'email': None})}),
+        Munch({
+            u'bug_feedback': [], u'user_id': 91, u'timestamp': u'2016-10-05 18:10:27',
+            u'testcase_feedback': [], u'karma_critpath': 0, u'update_id': 69704, u'karma': 0,
+            u'anonymous': False,
+            u'text': (u'This update has obsoleted [bodhi-2.2.3-1.el7]'
+                      u'(https://bodhi.fedoraproject.org/updates/FEDORA-EPEL-2016-a0eb4cc41f), and '
+                      u'has inherited its bugs and notes.'),
+            u'id': 501427,
+            u'user': Munch({
+                u'openid': u'bodhi.id.fedoraproject.org', u'name': u'bodhi', u'show_popups': True,
+                u'id': 91, u'avatar': u'https://apps.fedoraproject.org/img/icons/bodhi-24.png',
+                u'groups': [], u'email': None})})],
+    u'updateid': u'FEDORA-EPEL-2016-3081a94111', u'type': u'bugfix', u'close_bugs': True,
+    u'meets_testing_requirements': True, u'date_submitted': u'2016-10-05 18:10:22',
+    u'unstable_karma': -3, u'submitter': u'bowlofeggs',
+    u'user': Munch({
+        u'openid': u'bowlofeggs.id.fedoraproject.org', u'name': u'bowlofeggs', u'show_popups': True,
+        u'id': 2897,
+        u'avatar': u'https://seccdn.libravatar.org/avatar/some_hash',
+        u'groups': [Munch({u'name': u'packager'})], u'email': u'bowlofeggs@electronsweatshop.com'}),
+    u'locked': False,
+    u'builds': [Munch({u'epoch': 0, u'nvr': u'bodhi-2.2.4-1.el7', u'signed': True})],
+    u'date_modified': None,
+    u'url': u'https://bodhi.fedoraproject.org/updates/FEDORA-EPEL-2016-3081a94111',
+    u'test_cases': [],
+    u'notes': (u'Update to 2.2.4. Release notes available at '
+               u'https://github.com/fedora-infra/bodhi/releases/tag/2.2.4\n'),
+    u'request': None, u'bugs': [], u'alias': u'FEDORA-EPEL-2016-3081a94111', u'status': u'stable',
+    u'karma': 0,
+    u'release': Munch({
+        u'dist_tag': u'epel7', u'name': u'EPEL-7', u'testing_tag': u'epel7-testing',
+        u'pending_stable_tag': u'epel7-pending', u'pending_signing_tag': u'epel7-signing-pending',
+        u'long_name': u'Fedora EPEL 7', u'state': u'current', u'version': u'7',
+        u'override_tag': u'epel7-override', u'branch': u'epel7', u'id_prefix': u'FEDORA-EPEL',
+        u'pending_testing_tag': u'epel7-testing-pending', u'stable_tag': u'epel7',
+        u'candidate_tag': u'epel7-testing-candidate'}), u'date_stable': u'2016-10-21 13:23:01'})
diff --git a/bodhi/tests/client/test___init__.py b/bodhi/tests/client/test___init__.py
new file mode 100644
--- /dev/null
+++ b/bodhi/tests/client/test___init__.py
@@ -0,0 +1,108 @@
+# -*- coding: utf-8 -*-
+
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+"""This module contains tests for bodhi.client."""
+
+import unittest
+
+from click import testing
+import fedora.client
+import mock
+
+from bodhi import client
+from bodhi.tests import client as client_test_data
+
+
+EXAMPLE_UPDATE_OUTPUT = u"""================================================================================
+     bodhi-2.2.4-1.el7
+================================================================================
+  Update ID: FEDORA-EPEL-2016-3081a94111
+    Release: Fedora EPEL 7
+     Status: stable
+       Type: bugfix
+      Karma: 0
+      Notes: Update to 2.2.4. Release notes available at https://github.com
+           : /fedora-infra/bodhi/releases/tag/2.2.4
+  Submitter: bowlofeggs
+  Submitted: 2016-10-05 18:10:22
+   Comments: bodhi - 2016-10-05 18:10:22 (karma 0)
+             This update has been submitted for testing by
+             bowlofeggs.
+             bodhi - 2016-10-05 18:10:27 (karma 0)
+             This update has obsoleted [bodhi-2.2.3-1.el7](https://
+             bodhi.fedoraproject.org/updates/FEDORA-
+             EPEL-2016-a0eb4cc41f), and has inherited its bugs and
+             notes.
+
+  http://example.com/tests/updates/FEDORA-EPEL-2016-3081a94111
+
+"""
+
+
+class TestRequest(unittest.TestCase):
+    """
+    This class tests the request() function.
+    """
+    @mock.patch('bodhi.client.bindings.BodhiClient.__init__', return_value=None)
+    @mock.patch.object(client.bindings.BodhiClient, 'base_url', 'http://example.com/tests/',
+                       create=True)
+    @mock.patch('bodhi.client.bindings.BodhiClient.csrf',
+                mock.MagicMock(return_value='a_csrf_token'))
+    @mock.patch('bodhi.client.bindings.BodhiClient.send_request',
+                return_value=client_test_data.EXAMPLE_UPDATE_MUNCH)
+    def test_successful_operation(self, send_request, __init__):
+        """
+        Assert that a successful operation is handled properly.
+        """
+        runner = testing.CliRunner()
+
+        result = runner.invoke(client.request, ['bodhi-2.2.4-1.el7', 'revoke', '--user',
+                                                'some_user', '--password', 's3kr3t'])
+
+        self.assertEqual(result.exit_code, 0)
+        self.assertEqual(result.output, EXAMPLE_UPDATE_OUTPUT)
+        send_request.assert_called_once_with(
+            'updates/bodhi-2.2.4-1.el7/request', verb='POST', auth=True,
+            data={'csrf_token': 'a_csrf_token', 'request': u'revoke',
+                  'update': u'bodhi-2.2.4-1.el7'})
+        __init__.assert_called_once_with(username='some_user', password='s3kr3t', staging=False)
+
+    @mock.patch('bodhi.client.bindings.BodhiClient.__init__', return_value=None)
+    @mock.patch('bodhi.client.bindings.BodhiClient.csrf',
+                mock.MagicMock(return_value='a_csrf_token'))
+    @mock.patch('bodhi.client.bindings.BodhiClient.send_request',
+                side_effect=fedora.client.ServerError(
+                    url='http://example.com/tests/updates/bodhi-2.2.4-99.el7/request', status=404,
+                    msg='update not found'))
+    def test_update_not_found(self, send_request, __init__):
+        """
+        Assert that request() transforms a bodhi.client.bindings.UpdateNotFound into a
+        click.BadParameter so that the user gets a nice error message.
+        """
+        runner = testing.CliRunner()
+
+        result = runner.invoke(client.request, ['bodhi-2.2.4-99.el7', 'revoke', '--user',
+                                                'some_user', '--password', 's3kr3t'])
+
+        self.assertEqual(result.exit_code, 2)
+        self.assertEqual(
+            result.output,
+            (u'Usage: request [OPTIONS] UPDATE STATE\n\nError: Invalid value for UPDATE: Update not'
+             u' found: bodhi-2.2.4-99.el7\n'))
+        send_request.assert_called_once_with(
+            'updates/bodhi-2.2.4-99.el7/request', verb='POST', auth=True,
+            data={'csrf_token': 'a_csrf_token', 'request': u'revoke',
+                  'update': u'bodhi-2.2.4-99.el7'})
+        __init__.assert_called_once_with(username='some_user', password='s3kr3t', staging=False)
diff --git a/bodhi/tests/client/test_bindings.py b/bodhi/tests/client/test_bindings.py
new file mode 100644
--- /dev/null
+++ b/bodhi/tests/client/test_bindings.py
@@ -0,0 +1,127 @@
+# -*- coding: utf-8 -*-
+
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+"""This module contains tests for bodhi.client.bindings."""
+
+import unittest
+
+import fedora.client
+import mock
+
+from bodhi.client import bindings
+from bodhi.tests import client as client_test_data
+
+
+class TestBodhiClient_Resource(unittest.TestCase):
+    """
+    This class contains tests for BodhiClient.resource().
+    """
+    @mock.patch('bodhi.client.bindings.BodhiClient.__init__', return_value=None)
+    @mock.patch.object(bindings.BodhiClient, 'base_url', 'http://example.com/tests/',
+                       create=True)
+    @mock.patch('bodhi.client.bindings.BodhiClient.csrf',
+                mock.MagicMock(return_value='a_csrf_token'))
+    @mock.patch('bodhi.client.bindings.BodhiClient.send_request',
+                side_effect=fedora.client.ServerError(
+                    url='http://example.com/tests/updates/bodhi-2.2.4-99.el7/request', status=404,
+                    msg='update not found'))
+    def test_404_error(self, send_request, __init__):
+        """
+        Test for the case when the server returns a 404 error code.
+        """
+        client = bindings.BodhiClient(username='some_user', password='s3kr3t', staging=False)
+
+        with self.assertRaises(bindings.UpdateNotFound) as exc:
+            client.request('bodhi-2.2.4-1.el7', 'revoke')
+
+            self.assertEqual(exc.update, 'bodhi-2.2.4-1.el7')
+
+        send_request.assert_called_once_with(
+            'updates/bodhi-2.2.4-1.el7/request', verb='POST', auth=True,
+            data={'csrf_token': 'a_csrf_token', 'request': u'revoke',
+                  'update': u'bodhi-2.2.4-1.el7'})
+        __init__.assert_called_once_with(username='some_user', password='s3kr3t', staging=False)
+
+    @mock.patch('bodhi.client.bindings.BodhiClient.__init__', return_value=None)
+    @mock.patch.object(bindings.BodhiClient, 'base_url', 'http://example.com/tests/',
+                       create=True)
+    @mock.patch('bodhi.client.bindings.BodhiClient.csrf',
+                mock.MagicMock(return_value='a_csrf_token'))
+    @mock.patch('bodhi.client.bindings.BodhiClient.send_request',
+                return_value=client_test_data.EXAMPLE_UPDATE_MUNCH)
+    def test_successful_request(self, send_request, __init__):
+        """
+        Test with a successful request.
+        """
+        client = bindings.BodhiClient(username='some_user', password='s3kr3t', staging=False)
+
+        response = client.request('bodhi-2.2.4-1.el7', 'revoke')
+
+        self.assertEqual(response, client_test_data.EXAMPLE_UPDATE_MUNCH)
+        send_request.assert_called_once_with(
+            'updates/bodhi-2.2.4-1.el7/request', verb='POST', auth=True,
+            data={'csrf_token': 'a_csrf_token', 'request': u'revoke',
+                  'update': u'bodhi-2.2.4-1.el7'})
+        __init__.assert_called_once_with(username='some_user', password='s3kr3t', staging=False)
+
+    @mock.patch('bodhi.client.bindings.BodhiClient.__init__', return_value=None)
+    @mock.patch.object(bindings.BodhiClient, 'base_url', 'http://example.com/tests/',
+                       create=True)
+    @mock.patch('bodhi.client.bindings.BodhiClient.csrf',
+                mock.MagicMock(return_value='a_csrf_token'))
+    @mock.patch('bodhi.client.bindings.BodhiClient.send_request')
+    def test_other_ServerError(self, send_request, __init__):
+        """
+        Test for the case when a non-404 ServerError is raised.
+        """
+        server_error = fedora.client.ServerError(
+            url='http://example.com/tests/updates/bodhi-2.2.4-99.el7/request', status=500,
+            msg='Internal server error')
+        send_request.side_effect = server_error
+        client = bindings.BodhiClient(username='some_user', password='s3kr3t', staging=False)
+
+        with self.assertRaises(fedora.client.ServerError) as exc:
+            client.request('bodhi-2.2.4-1.el7', 'revoke')
+
+            self.assertTrue(exc is server_error)
+
+        send_request.assert_called_once_with(
+            'updates/bodhi-2.2.4-1.el7/request', verb='POST', auth=True,
+            data={'csrf_token': 'a_csrf_token', 'request': u'revoke',
+                  'update': u'bodhi-2.2.4-1.el7'})
+        __init__.assert_called_once_with(username='some_user', password='s3kr3t', staging=False)
+
+
+class TestUpdateNotFound(unittest.TestCase):
+    """
+    This class tests the UpdateNotFound class.
+    """
+    def test___init__(self):
+        """
+        Assert that __init__() works properly.
+        """
+        exc = bindings.UpdateNotFound('bodhi-2.2.4-1.el7')
+
+        self.assertEqual(exc.update, u'bodhi-2.2.4-1.el7')
+        self.assertEqual(type(exc.update), unicode)
+
+    def test___unicode__(self):
+        """
+        Assert that __unicode__() works properly.
+        """
+        exc = bindings.UpdateNotFound('bodhi-2.2.4-1.el7')
+
+        self.assertEqual(unicode(exc.update), u'bodhi-2.2.4-1.el7')
+        self.assertEqual(type(unicode(exc.update)), unicode)