diff -Nru python-jira-1.0.10/codecov.yml python-jira-2.0.0/codecov.yml --- python-jira-1.0.10/codecov.yml 1970-01-01 00:00:00.000000000 +0000 +++ python-jira-2.0.0/codecov.yml 2018-07-12 17:11:24.000000000 +0000 @@ -0,0 +1,4 @@ +--- +comment: + layout: diff + comment: false diff -Nru python-jira-1.0.10/.coveralls.yml python-jira-2.0.0/.coveralls.yml --- python-jira-1.0.10/.coveralls.yml 2017-02-10 19:26:23.000000000 +0000 +++ python-jira-2.0.0/.coveralls.yml 1970-01-01 00:00:00.000000000 +0000 @@ -1 +0,0 @@ -repo_token: TagM2COnwKYL3YaS7He9DkBPA7GFDQwsH diff -Nru python-jira-1.0.10/cspell.json python-jira-2.0.0/cspell.json --- python-jira-1.0.10/cspell.json 1970-01-01 00:00:00.000000000 +0000 +++ python-jira-2.0.0/cspell.json 2018-07-12 17:11:24.000000000 +0000 @@ -0,0 +1,185 @@ +{ + "version": "0.1", + "language": "en", + "words": [ + "Dalko", + "I18NSPHINXOPTS", + "TOXENV", + "addfinalizer", + "appid", + "atexit", + "atlassian", + "atlassians", + "ausername", + "bdist", + "bspeakmon", + "categorised", + "conda", + "cygwin", + "dae", + "Dalko", + "delete", + "deps", + "desk", + "devhelp", + "dgec", + "docutils", + "envars", + "envlist", + "envs", + "envvars", + "epub", + "errno", + "etree", + "favicon", + "favourite", + "favourites", + "fjira", + "fname", + "functools", + "fv", + "gerrit", + "googlicious", + "hashify", + "howto", + "hqi", + "iDalko", + "id", + "ifeq", + "ifndef", + "ifneq", + "igrid", + "imghdr", + "iname", + "incompleted", + "inexistent", + "instafail", + "ipython", + "issueid", + "issuperset", + "itil", + "jira", + "jirapython", + "jirapythondoc", + "jirashell", + "jspa", + "k", + "ky", + "kzh", + "lqqy", + "luk", + "makotemplate", + "mkdir", + "mktemp", + "myfilter", + "myid", + "navicat", + "nclqfp", + "netrc", + "nocheck", + "noqa", + "norecursedirs", + "oauth", + "oauthlib", + "onresolve", + "ornu", + "passenv", + "perc", + "posargs", + "printf", + "procs", + "proja", + "projb", + "project", + "pyargs", + "pycodestyle", + "pycontribs", + "pycrypto", + "pyenv", + "pyinstaller", + "pytest", + "qhcp", + "qthelp", + "reindex", + "reindexing", + "repo", + "repos", + "rnd", + "rndpassword", + "rrequirements", + "rsyncdirs", + "rsyncignore", + "rtype", + "sbarnea", + "schemeid", + "sdist", + "seqs", + "serialise", + "serialised", + "service", + "setenv", + "pyyaml", + "skipif", + "sorin", + "ssbarnea", + "str", + "strftime", + "symlinks", + "test", + "testenv", + "testsd", + "testvercomp", + "tfsds", + "th", + "toctree", + "tolower", + "toxinidir", + "transitionid", + "truthy", + "trw", + "twz", + "txcwsb", + "ucfirst", + "ul", + "uname", + "undoc", + "unstaged", + "untranslate", + "virtualenv", + "virtualenvs", + "websudo", + "woopsydoodle", + "workon", + "xargs", + "xdist", + "xenial", + "xfail", + "xscs", + "xsrf", + "yanc", + "ztravisdeb" + ], + "flagWords": [], + "allowCompoundWords": true, + "dictionaries": [ + "python", + "html", + "css" + ], + "ignoreRegExpList": [ + "/'s\\b/", + "/\\br'/", + "/\\bu'/", + "/\\b-rrequirements/", + "[^\\s]{20,}", + "/I18NSPHINXOPTS/" + ], + "ignorePaths": [ + "docs/build", + ".tox", + ".eggs" + ], + "ignoreWords": [ + "I18NSPHINXOPTS" + ] +} diff -Nru python-jira-1.0.10/debian/changelog python-jira-2.0.0/debian/changelog --- python-jira-1.0.10/debian/changelog 2018-12-27 22:02:24.000000000 +0000 +++ python-jira-2.0.0/debian/changelog 2019-01-12 12:27:25.000000000 +0000 @@ -1,3 +1,14 @@ +python-jira (2.0.0-1) unstable; urgency=medium + + * Team upload. + * Switch watch file to github + * New upstream version 2.0.0 + * Remove patch (merged upstream) + * Bump policy version (no changes) + * Update copyright + + -- Jochen Sprickerhof Sat, 12 Jan 2019 13:27:25 +0100 + python-jira (1.0.10-3) unstable; urgency=medium * Team upload. diff -Nru python-jira-1.0.10/debian/control python-jira-2.0.0/debian/control --- python-jira-1.0.10/debian/control 2018-12-26 18:17:42.000000000 +0000 +++ python-jira-2.0.0/debian/control 2019-01-12 12:18:57.000000000 +0000 @@ -30,7 +30,7 @@ python3-tenacity, subunit, testrepository, -Standards-Version: 4.1.5 +Standards-Version: 4.3.0 Rules-Requires-Root: no Homepage: https://github.com/pycontribs/jira Vcs-Git: https://salsa.debian.org/python-team/modules/python-jira.git diff -Nru python-jira-1.0.10/debian/copyright python-jira-2.0.0/debian/copyright --- python-jira-1.0.10/debian/copyright 2018-12-26 17:52:05.000000000 +0000 +++ python-jira-2.0.0/debian/copyright 2019-01-12 12:27:08.000000000 +0000 @@ -36,29 +36,6 @@ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -Files: jira/utils/lru_cache.py -Copyright: 2013 Raymond Hettinger -License: MIT - 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. -Comment: find the confirmation of the license and copyright here: https://github.com/ActiveState/code/blob/master/recipes/Python/578078_Py26_Py30_backport_Pyth33s_LRU/LICENSE.md - Files: debian/* Copyright: 2017 Sophie Brun License: BSD-2-clause diff -Nru python-jira-1.0.10/debian/patches/fix-install-python3.7.patch python-jira-2.0.0/debian/patches/fix-install-python3.7.patch --- python-jira-1.0.10/debian/patches/fix-install-python3.7.patch 2018-12-26 17:52:05.000000000 +0000 +++ python-jira-2.0.0/debian/patches/fix-install-python3.7.patch 1970-01-01 00:00:00.000000000 +0000 @@ -1,149 +0,0 @@ -From 206f62b490d792a9c4a10680489ef9f7bc10cb3f Mon Sep 17 00:00:00 2001 -From: Sorin Sbarnea -Date: Tue, 3 Jul 2018 09:40:01 +0100 -Subject: [PATCH] api: py37 support and rename async to async_ (#604) - -Breaking change as is renames all async parameters to async_ -in order to assure python 3.7 compatibility. - -In Debian: just pick up the relevant modifications for the version we -have. ---- a/jira/client.py -+++ b/jira/client.py -@@ -196,7 +196,7 @@ class JIRA(object): - AGILE_BASE_URL = GreenHopperResource.AGILE_BASE_URL - - def __init__(self, server=None, options=None, basic_auth=None, oauth=None, jwt=None, kerberos=False, -- validate=False, get_server_info=True, async=False, logging=True, max_retries=3, proxies=None, -+ validate=False, get_server_info=True, async_=False, logging=True, max_retries=3, proxies=None, - timeout=None): - """Construct a JIRA client instance. - -@@ -241,7 +241,7 @@ class JIRA(object): - as anononymous it will fail to instanciate. - :param get_server_info: If true it will fetch server version info first to determine if some API calls - are available. -- :param async: To enable async requests for those actions where we implemented it, like issue update() or delete(). -+ :param async_: To enable async requests for those actions where we implemented it, like issue update() or delete(). - :param timeout: Set a read/connect timeout for the underlying calls to JIRA (default: None) - Obviously this means that you cannot rely on the return code when this is enabled. - """ -@@ -260,8 +260,8 @@ class JIRA(object): - - if server: - options['server'] = server -- if async: -- options['async'] = async -+ if async_: -+ options['async'] = async_ - - self.logging = logging - -@@ -476,12 +476,12 @@ class JIRA(object): - return resource - - def async_do(self, size=10): -- """Execute all async jobs and wait for them to finish. By default it will run on 10 threads. -+ """Execute all asynchronous jobs and wait for them to finish. By default it will run on 10 threads. - - :param size: number of threads to run on. - """ - if hasattr(self._session, '_async_jobs'): -- logging.info("Executing async %s jobs found in queue by using %s threads..." % ( -+ logging.info("Executing asynchronous %s jobs found in queue by using %s threads..." % ( - len(self._session._async_jobs), size)) - threaded_requests.map(self._session._async_jobs, size=size) - -@@ -3072,8 +3072,8 @@ class JIRA(object): - - class GreenHopper(JIRA): - -- def __init__(self, options=None, basic_auth=None, oauth=None, async=None): -+ def __init__(self, options=None, basic_auth=None, oauth=None, async_=None): - warnings.warn( - "GreenHopper() class is deprecated, just use JIRA() instead.", DeprecationWarning) - JIRA.__init__( -- self, options=options, basic_auth=basic_auth, oauth=oauth, async=async) -+ self, options=options, basic_auth=basic_auth, oauth=oauth, async_=async_) ---- a/jira/resources.py -+++ b/jira/resources.py -@@ -201,17 +201,17 @@ class Resource(object): - options.update({'path': path}) - return self._base_url.format(**options) - -- def update(self, fields=None, async=None, jira=None, notify=True, **kwargs): -+ def update(self, fields=None, async_=None, jira=None, notify=True, **kwargs): - """Update this resource on the server. - - Keyword arguments are marshalled into a dict before being sent. If this - resource doesn't support ``PUT``, a :py:exc:`.JIRAError` will be raised; subclasses that specialize this method - will only raise errors in case of user error. - -- :param async: if true the request will be added to the queue so it can be executed later using async_run() -+ :param async_: if true the request will be added to the queue so it can be executed later using async_run() - """ -- if async is None: -- async = self._options['async'] -+ if async_ is None: -+ async_ = self._options['async'] - - data = {} - if fields is not None: -@@ -280,7 +280,7 @@ class Resource(object): - # data['fields']['assignee'] = {'name': self._options['autofix']} - # EXPERIMENTAL ---> - # import grequests -- if async: -+ if async_: - if not hasattr(self._session, '_async_jobs'): - self._session._async_jobs = set() - self._session._async_jobs.add(threaded_requests.put( -@@ -429,7 +429,7 @@ class Issue(Resource): - if raw: - self._parse_raw(raw) - -- def update(self, fields=None, update=None, async=None, jira=None, notify=True, **fieldargs): -+ def update(self, fields=None, update=None, async_=None, jira=None, notify=True, **fieldargs): - """Update this issue on the server. - - Each keyword argument (other than the predefined ones) is treated as a field name and the argument's value -@@ -477,7 +477,7 @@ class Issue(Resource): - else: - fields_dict[field] = value - -- super(Issue, self).update(async=async, jira=jira, notify=notify, fields=data) -+ super(Issue, self).update(async_=async_, jira=jira, notify=notify, fields=data) - - def add_field_value(self, field, value): - """Add a value to a field that supports multiple values, without resetting the existing values. -@@ -513,7 +513,7 @@ class Comment(Resource): - if raw: - self._parse_raw(raw) - -- def update(self, fields=None, async=None, jira=None, body='', visibility=None): -+ def update(self, fields=None, async_=None, jira=None, body='', visibility=None): - data = {} - if body: - data['body'] = body ---- a/requirements-dev.txt -+++ b/requirements-dev.txt -@@ -14,6 +14,9 @@ pytest-cov - pytest-instafail - pytest-xdist>=1.14 - pytest>=2.9.1 -+PyYAML>=3.12,<4; python_version<'3.7' -+# this file is needed by readthedocs.org so don't move them in another place -+PyYAML>=4.2b2; python_version>='3.7' - requires.io - sphinx>=1.3.5 - sphinx_rtd_theme ---- a/tox.ini -+++ b/tox.ini -@@ -1,6 +1,6 @@ - [tox] - minversion = 2.3.1 --envlist = {py27,py34,py35,py36}-{win,linux,darwin},docs -+envlist = {py27,py34,py35,py36,py37}-{win,linux,darwin},docs - skip_missing_interpreters = true - - [testenv:docs] diff -Nru python-jira-1.0.10/debian/patches/series python-jira-2.0.0/debian/patches/series --- python-jira-1.0.10/debian/patches/series 2018-12-26 17:52:05.000000000 +0000 +++ python-jira-2.0.0/debian/patches/series 1970-01-01 00:00:00.000000000 +0000 @@ -1 +0,0 @@ -fix-install-python3.7.patch diff -Nru python-jira-1.0.10/debian/watch python-jira-2.0.0/debian/watch --- python-jira-1.0.10/debian/watch 2018-12-26 17:52:05.000000000 +0000 +++ python-jira-2.0.0/debian/watch 2019-01-12 11:59:34.000000000 +0000 @@ -1,2 +1,4 @@ version=4 -https://pypi.debian.net/jira/jira-(.+)\.(?:zip|tgz|tbz|txz|(?:tar\.(?:gz|bz2|xz))) +opts="filenamemangle=s%(?:.*?)?v?(\d[\d.]*)\.tar\.gz%python-jira-$1.tar.gz%" \ + https://github.com/pycontribs/jira/tags \ + (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate diff -Nru python-jira-1.0.10/docs/api.rst python-jira-2.0.0/docs/api.rst --- python-jira-1.0.10/docs/api.rst 2017-02-10 19:26:23.000000000 +0000 +++ python-jira-2.0.0/docs/api.rst 2018-07-12 17:11:24.000000000 +0000 @@ -11,6 +11,11 @@ .. autoclass:: JIRA +Issue +======== + +.. autoclass:: Issue + Priority ======== diff -Nru python-jira-1.0.10/docs/conf.py python-jira-2.0.0/docs/conf.py --- python-jira-1.0.10/docs/conf.py 2017-02-10 19:26:23.000000000 +0000 +++ python-jira-2.0.0/docs/conf.py 2018-07-12 17:11:24.000000000 +0000 @@ -24,7 +24,7 @@ # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. -# needs_sphinx = '1.0' +needs_sphinx = '1.6.5' # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. @@ -73,7 +73,7 @@ # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: -# today = '' +today = "1" # Else, today_fmt is used as the format for a strftime call. # today_fmt = '%B %d, %Y' @@ -140,7 +140,7 @@ # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -# html_last_updated_fmt = '%b %d, %Y' +html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. @@ -166,10 +166,10 @@ html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -html_show_sphinx = True +html_show_sphinx = False # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -html_show_copyright = True +html_show_copyright = False # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the diff -Nru python-jira-1.0.10/docs/contributing.rst python-jira-2.0.0/docs/contributing.rst --- python-jira-1.0.10/docs/contributing.rst 2017-02-10 19:26:23.000000000 +0000 +++ python-jira-2.0.0/docs/contributing.rst 2018-07-12 17:11:24.000000000 +0000 @@ -1,17 +1,127 @@ +************ Contributing ************ -The client is an open source project under the BSD license. Contributions of any kind are welcome! +The client is an open source project under the BSD license. +Contributions of any kind are welcome! https://github.com/pycontribs/jira/ -If you find a bug or have an idea for a useful feature, file it at that bitbucket project. Extra points for source -code patches -- fork and send a pull request. +If you find a bug or have an idea for a useful feature, file it at the GitHub +project. Extra points for source code patches -- fork and send a pull request. + Discussion and support -====================== +********************** + +We encourage all who wish to discuss by using https://community.atlassian.com/t5/tag/jira-python/tg-p + +Keep in mind to use the jira-python tag when you add a new question. This will +assure that the project maintainers will get notified about your question. + + +Contributing Code +***************** + +* Patches should be: + * concise + * works across all supported versions of Python. + * follows the existing style of the code base (PEP-8). + * has comments included as needed. + +* Great Patch has: + * A test case that demonstrates the previous flaw that now passes with the included patch. + * Documentation for those changes to a public API + + +Testing +******* + +To test code run:: + + make test-all + +This will run the code in a virtual environment, and will test across the +versions of python which are installed. It will also install tox if it is +not already installed. + +Alternatively if you do not have make you can always run:: + + pip install tox + tox + +Issues and Feature Requests +*************************** + +* Check to see if there's an existing issue/pull request for the + bug/feature. All issues are at https://github.com/pycontribs/jira/issues + and pull requests are at https://github.com/pycontribs/jira/pulls. +* If there isn't an existing issue there, please file an issue. The ideal + report includes: + + * A description of the problem/suggestion. + * How to recreate the bug. + * If relevant, including the versions of your: + + * Python interpreter (2.7, 3.5, etc) + * jira-python + * Operating System and Version (Windows 7, OS X 10.10, Ubuntu 14.04, etc.) + * IPython if using jirashell + * Optionally of the other dependencies involved + + * If possible, create a pull request with a (failing) test case demonstrating + what's wrong. This makes the process for fixing bugs quicker & gets issues + resolved sooner. + * Here is an template + :: + + Description: + + Python Interpreter: + jira-python: + OS: + IPython (Optional): + Other Dependencies: + + Steps To Reproduce: + 1. + 2. + 3. + ... + + Stack Trace: + + + +Issues +****** +Here are the best ways to help with open issues: + +* For issues without reproduction steps + * Try to reproduce the issue, comment with the minimal amount of steps to + reproduce the bug (a code snippet would be ideal). + * If there is not a set of steps that can be made to reproduce the issue, + at least make sure there are debug logs that capture the unexpected behavior. + +* Submit pull requests for open issues. + + +Pull Requests +************* +There are some key points that are needed to be met before a pull request +can be merged: + +* All tests must pass for all python versions. (Once the Test Framework is fixed) + * For now, no new failures should occur -We encourage all who wish to discuss by using https://answers.atlassian.com/questions/topics/754366/jira-python +* All pull requests require tests that either test the new feature or test + that the specific bug is fixed. Pull requests for minor things like + adding a new region or fixing a typo do not need tests. +* Must follow PEP8 conventions. +* Within a major version changes must be backwards compatible. -Keep in mind to use the jira-python tag when you add a new question. This will assure that the project mantainers -will get notified about your question. +The best way to help with pull requests is to comment on pull requests by +noting if any of these key points are missing, it will both help get feedback +sooner to the issuer of the pull request and make it easier to determine for +an individual with write permissions to the repository if a pull request +is ready to be merged. diff -Nru python-jira-1.0.10/docs/examples.rst python-jira-2.0.0/docs/examples.rst --- python-jira-1.0.10/docs/examples.rst 2017-02-10 19:26:23.000000000 +0000 +++ python-jira-2.0.0/docs/examples.rst 2018-07-12 17:11:24.000000000 +0000 @@ -44,12 +44,22 @@ The library is able to load the credentials from inside the ~/.netrc file, so put them there instead of keeping them in your source code. +Cookie Based Authentication +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Pass a tuple of (username, password) to the ``auth`` constructor argument:: + + auth_jira = JIRA(auth=('username', 'password')) + +Using this method, authentication happens during then initialization of the object. If the authentication is successful, +the retrieved session cookie will be used in future requests. Upon cookie expiration, authentication will happen again transparently. + HTTP BASIC ^^^^^^^^^^ Pass a tuple of (username, password) to the ``basic_auth`` constructor argument:: - authed_jira = JIRA(basic_auth=('username', 'password')) + auth_jira = JIRA(basic_auth=('username', 'password')) OAuth ^^^^^ @@ -62,12 +72,12 @@ key_cert_data = key_cert_file.read() oauth_dict = { - 'access_token': 'd87f3hajglkjh89a97f8', - 'access_token_secret': 'a9f8ag0ehaljkhgeds90', + 'access_token': 'foo', + 'access_token_secret': 'bar', 'consumer_key': 'jira-oauth-consumer', 'key_cert': key_cert_data } - authed_jira = JIRA(oauth=oauth_dict) + auth_jira = JIRA(oauth=oauth_dict) .. note :: The OAuth access tokens must be obtained and authorized ahead of time through the standard OAuth dance. For @@ -89,7 +99,11 @@ To enable Kerberos auth, set ``kerberos=True``:: - authed_jira = JIRA(kerberos=True) + auth_jira = JIRA(kerberos=True) + +To pass additional options to Kerberos auth use dict ``kerberos_options``, e.g.:: + + auth_jira = JIRA(kerberos=True, kerberos_options={'mutual_authentication': 'DISABLED'}) .. _jirashell-label: @@ -215,6 +229,7 @@ Leverage the power of `JQL `_ to quickly find the issues you want:: + # Search returns first 50 results, `maxResults` must be set to exceed this issues_in_proj = jira.search_issues('project=PROJ') all_proj_issues_but_mine = jira.search_issues('project=PROJ and assignee != currentUser()') @@ -222,7 +237,8 @@ oh_crap = jira.search_issues('assignee = currentUser() and due < endOfWeek() order by priority desc', maxResults=5) # Summaries of my last 3 reported issues - print [issue.fields.summary for issue in jira.search_issues('reporter = currentUser() order by created desc', maxResults=3)] + for issue in jira.search_issues('reporter = currentUser() order by created desc', maxResults=3): + print('{}: {}'.format(issue.key, issue.fields.summary)) Comments -------- @@ -263,7 +279,7 @@ jira.transition_issue(issue, '5', assignee={'name': 'pm_user'}, resolution={'id': '3'}) # The above line is equivalent to: - jira.transition_issue(issue, '5', fields: {'assignee':{'name': 'pm_user'}, 'resolution':{'id': '3'}}) + jira.transition_issue(issue, '5', fields={'assignee':{'name': 'pm_user'}, 'resolution':{'id': '3'}}) Projects -------- @@ -276,7 +292,7 @@ jra = jira.project('JRA') print(jra.name) # 'JIRA' - print(jra.lead.displayName) # 'Paul Slade [Atlassian]' + print(jra.lead.displayName) # 'John Doe [ACME Inc.]' It's no trouble to get the components, versions or roles either (assuming you have permission):: diff -Nru python-jira-1.0.10/docs/installation.rst python-jira-2.0.0/docs/installation.rst --- python-jira-1.0.10/docs/installation.rst 2017-02-10 19:26:23.000000000 +0000 +++ python-jira-2.0.0/docs/installation.rst 2018-07-12 17:11:24.000000000 +0000 @@ -30,11 +30,11 @@ Python 2.7 and Python 3.x are both supported. -- :py:mod:`requests` - Kenneth Reitz's indispensable `python-requests `_ library handles the HTTP business. Usually, the latest version available at time of release is the minimum version required; at this writing, that version is 1.2.0, but any version >= 1.0.0 should work. +- :py:mod:`requests` - `python-requests `_ library handles the HTTP business. Usually, the latest version available at time of release is the minimum version required; at this writing, that version is 1.2.0, but any version >= 1.0.0 should work. - :py:mod:`requests-oauthlib` - Used to implement OAuth. The latest version as of this writing is 0.3.3. - :py:mod:`requests-kerberos` - Used to implement Kerberos. - :py:mod:`ipython` - The `IPython enhanced Python interpreter `_ provides the fancy chrome used by :ref:`jirashell-label`. -- :py:mod:`filemagic` - This library handles content-type autodetection for things like image uploads. This will only work on a system that provides libmagic; Mac and Unix will almost always have it preinstalled, but Windows users will have to use Cygwin or compile it natively. If your system doesn't have libmagic, you'll have to manually specify the ``contentType`` parameter on methods that take an image object, such as project and user avater creation. +- :py:mod:`filemagic` - This library handles content-type autodetection for things like image uploads. This will only work on a system that provides libmagic; Mac and Unix will almost always have it preinstalled, but Windows users will have to use Cygwin or compile it natively. If your system doesn't have libmagic, you'll have to manually specify the ``contentType`` parameter on methods that take an image object, such as project and user avatar creation. - :py:mod:`pycrypto` - This is required for the RSA-SHA1 used by OAuth. Please note that it's **not** installed automatically, since it's a fairly cumbersome process in Windows. On Linux and OS X, a ``pip install pycrypto`` should do it. Installing through :py:mod:`pip` takes care of these dependencies for you. diff -Nru python-jira-1.0.10/docs/Makefile python-jira-2.0.0/docs/Makefile --- python-jira-1.0.10/docs/Makefile 2017-02-10 19:26:23.000000000 +0000 +++ python-jira-2.0.0/docs/Makefile 2018-07-12 17:11:24.000000000 +0000 @@ -2,10 +2,11 @@ # # You can set these variables from the command line. -SPHINXOPTS = +SPHINXOPTS = -W SPHINXBUILD = sphinx-build PAPER = BUILDDIR = build +SOURCE_DATE_EPOCH = $(shell git log -1 --format=%ct) # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 diff -Nru python-jira-1.0.10/docs/requirements-rtd.txt python-jira-2.0.0/docs/requirements-rtd.txt --- python-jira-1.0.10/docs/requirements-rtd.txt 1970-01-01 00:00:00.000000000 +0000 +++ python-jira-2.0.0/docs/requirements-rtd.txt 2018-07-12 17:11:24.000000000 +0000 @@ -0,0 +1,3 @@ +pbr>=3.0.0 +sphinx>=1.7.1 +sphinx_rtd_theme diff -Nru python-jira-1.0.10/.editorconfig python-jira-2.0.0/.editorconfig --- python-jira-1.0.10/.editorconfig 1970-01-01 00:00:00.000000000 +0000 +++ python-jira-2.0.0/.editorconfig 2018-07-12 17:11:24.000000000 +0000 @@ -0,0 +1,7 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +max_line_length = 160 diff -Nru python-jira-1.0.10/examples/cookie_auth.py python-jira-2.0.0/examples/cookie_auth.py --- python-jira-1.0.10/examples/cookie_auth.py 1970-01-01 00:00:00.000000000 +0000 +++ python-jira-2.0.0/examples/cookie_auth.py 2018-07-12 17:11:24.000000000 +0000 @@ -0,0 +1,26 @@ +# This script shows how to connect to a JIRA instance with a +# username and password over HTTP BASIC authentication. + +from collections import Counter +from jira import JIRA + +# By default, the client will connect to a JIRA instance started from the Atlassian Plugin SDK. +# See +# https://developer.atlassian.com/display/DOCS/Installing+the+Atlassian+Plugin+SDK +# for details. +jira = JIRA(auth=('admin', 'admin')) # a username/password tuple for cookie auth + +# Get the mutable application properties for this server (requires +# jira-system-administrators permission) +props = jira.application_properties() + +# Find all issues reported by the admin +issues = jira.search_issues('assignee=admin') + +# Find the top three projects containing issues reported by admin +top_three = Counter( + [issue.fields.project.key for issue in issues]).most_common(3) + +# import time; time.sleep(65) # Fake cookie expiration + +issues = jira.search_issues('assignee=admin') diff -Nru python-jira-1.0.10/.gitchangelog.rc python-jira-2.0.0/.gitchangelog.rc --- python-jira-1.0.10/.gitchangelog.rc 2017-02-10 19:26:23.000000000 +0000 +++ python-jira-2.0.0/.gitchangelog.rc 2018-07-12 17:11:24.000000000 +0000 @@ -1,61 +1,3 @@ -## -## Format -## -## ACTION: [AUDIENCE:] COMMIT_MSG [!TAG ...] -## -## Description -## -## ACTION is one of 'chg', 'fix', 'new' -## -## Is WHAT the change is about. -## -## 'chg' is for refactor, small improvement, cosmetic changes... -## 'fix' is for bug fixes -## 'new' is for new features, big improvement -## -## AUDIENCE is optional and one of 'dev', 'usr', 'pkg', 'test', 'doc' -## -## Is WHO is concerned by the change. -## -## 'dev' is for developpers (API changes, refactors...) -## 'usr' is for final users (UI changes) -## 'pkg' is for packagers (packaging changes) -## 'test' is for testers (test only related changes) -## 'doc' is for doc guys (doc only changes) -## -## COMMIT_MSG is ... well ... the commit message itself. -## -## TAGs are additionnal adjective as 'refactor' 'minor' 'cosmetic' -## -## They are preceded with a '!' or a '@' (prefer the former, as the -## latter is wrongly interpreted in github.) Commonly used tags are: -## -## 'refactor' is obviously for refactoring code only -## 'minor' is for a very meaningless change (a typo, adding a comment) -## 'cosmetic' is for cosmetic driven change (re-indentation, 80-col...) -## 'wip' is for partial functionality but complete subfunctionality. -## -## Example: -## -## new: usr: support of bazaar implemented -## chg: re-indentend some lines !cosmetic -## new: dev: updated code to be compatible with last version of killer lib. -## fix: pkg: updated year of licence coverage. -## new: test: added a bunch of test around user usability of feature X. -## fix: typo in spelling my name in comment. !minor -## -## Please note that multi-line commit message are supported, and only the -## first line will be considered as the "summary" of the commit message. So -## tags, and other rules only applies to the summary. The body of the commit -## message will be displayed in the changelog without reformatting. - - -## -## ``ignore_regexps`` is a line of regexps -## -## Any commit having its full commit message matching any regexp listed here -## will be ignored and won't be reported in the changelog. -## ignore_regexps = [ # ignore trivial fixes r'Auto-generating.*', @@ -66,115 +8,30 @@ r'Merge pull request #\d+ from.*' ] - -## ``section_regexps`` is a list of 2-tuples associating a string label and a -## list of regexp -## -## Commit messages will be classified in sections thanks to this. Section -## titles are the label, and a commit is classified under this section if any -## of the regexps associated is matching. -## section_regexps = [ ('Other', None), ## Match all lines ] -## ``body_process`` is a callable -## -## This callable will be given the original body and result will -## be used in the changelog. -## -## Available constructs are: -## -## - any python callable that take one txt argument and return txt argument. -## -## - ReSub(pattern, replacement): will apply regexp substitution. -## -## - Indent(chars=" "): will indent the text with the prefix -## Please remember that template engines gets also to modify the text and -## will usually indent themselves the text if needed. -## -## - Wrap(regexp=r"\n\n"): re-wrap text in separate paragraph to fill 80-Columns -## -## - noop: do nothing -## -## - ucfirst: ensure the first letter is uppercase. -## (usually used in the ``subject_process`` pipeline) -## -## - final_dot: ensure text finishes with a dot -## (usually used in the ``subject_process`` pipeline) -## -## - strip: remove any spaces before or after the content of the string -## -## Additionally, you can `pipe` the provided filters, for instance: -#body_process = Wrap(regexp=r'\n(?=\w+\s*:)') | Indent(chars=" ") -#body_process = Wrap(regexp=r'\n(?=\w+\s*:)') -#body_process = noop empty_string = lambda _: '' body_process = empty_string -## ``subject_process`` is a callable -## -## This callable will be given the original subject and result will -## be used in the changelog. -## -## Available constructs are those listed in ``body_process`` doc. subject_process = (strip | ReSub(r'^([cC]hg|[fF]ix|[nN]ew)\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n@]*)(@[a-z]+\s+)*$', r'\4') | ucfirst | final_dot) -## ``tag_filter_regexp`` is a regexp -## -## Tags that will be used for the changelog must match this regexp. -## tag_filter_regexp = r'^[0-9]+\.[0-9]+(\.[0-9]+)?$' -## ``unreleased_version_label`` is a string -## -## This label will be used as the changelog Title of the last set of changes -## between last valid tag and HEAD if any. unreleased_version_label = "Upcoming release (unreleased changes)" -## ``output_engine`` is a callable -## -## This will change the output format of the generated changelog file -## -## Available choices are: -## -## - rest_py -## -## Legacy pure python engine, outputs ReSTructured text. -## This is the default. -## -## - mustache() -## -## Template name could be any of the available templates in -## ``templates/mustache/*.tpl``. -## Requires python package ``pystache``. -## Examples: -## - mustache("markdown") -## - mustache("restructuredtext") -## -## - makotemplate() -## -## Template name could be any of the available templates in -## ``templates/mako/*.tpl``. -## Requires python package ``mako``. -## Examples: -## - makotemplate("restructuredtext") -## output_engine = rest_py #output_engine = mustache("restructuredtext") #output_engine = mustache("markdown") #output_engine = makotemplate("restructuredtext") -## ``include_merge`` is a boolean -## -## This option tells git-log whether to include merge commits in the log. -## The default is to include them. include_merge = True diff -Nru python-jira-1.0.10/.github/ISSUE_TEMPLATE/bug_report.md python-jira-2.0.0/.github/ISSUE_TEMPLATE/bug_report.md --- python-jira-1.0.10/.github/ISSUE_TEMPLATE/bug_report.md 1970-01-01 00:00:00.000000000 +0000 +++ python-jira-2.0.0/.github/ISSUE_TEMPLATE/bug_report.md 2018-07-12 17:11:24.000000000 +0000 @@ -0,0 +1,35 @@ +--- +name: Bug report +about: Create a report to help us improve + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +``` +Code block +``` +1. Any additional steps or considerations that happen before or after. + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Stack Trace** +``` +A code block with the any trace messages. +``` + + +**Version Information** +Python Interpreter: +jira-python: +OS: +IPython (Optional): +Other Dependencies: + + +**Additional context** +Add any other context about the problem here. diff -Nru python-jira-1.0.10/.github/ISSUE_TEMPLATE/feature_request.md python-jira-2.0.0/.github/ISSUE_TEMPLATE/feature_request.md --- python-jira-1.0.10/.github/ISSUE_TEMPLATE/feature_request.md 1970-01-01 00:00:00.000000000 +0000 +++ python-jira-2.0.0/.github/ISSUE_TEMPLATE/feature_request.md 2018-07-12 17:11:24.000000000 +0000 @@ -0,0 +1,17 @@ +--- +name: Feature request +about: Suggest an idea for this project + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff -Nru python-jira-1.0.10/.gitignore python-jira-2.0.0/.gitignore --- python-jira-1.0.10/.gitignore 2017-02-10 19:26:23.000000000 +0000 +++ python-jira-2.0.0/.gitignore 2018-07-12 17:11:24.000000000 +0000 @@ -1,4 +1,8 @@ -.idea/ +# See http://stackoverflow.com/questions/5533050/gitignore-exclude-folder-but-include-specific-subfolder +# to understand pattern used to include .idea/codeStyleSettings.xml but not the rest of .idea/ +!.idea/ +.idea/* +!.idea/codeStyleSettings.xml *.bak *.egg *.egg-info/ @@ -8,7 +12,7 @@ .coverage.* .eggs/ .tox/ -amps-standalone +amps-standalone* coverage.xml docs/_build docs/build @@ -26,3 +30,6 @@ /ChangeLog /AUTHORS /tests/build +/.pytest_cache +/.vscode +/node_modules diff -Nru python-jira-1.0.10/hooks/pre-commit python-jira-2.0.0/hooks/pre-commit --- python-jira-1.0.10/hooks/pre-commit 2017-02-10 19:26:23.000000000 +0000 +++ python-jira-2.0.0/hooks/pre-commit 1970-01-01 00:00:00.000000000 +0000 @@ -1,4 +0,0 @@ -#!/bin/sh -set -e -python -m autopep8 --in-place jira/*.py setup.py tests/*.py examples/*.py --recursive - diff -Nru python-jira-1.0.10/.idea/codeStyleSettings.xml python-jira-2.0.0/.idea/codeStyleSettings.xml --- python-jira-1.0.10/.idea/codeStyleSettings.xml 1970-01-01 00:00:00.000000000 +0000 +++ python-jira-2.0.0/.idea/codeStyleSettings.xml 2018-07-12 17:11:24.000000000 +0000 @@ -0,0 +1,9 @@ + + + + + + diff -Nru python-jira-1.0.10/jira/client.py python-jira-2.0.0/jira/client.py --- python-jira-1.0.10/jira/client.py 2017-02-10 19:26:23.000000000 +0000 +++ python-jira-2.0.0/jira/client.py 2018-07-12 17:11:24.000000000 +0000 @@ -3,6 +3,8 @@ from __future__ import unicode_literals from __future__ import print_function +from requests.auth import AuthBase + """ This module implements a friendly (well, friendlier) interface between the raw JSON responses from JIRA and the Resource/dict abstractions provided by this library. Users @@ -41,16 +43,41 @@ from requests.utils import get_netrc_auth from six import iteritems from six.moves.urllib.parse import urlparse -# JIRA specific resources -from jira.resources import * # NOQA # GreenHopper specific resources from jira.exceptions import JIRAError from jira.resilientsession import raise_on_error from jira.resilientsession import ResilientSession +# JIRA specific resources +from jira.resources import Attachment from jira.resources import Board +from jira.resources import Comment +from jira.resources import Component +from jira.resources import Customer +from jira.resources import CustomFieldOption +from jira.resources import Dashboard +from jira.resources import Filter from jira.resources import GreenHopperResource +from jira.resources import Issue +from jira.resources import IssueLink +from jira.resources import IssueLinkType +from jira.resources import IssueType +from jira.resources import Priority +from jira.resources import Project +from jira.resources import RemoteLink +from jira.resources import RequestType +from jira.resources import Resolution +from jira.resources import Resource +from jira.resources import Role +from jira.resources import SecurityLevel +from jira.resources import ServiceDesk from jira.resources import Sprint +from jira.resources import Status +from jira.resources import User +from jira.resources import Version +from jira.resources import Votes +from jira.resources import Watchers +from jira.resources import Worklog from jira import __version__ from jira.utils import CaseInsensitiveDict @@ -58,21 +85,12 @@ from jira.utils import threaded_requests from pkg_resources import parse_version -try: - from collections import OrderedDict -except ImportError: - # noinspection PyUnresolvedReferences - from ordereddict import OrderedDict +from collections import OrderedDict from six import integer_types from six import string_types # six.moves does not play well with pyinstaller, see https://github.com/pycontribs/jira/issues/38 -# from six.moves import html_parser -if sys.version_info < (3, 0, 0): - import HTMLParser as html_parser -else: - import html.parser as html_parser try: # noinspection PyUnresolvedReferences from requests_toolbelt import MultipartEncoder @@ -129,7 +147,7 @@ class ResultList(list): - def __init__(self, iterable=None, _startAt=None, _maxResults=None, _total=None, _isLast=None): + def __init__(self, iterable=None, _startAt=0, _maxResults=0, _total=0, _isLast=None): if iterable is not None: list.__init__(self, iterable) else: @@ -141,6 +159,18 @@ self.isLast = _isLast self.total = _total + self.iterable = iterable or [] + self.current = self.startAt + + def __next__(self): + self.current += 1 + if self.current > self.total: + raise StopIteration + else: + return self.iterable[self.current - 1] + # Python 2 and 3 compat + next = __next__ + class QshGenerator(object): @@ -151,10 +181,61 @@ parse_result = urlparse(req.url) path = parse_result.path[len(self.context_path):] if len(self.context_path) > 1 else parse_result.path - query = '&'.join(sorted(parse_result.query.split("&"))) + # Per Atlassian docs, use %20 for whitespace when generating qsh for URL + # https://developer.atlassian.com/cloud/jira/platform/understanding-jwt/#qsh + query = '&'.join(sorted(parse_result.query.split("&"))).replace('+', '%20') qsh = '%(method)s&%(path)s&%(query)s' % {'method': req.method.upper(), 'path': path, 'query': query} - return hashlib.sha256(qsh).hexdigest() + return hashlib.sha256(qsh.encode('utf-8')).hexdigest() + + +class JiraCookieAuth(AuthBase): + """Jira Cookie Authentication + + Allows using cookie authentication as described by + https://developer.atlassian.com/jiradev/jira-apis/jira-rest-apis/jira-rest-api-tutorials/jira-rest-api-example-cookie-based-authentication + + """ + + def __init__(self, session, _get_session, auth): + self._session = session + self._get_session = _get_session + self.__auth = auth + + def handle_401(self, response, **kwargs): + if response.status_code != 401: + return response + self.init_session() + response = self.process_original_request(response.request.copy()) + return response + + def process_original_request(self, original_request): + self.update_cookies(original_request) + return self.send_request(original_request) + + def update_cookies(self, original_request): + # Cookie header needs first to be deleted for the header to be updated using + # the prepare_cookies method. See request.PrepareRequest.prepare_cookies + if 'Cookie' in original_request.headers: + del original_request.headers['Cookie'] + original_request.prepare_cookies(self.cookies) + + def init_session(self): + self.start_session() + + def __call__(self, request): + request.register_hook('response', self.handle_401) + return request + + def send_request(self, request): + return self._session.send(request) + + @property + def cookies(self): + return self._session.cookies + + def start_session(self): + self._get_session(self.__auth) class JIRA(object): @@ -166,10 +247,71 @@ of the form ``issue.fields.summary`` will be resolved into the proper lookups to return the JSON value at that mapping. Methods that do not return resources will return a dict constructed from the JSON response or a scalar value; see each method's documentation for details on what that method returns. + + Without any arguments, this client will connect anonymously to the JIRA instance + started by the Atlassian Plugin SDK from one of the 'atlas-run', ``atlas-debug``, + or ``atlas-run-standalone`` commands. By default, this instance runs at + ``http://localhost:2990/jira``. The ``options`` argument can be used to set the JIRA instance to use. + + Authentication is handled with the ``basic_auth`` argument. If authentication is supplied (and is + accepted by JIRA), the client will remember it for subsequent requests. + + For quick command line access to a server, see the ``jirashell`` script included with this distribution. + + The easiest way to instantiate is using ``j = JIRA("https://jira.atlassian.com")`` + + :param options: Specify the server and properties this client will use. Use a dict with any + of the following properties: + + * server -- the server address and context path to use. Defaults to ``http://localhost:2990/jira``. + * rest_path -- the root REST path to use. Defaults to ``api``, where the JIRA REST resources live. + * rest_api_version -- the version of the REST resources under rest_path to use. Defaults to ``2``. + * agile_rest_path - the REST path to use for JIRA Agile requests. Defaults to ``greenhopper`` (old, private + API). Check `GreenHopperResource` for other supported values. + * verify -- Verify SSL certs. Defaults to ``True``. + * client_cert -- a tuple of (cert,key) for the requests library for client side SSL + * check_update -- Check whether using the newest python-jira library version. + * cookies -- A dict of custom cookies that are sent in all requests to the server. + + :param basic_auth: A tuple of username and password to use when establishing a session via HTTP BASIC + authentication. + :param oauth: A dict of properties for OAuth authentication. The following properties are required: + + * access_token -- OAuth access token for the user + * access_token_secret -- OAuth access token secret to sign with the key + * consumer_key -- key of the OAuth application link defined in JIRA + * key_cert -- private key file to sign requests with (should be the pair of the public key supplied to + JIRA in the OAuth application link) + + :param kerberos: If true it will enable Kerberos authentication. + :param kerberos_options: A dict of properties for Kerberos authentication. The following properties are possible: + + * mutual_authentication -- string DISABLED or OPTIONAL. + + Example kerberos_options structure: ``{'mutual_authentication': 'DISABLED'}`` + + :param jwt: A dict of properties for JWT authentication supported by Atlassian Connect. The following + properties are required: + + * secret -- shared secret as delivered during 'installed' lifecycle event + (see https://developer.atlassian.com/static/connect/docs/latest/modules/lifecycle.html for details) + * payload -- dict of fields to be inserted in the JWT payload, e.g. 'iss' + + Example jwt structure: ``{'secret': SHARED_SECRET, 'payload': {'iss': PLUGIN_KEY}}`` + + :param validate: If true it will validate your credentials first. Remember that if you are accessing JIRA + as anonymous it will fail to instantiate. + :param get_server_info: If true it will fetch server version info first to determine if some API calls + are available. + :param async_: To enable asynchronous requests for those actions where we implemented it, like issue update() or delete(). + :param async_workers: Set the number of worker threads for async operations. + :param timeout: Set a read/connect timeout for the underlying calls to JIRA (default: None) + Obviously this means that you cannot rely on the return code when this is enabled. """ DEFAULT_OPTIONS = { "server": "http://localhost:2990/jira", + "auth_url": '/rest/auth/1/session', "context_path": "/", "rest_path": "api", "rest_api_version": "2", @@ -178,6 +320,7 @@ "verify": True, "resilient": True, "async": False, + "async_workers": 5, "client_cert": None, "check_update": False, "headers": { @@ -195,9 +338,9 @@ JIRA_BASE_URL = Resource.JIRA_BASE_URL AGILE_BASE_URL = GreenHopperResource.AGILE_BASE_URL - def __init__(self, server=None, options=None, basic_auth=None, oauth=None, jwt=None, kerberos=False, - validate=False, get_server_info=True, async=False, logging=True, max_retries=3, proxies=None, - timeout=None): + def __init__(self, server=None, options=None, basic_auth=None, oauth=None, jwt=None, kerberos=False, kerberos_options=None, + validate=False, get_server_info=True, async_=False, async_workers=5, logging=True, max_retries=3, proxies=None, + timeout=None, auth=None): """Construct a JIRA client instance. Without any arguments, this client will connect anonymously to the JIRA instance @@ -231,19 +374,24 @@ * key_cert -- private key file to sign requests with (should be the pair of the public key supplied to JIRA in the OAuth application link) :param kerberos: If true it will enable Kerberos authentication. + :param kerberos_options: A dict of properties for Kerberos authentication. The following properties are possible: + * mutual_authentication -- string DISABLED or OPTIONAL. + Example kerberos_options structure: ``{'mutual_authentication': 'DISABLED'}`` :param jwt: A dict of properties for JWT authentication supported by Atlassian Connect. The following properties are required: * secret -- shared secret as delivered during 'installed' lifecycle event (see https://developer.atlassian.com/static/connect/docs/latest/modules/lifecycle.html for details) * payload -- dict of fields to be inserted in the JWT payload, e.g. 'iss' Example jwt structure: ``{'secret': SHARED_SECRET, 'payload': {'iss': PLUGIN_KEY}}`` - :param validate: If true it will validate your credentials first. Remember that if you are accesing JIRA - as anononymous it will fail to instanciate. + :param validate: If true it will validate your credentials first. Remember that if you are accessing JIRA + as anonymous it will fail to instantiate. :param get_server_info: If true it will fetch server version info first to determine if some API calls are available. - :param async: To enable async requests for those actions where we implemented it, like issue update() or delete(). + :param async_: To enable async requests for those actions where we implemented it, like issue update() or delete(). + :param async_workers: Set the number of worker threads for async operations. :param timeout: Set a read/connect timeout for the underlying calls to JIRA (default: None) Obviously this means that you cannot rely on the return code when this is enabled. + :param auth: Set a cookie auth token if this is required. """ # force a copy of the tuple to be used in __del__() because # sys.version_info could have already been deleted in __del__() @@ -260,8 +408,9 @@ if server: options['server'] = server - if async: - options['async'] = async + if async_: + options['async'] = async_ + options['async_workers'] = async_workers self.logging = logging @@ -289,13 +438,19 @@ elif jwt: self._create_jwt_session(jwt, timeout) elif kerberos: - self._create_kerberos_session(timeout) + self._create_kerberos_session(timeout, kerberos_options=kerberos_options) + elif auth: + self._create_cookie_auth(auth, timeout) + validate = True # always log in for cookie based auth, as we need a first request to be logged in else: verify = self._options['verify'] self._session = ResilientSession(timeout=timeout) self._session.verify = verify self._session.headers.update(self._options['headers']) + if 'cookies' in self._options: + self._session.cookies.update(self._options['cookies']) + self._session.max_retries = max_retries if proxies: @@ -304,10 +459,10 @@ if validate: # This will raise an Exception if you are not allowed to login. # It's better to fail faster than later. - user = self.session() + user = self.session(auth) if user.raw is None: auth_method = ( - oauth or basic_auth or jwt or kerberos or "anonymous" + oauth or basic_auth or jwt or kerberos or auth or "anonymous" ) raise JIRAError("Can not log in with %s" % str(auth_method)) @@ -334,6 +489,12 @@ for name in f['clauseNames']: self._fields[name] = f['id'] + def _create_cookie_auth(self, auth, timeout): + self._session = ResilientSession(timeout=timeout) + self._session.auth = JiraCookieAuth(self._session, self.session, auth) + self._session.verify = self._options['verify'] + self._session.cert = self._options['client_cert'] + def _check_update_(self): """Check if the current version of the library is outdated.""" try: @@ -351,8 +512,12 @@ def __del__(self): """Destructor for JIRA instance.""" + self.close() + + def close(self): session = getattr(self, "_session", None) if session is not None: + self._session = None if self.sys_version_info < (3, 4, 0): # workaround for https://github.com/kennethreitz/requests/issues/2303 try: session.close() @@ -364,7 +529,7 @@ pass def _check_for_html_error(self, content): - # JIRA has the bad habbit of returning errors in pages with 200 and + # JIRA has the bad habit of returning errors in pages with 200 and # embedding the error in a huge webpage. if '' in content: logging.warning("Got SecurityTokenMissing") @@ -372,6 +537,12 @@ return False return True + def _get_sprint_field_id(self): + sprint_field_name = "Sprint" + sprint_field_id = [f['schema']['customId'] for f in self.fields() + if f['name'] == sprint_field_name][0] + return sprint_field_id + def _fetch_pages(self, item_type, items_key, request_path, startAt=0, maxResults=50, params=None, base=JIRA_BASE_URL): """Fetch pages. @@ -387,20 +558,23 @@ :param base: base URL :return: ResultList """ + async_class = None + if self._options['async']: + try: + from requests_futures.sessions import FuturesSession + async_class = FuturesSession + except ImportError: + pass + async_workers = self._options['async_workers'] page_params = params.copy() if params else {} if startAt: page_params['startAt'] = startAt if maxResults: page_params['maxResults'] = maxResults - try: - resource = self._get_json(request_path, params=page_params, base=base) - next_items_page = [item_type(self._options, self._session, raw_issue_json) for raw_issue_json in - (resource[items_key] if items_key else resource)] - except KeyError as e: - # improving the error text so we know why it happened - raise KeyError(str(e) + " : " + json.dumps(resource)) - + resource = self._get_json(request_path, params=page_params, base=base) + next_items_page = self._get_items_from_page(item_type, items_key, + resource) items = next_items_page if True: # isinstance(resource, dict): @@ -408,7 +582,7 @@ if isinstance(resource, dict): total = resource.get('total') # 'isLast' is the optional key added to responses in JIRA Agile 6.7.6. So far not used in basic JIRA API. - is_last = resource.get('isLast', True) + is_last = resource.get('isLast', False) start_at_from_response = resource.get('startAt', 0) max_results_from_response = resource.get('maxResults', 1) else: @@ -422,17 +596,33 @@ if not maxResults: page_size = max_results_from_response or len(items) page_start = (startAt or start_at_from_response or 0) + page_size - while not is_last and (total is None or page_start < total) and len(next_items_page) == page_size: + if async_class is not None and not is_last and ( + total is not None and len(items) < total): + async_fetches = [] + future_session = async_class(session=self._session, max_workers=async_workers) + for start_index in range(page_start, total, page_size): + page_params = params.copy() + page_params['startAt'] = start_index + page_params['maxResults'] = page_size + url = self._get_url(request_path) + r = future_session.get(url, params=page_params) + async_fetches.append(r) + for future in async_fetches: + response = future.result() + resource = json_loads(response) + if resource: + next_items_page = self._get_items_from_page( + item_type, items_key, resource) + items.extend(next_items_page) + while async_class is None and not is_last and ( + total is None or page_start < total) and len( + next_items_page) == page_size: page_params['startAt'] = page_start page_params['maxResults'] = page_size resource = self._get_json(request_path, params=page_params, base=base) if resource: - try: - next_items_page = [item_type(self._options, self._session, raw_issue_json) for raw_issue_json in - (resource[items_key] if items_key else resource)] - except KeyError as e: - # improving the error text so we know why it happened - raise KeyError(str(e) + " : " + json.dumps(resource)) + next_items_page = self._get_items_from_page( + item_type, items_key, resource) items.extend(next_items_page) page_start += page_size else: @@ -444,6 +634,14 @@ # it seams that search_users can return a list() containing a single user! return ResultList([item_type(self._options, self._session, resource)], 0, 1, 1, True) + def _get_items_from_page(self, item_type, items_key, resource): + try: + return [item_type(self._options, self._session, raw_issue_json) for raw_issue_json in + (resource[items_key] if items_key else resource)] + except KeyError as e: + # improving the error text so we know why it happened + raise KeyError(str(e) + " : " + json.dumps(resource)) + # Information about this client def client_info(self): @@ -455,7 +653,7 @@ def find(self, resource_format, ids=None): """Find Resource object for any addressable resource on the server. - This method is a universal resource locator for any RESTful resource in JIRA. The + This method is a universal resource locator for any REST-ful resource in JIRA. The argument ``resource_format`` is a string of the form ``resource``, ``resource/{0}``, ``resource/{0}/sub``, ``resource/{0}/sub/{1}``, etc. The format placeholders will be populated from the ``ids`` argument if present. The existing authentication session @@ -476,12 +674,12 @@ return resource def async_do(self, size=10): - """Execute all async jobs and wait for them to finish. By default it will run on 10 threads. + """Execute all asynchronous jobs and wait for them to finish. By default it will run on 10 threads. :param size: number of threads to run on. """ if hasattr(self._session, '_async_jobs'): - logging.info("Executing async %s jobs found in queue by using %s threads..." % ( + logging.info("Executing asynchronous %s jobs found in queue by using %s threads..." % ( len(self._session._async_jobs), size)) threaded_requests.map(self._session._async_jobs, size=size) @@ -554,7 +752,7 @@ :param issue: the issue to attach the attachment to :param attachment: file-like object to attach to the issue, also works if it is a string with the filename. :param filename: optional name for the attached file. If omitted, the file object's ``name`` attribute - is used. If you aquired the file-like object by any other method than ``open()``, make sure + is used. If you acquired the file-like object by any other method than ``open()``, make sure that a name is specified in one way or the other. :rtype: an Attachment Resource """ @@ -651,6 +849,14 @@ """ return self._get_json('component/' + id + '/relatedIssueCounts')['issueCount'] + def delete_component(self, id): + """Delete component by id. + + :param id: ID of the component to use + """ + url = self._get_url('component/' + str(id)) + return self._session.delete(url) + # Custom field options def custom_field_option(self, id): @@ -802,15 +1008,17 @@ result = {} for user in r['users']['items']: - result[user['name']] = {'fullname': user['displayName'], 'email': user.get('emailAddress', 'hidden'), - 'active': user['active']} - return result + result[user['key']] = {'name': user['name'], + 'fullname': user['displayName'], + 'email': user.get('emailAddress', 'hidden'), + 'active': user['active']} + return OrderedDict(sorted(result.items(), key=lambda t: t[0])) def add_group(self, groupname): """Create a new group in JIRA. :param groupname: The name of the group you wish to create. - :return: Boolean - True if succesfull. + :return: Boolean - True if successful. """ url = self._options['server'] + '/rest/api/latest/group' @@ -933,9 +1141,15 @@ data['issueUpdates'].append(issue_data) url = self._get_url('issue/bulk') - r = self._session.post(url, data=json.dumps(data)) - - raw_issue_json = json_loads(r) + try: + r = self._session.post(url, data=json.dumps(data)) + raw_issue_json = json_loads(r) + # Catching case where none of the issues has been created. See https://github.com/pycontribs/jira/issues/350 + except JIRAError as je: + if je.status_code == 400: + raw_issue_json = json.loads(je.response.text) + else: + raise issue_list = [] errors = {} for error in raw_issue_json['errors']: @@ -954,6 +1168,96 @@ 'error': None, 'input_fields': fields}) return issue_list + def supports_service_desk(self): + url = self._options['server'] + '/rest/servicedeskapi/info' + headers = {'X-ExperimentalApi': 'opt-in'} + try: + r = self._session.get(url, headers=headers) + return r.status_code == 200 + except JIRAError: + return False + + def create_customer(self, email, displayName): + """Create a new customer and return an issue Resource for it.""" + url = self._options['server'] + '/rest/servicedeskapi/customer' + headers = {'X-ExperimentalApi': 'opt-in'} + r = self._session.post(url, headers=headers, data=json.dumps({ + 'email': email, + 'displayName': displayName + })) + + raw_customer_json = json_loads(r) + + if r.status_code != 201: + raise JIRAError(r.status_code, request=r) + return Customer(self._options, self._session, raw=raw_customer_json) + + def service_desks(self): + """Get a list of ServiceDesk Resources from the server visible to the current authenticated user.""" + url = self._options['server'] + '/rest/servicedeskapi/servicedesk' + headers = {'X-ExperimentalApi': 'opt-in'} + r_json = json_loads(self._session.get(url, headers=headers)) + projects = [ServiceDesk(self._options, self._session, raw_project_json) + for raw_project_json in r_json['values']] + return projects + + def service_desk(self, id): + """Get a Service Desk Resource from the server. + + :param id: ID or key of the Service Desk to get + """ + return self._find_for_resource(ServiceDesk, id) + + def create_customer_request(self, fields=None, prefetch=True, **fieldargs): + """Create a new customer request and return an issue Resource for it. + + Each keyword argument (other than the predefined ones) is treated as a field name and the argument's value + is treated as the intended value for that field -- if the fields argument is used, all other keyword arguments + will be ignored. + + By default, the client will immediately reload the issue Resource created by this method in order to return + a complete Issue object to the caller; this behavior can be controlled through the 'prefetch' argument. + + JIRA projects may contain many different issue types. Some issue screens have different requirements for + fields in a new issue. This information is available through the 'createmeta' method. Further examples are + available here: https://developer.atlassian.com/display/JIRADEV/JIRA+REST+API+Example+-+Create+Issue + + :param fields: a dict containing field names and the values to use. If present, all other keyword arguments + will be ignored + :param prefetch: whether to reload the created issue Resource so that all of its data is present in the value + returned from this method + """ + data = fields + + p = data['serviceDeskId'] + service_desk = None + + if isinstance(p, string_types) or isinstance(p, integer_types): + service_desk = self.service_desk(p) + elif isinstance(p, ServiceDesk): + service_desk = p + + data['serviceDeskId'] = service_desk.id + + p = data['requestTypeId'] + if isinstance(p, integer_types): + data['requestTypeId'] = p + elif isinstance(p, string_types): + data['requestTypeId'] = self.request_type_by_name( + service_desk, p).id + + url = self._options['server'] + '/rest/servicedeskapi/request' + headers = {'X-ExperimentalApi': 'opt-in'} + r = self._session.post(url, headers=headers, data=json.dumps(data)) + + raw_issue_json = json_loads(r) + if 'issueKey' not in raw_issue_json: + raise JIRAError(r.status_code, request=r) + if prefetch: + return self.issue(raw_issue_json['issueKey']) + else: + return Issue(self._options, self._session, raw=raw_issue_json) + def createmeta(self, projectKeys=None, projectIds=[], issuetypeIds=None, issuetypeNames=None, expand=None): """Get the metadata required to create issues, optionally filtered by projects and issue types. @@ -992,8 +1296,13 @@ def assign_issue(self, issue, assignee): """Assign an issue to a user. None will set it to unassigned. -1 will set it to Automatic. - :param issue: the issue to assign + :param issue: the issue ID or key to assign :param assignee: the user to assign the issue to + + :type issue: int or str + :type assignee: str + + :rtype: bool """ url = self._options['server'] + \ '/rest/api/latest/issue/' + str(issue) + '/assignee' @@ -1025,7 +1334,7 @@ return self._find_for_resource(Comment, (issue, comment)) @translate_resource_args - def add_comment(self, issue, body, visibility=None): + def add_comment(self, issue, body, visibility=None, is_internal=False): """Add a comment from the current authenticated user on the specified issue and return a Resource for it. The issue identifier and comment body are required. @@ -1036,15 +1345,27 @@ "type" is 'role' (or 'group' if the JIRA server has configured comment visibility for groups) and 'value' is the name of the role (or group) to which viewing of this comment will be restricted. + :param is_internal: defines whether a comment has to be marked as 'Internal' in Jira Service Desk """ data = { - 'body': body} + 'body': body, + } + + if is_internal: + data.update({ + 'properties': [ + {'key': 'sd.public.comment', + 'value': {'internal': is_internal}} + ] + }) + if visibility is not None: data['visibility'] = visibility url = self._get_url('issue/' + str(issue) + '/comment') r = self._session.post( - url, data=json.dumps(data)) + url, data=json.dumps(data) + ) comment = Comment(self._options, self._session, raw=json_loads(r)) return comment @@ -1213,7 +1534,7 @@ return id @translate_resource_args - def transition_issue(self, issue, transition, fields=None, comment=None, **fieldargs): + def transition_issue(self, issue, transition, fields=None, comment=None, worklog=None, **fieldargs): """Perform a transition on an issue. Each keyword argument (other than the predefined ones) is treated as a field name and the argument's value @@ -1242,6 +1563,8 @@ 'id': transitionId}} if comment: data['update'] = {'comment': [{'add': {'body': comment}}]} + if worklog: + data['update'] = {'worklog': [{'add': {'timeSpent': worklog}}]} if fields is not None: data['fields'] = fields else: @@ -1281,7 +1604,7 @@ def remove_vote(self, issue): """Remove the current authenticated user's vote from an issue. - :param issue: ID or key of the issue to unvote on + :param issue: ID or key of the issue to remove vote on """ url = self._get_url('issue/' + str(issue) + '/votes') self._session.delete(url) @@ -1372,7 +1695,7 @@ if started is not None: # based on REST Browser it needs: "2014-06-03T08:21:01.273+0000" - data['started'] = started.strftime("%Y-%m-%dT%H:%M:%S.000%z") + data['started'] = started.strftime("%Y-%m-%dT%H:%M:%S.000+0000%z") if user is not None: data['author'] = {"name": user, 'self': self.JIRA_BASE_URL + '/rest/api/latest/user?username=' + user, @@ -1411,7 +1734,7 @@ if type not in self._cached_issue_link_types: for lt in self._cached_issue_link_types: if lt.outward == type: - # we are smart to figure it out what he ment + # we are smart to figure it out what he meant type = lt.name break elif lt.inward == type: @@ -1487,6 +1810,27 @@ raise KeyError("Issue type '%s' is unknown." % name) return issue_type + def request_types(self, service_desk): + if hasattr(service_desk, 'id'): + service_desk = service_desk.id + url = (self._options['server'] + + '/rest/servicedeskapi/servicedesk/%s/requesttype' + % service_desk) + headers = {'X-ExperimentalApi': 'opt-in'} + r_json = json_loads(self._session.get(url, headers=headers)) + request_types = [ + RequestType(self._options, self._session, raw_type_json) + for raw_type_json in r_json['values']] + return request_types + + def request_type_by_name(self, service_desk, name): + request_types = self.request_types(service_desk) + try: + request_type = [rt for rt in request_types if rt.name == name][0] + except IndexError: + raise KeyError("Request type '%s' is unknown." % name) + return request_type + # User permissions # non-resource @@ -1636,7 +1980,7 @@ """Delete a project's avatar. :param project: ID or key of the project to delete the avatar from - :param avatar: ID of the avater to delete + :param avatar: ID of the avatar to delete """ url = self._get_url('project/' + project + '/avatar/' + avatar) return self._session.delete(url) @@ -1670,8 +2014,16 @@ :param project: ID or key of the project to get roles from """ - roles_dict = self._get_json('project/' + project + '/role') - return roles_dict + path = 'project/' + project + '/role' + _rolesdict = self._get_json(path) + rolesdict = {} + + for k, v in _rolesdict.items(): + tmp = {} + tmp['id'] = v.split("/")[-1] + tmp['url'] = v + rolesdict[k] = tmp + return rolesdict # TODO(ssbarnea): return a list of Roles() @translate_resource_args @@ -1705,22 +2057,32 @@ def search_issues(self, jql_str, startAt=0, maxResults=50, validate_query=True, fields=None, expand=None, json_result=None): - """Get a ResultList of issue Resources matching a JQL search string. + """Get a :class:`~jira.client.ResultList` of issue Resources matching a JQL search string. :param jql_str: the JQL search string to use :param startAt: index of the first issue to return :param maxResults: maximum number of issues to return. Total number of results - is available in the ``total`` attribute of the returned ResultList. + is available in the ``total`` attribute of the returned :class:`~jira.client.ResultList`. If maxResults evaluates as False, it will try to get all issues in batches. :param fields: comma-separated string of issue fields to include in the results :param expand: extra information to fetch inside each resource :param json_result: JSON response will be returned when this parameter is set to True. - Otherwise, ResultList will be returned. + Otherwise, :class:`~jira.client.ResultList` will be returned. + + :type jql_str: str + :type startAt: int + :type maxResults: int + :type fields: str + :type expand: str + :type json_result: bool + + :rtype: dict or :class:`~jira.client.ResultList` """ if fields is None: fields = [] - - if isinstance(fields, string_types): + elif isinstance(fields, list): + fields = fields.copy() + elif isinstance(fields, string_types): fields = fields.split(",") # this will translate JQL field names to REST API Name @@ -2072,13 +2434,15 @@ # Session authentication - def session(self): + def session(self, auth=None): """Get a dict of the current authenticated user's session information.""" - url = '{server}/rest/auth/1/session'.format(**self._options) + url = '{server}{auth_url}'.format(**self._options) - if isinstance(self._session.auth, tuple): - authentication_data = { - 'username': self._session.auth[0], 'password': self._session.auth[1]} + if isinstance(self._session.auth, tuple) or auth: + if not auth: + auth = self._session.auth + username, password = auth + authentication_data = {'username': username, 'password': password} r = self._session.post(url, data=json.dumps(authentication_data)) else: r = self._session.get(url) @@ -2125,15 +2489,26 @@ self._session.verify = verify self._session.auth = oauth - def _create_kerberos_session(self, timeout): + def _create_kerberos_session(self, timeout, kerberos_options=None): verify = self._options['verify'] + if kerberos_options is None: + kerberos_options = {} + from requests_kerberos import DISABLED from requests_kerberos import HTTPKerberosAuth from requests_kerberos import OPTIONAL + if kerberos_options.get('mutual_authentication', 'OPTIONAL') == 'OPTIONAL': + mutual_authentication = OPTIONAL + elif kerberos_options.get('mutual_authentication') == 'DISABLED': + mutual_authentication = DISABLED + else: + raise ValueError("Unknown value for mutual_authentication: %s" % + kerberos_options['mutual_authentication']) + self._session = ResilientSession(timeout=timeout) self._session.verify = verify - self._session.auth = HTTPKerberosAuth(mutual_authentication=OPTIONAL) + self._session.auth = HTTPKerberosAuth(mutual_authentication=mutual_authentication) @staticmethod def _timestamp(dt=None): @@ -2148,6 +2523,8 @@ except NameError as e: logging.error("JWT authentication requires requests_jwt") raise e + jwt_auth.set_header_format('JWT %s') + jwt_auth.add_field("iat", lambda req: JIRA._timestamp()) jwt_auth.add_field("exp", lambda req: JIRA._timestamp(datetime.timedelta(minutes=3))) jwt_auth.add_field("qsh", QshGenerator(self._options['context_path'])) @@ -2217,38 +2594,13 @@ ". Specify the 'contentType' parameter explicitly.") return None - def email_user(self, user, body, title="JIRA Notification"): - """(Obsolete) Send an email to an user via CannedScriptRunner.""" - url = self._options['server'] + \ - '/secure/admin/groovy/CannedScriptRunner.jspa' - payload = { - 'cannedScript': 'com.onresolve.jira.groovy.canned.workflow.postfunctions.SendCustomEmail', - 'cannedScriptArgs_FIELD_CONDITION': '', - 'cannedScriptArgs_FIELD_EMAIL_TEMPLATE': body, - 'cannedScriptArgs_FIELD_EMAIL_SUBJECT_TEMPLATE': title, - 'cannedScriptArgs_FIELD_EMAIL_FORMAT': 'TEXT', - 'cannedScriptArgs_FIELD_TO_ADDRESSES': self.user(user).emailAddress, - 'cannedScriptArgs_FIELD_TO_USER_FIELDS': '', - 'cannedScriptArgs_FIELD_INCLUDE_ATTACHMENTS': 'FIELD_INCLUDE_ATTACHMENTS_NONE', - 'cannedScriptArgs_FIELD_FROM': '', - 'cannedScriptArgs_FIELD_PREVIEW_ISSUE': '', - 'cannedScript': 'com.onresolve.jira.groovy.canned.workflow.postfunctions.SendCustomEmail', - 'id': '', - 'Preview': 'Preview'} - - r = self._session.post( - url, headers=self._options['headers'], data=payload) - with open("/tmp/jira_email_user_%s.html" % user, "w") as f: - f.write(r.text) - def rename_user(self, old_user, new_user): - """Rename a JIRA user. Current implementation relies on third party plugin but in the future it may use embedded JIRA functionality. + """Rename a JIRA user. :param old_user: string with username login :param new_user: string with username login """ - if self._version >= (6, 0, 0): - + if self._version > (6, 0, 0): url = self._options['server'] + '/rest/api/latest/user' payload = { "name": new_user} @@ -2260,69 +2612,10 @@ r = self._session.put(url, params=params, data=json.dumps(payload)) - + raise_on_error(r) else: - # old implementation needed the ScripRunner plugin - merge = "true" - try: - self.user(new_user) - except Exception: - merge = "false" - - url = self._options['server'] + '/secure/admin/groovy/CannedScriptRunner.jspa#result' - payload = { - "cannedScript": "com.onresolve.jira.groovy.canned.admin.RenameUser", - "cannedScriptArgs_FIELD_FROM_USER_ID": old_user, - "cannedScriptArgs_FIELD_TO_USER_ID": new_user, - "cannedScriptArgs_FIELD_MERGE": merge, - "id": "", - "RunCanned": "Run"} - - # raw displayName - logging.debug("renaming %s" % self.user(old_user).emailAddress) - - r = self._session.post( - url, headers=self._options['headers'], data=payload) - if r.status_code == 404: - logging.error( - "In order to be able to use rename_user() you need to install Script Runner plugin. " - "See https://marketplace.atlassian.com/plugins/com.onresolve.jira.groovy.groovyrunner") - return False - if r.status_code != 200: - logging.error(r.status_code) - - if re.compile("XSRF Security Token Missing").search(r.content): - logging.fatal( - "Reconfigure JIRA and disable XSRF in order to be able call this. See https://developer.atlassian.com/display/JIRADEV/Form+Token+Handling") - return False - - with open("/tmp/jira_rename_user_%s_to%s.html" % (old_user, new_user), "w") as f: - f.write(r.content) - - msg = r.status_code - m = re.search("(.*)<\/span>", r.content) - if m: - msg = m.group(1) - logging.error(msg) - return False - # Target user ID must exist already for a merge - p = re.compile("type=\"hidden\" name=\"cannedScriptArgs_Hidden_output\" value=\"(.*?)\"\/>", - re.MULTILINE | re.DOTALL) - m = p.search(r.content) - if m: - h = html_parser.HTMLParser() - msg = h.unescape(m.group(1)) - logging.info(msg) - - # let's check if the user still exists - try: - self.user(old_user) - except Exception as e: - logging.error("User %s does not exists. %s", old_user, e) - return msg - - logging.error(msg + '\n' + "User %s does still exists after rename, that's clearly a problem." % old_user) - return False + raise NotImplementedError("Support for renaming users in Jira " + "< 6.0.0 has been removed.") def delete_user(self, username): @@ -2367,10 +2660,10 @@ def reindex(self, force=False, background=True): """Start jira re-indexing. Returns True if reindexing is in progress or not needed, or False. - If you call reindex() without any parameters it will perform a backfround reindex only if JIRA thinks it should do it. + If you call reindex() without any parameters it will perform a background reindex only if JIRA thinks it should do it. - :param force: reindex even if JIRA doesn'tt say this is needed, False by default. - :param background: reindex inde background, slower but does not impact the users, defaults to True. + :param force: reindex even if JIRA doesn't say this is needed, False by default. + :param background: reindex in background, slower but does not impact the users, defaults to True. """ # /secure/admin/IndexAdmin.jspa # /secure/admin/jira/IndexProgress.jspa?taskId=1 @@ -2405,7 +2698,7 @@ def backup(self, filename='backup.zip', attachments=False): """Will call jira export to backup as zipped xml. Returning with success does not mean that the backup process finished.""" if self.deploymentType == 'Cloud': - url = self._options['server'] + '/rest/obm/1.0/runbackup' + url = self._options['server'] + '/rest/backup/1/export/runbackup' payload = json.dumps({"cbAttachments": attachments}) self._options['headers']['X-Requested-With'] = 'XMLHttpRequest' else: @@ -2586,12 +2879,17 @@ r = self._session.get(url) j = json_loads(r) + possible_templates = ['JIRA Classic', 'JIRA Default Schemes', 'Basic software development'] + + if template_name is not None: + possible_templates = [template_name] + # https://confluence.atlassian.com/jirakb/creating-a-project-via-rest-based-on-jira-default-schemes-744325852.html template_key = 'com.atlassian.jira-legacy-project-templates:jira-blank-item' templates = [] for template in _get_template_list(j): templates.append(template['name']) - if template['name'] in ['JIRA Classic', 'JIRA Default Schemes', 'Basic software development', template_name]: + if template['name'] in possible_templates: template_key = template['projectTemplateModuleCompleteKey'] break @@ -2630,7 +2928,7 @@ return False def add_user(self, username, email, directoryId=1, password=None, - fullname=None, notify=False, active=True, ignore_existing=False): + fullname=None, notify=False, active=True, ignore_existing=False, application_keys=None): """Create a new JIRA user. :param username: the username of the new user @@ -2647,6 +2945,8 @@ :type notify: ``bool`` :param active: Whether or not to make the new user active upon creation :type active: ``bool`` + :param applicationKeys: Keys of products user should have access to + :type applicationKeys: ``list`` """ if not fullname: fullname = username @@ -2665,6 +2965,8 @@ x['password'] = password if notify: x['notification'] = 'True' + if application_keys is not None: + x['applicationKeys'] = application_keys payload = json.dumps(x) try: @@ -2717,13 +3019,6 @@ if str(customfield).isdigit(): customfield = "customfield_%s" % customfield params = { - # '_mode':'view', - # 'validate':True, - # '_search':False, - # 'rows':100, - # 'page':1, - # 'sidx':'DEFAULT', - # 'sord':'asc' '_issueId': issueid, '_fieldId': customfield, '_confSchemeId': schemeid} @@ -2776,18 +3071,22 @@ New JIRA Agile API always returns this information without a need for additional requests. :param startAt: the index of the first sprint to return (0 based) :param maxResults: the maximum number of sprints to return - :param state: Filters results to sprints in specified states. Valid values: future, active, closed. + :param state: Filters results to sprints in specified states. Valid values: `future`, `active`, `closed`. You can define multiple states separated by commas - :rtype: dict + :type board_id: int + :type extended: bool + :type startAt: int + :type maxResults: int + :type state: str + + :rtype: list of :class:`~jira.resources.Sprint` :return: (content depends on API version, but always contains id, name, state, startDate and endDate) When old GreenHopper private API is used, paging is not enabled, and `startAt`, `maxResults` and `state` parameters are ignored. """ params = {} if state: - if isinstance(state, string_types): - state = state.split(",") params['state'] = state if self._options['agile_rest_path'] == GreenHopperResource.GREENHOPPER_REST_PATH: @@ -2868,6 +3167,14 @@ return sprint.raw def sprint(self, id): + """Return the information about a sprint. + + :param sprint_id: the sprint retrieving issues from + + :type sprint_id: int + + :rtype: :class:`~jira.resources.Sprint` + """ sprint = Sprint(self._options, self._session) sprint.find(id) return sprint @@ -2878,13 +3185,18 @@ board = Board(self._options, self._session, raw={'id': id}) board.delete() - def create_board(self, name, project_ids, preset="scrum"): + def create_board(self, name, project_ids, preset="scrum", + location_type='user', location_id=None): """Create a new board for the ``project_ids``. :param name: name of the board :param project_ids: the projects to create the board in :param preset: what preset to use for this board :type preset: 'kanban', 'scrum', 'diy' + :param location_type: the location type. Available in cloud. + :type location_type: 'user', 'project' + :param location_id: the id of project that the board should be + located under. Omit this for a 'user' location_type. Available in cloud. """ if self._options['agile_rest_path'] != GreenHopperResource.GREENHOPPER_REST_PATH: raise NotImplementedError('JIRA Agile Public API does not support this request') @@ -2901,6 +3213,9 @@ project_ids = project_ids.split(',') payload['projectIds'] = project_ids payload['preset'] = preset + if self.deploymentType == 'Cloud': + payload['locationType'] = location_type + payload['locationId'] = location_id url = self._get_url( 'rapidview/create/presets', base=self.AGILE_BASE_URL) r = self._session.post( @@ -2965,6 +3280,9 @@ :param sprint_id: the sprint to add issues to :param issue_keys: the issues to add to the sprint + + :type sprint_id: int + :type issue_keys: list of str """ if self._options['agile_rest_path'] == GreenHopperResource.AGILE_BASE_REST_PATH: url = self._get_url('sprint/%s/issue' % sprint_id, base=self.AGILE_BASE_URL) @@ -2981,10 +3299,7 @@ # issue.update() to perform this operation # Workaround based on https://answers.atlassian.com/questions/277651/jira-agile-rest-api-example - # Get the customFieldId for "Sprint" - sprint_field_name = "Sprint" - sprint_field_id = [f['schema']['customId'] for f in self.fields() - if f['name'] == sprint_field_name][0] + sprint_field_id = self._get_sprint_field_id() data = {'idOrKeys': issue_keys, 'customFieldId': sprint_field_id, 'sprintId': sprint_id, 'addToBacklog': False} @@ -3041,7 +3356,6 @@ ' At least version 6.7.10 is required.') raise elif self._options['agile_rest_path'] == GreenHopperResource.GREENHOPPER_REST_PATH: - # {"issueKeys":["ANERDS-102"],"rankBeforeKey":"ANERDS-94","rankAfterKey":"ANERDS-7","customFieldId":11431} data = { "issueKeys": [issue], "rankBeforeKey": next_issue, "customFieldId": self._rank} url = self._get_url('rank', base=self.AGILE_BASE_URL) @@ -3065,6 +3379,17 @@ warnings.warn('Status code 404 may mean, that too old JIRA Agile version is installed.' ' At least version 6.7.10 is required.') raise + elif self._options['agile_rest_path'] == GreenHopperResource.GREENHOPPER_REST_PATH: + # In old, private API the function does not exist anymore and we need to use + # issue.update() to perform this operation + # Workaround based on https://answers.atlassian.com/questions/277651/jira-agile-rest-api-example + + sprint_field_id = self._get_sprint_field_id() + + data = {'idOrKeys': issue_keys, 'customFieldId': sprint_field_id, + 'addToBacklog': True} + url = self._get_url('sprint/rank', base=self.AGILE_BASE_URL) + return self._session.put(url, data=json.dumps(data)) else: raise NotImplementedError('No API for moving issues to backlog for agile_rest_path="%s"' % self._options['agile_rest_path']) @@ -3072,8 +3397,8 @@ class GreenHopper(JIRA): - def __init__(self, options=None, basic_auth=None, oauth=None, async=None): + def __init__(self, options=None, basic_auth=None, oauth=None, async_=None): warnings.warn( "GreenHopper() class is deprecated, just use JIRA() instead.", DeprecationWarning) JIRA.__init__( - self, options=options, basic_auth=basic_auth, oauth=oauth, async=async) + self, options=options, basic_auth=basic_auth, oauth=oauth, async_=async_) diff -Nru python-jira-1.0.10/jira/jirashell.py python-jira-2.0.0/jira/jirashell.py --- python-jira-1.0.10/jira/jirashell.py 2017-02-10 19:26:23.000000000 +0000 +++ python-jira-2.0.0/jira/jirashell.py 2018-07-12 17:11:24.000000000 +0000 @@ -134,6 +134,8 @@ help='The JIRA instance to connect to, including context path.') jira_group.add_argument('-r', '--rest-path', help='The root path of the REST API to use.') + jira_group.add_argument('--auth-url', + help='Path to URL to auth against.') jira_group.add_argument('-v', '--rest-api-version', help='The version of the API under the specified name.') @@ -175,6 +177,9 @@ if args.rest_path: options['rest_path'] = args.rest_path + if args.auth_url: + options['auth_url'] = args.auth_url + if args.rest_api_version: options['rest_api_version'] = args.rest_api_version @@ -249,12 +254,18 @@ jira = JIRA(options=options, basic_auth=basic_auth, oauth=oauth) - from IPython.frontend.terminal.embed import InteractiveShellEmbed + import IPython + # The top-level `frontend` package has been deprecated since IPython 1.0. + if IPython.version_info[0] >= 1: + from IPython.terminal.embed import InteractiveShellEmbed + else: + from IPython.frontend.terminal.embed import InteractiveShellEmbed - ipshell = InteractiveShellEmbed( + ip_shell = InteractiveShellEmbed( banner1='') - ipshell("*** JIRA shell active; client is in 'jira'." - ' Press Ctrl-D to exit.') + ip_shell("*** JIRA shell active; client is in 'jira'." + ' Press Ctrl-D to exit.') + if __name__ == '__main__': status = main() diff -Nru python-jira-1.0.10/jira/resilientsession.py python-jira-2.0.0/jira/resilientsession.py --- python-jira-1.0.10/jira/resilientsession.py 2017-02-10 19:26:23.000000000 +0000 +++ python-jira-2.0.0/jira/resilientsession.py 2018-07-12 17:11:24.000000000 +0000 @@ -44,7 +44,8 @@ error = errorMessages[0] else: error = errorMessages - elif 'errors' in response and len(response['errors']) > 0: + # Catching only 'errors' that are dict. See https://github.com/pycontribs/jira/issues/350 + elif 'errors' in response and len(response['errors']) > 0 and isinstance(response['errors'], dict): # JIRA 6.x error messages are found in this array. error_list = response['errors'].values() error = ", ".join(error_list) diff -Nru python-jira-1.0.10/jira/resources.py python-jira-2.0.0/jira/resources.py --- python-jira-1.0.10/jira/resources.py 2017-02-10 19:26:23.000000000 +0000 +++ python-jira-2.0.0/jira/resources.py 2018-07-12 17:11:24.000000000 +0000 @@ -48,8 +48,12 @@ 'SecurityLevel', 'Status', 'User', + 'Group', 'CustomFieldOption', - 'RemoteLink' + 'RemoteLink', + 'Customer', + 'ServiceDesk', + 'RequestType', ) logging.getLogger('jira').addHandler(NullHandler()) @@ -201,17 +205,17 @@ options.update({'path': path}) return self._base_url.format(**options) - def update(self, fields=None, async=None, jira=None, notify=True, **kwargs): + def update(self, fields=None, async_=None, jira=None, notify=True, **kwargs): """Update this resource on the server. Keyword arguments are marshalled into a dict before being sent. If this resource doesn't support ``PUT``, a :py:exc:`.JIRAError` will be raised; subclasses that specialize this method will only raise errors in case of user error. - :param async: if true the request will be added to the queue so it can be executed later using async_run() + :param async_: if true the request will be added to the queue so it can be executed later using async_run() """ - if async is None: - async = self._options['async'] + if async_ is None: + async_ = self._options['async'] data = {} if fields is not None: @@ -279,8 +283,7 @@ # logging.warning("autofix: setting assignee to '%s' and retrying the update." % self._options['autofix']) # data['fields']['assignee'] = {'name': self._options['autofix']} # EXPERIMENTAL ---> - # import grequests - if async: + if async_: if not hasattr(self._session, '_async_jobs'): self._session._async_jobs = set() self._session._async_jobs.add(threaded_requests.put( @@ -328,7 +331,7 @@ def _default_headers(self, user_headers): # result = dict(user_headers) - # esult['accept'] = 'application/json' + # result['accept'] = 'application/json' return CaseInsensitiveDict(self._options['headers'].items() + user_headers.items()) @@ -421,15 +424,15 @@ Resource.__init__(self, 'issue/{0}', options, session) self.fields = None - """ :type : Issue._IssueFields """ + """ :type: :class:`~Issue._IssueFields` """ self.id = None - """ :type : int """ + """ :type: int """ self.key = None - """ :type : str """ + """ :type: str """ if raw: self._parse_raw(raw) - def update(self, fields=None, update=None, async=None, jira=None, notify=True, **fieldargs): + def update(self, fields=None, update=None, async_=None, jira=None, notify=True, **fieldargs): """Update this issue on the server. Each keyword argument (other than the predefined ones) is treated as a field name and the argument's value @@ -441,10 +444,12 @@ are available here: https://developer.atlassian.com/display/JIRADEV/JIRA+REST+API+Example+-+Edit+issues :param fields: a dict containing field names and the values to use - :param update: a dict containing update operations to apply + :param fieldargs: keyword arguments will generally be merged into fields, except lists, + which will be merged into updates - keyword arguments will generally be merged into fields, except lists, which will be merged into updates + :type fields: dict + :type update: dict """ data = {} if fields is not None: @@ -477,12 +482,17 @@ else: fields_dict[field] = value - super(Issue, self).update(async=async, jira=jira, notify=notify, fields=data) + super(Issue, self).update(async_=async_, jira=jira, notify=notify, fields=data) def add_field_value(self, field, value): """Add a value to a field that supports multiple values, without resetting the existing values. This should work with: labels, multiple checkbox lists, multiple select + + :param field: The field name + :param value: The field's value + + :type field: str """ super(Issue, self).update(fields={"update": {field: [{"add": value}]}}) @@ -490,6 +500,8 @@ """Delete this issue from the server. :param deleteSubtasks: if the issue has subtasks, this argument must be set to true for the call to succeed. + + :type deleteSubtasks: bool """ super(Issue, self).delete(params={'deleteSubtasks': deleteSubtasks}) @@ -497,6 +509,8 @@ """Get the URL of the issue, the browsable one not the REST one. :return: URL of the issue + + :rtype: str """ return "%s/browse/%s" % (self._options['server'], self.key) @@ -513,7 +527,7 @@ if raw: self._parse_raw(raw) - def update(self, fields=None, async=None, jira=None, body='', visibility=None): + def update(self, fields=None, async_=None, jira=None, body='', visibility=None): data = {} if body: data['body'] = body @@ -672,6 +686,7 @@ :param groups: a group or groups to add to the role :type groups: string, list or tuple """ + if users is not None and isinstance(users, string_types): users = (users,) if groups is not None and isinstance(groups, string_types): @@ -685,6 +700,26 @@ super(Role, self).update(**data) + def add_user(self, users=None, groups=None): + """Add the specified users or groups to this project role. + + One of ``users`` or ``groups`` must be specified. + + :param users: a user or users to add to the role + :type users: string, list or tuple + :param groups: a group or groups to add to the role + :type groups: string, list or tuple + """ + + if users is not None and isinstance(users, string_types): + users = (users,) + if groups is not None and isinstance(groups, string_types): + groups = (groups,) + + data = { + 'user': users} + self._session.post(self.self, data=json.dumps(data)) + class Resolution(Resource): """A resolution for an issue.""" @@ -722,7 +757,7 @@ self._parse_raw(raw) def __hash__(self): - """Hash carculation.""" + """Hash calculation.""" return hash(str(self.name)) def __eq__(self, other): @@ -730,6 +765,23 @@ return str(self.name) == str(other.name) +class Group(Resource): + """A JIRA user group.""" + + def __init__(self, options, session, raw=None): + Resource.__init__(self, 'group?groupname={0}', options, session) + if raw: + self._parse_raw(raw) + + def __hash__(self): + """Hash calculation.""" + return hash(str(self.name)) + + def __eq__(self, other): + """Equality by name.""" + return str(self.name) == str(other.name) + + class Version(Resource): """A version of a project.""" @@ -749,6 +801,7 @@ :param moveAffectedIssuesTo: in issues for which this version is an affected version, add this argument version to the affected version list """ + params = {} if moveFixIssuesTo is not None: params['moveFixIssuesTo'] = moveFixIssuesTo @@ -825,6 +878,34 @@ Resource.delete(self, params) +# Service Desk + +class Customer(Resource): + """A Service Desk customer.""" + + def __init__(self, options, session, raw=None): + Resource.__init__(self, 'customer', options, session, '{server}/rest/servicedeskapi/{path}') + if raw: + self._parse_raw(raw) + + +class ServiceDesk(Resource): + """A Service Desk.""" + + def __init__(self, options, session, raw=None): + Resource.__init__(self, 'servicedesk/{0}', options, session, '{server}/rest/servicedeskapi/{path}') + if raw: + self._parse_raw(raw) + + +class RequestType(Resource): + """A Service Desk Request Type.""" + + def __init__(self, options, session, raw=None): + Resource.__init__(self, 'servicedesk/{0}/requesttype', options, session, '{server}/rest/servicedeskapi/{path}') + if raw: + self._parse_raw(raw) + # Utilities @@ -867,6 +948,7 @@ setattr(top, i, j) return top + resource_class_map = { # JIRA specific resources r'attachment/[^/]+$': Attachment, @@ -889,16 +971,26 @@ r'securitylevel/[^/]+$': SecurityLevel, r'status/[^/]+$': Status, r'user\?username.+$': User, + r'group\?groupname.+$': Group, r'version/[^/]+$': Version, # GreenHopper specific resources r'sprints/[^/]+$': Sprint, r'views/[^/]+$': Board} +class UnknownResource(Resource): + """A Resource from JIRA that is not (yet) supported.""" + + def __init__(self, options, session, raw=None): + Resource.__init__(self, 'unknown{0}', options, session) + if raw: + self._parse_raw(raw) + + def cls_for_resource(resource_literal): for resource in resource_class_map: if re.search(resource, resource_literal): return resource_class_map[resource] else: - # Generic Resource without specialized update/delete behavior - return Resource + # Generic Resource cannot directly be used b/c of different constructor signature + return UnknownResource diff -Nru python-jira-1.0.10/jira/utils/__init__.py python-jira-2.0.0/jira/utils/__init__.py --- python-jira-1.0.10/jira/utils/__init__.py 2017-02-10 19:26:23.000000000 +0000 +++ python-jira-2.0.0/jira/utils/__init__.py 2018-07-12 17:11:24.000000000 +0000 @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- """JIRA utils used internally.""" from __future__ import unicode_literals -import json import threading from jira.resilientsession import raise_on_error @@ -22,7 +21,7 @@ cid = CaseInsensitiveDict() cid['Accept'] = 'application/json' - cid['aCCEPT'] == 'application/json' # True + cid['accept'] == 'application/json' # True list(cid) == ['Accept'] # True For example, ``headers['content-encoding']`` will return the @@ -77,8 +76,10 @@ def json_loads(r): raise_on_error(r) - if len(r.text): # r.status_code != 204: - return json.loads(r.text) - else: + try: + return r.json() + except ValueError: # json.loads() fails with empty bodies - return {} + if not r.text: + return {} + raise diff -Nru python-jira-1.0.10/jira/utils/lru_cache.py python-jira-2.0.0/jira/utils/lru_cache.py --- python-jira-1.0.10/jira/utils/lru_cache.py 2017-02-10 19:26:23.000000000 +0000 +++ python-jira-2.0.0/jira/utils/lru_cache.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,170 +0,0 @@ -try: - from functools import lru_cache - -except ImportError: - # backport of Python's 3.3 lru_cache, written by Raymond Hettinger and - # licensed under MIT license, from: - # - # Should be removed when Django only supports Python 3.2 and above. - - from collections import namedtuple - from functools import update_wrapper - from threading import RLock - - _CacheInfo = namedtuple("CacheInfo", ["hits", "misses", "maxsize", "currsize"]) - - class _HashedSeq(list): - __slots__ = 'hashvalue' - - def __init__(self, tup, hash=hash): - self[:] = tup - self.hashvalue = hash(tup) - - def __hash__(self): - return self.hashvalue - - def _make_key(args, kwds, typed, - kwd_mark=(object(),), - fasttypes=set([int, str, frozenset, type(None)]), - sorted=sorted, tuple=tuple, type=type, len=len): - """Make a cache key from optionally typed positional and keyword arguments.""" - key = args - if kwds: - sorted_items = sorted(kwds.items()) - key += kwd_mark - for item in sorted_items: - key += item - if typed: - key += tuple(type(v) for v in args) - if kwds: - key += tuple(type(v) for k, v in sorted_items) - elif len(key) == 1 and type(key[0]) in fasttypes: - return key[0] - return _HashedSeq(key) - - def lru_cache(maxsize=100, typed=False): - """Least-recently-used cache decorator. - - If *maxsize* is set to None, the LRU features are disabled and the cache - can grow without bound. - - If *typed* is True, arguments of different types will be cached separately. - For example, f(3.0) and f(3) will be treated as distinct calls with - distinct results. - - Arguments to the cached function must be hashable. - - View the cache statistics named tuple (hits, misses, maxsize, currsize) with - f.cache_info(). Clear the cache and statistics with f.cache_clear(). - Access the underlying function with f.__wrapped__. - - See: https://en.wikipedia.org/wiki/Cache_algorithms#Least_Recently_Used - """ - # Users should only access the lru_cache through its public API: - # cache_info, cache_clear, and f.__wrapped__ - # The internals of the lru_cache are encapsulated for thread safety and - # to allow the implementation to change (including a possible C version). - - def decorating_function(user_function): - - cache = dict() - stats = [0, 0] # make statistics updateable non-locally - HITS, MISSES = 0, 1 # names for the stats fields - make_key = _make_key - cache_get = cache.get # bound method to lookup key or return None - _len = len # localize the global len() function - lock = RLock() # because linkedlist updates aren't threadsafe - root = [] # root of the circular doubly linked list - root[:] = [root, root, None, None] # initialize by pointing to self - nonlocal_root = [root] # make updateable non-locally - PREV, NEXT, KEY, RESULT = 0, 1, 2, 3 # names for the link fields - - if maxsize == 0: - - def wrapper(*args, **kwds): - # no caching, just do a statistics update after a successful call - result = user_function(*args, **kwds) - stats[MISSES] += 1 - return result - - elif maxsize is None: - - def wrapper(*args, **kwds): - # simple caching without ordering or size limit - key = make_key(args, kwds, typed) - result = cache_get(key, root) # root used here as a unique not-found sentinel - if result is not root: - stats[HITS] += 1 - return result - result = user_function(*args, **kwds) - cache[key] = result - stats[MISSES] += 1 - return result - - else: - - def wrapper(*args, **kwds): - # size limited caching that tracks accesses by recency - key = make_key(args, kwds, typed) if kwds or typed else args - with lock: - link = cache_get(key) - if link is not None: - # record recent use of the key by moving it to the front of the list - root, = nonlocal_root - link_prev, link_next, key, result = link - link_prev[NEXT] = link_next - link_next[PREV] = link_prev - last = root[PREV] - last[NEXT] = root[PREV] = link - link[PREV] = last - link[NEXT] = root - stats[HITS] += 1 - return result - result = user_function(*args, **kwds) - with lock: - root, = nonlocal_root - if key in cache: - # getting here means that this same key was added to the - # cache while the lock was released. since the link - # update is already done, we need only return the - # computed result and update the count of misses. - pass - elif _len(cache) >= maxsize: - # use the old root to store the new key and result - oldroot = root - oldroot[KEY] = key - oldroot[RESULT] = result - # empty the oldest link and make it the new root - root = nonlocal_root[0] = oldroot[NEXT] - oldkey = root[KEY] - root[KEY] = root[RESULT] = None - # now update the cache dictionary for the new links - del cache[oldkey] - cache[key] = oldroot - else: - # put result in a new link at the front of the list - last = root[PREV] - link = [last, root, key, result] - last[NEXT] = root[PREV] = cache[key] = link - stats[MISSES] += 1 - return result - - def cache_info(): - """Report cache statistics.""" - with lock: - return _CacheInfo(stats[HITS], stats[MISSES], maxsize, len(cache)) - - def cache_clear(): - """Clear the cache and cache statistics.""" - with lock: - cache.clear() - root = nonlocal_root[0] - root[:] = [root, root, None, None] - stats[:] = [0, 0] - - wrapper.__wrapped__ = user_function - wrapper.cache_info = cache_info - wrapper.cache_clear = cache_clear - return update_wrapper(wrapper, user_function) - - return decorating_function diff -Nru python-jira-1.0.10/jira/utils/version.py python-jira-2.0.0/jira/utils/version.py --- python-jira-1.0.10/jira/utils/version.py 2017-02-10 19:26:23.000000000 +0000 +++ python-jira-2.0.0/jira/utils/version.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,82 +0,0 @@ -from __future__ import unicode_literals - -import datetime -import os -import subprocess - -from jira.utils.lru_cache import lru_cache - - -def get_version(version=None): - """Return a PEP 440-compliant version number from VERSION.""" - version = get_complete_version(version) - - # Now build the two parts of the version number: - # main = X.Y[.Z] - # sub = .devN - for pre-alpha releases - # | {a|b|rc}N - for alpha, beta, and rc releases - - main = get_main_version(version) - - sub = '' - if version[3] == 'alpha' and version[4] == 0: - git_changeset = get_git_changeset() - if git_changeset: - sub = '.dev%s' % git_changeset - - elif version[3] != 'final': - mapping = {'alpha': 'a', 'beta': 'b', 'rc': 'rc'} - sub = mapping[version[3]] + str(version[4]) - - return str(main + sub) - - -def get_main_version(version=None): - """Return main version (X.Y[.Z]) from VERSION.""" - version = get_complete_version(version) - parts = 2 if version[2] == 0 else 3 - return '.'.join(str(x) for x in version[:parts]) - - -def get_complete_version(version=None): - """Return a tuple of the jira version. - - If version argument is non-empty, then checks for correctness of the tuple provided. - """ - if version is None: - from jira import VERSION as version - else: - assert len(version) == 5 - assert version[3] in ('alpha', 'beta', 'rc', 'final') - - return version - - -def get_docs_version(version=None): - version = get_complete_version(version) - if version[3] != 'final': - return 'dev' - else: - return '%d.%d' % version[:2] - - -@lru_cache() -def get_git_changeset(): - """Return a numeric identifier of the latest git changeset. - - The result is the UTC timestamp of the changeset in YYYYMMDDHHMMSS format. - This value isn't guaranteed to be unique, but collisions are very unlikely, - so it's sufficient for generating the development version numbers. - """ - repo_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - git_log = subprocess.Popen( - 'git log --pretty=format:%ct --quiet -1 HEAD', - stdout=subprocess.PIPE, stderr=subprocess.PIPE, - shell=True, cwd=repo_dir, universal_newlines=True, - ) - timestamp = git_log.communicate()[0] - try: - timestamp = datetime.datetime.utcfromtimestamp(int(timestamp)) - except ValueError: - return None - return timestamp.strftime('%Y%m%d%H%M%S') diff -Nru python-jira-1.0.10/LICENSE python-jira-2.0.0/LICENSE --- python-jira-1.0.10/LICENSE 2017-02-10 19:26:23.000000000 +0000 +++ python-jira-2.0.0/LICENSE 2018-07-12 17:11:24.000000000 +0000 @@ -16,4 +16,4 @@ 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. \ No newline at end of file +THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff -Nru python-jira-1.0.10/Makefile python-jira-2.0.0/Makefile --- python-jira-1.0.10/Makefile 2017-02-10 19:26:23.000000000 +0000 +++ python-jira-2.0.0/Makefile 2018-07-12 17:11:24.000000000 +0000 @@ -1,93 +1,109 @@ -all: info clean flake8 test docs upload release -.PHONY: all docs upload info req +all: info clean lint test docs dist upload release +.PHONY: all docs upload info req dist PACKAGE_NAME := $(shell python setup.py --name) PACKAGE_VERSION := $(shell python setup.py --version) PYTHON_PATH := $(shell which python) -PLATFORM := $(shell uname -s | awk '{print tolower($0)}') -DIR := $(shell dirname $(realpath $(lastword $(MAKEFILE_LIST)))) +PLATFORM := $(shell uname -s | awk '{print tolower($$0)}') +ifeq ($(PLATFORM), darwin) + DIR := $(shell dirname $(realpath $(lastword $(MAKEFILE_LIST)))) +else + DIR := $(shell dirname $(realpath $(MAKEFILE_LIST))) +endif PYTHON_VERSION := $(shell python3 -c "import sys; print('py%s%s' % sys.version_info[0:2] + ('-conda' if 'conda' in sys.version or 'Continuum' in sys.version else ''))") -PYENV_HOME := $(DIR)/.tox/$(PYTHON_VERSION)-$(PLATFORM)/ -ifneq (,$(findstring conda,$(PYTHON_VERSION))) -CONDA:=1 +ifneq (,$(findstring conda, $(PYTHON_VERSION))) + #CONDA := $(shell conda info --envs | grep '*' | awk '{print $$1}') + CONDA := $(CONDA_DEFAULT_ENV) endif +PREFIX := ifndef GIT_BRANCH GIT_BRANCH=$(shell git branch | sed -n '/\* /s///p') endif info: @echo "INFO: Building $(PACKAGE_NAME):$(PACKAGE_VERSION) on $(GIT_BRANCH) branch" - @echo "INFO: Python $(PYTHON_VERSION) from $(PYENV_HOME) [$(CONDA)]" + @echo "INFO: Python $(PYTHON_VERSION) from '$(PREFIX)' [$(CONDA)]" clean: @find . -name "*.pyc" -delete - @rm -rf .tox/*-$(PLATFORM) .tox/docs dist/* .tox/dist .tox/log docs/build/* + @rm -rf .tox dist/* docs/build/* package: python setup.py sdist bdist_wheel build_sphinx req: - @$(PYENV_HOME)/bin/requires.io update-site -t ac3bbcca32ae03237a6aae2b02eb9411045489bb -r $(PACKAGE_NAME) + @$(PREFIX)requires.io update-site -t ac3bbcca32ae03237a6aae2b02eb9411045489bb -r $(PACKAGE_NAME) + +hooks: + @$(PREFIX)python -m flake8 --install-hook 2>/dev/null || true install: prepare - $(PYENV_HOME)/bin/pip install . + $(PREFIX)pip install . +# https://developer.atlassian.com/docs/getting-started/set-up-the-atlassian-plugin-sdk-and-build-a-project/install-the-atlassian-sdk-on-a-linux-or-mac-system#InstalltheAtlassianSDKonaLinuxorMacsystem-Homebrew install-sdk: - # https://developer.atlassian.com/docs/getting-started/set-up-the-atlassian-plugin-sdk-and-build-a-project/install-the-atlassian-sdk-on-a-linux-or-mac-system#InstalltheAtlassianSDKonaLinuxorMacsystem-Homebrew +ifeq ($(PLATFORM), darwin) which atlas-run-standalone || brew tap atlassian/tap && brew install atlassian/tap/atlassian-plugin-sdk +else ifeq ($(PLATFORM), linux) + ifneq ($(USER), root) + @echo "Install of Atlassian SDK must be run as root (or with sudo)" + exit 1 + endif + ifneq ($(wildcard /etc/debian_version),) + sh -c 'echo "deb https://sdkrepo.atlassian.com/debian/ stable contrib" >/etc/apt/sources.list.d/atlassian_development.list' + apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys B07804338C015B73 + apt-get install apt-transport-https + apt-get update + apt-get install atlassian-plugin-sdk + else ifneq ($(wildcard /etc/redhat-release),) + tmp_dir=$(mktemp -d) + curl https://marketplace.atlassian.com/download/plugins/atlassian-plugin-sdk-rpm/version/42380 -o ${tmp_dir}/atlassian-plugin-sdk.noarch.rpm + yum -y install ${tmp_dir}/atlassian-plugin-sdk.noarch.rpm + rm -rf ${tmp_dir} + else + @echo "Error: Cannot determine package manager to use to install atlassian-sdk. Please see:" + @echo "https://developer.atlassian.com/docs/getting-started/set-up-the-atlassian-plugin-sdk-and-build-a-project/install-the-atlassian-sdk-on-a-linux-or-mac-system" + exit 1 + endif +endif uninstall: - $(PYENV_HOME)/bin/pip uninstall -y $(PACKAGE_NAME) + $(PREFIX)pip uninstall -y $(PACKAGE_NAME) -venv: $(PYENV_HOME)/bin/activate +dist: + $(PREFIX)python setup.py sdist bdist_wheel -# virtual environment depends on requriements files -$(PYENV_HOME)/bin/activate: requirements*.txt - @echo "INFO: (Re)creating virtual environment..." -ifdef CONDA - test -e $(PYENV_HOME)/bin/activate || conda create -y --prefix $(PYENV_HOME) pip -else - test -e $(PYENV_HOME)/bin/activate || virtualenv --python=$(PYTHON_PATH) --system-site-packages $(PYENV_HOME) -endif - $(PYENV_HOME)/bin/pip install -q -r requirements.txt -r requirements-opt.txt -r requirements-dev.txt - touch $(PYENV_HOME)/bin/activate - -prepare: venv - pyenv install -s 2.7.13 - pyenv install -s 3.4.5 - pyenv install -s 3.5.2 - pyenv install -s 3.6.0 - pyenv local 2.7.13 3.4.5 3.5.2 3.6.0 - @echo "INFO: === Prearing to run for package:$(PACKAGE_NAME) platform:$(PLATFORM) py:$(PYTHON_VERSION) dir:$(DIR) ===" - if [ -f ${HOME}/testspace/testspace ]; then ${HOME}/testspace/testspace config url ${TESTSPACE_TOKEN}@pycontribs.testspace.com/jira/tests ; fi; +prepare: + @pyenv install -s 2.7.13 + @pyenv install -s 3.4.5 + @pyenv install -s 3.5.2 + @pyenv install -s 3.6.0 + @pyenv local 2.7.13 3.4.5 3.5.2 3.6.0 + @echo "INFO: === Preparing to run for package:$(PACKAGE_NAME) platform:$(PLATFORM) py:$(PYTHON_VERSION) dir:$(DIR) ===" + #if [ -f ${HOME}/testspace/testspace ]; then ${HOME}/testspace/testspace config url ${TESTSPACE_TOKEN}@pycontribs.testspace.com/jira/tests ; fi; testspace: ${HOME}/testspace/testspace publish build/results.xml -flake8: venv - @echo "INFO: flake8" - $(PYENV_HOME)/bin/python -m flake8 - $(PYENV_HOME)/bin/python -m flake8 --install-hook 2>/dev/null || true +lint: + @echo "INFO: linting...." + $(PREFIX)tox -e lint -test: prepare flake8 +test: prepare lint @echo "INFO: test" - $(PYENV_HOME)/bin/python setup.py build test build_sphinx sdist bdist_wheel check --restructuredtext --strict - -test-cli: - $(PYENV_HOME)/bin/ipython -c "import jira; j = jira.JIRA('https://pycontribs.atlassian.net'); j.server_info()" -i + $(PREFIX)python setup.py build test build_sphinx sdist bdist_wheel check --restructuredtext --strict test-all: @echo "INFO: test-all (extended/matrix tests)" # tox should not run inside virtualenv because it does create and use multiple virtualenvs pip install -q tox tox-pyenv - python -m tox --skip-missing-interpreters true - + python -m tox docs: @echo "INFO: Building the docs" - $(PYENV_HOME)/bin/pip install sphinx - $(PYENV_HOME)/bin/python setup.py build_sphinx + $(PREFIX)pip install sphinx sphinx_rtd_theme + $(PREFIX)python setup.py build_sphinx @mkdir -p docs/build/docset @mkdir -p docs/build/html/docset # cannot put doc2dash into requirements.txt file because is using pinned requirements @@ -112,13 +128,16 @@ endif upload: + rm -f dist/* ifeq ($(GIT_BRANCH),develop) @echo "INFO: Upload package to testpypi.python.org" - $(PYENV_HOME)/bin/python setup.py check --restructuredtext --strict - $(PYENV_HOME)/bin/python setup.py sdist bdist_wheel upload -r https://testpypi.python.org/pypi + $(PREFIX)python setup.py check --restructuredtext --strict + $(PREFIX)python setup.py sdist bdist_wheel + $(PREFIX)twine upload --repository-url https://test.pypi.org/legacy/ dist/* endif ifeq ($(GIT_BRANCH),master) @echo "INFO: Upload package to pypi.python.org" - $(PYENV_HOME)/bin/python setup.py check --restructuredtext --strict - $(PYENV_HOME)/bin/python setup.py sdist bdist_wheel upload + $(PREFIX)python setup.py check --restructuredtext --strict + $(PREFIX)python setup.py sdist bdist_wheel + $(PREFIX)twine upload dist/* endif diff -Nru python-jira-1.0.10/package.json python-jira-2.0.0/package.json --- python-jira-1.0.10/package.json 1970-01-01 00:00:00.000000000 +0000 +++ python-jira-2.0.0/package.json 2018-07-12 17:11:24.000000000 +0000 @@ -0,0 +1,18 @@ +{ + "name": "python-jira", + "version": "0.0.1", + "license": "SEE LICENSE IN LICENSE", + "scripts": { + "spell": "npm -s run spell-files && npm -s run spell-commit", + "spell-commit": "git log -1 --pretty=%B > .git/commit.msg && cspell .git/commit.msg", + "spell-files": "git ls-files | xargs cspell --unique" + }, + "repository": { + "type": "git", + "url": "https://github.com/pycontribs/jira.git" + }, + "dependencies": { + "cspell": "^2.1.12", + "npm": "^6.1.0" + } +} diff -Nru python-jira-1.0.10/.pre-commit-config.yaml python-jira-2.0.0/.pre-commit-config.yaml --- python-jira-1.0.10/.pre-commit-config.yaml 1970-01-01 00:00:00.000000000 +0000 +++ python-jira-2.0.0/.pre-commit-config.yaml 2018-07-12 17:11:24.000000000 +0000 @@ -0,0 +1,28 @@ +--- +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v1.3.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: mixed-line-ending + - id: check-byte-order-marker + - id: check-executables-have-shebangs + - id: check-merge-conflict + - id: check-symlinks + - id: check-vcs-permalinks + - id: flake8 + - id: debug-statements + - id: requirements-txt-fixer + - id: check-yaml + files: .*\.(yaml|yml)$ + - repo: https://github.com/adrienverge/yamllint.git + rev: v1.11.1 + hooks: + - id: yamllint + files: \.(yaml|yml)$ + + - repo: https://github.com/ssbarnea/bashate.git + rev: 0.5.2 + hooks: + - id: bashate diff -Nru python-jira-1.0.10/README.rst python-jira-2.0.0/README.rst --- python-jira-1.0.10/README.rst 2017-02-10 19:26:23.000000000 +0000 +++ python-jira-2.0.0/README.rst 2018-07-12 17:11:24.000000000 +0000 @@ -8,22 +8,19 @@ .. image:: https://img.shields.io/pypi/l/jira.svg :target: https://pypi.python.org/pypi/jira/ -.. image:: https://img.shields.io/pypi/dm/jira.svg +.. image:: https://img.shields.io/pypi/wheel/jira.svg :target: https://pypi.python.org/pypi/jira/ -.. image:: https://img.shields.io/pypi/wheel/Django.svg - :target: https://pypi.python.org/pypi/jira/ +.. image:: https://img.shields.io/github/issues/pycontribs/jira.svg + :target: https://github.com/pycontribs/jira/issues ------------ .. image:: https://readthedocs.org/projects/jira/badge/?version=master :target: http://jira.readthedocs.io -.. image:: https://api.travis-ci.org/pycontribs/jira.svg?branch=master - :target: https://travis-ci.org/pycontribs/jira - -.. image:: https://img.shields.io/pypi/status/jira.svg - :target: https://pypi.python.org/pypi/jira/ +.. image:: https://travis-ci.com/pycontribs/jira.svg?branch=master + :target: https://travis-ci.com/pycontribs/jira .. image:: https://codecov.io/gh/pycontribs/jira/branch/develop/graph/badge.svg :target: https://codecov.io/gh/pycontribs/jira @@ -61,7 +58,7 @@ Installation -~~~~~~~~~~~~ +------------ Download and install using ``pip install jira`` or ``easy_install jira`` @@ -69,11 +66,15 @@ upgrade jira to your user directory. Or maybe you ARE using a virtualenv_ right? +By default only the basic library dependencies are installed, so if you want +to use the ``cli`` tool or other optional dependencies do perform a full +installation using ``pip install jira[opt,cli,testing]`` + .. _virtualenv: http://www.virtualenv.org/en/latest/index.html Usage -~~~~~ +----- See the documentation_ for full details. @@ -81,28 +82,41 @@ Development -~~~~~~~~~~~ +----------- -Development takes place on GitHub_, where the git-flow_ branch structure is used: +Development takes place on GitHub_: -* ``master`` - contains the latest released code. -* ``develop`` - (default branch) is used for development of the next release. -* ``feature/XXX`` - feature branches are used for development of new features before they are merged to ``develop``. +* ``master`` - (default branch) contains the primary development stream. Tags will be used to show latest releases. .. _GitHub: https://github.com/pycontribs/jira -.. _git-flow: http://nvie.com/posts/a-successful-git-branching-model/ + +Setup +===== +* Fork_ repo +* Keep it sync_'ed while you are developing +* Install pyenv_ +* Install Atlassian Server for testing + - make install-sdk +* pip install requirements-dev.txt +* Start up Jira Server + - atlas-run-standalone +* Test your changes + - make test + +.. _Fork: https://help.github.com/articles/fork-a-repo/ +.. _sync: https://help.github.com/articles/syncing-a-fork/ +.. _pyenv: https://amaral.northwestern.edu/resources/guides/pyenv-tutorial Credits ------- -In additions to all the contributors we would like to thank to these companies: +In addition to all the contributors we would like to thank to these companies: -* Atlassian_ for developing such a powerful issue tracker and for providing a free on-demand JIRA_ instance that we can use for continous integration testing. +* Atlassian_ for developing such a powerful issue tracker and for providing a free on-demand JIRA_ instance that we can use for continuous integration testing. * JetBrains_ for providing us with free licenses of PyCharm_ -* Travis_ for hosting our continous integration +* Travis_ for hosting our continuous integration * Navicat_ for providing us free licenses of their powerful database client GUI tools. -* Citrix_ for providing maintenance of the library. .. _Atlassian: https://www.atlassian.com/ .. _JIRA: https://pycontribs.atlassian.net @@ -110,16 +124,12 @@ .. _PyCharm: http://www.jetbrains.com/pycharm/ .. _Travis: https://travis-ci.org/ .. _navicat: https://www.navicat.com/ -.. _Citrix: http://www.citrix.com/ -.. image:: https://images1-focus-opensocial.googleusercontent.com/gadgets/proxy?container=focus&refresh=3600&resize_h=50&url=https://www.atlassian.com/dms/wac/images/press/Atlassian-logos/logoAtlassianPNG.png +.. image:: https://raw.githubusercontent.com/pycontribs/resources/master/logos/x32/logo-atlassian.png :target: http://www.atlassian.com -.. image:: https://images1-focus-opensocial.googleusercontent.com/gadgets/proxy?container=focus&refresh=3600&resize_h=50&url=http://blog.jetbrains.com/pycharm/files/2015/12/PyCharm_400x400_Twitter_logo_white.png +.. image:: https://raw.githubusercontent.com/pycontribs/resources/master/logos/x32/logo-pycharm.png :target: http://www.jetbrains.com/ -.. image:: https://images1-focus-opensocial.googleusercontent.com/gadgets/proxy?container=focus&refresh=3600&resize_h=50&url=https://upload.wikimedia.org/wikipedia/en/9/90/PremiumSoft_Navicat_Premium_Logo.png +.. image:: https://raw.githubusercontent.com/pycontribs/resources/master/logos/x32/logo-navicat.png :target: http://www.navicat.com/ - -.. image:: https://images1-focus-opensocial.googleusercontent.com/gadgets/proxy?container=focus&refresh=3600&resize_h=50&url=https://www.citrix.com/content/dam/citrix/en_us/images/logos/citrix/citrix-logo-black.jpg - :target: http://www.citrix.com/ diff -Nru python-jira-1.0.10/release.sh python-jira-2.0.0/release.sh --- python-jira-1.0.10/release.sh 2017-02-10 19:26:23.000000000 +0000 +++ python-jira-2.0.0/release.sh 1970-01-01 00:00:00.000000000 +0000 @@ -1,61 +0,0 @@ -#!/bin/bash -set -e - -TAG=$(git describe $(git rev-list --tags --max-count=1)) -VERSION=$(python setup.py --version) -echo "INFO: Preparing to release version ${VERSION} based on git tag ${TAG}" - -exit 1 - -if testvercomp $TAG $VERSION '<'; then - echo "." -else - echo >&2 "ERROR: Current version and git tag do not match, cannot make release." - exit 1 -fi - -echo "INFO: Checking that all changes are commited and pushed" -git pull - -#git diff -# Disallow unstaged changes in the working tree - if ! git diff-files --check --exit-code --ignore-submodules -- >&2 - then - echo >&2 "ERROR: You have unstaged changes." - #exit 1 - fi - -# Disallow uncommitted changes in the index - if ! git diff-index --cached --exit-code -r --ignore-submodules HEAD -- >&2 - then - echo >&2 "ERROR: Your index contains uncommitted changes." - #exit 1 - fi - -# Use the gitchangelog tool to re-generate automated changelog -gitchangelog > CHANGELOG - -if [ -z ${CI+x} ]; then - echo "WARN: Please don't run this as a user. This generates a new release for PyPI. Press ^C to exit or Enter to continue." -else - echo "INFO: Automatic deployment" -fi - -git add CHANGELOG -git commit -m "Auto-generating release notes." - -git tag -fa ${VERSION} -m "Version ${VERSION}" -git tag -fa -a RELEASE -m "Current RELEASE" - -NEW_VERSION="${VERSION%.*}.$((${VERSION##*.}+1))" -set -ex -sed -i.bak "s/${VERSION}/${NEW_VERSION}/" setup.py - -git commit -m "Auto-increasing the version number after a release." - -# disables because this is done only by Travis CI from now, which calls this script after that. -#python setup.py register sdist bdist_wheel build_sphinx upload --sign - -git push --force origin --tags - -echo "INFO: done." diff -Nru python-jira-1.0.10/requirements-all.txt python-jira-2.0.0/requirements-all.txt --- python-jira-1.0.10/requirements-all.txt 2017-02-10 19:26:23.000000000 +0000 +++ python-jira-2.0.0/requirements-all.txt 1970-01-01 00:00:00.000000000 +0000 @@ -1,3 +0,0 @@ --r requirements.txt --r requirements-opt.txt --r requirements-dev.txt diff -Nru python-jira-1.0.10/requirements-dev.txt python-jira-2.0.0/requirements-dev.txt --- python-jira-1.0.10/requirements-dev.txt 2017-02-10 19:26:23.000000000 +0000 +++ python-jira-2.0.0/requirements-dev.txt 2018-07-12 17:11:24.000000000 +0000 @@ -1,27 +1,24 @@ -# The order of packages is significant, because pip processes them in the order -# of appearance. Changing the order has an impact on the overall integration -# process, which may cause wedges in the gate later. - -py >= 1.4 - -hacking>=0.13 -MarkupSafe>=0.23 +# this file is needed by readthedocs.org so don't move them in another place coveralls>=1.1 docutils>=0.12 +flaky +hacking>=0.13 +MarkupSafe>=0.23 +mock; python_version<'3.3' oauthlib +pre-commit +py >= 1.4 pytest-cache pytest-cov pytest-instafail pytest-xdist>=1.14 pytest>=2.9.1 +PyYAML>=3.13 requires.io -sphinx>=1.3.5 -sphinx_rtd_theme -tox>=2.3.1 +tenacity tox-pyenv +tox>=2.3.1 +unittest2; python_version<'3.1' wheel>=0.24.0 xmlrunner>=1.7.7 yanc>=0.3.3 -unittest2; python_version < '3.1' -flaky -tenacity diff -Nru python-jira-1.0.10/requirements-opt.txt python-jira-2.0.0/requirements-opt.txt --- python-jira-1.0.10/requirements-opt.txt 2017-02-10 19:26:23.000000000 +0000 +++ python-jira-2.0.0/requirements-opt.txt 2018-07-12 17:11:24.000000000 +0000 @@ -2,8 +2,7 @@ # of appearance. Changing the order has an impact on the overall integration # process, which may cause wedges in the gate later. -ipython>=4.0.0 +filemagic>=1.6 PyJWT requests_jwt requests_kerberos -filemagic>=1.6 diff -Nru python-jira-1.0.10/requirements.txt python-jira-2.0.0/requirements.txt --- python-jira-1.0.10/requirements.txt 2017-02-10 19:26:23.000000000 +0000 +++ python-jira-2.0.0/requirements.txt 2018-07-12 17:11:24.000000000 +0000 @@ -1,13 +1,9 @@ -# The order of packages is significant, because pip processes them in the order -# of appearance. Changing the order has an impact on the overall integration -# process, which may cause wedges in the gate later. -pbr - -ordereddict; python_version < '3.1' -argparse; python_version < '3.2' +argparse; python_version<'2.7' +defusedxml +oauthlib[signedtoken]>=1.0.0 # dep of requests-oauthlib, to avoid pycrypto. See: https://github.com/pycontribs/jira/issues/619 +pbr>=3.0.0 requests-oauthlib>=0.6.1 requests>=2.10.0 requests_toolbelt setuptools>=20.10.1 six>=1.10.0 -defusedxml diff -Nru python-jira-1.0.10/setup.cfg python-jira-2.0.0/setup.cfg --- python-jira-1.0.10/setup.cfg 2017-02-10 19:26:23.000000000 +0000 +++ python-jira-2.0.0/setup.cfg 2018-07-12 17:11:24.000000000 +0000 @@ -5,8 +5,19 @@ maintainer = Sorin Sbarnea maintainer-email = sorin.sbarnea@gmail.com summary = Python library for interacting with JIRA via REST APIs. -description-file = README.rst +description-file = + README.rst +# Do not include ChangeLog in description-file due to multiple reasons: +# - Unicode chars, see https://github.com/pycontribs/jira/issues/512 +# - Breaks ability to perform `python setup.py install` +description-content-type = text/plain; charset=UTF-8 home-page = https://github.com/pycontribs/jira +project_urls = + Bug Tracker = https://github.com/pycontribs/jira/issues + Source Code = https://github.com/pycontribs/jira.git + Documentation = https://jira.readthedocs.io/en/master/ +requires-python = >=2.7 + license = BSD classifier = Development Status :: 5 - Production/Stable @@ -21,21 +32,28 @@ Programming Language :: Python :: 3.4 Programming Language :: Python :: 3.5 Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 Topic :: Software Development :: Libraries :: Python Modules Topic :: Internet :: WWW/HTTP - -keywords = - api - atlassian - jira - rest - web - rest +keywords = api, atlassian, jira, rest, web [files] packages = jira +#[options.extras_require] +[extras] +cli = + ipython>=4.0.0,<6.0.0; python_version<'3.3' + ipython>=4.0.0; python_version>='3.3' +opt = + filemagic>=1.6 + PyJWT + requests_jwt + requests_kerberos +async = + requests-futures>=0.9.7 + [entry_points] console_scripts = jirashell = jira.jirashell:main @@ -61,11 +79,11 @@ max-line-length=160 exclude=build,.eggs,.tox statistics=yes -ignore = D100,D101,D102,D103,F405,B001,B002 +ignore = # TODO(ssbarnea): remove ignored flake8 rules one by one by fixing them. [pep8] -exclude=build,lib,.tox,third,*.egg,docs,packages,.eggs +exclude=build,lib,.tox,third,*.egg,docs,packages,.eggs,node_modules ;filename= ;select ignore=E501,E265,E402 @@ -82,4 +100,8 @@ ;pep8 --statistics -qq $PEP8_OPTS [pbr] -warnerrors = true \ No newline at end of file +warnerrors = true + +[pycodestyle] +max-line-length=160 +exclude = .eggs,.tox,build diff -Nru python-jira-1.0.10/setup.py python-jira-2.0.0/setup.py --- python-jira-1.0.10/setup.py 2017-02-10 19:26:23.000000000 +0000 +++ python-jira-2.0.0/setup.py 2018-07-12 17:11:24.000000000 +0000 @@ -1,8 +1,5 @@ #!/usr/bin/env python - import setuptools -from setuptools.command.test import test as TestCommand -import sys # In python < 2.7.4, a lazy loading of package `pbr` will break # setuptools if some other modules registered functions in `atexit`. @@ -13,25 +10,6 @@ pass -class PyTest(TestCommand): - - def initialize_options(self): - TestCommand.initialize_options(self) - self.pytest_args = [] - - def finalize_options(self): - TestCommand.finalize_options(self) - self.test_args = [] - self.test_suite = True - - def run_tests(self): - # import here, cause outside the eggs aren't loaded - import pytest - errno = pytest.main(self.pytest_args) - sys.exit(errno) - setuptools.setup( - setup_requires=['pbr>=1.9', 'setuptools>=17.1', 'pytest-runner'], - pbr=True, - cmdclass={'test': PyTest}, - test_suite='tests') + setup_requires=['pbr>=3.0.0', 'setuptools>=17.1', 'pytest-runner', 'sphinx>=1.6.5'], + pbr=True) diff -Nru python-jira-1.0.10/tests/start-jira.sh python-jira-2.0.0/tests/start-jira.sh --- python-jira-1.0.10/tests/start-jira.sh 2017-02-10 19:26:23.000000000 +0000 +++ python-jira-2.0.0/tests/start-jira.sh 2018-07-12 17:11:24.000000000 +0000 @@ -3,7 +3,8 @@ JIRA_URL=http://127.0.0.1:2990/jira/secure/Dashboard.jspa cd "$DIR" rm jira.log -atlas-run-standalone --product jira --http-port 2990 -B -nsu -o --threads 2.0C jira.log 2>&1 & +atlas-run-standalone --product jira --http-port 2990 \ + -B -nsu -o --threads 2.0C jira.log 2>&1 & printf "Waiting for JIRA to start respinding on $JIRA_URL " until $(curl --output /dev/null --silent --head --fail $JIRA_URL); do diff -Nru python-jira-1.0.10/tests/stop-jira.sh python-jira-2.0.0/tests/stop-jira.sh --- python-jira-1.0.10/tests/stop-jira.sh 2017-02-10 19:26:23.000000000 +0000 +++ python-jira-2.0.0/tests/stop-jira.sh 2018-07-12 17:11:24.000000000 +0000 @@ -2,4 +2,3 @@ set -ex kill $(ps -o pid,command|grep atlassian-plugin-sdk|grep java|awk '{print $1}') #ps -o pid,command|grep atlassian-plugin-sdk|grep java|awk '{kill -9 $1;}' - diff -Nru python-jira-1.0.10/tests/test_client.py python-jira-2.0.0/tests/test_client.py --- python-jira-1.0.10/tests/test_client.py 2017-02-10 19:26:23.000000000 +0000 +++ python-jira-2.0.0/tests/test_client.py 2018-07-12 17:11:24.000000000 +0000 @@ -68,7 +68,7 @@ raise -def test_delete_inexistant_project(cl_admin): +def test_delete_inexistent_project(cl_admin): slug = 'abogus123' with pytest.raises(ValueError) as ex: assert cl_admin.delete_project(slug) @@ -127,3 +127,31 @@ assert [t['name'] for t in template_list] == ["Scrum software development", "Kanban software development", "Basic software development", "Basic Service Desk", "IT Service Desk", "Task management", "Project management", "Process management"] + + +def test_result_list(): + iterable = [2, 3] + startAt = 0 + maxResults = 50 + total = 2 + + results = jira.client.ResultList(iterable, startAt, maxResults, total) + + for idx, result in enumerate(results): + assert results[idx] == iterable[idx] + + assert next(results) == iterable[0] + assert next(results) == iterable[1] + + with pytest.raises(StopIteration): + next(results) + + +def test_result_list_if_empty(): + results = jira.client.ResultList() + + for r in results: + raise AssertionError("`results` should be empty") + + with pytest.raises(StopIteration): + next(results) diff -Nru python-jira-1.0.10/tests/tests.py python-jira-2.0.0/tests/tests.py --- python-jira-1.0.10/tests/tests.py 2017-02-10 19:26:23.000000000 +0000 +++ python-jira-2.0.0/tests/tests.py 2018-07-12 17:11:24.000000000 +0000 @@ -30,7 +30,7 @@ _non_parallel = True if platform.python_version() < '3': _non_parallel = False - + import mock try: import unittest2 as unittest except ImportError: @@ -43,6 +43,11 @@ import unittest2 as unittest else: import unittest + try: + from unittest import mock + except ImportError: + import mock + cmd_folder = os.path.abspath(os.path.join(os.path.split(inspect.getfile( inspect.currentframe()))[0], "..")) @@ -51,7 +56,7 @@ import jira # noqa from jira import Role, Issue, JIRA, JIRAError, Project # noqa -from jira.resources import Resource, cls_for_resource # noqa +from jira.resources import Resource, cls_for_resource, Group, UnknownResource # noqa TEST_ROOT = os.path.dirname(__file__) TEST_ICON_PATH = os.path.join(TEST_ROOT, 'icon.png') @@ -82,7 +87,7 @@ def rndpassword(): - # generates a password of lengh 14 + # generates a password of length 14 s = ''.join(random.sample(string.ascii_uppercase, 5)) + \ ''.join(random.sample(string.ascii_lowercase, 5)) + \ ''.join(random.sample(string.digits, 2)) + \ @@ -97,10 +102,9 @@ def get_unique_project_name(): jid = "" user = re.sub("[^A-Z_]", "", getpass.getuser().upper()) - if user == 'TRAVIS' and 'TRAVIS_JOB_NUMBER' in os.environ: - # please note that user underline (_) is not suppored by - # jira even if is documented as supported. + # please note that user underline (_) is not supported by + # JIRA even if it is documented as supported. jid = 'T' + hashify(user + os.environ['TRAVIS_JOB_NUMBER']) else: identifier = user + \ @@ -110,18 +114,6 @@ return jid -class Singleton(type): - - def __init__(cls, name, bases, dict): - super(Singleton, cls).__init__(name, bases, dict) - cls.instance = None - - def __call__(cls, *args, **kw): - if cls.instance is None: - cls.instance = super(Singleton, cls).__call__(*args, **kw) - return cls.instance - - class JiraTestManager(object): """Used to instantiate and populate the JIRA instance with data used by the unit tests. @@ -131,19 +123,6 @@ max_retries (int): number of retries to perform for recoverable HTTP errors. """ - # __metaclass__ = Singleton - - # __instance = None - # - # Singleton implementation - # def __new__(cls, *args, **kwargs): - # if not cls.__instance: - # cls.__instance = super(JiraTestManager, cls).__new__( - # cls, *args, **kwargs) - # return cls.__instance - - # Implementing some kind of Singleton, to prevent test initialization - # http://stackoverflow.com/questions/31875/is-there-a-simple-elegant-way-to-define-singletons-in-python/33201#33201 __shared_state = {} @retry(stop=stop_after_attempt(2)) @@ -201,7 +180,6 @@ self.jira_admin = JIRA(self.CI_JIRA_URL, validate=True, logging=False, max_retries=self.max_retries) if self.jira_admin.current_user() != self.CI_JIRA_ADMIN: - # self.jira_admin. self.initialized = 1 sys.exit(3) @@ -305,21 +283,20 @@ try: self.jira_admin.create_project(self.project_a, - self.project_a_name) + self.project_a_name, + template_name='Scrum software development') except Exception: # we care only for the project to exist pass self.project_a_id = self.jira_admin.project(self.project_a).id - # except Exception as e: - # logging.warning("Got %s" % e) - # try: - # assert self.jira_admin.create_project(self.project_b, - # self.project_b_name) is True, "Failed to create %s" % - # self.project_b + self.jira_admin.create_project(self.project_b, + self.project_b_name, + template_name='Scrum software development') try: self.jira_admin.create_project(self.project_b, - self.project_b_name) + self.project_b_name, + template_name='Scrum software development') except Exception: # we care only for the project to exist pass @@ -440,7 +417,9 @@ self.assertEqual(cls_for_resource('http://imaginary-jira.com/rest/\ api/latest/project/IMG/role/10002'), Role) self.assertEqual(cls_for_resource('http://customized-jira.com/rest/\ - plugin-resource/4.5/json/getMyObject'), Resource) + plugin-resource/4.5/json/getMyObject'), UnknownResource) + self.assertEqual(cls_for_resource('http://customized-jira.com/rest/\ + group?groupname=bla'), Group) @flaky @@ -464,7 +443,7 @@ def test_set_application_property(self): prop = 'jira.lf.favicon.hires.url' valid_value = '/jira-favicon-hires.png' - invalid_value = '/Tjira-favicon-hires.png' + invalid_value = '/invalid-jira-favicon-hires.png' self.jira.set_application_property(prop, invalid_value) self.assertEqual(self.jira.application_properties(key=prop)['value'], @@ -494,7 +473,6 @@ self.assertTrue(meta['enabled']) self.assertEqual(meta['uploadLimit'], 10485760) - @unittest.skip("TBD: investigate failure") def test_1_add_remove_attachment(self): issue = self.jira.issue(self.issue_1) attachment = self.jira.add_attachment(issue, @@ -506,7 +484,8 @@ new_attachment.filename, 'new test attachment', msg=msg) self.assertEqual( new_attachment.size, os.path.getsize(TEST_ATTACH_PATH), msg=msg) - assert attachment.delete() is None + # JIRA returns a HTTP 204 upon successful deletion + self.assertEqual(attachment.delete().status_code, 204) @flaky @@ -575,6 +554,13 @@ component.delete() self.assertRaises(JIRAError, self.jira.component, myid) + def test_delete_component_by_id(self): + component = self.jira.create_component('To be deleted', + self.project_b, description='not long for this world') + myid = component.id + self.jira.delete_component(myid) + self.assertRaises(JIRAError, self.jira.component, myid) + @flaky class CustomFieldOptionTests(unittest.TestCase): @@ -722,7 +708,7 @@ self.assertTrue(issue1 == issues[0]) self.assertFalse(issue2 == issues[0]) - def test_issue_expandos(self): + def test_issue_expand(self): issue = self.jira.issue(self.issue_1, expand='editmeta,schema') self.assertTrue(hasattr(issue, 'editmeta')) self.assertTrue(hasattr(issue, 'schema')) @@ -732,10 +718,10 @@ @not_on_custom_jira_instance def test_create_issue_with_fieldargs(self): issue = self.jira.create_issue(project=self.project_b, - summary='Test issue created', description='blahery', + summary='Test issue created', description='foo description', issuetype={'name': 'Bug'}) # customfield_10022='XSS' self.assertEqual(issue.fields.summary, 'Test issue created') - self.assertEqual(issue.fields.description, 'blahery') + self.assertEqual(issue.fields.description, 'foo description') self.assertEqual(issue.fields.issuetype.name, 'Bug') self.assertEqual(issue.fields.project.key, self.project_b) # self.assertEqual(issue.fields.customfield_10022, 'XSS') @@ -768,7 +754,7 @@ issue = self.jira.create_issue(prefetch=False, project=self.project_b, summary='Test issue created', - description='blahery', issuetype={'name': 'Bug'} + description='some details', issuetype={'name': 'Bug'} ) # customfield_10022='XSS' assert hasattr(issue, 'self') @@ -787,8 +773,7 @@ 'name': 'Bug'}, # 'customfield_10022': 'XSS', 'priority': { - 'name': 'Major'}}, - { + 'name': 'Major'}}, { 'project': { 'key': self.project_a}, 'issuetype': { @@ -798,6 +783,8 @@ 'priority': { 'name': 'Major'}}] issues = self.jira.create_issues(field_list=field_list) + self.assertEqual(len(issues), 2) + self.assertIsNotNone(issues[0]['issue'], "the first issue has not been created") self.assertEqual(issues[0]['issue'].fields.summary, 'Issue created via bulk create #1') self.assertEqual(issues[0]['issue'].fields.description, @@ -805,6 +792,7 @@ self.assertEqual(issues[0]['issue'].fields.issuetype.name, 'Bug') self.assertEqual(issues[0]['issue'].fields.project.key, self.project_b) self.assertEqual(issues[0]['issue'].fields.priority.name, 'Major') + self.assertIsNotNone(issues[1]['issue'], "the second issue has not been created") self.assertEqual(issues[1]['issue'].fields.summary, 'Issue created via bulk create #2') self.assertEqual(issues[1]['issue'].fields.description, @@ -872,11 +860,11 @@ def test_create_issues_without_prefetch(self): field_list = [dict(project=self.project_b, summary='Test issue created', - description='blahery', + description='some details', issuetype={'name': 'Bug'}), dict(project=self.project_a, summary='Test issue #2', - description='fooery', + description='foo description', issuetype={'name': 'Bug'})] issues = self.jira.create_issues(field_list, prefetch=False) @@ -897,10 +885,10 @@ issuetype={'name': 'Bug'}) # customfield_10022='XSS') issue.update(summary='Updated summary', description='Now updated', - issuetype={'name': 'Improvement'}) + issuetype={'name': 'Story'}) self.assertEqual(issue.fields.summary, 'Updated summary') self.assertEqual(issue.fields.description, 'Now updated') - self.assertEqual(issue.fields.issuetype.name, 'Improvement') + self.assertEqual(issue.fields.issuetype.name, 'Story') # self.assertEqual(issue.fields.customfield_10022, 'XSS') self.assertEqual(issue.fields.project.key, self.project_b) issue.delete() @@ -914,14 +902,14 @@ 'summary': 'Issue is updated', 'description': "it sure is", 'issuetype': { - 'name': 'Improvement'}, + 'name': 'Story'}, # 'customfield_10022': 'DOC', 'priority': { 'name': 'Major'}} issue.update(fields=fields) self.assertEqual(issue.fields.summary, 'Issue is updated') self.assertEqual(issue.fields.description, 'it sure is') - self.assertEqual(issue.fields.issuetype.name, 'Improvement') + self.assertEqual(issue.fields.issuetype.name, 'Story') # self.assertEqual(issue.fields.customfield_10022, 'DOC') self.assertEqual(issue.fields.priority.name, 'Major') issue.delete() @@ -972,9 +960,9 @@ @not_on_custom_jira_instance def test_createmeta(self): meta = self.jira.createmeta() - ztravisdeb_proj = find_by_key(meta['projects'], self.project_b) + proj = find_by_key(meta['projects'], self.project_b) # we assume that this project should allow at least one issue type - self.assertGreaterEqual(len(ztravisdeb_proj['issuetypes']), 1) + self.assertGreaterEqual(len(proj['issuetypes']), 1) @not_on_custom_jira_instance def test_createmeta_filter_by_projectkey_and_name(self): @@ -986,7 +974,7 @@ @not_on_custom_jira_instance def test_createmeta_filter_by_projectkeys_and_name(self): meta = self.jira.createmeta(projectKeys=(self.project_a, - self.project_b), issuetypeNames='Improvement') + self.project_b), issuetypeNames='Story') self.assertEqual(len(meta['projects']), 2) for project in meta['projects']: self.assertEqual(len(project['issuetypes']), 1) @@ -996,13 +984,31 @@ projects = self.jira.projects() proja = find_by_key_value(projects, self.project_a) projb = find_by_key_value(projects, self.project_b) + issue_type_ids = dict() + full_meta = self.jira.createmeta(projectIds=(proja.id, projb.id)) + for project in full_meta['projects']: + for issue_t in project['issuetypes']: + issue_t_id = issue_t['id'] + val = issue_type_ids.get(issue_t_id) + if val is None: + issue_type_ids[issue_t_id] = [] + issue_type_ids[issue_t_id].append([project['id']]) + common_issue_ids = [] + for key, val in issue_type_ids.items(): + if len(val) == 2: + common_issue_ids.append(key) + self.assertNotEqual(len(common_issue_ids), 0) + for_lookup_common_issue_ids = common_issue_ids + if len(common_issue_ids) > 2: + for_lookup_common_issue_ids = common_issue_ids[:-1] meta = self.jira.createmeta(projectIds=(proja.id, projb.id), - issuetypeIds=('3', '4', '5')) + issuetypeIds=for_lookup_common_issue_ids) self.assertEqual(len(meta['projects']), 2) for project in meta['projects']: - self.assertEqual(len(project['issuetypes']), 3) + self.assertEqual(len(project['issuetypes']), + len(for_lookup_common_issue_ids)) - def test_createmeta_expando(self): + def test_createmeta_expand(self): # limit to SCR project so the call returns promptly meta = self.jira.createmeta(projectKeys=self.project_b, expand='projects.issuetypes.fields') @@ -1063,20 +1069,23 @@ comment.delete() def test_editmeta(self): + expected_fields = {'assignee', + 'attachment', + 'comment', + 'components', + 'description', + 'environment', + 'fixVersions', + 'issuelinks', + 'labels', + 'summary', + 'versions' + } for i in (self.issue_1, self.issue_2): meta = self.jira.editmeta(i) - self.assertTrue('assignee' in meta['fields']) - self.assertTrue('attachment' in meta['fields']) - self.assertTrue('comment' in meta['fields']) - self.assertTrue('components' in meta['fields']) - self.assertTrue('description' in meta['fields']) - self.assertTrue('duedate' in meta['fields']) - self.assertTrue('environment' in meta['fields']) - self.assertTrue('fixVersions' in meta['fields']) - self.assertTrue('issuelinks' in meta['fields']) - self.assertTrue('issuetype' in meta['fields']) - self.assertTrue('labels' in meta['fields']) - self.assertTrue('versions' in meta['fields']) + meta_field_set = set(meta['fields'].keys()) + self.assertEqual(meta_field_set.intersection(expected_fields), + expected_fields) # Nothing from remote link works # def test_remote_links(self): @@ -1146,12 +1155,12 @@ # application={'name': 'far too silly', 'type': 'sketch'}, relationship='mousebending') # creation response doesn't include full remote link info, so we fetch it again using the new internal ID # link = self.jira.remote_link('BULK-3', link.id) - # link.update(object={'url': 'http://yahoo.com', 'title': 'yahooery'}, globalId='python-test:updated.id', + # link.update(object={'url': 'http://yahoo.com', 'title': 'yahoo stuff'}, globalId='python-test:updated.id', # relationship='cheesing') # self.assertEqual(link.globalId, 'python-test:updated.id') # self.assertEqual(link.relationship, 'cheesing') # self.assertEqual(link.object.url, 'http://yahoo.com') - # self.assertEqual(link.object.title, 'yahooery') + # self.assertEqual(link.object.title, 'yahoo stuff') # link.delete() # # @unittest.skip("temporary disabled") @@ -1359,7 +1368,7 @@ JiraTestManager().project_b_issue1, JiraTestManager().project_b_issue2) - def test_create_issue_link_with_issue_objs(self): + def test_create_issue_link_with_issue_obj(self): inwardissue = self.manager.jira_admin.issue( JiraTestManager().project_b_issue1) self.assertIsNotNone(inwardissue) @@ -1596,6 +1605,18 @@ for issue in issues: self.assertTrue(issue.key.startswith(self.project_b)) + def test_search_issues_async(self): + original_val = self.jira._options['async'] + try: + self.jira._options['async'] = True + issues = self.jira.search_issues('project=%s' % self.project_b, + maxResults=False) + self.assertEqual(len(issues), issues.total) + for issue in issues: + self.assertTrue(issue.key.startswith(self.project_b)) + finally: + self.jira._options['async'] = original_val + def test_search_issues_maxresults(self): issues = self.jira.search_issues('project=%s' % self.project_b, maxResults=10) @@ -1615,7 +1636,7 @@ self.assertFalse(hasattr(issues[0].fields, 'reporter')) self.assertFalse(hasattr(issues[0].fields, 'progress')) - def test_search_issues_expandos(self): + def test_search_issues_expand(self): issues = self.jira.search_issues('key=%s' % self.issue, expand='changelog') # self.assertTrue(hasattr(issues[0], 'names')) @@ -1686,7 +1707,7 @@ def test_user(self): user = self.jira.user(self.test_manager.CI_JIRA_ADMIN) self.assertEqual(user.name, self.test_manager.CI_JIRA_ADMIN) - self.assertRegex(user.emailAddress, '.*@example.com') + self.assertRegex(user.emailAddress, '^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$') @pytest.mark.xfail(reason='query returns empty list') def test_search_assignable_users_for_projects(self): @@ -1900,23 +1921,20 @@ version.delete() self.assertRaises(JIRAError, self.jira.version, version.id) - # def test_version_expandos(self): - # pass - @flaky class OtherTests(unittest.TestCase): def test_session_invalid_login(self): try: - JIRA('https://support.atlassian.com', + JIRA('https://jira.atlassian.com', basic_auth=("xxx", "xxx"), validate=True, logging=False) except Exception as e: self.assertIsInstance(e, JIRAError) # 20161010: jira cloud returns 500 - assert e.status_code in (401, 500) + assert e.status_code in (401, 500, 403) str(JIRAError) # to see that this does not raise an exception return assert False @@ -1933,7 +1951,7 @@ self.assertIsNotNone(user.raw['session']) def test_session_with_no_logged_in_user_raises(self): - anon_jira = JIRA('https://support.atlassian.com', logging=False) + anon_jira = JIRA('https://jira.atlassian.com', logging=False) self.assertRaises(JIRAError, anon_jira.session) # @pytest.mark.skipif(platform.python_version() < '3', reason='Does not work with Python 2') @@ -1947,6 +1965,64 @@ self.assertTrue(False, "Instantiation of invalid JIRA instance succeeded.") +class AsyncTests(unittest.TestCase): + + def setUp(self): + self.jira = JIRA('https://jira.atlassian.com', logging=False, + async_=True, validate=False, get_server_info=False) + + def test_fetch_pages(self): + """Tests that the JIRA._fetch_pages method works as expected. """ + params = {"startAt": 0} + total = 26 + expected_results = [] + for i in range(0, total): + result = _create_issue_result_json( + i, 'summary %s' % i, key='KEY-%s' % i) + expected_results.append(result) + result_one = _create_issue_search_results_json( + expected_results[:10], max_results=10, total=total) + result_two = _create_issue_search_results_json( + expected_results[10:20], max_results=10, total=total) + result_three = _create_issue_search_results_json( + expected_results[20:], max_results=6, total=total) + mock_session = mock.Mock(name='mock_session') + responses = mock.Mock(name='responses') + responses.content = '_filler_' + responses.json.side_effect = [result_one, result_two, result_three] + responses.status_code = 200 + mock_session.request.return_value = responses + mock_session.get.return_value = responses + self.jira._session.close() + self.jira._session = mock_session + items = self.jira._fetch_pages(Issue, 'issues', 'search', 0, False, params) + self.assertEqual(len(items), total) + self.assertEqual( + set(item.key for item in items), + set(expected_r['key'] for expected_r in expected_results)) + + +def _create_issue_result_json(issue_id, summary, key, **kwargs): + """Returns a minimal json object for an issue. """ + return { + 'id': '%s' % issue_id, + 'summary': summary, + 'key': key, + 'self': kwargs.get('self', 'http://example.com/%s' % + issue_id), + } + + +def _create_issue_search_results_json(issues, **kwargs): + """Returns a minimal json object for Jira issue search results. """ + return { + 'startAt': kwargs.get('start_at', 0), + 'maxResults': kwargs.get('max_results', 50), + 'total': kwargs.get('total', len(issues)), + 'issues': issues, + } + + @flaky class WebsudoTests(unittest.TestCase): @@ -1971,8 +2047,19 @@ self.test_password = rndpassword() self.test_groupname = 'testGroupFor_%s' % self.test_manager.project_a - def test_add_and_remove_user(self): + def _skip_pycontribs_instance(self): + pytest.skip('The current ci jira admin user for ' + 'https://pycontribs.atlassian.net lacks ' + 'permission to modify users.') + + def _should_skip_for_pycontribs_instance(self): + return self.test_manager.CI_JIRA_ADMIN == 'ci-admin' and ( + self.test_manager.CI_JIRA_URL == + "https://pycontribs.atlassian.net") + def test_add_and_remove_user(self): + if self._should_skip_for_pycontribs_instance(): + self._skip_pycontribs_instance() try: self.jira.delete_user(self.test_username) except JIRAError: @@ -2004,8 +2091,19 @@ self.assertEqual( len(x), 0, "Found test user when it should have been deleted. Test Fails.") + # test creating users with no application access (used for Service Desk) + result = self.jira.add_user( + self.test_username, self.test_email, password=self.test_password, + application_keys=['jira-software']) + assert result, True + + result = self.jira.delete_user(self.test_username) + assert result, True + @flaky def test_add_group(self): + if self._should_skip_for_pycontribs_instance(): + self._skip_pycontribs_instance() try: self.jira.remove_group(self.test_groupname) except JIRAError: @@ -2021,6 +2119,8 @@ self.jira.remove_group(self.test_groupname) def test_remove_group(self): + if self._should_skip_for_pycontribs_instance(): + self._skip_pycontribs_instance() try: self.jira.add_group(self.test_groupname) sleep(1) # avoid 400: https://travis-ci.org/pycontribs/jira/jobs/176539521#L395 @@ -2067,6 +2167,8 @@ self.jira.delete_user(self.test_username) def test_remove_user_from_group(self): + if self._should_skip_for_pycontribs_instance(): + self._skip_pycontribs_instance() try: self.jira.add_user( self.test_username, self.test_email, password=self.test_password) @@ -2104,6 +2206,36 @@ self.assertEqual(result, 0) +class JiraServiceDeskTests(unittest.TestCase): + + def setUp(self): + self.jira = JiraTestManager().jira_admin + self.test_manager = JiraTestManager() + + def test_create_customer_request(self): + if not self.jira.supports_service_desk(): + pytest.skip('Skipping Service Desk not enabled') + + try: + self.jira.create_project('TESTSD', template_name='IT Service Desk') + except JIRAError: + pass + service_desk = self.jira.service_desks()[0] + request_type = self.jira.request_types(service_desk)[0] + + request = self.jira.create_customer_request(dict( + serviceDeskId=service_desk.id, + requestTypeId=int(request_type.id), + requestFieldValues=dict( + summary='Ticket title here', + description='Ticket body here' + ) + )) + + self.assertEqual(request.fields.summary, 'Ticket title here') + self.assertEqual(request.fields.description, 'Ticket body here') + + if __name__ == '__main__': # when running tests we expect various errors and we don't want to display them by default diff -Nru python-jira-1.0.10/tox.ini python-jira-2.0.0/tox.ini --- python-jira-1.0.10/tox.ini 2017-02-10 19:26:23.000000000 +0000 +++ python-jira-2.0.0/tox.ini 2018-07-12 17:11:24.000000000 +0000 @@ -1,38 +1,47 @@ [tox] minversion = 2.3.1 -envlist = {py27,py34,py35,py36}-{win,linux,darwin},docs +envlist = lint,py27,py37,py36,py35,py34,docs skip_missing_interpreters = true [testenv:docs] basepython=python changedir=docs -deps= +deps = -rrequirements.txt - -rrequirements-dev.txt + -rdocs/requirements-rtd.txt commands= - sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html + bash -c 'cd {toxinidir}/docs && make html' [testenv] -sitepackages=False -platform = - win: windows - linux: linux - darwin: darwin - -deps= +usedevelop = True +deps = -rrequirements.txt -rrequirements-dev.txt - -rrequirements-opt.txt - +extras = + cli + opt +sitepackages=False commands= + bash -c 'find . | grep -E "(__pycache__|\.pyc|\.pyo$)" | xargs rm -rf' python -m pip check - python -m flake8 - python -m pytest -# removed -n4 due to fixture failure -n4 -setenv = - PYTHONPATH = + python -m pytest {posargs} passenv = + CI CI_JIRA_* + PIP_* + TRAVIS + TRAVIS_* + XDG_CACHE_HOME +envars = + PIP_DISABLE_PIP_VERSION_CHECK=1 + PIP_USER=no +whitelist_externals = + bash + find + grep + rm + xargs -[travis:after] -toxenv = py27 +[testenv:lint] +commands= + python -m pre_commit run --all diff -Nru python-jira-1.0.10/.travis.yml python-jira-2.0.0/.travis.yml --- python-jira-1.0.10/.travis.yml 2017-02-10 19:26:23.000000000 +0000 +++ python-jira-2.0.0/.travis.yml 2018-07-12 17:11:24.000000000 +0000 @@ -1,70 +1,106 @@ +--- language: python -sudo: false -matrix: - fast_finish: false +cache: + - pip + - directories: + - "node_modules" + - $HOME/.cache os: -- linux -python: -- '2.7' -- '3.4' -- '3.5' -- '3.6' -install: -- pip -q --log dist/pip.log install --upgrade pip setuptools tox-travis py wheel -- python setup.py sdist bdist_wheel install -- pip install ./dist/jira-*.whl -- pip --version -script: -- export PACKAGE_NAME=$(python setup.py --name) -- export PACKAGE_VERSION=$(python setup.py --version) -- python setup.py --version -- tox --installpkg ./dist/jira-*.whl --travis-after -# validates that the build source distribution is installable using the old easy_install -- pip uninstall -y jira && easy_install ./dist/jira-*.tar.gz -after_success: -- coveralls -- bash <(curl -s https://codecov.io/bash) -- requires.io update-site -t ac3bbcca32ae03237a6aae2b02eb9411045489bb -r + - linux +stages: + - phase1 + - phase2 + - deploy +before_install: + # begin: workaround to enable support for py37: + # - curl -sL https://deb.nodesource.com/setup_8.x | sudo -E bash - + # - sudo apt-get install -y nodejs + # - curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add - + # - echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list + # - sudo apt-get update && sudo apt-get install yarn + # - if [[ $TRAVIS_PYTHON_VERSION == '3.7-dev' ]]; then sudo add-apt-repository ppa:deadsnakes/ppa -y; fi + # - if [[ $TRAVIS_PYTHON_VERSION == '3.7-dev' ]]; then sudo apt-get update; fi + # sudo required only till https://github.com/travis-ci/travis-ci/issues/9848 is fixed + # - sudo pip install -U pip setuptools twine wheel + # ^ end workaround + - nvm install $TRAVIS_NODE_VERSION + # end + - which tox >/dev/null || if [ -z ${VIRTUAL_ENV+x} ]; then python -m pip install --user tox tox-pyenv ; else python -m pip install tox tox-pyenv twine; fi notifications: email: - - pycontribs@googlegroups.com - - sorin.sbarnea@gmail.com -deploy: -- provider: releases - api_key: - secure: gr9iOcQjdoAyUAim6FWKzJI9MBaJo9XKfGQGu7wdPXUFhg80Rp6GLJsowP+aU94NjXM1UQlVHDAy627WtjBlLH2SvmVEIIr7+UKBopBYuXG5jJ1m3wOZE+4f1Pqe9bqFc1DxgucqE8qF0sC24fIbNM2ToeyYrxrS6RoL2gRrX2I= - file: - - dist/$PACKAGE_NAME-$PACKAGE_VERSION* - - ChangeLog - skip_cleanup: true - on: - tags: false - python: 2.7 - condition: $TOXENV != docs -- provider: pypi - user: sorin - password: - secure: E0cjANF7SLBdYrsnWLK8X/xWznqkF0JrP/DVfDazPzUYH6ynFeneyofzNJQPLTLsqe1eKXhuUJ/Sbl+RHFB0ySo/j/7NfYd/9pm8hpUkGCvR09IwtvMLgWKp3k10NWab03o2GOkSJSrLvZofyZBGR40wwu2O9uXPCb2rvucCGbw= - distributions: sdist bdist_wheel - skip_cleanup: true - on: - tags: true - python: 2.7 - condition: $TOXENV != docs - branch: master -- provider: pypi - server: https://testpypi.python.org/pypi - user: sorins - password: - secure: E0cjANF7SLBdYrsnWLK8X/xWznqkF0JrP/DVfDazPzUYH6ynFeneyofzNJQPLTLsqe1eKXhuUJ/Sbl+RHFB0ySo/j/7NfYd/9pm8hpUkGCvR09IwtvMLgWKp3k10NWab03o2GOkSJSrLvZofyZBGR40wwu2O9uXPCb2rvucCGbw= - distributions: sdist bdist_wheel - skip_cleanup: true - on: - tags: false - python: 2.7 - condition: $TOXENV != docs - branch: develop + - pycontribs@googlegroups.com +jobs: + include: + - stage: phase1 + script: + # package building added here purely to fail-fast if is broken + - python setup.py sdist bdist_wheel + - python -m tox + - npm install && npm run spell + env: TOXENV=lint + python: "2.7" + language: nodejs + node_js: + - "8" + - stage: phase1 + script: python -m tox + python: "2.7" + env: TOXENV=docs + - stage: phase1 + script: python -m tox + python: "2.7" + env: TOXENV=py27 + after_success: + - bash <(curl -s https://codecov.io/bash) -e TOX_ENV + - requires.io update-site -t ac3bbcca32ae03237a6aae2b02eb9411045489bb -r + - stage: phase2 + script: python -m tox + python: "3.4" + env: TOXENV=py34 + after_success: + - bash <(curl -s https://codecov.io/bash) -e TOX_ENV + - stage: phase2 + script: python -m tox + python: "3.5" + env: TOXENV=py35 + after_success: + - bash <(curl -s https://codecov.io/bash) -e TOX_ENV + - stage: phase2 + script: python -m tox + python: "3.6" + env: TOXENV=py36 PYTHON='3.6' PYENV_VERSION='system' + after_success: + - bash <(curl -s https://codecov.io/bash) -e TOX_ENV + - stage: phase2 + script: python -m pip install -q tox-travis && python -m tox + python: "3.7" + env: TOXENV=py37 PYTHON='3.7' + after_success: + - bash <(curl -s https://codecov.io/bash) -e TOX_ENV + # begin: workaround to enable support for py37: https://github.com/travis-ci/travis-ci/issues/9815 + dist: xenial + sudo: required + # end + - stage: deploy + script: + - python setup.py sdist bdist_wheel + - python -m twine upload dist/* + if: tag IS present + deploy: + - provider: releases + api_key: + secure: YJGigSNYOzMJqs23gIZLFxiVYRqHdV4WsTZmRVosishD2QIaDlTwJma7k6Y5eMPVNdLpqo7Tq6bt7xkJAz/dcr3UO35T/Y0tiRFFW3sd6IOB6ELwSwPhSeHoyUMvZtKyDTl+9tOfeZusFZuCc+mBLQcG+S2NzEaeyrQ6n5hTT/8FGBP91FOq9l5q2gYbmACZ9MisDIjZkTHNYih36ComnZ9QHC91jHKcSuHmOfWWX3GneDVFtuPhF2vjaLQrz8IFtWGW5Sfe35yDYlVQRH+NFxzSJ2zDuT5j8cRgwXjGout78umtMsqAn+zv1Ws/MUNKMTEtONsACndMpGCkuB6Nifl/KcGj5kD7V4PO/gE0ecr830qAwJxSVB7xk6rl797nMxGbr4w2DWQ/iDdHDTlPAEzbLBMLrMRgPxzKPgg5CNxxjT1cHoBNcFPp6gaf017w4XOVUgp/zxXeCg7iGiNJj7z2t8/m9eYVNNlNRPcodN6BjSjPqkYxC3ZMVCI5KsRXbHmR0zOWbPdcRjrY/IgbiTqX09sHotHw5GThP6YTMbienC4h93cdx6MEfX656W6XMOxpC+MjWtYuV8QlfMEJFlstOnA86MVLcmbl+4A6FHuvlQMdDtP9KsKdKIf/4juGhNEFir32P1rUe8J1abmjwXmDkHVbli0SDqaFtB5gyCc= + file_glob: true + file: + - dist/* + - ChangeLog + skip_cleanup: true + on: + tags: true + repo: pycontribs/jira + branch: master env: global: - - secure: fuXwQL+KHQ96XkAFl2uQc8eK8dAjrgkup46tck/UGjVpdv1PT/yHmBKrvpFjDa50ueGbtBwTdKAwhyAmYuiZCk2IYHzdvBylCZBBji2FSpaTM59CVwgkVT6tx3HHO83X0mEX6ih9TJvZD5XhX+YUjopnseRXRq3ey3JZJXWN4RM= - - secure: "pGQGM5YmHvOgaKihOyzb3k6bdqLQnZQ2OXO9QrfXlXwtop3zvZQi80Q+01l230x2psDWlwvqWTknAjAt1w463fYXPwpoSvKVCsLSSbjrf2l56nrDqnoir+n0CBy288+eIdaGEfzcxDiuULeKjlg08zrqjcjLjW0bDbBrlTXsb5U=" + - secure: "pGQGM5YmHvOgaKihOyzb3k6bdqLQnZQ2OXO9QrfXlXwtop3zvZQi80Q+01l230x2psDWlwvqWTknAjAt1w463fYXPwpoSvKVCsLSSbjrf2l56nrDqnoir+n0CBy288+eIdaGEfzcxDiuULeKjlg08zrqjcjLjW0bDbBrlTXsb5U=" + - PIP_DISABLE_PIP_VERSION_CHECK=1 + - TRAVIS_NODE_VERSION="v8.11.3" diff -Nru python-jira-1.0.10/.yamllint python-jira-2.0.0/.yamllint --- python-jira-1.0.10/.yamllint 1970-01-01 00:00:00.000000000 +0000 +++ python-jira-2.0.0/.yamllint 2018-07-12 17:11:24.000000000 +0000 @@ -0,0 +1,31 @@ +--- +extends: default + +rules: + braces: {max-spaces-inside: 1, level: error} + brackets: {max-spaces-inside: 1, level: error} + colons: {max-spaces-after: -1, level: error} + commas: {max-spaces-after: -1, level: error} + comments: disable + comments-indentation: disable + document-start: disable + empty-lines: {max: 3, level: error} + hyphens: {level: error} + indentation: + indent-sequences: consistent + # spaces: consistent + ignore: | + /jobs/DFG + # TODO: slowly fix reduce ignore pattern while fixing the errors + key-duplicates: enable + line-length: + max: 270 + allow-non-breakable-words: true + allow-non-breakable-inline-mappings: true + new-line-at-end-of-file: disable + new-lines: {type: unix} + trailing-spaces: disable + truthy: disable + +ignore: + .tox