├── PULL_REQUEST_TEMPLATE.md ├── pytest.ini ├── setup.cfg ├── .pyup.yml ├── docs ├── pytest_needle │ ├── driver.rst │ ├── plugin.rst │ └── exceptions.rst ├── pytest_needle.rst ├── install.rst ├── misc.rst ├── Makefile ├── make.bat ├── development.rst ├── advanced.rst ├── index.rst ├── readme.rst └── conf.py ├── .bumpversion.cfg ├── ISSUE_TEMPLATE.md ├── pytest_needle ├── __init__.py ├── exceptions.py ├── plugin.py └── driver.py ├── requirements.txt ├── .codeclimate.yml ├── Pipfile ├── .travis.yml ├── test ├── test_screenshot_creation.py └── test_example.py ├── LICENSE.txt ├── publish.sh ├── setup.py ├── .gitignore ├── README.md ├── .pylintrc └── Pipfile.lock /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Fixes # 2 | 3 | ## Proposed Changes 4 | 5 | - 6 | - 7 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --driver Chrome 3 | testpaths = test 4 | pep8maxlinelength = 120 -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | 4 | [wheel] 5 | universal = 1 6 | 7 | [pep8] 8 | max-line-length = 120 9 | -------------------------------------------------------------------------------- /.pyup.yml: -------------------------------------------------------------------------------- 1 | # autogenerated pyup.io config file 2 | # see https://pyup.io/docs/configuration/ for all available options 3 | 4 | schedule: every week 5 | -------------------------------------------------------------------------------- /docs/pytest_needle/driver.rst: -------------------------------------------------------------------------------- 1 | ====== 2 | Driver 3 | ====== 4 | 5 | .. automodule:: pytest_needle.driver 6 | :members: 7 | :undoc-members: 8 | :show-inheritance: 9 | -------------------------------------------------------------------------------- /docs/pytest_needle/plugin.rst: -------------------------------------------------------------------------------- 1 | ====== 2 | Plugin 3 | ====== 4 | 5 | .. automodule:: pytest_needle.plugin 6 | :members: 7 | :undoc-members: 8 | :show-inheritance: 9 | -------------------------------------------------------------------------------- /docs/pytest_needle/exceptions.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Exceptions 3 | ========== 4 | 5 | .. automodule:: pytest_needle.exceptions 6 | :members: 7 | :undoc-members: 8 | :show-inheritance: 9 | -------------------------------------------------------------------------------- /docs/pytest_needle.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ============= 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | 7 | pytest_needle/driver 8 | pytest_needle/exceptions 9 | pytest_needle/plugin 10 | -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.3.11 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:pytest_needle/__init__.py] 7 | 8 | [bumpversion:file:docs/conf.py] 9 | 10 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Expected Behavior 2 | 3 | 4 | ## Actual Behavior 5 | 6 | 7 | ## Steps to Reproduce the Problem 8 | 9 | 1. 10 | 1. 11 | 1. 12 | 13 | ## Specifications 14 | 15 | - Version: 16 | - Platform: 17 | -------------------------------------------------------------------------------- /pytest_needle/__init__.py: -------------------------------------------------------------------------------- 1 | """pytest_needle 2 | 3 | .. codeauthor:: John Lane 4 | 5 | """ 6 | 7 | __author__ = 'jlane' 8 | __email__ = 'jlane@fanthreesixty.com' 9 | __license__ = 'MIT' 10 | __version__ = '0.3.11' 11 | -------------------------------------------------------------------------------- /docs/install.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Installation 3 | ============ 4 | 5 | Install through pip: 6 | 7 | .. code-block:: bash 8 | 9 | pip install pytest-needle 10 | 11 | Install from source: 12 | 13 | .. code-block:: bash 14 | 15 | cd /path/to/source/pytest-needle 16 | python setup.py install 17 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | bumpversion>=0.5.0 2 | needle>=0.5.0,<0.6.0 3 | Pillow>=6.0.0 4 | pytest>=3.7.0,<5.0.0 5 | pytest-cov>=2.7.0 6 | pytest-pep8>=1.0.0 7 | python-coveralls>=2.9.0 8 | pytest-selenium>=1.16.0,<2.0.0 9 | recommonmark>=0.5.0 10 | selenium>=3.0.0 11 | sphinx>=1.8.0 12 | sphinx-autobuild>=0.7.0 13 | sphinx-rtd-theme>=0.4.0 -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | engines: 4 | pep8: 5 | enabled: true 6 | 7 | ratings: 8 | paths: 9 | - "**.py" 10 | 11 | checks: 12 | argument-count: 13 | config: 14 | threshold: 5 15 | file-lines: 16 | config: 17 | threshold: 1000 18 | method-count: 19 | config: 20 | threshold: 30 21 | method-lines: 22 | config: 23 | threshold: 50 -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | 8 | [packages] 9 | bumpversion = ">=0.5.0" 10 | needle = ">=0.5.0,<0.6.0" 11 | Pillow = ">=6.0.0" 12 | pytest = ">=3.7.0,<5.0.0" 13 | pytest-cov = ">=2.7.0" 14 | pytest-pep8 = ">=1.0.0" 15 | python-coveralls = ">=2.9.0" 16 | pytest-selenium = ">=1.16.0,<2.0.0" 17 | recommonmark = ">=0.5.0" 18 | selenium = ">=3.0.0" 19 | sphinx-autobuild = ">=0.7.0" 20 | sphinx-rtd-theme = ">=0.4.0" 21 | Sphinx = ">=1.8.0" 22 | 23 | [requires] 24 | python_version = "3.7" 25 | -------------------------------------------------------------------------------- /docs/misc.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | Miscellaneous 3 | ============= 4 | 5 | -------------- 6 | Special Thanks 7 | -------------- 8 | 9 | 10 | .. image:: http://svgshare.com/i/3ZQ.svg 11 | :target: https://www.browserstack.com 12 | 13 | Special thanks to BrowserStack for providing automated browser testing, at no charge, for this project and other open source projects like this. With over 1000+ device, browser and os versions combinations to choose from and integrations with Travis CI this project could not be successful without the hard work of the BrowserStack team and their continued support of the open source community. 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: required 3 | python: 4 | - "2.7" 5 | - "3.6" 6 | # command to install dependencies 7 | install: 8 | - pip install -e . 9 | - pip install -r requirements.txt 10 | - sudo apt-get install imagemagick perceptualdiff 11 | before_script: 12 | - pytest --driver BrowserStack --capability os "OS X" --capability os_version "El Capitan" --needle-save-baseline test/ 13 | # command to run tests 14 | script: 15 | - pytest --driver BrowserStack --capability os "OS X" --capability os_version "El Capitan" --pep8 pytest_needle --cov pytest_needle/ --cov-report term-missing test/ 16 | after_success: 17 | - coveralls -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = pytest-needle 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /test/test_screenshot_creation.py: -------------------------------------------------------------------------------- 1 | """test_screenshot_creation 2 | """ 3 | 4 | import os 5 | import pytest 6 | from pytest_needle.exceptions import MissingBaselineException 7 | 8 | 9 | def test_screenshot_creation(needle): 10 | """Verify that fresh images are generated regardless if there are baselines 11 | 12 | :param needle: 13 | :return: 14 | """ 15 | 16 | if needle.save_baseline: 17 | pytest.skip('Only run screenshot creation for non-baseline runs') 18 | 19 | screenshot_name = 'screenshot_without_baseline' 20 | screenshot_path = os.path.join(needle.output_dir, screenshot_name + ".png") 21 | needle.driver.get('https://www.example.com') 22 | 23 | try: 24 | needle.assert_screenshot(screenshot_name) 25 | except MissingBaselineException: 26 | pass 27 | 28 | assert os.path.isfile(screenshot_path), "Fresh screenshot was not created (there is no baseline image)" 29 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=pytest-needle 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 20 | echo.installed, then set the SPHINXBUILD environment variable to point 21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 22 | echo.may add the Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /pytest_needle/exceptions.py: -------------------------------------------------------------------------------- 1 | """exceptions 2 | 3 | .. codeauthor: John Lane 4 | 5 | """ 6 | 7 | 8 | class NeedleException(AssertionError): 9 | """Base exception for pytest-needle 10 | """ 11 | 12 | 13 | class ImageMismatchException(NeedleException): 14 | """Image mismatch exception 15 | """ 16 | 17 | def __init__(self, message, baseline_image, output_image, *args): 18 | 19 | self.baseline_image = baseline_image 20 | self.output_image = output_image 21 | 22 | super(ImageMismatchException, self).__init__(message, *args) 23 | 24 | 25 | class MissingBaselineException(NeedleException): 26 | """Missing baseline exception 27 | """ 28 | 29 | def __init__(self, message, *args): 30 | 31 | super(MissingBaselineException, self).__init__(message, *args) 32 | 33 | 34 | class MissingEngineException(NeedleException): 35 | """Missing engine exception 36 | """ 37 | 38 | def __init__(self, message, *args): 39 | 40 | super(MissingEngineException, self).__init__(message, *args) 41 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 John Lane 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /docs/development.rst: -------------------------------------------------------------------------------- 1 | =========== 2 | Development 3 | =========== 4 | 5 | ------------ 6 | Installation 7 | ------------ 8 | 9 | To install for development, simply run the following commands: 10 | 11 | .. code-block:: bash 12 | 13 | git clone https://github.com/jlane9/pytest-needle.git 14 | cd pyest-needle 15 | pip install -r requirements.txt 16 | pip install -e . 17 | 18 | ------------------------ 19 | Generating documentation 20 | ------------------------ 21 | 22 | You can either use makefile: 23 | 24 | .. code-block:: bash 25 | 26 | cd docs 27 | make html 28 | 29 | Or you can use autobuild: 30 | 31 | .. code-block:: bash 32 | 33 | cd docs 34 | sphinx-autobuild . _build/html/ 35 | 36 | ------------- 37 | Running Tests 38 | ------------- 39 | 40 | To run tests you must first provide a base line to go against: 41 | 42 | .. code-block:: bash 43 | 44 | pytest --driver Chrome --needle-save-baseline test/ 45 | 46 | Then all runs afterwards can be just: 47 | 48 | .. code-block:: bash 49 | 50 | pytest --driver Chrome --pep8 pytest_needle --cov pytest_needle --cov-report term-missing test/ -------------------------------------------------------------------------------- /publish.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | while [ $# -gt 0 ] 4 | do 5 | case "$1" in 6 | --) shift; break;; 7 | -*) 8 | echo >&2 \ 9 | "usage: $0 [major,minor,patch]" 10 | exit 1;; 11 | *) break;; # terminate while loop 12 | esac 13 | shift 14 | done 15 | 16 | # Navigate to project path 17 | cd $(dirname "$0") 18 | 19 | # Bump version 20 | if [[ "$1" =~ "minor" ]] || [[ "$1" =~ "major" ]] || [[ "$1" =~ "patch" ]] 21 | then 22 | bumpversion $1 23 | RESULT=$? 24 | else 25 | echo "error: bump version required." 26 | echo "usage $0 [major,minor,patch]" 27 | exit 1 28 | fi 29 | 30 | if [ "$RESULT" -eq 0 ] 31 | then 32 | # Get new version 33 | VERSION=$(grep -oEi "[0-9]+\.[0-9]+\.[0-9]+" pytest_needle/__init__.py) 34 | 35 | # Release new version 36 | git push --tags origin master 37 | python setup.py sdist bdist_wheel 38 | twine upload dist/* 39 | RELEASED=$? 40 | else 41 | echo "Failed to update to version $VERSION" 42 | exit 1 43 | fi 44 | 45 | if [ "$RELEASED" -eq 0 ] 46 | then 47 | echo "${VERSION} released..." 48 | else 49 | echo "Unable to release version ${VERSION}" 50 | fi 51 | -------------------------------------------------------------------------------- /docs/advanced.rst: -------------------------------------------------------------------------------- 1 | ================= 2 | Advanced Settings 3 | ================= 4 | 5 | ------- 6 | Engines 7 | ------- 8 | 9 | By default Needle uses the PIL engine (``needle.engines.pil_engine.Engine``) to take screenshots. Instead of PIL, you may also use PerceptualDiff or ImageMagick. 10 | 11 | 12 | Example with PerceptualDiff: 13 | 14 | .. code-block:: bash 15 | 16 | pytest --driver Chrome --needle-engine perceptualdiff test_example.py 17 | 18 | Example with ImageMagick: 19 | 20 | .. code-block:: bash 21 | 22 | pytest --driver Chrome --needle-engine imagemagick test_example.py 23 | 24 | Besides being much faster than PIL, PerceptualDiff and ImageMagick also generate a diff PNG file when a test fails, highlighting the differences between the baseline image and the new screenshot. 25 | 26 | Note that to use the PerceptualDiff engine you will first need to `download `_ the perceptualdiff binary and place it in your PATH. 27 | 28 | To use the ImageMagick engine you will need to install a package on your machine (e.g. sudo apt-get install imagemagick on Ubuntu or brew install imagemagick on OSX). 29 | 30 | 31 | ------------ 32 | File cleanup 33 | ------------ 34 | 35 | Each time you run tests, Needle will create new screenshot images on disk, for comparison with the baseline screenshots. 36 | It’s then up to you whether you want to delete them or archive them. To remove screenshots from successful test use: 37 | 38 | .. code-block:: bash 39 | 40 | pytest --driver Chrome --needle-cleanup-on-success test_example.py 41 | 42 | Any unsuccessful tests will remain on the file system. 43 | 44 | 45 | ----------- 46 | File output 47 | ----------- 48 | 49 | To specify a path for baseline image path use: 50 | 51 | .. code-block:: bash 52 | 53 | pytest --driver Chrome --needle-baseline-dir /path/to/baseline/images 54 | 55 | Default path is ./screenshots/baseline 56 | 57 | To specify a path for output image path use: 58 | 59 | .. code-block:: bash 60 | 61 | pytest --driver Chrome --needle-output-dir /path/to/output/images 62 | 63 | Default path is ./screenshots 64 | 65 | 66 | ----------------------- 67 | Generating HTML reports 68 | ----------------------- 69 | 70 | To generate html reports use: 71 | 72 | .. code-block:: bash 73 | 74 | pytest --driver Chrome --html=report.html --self-contained-html -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. pytest-needle documentation master file, created by 2 | sphinx-quickstart on Wed Oct 25 11:30:00 2017. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | ============= 7 | pytest-needle 8 | ============= 9 | 10 | .. image:: https://travis-ci.org/jlane9/pytest-needle.svg?branch=master 11 | :target: https://travis-ci.org/jlane9/pytest-needle 12 | 13 | .. image:: https://coveralls.io/repos/github/jlane9/pytest-needle/badge.svg?branch=master 14 | :target: https://coveralls.io/github/jlane9/pytest-needle?branch=master 15 | 16 | .. image:: https://badge.fury.io/py/pytest-needle.svg 17 | :target: https://badge.fury.io/py/pytest-needle 18 | 19 | .. image:: https://img.shields.io/pypi/pyversions/pytest-needle.svg 20 | :target: https://pypi.python.org/pypi/pytest-needle 21 | 22 | .. image:: https://img.shields.io/pypi/l/pytest-needle.svg 23 | :target: https://pypi.python.org/pypi/pytest-needle 24 | 25 | .. image:: https://img.shields.io/pypi/status/pytest-needle.svg 26 | :target: https://pypi.python.org/pypi/pytest-needle 27 | 28 | .. image:: https://requires.io/github/jlane9/pytest-needle/requirements.svg?branch=master 29 | :target: https://requires.io/github/jlane9/pytest-needle/requirements/?branch=master 30 | 31 | .. image:: https://readthedocs.org/projects/pytest-needle/badge/?version=latest 32 | :target: http://pytest-needle.readthedocs.io/en/latest/?badge=latest 33 | 34 | .. image:: https://api.codeclimate.com/v1/badges/a15071d58f78ebe3e6c0/maintainability 35 | :target: https://codeclimate.com/github/jlane9/pytest-needle/maintainability 36 | 37 | 38 | pytest-needle is a pytest implementation of `needle `_. 39 | 40 | It's fairly similar to needle and shares much of the same functionality, 41 | except it uses `pytest-selenium `_ for handling the webdriver 42 | and implements needle as a fixture instead of having test cases inherit from needle's base test class. 43 | 44 | 45 | .. toctree:: 46 | :maxdepth: 2 47 | 48 | Installation 49 | Getting Started 50 | Advanced Settings 51 | API Reference 52 | Development 53 | Miscellaneous 54 | 55 | 56 | Indices and tables 57 | ================== 58 | 59 | * :ref:`genindex` 60 | * :ref:`modindex` 61 | * :ref:`search` 62 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """setup.py 2 | 3 | .. codeauthor:: John Lane 4 | 5 | """ 6 | 7 | import os 8 | from setuptools import setup 9 | from pytest_needle import __author__, __email__, __license__, __version__ 10 | 11 | 12 | # Utility function to read the README file. 13 | # Used for the long_description. It's nice, because now 1) we have a top level 14 | # README file and 2) it's easier to type in the README file than to put a raw 15 | # string in below ... 16 | def read(filename): 17 | return open(os.path.join(os.path.dirname(__file__), filename)).read() 18 | 19 | 20 | setup(name='pytest-needle', 21 | version=__version__, 22 | author=__author__, 23 | author_email=__email__, 24 | description='pytest plugin for visual testing websites using selenium', 25 | license=__license__, 26 | keywords='py.test pytest needle imagemagick perceptualdiff pil selenium visual', 27 | url=u'https://github.com/jlane9/pytest-needle', 28 | project_urls={ 29 | "Documentation": "https://pytest-needle.readthedocs.io/en/latest/", 30 | "Tracker": "https://github.com/jlane9/pytest-needle/issues" 31 | }, 32 | packages=['pytest_needle'], 33 | entry_points={'pytest11': ['needle = pytest_needle.plugin', ]}, 34 | long_description=read("README.md"), 35 | long_description_content_type="text/markdown", 36 | install_requires=[ 37 | 'pytest>=3.7.0,<5.0.0', 38 | 'pytest-selenium>=1.16.0,<2.0.0', 39 | 'needle>=0.5.0,<0.6.0', 40 | 'selenium>=3.0.0' 41 | ], 42 | python_requires=">=2.7", 43 | tests_require=[ 44 | "pytest-cov>=2.7.0", 45 | "python-coveralls>=2.9.0", 46 | "pytest-pep8>=1.0.0" 47 | ], 48 | extras_require={ 49 | "release": [ 50 | "bumpversion>=0.5.0", 51 | "recommonmark>=0.5.0", 52 | "Sphinx>=1.8.0", 53 | "sphinx-autobuild>=0.7.0", 54 | "sphinx-rtd-theme>=0.4.0", 55 | ] 56 | }, 57 | classifiers=[ 58 | 'Development Status :: 4 - Beta', 59 | 'Framework :: Pytest', 60 | 'Intended Audience :: Developers', 61 | "Natural Language :: English", 62 | 'Operating System :: POSIX', 63 | 'Operating System :: Microsoft :: Windows', 64 | 'Operating System :: MacOS :: MacOS X', 65 | 'Programming Language :: Python :: 2.7', 66 | 'Programming Language :: Python :: 3.5', 67 | 'Programming Language :: Python :: 3.6', 68 | 'Programming Language :: Python :: 3.7', 69 | 'Programming Language :: Python :: 3.8', 70 | 'Topic :: Software Development :: Libraries', 71 | 'Topic :: Software Development :: Quality Assurance', 72 | 'Topic :: Software Development :: Testing', 73 | 'Topic :: Utilities' 74 | ]) 75 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### JetBrains template 3 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 4 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 5 | 6 | # User-specific stuff: 7 | .idea/ 8 | .idea/**/tasks.xml 9 | 10 | # Sensitive or high-churn files: 11 | .idea/**/dataSources/ 12 | .idea/**/dataSources.ids 13 | .idea/**/dataSources.xml 14 | .idea/**/dataSources.local.xml 15 | .idea/**/sqlDataSources.xml 16 | .idea/**/dynamic.xml 17 | .idea/**/uiDesigner.xml 18 | 19 | # Gradle: 20 | .idea/**/gradle.xml 21 | .idea/**/libraries 22 | 23 | # Mongo Explorer plugin: 24 | .idea/**/mongoSettings.xml 25 | 26 | ## File-based project format: 27 | *.iws 28 | 29 | ## Plugin-specific files: 30 | 31 | # IntelliJ 32 | /out/ 33 | 34 | # mpeltonen/sbt-idea plugin 35 | .idea_modules/ 36 | 37 | # JIRA plugin 38 | atlassian-ide-plugin.xml 39 | 40 | # Crashlytics plugin (for Android Studio and IntelliJ) 41 | com_crashlytics_export_strings.xml 42 | crashlytics.properties 43 | crashlytics-build.properties 44 | fabric.properties 45 | ### VirtualEnv template 46 | # Virtualenv 47 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 48 | .Python 49 | [Bb]in 50 | [Ii]nclude 51 | [Ll]ib 52 | [Ll]ib64 53 | [Ll]ocal 54 | [Ss]cripts 55 | pyvenv.cfg 56 | .venv 57 | pip-selfcheck.json 58 | ### Python template 59 | # Byte-compiled / optimized / DLL files 60 | __pycache__/ 61 | *.py[cod] 62 | *$py.class 63 | 64 | # C extensions 65 | *.so 66 | 67 | # Distribution / packaging 68 | env/ 69 | build/ 70 | develop-eggs/ 71 | dist/ 72 | downloads/ 73 | eggs/ 74 | .eggs/ 75 | lib64/ 76 | parts/ 77 | sdist/ 78 | var/ 79 | wheels/ 80 | *.egg-info/ 81 | .installed.cfg 82 | *.egg 83 | 84 | # PyInstaller 85 | # Usually these files are written by a python script from a template 86 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 87 | *.manifest 88 | *.spec 89 | 90 | # Installer logs 91 | pip-log.txt 92 | pip-delete-this-directory.txt 93 | 94 | # Unit test / coverage reports 95 | htmlcov/ 96 | .tox/ 97 | .coverage 98 | .coverage.* 99 | .cache 100 | nosetests.xml 101 | coverage.xml 102 | *,cover 103 | .hypothesis/ 104 | 105 | # Translations 106 | *.mo 107 | *.pot 108 | 109 | # Django stuff: 110 | *.log 111 | local_settings.py 112 | 113 | # Flask stuff: 114 | instance/ 115 | .webassets-cache 116 | 117 | # Scrapy stuff: 118 | .scrapy 119 | 120 | # Sphinx documentation 121 | docs/_build/ 122 | 123 | # PyBuilder 124 | target/ 125 | 126 | # Jupyter Notebook 127 | .ipynb_checkpoints 128 | 129 | # pyenv 130 | .python-version 131 | 132 | # celery beat schedule file 133 | celerybeat-schedule 134 | 135 | # SageMath parsed files 136 | *.sage.py 137 | 138 | # dotenv 139 | .env 140 | 141 | # virtualenv 142 | venv/ 143 | ENV/ 144 | 145 | # Spyder project settings 146 | .spyderproject 147 | 148 | # Rope project settings 149 | .ropeproject 150 | 151 | # Ignore screenshots folder 152 | screenshots/ 153 | 154 | .DS_Store -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | pytest-needle 3 | ============= 4 | 5 | ------- 6 | Example 7 | ------- 8 | 9 | Example needle pytest implementation: 10 | 11 | 12 | .. code-block:: python 13 | 14 | """test_example.py 15 | """ 16 | 17 | from selenium.webdriver.common.by import By 18 | import pytest 19 | 20 | @pytest.mark.element 21 | def test_example_element(needle): 22 | """Example for comparing individual elements 23 | 24 | :param NeedleDriver needle: NeedleDriver instance 25 | :return: 26 | """ 27 | 28 | # Navigate to web page 29 | needle.driver.get('https://www.google.com') 30 | 31 | # Take an element screen diff 32 | needle.assert_screenshot('search_field', (By.ID, 'tsf')) 33 | 34 | To create a baseline for all subsequent test run: 35 | 36 | .. code-block:: bash 37 | 38 | pytest --driver Chrome --needle-save-baseline test_example.py 39 | 40 | After we have a baseline, to run test use: 41 | 42 | .. code-block:: bash 43 | 44 | pytest --driver Chrome test_example.py 45 | 46 | --------------------- 47 | Selecting a WebDriver 48 | --------------------- 49 | 50 | To control which browser to use, use ``--driver `` from pytest-selenium. For example to change to browser to Firefox: 51 | 52 | .. code-block:: bash 53 | 54 | pytest --driver Firefox test_example.py 55 | 56 | --------------------------- 57 | Setting the viewport's size 58 | --------------------------- 59 | 60 | You may set the size of the browser's viewport using the ``set_viewport_size()`` on the needle fixture 61 | 62 | .. code-block:: python 63 | 64 | def test_example_viewport(needle): 65 | 66 | # Navigate to web page 67 | needle.set_viewport_size(width=1024, height=768) 68 | 69 | # Rest of the test ... 70 | 71 | You may also set the default viewport size for all your tests by using the command line argument ``--needle-viewport-size``: 72 | 73 | .. code-block:: bash 74 | 75 | pytest --driver Chrome --needle-viewport-size "1024 x 768" test_example.py 76 | 77 | --------------- 78 | Excluding areas 79 | --------------- 80 | 81 | Sometimes areas on a web page may contain dynamic content and cause false negatives, or worse convince testers to raise 82 | the threshold at which changes are acceptable. You can instead choose to mask these areas to avoid the issue of consistently 83 | failing tests: 84 | 85 | .. code-block:: python 86 | 87 | """test_example.py 88 | """ 89 | 90 | from selenium.webdriver.common.by import By 91 | import pytest 92 | 93 | 94 | @pytest.mark.mask 95 | def test_example_page_with_mask(needle): 96 | """Example for comparing page with a mask 97 | 98 | :param NeedleDriver needle: NeedleDriver instance 99 | :return: 100 | """ 101 | 102 | # Navigate to web page 103 | needle.driver.get('https://www.google.com') 104 | 105 | # Take a entire page screen diff, ignore the doodle banner 106 | needle.assert_screenshot('search_page', threshold=60, exclude=[(By.ID, 'hplogo'), (By.ID, 'prm')]) 107 | 108 | In the case with Google's home page the doodle banner frequently changes, so to visually regress day-to-day requires 109 | generating new baselines every time the banner is updated. Masking allows only the banner to be ignored while the rest 110 | of the page can be evaluated. -------------------------------------------------------------------------------- /pytest_needle/plugin.py: -------------------------------------------------------------------------------- 1 | """pytest_needle.plugin 2 | 3 | .. codeauthor:: John Lane 4 | 5 | """ 6 | 7 | from __future__ import absolute_import 8 | import base64 9 | import os 10 | import pytest 11 | from pytest_needle.driver import DEFAULT_BASELINE_DIR, DEFAULT_OUTPUT_DIR, DEFAULT_ENGINE, \ 12 | DEFAULT_VIEWPORT_SIZE, NeedleDriver 13 | from pytest_needle.exceptions import ImageMismatchException 14 | 15 | 16 | def pytest_addoption(parser): 17 | """ 18 | 19 | :param parser: 20 | :return: 21 | """ 22 | 23 | group = parser.getgroup('needle') 24 | group.addoption('--needle-cleanup-on-success', action='store_true', 25 | help='destroy all non-baseline screenshots') 26 | 27 | group.addoption('--needle-save-baseline', action='store_true', 28 | help='save baseline screenshots to disk') 29 | 30 | group.addoption('--needle-engine', action='store', dest='needle_engine', metavar='engine', 31 | default=DEFAULT_ENGINE, help='engine for compare screenshots') 32 | 33 | group.addoption('--needle-baseline-dir', action='store', dest='baseline_dir', 34 | metavar='dir', default=DEFAULT_BASELINE_DIR, 35 | help='where to store baseline images') 36 | 37 | group.addoption('--needle-output-dir', action='store', dest='output_dir', 38 | metavar='dir', default=DEFAULT_OUTPUT_DIR, 39 | help='where to store baseline images') 40 | 41 | group.addoption('--needle-viewport-size', action='store', dest='viewport_size', 42 | metavar='pixels', default=DEFAULT_VIEWPORT_SIZE, 43 | help='size of window width (px) x height (px)') 44 | 45 | 46 | @pytest.mark.hookwrapper 47 | def pytest_runtest_makereport(item, call): 48 | """Add image diff to report 49 | 50 | :param item: 51 | :param call: 52 | :return: 53 | """ 54 | 55 | outcome = yield 56 | report = outcome.get_result() 57 | report.extra = getattr(report, 'extra', []) 58 | 59 | # If the test passed, return 60 | if not (is_failure(report) and call.excinfo): 61 | return 62 | 63 | exception = call.excinfo.value 64 | 65 | # Only capture screenshots if they did not match 66 | if not isinstance(exception, ImageMismatchException): 67 | return 68 | 69 | pytest_html = item.config.pluginmanager.getplugin('html') 70 | 71 | # If pytest-html plugin is not available, return 72 | if pytest_html is None: 73 | return 74 | 75 | attachments = ( 76 | (exception.baseline_image, 'PDIFF: Expected'), 77 | (exception.output_image.replace('.png', '.diff.png'), 'PDIFF: Comparison'), 78 | (exception.output_image, 'PDIFF: Actual') 79 | ) 80 | 81 | for attachment in attachments: 82 | 83 | if os.path.exists(attachment[0]): 84 | 85 | report.extra.append(pytest_html.extras.image( 86 | get_image_as_base64(attachment[0]), 87 | attachment[1] 88 | )) 89 | 90 | 91 | def is_failure(report): 92 | """True, if test failed 93 | 94 | :param report: 95 | :return: 96 | """ 97 | 98 | xfail = hasattr(report, 'wasxfail') 99 | return (report.skipped and xfail) or (report.failed and not xfail) 100 | 101 | 102 | def get_image_as_base64(filename): 103 | """Open image from file as base64 encoded string 104 | 105 | :param str filename: File path 106 | :return: 107 | """ 108 | 109 | with open(filename, 'rb') as image: 110 | return base64.b64encode(image.read()).decode('ascii') 111 | 112 | 113 | @pytest.fixture() 114 | def needle(request, selenium): 115 | """Visual regression testing fixture 116 | 117 | :param request: pytest request 118 | :param selenium: Selenium web driver 119 | :return: 120 | """ 121 | 122 | options = { 123 | 'cleanup_on_success': request.config.getoption('needle_cleanup_on_success'), 124 | 'save_baseline': request.config.getoption('needle_save_baseline'), 125 | 'needle_engine': request.config.getoption('needle_engine'), 126 | 'baseline_dir': request.config.getoption('baseline_dir'), 127 | 'output_dir': request.config.getoption('output_dir'), 128 | 'viewport_size': request.config.getoption('viewport_size') 129 | } 130 | 131 | return NeedleDriver(selenium, **options) 132 | -------------------------------------------------------------------------------- /test/test_example.py: -------------------------------------------------------------------------------- 1 | """test_example 2 | 3 | .. codeauthor:: John Lane 4 | 5 | """ 6 | 7 | import os 8 | import pytest 9 | from selenium.webdriver.common.by import By 10 | 11 | 12 | @pytest.mark.page 13 | def test_example_page(needle): 14 | """Example for comparing entire pages 15 | 16 | :param NeedleDriver needle: NeedleDriver instance 17 | :return: 18 | """ 19 | 20 | # Navigate to web page 21 | needle.driver.get('https://www.example.com') 22 | 23 | # Take a entire page screen diff 24 | needle.assert_screenshot('static_page', threshold=80) 25 | 26 | 27 | @pytest.mark.mask 28 | def test_example_page_with_mask(needle): 29 | """Example for comparing page with a mask 30 | 31 | :param NeedleDriver needle: NeedleDriver instance 32 | :return: 33 | """ 34 | 35 | # Navigate to web page 36 | needle.driver.get('https://www.google.com') 37 | 38 | # Ensure the cursor does not appear in the screenshot 39 | footer = needle.driver.find_elements_by_xpath('//div[@class="fbar"]') 40 | 41 | if footer: 42 | footer[0].click() 43 | 44 | # Take a entire page screen diff, ignore the doodle banner 45 | needle.assert_screenshot('search_page', exclude=[ 46 | (By.ID, 'hplogo'), 47 | (By.ID, 'prm'), 48 | (By.XPATH, '//div[@jsmodel]/div/div[//input[@title="Search"] and @jsname and not(@jscontroller)]') 49 | ], threshold=80) 50 | 51 | 52 | @pytest.mark.element 53 | def test_example_element(needle): 54 | """Example for comparing individual elements 55 | 56 | :param NeedleDriver needle: NeedleDriver instance 57 | :return: 58 | """ 59 | 60 | # Navigate to web page 61 | needle.driver.get('https://www.google.com') 62 | 63 | # Ensure the cursor does not appear in the screenshot 64 | footer = needle.driver.find_elements_by_xpath('//div[@class="fbar"]') 65 | 66 | if footer: 67 | footer[0].click() 68 | 69 | # Take an element screen diff 70 | needle.assert_screenshot('search_field', (By.ID, 'tsf'), threshold=80) 71 | 72 | 73 | @pytest.mark.cleanup 74 | def test_cleanup_on_success(needle): 75 | """Verify that the --needle-cleanup-on-success removes the newly generated file 76 | 77 | :param NeedleDriver needle: NeedleDriver instance 78 | :return: 79 | """ 80 | 81 | screenshot_path = os.path.join(needle.output_dir, "cleanup_test.png") 82 | 83 | # Set cleanup on success to true 84 | needle.cleanup_on_success = True 85 | 86 | # Navigate to web page 87 | needle.driver.get('https://www.example.com') 88 | 89 | # Take a entire page screen diff 90 | needle.assert_screenshot('cleanup_test', threshold=80) 91 | 92 | assert not os.path.exists(screenshot_path) 93 | 94 | 95 | @pytest.mark.output_dir 96 | def test_output_dir(needle): 97 | """Verify that the --needle-output-dir saves the fresh image in the specified directory 98 | 99 | :param NeedleDriver needle: NeedleDriver instance 100 | :return: 101 | """ 102 | 103 | # Reassign output_dir 104 | needle.output_dir = os.path.join(needle.output_dir, 'extra') 105 | needle._create_dir(needle.output_dir) 106 | 107 | screenshot_path = os.path.join(needle.output_dir, "output_dir_test.png") 108 | 109 | # Navigate to web page 110 | needle.driver.get('https://www.example.com') 111 | 112 | # Take a entire page screen diff 113 | needle.assert_screenshot('output_dir_test', threshold=80) 114 | 115 | if not needle.save_baseline: 116 | assert os.path.exists(screenshot_path) 117 | 118 | 119 | @pytest.mark.baseline_dir 120 | def test_baseline_dir(needle): 121 | """Verify that the --needle-baseline-dir saves the fresh image in the specified directory 122 | 123 | :param NeedleDriver needle: NeedleDriver instance 124 | :return: 125 | """ 126 | 127 | # Reassign output_dir 128 | needle.baseline_dir = os.path.join(needle.baseline_dir, 'default') 129 | needle._create_dir(needle.baseline_dir) 130 | 131 | screenshot_path = os.path.join(needle.baseline_dir, "baseline_dir_test.png") 132 | 133 | # Navigate to web page 134 | needle.driver.get('https://www.example.com') 135 | 136 | # Take a entire page screen diff 137 | needle.assert_screenshot('baseline_dir_test', threshold=80) 138 | 139 | assert os.path.exists(screenshot_path) 140 | 141 | 142 | @pytest.mark.viewport 143 | def test_viewport_size(needle): 144 | """Verify that viewport size can be 145 | 146 | :param NeedleDriver needle: NeedleDriver instance 147 | :return: 148 | """ 149 | 150 | original_size = needle.driver.get_window_size() 151 | 152 | needle.viewport_size = "900x600" 153 | needle.set_viewport() 154 | 155 | assert needle.driver.get_window_size() != original_size 156 | 157 | 158 | @pytest.mark.engine 159 | @pytest.mark.parametrize('engine', ('pil', 'perceptualdiff', 'imagemagick')) 160 | def test_image_engine(needle, engine): 161 | """Verify all image engines can be set 162 | 163 | :param NeedleDriver needle: NeedleDriver instance 164 | :param engine: Image engine class 165 | :return: 166 | """ 167 | 168 | needle.engine_class = engine 169 | assert needle.engine_class == needle.ENGINES[engine] 170 | 171 | # Navigate to web page 172 | needle.driver.get('https://www.example.com') 173 | 174 | # Take a entire page screen diff 175 | needle.assert_screenshot('test_' + engine, threshold=80) 176 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # pytest-needle documentation build configuration file, created by 4 | # sphinx-quickstart on Wed Oct 25 11:30:00 2017. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | # 19 | # import os 20 | # import sys 21 | # sys.path.insert(0, os.path.abspath('.')) 22 | 23 | 24 | # -- General configuration ------------------------------------------------ 25 | 26 | # If your documentation needs a minimal Sphinx version, state it here. 27 | # 28 | # needs_sphinx = '1.0' 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = ['sphinx.ext.githubpages', 'sphinx.ext.autodoc'] 34 | 35 | # Add any paths that contain templates here, relative to this directory. 36 | templates_path = ['_templates'] 37 | 38 | # The suffix(es) of source filenames. 39 | # You can specify multiple suffix as a list of string: 40 | # 41 | # source_suffix = ['.rst', '.md'] 42 | source_suffix = '.rst' 43 | 44 | # The master toctree document. 45 | master_doc = 'index' 46 | 47 | # General information about the project. 48 | project = u'pytest-needle' 49 | copyright = u'2017, John Lane' 50 | author = u'John Lane' 51 | 52 | # The version info for the project you're documenting, acts as replacement for 53 | # |version| and |release|, also used in various other places throughout the 54 | # built documents. 55 | # 56 | # The short X.Y version. 57 | version = u'0.3.11' 58 | # The full version, including alpha/beta/rc tags. 59 | release = u'0.3.11' 60 | 61 | # The language for content autogenerated by Sphinx. Refer to documentation 62 | # for a list of supported languages. 63 | # 64 | # This is also used if you do content translation via gettext catalogs. 65 | # Usually you set "language" from the command line for these cases. 66 | language = None 67 | 68 | # List of patterns, relative to source directory, that match files and 69 | # directories to ignore when looking for source files. 70 | # This patterns also effect to html_static_path and html_extra_path 71 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 72 | 73 | # The name of the Pygments (syntax highlighting) style to use. 74 | pygments_style = 'sphinx' 75 | 76 | # If true, `todo` and `todoList` produce output, else they produce nothing. 77 | todo_include_todos = False 78 | 79 | 80 | # -- Options for HTML output ---------------------------------------------- 81 | 82 | # The theme to use for HTML and HTML Help pages. See the documentation for 83 | # a list of builtin themes. 84 | # 85 | html_theme = 'sphinx_rtd_theme' 86 | 87 | # Theme options are theme-specific and customize the look and feel of a theme 88 | # further. For a list of options available for each theme, see the 89 | # documentation. 90 | # 91 | # html_theme_options = {} 92 | 93 | # Add any paths that contain custom static files (such as style sheets) here, 94 | # relative to this directory. They are copied after the builtin static files, 95 | # so a file named "default.css" will overwrite the builtin "default.css". 96 | html_static_path = ['_static'] 97 | 98 | # Custom sidebar templates, must be a dictionary that maps document names 99 | # to template names. 100 | # 101 | # This is required for the alabaster theme 102 | # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars 103 | html_sidebars = { 104 | '**': [ 105 | 'relations.html', # needs 'show_related': True theme option to display 106 | 'searchbox.html', 107 | ] 108 | } 109 | 110 | 111 | # -- Options for HTMLHelp output ------------------------------------------ 112 | 113 | # Output file base name for HTML help builder. 114 | htmlhelp_basename = 'pytest-needledoc' 115 | 116 | 117 | # -- Options for LaTeX output --------------------------------------------- 118 | 119 | latex_elements = { 120 | # The paper size ('letterpaper' or 'a4paper'). 121 | # 122 | # 'papersize': 'letterpaper', 123 | 124 | # The font size ('10pt', '11pt' or '12pt'). 125 | # 126 | # 'pointsize': '10pt', 127 | 128 | # Additional stuff for the LaTeX preamble. 129 | # 130 | # 'preamble': '', 131 | 132 | # Latex figure (float) alignment 133 | # 134 | # 'figure_align': 'htbp', 135 | } 136 | 137 | # Grouping the document tree into LaTeX files. List of tuples 138 | # (source start file, target name, title, 139 | # author, documentclass [howto, manual, or own class]). 140 | latex_documents = [ 141 | (master_doc, 'pytest-needle.tex', u'pytest-needle Documentation', 142 | u'John Lane', 'manual'), 143 | ] 144 | 145 | 146 | # -- Options for manual page output --------------------------------------- 147 | 148 | # One entry per manual page. List of tuples 149 | # (source start file, name, description, authors, manual section). 150 | man_pages = [ 151 | (master_doc, 'pytest-needle', u'pytest-needle Documentation', 152 | [author], 1) 153 | ] 154 | 155 | 156 | # -- Options for Texinfo output ------------------------------------------- 157 | 158 | # Grouping the document tree into Texinfo files. List of tuples 159 | # (source start file, target name, title, author, 160 | # dir menu entry, description, category) 161 | texinfo_documents = [ 162 | (master_doc, 'pytest-needle', u'pytest-needle Documentation', 163 | author, 'pytest-needle', 'One line description of project.', 164 | 'Miscellaneous'), 165 | ] 166 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | pytest-needle 2 | ============= 3 | [![Build Status](https://travis-ci.org/jlane9/pytest-needle.svg?branch=master)](https://travis-ci.org/jlane9/pytest-needle) 4 | [![Coverage Status](https://coveralls.io/repos/github/jlane9/pytest-needle/badge.svg?branch=master)](https://coveralls.io/github/jlane9/pytest-needle?branch=master) 5 | [![PyPI version](https://badge.fury.io/py/pytest-needle.svg)](https://badge.fury.io/py/pytest-needle) 6 | [![Python version](https://img.shields.io/pypi/pyversions/pytest-needle.svg)](https://pypi.python.org/pypi/pytest-needle) 7 | [![License](https://img.shields.io/pypi/l/pytest-needle.svg)](https://pypi.python.org/pypi/pytest-needle) 8 | [![Status](https://img.shields.io/pypi/status/pytest-needle.svg)](https://pypi.python.org/pypi/pytest-needle) 9 | [![Requirements Status](https://requires.io/github/jlane9/pytest-needle/requirements.svg?branch=master)](https://requires.io/github/jlane9/pytest-needle/requirements/?branch=master) 10 | [![Documentation Status](https://readthedocs.org/projects/pytest-needle/badge/?version=latest)](http://pytest-needle.readthedocs.io/en/latest/?badge=latest) 11 | [![Maintainability](https://api.codeclimate.com/v1/badges/a15071d58f78ebe3e6c0/maintainability)](https://codeclimate.com/github/jlane9/pytest-needle/maintainability) 12 | 13 | pytest-needle is a pytest implementation of [needle](https://github.com/python-needle/needle). 14 | 15 | It's fairly similar to needle and shares much of the same functionality, 16 | except it uses [pytest-selenium](https://github.com/pytest-dev/pytest-selenium) for handling the webdriver 17 | and implements needle as a fixture instead of having test cases inherit from needle's base test class. 18 | 19 | 20 | Installation 21 | ------------ 22 | 23 | Install through pip: 24 | 25 | ```bash 26 | pip install pytest-needle 27 | ``` 28 | 29 | 30 | Install from source: 31 | 32 | ```bash 33 | 34 | cd /path/to/source/pytest-needle 35 | python setup.py install 36 | ``` 37 | 38 | Example 39 | ------- 40 | 41 | Example needle pytest implementation: 42 | 43 | ```python 44 | """test_example.py 45 | """ 46 | 47 | from selenium.webdriver.common.by import By 48 | import pytest 49 | 50 | @pytest.mark.element 51 | def test_example_element(needle): 52 | """Example for comparing individual elements 53 | 54 | :param NeedleDriver needle: NeedleDriver instance 55 | :return: 56 | """ 57 | 58 | # Navigate to web page 59 | needle.driver.get('https://www.google.com') 60 | 61 | # Take an element screen diff 62 | needle.assert_screenshot('search_field', (By.ID, 'tsf')) 63 | 64 | ``` 65 | 66 | To create a baseline for all subsequent test run: 67 | 68 | ```bash 69 | pytest --driver Chrome --needle-save-baseline test_example.py 70 | ``` 71 | 72 | After we have a baseline, to run test use: 73 | 74 | ```bash 75 | pytest --driver Chrome test_example.py 76 | ``` 77 | 78 | Selecting a WebDriver 79 | --------------------- 80 | 81 | To control which browser to use, use `--driver ` from pytest-selenium. For example to change to browser to Firefox: 82 | 83 | ```bash 84 | pytest --driver Firefox test_example.py 85 | ``` 86 | 87 | Setting the viewport's size 88 | --------------------------- 89 | 90 | You may set the size of the browser's viewport using the `set_viewport_size()` on the needle fixture 91 | 92 | ```python 93 | 94 | def test_example_viewport(needle): 95 | 96 | # Navigate to web page 97 | needle.set_viewport_size(width=1024, height=768) 98 | 99 | # Rest of the test ... 100 | 101 | ``` 102 | 103 | You may also set the default viewport size for all your tests by using the command line argument `--needle-viewport-size`: 104 | 105 | ```bash 106 | pytest --driver Chrome --needle-viewport-size "1024 x 768" test_example.py 107 | ``` 108 | 109 | You can also maximize viewport size for all your tests by using the command line argument `--needle-viewport-size fullscreen`: 110 | 111 | ```bash 112 | pytest --driver Chrome --needle-viewport-size fullscreen test_example.py 113 | ``` 114 | 115 | Excluding areas 116 | --------------- 117 | 118 | Sometimes areas on a web page may contain dynamic content and cause false negatives, or worse convince testers to raise 119 | the threshold at which changes are acceptable. You can instead choose to mask these areas to avoid the issue of consistently 120 | failing tests: 121 | 122 | ```python 123 | """test_example.py 124 | """ 125 | 126 | from selenium.webdriver.common.by import By 127 | import pytest 128 | 129 | 130 | @pytest.mark.mask 131 | def test_example_page_with_mask(needle): 132 | """Example for comparing page with a mask 133 | 134 | :param NeedleDriver needle: NeedleDriver instance 135 | :return: 136 | """ 137 | 138 | # Navigate to web page 139 | needle.driver.get('https://www.google.com') 140 | 141 | # Take a entire page screen diff, ignore the doodle banner 142 | needle.assert_screenshot('search_page', threshold=60, exclude=[(By.ID, 'hplogo'), (By.ID, 'prm')]) 143 | ``` 144 | 145 | In the case with Google's home page the doodle banner frequently changes, so to visually regress day-to-day requires 146 | generating new baselines every time the banner is updated. Masking allows only the banner to be ignored while the rest 147 | of the page can be evaluated. 148 | 149 | 150 | Engines 151 | ------- 152 | 153 | By default Needle uses the PIL engine (`needle.engines.pil_engine.Engine`) to take screenshots. Instead of PIL, you may also use PerceptualDiff or ImageMagick. 154 | 155 | 156 | Example with PerceptualDiff: 157 | 158 | ```bash 159 | pytest --driver Chrome --needle-engine perceptualdiff test_example.py 160 | ``` 161 | 162 | Example with ImageMagick: 163 | 164 | ```bash 165 | pytest --driver Chrome --needle-engine imagemagick test_example.py 166 | ``` 167 | 168 | Besides being much faster than PIL, PerceptualDiff and ImageMagick also generate a diff PNG file when a test fails, highlighting the differences between the baseline image and the new screenshot. 169 | 170 | Note that to use the PerceptualDiff engine you will first need to [download](http://pdiff.sourceforge.net/) the perceptualdiff binary and place it in your PATH. 171 | 172 | To use the ImageMagick engine you will need to install a package on your machine (e.g. sudo apt-get install imagemagick on Ubuntu or brew install imagemagick on OSX). 173 | 174 | 175 | File cleanup 176 | ------------ 177 | 178 | Each time you run tests, Needle will create new screenshot images on disk, for comparison with the baseline screenshots. 179 | It’s then up to you whether you want to delete them or archive them. To remove screenshots from successful test use: 180 | 181 | ```bash 182 | pytest --driver Chrome --needle-cleanup-on-success test_example.py 183 | ``` 184 | 185 | Any unsuccessful tests will remain on the file system. 186 | 187 | 188 | File output 189 | ----------- 190 | 191 | To specify a path for baseline image path use: 192 | 193 | ```bash 194 | pytest --driver Chrome --needle-baseline-dir /path/to/baseline/images 195 | ``` 196 | 197 | Default path is ./screenshots/baseline 198 | 199 | To specify a path for output image path use: 200 | 201 | ```bash 202 | pytest --driver Chrome --needle-output-dir /path/to/output/images 203 | ``` 204 | 205 | Default path is ./screenshots 206 | 207 | 208 | Generating HTML reports 209 | ----------------------- 210 | 211 | To generate html reports use: 212 | 213 | ```bash 214 | pytest --driver Chrome --html=report.html --self-contained-html 215 | ``` 216 | 217 | Special Thanks 218 | -------------- 219 | 220 | [![browserstack_logo](http://svgshare.com/i/3ZQ.svg)](https://www.browserstack.com) 221 | 222 | > Special thanks to BrowserStack for providing automated browser testing, at no charge, for this project and other open source projects like this. With over 1000+ device, browser and os versions combinations to choose from and integrations with Travis CI this project could not be successful without the hard work of the BrowserStack team and their continued support of the open source community. 223 | -------------------------------------------------------------------------------- /pytest_needle/driver.py: -------------------------------------------------------------------------------- 1 | """pytest_needle.driver 2 | 3 | .. codeauthor:: John Lane 4 | 5 | """ 6 | 7 | import base64 8 | from errno import EEXIST 9 | import math 10 | import os 11 | import re 12 | import sys 13 | import pytest 14 | from needle.cases import import_from_string 15 | from needle.engines.pil_engine import ImageDiff 16 | from PIL import Image, ImageDraw, ImageColor 17 | from selenium.webdriver.remote.webdriver import WebElement 18 | from pytest_needle.exceptions import ImageMismatchException, MissingBaselineException, MissingEngineException 19 | 20 | 21 | if sys.version_info >= (3, 0): 22 | 23 | from io import BytesIO as IOClass 24 | 25 | # Ignoring since basetring is not redefined if running on python3 26 | basestring = str # pylint: disable=W0622,C0103 27 | 28 | else: 29 | try: 30 | from cStringIO import StringIO as IOClass 31 | except ImportError: 32 | from StringIO import StringIO as IOClass 33 | 34 | 35 | DEFAULT_BASELINE_DIR = os.path.realpath(os.path.join(os.getcwd(), 'screenshots', 'baseline')) 36 | DEFAULT_OUTPUT_DIR = os.path.realpath(os.path.join(os.getcwd(), 'screenshots')) 37 | DEFAULT_ENGINE = 'needle.engines.pil_engine.Engine' 38 | DEFAULT_VIEWPORT_SIZE = '1024x768' 39 | 40 | 41 | class NeedleDriver(object): # pylint: disable=R0205 42 | """NeedleDriver instance 43 | """ 44 | 45 | ENGINES = { 46 | 'pil': DEFAULT_ENGINE, 47 | 'imagemagick': 'needle.engines.imagemagick_engine.Engine', 48 | 'perceptualdiff': 'needle.engines.perceptualdiff_engine.Engine' 49 | } 50 | 51 | def __init__(self, driver, **kwargs): 52 | 53 | self.options = kwargs 54 | self.driver = driver 55 | 56 | # Set viewport position, size 57 | self.driver.set_window_position(0, 0) 58 | self.set_viewport() 59 | 60 | @staticmethod 61 | def _create_dir(directory): 62 | """Recursively create a directory 63 | 64 | .. note:: From needle 65 | https://github.com/python-needle/needle/blob/master/needle/cases.py#L125 66 | 67 | :param str directory: Directory path to create 68 | :return: 69 | """ 70 | 71 | try: 72 | 73 | os.makedirs(directory) 74 | 75 | except OSError as err: 76 | 77 | if err.errno == EEXIST and os.path.isdir(directory): 78 | return 79 | 80 | raise err 81 | 82 | def _find_element(self, element_or_selector=None): 83 | """Returns an element 84 | 85 | :param element_or_selector: WebElement or tuple containing selector ex. ('id', 'mainPage') 86 | :return: 87 | """ 88 | 89 | if isinstance(element_or_selector, tuple): # pylint: disable=R1705 90 | 91 | elements = self.driver.find_elements(*element_or_selector) 92 | return elements[0] if elements else None 93 | 94 | elif isinstance(element_or_selector, WebElement): 95 | return element_or_selector 96 | 97 | raise ValueError("element_or_selector must be a WebElement or tuple selector") 98 | 99 | @staticmethod 100 | def _get_element_dimensions(element): 101 | """Returns an element's position and size 102 | 103 | :param WebElement element: Element to get dimensions for 104 | :return: 105 | """ 106 | 107 | if isinstance(element, WebElement): 108 | 109 | # Get dimensions of element 110 | location = element.location 111 | size = element.size 112 | 113 | return { 114 | 'top': int(location['y']), 115 | 'left': int(location['x']), 116 | 'width': int(size['width']), 117 | 'height': int(size['height']) 118 | } 119 | 120 | raise ValueError("element must be a WebElement") 121 | 122 | def _get_element_rect(self, element): 123 | """Returns the two points that define the rectangle 124 | 125 | :param WebElement element: Element to get points for 126 | :return: 127 | """ 128 | 129 | dimensions = self._get_element_dimensions(element) 130 | 131 | if dimensions: 132 | 133 | return ( 134 | dimensions['left'], 135 | dimensions['top'], 136 | (dimensions['left'] + dimensions['width']), 137 | (dimensions['top'] + dimensions['height']) 138 | ) 139 | 140 | return () 141 | 142 | @staticmethod 143 | def _get_ratio(image_size, window_size): 144 | 145 | return max(( 146 | math.ceil(image_size[0] / float(window_size[0])), 147 | math.ceil(image_size[1] / float(window_size[1])) 148 | )) 149 | 150 | def _get_window_size(self): 151 | 152 | window_size = self.driver.get_window_size() 153 | return window_size['width'], window_size['height'] 154 | 155 | @property 156 | def baseline_dir(self): 157 | """Return baseline image path 158 | 159 | :return: 160 | :rtype: str 161 | """ 162 | 163 | return self.options.get('baseline_dir', DEFAULT_BASELINE_DIR) 164 | 165 | @baseline_dir.setter 166 | def baseline_dir(self, value): 167 | """Set baseline image directory 168 | 169 | :param str value: File path 170 | :return: 171 | """ 172 | 173 | assert isinstance(value, basestring) 174 | self.options['baseline_dir'] = value 175 | 176 | @property 177 | def cleanup_on_success(self): 178 | """Returns True, if cleanup on success flag is set 179 | 180 | :return: 181 | :rtype: bool 182 | """ 183 | 184 | return self.options.get('cleanup_on_success', False) 185 | 186 | @cleanup_on_success.setter 187 | def cleanup_on_success(self, value): 188 | """Set cleanup on success flag 189 | 190 | :param bool value: Cleanup on success flag 191 | :return: 192 | """ 193 | 194 | self.options['cleanup_on_success'] = bool(value) 195 | 196 | @property 197 | def engine(self): 198 | """Return image processing engine 199 | 200 | :return: 201 | """ 202 | 203 | return import_from_string(self.engine_class)() 204 | 205 | @property 206 | def engine_class(self): 207 | """Return image processing engine name 208 | 209 | :return: 210 | :rtype: str 211 | """ 212 | 213 | return self.ENGINES.get(self.options.get('needle_engine', 'pil').lower(), DEFAULT_ENGINE) 214 | 215 | @engine_class.setter 216 | def engine_class(self, value): 217 | """Set image processing engine name 218 | 219 | :param str value: Image processing engine name (pil, imagemagick, perceptualdiff) 220 | :return: 221 | """ 222 | 223 | assert value.lower() in self.ENGINES 224 | self.options['needle_engine'] = value.lower() 225 | 226 | def get_screenshot(self, element=None): 227 | """Returns screenshot image 228 | 229 | :param WebElement element: Crop image to element (Optional) 230 | :return: 231 | """ 232 | 233 | stream = IOClass(base64.b64decode(self.driver.get_screenshot_as_base64().encode('ascii'))) 234 | image = Image.open(stream).convert('RGB') 235 | 236 | if isinstance(element, WebElement): 237 | 238 | window_size = self._get_window_size() 239 | 240 | image_size = image.size 241 | 242 | # Get dimensions of element 243 | dimensions = self._get_element_dimensions(element) 244 | 245 | if not image_size == (dimensions['width'], dimensions['height']): 246 | 247 | ratio = self._get_ratio(image_size, window_size) 248 | 249 | return image.crop([point * ratio for point in self._get_element_rect(element)]) 250 | 251 | return image 252 | 253 | def get_screenshot_as_image(self, element=None, exclude=None): 254 | """ 255 | 256 | :param WebElement element: Crop image to element (Optional) 257 | :param list exclude: Elements to exclude 258 | :return: 259 | """ 260 | 261 | image = self.get_screenshot(element) 262 | 263 | # Mask elements in exclude if element is not included 264 | if isinstance(exclude, (list, tuple)) and exclude and not element: 265 | 266 | # Gather all elements to exclude 267 | elements = [self._find_element(element) for element in exclude] 268 | elements = [element for element in elements if element] 269 | 270 | canvas = ImageDraw.Draw(image) 271 | 272 | window_size = self._get_window_size() 273 | 274 | image_size = image.size 275 | 276 | ratio = self._get_ratio(image_size, window_size) 277 | 278 | for ele in elements: 279 | canvas.rectangle([point * ratio for point in self._get_element_rect(ele)], 280 | fill=ImageColor.getrgb('black')) 281 | 282 | del canvas 283 | 284 | return image 285 | 286 | def assert_screenshot(self, file_path, element_or_selector=None, threshold=0, exclude=None): 287 | """Fail if new fresh image is too dissimilar from the baseline image 288 | 289 | .. note:: From needle 290 | https://github.com/python-needle/needle/blob/master/needle/cases.py#L161 291 | 292 | :param str file_path: File name for baseline image 293 | :param element_or_selector: WebElement or tuple containing selector ex. ('id', 'mainPage') 294 | :param threshold: Distance threshold 295 | :param list exclude: Elements or element selectors for areas to exclude 296 | :return: 297 | """ 298 | 299 | element = self._find_element(element_or_selector) if element_or_selector else None 300 | 301 | # Get baseline screenshot 302 | self._create_dir(self.baseline_dir) 303 | baseline_image = os.path.join(self.baseline_dir, '%s.png' % file_path) \ 304 | if isinstance(file_path, basestring) else Image.open(file_path).convert('RGB') 305 | 306 | # Take screenshot and exit if in baseline saving mode 307 | if self.save_baseline: 308 | self.get_screenshot_as_image(element, exclude=exclude).save(baseline_image) 309 | return 310 | 311 | # Get fresh screenshot 312 | self._create_dir(self.output_dir) 313 | fresh_image = self.get_screenshot_as_image(element, exclude=exclude) 314 | fresh_image_file = os.path.join(self.output_dir, '%s.png' % file_path) 315 | fresh_image.save(fresh_image_file) 316 | 317 | # Error if there is not a baseline image to compare 318 | if not self.save_baseline and not isinstance(file_path, basestring) and not os.path.exists(baseline_image): 319 | raise IOError('The baseline screenshot %s does not exist. You might want to ' 320 | 're-run this test in baseline-saving mode.' % baseline_image) 321 | 322 | # Compare images 323 | if isinstance(baseline_image, basestring): 324 | try: 325 | self.engine.assertSameFiles(fresh_image_file, baseline_image, threshold) 326 | 327 | except AssertionError as err: 328 | msg = getattr(err, 'message', err.args[0] if err.args else "") 329 | args = err.args[1:] if len(err.args) > 1 else [] 330 | raise ImageMismatchException(msg, baseline_image, fresh_image_file, args) 331 | 332 | except EnvironmentError: 333 | msg = "Missing baseline '{}'. Please run again with --needle-save-baseline".format(baseline_image) 334 | raise MissingBaselineException(msg) 335 | 336 | except ValueError as err: 337 | 338 | if self.options['needle_engine'] == 'imagemagick': 339 | msg = "It appears {0} is not installed. Please verify {0} is installed or choose a different engine" 340 | raise MissingEngineException(msg.format(self.options['needle_engine'])) 341 | 342 | raise err 343 | 344 | finally: 345 | if self.cleanup_on_success: 346 | os.remove(fresh_image_file) 347 | 348 | else: 349 | 350 | diff = ImageDiff(fresh_image, baseline_image) 351 | distance = abs(diff.get_distance()) 352 | 353 | if distance > threshold: 354 | pytest.fail('Fail: New screenshot did not match the baseline (by a distance of %.2f)' % distance) 355 | 356 | @property 357 | def output_dir(self): 358 | """Return output image path 359 | 360 | :return: 361 | :rtype: str 362 | """ 363 | 364 | return self.options.get('output_dir', DEFAULT_OUTPUT_DIR) 365 | 366 | @output_dir.setter 367 | def output_dir(self, value): 368 | """Set output image directory 369 | 370 | :param str value: File path 371 | :return: 372 | """ 373 | 374 | assert isinstance(value, basestring) 375 | self.options['output_dir'] = value 376 | 377 | @property 378 | def save_baseline(self): 379 | """Returns True, if save baseline flag is set 380 | 381 | :return: 382 | :rtype: bool 383 | """ 384 | 385 | return self.options.get('save_baseline', False) 386 | 387 | @save_baseline.setter 388 | def save_baseline(self, value): 389 | """Set save baseline flag 390 | 391 | :param bool value: Save baseline flag 392 | :return: 393 | """ 394 | 395 | self.options['save_baseline'] = bool(value) 396 | 397 | def set_viewport(self): 398 | """Set viewport width, height based off viewport size 399 | 400 | :return: 401 | """ 402 | 403 | if self.viewport_size.lower() == 'fullscreen': 404 | self.driver.maximize_window() 405 | return 406 | 407 | viewport_size = re.match(r'(?P\d+)\s?[xX]\s?(?P\d+)', self.viewport_size) 408 | 409 | viewport_dimensions = (viewport_size.group('width'), viewport_size.group('height')) if viewport_size \ 410 | else DEFAULT_VIEWPORT_SIZE.split('x') 411 | 412 | self.driver.set_window_size(*[int(dimension) for dimension in viewport_dimensions]) 413 | 414 | @property 415 | def viewport_size(self): 416 | """Return setting for browser window size 417 | 418 | :return: 419 | :rtype: str 420 | """ 421 | 422 | return self.options.get('viewport_size', DEFAULT_VIEWPORT_SIZE) 423 | 424 | @viewport_size.setter 425 | def viewport_size(self, value): 426 | """Set setting for browser window size 427 | 428 | :param value: Browser window size, as string or (x,y) 429 | :return: 430 | """ 431 | 432 | assert isinstance(value, (basestring, list, tuple)) 433 | assert len(value) == 2 and all([isinstance(i, int) for i in value]) \ 434 | if isinstance(value, (list, tuple)) else True 435 | self.options['viewport_size'] = value if isinstance(value, basestring) else '{}x{}'.format(*value) 436 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # A comma-separated list of package or module names from where C extensions may 4 | # be loaded. Extensions are loading into the active Python interpreter and may 5 | # run arbitrary code 6 | extension-pkg-whitelist= 7 | 8 | # Add files or directories to the blacklist. They should be base names, not 9 | # paths. 10 | ignore=CVS 11 | 12 | # Add files or directories matching the regex patterns to the blacklist. The 13 | # regex matches against base names, not paths. 14 | ignore-patterns= 15 | 16 | # Python code to execute, usually for sys.path manipulation such as 17 | # pygtk.require(). 18 | #init-hook= 19 | 20 | # Use multiple processes to speed up Pylint. 21 | jobs=1 22 | 23 | # List of plugins (as comma separated values of python modules names) to load, 24 | # usually to register additional checkers. 25 | load-plugins= 26 | 27 | # Pickle collected data for later comparisons. 28 | persistent=yes 29 | 30 | # Specify a configuration file. 31 | #rcfile= 32 | 33 | # Allow loading of arbitrary C extensions. Extensions are imported into the 34 | # active Python interpreter and may run arbitrary code. 35 | unsafe-load-any-extension=no 36 | 37 | 38 | [MESSAGES CONTROL] 39 | 40 | # Only show warnings with the listed confidence levels. Leave empty to show 41 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 42 | confidence= 43 | 44 | # Disable the message, report, category or checker with the given id(s). You 45 | # can either give multiple identifiers separated by comma (,) or put this 46 | # option multiple times (only on the command line, not in the configuration 47 | # file where it should appear only once).You can also use "--disable=all" to 48 | # disable everything first and then reenable specific checks. For example, if 49 | # you want to run only the similarities checker, you can use "--disable=all 50 | # --enable=similarities". If you want to run only the classes checker, but have 51 | # no Warning level messages displayed, use"--disable=all --enable=classes 52 | # --disable=W" 53 | disable=print-statement,parameter-unpacking,unpacking-in-except,old-raise-syntax,backtick,long-suffix,old-ne-operator,old-octal-literal,import-star-module-level,raw-checker-failed,bad-inline-option,locally-disabled,locally-enabled,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,apply-builtin,basestring-builtin,buffer-builtin,cmp-builtin,coerce-builtin,execfile-builtin,file-builtin,long-builtin,raw_input-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,no-absolute-import,old-division,dict-iter-method,dict-view-method,next-method-called,metaclass-assignment,indexing-exception,raising-string,reload-builtin,oct-method,hex-method,nonzero-method,cmp-method,input-builtin,round-builtin,intern-builtin,unichr-builtin,map-builtin-not-iterating,zip-builtin-not-iterating,range-builtin-not-iterating,filter-builtin-not-iterating,using-cmp-argument,eq-without-hash,div-method,idiv-method,rdiv-method,exception-message-attribute,invalid-str-codec,sys-max-int,bad-python3-import,deprecated-string-function,deprecated-str-translate-call 54 | 55 | # Enable the message, report, category or checker with the given id(s). You can 56 | # either give multiple identifier separated by comma (,) or put this option 57 | # multiple time (only on the command line, not in the configuration file where 58 | # it should appear only once). See also the "--disable" option for examples. 59 | enable= 60 | 61 | 62 | [REPORTS] 63 | 64 | # Python expression which should return a note less than 10 (10 is the highest 65 | # note). You have access to the variables errors warning, statement which 66 | # respectively contain the number of errors / warnings messages and the total 67 | # number of statements analyzed. This is used by the global evaluation report 68 | # (RP0004). 69 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 70 | 71 | # Template used to display messages. This is a python new-style format string 72 | # used to format the message information. See doc for all details 73 | #msg-template= 74 | 75 | # Set the output format. Available formats are text, parseable, colorized, json 76 | # and msvs (visual studio).You can also give a reporter class, eg 77 | # mypackage.mymodule.MyReporterClass. 78 | output-format=text 79 | 80 | # Tells whether to display a full report or only the messages 81 | reports=no 82 | 83 | # Activate the evaluation score. 84 | score=yes 85 | 86 | 87 | [REFACTORING] 88 | 89 | # Maximum number of nested blocks for function / method body 90 | max-nested-blocks=5 91 | 92 | 93 | [BASIC] 94 | 95 | # Naming hint for argument names 96 | argument-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 97 | 98 | # Regular expression matching correct argument names 99 | argument-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 100 | 101 | # Naming hint for attribute names 102 | attr-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 103 | 104 | # Regular expression matching correct attribute names 105 | attr-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 106 | 107 | # Bad variable names which should always be refused, separated by a comma 108 | bad-names=foo,bar,baz,toto,tutu,tata 109 | 110 | # Naming hint for class attribute names 111 | class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 112 | 113 | # Regular expression matching correct class attribute names 114 | class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 115 | 116 | # Naming hint for class names 117 | class-name-hint=[A-Z_][a-zA-Z0-9]+$ 118 | 119 | # Regular expression matching correct class names 120 | class-rgx=[A-Z_][a-zA-Z0-9]+$ 121 | 122 | # Naming hint for constant names 123 | const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 124 | 125 | # Regular expression matching correct constant names 126 | const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 127 | 128 | # Minimum line length for functions/classes that require docstrings, shorter 129 | # ones are exempt. 130 | docstring-min-length=-1 131 | 132 | # Naming hint for function names 133 | function-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 134 | 135 | # Regular expression matching correct function names 136 | function-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 137 | 138 | # Good variable names which should always be accepted, separated by a comma 139 | good-names=i,j,k,ex,Run,_ 140 | 141 | # Include a hint for the correct naming format with invalid-name 142 | include-naming-hint=no 143 | 144 | # Naming hint for inline iteration names 145 | inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ 146 | 147 | # Regular expression matching correct inline iteration names 148 | inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ 149 | 150 | # Naming hint for method names 151 | method-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 152 | 153 | # Regular expression matching correct method names 154 | method-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 155 | 156 | # Naming hint for module names 157 | module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 158 | 159 | # Regular expression matching correct module names 160 | module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 161 | 162 | # Colon-delimited sets of names that determine each other's naming style when 163 | # the name regexes allow several styles. 164 | name-group= 165 | 166 | # Regular expression which should only match function or class names that do 167 | # not require a docstring. 168 | no-docstring-rgx=^_ 169 | 170 | # List of decorators that produce properties, such as abc.abstractproperty. Add 171 | # to this list to register other decorators that produce valid properties. 172 | property-classes=abc.abstractproperty 173 | 174 | # Naming hint for variable names 175 | variable-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 176 | 177 | # Regular expression matching correct variable names 178 | variable-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 179 | 180 | 181 | [FORMAT] 182 | 183 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 184 | expected-line-ending-format= 185 | 186 | # Regexp for a line that is allowed to be longer than the limit. 187 | ignore-long-lines=^\s*(# )??$ 188 | 189 | # Number of spaces of indent required inside a hanging or continued line. 190 | indent-after-paren=4 191 | 192 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 193 | # tab). 194 | indent-string=' ' 195 | 196 | # Maximum number of characters on a single line. 197 | max-line-length=120 198 | 199 | # Maximum number of lines in a module 200 | max-module-lines=1000 201 | 202 | # List of optional constructs for which whitespace checking is disabled. `dict- 203 | # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. 204 | # `trailing-comma` allows a space between comma and closing bracket: (a, ). 205 | # `empty-line` allows space-only lines. 206 | no-space-check=trailing-comma,dict-separator 207 | 208 | # Allow the body of a class to be on the same line as the declaration if body 209 | # contains single statement. 210 | single-line-class-stmt=no 211 | 212 | # Allow the body of an if to be on the same line as the test if there is no 213 | # else. 214 | single-line-if-stmt=no 215 | 216 | 217 | [LOGGING] 218 | 219 | # Logging modules to check that the string format arguments are in logging 220 | # function parameter format 221 | logging-modules=logging 222 | 223 | 224 | [MISCELLANEOUS] 225 | 226 | # List of note tags to take in consideration, separated by a comma. 227 | notes=FIXME,XXX,TODO 228 | 229 | 230 | [SIMILARITIES] 231 | 232 | # Ignore comments when computing similarities. 233 | ignore-comments=yes 234 | 235 | # Ignore docstrings when computing similarities. 236 | ignore-docstrings=yes 237 | 238 | # Ignore imports when computing similarities. 239 | ignore-imports=no 240 | 241 | # Minimum lines number of a similarity. 242 | min-similarity-lines=4 243 | 244 | 245 | [SPELLING] 246 | 247 | # Spelling dictionary name. Available dictionaries: none. To make it working 248 | # install python-enchant package. 249 | spelling-dict= 250 | 251 | # List of comma separated words that should not be checked. 252 | spelling-ignore-words= 253 | 254 | # A path to a file that contains private dictionary; one word per line. 255 | spelling-private-dict-file= 256 | 257 | # Tells whether to store unknown words to indicated private dictionary in 258 | # --spelling-private-dict-file option instead of raising a message. 259 | spelling-store-unknown-words=no 260 | 261 | 262 | [TYPECHECK] 263 | 264 | # List of decorators that produce context managers, such as 265 | # contextlib.contextmanager. Add to this list to register other decorators that 266 | # produce valid context managers. 267 | contextmanager-decorators=contextlib.contextmanager 268 | 269 | # List of members which are set dynamically and missed by pylint inference 270 | # system, and so shouldn't trigger E1101 when accessed. Python regular 271 | # expressions are accepted. 272 | generated-members= 273 | 274 | # Tells whether missing members accessed in mixin class should be ignored. A 275 | # mixin class is detected if its name ends with "mixin" (case insensitive). 276 | ignore-mixin-members=yes 277 | 278 | # This flag controls whether pylint should warn about no-member and similar 279 | # checks whenever an opaque object is returned when inferring. The inference 280 | # can return multiple potential results while evaluating a Python object, but 281 | # some branches might not be evaluated, which results in partial inference. In 282 | # that case, it might be useful to still emit no-member and other checks for 283 | # the rest of the inferred objects. 284 | ignore-on-opaque-inference=yes 285 | 286 | # List of class names for which member attributes should not be checked (useful 287 | # for classes with dynamically set attributes). This supports the use of 288 | # qualified names. 289 | ignored-classes=optparse.Values,thread._local,_thread._local 290 | 291 | # List of module names for which member attributes should not be checked 292 | # (useful for modules/projects where namespaces are manipulated during runtime 293 | # and thus existing member attributes cannot be deduced by static analysis. It 294 | # supports qualified module names, as well as Unix pattern matching. 295 | ignored-modules= 296 | 297 | # Show a hint with possible names when a member name was not found. The aspect 298 | # of finding the hint is based on edit distance. 299 | missing-member-hint=yes 300 | 301 | # The minimum edit distance a name should have in order to be considered a 302 | # similar match for a missing member name. 303 | missing-member-hint-distance=1 304 | 305 | # The total number of similar names that should be taken in consideration when 306 | # showing a hint for a missing member. 307 | missing-member-max-choices=1 308 | 309 | 310 | [VARIABLES] 311 | 312 | # List of additional names supposed to be defined in builtins. Remember that 313 | # you should avoid to define new builtins when possible. 314 | additional-builtins= 315 | 316 | # Tells whether unused global variables should be treated as a violation. 317 | allow-global-unused-variables=yes 318 | 319 | # List of strings which can identify a callback function by name. A callback 320 | # name must start or end with one of those strings. 321 | callbacks=cb_,_cb 322 | 323 | # A regular expression matching the name of dummy variables (i.e. expectedly 324 | # not used). 325 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 326 | 327 | # Argument names that match this expression will be ignored. Default to name 328 | # with leading underscore 329 | ignored-argument-names=_.*|^ignored_|^unused_ 330 | 331 | # Tells whether we should check for unused import in __init__ files. 332 | init-import=no 333 | 334 | # List of qualified module names which can have objects that can redefine 335 | # builtins. 336 | redefining-builtins-modules=six.moves,future.builtins 337 | 338 | 339 | [CLASSES] 340 | 341 | # List of method names used to declare (i.e. assign) instance attributes. 342 | defining-attr-methods=__init__,__new__,setUp 343 | 344 | # List of member names, which should be excluded from the protected access 345 | # warning. 346 | exclude-protected=_asdict,_fields,_replace,_source,_make 347 | 348 | # List of valid names for the first argument in a class method. 349 | valid-classmethod-first-arg=cls 350 | 351 | # List of valid names for the first argument in a metaclass class method. 352 | valid-metaclass-classmethod-first-arg=mcs 353 | 354 | 355 | [DESIGN] 356 | 357 | # Maximum number of arguments for function / method 358 | max-args=5 359 | 360 | # Maximum number of attributes for a class (see R0902). 361 | max-attributes=10 362 | 363 | # Maximum number of boolean expressions in a if statement 364 | max-bool-expr=5 365 | 366 | # Maximum number of branch for function / method body 367 | max-branches=12 368 | 369 | # Maximum number of locals for function / method body 370 | max-locals=15 371 | 372 | # Maximum number of parents for a class (see R0901). 373 | max-parents=7 374 | 375 | # Maximum number of public methods for a class (see R0904). 376 | max-public-methods=20 377 | 378 | # Maximum number of return / yield for function / method body 379 | max-returns=6 380 | 381 | # Maximum number of statements in function / method body 382 | max-statements=50 383 | 384 | # Minimum number of public methods for a class (see R0903). 385 | min-public-methods=2 386 | 387 | 388 | [IMPORTS] 389 | 390 | # Allow wildcard imports from modules that define __all__. 391 | allow-wildcard-with-all=no 392 | 393 | # Analyse import fallback blocks. This can be used to support both Python 2 and 394 | # 3 compatible code, which means that the block might have code that exists 395 | # only in one or another interpreter, leading to false positives when analysed. 396 | analyse-fallback-blocks=no 397 | 398 | # Deprecated modules which should not be used, separated by a comma 399 | deprecated-modules=regsub,TERMIOS,Bastion,rexec 400 | 401 | # Create a graph of external dependencies in the given file (report RP0402 must 402 | # not be disabled) 403 | ext-import-graph= 404 | 405 | # Create a graph of every (i.e. internal and external) dependencies in the 406 | # given file (report RP0402 must not be disabled) 407 | import-graph= 408 | 409 | # Create a graph of internal dependencies in the given file (report RP0402 must 410 | # not be disabled) 411 | int-import-graph= 412 | 413 | # Force import order to recognize a module as part of the standard 414 | # compatibility libraries. 415 | known-standard-library= 416 | 417 | # Force import order to recognize a module as part of a third party library. 418 | known-third-party=enchant 419 | 420 | 421 | [EXCEPTIONS] 422 | 423 | # Exceptions that will emit a warning when being caught. Defaults to 424 | # "Exception" 425 | overgeneral-exceptions=Exception 426 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "f255d44563a54c1de5396ae4aa0d3325356d1c52217b803361ad62e8cb2d9b4e" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.7" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "alabaster": { 20 | "hashes": [ 21 | "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359", 22 | "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02" 23 | ], 24 | "version": "==0.7.12" 25 | }, 26 | "apipkg": { 27 | "hashes": [ 28 | "sha256:37228cda29411948b422fae072f57e31d3396d2ee1c9783775980ee9c9990af6", 29 | "sha256:58587dd4dc3daefad0487f6d9ae32b4542b185e1c36db6993290e7c41ca2b47c" 30 | ], 31 | "version": "==1.5" 32 | }, 33 | "argh": { 34 | "hashes": [ 35 | "sha256:a9b3aaa1904eeb78e32394cd46c6f37ac0fb4af6dc488daa58971bdc7d7fcaf3", 36 | "sha256:e9535b8c84dc9571a48999094fda7f33e63c3f1b74f3e5f3ac0105a58405bb65" 37 | ], 38 | "version": "==0.26.2" 39 | }, 40 | "atomicwrites": { 41 | "hashes": [ 42 | "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", 43 | "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6" 44 | ], 45 | "version": "==1.3.0" 46 | }, 47 | "attrs": { 48 | "hashes": [ 49 | "sha256:69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79", 50 | "sha256:f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399" 51 | ], 52 | "version": "==19.1.0" 53 | }, 54 | "babel": { 55 | "hashes": [ 56 | "sha256:af92e6106cb7c55286b25b38ad7695f8b4efb36a90ba483d7f7a6628c46158ab", 57 | "sha256:e86135ae101e31e2c8ec20a4e0c5220f4eed12487d5cf3f78be7e98d3a57fc28" 58 | ], 59 | "version": "==2.7.0" 60 | }, 61 | "bumpversion": { 62 | "hashes": [ 63 | "sha256:6744c873dd7aafc24453d8b6a1a0d6d109faf63cd0cd19cb78fd46e74932c77e", 64 | "sha256:6753d9ff3552013e2130f7bc03c1007e24473b4835952679653fb132367bdd57" 65 | ], 66 | "index": "pypi", 67 | "version": "==0.5.3" 68 | }, 69 | "certifi": { 70 | "hashes": [ 71 | "sha256:046832c04d4e752f37383b628bc601a7ea7211496b4638f6514d0e5b9acc4939", 72 | "sha256:945e3ba63a0b9f577b1395204e13c3a231f9bc0223888be653286534e5873695" 73 | ], 74 | "version": "==2019.6.16" 75 | }, 76 | "chardet": { 77 | "hashes": [ 78 | "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", 79 | "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" 80 | ], 81 | "version": "==3.0.4" 82 | }, 83 | "commonmark": { 84 | "hashes": [ 85 | "sha256:14c3df31e8c9c463377e287b2a1eefaa6019ab97b22dad36e2f32be59d61d68d", 86 | "sha256:867fc5db078ede373ab811e16b6789e9d033b15ccd7296f370ca52d1ee792ce0" 87 | ], 88 | "version": "==0.9.0" 89 | }, 90 | "coverage": { 91 | "hashes": [ 92 | "sha256:3684fabf6b87a369017756b551cef29e505cb155ddb892a7a29277b978da88b9", 93 | "sha256:39e088da9b284f1bd17c750ac672103779f7954ce6125fd4382134ac8d152d74", 94 | "sha256:3c205bc11cc4fcc57b761c2da73b9b72a59f8d5ca89979afb0c1c6f9e53c7390", 95 | "sha256:465ce53a8c0f3a7950dfb836438442f833cf6663d407f37d8c52fe7b6e56d7e8", 96 | "sha256:48020e343fc40f72a442c8a1334284620f81295256a6b6ca6d8aa1350c763bbe", 97 | "sha256:5296fc86ab612ec12394565c500b412a43b328b3907c0d14358950d06fd83baf", 98 | "sha256:5f61bed2f7d9b6a9ab935150a6b23d7f84b8055524e7be7715b6513f3328138e", 99 | "sha256:68a43a9f9f83693ce0414d17e019daee7ab3f7113a70c79a3dd4c2f704e4d741", 100 | "sha256:6b8033d47fe22506856fe450470ccb1d8ba1ffb8463494a15cfc96392a288c09", 101 | "sha256:7ad7536066b28863e5835e8cfeaa794b7fe352d99a8cded9f43d1161be8e9fbd", 102 | "sha256:7bacb89ccf4bedb30b277e96e4cc68cd1369ca6841bde7b005191b54d3dd1034", 103 | "sha256:839dc7c36501254e14331bcb98b27002aa415e4af7ea039d9009409b9d2d5420", 104 | "sha256:8f9a95b66969cdea53ec992ecea5406c5bd99c9221f539bca1e8406b200ae98c", 105 | "sha256:932c03d2d565f75961ba1d3cec41ddde00e162c5b46d03f7423edcb807734eab", 106 | "sha256:988529edadc49039d205e0aa6ce049c5ccda4acb2d6c3c5c550c17e8c02c05ba", 107 | "sha256:998d7e73548fe395eeb294495a04d38942edb66d1fa61eb70418871bc621227e", 108 | "sha256:9de60893fb447d1e797f6bf08fdf0dbcda0c1e34c1b06c92bd3a363c0ea8c609", 109 | "sha256:9e80d45d0c7fcee54e22771db7f1b0b126fb4a6c0a2e5afa72f66827207ff2f2", 110 | "sha256:a545a3dfe5082dc8e8c3eb7f8a2cf4f2870902ff1860bd99b6198cfd1f9d1f49", 111 | "sha256:a5d8f29e5ec661143621a8f4de51adfb300d7a476224156a39a392254f70687b", 112 | "sha256:aca06bfba4759bbdb09bf52ebb15ae20268ee1f6747417837926fae990ebc41d", 113 | "sha256:bb23b7a6fd666e551a3094ab896a57809e010059540ad20acbeec03a154224ce", 114 | "sha256:bfd1d0ae7e292105f29d7deaa9d8f2916ed8553ab9d5f39ec65bcf5deadff3f9", 115 | "sha256:c62ca0a38958f541a73cf86acdab020c2091631c137bd359c4f5bddde7b75fd4", 116 | "sha256:c709d8bda72cf4cd348ccec2a4881f2c5848fd72903c185f363d361b2737f773", 117 | "sha256:c968a6aa7e0b56ecbd28531ddf439c2ec103610d3e2bf3b75b813304f8cb7723", 118 | "sha256:df785d8cb80539d0b55fd47183264b7002077859028dfe3070cf6359bf8b2d9c", 119 | "sha256:f406628ca51e0ae90ae76ea8398677a921b36f0bd71aab2099dfed08abd0322f", 120 | "sha256:f46087bbd95ebae244a0eda01a618aff11ec7a069b15a3ef8f6b520db523dcf1", 121 | "sha256:f8019c5279eb32360ca03e9fac40a12667715546eed5c5eb59eb381f2f501260", 122 | "sha256:fc5f4d209733750afd2714e9109816a29500718b32dd9a5db01c0cb3a019b96a" 123 | ], 124 | "version": "==4.5.3" 125 | }, 126 | "docutils": { 127 | "hashes": [ 128 | "sha256:02aec4bd92ab067f6ff27a38a38a41173bf01bed8f89157768c1573f53e474a6", 129 | "sha256:51e64ef2ebfb29cae1faa133b3710143496eca21c530f3f71424d77687764274", 130 | "sha256:7a4bd47eaf6596e1295ecb11361139febe29b084a87bf005bf899f9a42edc3c6" 131 | ], 132 | "version": "==0.14" 133 | }, 134 | "execnet": { 135 | "hashes": [ 136 | "sha256:027ee5d961afa01e97b90d6ccc34b4ed976702bc58e7f092b3c513ea288cb6d2", 137 | "sha256:752a3786f17416d491f833a29217dda3ea4a471fc5269c492eebcee8cc4772d3" 138 | ], 139 | "version": "==1.6.0" 140 | }, 141 | "future": { 142 | "hashes": [ 143 | "sha256:67045236dcfd6816dc439556d009594abf643e5eb48992e36beac09c2ca659b8" 144 | ], 145 | "version": "==0.17.1" 146 | }, 147 | "idna": { 148 | "hashes": [ 149 | "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", 150 | "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" 151 | ], 152 | "version": "==2.8" 153 | }, 154 | "imagesize": { 155 | "hashes": [ 156 | "sha256:3f349de3eb99145973fefb7dbe38554414e5c30abd0c8e4b970a7c9d09f3a1d8", 157 | "sha256:f3832918bc3c66617f92e35f5d70729187676313caa60c187eb0f28b8fe5e3b5" 158 | ], 159 | "version": "==1.1.0" 160 | }, 161 | "importlib-metadata": { 162 | "hashes": [ 163 | "sha256:6dfd58dfe281e8d240937776065dd3624ad5469c835248219bd16cf2e12dbeb7", 164 | "sha256:cb6ee23b46173539939964df59d3d72c3e0c1b5d54b84f1d8a7e912fe43612db" 165 | ], 166 | "version": "==0.18" 167 | }, 168 | "jinja2": { 169 | "hashes": [ 170 | "sha256:065c4f02ebe7f7cf559e49ee5a95fb800a9e4528727aec6f24402a5374c65013", 171 | "sha256:14dd6caf1527abb21f08f86c784eac40853ba93edb79552aa1e4b8aef1b61c7b" 172 | ], 173 | "version": "==2.10.1" 174 | }, 175 | "livereload": { 176 | "hashes": [ 177 | "sha256:78d55f2c268a8823ba499305dcac64e28ddeb9a92571e12d543cd304faf5817b", 178 | "sha256:89254f78d7529d7ea0a3417d224c34287ebfe266b05e67e51facaf82c27f0f66" 179 | ], 180 | "version": "==2.6.1" 181 | }, 182 | "markupsafe": { 183 | "hashes": [ 184 | "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", 185 | "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", 186 | "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", 187 | "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", 188 | "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", 189 | "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", 190 | "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", 191 | "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", 192 | "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", 193 | "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", 194 | "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", 195 | "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", 196 | "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", 197 | "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", 198 | "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", 199 | "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", 200 | "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", 201 | "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", 202 | "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", 203 | "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", 204 | "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", 205 | "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", 206 | "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", 207 | "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", 208 | "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", 209 | "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", 210 | "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", 211 | "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7" 212 | ], 213 | "version": "==1.1.1" 214 | }, 215 | "more-itertools": { 216 | "hashes": [ 217 | "sha256:3ad685ff8512bf6dc5a8b82ebf73543999b657eded8c11803d9ba6b648986f4d", 218 | "sha256:8bb43d1f51ecef60d81854af61a3a880555a14643691cc4b64a6ee269c78f09a" 219 | ], 220 | "markers": "python_version > '2.7'", 221 | "version": "==7.1.0" 222 | }, 223 | "needle": { 224 | "hashes": [ 225 | "sha256:4d232419898e89108ee7b79a15b0955076f852a7436de694eeecbe5f76a6580b" 226 | ], 227 | "index": "pypi", 228 | "version": "==0.5.0" 229 | }, 230 | "nose": { 231 | "hashes": [ 232 | "sha256:9ff7c6cc443f8c51994b34a667bbcf45afd6d945be7477b52e97516fd17c53ac", 233 | "sha256:dadcddc0aefbf99eea214e0f1232b94f2fa9bd98fa8353711dacb112bfcbbb2a", 234 | "sha256:f1bffef9cbc82628f6e7d7b40d7e255aefaa1adb6a1b1d26c69a8b79e6208a98" 235 | ], 236 | "version": "==1.3.7" 237 | }, 238 | "packaging": { 239 | "hashes": [ 240 | "sha256:0c98a5d0be38ed775798ece1b9727178c4469d9c3b4ada66e8e6b7849f8732af", 241 | "sha256:9e1cbf8c12b1f1ce0bb5344b8d7ecf66a6f8a6e91bcb0c84593ed6d3ab5c4ab3" 242 | ], 243 | "version": "==19.0" 244 | }, 245 | "pathtools": { 246 | "hashes": [ 247 | "sha256:7c35c5421a39bb82e58018febd90e3b6e5db34c5443aaaf742b3f33d4655f1c0" 248 | ], 249 | "version": "==0.1.2" 250 | }, 251 | "pep8": { 252 | "hashes": [ 253 | "sha256:b22cfae5db09833bb9bd7c8463b53e1a9c9b39f12e304a8d0bba729c501827ee", 254 | "sha256:fe249b52e20498e59e0b5c5256aa52ee99fc295b26ec9eaa85776ffdb9fe6374" 255 | ], 256 | "version": "==1.7.1" 257 | }, 258 | "pillow": { 259 | "hashes": [ 260 | "sha256:0804f77cb1e9b6dbd37601cee11283bba39a8d44b9ddb053400c58e0c0d7d9de", 261 | "sha256:0ab7c5b5d04691bcbd570658667dd1e21ca311c62dcfd315ad2255b1cd37f64f", 262 | "sha256:0b3e6cf3ea1f8cecd625f1420b931c83ce74f00c29a0ff1ce4385f99900ac7c4", 263 | "sha256:365c06a45712cd723ec16fa4ceb32ce46ad201eb7bbf6d3c16b063c72b61a3ed", 264 | "sha256:38301fbc0af865baa4752ddae1bb3cbb24b3d8f221bf2850aad96b243306fa03", 265 | "sha256:3aef1af1a91798536bbab35d70d35750bd2884f0832c88aeb2499aa2d1ed4992", 266 | "sha256:3fe0ab49537d9330c9bba7f16a5f8b02da615b5c809cdf7124f356a0f182eccd", 267 | "sha256:45a619d5c1915957449264c81c008934452e3fd3604e36809212300b2a4dab68", 268 | "sha256:49f90f147883a0c3778fd29d3eb169d56416f25758d0f66775db9184debc8010", 269 | "sha256:571b5a758baf1cb6a04233fb23d6cf1ca60b31f9f641b1700bfaab1194020555", 270 | "sha256:5ac381e8b1259925287ccc5a87d9cf6322a2dc88ae28a97fe3e196385288413f", 271 | "sha256:6153db744a743c0c8c91b8e3b9d40e0b13a5d31dbf8a12748c6d9bfd3ddc01ad", 272 | "sha256:6fd63afd14a16f5d6b408f623cc2142917a1f92855f0df997e09a49f0341be8a", 273 | "sha256:70acbcaba2a638923c2d337e0edea210505708d7859b87c2bd81e8f9902ae826", 274 | "sha256:70b1594d56ed32d56ed21a7fbb2a5c6fd7446cdb7b21e749c9791eac3a64d9e4", 275 | "sha256:76638865c83b1bb33bcac2a61ce4d13c17dba2204969dedb9ab60ef62bede686", 276 | "sha256:7b2ec162c87fc496aa568258ac88631a2ce0acfe681a9af40842fc55deaedc99", 277 | "sha256:7cee2cef07c8d76894ebefc54e4bb707dfc7f258ad155bd61d87f6cd487a70ff", 278 | "sha256:7d16d4498f8b374fc625c4037742fbdd7f9ac383fd50b06f4df00c81ef60e829", 279 | "sha256:b50bc1780681b127e28f0075dfb81d6135c3a293e0c1d0211133c75e2179b6c0", 280 | "sha256:bd0582f831ad5bcad6ca001deba4568573a4675437db17c4031939156ff339fa", 281 | "sha256:cfd40d8a4b59f7567620410f966bb1f32dc555b2b19f82a91b147fac296f645c", 282 | "sha256:e3ae410089de680e8f84c68b755b42bc42c0ceb8c03dbea88a5099747091d38e", 283 | "sha256:e9046e559c299b395b39ac7dbf16005308821c2f24a63cae2ab173bd6aa11616", 284 | "sha256:ef6be704ae2bc8ad0ebc5cb850ee9139493b0fc4e81abcc240fb392a63ebc808", 285 | "sha256:f8dc19d92896558f9c4317ee365729ead9d7bbcf2052a9a19a3ef17abbb8ac5b" 286 | ], 287 | "index": "pypi", 288 | "version": "==6.1.0" 289 | }, 290 | "pluggy": { 291 | "hashes": [ 292 | "sha256:0825a152ac059776623854c1543d65a4ad408eb3d33ee114dff91e57ec6ae6fc", 293 | "sha256:b9817417e95936bf75d85d3f8767f7df6cdde751fc40aed3bb3074cbcb77757c" 294 | ], 295 | "version": "==0.12.0" 296 | }, 297 | "port-for": { 298 | "hashes": [ 299 | "sha256:b16a84bb29c2954db44c29be38b17c659c9c27e33918dec16b90d375cc596f1c" 300 | ], 301 | "version": "==0.3.1" 302 | }, 303 | "py": { 304 | "hashes": [ 305 | "sha256:64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa", 306 | "sha256:dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53" 307 | ], 308 | "version": "==1.8.0" 309 | }, 310 | "pygments": { 311 | "hashes": [ 312 | "sha256:71e430bc85c88a430f000ac1d9b331d2407f681d6f6aec95e8bcfbc3df5b0127", 313 | "sha256:881c4c157e45f30af185c1ffe8d549d48ac9127433f2c380c24b84572ad66297" 314 | ], 315 | "version": "==2.4.2" 316 | }, 317 | "pyparsing": { 318 | "hashes": [ 319 | "sha256:1873c03321fc118f4e9746baf201ff990ceb915f433f23b395f5580d1840cb2a", 320 | "sha256:9b6323ef4ab914af344ba97510e966d64ba91055d6b9afa6b30799340e89cc03" 321 | ], 322 | "version": "==2.4.0" 323 | }, 324 | "pytest": { 325 | "hashes": [ 326 | "sha256:6aa9bc2f6f6504d7949e9df2a756739ca06e58ffda19b5e53c725f7b03fb4aae", 327 | "sha256:b77ae6f2d1a760760902a7676887b665c086f71e3461c64ed2a312afcedc00d6" 328 | ], 329 | "index": "pypi", 330 | "version": "==4.6.4" 331 | }, 332 | "pytest-base-url": { 333 | "hashes": [ 334 | "sha256:31e42366a5fc22f450b398837dc819bb7569f5e6bd5d74e494b2b9ec239876d1", 335 | "sha256:7425e8163345494ac7f544e99c6f3e5a08f4228bee5e26013b98c462a4d31f6e" 336 | ], 337 | "version": "==1.4.1" 338 | }, 339 | "pytest-cache": { 340 | "hashes": [ 341 | "sha256:be7468edd4d3d83f1e844959fd6e3fd28e77a481440a7118d430130ea31b07a9" 342 | ], 343 | "version": "==1.0" 344 | }, 345 | "pytest-cov": { 346 | "hashes": [ 347 | "sha256:2b097cde81a302e1047331b48cadacf23577e431b61e9c6f49a1170bbe3d3da6", 348 | "sha256:e00ea4fdde970725482f1f35630d12f074e121a23801aabf2ae154ec6bdd343a" 349 | ], 350 | "index": "pypi", 351 | "version": "==2.7.1" 352 | }, 353 | "pytest-html": { 354 | "hashes": [ 355 | "sha256:ac405ca2fc4a55b83ca59319c69552f9bc870db2378e851633970bfc7fb93928", 356 | "sha256:c2312f3bb1c78fac08580dd2cc9bd0a726cad369375a9bf30c838ff7532120cc" 357 | ], 358 | "version": "==1.21.1" 359 | }, 360 | "pytest-metadata": { 361 | "hashes": [ 362 | "sha256:2071a59285de40d7541fde1eb9f1ddea1c9db165882df82781367471238b66ba", 363 | "sha256:c29a1fb470424926c63154c1b632c02585f2ba4282932058a71d35295ff8c96d" 364 | ], 365 | "version": "==1.8.0" 366 | }, 367 | "pytest-pep8": { 368 | "hashes": [ 369 | "sha256:032ef7e5fa3ac30f4458c73e05bb67b0f036a8a5cb418a534b3170f89f120318" 370 | ], 371 | "index": "pypi", 372 | "version": "==1.0.6" 373 | }, 374 | "pytest-selenium": { 375 | "hashes": [ 376 | "sha256:18c5db66512efcf6db9e32cfe8dcd0b69ef11ac1e8b9a4ce8a1b4813e6c73c9a", 377 | "sha256:c4d5a73d501f9fb3afb70e781f6fc2106a7b5f64387ed449b15eb1df61fa7915" 378 | ], 379 | "index": "pypi", 380 | "version": "==1.16.0" 381 | }, 382 | "pytest-variables": { 383 | "hashes": [ 384 | "sha256:59c00b95779657532ac5f8209b28b5d447c8b4bc4210c1d6bdf9a42aa201f9b0", 385 | "sha256:7808b77b643b9f8a24f1ee1c32132648b1c62ab93956f20fe101dde66db6d09a" 386 | ], 387 | "version": "==1.7.1" 388 | }, 389 | "python-coveralls": { 390 | "hashes": [ 391 | "sha256:38a8c55849308b136f9b831e7ac972f9b6d0068d0087cd75dc147ac6a0e8e360", 392 | "sha256:f044de08b547cfa3cd6e120cd4656e217b9ff012a4211ed11a60016e1362223e" 393 | ], 394 | "index": "pypi", 395 | "version": "==2.9.2" 396 | }, 397 | "pytz": { 398 | "hashes": [ 399 | "sha256:303879e36b721603cc54604edcac9d20401bdbe31e1e4fdee5b9f98d5d31dfda", 400 | "sha256:d747dd3d23d77ef44c6a3526e274af6efeb0a6f1afd5a69ba4d5be4098c8e141" 401 | ], 402 | "version": "==2019.1" 403 | }, 404 | "pyyaml": { 405 | "hashes": [ 406 | "sha256:57acc1d8533cbe51f6662a55434f0dbecfa2b9eaf115bede8f6fd00115a0c0d3", 407 | "sha256:588c94b3d16b76cfed8e0be54932e5729cc185caffaa5a451e7ad2f7ed8b4043", 408 | "sha256:68c8dd247f29f9a0d09375c9c6b8fdc64b60810ebf07ba4cdd64ceee3a58c7b7", 409 | "sha256:70d9818f1c9cd5c48bb87804f2efc8692f1023dac7f1a1a5c61d454043c1d265", 410 | "sha256:86a93cccd50f8c125286e637328ff4eef108400dd7089b46a7be3445eecfa391", 411 | "sha256:a0f329125a926876f647c9fa0ef32801587a12328b4a3c741270464e3e4fa778", 412 | "sha256:a3c252ab0fa1bb0d5a3f6449a4826732f3eb6c0270925548cac342bc9b22c225", 413 | "sha256:b4bb4d3f5e232425e25dda21c070ce05168a786ac9eda43768ab7f3ac2770955", 414 | "sha256:cd0618c5ba5bda5f4039b9398bb7fb6a317bb8298218c3de25c47c4740e4b95e", 415 | "sha256:ceacb9e5f8474dcf45b940578591c7f3d960e82f926c707788a570b51ba59190", 416 | "sha256:fe6a88094b64132c4bb3b631412e90032e8cfe9745a58370462240b8cb7553cd" 417 | ], 418 | "version": "==5.1.1" 419 | }, 420 | "recommonmark": { 421 | "hashes": [ 422 | "sha256:a520b8d25071a51ae23a27cf6252f2fe387f51bdc913390d83b2b50617f5bb48", 423 | "sha256:c85228b9b7aea7157662520e74b4e8791c5eacd375332ec68381b52bf10165be" 424 | ], 425 | "index": "pypi", 426 | "version": "==0.5.0" 427 | }, 428 | "requests": { 429 | "hashes": [ 430 | "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", 431 | "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31" 432 | ], 433 | "version": "==2.22.0" 434 | }, 435 | "selenium": { 436 | "hashes": [ 437 | "sha256:2d7131d7bc5a5b99a2d9b04aaf2612c411b03b8ca1b1ee8d3de5845a9be2cb3c", 438 | "sha256:deaf32b60ad91a4611b98d8002757f29e6f2c2d5fcaf202e1c9ad06d6772300d" 439 | ], 440 | "index": "pypi", 441 | "version": "==3.141.0" 442 | }, 443 | "six": { 444 | "hashes": [ 445 | "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", 446 | "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" 447 | ], 448 | "version": "==1.12.0" 449 | }, 450 | "snowballstemmer": { 451 | "hashes": [ 452 | "sha256:9f3b9ffe0809d174f7047e121431acf99c89a7040f0ca84f94ba53a498e6d0c9" 453 | ], 454 | "version": "==1.9.0" 455 | }, 456 | "sphinx": { 457 | "hashes": [ 458 | "sha256:22538e1bbe62b407cf5a8aabe1bb15848aa66bb79559f42f5202bbce6b757a69", 459 | "sha256:f9a79e746b87921cabc3baa375199c6076d1270cee53915dbd24fdbeaaacc427" 460 | ], 461 | "index": "pypi", 462 | "version": "==2.1.2" 463 | }, 464 | "sphinx-autobuild": { 465 | "hashes": [ 466 | "sha256:66388f81884666e3821edbe05dd53a0cfb68093873d17320d0610de8db28c74e", 467 | "sha256:e60aea0789cab02fa32ee63c7acae5ef41c06f1434d9fd0a74250a61f5994692" 468 | ], 469 | "index": "pypi", 470 | "version": "==0.7.1" 471 | }, 472 | "sphinx-rtd-theme": { 473 | "hashes": [ 474 | "sha256:00cf895504a7895ee433807c62094cf1e95f065843bf3acd17037c3e9a2becd4", 475 | "sha256:728607e34d60456d736cc7991fd236afb828b21b82f956c5ea75f94c8414040a" 476 | ], 477 | "index": "pypi", 478 | "version": "==0.4.3" 479 | }, 480 | "sphinxcontrib-applehelp": { 481 | "hashes": [ 482 | "sha256:edaa0ab2b2bc74403149cb0209d6775c96de797dfd5b5e2a71981309efab3897", 483 | "sha256:fb8dee85af95e5c30c91f10e7eb3c8967308518e0f7488a2828ef7bc191d0d5d" 484 | ], 485 | "version": "==1.0.1" 486 | }, 487 | "sphinxcontrib-devhelp": { 488 | "hashes": [ 489 | "sha256:6c64b077937330a9128a4da74586e8c2130262f014689b4b89e2d08ee7294a34", 490 | "sha256:9512ecb00a2b0821a146736b39f7aeb90759834b07e81e8cc23a9c70bacb9981" 491 | ], 492 | "version": "==1.0.1" 493 | }, 494 | "sphinxcontrib-htmlhelp": { 495 | "hashes": [ 496 | "sha256:4670f99f8951bd78cd4ad2ab962f798f5618b17675c35c5ac3b2132a14ea8422", 497 | "sha256:d4fd39a65a625c9df86d7fa8a2d9f3cd8299a3a4b15db63b50aac9e161d8eff7" 498 | ], 499 | "version": "==1.0.2" 500 | }, 501 | "sphinxcontrib-jsmath": { 502 | "hashes": [ 503 | "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", 504 | "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8" 505 | ], 506 | "version": "==1.0.1" 507 | }, 508 | "sphinxcontrib-qthelp": { 509 | "hashes": [ 510 | "sha256:513049b93031beb1f57d4daea74068a4feb77aa5630f856fcff2e50de14e9a20", 511 | "sha256:79465ce11ae5694ff165becda529a600c754f4bc459778778c7017374d4d406f" 512 | ], 513 | "version": "==1.0.2" 514 | }, 515 | "sphinxcontrib-serializinghtml": { 516 | "hashes": [ 517 | "sha256:c0efb33f8052c04fd7a26c0a07f1678e8512e0faec19f4aa8f2473a8b81d5227", 518 | "sha256:db6615af393650bf1151a6cd39120c29abaf93cc60db8c48eb2dddbfdc3a9768" 519 | ], 520 | "version": "==1.1.3" 521 | }, 522 | "tornado": { 523 | "hashes": [ 524 | "sha256:349884248c36801afa19e342a77cc4458caca694b0eda633f5878e458a44cb2c", 525 | "sha256:398e0d35e086ba38a0427c3b37f4337327231942e731edaa6e9fd1865bbd6f60", 526 | "sha256:4e73ef678b1a859f0cb29e1d895526a20ea64b5ffd510a2307b5998c7df24281", 527 | "sha256:559bce3d31484b665259f50cd94c5c28b961b09315ccd838f284687245f416e5", 528 | "sha256:abbe53a39734ef4aba061fca54e30c6b4639d3e1f59653f0da37a0003de148c7", 529 | "sha256:c845db36ba616912074c5b1ee897f8e0124df269468f25e4fe21fe72f6edd7a9", 530 | "sha256:c9399267c926a4e7c418baa5cbe91c7d1cf362d505a1ef898fde44a07c9dd8a5" 531 | ], 532 | "version": "==6.0.3" 533 | }, 534 | "urllib3": { 535 | "hashes": [ 536 | "sha256:b246607a25ac80bedac05c6f282e3cdaf3afb65420fd024ac94435cabe6e18d1", 537 | "sha256:dbe59173209418ae49d485b87d1681aefa36252ee85884c31346debd19463232" 538 | ], 539 | "version": "==1.25.3" 540 | }, 541 | "watchdog": { 542 | "hashes": [ 543 | "sha256:965f658d0732de3188211932aeb0bb457587f04f63ab4c1e33eab878e9de961d" 544 | ], 545 | "version": "==0.9.0" 546 | }, 547 | "wcwidth": { 548 | "hashes": [ 549 | "sha256:3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e", 550 | "sha256:f4ebe71925af7b40a864553f761ed559b43544f8f71746c2d756c7fe788ade7c" 551 | ], 552 | "version": "==0.1.7" 553 | }, 554 | "zipp": { 555 | "hashes": [ 556 | "sha256:4970c3758f4e89a7857a973b1e2a5d75bcdc47794442f2e2dd4fe8e0466e809a", 557 | "sha256:8a5712cfd3bb4248015eb3b0b3c54a5f6ee3f2425963ef2a0125b8bc40aafaec" 558 | ], 559 | "version": "==0.5.2" 560 | } 561 | }, 562 | "develop": {} 563 | } 564 | --------------------------------------------------------------------------------