From d202b97cb67f816ee7b82f6850e8a36bf65babf9 Mon Sep 17 00:00:00 2001 From: David Pursehouse Date: Thu, 29 May 2014 17:08:22 +0900 Subject: Revert "Remove support for Gerrit over ssh" The ssh interface is still being used and patches are being contributed [1]. It's easier to revert the removal on master than attempt to keep the support in a separate branch. This reverts commit f0b77968389966cd7bad0cac1fe7f04526eafde1. [1] https://github.com/sonyxperiadev/pygerrit/pull/21 Conflicts: README.rst example.py requirements.txt setup.py unittests.py Change-Id: I9e02208c57cb2022ff39cff6650101ed40c0519d --- README.rst | 73 ++++++-- example.py | 113 ++++++------ pygerrit/auth.py | 56 ------ pygerrit/client.py | 136 +++++++++++++++ pygerrit/error.py | 31 ++++ pygerrit/events.py | 331 ++++++++++++++++++++++++++++++++++++ pygerrit/models.py | 157 +++++++++++++++++ pygerrit/rest.py | 156 ----------------- pygerrit/rest/__init__.py | 156 +++++++++++++++++ pygerrit/rest/auth.py | 56 ++++++ pygerrit/ssh.py | 178 +++++++++++++++++++ pygerrit/stream.py | 77 +++++++++ requirements.txt | 2 + rest_example.py | 110 ++++++++++++ testdata/change-abandoned-event.txt | 13 ++ testdata/change-merged-event.txt | 18 ++ testdata/change-restored-event.txt | 13 ++ testdata/comment-added-event.txt | 25 +++ testdata/draft-published-event.txt | 18 ++ testdata/invalid-json.txt | 4 + testdata/merge-failed-event.txt | 19 +++ testdata/patchset-created-event.txt | 18 ++ testdata/ref-updated-event.txt | 7 + testdata/reviewer-added-event.txt | 18 ++ testdata/topic-changed-event.txt | 13 ++ testdata/unhandled-event.txt | 3 + testdata/user-defined-event.txt | 3 + unittests.py | 289 ++++++++++++++++++++++++++++++- 28 files changed, 1815 insertions(+), 278 deletions(-) delete mode 100644 pygerrit/auth.py create mode 100644 pygerrit/client.py create mode 100644 pygerrit/error.py create mode 100644 pygerrit/events.py create mode 100644 pygerrit/models.py delete mode 100644 pygerrit/rest.py create mode 100644 pygerrit/rest/__init__.py create mode 100644 pygerrit/rest/auth.py create mode 100644 pygerrit/ssh.py create mode 100644 pygerrit/stream.py create mode 100755 rest_example.py create mode 100644 testdata/change-abandoned-event.txt create mode 100644 testdata/change-merged-event.txt create mode 100644 testdata/change-restored-event.txt create mode 100644 testdata/comment-added-event.txt create mode 100644 testdata/draft-published-event.txt create mode 100644 testdata/invalid-json.txt create mode 100644 testdata/merge-failed-event.txt create mode 100644 testdata/patchset-created-event.txt create mode 100644 testdata/ref-updated-event.txt create mode 100644 testdata/reviewer-added-event.txt create mode 100644 testdata/topic-changed-event.txt create mode 100644 testdata/unhandled-event.txt create mode 100644 testdata/user-defined-event.txt diff --git a/README.rst b/README.rst index 767cd1d..7d070cb 100644 --- a/README.rst +++ b/README.rst @@ -7,12 +7,8 @@ Pygerrit - Client library for interacting with Gerrit Code Review .. image:: https://pypip.in/d/pygerrit/badge.png :target: https://crate.io/packages/pygerrit/ -`Gerrit Code Review`_ offers a feature-rich REST API. Pygerrit provides a -simple interface for clients to interact with Gerrit via the REST API. - -Note that from version 0.3.0 Pygerrit no longer includes support for the Gerrit -ssh interface. For ssh support please use version 0.2.5 or earlier. - +Pygerrit provides a simple interface for clients to interact with +`Gerrit Code Review`_ via ssh or the REST API. Prerequisites ------------- @@ -20,7 +16,7 @@ Prerequisites Pygerrit has been tested on Ubuntu 10.4 and Mac OSX 10.8.4, with Python 2.6.x and 2.7.x. Support for other platforms and Python versions is not guaranteed. -Pygerrit depends on the `requests`_ library. +Pygerrit depends on the `paramiko_` and `requests`_ libraries. Installation @@ -34,6 +30,16 @@ To install pygerrit, simply:: Configuration ------------- +For easier connection to the review server over ssh, the ssh connection +parameters (hostname, port, username) can be given in the user's ``.ssh/config`` +file:: + + Host review + HostName review.example.net + Port 29418 + User username + + For easier connection to the review server over the REST API, the user's HTTP username and password can be given in the user's ``.netrc`` file:: @@ -44,8 +50,53 @@ For instructions on how to obtain the HTTP password, refer to Gerrit's `HTTP upload settings`_ documentation. -Usage ------ +SSH Interface +------------- + +The SSH interface can be used to run commands on the Gerrit server:: + + >>> from pygerrit.ssh import GerritSSHClient + >>> client = GerritSSHClient("review") + >>> result = client.run_gerrit_command("version") + >>> result + + >>> result.stdout + >> + >>> result.stdout.read() + 'gerrit version 2.6.1\n' + >>> + +Event Stream +------------ + +Gerrit offers a ``stream-events`` command that is run over ssh, and returns back +a stream of events (new change uploaded, change merged, comment added, etc) as +JSON text. + +This library handles the parsing of the JSON text from the event stream, +encapsulating the data in event objects (Python classes), and allowing the +client to fetch them from a queue. It also allows users to easily add handling +of custom event types, for example if they are running a customised Gerrit +installation with non-standard events:: + + >>> from pygerrit.client import GerritClient + >>> client = GerritClient("review") + >>> client.gerrit_version() + '2.6.1' + >>> client.start_event_stream() + >>> client.get_event() + : + >>> client.get_event() + : + >>> client.stop_event_stream() + >>> + + +Refer to the `example`_ script for a more detailed example of how the SSH +event stream interface works. + +REST API +-------- This simple example shows how to get the user's open changes, authenticating to Gerrit via HTTP Digest authentication using an explicitly given username and @@ -58,7 +109,7 @@ password:: >>> changes = rest.get("/changes/?q=owner:self%20status:open") -Refer to the `example`_ script for a more detailed example of how the +Refer to the `rest_example`_ script for a more detailed example of how the REST API interface works. @@ -74,6 +125,8 @@ license details. .. _`Gerrit Code Review`: https://code.google.com/p/gerrit/ .. _`requests`: https://github.com/kennethreitz/requests +.. _`paramiko`: https://github.com/paramiko/paramiko .. _example: https://github.com/sonyxperiadev/pygerrit/blob/master/example.py +.. _rest_example: https://github.com/sonyxperiadev/pygerrit/blob/master/rest_example.py .. _`HTTP upload settings`: https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/user-upload.html#http .. _LICENSE: https://github.com/sonyxperiadev/pygerrit/blob/master/LICENSE diff --git a/example.py b/example.py index f53820a..99fe940 100755 --- a/example.py +++ b/example.py @@ -3,7 +3,7 @@ # The MIT License # -# Copyright 2013 Sony Mobile Communications. All rights reserved. +# Copyright 2012 Sony Mobile Communications. All rights reserved. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -23,86 +23,89 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -""" Example of using the Gerrit client REST API. """ +""" Example of using the Gerrit client class. """ import argparse import logging import sys +from threading import Event +import time -from requests.auth import HTTPBasicAuth, HTTPDigestAuth -from requests.exceptions import RequestException -try: - from requests_kerberos import HTTPKerberosAuth, OPTIONAL - _kerberos_support = True -except ImportError: - _kerberos_support = False - -from pygerrit.rest import GerritRestAPI -from pygerrit.auth import HTTPDigestAuthFromNetrc, HTTPBasicAuthFromNetrc +from pygerrit.client import GerritClient +from pygerrit.error import GerritError +from pygerrit.events import ErrorEvent def _main(): - descr = 'Send request using Gerrit HTTP API' + descr = 'Send request using Gerrit ssh API' parser = argparse.ArgumentParser( description=descr, formatter_class=argparse.ArgumentDefaultsHelpFormatter) - parser.add_argument('-g', '--gerrit-url', dest='gerrit_url', - required=True, - help='gerrit server url') - parser.add_argument('-b', '--basic-auth', dest='basic_auth', - action='store_true', - help='use basic auth instead of digest') - if _kerberos_support: - parser.add_argument('-k', '--kerberos-auth', dest='kerberos_auth', - action='store_true', - help='use kerberos auth') + parser.add_argument('-g', '--gerrit-hostname', dest='hostname', + default='review', + help='gerrit server hostname') + parser.add_argument('-p', '--port', dest='port', + type=int, default=29418, + help='port number') parser.add_argument('-u', '--username', dest='username', help='username') - parser.add_argument('-p', '--password', dest='password', - help='password') - parser.add_argument('-n', '--netrc', dest='netrc', + parser.add_argument('-b', '--blocking', dest='blocking', action='store_true', - help='Use credentials from netrc') + help='block on event get') + parser.add_argument('-t', '--timeout', dest='timeout', + default=None, type=int, + metavar='SECONDS', + help='timeout for blocking event get') parser.add_argument('-v', '--verbose', dest='verbose', action='store_true', help='enable verbose (debug) logging') + parser.add_argument('-i', '--ignore-stream-errors', dest='ignore', + action='store_true', + help='do not exit when an error event is received') options = parser.parse_args() + if options.timeout and not options.blocking: + parser.error('Can only use --timeout with --blocking') level = logging.DEBUG if options.verbose else logging.INFO logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s', level=level) - if _kerberos_support and options.kerberos_auth: - if options.username or options.password \ - or options.basic_auth or options.netrc: - parser.error("--kerberos-auth may not be used together with " - "--username, --password, --basic-auth or --netrc") - auth = HTTPKerberosAuth(mutual_authentication=OPTIONAL) - elif options.username and options.password: - if options.netrc: - logging.warning("--netrc option ignored") - if options.basic_auth: - auth = HTTPBasicAuth(options.username, options.password) - else: - auth = HTTPDigestAuth(options.username, options.password) - elif options.netrc: - if options.basic_auth: - auth = HTTPBasicAuthFromNetrc(url=options.gerrit_url) - else: - auth = HTTPDigestAuthFromNetrc(url=options.gerrit_url) - else: - auth = None - - rest = GerritRestAPI(url=options.gerrit_url, auth=auth) + try: + gerrit = GerritClient(host=options.hostname, + username=options.username, + port=options.port) + logging.info("Connected to Gerrit version [%s]", + gerrit.gerrit_version()) + gerrit.start_event_stream() + except GerritError as err: + logging.error("Gerrit error: %s", err) + return 1 + errors = Event() try: - changes = rest.get("/changes/?q=owner:self%20status:open") - logging.info("%d changes", len(changes)) - for change in changes: - logging.info(change['change_id']) - except RequestException as err: - logging.error("Error: %s", str(err)) + while True: + event = gerrit.get_event(block=options.blocking, + timeout=options.timeout) + if event: + logging.info("Event: %s", event) + if isinstance(event, ErrorEvent) and not options.ignore: + logging.error(event.error) + errors.set() + break + else: + logging.info("No event") + if not options.blocking: + time.sleep(1) + except KeyboardInterrupt: + logging.info("Terminated by user") + finally: + logging.debug("Stopping event stream...") + gerrit.stop_event_stream() + + if errors.isSet(): + logging.error("Exited with error") + return 1 if __name__ == "__main__": sys.exit(_main()) diff --git a/pygerrit/auth.py b/pygerrit/auth.py deleted file mode 100644 index c43c3fa..0000000 --- a/pygerrit/auth.py +++ /dev/null @@ -1,56 +0,0 @@ -# The MIT License -# -# Copyright 2013 Sony Mobile Communications. All rights reserved. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - -""" Authentication classes. """ - -from requests.auth import HTTPDigestAuth, HTTPBasicAuth -from requests.utils import get_netrc_auth - - -class HTTPDigestAuthFromNetrc(HTTPDigestAuth): - - """ HTTP Digest Auth with netrc credentials. """ - - def __init__(self, url): - auth = get_netrc_auth(url) - if not auth: - raise ValueError("netrc missing or no credentials found in netrc") - username, password = auth - super(HTTPDigestAuthFromNetrc, self).__init__(username, password) - - def __call__(self, req): - return super(HTTPDigestAuthFromNetrc, self).__call__(req) - - -class HTTPBasicAuthFromNetrc(HTTPBasicAuth): - - """ HTTP Basic Auth with netrc credentials. """ - - def __init__(self, url): - auth = get_netrc_auth(url) - if not auth: - raise ValueError("netrc missing or no credentials found in netrc") - username, password = auth - super(HTTPBasicAuthFromNetrc, self).__init__(username, password) - - def __call__(self, req): - return super(HTTPBasicAuthFromNetrc, self).__call__(req) diff --git a/pygerrit/client.py b/pygerrit/client.py new file mode 100644 index 0000000..4095914 --- /dev/null +++ b/pygerrit/client.py @@ -0,0 +1,136 @@ +# The MIT License +# +# Copyright 2012 Sony Mobile Communications. All rights reserved. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +""" Gerrit client interface. """ + +from json import JSONDecoder +from Queue import Queue, Empty, Full + +from . import escape_string +from .error import GerritError +from .events import GerritEventFactory +from .models import Change +from .ssh import GerritSSHClient +from .stream import GerritStream + + +class GerritClient(object): + + """ Gerrit client interface. """ + + def __init__(self, host, username=None, port=None): + self._factory = GerritEventFactory() + self._events = Queue() + self._stream = None + self._ssh_client = GerritSSHClient(host, username=username, port=port) + + def gerrit_version(self): + """ Return the version of Gerrit that is connected to. """ + return self._ssh_client.get_remote_version() + + def gerrit_info(self): + """ Return the username, and version of Gerrit that is connected to. """ + return self._ssh_client.get_remote_info() + + def run_command(self, command): + """ Run the command. Return the result. """ + if not isinstance(command, basestring): + raise ValueError("command must be a string") + return self._ssh_client.run_gerrit_command(command) + + def query(self, term): + """ Run `gerrit query` with the given `term`. + + Return a list of results as `Change` objects. + + Raise `ValueError` if `term` is not a string. + + """ + results = [] + command = ["query", "--current-patch-set", "--all-approvals", + "--format JSON", "--commit-message"] + + if not isinstance(term, basestring): + raise ValueError("term must be a string") + + command.append(escape_string(term)) + result = self._ssh_client.run_gerrit_command(" ".join(command)) + decoder = JSONDecoder() + for line in result.stdout.read().splitlines(): + # Gerrit's response to the query command contains one or more + # lines of JSON-encoded strings. The last one is a status + # dictionary containing the key "type" whose value indicates + # whether or not the operation was successful. + # According to http://goo.gl/h13HD it should be safe to use the + # presence of the "type" key to determine whether the dictionary + # represents a change or if it's the query status indicator. + try: + data = decoder.decode(line) + except ValueError as err: + raise GerritError("Query returned invalid data: %s", err) + if "type" in data and data["type"] == "error": + raise GerritError("Query error: %s" % data["message"]) + elif "project" in data: + results.append(Change(data)) + return results + + def start_event_stream(self): + """ Start streaming events from `gerrit stream-events`. """ + if not self._stream: + self._stream = GerritStream(self, ssh_client=self._ssh_client) + self._stream.start() + + def stop_event_stream(self): + """ Stop streaming events from `gerrit stream-events`.""" + if self._stream: + self._stream.stop() + self._stream.join() + self._stream = None + with self._events.mutex: + self._events.queue.clear() + + def get_event(self, block=True, timeout=None): + """ Get the next event from the queue. + + Return a `GerritEvent` instance, or None if: + - `block` is False and there is no event available in the queue, or + - `block` is True and no event is available within the time + specified by `timeout`. + + """ + try: + return self._events.get(block, timeout) + except Empty: + return None + + def put_event(self, data): + """ Create event from `data` and add it to the queue. + + Raise GerritError if the queue is full, or the factory could not + create the event. + + """ + try: + event = self._factory.create(data) + self._events.put(event) + except Full: + raise GerritError("Unable to add event: queue is full") diff --git a/pygerrit/error.py b/pygerrit/error.py new file mode 100644 index 0000000..b500812 --- /dev/null +++ b/pygerrit/error.py @@ -0,0 +1,31 @@ +# The MIT License +# +# Copyright 2011 Sony Ericsson Mobile Communications. All rights reserved. +# Copyright 2012 Sony Mobile Communications. All rights reserved. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +""" Error classes. """ + + +class GerritError(Exception): + + """ Raised when something goes wrong in Gerrit handling. """ + + pass diff --git a/pygerrit/events.py b/pygerrit/events.py new file mode 100644 index 0000000..c6563cd --- /dev/null +++ b/pygerrit/events.py @@ -0,0 +1,331 @@ +# The MIT License +# +# Copyright 2011 Sony Ericsson Mobile Communications. All rights reserved. +# Copyright 2012 Sony Mobile Communications. All rights reserved. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +""" Gerrit event classes. """ + +import json +import logging + +from .error import GerritError +from .models import Account, Approval, Change, Patchset, RefUpdate + + +class GerritEventFactory(object): + + """ Gerrit event factory. """ + + _events = {} + + @classmethod + def register(cls, name): + """ Decorator to register the event identified by `name`. + + Return the decorated class. + + Raise GerritError if the event is already registered. + + """ + + def decorate(klazz): + """ Decorator. """ + if name in cls._events: + raise GerritError("Duplicate event: %s" % name) + cls._events[name] = [klazz.__module__, klazz.__name__] + klazz.name = name + return klazz + return decorate + + @classmethod + def create(cls, data): + """ Create a new event instance. + + Return an instance of the `GerritEvent` subclass after converting + `data` to json. + + Raise GerritError if json parsed from `data` does not contain a `type` + key. + + """ + try: + json_data = json.loads(data) + except ValueError as err: + logging.debug("Failed to load json data: %s: [%s]", str(err), data) + json_data = json.loads(ErrorEvent.error_json(err)) + + if not "type" in json_data: + raise GerritError("`type` not in json_data") + name = json_data["type"] + if not name in cls._events: + name = 'unhandled-event' + event = cls._events[name] + module_name = event[0] + class_name = event[1] + module = __import__(module_name, fromlist=[module_name]) + klazz = getattr(module, class_name) + return klazz(json_data) + + +class GerritEvent(object): + + """ Gerrit event base class. """ + + def __init__(self, json_data): + self.json = json_data + + +@GerritEventFactory.register("unhandled-event") +class UnhandledEvent(GerritEvent): + + """ Unknown event type received in json data from Gerrit's event stream. """ + + def __init__(self, json_data): + super(UnhandledEvent, self).__init__(json_data) + + def __repr__(self): + return u"" + + +@GerritEventFactory.register("error-event") +class ErrorEvent(GerritEvent): + + """ Error occurred when processing json data from Gerrit's event stream. """ + + def __init__(self, json_data): + super(ErrorEvent, self).__init__(json_data) + self.error = json_data["error"] + + @classmethod + def error_json(cls, error): + """ Return a json string for the `error`. """ + return '{"type":"error-event",' \ + '"error":"%s"}' % str(error) + + def __repr__(self): + return u"" % self.error + + +@GerritEventFactory.register("patchset-created") +class PatchsetCreatedEvent(GerritEvent): + + """ Gerrit "patchset-created" event. """ + + def __init__(self, json_data): + super(PatchsetCreatedEvent, self).__init__(json_data) + try: + self.change = Change(json_data["change"]) + self.patchset = Patchset(json_data["patchSet"]) + self.uploader = Account(json_data["uploader"]) + except KeyError as e: + raise GerritError("PatchsetCreatedEvent: %s" % e) + + def __repr__(self): + return u": %s %s %s" % (self.change, + self.patchset, + self.uploader) + + +@GerritEventFactory.register("draft-published") +class DraftPublishedEvent(GerritEvent): + + """ Gerrit "draft-published" event. """ + + def __init__(self, json_data): + super(DraftPublishedEvent, self).__init__(json_data) + try: + self.change = Change(json_data["change"]) + self.patchset = Patchset(json_data["patchSet"]) + self.uploader = Account(json_data["uploader"]) + except KeyError as e: + raise GerritError("DraftPublishedEvent: %s" % e) + + def __repr__(self): + return u": %s %s %s" % (self.change, + self.patchset, + self.uploader) + + +@GerritEventFactory.register("comment-added") +class CommentAddedEvent(GerritEvent): + + """ Gerrit "comment-added" event. """ + + def __init__(self, json_data): + super(CommentAddedEvent, self).__init__(json_data) + try: + self.change = Change(json_data["change"]) + self.patchset = Patchset(json_data["patchSet"]) + self.author = Account(json_data["author"]) + self.approvals = [] + if "approvals" in json_data: + for approval in json_data["approvals"]: + self.approvals.append(Approval(approval)) + self.comment = json_data["comment"] + except (KeyError, ValueError) as e: + raise GerritError("CommentAddedEvent: %s" % e) + + def __repr__(self): + return u": %s %s %s" % (self.change, + self.patchset, + self.author) + + +@GerritEventFactory.register("change-merged") +class ChangeMergedEvent(GerritEvent): + + """ Gerrit "change-merged" event. """ + + def __init__(self, json_data): + super(ChangeMergedEvent, self).__init__(json_data) + try: + self.change = Change(json_data["change"]) + self.patchset = Patchset(json_data["patchSet"]) + self.submitter = Account(json_data["submitter"]) + except KeyError as e: + raise GerritError("ChangeMergedEvent: %s" % e) + + def __repr__(self): + return u": %s %s %s" % (self.change, + self.patchset, + self.submitter) + + +@GerritEventFactory.register("merge-failed") +class MergeFailedEvent(GerritEvent): + + """ Gerrit "merge-failed" event. """ + + def __init__(self, json_data): + super(MergeFailedEvent, self).__init__(json_data) + try: + self.change = Change(json_data["change"]) + self.patchset = Patchset(json_data["patchSet"]) + self.submitter = Account(json_data["submitter"]) + if 'reason' in json_data: + self.reason = json_data["reason"] + except KeyError as e: + raise GerritError("MergeFailedEvent: %s" % e) + + def __repr__(self): + return u": %s %s %s" % (self.change, + self.patchset, + self.submitter) + + +@GerritEventFactory.register("change-abandoned") +class ChangeAbandonedEvent(GerritEvent): + + """ Gerrit "change-abandoned" event. """ + + def __init__(self, json_data): + super(ChangeAbandonedEvent, self).__init__(json_data) + try: + self.change = Change(json_data["change"]) + self.abandoner = Account(json_data["abandoner"]) + if 'reason' in json_data: + self.reason = json_data["reason"] + except KeyError as e: + raise GerritError("ChangeAbandonedEvent: %s" % e) + + def __repr__(self): + return u": %s %s" % (self.change, + self.abandoner) + + +@GerritEventFactory.register("change-restored") +class ChangeRestoredEvent(GerritEvent): + + """ Gerrit "change-restored" event. """ + + def __init__(self, json_data): + super(ChangeRestoredEvent, self).__init__(json_data) + try: + self.change = Change(json_data["change"]) + self.restorer = Account(json_data["restorer"]) + if 'reason' in json_data: + self.reason = json_data["reason"] + except KeyError as e: + raise GerritError("ChangeRestoredEvent: %s" % e) + + def __repr__(self): + return u": %s %s" % (self.change, + self.restorer) + + +@GerritEventFactory.register("ref-updated") +class RefUpdatedEvent(GerritEvent): + + """ Gerrit "ref-updated" event. """ + + def __init__(self, json_data): + super(RefUpdatedEvent, self).__init__(json_data) + try: + self.ref_update = RefUpdate(json_data["refUpdate"]) + self.submitter = Account.from_json(json_data, "submitter") + except KeyError as e: + raise GerritError("RefUpdatedEvent: %s" % e) + + def __repr__(self): + return u": %s %s" % (self.ref_update, self.submitter) + + +@GerritEventFactory.register("reviewer-added") +class ReviewerAddedEvent(GerritEvent): + + """ Gerrit "reviewer-added" event. """ + + def __init__(self, json_data): + super(ReviewerAddedEvent, self).__init__(json_data) + try: + self.change = Change(json_data["change"]) + self.patchset = Patchset.from_json(json_data) + self.reviewer = Account(json_data["reviewer"]) + except KeyError as e: + raise GerritError("ReviewerAddedEvent: %s" % e) + + def __repr__(self): + return u": %s %s %s" % (self.change, + self.patchset, + self.reviewer) + + +@GerritEventFactory.register("topic-changed") +class TopicChangedEvent(GerritEvent): + + """ Gerrit "topic-changed" event. """ + + def __init__(self, json_data): + super(TopicChangedEvent, self).__init__(json_data) + try: + self.change = Change(json_data["change"]) + self.changer = Account(json_data["changer"]) + if "oldTopic" in json_data: + self.oldtopic = json_data["oldTopic"] + else: + self.oldtopic = "" + except KeyError as e: + raise GerritError("TopicChangedEvent: %s" % e) + + def __repr__(self): + return u": %s %s [%s]" % (self.change, + self.changer, + self.oldtopic) diff --git a/pygerrit/models.py b/pygerrit/models.py new file mode 100644 index 0000000..17dc7ee --- /dev/null +++ b/pygerrit/models.py @@ -0,0 +1,157 @@ +# The MIT License +# +# Copyright 2011 Sony Ericsson Mobile Communications. All rights reserved. +# Copyright 2012 Sony Mobile Communications. All rights reserved. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +""" Models for Gerrit JSON data. """ + +from . import from_json + + +class Account(object): + + """ Gerrit user account (name and email address). """ + + def __init__(self, json_data): + self.name = from_json(json_data, "name") + self.email = from_json(json_data, "email") + self.username = from_json(json_data, "username") + + def __repr__(self): + return u"" % (self.name, + " (%s)" % self.email if self.email else "") + + @staticmethod + def from_json(json_data, key): + """ Create an Account instance. + + Return an instance of Account initialised with values from `key` + in `json_data`, or None if `json_data` does not contain `key`. + + """ + if key in json_data: + return Account(json_data[key]) + return None + + +class Change(object): + + """ Gerrit change. """ + + def __init__(self, json_data): + self.project = from_json(json_data, "project") + self.branch = from_json(json_data, "branch") + self.topic = from_json(json_data, "topic") + self.change_id = from_json(json_data, "id") + self.number = from_json(json_data, "number") + self.subject = from_json(json_data, "subject") + self.url = from_json(json_data, "url") + self.owner = Account.from_json(json_data, "owner") + self.sortkey = from_json(json_data, "sortKey") + self.status = from_json(json_data, "status") + self.current_patchset = CurrentPatchset.from_json(json_data) + + def __repr__(self): + return u"" % (self.number, self.project, self.branch) + + +class Patchset(object): + + """ Gerrit patch set. """ + + def __init__(self, json_data): + self.number = from_json(json_data, "number") + self.revision = from_json(json_data, "revision") + self.ref = from_json(json_data, "ref") + self.uploader = Account.from_json(json_data, "uploader") + + def __repr__(self): + return u"" % (self.number, self.revision) + + @staticmethod + def from_json(json_data): + r""" Create a Patchset instance. + + Return an instance of Patchset initialised with values from "patchSet" + in `json_data`, or None if `json_data` does not contain "patchSet". + + """ + if "patchSet" in json_data: + return Patchset(json_data["patchSet"]) + return None + + +class CurrentPatchset(Patchset): + + """ Gerrit current patch set. """ + + def __init__(self, json_data): + super(CurrentPatchset, self).__init__(json_data) + self.author = Account.from_json(json_data, "author") + self.approvals = [] + if "approvals" in json_data: + for approval in json_data["approvals"]: + self.approvals.append(Approval(approval)) + + def __repr__(self): + return u"" % (self.number, self.revision) + + @staticmethod + def from_json(json_data): + r""" Create a CurrentPatchset instance. + + Return an instance of CurrentPatchset initialised with values from + "currentPatchSet" in `json_data`, or None if `json_data` does not + contain "currentPatchSet". + + """ + if "currentPatchSet" in json_data: + return CurrentPatchset(json_data["currentPatchSet"]) + return None + + +class Approval(object): + + """ Gerrit approval (verified, code review, etc). """ + + def __init__(self, json_data): + self.category = from_json(json_data, "type") + self.value = from_json(json_data, "value") + self.description = from_json(json_data, "description") + self.approver = Account.from_json(json_data, "by") + + def __repr__(self): + return u"" % (self.description, self.value) + + +class RefUpdate(object): + + """ Gerrit ref update. """ + + def __init__(self, json_data): + self.oldrev = from_json(json_data, "oldRev") + self.newrev = from_json(json_data, "newRev") + self.refname = from_json(json_data, "refName") + self.project = from_json(json_data, "project") + + def __repr__(self): + return "" % \ + (self.project, self.refname, self.oldrev, self.newrev) diff --git a/pygerrit/rest.py b/pygerrit/rest.py deleted file mode 100644 index 5b197a0..0000000 --- a/pygerrit/rest.py +++ /dev/null @@ -1,156 +0,0 @@ -# The MIT License -# -# Copyright 2013 Sony Mobile Communications. All rights reserved. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - -""" Interface to the Gerrit REST API. """ - -import json -import logging -import requests - -GERRIT_MAGIC_JSON_PREFIX = ")]}\'\n" -GERRIT_AUTH_SUFFIX = "/a" - - -def _decode_response(response): - """ Decode the `response` received from a REST API call. - - Strip off Gerrit's magic prefix if it is there, and return decoded - JSON content or raw text if it cannot be decoded as JSON. - - Raise requests.HTTPError if the response contains an HTTP error status - code. - - """ - response.raise_for_status() - content = response.content - if content.startswith(GERRIT_MAGIC_JSON_PREFIX): - content = content[len(GERRIT_MAGIC_JSON_PREFIX):] - try: - return json.loads(content) - except ValueError: - return content.strip() - - -class GerritRestAPI(object): - - """ Interface to the Gerrit REST API. """ - - def __init__(self, url, auth=None, verify=True): - """ Constructor. - - `url` is assumed to be the full URL to the server, including the - 'http(s)://' prefix. - - If `auth` is specified, it must be a derivation of the `AuthBase` - class from the `requests` module. The `url` will be adjusted if - necessary to make sure it includes Gerrit's authentication suffix. - - If `verify` is False, the underlying requests library will be - configured to not attempt to verify SSL certificates. - - """ - headers = {'Accept': 'application/json', - 'Accept-Encoding': 'gzip'} - self.kwargs = {'auth': auth, - 'verify': verify, - 'headers': headers} - self.url = url.rstrip('/') - self.session = requests.session() - - if auth: - if not isinstance(auth, requests.auth.AuthBase): - raise ValueError('Invalid auth type; must be derived ' - 'from requests.auth.AuthBase') - - if not self.url.endswith(GERRIT_AUTH_SUFFIX): - self.url += GERRIT_AUTH_SUFFIX - else: - if self.url.endswith(GERRIT_AUTH_SUFFIX): - self.url = self.url[: - len(GERRIT_AUTH_SUFFIX)] - - if not self.url.endswith('/'): - self.url += '/' - logging.debug("url %s", self.url) - - def make_url(self, endpoint): - """ Make the necessary url from `endpoint`. - - Strip leading slashes off the endpoint, and return the full - url. - - Raise requests.RequestException on timeout or connection error. - - """ - endpoint = endpoint.lstrip('/') - return self.url + endpoint - - def get(self, endpoint, **kwargs): - """ Send HTTP GET to `endpoint`. - - Return JSON decoded result. - - Raise requests.RequestException on timeout or connection error. - - """ - kwargs.update(self.kwargs.copy()) - response = self.session.get(self.make_url(endpoint), **kwargs) - return _decode_response(response) - - def put(self, endpoint, **kwargs): - """ Send HTTP PUT to `endpoint`. - - Return JSON decoded result. - - Raise requests.RequestException on timeout or connection error. - - """ - kwargs.update(self.kwargs.copy()) - kwargs["headers"].update( - {"Content-Type": "application/json;charset=UTF-8"}) - response = self.session.put(self.make_url(endpoint), **kwargs) - return _decode_response(response) - - def post(self, endpoint, **kwargs): - """ Send HTTP POST to `endpoint`. - - Return JSON decoded result. - - Raise requests.RequestException on timeout or connection error. - - """ - kwargs.update(self.kwargs.copy()) - kwargs["headers"].update( - {"Content-Type": "application/json;charset=UTF-8"}) - response = self.session.post(self.make_url(endpoint), **kwargs) - return _decode_response(response) - - def delete(self, endpoint, **kwargs): - """ Send HTTP DELETE to `endpoint`. - - Return JSON decoded result. - - Raise requests.RequestException on timeout or connection error. - - """ - kwargs.update(self.kwargs.copy()) - response = self.session.delete(self.make_url(endpoint), **kwargs) - return _decode_response(response) diff --git a/pygerrit/rest/__init__.py b/pygerrit/rest/__init__.py new file mode 100644 index 0000000..5b197a0 --- /dev/null +++ b/pygerrit/rest/__init__.py @@ -0,0 +1,156 @@ +# The MIT License +# +# Copyright 2013 Sony Mobile Communications. All rights reserved. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +""" Interface to the Gerrit REST API. """ + +import json +import logging +import requests + +GERRIT_MAGIC_JSON_PREFIX = ")]}\'\n" +GERRIT_AUTH_SUFFIX = "/a" + + +def _decode_response(response): + """ Decode the `response` received from a REST API call. + + Strip off Gerrit's magic prefix if it is there, and return decoded + JSON content or raw text if it cannot be decoded as JSON. + + Raise requests.HTTPError if the response contains an HTTP error status + code. + + """ + response.raise_for_status() + content = response.content + if content.startswith(GERRIT_MAGIC_JSON_PREFIX): + content = content[len(GERRIT_MAGIC_JSON_PREFIX):] + try: + return json.loads(content) + except ValueError: + return content.strip() + + +class GerritRestAPI(object): + + """ Interface to the Gerrit REST API. """ + + def __init__(self, url, auth=None, verify=True): + """ Constructor. + + `url` is assumed to be the full URL to the server, including the + 'http(s)://' prefix. + + If `auth` is specified, it must be a derivation of the `AuthBase` + class from the `requests` module. The `url` will be adjusted if + necessary to make sure it includes Gerrit's authentication suffix. + + If `verify` is False, the underlying requests library will be + configured to not attempt to verify SSL certificates. + + """ + headers = {'Accept': 'application/json', + 'Accept-Encoding': 'gzip'} + self.kwargs = {'auth': auth, + 'verify': verify, + 'headers': headers} + self.url = url.rstrip('/') + self.session = requests.session() + + if auth: + if not isinstance(auth, requests.auth.AuthBase): + raise ValueError('Invalid auth type; must be derived ' + 'from requests.auth.AuthBase') + + if not self.url.endswith(GERRIT_AUTH_SUFFIX): + self.url += GERRIT_AUTH_SUFFIX + else: + if self.url.endswith(GERRIT_AUTH_SUFFIX): + self.url = self.url[: - len(GERRIT_AUTH_SUFFIX)] + + if not self.url.endswith('/'): + self.url += '/' + logging.debug("url %s", self.url) + + def make_url(self, endpoint): + """ Make the necessary url from `endpoint`. + + Strip leading slashes off the endpoint, and return the full + url. + + Raise requests.RequestException on timeout or connection error. + + """ + endpoint = endpoint.lstrip('/') + return self.url + endpoint + + def get(self, endpoint, **kwargs): + """ Send HTTP GET to `endpoint`. + + Return JSON decoded result. + + Raise requests.RequestException on timeout or connection error. + + """ + kwargs.update(self.kwargs.copy()) + response = self.session.get(self.make_url(endpoint), **kwargs) + return _decode_response(response) + + def put(self, endpoint, **kwargs): + """ Send HTTP PUT to `endpoint`. + + Return JSON decoded result. + + Raise requests.RequestException on timeout or connection error. + + """ + kwargs.update(self.kwargs.copy()) + kwargs["headers"].update( + {"Content-Type": "application/json;charset=UTF-8"}) + response = self.session.put(self.make_url(endpoint), **kwargs) + return _decode_response(response) + + def post(self, endpoint, **kwargs): + """ Send HTTP POST to `endpoint`. + + Return JSON decoded result. + + Raise requests.RequestException on timeout or connection error. + + """ + kwargs.update(self.kwargs.copy()) + kwargs["headers"].update( + {"Content-Type": "application/json;charset=UTF-8"}) + response = self.session.post(self.make_url(endpoint), **kwargs) + return _decode_response(response) + + def delete(self, endpoint, **kwargs): + """ Send HTTP DELETE to `endpoint`. + + Return JSON decoded result. + + Raise requests.RequestException on timeout or connection error. + + """ + kwargs.update(self.kwargs.copy()) + response = self.session.delete(self.make_url(endpoint), **kwargs) + return _decode_response(response) diff --git a/pygerrit/rest/auth.py b/pygerrit/rest/auth.py new file mode 100644 index 0000000..c43c3fa --- /dev/null +++ b/pygerrit/rest/auth.py @@ -0,0 +1,56 @@ +# The MIT License +# +# Copyright 2013 Sony Mobile Communications. All rights reserved. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +""" Authentication classes. """ + +from requests.auth import HTTPDigestAuth, HTTPBasicAuth +from requests.utils import get_netrc_auth + + +class HTTPDigestAuthFromNetrc(HTTPDigestAuth): + + """ HTTP Digest Auth with netrc credentials. """ + + def __init__(self, url): + auth = get_netrc_auth(url) + if not auth: + raise ValueError("netrc missing or no credentials found in netrc") + username, password = auth + super(HTTPDigestAuthFromNetrc, self).__init__(username, password) + + def __call__(self, req): + return super(HTTPDigestAuthFromNetrc, self).__call__(req) + + +class HTTPBasicAuthFromNetrc(HTTPBasicAuth): + + """ HTTP Basic Auth with netrc credentials. """ + + def __init__(self, url): + auth = get_netrc_auth(url) + if not auth: + raise ValueError("netrc missing or no credentials found in netrc") + username, password = auth + super(HTTPBasicAuthFromNetrc, self).__init__(username, password) + + def __call__(self, req): + return super(HTTPBasicAuthFromNetrc, self).__call__(req) diff --git a/pygerrit/ssh.py b/pygerrit/ssh.py new file mode 100644 index 0000000..2933ddf --- /dev/null +++ b/pygerrit/ssh.py @@ -0,0 +1,178 @@ +# The MIT License +# +# Copyright 2012 Sony Mobile Communications. All rights reserved. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +""" Gerrit SSH Client. """ + +from os.path import abspath, expanduser, isfile +import re +import socket +from threading import Event, Lock + +from .error import GerritError + +from paramiko import SSHClient, SSHConfig +from paramiko.ssh_exception import SSHException + + +def _extract_version(version_string, pattern): + """ Extract the version from `version_string` using `pattern`. + + Return the version as a string, with leading/trailing whitespace + stripped. + + """ + if version_string: + match = pattern.match(version_string.strip()) + if match: + return match.group(1) + return "" + + +class GerritSSHCommandResult(object): + + """ Represents the results of a Gerrit command run over SSH. """ + + def __init__(self, command, stdin, stdout, stderr): + self.command = command + self.stdin = stdin + self.stdout = stdout + self.stderr = stderr + + def __repr__(self): + return "" % self.command + + +class GerritSSHClient(SSHClient): + + """ Gerrit SSH Client, wrapping the paramiko SSH Client. """ + + def __init__(self, hostname, username=None, port=None): + """ Initialise and connect to SSH. """ + super(GerritSSHClient, self).__init__() + self.remote_version = None + self.hostname = hostname + self.username = username + self.key_filename = None + self.port = port + self.connected = Event() + self.lock = Lock() + + def _configure(self): + """ Configure the ssh parameters from the config file. """ + configfile = expanduser("~/.ssh/config") + if not isfile(configfile): + raise GerritError("ssh config file '%s' does not exist" % + configfile) + + config = SSHConfig() + config.parse(open(configfile)) + data = config.lookup(self.hostname) + if not data: + raise GerritError("No ssh config for host %s" % self.hostname) + if not 'hostname' in data or not 'port' in data or not 'user' in data: + raise GerritError("Missing configuration data in %s" % configfile) + self.hostname = data['hostname'] + self.username = data['user'] + if 'identityfile' in data: + key_filename = abspath(expanduser(data['identityfile'][0])) + if not isfile(key_filename): + raise GerritError("Identity file '%s' does not exist" % + key_filename) + self.key_filename = key_filename + try: + self.port = int(data['port']) + except ValueError: + raise GerritError("Invalid port: %s" % data['port']) + + def _do_connect(self): + """ Connect to the remote. """ + self.load_system_host_keys() + if self.username is None or self.port is None: + self._configure() + try: + self.connect(hostname=self.hostname, + port=self.port, + username=self.username, + key_filename=self.key_filename) + except socket.error as e: + raise GerritError("Failed to connect to server: %s" % e) + + try: + version_string = self._transport.remote_version + pattern = re.compile(r'^.*GerritCodeReview_([a-z0-9-\.]*) .*$') + self.remote_version = _extract_version(version_string, pattern) + except AttributeError: + self.remote_version = None + + def _connect(self): + """ Connect to the remote if not already connected. """ + if not self.connected.is_set(): + try: + self.lock.acquire() + # Another thread may have connected while we were + # waiting to acquire the lock + if not self.connected.is_set(): + self._do_connect() + self.connected.set() + except GerritError: + raise + finally: + self.lock.release() + + def get_remote_version(self): + """ Return the version of the remote Gerrit server. """ + if self.remote_version is None: + result = self.run_gerrit_command("version") + version_string = result.stdout.read() + pattern = re.compile(r'^gerrit version (.*)$') + self.remote_version = _extract_version(version_string, pattern) + return self.remote_version + + def get_remote_info(self): + """ Return the username, and version of the remote Gerrit server. """ + version = self.get_remote_version() + return (self.username, version) + + def run_gerrit_command(self, command): + """ Run the given command. + + Make sure we're connected to the remote server, and run `command`. + + Return the results as a `GerritSSHCommandResult`. + + Raise `ValueError` if `command` is not a string, or `GerritError` if + command execution fails. + + """ + if not isinstance(command, basestring): + raise ValueError("command must be a string") + gerrit_command = "gerrit " + command + + self._connect() + try: + stdin, stdout, stderr = self.exec_command(gerrit_command, + bufsize=1, + timeout=None, + get_pty=False) + except SSHException as err: + raise GerritError("Command execution error: %s" % err) + return GerritSSHCommandResult(command, stdin, stdout, stderr) diff --git a/pygerrit/stream.py b/pygerrit/stream.py new file mode 100644 index 0000000..1504dde --- /dev/null +++ b/pygerrit/stream.py @@ -0,0 +1,77 @@ +# The MIT License +# +# Copyright 2012 Sony Mobile Communications. All rights reserved. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +""" Gerrit event stream interface. + +Class to listen to the Gerrit event stream and dispatch events. + +""" + +from threading import Thread, Event + +from .events import ErrorEvent + + +class GerritStream(Thread): + + """ Gerrit events stream handler. """ + + def __init__(self, gerrit, ssh_client): + Thread.__init__(self) + self.daemon = True + self._gerrit = gerrit + self._ssh_client = ssh_client + self._stop = Event() + self._channel = None + + def stop(self): + """ Stop the thread. """ + self._stop.set() + if self._channel is not None: + self._channel.close() + + def _error_event(self, error): + """ Dispatch `error` to the Gerrit client. """ + self._gerrit.put_event(ErrorEvent.error_json(error)) + + def run(self): + """ Listen to the stream and send events to the client. """ + channel = self._ssh_client.get_transport().open_session() + self._channel = channel + channel.exec_command("gerrit stream-events") + stdout = channel.makefile() + stderr = channel.makefile_stderr() + while not self._stop.is_set(): + try: + if channel.exit_status_ready(): + if channel.recv_stderr_ready(): + error = stderr.readline().strip() + else: + error = "Remote server connection closed" + self._error_event(error) + self._stop.set() + else: + data = stdout.readline() + self._gerrit.put_event(data) + except Exception as e: # pylint: disable=W0703 + self._error_event(repr(e)) + self._stop.set() diff --git a/requirements.txt b/requirements.txt index de2c0fe..d7b30e5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ +paramiko==1.11.0 pbr==0.8.0 +pycrypto==2.3 requests==2.2.1 diff --git a/rest_example.py b/rest_example.py new file mode 100755 index 0000000..1b69c00 --- /dev/null +++ b/rest_example.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# The MIT License +# +# Copyright 2013 Sony Mobile Communications. All rights reserved. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +""" Example of using the Gerrit client REST API. """ + +import argparse +import logging +import sys + +from requests.auth import HTTPBasicAuth, HTTPDigestAuth +from requests.exceptions import RequestException +try: + # pylint: disable=F0401 + from requests_kerberos import HTTPKerberosAuth, OPTIONAL + # pylint: enable=F0401 + _kerberos_support = True +except ImportError: + _kerberos_support = False + +from pygerrit.rest import GerritRestAPI +from pygerrit.rest.auth import HTTPDigestAuthFromNetrc, HTTPBasicAuthFromNetrc + + +def _main(): + descr = 'Send request using Gerrit HTTP API' + parser = argparse.ArgumentParser( + description=descr, + formatter_class=argparse.ArgumentDefaultsHelpFormatter) + parser.add_argument('-g', '--gerrit-url', dest='gerrit_url', + required=True, + help='gerrit server url') + parser.add_argument('-b', '--basic-auth', dest='basic_auth', + action='store_true', + help='use basic auth instead of digest') + if _kerberos_support: + parser.add_argument('-k', '--kerberos-auth', dest='kerberos_auth', + action='store_true', + help='use kerberos auth') + parser.add_argument('-u', '--username', dest='username', + help='username') + parser.add_argument('-p', '--password', dest='password', + help='password') + parser.add_argument('-n', '--netrc', dest='netrc', + action='store_true', + help='Use credentials from netrc') + parser.add_argument('-v', '--verbose', dest='verbose', + action='store_true', + help='enable verbose (debug) logging') + + options = parser.parse_args() + + level = logging.DEBUG if options.verbose else logging.INFO + logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s', + level=level) + + if _kerberos_support and options.kerberos_auth: + if options.username or options.password \ + or options.basic_auth or options.netrc: + parser.error("--kerberos-auth may not be used together with " + "--username, --password, --basic-auth or --netrc") + auth = HTTPKerberosAuth(mutual_authentication=OPTIONAL) + elif options.username and options.password: + if options.netrc: + logging.warning("--netrc option ignored") + if options.basic_auth: + auth = HTTPBasicAuth(options.username, options.password) + else: + auth = HTTPDigestAuth(options.username, options.password) + elif options.netrc: + if options.basic_auth: + auth = HTTPBasicAuthFromNetrc(url=options.gerrit_url) + else: + auth = HTTPDigestAuthFromNetrc(url=options.gerrit_url) + else: + auth = None + + rest = GerritRestAPI(url=options.gerrit_url, auth=auth) + + try: + changes = rest.get("/changes/?q=owner:self%20status:open") + logging.info("%d changes", len(changes)) + for change in changes: + logging.info(change['change_id']) + except RequestException as err: + logging.error("Error: %s", str(err)) + +if __name__ == "__main__": + sys.exit(_main()) diff --git a/testdata/change-abandoned-event.txt b/testdata/change-abandoned-event.txt new file mode 100644 index 0000000..879ea28 --- /dev/null +++ b/testdata/change-abandoned-event.txt @@ -0,0 +1,13 @@ +{"type":"change-abandoned", + "change":{"project":"project-name", + "branch":"branch-name", + "topic":"topic-name", + "id":"Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + "number":"123456", + "subject":"Commit message subject", + "owner":{"name":"Owner Name", + "email":"owner@example.com"}, + "url":"http://review.example.com/123456"}, + "abandoner":{"name":"Abandoner Name", + "email":"abandoner@example.com"}, + "reason":"Abandon reason"} diff --git a/testdata/change-merged-event.txt b/testdata/change-merged-event.txt new file mode 100644 index 0000000..4da678a --- /dev/null +++ b/testdata/change-merged-event.txt @@ -0,0 +1,18 @@ +{"type":"change-merged", + "change":{"project":"project-name", + "branch":"branch-name", + "topic":"topic-name", + "id":"Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + "number":"123456", + "subject":"Commit message subject", + "owner":{"name":"Owner Name", + "email":"owner@example.com"}, + "url":"http://review.example.com/123456"}, + "patchSet":{"number":"4", + "revision":"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + "ref":"refs/changes/56/123456/4", + "uploader":{"name":"Uploader Name", + "email":"uploader@example.com"}, + "createdOn":1341370514}, + "submitter":{"name":"Submitter Name", + "email":"submitter@example.com"}} diff --git a/testdata/change-restored-event.txt b/testdata/change-restored-event.txt new file mode 100644 index 0000000..e0300a8 --- /dev/null +++ b/testdata/change-restored-event.txt @@ -0,0 +1,13 @@ +{"type":"change-restored", + "change":{"project":"project-name", + "branch":"branch-name", + "topic":"topic-name", + "id":"Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + "number":"123456", + "subject":"Commit message subject", + "owner":{"name":"Owner Name", + "email":"owner@example.com"}, + "url":"http://review.example.com/123456"}, + "restorer":{"name":"Restorer Name", + "email":"restorer@example.com"}, + "reason":"Restore reason"} diff --git a/testdata/comment-added-event.txt b/testdata/comment-added-event.txt new file mode 100644 index 0000000..9ab50f7 --- /dev/null +++ b/testdata/comment-added-event.txt @@ -0,0 +1,25 @@ +{"type":"comment-added", +"change":{"project":"project-name", + "branch":"branch-name", + "topic":"topic-name", + "id":"Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + "number":"123456", + "subject":"Commit message subject", + "owner":{"name":"Owner Name", + "email":"owner@example.com"}, + "url":"http://review.example.com/123456"}, + "patchSet":{"number":"4", + "revision":"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + "ref":"refs/changes/56/123456/4", + "uploader":{"name":"Uploader Name", + "email":"uploader@example.com"}, + "createdOn":1341370514}, + "author":{"name":"Author Name", + "email":"author@example.com"}, + "approvals":[{"type":"CRVW", + "description":"Code Review", + "value":"1"}, + {"type":"VRIF", + "description":"Verified", + "value":"1"}], + "comment":"Review comment"} diff --git a/testdata/draft-published-event.txt b/testdata/draft-published-event.txt new file mode 100644 index 0000000..8c3453a --- /dev/null +++ b/testdata/draft-published-event.txt @@ -0,0 +1,18 @@ +{"type":"draft-published", + "change":{"project":"project-name", + "branch":"branch-name", + "topic":"topic-name", + "id":"Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + "number":"123456", + "subject":"Commit message subject", + "owner":{"name":"Owner Name", + "email":"owner@example.com"}, + "url":"http://review.example.com/123456"}, + "patchSet":{"number":"4", + "revision":"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + "ref":"refs/changes/56/123456/4", + "uploader":{"name":"Uploader Name", + "email":"uploader@example.com"}, + "createdOn":1342075181}, + "uploader":{"name":"Uploader Name", + "email":"uploader@example.com"}} diff --git a/testdata/invalid-json.txt b/testdata/invalid-json.txt new file mode 100644 index 0000000..f7a60bb --- /dev/null +++ b/testdata/invalid-json.txt @@ -0,0 +1,4 @@ +)]}' +{"type":"user-defined-event", + "title":"Event title", + "description":"Event description"} diff --git a/testdata/merge-failed-event.txt b/testdata/merge-failed-event.txt new file mode 100644 index 0000000..2d29d29 --- /dev/null +++ b/testdata/merge-failed-event.txt @@ -0,0 +1,19 @@ +{"type":"merge-failed", + "change":{"project":"project-name", + "branch":"branch-name", + "topic":"topic-name", + "id":"Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + "number":"123456", + "subject":"Commit message subject", + "owner":{"name":"Owner Name", + "email":"owner@example.com"}, + "url":"http://review.example.com/123456"}, + "patchSet":{"number":"4", + "revision":"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + "ref":"refs/changes/56/123456/4", + "uploader":{"name":"Uploader Name", + "email":"uploader@example.com"}, + "createdOn":1341370514}, + "submitter":{"name":"Submitter Name", + "email":"submitter@example.com"}, + "reason":"Merge failed reason"} diff --git a/testdata/patchset-created-event.txt b/testdata/patchset-created-event.txt new file mode 100644 index 0000000..2c464be --- /dev/null +++ b/testdata/patchset-created-event.txt @@ -0,0 +1,18 @@ +{"type":"patchset-created", + "change":{"project":"project-name", + "branch":"branch-name", + "topic":"topic-name", + "id":"Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + "number":"123456", + "subject":"Commit message subject", + "owner":{"name":"Owner Name", + "email":"owner@example.com"}, + "url":"http://review.example.com/123456"}, + "patchSet":{"number":"4", + "revision":"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + "ref":"refs/changes/56/123456/4", + "uploader":{"name":"Uploader Name", + "email":"uploader@example.com"}, + "createdOn":1342075181}, + "uploader":{"name":"Uploader Name", + "email":"uploader@example.com"}} \ No newline at end of file diff --git a/testdata/ref-updated-event.txt b/testdata/ref-updated-event.txt new file mode 100644 index 0000000..61944cc --- /dev/null +++ b/testdata/ref-updated-event.txt @@ -0,0 +1,7 @@ +{"type":"ref-updated", + "submitter":{"name":"Submitter Name", + "email":"submitter@example.com"}, + "refUpdate":{"oldRev":"0000000000000000000000000000000000000000", + "newRev":"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + "refName":"refs/tags/refname", + "project":"project-name"}} diff --git a/testdata/reviewer-added-event.txt b/testdata/reviewer-added-event.txt new file mode 100644 index 0000000..b460afc --- /dev/null +++ b/testdata/reviewer-added-event.txt @@ -0,0 +1,18 @@ +{"type":"reviewer-added", + "change":{"project":"project-name", + "branch":"branch-name", + "topic":"topic-name", + "id":"Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + "number":"123456", + "subject":"Commit message subject", + "owner":{"name":"Owner Name", + "email":"owner@example.com"}, + "url":"http://review.example.com/123456"}, + "patchSet":{"number":"4", + "revision":"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + "ref":"refs/changes/56/123456/4", + "uploader":{"name":"Uploader Name", + "email":"uploader@example.com"}, + "createdOn":1341370514}, + "reviewer":{"name":"Reviewer Name", + "email":"reviewer@example.com"}} diff --git a/testdata/topic-changed-event.txt b/testdata/topic-changed-event.txt new file mode 100644 index 0000000..1847440 --- /dev/null +++ b/testdata/topic-changed-event.txt @@ -0,0 +1,13 @@ +{"type":"topic-changed", + "change":{"project":"project-name", + "branch":"branch-name", + "topic":"topic-name", + "id":"Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + "number":"123456", + "subject":"Commit message subject", + "owner":{"name":"Owner Name", + "email":"owner@example.com"}, + "url":"http://review.example.com/123456"}, + "changer":{"name":"Changer Name", + "email":"changer@example.com"}, + "oldTopic":"old-topic"} diff --git a/testdata/unhandled-event.txt b/testdata/unhandled-event.txt new file mode 100644 index 0000000..6824cc8 --- /dev/null +++ b/testdata/unhandled-event.txt @@ -0,0 +1,3 @@ +{"type":"this-event-is-not-handled", + "title":"Unhandled event title", + "description":"Unhandled event description"} diff --git a/testdata/user-defined-event.txt b/testdata/user-defined-event.txt new file mode 100644 index 0000000..7f7b65e --- /dev/null +++ b/testdata/user-defined-event.txt @@ -0,0 +1,3 @@ +{"type":"user-defined-event", + "title":"Event title", + "description":"Event description"} diff --git a/unittests.py b/unittests.py index f853e35..b4b3a23 100755 --- a/unittests.py +++ b/unittests.py @@ -23,10 +23,297 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -""" Unit tests for pygerrit. """ +""" Unit tests for the Gerrit event stream handler and event objects. """ +import json +import os import unittest +from pygerrit.events import PatchsetCreatedEvent, \ + RefUpdatedEvent, ChangeMergedEvent, CommentAddedEvent, \ + ChangeAbandonedEvent, ChangeRestoredEvent, \ + DraftPublishedEvent, GerritEventFactory, GerritEvent, UnhandledEvent, \ + ErrorEvent, MergeFailedEvent, ReviewerAddedEvent, TopicChangedEvent +from pygerrit.client import GerritClient + + +@GerritEventFactory.register("user-defined-event") +class UserDefinedEvent(GerritEvent): + + """ Dummy event class to test event registration. """ + + def __init__(self, json_data): + super(UserDefinedEvent, self).__init__(json_data) + self.title = json_data['title'] + self.description = json_data['description'] + + +def _create_event(name, gerrit): + """ Create a new event. + + Read the contents of the file specified by `name` and load as JSON + data, then add as an event in the `gerrit` client. + + """ + testfile = open(os.path.join("testdata", name + ".txt")) + data = testfile.read().replace("\n", "") + gerrit.put_event(data) + return data + + +class TestGerritEvents(unittest.TestCase): + def setUp(self): + self.gerrit = GerritClient("review") + + def test_patchset_created(self): + _create_event("patchset-created-event", self.gerrit) + event = self.gerrit.get_event(False) + self.assertTrue(isinstance(event, PatchsetCreatedEvent)) + self.assertEquals(event.name, "patchset-created") + self.assertEquals(event.change.project, "project-name") + self.assertEquals(event.change.branch, "branch-name") + self.assertEquals(event.change.topic, "topic-name") + self.assertEquals(event.change.change_id, + "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef") + self.assertEquals(event.change.number, "123456") + self.assertEquals(event.change.subject, "Commit message subject") + self.assertEquals(event.change.url, "http://review.example.com/123456") + self.assertEquals(event.change.owner.name, "Owner Name") + self.assertEquals(event.change.owner.email, "owner@example.com") + self.assertEquals(event.patchset.number, "4") + self.assertEquals(event.patchset.revision, + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef") + self.assertEquals(event.patchset.ref, "refs/changes/56/123456/4") + self.assertEquals(event.patchset.uploader.name, "Uploader Name") + self.assertEquals(event.patchset.uploader.email, "uploader@example.com") + self.assertEquals(event.uploader.name, "Uploader Name") + self.assertEquals(event.uploader.email, "uploader@example.com") + + def test_draft_published(self): + _create_event("draft-published-event", self.gerrit) + event = self.gerrit.get_event(False) + self.assertTrue(isinstance(event, DraftPublishedEvent)) + self.assertEquals(event.name, "draft-published") + self.assertEquals(event.change.project, "project-name") + self.assertEquals(event.change.branch, "branch-name") + self.assertEquals(event.change.topic, "topic-name") + self.assertEquals(event.change.change_id, + "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef") + self.assertEquals(event.change.number, "123456") + self.assertEquals(event.change.subject, "Commit message subject") + self.assertEquals(event.change.url, "http://review.example.com/123456") + self.assertEquals(event.change.owner.name, "Owner Name") + self.assertEquals(event.change.owner.email, "owner@example.com") + self.assertEquals(event.patchset.number, "4") + self.assertEquals(event.patchset.revision, + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef") + self.assertEquals(event.patchset.ref, "refs/changes/56/123456/4") + self.assertEquals(event.patchset.uploader.name, "Uploader Name") + self.assertEquals(event.patchset.uploader.email, "uploader@example.com") + self.assertEquals(event.uploader.name, "Uploader Name") + self.assertEquals(event.uploader.email, "uploader@example.com") + + def test_ref_updated(self): + _create_event("ref-updated-event", self.gerrit) + event = self.gerrit.get_event(False) + self.assertTrue(isinstance(event, RefUpdatedEvent)) + self.assertEquals(event.name, "ref-updated") + self.assertEquals(event.ref_update.project, "project-name") + self.assertEquals(event.ref_update.oldrev, + "0000000000000000000000000000000000000000") + self.assertEquals(event.ref_update.newrev, + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef") + self.assertEquals(event.ref_update.refname, "refs/tags/refname") + self.assertEquals(event.submitter.name, "Submitter Name") + self.assertEquals(event.submitter.email, "submitter@example.com") + + def test_change_merged(self): + _create_event("change-merged-event", self.gerrit) + event = self.gerrit.get_event(False) + self.assertTrue(isinstance(event, ChangeMergedEvent)) + self.assertEquals(event.name, "change-merged") + self.assertEquals(event.change.project, "project-name") + self.assertEquals(event.change.branch, "branch-name") + self.assertEquals(event.change.topic, "topic-name") + self.assertEquals(event.change.change_id, + "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef") + self.assertEquals(event.change.number, "123456") + self.assertEquals(event.change.subject, "Commit message subject") + self.assertEquals(event.change.url, "http://review.example.com/123456") + self.assertEquals(event.change.owner.name, "Owner Name") + self.assertEquals(event.change.owner.email, "owner@example.com") + self.assertEquals(event.patchset.number, "4") + self.assertEquals(event.patchset.revision, + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef") + self.assertEquals(event.patchset.ref, "refs/changes/56/123456/4") + self.assertEquals(event.patchset.uploader.name, "Uploader Name") + self.assertEquals(event.patchset.uploader.email, "uploader@example.com") + self.assertEquals(event.submitter.name, "Submitter Name") + self.assertEquals(event.submitter.email, "submitter@example.com") + + def test_merge_failed(self): + _create_event("merge-failed-event", self.gerrit) + event = self.gerrit.get_event(False) + self.assertTrue(isinstance(event, MergeFailedEvent)) + self.assertEquals(event.name, "merge-failed") + self.assertEquals(event.change.project, "project-name") + self.assertEquals(event.change.branch, "branch-name") + self.assertEquals(event.change.topic, "topic-name") + self.assertEquals(event.change.change_id, + "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef") + self.assertEquals(event.change.number, "123456") + self.assertEquals(event.change.subject, "Commit message subject") + self.assertEquals(event.change.url, "http://review.example.com/123456") + self.assertEquals(event.change.owner.name, "Owner Name") + self.assertEquals(event.change.owner.email, "owner@example.com") + self.assertEquals(event.patchset.number, "4") + self.assertEquals(event.patchset.revision, + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef") + self.assertEquals(event.patchset.ref, "refs/changes/56/123456/4") + self.assertEquals(event.patchset.uploader.name, "Uploader Name") + self.assertEquals(event.patchset.uploader.email, "uploader@example.com") + self.assertEquals(event.submitter.name, "Submitter Name") + self.assertEquals(event.submitter.email, "submitter@example.com") + self.assertEquals(event.reason, "Merge failed reason") + + def test_comment_added(self): + _create_event("comment-added-event", self.gerrit) + event = self.gerrit.get_event(False) + self.assertTrue(isinstance(event, CommentAddedEvent)) + self.assertEquals(event.name, "comment-added") + self.assertEquals(event.change.project, "project-name") + self.assertEquals(event.change.branch, "branch-name") + self.assertEquals(event.change.topic, "topic-name") + self.assertEquals(event.change.change_id, + "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef") + self.assertEquals(event.change.number, "123456") + self.assertEquals(event.change.subject, "Commit message subject") + self.assertEquals(event.change.url, "http://review.example.com/123456") + self.assertEquals(event.change.owner.name, "Owner Name") + self.assertEquals(event.change.owner.email, "owner@example.com") + self.assertEquals(event.patchset.number, "4") + self.assertEquals(event.patchset.revision, + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef") + self.assertEquals(event.patchset.ref, "refs/changes/56/123456/4") + self.assertEquals(event.patchset.uploader.name, "Uploader Name") + self.assertEquals(event.patchset.uploader.email, "uploader@example.com") + self.assertEquals(len(event.approvals), 2) + self.assertEquals(event.approvals[0].category, "CRVW") + self.assertEquals(event.approvals[0].description, "Code Review") + self.assertEquals(event.approvals[0].value, "1") + self.assertEquals(event.approvals[1].category, "VRIF") + self.assertEquals(event.approvals[1].description, "Verified") + self.assertEquals(event.approvals[1].value, "1") + self.assertEquals(event.author.name, "Author Name") + self.assertEquals(event.author.email, "author@example.com") + + def test_reviewer_added(self): + _create_event("reviewer-added-event", self.gerrit) + event = self.gerrit.get_event(False) + self.assertTrue(isinstance(event, ReviewerAddedEvent)) + self.assertEquals(event.name, "reviewer-added") + self.assertEquals(event.change.project, "project-name") + self.assertEquals(event.change.branch, "branch-name") + self.assertEquals(event.change.topic, "topic-name") + self.assertEquals(event.change.change_id, + "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef") + self.assertEquals(event.change.number, "123456") + self.assertEquals(event.change.subject, "Commit message subject") + self.assertEquals(event.change.url, "http://review.example.com/123456") + self.assertEquals(event.change.owner.name, "Owner Name") + self.assertEquals(event.change.owner.email, "owner@example.com") + self.assertEquals(event.patchset.number, "4") + self.assertEquals(event.patchset.revision, + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef") + self.assertEquals(event.patchset.ref, "refs/changes/56/123456/4") + self.assertEquals(event.patchset.uploader.name, "Uploader Name") + self.assertEquals(event.patchset.uploader.email, "uploader@example.com") + self.assertEquals(event.reviewer.name, "Reviewer Name") + self.assertEquals(event.reviewer.email, "reviewer@example.com") + + def test_change_abandoned(self): + _create_event("change-abandoned-event", self.gerrit) + event = self.gerrit.get_event(False) + self.assertTrue(isinstance(event, ChangeAbandonedEvent)) + self.assertEquals(event.name, "change-abandoned") + self.assertEquals(event.change.project, "project-name") + self.assertEquals(event.change.branch, "branch-name") + self.assertEquals(event.change.topic, "topic-name") + self.assertEquals(event.change.change_id, + "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef") + self.assertEquals(event.change.number, "123456") + self.assertEquals(event.change.subject, "Commit message subject") + self.assertEquals(event.change.url, "http://review.example.com/123456") + self.assertEquals(event.change.owner.name, "Owner Name") + self.assertEquals(event.change.owner.email, "owner@example.com") + self.assertEquals(event.abandoner.name, "Abandoner Name") + self.assertEquals(event.abandoner.email, "abandoner@example.com") + self.assertEquals(event.reason, "Abandon reason") + + def test_change_restored(self): + _create_event("change-restored-event", self.gerrit) + event = self.gerrit.get_event(False) + self.assertTrue(isinstance(event, ChangeRestoredEvent)) + self.assertEquals(event.name, "change-restored") + self.assertEquals(event.change.project, "project-name") + self.assertEquals(event.change.branch, "branch-name") + self.assertEquals(event.change.topic, "topic-name") + self.assertEquals(event.change.change_id, + "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef") + self.assertEquals(event.change.number, "123456") + self.assertEquals(event.change.subject, "Commit message subject") + self.assertEquals(event.change.url, "http://review.example.com/123456") + self.assertEquals(event.change.owner.name, "Owner Name") + self.assertEquals(event.change.owner.email, "owner@example.com") + self.assertEquals(event.restorer.name, "Restorer Name") + self.assertEquals(event.restorer.email, "restorer@example.com") + self.assertEquals(event.reason, "Restore reason") + + def test_topic_changed(self): + _create_event("topic-changed-event", self.gerrit) + event = self.gerrit.get_event(False) + self.assertTrue(isinstance(event, TopicChangedEvent)) + self.assertEquals(event.name, "topic-changed") + self.assertEquals(event.change.project, "project-name") + self.assertEquals(event.change.branch, "branch-name") + self.assertEquals(event.change.topic, "topic-name") + self.assertEquals(event.change.change_id, + "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef") + self.assertEquals(event.change.number, "123456") + self.assertEquals(event.change.subject, "Commit message subject") + self.assertEquals(event.change.url, "http://review.example.com/123456") + self.assertEquals(event.change.owner.name, "Owner Name") + self.assertEquals(event.change.owner.email, "owner@example.com") + self.assertEquals(event.changer.name, "Changer Name") + self.assertEquals(event.changer.email, "changer@example.com") + self.assertEquals(event.oldtopic, "old-topic") + + def test_user_defined_event(self): + _create_event("user-defined-event", self.gerrit) + event = self.gerrit.get_event(False) + self.assertTrue(isinstance(event, UserDefinedEvent)) + self.assertEquals(event.title, "Event title") + self.assertEquals(event.description, "Event description") + + def test_unhandled_event(self): + data = _create_event("unhandled-event", self.gerrit) + event = self.gerrit.get_event(False) + self.assertTrue(isinstance(event, UnhandledEvent)) + self.assertEquals(event.json, json.loads(data)) + + def test_invalid_json(self): + _create_event("invalid-json", self.gerrit) + event = self.gerrit.get_event(False) + self.assertTrue(isinstance(event, ErrorEvent)) + + def test_add_duplicate_event(self): + try: + @GerritEventFactory.register("user-defined-event") + class AnotherUserDefinedEvent(GerritEvent): + pass + except: + return + self.fail("Did not raise exception when duplicate event registered") if __name__ == '__main__': unittest.main() -- cgit v1.2.1