├── __init__.py ├── docs └── index.md ├── setup.cfg ├── requirements-test.txt ├── pytest_hoverfly_wrapper ├── logger.py ├── __init__.py ├── block_domain_template.json ├── download.py ├── simulations.py └── plugin.py ├── MANIFEST.in ├── tests ├── unit │ ├── test_template_block_domain.py │ ├── test_simulations.py │ ├── test_generate_logs.py │ └── test_download.py ├── end_to_end │ ├── makepyfile_inputs │ │ ├── no_simulation_marker.py │ │ ├── marker_registered.py │ │ ├── custom_test_data_dir.py │ │ ├── raise_hoverflycrashedexc.py │ │ ├── record_static.py │ │ ├── generate_sim.py │ │ └── existing_static.py │ └── test_hoverfly_wrapper.py ├── conftest.py └── input.json ├── mkdocs.yml ├── tox.ini ├── veracode-log.txt ├── sample ├── test_sample.py └── static │ └── google_returns_404.json ├── appveyor.yml ├── .gitignore ├── LICENSE ├── setup.py ├── .github └── workflows │ └── ci.yml ├── test_data └── static │ └── google_returns_404.json ├── README.md └── .pylintrc /__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Welcome to pytest-hoverfly-wrapper 2 | 3 | TBD 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | # Inside of setup.cfg 2 | [metadata] 3 | description-file = README.md -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | pytest-mock==3.6.1 2 | black>22.1.0 3 | pylint>2.13.0 4 | isort>5.10.0 -------------------------------------------------------------------------------- /pytest_hoverfly_wrapper/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | LOGGER_NAME = "pytest_hoverfly" 4 | logger = logging.getLogger(LOGGER_NAME) 5 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | include pytest_hoverfly_wrapper/block_domain_template.json 4 | recursive-exclude * __pycache__ 5 | recursive-exclude * *.py[co] 6 | -------------------------------------------------------------------------------- /pytest_hoverfly_wrapper/__init__.py: -------------------------------------------------------------------------------- 1 | from .logger import LOGGER_NAME 2 | from .plugin import TEST_DATA_DIR, HoverflyCrashedException 3 | from .simulations import GeneratedSimulation, StaticSimulation 4 | -------------------------------------------------------------------------------- /tests/unit/test_template_block_domain.py: -------------------------------------------------------------------------------- 1 | from pytest_hoverfly_wrapper.simulations import template_block_domain_json 2 | 3 | 4 | def test_template_block_domain_json(): 5 | template_block_domain_json("reddit.com") 6 | -------------------------------------------------------------------------------- /tests/end_to_end/makepyfile_inputs/no_simulation_marker.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import requests 3 | 4 | from pytest_hoverfly_wrapper.simulations import StaticSimulation 5 | 6 | 7 | def test_no_simulation_marker(setup_hoverfly): 8 | pass 9 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: pytest-hoverfly-wrapper 2 | site_description: TBD 3 | site_author: Veli Akiner 4 | 5 | theme: readthedocs 6 | 7 | repo_url: https://github.com/kopernio/pytest-hoverfly-wrapper 8 | 9 | pages: 10 | - Home: index.md 11 | -------------------------------------------------------------------------------- /tests/unit/test_simulations.py: -------------------------------------------------------------------------------- 1 | from pytest_hoverfly_wrapper.simulations import GeneratedSimulation, StaticSimulation 2 | 3 | 4 | def test_generated_simulation(): 5 | GeneratedSimulation() 6 | 7 | 8 | def test_static_simulation(): 9 | StaticSimulation() 10 | -------------------------------------------------------------------------------- /tests/end_to_end/makepyfile_inputs/marker_registered.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import requests 3 | 4 | from pytest_hoverfly_wrapper.simulations import GeneratedSimulation 5 | 6 | 7 | @pytest.mark.simulated(GeneratedSimulation()) 8 | def test_sth(setup_hoverfly, mocker): 9 | pass 10 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # For more information about tox, see https://tox.readthedocs.io/en/latest/ 2 | [tox] 3 | envlist = py35,py36,py37,pypy,flake8 4 | 5 | [testenv] 6 | deps = pytest>=3.7 7 | requests>=2.23.0 8 | pytest-mock>=3.0.0 9 | commands = pytest {posargs:tests} -p no:hoverfly-wrapper 10 | 11 | -------------------------------------------------------------------------------- /veracode-log.txt: -------------------------------------------------------------------------------- 1 | Veracode Security Scan results: 2 | 0.1.0 - VL4+SCA 3 | 0.1.1 - VL4+SCA 4 | 0.1.2 - VL3+SCA 5 | 0.1.3 - VL4+SCA 6 | 0.1.4 - VL4+SCA 7 | 0.2.0 - VL4+SCA 8 | 0.3.0 - VL4+SCA 9 | 0.3.1 - VL4+SCA 10 | 0.3.2 - VL4+SCA 11 | 0.3.3 - VL4+SCA 12 | 0.4.0 - VL4+SCA 13 | 0.4.1 - N/A (docs-only change) -------------------------------------------------------------------------------- /tests/end_to_end/makepyfile_inputs/custom_test_data_dir.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import requests 3 | 4 | from pytest_hoverfly_wrapper.simulations import GeneratedSimulation 5 | 6 | 7 | @pytest.fixture 8 | def test_data_dir(): 9 | return "./this/dir/structure/doesnt/exist" 10 | 11 | 12 | @pytest.mark.simulated(GeneratedSimulation()) 13 | def test_sth(setup_hoverfly): 14 | pass 15 | -------------------------------------------------------------------------------- /tests/end_to_end/makepyfile_inputs/raise_hoverflycrashedexc.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import requests 3 | 4 | from pytest_hoverfly_wrapper.simulations import GeneratedSimulation 5 | 6 | 7 | @pytest.mark.simulated(GeneratedSimulation()) 8 | def test_sth(setup_hoverfly, mocker): 9 | mock_obj = mocker.patch("requests.get") 10 | mock_obj.side_effect = requests.exceptions.ConnectionError 11 | -------------------------------------------------------------------------------- /tests/end_to_end/makepyfile_inputs/record_static.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import requests 3 | 4 | from pytest_hoverfly_wrapper.simulations import StaticSimulation 5 | 6 | 7 | @pytest.mark.simulated(StaticSimulation(files=["foobar.json"])) 8 | def test_generate(setup_hoverfly): 9 | proxy_port = setup_hoverfly[1] 10 | proxies = { 11 | "http": "http://localhost:{}".format(proxy_port), 12 | "https": "http://localhost:{}".format(proxy_port), 13 | } 14 | r = requests.get("http://google.com", proxies=proxies) 15 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import re 3 | 4 | import pytest 5 | 6 | pytest_plugins = "pytester" # pylint: disable=C0103 7 | TEST_DIR = pathlib.Path(__file__).parent.resolve() 8 | 9 | 10 | @pytest.fixture 11 | def pyfile_source(request): 12 | """Yields the appropriate pyfile for a test.""" 13 | pyfile_name = re.sub(r"^test_", "", request.node.name) + ".py" 14 | pyfile_full_path = TEST_DIR / "end_to_end" / "makepyfile_inputs" / pyfile_name 15 | with open(pyfile_full_path) as file: 16 | yield file.read() 17 | -------------------------------------------------------------------------------- /tests/end_to_end/makepyfile_inputs/generate_sim.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import requests 3 | 4 | from pytest_hoverfly_wrapper.simulations import GeneratedSimulation 5 | 6 | 7 | @pytest.mark.simulated(GeneratedSimulation(file="foobar.json")) 8 | def test_generate(setup_hoverfly): 9 | proxy_port = setup_hoverfly[1] 10 | proxies = { 11 | "http": "http://localhost:{}".format(proxy_port), 12 | "https": "http://localhost:{}".format(proxy_port), 13 | } 14 | r = requests.get("http://google.com", proxies=proxies) 15 | -------------------------------------------------------------------------------- /tests/end_to_end/makepyfile_inputs/existing_static.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import requests 3 | 4 | from pytest_hoverfly_wrapper.simulations import StaticSimulation 5 | 6 | 7 | @pytest.mark.simulated(StaticSimulation(files=["google_returns_404.json"])) 8 | def test_generate(setup_hoverfly): 9 | proxy_port = setup_hoverfly[1] 10 | proxies = { 11 | "http": "http://localhost:{}".format(proxy_port), 12 | "https": "http://localhost:{}".format(proxy_port), 13 | } 14 | r = requests.get("http://google.com", proxies=proxies) 15 | assert r.status_code == 404 16 | -------------------------------------------------------------------------------- /sample/test_sample.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | import requests 5 | 6 | from pytest_hoverfly_wrapper import StaticSimulation 7 | 8 | pytest_plugins = ["pytest_hoverfly_wrapper"] 9 | 10 | 11 | @pytest.fixture 12 | def test_data_dir(): 13 | """Overrides the default test data directory""" 14 | return os.path.join(os.getcwd(), "sample") 15 | 16 | 17 | @pytest.mark.simulated(StaticSimulation(files=["google_returns_404.json"])) 18 | def test_something(setup_hoverfly): 19 | proxy_port = setup_hoverfly[1] 20 | proxies = { 21 | "http": f"http://localhost:{proxy_port}", 22 | "https": f"http://localhost:{proxy_port}", 23 | } 24 | response = requests.get("http://google.com", proxies=proxies, timeout=5) 25 | assert response.status_code == 404 26 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | # What Python version is installed where: 2 | # http://www.appveyor.com/docs/installed-software#python 3 | 4 | environment: 5 | matrix: 6 | - PYTHON: "C:\\Python27" 7 | TOX_ENV: "py27" 8 | 9 | - PYTHON: "C:\\Python34" 10 | TOX_ENV: "py34" 11 | 12 | - PYTHON: "C:\\Python35" 13 | TOX_ENV: "py35" 14 | 15 | - PYTHON: "C:\\Python36" 16 | TOX_ENV: "py36" 17 | 18 | - PYTHON: "C:\\Python37" 19 | TOX_ENV: "py37" 20 | 21 | init: 22 | - "%PYTHON%/python -V" 23 | - "%PYTHON%/python -c \"import struct;print( 8 * struct.calcsize(\'P\'))\"" 24 | 25 | install: 26 | - "%PYTHON%/Scripts/easy_install -U pip" 27 | - "%PYTHON%/Scripts/pip install tox" 28 | - "%PYTHON%/Scripts/pip install wheel" 29 | 30 | build: false # Not a C# project, build stuff at the test step instead. 31 | 32 | test_script: 33 | - "%PYTHON%/Scripts/tox -e %TOX_ENV%" 34 | 35 | after_test: 36 | - "%PYTHON%/python setup.py bdist_wheel" 37 | - ps: "ls dist" 38 | 39 | artifacts: 40 | - path: dist\* 41 | 42 | #on_success: 43 | # - TODO: upload the content of dist/*.whl to a public wheelhouse 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | .pytest_cache 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask instance folder 58 | instance/ 59 | 60 | # Sphinx documentation 61 | docs/_build/ 62 | 63 | # MkDocs documentation 64 | /site/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | .idea/ 76 | hoverfly_logs 77 | test_data/generated 78 | test_data/static/foobar.json 79 | hoverfly_executables/ 80 | -------------------------------------------------------------------------------- /tests/unit/test_generate_logs.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | import pytest 5 | import requests 6 | 7 | from pytest_hoverfly_wrapper.plugin import generate_logs 8 | 9 | 10 | def test_generate_logs(mocker, tmpdir): 11 | mock_request = mocker.MagicMock() 12 | mock_request.node.sensitive = ["sensitive.host"] 13 | mock_request.node.mode = "simulate" 14 | mock_journal_api = mocker.MagicMock() 15 | with open("tests/input.json") as f: 16 | mock_journal_api.get.return_value = json.load(f) 17 | log_file = os.path.join(tmpdir.strpath, "network.json") 18 | # golden path 19 | generate_logs(request=mock_request, journal_api=mock_journal_api, test_log_directory=tmpdir.strpath) 20 | assert os.path.isfile(log_file) 21 | # exception raised if sensitive host isn't cached 22 | del mock_journal_api.get.return_value["journal"][0]["response"]["headers"]["Hoverfly-Cache-Served"] 23 | with pytest.raises(AssertionError): 24 | generate_logs(request=mock_request, journal_api=mock_journal_api, test_log_directory=tmpdir.strpath) 25 | 26 | # useful message dumped if hoverfly crashes during log retrieval 27 | mock_journal_api.get.side_effect = requests.exceptions.ConnectionError 28 | generate_logs(request=mock_request, journal_api=mock_journal_api, test_log_directory=tmpdir.strpath) 29 | with open(log_file) as f: 30 | assert json.load(f) == {"msg": "Hoverfly crashed while retrieving logs"} 31 | -------------------------------------------------------------------------------- /tests/input.json: -------------------------------------------------------------------------------- 1 | { 2 | "journal": [ 3 | { 4 | "request": { 5 | "path": "/signup", 6 | "method": "GET", 7 | "destination": "sensitive.host", 8 | "scheme": "http", 9 | "query": "", 10 | "body": "", 11 | "headers": { 12 | "Accept": [ 13 | "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9" 14 | ], 15 | "Accept-Encoding": [ 16 | "gzip, deflate" 17 | ], 18 | "Accept-Language": [ 19 | "en-GB,en-US;q=0.9,en;q=0.8" 20 | ], 21 | "Proxy-Connection": [ 22 | "keep-alive" 23 | ], 24 | "Upgrade-Insecure-Requests": [ 25 | "1" 26 | ], 27 | "User-Agent": [ 28 | "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36" 29 | ] 30 | } 31 | }, 32 | "response": { 33 | "status": 302, 34 | "body": "foobar", 35 | "encodedBody": false, 36 | "headers": { 37 | "Hoverfly-Cache-Served": [ 38 | "gzip, deflate, br" 39 | ], 40 | "foobar": [ 41 | "gzip, deflate, br" 42 | ] 43 | } 44 | }, 45 | "mode": "spy", 46 | "timeStarted": "2020-04-05T18:27:15.444+01:00", 47 | "latency": 91.423878 48 | } 49 | ], 50 | "offset": 0, 51 | "limit": 10, 52 | "total": 148 53 | } -------------------------------------------------------------------------------- /tests/unit/test_download.py: -------------------------------------------------------------------------------- 1 | from subprocess import CalledProcessError 2 | 3 | import pytest 4 | 5 | from pytest_hoverfly_wrapper.download import binaries_valid 6 | 7 | 8 | @pytest.fixture 9 | def mock_run(mocker): 10 | return mocker.patch("pytest_hoverfly_wrapper.download.run", autospec=True) 11 | 12 | 13 | @pytest.fixture 14 | def mock_get_latest_version(mocker): 15 | mock_obj = mocker.patch("pytest_hoverfly_wrapper.download.get_latest_version", autospec=True) 16 | mock_obj.return_value = "v1.5.0" 17 | return mock_obj 18 | 19 | 20 | @pytest.mark.parametrize("exception", [FileNotFoundError, PermissionError, CalledProcessError(1, "python")]) 21 | def test_binaries_valid_fail(mock_run, exception): 22 | def raise_exc(exc): 23 | raise exc 24 | 25 | mock_run.side_effect = lambda _, **kw: raise_exc(exception) 26 | assert not binaries_valid() 27 | 28 | 29 | def test_binaries_valid_general_exception(mock_run): 30 | def raise_exc(): 31 | raise Exception 32 | 33 | mock_run.side_effect = lambda _, **kw: raise_exc() 34 | with pytest.raises(Exception): 35 | binaries_valid() 36 | 37 | 38 | def test_binaries_valid_wrong_ver(mock_run, mock_get_latest_version): 39 | mock_run.return_value.stdout = b"v1.0.0\n" 40 | mock_run.return_value.returncode = 0 41 | assert not binaries_valid() 42 | 43 | 44 | def test_binaries_valid_pass(mock_run, mock_get_latest_version): 45 | mock_run.return_value.stdout = b"v1.5.0\n" 46 | mock_run.return_value.returncode = 0 47 | assert binaries_valid() 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Kopernio Limited 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 6 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation 7 | the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and 8 | to permit persons to whom the Software is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of 11 | the Software. 12 | 13 | THE SOFTWARE IS PROVIDED ON AN "AS IS" AND “AS AVAILABLE” BASIS TO THE FULLEST EXTENT PERMITTED BY APPLICABLE 14 | LAW, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 15 | PERFORMANCE, MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, ACCURACY, COMPLETENESS, RELIABILITY AND 16 | NONINFRINGEMENT. THE AUTHORS AND COPYRIGHT HOLDERS DISCLAIM ALL RESPONSIBILITY FOR ANY LOSS, INJURY CLAIM, 17 | LIABILITY, OR DAMAGE OF ANY KIND RESULTING FROM OR RELATED TO ACCESS, USE OR THE UNAVAILABILITY OF THE SOFTWARE 18 | OR ANY PART THEREOF). THE AUTHORS, COPYRIGHT HOLDERS OR ANY OF THEIR RESPECTIVE AFFILIATES, SUBSIDIARIES, 19 | SHAREHOLDERS, DIRECTORS, OFFICERS, EMPLOYEES, AGENTS, ADVERTISERS, CONTENT PROVIDERS AND LICENSORS WILL NOT BE 20 | LIABLE (JOINTLY OR SEVERALLY) UNDER ANY CIRCUMSTANCES FOR ANY DIRECT, INDIRECT, CONSEQUENTIAL, SPECIAL, 21 | INCIDENTAL, PUNITIVE, OR EXEMPLARY DAMAGES, INCLUDING WITHOUT LIMITATION, LOST PROFITS, LOST SAVINGS AND LOST 22 | REVENUES, WHETHER IN NEGLIGENCE, TORT, CONTRACT OR ANY OTHER THEORY OF LIABILITY, EVEN IF SUCH PARTIES HAVE 23 | BEEN ADVISED OF THE POSSIBILITY OR COULD HAVE FORSEEN ANY SUCH DAMAGES. 24 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import codecs 5 | import os 6 | 7 | from setuptools import setup 8 | 9 | 10 | def read(fname): 11 | file_path = os.path.join(os.path.dirname(__file__), fname) 12 | return codecs.open(file_path, encoding="utf-8").read() 13 | 14 | 15 | setup( 16 | name="pytest-hoverfly-wrapper", 17 | version="1.0.1", 18 | author="Veli Akiner", 19 | author_email="veli.akiner@gmail.com", 20 | maintainer="Veli Akiner", 21 | maintainer_email="veli.akiner@gmail.com", 22 | license="MIT", 23 | url="https://github.com/kopernio/pytest-hoverfly-wrapper", 24 | description="Integrates the Hoverfly HTTP proxy into Pytest", 25 | long_description=read("README.md"), 26 | long_description_content_type="text/markdown", 27 | python_requires=">=3.7", 28 | install_requires=["pytest>=3.7.0", "requests", "python-dateutil", "polling", "requests-cache==0.9.8"], 29 | setup_requires=["requests"], 30 | packages=["pytest_hoverfly_wrapper"], 31 | include_package_data=True, 32 | classifiers=[ 33 | "Development Status :: 5 - Production/Stable", 34 | "Framework :: Pytest", 35 | "Intended Audience :: Developers", 36 | "Topic :: Software Development :: Testing", 37 | "Programming Language :: Python", 38 | "Programming Language :: Python :: 3", 39 | "Programming Language :: Python :: 3.7", 40 | "Programming Language :: Python :: 3.8", 41 | "Programming Language :: Python :: 3.10", 42 | "Programming Language :: Python :: 3.11", 43 | "Programming Language :: Python :: Implementation :: CPython", 44 | "Programming Language :: Python :: Implementation :: PyPy", 45 | "Operating System :: Microsoft :: Windows", 46 | "Operating System :: POSIX :: Linux", 47 | "Operating System :: MacOS", 48 | "License :: OSI Approved :: MIT License", 49 | ], 50 | entry_points={ 51 | "pytest11": [ 52 | "hoverfly-wrapper = pytest_hoverfly_wrapper.plugin", 53 | ], 54 | }, 55 | ) 56 | -------------------------------------------------------------------------------- /pytest_hoverfly_wrapper/block_domain_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "pairs": [ 4 | { 5 | "request": { 6 | "destination": [ 7 | { 8 | "matcher": "glob", 9 | "value": "" 10 | } 11 | ] 12 | }, 13 | "response": { 14 | "encodedBody": true, 15 | "status": 404, 16 | "body": "", 17 | "templated": false, 18 | "headers": { 19 | "Access-Control-Allow-Origin": [ 20 | "*" 21 | ], 22 | "Server": [ 23 | "gunicorn/19.4.5" 24 | ], 25 | "Date": [ 26 | "Tue, 17 Jul 2018 10:00:53 GMT" 27 | ], 28 | "Via": [ 29 | "1.1 vegur" 30 | ], 31 | "Hoverfly": [ 32 | "Was-Here" 33 | ], 34 | "Content-Type": [ 35 | "application/json" 36 | ], 37 | "Content-Length": [ 38 | "0" 39 | ], 40 | "Content-Encoding": [ 41 | "gzip" 42 | ], 43 | "Connection": [ 44 | "keep-alive" 45 | ], 46 | "Access-Control-Allow-Methods": [ 47 | "POST, GET, OPTIONS, PUT, DELETE, PATCH" 48 | ], 49 | "Vary": [ 50 | "Accept-Encoding" 51 | ], 52 | "Access-Control-Allow-Headers": [ 53 | "origin, content-type, accept, x-requested-with" 54 | ], 55 | "Hoverfly-Cache-Served": [ 56 | "True" 57 | ] 58 | } 59 | } 60 | } 61 | ], 62 | "globalActions": { 63 | "delays": [] 64 | } 65 | }, 66 | "meta": { 67 | "hoverflyVersion": "v0.17.0", 68 | "timeExported": "2018-07-17T11:01:08+01:00", 69 | "schemaVersion": "v5" 70 | } 71 | } -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | 10 | 11 | jobs: 12 | test: 13 | name: Tests 14 | runs-on: ${{ matrix.os }} 15 | strategy: 16 | matrix: 17 | os: 18 | - ubuntu-latest 19 | - macos-latest 20 | python-version: 21 | - 3.7 22 | - 3.8 23 | - 3.9 24 | - 3.10.10 25 | - 3.11 26 | exclude: # mac runners are expensive 27 | - python-version: 3.8 28 | os: macos-latest 29 | - python-version: 3.9 30 | os: macos-latest 31 | - python-version: 3.10.10 32 | os: macos-latest 33 | - python-version: 3.11 34 | os: macos-latest 35 | 36 | steps: 37 | - name: Checkout 38 | uses: actions/checkout@v2 39 | - name: Set up Python ${{ matrix.python-version }} 40 | uses: actions/setup-python@v2 41 | with: 42 | python-version: ${{ matrix.python-version }} 43 | - name: Install dependencies 44 | run: | 45 | python setup.py install 46 | pip install -r requirements-test.txt 47 | - name: Lint 48 | run: | 49 | black -l120 --check ./ 50 | isort -l120 --check ./ 51 | pylint **/*.py 52 | - name: Run tests 53 | run: | 54 | pytest tests/ -v -p no:hoverfly-wrapper -x 55 | test_windows: 56 | name: Tests (windows) 57 | needs: test 58 | runs-on: windows-latest 59 | strategy: 60 | matrix: 61 | python-version: 62 | - 3.7 63 | steps: 64 | - name: Checkout 65 | uses: actions/checkout@v2 66 | - name: Set up Python ${{ matrix.python-version }} 67 | uses: actions/setup-python@v2 68 | with: 69 | python-version: ${{ matrix.python-version }} 70 | - name: Install dependencies 71 | run: | 72 | python setup.py install 73 | pip install -r requirements-test.txt 74 | - name: Run tests 75 | run: | 76 | pytest tests\ -v -p no:hoverfly-wrapper -x 77 | 78 | build_release: 79 | runs-on: ubuntu-latest 80 | needs: [test, test_windows] 81 | if: github.ref == 'refs/heads/master' 82 | steps: 83 | - name: Checkout 84 | uses: actions/checkout@v2 85 | with: 86 | fetch-depth: 0 87 | - name: Set up Python 88 | uses: actions/setup-python@v2 89 | with: 90 | python-version: 3.7 91 | - name: Build 92 | run: | 93 | ./build_release_candidate.sh 94 | - name: Archive production artifacts 95 | uses: actions/upload-artifact@v3 96 | with: 97 | name: dist 98 | path: | 99 | dist/* 100 | -------------------------------------------------------------------------------- /sample/static/google_returns_404.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "pairs": [ 4 | { 5 | "request": { 6 | "path": [ 7 | { 8 | "matcher": "glob", 9 | "value": "*" 10 | } 11 | ], 12 | "destination": [ 13 | { 14 | "matcher": "exact", 15 | "value": "google.com" 16 | } 17 | ], 18 | "scheme": [ 19 | { 20 | "matcher": "glob", 21 | "value": "http*" 22 | } 23 | ] 24 | }, 25 | "response": { 26 | "encodedBody": true, 27 | "status": 404, 28 | "body": "", 29 | "templated": false, 30 | "headers": { 31 | "Access-Control-Allow-Origin": [ 32 | "*" 33 | ], 34 | "Server": [ 35 | "gunicorn/19.4.5" 36 | ], 37 | "Date": [ 38 | "Tue, 17 Jul 2018 10:00:53 GMT" 39 | ], 40 | "Via": [ 41 | "1.1 vegur" 42 | ], 43 | "Hoverfly": [ 44 | "Was-Here" 45 | ], 46 | "Content-Type": [ 47 | "application/json" 48 | ], 49 | "Content-Length": [ 50 | "0" 51 | ], 52 | "Content-Encoding": [ 53 | "gzip" 54 | ], 55 | "Connection": [ 56 | "keep-alive" 57 | ], 58 | "Access-Control-Allow-Methods": [ 59 | "POST, GET, OPTIONS, PUT, DELETE, PATCH" 60 | ], 61 | "Vary": [ 62 | "Accept-Encoding" 63 | ], 64 | "Access-Control-Allow-Headers": [ 65 | "origin, content-type, accept, x-requested-with" 66 | ], 67 | "Hoverfly-Cache-Served": [ 68 | "True" 69 | ] 70 | } 71 | } 72 | } 73 | ], 74 | "globalActions": { 75 | "delays": [] 76 | } 77 | }, 78 | "meta": { 79 | "hoverflyVersion": "v0.17.0", 80 | "timeExported": "2018-07-17T11:01:08+01:00", 81 | "schemaVersion": "v5" 82 | } 83 | } -------------------------------------------------------------------------------- /test_data/static/google_returns_404.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "pairs": [ 4 | { 5 | "request": { 6 | "path": [ 7 | { 8 | "matcher": "glob", 9 | "value": "*" 10 | } 11 | ], 12 | "destination": [ 13 | { 14 | "matcher": "exact", 15 | "value": "google.com" 16 | } 17 | ], 18 | "scheme": [ 19 | { 20 | "matcher": "glob", 21 | "value": "http*" 22 | } 23 | ] 24 | }, 25 | "response": { 26 | "encodedBody": true, 27 | "status": 404, 28 | "body": "", 29 | "templated": false, 30 | "headers": { 31 | "Access-Control-Allow-Origin": [ 32 | "*" 33 | ], 34 | "Server": [ 35 | "gunicorn/19.4.5" 36 | ], 37 | "Date": [ 38 | "Tue, 17 Jul 2018 10:00:53 GMT" 39 | ], 40 | "Via": [ 41 | "1.1 vegur" 42 | ], 43 | "Hoverfly": [ 44 | "Was-Here" 45 | ], 46 | "Content-Type": [ 47 | "application/json" 48 | ], 49 | "Content-Length": [ 50 | "0" 51 | ], 52 | "Content-Encoding": [ 53 | "gzip" 54 | ], 55 | "Connection": [ 56 | "keep-alive" 57 | ], 58 | "Access-Control-Allow-Methods": [ 59 | "POST, GET, OPTIONS, PUT, DELETE, PATCH" 60 | ], 61 | "Vary": [ 62 | "Accept-Encoding" 63 | ], 64 | "Access-Control-Allow-Headers": [ 65 | "origin, content-type, accept, x-requested-with" 66 | ], 67 | "Hoverfly-Cache-Served": [ 68 | "True" 69 | ] 70 | } 71 | } 72 | } 73 | ], 74 | "globalActions": { 75 | "delays": [] 76 | } 77 | }, 78 | "meta": { 79 | "hoverflyVersion": "v0.17.0", 80 | "timeExported": "2018-07-17T11:01:08+01:00", 81 | "schemaVersion": "v5" 82 | } 83 | } -------------------------------------------------------------------------------- /pytest_hoverfly_wrapper/download.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import os 3 | import re 4 | import sys 5 | import zipfile 6 | from subprocess import CalledProcessError, run 7 | 8 | import requests_cache 9 | 10 | from .logger import logger 11 | 12 | session = requests_cache.CachedSession(".pytest_cache/hoverfly_cache") 13 | 14 | 15 | OUTPATH = "hoverfly_executables" # configure via plugin options 16 | HOVERCTL = f"hoverctl{'.exe' if sys.platform.startswith('win') else ''}" 17 | HOVERFLY = f"hoverfly{'.exe' if sys.platform.startswith('win') else ''}" 18 | 19 | HOVERCTL_PATH = os.path.join(OUTPATH, HOVERCTL) 20 | HOVERFLY_PATH = os.path.join(OUTPATH, HOVERFLY) 21 | 22 | 23 | def get_platform_architecture(): 24 | if sys.maxsize > 2**32: 25 | architecture = "amd64" 26 | else: 27 | architecture = "386" 28 | if sys.platform.startswith("linux"): 29 | platform = "linux" 30 | elif sys.platform == "darwin": 31 | platform = "OSX" 32 | architecture = "amd64" 33 | elif sys.platform.startswith("win"): 34 | platform = "windows" 35 | else: 36 | raise RuntimeError(f"Unsupported operating system: {sys.platform}") 37 | return platform, architecture 38 | 39 | 40 | def binaries_valid(): 41 | """Binaries exist and are runnable""" 42 | try: 43 | output_1 = run([HOVERFLY_PATH, "-version"], capture_output=True, check=True) 44 | run([HOVERCTL_PATH, "version"], capture_output=True, check=True) 45 | version = output_1.stdout.decode("utf-8").split("\n")[0] 46 | if version != get_latest_version(): 47 | logger.info("Version mismatch.") 48 | return False 49 | except FileNotFoundError: 50 | logger.info("Files missing.") 51 | return False 52 | except CalledProcessError: 53 | logger.info("Error running files.") 54 | return False 55 | except PermissionError: 56 | logger.info("Files not executable.") 57 | return False 58 | logger.debug("Hoverfly executables are valid.") 59 | return True 60 | 61 | 62 | def get_latest_version(): 63 | resp = session.get("https://github.com/SpectoLabs/hoverfly/releases/latest", timeout=5, allow_redirects=False) 64 | resp.raise_for_status() 65 | latest_ver_url = resp.headers["Location"] 66 | return re.findall(r"[^/]+$", latest_ver_url)[0] 67 | 68 | 69 | @contextlib.contextmanager 70 | def download(): 71 | platform, architecture = get_platform_architecture() 72 | # Define the remote file to retrieve 73 | latest_version = get_latest_version() 74 | remote_url = ( 75 | f"https://github.com/SpectoLabs/hoverfly/releases/download/" 76 | f"{latest_version}/hoverfly_bundle_{platform}_{architecture}.zip" 77 | ) 78 | # Define the local filename to save data 79 | local_file = "hf.zip" 80 | # Make http request for remote file data 81 | data = session.get(remote_url, timeout=60) 82 | data.raise_for_status() 83 | # Save file data to local copy 84 | with open(local_file, "wb") as file: 85 | file.write(data.content) 86 | try: 87 | yield local_file 88 | finally: 89 | os.remove(local_file) 90 | 91 | 92 | def unzip(path_to_zip_file): 93 | with zipfile.ZipFile(path_to_zip_file, "r") as zip_ref: 94 | zip_ref.extractall(OUTPATH) 95 | for executable in (HOVERFLY_PATH, HOVERCTL_PATH): 96 | if not os.access(executable, os.X_OK): 97 | os.chmod(executable, 0o744) 98 | 99 | 100 | def manage_executables(): 101 | if binaries_valid(): 102 | return 103 | with download() as zip_file_name: 104 | unzip(zip_file_name) 105 | -------------------------------------------------------------------------------- /tests/end_to_end/test_hoverfly_wrapper.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from pytest_hoverfly_wrapper.plugin import TEST_DATA_DIR 4 | 5 | 6 | def test_raise_hoverflycrashedexc(testdir, pyfile_source): 7 | """The plugin should raise HoverflyCrashedException when ConnectionError is raised 8 | while accessing hoverfly.""" 9 | 10 | testdir.makepyfile(pyfile_source) 11 | result = testdir.runpytest() 12 | 13 | assert result.ret == 1 14 | 15 | result.stdout.fnmatch_lines( 16 | [ 17 | "*raise HoverflyCrashedException*", 18 | ] 19 | ) 20 | 21 | 22 | def test_custom_test_data_dir(testdir, pyfile_source): 23 | """Test creating a custom directory.""" 24 | 25 | testdir.makepyfile(pyfile_source) 26 | result = testdir.runpytest() 27 | assert result.ret == 0 28 | 29 | 30 | def test_generate_sim(testdir, pyfile_source): 31 | """End-to-end test that runs a test once to generate a simulation, and then again to verify it gets used.""" 32 | 33 | sim_file = os.path.join(TEST_DATA_DIR, "generated", "foobar.json") 34 | try: 35 | os.remove(sim_file) 36 | except FileNotFoundError: 37 | pass 38 | assert not os.path.exists(sim_file) 39 | 40 | # Run a test with the GeneratedSimulation marker to verify we get a simulation file 41 | testdir.makepyfile(pyfile_source) 42 | result = testdir.runpytest() 43 | assert result.ret == 0 44 | assert os.path.isfile(sim_file) 45 | 46 | # Run the test again, but this time check for the Hoverfly-Cache-Served header, which indicates that the simulation was used. 47 | assert_cached_response = """ assert r.headers.get("Hoverfly-Cache-Served")""" 48 | testdir.makepyfile(pyfile_source + assert_cached_response) 49 | result = testdir.runpytest("-s") 50 | assert result.ret == 0 51 | 52 | 53 | def test_record_static(testdir, pyfile_source): 54 | # Like the last test but for a static simulation with just one file 55 | sim_file = os.path.join(TEST_DATA_DIR, "static", "foobar.json") 56 | try: 57 | os.remove(sim_file) 58 | except FileNotFoundError: 59 | pass 60 | assert not os.path.exists(sim_file) 61 | 62 | # Run a test with the GeneratedSimulation marker to verify we get a simulation file 63 | testdir.makepyfile(pyfile_source) 64 | result = testdir.runpytest() 65 | assert result.ret == 0 66 | assert os.path.isfile(sim_file) 67 | 68 | # Run the test again, but this time check for the Hoverfly-Cache-Served header, which indicates that the simulation was used. 69 | assert_cached_response = """ assert r.headers.get("Hoverfly-Cache-Served")""" 70 | testdir.makepyfile(pyfile_source + assert_cached_response) 71 | result = testdir.runpytest("-s") 72 | assert result.ret == 0 73 | 74 | 75 | def test_existing_static(testdir, pyfile_source): 76 | """Test static simulation functionality""" 77 | testdir.makepyfile(pyfile_source) 78 | result = testdir.runpytest() 79 | assert result.ret == 0 80 | 81 | 82 | def test_no_simulation_marker(testdir, pyfile_source): 83 | """We should be able to setup Hoverfly without specifying a simulation""" 84 | testdir.makepyfile(pyfile_source) 85 | result = testdir.runpytest() 86 | assert result.ret == 0 87 | 88 | 89 | def test_marker_registered(testdir, pyfile_source): 90 | """Make sure that the plugin's markers are registered.""" 91 | 92 | # create a temporary pytest test module 93 | testdir.makepyfile(pyfile_source) 94 | 95 | # run pytest with the following cmd args 96 | result = testdir.runpytest("--strict") 97 | 98 | assert result.ret == 0 99 | 100 | 101 | # TODO: end-to-end tests covering: 102 | # using static sims, 103 | # recording and using sims, 104 | # logging output, 105 | # spying mode, 106 | # default mode, 107 | # combining sims, 108 | # command line parameters, 109 | # raising exceptions when sensitive host URLs are accessed, 110 | # unit tests for setup_hoverfly_mode and generate_logs 111 | # crashing hoverfly 112 | -------------------------------------------------------------------------------- /pytest_hoverfly_wrapper/simulations.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import time 4 | 5 | from .logger import logger 6 | 7 | 8 | class StaticSimulation: # pylint: disable=R0903 9 | """Data class for static Hoverfly simulation files 10 | 11 | :list file: list of files that are used in the simulation 12 | :list block_domains: list of domains (or domain glob patterns) for which simulations will be generated to 13 | block requests to. 14 | """ 15 | 16 | file_type = "static" 17 | max_age = None # Static simulations have no expiry 18 | 19 | def __init__(self, files: list = None, block_domains: list = (), capture_config=None): 20 | self.files = files if files else [] 21 | self.block_domains = block_domains 22 | self.capture_config = capture_config 23 | self.file_paths = [os.path.join(self.file_type, file) for file in self.files] 24 | 25 | def full_file_path(self, data_dir, admin_port): 26 | # Specifying one static simulation that doesn't exist implies we want to record it once, then use it. 27 | if len(self.file_paths) == 1: 28 | return os.path.join(data_dir, "static", self.files[0]) 29 | 30 | # pre-loaded simulations are modularised into multiple simulations, so need to be glommed into one for hoverfly 31 | # We just need a thread-specific identifier for each combined simulation - the admin port will do nicely 32 | if self.file_paths: 33 | return _combine_simulations( 34 | [os.path.join(data_dir, p) for p in self.file_paths], domains_to_block=(), worker=admin_port 35 | ) 36 | return _combine_simulations(simulations=[BLOCK_DOMAIN_TEMPLATE], domains_to_block=(), worker=admin_port) 37 | 38 | 39 | class GeneratedSimulation: # pylint: disable=R0903 40 | 41 | """Data class for static Hoverfly simulation files 42 | 43 | :str file: the file simulations are recorded to or read from 44 | :int max_age: if not None, tells the tests how long a generated simulation is valid for (in seconds) 45 | :dict capture_config: overrides the existing Hoverfly simulation capture settings 46 | :tuple static_files: static file simulations that get used in combination with recorded simulations. 47 | These aren't used when a simulation is being recorded. 48 | """ 49 | 50 | file_type = "generated" 51 | default_static_files = [] 52 | 53 | def __init__(self, file: str = None, max_age: int = None, capture_config=None, static_files=()): 54 | self.file = os.path.join(self.file_type, file or f"temp_{time.time()}.json") 55 | self.max_age = max_age 56 | self.capture_config = capture_config 57 | self.static_files = list(static_files) + self.default_static_files 58 | self.static_files = [os.path.join("static", file) for file in self.static_files] 59 | 60 | def full_file_path(self, data_dir, admin_port): 61 | for sim in self.static_files: 62 | logger.info("Static simulations used in test: %s", sim) 63 | if self.static_files: 64 | # The order is important here: `extra` typically contains fallback matchers. 65 | # So add it first so that Hoverfly prioritises matchers in the recorded simulation. 66 | return _combine_simulations( 67 | [os.path.join(data_dir, p) for p in (*self.static_files, self.file)], (), admin_port 68 | ) 69 | return os.path.join(data_dir, self.file) 70 | 71 | 72 | def _combine_simulations(simulations, domains_to_block, worker): 73 | with open(simulations[0]) as file: 74 | combined_sim = json.load(file) 75 | 76 | for sim in simulations[1:]: 77 | with open(sim) as file: 78 | pairs = json.load(file)["data"]["pairs"] 79 | combined_sim["data"]["pairs"] += pairs 80 | for domain in domains_to_block: 81 | pairs = template_block_domain_json(domain)["data"]["pairs"] 82 | combined_sim["data"]["pairs"] += pairs 83 | file_name = f"combined_temp_{worker}.json" 84 | with open(file_name, "w") as file: 85 | file.write(json.dumps(combined_sim, indent=4, separators=(",", ": "))) 86 | return file_name 87 | 88 | 89 | def template_block_domain_json(domain): 90 | with open(BLOCK_DOMAIN_TEMPLATE) as file: 91 | sim = file.read() 92 | sim = sim.replace("", domain) 93 | 94 | return json.loads(sim) 95 | 96 | 97 | BLOCK_DOMAIN_TEMPLATE = os.path.join(os.path.dirname(os.path.realpath(__file__)), "block_domain_template.json") 98 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pytest Hoverfly Wrapper 2 | 3 | This `pytest` plugin allows easy integration of Hoverfly into your tests. Hoverfly is a proxy server that can intercept requests and return custom responses. More info on Hoverfly: https://hoverfly.io/ 4 | 5 | ## Installation 6 | 7 | Clone the repository and then install using `setup.py`: 8 | 9 | ```sh 10 | python setup.py install 11 | ``` 12 | This will also automatically install the plugin's dependencies. Alternatively, install via `pip`: 13 | 14 | 15 | pip install pytest-hoverfly-wrapper 16 | 17 | 18 | ## Testing 19 | The quickest way is to run tox: 20 | ``` 21 | pip install tox 22 | tox -e 23 | ``` 24 | You can also run in pytest to make use of its debugging tools: 25 | (Assumes you have a virtual environment set up for a compatible Python version - see setup.py for compatible versions) 26 | ``` 27 | python setup.py install 28 | pip install -r requirements-test.txt 29 | pytest tests/ 30 | ``` 31 | 32 | `end_to_end` tests the plugin's integration with a Pytest framework: each test consists of a pytest script that gets 33 | fed into pytester and run inside a virtual Pytest environment. The result of *that* is asserted against. The reason we 34 | need this is to test failure modes, which would cause the test to fail. 35 | 36 | `unit` contains tests for individual functions. 37 | 38 | ## Usage example 39 | 40 | ### Cache responses to external services 41 | 42 | Adding the `setup_hoverfly` fixture will stand up a Hoverfly server instance running on port 8500. You can then use this 43 | as a proxy that saves the responses to any requests make via the proxy. If the test passes, the saved responses will be dumped 44 | to file, which will be used when the test runs again. 45 | 46 | ```python 47 | # Enable the fixture explicitly in your tests or conftest.py (not required when using setuptools entry points) 48 | from pytest_hoverfly_wrapper import GeneratedSimulation 49 | import requests 50 | import pytest 51 | 52 | @pytest.mark.simulated(GeneratedSimulation(file="some_file.json")) 53 | def test_something(setup_hoverfly): 54 | proxy_port = setup_hoverfly[1] 55 | proxies = { 56 | "http": "http://localhost:{}".format(proxy_port), 57 | "https": "http://localhost:{}".format(proxy_port), 58 | } 59 | requests.get("https://urlwedontwanttospam.com", proxies=proxies) 60 | 61 | ``` 62 | After running the test for the first time, you will find a file located at `./test_data/generated/some_file.json`, 63 | containing all the requests made using the proxy, as well as the responses to them. Upon running the test the second time, 64 | the test will load the file and attempt to match requests to the list in the file. If a successful match is found, the matching 65 | response will be served. If not, the request will be made to its original target and the target's response will be served instead. 66 | 67 | ### Completely fake responses 68 | 69 | You can also specify your own custom responses. 70 | 71 | ```python 72 | # Enable the fixture explicitly in your tests or conftest.py (not required when using setuptools entry points) 73 | from pytest_hoverfly_wrapper import StaticSimulation 74 | import requests 75 | import pytest 76 | 77 | @pytest.mark.simulated(StaticSimulation(files=["google_returns_404.json"])) 78 | def test_something(setup_hoverfly): 79 | proxy_port = setup_hoverfly[1] 80 | proxies = { 81 | "http": "http://localhost:{}".format(proxy_port), 82 | "https": "http://localhost:{}".format(proxy_port), 83 | } 84 | r = requests.get("http://google.com", proxies=proxies) 85 | assert r.status_code == 404 86 | ``` 87 | Full code is in `sample/` 88 | 89 | ### Hoverfly crashes 90 | Occasionally, the Hoverfly proxy might crash mid-test. If this happens, the test will raise `HoverflyCrashException`, 91 | which gives you clarity of why the test failed and can be caught in your testing framework as part of some test retrying 92 | logic. 93 | 94 | ### Logging 95 | `pytest-hoverfly-wrapper` uses the in-built `logging` module for logs. To import the logger: 96 | ```python 97 | import logging 98 | from pytest_hoverfly_wrapper import LOGGER_NAME 99 | hoverfly_logger = logging.getLogger(LOGGER_NAME) 100 | ``` 101 | Then customise the logger as necessary. 102 | 103 | 104 | ### Debugging 105 | In all scenarios, when a response is sent by Hoverfly rather than a remote server, that response will have the `Hoverfly-Cache-Served` 106 | header set. This differentiates the two types of response, and helps debug situations where you think a response is being served by Hoverfly 107 | but isn't, e.g. when Hoverfly fails to match the request even though you're expecting it to. 108 | 109 | At the end of the test, the plugin will create a `network.json` file containing the list of all requests made (and responses received) 110 | during the test, including parameters and headers. 111 | 112 | ## Release History 113 | 114 | * 0.1.0 115 | * Initial release 116 | * 0.1.1 117 | * Updates the description in the PyPi page. 118 | * 0.1.2 119 | * Create test data directory if it doesn't exist 120 | * 0.1.3 121 | * Put the bugfix in 0.1.2 in its correct place and remove extraneous plugin.py code 122 | * 0.1.4 123 | * Fixes broken domain blocking functionality 124 | * 0.2.0 125 | * Bug fixes and command line option to pass custom parameters to Hoverfly executable command 126 | * 0.3.0 127 | * Expose Journal API for accessing journal 128 | * 0.3.2 129 | * Fixes bug where `block_domains` is ignored if a simulation file isn't specified in `StaticSimulation` 130 | * 0.3.3 131 | * Registers `simulated` marker used by plugin 132 | * 0.4.0 133 | * Strips `Expires` property from `Set-Cookie` headers in recorded simulations 134 | * 0.4.1 135 | * Fixes typo in installation instructions 136 | * 0.5.0 137 | * Records simulations for static simulations if they don't exist yet 138 | * 0.5.1 139 | * Clean up of code styling, docs and tests. 140 | * 1.0.0 141 | * Automatic download of binaries; support for <3.7 dropped; support added for Windows 142 | * 1.0.1 143 | * Fix setup.py classifiers, and pull in latest Hoverfly version always 144 | 145 | ## Meta 146 | 147 | For all queries contact Veli Akiner: https://www.linkedin.com/in/veli-akiner-70a19b69/ 148 | 149 | Distributed under a modified MIT license. See ``LICENSE`` for more information. 150 | 151 | [https://github.com/kopernio/pytest-hoverfly-wrapper](https://github.com/kopernio/pytest-hoverfly-wrapper) 152 | 153 | ## Contributing 154 | 155 | 1. Fork it () 156 | 2. Create your feature branch (`git checkout -b feature/fooBar`) 157 | 3. Commit your changes (`git commit -am 'Add some fooBar'`) 158 | 4. Push to the branch (`git push origin feature/fooBar`) 159 | 5. Create a new Pull Request 160 | 161 | -------------------------------------------------------------------------------- /pytest_hoverfly_wrapper/plugin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import glob 4 | import json 5 | import os 6 | import re 7 | import subprocess 8 | from datetime import datetime, timezone 9 | from pathlib import Path 10 | 11 | import polling 12 | import pytest 13 | import requests 14 | from dateutil.parser import parse 15 | 16 | from .download import HOVERCTL_PATH, HOVERFLY_PATH, manage_executables 17 | from .logger import logger 18 | from .simulations import StaticSimulation 19 | 20 | BASE_API_URL = "http://localhost:{}/api/v2" 21 | HOVERFLY_API_MODE = f"{BASE_API_URL}/hoverfly/mode" 22 | HOVERFLY_API_SIMULATION = f"{BASE_API_URL}/simulation" 23 | HOVERFLY_API_JOURNAL = f"{BASE_API_URL}/journal" 24 | 25 | JOURNAL_LIMIT = 2000 26 | 27 | HF_ADMIN_PORT = 8888 28 | PROXY_PORT = 8500 29 | 30 | 31 | @pytest.fixture 32 | def ignore_hosts(request): 33 | request.node.ignore = "localhost" 34 | 35 | 36 | @pytest.fixture 37 | def sensitive_hosts(request): 38 | # We verify that requests to these hosts in tests are cached in simulations. 39 | request.node.sensitive = () 40 | 41 | 42 | TEST_DATA_DIR = os.path.join(os.getcwd(), "test_data") 43 | 44 | 45 | @pytest.fixture 46 | def test_data_dir(): 47 | return TEST_DATA_DIR 48 | 49 | 50 | @pytest.fixture 51 | def _test_data_dir(test_data_dir): 52 | for dir_ in (test_data_dir, os.path.join(test_data_dir, "static"), os.path.join(test_data_dir, "generated")): 53 | Path(dir_).mkdir(parents=True, exist_ok=True) 54 | return test_data_dir 55 | 56 | 57 | def pytest_addoption(parser): 58 | parser.addoption( 59 | "--forcelive", 60 | action="store_true", 61 | default=False, 62 | help="Forces tests using generated simulations to run against live endpoints, and re-record the simulation.", 63 | ) 64 | parser.addoption( 65 | "--refreshexpired", 66 | action="store_true", 67 | default=False, 68 | help="Re-records any tests whose generated simulations have expired. Don't use for actual testing.", 69 | ) 70 | parser.addoption( 71 | "--hoverfly-opts", action="store", default="", help="Additional arguments to pass to the Hoverfly executable" 72 | ) 73 | 74 | 75 | @pytest.fixture 76 | def test_log_directory(): 77 | directory = os.path.join("hoverfly_logs") 78 | if not os.path.exists(directory): 79 | os.mkdir(directory) 80 | return directory 81 | 82 | 83 | def pytest_collection_modifyitems(config, items): 84 | if config.getoption("refreshexpired"): 85 | # Collect all tests that have expiring simulations 86 | # (the up-to-date ones get skipped, which is simpler than parsing 87 | # the simulation files to determine expired status during collection) 88 | items[:] = [ 89 | item 90 | for item in items 91 | if item.get_closest_marker("simulated") and item.get_closest_marker("simulated").args[0].max_age 92 | ] 93 | 94 | 95 | def pytest_configure(config): 96 | config.addinivalue_line( 97 | "markers", 98 | "simulated(simulation_obj): Makes use of recorded responses which are sent in response to web requests " 99 | "made in tests, rather than receiving responses from their intended targets", 100 | ) 101 | manage_executables() 102 | 103 | 104 | def simulate(file, hf_port, admin_port): 105 | logger.info("Simulation exists and is up-to-date. Importing.") 106 | if file: 107 | with open(file) as file_: 108 | data = file_.read().encode("utf-8") 109 | requests.put(HOVERFLY_API_SIMULATION.format(admin_port), data, timeout=5) 110 | yield "simulate", hf_port, admin_port 111 | 112 | 113 | def record(file, node, proxy_port, admin_port, capture_arguments): 114 | logger.info("Recording a simulation.") 115 | if not capture_arguments: 116 | capture_arguments = {"headersWhitelist": ["Cookie"]} 117 | requests.put( 118 | HOVERFLY_API_MODE.format(admin_port), json={"mode": "capture", "arguments": capture_arguments}, timeout=5 119 | ) 120 | yield "record", proxy_port, admin_port 121 | if hasattr(node, "dont_save_sim"): 122 | logger.info("Test did not pass, not saving simulation") 123 | return 124 | 125 | sim = requests.get(HOVERFLY_API_SIMULATION.format(admin_port), timeout=5).text 126 | 127 | data = json.loads(sim) 128 | new_pairs = [] 129 | hosts_to_ignore = [node.ignore] if isinstance(node.ignore, str) else node.ignore 130 | for pair in data["data"]["pairs"]: 131 | # Remove expiry from Set-Cookie headers in Hoverfly responses 132 | set_cookie_header = pair["response"]["headers"].get("Set-Cookie", []) 133 | set_cookie_header[:] = [re.sub(r"(E|e)xpires=[^;]+;*", "", c) for c in set_cookie_header] 134 | # Allow us to differentiate cached responses from proxied ones. 135 | pair["response"]["headers"]["Hoverfly-Cache-Served"] = ["True"] 136 | # `value` is a URL 137 | if not any(host in pair["request"]["destination"][0]["value"] for host in hosts_to_ignore): 138 | new_pairs.append(pair) 139 | data["data"]["pairs"] = new_pairs 140 | with open(file, "w") as file_: 141 | file_.write(json.dumps(data, indent=4, separators=(",", ": "))) 142 | 143 | 144 | @pytest.hookimpl(hookwrapper=True) 145 | def pytest_runtest_makereport(item, call): 146 | yield 147 | if call.when == "call" and call.excinfo: 148 | item.dont_save_sim = True 149 | 150 | 151 | @pytest.fixture 152 | def setup_hoverfly( 153 | request, hf_ports, test_log_directory, ignore_hosts, sensitive_hosts, _test_data_dir 154 | ): # pylint: disable=W0613 155 | # Start Hoverfly 156 | logger.info("Setting up hoverfly") 157 | port, admin_port = hf_ports 158 | if not hasattr(request.config, "slaveinput"): 159 | # Cleaning up any running hoverctl processes is nice, but too risky in distributed mode 160 | with subprocess.Popen([HOVERCTL_PATH, "stop"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) as proc: 161 | proc.wait() 162 | 163 | logger.info("Starting hoverfly") 164 | add_opts = request.config.getoption("hoverfly_opts") 165 | hoverfly_cmd = [HOVERFLY_PATH, "-pp", str(port), "-ap", str(admin_port), *add_opts.split()] 166 | exc = None 167 | with open(os.path.join(test_log_directory, "hoverfly.log"), "w") as file: 168 | for _ in range(3): 169 | hf_proc = subprocess.Popen(hoverfly_cmd, stdout=file, stderr=file) # pylint: disable=R1732 170 | try: 171 | polling.poll( 172 | target=lambda: requests.get(HOVERFLY_API_MODE.format(admin_port), timeout=5).status_code == 200, 173 | step=0.2, 174 | timeout=5, 175 | ignore_exceptions=requests.exceptions.ConnectionError, 176 | ) 177 | break 178 | except polling.TimeoutException as exc_: 179 | exc = exc_ 180 | with subprocess.Popen(["ps", "-ef"], stdout=file, stderr=file) as proc: 181 | proc.wait() 182 | else: 183 | raise exc 184 | 185 | requests.put(HOVERFLY_API_MODE.format(admin_port), json={"mode": "spy"}, timeout=5) 186 | 187 | try: 188 | yield from setup_hoverfly_mode(request, port, admin_port, _test_data_dir) 189 | generate_logs(request, JournalAPI(admin_port=hf_ports[1]), test_log_directory) 190 | finally: 191 | logger.warning("Killing hoverfly") 192 | hf_proc.kill() 193 | logger.warning("Killed hoverfly") 194 | 195 | 196 | def setup_hoverfly_mode(request, port, admin_port, data_dir): 197 | sim_marker = request.node.get_closest_marker("simulated") 198 | sim_config = StaticSimulation() if not sim_marker else sim_marker.args[0] 199 | file = sim_config.full_file_path(data_dir, admin_port) 200 | if no_valid_simulation_exists(request, file, sim_config.max_age): 201 | request.node.mode = "record" 202 | yield from record(file, request.node, port, admin_port, sim_config.capture_config) 203 | else: 204 | request.node.mode = "simulate" 205 | logger.info("Loading file: %s", file) 206 | yield from simulate(file, port, admin_port) 207 | 208 | 209 | def no_valid_simulation_exists(request, sim_file, max_age_seconds): 210 | if request.config.getoption("forcelive"): 211 | return True 212 | try: 213 | with open(sim_file) as file: 214 | sim_metadata = json.loads(file.read())["meta"] 215 | date_sim_created = parse(sim_metadata.get("timeExported")) 216 | age = (datetime.now(timezone.utc) - date_sim_created).total_seconds() 217 | if request.config.getoption("refreshexpired"): 218 | if max_age_seconds and age > max_age_seconds: 219 | logger.debug("Simulation is expired.") 220 | return True 221 | skip_msg = "Simulation up-to-date. No need to run test." 222 | logger.warning(skip_msg) 223 | pytest.skip(skip_msg) 224 | except FileNotFoundError: 225 | logger.debug("No simulation file found.") 226 | return True 227 | return False 228 | 229 | 230 | @pytest.fixture 231 | def hf_ports(request): 232 | """Sets a unique port for each worker thread to talk to its instance of hoverfly.""" 233 | if hasattr(request.config, "slaveinput"): 234 | increment = int(request.config.slaveinput["slaveid"][-1]) 235 | else: 236 | increment = 0 237 | admin_port = HF_ADMIN_PORT + increment 238 | request.config.admin_port = admin_port 239 | return PROXY_PORT + increment, admin_port 240 | 241 | 242 | @pytest.hookimpl(hookwrapper=True) 243 | def pytest_runtest_call(item): 244 | outcome = yield 245 | if "setup_hoverfly" not in item.fixturenames: 246 | return 247 | try: 248 | requests.get(f"http://localhost:{item.config.admin_port}", timeout=5) 249 | except requests.exceptions.ConnectionError: 250 | logger.warning("Hoverfly crashed.") 251 | try: 252 | requests.get(f"http://localhost:{item.config.admin_port}", timeout=5) 253 | except requests.exceptions.ConnectionError: 254 | 255 | def raise_hoverfly_exception(): 256 | raise HoverflyCrashedException("Hoverfly crashed") 257 | 258 | outcome.get_result = raise_hoverfly_exception 259 | 260 | 261 | def generate_logs(request, journal_api, test_log_directory): 262 | network_log_file = os.path.join(test_log_directory, "network.json") 263 | with open(network_log_file, "w") as file: 264 | logger.warning("Getting journal") 265 | try: 266 | loaded_journal = journal_api.get() 267 | except requests.exceptions.ConnectionError: 268 | logger.warning("Hoverfly fell over. No network log available") 269 | file.write(json.dumps({"msg": "Hoverfly crashed while retrieving logs"})) 270 | return 271 | logger.warning("Got journal") 272 | try: 273 | for pair in loaded_journal["journal"]: 274 | # Truncate long responses, particularly PDF ones. We're not usually interested in the data itself. 275 | if len(pair["response"]["body"]) > 1000: 276 | pair["response"]["body"] = pair["response"]["body"][:1000] + "..." 277 | if request.node.mode == "simulate" and any( 278 | host in pair["request"]["destination"] for host in request.node.sensitive 279 | ): 280 | assert pair["response"]["headers"].get( 281 | "Hoverfly-Cache-Served" 282 | ), f"Warning: sensitive URL is being hit in a simulated test: {pair['request']}" 283 | finally: 284 | file.write(json.dumps(loaded_journal, indent=4, separators=(",", ": "))) 285 | 286 | 287 | class JournalAPI: 288 | """Python interface for Hoverctl's REST API for accessing its journal.""" 289 | 290 | def __init__(self, admin_port): 291 | self.admin_port = admin_port 292 | 293 | def delete(self): 294 | requests.delete(HOVERFLY_API_JOURNAL.format(self.admin_port), timeout=5) 295 | 296 | def get(self): 297 | offset = 0 298 | journals_per_request = 10 299 | 300 | def get_running_journal(): 301 | return json.loads( 302 | requests.get( 303 | HOVERFLY_API_JOURNAL.format(self.admin_port) + f"?limit={journals_per_request}&offset={offset}", 304 | timeout=5, 305 | ).text 306 | ) 307 | 308 | running_journal = get_running_journal() 309 | while running_journal["total"] > len(running_journal["journal"]): 310 | hf_journal = get_running_journal() 311 | running_journal["journal"] += hf_journal["journal"] 312 | offset += journals_per_request 313 | return running_journal 314 | 315 | 316 | @pytest.fixture 317 | def journal_api(setup_hoverfly): 318 | return JournalAPI(setup_hoverfly[2]) 319 | 320 | 321 | def pytest_unconfigure(): 322 | for file in glob.glob("combined_temp*.json"): 323 | os.remove(file) 324 | 325 | 326 | class HoverflyCrashedException(Exception): 327 | """Custom exception to signal to framework that Hoverfly has crashed.""" 328 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MAIN] 2 | 3 | # Analyse import fallback blocks. This can be used to support both Python 2 and 4 | # 3 compatible code, which means that the block might have code that exists 5 | # only in one or another interpreter, leading to false positives when analysed. 6 | analyse-fallback-blocks=no 7 | 8 | # Clear in-memory caches upon conclusion of linting. Useful if running pylint 9 | # in a server-like mode. 10 | clear-cache-post-run=no 11 | 12 | # Load and enable all available extensions. Use --list-extensions to see a list 13 | # all available extensions. 14 | #enable-all-extensions= 15 | 16 | # In error mode, messages with a category besides ERROR or FATAL are 17 | # suppressed, and no reports are done by default. Error mode is compatible with 18 | # disabling specific errors. 19 | #errors-only= 20 | 21 | # Always return a 0 (non-error) status code, even if lint errors are found. 22 | # This is primarily useful in continuous integration scripts. 23 | #exit-zero= 24 | 25 | # A comma-separated list of package or module names from where C extensions may 26 | # be loaded. Extensions are loading into the active Python interpreter and may 27 | # run arbitrary code. 28 | extension-pkg-allow-list= 29 | 30 | # A comma-separated list of package or module names from where C extensions may 31 | # be loaded. Extensions are loading into the active Python interpreter and may 32 | # run arbitrary code. (This is an alternative name to extension-pkg-allow-list 33 | # for backward compatibility.) 34 | extension-pkg-whitelist= 35 | 36 | # Return non-zero exit code if any of these messages/categories are detected, 37 | # even if score is above --fail-under value. Syntax same as enable. Messages 38 | # specified are enabled, while categories only check already-enabled messages. 39 | fail-on= 40 | 41 | # Specify a score threshold under which the program will exit with error. 42 | fail-under=10 43 | 44 | # Interpret the stdin as a python script, whose filename needs to be passed as 45 | # the module_or_package argument. 46 | #from-stdin= 47 | 48 | # Files or directories to be skipped. They should be base names, not paths. 49 | ignore=CVS 50 | 51 | # Add files or directories matching the regular expressions patterns to the 52 | # ignore-list. The regex matches against paths and can be in Posix or Windows 53 | # format. Because '\\' represents the directory delimiter on Windows systems, 54 | # it can't be used as an escape character. 55 | ignore-paths=build,dist 56 | 57 | # Files or directories matching the regular expression patterns are skipped. 58 | # The regex matches against base names, not paths. The default value ignores 59 | # Emacs file locks 60 | ignore-patterns=^\.# 61 | 62 | # List of module names for which member attributes should not be checked 63 | # (useful for modules/projects where namespaces are manipulated during runtime 64 | # and thus existing member attributes cannot be deduced by static analysis). It 65 | # supports qualified module names, as well as Unix pattern matching. 66 | ignored-modules= 67 | 68 | # Python code to execute, usually for sys.path manipulation such as 69 | # pygtk.require(). 70 | #init-hook= 71 | 72 | # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the 73 | # number of processors available to use, and will cap the count on Windows to 74 | # avoid hangs. 75 | jobs=1 76 | 77 | # Control the amount of potential inferred values when inferring a single 78 | # object. This can help the performance when dealing with large functions or 79 | # complex, nested conditions. 80 | limit-inference-results=100 81 | 82 | # List of plugins (as comma separated values of python module names) to load, 83 | # usually to register additional checkers. 84 | load-plugins= 85 | 86 | # Pickle collected data for later comparisons. 87 | persistent=yes 88 | 89 | # Minimum Python version to use for version dependent checks. Will default to 90 | # the version used to run pylint. 91 | py-version=3.10 92 | 93 | # Discover python modules and packages in the file system subtree. 94 | recursive=no 95 | 96 | # When enabled, pylint would attempt to guess common misconfiguration and emit 97 | # user-friendly hints instead of false-positive error messages. 98 | suggestion-mode=yes 99 | 100 | # Allow loading of arbitrary C extensions. Extensions are imported into the 101 | # active Python interpreter and may run arbitrary code. 102 | unsafe-load-any-extension=no 103 | 104 | # In verbose mode, extra non-checker-related info will be displayed. 105 | #verbose= 106 | 107 | 108 | [BASIC] 109 | 110 | # Naming style matching correct argument names. 111 | argument-naming-style=snake_case 112 | 113 | # Regular expression matching correct argument names. Overrides argument- 114 | # naming-style. If left empty, argument names will be checked with the set 115 | # naming style. 116 | #argument-rgx= 117 | 118 | # Naming style matching correct attribute names. 119 | attr-naming-style=snake_case 120 | 121 | # Regular expression matching correct attribute names. Overrides attr-naming- 122 | # style. If left empty, attribute names will be checked with the set naming 123 | # style. 124 | #attr-rgx= 125 | 126 | # Bad variable names which should always be refused, separated by a comma. 127 | bad-names=foo, 128 | bar, 129 | baz, 130 | toto, 131 | tutu, 132 | tata 133 | 134 | # Bad variable names regexes, separated by a comma. If names match any regex, 135 | # they will always be refused 136 | bad-names-rgxs= 137 | 138 | # Naming style matching correct class attribute names. 139 | class-attribute-naming-style=any 140 | 141 | # Regular expression matching correct class attribute names. Overrides class- 142 | # attribute-naming-style. If left empty, class attribute names will be checked 143 | # with the set naming style. 144 | #class-attribute-rgx= 145 | 146 | # Naming style matching correct class constant names. 147 | class-const-naming-style=UPPER_CASE 148 | 149 | # Regular expression matching correct class constant names. Overrides class- 150 | # const-naming-style. If left empty, class constant names will be checked with 151 | # the set naming style. 152 | #class-const-rgx= 153 | 154 | # Naming style matching correct class names. 155 | class-naming-style=PascalCase 156 | 157 | # Regular expression matching correct class names. Overrides class-naming- 158 | # style. If left empty, class names will be checked with the set naming style. 159 | #class-rgx= 160 | 161 | # Naming style matching correct constant names. 162 | const-naming-style=UPPER_CASE 163 | 164 | # Regular expression matching correct constant names. Overrides const-naming- 165 | # style. If left empty, constant names will be checked with the set naming 166 | # style. 167 | #const-rgx= 168 | 169 | # Minimum line length for functions/classes that require docstrings, shorter 170 | # ones are exempt. 171 | docstring-min-length=-1 172 | 173 | # Naming style matching correct function names. 174 | function-naming-style=snake_case 175 | 176 | # Regular expression matching correct function names. Overrides function- 177 | # naming-style. If left empty, function names will be checked with the set 178 | # naming style. 179 | #function-rgx= 180 | 181 | # Good variable names which should always be accepted, separated by a comma. 182 | good-names=i, 183 | j, 184 | k, 185 | ex, 186 | Run, 187 | _ 188 | 189 | # Good variable names regexes, separated by a comma. If names match any regex, 190 | # they will always be accepted 191 | good-names-rgxs= 192 | 193 | # Include a hint for the correct naming format with invalid-name. 194 | include-naming-hint=no 195 | 196 | # Naming style matching correct inline iteration names. 197 | inlinevar-naming-style=any 198 | 199 | # Regular expression matching correct inline iteration names. Overrides 200 | # inlinevar-naming-style. If left empty, inline iteration names will be checked 201 | # with the set naming style. 202 | #inlinevar-rgx= 203 | 204 | # Naming style matching correct method names. 205 | method-naming-style=snake_case 206 | 207 | # Regular expression matching correct method names. Overrides method-naming- 208 | # style. If left empty, method names will be checked with the set naming style. 209 | #method-rgx= 210 | 211 | # Naming style matching correct module names. 212 | module-naming-style=snake_case 213 | 214 | # Regular expression matching correct module names. Overrides module-naming- 215 | # style. If left empty, module names will be checked with the set naming style. 216 | #module-rgx= 217 | 218 | # Colon-delimited sets of names that determine each other's naming style when 219 | # the name regexes allow several styles. 220 | name-group= 221 | 222 | # Regular expression which should only match function or class names that do 223 | # not require a docstring. 224 | no-docstring-rgx=^_ 225 | 226 | # List of decorators that produce properties, such as abc.abstractproperty. Add 227 | # to this list to register other decorators that produce valid properties. 228 | # These decorators are taken in consideration only for invalid-name. 229 | property-classes=abc.abstractproperty 230 | 231 | # Regular expression matching correct type variable names. If left empty, type 232 | # variable names will be checked with the set naming style. 233 | #typevar-rgx= 234 | 235 | # Naming style matching correct variable names. 236 | variable-naming-style=snake_case 237 | 238 | # Regular expression matching correct variable names. Overrides variable- 239 | # naming-style. If left empty, variable names will be checked with the set 240 | # naming style. 241 | #variable-rgx= 242 | 243 | 244 | [CLASSES] 245 | 246 | # Warn about protected attribute access inside special methods 247 | check-protected-access-in-special-methods=no 248 | 249 | # List of method names used to declare (i.e. assign) instance attributes. 250 | defining-attr-methods=__init__, 251 | __new__, 252 | setUp, 253 | __post_init__ 254 | 255 | # List of member names, which should be excluded from the protected access 256 | # warning. 257 | exclude-protected=_asdict, 258 | _fields, 259 | _replace, 260 | _source, 261 | _make 262 | 263 | # List of valid names for the first argument in a class method. 264 | valid-classmethod-first-arg=cls 265 | 266 | # List of valid names for the first argument in a metaclass class method. 267 | valid-metaclass-classmethod-first-arg=mcs 268 | 269 | 270 | [DESIGN] 271 | 272 | # List of regular expressions of class ancestor names to ignore when counting 273 | # public methods (see R0903) 274 | exclude-too-few-public-methods= 275 | 276 | # List of qualified class names to ignore when counting class parents (see 277 | # R0901) 278 | ignored-parents= 279 | 280 | # Maximum number of arguments for function / method. 281 | max-args=5 282 | 283 | # Maximum number of attributes for a class (see R0902). 284 | max-attributes=7 285 | 286 | # Maximum number of boolean expressions in an if statement (see R0916). 287 | max-bool-expr=5 288 | 289 | # Maximum number of branch for function / method body. 290 | max-branches=12 291 | 292 | # Maximum number of locals for function / method body. 293 | max-locals=15 294 | 295 | # Maximum number of parents for a class (see R0901). 296 | max-parents=7 297 | 298 | # Maximum number of public methods for a class (see R0904). 299 | max-public-methods=20 300 | 301 | # Maximum number of return / yield for function / method body. 302 | max-returns=6 303 | 304 | # Maximum number of statements in function / method body. 305 | max-statements=50 306 | 307 | # Minimum number of public methods for a class (see R0903). 308 | min-public-methods=2 309 | 310 | 311 | [EXCEPTIONS] 312 | 313 | # Exceptions that will emit a warning when caught. 314 | overgeneral-exceptions=builtins.BaseException,builtins.Exception 315 | 316 | 317 | [FORMAT] 318 | 319 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 320 | expected-line-ending-format= 321 | 322 | # Regexp for a line that is allowed to be longer than the limit. 323 | ignore-long-lines=^\s*(# )??$ 324 | 325 | # Number of spaces of indent required inside a hanging or continued line. 326 | indent-after-paren=4 327 | 328 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 329 | # tab). 330 | indent-string=' ' 331 | 332 | # Maximum number of characters on a single line. 333 | max-line-length=120 334 | 335 | # Maximum number of lines in a module. 336 | max-module-lines=1000 337 | 338 | # Allow the body of a class to be on the same line as the declaration if body 339 | # contains single statement. 340 | single-line-class-stmt=no 341 | 342 | # Allow the body of an if to be on the same line as the test if there is no 343 | # else. 344 | single-line-if-stmt=no 345 | 346 | 347 | [IMPORTS] 348 | 349 | # List of modules that can be imported at any level, not just the top level 350 | # one. 351 | allow-any-import-level= 352 | 353 | # Allow explicit reexports by alias from a package __init__. 354 | allow-reexport-from-package=no 355 | 356 | # Allow wildcard imports from modules that define __all__. 357 | allow-wildcard-with-all=no 358 | 359 | # Deprecated modules which should not be used, separated by a comma. 360 | deprecated-modules= 361 | 362 | # Output a graph (.gv or any supported image format) of external dependencies 363 | # to the given file (report RP0402 must not be disabled). 364 | ext-import-graph= 365 | 366 | # Output a graph (.gv or any supported image format) of all (i.e. internal and 367 | # external) dependencies to the given file (report RP0402 must not be 368 | # disabled). 369 | import-graph= 370 | 371 | # Output a graph (.gv or any supported image format) of internal dependencies 372 | # to the given file (report RP0402 must not be disabled). 373 | int-import-graph= 374 | 375 | # Force import order to recognize a module as part of the standard 376 | # compatibility libraries. 377 | known-standard-library= 378 | 379 | # Force import order to recognize a module as part of a third party library. 380 | known-third-party=enchant 381 | 382 | # Couples of modules and preferred modules, separated by a comma. 383 | preferred-modules= 384 | 385 | 386 | [LOGGING] 387 | 388 | # The type of string formatting that logging methods do. `old` means using % 389 | # formatting, `new` is for `{}` formatting. 390 | logging-format-style=old 391 | 392 | # Logging modules to check that the string format arguments are in logging 393 | # function parameter format. 394 | logging-modules=logging 395 | 396 | 397 | [MESSAGES CONTROL] 398 | 399 | # Only show warnings with the listed confidence levels. Leave empty to show 400 | # all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE, 401 | # UNDEFINED. 402 | confidence=HIGH, 403 | CONTROL_FLOW, 404 | INFERENCE, 405 | INFERENCE_FAILURE, 406 | UNDEFINED 407 | 408 | # Disable the message, report, category or checker with the given id(s). You 409 | # can either give multiple identifiers separated by comma (,) or put this 410 | # option multiple times (only on the command line, not in the configuration 411 | # file where it should appear only once). You can also use "--disable=all" to 412 | # disable everything first and then re-enable specific checks. For example, if 413 | # you want to run only the similarities checker, you can use "--disable=all 414 | # --enable=similarities". If you want to run only the classes checker, but have 415 | # no Warning level messages displayed, use "--disable=all --enable=classes 416 | # --disable=W". 417 | disable=raw-checker-failed, 418 | bad-inline-option, 419 | locally-disabled, 420 | file-ignored, 421 | suppressed-message, 422 | useless-suppression, 423 | deprecated-pragma, 424 | use-symbolic-message-instead, 425 | C0114, 426 | C0116, 427 | W1514, 428 | W0621 429 | 430 | # Enable the message, report, category or checker with the given id(s). You can 431 | # either give multiple identifier separated by comma (,) or put this option 432 | # multiple time (only on the command line, not in the configuration file where 433 | # it should appear only once). See also the "--disable" option for examples. 434 | enable=c-extension-no-member 435 | 436 | 437 | [METHOD_ARGS] 438 | 439 | # List of qualified names (i.e., library.method) which require a timeout 440 | # parameter e.g. 'requests.api.get,requests.api.post' 441 | timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request 442 | 443 | 444 | [MISCELLANEOUS] 445 | 446 | # List of note tags to take in consideration, separated by a comma. 447 | notes=FIXME, 448 | XXX, 449 | TODO 450 | 451 | # Regular expression of note tags to take in consideration. 452 | notes-rgx= 453 | 454 | 455 | [REFACTORING] 456 | 457 | # Maximum number of nested blocks for function / method body 458 | max-nested-blocks=5 459 | 460 | # Complete name of functions that never returns. When checking for 461 | # inconsistent-return-statements if a never returning function is called then 462 | # it will be considered as an explicit return statement and no message will be 463 | # printed. 464 | never-returning-functions=sys.exit,argparse.parse_error 465 | 466 | 467 | [REPORTS] 468 | 469 | # Python expression which should return a score less than or equal to 10. You 470 | # have access to the variables 'fatal', 'error', 'warning', 'refactor', 471 | # 'convention', and 'info' which contain the number of messages in each 472 | # category, as well as 'statement' which is the total number of statements 473 | # analyzed. This score is used by the global evaluation report (RP0004). 474 | evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)) 475 | 476 | # Template used to display messages. This is a python new-style format string 477 | # used to format the message information. See doc for all details. 478 | msg-template= 479 | 480 | # Set the output format. Available formats are text, parseable, colorized, json 481 | # and msvs (visual studio). You can also give a reporter class, e.g. 482 | # mypackage.mymodule.MyReporterClass. 483 | #output-format= 484 | 485 | # Tells whether to display a full report or only the messages. 486 | reports=no 487 | 488 | # Activate the evaluation score. 489 | score=yes 490 | 491 | 492 | [SIMILARITIES] 493 | 494 | # Comments are removed from the similarity computation 495 | ignore-comments=yes 496 | 497 | # Docstrings are removed from the similarity computation 498 | ignore-docstrings=yes 499 | 500 | # Imports are removed from the similarity computation 501 | ignore-imports=yes 502 | 503 | # Signatures are removed from the similarity computation 504 | ignore-signatures=yes 505 | 506 | # Minimum lines number of a similarity. 507 | min-similarity-lines=4 508 | 509 | 510 | [SPELLING] 511 | 512 | # Limits count of emitted suggestions for spelling mistakes. 513 | max-spelling-suggestions=4 514 | 515 | # Spelling dictionary name. Available dictionaries: none. To make it work, 516 | # install the 'python-enchant' package. 517 | spelling-dict= 518 | 519 | # List of comma separated words that should be considered directives if they 520 | # appear at the beginning of a comment and should not be checked. 521 | spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: 522 | 523 | # List of comma separated words that should not be checked. 524 | spelling-ignore-words= 525 | 526 | # A path to a file that contains the private dictionary; one word per line. 527 | spelling-private-dict-file= 528 | 529 | # Tells whether to store unknown words to the private dictionary (see the 530 | # --spelling-private-dict-file option) instead of raising a message. 531 | spelling-store-unknown-words=no 532 | 533 | 534 | [STRING] 535 | 536 | # This flag controls whether inconsistent-quotes generates a warning when the 537 | # character used as a quote delimiter is used inconsistently within a module. 538 | check-quote-consistency=no 539 | 540 | # This flag controls whether the implicit-str-concat should generate a warning 541 | # on implicit string concatenation in sequences defined over several lines. 542 | check-str-concat-over-line-jumps=no 543 | 544 | 545 | [TYPECHECK] 546 | 547 | # List of decorators that produce context managers, such as 548 | # contextlib.contextmanager. Add to this list to register other decorators that 549 | # produce valid context managers. 550 | contextmanager-decorators=contextlib.contextmanager 551 | 552 | # List of members which are set dynamically and missed by pylint inference 553 | # system, and so shouldn't trigger E1101 when accessed. Python regular 554 | # expressions are accepted. 555 | generated-members= 556 | 557 | # Tells whether to warn about missing members when the owner of the attribute 558 | # is inferred to be None. 559 | ignore-none=yes 560 | 561 | # This flag controls whether pylint should warn about no-member and similar 562 | # checks whenever an opaque object is returned when inferring. The inference 563 | # can return multiple potential results while evaluating a Python object, but 564 | # some branches might not be evaluated, which results in partial inference. In 565 | # that case, it might be useful to still emit no-member and other checks for 566 | # the rest of the inferred objects. 567 | ignore-on-opaque-inference=yes 568 | 569 | # List of symbolic message names to ignore for Mixin members. 570 | ignored-checks-for-mixins=no-member, 571 | not-async-context-manager, 572 | not-context-manager, 573 | attribute-defined-outside-init 574 | 575 | # List of class names for which member attributes should not be checked (useful 576 | # for classes with dynamically set attributes). This supports the use of 577 | # qualified names. 578 | ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace 579 | 580 | # Show a hint with possible names when a member name was not found. The aspect 581 | # of finding the hint is based on edit distance. 582 | missing-member-hint=yes 583 | 584 | # The minimum edit distance a name should have in order to be considered a 585 | # similar match for a missing member name. 586 | missing-member-hint-distance=1 587 | 588 | # The total number of similar names that should be taken in consideration when 589 | # showing a hint for a missing member. 590 | missing-member-max-choices=1 591 | 592 | # Regex pattern to define which classes are considered mixins. 593 | mixin-class-rgx=.*[Mm]ixin 594 | 595 | # List of decorators that change the signature of a decorated function. 596 | signature-mutators= 597 | 598 | 599 | [VARIABLES] 600 | 601 | # List of additional names supposed to be defined in builtins. Remember that 602 | # you should avoid defining new builtins when possible. 603 | additional-builtins= 604 | 605 | # Tells whether unused global variables should be treated as a violation. 606 | allow-global-unused-variables=yes 607 | 608 | # List of names allowed to shadow builtins 609 | allowed-redefined-builtins= 610 | 611 | # List of strings which can identify a callback function by name. A callback 612 | # name must start or end with one of those strings. 613 | callbacks=cb_, 614 | _cb 615 | 616 | # A regular expression matching the name of dummy variables (i.e. expected to 617 | # not be used). 618 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 619 | 620 | # Argument names that match this expression will be ignored. 621 | ignored-argument-names=_.*|^ignored_|^unused_ 622 | 623 | # Tells whether we should check for unused import in __init__ files. 624 | init-import=no 625 | 626 | # List of qualified module names which can have objects that can redefine 627 | # builtins. 628 | redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io 629 | --------------------------------------------------------------------------------