Bugsy: synchronize with upstream
authorGregory Szorc <gps@mozilla.com>
Fri, 04 Sep 2015 12:09:05 -0700
changeset 361471 5d2431f11d23a355483ede7cec8458a0112c6dd9
parent 361470 aacb829e9ade1f25ca98ce45591a78d25c62a41b
child 361472 9c8b8187f21b12da3bedeec4a91dced132c47064
push id16998
push userrwood@mozilla.com
push dateMon, 02 May 2016 19:42:03 +0000
Bugsy: synchronize with upstream Using commit a099a9b86682117b54e73f96892bd4923296bce1. The big addition is API key support.
pylib/Bugsy/.travis.yml
pylib/Bugsy/CONTRIBUTING.md
pylib/Bugsy/History.md
pylib/Bugsy/README.md
pylib/Bugsy/bugsy/__init__.py
pylib/Bugsy/bugsy/bug.py
pylib/Bugsy/bugsy/bugsy.py
pylib/Bugsy/bugsy/search.py
pylib/Bugsy/requirements.txt
pylib/Bugsy/setup.py
pylib/Bugsy/tests/__init__.py
pylib/Bugsy/tests/test_bugs.py
pylib/Bugsy/tests/test_bugsy.py
pylib/Bugsy/tests/test_search.py
--- a/pylib/Bugsy/.travis.yml
+++ b/pylib/Bugsy/.travis.yml
@@ -1,6 +1,13 @@
 language: python
 python:
   - 2.6
   - 2.7
-install: pip install -r requirements.txt
-script: python setup.py develop && py.test tests
\ No newline at end of file
+install:
+  - pip install -r requirements.txt
+  - pip install pytest-cov coveralls
+
+before_script: flake8 bugsy
+
+script: python setup.py develop && py.test --cov bugsy tests
+
+after_success: coveralls
--- a/pylib/Bugsy/CONTRIBUTING.md
+++ b/pylib/Bugsy/CONTRIBUTING.md
@@ -19,17 +19,18 @@ in the review process.
   * This is usually the master branch.
   * Only target release branches if you are certain your fix must be on that
     branch.
   * To quickly create a topic branch based on master; `git checkout -b
     /my_contribution master`. Please avoid working directly on the
     `master` branch.
 * Make commits of logical units.
 * Check for unnecessary whitespace with `git diff --check` before committing.
+* Check for python pep8 compliance: `flake8 bugsy`
 * Make sure you have added the necessary tests for your changes.
 * Run _all_ the tests to assure nothing else was accidentally broken.
 
 ## Submitting Changes
 
 * Push your changes to a topic branch in your fork of the repository.
 * Submit a pull request to the main repository
 * After feedback has been given we expect responses within two weeks. After two
-  weeks will may close the pull request if it isn't showing any activity.
\ No newline at end of file
+  weeks will may close the pull request if it isn't showing any activity.
--- a/pylib/Bugsy/History.md
+++ b/pylib/Bugsy/History.md
@@ -1,8 +1,28 @@
+
+n.n.n / 2015-06-01
+==================
+
+ * add test coverage link in README.md
+ * Add unit test coverage report on coveralls
+ * create documented properties for available comment data
+ * run flake8 on travis and ask contributors to do so
+ * flake8 fixes in bugsy package
+
+0.4.1 / 2014-05-26
+==================
+
+ * remove unused imports
+ * If we get an error when searching throw a search exception and pass through the error from Bugzilla
+ * add text property to Comment class
+ * add id property to Comment class
+ * Make contributing docs more thorough around creating a virtualenv and testing
+ * Allow authenticating to Bugzilla with a userid and cookie.
+ * When updating a bug that already exists we should use PUT not POST
 
 0.4.0 / 2014-12-05
 ==================
 
  * Add in the ability to use change history fields for searchings
  * allow searching to handle time frames
  * Change UserAgent for bugsy to that Bugzilla knows that it is us calling. Fixes #4
  * Add version added to comments documentation
@@ -24,9 +44,9 @@ 0.2.0 / 2014-06-26
     * Set include_fields to have defaults as used in Bugs object
     * Add the ability to search whiteboard
     * Add the ability to search summary fields
     * Add in the ability to search for bugs assigned to people
 
 0.1.0
 ==============================
 
- * Initial implementation
\ No newline at end of file
+ * Initial implementation
--- a/pylib/Bugsy/README.md
+++ b/pylib/Bugsy/README.md
@@ -1,8 +1,9 @@
 Bugsy
 =====
 [![Build Status](https://travis-ci.org/AutomatedTester/Bugsy.svg?branch=master)](https://travis-ci.org/AutomatedTester/Bugsy)
+[![Coverage Status](https://coveralls.io/repos/AutomatedTester/Bugsy/badge.svg?branch=master)](https://coveralls.io/r/AutomatedTester/Bugsy?branch=master)
 
 
 Bugsy is a library for interacting with the native REST API for Bugzilla.
 
 Documentation can be found on [Read The Docs](http://bugsy.readthedocs.org/en/latest)
--- a/pylib/Bugsy/bugsy/__init__.py
+++ b/pylib/Bugsy/bugsy/__init__.py
@@ -1,3 +1,3 @@
-from bug import Bug, BugException, Comment
-from bugsy import Bugsy, BugsyException, LoginException
-from search import Search
\ No newline at end of file
+from bug import Bug, BugException, Comment  # noqa
+from bugsy import Bugsy, BugsyException, LoginException  # noqa
+from search import Search  # noqa
--- a/pylib/Bugsy/bugsy/bug.py
+++ b/pylib/Bugsy/bugsy/bug.py
@@ -1,18 +1,20 @@
 import datetime
-import requests
 
 
 VALID_STATUS = ["RESOLVED", "ASSIGNED", "NEW", "UNCONFIRMED"]
-VALID_RESOLUTION = ["FIXED", "INCOMPLETE", "INVALID", "WORKSFORME", "DUPLICATE", "WONTFIX"]
+VALID_RESOLUTION = ["FIXED", "INCOMPLETE", "INVALID", "WORKSFORME",
+                    "DUPLICATE", "WONTFIX"]
+
 
 def str2datetime(s):
     return datetime.datetime.strptime(s, '%Y-%m-%dT%H:%M:%SZ')
 
+
 class BugException(Exception):
     """
         If we try do something that is not allowed to a bug then
         this error is raised
     """
     def __init__(self, msg):
         self.msg = msg
 
@@ -87,17 +89,18 @@ class Bug(object):
             >>> bug.status = "REOPENED"
         """
         if self._bug.get('id', None):
             if value in VALID_STATUS:
                 self._bug['status'] = value
             else:
                 raise BugException("Invalid status type was used")
         else:
-            raise BugException("Can not set status unless there is a bug id. Please call Update() before setting")
+            raise BugException("Can not set status unless there is a bug id."
+                               " Please call Update() before setting")
 
     @property
     def OS(self):
         """
             Property for getting or setting the OS that the bug occured on
 
             >>> bug.OS
             "All"
@@ -223,93 +226,151 @@ class Bug(object):
 
             >>> bug.status
             'NEW'
             #Changes happen on Bugzilla
             >>> bug.update()
             >>> bug.status
             'FIXED'
         """
-        if self._bug.has_key('id'):
+        if 'id' in self._bug:
             result = self._bugsy.request('bug/%s' % self._bug['id']).json()
             self._bug = dict(**result['bugs'][0])
         else:
             raise BugException("Unable to update bug that isn't in Bugzilla")
 
     def get_comments(self):
         """
             Obtain comments for this bug.
 
             Returns a list of Comment instances.
         """
         bug = unicode(self._bug['id'])
         res = self._bugsy.request('bug/%s/comment' % bug).json()
 
-        return [Comment(**comments) for comments in res['bugs'][bug]['comments']]
+        return [Comment(**comments) for comments
+                in res['bugs'][bug]['comments']]
 
     def add_comment(self, comment):
         """
-            Adds a comment to a bug. If a bug does not have a bug ID then you need
-            call `put` on the :class:`Bugsy` class.
+            Adds a comment to a bug. If a bug does not have a bug ID then
+            you need call `put` on the :class:`Bugsy` class.
 
             >>> bug.add_comment("I like sausages")
             >>> bugzilla.put(bug)
 
             If it does have a bug id then this will do a post to the server
 
             >>> bug.add_comment("I like eggs too")
         """
-        # If we have a key post immediately otherwise hold onto it until put(bug)
-        # is called
-        if self._bug.has_key('id'):
-            self._bugsy.session.post('%s/bug/%s/comment' % (self._bugsy.bugzilla_url, self._bug['id']), data={"comment": comment}, )
+        # If we have a key post immediately otherwise hold onto it until
+        # put(bug) is called
+        if 'id' in self._bug:
+            self._bugsy.session.post(
+                '%s/bug/%s/comment' % (self._bugsy.bugzilla_url,
+                                       self._bug['id']),
+                data={"comment": comment}
+            )
         else:
             self._bug['comment'] = comment
 
-    def to_dict(self):
-        """
-            Return the raw dict that is used inside this object
-        """
-        return self._bug
-
 
 class Comment(object):
     """
         Represents a single Bugzilla comment.
 
         To get comments you need to do the following
 
         >>> bugs = bugzilla.search_for.keywords("checkin-needed").search()
         >>> comments = bugs[0].get_comments()
-        >>> comments[0].text # Returns the comment 0 of the first checkin-needed bug
+        >>> # Returns the comment 0 of the first checkin-needed bug
+        >>> comments[0].text
     """
 
     def __init__(self, **kwargs):
-
-        self.attachment_id = kwargs['attachment_id']
-        self.author = kwargs['author']
-        self.bug_id = kwargs['bug_id']
-        self.creation_time = str2datetime(kwargs['creation_time'])
-        self.creator = kwargs['creator']
-        self._id = kwargs['id']
-        self.is_private = kwargs['is_private']
-        self._text = kwargs['text']
-        self.time = str2datetime(kwargs['time'])
-
+        kwargs['time'] = str2datetime(kwargs['time'])
+        kwargs['creation_time'] = str2datetime(kwargs['creation_time'])
         if 'tags' in kwargs:
-            self.tags = set(kwargs['tags'])
+            kwargs['tags'] = set(kwargs['tags'])
+        else:
+            kwargs['tags'] = set()
+        self._comment = kwargs
 
     @property
     def text(self):
         r"""
             Return the text that is in this comment
 
             >>> comment.text # David really likes cheese apparently
 
         """
-        return self._text
+        return self._comment['text']
 
     @property
     def id(self):
         r"""
             Return the comment id that is associated with Bugzilla.
         """
-        return self._id
+        return self._comment['id']
+
+    @property
+    def attachment_id(self):
+        """
+            If the comment was made on an attachment, return the ID of that
+            attachment. Otherwise it will return None.
+        """
+        return self._comment['attachment_id']
+
+    @property
+    def author(self):
+        """
+            Return the login name of the comment's author.
+        """
+        return self._comment['author']
+
+    @property
+    def creator(self):
+        """
+            Return the login name of the comment's author.
+        """
+        return self._comment['creator']
+
+    @property
+    def bug_id(self):
+        """
+            Return the ID of the bug that this comment is on.
+        """
+        return self._comment['bug_id']
+
+    @property
+    def time(self):
+        """
+            This is exactly same as :attr:`creation_time`.
+
+            For compatibility, time is still usable. However, please note
+            that time may be deprecated and removed in a future release.
+
+            Prefer :attr:`creation_time` instead.
+        """
+        return self._comment['time']
+
+    @property
+    def creation_time(self):
+        """
+            Return the time (in Bugzilla's timezone) that the comment was
+            added.
+        """
+        return self._comment['creation_time']
+
+    @property
+    def is_private(self):
+        """
+            Return True if this comment is private (only visible to a certain
+            group called the "insidergroup").
+        """
+        return self._comment['is_private']
+
+    @property
+    def tags(self):
+        """
+            Return a set of comment tags currently set for the comment.
+        """
+        return self._comment['tags']
--- a/pylib/Bugsy/bugsy/bugsy.py
+++ b/pylib/Bugsy/bugsy/bugsy.py
@@ -1,154 +1,193 @@
-import json
-
 import requests
 from bug import Bug
 from search import Search
 
 
-
-
 class BugsyException(Exception):
     """
         If while interacting with Bugzilla and we try do something that is not
         supported this error will be raised.
     """
     def __init__(self, msg):
         self.msg = msg
 
     def __str__(self):
         return "Message: %s" % self.msg
 
+
 class LoginException(Exception):
     """
         If a username and password are passed in but we don't receive a token
         then this error will be raised.
     """
     def __init__(self, msg):
         self.msg = msg
 
     def __str__(self):
         return "Message: %s" % self.msg
 
+
 class Bugsy(object):
     """
         Bugsy allows easy getting and putting of Bugzilla bugs
     """
 
     DEFAULT_SEARCH = ['version', 'id', 'summary', 'status', 'op_sys',
-                      'resolution', 'product', 'component', 'platform']
+                      'resolution', 'product', 'component', 'platform',
+                      'whiteboard']
 
     def __init__(
             self,
             username=None,
             password=None,
             userid=None,
             cookie=None,
+            api_key=None,
             bugzilla_url='https://bugzilla.mozilla.org/rest'
     ):
         """
             Initialises a new instance of Bugsy
 
             :param username: Username to login with. Defaults to None
             :param password: Password to login with. Defaults to None
             :param userid: User ID to login with. Defaults to None
             :param cookie: Cookie to login with. Defaults to None
-            :param bugzilla_url: URL endpoint to interact with. Defaults to https://bugzilla.mozilla.org/rest
+            :param apikey: API key to use. Defaults to None.
+            :param bugzilla_url: URL endpoint to interact with. Defaults to
+            https://bugzilla.mozilla.org/rest
 
-            If a username AND password are passed in Bugsy will try get a login token
-            from Bugzilla. If we can't login then a LoginException will
+            If a api_key is passed in, Bugsy will use this for authenticating
+            requests. While not required to perform requests, if a username is
+            passed in along with api_key, we will validate that the api key is
+            valid for this username. Otherwise the api key is blindly used
+            later.
+
+            If a username AND password are passed in Bugsy will try get a login
+            token from Bugzilla. If we can't login then a LoginException will
             be raised.
 
-            If a userid AND cookie are passed in Bugsy will create a login token from them.
+            If a userid AND cookie are passed in Bugsy will create a login
+            token from them.
             If no username was passed in it will then try to get the username
             from Bugzilla.
         """
+        self.api_key = api_key
         self.username = username
         self.password = password
         self.userid = userid
         self.cookie = cookie
         self.bugzilla_url = bugzilla_url
         self.token = None
         self.session = requests.Session()
+        self._have_auth = False
 
-        if self.username and self.password:
-            result = self.request('login',
-                params={'login': username, 'password': password})
+        # Prefer API keys over all other auth methods.
+        if self.api_key:
+            if self.username:
+                result = self.request(
+                    'valid_login',
+                    params={'login': username, 'api_key': api_key}
+                )
+
+                if result.json() is not True:
+                    raise LoginException(result.json()['message'])
+
+            self.session.params['api_key'] = self.api_key
+            self._have_auth = True
+        elif self.username and self.password:
+            result = self.request(
+                'login',
+                params={'login': username, 'password': password}
+            )
             result = result.json()
-            if result.has_key('token'):
+            if 'token' in result:
                 self.session.params['token'] = result['token']
                 self.token = result['token']
             else:
                 raise LoginException(result['message'])
+            self._have_auth = True
         elif self.userid and self.cookie:
             # The token is crafted from the userid and cookie.
             self.token = '%s-%s' % (self.userid, self.cookie)
             self.session.params['token'] = self.token
             if not self.username:
                 result = self.request('user/%s' % self.userid).json()
                 if result.get('users', []):
                     self.username = result['users'][0]['name']
                 else:
                     raise LoginException(result['message'])
 
+            self._have_auth = True
+
     def get(self, bug_number):
         """
-            Get a bug from Bugzilla. If there is a login token created during object initialisation
-            it will be part of the query string passed to Bugzilla
+            Get a bug from Bugzilla. If there is a login token created during
+            object initialisation it will be part of the query string passed to
+            Bugzilla
 
-            :param bug_number: Bug Number that will be searched. If found will return a Bug object.
+            :param bug_number: Bug Number that will be searched. If found will
+                               return a Bug object.
 
             >>> bugzilla = Bugsy()
             >>> bug = bugzilla.get(123456)
         """
-        bug = self.request('bug/%s' % bug_number, params={"include_fields" : self. DEFAULT_SEARCH}).json()
+        bug = self.request(
+            'bug/%s' % bug_number,
+            params={"include_fields": self. DEFAULT_SEARCH}
+        ).json()
         return Bug(self, **bug['bugs'][0])
 
     def put(self, bug):
         """
-            This method allows you to create or update a bug on Bugzilla. You will have had to pass
-            in a valid username and password to the object initialisation and recieved back a token.
+            This method allows you to create or update a bug on Bugzilla. You
+            will have had to pass in a valid username and password to the
+            object initialisation and recieved back a token.
 
             :param bug: A Bug object either created by hand or by using get()
 
             If there is no valid token then a BugsyException will be raised.
-            If the object passed in is not a Bug then a BugsyException will be raised.
+            If the object passed in is not a Bug then a BugsyException will
+            be raised.
 
             >>> bugzilla = Bugsy()
             >>> bug = bugzilla.get(123456)
             >>> bug.summary = "I like cheese and sausages"
             >>> bugzilla.put(bug)
 
         """
-        if not self.token:
-            raise BugsyException("Unfortunately you can't put bugs in Bugzilla without credentials")
+        if not self._have_auth:
+            raise BugsyException("Unfortunately you can't put bugs in Bugzilla"
+                                 " without credentials")
 
         if not isinstance(bug, Bug):
-            raise BugsyException("Please pass in a Bug object when posting to Bugzilla")
+            raise BugsyException("Please pass in a Bug object when posting"
+                                 " to Bugzilla")
 
         if not bug.id:
             result = self.request('bug', 'POST', data=bug.to_dict()).json()
-            if not result.has_key('error'):
+            if 'error' not in result:
                 bug._bug['id'] = result['id']
                 bug._bugsy = self
             else:
                 raise BugsyException(result['message'])
         else:
-            self.session.post('%s/bug/%s' % (self.bugzilla_url, bug.id),
-                data=bug.to_dict())
+            result = self.request('bug/%s' % bug.id, 'PUT',
+                                  data=bug.to_dict()).json()
+            if "error" in result:
+                raise BugsyException(result['message'])
 
     @property
     def search_for(self):
         return Search(self)
 
     def request(self, path, method='GET', **kwargs):
         """Perform a HTTP request.
 
         Given a relative Bugzilla URL path, an optional request method,
         and arguments suitable for requests.Request(), perform a
         HTTP request.
         """
         headers = {"User-Agent": "Bugsy"}
         kwargs['headers'] = headers
         url = '%s/%s' % (self.bugzilla_url, path)
         return self.session.request(method, url, **kwargs)
-
--- a/pylib/Bugsy/bugsy/search.py
+++ b/pylib/Bugsy/bugsy/search.py
@@ -32,87 +32,92 @@ class Search(object):
         self._summaries = []
         self._whiteboard = []
         self._bug_numbers = []
         self._time_frame = {}
         self._change_history = {"fields": []}
 
     def include_fields(self, *args):
         r"""
-            Include fields is the fields that you want to be returned when searching. These
-            are in addition to the fields that are always included below.
+            Include fields is the fields that you want to be returned when
+            searching. These are in addition to the fields that are always
+            included below.
 
             :param args: items passed in will be turned into a list
             :returns: :class:`Search`
 
             >>> bugzilla.search_for.include_fields("flags")
 
             The following fields are always included in search:
                 'version', 'id', 'summary', 'status', 'op_sys',
                 'resolution', 'product', 'component', 'platform'
         """
         for arg in args:
             self._includefields.append(arg)
         return self
 
     def keywords(self, *args):
         r"""
-            When search() is called it will search for the keywords passed in here
+            When search() is called it will search for the keywords passed
+            in here
 
             :param args: items passed in will be turned into a list
             :returns: :class:`Search`
 
             >>> bugzilla.search_for.keywords("checkin-needed")
         """
         self._keywords = list(args)
         return self
 
     def assigned_to(self, *args):
         r"""
-            When search() is called it will search for bugs assigned to these users
+            When search() is called it will search for bugs assigned to these
+            users
 
             :param args: items passed in will be turned into a list
             :returns: :class:`Search`
 
             >>> bugzilla.search_for.assigned_to("dburns@mozilla.com")
         """
         self._assigned = list(args)
         return self
 
     def summary(self, *args):
         r"""
-            When search is called it will search for bugs with the words passed into the
-            methods
+            When search is called it will search for bugs with the words
+            passed into the methods
 
             :param args: items passed in will be turned into a list
             :returns: :class:`Search`
 
             >>> bugzilla.search_for.summary("663399")
         """
         self._summaries = list(args)
         return self
 
     def whiteboard(self, *args):
         r"""
-            When search is called it will search for bugs with the words passed into the
-            methods
+            When search is called it will search for bugs with the words
+            passed into the methods
 
             :param args: items passed in will be turned into a list
             :returns: :class:`Search`
 
             >>> bugzilla.search_for.whiteboard("affects")
         """
         self._whiteboard = list(args)
         return self
 
     def bug_number(self, bug_numbers):
         r"""
-            When you want to search for a bugs and be able to change the fields returned.
+            When you want to search for a bugs and be able to change the fields
+            returned.
 
-            :param bug_numbers: A string for the bug number or a list of strings
+            :param bug_numbers: A string for the bug number or a list of
+                                strings
             :returns: :class:`Search`
 
             >>> bugzilla.search_for.bug_number(['123123', '123456'])
         """
         self._bug_numbers = list(bug_numbers)
         return self
 
     def timeframe(self, start, end):
@@ -139,34 +144,36 @@ class Search(object):
         self._change_history['fields'] = fields
         if value:
             self._change_history['value'] = value
 
         return self
 
     def search(self):
         r"""
-            Call the Bugzilla endpoint that will do the search. It will take the information
-            used in other methods on the Search object and build up the query string. If no
-            bugs are found then an empty list is returned.
+            Call the Bugzilla endpoint that will do the search. It will take
+            the information used in other methods on the Search object and
+            build up the query string. If no bugs are found then an empty list
+            is returned.
 
             >>> bugs = bugzilla.search_for\
             ...                .keywords("checkin-needed")\
             ...                .include_fields("flags")\
             ...                .search()
         """
         params = {}
         params = dict(params.items() + self._time_frame.items())
 
         if self._includefields:
             params['include_fields'] = list(self._includefields)
         if self._bug_numbers:
             bugs = []
             for bug in self._bug_numbers:
-                result = self._bugsy.request('bug/%s' % bug, params=params).json()
+                result = self._bugsy.request('bug/%s' % bug,
+                                             params=params).json()
                 bugs.append(Bug(self._bugsy, **result['bugs'][0]))
 
             return bugs
         else:
             if self._keywords:
                 params['keywords'] = list(self._keywords)
             if self._assigned:
                 params['assigned_to'] = list(self._assigned)
--- a/pylib/Bugsy/requirements.txt
+++ b/pylib/Bugsy/requirements.txt
@@ -1,3 +1,4 @@
 pytest
+flake8
 requests
-responses
\ No newline at end of file
+responses
--- a/pylib/Bugsy/setup.py
+++ b/pylib/Bugsy/setup.py
@@ -1,12 +1,12 @@
 from setuptools import setup, find_packages
 
 setup(name='bugsy',
-      version='0.4.0',
+      version='0.4.1',
       description='A library for interacting Bugzilla Native REST API',
       author='David Burns',
       author_email='david.burns at theautomatedtester dot co dot uk',
       url='http://oss.theautomatedtester.co.uk/bugzilla',
       classifiers=['Development Status :: 3 - Alpha',
                   'Intended Audience :: Developers',
                   'License :: OSI Approved :: Apache Software License',
                   'Operating System :: POSIX',
new file mode 100644
--- /dev/null
+++ b/pylib/Bugsy/tests/__init__.py
@@ -0,0 +1,12 @@
+import urllib
+from bugsy import Bugsy
+
+
+def rest_url(*parts, **kwargs):
+    base = '/'.join(['https://bugzilla.mozilla.org/rest'] +
+                    [str(p) for p in parts])
+    kwargs.setdefault('include_fields', Bugsy.DEFAULT_SEARCH)
+    params = urllib.urlencode(kwargs, True)
+    if params:
+        return '%s?%s' % (base, params)
+    return base
--- a/pylib/Bugsy/tests/test_bugs.py
+++ b/pylib/Bugsy/tests/test_bugs.py
@@ -1,12 +1,13 @@
 import datetime
 import responses
 import json
 
+from . import rest_url
 from bugsy import Bugsy, Bug
 from bugsy import BugException
 
 example_return = {u'faults': [], u'bugs': [{u'cf_tracking_firefox29': u'---', u'classification': u'Other', u'creator': u'jgriffin@mozilla.com', u'cf_status_firefox30':
 u'---', u'depends_on': [], u'cf_status_firefox32': u'---', u'creation_time': u'2014-05-28T23:57:58Z', u'product': u'Release Engineering', u'cf_user_story': u'', u'dupe_of': None, u'cf_tracking_firefox_relnote': u'---', u'keywords': [], u'cf_tracking_b2g18': u'---', u'summary': u'Schedule Mn tests on o\
 pt Linux builds on cedar', u'id': 1017315, u'assigned_to_detail': {u'id': 347295, u'email': u'jgriffin@mozilla.com', u'name': u'jgriffin@mozilla.com',
 u'real_name': u'Jonathan Griffin (:jgriffin)'}, u'severity': u'normal', u'is_confirmed': True, u'is_creator_accessible': True, u'cf_status_b2g_1_1_hd':
  u'---', u'qa_contact_detail': {u'id': 20203, u'email': u'catlee@mozilla.com', u'name': u'catlee@mozilla.com', u'real_name': u'Chris AtLee [:catlee]'},
@@ -122,17 +123,17 @@ def test_we_can_pass_in_dict_and_get_a_b
 
 def test_we_can_get_a_dict_version_of_the_bug():
     bug = Bug(**example_return['bugs'][0])
     result = bug.to_dict()
     assert example_return['bugs'][0]['id'] == result['id']
 
 @responses.activate
 def test_we_can_update_a_bug_from_bugzilla():
-    responses.add(responses.GET, 'https://bugzilla.mozilla.org/rest/bug/1017315?include_fields=version&include_fields=id&include_fields=summary&include_fields=status&include_fields=op_sys&include_fields=resolution&include_fields=product&include_fields=component&include_fields=platform',
+    responses.add(responses.GET, rest_url('bug', 1017315),
                       body=json.dumps(example_return), status=200,
                       content_type='application/json', match_querystring=True)
     bugzilla = Bugsy()
     bug = bugzilla.get(1017315)
     import copy
     bug_dict = copy.deepcopy(example_return)
     bug_dict['bugs'][0]['status'] = "REOPENED"
     responses.reset()
@@ -150,17 +151,17 @@ def test_we_cant_update_unless_we_have_a
         assert str(e) == "Message: Unable to update bug that isn't in Bugzilla"
 
 @responses.activate
 def test_we_can_update_a_bug_with_login_token():
   responses.add(responses.GET, 'https://bugzilla.mozilla.org/rest/login?login=foo&password=bar',
                         body='{"token": "foobar"}', status=200,
                         content_type='application/json', match_querystring=True)
 
-  responses.add(responses.GET, 'https://bugzilla.mozilla.org/rest/bug/1017315?token=foobar&include_fields=version&include_fields=id&include_fields=summary&include_fields=status&include_fields=op_sys&include_fields=resolution&include_fields=product&include_fields=component&include_fields=platform',
+  responses.add(responses.GET, rest_url('bug', 1017315, token='foobar'),
                     body=json.dumps(example_return), status=200,
                     content_type='application/json', match_querystring=True)
   bugzilla = Bugsy("foo", "bar")
   bug = bugzilla.get(1017315)
   import copy
   bug_dict = copy.deepcopy(example_return)
   bug_dict['bugs'][0]['status'] = "REOPENED"
   responses.reset()
@@ -195,17 +196,17 @@ def test_that_we_can_add_a_comment_to_a_
     bugzilla.put(bug)
 
 @responses.activate
 def test_that_we_can_add_a_comment_to_an_existing_bug():
     responses.add(responses.GET, 'https://bugzilla.mozilla.org/rest/login?login=foo&password=bar',
                           body='{"token": "foobar"}', status=200,
                           content_type='application/json', match_querystring=True)
 
-    responses.add(responses.GET, 'https://bugzilla.mozilla.org/rest/bug/1017315?token=foobar&include_fields=version&include_fields=id&include_fields=summary&include_fields=status&include_fields=op_sys&include_fields=resolution&include_fields=product&include_fields=component&include_fields=platform',
+    responses.add(responses.GET, rest_url('bug', 1017315, token='foobar'),
                       body=json.dumps(example_return), status=200,
                       content_type='application/json', match_querystring=True)
     bugzilla = Bugsy("foo", "bar")
     bug = bugzilla.get(1017315)
 
     responses.add(responses.POST, 'https://bugzilla.mozilla.org/rest/bug/1017315/comment?token=foobar',
                       body=json.dumps({}), status=200,
                       content_type='application/json', match_querystring=True)
@@ -214,17 +215,17 @@ def test_that_we_can_add_a_comment_to_an
 
     assert len(responses.calls) == 3
 
 @responses.activate
 def test_comment_retrieval():
     responses.add(responses.GET, 'https://bugzilla.mozilla.org/rest/login?login=foo&password=bar',
                         body='{"token": "foobar"}', status=200,
                         content_type='application/json', match_querystring=True)
-    responses.add(responses.GET, 'https://bugzilla.mozilla.org/rest/bug/1017315?token=foobar&include_fields=version&include_fields=id&include_fields=summary&include_fields=status&include_fields=op_sys&include_fields=resolution&include_fields=product&include_fields=component&include_fields=platform',
+    responses.add(responses.GET, rest_url('bug', 1017315, token='foobar'),
                       body=json.dumps(example_return), status=200,
                       content_type='application/json', match_querystring=True)
     responses.add(responses.GET, 'https://bugzilla.mozilla.org/rest/bug/1017315/comment?token=foobar',
                     body=json.dumps(comments_return), status=200,
                     content_type='application/json', match_querystring=True)
 
     bugzilla = Bugsy("foo", "bar")
     bug = bugzilla.get(1017315)
--- a/pylib/Bugsy/tests/test_bugsy.py
+++ b/pylib/Bugsy/tests/test_bugsy.py
@@ -1,9 +1,10 @@
 import bugsy
+from . import rest_url
 from bugsy import Bugsy, BugsyException, LoginException
 from bugsy import Bug
 
 import responses
 import json
 
 example_return = {u'faults': [], u'bugs': [{u'cf_tracking_firefox29': u'---', u'classification': u'Other', u'creator': u'jgriffin@mozilla.com', u'cf_status_firefox30':
 u'---', u'depends_on': [], u'cf_status_firefox32': u'---', u'creation_time': u'2014-05-28T23:57:58Z', u'product': u'Release Engineering', u'cf_user_story': u'', u'dupe_of': None, u'cf_tracking_firefox_relnote': u'---', u'keywords': [], u'cf_tracking_b2g18': u'---', u'summary': u'Schedule Mn tests on o\
@@ -38,45 +39,66 @@ def test_we_get_a_login_exception_when_d
                       content_type='application/json', match_querystring=True)
     try:
         Bugsy("foo", "bar")
         assert 1 == 0, "Should have thrown an error"
     except LoginException as e:
         assert str(e) == "Message: The username or password you entered is not valid."
 
 @responses.activate
+def test_bad_api_key():
+    responses.add(responses.GET,
+                  'https://bugzilla.mozilla.org/rest/valid_login?login=foo&api_key=badkey',
+                  body='{"documentation":"http://www.bugzilla.org/docs/tip/en/html/api/","error":true,"code":306,"message":"The API key you specified is invalid. Please check that you typed it correctly."}',
+                  status=400,
+                  content_type='application/json', match_querystring=True)
+    try:
+        Bugsy(username='foo', api_key='badkey')
+        assert False, 'Should have thrown'
+    except LoginException as e:
+        assert str(e) == 'Message: The API key you specified is invalid. Please check that you typed it correctly.'
+
+@responses.activate
+def test_validate_api_key():
+    responses.add(responses.GET,
+                  'https://bugzilla.mozilla.org/rest/valid_login?login=foo&api_key=goodkey',
+                  body='true', status=200, content_type='application/json',
+                  match_querystring=True)
+    Bugsy(username='foo', api_key='goodkey')
+
+@responses.activate
 def test_we_cant_post_without_passing_a_bug_object():
     responses.add(responses.GET, 'https://bugzilla.mozilla.org/rest/login?login=foo&password=bar',
                       body='{"token": "foobar"}', status=200,
                       content_type='application/json', match_querystring=True)
     bugzilla = Bugsy("foo", "bar")
     try:
         bugzilla.put("foo")
         assert 1 == 0, "Should have thrown an error about type when calling put"
     except BugsyException as e:
         assert str(e) == "Message: Please pass in a Bug object when posting to Bugzilla"
 
 @responses.activate
 def test_we_can_get_a_bug():
-    responses.add(responses.GET, 'https://bugzilla.mozilla.org/rest/bug/1017315?include_fields=version&include_fields=id&include_fields=summary&include_fields=status&include_fields=op_sys&include_fields=resolution&include_fields=product&include_fields=component&include_fields=platform',
+    responses.add(responses.GET, rest_url('bug', 1017315),
                       body=json.dumps(example_return), status=200,
                       content_type='application/json', match_querystring=True)
     bugzilla = Bugsy()
     bug = bugzilla.get(1017315)
     assert bug.id == 1017315
     assert bug.status == 'RESOLVED'
     assert bug.summary == 'Schedule Mn tests on opt Linux builds on cedar'
 
 @responses.activate
 def test_we_can_get_a_bug_with_login_token():
   responses.add(responses.GET, 'https://bugzilla.mozilla.org/rest/login?login=foo&password=bar',
                         body='{"token": "foobar"}', status=200,
                         content_type='application/json', match_querystring=True)
 
-  responses.add(responses.GET, 'https://bugzilla.mozilla.org/rest/bug/1017315?token=foobar&include_fields=version&include_fields=id&include_fields=summary&include_fields=status&include_fields=op_sys&include_fields=resolution&include_fields=product&include_fields=component&include_fields=platform',
+  responses.add(responses.GET, rest_url('bug', 1017315, token='foobar'),
                     body=json.dumps(example_return), status=200,
                     content_type='application/json', match_querystring=True)
   bugzilla = Bugsy("foo", "bar")
   bug = bugzilla.get(1017315)
   assert bug.id == 1017315
   assert bug.status == 'RESOLVED'
   assert bug.summary == 'Schedule Mn tests on opt Linux builds on cedar'
 
@@ -107,17 +129,17 @@ def test_we_can_create_a_new_remote_bug(
 
 @responses.activate
 def test_we_can_put_a_current_bug():
     responses.add(responses.GET, 'https://bugzilla.mozilla.org/rest/login?login=foo&password=bar',
                       body='{"token": "foobar"}', status=200,
                       content_type='application/json', match_querystring=True)
     bug_dict = example_return.copy()
     bug_dict['summary'] = 'I love foo but hate bar'
-    responses.add(responses.POST, 'https://bugzilla.mozilla.org/rest/bug/1017315',
+    responses.add(responses.PUT, 'https://bugzilla.mozilla.org/rest/bug/1017315',
                       body=json.dumps(bug_dict), status=200,
                       content_type='application/json')
     bugzilla = Bugsy("foo", "bar")
     bug = Bug(**example_return['bugs'][0])
     bug.summary = 'I love foo but hate bar'
 
 
     bugzilla.put(bug)
@@ -140,17 +162,17 @@ def test_we_handle_errors_from_bugzilla_
   except BugsyException as e:
       assert str(e) == "Message: You must select/enter a component."
 
 @responses.activate
 def test_we_handle_errors_from_bugzilla_when_updating_a_bug():
   responses.add(responses.GET, 'https://bugzilla.mozilla.org/rest/login?login=foo&password=bar',
                     body='{"token": "foobar"}', status=200,
                     content_type='application/json', match_querystring=True)
-  responses.add(responses.POST, 'https://bugzilla.mozilla.org/rest/bug/1017315',
+  responses.add(responses.PUT, 'https://bugzilla.mozilla.org/rest/bug/1017315',
                     body='{"error":true,"code":50,"message":"You must select/enter a component."}', status=200,
                     content_type='application/json')
   bugzilla = Bugsy("foo", "bar")
 
   bug_dict = example_return.copy()
   bug_dict['summary'] = 'I love foo but hate bar'
   bug = Bug(**bug_dict['bugs'][0])
   try:
@@ -160,10 +182,8 @@ def test_we_handle_errors_from_bugzilla_
 
 @responses.activate
 def test_we_can_set_the_user_agent_to_bugsy():
   responses.add(responses.GET, 'https://bugzilla.mozilla.org/rest/login?login=foo&password=bar',
                     body='{"token": "foobar"}', status=200,
                     content_type='application/json', match_querystring=True)
   Bugsy("foo", "bar")
   assert responses.calls[0].request.headers['User-Agent'] == "Bugsy"
-
-
--- a/pylib/Bugsy/tests/test_search.py
+++ b/pylib/Bugsy/tests/test_search.py
@@ -1,9 +1,10 @@
 import bugsy
+from . import rest_url
 from bugsy import Bugsy, BugsyException, LoginException
 from bugsy import Bug
 from bugsy.search import SearchException
 
 import responses
 import json
 
 @responses.activate
@@ -43,17 +44,23 @@ def test_we_only_ask_for_the_include_fie
                "resolution" : "",
                "status" : "NEW",
                "summary" : "Marionette thinks that the play button in the music app is not displayed",
                "version" : "unspecified"
             }
          ]
       }
 
-  responses.add(responses.GET, 'https://bugzilla.mozilla.org/rest/bug?assigned_to=dburns@mozilla.com&whiteboard=affects&short_desc_type=allwordssubstr&include_fields=version&include_fields=id&include_fields=summary&include_fields=status&include_fields=op_sys&include_fields=resolution&include_fields=product&include_fields=component&include_fields=platform&include_fields=flags',
+  url_params = dict(
+    assigned_to='dburns@mozilla.com',
+    whiteboard='affects',
+    short_desc_type='allwordssubstr',
+    include_fields=Bugsy.DEFAULT_SEARCH + ['flags'],
+  )
+  responses.add(responses.GET, rest_url('bug', **url_params),
                     body=json.dumps(include_return), status=200,
                     content_type='application/json', match_querystring=True)
 
   bugzilla = Bugsy()
   bugs = bugzilla.search_for\
           .include_fields('flags')\
           .assigned_to("dburns@mozilla.com")\
           .whiteboard("affects")\
@@ -103,17 +110,21 @@ def test_we_only_ask_for_the_include_fie
                "version" : "unspecified"
             }
          ]
       }
   responses.add(responses.GET, 'https://bugzilla.mozilla.org/rest/login?login=foo&password=bar',
                     body='{"token": "foobar"}', status=200,
                     content_type='application/json', match_querystring=True)
 
-  responses.add(responses.GET, 'https://bugzilla.mozilla.org/rest/bug?include_fields=version&include_fields=id&include_fields=summary&include_fields=status&include_fields=op_sys&include_fields=resolution&include_fields=product&include_fields=component&include_fields=platform&include_fields=flags&token=foobar',
+  url_params = dict(
+    token='foobar',
+    include_fields=Bugsy.DEFAULT_SEARCH + ['flags'],
+  )
+  responses.add(responses.GET, rest_url('bug', **url_params),
                     body=json.dumps(include_return), status=200,
                     content_type='application/json', match_querystring=True)
 
   bugzilla = Bugsy('foo', 'bar')
   bugs = bugzilla.search_for\
           .include_fields('flags')\
           .search()
 
@@ -147,17 +158,17 @@ def test_we_can_return_keyword_search():
       },
       {
          "component" : "Message Reader UI",
          "product" : "Thunderbird",
          "summary" : "Make \"visited link\" coloring work in thunderbird"
       }]
     }
 
-    responses.add(responses.GET, 'https://bugzilla.mozilla.org/rest/bug?include_fields=version&include_fields=id&include_fields=summary&include_fields=status&include_fields=op_sys&include_fields=resolution&include_fields=product&include_fields=component&include_fields=platform&keywords=checkin-needed&',
+    responses.add(responses.GET, rest_url('bug', keywords='checkin-needed'),
                     body=json.dumps(keyword_return), status=200,
                     content_type='application/json', match_querystring=True)
 
     bugzilla = Bugsy()
     bugs = bugzilla.search_for\
             .keywords('checkin-needed')\
             .search()
 
@@ -183,17 +194,17 @@ def test_that_we_can_search_for_a_specif
                "summary" : "Add a name for AMO Themes sort links for testability"
             },
             {
                "product" : "addons.mozilla.org",
                "summary" : "Missing ID for div with class \"feature ryff\" (Mobile Add-on: Foursquare)"
             }
            ]
         }
-    responses.add(responses.GET, 'https://bugzilla.mozilla.org/rest/bug?include_fields=version&include_fields=id&include_fields=summary&include_fields=status&include_fields=op_sys&include_fields=resolution&include_fields=product&include_fields=component&include_fields=platform&assigned_to=dburns@mozilla.com&',
+    responses.add(responses.GET, rest_url('bug', assigned_to='dburns@mozilla.com'),
                     body=json.dumps(user_return), status=200,
                     content_type='application/json', match_querystring=True)
 
     bugzilla = Bugsy()
     bugs = bugzilla.search_for\
             .assigned_to('dburns@mozilla.com')\
             .search()
 
@@ -209,18 +220,22 @@ def test_we_can_search_summary_fields():
         {
            "component" : "CSS Parsing and Computation",
            "product" : "Core",
            "summary" : "Map \"rebeccapurple\" to #663399 in named color list."
         }
       ]
     }
 
-
-    responses.add(responses.GET, 'https://bugzilla.mozilla.org/rest/bug?assigned_to=dburns@mozilla.com&short_desc=rebecca&short_desc_type=allwordssubstr&include_fields=version&include_fields=id&include_fields=summary&include_fields=status&include_fields=op_sys&include_fields=resolution&include_fields=product&include_fields=component&include_fields=platform&',
+    url_params = dict(
+        assigned_to='dburns@mozilla.com',
+        short_desc='rebecca',
+        short_desc_type='allwordssubstr',
+    )
+    responses.add(responses.GET, rest_url('bug', **url_params),
                     body=json.dumps(summary_return), status=200,
                     content_type='application/json', match_querystring=True)
 
     bugzilla = Bugsy()
     bugs = bugzilla.search_for\
             .assigned_to('dburns@mozilla.com')\
             .summary("rebecca")\
             .search()
@@ -243,18 +258,22 @@ def test_we_can_search_whiteboard_fields
           {
              "component" : "Marionette",
              "product" : "Testing",
              "summary" : "Marionette thinks that the play button in the music app is not displayed"
           }
        ]
     }
 
-
-    responses.add(responses.GET, 'https://bugzilla.mozilla.org/rest/bug?assigned_to=dburns@mozilla.com&whiteboard=affects&short_desc_type=allwordssubstr&include_fields=version&include_fields=id&include_fields=summary&include_fields=status&include_fields=op_sys&include_fields=resolution&include_fields=product&include_fields=component&include_fields=platform&',
+    url_params = dict(
+        assigned_to='dburns@mozilla.com',
+        whiteboard='affects',
+        short_desc_type='allwordssubstr',
+    )
+    responses.add(responses.GET, rest_url('bug', **url_params),
                     body=json.dumps(whiteboard_return), status=200,
                     content_type='application/json', match_querystring=True)
 
     bugzilla = Bugsy()
     bugs = bugzilla.search_for\
             .assigned_to('dburns@mozilla.com')\
             .whiteboard("affects")\
             .search()
@@ -280,21 +299,21 @@ def test_we_can_search_for_a_list_of_bug
      "bugs" : [
         {
            "component" : "Marionette",
            "product" : "Testing",
            "summary" : "Marionette thinks that the play button in the music app is not displayed"
         }
       ]
     }
-    responses.add(responses.GET, 'https://bugzilla.mozilla.org/rest/bug/1017315?include_fields=version&include_fields=id&include_fields=summary&include_fields=status&include_fields=op_sys&include_fields=resolution&include_fields=product&include_fields=component&include_fields=platform',
+    responses.add(responses.GET, rest_url('bug', 1017315),
                       body=json.dumps(return_1), status=200,
                       content_type='application/json', match_querystring=True)
 
-    responses.add(responses.GET, 'https://bugzilla.mozilla.org/rest/bug/1017316?include_fields=version&include_fields=id&include_fields=summary&include_fields=status&include_fields=op_sys&include_fields=resolution&include_fields=product&include_fields=component&include_fields=platform',
+    responses.add(responses.GET, rest_url('bug', 1017316),
                       body=json.dumps(return_2), status=200,
                       content_type='application/json', match_querystring=True)
     bugzilla = Bugsy()
     bugs = bugzilla.search_for\
             .bug_number(['1017315', '1017316'])\
             .search()
 
     assert len(responses.calls) == 2
@@ -309,17 +328,21 @@ def test_we_can_search_for_a_list_of_bug
         {
            "component" : "CSS Parsing and Computation",
            "product" : "Core",
            "summary" : "Map \"rebeccapurple\" to #663399 in named color list."
         }
       ]
     }
 
-    responses.add(responses.GET, 'https://bugzilla.mozilla.org/rest/bug?chfieldfrom=2014-12-01&chfieldto=2014-12-05&include_fields=version&include_fields=id&include_fields=summary&include_fields=status&include_fields=op_sys&include_fields=resolution&include_fields=product&include_fields=component&include_fields=platform',
+    url_params = dict(
+        chfieldfrom='2014-12-01',
+        chfieldto='2014-12-05',
+    )
+    responses.add(responses.GET, rest_url('bug', **url_params),
                       body=json.dumps(return_1), status=200,
                       content_type='application/json', match_querystring=True)
 
     bugzilla = Bugsy()
     bugs = bugzilla.search_for\
             .timeframe('2014-12-01', '2014-12-05')\
             .search()
 
@@ -362,17 +385,23 @@ def test_we_can_search_with_change_histo
         {
            "component" : "CSS Parsing and Computation",
            "product" : "Core",
            "summary" : "Map \"rebeccapurple\" to #663399 in named color list."
         }
       ]
     }
 
-    responses.add(responses.GET, 'https://bugzilla.mozilla.org/rest/bug?chfield=%5BBug+creation%5D&chfield=Alias&chfieldvalue=foo&chfieldfrom=2014-12-01&chfieldto=2014-12-05&include_fields=version&include_fields=id&include_fields=summary&include_fields=status&include_fields=op_sys&include_fields=resolution&include_fields=product&include_fields=component&include_fields=platform',
+    url_params = dict(
+        chfield=['[Bug creation]', 'Alias'],
+        chfieldvalue='foo',
+        chfieldfrom='2014-12-01',
+        chfieldto='2014-12-05',
+    )
+    responses.add(responses.GET, rest_url('bug', **url_params),
                       body=json.dumps(return_1), status=200,
                       content_type='application/json', match_querystring=True)
 
     bugzilla = Bugsy()
     bugs = bugzilla.search_for\
             .change_history_fields(['[Bug creation]', 'Alias'], 'foo')\
             .timeframe('2014-12-01', '2014-12-05')\
             .search()
@@ -386,20 +415,26 @@ def test_we_can_search_with_change_histo
 def test_we_can_handle_errors_coming_back_from_search():
     error_return = {
         "code" : 108,
         "documentation" : "http://www.bugzilla.org/docs/tip/en/html/api/",
         "error" : True,
         "message" : "Can't use [Bug Creation] as a field name."
     }
 
-    responses.add(responses.GET, 'https://bugzilla.mozilla.org/rest/bug?chfield=%5BBug+Creation%5D&chfield=Alias&chfieldvalue=foo&chfieldfrom=2014-12-01&chfieldto=2014-12-05&include_fields=version&include_fields=id&include_fields=summary&include_fields=status&include_fields=op_sys&include_fields=resolution&include_fields=product&include_fields=component&include_fields=platform',
+    url_params = dict(
+        chfield=['[Bug Creation]', 'Alias'],
+        chfieldvalue='foo',
+        chfieldfrom='2014-12-01',
+        chfieldto='2014-12-05',
+    )
+    responses.add(responses.GET, rest_url('bug', **url_params),
                       body=json.dumps(error_return), status=200,
                       content_type='application/json', match_querystring=True)
 
     bugzilla = Bugsy()
     try:
         bugzilla.search_for\
                 .change_history_fields(['[Bug Creation]', 'Alias'], 'foo')\
                 .timeframe('2014-12-01', '2014-12-05')\
                 .search()
     except SearchException as e:
-        assert str(e) == "Can't use [Bug Creation] as a field name."
\ No newline at end of file
+        assert str(e) == "Can't use [Bug Creation] as a field name."