From 0acbc1d58734a9cddf6f4ab770e84a5df99c8e1b Mon Sep 17 00:00:00 2001 From: Ralph Schmieder Date: Tue, 18 May 2021 10:13:03 +0200 Subject: [PATCH] Missing changes from Bitbucket - added requests-toolbelt (missing dependency), resolves https://github.com/CiscoDevNet/virl2-client/issues/10 - also added pytest configuration files - added fixtures for tests to pass - added tests which were missing from Bitbucket - applied black formatting - GitHub test automation - assure 3.5 compatibility - remove hashes from requirements.txt --- .github/workflows/main.yml | 45 ++ .gitignore | 2 +- Makefile | 14 + README.md | 2 + conftest.py | 107 ++++ docs/source/api/virl2_client.models.rst | 99 +++ docs/source/{ => api}/virl2_client.rst | 25 +- docs/source/modules.rst | 7 - docs/source/virl2_client.models.rst | 58 -- poetry.lock | 596 ++++++++++-------- pyproject.toml | 5 +- pytest.ini | 28 + tests/requirements.txt | 45 ++ tests/test_client_library.py | 434 +++++++++---- tests/test_client_library_integration.py | 428 ++++++++++++- tests/test_data/sample_topology.json | 43 ++ tests/test_group_api.py | 736 +++++++++++++++++++++++ tests/test_rate_limit.py | 18 + virl2_client/__init__.py | 5 +- virl2_client/models/__init__.py | 2 +- virl2_client/models/groups.py | 8 +- virl2_client/models/users.py | 7 +- virl2_client/utils.py | 2 +- 23 files changed, 2257 insertions(+), 459 deletions(-) create mode 100644 .github/workflows/main.yml create mode 100644 conftest.py create mode 100644 docs/source/api/virl2_client.models.rst rename docs/source/{ => api}/virl2_client.rst (53%) delete mode 100644 docs/source/modules.rst delete mode 100644 docs/source/virl2_client.models.rst create mode 100644 pytest.ini create mode 100644 tests/requirements.txt create mode 100644 tests/test_data/sample_topology.json create mode 100644 tests/test_group_api.py create mode 100644 tests/test_rate_limit.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..44d5f0a --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,45 @@ +# basic workflow to run tests + +name: CI + +on: + + # Triggers the workflow on push or pull request events but only for the master branch + push: + branches: [ master ] + pull_request: + branches: [ master ] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.5, 3.6, 3.7, 3.8] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + # pip install flake8 pytest + # if [ -f tests/requirements.txt ]; then pip install -r tests/requirements.txt; fi + pip install -r tests/requirements.txt + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + run: | + pytest + diff --git a/.gitignore b/.gitignore index 22ca86f..c6b4280 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ -.direnv/ .envrc .python-version +.venv dist/ */__pycache__ docs/build/* diff --git a/Makefile b/Makefile index e4ce18b..cd167e9 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,19 @@ + +.phony: export + +# https://github.com/python-poetry/poetry/issues/3160 +# when resolved, we should be able to run with hashes +tests/requirements.txt: poetry.lock + poetry export --format=requirements.txt --dev --without-hashes --output=$@ + clean: rm -rf dist virl2_client.egg-info .built find . -type f -name '*.pyc' -exec rm {} \; || true find . -type d -name '__pycache__' -exec rmdir {} \; || true cd docs && make clean + +poetry: + poetry update + +export: tests/requirements.txt + @echo "exported dependencies" diff --git a/README.md b/README.md index cd00bac..23bf402 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +[![CI](https://github.com/CiscoDevNet/virl2-client/actions/workflows/main.yml/badge.svg)](https://github.com/CiscoDevNet/virl2-client/actions/workflows/main.yml) + # VIRL 2 Client Library ## Introduction diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..8801f8b --- /dev/null +++ b/conftest.py @@ -0,0 +1,107 @@ +# +# Python bindings for the Cisco VIRL 2 Network Simulation Platform +# +# This file is part of VIRL 2 +# +# Copyright 2020-2021 Cisco Systems Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import pytest +import time +import warnings + +from requests import HTTPError +from unittest.mock import patch +from urllib3.exceptions import InsecureRequestWarning + +from virl2_client import ClientLibrary + + +def pytest_addoption(parser): + parser.addoption( + "--controller-url", + default="http://127.0.0.1:8001", + help="The URL of simple controller server", + ) + + +@pytest.fixture(scope="session") +def controller_url(request): + return request.config.getoption("--controller-url") + + +@pytest.fixture +def no_ssl_warnings(): + with warnings.catch_warnings(): + # We don't care about SSL connections to untrusted servers in tests: + warnings.simplefilter("ignore", InsecureRequestWarning) + yield + + +def stop_wipe_and_remove_all_labs(client_library: ClientLibrary): + lab_list = client_library.get_lab_list() + for lab_id in lab_list: + lab = client_library.join_existing_lab(lab_id) + lab.stop() + lab.wipe() + client_library.remove_lab(lab_id) + + +def client_library_keep_labs_base( + url, usr="cml2", pwd="cml2cml2", ssl_verify=False, allow_http=True +): + clientlibrary = ClientLibrary( + url, + username=usr, + password=pwd, + ssl_verify=ssl_verify, + allow_http=allow_http, + ) + for _ in range(5): + try: + clientlibrary.is_system_ready() + except HTTPError as err: + if err.errno == 504: + # system still initialising, wait longer + time.sleep(2) + + return clientlibrary + + +@pytest.fixture +def client_library_keep_labs(no_ssl_warnings, controller_url: str) -> ClientLibrary: + # for integration testing, the client library needs to connect to a mock simulator + # running via HTTP on a non SSL servr / non-standard port. We therefore need to + # set the allow_http to True. Otherwise the client library would enforce the HTTPS + # scheme and the tests would fail. This should never be required in the wild. + yield client_library_keep_labs_base(url=controller_url) + + +@pytest.fixture(scope="session") +def client_library_session(controller_url: str) -> ClientLibrary: + """This client library has session lifetime""" + yield client_library_keep_labs_base(url=controller_url) + + +@pytest.fixture +def client_library(client_library_keep_labs: ClientLibrary) -> ClientLibrary: + clientlibrary = client_library_keep_labs + stop_wipe_and_remove_all_labs(clientlibrary) + # Reset "current" lab: + clientlibrary.lab = None + yield clientlibrary + # tear down - delete labs from the tests + # TODO: see if these need updating now remove_all_labs doesnt stop the lab + stop_wipe_and_remove_all_labs(clientlibrary) diff --git a/docs/source/api/virl2_client.models.rst b/docs/source/api/virl2_client.models.rst new file mode 100644 index 0000000..7c57261 --- /dev/null +++ b/docs/source/api/virl2_client.models.rst @@ -0,0 +1,99 @@ +virl2\_client.models package +============================ + +.. automodule:: virl2_client.models + :members: + :undoc-members: + :show-inheritance: + +Submodules +---------- + +virl2\_client.models.authentication +----------------------------------- + +.. automodule:: virl2_client.models.authentication + :members: + :undoc-members: + :show-inheritance: + +virl2\_client.models.cl\_pyats +------------------------------ + +.. automodule:: virl2_client.models.cl_pyats + :members: + :undoc-members: + :show-inheritance: + +virl2\_client.models.groups +--------------------------- + +.. automodule:: virl2_client.models.groups + :members: + :undoc-members: + :show-inheritance: + +virl2\_client.models.interface +------------------------------ + +.. automodule:: virl2_client.models.interface + :members: + :undoc-members: + :show-inheritance: + +virl2\_client.models.lab +------------------------ + +.. automodule:: virl2_client.models.lab + :members: + :undoc-members: + :show-inheritance: + +virl2\_client.models.licensing +------------------------------ + +.. automodule:: virl2_client.models.licensing + :members: + :undoc-members: + :show-inheritance: + +virl2\_client.models.link +------------------------- + +.. automodule:: virl2_client.models.link + :members: + :undoc-members: + :show-inheritance: + +virl2\_client.models.node +------------------------- + +.. automodule:: virl2_client.models.node + :members: + :undoc-members: + :show-inheritance: + +virl2\_client.models.node\_image\_definitions +--------------------------------------------- + +.. automodule:: virl2_client.models.node_image_definitions + :members: + :undoc-members: + :show-inheritance: + +virl2\_client.models.system +--------------------------- + +.. automodule:: virl2_client.models.system + :members: + :undoc-members: + :show-inheritance: + +virl2\_client.models.users +-------------------------- + +.. automodule:: virl2_client.models.users + :members: + :undoc-members: + :show-inheritance: + diff --git a/docs/source/virl2_client.rst b/docs/source/api/virl2_client.rst similarity index 53% rename from docs/source/virl2_client.rst rename to docs/source/api/virl2_client.rst index d873242..abf52f9 100644 --- a/docs/source/virl2_client.rst +++ b/docs/source/api/virl2_client.rst @@ -1,5 +1,10 @@ -VIRL :sup:`2` API Client -========================= +virl2\_client package +===================== + +.. automodule:: virl2_client + :members: + :undoc-members: + :show-inheritance: Subpackages ----------- @@ -8,26 +13,30 @@ Subpackages virl2_client.models -Exceptions ------------ +Submodules +---------- + +virl2\_client.exceptions +------------------------ .. automodule:: virl2_client.exceptions :members: :undoc-members: :show-inheritance: -Utils ------- +virl2\_client.utils +------------------- .. automodule:: virl2_client.utils :members: :undoc-members: :show-inheritance: -ClientLibrary --------------- +virl2\_client.virl2\_client +--------------------------- .. automodule:: virl2_client.virl2_client :members: :undoc-members: :show-inheritance: + diff --git a/docs/source/modules.rst b/docs/source/modules.rst deleted file mode 100644 index cb6a6a9..0000000 --- a/docs/source/modules.rst +++ /dev/null @@ -1,7 +0,0 @@ -VIRL :sup:`2` API Client -========================= - -.. toctree:: - :maxdepth: 4 - - virl2_client diff --git a/docs/source/virl2_client.models.rst b/docs/source/virl2_client.models.rst deleted file mode 100644 index cb76c96..0000000 --- a/docs/source/virl2_client.models.rst +++ /dev/null @@ -1,58 +0,0 @@ -VIRL :sup:`2` Modules and Models -================================= - -Authentication Module ----------------------- - -.. automodule:: virl2_client.models.authentication - :members: - :undoc-members: - :show-inheritance: - -pyATS Module -------------- - -.. automodule:: virl2_client.models.cl_pyats - :members: - :undoc-members: - :show-inheritance: - -Interface Model ----------------- - -.. automodule:: virl2_client.models.interface - :members: - :undoc-members: - :show-inheritance: - -Lab Model ----------- - -.. automodule:: virl2_client.models.lab - :members: - :undoc-members: - :show-inheritance: - -Link Model ------------ - -.. automodule:: virl2_client.models.link - :members: - :undoc-members: - :show-inheritance: - -Node Model ------------ - -.. automodule:: virl2_client.models.node - :members: - :undoc-members: - :show-inheritance: - -Node and Image Definitions Model ---------------------------------- - -.. automodule:: virl2_client.models.node_image_definitions - :members: - :undoc-members: - :show-inheritance: diff --git a/poetry.lock b/poetry.lock index e362908..56b80bf 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,136 +1,150 @@ [[package]] -category = "dev" -description = "A configurable sidebar-enabled Sphinx theme" name = "alabaster" +version = "0.7.12" +description = "A configurable sidebar-enabled Sphinx theme" +category = "dev" optional = false python-versions = "*" -version = "0.7.12" [[package]] -category = "dev" -description = "Atomic file writes." -marker = "sys_platform == \"win32\"" name = "atomicwrites" +version = "1.4.0" +description = "Atomic file writes." +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "1.4.0" [[package]] -category = "dev" -description = "Classes Without Boilerplate" name = "attrs" +version = "21.2.0" +description = "Classes Without Boilerplate" +category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "19.3.0" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [package.extras] -azure-pipelines = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "pytest-azurepipelines"] -dev = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "pre-commit"] -docs = ["sphinx", "zope.interface"] -tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"] +docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"] [[package]] -category = "dev" -description = "Internationalization utilities" name = "babel" +version = "2.9.1" +description = "Internationalization utilities" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.8.0" [package.dependencies] pytz = ">=2015.7" [[package]] -category = "main" -description = "Python package for providing Mozilla's CA Bundle." name = "certifi" +version = "2020.12.5" +description = "Python package for providing Mozilla's CA Bundle." +category = "main" optional = false python-versions = "*" -version = "2020.6.20" [[package]] -category = "main" -description = "Universal encoding detector for Python 2 and 3" name = "chardet" +version = "4.0.0" +description = "Universal encoding detector for Python 2 and 3" +category = "main" optional = false -python-versions = "*" -version = "3.0.4" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] -category = "dev" -description = "Cross-platform colored terminal text." -marker = "sys_platform == \"win32\"" name = "colorama" +version = "0.4.4" +description = "Cross-platform colored terminal text." +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "0.4.3" [[package]] -category = "dev" -description = "Code coverage measurement for Python" name = "coverage" +version = "5.5" +description = "Code coverage measurement for Python" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" -version = "5.2.1" + +[package.dependencies] +toml = {version = "*", optional = true, markers = "extra == \"toml\""} [package.extras] toml = ["toml"] [[package]] -category = "dev" -description = "Docutils -- Python Documentation Utilities" name = "docutils" +version = "0.16" +description = "Docutils -- Python Documentation Utilities" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "0.16" [[package]] -category = "main" -description = "Internationalized Domain Names in Applications (IDNA)" +name = "flake8" +version = "3.9.2" +description = "the modular source code checker: pep8 pyflakes and co" +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" + +[package.dependencies] +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} +mccabe = ">=0.6.0,<0.7.0" +pycodestyle = ">=2.7.0,<2.8.0" +pyflakes = ">=2.3.0,<2.4.0" + +[[package]] name = "idna" +version = "2.10" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.10" [[package]] -category = "dev" -description = "Getting image size from png/jpeg/jpeg2000/gif file" name = "imagesize" +version = "1.2.0" +description = "Getting image size from png/jpeg/jpeg2000/gif file" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "1.2.0" [[package]] -category = "dev" -description = "Read metadata from Python packages" -marker = "python_version < \"3.8\"" name = "importlib-metadata" +version = "2.1.1" +description = "Read metadata from Python packages" +category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" -version = "1.7.0" [package.dependencies] zipp = ">=0.5" [package.extras] docs = ["sphinx", "rst.linker"] -testing = ["packaging", "pep517", "importlib-resources (>=1.3)"] +testing = ["packaging", "pep517", "unittest2", "importlib-resources (>=1.3)"] [[package]] -category = "dev" -description = "iniconfig: brain-dead simple config-ini parsing" name = "iniconfig" +version = "1.1.1" +description = "iniconfig: brain-dead simple config-ini parsing" +category = "dev" optional = false python-versions = "*" -version = "1.0.1" [[package]] -category = "dev" -description = "A very fast and expressive template engine." name = "jinja2" +version = "2.11.3" +description = "A very fast and expressive template engine." +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "2.11.2" [package.dependencies] MarkupSafe = ">=0.23" @@ -139,207 +153,239 @@ MarkupSafe = ">=0.23" i18n = ["Babel (>=0.8)"] [[package]] -category = "dev" -description = "Safely add untrusted strings to HTML/XML markup." name = "markupsafe" +version = "1.1.1" +description = "Safely add untrusted strings to HTML/XML markup." +category = "dev" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" -version = "1.1.1" [[package]] +name = "mccabe" +version = "0.6.1" +description = "McCabe checker, plugin for flake8" category = "dev" -description = "More routines for operating on iterables, beyond itertools" -name = "more-itertools" optional = false -python-versions = ">=3.5" -version = "8.4.0" +python-versions = "*" [[package]] -category = "dev" -description = "Core utilities for Python packages" name = "packaging" +version = "20.9" +description = "Core utilities for Python packages" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "20.4" [package.dependencies] pyparsing = ">=2.0.2" -six = "*" [[package]] -category = "dev" -description = "Object-oriented filesystem paths" -marker = "python_version < \"3.6\"" name = "pathlib2" +version = "2.3.5" +description = "Object-oriented filesystem paths" +category = "dev" optional = false python-versions = "*" -version = "2.3.5" [package.dependencies] six = "*" [[package]] -category = "dev" -description = "plugin and hook calling mechanisms for python" name = "pluggy" +version = "0.13.1" +description = "plugin and hook calling mechanisms for python" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "0.13.1" [package.dependencies] -[package.dependencies.importlib-metadata] -python = "<3.8" -version = ">=0.12" +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} [package.extras] dev = ["pre-commit", "tox"] [[package]] -category = "dev" -description = "library with cross-python path, ini-parsing, io, code, log facilities" name = "py" +version = "1.10.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "1.9.0" [[package]] +name = "pycodestyle" +version = "2.7.0" +description = "Python style guide checker" category = "dev" -description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pyflakes" +version = "2.3.1" +description = "passive checker of Python programs" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] name = "pygments" +version = "2.9.0" +description = "Pygments is a syntax highlighting package written in Python." +category = "dev" optional = false python-versions = ">=3.5" -version = "2.6.1" [[package]] -category = "dev" -description = "Python parsing module" name = "pyparsing" +version = "2.4.7" +description = "Python parsing module" +category = "dev" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -version = "2.4.7" [[package]] -category = "dev" -description = "pytest: simple powerful testing with Python" name = "pytest" +version = "6.1.2" +description = "pytest: simple powerful testing with Python" +category = "dev" optional = false python-versions = ">=3.5" -version = "6.0.1" [package.dependencies] -atomicwrites = ">=1.0" +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} attrs = ">=17.4.0" -colorama = "*" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} iniconfig = "*" -more-itertools = ">=4.0.0" packaging = "*" +pathlib2 = {version = ">=2.2.0", markers = "python_version < \"3.6\""} pluggy = ">=0.12,<1.0" py = ">=1.8.2" toml = "*" -[package.dependencies.importlib-metadata] -python = "<3.8" -version = ">=0.12" - -[package.dependencies.pathlib2] -python = "<3.6" -version = ">=2.2.0" - [package.extras] -checkqa_mypy = ["mypy (0.780)"] +checkqa_mypy = ["mypy (==0.780)"] testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] [[package]] -category = "dev" -description = "Pytest plugin for measuring coverage." name = "pytest-cov" +version = "2.12.0" +description = "Pytest plugin for measuring coverage." +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "2.10.0" [package.dependencies] -coverage = ">=4.4" +coverage = {version = ">=5.2.1", extras = ["toml"]} pytest = ">=4.6" [package.extras] -testing = ["fields", "hunter", "process-tests (2.0.2)", "six", "pytest-xdist", "virtualenv"] +testing = ["fields", "hunter", "process-tests (==2.0.2)", "six", "pytest-xdist", "virtualenv"] [[package]] -category = "dev" -description = "World timezone definitions, modern and historical" name = "pytz" +version = "2021.1" +description = "World timezone definitions, modern and historical" +category = "dev" optional = false python-versions = "*" -version = "2020.1" [[package]] -category = "main" -description = "Python HTTP for Humans." name = "requests" +version = "2.25.1" +description = "Python HTTP for Humans." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "2.24.0" [package.dependencies] certifi = ">=2017.4.17" -chardet = ">=3.0.2,<4" +chardet = ">=3.0.2,<5" idna = ">=2.5,<3" -urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26" +urllib3 = ">=1.21.1,<1.27" [package.extras] security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] -socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7)", "win-inet-pton"] +socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] [[package]] +name = "requests-mock" +version = "1.9.2" +description = "Mock out responses from the requests package" category = "dev" -description = "A utility library for mocking out the `requests` Python library." +optional = false +python-versions = "*" + +[package.dependencies] +requests = ">=2.3,<3" +six = "*" + +[package.extras] +fixture = ["fixtures"] +test = ["fixtures", "mock", "purl", "pytest", "sphinx", "testrepository (>=0.0.18)", "testtools"] + +[[package]] +name = "requests-toolbelt" +version = "0.9.1" +description = "A utility belt for advanced users of python-requests" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +requests = ">=2.0.1,<3.0.0" + +[[package]] name = "responses" +version = "0.13.3" +description = "A utility library for mocking out the `requests` Python library." +category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "0.10.15" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [package.dependencies] requests = ">=2.0" six = "*" +urllib3 = ">=1.25.10" [package.extras] -tests = ["coverage (>=3.7.1,<5.0.0)", "pytest-cov", "pytest-localserver", "flake8", "pytest (>=4.6,<5.0)", "pytest"] +tests = ["coverage (>=3.7.1,<6.0.0)", "pytest-cov", "pytest-localserver", "flake8", "pytest (>=4.6,<5.0)", "pytest (>=4.6)", "mypy"] [[package]] -category = "dev" -description = "Python 2 and 3 compatibility utilities" name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -version = "1.15.0" [[package]] -category = "dev" -description = "This package provides 26 stemmers for 25 languages generated from Snowball algorithms." name = "snowballstemmer" +version = "2.1.0" +description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." +category = "dev" optional = false python-versions = "*" -version = "2.0.0" [[package]] -category = "dev" -description = "Python documentation generator" name = "sphinx" +version = "3.5.4" +description = "Python documentation generator" +category = "dev" optional = false python-versions = ">=3.5" -version = "3.1.2" [package.dependencies] -Jinja2 = ">=2.3" -Pygments = ">=2.0" alabaster = ">=0.7,<0.8" babel = ">=1.3" -colorama = ">=0.3.5" -docutils = ">=0.12" +colorama = {version = ">=0.3.5", markers = "sys_platform == \"win32\""} +docutils = ">=0.12,<0.17" imagesize = "*" +Jinja2 = ">=2.3" packaging = "*" +Pygments = ">=2.0" requests = ">=2.5.0" -setuptools = "*" snowballstemmer = ">=1.1" sphinxcontrib-applehelp = "*" sphinxcontrib-devhelp = "*" @@ -350,131 +396,132 @@ sphinxcontrib-serializinghtml = "*" [package.extras] docs = ["sphinxcontrib-websupport"] -lint = ["flake8 (>=3.5.0)", "flake8-import-order", "mypy (>=0.780)", "docutils-stubs"] -test = ["pytest", "pytest-cov", "html5lib", "typed-ast", "cython"] +lint = ["flake8 (>=3.5.0)", "isort", "mypy (>=0.800)", "docutils-stubs"] +test = ["pytest", "pytest-cov", "html5lib", "cython", "typed-ast"] [[package]] -category = "dev" -description = "Read the Docs theme for Sphinx" name = "sphinx-rtd-theme" +version = "0.5.2" +description = "Read the Docs theme for Sphinx" +category = "dev" optional = false python-versions = "*" -version = "0.5.0" [package.dependencies] +docutils = "<0.17" sphinx = "*" [package.extras] dev = ["transifex-client", "sphinxcontrib-httpdomain", "bump2version"] [[package]] -category = "dev" -description = "sphinxcontrib-applehelp is a sphinx extension which outputs Apple help books" name = "sphinxcontrib-applehelp" +version = "1.0.2" +description = "sphinxcontrib-applehelp is a sphinx extension which outputs Apple help books" +category = "dev" optional = false python-versions = ">=3.5" -version = "1.0.2" [package.extras] lint = ["flake8", "mypy", "docutils-stubs"] test = ["pytest"] [[package]] -category = "dev" -description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." name = "sphinxcontrib-devhelp" +version = "1.0.2" +description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." +category = "dev" optional = false python-versions = ">=3.5" -version = "1.0.2" [package.extras] lint = ["flake8", "mypy", "docutils-stubs"] test = ["pytest"] [[package]] -category = "dev" -description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" name = "sphinxcontrib-htmlhelp" +version = "1.0.3" +description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" +category = "dev" optional = false python-versions = ">=3.5" -version = "1.0.3" [package.extras] lint = ["flake8", "mypy", "docutils-stubs"] test = ["pytest", "html5lib"] [[package]] -category = "dev" -description = "A sphinx extension which renders display math in HTML via JavaScript" name = "sphinxcontrib-jsmath" +version = "1.0.1" +description = "A sphinx extension which renders display math in HTML via JavaScript" +category = "dev" optional = false python-versions = ">=3.5" -version = "1.0.1" [package.extras] test = ["pytest", "flake8", "mypy"] [[package]] -category = "dev" -description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." name = "sphinxcontrib-qthelp" +version = "1.0.3" +description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." +category = "dev" optional = false python-versions = ">=3.5" -version = "1.0.3" [package.extras] lint = ["flake8", "mypy", "docutils-stubs"] test = ["pytest"] [[package]] -category = "dev" -description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." name = "sphinxcontrib-serializinghtml" +version = "1.1.4" +description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." +category = "dev" optional = false python-versions = ">=3.5" -version = "1.1.4" [package.extras] lint = ["flake8", "mypy", "docutils-stubs"] test = ["pytest"] [[package]] -category = "dev" -description = "Python Library for Tom's Obvious, Minimal Language" name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +category = "dev" optional = false -python-versions = "*" -version = "0.10.1" +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] -category = "main" -description = "HTTP library with thread-safe connection pooling, file post, and more." name = "urllib3" +version = "1.26.4" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" -version = "1.25.10" [package.extras] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] brotli = ["brotlipy (>=0.6.0)"] -secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "pyOpenSSL (>=0.14)", "ipaddress"] -socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"] [[package]] -category = "dev" -description = "Backport of pathlib-compatible object wrapper for zip files" -marker = "python_version < \"3.8\"" name = "zipp" +version = "1.2.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +category = "dev" optional = false python-versions = ">=2.7" -version = "1.2.0" [package.extras] docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["pathlib2", "unittest2", "jaraco.itertools", "func-timeout"] [metadata] -content-hash = "5e1866bb49a4f6a34069cc56a1ed18d11805e7f80b735a8f5e1c594ccafd691e" +lock-version = "1.1" python-versions = "^3.5.0" +content-hash = "843f99106183db483663768bd95d1076c1930b7d686836d8a49365a997bea0c8" [metadata.files] alabaster = [ @@ -486,65 +533,87 @@ atomicwrites = [ {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, ] attrs = [ - {file = "attrs-19.3.0-py2.py3-none-any.whl", hash = "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c"}, - {file = "attrs-19.3.0.tar.gz", hash = "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"}, + {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"}, + {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, ] babel = [ - {file = "Babel-2.8.0-py2.py3-none-any.whl", hash = "sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4"}, - {file = "Babel-2.8.0.tar.gz", hash = "sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38"}, + {file = "Babel-2.9.1-py2.py3-none-any.whl", hash = "sha256:ab49e12b91d937cd11f0b67cb259a57ab4ad2b59ac7a3b41d6c06c0ac5b0def9"}, + {file = "Babel-2.9.1.tar.gz", hash = "sha256:bc0c176f9f6a994582230df350aa6e05ba2ebe4b3ac317eab29d9be5d2768da0"}, ] certifi = [ - {file = "certifi-2020.6.20-py2.py3-none-any.whl", hash = "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41"}, - {file = "certifi-2020.6.20.tar.gz", hash = "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3"}, + {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"}, + {file = "certifi-2020.12.5.tar.gz", hash = "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c"}, ] chardet = [ - {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"}, - {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"}, + {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"}, + {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"}, ] colorama = [ - {file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"}, - {file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"}, + {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, + {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, ] coverage = [ - {file = "coverage-5.2.1-cp27-cp27m-macosx_10_13_intel.whl", hash = "sha256:40f70f81be4d34f8d491e55936904db5c527b0711b2a46513641a5729783c2e4"}, - {file = "coverage-5.2.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:675192fca634f0df69af3493a48224f211f8db4e84452b08d5fcebb9167adb01"}, - {file = "coverage-5.2.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:2fcc8b58953d74d199a1a4d633df8146f0ac36c4e720b4a1997e9b6327af43a8"}, - {file = "coverage-5.2.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:64c4f340338c68c463f1b56e3f2f0423f7b17ba6c3febae80b81f0e093077f59"}, - {file = "coverage-5.2.1-cp27-cp27m-win32.whl", hash = "sha256:52f185ffd3291196dc1aae506b42e178a592b0b60a8610b108e6ad892cfc1bb3"}, - {file = "coverage-5.2.1-cp27-cp27m-win_amd64.whl", hash = "sha256:30bc103587e0d3df9e52cd9da1dd915265a22fad0b72afe54daf840c984b564f"}, - {file = "coverage-5.2.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:9ea749fd447ce7fb1ac71f7616371f04054d969d412d37611716721931e36efd"}, - {file = "coverage-5.2.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ce7866f29d3025b5b34c2e944e66ebef0d92e4a4f2463f7266daa03a1332a651"}, - {file = "coverage-5.2.1-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:4869ab1c1ed33953bb2433ce7b894a28d724b7aa76c19b11e2878034a4e4680b"}, - {file = "coverage-5.2.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:a3ee9c793ffefe2944d3a2bd928a0e436cd0ac2d9e3723152d6fd5398838ce7d"}, - {file = "coverage-5.2.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:28f42dc5172ebdc32622a2c3f7ead1b836cdbf253569ae5673f499e35db0bac3"}, - {file = "coverage-5.2.1-cp35-cp35m-win32.whl", hash = "sha256:e26c993bd4b220429d4ec8c1468eca445a4064a61c74ca08da7429af9bc53bb0"}, - {file = "coverage-5.2.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4186fc95c9febeab5681bc3248553d5ec8c2999b8424d4fc3a39c9cba5796962"}, - {file = "coverage-5.2.1-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:b360d8fd88d2bad01cb953d81fd2edd4be539df7bfec41e8753fe9f4456a5082"}, - {file = "coverage-5.2.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:1adb6be0dcef0cf9434619d3b892772fdb48e793300f9d762e480e043bd8e716"}, - {file = "coverage-5.2.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:098a703d913be6fbd146a8c50cc76513d726b022d170e5e98dc56d958fd592fb"}, - {file = "coverage-5.2.1-cp36-cp36m-win32.whl", hash = "sha256:962c44070c281d86398aeb8f64e1bf37816a4dfc6f4c0f114756b14fc575621d"}, - {file = "coverage-5.2.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b1ed2bdb27b4c9fc87058a1cb751c4df8752002143ed393899edb82b131e0546"}, - {file = "coverage-5.2.1-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:c890728a93fffd0407d7d37c1e6083ff3f9f211c83b4316fae3778417eab9811"}, - {file = "coverage-5.2.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:538f2fd5eb64366f37c97fdb3077d665fa946d2b6d95447622292f38407f9258"}, - {file = "coverage-5.2.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:27ca5a2bc04d68f0776f2cdcb8bbd508bbe430a7bf9c02315cd05fb1d86d0034"}, - {file = "coverage-5.2.1-cp37-cp37m-win32.whl", hash = "sha256:aab75d99f3f2874733946a7648ce87a50019eb90baef931698f96b76b6769a46"}, - {file = "coverage-5.2.1-cp37-cp37m-win_amd64.whl", hash = "sha256:c2ff24df02a125b7b346c4c9078c8936da06964cc2d276292c357d64378158f8"}, - {file = "coverage-5.2.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:304fbe451698373dc6653772c72c5d5e883a4aadaf20343592a7abb2e643dae0"}, - {file = "coverage-5.2.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:c96472b8ca5dc135fb0aa62f79b033f02aa434fb03a8b190600a5ae4102df1fd"}, - {file = "coverage-5.2.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8505e614c983834239f865da2dd336dcf9d72776b951d5dfa5ac36b987726e1b"}, - {file = "coverage-5.2.1-cp38-cp38-win32.whl", hash = "sha256:700997b77cfab016533b3e7dbc03b71d33ee4df1d79f2463a318ca0263fc29dd"}, - {file = "coverage-5.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:46794c815e56f1431c66d81943fa90721bb858375fb36e5903697d5eef88627d"}, - {file = "coverage-5.2.1-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:16042dc7f8e632e0dcd5206a5095ebd18cb1d005f4c89694f7f8aafd96dd43a3"}, - {file = "coverage-5.2.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:c1bbb628ed5192124889b51204de27c575b3ffc05a5a91307e7640eff1d48da4"}, - {file = "coverage-5.2.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:4f6428b55d2916a69f8d6453e48a505c07b2245653b0aa9f0dee38785939f5e4"}, - {file = "coverage-5.2.1-cp39-cp39-win32.whl", hash = "sha256:9e536783a5acee79a9b308be97d3952b662748c4037b6a24cbb339dc7ed8eb89"}, - {file = "coverage-5.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:b8f58c7db64d8f27078cbf2a4391af6aa4e4767cc08b37555c4ae064b8558d9b"}, - {file = "coverage-5.2.1.tar.gz", hash = "sha256:a34cb28e0747ea15e82d13e14de606747e9e484fb28d63c999483f5d5188e89b"}, + {file = "coverage-5.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf"}, + {file = "coverage-5.5-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:b7895207b4c843c76a25ab8c1e866261bcfe27bfaa20c192de5190121770672b"}, + {file = "coverage-5.5-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:c2723d347ab06e7ddad1a58b2a821218239249a9e4365eaff6649d31180c1669"}, + {file = "coverage-5.5-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:900fbf7759501bc7807fd6638c947d7a831fc9fdf742dc10f02956ff7220fa90"}, + {file = "coverage-5.5-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:004d1880bed2d97151facef49f08e255a20ceb6f9432df75f4eef018fdd5a78c"}, + {file = "coverage-5.5-cp27-cp27m-win32.whl", hash = "sha256:06191eb60f8d8a5bc046f3799f8a07a2d7aefb9504b0209aff0b47298333302a"}, + {file = "coverage-5.5-cp27-cp27m-win_amd64.whl", hash = "sha256:7501140f755b725495941b43347ba8a2777407fc7f250d4f5a7d2a1050ba8e82"}, + {file = "coverage-5.5-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:372da284cfd642d8e08ef606917846fa2ee350f64994bebfbd3afb0040436905"}, + {file = "coverage-5.5-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:8963a499849a1fc54b35b1c9f162f4108017b2e6db2c46c1bed93a72262ed083"}, + {file = "coverage-5.5-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:869a64f53488f40fa5b5b9dcb9e9b2962a66a87dab37790f3fcfb5144b996ef5"}, + {file = "coverage-5.5-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:4a7697d8cb0f27399b0e393c0b90f0f1e40c82023ea4d45d22bce7032a5d7b81"}, + {file = "coverage-5.5-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:8d0a0725ad7c1a0bcd8d1b437e191107d457e2ec1084b9f190630a4fb1af78e6"}, + {file = "coverage-5.5-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:51cb9476a3987c8967ebab3f0fe144819781fca264f57f89760037a2ea191cb0"}, + {file = "coverage-5.5-cp310-cp310-win_amd64.whl", hash = "sha256:c0891a6a97b09c1f3e073a890514d5012eb256845c451bd48f7968ef939bf4ae"}, + {file = "coverage-5.5-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:3487286bc29a5aa4b93a072e9592f22254291ce96a9fbc5251f566b6b7343cdb"}, + {file = "coverage-5.5-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:deee1077aae10d8fa88cb02c845cfba9b62c55e1183f52f6ae6a2df6a2187160"}, + {file = "coverage-5.5-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f11642dddbb0253cc8853254301b51390ba0081750a8ac03f20ea8103f0c56b6"}, + {file = "coverage-5.5-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:6c90e11318f0d3c436a42409f2749ee1a115cd8b067d7f14c148f1ce5574d701"}, + {file = "coverage-5.5-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:30c77c1dc9f253283e34c27935fded5015f7d1abe83bc7821680ac444eaf7793"}, + {file = "coverage-5.5-cp35-cp35m-win32.whl", hash = "sha256:9a1ef3b66e38ef8618ce5fdc7bea3d9f45f3624e2a66295eea5e57966c85909e"}, + {file = "coverage-5.5-cp35-cp35m-win_amd64.whl", hash = "sha256:972c85d205b51e30e59525694670de6a8a89691186012535f9d7dbaa230e42c3"}, + {file = "coverage-5.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:af0e781009aaf59e25c5a678122391cb0f345ac0ec272c7961dc5455e1c40066"}, + {file = "coverage-5.5-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:74d881fc777ebb11c63736622b60cb9e4aee5cace591ce274fb69e582a12a61a"}, + {file = "coverage-5.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:92b017ce34b68a7d67bd6d117e6d443a9bf63a2ecf8567bb3d8c6c7bc5014465"}, + {file = "coverage-5.5-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:d636598c8305e1f90b439dbf4f66437de4a5e3c31fdf47ad29542478c8508bbb"}, + {file = "coverage-5.5-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:41179b8a845742d1eb60449bdb2992196e211341818565abded11cfa90efb821"}, + {file = "coverage-5.5-cp36-cp36m-win32.whl", hash = "sha256:040af6c32813fa3eae5305d53f18875bedd079960822ef8ec067a66dd8afcd45"}, + {file = "coverage-5.5-cp36-cp36m-win_amd64.whl", hash = "sha256:5fec2d43a2cc6965edc0bb9e83e1e4b557f76f843a77a2496cbe719583ce8184"}, + {file = "coverage-5.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:18ba8bbede96a2c3dde7b868de9dcbd55670690af0988713f0603f037848418a"}, + {file = "coverage-5.5-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2910f4d36a6a9b4214bb7038d537f015346f413a975d57ca6b43bf23d6563b53"}, + {file = "coverage-5.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:f0b278ce10936db1a37e6954e15a3730bea96a0997c26d7fee88e6c396c2086d"}, + {file = "coverage-5.5-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:796c9c3c79747146ebd278dbe1e5c5c05dd6b10cc3bcb8389dfdf844f3ead638"}, + {file = "coverage-5.5-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:53194af30d5bad77fcba80e23a1441c71abfb3e01192034f8246e0d8f99528f3"}, + {file = "coverage-5.5-cp37-cp37m-win32.whl", hash = "sha256:184a47bbe0aa6400ed2d41d8e9ed868b8205046518c52464fde713ea06e3a74a"}, + {file = "coverage-5.5-cp37-cp37m-win_amd64.whl", hash = "sha256:2949cad1c5208b8298d5686d5a85b66aae46d73eec2c3e08c817dd3513e5848a"}, + {file = "coverage-5.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:217658ec7187497e3f3ebd901afdca1af062b42cfe3e0dafea4cced3983739f6"}, + {file = "coverage-5.5-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1aa846f56c3d49205c952d8318e76ccc2ae23303351d9270ab220004c580cfe2"}, + {file = "coverage-5.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:24d4a7de75446be83244eabbff746d66b9240ae020ced65d060815fac3423759"}, + {file = "coverage-5.5-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d1f8bf7b90ba55699b3a5e44930e93ff0189aa27186e96071fac7dd0d06a1873"}, + {file = "coverage-5.5-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:970284a88b99673ccb2e4e334cfb38a10aab7cd44f7457564d11898a74b62d0a"}, + {file = "coverage-5.5-cp38-cp38-win32.whl", hash = "sha256:01d84219b5cdbfc8122223b39a954820929497a1cb1422824bb86b07b74594b6"}, + {file = "coverage-5.5-cp38-cp38-win_amd64.whl", hash = "sha256:2e0d881ad471768bf6e6c2bf905d183543f10098e3b3640fc029509530091502"}, + {file = "coverage-5.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d1f9ce122f83b2305592c11d64f181b87153fc2c2bbd3bb4a3dde8303cfb1a6b"}, + {file = "coverage-5.5-cp39-cp39-manylinux1_i686.whl", hash = "sha256:13c4ee887eca0f4c5a247b75398d4114c37882658300e153113dafb1d76de529"}, + {file = "coverage-5.5-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:52596d3d0e8bdf3af43db3e9ba8dcdaac724ba7b5ca3f6358529d56f7a166f8b"}, + {file = "coverage-5.5-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:2cafbbb3af0733db200c9b5f798d18953b1a304d3f86a938367de1567f4b5bff"}, + {file = "coverage-5.5-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:44d654437b8ddd9eee7d1eaee28b7219bec228520ff809af170488fd2fed3e2b"}, + {file = "coverage-5.5-cp39-cp39-win32.whl", hash = "sha256:d314ed732c25d29775e84a960c3c60808b682c08d86602ec2c3008e1202e3bb6"}, + {file = "coverage-5.5-cp39-cp39-win_amd64.whl", hash = "sha256:13034c4409db851670bc9acd836243aeee299949bd5673e11844befcb0149f03"}, + {file = "coverage-5.5-pp36-none-any.whl", hash = "sha256:f030f8873312a16414c0d8e1a1ddff2d3235655a2174e3648b4fa66b3f2f1079"}, + {file = "coverage-5.5-pp37-none-any.whl", hash = "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4"}, + {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"}, ] docutils = [ {file = "docutils-0.16-py2.py3-none-any.whl", hash = "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af"}, {file = "docutils-0.16.tar.gz", hash = "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"}, ] +flake8 = [ + {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, + {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, +] idna = [ {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"}, @@ -554,16 +623,16 @@ imagesize = [ {file = "imagesize-1.2.0.tar.gz", hash = "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1"}, ] importlib-metadata = [ - {file = "importlib_metadata-1.7.0-py2.py3-none-any.whl", hash = "sha256:dc15b2969b4ce36305c51eebe62d418ac7791e9a157911d58bfb1f9ccd8e2070"}, - {file = "importlib_metadata-1.7.0.tar.gz", hash = "sha256:90bb658cdbbf6d1735b6341ce708fc7024a3e14e99ffdc5783edea9f9b077f83"}, + {file = "importlib_metadata-2.1.1-py2.py3-none-any.whl", hash = "sha256:c2d6341ff566f609e89a2acb2db190e5e1d23d5409d6cc8d2fe34d72443876d4"}, + {file = "importlib_metadata-2.1.1.tar.gz", hash = "sha256:b8de9eff2b35fb037368f28a7df1df4e6436f578fa74423505b6c6a778d5b5dd"}, ] iniconfig = [ - {file = "iniconfig-1.0.1-py3-none-any.whl", hash = "sha256:80cf40c597eb564e86346103f609d74efce0f6b4d4f30ec8ce9e2c26411ba437"}, - {file = "iniconfig-1.0.1.tar.gz", hash = "sha256:e5f92f89355a67de0595932a6c6c02ab4afddc6fcdc0bfc5becd0d60884d3f69"}, + {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, + {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] jinja2 = [ - {file = "Jinja2-2.11.2-py2.py3-none-any.whl", hash = "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"}, - {file = "Jinja2-2.11.2.tar.gz", hash = "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0"}, + {file = "Jinja2-2.11.3-py2.py3-none-any.whl", hash = "sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419"}, + {file = "Jinja2-2.11.3.tar.gz", hash = "sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6"}, ] markupsafe = [ {file = "MarkupSafe-1.1.1-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161"}, @@ -584,29 +653,48 @@ markupsafe = [ {file = "MarkupSafe-1.1.1-cp35-cp35m-win32.whl", hash = "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1"}, {file = "MarkupSafe-1.1.1-cp35-cp35m-win_amd64.whl", hash = "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d"}, {file = "MarkupSafe-1.1.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d53bc011414228441014aa71dbec320c66468c1030aae3a6e29778a3382d96e5"}, {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473"}, {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:3b8a6499709d29c2e2399569d96719a1b21dcd94410a586a18526b143ec8470f"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:84dee80c15f1b560d55bcfe6d47b27d070b4681c699c572af2e3c7cc90a3b8e0"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:b1dba4527182c95a0db8b6060cc98ac49b9e2f5e64320e2b56e47cb2831978c7"}, {file = "MarkupSafe-1.1.1-cp36-cp36m-win32.whl", hash = "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66"}, {file = "MarkupSafe-1.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5"}, {file = "MarkupSafe-1.1.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:bf5aa3cbcfdf57fa2ee9cd1822c862ef23037f5c832ad09cfea57fa846dec193"}, {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e"}, {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:6fffc775d90dcc9aed1b89219549b329a9250d918fd0b8fa8d93d154918422e1"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:a6a744282b7718a2a62d2ed9d993cad6f5f585605ad352c11de459f4108df0a1"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:195d7d2c4fbb0ee8139a6cf67194f3973a6b3042d742ebe0a9ed36d8b6f0c07f"}, {file = "MarkupSafe-1.1.1-cp37-cp37m-win32.whl", hash = "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2"}, {file = "MarkupSafe-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c"}, {file = "MarkupSafe-1.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15"}, {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2"}, {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:acf08ac40292838b3cbbb06cfe9b2cb9ec78fce8baca31ddb87aaac2e2dc3bc2"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:d9be0ba6c527163cbed5e0857c451fcd092ce83947944d6c14bc95441203f032"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:caabedc8323f1e93231b52fc32bdcde6db817623d33e100708d9a68e1f53b26b"}, {file = "MarkupSafe-1.1.1-cp38-cp38-win32.whl", hash = "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b"}, {file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"}, + {file = "MarkupSafe-1.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d73a845f227b0bfe8a7455ee623525ee656a9e2e749e4742706d80a6065d5e2c"}, + {file = "MarkupSafe-1.1.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:98bae9582248d6cf62321dcb52aaf5d9adf0bad3b40582925ef7c7f0ed85fceb"}, + {file = "MarkupSafe-1.1.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:2beec1e0de6924ea551859edb9e7679da6e4870d32cb766240ce17e0a0ba2014"}, + {file = "MarkupSafe-1.1.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:7fed13866cf14bba33e7176717346713881f56d9d2bcebab207f7a036f41b850"}, + {file = "MarkupSafe-1.1.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:6f1e273a344928347c1290119b493a1f0303c52f5a5eae5f16d74f48c15d4a85"}, + {file = "MarkupSafe-1.1.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:feb7b34d6325451ef96bc0e36e1a6c0c1c64bc1fbec4b854f4529e51887b1621"}, + {file = "MarkupSafe-1.1.1-cp39-cp39-win32.whl", hash = "sha256:22c178a091fc6630d0d045bdb5992d2dfe14e3259760e713c490da5323866c39"}, + {file = "MarkupSafe-1.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:b7d644ddb4dbd407d31ffb699f1d140bc35478da613b441c582aeb7c43838dd8"}, {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, ] -more-itertools = [ - {file = "more-itertools-8.4.0.tar.gz", hash = "sha256:68c70cc7167bdf5c7c9d8f6954a7837089c6a36bf565383919bb595efb8a17e5"}, - {file = "more_itertools-8.4.0-py3-none-any.whl", hash = "sha256:b78134b2063dd214000685165d81c154522c3ee0a1c0d4d113c80361c234c5a2"}, +mccabe = [ + {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, + {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, ] packaging = [ - {file = "packaging-20.4-py2.py3-none-any.whl", hash = "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"}, - {file = "packaging-20.4.tar.gz", hash = "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8"}, + {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"}, + {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"}, ] pathlib2 = [ {file = "pathlib2-2.3.5-py2.py3-none-any.whl", hash = "sha256:0ec8205a157c80d7acc301c0b18fbd5d44fe655968f5d947b6ecef5290fc35db"}, @@ -617,52 +705,68 @@ pluggy = [ {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, ] py = [ - {file = "py-1.9.0-py2.py3-none-any.whl", hash = "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2"}, - {file = "py-1.9.0.tar.gz", hash = "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342"}, + {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, + {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, +] +pycodestyle = [ + {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"}, + {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"}, +] +pyflakes = [ + {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"}, + {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, ] pygments = [ - {file = "Pygments-2.6.1-py3-none-any.whl", hash = "sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324"}, - {file = "Pygments-2.6.1.tar.gz", hash = "sha256:647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44"}, + {file = "Pygments-2.9.0-py3-none-any.whl", hash = "sha256:d66e804411278594d764fc69ec36ec13d9ae9147193a1740cd34d272ca383b8e"}, + {file = "Pygments-2.9.0.tar.gz", hash = "sha256:a18f47b506a429f6f4b9df81bb02beab9ca21d0a5fee38ed15aef65f0545519f"}, ] pyparsing = [ {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, ] pytest = [ - {file = "pytest-6.0.1-py3-none-any.whl", hash = "sha256:8b6007800c53fdacd5a5c192203f4e531eb2a1540ad9c752e052ec0f7143dbad"}, - {file = "pytest-6.0.1.tar.gz", hash = "sha256:85228d75db9f45e06e57ef9bf4429267f81ac7c0d742cc9ed63d09886a9fe6f4"}, + {file = "pytest-6.1.2-py3-none-any.whl", hash = "sha256:4288fed0d9153d9646bfcdf0c0428197dba1ecb27a33bb6e031d002fa88653fe"}, + {file = "pytest-6.1.2.tar.gz", hash = "sha256:c0a7e94a8cdbc5422a51ccdad8e6f1024795939cc89159a0ae7f0b316ad3823e"}, ] pytest-cov = [ - {file = "pytest-cov-2.10.0.tar.gz", hash = "sha256:1a629dc9f48e53512fcbfda6b07de490c374b0c83c55ff7a1720b3fccff0ac87"}, - {file = "pytest_cov-2.10.0-py2.py3-none-any.whl", hash = "sha256:6e6d18092dce6fad667cd7020deed816f858ad3b49d5b5e2b1cc1c97a4dba65c"}, + {file = "pytest-cov-2.12.0.tar.gz", hash = "sha256:8535764137fecce504a49c2b742288e3d34bc09eed298ad65963616cc98fd45e"}, + {file = "pytest_cov-2.12.0-py2.py3-none-any.whl", hash = "sha256:95d4933dcbbacfa377bb60b29801daa30d90c33981ab2a79e9ab4452c165066e"}, ] pytz = [ - {file = "pytz-2020.1-py2.py3-none-any.whl", hash = "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed"}, - {file = "pytz-2020.1.tar.gz", hash = "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048"}, + {file = "pytz-2021.1-py2.py3-none-any.whl", hash = "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798"}, + {file = "pytz-2021.1.tar.gz", hash = "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da"}, ] requests = [ - {file = "requests-2.24.0-py2.py3-none-any.whl", hash = "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898"}, - {file = "requests-2.24.0.tar.gz", hash = "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b"}, + {file = "requests-2.25.1-py2.py3-none-any.whl", hash = "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"}, + {file = "requests-2.25.1.tar.gz", hash = "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804"}, +] +requests-mock = [ + {file = "requests-mock-1.9.2.tar.gz", hash = "sha256:33296f228d8c5df11a7988b741325422480baddfdf5dd9318fd0eb40c3ed8595"}, + {file = "requests_mock-1.9.2-py2.py3-none-any.whl", hash = "sha256:5c8ef0254c14a84744be146e9799dc13ebc4f6186058112d9aeed96b131b58e2"}, +] +requests-toolbelt = [ + {file = "requests-toolbelt-0.9.1.tar.gz", hash = "sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0"}, + {file = "requests_toolbelt-0.9.1-py2.py3-none-any.whl", hash = "sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f"}, ] responses = [ - {file = "responses-0.10.15-py2.py3-none-any.whl", hash = "sha256:af94d28cdfb48ded0ad82a5216616631543650f440334a693479b8991a6594a2"}, - {file = "responses-0.10.15.tar.gz", hash = "sha256:7bb697a5fedeb41d81e8b87f152d453d5cab42dcd1691b6a7d6097e94d33f373"}, + {file = "responses-0.13.3-py2.py3-none-any.whl", hash = "sha256:b54067596f331786f5ed094ff21e8d79e6a1c68ef625180a7d34808d6f36c11b"}, + {file = "responses-0.13.3.tar.gz", hash = "sha256:18a5b88eb24143adbf2b4100f328a2f5bfa72fbdacf12d97d41f07c26c45553d"}, ] six = [ - {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, - {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] snowballstemmer = [ - {file = "snowballstemmer-2.0.0-py2.py3-none-any.whl", hash = "sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0"}, - {file = "snowballstemmer-2.0.0.tar.gz", hash = "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52"}, + {file = "snowballstemmer-2.1.0-py2.py3-none-any.whl", hash = "sha256:b51b447bea85f9968c13b650126a888aabd4cb4463fca868ec596826325dedc2"}, + {file = "snowballstemmer-2.1.0.tar.gz", hash = "sha256:e997baa4f2e9139951b6f4c631bad912dfd3c792467e2f03d7239464af90e914"}, ] sphinx = [ - {file = "Sphinx-3.1.2-py3-none-any.whl", hash = "sha256:97dbf2e31fc5684bb805104b8ad34434ed70e6c588f6896991b2fdfd2bef8c00"}, - {file = "Sphinx-3.1.2.tar.gz", hash = "sha256:b9daeb9b39aa1ffefc2809b43604109825300300b987a24f45976c001ba1a8fd"}, + {file = "Sphinx-3.5.4-py3-none-any.whl", hash = "sha256:2320d4e994a191f4b4be27da514e46b3d6b420f2ff895d064f52415d342461e8"}, + {file = "Sphinx-3.5.4.tar.gz", hash = "sha256:19010b7b9fa0dc7756a6e105b2aacd3a80f798af3c25c273be64d7beeb482cb1"}, ] sphinx-rtd-theme = [ - {file = "sphinx_rtd_theme-0.5.0-py2.py3-none-any.whl", hash = "sha256:373413d0f82425aaa28fb288009bf0d0964711d347763af2f1b65cafcb028c82"}, - {file = "sphinx_rtd_theme-0.5.0.tar.gz", hash = "sha256:22c795ba2832a169ca301cd0a083f7a434e09c538c70beb42782c073651b707d"}, + {file = "sphinx_rtd_theme-0.5.2-py2.py3-none-any.whl", hash = "sha256:4a05bdbe8b1446d77a01e20a23ebc6777c74f43237035e76be89699308987d6f"}, + {file = "sphinx_rtd_theme-0.5.2.tar.gz", hash = "sha256:32bd3b5d13dc8186d7a42fc816a23d32e83a4827d7d9882948e7b837c232da5a"}, ] sphinxcontrib-applehelp = [ {file = "sphinxcontrib-applehelp-1.0.2.tar.gz", hash = "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58"}, @@ -689,12 +793,12 @@ sphinxcontrib-serializinghtml = [ {file = "sphinxcontrib_serializinghtml-1.1.4-py2.py3-none-any.whl", hash = "sha256:f242a81d423f59617a8e5cf16f5d4d74e28ee9a66f9e5b637a18082991db5a9a"}, ] toml = [ - {file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"}, - {file = "toml-0.10.1.tar.gz", hash = "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f"}, + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] urllib3 = [ - {file = "urllib3-1.25.10-py2.py3-none-any.whl", hash = "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461"}, - {file = "urllib3-1.25.10.tar.gz", hash = "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a"}, + {file = "urllib3-1.26.4-py2.py3-none-any.whl", hash = "sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df"}, + {file = "urllib3-1.26.4.tar.gz", hash = "sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937"}, ] zipp = [ {file = "zipp-1.2.0-py2.py3-none-any.whl", hash = "sha256:e0d9e63797e483a30d27e09fffd308c59a700d365ec34e93cc100844168bf921"}, diff --git a/pyproject.toml b/pyproject.toml index 6aca8ab..0a5b3df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "virl2_client" -version = "2.2.1" +version = "2.2.1+update1" description = "VIRL2 Client Library" authors = ["Simon Knight ", "Ralph Schmieder "] license = "Apache-2.0" @@ -22,12 +22,15 @@ classifiers = [ [tool.poetry.dependencies] python = "^3.5.0" requests = "^2" +requests-toolbelt = "^0.9.1" [tool.poetry.dev-dependencies] pytest = "*" pytest-cov = "*" responses = "*" sphinx_rtd_theme = "*" +requests-mock = "^1.9.2" +flake8 = "^3.9.2" [build-system] requires = ["poetry>=0.12"] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..bbb2650 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,28 @@ +[pytest] +addopts = + --strict-markers + --verbose + --showlocals + -m "not integration and not slow" + --cov-branch + --cov-report term-missing + +filterwarnings = + ignore:.*resolve package from __spec__ or __package__, falling back.*:ImportWarning + ignore:Deprecated, use .can_read_body #2005:DeprecationWarning + ignore:Flags not at the start of the expression.*:DeprecationWarning + +markers = + integration: tests that communicate external servers that need to be running before firing the tests + slow: unit tests that take too long to run in a "regular" test run during development + nomock: unit tests that require an actual VM to run (in combination with integration tests) + +junit_family=xunit1 + +# start pytest with --log-level=info +# log_cli=True + +# for coverage, use e.g. +# pytest --log-cli-level=info --cov=core --cov-report=xml -sx core -k test_event_router +# and use the excellent "Coverage Gutters" extension in VSCode + diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..124d5d6 --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,45 @@ +alabaster==0.7.12; python_version >= "3.5" +atomicwrites==1.4.0; python_version >= "3.5" and python_full_version < "3.0.0" and sys_platform == "win32" and (python_version >= "3.5" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.5") or sys_platform == "win32" and python_version >= "3.5" and python_full_version >= "3.4.0" and (python_version >= "3.5" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.5") +attrs==21.2.0; python_version >= "3.5" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.5" +babel==2.9.1; python_version >= "3.5" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.5" +certifi==2020.12.5; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" +chardet==4.0.0; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" +colorama==0.4.4; python_version >= "3.5" and python_full_version < "3.0.0" and sys_platform == "win32" and (python_version >= "3.5" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.5") or sys_platform == "win32" and python_version >= "3.5" and python_full_version >= "3.5.0" and (python_version >= "3.5" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.5") +coverage==5.5; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version < "4" +docutils==0.16; python_version >= "3.5" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.5" +flake8==3.9.2; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.5.0") +idna==2.10; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" +imagesize==1.2.0; python_version >= "3.5" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.5" +importlib-metadata==2.1.1; python_version >= "3.5" and python_full_version < "3.0.0" and python_version < "3.8" and (python_version >= "3.5" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.5") or python_version < "3.8" and python_version >= "3.5" and python_full_version >= "3.5.0" and (python_version >= "3.5" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.5") +iniconfig==1.1.1; python_version >= "3.5" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.5" +jinja2==2.11.3; python_version >= "3.5" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.5" +markupsafe==1.1.1; python_version >= "3.5" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.5" +mccabe==0.6.1; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" +packaging==20.9; python_version >= "3.5" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.5" +pathlib2==2.3.5; python_version < "3.6" and python_version >= "3.5" and (python_version >= "3.5" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.5") +pluggy==0.13.1; python_version >= "3.5" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.5" +py==1.10.0; python_version >= "3.5" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.5" +pycodestyle==2.7.0; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" +pyflakes==2.3.1; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" +pygments==2.9.0; python_version >= "3.5" +pyparsing==2.4.7; python_version >= "3.5" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.5" +pytest-cov==2.12.0; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.5.0") +pytest==6.1.2; python_version >= "3.5" +pytz==2021.1; python_version >= "3.5" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.5" +requests-mock==1.9.2 +requests-toolbelt==0.9.1 +requests==2.25.1; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.5.0") +responses==0.13.3; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.5.0") +six==1.16.0; python_version >= "3.5" and python_full_version < "3.0.0" and python_version < "3.6" or python_version < "3.6" and python_version >= "3.5" and python_full_version >= "3.5.0" +snowballstemmer==2.1.0; python_version >= "3.5" +sphinx-rtd-theme==0.5.2 +sphinx==3.5.4; python_version >= "3.5" +sphinxcontrib-applehelp==1.0.2; python_version >= "3.5" +sphinxcontrib-devhelp==1.0.2; python_version >= "3.5" +sphinxcontrib-htmlhelp==1.0.3; python_version >= "3.5" +sphinxcontrib-jsmath==1.0.1; python_version >= "3.5" +sphinxcontrib-qthelp==1.0.3; python_version >= "3.5" +sphinxcontrib-serializinghtml==1.1.4; python_version >= "3.5" +toml==0.10.2; python_version >= "3.5" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.5" and python_version < "4" +urllib3==1.26.4; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version < "4" +zipp==1.2.0; python_version >= "3.5" and python_full_version < "3.0.0" and python_version < "3.8" or python_version < "3.8" and python_version >= "3.5" and python_full_version >= "3.5.0" diff --git a/tests/test_client_library.py b/tests/test_client_library.py index a02b4d2..c4e6d22 100644 --- a/tests/test_client_library.py +++ b/tests/test_client_library.py @@ -34,6 +34,9 @@ from virl2_client.virl2_client import ClientLibrary, Version, InitializationError +CURRENT_VERSION = ClientLibrary.VERSION.version_str + + def client_library_patched_system_info(version): with patch.object( ClientLibrary, "system_info", return_value={"version": version, "ready": True} @@ -42,20 +45,30 @@ def client_library_patched_system_info(version): @pytest.fixture -def client_library_exact_version(): - yield from client_library_patched_system_info(version="2.1.0") +def client_library_server_current(): + yield from client_library_patched_system_info(version=CURRENT_VERSION) @pytest.fixture -def client_library_compatible_version(): +def client_library_server_2_0_0(): yield from client_library_patched_system_info(version="2.0.0") @pytest.fixture -def client_library_incompatible_version(): +def client_library_server_1_0_0(): yield from client_library_patched_system_info(version="1.0.0") +@pytest.fixture +def client_library_server_2_9_0(): + yield from client_library_patched_system_info(version="2.9.0") + + +@pytest.fixture +def client_library_server_2_19_0(): + yield from client_library_patched_system_info(version="2.19.0") + + @pytest.fixture def mocked_session(): with patch.object(requests, "Session", autospec=True) as session: @@ -63,7 +76,7 @@ def mocked_session(): def test_import_lab_from_path_ng( - client_library_compatible_version, mocked_session, tmp_path: Path + client_library_server_2_0_0, mocked_session, tmp_path: Path ): client_library = ClientLibrary( url="http://0.0.0.0/fake_url/", username="test", password="pa$$" @@ -90,7 +103,7 @@ def test_import_lab_from_path_ng( def test_import_lab_from_path_virl( - client_library_compatible_version, mocked_session, tmp_path: Path + client_library_server_2_0_0, mocked_session, tmp_path: Path ): cl = ClientLibrary(url="http://0.0.0.0/fake_url/", username="test", password="pa$$") Lab.sync = Mock() @@ -111,7 +124,7 @@ def test_import_lab_from_path_virl( sync_mock.assert_called_once_with() -def test_ssl_certificate(client_library_compatible_version, mocked_session): +def test_ssl_certificate(client_library_server_2_0_0, mocked_session): cl = ClientLibrary( url="http://0.0.0.0/fake_url/", username="test", @@ -128,7 +141,7 @@ def test_ssl_certificate(client_library_compatible_version, mocked_session): def test_ssl_certificate_from_env_variable( - client_library_compatible_version, monkeypatch, mocked_session + client_library_server_2_0_0, monkeypatch, mocked_session ): monkeypatch.setitem(os.environ, "CA_BUNDLE", "/home/user/cert.pem") cl = ClientLibrary(url="http://0.0.0.0/fake_url/", username="test", password="pa$$") @@ -142,7 +155,7 @@ def test_ssl_certificate_from_env_variable( @responses.activate -def test_auth_and_reauth_token(client_library_compatible_version): +def test_auth_and_reauth_token(client_library_server_2_0_0): # TODO: need to check what the purpose of this test is, and how it # works with the automatic auth check on CL init # if there's environ vars for username and password set @@ -174,7 +187,7 @@ def test_auth_and_reauth_token(client_library_compatible_version): with pytest.raises(InitializationError): # Test returns custom exception when instructed to raise on failure - cl = ClientLibrary( + ClientLibrary( url="http://0.0.0.0/fake_url/", username="test", password="pa$$", @@ -215,7 +228,7 @@ def test_auth_and_reauth_token(client_library_compatible_version): assert len(responses.calls) == 6 -def test_client_library_init_allow_http(client_library_compatible_version): +def test_client_library_init_allow_http(client_library_server_2_0_0): cl = ClientLibrary("http://somehost", "virl2", "virl2", allow_http=True) url_parts = urlsplit(cl._context.base_url) assert url_parts.scheme == "http" @@ -228,7 +241,7 @@ def test_client_library_init_allow_http(client_library_compatible_version): @pytest.mark.parametrize("via", ["environment", "parameter"]) @pytest.mark.parametrize( - "parms", + "params", [ (False, "somehost"), (False, "http://somehost"), @@ -237,16 +250,14 @@ def test_client_library_init_allow_http(client_library_compatible_version): (True, "https:@somehost:4:4:3"), ], ) -def test_client_library_init_url( - client_library_compatible_version, monkeypatch, via, parms -): - (fail, url) = parms +def test_client_library_init_url(client_library_server_2_0_0, monkeypatch, via, params): + (fail, url) = params if via == "environment": monkeypatch.setenv("VIRL2_URL", url) url = None if fail: with pytest.raises((InitializationError, requests.exceptions.InvalidURL)): - cl = ClientLibrary(url=url, username="virl2", password="virl2") + ClientLibrary(url=url, username="virl2", password="virl2") else: cl = ClientLibrary(url, username="virl2", password="virl2") url_parts = urlsplit(cl._context.base_url) @@ -259,48 +270,48 @@ def test_client_library_init_url( @pytest.mark.parametrize("via", ["environment", "parameter"]) -@pytest.mark.parametrize("parms", [(False, "johndoe"), (True, ""), (True, None)]) +@pytest.mark.parametrize("params", [(False, "johndoe"), (True, ""), (True, None)]) def test_client_library_init_user( - client_library_compatible_version, monkeypatch, via, parms + client_library_server_2_0_0, monkeypatch, via, params ): url = "validhostname" - (fail, user) = parms + (fail, user) = params if via == "environment": # can't set a None value for an environment variable monkeypatch.setenv("VIRL2_USER", user or "") user = None if fail: with pytest.raises((InitializationError, requests.exceptions.InvalidURL)): - cl = ClientLibrary(url=url, username=user, password="virl2") + ClientLibrary(url=url, username=user, password="virl2") else: cl = ClientLibrary(url, username=user, password="virl2") - assert cl.username == parms[1] + assert cl.username == params[1] assert cl.password == "virl2" assert cl._context.base_url == "https://validhostname/api/v0/" @pytest.mark.parametrize("via", ["environment", "parameter"]) -@pytest.mark.parametrize("parms", [(False, "validPa$$w!2"), (True, ""), (True, None)]) +@pytest.mark.parametrize("params", [(False, "validPa$$w!2"), (True, ""), (True, None)]) def test_client_library_init_password( - client_library_compatible_version, monkeypatch, via, parms + client_library_server_2_0_0, monkeypatch, via, params ): url = "validhostname" - (fail, password) = parms + (fail, password) = params if via == "environment": # can't set a None value for an environment variable monkeypatch.setenv("VIRL2_PASS", password or "") password = None if fail: with pytest.raises((InitializationError, requests.exceptions.InvalidURL)): - cl = ClientLibrary(url=url, username="virl2", password=password) + ClientLibrary(url=url, username="virl2", password=password) else: cl = ClientLibrary(url, username="virl2", password=password) assert cl.username == "virl2" - assert cl.password == parms[1] + assert cl.password == params[1] assert cl._context.base_url == "https://validhostname/api/v0/" -def test_client_library_str_and_repr(client_library_compatible_version): +def test_client_library_str_and_repr(client_library_server_2_0_0): client_library = ClientLibrary("somehost", "virl2", password="virl2") assert ( repr(client_library) @@ -309,146 +320,359 @@ def test_client_library_str_and_repr(client_library_compatible_version): assert str(client_library) == "ClientLibrary URL: https://somehost/api/v0/" -def test_major_version_mismatch(client_library_incompatible_version): +def test_major_version_mismatch(client_library_server_1_0_0): with pytest.raises(InitializationError) as err: ClientLibrary("somehost", "virl2", password="virl2") - assert str(err.value) == "Major version mismatch. server 1.0.0, client 2.1.0" + assert str( + err.value + ) == "Major version mismatch. Client {}, controller 1.0.0.".format(CURRENT_VERSION) -def test_incompatible_version(client_library_compatible_version): +def test_incompatible_version(client_library_server_2_0_0): with pytest.raises(InitializationError) as err: with patch.object( ClientLibrary, "INCOMPATIBLE_CONTROLLER_VERSIONS", new=[Version("2.0.0")] ): ClientLibrary("somehost", "virl2", password="virl2") assert ( - str(err.value) - == "Controller version 2.0.0 is marked incompatible! List of versions marked expclicitly as incompatible: [2.0.0]" + str(err.value) == "Controller version 2.0.0 is marked incompatible! " + "List of versions marked explicitly as incompatible: [2.0.0]." ) -def test_minor_version_mismatch(client_library_compatible_version, caplog): +def test_client_minor_version_gt_nowarn(client_library_server_2_0_0, caplog): with caplog.at_level(logging.WARNING): client_library = ClientLibrary("somehost", "virl2", password="virl2") assert client_library is not None assert ( - "Please ensure the client version is compatible with the server version. client 2.1.0, server 2.0.0" - in caplog.text + "Please ensure the client version is compatible with the controller version. " + "Client {}, controller 2.0.0.".format(CURRENT_VERSION) not in caplog.text ) -def test_exact_version_no_warn(client_library_exact_version, caplog): +def test_client_minor_version_lt_warn(client_library_server_2_9_0, caplog): with caplog.at_level(logging.WARNING): client_library = ClientLibrary("somehost", "virl2", password="virl2") assert client_library is not None assert ( - "Please ensure the client version is compatible with the server version. client 2.1.0, server 2.0.0" - not in caplog.text + "Please ensure the client version is compatible with the controller version. " + "Client {}, controller 2.9.0.".format(CURRENT_VERSION) in caplog.text + ) + + +def test_client_minor_version_lt_warn_1(client_library_server_2_19_0, caplog): + with caplog.at_level(logging.WARNING): + client_library = ClientLibrary("somehost", "virl2", password="virl2") + assert client_library is not None + assert ( + "Please ensure the client version is compatible with the controller version. " + "Client {}, controller 2.19.0.".format(CURRENT_VERSION) in caplog.text + ) + + +def test_exact_version_no_warn(client_library_server_current, caplog): + with caplog.at_level(logging.WARNING): + client_library = ClientLibrary("somehost", "virl2", password="virl2") + assert client_library is not None + assert ( + "Please ensure the client version is compatible with the controller version. " + "Client {}, controller 2.0.0.".format(CURRENT_VERSION) not in caplog.text ) @pytest.mark.parametrize( "greater, lesser, expected", [ - pytest.param(Version("2.0.1"), Version("2.0.0"), True, id="Patch is greater than"), - pytest.param(Version("2.0.10"), Version("2.0.0"), True, id="Patch is much greater than"), - pytest.param(Version("2.1.0"), Version("2.0.0"), True, id="Minor is greater than"), - pytest.param(Version("2.10.0"), Version("2.0.0"), True, id="Minor is much greater than"), - pytest.param(Version("3.0.0"), Version("2.0.0"), True, id="Major is greater than"), - pytest.param(Version("10.0.0"), Version("2.0.0"), True, id="Major is much greater than"), - pytest.param(Version("2.0.0"), Version("2.0.1"), False, id="Patch is lesser than"), - pytest.param(Version("2.0.0"), Version("2.0.10"), False, id="Patch is much lesser than"), - pytest.param(Version("2.0.0"), Version("2.1.0"), False, id="Minor is lesser than"), - pytest.param(Version("2.0.0"), Version("2.10.0"), False, id="Minor is much lesser than"), - pytest.param(Version("2.0.0"), Version("3.0.0"), False, id="Major is lesser than"), - pytest.param(Version("2.0.0"), Version("10.0.0"), False, id="Major is much lesser than"), - pytest.param(Version("2.0.0"), "random string", False, id="Other object is string and not a Version object"), - pytest.param(Version("2.0.0"), 12345, False, id="Other object is int and not a Version object") - ] + pytest.param( + Version("2.0.1"), Version("2.0.0"), True, id="Patch is greater than" + ), + pytest.param( + Version("2.0.10"), Version("2.0.0"), True, id="Patch is much greater than" + ), + pytest.param( + Version("2.1.0"), Version("2.0.0"), True, id="Minor is greater than" + ), + pytest.param( + Version("2.10.0"), Version("2.0.0"), True, id="Minor is much greater than" + ), + pytest.param( + Version("3.0.0"), Version("2.0.0"), True, id="Major is greater than" + ), + pytest.param( + Version("10.0.0"), Version("2.0.0"), True, id="Major is much greater than" + ), + pytest.param( + Version("2.0.0"), Version("2.0.1"), False, id="Patch is lesser than" + ), + pytest.param( + Version("2.0.0"), Version("2.0.10"), False, id="Patch is much lesser than" + ), + pytest.param( + Version("2.0.0"), Version("2.1.0"), False, id="Minor is lesser than" + ), + pytest.param( + Version("2.0.0"), Version("2.10.0"), False, id="Minor is much lesser than" + ), + pytest.param( + Version("2.0.0"), Version("3.0.0"), False, id="Major is lesser than" + ), + pytest.param( + Version("2.0.0"), Version("10.0.0"), False, id="Major is much lesser than" + ), + pytest.param( + Version("2.0.0"), + "random string", + False, + id="Other object is string and not a Version object", + ), + pytest.param( + Version("2.0.0"), + 12345, + False, + id="Other object is int and not a Version object", + ), + ], ) def test_version_comparison_greater_than(greater, lesser, expected): assert (greater > lesser) == expected + @pytest.mark.parametrize( "first, second, expected", [ - pytest.param(Version("2.0.1"), Version("2.0.0"), True, id="Patch is greater than"), - pytest.param(Version("2.0.10"), Version("2.0.0"), True, id="Patch is much greater than"), - pytest.param(Version("2.1.0"), Version("2.0.0"), True, id="Minor is greater than"), - pytest.param(Version("2.10.0"), Version("2.0.0"), True, id="Minor is much greater than"), - pytest.param(Version("3.0.0"), Version("2.0.0"), True, id="Major is greater than"), - pytest.param(Version("10.0.0"), Version("2.0.0"), True, id="Major is much greater than"), - pytest.param(Version("2.0.0"), Version("2.0.1"), False, id="Patch is lesser than"), - pytest.param(Version("2.0.0"), Version("2.0.10"), False, id="Patch is much lesser than"), - pytest.param(Version("2.0.0"), Version("2.1.0"), False, id="Minor is lesser than"), - pytest.param(Version("2.0.0"), Version("2.10.0"), False, id="Minor is much lesser than"), - pytest.param(Version("2.0.0"), Version("3.0.0"), False, id="Major is lesser than"), - pytest.param(Version("2.0.0"), Version("10.0.0"), False, id="Major is much lesser than"), - pytest.param(Version("2.0.0"), Version("2.0.0"), True, id="Equal versions no minor no patch"), - pytest.param(Version("2.0.1"), Version("2.0.1"), True, id="Equal versions patch increment"), - pytest.param(Version("2.1.0"), Version("2.1.0"), True, id="Equal versions minor increment"), - pytest.param(Version("3.0.0"), Version("3.0.0"), True, id="Equal versions major increment"), - pytest.param(Version("2.0.0"), "random string", False, id="Other object is string and not a Version object"), - pytest.param(Version("2.0.0"), 12345, False, id="Other object is int and not a Version object") - ] + pytest.param( + Version("2.0.1"), Version("2.0.0"), True, id="Patch is greater than" + ), + pytest.param( + Version("2.0.10"), Version("2.0.0"), True, id="Patch is much greater than" + ), + pytest.param( + Version("2.1.0"), Version("2.0.0"), True, id="Minor is greater than" + ), + pytest.param( + Version("2.10.0"), Version("2.0.0"), True, id="Minor is much greater than" + ), + pytest.param( + Version("3.0.0"), Version("2.0.0"), True, id="Major is greater than" + ), + pytest.param( + Version("10.0.0"), Version("2.0.0"), True, id="Major is much greater than" + ), + pytest.param( + Version("2.0.0"), Version("2.0.1"), False, id="Patch is lesser than" + ), + pytest.param( + Version("2.0.0"), Version("2.0.10"), False, id="Patch is much lesser than" + ), + pytest.param( + Version("2.0.0"), Version("2.1.0"), False, id="Minor is lesser than" + ), + pytest.param( + Version("2.0.0"), Version("2.10.0"), False, id="Minor is much lesser than" + ), + pytest.param( + Version("2.0.0"), Version("3.0.0"), False, id="Major is lesser than" + ), + pytest.param( + Version("2.0.0"), Version("10.0.0"), False, id="Major is much lesser than" + ), + pytest.param( + Version("2.0.0"), + Version("2.0.0"), + True, + id="Equal versions no minor no patch", + ), + pytest.param( + Version("2.0.1"), + Version("2.0.1"), + True, + id="Equal versions patch increment", + ), + pytest.param( + Version("2.1.0"), + Version("2.1.0"), + True, + id="Equal versions minor increment", + ), + pytest.param( + Version("3.0.0"), + Version("3.0.0"), + True, + id="Equal versions major increment", + ), + pytest.param( + Version("2.0.0"), + "random string", + False, + id="Other object is string and not a Version object", + ), + pytest.param( + Version("2.0.0"), + 12345, + False, + id="Other object is int and not a Version object", + ), + ], ) def test_version_comparison_greater_than_or_equal_to(first, second, expected): assert (first >= second) == expected + @pytest.mark.parametrize( "lesser, greater, expected", [ pytest.param(Version("2.0.0"), Version("2.0.1"), True, id="Patch is less than"), - pytest.param(Version("2.0.0"), Version("2.0.10"), True, id="Patch is much less than"), + pytest.param( + Version("2.0.0"), Version("2.0.10"), True, id="Patch is much less than" + ), pytest.param(Version("2.0.0"), Version("2.1.0"), True, id="Minor is less than"), - pytest.param(Version("2.0.0"), Version("2.10.0"), True, id="Minor is much less than"), + pytest.param( + Version("2.0.0"), Version("2.10.0"), True, id="Minor is much less than" + ), pytest.param(Version("2.0.0"), Version("3.0.0"), True, id="Major is less than"), - pytest.param(Version("2.0.0"), Version("10.0.0"), True, id="Major is much less than"), - pytest.param(Version("2.0.1"), Version("2.0.0"), False, id="Patch is greater than"), - pytest.param(Version("2.0.10"), Version("2.0.0"), False, id="Patch is much greater than"), - pytest.param(Version("2.1.0"), Version("2.0.0"), False, id="Minor is greater than"), - pytest.param(Version("2.10.0"), Version("2.0.0"), False, id="Minor is much greater than"), - pytest.param(Version("3.0.0"), Version("2.0.0"), False, id="Major is greater than"), - pytest.param(Version("10.0.0"), Version("2.0.0"), False, id="Major is much greater than"), - pytest.param(Version("2.0.0"), "random string", False, id="Other object is string and not a Version object"), - pytest.param(Version("2.0.0"), 12345, False, id="Other object is int and not a Version object") - ] + pytest.param( + Version("2.0.0"), Version("10.0.0"), True, id="Major is much less than" + ), + pytest.param( + Version("2.0.1"), Version("2.0.0"), False, id="Patch is greater than" + ), + pytest.param( + Version("2.0.10"), Version("2.0.0"), False, id="Patch is much greater than" + ), + pytest.param( + Version("2.1.0"), Version("2.0.0"), False, id="Minor is greater than" + ), + pytest.param( + Version("2.10.0"), Version("2.0.0"), False, id="Minor is much greater than" + ), + pytest.param( + Version("3.0.0"), Version("2.0.0"), False, id="Major is greater than" + ), + pytest.param( + Version("10.0.0"), Version("2.0.0"), False, id="Major is much greater than" + ), + pytest.param( + Version("2.0.0"), + "random string", + False, + id="Other object is string and not a Version object", + ), + pytest.param( + Version("2.0.0"), + 12345, + False, + id="Other object is int and not a Version object", + ), + ], ) def test_version_comparison_less_than(lesser, greater, expected): assert (lesser < greater) == expected + @pytest.mark.parametrize( "first, second, expected", [ pytest.param(Version("2.0.0"), Version("2.0.1"), True, id="Patch is less than"), - pytest.param(Version("2.0.0"), Version("2.0.10"), True, id="Patch is much less than"), + pytest.param( + Version("2.0.0"), Version("2.0.10"), True, id="Patch is much less than" + ), pytest.param(Version("2.0.0"), Version("2.1.0"), True, id="Minor is less than"), - pytest.param(Version("2.0.0"), Version("2.10.0"), True, id="Minor is much less than"), + pytest.param( + Version("2.0.0"), Version("2.10.0"), True, id="Minor is much less than" + ), pytest.param(Version("2.0.0"), Version("3.0.0"), True, id="Major is less than"), - pytest.param(Version("2.0.0"), Version("10.0.0"), True, id="Major is much less than"), - pytest.param(Version("2.0.1"), Version("2.0.0"), False, id="Patch is greater than"), - pytest.param(Version("2.0.10"), Version("2.0.0"), False, id="Patch is much greater than"), - pytest.param(Version("2.1.0"), Version("2.0.0"), False, id="Minor is greater than"), - pytest.param(Version("2.10.0"), Version("2.0.0"), False, id="Minor is much greater than"), - pytest.param(Version("3.0.0"), Version("2.0.0"), False, id="Major is greater than"), - pytest.param(Version("10.0.0"), Version("2.0.0"), False, id="Major is much greater than"), - pytest.param(Version("2.0.0"), Version("2.0.0"), True, id="Equal versions no minor no patch"), - pytest.param(Version("2.0.1"), Version("2.0.1"), True, id="Equal versions patch increment"), - pytest.param(Version("2.1.0"), Version("2.1.0"), True, id="Equal versions minor increment"), - pytest.param(Version("3.0.0"), Version("3.0.0"), True, id="Equal versions major increment"), - pytest.param(Version("2.0.0"), "random string", False, id="Other object is string and not a Version object"), - pytest.param(Version("2.0.0"), 12345, False, id="Other object is int and not a Version object"), - ] + pytest.param( + Version("2.0.0"), Version("10.0.0"), True, id="Major is much less than" + ), + pytest.param( + Version("2.0.1"), Version("2.0.0"), False, id="Patch is greater than" + ), + pytest.param( + Version("2.0.10"), Version("2.0.0"), False, id="Patch is much greater than" + ), + pytest.param( + Version("2.1.0"), Version("2.0.0"), False, id="Minor is greater than" + ), + pytest.param( + Version("2.10.0"), Version("2.0.0"), False, id="Minor is much greater than" + ), + pytest.param( + Version("3.0.0"), Version("2.0.0"), False, id="Major is greater than" + ), + pytest.param( + Version("10.0.0"), Version("2.0.0"), False, id="Major is much greater than" + ), + pytest.param( + Version("2.0.0"), + Version("2.0.0"), + True, + id="Equal versions no minor no patch", + ), + pytest.param( + Version("2.0.1"), + Version("2.0.1"), + True, + id="Equal versions patch increment", + ), + pytest.param( + Version("2.1.0"), + Version("2.1.0"), + True, + id="Equal versions minor increment", + ), + pytest.param( + Version("3.0.0"), + Version("3.0.0"), + True, + id="Equal versions major increment", + ), + pytest.param( + Version("2.0.0"), + "random string", + False, + id="Other object is string and not a Version object", + ), + pytest.param( + Version("2.0.0"), + 12345, + False, + id="Other object is int and not a Version object", + ), + ], ) def test_version_comparison_less_than_or_equal_to(first, second, expected): assert (first <= second) == expected + +def test_different_version_strings(): + v = Version("2.1.0-dev0+build8.7ee86bf8") + assert v.major == 2 and v.minor == 1 and v.patch == 0 + v = Version("2.1.0dev0+build8.7ee86bf8") + assert v.major == 2 and v.minor == 1 and v.patch == 0 + v = Version("2.1.0--dev0+build8.7ee86bf8") + assert v.major == 2 and v.minor == 1 and v.patch == 0 + v = Version("2.1.0_dev0+build8.7ee86bf8") + assert v.major == 2 and v.minor == 1 and v.patch == 0 + v = Version("2.1.0") + assert v.major == 2 and v.minor == 1 and v.patch == 0 + v = Version("2.1.0-") + assert v.major == 2 and v.minor == 1 and v.patch == 0 + + with pytest.raises(ValueError): + Version("2.1-dev0+build8.7ee86bf8") + with pytest.raises(ValueError): + Version("2-dev0+build8.7ee86bf8") + with pytest.raises(ValueError): + Version("54dev0+build8.7ee86bf8") + + def test_import_lab_offline( - client_library_compatible_version, mocked_session, tmp_path: Path + client_library_server_2_0_0, mocked_session, tmp_path: Path ): client_library = ClientLibrary( url="http://0.0.0.0/fake_url/", username="test", password="pa$$" ) - topology_file_path = "import_export/SampleData/topology-v0_0_4.json" - topology = pkg_resources.resource_string("simple_common", topology_file_path) - client_library.import_lab(topology, "topology-v0_0_4", offline=True) + topology_file_path = Path("tests/test_data/sample_topology.json") + with open(topology_file_path) as fh: + topology_file = fh.read() + client_library.import_lab(topology_file, "topology-v0_0_4", offline=True) diff --git a/tests/test_client_library_integration.py b/tests/test_client_library_integration.py index c8dd396..6ae8331 100644 --- a/tests/test_client_library_integration.py +++ b/tests/test_client_library_integration.py @@ -21,6 +21,7 @@ import json import pytest import requests +import logging from virl2_client import ClientLibrary @@ -225,7 +226,7 @@ def test_server_node_deletion(client_library: ClientLibrary): # can't remove node while running with pytest.raises(requests.exceptions.HTTPError) as exc: lab.remove_node(s3) - assert exc.value.response.status_code == 403 + assert exc.value.response.status_code == 400 # need to stop and wipe to be able to remove node. s3.stop() @@ -233,19 +234,174 @@ def test_server_node_deletion(client_library: ClientLibrary): lab.remove_node(s3) -def test_import_json(client_library: ClientLibrary): - lab = client_library.import_sample_lab("server-triangle.ng") +def test_user_management(client_library: ClientLibrary): + test_user = "test_user" + test_password = "test_password" + + res = client_library.user_management.create_user( + username=test_user, pwd=test_password + ) + assert isinstance(res, dict) + assert res["username"] == test_user + assert res["fullname"] == "" + assert res["description"] == "" + test_user_id = res["id"] + + # changing only fullname + res = client_library.user_management.update_user( + user_id=test_user_id, fullname=test_user + ) + assert res["fullname"] == test_user + assert res["description"] == "" + + # changing only description; already changed fullname must be kept + res = client_library.user_management.update_user( + user_id=test_user_id, description=test_user + ) + assert res["fullname"] == test_user + assert res["description"] == test_user + + # changing both fullname and description + res = client_library.user_management.update_user( + user_id=test_user_id, + description=test_user + test_user, + fullname=test_user + test_user, + ) + assert res["fullname"] == test_user + test_user + assert res["description"] == test_user + test_user + + user = client_library.user_management.get_user(test_user_id) + assert isinstance(user, dict) + assert user["username"] == test_user + assert "admin" in user and "groups" in user + assert res["fullname"] == test_user + test_user + assert res["description"] == test_user + test_user + + users = client_library.user_management.users() + assert isinstance(users, list) + assert test_user in [user["username"] for user in users] + + res = client_library.user_management.update_user( + user_id=test_user_id, + password_dict=dict( + old_password=test_password, new_password="new_test_password" + ), + ) + assert isinstance(res, dict) + assert test_user_id == res["id"] + res = client_library.user_management.delete_user(user_id=test_user_id) + assert res is None + + users = client_library.user_management.users() + assert isinstance(users, list) + assert test_user not in [user["username"] for user in users] + + with pytest.raises(requests.exceptions.HTTPError): + client_library.user_management.get_user(test_user_id) + + # non existent role should return 400 - Bad request + with pytest.raises(requests.exceptions.HTTPError) as err: + client_library.user_management.create_user( + username=test_user_id, pwd=test_password, admin=["non-existent-role"] + ) + assert err.value.response.status_code == 400 + assert "not of type 'boolean'" in err.value.response.text + + # delete non-existent user + with pytest.raises(requests.exceptions.HTTPError) as err: + client_library.user_management.delete_user(user_id="non-existent-user") + assert err.value.response.status_code == 404 + assert "User does not exist" in err.value.response.text + + +def test_password_word_list(client_library: ClientLibrary): + test_user = "another-test-user" + restricted_password = "password" + # try to create user with restricted password - must fail + with pytest.raises(requests.exceptions.HTTPError) as err: + client_library.user_management.create_user( + username=test_user, pwd=restricted_password + ) + assert err.value.response.status_code == 403 + assert "password in common word list" in err.value.response.text + + good_pwd = restricted_password + "dfsjdf" + res = client_library.user_management.create_user(username=test_user, pwd=good_pwd) + assert isinstance(res, dict) + assert res["username"] == test_user + test_user_id = res["id"] + + # try to change password to restricted password - must fail + with pytest.raises(requests.exceptions.HTTPError) as err: + client_library.user_management.update_user( + user_id=test_user_id, + password_dict=dict(old_password=good_pwd, new_password=restricted_password), + ) + assert err.value.response.status_code == 403 + assert "password in common word list" in err.value.response.text + + res = client_library.user_management.delete_user(user_id=test_user_id) + assert res is None + + with pytest.raises(requests.exceptions.HTTPError): + client_library.user_management.get_user(test_user_id) + + +def test_webtoken_config(client_library_session: ClientLibrary): + orig = client_library_session.system_management.get_web_session_timeout() + + client_library_session.system_management.set_web_session_timeout(3600) + res = client_library_session.system_management.get_web_session_timeout() + assert res == 3600 + client_library_session.system_management.set_web_session_timeout(orig) + res = client_library_session.system_management.get_web_session_timeout() + assert res == orig + + +def test_mac_addr_block_config(client_library_session: ClientLibrary): + orig = client_library_session.system_management.get_mac_address_block() + + client_library_session.system_management.set_mac_address_block(7) + res = client_library_session.system_management.get_mac_address_block() + assert res == 7 + + client_library_session.system_management.set_mac_address_block(orig) + res = client_library_session.system_management.get_mac_address_block() + assert res == orig + + # client validation + with pytest.raises(ValueError): + client_library_session.system_management.set_mac_address_block(8) + + with pytest.raises(ValueError): + client_library_session.system_management.set_mac_address_block(-1) + + # server validation + with pytest.raises(requests.exceptions.HTTPError) as err: + client_library_session.system_management._set_mac_address_block(8) + assert err.value.response.status_code == 400 + + with pytest.raises(requests.exceptions.HTTPError) as err: + client_library_session.system_management._set_mac_address_block(-1) + assert err.value.response.status_code == 400 + + +def test_import_json(client_library_session: ClientLibrary): + lab = client_library_session.import_sample_lab("server-triangle.ng") assert lab is not None + lab.remove() -def test_import_yaml(client_library: ClientLibrary): - lab = client_library.import_sample_lab("server-triangle.yaml") +def test_import_yaml(client_library_session: ClientLibrary): + lab = client_library_session.import_sample_lab("server-triangle.yaml") assert lab is not None + lab.remove() -def test_import_virl(client_library: ClientLibrary): - lab = client_library.import_sample_lab("dual-server.virl") +def test_import_virl(client_library_session: ClientLibrary): + lab = client_library_session.import_sample_lab("dual-server.virl") assert lab is not None + lab.remove() def test_lab_state(client_library: ClientLibrary): @@ -321,49 +477,281 @@ def test_labels_and_tags(client_library: ClientLibrary): assert len(node_3.tags()) == 5 -def test_remove_non_existent_node_definition(client_library: ClientLibrary): +def test_remove_non_existent_node_definition(client_library_session: ClientLibrary): def_id = "non_existent_node_definition" with pytest.raises(requests.exceptions.HTTPError) as err: - client_library.definitions.remove_node_definition(definition_id=def_id) + client_library_session.definitions.remove_node_definition(definition_id=def_id) assert err.value.response.status_code == 404 -def test_remove_non_existent_dropfolder_image(client_library: ClientLibrary): +def test_remove_non_existent_dropfolder_image(client_library_session: ClientLibrary): filename = "non_existent_file" with pytest.raises(requests.exceptions.HTTPError) as err: - client_library.definitions.remove_dropfolder_image(filename=filename) + client_library_session.definitions.remove_dropfolder_image(filename=filename) assert err.value.response.status_code == 404 -def test_node_with_unavailable_vnc(client_library: ClientLibrary): - lab = client_library.create_lab("lab_111") +def test_node_with_unavailable_vnc(client_library_session: ClientLibrary): + lab = client_library_session.create_lab("lab_111") node = lab.create_node("s1", "unmanaged_switch", 5, 100) lab.start() assert lab.state() == "STARTED" with pytest.raises(requests.exceptions.HTTPError) as err: node.vnc_key() assert err.value.response.status_code == 404 + lab.stop() + lab.wipe() + lab.remove() + + +@pytest.mark.nomock +def test_node_console_logs(client_library_session: ClientLibrary): + lab = client_library_session.create_lab("lab_space") + ext_conn = lab.create_node("ec", "external_connector", 100, 50, wait=False) + server = lab.create_node("s1", "server", 100, 100) + iosv = lab.create_node("n", "iosv", 50, 0) + lab.start() + assert lab.state() == "STARTED" + # server has one serial console on id 0 + logs = server.console_logs(console_id=0) + assert type(logs) == str + + # external connector - no serial console + with pytest.raises(requests.exceptions.HTTPError) as err: + ext_conn.console_logs(console_id=0) + assert err.value.response.status_code == 400 + assert "Serial port does not exist on node" in err.value.response.text + + # test limited number of lines + num_lines = 5 + logs = server.console_logs(console_id=0, lines=num_lines) + assert type(logs) == str + assert len(logs.split("\n")) == num_lines + + # assert 400 for non existent console id for server >0 + with pytest.raises(requests.exceptions.HTTPError) as err: + server.console_logs(console_id=55) + assert err.value.response.status_code == 400 + assert "Serial port does not exist on node" in err.value.response.text + + # iosv has 2 serial consoles + logs = iosv.console_logs(console_id=0) + assert type(logs) == str + logs = iosv.console_logs(console_id=1) + assert type(logs) == str + with pytest.raises(requests.exceptions.HTTPError) as err: + iosv.console_logs(console_id=2) + assert err.value.response.status_code == 400 + assert "Serial port does not exist on node" in err.value.response.text + + lab.stop() + lab.wipe() + lab.remove() -def test_upload_node_definition_invalid_body(client_library: ClientLibrary): +def test_upload_node_definition_invalid_body(client_library_session: ClientLibrary): with pytest.raises(requests.exceptions.HTTPError) as err: - client_library.definitions.upload_node_definition(body=json.dumps(None)) + client_library_session.definitions.upload_node_definition(body=json.dumps(None)) assert err.value.response.status_code == 400 with pytest.raises(requests.exceptions.HTTPError) as err: - client_library.definitions.upload_node_definition( + client_library_session.definitions.upload_node_definition( body=json.dumps({"id": "test1"}) ) assert err.value.response.status_code == 400 with pytest.raises(requests.exceptions.HTTPError) as err: - client_library.definitions.upload_node_definition( + client_library_session.definitions.upload_node_definition( body=json.dumps({"general": {}}) ) assert err.value.response.status_code == 400 -def test_topology_owner(client_library_keep_labs: ClientLibrary): - lab = client_library_keep_labs.create_lab("owned_by_cml2") +def test_topology_owner(client_library_session: ClientLibrary): + cml2_uid = client_library_session.user_management.user_id("cml2") + lab = client_library_session.create_lab("owned_by_cml2") lab.sync(topology_only=True) - assert lab.owner == "cml2" + assert lab.owner == cml2_uid + lab.remove() + + +@pytest.mark.nomock +def test_server_tokens_off(controller_url): + resp = requests.get(controller_url, verify=False) + headers = resp.headers + # has to equal to 'nginx' without version + assert headers["Server"] == "nginx" + + +def test_user_role_change(controller_url, client_library_session: ClientLibrary): + cl_admin = client_library_session + cl_admin_uid = client_library_session.user_management.user_id(cl_admin.username) + # create non admin users + cl_user1, cl_user2 = "cl_user1", "cl_user2" + password = "super-secret" + res = cl_admin.user_management.create_user(username=cl_user1, pwd=password) + cl_user1_uid = res["id"] + res = cl_admin.user_management.create_user(username=cl_user2, pwd=password) + cl_user2_uid = res["id"] + + cl_user1 = ClientLibrary( + controller_url, + username=cl_user1, + password=password, + ssl_verify=False, + allow_http=True, + ) + cl_user2 = ClientLibrary( + controller_url, + username=cl_user2, + password=password, + ssl_verify=False, + allow_http=True, + ) + + cl_user1.create_lab("lab1-cl_user1") + cl_user1.create_lab("lab2-cl_user1") + + assert cl_user2.all_labs(show_all=True) == [] + # promote cl_user2 to admin + cl_admin.user_management.update_user(user_id=cl_user2_uid, admin=True) + # check if cl_user2 can see all the labs as admin + all_labs = cl_user2.all_labs(show_all=True) + assert len(all_labs) == 2 + assert all_labs[0].owner == cl_user1_uid + + # check if cl_user2 can create user and delete users + res = cl_user2.user_management.create_user(username="TestUser", pwd=password) + assert cl_user2.user_management.get_user(user_id=res["id"]) + + cl_user2.user_management.delete_user(user_id=res["id"]) + with pytest.raises(requests.exceptions.HTTPError) as err: + cl_user2.user_management.get_user(user_id=res["id"]) + assert err.value.response.status_code == 404 + + # check cl_user2 can see licensing + assert cl_user2.licensing.status() + + for lab in all_labs: + cl_user2.remove_lab(lab.id) + assert cl_user2.all_labs(show_all=True) == [] + + # promote cl_user1 to admin + cl_admin.user_management.update_user(user_id=cl_user1_uid, admin=True) + + # check if cl_user1 can remove admin cl_user2 + cl_user1.user_management.delete_user(user_id=cl_user2_uid) + with pytest.raises(requests.exceptions.HTTPError) as err: + cl_user1.user_management.get_user(user_id=cl_user2_uid) + assert err.value.response.status_code == 404 + + # remove admin rights from cl_user1 + cl_admin.user_management.update_user(user_id=cl_user1_uid, admin=False) + lab = cl_admin.create_lab("origin-lab") + assert cl_user1.all_labs(show_all=True) == [] + + with pytest.raises(requests.exceptions.HTTPError) as err: + cl_user1.user_management.create_user(username="TestUser", pwd=password) + assert err.value.response.status_code == 403 + + with pytest.raises(requests.exceptions.HTTPError) as err: + cl_user1.licensing.status() + assert err.value.response.status_code == 403 + + cl_admin.user_management.delete_user(user_id=cl_user1_uid) + + # check that user cannot update its own user role + with pytest.raises(requests.exceptions.HTTPError) as err: + cl_admin.user_management.update_user(user_id=cl_admin_uid, admin=False) + assert err.value.response.status_code == 400 + + # cleanup + cl_admin.remove_lab(lab.id) + + +def test_token_invalidation( + caplog, controller_url, client_library_session: ClientLibrary +): + cl_admin = client_library_session + + res = cl_admin.user_management.create_user(username="test_user", pwd="moremore") + test_user_uid = res["id"] + + cl_test_user = ClientLibrary( + controller_url, + username="test_user", + password="moremore", + ssl_verify=False, + allow_http=True, + ) + # make sure user token works + res = cl_test_user.user_management.users() + assert isinstance(res, list) + for obj in res: + assert "username" in obj and "id" in obj + + # CHANGE PASSWORD + new_pwd = "moremoreevenmore" + res = cl_test_user.user_management.update_user( + user_id=test_user_uid, + password_dict=dict(old_password="moremore", new_password=new_pwd), + ) + assert isinstance(res, dict) + cl_test_user.password = new_pwd + with caplog.at_level(logging.WARNING): + res = cl_test_user.user_management.users() + assert isinstance(res, list) + assert "re-auth called on 401 unauthorized" in caplog.text + + # CHANGE ROLE + res = cl_admin.user_management.update_user(user_id=test_user_uid, admin=True) + assert isinstance(res, dict) + assert res["admin"] is True + with caplog.at_level(logging.WARNING): + res = cl_test_user.user_management.users() + assert isinstance(res, list) + assert "re-auth called on 401 unauthorized" in caplog.text + + # LOGOUT + res = cl_test_user.logout() + assert res is True + with caplog.at_level(logging.WARNING): + res = cl_test_user.user_management.users() + assert isinstance(res, list) + assert "re-auth called on 401 unauthorized" in caplog.text + + # CLEAR SESSION + # create another client library object (this generates new token) + cl_test_user1 = ClientLibrary( + controller_url, + username="test_user", + password=new_pwd, + ssl_verify=False, + allow_http=True, + ) + # clear whole test_user session (remove all tokens) + res = cl_test_user.logout(clear_all_sessions=True) + assert res is True + # cl_test_user + with caplog.at_level(logging.WARNING): + res = cl_test_user.user_management.users() + assert isinstance(res, list) + assert "re-auth called on 401 unauthorized" in caplog.text + # cl_test_user1 + with caplog.at_level(logging.WARNING): + res = cl_test_user1.user_management.users() + assert isinstance(res, list) + assert "re-auth called on 401 unauthorized" in caplog.text + + # DELETE USER + res = cl_admin.user_management.delete_user(user_id=test_user_uid) + # test that user token works no more + with pytest.raises(requests.exceptions.HTTPError) as err: + with caplog.at_level(logging.WARNING): + cl_test_user.user_management.users() + assert isinstance(res, list) + assert "re-auth called on 401 unauthorized" in caplog.text + # normally you would expect 401 here, it was returned, however + # response hook that catches 401 TokenAuth.handle_401_unauthorized + # tried to authenticate and user no longer exists - so 403 Forbidden + assert err.value.response.status_code == 403 diff --git a/tests/test_data/sample_topology.json b/tests/test_data/sample_topology.json new file mode 100644 index 0000000..6427518 --- /dev/null +++ b/tests/test_data/sample_topology.json @@ -0,0 +1,43 @@ +{ + "nodes": [ + { + "id": "n0", + "data": { + "node_definition": "alpine", + "image_definition": "alpine-3-12-base", + "label": "alpine-0", + "configuration": "# this is a shell script which will be sourced at boot\n# if you change the hostname then you need to add a\n# /etc/hosts entry as well\n# hostname inserthostname_here\n# like this:\n# echo \"127.0.0.1 inserthostname_here\" >>/etc/hosts", + "x": -50, + "y": -200, + "state": "STOPPED", + "ram": null, + "cpus": null, + "cpu_limit": null, + "data_volume": null, + "boot_disk_size": null, + "tags": [] + } + } + ], + "links": [], + "interfaces": [ + { + "id": "i0", + "node": "n0", + "data": { + "label": "eth0", + "slot": 0, + "state": "STOPPED", + "type": "physical" + } + } + ], + "lab_notes": "", + "lab_title": "Lab at Fri 13:33 PM", + "lab_description": "", + "lab_owner": "cml2", + "state": "STOPPED", + "created_timestamp": 1600428834.497289, + "cluster_id": "cluster_1", + "version": "0.0.4" + } diff --git a/tests/test_group_api.py b/tests/test_group_api.py new file mode 100644 index 0000000..96d090b --- /dev/null +++ b/tests/test_group_api.py @@ -0,0 +1,736 @@ +# +# Python bindings for the Cisco VIRL 2 Network Simulation Platform +# +# This file is part of VIRL 2 +# +# Copyright 2020 Cisco Systems Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import uuid +import pytest +import requests + +from virl2_client import ClientLibrary + +pytestmark = [pytest.mark.integration] + + +def test_group_api_basic(client_library_session: ClientLibrary): + cl = client_library_session + g0 = cl.group_management.create_group(name="g0") + assert isinstance(g0, dict) + assert "id" in g0 and uuid.UUID(g0["id"], version=4) + assert g0["description"] == "" + assert g0["members"] == [] and g0["labs"] == [] + + # try to create group with same name + with pytest.raises(requests.exceptions.HTTPError) as err: + cl.group_management.create_group(name="g0") + assert err.value.response.status_code == 422 + assert "Group already exists" in err.value.response.text + + # same object must be returned in get as upon create + g0_a = cl.group_management.get_group(group_id=g0["id"]) + assert g0 == g0_a + + # get non-existent group + with pytest.raises(requests.exceptions.HTTPError) as err: + cl.group_management.get_group(group_id="non-existent") + assert err.value.response.status_code == 404 + assert "Group does not exist" in err.value.response.text + + all_groups = cl.group_management.groups() + assert isinstance(all_groups, list) + g0_b = all_groups[0] + assert g0_b == g0_a and g0_b == g0 + + # groups labs is empty list + group_labs = cl.group_management.group_labs(group_id=g0["id"]) + assert group_labs == [] + + with pytest.raises(requests.exceptions.HTTPError) as err: + cl.group_management.group_labs(group_id="non-existent") + assert err.value.response.status_code == 404 + assert "Group does not exist" in err.value.response.text + + # groups members is empty list + group_members = cl.group_management.group_members(group_id=g0["id"]) + assert group_members == [] + + with pytest.raises(requests.exceptions.HTTPError) as err: + cl.group_management.group_members(group_id="non-existent") + assert err.value.response.status_code == 404 + assert "Group does not exist" in err.value.response.text + + # update group + new_description = "new_description" + new_name = "g1" + updated_g0 = cl.group_management.update_group( + group_id=g0["id"], description=new_description, name=new_name + ) + assert updated_g0["id"] == g0["id"] + assert updated_g0["description"] == new_description + assert updated_g0["name"] == new_name + updated_g0_a = cl.group_management.get_group(group_id=updated_g0["id"]) + assert updated_g0 == updated_g0_a + all_groups = cl.group_management.groups() + assert all_groups[0] == updated_g0 + + # make sure name cannot be set to empty string + with pytest.raises(requests.exceptions.HTTPError) as err: + cl.group_management.update_group( + group_id=updated_g0["id"], description=new_description, name="" + ) + assert err.value.response.status_code == 400 + + # update non-existent group + with pytest.raises(requests.exceptions.HTTPError) as err: + cl.group_management.update_group( + group_id="non-existent", description=new_description + ) + assert err.value.response.status_code == 404 + assert "Group does not exist" in err.value.response.text + + # delete non existent group + with pytest.raises(requests.exceptions.HTTPError) as err: + cl.group_management.delete_group(group_id="non-existent") + assert err.value.response.status_code == 404 + assert "Group does not exist" in err.value.response.text + + assert cl.group_management.delete_group(group_id=updated_g0["id"]) is None + assert cl.group_management.groups() == [] + with pytest.raises(requests.exceptions.HTTPError) as err: + cl.group_management.get_group(group_id=updated_g0["id"]) + assert err.value.response.status_code == 404 + assert "Group does not exist" in err.value.response.text + + +def test_group_api_invalid_request_data(client_library_session: ClientLibrary): + cl = client_library_session + with pytest.raises(requests.exceptions.HTTPError) as err: + cl.group_management.create_group(name="") + assert err.value.response.status_code == 400 + with pytest.raises(requests.exceptions.HTTPError) as err: + cl.group_management.create_group(name="xxx", description=[{}]) + assert err.value.response.status_code == 400 + with pytest.raises(requests.exceptions.HTTPError) as err: + cl.group_management.create_group(name=458) + assert err.value.response.status_code == 400 + with pytest.raises(requests.exceptions.HTTPError) as err: + cl.group_management.create_group(name="xxx", members="incorrect type") + assert err.value.response.status_code == 400 + with pytest.raises(requests.exceptions.HTTPError) as err: + cl.group_management.create_group(name="xxx", labs=[{}]) + assert err.value.response.status_code == 400 + with pytest.raises(requests.exceptions.HTTPError) as err: + cl.group_management.create_group(name="xxx", labs=["dsdsds"]) + assert err.value.response.status_code == 400 + with pytest.raises(requests.exceptions.HTTPError) as err: + cl.group_management.create_group(name="xxx", labs=[{"id": "x"}]) + assert err.value.response.status_code == 400 + with pytest.raises(requests.exceptions.HTTPError) as err: + cl.group_management.create_group(name="xxx", labs=[{"permission": "read_only"}]) + assert err.value.response.status_code == 400 + with pytest.raises(requests.exceptions.HTTPError) as err: + cl.group_management.create_group( + name="xxx", labs=[{"id": "dsd", "permission": "rw"}] + ) + assert err.value.response.status_code == 400 + with pytest.raises(requests.exceptions.HTTPError) as err: + cl.user_management.create_user("xxx", "hardpass", groups="pedro") + assert err.value.response.status_code == 400 + lab = cl.create_lab() + with pytest.raises(requests.exceptions.HTTPError) as err: + lab.update_lab_groups(["x"]) + assert err.value.response.status_code == 400 + with pytest.raises(requests.exceptions.HTTPError) as err: + lab.update_lab_groups([{"not_id": "x", "permission": "read_write"}]) + assert err.value.response.status_code == 400 + with pytest.raises(requests.exceptions.HTTPError) as err: + lab.update_lab_groups([{"id": "x", "perm": "read_write"}]) + assert err.value.response.status_code == 400 + with pytest.raises(requests.exceptions.HTTPError) as err: + lab.update_lab_groups([{"id": "x", "perm": "not_one_of_read_write_only"}]) + assert err.value.response.status_code == 400 + lab.remove() + gg0 = cl.group_management.create_group("gg0") + gg0_uid = gg0["id"] + with pytest.raises(requests.exceptions.HTTPError) as err: + cl.group_management.update_group(gg0_uid, labs=["labs"]) + assert err.value.response.status_code == 400 + with pytest.raises(requests.exceptions.HTTPError) as err: + cl.group_management.update_group(gg0_uid, labs=[{"id": "dsdsd"}]) + assert err.value.response.status_code == 400 + with pytest.raises(requests.exceptions.HTTPError) as err: + cl.group_management.update_group(gg0_uid, labs=[{"permission": "read_only"}]) + assert err.value.response.status_code == 400 + with pytest.raises(requests.exceptions.HTTPError) as err: + cl.group_management.update_group( + "gg0", labs=[{"id": "dsdsd", "permission": "xxx"}] + ) + assert err.value.response.status_code == 400 + with pytest.raises(requests.exceptions.HTTPError) as err: + cl.group_management.update_group(gg0_uid, name="") + assert err.value.response.status_code == 400 + cl.group_management.delete_group(gg0_uid) + + +def test_group_api_user_associations(client_library_session: ClientLibrary): + cl = client_library_session + # create non admin users + satoshi = cl.user_management.create_user(username="satoshi", pwd="super-secret-pwd") + satoshi_uid = satoshi["id"] + assert uuid.UUID(satoshi_uid, version=4) + nick_szabo = cl.user_management.create_user( + username="nick_szabo", pwd="super-secret-pwd" + ) + nick_szabo_uid = nick_szabo["id"] + assert uuid.UUID(nick_szabo_uid, version=4) + # create teachers group and immediately add teacher user to it + teachers_group = cl.group_management.create_group( + name="teachers", description="teachers group", members=[satoshi_uid] + ) + assert "id" in teachers_group and uuid.UUID(teachers_group["id"], version=4) + assert teachers_group["members"] == [satoshi_uid] + assert ( + cl.group_management.group_members(group_id=teachers_group["id"]) + == teachers_group["members"] + ) + assert cl.user_management.user_groups(user_id=satoshi_uid) == [teachers_group["id"]] + assert ( + cl.group_management.get_group(group_id=teachers_group["id"]) == teachers_group + ) + + # create students group + students_group = cl.group_management.create_group( + name="students", + description="students group", + ) + # add user to group subsequently + students_group = cl.group_management.update_group( + group_id=students_group["id"], members=[nick_szabo_uid] + ) + assert "id" in students_group and uuid.UUID(students_group["id"], version=4) + assert students_group["members"] == [nick_szabo_uid] + assert ( + cl.group_management.group_members(group_id=students_group["id"]) + == students_group["members"] + ) + assert cl.user_management.user_groups(user_id=nick_szabo_uid) == [ + students_group["id"] + ] + assert ( + cl.group_management.get_group(group_id=students_group["id"]) == students_group + ) + + # add group to user during user creation + nocoiners = cl.group_management.create_group( + name="nocoiners", description="group for nocoiners" + ) + mario_draghi = cl.user_management.create_user( + username="mario_draghi", pwd="super-secret-pwd", groups=[nocoiners["id"]] + ) + mario_draghi_uid = mario_draghi["id"] + assert cl.user_management.user_groups(user_id=mario_draghi_uid) == [nocoiners["id"]] + assert cl.group_management.group_members(group_id=nocoiners["id"]) == [ + mario_draghi_uid + ] + + # try add non-existent user to group + with pytest.raises(requests.exceptions.HTTPError) as err: + cl.group_management.update_group( + group_id=teachers_group["id"], members=["non-existent"] + ) + assert err.value.response.status_code == 404 + assert "User does not exist" in err.value.response.text + + # try add non-existent group to user + with pytest.raises(requests.exceptions.HTTPError) as err: + cl.user_management.create_user( + username="xxx", pwd="super-secret-pwd", groups=["non-existent"] + ) + assert err.value.response.status_code == 404 + assert "Group does not exist" in err.value.response.text + # above should not create user + assert "xxx" not in [ + user_obj["username"] for user_obj in cl.user_management.users() + ] + + # CLEAN UP + # remove nocoiner group and draghi user + assert cl.user_management.delete_user(user_id=mario_draghi_uid) == None + assert cl.group_management.delete_group(group_id=nocoiners["id"]) is None + # remove teachers group - check if not in user_groups + assert cl.group_management.delete_group(group_id=teachers_group["id"]) is None + assert cl.user_management.user_groups(user_id=satoshi_uid) == [] + assert cl.user_management.delete_user(user_id=satoshi_uid) == None + # remove user first - check if not part of the group + assert cl.user_management.delete_user(user_id=nick_szabo_uid) == None + assert cl.group_management.group_members(group_id=students_group["id"]) == [] + assert cl.group_management.delete_group(group_id=students_group["id"]) is None + # check if clean + assert cl.group_management.groups() == [] + assert len(cl.user_management.users()) == 1 # only cml2 user left + + +def test_group_api_lab_associations(client_library_session: ClientLibrary): + cl = client_library_session + # assert there is no lab + assert cl.all_labs(show_all=True) == [] + # create lab + lab0 = cl.create_lab(title="lab0") + # create group and add lab into it with rw + lab0_rw = [{"id": lab0.id, "permission": "read_write"}] + teachers_group = cl.group_management.create_group( + name="teachers", description="teachers group", labs=lab0_rw + ) + assert teachers_group["labs"] == lab0_rw + teachers_group_labs = cl.group_management.group_labs(group_id=teachers_group["id"]) + assert teachers_group_labs == [lab0.id] + assert lab0.groups == [{"id": teachers_group["id"], "permission": "read_write"}] + # remove association between group and lab using lab endpoint + assert lab0.update_lab_groups(group_list=[]) == [] + assert lab0.groups == [] + assert cl.group_management.get_group(group_id=teachers_group["id"])["labs"] == [] + # reinstantiate association via group update - change to read_only + lab0_ro = [{"id": lab0.id, "permission": "read_only"}] + teachers_group = cl.group_management.update_group( + group_id=teachers_group["id"], labs=lab0_ro + ) + assert teachers_group["labs"] == lab0_ro + assert lab0.groups == [{"id": teachers_group["id"], "permission": "read_only"}] + teachers_group_labs = cl.group_management.group_labs(group_id=teachers_group["id"]) + assert teachers_group_labs == [lab0.id] + # remove association via group update endpoint + teachers_group = cl.group_management.update_group( + group_id=teachers_group["id"], labs=[] + ) + assert lab0.groups == [] + assert cl.group_management.get_group(group_id=teachers_group["id"])["labs"] == [] + teachers_group_labs = cl.group_management.group_labs(group_id=teachers_group["id"]) + assert teachers_group_labs == [] + + # create on emore lab and group and create an association between them + # so that we can test both 1. lab removal 2. group removal + lab1 = cl.create_lab(title="lab1") + lab1_rw = [{"id": lab1.id, "permission": "read_write"}] + students_group = cl.group_management.create_group( + name="students", description="students group", labs=lab1_rw + ) + assert lab1.groups == [{"id": students_group["id"], "permission": "read_write"}] + students_group_labs = cl.group_management.group_labs(group_id=students_group["id"]) + + assert students_group_labs == [lab1.id] + + # add non-existent lab to group (create) + with pytest.raises(requests.exceptions.HTTPError) as err: + cl.group_management.create_group( + name="xxx", labs=[{"id": "non-existent", "permission": "read_only"}] + ) + assert err.value.response.status_code == 404 + assert "Lab not found" in err.value.response.text + assert "xxx" not in [group["name"] for group in cl.group_management.groups()] + + # add non-existent lab to group (update) + with pytest.raises(requests.exceptions.HTTPError) as err: + cl.group_management.update_group( + group_id=teachers_group["id"], + labs=[{"id": "non-existent", "permission": "read_only"}], + ) + assert err.value.response.status_code == 404 + assert "Lab not found" in err.value.response.text + assert "non-existent" not in cl.group_management.group_labs( + group_id=teachers_group["id"] + ) + + # add non-existent group to lab + with pytest.raises(requests.exceptions.HTTPError) as err: + lab0.update_lab_groups( + group_list=[{"id": "non-existent", "permission": "read_only"}] + ) + assert err.value.response.status_code == 404 + assert "Group does not exist" in err.value.response.text + assert "non-existent" not in [obj["id"] for obj in lab0.groups] + + # CLEAN UP + # remove lab first + lab1.remove() + assert cl.find_labs_by_title(title="lab1") == [] + # assert associtation is removed also + students_group_labs = cl.group_management.group_labs(group_id=students_group["id"]) + assert students_group_labs == [] + assert cl.group_management.get_group(group_id=students_group["id"])["labs"] == [] + assert cl.group_management.delete_group(group_id=students_group["id"]) is None + # remove group first + assert cl.group_management.delete_group(group_id=teachers_group["id"]) is None + with pytest.raises(requests.exceptions.HTTPError) as err: + cl.group_management.group_labs(group_id=teachers_group["id"]) + assert err.value.response.status_code == 404 + assert "Group does not exist" in err.value.response.text + assert lab0.groups == [] + lab0.remove() + assert cl.group_management.groups() == [] + assert cl.all_labs(show_all=True) == [] + + +def test_group_api_permissions(controller_url, client_library_session: ClientLibrary): + cl_admin = client_library_session + # create non-admin user + username = "satoshi" + satoshi_pwd = "super-secret-pwd" + satoshi = cl_admin.user_management.create_user(username=username, pwd=satoshi_pwd) + halfinn = cl_admin.user_management.create_user( + username="halfinney", pwd=satoshi_pwd + ) + satoshi_uid = satoshi["id"] + cml2_uid = client_library_session.user_management.user_id(username="cml2") + halfinn_uid = client_library_session.user_management.user_id(username="halfinney") + # assert there is no lab + assert cl_admin.all_labs(show_all=True) == [] + # create lab + lab0 = cl_admin.create_lab(title="lab0") + lab1 = cl_admin.create_lab(title="lab1") + # create students group + lab0_ro = [{"id": lab0.id, "permission": "read_only"}] + lab0_1_rw = [ + {"id": lab0.id, "permission": "read_write"}, + {"id": lab1.id, "permission": "read_write"}, + ] + students_group = cl_admin.group_management.create_group( + name="students", + description="students group", + members=[satoshi_uid], + labs=lab0_ro, + ) + teachers_group = cl_admin.group_management.create_group( + name="teachers", description="teachers group", members=[], labs=lab0_1_rw + ) + all_groups = cl_admin.group_management.groups() + assert len(all_groups) == 2 + all_groups_names = [group["id"] for group in all_groups] + assert students_group["id"] in all_groups_names + assert teachers_group["id"] in all_groups_names + + # log in as non-admin satoshi user + cl_satoshi = ClientLibrary( + controller_url, + username=username, + password=satoshi_pwd, + ssl_verify=False, + allow_http=True, + ) + # satoshi must only see groups that he is part of + satoshi_groups = cl_satoshi.group_management.groups() + assert len(satoshi_groups) == 1 + assert satoshi_groups[0]["name"] == "students" + assert cl_satoshi.user_management.user_groups(user_id=satoshi_uid) == [ + students_group["id"] + ] + + # cannot check other user info + with pytest.raises(requests.exceptions.HTTPError) as err: + cl_satoshi.user_management.user_groups(user_id=cml2_uid) + assert err.value.response.status_code == 403 + assert "User does not have required access" in err.value.response.text + + # user cannot see groups he is not part of + with pytest.raises(requests.exceptions.HTTPError) as err: + cl_satoshi.group_management.get_group(group_id=teachers_group["id"]) + assert err.value.response.status_code == 403 + assert "User does not have required access" in err.value.response.text + # can see those where he is member + students_group = cl_satoshi.group_management.get_group( + group_id=students_group["id"] + ) + assert students_group["members"] == [satoshi_uid] + # only admin can create, delete and modify group + # create + with pytest.raises(requests.exceptions.HTTPError) as err: + cl_satoshi.group_management.create_group(name="xxx") + assert err.value.response.status_code == 403 + assert "User does not have required access" in err.value.response.text + # update + with pytest.raises(requests.exceptions.HTTPError) as err: + cl_satoshi.group_management.update_group( + group_id=teachers_group["id"], description="new" + ) + assert err.value.response.status_code == 403 + assert "User does not have required access" in err.value.response.text + # delete + with pytest.raises(requests.exceptions.HTTPError) as err: + cl_satoshi.group_management.delete_group(group_id=teachers_group["id"]) + assert err.value.response.status_code == 403 + assert "User does not have required access" in err.value.response.text + # user cannot see members of group that he is not part of + with pytest.raises(requests.exceptions.HTTPError) as err: + cl_satoshi.group_management.group_members(group_id=teachers_group["id"]) + assert err.value.response.status_code == 403 + assert "User does not have required access" in err.value.response.text + # can see those where he is member + students_group_members = cl_satoshi.group_management.group_members( + group_id=students_group["id"] + ) + assert students_group_members == [satoshi_uid] + # user cannot see labs of group that he is not part of + with pytest.raises(requests.exceptions.HTTPError) as err: + cl_satoshi.group_management.group_labs(group_id=teachers_group["id"]) + assert err.value.response.status_code == 403 + assert "User does not have required access" in err.value.response.text + # can see those where he is member + students_group_labs = cl_satoshi.group_management.group_labs( + group_id=students_group["id"] + ) + assert students_group_labs == [lab0.id] + + # we need to get lab objects again so that they are bound to satoshi user + lab0 = cl_satoshi.find_labs_by_title(title="lab0")[0] + # satishi can only see groups where he is a member - in this case students + assert lab0.groups == [ + {"id": students_group["id"], "permission": "read_only"}, + ] + # we cannot modify groups associations as satoshi is not owner or admin + with pytest.raises(requests.exceptions.HTTPError) as err: + lab0.update_lab_groups(group_list=[]) + assert err.value.response.status_code == 403 + assert "User does not have required access" in err.value.response.text + + # we cannot modify notes + with pytest.raises(requests.exceptions.HTTPError) as err: + lab0.notes = "new note" + assert err.value.response.status_code == 403 + assert "User does not have write permission to lab" in err.value.response.text + + # we cannot modify description + with pytest.raises(requests.exceptions.HTTPError) as err: + lab0.description = "new description" + assert err.value.response.status_code == 403 + assert "User does not have write permission to lab" in err.value.response.text + + # change students association to lab0 to read_write + assert cl_admin.group_management.update_group( + group_id=students_group["id"], + labs=[{"id": lab0.id, "permission": "read_write"}], + ) + # now user can perform writes to associated lab + lab0.notes = "new note" + assert lab0.notes == "new note" + lab0.description = "new description" + assert lab0.description == "new description" + # get students groups association to lab0 back to read only + # satoshi cannot - he is not admin or owner + with pytest.raises(requests.exceptions.HTTPError) as err: + lab0.update_lab_groups( + group_list=[ + {"id": students_group["id"], "permission": "read_only"}, + ] + ) + assert err.value.response.status_code == 403 + assert "User does not have required access" in err.value.response.text + # admin can + lab0 = cl_admin.find_labs_by_title(title="lab0")[0] + lab0.update_lab_groups( + group_list=[ + {"id": students_group["id"], "permission": "read_only"}, + ] + ) + lab0 = cl_satoshi.find_labs_by_title(title="lab0")[0] + assert lab0.groups == [ + {"id": students_group["id"], "permission": "read_only"}, + ] + # add teachers group rw association to lab0 (admin action) + teachers_group = cl_admin.group_management.update_group( + group_id=teachers_group["id"], labs=lab0_1_rw + ) + # we cannot modify groups associations as satoshi is not admin or owner + with pytest.raises(requests.exceptions.HTTPError) as err: + lab0.update_lab_groups(group_list=[]) + assert err.value.response.status_code == 403 + assert "User does not have required access" in err.value.response.text + + # we cannot modify notes + with pytest.raises(requests.exceptions.HTTPError) as err: + lab0.notes = "new note" + assert err.value.response.status_code == 403 + assert "User does not have write permission to lab" in err.value.response.text + + # we cannot modify description + with pytest.raises(requests.exceptions.HTTPError) as err: + lab0.description = "new description" + assert err.value.response.status_code == 403 + assert "User does not have write permission to lab" in err.value.response.text + + # as satoshi has no access to lab1 - below list is empty + assert cl_satoshi.find_labs_by_title(title="lab1") == [] + # add satoshi to teachers group - by doing this now he gains read write + # access to both ;ab0 and lab1 + cl_admin.group_management.update_group( + group_id=teachers_group["id"], members=[satoshi_uid] + ) + # now we can access teachers group and related data + assert cl_satoshi.group_management.get_group(group_id=teachers_group["id"]) + assert cl_satoshi.group_management.group_members(group_id=teachers_group["id"]) == [ + satoshi_uid + ] + assert ( + len(cl_satoshi.group_management.group_labs(group_id=teachers_group["id"])) == 2 + ) + user_groups = cl_satoshi.user_management.user_groups(user_id=satoshi_uid) + assert students_group["id"] in user_groups and teachers_group["id"] in user_groups + associated_groups_names = [ + group["name"] for group in cl_satoshi.group_management.groups() + ] + assert ( + "students" in associated_groups_names and "teachers" in associated_groups_names + ) + # test adjusting lab groups (only owner and admin can change lab group associations) + # admin must see all associations + # owner and non-admin users can only see those associations where they are members of group + # log in as non-admin satoshi user + cl_halfinn = ClientLibrary( + controller_url, + username="halfinney", + password=satoshi_pwd, + ssl_verify=False, + allow_http=True, + ) + # create lab owned by halfin + lab2 = cl_halfinn.create_lab(title="lab2") + # only satoshi in students group + add lab2 association + cl_admin.group_management.update_group( + group_id=students_group["id"], + members=[satoshi_uid], + labs=[{"id": lab2.id, "permission": "read_only"}], + ) + # only halfinney in teachers group + add lab2 association + cl_admin.group_management.update_group( + group_id=teachers_group["id"], + members=[halfinn_uid], + labs=[{"id": lab2.id, "permission": "read_only"}], + ) + halfinn_lab2 = cl_halfinn.find_labs_by_title(title="lab2")[0] + # get lab owned by halfinney with satoshi (who is not owner) + satoshi_lab2 = cl_satoshi.find_labs_by_title(title="lab2")[0] + # get lab owned by halfinney with admin + admin_lab2 = cl_admin.find_labs_by_title(title="lab2")[0] + + # admin must see both groups associated with lab2 + assert admin_lab2.groups == [ + {"id": students_group["id"], "permission": "read_only"}, + {"id": teachers_group["id"], "permission": "read_only"}, + ] + # halfinney only sees group that he is member of (teachers) + assert halfinn_lab2.groups == [ + {"id": teachers_group["id"], "permission": "read_only"}, + ] + # satoshi only sees group that he is member of (students) + assert satoshi_lab2.groups == [ + {"id": students_group["id"], "permission": "read_only"}, + ] + # satoshi cannot update lab groups associations for lab2 -> 403 (not owner or admin) + with pytest.raises(requests.exceptions.HTTPError) as err: + satoshi_lab2.update_lab_groups(group_list=[]) + assert err.value.response.status_code == 403 + assert "User does not have required access" in err.value.response.text + # associations mus still be present after above failure + assert admin_lab2.groups == [ + {"id": students_group["id"], "permission": "read_only"}, + {"id": teachers_group["id"], "permission": "read_only"}, + ] + # halfinney cannot add/remove students association to lab2 as he is not member of students + halfinn_lab2.update_lab_groups(group_list=[]) + # above only removed the group teachers as halfinn is owner and also member of teachers + assert halfinn_lab2.groups == [] # sees nothing + assert satoshi_lab2.groups == [ + {"id": students_group["id"], "permission": "read_only"}, + ] # sees students + assert admin_lab2.groups == [ + {"id": students_group["id"], "permission": "read_only"}, + ] # admin too sees only students as that is now only associtation + # halfinney cannot add students group as he is not member + with pytest.raises(requests.exceptions.HTTPError) as err: + halfinn_lab2.update_lab_groups( + group_list=[{"id": students_group["id"], "permission": "read_only"}] + ) + assert err.value.response.status_code == 403 + assert "User does not have required access" in err.value.response.text + # halfinney can add teachers as he is a member + halfinn_lab2.update_lab_groups( + group_list=[{"id": teachers_group["id"], "permission": "read_only"}] + ) + # halfinney only sees group that he is member of (teachers) + assert halfinn_lab2.groups == [ + {"id": teachers_group["id"], "permission": "read_only"}, + ] + # add halfinney to students group + cl_admin.group_management.update_group( + group_id=students_group["id"], + members=[satoshi_uid, halfinn_uid], + ) + # halfinney now sees both students and teachers + # associations mus still be present after above failure + assert halfinn_lab2.groups == [ + {"id": students_group["id"], "permission": "read_only"}, + {"id": teachers_group["id"], "permission": "read_only"}, + ] + # he can now also remove both associations + halfinn_lab2.update_lab_groups(group_list=[]) + assert admin_lab2.groups == [] + assert halfinn_lab2.groups == [] + # satoshi lost access --> 404 + with pytest.raises(requests.exceptions.HTTPError) as err: + assert satoshi_lab2.groups == [] + assert err.value.response.status_code == 404 + assert "Lab not found" in err.value.response.text + # add also possible + halfinn_lab2.update_lab_groups( + group_list=[ + {"id": students_group["id"], "permission": "read_only"}, + {"id": teachers_group["id"], "permission": "read_only"}, + ] + ) + assert halfinn_lab2.groups == [ + {"id": students_group["id"], "permission": "read_only"}, + {"id": teachers_group["id"], "permission": "read_only"}, + ] + assert satoshi_lab2.groups == [ + {"id": students_group["id"], "permission": "read_only"}, + ] # sees students as he is only part of students not teachers + assert admin_lab2.groups == [ + {"id": students_group["id"], "permission": "read_only"}, + {"id": teachers_group["id"], "permission": "read_only"}, + ] + # admin can do whatever he pleases + admin_lab2.update_lab_groups(group_list=[]) + assert admin_lab2.groups == [] + assert halfinn_lab2.groups == [] + # satoshi lost access --> 404 + with pytest.raises(requests.exceptions.HTTPError) as err: + assert satoshi_lab2.groups == [] + assert err.value.response.status_code == 404 + assert "Lab not found" in err.value.response.text + + # CLEAN UP + # again need to get lab0 from admin account + lab0 = cl_admin.find_labs_by_title(title="lab0")[0] + lab0.remove() + lab1.remove() + lab2.remove() + cl_admin.user_management.delete_user(user_id=satoshi_uid) + cl_admin.user_management.delete_user(user_id=halfinn_uid) + assert cl_admin.group_management.delete_group(group_id=students_group["id"]) is None + assert cl_admin.group_management.delete_group(group_id=teachers_group["id"]) is None + + assert cl_admin.group_management.groups() == [] + assert cl_admin.all_labs(show_all=True) == [] diff --git a/tests/test_rate_limit.py b/tests/test_rate_limit.py new file mode 100644 index 0000000..2dbc780 --- /dev/null +++ b/tests/test_rate_limit.py @@ -0,0 +1,18 @@ +import pytest +import requests + + +LIMITED_ENDPOINTS = ["/api/v0/authenticate", "/api/v0/auth_extended"] + + +@pytest.mark.integration +@pytest.mark.nomock +def test_rate_limit(controller_url): + for endpoint in LIMITED_ENDPOINTS: + for i in range(30): + r = requests.post( + controller_url + endpoint, + json={"username": "admin", "password": "incorrect_pwd"}, + verify=False, + ) + assert r.status_code != 429 diff --git a/virl2_client/__init__.py b/virl2_client/__init__.py index 193d610..53fe3b1 100644 --- a/virl2_client/__init__.py +++ b/virl2_client/__init__.py @@ -20,9 +20,6 @@ # flake8: noqa: F401 -from .exceptions import ( - InterfaceNotFound, LabNotFound, LinkNotFound, - NodeNotFound -) +from .exceptions import InterfaceNotFound, LabNotFound, LinkNotFound, NodeNotFound from .virl2_client import ClientLibrary, InitializationError from .models.authentication import Context diff --git a/virl2_client/models/__init__.py b/virl2_client/models/__init__.py index 19bae3b..72eaa71 100644 --- a/virl2_client/models/__init__.py +++ b/virl2_client/models/__init__.py @@ -44,5 +44,5 @@ "SystemManagement", "UserManagement", "GroupManagement", - "TokenAuth" + "TokenAuth", ) diff --git a/virl2_client/models/groups.py b/virl2_client/models/groups.py index 227982b..635be64 100644 --- a/virl2_client/models/groups.py +++ b/virl2_client/models/groups.py @@ -26,7 +26,6 @@ class GroupManagement(object): - def __init__(self, context): self.ctx = context @@ -90,14 +89,15 @@ def create_group(self, name, description="", members=None, labs=None): "name": name, "description": description, "members": members or [], - "labs": labs or [] + "labs": labs or [], } response = self.ctx.session.post(self.base_url, json=data) response.raise_for_status() return response.json() - def update_group(self, group_id, name=None, description=None, members=None, - labs=None): + def update_group( + self, group_id, name=None, description=None, members=None, labs=None + ): """ Updates a group. diff --git a/virl2_client/models/users.py b/virl2_client/models/users.py index 615c993..2c0e912 100644 --- a/virl2_client/models/users.py +++ b/virl2_client/models/users.py @@ -100,8 +100,9 @@ def delete_user(self, user_id): response.raise_for_status() return response.json() - def create_user(self, user_id, pwd, fullname="", description="", - roles=None, groups=None): + def create_user( + self, user_id, pwd, fullname="", description="", roles=None, groups=None + ): """ Creates user. @@ -125,7 +126,7 @@ def create_user(self, user_id, pwd, fullname="", description="", "fullname": fullname, "description": description, "roles": roles or ["USER"], - "groups": groups or [] + "groups": groups or [], } url = self.base_url + "/{}".format(user_id) response = self.ctx.session.post(url, json=data) diff --git a/virl2_client/utils.py b/virl2_client/utils.py index 87ad9aa..4406832 100644 --- a/virl2_client/utils.py +++ b/virl2_client/utils.py @@ -60,7 +60,7 @@ def render(self): result += "\n" result += "Start\n" for line in self._lines: - result += fr" ^{line} -> Record" + result += r" ^{} -> Record".format(line) return result