From bd46e6f713733ab1043195310a94fad7a90ced0a Mon Sep 17 00:00:00 2001 From: chris001 Date: Sun, 4 Mar 2018 16:35:41 -0500 Subject: [PATCH] Autmomated testing using Travis and CodeCov.io! Linux, Mac OSX. Outlook IMAP, Gmail. LOGIN, PLAIN, XOAUTH2. python 2.7, python 3.6! Additional files required for Automated testing with Travis-CI and CodeCov.io! Add shebang for python2 so the Travis tests will pass on python3. Add gitter.im badge to README. Line break between rows of badges. Edit README.md mostly back to 7.1.5. Fix imaplib2 v2.57. Re-add [STALLED] for python3. Signed-off-by: Chris Coleman/EspaceNetworks --- .coveragerc | 14 +++++++ .gitignore | 12 +++--- .travis.yml | 68 +++++++++++++++++++++++++++++++ README.md | 18 ++++++++- bin/offlineimap | 2 +- offlineimap.py | 2 +- setup.py | 0 test/.gitignore | 5 ++- test/OLItest/TestRunner.py | 2 +- tests/.gitignore | 2 + tests/create_conf_file.py | 82 ++++++++++++++++++++++++++++++++++++++ tests/requirements.txt | 4 ++ 12 files changed, 200 insertions(+), 11 deletions(-) create mode 100644 .coveragerc create mode 100644 .travis.yml mode change 100644 => 100755 setup.py create mode 100644 tests/.gitignore create mode 100755 tests/create_conf_file.py create mode 100644 tests/requirements.txt diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..56c90a1 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,14 @@ +[run] +branch = True +source = offlineimap + +[report] +exclude_lines = + if self.debug: + pragma: no cover + raise NotImplementedError + if __name__ == .__main__.: +ignore_errors = True +omit = + tests/* + test/* diff --git a/.gitignore b/.gitignore index a362868..c10a05a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,18 @@ -# Editors/IDEs +# Backups. .*.swp .*.swo +*.html *~ -tags - # websites. /website/ /wiki/ - # Generated files. /docs/dev-doc/ /build/ -*.html -*.css *.pyc offlineimap.1 offlineimapui.7 +# Editors/IDEs +tags +#Generated conf files for Travis-CI tests. +oli-travis.conf diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..acda33e --- /dev/null +++ b/.travis.yml @@ -0,0 +1,68 @@ +language: python +python: + - '2.7' + - '3.6' +os: + - linux + #- osx #Can't specify OSX here, because Travis can't yet install python in its test environment, so Travis would remove OSX from the test run. + #We have a good workaround however, no worries. +env: + global: + - secure: iF/0aJyLAfx4dfFIWcdDOfAQKcN9GyZmi0+n7lmHnCdPC1uEer7W8eCvyeIoN3lGPUjXKcYkKyUvX6m8n64E2Js0w3zTNzCx0e/0owu4T5mgbxYEm4/p+cZLVuHMwdBs+qU90yPy8aQnUkQgUISDf53dl5MTtcBVbXTpx6H14IJmNXjrfXabG/07kclh5rAZnrmgQHvfR7n32BTghwo0nZfe41SVpM1T9YKoomBEu0yABV/+74i3tZr1Y+BF4sy8noipr1mwUhOIawaz8TmNRT+8E+1vLzbgAfd8qJgNmLPqd0hHLdDU5Fuo6I2XpvaUIPwShn6wZmloH6inbDuN4aafc9NgytvSDfwvHlYSYJJppiqYKqtd145ppsiPE7peT4k7i9U7fmbuJkH87Z1POF/CQRSs32YWRFpVppUBOUGJPSSMxr/dofX/E01YNGButoMJPvyCsQQX/9KwRFkwEK7mX++c0RMAv1R8VeI1ZKtUixYKLU3lYLYJsKduHaZnH2wOC9YJG/qU3ywp3EDfhVTLkY0eQ1PFcqurw1BUICS/d58vH121f/cMhvn5Pa3+bvHZBq7ZbC6jEU2QXWquzOkM5CBXMqLoxgKiVlrzTfWxgQIjY4IHZH6V4E2BOfFJw5s6bG8mMZjY3dpHRvsIMltNn9tXGSWcVepWlyXdlN4= + - secure: FKrFjv/MDkvJTQ9/5XnvHAFjDv7q+PWDPOXAAZkgdHmA/HdQnIp8J73O2MwmyP0Pqd+Eb2OZ1Qw4K9aQeYy56YIZkqZcupKxReqvtD6Ixp8oPTcu6YhvblDRKDJw6UC279LgTI0KtgWSN3Uqm1fAeriFJHRkUv4+vsNofidCmvHmJ39xWp+9G5Gt0lOeCMnkridxSPbTFUKRusEVMREj84QewkPSG9inF7zeFAPNNMT4QQHZrKFJ/rUGflWVwWMB3NSTLsj62V8mbbzgoelBk/RvreoV5WmFx8LdbiNyXvAobuDq4X3sohYs6BOJfTQOHi9JeDo/MuW1s8tc8ixruIg4RLdEd/JDhdQOW4qtDhEEfwronYSPzV3WUj/QrUOCIu68pQFOWflbk0pbH8V588gRqzh24BXHSb220tkJV4AvcRjZD1X7Rv5wIDSmsZDnleCgXoO2TGYpan0zbP/MQCEnlgjT5Qo00Ib+e+H2wkn6puTWkkIOXKYuagagsGB6jHs/fmbWlClbSXNkGdpYz6uAqamcWvLyBhbO2EIIVd1Lvf9CrswrNdELSaqmtSKlGKgeeaiJS7TEAYYNQ9dgLAmDRjKAsXnLFMEdD47jDh0jbMMMhqOBDsmk6nXLi0fWQ17MOuBAS4kAaYHOZfEWCVTmPCMZGpG8EecBe5N5WVs= + - secure: CkUhCTmaAzNmuCqyZk/ApysYKJ8LYyanQSMhIxcr+7oefgAegXl3XJnwMbx5e0mnhTmeM+lJGVicbWpVoPTOb3JW2p1u8JCNBl6LO07Vr9AahH/gk2vZGnxU3vTDihj2SqluOOVDE8wKADCwAFDoPonwiRxWAJMfD2F8pICN/PN6UYzXfugd6JWlYgMBMdVJ1CYPyBuh+mL7SQGPr+yrPs1Bd+gyOaCH92Q0megUwlwR0MyCo76ivhTQJPjDQYBFB6/xnvoI32I9YrvDfSueTA9UPHLXBV1EYzhAeRKc9Xe/kuefHown+n/uwP3NNfI5rd5w43E6Q2denK9T99cDbgyNQYxzb3BCh5CFF4NAwwNIbNiYzZOGfezYTEleH5B/wwLk5uFsjvADg2+iwatbPI2G1e6uuKZwhp0m5+vDQyvyK6tkAuPHPU5N+X+fYj9pSdMz8ljjtA9bItfTKj2ueNk3eZt0B3/JaPJtW8r+ibJo9qzoM1PPAAsaxMjTGTSZAAB1QAV4HumOARapyGkfteBooOwkp23B2EuEVg9ph9aBfQSm1z4yDIgFvR0XdTdfDS+iaPJ8SlvCNZS0dKli/3v2VjG6Wm6Ao9ASXw1s/tKzyEp7HTrhVanjokO7JD2aEg03gIJhF/7GaINFrMxRafOJvtx2x7aKqUes35qToTI= + - secure: RFH1UZ0a3Rd+VmxeVKh49tUqw12aLlpRl0+bqmWPxpoBXRbcpDq6kH/F7hozmdwAmYAzHtyQfEeUQ0jQd/MUxJ7QiZWzEW77ZgNToUt4DbnFuVj6wwNXWAUcZWAN4f2HTKam0Blk8ji50wI57+7ZiOOKEKJCynlBGcihQd8TVqGhx040N2UFgxKsDr1fmrStqf+rSyrlzrOXnirLHQQn2oiDKy3X/DDSoFdOrR88bR4VGGAuI/0eiJBBQnpgLLeGPq13GkyjbcxLewW6kFitqRHgoCwBkq3lSHqem0rbkdfekq94FR4wWnbvCyWse1PbtK9cFVa0AyR6QscNYXpADbI1s6Aubh23CDXDlm92feEdSElvgbxczzTrP4I5mPjrDRsanPpnS2xG3QZk3gQ9lRHH91JxPXI/pCu/XHptfpPCIaS5aBL2ekGT0dGC1GOHDvjLqH+OKLiOfzj75UXeo0a3VhJ2o+HWlRJMyzvUNIWKFd4ZSKMHl/v5Jpgr4lE8q4VB2pHf5zBp1ttdNRTlrifq393RTKzYsNeHPxBqcRv9eGJbws4q4kyQiH0lqAU2RUA7HFyAqeBzeMwSYK1IlXNkB3uXo0Im2IUwC0v6j6UTv23zniwz8mMQxru0OuTs7qzBfoKxvaHk4ywSXhsFQpQDpuWMptZ4VU+dwoL5pWI= + - secure: YTdj+wWBXikkSwz5iAxamxGIaPjadw+Fyec3fdNhV+HEM2z2+AiJ4AIP6UU34Agy6vOjtH1jCzrhf2Zj8R/W+vUT+GA2OOZylr68SOyM4A8thqiGiZ8FQDGJtQ24/GcaHgm/kkjJkILARfuWJy8zZ0kr60JH45SkwKRSWeP6Mic6/uANY5jxTZFIi7H0yhfbUURCk/ftA8y2RUA3P/5GpCiXI05VKJvIhQ0jFjrRkrqF3Pr3h6O0CjtqifV3YGQx9UrinmWmDQIey0HKzYvPpUsRejG3X+uoNTp63u6VeeYOkqRIHxYcsJtoKNeydbaQpbuIrZ/8i6IaakT50b1FNMgiwfu+Ta0cfimbkllRF2V0FAX35FAjoD+QHgI93kfhYgrpCgzJEf3a+WFFI4jlrCKF/r95QfNcMBXKGK6x6vx0LJLqT/PSuVhKrFvfbUxXjHsCZBBYGQqb5F4dVK7DAyyPkQoKXQKso6HhfO1CLpSnMwzsSlWnefx+b4py0e5ZYP0AHClfm00WdOxWmd6mWvrWmA4xLxACEF78lUQnd0+2KXmeodlBxZxkq0K/VsPdkE8kKw7eTkYVWDWoK41ahRhM4Q4ZoENarPTBH4F1qTWOdeC9feqwG2LPvRK5ZLbCxX+qujtS8xtwqamQQHt+J7b1o9vhl53cTsNzBYONGrU= + - secure: cXmKt5yvklVRpRj9/u+U8TA4zrbk8VE+wnU/L75i24zRZ38z9HH1POSVW/YrnBeAmPMzfBsWaXZ4Q2kjH2t4krB/FouEXLdzWbCc4ozw5mU+uySVuH2mkscxP1T1zv2asaDn7vTgJBs+QpRcvm/RQmeZq9IKwezV2SC82sCUG68NQKLAFYl0igd/DrBfLRNS58YQ/RJcCyWm5IItZ9ONvR+0mvpT0mM8owyNz6wsvVip/LWyfxoGmou7D5QgRYPDO5EDI/Jr0q3TzUZdGdPjfCPfDTwOWRzBUssPM4sAD6zQAqFxPiOcuiUpCGMKw2PJdrvKvpufrPhbsUsiW9gDU+uiz3tESgHIlH47fdrhCC1FZKR3O7QWl6JyvAkt2Ass56cw4mMskIhe93UC65spfapolusOs8IJJAGiSBv6J+m4w7+W/R/dfb06lkEAvG1nNyNPRM2lpoyZ3wuoWO2VoahyoUYEO+pWdtFfe5TuLyXYRjmTiAztcxFkRfqw7wjL2Ju6qmKS0ChKex1H6gDwftg0wku6/LlMvyTMnRs3kLHzsgjy/lY/mQK10Z6nXAPpB7EZQAptwCp2XCJoZYRTFS1pEWXc7gxBdv3d6C7KtZnLlMp/N+U61qfEaM227dl7ol01f0waBZXP/GUY8ap8tF+aiKDRFaMU+FnYFxE60sw= + matrix: + #- DOVECOT_AUTH=GSSAPI GMAIL_AUTH=XOAUTH2 #Dovecot IMAP server inbox not yet created. + #- DOVECOT_AUTH=CRAM-MD5 GMAIL_AUTH=XOAUTH2 # + - OUTLOOK_AUTH=PLAIN GMAIL_AUTH=XOAUTH2 + - OUTLOOK_AUTH=LOGIN GMAIL_AUTH=XOAUTH2 +matrix: + include: + - os: osx + language: generic + env: PYTHON=2.7.14 OUTLOOK_AUTH=PLAIN GMAIL_AUTH=XOAUTH2 + - os: osx + language: generic + env: PYTHON=2.7.14 OUTLOOK_AUTH=LOGIN GMAIL_AUTH=XOAUTH2 + - os: osx + language: generic + env: PYTHON=3.6.4 OUTLOOK_AUTH=PLAIN GMAIL_AUTH=XOAUTH2 + - os: osx + language: generic + env: PYTHON=3.6.4 OUTLOOK_AUTH=LOGIN GMAIL_AUTH=XOAUTH2 + allow_failures: + #- python: '3.6' # Python3 runs should work fine, because shebang is set to python2. + - os: osx # OSX should work now. +cache: pip +before_install: +- if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then brew update && brew install openssl readline; fi +- if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then export OSX_BREW_SSLCACERTFILE="/usr/local/etc/openssl/cert.pem"; fi +- if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then brew outdated pyenv || brew upgrade pyenv; fi +# virtualenv doesn't work without pyenv knowledge. +# venv in Python 3.3 doesn't provide pip by default. +# So, use 'pyenv-virtualenv'. +- if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then brew install pyenv-virtualenv; fi +- if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then pyenv install $PYTHON; fi +- if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then export PYENV_VERSION="${PYTHON}"; fi +- if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then export PATH="/Users/travis/.pyenv/shims:${PATH}"; fi +- if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then pyenv virtualenv $PYTHON myvenv; fi +- if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then pyenv versions; fi +- if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then python --version; fi +- if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then pyenv version; fi +#- if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then pyenv activate myvenv; fi +# Manually check that the correct version of python is running. +- if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then python --version; fi +- if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then python -m pip install -U pip; fi +- if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then python -m easy_install -U setuptools; fi +install: +- pip install -r requirements.txt +- pip install -r tests/requirements.txt +- export PATH=$PATH:. +- python tests/create_conf_file.py +script: +#- pytest # Disabled because OLItest hardcoded LOGIN method which fails on Gmail. Gmail must use/test the built-in XOAUTH2 authentication in offlineimap. +- ./offlineimap.py -c ./oli-travis.conf +- codecov diff --git a/README.md b/README.md index dc0b6ff..9bf7f4e 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,24 @@ +Upstream status: +[![OfflineIMAP build status on Travis-CI.org](https://travis-ci.org/OfflineIMAP/offlineimap.svg?branch=master)](https://travis-ci.org/OfflineIMAP/offlineimap) +[![OfflineIMAP code coverage on Codecov.io](https://codecov.io/gh/OfflineIMAP/offlineimap/branch/master/graph/badge.svg)](https://codedov.io/gh/OfflineIMAP/offlineimap) +[![Gitter chat](https://badges.gitter.im/OfflineIMAP/offlineimap.png)](https://gitter.im/OfflineIMAP/offlineimap) + +Fork status: +[![OfflineIMAP build status on Travis-CI.org](https://travis-ci.org/EspaceNetworks/offlineimap.svg?branch=master)](https://travis-ci.org/EspaceNetworks/offlineimap) +[![OfflineIMAP code coverage on Codecov.io](https://codecov.io/gh/EspaceNetworks/offlineimap/branch/master/graph/badge.svg)](https://codedov.io/gh/EspaceNetworks/offlineimap) +[![Gitter chat](https://badges.gitter.im/EspaceNetworks/offlineimap.png)](https://gitter.im/EspaceNetworks/offlineimap) + [offlineimap]: http://github.com/OfflineIMAP/offlineimap [website]: http://www.offlineimap.org [wiki]: http://github.com/OfflineIMAP/offlineimap/wiki [blog]: http://www.offlineimap.org/posts.html +Links: +* Official github code repository: [offlineimap] +* Website: [website] +* Wiki: [wiki] +* Blog: [blog] + # OfflineIMAP ***"Get the emails where you need them."*** @@ -87,7 +103,7 @@ Bugs, issues and contributions can be requested to both the mailing list or the ## Requirements & dependencies * Python v2.7+ -* Python v3.4+ ***[STALLED] (experimental: [see known issues](https://github.com/OfflineIMAP/offlineimap/issues?q=is%3Aissue+is%3Aopen+label%3APy3))*** +* Python v3.4+ ***[STALLED](experimental: [see known issues](https://github.com/OfflineIMAP/offlineimap/issues?q=is%3Aissue+is%3Aopen+label%3APy3))*** * six (required) * imaplib2 >= 2.57 (optional) diff --git a/bin/offlineimap b/bin/offlineimap index 5f307a7..8d46b2b 100755 --- a/bin/offlineimap +++ b/bin/offlineimap @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python2 # Startup from system-wide installation # Copyright (C) 2002 - 2009 John Goerzen # diff --git a/offlineimap.py b/offlineimap.py index 575e04d..e614b08 100755 --- a/offlineimap.py +++ b/offlineimap.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python2 # Startup from single-user installation # Copyright (C) 2002 - 2008 John Goerzen # diff --git a/setup.py b/setup.py old mode 100644 new mode 100755 diff --git a/test/.gitignore b/test/.gitignore index 08ea7df..8ec22c7 100644 --- a/test/.gitignore +++ b/test/.gitignore @@ -1,2 +1,5 @@ credentials.conf -tmp_* \ No newline at end of file +tmp_* +*.pyc +OLItest/*.pyc +tests/*.pyc diff --git a/test/OLItest/TestRunner.py b/test/OLItest/TestRunner.py index 88dade2..209ad68 100644 --- a/test/OLItest/TestRunner.py +++ b/test/OLItest/TestRunner.py @@ -13,7 +13,7 @@ # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA -import imaplib +import offlineimap.virtual_imaplib2 as imaplib import unittest import logging import os diff --git a/tests/.gitignore b/tests/.gitignore new file mode 100644 index 0000000..2f78cf5 --- /dev/null +++ b/tests/.gitignore @@ -0,0 +1,2 @@ +*.pyc + diff --git a/tests/create_conf_file.py b/tests/create_conf_file.py new file mode 100755 index 0000000..60dd06d --- /dev/null +++ b/tests/create_conf_file.py @@ -0,0 +1,82 @@ +#!/bin/env python +# Copyright 2018 Espace LLC/espacenetworks.com. Written by @chris001. +# This must be run from the main directory of the offlineimap project. +# Typically this script will be run by Travis to create the config files needed for running the automated tests. +# python ./tests/create_conf_file.py +# Input: Seven shell environment variables. +# Output: it writes the config settings to "filename" (./oli-travis.conf) and "additionalfilename" (./test/credentials.conf). +# "filename" is used by normal run of ./offlineimap -c ./oli-travis.conf , "additionalfilename" is used by "pytest". +# They are the same conf file, copie to two different locations for convenience. + +import os +import shutil +try: + import ConfigParser + Config = ConfigParser.ConfigParser() +except ImportError: + import configparser + Config = configparser.ConfigParser() + +filename = "./oli-travis.conf" +additionalfilename = "./test/credentials.conf" # for the 'pytest' which automatically finds and runs the unittests. + +#TODO: detect OS we running on now, and set sslcacertfile location accordingly. +sslcacertfile = "/etc/pki/tls/cert.pem" # CentOS 7 +sslcacertfile = "" # TODO: https://gist.github.com/1stvamp/2158128 Current Mac OSX now must download the cacertfile. +sslcacertfile = "/etc/ssl/certs/ca-certificates.crt" # Ubuntu Trusty 14.04 (Travis linux test container 2018.) +if os.environ["TRAVIS_OS_NAME"] == "osx": + sslcacertfile = os.environ["OSX_BREW_SSLCACERTFILE"] + +# lets create that config file. +cfgfile = open(filename,'w') + +# add the settings to the structure of the file, and lets write it out. +sect = 'general' +Config.add_section(sect) +Config.set(sect,'accounts','Test') +Config.set(sect,'maxsyncaccounts', '1') + +sect = 'Account Test' +Config.add_section(sect) +Config.set(sect,'localrepository','IMAP') # Outlook. +Config.set(sect,'remoterepository', 'Gmail') + +### "Repository IMAP" is hardcoded in test/OLItest/TestRunner.py it should dynamically get the Repository names but it doesn't. +sect = 'Repository IMAP' # Outlook. +Config.add_section(sect) +Config.set(sect,'type','IMAP') +Config.set(sect,'remotehost', 'imap-mail.outlook.com') +Config.set(sect,'remoteport', '993') +Config.set(sect,'auth_mechanisms', os.environ["OUTLOOK_AUTH"]) +Config.set(sect,'ssl', 'True') +#Config.set(sect,'tls_level', 'tls_compat') #Default is 'tls_compat'. +#Config.set(sect,'ssl_version', 'tls1_2') # Leave this unset. Will auto select between tls1_1 and tls1_2 for tls_secure. +Config.set(sect,'sslcacertfile', sslcacertfile) +Config.set(sect,'remoteuser', os.environ["secure_outlook_email_address"]) +Config.set(sect,'remotepass', os.environ["secure_outlook_email_pw"]) +Config.set(sect,'createfolders', 'True') +Config.set(sect,'folderfilter', 'lambda f: f not in ["Inbox", "[Gmail]/All Mail"]') #Capitalization of Inbox INBOX was causing runtime failure. +#Config.set(sect,'folderfilter', 'lambda f: f not in ["[Gmail]/All Mail"]') + + +### "Repository Gmail" is also hardcoded into test/OLItest/TestRunner.py +sect = 'Repository Gmail' +Config.add_section(sect) +Config.set(sect,'type', 'Gmail') +Config.set(sect,'remoteport', '993') +Config.set(sect,'auth_mechanisms', os.environ["GMAIL_AUTH"]) +Config.set(sect,'oauth2_client_id', os.environ["secure_gmail_oauth2_client_id"]) +Config.set(sect,'oauth2_client_secret', os.environ["secure_gmail_oauth2_client_secret"]) +Config.set(sect,'oauth2_refresh_token', os.environ["secure_gmail_oauth2_refresh_token"]) +Config.set(sect,'remoteuser', os.environ["secure_gmail_email_address"]) +Config.set(sect,'ssl', 'True') +#Config.set(sect,'tls_level', 'tls_compat') +#Config.set(sect,'ssl_version', 'tls1_2') +Config.set(sect,'sslcacertfile', sslcacertfile) +Config.set(sect,'createfolders', 'True') +Config.set(sect,'folderfilter', 'lambda f: f not in ["INBOX", "[Gmail]/All Mail"]') + +Config.write(cfgfile) +cfgfile.close() + +shutil.copy(filename, additionalfilename) diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..f43eee1 --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,4 @@ +pytest +pytest-cov +coverage +codecov