├── requirements.txt ├── MANIFEST.in ├── .gitignore ├── .mailmap ├── .github └── dependabot.yml ├── .travis.yml ├── Pipfile ├── setup.cfg ├── description.rst ├── LICENSE ├── setup.py ├── Makefile ├── pygerrit2 ├── rest │ ├── auth.py │ └── __init__.py └── __init__.py ├── example.py ├── README.md ├── livetests.py ├── unittests.py └── Pipfile.lock /requirements.txt: -------------------------------------------------------------------------------- 1 | pbr>=0.8.0 2 | requests>=2.20.0 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md LICENSE requirements.txt 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .project 3 | .pydevproject 4 | .pytest_cache 5 | .settings 6 | .idea 7 | .eggs 8 | build/ 9 | dist/ 10 | pygerrit2.egg-info/ 11 | ChangeLog 12 | AUTHORS 13 | MANIFEST 14 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Dependabot 2 | Dependabot <27856297+dependabot-preview[bot]@users.noreply.github.com> 3 | Dependabot 4 | Ernst Sjöstrand 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "09:00" 8 | open-pull-requests-limit: 10 9 | ignore: 10 | - dependency-name: pep257 11 | versions: 12 | - "< 1, >= 0.a" 13 | - dependency-name: importlib-metadata 14 | versions: 15 | - 3.6.0 16 | - 3.7.2 17 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | dist: trusty 3 | 4 | services: 5 | - docker 6 | 7 | language: python 8 | matrix: 9 | include: 10 | - python: 3.6 11 | dist: trusty 12 | sudo: false 13 | - python: 3.7 14 | dist: xenial 15 | sudo: true 16 | - python: 3.8 17 | dist: bionic 18 | sudo: true 19 | 20 | install: 21 | - pip install pipenv 22 | 23 | script: 24 | - make test 25 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | e1839a8 = { path = ".", editable = true } 8 | requests = "*" 9 | typing_extensions = "*" 10 | 11 | [dev-packages] 12 | e1839a8 = { path = ".", editable = true } 13 | black = { version = "==23.7.0", markers = "python_version >= '3.6.2'" } 14 | flake8 = "*" 15 | importlib_metadata = { version = "==6.8.0", markers = "python_version >= '3.6.0'" } 16 | mock = "*" 17 | pbr = "*" 18 | pydocstyle = "*" 19 | pyflakes = "*" 20 | pytest = "*" 21 | six = "*" 22 | testcontainers = "*" 23 | 24 | [pipenv] 25 | allow_prereleases = true 26 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = pygerrit2 3 | summary = Client library for interacting with Gerrit's REST API 4 | author = David Pursehouse 5 | author_email = david.pursehouse@gmail.com 6 | home_page = https://github.com/dpursehouse/pygerrit2 7 | license = The MIT License 8 | description_file = description.rst 9 | keywords = gerrit rest http api client 10 | platform = POSIX, Unix, MacOS 11 | classifiers = 12 | Development Status :: 5 - Production/Stable 13 | Environment :: Console 14 | Intended Audience :: Developers 15 | Intended Audience :: Information Technology 16 | License :: OSI Approved :: MIT License 17 | Natural Language :: English 18 | Programming Language :: Python 19 | Programming Language :: Python :: 3.6 20 | Programming Language :: Python :: 3.7 21 | Programming Language :: Python :: 3.8 22 | Operating System :: POSIX 23 | Operating System :: Unix 24 | Operating System :: MacOS 25 | Topic :: Software Development :: Libraries :: Python Modules 26 | -------------------------------------------------------------------------------- /description.rst: -------------------------------------------------------------------------------- 1 | Pygerrit2 - Client library for interacting with Gerrit Code Review's REST API 2 | ============================================================================= 3 | 4 | .. image:: https://img.shields.io/pypi/v/pygerrit2.svg 5 | 6 | .. image:: https://img.shields.io/pypi/l/pygerrit2.svg 7 | 8 | .. image:: https://travis-ci.org/dpursehouse/pygerrit2.svg?branch=master 9 | 10 | .. image:: https://api.dependabot.com/badges/status?host=github&repo=dpursehouse/pygerrit2 11 | 12 | .. image:: https://img.shields.io/badge/code%20style-black-000000.svg 13 | 14 | Pygerrit2 provides a simple interface for clients to interact with 15 | `Gerrit Code Review`_ via the REST API. 16 | 17 | Copyright and License 18 | --------------------- 19 | 20 | Copyright 2011 Sony Ericsson Mobile Communications. All rights reserved. 21 | 22 | Copyright 2012 Sony Mobile Communications. All rights reserved. 23 | 24 | Copyright 2016 David Pursehouse. All rights reserved. 25 | 26 | Licensed under The MIT License. Please refer to the `LICENSE`_ file for full 27 | license details. 28 | 29 | .. _`Gerrit Code Review`: https://gerritcodereview.com/ 30 | .. _LICENSE: https://github.com/dpursehouse/pygerrit2/blob/master/LICENSE 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright 2011 Sony Ericsson Mobile Communications. All rights reserved. 4 | Copyright 2012 Sony Mobile Communications. All rights reserved. 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # The MIT License 5 | # 6 | # Copyright 2012 Sony Mobile Communications. All rights reserved. 7 | # 8 | # Permission is hereby granted, free of charge, to any person obtaining a copy 9 | # of this software and associated documentation files (the "Software"), to deal 10 | # in the Software without restriction, including without limitation the rights 11 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | # copies of the Software, and to permit persons to whom the Software is 13 | # furnished to do so, subject to the following conditions: 14 | # 15 | # The above copyright notice and this permission notice shall be included in 16 | # all copies or substantial portions of the Software. 17 | # 18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 24 | # THE SOFTWARE. 25 | 26 | """Client library for interacting with Gerrit.""" 27 | 28 | import setuptools 29 | 30 | 31 | def _main(): 32 | setuptools.setup( 33 | packages=setuptools.find_packages(), setup_requires=["pbr"], pbr=True 34 | ) 35 | 36 | 37 | if __name__ == "__main__": 38 | _main() 39 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | # 3 | # Copyright 2013 Sony Mobile Communications. All rights reserved. 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in 13 | # all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | # THE SOFTWARE. 22 | 23 | FILES := $(shell git ls-files | grep py$$) 24 | 25 | test: clean pyflakes pep8 pydocstyle unittests livetests 26 | 27 | sdist: test 28 | pipenv run python setup.py sdist 29 | 30 | pydocstyle: testenvsetup 31 | pipenv run pydocstyle $(FILES) 32 | 33 | pep8: testenvsetup 34 | pipenv run flake8 $(FILES) --max-line-length 88 35 | 36 | pyflakes: testenvsetup 37 | pipenv run pyflakes $(FILES) 38 | 39 | black-check: testenvsetup 40 | pipenv run black --check $(FILES) 41 | 42 | black-format: testenvsetup 43 | pipenv run black $(FILES) 44 | 45 | unittests: testenvsetup 46 | pipenv run pytest -sv unittests.py 47 | 48 | livetests: testenvsetup 49 | pipenv run pytest -sv livetests.py 50 | 51 | testenvsetup: 52 | pipenv install --dev 53 | 54 | clean: 55 | @find . -type f -name "*.pyc" -exec rm -f {} \; 56 | @rm -rf pygerrit2env pygerrit2.egg-info build dist 57 | -------------------------------------------------------------------------------- /pygerrit2/rest/auth.py: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | # 3 | # Copyright 2013 Sony Mobile Communications. All rights reserved. 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in 13 | # all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | # THE SOFTWARE. 22 | 23 | """Authentication handlers.""" 24 | 25 | from requests.auth import HTTPDigestAuth, HTTPBasicAuth 26 | from requests.utils import get_netrc_auth 27 | 28 | 29 | def _get_netrc_auth(url): 30 | return get_netrc_auth(url) 31 | 32 | 33 | class HTTPDigestAuthFromNetrc(HTTPDigestAuth): 34 | """HTTP Digest Auth with netrc credentials.""" 35 | 36 | def __init__(self, url): 37 | """See class docstring.""" 38 | auth = _get_netrc_auth(url) 39 | if not auth: 40 | raise ValueError("netrc missing or no credentials found in netrc") 41 | username, password = auth 42 | super(HTTPDigestAuthFromNetrc, self).__init__(username, password) 43 | 44 | 45 | class HTTPBasicAuthFromNetrc(HTTPBasicAuth): 46 | """HTTP Basic Auth with netrc credentials.""" 47 | 48 | def __init__(self, url): 49 | """See class docstring.""" 50 | auth = _get_netrc_auth(url) 51 | if not auth: 52 | raise ValueError("netrc missing or no credentials found in netrc") 53 | username, password = auth 54 | super(HTTPBasicAuthFromNetrc, self).__init__(username, password) 55 | 56 | 57 | class Anonymous: 58 | """No authentication; i.e. anonymous access.""" 59 | 60 | pass 61 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # The MIT License 5 | # 6 | # Copyright 2013 Sony Mobile Communications. All rights reserved. 7 | # 8 | # Permission is hereby granted, free of charge, to any person obtaining a copy 9 | # of this software and associated documentation files (the "Software"), to deal 10 | # in the Software without restriction, including without limitation the rights 11 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | # copies of the Software, and to permit persons to whom the Software is 13 | # furnished to do so, subject to the following conditions: 14 | # 15 | # The above copyright notice and this permission notice shall be included in 16 | # all copies or substantial portions of the Software. 17 | # 18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 24 | # THE SOFTWARE. 25 | 26 | """Example of using the Gerrit client REST API.""" 27 | 28 | import argparse 29 | import logging 30 | import sys 31 | 32 | from requests.exceptions import RequestException 33 | 34 | try: 35 | from requests_kerberos import HTTPKerberosAuth, OPTIONAL 36 | 37 | _KERBEROS_SUPPORT = True 38 | except ImportError: 39 | _KERBEROS_SUPPORT = False 40 | 41 | from pygerrit2 import GerritRestAPI 42 | from pygerrit2 import HTTPDigestAuthFromNetrc, HTTPBasicAuthFromNetrc 43 | from pygerrit2 import HTTPBasicAuth, HTTPDigestAuth 44 | 45 | 46 | def _main(): 47 | descr = "Send request using Gerrit HTTP API" 48 | parser = argparse.ArgumentParser( 49 | description=descr, formatter_class=argparse.ArgumentDefaultsHelpFormatter 50 | ) 51 | parser.add_argument( 52 | "-g", "--gerrit-url", dest="gerrit_url", required=True, help="gerrit server url" 53 | ) 54 | parser.add_argument( 55 | "-b", 56 | "--basic-auth", 57 | dest="basic_auth", 58 | action="store_true", 59 | help="(deprecated) use basic auth instead of digest", 60 | ) 61 | parser.add_argument( 62 | "-d", 63 | "--digest-auth", 64 | dest="digest_auth", 65 | action="store_true", 66 | help="use digest auth instead of basic", 67 | ) 68 | if _KERBEROS_SUPPORT: 69 | parser.add_argument( 70 | "-k", 71 | "--kerberos-auth", 72 | dest="kerberos_auth", 73 | action="store_true", 74 | help="use kerberos auth", 75 | ) 76 | parser.add_argument("-u", "--username", dest="username", help="username") 77 | parser.add_argument("-p", "--password", dest="password", help="password") 78 | parser.add_argument( 79 | "-n", 80 | "--netrc", 81 | dest="netrc", 82 | action="store_true", 83 | help="Use credentials from netrc", 84 | ) 85 | parser.add_argument( 86 | "-v", 87 | "--verbose", 88 | dest="verbose", 89 | action="store_true", 90 | help="enable verbose (debug) logging", 91 | ) 92 | 93 | options = parser.parse_args() 94 | 95 | level = logging.DEBUG if options.verbose else logging.INFO 96 | logging.basicConfig(format="%(asctime)s %(levelname)s %(message)s", level=level) 97 | 98 | if _KERBEROS_SUPPORT and options.kerberos_auth: 99 | if options.username or options.password or options.basic_auth or options.netrc: 100 | parser.error( 101 | "--kerberos-auth may not be used together with " 102 | "--username, --password, --basic-auth or --netrc" 103 | ) 104 | auth = HTTPKerberosAuth(mutual_authentication=OPTIONAL) 105 | elif options.username and options.password: 106 | if options.netrc: 107 | logging.warning("--netrc option ignored") 108 | if options.digest_auth: 109 | auth = HTTPDigestAuth(options.username, options.password) 110 | else: 111 | auth = HTTPBasicAuth(options.username, options.password) 112 | elif options.netrc: 113 | if options.digest_auth: 114 | auth = HTTPDigestAuthFromNetrc(url=options.gerrit_url) 115 | else: 116 | auth = HTTPBasicAuthFromNetrc(url=options.gerrit_url) 117 | else: 118 | auth = None 119 | 120 | rest = GerritRestAPI(url=options.gerrit_url, auth=auth) 121 | 122 | try: 123 | query = ["status:open"] 124 | if auth: 125 | query += ["owner:self"] 126 | else: 127 | query += ["limit:10"] 128 | changes = rest.get("/changes/?q=%s" % "%20".join(query)) 129 | logging.info("%d changes", len(changes)) 130 | for change in changes: 131 | logging.info(change["change_id"]) 132 | except RequestException as err: 133 | logging.error("Error: %s", str(err)) 134 | 135 | 136 | if __name__ == "__main__": 137 | sys.exit(_main()) 138 | -------------------------------------------------------------------------------- /pygerrit2/__init__.py: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | # 3 | # Copyright 2012 Sony Mobile Communications. All rights reserved. 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in 13 | # all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | # THE SOFTWARE. 22 | 23 | """Module to interface with Gerrit.""" 24 | 25 | from .rest import GerritRestAPI, GerritReview 26 | from requests.auth import HTTPBasicAuth, HTTPDigestAuth 27 | from .rest.auth import HTTPBasicAuthFromNetrc, HTTPDigestAuthFromNetrc, Anonymous 28 | 29 | __all__ = [ 30 | "Anonymous", 31 | "GerritRestAPI", 32 | "GerritReview", 33 | "HTTPBasicAuth", 34 | "HTTPDigestAuth", 35 | "HTTPBasicAuthFromNetrc", 36 | "HTTPDigestAuthFromNetrc", 37 | ] 38 | 39 | 40 | def from_json(json_data, key): 41 | """Extract values from JSON data. 42 | 43 | :arg dict json_data: The JSON data 44 | :arg str key: Key to get data for. 45 | 46 | :Returns: The value of `key` from `json_data`, or None if `json_data` 47 | does not contain `key`. 48 | 49 | """ 50 | if key in json_data: 51 | return json_data[key] 52 | return None 53 | 54 | 55 | def escape_string(string): 56 | """Escape a string for use in Gerrit commands. 57 | 58 | :arg str string: The string to escape. 59 | 60 | :returns: The string with necessary escapes and surrounding double quotes 61 | so that it can be passed to any of the Gerrit commands that require 62 | double-quoted strings. 63 | 64 | """ 65 | result = string 66 | result = result.replace("\\", "\\\\") 67 | result = result.replace('"', '\\"') 68 | return '"' + result + '"' 69 | 70 | 71 | class GerritReviewMessageFormatter(object): 72 | """Helper class to format review messages that are sent to Gerrit. 73 | 74 | :arg str header: (optional) If specified, will be prepended as the first 75 | paragraph of the output message. 76 | :arg str footer: (optional) If specified, will be appended as the last 77 | paragraph of the output message. 78 | 79 | """ 80 | 81 | def __init__(self, header=None, footer=None): 82 | """See class docstring.""" 83 | self.paragraphs = [] 84 | if header: 85 | self.header = header.strip() 86 | else: 87 | self.header = "" 88 | if footer: 89 | self.footer = footer.strip() 90 | else: 91 | self.footer = "" 92 | 93 | def append(self, data): 94 | """Append the given `data` to the output. 95 | 96 | :arg data: If a list, it is formatted as a bullet list with each 97 | entry in the list being a separate bullet. Otherwise if it is a 98 | string, the string is added as a paragraph. 99 | 100 | :raises: ValueError if `data` is not a list or a string. 101 | 102 | """ 103 | if not data: 104 | return 105 | 106 | if isinstance(data, list): 107 | # First we need to clean up the data. 108 | # 109 | # Gerrit creates new bullet items when it gets newline characters 110 | # within a bullet list paragraph, so unless we remove the newlines 111 | # from the texts the resulting bullet list will contain multiple 112 | # bullets and look crappy. 113 | # 114 | # We add the '*' character on the beginning of each bullet text in 115 | # the next step, so we strip off any existing leading '*' that the 116 | # caller has added, and then strip off any leading or trailing 117 | # whitespace. 118 | _items = [x.replace("\n", " ").strip().lstrip("*").strip() for x in data] 119 | 120 | # Create the bullet list only with the items that still have any 121 | # text in them after cleaning up. 122 | _paragraph = "\n".join(["* %s" % x for x in _items if x]) 123 | if _paragraph: 124 | self.paragraphs.append(_paragraph) 125 | elif isinstance(data, str): 126 | _paragraph = data.strip() 127 | if _paragraph: 128 | self.paragraphs.append(_paragraph) 129 | else: 130 | raise ValueError("Data must be a list or a string") 131 | 132 | def is_empty(self): 133 | """Check if the formatter is empty. 134 | 135 | :Returns: True if empty, i.e. no paragraphs have been added. 136 | 137 | """ 138 | return not self.paragraphs 139 | 140 | def format(self): 141 | """Format the message parts to a string. 142 | 143 | :Returns: A string of all the message parts separated into paragraphs, 144 | with header and footer paragraphs if they were specified in the 145 | constructor. 146 | 147 | """ 148 | message = "" 149 | if self.paragraphs: 150 | if self.header: 151 | message += self.header + "\n\n" 152 | message += "\n\n".join(self.paragraphs) 153 | if self.footer: 154 | message += "\n\n" + self.footer 155 | return message 156 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pygerrit2 - Client library for interacting with Gerrit Code Review's REST API 2 | 3 | ![Version](https://img.shields.io/pypi/v/pygerrit2.svg) 4 | ![License](https://img.shields.io/pypi/l/pygerrit2.svg) 5 | [![Build Status](https://travis-ci.org/dpursehouse/pygerrit2.svg?branch=master)](https://travis-ci.org/dpursehouse/pygerrit2) 6 | [![Dependabot Status](https://api.dependabot.com/badges/status?host=github&repo=dpursehouse/pygerrit2)](https://dependabot.com) 7 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 8 | 9 | Pygerrit2 provides a simple interface for clients to interact with 10 | [Gerrit Code Review][gerrit] via the REST API. It is based on [pygerrit][pygerrit] 11 | which was originally developed at Sony Mobile, but is no longer 12 | actively maintained. 13 | 14 | Unlike the original pygerrit, pygerrit2 does not provide any SSH 15 | interface. Users who require an SSH interface should continue to use 16 | [pygerrit][pygerrit]. 17 | 18 | ## Prerequisites 19 | 20 | Pygerrit2 is tested on the following platforms and Python versions: 21 | 22 | Platform | Python version(s) 23 | -------- | ----------------- 24 | OSX | 3.8 25 | Ubuntu (trusty) | 3.6 26 | Ubuntu (xenial) | 3.7 27 | Ubuntu (bionic) | 3.8 28 | 29 | Support for Python 2.x is no longer guaranteed. 30 | 31 | ## Installation 32 | 33 | To install pygerrit2, simply: 34 | 35 | ```bash 36 | pip install pygerrit2 37 | ``` 38 | 39 | ## Usage 40 | 41 | This simple example shows how to get the user's open changes. Authentication 42 | to Gerrit is done via HTTP Basic authentication, using an explicitly given 43 | username and password: 44 | 45 | ```python 46 | from pygerrit2 import GerritRestAPI, HTTPBasicAuth 47 | 48 | auth = HTTPBasicAuth('username', 'password') 49 | rest = GerritRestAPI(url='http://review.example.net', auth=auth) 50 | changes = rest.get("/changes/?q=owner:self%20status:open") 51 | ``` 52 | 53 | Note that it is not necessary to add the `/a/` prefix; it is automatically 54 | added on all URLs when the API is instantiated with authentication. 55 | 56 | If the user's HTTP username and password are defined in the `.netrc` 57 | file: 58 | 59 | ```bash 60 | machine review.example.net login MyUsername password MyPassword 61 | ``` 62 | 63 | then it is possible to authenticate with those credentials: 64 | 65 | ```python 66 | from pygerrit2 import GerritRestAPI, HTTPBasicAuthFromNetrc 67 | 68 | url = 'http://review.example.net' 69 | auth = HTTPBasicAuthFromNetrc(url=url) 70 | rest = GerritRestAPI(url=url, auth=auth) 71 | changes = rest.get("/changes/?q=owner:self%20status:open") 72 | ``` 73 | 74 | If no `auth` parameter is specified, pygerrit2 will attempt to find 75 | credentials in the `.netrc` and use them with HTTP basic auth. If no 76 | credentials are found, it will fall back to using no authentication. 77 | 78 | To explicitly use anonymous access, i.e. no authentication, use the 79 | `Anonymous` class: 80 | 81 | ```python 82 | from pygerrit2 import GerritRestAPI, Anonymous 83 | 84 | url = 'http://review.example.net' 85 | auth = Anonymous() 86 | rest = GerritRestAPI(url=url, auth=auth) 87 | changes = rest.get("/changes/?q=status:open") 88 | ``` 89 | 90 | Note that the HTTP password is not the same as the SSH password. For 91 | instructions on how to obtain the HTTP password, refer to Gerrit's 92 | [HTTP upload settings documentation][settings]. 93 | 94 | Also note that in Gerrit version 2.14, support for HTTP Digest authentication 95 | was removed and only HTTP Basic authentication is supported. When using 96 | pygerrit2 against an earlier Gerrit version, it may be necessary to replace 97 | the `HTTPBasic...` classes with the corresponding `HTTPDigest...` versions. 98 | 99 | Refer to the [example script][example] for a full working example. 100 | 101 | ## Contributing 102 | 103 | Contributions are welcome. Simply fork the repository, make your changes, 104 | and submit a pull request. 105 | 106 | ### Tests 107 | 108 | Run the tests with: 109 | 110 | ```bash 111 | make test 112 | ``` 113 | 114 | The tests include unit tests and integration tests against various versions 115 | of Gerrit running in Docker. Docker must be running on the development 116 | environment. 117 | 118 | ### Code Formatting 119 | 120 | Python code is formatted with [black][black]. To check the formatting, run: 121 | 122 | ```bash 123 | make black-check 124 | ``` 125 | 126 | and to automatically apply formatting, run: 127 | 128 | ```bash 129 | make black-format 130 | ``` 131 | 132 | Note that black requires minimum Python version 3.6. 133 | 134 | ### Making Releases 135 | 136 | Done by the pygerrit2 maintainers whenever necessary or due. 137 | 138 | Assumes a local [`~/.pypirc`][pypirc] file that looks something like this: 139 | 140 | ```bash 141 | [distutils] 142 | index-servers = 143 | pypi 144 | 145 | [pypi] 146 | username: 147 | password: 148 | ``` 149 | 150 | Example steps used; assumes [twine][twine] installed locally: 151 | 152 | ```bash 153 | git tag 2.0.15 154 | make sdist 155 | twine upload dist/pygerrit2-2.0.15.tar.gz 156 | git push origin 2.0.15 157 | ``` 158 | 159 | Optional: announcing the new [release][release] highlights on Twitter; no known hashtag. 160 | 161 | ## Copyright and License 162 | 163 | Copyright 2011 Sony Ericsson Mobile Communications. All rights reserved. 164 | 165 | Copyright 2012 Sony Mobile Communications. All rights reserved. 166 | 167 | Copyright 2016 David Pursehouse. All rights reserved. 168 | 169 | Licensed under The MIT License. Please refer to the [LICENSE file][license] 170 | for full license details. 171 | 172 | [black]: https://github.com/psf/black 173 | [example]: https://github.com/dpursehouse/pygerrit2/blob/master/example.py 174 | [gerrit]: https://gerritcodereview.com/ 175 | [license]: https://github.com/dpursehouse/pygerrit2/blob/master/LICENSE 176 | [pygerrit]: https://github.com/sonyxperiadev/pygerrit 177 | [pypirc]: https://packaging.python.org/specifications/pypirc/#common-configurations 178 | [release]: https://pypi.org/project/pygerrit2/ 179 | [settings]: https://gerrit-documentation.storage.googleapis.com/Documentation/2.15.2/user-upload.html#http 180 | [twine]: https://pypi.org/project/twine/ 181 | -------------------------------------------------------------------------------- /livetests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # The MIT License 5 | # 6 | # Copyright 2018 David Pursehouse. All rights reserved. 7 | # 8 | # Permission is hereby granted, free of charge, to any person obtaining a copy 9 | # of this software and associated documentation files (the "Software"), to deal 10 | # in the Software without restriction, including without limitation the rights 11 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | # copies of the Software, and to permit persons to whom the Software is 13 | # furnished to do so, subject to the following conditions: 14 | # 15 | # The above copyright notice and this permission notice shall be included in 16 | # all copies or substantial portions of the Software. 17 | # 18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 24 | # THE SOFTWARE. 25 | 26 | """Live server tests.""" 27 | 28 | import base64 29 | import pytest 30 | import unittest 31 | from pygerrit2 import GerritRestAPI, GerritReview, HTTPBasicAuth, Anonymous 32 | from testcontainers.core.container import DockerContainer 33 | from testcontainers.core.waiting_utils import wait_container_is_ready 34 | 35 | TEST_TOPIC = "test-topic" 36 | 37 | 38 | class GerritContainer(DockerContainer): 39 | """Gerrit container.""" 40 | 41 | def __init__(self, version): 42 | """Construct a GerritContainer with the given version.""" 43 | image = "gerritcodereview/gerrit:" + version 44 | super(GerritContainer, self).__init__(image) 45 | self.with_exposed_ports(8080) 46 | 47 | 48 | @wait_container_is_ready() 49 | def _initialize(api): 50 | api.get("/changes/") 51 | 52 | 53 | @pytest.fixture(scope="module", params=["3.6.6", "3.7.4", "3.8.1"]) 54 | def gerrit_api(request): 55 | """Create a Gerrit container for the given version and return an API.""" 56 | with GerritContainer(request.param) as gerrit: 57 | port = gerrit.get_exposed_port(8080) 58 | url = "http://localhost:%s" % port 59 | api = GerritRestAPI(url=url, auth=Anonymous()) 60 | _initialize(api) 61 | auth = HTTPBasicAuth("admin", "secret") 62 | api = GerritRestAPI(url=url, auth=auth) 63 | yield api 64 | 65 | 66 | class TestGerritAgainstLiveServer(object): 67 | """Run tests against a live server.""" 68 | 69 | def _get_test_change(self, gerrit_api, topic=TEST_TOPIC): 70 | results = gerrit_api.get("/changes/?q=topic:" + topic) 71 | assert len(results) == 1 72 | return results[0] 73 | 74 | def test_put_with_json_dict(self, gerrit_api): 75 | """Test a PUT request passing data as a dict to `json`. 76 | 77 | Tests that the PUT request works as expected when data is passed 78 | via the `json` argument as a `dict`. 79 | 80 | Creates the test project which is used by subsequent tests. 81 | """ 82 | projectinput = {"create_empty_commit": "true"} 83 | gerrit_api.put("/projects/test-project", json=projectinput) 84 | gerrit_api.get("/projects/test-project") 85 | 86 | def test_put_with_data_dict(self, gerrit_api): 87 | """Test a PUT request passing data as a dict to `data`. 88 | 89 | Tests that the PUT request works as expected when data is passed 90 | via the `data` argument as a `dict`. 91 | """ 92 | description = {"description": "New Description"} 93 | gerrit_api.put("/projects/test-project/description", data=description) 94 | project = gerrit_api.get("/projects/test-project") 95 | assert project["description"] == "New Description" 96 | 97 | def test_post_with_data_dict_and_no_data(self, gerrit_api): 98 | """Test a POST request passing data as a dict to `data`. 99 | 100 | Tests that the POST request works as expected when data is passed 101 | via the `data` argument as a `dict`. 102 | """ 103 | changeinput = { 104 | "project": "test-project", 105 | "subject": "subject", 106 | "branch": "master", 107 | "topic": "post-with-data", 108 | } 109 | result = gerrit_api.post("/changes/", data=changeinput) 110 | change = self._get_test_change(gerrit_api, "post-with-data") 111 | assert change["id"] == result["id"] 112 | 113 | # Subsequent post without data or json should not have the Content-Type 114 | # json header, and should succeed. 115 | result = gerrit_api.post("/changes/" + change["id"] + "/abandon") 116 | assert result["status"] == "ABANDONED" 117 | 118 | def test_post_with_json_dict(self, gerrit_api): 119 | """Test a POST request passing data as a dict to `json`. 120 | 121 | Tests that the POST request works as expected when data is passed 122 | via the `json` argument as a `dict`. 123 | 124 | Creates the change which is used by subsequent tests. 125 | """ 126 | changeinput = { 127 | "project": "test-project", 128 | "subject": "subject", 129 | "branch": "master", 130 | "topic": TEST_TOPIC, 131 | } 132 | result = gerrit_api.post("/changes/", json=changeinput) 133 | change = self._get_test_change(gerrit_api) 134 | assert change["id"] == result["id"] 135 | 136 | def test_put_with_data_as_string(self, gerrit_api): 137 | """Test a PUT request passing data as a string to `data`. 138 | 139 | Tests that the PUT request works as expected when data is passed 140 | via the `data` parameter as a string. 141 | 142 | Creates a change edit that is checked in the subsequent test. 143 | """ 144 | change_id = self._get_test_change(gerrit_api)["id"] 145 | gerrit_api.put( 146 | "/changes/" + change_id + "/edit/foo", 147 | data="Content with non base64 valid chars åäö", 148 | ) 149 | 150 | def test_put_json_content(self, gerrit_api): 151 | """Test a PUT request with a json file content (issue #54).""" 152 | change_id = self._get_test_change(gerrit_api)["id"] 153 | content = """{"foo" : "bar"}""" 154 | gerrit_api.put("/changes/" + change_id + "/edit/file.json", data=content) 155 | 156 | def test_get_base64_data(self, gerrit_api): 157 | """Test a GET request on an API that returns base64 encoded response. 158 | 159 | Tests that the headers can be overridden on the GET call, resulting 160 | in the data being returned as text/plain, and that the content of the 161 | response can be base64 decoded. 162 | """ 163 | change_id = self._get_test_change(gerrit_api)["id"] 164 | response = gerrit_api.get( 165 | "/changes/" + change_id + "/edit/foo", headers={"Accept": "text/plain"} 166 | ) 167 | 168 | # Will raise binascii.Error if content is not properly encoded 169 | base64.b64decode(response) 170 | 171 | def test_get_patch_zip(self, gerrit_api): 172 | """Test a GET request to get a patch file (issue #19).""" 173 | change_id = self._get_test_change(gerrit_api)["id"] 174 | gerrit_api.get("/changes/" + change_id + "/revisions/current/patch?zip") 175 | 176 | def test_put_with_no_content(self, gerrit_api): 177 | """Test a PUT request with no content.""" 178 | change_id = self._get_test_change(gerrit_api)["id"] 179 | gerrit_api.put("/changes/" + change_id + "/edit/foo") 180 | 181 | def test_review(self, gerrit_api): 182 | """Test that a review can be posted by the review API.""" 183 | change_id = self._get_test_change(gerrit_api)["id"] 184 | review = GerritReview() 185 | review.set_message("Review from live test") 186 | review.add_labels({"Code-Review": 1}) 187 | review.set_tag("a_test_tag") 188 | result = gerrit_api.review(change_id, "current", review) 189 | assert "labels" in result 190 | assert "Code-Review" in result["labels"] 191 | assert result["labels"]["Code-Review"] == 1 192 | 193 | 194 | if __name__ == "__main__": 195 | unittest.main() 196 | -------------------------------------------------------------------------------- /pygerrit2/rest/__init__.py: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | # 3 | # Copyright 2013 Sony Mobile Communications. All rights reserved. 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in 13 | # all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | # THE SOFTWARE. 22 | 23 | """Interface to the Gerrit REST API.""" 24 | 25 | import json 26 | import logging 27 | import requests 28 | from requests.adapters import HTTPAdapter 29 | from requests.packages.urllib3.util.retry import Retry 30 | 31 | from .auth import HTTPBasicAuthFromNetrc, Anonymous 32 | 33 | logger = logging.getLogger("pygerrit2") 34 | fmt = "%(asctime)s-[%(name)s-%(levelname)s] %(message)s" 35 | datefmt = "[%y-%m-%d %H:%M:%S]" 36 | sh = logging.StreamHandler() 37 | sh.setLevel(logging.WARNING) 38 | sh.setFormatter(logging.Formatter(fmt, datefmt)) 39 | if not logger.handlers: 40 | logger.addHandler(sh) 41 | 42 | GERRIT_MAGIC_JSON_PREFIX = ")]}'\n" 43 | GERRIT_AUTH_SUFFIX = "/a" 44 | DEFAULT_HEADERS = {"Accept": "application/json", "Accept-Encoding": "gzip"} 45 | 46 | 47 | def _decode_response(response): 48 | """Strip off Gerrit's magic prefix and decode a response. 49 | 50 | :returns: 51 | Decoded JSON content as a dict, or raw text if content could not be 52 | decoded as JSON. 53 | 54 | :raises: 55 | requests.HTTPError if the response contains an HTTP error status code. 56 | 57 | """ 58 | content_type = response.headers.get("content-type", "") 59 | logger.debug( 60 | "status[%s] content_type[%s] encoding[%s]" 61 | % (response.status_code, content_type, response.encoding) 62 | ) 63 | response.raise_for_status() 64 | content = response.content.strip() 65 | if response.encoding: 66 | content = content.decode(response.encoding) 67 | if not content: 68 | logger.debug("no content in response") 69 | return content 70 | if content_type.split(";")[0] != "application/json": 71 | return content 72 | if content.startswith(GERRIT_MAGIC_JSON_PREFIX): 73 | index = len(GERRIT_MAGIC_JSON_PREFIX) 74 | content = content[index:] 75 | try: 76 | return json.loads(content) 77 | except ValueError: 78 | logger.error("Invalid json content: %s", content) 79 | raise 80 | 81 | 82 | class GerritRestAPI(object): 83 | """Interface to the Gerrit REST API. 84 | 85 | :arg str url: The full URL to the server, including the `http(s)://` 86 | prefix. If `auth` is given, `url` will be automatically adjusted to 87 | include Gerrit's authentication suffix. 88 | :arg auth: (optional) Authentication handler. Must be derived from 89 | `requests.auth.AuthBase`. 90 | :arg boolean verify: (optional) Set to False to disable verification of 91 | SSL certificates. 92 | :arg requests.adapters.BaseAdapter adapter: (optional) Custom connection 93 | adapter. See 94 | https://requests.readthedocs.io/en/master/api/#requests.adapters.BaseAdapter 95 | 96 | """ 97 | 98 | def __init__(self, url, auth=None, verify=True, adapter=None): 99 | """See class docstring.""" 100 | self.url = url.rstrip("/") 101 | self.session = requests.session() 102 | if not adapter: 103 | retry = Retry( 104 | total=5, 105 | read=5, 106 | connect=5, 107 | backoff_factor=0.3, 108 | status_forcelist=(500, 502, 504), 109 | ) 110 | adapter = HTTPAdapter(max_retries=retry) 111 | self.session.mount("http://", adapter) 112 | self.session.mount("https://", adapter) 113 | 114 | if not auth: 115 | try: 116 | auth = HTTPBasicAuthFromNetrc(url) 117 | except ValueError as e: 118 | logger.debug("Error parsing netrc: %s", str(e)) 119 | pass 120 | elif isinstance(auth, Anonymous): 121 | logger.debug("Anonymous") 122 | auth = None 123 | 124 | if auth: 125 | if not isinstance(auth, requests.auth.AuthBase): 126 | raise ValueError( 127 | "Invalid auth type; must be derived from requests.auth.AuthBase" 128 | ) 129 | 130 | if not self.url.endswith(GERRIT_AUTH_SUFFIX): 131 | self.url += GERRIT_AUTH_SUFFIX 132 | else: 133 | if self.url.endswith(GERRIT_AUTH_SUFFIX): 134 | self.url = self.url[: -len(GERRIT_AUTH_SUFFIX)] 135 | 136 | self.kwargs = {"auth": auth, "verify": verify} 137 | 138 | # Keep a copy of the auth, only needed for tests 139 | self.auth = auth 140 | 141 | if not self.url.endswith("/"): 142 | self.url += "/" 143 | 144 | def make_url(self, endpoint): 145 | """Make the full url for the endpoint. 146 | 147 | :arg str endpoint: The endpoint. 148 | 149 | :returns: 150 | The full url. 151 | 152 | """ 153 | endpoint = endpoint.lstrip("/") 154 | return self.url + endpoint 155 | 156 | def translate_kwargs(self, **kwargs): 157 | """Translate kwargs replacing `data` with `json` if necessary.""" 158 | local_kwargs = self.kwargs.copy() 159 | local_kwargs.update(kwargs) 160 | 161 | if "data" in local_kwargs and "json" in local_kwargs: 162 | raise ValueError("Cannot use data and json together") 163 | 164 | if "data" in local_kwargs and isinstance(local_kwargs["data"], dict): 165 | local_kwargs.update({"json": local_kwargs["data"]}) 166 | del local_kwargs["data"] 167 | 168 | if "timeout" not in local_kwargs: 169 | local_kwargs.update({"timeout": 10}) 170 | 171 | headers = DEFAULT_HEADERS.copy() 172 | if "headers" in kwargs: 173 | headers.update(kwargs["headers"]) 174 | if "json" in local_kwargs: 175 | headers.update({"Content-Type": "application/json;charset=UTF-8"}) 176 | local_kwargs.update({"headers": headers}) 177 | 178 | return local_kwargs 179 | 180 | def get(self, endpoint, return_response=False, **kwargs): 181 | """Send HTTP GET to the endpoint. 182 | 183 | :arg str endpoint: The endpoint to send to. 184 | :arg bool return_response: If true will also return the response 185 | 186 | :returns: 187 | JSON decoded result. 188 | 189 | :raises: 190 | requests.RequestException on timeout or connection error. 191 | 192 | """ 193 | args = self.translate_kwargs(**kwargs) 194 | response = self.session.get(self.make_url(endpoint), **args) 195 | 196 | decoded_response = _decode_response(response) 197 | 198 | if return_response: 199 | return decoded_response, response 200 | return decoded_response 201 | 202 | def put(self, endpoint, return_response=False, **kwargs): 203 | """Send HTTP PUT to the endpoint. 204 | 205 | :arg str endpoint: The endpoint to send to. 206 | 207 | :returns: 208 | JSON decoded result. 209 | 210 | :raises: 211 | requests.RequestException on timeout or connection error. 212 | 213 | """ 214 | args = self.translate_kwargs(**kwargs) 215 | response = self.session.put(self.make_url(endpoint), **args) 216 | 217 | decoded_response = _decode_response(response) 218 | 219 | if return_response: 220 | return decoded_response, response 221 | return decoded_response 222 | 223 | def post(self, endpoint, return_response=False, **kwargs): 224 | """Send HTTP POST to the endpoint. 225 | 226 | :arg str endpoint: The endpoint to send to. 227 | 228 | :returns: 229 | JSON decoded result. 230 | 231 | :raises: 232 | requests.RequestException on timeout or connection error. 233 | 234 | """ 235 | args = self.translate_kwargs(**kwargs) 236 | response = self.session.post(self.make_url(endpoint), **args) 237 | 238 | decoded_response = _decode_response(response) 239 | 240 | if return_response: 241 | return decoded_response, response 242 | return decoded_response 243 | 244 | def delete(self, endpoint, return_response=False, **kwargs): 245 | """Send HTTP DELETE to the endpoint. 246 | 247 | :arg str endpoint: The endpoint to send to. 248 | 249 | :returns: 250 | JSON decoded result. 251 | 252 | :raises: 253 | requests.RequestException on timeout or connection error. 254 | 255 | """ 256 | args = self.translate_kwargs(**kwargs) 257 | response = self.session.delete(self.make_url(endpoint), **args) 258 | 259 | decoded_response = _decode_response(response) 260 | 261 | if return_response: 262 | return decoded_response, response 263 | return decoded_response 264 | 265 | def review(self, change_id, revision, review): 266 | """Submit a review. 267 | 268 | :arg str change_id: The change ID. 269 | :arg str revision: The revision. 270 | :arg str review: The review details as a :class:`GerritReview`. 271 | 272 | :returns: 273 | JSON decoded result. 274 | 275 | :raises: 276 | requests.RequestException on timeout or connection error. 277 | 278 | """ 279 | endpoint = "changes/%s/revisions/%s/review" % (change_id, revision) 280 | return self.post( 281 | endpoint, data=str(review), headers={"Content-Type": "application/json"} 282 | ) 283 | 284 | 285 | class GerritReview(object): 286 | """Encapsulation of a Gerrit review. 287 | 288 | :arg str message: (optional) Cover message. 289 | :arg dict labels: (optional) Review labels. 290 | :arg dict comments: (optional) Inline comments. 291 | :arg str tag: (optional) Review tag. 292 | 293 | """ 294 | 295 | def __init__(self, message=None, labels=None, comments=None, tag=None): 296 | """See class docstring.""" 297 | self.message = message if message else "" 298 | self.tag = tag if tag else "" 299 | if labels: 300 | if not isinstance(labels, dict): 301 | raise ValueError("labels must be a dict.") 302 | self.labels = labels 303 | else: 304 | self.labels = {} 305 | if comments: 306 | if not isinstance(comments, list): 307 | raise ValueError("comments must be a list.") 308 | self.comments = {} 309 | self.add_comments(comments) 310 | else: 311 | self.comments = {} 312 | 313 | def set_message(self, message): 314 | """Set review cover message. 315 | 316 | :arg str message: Cover message. 317 | 318 | """ 319 | self.message = message 320 | 321 | def set_tag(self, tag): 322 | """Set review tag. 323 | 324 | :arg str tag: Review tag. 325 | 326 | """ 327 | self.tag = tag 328 | 329 | def add_labels(self, labels): 330 | """Add labels. 331 | 332 | :arg dict labels: Labels to add, for example 333 | 334 | Usage:: 335 | 336 | add_labels({'Verified': 1, 337 | 'Code-Review': -1}) 338 | 339 | """ 340 | self.labels.update(labels) 341 | 342 | def add_comments(self, comments): 343 | """Add inline comments. 344 | 345 | :arg dict comments: Comments to add. 346 | 347 | Usage:: 348 | 349 | add_comments([{'filename': 'Makefile', 350 | 'line': 10, 351 | 'message': 'inline message'}]) 352 | 353 | add_comments([{'filename': 'Makefile', 354 | 'range': {'start_line': 0, 355 | 'start_character': 1, 356 | 'end_line': 0, 357 | 'end_character': 5}, 358 | 'message': 'inline message'}]) 359 | 360 | """ 361 | for comment in comments: 362 | if "filename" and "message" in list(comment.keys()): 363 | msg = {} 364 | if "range" in list(comment.keys()): 365 | msg = {"range": comment["range"], "message": comment["message"]} 366 | elif "line" in list(comment.keys()): 367 | msg = {"line": comment["line"], "message": comment["message"]} 368 | else: 369 | continue 370 | file_comment = {comment["filename"]: [msg]} 371 | if self.comments: 372 | if comment["filename"] in list(self.comments.keys()): 373 | self.comments[comment["filename"]].append(msg) 374 | else: 375 | self.comments.update(file_comment) 376 | else: 377 | self.comments.update(file_comment) 378 | 379 | def __str__(self): 380 | """Return a string representation.""" 381 | review_input = {} 382 | if self.message: 383 | review_input.update({"message": self.message}) 384 | if self.tag: 385 | review_input.update({"tag": self.tag}) 386 | if self.labels: 387 | review_input.update({"labels": self.labels}) 388 | if self.comments: 389 | review_input.update({"comments": self.comments}) 390 | return json.dumps(review_input, sort_keys=True) 391 | -------------------------------------------------------------------------------- /unittests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # The MIT License 5 | # 6 | # Copyright 2012 Sony Mobile Communications. All rights reserved. 7 | # 8 | # Permission is hereby granted, free of charge, to any person obtaining a copy 9 | # of this software and associated documentation files (the "Software"), to deal 10 | # in the Software without restriction, including without limitation the rights 11 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | # copies of the Software, and to permit persons to whom the Software is 13 | # furnished to do so, subject to the following conditions: 14 | # 15 | # The above copyright notice and this permission notice shall be included in 16 | # all copies or substantial portions of the Software. 17 | # 18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 24 | # THE SOFTWARE. 25 | 26 | """Unit tests for the Pygerrit2 helper methods.""" 27 | 28 | import re 29 | import unittest 30 | 31 | from mock import patch 32 | from pygerrit2 import GerritReviewMessageFormatter, GerritReview 33 | from pygerrit2 import HTTPBasicAuthFromNetrc, HTTPDigestAuthFromNetrc, Anonymous 34 | from pygerrit2 import GerritRestAPI 35 | 36 | EXPECTED_TEST_CASE_FIELDS = ["header", "footer", "paragraphs", "result"] 37 | 38 | 39 | TEST_CASES = [ 40 | {"header": None, "footer": None, "paragraphs": [], "result": ""}, 41 | {"header": "Header", "footer": "Footer", "paragraphs": [], "result": ""}, 42 | {"header": None, "footer": None, "paragraphs": ["Test"], "result": "Test"}, 43 | { 44 | "header": None, 45 | "footer": None, 46 | "paragraphs": ["Test", "Test"], 47 | "result": "Test\n\nTest", 48 | }, 49 | { 50 | "header": "Header", 51 | "footer": None, 52 | "paragraphs": ["Test"], 53 | "result": "Header\n\nTest", 54 | }, 55 | { 56 | "header": "Header", 57 | "footer": None, 58 | "paragraphs": ["Test", "Test"], 59 | "result": "Header\n\nTest\n\nTest", 60 | }, 61 | { 62 | "header": "Header", 63 | "footer": "Footer", 64 | "paragraphs": ["Test", "Test"], 65 | "result": "Header\n\nTest\n\nTest\n\nFooter", 66 | }, 67 | { 68 | "header": "Header", 69 | "footer": "Footer", 70 | "paragraphs": [["One"]], 71 | "result": "Header\n\n* One\n\nFooter", 72 | }, 73 | { 74 | "header": "Header", 75 | "footer": "Footer", 76 | "paragraphs": [["One", "Two"]], 77 | "result": "Header\n\n* One\n* Two\n\nFooter", 78 | }, 79 | { 80 | "header": "Header", 81 | "footer": "Footer", 82 | "paragraphs": ["Test", ["One"], "Test"], 83 | "result": "Header\n\nTest\n\n* One\n\nTest\n\nFooter", 84 | }, 85 | { 86 | "header": "Header", 87 | "footer": "Footer", 88 | "paragraphs": ["Test", ["One", "Two"], "Test"], 89 | "result": "Header\n\nTest\n\n* One\n* Two\n\nTest\n\nFooter", 90 | }, 91 | { 92 | "header": "Header", 93 | "footer": "Footer", 94 | "paragraphs": ["Test", "Test", ["One"]], 95 | "result": "Header\n\nTest\n\nTest\n\n* One\n\nFooter", 96 | }, 97 | { 98 | "header": None, 99 | "footer": None, 100 | "paragraphs": [["* One", "* Two"]], 101 | "result": "* One\n* Two", 102 | }, 103 | { 104 | "header": None, 105 | "footer": None, 106 | "paragraphs": [["* One ", " * Two "]], 107 | "result": "* One\n* Two", 108 | }, 109 | {"header": None, "footer": None, "paragraphs": [["*", "*"]], "result": ""}, 110 | {"header": None, "footer": None, "paragraphs": [["", ""]], "result": ""}, 111 | {"header": None, "footer": None, "paragraphs": [[" ", " "]], "result": ""}, 112 | { 113 | "header": None, 114 | "footer": None, 115 | "paragraphs": [["* One", " ", "* Two"]], 116 | "result": "* One\n* Two", 117 | }, 118 | ] 119 | 120 | 121 | class TestGerritReviewMessageFormatter(unittest.TestCase): 122 | """Test that the GerritReviewMessageFormatter class behaves properly.""" 123 | 124 | def _check_test_case_fields(self, test_case, i): 125 | for field in EXPECTED_TEST_CASE_FIELDS: 126 | self.assertTrue( 127 | field in test_case, 128 | "field '%s' not present in test case #%d" % (field, i), 129 | ) 130 | self.assertTrue( 131 | isinstance(test_case["paragraphs"], list), 132 | "'paragraphs' field is not a list in test case #%d" % i, 133 | ) 134 | 135 | def test_is_empty(self): 136 | """Test if message is empty for missing header and footer.""" 137 | fmt = GerritReviewMessageFormatter(header=None, footer=None) 138 | self.assertTrue(fmt.is_empty()) 139 | fmt.append(["test"]) 140 | self.assertFalse(fmt.is_empty()) 141 | 142 | def test_message_formatting(self): 143 | """Test message formatter for different test cases.""" 144 | for i, test_case in enumerate(TEST_CASES): 145 | self._check_test_case_fields(test_case, i) 146 | fmt = GerritReviewMessageFormatter( 147 | header=test_case["header"], footer=test_case["footer"] 148 | ) 149 | for paragraph in test_case["paragraphs"]: 150 | fmt.append(paragraph) 151 | msg = fmt.format() 152 | self.assertEqual( 153 | msg, 154 | test_case["result"], 155 | "Formatted message does not match expected " 156 | "result in test case #%d:\n[%s]" % (i, msg), 157 | ) 158 | 159 | 160 | class TestGerritReview(unittest.TestCase): 161 | """Test that the GerritReview class behaves properly.""" 162 | 163 | def test_str(self): 164 | """Test for str function.""" 165 | obj = GerritReview() 166 | self.assertEqual(str(obj), "{}") 167 | 168 | obj2 = GerritReview(labels={"Verified": 1, "Code-Review": -1}) 169 | self.assertEqual(str(obj2), '{"labels": {"Code-Review": -1, "Verified": 1}}') 170 | 171 | obj3 = GerritReview( 172 | comments=[{"filename": "Makefile", "line": 10, "message": "test"}] 173 | ) 174 | self.assertEqual( 175 | str(obj3), '{"comments": {"Makefile": [{"line": 10, "message": "test"}]}}' 176 | ) 177 | 178 | obj4 = GerritReview( 179 | labels={"Verified": 1, "Code-Review": -1}, 180 | comments=[{"filename": "Makefile", "line": 10, "message": "test"}], 181 | ) 182 | self.assertEqual( 183 | str(obj4), 184 | '{"comments": {"Makefile": [{"line": 10, "message": "test"}]},' 185 | ' "labels": {"Code-Review": -1, "Verified": 1}}', 186 | ) 187 | 188 | obj5 = GerritReview( 189 | comments=[ 190 | {"filename": "Makefile", "line": 15, "message": "test"}, 191 | {"filename": "Make", "line": 10, "message": "test1"}, 192 | ] 193 | ) 194 | self.assertEqual( 195 | str(obj5), 196 | '{"comments": {"Make": [{"line": 10, "message": "test1"}],' 197 | ' "Makefile": [{"line": 15, "message": "test"}]}}', 198 | ) 199 | 200 | 201 | class TestNetrcAuth(unittest.TestCase): 202 | """Test that netrc authentication works.""" 203 | 204 | def test_basic_auth_from_netrc(self): 205 | """Test that the HTTP basic auth is taken from netrc.""" 206 | with patch("pygerrit2.rest.auth._get_netrc_auth") as mock_netrc: 207 | mock_netrc.return_value = ("netrcuser", "netrcpass") 208 | auth = HTTPBasicAuthFromNetrc(url="http://review.example.com") 209 | assert auth.username == "netrcuser" 210 | assert auth.password == "netrcpass" 211 | 212 | def test_digest_auth_from_netrc(self): 213 | """Test that the HTTP digest auth is taken from netrc.""" 214 | with patch("pygerrit2.rest.auth._get_netrc_auth") as mock_netrc: 215 | mock_netrc.return_value = ("netrcuser", "netrcpass") 216 | auth = HTTPDigestAuthFromNetrc(url="http://review.example.com") 217 | assert auth.username == "netrcuser" 218 | assert auth.password == "netrcpass" 219 | 220 | def test_basic_auth_from_netrc_fails(self): 221 | """Test that an exception is raised when credentials are not found.""" 222 | with self.assertRaises(ValueError) as exc: 223 | HTTPBasicAuthFromNetrc(url="http://review.example.com") 224 | assert str(exc.exception) == "netrc missing or no credentials found in netrc" 225 | 226 | def test_digest_auth_from_netrc_fails(self): 227 | """Test that an exception is raised when credentials are not found.""" 228 | with self.assertRaises(ValueError) as exc: 229 | HTTPDigestAuthFromNetrc(url="http://review.example.com") 230 | assert str(exc.exception) == "netrc missing or no credentials found in netrc" 231 | 232 | def test_default_to_basic_auth_from_netrc(self): 233 | """Test auth defaults to HTTP basic from netrc when not specified.""" 234 | with patch("pygerrit2.rest.auth._get_netrc_auth") as mock_netrc: 235 | mock_netrc.return_value = ("netrcuser", "netrcpass") 236 | api = GerritRestAPI(url="http://review.example.com") 237 | assert isinstance(api.auth, HTTPBasicAuthFromNetrc) 238 | assert api.url.endswith("/a/") 239 | 240 | def test_default_to_no_auth_when_not_in_netrc(self): 241 | """Test auth defaults to none when not specified and not in netrc.""" 242 | with patch("pygerrit2.rest.auth._get_netrc_auth") as mock_netrc: 243 | mock_netrc.return_value = None 244 | api = GerritRestAPI(url="http://review.example.com") 245 | assert api.auth is None 246 | assert not api.url.endswith("/a/") 247 | 248 | def test_invalid_auth_type(self): 249 | """Test that an exception is raised for invalid auth type.""" 250 | with self.assertRaises(ValueError) as exc: 251 | GerritRestAPI(url="http://review.example.com", auth="foo") 252 | assert re.search(r"Invalid auth type", str(exc.exception)) 253 | 254 | def test_explicit_anonymous_with_netrc(self): 255 | """Test explicit anonymous access when credentials are in netrc.""" 256 | with patch("pygerrit2.rest.auth._get_netrc_auth") as mock_netrc: 257 | mock_netrc.return_value = ("netrcuser", "netrcpass") 258 | auth = Anonymous() 259 | api = GerritRestAPI(url="http://review.example.com", auth=auth) 260 | assert api.auth is None 261 | assert not api.url.endswith("/a/") 262 | 263 | def test_explicit_anonymous_without_netrc(self): 264 | """Test explicit anonymous access when credentials are not in netrc.""" 265 | with patch("pygerrit2.rest.auth._get_netrc_auth") as mock_netrc: 266 | mock_netrc.return_value = None 267 | auth = Anonymous() 268 | api = GerritRestAPI(url="http://review.example.com", auth=auth) 269 | assert api.auth is None 270 | assert not api.url.endswith("/a/") 271 | 272 | 273 | class TestKwargsTranslation(unittest.TestCase): 274 | """Test that kwargs translation works.""" 275 | 276 | def test_data_and_json(self): 277 | """Test that `json` and `data` cannot be used at the same time.""" 278 | api = GerritRestAPI(url="http://review.example.com") 279 | with self.assertRaises(ValueError) as exc: 280 | api.translate_kwargs(data="d", json="j") 281 | assert re.search(r"Cannot use data and json together", str(exc.exception)) 282 | 283 | def test_data_as_dict_converts_to_json_and_header_added(self): 284 | """Test that `data` dict is converted to `json`. 285 | 286 | Also test that a Content-Type header is added. 287 | """ 288 | api = GerritRestAPI(url="http://review.example.com") 289 | data = {"a": "a"} 290 | result = api.translate_kwargs(data=data) 291 | assert "json" in result 292 | assert "data" not in result 293 | assert "headers" in result 294 | headers = result["headers"] 295 | assert "Content-Type" in headers 296 | assert result["json"] == {"a": "a"} 297 | assert headers["Content-Type"] == "application/json;charset=UTF-8" 298 | 299 | def test_json_is_unchanged_and_header_added(self): 300 | """Test that `json` is unchanged and a Content-Type header is added.""" 301 | api = GerritRestAPI(url="http://review.example.com") 302 | json = {"a": "a"} 303 | result = api.translate_kwargs(json=json) 304 | assert "json" in result 305 | assert "data" not in result 306 | assert "headers" in result 307 | headers = result["headers"] 308 | assert "Content-Type" in headers 309 | assert result["json"] == {"a": "a"} 310 | assert headers["Content-Type"] == "application/json;charset=UTF-8" 311 | 312 | def test_json_no_side_effect_on_subsequent_call(self): 313 | """Test that subsequent call is not polluted with results of previous. 314 | 315 | If the translate_kwargs method is called, resulting in the content-type 316 | header being added, the header should not also be added on a subsequent 317 | call that does not need it. 318 | """ 319 | api = GerritRestAPI(url="http://review.example.com") 320 | json = {"a": "a"} 321 | result = api.translate_kwargs(json=json) 322 | assert "json" in result 323 | assert "data" not in result 324 | assert "headers" in result 325 | headers = result["headers"] 326 | assert "Content-Type" in headers 327 | assert result["json"] == {"a": "a"} 328 | assert headers["Content-Type"] == "application/json;charset=UTF-8" 329 | kwargs = {"a": "a", "b": "b"} 330 | result = api.translate_kwargs(**kwargs) 331 | assert "json" not in result 332 | assert "data" not in result 333 | assert "a" in result 334 | assert "b" in result 335 | assert "headers" in result 336 | headers = result["headers"] 337 | assert "Content-Type" not in headers 338 | 339 | def test_kwargs_unchanged_when_no_data_or_json(self): 340 | """Test that `json` or `data` are not added when not passed.""" 341 | api = GerritRestAPI(url="http://review.example.com") 342 | kwargs = {"a": "a", "b": "b"} 343 | result = api.translate_kwargs(**kwargs) 344 | assert "json" not in result 345 | assert "data" not in result 346 | assert "a" in result 347 | assert "b" in result 348 | assert "headers" in result 349 | headers = result["headers"] 350 | assert "Content-Type" not in headers 351 | 352 | def test_data_as_string_is_unchanged(self): 353 | """Test that `data` is unchanged when passed as a string.""" 354 | api = GerritRestAPI(url="http://review.example.com") 355 | kwargs = {"data": "Content with non base64 valid chars åäö"} 356 | result = api.translate_kwargs(**kwargs) 357 | assert "json" not in result 358 | assert "data" in result 359 | assert result["data"] == "Content with non base64 valid chars åäö" 360 | assert "headers" in result 361 | headers = result["headers"] 362 | assert "Content-Type" not in headers 363 | 364 | 365 | if __name__ == "__main__": 366 | unittest.main() 367 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "336f3ee4747a8e92a1637c46d9500df77711490bf394fcb738712ad2e481d9fb" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": {}, 8 | "sources": [ 9 | { 10 | "name": "pypi", 11 | "url": "https://pypi.org/simple", 12 | "verify_ssl": true 13 | } 14 | ] 15 | }, 16 | "default": { 17 | "certifi": { 18 | "hashes": [ 19 | "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082", 20 | "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9" 21 | ], 22 | "index": "pypi", 23 | "version": "==2023.7.22" 24 | }, 25 | "charset-normalizer": { 26 | "hashes": [ 27 | "sha256:04e57ab9fbf9607b77f7d057974694b4f6b142da9ed4a199859d9d4d5c63fe96", 28 | "sha256:09393e1b2a9461950b1c9a45d5fd251dc7c6f228acab64da1c9c0165d9c7765c", 29 | "sha256:0b87549028f680ca955556e3bd57013ab47474c3124dc069faa0b6545b6c9710", 30 | "sha256:1000fba1057b92a65daec275aec30586c3de2401ccdcd41f8a5c1e2c87078706", 31 | "sha256:1249cbbf3d3b04902ff081ffbb33ce3377fa6e4c7356f759f3cd076cc138d020", 32 | "sha256:1920d4ff15ce893210c1f0c0e9d19bfbecb7983c76b33f046c13a8ffbd570252", 33 | "sha256:193cbc708ea3aca45e7221ae58f0fd63f933753a9bfb498a3b474878f12caaad", 34 | "sha256:1a100c6d595a7f316f1b6f01d20815d916e75ff98c27a01ae817439ea7726329", 35 | "sha256:1f30b48dd7fa1474554b0b0f3fdfdd4c13b5c737a3c6284d3cdc424ec0ffff3a", 36 | "sha256:203f0c8871d5a7987be20c72442488a0b8cfd0f43b7973771640fc593f56321f", 37 | "sha256:246de67b99b6851627d945db38147d1b209a899311b1305dd84916f2b88526c6", 38 | "sha256:2dee8e57f052ef5353cf608e0b4c871aee320dd1b87d351c28764fc0ca55f9f4", 39 | "sha256:2efb1bd13885392adfda4614c33d3b68dee4921fd0ac1d3988f8cbb7d589e72a", 40 | "sha256:2f4ac36d8e2b4cc1aa71df3dd84ff8efbe3bfb97ac41242fbcfc053c67434f46", 41 | "sha256:3170c9399da12c9dc66366e9d14da8bf7147e1e9d9ea566067bbce7bb74bd9c2", 42 | "sha256:3b1613dd5aee995ec6d4c69f00378bbd07614702a315a2cf6c1d21461fe17c23", 43 | "sha256:3bb3d25a8e6c0aedd251753a79ae98a093c7e7b471faa3aa9a93a81431987ace", 44 | "sha256:3bb7fda7260735efe66d5107fb7e6af6a7c04c7fce9b2514e04b7a74b06bf5dd", 45 | "sha256:41b25eaa7d15909cf3ac4c96088c1f266a9a93ec44f87f1d13d4a0e86c81b982", 46 | "sha256:45de3f87179c1823e6d9e32156fb14c1927fcc9aba21433f088fdfb555b77c10", 47 | "sha256:46fb8c61d794b78ec7134a715a3e564aafc8f6b5e338417cb19fe9f57a5a9bf2", 48 | "sha256:48021783bdf96e3d6de03a6e39a1171ed5bd7e8bb93fc84cc649d11490f87cea", 49 | "sha256:4957669ef390f0e6719db3613ab3a7631e68424604a7b448f079bee145da6e09", 50 | "sha256:5e86d77b090dbddbe78867a0275cb4df08ea195e660f1f7f13435a4649e954e5", 51 | "sha256:6339d047dab2780cc6220f46306628e04d9750f02f983ddb37439ca47ced7149", 52 | "sha256:681eb3d7e02e3c3655d1b16059fbfb605ac464c834a0c629048a30fad2b27489", 53 | "sha256:6c409c0deba34f147f77efaa67b8e4bb83d2f11c8806405f76397ae5b8c0d1c9", 54 | "sha256:7095f6fbfaa55defb6b733cfeb14efaae7a29f0b59d8cf213be4e7ca0b857b80", 55 | "sha256:70c610f6cbe4b9fce272c407dd9d07e33e6bf7b4aa1b7ffb6f6ded8e634e3592", 56 | "sha256:72814c01533f51d68702802d74f77ea026b5ec52793c791e2da806a3844a46c3", 57 | "sha256:7a4826ad2bd6b07ca615c74ab91f32f6c96d08f6fcc3902ceeedaec8cdc3bcd6", 58 | "sha256:7c70087bfee18a42b4040bb9ec1ca15a08242cf5867c58726530bdf3945672ed", 59 | "sha256:855eafa5d5a2034b4621c74925d89c5efef61418570e5ef9b37717d9c796419c", 60 | "sha256:8700f06d0ce6f128de3ccdbc1acaea1ee264d2caa9ca05daaf492fde7c2a7200", 61 | "sha256:89f1b185a01fe560bc8ae5f619e924407efca2191b56ce749ec84982fc59a32a", 62 | "sha256:8b2c760cfc7042b27ebdb4a43a4453bd829a5742503599144d54a032c5dc7e9e", 63 | "sha256:8c2f5e83493748286002f9369f3e6607c565a6a90425a3a1fef5ae32a36d749d", 64 | "sha256:8e098148dd37b4ce3baca71fb394c81dc5d9c7728c95df695d2dca218edf40e6", 65 | "sha256:94aea8eff76ee6d1cdacb07dd2123a68283cb5569e0250feab1240058f53b623", 66 | "sha256:95eb302ff792e12aba9a8b8f8474ab229a83c103d74a750ec0bd1c1eea32e669", 67 | "sha256:9bd9b3b31adcb054116447ea22caa61a285d92e94d710aa5ec97992ff5eb7cf3", 68 | "sha256:9e608aafdb55eb9f255034709e20d5a83b6d60c054df0802fa9c9883d0a937aa", 69 | "sha256:a103b3a7069b62f5d4890ae1b8f0597618f628b286b03d4bc9195230b154bfa9", 70 | "sha256:a386ebe437176aab38c041de1260cd3ea459c6ce5263594399880bbc398225b2", 71 | "sha256:a38856a971c602f98472050165cea2cdc97709240373041b69030be15047691f", 72 | "sha256:a401b4598e5d3f4a9a811f3daf42ee2291790c7f9d74b18d75d6e21dda98a1a1", 73 | "sha256:a7647ebdfb9682b7bb97e2a5e7cb6ae735b1c25008a70b906aecca294ee96cf4", 74 | "sha256:aaf63899c94de41fe3cf934601b0f7ccb6b428c6e4eeb80da72c58eab077b19a", 75 | "sha256:b0dac0ff919ba34d4df1b6131f59ce95b08b9065233446be7e459f95554c0dc8", 76 | "sha256:baacc6aee0b2ef6f3d308e197b5d7a81c0e70b06beae1f1fcacffdbd124fe0e3", 77 | "sha256:bf420121d4c8dce6b889f0e8e4ec0ca34b7f40186203f06a946fa0276ba54029", 78 | "sha256:c04a46716adde8d927adb9457bbe39cf473e1e2c2f5d0a16ceb837e5d841ad4f", 79 | "sha256:c0b21078a4b56965e2b12f247467b234734491897e99c1d51cee628da9786959", 80 | "sha256:c1c76a1743432b4b60ab3358c937a3fe1341c828ae6194108a94c69028247f22", 81 | "sha256:c4983bf937209c57240cff65906b18bb35e64ae872da6a0db937d7b4af845dd7", 82 | "sha256:c4fb39a81950ec280984b3a44f5bd12819953dc5fa3a7e6fa7a80db5ee853952", 83 | "sha256:c57921cda3a80d0f2b8aec7e25c8aa14479ea92b5b51b6876d975d925a2ea346", 84 | "sha256:c8063cf17b19661471ecbdb3df1c84f24ad2e389e326ccaf89e3fb2484d8dd7e", 85 | "sha256:ccd16eb18a849fd8dcb23e23380e2f0a354e8daa0c984b8a732d9cfaba3a776d", 86 | "sha256:cd6dbe0238f7743d0efe563ab46294f54f9bc8f4b9bcf57c3c666cc5bc9d1299", 87 | "sha256:d62e51710986674142526ab9f78663ca2b0726066ae26b78b22e0f5e571238dd", 88 | "sha256:db901e2ac34c931d73054d9797383d0f8009991e723dab15109740a63e7f902a", 89 | "sha256:e03b8895a6990c9ab2cdcd0f2fe44088ca1c65ae592b8f795c3294af00a461c3", 90 | "sha256:e1c8a2f4c69e08e89632defbfabec2feb8a8d99edc9f89ce33c4b9e36ab63037", 91 | "sha256:e4b749b9cc6ee664a3300bb3a273c1ca8068c46be705b6c31cf5d276f8628a94", 92 | "sha256:e6a5bf2cba5ae1bb80b154ed68a3cfa2fa00fde979a7f50d6598d3e17d9ac20c", 93 | "sha256:e857a2232ba53ae940d3456f7533ce6ca98b81917d47adc3c7fd55dad8fab858", 94 | "sha256:ee4006268ed33370957f55bf2e6f4d263eaf4dc3cfc473d1d90baff6ed36ce4a", 95 | "sha256:eef9df1eefada2c09a5e7a40991b9fc6ac6ef20b1372abd48d2794a316dc0449", 96 | "sha256:f058f6963fd82eb143c692cecdc89e075fa0828db2e5b291070485390b2f1c9c", 97 | "sha256:f25c229a6ba38a35ae6e25ca1264621cc25d4d38dca2942a7fce0b67a4efe918", 98 | "sha256:f2a1d0fd4242bd8643ce6f98927cf9c04540af6efa92323e9d3124f57727bfc1", 99 | "sha256:f7560358a6811e52e9c4d142d497f1a6e10103d3a6881f18d04dbce3729c0e2c", 100 | "sha256:f779d3ad205f108d14e99bb3859aa7dd8e9c68874617c72354d7ecaec2a054ac", 101 | "sha256:f87f746ee241d30d6ed93969de31e5ffd09a2961a051e60ae6bddde9ec3583aa" 102 | ], 103 | "markers": "python_version >= '3.7'", 104 | "version": "==3.2.0" 105 | }, 106 | "e1839a8": { 107 | "editable": true, 108 | "path": "." 109 | }, 110 | "idna": { 111 | "hashes": [ 112 | "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", 113 | "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2" 114 | ], 115 | "markers": "python_version >= '3.5'", 116 | "version": "==3.4" 117 | }, 118 | "pbr": { 119 | "hashes": [ 120 | "sha256:567f09558bae2b3ab53cb3c1e2e33e726ff3338e7bae3db5dc954b3a44eef12b", 121 | "sha256:aefc51675b0b533d56bb5fd1c8c6c0522fe31896679882e1c4c63d5e4a0fccb3" 122 | ], 123 | "markers": "python_version >= '2.6'", 124 | "version": "==5.11.1" 125 | }, 126 | "requests": { 127 | "hashes": [ 128 | "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", 129 | "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1" 130 | ], 131 | "index": "pypi", 132 | "version": "==2.31.0" 133 | }, 134 | "sanitized-package": { 135 | "editable": true, 136 | "path": "." 137 | }, 138 | "typing-extensions": { 139 | "hashes": [ 140 | "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36", 141 | "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2" 142 | ], 143 | "index": "pypi", 144 | "version": "==4.7.1" 145 | }, 146 | "urllib3": { 147 | "hashes": [ 148 | "sha256:8d22f86aae8ef5e410d4f539fde9ce6b2113a001bb4d189e0aed70642d602b11", 149 | "sha256:de7df1803967d2c2a98e4b11bb7d6bd9210474c46e8a0401514e3a42a75ebde4" 150 | ], 151 | "markers": "python_version >= '3.7'", 152 | "version": "==2.0.4" 153 | } 154 | }, 155 | "develop": { 156 | "black": { 157 | "hashes": [ 158 | "sha256:01ede61aac8c154b55f35301fac3e730baf0c9cf8120f65a9cd61a81cfb4a0c3", 159 | "sha256:022a582720b0d9480ed82576c920a8c1dde97cc38ff11d8d8859b3bd6ca9eedb", 160 | "sha256:25cc308838fe71f7065df53aedd20327969d05671bac95b38fdf37ebe70ac087", 161 | "sha256:27eb7a0c71604d5de083757fbdb245b1a4fae60e9596514c6ec497eb63f95320", 162 | "sha256:327a8c2550ddc573b51e2c352adb88143464bb9d92c10416feb86b0f5aee5ff6", 163 | "sha256:47e56d83aad53ca140da0af87678fb38e44fd6bc0af71eebab2d1f59b1acf1d3", 164 | "sha256:501387a9edcb75d7ae8a4412bb8749900386eaef258f1aefab18adddea1936bc", 165 | "sha256:552513d5cd5694590d7ef6f46e1767a4df9af168d449ff767b13b084c020e63f", 166 | "sha256:5c4bc552ab52f6c1c506ccae05681fab58c3f72d59ae6e6639e8885e94fe2587", 167 | "sha256:642496b675095d423f9b8448243336f8ec71c9d4d57ec17bf795b67f08132a91", 168 | "sha256:6d1c6022b86f83b632d06f2b02774134def5d4d4f1dac8bef16d90cda18ba28a", 169 | "sha256:7f3bf2dec7d541b4619b8ce526bda74a6b0bffc480a163fed32eb8b3c9aed8ad", 170 | "sha256:831d8f54c3a8c8cf55f64d0422ee875eecac26f5f649fb6c1df65316b67c8926", 171 | "sha256:8417dbd2f57b5701492cd46edcecc4f9208dc75529bcf76c514864e48da867d9", 172 | "sha256:86cee259349b4448adb4ef9b204bb4467aae74a386bce85d56ba4f5dc0da27be", 173 | "sha256:893695a76b140881531062d48476ebe4a48f5d1e9388177e175d76234ca247cd", 174 | "sha256:9fd59d418c60c0348505f2ddf9609c1e1de8e7493eab96198fc89d9f865e7a96", 175 | "sha256:ad0014efc7acf0bd745792bd0d8857413652979200ab924fbf239062adc12491", 176 | "sha256:b5b0ee6d96b345a8b420100b7d71ebfdd19fab5e8301aff48ec270042cd40ac2", 177 | "sha256:c333286dc3ddca6fdff74670b911cccedacb4ef0a60b34e491b8a67c833b343a", 178 | "sha256:f9062af71c59c004cd519e2fb8f5d25d39e46d3af011b41ab43b9c74e27e236f", 179 | "sha256:fb074d8b213749fa1d077d630db0d5f8cc3b2ae63587ad4116e8a436e9bbe995" 180 | ], 181 | "index": "pypi", 182 | "markers": "python_version >= '3.6.2'", 183 | "version": "==23.7.0" 184 | }, 185 | "certifi": { 186 | "hashes": [ 187 | "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082", 188 | "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9" 189 | ], 190 | "index": "pypi", 191 | "version": "==2023.7.22" 192 | }, 193 | "charset-normalizer": { 194 | "hashes": [ 195 | "sha256:04e57ab9fbf9607b77f7d057974694b4f6b142da9ed4a199859d9d4d5c63fe96", 196 | "sha256:09393e1b2a9461950b1c9a45d5fd251dc7c6f228acab64da1c9c0165d9c7765c", 197 | "sha256:0b87549028f680ca955556e3bd57013ab47474c3124dc069faa0b6545b6c9710", 198 | "sha256:1000fba1057b92a65daec275aec30586c3de2401ccdcd41f8a5c1e2c87078706", 199 | "sha256:1249cbbf3d3b04902ff081ffbb33ce3377fa6e4c7356f759f3cd076cc138d020", 200 | "sha256:1920d4ff15ce893210c1f0c0e9d19bfbecb7983c76b33f046c13a8ffbd570252", 201 | "sha256:193cbc708ea3aca45e7221ae58f0fd63f933753a9bfb498a3b474878f12caaad", 202 | "sha256:1a100c6d595a7f316f1b6f01d20815d916e75ff98c27a01ae817439ea7726329", 203 | "sha256:1f30b48dd7fa1474554b0b0f3fdfdd4c13b5c737a3c6284d3cdc424ec0ffff3a", 204 | "sha256:203f0c8871d5a7987be20c72442488a0b8cfd0f43b7973771640fc593f56321f", 205 | "sha256:246de67b99b6851627d945db38147d1b209a899311b1305dd84916f2b88526c6", 206 | "sha256:2dee8e57f052ef5353cf608e0b4c871aee320dd1b87d351c28764fc0ca55f9f4", 207 | "sha256:2efb1bd13885392adfda4614c33d3b68dee4921fd0ac1d3988f8cbb7d589e72a", 208 | "sha256:2f4ac36d8e2b4cc1aa71df3dd84ff8efbe3bfb97ac41242fbcfc053c67434f46", 209 | "sha256:3170c9399da12c9dc66366e9d14da8bf7147e1e9d9ea566067bbce7bb74bd9c2", 210 | "sha256:3b1613dd5aee995ec6d4c69f00378bbd07614702a315a2cf6c1d21461fe17c23", 211 | "sha256:3bb3d25a8e6c0aedd251753a79ae98a093c7e7b471faa3aa9a93a81431987ace", 212 | "sha256:3bb7fda7260735efe66d5107fb7e6af6a7c04c7fce9b2514e04b7a74b06bf5dd", 213 | "sha256:41b25eaa7d15909cf3ac4c96088c1f266a9a93ec44f87f1d13d4a0e86c81b982", 214 | "sha256:45de3f87179c1823e6d9e32156fb14c1927fcc9aba21433f088fdfb555b77c10", 215 | "sha256:46fb8c61d794b78ec7134a715a3e564aafc8f6b5e338417cb19fe9f57a5a9bf2", 216 | "sha256:48021783bdf96e3d6de03a6e39a1171ed5bd7e8bb93fc84cc649d11490f87cea", 217 | "sha256:4957669ef390f0e6719db3613ab3a7631e68424604a7b448f079bee145da6e09", 218 | "sha256:5e86d77b090dbddbe78867a0275cb4df08ea195e660f1f7f13435a4649e954e5", 219 | "sha256:6339d047dab2780cc6220f46306628e04d9750f02f983ddb37439ca47ced7149", 220 | "sha256:681eb3d7e02e3c3655d1b16059fbfb605ac464c834a0c629048a30fad2b27489", 221 | "sha256:6c409c0deba34f147f77efaa67b8e4bb83d2f11c8806405f76397ae5b8c0d1c9", 222 | "sha256:7095f6fbfaa55defb6b733cfeb14efaae7a29f0b59d8cf213be4e7ca0b857b80", 223 | "sha256:70c610f6cbe4b9fce272c407dd9d07e33e6bf7b4aa1b7ffb6f6ded8e634e3592", 224 | "sha256:72814c01533f51d68702802d74f77ea026b5ec52793c791e2da806a3844a46c3", 225 | "sha256:7a4826ad2bd6b07ca615c74ab91f32f6c96d08f6fcc3902ceeedaec8cdc3bcd6", 226 | "sha256:7c70087bfee18a42b4040bb9ec1ca15a08242cf5867c58726530bdf3945672ed", 227 | "sha256:855eafa5d5a2034b4621c74925d89c5efef61418570e5ef9b37717d9c796419c", 228 | "sha256:8700f06d0ce6f128de3ccdbc1acaea1ee264d2caa9ca05daaf492fde7c2a7200", 229 | "sha256:89f1b185a01fe560bc8ae5f619e924407efca2191b56ce749ec84982fc59a32a", 230 | "sha256:8b2c760cfc7042b27ebdb4a43a4453bd829a5742503599144d54a032c5dc7e9e", 231 | "sha256:8c2f5e83493748286002f9369f3e6607c565a6a90425a3a1fef5ae32a36d749d", 232 | "sha256:8e098148dd37b4ce3baca71fb394c81dc5d9c7728c95df695d2dca218edf40e6", 233 | "sha256:94aea8eff76ee6d1cdacb07dd2123a68283cb5569e0250feab1240058f53b623", 234 | "sha256:95eb302ff792e12aba9a8b8f8474ab229a83c103d74a750ec0bd1c1eea32e669", 235 | "sha256:9bd9b3b31adcb054116447ea22caa61a285d92e94d710aa5ec97992ff5eb7cf3", 236 | "sha256:9e608aafdb55eb9f255034709e20d5a83b6d60c054df0802fa9c9883d0a937aa", 237 | "sha256:a103b3a7069b62f5d4890ae1b8f0597618f628b286b03d4bc9195230b154bfa9", 238 | "sha256:a386ebe437176aab38c041de1260cd3ea459c6ce5263594399880bbc398225b2", 239 | "sha256:a38856a971c602f98472050165cea2cdc97709240373041b69030be15047691f", 240 | "sha256:a401b4598e5d3f4a9a811f3daf42ee2291790c7f9d74b18d75d6e21dda98a1a1", 241 | "sha256:a7647ebdfb9682b7bb97e2a5e7cb6ae735b1c25008a70b906aecca294ee96cf4", 242 | "sha256:aaf63899c94de41fe3cf934601b0f7ccb6b428c6e4eeb80da72c58eab077b19a", 243 | "sha256:b0dac0ff919ba34d4df1b6131f59ce95b08b9065233446be7e459f95554c0dc8", 244 | "sha256:baacc6aee0b2ef6f3d308e197b5d7a81c0e70b06beae1f1fcacffdbd124fe0e3", 245 | "sha256:bf420121d4c8dce6b889f0e8e4ec0ca34b7f40186203f06a946fa0276ba54029", 246 | "sha256:c04a46716adde8d927adb9457bbe39cf473e1e2c2f5d0a16ceb837e5d841ad4f", 247 | "sha256:c0b21078a4b56965e2b12f247467b234734491897e99c1d51cee628da9786959", 248 | "sha256:c1c76a1743432b4b60ab3358c937a3fe1341c828ae6194108a94c69028247f22", 249 | "sha256:c4983bf937209c57240cff65906b18bb35e64ae872da6a0db937d7b4af845dd7", 250 | "sha256:c4fb39a81950ec280984b3a44f5bd12819953dc5fa3a7e6fa7a80db5ee853952", 251 | "sha256:c57921cda3a80d0f2b8aec7e25c8aa14479ea92b5b51b6876d975d925a2ea346", 252 | "sha256:c8063cf17b19661471ecbdb3df1c84f24ad2e389e326ccaf89e3fb2484d8dd7e", 253 | "sha256:ccd16eb18a849fd8dcb23e23380e2f0a354e8daa0c984b8a732d9cfaba3a776d", 254 | "sha256:cd6dbe0238f7743d0efe563ab46294f54f9bc8f4b9bcf57c3c666cc5bc9d1299", 255 | "sha256:d62e51710986674142526ab9f78663ca2b0726066ae26b78b22e0f5e571238dd", 256 | "sha256:db901e2ac34c931d73054d9797383d0f8009991e723dab15109740a63e7f902a", 257 | "sha256:e03b8895a6990c9ab2cdcd0f2fe44088ca1c65ae592b8f795c3294af00a461c3", 258 | "sha256:e1c8a2f4c69e08e89632defbfabec2feb8a8d99edc9f89ce33c4b9e36ab63037", 259 | "sha256:e4b749b9cc6ee664a3300bb3a273c1ca8068c46be705b6c31cf5d276f8628a94", 260 | "sha256:e6a5bf2cba5ae1bb80b154ed68a3cfa2fa00fde979a7f50d6598d3e17d9ac20c", 261 | "sha256:e857a2232ba53ae940d3456f7533ce6ca98b81917d47adc3c7fd55dad8fab858", 262 | "sha256:ee4006268ed33370957f55bf2e6f4d263eaf4dc3cfc473d1d90baff6ed36ce4a", 263 | "sha256:eef9df1eefada2c09a5e7a40991b9fc6ac6ef20b1372abd48d2794a316dc0449", 264 | "sha256:f058f6963fd82eb143c692cecdc89e075fa0828db2e5b291070485390b2f1c9c", 265 | "sha256:f25c229a6ba38a35ae6e25ca1264621cc25d4d38dca2942a7fce0b67a4efe918", 266 | "sha256:f2a1d0fd4242bd8643ce6f98927cf9c04540af6efa92323e9d3124f57727bfc1", 267 | "sha256:f7560358a6811e52e9c4d142d497f1a6e10103d3a6881f18d04dbce3729c0e2c", 268 | "sha256:f779d3ad205f108d14e99bb3859aa7dd8e9c68874617c72354d7ecaec2a054ac", 269 | "sha256:f87f746ee241d30d6ed93969de31e5ffd09a2961a051e60ae6bddde9ec3583aa" 270 | ], 271 | "markers": "python_version >= '3.7'", 272 | "version": "==3.2.0" 273 | }, 274 | "click": { 275 | "hashes": [ 276 | "sha256:48ee849951919527a045bfe3bf7baa8a959c423134e1a5b98c05c20ba75a1cbd", 277 | "sha256:fa244bb30b3b5ee2cae3da8f55c9e5e0c0e86093306301fb418eb9dc40fbded5" 278 | ], 279 | "markers": "python_version >= '3.7'", 280 | "version": "==8.1.6" 281 | }, 282 | "deprecation": { 283 | "hashes": [ 284 | "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff", 285 | "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a" 286 | ], 287 | "version": "==2.1.0" 288 | }, 289 | "docker": { 290 | "hashes": [ 291 | "sha256:aa6d17830045ba5ef0168d5eaa34d37beeb113948c413affe1d5991fc11f9a20", 292 | "sha256:aecd2277b8bf8e506e484f6ab7aec39abe0038e29fa4a6d3ba86c3fe01844ed9" 293 | ], 294 | "markers": "python_version >= '3.7'", 295 | "version": "==6.1.3" 296 | }, 297 | "e1839a8": { 298 | "editable": true, 299 | "path": "." 300 | }, 301 | "flake8": { 302 | "hashes": [ 303 | "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b", 304 | "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907" 305 | ], 306 | "index": "pypi", 307 | "version": "==3.9.2" 308 | }, 309 | "idna": { 310 | "hashes": [ 311 | "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", 312 | "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2" 313 | ], 314 | "markers": "python_version >= '3.5'", 315 | "version": "==3.4" 316 | }, 317 | "importlib-metadata": { 318 | "hashes": [ 319 | "sha256:3ebb78df84a805d7698245025b975d9d67053cd94c79245ba4b3eb694abe68bb", 320 | "sha256:dbace7892d8c0c4ac1ad096662232f831d4e64f4c4545bd53016a3e9d4654743" 321 | ], 322 | "index": "pypi", 323 | "markers": "python_version >= '3.6.0'", 324 | "version": "==6.8.0" 325 | }, 326 | "iniconfig": { 327 | "hashes": [ 328 | "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", 329 | "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" 330 | ], 331 | "markers": "python_version >= '3.7'", 332 | "version": "==2.0.0" 333 | }, 334 | "mccabe": { 335 | "hashes": [ 336 | "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", 337 | "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" 338 | ], 339 | "version": "==0.6.1" 340 | }, 341 | "mock": { 342 | "hashes": [ 343 | "sha256:18c694e5ae8a208cdb3d2c20a993ca1a7b0efa258c247a1e565150f477f83744", 344 | "sha256:5e96aad5ccda4718e0a229ed94b2024df75cc2d55575ba5762d31f5767b8767d" 345 | ], 346 | "index": "pypi", 347 | "version": "==5.1.0" 348 | }, 349 | "mypy-extensions": { 350 | "hashes": [ 351 | "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", 352 | "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782" 353 | ], 354 | "markers": "python_version >= '3.5'", 355 | "version": "==1.0.0" 356 | }, 357 | "packaging": { 358 | "hashes": [ 359 | "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61", 360 | "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f" 361 | ], 362 | "markers": "python_version >= '3.7'", 363 | "version": "==23.1" 364 | }, 365 | "pathspec": { 366 | "hashes": [ 367 | "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687", 368 | "sha256:d8af70af76652554bd134c22b3e8a1cc46ed7d91edcdd721ef1a0c51a84a5293" 369 | ], 370 | "markers": "python_version >= '3.7'", 371 | "version": "==0.11.1" 372 | }, 373 | "pbr": { 374 | "hashes": [ 375 | "sha256:567f09558bae2b3ab53cb3c1e2e33e726ff3338e7bae3db5dc954b3a44eef12b", 376 | "sha256:aefc51675b0b533d56bb5fd1c8c6c0522fe31896679882e1c4c63d5e4a0fccb3" 377 | ], 378 | "markers": "python_version >= '2.6'", 379 | "version": "==5.11.1" 380 | }, 381 | "platformdirs": { 382 | "hashes": [ 383 | "sha256:1b42b450ad933e981d56e59f1b97495428c9bd60698baab9f3eb3d00d5822421", 384 | "sha256:ad8291ae0ae5072f66c16945166cb11c63394c7a3ad1b1bc9828ca3162da8c2f" 385 | ], 386 | "markers": "python_version >= '3.7'", 387 | "version": "==3.9.1" 388 | }, 389 | "pluggy": { 390 | "hashes": [ 391 | "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849", 392 | "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3" 393 | ], 394 | "markers": "python_version >= '3.7'", 395 | "version": "==1.2.0" 396 | }, 397 | "pycodestyle": { 398 | "hashes": [ 399 | "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068", 400 | "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef" 401 | ], 402 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 403 | "version": "==2.7.0" 404 | }, 405 | "pydocstyle": { 406 | "hashes": [ 407 | "sha256:118762d452a49d6b05e194ef344a55822987a462831ade91ec5c06fd2169d019", 408 | "sha256:7ce43f0c0ac87b07494eb9c0b462c0b73e6ff276807f204d6b53edc72b7e44e1" 409 | ], 410 | "index": "pypi", 411 | "version": "==6.3.0" 412 | }, 413 | "pyflakes": { 414 | "hashes": [ 415 | "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3", 416 | "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db" 417 | ], 418 | "index": "pypi", 419 | "version": "==2.3.1" 420 | }, 421 | "pytest": { 422 | "hashes": [ 423 | "sha256:78bf16451a2eb8c7a2ea98e32dc119fd2aa758f1d5d66dbf0a59d69a3969df32", 424 | "sha256:b4bf8c45bd59934ed84001ad51e11b4ee40d40a1229d2c79f9c592b0a3f6bd8a" 425 | ], 426 | "index": "pypi", 427 | "version": "==7.4.0" 428 | }, 429 | "requests": { 430 | "hashes": [ 431 | "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", 432 | "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1" 433 | ], 434 | "index": "pypi", 435 | "version": "==2.31.0" 436 | }, 437 | "sanitized-package": { 438 | "editable": true, 439 | "path": "." 440 | }, 441 | "six": { 442 | "hashes": [ 443 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 444 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 445 | ], 446 | "index": "pypi", 447 | "version": "==1.16.0" 448 | }, 449 | "snowballstemmer": { 450 | "hashes": [ 451 | "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1", 452 | "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a" 453 | ], 454 | "version": "==2.2.0" 455 | }, 456 | "testcontainers": { 457 | "hashes": [ 458 | "sha256:cf810b0ef8b514cc161a8a85878ebc26943453e1838ada2c469d469d4082d2ba" 459 | ], 460 | "index": "pypi", 461 | "version": "==3.4.2" 462 | }, 463 | "urllib3": { 464 | "hashes": [ 465 | "sha256:8d22f86aae8ef5e410d4f539fde9ce6b2113a001bb4d189e0aed70642d602b11", 466 | "sha256:de7df1803967d2c2a98e4b11bb7d6bd9210474c46e8a0401514e3a42a75ebde4" 467 | ], 468 | "markers": "python_version >= '3.7'", 469 | "version": "==2.0.4" 470 | }, 471 | "websocket-client": { 472 | "hashes": [ 473 | "sha256:c951af98631d24f8df89ab1019fc365f2227c0892f12fd150e935607c79dd0dd", 474 | "sha256:f1f9f2ad5291f0225a49efad77abf9e700b6fef553900623060dad6e26503b9d" 475 | ], 476 | "markers": "python_version >= '3.7'", 477 | "version": "==1.6.1" 478 | }, 479 | "wrapt": { 480 | "hashes": [ 481 | "sha256:02fce1852f755f44f95af51f69d22e45080102e9d00258053b79367d07af39c0", 482 | "sha256:077ff0d1f9d9e4ce6476c1a924a3332452c1406e59d90a2cf24aeb29eeac9420", 483 | "sha256:078e2a1a86544e644a68422f881c48b84fef6d18f8c7a957ffd3f2e0a74a0d4a", 484 | "sha256:0970ddb69bba00670e58955f8019bec4a42d1785db3faa043c33d81de2bf843c", 485 | "sha256:1286eb30261894e4c70d124d44b7fd07825340869945c79d05bda53a40caa079", 486 | "sha256:21f6d9a0d5b3a207cdf7acf8e58d7d13d463e639f0c7e01d82cdb671e6cb7923", 487 | "sha256:230ae493696a371f1dbffaad3dafbb742a4d27a0afd2b1aecebe52b740167e7f", 488 | "sha256:26458da5653aa5b3d8dc8b24192f574a58984c749401f98fff994d41d3f08da1", 489 | "sha256:2cf56d0e237280baed46f0b5316661da892565ff58309d4d2ed7dba763d984b8", 490 | "sha256:2e51de54d4fb8fb50d6ee8327f9828306a959ae394d3e01a1ba8b2f937747d86", 491 | "sha256:2fbfbca668dd15b744418265a9607baa970c347eefd0db6a518aaf0cfbd153c0", 492 | "sha256:38adf7198f8f154502883242f9fe7333ab05a5b02de7d83aa2d88ea621f13364", 493 | "sha256:3a8564f283394634a7a7054b7983e47dbf39c07712d7b177b37e03f2467a024e", 494 | "sha256:3abbe948c3cbde2689370a262a8d04e32ec2dd4f27103669a45c6929bcdbfe7c", 495 | "sha256:3bbe623731d03b186b3d6b0d6f51865bf598587c38d6f7b0be2e27414f7f214e", 496 | "sha256:40737a081d7497efea35ab9304b829b857f21558acfc7b3272f908d33b0d9d4c", 497 | "sha256:41d07d029dd4157ae27beab04d22b8e261eddfc6ecd64ff7000b10dc8b3a5727", 498 | "sha256:46ed616d5fb42f98630ed70c3529541408166c22cdfd4540b88d5f21006b0eff", 499 | "sha256:493d389a2b63c88ad56cdc35d0fa5752daac56ca755805b1b0c530f785767d5e", 500 | "sha256:4ff0d20f2e670800d3ed2b220d40984162089a6e2c9646fdb09b85e6f9a8fc29", 501 | "sha256:54accd4b8bc202966bafafd16e69da9d5640ff92389d33d28555c5fd4f25ccb7", 502 | "sha256:56374914b132c702aa9aa9959c550004b8847148f95e1b824772d453ac204a72", 503 | "sha256:578383d740457fa790fdf85e6d346fda1416a40549fe8db08e5e9bd281c6a475", 504 | "sha256:58d7a75d731e8c63614222bcb21dd992b4ab01a399f1f09dd82af17bbfc2368a", 505 | "sha256:5c5aa28df055697d7c37d2099a7bc09f559d5053c3349b1ad0c39000e611d317", 506 | "sha256:5fc8e02f5984a55d2c653f5fea93531e9836abbd84342c1d1e17abc4a15084c2", 507 | "sha256:63424c681923b9f3bfbc5e3205aafe790904053d42ddcc08542181a30a7a51bd", 508 | "sha256:64b1df0f83706b4ef4cfb4fb0e4c2669100fd7ecacfb59e091fad300d4e04640", 509 | "sha256:74934ebd71950e3db69960a7da29204f89624dde411afbfb3b4858c1409b1e98", 510 | "sha256:75669d77bb2c071333417617a235324a1618dba66f82a750362eccbe5b61d248", 511 | "sha256:75760a47c06b5974aa5e01949bf7e66d2af4d08cb8c1d6516af5e39595397f5e", 512 | "sha256:76407ab327158c510f44ded207e2f76b657303e17cb7a572ffe2f5a8a48aa04d", 513 | "sha256:76e9c727a874b4856d11a32fb0b389afc61ce8aaf281ada613713ddeadd1cfec", 514 | "sha256:77d4c1b881076c3ba173484dfa53d3582c1c8ff1f914c6461ab70c8428b796c1", 515 | "sha256:780c82a41dc493b62fc5884fb1d3a3b81106642c5c5c78d6a0d4cbe96d62ba7e", 516 | "sha256:7dc0713bf81287a00516ef43137273b23ee414fe41a3c14be10dd95ed98a2df9", 517 | "sha256:7eebcdbe3677e58dd4c0e03b4f2cfa346ed4049687d839adad68cc38bb559c92", 518 | "sha256:896689fddba4f23ef7c718279e42f8834041a21342d95e56922e1c10c0cc7afb", 519 | "sha256:96177eb5645b1c6985f5c11d03fc2dbda9ad24ec0f3a46dcce91445747e15094", 520 | "sha256:96e25c8603a155559231c19c0349245eeb4ac0096fe3c1d0be5c47e075bd4f46", 521 | "sha256:9d37ac69edc5614b90516807de32d08cb8e7b12260a285ee330955604ed9dd29", 522 | "sha256:9ed6aa0726b9b60911f4aed8ec5b8dd7bf3491476015819f56473ffaef8959bd", 523 | "sha256:a487f72a25904e2b4bbc0817ce7a8de94363bd7e79890510174da9d901c38705", 524 | "sha256:a4cbb9ff5795cd66f0066bdf5947f170f5d63a9274f99bdbca02fd973adcf2a8", 525 | "sha256:a74d56552ddbde46c246b5b89199cb3fd182f9c346c784e1a93e4dc3f5ec9975", 526 | "sha256:a89ce3fd220ff144bd9d54da333ec0de0399b52c9ac3d2ce34b569cf1a5748fb", 527 | "sha256:abd52a09d03adf9c763d706df707c343293d5d106aea53483e0ec8d9e310ad5e", 528 | "sha256:abd8f36c99512755b8456047b7be10372fca271bf1467a1caa88db991e7c421b", 529 | "sha256:af5bd9ccb188f6a5fdda9f1f09d9f4c86cc8a539bd48a0bfdc97723970348418", 530 | "sha256:b02f21c1e2074943312d03d243ac4388319f2456576b2c6023041c4d57cd7019", 531 | "sha256:b06fa97478a5f478fb05e1980980a7cdf2712015493b44d0c87606c1513ed5b1", 532 | "sha256:b0724f05c396b0a4c36a3226c31648385deb6a65d8992644c12a4963c70326ba", 533 | "sha256:b130fe77361d6771ecf5a219d8e0817d61b236b7d8b37cc045172e574ed219e6", 534 | "sha256:b56d5519e470d3f2fe4aa7585f0632b060d532d0696c5bdfb5e8319e1d0f69a2", 535 | "sha256:b67b819628e3b748fd3c2192c15fb951f549d0f47c0449af0764d7647302fda3", 536 | "sha256:ba1711cda2d30634a7e452fc79eabcadaffedf241ff206db2ee93dd2c89a60e7", 537 | "sha256:bbeccb1aa40ab88cd29e6c7d8585582c99548f55f9b2581dfc5ba68c59a85752", 538 | "sha256:bd84395aab8e4d36263cd1b9308cd504f6cf713b7d6d3ce25ea55670baec5416", 539 | "sha256:c99f4309f5145b93eca6e35ac1a988f0dc0a7ccf9ccdcd78d3c0adf57224e62f", 540 | "sha256:ca1cccf838cd28d5a0883b342474c630ac48cac5df0ee6eacc9c7290f76b11c1", 541 | "sha256:cd525e0e52a5ff16653a3fc9e3dd827981917d34996600bbc34c05d048ca35cc", 542 | "sha256:cdb4f085756c96a3af04e6eca7f08b1345e94b53af8921b25c72f096e704e145", 543 | "sha256:ce42618f67741d4697684e501ef02f29e758a123aa2d669e2d964ff734ee00ee", 544 | "sha256:d06730c6aed78cee4126234cf2d071e01b44b915e725a6cb439a879ec9754a3a", 545 | "sha256:d5fe3e099cf07d0fb5a1e23d399e5d4d1ca3e6dfcbe5c8570ccff3e9208274f7", 546 | "sha256:d6bcbfc99f55655c3d93feb7ef3800bd5bbe963a755687cbf1f490a71fb7794b", 547 | "sha256:d787272ed958a05b2c86311d3a4135d3c2aeea4fc655705f074130aa57d71653", 548 | "sha256:e169e957c33576f47e21864cf3fc9ff47c223a4ebca8960079b8bd36cb014fd0", 549 | "sha256:e20076a211cd6f9b44a6be58f7eeafa7ab5720eb796975d0c03f05b47d89eb90", 550 | "sha256:e826aadda3cae59295b95343db8f3d965fb31059da7de01ee8d1c40a60398b29", 551 | "sha256:eef4d64c650f33347c1f9266fa5ae001440b232ad9b98f1f43dfe7a79435c0a6", 552 | "sha256:f2e69b3ed24544b0d3dbe2c5c0ba5153ce50dcebb576fdc4696d52aa22db6034", 553 | "sha256:f87ec75864c37c4c6cb908d282e1969e79763e0d9becdfe9fe5473b7bb1e5f09", 554 | "sha256:fbec11614dba0424ca72f4e8ba3c420dba07b4a7c206c8c8e4e73f2e98f4c559", 555 | "sha256:fd69666217b62fa5d7c6aa88e507493a34dec4fa20c5bd925e4bc12fce586639" 556 | ], 557 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 558 | "version": "==1.15.0" 559 | }, 560 | "zipp": { 561 | "hashes": [ 562 | "sha256:679e51dd4403591b2d6838a48de3d283f3d188412a9782faadf845f298736ba0", 563 | "sha256:ebc15946aa78bd63458992fc81ec3b6f7b1e92d51c35e6de1c3804e73b799147" 564 | ], 565 | "markers": "python_version >= '3.8'", 566 | "version": "==3.16.2" 567 | } 568 | } 569 | } 570 | --------------------------------------------------------------------------------