diff --git a/doc/source/api.rst b/doc/source/api.rst index b950f39..786640a 100644 --- a/doc/source/api.rst +++ b/doc/source/api.rst @@ -6,3 +6,8 @@ API reference .. automodule:: jenkins :members: :undoc-members: + +.. automodule:: jenkins.plugins + :members: + :noindex: + :undoc-members: diff --git a/jenkins/__init__.py b/jenkins/__init__.py index 45d1909..679f706 100644 --- a/jenkins/__init__.py +++ b/jenkins/__init__.py @@ -53,6 +53,7 @@ import socket import sys import warnings +import multi_key_dict import six from six.moves.http_client import BadStatusLine from six.moves.urllib.error import HTTPError @@ -60,6 +61,10 @@ from six.moves.urllib.error import URLError from six.moves.urllib.parse import quote, urlencode, urljoin, urlparse from six.moves.urllib.request import Request, urlopen +from jenkins import plugins + +warnings.simplefilter("default", DeprecationWarning) + if sys.version_info < (2, 7, 0): warnings.warn("Support for python 2.6 is deprecated and will be removed.") @@ -495,7 +500,10 @@ class Jenkins(object): """Get all installed plugins information on this Master. This method retrieves information about each plugin that is installed - on master. + on master returning the raw plugin data in a JSON format. + + .. deprecated:: 0.4.9 + Use :func:`get_plugins` instead. :param depth: JSON depth, ``int`` :returns: info on all plugins ``[dict]`` @@ -513,24 +521,24 @@ class Jenkins(object): u'gearman-plugin', u'bundled': False}, ..] """ - try: - plugins_info = json.loads(self.jenkins_open( - Request(self._build_url(PLUGIN_INFO, locals())) - )) - return plugins_info['plugins'] - except (HTTPError, BadStatusLine): - raise BadHTTPException("Error communicating with server[%s]" - % self.server) - except ValueError: - raise JenkinsException("Could not parse JSON info for server[%s]" - % self.server) + warnings.warn("get_plugins_info() is deprecated, use get_plugins()", + DeprecationWarning) + return [plugin_data for plugin_data in self.get_plugins(depth).values()] def get_plugin_info(self, name, depth=2): """Get an installed plugin information on this Master. - This method retrieves information about a speicifc plugin. + This method retrieves information about a specific plugin and returns + the raw plugin data in a JSON format. The passed in plugin name (short or long) must be an exact match. + .. note:: Calling this method will query Jenkins fresh for the + information for all plugins on each call. If you need to retrieve + information for multiple plugins it's recommended to use + :func:`get_plugins` instead, which will return a multi key + dictionary that can be accessed via either the short or long name + of the plugin. + :param name: Name (short or long) of plugin, ``str`` :param depth: JSON depth, ``int`` :returns: a specific plugin ``dict`` @@ -548,12 +556,45 @@ class Jenkins(object): u'gearman-plugin', u'bundled': False} """ + plugins_info = self.get_plugins(depth) try: - plugins_info = json.loads(self.jenkins_open( + return plugins_info[name] + except KeyError: + pass + + def get_plugins(self, depth=2): + """Return plugins info using helper class for version comparison + + This method retrieves information about all the installed plugins and + uses a Plugin helper class to simplify version comparison. Also uses + a multi key dict to allow retrieval via either short or long names. + + When printing/dumping the data, the version will transparently return + a unicode string, which is exactly what was previously returned by the + API. + + :param depth: JSON depth, ``int`` + :returns: info on all plugins ``[dict]`` + + Example:: + + >>> j = Jenkins() + >>> info = j.get_plugins() + >>> print(info) + {('gearman-plugin', 'Gearman Plugin'): + {u'backupVersion': None, u'version': u'0.0.4', + u'deleted': False, u'supportsDynamicLoad': u'MAYBE', + u'hasUpdate': True, u'enabled': True, u'pinned': False, + u'downgradable': False, u'dependencies': [], u'url': + u'http://wiki.jenkins-ci.org/display/JENKINS/Gearman+Plugin', + u'longName': u'Gearman Plugin', u'active': True, u'shortName': + u'gearman-plugin', u'bundled': False}, ...} + + """ + + try: + plugins_info_json = json.loads(self.jenkins_open( Request(self._build_url(PLUGIN_INFO, locals())))) - for plugin in plugins_info['plugins']: - if plugin['longName'] == name or plugin['shortName'] == name: - return plugin except (HTTPError, BadStatusLine): raise BadHTTPException("Error communicating with server[%s]" % self.server) @@ -561,6 +602,13 @@ class Jenkins(object): raise JenkinsException("Could not parse JSON info for server[%s]" % self.server) + plugins_data = multi_key_dict.multi_key_dict() + for plugin_data in plugins_info_json['plugins']: + keys = (str(plugin_data['shortName']), str(plugin_data['longName'])) + plugins_data[keys] = plugins.Plugin(**plugin_data) + + return plugins_data + def get_jobs(self, folder_depth=0): """Get list of jobs. diff --git a/jenkins/plugins.py b/jenkins/plugins.py new file mode 100644 index 0000000..d16e1d5 --- /dev/null +++ b/jenkins/plugins.py @@ -0,0 +1,111 @@ +# Software License Agreement (BSD License) +# +# Copyright (c) 2015 Hewlett-Packard Development Company, L.P. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following +# disclaimer in the documentation and/or other materials provided +# with the distribution. +# * Neither the name of Willow Garage, Inc. nor the names of its +# contributors may be used to endorse or promote products derived +# from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# 'AS IS' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +# COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# +# Authors: +# Darragh Bailey + +''' +.. module:: jenkins.plugins + :platform: Unix, Windows + :synopsis: Class for interacting with plugins +''' + +import operator +import re + +import pkg_resources + + +class Plugin(dict): + '''Dictionary object containing plugin metadata.''' + + def __init__(self, *args, **kwargs): + '''Populates dictionary using json object input. + + accepts same arguments as python `dict` class. + ''' + version = kwargs.pop('version', None) + + super(Plugin, self).__init__(*args, **kwargs) + self['version'] = version + + def __setitem__(self, key, value): + '''Overrides default setter to ensure that the version key is always + a PluginVersion class to abstract and simplify version comparisons + ''' + if key == 'version': + value = PluginVersion(value) + super(Plugin, self).__setitem__(key, value) + + +class PluginVersion(object): + '''Class providing comparison capabilities for plugin versions.''' + + _VERSION_RE = re.compile(r'(.*)-(?:SNAPSHOT|BETA)') + + def __init__(self, version): + '''Parse plugin version and store it for comparison.''' + + self._version = version + self.parsed_version = pkg_resources.parse_version( + self.__convert_version(version)) + + def __convert_version(self, version): + return self._VERSION_RE.sub(r'\g<1>.preview', str(version)) + + def __compare(self, op, version): + return op(self.parsed_version, pkg_resources.parse_version( + self.__convert_version(version))) + + def __le__(self, version): + return self.__compare(operator.le, version) + + def __lt__(self, version): + return self.__compare(operator.lt, version) + + def __ge__(self, version): + return self.__compare(operator.ge, version) + + def __gt__(self, version): + return self.__compare(operator.gt, version) + + def __eq__(self, version): + return self.__compare(operator.eq, version) + + def __ne__(self, version): + return self.__compare(operator.ne, version) + + def __str__(self): + return str(self._version) + + def __repr__(self): + return str(self._version) diff --git a/requirements.txt b/requirements.txt index 1b08df6..4e3e8db 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ six>=1.3.0 pbr>=0.8.2,<2.0 +multi_key_dict diff --git a/test-requirements.txt b/test-requirements.txt index 0d2aa87..956f12a 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -6,4 +6,5 @@ unittest2 python-subunit sphinx>=1.2,<1.3.0 testrepository +testscenarios testtools diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 04eae34..698a7a6 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1,7 +1,42 @@ +# Software License Agreement (BSD License) +# +# Copyright (c) 2015 Hewlett-Packard Development Company, L.P. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following +# disclaimer in the documentation and/or other materials provided +# with the distribution. +# * Neither the name of Willow Garage, Inc. nor the names of its +# contributors may be used to endorse or promote products derived +# from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# 'AS IS' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +# COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + + import json from mock import patch +from testscenarios.testcase import TestWithScenarios import jenkins +from jenkins import plugins from tests.base import JenkinsTestBase @@ -29,6 +64,14 @@ class JenkinsPluginsBase(JenkinsTestBase): ] } + updated_plugin_info_json = { + u"plugins": + [ + dict(plugin_info_json[u"plugins"][0], + **{u"version": u"1.6"}) + ] + } + class JenkinsPluginsInfoTest(JenkinsPluginsBase): @@ -129,6 +172,28 @@ class JenkinsPluginInfoTest(JenkinsPluginsBase): self.assertEqual(plugin_info, self.plugin_info_json['plugins'][0]) self._check_requests(jenkins_mock.call_args_list) + @patch.object(jenkins.Jenkins, 'jenkins_open') + def test_get_plugin_info_updated(self, jenkins_mock): + + jenkins_mock.side_effect = [ + json.dumps(self.plugin_info_json), + json.dumps(self.updated_plugin_info_json) + ] + j = jenkins.Jenkins('http://example.com/', 'test', 'test') + + plugins_info = j.get_plugins() + self.assertEqual(plugins_info["mailer"]["version"], + self.plugin_info_json['plugins'][0]["version"]) + + self.assertNotEqual( + plugins_info["mailer"]["version"], + self.updated_plugin_info_json['plugins'][0]["version"]) + + plugins_info = j.get_plugins() + self.assertEqual( + plugins_info["mailer"]["version"], + self.updated_plugin_info_json['plugins'][0]["version"]) + @patch.object(jenkins.Jenkins, 'jenkins_open') def test_return_none(self, jenkins_mock): jenkins_mock.return_value = json.dumps(self.plugin_info_json) @@ -191,3 +256,87 @@ class JenkinsPluginInfoTest(JenkinsPluginsBase): str(context_manager.exception), 'Error communicating with server[http://example.com/]') self._check_requests(jenkins_mock.call_args_list) + + +class PluginsTestScenarios(TestWithScenarios, JenkinsPluginsBase): + scenarios = [ + ('s1', dict(v1='1.0.0', op='__gt__', v2='0.8.0')), + ('s2', dict(v1='1.0.1alpha', op='__gt__', v2='1.0.0')), + ('s3', dict(v1='1.0', op='__eq__', v2='1.0.0')), + ('s4', dict(v1='1.0', op='__eq__', v2='1.0')), + ('s5', dict(v1='1.0', op='__lt__', v2='1.8.0')), + ('s6', dict(v1='1.0.1alpha', op='__lt__', v2='1.0.1')), + ('s7', dict(v1='1.0alpha', op='__lt__', v2='1.0.0')), + ('s8', dict(v1='1.0-alpha', op='__lt__', v2='1.0.0')), + ('s9', dict(v1='1.1-alpha', op='__gt__', v2='1.0')), + ('s10', dict(v1='1.0-SNAPSHOT', op='__lt__', v2='1.0')), + ('s11', dict(v1='1.0.preview', op='__lt__', v2='1.0')), + ('s12', dict(v1='1.1-SNAPSHOT', op='__gt__', v2='1.0')), + ('s13', dict(v1='1.0a-SNAPSHOT', op='__lt__', v2='1.0a')), + ] + + def setUp(self): + super(PluginsTestScenarios, self).setUp() + + plugin_info_json = dict(self.plugin_info_json) + plugin_info_json[u"plugins"][0][u"version"] = self.v1 + + patcher = patch.object(jenkins.Jenkins, 'jenkins_open') + self.jenkins_mock = patcher.start() + self.addCleanup(patcher.stop) + self.jenkins_mock.return_value = json.dumps(plugin_info_json) + + def test_plugin_version_comparison(self): + """Verify that valid versions are ordinally correct. + + That is, for each given scenario, v1.op(v2)==True where 'op' is the + equality operator defined for the scenario. + """ + plugin_name = "Jenkins Mailer Plugin" + j = jenkins.Jenkins('http://example.com/', 'test', 'test') + plugin_info = j.get_plugins()[plugin_name] + v1 = plugin_info.get("version") + + op = getattr(v1, self.op) + + self.assertTrue(op(self.v2), + msg="Unexpectedly found {0} {2} {1} == False " + "when comparing versions!" + .format(v1, self.v2, self.op)) + + def test_plugin_version_object_comparison(self): + """Verify use of PluginVersion for comparison + + Verify that converting the version to be compared to the same object + type of PluginVersion before comparing provides the same result. + """ + plugin_name = "Jenkins Mailer Plugin" + j = jenkins.Jenkins('http://example.com/', 'test', 'test') + plugin_info = j.get_plugins()[plugin_name] + v1 = plugin_info.get("version") + + op = getattr(v1, self.op) + v2 = plugins.PluginVersion(self.v2) + + self.assertTrue(op(v2), + msg="Unexpectedly found {0} {2} {1} == False " + "when comparing versions!" + .format(v1, v2, self.op)) + + +class PluginsTest(JenkinsPluginsBase): + + def test_plugin_equal(self): + + p1 = plugins.Plugin(self.plugin_info_json) + p2 = plugins.Plugin(self.plugin_info_json) + + self.assertEqual(p1, p2) + + def test_plugin_not_equal(self): + + p1 = plugins.Plugin(self.plugin_info_json) + p2 = plugins.Plugin(self.plugin_info_json) + p2[u'version'] = u"1.6" + + self.assertNotEqual(p1, p2)