From 868c6cc771de31a4be6f63d32a8b6674b82f020d Mon Sep 17 00:00:00 2001 From: Dan Callaghan Date: Jun 16 2017 04:02:17 +0000 Subject: convert API tests to functional tests which talk real HTTP --- diff --git a/README.md b/README.md index e76120c..1eec551 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,8 @@ values in `greenwave/config.py`. ## Running test suite -You can run this test suite with the following command:: +You can run the unit tests, which live in the `greenwave.tests` package, with +the following command: $ py.test greenwave/tests/ @@ -43,6 +44,15 @@ To test against all supported versions of Python, you can use tox:: $ sudo dnf install python3-tox $ tox +There are also functional tests in the `functional-tests` directory. +The functional tests will start their own copy of the +[ResultsDB](https://pagure.io/taskotron/resultsdb), +[WaiverDB](https://pagure.io/waiverdb), and Greenwave applications and then +send HTTP requests to them. If you have a git checkout of all three projects, +you can run the functional tests like this (adjust the paths as appropriate): + + $ PYTHONPATH=../resultsdb:../waiverdb:. py.test functional-tests/ + ## Building the docs You can view the docs locally with:: diff --git a/dev-requirements.txt b/dev-requirements.txt index efc2b73..a172c29 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,9 +1,9 @@ # Testing requirements flake8 -mock pytest pytest-cov -requests_mock +waiverdb +resultsdb # Documentation build requirements # Pin sphinx until https://bitbucket.org/birkenfeld/sphinx-contrib/issues/182 is fixed and released diff --git a/functional-tests/conftest.py b/functional-tests/conftest.py new file mode 100644 index 0000000..6652fe5 --- /dev/null +++ b/functional-tests/conftest.py @@ -0,0 +1,142 @@ +# SPDX-License-Identifier: GPL-2.0+ + +import os +import itertools +import json +import threading +import socket +import wsgiref.simple_server +import pytest +import requests + +import waiverdb.config +import waiverdb.app +import greenwave.app_factory + + +class WSGIServerThread(threading.Thread): + + def __init__(self, application, init_func, port): + self._server = wsgiref.simple_server.make_server('127.0.0.1', port, application) + self.init_func = init_func + name = '{}-server-thread'.format(application.name) + super(WSGIServerThread, self).__init__(name=name) + + def run(self): + # We call the init_func *inside* our new thread, because when the + # application is using a SQLite in-memory database with SQLAlchemy + # each thread gets its own separate db. So initialising the database in + # the main thread would not work. + self.init_func() + self._server.serve_forever() + + def stop(self): + self._server.shutdown() + self._server.socket.shutdown(socket.SHUT_RD) + self._server.server_close() + self.join() + + @property + def url(self): + host, port = self._server.server_address + return 'http://{}:{}/'.format(host, port) + + +@pytest.fixture(scope='session') +def resultsdb_server(request): + # Ideally ResultsDB would let us configure the app programmatically, + # instead of doing everything globally at import time... + os.environ['TEST'] = 'true' + import resultsdb + import resultsdb.cli + del os.environ['TEST'] + app = resultsdb.app + init_func = lambda: resultsdb.cli.initialize_db(destructive=True) + server = WSGIServerThread(app, init_func, port=5001) + server.start() + request.addfinalizer(server.stop) + return server + + +@pytest.fixture(scope='session') +def waiverdb_server(request): + class WaiverdbTestingConfig(waiverdb.config.TestingConfig): + AUTH_METHOD = 'dummy' + # As a workaround for https://github.com/mitsuhiko/flask-sqlalchemy/pull/364 + # WaiverDB patches flask_sqlalchemy.SignallingSession globally, which + # messes up ResultsDB. So let's just turn off the messaging support in + # WaiverDB entirely for now. + MESSAGE_BUS_PUBLISH = False + app = waiverdb.app.create_app(WaiverdbTestingConfig) + init_func = lambda: waiverdb.app.init_db(app) + server = WSGIServerThread(app, init_func, port=5004) + server.start() + request.addfinalizer(server.stop) + return server + + +@pytest.fixture(scope='session') +def greenwave_server(request): + app = greenwave.app_factory.create_app('greenwave.config.TestingConfig') + init_func = lambda: None + server = WSGIServerThread(app, init_func, port=5005) + server.start() + request.addfinalizer(server.stop) + return server + + +@pytest.fixture(scope='session') +def requests_session(request): + s = requests.Session() + request.addfinalizer(s.close) + return s + + +class TestDataBuilder(object): + """ + Test fixture object which has helper methods for setting up test data in + ResultsDB and WaiverDB. + """ + + def __init__(self, requests_session, resultsdb_url, waiverdb_url): + self.requests_session = requests_session + self.resultsdb_url = resultsdb_url + self.waiverdb_url = waiverdb_url + self._counter = itertools.count(1) + + def unique_nvr(self): + return 'glibc-1.0-{}.el7'.format(self._counter.next()) + + def create_result(self, item, testcase_name, outcome): + data = { + 'testcase': {'name': testcase_name}, + 'data': {'item': item}, + 'outcome': outcome, + } + response = self.requests_session.post( + self.resultsdb_url + 'api/v2.0/results', + headers={'Content-Type': 'application/json'}, + data=json.dumps(data)) + response.raise_for_status() + return response.json() + + def create_waiver(self, result_id, product_version, waived=True): + data = { + 'result_id': result_id, + 'product_version': product_version, + 'waived': waived, + } + # We assume WaiverDB is configured with + # AUTH_METHOD = 'dummy' to accept Basic with any credentials. + response = self.requests_session.post( + self.waiverdb_url + 'api/v1.0/waivers/', + auth=('dummy', 'dummy'), + headers={'Content-Type': 'application/json'}, + data=json.dumps(data)) + response.raise_for_status() + return response.json() + + +@pytest.fixture(scope='session') +def testdatabuilder(requests_session, resultsdb_server, waiverdb_server): + return TestDataBuilder(requests_session, resultsdb_server.url, waiverdb_server.url) diff --git a/functional-tests/test_api_v1.py b/functional-tests/test_api_v1.py new file mode 100644 index 0000000..ffc87da --- /dev/null +++ b/functional-tests/test_api_v1.py @@ -0,0 +1,176 @@ +# SPDX-License-Identifier: GPL-2.0+ + +import json + + +all_rpmdiff_testcase_names = [ + # XXX this is not all of them + 'dist.rpmdiff.comparison.xml_validity', + 'dist.rpmdiff.comparison.virus_scan', + 'dist.rpmdiff.comparison.upstream_source', + 'dist.rpmdiff.comparison.symlinks', + 'dist.rpmdiff.comparison.binary_stripping', +] + + +def test_cannot_make_decision_without_product_version(requests_session, greenwave_server): + data = { + 'decision_context': 'errata_newfile_to_qe', + 'subject': ['foo-1.0.0-1.el7'] + } + r = requests_session.post(greenwave_server.url + 'api/v1.0/decision', + headers={'Content-Type': 'application/json'}, + data=json.dumps(data)) + assert r.status_code == 400 + assert u'Missing required product version' in r.text + + +def test_cannot_make_decision_without_decision_context(requests_session, greenwave_server): + data = { + 'product_version': 'rhel-7', + 'subject': ['foo-1.0.0-1.el7'] + } + r = requests_session.post(greenwave_server.url + 'api/v1.0/decision', + headers={'Content-Type': 'application/json'}, + data=json.dumps(data)) + assert r.status_code == 400 + assert u'Missing required decision context' in r.text + + +def test_cannot_make_decision_without_subject(requests_session, greenwave_server): + data = { + 'decision_context': 'errata_newfile_to_qe', + 'product_version': 'rhel-7', + } + r = requests_session.post(greenwave_server.url + 'api/v1.0/decision', + headers={'Content-Type': 'application/json'}, + data=json.dumps(data)) + assert r.status_code == 400 + assert u'Missing required subject' in r.text + + +def test_404_for_inapplicable_policies(requests_session, greenwave_server): + data = { + 'decision_context': 'dummpy_decision', + 'product_version': 'rhel-7', + 'subject': ['foo-1.0.0-1.el7'] + } + r = requests_session.post(greenwave_server.url + 'api/v1.0/decision', + headers={'Content-Type': 'application/json'}, + data=json.dumps(data)) + assert r.status_code == 404 + assert u'Cannot find any applicable policies for rhel-7' in r.text + + +def test_make_a_decison_on_passed_result(requests_session, greenwave_server, testdatabuilder): + nvr = testdatabuilder.unique_nvr() + for testcase_name in all_rpmdiff_testcase_names: + testdatabuilder.create_result(item=nvr, + testcase_name=testcase_name, + outcome='PASSED') + data = { + 'decision_context': 'errata_newfile_to_qe', + 'product_version': 'rhel-7', + 'subject': [nvr] + } + r = requests_session.post(greenwave_server.url + 'api/v1.0/decision', + headers={'Content-Type': 'application/json'}, + data=json.dumps(data)) + assert r.status_code == 200 + res_data = r.json() + assert res_data['policies_satisified'] is True + assert res_data['applicable_policies'] == ['1'] + expected_summary = '{}: policy 1 is satisfied as all required tests are passing'.format(nvr) + assert res_data['summary'] == expected_summary + + +def test_make_a_decison_on_failed_result_with_waiver( + requests_session, greenwave_server, testdatabuilder): + nvr = testdatabuilder.unique_nvr() + # First one failed but was waived + result = testdatabuilder.create_result(item=nvr, + testcase_name=all_rpmdiff_testcase_names[0], + outcome='FAILED') + testdatabuilder.create_waiver(result_id=result['id'], product_version='rhel-7') + # The rest passed + for testcase_name in all_rpmdiff_testcase_names[1:]: + testdatabuilder.create_result(item=nvr, + testcase_name=testcase_name, + outcome='PASSED') + data = { + 'decision_context': 'errata_newfile_to_qe', + 'product_version': 'rhel-7', + 'subject': [nvr] + } + r = requests_session.post(greenwave_server.url + 'api/v1.0/decision', + headers={'Content-Type': 'application/json'}, + data=json.dumps(data)) + assert r.status_code == 200 + res_data = r.json() + assert res_data['policies_satisified'] is True + assert res_data['applicable_policies'] == ['1'] + expected_summary = '{}: policy 1 is satisfied as all required tests are passing'.format(nvr) + assert res_data['summary'] == expected_summary + + +def test_make_a_decison_on_failed_result(requests_session, greenwave_server, testdatabuilder): + nvr = testdatabuilder.unique_nvr() + result = testdatabuilder.create_result(item=nvr, + testcase_name='dist.rpmdiff.comparison.xml_validity', + outcome='FAILED') + data = { + 'decision_context': 'errata_newfile_to_qe', + 'product_version': 'rhel-7', + 'subject': [nvr] + } + r = requests_session.post(greenwave_server.url + 'api/v1.0/decision', + headers={'Content-Type': 'application/json'}, + data=json.dumps(data)) + assert r.status_code == 200 + res_data = r.json() + assert res_data['policies_satisified'] is False + assert res_data['applicable_policies'] == ['1'] + # XXX actually 1 failed and 4 are missing, need to improve this summary + expected_summary = '{}: 5 of 5 required tests failed, the policy 1 is not satisfied'.format(nvr) + assert res_data['summary'] == expected_summary + expected_unsatisfied_requirements = [ + { + 'item': nvr, + 'result_id': result['id'], + 'testcase': 'dist.rpmdiff.comparison.xml_validity', + 'type': 'test-result-failed' + }, + ] + [ + { + 'item': nvr, + 'testcase': name, + 'type': 'test-result-missing' + } for name in all_rpmdiff_testcase_names if name != 'dist.rpmdiff.comparison.xml_validity' + ] + assert res_data['unsatisfied_requirements'] == expected_unsatisfied_requirements + + +def test_make_a_decison_on_no_results(requests_session, greenwave_server, testdatabuilder): + nvr = testdatabuilder.unique_nvr() + data = { + 'decision_context': 'errata_newfile_to_qe', + 'product_version': 'rhel-7', + 'subject': [nvr] + } + r = requests_session.post(greenwave_server.url + 'api/v1.0/decision', + headers={'Content-Type': 'application/json'}, + data=json.dumps(data)) + assert r.status_code == 200 + res_data = r.json() + assert res_data['policies_satisified'] is False + assert res_data['applicable_policies'] == ['1'] + expected_summary = '{}: no test results found'.format(nvr) + assert res_data['summary'] == expected_summary + expected_unsatisfied_requirements = [ + { + 'item': nvr, + 'testcase': name, + 'type': 'test-result-missing' + } for name in all_rpmdiff_testcase_names + ] + assert res_data['unsatisfied_requirements'] == expected_unsatisfied_requirements diff --git a/greenwave/config.py b/greenwave/config.py index b4ddbb8..abcb1f7 100644 --- a/greenwave/config.py +++ b/greenwave/config.py @@ -21,10 +21,12 @@ class ProductionConfig(Config): class DevelopmentConfig(Config): - RESULTSDB_API_URL = 'https://taskotron.stg.fedoraproject.org/resultsdb_api/api/v2.0' - WAIVERDB_API_URL = 'http://waiverdb-dev.fedorainfracloud.org/api/v1.0' + #RESULTSDB_API_URL = 'https://taskotron.stg.fedoraproject.org/resultsdb_api/api/v2.0' + RESULTSDB_API_URL = 'http://localhost:5001/api/v2.0' + #WAIVERDB_API_URL = 'http://waiverdb-dev.fedorainfracloud.org/api/v1.0' + WAIVERDB_API_URL = 'http://localhost:5004/api/v1.0' class TestingConfig(Config): - RESULTSDB_API_URL = 'https://resultsdb.domain.local/api/v2.0' - WAIVERDB_API_URL = 'https://waiverdb.domain.local/api/v1.0' + RESULTSDB_API_URL = 'http://localhost:5001/api/v2.0' + WAIVERDB_API_URL = 'http://localhost:5004/api/v1.0' diff --git a/greenwave/tests/conftest.py b/greenwave/tests/conftest.py deleted file mode 100644 index ce6922b..0000000 --- a/greenwave/tests/conftest.py +++ /dev/null @@ -1,27 +0,0 @@ -# SPDX-License-Identifier: GPL-2.0+ - -import pytest -from greenwave.app_factory import create_app - - -@pytest.fixture(scope='session') -def app(request): - app = create_app('greenwave.config.TestingConfig') - # Establish an application context before running the tests. - ctx = app.app_context() - ctx.push() - - def teardown(): - ctx.pop() - - request.addfinalizer(teardown) - return app - - -@pytest.yield_fixture -def client(app): - """A Flask test client. An instance of :class:`flask.testing.TestClient` - by default. - """ - with app.test_client() as client: - yield client diff --git a/greenwave/tests/test_api_v10.py b/greenwave/tests/test_api_v10.py deleted file mode 100644 index 757047b..0000000 --- a/greenwave/tests/test_api_v10.py +++ /dev/null @@ -1,273 +0,0 @@ -# SPDX-License-Identifier: GPL-2.0+ - -import json -import requests_mock -from flask import current_app - - -all_rpmdiff_testcase_names = [ - # XXX this is not all of them - 'dist.rpmdiff.comparison.xml_validity', - 'dist.rpmdiff.comparison.virus_scan', - 'dist.rpmdiff.comparison.upstream_source', - 'dist.rpmdiff.comparison.symlinks', - 'dist.rpmdiff.comparison.binary_stripping', -] - - -def test_cannot_make_decision_without_product_version(client): - data = { - 'decision_context': 'errata_newfile_to_qe', - 'subject': ['foo-1.0.0-1.el7'] - } - r = client.post('/api/v1.0/decision', data=json.dumps(data), - content_type='application/json') - assert r.status_code == 400 - assert u'Missing required product version' in r.get_data(as_text=True) - - -def test_cannot_make_decision_without_decision_context(client): - data = { - 'product_version': 'rhel-7', - 'subject': ['foo-1.0.0-1.el7'] - } - r = client.post('/api/v1.0/decision', data=json.dumps(data), - content_type='application/json') - assert r.status_code == 400 - assert u'Missing required decision context' in r.get_data(as_text=True) - - -def test_cannot_make_decision_without_subject(client): - data = { - 'decision_context': 'errata_newfile_to_qe', - 'product_version': 'rhel-7', - } - r = client.post('/api/v1.0/decision', data=json.dumps(data), - content_type='application/json') - assert r.status_code == 400 - assert u'Missing required subject' in r.get_data(as_text=True) - - -def test_404_for_inapplicable_policies(client): - data = { - 'decision_context': 'dummpy_decision', - 'product_version': 'rhel-7', - 'subject': ['foo-1.0.0-1.el7'] - } - r = client.post('/api/v1.0/decision', data=json.dumps(data), - content_type='application/json') - assert r.status_code == 404 - assert u'Cannot find any applicable policies for rhel-7' in r.get_data(as_text=True) - - -def test_make_a_decison_on_passed_result(client): - with requests_mock.Mocker() as m: - mocked_results = { - "data": [ - { - "data": { - "item": [ - "foo-1.0.0-2.el7" - ] - }, - "groups": [ - "5d307e4f-1ade-4c41-9e67-e5a73d5cdd07" - ], - "href": "https://resultsdb.domain.local/api/v2.0/results/331284", - "id": id, - "note": "", - "outcome": "PASSED", - "ref_url": "https://rpmdiff.domain.local/run/97683/26", - "submit_time": "2017-05-19T04:41:13.957729", - "testcase": { - "href": 'https://resultsdb.domain.local/api/v2.0/testcases/' + name, - "name": name, - "ref_url": "https://docs.domain.local/display/HTD/rpmdiff-valid-file" - } - } for id, name in enumerate(all_rpmdiff_testcase_names, 1) - ] - } - m.register_uri('GET', '{}/results?item={}&testcases={}'.format( - current_app.config['RESULTSDB_API_URL'], - 'foo-1.0.0-2.el7', - ','.join(all_rpmdiff_testcase_names) - ), json=mocked_results) - data = { - 'decision_context': 'errata_newfile_to_qe', - 'product_version': 'rhel-7', - 'subject': ['foo-1.0.0-2.el7'] - } - r = client.post('/api/v1.0/decision', data=json.dumps(data), - content_type='application/json') - assert r.status_code == 200 - res_data = json.loads(r.get_data(as_text=True)) - assert res_data['policies_satisified'] is True - assert res_data['applicable_policies'] == ['1'] - assert res_data['summary'] == 'foo-1.0.0-2.el7: policy 1 is satisfied as all required' \ - ' tests are passing' - - -def test_make_a_decison_on_failed_result_with_waiver(client): - with requests_mock.Mocker() as m: - mocked_results = { - "data": [ - { - "data": { - "item": [ - "foo-1.0.0-2.el7" - ] - }, - "groups": [ - "5d307e4f-1ade-4c41-9e67-e5a73d5cdd07" - ], - "href": "https://resultsdb.domain.local/api/v2.0/results/331284", - "id": id, - "note": "", - "outcome": "PASSED", - "ref_url": "https://rpmdiff.domain.local/run/97683/26", - "submit_time": "2017-05-19T04:41:13.957729", - "testcase": { - "href": 'https://resultsdb.domain.local/api/v2.0/testcases/' + name, - "name": name, - "ref_url": "https://docs.domain.local/display/HTD/rpmdiff-valid-file" - } - } for id, name in enumerate(all_rpmdiff_testcase_names, 1) - ] - } - mocked_results['data'][0]['id'] = 331284 - mocked_results['data'][0]['outcome'] = 'FAILED' - m.register_uri('GET', '{}/results?item={}&testcases={}'.format( - current_app.config['RESULTSDB_API_URL'], - 'foo-1.0.0-2.el7', - ','.join(all_rpmdiff_testcase_names) - ), json=mocked_results) - mocked_waiver = { - "data": [ - { - "id": 1, - "result_id": 331284, - "username": 'fool', - "comment": 'it broke', - "waived": True, - "timestamp": '2017-05-17T03:13:31.735858', - "product_version": 'rhel-7' - } - ] - } - m.register_uri('GET', '{}/waivers/?result_id={}&product_version={}'.format( - current_app.config['WAIVERDB_API_URL'], - 331284, - 'rhel-7' - ), json=mocked_waiver) - data = { - 'decision_context': 'errata_newfile_to_qe', - 'product_version': 'rhel-7', - 'subject': ['foo-1.0.0-2.el7'] - } - r = client.post('/api/v1.0/decision', data=json.dumps(data), - content_type='application/json') - assert r.status_code == 200 - res_data = json.loads(r.get_data(as_text=True)) - assert res_data['policies_satisified'] is True - assert res_data['applicable_policies'] == ['1'] - assert res_data['summary'] == 'foo-1.0.0-2.el7: policy 1 is satisfied as all required' \ - ' tests are passing' - - -def test_make_a_decison_on_failed_result(client): - with requests_mock.Mocker() as m: - mocked_results = { - "data": [ - { - "data": { - "item": [ - "foo-1.0.0-2.el7" - ] - }, - "groups": [ - "5d307e4f-1ade-4c41-9e67-e5a73d5cdd07" - ], - "href": "https://resultsdb.domain.local/api/v2.0/results/331284", - "id": 331284, - "note": "", - "outcome": "FAILED", - "ref_url": "https://rpmdiff.domain.local/run/97683/26", - "submit_time": "2017-05-19T04:41:13.957729", - "testcase": { - "href": 'https://resultsdb.domain.local/api/v2.0/testcases/' - 'dist.rpmdiff.comparison.xml_validity', - "name": "dist.rpmdiff.comparison.xml_validity", - "ref_url": "https://docs.domain.local/display/HTD/rpmdiff-valid-file" - } - } - ] - } - m.register_uri('GET', '{}/results?item={}&testcases={}'.format( - current_app.config['RESULTSDB_API_URL'], - 'foo-1.0.0-2.el7', - ','.join(all_rpmdiff_testcase_names) - ), json=mocked_results) - m.register_uri('GET', '{}/waivers/?result_id={}&product_version={}'.format( - current_app.config['WAIVERDB_API_URL'], - 331284, - 'rhel-7' - ), json={"data": []}) - data = { - 'decision_context': 'errata_newfile_to_qe', - 'product_version': 'rhel-7', - 'subject': ['foo-1.0.0-2.el7'] - } - r = client.post('/api/v1.0/decision', data=json.dumps(data), - content_type='application/json') - assert r.status_code == 200 - res_data = json.loads(r.get_data(as_text=True)) - assert res_data['policies_satisified'] is False - assert res_data['applicable_policies'] == ['1'] - # XXX actually 1 failed and 4 are missing, need to improve this summary - assert res_data['summary'] == 'foo-1.0.0-2.el7: 5 of 5 required tests' \ - ' failed, the policy 1 is not satisfied' - expected_unsatisfied_requirements = [ - { - 'item': 'foo-1.0.0-2.el7', - 'result_id': 331284, - 'testcase': 'dist.rpmdiff.comparison.xml_validity', - 'type': 'test-result-failed' - }, - ] + [ - { - 'item': 'foo-1.0.0-2.el7', - 'testcase': name, - 'type': 'test-result-missing' - } for name in all_rpmdiff_testcase_names - if name != 'dist.rpmdiff.comparison.xml_validity' - ] - assert res_data['unsatisfied_requirements'] == expected_unsatisfied_requirements - - -def test_make_a_decison_on_no_results(client): - with requests_mock.Mocker() as m: - m.register_uri('GET', '{}/results?item={}&testcases={}'.format( - current_app.config['RESULTSDB_API_URL'], - 'foo-1.0.0-2.el7', - ','.join(all_rpmdiff_testcase_names) - ), json={"data": []}) - data = { - 'decision_context': 'errata_newfile_to_qe', - 'product_version': 'rhel-7', - 'subject': ['foo-1.0.0-2.el7'] - } - r = client.post('/api/v1.0/decision', data=json.dumps(data), - content_type='application/json') - assert r.status_code == 200 - res_data = json.loads(r.get_data(as_text=True)) - assert res_data['policies_satisified'] is False - assert res_data['applicable_policies'] == ['1'] - assert res_data['summary'] == 'foo-1.0.0-2.el7: no test results found' - expected_unsatisfied_requirements = [ - { - 'item': 'foo-1.0.0-2.el7', - 'testcase': name, - 'type': 'test-result-missing' - } for name in all_rpmdiff_testcase_names - ] - assert res_data['unsatisfied_requirements'] == expected_unsatisfied_requirements diff --git a/greenwave/tests/test_policies.py b/greenwave/tests/test_policies.py new file mode 100644 index 0000000..fe9e18d --- /dev/null +++ b/greenwave/tests/test_policies.py @@ -0,0 +1,7 @@ + +# SPDX-License-Identifier: GPL-2.0+ + + +# This is just a placeholder for where unit tests could go. +def test_it(): + pass diff --git a/tox.ini b/tox.ini index 6079f6a..3351dd3 100644 --- a/tox.ini +++ b/tox.ini @@ -34,3 +34,5 @@ commands = show-source = True max-line-length = 100 exclude = .git,.tox,dist,*egg +# E265 block comment should start with '# ' +ignore = E265